From 4005365239d393f9b23366c9bb73bc2052ee0181 Mon Sep 17 00:00:00 2001 From: Aiden McClelland Date: Wed, 4 Mar 2026 17:30:00 -0700 Subject: [PATCH] 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 --- core/src/net/gateway.rs | 14 +-- core/src/net/host/address.rs | 78 ++++++++++-- sdk/base/lib/interfaces/Host.ts | 10 +- sdk/base/lib/osBindings/AddPublicDomainRes.ts | 7 ++ sdk/base/lib/osBindings/NetworkInfo.ts | 2 + sdk/base/lib/osBindings/PassthroughInfo.ts | 9 ++ sdk/base/lib/osBindings/index.ts | 2 + sdk/base/lib/util/getServiceInterface.ts | 103 ++++++++++++++- .../shared/src/i18n/dictionaries/de.ts | 2 +- .../shared/src/i18n/dictionaries/en.ts | 2 +- .../shared/src/i18n/dictionaries/es.ts | 2 +- .../shared/src/i18n/dictionaries/fr.ts | 2 +- .../shared/src/i18n/dictionaries/pl.ts | 2 +- .../portal/components/form.component.ts | 11 ++ .../addresses/addresses.component.ts | 44 +++++-- .../interfaces/addresses/dns.component.ts | 117 ++++++++++++------ .../addresses/domain-health.service.ts | 68 ++++++---- .../addresses/port-forward.component.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 | 42 +++++-- .../ui/src/app/services/api/mock-patch.ts | 1 + 22 files changed, 423 insertions(+), 121 deletions(-) create mode 100644 sdk/base/lib/osBindings/AddPublicDomainRes.ts create mode 100644 sdk/base/lib/osBindings/PassthroughInfo.ts diff --git a/core/src/net/gateway.rs b/core/src/net/gateway.rs index 49bf35a23..2ec976702 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 { diff --git a/core/src/net/host/address.rs b/core/src/net/host/address.rs index 0a69d0427..d99ee1efc 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}; @@ -170,6 +171,15 @@ pub struct AddPublicDomainParams { pub gateway: GatewayId, } +#[derive(Debug, Clone, Deserialize, Serialize, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export)] +pub struct AddPublicDomainRes { + #[ts(type = "string | null")] + pub dns: Option, + pub port: Vec, +} + pub async fn add_public_domain( ctx: RpcContext, AddPublicDomainParams { @@ -178,8 +188,9 @@ pub async fn add_public_domain( gateway, }: AddPublicDomainParams, inheritance: Kind::Inheritance, -) -> Result, Error> { - ctx.db +) -> Result { + let ports = ctx + .db .mutate(|db| { if let Some(acme) = &acme { if !db @@ -195,21 +206,62 @@ 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)?; + let bindings = host.as_bindings().de()?; + let ports: BTreeSet = bindings + .values() + .flat_map(|b| &b.addresses.available) + .filter(|a| a.public && a.hostname == fqdn) + .filter_map(|a| a.port) + .collect(); + Ok(ports) }) .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_results) = 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)? + }, + futures::future::join_all(ports.into_iter().map(|port| { + check_port( + ctx.clone(), + CheckPortParams { + port, + gateway: gateway.clone(), + }, + ) + })) + ); + + Ok(AddPublicDomainRes { + dns: dns_result?, + port: port_results.into_iter().collect::, _>>()?, }) - .await - .with_kind(ErrorKind::Unknown)? } #[derive(Deserialize, Serialize, Parser, TS)] @@ -257,13 +309,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 +330,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/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/AddPublicDomainRes.ts b/sdk/base/lib/osBindings/AddPublicDomainRes.ts new file mode 100644 index 000000000..05f99b617 --- /dev/null +++ b/sdk/base/lib/osBindings/AddPublicDomainRes.ts @@ -0,0 +1,7 @@ +// 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: Array +} 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/util/getServiceInterface.ts b/sdk/base/lib/util/getServiceInterface.ts index 60fb2a745..151cf8960 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), ) } @@ -280,6 +362,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/web/projects/shared/src/i18n/dictionaries/de.ts b/web/projects/shared/src/i18n/dictionaries/de.ts index 7926e9644..046d1ff9a 100644 --- a/web/projects/shared/src/i18n/dictionaries/de.ts +++ b/web/projects/shared/src/i18n/dictionaries/de.ts @@ -678,7 +678,7 @@ export default { 741: 'In Ihrem Domain-Registrar für', 742: 'diesen DNS-Eintrag erstellen', 743: 'In Ihrem Gateway', - 744: 'diese Portweiterleitungsregel erstellen', + 744: 'diese Portweiterleitungsregeln erstellen', 745: 'Externer Port', 747: 'Interner Port', 749: 'DNS-Server-Konfiguration', diff --git a/web/projects/shared/src/i18n/dictionaries/en.ts b/web/projects/shared/src/i18n/dictionaries/en.ts index 6b8e5e178..4e16b3f7b 100644 --- a/web/projects/shared/src/i18n/dictionaries/en.ts +++ b/web/projects/shared/src/i18n/dictionaries/en.ts @@ -678,7 +678,7 @@ export const ENGLISH: Record = { 'In your domain registrar for': 741, // partial sentence, followed by a domain name 'create this DNS record': 742, 'In your gateway': 743, // partial sentence, followed by a gateway name - 'create this port forwarding rule': 744, + 'create these port forwarding rules': 744, 'External Port': 745, 'Internal Port': 747, 'DNS Server Config': 749, diff --git a/web/projects/shared/src/i18n/dictionaries/es.ts b/web/projects/shared/src/i18n/dictionaries/es.ts index 1acc39a5b..2e98c2fd4 100644 --- a/web/projects/shared/src/i18n/dictionaries/es.ts +++ b/web/projects/shared/src/i18n/dictionaries/es.ts @@ -678,7 +678,7 @@ export default { 741: 'En su registrador de dominios para', 742: 'cree este registro DNS', 743: 'En su puerta de enlace', - 744: 'cree esta regla de reenvío de puertos', + 744: 'cree estas reglas de reenvío de puertos', 745: 'Puerto externo', 747: 'Puerto interno', 749: 'Configuración del servidor DNS', diff --git a/web/projects/shared/src/i18n/dictionaries/fr.ts b/web/projects/shared/src/i18n/dictionaries/fr.ts index d3e2eeb21..6b5db3b09 100644 --- a/web/projects/shared/src/i18n/dictionaries/fr.ts +++ b/web/projects/shared/src/i18n/dictionaries/fr.ts @@ -678,7 +678,7 @@ export default { 741: 'Dans votre registraire de domaine pour', 742: 'créez cet enregistrement DNS', 743: 'Dans votre passerelle', - 744: 'créez cette règle de redirection de port', + 744: 'créez ces règles de redirection de port', 745: 'Port externe', 747: 'Port interne', 749: 'Configuration du serveur DNS', diff --git a/web/projects/shared/src/i18n/dictionaries/pl.ts b/web/projects/shared/src/i18n/dictionaries/pl.ts index b616b4f79..e5a43ef7f 100644 --- a/web/projects/shared/src/i18n/dictionaries/pl.ts +++ b/web/projects/shared/src/i18n/dictionaries/pl.ts @@ -678,7 +678,7 @@ export default { 741: 'W rejestratorze domeny dla', 742: 'utwórz ten rekord DNS', 743: 'W bramie', - 744: 'utwórz tę regułę przekierowania portów', + 744: 'utwórz te reguły przekierowania portów', 745: 'Port zewnętrzny', 747: 'Port wewnętrzny', 749: 'Konfiguracja serwera DNS', 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 }}

+ }