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

@@ -55,6 +55,7 @@ socat
sqlite3
squashfs-tools
squashfs-tools-ng
ssl-cert
sudo
systemd
systemd-resolved

View File

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

View File

@@ -316,6 +316,31 @@ export function makeEffects(context: EffectContext): Effects {
T.Effects["setDataVersion"]
>
},
plugin: {
url: {
register(
...[options]: Parameters<T.Effects["plugin"]["url"]["register"]>
) {
return rpcRound("plugin.url.register", options) as ReturnType<
T.Effects["plugin"]["url"]["register"]
>
},
exportUrl(
...[options]: Parameters<T.Effects["plugin"]["url"]["exportUrl"]>
) {
return rpcRound("plugin.url.export-url", options) as ReturnType<
T.Effects["plugin"]["url"]["exportUrl"]
>
},
clearUrls(
...[options]: Parameters<T.Effects["plugin"]["url"]["clearUrls"]>
) {
return rpcRound("plugin.url.clear-urls", options) as ReturnType<
T.Effects["plugin"]["url"]["clearUrls"]
>
},
},
},
}
if (context.callbacks?.onLeaveContext)
self.onLeaveContext(() => {

View File

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

View File

@@ -1245,7 +1245,7 @@ async function updateConfig(
: catchFn(
() =>
filled.addressInfo!.filter({ kind: "mdns" })!.hostnames[0]
.host,
.hostname,
) || ""
mutConfigValue[key] = url
}

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)

View File

@@ -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<u16>` 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<HostnameInfo>,
pub public_enabled: BTreeSet<HostnameInfo>,
pub possible: BTreeSet<HostnameInfo>, // 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

View File

@@ -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<null>
plugin: {
url: {
register(options: { tableAction: ActionId }): Promise<null>
exportUrl(options: {
hostnameInfo: PluginHostnameInfo
rowActions: ActionId[]
}): Promise<null>
clearUrls(options: { except: PluginHostnameInfo[] }): Promise<null>
}
}
// ssl
/** Returns a PEM encoded fullchain for the hostnames specified */
getSslCertificate: (options: {

View File

@@ -0,0 +1,28 @@
import { Effects, PluginHostnameInfo } from '../types'
export type SetExportedUrls = (opts: { effects: Effects }) => Promise<void>
export type UpdateExportedUrls = (effects: Effects) => Promise<null>
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
}

View File

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

View File

@@ -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<GatewayId> }
| { kind: 'private-domain'; gateways: Array<GatewayId> }
| { kind: 'public-domain'; gateway: GatewayId }
| { kind: 'plugin'; package: PackageId }
| {
kind: 'plugin'
packageId: PackageId
rowActions: Array<ActionId>
info: unknown
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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<ExpectedType>(_a: ExpectedType) {}
@@ -90,6 +93,13 @@ describe('startosTypeValidation ', () => {
getDependencies: undefined,
getStatus: {} as WithCallback<GetStatusParams>,
setMainStatus: {} as SetMainStatus,
plugin: {
url: {
register: {} as UrlPluginRegisterParams,
exportUrl: {} as UrlPluginExportUrlParams,
clearUrls: {} as UrlPluginClearUrlsParams,
},
},
})
})
})

View File

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

View File

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

View File

@@ -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<Manifest extends T.SDKManifest> {
return new StartSdk<Manifest>(manifest)
}
private ifPluginEnabled<P extends T.PluginId, T>(
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<Manifest extends T.SDKManifest> {
},
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<Manifest extends T.SDKManifest> {
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
}),
},
}
}
}

View File

@@ -89,5 +89,6 @@ export function buildManifest<
),
},
hardwareAcceleration: manifest.hardwareAcceleration ?? false,
plugins: manifest.plugins ?? [],
}
}

View File

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

View File

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

View File

@@ -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: `
<header>
{{ pluginGroup().pluginName }}
@if (pluginGroup().tableAction; as action) {
<button
tuiButton
iconStart="@tui.plus"
[style.margin-inline-start]="'auto'"
(click)="runTableAction()"
>
{{ action.metadata.name }}
</button>
}
</header>
<table [appTable]="['Protocol', 'URL', null]">
@for (address of pluginGroup().addresses; track $index) {
@@ -55,6 +71,23 @@ import { PluginAddressGroup } from '../interface.service'
>
{{ 'Copy URL' | i18n }}
</button>
@if (address.hostnameInfo.metadata.kind === 'plugin') {
@for (
actionId of address.hostnameInfo.metadata.rowActions;
track actionId
) {
@if (pluginGroup().pluginActions[actionId]; as meta) {
<button
tuiIconButton
appearance="flat-grayscale"
iconStart="@tui.play"
(click)="runRowAction(actionId, meta, address)"
>
{{ meta.name }}
</button>
}
}
}
</div>
<div class="mobile">
<button
@@ -83,6 +116,23 @@ import { PluginAddressGroup } from '../interface.service'
>
{{ 'Copy URL' | i18n }}
</button>
@if (address.hostnameInfo.metadata.kind === 'plugin') {
@for (
actionId of address.hostnameInfo.metadata.rowActions;
track actionId
) {
@if (pluginGroup().pluginActions[actionId]; as meta) {
<button
tuiOption
new
iconStart="@tui.play"
(click)="runRowAction(actionId, meta, address)"
>
{{ meta.name }}
</button>
}
}
}
</tui-data-list>
</button>
</div>
@@ -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<PluginAddressGroup>()
readonly packageId = input('')
readonly value = input<MappedServiceInterface | undefined>()
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<string, unknown> = {}
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<string, unknown> = {}
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,
})
}
}

View File

@@ -16,7 +16,11 @@ import { PluginAddressesComponent } from './addresses/plugin.component'
></section>
}
@for (group of value()?.pluginGroups; track group.pluginId) {
<section [pluginGroup]="group"></section>
<section
[pluginGroup]="group"
[packageId]="packageId()"
[value]="value()"
></section>
}
`,
styles: `

View File

@@ -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<string, T.PackageDataEntry>,
): 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<string, T.ActionMetadata>
}
export type MappedServiceInterface = T.ServiceInterface & {

View File

@@ -42,6 +42,7 @@ export type PackageActionData = {
metadata: T.ActionMetadata
}
requestInfo?: T.Task
prefill?: Record<string, unknown>
}
@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()
}

View File

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

View File

@@ -71,10 +71,14 @@ export default class StartOsUiComponent {
},
}
private readonly patch = inject<PatchDB<DataModel>>(PatchDB)
readonly network = toSignal(
inject<PatchDB<DataModel>>(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,
}

View File

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

View File

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

View File

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

View File

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