fix: scope public domain to single binding and return single port check

Accept internalPort in AddPublicDomainParams to target a specific
binding. Disable the domain on all other bindings. Return a single
CheckPortRes instead of Vec. Revert multi-port UI to singular port
display from 0f8a66b35.
This commit is contained in:
Aiden McClelland
2026-03-04 21:43:34 -07:00
parent d982ffa722
commit e077b5425b
13 changed files with 131 additions and 146 deletions

View File

@@ -161,6 +161,7 @@ pub fn address_api<C: Context, Kind: HostApiKind>()
} }
#[derive(Deserialize, Serialize, Parser, TS)] #[derive(Deserialize, Serialize, Parser, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export)] #[ts(export)]
pub struct AddPublicDomainParams { pub struct AddPublicDomainParams {
#[arg(help = "help.arg.fqdn")] #[arg(help = "help.arg.fqdn")]
@@ -169,6 +170,8 @@ pub struct AddPublicDomainParams {
pub acme: Option<AcmeProvider>, pub acme: Option<AcmeProvider>,
#[arg(help = "help.arg.gateway-id")] #[arg(help = "help.arg.gateway-id")]
pub gateway: GatewayId, pub gateway: GatewayId,
#[arg(help = "help.arg.internal-port")]
pub internal_port: u16,
} }
#[derive(Debug, Clone, Deserialize, Serialize, TS)] #[derive(Debug, Clone, Deserialize, Serialize, TS)]
@@ -177,7 +180,7 @@ pub struct AddPublicDomainParams {
pub struct AddPublicDomainRes { pub struct AddPublicDomainRes {
#[ts(type = "string | null")] #[ts(type = "string | null")]
pub dns: Option<Ipv4Addr>, pub dns: Option<Ipv4Addr>,
pub port: Vec<CheckPortRes>, pub port: CheckPortRes,
} }
pub async fn add_public_domain<Kind: HostApiKind>( pub async fn add_public_domain<Kind: HostApiKind>(
@@ -186,10 +189,11 @@ pub async fn add_public_domain<Kind: HostApiKind>(
fqdn, fqdn,
acme, acme,
gateway, gateway,
internal_port,
}: AddPublicDomainParams, }: AddPublicDomainParams,
inheritance: Kind::Inheritance, inheritance: Kind::Inheritance,
) -> Result<AddPublicDomainRes, Error> { ) -> Result<AddPublicDomainRes, Error> {
let ports = ctx let ext_port = ctx
.db .db
.mutate(|db| { .mutate(|db| {
if let Some(acme) = &acme { if let Some(acme) = &acme {
@@ -224,14 +228,46 @@ pub async fn add_public_domain<Kind: HostApiKind>(
let available_ports = db.as_private().as_available_ports().de()?; let available_ports = db.as_private().as_available_ports().de()?;
let host = Kind::host_for(&inheritance, db)?; let host = Kind::host_for(&inheritance, db)?;
host.update_addresses(&hostname, &gateways, &available_ports)?; host.update_addresses(&hostname, &gateways, &available_ports)?;
// Find the external port for the target binding
let bindings = host.as_bindings().de()?; let bindings = host.as_bindings().de()?;
let ports: BTreeSet<u16> = bindings let target_bind = bindings
.values() .get(&internal_port)
.flat_map(|b| &b.addresses.available) .ok_or_else(|| Error::new(eyre!("binding not found for internal port {internal_port}"), ErrorKind::NotFound))?;
.filter(|a| a.public && a.hostname == fqdn) let ext_port = target_bind
.filter_map(|a| a.port) .addresses
.collect(); .available
Ok(ports) .iter()
.find(|a| a.public && a.hostname == fqdn)
.and_then(|a| a.port)
.ok_or_else(|| Error::new(eyre!("no public address found for {fqdn} on port {internal_port}"), ErrorKind::NotFound))?;
// Disable the domain on all other bindings
host.as_bindings_mut().mutate(|b| {
for (&port, bind) in b.iter_mut() {
if port == internal_port {
continue;
}
let has_addr = bind
.addresses
.available
.iter()
.any(|a| a.public && a.hostname == fqdn);
if has_addr {
let other_ext = bind
.addresses
.available
.iter()
.find(|a| a.public && a.hostname == fqdn)
.and_then(|a| a.port)
.unwrap_or(ext_port);
bind.addresses.disabled.insert((fqdn.clone(), other_ext));
}
}
Ok(())
})?;
Ok(ext_port)
}) })
.await .await
.result?; .result?;
@@ -239,7 +275,7 @@ pub async fn add_public_domain<Kind: HostApiKind>(
let ctx2 = ctx.clone(); let ctx2 = ctx.clone();
let fqdn2 = fqdn.clone(); let fqdn2 = fqdn.clone();
let (dns_result, port_results) = tokio::join!( let (dns_result, port_result) = tokio::join!(
async { async {
tokio::task::spawn_blocking(move || { tokio::task::spawn_blocking(move || {
crate::net::dns::query_dns(ctx2, crate::net::dns::QueryDnsParams { fqdn: fqdn2 }) crate::net::dns::query_dns(ctx2, crate::net::dns::QueryDnsParams { fqdn: fqdn2 })
@@ -247,20 +283,18 @@ pub async fn add_public_domain<Kind: HostApiKind>(
.await .await
.with_kind(ErrorKind::Unknown)? .with_kind(ErrorKind::Unknown)?
}, },
futures::future::join_all(ports.into_iter().map(|port| { check_port(
check_port( ctx.clone(),
ctx.clone(), CheckPortParams {
CheckPortParams { port: ext_port,
port, gateway: gateway.clone(),
gateway: gateway.clone(), },
}, )
)
}))
); );
Ok(AddPublicDomainRes { Ok(AddPublicDomainRes {
dns: dns_result?, dns: dns_result?,
port: port_results.into_iter().collect::<Result<Vec<_>, _>>()?, port: port_result?,
}) })
} }

View File

@@ -6,4 +6,5 @@ export type AddPublicDomainParams = {
fqdn: string fqdn: string
acme: AcmeProvider | null acme: AcmeProvider | null
gateway: GatewayId gateway: GatewayId
internalPort: number
} }

View File

@@ -1,7 +1,4 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. // 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' import type { CheckPortRes } from './CheckPortRes'
export type AddPublicDomainRes = { export type AddPublicDomainRes = { dns: string | null; port: CheckPortRes }
dns: string | null
port: Array<CheckPortRes>
}

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 Portweiterleitungsregeln erstellen', 744: 'diese Portweiterleitungsregel 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 these port forwarding rules': 744, 'create this port forwarding rule': 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 estas reglas de reenvío de puertos', 744: 'cree esta regla 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 ces règles de redirection de port', 744: 'créez cette règle 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 te reguły przekierowania portów', 744: 'utwórz tę regułę 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

@@ -269,6 +269,7 @@ export class InterfaceAddressesComponent {
fqdn, fqdn,
gateway: gatewayId, gateway: gatewayId,
acme: !authority || authority === 'local' ? null : authority, acme: !authority || authority === 'local' ? null : authority,
internalPort: iface?.addressInfo.internalPort || 80,
} }
try { try {

View File

@@ -15,6 +15,7 @@ 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'
@@ -28,11 +29,8 @@ export type DnsGateway = T.NetworkInterfaceInfo & {
export type DomainValidationData = { export type DomainValidationData = {
fqdn: string fqdn: string
gateway: DnsGateway gateway: DnsGateway
ports: number[] port: number
initialResults?: { initialResults?: { dnsPass: boolean; portResult: T.CheckPortRes | null }
dnsPass: boolean
portResults: (T.CheckPortRes | null)[]
}
} }
@Component({ @Component({
@@ -94,50 +92,32 @@ 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 these port forwarding rules' | i18n }} {{ 'create this port forwarding rule' | i18n }}
</p> </p>
@let portRes = portResult();
<table [appTable]="[null, 'External Port', 'Internal Port', null]"> <table [appTable]="[null, 'External Port', 'Internal Port', null]">
@for (port of context.data.ports; track port; let i = $index) { <tr>
<tr> <td class="status">
<td class="status"> <port-check-icon [result]="portRes" [loading]="portLoading()" />
<port-check-icon </td>
[result]="portResults()[i]" <td>{{ context.data.port }}</td>
[loading]="!!portLoadings()[i]" <td>{{ context.data.port }}</td>
/> <td>
</td> <button
<td>{{ port }}</td> tuiButton
<td>{{ port }}</td> size="s"
<td> [loading]="portLoading()"
<button (click)="testPort()"
tuiButton >
size="s" {{ 'Test' | i18n }}
[loading]="!!portLoadings()[i]" </button>
(click)="testPort(i)" </td>
> </tr>
{{ 'Test' | i18n }}
</button>
</td>
</tr>
}
</table> </table>
@if (anyNotRunning()) { <port-check-warnings [result]="portRes" />
<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">
@@ -236,6 +216,7 @@ export type DomainValidationData = {
TuiIcon, TuiIcon,
TuiLoader, TuiLoader,
PortCheckIconComponent, PortCheckIconComponent,
PortCheckWarningsComponent,
], ],
}) })
export class DomainValidationComponent { export class DomainValidationComponent {
@@ -251,28 +232,16 @@ 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 portLoadings = signal<boolean[]>( readonly portLoading = signal(false)
this.context.data.ports.map(() => false),
)
readonly dnsPass = signal<boolean | undefined>(undefined) readonly dnsPass = signal<boolean | undefined>(undefined)
readonly portResults = signal<(T.CheckPortRes | undefined)[]>( readonly portResult = signal<T.CheckPortRes | undefined>(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 results = this.portResults() const result = this.portResult()
return ( return (
this.dnsPass() === true && this.dnsPass() === true &&
results.length > 0 && !!result?.openInternally &&
results.every(r => !!r?.openInternally && !!r?.openExternally) !!result?.openExternally
) )
}) })
@@ -282,9 +251,7 @@ 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)
this.portResults.set( if (initial.portResult) this.portResult.set(initial.portResult)
initial.portResults.map(r => r ?? undefined),
)
} }
} }
@@ -304,32 +271,20 @@ export class DomainValidationComponent {
} }
} }
async testPort(index: number) { async testPort() {
this.portLoadings.update(l => { this.portLoading.set(true)
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.ports[index]!, port: this.context.data.port,
}) })
this.portResults.update(r => { this.portResult.set(result)
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.portLoadings.update(l => { this.portLoading.set(false)
const copy = [...l]
copy[index] = false
return copy
})
} }
} }
} }

View File

@@ -26,12 +26,12 @@ export class DomainHealthService {
if (!gateway) return if (!gateway) return
let dnsPass: boolean let dnsPass: boolean
let ports: number[] let port: number
let portResults: (T.CheckPortRes | null)[] let portResult: T.CheckPortRes | null
if (typeof portOrRes === 'number') { if (typeof portOrRes === 'number') {
ports = [portOrRes] port = portOrRes
const [dns, portResult] = await Promise.all([ const [dns, portRes] = await Promise.all([
this.api this.api
.queryDns({ fqdn }) .queryDns({ fqdn })
.then(ip => ip === gateway.ipInfo.wanIp) .then(ip => ip === gateway.ipInfo.wanIp)
@@ -41,23 +41,24 @@ export class DomainHealthService {
.catch((): null => null), .catch((): null => null),
]) ])
dnsPass = dns dnsPass = dns
portResults = [portResult] portResult = portRes
} else { } else {
dnsPass = portOrRes.dns === gateway.ipInfo.wanIp dnsPass = portOrRes.dns === gateway.ipInfo.wanIp
ports = portOrRes.port.map(r => r.port) port = portOrRes.port.port
portResults = portOrRes.port portResult = portOrRes.port
} }
const allPortsOk = portResults.every( const portOk =
r => !!r?.openInternally && !!r?.openExternally && !!r?.hairpinning, !!portResult?.openInternally &&
) !!portResult?.openExternally &&
!!portResult?.hairpinning
if (!dnsPass || !allPortsOk) { if (!dnsPass || !portOk) {
setTimeout( setTimeout(
() => () =>
this.openPublicDomainModal(fqdn, gateway, ports, { this.openPublicDomainModal(fqdn, gateway, port, {
dnsPass, dnsPass,
portResults, portResult,
}), }),
250, 250,
) )
@@ -99,7 +100,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)
} }
@@ -164,17 +165,17 @@ export class DomainHealthService {
private openPublicDomainModal( private openPublicDomainModal(
fqdn: string, fqdn: string,
gateway: DnsGateway, gateway: DnsGateway,
ports: number[], port: number,
initialResults?: { initialResults?: {
dnsPass: boolean dnsPass: boolean
portResults: (T.CheckPortRes | null)[] portResult: 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, ports, initialResults }, data: { fqdn, gateway, port, 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 these port forwarding rules' | i18n }} {{ 'create this port forwarding rule' | i18n }}
</p> </p>
@let portRes = portResult(); @let portRes = portResult();

View File

@@ -1467,15 +1467,13 @@ export class MockApiService extends ApiService {
return { return {
dns: null, dns: null,
port: [ port: {
{ ip: '0.0.0.0',
ip: '0.0.0.0', port: 443,
port: 443, openExternally: false,
openExternally: false, openInternally: false,
openInternally: false, hairpinning: false,
hairpinning: false, },
},
],
} }
} }
@@ -1575,15 +1573,13 @@ export class MockApiService extends ApiService {
return { return {
dns: null, dns: null,
port: [ port: {
{ ip: '0.0.0.0',
ip: '0.0.0.0', port: 443,
port: 443, openExternally: false,
openExternally: false, openInternally: false,
openInternally: false, hairpinning: false,
hairpinning: false, },
},
],
} }
} }