round out dns check, dns server check, port forward check, and gateway port forwards

This commit is contained in:
Matt Hill
2026-02-17 23:31:47 -07:00
parent a22707c1cb
commit 485fced691
37 changed files with 1228 additions and 252 deletions

View File

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

View File

@@ -62,7 +62,7 @@
<div>
<!-- link to store for brochure -->
<ng-content select="[slot=store-mobile]" />
<a docsLink path="/packaging-guide">
<a docsLink path="/packaging/quick-start.html">
<span>{{ 'Package a service' | i18n }}</span>
<tui-icon tuiAppearance="icon" icon="@tui.external-link" />
</a>
@@ -86,7 +86,7 @@
<div>
<!-- link to store for brochure -->
<ng-content select="[slot=store]" />
<a docsLink path="/packaging-guide">
<a docsLink path="/packaging/quick-start.html">
<span>{{ 'Package a service' | i18n }}</span>
<tui-icon tuiAppearance="icon" icon="@tui.external-link" />
</a>

View File

@@ -46,7 +46,7 @@ import { DocsLinkDirective } from '@start9labs/shared'
Download your server's Root CA and
<a
docsLink
path="/user-manual/trust-ca.html"
path="/start-os/user-manual/trust-ca.html"
style="color: #6866cc; font-weight: bold; text-decoration: none"
>
follow instructions

View File

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

View File

@@ -683,6 +683,14 @@ export const ENGLISH: Record<string, number> = {
'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,
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -50,7 +50,12 @@ import { ABOUT } from './about.component'
</button>
</tui-opt-group>
<tui-opt-group label="" safeLinks>
<a tuiOption docsLink iconStart="@tui.book-open" path="/user-manual">
<a
tuiOption
docsLink
iconStart="@tui.book-open"
path="/start-os/user-manual/index.html"
>
{{ 'User manual' | i18n }}
</a>
<a

View File

@@ -24,6 +24,7 @@ import { PolymorpheusComponent } from '@taiga-ui/polymorpheus'
import { QRModal } from 'src/app/routes/portal/modals/qr.component'
import { ApiService } from 'src/app/services/api/embassy-api.service'
import { GatewayAddress, MappedServiceInterface } from '../interface.service'
import { DomainHealthService } from './domain-health.service'
@Component({
selector: 'td[actions]',
@@ -39,6 +40,40 @@ import { GatewayAddress, MappedServiceInterface } from '../interface.service'
{{ 'Delete' | i18n }}
</button>
}
@if (address().hostnameInfo.metadata.kind === 'public-domain') {
<button
tuiIconButton
appearance="flat-grayscale"
iconStart="@tui.globe"
(click)="showDnsValidation()"
>
{{ 'Domain Setup' | i18n }}
</button>
}
@if (address().hostnameInfo.metadata.kind === 'private-domain') {
<button
tuiIconButton
appearance="flat-grayscale"
iconStart="@tui.globe"
(click)="showPrivateDnsValidation()"
>
{{ 'Domain Setup' | i18n }}
</button>
}
@if (
address().hostnameInfo.metadata.kind === 'ipv4' &&
address().access === 'public' &&
address().hostnameInfo.port !== null
) {
<button
tuiIconButton
appearance="flat-grayscale"
iconStart="@tui.globe"
(click)="showPortForwardValidation()"
>
{{ 'Port Forwarding' | i18n }}
</button>
}
<button
tuiIconButton
appearance="flat-grayscale"
@@ -88,6 +123,39 @@ import { GatewayAddress, MappedServiceInterface } from '../interface.service'
>
{{ 'Copy URL' | i18n }}
</button>
@if (address().hostnameInfo.metadata.kind === 'public-domain') {
<button
tuiOption
new
iconStart="@tui.heart-pulse"
(click)="showDnsValidation()"
>
{{ 'Domain Setup' | i18n }}
</button>
}
@if (address().hostnameInfo.metadata.kind === 'private-domain') {
<button
tuiOption
new
iconStart="@tui.heart-pulse"
(click)="showPrivateDnsValidation()"
>
{{ 'Domain Setup' | i18n }}
</button>
}
@if (
address().hostnameInfo.metadata.kind === 'ipv4' &&
address().hostnameInfo.port !== null
) {
<button
tuiOption
new
iconStart="@tui.heart-pulse"
(click)="showPortForwardValidation()"
>
{{ 'Port Forwarding' | i18n }}
</button>
}
@if (address().deletable) {
<button
tuiOption
@@ -135,6 +203,7 @@ export class AddressActionsComponent {
private readonly api = inject(ApiService)
private readonly loader = inject(LoadingService)
private readonly errorService = inject(ErrorService)
private readonly domainHealth = inject(DomainHealthService)
readonly copyService = inject(CopyService)
readonly open = signal(false)
@@ -142,6 +211,7 @@ export class AddressActionsComponent {
readonly packageId = input('')
readonly value = input<MappedServiceInterface | undefined>()
readonly disabled = input.required<boolean>()
readonly gatewayId = input('')
showQR() {
this.dialog
@@ -185,6 +255,23 @@ export class AddressActionsComponent {
}
}
showDnsValidation() {
this.domainHealth.showPublicDomainSetup(
this.address().hostnameInfo.host,
this.gatewayId(),
)
}
showPrivateDnsValidation() {
this.domainHealth.showPrivateDomainSetup(this.gatewayId())
}
showPortForwardValidation() {
const port = this.address().hostnameInfo.port
if (port === null) return
this.domainHealth.showPortForwardSetup(this.gatewayId(), port)
}
async deleteDomain() {
const addr = this.address()
const iface = this.value()

View File

@@ -5,12 +5,7 @@ import {
input,
signal,
} from '@angular/core'
import {
DialogService,
ErrorService,
i18nPipe,
LoadingService,
} from '@start9labs/shared'
import { ErrorService, i18nPipe, LoadingService } from '@start9labs/shared'
import { ISB, utils } from '@start9labs/start-sdk'
import {
TuiButton,
@@ -35,7 +30,7 @@ import {
GatewayAddressGroup,
MappedServiceInterface,
} from '../interface.service'
import { DOMAIN_VALIDATION, DnsGateway } from '../public-domains/dns.component'
import { DomainHealthService } from './domain-health.service'
import { InterfaceAddressItemComponent } from './item.component'
@Component({
@@ -62,14 +57,7 @@ import { InterfaceAddressItemComponent } from './item.component'
</button>
</header>
<table
[appTable]="[
'Enabled',
'Type',
'Access',
'Certificate Authority',
'URL',
null,
]"
[appTable]="['Enabled', 'Type', 'Certificate Authority', 'URL', null]"
>
@for (address of gatewayGroup().addresses; track $index) {
<tr
@@ -77,10 +65,11 @@ import { InterfaceAddressItemComponent } from './item.component'
[packageId]="packageId()"
[value]="value()"
[isRunning]="isRunning()"
[gatewayId]="gatewayGroup().gatewayId"
></tr>
} @empty {
<tr>
<td colspan="6">
<td colspan="5">
<app-placeholder icon="@tui.list-x">
{{ 'No addresses' | i18n }}
</app-placeholder>
@@ -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<DataModel>>(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<GatewayAddressGroup>()
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()
}
}

View File

@@ -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);
<h3>{{ 'DNS' | i18n }}</h3>
<h2>{{ 'DNS' | i18n }}</h2>
<p>
{{ 'In your domain registrar for' | i18n }} {{ domain }},
{{ 'create this DNS record' | i18n }}
@@ -63,10 +61,14 @@ export type DomainValidationData = {
<table [appTable]="[null, 'Type', 'Host', 'Value', null]">
<tr>
<td class="status">
@if (dnsPass() === true) {
@if (dnsLoading()) {
<tui-loader size="s" />
} @else if (dnsPass() === true) {
<tui-icon class="g-positive" icon="@tui.check" />
} @else if (dnsPass() === false) {
<tui-icon class="g-negative" icon="@tui.x" />
} @else {
<tui-icon class="g-secondary" icon="@tui.minus" />
}
</td>
<td>{{ ddns ? 'ALIAS' : 'A' }}</td>
@@ -85,25 +87,26 @@ export type DomainValidationData = {
</tr>
</table>
<h3>{{ 'Port Forwarding' | i18n }}</h3>
<h2>{{ 'Port Forwarding' | i18n }}</h2>
<p>
{{ 'In your gateway' | i18n }} "{{ gatewayName }}",
{{ 'create this port forwarding rule' | i18n }}
</p>
<table
[appTable]="[null, 'External Port', 'Internal IP', 'Internal Port', null]"
>
<table [appTable]="[null, 'External Port', 'Internal Port', null]">
<tr>
<td class="status">
@if (portPass() === true) {
@if (portLoading()) {
<tui-loader size="s" />
} @else if (portPass() === true) {
<tui-icon class="g-positive" icon="@tui.check" />
} @else if (portPass() === false) {
<tui-icon class="g-negative" icon="@tui.x" />
} @else {
<tui-icon class="g-secondary" icon="@tui.minus" />
}
</td>
<td>{{ context.data.port }}</td>
<td>{{ internalIp }}</td>
<td>{{ context.data.port }}</td>
<td>
<button
@@ -118,23 +121,25 @@ export type DomainValidationData = {
</tr>
</table>
<footer class="g-buttons">
<button
tuiButton
appearance="flat"
[disabled]="allPass()"
(click)="context.completeWith()"
>
{{ 'Later' | i18n }}
</button>
<button
tuiButton
[disabled]="!allPass()"
(click)="context.completeWith()"
>
{{ 'Done' | i18n }}
</button>
</footer>
@if (!isManualMode) {
<footer class="g-buttons padding-top">
<button
tuiButton
appearance="flat"
[disabled]="allPass()"
(click)="context.completeWith()"
>
{{ 'Later' | i18n }}
</button>
<button
tuiButton
[disabled]="!allPass()"
(click)="context.completeWith()"
>
{{ 'Done' | i18n }}
</button>
</footer>
}
`,
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<boolean | undefined>(this.context.data.dnsPass)
readonly portPass = signal<boolean | undefined>(this.context.data.portPass)
readonly dnsPass = signal<boolean | undefined>(undefined)
readonly portPass = signal<boolean | undefined>(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)

View File

@@ -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<DataModel>>(PatchDB)
private readonly dialog = inject(DialogService)
private readonly api = inject(ApiService)
private readonly errorService = inject(ErrorService)
async checkPublicDomain(fqdn: string, gatewayId: string): Promise<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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<DnsGateway | null> {
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()
}
}

View File

@@ -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()"
/>
</td>
<td>
{{ address.type }}
</td>
<td class="access">
<td class="type">
<tui-icon
[icon]="address.access === 'public' ? '@tui.globe' : '@tui.house'"
/>
{{ address.access | i18n }}
{{ address.type }}
</td>
<td>
{{ address.certificate }}
@@ -71,6 +69,7 @@ import { AddressActionsComponent } from './actions.component'
[packageId]="packageId()"
[value]="value()"
[disabled]="!isRunning()"
[gatewayId]="gatewayId()"
[style.width.rem]="5"
></td>
}
@@ -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<GatewayAddress>()
readonly packageId = input('')
readonly value = input<MappedServiceInterface | undefined>()
readonly isRunning = input.required<boolean>()
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 {

View File

@@ -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;
<h2>{{ 'Port Forwarding' | i18n }}</h2>
<p>
{{ 'In your gateway' | i18n }} "{{ gatewayName }}",
{{ 'create this port forwarding rule' | i18n }}
</p>
<table [appTable]="[null, 'External Port', 'Internal Port', null]">
<tr>
<td class="status">
@if (loading()) {
<tui-loader size="s" />
} @else if (pass() === true) {
<tui-icon class="g-positive" icon="@tui.check" />
} @else if (pass() === false) {
<tui-icon class="g-negative" icon="@tui.x" />
} @else {
<tui-icon class="g-secondary" icon="@tui.minus" />
}
</td>
<td>{{ context.data.port }}</td>
<td>{{ context.data.port }}</td>
<td>
<button tuiButton size="s" [loading]="loading()" (click)="testPort()">
{{ 'Test' | i18n }}
</button>
</td>
</tr>
</table>
@if (!isManualMode) {
<footer class="g-buttons padding-top">
<button
tuiButton
appearance="flat"
[disabled]="pass() === true"
(click)="context.completeWith()"
>
{{ 'Later' | i18n }}
</button>
<button
tuiButton
[disabled]="pass() !== true"
(click)="context.completeWith()"
>
{{ 'Done' | i18n }}
</button>
</footer>
}
`,
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<TuiDialogContext<void, PortForwardValidationData>>()
readonly loading = signal(false)
readonly pass = signal<boolean | undefined>(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,
)

View File

@@ -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);
<h2>{{ 'DNS Server Config' | i18n }}</h2>
<p>
{{ 'Gateway' | i18n }} "{{ gatewayName }}"
{{ 'must be configured to use' | i18n }}
{{ internalIp }}
({{ 'the LAN IP address of this server' | i18n }})
{{ 'as its DNS server' | i18n }}.
</p>
<table [appTable]="[null, 'Gateway', 'DNS Server', null]">
<tr>
<td class="status">
@if (loading()) {
<tui-loader size="s" />
} @else if (pass() === true) {
<tui-icon class="g-positive" icon="@tui.check" />
} @else if (pass() === false) {
<tui-icon class="g-negative" icon="@tui.x" />
} @else {
<tui-icon class="g-secondary" icon="@tui.minus" />
}
</td>
<td>{{ gatewayName }}</td>
<td>{{ internalIp }}</td>
<td>
<button tuiButton size="s" [loading]="loading()" (click)="testDns()">
{{ 'Test' | i18n }}
</button>
</td>
</tr>
</table>
@if (!isManualMode) {
<footer class="g-buttons padding-top">
<button
tuiButton
appearance="flat"
[disabled]="pass() === true"
(click)="context.completeWith()"
>
{{ 'Later' | i18n }}
</button>
<button
tuiButton
[disabled]="pass() !== true"
(click)="context.completeWith()"
>
{{ 'Done' | i18n }}
</button>
</footer>
}
`,
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<TuiDialogContext<void, PrivateDnsValidationData>>()
readonly loading = signal(false)
readonly pass = signal<boolean | undefined>(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,
)

View File

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

View File

@@ -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.
<a tuiLink docsLink path="/@TODO">View instructions</a>
<a tuiLink docsLink path="/start-os/user-manual/backup-create.html">
View instructions
</a>
</tui-notification>
<h3 class="g-title">
Saved Jobs

View File

@@ -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.
<a tuiLink docsLink path="/@TODO">View instructions</a>
<a tuiLink docsLink path="/start-os/user-manual/backup-create.html">
View instructions
</a>
</tui-notification>
<h3 class="g-title">
Unknown Physical Drives

View File

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

View File

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

View File

@@ -66,7 +66,7 @@ import { BACKUP_RESTORE } from './restore.component'
<a
tuiLink
docsLink
path="/user-manual/backup-create.html"
path="/start-os/user-manual/backup-create.html"
appearance="action-grayscale"
iconEnd="@tui.external-link"
[textContent]="'View instructions' | i18n"
@@ -79,7 +79,7 @@ import { BACKUP_RESTORE } from './restore.component'
<a
tuiLink
docsLink
path="/user-manual/backup-restore.html"
path="/start-os/user-manual/backup-restore.html"
appearance="action-grayscale"
iconEnd="@tui.external-link"
[textContent]="'View instructions' | i18n"
@@ -121,7 +121,7 @@ import { BACKUP_RESTORE } from './restore.component'
<a
tuiLink
docsLink
path="/user-manual/backup-create.html"
path="/start-os/user-manual/backup-create.html"
fragment="#network-folder"
appearance="action-grayscale"
iconEnd="@tui.external-link"

View File

@@ -47,7 +47,7 @@ const ipv6 =
tuiIconButton
size="xs"
docsLink
path="/user-manual/dns.html"
path="/start-os/user-manual/dns.html"
appearance="icon"
iconStart="@tui.external-link"
>
@@ -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)

View File

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

View File

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

View File

@@ -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) {
<td>
@switch (gateway.ipInfo.deviceType) {
@case ('ethernet') {
<tui-icon icon="@tui.ethernet-port" />
}
@case ('wireless') {
<tui-icon icon="@tui.wifi" />
}
@case ('wireguard') {
<tui-icon icon="@tui.shield" />
}
}
{{ gateway.name }}
@if (gateway.isDefaultOutbound) {
<tui-badge appearance="primary-success">
@@ -39,31 +51,10 @@ import { TuiBadge } from '@taiga-ui/kit'
</tui-badge>
}
</td>
<td>
@switch (gateway.ipInfo.deviceType) {
@case ('ethernet') {
<tui-icon icon="@tui.cable" />
{{ 'Ethernet' | i18n }}
}
@case ('wireless') {
<tui-icon icon="@tui.wifi" />
{{ 'WiFi' | i18n }}
}
@case ('wireguard') {
<tui-icon icon="@tui.shield" />
WireGuard
}
@default {
{{ gateway.ipInfo.deviceType }}
}
}
</td>
<td>
@if (gateway.type === 'outbound-only') {
<tui-icon icon="@tui.arrow-up" />
{{ 'Outbound Only' | i18n }}
} @else {
<tui-icon icon="@tui.arrow-up-down" />
{{ 'Inbound/Outbound' | i18n }}
}
</td>
@@ -93,25 +84,23 @@ import { TuiBadge } from '@taiga-ui/kit'
{{ 'Rename' | i18n }}
</button>
</tui-opt-group>
@if (gateway.type !== 'outbound-only') {
<tui-opt-group>
<button tuiOption new (click)="viewPortForwards()">
{{ 'View port forwards' | i18n }}
</button>
</tui-opt-group>
}
@if (!gateway.isDefaultOutbound) {
<tui-opt-group>
<button
tuiOption
new
(click)="setDefaultOutbound()"
>
<button tuiOption new (click)="setDefaultOutbound()">
{{ 'Set as default outbound' | i18n }}
</button>
</tui-opt-group>
}
@if (gateway.ipInfo.deviceType === 'wireguard') {
<tui-opt-group>
<button
tuiOption
new
class="g-negative"
(click)="remove()"
>
<button tuiOption new class="g-negative" (click)="remove()">
{{ 'Delete' | i18n }}
</button>
</tui-opt-group>
@@ -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' })

View File

@@ -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: `
<p>
{{ 'Port forwarding rules required on gateway' | i18n }}
"{{ context.data.gatewayName }}"
</p>
<table
[appTable]="[
'Interface(s)',
null,
'External Port',
'Internal Port',
null,
]"
>
@for (row of rows(); track row.externalPort; let i = $index) {
<tr>
<td class="interfaces">
@for (iface of row.interfaces; track iface) {
<div>{{ iface }}</div>
}
</td>
<td class="status">
@if (loading()[i]) {
<tui-loader size="s" />
} @else if (results()[i] === true) {
<tui-icon class="g-positive" icon="@tui.check" />
} @else if (results()[i] === false) {
<tui-icon class="g-negative" icon="@tui.x" />
} @else {
<tui-icon class="g-secondary" icon="@tui.minus" />
}
</td>
<td>{{ row.externalPort }}</td>
<td>{{ row.internalPort }}</td>
<td>
<button
tuiButton
size="s"
[loading]="!!loading()[i]"
(click)="testPort(i, row.externalPort)"
>
{{ 'Test' | i18n }}
</button>
</td>
</tr>
} @empty {
<tr>
<td colspan="5">
<app-placeholder icon="@tui.list-x">
{{ 'No port forwarding rules' | i18n }}
</app-placeholder>
</td>
</tr>
}
</table>
`,
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<DataModel>>(PatchDB)
private readonly api = inject(ApiService)
private readonly errorService = inject(ErrorService)
readonly context =
injectContext<TuiDialogContext<void, PortForwardsModalData>>()
readonly loading = signal<Record<number, boolean>>({})
readonly results = signal<Record<number, boolean>>({})
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<string, PortForwardRow>()
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,
)

View File

@@ -8,21 +8,12 @@ import { GatewayService } from 'src/app/services/gateway.service'
@Component({
selector: 'gateways-table',
template: `
<table
[appTable]="[
'Name',
'Connection',
'Type',
$any('LAN IP'),
$any('WAN IP'),
null,
]"
>
<table [appTable]="['Name', 'Type', $any('LAN IP'), $any('WAN IP'), null]">
@for (gateway of gatewayService.gateways(); track $index) {
<tr [gateway]="gateway"></tr>
} @empty {
<tr>
<td colspan="7">
<td colspan="6">
<div [tuiSkeleton]="true">{{ 'Loading' | i18n }}</div>
</td>
</tr>

View File

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

View File

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

View File

@@ -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<T.BackupTarget, { type: 'disk' }>

View File

@@ -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<string | null>
abstract checkPort(
params: T.CheckPortParams,
): Promise<T.CheckPortRes>
abstract checkPort(params: T.CheckPortParams): Promise<T.CheckPortRes>
abstract checkDns(params: T.CheckDnsParams): Promise<CheckDnsRes>
// smtp
@@ -191,9 +192,7 @@ export abstract class ApiService {
abstract setDefaultOutbound(params: { gateway: string | null }): Promise<null>
abstract setServiceOutbound(
params: T.SetOutboundGatewayParams,
): Promise<null>
abstract setServiceOutbound(params: T.SetOutboundGatewayParams): Promise<null>
// ** domains **

View File

@@ -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<CheckDnsRes> {
return this.rpcRequest({
method: 'net.gateway.check-dns',
params,
})
}
// marketplace URLs
async checkOSUpdate(params: {

View File

@@ -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<T.CheckPortRes> {
async checkPort(params: T.CheckPortParams): Promise<T.CheckPortRes> {
await pauseFor(2000)
return { ip: '0.0.0.0', port: params.port, reachable: false }
}
async checkDns(params: T.CheckDnsParams): Promise<CheckDnsRes> {
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<null> {
async setServiceOutbound(params: T.SetOutboundGatewayParams): Promise<null> {
await pauseFor(2000)
const patch = [
{

View File

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