diff --git a/CHANGELOG.md b/CHANGELOG.md index ecdef990..6cf60baf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,8 @@ ## v1.8.22-alpha (2026-09-30) +- Fixed Nginx Proxy Manager launch readiness choosing a proxy listener instead of its admin port after container recreation. + - Network diagnostic failures no longer stop all apps or rebuild shared container networking. - Prevented orphaned companion dashboards from repeatedly reinstalling themselves after their backend app was removed. - Fixed companion dashboard builds still referencing a retired image registry. diff --git a/core/archipelago/src/container/docker_packages.rs b/core/archipelago/src/container/docker_packages.rs index 7422c6d1..0d318c04 100644 --- a/core/archipelago/src/container/docker_packages.rs +++ b/core/archipelago/src/container/docker_packages.rs @@ -978,14 +978,20 @@ fn extract_lan_address(ports: &[String]) -> Option { let mut first_candidate = None; for port_str in ports { // Parse port strings like "0.0.0.0:18443->18443/tcp" or "0.0.0.0:18443-18444->18443-18444/tcp" - let Some(public_part) = port_str.split("->").next() else { + let Some((public_part, _)) = port_str.split_once("->") else { continue; }; - let Some(port_part) = public_part.split(':').nth(1) else { + let Some((_, port_part)) = public_part.rsplit_once(':') else { continue; }; // Extract just the first port if it's a range (e.g., "18443-18444" -> "18443") let host_port = port_part.split('-').next().unwrap_or(port_part); + let Ok(host_port) = host_port.parse::() else { + continue; + }; + if host_port == 0 { + continue; + } let candidate = format!("http://localhost:{}", host_port); if first_candidate.is_none() { first_candidate = Some(candidate.clone()); @@ -1113,6 +1119,29 @@ fn package_launch_candidate( if let Some(companion) = companion_lan_address(app_id) { return Some(companion); } + if app_id == "nginx-proxy-manager" { + // 80/443 serve users' proxy hosts; only container port 81 serves the + // admin UI. Podman's binding order is unstable across recreation. + // Resolve its actual host allocation rather than guessing the first + // HTTP port or hardcoding the default host port 8081. + let admin_ports: Vec = ports + .iter() + .filter(|port| { + port.split_once("->") + .is_some_and(|(_, target)| target == "81/tcp") + }) + .cloned() + .collect(); + // With published bindings, a missing admin mapping is not evidence + // that some unrelated service on the default host port is this UI. + return extract_lan_address(&admin_ports).or_else(|| { + if ports.is_empty() { + known + } else { + None + } + }); + } if uses_allocated_launch_port(app_id) { extract_lan_address(ports).or(known) } else { @@ -1241,6 +1270,98 @@ mod extract_lan_address_tests { ); } + #[test] + fn npm_admin_launch_is_independent_of_proxy_binding_order() { + let mappings = [ + "10.77.0.2:18081->80/tcp", + "10.77.0.2:18443->443/tcp", + "127.0.0.1:8081->81/tcp", + ]; + for order in [ + [0, 1, 2], + [0, 2, 1], + [1, 0, 2], + [1, 2, 0], + [2, 0, 1], + [2, 1, 0], + ] { + let ports: Vec = order.iter().map(|&i| mappings[i].into()).collect(); + assert_eq!( + package_launch_candidate( + "nginx-proxy-manager", + &ports, + Some("http://localhost:8081/".into()) + ) + .as_deref(), + Some("http://localhost:8081") + ); + } + } + + #[test] + fn npm_admin_launch_respects_host_allocation_and_ipv6_bindings() { + for binding in ["127.0.0.1", "0.0.0.0", "[::1]", "[::]"] { + let ports = vec![ + "10.77.0.2:18081->80/tcp".into(), + format!("{binding}:28081->81/tcp"), + ]; + assert_eq!( + package_launch_candidate( + "nginx-proxy-manager", + &ports, + Some("http://localhost:8081/".into()) + ) + .as_deref(), + Some("http://localhost:28081") + ); + } + let proxies = vec![ + "10.77.0.2:18081->80/tcp".into(), + "10.77.0.2:18443->443/tcp".into(), + ]; + assert_eq!( + package_launch_candidate("nginx-proxy-manager", &proxies, None), + None + ); + assert_eq!( + package_launch_candidate( + "nginx-proxy-manager", + &proxies, + Some("http://localhost:8081/".into()) + ), + None + ); + } + + #[test] + fn npm_without_port_information_uses_declared_admin_url() { + assert_eq!( + package_launch_candidate( + "nginx-proxy-manager", + &[], + Some("http://localhost:8081/".into()) + ) + .as_deref(), + Some("http://localhost:8081/") + ); + } + + #[test] + fn malformed_published_ports_do_not_become_launch_urls() { + for port in [ + "81/tcp", + "127.0.0.1:bad->81/tcp", + "[::1]:0->81/tcp", + "[::]:65536->81/tcp", + "127.0.0.1:8081->81/udp", + ] { + assert_eq!( + package_launch_candidate("nginx-proxy-manager", &[port.into()], None), + None + ); + } + } + #[test] fn skips_ssh_port_when_web_port_is_published() { // gitea: SSH published before the web port, in podman's list order. diff --git a/docs/next-release-20260930.md b/docs/next-release-20260930.md index 7309281e..0c2a60f9 100644 --- a/docs/next-release-20260930.md +++ b/docs/next-release-20260930.md @@ -398,3 +398,35 @@ the old recovery code. A regression executes the actual installer block against stale disposable files twice and confirms a missing safety payload fails closed. The mounted-ISO smoke test also compares all three overlay files to source. The final ISO build captures the exact newly deployed OTA UI/runtime payload. + +### Final kiosk acceptance found nondeterministic NPM launch selection + +Do not publish the staged `d1bc1273` candidate. NPM itself remains healthy and its +API, Portainer integration, site, and native services passed stability checks. +However, final kiosk acceptance found its card stuck at "Web UI not ready". +The runtime reported bindings in proxy-HTTP, proxy-HTTPS, admin order. The scanner +chose the first non-database/SSH binding, then rejected its tunnel-only host port +as unreachable on loopback, leaving the launch address empty. Earlier tests had +passed with admin first. This is a confirmed order-dependent scanner defect. + +The candidate fix explicitly resolves NPM container port 81 to its actual host +allocation. Proxy ports never become the admin URL. Missing/malformed admin +bindings do not fall back to another service when published bindings are present. +Port parsing handles IPv6 authorities and rejects invalid ports. Regressions +cover all six three-port permutations, allocated admin ports, IPv4/IPv6 binding +strings, missing admin mappings, missing runtime port information, and malformed +or UDP bindings. Full backend regression execution is pending for this change. +The ISO build is frozen at installer-environment creation; no release was signed +or published. Rebuild/revalidate the OTA and ISO with this correction. + +The companion orphan fix worked live: Cuprate UI was automatically removed and +all installed app container IDs remained unchanged. One observation helper raced +that expected removal between `podman ps` and `inspect`; it now excludes that +known orphan before inspection and repeats the stability check. This was a test +snapshot race, not another installed-app restart. + +NPM selector final backend gate passed: 1,617 tests, zero failures, four explicitly +ignored, through the isolated runner. This includes all new port-selection cases +and the existing companion security/configuration and lifecycle regressions. +Rebuild the release binary and UI metadata, deploy those exact OTA bytes to both +boxes, and require actual kiosk hard-refresh/Launch acceptance before ISO assembly. diff --git a/neode-ui/src/views/settings/AccountInfoSection.vue b/neode-ui/src/views/settings/AccountInfoSection.vue index 41cc77fe..beb78ebe 100644 --- a/neode-ui/src/views/settings/AccountInfoSection.vue +++ b/neode-ui/src/views/settings/AccountInfoSection.vue @@ -369,6 +369,7 @@ init() September 30, 2026
+

Fixed Nginx Proxy Manager launch readiness choosing a proxy listener instead of its admin port after container recreation.

Network diagnostic failures no longer stop all apps or rebuild shared container networking.

Prevented orphaned companion dashboards from repeatedly reinstalling themselves after their backend app was removed.

Fixed companion dashboard builds still referencing a retired image registry.