frontend plus some be types

This commit is contained in:
Matt Hill
2026-02-09 22:00:39 -07:00
parent b6262c8e13
commit cc5f316514
24 changed files with 528 additions and 156 deletions

View File

@@ -18,7 +18,7 @@ use crate::s9pk::manifest::{LocaleString, Manifest};
use crate::status::StatusInfo; use crate::status::StatusInfo;
use crate::util::DataUrl; use crate::util::DataUrl;
use crate::util::serde::{Pem, is_partial_of}; 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)] #[derive(Debug, Default, Deserialize, Serialize, TS)]
#[ts(export)] #[ts(export)]
@@ -381,6 +381,9 @@ pub struct PackageDataEntry {
pub hosts: Hosts, pub hosts: Hosts,
#[ts(type = "string[]")] #[ts(type = "string[]")]
pub store_exposed_dependents: Vec<JsonPointer>, pub store_exposed_dependents: Vec<JsonPointer>,
#[serde(default)]
#[ts(type = "string | null")]
pub outbound_gateway: Option<GatewayId>,
} }
impl AsRef<PackageDataEntry> for PackageDataEntry { impl AsRef<PackageDataEntry> for PackageDataEntry {
fn as_ref(&self) -> &PackageDataEntry { fn as_ref(&self) -> &PackageDataEntry {

View File

@@ -117,6 +117,7 @@ impl Public {
acme acme
}, },
dns: Default::default(), dns: Default::default(),
default_outbound: None,
}, },
status_info: ServerStatus { status_info: ServerStatus {
backup_progress: None, backup_progress: None,
@@ -220,6 +221,9 @@ pub struct NetworkInfo {
pub acme: BTreeMap<AcmeProvider, AcmeSettings>, pub acme: BTreeMap<AcmeProvider, AcmeSettings>,
#[serde(default)] #[serde(default)]
pub dns: DnsSettings, pub dns: DnsSettings,
#[serde(default)]
#[ts(type = "string | null")]
pub default_outbound: Option<GatewayId>,
} }
#[derive(Debug, Default, Deserialize, Serialize, HasModel, TS)] #[derive(Debug, Default, Deserialize, Serialize, HasModel, TS)]
@@ -239,39 +243,42 @@ pub struct DnsSettings {
#[ts(export)] #[ts(export)]
pub struct NetworkInterfaceInfo { pub struct NetworkInterfaceInfo {
pub name: Option<InternedString>, pub name: Option<InternedString>,
#[ts(skip)]
pub public: Option<bool>, pub public: Option<bool>,
pub secure: Option<bool>, pub secure: Option<bool>,
pub ip_info: Option<Arc<IpInfo>>, pub ip_info: Option<Arc<IpInfo>>,
#[serde(default, rename = "type")]
pub gateway_type: Option<GatewayType>,
} }
impl NetworkInterfaceInfo { impl NetworkInterfaceInfo {
pub fn public(&self) -> bool { pub fn public(&self) -> bool {
self.public.unwrap_or_else(|| { self.public.unwrap_or_else(|| {
!self.ip_info.as_ref().map_or(true, |ip_info| { !self.ip_info.as_ref().map_or(true, |ip_info| {
let ip4s = ip_info let ip4s = ip_info
.subnets .subnets
.iter() .iter()
.filter_map(|ipnet| { .filter_map(|ipnet| {
if let IpAddr::V4(ip4) = ipnet.addr() { if let IpAddr::V4(ip4) = ipnet.addr() {
Some(ip4) Some(ip4)
} else {
None
}
})
.collect::<BTreeSet<_>>();
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 { } else {
true None
} }
}) })
.collect::<BTreeSet<_>>();
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 { pub fn secure(&self) -> bool {
@@ -310,6 +317,15 @@ pub enum NetworkInterfaceType {
Loopback, 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)] #[derive(Debug, Deserialize, Serialize, HasModel, TS)]
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
#[model = "Model<Self>"] #[model = "Model<Self>"]

View File

@@ -758,13 +758,14 @@ async fn watch_ip(
write_to.send_if_modified( write_to.send_if_modified(
|m: &mut OrdMap<GatewayId, NetworkInterfaceInfo>| { |m: &mut OrdMap<GatewayId, NetworkInterfaceInfo>| {
let (name, public, secure, prev_wan_ip) = m let (name, public, secure, gateway_type, prev_wan_ip) = m
.get(&iface) .get(&iface)
.map_or((None, None, None, None), |i| { .map_or((None, None, None, None, None), |i| {
( (
i.name.clone(), i.name.clone(),
i.public, i.public,
i.secure, i.secure,
i.gateway_type,
i.ip_info i.ip_info
.as_ref() .as_ref()
.and_then(|i| i.wan_ip), .and_then(|i| i.wan_ip),
@@ -779,6 +780,7 @@ async fn watch_ip(
public, public,
secure, secure,
ip_info: Some(ip_info.clone()), ip_info: Some(ip_info.clone()),
gateway_type,
}, },
) )
.filter(|old| &old.ip_info == &Some(ip_info)) .filter(|old| &old.ip_info == &Some(ip_info))
@@ -1715,6 +1717,7 @@ fn test_filter() {
ntp_servers: Default::default(), ntp_servers: Default::default(),
dns_servers: Default::default(), dns_servers: Default::default(),
})), })),
gateway_type: None,
}, },
)); ));
} }

View File

@@ -8,7 +8,7 @@ use ts_rs::TS;
use crate::GatewayId; use crate::GatewayId;
use crate::context::{CliContext, RpcContext}; 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::net::host::all_hosts;
use crate::prelude::*; use crate::prelude::*;
use crate::util::Invoke; use crate::util::Invoke;
@@ -32,14 +32,19 @@ pub fn tunnel_api<C: Context>() -> ParentHandler<C> {
} }
#[derive(Debug, Clone, Deserialize, Serialize, Parser, TS)] #[derive(Debug, Clone, Deserialize, Serialize, Parser, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export)] #[ts(export)]
pub struct AddTunnelParams { pub struct AddTunnelParams {
#[arg(help = "help.arg.tunnel-name")] #[arg(help = "help.arg.tunnel-name")]
name: InternedString, name: InternedString,
#[arg(help = "help.arg.wireguard-config")] #[arg(help = "help.arg.wireguard-config")]
config: String, config: String,
#[arg(help = "help.arg.is-public")] #[arg(help = "help.arg.gateway-type")]
public: bool, #[serde(default, rename = "type")]
gateway_type: Option<GatewayType>,
#[arg(help = "help.arg.set-as-default-outbound")]
#[serde(default)]
set_as_default_outbound: bool,
} }
fn sanitize_config(config: &str) -> String { fn sanitize_config(config: &str) -> String {
@@ -64,7 +69,8 @@ pub async fn add_tunnel(
AddTunnelParams { AddTunnelParams {
name, name,
config, config,
public, gateway_type,
set_as_default_outbound,
}: AddTunnelParams, }: AddTunnelParams,
) -> Result<GatewayId, Error> { ) -> Result<GatewayId, Error> {
let ifaces = ctx.net_controller.net_iface.watcher.subscribe(); let ifaces = ctx.net_controller.net_iface.watcher.subscribe();
@@ -76,9 +82,10 @@ pub async fn add_tunnel(
iface.clone(), iface.clone(),
NetworkInterfaceInfo { NetworkInterfaceInfo {
name: Some(name), name: Some(name),
public: Some(public), public: None,
secure: None, secure: None,
ip_info: None, ip_info: None,
gateway_type,
}, },
); );
return true; return true;
@@ -120,6 +127,19 @@ pub async fn add_tunnel(
sub.recv().await; 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) Ok(iface)
} }

View File

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

View File

@@ -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. 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. 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 ## 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. 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. 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 ### 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. 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". 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
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 ### Static DNS Servers

View File

@@ -679,4 +679,16 @@ export default {
714: 'Installation abgeschlossen!', 714: 'Installation abgeschlossen!',
715: 'StartOS wurde erfolgreich installiert.', 715: 'StartOS wurde erfolgreich installiert.',
716: 'Weiter zur Einrichtung', 716: 'Weiter zur Einrichtung',
717: '',
718: '',
719: '',
720: '',
721: '',
722: '',
723: '',
724: '',
725: '',
726: '',
727: '',
728: '',
} satisfies i18n } satisfies i18n

View File

@@ -679,4 +679,16 @@ export const ENGLISH: Record<string, number> = {
'Installation Complete!': 714, 'Installation Complete!': 714,
'StartOS has been installed successfully.': 715, 'StartOS has been installed successfully.': 715,
'Continue to Setup': 716, '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,
} }

View File

@@ -679,4 +679,16 @@ export default {
714: '¡Instalación completada!', 714: '¡Instalación completada!',
715: 'StartOS se ha instalado correctamente.', 715: 'StartOS se ha instalado correctamente.',
716: 'Continuar con la configuración', 716: 'Continuar con la configuración',
717: '',
718: '',
719: '',
720: '',
721: '',
722: '',
723: '',
724: '',
725: '',
726: '',
727: '',
728: '',
} satisfies i18n } satisfies i18n

View File

@@ -679,4 +679,16 @@ export default {
714: 'Installation terminée !', 714: 'Installation terminée !',
715: 'StartOS a été installé avec succès.', 715: 'StartOS a été installé avec succès.',
716: 'Continuer vers la configuration', 716: 'Continuer vers la configuration',
717: '',
718: '',
719: '',
720: '',
721: '',
722: '',
723: '',
724: '',
725: '',
726: '',
727: '',
728: '',
} satisfies i18n } satisfies i18n

View File

@@ -679,4 +679,16 @@ export default {
714: 'Instalacja zakończona!', 714: 'Instalacja zakończona!',
715: 'StartOS został pomyślnie zainstalowany.', 715: 'StartOS został pomyślnie zainstalowany.',
716: 'Przejdź do konfiguracji', 716: 'Przejdź do konfiguracji',
717: '',
718: '',
719: '',
720: '',
721: '',
722: '',
723: '',
724: '',
725: '',
726: '',
727: '',
728: '',
} satisfies i18n } satisfies i18n

View File

@@ -45,8 +45,8 @@ export const mockTunnelData: TunnelData = {
gateways: { gateways: {
eth0: { eth0: {
name: null, name: null,
public: null,
secure: null, secure: null,
type: null,
ipInfo: { ipInfo: {
name: 'Wired Connection 1', name: 'Wired Connection 1',
scopeId: 1, scopeId: 1,

View File

@@ -15,7 +15,6 @@ import { TuiBadge } from '@taiga-ui/kit'
`, `,
styles: ` styles: `
:host { :host {
clip-path: inset(0 round 0.75rem);
cursor: pointer; cursor: pointer;
&:hover { &:hover {

View File

@@ -6,12 +6,18 @@ import {
inject, inject,
} from '@angular/core' } from '@angular/core'
import { toSignal } from '@angular/core/rxjs-interop' import { toSignal } from '@angular/core/rxjs-interop'
import { getPkgId, i18nPipe } from '@start9labs/shared' import {
import { T } from '@start9labs/start-sdk' ErrorService,
getPkgId,
i18nPipe,
LoadingService,
} from '@start9labs/shared'
import { ISB, T } from '@start9labs/start-sdk'
import { TuiCell } from '@taiga-ui/layout' import { TuiCell } from '@taiga-ui/layout'
import { PatchDB } from 'patch-db-client' import { PatchDB } from 'patch-db-client'
import { map } from 'rxjs' import { firstValueFrom, map } from 'rxjs'
import { ActionService } from 'src/app/services/action.service' 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 { DataModel } from 'src/app/services/patch-db/data-model'
import { StandardActionsService } from 'src/app/services/standard-actions.service' import { StandardActionsService } from 'src/app/services/standard-actions.service'
import { getManifest } from 'src/app/utils/get-package-data' import { getManifest } from 'src/app/utils/get-package-data'
@@ -20,6 +26,9 @@ import {
PrimaryStatus, PrimaryStatus,
renderPkgStatus, renderPkgStatus,
} from 'src/app/services/pkg-status-rendering.service' } 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[] = [ const INACTIVE: PrimaryStatus[] = [
'installing', 'installing',
@@ -65,6 +74,12 @@ const ALLOWED_STATUSES: Record<T.AllowedStatuses, Set<string>> = {
<section class="g-card"> <section class="g-card">
<header>StartOS</header> <header>StartOS</header>
<button
tuiCell
[action]="outboundGatewayAction()"
[inactive]="inactive"
(click)="openOutboundGatewayModal()"
></button>
<button <button
tuiCell tuiCell
[action]="rebuild" [action]="rebuild"
@@ -95,66 +110,78 @@ const ALLOWED_STATUSES: Record<T.AllowedStatuses, Set<string>> = {
export default class ServiceActionsRoute { export default class ServiceActionsRoute {
private readonly actions = inject(ActionService) private readonly actions = inject(ActionService)
private readonly i18n = inject(i18nPipe) private readonly i18n = inject(i18nPipe)
private readonly patch = inject<PatchDB<DataModel>>(PatchDB)
private readonly formDialog = inject(FormDialogService)
private readonly api = inject(ApiService)
private readonly loader = inject(LoadingService)
private readonly errorService = inject(ErrorService)
ungrouped: 'General' | 'Other' = 'General' ungrouped: 'General' | 'Other' = 'General'
readonly service = inject(StandardActionsService) readonly service = inject(StandardActionsService)
readonly package = toSignal( readonly package = toSignal(
inject<PatchDB<DataModel>>(PatchDB) this.patch.watch$('packageData', getPkgId()).pipe(
.watch$('packageData', getPkgId()) map(pkg => {
.pipe( const specialGroup = Object.values(pkg.actions).some(a => !!a.group)
map(pkg => { ? 'Other'
const specialGroup = Object.values(pkg.actions).some(a => !!a.group) : 'General'
? 'Other' const status = renderPkgStatus(pkg).primary
: 'General' return {
const status = renderPkgStatus(pkg).primary status,
return { icon: pkg.icon,
status, manifest: getManifest(pkg),
icon: pkg.icon, outboundGateway: pkg.outboundGateway,
manifest: getManifest(pkg), actions: Object.entries(pkg.actions)
actions: Object.entries(pkg.actions) .filter(([_, action]) => action.visibility !== 'hidden')
.filter(([_, action]) => action.visibility !== 'hidden') .map(([id, action]) => ({
.map(([id, action]) => ({ ...action,
...action, id,
id, group: action.group || specialGroup,
group: action.group || specialGroup, visibility: ALLOWED_STATUSES[action.allowedStatuses].has(status)
visibility: ALLOWED_STATUSES[action.allowedStatuses].has( ? action.visibility
status, : ({
) disabled: `${this.i18n.transform('Action can only be executed when service is')} ${this.i18n.transform(action.allowedStatuses === 'only-running' ? 'Running' : 'Stopped')?.toLowerCase()}`,
? action.visibility } as T.ActionVisibility),
: ({ }))
disabled: `${this.i18n.transform('Action can only be executed when service is')} ${this.i18n.transform(action.allowedStatuses === 'only-running' ? 'Running' : 'Stopped')?.toLowerCase()}`, .sort((a, b) => {
} as T.ActionVisibility), if (a.group === specialGroup && b.group !== specialGroup) return 1
})) if (b.group === specialGroup && a.group !== specialGroup)
.sort((a, b) => { return -1
if (a.group === specialGroup && b.group !== specialGroup)
return 1
if (b.group === specialGroup && a.group !== specialGroup)
return -1
const groupCompare = a.group.localeCompare(b.group) // sort groups lexicographically const groupCompare = a.group.localeCompare(b.group) // sort groups lexicographically
if (groupCompare !== 0) return groupCompare if (groupCompare !== 0) return groupCompare
return a.id.localeCompare(b.id) // sort actions within groups lexicographically return a.id.localeCompare(b.id) // sort actions within groups lexicographically
}) })
.reduce< .reduce<
Record< Record<
string, string,
Array<T.ActionMetadata & { id: string; group: string }> Array<T.ActionMetadata & { id: string; group: string }>
> >
>((acc, action) => { >((acc, action) => {
const key = action.group const key = action.group
if (!acc[key]) { if (!acc[key]) {
acc[key] = [] acc[key] = []
} }
acc[key].push(action) acc[key].push(action)
return acc return acc
}, {}), }, {}),
} }
}), }),
), ),
) )
readonly outboundGatewayAction = computed(() => {
const pkg = this.package()
const gateway = pkg?.outboundGateway
return {
name: this.i18n.transform('Set Outbound Gateway')!,
description: gateway
? `${this.i18n.transform('Current')}: ${gateway}`
: `${this.i18n.transform('Current')}: ${this.i18n.transform('System')}`,
}
})
readonly rebuild = { readonly rebuild = {
name: this.i18n.transform('Rebuild Service')!, name: this.i18n.transform('Rebuild Service')!,
description: this.i18n.transform( description: this.i18n.transform(
@@ -181,6 +208,71 @@ export default class ServiceActionsRoute {
}) })
} }
async openOutboundGatewayModal() {
const pkg = this.package()
if (!pkg) return
const gateways = await firstValueFrom(
this.patch.watch$('serverInfo', 'network', 'gateways'),
)
const SYSTEM_KEY = 'system'
const options: Record<string, string> = {
[SYSTEM_KEY]: this.i18n.transform('System default')!,
}
Object.entries(gateways)
.filter(
([_, g]) =>
!!g.ipInfo &&
g.ipInfo.deviceType !== 'bridge' &&
g.ipInfo.deviceType !== 'loopback',
)
.forEach(([id, g]) => {
options[id] = g.name ?? g.ipInfo?.name ?? id
})
const spec = ISB.InputSpec.of({
gateway: ISB.Value.select({
name: this.i18n.transform('Outbound Gateway'),
description: this.i18n.transform(
'Select the gateway for outbound traffic',
),
default: pkg.outboundGateway ?? SYSTEM_KEY,
values: options,
}),
})
this.formDialog.open(FormComponent, {
label: 'Set Outbound Gateway',
data: {
spec: await configBuilderToSpec(spec),
buttons: [
{
text: this.i18n.transform('Save'),
handler: async (input: typeof spec._TYPE) => {
const loader = this.loader.open('Saving').subscribe()
try {
await this.api.setServiceOutbound({
packageId: pkg.manifest.id,
gateway: input.gateway === SYSTEM_KEY ? null : input.gateway,
})
return true
} catch (e: any) {
this.errorService.handleError(e)
return false
} finally {
loader.unsubscribe()
}
},
},
],
},
})
}
protected readonly isInactive = computed( protected readonly isInactive = computed(
(pkg = this.package()) => !pkg || INACTIVE.includes(pkg.status), (pkg = this.package()) => !pkg || INACTIVE.includes(pkg.status),
) )

View File

@@ -15,6 +15,7 @@ import { GatewaysTableComponent } from './table.component'
import { configBuilderToSpec } from 'src/app/utils/configBuilderToSpec' import { configBuilderToSpec } from 'src/app/utils/configBuilderToSpec'
import { TitleDirective } from 'src/app/services/title.service' import { TitleDirective } from 'src/app/services/title.service'
import { ISB } from '@start9labs/start-sdk' import { ISB } from '@start9labs/start-sdk'
import { RR } from 'src/app/services/api/api.types'
@Component({ @Component({
template: ` template: `
@@ -51,11 +52,6 @@ import { ISB } from '@start9labs/start-sdk'
<gateways-table /> <gateways-table />
</section> </section>
`, `,
styles: `
:host {
max-width: 64rem;
}
`,
changeDetection: ChangeDetectionStrategy.OnPush, changeDetection: ChangeDetectionStrategy.OnPush,
imports: [ imports: [
CommonModule, CommonModule,
@@ -85,8 +81,19 @@ export default class GatewaysComponent {
default: null, default: null,
placeholder: 'StartTunnel 1', 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({ config: ISB.Value.union({
name: this.i18n.transform('StartTunnel Config File'), name: this.i18n.transform('Wireguard Config File'),
default: 'paste', default: 'paste',
variants: ISB.Variants.of({ variants: ISB.Variants.of({
paste: { 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, { this.formDialog.open(FormComponent, {
label: 'Add StartTunnel Gateway', label: 'Add Wireguard Gateway',
data: { data: {
spec: await configBuilderToSpec(spec), spec: await configBuilderToSpec(spec),
buttons: [ buttons: [
@@ -132,7 +146,8 @@ export default class GatewaysComponent {
input.config.selection === 'paste' input.config.selection === 'paste'
? input.config.value.file ? input.config.value.file
: await (input.config.value.file as any as File).text(), : await (input.config.value.file as any as File).text(),
public: false, type: input.type as RR.GatewayType,
setAsDefaultOutbound: input.setAsDefaultOutbound,
}) })
return true return true
} catch (e: any) { } catch (e: any) {

View File

@@ -15,6 +15,7 @@ import {
TuiButton, TuiButton,
TuiDataList, TuiDataList,
TuiDropdown, TuiDropdown,
TuiIcon,
TuiOptGroup, TuiOptGroup,
TuiTextfield, TuiTextfield,
} from '@taiga-ui/core' } 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 { FormDialogService } from 'src/app/services/form-dialog.service'
import { configBuilderToSpec } from 'src/app/utils/configBuilderToSpec' import { configBuilderToSpec } from 'src/app/utils/configBuilderToSpec'
import { GatewayPlus } from 'src/app/services/gateway.service' import { GatewayPlus } from 'src/app/services/gateway.service'
import { TuiBadge } from '@taiga-ui/kit'
@Component({ @Component({
selector: 'tr[gateway]', selector: 'tr[gateway]',
template: ` template: `
@if (gateway(); as gateway) { @if (gateway(); as gateway) {
<td class="name"> <td>
{{ gateway.name }} {{ gateway.name }}
</td> @if (gateway.isDefaultOutbound) {
<td class="type"> <span tuiBadge tuiStatus appearance="positive">Default outbound</span>
@if (gateway.ipInfo.deviceType; as type) { }
{{ type }} ({{ </td>
gateway.public ? ('public' | i18n) : ('private' | i18n) <td>
}}) @switch (gateway.ipInfo.deviceType) {
} @else { @case ('ethernet') {
- <tui-icon icon="@tui.cable" />
{{ 'Ethernet' | i18n }}
}
@case ('wireless') {
<tui-icon icon="@tui.wifi" />
{{ 'WiFi' | i18n }}
}
@case ('wireguard') {
<tui-icon icon="@tui.shield" />
{{ 'WireGuard' | i18n }}
}
@default {
{{ gateway.ipInfo.deviceType }}
}
}
</td>
<td>
@if (gateway.type === 'outbound-only') {
<tui-icon icon="@tui.arrow-up-right" />
{{ 'Outbound Only' | i18n }}
} @else {
<tui-icon icon="@tui.arrow-left-right" />
{{ 'Inbound/Outbound' | i18n }}
} }
</td> </td>
<td class="lan">{{ gateway.lanIpv4.join(', ') }}</td>
<td <td
class="wan" class="wan"
[style.color]=" [style.color]="
gateway.ipInfo.wanIp ? 'var(--tui-text-warning)' : undefined gateway.ipInfo.wanIp ? undefined : 'var(--tui-text-warning)'
" "
> >
{{ gateway.ipInfo.wanIp || ('Error' | i18n) }} {{ gateway.ipInfo.wanIp || ('Error' | i18n) }}
</td> </td>
<td class="lan">{{ gateway.lanIpv4.join(', ') || '-' }}</td>
<td> <td>
<button <button
tuiIconButton tuiIconButton
@@ -67,6 +91,18 @@ import { GatewayPlus } from 'src/app/services/gateway.service'
{{ 'Rename' | i18n }} {{ 'Rename' | i18n }}
</button> </button>
</tui-opt-group> </tui-opt-group>
@if (!gateway.isDefaultOutbound) {
<tui-opt-group>
<button
tuiOption
new
iconStart="@tui.arrow-up-right"
(click)="setDefaultOutbound()"
>
{{ 'Set as Default Outbound' | i18n }}
</button>
</tui-opt-group>
}
@if (gateway.ipInfo.deviceType === 'wireguard') { @if (gateway.ipInfo.deviceType === 'wireguard') {
<tui-opt-group> <tui-opt-group>
<button <button
@@ -87,19 +123,11 @@ import { GatewayPlus } from 'src/app/services/gateway.service'
`, `,
styles: ` styles: `
td:last-child { td:last-child {
grid-area: 1 / 3 / 5; grid-area: 1 / 3 / 7;
align-self: center; align-self: center;
text-align: right; text-align: right;
} }
.name {
width: 14rem;
}
.type {
width: 14rem;
}
:host-context(tui-root._mobile) { :host-context(tui-root._mobile) {
grid-template-columns: min-content 1fr min-content; grid-template-columns: min-content 1fr min-content;
@@ -107,11 +135,15 @@ import { GatewayPlus } from 'src/app/services/gateway.service'
grid-column: span 2; grid-column: span 2;
} }
.type { .connection {
grid-column: span 2; grid-column: span 2;
order: -1; order: -1;
} }
.type {
grid-column: span 2;
}
.lan, .lan,
.wan { .wan {
grid-column: span 2; grid-column: span 2;
@@ -132,9 +164,11 @@ import { GatewayPlus } from 'src/app/services/gateway.service'
TuiButton, TuiButton,
TuiDropdown, TuiDropdown,
TuiDataList, TuiDataList,
TuiIcon,
TuiOptGroup, TuiOptGroup,
TuiTextfield, TuiTextfield,
i18nPipe, i18nPipe,
TuiBadge,
], ],
}) })
export class GatewaysItemComponent { export class GatewaysItemComponent {
@@ -166,6 +200,18 @@ export class GatewaysItemComponent {
}) })
} }
async setDefaultOutbound() {
const loader = this.loader.open().subscribe()
try {
await this.api.setDefaultOutbound({ gateway: this.gateway().id })
} catch (e: any) {
this.errorService.handleError(e)
} finally {
loader.unsubscribe()
}
}
async rename() { async rename() {
const { id, name } = this.gateway() const { id, name } = this.gateway()
const renameSpec = ISB.InputSpec.of({ const renameSpec = ISB.InputSpec.of({

View File

@@ -8,12 +8,21 @@ import { GatewayService } from 'src/app/services/gateway.service'
@Component({ @Component({
selector: 'gateways-table', selector: 'gateways-table',
template: ` template: `
<table [appTable]="['Name', 'Type', $any('LAN IP'), $any('WAN IP'), null]"> <table
[appTable]="[
'Name',
'Connection',
'Type',
$any('WAN IP'),
$any('LAN IP'),
null,
]"
>
@for (gateway of gatewayService.gateways(); track $index) { @for (gateway of gatewayService.gateways(); track $index) {
<tr [gateway]="gateway"></tr> <tr [gateway]="gateway"></tr>
} @empty { } @empty {
<tr> <tr>
<td colspan="5"> <td colspan="7">
<div [tuiSkeleton]="true">{{ 'Loading' | i18n }}</div> <div [tuiSkeleton]="true">{{ 'Loading' | i18n }}</div>
</td> </td>
</tr> </tr>

View File

@@ -2270,6 +2270,7 @@ export namespace Mock {
}, },
}, },
storeExposedDependents: [], storeExposedDependents: [],
outboundGateway: null,
registry: 'https://registry.start9.com/', registry: 'https://registry.start9.com/',
developerKey: 'developer-key', developerKey: 'developer-key',
tasks: { tasks: {
@@ -2338,6 +2339,7 @@ export namespace Mock {
}, },
hosts: {}, hosts: {},
storeExposedDependents: [], storeExposedDependents: [],
outboundGateway: null,
registry: 'https://registry.start9.com/', registry: 'https://registry.start9.com/',
developerKey: 'developer-key', developerKey: 'developer-key',
tasks: {}, tasks: {},
@@ -2444,6 +2446,7 @@ export namespace Mock {
}, },
hosts: {}, hosts: {},
storeExposedDependents: [], storeExposedDependents: [],
outboundGateway: null,
registry: 'https://registry.start9.com/', registry: 'https://registry.start9.com/',
developerKey: 'developer-key', developerKey: 'developer-key',
tasks: { tasks: {

View File

@@ -258,10 +258,13 @@ export namespace RR {
// network // network
export type GatewayType = 'inbound-outbound' | 'outbound-only'
export type AddTunnelReq = { export type AddTunnelReq = {
name: string name: string
config: string // file contents config: string // file contents
public: boolean type: GatewayType
setAsDefaultOutbound?: boolean
} // net.tunnel.add } // net.tunnel.add
export type AddTunnelRes = { export type AddTunnelRes = {
id: string id: string
@@ -276,6 +279,17 @@ export namespace RR {
export type RemoveTunnelReq = { id: string } // net.tunnel.remove export type RemoveTunnelReq = { id: string } // net.tunnel.remove
export type RemoveTunnelRes = null export type RemoveTunnelRes = null
// Set default outbound gateway
export type SetDefaultOutboundReq = { gateway: string | null } // net.gateway.set-default-outbound
export type SetDefaultOutboundRes = null
// Set service outbound gateway
export type SetServiceOutboundReq = {
packageId: string
gateway: string | null
} // package.set-outbound-gateway
export type SetServiceOutboundRes = null
export type InitAcmeReq = { export type InitAcmeReq = {
provider: string provider: string
contact: string[] contact: string[]

View File

@@ -183,6 +183,14 @@ export abstract class ApiService {
abstract removeTunnel(params: RR.RemoveTunnelReq): Promise<RR.RemoveTunnelRes> abstract removeTunnel(params: RR.RemoveTunnelReq): Promise<RR.RemoveTunnelRes>
abstract setDefaultOutbound(
params: RR.SetDefaultOutboundReq,
): Promise<RR.SetDefaultOutboundRes>
abstract setServiceOutbound(
params: RR.SetServiceOutboundReq,
): Promise<RR.SetServiceOutboundRes>
// ** domains ** // ** domains **
// wifi // wifi

View File

@@ -369,6 +369,18 @@ export class LiveApiService extends ApiService {
return this.rpcRequest({ method: 'net.tunnel.remove', params }) return this.rpcRequest({ method: 'net.tunnel.remove', params })
} }
async setDefaultOutbound(
params: RR.SetDefaultOutboundReq,
): Promise<RR.SetDefaultOutboundRes> {
return this.rpcRequest({ method: 'net.gateway.set-default-outbound', params })
}
async setServiceOutbound(
params: RR.SetServiceOutboundReq,
): Promise<RR.SetServiceOutboundRes> {
return this.rpcRequest({ method: 'package.set-outbound-gateway', params })
}
// wifi // wifi
async enableWifi(params: RR.EnabledWifiReq): Promise<RR.EnabledWifiRes> { async enableWifi(params: RR.EnabledWifiReq): Promise<RR.EnabledWifiRes> {

View File

@@ -592,13 +592,12 @@ export class MockApiService extends ApiService {
const id = `wg${this.proxyId++}` const id = `wg${this.proxyId++}`
const patch: AddOperation<T.NetworkInterfaceInfo>[] = [ const patch: AddOperation<any>[] = [
{ {
op: PatchOp.ADD, op: PatchOp.ADD,
path: `/serverInfo/network/gateways/${id}`, path: `/serverInfo/network/gateways/${id}`,
value: { value: {
name: params.name, name: params.name,
public: params.public,
secure: false, secure: false,
ipInfo: { ipInfo: {
name: id, name: id,
@@ -610,9 +609,19 @@ export class MockApiService extends ApiService {
lanIp: ['192.168.1.10'], lanIp: ['192.168.1.10'],
dnsServers: [], dnsServers: [],
}, },
type: params.type,
}, },
}, },
] ]
if (params.setAsDefaultOutbound) {
;(patch as any[]).push({
op: PatchOp.REPLACE,
path: '/serverInfo/network/defaultOutbound',
value: id,
})
}
this.mockRevision(patch) this.mockRevision(patch)
return { id } return { id }
@@ -646,6 +655,38 @@ export class MockApiService extends ApiService {
return null return null
} }
async setDefaultOutbound(
params: RR.SetDefaultOutboundReq,
): Promise<RR.SetDefaultOutboundRes> {
await pauseFor(2000)
const patch = [
{
op: PatchOp.REPLACE,
path: '/serverInfo/network/defaultOutbound',
value: params.gateway,
},
]
this.mockRevision(patch)
return null
}
async setServiceOutbound(
params: RR.SetServiceOutboundReq,
): Promise<RR.SetServiceOutboundRes> {
await pauseFor(2000)
const patch = [
{
op: PatchOp.REPLACE,
path: `/packageData/${params.packageId}/outboundGateway`,
value: params.gateway,
},
]
this.mockRevision(patch)
return null
}
// wifi // wifi
async enableWifi(params: RR.EnabledWifiReq): Promise<RR.EnabledWifiRes> { async enableWifi(params: RR.EnabledWifiReq): Promise<RR.EnabledWifiRes> {

View File

@@ -139,8 +139,8 @@ export const mockPatchData: DataModel = {
gateways: { gateways: {
eth0: { eth0: {
name: null, name: null,
public: null,
secure: null, secure: null,
type: null,
ipInfo: { ipInfo: {
name: 'Wired Connection 1', name: 'Wired Connection 1',
scopeId: 1, scopeId: 1,
@@ -154,8 +154,8 @@ export const mockPatchData: DataModel = {
}, },
wlan0: { wlan0: {
name: null, name: null,
public: null,
secure: null, secure: null,
type: null,
ipInfo: { ipInfo: {
name: 'Wireless Connection 1', name: 'Wireless Connection 1',
scopeId: 2, scopeId: 2,
@@ -172,8 +172,8 @@ export const mockPatchData: DataModel = {
}, },
wireguard1: { wireguard1: {
name: 'StartTunnel', name: 'StartTunnel',
public: null,
secure: null, secure: null,
type: 'inbound-outbound',
ipInfo: { ipInfo: {
name: 'wireguard1', name: 'wireguard1',
scopeId: 2, scopeId: 2,
@@ -188,7 +188,23 @@ export const mockPatchData: DataModel = {
dnsServers: ['1.1.1.1'], dnsServers: ['1.1.1.1'],
}, },
}, },
wireguard2: {
name: 'Mullvad VPN',
secure: null,
type: 'outbound-only',
ipInfo: {
name: 'wireguard2',
scopeId: 4,
deviceType: 'wireguard',
subnets: [],
wanIp: '198.51.100.77',
ntpServers: [],
lanIp: [],
dnsServers: ['10.64.0.1'],
},
},
}, },
defaultOutbound: 'eth0',
dns: { dns: {
dhcpServers: ['1.1.1.1', '8.8.8.8'], dhcpServers: ['1.1.1.1', '8.8.8.8'],
staticServers: null, staticServers: null,
@@ -335,6 +351,7 @@ export const mockPatchData: DataModel = {
}, },
hosts: {}, hosts: {},
storeExposedDependents: [], storeExposedDependents: [],
outboundGateway: null,
registry: 'https://registry.start9.com/', registry: 'https://registry.start9.com/',
developerKey: 'developer-key', developerKey: 'developer-key',
tasks: { tasks: {
@@ -656,6 +673,7 @@ export const mockPatchData: DataModel = {
}, },
}, },
storeExposedDependents: [], storeExposedDependents: [],
outboundGateway: null,
registry: 'https://registry.start9.com/', registry: 'https://registry.start9.com/',
developerKey: 'developer-key', developerKey: 'developer-key',
tasks: { tasks: {

View File

@@ -1,7 +1,7 @@
import { inject, Injectable } from '@angular/core' import { inject, Injectable } from '@angular/core'
import { PatchDB } from 'patch-db-client' import { PatchDB } from 'patch-db-client'
import { T, utils } from '@start9labs/start-sdk' import { T, utils } from '@start9labs/start-sdk'
import { map } from 'rxjs/operators' import { map } from 'rxjs'
import { DataModel } from './patch-db/data-model' import { DataModel } from './patch-db/data-model'
import { toSignal } from '@angular/core/rxjs-interop' import { toSignal } from '@angular/core/rxjs-interop'
@@ -12,39 +12,47 @@ export type GatewayPlus = T.NetworkInterfaceInfo & {
subnets: utils.IpNet[] subnets: utils.IpNet[]
lanIpv4: string[] lanIpv4: string[]
wanIp?: utils.IpAddress wanIp?: utils.IpAddress
public: boolean isDefaultOutbound: boolean
} }
@Injectable() @Injectable()
export class GatewayService { export class GatewayService {
private readonly patch = inject<PatchDB<DataModel>>(PatchDB)
private readonly network$ = this.patch.watch$('serverInfo', 'network')
readonly defaultOutbound = toSignal(
this.network$.pipe(map(n => n.defaultOutbound)),
)
readonly gateways = toSignal( readonly gateways = toSignal(
inject<PatchDB<DataModel>>(PatchDB) this.network$.pipe(
.watch$('serverInfo', 'network', 'gateways') map(network => {
.pipe( const gateways = network.gateways
map(gateways => const defaultOutbound = network.defaultOutbound
Object.entries(gateways) return Object.entries(gateways)
.filter(([_, val]) => !!val?.ipInfo) .filter(([_, val]) => !!val?.ipInfo)
.filter( .filter(
([_, val]) => ([_, val]) =>
val?.ipInfo?.deviceType !== 'bridge' && val?.ipInfo?.deviceType !== 'bridge' &&
val?.ipInfo?.deviceType !== 'loopback', val?.ipInfo?.deviceType !== 'loopback',
) )
.map(([id, val]) => { .map(([id, val]) => {
const subnets = const subnets =
val.ipInfo?.subnets.map(s => utils.IpNet.parse(s)) ?? [] val.ipInfo?.subnets.map(s => utils.IpNet.parse(s)) ?? []
const name = val.name ?? val.ipInfo!.name const name = val.name ?? val.ipInfo!.name
return { return {
...val, ...val,
id, id,
name, name,
subnets, subnets,
lanIpv4: subnets.filter(s => s.isIpv4()).map(s => s.address), lanIpv4: subnets.filter(s => s.isIpv4()).map(s => s.address),
public: val.public ?? subnets.some(s => s.isPublic()), wanIp:
wanIp: val.ipInfo?.wanIp && utils.IpAddress.parse(val.ipInfo?.wanIp),
val.ipInfo?.wanIp && utils.IpAddress.parse(val.ipInfo?.wanIp), isDefaultOutbound: id === defaultOutbound,
} as GatewayPlus } as GatewayPlus
}), })
), }),
), ),
) )
} }