From cc5f3165147e67e2f7c43ad79197d40ddf2f5032 Mon Sep 17 00:00:00 2001 From: Matt Hill Date: Mon, 9 Feb 2026 22:00:39 -0700 Subject: [PATCH] frontend plus some be types --- core/src/db/model/package.rs | 5 +- core/src/db/model/public.rs | 56 +++-- core/src/net/gateway.rs | 7 +- core/src/net/tunnel.rs | 30 ++- core/src/service/service_map.rs | 1 + core/src/version/update_details/v0_4_0.md | 14 +- .../shared/src/i18n/dictionaries/de.ts | 12 ++ .../shared/src/i18n/dictionaries/en.ts | 12 ++ .../shared/src/i18n/dictionaries/es.ts | 12 ++ .../shared/src/i18n/dictionaries/fr.ts | 12 ++ .../shared/src/i18n/dictionaries/pl.ts | 12 ++ .../src/app/services/patch-db/data-model.ts | 2 +- .../components/interface-item.component.ts | 1 - .../services/routes/actions.component.ts | 200 +++++++++++++----- .../routes/gateways/gateways.component.ts | 31 ++- .../system/routes/gateways/item.component.ts | 88 ++++++-- .../system/routes/gateways/table.component.ts | 13 +- .../ui/src/app/services/api/api.fixures.ts | 3 + .../ui/src/app/services/api/api.types.ts | 16 +- .../app/services/api/embassy-api.service.ts | 8 + .../services/api/embassy-live-api.service.ts | 12 ++ .../services/api/embassy-mock-api.service.ts | 45 +++- .../ui/src/app/services/api/mock-patch.ts | 24 ++- .../ui/src/app/services/gateway.service.ts | 68 +++--- 24 files changed, 528 insertions(+), 156 deletions(-) diff --git a/core/src/db/model/package.rs b/core/src/db/model/package.rs index 70c33a360..63db0b8ac 100644 --- a/core/src/db/model/package.rs +++ b/core/src/db/model/package.rs @@ -18,7 +18,7 @@ use crate::s9pk::manifest::{LocaleString, Manifest}; use crate::status::StatusInfo; use crate::util::DataUrl; use crate::util::serde::{Pem, is_partial_of}; -use crate::{ActionId, HealthCheckId, HostId, PackageId, ReplayId, ServiceInterfaceId}; +use crate::{ActionId, GatewayId, HealthCheckId, HostId, PackageId, ReplayId, ServiceInterfaceId}; #[derive(Debug, Default, Deserialize, Serialize, TS)] #[ts(export)] @@ -381,6 +381,9 @@ pub struct PackageDataEntry { pub hosts: Hosts, #[ts(type = "string[]")] pub store_exposed_dependents: Vec, + #[serde(default)] + #[ts(type = "string | null")] + pub outbound_gateway: Option, } impl AsRef for PackageDataEntry { fn as_ref(&self) -> &PackageDataEntry { diff --git a/core/src/db/model/public.rs b/core/src/db/model/public.rs index 20c5bc390..be4291215 100644 --- a/core/src/db/model/public.rs +++ b/core/src/db/model/public.rs @@ -117,6 +117,7 @@ impl Public { acme }, dns: Default::default(), + default_outbound: None, }, status_info: ServerStatus { backup_progress: None, @@ -220,6 +221,9 @@ pub struct NetworkInfo { pub acme: BTreeMap, #[serde(default)] pub dns: DnsSettings, + #[serde(default)] + #[ts(type = "string | null")] + pub default_outbound: Option, } #[derive(Debug, Default, Deserialize, Serialize, HasModel, TS)] @@ -239,39 +243,42 @@ pub struct DnsSettings { #[ts(export)] pub struct NetworkInterfaceInfo { pub name: Option, + #[ts(skip)] pub public: Option, pub secure: Option, pub ip_info: Option>, + #[serde(default, rename = "type")] + pub gateway_type: Option, } impl NetworkInterfaceInfo { pub fn public(&self) -> bool { self.public.unwrap_or_else(|| { !self.ip_info.as_ref().map_or(true, |ip_info| { - let ip4s = ip_info - .subnets - .iter() - .filter_map(|ipnet| { - if let IpAddr::V4(ip4) = ipnet.addr() { - Some(ip4) - } else { - None - } - }) - .collect::>(); - if !ip4s.is_empty() { - return ip4s - .iter() - .all(|ip4| ip4.is_loopback() || ip4.is_private() || ip4.is_link_local()); - } - ip_info.subnets.iter().all(|ipnet| { - if let IpAddr::V6(ip6) = ipnet.addr() { - ipv6_is_local(ip6) + let ip4s = ip_info + .subnets + .iter() + .filter_map(|ipnet| { + if let IpAddr::V4(ip4) = ipnet.addr() { + Some(ip4) } else { - true + None } }) + .collect::>(); + if !ip4s.is_empty() { + return ip4s + .iter() + .all(|ip4| ip4.is_loopback() || ip4.is_private() || ip4.is_link_local()); + } + ip_info.subnets.iter().all(|ipnet| { + if let IpAddr::V6(ip6) = ipnet.addr() { + ipv6_is_local(ip6) + } else { + true + } }) }) + }) } pub fn secure(&self) -> bool { @@ -310,6 +317,15 @@ pub enum NetworkInterfaceType { Loopback, } +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, PartialOrd, Ord, Deserialize, Serialize, TS, clap::ValueEnum)] +#[ts(export)] +#[serde(rename_all = "kebab-case")] +pub enum GatewayType { + #[default] + InboundOutbound, + OutboundOnly, +} + #[derive(Debug, Deserialize, Serialize, HasModel, TS)] #[serde(rename_all = "camelCase")] #[model = "Model"] diff --git a/core/src/net/gateway.rs b/core/src/net/gateway.rs index 6079efd76..6f59273d2 100644 --- a/core/src/net/gateway.rs +++ b/core/src/net/gateway.rs @@ -758,13 +758,14 @@ async fn watch_ip( write_to.send_if_modified( |m: &mut OrdMap| { - let (name, public, secure, prev_wan_ip) = m + let (name, public, secure, gateway_type, prev_wan_ip) = m .get(&iface) - .map_or((None, None, None, None), |i| { + .map_or((None, None, None, None, None), |i| { ( i.name.clone(), i.public, i.secure, + i.gateway_type, i.ip_info .as_ref() .and_then(|i| i.wan_ip), @@ -779,6 +780,7 @@ async fn watch_ip( public, secure, ip_info: Some(ip_info.clone()), + gateway_type, }, ) .filter(|old| &old.ip_info == &Some(ip_info)) @@ -1715,6 +1717,7 @@ fn test_filter() { ntp_servers: Default::default(), dns_servers: Default::default(), })), + gateway_type: None, }, )); } diff --git a/core/src/net/tunnel.rs b/core/src/net/tunnel.rs index f3b505850..821db72eb 100644 --- a/core/src/net/tunnel.rs +++ b/core/src/net/tunnel.rs @@ -8,7 +8,7 @@ use ts_rs::TS; use crate::GatewayId; use crate::context::{CliContext, RpcContext}; -use crate::db::model::public::{NetworkInterfaceInfo, NetworkInterfaceType}; +use crate::db::model::public::{GatewayType, NetworkInterfaceInfo, NetworkInterfaceType}; use crate::net::host::all_hosts; use crate::prelude::*; use crate::util::Invoke; @@ -32,14 +32,19 @@ pub fn tunnel_api() -> ParentHandler { } #[derive(Debug, Clone, Deserialize, Serialize, Parser, TS)] +#[serde(rename_all = "camelCase")] #[ts(export)] pub struct AddTunnelParams { #[arg(help = "help.arg.tunnel-name")] name: InternedString, #[arg(help = "help.arg.wireguard-config")] config: String, - #[arg(help = "help.arg.is-public")] - public: bool, + #[arg(help = "help.arg.gateway-type")] + #[serde(default, rename = "type")] + gateway_type: Option, + #[arg(help = "help.arg.set-as-default-outbound")] + #[serde(default)] + set_as_default_outbound: bool, } fn sanitize_config(config: &str) -> String { @@ -64,7 +69,8 @@ pub async fn add_tunnel( AddTunnelParams { name, config, - public, + gateway_type, + set_as_default_outbound, }: AddTunnelParams, ) -> Result { let ifaces = ctx.net_controller.net_iface.watcher.subscribe(); @@ -76,9 +82,10 @@ pub async fn add_tunnel( iface.clone(), NetworkInterfaceInfo { name: Some(name), - public: Some(public), + public: None, secure: None, ip_info: None, + gateway_type, }, ); return true; @@ -120,6 +127,19 @@ pub async fn add_tunnel( sub.recv().await; + if set_as_default_outbound { + ctx.db + .mutate(|db| { + db.as_public_mut() + .as_server_info_mut() + .as_network_mut() + .as_default_outbound_mut() + .ser(&Some(iface.clone())) + }) + .await + .result?; + } + Ok(iface) } diff --git a/core/src/service/service_map.rs b/core/src/service/service_map.rs index fe8192d26..56292baba 100644 --- a/core/src/service/service_map.rs +++ b/core/src/service/service_map.rs @@ -259,6 +259,7 @@ impl ServiceMap { service_interfaces: Default::default(), hosts: Default::default(), store_exposed_dependents: Default::default(), + outbound_gateway: None, }, )?; }; diff --git a/core/src/version/update_details/v0_4_0.md b/core/src/version/update_details/v0_4_0.md index e546704ee..e3881a5fd 100644 --- a/core/src/version/update_details/v0_4_0.md +++ b/core/src/version/update_details/v0_4_0.md @@ -10,13 +10,13 @@ A server is not a toy. It is a critical component of the computing paradigm, and Start9 is paving new ground with StartOS, trying to create what most developers and IT professionals thought impossible; namely, an OS and user experience that affords a normal person the same independent control over their data and communications as an experienced Linux sysadmin. -The difficulty of our endeavor requires making mistakes; and our integrity and dedication to excellence require that we correct them. This means a willingness to discard bad ideas and broken parts, and if absolutely necessary, to tear it all down and start over. That is exactly what we did with StartOS v0.2.0 in 2020. It is what we did with StartOS v0.3.0 in 2022. And we are doing it now with StartOS v0.4.0 in 2025. +The difficulty of our endeavor requires making mistakes; and our integrity and dedication to excellence require that we correct them. This means a willingness to discard bad ideas and broken parts, and if absolutely necessary, to tear it all down and start over. That is exactly what we did with StartOS v0.2.0 in 2020. It is what we did with StartOS v0.3.0 in 2022. And we are doing it now with StartOS v0.4.0 in 2026. v0.4.0 is a complete rewrite of StartOS, almost nothing survived. After nearly six years of building StartOS, we believe that we have finally arrived at the correct architecture and foundation that will allow us to deliver on the promise of sovereign computing. ## Changelog -### Improved User interface +### New User interface We re-wrote the StartOS UI to be more performant, more intuitive, and better looking on both mobile and desktop. Enjoy. @@ -28,6 +28,10 @@ StartOS v0.4.0 supports multiple languages and also makes it easy to add more la Neither Docker nor Podman offer the reliability and flexibility needed for StartOS. Instead, v0.4.0 uses a nested container paradigm based on LXC for the outer container and Linux namespaces for sub containers. This architecture naturally supports multi container setups. +### Hardware Acceleration + +Services can take advantage of (and require) the presence of certain hardware modules, such as Nvidia GPUs, for transcoding or inference purposes. For example, StartOS and Ollama can run natively on The Nvidia DGX Spark and take full advantage of the hardware/firmware stack to perform local inference against open source models. + ### New S9PK archive format The S9PK archive format has been overhauled to allow for signature verification of partial downloads, and allow direct mounting of container images without unpacking the s9pk. @@ -80,13 +84,13 @@ The new start-fs fuse module unifies file system expectations for various platfo StartOS now uses Extended Versioning (Exver), which consists of three parts: (1) a Semver-compliant upstream version, (2) a Semver-compliant wrapper version, and (3) an optional "flavor" prefix. Flavors can be thought of as alternative implementations of services, where a user would only want one or the other installed, and data can feasibly be migrating between the two. Another common characteristic of flavors is that they satisfy the same API requirement of dependents, though this is not strictly necessary. A valid Exver looks something like this: `#knots:29.0:1.0-beta.1`. This would translate to "the first beta release of StartOS wrapper version 1.0 of Bitcoin Knots version 29.0". -### ACME +### Let's Encrypt -StartOS now supports using ACME protocol to automatically obtain SSL/TLS certificates from widely trusted certificate authorities, such as Let's Encrypt, for your public domains. This means people visiting your public websites and APIs will not need to download and trust your server's Root CA. +StartOS now supports Let's Encrypt to automatically obtain SSL/TLS certificates for public domains. This means people visiting your public websites and APIs will not need to download and trust your server's Root CA. ### Gateways -Gateways connect your server to the Internet. They process outbound traffic, and under certain conditions, they also permit inbound traffic. For example, your router is a gateway. It is now possible add gateways to StartOS, such as StartTunnel, in order to more granularly control how your installed services are exposed to the Internet. +Gateways connect your server to the Internet, facilitating inbound and outbound traffic. Your router is a gateway. It is now possible to add Wireguard VPN gateways to your server to control how devices outside the LAN connect to your server and how your server connects out to the Internet. ### Static DNS Servers diff --git a/web/projects/shared/src/i18n/dictionaries/de.ts b/web/projects/shared/src/i18n/dictionaries/de.ts index 16d7b9473..f5113fc61 100644 --- a/web/projects/shared/src/i18n/dictionaries/de.ts +++ b/web/projects/shared/src/i18n/dictionaries/de.ts @@ -679,4 +679,16 @@ export default { 714: 'Installation abgeschlossen!', 715: 'StartOS wurde erfolgreich installiert.', 716: 'Weiter zur Einrichtung', + 717: '', + 718: '', + 719: '', + 720: '', + 721: '', + 722: '', + 723: '', + 724: '', + 725: '', + 726: '', + 727: '', + 728: '', } satisfies i18n diff --git a/web/projects/shared/src/i18n/dictionaries/en.ts b/web/projects/shared/src/i18n/dictionaries/en.ts index d769ded05..3c2cd04d1 100644 --- a/web/projects/shared/src/i18n/dictionaries/en.ts +++ b/web/projects/shared/src/i18n/dictionaries/en.ts @@ -679,4 +679,16 @@ export const ENGLISH: Record = { 'Installation Complete!': 714, 'StartOS has been installed successfully.': 715, 'Continue to Setup': 716, + 'Set Outbound Gateway': 717, + 'Current': 718, + 'System default)': 719, + 'Outbound Gateway': 720, + 'Select the gateway for outbound traffic': 721, + 'The type of gateway': 722, + 'Outbound Only': 723, + 'Set as default outbound': 724, + 'Route all outbound traffic through this gateway': 725, + 'Wireguard Config File': 726, + 'Inbound/Outbound': 727, + 'StartTunnel (Inbound/Outbound)': 728, } diff --git a/web/projects/shared/src/i18n/dictionaries/es.ts b/web/projects/shared/src/i18n/dictionaries/es.ts index 1f82b395e..df7d867cb 100644 --- a/web/projects/shared/src/i18n/dictionaries/es.ts +++ b/web/projects/shared/src/i18n/dictionaries/es.ts @@ -679,4 +679,16 @@ export default { 714: '¡Instalación completada!', 715: 'StartOS se ha instalado correctamente.', 716: 'Continuar con la configuración', + 717: '', + 718: '', + 719: '', + 720: '', + 721: '', + 722: '', + 723: '', + 724: '', + 725: '', + 726: '', + 727: '', + 728: '', } satisfies i18n diff --git a/web/projects/shared/src/i18n/dictionaries/fr.ts b/web/projects/shared/src/i18n/dictionaries/fr.ts index db13e7d97..6d418644d 100644 --- a/web/projects/shared/src/i18n/dictionaries/fr.ts +++ b/web/projects/shared/src/i18n/dictionaries/fr.ts @@ -679,4 +679,16 @@ export default { 714: 'Installation terminée !', 715: 'StartOS a été installé avec succès.', 716: 'Continuer vers la configuration', + 717: '', + 718: '', + 719: '', + 720: '', + 721: '', + 722: '', + 723: '', + 724: '', + 725: '', + 726: '', + 727: '', + 728: '', } satisfies i18n diff --git a/web/projects/shared/src/i18n/dictionaries/pl.ts b/web/projects/shared/src/i18n/dictionaries/pl.ts index 13a3c4671..66d6ed757 100644 --- a/web/projects/shared/src/i18n/dictionaries/pl.ts +++ b/web/projects/shared/src/i18n/dictionaries/pl.ts @@ -679,4 +679,16 @@ export default { 714: 'Instalacja zakończona!', 715: 'StartOS został pomyślnie zainstalowany.', 716: 'Przejdź do konfiguracji', + 717: '', + 718: '', + 719: '', + 720: '', + 721: '', + 722: '', + 723: '', + 724: '', + 725: '', + 726: '', + 727: '', + 728: '', } satisfies i18n diff --git a/web/projects/start-tunnel/src/app/services/patch-db/data-model.ts b/web/projects/start-tunnel/src/app/services/patch-db/data-model.ts index f84546717..73548d7a5 100644 --- a/web/projects/start-tunnel/src/app/services/patch-db/data-model.ts +++ b/web/projects/start-tunnel/src/app/services/patch-db/data-model.ts @@ -45,8 +45,8 @@ export const mockTunnelData: TunnelData = { gateways: { eth0: { name: null, - public: null, secure: null, + type: null, ipInfo: { name: 'Wired Connection 1', scopeId: 1, diff --git a/web/projects/ui/src/app/routes/portal/routes/services/components/interface-item.component.ts b/web/projects/ui/src/app/routes/portal/routes/services/components/interface-item.component.ts index 4e76f7e54..9a5a94387 100644 --- a/web/projects/ui/src/app/routes/portal/routes/services/components/interface-item.component.ts +++ b/web/projects/ui/src/app/routes/portal/routes/services/components/interface-item.component.ts @@ -15,7 +15,6 @@ import { TuiBadge } from '@taiga-ui/kit' `, styles: ` :host { - clip-path: inset(0 round 0.75rem); cursor: pointer; &:hover { diff --git a/web/projects/ui/src/app/routes/portal/routes/services/routes/actions.component.ts b/web/projects/ui/src/app/routes/portal/routes/services/routes/actions.component.ts index 321423718..f8b0fcf47 100644 --- a/web/projects/ui/src/app/routes/portal/routes/services/routes/actions.component.ts +++ b/web/projects/ui/src/app/routes/portal/routes/services/routes/actions.component.ts @@ -6,12 +6,18 @@ import { inject, } from '@angular/core' import { toSignal } from '@angular/core/rxjs-interop' -import { getPkgId, i18nPipe } from '@start9labs/shared' -import { T } from '@start9labs/start-sdk' +import { + ErrorService, + getPkgId, + i18nPipe, + LoadingService, +} from '@start9labs/shared' +import { ISB, T } from '@start9labs/start-sdk' import { TuiCell } from '@taiga-ui/layout' import { PatchDB } from 'patch-db-client' -import { map } from 'rxjs' +import { firstValueFrom, map } from 'rxjs' import { ActionService } from 'src/app/services/action.service' +import { ApiService } from 'src/app/services/api/embassy-api.service' import { DataModel } from 'src/app/services/patch-db/data-model' import { StandardActionsService } from 'src/app/services/standard-actions.service' import { getManifest } from 'src/app/utils/get-package-data' @@ -20,6 +26,9 @@ import { PrimaryStatus, renderPkgStatus, } from 'src/app/services/pkg-status-rendering.service' +import { FormDialogService } from 'src/app/services/form-dialog.service' +import { FormComponent } from 'src/app/routes/portal/components/form.component' +import { configBuilderToSpec } from 'src/app/utils/configBuilderToSpec' const INACTIVE: PrimaryStatus[] = [ 'installing', @@ -65,6 +74,12 @@ const ALLOWED_STATUSES: Record> = {
StartOS
+
`, - styles: ` - :host { - max-width: 64rem; - } - `, changeDetection: ChangeDetectionStrategy.OnPush, imports: [ CommonModule, @@ -85,8 +81,19 @@ export default class GatewaysComponent { default: null, placeholder: 'StartTunnel 1', }), + type: ISB.Value.select({ + name: this.i18n.transform('Type'), + description: this.i18n.transform('The type of gateway'), + default: 'inbound-outbound', + values: { + 'inbound-outbound': this.i18n.transform( + 'StartTunnel (Inbound/Outbound)', + ), + 'outbound-only': this.i18n.transform('Outbound Only'), + }, + }), config: ISB.Value.union({ - name: this.i18n.transform('StartTunnel Config File'), + name: this.i18n.transform('Wireguard Config File'), default: 'paste', variants: ISB.Variants.of({ paste: { @@ -113,10 +120,17 @@ export default class GatewaysComponent { }, }), }), + setAsDefaultOutbound: ISB.Value.toggle({ + name: this.i18n.transform('Set as default outbound'), + description: this.i18n.transform( + 'Route all outbound traffic through this gateway', + ), + default: false, + }), }) this.formDialog.open(FormComponent, { - label: 'Add StartTunnel Gateway', + label: 'Add Wireguard Gateway', data: { spec: await configBuilderToSpec(spec), buttons: [ @@ -132,7 +146,8 @@ export default class GatewaysComponent { input.config.selection === 'paste' ? input.config.value.file : await (input.config.value.file as any as File).text(), - public: false, + type: input.type as RR.GatewayType, + setAsDefaultOutbound: input.setAsDefaultOutbound, }) return true } catch (e: any) { diff --git a/web/projects/ui/src/app/routes/portal/routes/system/routes/gateways/item.component.ts b/web/projects/ui/src/app/routes/portal/routes/system/routes/gateways/item.component.ts index 430c7e0df..14f63662d 100644 --- a/web/projects/ui/src/app/routes/portal/routes/system/routes/gateways/item.component.ts +++ b/web/projects/ui/src/app/routes/portal/routes/system/routes/gateways/item.component.ts @@ -15,6 +15,7 @@ import { TuiButton, TuiDataList, TuiDropdown, + TuiIcon, TuiOptGroup, TuiTextfield, } from '@taiga-ui/core' @@ -24,32 +25,55 @@ import { ApiService } from 'src/app/services/api/embassy-api.service' import { FormDialogService } from 'src/app/services/form-dialog.service' import { configBuilderToSpec } from 'src/app/utils/configBuilderToSpec' import { GatewayPlus } from 'src/app/services/gateway.service' +import { TuiBadge } from '@taiga-ui/kit' @Component({ selector: 'tr[gateway]', template: ` @if (gateway(); as gateway) { - + {{ gateway.name }} - - - @if (gateway.ipInfo.deviceType; as type) { - {{ type }} ({{ - gateway.public ? ('public' | i18n) : ('private' | i18n) - }}) - } @else { - - + @if (gateway.isDefaultOutbound) { + Default outbound + } + + + @switch (gateway.ipInfo.deviceType) { + @case ('ethernet') { + + {{ 'Ethernet' | i18n }} + } + @case ('wireless') { + + {{ 'WiFi' | i18n }} + } + @case ('wireguard') { + + {{ 'WireGuard' | i18n }} + } + @default { + {{ gateway.ipInfo.deviceType }} + } + } + + + @if (gateway.type === 'outbound-only') { + + {{ 'Outbound Only' | i18n }} + } @else { + + {{ 'Inbound/Outbound' | i18n }} } - {{ gateway.lanIpv4.join(', ') }} {{ gateway.ipInfo.wanIp || ('Error' | i18n) }} + {{ gateway.lanIpv4.join(', ') || '-' }} + + } @if (gateway.ipInfo.deviceType === 'wireguard') {