certificate authorities

This commit is contained in:
Matt Hill
2025-08-05 13:03:04 -06:00
parent 4a2777c52f
commit f8b03ea917
22 changed files with 351 additions and 327 deletions

View File

@@ -179,7 +179,6 @@ export default {
177: 'Kernelspeicher',
178: 'Leerlauf',
179: 'I/O-Wartezeit',
180: 'ACME',
181: 'Gesamt',
182: 'Verwendet',
183: 'Verfügbar',
@@ -294,12 +293,12 @@ export default {
296: 'Hochladen',
297: 'Version 1 s9pk erkannt. Dieses Format ist veraltet. Falls nötig, kann ein V1 s9pk über start-cli installiert werden.',
298: 'Ungültige Paketdatei',
299: 'Fügen Sie ACME-Anbieter hinzu, um SSL-(https)-Zertifikate für den Clearnet-Zugriff zu generieren.',
299: 'Das Hinzufügen einer Domain zu StartOS bedeutet, dass du sie und ihre Subdomains verwenden kannst, um Service-Oberflächen im öffentlichen Internet zu hosten.',
300: 'Anleitung anzeigen',
303: 'Kontakt',
304: 'Bearbeiten',
305: 'ACME-Anbieter hinzufügen',
306: 'ACME-Anbieter bearbeiten',
305: 'Zertifizierungsstelle hinzufügen',
306: 'Kontaktinformationen bearbeiten',
307: 'Kontakt-E-Mails',
308: 'Erforderlich, um ein Zertifikat von einer Zertifizierungsstelle zu erhalten',
309: 'Alle umschalten',
@@ -532,12 +531,12 @@ export default {
536: 'Umbenennen',
537: 'Zugriff',
538: 'Domains',
539: 'ACME-Anbieter',
539: 'Zertifizierungsstellen',
540: 'Domain',
541: 'Gateway',
542: 'Standard-ACME',
543: 'Gateway ändern',
544: 'Standard-ACME ändern',
542: 'Standard-Zertifizierungsstelle',
543: 'Zertifizierungsstelle',
544: 'Domain bearbeiten',
545: 'Keine Domains',
546: 'Anbieter',
547: 'DNS anzeigen',

View File

@@ -178,7 +178,6 @@ export const ENGLISH = {
'Kernel space': 177,
'Idle': 178, // a CPU metric
'I/O wait': 179,
'ACME': 180,
'Total': 181,
'Used': 182,
'Available': 183,
@@ -293,12 +292,12 @@ export const ENGLISH = {
'Upload': 296,
'Version 1 s9pk detected. This package format is deprecated. You can sideload a V1 s9pk via start-cli if necessary.': 297,
'Invalid package file': 298,
'Add ACME providers in order to generate SSL (https) certificates for clearnet access.': 299,
'Adding a domain to StartOS means you can use it and its subdomains to host service interfaces on the public Internet.': 299,
'View instructions': 300,
'Contact': 303, // as in, "contact us"
'Edit': 304,
'Add ACME Provider': 305,
'Edit ACME Provider': 306,
'Add Certificate Authority': 305,
'Edit Contact Info': 306,
'Contact Emails': 307,
'Needed to obtain a certificate from a Certificate Authority': 308,
'Toggle all': 309,
@@ -531,12 +530,12 @@ export const ENGLISH = {
'Rename': 536,
'Access': 537, // as in, public or private access, almost "permission"
'Domains': 538, // as in, internet domains
'ACME Providers': 539,
'Certificate Authorities': 539,
'Domain': 540, // as in, an internat domain name
'Gateway': 541, // as in, a device or software that connects two different networks
'Default ACME': 542, // as in, the default ACME provider for signing certificates
'Change gateway': 543, // as in, change the network gateway for a computer
'Change default ACME': 544, // as in, change the default ACME provider for a domain
'Default Certificate Authority': 542,
'Certificate Authority': 543,
'Edit Domain': 544,
'No domains': 545,
'Provider': 546,
'Show DNS': 547,

View File

@@ -179,7 +179,6 @@ export default {
177: 'Espacio del kernel',
178: 'Inactivo',
179: 'Espera de E/S',
180: 'ACME',
181: 'Total',
182: 'Usado',
183: 'Disponible',
@@ -294,12 +293,13 @@ export default {
296: 'Subir',
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: 'Agrega proveedores ACME para generar certificados SSL (https) para el acceso desde clearnet.',
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',
305: 'Agregar proveedor ACME',
306: 'Editar proveedor ACME',
305: 'Agregar autoridad certificadora',
306: 'Editar información de contacto',
307: 'Correos de contacto',
308: 'Necesarios para obtener un certificado de una Autoridad Certificadora',
309: 'Alternar todo',
@@ -532,12 +532,12 @@ export default {
536: 'Renombrar',
537: 'Acceso',
538: 'Dominios',
539: 'Proveedores ACME',
539: 'Autoridades certificadoras',
540: 'Dominio',
541: 'Puerta de enlace',
542: 'ACME predeterminado',
543: 'Cambiar puerta de enlace',
544: 'Cambiar ACME predeterminado',
542: 'Autoridad certificadora predeterminada',
543: 'Autoridad certificadora',
544: 'Editar dominio',
545: 'Sin dominios',
546: 'Proveedor',
547: 'Mostrar DNS',

View File

@@ -179,7 +179,6 @@ export default {
177: 'Espace noyau',
178: 'Inactif',
179: 'Attente E/S',
180: 'ACME',
181: 'Total',
182: 'Utilisé',
183: 'Disponible',
@@ -294,12 +293,12 @@ export default {
296: 'Téléverser',
297: 'Version 1 de s9pk détectée. Ce format de paquet est obsolète. Vous pouvez installer manuellement un s9pk V1 via start-cli si nécessaire.',
298: 'Fichier paquet invalide',
299: 'Ajoutez des fournisseurs ACME pour générer des certificats SSL (https) pour laccès clearnet.',
299: 'Ajouter un domaine à StartOS signifie que vous pouvez lutiliser, ainsi que ses sous-domaines, pour héberger des interfaces de services sur Internet public.',
300: 'Voir les instructions',
303: 'Contact',
304: 'Modifier',
305: 'Ajouter un fournisseur ACME',
306: 'Modifier le fournisseur ACME',
305: 'Ajouter une autorité de certification',
306: 'Modifier les informations de contact',
307: 'Emails de contact',
308: 'Nécessaire pour obtenir un certificat dune autorité de certification',
309: 'Tout cocher',
@@ -532,12 +531,12 @@ export default {
536: 'Renommer',
537: 'Accès',
538: 'Domaines',
539: 'Fournisseurs ACME',
539: 'Autorités de certification',
540: 'Domaine',
541: 'Passerelle',
542: 'ACME par défaut',
543: 'Changer de passerelle',
544: 'Changer lACME par défaut',
542: 'Autorité de certification par défaut',
543: 'Autorité de certification',
544: 'Modifier le domaine',
545: 'Aucun domaine',
546: 'Fournisseur',
547: 'Afficher le DNS',

View File

@@ -179,7 +179,6 @@ export default {
177: 'Przestrzeń jądra',
178: 'Bezczynność',
179: 'Oczekiwanie na I/O',
180: 'ACME',
181: 'Łącznie',
182: 'Wykorzystane',
183: 'Dostępne',
@@ -294,12 +293,12 @@ export default {
296: 'Prześlij',
297: 'Wykryto pakiet s9pk w wersji 1. Ten format pakietu jest przestarzały. Możesz zainstalować pakiet s9pk V1 przez start-cli, jeśli to konieczne.',
298: 'Nieprawidłowy plik pakietu',
299: 'Dodaj dostawców ACME, aby wygenerować certyfikaty SSL (https) dla dostępu przez clearnet.',
299: 'Dodanie domeny do StartOS oznacza, że możesz używać jej i jej subdomen do hostowania interfejsów usług w publicznym Internecie.',
300: 'Zobacz instrukcje',
303: 'Kontakt',
304: 'Edytuj',
305: 'Dodaj dostawcę ACME',
306: 'Edytuj dostawcę ACME',
305: 'Dodaj urząd certyfikacji',
306: 'Edytuj dane kontaktowe',
307: 'Adresy e-mail kontaktowe',
308: 'Wymagane do uzyskania certyfikatu od urzędu certyfikacji',
309: 'Zaznacz wszystkie',
@@ -532,12 +531,12 @@ export default {
536: 'Zmień nazwę',
537: 'Dostęp',
538: 'Domeny',
539: 'Dostawcy ACME',
539: 'Urzędy certyfikacji',
540: 'Domena',
541: 'Brama',
542: 'Domyślny ACME',
543: 'Zmień bramę',
544: 'Zmień domyślny ACME',
542: 'Domyślny urząd certyfikacji',
543: 'Urząd certyfikacji',
544: 'Edytuj domenę',
545: 'Brak domen',
546: 'Dostawca',
547: 'Pokaż DNS',

View File

@@ -1,11 +1,11 @@
import { Pipe, PipeTransform } from '@angular/core'
import { toAcmeName } from 'src/app/utils/acme'
import { toAuthorityName } from 'src/app/utils/acme'
@Pipe({
name: 'acme',
name: 'authorityName',
})
export class AcmePipe implements PipeTransform {
export class AuthorityNamePipe implements PipeTransform {
transform(value: string | null = null): string {
return toAcmeName(value)
return toAuthorityName(value)
}
}

View File

@@ -28,14 +28,14 @@ import {
FormComponent,
FormContext,
} from 'src/app/routes/portal/components/form.component'
import { AcmePipe } from 'src/app/routes/portal/components/interfaces/acme.pipe'
import { AuthorityNamePipe } from 'src/app/routes/portal/components/interfaces/acme.pipe'
import { InterfaceComponent } from 'src/app/routes/portal/components/interfaces/interface.component'
import { PlaceholderComponent } from 'src/app/routes/portal/components/placeholder.component'
import { TableComponent } from 'src/app/routes/portal/components/table.component'
import { ApiService } from 'src/app/services/api/embassy-api.service'
import { FormDialogService } from 'src/app/services/form-dialog.service'
import { DataModel } from 'src/app/services/patch-db/data-model'
import { toAcmeName } from 'src/app/utils/acme'
import { toAuthorityName } from 'src/app/utils/acme'
import { configBuilderToSpec } from 'src/app/utils/configBuilderToSpec'
import { InterfaceActionsComponent } from './actions.component'
import { ClearnetAddress } from './interface.utils'
@@ -43,7 +43,7 @@ import { MaskPipe } from './mask.pipe'
type ClearnetForm = {
domain: string
acme: string
authority: string
}
@Component({
@@ -85,11 +85,15 @@ type ClearnetForm = {
}}
</tui-notification>
}
<table [appTable]="['ACME', 'URL', null]">
<table [appTable]="['Certificate Authority', 'URL', null]">
@for (address of clearnet(); track $index) {
<tr>
<td [style.width.rem]="12">
{{ interface.value().addSsl ? (address.acme | acme) : '-' }}
{{
interface.value().addSsl
? (address.authority | authorityName)
: '-'
}}
</td>
<td [style.order]="-1">{{ address.url | mask }}</td>
<td
@@ -154,7 +158,7 @@ type ClearnetForm = {
PlaceholderComponent,
TableComponent,
MaskPipe,
AcmePipe,
AuthorityNamePipe,
InterfaceActionsComponent,
i18nPipe,
DocsLinkDirective,
@@ -175,7 +179,7 @@ export class InterfaceClearnetComponent {
readonly isRunning = input.required<boolean>()
readonly isPublic = input.required<boolean>()
readonly acme = toSignal(
readonly authorityUrls = toSignal(
inject<PatchDB<DataModel>>(PatchDB)
.watch$('serverInfo', 'network', 'acme')
.pipe(map(acme => Object.keys(acme))),
@@ -237,16 +241,16 @@ export class InterfaceClearnetComponent {
default: null,
patterns: [utils.Patterns.domain],
})
const acme = ISB.Value.select({
name: 'ACME Provider',
const authority = ISB.Value.select({
name: 'Certificate Authority',
description:
'Select which ACME provider to use for obtaining your SSL certificate. Add new ACME providers in the System tab. Optionally use your system Root CA. Note: only devices that have trusted your Root CA will be able to access the domain without security warnings.',
values: this.acme().reduce<Record<string, string>>(
'Select which Certificate authority to use for obtaining your SSL certificate. Add new authority in the System tab. Optionally use your local= Root CA. Note: only devices that have trusted your Root CA will be able to access the domain without security warnings.',
values: this.authorityUrls().reduce<Record<string, string>>(
(obj, url) => ({
...obj,
[url]: toAcmeName(url),
[url]: toAuthorityName(url),
}),
{ none: 'None (use system Root CA)' },
{ local: toAuthorityName(null) },
),
default: '',
})
@@ -256,7 +260,7 @@ export class InterfaceClearnetComponent {
data: {
spec: await configBuilderToSpec(
ISB.InputSpec.of(
this.interface.value().addSsl ? { domain, acme } : { domain },
this.interface.value().addSsl ? { domain, authority } : { domain },
),
),
buttons: [
@@ -272,11 +276,11 @@ export class InterfaceClearnetComponent {
private async save(domainInfo: ClearnetForm): Promise<boolean> {
const loader = this.loader.open('Saving').subscribe()
const { domain, acme } = domainInfo
const { domain, authority } = domainInfo
const params = {
domain,
acme: acme === 'none' ? null : acme,
acme: authority === 'local' ? null : authority,
private: false,
}

View File

@@ -72,7 +72,7 @@ export function getAddresses(
url,
disabled: !h.public,
isDomain: hostnameKind == 'domain',
acme:
authority:
hostnameKind == 'domain'
? host.domains[h.hostname.domain]?.acme || null
: null,
@@ -118,7 +118,7 @@ export type MappedServiceInterface = T.ServiceInterface & {
export type ClearnetAddress = {
url: string
acme: string | null
authority: string | null
isDomain: boolean
disabled: boolean
}

View File

@@ -1,83 +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 { ACMEInfo, AcmeService } from './acme.service'
@Component({
selector: 'tr[acme]',
template: `
<td>{{ acme().name }}</td>
<td>{{ acme().contact.join(', ') }}</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.pencil"
(click)="service.edit(acme())"
>
{{ 'Edit' | i18n }}
</button>
</tui-opt-group>
<tui-opt-group>
<button
tuiOption
new
iconStart="@tui.trash"
class="g-negative"
(click)="service.remove(acme())"
>
{{ 'Delete' | i18n }}
</button>
</tui-opt-group>
</tui-data-list>
</button>
</td>
`,
styles: `
td:last-child {
grid-area: 1 / 2 / 3;
align-self: center;
text-align: right;
}
:host-context(tui-root._mobile) {
grid-template-columns: 1fr min-content;
td:first-child {
font: var(--tui-font-text-m);
font-weight: bold;
}
}
`,
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [TuiButton, i18nPipe, TuiDropdown, TuiDataList, TuiTextfield],
})
export class AcmeItemComponent {
protected readonly service = inject(AcmeService)
readonly acme = input.required<ACMEInfo>()
open = false
}

View File

@@ -1,41 +0,0 @@
import { ChangeDetectionStrategy, Component, input } 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 { AcmeItemComponent } from './item.component'
import { ACMEInfo } from './acme.service'
@Component({
selector: 'acme-table',
template: `
<table [appTable]="['Provider', 'Contact', null]">
@for (acme of acmes(); track $index) {
<tr [acme]="acme"></tr>
} @empty {
<tr>
<td [attr.colspan]="3">
@if (acmes()) {
<app-placeholder icon="@tui.shield-question">
{{ 'No saved providers' | i18n }}
</app-placeholder>
} @else {
<div [tuiSkeleton]="true">{{ 'Loading' | i18n }}</div>
}
</td>
</tr>
}
</table>
`,
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [
TuiSkeleton,
i18nPipe,
TableComponent,
PlaceholderComponent,
AcmeItemComponent,
],
})
export class AcmeTableComponent {
readonly acmes = input<ACMEInfo[]>()
}

View File

@@ -13,18 +13,19 @@ import { ApiService } from 'src/app/services/api/embassy-api.service'
import { FormDialogService } from 'src/app/services/form-dialog.service'
import { PatchDB } from 'patch-db-client'
import { DataModel } from 'src/app/services/patch-db/data-model'
import { knownACME } from 'src/app/utils/acme'
import { knownAuthorities, toAuthorityName } from 'src/app/utils/acme'
import { configBuilderToSpec } from 'src/app/utils/configBuilderToSpec'
import { toAcmeName } from 'src/app/utils/acme'
export type ACMEInfo = {
export type Authority = {
url: string | null
name: string
url: string
contact: readonly string[]
contact: readonly string[] | null
}
export type RemoteAuthority = Authority & { url: string }
@Injectable()
export class AcmeService {
export class AuthorityService {
private readonly patch = inject<PatchDB<DataModel>>(PatchDB)
private readonly loader = inject(LoadingService)
private readonly errorService = inject(ErrorService)
@@ -33,31 +34,36 @@ export class AcmeService {
private readonly i18n = inject(i18nPipe)
private readonly dialog = inject(DialogService)
readonly acmes = toSignal<ACMEInfo[]>(
readonly authorities = toSignal<Authority[]>(
this.patch.watch$('serverInfo', 'network', 'acme').pipe(
map(acme =>
Object.keys(acme).map(url => ({
map(acme => [
{
url: null,
name: toAuthorityName(null),
contact: null,
},
...Object.keys(acme).map(url => ({
url,
name: toAcmeName(url),
name: toAuthorityName(url),
contact:
acme[url]?.contact.map(mailto => mailto.replace('mailto:', '')) ||
[],
null,
})),
),
]),
),
)
async add(acmes: ACMEInfo[]) {
const availableAcme = knownACME.filter(
acme => !acmes.map(a => a.url).includes(acme.url),
async add(authorities: Authority[]) {
const availableAuthorities = knownAuthorities.filter(
ca => !authorities.map(a => a.url).includes(ca.url),
)
const addSpec = ISB.InputSpec.of({
provider: ISB.Value.union({
name: 'Provider',
default: (availableAcme[0]?.url as any) || 'other',
default: (availableAuthorities[0]?.url as any) || 'other',
variants: ISB.Variants.of({
...availableAcme.reduce(
...availableAuthorities.reduce(
(obj, curr) => ({
...obj,
[curr.url]: {
@@ -85,7 +91,7 @@ export class AcmeService {
})
this.formDialog.open(FormComponent, {
label: 'Add ACME Provider',
label: 'Add Certificate Authority',
data: {
spec: await configBuilderToSpec(addSpec),
buttons: [
@@ -105,13 +111,13 @@ export class AcmeService {
})
}
async edit({ url, contact }: ACMEInfo) {
async edit({ url, contact }: RemoteAuthority) {
const editSpec = ISB.InputSpec.of({
contact: this.emailListSpec(),
})
this.formDialog.open(FormComponent, {
label: 'Edit ACME Provider',
label: 'Edit Contact Info',
data: {
spec: await configBuilderToSpec(editSpec),
buttons: [
@@ -126,7 +132,7 @@ export class AcmeService {
})
}
remove({ url }: ACMEInfo) {
remove({ url }: RemoteAuthority) {
this.dialog
.openConfirm({ label: 'Are you sure?', size: 's' })
.pipe(filter(Boolean))

View File

@@ -0,0 +1,99 @@
import {
ChangeDetectionStrategy,
Component,
inject,
input,
} from '@angular/core'
import { i18nPipe } from '@start9labs/shared'
import {
TuiButton,
TuiDataList,
TuiDropdown,
TuiTextfield,
} from '@taiga-ui/core'
import { Authority, AuthorityService } from './authority.service'
@Component({
selector: 'tr[authority]',
template: `
@if (authority(); as authority) {
<td>{{ authority.name }}</td>
<td>{{ authority.url || '-' }}</td>
<td>{{ authority.contact ? authority.contact.join(', ') : '-' }}</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>
@if (authority.url) {
<tui-opt-group>
<button
tuiOption
new
iconStart="@tui.pencil"
(click)="service.edit($any(authority))"
>
{{ 'Edit' | i18n }}
</button>
</tui-opt-group>
<tui-opt-group>
<button
tuiOption
new
iconStart="@tui.trash"
class="g-negative"
(click)="service.remove($any(authority))"
>
{{ 'Delete' | i18n }}
</button>
</tui-opt-group>
} @else {
<tui-opt-group>
<a
tuiOption
new
iconStart="@tui.download"
href="/static/local-root-ca.crt"
>
{{ 'Download your Root CA' | i18n }}
</a>
</tui-opt-group>
}
</tui-data-list>
</button>
</td>
}
`,
styles: `
td:last-child {
grid-area: 1 / 2 / 3;
align-self: center;
text-align: right;
}
:host-context(tui-root._mobile) {
grid-template-columns: 1fr min-content;
td:first-child {
font: var(--tui-font-text-m);
font-weight: bold;
}
}
`,
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [TuiButton, i18nPipe, TuiDropdown, TuiDataList, TuiTextfield],
})
export class AuthorityItemComponent {
protected readonly service = inject(AuthorityService)
readonly authority = input.required<Authority>()
open = false
}

View File

@@ -0,0 +1,26 @@
import { ChangeDetectionStrategy, Component, inject } from '@angular/core'
import { i18nPipe } from '@start9labs/shared'
import { TuiSkeleton } from '@taiga-ui/kit'
import { TableComponent } from 'src/app/routes/portal/components/table.component'
import { AuthorityItemComponent } from './item.component'
import { AuthorityService } from './authority.service'
@Component({
selector: 'authorities-table',
template: `
<table [appTable]="['Provider', 'URL', 'Contact', null]">
@for (authority of authorityService.authorities(); track $index) {
<tr [authority]="authority"></tr>
} @empty {
<td [attr.colspan]="4">
<div [tuiSkeleton]="true">{{ 'Loading' | i18n }}</div>
</td>
}
</table>
`,
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [TuiSkeleton, i18nPipe, TableComponent, AuthorityItemComponent],
})
export class AuthoritiesTableComponent {
protected readonly authorityService = inject(AuthorityService)
}

View File

@@ -4,10 +4,10 @@ import { DocsLinkDirective, i18nPipe } from '@start9labs/shared'
import { TuiButton, TuiLink, TuiTitle } from '@taiga-ui/core'
import { TuiHeader } from '@taiga-ui/layout'
import { TitleDirective } from 'src/app/services/title.service'
import { AcmeService } from './acme/acme.service'
import { DomainsService } from './domains/domains.service'
import { AuthorityService } from './authorities/authority.service'
import { DomainService } from './domains/domain.service'
import { DomainsTableComponent } from './domains/table.component'
import { AcmeTableComponent } from './acme/table.component'
import { AuthoritiesTableComponent } from './authorities/table.component'
@Component({
template: `
@@ -21,9 +21,9 @@ import { AcmeTableComponent } from './acme/table.component'
<hgroup tuiTitle>
<h3>{{ 'Domains' | i18n }}</h3>
<p tuiSubtitle>
<!-- @TODO translation -->
{{
'Adding a domain to StartOS means you can use it and its subdomains to host service interfaces on the public Internet.'
| i18n
}}
<a
tuiLink
@@ -40,38 +40,38 @@ import { AcmeTableComponent } from './acme/table.component'
<section class="g-card">
<header>
{{ 'ACME Providers' | i18n }}
@if (acmeService.acmes(); as acmes) {
{{ 'Certificate Authorities' | i18n }}
@if (authorityService.authorities(); as authorities) {
<button
tuiButton
size="xs"
iconStart="@tui.plus"
[style.margin-inline-start]="'auto'"
(click)="acmeService.add(acmes)"
(click)="authorityService.add(authorities)"
>
{{ 'Add' | i18n }}
</button>
}
</header>
<acme-table [acmes]="acmeService.acmes()" />
<authorities-table />
</section>
<section class="g-card">
<header>
{{ 'Domains' | i18n }}
@if (domainsService.data(); as value) {
@if (domainService.data(); as value) {
<button
tuiButton
size="xs"
iconStart="@tui.plus"
[style.margin-inline-start]="'auto'"
(click)="domainsService.add()"
(click)="domainService.add()"
>
Add
</button>
}
</header>
<domains-table [domains]="domainsService.data()?.domains" />
<domains-table />
</section>
`,
changeDetection: ChangeDetectionStrategy.OnPush,
@@ -85,11 +85,11 @@ import { AcmeTableComponent } from './acme/table.component'
i18nPipe,
DocsLinkDirective,
DomainsTableComponent,
AcmeTableComponent,
AuthoritiesTableComponent,
],
providers: [AcmeService, DomainsService],
providers: [AuthorityService, DomainService],
})
export default class SystemDomainsComponent {
protected readonly acmeService = inject(AcmeService)
protected readonly domainsService = inject(DomainsService)
protected readonly authorityService = inject(AuthorityService)
protected readonly domainService = inject(DomainService)
}

View File

@@ -14,12 +14,12 @@ 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 { toAcmeName } from 'src/app/utils/acme'
import { toAuthorityName } from 'src/app/utils/acme'
// @TODO translations
@Injectable()
export class DomainsService {
export class DomainService {
private readonly patch = inject<PatchDB<DataModel>>(PatchDB)
private readonly loader = inject(LoadingService)
private readonly errorService = inject(ErrorService)
@@ -46,32 +46,32 @@ export class DomainsService {
{
domain: 'blog.mydomain.com',
gateway: {
id: '',
id: 'wireguard1',
name: 'StartTunnel',
},
acme: {
url: '',
name: `Lert's Encrypt`,
authority: {
url: 'https://acme-v02.api.letsencrypt.org/directory',
name: `Let's Encrypt`,
},
},
{
domain: 'store.mydomain.com',
gateway: {
id: '',
id: 'eth0',
name: 'Ethernet',
},
acme: {
url: null,
name: 'System',
authority: {
url: 'local',
name: toAuthorityName(null),
},
},
],
acme: Object.keys(network.acme).reduce<Record<string, string>>(
authorities: Object.keys(network.acme).reduce<Record<string, string>>(
(obj, url) => ({
...obj,
[url]: toAcmeName(url),
[url]: toAuthorityName(url),
}),
{ none: 'None (use system Root CA)' },
{ local: toAuthorityName(null) },
),
}
}),
@@ -88,7 +88,7 @@ export class DomainsService {
default: null,
patterns: [utils.Patterns.domain],
}),
...this.gatewaysAndAcme(),
...this.gatewaysAndAuthorities(),
})
this.formDialog.open(FormComponent, {
@@ -107,11 +107,11 @@ export class DomainsService {
async edit(domain: any) {
const editSpec = ISB.InputSpec.of({
...this.gatewaysAndAcme(),
...this.gatewaysAndAuthorities(),
})
this.formDialog.open(FormComponent, {
label: 'Edit Domain' as any, // @TODO translation
label: 'Edit Domain',
data: {
spec: await configBuilderToSpec(editSpec),
buttons: [
@@ -126,7 +126,7 @@ export class DomainsService {
],
value: {
gateway: domain.gateway.id,
acme: domain.acme.url,
authority: domain.authority.url,
},
},
})
@@ -172,7 +172,7 @@ export class DomainsService {
}
}
private gatewaysAndAcme() {
private gatewaysAndAuthorities() {
return {
gateway: ISB.Value.select({
name: 'Gateway',
@@ -181,11 +181,11 @@ export class DomainsService {
values: this.data()!.gateways,
default: '',
}),
acme: ISB.Value.select({
name: 'Default ACME',
authority: ISB.Value.select({
name: 'Default Certificate Authority',
description:
'Select the default ACME provider for this domain. This can be overridden on a case-by-case basis.',
values: this.data()!.acme,
'Select the default certificate authority that will sign certificates for this domain. You can override this on a case-by-case basis.',
values: this.data()!.authorities,
default: '',
}),
}

View File

@@ -11,66 +11,68 @@ import {
TuiDropdown,
TuiTextfield,
} from '@taiga-ui/core'
import { DomainsService } from './domains.service'
import { DomainService } from './domain.service'
@Component({
selector: 'tr[domain]',
template: `
<td>{{ domain().domain }}</td>
<td [style.order]="-1">{{ domain().gateway.name }}</td>
<td>{{ domain().acme.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 size="s" *tuiTextfieldDropdown>
<tui-opt-group>
<button
tuiOption
new
iconStart="@tui.pencil"
(click)="domainsService.edit(domain())"
>
{{ 'Edit' | i18n }}
</button>
<button
tuiOption
new
iconStart="@tui.shield"
(click)="domainsService.showDns(domain())"
>
{{ 'Show DNS' | i18n }}
</button>
<button
tuiOption
new
iconStart="@tui.shield"
(click)="domainsService.testDns(domain())"
>
{{ 'Test DNS' | i18n }}
</button>
</tui-opt-group>
<tui-opt-group>
<button
tuiOption
new
iconStart="@tui.trash"
class="g-negative"
(click)="domainsService.remove(domain())"
>
{{ 'Delete' | i18n }}
</button>
</tui-opt-group>
</tui-data-list>
</button>
</td>
@if (domain(); as domain) {
<td>{{ domain.domain }}</td>
<td [style.order]="-1">{{ domain.gateway.name }}</td>
<td>{{ domain.authority.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 size="s" *tuiTextfieldDropdown>
<tui-opt-group>
<button
tuiOption
new
iconStart="@tui.pencil"
(click)="domainService.edit(domain)"
>
{{ 'Edit' | i18n }}
</button>
<button
tuiOption
new
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 }}
</button>
</tui-opt-group>
<tui-opt-group>
<button
tuiOption
new
iconStart="@tui.trash"
class="g-negative"
(click)="domainService.remove(domain)"
>
{{ 'Delete' | i18n }}
</button>
</tui-opt-group>
</tui-data-list>
</button>
</td>
}
`,
styles: `
td:last-child {
@@ -91,8 +93,8 @@ import { DomainsService } from './domains.service'
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [TuiButton, i18nPipe, TuiDropdown, TuiDataList, TuiTextfield],
})
export class DomainsItemComponent {
protected readonly domainsService = inject(DomainsService)
export class DomainItemComponent {
protected readonly domainService = inject(DomainService)
readonly domain = input.required<any>()

View File

@@ -1,20 +1,23 @@
import { ChangeDetectionStrategy, Component, input } from '@angular/core'
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 { DomainsItemComponent } from './item.component'
import { DomainItemComponent } from './item.component'
import { DomainService } from './domain.service'
@Component({
selector: 'domains-table',
template: `
<table [appTable]="['Domain', 'Gateway', 'Default ACME', null]">
@for (domain of domains(); track $index) {
<table
[appTable]="['Domain', 'Gateway', 'Default Certificate Authority', null]"
>
@for (domain of domainService.data()?.domains; track $index) {
<tr [domain]="domain"></tr>
} @empty {
<tr>
<td [attr.colspan]="4">
@if (domains()) {
@if (domainService.data()?.domains) {
<app-placeholder icon="@tui.globe">
{{ 'No domains' | i18n }}
</app-placeholder>
@@ -32,10 +35,9 @@ import { DomainsItemComponent } from './item.component'
i18nPipe,
TableComponent,
PlaceholderComponent,
DomainsItemComponent,
DomainItemComponent,
],
})
export class DomainsTableComponent {
// @TODO Alex proper types
readonly domains = input<readonly any[] | null>()
protected readonly domainService = inject(DomainService)
}

View File

@@ -12,7 +12,6 @@ import { TuiIcon } from '@taiga-ui/core'
import { TuiCheckbox, TuiFade, TuiSkeleton } from '@taiga-ui/kit'
import { TableComponent } from 'src/app/routes/portal/components/table.component'
import { Session } from 'src/app/services/api/api.types'
import { toAcmeName } from 'src/app/utils/acme'
import { PlatformInfoPipe } from './platform-info.pipe'
import { i18nPipe } from '@start9labs/shared'
@@ -182,6 +181,4 @@ export class SessionsTableComponent<T extends Session> implements OnChanges {
this.selected.update(selected => [...selected, session])
}
}
protected readonly toAcmeName = toAcmeName
}

View File

@@ -38,16 +38,16 @@ export const SYSTEM_MENU = [
},
],
[
{
icon: '@tui.globe',
item: 'Domains',
link: 'domains',
},
{
icon: '@tui.door-open',
item: 'Gateways',
link: 'gateways',
},
{
icon: '@tui.globe',
item: 'Domains',
link: 'domains',
},
],
[
{

View File

@@ -24,7 +24,7 @@ import { AuthService } from '../auth.service'
import { T } from '@start9labs/start-sdk'
import { MarketplacePkg } from '@start9labs/marketplace'
import { WebSocketSubject } from 'rxjs/webSocket'
import { toAcmeUrl } from 'src/app/utils/acme'
import { toAuthorityUrl } from 'src/app/utils/acme'
import markdown from './md-sample.md'
@@ -1396,7 +1396,7 @@ export class MockApiService extends ApiService {
op: PatchOp.ADD,
path: `/serverInfo/acme`,
value: {
[toAcmeUrl(params.provider)]: { contact: params.contact },
[toAuthorityUrl(params.provider)]: { contact: params.contact },
},
},
]

View File

@@ -1,6 +1,6 @@
import { DataModel } from 'src/app/services/patch-db/data-model'
import { Mock } from './api.fixures'
import { knownACME } from 'src/app/utils/acme'
import { knownAuthorities } from 'src/app/utils/acme'
const version = require('../../../../../../package.json').version
export const mockPatchData: DataModel = {
@@ -28,7 +28,7 @@ export const mockPatchData: DataModel = {
lastRegion: null,
},
acme: {
[knownACME[0].url]: {
[knownAuthorities[0].url]: {
contact: ['mailto:support@start9.com'],
},
},
@@ -160,6 +160,20 @@ export const mockPatchData: DataModel = {
ntpServers: [],
},
},
wireguard1: {
public: false,
ipInfo: {
name: 'StartTunnel',
scopeId: 2,
deviceType: 'wireguard',
subnets: [
'10.0.90.12/24',
'fe80::cd00:0000:0cde:1257:0000:211e:72cd/64',
],
wanIp: '203.0.113.45',
ntpServers: [],
},
},
},
},
unreadNotificationCount: 4,

View File

@@ -1,12 +1,14 @@
export function toAcmeName(url: string | null): string | 'System CA' {
return knownACME.find(acme => acme.url === url)?.name || url || 'System CA'
export function toAuthorityName(url: string | null): string | 'Local Root CA' {
return (
knownAuthorities.find(ca => ca.url === url)?.name || url || 'Local Root CA'
)
}
export function toAcmeUrl(name: string): string {
return knownACME.find(acme => acme.name === name)?.url || name
export function toAuthorityUrl(name: string): string {
return knownAuthorities.find(ca => ca.name === name)?.url || name
}
export const knownACME = [
export const knownAuthorities = [
{
name: `Let's Encrypt`,
url: 'https://acme-v02.api.letsencrypt.org/directory',