diff --git a/build/dpkg-deps/depends b/build/dpkg-deps/depends index b50e5168b..da2012ae2 100644 --- a/build/dpkg-deps/depends +++ b/build/dpkg-deps/depends @@ -55,6 +55,7 @@ socat sqlite3 squashfs-tools squashfs-tools-ng +ssl-cert sudo systemd systemd-resolved diff --git a/build/image-recipe/build.sh b/build/image-recipe/build.sh index 5f48d9b55..7a81e50dc 100755 --- a/build/image-recipe/build.sh +++ b/build/image-recipe/build.sh @@ -41,7 +41,7 @@ if [ "$IB_TARGET_PLATFORM" = "x86_64" ] || [ "$IB_TARGET_PLATFORM" = "x86_64-non elif [ "$IB_TARGET_PLATFORM" = "aarch64" ] || [ "$IB_TARGET_PLATFORM" = "aarch64-nonfree" ] || [ "$IB_TARGET_PLATFORM" = "raspberrypi" ] || [ "$IB_TARGET_PLATFORM" = "rockchip64" ]; then IB_TARGET_ARCH=arm64 QEMU_ARCH=aarch64 -elif [ "$IB_TARGET_PLATFORM" = "riscv64" ]; then +elif [ "$IB_TARGET_PLATFORM" = "riscv64" ] || [ "$IB_TARGET_PLATFORM" = "riscv64-nonfree" ]; then IB_TARGET_ARCH=riscv64 QEMU_ARCH=riscv64 else @@ -205,7 +205,7 @@ cat > config/hooks/normal/9000-install-startos.hook.chroot << EOF set -e -if [ "${NON_FREE}" = "1" ] && [ "${IB_TARGET_PLATFORM}" != "raspberrypi" ]; then +if [ "${NON_FREE}" = "1" ] && [ "${IB_TARGET_PLATFORM}" != "raspberrypi" ] && [ "${IB_TARGET_PLATFORM}" != "riscv64-nonfree" ]; then # install a specific NVIDIA driver version # ---------------- configuration ---------------- diff --git a/container-runtime/src/Adapters/EffectCreator.ts b/container-runtime/src/Adapters/EffectCreator.ts index 44c5d40b2..bcc040f4f 100644 --- a/container-runtime/src/Adapters/EffectCreator.ts +++ b/container-runtime/src/Adapters/EffectCreator.ts @@ -316,6 +316,31 @@ export function makeEffects(context: EffectContext): Effects { T.Effects["setDataVersion"] > }, + plugin: { + url: { + register( + ...[options]: Parameters + ) { + return rpcRound("plugin.url.register", options) as ReturnType< + T.Effects["plugin"]["url"]["register"] + > + }, + exportUrl( + ...[options]: Parameters + ) { + return rpcRound("plugin.url.export-url", options) as ReturnType< + T.Effects["plugin"]["url"]["exportUrl"] + > + }, + clearUrls( + ...[options]: Parameters + ) { + return rpcRound("plugin.url.clear-urls", options) as ReturnType< + T.Effects["plugin"]["url"]["clearUrls"] + > + }, + }, + }, } if (context.callbacks?.onLeaveContext) self.onLeaveContext(() => { diff --git a/container-runtime/src/Adapters/Systems/SystemForEmbassy/DockerProcedureContainer.ts b/container-runtime/src/Adapters/Systems/SystemForEmbassy/DockerProcedureContainer.ts index 799f7dae8..c18829d54 100644 --- a/container-runtime/src/Adapters/Systems/SystemForEmbassy/DockerProcedureContainer.ts +++ b/container-runtime/src/Adapters/Systems/SystemForEmbassy/DockerProcedureContainer.ts @@ -90,7 +90,7 @@ export class DockerProcedureContainer extends Drop { ...new Set( Object.values(hostInfo?.bindings || {}) .flatMap((b) => b.addresses.available) - .map((h) => h.host), + .map((h) => h.hostname), ).values(), ] const certChain = await effects.getSslCertificate({ diff --git a/container-runtime/src/Adapters/Systems/SystemForEmbassy/index.ts b/container-runtime/src/Adapters/Systems/SystemForEmbassy/index.ts index 32376de1c..cfa02c6ac 100644 --- a/container-runtime/src/Adapters/Systems/SystemForEmbassy/index.ts +++ b/container-runtime/src/Adapters/Systems/SystemForEmbassy/index.ts @@ -1245,7 +1245,7 @@ async function updateConfig( : catchFn( () => filled.addressInfo!.filter({ kind: "mdns" })!.hostnames[0] - .host, + .hostname, ) || "" mutConfigValue[key] = url } diff --git a/core/locales/i18n.yaml b/core/locales/i18n.yaml index 4bdfc48ed..4ed144c54 100644 --- a/core/locales/i18n.yaml +++ b/core/locales/i18n.yaml @@ -1243,6 +1243,21 @@ backup.target.cifs.target-not-found-id: fr_FR: "ID de cible de sauvegarde %{id} non trouvé" pl_PL: "Nie znaleziono ID celu kopii zapasowej %{id}" +# service/effects/net/plugin.rs +net.plugin.manifest-missing-plugin: + en_US: "manifest does not declare the \"%{plugin}\" plugin" + de_DE: "Manifest deklariert das Plugin \"%{plugin}\" nicht" + es_ES: "el manifiesto no declara el plugin \"%{plugin}\"" + fr_FR: "le manifeste ne déclare pas le plugin \"%{plugin}\"" + pl_PL: "manifest nie deklaruje wtyczki \"%{plugin}\"" + +net.plugin.binding-not-found: + en_US: "binding not found: %{binding}" + de_DE: "Bindung nicht gefunden: %{binding}" + es_ES: "enlace no encontrado: %{binding}" + fr_FR: "liaison introuvable : %{binding}" + pl_PL: "powiązanie nie znalezione: %{binding}" + # net/ssl.rs net.ssl.unreachable: en_US: "unreachable" diff --git a/core/src/db/model/package.rs b/core/src/db/model/package.rs index 928d1d934..68c9282b4 100644 --- a/core/src/db/model/package.rs +++ b/core/src/db/model/package.rs @@ -383,6 +383,8 @@ pub struct PackageDataEntry { pub store_exposed_dependents: Vec, #[ts(type = "string | null")] pub outbound_gateway: Option, + #[serde(default)] + pub plugin: PackagePlugin, } impl AsRef for PackageDataEntry { fn as_ref(&self) -> &PackageDataEntry { @@ -390,6 +392,21 @@ impl AsRef for PackageDataEntry { } } +#[derive(Debug, Default, Deserialize, Serialize, HasModel, TS)] +#[serde(rename_all = "camelCase")] +#[model = "Model"] +#[ts(export)] +pub struct PackagePlugin { + pub url: Option, +} + +#[derive(Debug, Clone, Deserialize, Serialize, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export)] +pub struct UrlPluginRegistration { + pub table_action: ActionId, +} + #[derive(Debug, Clone, Default, Deserialize, Serialize, TS)] #[ts(export)] pub struct CurrentDependencies(pub BTreeMap); diff --git a/core/src/net/host/binding.rs b/core/src/net/host/binding.rs index bd9bfe766..975c95390 100644 --- a/core/src/net/host/binding.rs +++ b/core/src/net/host/binding.rs @@ -75,7 +75,7 @@ impl DerivedAddressInfo { } else { !self .disabled - .contains(&(h.host.clone(), h.port.unwrap_or_default())) // disablable addresses will always have a port + .contains(&(h.hostname.clone(), h.port.unwrap_or_default())) // disablable addresses will always have a port } }) .collect() @@ -350,7 +350,7 @@ pub async fn set_address_enabled( } else { // Domains and private IPs: toggle via (host, port) in `disabled` set let port = address.port.unwrap_or(if address.ssl { 443 } else { 80 }); - let key = (address.host.clone(), port); + let key = (address.hostname.clone(), port); if enabled { bind.addresses.disabled.remove(&key); } else { diff --git a/core/src/net/host/mod.rs b/core/src/net/host/mod.rs index 160974a72..3454d4735 100644 --- a/core/src/net/host/mod.rs +++ b/core/src/net/host/mod.rs @@ -92,6 +92,14 @@ impl Model { for (_, bind) in this.bindings.as_entries_mut()? { let net = bind.as_net().de()?; let opt = bind.as_options().de()?; + // Preserve existing plugin-provided addresses across recomputation + let plugin_addrs: BTreeSet = bind + .as_addresses() + .as_available() + .de()? + .into_iter() + .filter(|h| matches!(h.metadata, HostnameMetadata::Plugin { .. })) + .collect(); let mut available = BTreeSet::new(); for (gid, g) in gateways { let Some(ip_info) = &g.ip_info else { @@ -117,7 +125,7 @@ impl Model { available.insert(HostnameInfo { ssl: opt.secure.map_or(false, |s| s.ssl), public: false, - host: host.clone(), + hostname: host.clone(), port: Some(port), metadata: metadata.clone(), }); @@ -126,7 +134,7 @@ impl Model { available.insert(HostnameInfo { ssl: true, public: false, - host: host.clone(), + hostname: host.clone(), port: Some(port), metadata, }); @@ -146,7 +154,7 @@ impl Model { available.insert(HostnameInfo { ssl: opt.secure.map_or(false, |s| s.ssl), public: true, - host: host.clone(), + hostname: host.clone(), port: Some(port), metadata: metadata.clone(), }); @@ -155,7 +163,7 @@ impl Model { available.insert(HostnameInfo { ssl: true, public: true, - host: host.clone(), + hostname: host.clone(), port: Some(port), metadata, }); @@ -182,7 +190,7 @@ impl Model { available.insert(HostnameInfo { ssl: opt.secure.map_or(false, |s| s.ssl), public: false, - host: mdns_host.clone(), + hostname: mdns_host.clone(), port: Some(port), metadata: HostnameMetadata::Mdns { gateways: mdns_gateways.clone(), @@ -193,7 +201,7 @@ impl Model { available.insert(HostnameInfo { ssl: true, public: false, - host: mdns_host, + hostname: mdns_host, port: Some(port), metadata: HostnameMetadata::Mdns { gateways: mdns_gateways, @@ -215,7 +223,7 @@ impl Model { available.insert(HostnameInfo { ssl: opt.secure.map_or(false, |s| s.ssl), public: true, - host: domain.clone(), + hostname: domain.clone(), port: Some(port), metadata: metadata.clone(), }); @@ -232,7 +240,7 @@ impl Model { available.insert(HostnameInfo { ssl: true, public: true, - host: domain, + hostname: domain, port: Some(port), metadata, }); @@ -257,7 +265,7 @@ impl Model { available.insert(HostnameInfo { ssl: opt.secure.map_or(false, |s| s.ssl), public: true, - host: domain.clone(), + hostname: domain.clone(), port: Some(port), metadata: HostnameMetadata::PrivateDomain { gateways }, }); @@ -274,7 +282,7 @@ impl Model { available.insert(HostnameInfo { ssl: true, public: true, - host: domain, + hostname: domain, port: Some(port), metadata: HostnameMetadata::PrivateDomain { gateways: domain_gateways, @@ -282,6 +290,7 @@ impl Model { }); } } + available.extend(plugin_addrs); bind.as_addresses_mut().as_available_mut().ser(&available)?; } diff --git a/core/src/net/net_controller.rs b/core/src/net/net_controller.rs index fc34facde..08491630e 100644 --- a/core/src/net/net_controller.rs +++ b/core/src/net/net_controller.rs @@ -277,7 +277,7 @@ impl NetServiceData { | HostnameMetadata::PrivateDomain { .. } => {} _ => continue, } - let domain = &addr_info.host; + let domain = &addr_info.hostname; let domain_ssl_port = addr_info.port.unwrap_or(443); let key = (Some(domain.clone()), domain_ssl_port); let target = vhosts.entry(key).or_insert_with(|| ProxyTarget { diff --git a/core/src/net/service_interface.rs b/core/src/net/service_interface.rs index 48c167bad..ba2feaf1d 100644 --- a/core/src/net/service_interface.rs +++ b/core/src/net/service_interface.rs @@ -1,12 +1,12 @@ use std::collections::BTreeSet; use std::net::SocketAddr; -use imbl_value::{InOMap, InternedString}; +use imbl_value::InternedString; use serde::{Deserialize, Serialize}; use ts_rs::TS; use crate::prelude::*; -use crate::{GatewayId, HostId, PackageId, ServiceInterfaceId}; +use crate::{ActionId, GatewayId, HostId, PackageId, ServiceInterfaceId}; #[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Deserialize, Serialize, TS)] #[ts(export)] @@ -14,7 +14,7 @@ use crate::{GatewayId, HostId, PackageId, ServiceInterfaceId}; pub struct HostnameInfo { pub ssl: bool, pub public: bool, - pub host: InternedString, + pub hostname: InternedString, pub port: Option, pub metadata: HostnameMetadata, } @@ -42,21 +42,22 @@ pub enum HostnameMetadata { gateway: GatewayId, }, Plugin { - package: PackageId, - #[serde(flatten)] - #[ts(skip)] - extra: InOMap, + package_id: PackageId, + row_actions: Vec, + #[ts(type = "unknown")] + #[serde(default)] + info: Value, }, } impl HostnameInfo { pub fn to_socket_addr(&self) -> Option { - let ip = self.host.parse().ok()?; + let ip = self.hostname.parse().ok()?; Some(SocketAddr::new(ip, self.port?)) } pub fn to_san_hostname(&self) -> InternedString { - self.host.clone() + self.hostname.clone() } } @@ -70,14 +71,66 @@ impl HostnameMetadata { Self::Ipv4 { gateway } | Self::Ipv6 { gateway, .. } | Self::PublicDomain { gateway } => Box::new(std::iter::once(gateway)), - Self::PrivateDomain { gateways } | Self::Mdns { gateways } => { - Box::new(gateways.iter()) - } + Self::PrivateDomain { gateways } | Self::Mdns { gateways } => Box::new(gateways.iter()), Self::Plugin { .. } => Box::new(std::iter::empty()), } } } +#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Deserialize, Serialize, TS)] +#[ts(export)] +#[serde(rename_all = "camelCase")] +pub struct PluginHostnameInfo { + pub package_id: Option, + pub host_id: HostId, + pub internal_port: u16, + pub ssl: bool, + pub public: bool, + #[ts(type = "string")] + pub hostname: InternedString, + pub port: Option, + #[ts(type = "unknown")] + #[serde(default)] + pub info: Value, +} + +impl PluginHostnameInfo { + /// Convert to a `HostnameInfo` with `Plugin` metadata, using the given plugin package ID. + pub fn to_hostname_info( + &self, + plugin_package: &PackageId, + row_actions: Vec, + ) -> HostnameInfo { + HostnameInfo { + ssl: self.ssl, + public: self.public, + hostname: self.hostname.clone(), + port: self.port, + metadata: HostnameMetadata::Plugin { + package_id: plugin_package.clone(), + info: self.info.clone(), + row_actions, + }, + } + } + + /// Check if a `HostnameInfo` with Plugin metadata matches this `PluginHostnameInfo` + /// (comparing address fields only, not row_actions). + pub fn matches_hostname_info(&self, h: &HostnameInfo, plugin_package: &PackageId) -> bool { + match &h.metadata { + HostnameMetadata::Plugin { package_id, info, .. } => { + package_id == plugin_package + && h.ssl == self.ssl + && h.public == self.public + && h.hostname == self.hostname + && h.port == self.port + && *info == self.info + } + _ => false, + } + } +} + #[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Deserialize, Serialize, TS)] #[ts(export)] #[serde(rename_all = "camelCase")] diff --git a/core/src/registry/package/get.rs b/core/src/registry/package/get.rs index 8e270d285..3adf4431d 100644 --- a/core/src/registry/package/get.rs +++ b/core/src/registry/package/get.rs @@ -602,6 +602,7 @@ fn check_matching_info_short() { os_version: exver::Version::new([0, 3, 6], []), sdk_version: None, hardware_acceleration: false, + plugins: BTreeSet::new(), }, icon: DataUrl::from_vec("image/png", vec![]), dependency_metadata: BTreeMap::new(), diff --git a/core/src/registry/package/index.rs b/core/src/registry/package/index.rs index 40e05c326..bf88bede2 100644 --- a/core/src/registry/package/index.rs +++ b/core/src/registry/package/index.rs @@ -10,6 +10,7 @@ use ts_rs::TS; use url::Url; use crate::PackageId; +use crate::service::effects::plugin::PluginId; use crate::prelude::*; use crate::registry::asset::RegistryAsset; use crate::registry::context::RegistryContext; @@ -107,6 +108,8 @@ pub struct PackageMetadata { pub sdk_version: Option, #[serde(default)] pub hardware_acceleration: bool, + #[serde(default)] + pub plugins: BTreeSet, } #[derive(Debug, Deserialize, Serialize, HasModel, TS)] diff --git a/core/src/s9pk/v2/compat.rs b/core/src/s9pk/v2/compat.rs index 486142031..9b0add0bf 100644 --- a/core/src/s9pk/v2/compat.rs +++ b/core/src/s9pk/v2/compat.rs @@ -218,6 +218,7 @@ impl TryFrom for Manifest { PackageProcedure::Docker(d) => d.gpu_acceleration, PackageProcedure::Script(_) => false, }, + plugins: BTreeSet::new(), }, images: BTreeMap::new(), volumes: value diff --git a/core/src/service/effects/mod.rs b/core/src/service/effects/mod.rs index 779a427ec..07faaf4af 100644 --- a/core/src/service/effects/mod.rs +++ b/core/src/service/effects/mod.rs @@ -14,6 +14,7 @@ mod control; mod dependency; mod health; mod net; +pub mod plugin; mod prelude; pub mod subcontainer; mod system; @@ -167,6 +168,26 @@ pub fn handler() -> ParentHandler { from_fn_async(net::ssl::get_ssl_certificate).no_cli(), ) .subcommand("get-ssl-key", from_fn_async(net::ssl::get_ssl_key).no_cli()) + // plugin + .subcommand( + "plugin", + ParentHandler::::new().subcommand( + "url", + ParentHandler::::new() + .subcommand( + "register", + from_fn_async(net::plugin::register).no_cli(), + ) + .subcommand( + "export-url", + from_fn_async(net::plugin::export_url).no_cli(), + ) + .subcommand( + "clear-urls", + from_fn_async(net::plugin::clear_urls).no_cli(), + ), + ), + ) .subcommand( "set-data-version", from_fn_async(version::set_data_version) diff --git a/core/src/service/effects/net/mod.rs b/core/src/service/effects/net/mod.rs index cf13451a6..3d8ca091b 100644 --- a/core/src/service/effects/net/mod.rs +++ b/core/src/service/effects/net/mod.rs @@ -2,4 +2,5 @@ pub mod bind; pub mod host; pub mod info; pub mod interface; +pub mod plugin; pub mod ssl; diff --git a/core/src/service/effects/net/plugin.rs b/core/src/service/effects/net/plugin.rs new file mode 100644 index 000000000..713fbf705 --- /dev/null +++ b/core/src/service/effects/net/plugin.rs @@ -0,0 +1,164 @@ +use std::collections::BTreeSet; +use std::sync::Arc; + +use crate::ActionId; +use crate::net::host::{all_hosts, host_for}; +use crate::net::service_interface::{HostnameMetadata, PluginHostnameInfo}; +use crate::service::Service; +use crate::service::effects::plugin::PluginId; +use crate::service::effects::prelude::*; + +fn require_url_plugin(context: &Arc) -> Result<(), Error> { + if !context + .seed + .persistent_container + .s9pk + .as_manifest() + .metadata + .plugins + .contains(&PluginId::UrlV0) + { + return Err(Error::new( + eyre!("{}", t!("net.plugin.manifest-missing-plugin", plugin = "url-v0")), + ErrorKind::InvalidRequest, + )); + } + Ok(()) +} + +#[derive(Debug, Clone, Serialize, Deserialize, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export)] +pub struct UrlPluginRegisterParams { + pub table_action: ActionId, +} + +pub async fn register( + context: EffectContext, + UrlPluginRegisterParams { table_action }: UrlPluginRegisterParams, +) -> Result<(), Error> { + use crate::db::model::package::UrlPluginRegistration; + + let context = context.deref()?; + require_url_plugin(&context)?; + let plugin_id = context.seed.id.clone(); + + context + .seed + .ctx + .db + .mutate(|db| { + db.as_public_mut() + .as_package_data_mut() + .as_idx_mut(&plugin_id) + .or_not_found(&plugin_id)? + .as_plugin_mut() + .as_url_mut() + .ser(&Some(UrlPluginRegistration { table_action }))?; + Ok(()) + }) + .await + .result?; + + Ok(()) +} + +#[derive(Debug, Clone, Serialize, Deserialize, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export)] +pub struct UrlPluginExportUrlParams { + pub hostname_info: PluginHostnameInfo, + pub row_actions: Vec, +} + +pub async fn export_url( + context: EffectContext, + UrlPluginExportUrlParams { + hostname_info, + row_actions, + }: UrlPluginExportUrlParams, +) -> Result<(), Error> { + let context = context.deref()?; + require_url_plugin(&context)?; + let plugin_id = context.seed.id.clone(); + + let entry = hostname_info.to_hostname_info(&plugin_id, row_actions); + + context + .seed + .ctx + .db + .mutate(|db| { + let host = host_for(db, hostname_info.package_id.as_ref(), &hostname_info.host_id)?; + host.as_bindings_mut() + .as_idx_mut(&hostname_info.internal_port) + .or_not_found(t!("net.plugin.binding-not-found", binding = format!( + "{}:{}:{}", + hostname_info.package_id.as_deref().unwrap_or("STARTOS"), + hostname_info.host_id, + hostname_info.internal_port + )))? + .as_addresses_mut() + .as_available_mut() + .mutate(|available: &mut BTreeSet<_>| { + available.insert(entry); + Ok(()) + })?; + Ok(()) + }) + .await + .result?; + + Ok(()) +} + +#[derive(Debug, Clone, Serialize, Deserialize, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export)] +pub struct UrlPluginClearUrlsParams { + pub except: BTreeSet, +} + +pub async fn clear_urls( + context: EffectContext, + UrlPluginClearUrlsParams { except }: UrlPluginClearUrlsParams, +) -> Result<(), Error> { + let context = context.deref()?; + require_url_plugin(&context)?; + let plugin_id = context.seed.id.clone(); + + context + .seed + .ctx + .db + .mutate(|db| { + for host in all_hosts(db) { + let host = host?; + for (_, bind) in host.as_bindings_mut().as_entries_mut()? { + bind.as_addresses_mut().as_available_mut().mutate( + |available: &mut BTreeSet<_>| { + available.retain(|h| { + match &h.metadata { + HostnameMetadata::Plugin { package_id, .. } + if package_id == &plugin_id => + { + // Keep if it matches any entry in the except list + except + .iter() + .any(|e| e.matches_hostname_info(h, &plugin_id)) + } + _ => true, + } + }); + Ok(()) + }, + )?; + } + } + Ok(()) + }) + .await + .result?; + + Ok(()) +} diff --git a/core/src/service/effects/plugin.rs b/core/src/service/effects/plugin.rs new file mode 100644 index 000000000..76f33e6e5 --- /dev/null +++ b/core/src/service/effects/plugin.rs @@ -0,0 +1,9 @@ +use serde::{Deserialize, Serialize}; +use ts_rs::TS; + +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize, TS)] +#[serde(rename_all = "kebab-case")] +#[ts(export)] +pub enum PluginId { + UrlV0, +} diff --git a/core/src/service/service_map.rs b/core/src/service/service_map.rs index 56292baba..697578a7c 100644 --- a/core/src/service/service_map.rs +++ b/core/src/service/service_map.rs @@ -260,6 +260,7 @@ impl ServiceMap { hosts: Default::default(), store_exposed_dependents: Default::default(), outbound_gateway: None, + plugin: Default::default(), }, )?; }; diff --git a/core/src/service/uninstall.rs b/core/src/service/uninstall.rs index a924e8cb0..c979658e1 100644 --- a/core/src/service/uninstall.rs +++ b/core/src/service/uninstall.rs @@ -1,9 +1,12 @@ +use std::collections::BTreeSet; use std::path::Path; use imbl::vector; use crate::context::RpcContext; use crate::db::model::package::{InstalledState, InstallingInfo, InstallingState, PackageState}; +use crate::net::host::all_hosts; +use crate::net::service_interface::{HostnameInfo, HostnameMetadata}; use crate::prelude::*; use crate::volume::PKG_VOLUME_DIR; use crate::{DATA_DIR, PACKAGE_DATA, PackageId}; @@ -36,6 +39,24 @@ pub async fn cleanup(ctx: &RpcContext, id: &PackageId, soft: bool) -> Result<(), Ok(()) })?; d.as_private_mut().as_package_stores_mut().remove(&id)?; + // Remove plugin URLs exported by this package from all hosts + for host in all_hosts(d) { + let host = host?; + for (_, bind) in host.as_bindings_mut().as_entries_mut()? { + bind.as_addresses_mut() + .as_available_mut() + .mutate(|available: &mut BTreeSet| { + available.retain(|h| { + !matches!( + &h.metadata, + HostnameMetadata::Plugin { package_id, .. } + if package_id == id + ) + }); + Ok(()) + })?; + } + } Ok(Some(pde)) } else { Ok(None) diff --git a/docs/TODO.md b/docs/TODO.md index 500e3d439..3b8a2a44b 100644 --- a/docs/TODO.md +++ b/docs/TODO.md @@ -2,175 +2,8 @@ Pending tasks for AI agents. Remove items when completed. -## Unreviewed CLAUDE.md Sections - -- [ ] Architecture - Web (`/web`) - @MattDHill - ## Features -- [ ] Support preferred external ports besides 443 - @dr-bonez - - **Problem**: Currently, port 443 is the only preferred external port that is actually honored. When a - service requests `preferred_external_port: 8443` (or any non-443 value) for SSL, the system ignores - the preference and assigns a dynamic-range port (49152-65535). The `preferred_external_port` is only - used as a label for Tor mappings and as a trigger for the port-443 special case in `update()`. - - **Goal**: Honor `preferred_external_port` for both SSL and non-SSL binds when the requested port is - available, with proper conflict resolution and fallback to dynamic-range allocation. - - ### Design - - **Key distinction**: There are two separate concepts for SSL port usage: - 1. **Port ownership** (`assigned_ssl_port`) — A port exclusively owned by a binding, allocated from - `AvailablePorts`. Used for server hostnames (`.local`, mDNS, etc.) and iptables forwards. - 2. **Domain SSL port** — The port used for domain-based vhost entries. A binding does NOT need to own - a port to have a domain vhost on it. The VHostController already supports multiple hostnames on the - same port via SNI. Any binding can create a domain vhost entry on any SSL port that the - VHostController has a listener for, regardless of who "owns" that port. - - For example: the OS owns port 443 as its `assigned_ssl_port`. A service with - `preferred_external_port: 443` won't get 443 as its `assigned_ssl_port` (it's taken), but it CAN - still have domain vhost entries on port 443 — SNI routes by hostname. - - #### 1. Preferred Port Allocation for Ownership ✅ DONE - - `AvailablePorts::try_alloc(port) -> Option` added to `forward.rs`. `BindInfo::new()` and - `BindInfo::update()` attempt the preferred port first, falling back to dynamic-range allocation. - - #### 2. Per-Address Enable/Disable ✅ DONE - - Gateway-level `private_disabled`/`public_enabled` on `NetInfo` replaced with per-address - `DerivedAddressInfo` on `BindInfo`. `hostname_info` removed from `Host` — computed addresses now - live in `BindInfo.addresses.possible`. - - **`DerivedAddressInfo` struct** (on `BindInfo`): - - ```rust - pub struct DerivedAddressInfo { - pub private_disabled: BTreeSet, - pub public_enabled: BTreeSet, - pub possible: BTreeSet, // COMPUTED by update() - } - ``` - - `DerivedAddressInfo::enabled()` returns `possible` filtered by the two sets. `HostnameInfo` derives - `Ord` for `BTreeSet` usage. `AddressFilter` (implementing `InterfaceFilter`) derives enabled - gateway set from `DerivedAddressInfo` for vhost/forward filtering. - - **RPC endpoint**: `set-gateway-enabled` replaced with `set-address-enabled` (on both - `server.host.binding` and `package.host.binding`). - - **How disabling works per address type** (enforcement deferred to Section 3): - - **WAN/LAN IP:port**: Will be enforced via **source-IP gating** in the vhost layer (Section 3). - - **Hostname-based addresses** (`.local`, domains): Disabled by **not creating the vhost/SNI - entry** for that hostname. - - #### 3. Eliminate the Port 5443 Hack: Source-IP-Based WAN Blocking (`vhost.rs`, `net_controller.rs`) - - **Current problem**: The `if ssl.preferred_external_port == 443` branch (line 341 of - `net_controller.rs`) creates a bespoke dual-vhost setup: port 5443 for private-only access and port - 443 for public (or public+private). This exists because both public and private traffic arrive on the - same port 443 listener, and the current `InterfaceFilter`/`PublicFilter` model distinguishes - public/private by which _network interface_ the connection arrived on — which doesn't work when both - traffic types share a listener. - - **Solution**: Determine public vs private based on **source IP** at the vhost level. Traffic arriving - from the gateway IP should be treated as public (the gateway may MASQUERADE/NAT internet traffic, so - anything from the gateway is potentially public). Traffic from LAN IPs is private. - - This applies to **all** vhost targets, not just port 443: - - **Add a `public` field to `ProxyTarget`** (or an enum: `Public`, `Private`, `Both`) indicating - what traffic this target accepts, derived from the binding's user-controlled `public` field. - - **Modify `VHostTarget::filter()`** (`vhost.rs:342`): Instead of (or in addition to) checking the - network interface via `GatewayInfo`, check the source IP of the TCP connection against known gateway - IPs. If the source IP matches a gateway or IP outside the subnet, the connection is public; - otherwise it's private. Use this to gate against the target's `public` field. - - **Eliminate the 5443 port entirely**: A single vhost entry on port 443 (or any shared SSL port) can - serve both public and private traffic, with per-target source-IP gating determining which backend - handles which connections. - - #### 4. Port Forward Mapping in Patch-DB - - When a binding is marked `public = true`, StartOS must record the required port forwards in patch-db - so the frontend can display them to the user. The user then configures these on their router manually. - - For each public binding, store: - - The external port the router should forward (the actual vhost port used for domains, or the - `assigned_port` / `assigned_ssl_port` for non-domain access) - - The protocol (TCP/UDP) - - The StartOS LAN IP as the forward target - - Which service/binding this forward is for (for display purposes) - - This mapping should be in the public database model so the frontend can read and display it. - - #### 5. Simplify `update()` Domain Vhost Logic (`net_controller.rs`) - - With source-IP gating in the vhost controller: - - **Remove the `== 443` special case** and the 5443 secondary vhost. - - For **server hostnames** (`.local`, mDNS, embassy, startos, localhost): use `assigned_ssl_port` - (the port the binding owns). - - For **domain-based vhost entries**: attempt to use `preferred_external_port` as the vhost port. - This succeeds if the port is either unused or already has an SSL listener (SNI handles sharing). - It fails only if the port is already in use by a non-SSL binding, or is a restricted port. On - failure, fall back to `assigned_ssl_port`. - - The binding's `public` field determines the `ProxyTarget`'s public/private gating. - - Hostname info must exactly match the actual vhost port used: for server hostnames, report - `ssl_port: assigned_ssl_port`. For domains, report `ssl_port: preferred_external_port` if it was - successfully used for the domain vhost, otherwise report `ssl_port: assigned_ssl_port`. - - #### 6. Reachability Test Endpoint - - New RPC endpoint that tests whether an address is actually reachable, with diagnostic info on - failure. - - **RPC endpoint** (`binding.rs` or new file): - - **`test-address`** — Test reachability of a specific address. - - ```ts - interface BindingTestAddressParams { - internalPort: number; - address: HostnameInfo; - } - ``` - - The backend simply performs the raw checks and returns the results. The **frontend** owns all - interpretation — it already knows the address type, expected IP, expected port, etc. from the - `HostnameInfo` data, so it can compare against the backend results and construct fix messaging. - - ```ts - interface TestAddressResult { - dns: string[] | null; // resolved IPs, null if not a domain address or lookup failed - portOpen: boolean | null; // TCP connect result, null if not applicable - } - ``` - - This yields two RPC methods: - - `server.host.binding.test-address` - - `package.host.binding.test-address` - - The frontend already has the full `HostnameInfo` context (expected IP, domain, port, gateway, - public/private). It compares the backend's raw results against the expected state and constructs - localized fix instructions. For example: - - `dns` returned but doesn't contain the expected WAN IP → "Update DNS A record for {domain} - to {wanIp}" - - `dns` is `null` for a domain address → "DNS lookup failed for {domain}" - - `portOpen` is `false` → "Configure port forward on your router: external {port} TCP → - {lanIp}:{port}" - - ### Key Files - - | File | Role | - | ------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------- | - | `core/src/net/forward.rs` | `AvailablePorts` — port pool allocation, `try_alloc()` for preferred ports | - | `core/src/net/host/binding.rs` | `Bindings` (Map wrapper for patchdb), `BindInfo`/`NetInfo`/`DerivedAddressInfo`/`AddressFilter` — per-address enable/disable, `set-address-enabled` RPC | - | `core/src/net/net_controller.rs:259` | `NetServiceData::update()` — computes `DerivedAddressInfo.possible`, vhost/forward/DNS reconciliation, 5443 hack removal | - | `core/src/net/vhost.rs` | `VHostController` / `ProxyTarget` — source-IP gating for public/private | - | `core/src/net/gateway.rs` | `InterfaceFilter` trait and filter types (`AddressFilter`, `PublicFilter`, etc.) | - | `core/src/net/service_interface.rs` | `HostnameInfo` — derives `Ord` for `BTreeSet` usage | - | `core/src/net/host/address.rs` | `HostAddress` (flattened struct), domain CRUD endpoints | - | `sdk/base/lib/interfaces/Host.ts` | SDK `MultiHost.bindPort()` — no changes needed | - | `core/src/db/model/public.rs` | Public DB model — port forward mapping | - - [ ] Extract TS-exported types into a lightweight sub-crate for fast binding generation **Problem**: `make ts-bindings` compiles the entire `start-os` crate (with all dependencies: tokio, @@ -206,9 +39,6 @@ Pending tasks for AI agents. Remove items when completed. - [ ] Auto-configure port forwards via UPnP/NAT-PMP/PCP - @dr-bonez - **Blocked by**: "Support preferred external ports besides 443" (must be implemented and tested - end-to-end first). - **Goal**: When a binding is marked public, automatically configure port forwards on the user's router using UPnP, NAT-PMP, or PCP, instead of requiring manual router configuration. Fall back to displaying manual instructions (the port forward mapping from patch-db) when auto-configuration is diff --git a/sdk/base/lib/Effects.ts b/sdk/base/lib/Effects.ts index b27c14728..baa03c082 100644 --- a/sdk/base/lib/Effects.ts +++ b/sdk/base/lib/Effects.ts @@ -16,6 +16,7 @@ import { MountParams, StatusInfo, Manifest, + HostnameInfo, } from './osBindings' import { PackageId, @@ -23,6 +24,7 @@ import { ServiceInterfaceId, SmtpValue, ActionResult, + PluginHostnameInfo, } from './types' /** Used to reach out from the pure js runtime */ @@ -151,6 +153,17 @@ export type Effects = { clearServiceInterfaces(options: { except: ServiceInterfaceId[] }): Promise + + plugin: { + url: { + register(options: { tableAction: ActionId }): Promise + exportUrl(options: { + hostnameInfo: PluginHostnameInfo + rowActions: ActionId[] + }): Promise + clearUrls(options: { except: PluginHostnameInfo[] }): Promise + } + } // ssl /** Returns a PEM encoded fullchain for the hostnames specified */ getSslCertificate: (options: { diff --git a/sdk/base/lib/interfaces/setupExportedUrls.ts b/sdk/base/lib/interfaces/setupExportedUrls.ts new file mode 100644 index 000000000..f090747de --- /dev/null +++ b/sdk/base/lib/interfaces/setupExportedUrls.ts @@ -0,0 +1,28 @@ +import { Effects, PluginHostnameInfo } from '../types' + +export type SetExportedUrls = (opts: { effects: Effects }) => Promise +export type UpdateExportedUrls = (effects: Effects) => Promise +export type SetupExportedUrls = (fn: SetExportedUrls) => UpdateExportedUrls + +export const setupExportedUrls: SetupExportedUrls = (fn: SetExportedUrls) => { + return (async (effects: Effects) => { + const urls: PluginHostnameInfo[] = [] + await fn({ + effects: { + ...effects, + plugin: { + ...effects.plugin, + url: { + ...effects.plugin.url, + exportUrl: (params) => { + urls.push(params.hostnameInfo) + return effects.plugin.url.exportUrl(params) + }, + }, + }, + }, + }) + await effects.plugin.url.clearUrls({ except: urls }) + return null + }) as UpdateExportedUrls +} diff --git a/sdk/base/lib/osBindings/HostnameInfo.ts b/sdk/base/lib/osBindings/HostnameInfo.ts index f38a0e908..ff08c833d 100644 --- a/sdk/base/lib/osBindings/HostnameInfo.ts +++ b/sdk/base/lib/osBindings/HostnameInfo.ts @@ -4,7 +4,7 @@ import type { HostnameMetadata } from './HostnameMetadata' export type HostnameInfo = { ssl: boolean public: boolean - host: string + hostname: string port: number | null metadata: HostnameMetadata } diff --git a/sdk/base/lib/osBindings/HostnameMetadata.ts b/sdk/base/lib/osBindings/HostnameMetadata.ts index c5d929730..fbd3cdd73 100644 --- a/sdk/base/lib/osBindings/HostnameMetadata.ts +++ b/sdk/base/lib/osBindings/HostnameMetadata.ts @@ -1,4 +1,5 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { ActionId } from './ActionId' import type { GatewayId } from './GatewayId' import type { PackageId } from './PackageId' @@ -8,4 +9,9 @@ export type HostnameMetadata = | { kind: 'mdns'; gateways: Array } | { kind: 'private-domain'; gateways: Array } | { kind: 'public-domain'; gateway: GatewayId } - | { kind: 'plugin'; package: PackageId } + | { + kind: 'plugin' + packageId: PackageId + rowActions: Array + info: unknown + } diff --git a/sdk/base/lib/osBindings/Manifest.ts b/sdk/base/lib/osBindings/Manifest.ts index 2f267f250..c962425eb 100644 --- a/sdk/base/lib/osBindings/Manifest.ts +++ b/sdk/base/lib/osBindings/Manifest.ts @@ -8,6 +8,7 @@ import type { ImageConfig } from './ImageConfig' import type { ImageId } from './ImageId' import type { LocaleString } from './LocaleString' import type { PackageId } from './PackageId' +import type { PluginId } from './PluginId' import type { Version } from './Version' import type { VolumeId } from './VolumeId' @@ -35,4 +36,5 @@ export type Manifest = { osVersion: string sdkVersion: string | null hardwareAcceleration: boolean + plugins: Array } diff --git a/sdk/base/lib/osBindings/PackageDataEntry.ts b/sdk/base/lib/osBindings/PackageDataEntry.ts index a0ea2fff7..ac219040e 100644 --- a/sdk/base/lib/osBindings/PackageDataEntry.ts +++ b/sdk/base/lib/osBindings/PackageDataEntry.ts @@ -4,6 +4,7 @@ import type { ActionMetadata } from './ActionMetadata' import type { CurrentDependencies } from './CurrentDependencies' import type { DataUrl } from './DataUrl' import type { Hosts } from './Hosts' +import type { PackagePlugin } from './PackagePlugin' import type { PackageState } from './PackageState' import type { ReplayId } from './ReplayId' import type { ServiceInterface } from './ServiceInterface' @@ -26,4 +27,5 @@ export type PackageDataEntry = { hosts: Hosts storeExposedDependents: string[] outboundGateway: string | null + plugin: PackagePlugin } diff --git a/sdk/base/lib/osBindings/PackagePlugin.ts b/sdk/base/lib/osBindings/PackagePlugin.ts new file mode 100644 index 000000000..2a72784f1 --- /dev/null +++ b/sdk/base/lib/osBindings/PackagePlugin.ts @@ -0,0 +1,4 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { UrlPluginRegistration } from './UrlPluginRegistration' + +export type PackagePlugin = { url: UrlPluginRegistration | null } diff --git a/sdk/base/lib/osBindings/PackageVersionInfo.ts b/sdk/base/lib/osBindings/PackageVersionInfo.ts index 60f5de9fd..00a3f3052 100644 --- a/sdk/base/lib/osBindings/PackageVersionInfo.ts +++ b/sdk/base/lib/osBindings/PackageVersionInfo.ts @@ -8,6 +8,7 @@ import type { HardwareRequirements } from './HardwareRequirements' import type { LocaleString } from './LocaleString' import type { MerkleArchiveCommitment } from './MerkleArchiveCommitment' import type { PackageId } from './PackageId' +import type { PluginId } from './PluginId' import type { RegistryAsset } from './RegistryAsset' export type PackageVersionInfo = { @@ -29,4 +30,5 @@ export type PackageVersionInfo = { osVersion: string sdkVersion: string | null hardwareAcceleration: boolean + plugins: Array } diff --git a/sdk/base/lib/osBindings/PluginHostnameInfo.ts b/sdk/base/lib/osBindings/PluginHostnameInfo.ts new file mode 100644 index 000000000..46117fa81 --- /dev/null +++ b/sdk/base/lib/osBindings/PluginHostnameInfo.ts @@ -0,0 +1,14 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { HostId } from './HostId' +import type { PackageId } from './PackageId' + +export type PluginHostnameInfo = { + packageId: PackageId | null + hostId: HostId + internalPort: number + ssl: boolean + public: boolean + hostname: string + port: number | null + info: unknown +} diff --git a/sdk/base/lib/osBindings/PluginId.ts b/sdk/base/lib/osBindings/PluginId.ts new file mode 100644 index 000000000..9de22068e --- /dev/null +++ b/sdk/base/lib/osBindings/PluginId.ts @@ -0,0 +1,3 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +export type PluginId = 'url-v0' diff --git a/sdk/base/lib/osBindings/UrlPluginClearUrlsParams.ts b/sdk/base/lib/osBindings/UrlPluginClearUrlsParams.ts new file mode 100644 index 000000000..72255710d --- /dev/null +++ b/sdk/base/lib/osBindings/UrlPluginClearUrlsParams.ts @@ -0,0 +1,4 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { PluginHostnameInfo } from './PluginHostnameInfo' + +export type UrlPluginClearUrlsParams = { except: Array } diff --git a/sdk/base/lib/osBindings/UrlPluginExportUrlParams.ts b/sdk/base/lib/osBindings/UrlPluginExportUrlParams.ts new file mode 100644 index 000000000..94a69db38 --- /dev/null +++ b/sdk/base/lib/osBindings/UrlPluginExportUrlParams.ts @@ -0,0 +1,8 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { ActionId } from './ActionId' +import type { PluginHostnameInfo } from './PluginHostnameInfo' + +export type UrlPluginExportUrlParams = { + hostnameInfo: PluginHostnameInfo + rowActions: Array +} diff --git a/sdk/base/lib/osBindings/UrlPluginRegisterParams.ts b/sdk/base/lib/osBindings/UrlPluginRegisterParams.ts new file mode 100644 index 000000000..1a6e3e41b --- /dev/null +++ b/sdk/base/lib/osBindings/UrlPluginRegisterParams.ts @@ -0,0 +1,4 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { ActionId } from './ActionId' + +export type UrlPluginRegisterParams = { tableAction: ActionId } diff --git a/sdk/base/lib/osBindings/UrlPluginRegistration.ts b/sdk/base/lib/osBindings/UrlPluginRegistration.ts new file mode 100644 index 000000000..03f03a11e --- /dev/null +++ b/sdk/base/lib/osBindings/UrlPluginRegistration.ts @@ -0,0 +1,4 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { ActionId } from './ActionId' + +export type UrlPluginRegistration = { tableAction: ActionId } diff --git a/sdk/base/lib/osBindings/index.ts b/sdk/base/lib/osBindings/index.ts index c1ab84df2..57fbb9352 100644 --- a/sdk/base/lib/osBindings/index.ts +++ b/sdk/base/lib/osBindings/index.ts @@ -197,6 +197,7 @@ export { PackageId } from './PackageId' export { PackageIndex } from './PackageIndex' export { PackageInfoShort } from './PackageInfoShort' export { PackageInfo } from './PackageInfo' +export { PackagePlugin } from './PackagePlugin' export { PackageState } from './PackageState' export { PackageVersionInfo } from './PackageVersionInfo' export { PartitionInfo } from './PartitionInfo' @@ -204,6 +205,8 @@ export { PasswordType } from './PasswordType' export { PathOrUrl } from './PathOrUrl' export { Pem } from './Pem' export { Percentage } from './Percentage' +export { PluginHostnameInfo } from './PluginHostnameInfo' +export { PluginId } from './PluginId' export { PortForward } from './PortForward' export { Progress } from './Progress' export { ProgressUnits } from './ProgressUnits' @@ -285,6 +288,10 @@ export { TimeInfo } from './TimeInfo' export { UmountParams } from './UmountParams' export { UninstallParams } from './UninstallParams' export { UpdatingState } from './UpdatingState' +export { UrlPluginClearUrlsParams } from './UrlPluginClearUrlsParams' +export { UrlPluginExportUrlParams } from './UrlPluginExportUrlParams' +export { UrlPluginRegisterParams } from './UrlPluginRegisterParams' +export { UrlPluginRegistration } from './UrlPluginRegistration' export { VerifyCifsParams } from './VerifyCifsParams' export { VersionSignerParams } from './VersionSignerParams' export { Version } from './Version' diff --git a/sdk/base/lib/test/startosTypeValidation.test.ts b/sdk/base/lib/test/startosTypeValidation.test.ts index eede4d669..52da23a7e 100644 --- a/sdk/base/lib/test/startosTypeValidation.test.ts +++ b/sdk/base/lib/test/startosTypeValidation.test.ts @@ -30,6 +30,9 @@ import { ExportServiceInterfaceParams } from '.././osBindings' import { ListServiceInterfacesParams } from '.././osBindings' import { ExportActionParams } from '.././osBindings' import { MountParams } from '.././osBindings' +import { UrlPluginRegisterParams } from '.././osBindings' +import { UrlPluginExportUrlParams } from '.././osBindings' +import { UrlPluginClearUrlsParams } from '.././osBindings' import { StringObject } from '../util' import { ExtendedVersion, VersionRange } from '../exver' function typeEquality(_a: ExpectedType) {} @@ -90,6 +93,13 @@ describe('startosTypeValidation ', () => { getDependencies: undefined, getStatus: {} as WithCallback, setMainStatus: {} as SetMainStatus, + plugin: { + url: { + register: {} as UrlPluginRegisterParams, + exportUrl: {} as UrlPluginExportUrlParams, + clearUrls: {} as UrlPluginClearUrlsParams, + }, + }, }) }) }) diff --git a/sdk/base/lib/types/ManifestTypes.ts b/sdk/base/lib/types/ManifestTypes.ts index 33b5bd7e7..4ba167430 100644 --- a/sdk/base/lib/types/ManifestTypes.ts +++ b/sdk/base/lib/types/ManifestTypes.ts @@ -147,6 +147,11 @@ export type SDKManifest = { * @description Enable access to hardware acceleration devices (such as /dev/dri, or /dev/nvidia*) */ readonly hardwareAcceleration?: boolean + + /** + * @description Enable OS plugins + */ + readonly plugins?: T.PluginId[] } // this is hacky but idk a more elegant way diff --git a/sdk/base/lib/util/getServiceInterface.ts b/sdk/base/lib/util/getServiceInterface.ts index 82510bb04..7d6975fcb 100644 --- a/sdk/base/lib/util/getServiceInterface.ts +++ b/sdk/base/lib/util/getServiceInterface.ts @@ -154,11 +154,11 @@ export const addressHostToUrl = ( const effectiveScheme = hostname.ssl ? sslScheme : scheme let host: string if (hostname.metadata.kind === 'ipv6') { - host = IPV6_LINK_LOCAL.contains(hostname.host) - ? `[${hostname.host}%${hostname.metadata.scopeId}]` - : `[${hostname.host}]` + host = IPV6_LINK_LOCAL.contains(hostname.hostname) + ? `[${hostname.hostname}%${hostname.metadata.scopeId}]` + : `[${hostname.hostname}]` } else { - host = hostname.host + host = hostname.hostname } let portStr = '' if (hostname.port !== null) { @@ -206,10 +206,10 @@ function filterRec( (kind.has('ipv4') && h.metadata.kind === 'ipv4') || (kind.has('ipv6') && h.metadata.kind === 'ipv6') || (kind.has('localhost') && - ['localhost', '127.0.0.1', '::1'].includes(h.host)) || + ['localhost', '127.0.0.1', '::1'].includes(h.hostname)) || (kind.has('link-local') && h.metadata.kind === 'ipv6' && - IPV6_LINK_LOCAL.contains(IpAddress.parse(h.host)))), + IPV6_LINK_LOCAL.contains(IpAddress.parse(h.hostname)))), ) } @@ -229,13 +229,13 @@ function enabledAddresses(addr: DerivedAddressInfo): HostnameInfo[] { if (h.port === null) return true const sa = h.metadata.kind === 'ipv6' - ? `[${h.host}]:${h.port}` - : `${h.host}:${h.port}` + ? `[${h.hostname}]:${h.port}` + : `${h.hostname}:${h.port}` return addr.enabled.includes(sa) } else { - // Everything else: enabled by default, explicitly disabled via [host, port] tuple + // Everything else: enabled by default, explicitly disabled via [hostname, port] tuple return !addr.disabled.some( - ([host, port]) => host === h.host && port === (h.port ?? 0), + ([hostname, port]) => hostname === h.hostname && port === (h.port ?? 0), ) } }) diff --git a/sdk/package/lib/StartSdk.ts b/sdk/package/lib/StartSdk.ts index 3855d594d..d1f5532f8 100644 --- a/sdk/package/lib/StartSdk.ts +++ b/sdk/package/lib/StartSdk.ts @@ -6,15 +6,9 @@ import { ActionInfo, Actions, } from '../../base/lib/actions/setupActions' -import { - SyncOptions, - ServiceInterfaceId, - PackageId, - ServiceInterfaceType, - Effects, -} from '../../base/lib/types' +import { ServiceInterfaceType, Effects } from '../../base/lib/types' import * as patterns from '../../base/lib/util/patterns' -import { BackupSync, Backups } from './backup/Backups' +import { Backups } from './backup/Backups' import { smtpInputSpec } from '../../base/lib/actions/input/inputSpecConstants' import { Daemon, Daemons } from './mainFn/Daemons' import { checkPortListening } from './health/checkFns/checkPortListening' @@ -25,6 +19,7 @@ import { setupMain } from './mainFn' import { defaultTrigger } from './trigger/defaultTrigger' import { changeOnFirstSuccess, cooldownTrigger } from './trigger' import { setupServiceInterfaces } from '../../base/lib/interfaces/setupInterfaces' +import { setupExportedUrls } from '../../base/lib/interfaces/setupExportedUrls' import { successFailure } from './trigger/successFailure' import { MultiHost, Scheme } from '../../base/lib/interfaces/Host' import { ServiceInterfaceBuilder } from '../../base/lib/interfaces/ServiceInterfaceBuilder' @@ -85,8 +80,16 @@ export class StartSdk { return new StartSdk(manifest) } + private ifPluginEnabled

( + plugin: P, + value: T, + ): Manifest extends { plugins: P[] } ? T : null { + if (this.manifest.plugins?.includes(plugin)) return value as any + return null as any + } + build(isReady: AnyNeverCond<[Manifest], 'Build not ready', true>) { - type NestedEffects = 'subcontainer' | 'store' | 'action' + type NestedEffects = 'subcontainer' | 'store' | 'action' | 'plugin' type InterfaceEffects = | 'getServiceInterface' | 'listServiceInterfaces' @@ -172,7 +175,7 @@ export class StartSdk { }, checkDependencies: checkDependencies as < DependencyId extends keyof Manifest['dependencies'] & - PackageId = keyof Manifest['dependencies'] & PackageId, + T.PackageId = keyof Manifest['dependencies'] & T.PackageId, >( effects: Effects, packageIds?: DependencyId[], @@ -737,6 +740,48 @@ export class StartSdk { List, Value, Variants, + plugin: { + url: this.ifPluginEnabled('url-v0' as const, { + register: ( + effects: T.Effects, + options: { + tableAction: ActionInfo< + T.ActionId, + { + urlPluginMetadata: { + packageId: T.PackageId + interfaceId: T.ServiceInterfaceId + hostId: T.HostId + internalPort: number + } + } + > + }, + ) => + effects.plugin.url.register({ + tableAction: options.tableAction.id, + }), + exportUrl: ( + effects: T.Effects, + options: { + hostnameInfo: T.PluginHostnameInfo + rowActions: ActionInfo< + T.ActionId, + { + urlPluginMetadata: T.PluginHostnameInfo & { + interfaceId: T.ServiceInterfaceId + } + } + >[] + }, + ) => + effects.plugin.url.exportUrl({ + hostnameInfo: options.hostnameInfo, + rowActions: options.rowActions.map((a) => a.id), + }), + setupExportedUrls, // similar to setupInterfaces + }), + }, } } } diff --git a/sdk/package/lib/manifest/setupManifest.ts b/sdk/package/lib/manifest/setupManifest.ts index 1f78e087d..0add560ba 100644 --- a/sdk/package/lib/manifest/setupManifest.ts +++ b/sdk/package/lib/manifest/setupManifest.ts @@ -89,5 +89,6 @@ export function buildManifest< ), }, hardwareAcceleration: manifest.hardwareAcceleration ?? false, + plugins: manifest.plugins ?? [], } } diff --git a/web/projects/ui/src/app/routes/portal/components/interfaces/addresses/actions.component.ts b/web/projects/ui/src/app/routes/portal/components/interfaces/addresses/actions.component.ts index 68acb0ebf..084ed9baf 100644 --- a/web/projects/ui/src/app/routes/portal/components/interfaces/addresses/actions.component.ts +++ b/web/projects/ui/src/app/routes/portal/components/interfaces/addresses/actions.component.ts @@ -257,7 +257,7 @@ export class AddressActionsComponent { showDnsValidation() { this.domainHealth.showPublicDomainSetup( - this.address().hostnameInfo.host, + this.address().hostnameInfo.hostname, this.gatewayId(), ) } @@ -286,7 +286,7 @@ export class AddressActionsComponent { const loader = this.loader.open('Removing').subscribe() try { - const host = addr.hostnameInfo.host + const host = addr.hostnameInfo.hostname if (addr.hostnameInfo.metadata.kind === 'public-domain') { if (this.packageId()) { diff --git a/web/projects/ui/src/app/routes/portal/components/interfaces/addresses/item.component.ts b/web/projects/ui/src/app/routes/portal/components/interfaces/addresses/item.component.ts index 30bcd54d5..649f26e8b 100644 --- a/web/projects/ui/src/app/routes/portal/components/interfaces/addresses/item.component.ts +++ b/web/projects/ui/src/app/routes/portal/components/interfaces/addresses/item.component.ts @@ -209,7 +209,7 @@ export class InterfaceAddressItemComponent { const kind = addr.hostnameInfo.metadata.kind if (kind === 'public-domain') { await this.domainHealth.checkPublicDomain( - addr.hostnameInfo.host, + addr.hostnameInfo.hostname, this.gatewayId(), ) } else if (kind === 'private-domain') { diff --git a/web/projects/ui/src/app/routes/portal/components/interfaces/addresses/plugin.component.ts b/web/projects/ui/src/app/routes/portal/components/interfaces/addresses/plugin.component.ts index 7613175f9..c1ccd0f8e 100644 --- a/web/projects/ui/src/app/routes/portal/components/interfaces/addresses/plugin.component.ts +++ b/web/projects/ui/src/app/routes/portal/components/interfaces/addresses/plugin.component.ts @@ -10,6 +10,7 @@ import { DialogService, i18nPipe, } from '@start9labs/shared' +import { T } from '@start9labs/start-sdk' import { TUI_IS_MOBILE } from '@taiga-ui/cdk' import { TuiButton, @@ -22,13 +23,28 @@ import { PolymorpheusComponent } from '@taiga-ui/polymorpheus' import { PlaceholderComponent } from 'src/app/routes/portal/components/placeholder.component' import { TableComponent } from 'src/app/routes/portal/components/table.component' import { QRModal } from 'src/app/routes/portal/modals/qr.component' -import { PluginAddressGroup } from '../interface.service' +import { ActionService } from 'src/app/services/action.service' +import { + MappedServiceInterface, + PluginAddress, + PluginAddressGroup, +} from '../interface.service' @Component({ selector: 'section[pluginGroup]', template: `

{{ pluginGroup().pluginName }} + @if (pluginGroup().tableAction; as action) { + + }
@for (address of pluginGroup().addresses; track $index) { @@ -55,6 +71,23 @@ import { PluginAddressGroup } from '../interface.service' > {{ 'Copy URL' | i18n }} + @if (address.hostnameInfo.metadata.kind === 'plugin') { + @for ( + actionId of address.hostnameInfo.metadata.rowActions; + track actionId + ) { + @if (pluginGroup().pluginActions[actionId]; as meta) { + + } + } + }
+ @if (address.hostnameInfo.metadata.kind === 'plugin') { + @for ( + actionId of address.hostnameInfo.metadata.rowActions; + track actionId + ) { + @if (pluginGroup().pluginActions[actionId]; as meta) { + + } + } + }
@@ -159,10 +209,13 @@ import { PluginAddressGroup } from '../interface.service' export class PluginAddressesComponent { private readonly isMobile = inject(TUI_IS_MOBILE) private readonly dialog = inject(DialogService) + private readonly actionService = inject(ActionService) readonly copyService = inject(CopyService) readonly open = signal(false) readonly pluginGroup = input.required() + readonly packageId = input('') + readonly value = input() showQR(url: string) { this.dialog @@ -173,4 +226,59 @@ export class PluginAddressesComponent { }) .subscribe() } + + runTableAction() { + const group = this.pluginGroup() + if (!group.tableAction || !group.pluginPkgInfo) return + + const iface = this.value() + const prefill: Record = {} + + if (iface) { + prefill['urlPluginMetadata'] = { + packageId: this.packageId() || null, + hostId: iface.addressInfo.hostId, + interfaceId: iface.id, + internalPort: iface.addressInfo.internalPort, + } + } + + this.actionService.present({ + pkgInfo: group.pluginPkgInfo, + actionInfo: group.tableAction, + prefill, + }) + } + + runRowAction( + actionId: string, + metadata: T.ActionMetadata, + address: PluginAddress, + ) { + const group = this.pluginGroup() + if (!group.pluginPkgInfo) return + + const iface = this.value() + const prefill: Record = {} + + if (iface && address.hostnameInfo.metadata.kind === 'plugin') { + prefill['urlPluginMetadata'] = { + packageId: this.packageId() || null, + hostId: iface.addressInfo.hostId, + interfaceId: iface.id, + internalPort: iface.addressInfo.internalPort, + hostname: address.hostnameInfo.hostname, + port: address.hostnameInfo.port, + ssl: address.hostnameInfo.ssl, + public: address.hostnameInfo.public, + info: address.hostnameInfo.metadata.info, + } + } + + this.actionService.present({ + pkgInfo: group.pluginPkgInfo, + actionInfo: { id: actionId, metadata }, + prefill, + }) + } } diff --git a/web/projects/ui/src/app/routes/portal/components/interfaces/interface.component.ts b/web/projects/ui/src/app/routes/portal/components/interfaces/interface.component.ts index 422c0ea36..8e50d45ac 100644 --- a/web/projects/ui/src/app/routes/portal/components/interfaces/interface.component.ts +++ b/web/projects/ui/src/app/routes/portal/components/interfaces/interface.component.ts @@ -16,7 +16,11 @@ import { PluginAddressesComponent } from './addresses/plugin.component' > } @for (group of value()?.pluginGroups; track group.pluginId) { -
+
} `, styles: ` diff --git a/web/projects/ui/src/app/routes/portal/components/interfaces/interface.service.ts b/web/projects/ui/src/app/routes/portal/components/interfaces/interface.service.ts index aa33492b9..31eea08ff 100644 --- a/web/projects/ui/src/app/routes/portal/components/interfaces/interface.service.ts +++ b/web/projects/ui/src/app/routes/portal/components/interfaces/interface.service.ts @@ -2,7 +2,12 @@ import { inject, Injectable } from '@angular/core' import { T, utils } from '@start9labs/start-sdk' import { ConfigService } from 'src/app/services/config.service' import { GatewayPlus } from 'src/app/services/gateway.service' +import { + PrimaryStatus, + renderPkgStatus, +} from 'src/app/services/pkg-status-rendering.service' import { toAuthorityName } from 'src/app/utils/acme' +import { getManifest } from 'src/app/utils/get-package-data' function isPublicIp(h: T.HostnameInfo): boolean { return h.public && (h.metadata.kind === 'ipv4' || h.metadata.kind === 'ipv6') @@ -13,12 +18,12 @@ function isEnabled(addr: T.DerivedAddressInfo, h: T.HostnameInfo): boolean { if (h.port === null) return true const sa = h.metadata.kind === 'ipv6' - ? `[${h.host}]:${h.port}` - : `${h.host}:${h.port}` + ? `[${h.hostname}]:${h.port}` + : `${h.hostname}:${h.port}` return addr.enabled.includes(sa) } else { return !addr.disabled.some( - ([host, port]) => host === h.host && port === (h.port ?? 0), + ([hostname, port]) => hostname === h.hostname && port === (h.port ?? 0), ) } } @@ -46,7 +51,7 @@ function getCertificate( if (!h.ssl) return '-' if (h.metadata.kind === 'public-domain') { - const config = host.publicDomains[h.host] + const config = host.publicDomains[h.hostname] return config ? toAuthorityName(config.acme) : toAuthorityName(null) } @@ -60,7 +65,7 @@ function sortDomainsFirst(a: GatewayAddress, b: GatewayAddress): number { const isDomain = (addr: GatewayAddress) => addr.hostnameInfo.metadata.kind === 'public-domain' || (addr.hostnameInfo.metadata.kind === 'private-domain' && - !addr.hostnameInfo.host.endsWith('.local')) + !addr.hostnameInfo.hostname.endsWith('.local')) return Number(isDomain(b)) - Number(isDomain(a)) } @@ -72,7 +77,7 @@ function getAddressType(h: T.HostnameInfo): string { return 'IPv6' case 'public-domain': case 'private-domain': - return h.host + return h.hostname case 'mdns': return 'mDNS' case 'plugin': @@ -140,6 +145,7 @@ export class InterfaceService { getPluginGroups( serviceInterface: T.ServiceInterface, host: T.Host, + allPackageData?: Record, ): PluginAddressGroup[] { const binding = host.bindings[serviceInterface.addressInfo.internalPort] if (!binding) return [] @@ -152,7 +158,7 @@ export class InterfaceService { if (h.metadata.kind !== 'plugin') continue const url = utils.addressHostToUrl(serviceInterface.addressInfo, h) - const pluginId = h.metadata.package + const pluginId = h.metadata.packageId if (!groupMap.has(pluginId)) { groupMap.set(pluginId, []) @@ -165,11 +171,35 @@ export class InterfaceService { }) } - return Array.from(groupMap.entries()).map(([pluginId, addresses]) => ({ - pluginId, - pluginName: pluginId.charAt(0).toUpperCase() + pluginId.slice(1), - addresses, - })) + return Array.from(groupMap.entries()).map(([pluginId, addresses]) => { + const pluginPkg = allPackageData?.[pluginId] + const pluginActions = pluginPkg?.actions ?? {} + const tableActionId = pluginPkg?.plugin?.url?.tableAction ?? null + const tableActionMeta = tableActionId ? pluginActions[tableActionId] : undefined + const tableAction = tableActionId && tableActionMeta + ? { id: tableActionId, metadata: tableActionMeta } + : null + + let pluginPkgInfo: PluginPkgInfo | null = null + if (pluginPkg) { + const manifest = getManifest(pluginPkg) + pluginPkgInfo = { + id: manifest.id, + title: manifest.title, + icon: pluginPkg.icon, + status: renderPkgStatus(pluginPkg).primary, + } + } + + return { + pluginId, + pluginName: pluginPkgInfo?.title ?? pluginId.charAt(0).toUpperCase() + pluginId.slice(1), + addresses, + tableAction, + pluginPkgInfo, + pluginActions, + } + }) } launchableAddress(ui: T.ServiceInterface, host: T.Host): string { @@ -201,7 +231,7 @@ export class InterfaceService { matching = addresses.nonLocal .filter({ kind: 'ipv4', - predicate: h => h.host === this.config.hostname, + predicate: h => h.hostname === this.config.hostname, }) .format('urlstring')[0] onLan = true @@ -210,7 +240,7 @@ export class InterfaceService { matching = addresses.nonLocal .filter({ kind: 'ipv6', - predicate: h => h.host === this.config.hostname, + predicate: h => h.hostname === this.config.hostname, }) .format('urlstring')[0] break @@ -257,10 +287,20 @@ export type PluginAddress = { masked: boolean } +export type PluginPkgInfo = { + id: string + title: string + icon: string + status: PrimaryStatus +} + export type PluginAddressGroup = { pluginId: string pluginName: string addresses: PluginAddress[] + tableAction: { id: string; metadata: T.ActionMetadata } | null + pluginPkgInfo: PluginPkgInfo | null + pluginActions: Record } export type MappedServiceInterface = T.ServiceInterface & { diff --git a/web/projects/ui/src/app/routes/portal/routes/services/modals/action-input.component.ts b/web/projects/ui/src/app/routes/portal/routes/services/modals/action-input.component.ts index 39362f5d2..cbcbbf758 100644 --- a/web/projects/ui/src/app/routes/portal/routes/services/modals/action-input.component.ts +++ b/web/projects/ui/src/app/routes/portal/routes/services/modals/action-input.component.ts @@ -42,6 +42,7 @@ export type PackageActionData = { metadata: T.ActionMetadata } requestInfo?: T.Task + prefill?: Record } @Component({ @@ -178,11 +179,14 @@ export class ActionInputModal { async execute(input: object) { if (await this.checkConflicts(input)) { + const merged = this.context.data.prefill + ? { ...input, ...this.context.data.prefill } + : input await this.actionService.execute( this.pkgInfo.id, this.eventId, this.actionId, - input, + merged, ) this.context.$implicit.complete() } diff --git a/web/projects/ui/src/app/routes/portal/routes/services/routes/interface.component.ts b/web/projects/ui/src/app/routes/portal/routes/services/routes/interface.component.ts index f20c1d95b..da57d88b6 100644 --- a/web/projects/ui/src/app/routes/portal/routes/services/routes/interface.component.ts +++ b/web/projects/ui/src/app/routes/portal/routes/services/routes/interface.component.ts @@ -97,6 +97,7 @@ export default class ServiceInterfaceRoute { readonly interfaceId = input('') readonly pkg = toSignal(this.patch.watch$('packageData', this.pkgId)) + readonly allPackageData = toSignal(this.patch.watch$('packageData')) readonly isRunning = computed(() => { const pkg = this.pkg() @@ -131,7 +132,7 @@ export default class ServiceInterfaceRoute { host, gateways, ), - pluginGroups: this.interfaceService.getPluginGroups(iFace, host), + pluginGroups: this.interfaceService.getPluginGroups(iFace, host, this.allPackageData()), addSsl: !!binding?.options.addSsl, } }) diff --git a/web/projects/ui/src/app/routes/portal/routes/system/routes/startos-ui/startos-ui.component.ts b/web/projects/ui/src/app/routes/portal/routes/system/routes/startos-ui/startos-ui.component.ts index 89702d7d0..6e46d5e20 100644 --- a/web/projects/ui/src/app/routes/portal/routes/system/routes/startos-ui/startos-ui.component.ts +++ b/web/projects/ui/src/app/routes/portal/routes/system/routes/startos-ui/startos-ui.component.ts @@ -71,10 +71,14 @@ export default class StartOsUiComponent { }, } + private readonly patch = inject>(PatchDB) + readonly network = toSignal( - inject>(PatchDB).watch$('serverInfo', 'network'), + this.patch.watch$('serverInfo', 'network'), ) + readonly allPackageData = toSignal(this.patch.watch$('packageData')) + readonly ui = computed(() => { const network = this.network() const gateways = this.gatewayService.gateways() @@ -91,6 +95,7 @@ export default class StartOsUiComponent { pluginGroups: this.interfaceService.getPluginGroups( this.iface, network.host, + this.allPackageData(), ), addSsl: true, } diff --git a/web/projects/ui/src/app/services/action.service.ts b/web/projects/ui/src/app/services/action.service.ts index 439635590..8b2734841 100644 --- a/web/projects/ui/src/app/services/action.service.ts +++ b/web/projects/ui/src/app/services/action.service.ts @@ -46,9 +46,9 @@ export class ActionService { }, }) .pipe(filter(Boolean)) - .subscribe(() => this.execute(pkgInfo.id, null, actionInfo.id)) + .subscribe(() => this.execute(pkgInfo.id, null, actionInfo.id, data.prefill)) } else { - this.execute(pkgInfo.id, null, actionInfo.id) + this.execute(pkgInfo.id, null, actionInfo.id, data.prefill) } } } diff --git a/web/projects/ui/src/app/services/api/api.fixures.ts b/web/projects/ui/src/app/services/api/api.fixures.ts index 4f584bf99..5508fd388 100644 --- a/web/projects/ui/src/app/services/api/api.fixures.ts +++ b/web/projects/ui/src/app/services/api/api.fixures.ts @@ -262,6 +262,7 @@ export namespace Mock { ram: null, }, hardwareAcceleration: false, + plugins: [], } export const MockManifestLnd: T.Manifest = { @@ -321,6 +322,7 @@ export namespace Mock { ram: null, }, hardwareAcceleration: false, + plugins: [], } export const MockManifestBitcoinProxy: T.Manifest = { @@ -373,6 +375,7 @@ export namespace Mock { ram: null, }, hardwareAcceleration: false, + plugins: [], } export const BitcoinDep: T.DependencyMetadata = { @@ -432,6 +435,7 @@ export namespace Mock { ], ], hardwareAcceleration: false, + plugins: [], }, '#knots:26.1.20240325:0': { title: 'Bitcoin Knots', @@ -473,6 +477,7 @@ export namespace Mock { ], ], hardwareAcceleration: false, + plugins: [], }, }, categories: ['bitcoin', 'featured'], @@ -524,6 +529,7 @@ export namespace Mock { ], ], hardwareAcceleration: false, + plugins: [], }, '#knots:26.1.20240325:0': { title: 'Bitcoin Knots', @@ -565,6 +571,7 @@ export namespace Mock { ], ], hardwareAcceleration: false, + plugins: [], }, }, categories: ['bitcoin', 'featured'], @@ -621,6 +628,7 @@ export namespace Mock { ], ], hardwareAcceleration: false, + plugins: [], }, }, categories: ['lightning'], @@ -675,6 +683,7 @@ export namespace Mock { ], ], hardwareAcceleration: false, + plugins: [], }, }, categories: ['lightning'], @@ -730,6 +739,7 @@ export namespace Mock { ], ], hardwareAcceleration: false, + plugins: [], }, '#knots:27.1.0:0': { title: 'Bitcoin Knots', @@ -771,6 +781,7 @@ export namespace Mock { ], ], hardwareAcceleration: false, + plugins: [], }, }, categories: ['bitcoin', 'featured'], @@ -825,6 +836,7 @@ export namespace Mock { ], ], hardwareAcceleration: false, + plugins: [], }, }, categories: ['lightning'], @@ -878,6 +890,7 @@ export namespace Mock { ], ], hardwareAcceleration: false, + plugins: [], }, }, categories: ['bitcoin'], @@ -2121,7 +2134,7 @@ export namespace Mock { { ssl: true, public: false, - host: 'adjective-noun.local', + hostname: 'adjective-noun.local', port: 1234, metadata: { kind: 'mdns', @@ -2131,28 +2144,28 @@ export namespace Mock { { ssl: true, public: false, - host: '192.168.10.11', + hostname: '192.168.10.11', port: 1234, metadata: { kind: 'ipv4', gateway: 'wlan0' }, }, { ssl: true, public: false, - host: '10.0.0.2', + hostname: '10.0.0.2', port: 1234, metadata: { kind: 'ipv4', gateway: 'wlan0' }, }, { ssl: true, public: false, - host: 'fe80:cd00:0000:0cde:1257:0000:211e:72cd', + hostname: 'fe80:cd00:0000:0cde:1257:0000:211e:72cd', port: 1234, metadata: { kind: 'ipv6', gateway: 'eth0', scopeId: 2 }, }, { ssl: true, public: false, - host: 'fe80:cd00:0000:0cde:1257:0000:211e:1234', + hostname: 'fe80:cd00:0000:0cde:1257:0000:211e:1234', port: 1234, metadata: { kind: 'ipv6', gateway: 'wlan0', scopeId: 3 }, }, @@ -2222,6 +2235,7 @@ export namespace Mock { outboundGateway: null, registry: 'https://registry.start9.com/', developerKey: 'developer-key', + plugin: { url: null }, tasks: { 'bitcoind-config': { task: { @@ -2291,6 +2305,7 @@ export namespace Mock { outboundGateway: null, registry: 'https://registry.start9.com/', developerKey: 'developer-key', + plugin: { url: null }, tasks: {}, } @@ -2398,6 +2413,7 @@ export namespace Mock { outboundGateway: null, registry: 'https://registry.start9.com/', developerKey: 'developer-key', + plugin: { url: null }, tasks: { config: { active: true, diff --git a/web/projects/ui/src/app/services/api/embassy-mock-api.service.ts b/web/projects/ui/src/app/services/api/embassy-mock-api.service.ts index 8905c468d..1e2232d98 100644 --- a/web/projects/ui/src/app/services/api/embassy-mock-api.service.ts +++ b/web/projects/ui/src/app/services/api/embassy-mock-api.service.ts @@ -1822,8 +1822,8 @@ export class MockApiService extends ApiService { if (h.port === null) return const sa = h.metadata.kind === 'ipv6' - ? `[${h.host}]:${h.port}` - : `${h.host}:${h.port}` + ? `[${h.hostname}]:${h.port}` + : `${h.hostname}:${h.port}` const arr = [...current.enabled] @@ -1841,11 +1841,11 @@ export class MockApiService extends ApiService { } else { const port = h.port ?? 0 const arr = current.disabled.filter( - ([dHost, dPort]) => !(dHost === h.host && dPort === port), + ([dHost, dPort]) => !(dHost === h.hostname && dPort === port), ) if (!enabled) { - arr.push([h.host, port]) + arr.push([h.hostname, port]) } current.disabled = arr diff --git a/web/projects/ui/src/app/services/api/mock-patch.ts b/web/projects/ui/src/app/services/api/mock-patch.ts index f81a5afdb..a8d79dc0c 100644 --- a/web/projects/ui/src/app/services/api/mock-patch.ts +++ b/web/projects/ui/src/app/services/api/mock-patch.ts @@ -46,7 +46,7 @@ export const mockPatchData: DataModel = { { ssl: true, public: false, - host: 'adjective-noun.local', + hostname: 'adjective-noun.local', port: 443, metadata: { kind: 'mdns', @@ -56,35 +56,35 @@ export const mockPatchData: DataModel = { { ssl: false, public: false, - host: '10.0.0.1', + hostname: '10.0.0.1', port: 80, metadata: { kind: 'ipv4', gateway: 'eth0' }, }, { ssl: false, public: false, - host: '10.0.0.2', + hostname: '10.0.0.2', port: 80, metadata: { kind: 'ipv4', gateway: 'wlan0' }, }, { ssl: false, public: false, - host: 'fe80::cd00:0000:0cde:1257:0000:211e:72cd', + hostname: 'fe80::cd00:0000:0cde:1257:0000:211e:72cd', port: 80, metadata: { kind: 'ipv6', gateway: 'eth0', scopeId: 2 }, }, { ssl: false, public: false, - host: 'fe80::cd00:0000:0cde:1257:0000:211e:1234', + hostname: 'fe80::cd00:0000:0cde:1257:0000:211e:1234', port: 80, metadata: { kind: 'ipv6', gateway: 'wlan0', scopeId: 3 }, }, { ssl: true, public: false, - host: 'my-server.home', + hostname: 'my-server.home', port: 443, metadata: { kind: 'private-domain', @@ -94,16 +94,16 @@ export const mockPatchData: DataModel = { { ssl: false, public: false, - host: 'abc123def456ghi789jkl012mno345pqr678stu901vwx234yz567abc.onion', + hostname: 'abc123def456ghi789jkl012mno345pqr678stu901vwx234yz567abc.onion', port: 80, - metadata: { kind: 'plugin', package: 'tor' }, + metadata: { kind: 'plugin', packageId: 'tor', rowActions: [], info: null }, }, { ssl: true, public: false, - host: 'abc123def456ghi789jkl012mno345pqr678stu901vwx234yz567abc.onion', + hostname: 'abc123def456ghi789jkl012mno345pqr678stu901vwx234yz567abc.onion', port: 443, - metadata: { kind: 'plugin', package: 'tor' }, + metadata: { kind: 'plugin', packageId: 'tor', rowActions: [], info: null }, }, ], }, @@ -349,6 +349,7 @@ export const mockPatchData: DataModel = { outboundGateway: null, registry: 'https://registry.start9.com/', developerKey: 'developer-key', + plugin: { url: null }, tasks: { config: { active: true, @@ -532,7 +533,7 @@ export const mockPatchData: DataModel = { { ssl: true, public: false, - host: 'adjective-noun.local', + hostname: 'adjective-noun.local', port: 42443, metadata: { kind: 'mdns', @@ -542,49 +543,49 @@ export const mockPatchData: DataModel = { { ssl: false, public: false, - host: '10.0.0.1', + hostname: '10.0.0.1', port: 42080, metadata: { kind: 'ipv4', gateway: 'eth0' }, }, { ssl: false, public: false, - host: 'fe80::cd00:0cde:1257:211e:72cd', + hostname: 'fe80::cd00:0cde:1257:211e:72cd', port: 42080, metadata: { kind: 'ipv6', gateway: 'eth0', scopeId: 2 }, }, { ssl: true, public: true, - host: '203.0.113.45', + hostname: '203.0.113.45', port: 42443, metadata: { kind: 'ipv4', gateway: 'eth0' }, }, { ssl: true, public: true, - host: 'bitcoin.example.com', + hostname: 'bitcoin.example.com', port: 42443, metadata: { kind: 'public-domain', gateway: 'eth0' }, }, { ssl: false, public: false, - host: '192.168.10.11', + hostname: '192.168.10.11', port: 42080, metadata: { kind: 'ipv4', gateway: 'wlan0' }, }, { ssl: false, public: false, - host: 'fe80::cd00:0cde:1257:211e:1234', + hostname: 'fe80::cd00:0cde:1257:211e:1234', port: 42080, metadata: { kind: 'ipv6', gateway: 'wlan0', scopeId: 3 }, }, { ssl: true, public: false, - host: 'my-bitcoin.home', + hostname: 'my-bitcoin.home', port: 42443, metadata: { kind: 'private-domain', @@ -594,16 +595,16 @@ export const mockPatchData: DataModel = { { ssl: false, public: false, - host: 'xyz789abc123def456ghi789jkl012mno345pqr678stu901vwx234.onion', + hostname: 'xyz789abc123def456ghi789jkl012mno345pqr678stu901vwx234.onion', port: 42080, - metadata: { kind: 'plugin', package: 'tor' }, + metadata: { kind: 'plugin', packageId: 'tor', rowActions: [], info: null }, }, { ssl: true, public: false, - host: 'xyz789abc123def456ghi789jkl012mno345pqr678stu901vwx234.onion', + hostname: 'xyz789abc123def456ghi789jkl012mno345pqr678stu901vwx234.onion', port: 42443, - metadata: { kind: 'plugin', package: 'tor' }, + metadata: { kind: 'plugin', packageId: 'tor', rowActions: [], info: null }, }, ], }, @@ -655,7 +656,7 @@ export const mockPatchData: DataModel = { { ssl: false, public: false, - host: 'adjective-noun.local', + hostname: 'adjective-noun.local', port: 48332, metadata: { kind: 'mdns', @@ -665,7 +666,7 @@ export const mockPatchData: DataModel = { { ssl: false, public: false, - host: '10.0.0.1', + hostname: '10.0.0.1', port: 48332, metadata: { kind: 'ipv4', gateway: 'eth0' }, }, @@ -711,6 +712,7 @@ export const mockPatchData: DataModel = { outboundGateway: null, registry: 'https://registry.start9.com/', developerKey: 'developer-key', + plugin: { url: null }, tasks: { // 'bitcoind-config': { // task: {