feat: implement URL plugins with table/row actions and prefill support

- Add URL plugin effects (register, export_url, clear_urls) in core
- Add PluginHostnameInfo, HostnameMetadata::Plugin, and plugin registration types
- Implement plugin URL table in web UI with tableAction button and rowAction overflow menus
- Thread urlPluginMetadata (packageId, hostId, interfaceId, internalPort) as prefill to actions
- Add prefill support to PackageActionData so metadata passes through form dialogs
- Add i18n translations for plugin error messages
- Clean up plugin URLs on package uninstall
This commit is contained in:
Aiden McClelland
2026-02-18 17:51:13 -07:00
parent dce975410f
commit 9c3053f103
53 changed files with 792 additions and 278 deletions

View File

@@ -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"

View File

@@ -383,6 +383,8 @@ pub struct PackageDataEntry {
pub store_exposed_dependents: Vec<JsonPointer>,
#[ts(type = "string | null")]
pub outbound_gateway: Option<GatewayId>,
#[serde(default)]
pub plugin: PackagePlugin,
}
impl AsRef<PackageDataEntry> for PackageDataEntry {
fn as_ref(&self) -> &PackageDataEntry {
@@ -390,6 +392,21 @@ impl AsRef<PackageDataEntry> for PackageDataEntry {
}
}
#[derive(Debug, Default, Deserialize, Serialize, HasModel, TS)]
#[serde(rename_all = "camelCase")]
#[model = "Model<Self>"]
#[ts(export)]
pub struct PackagePlugin {
pub url: Option<UrlPluginRegistration>,
}
#[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<PackageId, CurrentDependencyInfo>);

View File

@@ -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<Kind: HostApiKind>(
} 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 {

View File

@@ -92,6 +92,14 @@ impl Model<Host> {
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<HostnameInfo> = 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<Host> {
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<Host> {
available.insert(HostnameInfo {
ssl: true,
public: false,
host: host.clone(),
hostname: host.clone(),
port: Some(port),
metadata,
});
@@ -146,7 +154,7 @@ impl Model<Host> {
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<Host> {
available.insert(HostnameInfo {
ssl: true,
public: true,
host: host.clone(),
hostname: host.clone(),
port: Some(port),
metadata,
});
@@ -182,7 +190,7 @@ impl Model<Host> {
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<Host> {
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<Host> {
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<Host> {
available.insert(HostnameInfo {
ssl: true,
public: true,
host: domain,
hostname: domain,
port: Some(port),
metadata,
});
@@ -257,7 +265,7 @@ impl Model<Host> {
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<Host> {
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<Host> {
});
}
}
available.extend(plugin_addrs);
bind.as_addresses_mut().as_available_mut().ser(&available)?;
}

View File

@@ -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 {

View File

@@ -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<u16>,
pub metadata: HostnameMetadata,
}
@@ -42,21 +42,22 @@ pub enum HostnameMetadata {
gateway: GatewayId,
},
Plugin {
package: PackageId,
#[serde(flatten)]
#[ts(skip)]
extra: InOMap<InternedString, Value>,
package_id: PackageId,
row_actions: Vec<ActionId>,
#[ts(type = "unknown")]
#[serde(default)]
info: Value,
},
}
impl HostnameInfo {
pub fn to_socket_addr(&self) -> Option<SocketAddr> {
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<PackageId>,
pub host_id: HostId,
pub internal_port: u16,
pub ssl: bool,
pub public: bool,
#[ts(type = "string")]
pub hostname: InternedString,
pub port: Option<u16>,
#[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<ActionId>,
) -> 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")]

View File

@@ -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(),

View File

@@ -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<Version>,
#[serde(default)]
pub hardware_acceleration: bool,
#[serde(default)]
pub plugins: BTreeSet<PluginId>,
}
#[derive(Debug, Deserialize, Serialize, HasModel, TS)]

View File

@@ -218,6 +218,7 @@ impl TryFrom<ManifestV1> for Manifest {
PackageProcedure::Docker(d) => d.gpu_acceleration,
PackageProcedure::Script(_) => false,
},
plugins: BTreeSet::new(),
},
images: BTreeMap::new(),
volumes: value

View File

@@ -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<C: Context>() -> ParentHandler<C> {
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::<C>::new().subcommand(
"url",
ParentHandler::<C>::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)

View File

@@ -2,4 +2,5 @@ pub mod bind;
pub mod host;
pub mod info;
pub mod interface;
pub mod plugin;
pub mod ssl;

View File

@@ -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<Service>) -> 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<ActionId>,
}
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<PluginHostnameInfo>,
}
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(())
}

View File

@@ -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,
}

View File

@@ -260,6 +260,7 @@ impl ServiceMap {
hosts: Default::default(),
store_exposed_dependents: Default::default(),
outbound_gateway: None,
plugin: Default::default(),
},
)?;
};

View File

@@ -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<HostnameInfo>| {
available.retain(|h| {
!matches!(
&h.metadata,
HostnameMetadata::Plugin { package_id, .. }
if package_id == id
)
});
Ok(())
})?;
}
}
Ok(Some(pde))
} else {
Ok(None)