domains mostly finished

This commit is contained in:
Matt Hill
2025-08-05 17:29:48 -06:00
parent 3835562200
commit d8d1009417
30 changed files with 427 additions and 410 deletions

19
web/package-lock.json generated
View File

@@ -62,6 +62,7 @@
"patch-db-client": "file:../patch-db/client",
"pbkdf2": "^3.1.2",
"rxjs": "^7.8.2",
"tldts": "^7.0.11",
"ts-matches": "^6.3.2",
"tslib": "^2.8.1",
"uuid": "^8.3.2",
@@ -12185,6 +12186,24 @@
"url": "https://github.com/sponsors/SuperchupuDev"
}
},
"node_modules/tldts": {
"version": "7.0.11",
"resolved": "https://registry.npmjs.org/tldts/-/tldts-7.0.11.tgz",
"integrity": "sha512-7k7JV/LZpGhFUu2t+YDaMZ1wdPPRNpaCYNQ0NQbSLY3Rbgy+XbCdkXyqRiS9TLXiYAsrv0yiA0OvnxmgRFCdNA==",
"license": "MIT",
"dependencies": {
"tldts-core": "^7.0.11"
},
"bin": {
"tldts": "bin/cli.js"
}
},
"node_modules/tldts-core": {
"version": "7.0.11",
"resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-7.0.11.tgz",
"integrity": "sha512-65eeOpBwWBabh0XqT+zB0vEllq/V3XcrF2fhgMXWWFfNw1yxEjeYg9Vv/B/UNozd0CTR/TohO1ubfn6O6mBW3w==",
"license": "MIT"
},
"node_modules/tmp": {
"version": "0.0.33",
"resolved": "https://registry.npmjs.org/tmp/-/tmp-0.0.33.tgz",

View File

@@ -52,6 +52,7 @@
"@taiga-ui/addon-table": "4.47.0",
"@taiga-ui/cdk": "4.47.0",
"@taiga-ui/core": "4.47.0",
"@taiga-ui/dompurify": "4.1.11",
"@taiga-ui/event-plugins": "4.6.0",
"@taiga-ui/experimental": "4.47.0",
"@taiga-ui/icons": "4.47.0",
@@ -59,7 +60,6 @@
"@taiga-ui/layout": "4.47.0",
"@taiga-ui/legacy": "4.47.0",
"@taiga-ui/polymorpheus": "4.9.0",
"@taiga-ui/dompurify": "4.1.11",
"ansi-to-html": "^0.7.2",
"base64-js": "^1.5.1",
"buffer": "^6.0.3",
@@ -68,8 +68,8 @@
"core-js": "^3.42.0",
"cron": "^2.2.0",
"cronstrue": "^2.21.0",
"dompurify": "^3.1.7",
"deep-equality-data-structures": "1.5.1",
"dompurify": "^3.1.7",
"fast-json-patch": "^3.1.1",
"fuse.js": "^6.4.6",
"jose": "^4.9.0",
@@ -83,17 +83,18 @@
"patch-db-client": "file:../patch-db/client",
"pbkdf2": "^3.1.2",
"rxjs": "^7.8.2",
"tldts": "^7.0.11",
"ts-matches": "^6.3.2",
"tslib": "^2.8.1",
"uuid": "^8.3.2",
"zone.js": "^0.15.0"
},
"devDependencies": {
"@angular-experts/hawkeye": "^1.7.2",
"@angular/build": "^20.1.0",
"@angular/cli": "^20.1.0",
"@angular/compiler-cli": "^20.1.0",
"@angular/language-service": "^20.1.0",
"@angular-experts/hawkeye": "^1.7.2",
"@types/dompurify": "3.0.5",
"@types/estree": "^0.0.51",
"@types/js-yaml": "^4.0.5",

View File

@@ -112,7 +112,7 @@ export default {
109: 'Privat',
110: 'Fügen Sie eine Onion-Adresse hinzu, um dieses Interface anonym im Darknet verfügbar zu machen. Onion-Adressen sind nur über das Tor-Netzwerk erreichbar.',
111: 'Keine Onion-Adressen',
112: 'Neue Onion-Adresse',
112: 'Neue onion-adresse',
113: 'Privater Schlüssel (optional)',
114: 'Optional können Sie einen base64-codierten ed25519-Schlüssel angeben, um die Tor V3 (.onion)-Adresse zu generieren. Wenn nicht angegeben, wird ein zufälliger Schlüssel erstellt.',
115: 'Verarbeite 10.000 Logs',
@@ -303,7 +303,7 @@ export default {
308: 'Erforderlich, um ein Zertifikat von einer Zertifizierungsstelle zu erhalten',
309: 'Alle umschalten',
310: 'Fertig',
311: 'Master-Passwort erforderlich',
311: 'Master-passwort erforderlich',
312: 'Geben Sie Ihr Master-Passwort ein, um diese Sicherung zu verschlüsseln.',
313: 'Master-Passwort',
314: 'Master-Passwort eingeben',
@@ -539,6 +539,5 @@ export default {
544: 'Domain bearbeiten',
545: 'Keine Domains',
546: 'Anbieter',
547: 'DNS anzeigen',
548: 'DNS testen',
547: 'DNS verwalten',
} satisfies i18n

View File

@@ -15,7 +15,7 @@ export const ENGLISH = {
'Change Password': 13,
'General Settings': 14,
'Manage your overall setup and preferences': 15,
'Browser Tab Title': 16,
'Browser tab title': 16,
'Language': 17,
'Disk Repair': 18,
'Attempt automatic repair': 19,
@@ -103,7 +103,7 @@ export const ENGLISH = {
'You have unsaved changes. Are you sure you want to leave?': 101,
'Leave': 102,
'Are you sure?': 103,
'Select Domain': 104,
'Select domain': 104,
'Local': 105,
'Local addresses can only be accessed by devices connected to the same LAN as your server, either directly or using a VPN.': 106,
'Learn More': 107,
@@ -111,7 +111,7 @@ export const ENGLISH = {
'Private': 109,
'Add an onion address to anonymously expose this interface on the darknet. Onion addresses can only be reached over the Tor network.': 110,
'No onion addresses': 111,
'New Onion Address': 112,
'New onion address': 112,
'Private Key (optional)': 113,
'Optionally provide a base64-encoded ed25519 private key for generating the Tor V3 (.onion) address. If not provided, a random key will be generated and used.': 114,
'Processing 10,000 logs': 115,
@@ -297,16 +297,16 @@ export const ENGLISH = {
'Contact': 303, // as in, "contact us"
'Edit': 304,
'Add Certificate Authority': 305,
'Edit Contact Info': 306,
'Edit contact info': 306,
'Contact Emails': 307,
'Needed to obtain a certificate from a Certificate Authority': 308,
'Toggle all': 309,
'Done': 310,
'Master Password Needed': 311,
'Master password needed': 311,
'Enter your master password to encrypt this backup.': 312,
'Master Password': 313,
'Enter master password': 314,
'Original Password Needed': 315,
'Original password needed': 315,
'This backup was created with a different password. Enter the original password that was used to encrypt this backup.': 316,
'Original Password': 317,
'Enter original password': 318,
@@ -363,7 +363,7 @@ export const ENGLISH = {
'Ready to restore': 369,
'Local Hostname': 370,
'Created': 371,
'Password Required': 372,
'Password required': 372,
'Enter the master password that was used to encrypt this backup. On the next screen, you will select the individual services you want to restore.': 373,
'Decrypting drive': 374,
'Select services to restore': 375,
@@ -395,7 +395,7 @@ export const ENGLISH = {
'Terminate selected': 401,
'Terminating sessions': 402,
'No sessions': 403,
'Password Needed': 404,
'Password needed': 404,
'Connected': 405,
'Forget': 406, // as in, delete or remove
'WiFi Credentials': 407,
@@ -526,7 +526,7 @@ export const ENGLISH = {
'Finished': 532, // an in, complete
'Gateways': 533, // as in, a device or software that connects two different networks
'Gateways connect your server to the Internet. They process outbound traffic, and under certain conditions, they also permit inbound traffic.': 534,
'Add Gateway': 535, // as in, add a new network gateway to StartOS
'Add gateway': 535, // as in, add a new network gateway to StartOS
'Rename': 536,
'Access': 537, // as in, public or private access, almost "permission"
'Domains': 538, // as in, internet domains
@@ -535,9 +535,8 @@ export const ENGLISH = {
'Gateway': 541, // as in, a device or software that connects two different networks
'Default Certificate Authority': 542,
'Certificate Authority': 543,
'Edit Domain': 544,
'Edit domain': 544,
'No domains': 545,
'Provider': 546,
'Show DNS': 547,
'Test DNS': 548,
'Manage DNS': 547,
} as const

View File

@@ -112,7 +112,7 @@ export default {
109: 'Privado',
110: 'Agrega una dirección onion para exponer esta interfaz de forma anónima en la darknet. Las direcciones onion solo se pueden acceder a través de la red Tor.',
111: 'Sin direcciones onion',
112: 'Nueva dirección Onion',
112: 'Nueva dirección onion',
113: 'Clave privada (opcional)',
114: 'Opcionalmente proporciona una clave privada ed25519 codificada en base64 para generar la dirección Tor V3 (.onion). Si no se proporciona, se generará una clave aleatoria.',
115: 'Procesando 10,000 registros',
@@ -294,7 +294,6 @@ export default {
297: 'Se detectó un paquete s9pk de versión 1. Este formato está obsoleto. Puedes instalarlo manualmente con start-cli si es necesario.',
298: 'Archivo de paquete inválido',
299: 'Agregar un dominio a StartOS significa que puedes usarlo y sus subdominios para alojar interfaces de servicios en Internet público.',
300: 'Ver instrucciones',
303: 'Contacto',
304: 'Editar',
@@ -540,6 +539,5 @@ export default {
544: 'Editar dominio',
545: 'Sin dominios',
546: 'Proveedor',
547: 'Mostrar DNS',
548: 'Probar DNS',
547: 'Administrar DNS',
} satisfies i18n

View File

@@ -539,6 +539,5 @@ export default {
544: 'Modifier le domaine',
545: 'Aucun domaine',
546: 'Fournisseur',
547: 'Afficher le DNS',
548: 'Tester le DNS',
547: 'Gérer le DNS',
} satisfies i18n

View File

@@ -112,7 +112,7 @@ export default {
109: 'Prywatny',
110: 'Dodaj adres onion, aby anonimowo udostępnić ten interfejs w sieci Tor. Adresy onion są dostępne tylko przez sieć Tor.',
111: 'Brak adresów onion',
112: 'Nowy adres Onion',
112: 'Nowy adres onion',
113: 'Klucz prywatny (opcjonalnie)',
114: 'Opcjonalnie podaj klucz prywatny ed25519 zakodowany w base64, aby wygenerować adres Tor V3 (.onion). Jeśli nie zostanie podany, zostanie wygenerowany i użyty losowy klucz.',
115: 'Przetwarzanie 10 000 logów',
@@ -539,6 +539,5 @@ export default {
544: 'Edytuj domenę',
545: 'Brak domen',
546: 'Dostawca',
547: 'Pokaż DNS',
548: 'Test DNS',
547: 'Zarządzaj DNS',
} satisfies i18n

View File

@@ -12,6 +12,8 @@ import { distinctUntilChanged, map, merge, Subject } from 'rxjs'
import { ConfigService } from 'src/app/services/config.service'
import { DataModel } from 'src/app/services/patch-db/data-model'
// @TODO translations
@Component({
selector: 'refresh-alert',
template: `

View File

@@ -221,7 +221,7 @@ export class InterfaceClearnetComponent {
host: this.interface.value().addressInfo.hostId,
})
} else {
await this.api.serverRemoveDomain(params)
await this.api.osUiRemoveDomain(params)
}
return true
} catch (e: any) {
@@ -256,7 +256,7 @@ export class InterfaceClearnetComponent {
})
this.formDialog.open<FormContext<ClearnetForm>>(FormComponent, {
label: 'Select Domain',
label: 'Select domain',
data: {
spec: await configBuilderToSpec(
ISB.InputSpec.of(
@@ -292,7 +292,7 @@ export class InterfaceClearnetComponent {
host: this.interface.value().addressInfo.hostId,
})
} else {
await this.api.serverAddDomain(params)
await this.api.osUiAddDomain(params)
}
return true
} catch (e: any) {

View File

@@ -179,7 +179,7 @@ export class InterfaceTorComponent {
async add() {
this.formDialog.open<FormContext<OnionForm>>(FormComponent, {
label: 'New Onion Address',
label: 'New onion address',
data: {
spec: await configBuilderToSpec(
ISB.InputSpec.of({

View File

@@ -133,7 +133,7 @@ export class TabsComponent {
)
more(content: TemplateRef<any>) {
this.dialogs.open(content, { label: 'Start OS' }).subscribe({
this.dialogs.open(content, { label: 'StartOS' }).subscribe({
complete: () => this.update(),
})
}

View File

@@ -128,7 +128,7 @@ export class BackupsBackupComponent {
this.dialog
.openPrompt<string>({
label: 'Master Password Needed',
label: 'Master password needed',
data: {
message: 'Enter your master password to encrypt this backup.',
label: 'Master Password',
@@ -169,7 +169,7 @@ export class BackupsBackupComponent {
this.dialog
.openPrompt<string>({
label: 'Original Password Needed',
label: 'Original password needed',
data: {
message:
'This backup was created with a different password. Enter the original password that was used to encrypt this backup.',

View File

@@ -55,7 +55,7 @@ export class BackupRestoreComponent {
onClick(serverId: string, { passwordHash }: StartOSDiskInfo) {
this.dialog
.openPrompt<string>({
label: 'Password Required',
label: 'Password required',
data: {
message:
'Enter the master password that was used to encrypt this backup. On the next screen, you will select the individual services you want to restore.',

View File

@@ -117,7 +117,7 @@ export class AuthorityService {
})
this.formDialog.open(FormComponent, {
label: 'Edit Contact Info',
label: 'Edit contact info',
data: {
spec: await configBuilderToSpec(editSpec),
buttons: [

View File

@@ -19,7 +19,9 @@ import { Authority, AuthorityService } from './authority.service'
@if (authority(); as authority) {
<td>{{ authority.name }}</td>
<td>{{ authority.url || '-' }}</td>
<td>{{ authority.contact ? authority.contact.join(', ') : '-' }}</td>
<td class="hidden">
{{ authority.contact ? authority.contact.join(', ') : '-' }}
</td>
<td>
<button
tuiIconButton
@@ -73,7 +75,7 @@ import { Authority, AuthorityService } from './authority.service'
`,
styles: `
td:last-child {
grid-area: 1 / 2 / 3;
grid-area: 1 / 2 / 4;
align-self: center;
text-align: right;
}
@@ -85,6 +87,10 @@ import { Authority, AuthorityService } from './authority.service'
font: var(--tui-font-text-m);
font-weight: bold;
}
.hidden {
display: none;
}
}
`,
changeDetection: ChangeDetectionStrategy.OnPush,

View File

@@ -0,0 +1,92 @@
import { ChangeDetectionStrategy, Component, inject } from '@angular/core'
import { ErrorService, i18nPipe, LoadingService } from '@start9labs/shared'
import { TuiButton, TuiDialogContext } from '@taiga-ui/core'
import { ApiService } from 'src/app/services/api/embassy-api.service'
import { MappedDomain } from './domain.service'
import { injectContext, PolymorpheusComponent } from '@taiga-ui/polymorpheus'
import { TableComponent } from 'src/app/routes/portal/components/table.component'
@Component({
selector: 'dns',
template: `
<section class="g-card">
<header>
{{ $any('Using IP') | i18n }}
</header>
@let subdomain = context.data.subdomain;
@let wanIp = context.data.gateway.ipInfo?.wanIp || ('Error' | i18n);
<table [appTable]="['Type', $any('Host'), 'Value', 'Purpose']">
<tr>
<td>A</td>
<td>{{ subdomain || '@' }}</td>
<td>{{ wanIp }}</td>
<td></td>
</tr>
<tr>
<td>A</td>
<td>
{{ subdomain ? '*.' + subdomain : '*' }}
</td>
<td>{{ wanIp }}</td>
<td></td>
</tr>
</table>
</section>
@if (context.data.gateway.ipInfo?.deviceType !== 'wireguard') {
<section class="g-card">
<header>
{{ $any('Using Dynamic DNS') | i18n }}
</header>
<table [appTable]="['Type', $any('Host'), 'Value', 'Purpose']">
<tr>
<td>ALIAS</td>
<td>{{ subdomain || '@' }}</td>
<td>[Dynamic DNS Address]</td>
<td></td>
</tr>
<tr>
<td>ALIAS</td>
<td>{{ subdomain ? '*.' + subdomain : '*' }}</td>
<td>[Dynamic DNS Address]</td>
<td></td>
</tr>
</table>
</section>
}
<button tuiButton size="l" (click)="testDns()">
{{ 'Test' | i18n }}
</button>
`,
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [TuiButton, i18nPipe, TableComponent],
})
export class DnsComponent {
private readonly loader = inject(LoadingService)
private readonly errorService = inject(ErrorService)
private readonly api = inject(ApiService)
readonly context = injectContext<TuiDialogContext<void, MappedDomain>>()
async testDns() {
const loader = this.loader.open().subscribe()
try {
await this.api.testDomain({
fqdn: this.context.data.fqdn,
gateway: this.context.data.gateway.id,
})
return true
} catch (e: any) {
this.errorService.handleError(e)
return false
} finally {
loader.unsubscribe()
}
}
}
export const DNS = new PolymorpheusComponent(DnsComponent)

View File

@@ -6,7 +6,7 @@ import {
LoadingService,
} from '@start9labs/shared'
import { toSignal } from '@angular/core/rxjs-interop'
import { ISB, utils } from '@start9labs/start-sdk'
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'
@@ -15,9 +15,26 @@ 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 { parse } from 'tldts'
import { RR } from 'src/app/services/api/api.types'
import { DNS } from './dns.component'
// @TODO translations
export type MappedDomain = {
fqdn: string
subdomain: string | null
gateway: {
id: string
name: string | null
ipInfo: T.IpInfo | null
}
authority: {
url: string | null
name: string | null
}
}
@Injectable()
export class DomainService {
private readonly patch = inject<PatchDB<DataModel>>(PatchDB)
@@ -30,57 +47,45 @@ export class DomainService {
readonly data = toSignal(
this.patch.watch$('serverInfo', 'network').pipe(
map(network => {
return {
gateways: Object.entries(network.networkInterfaces).reduce<
Record<string, string>
>(
(obj, [id, n]) => ({
...obj,
[id]: n.ipInfo?.name || '',
}),
{},
),
// @TODO use real data
domains: [
{
domain: 'blog.mydomain.com',
map(({ networkInterfaces, domains, acme }) => ({
gateways: Object.entries(networkInterfaces).reduce<
Record<string, string>
>(
(obj, [id, n]) => ({
...obj,
[id]: n.ipInfo?.name || '',
}),
{},
),
domains: Object.entries(domains).map(
([fqdn, { gateway, acme }]) =>
({
fqdn,
subdomain: parse(fqdn).subdomain,
gateway: {
id: 'wireguard1',
name: 'StartTunnel',
id: gateway,
ipInfo: networkInterfaces[gateway]?.ipInfo || null,
},
authority: {
url: 'https://acme-v02.api.letsencrypt.org/directory',
name: `Let's Encrypt`,
url: acme,
name: toAuthorityName(acme),
},
},
{
domain: 'store.mydomain.com',
gateway: {
id: 'eth0',
name: 'Ethernet',
},
authority: {
url: 'local',
name: toAuthorityName(null),
},
},
],
authorities: Object.keys(network.acme).reduce<Record<string, string>>(
(obj, url) => ({
...obj,
[url]: toAuthorityName(url),
}),
{ local: toAuthorityName(null) },
),
}
}),
}) as MappedDomain,
),
authorities: Object.keys(acme).reduce<Record<string, string>>(
(obj, url) => ({
...obj,
[url]: toAuthorityName(url),
}),
{ local: toAuthorityName(null) },
),
})),
),
)
async add() {
const addSpec = ISB.InputSpec.of({
domain: ISB.Value.text({
fqdn: ISB.Value.text({
name: 'Domain',
description:
'Enter a domain/subdomain. 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',
@@ -92,26 +97,31 @@ export class DomainService {
})
this.formDialog.open(FormComponent, {
label: 'Add Domain' as any,
label: 'Add domain',
data: {
spec: await configBuilderToSpec(addSpec),
buttons: [
{
text: 'Save',
handler: (input: typeof addSpec._TYPE) => this.save(input),
handler: (input: typeof addSpec._TYPE) =>
this.save({
fqdn: input.fqdn,
gateway: input.gateway,
acme: input.authority === 'local' ? null : input.authority,
}),
},
],
},
})
}
async edit(domain: any) {
async edit(domain: MappedDomain) {
const editSpec = ISB.InputSpec.of({
...this.gatewaysAndAuthorities(),
})
this.formDialog.open(FormComponent, {
label: 'Edit Domain',
label: 'Edit domain',
data: {
spec: await configBuilderToSpec(editSpec),
buttons: [
@@ -119,20 +129,21 @@ export class DomainService {
text: 'Save',
handler: (input: typeof editSpec._TYPE) =>
this.save({
domain: domain.domain,
...input,
fqdn: domain.fqdn,
gateway: input.gateway,
acme: input.authority === 'local' ? null : input.authority,
}),
},
],
value: {
gateway: domain.gateway.id,
authority: domain.authority.url,
authority: domain.authority.url || 'local',
},
},
})
}
remove(domain: any) {
remove(fqdn: string) {
this.dialog
.openConfirm({ label: 'Are you sure?', size: 's' })
.pipe(filter(Boolean))
@@ -140,7 +151,7 @@ export class DomainService {
const loader = this.loader.open('Deleting').subscribe()
try {
// @TODO API
await this.api.removeDomain({ fqdn })
} catch (e: any) {
this.errorService.handleError(e)
} finally {
@@ -149,20 +160,17 @@ export class DomainService {
})
}
showDns(domain: any) {
// @TODO
showDns(domain: MappedDomain) {
this.dialog
.openComponent(DNS, { label: 'Manage DNS', data: domain })
.subscribe()
}
testDns(domain: any) {
// @TODO
}
// @TODO different endpoints for create and edit?
private async save(params: any) {
private async save(params: RR.AddDomainReq) {
const loader = this.loader.open('Saving').subscribe()
try {
// @TODO API
await this.api.addDomain(params)
return true
} catch (e: any) {
this.errorService.handleError(e)

View File

@@ -11,14 +11,14 @@ import {
TuiDropdown,
TuiTextfield,
} from '@taiga-ui/core'
import { DomainService } from './domain.service'
import { DomainService, MappedDomain } from './domain.service'
@Component({
selector: 'tr[domain]',
template: `
@if (domain(); as domain) {
<td>{{ domain.domain }}</td>
<td [style.order]="-1">{{ domain.gateway.name }}</td>
<td>{{ domain.fqdn }}</td>
<td [style.order]="-1">{{ domain.gateway.ipInfo?.name || '-' }}</td>
<td>{{ domain.authority.name }}</td>
<td>
<button
@@ -47,15 +47,7 @@ import { DomainService } from './domain.service'
iconStart="@tui.eye"
(click)="domainService.showDns(domain)"
>
{{ 'Show DNS' | i18n }}
</button>
<button
tuiOption
new
iconStart="@tui.arrow-up-down"
(click)="domainService.testDns(domain)"
>
{{ 'Test DNS' | i18n }}
{{ 'Manage DNS' | i18n }}
</button>
</tui-opt-group>
<tui-opt-group>
@@ -64,7 +56,7 @@ import { DomainService } from './domain.service'
new
iconStart="@tui.trash"
class="g-negative"
(click)="domainService.remove(domain)"
(click)="domainService.remove(domain.fqdn)"
>
{{ 'Delete' | i18n }}
</button>
@@ -96,7 +88,7 @@ import { DomainService } from './domain.service'
export class DomainItemComponent {
protected readonly domainService = inject(DomainService)
readonly domain = input.required<any>()
readonly domain = input.required<MappedDomain>()
open = false
}

View File

@@ -18,7 +18,7 @@ import { TitleDirective } from 'src/app/services/title.service'
import { TuiHeader } from '@taiga-ui/layout'
import { map } from 'rxjs'
import { ISB } from '@start9labs/start-sdk'
import { GatewayWithID } from './item.component'
import { GatewayPlus } from './item.component'
@Component({
template: `
@@ -87,19 +87,24 @@ export default class GatewaysComponent {
.watch$('serverInfo', 'network', 'networkInterfaces')
.pipe(
map(gateways =>
Object.entries(gateways).map(
([id, val]) =>
({
...val,
id,
}) as GatewayWithID,
),
Object.entries(gateways)
.filter(([_, val]) => !!val.ipInfo)
.map(
([id, val]) =>
({
...val,
id,
ipv4: val.ipInfo?.subnets
.filter(s => !s.includes('::'))
.map(s => s.split('/')[0]),
}) as GatewayPlus,
),
),
)
async add() {
this.formDialog.open(FormComponent, {
label: 'Add Gateway',
label: 'Add gateway',
data: {
spec: await configBuilderToSpec(gatewaySpec),
buttons: [

View File

@@ -24,55 +24,64 @@ 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'
export type GatewayWithID = T.NetworkInterfaceInfo & {
export type GatewayPlus = T.NetworkInterfaceInfo & {
id: string
ipInfo: T.IpInfo
ipv4: string[]
}
@Component({
selector: 'tr[proxy]',
template: `
<td [style.grid-column]="'span 2'">{{ proxy().ipInfo.name }}</td>
<td class="type">{{ proxy().ipInfo.deviceType || '-' }}</td>
<td [style.order]="-2">
{{ proxy().public ? ('Public' | i18n) : ('Private' | i18n) }}
</td>
<!-- // @TODO show both LAN IPs? -->
<td class="lan">{{ proxy().ipInfo.subnets[0] }}</td>
<td class="wan">{{ proxy().ipInfo.wanIp }}</td>
<td>
<button
tuiIconButton
tuiDropdown
size="s"
appearance="flat-grayscale"
iconStart="@tui.ellipsis-vertical"
[tuiAppearanceState]="open ? 'hover' : null"
[(tuiDropdownOpen)]="open"
@if (proxy(); as proxy) {
<td [style.grid-column]="'span 2'">{{ proxy.ipInfo.name }}</td>
<td class="type">{{ proxy.ipInfo.deviceType || '-' }}</td>
<td [style.order]="-2">
{{ proxy.public ? ('Public' | i18n) : ('Private' | i18n) }}
</td>
<td class="lan">{{ proxy.ipv4.join(', ') }}</td>
<td
class="wan"
[style.color]="
proxy.ipInfo.wanIp ? 'var(--tui-text-warning)' : undefined
"
>
{{ 'More' | i18n }}
<tui-data-list size="s" *tuiTextfieldDropdown>
<tui-opt-group>
<button tuiOption new iconStart="@tui.pencil" (click)="rename()">
{{ 'Rename' | i18n }}
</button>
</tui-opt-group>
@if (proxy().ipInfo.deviceType === 'wireguard') {
{{ proxy.ipInfo.wanIp || ('Error' | i18n) }}
</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 size="s" *tuiTextfieldDropdown>
<tui-opt-group>
<button
tuiOption
new
iconStart="@tui.trash"
class="g-negative"
(click)="remove()"
>
{{ 'Delete' | i18n }}
<button tuiOption new iconStart="@tui.pencil" (click)="rename()">
{{ 'Rename' | i18n }}
</button>
</tui-opt-group>
}
</tui-data-list>
</button>
</td>
@if (proxy.ipInfo.deviceType === 'wireguard') {
<tui-opt-group>
<button
tuiOption
new
iconStart="@tui.trash"
class="g-negative"
(click)="remove()"
>
{{ 'Delete' | i18n }}
</button>
</tui-opt-group>
}
</tui-data-list>
</button>
</td>
}
`,
styles: `
td:last-child {
@@ -106,7 +115,7 @@ export type GatewayWithID = T.NetworkInterfaceInfo & {
grid-column: span 2;
&::before {
content: 'LAN IPs: ';
content: 'LAN IP: ';
color: var(--tui-text-primary);
}
}
@@ -133,7 +142,7 @@ export class GatewaysItemComponent {
private readonly api = inject(ApiService)
private readonly formDialog = inject(FormDialogService)
readonly proxy = input.required<GatewayWithID>()
readonly proxy = input.required<GatewayPlus>()
open = false

View File

@@ -3,7 +3,7 @@ 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 { GatewaysItemComponent, GatewayWithID } from './item.component'
import { GatewaysItemComponent, GatewayPlus } from './item.component'
@Component({
selector: '[gateways]',
@@ -13,7 +13,7 @@ import { GatewaysItemComponent, GatewayWithID } from './item.component'
'Name',
'Type',
'Access',
$any('LAN IPs'),
$any('LAN IP'),
$any('WAN IP'),
null,
]"
@@ -25,7 +25,7 @@ import { GatewaysItemComponent, GatewayWithID } from './item.component'
<td colspan="5">
@if (gateways()) {
<app-placeholder icon="@tui.door-closed-locked">
<!-- @TODO Matt finalize text and add translations -->
<!-- @TODO translation -->
No gateways
</app-placeholder>
} @else {
@@ -45,6 +45,6 @@ import { GatewaysItemComponent, GatewayWithID } from './item.component'
PlaceholderComponent,
],
})
export class GatewaysTableComponent<T extends GatewayWithID> {
export class GatewaysTableComponent<T extends GatewayPlus> {
readonly gateways = input<readonly T[] | null>(null)
}

View File

@@ -102,7 +102,7 @@ import { SystemWipeComponent } from './wipe.component'
<div tuiCell tuiAppearance="outline-grayscale">
<tui-icon icon="@tui.app-window" />
<span tuiTitle>
<strong>{{ 'Browser Tab Title' | i18n }}</strong>
<strong>{{ 'Browser tab title' | i18n }}</strong>
<span tuiSubtitle>
{{ 'Customize the name appearing in your browser tab' | i18n }}
</span>
@@ -302,7 +302,7 @@ export default class SystemGeneralComponent {
onTitle() {
const sub = this.dialog
.openPrompt<string>({
label: 'Browser Tab Title',
label: 'Browser tab title',
data: {
label: 'Device Name',
message:

View File

@@ -144,7 +144,7 @@ export class WifiTableComponent {
await this.component.saveAndConnect(network.ssid)
} else {
this.formDialog.open<FormContext<WiFiForm>>(FormComponent, {
label: 'Password Needed',
label: 'Password needed',
data: {
spec: wifiSpec.spec,
buttons: [

View File

@@ -234,7 +234,28 @@ export namespace RR {
}
export type CreateBackupRes = null
// tunnel
// network
export type AddDomainReq = {
fqdn: string
gateway: string
acme: string | null
} // 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
export type TestDomainRes = {
root: boolean
wildcard: boolean
}
export type AddTunnelReq = {
name: string
@@ -255,7 +276,7 @@ export namespace RR {
export type RemoveTunnelRes = null
export type InitAcmeReq = {
provider: 'letsencrypt' | 'letsencrypt-staging' | string
provider: string
contact: string[]
}
export type InitAcmeRes = null
@@ -288,19 +309,19 @@ export namespace RR {
export type ServerRemoveOnionReq = ServerAddOnionReq // server.host.address.onion.remove
export type RemoveOnionRes = null
export type ServerAddDomainReq = {
export type OsUiAddDomainReq = {
// server.host.address.domain.add
domain: string // FQDN
private: boolean
acme: string | null // "letsencrypt" | "letsencrypt-staging" | Url | null
acme: string | null // Url | null
}
export type AddDomainRes = null
export type OsUiAddDomainRes = null
export type ServerRemoveDomainReq = {
export type OsUiRemoveDomainReq = {
// server.host.address.domain.remove
domain: string // FQDN
}
export type RemoveDomainRes = null
export type OsUiRemoveDomainRes = null
export type PkgBindingSetPublicReq = ServerBindingSetPublicReq & {
// package.host.binding.set-public
@@ -316,17 +337,19 @@ export namespace RR {
export type PkgRemoveOnionReq = PkgAddOnionReq // package.host.address.onion.remove
export type PkgAddDomainReq = ServerAddDomainReq & {
export type PkgAddDomainReq = OsUiAddDomainReq & {
// package.host.address.domain.add
package: T.PackageId // string
host: T.HostId // string
}
export type PkgAddDomainRes = null
export type PkgRemoveDomainReq = ServerRemoveDomainReq & {
export type PkgRemoveDomainReq = OsUiRemoveDomainReq & {
// package.host.address.domain.remove
package: T.PackageId // string
host: T.HostId // string
}
export type PkgRemoveDomainRes = null
export type GetPackageLogsReq = FetchLogsReq & { id: string } // package.logs
export type GetPackageLogsRes = FetchLogsRes
@@ -624,32 +647,6 @@ export type DependencyErrorTransitive = {
// @TODO 041
// export namespace RR041 {
// // ** domains **
// export type ClaimStart9ToReq = { gatewayId: string } // net.domain.me.claim
// export type ClaimStart9ToRes = null
// export type DeleteStart9ToReq = {} // net.domain.me.delete
// export type DeleteStart9ToRes = null
// export type AddDomainReq = {
// hostname: string
// provider: {
// name: string
// username: string | null
// password: string | null
// }
// gatewayId: string
// } // net.domain.add
// export type AddDomainRes = null
// export type DeleteDomainReq = { hostname: string } // net.domain.delete
// export type DeleteDomainRes = null
// // port forwards
// export type OverridePortReq = { target: number; port: number } // net.port-forwards.override
// export type OverridePortRes = null
// // ** automated backups **
@@ -731,20 +728,6 @@ export type DependencyErrorTransitive = {
// @TODO 041 types
// export type AppMetrics = {
// memory: {
// percentageUsed: MetricData
// used: MetricData
// }
// cpu: {
// percentageUsed: MetricData
// }
// disk: {
// percentageUsed: MetricData
// used: MetricData
// }
// }
// export type RemoteBackupTarget = CifsBackupTarget | CloudBackupTarget
// export type BackupTarget = RemoteBackupTarget | DiskBackupTarget

View File

@@ -184,33 +184,15 @@ export abstract class ApiService {
// @TODO 041
// abstract setOutboundProxy(
// params: RR.SetOutboundTunnelReq,
// ): Promise<RR.SetOutboundTunnelRes>
// ** domains **
// @TODO 041
// abstract claimStart9ToDomain(
// params: RR.ClaimStart9ToReq,
// ): Promise<RR.ClaimStart9ToRes>
abstract addDomain(params: RR.AddDomainReq): Promise<RR.AddDomainRes>
// abstract deleteStart9ToDomain(
// params: RR.DeleteStart9ToReq,
// ): Promise<RR.DeleteStart9ToRes>
abstract removeDomain(params: RR.RemoveDomainReq): Promise<RR.RemoveDomainRes>
// abstract addDomain(params: RR.AddDomainReq): Promise<RR.AddDomainRes>
// abstract deleteDomain(params: RR.DeleteDomainReq): Promise<RR.DeleteDomainRes>
// ** port forwards **
// @TODO 041
// abstract overridePortForward(
// params: RR.OverridePortReq,
// ): Promise<RR.OverridePortRes>
abstract testDomain(params: RR.TestDomainReq): Promise<RR.TestDomainRes>
// wifi
@@ -387,13 +369,13 @@ export abstract class ApiService {
params: RR.ServerRemoveOnionReq,
): Promise<RR.RemoveOnionRes>
abstract serverAddDomain(
params: RR.ServerAddDomainReq,
): Promise<RR.AddDomainRes>
abstract osUiAddDomain(
params: RR.OsUiAddDomainReq,
): Promise<RR.OsUiAddDomainRes>
abstract serverRemoveDomain(
params: RR.ServerRemoveDomainReq,
): Promise<RR.RemoveDomainRes>
abstract osUiRemoveDomain(
params: RR.OsUiRemoveDomainReq,
): Promise<RR.OsUiRemoveDomainRes>
abstract pkgBindingSetPubic(
params: RR.PkgBindingSetPublicReq,
@@ -405,9 +387,9 @@ export abstract class ApiService {
params: RR.PkgRemoveOnionReq,
): Promise<RR.RemoveOnionRes>
abstract pkgAddDomain(params: RR.PkgAddDomainReq): Promise<RR.AddDomainRes>
abstract pkgAddDomain(params: RR.PkgAddDomainReq): Promise<RR.PkgAddDomainRes>
abstract pkgRemoveDomain(
params: RR.PkgRemoveDomainReq,
): Promise<RR.RemoveDomainRes>
): Promise<RR.PkgRemoveDomainRes>
}

View File

@@ -358,41 +358,19 @@ export class LiveApiService extends ApiService {
return this.rpcRequest({ method: 'net.tunnel.remove', params })
}
// async setOutboundProxy(
// params: RR.SetOutboundTunnelReq,
// ): Promise<RR.SetOutboundTunnelRes> {
// return this.rpcRequest({ method: 'server.proxy.set-outbound', params })
// }
// domains
// async claimStart9ToDomain(
// params: RR.ClaimStart9ToReq,
// ): Promise<RR.ClaimStart9ToRes> {
// return this.rpcRequest({ method: 'net.domain.me.claim', params })
// }
async addDomain(params: RR.AddDomainReq): Promise<RR.AddDomainRes> {
return this.rpcRequest({ method: 'net.domain.add', params })
}
// async deleteStart9ToDomain(
// params: RR.DeleteStart9ToReq,
// ): Promise<RR.DeleteStart9ToRes> {
// return this.rpcRequest({ method: 'net.domain.me.delete', params })
// }
async removeDomain(params: RR.RemoveDomainReq): Promise<RR.RemoveDomainRes> {
return this.rpcRequest({ method: 'net.domain.remove', params })
}
// async addDomain(params: RR.AddDomainReq): Promise<RR.AddDomainRes> {
// return this.rpcRequest({ method: 'net.domain.add', params })
// }
// async deleteDomain(params: RR.DeleteDomainReq): Promise<RR.DeleteDomainRes> {
// return this.rpcRequest({ method: 'net.domain.delete', params })
// }
// port forwards
// async overridePortForward(
// params: RR.OverridePortReq,
// ): Promise<RR.OverridePortRes> {
// return this.rpcRequest({ method: 'net.port-forwards.override', params })
// }
async testDomain(params: RR.TestDomainReq): Promise<RR.TestDomainRes> {
return this.rpcRequest({ method: 'net.domain.test', params })
}
// wifi
@@ -685,18 +663,18 @@ export class LiveApiService extends ApiService {
})
}
async serverAddDomain(
params: RR.ServerAddDomainReq,
): Promise<RR.AddDomainRes> {
async osUiAddDomain(
params: RR.OsUiAddDomainReq,
): Promise<RR.OsUiAddDomainRes> {
return this.rpcRequest({
method: 'server.host.address.domain.add',
params,
})
}
async serverRemoveDomain(
params: RR.ServerRemoveDomainReq,
): Promise<RR.RemoveDomainRes> {
async osUiRemoveDomain(
params: RR.OsUiRemoveDomainReq,
): Promise<RR.OsUiRemoveDomainRes> {
return this.rpcRequest({
method: 'server.host.address.domain.remove',
params,
@@ -728,7 +706,7 @@ export class LiveApiService extends ApiService {
})
}
async pkgAddDomain(params: RR.PkgAddDomainReq): Promise<RR.AddDomainRes> {
async pkgAddDomain(params: RR.PkgAddDomainReq): Promise<RR.PkgAddDomainRes> {
return this.rpcRequest({
method: 'package.host.address.domain.add',
params,
@@ -737,7 +715,7 @@ export class LiveApiService extends ApiService {
async pkgRemoveDomain(
params: RR.PkgRemoveDomainReq,
): Promise<RR.RemoveDomainRes> {
): Promise<RR.PkgRemoveDomainRes> {
return this.rpcRequest({
method: 'package.host.address.domain.remove',
params,

View File

@@ -601,113 +601,50 @@ export class MockApiService extends ApiService {
return null
}
// async setOutboundProxy(
// params: RR.SetOutboundTunnelReq,
// ): Promise<RR.SetOutboundTunnelRes> {
// await pauseFor(2000)
// const patch: ReplaceOperation<string | null>[] = [
// {
// op: PatchOp.REPLACE,
// path: '/serverInfo/network/outboundInterface',
// value: params.id,
// },
// ]
// this.mockRevision(patch)
// return null
// }
// domains
// async claimStart9ToDomain(
// params: RR.ClaimStart9ToReq,
// ): Promise<RR.ClaimStart9ToRes> {
// await pauseFor(2000)
async addDomain(params: RR.AddDomainReq): Promise<RR.AddDomainRes> {
await pauseFor(2000)
// const patch = [
// {
// op: PatchOp.REPLACE,
// path: '/serverInfo/network/start9To',
// value: {
// subdomain: 'xyz',
// gatewayId: params.gatewayId,
// },
// },
// ]
// this.mockRevision(patch)
const patch = [
{
op: PatchOp.REPLACE,
path: `/serverInfo/network/domains`,
value: {
[params.fqdn]: {
gateway: params.gateway,
acme: params.acme,
},
},
},
]
this.mockRevision(patch)
// return null
// }
return null
}
// async deleteStart9ToDomain(
// params: RR.DeleteStart9ToReq,
// ): Promise<RR.DeleteStart9ToRes> {
// await pauseFor(2000)
// const patch = [
// {
// op: PatchOp.REPLACE,
// path: '/serverInfo/network/start9To',
// value: null,
// },
// ]
// this.mockRevision(patch)
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
// }
return null
}
// async addDomain(params: RR.AddDomainReq): Promise<RR.AddDomainRes> {
// await pauseFor(2000)
async testDomain(params: RR.TestDomainReq): Promise<RR.TestDomainRes> {
await pauseFor(2000)
// const patch = [
// {
// op: PatchOp.REPLACE,
// path: `/serverInfo/network/domains`,
// value: {
// [params.hostname]: {
// gatewayId: params.gatewayId,
// provider: params.provider.name,
// },
// },
// },
// ]
// this.mockRevision(patch)
// return null
// }
// async deleteDomain(params: RR.DeleteDomainReq): Promise<RR.DeleteDomainRes> {
// await pauseFor(2000)
// const patch = [
// {
// op: PatchOp.REPLACE,
// path: '/serverInfo/network/domains',
// value: {},
// },
// ]
// this.mockRevision(patch)
// return null
// }
// port forwards
// async overridePortForward(
// params: RR.OverridePortReq,
// ): Promise<RR.OverridePortRes> {
// await pauseFor(2000)
// const patch = [
// {
// op: PatchOp.REPLACE,
// path: '/serverInfo/network/wanConfig/forwards/0/override',
// value: params.port,
// },
// ]
// this.mockRevision(patch)
// return null
// }
return {
root: true,
wildcard: true,
}
}
// wifi
@@ -1496,7 +1433,9 @@ export class MockApiService extends ApiService {
return null
}
async serverAddDomain(params: RR.PkgAddDomainReq): Promise<RR.AddDomainRes> {
async osUiAddDomain(
params: RR.OsUiAddDomainReq,
): Promise<RR.OsUiAddDomainRes> {
await pauseFor(2000)
const patch: Operation<any>[] = [
@@ -1529,9 +1468,9 @@ export class MockApiService extends ApiService {
return null
}
async serverRemoveDomain(
params: RR.PkgRemoveDomainReq,
): Promise<RR.RemoveDomainRes> {
async osUiRemoveDomain(
params: RR.OsUiRemoveDomainReq,
): Promise<RR.OsUiRemoveDomainRes> {
await pauseFor(2000)
const patch: RemoveOperation[] = [
@@ -1613,7 +1552,7 @@ export class MockApiService extends ApiService {
return null
}
async pkgAddDomain(params: RR.PkgAddDomainReq): Promise<RR.AddDomainRes> {
async pkgAddDomain(params: RR.PkgAddDomainReq): Promise<RR.PkgAddDomainRes> {
await pauseFor(2000)
const patch: Operation<any>[] = [
@@ -1648,7 +1587,7 @@ export class MockApiService extends ApiService {
async pkgRemoveDomain(
params: RR.PkgRemoveDomainReq,
): Promise<RR.RemoveDomainRes> {
): Promise<RR.PkgRemoveDomainRes> {
await pauseFor(2000)
const patch: RemoveOperation[] = [

View File

@@ -32,6 +32,16 @@ export const mockPatchData: DataModel = {
contact: ['mailto:support@start9.com'],
},
},
domains: {
'cloud.private.com': {
gateway: 'eth0',
acme: null,
},
'public.com': {
gateway: 'wireguard1',
acme: 'https://acme-v02.api.letsencrypt.org/directory',
},
},
host: {
bindings: {
80: {

View File

@@ -1,7 +1,20 @@
import { Languages } from '@start9labs/shared'
import { T } from '@start9labs/start-sdk'
export type DataModel = T.Public & { ui: UIData; packageData: AllPackageData }
export type DataModel = T.Public & {
ui: UIData
packageData: AllPackageData
serverInfo: T.ServerInfo & {
network: T.NetworkInfo & {
domains: {
[fqdn: string]: {
gateway: string
acme: string | null
}
}
}
}
}
export type UIData = {
name: string | null
@@ -11,22 +24,6 @@ export type UIData = {
language: Languages
}
export type NetworkInfo = T.NetworkInfo & {
// @TODO 041
// start9To: {
// subdomain: string
// gatewayId: string
// } | null
// domains: {
// [key: string]: Domain
// }
// wanConfig: {
// upnp: boolean
// forwards: PortForward[]
// }
// outboundProxy: string | null
}
export type PackageDataEntry<T extends StateInfo = StateInfo> =
T.PackageDataEntry & {
stateInfo: T

View File

@@ -50,7 +50,7 @@
// const options: Partial<
// TuiDialogOptions<FormContext<typeof config.validator._TYPE>>
// > = {
// label: 'Outbound Proxy',
// label: 'Outbound proxy',
// data: {
// spec: await configBuilderToSpec(config),
// buttons: [