mirror of
https://github.com/Start9Labs/start-os.git
synced 2026-03-26 02:11:53 +00:00
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:
@@ -55,6 +55,7 @@ socat
|
||||
sqlite3
|
||||
squashfs-tools
|
||||
squashfs-tools-ng
|
||||
ssl-cert
|
||||
sudo
|
||||
systemd
|
||||
systemd-resolved
|
||||
|
||||
@@ -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 ----------------
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -1245,7 +1245,7 @@ async function updateConfig(
|
||||
: catchFn(
|
||||
() =>
|
||||
filled.addressInfo!.filter({ kind: "mdns" })!.hostnames[0]
|
||||
.host,
|
||||
.hostname,
|
||||
) || ""
|
||||
mutConfigValue[key] = url
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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>);
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)?;
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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")]
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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)]
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -2,4 +2,5 @@ pub mod bind;
|
||||
pub mod host;
|
||||
pub mod info;
|
||||
pub mod interface;
|
||||
pub mod plugin;
|
||||
pub mod ssl;
|
||||
|
||||
164
core/src/service/effects/net/plugin.rs
Normal file
164
core/src/service/effects/net/plugin.rs
Normal 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(())
|
||||
}
|
||||
9
core/src/service/effects/plugin.rs
Normal file
9
core/src/service/effects/plugin.rs
Normal 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,
|
||||
}
|
||||
@@ -260,6 +260,7 @@ impl ServiceMap {
|
||||
hosts: Default::default(),
|
||||
store_exposed_dependents: Default::default(),
|
||||
outbound_gateway: None,
|
||||
plugin: Default::default(),
|
||||
},
|
||||
)?;
|
||||
};
|
||||
|
||||
@@ -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)
|
||||
|
||||
170
docs/TODO.md
170
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<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
|
||||
|
||||
@@ -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: {
|
||||
|
||||
28
sdk/base/lib/interfaces/setupExportedUrls.ts
Normal file
28
sdk/base/lib/interfaces/setupExportedUrls.ts
Normal 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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
4
sdk/base/lib/osBindings/PackagePlugin.ts
Normal file
4
sdk/base/lib/osBindings/PackagePlugin.ts
Normal 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 }
|
||||
@@ -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>
|
||||
}
|
||||
|
||||
14
sdk/base/lib/osBindings/PluginHostnameInfo.ts
Normal file
14
sdk/base/lib/osBindings/PluginHostnameInfo.ts
Normal 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
|
||||
}
|
||||
3
sdk/base/lib/osBindings/PluginId.ts
Normal file
3
sdk/base/lib/osBindings/PluginId.ts
Normal 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'
|
||||
4
sdk/base/lib/osBindings/UrlPluginClearUrlsParams.ts
Normal file
4
sdk/base/lib/osBindings/UrlPluginClearUrlsParams.ts
Normal 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> }
|
||||
8
sdk/base/lib/osBindings/UrlPluginExportUrlParams.ts
Normal file
8
sdk/base/lib/osBindings/UrlPluginExportUrlParams.ts
Normal 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>
|
||||
}
|
||||
4
sdk/base/lib/osBindings/UrlPluginRegisterParams.ts
Normal file
4
sdk/base/lib/osBindings/UrlPluginRegisterParams.ts
Normal 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 }
|
||||
4
sdk/base/lib/osBindings/UrlPluginRegistration.ts
Normal file
4
sdk/base/lib/osBindings/UrlPluginRegistration.ts
Normal 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 }
|
||||
@@ -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'
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
},
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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),
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
@@ -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
|
||||
}),
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -89,5 +89,6 @@ export function buildManifest<
|
||||
),
|
||||
},
|
||||
hardwareAcceleration: manifest.hardwareAcceleration ?? false,
|
||||
plugins: manifest.plugins ?? [],
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()) {
|
||||
|
||||
@@ -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') {
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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: `
|
||||
|
||||
@@ -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 & {
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
})
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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: {
|
||||
|
||||
Reference in New Issue
Block a user