diff --git a/web/projects/shared/src/i18n/dictionaries/de.ts b/web/projects/shared/src/i18n/dictionaries/de.ts
index 7934dcfd1..dec1340ab 100644
--- a/web/projects/shared/src/i18n/dictionaries/de.ts
+++ b/web/projects/shared/src/i18n/dictionaries/de.ts
@@ -525,4 +525,5 @@ export default {
556: '',
557: '',
558: '',
+ 559: '',
} satisfies i18n
diff --git a/web/projects/shared/src/i18n/dictionaries/en.ts b/web/projects/shared/src/i18n/dictionaries/en.ts
index 5175bc3ac..6290173b4 100644
--- a/web/projects/shared/src/i18n/dictionaries/en.ts
+++ b/web/projects/shared/src/i18n/dictionaries/en.ts
@@ -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
diff --git a/web/projects/shared/src/i18n/dictionaries/es.ts b/web/projects/shared/src/i18n/dictionaries/es.ts
index da7a060da..5383bb07c 100644
--- a/web/projects/shared/src/i18n/dictionaries/es.ts
+++ b/web/projects/shared/src/i18n/dictionaries/es.ts
@@ -525,4 +525,5 @@ export default {
556: '',
557: '',
558: '',
+ 559: '',
} satisfies i18n
diff --git a/web/projects/shared/src/i18n/dictionaries/fr.ts b/web/projects/shared/src/i18n/dictionaries/fr.ts
index 3da50f08e..17b4cd60a 100644
--- a/web/projects/shared/src/i18n/dictionaries/fr.ts
+++ b/web/projects/shared/src/i18n/dictionaries/fr.ts
@@ -525,4 +525,5 @@ export default {
556: '',
557: '',
558: '',
+ 559: '',
} satisfies i18n
diff --git a/web/projects/shared/src/i18n/dictionaries/pl.ts b/web/projects/shared/src/i18n/dictionaries/pl.ts
index 02ea43e2d..fdcbd1c96 100644
--- a/web/projects/shared/src/i18n/dictionaries/pl.ts
+++ b/web/projects/shared/src/i18n/dictionaries/pl.ts
@@ -525,4 +525,5 @@ export default {
556: '',
557: '',
558: '',
+ 559: '',
} satisfies i18n
diff --git a/web/projects/ui/src/app/routes/portal/routes/system/routes/dns/dns.component.ts b/web/projects/ui/src/app/routes/portal/routes/system/routes/dns/dns.component.ts
new file mode 100644
index 000000000..59086b878
--- /dev/null
+++ b/web/projects/ui/src/app/routes/portal/routes/system/routes/dns/dns.component.ts
@@ -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: `
+
+
+ {{ 'Back' | i18n }}
+
+ {{ 'DNS Servers' | i18n }}
+
+ @if (data(); as d) {
+
+ }
+ `,
+ 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)
+ 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 {
+ 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()
+ }
+ }
+}
diff --git a/web/projects/ui/src/app/routes/portal/routes/system/system.const.ts b/web/projects/ui/src/app/routes/portal/routes/system/system.const.ts
index 974b0f40a..313481e6b 100644
--- a/web/projects/ui/src/app/routes/portal/routes/system/system.const.ts
+++ b/web/projects/ui/src/app/routes/portal/routes/system/system.const.ts
@@ -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',
+ },
],
[
{
diff --git a/web/projects/ui/src/app/routes/portal/routes/system/system.routes.ts b/web/projects/ui/src/app/routes/portal/routes/system/system.routes.ts
index 8e3622843..983a6f7f2 100644
--- a/web/projects/ui/src/app/routes/portal/routes/system/system.routes.ts
+++ b/web/projects/ui/src/app/routes/portal/routes/system/system.routes.ts
@@ -77,6 +77,11 @@ export default [
loadComponent: () =>
import('./routes/authorities/authorities.component'),
},
+ {
+ path: 'dns',
+ title: titleResolver,
+ loadComponent: () => import('./routes/dns/dns.component'),
+ },
],
},
] satisfies Routes
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 ea9019513..48bb3843e 100644
--- a/web/projects/ui/src/app/services/api/api.types.ts
+++ b/web/projects/ui/src/app/services/api/api.types.ts
@@ -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
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 32acc8d94..80b2368af 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
@@ -122,7 +122,9 @@ export abstract class ApiService {
abstract toggleKiosk(enable: boolean): Promise
- abstract testDns(params: RR.TestDnsReq): Promise
+ abstract setDns(params: RR.SetDnsReq): Promise
+
+ abstract queryDns(params: RR.QueryDnsReq): Promise
abstract resetTor(params: RR.ResetTorReq): Promise
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 62330e4f1..49e841a59 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
@@ -267,9 +267,16 @@ export class LiveApiService extends ApiService {
})
}
- async testDns(params: RR.TestDnsReq): Promise {
+ async setDns(params: RR.SetDnsReq): Promise {
return this.rpcRequest({
- method: 'net.dns.test',
+ method: 'net.dns.set',
+ params,
+ })
+ }
+
+ async queryDns(params: RR.QueryDnsReq): Promise {
+ return this.rpcRequest({
+ method: 'net.dns.query',
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 9db0e5fff..f5cf9b560 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
@@ -462,7 +462,22 @@ export class MockApiService extends ApiService {
return null
}
- async testDns(params: RR.TestDnsReq): Promise {
+ async setDns(params: RR.SetDnsReq): Promise {
+ await pauseFor(2000)
+
+ const patch: ReplaceOperation[] = [
+ {
+ op: PatchOp.REPLACE,
+ path: '/serverInfo/network/dns',
+ value: params,
+ },
+ ]
+ this.mockRevision(patch)
+
+ return null
+ }
+
+ async queryDns(params: RR.QueryDnsReq): Promise {
await pauseFor(2000)
return null