From 485fced691373a562d71dcdf075755e0e37d3de6 Mon Sep 17 00:00:00 2001 From: Matt Hill Date: Tue, 17 Feb 2026 23:31:47 -0700 Subject: [PATCH] round out dns check, dns server check, port forward check, and gateway port forwards --- web/ARCHITECTURE.md | 9 + .../src/components/menu/menu.component.html | 4 +- .../app/components/documentation.component.ts | 2 +- .../shared/src/i18n/dictionaries/de.ts | 10 +- .../shared/src/i18n/dictionaries/en.ts | 10 +- .../shared/src/i18n/dictionaries/es.ts | 10 +- .../shared/src/i18n/dictionaries/fr.ts | 10 +- .../shared/src/i18n/dictionaries/pl.ts | 10 +- .../shared/src/services/error.service.ts | 1 - .../login/ca-wizard/ca-wizard.component.html | 2 +- .../components/header/menu.component.ts | 7 +- .../interfaces/addresses/actions.component.ts | 87 ++++++ .../addresses/addresses.component.ts | 83 +----- .../dns.component.ts | 96 ++++--- .../addresses/domain-health.service.ts | 173 +++++++++++ .../interfaces/addresses/item.component.ts | 41 ++- .../addresses/port-forward.component.ts | 178 ++++++++++++ .../addresses/private-dns.component.ts | 180 ++++++++++++ .../interfaces/interface.service.ts | 5 +- .../routes/backups/modals/jobs.component.ts | 4 +- .../backups/modals/targets.component.ts | 4 +- .../portal/routes/metrics/time.component.ts | 2 +- .../authorities/authorities.component.ts | 2 +- .../routes/backups/backups.component.ts | 6 +- .../routes/system/routes/dns/dns.component.ts | 6 +- .../system/routes/email/email.component.ts | 2 +- .../routes/gateways/gateways.component.ts | 2 +- .../system/routes/gateways/item.component.ts | 110 +++---- .../gateways/port-forwards.component.ts | 268 ++++++++++++++++++ .../system/routes/gateways/table.component.ts | 13 +- .../routes/system/routes/ssh/ssh.component.ts | 2 +- .../system/routes/wifi/wifi.component.ts | 2 +- .../ui/src/app/services/api/api.types.ts | 4 + .../app/services/api/embassy-api.service.ts | 11 +- .../services/api/embassy-live-api.service.ts | 8 + .../services/api/embassy-mock-api.service.ts | 15 +- .../ui/src/app/services/api/mock-patch.ts | 101 ++++--- 37 files changed, 1228 insertions(+), 252 deletions(-) rename web/projects/ui/src/app/routes/portal/components/interfaces/{public-domains => addresses}/dns.component.ts (75%) create mode 100644 web/projects/ui/src/app/routes/portal/components/interfaces/addresses/domain-health.service.ts create mode 100644 web/projects/ui/src/app/routes/portal/components/interfaces/addresses/port-forward.component.ts create mode 100644 web/projects/ui/src/app/routes/portal/components/interfaces/addresses/private-dns.component.ts create mode 100644 web/projects/ui/src/app/routes/portal/routes/system/routes/gateways/port-forwards.component.ts diff --git a/web/ARCHITECTURE.md b/web/ARCHITECTURE.md index c8cc8becb..33d92abcf 100644 --- a/web/ARCHITECTURE.md +++ b/web/ARCHITECTURE.md @@ -78,6 +78,15 @@ Form controls live in `ui/src/app/routes/portal/components/form/controls/` — e - **Dictionaries** live in `shared/src/i18n/dictionaries/` (en, es, de, fr, pl). - Usage in templates: `{{ 'Some English Text' | i18n }}` +### How dictionaries work + +- **`en.ts`** is the source of truth. Keys are English strings; values are numeric IDs (e.g. `'Domain Health': 748`). +- **Other language files** (`de.ts`, `es.ts`, `fr.ts`, `pl.ts`) use those same numeric IDs as keys, mapping to translated strings (e.g. `748: 'Santé du domaine'`). +- When adding a new i18n key: + 1. Add the English string and next available numeric ID to `en.ts`. + 2. Add the same numeric ID with a proper translation to every other language file. + 3. Always provide real translations, not empty strings. + ## Services & State Services often extend `Observable` and expose reactive streams via DI: diff --git a/web/projects/marketplace/src/components/menu/menu.component.html b/web/projects/marketplace/src/components/menu/menu.component.html index f934c55c6..b1e2174e1 100644 --- a/web/projects/marketplace/src/components/menu/menu.component.html +++ b/web/projects/marketplace/src/components/menu/menu.component.html @@ -62,7 +62,7 @@
- + {{ 'Package a service' | i18n }} @@ -86,7 +86,7 @@
- + {{ 'Package a service' | i18n }} diff --git a/web/projects/setup-wizard/src/app/components/documentation.component.ts b/web/projects/setup-wizard/src/app/components/documentation.component.ts index b832ffa5d..07d1b4a12 100644 --- a/web/projects/setup-wizard/src/app/components/documentation.component.ts +++ b/web/projects/setup-wizard/src/app/components/documentation.component.ts @@ -46,7 +46,7 @@ import { DocsLinkDirective } from '@start9labs/shared' Download your server's Root CA and follow instructions diff --git a/web/projects/shared/src/i18n/dictionaries/de.ts b/web/projects/shared/src/i18n/dictionaries/de.ts index fee286790..5c13161db 100644 --- a/web/projects/shared/src/i18n/dictionaries/de.ts +++ b/web/projects/shared/src/i18n/dictionaries/de.ts @@ -683,6 +683,14 @@ export default { 743: 'In Ihrem Gateway', 744: 'diese Portweiterleitungsregel erstellen', 745: 'Externer Port', - 746: 'Interne IP', 747: 'Interner Port', + 749: 'DNS-Server-Konfiguration', + 750: 'muss konfiguriert werden, um', + 751: 'die LAN-IP-Adresse dieses Servers', + 752: 'als DNS-Server zu verwenden', + 753: 'DNS-Server', + 754: 'Portweiterleitungen anzeigen', + 755: 'Schnittstelle(n)', + 756: 'Keine Portweiterleitungsregeln', + 757: 'Portweiterleitungsregeln am Gateway erforderlich', } satisfies i18n diff --git a/web/projects/shared/src/i18n/dictionaries/en.ts b/web/projects/shared/src/i18n/dictionaries/en.ts index 39fde61db..5616731b2 100644 --- a/web/projects/shared/src/i18n/dictionaries/en.ts +++ b/web/projects/shared/src/i18n/dictionaries/en.ts @@ -683,6 +683,14 @@ export const ENGLISH: Record = { 'In your gateway': 743, // partial sentence, followed by a gateway name 'create this port forwarding rule': 744, 'External Port': 745, - 'Internal IP': 746, 'Internal Port': 747, + 'DNS Server Config': 749, + 'must be configured to use': 750, + 'the LAN IP address of this server': 751, + 'as its DNS server': 752, + 'DNS Server': 753, + 'View port forwards': 754, + 'Interface(s)': 755, + 'No port forwarding rules': 756, + 'Port forwarding rules required on gateway': 757, } diff --git a/web/projects/shared/src/i18n/dictionaries/es.ts b/web/projects/shared/src/i18n/dictionaries/es.ts index ba4c446ae..c837e4d7d 100644 --- a/web/projects/shared/src/i18n/dictionaries/es.ts +++ b/web/projects/shared/src/i18n/dictionaries/es.ts @@ -683,6 +683,14 @@ export default { 743: 'En su puerta de enlace', 744: 'cree esta regla de reenvío de puertos', 745: 'Puerto externo', - 746: 'IP interna', 747: 'Puerto interno', + 749: 'Configuración del servidor DNS', + 750: 'debe estar configurada para usar', + 751: 'la dirección IP LAN de este servidor', + 752: 'como su servidor DNS', + 753: 'Servidor DNS', + 754: 'Ver redirecciones de puertos', + 755: 'Interfaz/Interfaces', + 756: 'Sin reglas de redirección de puertos', + 757: 'Reglas de redirección de puertos requeridas en la puerta de enlace', } satisfies i18n diff --git a/web/projects/shared/src/i18n/dictionaries/fr.ts b/web/projects/shared/src/i18n/dictionaries/fr.ts index d62742233..7e6fadfbc 100644 --- a/web/projects/shared/src/i18n/dictionaries/fr.ts +++ b/web/projects/shared/src/i18n/dictionaries/fr.ts @@ -683,6 +683,14 @@ export default { 743: 'Dans votre passerelle', 744: 'créez cette règle de redirection de port', 745: 'Port externe', - 746: 'IP interne', 747: 'Port interne', + 749: 'Configuration du serveur DNS', + 750: 'doit être configurée pour utiliser', + 751: "l'adresse IP LAN de ce serveur", + 752: 'comme serveur DNS', + 753: 'Serveur DNS', + 754: 'Voir les redirections de ports', + 755: 'Interface(s)', + 756: 'Aucune règle de redirection de port', + 757: 'Règles de redirection de ports requises sur la passerelle', } satisfies i18n diff --git a/web/projects/shared/src/i18n/dictionaries/pl.ts b/web/projects/shared/src/i18n/dictionaries/pl.ts index 3679d9c40..7f1b2bf98 100644 --- a/web/projects/shared/src/i18n/dictionaries/pl.ts +++ b/web/projects/shared/src/i18n/dictionaries/pl.ts @@ -683,6 +683,14 @@ export default { 743: 'W bramie', 744: 'utwórz tę regułę przekierowania portów', 745: 'Port zewnętrzny', - 746: 'Wewnętrzny IP', 747: 'Port wewnętrzny', + 749: 'Konfiguracja serwera DNS', + 750: 'musi być skonfigurowana do używania', + 751: 'adresu IP LAN tego serwera', + 752: 'jako serwera DNS', + 753: 'Serwer DNS', + 754: 'Wyświetl przekierowania portów', + 755: 'Interfejs(y)', + 756: 'Brak reguł przekierowania portów', + 757: 'Reguły przekierowania portów wymagane na bramce', } satisfies i18n diff --git a/web/projects/shared/src/services/error.service.ts b/web/projects/shared/src/services/error.service.ts index 46e129649..cb4344610 100644 --- a/web/projects/shared/src/services/error.service.ts +++ b/web/projects/shared/src/services/error.service.ts @@ -30,7 +30,6 @@ export function getErrorMessage(e: HttpError | string, link?: string): string { 'Request Error. Your browser blocked the request. This is usually caused by a corrupt browser cache or an overly aggressive ad blocker. Please clear your browser cache and/or adjust your ad blocker and try again' } else if (!e.message) { message = 'Unknown Error' - link = 'https://docs.start9.com/help/common-issues.html' } else { message = e.message } diff --git a/web/projects/ui/src/app/routes/login/ca-wizard/ca-wizard.component.html b/web/projects/ui/src/app/routes/login/ca-wizard/ca-wizard.component.html index 3d2b796b3..52c87c61c 100644 --- a/web/projects/ui/src/app/routes/login/ca-wizard/ca-wizard.component.html +++ b/web/projects/ui/src/app/routes/login/ca-wizard/ca-wizard.component.html @@ -46,7 +46,7 @@ tuiButton docsLink size="s" - path="/user-manual/trust-ca.html" + path="/start-os/user-manual/trust-ca.html" iconEnd="@tui.external-link" > {{ 'View instructions' | i18n }} diff --git a/web/projects/ui/src/app/routes/portal/components/header/menu.component.ts b/web/projects/ui/src/app/routes/portal/components/header/menu.component.ts index c3e9c47f0..d21e62e3d 100644 --- a/web/projects/ui/src/app/routes/portal/components/header/menu.component.ts +++ b/web/projects/ui/src/app/routes/portal/components/header/menu.component.ts @@ -50,7 +50,12 @@ import { ABOUT } from './about.component' - + {{ 'User manual' | i18n }} } + @if (address().hostnameInfo.metadata.kind === 'public-domain') { + + } + @if (address().hostnameInfo.metadata.kind === 'private-domain') { + + } + @if ( + address().hostnameInfo.metadata.kind === 'ipv4' && + address().access === 'public' && + address().hostnameInfo.port !== null + ) { + + } + @if (address().hostnameInfo.metadata.kind === 'public-domain') { + + } + @if (address().hostnameInfo.metadata.kind === 'private-domain') { + + } + @if ( + address().hostnameInfo.metadata.kind === 'ipv4' && + address().hostnameInfo.port !== null + ) { + + } @if (address().deletable) { @for (address of gatewayGroup().addresses; track $index) { } @empty { - - - } @@ -80,8 +79,9 @@ import { AddressActionsComponent } from './actions.component' grid-template-columns: fit-content(10rem) 1fr 2rem 2rem; } - .access tui-icon { - font-size: 1rem; + .type tui-icon { + font-size: 1.3rem; + margin-right: 0.7rem; vertical-align: middle; } @@ -134,11 +134,11 @@ import { AddressActionsComponent } from './actions.component' padding-inline-end: 0.5rem; } - td:nth-child(4) { + td:nth-child(3) { grid-area: 2 / 1 / 2 / 3; } - td:nth-child(5) { + td:nth-child(4) { grid-area: 3 / 1 / 3 / 3; } @@ -164,11 +164,13 @@ export class InterfaceAddressItemComponent { private readonly api = inject(ApiService) private readonly errorService = inject(ErrorService) private readonly loader = inject(LoadingService) + private readonly domainHealth = inject(DomainHealthService) readonly address = input.required() readonly packageId = input('') readonly value = input() readonly isRunning = input.required() + readonly gatewayId = input('') readonly toggling = signal(false) readonly currentlyMasked = signal(true) @@ -202,6 +204,27 @@ export class InterfaceAddressItemComponent { enabled, }) } + + if (enabled) { + const kind = addr.hostnameInfo.metadata.kind + if (kind === 'public-domain') { + await this.domainHealth.checkPublicDomain( + addr.hostnameInfo.host, + this.gatewayId(), + ) + } else if (kind === 'private-domain') { + await this.domainHealth.checkPrivateDomain(this.gatewayId()) + } else if ( + kind === 'ipv4' && + addr.access === 'public' && + addr.hostnameInfo.port !== null + ) { + await this.domainHealth.checkPortForward( + this.gatewayId(), + addr.hostnameInfo.port, + ) + } + } } catch (e: any) { this.errorService.handleError(e) } finally { diff --git a/web/projects/ui/src/app/routes/portal/components/interfaces/addresses/port-forward.component.ts b/web/projects/ui/src/app/routes/portal/components/interfaces/addresses/port-forward.component.ts new file mode 100644 index 000000000..ce08b8c2c --- /dev/null +++ b/web/projects/ui/src/app/routes/portal/components/interfaces/addresses/port-forward.component.ts @@ -0,0 +1,178 @@ +import { + ChangeDetectionStrategy, + Component, + inject, + signal, +} from '@angular/core' +import { ErrorService, i18nPipe } from '@start9labs/shared' +import { TuiButton, TuiDialogContext, TuiIcon, TuiLoader } from '@taiga-ui/core' +import { TuiButtonLoading } from '@taiga-ui/kit' +import { injectContext, PolymorpheusComponent } from '@taiga-ui/polymorpheus' +import { TableComponent } from 'src/app/routes/portal/components/table.component' +import { ApiService } from 'src/app/services/api/embassy-api.service' +import { DnsGateway } from './dns.component' + +export type PortForwardValidationData = { + gateway: DnsGateway + port: number + initialResults?: { portPass: boolean } +} + +@Component({ + selector: 'port-forward-validation', + template: ` + @let gatewayName = + context.data.gateway.name || context.data.gateway.ipInfo.name; + +

{{ 'Port Forwarding' | i18n }}

+

+ {{ 'In your gateway' | i18n }} "{{ gatewayName }}", + {{ 'create this port forwarding rule' | i18n }} +

+ +
+ {{ 'No addresses' | i18n }} @@ -94,12 +83,6 @@ import { InterfaceAddressItemComponent } from './item.component' th:first-child { width: 5rem; } - - th:nth-child(2), - th:nth-child(3), - th:nth-child(4) { - width: 11rem; - } } `, host: { class: 'g-card' }, @@ -118,11 +101,11 @@ import { InterfaceAddressItemComponent } from './item.component' export class InterfaceAddressesComponent { private readonly patch = inject>(PatchDB) private readonly formDialog = inject(FormDialogService) - private readonly dialog = inject(DialogService) private readonly loader = inject(LoadingService) private readonly errorService = inject(ErrorService) private readonly api = inject(ApiService) private readonly i18n = inject(i18nPipe) + private readonly domainHealth = inject(DomainHealthService) readonly gatewayGroup = input.required() readonly packageId = input('') @@ -230,6 +213,9 @@ export class InterfaceAddressesComponent { } else { await this.api.osUiAddPrivateDomain({ fqdn, gateway: gatewayId }) } + + await this.domainHealth.checkPrivateDomain(gatewayId) + return true } catch (e: any) { this.errorService.handleError(e) @@ -254,46 +240,17 @@ export class InterfaceAddressesComponent { } try { - let ip: string | null if (this.packageId()) { - ip = await this.api.pkgAddPublicDomain({ + await this.api.pkgAddPublicDomain({ ...params, package: this.packageId(), host: iface?.addressInfo.hostId || '', }) } else { - ip = await this.api.osUiAddPublicDomain(params) + await this.api.osUiAddPublicDomain(params) } - const [network, portPass] = await Promise.all([ - firstValueFrom(this.patch.watch$('serverInfo', 'network')), - this.api - .checkPort({ gateway: gatewayId, port: 443 }) - .then(r => r.reachable) - .catch(() => false), - ]) - const gateway = network.gateways[gatewayId] - - if (gateway?.ipInfo) { - const gatewayData = { - id: gatewayId, - ...gateway, - ipInfo: gateway.ipInfo, - } - const dnsPass = ip === gateway.ipInfo.wanIp - - setTimeout( - () => - this.showDomainValidation( - fqdn, - gatewayData, - 443, - dnsPass, - portPass, - ), - 250, - ) - } + await this.domainHealth.checkPublicDomain(fqdn, gatewayId) return true } catch (e: any) { @@ -303,20 +260,4 @@ export class InterfaceAddressesComponent { loader.unsubscribe() } } - - private showDomainValidation( - fqdn: string, - gateway: DnsGateway, - port: number, - dnsPass: boolean, - portPass: boolean, - ) { - this.dialog - .openComponent(DOMAIN_VALIDATION, { - label: 'Domain Setup', - size: 'm', - data: { fqdn, gateway, port, dnsPass, portPass }, - }) - .subscribe() - } } diff --git a/web/projects/ui/src/app/routes/portal/components/interfaces/public-domains/dns.component.ts b/web/projects/ui/src/app/routes/portal/components/interfaces/addresses/dns.component.ts similarity index 75% rename from web/projects/ui/src/app/routes/portal/components/interfaces/public-domains/dns.component.ts rename to web/projects/ui/src/app/routes/portal/components/interfaces/addresses/dns.component.ts index 3b3921a68..77d639a9f 100644 --- a/web/projects/ui/src/app/routes/portal/components/interfaces/public-domains/dns.component.ts +++ b/web/projects/ui/src/app/routes/portal/components/interfaces/addresses/dns.component.ts @@ -7,7 +7,7 @@ import { } from '@angular/core' import { FormsModule } from '@angular/forms' import { ErrorService, i18nPipe } from '@start9labs/shared' -import { TuiButton, TuiDialogContext, TuiIcon } from '@taiga-ui/core' +import { TuiButton, TuiDialogContext, TuiIcon, TuiLoader } from '@taiga-ui/core' import { TuiButtonLoading, TuiSwitch, @@ -28,8 +28,7 @@ export type DomainValidationData = { fqdn: string gateway: DnsGateway port: number - dnsPass: boolean - portPass: boolean + initialResults?: { dnsPass: boolean; portPass: boolean } } @Component({ @@ -38,9 +37,8 @@ export type DomainValidationData = { @let wanIp = context.data.gateway.ipInfo.wanIp || ('Error' | i18n); @let gatewayName = context.data.gateway.name || context.data.gateway.ipInfo.name; - @let internalIp = context.data.gateway.ipInfo.lanIp[0] || ('Error' | i18n); -

{{ 'DNS' | i18n }}

+

{{ 'DNS' | i18n }}

{{ 'In your domain registrar for' | i18n }} {{ domain }}, {{ 'create this DNS record' | i18n }} @@ -63,10 +61,14 @@ export type DomainValidationData = { @@ -85,25 +87,26 @@ export type DomainValidationData = {
- @if (dnsPass() === true) { + @if (dnsLoading()) { + + } @else if (dnsPass() === true) { } @else if (dnsPass() === false) { + } @else { + } {{ ddns ? 'ALIAS' : 'A' }}
-

{{ 'Port Forwarding' | i18n }}

+

{{ 'Port Forwarding' | i18n }}

{{ 'In your gateway' | i18n }} "{{ gatewayName }}", {{ 'create this port forwarding rule' | i18n }}

- +
-
- @if (portPass() === true) { + @if (portLoading()) { + + } @else if (portPass() === true) { } @else if (portPass() === false) { + } @else { + } {{ context.data.port }}{{ internalIp }} {{ context.data.port }}
-
- - -
+ @if (!isManualMode) { +
+ + +
+ } `, styles: ` label { @@ -144,21 +149,25 @@ export type DomainValidationData = { margin: 1rem 0; } - h3 { - margin: 1.5rem 0 0.5rem; + h2 { + margin: 2rem 0 0 0; + } - &:first-child { - margin-top: 0; - } + p { + margin-top: 0.5rem; } tui-icon { - font-size: 1rem; + font-size: 1.3rem; vertical-align: text-bottom; } .status { - width: 1.5rem; + width: 3.2rem; + } + + .padding-top { + padding-top: 2rem; } td:last-child { @@ -207,6 +216,7 @@ export type DomainValidationData = { FormsModule, TuiButtonLoading, TuiIcon, + TuiLoader, ], }) export class DomainValidationComponent { @@ -223,13 +233,23 @@ export class DomainValidationComponent { readonly dnsLoading = signal(false) readonly portLoading = signal(false) - readonly dnsPass = signal(this.context.data.dnsPass) - readonly portPass = signal(this.context.data.portPass) + readonly dnsPass = signal(undefined) + readonly portPass = signal(undefined) readonly allPass = computed( () => this.dnsPass() === true && this.portPass() === true, ) + readonly isManualMode = !this.context.data.initialResults + + constructor() { + const initial = this.context.data.initialResults + if (initial) { + this.dnsPass.set(initial.dnsPass) + this.portPass.set(initial.portPass) + } + } + async testDns() { this.dnsLoading.set(true) diff --git a/web/projects/ui/src/app/routes/portal/components/interfaces/addresses/domain-health.service.ts b/web/projects/ui/src/app/routes/portal/components/interfaces/addresses/domain-health.service.ts new file mode 100644 index 000000000..afa0baa79 --- /dev/null +++ b/web/projects/ui/src/app/routes/portal/components/interfaces/addresses/domain-health.service.ts @@ -0,0 +1,173 @@ +import { inject, Injectable } from '@angular/core' +import { DialogService, ErrorService } from '@start9labs/shared' +import { PatchDB } from 'patch-db-client' +import { firstValueFrom } from 'rxjs' +import { ApiService } from 'src/app/services/api/embassy-api.service' +import { DataModel } from 'src/app/services/patch-db/data-model' +import { DOMAIN_VALIDATION, DnsGateway } from './dns.component' +import { PORT_FORWARD_VALIDATION } from './port-forward.component' +import { PRIVATE_DNS_VALIDATION } from './private-dns.component' + +@Injectable({ providedIn: 'root' }) +export class DomainHealthService { + private readonly patch = inject>(PatchDB) + private readonly dialog = inject(DialogService) + private readonly api = inject(ApiService) + private readonly errorService = inject(ErrorService) + + async checkPublicDomain(fqdn: string, gatewayId: string): Promise { + try { + const gateway = await this.getGatewayData(gatewayId) + if (!gateway) return + + const [dnsPass, portPass] = await Promise.all([ + this.api + .queryDns({ fqdn }) + .then(ip => ip === gateway.ipInfo.wanIp) + .catch(() => false), + this.api + .checkPort({ gateway: gatewayId, port: 443 }) + .then(r => r.reachable) + .catch(() => false), + ]) + + if (!dnsPass || !portPass) { + setTimeout( + () => + this.openPublicDomainModal(fqdn, gateway, 443, { + dnsPass, + portPass, + }), + 250, + ) + } + } catch (e: any) { + this.errorService.handleError(e) + } + } + + async checkPrivateDomain(gatewayId: string): Promise { + try { + const gateway = await this.getGatewayData(gatewayId) + if (!gateway) return + + const configured = await this.api + .checkDns({ gateway: gatewayId }) + .catch(() => false) + + if (!configured) { + setTimeout( + () => this.openPrivateDomainModal(gateway, { configured }), + 250, + ) + } + } catch (e: any) { + this.errorService.handleError(e) + } + } + + async showPublicDomainSetup(fqdn: string, gatewayId: string): Promise { + try { + const gateway = await this.getGatewayData(gatewayId) + if (!gateway) return + + this.openPublicDomainModal(fqdn, gateway, 443) + } catch (e: any) { + this.errorService.handleError(e) + } + } + + async checkPortForward(gatewayId: string, port: number): Promise { + try { + const gateway = await this.getGatewayData(gatewayId) + if (!gateway) return + + const portPass = await this.api + .checkPort({ gateway: gatewayId, port }) + .then(r => r.reachable) + .catch(() => false) + + if (!portPass) { + setTimeout( + () => this.openPortForwardModal(gateway, port, { portPass }), + 250, + ) + } + } catch (e: any) { + this.errorService.handleError(e) + } + } + + async showPortForwardSetup(gatewayId: string, port: number): Promise { + try { + const gateway = await this.getGatewayData(gatewayId) + if (!gateway) return + + this.openPortForwardModal(gateway, port) + } catch (e: any) { + this.errorService.handleError(e) + } + } + + async showPrivateDomainSetup(gatewayId: string): Promise { + try { + const gateway = await this.getGatewayData(gatewayId) + if (!gateway) return + + this.openPrivateDomainModal(gateway) + } catch (e: any) { + this.errorService.handleError(e) + } + } + + private async getGatewayData(gatewayId: string): Promise { + const network = await firstValueFrom( + this.patch.watch$('serverInfo', 'network'), + ) + const gateway = network.gateways[gatewayId] + if (!gateway?.ipInfo) return null + return { id: gatewayId, ...gateway, ipInfo: gateway.ipInfo } + } + + private openPublicDomainModal( + fqdn: string, + gateway: DnsGateway, + port: number, + initialResults?: { dnsPass: boolean; portPass: boolean }, + ) { + this.dialog + .openComponent(DOMAIN_VALIDATION, { + label: 'Domain Setup', + size: 'm', + data: { fqdn, gateway, port, initialResults }, + }) + .subscribe() + } + + private openPortForwardModal( + gateway: DnsGateway, + port: number, + initialResults?: { portPass: boolean }, + ) { + this.dialog + .openComponent(PORT_FORWARD_VALIDATION, { + label: 'Port Forwarding', + size: 'm', + data: { gateway, port, initialResults }, + }) + .subscribe() + } + + private openPrivateDomainModal( + gateway: DnsGateway, + initialResults?: { configured: boolean }, + ) { + this.dialog + .openComponent(PRIVATE_DNS_VALIDATION, { + label: 'Domain Setup', + size: 'm', + data: { gateway, initialResults }, + }) + .subscribe() + } +} diff --git a/web/projects/ui/src/app/routes/portal/components/interfaces/addresses/item.component.ts b/web/projects/ui/src/app/routes/portal/components/interfaces/addresses/item.component.ts index 6740da5ba..30bcd54d5 100644 --- a/web/projects/ui/src/app/routes/portal/components/interfaces/addresses/item.component.ts +++ b/web/projects/ui/src/app/routes/portal/components/interfaces/addresses/item.component.ts @@ -14,6 +14,7 @@ import { TuiSwitch } from '@taiga-ui/kit' import { ApiService } from 'src/app/services/api/embassy-api.service' import { GatewayAddress, MappedServiceInterface } from '../interface.service' import { AddressActionsComponent } from './actions.component' +import { DomainHealthService } from './domain-health.service' @Component({ selector: 'tr[address]', @@ -33,14 +34,11 @@ import { AddressActionsComponent } from './actions.component' (ngModelChange)="onToggleEnabled()" />
- {{ address.type }} - + - {{ address.access | i18n }} + {{ address.type }} {{ address.certificate }} @@ -71,6 +69,7 @@ import { AddressActionsComponent } from './actions.component' [packageId]="packageId()" [value]="value()" [disabled]="!isRunning()" + [gatewayId]="gatewayId()" [style.width.rem]="5" >
+ + + + + + +
+ @if (loading()) { + + } @else if (pass() === true) { + + } @else if (pass() === false) { + + } @else { + + } + {{ context.data.port }}{{ context.data.port }} + +
+ + @if (!isManualMode) { +
+ + +
+ } + `, + styles: ` + h2 { + margin: 2rem 0 0 0; + } + + p { + margin-top: 0.5rem; + } + + tui-icon { + font-size: 1.3rem; + vertical-align: text-bottom; + } + + .status { + width: 3.2rem; + } + + .padding-top { + padding-top: 2rem; + } + + td:last-child { + text-align: end; + } + + footer { + margin-top: 1.5rem; + } + + :host-context(tui-root._mobile) table { + thead { + display: table-header-group !important; + } + + tr { + display: table-row !important; + box-shadow: none !important; + } + + td, + th { + padding: 0.5rem 0.5rem !important; + font: var(--tui-font-text-s) !important; + color: var(--tui-text-primary) !important; + font-weight: normal !important; + } + + th { + font-weight: bold !important; + } + } + `, + changeDetection: ChangeDetectionStrategy.OnPush, + imports: [ + TuiButton, + i18nPipe, + TableComponent, + TuiButtonLoading, + TuiIcon, + TuiLoader, + ], +}) +export class PortForwardValidationComponent { + private readonly errorService = inject(ErrorService) + private readonly api = inject(ApiService) + + readonly context = + injectContext>() + + readonly loading = signal(false) + readonly pass = signal(undefined) + + readonly isManualMode = !this.context.data.initialResults + + constructor() { + const initial = this.context.data.initialResults + if (initial) { + this.pass.set(initial.portPass) + } + } + + async testPort() { + this.loading.set(true) + + try { + const result = await this.api.checkPort({ + gateway: this.context.data.gateway.id, + port: this.context.data.port, + }) + + this.pass.set(result.reachable) + } catch (e: any) { + this.errorService.handleError(e) + } finally { + this.loading.set(false) + } + } +} + +export const PORT_FORWARD_VALIDATION = new PolymorpheusComponent( + PortForwardValidationComponent, +) diff --git a/web/projects/ui/src/app/routes/portal/components/interfaces/addresses/private-dns.component.ts b/web/projects/ui/src/app/routes/portal/components/interfaces/addresses/private-dns.component.ts new file mode 100644 index 000000000..dd246d442 --- /dev/null +++ b/web/projects/ui/src/app/routes/portal/components/interfaces/addresses/private-dns.component.ts @@ -0,0 +1,180 @@ +import { + ChangeDetectionStrategy, + Component, + inject, + signal, +} from '@angular/core' +import { ErrorService, i18nPipe } from '@start9labs/shared' +import { TuiButton, TuiDialogContext, TuiIcon, TuiLoader } from '@taiga-ui/core' +import { TuiButtonLoading } from '@taiga-ui/kit' +import { injectContext, PolymorpheusComponent } from '@taiga-ui/polymorpheus' +import { TableComponent } from 'src/app/routes/portal/components/table.component' +import { ApiService } from 'src/app/services/api/embassy-api.service' +import { DnsGateway } from './dns.component' + +export type PrivateDnsValidationData = { + gateway: DnsGateway + initialResults?: { configured: boolean } +} + +@Component({ + selector: 'private-dns-validation', + template: ` + @let gatewayName = + context.data.gateway.name || context.data.gateway.ipInfo.name; + @let internalIp = context.data.gateway.ipInfo.lanIp[0] || ('Error' | i18n); + +

{{ 'DNS Server Config' | i18n }}

+

+ {{ 'Gateway' | i18n }} "{{ gatewayName }}" + {{ 'must be configured to use' | i18n }} + {{ internalIp }} + ({{ 'the LAN IP address of this server' | i18n }}) + {{ 'as its DNS server' | i18n }}. +

+ + + + + + + + +
+ @if (loading()) { + + } @else if (pass() === true) { + + } @else if (pass() === false) { + + } @else { + + } + {{ gatewayName }}{{ internalIp }} + +
+ + @if (!isManualMode) { +
+ + +
+ } + `, + styles: ` + h2 { + margin: 2rem 0 0 0; + } + + p { + margin-top: 0.5rem; + } + + tui-icon { + font-size: 1rem; + vertical-align: text-bottom; + } + + .status { + width: 3.2rem; + } + + .padding-top { + padding-top: 2rem; + } + + td:last-child { + text-align: end; + } + + footer { + margin-top: 1.5rem; + } + + :host-context(tui-root._mobile) table { + thead { + display: table-header-group !important; + } + + tr { + display: table-row !important; + box-shadow: none !important; + } + + td, + th { + padding: 0.5rem 0.5rem !important; + font: var(--tui-font-text-s) !important; + color: var(--tui-text-primary) !important; + font-weight: normal !important; + } + + th { + font-weight: bold !important; + } + } + `, + changeDetection: ChangeDetectionStrategy.OnPush, + imports: [ + TuiButton, + i18nPipe, + TableComponent, + TuiButtonLoading, + TuiIcon, + TuiLoader, + ], +}) +export class PrivateDnsValidationComponent { + private readonly errorService = inject(ErrorService) + private readonly api = inject(ApiService) + + readonly context = + injectContext>() + + readonly loading = signal(false) + readonly pass = signal(undefined) + + readonly isManualMode = !this.context.data.initialResults + + constructor() { + const initial = this.context.data.initialResults + if (initial) { + this.pass.set(initial.configured) + } + } + + async testDns() { + this.loading.set(true) + + try { + const result = await this.api.checkDns({ + gateway: this.context.data.gateway.id, + }) + + this.pass.set(result) + } catch (e: any) { + this.errorService.handleError(e) + } finally { + this.loading.set(false) + } + } +} + +export const PRIVATE_DNS_VALIDATION = new PolymorpheusComponent( + PrivateDnsValidationComponent, +) diff --git a/web/projects/ui/src/app/routes/portal/components/interfaces/interface.service.ts b/web/projects/ui/src/app/routes/portal/components/interfaces/interface.service.ts index 857fbd669..aa33492b9 100644 --- a/web/projects/ui/src/app/routes/portal/components/interfaces/interface.service.ts +++ b/web/projects/ui/src/app/routes/portal/components/interfaces/interface.service.ts @@ -71,11 +71,10 @@ function getAddressType(h: T.HostnameInfo): string { case 'ipv6': return 'IPv6' case 'public-domain': - return 'Public Domain' + case 'private-domain': + return h.host case 'mdns': return 'mDNS' - case 'private-domain': - return 'Private Domain' case 'plugin': return 'Plugin' } diff --git a/web/projects/ui/src/app/routes/portal/routes/backups/modals/jobs.component.ts b/web/projects/ui/src/app/routes/portal/routes/backups/modals/jobs.component.ts index 72c67e8d9..970745044 100644 --- a/web/projects/ui/src/app/routes/portal/routes/backups/modals/jobs.component.ts +++ b/web/projects/ui/src/app/routes/portal/routes/backups/modals/jobs.component.ts @@ -26,7 +26,9 @@ import { DocsLinkDirective } from 'projects/shared/src/public-api' Scheduling automatic backups is an excellent way to ensure your StartOS data is safely backed up. StartOS will issue a notification whenever one of your scheduled backups succeeds or fails. -
View instructions + + View instructions +

Saved Jobs diff --git a/web/projects/ui/src/app/routes/portal/routes/backups/modals/targets.component.ts b/web/projects/ui/src/app/routes/portal/routes/backups/modals/targets.component.ts index a0fd73bfa..6bde58daa 100644 --- a/web/projects/ui/src/app/routes/portal/routes/backups/modals/targets.component.ts +++ b/web/projects/ui/src/app/routes/portal/routes/backups/modals/targets.component.ts @@ -31,7 +31,9 @@ import { DocsLinkDirective } from 'projects/shared/src/public-api' backups. They can be physical drives plugged into your server, shared folders on your Local Area Network (LAN), or third party clouds such as Dropbox or Google Drive. - View instructions + + View instructions +

Unknown Physical Drives diff --git a/web/projects/ui/src/app/routes/portal/routes/metrics/time.component.ts b/web/projects/ui/src/app/routes/portal/routes/metrics/time.component.ts index 73cae2664..b6f1247e7 100644 --- a/web/projects/ui/src/app/routes/portal/routes/metrics/time.component.ts +++ b/web/projects/ui/src/app/routes/portal/routes/metrics/time.component.ts @@ -49,7 +49,7 @@ import { TimeService } from 'src/app/services/time.service' docsLink iconEnd="@tui.external-link" appearance="" - path="/help/common-issues.html" + path="/start-os/faq/index.html" fragment="#clock-sync-failure" [pseudo]="true" [textContent]="'the docs' | i18n" diff --git a/web/projects/ui/src/app/routes/portal/routes/system/routes/authorities/authorities.component.ts b/web/projects/ui/src/app/routes/portal/routes/system/routes/authorities/authorities.component.ts index 0b56e05de..95e37e4f3 100644 --- a/web/projects/ui/src/app/routes/portal/routes/system/routes/authorities/authorities.component.ts +++ b/web/projects/ui/src/app/routes/portal/routes/system/routes/authorities/authorities.component.ts @@ -21,7 +21,7 @@ import { AuthoritiesTableComponent } from './table.component' tuiIconButton size="xs" docsLink - path="/user-manual/authorities.html" + path="/start-os/user-manual/trust-ca.html" appearance="icon" iconStart="@tui.external-link" > diff --git a/web/projects/ui/src/app/routes/portal/routes/system/routes/backups/backups.component.ts b/web/projects/ui/src/app/routes/portal/routes/system/routes/backups/backups.component.ts index 0f33d4f26..604cf9918 100644 --- a/web/projects/ui/src/app/routes/portal/routes/system/routes/backups/backups.component.ts +++ b/web/projects/ui/src/app/routes/portal/routes/system/routes/backups/backups.component.ts @@ -66,7 +66,7 @@ import { BACKUP_RESTORE } from './restore.component' @@ -184,7 +184,9 @@ export default class SystemDnsComponent { if ( Object.values(pkgs).some(p => - Object.values(p.hosts).some(h => Object.keys(h?.privateDomains || {}).length), + Object.values(p.hosts).some( + h => Object.keys(h?.privateDomains || {}).length, + ), ) ) { Object.values(gateways) diff --git a/web/projects/ui/src/app/routes/portal/routes/system/routes/email/email.component.ts b/web/projects/ui/src/app/routes/portal/routes/system/routes/email/email.component.ts index 114f487d6..8b7d413c4 100644 --- a/web/projects/ui/src/app/routes/portal/routes/system/routes/email/email.component.ts +++ b/web/projects/ui/src/app/routes/portal/routes/system/routes/email/email.component.ts @@ -40,7 +40,7 @@ import { configBuilderToSpec } from 'src/app/utils/configBuilderToSpec' tuiIconButton size="xs" docsLink - path="/user-manual/smtp.html" + path="/start-os/user-manual/smtp.html" appearance="icon" iconStart="@tui.external-link" > diff --git a/web/projects/ui/src/app/routes/portal/routes/system/routes/gateways/gateways.component.ts b/web/projects/ui/src/app/routes/portal/routes/system/routes/gateways/gateways.component.ts index 6c5484d6f..3bd243957 100644 --- a/web/projects/ui/src/app/routes/portal/routes/system/routes/gateways/gateways.component.ts +++ b/web/projects/ui/src/app/routes/portal/routes/system/routes/gateways/gateways.component.ts @@ -32,7 +32,7 @@ import { ISB } from '@start9labs/start-sdk' tuiIconButton size="xs" docsLink - path="/user-manual/gateways.html" + path="/start-os/user-manual/gateways.html" appearance="icon" iconStart="@tui.external-link" > diff --git a/web/projects/ui/src/app/routes/portal/routes/system/routes/gateways/item.component.ts b/web/projects/ui/src/app/routes/portal/routes/system/routes/gateways/item.component.ts index e2e7706f4..75c806d90 100644 --- a/web/projects/ui/src/app/routes/portal/routes/system/routes/gateways/item.component.ts +++ b/web/projects/ui/src/app/routes/portal/routes/system/routes/gateways/item.component.ts @@ -26,12 +26,24 @@ import { FormDialogService } from 'src/app/services/form-dialog.service' import { configBuilderToSpec } from 'src/app/utils/configBuilderToSpec' import { GatewayPlus } from 'src/app/services/gateway.service' import { TuiBadge } from '@taiga-ui/kit' +import { PORT_FORWARDS_MODAL } from './port-forwards.component' @Component({ selector: 'tr[gateway]', template: ` @if (gateway(); as gateway) { + @switch (gateway.ipInfo.deviceType) { + @case ('ethernet') { + + } + @case ('wireless') { + + } + @case ('wireguard') { + + } + } {{ gateway.name }} @if (gateway.isDefaultOutbound) { @@ -39,31 +51,10 @@ import { TuiBadge } from '@taiga-ui/kit' } - - @switch (gateway.ipInfo.deviceType) { - @case ('ethernet') { - - {{ 'Ethernet' | i18n }} - } - @case ('wireless') { - - {{ 'WiFi' | i18n }} - } - @case ('wireguard') { - - WireGuard - } - @default { - {{ gateway.ipInfo.deviceType }} - } - } - @if (gateway.type === 'outbound-only') { - {{ 'Outbound Only' | i18n }} } @else { - {{ 'Inbound/Outbound' | i18n }} } @@ -93,25 +84,23 @@ import { TuiBadge } from '@taiga-ui/kit' {{ 'Rename' | i18n }} + @if (gateway.type !== 'outbound-only') { + + + + } @if (!gateway.isDefaultOutbound) { - } @if (gateway.ipInfo.deviceType === 'wireguard') { - @@ -122,41 +111,55 @@ import { TuiBadge } from '@taiga-ui/kit' } `, styles: ` + tui-icon { + font-size: 1.3rem; + margin-right: 0.7rem; + } + + tui-badge { + margin-left: 1rem; + } + td:last-child { - grid-area: 1 / 3 / 7; - align-self: center; text-align: right; } :host-context(tui-root._mobile) { - grid-template-columns: min-content 1fr min-content; - - .name { - grid-column: span 2; + td { + width: auto !important; + align-content: center; } - .connection { - grid-column: span 2; - order: -1; + td:first-child { + font: var(--tui-font-text-m); + font-weight: bold; + color: var(--tui-text-primary); } - .type { - grid-column: span 2; + td:nth-child(2) { + grid-area: 2 / 1 / 2 / 3; } - .lan, - .wan { - grid-column: span 2; + td:nth-child(3), + td:nth-child(4) { + grid-area: auto / 1 / auto / 3; &::before { - content: 'LAN IP: '; color: var(--tui-text-primary); } } - .wan::before { + td:nth-child(3)::before { + content: 'LAN IP: '; + } + + td:nth-child(4)::before { content: 'WAN IP: '; } + + td:last-child { + grid-area: 1 / 3 / 6; + } } `, changeDetection: ChangeDetectionStrategy.OnPush, @@ -183,6 +186,17 @@ export class GatewaysItemComponent { open = false + viewPortForwards() { + const { id, name } = this.gateway() + this.dialog + .openComponent(PORT_FORWARDS_MODAL, { + label: 'Port Forwards', + size: 'l', + data: { gatewayId: id, gatewayName: name }, + }) + .subscribe() + } + remove() { this.dialog .openConfirm({ label: 'Are you sure?', size: 's' }) diff --git a/web/projects/ui/src/app/routes/portal/routes/system/routes/gateways/port-forwards.component.ts b/web/projects/ui/src/app/routes/portal/routes/system/routes/gateways/port-forwards.component.ts new file mode 100644 index 000000000..857f40d65 --- /dev/null +++ b/web/projects/ui/src/app/routes/portal/routes/system/routes/gateways/port-forwards.component.ts @@ -0,0 +1,268 @@ +import { + ChangeDetectionStrategy, + Component, + inject, + signal, +} from '@angular/core' +import { toSignal } from '@angular/core/rxjs-interop' +import { ErrorService, i18nPipe } from '@start9labs/shared' +import { TuiButton, TuiDialogContext, TuiIcon, TuiLoader } from '@taiga-ui/core' +import { TuiButtonLoading } from '@taiga-ui/kit' +import { injectContext, PolymorpheusComponent } from '@taiga-ui/polymorpheus' +import { PatchDB } from 'patch-db-client' +import { combineLatest, map } from 'rxjs' +import { PlaceholderComponent } from 'src/app/routes/portal/components/placeholder.component' +import { TableComponent } from 'src/app/routes/portal/components/table.component' +import { ApiService } from 'src/app/services/api/embassy-api.service' +import { DataModel } from 'src/app/services/patch-db/data-model' + +export type PortForwardsModalData = { + gatewayId: string + gatewayName: string +} + +type PortForwardRow = { + interfaces: string[] + externalPort: number + internalPort: number +} + +function parseSocketAddr(s: string): { ip: string; port: number } { + const lastColon = s.lastIndexOf(':') + return { + ip: s.substring(0, lastColon), + port: Number(s.substring(lastColon + 1)), + } +} + +@Component({ + selector: 'port-forwards-modal', + template: ` +

+ {{ 'Port forwarding rules required on gateway' | i18n }} + "{{ context.data.gatewayName }}" +

+ + + @for (row of rows(); track row.externalPort; let i = $index) { + + + + + + + + } @empty { + + + + } +
+ @for (iface of row.interfaces; track iface) { +
{{ iface }}
+ } +
+ @if (loading()[i]) { + + } @else if (results()[i] === true) { + + } @else if (results()[i] === false) { + + } @else { + + } + {{ row.externalPort }}{{ row.internalPort }} + +
+ + {{ 'No port forwarding rules' | i18n }} + +
+ `, + styles: ` + p { + margin: 0 0 1rem 0; + } + + .interfaces { + white-space: nowrap; + } + + tui-icon { + font-size: 1.3rem; + vertical-align: text-bottom; + } + + .status { + width: 3.2rem; + } + + td:last-child { + text-align: end; + } + + :host-context(tui-root._mobile) table { + thead { + display: table-header-group !important; + } + + tr { + display: table-row !important; + box-shadow: none !important; + } + + td, + th { + padding: 0.5rem 0.5rem !important; + font: var(--tui-font-text-s) !important; + color: var(--tui-text-primary) !important; + font-weight: normal !important; + } + + th { + font-weight: bold !important; + } + } + `, + changeDetection: ChangeDetectionStrategy.OnPush, + imports: [ + TuiButton, + i18nPipe, + TableComponent, + PlaceholderComponent, + TuiIcon, + TuiLoader, + TuiButtonLoading, + ], +}) +export class PortForwardsModalComponent { + private readonly patch = inject>(PatchDB) + private readonly api = inject(ApiService) + private readonly errorService = inject(ErrorService) + + readonly context = + injectContext>() + + readonly loading = signal>({}) + readonly results = signal>({}) + + private readonly portForwards$ = combineLatest([ + this.patch.watch$('serverInfo', 'network', 'host', 'portForwards').pipe( + map(pfs => + pfs.map(pf => ({ + ...pf, + interfaces: ['StartOS - UI'], + })), + ), + ), + this.patch.watch$('packageData').pipe( + map(pkgData => { + const rows: Array<{ + src: string + dst: string + gateway: string + interfaces: string[] + }> = [] + + for (const [pkgId, pkg] of Object.entries(pkgData)) { + const title = + pkg.stateInfo.manifest?.title ?? + pkg.stateInfo.installingInfo?.newManifest?.title ?? + pkgId + + for (const [hostId, host] of Object.entries(pkg.hosts)) { + // Find interface names pointing to this host + const ifaceNames: string[] = [] + for (const iface of Object.values(pkg.serviceInterfaces)) { + if (iface.addressInfo.hostId === hostId) { + ifaceNames.push(`${title} - ${iface.name}`) + } + } + + const label = + ifaceNames.length > 0 ? ifaceNames : [`${title} - ${hostId}`] + + for (const pf of host.portForwards) { + rows.push({ ...pf, interfaces: label }) + } + } + } + + return rows + }), + ), + ]).pipe( + map(([osForwards, pkgForwards]) => { + const gatewayId = this.context.data.gatewayId + const all = [...osForwards, ...pkgForwards].filter( + pf => pf.gateway === gatewayId, + ) + + // Group by (externalPort, internalPort) + const grouped = new Map() + + for (const pf of all) { + const src = parseSocketAddr(pf.src) + const dst = parseSocketAddr(pf.dst) + const key = `${src.port}:${dst.port}` + + const existing = grouped.get(key) + if (existing) { + for (const iface of pf.interfaces) { + if (!existing.interfaces.includes(iface)) { + existing.interfaces.push(iface) + } + } + } else { + grouped.set(key, { + interfaces: [...pf.interfaces], + externalPort: src.port, + internalPort: dst.port, + }) + } + } + + return [...grouped.values()].sort( + (a, b) => a.externalPort - b.externalPort, + ) + }), + ) + + readonly rows = toSignal(this.portForwards$, { initialValue: [] }) + + async testPort(index: number, port: number) { + this.loading.update(l => ({ ...l, [index]: true })) + + try { + const result = await this.api.checkPort({ + gateway: this.context.data.gatewayId, + port, + }) + + this.results.update(r => ({ ...r, [index]: result.reachable })) + } catch (e: any) { + this.errorService.handleError(e) + } finally { + this.loading.update(l => ({ ...l, [index]: false })) + } + } +} + +export const PORT_FORWARDS_MODAL = new PolymorpheusComponent( + PortForwardsModalComponent, +) diff --git a/web/projects/ui/src/app/routes/portal/routes/system/routes/gateways/table.component.ts b/web/projects/ui/src/app/routes/portal/routes/system/routes/gateways/table.component.ts index a904aeab7..d7e5d7d03 100644 --- a/web/projects/ui/src/app/routes/portal/routes/system/routes/gateways/table.component.ts +++ b/web/projects/ui/src/app/routes/portal/routes/system/routes/gateways/table.component.ts @@ -8,21 +8,12 @@ import { GatewayService } from 'src/app/services/gateway.service' @Component({ selector: 'gateways-table', template: ` - +
@for (gateway of gatewayService.gateways(); track $index) { } @empty { - diff --git a/web/projects/ui/src/app/routes/portal/routes/system/routes/ssh/ssh.component.ts b/web/projects/ui/src/app/routes/portal/routes/system/routes/ssh/ssh.component.ts index 565916acc..e76e0e92f 100644 --- a/web/projects/ui/src/app/routes/portal/routes/system/routes/ssh/ssh.component.ts +++ b/web/projects/ui/src/app/routes/portal/routes/system/routes/ssh/ssh.component.ts @@ -39,7 +39,7 @@ import { SSHTableComponent } from './table.component' tuiIconButton size="xs" docsLink - path="/user-manual/ssh.html" + path="/start-os/user-manual/ssh.html" appearance="icon" iconStart="@tui.external-link" > diff --git a/web/projects/ui/src/app/routes/portal/routes/system/routes/wifi/wifi.component.ts b/web/projects/ui/src/app/routes/portal/routes/system/routes/wifi/wifi.component.ts index a2c0dffe8..d9409d96b 100644 --- a/web/projects/ui/src/app/routes/portal/routes/system/routes/wifi/wifi.component.ts +++ b/web/projects/ui/src/app/routes/portal/routes/system/routes/wifi/wifi.component.ts @@ -54,7 +54,7 @@ import { wifiSpec } from './wifi.const' tuiIconButton size="xs" docsLink - path="/user-manual/wifi.html" + path="/start-os/user-manual/wifi.html" appearance="icon" iconStart="@tui.external-link" > diff --git a/web/projects/ui/src/app/services/api/api.types.ts b/web/projects/ui/src/app/services/api/api.types.ts index 815364b93..1f179b20a 100644 --- a/web/projects/ui/src/app/services/api/api.types.ts +++ b/web/projects/ui/src/app/services/api/api.types.ts @@ -89,6 +89,10 @@ export type GetRegistryPackageReq = GetPackageReq & { registry: string } export type GetRegistryPackagesReq = GetPackagesReq & { registry: string } +// dns +// TODO: Replace with T.CheckDnsRes when SDK types are generated +export type CheckDnsRes = boolean + // backup export type DiskBackupTarget = Extract diff --git a/web/projects/ui/src/app/services/api/embassy-api.service.ts b/web/projects/ui/src/app/services/api/embassy-api.service.ts index d44667ca9..e69d6c243 100644 --- a/web/projects/ui/src/app/services/api/embassy-api.service.ts +++ b/web/projects/ui/src/app/services/api/embassy-api.service.ts @@ -6,6 +6,7 @@ import { WebSocketSubject } from 'rxjs/webSocket' import { DataModel } from '../patch-db/data-model' import { ActionRes, + CheckDnsRes, CifsBackupTarget, DiagnosticErrorRes, FollowPackageLogsReq, @@ -128,9 +129,9 @@ export abstract class ApiService { abstract queryDns(params: T.QueryDnsParams): Promise - abstract checkPort( - params: T.CheckPortParams, - ): Promise + abstract checkPort(params: T.CheckPortParams): Promise + + abstract checkDns(params: T.CheckDnsParams): Promise // smtp @@ -191,9 +192,7 @@ export abstract class ApiService { abstract setDefaultOutbound(params: { gateway: string | null }): Promise - abstract setServiceOutbound( - params: T.SetOutboundGatewayParams, - ): Promise + abstract setServiceOutbound(params: T.SetOutboundGatewayParams): Promise // ** domains ** diff --git a/web/projects/ui/src/app/services/api/embassy-live-api.service.ts b/web/projects/ui/src/app/services/api/embassy-live-api.service.ts index 0d12469d8..3441ba69c 100644 --- a/web/projects/ui/src/app/services/api/embassy-live-api.service.ts +++ b/web/projects/ui/src/app/services/api/embassy-live-api.service.ts @@ -19,6 +19,7 @@ import { AuthService } from '../auth.service' import { DataModel } from '../patch-db/data-model' import { ActionRes, + CheckDnsRes, CifsBackupTarget, DiagnosticErrorRes, FollowPackageLogsReq, @@ -283,6 +284,13 @@ export class LiveApiService extends ApiService { }) } + async checkDns(params: T.CheckDnsParams): Promise { + return this.rpcRequest({ + method: 'net.gateway.check-dns', + params, + }) + } + // marketplace URLs async checkOSUpdate(params: { diff --git a/web/projects/ui/src/app/services/api/embassy-mock-api.service.ts b/web/projects/ui/src/app/services/api/embassy-mock-api.service.ts index ab6399eb8..8905c468d 100644 --- a/web/projects/ui/src/app/services/api/embassy-mock-api.service.ts +++ b/web/projects/ui/src/app/services/api/embassy-mock-api.service.ts @@ -26,6 +26,7 @@ import { import { GetPackageRes, GetPackagesRes } from '@start9labs/marketplace' import { ActionRes, + CheckDnsRes, CifsBackupTarget, DiagnosticErrorRes, FollowPackageLogsReq, @@ -497,14 +498,18 @@ export class MockApiService extends ApiService { return null } - async checkPort( - params: T.CheckPortParams, - ): Promise { + async checkPort(params: T.CheckPortParams): Promise { await pauseFor(2000) return { ip: '0.0.0.0', port: params.port, reachable: false } } + async checkDns(params: T.CheckDnsParams): Promise { + await pauseFor(2000) + + return false + } + // marketplace URLs async checkOSUpdate(params: { @@ -662,9 +667,7 @@ export class MockApiService extends ApiService { return null } - async setServiceOutbound( - params: T.SetOutboundGatewayParams, - ): Promise { + async setServiceOutbound(params: T.SetOutboundGatewayParams): Promise { await pauseFor(2000) const patch = [ { diff --git a/web/projects/ui/src/app/services/api/mock-patch.ts b/web/projects/ui/src/app/services/api/mock-patch.ts index 1f0e7cf59..f81a5afdb 100644 --- a/web/projects/ui/src/app/services/api/mock-patch.ts +++ b/web/projects/ui/src/app/services/api/mock-patch.ts @@ -54,32 +54,42 @@ export const mockPatchData: DataModel = { }, }, { - ssl: true, + ssl: false, public: false, host: '10.0.0.1', - port: 443, + port: 80, metadata: { kind: 'ipv4', gateway: 'eth0' }, }, { - ssl: true, + ssl: false, public: false, host: '10.0.0.2', - port: 443, + port: 80, metadata: { kind: 'ipv4', gateway: 'wlan0' }, }, { - ssl: true, + ssl: false, public: false, host: 'fe80::cd00:0000:0cde:1257:0000:211e:72cd', - port: 443, + port: 80, metadata: { kind: 'ipv6', gateway: 'eth0', scopeId: 2 }, }, + { + ssl: false, + public: false, + host: 'fe80::cd00:0000:0cde:1257:0000:211e:1234', + port: 80, + metadata: { kind: 'ipv6', gateway: 'wlan0', scopeId: 3 }, + }, { ssl: true, public: false, - host: 'fe80::cd00:0000:0cde:1257:0000:211e:1234', + host: 'my-server.home', port: 443, - metadata: { kind: 'ipv6', gateway: 'wlan0', scopeId: 3 }, + metadata: { + kind: 'private-domain', + gateways: ['eth0'], + }, }, { ssl: false, @@ -109,8 +119,16 @@ export const mockPatchData: DataModel = { }, }, publicDomains: {}, - privateDomains: {}, - portForwards: [], + privateDomains: { + 'my-server.home': ['eth0'], + }, + portForwards: [ + { + src: '203.0.113.45:443', + dst: '10.0.0.1:443', + gateway: 'eth0', + }, + ], }, gateways: { eth0: { @@ -504,70 +522,70 @@ export const mockPatchData: DataModel = { 80: { enabled: true, net: { - assignedPort: 80, - assignedSslPort: 443, + assignedPort: 42080, + assignedSslPort: 42443, }, addresses: { - enabled: ['203.0.113.45:443'], + enabled: ['203.0.113.45:42443'], disabled: [], available: [ { ssl: true, public: false, host: 'adjective-noun.local', - port: 443, + port: 42443, metadata: { kind: 'mdns', gateways: ['eth0'], }, }, { - ssl: true, + ssl: false, public: false, host: '10.0.0.1', - port: 443, + port: 42080, metadata: { kind: 'ipv4', gateway: 'eth0' }, }, { - ssl: true, + ssl: false, public: false, host: 'fe80::cd00:0cde:1257:211e:72cd', - port: 443, + port: 42080, metadata: { kind: 'ipv6', gateway: 'eth0', scopeId: 2 }, }, { ssl: true, public: true, host: '203.0.113.45', - port: 443, + port: 42443, metadata: { kind: 'ipv4', gateway: 'eth0' }, }, { ssl: true, public: true, host: 'bitcoin.example.com', - port: 443, + port: 42443, metadata: { kind: 'public-domain', gateway: 'eth0' }, }, { - ssl: true, + ssl: false, public: false, host: '192.168.10.11', - port: 443, + port: 42080, metadata: { kind: 'ipv4', gateway: 'wlan0' }, }, { - ssl: true, + ssl: false, public: false, host: 'fe80::cd00:0cde:1257:211e:1234', - port: 443, + port: 42080, metadata: { kind: 'ipv6', gateway: 'wlan0', scopeId: 3 }, }, { ssl: true, public: false, host: 'my-bitcoin.home', - port: 443, + port: 42443, metadata: { kind: 'private-domain', gateways: ['wlan0'], @@ -577,22 +595,22 @@ export const mockPatchData: DataModel = { ssl: false, public: false, host: 'xyz789abc123def456ghi789jkl012mno345pqr678stu901vwx234.onion', - port: 80, + port: 42080, metadata: { kind: 'plugin', package: 'tor' }, }, { ssl: true, public: false, host: 'xyz789abc123def456ghi789jkl012mno345pqr678stu901vwx234.onion', - port: 443, + port: 42443, metadata: { kind: 'plugin', package: 'tor' }, }, ], }, options: { - preferredExternalPort: 443, + preferredExternalPort: 42443, addSsl: { - preferredExternalPort: 443, + preferredExternalPort: 42443, alpn: { specified: ['http/1.1', 'h2'] }, addXForwardedHeaders: false, }, @@ -609,14 +627,25 @@ export const mockPatchData: DataModel = { privateDomains: { 'my-bitcoin.home': ['wlan0'], }, - portForwards: [], + portForwards: [ + { + src: '203.0.113.45:443', + dst: '10.0.0.1:443', + gateway: 'eth0', + }, + { + src: '203.0.113.45:42443', + dst: '10.0.0.1:42443', + gateway: 'eth0', + }, + ], }, bcdefgh: { bindings: { 8332: { enabled: true, net: { - assignedPort: 8332, + assignedPort: 48332, assignedSslPort: null, }, addresses: { @@ -627,7 +656,7 @@ export const mockPatchData: DataModel = { ssl: false, public: false, host: 'adjective-noun.local', - port: 8332, + port: 48332, metadata: { kind: 'mdns', gateways: ['eth0'], @@ -637,14 +666,14 @@ export const mockPatchData: DataModel = { ssl: false, public: false, host: '10.0.0.1', - port: 8332, + port: 48332, metadata: { kind: 'ipv4', gateway: 'eth0' }, }, ], }, options: { addSsl: null, - preferredExternalPort: 8332, + preferredExternalPort: 48332, secure: { ssl: false }, }, }, @@ -658,7 +687,7 @@ export const mockPatchData: DataModel = { 8333: { enabled: true, net: { - assignedPort: 8333, + assignedPort: 48333, assignedSslPort: null, }, addresses: { @@ -668,7 +697,7 @@ export const mockPatchData: DataModel = { }, options: { addSsl: null, - preferredExternalPort: 8333, + preferredExternalPort: 48333, secure: { ssl: false }, }, },
+
{{ 'Loading' | i18n }}