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
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:
parent
d7c5d39747
commit
da14c135e4
@ -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()
|
||||
|
||||
@ -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};
|
||||
|
||||
248
core/archipelago/src/container/ui_detection.rs
Normal file
248
core/archipelago/src/container/ui_detection.rs
Normal 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);
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user