feat(apps): backend-only services classify as services with no Launch button
Some checks failed
Demo images / Build & push demo images (push) Has been cancelled

A published port no longer implies a web UI. The package scanner used to
synthesize interfaces.main.ui="true" for any container with a port or
onion address, so headless backends — including self-deployed compose
stacks like podsteadr — showed up as launchable apps. New ui_detection
module decides instead: a manifest interfaces declaration (catalog
overlay first, disk second) is definitive; undeclared apps get a short
HTTP probe of the launch port (HTML page, redirect, or browser auth
wall = UI; JSON APIs, raw TCP, dead ports = service), with cached
verdicts and probes gated on running containers. Frontend canLaunch
now refuses curated services outright and only treats a bare runtime
address as launchable for curated known apps.

Works identically for manifest apps and containers deployed by hand
outside the orchestrator. ui_detection tests 6/6, frontend suite
696/696.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
archipelago 2026-07-29 05:48:38 -04:00
parent d7c5d39747
commit da14c135e4
5 changed files with 302 additions and 3 deletions

View File

@ -212,9 +212,21 @@ impl DockerPackageScanner {
website: lan_address.clone(),
tier: Some(metadata.tier.to_string()),
interfaces: if lan_address.is_some() || tor_address.is_some() {
// `ui` is no longer implied by a published port: a
// headless backend with an exposed port is a service,
// not a launchable app. ui_detection consults the
// manifest declaration first, then HTTP-probes the
// port. Addresses stay present either way so the
// Services tab can still show where a backend lives.
let has_ui = super::ui_detection::has_web_ui(
&app_id,
lan_address.as_deref(),
package_state == PackageState::Running,
)
.await;
Some(Interfaces {
main: Some(MainInterface {
ui: Some("true".to_string()),
ui: has_ui.then(|| "true".to_string()),
tor_config: tor_address.clone(),
lan_config: None,
}),
@ -729,7 +741,7 @@ async fn reachable_lan_address(app_id: &str, candidate: Option<String>) -> Optio
/// `rsplit(':')` then yields `"8096/"`, which fails to parse and silently
/// drops a reachable launch URL. Reading digits after the final colon mirrors
/// `port_from_url` in the RPC layer and tolerates a trailing path.
fn launch_url_port(url: &str) -> Option<u16> {
pub(crate) fn launch_url_port(url: &str) -> Option<u16> {
let after_colon = url.rsplit_once(':')?.1;
after_colon
.chars()

View File

@ -15,6 +15,7 @@ pub mod quadlet;
pub mod registry;
pub mod secrets;
pub mod traits;
pub mod ui_detection;
pub mod version_config;
pub use boot_reconciler::{BootReconciler, DEFAULT_INTERVAL as RECONCILER_DEFAULT_INTERVAL};

View File

@ -0,0 +1,248 @@
//! Web-UI detection for discovered containers.
//!
//! The packages list used to mark every container that published a port (or
//! carried an onion address) as a UI app, which gave headless backends —
//! databases, media servers, self-deployed compose stacks — a Launch button.
//! UI-ness is decided here instead, for manifest apps and ad-hoc containers
//! alike:
//!
//! 1. A manifest that declares an `interfaces:` block is definitive: any
//! entry of `type: ui` means a browsable UI, a block without one means a
//! backend service. The signed catalog overlay is consulted before disk
//! manifests (catalog supremacy).
//! 2. Manifests without an `interfaces:` block (the overwhelming majority)
//! and manifest-less containers fall through to a short HTTP probe of the
//! launch port: an HTML page, a redirect, or a browser-auth wall means a
//! UI; JSON APIs, raw TCP protocols, and dead ports mean a service.
//!
//! Verdicts are cached — positives longer than negatives, since "no UI yet"
//! is often just an app that hasn't finished starting.
use std::collections::HashMap;
use std::sync::{Mutex, OnceLock};
use std::time::{Duration, Instant};
use tracing::debug;
const PROBE_TIMEOUT: Duration = Duration::from_secs(2);
/// A confirmed UI stays a UI — re-check rarely.
const POSITIVE_TTL: Duration = Duration::from_secs(15 * 60);
/// A "no UI" verdict may be a slow-starting app — re-check sooner.
const NEGATIVE_TTL: Duration = Duration::from_secs(2 * 60);
/// Decide whether `app_id` exposes a browsable web UI. `lan_address` is the
/// launch candidate already computed by the package scanner (host-published),
/// and `running` gates the probe: a stopped container can't answer, and a
/// dead-port verdict against it would poison the cache.
pub async fn has_web_ui(app_id: &str, lan_address: Option<&str>, running: bool) -> bool {
if let Some(declared) = manifest_declares_ui(app_id) {
return declared;
}
let Some(port) = lan_address.and_then(super::docker_packages::launch_url_port) else {
return false;
};
if !running {
// Serve a cached verdict if we have one, but never record one.
return cached_verdict(app_id, port).unwrap_or(false);
}
if let Some(v) = cached_verdict(app_id, port) {
return v;
}
let verdict = probe_port(port).await;
debug!(app_id, port, verdict, "web-UI probe");
cache_verdict(app_id, port, verdict);
verdict
}
// ─── Manifest declarations ───────────────────────────────────────────────
/// `Some(true)`: a manifest covers this app and declares a `type: ui`
/// interface. `Some(false)`: a manifest declares interfaces, none of them UI.
/// `None`: no manifest, or one without an `interfaces:` block — undeclared,
/// let the probe decide.
fn manifest_declares_ui(app_id: &str) -> Option<bool> {
// Catalog overlay first: disk manifests don't apply to catalog-covered
// apps, so the catalog must win where it speaks.
for (id, value) in super::app_catalog::catalog_manifest_values() {
if id == app_id {
if let Some(v) = declared_ui_in_value(&value) {
return Some(v);
}
break;
}
}
for path in disk_manifest_candidates(app_id) {
if !path.exists() {
continue;
}
match archipelago_container::AppManifest::from_file(&path) {
Ok(m) => {
if m.app.interfaces.is_empty() {
return None;
}
return Some(
m.app
.interfaces
.values()
.any(|i| i.interface_type == "ui"),
);
}
// Malformed manifests are already reported by the orchestrator's
// loader; here they simply don't count as a declaration.
Err(_) => return None,
}
}
None
}
/// Same declaration logic against a catalog manifest carried as raw JSON.
fn declared_ui_in_value(manifest: &serde_json::Value) -> Option<bool> {
let interfaces = manifest.get("app")?.get("interfaces")?.as_object()?;
if interfaces.is_empty() {
return None;
}
Some(interfaces.values().any(|i| {
// An omitted `type` defaults to "ui", mirroring the YAML schema.
i.get("type")
.and_then(|t| t.as_str())
.map(|t| t == "ui")
.unwrap_or(true)
}))
}
fn disk_manifest_candidates(app_id: &str) -> Vec<std::path::PathBuf> {
let mut roots: Vec<std::path::PathBuf> = Vec::new();
if let Ok(v) = std::env::var("ARCHIPELAGO_APPS_DIR") {
let v = v.trim();
if !v.is_empty() {
roots.push(v.into());
}
}
roots.push("/opt/archipelago/apps".into());
roots
.into_iter()
.map(|root| root.join(app_id).join("manifest.yml"))
.collect()
}
// ─── HTTP probe ──────────────────────────────────────────────────────────
async fn probe_port(port: u16) -> bool {
static CLIENT: OnceLock<reqwest::Client> = OnceLock::new();
let client = CLIENT.get_or_init(|| {
reqwest::Client::builder()
.timeout(PROBE_TIMEOUT)
.redirect(reqwest::redirect::Policy::none())
.build()
.expect("static probe client")
});
match client.get(format!("http://127.0.0.1:{port}/")).send().await {
Ok(resp) => {
let content_type = resp
.headers()
.get(reqwest::header::CONTENT_TYPE)
.and_then(|v| v.to_str().ok())
.unwrap_or("");
let auth_challenge = resp
.headers()
.contains_key(reqwest::header::WWW_AUTHENTICATE);
ui_verdict(resp.status().as_u16(), content_type, auth_challenge)
}
// Connection refused, timeout, or a non-HTTP protocol on the port.
Err(_) => false,
}
}
/// The pure classification rule, split out for tests: a browsable UI is an
/// HTML response (any status — error pages included), a redirect (login
/// flows), or a browser auth prompt. JSON/plaintext APIs are services.
fn ui_verdict(status: u16, content_type: &str, auth_challenge: bool) -> bool {
if content_type.contains("text/html") {
return true;
}
if (300..400).contains(&status) {
return true;
}
if (status == 401 || status == 403) && auth_challenge {
return true;
}
false
}
// ─── Verdict cache ───────────────────────────────────────────────────────
fn cache() -> &'static Mutex<HashMap<String, (bool, Instant)>> {
static CACHE: OnceLock<Mutex<HashMap<String, (bool, Instant)>>> = OnceLock::new();
CACHE.get_or_init(Default::default)
}
fn cached_verdict(app_id: &str, port: u16) -> Option<bool> {
let cache = cache().lock().ok()?;
let (verdict, at) = cache.get(&format!("{app_id}:{port}"))?;
let ttl = if *verdict { POSITIVE_TTL } else { NEGATIVE_TTL };
(at.elapsed() < ttl).then_some(*verdict)
}
fn cache_verdict(app_id: &str, port: u16, verdict: bool) {
if let Ok(mut cache) = cache().lock() {
cache.insert(format!("{app_id}:{port}"), (verdict, Instant::now()));
}
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn html_is_ui_regardless_of_status() {
assert!(ui_verdict(200, "text/html; charset=utf-8", false));
assert!(ui_verdict(404, "text/html", false));
assert!(ui_verdict(401, "text/html", false));
}
#[test]
fn redirects_are_ui() {
assert!(ui_verdict(302, "", false));
assert!(ui_verdict(307, "text/plain", false));
}
#[test]
fn auth_walls_are_ui_only_with_challenge() {
assert!(ui_verdict(401, "text/plain", true));
assert!(!ui_verdict(401, "application/json", false));
}
#[test]
fn apis_and_plain_ports_are_services() {
assert!(!ui_verdict(200, "application/json", false));
assert!(!ui_verdict(200, "text/plain", false));
assert!(!ui_verdict(500, "", false));
}
#[test]
fn catalog_value_declaration() {
let ui = json!({"app": {"interfaces": {"main": {"type": "ui", "port": 80}}}});
assert_eq!(declared_ui_in_value(&ui), Some(true));
// Omitted type defaults to "ui", mirroring the YAML schema default.
let default_ty = json!({"app": {"interfaces": {"main": {"port": 80}}}});
assert_eq!(declared_ui_in_value(&default_ty), Some(true));
let api_only = json!({"app": {"interfaces": {"rpc": {"type": "api", "port": 80}}}});
assert_eq!(declared_ui_in_value(&api_only), Some(false));
// No interfaces block ⇒ undeclared, not "no UI".
let none = json!({"app": {"id": "x"}});
assert_eq!(declared_ui_in_value(&none), None);
let empty = json!({"app": {"interfaces": {}}});
assert_eq!(declared_ui_in_value(&empty), None);
}
#[test]
fn verdict_cache_roundtrip() {
cache_verdict("test-app", 1234, true);
assert_eq!(cached_verdict("test-app", 1234), Some(true));
assert_eq!(cached_verdict("test-app", 9999), None);
}
}

View File

@ -98,6 +98,36 @@ describe('appsConfig service filtering', () => {
expect(isWebsitePackage('some-ui-app', uiApp)).toBe(false)
})
it('never offers Launch for an unknown container with a bare exposed port', () => {
// A self-deployed compose stack (e.g. podsteadr) publishes a port, so it
// has a runtime lan-address — but no manifest-declared or probed UI. It
// must classify as a service and must NOT get a Launch button.
const selfDeployed = makePkg('podsteadr', 'podsteadr', 'other')
selfDeployed.installed = { 'interface-addresses': { main: { 'lan-address': 'http://localhost:8095' } }, status: 'running' } as unknown as PackageDataEntry['installed']
expect(isWebsitePackage('podsteadr', selfDeployed)).toBe(true)
expect(canLaunch(selfDeployed)).toBe(false)
})
it('offers Launch for an unknown container once the backend confirms a UI', () => {
const confirmedUi = makePkg('podsteadr', 'podsteadr', 'other')
;(confirmedUi.manifest as unknown as Record<string, unknown>).interfaces = { main: { ui: 'true' } }
confirmedUi.installed = { 'interface-addresses': { main: { 'lan-address': 'http://localhost:8095' } }, status: 'running' } as unknown as PackageDataEntry['installed']
expect(canLaunch(confirmedUi)).toBe(true)
})
it('never offers Launch for curated service containers even with a UI flag', () => {
const service = makePkg('indeedhub-api', 'IndeeHub API', 'media')
;(service.manifest as unknown as Record<string, unknown>).interfaces = { main: { ui: 'true' } }
service.installed = { 'interface-addresses': { main: { 'lan-address': 'http://localhost:9100' } }, status: 'running' } as unknown as PackageDataEntry['installed']
expect(canLaunch(service)).toBe(false)
})
it('keeps Launch for curated apps that rely on a runtime address alone', () => {
const known = makePkg('jellyfin', 'Jellyfin', 'media')
known.installed = { 'interface-addresses': { main: { 'lan-address': 'http://localhost:8096' } }, status: 'running' } as unknown as PackageDataEntry['installed']
expect(canLaunch(known)).toBe(true)
})
it('explains that Fedimint waits for Bitcoin sync before Guardian starts', () => {
const pkg = makePkg('fedimint', 'Fedimint', 'money')
pkg.state = PackageState.Starting

View File

@ -255,9 +255,17 @@ export function resolveAppIcon(id: string, pkg: PackageDataEntry, curatedIcon?:
export function canLaunch(pkg: PackageDataEntry): boolean {
if (isWebOnlyApp(pkg.manifest.id)) return true
// Headless backends never get a Launch button, even with a published port.
if (isServicePackage(pkg.manifest.id, pkg)) return false
const hasRuntimeAddress = !!pkg.installed?.['interface-addresses']?.main?.['lan-address']
const hasKnownLaunchUrl = typeof window !== 'undefined' && !!resolveAppUrl(pkg.manifest.id)
const hasUI = pkg.manifest.interfaces?.main?.ui || hasRuntimeAddress || hasKnownLaunchUrl
// A bare runtime address is only a launch signal for curated apps: the
// backend now sets interfaces.main.ui strictly for confirmed web UIs
// (manifest declaration or HTTP probe), so an unknown container with an
// exposed non-UI port must not become launchable just for having one.
const hasUI = pkg.manifest.interfaces?.main?.ui
|| hasKnownLaunchUrl
|| (hasRuntimeAddress && isKnownApp(pkg.manifest.id, pkg))
if ((pkg.manifest.id === 'fedimint' || pkg.manifest.id === 'fedimintd') && hasUI) {
return pkg.state === PackageState.Running || pkg.state === PackageState.Starting
}