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)]
#[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<CheckPortRes, Error> {
@@ -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<bool, Error> {

View File

@@ -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<Ipv4Addr>,
pub port: Vec<CheckPortRes>,
}
pub async fn add_public_domain<Kind: HostApiKind>(
ctx: RpcContext,
AddPublicDomainParams {
@@ -178,8 +188,9 @@ pub async fn add_public_domain<Kind: HostApiKind>(
gateway,
}: AddPublicDomainParams,
inheritance: Kind::Inheritance,
) -> Result<Option<Ipv4Addr>, Error> {
ctx.db
) -> Result<AddPublicDomainRes, Error> {
let ports = ctx
.db
.mutate(|db| {
if let Some(acme) = &acme {
if !db
@@ -195,21 +206,62 @@ pub async fn add_public_domain<Kind: HostApiKind>(
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<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
.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::<Result<Vec<_>, _>>()?,
})
.await
.with_kind(ErrorKind::Unknown)?
}
#[derive(Deserialize, Serialize, Parser, TS)]
@@ -257,13 +309,13 @@ pub async fn add_private_domain<Kind: HostApiKind>(
ctx: RpcContext,
AddPrivateDomainParams { fqdn, gateway }: AddPrivateDomainParams,
inheritance: Kind::Inheritance,
) -> Result<(), Error> {
) -> Result<bool, Error> {
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<Kind: HostApiKind>(
.await
.result?;
Ok(())
check_dns(ctx, CheckDnsParams { gateway }).await
}
pub async fn remove_private_domain<Kind: HostApiKind>(

View File

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

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 { 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<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 { 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'

View File

@@ -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 FilterKinds> = K extends 'mdns'
?
| (HostnameInfo & { metadata: { kind: 'ipv6' } })
| KindFilter<Exclude<K, 'ipv6'>>
: K extends 'ip'
? KindFilter<Exclude<K, 'ip'> | 'ipv4' | 'ipv6'>
: never
: K extends 'plugin'
?
| (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 {
visibility: infer V extends 'public' | 'private'
@@ -107,20 +138,62 @@ type FormatReturnTy<
? UrlString | FormatReturnTy<F, Exclude<Format, 'urlstring'>>
: 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 = {}> = {
/** 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 extends Formats = 'urlstring'>(
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: <NewFilter extends Filter>(
filter: NewFilter,
) => 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>
/** Shorthand filter that keeps only publicly-reachable hostnames (those with `public: true`). */
public: Filled<typeof publicFilter & Filter>
}
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: <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> {
return getNonLocal()
},

View File

@@ -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',

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
'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,

View File

@@ -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',

View File

@@ -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',

View File

@@ -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',

View File

@@ -31,6 +31,7 @@ export interface FormContext<T> {
buttons: ActionButton<T>[]
value?: T
operations?: Operation[]
note?: string
}
@Component({
@@ -43,6 +44,9 @@ export interface FormContext<T> {
(tuiValueChanges)="markAsDirty()"
>
<form-group [spec]="spec" />
@if (note) {
<p class="note">{{ note }}</p>
}
<footer>
<ng-content />
@for (button of buttons; track $index) {
@@ -70,6 +74,12 @@ export interface FormContext<T> {
</form>
`,
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<T extends Record<string, any>> 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({})

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, {
label: 'Add public domain',
size: 's',
data: {
spec: await configBuilderToSpec(addSpec),
note,
buttons: [
{
text: this.i18n.transform('Save')!,
@@ -207,18 +228,22 @@ export class InterfaceAddressesComponent {
const loader = this.loader.open('Saving').subscribe()
try {
let configured: boolean
if (this.packageId()) {
await this.api.pkgAddPrivateDomain({
configured = await this.api.pkgAddPrivateDomain({
fqdn,
gateway: gatewayId,
package: this.packageId(),
host: iface?.addressInfo.hostId || '',
})
} 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
} catch (e: any) {
@@ -244,23 +269,18 @@ export class InterfaceAddressesComponent {
}
try {
let res
if (this.packageId()) {
await this.api.pkgAddPublicDomain({
res = await this.api.pkgAddPublicDomain({
...params,
package: this.packageId(),
host: iface?.addressInfo.hostId || '',
})
} else {
await this.api.osUiAddPublicDomain(params)
res = await this.api.osUiAddPublicDomain(params)
}
const port = this.gatewayGroup().addresses.find(
a => a.access === 'public' && a.hostnameInfo.port !== null,
)?.hostnameInfo.port
if (port !== undefined && port !== null) {
await this.domainHealth.checkPublicDomain(fqdn, gatewayId, port)
}
await this.domainHealth.checkPublicDomain(fqdn, gatewayId, res)
return true
} catch (e: any) {

View File

@@ -15,7 +15,6 @@ import {
} from '@taiga-ui/kit'
import { injectContext, PolymorpheusComponent } from '@taiga-ui/polymorpheus'
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 { ApiService } from 'src/app/services/api/embassy-api.service'
import { T } from '@start9labs/start-sdk'
@@ -29,8 +28,11 @@ export type DnsGateway = T.NetworkInterfaceInfo & {
export type DomainValidationData = {
fqdn: string
gateway: DnsGateway
port: number
initialResults?: { dnsPass: boolean; portResult: T.CheckPortRes | null }
ports: number[]
initialResults?: {
dnsPass: boolean
portResults: (T.CheckPortRes | null)[]
}
}
@Component({
@@ -92,32 +94,50 @@ export type DomainValidationData = {
<h2>{{ 'Port Forwarding' | i18n }}</h2>
<p>
{{ 'In your gateway' | i18n }} "{{ gatewayName }}",
{{ 'create this port forwarding rule' | i18n }}
{{ 'create these port forwarding rules' | i18n }}
</p>
@let portRes = portResult();
<table [appTable]="[null, 'External Port', 'Internal Port', null]">
<tr>
<td class="status">
<port-check-icon [result]="portRes" [loading]="portLoading()" />
</td>
<td>{{ context.data.port }}</td>
<td>{{ context.data.port }}</td>
<td>
<button
tuiButton
size="s"
[loading]="portLoading()"
(click)="testPort()"
>
{{ 'Test' | i18n }}
</button>
</td>
</tr>
@for (port of context.data.ports; track port; let i = $index) {
<tr>
<td class="status">
<port-check-icon
[result]="portResults()[i]"
[loading]="!!portLoadings()[i]"
/>
</td>
<td>{{ port }}</td>
<td>{{ port }}</td>
<td>
<button
tuiButton
size="s"
[loading]="!!portLoadings()[i]"
(click)="testPort(i)"
>
{{ 'Test' | i18n }}
</button>
</td>
</tr>
}
</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) {
<footer class="g-buttons padding-top">
@@ -216,7 +236,6 @@ export type DomainValidationData = {
TuiIcon,
TuiLoader,
PortCheckIconComponent,
PortCheckWarningsComponent,
],
})
export class DomainValidationComponent {
@@ -232,16 +251,28 @@ export class DomainValidationComponent {
parse(this.context.data.fqdn).domain || this.context.data.fqdn
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 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(() => {
const result = this.portResult()
const results = this.portResults()
return (
this.dnsPass() === true &&
!!result?.openInternally &&
!!result?.openExternally
results.length > 0 &&
results.every(r => !!r?.openInternally && !!r?.openExternally)
)
})
@@ -251,7 +282,9 @@ export class DomainValidationComponent {
const initial = this.context.data.initialResults
if (initial) {
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() {
this.portLoading.set(true)
async testPort(index: number) {
this.portLoadings.update(l => {
const copy = [...l]
copy[index] = true
return copy
})
try {
const result = await this.api.checkPort({
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) {
this.errorService.handleError(e)
} 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(
fqdn: string,
gatewayId: string,
port: number,
portOrRes: number | T.AddPublicDomainRes,
): Promise<void> {
try {
const gateway = await this.getGatewayData(gatewayId)
if (!gateway) return
const [dnsPass, portResult] = await Promise.all([
this.api
.queryDns({ fqdn })
.then(ip => ip === gateway.ipInfo.wanIp)
.catch(() => false),
this.api
.checkPort({ gateway: gatewayId, port })
.catch((): null => null),
])
let dnsPass: boolean
let ports: number[]
let portResults: (T.CheckPortRes | null)[]
const portOk =
!!portResult?.openInternally &&
!!portResult?.openExternally &&
!!portResult?.hairpinning
if (typeof portOrRes === 'number') {
ports = [portOrRes]
const [dns, portResult] = await Promise.all([
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(
() =>
this.openPublicDomainModal(fqdn, gateway, port, {
this.openPublicDomainModal(fqdn, gateway, ports, {
dnsPass,
portResult,
portResults,
}),
250,
)
@@ -55,14 +67,17 @@ export class DomainHealthService {
}
}
async checkPrivateDomain(gatewayId: string): Promise<void> {
async checkPrivateDomain(
gatewayId: string,
prefetchedConfigured?: boolean,
): Promise<void> {
try {
const gateway = await this.getGatewayData(gatewayId)
if (!gateway) return
const configured = await this.api
.checkDns({ gateway: gatewayId })
.catch(() => false)
const configured =
prefetchedConfigured ??
(await this.api.checkDns({ gateway: gatewayId }).catch(() => false))
if (!configured) {
setTimeout(
@@ -84,7 +99,7 @@ export class DomainHealthService {
const gateway = await this.getGatewayData(gatewayId)
if (!gateway) return
this.openPublicDomainModal(fqdn, gateway, port)
this.openPublicDomainModal(fqdn, gateway, [port])
} catch (e: any) {
this.errorService.handleError(e)
}
@@ -149,14 +164,17 @@ export class DomainHealthService {
private openPublicDomainModal(
fqdn: string,
gateway: DnsGateway,
port: number,
initialResults?: { dnsPass: boolean; portResult: T.CheckPortRes | null },
ports: number[],
initialResults?: {
dnsPass: boolean
portResults: (T.CheckPortRes | null)[]
},
) {
this.dialog
.openComponent(DOMAIN_VALIDATION, {
label: 'Address Requirements',
size: 'm',
data: { fqdn, gateway, port, initialResults },
data: { fqdn, gateway, ports, initialResults },
})
.subscribe()
}

View File

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

View File

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

View File

@@ -630,7 +630,7 @@ export class LiveApiService extends ApiService {
async osUiAddPublicDomain(
params: T.AddPublicDomainParams,
): Promise<string | null> {
): Promise<T.AddPublicDomainRes> {
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<null> {
async osUiAddPrivateDomain(
params: T.AddPrivateDomainParams,
): Promise<boolean> {
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<string | null> {
): Promise<T.AddPublicDomainRes> {
return this.rpcRequest({
method: 'package.host.address.domain.public.add',
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({
method: 'package.host.address.domain.private.add',
params,

View File

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

View File

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