From 881478a873960107fe19ebcff592723e3f6a4c6f Mon Sep 17 00:00:00 2001 From: archipelago Date: Thu, 11 Jun 2026 00:52:16 -0400 Subject: [PATCH] app-platform: type manifest launch interfaces --- apps/home-assistant/manifest.yml | 9 + apps/jellyfin/manifest.yml | 9 + apps/nextcloud/manifest.yml | 9 + apps/photoprism/manifest.yml | 9 + apps/vaultwarden/manifest.yml | 9 + core/container/src/lib.rs | 6 +- core/container/src/manifest.rs | 177 ++++++++++++++++++ core/container/src/podman_client.rs | 28 +-- docs/app-developer-guide.md | 5 + .../__tests__/appSessionConfig.test.ts | 12 +- .../appSession/generatedAppSessionConfig.ts | 2 - scripts/generate-app-catalog.py | 4 + 12 files changed, 253 insertions(+), 26 deletions(-) diff --git a/apps/home-assistant/manifest.yml b/apps/home-assistant/manifest.yml index 4c76ba44..f939e48f 100644 --- a/apps/home-assistant/manifest.yml +++ b/apps/home-assistant/manifest.yml @@ -49,6 +49,15 @@ app: timeout: 5s retries: 3 + interfaces: + main: + name: Web UI + description: Home Assistant dashboard + type: ui + port: 8123 + protocol: http + path: / + metadata: icon: /assets/img/app-icons/homeassistant.png category: home diff --git a/apps/jellyfin/manifest.yml b/apps/jellyfin/manifest.yml index 410d61ca..7234c1c8 100644 --- a/apps/jellyfin/manifest.yml +++ b/apps/jellyfin/manifest.yml @@ -45,6 +45,15 @@ app: timeout: 5s retries: 3 + interfaces: + main: + name: Web UI + description: Jellyfin media dashboard + type: ui + port: 8096 + protocol: http + path: / + metadata: icon: /assets/img/app-icons/jellyfin.webp category: data diff --git a/apps/nextcloud/manifest.yml b/apps/nextcloud/manifest.yml index d438e583..a8165868 100644 --- a/apps/nextcloud/manifest.yml +++ b/apps/nextcloud/manifest.yml @@ -41,6 +41,15 @@ app: timeout: 5s retries: 3 + interfaces: + main: + name: Web UI + description: Nextcloud file and collaboration dashboard + type: ui + port: 8085 + protocol: http + path: / + metadata: icon: /assets/img/app-icons/nextcloud.webp category: data diff --git a/apps/photoprism/manifest.yml b/apps/photoprism/manifest.yml index 2966300a..485d5936 100644 --- a/apps/photoprism/manifest.yml +++ b/apps/photoprism/manifest.yml @@ -42,6 +42,15 @@ app: timeout: 5s retries: 3 + interfaces: + main: + name: Web UI + description: PhotoPrism photo library + type: ui + port: 2342 + protocol: http + path: / + metadata: icon: /assets/img/app-icons/photoprism.svg category: data diff --git a/apps/vaultwarden/manifest.yml b/apps/vaultwarden/manifest.yml index c7718267..2f3d49d3 100644 --- a/apps/vaultwarden/manifest.yml +++ b/apps/vaultwarden/manifest.yml @@ -41,6 +41,15 @@ app: timeout: 5s retries: 3 + interfaces: + main: + name: Web UI + description: Vaultwarden web vault + type: ui + port: 8082 + protocol: http + path: / + metadata: icon: /assets/img/app-icons/vaultwarden.webp category: data diff --git a/core/container/src/lib.rs b/core/container/src/lib.rs index e9f1ceb7..150d35af 100644 --- a/core/container/src/lib.rs +++ b/core/container/src/lib.rs @@ -8,9 +8,9 @@ pub mod runtime; pub use bitcoin_simulator::{BitcoinSimulationMode, BitcoinSimulator}; pub use health_monitor::HealthMonitor; pub use manifest::{ - AppManifest, BuildConfig, ContainerConfig, Dependency, DerivedEnv, GeneratedFile, HealthCheck, - HostFacts, ManifestError, ResolvedSource, ResourceLimits, SecretEnv, SecretsProvider, - SecurityPolicy, Volume, + AppInterface, AppManifest, BuildConfig, ContainerConfig, Dependency, DerivedEnv, GeneratedFile, + HealthCheck, HostFacts, ManifestError, ResolvedSource, ResourceLimits, SecretEnv, + SecretsProvider, SecurityPolicy, Volume, }; pub use podman_client::{ image_uses_insecure_registry, ContainerState, ContainerStatus, PodmanClient, diff --git a/core/container/src/manifest.rs b/core/container/src/manifest.rs index 62c22b9b..cbf8a55a 100644 --- a/core/container/src/manifest.rs +++ b/core/container/src/manifest.rs @@ -54,6 +54,9 @@ pub struct AppDefinition { #[serde(default)] pub devices: Vec, + #[serde(default)] + pub interfaces: HashMap, + #[serde(flatten)] pub extensions: HashMap, } @@ -290,6 +293,33 @@ pub struct HealthCheck { pub retries: u32, } +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct AppInterface { + #[serde(default)] + pub name: Option, + #[serde(default)] + pub description: Option, + #[serde(rename = "type", default = "default_interface_type")] + pub interface_type: String, + pub port: u16, + #[serde(default = "default_http_protocol")] + pub protocol: String, + #[serde(default = "default_root_path")] + pub path: String, +} + +fn default_interface_type() -> String { + "ui".to_string() +} + +fn default_http_protocol() -> String { + "http".to_string() +} + +fn default_root_path() -> String { + "/".to_string() +} + fn default_interval() -> String { "30s".to_string() } @@ -475,6 +505,7 @@ impl AppManifest { validate_security(&self.app.security)?; validate_ports(&self.app.ports)?; + validate_interfaces(&self.app.interfaces)?; validate_environment(&self.app.environment)?; validate_devices(&self.app.devices)?; @@ -638,6 +669,66 @@ fn validate_ports(ports: &[PortMapping]) -> Result<(), ManifestError> { Ok(()) } +fn validate_interfaces(interfaces: &HashMap) -> Result<(), ManifestError> { + let allowed_types = ["ui", "api", "metrics"]; + let allowed_protocols = ["http", "https"]; + for (key, interface) in interfaces { + if !is_valid_interface_key(key) { + return Err(ManifestError::Invalid(format!( + "interfaces key '{key}' must be lowercase ASCII letters, digits, hyphens, or underscores" + ))); + } + if interface.port == 0 { + return Err(ManifestError::Invalid(format!( + "interfaces.{key}.port must be non-zero" + ))); + } + if !allowed_types.contains(&interface.interface_type.as_str()) { + return Err(ManifestError::Invalid(format!( + "interfaces.{key}.type must be one of {}", + allowed_types.join(", ") + ))); + } + if !allowed_protocols.contains(&interface.protocol.as_str()) { + return Err(ManifestError::Invalid(format!( + "interfaces.{key}.protocol must be one of {}", + allowed_protocols.join(", ") + ))); + } + if !interface.path.starts_with('/') || interface.path.chars().any(char::is_control) { + return Err(ManifestError::Invalid(format!( + "interfaces.{key}.path must start with '/' and contain no control characters" + ))); + } + if interface + .name + .as_ref() + .is_some_and(|name| name.trim().is_empty()) + { + return Err(ManifestError::Invalid(format!( + "interfaces.{key}.name cannot be empty when set" + ))); + } + if interface + .description + .as_ref() + .is_some_and(|description| description.trim().is_empty()) + { + return Err(ManifestError::Invalid(format!( + "interfaces.{key}.description cannot be empty when set" + ))); + } + } + Ok(()) +} + +fn is_valid_interface_key(key: &str) -> bool { + !key.is_empty() + && key + .chars() + .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-' || c == '_') +} + fn validate_environment(env: &[String]) -> Result<(), ManifestError> { let mut seen = HashSet::new(); for (i, entry) in env.iter().enumerate() { @@ -921,6 +1012,92 @@ app: assert_eq!(manifest.app.version, "1.0.0"); } + #[test] + fn typed_interfaces_parse_with_defaults() { + let yaml = r#" +app: + id: test-app + name: Test App + version: 1.0.0 + container: + image: test/image:1.0.0 + interfaces: + main: + port: 8080 +"#; + + let manifest = AppManifest::parse(yaml).unwrap(); + let main = manifest.app.interfaces.get("main").unwrap(); + assert_eq!(main.interface_type, "ui"); + assert_eq!(main.port, 8080); + assert_eq!(main.protocol, "http"); + assert_eq!(main.path, "/"); + } + + #[test] + fn invalid_interfaces_are_rejected() { + let cases = [ + ( + "bad key", + r#" +app: + id: test-app + name: Test App + version: 1.0.0 + container: + image: test/image:1.0.0 + interfaces: + Bad Key: + port: 8080 +"#, + "interfaces key", + ), + ( + "bad protocol", + r#" +app: + id: test-app + name: Test App + version: 1.0.0 + container: + image: test/image:1.0.0 + interfaces: + main: + port: 8080 + protocol: ftp +"#, + "interfaces.main.protocol", + ), + ( + "bad path", + r#" +app: + id: test-app + name: Test App + version: 1.0.0 + container: + image: test/image:1.0.0 + interfaces: + main: + port: 8080 + path: dashboard +"#, + "interfaces.main.path", + ), + ]; + + for (name, yaml, expected) in cases { + let err = AppManifest::parse(yaml).unwrap_err(); + let ManifestError::Invalid(msg) = err else { + panic!("{name}: expected invalid manifest, got {err:?}"); + }; + assert!( + msg.contains(expected), + "{name}: expected error containing {expected:?}, got {msg:?}" + ); + } + } + #[test] fn test_manifest_validation() { let yaml = r#" diff --git a/core/container/src/podman_client.rs b/core/container/src/podman_client.rs index dceee65f..2eeef47e 100644 --- a/core/container/src/podman_client.rs +++ b/core/container/src/podman_client.rs @@ -689,26 +689,14 @@ fn manifest_lan_address_for(container_name: &str) -> Option { } fn manifest_primary_interface_url(manifest: &AppManifest) -> Option { - let interfaces = manifest.app.extensions.get("interfaces")?.as_mapping()?; - let main = interfaces - .get(&serde_yaml::Value::String("main".to_string()))? - .as_mapping()?; - let port = main - .get(&serde_yaml::Value::String("port".to_string()))? - .as_i64() - .and_then(|port| u16::try_from(port).ok()) - .filter(|port| *port > 0)?; - let protocol = main - .get(&serde_yaml::Value::String("protocol".to_string())) - .and_then(|v| v.as_str()) - .filter(|protocol| *protocol == "https") - .unwrap_or("http"); - let path = main - .get(&serde_yaml::Value::String("path".to_string())) - .and_then(|v| v.as_str()) - .filter(|path| path.starts_with('/')) - .unwrap_or("/"); - Some(format!("{protocol}://localhost:{port}{path}")) + let main = manifest.app.interfaces.get("main")?; + if main.interface_type != "ui" { + return None; + } + Some(format!( + "{}://localhost:{}{}", + main.protocol, main.port, main.path + )) } fn manifest_has_http_health(manifest: &AppManifest) -> bool { diff --git a/docs/app-developer-guide.md b/docs/app-developer-guide.md index cf08340c..202143cc 100644 --- a/docs/app-developer-guide.md +++ b/docs/app-developer-guide.md @@ -151,6 +151,11 @@ launch URL from the first declared TCP host port when the app has an HTTP health check. TCP-only service ports, such as Bitcoin RPC/P2P, are not treated as UI launch URLs. +Interface keys must use lowercase ASCII letters, digits, hyphens, or +underscores. Supported interface types are `ui`, `api`, and `metrics`; only +`type: ui` is treated as a launchable app surface. Supported protocols are +`http` and `https`, and `path` must start with `/`. + ## Security Requirements These are enforced by the marketplace/catalog pipeline and the node. Non-compliant apps are flagged. diff --git a/neode-ui/src/views/appSession/__tests__/appSessionConfig.test.ts b/neode-ui/src/views/appSession/__tests__/appSessionConfig.test.ts index 186b3654..61d7b46d 100644 --- a/neode-ui/src/views/appSession/__tests__/appSessionConfig.test.ts +++ b/neode-ui/src/views/appSession/__tests__/appSessionConfig.test.ts @@ -33,7 +33,17 @@ describe('appSessionConfig', () => { configurable: true, }) - expect(resolveAppUrl('meshtastic')).toBe('http://192.168.1.228:4403') + expect(resolveAppUrl('did-wallet')).toBe('http://192.168.1.228:8083') + }) + + it('does not treat service-only tcp ports as web launch surfaces', () => { + Object.defineProperty(window, 'location', { + value: { hostname: '192.168.1.228' }, + writable: true, + configurable: true, + }) + + expect(resolveAppUrl('meshtastic')).toBe('') }) it('keeps NetBird on the unified dashboard proxy port', () => { diff --git a/neode-ui/src/views/appSession/generatedAppSessionConfig.ts b/neode-ui/src/views/appSession/generatedAppSessionConfig.ts index 02c59a06..b9929c41 100644 --- a/neode-ui/src/views/appSession/generatedAppSessionConfig.ts +++ b/neode-ui/src/views/appSession/generatedAppSessionConfig.ts @@ -7,7 +7,6 @@ export const GENERATED_APP_PORTS: Record = { "botfights": 9100, "btcpay-server": 23000, "did-wallet": 8083, - "electrumx": 50001, "fedimint": 8175, "filebrowser": 8083, "gitea": 3001, @@ -18,7 +17,6 @@ export const GENERATED_APP_PORTS: Record = { "lnd-ui": 18083, "mempool": 4080, "mempool-api": 8999, - "meshtastic": 4403, "morphos-server": 8086, "nextcloud": 8085, "nostr-rs-relay": 18081, diff --git a/scripts/generate-app-catalog.py b/scripts/generate-app-catalog.py index 9d46f3ab..6ee7a96e 100644 --- a/scripts/generate-app-catalog.py +++ b/scripts/generate-app-catalog.py @@ -68,6 +68,10 @@ def manifest_launch_port(app: dict[str, Any]) -> int | None: if isinstance(port, str) and port.isdigit(): return int(port) + health_check = app.get("health_check") + if not isinstance(health_check, dict) or str(health_check.get("type", "")).lower() != "http": + return None + ports = app.get("ports") if not isinstance(ports, list): return None