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

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