chore: release v1.7.57-alpha

This commit is contained in:
archipelago 2026-05-17 17:30:04 -04:00
parent a322b04021
commit 7804223152
37 changed files with 382 additions and 119 deletions

View File

@ -1,5 +1,13 @@
# Changelog
## v1.7.57-alpha (2026-05-17)
- Nginx Proxy Manager now avoids privileged rootless Podman host port `81`, preferring `8081:81` while host nginx keeps a compatibility proxy on `:81` for stale cached launch buttons.
- App installs now allocate ports by checking live host bind availability, falling back to a free high port when preferred ports are already occupied.
- Portainer-created launchable containers are separated into a `Websites` tab and launch through their discovered published host port instead of hard-coded app URLs.
- Internal BuildKit helper containers such as `buildx_buildkit_default` are hidden from the Apps UI.
- Portainer works out of the box on Debian 13/Podman installs by including `catatonit` and by preserving the Podman socket mount as a socket rather than creating it as a directory.
## v1.7.56-alpha (2026-05-15)
- Health notifications now clear when an app is no longer unhealthy, including stale alerts for removed containers such as Portainer.

2
core/Cargo.lock generated
View File

@ -80,7 +80,7 @@ checksum = "a23eb6b1614318a8071c9b2521f36b424b2c83db5eb3a0fead4a6c0809af6e61"
[[package]]
name = "archipelago"
version = "1.7.56-alpha"
version = "1.7.57-alpha"
dependencies = [
"anyhow",
"archipelago-container",

View File

@ -1,6 +1,6 @@
[package]
name = "archipelago"
version = "1.7.56-alpha"
version = "1.7.57-alpha"
edition = "2021"
description = "Archipelago Bitcoin Node OS - Native backend"
authors = ["Archipelago Team"]

View File

@ -153,11 +153,13 @@ pub(super) fn get_app_capabilities(app_id: &str) -> Vec<String> {
"--cap-add=SETGID".to_string(),
"--cap-add=DAC_OVERRIDE".to_string(),
],
// Nginx Proxy Manager needs to bind low ports
// Nginx Proxy Manager initializes/chowns mounted state on first boot.
"nginx-proxy-manager" => vec![
"--cap-add=CHOWN".to_string(),
"--cap-add=FOWNER".to_string(),
"--cap-add=SETUID".to_string(),
"--cap-add=SETGID".to_string(),
"--cap-add=DAC_OVERRIDE".to_string(),
"--cap-add=NET_BIND_SERVICE".to_string(),
],
// Bitcoin needs only file-ownership ops + NET_BIND_SERVICE for the
@ -924,20 +926,34 @@ pub(super) async fn get_app_config(
]),
)
}
"nginx-proxy-manager" => (
vec![
"81:81".to_string(),
"8084:80".to_string(),
"8444:443".to_string(),
],
vec![
"/var/lib/archipelago/nginx-proxy-manager/data:/data".to_string(),
"/var/lib/archipelago/nginx-proxy-manager/letsencrypt:/etc/letsencrypt".to_string(),
],
vec![],
None,
None,
),
"nginx-proxy-manager" => {
let admin_port = allocator
.allocate_or_get(app_id, 8081, 81)
.await
.unwrap_or(8081);
let http_port = allocator
.allocate_or_get("nginx-proxy-manager-http", 8084, 80)
.await
.unwrap_or(8084);
let https_port = allocator
.allocate_or_get("nginx-proxy-manager-https", 8444, 443)
.await
.unwrap_or(8444);
(
vec![
format!("{}:81", admin_port),
format!("{}:80", http_port),
format!("{}:443", https_port),
],
vec![
"/var/lib/archipelago/nginx-proxy-manager/data:/data".to_string(),
"/var/lib/archipelago/nginx-proxy-manager/letsencrypt:/etc/letsencrypt".to_string(),
],
vec![],
None,
None,
)
}
"portainer" => (
vec!["9000:9000".to_string()],
vec![

View File

@ -376,7 +376,7 @@ impl RpcHandler {
package_id
))
.await;
if let Err(e) = ensure_host_port_listener(package_id, package_id).await {
if let Err(e) = ensure_host_port_listener(package_id, package_id, &[]).await {
install_log(&format!(
"INSTALL ADOPT RECREATE: {} — host listener repair failed, removing wedged container: {}",
package_id, e
@ -402,7 +402,7 @@ impl RpcHandler {
package_id
))
.await;
if let Err(e) = ensure_host_port_listener(package_id, package_id).await {
if let Err(e) = ensure_host_port_listener(package_id, package_id, &[]).await {
install_log(&format!(
"INSTALL ADOPT RECREATE: {} — host listener repair failed, removing wedged container: {}",
package_id, e
@ -880,7 +880,7 @@ impl RpcHandler {
repair_nextcloud_permissions().await;
}
ensure_host_port_listener(package_id, container_name).await?;
ensure_host_port_listener(package_id, container_name, &ports).await?;
install_log(&format!(
"INSTALL OK: {} (container: {})",
@ -1866,7 +1866,7 @@ async fn cleanup_stale_package_ports(package_id: &str) {
cleanup_stale_pasta_port("3000").await;
}
"nginx-proxy-manager" => {
cleanup_stale_pasta_port("81").await;
cleanup_stale_pasta_port("8081").await;
cleanup_stale_pasta_port("8084").await;
cleanup_stale_pasta_port("8444").await;
}
@ -1914,7 +1914,7 @@ async fn cleanup_start_conflict(package_id: &str, stderr: &str) -> bool {
"nginx-proxy-manager"
if stderr.contains("pasta failed") || stderr.contains("address already in use") =>
{
cleanup_stale_pasta_port("81").await;
cleanup_stale_pasta_port("8081").await;
cleanup_stale_pasta_port("8084").await;
cleanup_stale_pasta_port("8444").await;
true
@ -1968,8 +1968,18 @@ async fn repair_nextcloud_permissions() {
}
}
async fn ensure_host_port_listener(package_id: &str, container_name: &str) -> Result<()> {
let Some(port) = required_host_port(package_id) else {
async fn ensure_host_port_listener(
package_id: &str,
container_name: &str,
runtime_ports: &[String],
) -> Result<()> {
let Some(port) = runtime_ports
.first()
.and_then(|p| p.split(':').next())
.and_then(|p| p.parse::<u16>().ok())
.or_else(|| published_host_port(container_name))
.or_else(|| required_host_port(package_id))
else {
return Ok(());
};
@ -2015,6 +2025,22 @@ async fn ensure_host_port_listener(package_id: &str, container_name: &str) -> Re
))
}
fn published_host_port(container_name: &str) -> Option<u16> {
let output = std::process::Command::new("podman")
.args(["port", container_name])
.output()
.ok()?;
if !output.status.success() {
return None;
}
let stdout = String::from_utf8_lossy(&output.stdout);
stdout.lines().find_map(|line| {
line.rsplit(':')
.next()
.and_then(|p| p.trim().parse::<u16>().ok())
})
}
async fn ensure_user_podman_socket() -> Result<()> {
let socket_path = "/run/user/1000/podman/podman.sock";
if tokio::fs::try_exists(socket_path).await.unwrap_or(false) {
@ -2048,7 +2074,7 @@ fn required_host_port(package_id: &str) -> Option<u16> {
"uptime-kuma" => Some(3002),
"gitea" => Some(3001),
"nextcloud" => Some(8085),
"nginx-proxy-manager" => Some(81),
"nginx-proxy-manager" => Some(8081),
_ => None,
}
}

View File

@ -366,6 +366,10 @@ impl RpcHandler {
{
let mut allocator = self.port_allocator.lock().await;
let _ = allocator.release(package_id).await;
if package_id == "nginx-proxy-manager" {
let _ = allocator.release("nginx-proxy-manager-http").await;
let _ = allocator.release("nginx-proxy-manager-https").await;
}
}
// Clean data directories unless preserve_data
@ -916,7 +920,7 @@ fn runtime_required_host_port(container_name: &str) -> Option<u16> {
"vaultwarden" => Some(8082),
"gitea" => Some(3001),
"nextcloud" => Some(8085),
"nginx-proxy-manager" => Some(81),
"nginx-proxy-manager" => Some(8081),
_ => None,
}
}
@ -1072,7 +1076,7 @@ async fn cleanup_start_conflict(container_name: &str, stderr: &str) {
"vaultwarden" => cleanup_stale_pasta_port("8082").await,
"nextcloud" => cleanup_stale_pasta_port("8085").await,
"nginx-proxy-manager" => {
cleanup_stale_pasta_port("81").await;
cleanup_stale_pasta_port("8081").await;
cleanup_stale_pasta_port("8084").await;
cleanup_stale_pasta_port("8444").await;
}

View File

@ -353,7 +353,7 @@ pub(in crate::api::rpc) fn known_service_port(name: &str) -> u16 {
"immich" => 2283,
"photoprism" => 2342,
"penpot" => 9001,
"nginx-proxy-manager" => 81,
"nginx-proxy-manager" => 8081,
"vaultwarden" => 8343,
"indeedhub" => 7778,
_ => 0,

View File

@ -61,6 +61,7 @@ impl DockerPackageScanner {
"indeedhub-build_minio-init_1",
"indeedhub-build_relay_1",
"indeedhub-build_ffmpeg-worker_1",
"buildx_buildkit_default",
];
// First pass: collect running UI containers. Custom UI-backed apps must
@ -123,6 +124,11 @@ impl DockerPackageScanner {
continue;
}
if app_id.starts_with("buildx_buildkit") {
debug!("Skipping BuildKit helper container: {}", app_id);
continue;
}
// Skip UI containers (they're merged with their parent apps)
if app_id.ends_with("-ui") {
debug!("Skipping UI container: {}", app_id);
@ -140,8 +146,13 @@ impl DockerPackageScanner {
} else {
// Prefer the known web UI port over arbitrary first binding
// (for example Gitea exposes SSH on 2222 before web on 3001).
let candidate = PodmanClient::lan_address_for(&app_id)
.or_else(|| extract_lan_address(&container.ports));
let candidate = if uses_allocated_launch_port(&app_id) {
extract_lan_address(&container.ports)
.or_else(|| PodmanClient::lan_address_for(&app_id))
} else {
PodmanClient::lan_address_for(&app_id)
.or_else(|| extract_lan_address(&container.ports))
};
reachable_lan_address(&app_id, candidate).await
};
@ -673,6 +684,13 @@ fn companion_lan_address(app_id: &str) -> Option<String> {
}
}
fn uses_allocated_launch_port(app_id: &str) -> bool {
matches!(
app_id,
"filebrowser" | "nextcloud" | "nginx-proxy-manager" | "vaultwarden"
)
}
fn convert_state(container_state: &ContainerState) -> (PackageState, ServiceStatus) {
match container_state {
ContainerState::Running => (PackageState::Running, ServiceStatus::Running),

View File

@ -28,7 +28,6 @@ const RESERVED_PORTS: &[u16] = &[
8888, // SearXNG
8096, 2342, 2283, // Jellyfin, Photoprism, Immich
8443, // FIPS TCP fallback
8444, 8084, // NPM
];
/// Start of range for allocating web app ports when preferred is taken.
@ -94,8 +93,12 @@ impl PortAllocator {
.any(|m| m.host_port == port)
}
fn can_bind(port: u16) -> bool {
std::net::TcpListener::bind(("0.0.0.0", port)).is_ok()
}
fn is_available(&self, port: u16) -> bool {
!self.is_reserved(port) && !self.is_allocated(port)
!self.is_reserved(port) && !self.is_allocated(port) && Self::can_bind(port)
}
/// Allocate a host port for an app. Uses preferred_port if available, else finds next free.

View File

@ -127,7 +127,7 @@ impl PodmanClient {
"photoprism" => "http://localhost:2342",
"immich_server" | "immich" => "http://localhost:2283",
"filebrowser" => "http://localhost:8083",
"nginx-proxy-manager" => "http://localhost:81",
"nginx-proxy-manager" => "http://localhost:8081",
"portainer" => "http://localhost:9000",
"uptime-kuma" => "http://localhost:3002",
"fedimint" | "fedimintd" => "http://localhost:8175",

View File

@ -324,6 +324,7 @@ RUN apt-get update && apt-get -y full-upgrade && apt-get install -y --no-install
openssh-server \
nginx \
podman \
catatonit \
uidmap \
slirp4netns \
passt \

View File

@ -183,7 +183,7 @@ chroot /mnt/archipelago apt-get install -y \
chrony
echo "📦 Installing container tools..."
chroot /mnt/archipelago apt-get install -y podman || echo "⚠️ Podman not available in base repos, will use containers.io later"
chroot /mnt/archipelago apt-get install -y podman catatonit || echo "⚠️ Podman/catatonit not available in base repos, will use containers.io later"
echo "🔧 Installing GRUB bootloader..."
# Need to run grub-install inside chroot with proper environment

View File

@ -741,7 +741,7 @@ server {
sub_filter '</head>' '<script src="/nostr-provider.js"></script></head>';
}
location /app/nginx-proxy-manager/ {
proxy_pass http://127.0.0.1:81/;
proxy_pass http://127.0.0.1:8081/;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
@ -873,6 +873,23 @@ server {
}
}
# Compatibility proxy for cached PWA bundles that still launch Nginx Proxy
# Manager on :81. Rootless Podman cannot bind host ports below 1024, so the
# container admin UI runs on :8081 and host nginx owns the old :81 entrypoint.
server {
listen 81;
server_name _;
location / {
proxy_pass http://127.0.0.1:8081/;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
}
# HTTPS - required for PWA install (Add to Home Screen) from dev servers
server {
listen 443 ssl;

View File

@ -358,7 +358,7 @@ location /app/indeedhub/ {
sub_filter '</head>' '<script src="/nostr-provider.js"></script></head>';
}
location /app/nginx-proxy-manager/ {
proxy_pass http://127.0.0.1:81/;
proxy_pass http://127.0.0.1:8081/;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;

View File

@ -1,12 +1,12 @@
{
"name": "neode-ui",
"version": "1.7.56-alpha",
"version": "1.7.57-alpha",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "neode-ui",
"version": "1.7.56-alpha",
"version": "1.7.57-alpha",
"dependencies": {
"@types/dompurify": "^3.0.5",
"@vue-leaflet/vue-leaflet": "^0.10.1",

View File

@ -1,7 +1,7 @@
{
"name": "neode-ui",
"private": true,
"version": "1.7.56-alpha",
"version": "1.7.57-alpha",
"type": "module",
"scripts": {
"start": "./start-dev.sh",

View File

@ -98,7 +98,7 @@ describe('useAppLauncherStore', () => {
expect(store.panelAppId).toBe('btcpay-server')
})
it('opens Nginx Proxy Manager in new tab even when URL resolves', () => {
it('normalizes old Nginx Proxy Manager port 81 to 8081', () => {
const store = useAppLauncherStore()
store.open({ url: 'http://192.168.1.228:81', title: 'Nginx Proxy Manager' })
@ -106,7 +106,7 @@ describe('useAppLauncherStore', () => {
expect(store.isOpen).toBe(false)
expect(store.panelAppId).toBe(null)
expect(mockWindowOpen).toHaveBeenCalledWith(
'http://192.168.1.228:81',
'http://192.168.1.228:8081',
'_blank',
'noopener,noreferrer',
)
@ -125,13 +125,13 @@ describe('useAppLauncherStore', () => {
expect(store.panelAppId).toBe(null)
})
it('normalizes legacy Nginx Proxy Manager port 8181 to 81', () => {
it('normalizes legacy Nginx Proxy Manager ports to 8081', () => {
const store = useAppLauncherStore()
store.open({ url: 'http://192.168.1.228:8181', title: 'Nginx Proxy Manager' })
expect(mockWindowOpen).toHaveBeenCalledWith(
'http://192.168.1.228:81',
'http://192.168.1.228:8081',
'_blank',
'noopener,noreferrer',
)

View File

@ -61,8 +61,8 @@ function normalizeLaunchUrl(urlStr: string, appIdHint?: string | null): string {
return rebuilt('3002')
}
if (sameHost && appIdHint === 'nginx-proxy-manager' && u.port === '8181') {
return rebuilt('81')
if (sameHost && appIdHint === 'nginx-proxy-manager' && (u.port === '81' || u.port === '8181')) {
return rebuilt('8081')
}
return rewrittenLocalhost ? u.toString() : urlStr
@ -74,6 +74,7 @@ function normalizeLaunchUrl(urlStr: string, appIdHint?: string | null): string {
/** Port → app ID for resolving URLs to AppSession routes */
const PORT_TO_APP_ID: Record<string, string> = {
'81': 'nginx-proxy-manager',
'8081': 'nginx-proxy-manager',
'8181': 'nginx-proxy-manager',
'3000': 'grafana',
'3002': 'uptime-kuma',

View File

@ -141,6 +141,7 @@ import AppHeroSection from './appDetails/AppHeroSection.vue'
import AppContentSection from './appDetails/AppContentSection.vue'
import AppSidebar from './appDetails/AppSidebar.vue'
import { resolveAppUrl } from './appSession/appSessionConfig'
import { isWebsitePackage, resolveRuntimeLaunchUrl } from './apps/appsConfig'
import {
WEB_ONLY_APP_URLS,
PACKAGE_ALIASES,
@ -294,6 +295,18 @@ function launchApp() {
return
}
if (isWebsitePackage(id, pkg.value)) {
const url = resolveRuntimeLaunchUrl(pkg.value)
if (url) window.open(url, '_blank', 'noopener,noreferrer')
return
}
const runtimeUrl = resolveRuntimeLaunchUrl(pkg.value)
if (runtimeUrl) {
useAppLauncherStore().open({ url: runtimeUrl, title: pkg.value.manifest.title })
return
}
// Container apps should launch through session routing so protocol/path
// handling stays centralized in appSessionConfig.
useAppLauncherStore().openSession(id)

View File

@ -7,7 +7,7 @@
<div class="mode-switcher flex-shrink-0">
<button class="mode-switcher-btn" :class="{ 'mode-switcher-btn-active': activeTab === 'apps' }" @click="activeTab = 'apps'; router.replace({ query: {} })">My Apps</button>
<RouterLink to="/dashboard/discover" class="mode-switcher-btn">App Store</RouterLink>
<button class="mode-switcher-btn" :class="{ 'mode-switcher-btn-active': activeTab === 'services' }" @click="activeTab = 'services'; router.replace({ query: { tab: 'services' } })">Services</button>
<button class="mode-switcher-btn" :class="{ 'mode-switcher-btn-active': activeTab === 'websites' }" @click="activeTab = 'websites'; router.replace({ query: { tab: 'websites' } })">Websites</button>
</div>
<div v-if="activeTab === 'apps' && categoriesWithApps.length > 1" class="mode-switcher flex-shrink-0">
<button
@ -155,7 +155,7 @@ import AppIconGrid from './apps/AppIconGrid.vue'
import AppsUninstallModal from './apps/AppsUninstallModal.vue'
import { useAppsActions } from './apps/useAppsActions'
import {
filterEntriesForTab, isWebOnlyApp,
type AppsTab, filterEntriesForTab, isWebOnlyApp, isWebsitePackage, opensInTab, resolveRuntimeLaunchUrl,
WEB_ONLY_APPS, buildAllCategories, useCategoriesWithApps,
} from './apps/appsConfig'
@ -170,8 +170,8 @@ const actions = useAppsActions()
const showStagger = !appsAnimationDone
// Tabs
const activeTab = ref<'apps' | 'services'>(
route.query.tab === 'services' ? 'services' : 'apps'
const activeTab = ref<AppsTab>(
route.query.tab === 'websites' || route.query.tab === 'services' ? 'websites' : 'apps'
)
// Search (debounced)
@ -285,6 +285,19 @@ function goToApp(id: string) {
}
function launchApp(id: string) {
const pkg = packages.value[id]
if (pkg && isWebsitePackage(id, pkg)) {
const url = resolveRuntimeLaunchUrl(pkg)
if (url) window.open(url, '_blank', 'noopener,noreferrer')
return
}
if (pkg && opensInTab(id)) {
const url = resolveRuntimeLaunchUrl(pkg)
if (url) {
window.open(url, '_blank', 'noopener,noreferrer')
return
}
}
useAppLauncherStore().openSession(id)
}

View File

@ -7,7 +7,7 @@
<div class="mode-switcher flex-shrink-0">
<RouterLink to="/dashboard/apps" class="mode-switcher-btn">My Apps</RouterLink>
<RouterLink to="/dashboard/discover" class="mode-switcher-btn mode-switcher-btn-active">App Store</RouterLink>
<RouterLink to="/dashboard/apps?tab=services" class="mode-switcher-btn">Services</RouterLink>
<RouterLink to="/dashboard/apps?tab=websites" class="mode-switcher-btn">Websites</RouterLink>
</div>
<div class="mode-switcher flex-shrink-0">
<RouterLink

View File

@ -7,7 +7,7 @@
<div class="mode-switcher flex-shrink-0">
<RouterLink to="/dashboard/apps" class="mode-switcher-btn">My Apps</RouterLink>
<RouterLink to="/dashboard/discover" class="mode-switcher-btn mode-switcher-btn-active">App Store</RouterLink>
<RouterLink to="/dashboard/apps?tab=services" class="mode-switcher-btn">Services</RouterLink>
<RouterLink to="/dashboard/apps?tab=websites" class="mode-switcher-btn">Websites</RouterLink>
</div>
<div class="mode-switcher flex-shrink-0">
<RouterLink

View File

@ -83,7 +83,7 @@ export const APP_URLS: Record<string, { dev: string; prod: string }> = {
'photoprism': { dev: 'http://localhost:2342', prod: 'http://localhost:2342' },
'immich': { dev: 'http://localhost:2283', prod: 'http://localhost:2283' },
'filebrowser': { dev: 'http://localhost:8083', prod: 'http://localhost:8083' },
'nginx-proxy-manager': { dev: 'http://localhost:81', prod: 'http://localhost:81' },
'nginx-proxy-manager': { dev: 'http://localhost:8081', prod: 'http://localhost:8081' },
'gitea': { dev: 'http://localhost:3001', prod: 'http://localhost:3001' },
'portainer': { dev: 'http://localhost:9000', prod: 'http://localhost:9000' },
'uptime-kuma': { dev: 'http://localhost:3002', prod: 'http://localhost:3002' },

View File

@ -31,7 +31,7 @@ export const APP_PORTS: Record<string, number> = {
'immich': 2283,
'immich_server': 2283,
'filebrowser': 8083,
'nginx-proxy-manager': 81,
'nginx-proxy-manager': 8081,
'gitea': 3001,
'portainer': 9000,
'tailscale': 8240,

View File

@ -79,7 +79,7 @@ import { useServerStore } from '@/stores/server'
import { useAppLauncherStore } from '@/stores/appLauncher'
import type { PackageDataEntry } from '@/types/api'
import { resolveAppUrl } from '@/views/appSession/appSessionConfig'
import { canLaunch, handleImageError, opensInTab, resolveAppIcon } from './appsConfig'
import { canLaunch, handleImageError, isWebsitePackage, opensInTab, resolveAppIcon, resolveRuntimeLaunchUrl } from './appsConfig'
import { getCuratedAppList } from '../discover/curatedApps'
const ITEMS_PER_PAGE = 16 // 4 columns x 4 rows
@ -120,8 +120,15 @@ function getIcon(id: string, pkg: PackageDataEntry): string {
function handleTap(id: string, pkg: PackageDataEntry) {
if (canLaunch(pkg)) {
if (isWebsitePackage(id, pkg)) {
const url = resolveRuntimeLaunchUrl(pkg)
if (url) {
window.open(url, '_blank', 'noopener,noreferrer')
return
}
}
if (opensInTab(id)) {
const appUrl = resolveAppUrl(id)
const appUrl = resolveRuntimeLaunchUrl(pkg) || resolveAppUrl(id)
if (appUrl) {
window.open(appUrl, '_blank', 'noopener,noreferrer')
return

View File

@ -5,6 +5,8 @@ import { computed } from 'vue'
import { PackageState, type PackageDataEntry } from '@/types/api'
import { resolveAppUrl } from '../appSession/appSessionConfig'
export type AppsTab = 'apps' | 'websites' | 'services'
// Service container name patterns (backend/infra, not user-facing)
export const SERVICE_NAMES = new Set([
'dwn', 'archy-mempool-db', 'archy-btcpay-db', 'archy-nbxplorer', 'archy-tor',
@ -22,6 +24,15 @@ export const SERVICE_NAMES = new Set([
'syntropy-institute', 't-zero', 'arch-presentation',
])
const INTERNAL_TOOLING_NAMES = new Set([
'buildx_buildkit_default',
])
export function isInternalToolingPackage(id: string, pkg?: PackageDataEntry): boolean {
const manifestId = pkg?.manifest?.id || ''
return INTERNAL_TOOLING_NAMES.has(id) || INTERNAL_TOOLING_NAMES.has(manifestId) || id.startsWith('buildx_buildkit') || manifestId.startsWith('buildx_buildkit')
}
export function isServiceContainer(id: string): boolean {
if (SERVICE_NAMES.has(id)) return true
if (id.startsWith('indeedhub-build_')) return true
@ -58,14 +69,32 @@ export function getAppCategory(id: string, pkg: PackageDataEntry): string {
return cat || 'other'
}
export function runtimeLanAddress(pkg: PackageDataEntry): string {
return pkg.installed?.['interface-addresses']?.main?.['lan-address'] || ''
}
export function isKnownApp(id: string, pkg?: PackageDataEntry): boolean {
const manifestId = pkg?.manifest?.id
return !!(APP_CATEGORY_MAP[id] || (manifestId && APP_CATEGORY_MAP[manifestId]) || isWebOnlyApp(id))
}
export function isWebsitePackage(id: string, pkg?: PackageDataEntry): boolean {
if (isInternalToolingPackage(id, pkg)) return false
if (isServicePackage(id, pkg)) return true
if (isKnownApp(id, pkg)) return false
return !!pkg && !!runtimeLanAddress(pkg)
}
export function filterEntriesForTab(
entries: Array<[string, PackageDataEntry]>,
activeTab: 'apps' | 'services',
activeTab: AppsTab,
selectedCategory: string,
): Array<[string, PackageDataEntry]> {
return entries.filter(([id, pkg]) => {
const isSvc = isServicePackage(id, pkg)
if (activeTab === 'services' ? !isSvc : isSvc) return false
if (isInternalToolingPackage(id, pkg)) return false
const wantsWebsites = activeTab === 'websites' || activeTab === 'services'
const isWebsite = isWebsitePackage(id, pkg)
if (wantsWebsites ? !isWebsite : isWebsite) return false
if (activeTab === 'apps' && selectedCategory !== 'all') {
return getAppCategory(id, pkg) === selectedCategory
}
@ -151,6 +180,12 @@ export function canLaunch(pkg: PackageDataEntry): boolean {
return !!hasUI && pkg.state === 'running' && pkg.health !== 'starting' && pkg.health !== 'unhealthy'
}
export function resolveRuntimeLaunchUrl(pkg: PackageDataEntry): string {
const addr = runtimeLanAddress(pkg)
if (!addr || typeof window === 'undefined') return addr
return addr.replace(/^http:\/\/(localhost|127\.0\.0\.1)(?=[:/]|$)/, `http://${window.location.hostname}`)
}
export function getStatusClass(state: PackageState, health?: string | null, exitCode?: number | null): string {
if (state === PackageState.Running && health === 'starting') return 'bg-yellow-500/20 text-yellow-200'
if (state === PackageState.Running && health === 'unhealthy') return 'bg-orange-500/20 text-orange-200'
@ -212,7 +247,7 @@ export function useCategoriesWithApps(
allCategories: Ref<Array<{ id: string; name: string }>>,
) {
return computed(() => {
const entries = Object.entries(packages.value).filter(([id, pkg]) => !isServicePackage(id, pkg))
const entries = Object.entries(packages.value).filter(([id, pkg]) => !isWebsitePackage(id, pkg) && !isInternalToolingPackage(id, pkg))
return allCategories.value.filter(cat => {
if (cat.id === 'all') return true
return entries.some(([id, pkg]) => getAppCategory(id, pkg) === cat.id)

View File

@ -10,7 +10,7 @@
<RouterLink
to="/dashboard/apps"
class="mode-switcher-btn"
:class="{ 'mode-switcher-btn-active': (route.path === '/dashboard/apps' || route.path.startsWith('/dashboard/apps/')) && route.query.tab !== 'services' }"
:class="{ 'mode-switcher-btn-active': (route.path === '/dashboard/apps' || route.path.startsWith('/dashboard/apps/')) && route.query.tab !== 'services' && route.query.tab !== 'websites' }"
@click.prevent="router.push({ path: '/dashboard/apps', query: {} })"
>My Apps</RouterLink>
<RouterLink
@ -19,10 +19,10 @@
:class="{ 'mode-switcher-btn-active': route.path === '/dashboard/marketplace' || route.path.startsWith('/dashboard/marketplace/') || route.path === '/dashboard/discover' }"
>App Store</RouterLink>
<RouterLink
to="/dashboard/apps?tab=services"
to="/dashboard/apps?tab=websites"
class="mode-switcher-btn"
:class="{ 'mode-switcher-btn-active': route.query.tab === 'services' }"
>Services</RouterLink>
:class="{ 'mode-switcher-btn-active': route.query.tab === 'services' || route.query.tab === 'websites' }"
>Websites</RouterLink>
</div>
</div>

View File

@ -96,8 +96,8 @@ export function useRouteTransitions() {
// Mobile: Horizontal slide transitions between sub-tabs
if (typeof window !== 'undefined' && window.innerWidth < 768) {
const isServices = currentPath === '/dashboard/apps' && currentRoute.query.tab === 'services'
const wasServices = previousTab === 'services'
const isServices = currentPath === '/dashboard/apps' && (currentRoute.query.tab === 'services' || currentRoute.query.tab === 'websites')
const wasServices = previousTab === 'services' || previousTab === 'websites'
const currentAppsIdx = isServices ? 2
: currentPath === '/dashboard/marketplace' ? 1
: currentPath === '/dashboard/apps' ? 0 : -1

View File

@ -38,6 +38,7 @@ export default defineConfig({
},
workbox: {
navigateFallbackDenylist: [/^\/app\//, /^\/rpc\//, /^\/ws/, /^\/aiui\//],
cleanupOutdatedCaches: true,
globPatterns: ['**/*.{js,css,html,ico,png,svg,jpg,jpeg,mp4,webp}'],
globIgnores: [
'**/*-backup-*.mp4',

View File

@ -1,34 +1,29 @@
{
"version": "1.7.56-alpha",
"release_date": "2026-05-15",
"version": "1.7.57-alpha",
"release_date": "2026-05-17",
"changelog": [
"Health notifications now clear when an app is no longer unhealthy, including stale alerts for removed containers such as Portainer.",
"Fresh installs now include the full Wi-Fi userspace stack (`wpasupplicant`, `wireless-regdb`, `iw`, `rfkill`, `polkitd`, `pciutils`, and `usbutils`) so NetworkManager can scan and connect with Intel Wi-Fi cards out of the box.",
"The installed system now grants the `archipelago` service user explicit NetworkManager PolicyKit access for web-triggered Wi-Fi scans and connection changes.",
"Wi-Fi connect now replaces stale/partial NetworkManager profiles and creates an explicit WPA-PSK profile with the supplied password, avoiding no-secret retry failures after a failed attempt.",
"Settings password changes now update the Linux/SSH password through non-interactive sudo, so the web password and SSH password stay in sync when the checkbox is enabled.",
"Quadlet environment values with spaces or shell metacharacters are quoted consistently, preventing env drift recreate loops for apps like nostr-rs-relay and Grafana.",
"Boot/bootstrap reconcile avoids restarting running Bitcoin containers while repairing RPC config, preserving IBD progress on active nodes.",
"Exit code 137 is labeled as SIGKILL instead of assuming OOM, avoiding false OOM alerts for orchestrator-managed recreates.",
"Container reconcile force-recreates Podman records stuck in `Stopping`, preserving bind-mounted app data while recovering wedged containers automatically.",
"Container health reporting is honest for running containers: Archipelago surfaces Podman's actual health state instead of marking every running container healthy."
"Nginx Proxy Manager now avoids privileged rootless Podman host port `81`, preferring `8081:81` while host nginx keeps a compatibility proxy on `:81` for stale cached launch buttons.",
"App installs now allocate ports by checking live host bind availability, falling back to a free high port when preferred ports are already occupied.",
"Portainer-created launchable containers are separated into a `Websites` tab and launch through their discovered published host port instead of hard-coded app URLs.",
"Internal BuildKit helper containers such as `buildx_buildkit_default` are hidden from the Apps UI.",
"Portainer works out of the box on Debian 13/Podman installs by including `catatonit` and by preserving the Podman socket mount as a socket rather than creating it as a directory."
],
"components": [
{
"name": "archipelago",
"current_version": "1.7.56-alpha",
"new_version": "1.7.56-alpha",
"download_url": "http://146.59.87.168:3000/lfg2025/archy/releases/download/v1.7.56-alpha/archipelago",
"sha256": "f6c54cc7fbaac3dde97b1a719a6d380ce734a4d52366d4579770effadef92c9c",
"size_bytes": 42862944
"current_version": "1.7.57-alpha",
"new_version": "1.7.57-alpha",
"download_url": "http://146.59.87.168:3000/lfg2025/archy/releases/download/v1.7.57-alpha/archipelago",
"sha256": "e96d657c28f84c72c1358fdbbc014184530763341a2d02242b3a479f36dc2aef",
"size_bytes": 42647224
},
{
"name": "archipelago-frontend-1.7.56-alpha.tar.gz",
"current_version": "1.7.56-alpha",
"new_version": "1.7.56-alpha",
"download_url": "http://146.59.87.168:3000/lfg2025/archy/releases/download/v1.7.56-alpha/archipelago-frontend-1.7.56-alpha.tar.gz",
"sha256": "91bfa10085696921c929ab7d216a8e617eb02e7105f21f32cdc3a2ba15158cd4",
"size_bytes": 166465069
"name": "archipelago-frontend-1.7.57-alpha.tar.gz",
"current_version": "1.7.57-alpha",
"new_version": "1.7.57-alpha",
"download_url": "http://146.59.87.168:3000/lfg2025/archy/releases/download/v1.7.57-alpha/archipelago-frontend-1.7.57-alpha.tar.gz",
"sha256": "a09c1507afb4b7e9f5279b92215e92ebc0b86155ae53ac2c0baaecd07522c7ee",
"size_bytes": 78322316
}
]
}

View File

@ -1,34 +1,29 @@
{
"version": "1.7.56-alpha",
"release_date": "2026-05-15",
"version": "1.7.57-alpha",
"release_date": "2026-05-17",
"changelog": [
"Health notifications now clear when an app is no longer unhealthy, including stale alerts for removed containers such as Portainer.",
"Fresh installs now include the full Wi-Fi userspace stack (`wpasupplicant`, `wireless-regdb`, `iw`, `rfkill`, `polkitd`, `pciutils`, and `usbutils`) so NetworkManager can scan and connect with Intel Wi-Fi cards out of the box.",
"The installed system now grants the `archipelago` service user explicit NetworkManager PolicyKit access for web-triggered Wi-Fi scans and connection changes.",
"Wi-Fi connect now replaces stale/partial NetworkManager profiles and creates an explicit WPA-PSK profile with the supplied password, avoiding no-secret retry failures after a failed attempt.",
"Settings password changes now update the Linux/SSH password through non-interactive sudo, so the web password and SSH password stay in sync when the checkbox is enabled.",
"Quadlet environment values with spaces or shell metacharacters are quoted consistently, preventing env drift recreate loops for apps like nostr-rs-relay and Grafana.",
"Boot/bootstrap reconcile avoids restarting running Bitcoin containers while repairing RPC config, preserving IBD progress on active nodes.",
"Exit code 137 is labeled as SIGKILL instead of assuming OOM, avoiding false OOM alerts for orchestrator-managed recreates.",
"Container reconcile force-recreates Podman records stuck in `Stopping`, preserving bind-mounted app data while recovering wedged containers automatically.",
"Container health reporting is honest for running containers: Archipelago surfaces Podman's actual health state instead of marking every running container healthy."
"Nginx Proxy Manager now avoids privileged rootless Podman host port `81`, preferring `8081:81` while host nginx keeps a compatibility proxy on `:81` for stale cached launch buttons.",
"App installs now allocate ports by checking live host bind availability, falling back to a free high port when preferred ports are already occupied.",
"Portainer-created launchable containers are separated into a `Websites` tab and launch through their discovered published host port instead of hard-coded app URLs.",
"Internal BuildKit helper containers such as `buildx_buildkit_default` are hidden from the Apps UI.",
"Portainer works out of the box on Debian 13/Podman installs by including `catatonit` and by preserving the Podman socket mount as a socket rather than creating it as a directory."
],
"components": [
{
"name": "archipelago",
"current_version": "1.7.56-alpha",
"new_version": "1.7.56-alpha",
"download_url": "http://146.59.87.168:3000/lfg2025/archy/releases/download/v1.7.56-alpha/archipelago",
"sha256": "f6c54cc7fbaac3dde97b1a719a6d380ce734a4d52366d4579770effadef92c9c",
"size_bytes": 42862944
"current_version": "1.7.57-alpha",
"new_version": "1.7.57-alpha",
"download_url": "http://146.59.87.168:3000/lfg2025/archy/releases/download/v1.7.57-alpha/archipelago",
"sha256": "e96d657c28f84c72c1358fdbbc014184530763341a2d02242b3a479f36dc2aef",
"size_bytes": 42647224
},
{
"name": "archipelago-frontend-1.7.56-alpha.tar.gz",
"current_version": "1.7.56-alpha",
"new_version": "1.7.56-alpha",
"download_url": "http://146.59.87.168:3000/lfg2025/archy/releases/download/v1.7.56-alpha/archipelago-frontend-1.7.56-alpha.tar.gz",
"sha256": "91bfa10085696921c929ab7d216a8e617eb02e7105f21f32cdc3a2ba15158cd4",
"size_bytes": 166465069
"name": "archipelago-frontend-1.7.57-alpha.tar.gz",
"current_version": "1.7.57-alpha",
"new_version": "1.7.57-alpha",
"download_url": "http://146.59.87.168:3000/lfg2025/archy/releases/download/v1.7.57-alpha/archipelago-frontend-1.7.57-alpha.tar.gz",
"sha256": "a09c1507afb4b7e9f5279b92215e92ebc0b86155ae53ac2c0baaecd07522c7ee",
"size_bytes": 78322316
}
]
}

View File

@ -99,6 +99,10 @@ reset_spec() {
SPEC_ENTRYPOINT=""
}
if ! declare -F alloc_port >/dev/null 2>&1; then
alloc_port() { printf '%s' "$2"; }
fi
# ── Tier 0: Databases ────────────────────────────────────────────────
load_spec_archy-mempool-db() {
@ -493,13 +497,17 @@ load_spec_nginx-proxy-manager() {
reset_spec
SPEC_NAME="nginx-proxy-manager"
SPEC_IMAGE="${NPM_IMAGE}"
SPEC_PORTS="81:81 8084:80 8444:443"
local admin_port http_port https_port
admin_port=$(alloc_port nginx-proxy-manager 8081 81)
http_port=$(alloc_port nginx-proxy-manager-http 8084 80)
https_port=$(alloc_port nginx-proxy-manager-https 8444 443)
SPEC_PORTS="$admin_port:81 $http_port:80 $https_port:443"
SPEC_VOLUMES="/var/lib/archipelago/nginx-proxy-manager/data:/data /var/lib/archipelago/nginx-proxy-manager/letsencrypt:/etc/letsencrypt"
SPEC_MEMORY="$(mem_limit nginx-proxy-manager)"
SPEC_HEALTH_CMD="curl -sf http://localhost:81/ || exit 1"
SPEC_TIER="3"
SPEC_DATA_DIR="/var/lib/archipelago/nginx-proxy-manager"
SPEC_CAPS="CHOWN SETUID SETGID NET_BIND_SERVICE"
SPEC_CAPS="CHOWN FOWNER SETUID SETGID DAC_OVERRIDE NET_BIND_SERVICE"
SPEC_OPTIONAL="true"
}

View File

@ -914,7 +914,7 @@ LNDCONF
--health-cmd 'curl -sf http://localhost:81/' --health-interval=30s --health-timeout=5s --health-retries=3 \
--cap-drop ALL --cap-add CHOWN --cap-add SETUID --cap-add SETGID --cap-add NET_BIND_SERVICE \
--security-opt no-new-privileges:true \
-p 81:81 -p 8084:80 -p 8444:443 \
-p 8081:81 -p 8084:80 -p 8444:443 \
-v /var/lib/archipelago/nginx-proxy-manager/data:/data \
-v /var/lib/archipelago/nginx-proxy-manager/letsencrypt:/etc/letsencrypt \
$NPM_IMAGE

View File

@ -48,6 +48,37 @@ SCRIPT_DIR_FBC="$(cd "$(dirname "$0")" && pwd)"
# as root (rootful podman), the backend can't see them at all.
DOCKER="runuser -u archipelago -- env XDG_RUNTIME_DIR=/run/user/$(id -u archipelago) podman"
PORT_ALLOC_FILE="/var/lib/archipelago/port-allocations.env"
mkdir -p /var/lib/archipelago 2>/dev/null || true
[ -f "$PORT_ALLOC_FILE" ] && . "$PORT_ALLOC_FILE"
port_available() {
local port="$1"
ss -ltn 2>/dev/null | awk -v p=":$port" '$4 == p || $4 ~ p "$" { found=1 } END { exit found ? 1 : 0 }'
}
alloc_port() {
local key="$1" preferred="$2" var="PORT_${key//[^A-Za-z0-9]/_}" cur=""
eval "cur=\${$var:-}"
if [ -n "$cur" ] && port_available "$cur"; then
printf '%s' "$cur"
return
fi
if port_available "$preferred"; then
cur="$preferred"
else
cur=""
for p in $(seq 8085 9999); do
if port_available "$p"; then cur="$p"; break; fi
done
fi
[ -n "$cur" ] || cur="$preferred"
if ! grep -q "^$var=" "$PORT_ALLOC_FILE" 2>/dev/null; then
printf '%s=%s\n' "$var" "$cur" >> "$PORT_ALLOC_FILE"
fi
printf '%s' "$cur"
}
# UNBUNDLED mode: only create FileBrowser, skip all other containers.
# Users install apps on-demand from the Marketplace.
UNBUNDLED_MARKER="/opt/archipelago/.unbundled"
@ -1146,12 +1177,15 @@ track_container "filebrowser"
if ! $DOCKER ps --format '{{.Names}}' 2>/dev/null | grep -q nginx-proxy-manager; then
log "Creating Nginx Proxy Manager..."
mkdir -p /var/lib/archipelago/nginx-proxy-manager/data /var/lib/archipelago/nginx-proxy-manager/letsencrypt
NPM_ADMIN_PORT=$(alloc_port nginx-proxy-manager 8081)
NPM_HTTP_PORT=$(alloc_port nginx-proxy-manager-http 8084)
NPM_HTTPS_PORT=$(alloc_port nginx-proxy-manager-https 8444)
$DOCKER run -d --name nginx-proxy-manager --restart unless-stopped \
--health-cmd="curl -sf http://localhost:81/ || exit 1" --health-interval=120s --health-timeout=5s --health-retries=3 \
--memory=$(mem_limit nginx-proxy-manager) \
--cap-drop ALL --cap-add CHOWN --cap-add SETUID --cap-add SETGID --cap-add NET_BIND_SERVICE \
--cap-drop ALL --cap-add CHOWN --cap-add FOWNER --cap-add SETUID --cap-add SETGID --cap-add DAC_OVERRIDE --cap-add NET_BIND_SERVICE \
--security-opt no-new-privileges:true \
-p 81:81 -p 8084:80 -p 8444:443 \
-p ${NPM_ADMIN_PORT}:81 -p ${NPM_HTTP_PORT}:80 -p ${NPM_HTTPS_PORT}:443 \
-v /var/lib/archipelago/nginx-proxy-manager/data:/data \
-v /var/lib/archipelago/nginx-proxy-manager/letsencrypt:/etc/letsencrypt \
"${NPM_IMAGE}" 2>>"$LOG" || true

View File

@ -204,7 +204,7 @@ location /app/electrs-ui/ {
proxy_hide_header Content-Security-Policy;
}
location /app/nginx-proxy-manager/ {
proxy_pass http://127.0.0.1:81/;
proxy_pass http://127.0.0.1:8081/;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;

View File

@ -63,6 +63,37 @@ header(){ echo -e "\n${BOLD}$*${NC}"; }
source "$SCRIPT_DIR/container-specs.sh" || { echo "Cannot source container-specs.sh"; exit 1; }
detect_environment
PORT_ALLOC_FILE="/var/lib/archipelago/port-allocations.env"
[ -f "$PORT_ALLOC_FILE" ] && . "$PORT_ALLOC_FILE"
port_available() {
local port="$1"
ss -ltn 2>/dev/null | awk -v p=":$port" '$4 == p || $4 ~ p "$" { found=1 } END { exit found ? 1 : 0 }'
}
alloc_port() {
local key="$1" preferred="$2" var="PORT_${key//[^A-Za-z0-9]/_}" cur=""
eval "cur=\${$var:-}"
if [ -n "$cur" ] && port_available "$cur"; then
printf '%s' "$cur"
return
fi
if port_available "$preferred"; then
cur="$preferred"
else
cur=""
for p in $(seq 8085 9999); do
if port_available "$p"; then cur="$p"; break; fi
done
fi
[ -n "$cur" ] || cur="$preferred"
sudo mkdir -p "$(dirname "$PORT_ALLOC_FILE")" 2>/dev/null || true
if ! grep -q "^$var=" "$PORT_ALLOC_FILE" 2>/dev/null; then
printf '%s=%s\n' "$var" "$cur" | sudo tee -a "$PORT_ALLOC_FILE" >/dev/null
fi
printf '%s' "$cur"
}
# ── Podman command ───────────────────────────────────────────────────
# Run as archipelago user — podman sees rootless containers directly.
# Use sudo only for chown/mkdir operations.
@ -154,6 +185,39 @@ host_port_listening() {
'
}
prepare_bind_source() {
local source="$1"
[ -n "$source" ] || return 0
case "$source" in
/run/user/*/podman/podman.sock)
if [ ! -S "$source" ]; then
local runtime_dir="${source%/podman/podman.sock}"
XDG_RUNTIME_DIR="$runtime_dir" systemctl --user start podman.socket 2>/dev/null || true
for _ in 1 2 3 4 5 6 7 8 9 10; do
[ -S "$source" ] && return 0
sleep 0.25
done
fi
;;
esac
case "$source" in
/var/lib/archipelago/*)
sudo mkdir -p "$source" 2>/dev/null
;;
*)
# Non-data bind mounts can be files/sockets/devices. Creating the full
# path would turn e.g. podman.sock into a directory and break Portainer.
if [ -e "$source" ]; then
return 0
fi
fail "bind source missing: $source"
return 1
;;
esac
}
container_has_mount() {
local name="$1" source="$2" target="$3"
$PODMAN inspect "$name" --format '{{range .Mounts}}{{println .Source "|" .Destination}}{{end}}' 2>/dev/null \
@ -536,7 +600,11 @@ reconcile() {
else
for v in $SPEC_VOLUMES; do
local host_dir="${v%%:*}"
[ -n "$host_dir" ] && sudo mkdir -p "$host_dir" 2>/dev/null
prepare_bind_source "$host_dir" || {
COUNT_FAILED=$((COUNT_FAILED + 1))
FAILED_LIST+=" $name"
return
}
done
if eval "$(build_run_cmd)" >/dev/null 2>&1; then
fixed "$name — created"

View File

@ -178,7 +178,7 @@ launch_url_for() {
ollama) echo "http://${ARCHY_HOST}:11434/" ;;
immich|immich_server) echo "http://${ARCHY_HOST}:2283/" ;;
portainer) echo "http://${ARCHY_HOST}:9000/" ;;
nginx-proxy-manager) echo "http://${ARCHY_HOST}:81/" ;;
nginx-proxy-manager) echo "http://${ARCHY_HOST}:8081/" ;;
tailscale) echo "http://${ARCHY_HOST}:8240/" ;;
uptime-kuma) echo "http://${ARCHY_HOST}:3002/" ;;
homeassistant) echo "http://${ARCHY_HOST}:8123/" ;;