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
This commit is contained in:
Aiden McClelland
2026-03-04 17:30:00 -07:00
parent 0f8a66b357
commit 4005365239
22 changed files with 423 additions and 121 deletions

View File

@@ -174,11 +174,11 @@ async fn set_name(
#[derive(Debug, Clone, Deserialize, Serialize, Parser, TS)] #[derive(Debug, Clone, Deserialize, Serialize, Parser, TS)]
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
#[ts(export)] #[ts(export)]
struct CheckPortParams { pub struct CheckPortParams {
#[arg(help = "help.arg.port")] #[arg(help = "help.arg.port")]
port: u16, pub port: u16,
#[arg(help = "help.arg.gateway-id")] #[arg(help = "help.arg.gateway-id")]
gateway: GatewayId, pub gateway: GatewayId,
} }
#[derive(Debug, Clone, Deserialize, Serialize, TS)] #[derive(Debug, Clone, Deserialize, Serialize, TS)]
@@ -200,7 +200,7 @@ pub struct IfconfigPortRes {
pub reachable: bool, pub reachable: bool,
} }
async fn check_port( pub async fn check_port(
ctx: RpcContext, ctx: RpcContext,
CheckPortParams { port, gateway }: CheckPortParams, CheckPortParams { port, gateway }: CheckPortParams,
) -> Result<CheckPortRes, Error> { ) -> Result<CheckPortRes, Error> {
@@ -276,12 +276,12 @@ async fn check_port(
#[derive(Debug, Clone, Deserialize, Serialize, Parser, TS)] #[derive(Debug, Clone, Deserialize, Serialize, Parser, TS)]
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
#[ts(export)] #[ts(export)]
struct CheckDnsParams { pub struct CheckDnsParams {
#[arg(help = "help.arg.gateway-id")] #[arg(help = "help.arg.gateway-id")]
gateway: GatewayId, pub gateway: GatewayId,
} }
async fn check_dns( pub async fn check_dns(
ctx: RpcContext, ctx: RpcContext,
CheckDnsParams { gateway }: CheckDnsParams, CheckDnsParams { gateway }: CheckDnsParams,
) -> Result<bool, Error> { ) -> Result<bool, Error> {

View File

@@ -12,6 +12,7 @@ use crate::context::{CliContext, RpcContext};
use crate::db::model::DatabaseModel; use crate::db::model::DatabaseModel;
use crate::hostname::ServerHostname; use crate::hostname::ServerHostname;
use crate::net::acme::AcmeProvider; 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::net::host::{HostApiKind, all_hosts};
use crate::prelude::*; use crate::prelude::*;
use crate::util::serde::{HandlerExtSerde, display_serializable}; use crate::util::serde::{HandlerExtSerde, display_serializable};
@@ -170,6 +171,15 @@ pub struct AddPublicDomainParams {
pub gateway: GatewayId, 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<Ipv4Addr>,
pub port: Vec<CheckPortRes>,
}
pub async fn add_public_domain<Kind: HostApiKind>( pub async fn add_public_domain<Kind: HostApiKind>(
ctx: RpcContext, ctx: RpcContext,
AddPublicDomainParams { AddPublicDomainParams {
@@ -178,8 +188,9 @@ pub async fn add_public_domain<Kind: HostApiKind>(
gateway, gateway,
}: AddPublicDomainParams, }: AddPublicDomainParams,
inheritance: Kind::Inheritance, inheritance: Kind::Inheritance,
) -> Result<Option<Ipv4Addr>, Error> { ) -> Result<AddPublicDomainRes, Error> {
ctx.db let ports = ctx
.db
.mutate(|db| { .mutate(|db| {
if let Some(acme) = &acme { if let Some(acme) = &acme {
if !db if !db
@@ -195,21 +206,62 @@ pub async fn add_public_domain<Kind: HostApiKind>(
Kind::host_for(&inheritance, db)? Kind::host_for(&inheritance, db)?
.as_public_domains_mut() .as_public_domains_mut()
.insert(&fqdn, &PublicDomainConfig { acme, gateway })?; .insert(
&fqdn,
&PublicDomainConfig {
acme,
gateway: gateway.clone(),
},
)?;
handle_duplicates(db)?; handle_duplicates(db)?;
let hostname = ServerHostname::load(db.as_public().as_server_info())?; let hostname = ServerHostname::load(db.as_public().as_server_info())?;
let gateways = db.as_public().as_server_info().as_network().as_gateways().de()?; let gateways = db
let ports = db.as_private().as_available_ports().de()?; .as_public()
Kind::host_for(&inheritance, db)?.update_addresses(&hostname, &gateways, &ports) .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<u16> = bindings
.values()
.flat_map(|b| &b.addresses.available)
.filter(|a| a.public && a.hostname == fqdn)
.filter_map(|a| a.port)
.collect();
Ok(ports)
}) })
.await .await
.result?; .result?;
tokio::task::spawn_blocking(|| { let ctx2 = ctx.clone();
crate::net::dns::query_dns(ctx, crate::net::dns::QueryDnsParams { fqdn }) 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::<Result<Vec<_>, _>>()?,
}) })
.await
.with_kind(ErrorKind::Unknown)?
} }
#[derive(Deserialize, Serialize, Parser, TS)] #[derive(Deserialize, Serialize, Parser, TS)]
@@ -257,13 +309,13 @@ pub async fn add_private_domain<Kind: HostApiKind>(
ctx: RpcContext, ctx: RpcContext,
AddPrivateDomainParams { fqdn, gateway }: AddPrivateDomainParams, AddPrivateDomainParams { fqdn, gateway }: AddPrivateDomainParams,
inheritance: Kind::Inheritance, inheritance: Kind::Inheritance,
) -> Result<(), Error> { ) -> Result<bool, Error> {
ctx.db ctx.db
.mutate(|db| { .mutate(|db| {
Kind::host_for(&inheritance, db)? Kind::host_for(&inheritance, db)?
.as_private_domains_mut() .as_private_domains_mut()
.upsert(&fqdn, || Ok(BTreeSet::new()))? .upsert(&fqdn, || Ok(BTreeSet::new()))?
.mutate(|d| Ok(d.insert(gateway)))?; .mutate(|d| Ok(d.insert(gateway.clone())))?;
handle_duplicates(db)?; handle_duplicates(db)?;
let hostname = ServerHostname::load(db.as_public().as_server_info())?; let hostname = ServerHostname::load(db.as_public().as_server_info())?;
let gateways = db let gateways = db
@@ -278,7 +330,7 @@ pub async fn add_private_domain<Kind: HostApiKind>(
.await .await
.result?; .result?;
Ok(()) check_dns(ctx, CheckDnsParams { gateway }).await
} }
pub async fn remove_private_domain<Kind: HostApiKind>( pub async fn remove_private_domain<Kind: HostApiKind>(

View File

@@ -14,28 +14,34 @@ export const knownProtocols = {
defaultPort: 80, defaultPort: 80,
withSsl: 'https', withSsl: 'https',
alpn: { specified: ['http/1.1'] } as AlpnInfo, alpn: { specified: ['http/1.1'] } as AlpnInfo,
addXForwardedHeaders: true,
}, },
https: { https: {
secure: { ssl: true }, secure: { ssl: true },
defaultPort: 443, defaultPort: 443,
addXForwardedHeaders: true,
}, },
ws: { ws: {
secure: null, secure: null,
defaultPort: 80, defaultPort: 80,
withSsl: 'wss', withSsl: 'wss',
alpn: { specified: ['http/1.1'] } as AlpnInfo, alpn: { specified: ['http/1.1'] } as AlpnInfo,
addXForwardedHeaders: true,
}, },
wss: { wss: {
secure: { ssl: true }, secure: { ssl: true },
defaultPort: 443, defaultPort: 443,
addXForwardedHeaders: true,
}, },
ssh: { ssh: {
secure: { ssl: false }, secure: { ssl: false },
defaultPort: 22, defaultPort: 22,
addXForwardedHeaders: false,
}, },
dns: { dns: {
secure: { ssl: false }, secure: { ssl: false },
defaultPort: 53, defaultPort: 53,
addXForwardedHeaders: false,
}, },
} as const } as const
@@ -136,7 +142,7 @@ export class MultiHost {
const sslProto = this.getSslProto(options) const sslProto = this.getSslProto(options)
const addSsl = sslProto const addSsl = sslProto
? { ? {
addXForwardedHeaders: false, addXForwardedHeaders: knownProtocols[sslProto].addXForwardedHeaders,
preferredExternalPort: knownProtocols[sslProto].defaultPort, preferredExternalPort: knownProtocols[sslProto].defaultPort,
scheme: sslProto, scheme: sslProto,
alpn: 'alpn' in protoInfo ? protoInfo.alpn : null, alpn: 'alpn' in protoInfo ? protoInfo.alpn : null,
@@ -148,7 +154,7 @@ export class MultiHost {
preferredExternalPort: 443, preferredExternalPort: 443,
scheme: sslProto, scheme: sslProto,
alpn: null, alpn: null,
...('addSsl' in options ? options.addSsl : null), ...options.addSsl,
} }
: null : null

View File

@@ -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<CheckPortRes>
}

View File

@@ -5,6 +5,7 @@ import type { DnsSettings } from './DnsSettings'
import type { GatewayId } from './GatewayId' import type { GatewayId } from './GatewayId'
import type { Host } from './Host' import type { Host } from './Host'
import type { NetworkInterfaceInfo } from './NetworkInterfaceInfo' import type { NetworkInterfaceInfo } from './NetworkInterfaceInfo'
import type { PassthroughInfo } from './PassthroughInfo'
import type { WifiInfo } from './WifiInfo' import type { WifiInfo } from './WifiInfo'
export type NetworkInfo = { export type NetworkInfo = {
@@ -14,4 +15,5 @@ export type NetworkInfo = {
acme: { [key: AcmeProvider]: AcmeSettings } acme: { [key: AcmeProvider]: AcmeSettings }
dns: DnsSettings dns: DnsSettings
defaultOutbound: string | null defaultOutbound: string | null
passthroughs: Array<PassthroughInfo>
} }

View File

@@ -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[]
}

View File

@@ -19,6 +19,7 @@ export { AddPackageSignerParams } from './AddPackageSignerParams'
export { AddPackageToCategoryParams } from './AddPackageToCategoryParams' export { AddPackageToCategoryParams } from './AddPackageToCategoryParams'
export { AddPrivateDomainParams } from './AddPrivateDomainParams' export { AddPrivateDomainParams } from './AddPrivateDomainParams'
export { AddPublicDomainParams } from './AddPublicDomainParams' export { AddPublicDomainParams } from './AddPublicDomainParams'
export { AddPublicDomainRes } from './AddPublicDomainRes'
export { AddressInfo } from './AddressInfo' export { AddressInfo } from './AddressInfo'
export { AddSslOptions } from './AddSslOptions' export { AddSslOptions } from './AddSslOptions'
export { AddTunnelParams } from './AddTunnelParams' export { AddTunnelParams } from './AddTunnelParams'
@@ -201,6 +202,7 @@ export { PackagePlugin } from './PackagePlugin'
export { PackageState } from './PackageState' export { PackageState } from './PackageState'
export { PackageVersionInfo } from './PackageVersionInfo' export { PackageVersionInfo } from './PackageVersionInfo'
export { PartitionInfo } from './PartitionInfo' export { PartitionInfo } from './PartitionInfo'
export { PassthroughInfo } from './PassthroughInfo'
export { PasswordType } from './PasswordType' export { PasswordType } from './PasswordType'
export { PathOrUrl } from './PathOrUrl' export { PathOrUrl } from './PathOrUrl'
export { Pem } from './Pem' export { Pem } from './Pem'

View File

@@ -26,6 +26,18 @@ export const getHostname = (url: string): Hostname | null => {
return last 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 = type FilterKinds =
| 'mdns' | 'mdns'
| 'domain' | 'domain'
@@ -34,10 +46,25 @@ type FilterKinds =
| 'ipv6' | 'ipv6'
| 'localhost' | 'localhost'
| 'link-local' | '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 = { export type Filter = {
/** Keep only hostnames with the given visibility. `'public'` = externally reachable, `'private'` = LAN-only. */
visibility?: 'public' | 'private' 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[] kind?: FilterKinds | FilterKinds[]
/** Arbitrary predicate — hostnames for which this returns `false` are excluded. */
predicate?: (h: HostnameInfo) => boolean 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 exclude?: Filter
} }
@@ -65,9 +92,13 @@ type KindFilter<K extends FilterKinds> = K extends 'mdns'
? ?
| (HostnameInfo & { metadata: { kind: 'ipv6' } }) | (HostnameInfo & { metadata: { kind: 'ipv6' } })
| KindFilter<Exclude<K, 'ipv6'>> | KindFilter<Exclude<K, 'ipv6'>>
: K extends 'ip' : K extends 'plugin'
? KindFilter<Exclude<K, 'ip'> | 'ipv4' | 'ipv6'> ?
: never | (HostnameInfo & { metadata: { kind: 'plugin' } })
| KindFilter<Exclude<K, 'plugin'>>
: K extends 'ip'
? KindFilter<Exclude<K, 'ip'> | 'ipv4' | 'ipv6'>
: never
type FilterReturnTy<F extends Filter> = F extends { type FilterReturnTy<F extends Filter> = F extends {
visibility: infer V extends 'public' | 'private' visibility: infer V extends 'public' | 'private'
@@ -107,20 +138,62 @@ type FormatReturnTy<
? UrlString | FormatReturnTy<F, Exclude<Format, 'urlstring'>> ? UrlString | FormatReturnTy<F, Exclude<Format, 'urlstring'>>
: never : 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<F extends Filter = {}> = { export type Filled<F extends Filter = {}> = {
/** The hostnames that survived all applied filters. */
hostnames: HostnameInfo[] hostnames: HostnameInfo[]
/** Convert a single hostname into a fully-formed URL string, applying the address's scheme, username, and suffix. */
toUrl: (h: HostnameInfo) => UrlString 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 extends Formats = 'urlstring'>( format: <Format extends Formats = 'urlstring'>(
format?: Format, format?: Format,
) => FormatReturnTy<{}, 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: <NewFilter extends Filter>( filter: <NewFilter extends Filter>(
filter: NewFilter, filter: NewFilter,
) => Filled<NewFilter & Filter> ) => Filled<NewFilter & Filter>
/**
* Apply multiple filters and return hostnames that match **any** of them (union / OR).
*
* ```ts
* addresses.matchesAny([{ kind: 'domain' }, { kind: 'mdns' }])
* ```
*/
matchesAny: <NewFilters extends Filter[]>(
filters: [...NewFilters],
) => Filled<NewFilters[number] & F>
/** Shorthand filter that excludes `localhost` and IPv6 link-local addresses — keeps only network-reachable hostnames. */
nonLocal: Filled<typeof nonLocalFilter & Filter> nonLocal: Filled<typeof nonLocalFilter & Filter>
/** Shorthand filter that keeps only publicly-reachable hostnames (those with `public: true`). */
public: Filled<typeof publicFilter & Filter> public: Filled<typeof publicFilter & Filter>
} }
export type FilledAddressInfo = AddressInfo & Filled export type FilledAddressInfo = AddressInfo & Filled
@@ -210,7 +283,16 @@ function filterRec(
['localhost', '127.0.0.1', '::1'].includes(h.hostname)) || ['localhost', '127.0.0.1', '::1'].includes(h.hostname)) ||
(kind.has('link-local') && (kind.has('link-local') &&
h.metadata.kind === 'ipv6' && 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), filterRec(hostnames, filter, false),
) )
}, },
matchesAny: <NewFilters extends Filter[]>(filters: [...NewFilters]) => {
const seen = new Set<HostnameInfo>()
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<NewFilters[number] & F>(union)
},
get nonLocal(): Filled<typeof nonLocalFilter & F> { get nonLocal(): Filled<typeof nonLocalFilter & F> {
return getNonLocal() return getNonLocal()
}, },

View File

@@ -678,7 +678,7 @@ export default {
741: 'In Ihrem Domain-Registrar für', 741: 'In Ihrem Domain-Registrar für',
742: 'diesen DNS-Eintrag erstellen', 742: 'diesen DNS-Eintrag erstellen',
743: 'In Ihrem Gateway', 743: 'In Ihrem Gateway',
744: 'diese Portweiterleitungsregel erstellen', 744: 'diese Portweiterleitungsregeln erstellen',
745: 'Externer Port', 745: 'Externer Port',
747: 'Interner Port', 747: 'Interner Port',
749: 'DNS-Server-Konfiguration', 749: 'DNS-Server-Konfiguration',

View File

@@ -678,7 +678,7 @@ export const ENGLISH: Record<string, number> = {
'In your domain registrar for': 741, // partial sentence, followed by a domain name 'In your domain registrar for': 741, // partial sentence, followed by a domain name
'create this DNS record': 742, 'create this DNS record': 742,
'In your gateway': 743, // partial sentence, followed by a gateway name '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, 'External Port': 745,
'Internal Port': 747, 'Internal Port': 747,
'DNS Server Config': 749, 'DNS Server Config': 749,

View File

@@ -678,7 +678,7 @@ export default {
741: 'En su registrador de dominios para', 741: 'En su registrador de dominios para',
742: 'cree este registro DNS', 742: 'cree este registro DNS',
743: 'En su puerta de enlace', 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', 745: 'Puerto externo',
747: 'Puerto interno', 747: 'Puerto interno',
749: 'Configuración del servidor DNS', 749: 'Configuración del servidor DNS',

View File

@@ -678,7 +678,7 @@ export default {
741: 'Dans votre registraire de domaine pour', 741: 'Dans votre registraire de domaine pour',
742: 'créez cet enregistrement DNS', 742: 'créez cet enregistrement DNS',
743: 'Dans votre passerelle', 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', 745: 'Port externe',
747: 'Port interne', 747: 'Port interne',
749: 'Configuration du serveur DNS', 749: 'Configuration du serveur DNS',

View File

@@ -678,7 +678,7 @@ export default {
741: 'W rejestratorze domeny dla', 741: 'W rejestratorze domeny dla',
742: 'utwórz ten rekord DNS', 742: 'utwórz ten rekord DNS',
743: 'W bramie', 743: 'W bramie',
744: 'utwórz tę regułę przekierowania portów', 744: 'utwórz te reguły przekierowania portów',
745: 'Port zewnętrzny', 745: 'Port zewnętrzny',
747: 'Port wewnętrzny', 747: 'Port wewnętrzny',
749: 'Konfiguracja serwera DNS', 749: 'Konfiguracja serwera DNS',

View File

@@ -31,6 +31,7 @@ export interface FormContext<T> {
buttons: ActionButton<T>[] buttons: ActionButton<T>[]
value?: T value?: T
operations?: Operation[] operations?: Operation[]
note?: string
} }
@Component({ @Component({
@@ -43,6 +44,9 @@ export interface FormContext<T> {
(tuiValueChanges)="markAsDirty()" (tuiValueChanges)="markAsDirty()"
> >
<form-group [spec]="spec" /> <form-group [spec]="spec" />
@if (note) {
<p class="note">{{ note }}</p>
}
<footer> <footer>
<ng-content /> <ng-content />
@for (button of buttons; track $index) { @for (button of buttons; track $index) {
@@ -70,6 +74,12 @@ export interface FormContext<T> {
</form> </form>
`, `,
styles: ` styles: `
.note {
color: var(--tui-text-secondary);
font: var(--tui-font-text-s);
margin-top: 1rem;
}
footer { footer {
position: sticky; position: sticky;
bottom: 0; bottom: 0;
@@ -106,6 +116,7 @@ export class FormComponent<T extends Record<string, any>> implements OnInit {
@Input() buttons = this.context?.data.buttons || [] @Input() buttons = this.context?.data.buttons || []
@Input() operations = this.context?.data.operations || [] @Input() operations = this.context?.data.operations || []
@Input() value?: T = this.context?.data.value @Input() value?: T = this.context?.data.value
@Input() note = this.context?.data.note || ''
form = new FormGroup({}) form = new FormGroup({})

View File

@@ -185,11 +185,32 @@ export class InterfaceAddressesComponent {
: {}), : {}),
}) })
let note = ''
const pkgId = this.packageId()
if (pkgId) {
const pkg = await firstValueFrom(
this.patch.watch$('packageData', pkgId),
)
if (pkg) {
const hostId = iface.addressInfo.hostId
const otherNames = Object.values(pkg.serviceInterfaces)
.filter(
si =>
si.addressInfo.hostId === hostId && si.id !== iface.id,
)
.map(si => si.name)
if (otherNames.length) {
note = `This domain also applies to ${otherNames.join(', ')}`
}
}
}
this.formDialog.open(FormComponent, { this.formDialog.open(FormComponent, {
label: 'Add public domain', label: 'Add public domain',
size: 's', size: 's',
data: { data: {
spec: await configBuilderToSpec(addSpec), spec: await configBuilderToSpec(addSpec),
note,
buttons: [ buttons: [
{ {
text: this.i18n.transform('Save')!, text: this.i18n.transform('Save')!,
@@ -207,18 +228,22 @@ export class InterfaceAddressesComponent {
const loader = this.loader.open('Saving').subscribe() const loader = this.loader.open('Saving').subscribe()
try { try {
let configured: boolean
if (this.packageId()) { if (this.packageId()) {
await this.api.pkgAddPrivateDomain({ configured = await this.api.pkgAddPrivateDomain({
fqdn, fqdn,
gateway: gatewayId, gateway: gatewayId,
package: this.packageId(), package: this.packageId(),
host: iface?.addressInfo.hostId || '', host: iface?.addressInfo.hostId || '',
}) })
} else { } else {
await this.api.osUiAddPrivateDomain({ fqdn, gateway: gatewayId }) configured = await this.api.osUiAddPrivateDomain({
fqdn,
gateway: gatewayId,
})
} }
await this.domainHealth.checkPrivateDomain(gatewayId) await this.domainHealth.checkPrivateDomain(gatewayId, configured)
return true return true
} catch (e: any) { } catch (e: any) {
@@ -244,23 +269,18 @@ export class InterfaceAddressesComponent {
} }
try { try {
let res
if (this.packageId()) { if (this.packageId()) {
await this.api.pkgAddPublicDomain({ res = await this.api.pkgAddPublicDomain({
...params, ...params,
package: this.packageId(), package: this.packageId(),
host: iface?.addressInfo.hostId || '', host: iface?.addressInfo.hostId || '',
}) })
} else { } else {
await this.api.osUiAddPublicDomain(params) res = await this.api.osUiAddPublicDomain(params)
} }
const port = this.gatewayGroup().addresses.find( await this.domainHealth.checkPublicDomain(fqdn, gatewayId, res)
a => a.access === 'public' && a.hostnameInfo.port !== null,
)?.hostnameInfo.port
if (port !== undefined && port !== null) {
await this.domainHealth.checkPublicDomain(fqdn, gatewayId, port)
}
return true return true
} catch (e: any) { } catch (e: any) {

View File

@@ -15,7 +15,6 @@ import {
} from '@taiga-ui/kit' } from '@taiga-ui/kit'
import { injectContext, PolymorpheusComponent } from '@taiga-ui/polymorpheus' import { injectContext, PolymorpheusComponent } from '@taiga-ui/polymorpheus'
import { PortCheckIconComponent } from 'src/app/routes/portal/components/port-check-icon.component' import { PortCheckIconComponent } from 'src/app/routes/portal/components/port-check-icon.component'
import { PortCheckWarningsComponent } from 'src/app/routes/portal/components/port-check-warnings.component'
import { TableComponent } from 'src/app/routes/portal/components/table.component' import { TableComponent } from 'src/app/routes/portal/components/table.component'
import { ApiService } from 'src/app/services/api/embassy-api.service' import { ApiService } from 'src/app/services/api/embassy-api.service'
import { T } from '@start9labs/start-sdk' import { T } from '@start9labs/start-sdk'
@@ -29,8 +28,11 @@ export type DnsGateway = T.NetworkInterfaceInfo & {
export type DomainValidationData = { export type DomainValidationData = {
fqdn: string fqdn: string
gateway: DnsGateway gateway: DnsGateway
port: number ports: number[]
initialResults?: { dnsPass: boolean; portResult: T.CheckPortRes | null } initialResults?: {
dnsPass: boolean
portResults: (T.CheckPortRes | null)[]
}
} }
@Component({ @Component({
@@ -92,32 +94,50 @@ export type DomainValidationData = {
<h2>{{ 'Port Forwarding' | i18n }}</h2> <h2>{{ 'Port Forwarding' | i18n }}</h2>
<p> <p>
{{ 'In your gateway' | i18n }} "{{ gatewayName }}", {{ 'In your gateway' | i18n }} "{{ gatewayName }}",
{{ 'create this port forwarding rule' | i18n }} {{ 'create these port forwarding rules' | i18n }}
</p> </p>
@let portRes = portResult();
<table [appTable]="[null, 'External Port', 'Internal Port', null]"> <table [appTable]="[null, 'External Port', 'Internal Port', null]">
<tr> @for (port of context.data.ports; track port; let i = $index) {
<td class="status"> <tr>
<port-check-icon [result]="portRes" [loading]="portLoading()" /> <td class="status">
</td> <port-check-icon
<td>{{ context.data.port }}</td> [result]="portResults()[i]"
<td>{{ context.data.port }}</td> [loading]="!!portLoadings()[i]"
<td> />
<button </td>
tuiButton <td>{{ port }}</td>
size="s" <td>{{ port }}</td>
[loading]="portLoading()" <td>
(click)="testPort()" <button
> tuiButton
{{ 'Test' | i18n }} size="s"
</button> [loading]="!!portLoadings()[i]"
</td> (click)="testPort(i)"
</tr> >
{{ 'Test' | i18n }}
</button>
</td>
</tr>
}
</table> </table>
<port-check-warnings [result]="portRes" /> @if (anyNotRunning()) {
<p class="g-warning">
{{
'Port status cannot be determined while service is not running'
| i18n
}}
</p>
}
@if (anyNoHairpinning()) {
<p class="g-warning">
{{
'This address will not work from your local network due to a router hairpinning limitation'
| i18n
}}
</p>
}
@if (!isManualMode) { @if (!isManualMode) {
<footer class="g-buttons padding-top"> <footer class="g-buttons padding-top">
@@ -216,7 +236,6 @@ export type DomainValidationData = {
TuiIcon, TuiIcon,
TuiLoader, TuiLoader,
PortCheckIconComponent, PortCheckIconComponent,
PortCheckWarningsComponent,
], ],
}) })
export class DomainValidationComponent { export class DomainValidationComponent {
@@ -232,16 +251,28 @@ export class DomainValidationComponent {
parse(this.context.data.fqdn).domain || this.context.data.fqdn parse(this.context.data.fqdn).domain || this.context.data.fqdn
readonly dnsLoading = signal(false) readonly dnsLoading = signal(false)
readonly portLoading = signal(false) readonly portLoadings = signal<boolean[]>(
this.context.data.ports.map(() => false),
)
readonly dnsPass = signal<boolean | undefined>(undefined) readonly dnsPass = signal<boolean | undefined>(undefined)
readonly portResult = signal<T.CheckPortRes | undefined>(undefined) readonly portResults = signal<(T.CheckPortRes | undefined)[]>(
this.context.data.ports.map(() => undefined),
)
readonly anyNotRunning = computed(() =>
this.portResults().some(r => r && !r.openInternally),
)
readonly anyNoHairpinning = computed(() =>
this.portResults().some(r => r && r.openExternally && !r.hairpinning),
)
readonly allPass = computed(() => { readonly allPass = computed(() => {
const result = this.portResult() const results = this.portResults()
return ( return (
this.dnsPass() === true && this.dnsPass() === true &&
!!result?.openInternally && results.length > 0 &&
!!result?.openExternally results.every(r => !!r?.openInternally && !!r?.openExternally)
) )
}) })
@@ -251,7 +282,9 @@ export class DomainValidationComponent {
const initial = this.context.data.initialResults const initial = this.context.data.initialResults
if (initial) { if (initial) {
this.dnsPass.set(initial.dnsPass) this.dnsPass.set(initial.dnsPass)
if (initial.portResult) this.portResult.set(initial.portResult) this.portResults.set(
initial.portResults.map(r => r ?? undefined),
)
} }
} }
@@ -271,20 +304,32 @@ export class DomainValidationComponent {
} }
} }
async testPort() { async testPort(index: number) {
this.portLoading.set(true) this.portLoadings.update(l => {
const copy = [...l]
copy[index] = true
return copy
})
try { try {
const result = await this.api.checkPort({ const result = await this.api.checkPort({
gateway: this.context.data.gateway.id, gateway: this.context.data.gateway.id,
port: this.context.data.port, port: this.context.data.ports[index]!,
}) })
this.portResult.set(result) this.portResults.update(r => {
const copy = [...r]
copy[index] = result
return copy
})
} catch (e: any) { } catch (e: any) {
this.errorService.handleError(e) this.errorService.handleError(e)
} finally { } finally {
this.portLoading.set(false) this.portLoadings.update(l => {
const copy = [...l]
copy[index] = false
return copy
})
} }
} }
} }

View File

@@ -19,33 +19,45 @@ export class DomainHealthService {
async checkPublicDomain( async checkPublicDomain(
fqdn: string, fqdn: string,
gatewayId: string, gatewayId: string,
port: number, portOrRes: number | T.AddPublicDomainRes,
): Promise<void> { ): Promise<void> {
try { try {
const gateway = await this.getGatewayData(gatewayId) const gateway = await this.getGatewayData(gatewayId)
if (!gateway) return if (!gateway) return
const [dnsPass, portResult] = await Promise.all([ let dnsPass: boolean
this.api let ports: number[]
.queryDns({ fqdn }) let portResults: (T.CheckPortRes | null)[]
.then(ip => ip === gateway.ipInfo.wanIp)
.catch(() => false),
this.api
.checkPort({ gateway: gatewayId, port })
.catch((): null => null),
])
const portOk = if (typeof portOrRes === 'number') {
!!portResult?.openInternally && ports = [portOrRes]
!!portResult?.openExternally && const [dns, portResult] = await Promise.all([
!!portResult?.hairpinning this.api
.queryDns({ fqdn })
.then(ip => ip === gateway.ipInfo.wanIp)
.catch(() => false),
this.api
.checkPort({ gateway: gatewayId, port: portOrRes })
.catch((): null => null),
])
dnsPass = dns
portResults = [portResult]
} else {
dnsPass = portOrRes.dns === gateway.ipInfo.wanIp
ports = portOrRes.port.map(r => r.port)
portResults = portOrRes.port
}
if (!dnsPass || !portOk) { const allPortsOk = portResults.every(
r => !!r?.openInternally && !!r?.openExternally && !!r?.hairpinning,
)
if (!dnsPass || !allPortsOk) {
setTimeout( setTimeout(
() => () =>
this.openPublicDomainModal(fqdn, gateway, port, { this.openPublicDomainModal(fqdn, gateway, ports, {
dnsPass, dnsPass,
portResult, portResults,
}), }),
250, 250,
) )
@@ -55,14 +67,17 @@ export class DomainHealthService {
} }
} }
async checkPrivateDomain(gatewayId: string): Promise<void> { async checkPrivateDomain(
gatewayId: string,
prefetchedConfigured?: boolean,
): Promise<void> {
try { try {
const gateway = await this.getGatewayData(gatewayId) const gateway = await this.getGatewayData(gatewayId)
if (!gateway) return if (!gateway) return
const configured = await this.api const configured =
.checkDns({ gateway: gatewayId }) prefetchedConfigured ??
.catch(() => false) (await this.api.checkDns({ gateway: gatewayId }).catch(() => false))
if (!configured) { if (!configured) {
setTimeout( setTimeout(
@@ -84,7 +99,7 @@ export class DomainHealthService {
const gateway = await this.getGatewayData(gatewayId) const gateway = await this.getGatewayData(gatewayId)
if (!gateway) return if (!gateway) return
this.openPublicDomainModal(fqdn, gateway, port) this.openPublicDomainModal(fqdn, gateway, [port])
} catch (e: any) { } catch (e: any) {
this.errorService.handleError(e) this.errorService.handleError(e)
} }
@@ -149,14 +164,17 @@ export class DomainHealthService {
private openPublicDomainModal( private openPublicDomainModal(
fqdn: string, fqdn: string,
gateway: DnsGateway, gateway: DnsGateway,
port: number, ports: number[],
initialResults?: { dnsPass: boolean; portResult: T.CheckPortRes | null }, initialResults?: {
dnsPass: boolean
portResults: (T.CheckPortRes | null)[]
},
) { ) {
this.dialog this.dialog
.openComponent(DOMAIN_VALIDATION, { .openComponent(DOMAIN_VALIDATION, {
label: 'Address Requirements', label: 'Address Requirements',
size: 'm', size: 'm',
data: { fqdn, gateway, port, initialResults }, data: { fqdn, gateway, ports, initialResults },
}) })
.subscribe() .subscribe()
} }

View File

@@ -31,7 +31,7 @@ export type PortForwardValidationData = {
<h2>{{ 'Port Forwarding' | i18n }}</h2> <h2>{{ 'Port Forwarding' | i18n }}</h2>
<p> <p>
{{ 'In your gateway' | i18n }} "{{ gatewayName }}", {{ 'In your gateway' | i18n }} "{{ gatewayName }}",
{{ 'create this port forwarding rule' | i18n }} {{ 'create these port forwarding rules' | i18n }}
</p> </p>
@let portRes = portResult(); @let portRes = portResult();

View File

@@ -340,11 +340,13 @@ export abstract class ApiService {
abstract osUiAddPublicDomain( abstract osUiAddPublicDomain(
params: T.AddPublicDomainParams, params: T.AddPublicDomainParams,
): Promise<string | null> ): Promise<T.AddPublicDomainRes>
abstract osUiRemovePublicDomain(params: T.RemoveDomainParams): Promise<null> abstract osUiRemovePublicDomain(params: T.RemoveDomainParams): Promise<null>
abstract osUiAddPrivateDomain(params: T.AddPrivateDomainParams): Promise<null> abstract osUiAddPrivateDomain(
params: T.AddPrivateDomainParams,
): Promise<boolean>
abstract osUiRemovePrivateDomain(params: T.RemoveDomainParams): Promise<null> abstract osUiRemovePrivateDomain(params: T.RemoveDomainParams): Promise<null>
@@ -354,13 +356,15 @@ export abstract class ApiService {
abstract pkgAddPublicDomain( abstract pkgAddPublicDomain(
params: PkgAddPublicDomainReq, params: PkgAddPublicDomainReq,
): Promise<string | null> ): Promise<T.AddPublicDomainRes>
abstract pkgRemovePublicDomain( abstract pkgRemovePublicDomain(
params: PkgRemovePublicDomainReq, params: PkgRemovePublicDomainReq,
): Promise<null> ): Promise<null>
abstract pkgAddPrivateDomain(params: PkgAddPrivateDomainReq): Promise<null> abstract pkgAddPrivateDomain(
params: PkgAddPrivateDomainReq,
): Promise<boolean>
abstract pkgRemovePrivateDomain( abstract pkgRemovePrivateDomain(
params: PkgRemovePrivateDomainReq, params: PkgRemovePrivateDomainReq,

View File

@@ -630,7 +630,7 @@ export class LiveApiService extends ApiService {
async osUiAddPublicDomain( async osUiAddPublicDomain(
params: T.AddPublicDomainParams, params: T.AddPublicDomainParams,
): Promise<string | null> { ): Promise<T.AddPublicDomainRes> {
return this.rpcRequest({ return this.rpcRequest({
method: 'server.host.address.domain.public.add', method: 'server.host.address.domain.public.add',
params, params,
@@ -644,7 +644,9 @@ export class LiveApiService extends ApiService {
}) })
} }
async osUiAddPrivateDomain(params: T.AddPrivateDomainParams): Promise<null> { async osUiAddPrivateDomain(
params: T.AddPrivateDomainParams,
): Promise<boolean> {
return this.rpcRequest({ return this.rpcRequest({
method: 'server.host.address.domain.private.add', method: 'server.host.address.domain.private.add',
params, params,
@@ -669,7 +671,7 @@ export class LiveApiService extends ApiService {
async pkgAddPublicDomain( async pkgAddPublicDomain(
params: PkgAddPublicDomainReq, params: PkgAddPublicDomainReq,
): Promise<string | null> { ): Promise<T.AddPublicDomainRes> {
return this.rpcRequest({ return this.rpcRequest({
method: 'package.host.address.domain.public.add', method: 'package.host.address.domain.public.add',
params, params,
@@ -683,7 +685,9 @@ export class LiveApiService extends ApiService {
}) })
} }
async pkgAddPrivateDomain(params: PkgAddPrivateDomainReq): Promise<null> { async pkgAddPrivateDomain(
params: PkgAddPrivateDomainReq,
): Promise<boolean> {
return this.rpcRequest({ return this.rpcRequest({
method: 'package.host.address.domain.private.add', method: 'package.host.address.domain.private.add',
params, params,

View File

@@ -1440,7 +1440,7 @@ export class MockApiService extends ApiService {
async osUiAddPublicDomain( async osUiAddPublicDomain(
params: T.AddPublicDomainParams, params: T.AddPublicDomainParams,
): Promise<string | null> { ): Promise<T.AddPublicDomainRes> {
await pauseFor(2000) await pauseFor(2000)
const patch: Operation<any>[] = [ const patch: Operation<any>[] = [
@@ -1465,7 +1465,18 @@ export class MockApiService extends ApiService {
] ]
this.mockRevision(patch) 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<null> { async osUiRemovePublicDomain(params: T.RemoveDomainParams): Promise<null> {
@@ -1482,7 +1493,9 @@ export class MockApiService extends ApiService {
return null return null
} }
async osUiAddPrivateDomain(params: T.AddPrivateDomainParams): Promise<null> { async osUiAddPrivateDomain(
params: T.AddPrivateDomainParams,
): Promise<boolean> {
await pauseFor(2000) await pauseFor(2000)
const patch: Operation<any>[] = [ const patch: Operation<any>[] = [
@@ -1505,7 +1518,7 @@ export class MockApiService extends ApiService {
] ]
this.mockRevision(patch) this.mockRevision(patch)
return null return false
} }
async osUiRemovePrivateDomain(params: T.RemoveDomainParams): Promise<null> { async osUiRemovePrivateDomain(params: T.RemoveDomainParams): Promise<null> {
@@ -1535,7 +1548,7 @@ export class MockApiService extends ApiService {
async pkgAddPublicDomain( async pkgAddPublicDomain(
params: PkgAddPublicDomainReq, params: PkgAddPublicDomainReq,
): Promise<string | null> { ): Promise<T.AddPublicDomainRes> {
await pauseFor(2000) await pauseFor(2000)
const patch: Operation<any>[] = [ const patch: Operation<any>[] = [
@@ -1560,7 +1573,18 @@ export class MockApiService extends ApiService {
] ]
this.mockRevision(patch) 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<null> { async pkgRemovePublicDomain(params: PkgRemovePublicDomainReq): Promise<null> {
@@ -1577,7 +1601,9 @@ export class MockApiService extends ApiService {
return null return null
} }
async pkgAddPrivateDomain(params: PkgAddPrivateDomainReq): Promise<null> { async pkgAddPrivateDomain(
params: PkgAddPrivateDomainReq,
): Promise<boolean> {
await pauseFor(2000) await pauseFor(2000)
const patch: Operation<any>[] = [ const patch: Operation<any>[] = [
@@ -1600,7 +1626,7 @@ export class MockApiService extends ApiService {
] ]
this.mockRevision(patch) this.mockRevision(patch)
return null return false
} }
async pkgRemovePrivateDomain( async pkgRemovePrivateDomain(

View File

@@ -212,6 +212,7 @@ export const mockPatchData: DataModel = {
}, },
}, },
}, },
passthroughs: [],
defaultOutbound: 'eth0', defaultOutbound: 'eth0',
dns: { dns: {
dhcpServers: ['1.1.1.1', '8.8.8.8'], dhcpServers: ['1.1.1.1', '8.8.8.8'],