This commit is contained in:
Matt Hill
2025-08-13 13:27:05 -06:00
parent 3abae65b22
commit d5bb537368
12 changed files with 250 additions and 10 deletions

View File

@@ -525,4 +525,5 @@ export default {
556: '',
557: '',
558: '',
559: '',
} satisfies i18n

View File

@@ -523,5 +523,6 @@ export const ENGLISH = {
'Address details': 555,
'Private Domains': 556,
'No private domains': 557,
'New private domain': 558
'New private domain': 558,
'DNS Servers': 559
} as const

View File

@@ -525,4 +525,5 @@ export default {
556: '',
557: '',
558: '',
559: '',
} satisfies i18n

View File

@@ -525,4 +525,5 @@ export default {
556: '',
557: '',
558: '',
559: '',
} satisfies i18n

View File

@@ -525,4 +525,5 @@ export default {
556: '',
557: '',
558: '',
559: '',
} satisfies i18n

View File

@@ -0,0 +1,194 @@
import { CommonModule } from '@angular/common'
import { ChangeDetectionStrategy, Component, inject } from '@angular/core'
import { toSignal } from '@angular/core/rxjs-interop'
import { FormsModule, ReactiveFormsModule } from '@angular/forms'
import { RouterLink } from '@angular/router'
import {
DocsLinkDirective,
ErrorService,
i18nPipe,
LoadingService,
} from '@start9labs/shared'
import { ISB } from '@start9labs/start-sdk'
import { TuiButton, TuiTitle } from '@taiga-ui/core'
import { TuiHeader } from '@taiga-ui/layout'
import { PatchDB } from 'patch-db-client'
import { combineLatest, first, switchMap } from 'rxjs'
import { FormModule } from 'src/app/routes/portal/components/form/form.module'
import { ApiService } from 'src/app/services/api/embassy-api.service'
import { FormService } from 'src/app/services/form.service'
import { DataModel } from 'src/app/services/patch-db/data-model'
import { TitleDirective } from 'src/app/services/title.service'
import { configBuilderToSpec } from 'src/app/utils/configBuilderToSpec'
@Component({
template: `
<ng-container *title>
<a routerLink=".." tuiIconButton iconStart="@tui.arrow-left">
{{ 'Back' | i18n }}
</a>
{{ 'DNS Servers' | i18n }}
</ng-container>
@if (data(); as d) {
<form [formGroup]="d.form">
<header tuiHeader="body-l">
<h3 tuiTitle>
<b>
{{ 'DNS Servers' | i18n }}
<a
tuiIconButton
size="xs"
docsLink
path="/user-manual/dns.html"
appearance="icon"
iconStart="@tui.external-link"
>
{{ 'Documentation' | i18n }}
</a>
</b>
</h3>
</header>
<form-group [spec]="d.spec" />
@if (d.warn.length; as length) {
<p>
Warning. StartOS is currently using {{ d.warn.join(', ') }} for DNS.
Therefore, {{ length > 1 ? 'they' : 'it' }} cannot use StartOS for
DNS. This is circular. If you want to use StartOS as the DNS server
for {{ d.warn.join(', ') }} for private domain resolution, you must
set custom DNS servers above.
</p>
}
<footer>
<button
tuiButton
size="l"
[disabled]="d.form.invalid || d.form.pristine"
(click)="save(d.form.value)"
>
{{ 'Save' | i18n }}
</button>
</footer>
</form>
}
`,
styles: `
:host {
max-width: 36rem;
}
form header,
form footer {
margin: 1rem 0;
display: flex;
gap: 1rem;
}
footer {
justify-content: flex-end;
}
`,
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [
CommonModule,
FormsModule,
ReactiveFormsModule,
FormModule,
TuiButton,
TuiHeader,
TuiTitle,
RouterLink,
TitleDirective,
i18nPipe,
DocsLinkDirective,
],
})
export default class SystemDnsComponent {
private readonly loader = inject(LoadingService)
private readonly errorService = inject(ErrorService)
private readonly formService = inject(FormService)
private readonly patch = inject<PatchDB<DataModel>>(PatchDB)
private readonly api = inject(ApiService)
private readonly i18n = inject(i18nPipe)
private readonly dnsSpec = ISB.InputSpec.of({
strategy: ISB.Value.union({
name: 'DNS Servers',
default: 'defaults',
variants: ISB.Variants.of({
defaults: {
name: 'Default',
spec: ISB.InputSpec.of({
servers: ISB.Value.list(
ISB.List.text(
{
name: 'Default DNS Servers',
},
{},
),
),
}),
},
custom: {
name: 'Custom',
spec: ISB.InputSpec.of({
servers: ISB.Value.list(
ISB.List.text(
{
name: 'DNS Servers',
minLength: 1,
maxLength: 3,
},
{ placeholder: '1.1.1.1' },
),
),
}),
},
}),
}),
})
readonly data = toSignal(
combineLatest([
this.patch.watch$('packageData').pipe(first()),
this.patch.watch$('serverInfo', 'network'),
]).pipe(
switchMap(async ([pkgs, { gateways, dns }]) => {
const spec = await configBuilderToSpec(this.dnsSpec)
const selection = dns.static ? 'custom' : 'defaults'
const form = this.formService.createForm(spec, {
strategy: { selection, value: dns.servers },
})
return {
spec,
form,
warn:
(Object.values(pkgs).some(p => p) || []) &&
Object.values(gateways)
.filter(g => dns.servers.includes(g.ipInfo?.lanIp))
.map(g => g.ipInfo?.name),
}
}),
),
)
async save(value: typeof this.dnsSpec._TYPE): Promise<void> {
const loader = this.loader.open('Saving').subscribe()
try {
await this.api.setDns({
servers: value.strategy.value.servers,
static: value.strategy.selection === 'custom',
})
} catch (e: any) {
this.errorService.handleError(e)
} finally {
loader.unsubscribe()
}
}
}

View File

@@ -1,3 +1,5 @@
import { i18nKey } from '@start9labs/shared'
export const SYSTEM_MENU = [
[
{
@@ -46,6 +48,11 @@ export const SYSTEM_MENU = [
item: 'Certificate Authorities',
link: 'authorities',
},
{
icon: '@tui.globe',
item: 'DNS' as i18nKey,
link: 'dns',
},
],
[
{

View File

@@ -77,6 +77,11 @@ export default [
loadComponent: () =>
import('./routes/authorities/authorities.component'),
},
{
path: 'dns',
title: titleResolver,
loadComponent: () => import('./routes/dns/dns.component'),
},
],
},
] satisfies Routes

View File

@@ -104,11 +104,16 @@ export namespace RR {
export type DiskRepairReq = {} // server.disk.repair
export type DiskRepairRes = null
export type TestDnsReq = {
export type SetDnsReq = {
servers: string[]
static: boolean
} // net.dns.set
export type SetDnsRes = null
export type QueryDnsReq = {
fqdn: string
gateway: T.GatewayId // string
} // net.dns.test
export type TestDnsRes = string | null
} // net.dns.query
export type QueryDnsRes = string | null
export type ResetTorReq = {
wipeState: boolean
@@ -301,7 +306,7 @@ export namespace RR {
gateway: T.GatewayId
acme: string | null // URL. null means local Root CA
}
export type OsUiAddPublicDomainRes = TestDnsRes
export type OsUiAddPublicDomainRes = QueryDnsRes
export type OsUiRemovePublicDomainReq = {
// server.host.address.domain.public.remove

View File

@@ -122,7 +122,9 @@ export abstract class ApiService {
abstract toggleKiosk(enable: boolean): Promise<null>
abstract testDns(params: RR.TestDnsReq): Promise<RR.TestDnsRes>
abstract setDns(params: RR.SetDnsReq): Promise<RR.SetDnsRes>
abstract queryDns(params: RR.QueryDnsReq): Promise<RR.QueryDnsRes>
abstract resetTor(params: RR.ResetTorReq): Promise<RR.ResetTorRes>

View File

@@ -267,9 +267,16 @@ export class LiveApiService extends ApiService {
})
}
async testDns(params: RR.TestDnsReq): Promise<RR.TestDnsRes> {
async setDns(params: RR.SetDnsReq): Promise<RR.SetDnsRes> {
return this.rpcRequest({
method: 'net.dns.test',
method: 'net.dns.set',
params,
})
}
async queryDns(params: RR.QueryDnsReq): Promise<RR.QueryDnsRes> {
return this.rpcRequest({
method: 'net.dns.query',
params,
})
}

View File

@@ -462,7 +462,22 @@ export class MockApiService extends ApiService {
return null
}
async testDns(params: RR.TestDnsReq): Promise<RR.TestDnsRes> {
async setDns(params: RR.SetDnsReq): Promise<RR.SetDnsRes> {
await pauseFor(2000)
const patch: ReplaceOperation<RR.SetDnsReq>[] = [
{
op: PatchOp.REPLACE,
path: '/serverInfo/network/dns',
value: params,
},
]
this.mockRevision(patch)
return null
}
async queryDns(params: RR.QueryDnsReq): Promise<RR.QueryDnsRes> {
await pauseFor(2000)
return null