nix StartOS domains, implement public and private domains at interface scope

This commit is contained in:
Matt Hill
2025-08-11 23:01:31 -06:00
parent e8b7a35d43
commit 63323faa97
32 changed files with 1162 additions and 1163 deletions

View File

@@ -90,13 +90,12 @@ export default {
88: 'Aktionen',
89: 'nicht empfohlen',
90: 'Root-CA ist vertrauenswürdig!',
96: 'Domain hinzufügen',
96: '',
97: 'Wird entfernt',
100: 'Nicht gespeicherte Änderungen',
101: 'Sie haben nicht gespeicherte Änderungen. Möchten Sie die Seite wirklich verlassen?',
102: 'Verlassen',
103: 'Sind Sie sicher?',
104: 'Domain auswählen',
108: 'Öffentlich',
109: 'privat',
111: 'Keine Onion-Domains',
@@ -519,11 +518,13 @@ export default {
546: 'Anbieter',
547: '',
548: '',
549: '',
550: '',
551: '',
552: '',
553: '',
554: '',
555: '',
556: '',
557: '',
558: '',
} satisfies i18n

View File

@@ -89,13 +89,12 @@ export const ENGLISH = {
'Actions': 88, // as in, actions available to the user
'not recommended': 89,
'Root CA Trusted!': 90,
'Add domain': 96,
'Add public domain': 96,
'Removing': 97,
'Unsaved changes': 100,
'You have unsaved changes. Are you sure you want to leave?': 101,
'Leave': 102,
'Are you sure?': 103,
'Select domain': 104,
'public': 108,
'private': 109,
'No Tor domains': 111,
@@ -513,16 +512,18 @@ export const ENGLISH = {
'Domain': 540, // as in, an internat domain name
'Gateway': 541, // as in, a device or software that connects two different networks
'Certificate Authority': 543,
'Edit domain': 544,
'Edit public domain': 544,
'No public domains': 545,
'Provider': 546,
'View DNS': 547,
'Clearnet Domains': 548,
'No clearnet domains': 549,
'New public domain': 548,
'Addresses': 550,
'Common': 551,
'Uncommon': 552,
'No addresses': 553,
'Change CA': 554,
'Address details': 555,
'Private Domains': 556,
'No private domains': 557,
'New private domain': 558
} as const

View File

@@ -90,13 +90,12 @@ export default {
88: 'Acciones',
89: 'no recomendado',
90: '¡CA raíz confiable!',
96: 'Agregar dominio',
96: '',
97: 'Eliminando',
100: 'Cambios no guardados',
101: 'Tienes cambios no guardados. ¿Estás seguro de que deseas salir?',
102: 'Salir',
103: '¿Estás seguro?',
104: 'Seleccionar dominio',
108: 'público',
109: 'privado',
111: 'Sin dominios onion',
@@ -519,11 +518,13 @@ export default {
546: 'Proveedor',
547: '',
548: '',
549: '',
550: '',
551: '',
552: '',
553: '',
554: '',
555: '',
556: '',
557: '',
558: '',
} satisfies i18n

View File

@@ -90,13 +90,12 @@ export default {
88: 'Actions',
89: 'non recommandé',
90: 'Certificat racine approuvé !',
96: 'Ajouter un domaine',
96: '',
97: 'Suppression',
100: 'Modifications non enregistrées',
101: 'Vous avez des modifications non enregistrées. Voulez-vous vraiment quitter ?',
102: 'Quitter',
103: 'Êtes-vous sûr ?',
104: 'Sélectionner un domaine',
108: 'public',
109: 'privé',
111: 'Aucune domaine onion',
@@ -519,11 +518,13 @@ export default {
546: 'Fournisseur',
547: '',
548: '',
549: '',
550: '',
551: '',
552: '',
553: '',
554: '',
555: '',
556: '',
557: '',
558: '',
} satisfies i18n

View File

@@ -90,13 +90,12 @@ export default {
88: 'Akcje',
89: 'niezalecane',
90: 'Główny certyfikat CA zaufany!',
96: 'Dodaj domenę',
96: '',
97: 'Usuwanie',
100: 'Niezapisane zmiany',
101: 'Masz niezapisane zmiany. Czy na pewno chcesz opuścić tę stronę?',
102: 'Opuść',
103: 'Czy jesteś pewien?',
104: 'Wybierz domenę',
108: 'publiczny',
109: 'prywatny',
111: 'Brak domeny onion',
@@ -519,11 +518,13 @@ export default {
546: 'Dostawca',
547: '',
548: '',
549: '',
550: '',
551: '',
552: '',
553: '',
554: '',
555: '',
556: '',
557: '',
558: '',
} satisfies i18n

View File

@@ -1,236 +0,0 @@
import {
ChangeDetectionStrategy,
Component,
computed,
inject,
input,
} from '@angular/core'
import {
DocsLinkDirective,
ErrorService,
i18nPipe,
LoadingService,
} from '@start9labs/shared'
import { TuiButton } from '@taiga-ui/core'
import { TuiSkeleton } from '@taiga-ui/kit'
import { PlaceholderComponent } from 'src/app/routes/portal/components/placeholder.component'
import { TableComponent } from 'src/app/routes/portal/components/table.component'
import { InterfaceClearnetDomainsItemComponent } from './item.component'
import { ClearnetDomain } from '../interface.service'
import { ISB, utils } from '@start9labs/start-sdk'
import { FormDialogService } from 'src/app/services/form-dialog.service'
import { FormComponent } from '../../form.component'
import { configBuilderToSpec } from 'src/app/utils/configBuilderToSpec'
import { PatchDB } from 'patch-db-client'
import { DataModel } from 'src/app/services/patch-db/data-model'
import { toSignal } from '@angular/core/rxjs-interop'
import { map } from 'rxjs'
import { toAuthorityName } from 'src/app/utils/acme'
import { ApiService } from 'src/app/services/api/embassy-api.service'
import { InterfaceComponent } from '../interface.component'
// @TODO translations
@Component({
selector: 'section[clearnetDomains]',
template: `
<header>
{{ 'Clearnet Domains' | i18n }}
<a
tuiIconButton
docsLink
path="/user-manual/connecting-remotely/clearnet.html"
appearance="icon"
iconStart="@tui.external-link"
>
{{ 'Documentation' | i18n }}
</a>
<button
tuiButton
iconStart="@tui.plus"
[style.margin-inline-start]="'auto'"
(click)="add()"
>
{{ 'Add' | i18n }}
</button>
</header>
<table [appTable]="['Domain', 'Certificate Authority', 'Type', null]">
@for (domain of clearnetDomains(); track $index) {
<tr [domain]="domain"></tr>
} @empty {
@if (clearnetDomains()) {
<tr>
<td colspan="4">
<app-placeholder icon="@tui.globe">
{{ 'No clearnet domains' | i18n }}
</app-placeholder>
</td>
</tr>
} @else {
@for (_ of [0, 1]; track $index) {
<tr>
<td colspan="4">
<div [tuiSkeleton]="true">{{ 'Loading' | i18n }}</div>
</td>
</tr>
}
}
}
</table>
`,
styles: `
:host {
grid-column: span 3;
}
`,
host: { class: 'g-card' },
imports: [
TuiButton,
TableComponent,
PlaceholderComponent,
i18nPipe,
DocsLinkDirective,
InterfaceClearnetDomainsItemComponent,
TuiSkeleton,
],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class InterfaceClearnetDomainsComponent {
private readonly formDialog = inject(FormDialogService)
private readonly patch = inject<PatchDB<DataModel>>(PatchDB)
private readonly api = inject(ApiService)
private readonly loader = inject(LoadingService)
private readonly errorService = inject(ErrorService)
private readonly interface = inject(InterfaceComponent)
readonly clearnetDomains = input.required<
readonly ClearnetDomain[] | undefined
>()
private readonly domains = toSignal(
this.patch.watch$('serverInfo', 'network', 'domains'),
)
private readonly acme = toSignal(
this.patch.watch$('serverInfo', 'network', 'acme').pipe(
map(acme =>
Object.keys(acme).reduce<Record<string, string>>(
(obj, url) => ({
...obj,
[url]: toAuthorityName(url),
}),
{ local: toAuthorityName(null) },
),
),
),
)
async add() {
const addSpec = ISB.InputSpec.of({
type: ISB.Value.union({
name: 'Type',
default: 'public',
description:
'- **Public**: the domain can be accessed by anyone with an Internet connection.\n- **Private**: the domain can only be accessed by people connected to the same Local Area Network (LAN) as the server, either physically or via VPN.',
variants: ISB.Variants.of({
public: {
name: 'Public',
spec: ISB.InputSpec.of({
domain: ISB.Value.select({
name: 'Domain',
default: '',
values: Object.keys(this.domains() || {}).reduce<
Record<string, string>
>(
(obj, domain) => ({
...obj,
[domain]: domain,
}),
{},
),
}),
subdomain: ISB.Value.text({
name: 'Subdomain',
description: 'Optionally enter a subdomain',
required: false,
default: null,
patterns: [], // @TODO subdomain pattern
}),
...this.acmeSpec(true),
}),
},
private: {
name: 'Private',
spec: ISB.InputSpec.of({
fqdn: ISB.Value.text({
name: 'Domain',
description:
'Enter a fully qualified domain name. For example, if you control domain.com, you could enter domain.com or subdomain.domain.com or another.subdomain.domain.com.',
required: true,
default: null,
patterns: [utils.Patterns.domain],
}),
...this.acmeSpec(false),
}),
},
}),
}),
})
this.formDialog.open(FormComponent, {
label: 'Add domain',
data: {
spec: await configBuilderToSpec(addSpec),
buttons: [
{
text: 'Save',
handler: async (input: typeof addSpec._TYPE) => {
const loader = this.loader.open('Removing').subscribe()
const type = input.type.selection
const params = {
private: type === 'private',
fqdn:
type === 'public'
? `${input.type.value.subdomain}.${input.type.value.domain}`
: input.type.value.fqdn,
acme:
input.type.value.authority === 'local'
? null
: input.type.value.authority,
}
try {
if (this.interface.packageId()) {
await this.api.pkgAddDomain({
...params,
package: this.interface.packageId(),
host: this.interface.value()?.addressInfo.hostId || '',
})
} else {
await this.api.osUiAddDomain(params)
}
return true
} catch (e: any) {
this.errorService.handleError(e)
return false
} finally {
loader.unsubscribe()
}
},
},
],
},
})
}
private acmeSpec(isPublic: boolean) {
return {
authority: ISB.Value.select({
name: 'Certificate Authority',
description:
'Select the Certificate Authority that will issue the SSL/TLS certificate for this domain',
values: this.acme()!,
default: isPublic ? '' : 'local',
}),
}
}
}

View File

@@ -1,121 +0,0 @@
import {
ChangeDetectionStrategy,
Component,
inject,
input,
} from '@angular/core'
import {
DialogService,
ErrorService,
i18nPipe,
LoadingService,
} from '@start9labs/shared'
import {
TuiButton,
TuiDataList,
TuiDropdown,
TuiTextfield,
} from '@taiga-ui/core'
import { TuiBadge } from '@taiga-ui/kit'
import { filter } from 'rxjs'
import { ApiService } from 'src/app/services/api/embassy-api.service'
import { InterfaceComponent } from '../interface.component'
import { ClearnetDomain } from '../interface.service'
@Component({
selector: 'tr[domain]',
template: `
<td>{{ domain().fqdn }}</td>
<td>{{ domain().authority }}</td>
<td>
@if (domain().public) {
<tui-badge size="s" appearance="primary-success">
{{ 'public' | i18n }}
</tui-badge>
} @else {
<tui-badge size="s" appearance="primary-destructive">
{{ 'private' | i18n }}
</tui-badge>
}
</td>
<td>
<button
tuiIconButton
iconStart="@tui.trash"
appearance="action-destructive"
(click)="remove()"
>
{{ 'Delete' | i18n }}
</button>
</td>
`,
styles: `
:host {
grid-template-columns: min-content 1fr min-content;
}
td:nth-child(2) {
order: -1;
grid-column: span 2;
}
td:last-child {
grid-area: 1 / 3 / 3;
align-self: center;
text-align: right;
}
:host-context(tui-root._mobile) {
tui-badge {
vertical-align: bottom;
margin-inline-start: 0.25rem;
}
}
`,
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [
TuiButton,
TuiDataList,
TuiDropdown,
i18nPipe,
TuiTextfield,
TuiBadge,
],
})
export class InterfaceClearnetDomainsItemComponent {
private readonly dialog = inject(DialogService)
private readonly loader = inject(LoadingService)
private readonly errorService = inject(ErrorService)
private readonly api = inject(ApiService)
private readonly interface = inject(InterfaceComponent)
readonly domain = input.required<ClearnetDomain>()
remove() {
this.dialog
.openConfirm({ label: 'Are you sure?', size: 's' })
.pipe(filter(Boolean))
.subscribe(async () => {
const loader = this.loader.open('Removing').subscribe()
const params = { fqdn: this.domain().fqdn }
try {
if (this.interface.packageId()) {
await this.api.pkgRemoveDomain({
...params,
package: this.interface.packageId(),
host: this.interface.value()?.addressInfo.hostId || '',
})
} else {
await this.api.osUiRemoveDomain(params)
}
return true
} catch (e: any) {
this.errorService.handleError(e)
return false
} finally {
loader.unsubscribe()
}
})
}
}

View File

@@ -3,20 +3,23 @@ import { tuiButtonOptionsProvider } from '@taiga-ui/core'
import { MappedServiceInterface } from './interface.service'
import { InterfaceGatewaysComponent } from './gateways.component'
import { InterfaceTorDomainsComponent } from './tor-domains.component'
import { InterfaceClearnetDomainsComponent } from './clearnet-domains/clearnet-domains.component'
import { PublicDomainsComponent } from './public-domains/pd.component'
import { InterfacePrivateDomainsComponent } from './private-domains.component'
import { InterfaceAddressesComponent } from './addresses/addresses.component'
// @TODO translations
@Component({
selector: 'service-interface',
template: `
<!-- @TODO Alex / Matt translation in all nested components -->
<div [style.display]="'grid'">
<section
[gateways]="value()?.gateways"
[isOs]="!!value()?.isOs"
></section>
<section [torDomains]="value()?.torDomains"></section>
<section [clearnetDomains]="value()?.clearnetDomains"></section>
<section [privateDomains]="value()?.privateDomains"></section>
<section [publicDomains]="value()?.publicDomains"></section>
</div>
<hr [style.width.rem]="10" />
<section [addresses]="value()?.addresses" [isRunning]="true"></section>
@@ -49,7 +52,8 @@ import { InterfaceAddressesComponent } from './addresses/addresses.component'
imports: [
InterfaceGatewaysComponent,
InterfaceTorDomainsComponent,
InterfaceClearnetDomainsComponent,
PublicDomainsComponent,
InterfacePrivateDomainsComponent,
InterfaceAddressesComponent,
],
})

View File

@@ -3,6 +3,7 @@ import { T, utils } from '@start9labs/start-sdk'
import { ConfigService } from 'src/app/services/config.service'
import { toAuthorityName } from 'src/app/utils/acme'
import { GatewayPlus } from 'src/app/services/gateway.service'
import { PublicDomain } from './public-domains/pd.service'
import { i18nKey } from '@start9labs/shared'
type AddressWithInfo = {
@@ -104,7 +105,8 @@ function cmpClearnet(
function toDisplayAddress(
{ info, url }: AddressWithInfo,
gateways: GatewayPlus[],
domains: Record<string, T.DomainConfig>,
publicDomains: Record<string, T.DomainConfig>,
privateDomains: string[],
): DisplayAddress {
let access: DisplayAddress['access']
let gatewayName: DisplayAddress['gatewayName']
@@ -145,13 +147,13 @@ function toDisplayAddress(
const gateway = gateways.find(g => g.id === info.gatewayId)!
gatewayName = gateway.ipInfo.name
const gatewayIpv4 = gateway.ipv4[0]
const gatewayLanIpv4 = gateway.lanIpv4[0]
const isWireguard = gateway.ipInfo.deviceType === 'wireguard'
const localIdeal = 'Ideal for local access'
const lanRequired =
'Requires being connected to the same Local Area Network (LAN) as your server, either physically or via VPN'
const staticRequired = `Requires setting a static IP address for ${gatewayIpv4} in your gateway`
const staticRequired = `Requires setting a static IP address for ${gatewayLanIpv4} in your gateway`
const vpnAccess = 'Ideal for VPN access via your'
// * Local *
@@ -201,18 +203,17 @@ function toDisplayAddress(
// * Domain *
} else {
type = 'Domain'
const domain = domains[info.hostname.value]!
if (info.public) {
access = 'public'
bullets = [
`Requires DNS record(s) for ${domains[info.hostname.value]?.root}, as shown in System -> Domains`,
`Requires a DNS record for ${info.hostname.value} that resolves to ${gateway.ipInfo.wanIp}`,
`Requires port forwarding in gateway "${gatewayName}": ${port} -> ${info.hostname.value}:${port === 443 ? 5443 : port}`,
]
if (domain.acme) {
if (publicDomains[info.hostname.value]!) {
bullets.unshift('Ideal for public access via the Internet')
} else {
bullets = [
'Can be used for personal access via the public Internet. VPN is more secure',
'Can be used for personal access via the public Internet. VPN is more private and secure',
rootCaRequired,
...bullets,
]
@@ -220,24 +221,23 @@ function toDisplayAddress(
} else {
access = 'private'
const ipPortBad = 'when using IP addresses and ports is undesirable'
const customDnsRequired = `Requires DNS record for ${info.hostname.value} that resolve to ${gatewayIpv4}`
const customDnsRequired = `Requires a DNS record for ${info.hostname.value} that resolves to ${gatewayLanIpv4}`
if (isWireguard) {
bullets = [
`${vpnAccess} StartTunnel (or similar) ${ipPortBad}`,
customDnsRequired,
rootCaRequired,
]
} else {
bullets = [
`${localIdeal} ${ipPortBad}`,
`${vpnAccess} router's Wireguard server ${ipPortBad}`,
customDnsRequired,
rootCaRequired,
lanRequired,
staticRequired,
]
}
if (domain.acme) {
bullets.push(rootCaRequired)
}
}
}
}
@@ -251,11 +251,10 @@ function toDisplayAddress(
}
}
export function getClearnetDomains(host: T.Host): ClearnetDomain[] {
return Object.entries(host.domains).map(([fqdn, info]) => ({
export function getPublicDomains(publicDomains: any): PublicDomain[] {
return Object.entries(publicDomains).map(([fqdn, info]) => ({
fqdn,
authority: toAuthorityName(info.acme),
public: info.public,
...info,
}))
}
@@ -305,10 +304,19 @@ export class InterfaceService {
}, [] as AddressWithInfo[])
return {
common: bestAddrs.map(a => toDisplayAddress(a, gateways, host.domains)),
common: bestAddrs.map(a =>
toDisplayAddress(a, gateways, host.publicDomains, host.privateDomains),
),
uncommon: allAddressesWithInfo
.filter(a => !bestAddrs.includes(a))
.map(a => toDisplayAddress(a, gateways, host.domains)),
.map(a =>
toDisplayAddress(
a,
gateways,
host.publicDomains,
host.privateDomains,
),
),
}
}
@@ -453,7 +461,8 @@ export class InterfaceService {
export type MappedServiceInterface = T.ServiceInterface & {
gateways: InterfaceGateway[]
torDomains: string[]
clearnetDomains: ClearnetDomain[]
publicDomains: PublicDomain[]
privateDomains: string[]
addresses: {
common: DisplayAddress[]
uncommon: DisplayAddress[]
@@ -465,12 +474,6 @@ export type InterfaceGateway = GatewayPlus & {
enabled: boolean
}
export type ClearnetDomain = {
fqdn: string
authority: string
public: boolean
}
export type DisplayAddress = {
type: string
access: 'public' | 'private' | null

View File

@@ -0,0 +1,183 @@
import {
ChangeDetectionStrategy,
Component,
inject,
input,
} from '@angular/core'
import {
DialogService,
DocsLinkDirective,
ErrorService,
i18nPipe,
LoadingService,
} from '@start9labs/shared'
import { ISB, utils } from '@start9labs/start-sdk'
import { TuiButton, TuiTitle } from '@taiga-ui/core'
import { TuiSkeleton } from '@taiga-ui/kit'
import { TuiCell } from '@taiga-ui/layout'
import { filter } from 'rxjs'
import {
FormComponent,
FormContext,
} from 'src/app/routes/portal/components/form.component'
import { PlaceholderComponent } from 'src/app/routes/portal/components/placeholder.component'
import { ApiService } from 'src/app/services/api/embassy-api.service'
import { FormDialogService } from 'src/app/services/form-dialog.service'
import { configBuilderToSpec } from 'src/app/utils/configBuilderToSpec'
import { InterfaceComponent } from './interface.component'
// @TODO translations
@Component({
selector: 'section[privateDomains]',
template: `
<header>
{{ 'Private Domains' | i18n }}
<a
tuiIconButton
docsLink
path="/user-manual/connecting-locally.html"
fragment="private-domains"
appearance="icon"
iconStart="@tui.external-link"
>
{{ 'Documentation' | i18n }}
</a>
<button
tuiButton
iconStart="@tui.plus"
[style.margin-inline-start]="'auto'"
(click)="add()"
>
{{ 'Add' | i18n }}
</button>
</header>
@for (domain of privateDomains(); track $index) {
<div tuiCell="s">
<span tuiTitle>{{ domain }}</span>
<button
tuiIconButton
iconStart="@tui.trash"
appearance="action-destructive"
(click)="remove(domain)"
>
{{ 'Delete' | i18n }}
</button>
</div>
} @empty {
@if (privateDomains()) {
<app-placeholder icon="@tui.globe-lock">
{{ 'No private domains' | i18n }}
</app-placeholder>
} @else {
@for (_ of [0, 1]; track $index) {
<label tuiCell="s">
<span tuiTitle [tuiSkeleton]="true">{{ 'Loading' | i18n }}</span>
</label>
}
}
}
`,
styles: `
:host {
grid-column: span 2;
}
`,
host: { class: 'g-card' },
imports: [
TuiCell,
TuiTitle,
TuiButton,
PlaceholderComponent,
i18nPipe,
DocsLinkDirective,
TuiSkeleton,
],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class InterfacePrivateDomainsComponent {
private readonly dialog = inject(DialogService)
private readonly formDialog = inject(FormDialogService)
private readonly loader = inject(LoadingService)
private readonly errorService = inject(ErrorService)
private readonly api = inject(ApiService)
private readonly interface = inject(InterfaceComponent)
private readonly i18n = inject(i18nPipe)
readonly privateDomains = input.required<readonly string[] | undefined>()
async add() {
this.formDialog.open<FormContext<{ fqdn: string }>>(FormComponent, {
label: 'New private domain',
data: {
spec: await configBuilderToSpec(
ISB.InputSpec.of({
fqdn: ISB.Value.text({
name: 'Domain',
description:
'Enter a fully qualified domain name. Since the domain is for private use, it can be any domain you want, even one you do not control.',
required: true,
default: null,
patterns: [utils.Patterns.domain],
}),
}),
),
buttons: [
{
text: this.i18n.transform('Save')!,
handler: async value => this.save(value.fqdn),
},
],
},
})
}
async remove(fqdn: string) {
this.dialog
.openConfirm({ label: 'Are you sure?', size: 's' })
.pipe(filter(Boolean))
.subscribe(async () => {
const loader = this.loader.open('Removing').subscribe()
try {
if (this.interface.packageId()) {
await this.api.pkgRemovePrivateDomain({
fqdn,
package: this.interface.packageId(),
host: this.interface.value()?.addressInfo.hostId || '',
})
} else {
await this.api.osUiRemovePrivateDomain({ fqdn })
}
return true
} catch (e: any) {
this.errorService.handleError(e)
return false
} finally {
loader.unsubscribe()
}
})
}
private async save(fqdn: string): Promise<boolean> {
const loader = this.loader.open('Saving').subscribe()
try {
if (this.interface.packageId) {
await this.api.pkgAddPrivateDomain({
fqdn,
package: this.interface.packageId(),
host: this.interface.value()?.addressInfo.hostId || '',
})
} else {
await this.api.osUiAddPrivateDomain({ fqdn })
}
return true
} catch (e: any) {
this.errorService.handleError(e)
return false
} finally {
loader.unsubscribe()
}
}
}

View File

@@ -0,0 +1,167 @@
import {
ChangeDetectionStrategy,
Component,
computed,
inject,
signal,
} from '@angular/core'
import { FormsModule } from '@angular/forms'
import { ErrorService, i18nPipe } from '@start9labs/shared'
import { TuiButton, TuiDialogContext, TuiIcon } from '@taiga-ui/core'
import {
TuiButtonLoading,
TuiSwitch,
tuiSwitchOptionsProvider,
} 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 { parse } from 'tldts'
import { GatewayWithId } from './pd.service'
// @TODO translations
@Component({
selector: 'dns',
template: `
<p>{{ context.data.message }}</p>
@let wanIp = context.data.gateway.ipInfo.wanIp || ('Error' | i18n);
@if (context.data.gateway.ipInfo.deviceType !== 'wireguard') {
<label>
IP
<input
type="checkbox"
tuiSwitch
[(ngModel)]="ddns"
(ngModelChange)="pass.set(undefined)"
/>
Dynamic DNS
</label>
}
<table [appTable]="['Type', $any('Host'), 'Value', 'Purpose']">
@for (row of rows(); track $index) {
<tr>
<td>
@if (pass() === true) {
<tui-icon class="g-positive" icon="@tui.check" />
} @else if (pass() === false) {
<tui-icon class="g-negative" icon="@tui.x" />
}
{{ ddns ? 'ALIAS' : 'A' }}
</td>
<td>{{ row.host }}</td>
<td>{{ ddns ? '[DDNS Address]' : wanIp }}</td>
<td>{{ row.purpose }}</td>
</tr>
}
</table>
<footer class="g-buttons">
<button tuiButton [loading]="loading()" (click)="testDns()">
{{ 'Test' | i18n }}
</button>
</footer>
`,
styles: `
label {
display: flex;
gap: 0.75rem;
align-items: center;
margin: 1rem 0;
}
tui-icon {
font-size: 1rem;
vertical-align: text-bottom;
}
`,
providers: [
tuiSwitchOptionsProvider({
appearance: () => 'primary',
icon: () => '',
}),
],
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [
TuiButton,
i18nPipe,
TableComponent,
TuiSwitch,
FormsModule,
TuiButtonLoading,
TuiIcon,
],
})
export class DnsComponent {
private readonly errorService = inject(ErrorService)
private readonly api = inject(ApiService)
readonly ddns = false
readonly context =
injectContext<
TuiDialogContext<
void,
{ fqdn: string; gateway: GatewayWithId; message: string }
>
>()
readonly loading = signal(false)
readonly pass = signal<boolean | undefined>(undefined)
readonly rows = computed<{ host: string; purpose: string }[]>(() => {
const { domain, subdomain } = parse(this.context.data.fqdn)
if (!subdomain) {
return [
{
host: '@',
purpose: domain!,
},
]
}
const segments = subdomain.split('.')
return [
{
host: subdomain,
purpose: `only ${subdomain}`,
},
...segments.map((_, i) => {
const parent = segments.slice(i + 1).join('.')
return {
host: `*.${parent}`,
purpose: `subdomains of ${parent}`,
}
}),
{
host: '*',
purpose: `subdomains of ${domain}`,
},
]
})
async testDns() {
this.pass.set(undefined)
this.loading.set(true)
try {
const ip = await this.api.testDns({
fqdn: this.context.data.fqdn,
gateway: this.context.data.gateway.id,
})
this.pass.set(ip === this.context.data.gateway.ipInfo.wanIp)
} catch (e: any) {
this.errorService.handleError(e)
} finally {
this.loading.set(false)
}
}
}
export const DNS = new PolymorpheusComponent(DnsComponent)

View File

@@ -0,0 +1,86 @@
import {
ChangeDetectionStrategy,
Component,
inject,
input,
} from '@angular/core'
import { DocsLinkDirective, i18nPipe } from '@start9labs/shared'
import { TuiButton } from '@taiga-ui/core'
import { TuiSkeleton } from '@taiga-ui/kit'
import { PlaceholderComponent } from 'src/app/routes/portal/components/placeholder.component'
import { TableComponent } from 'src/app/routes/portal/components/table.component'
import { PublicDomainsItemComponent } from './pd.item.component'
import { PublicDomain, PublicDomainService } from './pd.service'
@Component({
selector: 'section[publicDomains]',
template: `
<header>
{{ 'Public Domains' | i18n }}
<a
tuiIconButton
docsLink
path="/user-manual/connecting-remotely/clearnet.html"
appearance="icon"
iconStart="@tui.external-link"
>
{{ 'Documentation' | i18n }}
</a>
@if (service.data()) {
<button
tuiButton
iconStart="@tui.plus"
[style.margin-inline-start]="'auto'"
(click)="service.add()"
>
{{ 'Add' | i18n }}
</button>
}
</header>
<table [appTable]="['Domain', 'Gateway', 'Certificate Authority', null]">
@for (domain of publicDomains(); track $index) {
<tr [domain]="domain"></tr>
} @empty {
@if (publicDomains()) {
<tr>
<td colspan="4">
<app-placeholder icon="@tui.globe">
{{ 'No public domains' | i18n }}
</app-placeholder>
</td>
</tr>
} @else {
@for (_ of [0]; track $index) {
<tr>
<td colspan="4">
<div [tuiSkeleton]="true">{{ 'Loading' | i18n }}</div>
</td>
</tr>
}
}
}
</table>
`,
styles: `
:host {
grid-column: span 3;
}
`,
host: { class: 'g-card' },
providers: [PublicDomainService],
imports: [
TuiButton,
TableComponent,
PlaceholderComponent,
i18nPipe,
DocsLinkDirective,
PublicDomainsItemComponent,
TuiSkeleton,
],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class PublicDomainsComponent {
readonly service = inject(PublicDomainService)
readonly publicDomains = input.required<readonly PublicDomain[] | undefined>()
}

View File

@@ -0,0 +1,109 @@
import {
ChangeDetectionStrategy,
Component,
computed,
inject,
input,
} from '@angular/core'
import { i18nPipe, i18nKey } from '@start9labs/shared'
import {
TuiButton,
TuiDataList,
TuiDropdown,
TuiTextfield,
} from '@taiga-ui/core'
import { PublicDomain, PublicDomainService } from './pd.service'
import { toAuthorityName } from 'src/app/utils/acme'
@Component({
selector: 'tr[domain]',
template: `
<td>{{ domain().fqdn }}</td>
<td>{{ domain().gateway }}</td>
<td>{{ authority() }}</td>
<td>
<button
tuiIconButton
tuiDropdown
size="s"
appearance="flat-grayscale"
iconStart="@tui.ellipsis-vertical"
[tuiAppearanceState]="open ? 'hover' : null"
[(tuiDropdownOpen)]="open"
>
{{ 'More' | i18n }}
<tui-data-list *tuiTextfieldDropdown>
<tui-opt-group>
<button
tuiOption
new
iconStart="@tui.eye"
(click)="
service.showDns(domain().fqdn, domain().gateway, dnsMessage())
"
>
{{ 'View DNS' | i18n }}
</button>
<button
tuiOption
new
iconStart="@tui.pencil"
(click)="service.edit(domain())"
>
{{ 'Edit' | i18n }}
</button>
</tui-opt-group>
<tui-opt-group>
<button
tuiOption
new
iconStart="@tui.trash"
class="g-negative"
(click)="service.remove(domain().fqdn)"
>
{{ 'Delete' | i18n }}
</button>
</tui-opt-group>
</tui-data-list>
</button>
</td>
`,
styles: `
:host {
grid-template-columns: min-content 1fr min-content;
}
td:nth-child(2) {
order: -1;
grid-column: span 2;
}
td:last-child {
grid-area: 1 / 3 / 3;
align-self: center;
text-align: right;
}
:host-context(tui-root._mobile) {
tui-badge {
vertical-align: bottom;
margin-inline-start: 0.25rem;
}
}
`,
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [TuiButton, TuiDataList, TuiDropdown, i18nPipe, TuiTextfield],
})
export class PublicDomainsItemComponent {
protected readonly service = inject(PublicDomainService)
open = false
readonly domain = input.required<PublicDomain>()
readonly authority = computed(() => toAuthorityName(this.domain().acme))
readonly dnsMessage = computed<i18nKey>(
() =>
`Create one of the DNS records below to cause ${this.domain().fqdn} to resolve to ${this.domain().gateway.ipInfo.wanIp}` as i18nKey,
)
}

View File

@@ -0,0 +1,253 @@
import { inject, Injectable } from '@angular/core'
import {
DialogService,
ErrorService,
i18nKey,
LoadingService,
} from '@start9labs/shared'
import { toSignal } from '@angular/core/rxjs-interop'
import { ISB, T, utils } from '@start9labs/start-sdk'
import { filter, map } from 'rxjs'
import { FormComponent } from 'src/app/routes/portal/components/form.component'
import { ApiService } from 'src/app/services/api/embassy-api.service'
import { FormDialogService } from 'src/app/services/form-dialog.service'
import { configBuilderToSpec } from 'src/app/utils/configBuilderToSpec'
import { PatchDB } from 'patch-db-client'
import { DataModel } from 'src/app/services/patch-db/data-model'
import { toAuthorityName } from 'src/app/utils/acme'
import { GatewayPlus } from 'src/app/services/gateway.service'
import { InterfaceComponent } from '../interface.component'
import { DNS } from './dns.component'
// @TODO translations
export type PublicDomain = {
fqdn: string
gateway: GatewayPlus
acme: string | null
}
export type GatewayWithId = T.NetworkInterfaceInfo & {
id: string
ipInfo: T.IpInfo
}
@Injectable()
export class PublicDomainService {
private readonly patch = inject<PatchDB<DataModel>>(PatchDB)
private readonly loader = inject(LoadingService)
private readonly errorService = inject(ErrorService)
private readonly api = inject(ApiService)
private readonly formDialog = inject(FormDialogService)
private readonly dialog = inject(DialogService)
private readonly interface = inject(InterfaceComponent)
readonly data = toSignal(
this.patch.watch$('serverInfo', 'network').pipe(
map(({ gateways, acme }) => ({
gateways: Object.entries(gateways)
.filter(([_, g]) => g.ipInfo)
.map(([id, g]) => ({ id, ...g })) as GatewayWithId[],
authorities: Object.keys(acme).reduce<Record<string, string>>(
(obj, url) => ({
...obj,
[url]: toAuthorityName(url),
}),
{ local: toAuthorityName(null) },
),
})),
),
)
async add() {
const addSpec = ISB.InputSpec.of({
fqdn: ISB.Value.text({
name: 'Domain',
description:
'Enter a fully qualified domain name. For example, if you control domain.com, you could enter domain.com or subdomain.domain.com or another.subdomain.domain.com.',
required: true,
default: null,
patterns: [utils.Patterns.domain],
}),
...this.gatewayAndAuthoritySpec(),
})
this.formDialog.open(FormComponent, {
label: 'Add public domain',
data: {
spec: await configBuilderToSpec(addSpec),
buttons: [
{
text: 'Save',
handler: (input: typeof addSpec._TYPE) =>
this.save(input.fqdn, input.gateway, input.authority),
},
],
},
})
}
async edit(domain: PublicDomain) {
const editSpec = ISB.InputSpec.of({
...this.gatewayAndAuthoritySpec(),
})
this.formDialog.open(FormComponent, {
label: 'Edit public domain',
data: {
spec: await configBuilderToSpec(editSpec),
buttons: [
{
text: 'Save',
handler: ({ gateway, authority }: typeof editSpec._TYPE) =>
this.save(domain.fqdn, gateway, authority),
},
],
value: {
gateway: domain.gateway.id,
authority: domain.acme,
},
},
})
}
remove(fqdn: string) {
this.dialog
.openConfirm({ label: 'Are you sure?', size: 's' })
.pipe(filter(Boolean))
.subscribe(async () => {
const loader = this.loader.open('Deleting').subscribe()
try {
if (this.interface.packageId()) {
await this.api.pkgRemovePublicDomain({
fqdn,
package: this.interface.packageId(),
host: this.interface.value()?.addressInfo.hostId || '',
})
} else {
await this.api.osUiRemovePublicDomain({ fqdn })
}
return true
} catch (e: any) {
this.errorService.handleError(e)
return false
} finally {
loader.unsubscribe()
}
})
}
showDns(fqdn: string, gateway: GatewayWithId, message: i18nKey) {
this.dialog
.openComponent(DNS, {
label: 'DNS Records' as i18nKey,
size: 'l',
data: {
fqdn,
gateway,
message,
},
})
.subscribe()
}
private async save(
fqdn: string,
gatewayId: string,
authority: 'local' | string,
) {
const gateway = this.data()!.gateways.find(g => (g.id = gatewayId))!
const loader = this.loader.open('Saving').subscribe()
const params = {
fqdn,
gateway: gatewayId,
acme: authority === 'local' ? null : authority,
}
try {
let ip: string | null
if (this.interface.packageId()) {
ip = await this.api.pkgAddPublicDomain({
...params,
package: this.interface.packageId(),
host: this.interface.value()?.addressInfo.hostId || '',
})
} else {
ip = await this.api.osUiAddPublicDomain(params)
}
const wanIp = gateway.ipInfo.wanIp
let message = `Create one of the DNS records below to cause ${fqdn} to resolve to ${wanIp}`
if (!ip) {
setTimeout(
() =>
this.showDns(
fqdn,
gateway,
`No DNS detected for ${fqdn}. ${message}` as i18nKey,
),
250,
)
} else if (ip === wanIp) {
setTimeout(
() =>
this.showDns(
fqdn,
gateway,
`Invalid DNS. ${fqdn} is currently resolving to ${ip}. ${message}` as i18nKey,
),
250,
)
} else {
setTimeout(
() =>
this.dialog.openAlert(
`${fqdn} is successfully resolving to ${wanIp}` as i18nKey,
{ label: 'DNS detected!' as i18nKey, appearance: 'positive' },
),
250,
)
}
return true
} catch (e: any) {
this.errorService.handleError(e)
return false
} finally {
loader.unsubscribe()
}
}
private gatewayAndAuthoritySpec() {
const data = this.data()!
return {
gateway: ISB.Value.dynamicSelect(() => ({
name: 'Gateway',
description: 'Select a gateway to use for this domain.',
values: data.gateways.reduce<Record<string, string>>(
(obj, gateway) => ({
...obj,
[gateway.id]: gateway.ipInfo!.name,
}),
{},
),
default: '',
disabled: data.gateways
.filter(
g => !g.ipInfo.wanIp || g.ipInfo.wanIp.split('.').at(-1) === '100',
)
.map(g => g.id),
})),
authority: ISB.Value.select({
name: 'Certificate Authority',
description:
'Select the Certificate Authority that will issue the SSL/TLS certificate for this domain',
values: data.authorities,
default: '',
}),
}
}
}

View File

@@ -156,19 +156,19 @@ export class InterfaceTorDomainsComponent {
buttons: [
{
text: this.i18n.transform('Save')!,
handler: async value => this.save(value),
handler: async value => this.save(value.key),
},
],
},
})
}
private async save(form: OnionForm): Promise<boolean> {
private async save(key?: string): Promise<boolean> {
const loader = this.loader.open('Saving').subscribe()
try {
let onion = form.key
? await this.api.addTorKey({ key: form.key })
let onion = key
? await this.api.addTorKey({ key })
: await this.api.generateTorKey({})
onion = `${onion}.onion`

View File

@@ -18,7 +18,7 @@ import { InterfaceComponent } from 'src/app/routes/portal/components/interfaces/
import { DataModel } from 'src/app/services/patch-db/data-model'
import { TitleDirective } from 'src/app/services/title.service'
import {
getClearnetDomains,
getPublicDomains,
InterfaceService,
} from '../../../components/interfaces/interface.service'
import { GatewayService } from 'src/app/services/gateway.service'
@@ -143,7 +143,8 @@ export default class ServiceInterfaceRoute {
...g,
})) || [],
torDomains: host.onions.map(o => `${o}.onion`),
clearnetDomains: getClearnetDomains(host),
publicDomains: getPublicDomains(host.domains.public),
privateDomains: host.domains.private,
isOs: false,
}
})

View File

@@ -1,195 +0,0 @@
import { NgTemplateOutlet } from '@angular/common'
import {
ChangeDetectionStrategy,
Component,
computed,
inject,
signal,
} from '@angular/core'
import { FormsModule } from '@angular/forms'
import { ErrorService, i18nKey, i18nPipe } from '@start9labs/shared'
import { TuiButton, TuiDialogContext, TuiIcon } from '@taiga-ui/core'
import {
TuiButtonLoading,
TuiSwitch,
tuiSwitchOptionsProvider,
} 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 { parse } from 'tldts'
import { MappedDomain } from './domain.service'
// @TODO translations
@Component({
selector: 'dns',
template: `
@let wanIp = context.data.gateway.ipInfo?.wanIp || ('Error' | i18n);
@if (context.data.gateway.ipInfo?.deviceType !== 'wireguard') {
<label>
IP
<input
type="checkbox"
tuiSwitch
[(ngModel)]="ddns"
(ngModelChange)="reset()"
/>
Dynamic DNS
</label>
}
<table [appTable]="['Type', $any('Host'), 'Value', 'Purpose']">
@if (ddns) {
<tr>
<td>
@if (root() !== undefined; as $implicit) {
<ng-container
[ngTemplateOutlet]="test"
[ngTemplateOutletContext]="{ $implicit }"
/>
}
ALIAS
</td>
<td>{{ subdomain() || '@' }}</td>
<td>[DDNS Address]</td>
<td>{{ purpose().root }}</td>
</tr>
<tr>
<td>
@if (wildcard() !== undefined; as $implicit) {
<ng-container
[ngTemplateOutlet]="test"
[ngTemplateOutletContext]="{ $implicit }"
/>
}
ALIAS
</td>
<td>{{ subdomain() ? '*.' + subdomain() : '*' }}</td>
<td>[DDNS Address]</td>
<td>{{ purpose().wildcard }}</td>
</tr>
} @else {
<tr>
<td>
@if (root() !== undefined; as $implicit) {
<ng-container
[ngTemplateOutlet]="test"
[ngTemplateOutletContext]="{ $implicit }"
/>
}
A
</td>
<td>{{ subdomain() || '@' }}</td>
<td>{{ wanIp }}</td>
<td>{{ purpose().root }}</td>
</tr>
<tr>
<td>
@if (wildcard() !== undefined; as $implicit) {
<ng-container
[ngTemplateOutlet]="test"
[ngTemplateOutletContext]="{ $implicit }"
/>
}
A
</td>
<td>{{ subdomain() ? '*.' + subdomain() : '*' }}</td>
<td>{{ wanIp }}</td>
<td>{{ purpose().wildcard }}</td>
</tr>
}
</table>
<ng-template #test let-result>
@if (result) {
<tui-icon class="g-positive" icon="@tui.check" />
} @else {
<tui-icon class="g-negative" icon="@tui.x" />
}
</ng-template>
<footer class="g-buttons">
<button tuiButton [loading]="loading()" (click)="testDns()">
{{ 'Test' | i18n }}
</button>
</footer>
`,
styles: `
label {
display: flex;
gap: 0.75rem;
align-items: center;
margin: 1rem 0;
}
tui-icon {
font-size: 1rem;
vertical-align: text-bottom;
}
`,
providers: [
tuiSwitchOptionsProvider({
appearance: () => 'primary',
icon: () => '',
}),
],
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [
TuiButton,
i18nPipe,
TableComponent,
TuiSwitch,
FormsModule,
TuiButtonLoading,
NgTemplateOutlet,
TuiIcon,
],
})
export class DnsComponent {
private readonly errorService = inject(ErrorService)
private readonly api = inject(ApiService)
ddns = false
readonly context = injectContext<TuiDialogContext<void, MappedDomain>>()
readonly subdomain = computed(() => parse(this.context.data.fqdn).subdomain)
readonly loading = signal(false)
readonly root = signal<boolean | undefined>(undefined)
readonly wildcard = signal<boolean | undefined>(undefined)
readonly purpose = computed(() => ({
root: this.context.data.fqdn,
wildcard: `subdomains of ${this.context.data.fqdn}`,
}))
async testDns() {
this.reset()
this.loading.set(true)
try {
await this.api
.testDomain({
fqdn: this.context.data.fqdn,
gateway: this.context.data.gateway.id,
})
.then(({ root, wildcard }) => {
this.root.set(root)
this.wildcard.set(wildcard)
})
} catch (e: any) {
this.errorService.handleError(e)
} finally {
this.loading.set(false)
}
}
reset() {
this.root.set(undefined)
this.wildcard.set(undefined)
}
}
export const DNS = new PolymorpheusComponent(DnsComponent)

View File

@@ -1,188 +0,0 @@
import { inject, Injectable } from '@angular/core'
import {
DialogService,
ErrorService,
i18nKey,
i18nPipe,
LoadingService,
} from '@start9labs/shared'
import { toSignal } from '@angular/core/rxjs-interop'
import { ISB, T, utils } from '@start9labs/start-sdk'
import { filter, map } from 'rxjs'
import { FormComponent } from 'src/app/routes/portal/components/form.component'
import { ApiService } from 'src/app/services/api/embassy-api.service'
import { FormDialogService } from 'src/app/services/form-dialog.service'
import { configBuilderToSpec } from 'src/app/utils/configBuilderToSpec'
import { PatchDB } from 'patch-db-client'
import { DataModel } from 'src/app/services/patch-db/data-model'
import { RR } from 'src/app/services/api/api.types'
import { DNS } from './dns.component'
// @TODO translations
export type MappedDomain = {
fqdn: string
gateway: {
id: string
name: string | null
ipInfo: T.IpInfo | null
}
}
type GatewayWithId = T.NetworkInterfaceInfo & {
id: string
ipInfo: T.IpInfo & {
wanIp: string
}
}
@Injectable()
export class DomainService {
private readonly patch = inject<PatchDB<DataModel>>(PatchDB)
private readonly loader = inject(LoadingService)
private readonly errorService = inject(ErrorService)
private readonly api = inject(ApiService)
private readonly formDialog = inject(FormDialogService)
private readonly i18n = inject(i18nPipe)
private readonly dialog = inject(DialogService)
readonly data = toSignal(
this.patch.watch$('serverInfo', 'network').pipe(
map(({ gateways, domains }) => ({
gateways: Object.entries(gateways)
.filter(([_, g]) => g.ipInfo && g.ipInfo.wanIp)
.map(([id, g]) => ({ id, ...g })) as GatewayWithId[],
domains: Object.entries(domains).map(
([fqdn, { gateway }]) =>
({
fqdn,
gateway: {
id: gateway,
ipInfo: gateways[gateway]?.ipInfo || null,
},
}) as MappedDomain,
),
})),
),
)
async add() {
const addSpec = ISB.InputSpec.of({
fqdn: ISB.Value.text({
name: 'Domain',
description:
'Enter a fully qualified domain name. For example, if you control domain.com, you could enter domain.com or subdomain.domain.com or another.subdomain.domain.com. In any case, the domain you enter and all possible subdomains of the domain will be available for assignment in StartOS',
required: true,
default: null,
patterns: [utils.Patterns.domain],
}),
...this.gatewaysSpec(),
})
this.formDialog.open(FormComponent, {
label: 'Add domain',
data: {
spec: await configBuilderToSpec(addSpec),
buttons: [
{
text: 'Save',
handler: (input: typeof addSpec._TYPE) =>
this.save({
fqdn: input.fqdn,
gateway: input.gateway,
}),
},
],
},
})
}
async edit(domain: MappedDomain) {
const editSpec = ISB.InputSpec.of({
...this.gatewaysSpec(),
})
this.formDialog.open(FormComponent, {
label: 'Edit domain',
data: {
spec: await configBuilderToSpec(editSpec),
buttons: [
{
text: 'Save',
handler: (input: typeof editSpec._TYPE) =>
this.save({
fqdn: domain.fqdn,
gateway: input.gateway,
}),
},
],
value: {
gateway: domain.gateway.id,
},
},
})
}
remove(fqdn: string) {
this.dialog
.openConfirm({ label: 'Are you sure?', size: 's' })
.pipe(filter(Boolean))
.subscribe(async () => {
const loader = this.loader.open('Deleting').subscribe()
try {
await this.api.removeDomain({ fqdn })
} catch (e: any) {
this.errorService.handleError(e)
} finally {
loader.unsubscribe()
}
})
}
showDns(domain: MappedDomain) {
this.dialog
.openComponent(DNS, {
label: 'DNS Records' as i18nKey,
size: 'l',
data: domain,
})
.subscribe()
}
private async save(params: RR.AddDomainReq) {
const loader = this.loader.open('Saving').subscribe()
try {
await this.api.addDomain(params)
return true
} catch (e: any) {
this.errorService.handleError(e)
return false
} finally {
loader.unsubscribe()
}
}
private gatewaysSpec() {
const gateways = this.data()?.gateways || []
return {
gateway: ISB.Value.dynamicSelect(() => ({
name: 'Gateway',
description: 'Select a gateway to use for this domain.',
values: gateways.reduce<Record<string, string>>(
(obj, gateway) => ({
...obj,
[gateway.id]: gateway.ipInfo!.name,
}),
{},
),
default: '',
disabled: gateways
.filter(g => g.ipInfo.wanIp.split('.').at(-1) === '100')
.map(g => g.id),
})),
}
}
}

View File

@@ -1,64 +0,0 @@
import { ChangeDetectionStrategy, Component, inject } from '@angular/core'
import { RouterLink } from '@angular/router'
import { DocsLinkDirective, i18nPipe } from '@start9labs/shared'
import { TuiButton } from '@taiga-ui/core'
import { TitleDirective } from 'src/app/services/title.service'
import { DomainService } from './domain.service'
import { DomainsTableComponent } from './table.component'
@Component({
template: `
<ng-container *title>
<a routerLink=".." tuiIconButton iconStart="@tui.arrow-left">
{{ 'Back' | i18n }}
</a>
{{ 'Public Domains' | i18n }}
</ng-container>
<section class="g-card">
<header>
{{ 'Public Domains' | i18n }}
<a
tuiIconButton
size="xs"
docsLink
path="/user-manual/domains.html"
appearance="icon"
iconStart="@tui.external-link"
>
{{ 'Documentation' | i18n }}
</a>
@if (domainService.data(); as value) {
<button
tuiButton
size="xs"
iconStart="@tui.plus"
[style.margin-inline-start]="'auto'"
(click)="domainService.add()"
>
Add
</button>
}
</header>
<domains-table />
</section>
`,
styles: `
:host {
max-width: 48rem;
}
`,
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [
TuiButton,
RouterLink,
TitleDirective,
i18nPipe,
DocsLinkDirective,
DomainsTableComponent,
],
providers: [DomainService],
})
export default class SystemDomainsComponent {
protected readonly domainService = inject(DomainService)
}

View File

@@ -1,88 +0,0 @@
import {
ChangeDetectionStrategy,
Component,
inject,
input,
} from '@angular/core'
import { i18nPipe } from '@start9labs/shared'
import {
TuiButton,
TuiDataList,
TuiDropdown,
TuiTextfield,
} from '@taiga-ui/core'
import { DomainService, MappedDomain } from './domain.service'
@Component({
selector: 'tr[domain]',
template: `
@if (domain(); as domain) {
<td>{{ domain.fqdn }}</td>
<td>{{ domain.gateway.ipInfo?.name || '-' }}</td>
<td>
<button
tuiIconButton
tuiDropdown
size="s"
appearance="flat-grayscale"
iconStart="@tui.ellipsis-vertical"
[tuiAppearanceState]="open ? 'hover' : null"
[(tuiDropdownOpen)]="open"
>
{{ 'More' | i18n }}
<tui-data-list *tuiTextfieldDropdown>
<tui-opt-group>
<button
tuiOption
new
iconStart="@tui.eye"
(click)="domainService.showDns(domain)"
>
{{ 'View DNS' | i18n }}
</button>
<button
tuiOption
new
iconStart="@tui.pencil"
(click)="domainService.edit(domain)"
>
{{ 'Edit' | i18n }}
</button>
</tui-opt-group>
<tui-opt-group>
<button
tuiOption
new
iconStart="@tui.trash"
class="g-negative"
(click)="domainService.remove(domain.fqdn)"
>
{{ 'Delete' | i18n }}
</button>
</tui-opt-group>
</tui-data-list>
</button>
</td>
}
`,
styles: `
td:last-child {
grid-area: 1 / 2 / 4;
align-self: center;
text-align: right;
}
:host-context(tui-root._mobile) {
grid-template-columns: 1fr min-content;
}
`,
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [TuiButton, i18nPipe, TuiDropdown, TuiDataList, TuiTextfield],
})
export class DomainItemComponent {
protected readonly domainService = inject(DomainService)
readonly domain = input.required<MappedDomain>()
open = false
}

View File

@@ -1,41 +0,0 @@
import { ChangeDetectionStrategy, Component, inject } from '@angular/core'
import { i18nPipe } from '@start9labs/shared'
import { TuiSkeleton } from '@taiga-ui/kit'
import { PlaceholderComponent } from 'src/app/routes/portal/components/placeholder.component'
import { TableComponent } from 'src/app/routes/portal/components/table.component'
import { DomainItemComponent } from './item.component'
import { DomainService } from './domain.service'
@Component({
selector: 'domains-table',
template: `
<table [appTable]="['Domain', 'Gateway', null]">
@for (domain of domainService.data()?.domains; track $index) {
<tr [domain]="domain"></tr>
} @empty {
<tr>
<td [attr.colspan]="3">
@if (domainService.data()?.domains) {
<app-placeholder icon="@tui.globe">
{{ 'No public domains' | i18n }}
</app-placeholder>
} @else {
<div [tuiSkeleton]="true">{{ 'Loading' | i18n }}</div>
}
</td>
</tr>
}
</table>
`,
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [
TuiSkeleton,
i18nPipe,
TableComponent,
PlaceholderComponent,
DomainItemComponent,
],
})
export class DomainsTableComponent {
protected readonly domainService = inject(DomainService)
}

View File

@@ -41,7 +41,7 @@ import { GatewayPlus } from 'src/app/services/gateway.service'
-
}
</td>
<td class="lan">{{ gateway.ipv4.join(', ') }}</td>
<td class="lan">{{ gateway.lanIpv4.join(', ') }}</td>
<td
class="wan"
[style.color]="

View File

@@ -11,10 +11,9 @@ import { T } from '@start9labs/start-sdk'
import { TuiButton, TuiTitle } from '@taiga-ui/core'
import { TuiHeader } from '@taiga-ui/layout'
import { PatchDB } from 'patch-db-client'
import { map } from 'rxjs'
import { InterfaceComponent } from 'src/app/routes/portal/components/interfaces/interface.component'
import {
getClearnetDomains,
getPublicDomains,
InterfaceService,
} from 'src/app/routes/portal/components/interfaces/interface.service'
import { GatewayService } from 'src/app/services/gateway.service'
@@ -98,7 +97,8 @@ export default class StartOsUiComponent {
...g,
})),
torDomains: network.host.onions.map(o => `${o}.onion`),
clearnetDomains: getClearnetDomains(network.host),
publicDomains: getPublicDomains(network.host.domains.public),
privateDomains: network.host.domains.private,
isOs: true,
}
})

View File

@@ -46,11 +46,6 @@ export const SYSTEM_MENU = [
item: 'Certificate Authorities',
link: 'authorities',
},
{
icon: '@tui.globe',
item: 'Public Domains',
link: 'domains',
},
],
[
{

View File

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

View File

@@ -2070,7 +2070,10 @@ export namespace Mock {
},
},
onions: [],
domains: {},
domains: {
public: {},
private: {},
},
hostnameInfo: {
80: [
{
@@ -2170,7 +2173,10 @@ export namespace Mock {
},
},
onions: [],
domains: {},
domains: {
public: {},
private: {},
},
hostnameInfo: {
8332: [],
},
@@ -2193,7 +2199,10 @@ export namespace Mock {
},
},
onions: [],
domains: {},
domains: {
public: {},
private: {},
},
hostnameInfo: {
8333: [],
},

View File

@@ -104,6 +104,12 @@ export namespace RR {
export type DiskRepairReq = {} // server.disk.repair
export type DiskRepairRes = null
export type TestDnsReq = {
fqdn: string
gateway: T.GatewayId // string
} // net.dns.test
export type TestDnsRes = string | null
export type ResetTorReq = {
wipeState: boolean
reason: string
@@ -236,26 +242,6 @@ export namespace RR {
// network
export type AddDomainReq = {
fqdn: string
gateway: string
} // net.domain.add
export type AddDomainRes = null
export type RemoveDomainReq = {
fqdn: string
} // net.domain.remove
export type RemoveDomainRes = null
export type TestDomainReq = {
fqdn: string
gateway: string
} // net.domain.test-dns
export type TestDomainRes = {
root: boolean
wildcard: boolean
}
export type AddTunnelReq = {
name: string
config: string // file contents
@@ -309,19 +295,31 @@ export namespace RR {
export type ServerRemoveOnionReq = ServerAddOnionReq // server.host.address.onion.remove
export type RemoveOnionRes = null
export type OsUiAddDomainReq = {
// server.host.address.domain.add
export type OsUiAddPublicDomainReq = {
// server.host.address.domain.public.add
fqdn: string // FQDN
private: boolean
gateway: T.GatewayId
acme: string | null // URL. null means local Root CA
}
export type OsUiAddDomainRes = null
export type OsUiAddPublicDomainRes = TestDnsRes
export type OsUiRemoveDomainReq = {
// server.host.address.domain.remove
export type OsUiRemovePublicDomainReq = {
// server.host.address.domain.public.remove
fqdn: string // FQDN
}
export type OsUiRemoveDomainRes = null
export type OsUiRemovePublicDomainRes = null
export type OsUiAddPrivateDomainReq = {
// server.host.address.domain.private.add
fqdn: string // FQDN
}
export type OsUiAddPrivateDomainRes = null
export type OsUiRemovePrivateDomainReq = {
// server.host.address.domain.private.remove
fqdn: string // FQDN
}
export type OsUiRemovePrivateDomainRes = null
export type PkgBindingToggleGatewayReq = ServerBindingToggleGatewayReq & {
// package.host.binding.set-gateway-enabled
@@ -336,22 +334,31 @@ export namespace RR {
package: T.PackageId // string
host: T.HostId // string
}
export type PkgRemoveOnionReq = PkgAddOnionReq // package.host.address.onion.remove
export type PkgAddDomainReq = OsUiAddDomainReq & {
// package.host.address.domain.add
export type PkgAddPublicDomainReq = OsUiAddPublicDomainReq & {
// package.host.address.domain.public.add
package: T.PackageId // string
host: T.HostId // string
}
export type PkgAddDomainRes = null
export type PkgAddPublicDomainRes = OsUiAddPublicDomainRes
export type PkgRemoveDomainReq = OsUiRemoveDomainReq & {
// package.host.address.domain.remove
export type PkgRemovePublicDomainReq = OsUiRemovePublicDomainReq & {
// package.host.address.domain.public.remove
package: T.PackageId // string
host: T.HostId // string
}
export type PkgRemoveDomainRes = null
export type PkgRemovePublicDomainRes = OsUiRemovePublicDomainRes
export type PkgAddPrivateDomainReq = OsUiAddPrivateDomainReq & {
// package.host.address.domain.private.add
package: T.PackageId // string
host: T.HostId // string
}
export type PkgAddPrivateDomainRes = OsUiAddPrivateDomainRes
export type PkgRemovePrivateDomainReq = PkgAddPrivateDomainReq
export type PkgRemovePrivateDomainRes = OsUiRemovePrivateDomainRes
export type GetPackageLogsReq = FetchLogsReq & { id: string } // package.logs
export type GetPackageLogsRes = FetchLogsRes

View File

@@ -122,9 +122,9 @@ export abstract class ApiService {
abstract toggleKiosk(enable: boolean): Promise<null>
abstract resetTor(params: RR.ResetTorReq): Promise<RR.ResetTorRes>
abstract testDns(params: RR.TestDnsReq): Promise<RR.TestDnsRes>
// @TODO 041
abstract resetTor(params: RR.ResetTorReq): Promise<RR.ResetTorRes>
// smtp
@@ -182,18 +182,8 @@ export abstract class ApiService {
abstract removeTunnel(params: RR.RemoveTunnelReq): Promise<RR.RemoveTunnelRes>
// @TODO 041
// ** domains **
// @TODO 041
abstract addDomain(params: RR.AddDomainReq): Promise<RR.AddDomainRes>
abstract removeDomain(params: RR.RemoveDomainReq): Promise<RR.RemoveDomainRes>
abstract testDomain(params: RR.TestDomainReq): Promise<RR.TestDomainRes>
// wifi
abstract enableWifi(params: RR.EnabledWifiReq): Promise<RR.EnabledWifiRes>
@@ -369,13 +359,21 @@ export abstract class ApiService {
params: RR.ServerRemoveOnionReq,
): Promise<RR.RemoveOnionRes>
abstract osUiAddDomain(
params: RR.OsUiAddDomainReq,
): Promise<RR.OsUiAddDomainRes>
abstract osUiAddPublicDomain(
params: RR.OsUiAddPublicDomainReq,
): Promise<RR.OsUiAddPublicDomainRes>
abstract osUiRemoveDomain(
params: RR.OsUiRemoveDomainReq,
): Promise<RR.OsUiRemoveDomainRes>
abstract osUiRemovePublicDomain(
params: RR.OsUiRemovePublicDomainReq,
): Promise<RR.OsUiRemovePublicDomainRes>
abstract osUiAddPrivateDomain(
params: RR.OsUiAddPrivateDomainReq,
): Promise<RR.OsUiAddPrivateDomainRes>
abstract osUiRemovePrivateDomain(
params: RR.OsUiRemovePrivateDomainReq,
): Promise<RR.OsUiRemovePrivateDomainRes>
abstract pkgBindingToggleGateway(
params: RR.PkgBindingToggleGatewayReq,
@@ -387,9 +385,19 @@ export abstract class ApiService {
params: RR.PkgRemoveOnionReq,
): Promise<RR.RemoveOnionRes>
abstract pkgAddDomain(params: RR.PkgAddDomainReq): Promise<RR.PkgAddDomainRes>
abstract pkgAddPublicDomain(
params: RR.PkgAddPublicDomainReq,
): Promise<RR.PkgAddPublicDomainRes>
abstract pkgRemoveDomain(
params: RR.PkgRemoveDomainReq,
): Promise<RR.PkgRemoveDomainRes>
abstract pkgRemovePublicDomain(
params: RR.PkgRemovePublicDomainReq,
): Promise<RR.PkgRemovePublicDomainRes>
abstract pkgAddPrivateDomain(
params: RR.PkgAddPrivateDomainReq,
): Promise<RR.PkgAddPrivateDomainRes>
abstract pkgRemovePrivateDomain(
params: RR.PkgRemovePrivateDomainReq,
): Promise<RR.PkgRemovePrivateDomainRes>
}

View File

@@ -267,6 +267,13 @@ export class LiveApiService extends ApiService {
})
}
async testDns(params: RR.TestDnsReq): Promise<RR.TestDnsRes> {
return this.rpcRequest({
method: 'net.dns.test',
params,
})
}
async resetTor(params: RR.ResetTorReq): Promise<RR.ResetTorRes> {
return this.rpcRequest({ method: 'net.tor.reset', params })
}
@@ -358,20 +365,6 @@ export class LiveApiService extends ApiService {
return this.rpcRequest({ method: 'net.tunnel.remove', params })
}
// domains
async addDomain(params: RR.AddDomainReq): Promise<RR.AddDomainRes> {
return this.rpcRequest({ method: 'net.domain.add', params })
}
async removeDomain(params: RR.RemoveDomainReq): Promise<RR.RemoveDomainRes> {
return this.rpcRequest({ method: 'net.domain.remove', params })
}
async testDomain(params: RR.TestDomainReq): Promise<RR.TestDomainRes> {
return this.rpcRequest({ method: 'net.domain.test-dns', params })
}
// wifi
async enableWifi(params: RR.EnabledWifiReq): Promise<RR.EnabledWifiRes> {
@@ -663,20 +656,38 @@ export class LiveApiService extends ApiService {
})
}
async osUiAddDomain(
params: RR.OsUiAddDomainReq,
): Promise<RR.OsUiAddDomainRes> {
async osUiAddPublicDomain(
params: RR.OsUiAddPublicDomainReq,
): Promise<RR.OsUiAddPublicDomainRes> {
return this.rpcRequest({
method: 'server.host.address.domain.add',
method: 'server.host.address.domain.public.add',
params,
})
}
async osUiRemoveDomain(
params: RR.OsUiRemoveDomainReq,
): Promise<RR.OsUiRemoveDomainRes> {
async osUiRemovePublicDomain(
params: RR.OsUiRemovePublicDomainReq,
): Promise<RR.OsUiRemovePublicDomainRes> {
return this.rpcRequest({
method: 'server.host.address.domain.remove',
method: 'server.host.address.domain.public.remove',
params,
})
}
async osUiAddPrivateDomain(
params: RR.OsUiAddPrivateDomainReq,
): Promise<RR.OsUiAddPrivateDomainRes> {
return this.rpcRequest({
method: 'server.host.address.domain.private.add',
params,
})
}
async osUiRemovePrivateDomain(
params: RR.OsUiRemovePrivateDomainReq,
): Promise<RR.OsUiRemovePrivateDomainRes> {
return this.rpcRequest({
method: 'server.host.address.domain.private.remove',
params,
})
}
@@ -706,18 +717,38 @@ export class LiveApiService extends ApiService {
})
}
async pkgAddDomain(params: RR.PkgAddDomainReq): Promise<RR.PkgAddDomainRes> {
async pkgAddPublicDomain(
params: RR.PkgAddPublicDomainReq,
): Promise<RR.PkgAddPublicDomainRes> {
return this.rpcRequest({
method: 'package.host.address.domain.add',
method: 'package.host.address.domain.public.add',
params,
})
}
async pkgRemoveDomain(
params: RR.PkgRemoveDomainReq,
): Promise<RR.PkgRemoveDomainRes> {
async pkgRemovePublicDomain(
params: RR.PkgRemovePublicDomainReq,
): Promise<RR.PkgRemovePublicDomainRes> {
return this.rpcRequest({
method: 'package.host.address.domain.remove',
method: 'package.host.address.domain.public.remove',
params,
})
}
async pkgAddPrivateDomain(
params: RR.PkgAddPrivateDomainReq,
): Promise<RR.PkgAddPrivateDomainRes> {
return this.rpcRequest({
method: 'package.host.address.domain.private.add',
params,
})
}
async pkgRemovePrivateDomain(
params: RR.PkgRemovePrivateDomainReq,
): Promise<RR.PkgRemovePrivateDomainRes> {
return this.rpcRequest({
method: 'package.host.address.domain.private.remove',
params,
})
}

View File

@@ -462,6 +462,12 @@ export class MockApiService extends ApiService {
return null
}
async testDns(params: RR.TestDnsReq): Promise<RR.TestDnsRes> {
await pauseFor(2000)
return null
}
async resetTor(params: RR.ResetTorReq): Promise<RR.ResetTorRes> {
await pauseFor(2000)
return null
@@ -601,50 +607,6 @@ export class MockApiService extends ApiService {
return null
}
// domains
async addDomain(params: RR.AddDomainReq): Promise<RR.AddDomainRes> {
await pauseFor(2000)
const patch = [
{
op: PatchOp.REPLACE,
path: `/serverInfo/network/domains`,
value: {
[params.fqdn]: {
gateway: params.gateway,
},
},
},
]
this.mockRevision(patch)
return null
}
async removeDomain(params: RR.RemoveDomainReq): Promise<RR.RemoveDomainRes> {
await pauseFor(2000)
const patch = [
{
op: PatchOp.REPLACE,
path: '/serverInfo/network/domains',
value: {},
},
]
this.mockRevision(patch)
return null
}
async testDomain(params: RR.TestDomainReq): Promise<RR.TestDomainRes> {
await pauseFor(2000)
return {
root: true,
wildcard: true,
}
}
// wifi
async enableWifi(params: RR.EnabledWifiReq): Promise<RR.EnabledWifiRes> {
@@ -1432,19 +1394,72 @@ export class MockApiService extends ApiService {
return null
}
async osUiAddDomain(
params: RR.OsUiAddDomainReq,
): Promise<RR.OsUiAddDomainRes> {
async osUiAddPublicDomain(
params: RR.OsUiAddPublicDomainReq,
): Promise<RR.OsUiAddPublicDomainRes> {
await pauseFor(2000)
const patch: Operation<any>[] = [
{
op: PatchOp.ADD,
path: `/serverInfo/host/domains`,
path: `/serverInfo/host/domains/public`,
value: {
[params.fqdn]: { public: !params.private, acme: params.acme },
[params.fqdn]: { gateway: params.gateway, acme: params.acme },
},
},
{
op: PatchOp.ADD,
path: `/serverInfo/host/hostnameInfo/80/0`,
value: {
kind: 'ip',
gatewayId: 'eth0',
public: true,
hostname: {
kind: 'domain',
domain: params.fqdn,
subdomain: null,
port: null,
sslPort: 443,
},
},
},
]
this.mockRevision(patch)
return null
}
async osUiRemovePublicDomain(
params: RR.OsUiRemovePublicDomainReq,
): Promise<RR.OsUiRemovePublicDomainRes> {
await pauseFor(2000)
const patch: RemoveOperation[] = [
{
op: PatchOp.REMOVE,
path: `/serverInfo/host/domains/public/${params.fqdn}`,
},
{
op: PatchOp.REMOVE,
path: `/serverInfo/host/hostnameInfo/80/0`,
},
]
this.mockRevision(patch)
return null
}
async osUiAddPrivateDomain(
params: RR.OsUiAddPrivateDomainReq,
): Promise<RR.OsUiAddPrivateDomainRes> {
await pauseFor(2000)
const patch: Operation<any>[] = [
{
op: PatchOp.REPLACE,
path: `/serverInfo/host/domains/private`,
value: [params.fqdn],
},
{
op: PatchOp.ADD,
path: `/serverInfo/host/hostnameInfo/80/0`,
@@ -1467,15 +1482,16 @@ export class MockApiService extends ApiService {
return null
}
async osUiRemoveDomain(
params: RR.OsUiRemoveDomainReq,
): Promise<RR.OsUiRemoveDomainRes> {
async osUiRemovePrivateDomain(
params: RR.OsUiRemovePrivateDomainReq,
): Promise<RR.OsUiRemovePrivateDomainRes> {
await pauseFor(2000)
const patch: RemoveOperation[] = [
const patch: Operation<any>[] = [
{
op: PatchOp.REMOVE,
path: `/serverInfo/host/domains/${params.fqdn}`,
op: PatchOp.REPLACE,
path: `/serverInfo/host/domains/private`,
value: [],
},
{
op: PatchOp.REMOVE,
@@ -1551,17 +1567,72 @@ export class MockApiService extends ApiService {
return null
}
async pkgAddDomain(params: RR.PkgAddDomainReq): Promise<RR.PkgAddDomainRes> {
async pkgAddPublicDomain(
params: RR.PkgAddPublicDomainReq,
): Promise<RR.PkgAddPublicDomainRes> {
await pauseFor(2000)
const patch: Operation<any>[] = [
{
op: PatchOp.ADD,
path: `/packageData/${params.package}/hosts/${params.host}/domains`,
path: `/packageData/${params.package}/hosts/${params.host}/domains/public`,
value: {
[params.fqdn]: { public: !params.private, acme: params.acme },
[params.fqdn]: { gateway: params.gateway, acme: params.acme },
},
},
{
op: PatchOp.ADD,
path: `/packageData/${params.package}/hosts/${params.host}/hostnameInfo/80/0`,
value: {
kind: 'ip',
gatewayId: 'eth0',
public: true,
hostname: {
kind: 'domain',
domain: params.fqdn,
subdomain: null,
port: null,
sslPort: 443,
},
},
},
]
this.mockRevision(patch)
return null
}
async pkgRemovePublicDomain(
params: RR.PkgRemovePublicDomainReq,
): Promise<RR.PkgRemovePublicDomainRes> {
await pauseFor(2000)
const patch: RemoveOperation[] = [
{
op: PatchOp.REMOVE,
path: `/packageData/${params.package}/hosts/${params.host}/domains/public/${params.fqdn}`,
},
{
op: PatchOp.REMOVE,
path: `/packageData/${params.package}/hosts/${params.host}/hostnameInfo/80/0`,
},
]
this.mockRevision(patch)
return null
}
async pkgAddPrivateDomain(
params: RR.PkgAddPrivateDomainReq,
): Promise<RR.PkgAddPrivateDomainRes> {
await pauseFor(2000)
const patch: Operation<any>[] = [
{
op: PatchOp.REPLACE,
path: `/packageData/${params.package}/hosts/${params.host}/domains/private`,
value: [params.fqdn],
},
{
op: PatchOp.ADD,
path: `/packageData/${params.package}/hosts/${params.host}/hostnameInfo/80/0`,
@@ -1584,15 +1655,16 @@ export class MockApiService extends ApiService {
return null
}
async pkgRemoveDomain(
params: RR.PkgRemoveDomainReq,
): Promise<RR.PkgRemoveDomainRes> {
async pkgRemovePrivateDomain(
params: RR.PkgRemovePrivateDomainReq,
): Promise<RR.PkgRemovePrivateDomainRes> {
await pauseFor(2000)
const patch: RemoveOperation[] = [
const patch: Operation<any>[] = [
{
op: PatchOp.REMOVE,
path: `/packageData/${params.package}/hosts/${params.host}/domains/${params.fqdn}`,
op: PatchOp.REPLACE,
path: `/packageData/${params.package}/hosts/${params.host}/domains/private`,
value: [],
},
{
op: PatchOp.REMOVE,

View File

@@ -32,14 +32,6 @@ export const mockPatchData: DataModel = {
contact: ['mailto:support@start9.com'],
},
},
domains: {
'cloud.private.com': {
gateway: 'eth0',
},
'public.com': {
gateway: 'wireguard1',
},
},
host: {
bindings: {
80: {
@@ -60,7 +52,10 @@ export const mockPatchData: DataModel = {
},
},
},
domains: {},
domains: {
public: {},
private: {},
},
onions: ['myveryownspecialtoraddress'],
hostnameInfo: {
80: [
@@ -344,7 +339,10 @@ export const mockPatchData: DataModel = {
},
},
onions: [],
domains: {},
domains: {
public: {},
private: {},
},
hostnameInfo: {
80: [
{
@@ -444,7 +442,10 @@ export const mockPatchData: DataModel = {
},
},
onions: [],
domains: {},
domains: {
public: {},
private: {},
},
hostnameInfo: {
8332: [],
},
@@ -467,7 +468,10 @@ export const mockPatchData: DataModel = {
},
},
onions: [],
domains: {},
domains: {
public: {},
private: {},
},
hostnameInfo: {
8333: [],
},

View File

@@ -8,7 +8,7 @@ import { toSignal } from '@angular/core/rxjs-interop'
export type GatewayPlus = T.NetworkInterfaceInfo & {
id: string
ipInfo: T.IpInfo
ipv4: string[]
lanIpv4: string[]
}
@Injectable()
@@ -25,7 +25,7 @@ export class GatewayService {
({
...val,
id,
ipv4: val.ipInfo?.subnets
lanIpv4: val.ipInfo?.subnets
.filter(s => !s.includes('::'))
.map(s => s.split('/')[0]),
}) as GatewayPlus,