From 8b89f016ad669181df3b2cfbb4e786249e002eb9 Mon Sep 17 00:00:00 2001 From: Matt Hill Date: Fri, 6 Mar 2026 00:30:06 -0700 Subject: [PATCH] task fix and keyboard fix (#3130) * task fix and keyboard fix * fixes for build scripts * passthrough feature * feat: inline domain health checks and improve address UX - addPublicDomain returns DNS query + port check results (AddPublicDomainRes) so frontend skips separate API calls after adding a domain - addPrivateDomain returns check_dns result for the gateway - Support multiple ports per domain in validation modal (deduplicated) - Run port checks concurrently via futures::future::join_all - Add note to add-domain dialog showing other interfaces on same host - Add addXForwardedHeaders to knownProtocols in SDK Host.ts - Add plugin filter kind, pluginId filter, matchesAny, and docs to getServiceInterface.ts - Add PassthroughInfo type and passthroughs field to NetworkInfo - Pluralize "port forwarding rules" in i18n dictionaries * feat: add shared host note to private domain dialog with i18n * fix: scope public domain to single binding and return single port check Accept internalPort in AddPublicDomainParams to target a specific binding. Disable the domain on all other bindings. Return a single CheckPortRes instead of Vec. Revert multi-port UI to singular port display from 0f8a66b35. * better shared hostname approach, and improve look-feel of addresses tables * fix starttls * preserve usb as top efi boot option * fix race condition in wan ip check * sdk beta.56 * various bug, improve smtp * multiple bugs, better outbound gateway UX * remove non option from smtp for better package compat * bump sdk --------- Co-authored-by: Aiden McClelland --- build/image-recipe/build.sh | 2 +- build/lib/scripts/upgrade | 17 +- build/manage-release.sh | 46 ++- container-runtime/package-lock.json | 2 +- core/CLAUDE.md | 1 + core/src/db/model/public.rs | 5 +- core/src/net/acme.rs | 10 +- core/src/net/gateway.rs | 21 +- core/src/net/host/address.rs | 112 +++++- core/src/net/host/mod.rs | 28 ++ core/src/net/net_controller.rs | 68 +++- core/src/net/ssl.rs | 5 +- core/src/net/tls.rs | 99 ++++-- core/src/net/vhost.rs | 335 +++++++++++++++--- core/src/os_install/mod.rs | 87 ++++- core/src/system/mod.rs | 20 +- core/src/tunnel/web.rs | 6 +- .../lib/actions/input/inputSpecConstants.ts | 87 +++-- sdk/base/lib/interfaces/Host.ts | 10 +- .../lib/osBindings/AddPublicDomainParams.ts | 1 + sdk/base/lib/osBindings/AddPublicDomainRes.ts | 4 + sdk/base/lib/osBindings/NetworkInfo.ts | 2 + sdk/base/lib/osBindings/PassthroughInfo.ts | 9 + sdk/base/lib/osBindings/index.ts | 2 + sdk/base/lib/types.ts | 32 +- sdk/base/lib/util/getServiceInterface.ts | 111 +++++- sdk/base/lib/util/index.ts | 1 + sdk/package/lib/StartSdk.ts | 100 +++++- sdk/package/package-lock.json | 4 +- sdk/package/package.json | 2 +- .../setup-wizard/src/app/pages/drives.page.ts | 246 +++++++------ .../src/app/pages/keyboard.page.ts | 17 +- .../src/app/pages/language.page.ts | 8 +- .../src/app/services/mock-api.service.ts | 113 ++++++ .../src/app/services/state.service.ts | 19 + .../shared/assets/icons/letsencrypt.svg | 1 + .../src/directives/docs-link.directive.ts | 2 +- .../shared/src/i18n/dictionaries/de.ts | 15 +- .../shared/src/i18n/dictionaries/en.ts | 15 +- .../shared/src/i18n/dictionaries/es.ts | 15 +- .../shared/src/i18n/dictionaries/fr.ts | 15 +- .../shared/src/i18n/dictionaries/pl.ts | 15 +- .../portal/components/form.component.ts | 11 + .../components/header/menu.component.ts | 14 +- .../interfaces/addresses/actions.component.ts | 26 +- .../addresses/addresses.component.ts | 46 ++- .../addresses/domain-health.service.ts | 49 ++- .../interfaces/addresses/item.component.ts | 114 +++++- .../interfaces/addresses/plugin.component.ts | 2 +- .../interfaces/interface.service.ts | 10 +- .../services/components/action.component.ts | 2 +- .../services/components/task.component.ts | 3 +- .../services/routes/actions.component.ts | 8 +- .../services/routes/interface.component.ts | 11 +- .../authorities/authorities.component.ts | 2 +- .../routes/system/routes/dns/dns.component.ts | 2 +- .../system/routes/email/email.component.ts | 258 -------------- .../routes/gateways/gateways.component.ts | 167 ++++++++- .../system/routes/gateways/item.component.ts | 32 +- .../system/routes/gateways/table.component.ts | 1 - .../system/routes/smtp/smtp.component.ts | 280 +++++++++++++++ .../routes/system/routes/ssh/ssh.component.ts | 2 +- .../routes/startos-ui/startos-ui.component.ts | 5 +- .../system/routes/wifi/wifi.component.ts | 2 +- .../portal/routes/system/system.routes.ts | 2 +- .../app/services/api/embassy-api.service.ts | 12 +- .../services/api/embassy-live-api.service.ts | 12 +- .../services/api/embassy-mock-api.service.ts | 38 +- .../ui/src/app/services/api/mock-patch.ts | 3 +- .../ui/src/app/services/gateway.service.ts | 3 - .../src/app/services/marketplace.service.ts | 1 - web/projects/ui/src/styles.scss | 6 + 72 files changed, 2075 insertions(+), 759 deletions(-) create mode 100644 sdk/base/lib/osBindings/AddPublicDomainRes.ts create mode 100644 sdk/base/lib/osBindings/PassthroughInfo.ts create mode 100644 web/projects/shared/assets/icons/letsencrypt.svg delete mode 100644 web/projects/ui/src/app/routes/portal/routes/system/routes/email/email.component.ts create mode 100644 web/projects/ui/src/app/routes/portal/routes/system/routes/smtp/smtp.component.ts diff --git a/build/image-recipe/build.sh b/build/image-recipe/build.sh index eb5b7fff2..8bd27daf3 100755 --- a/build/image-recipe/build.sh +++ b/build/image-recipe/build.sh @@ -105,7 +105,7 @@ lb config \ --iso-preparer "START9 LABS; HTTPS://START9.COM" \ --iso-publisher "START9 LABS; HTTPS://START9.COM" \ --backports true \ - --bootappend-live "boot=live noautologin" \ + --bootappend-live "boot=live noautologin console=tty0" \ --bootloaders $BOOTLOADERS \ --cache false \ --mirror-bootstrap "https://deb.debian.org/debian/" \ diff --git a/build/lib/scripts/upgrade b/build/lib/scripts/upgrade index 60c1b5556..35230eb0a 100755 --- a/build/lib/scripts/upgrade +++ b/build/lib/scripts/upgrade @@ -62,12 +62,27 @@ fi chroot /media/startos/next bash -e << "EOF" if [ -f /boot/grub/grub.cfg ]; then - grub-install --no-nvram /dev/$(eval $(lsblk -o MOUNTPOINT,PKNAME -P | grep 'MOUNTPOINT="/media/startos/root"') && echo $PKNAME) + grub-install /dev/$(eval $(lsblk -o MOUNTPOINT,PKNAME -P | grep 'MOUNTPOINT="/media/startos/root"') && echo $PKNAME) update-grub fi EOF +# Promote the USB installer boot entry back to first in EFI boot order. +# The entry number was saved during initial OS install. +if [ -d /sys/firmware/efi ] && [ -f /media/startos/config/efi-installer-entry ]; then + USB_ENTRY=$(cat /media/startos/config/efi-installer-entry) + if [ -n "$USB_ENTRY" ]; then + CURRENT_ORDER=$(efibootmgr | grep BootOrder | sed 's/BootOrder: //') + OTHER_ENTRIES=$(echo "$CURRENT_ORDER" | tr ',' '\n' | grep -v "$USB_ENTRY" | tr '\n' ',' | sed 's/,$//') + if [ -n "$OTHER_ENTRIES" ]; then + efibootmgr -o "$USB_ENTRY,$OTHER_ENTRIES" + else + efibootmgr -o "$USB_ENTRY" + fi + fi +fi + sync umount -Rl /media/startos/next diff --git a/build/manage-release.sh b/build/manage-release.sh index bd98dfbd1..c3b71717a 100755 --- a/build/manage-release.sh +++ b/build/manage-release.sh @@ -11,10 +11,22 @@ START9_GPG_KEY="2D63C217" ARCHES="aarch64 aarch64-nonfree aarch64-nvidia riscv64 riscv64-nonfree x86_64 x86_64-nonfree x86_64-nvidia" CLI_ARCHES="aarch64 riscv64 x86_64" +parse_run_id() { + local val="$1" + if [[ "$val" =~ /actions/runs/([0-9]+) ]]; then + echo "${BASH_REMATCH[1]}" + else + echo "$val" + fi +} + require_version() { - if [ -z "$VERSION" ]; then - >&2 echo '$VERSION required' - exit 2 + if [ -z "${VERSION:-}" ]; then + read -rp "VERSION: " VERSION + if [ -z "$VERSION" ]; then + >&2 echo '$VERSION required' + exit 2 + fi fi } @@ -75,6 +87,22 @@ resolve_gh_user() { cmd_download() { require_version + + if [ -z "${RUN_ID:-}" ]; then + read -rp "RUN_ID (OS images, leave blank to skip): " RUN_ID + fi + RUN_ID=$(parse_run_id "${RUN_ID:-}") + + if [ -z "${ST_RUN_ID:-}" ]; then + read -rp "ST_RUN_ID (start-tunnel, leave blank to skip): " ST_RUN_ID + fi + ST_RUN_ID=$(parse_run_id "${ST_RUN_ID:-}") + + if [ -z "${CLI_RUN_ID:-}" ]; then + read -rp "CLI_RUN_ID (start-cli, leave blank to skip): " CLI_RUN_ID + fi + CLI_RUN_ID=$(parse_run_id "${CLI_RUN_ID:-}") + ensure_release_dir if [ -n "$RUN_ID" ]; then @@ -143,10 +171,14 @@ cmd_upload() { enter_release_dir for file in $(release_files); do - gh release upload -R $REPO "v$VERSION" "$file" - done - for file in *.iso *.squashfs; do - s3cmd put -P "$file" "$S3_BUCKET/v$VERSION/$file" + case "$file" in + *.iso|*.squashfs) + s3cmd put -P "$file" "$S3_BUCKET/v$VERSION/$file" + ;; + *) + gh release upload -R $REPO "v$VERSION" "$file" + ;; + esac done } diff --git a/container-runtime/package-lock.json b/container-runtime/package-lock.json index 7ff3f092f..b690a6d74 100644 --- a/container-runtime/package-lock.json +++ b/container-runtime/package-lock.json @@ -37,7 +37,7 @@ }, "../sdk/dist": { "name": "@start9labs/start-sdk", - "version": "0.4.0-beta.55", + "version": "0.4.0-beta.58", "license": "MIT", "dependencies": { "@iarna/toml": "^3.0.0", diff --git a/core/CLAUDE.md b/core/CLAUDE.md index dbb053348..883e68991 100644 --- a/core/CLAUDE.md +++ b/core/CLAUDE.md @@ -25,3 +25,4 @@ cd sdk && make baseDist dist # Rebuild SDK after ts-bindings - When adding i18n keys, add all 5 locales in `core/locales/i18n.yaml` (see [i18n-patterns.md](i18n-patterns.md)) - When using DB watches, follow the `TypedDbWatch` patterns in [patchdb.md](patchdb.md) - **Always use `.invoke(ErrorKind::...)` instead of `.status()` when running CLI commands** via `tokio::process::Command`. The `Invoke` trait (from `crate::util::Invoke`) captures stdout/stderr and checks exit codes properly. Using `.status()` leaks stderr directly to system logs, creating noise. For check-then-act patterns (e.g. `iptables -C`), use `.invoke(...).await.is_ok()` / `.is_err()` instead of `.status().await.map_or(false, |s| s.success())`. +- Always use file utils in util::io instead of tokio::fs when available diff --git a/core/src/db/model/public.rs b/core/src/db/model/public.rs index dac5faf11..30ee515fd 100644 --- a/core/src/db/model/public.rs +++ b/core/src/db/model/public.rs @@ -24,7 +24,7 @@ use crate::net::host::Host; use crate::net::host::binding::{ AddSslOptions, BindInfo, BindOptions, Bindings, DerivedAddressInfo, NetInfo, }; -use crate::net::vhost::AlpnInfo; +use crate::net::vhost::{AlpnInfo, PassthroughInfo}; use crate::prelude::*; use crate::progress::FullProgress; use crate::system::{KeyboardOptions, SmtpValue}; @@ -121,6 +121,7 @@ impl Public { }, dns: Default::default(), default_outbound: None, + passthroughs: Vec::new(), }, status_info: ServerStatus { backup_progress: None, @@ -233,6 +234,8 @@ pub struct NetworkInfo { #[serde(default)] #[ts(type = "string | null")] pub default_outbound: Option, + #[serde(default)] + pub passthroughs: Vec, } #[derive(Debug, Default, Deserialize, Serialize, HasModel, TS)] diff --git a/core/src/net/acme.rs b/core/src/net/acme.rs index 056e77e4f..68a352ae7 100644 --- a/core/src/net/acme.rs +++ b/core/src/net/acme.rs @@ -27,7 +27,7 @@ use crate::db::model::public::AcmeSettings; use crate::db::{DbAccess, DbAccessByKey, DbAccessMut}; use crate::error::ErrorData; use crate::net::ssl::should_use_cert; -use crate::net::tls::{SingleCertResolver, TlsHandler}; +use crate::net::tls::{SingleCertResolver, TlsHandler, TlsHandlerAction}; use crate::net::web_server::Accept; use crate::prelude::*; use crate::util::FromStrParser; @@ -173,7 +173,7 @@ where &'a mut self, hello: &'a ClientHello<'a>, _: &'a ::Metadata, - ) -> Option { + ) -> Option { let domain = hello.server_name()?; if hello .alpn() @@ -207,20 +207,20 @@ where cfg.alpn_protocols = vec![ACME_TLS_ALPN_NAME.to_vec()]; tracing::info!("performing ACME auth challenge"); - return Some(cfg); + return Some(TlsHandlerAction::Tls(cfg)); } let domains: BTreeSet = [domain.into()].into_iter().collect(); let crypto_provider = self.crypto_provider.clone(); if let Some(cert) = self.get_cert(&domains).await { - return Some( + return Some(TlsHandlerAction::Tls( ServerConfig::builder_with_provider(crypto_provider) .with_safe_default_protocol_versions() .log_err()? .with_no_client_auth() .with_cert_resolver(Arc::new(SingleCertResolver(Arc::new(cert)))), - ); + )); } None diff --git a/core/src/net/gateway.rs b/core/src/net/gateway.rs index 49bf35a23..a377eb453 100644 --- a/core/src/net/gateway.rs +++ b/core/src/net/gateway.rs @@ -174,11 +174,11 @@ async fn set_name( #[derive(Debug, Clone, Deserialize, Serialize, Parser, TS)] #[serde(rename_all = "camelCase")] #[ts(export)] -struct CheckPortParams { +pub struct CheckPortParams { #[arg(help = "help.arg.port")] - port: u16, + pub port: u16, #[arg(help = "help.arg.gateway-id")] - gateway: GatewayId, + pub gateway: GatewayId, } #[derive(Debug, Clone, Deserialize, Serialize, TS)] @@ -200,7 +200,7 @@ pub struct IfconfigPortRes { pub reachable: bool, } -async fn check_port( +pub async fn check_port( ctx: RpcContext, CheckPortParams { port, gateway }: CheckPortParams, ) -> Result { @@ -276,12 +276,12 @@ async fn check_port( #[derive(Debug, Clone, Deserialize, Serialize, Parser, TS)] #[serde(rename_all = "camelCase")] #[ts(export)] -struct CheckDnsParams { +pub struct CheckDnsParams { #[arg(help = "help.arg.gateway-id")] - gateway: GatewayId, + pub gateway: GatewayId, } -async fn check_dns( +pub async fn check_dns( ctx: RpcContext, CheckDnsParams { gateway }: CheckDnsParams, ) -> Result { @@ -1238,8 +1238,7 @@ async fn poll_ip_info( device_type, Some(NetworkInterfaceType::Bridge | NetworkInterfaceType::Loopback) ) { - *prev_attempt = Some(Instant::now()); - match get_wan_ipv4(iface.as_str(), &ifconfig_url).await { + let res = match get_wan_ipv4(iface.as_str(), &ifconfig_url).await { Ok(a) => a, Err(e) => { tracing::error!( @@ -1253,7 +1252,9 @@ async fn poll_ip_info( tracing::debug!("{e:?}"); None } - } + }; + *prev_attempt = Some(Instant::now()); + res } else { None }; diff --git a/core/src/net/host/address.rs b/core/src/net/host/address.rs index 0a69d0427..8cb6da743 100644 --- a/core/src/net/host/address.rs +++ b/core/src/net/host/address.rs @@ -12,6 +12,7 @@ use crate::context::{CliContext, RpcContext}; use crate::db::model::DatabaseModel; use crate::hostname::ServerHostname; use crate::net::acme::AcmeProvider; +use crate::net::gateway::{CheckDnsParams, CheckPortParams, CheckPortRes, check_dns, check_port}; use crate::net::host::{HostApiKind, all_hosts}; use crate::prelude::*; use crate::util::serde::{HandlerExtSerde, display_serializable}; @@ -160,6 +161,7 @@ pub fn address_api() } #[derive(Deserialize, Serialize, Parser, TS)] +#[serde(rename_all = "camelCase")] #[ts(export)] pub struct AddPublicDomainParams { #[arg(help = "help.arg.fqdn")] @@ -168,6 +170,17 @@ pub struct AddPublicDomainParams { pub acme: Option, #[arg(help = "help.arg.gateway-id")] pub gateway: GatewayId, + #[arg(help = "help.arg.internal-port")] + pub internal_port: u16, +} + +#[derive(Debug, Clone, Deserialize, Serialize, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export)] +pub struct AddPublicDomainRes { + #[ts(type = "string | null")] + pub dns: Option, + pub port: CheckPortRes, } pub async fn add_public_domain( @@ -176,10 +189,12 @@ pub async fn add_public_domain( fqdn, acme, gateway, + internal_port, }: AddPublicDomainParams, inheritance: Kind::Inheritance, -) -> Result, Error> { - ctx.db +) -> Result { + let ext_port = ctx + .db .mutate(|db| { if let Some(acme) = &acme { if !db @@ -195,21 +210,92 @@ pub async fn add_public_domain( Kind::host_for(&inheritance, db)? .as_public_domains_mut() - .insert(&fqdn, &PublicDomainConfig { acme, gateway })?; + .insert( + &fqdn, + &PublicDomainConfig { + acme, + gateway: gateway.clone(), + }, + )?; handle_duplicates(db)?; let hostname = ServerHostname::load(db.as_public().as_server_info())?; - let gateways = db.as_public().as_server_info().as_network().as_gateways().de()?; - let ports = db.as_private().as_available_ports().de()?; - Kind::host_for(&inheritance, db)?.update_addresses(&hostname, &gateways, &ports) + let gateways = db + .as_public() + .as_server_info() + .as_network() + .as_gateways() + .de()?; + let available_ports = db.as_private().as_available_ports().de()?; + let host = Kind::host_for(&inheritance, db)?; + host.update_addresses(&hostname, &gateways, &available_ports)?; + + // Find the external port for the target binding + let bindings = host.as_bindings().de()?; + let target_bind = bindings + .get(&internal_port) + .ok_or_else(|| Error::new(eyre!("binding not found for internal port {internal_port}"), ErrorKind::NotFound))?; + let ext_port = target_bind + .addresses + .available + .iter() + .find(|a| a.public && a.hostname == fqdn) + .and_then(|a| a.port) + .ok_or_else(|| Error::new(eyre!("no public address found for {fqdn} on port {internal_port}"), ErrorKind::NotFound))?; + + // Disable the domain on all other bindings + host.as_bindings_mut().mutate(|b| { + for (&port, bind) in b.iter_mut() { + if port == internal_port { + continue; + } + let has_addr = bind + .addresses + .available + .iter() + .any(|a| a.public && a.hostname == fqdn); + if has_addr { + let other_ext = bind + .addresses + .available + .iter() + .find(|a| a.public && a.hostname == fqdn) + .and_then(|a| a.port) + .unwrap_or(ext_port); + bind.addresses.disabled.insert((fqdn.clone(), other_ext)); + } + } + Ok(()) + })?; + + Ok(ext_port) }) .await .result?; - tokio::task::spawn_blocking(|| { - crate::net::dns::query_dns(ctx, crate::net::dns::QueryDnsParams { fqdn }) + let ctx2 = ctx.clone(); + let fqdn2 = fqdn.clone(); + + let (dns_result, port_result) = tokio::join!( + async { + tokio::task::spawn_blocking(move || { + crate::net::dns::query_dns(ctx2, crate::net::dns::QueryDnsParams { fqdn: fqdn2 }) + }) + .await + .with_kind(ErrorKind::Unknown)? + }, + check_port( + ctx.clone(), + CheckPortParams { + port: ext_port, + gateway: gateway.clone(), + }, + ) + ); + + Ok(AddPublicDomainRes { + dns: dns_result?, + port: port_result?, }) - .await - .with_kind(ErrorKind::Unknown)? } #[derive(Deserialize, Serialize, Parser, TS)] @@ -257,13 +343,13 @@ pub async fn add_private_domain( ctx: RpcContext, AddPrivateDomainParams { fqdn, gateway }: AddPrivateDomainParams, inheritance: Kind::Inheritance, -) -> Result<(), Error> { +) -> Result { ctx.db .mutate(|db| { Kind::host_for(&inheritance, db)? .as_private_domains_mut() .upsert(&fqdn, || Ok(BTreeSet::new()))? - .mutate(|d| Ok(d.insert(gateway)))?; + .mutate(|d| Ok(d.insert(gateway.clone())))?; handle_duplicates(db)?; let hostname = ServerHostname::load(db.as_public().as_server_info())?; let gateways = db @@ -278,7 +364,7 @@ pub async fn add_private_domain( .await .result?; - Ok(()) + check_dns(ctx, CheckDnsParams { gateway }).await } pub async fn remove_private_domain( diff --git a/core/src/net/host/mod.rs b/core/src/net/host/mod.rs index aff25ccd5..c77b4aa26 100644 --- a/core/src/net/host/mod.rs +++ b/core/src/net/host/mod.rs @@ -249,6 +249,20 @@ impl Model { port: Some(port), metadata, }); + } else if opt.secure.map_or(false, |s| s.ssl) + && opt.add_ssl.is_none() + && available_ports.is_ssl(opt.preferred_external_port) + && net.assigned_port != Some(opt.preferred_external_port) + { + // Service handles its own TLS and the preferred port is + // allocated as SSL — add an address for passthrough vhost. + available.insert(HostnameInfo { + ssl: true, + public: true, + hostname: domain, + port: Some(opt.preferred_external_port), + metadata, + }); } } @@ -293,6 +307,20 @@ impl Model { gateways: domain_gateways, }, }); + } else if opt.secure.map_or(false, |s| s.ssl) + && opt.add_ssl.is_none() + && available_ports.is_ssl(opt.preferred_external_port) + && net.assigned_port != Some(opt.preferred_external_port) + { + available.insert(HostnameInfo { + ssl: true, + public: true, + hostname: domain, + port: Some(opt.preferred_external_port), + metadata: HostnameMetadata::PrivateDomain { + gateways: domain_gateways, + }, + }); } } bind.as_addresses_mut().as_available_mut().ser(&available)?; diff --git a/core/src/net/net_controller.rs b/core/src/net/net_controller.rs index dc990ec06..529b8824a 100644 --- a/core/src/net/net_controller.rs +++ b/core/src/net/net_controller.rs @@ -76,9 +76,22 @@ impl NetController { ], ) .await?; + let passthroughs = db + .peek() + .await + .as_public() + .as_server_info() + .as_network() + .as_passthroughs() + .de()?; Ok(Self { db: db.clone(), - vhost: VHostController::new(db.clone(), net_iface.clone(), crypto_provider), + vhost: VHostController::new( + db.clone(), + net_iface.clone(), + crypto_provider, + passthroughs, + ), tls_client_config, dns: DnsController::init(db, &net_iface.watcher).await?, forward: InterfacePortForwardController::new(net_iface.watcher.subscribe()), @@ -237,6 +250,7 @@ impl NetServiceData { connect_ssl: connect_ssl .clone() .map(|_| ctrl.tls_client_config.clone()), + passthrough: false, }, ); } @@ -253,7 +267,9 @@ impl NetServiceData { _ => continue, } let domain = &addr_info.hostname; - let domain_ssl_port = addr_info.port.unwrap_or(443); + let Some(domain_ssl_port) = addr_info.port else { + continue; + }; let key = (Some(domain.clone()), domain_ssl_port); let target = vhosts.entry(key).or_insert_with(|| ProxyTarget { public: BTreeSet::new(), @@ -266,6 +282,7 @@ impl NetServiceData { addr, add_x_forwarded_headers: ssl.add_x_forwarded_headers, connect_ssl: connect_ssl.clone().map(|_| ctrl.tls_client_config.clone()), + passthrough: false, }); if addr_info.public { for gw in addr_info.metadata.gateways() { @@ -317,6 +334,53 @@ impl NetServiceData { ), ); } + + // Passthrough vhosts: if the service handles its own TLS + // (secure.ssl && no add_ssl) and a domain address is enabled on + // an SSL port different from assigned_port, add a passthrough + // vhost so the service's TLS endpoint is reachable on that port. + if bind.options.secure.map_or(false, |s| s.ssl) && bind.options.add_ssl.is_none() { + let assigned = bind.net.assigned_port; + for addr_info in &enabled_addresses { + if !addr_info.ssl { + continue; + } + let Some(pt_port) = addr_info.port.filter(|p| assigned != Some(*p)) else { + continue; + }; + match &addr_info.metadata { + HostnameMetadata::PublicDomain { .. } + | HostnameMetadata::PrivateDomain { .. } => {} + _ => continue, + } + let domain = &addr_info.hostname; + let key = (Some(domain.clone()), pt_port); + let target = vhosts.entry(key).or_insert_with(|| ProxyTarget { + public: BTreeSet::new(), + private: BTreeSet::new(), + acme: None, + addr, + add_x_forwarded_headers: false, + connect_ssl: Err(AlpnInfo::Reflect), + passthrough: true, + }); + if addr_info.public { + for gw in addr_info.metadata.gateways() { + target.public.insert(gw.clone()); + } + } else { + for gw in addr_info.metadata.gateways() { + if let Some(info) = net_ifaces.get(gw) { + if let Some(ip_info) = &info.ip_info { + for subnet in &ip_info.subnets { + target.private.insert(subnet.addr()); + } + } + } + } + } + } + } } // ── Phase 3: Reconcile ── diff --git a/core/src/net/ssl.rs b/core/src/net/ssl.rs index 284d224a2..3b8e69c8e 100644 --- a/core/src/net/ssl.rs +++ b/core/src/net/ssl.rs @@ -36,7 +36,7 @@ use crate::db::{DbAccess, DbAccessMut}; use crate::hostname::ServerHostname; use crate::init::check_time_is_synchronized; use crate::net::gateway::GatewayInfo; -use crate::net::tls::TlsHandler; +use crate::net::tls::{TlsHandler, TlsHandlerAction}; use crate::net::web_server::{Accept, ExtractVisitor, TcpMetadata, extract}; use crate::prelude::*; use crate::util::serde::Pem; @@ -620,7 +620,7 @@ where &mut self, hello: &ClientHello<'_>, metadata: &::Metadata, - ) -> Option { + ) -> Option { let hostnames: BTreeSet = hello .server_name() .map(InternedString::from) @@ -684,5 +684,6 @@ where ) } .log_err() + .map(TlsHandlerAction::Tls) } } diff --git a/core/src/net/tls.rs b/core/src/net/tls.rs index 3d8c1b1a4..4f254f6a4 100644 --- a/core/src/net/tls.rs +++ b/core/src/net/tls.rs @@ -16,6 +16,14 @@ use tokio_rustls::rustls::sign::CertifiedKey; use tokio_rustls::rustls::{ClientConfig, RootCertStore, ServerConfig}; use visit_rs::{Visit, VisitFields}; +/// Result of a TLS handler's decision about how to handle a connection. +pub enum TlsHandlerAction { + /// Complete the TLS handshake with this ServerConfig. + Tls(ServerConfig), + /// Don't complete TLS — rewind the BackTrackingIO and return the raw stream. + Passthrough, +} + use crate::net::http::handle_http_on_https; use crate::net::web_server::{Accept, AcceptStream, MetadataVisitor}; use crate::prelude::*; @@ -50,7 +58,7 @@ pub trait TlsHandler<'a, A: Accept> { &'a mut self, hello: &'a ClientHello<'a>, metadata: &'a A::Metadata, - ) -> impl Future> + Send + 'a; + ) -> impl Future> + Send + 'a; } #[derive(Clone)] @@ -66,7 +74,7 @@ where &'a mut self, hello: &'a ClientHello<'a>, metadata: &'a ::Metadata, - ) -> Option { + ) -> Option { if let Some(config) = self.0.get_config(hello, metadata).await { return Some(config); } @@ -86,7 +94,7 @@ pub trait WrapTlsHandler { prev: ServerConfig, hello: &'a ClientHello<'a>, metadata: &'a ::Metadata, - ) -> impl Future> + Send + 'a + ) -> impl Future> + Send + 'a where Self: 'a; } @@ -102,9 +110,12 @@ where &'a mut self, hello: &'a ClientHello<'a>, metadata: &'a ::Metadata, - ) -> Option { - let prev = self.inner.get_config(hello, metadata).await?; - self.wrapper.wrap(prev, hello, metadata).await + ) -> Option { + let action = self.inner.get_config(hello, metadata).await?; + match action { + TlsHandlerAction::Tls(cfg) => self.wrapper.wrap(cfg, hello, metadata).await, + other => Some(other), + } } } @@ -203,34 +214,56 @@ where } }; let hello = mid.client_hello(); - if let Some(cfg) = tls_handler.get_config(&hello, &metadata).await { - let buffered = mid.io.stop_buffering(); - mid.io - .write_all(&buffered) - .await - .with_kind(ErrorKind::Network)?; - return Ok(match mid.into_stream(Arc::new(cfg)).await { - Ok(stream) => { - let s = stream.get_ref().1; - Some(( - TlsMetadata { - inner: metadata, - tls_info: TlsHandshakeInfo { - sni: s.server_name().map(InternedString::intern), - alpn: s - .alpn_protocol() - .map(|a| MaybeUtf8String(a.to_vec())), + let sni = hello.server_name().map(InternedString::intern); + match tls_handler.get_config(&hello, &metadata).await { + Some(TlsHandlerAction::Tls(cfg)) => { + let buffered = mid.io.stop_buffering(); + mid.io + .write_all(&buffered) + .await + .with_kind(ErrorKind::Network)?; + return Ok(match mid.into_stream(Arc::new(cfg)).await { + Ok(stream) => { + let s = stream.get_ref().1; + Some(( + TlsMetadata { + inner: metadata, + tls_info: TlsHandshakeInfo { + sni: s + .server_name() + .map(InternedString::intern), + alpn: s + .alpn_protocol() + .map(|a| MaybeUtf8String(a.to_vec())), + }, }, - }, - Box::pin(stream) as AcceptStream, - )) - } - Err(e) => { - tracing::trace!("Error completing TLS handshake: {e}"); - tracing::trace!("{e:?}"); - None - } - }); + Box::pin(stream) as AcceptStream, + )) + } + Err(e) => { + tracing::trace!("Error completing TLS handshake: {e}"); + tracing::trace!("{e:?}"); + None + } + }); + } + Some(TlsHandlerAction::Passthrough) => { + let (dummy, _drop) = tokio::io::duplex(1); + let mut bt = std::mem::replace( + &mut mid.io, + BackTrackingIO::new(Box::pin(dummy) as AcceptStream), + ); + drop(mid); + bt.rewind(); + return Ok(Some(( + TlsMetadata { + inner: metadata, + tls_info: TlsHandshakeInfo { sni, alpn: None }, + }, + Box::pin(bt) as AcceptStream, + ))); + } + None => {} } Ok(None) diff --git a/core/src/net/vhost.rs b/core/src/net/vhost.rs index 85054e62b..970a9ccb9 100644 --- a/core/src/net/vhost.rs +++ b/core/src/net/vhost.rs @@ -6,12 +6,13 @@ use std::sync::{Arc, Weak}; use std::task::{Poll, ready}; use async_acme::acme::ACME_TLS_ALPN_NAME; +use clap::Parser; use color_eyre::eyre::eyre; use futures::FutureExt; use futures::future::BoxFuture; use imbl::OrdMap; use imbl_value::{InOMap, InternedString}; -use rpc_toolkit::{Context, HandlerArgs, HandlerExt, ParentHandler, from_fn}; +use rpc_toolkit::{Context, HandlerArgs, HandlerExt, ParentHandler, from_fn, from_fn_async}; use serde::{Deserialize, Serialize}; use tokio::net::{TcpListener, TcpStream}; use tokio_rustls::TlsConnector; @@ -35,7 +36,7 @@ use crate::net::gateway::{ }; use crate::net::ssl::{CertStore, RootCaTlsHandler}; use crate::net::tls::{ - ChainedHandler, TlsHandlerWrapper, TlsListener, TlsMetadata, WrapTlsHandler, + ChainedHandler, TlsHandlerAction, TlsHandlerWrapper, TlsListener, TlsMetadata, WrapTlsHandler, }; use crate::net::utils::ipv6_is_link_local; use crate::net::web_server::{Accept, AcceptStream, ExtractVisitor, TcpMetadata, extract}; @@ -46,68 +47,228 @@ use crate::util::serde::{HandlerExtSerde, MaybeUtf8String, display_serializable} use crate::util::sync::{SyncMutex, Watch}; use crate::{GatewayId, ResultExt}; +#[derive(Debug, Clone, Deserialize, Serialize, HasModel, TS)] +#[serde(rename_all = "camelCase")] +#[model = "Model"] +#[ts(export)] +pub struct PassthroughInfo { + #[ts(type = "string")] + pub hostname: InternedString, + pub listen_port: u16, + #[ts(type = "string")] + pub backend: SocketAddr, + #[ts(type = "string[]")] + pub public_gateways: BTreeSet, + #[ts(type = "string[]")] + pub private_ips: BTreeSet, +} + +#[derive(Debug, Clone, Deserialize, Serialize, Parser)] +#[serde(rename_all = "kebab-case")] +struct AddPassthroughParams { + #[arg(long)] + pub hostname: InternedString, + #[arg(long)] + pub listen_port: u16, + #[arg(long)] + pub backend: SocketAddr, + #[arg(long)] + pub public_gateway: Vec, + #[arg(long)] + pub private_ip: Vec, +} + +#[derive(Debug, Clone, Deserialize, Serialize, Parser)] +#[serde(rename_all = "kebab-case")] +struct RemovePassthroughParams { + #[arg(long)] + pub hostname: InternedString, + #[arg(long)] + pub listen_port: u16, +} + pub fn vhost_api() -> ParentHandler { - ParentHandler::new().subcommand( - "dump-table", - from_fn(|ctx: RpcContext| Ok(ctx.net_controller.vhost.dump_table())) - .with_display_serializable() - .with_custom_display_fn(|HandlerArgs { params, .. }, res| { - use prettytable::*; + ParentHandler::new() + .subcommand( + "dump-table", + from_fn(dump_table) + .with_display_serializable() + .with_custom_display_fn(|HandlerArgs { params, .. }, res| { + use prettytable::*; - if let Some(format) = params.format { - display_serializable(format, res)?; - return Ok::<_, Error>(()); - } + if let Some(format) = params.format { + display_serializable(format, res)?; + return Ok::<_, Error>(()); + } - let mut table = Table::new(); - table.add_row(row![bc => "FROM", "TO", "ACTIVE"]); + let mut table = Table::new(); + table.add_row(row![bc => "FROM", "TO", "ACTIVE"]); - for (external, targets) in res { - for (host, targets) in targets { - for (idx, target) in targets.into_iter().enumerate() { - table.add_row(row![ - format!( - "{}:{}", - host.as_ref().map(|s| &**s).unwrap_or("*"), - external.0 - ), - target, - idx == 0 - ]); + for (external, targets) in res { + for (host, targets) in targets { + for (idx, target) in targets.into_iter().enumerate() { + table.add_row(row![ + format!( + "{}:{}", + host.as_ref().map(|s| &**s).unwrap_or("*"), + external.0 + ), + target, + idx == 0 + ]); + } } } - } - table.print_tty(false)?; + table.print_tty(false)?; - Ok(()) - }) - .with_call_remote::(), - ) + Ok(()) + }) + .with_call_remote::(), + ) + .subcommand( + "add-passthrough", + from_fn_async(add_passthrough) + .no_display() + .with_call_remote::(), + ) + .subcommand( + "remove-passthrough", + from_fn_async(remove_passthrough) + .no_display() + .with_call_remote::(), + ) + .subcommand( + "list-passthrough", + from_fn(list_passthrough) + .with_display_serializable() + .with_call_remote::(), + ) +} + +fn dump_table( + ctx: RpcContext, +) -> Result, BTreeMap>, EqSet>>, Error> +{ + Ok(ctx.net_controller.vhost.dump_table()) +} + +async fn add_passthrough( + ctx: RpcContext, + AddPassthroughParams { + hostname, + listen_port, + backend, + public_gateway, + private_ip, + }: AddPassthroughParams, +) -> Result<(), Error> { + let public_gateways: BTreeSet = public_gateway.into_iter().collect(); + let private_ips: BTreeSet = private_ip.into_iter().collect(); + ctx.net_controller.vhost.add_passthrough( + hostname.clone(), + listen_port, + backend, + public_gateways.clone(), + private_ips.clone(), + )?; + ctx.db + .mutate(|db| { + let pts = db + .as_public_mut() + .as_server_info_mut() + .as_network_mut() + .as_passthroughs_mut(); + let mut vec: Vec = pts.de()?; + vec.retain(|p| !(p.hostname == hostname && p.listen_port == listen_port)); + vec.push(PassthroughInfo { + hostname, + listen_port, + backend, + public_gateways, + private_ips, + }); + pts.ser(&vec) + }) + .await + .result?; + Ok(()) +} + +async fn remove_passthrough( + ctx: RpcContext, + RemovePassthroughParams { + hostname, + listen_port, + }: RemovePassthroughParams, +) -> Result<(), Error> { + ctx.net_controller + .vhost + .remove_passthrough(&hostname, listen_port); + ctx.db + .mutate(|db| { + let pts = db + .as_public_mut() + .as_server_info_mut() + .as_network_mut() + .as_passthroughs_mut(); + let mut vec: Vec = pts.de()?; + vec.retain(|p| !(p.hostname == hostname && p.listen_port == listen_port)); + pts.ser(&vec) + }) + .await + .result?; + Ok(()) +} + +fn list_passthrough(ctx: RpcContext) -> Result, Error> { + Ok(ctx.net_controller.vhost.list_passthrough()) } // not allowed: <=1024, >=32768, 5355, 5432, 9050, 6010, 9051, 5353 +struct PassthroughHandle { + _rc: Arc<()>, + backend: SocketAddr, + public: BTreeSet, + private: BTreeSet, +} + pub struct VHostController { db: TypedPatchDb, interfaces: Arc, crypto_provider: Arc, acme_cache: AcmeTlsAlpnCache, servers: SyncMutex>>, + passthrough_handles: SyncMutex>, } impl VHostController { pub fn new( db: TypedPatchDb, interfaces: Arc, crypto_provider: Arc, + passthroughs: Vec, ) -> Self { - Self { + let controller = Self { db, interfaces, crypto_provider, acme_cache: Arc::new(SyncMutex::new(BTreeMap::new())), servers: SyncMutex::new(BTreeMap::new()), + passthrough_handles: SyncMutex::new(BTreeMap::new()), + }; + for pt in passthroughs { + if let Err(e) = controller.add_passthrough( + pt.hostname, + pt.listen_port, + pt.backend, + pt.public_gateways, + pt.private_ips, + ) { + tracing::warn!("failed to restore passthrough: {e}"); + } } + controller } #[instrument(skip_all)] pub fn add( @@ -120,20 +281,7 @@ impl VHostController { let server = if let Some(server) = writable.remove(&external) { server } else { - let bind_reqs = Watch::new(VHostBindRequirements::default()); - let listener = VHostBindListener { - ip_info: self.interfaces.watcher.subscribe(), - port: external, - bind_reqs: bind_reqs.clone_unseen(), - listeners: BTreeMap::new(), - }; - VHostServer::new( - listener, - bind_reqs, - self.db.clone(), - self.crypto_provider.clone(), - self.acme_cache.clone(), - ) + self.create_server(external) }; let rc = server.add(hostname, target); writable.insert(external, server); @@ -141,6 +289,75 @@ impl VHostController { }) } + fn create_server(&self, port: u16) -> VHostServer { + let bind_reqs = Watch::new(VHostBindRequirements::default()); + let listener = VHostBindListener { + ip_info: self.interfaces.watcher.subscribe(), + port, + bind_reqs: bind_reqs.clone_unseen(), + listeners: BTreeMap::new(), + }; + VHostServer::new( + listener, + bind_reqs, + self.db.clone(), + self.crypto_provider.clone(), + self.acme_cache.clone(), + ) + } + + pub fn add_passthrough( + &self, + hostname: InternedString, + port: u16, + backend: SocketAddr, + public: BTreeSet, + private: BTreeSet, + ) -> Result<(), Error> { + let target = ProxyTarget { + public: public.clone(), + private: private.clone(), + acme: None, + addr: backend, + add_x_forwarded_headers: false, + connect_ssl: Err(AlpnInfo::Reflect), + passthrough: true, + }; + let rc = self.add(Some(hostname.clone()), port, DynVHostTarget::new(target))?; + self.passthrough_handles.mutate(|h| { + h.insert( + (hostname, port), + PassthroughHandle { + _rc: rc, + backend, + public, + private, + }, + ); + }); + Ok(()) + } + + pub fn remove_passthrough(&self, hostname: &InternedString, port: u16) { + self.passthrough_handles + .mutate(|h| h.remove(&(hostname.clone(), port))); + self.gc(Some(hostname.clone()), port); + } + + pub fn list_passthrough(&self) -> Vec { + self.passthrough_handles.peek(|h| { + h.iter() + .map(|((hostname, port), handle)| PassthroughInfo { + hostname: hostname.clone(), + listen_port: *port, + backend: handle.backend, + public_gateways: handle.public.clone(), + private_ips: handle.private.clone(), + }) + .collect() + }) + } + pub fn dump_table( &self, ) -> BTreeMap, BTreeMap>, EqSet>> { @@ -330,6 +547,9 @@ pub trait VHostTarget: std::fmt::Debug + Eq { fn bind_requirements(&self) -> (BTreeSet, BTreeSet) { (BTreeSet::new(), BTreeSet::new()) } + fn is_passthrough(&self) -> bool { + false + } fn preprocess<'a>( &'a self, prev: ServerConfig, @@ -349,6 +569,7 @@ pub trait DynVHostTargetT: std::fmt::Debug + Any { fn filter(&self, metadata: &::Metadata) -> bool; fn acme(&self) -> Option<&AcmeProvider>; fn bind_requirements(&self) -> (BTreeSet, BTreeSet); + fn is_passthrough(&self) -> bool; fn preprocess<'a>( &'a self, prev: ServerConfig, @@ -373,6 +594,9 @@ impl + 'static> DynVHostTargetT for T { fn acme(&self) -> Option<&AcmeProvider> { VHostTarget::acme(self) } + fn is_passthrough(&self) -> bool { + VHostTarget::is_passthrough(self) + } fn bind_requirements(&self) -> (BTreeSet, BTreeSet) { VHostTarget::bind_requirements(self) } @@ -459,6 +683,7 @@ pub struct ProxyTarget { pub addr: SocketAddr, pub add_x_forwarded_headers: bool, pub connect_ssl: Result, AlpnInfo>, // Ok: yes, connect using ssl, pass through alpn; Err: connect tcp, use provided strategy for alpn + pub passthrough: bool, } impl PartialEq for ProxyTarget { fn eq(&self, other: &Self) -> bool { @@ -466,6 +691,7 @@ impl PartialEq for ProxyTarget { && self.private == other.private && self.acme == other.acme && self.addr == other.addr + && self.passthrough == other.passthrough && self.connect_ssl.as_ref().map(Arc::as_ptr) == other.connect_ssl.as_ref().map(Arc::as_ptr) } @@ -480,6 +706,7 @@ impl fmt::Debug for ProxyTarget { .field("addr", &self.addr) .field("add_x_forwarded_headers", &self.add_x_forwarded_headers) .field("connect_ssl", &self.connect_ssl.as_ref().map(|_| ())) + .field("passthrough", &self.passthrough) .finish() } } @@ -524,6 +751,9 @@ where fn bind_requirements(&self) -> (BTreeSet, BTreeSet) { (self.public.clone(), self.private.clone()) } + fn is_passthrough(&self) -> bool { + self.passthrough + } async fn preprocess<'a>( &'a self, mut prev: ServerConfig, @@ -677,7 +907,7 @@ where prev: ServerConfig, hello: &'a ClientHello<'a>, metadata: &'a ::Metadata, - ) -> Option + ) -> Option where Self: 'a, { @@ -687,7 +917,7 @@ where .flatten() .any(|a| a == ACME_TLS_ALPN_NAME) { - return Some(prev); + return Some(TlsHandlerAction::Tls(prev)); } let (target, rc) = self.0.peek(|m| { @@ -700,11 +930,16 @@ where .map(|(t, rc)| (t.clone(), rc.clone())) })?; + let is_pt = target.0.is_passthrough(); let (prev, store) = target.into_preprocessed(rc, prev, hello, metadata).await?; self.1 = Some(store); - Some(prev) + if is_pt { + Some(TlsHandlerAction::Passthrough) + } else { + Some(TlsHandlerAction::Tls(prev)) + } } } diff --git a/core/src/os_install/mod.rs b/core/src/os_install/mod.rs index d121a1387..d04491acd 100644 --- a/core/src/os_install/mod.rs +++ b/core/src/os_install/mod.rs @@ -27,6 +27,63 @@ use crate::util::serde::IoFormat; mod gpt; mod mbr; +/// Get the EFI BootCurrent entry number (the entry firmware used to boot). +/// Returns None on non-EFI systems or if BootCurrent is not set. +async fn get_efi_boot_current() -> Result, Error> { + let efi_output = String::from_utf8( + Command::new("efibootmgr") + .invoke(ErrorKind::Grub) + .await?, + ) + .map_err(|e| Error::new(eyre!("efibootmgr output not valid UTF-8: {e}"), ErrorKind::Grub))?; + + Ok(efi_output + .lines() + .find(|line| line.starts_with("BootCurrent:")) + .and_then(|line| line.strip_prefix("BootCurrent:")) + .map(|s| s.trim().to_string())) +} + +/// Promote a specific boot entry to first in the EFI boot order. +async fn promote_efi_entry(entry: &str) -> Result<(), Error> { + let efi_output = String::from_utf8( + Command::new("efibootmgr") + .invoke(ErrorKind::Grub) + .await?, + ) + .map_err(|e| Error::new(eyre!("efibootmgr output not valid UTF-8: {e}"), ErrorKind::Grub))?; + + let current_order = efi_output + .lines() + .find(|line| line.starts_with("BootOrder:")) + .and_then(|line| line.strip_prefix("BootOrder:")) + .map(|s| s.trim()) + .unwrap_or(""); + + if current_order.is_empty() || current_order.starts_with(entry) { + return Ok(()); + } + + let other_entries: Vec<&str> = current_order + .split(',') + .filter(|e| e.trim() != entry) + .collect(); + + let new_order = if other_entries.is_empty() { + entry.to_string() + } else { + format!("{},{}", entry, other_entries.join(",")) + }; + + Command::new("efibootmgr") + .arg("-o") + .arg(&new_order) + .invoke(ErrorKind::Grub) + .await?; + + Ok(()) +} + /// Probe a squashfs image to determine its target architecture async fn probe_squashfs_arch(squashfs_path: &Path) -> Result { let output = String::from_utf8( @@ -359,7 +416,6 @@ pub async fn install_os_to( "riscv64" => install.arg("--target=riscv64-efi"), _ => &mut install, }; - install.arg("--no-nvram"); } install .arg(disk_path) @@ -429,6 +485,21 @@ pub async fn install_os( }); let use_efi = tokio::fs::metadata("/sys/firmware/efi").await.is_ok(); + + // Save the boot entry we booted from (the USB installer) before grub-install + // overwrites the boot order. + let boot_current = if use_efi { + match get_efi_boot_current().await { + Ok(entry) => entry, + Err(e) => { + tracing::warn!("Failed to get EFI BootCurrent: {e}"); + None + } + } + } else { + None + }; + let InstallOsResult { part_info, rootfs } = install_os_to( "/run/live/medium/live/filesystem.squashfs", &disk.logicalname, @@ -440,6 +511,20 @@ pub async fn install_os( ) .await?; + // grub-install prepends its new entry to the EFI boot order, overriding the + // USB-first priority. Promote the USB entry (identified by BootCurrent from + // when we booted the installer) back to first, and persist the entry number + // so the upgrade script can do the same. + if let Some(ref entry) = boot_current { + if let Err(e) = promote_efi_entry(entry).await { + tracing::warn!("Failed to restore EFI boot order: {e}"); + } + let efi_entry_path = rootfs.path().join("config/efi-installer-entry"); + if let Err(e) = tokio::fs::write(&efi_entry_path, entry).await { + tracing::warn!("Failed to save EFI installer entry number: {e}"); + } + } + ctx.config .mutate(|c| c.os_partitions = Some(part_info.clone())); diff --git a/core/src/system/mod.rs b/core/src/system/mod.rs index 248990581..b0570379b 100644 --- a/core/src/system/mod.rs +++ b/core/src/system/mod.rs @@ -1238,19 +1238,13 @@ pub async fn test_smtp( .body("This is a test email sent from your StartOS Server".to_owned())?; let transport = match security { - SmtpSecurity::Starttls => AsyncSmtpTransport::::relay(&host)? - .port(port) - .credentials(creds) - .build(), - SmtpSecurity::Tls => { - let tls = TlsParameters::new(host.clone())?; - AsyncSmtpTransport::::relay(&host)? - .port(port) - .tls(Tls::Wrapper(tls)) - .credentials(creds) - .build() - } - }; + SmtpSecurity::Starttls => AsyncSmtpTransport::::starttls_relay(&host)?, + SmtpSecurity::Tls => AsyncSmtpTransport::::relay(&host)?, + } + .port(port) + .tls(Tls::Wrapper(TlsParameters::new(host.clone())?)) + .credentials(creds) + .build(); transport.send(message).await?; Ok(()) diff --git a/core/src/tunnel/web.rs b/core/src/tunnel/web.rs index 598f05fa7..04e7f84c0 100644 --- a/core/src/tunnel/web.rs +++ b/core/src/tunnel/web.rs @@ -20,7 +20,7 @@ use ts_rs::TS; use crate::context::CliContext; use crate::hostname::ServerHostname; use crate::net::ssl::{SANInfo, root_ca_start_time}; -use crate::net::tls::TlsHandler; +use crate::net::tls::{TlsHandler, TlsHandlerAction}; use crate::net::web_server::Accept; use crate::prelude::*; use crate::tunnel::auth::SetPasswordParams; @@ -59,7 +59,7 @@ where &'a mut self, _: &'a ClientHello<'a>, _: &'a ::Metadata, - ) -> Option { + ) -> Option { let cert_info = self .db .peek() @@ -88,7 +88,7 @@ where .log_err()?; cfg.alpn_protocols .extend([b"http/1.1".into(), b"h2".into()]); - Some(cfg) + Some(TlsHandlerAction::Tls(cfg)) } } diff --git a/sdk/base/lib/actions/input/inputSpecConstants.ts b/sdk/base/lib/actions/input/inputSpecConstants.ts index e2992740a..54c7afd7d 100644 --- a/sdk/base/lib/actions/input/inputSpecConstants.ts +++ b/sdk/base/lib/actions/input/inputSpecConstants.ts @@ -1,41 +1,57 @@ -import { SmtpValue } from '../../types' import { GetSystemSmtp, Patterns } from '../../util' -import { InputSpec, InputSpecOf } from './builder/inputSpec' +import { InputSpec } from './builder/inputSpec' import { Value } from './builder/value' import { Variants } from './builder/variants' +const securityVariants = Variants.of({ + tls: { + name: 'TLS', + spec: InputSpec.of({ + port: Value.dynamicText(async () => ({ + name: 'Port', + required: true, + default: '465', + disabled: 'Fixed for TLS', + })), + }), + }, + starttls: { + name: 'STARTTLS', + spec: InputSpec.of({ + port: Value.select({ + name: 'Port', + default: '587', + values: { '25': '25', '587': '587', '2525': '2525' }, + }), + }), + }, +}) + /** * Creates an SMTP field spec with provider-specific defaults pre-filled. */ function smtpFields( defaults: { host?: string - port?: number security?: 'starttls' | 'tls' + hostDisabled?: boolean } = {}, -): InputSpec { - return InputSpec.of>({ - host: Value.text({ - name: 'Host', - required: true, - default: defaults.host ?? null, - placeholder: 'smtp.example.com', - }), - port: Value.number({ - name: 'Port', - required: true, - default: defaults.port ?? 587, - min: 1, - max: 65535, - integer: true, - }), - security: Value.select({ +) { + const hostSpec = Value.text({ + name: 'Host', + required: true, + default: defaults.host ?? null, + placeholder: 'smtp.example.com', + }) + + return InputSpec.of({ + host: defaults.hostDisabled + ? hostSpec.withDisabled('Fixed for this provider') + : hostSpec, + security: Value.union({ name: 'Connection Security', - default: defaults.security ?? 'starttls', - values: { - starttls: 'STARTTLS', - tls: 'TLS', - }, + default: defaults.security ?? 'tls', + variants: securityVariants, }), from: Value.text({ name: 'From Address', @@ -72,40 +88,39 @@ export const smtpProviderVariants = Variants.of({ name: 'Gmail', spec: smtpFields({ host: 'smtp.gmail.com', - port: 587, - security: 'starttls', + security: 'tls', + hostDisabled: true, }), }, ses: { name: 'Amazon SES', spec: smtpFields({ host: 'email-smtp.us-east-1.amazonaws.com', - port: 587, - security: 'starttls', + security: 'tls', }), }, sendgrid: { name: 'SendGrid', spec: smtpFields({ host: 'smtp.sendgrid.net', - port: 587, - security: 'starttls', + security: 'tls', + hostDisabled: true, }), }, mailgun: { name: 'Mailgun', spec: smtpFields({ host: 'smtp.mailgun.org', - port: 587, - security: 'starttls', + security: 'tls', + hostDisabled: true, }), }, protonmail: { name: 'Proton Mail', spec: smtpFields({ host: 'smtp.protonmail.ch', - port: 587, - security: 'starttls', + security: 'tls', + hostDisabled: true, }), }, other: { @@ -121,7 +136,7 @@ export const smtpProviderVariants = Variants.of({ export const systemSmtpSpec = InputSpec.of({ provider: Value.union({ name: 'Provider', - default: null as any, + default: 'gmail', variants: smtpProviderVariants, }), }) diff --git a/sdk/base/lib/interfaces/Host.ts b/sdk/base/lib/interfaces/Host.ts index 8842caf77..63840fb02 100644 --- a/sdk/base/lib/interfaces/Host.ts +++ b/sdk/base/lib/interfaces/Host.ts @@ -14,28 +14,34 @@ export const knownProtocols = { defaultPort: 80, withSsl: 'https', alpn: { specified: ['http/1.1'] } as AlpnInfo, + addXForwardedHeaders: true, }, https: { secure: { ssl: true }, defaultPort: 443, + addXForwardedHeaders: true, }, ws: { secure: null, defaultPort: 80, withSsl: 'wss', alpn: { specified: ['http/1.1'] } as AlpnInfo, + addXForwardedHeaders: true, }, wss: { secure: { ssl: true }, defaultPort: 443, + addXForwardedHeaders: true, }, ssh: { secure: { ssl: false }, defaultPort: 22, + addXForwardedHeaders: false, }, dns: { secure: { ssl: false }, defaultPort: 53, + addXForwardedHeaders: false, }, } as const @@ -136,7 +142,7 @@ export class MultiHost { const sslProto = this.getSslProto(options) const addSsl = sslProto ? { - addXForwardedHeaders: false, + addXForwardedHeaders: knownProtocols[sslProto].addXForwardedHeaders, preferredExternalPort: knownProtocols[sslProto].defaultPort, scheme: sslProto, alpn: 'alpn' in protoInfo ? protoInfo.alpn : null, @@ -148,7 +154,7 @@ export class MultiHost { preferredExternalPort: 443, scheme: sslProto, alpn: null, - ...('addSsl' in options ? options.addSsl : null), + ...options.addSsl, } : null diff --git a/sdk/base/lib/osBindings/AddPublicDomainParams.ts b/sdk/base/lib/osBindings/AddPublicDomainParams.ts index 3d7ddbdc1..552121859 100644 --- a/sdk/base/lib/osBindings/AddPublicDomainParams.ts +++ b/sdk/base/lib/osBindings/AddPublicDomainParams.ts @@ -6,4 +6,5 @@ export type AddPublicDomainParams = { fqdn: string acme: AcmeProvider | null gateway: GatewayId + internalPort: number } diff --git a/sdk/base/lib/osBindings/AddPublicDomainRes.ts b/sdk/base/lib/osBindings/AddPublicDomainRes.ts new file mode 100644 index 000000000..0d02fe934 --- /dev/null +++ b/sdk/base/lib/osBindings/AddPublicDomainRes.ts @@ -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 { CheckPortRes } from './CheckPortRes' + +export type AddPublicDomainRes = { dns: string | null; port: CheckPortRes } diff --git a/sdk/base/lib/osBindings/NetworkInfo.ts b/sdk/base/lib/osBindings/NetworkInfo.ts index 3acfb3851..966d3313a 100644 --- a/sdk/base/lib/osBindings/NetworkInfo.ts +++ b/sdk/base/lib/osBindings/NetworkInfo.ts @@ -5,6 +5,7 @@ import type { DnsSettings } from './DnsSettings' import type { GatewayId } from './GatewayId' import type { Host } from './Host' import type { NetworkInterfaceInfo } from './NetworkInterfaceInfo' +import type { PassthroughInfo } from './PassthroughInfo' import type { WifiInfo } from './WifiInfo' export type NetworkInfo = { @@ -14,4 +15,5 @@ export type NetworkInfo = { acme: { [key: AcmeProvider]: AcmeSettings } dns: DnsSettings defaultOutbound: string | null + passthroughs: Array } diff --git a/sdk/base/lib/osBindings/PassthroughInfo.ts b/sdk/base/lib/osBindings/PassthroughInfo.ts new file mode 100644 index 000000000..b04363597 --- /dev/null +++ b/sdk/base/lib/osBindings/PassthroughInfo.ts @@ -0,0 +1,9 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +export type PassthroughInfo = { + hostname: string + listenPort: number + backend: string + publicGateways: string[] + privateIps: string[] +} diff --git a/sdk/base/lib/osBindings/index.ts b/sdk/base/lib/osBindings/index.ts index 60dc64898..3df8c985f 100644 --- a/sdk/base/lib/osBindings/index.ts +++ b/sdk/base/lib/osBindings/index.ts @@ -19,6 +19,7 @@ export { AddPackageSignerParams } from './AddPackageSignerParams' export { AddPackageToCategoryParams } from './AddPackageToCategoryParams' export { AddPrivateDomainParams } from './AddPrivateDomainParams' export { AddPublicDomainParams } from './AddPublicDomainParams' +export { AddPublicDomainRes } from './AddPublicDomainRes' export { AddressInfo } from './AddressInfo' export { AddSslOptions } from './AddSslOptions' export { AddTunnelParams } from './AddTunnelParams' @@ -201,6 +202,7 @@ export { PackagePlugin } from './PackagePlugin' export { PackageState } from './PackageState' export { PackageVersionInfo } from './PackageVersionInfo' export { PartitionInfo } from './PartitionInfo' +export { PassthroughInfo } from './PassthroughInfo' export { PasswordType } from './PasswordType' export { PathOrUrl } from './PathOrUrl' export { Pem } from './Pem' diff --git a/sdk/base/lib/types.ts b/sdk/base/lib/types.ts index 5d6e3756f..f552f89aa 100644 --- a/sdk/base/lib/types.ts +++ b/sdk/base/lib/types.ts @@ -1,25 +1,25 @@ export * as inputSpecTypes from './actions/input/inputSpecTypes' +export { + CurrentDependenciesResult, + OptionalDependenciesOf as OptionalDependencies, + RequiredDependenciesOf as RequiredDependencies, +} from './dependencies/setupDependencies' +export * from './osBindings' +export { SDKManifest } from './types/ManifestTypes' +export { Effects } import { InputSpec as InputSpecClass } from './actions/input/builder/inputSpec' -import { - DependencyRequirement, - NamedHealthCheckResult, - Manifest, - ServiceInterface, - ActionId, -} from './osBindings' -import { Affine, StringObject, ToKebab } from './util' import { Action, Actions } from './actions/setupActions' import { Effects } from './Effects' import { ExtendedVersion, VersionRange } from './exver' -export { Effects } -export * from './osBindings' -export { SDKManifest } from './types/ManifestTypes' -export { - RequiredDependenciesOf as RequiredDependencies, - OptionalDependenciesOf as OptionalDependencies, - CurrentDependenciesResult, -} from './dependencies/setupDependencies' +import { + ActionId, + DependencyRequirement, + Manifest, + NamedHealthCheckResult, + ServiceInterface, +} from './osBindings' +import { StringObject, ToKebab } from './util' /** An object that can be built into a terminable daemon process. */ export type DaemonBuildable = { diff --git a/sdk/base/lib/util/getServiceInterface.ts b/sdk/base/lib/util/getServiceInterface.ts index 60fb2a745..e0cedc529 100644 --- a/sdk/base/lib/util/getServiceInterface.ts +++ b/sdk/base/lib/util/getServiceInterface.ts @@ -26,6 +26,18 @@ export const getHostname = (url: string): Hostname | null => { return last } +/** + * The kinds of hostnames that can be filtered on. + * + * - `'mdns'` — mDNS / Bonjour `.local` hostnames + * - `'domain'` — any os-managed domain name (matches both `'private-domain'` and `'public-domain'` metadata kinds) + * - `'ip'` — shorthand for both `'ipv4'` and `'ipv6'` + * - `'ipv4'` — IPv4 addresses only + * - `'ipv6'` — IPv6 addresses only + * - `'localhost'` — loopback addresses (`localhost`, `127.0.0.1`, `::1`) + * - `'link-local'` — IPv6 link-local addresses (fe80::/10) + * - `'plugin'` — hostnames provided by a plugin package + */ type FilterKinds = | 'mdns' | 'domain' @@ -34,10 +46,25 @@ type FilterKinds = | 'ipv6' | 'localhost' | 'link-local' + | 'plugin' + +/** + * Describes which hostnames to include (or exclude) when filtering a `Filled` address. + * + * Every field is optional — omitted fields impose no constraint. + * Filters are composable: the `.filter()` method intersects successive filters, + * and the `exclude` field inverts a nested filter. + */ export type Filter = { + /** Keep only hostnames with the given visibility. `'public'` = externally reachable, `'private'` = LAN-only. */ visibility?: 'public' | 'private' + /** Keep only hostnames whose metadata kind matches. A single kind or array of kinds. `'ip'` expands to `['ipv4','ipv6']`, `'domain'` matches both `'private-domain'` and `'public-domain'`. */ kind?: FilterKinds | FilterKinds[] + /** Arbitrary predicate — hostnames for which this returns `false` are excluded. */ predicate?: (h: HostnameInfo) => boolean + /** Keep only plugin hostnames provided by this package. Implies `kind: 'plugin'`. */ + pluginId?: PackageId + /** A nested filter whose matches are *removed* from the result (logical NOT). */ exclude?: Filter } @@ -65,9 +92,13 @@ type KindFilter = K extends 'mdns' ? | (HostnameInfo & { metadata: { kind: 'ipv6' } }) | KindFilter> - : K extends 'ip' - ? KindFilter | 'ipv4' | 'ipv6'> - : never + : K extends 'plugin' + ? + | (HostnameInfo & { metadata: { kind: 'plugin' } }) + | KindFilter> + : K extends 'ip' + ? KindFilter | 'ipv4' | 'ipv6'> + : never type FilterReturnTy = F extends { visibility: infer V extends 'public' | 'private' @@ -107,20 +138,62 @@ type FormatReturnTy< ? UrlString | FormatReturnTy> : never +/** + * A resolved address with its hostnames already populated, plus helpers + * for filtering, formatting, and converting hostnames to URLs. + * + * Filters are chainable and each call returns a new `Filled` narrowed to the + * matching subset of hostnames: + * + * ```ts + * addresses.nonLocal // exclude localhost & link-local + * addresses.public // only publicly-reachable hostnames + * addresses.filter({ kind: 'domain' }) // only domain-name hostnames + * addresses.filter({ visibility: 'private' }) // only LAN-reachable hostnames + * addresses.nonLocal.filter({ kind: 'ip' }) // chainable — non-local IPs only + * ``` + */ export type Filled = { + /** The hostnames that survived all applied filters. */ hostnames: HostnameInfo[] + /** Convert a single hostname into a fully-formed URL string, applying the address's scheme, username, and suffix. */ toUrl: (h: HostnameInfo) => UrlString + /** + * Return every hostname in the requested format. + * + * - `'urlstring'` (default) — formatted URL strings + * - `'url'` — `URL` objects + * - `'hostname-info'` — raw `HostnameInfo` objects + */ format: ( format?: Format, ) => FormatReturnTy<{}, Format>[] + /** + * Apply an arbitrary {@link Filter} and return a new `Filled` containing only + * the hostnames that match. Filters compose: calling `.filter()` on an + * already-filtered `Filled` intersects the constraints. + */ filter: ( filter: NewFilter, ) => Filled + /** + * Apply multiple filters and return hostnames that match **any** of them (union / OR). + * + * ```ts + * addresses.matchesAny([{ kind: 'domain' }, { kind: 'mdns' }]) + * ``` + */ + matchesAny: ( + filters: [...NewFilters], + ) => Filled + + /** Shorthand filter that excludes `localhost` and IPv6 link-local addresses — keeps only network-reachable hostnames. */ nonLocal: Filled + /** Shorthand filter that keeps only publicly-reachable hostnames (those with `public: true`). */ public: Filled } export type FilledAddressInfo = AddressInfo & Filled @@ -210,7 +283,16 @@ function filterRec( ['localhost', '127.0.0.1', '::1'].includes(h.hostname)) || (kind.has('link-local') && h.metadata.kind === 'ipv6' && - IPV6_LINK_LOCAL.contains(IpAddress.parse(h.hostname)))), + IPV6_LINK_LOCAL.contains(IpAddress.parse(h.hostname))) || + (kind.has('plugin') && h.metadata.kind === 'plugin')), + ) + } + if (filter.pluginId) { + const id = filter.pluginId + hostnames = hostnames.filter( + (h) => + invert !== + (h.metadata.kind === 'plugin' && h.metadata.packageId === id), ) } @@ -242,6 +324,14 @@ function enabledAddresses(addr: DerivedAddressInfo): HostnameInfo[] { }) } +/** + * Filters out localhost and IPv6 link-local hostnames from a list. + * Equivalent to the `nonLocal` filter on `Filled` addresses. + */ +export function filterNonLocal(hostnames: HostnameInfo[]): HostnameInfo[] { + return filterRec(hostnames, nonLocalFilter, false) +} + export const filledAddress = ( host: Host, addressInfo: AddressInfo, @@ -280,6 +370,19 @@ export const filledAddress = ( filterRec(hostnames, filter, false), ) }, + matchesAny: (filters: [...NewFilters]) => { + const seen = new Set() + const union: HostnameInfo[] = [] + for (const f of filters) { + for (const h of filterRec(hostnames, f, false)) { + if (!seen.has(h)) { + seen.add(h) + union.push(h) + } + } + } + return filledAddressFromHostnames(union) + }, get nonLocal(): Filled { return getNonLocal() }, diff --git a/sdk/base/lib/util/index.ts b/sdk/base/lib/util/index.ts index bad134501..e156cb97b 100644 --- a/sdk/base/lib/util/index.ts +++ b/sdk/base/lib/util/index.ts @@ -8,6 +8,7 @@ export { GetServiceInterface, getServiceInterface, filledAddress, + filterNonLocal, } from './getServiceInterface' export { getServiceInterfaces } from './getServiceInterfaces' export { once } from './once' diff --git a/sdk/package/lib/StartSdk.ts b/sdk/package/lib/StartSdk.ts index 755425ccd..9450e6b8e 100644 --- a/sdk/package/lib/StartSdk.ts +++ b/sdk/package/lib/StartSdk.ts @@ -141,6 +141,7 @@ export class StartSdk { | 'getSystemSmtp' | 'getOutboundGateway' | 'getContainerIp' + | 'getStatus' | 'getDataVersion' | 'setDataVersion' | 'getServiceManifest' @@ -164,7 +165,6 @@ export class StartSdk { getSslKey: (effects, ...args) => effects.getSslKey(...args), shutdown: (effects, ...args) => effects.shutdown(...args), getDependencies: (effects, ...args) => effects.getDependencies(...args), - getStatus: (effects, ...args) => effects.getStatus(...args), setHealth: (effects, ...args) => effects.setHealth(...args), } @@ -342,6 +342,104 @@ export class StartSdk { } }, + /** + * Get the service's current status with reactive subscription support. + * + * Returns an object with multiple read strategies: `const()` for a value + * that retries on change, `once()` for a single read, `watch()` for an async + * generator, `onChange()` for a callback, and `waitFor()` to block until a predicate is met. + * + * @param effects - The effects context + * @param options - Optional filtering options (e.g. `packageId`) + */ + getStatus: ( + effects: T.Effects, + options: Omit[0], 'callback'> = {}, + ) => { + async function* watch(abort?: AbortSignal) { + const resolveCell = { resolve: () => {} } + effects.onLeaveContext(() => { + resolveCell.resolve() + }) + abort?.addEventListener('abort', () => resolveCell.resolve()) + while (effects.isInContext && !abort?.aborted) { + let callback: () => void = () => {} + const waitForNext = new Promise((resolve) => { + callback = resolve + resolveCell.resolve = resolve + }) + yield await effects.getStatus({ ...options, callback }) + await waitForNext + } + } + return { + const: () => + effects.getStatus({ + ...options, + callback: + effects.constRetry && + (() => effects.constRetry && effects.constRetry()), + }), + once: () => effects.getStatus(options), + watch: (abort?: AbortSignal) => { + const ctrl = new AbortController() + abort?.addEventListener('abort', () => ctrl.abort()) + return DropGenerator.of(watch(ctrl.signal), () => ctrl.abort()) + }, + onChange: ( + callback: ( + value: T.StatusInfo | null, + error?: Error, + ) => { cancel: boolean } | Promise<{ cancel: boolean }>, + ) => { + ;(async () => { + const ctrl = new AbortController() + for await (const value of watch(ctrl.signal)) { + try { + const res = await callback(value) + if (res.cancel) { + ctrl.abort() + break + } + } catch (e) { + console.error( + 'callback function threw an error @ getStatus.onChange', + e, + ) + } + } + })() + .catch((e) => callback(null, e)) + .catch((e) => + console.error( + 'callback function threw an error @ getStatus.onChange', + e, + ), + ) + }, + waitFor: async (pred: (value: T.StatusInfo | null) => boolean) => { + const resolveCell = { resolve: () => {} } + effects.onLeaveContext(() => { + resolveCell.resolve() + }) + while (effects.isInContext) { + let callback: () => void = () => {} + const waitForNext = new Promise((resolve) => { + callback = resolve + resolveCell.resolve = resolve + }) + const res = await effects.getStatus({ ...options, callback }) + if (pred(res)) { + resolveCell.resolve() + return res + } + await waitForNext + } + return null + }, + } + }, + MultiHost: { /** * Create a new MultiHost instance for binding ports and exporting interfaces. diff --git a/sdk/package/package-lock.json b/sdk/package/package-lock.json index b6bf59802..868def13d 100644 --- a/sdk/package/package-lock.json +++ b/sdk/package/package-lock.json @@ -1,12 +1,12 @@ { "name": "@start9labs/start-sdk", - "version": "0.4.0-beta.55", + "version": "0.4.0-beta.58", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@start9labs/start-sdk", - "version": "0.4.0-beta.55", + "version": "0.4.0-beta.58", "license": "MIT", "dependencies": { "@iarna/toml": "^3.0.0", diff --git a/sdk/package/package.json b/sdk/package/package.json index 31265ba99..14cc168bc 100644 --- a/sdk/package/package.json +++ b/sdk/package/package.json @@ -1,6 +1,6 @@ { "name": "@start9labs/start-sdk", - "version": "0.4.0-beta.55", + "version": "0.4.0-beta.58", "description": "Software development kit to facilitate packaging services for StartOS", "main": "./package/lib/index.js", "types": "./package/lib/index.d.ts", diff --git a/web/projects/setup-wizard/src/app/pages/drives.page.ts b/web/projects/setup-wizard/src/app/pages/drives.page.ts index 2790fc306..1d20848c1 100644 --- a/web/projects/setup-wizard/src/app/pages/drives.page.ts +++ b/web/projects/setup-wizard/src/app/pages/drives.page.ts @@ -34,110 +34,121 @@ import { PreserveOverwriteDialog } from '../components/preserve-overwrite.dialog @Component({ template: ` @if (!shuttingDown) { -
-
-

{{ 'Select Drives' | i18n }}

-
+
+
+

{{ 'Select Drives' | i18n }}

+
- @if (loading) { - - } @else if (drives.length === 0) { -

- {{ - 'No drives found. Please connect a drive and click Refresh.' | i18n - }} -

- } @else { - - - @if (mobile) { - - } @else { - - } - @if (!mobile) { - - } - - - - - - @if (mobile) { - - } @else { - - } - @if (!mobile) { - - } - @if (preserveData === true) { - - } - @if (preserveData === false) { - - } - - - - -
- - {{ drive.vendor || ('Unknown' | i18n) }} - {{ drive.model || ('Drive' | i18n) }} - - - {{ formatCapacity(drive.capacity) }} · {{ drive.logicalname }} - -
-
- } - -
- @if (drives.length === 0) { - + @if (loading) { + + } @else if (drives.length === 0) { +

+ {{ + 'No drives found. Please connect a drive and click Refresh.' + | i18n + }} +

} @else { - + + @if (mobile) { + + } @else { + + } + @if (!mobile) { + + } + + + + + + @if (mobile) { + + } @else { + + } + @if (!mobile) { + + } + @if (preserveData === true) { + + } + @if (preserveData === false) { + + } + + + + +
+ + {{ driveName(drive) }} + + + {{ formatCapacity(drive.capacity) }} · {{ drive.logicalname }} + +
+
} -
-
+ +
+ @if (drives.length === 0) { + + } @else { + + } +
+
} `, styles: ` @@ -198,6 +209,10 @@ export default class DrivesPage { 'The drive where your StartOS data (services, settings, etc.) will be stored. This can be the same as the OS drive or a separate drive.', ) + private readonly MIN_OS = 18 * 2 ** 30 // 18 GiB + private readonly MIN_DATA = 20 * 2 ** 30 // 20 GiB + private readonly MIN_BOTH = 38 * 2 ** 30 // 38 GiB + drives: DiskInfo[] = [] loading = true shuttingDown = false @@ -206,10 +221,17 @@ export default class DrivesPage { selectedDataDrive: DiskInfo | null = null preserveData: boolean | null = null + readonly osDisabled = (drive: DiskInfo): boolean => + drive.capacity < this.MIN_OS + + dataDisabled = (drive: DiskInfo): boolean => drive.capacity < this.MIN_DATA + + readonly driveName = (drive: DiskInfo): string => + [drive.vendor, drive.model].filter(Boolean).join(' ') || + this.i18n.transform('Unknown Drive') + readonly stringify = (drive: DiskInfo | null) => - drive - ? `${drive.vendor || this.i18n.transform('Unknown')} ${drive.model || this.i18n.transform('Drive')}` - : '' + drive ? this.driveName(drive) : '' formatCapacity(bytes: number): string { const gb = bytes / 1e9 @@ -231,6 +253,22 @@ export default class DrivesPage { await this.loadDrives() } + onOsDriveChange(osDrive: DiskInfo | null) { + this.selectedOsDrive = osDrive + this.dataDisabled = (drive: DiskInfo) => { + if (osDrive && drive.logicalname === osDrive.logicalname) { + return drive.capacity < this.MIN_BOTH + } + return drive.capacity < this.MIN_DATA + } + + // Clear data drive if it's now invalid + if (this.selectedDataDrive && this.dataDisabled(this.selectedDataDrive)) { + this.selectedDataDrive = null + this.preserveData = null + } + } + onDataDriveChange(drive: DiskInfo | null) { this.preserveData = null @@ -400,7 +438,7 @@ export default class DrivesPage { private async loadDrives() { try { - this.drives = await this.api.getDisks() + this.drives = (await this.api.getDisks()).filter(d => d.capacity > 0) } catch (e: any) { this.errorService.handleError(e) } finally { diff --git a/web/projects/setup-wizard/src/app/pages/keyboard.page.ts b/web/projects/setup-wizard/src/app/pages/keyboard.page.ts index fbd4e5c11..082edc3dc 100644 --- a/web/projects/setup-wizard/src/app/pages/keyboard.page.ts +++ b/web/projects/setup-wizard/src/app/pages/keyboard.page.ts @@ -1,5 +1,4 @@ import { Component, inject, signal } from '@angular/core' -import { Router } from '@angular/router' import { FormsModule } from '@angular/forms' import { getAllKeyboardsSorted, @@ -72,7 +71,6 @@ import { StateService } from '../services/state.service' ], }) export default class KeyboardPage { - private readonly router = inject(Router) private readonly api = inject(ApiService) private readonly stateService = inject(StateService) @@ -103,22 +101,9 @@ export default class KeyboardPage { }) this.stateService.keyboard = this.selected.layout - await this.navigateToNextStep() + await this.stateService.navigateAfterLocale() } finally { this.saving.set(false) } } - - private async navigateToNextStep() { - if (this.stateService.dataDriveGuid) { - if (this.stateService.attach) { - this.stateService.setupType = 'attach' - await this.router.navigate(['/password']) - } else { - await this.router.navigate(['/home']) - } - } else { - await this.router.navigate(['/drives']) - } - } } diff --git a/web/projects/setup-wizard/src/app/pages/language.page.ts b/web/projects/setup-wizard/src/app/pages/language.page.ts index f30953d1e..066a48787 100644 --- a/web/projects/setup-wizard/src/app/pages/language.page.ts +++ b/web/projects/setup-wizard/src/app/pages/language.page.ts @@ -141,8 +141,12 @@ export default class LanguagePage { try { await this.api.setLanguage({ language: this.selected.name }) - // Always go to keyboard selection - await this.router.navigate(['/keyboard']) + + if (this.stateService.kiosk) { + await this.router.navigate(['/keyboard']) + } else { + await this.stateService.navigateAfterLocale() + } } finally { this.saving.set(false) } diff --git a/web/projects/setup-wizard/src/app/services/mock-api.service.ts b/web/projects/setup-wizard/src/app/services/mock-api.service.ts index 47a64babf..743977e94 100644 --- a/web/projects/setup-wizard/src/app/services/mock-api.service.ts +++ b/web/projects/setup-wizard/src/app/services/mock-api.service.ts @@ -191,7 +191,118 @@ export class MockApiService extends ApiService { } } +const GiB = 2 ** 30 + const MOCK_DISKS: DiskInfo[] = [ + // 0 capacity - should be hidden entirely + { + logicalname: '/dev/sdd', + vendor: 'Generic', + model: 'Card Reader', + partitions: [], + capacity: 0, + guid: null, + }, + // 10 GiB - too small for OS and data; also tests both vendor+model null + { + logicalname: '/dev/sde', + vendor: null, + model: null, + partitions: [ + { + logicalname: '/dev/sde1', + label: null, + capacity: 10 * GiB, + used: null, + startOs: {}, + guid: null, + }, + ], + capacity: 10 * GiB, + guid: null, + }, + // 18 GiB - exact OS boundary; tests vendor null with model present + { + logicalname: '/dev/sdf', + vendor: null, + model: 'SATA Flash Drive', + partitions: [ + { + logicalname: '/dev/sdf1', + label: null, + capacity: 18 * GiB, + used: null, + startOs: {}, + guid: null, + }, + ], + capacity: 18 * GiB, + guid: null, + }, + // 20 GiB - exact data boundary; tests vendor present with model null + { + logicalname: '/dev/sdg', + vendor: 'PNY', + model: null, + partitions: [ + { + logicalname: '/dev/sdg1', + label: null, + capacity: 20 * GiB, + used: null, + startOs: {}, + guid: null, + }, + ], + capacity: 20 * GiB, + guid: null, + }, + // 30 GiB - OK for OS or data alone, too small for both (< 38 GiB) + { + logicalname: '/dev/sdh', + vendor: 'SanDisk', + model: 'Ultra', + partitions: [ + { + logicalname: '/dev/sdh1', + label: null, + capacity: 30 * GiB, + used: null, + startOs: {}, + guid: null, + }, + ], + capacity: 30 * GiB, + guid: null, + }, + // 30 GiB with existing StartOS data - tests preserve/overwrite + capacity constraint + { + logicalname: '/dev/sdi', + vendor: 'Kingston', + model: 'A400', + partitions: [ + { + logicalname: '/dev/sdi1', + label: null, + capacity: 30 * GiB, + used: null, + startOs: { + 'small-server-id': { + hostname: 'small-server', + version: '0.3.6', + timestamp: new Date().toISOString(), + passwordHash: + '$argon2d$v=19$m=1024,t=1,p=1$YXNkZmFzZGZhc2RmYXNkZg$Ceev1I901G6UwU+hY0sHrFZ56D+o+LNJ', + wrappedKey: null, + }, + }, + guid: 'small-existing-guid', + }, + ], + capacity: 30 * GiB, + guid: 'small-existing-guid', + }, + // 500 GB - large, always OK { logicalname: '/dev/sda', vendor: 'Samsung', @@ -209,6 +320,7 @@ const MOCK_DISKS: DiskInfo[] = [ capacity: 500000000000, guid: null, }, + // 1 TB with existing StartOS data { logicalname: '/dev/sdb', vendor: 'Crucial', @@ -235,6 +347,7 @@ const MOCK_DISKS: DiskInfo[] = [ capacity: 1000000000000, guid: 'existing-guid', }, + // 2 TB { logicalname: '/dev/sdc', vendor: 'WD', diff --git a/web/projects/setup-wizard/src/app/services/state.service.ts b/web/projects/setup-wizard/src/app/services/state.service.ts index a36f08d3e..a3aba2c7b 100644 --- a/web/projects/setup-wizard/src/app/services/state.service.ts +++ b/web/projects/setup-wizard/src/app/services/state.service.ts @@ -1,4 +1,5 @@ import { inject, Injectable } from '@angular/core' +import { Router } from '@angular/router' import { T } from '@start9labs/start-sdk' import { ApiService } from './api.service' @@ -29,6 +30,7 @@ export type RecoverySource = }) export class StateService { private readonly api = inject(ApiService) + private readonly router = inject(Router) // Determined at app init kiosk = false @@ -45,6 +47,23 @@ export class StateService { setupType?: SetupType recoverySource?: RecoverySource + /** + * Navigate to the appropriate step after language/keyboard selection. + * Keyboard selection is only needed in kiosk mode. + */ + async navigateAfterLocale(): Promise { + if (this.dataDriveGuid) { + if (this.attach) { + this.setupType = 'attach' + await this.router.navigate(['/password']) + } else { + await this.router.navigate(['/home']) + } + } else { + await this.router.navigate(['/drives']) + } + } + /** * Called for attach flow (existing data drive) */ diff --git a/web/projects/shared/assets/icons/letsencrypt.svg b/web/projects/shared/assets/icons/letsencrypt.svg new file mode 100644 index 000000000..b222ef03e --- /dev/null +++ b/web/projects/shared/assets/icons/letsencrypt.svg @@ -0,0 +1 @@ + diff --git a/web/projects/shared/src/directives/docs-link.directive.ts b/web/projects/shared/src/directives/docs-link.directive.ts index 2e2610954..b46551ea1 100644 --- a/web/projects/shared/src/directives/docs-link.directive.ts +++ b/web/projects/shared/src/directives/docs-link.directive.ts @@ -16,7 +16,7 @@ export const VERSION = new InjectionToken('VERSION') host: { target: '_blank', rel: 'noreferrer', - '[href]': 'url()', + '[attr.href]': 'url()', }, }) export class DocsLinkDirective { diff --git a/web/projects/shared/src/i18n/dictionaries/de.ts b/web/projects/shared/src/i18n/dictionaries/de.ts index 7926e9644..95f7152bb 100644 --- a/web/projects/shared/src/i18n/dictionaries/de.ts +++ b/web/projects/shared/src/i18n/dictionaries/de.ts @@ -360,7 +360,6 @@ export default { 377: 'StartOS-Sicherungen erkannt', 378: 'Keine StartOS-Sicherungen erkannt', 379: 'StartOS-Version', - 381: 'SMTP-Zugangsdaten', 382: 'Test-E-Mail senden', 383: 'Senden', 384: 'E-Mail wird gesendet', @@ -644,7 +643,6 @@ export default { 706: 'Beibehalten', 707: 'Überschreiben', 708: 'Entsperren', - 709: 'Laufwerk', 710: 'Übertragen', 711: 'Die Liste ist leer', 712: 'Jetzt neu starten', @@ -659,8 +657,6 @@ export default { 721: 'Gateway für ausgehenden Datenverkehr auswählen', 722: 'Der Typ des Gateways', 723: 'Nur ausgehend', - 724: 'Als Standard für ausgehenden Verkehr festlegen', - 725: 'Gesamten ausgehenden Datenverkehr über dieses Gateway leiten', 726: 'WireGuard-Konfigurationsdatei', 727: 'Eingehend/Ausgehend', 728: 'StartTunnel (Eingehend/Ausgehend)', @@ -669,7 +665,6 @@ export default { 731: 'Öffentliche Domain', 732: 'Private Domain', 733: 'Ausblenden', - 734: 'Standard ausgehend', 735: 'Zertifikat', 736: 'Selbstsigniert', 737: 'Portweiterleitung', @@ -704,4 +699,14 @@ export default { 774: 'Der Portstatus kann nicht ermittelt werden, solange der Dienst nicht läuft', 775: 'Diese Adresse funktioniert nicht aus Ihrem lokalen Netzwerk aufgrund einer Router-Hairpinning-Einschränkung', 776: 'Aktion nicht gefunden', + 777: 'Diese Domain wird auch gelten für', + 778: 'Plugin', + 779: 'Öffentlich', + 780: 'Privat', + 781: 'Lokal', + 782: 'Unbekanntes Laufwerk', + 783: 'Muss eine gültige E-Mail-Adresse sein', + 786: 'Automatisch', + 787: 'Ausgehender Datenverkehr', + 788: 'Gateway verwenden', } satisfies i18n diff --git a/web/projects/shared/src/i18n/dictionaries/en.ts b/web/projects/shared/src/i18n/dictionaries/en.ts index 6b8e5e178..e7856c8fc 100644 --- a/web/projects/shared/src/i18n/dictionaries/en.ts +++ b/web/projects/shared/src/i18n/dictionaries/en.ts @@ -359,7 +359,6 @@ export const ENGLISH: Record = { 'StartOS backups detected': 377, 'No StartOS backups detected': 378, 'StartOS Version': 379, - 'SMTP Credentials': 381, 'Send test email': 382, 'Send': 383, 'Sending email': 384, @@ -644,7 +643,6 @@ export const ENGLISH: Record = { 'Preserve': 706, 'Overwrite': 707, 'Unlock': 708, - 'Drive': 709, // the noun, a storage device 'Transfer': 710, // the verb 'The list is empty': 711, 'Restart now': 712, @@ -659,8 +657,6 @@ export const ENGLISH: Record = { '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, @@ -669,7 +665,6 @@ export const ENGLISH: Record = { 'Public Domain': 731, 'Private Domain': 732, 'Hide': 733, - 'default outbound': 734, 'Certificate': 735, 'Self signed': 736, 'Port Forwarding': 737, @@ -704,4 +699,14 @@ export const ENGLISH: Record = { 'Port status cannot be determined while service is not running': 774, 'This address will not work from your local network due to a router hairpinning limitation': 775, 'Action not found': 776, + 'This domain will also apply to': 777, + 'Plugin': 778, + 'Public': 779, // as in, publicly accessible + 'Private': 780, // as in, privately accessible + 'Local': 781, // as in, locally accessible + 'Unknown Drive': 782, + 'Must be a valid email address': 783, + 'Auto': 786, + 'Outbound Traffic': 787, + 'Use gateway': 788, } diff --git a/web/projects/shared/src/i18n/dictionaries/es.ts b/web/projects/shared/src/i18n/dictionaries/es.ts index 1acc39a5b..11cf98218 100644 --- a/web/projects/shared/src/i18n/dictionaries/es.ts +++ b/web/projects/shared/src/i18n/dictionaries/es.ts @@ -360,7 +360,6 @@ export default { 377: 'Copias de seguridad de StartOS detectadas', 378: 'No se detectaron copias de seguridad de StartOS', 379: 'Versión de StartOS', - 381: 'Credenciales SMTP', 382: 'Enviar correo de prueba', 383: 'Enviar', 384: 'Enviando correo', @@ -644,7 +643,6 @@ export default { 706: 'Conservar', 707: 'Sobrescribir', 708: 'Desbloquear', - 709: 'Unidad', 710: 'Transferir', 711: 'La lista está vacía', 712: 'Reiniciar ahora', @@ -659,8 +657,6 @@ export default { 721: 'Selecciona la puerta de enlace para el tráfico saliente', 722: 'El tipo de puerta de enlace', 723: 'Solo saliente', - 724: 'Establecer como saliente predeterminado', - 725: 'Enrutar todo el tráfico saliente a través de esta puerta de enlace', 726: 'Archivo de configuración WireGuard', 727: 'Entrante/Saliente', 728: 'StartTunnel (Entrante/Saliente)', @@ -669,7 +665,6 @@ export default { 731: 'Dominio público', 732: 'Dominio privado', 733: 'Ocultar', - 734: 'saliente predeterminado', 735: 'Certificado', 736: 'Autofirmado', 737: 'Reenvío de puertos', @@ -704,4 +699,14 @@ export default { 774: 'El estado del puerto no se puede determinar mientras el servicio no está en ejecución', 775: 'Esta dirección no funcionará desde tu red local debido a una limitación de hairpinning del router', 776: 'Acción no encontrada', + 777: 'Este dominio también se aplicará a', + 778: 'Plugin', + 779: 'Público', + 780: 'Privado', + 781: 'Local', + 782: 'Unidad desconocida', + 783: 'Debe ser una dirección de correo electrónico válida', + 786: 'Automático', + 787: 'Tráfico saliente', + 788: 'Usar gateway', } satisfies i18n diff --git a/web/projects/shared/src/i18n/dictionaries/fr.ts b/web/projects/shared/src/i18n/dictionaries/fr.ts index d3e2eeb21..678f5961b 100644 --- a/web/projects/shared/src/i18n/dictionaries/fr.ts +++ b/web/projects/shared/src/i18n/dictionaries/fr.ts @@ -360,7 +360,6 @@ export default { 377: 'Sauvegardes StartOS détectées', 378: 'Aucune sauvegarde StartOS détectée', 379: 'Version de StartOS', - 381: 'Identifiants SMTP', 382: 'Envoyer un email de test', 383: 'Envoyer', 384: 'Envoi de l’email', @@ -644,7 +643,6 @@ export default { 706: 'Conserver', 707: 'Écraser', 708: 'Déverrouiller', - 709: 'Disque', 710: 'Transférer', 711: 'La liste est vide', 712: 'Redémarrer maintenant', @@ -659,8 +657,6 @@ export default { 721: 'Sélectionnez la passerelle pour le trafic sortant', 722: 'Le type de passerelle', 723: 'Sortant uniquement', - 724: 'Définir comme sortant par défaut', - 725: 'Acheminer tout le trafic sortant via cette passerelle', 726: 'Fichier de configuration WireGuard', 727: 'Entrant/Sortant', 728: 'StartTunnel (Entrant/Sortant)', @@ -669,7 +665,6 @@ export default { 731: 'Domaine public', 732: 'Domaine privé', 733: 'Masquer', - 734: 'sortant par défaut', 735: 'Certificat', 736: 'Auto-signé', 737: 'Redirection de ports', @@ -704,4 +699,14 @@ export default { 774: "L'état du port ne peut pas être déterminé tant que le service n'est pas en cours d'exécution", 775: "Cette adresse ne fonctionnera pas depuis votre réseau local en raison d'une limitation de hairpinning du routeur", 776: 'Action introuvable', + 777: "Ce domaine s'appliquera également à", + 778: 'Plugin', + 779: 'Public', + 780: 'Privé', + 781: 'Local', + 782: 'Lecteur inconnu', + 783: 'Doit être une adresse e-mail valide', + 786: 'Automatique', + 787: 'Trafic sortant', + 788: 'Utiliser la passerelle', } satisfies i18n diff --git a/web/projects/shared/src/i18n/dictionaries/pl.ts b/web/projects/shared/src/i18n/dictionaries/pl.ts index b616b4f79..b26484268 100644 --- a/web/projects/shared/src/i18n/dictionaries/pl.ts +++ b/web/projects/shared/src/i18n/dictionaries/pl.ts @@ -360,7 +360,6 @@ export default { 377: 'Wykryto kopie zapasowe StartOS', 378: 'Nie wykryto kopii zapasowych StartOS', 379: 'Wersja StartOS', - 381: 'Dane logowania SMTP', 382: 'Wyślij e-mail testowy', 383: 'Wyślij', 384: 'Wysyłanie e-maila', @@ -644,7 +643,6 @@ export default { 706: 'Zachowaj', 707: 'Nadpisz', 708: 'Odblokuj', - 709: 'Dysk', 710: 'Przenieś', 711: 'Lista jest pusta', 712: 'Uruchom ponownie teraz', @@ -659,8 +657,6 @@ export default { 721: 'Wybierz bramę dla ruchu wychodzącego', 722: 'Typ bramy', 723: 'Tylko wychodzący', - 724: 'Ustaw jako domyślne wychodzące', - 725: 'Kieruj cały ruch wychodzący przez tę bramę', 726: 'Plik konfiguracyjny WireGuard', 727: 'Przychodzący/Wychodzący', 728: 'StartTunnel (Przychodzący/Wychodzący)', @@ -669,7 +665,6 @@ export default { 731: 'Domena publiczna', 732: 'Domena prywatna', 733: 'Ukryj', - 734: 'domyślne wychodzące', 735: 'Certyfikat', 736: 'Samopodpisany', 737: 'Przekierowanie portów', @@ -704,4 +699,14 @@ export default { 774: 'Status portu nie może być określony, gdy usługa nie jest uruchomiona', 775: 'Ten adres nie będzie działać z Twojej sieci lokalnej z powodu ograniczenia hairpinning routera', 776: 'Nie znaleziono akcji', + 777: 'Ta domena będzie również dotyczyć', + 778: 'Wtyczka', + 779: 'Publiczny', + 780: 'Prywatny', + 781: 'Lokalny', + 782: 'Nieznany dysk', + 783: 'Musi być prawidłowy adres e-mail', + 786: 'Automatycznie', + 787: 'Ruch wychodzący', + 788: 'Użyj bramy', } satisfies i18n diff --git a/web/projects/ui/src/app/routes/portal/components/form.component.ts b/web/projects/ui/src/app/routes/portal/components/form.component.ts index 3b300e3c2..f120eda49 100644 --- a/web/projects/ui/src/app/routes/portal/components/form.component.ts +++ b/web/projects/ui/src/app/routes/portal/components/form.component.ts @@ -31,6 +31,7 @@ export interface FormContext { buttons: ActionButton[] value?: T operations?: Operation[] + note?: string } @Component({ @@ -43,6 +44,9 @@ export interface FormContext { (tuiValueChanges)="markAsDirty()" > + @if (note) { +

{{ note }}

+ }
@for (button of buttons; track $index) { @@ -70,6 +74,12 @@ export interface FormContext { `, styles: ` + .note { + color: var(--tui-text-secondary); + font: var(--tui-font-text-s); + margin-top: 1rem; + } + footer { position: sticky; bottom: 0; @@ -106,6 +116,7 @@ export class FormComponent> implements OnInit { @Input() buttons = this.context?.data.buttons || [] @Input() operations = this.context?.data.operations || [] @Input() value?: T = this.context?.data.value + @Input() note = this.context?.data.note || '' form = new FormGroup({}) diff --git a/web/projects/ui/src/app/routes/portal/components/header/menu.component.ts b/web/projects/ui/src/app/routes/portal/components/header/menu.component.ts index d21e62e3d..55f5bc354 100644 --- a/web/projects/ui/src/app/routes/portal/components/header/menu.component.ts +++ b/web/projects/ui/src/app/routes/portal/components/header/menu.component.ts @@ -45,7 +45,7 @@ import { ABOUT } from './about.component' } - @@ -53,13 +53,15 @@ import { ABOUT } from './about.component' {{ 'User manual' | i18n }} @@ -67,6 +69,7 @@ import { ABOUT } from './about.component' @@ -76,6 +79,7 @@ import { ABOUT } from './about.component' - diff --git a/web/projects/ui/src/app/routes/portal/components/interfaces/addresses/actions.component.ts b/web/projects/ui/src/app/routes/portal/components/interfaces/addresses/actions.component.ts index ed0272cc4..b3c0616ff 100644 --- a/web/projects/ui/src/app/routes/portal/components/interfaces/addresses/actions.component.ts +++ b/web/projects/ui/src/app/routes/portal/components/interfaces/addresses/actions.component.ts @@ -30,19 +30,6 @@ import { DomainHealthService } from './domain-health.service' selector: 'td[actions]', template: `
- @if (address().ui) { - - {{ 'Open UI' | i18n }} - - } @if (address().deletable) { + } + +
+ + @if (data.form.value.smtp?.selection === 'enabled') { +
+
+

+ {{ 'Send test email' | i18n }} +

+
+ + + + + +
+ +
+ + } + } + `, + styles: ` + :host { + max-width: 36rem; + } + + form header, + form footer { + margin: 1rem 0; + display: flex; + gap: 1rem; + } + + footer { + justify-content: flex-end; + } + `, + changeDetection: ChangeDetectionStrategy.OnPush, + imports: [ + CommonModule, + ReactiveFormsModule, + FormGroupComponent, + TuiButton, + TuiError, + TuiTextfield, + TuiHeader, + TuiTitle, + RouterLink, + TitleDirective, + i18nPipe, + DocsLinkDirective, + ], +}) +export default class SystemEmailComponent { + private readonly dialog = inject(DialogService) + private readonly loader = inject(LoadingService) + private readonly errorService = inject(ErrorService) + private readonly formService = inject(FormService) + private readonly patch = inject>(PatchDB) + private readonly api = inject(ApiService) + private readonly i18n = inject(i18nPipe) + + private readonly emailRegex = new RegExp(utils.Patterns.email.regex) + readonly testEmailControl = new FormControl('') + + get isEmailInvalid(): boolean { + const value = this.testEmailControl.value + return !!value && !this.emailRegex.test(value) + } + + private readonly smtpSpec = ISB.InputSpec.of({ + smtp: ISB.Value.union({ + name: this.i18n.transform('SMTP'), + default: 'disabled', + variants: ISB.Variants.of({ + disabled: { + name: this.i18n.transform('Disabled'), + spec: ISB.InputSpec.of({}), + }, + enabled: { + name: this.i18n.transform('Enabled'), + spec: inputSpec.constants.systemSmtpSpec, + }, + }), + }), + }) + + readonly form$ = this.patch.watch$('serverInfo', 'smtp').pipe( + switchMap(async value => { + const spec = await configBuilderToSpec(this.smtpSpec) + + const formData = value + ? { + smtp: { + selection: 'enabled' as const, + value: { + provider: { + selection: detectProviderKey(value.host), + value: { + host: value.host, + security: { + selection: value.security, + value: { port: String(value.port) }, + }, + from: value.from, + username: value.username, + password: value.password, + }, + }, + }, + }, + } + : undefined + const form = this.formService.createForm(spec, formData) + + return { form, spec, formData } + }), + ) + + private getSmtpValue(formValue: Record) { + const { security, ...rest } = formValue['smtp'].value.provider.value + return { + ...rest, + security: security.selection, + port: Number(security.value.port), + } + } + + async save(formValue: Record): Promise { + const loader = this.loader.open('Saving').subscribe() + + try { + if (formValue['smtp'].selection === 'disabled') { + await this.api.clearSmtp({}) + } else { + await this.api.setSmtp(this.getSmtpValue(formValue)) + } + } catch (e: any) { + this.errorService.handleError(e) + } finally { + loader.unsubscribe() + } + } + + cancel(data: { + form: ReturnType + formData: Record | undefined + }) { + data.form.reset(data.formData) + } + + async sendTestEmail(formValue: Record) { + const smtpValue = this.getSmtpValue(formValue) + const address = this.testEmailControl.value! + const loader = this.loader.open('Sending email').subscribe() + const success = + `${this.i18n.transform('A test email has been sent to')} ${address}. ${this.i18n.transform('Check your spam folder and mark as not spam.')}` as i18nKey + + try { + await this.api.testSmtp({ + ...smtpValue, + password: smtpValue.password || '', + to: address, + }) + this.dialog + .openAlert(success, { label: 'Success', size: 's' }) + .subscribe() + this.testEmailControl.reset() + } catch (e: any) { + this.errorService.handleError(e) + } finally { + loader.unsubscribe() + } + } +} diff --git a/web/projects/ui/src/app/routes/portal/routes/system/routes/ssh/ssh.component.ts b/web/projects/ui/src/app/routes/portal/routes/system/routes/ssh/ssh.component.ts index e76e0e92f..500acb399 100644 --- a/web/projects/ui/src/app/routes/portal/routes/system/routes/ssh/ssh.component.ts +++ b/web/projects/ui/src/app/routes/portal/routes/system/routes/ssh/ssh.component.ts @@ -41,7 +41,7 @@ import { SSHTableComponent } from './table.component' docsLink path="/start-os/user-manual/ssh.html" appearance="icon" - iconStart="@tui.external-link" + iconStart="@tui.book-open-text" > {{ 'Documentation' | i18n }} diff --git a/web/projects/ui/src/app/routes/portal/routes/system/routes/startos-ui/startos-ui.component.ts b/web/projects/ui/src/app/routes/portal/routes/system/routes/startos-ui/startos-ui.component.ts index 6e46d5e20..32d0191f1 100644 --- a/web/projects/ui/src/app/routes/portal/routes/system/routes/startos-ui/startos-ui.component.ts +++ b/web/projects/ui/src/app/routes/portal/routes/system/routes/startos-ui/startos-ui.component.ts @@ -73,9 +73,7 @@ export default class StartOsUiComponent { private readonly patch = inject>(PatchDB) - readonly network = toSignal( - this.patch.watch$('serverInfo', 'network'), - ) + readonly network = toSignal(this.patch.watch$('serverInfo', 'network')) readonly allPackageData = toSignal(this.patch.watch$('packageData')) @@ -98,6 +96,7 @@ export default class StartOsUiComponent { this.allPackageData(), ), addSsl: true, + sharedHostNames: [], } }) } diff --git a/web/projects/ui/src/app/routes/portal/routes/system/routes/wifi/wifi.component.ts b/web/projects/ui/src/app/routes/portal/routes/system/routes/wifi/wifi.component.ts index d9409d96b..24a30ab15 100644 --- a/web/projects/ui/src/app/routes/portal/routes/system/routes/wifi/wifi.component.ts +++ b/web/projects/ui/src/app/routes/portal/routes/system/routes/wifi/wifi.component.ts @@ -56,7 +56,7 @@ import { wifiSpec } from './wifi.const' docsLink path="/start-os/user-manual/wifi.html" appearance="icon" - iconStart="@tui.external-link" + iconStart="@tui.book-open-text" > {{ 'Documentation' | i18n }} diff --git a/web/projects/ui/src/app/routes/portal/routes/system/system.routes.ts b/web/projects/ui/src/app/routes/portal/routes/system/system.routes.ts index 8ed0703e5..689366dbf 100644 --- a/web/projects/ui/src/app/routes/portal/routes/system/system.routes.ts +++ b/web/projects/ui/src/app/routes/portal/routes/system/system.routes.ts @@ -28,7 +28,7 @@ export default [ { path: 'email', title: titleResolver, - loadComponent: () => import('./routes/email/email.component'), + loadComponent: () => import('./routes/smtp/smtp.component'), }, { path: 'backup', diff --git a/web/projects/ui/src/app/services/api/embassy-api.service.ts b/web/projects/ui/src/app/services/api/embassy-api.service.ts index cf660b87b..e415f6f57 100644 --- a/web/projects/ui/src/app/services/api/embassy-api.service.ts +++ b/web/projects/ui/src/app/services/api/embassy-api.service.ts @@ -340,11 +340,13 @@ export abstract class ApiService { abstract osUiAddPublicDomain( params: T.AddPublicDomainParams, - ): Promise + ): Promise abstract osUiRemovePublicDomain(params: T.RemoveDomainParams): Promise - abstract osUiAddPrivateDomain(params: T.AddPrivateDomainParams): Promise + abstract osUiAddPrivateDomain( + params: T.AddPrivateDomainParams, + ): Promise abstract osUiRemovePrivateDomain(params: T.RemoveDomainParams): Promise @@ -354,13 +356,15 @@ export abstract class ApiService { abstract pkgAddPublicDomain( params: PkgAddPublicDomainReq, - ): Promise + ): Promise abstract pkgRemovePublicDomain( params: PkgRemovePublicDomainReq, ): Promise - abstract pkgAddPrivateDomain(params: PkgAddPrivateDomainReq): Promise + abstract pkgAddPrivateDomain( + params: PkgAddPrivateDomainReq, + ): Promise abstract pkgRemovePrivateDomain( params: PkgRemovePrivateDomainReq, diff --git a/web/projects/ui/src/app/services/api/embassy-live-api.service.ts b/web/projects/ui/src/app/services/api/embassy-live-api.service.ts index 200792a89..28ab2ad3b 100644 --- a/web/projects/ui/src/app/services/api/embassy-live-api.service.ts +++ b/web/projects/ui/src/app/services/api/embassy-live-api.service.ts @@ -393,7 +393,7 @@ export class LiveApiService extends ApiService { // wifi async enableWifi(params: T.SetWifiEnabledParams): Promise { - return this.rpcRequest({ method: 'wifi.enable', params }) + return this.rpcRequest({ method: 'wifi.set-enabled', params }) } async getWifi(params: {}, timeout?: number): Promise { @@ -630,7 +630,7 @@ export class LiveApiService extends ApiService { async osUiAddPublicDomain( params: T.AddPublicDomainParams, - ): Promise { + ): Promise { return this.rpcRequest({ method: 'server.host.address.domain.public.add', params, @@ -644,7 +644,9 @@ export class LiveApiService extends ApiService { }) } - async osUiAddPrivateDomain(params: T.AddPrivateDomainParams): Promise { + async osUiAddPrivateDomain( + params: T.AddPrivateDomainParams, + ): Promise { return this.rpcRequest({ method: 'server.host.address.domain.private.add', params, @@ -669,7 +671,7 @@ export class LiveApiService extends ApiService { async pkgAddPublicDomain( params: PkgAddPublicDomainReq, - ): Promise { + ): Promise { return this.rpcRequest({ method: 'package.host.address.domain.public.add', params, @@ -683,7 +685,7 @@ export class LiveApiService extends ApiService { }) } - async pkgAddPrivateDomain(params: PkgAddPrivateDomainReq): Promise { + async pkgAddPrivateDomain(params: PkgAddPrivateDomainReq): Promise { return this.rpcRequest({ method: 'package.host.address.domain.private.add', params, diff --git a/web/projects/ui/src/app/services/api/embassy-mock-api.service.ts b/web/projects/ui/src/app/services/api/embassy-mock-api.service.ts index 2f84e1ffb..10c2ed162 100644 --- a/web/projects/ui/src/app/services/api/embassy-mock-api.service.ts +++ b/web/projects/ui/src/app/services/api/embassy-mock-api.service.ts @@ -1440,7 +1440,7 @@ export class MockApiService extends ApiService { async osUiAddPublicDomain( params: T.AddPublicDomainParams, - ): Promise { + ): Promise { await pauseFor(2000) const patch: Operation[] = [ @@ -1465,7 +1465,16 @@ export class MockApiService extends ApiService { ] this.mockRevision(patch) - return null + return { + dns: null, + port: { + ip: '0.0.0.0', + port: 443, + openExternally: false, + openInternally: false, + hairpinning: false, + }, + } } async osUiRemovePublicDomain(params: T.RemoveDomainParams): Promise { @@ -1482,7 +1491,9 @@ export class MockApiService extends ApiService { return null } - async osUiAddPrivateDomain(params: T.AddPrivateDomainParams): Promise { + async osUiAddPrivateDomain( + params: T.AddPrivateDomainParams, + ): Promise { await pauseFor(2000) const patch: Operation[] = [ @@ -1505,7 +1516,7 @@ export class MockApiService extends ApiService { ] this.mockRevision(patch) - return null + return false } async osUiRemovePrivateDomain(params: T.RemoveDomainParams): Promise { @@ -1535,7 +1546,7 @@ export class MockApiService extends ApiService { async pkgAddPublicDomain( params: PkgAddPublicDomainReq, - ): Promise { + ): Promise { await pauseFor(2000) const patch: Operation[] = [ @@ -1560,7 +1571,16 @@ export class MockApiService extends ApiService { ] this.mockRevision(patch) - return null + return { + dns: null, + port: { + ip: '0.0.0.0', + port: 443, + openExternally: false, + openInternally: false, + hairpinning: false, + }, + } } async pkgRemovePublicDomain(params: PkgRemovePublicDomainReq): Promise { @@ -1577,7 +1597,9 @@ export class MockApiService extends ApiService { return null } - async pkgAddPrivateDomain(params: PkgAddPrivateDomainReq): Promise { + async pkgAddPrivateDomain( + params: PkgAddPrivateDomainReq, + ): Promise { await pauseFor(2000) const patch: Operation[] = [ @@ -1600,7 +1622,7 @@ export class MockApiService extends ApiService { ] this.mockRevision(patch) - return null + return false } async pkgRemovePrivateDomain( diff --git a/web/projects/ui/src/app/services/api/mock-patch.ts b/web/projects/ui/src/app/services/api/mock-patch.ts index cff1c4ddf..557310204 100644 --- a/web/projects/ui/src/app/services/api/mock-patch.ts +++ b/web/projects/ui/src/app/services/api/mock-patch.ts @@ -212,6 +212,7 @@ export const mockPatchData: DataModel = { }, }, }, + passthroughs: [], defaultOutbound: 'eth0', dns: { dhcpServers: ['1.1.1.1', '8.8.8.8'], @@ -651,7 +652,7 @@ export const mockPatchData: DataModel = { publicDomains: { 'bitcoin.example.com': { gateway: 'eth0', - acme: null, + acme: 'https://acme-v02.api.letsencrypt.org/directory', }, }, privateDomains: { diff --git a/web/projects/ui/src/app/services/gateway.service.ts b/web/projects/ui/src/app/services/gateway.service.ts index ecbdc1420..b425149fb 100644 --- a/web/projects/ui/src/app/services/gateway.service.ts +++ b/web/projects/ui/src/app/services/gateway.service.ts @@ -12,7 +12,6 @@ export type GatewayPlus = T.NetworkInterfaceInfo & { subnets: utils.IpNet[] lanIpv4: string[] wanIp?: utils.IpAddress - isDefaultOutbound: boolean } @Injectable() @@ -29,7 +28,6 @@ export class GatewayService { this.network$.pipe( map(network => { const gateways = network.gateways - const defaultOutbound = network.defaultOutbound return Object.entries(gateways) .filter(([_, val]) => !!val?.ipInfo) .filter( @@ -49,7 +47,6 @@ export class GatewayService { lanIpv4: subnets.filter(s => s.isIpv4()).map(s => s.address), wanIp: val.ipInfo?.wanIp && utils.IpAddress.parse(val.ipInfo?.wanIp), - isDefaultOutbound: id === defaultOutbound, } as GatewayPlus }) }), diff --git a/web/projects/ui/src/app/services/marketplace.service.ts b/web/projects/ui/src/app/services/marketplace.service.ts index b5bfec34a..7c5576054 100644 --- a/web/projects/ui/src/app/services/marketplace.service.ts +++ b/web/projects/ui/src/app/services/marketplace.service.ts @@ -161,7 +161,6 @@ export class MarketplaceService { } private fetchRegistry$(url: string): Observable { - console.log('FETCHING REGISTRY: ', url) return combineLatest([this.fetchInfo$(url), this.fetchPackages$(url)]).pipe( map(([info, packages]) => ({ info, packages, url })), catchError(e => { diff --git a/web/projects/ui/src/styles.scss b/web/projects/ui/src/styles.scss index 757b8a6b1..fb3062ee4 100644 --- a/web/projects/ui/src/styles.scss +++ b/web/projects/ui/src/styles.scss @@ -70,6 +70,12 @@ hr { min-height: fit-content; flex: 1; padding: 1rem; + + &::after { + content: ''; + display: block; + height: 1rem; + } } .g-aside {