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', 177: 'Kernelspeicher',
178: 'Leerlauf', 178: 'Leerlauf',
179: 'I/O-Wartezeit', 179: 'I/O-Wartezeit',
180: 'ACME',
181: 'Gesamt', 181: 'Gesamt',
182: 'Verwendet', 182: 'Verwendet',
183: 'Verfügbar', 183: 'Verfügbar',
@@ -294,12 +293,12 @@ export default {
296: 'Hochladen', 296: 'Hochladen',
297: 'Version 1 s9pk erkannt. Dieses Format ist veraltet. Falls nötig, kann ein V1 s9pk über start-cli installiert werden.', 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', 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', 300: 'Anleitung anzeigen',
303: 'Kontakt', 303: 'Kontakt',
304: 'Bearbeiten', 304: 'Bearbeiten',
305: 'ACME-Anbieter hinzufügen', 305: 'Zertifizierungsstelle hinzufügen',
306: 'ACME-Anbieter bearbeiten', 306: 'Kontaktinformationen bearbeiten',
307: 'Kontakt-E-Mails', 307: 'Kontakt-E-Mails',
308: 'Erforderlich, um ein Zertifikat von einer Zertifizierungsstelle zu erhalten', 308: 'Erforderlich, um ein Zertifikat von einer Zertifizierungsstelle zu erhalten',
309: 'Alle umschalten', 309: 'Alle umschalten',
@@ -532,12 +531,12 @@ export default {
536: 'Umbenennen', 536: 'Umbenennen',
537: 'Zugriff', 537: 'Zugriff',
538: 'Domains', 538: 'Domains',
539: 'ACME-Anbieter', 539: 'Zertifizierungsstellen',
540: 'Domain', 540: 'Domain',
541: 'Gateway', 541: 'Gateway',
542: 'Standard-ACME', 542: 'Standard-Zertifizierungsstelle',
543: 'Gateway ändern', 543: 'Zertifizierungsstelle',
544: 'Standard-ACME ändern', 544: 'Domain bearbeiten',
545: 'Keine Domains', 545: 'Keine Domains',
546: 'Anbieter', 546: 'Anbieter',
547: 'DNS anzeigen', 547: 'DNS anzeigen',

View File

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

View File

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

View File

@@ -179,7 +179,6 @@ export default {
177: 'Espace noyau', 177: 'Espace noyau',
178: 'Inactif', 178: 'Inactif',
179: 'Attente E/S', 179: 'Attente E/S',
180: 'ACME',
181: 'Total', 181: 'Total',
182: 'Utilisé', 182: 'Utilisé',
183: 'Disponible', 183: 'Disponible',
@@ -294,12 +293,12 @@ export default {
296: 'Téléverser', 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.', 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', 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', 300: 'Voir les instructions',
303: 'Contact', 303: 'Contact',
304: 'Modifier', 304: 'Modifier',
305: 'Ajouter un fournisseur ACME', 305: 'Ajouter une autorité de certification',
306: 'Modifier le fournisseur ACME', 306: 'Modifier les informations de contact',
307: 'Emails de contact', 307: 'Emails de contact',
308: 'Nécessaire pour obtenir un certificat dune autorité de certification', 308: 'Nécessaire pour obtenir un certificat dune autorité de certification',
309: 'Tout cocher', 309: 'Tout cocher',
@@ -532,12 +531,12 @@ export default {
536: 'Renommer', 536: 'Renommer',
537: 'Accès', 537: 'Accès',
538: 'Domaines', 538: 'Domaines',
539: 'Fournisseurs ACME', 539: 'Autorités de certification',
540: 'Domaine', 540: 'Domaine',
541: 'Passerelle', 541: 'Passerelle',
542: 'ACME par défaut', 542: 'Autorité de certification par défaut',
543: 'Changer de passerelle', 543: 'Autorité de certification',
544: 'Changer lACME par défaut', 544: 'Modifier le domaine',
545: 'Aucun domaine', 545: 'Aucun domaine',
546: 'Fournisseur', 546: 'Fournisseur',
547: 'Afficher le DNS', 547: 'Afficher le DNS',

View File

@@ -179,7 +179,6 @@ export default {
177: 'Przestrzeń jądra', 177: 'Przestrzeń jądra',
178: 'Bezczynność', 178: 'Bezczynność',
179: 'Oczekiwanie na I/O', 179: 'Oczekiwanie na I/O',
180: 'ACME',
181: 'Łącznie', 181: 'Łącznie',
182: 'Wykorzystane', 182: 'Wykorzystane',
183: 'Dostępne', 183: 'Dostępne',
@@ -294,12 +293,12 @@ export default {
296: 'Prześlij', 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.', 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', 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', 300: 'Zobacz instrukcje',
303: 'Kontakt', 303: 'Kontakt',
304: 'Edytuj', 304: 'Edytuj',
305: 'Dodaj dostawcę ACME', 305: 'Dodaj urząd certyfikacji',
306: 'Edytuj dostawcę ACME', 306: 'Edytuj dane kontaktowe',
307: 'Adresy e-mail kontaktowe', 307: 'Adresy e-mail kontaktowe',
308: 'Wymagane do uzyskania certyfikatu od urzędu certyfikacji', 308: 'Wymagane do uzyskania certyfikatu od urzędu certyfikacji',
309: 'Zaznacz wszystkie', 309: 'Zaznacz wszystkie',
@@ -532,12 +531,12 @@ export default {
536: 'Zmień nazwę', 536: 'Zmień nazwę',
537: 'Dostęp', 537: 'Dostęp',
538: 'Domeny', 538: 'Domeny',
539: 'Dostawcy ACME', 539: 'Urzędy certyfikacji',
540: 'Domena', 540: 'Domena',
541: 'Brama', 541: 'Brama',
542: 'Domyślny ACME', 542: 'Domyślny urząd certyfikacji',
543: 'Zmień bramę', 543: 'Urząd certyfikacji',
544: 'Zmień domyślny ACME', 544: 'Edytuj domenę',
545: 'Brak domen', 545: 'Brak domen',
546: 'Dostawca', 546: 'Dostawca',
547: 'Pokaż DNS', 547: 'Pokaż DNS',

View File

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

View File

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

View File

@@ -72,7 +72,7 @@ export function getAddresses(
url, url,
disabled: !h.public, disabled: !h.public,
isDomain: hostnameKind == 'domain', isDomain: hostnameKind == 'domain',
acme: authority:
hostnameKind == 'domain' hostnameKind == 'domain'
? host.domains[h.hostname.domain]?.acme || null ? host.domains[h.hostname.domain]?.acme || null
: null, : null,
@@ -118,7 +118,7 @@ export type MappedServiceInterface = T.ServiceInterface & {
export type ClearnetAddress = { export type ClearnetAddress = {
url: string url: string
acme: string | null authority: string | null
isDomain: boolean isDomain: boolean
disabled: 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 { FormDialogService } from 'src/app/services/form-dialog.service'
import { PatchDB } from 'patch-db-client' import { PatchDB } from 'patch-db-client'
import { DataModel } from 'src/app/services/patch-db/data-model' 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 { configBuilderToSpec } from 'src/app/utils/configBuilderToSpec'
import { toAcmeName } from 'src/app/utils/acme'
export type ACMEInfo = { export type Authority = {
url: string | null
name: string name: string
url: string contact: readonly string[] | null
contact: readonly string[]
} }
export type RemoteAuthority = Authority & { url: string }
@Injectable() @Injectable()
export class AcmeService { export class AuthorityService {
private readonly patch = inject<PatchDB<DataModel>>(PatchDB) private readonly patch = inject<PatchDB<DataModel>>(PatchDB)
private readonly loader = inject(LoadingService) private readonly loader = inject(LoadingService)
private readonly errorService = inject(ErrorService) private readonly errorService = inject(ErrorService)
@@ -33,31 +34,36 @@ export class AcmeService {
private readonly i18n = inject(i18nPipe) private readonly i18n = inject(i18nPipe)
private readonly dialog = inject(DialogService) private readonly dialog = inject(DialogService)
readonly acmes = toSignal<ACMEInfo[]>( readonly authorities = toSignal<Authority[]>(
this.patch.watch$('serverInfo', 'network', 'acme').pipe( this.patch.watch$('serverInfo', 'network', 'acme').pipe(
map(acme => map(acme => [
Object.keys(acme).map(url => ({ {
url: null,
name: toAuthorityName(null),
contact: null,
},
...Object.keys(acme).map(url => ({
url, url,
name: toAcmeName(url), name: toAuthorityName(url),
contact: contact:
acme[url]?.contact.map(mailto => mailto.replace('mailto:', '')) || acme[url]?.contact.map(mailto => mailto.replace('mailto:', '')) ||
[], null,
})), })),
), ]),
), ),
) )
async add(acmes: ACMEInfo[]) { async add(authorities: Authority[]) {
const availableAcme = knownACME.filter( const availableAuthorities = knownAuthorities.filter(
acme => !acmes.map(a => a.url).includes(acme.url), ca => !authorities.map(a => a.url).includes(ca.url),
) )
const addSpec = ISB.InputSpec.of({ const addSpec = ISB.InputSpec.of({
provider: ISB.Value.union({ provider: ISB.Value.union({
name: 'Provider', name: 'Provider',
default: (availableAcme[0]?.url as any) || 'other', default: (availableAuthorities[0]?.url as any) || 'other',
variants: ISB.Variants.of({ variants: ISB.Variants.of({
...availableAcme.reduce( ...availableAuthorities.reduce(
(obj, curr) => ({ (obj, curr) => ({
...obj, ...obj,
[curr.url]: { [curr.url]: {
@@ -85,7 +91,7 @@ export class AcmeService {
}) })
this.formDialog.open(FormComponent, { this.formDialog.open(FormComponent, {
label: 'Add ACME Provider', label: 'Add Certificate Authority',
data: { data: {
spec: await configBuilderToSpec(addSpec), spec: await configBuilderToSpec(addSpec),
buttons: [ buttons: [
@@ -105,13 +111,13 @@ export class AcmeService {
}) })
} }
async edit({ url, contact }: ACMEInfo) { async edit({ url, contact }: RemoteAuthority) {
const editSpec = ISB.InputSpec.of({ const editSpec = ISB.InputSpec.of({
contact: this.emailListSpec(), contact: this.emailListSpec(),
}) })
this.formDialog.open(FormComponent, { this.formDialog.open(FormComponent, {
label: 'Edit ACME Provider', label: 'Edit Contact Info',
data: { data: {
spec: await configBuilderToSpec(editSpec), spec: await configBuilderToSpec(editSpec),
buttons: [ buttons: [
@@ -126,7 +132,7 @@ export class AcmeService {
}) })
} }
remove({ url }: ACMEInfo) { remove({ url }: RemoteAuthority) {
this.dialog this.dialog
.openConfirm({ label: 'Are you sure?', size: 's' }) .openConfirm({ label: 'Are you sure?', size: 's' })
.pipe(filter(Boolean)) .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 { TuiButton, TuiLink, TuiTitle } from '@taiga-ui/core'
import { TuiHeader } from '@taiga-ui/layout' import { TuiHeader } from '@taiga-ui/layout'
import { TitleDirective } from 'src/app/services/title.service' import { TitleDirective } from 'src/app/services/title.service'
import { AcmeService } from './acme/acme.service' import { AuthorityService } from './authorities/authority.service'
import { DomainsService } from './domains/domains.service' import { DomainService } from './domains/domain.service'
import { DomainsTableComponent } from './domains/table.component' import { DomainsTableComponent } from './domains/table.component'
import { AcmeTableComponent } from './acme/table.component' import { AuthoritiesTableComponent } from './authorities/table.component'
@Component({ @Component({
template: ` template: `
@@ -21,9 +21,9 @@ import { AcmeTableComponent } from './acme/table.component'
<hgroup tuiTitle> <hgroup tuiTitle>
<h3>{{ 'Domains' | i18n }}</h3> <h3>{{ 'Domains' | i18n }}</h3>
<p tuiSubtitle> <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.' 'Adding a domain to StartOS means you can use it and its subdomains to host service interfaces on the public Internet.'
| i18n
}} }}
<a <a
tuiLink tuiLink
@@ -40,38 +40,38 @@ import { AcmeTableComponent } from './acme/table.component'
<section class="g-card"> <section class="g-card">
<header> <header>
{{ 'ACME Providers' | i18n }} {{ 'Certificate Authorities' | i18n }}
@if (acmeService.acmes(); as acmes) { @if (authorityService.authorities(); as authorities) {
<button <button
tuiButton tuiButton
size="xs" size="xs"
iconStart="@tui.plus" iconStart="@tui.plus"
[style.margin-inline-start]="'auto'" [style.margin-inline-start]="'auto'"
(click)="acmeService.add(acmes)" (click)="authorityService.add(authorities)"
> >
{{ 'Add' | i18n }} {{ 'Add' | i18n }}
</button> </button>
} }
</header> </header>
<acme-table [acmes]="acmeService.acmes()" /> <authorities-table />
</section> </section>
<section class="g-card"> <section class="g-card">
<header> <header>
{{ 'Domains' | i18n }} {{ 'Domains' | i18n }}
@if (domainsService.data(); as value) { @if (domainService.data(); as value) {
<button <button
tuiButton tuiButton
size="xs" size="xs"
iconStart="@tui.plus" iconStart="@tui.plus"
[style.margin-inline-start]="'auto'" [style.margin-inline-start]="'auto'"
(click)="domainsService.add()" (click)="domainService.add()"
> >
Add Add
</button> </button>
} }
</header> </header>
<domains-table [domains]="domainsService.data()?.domains" /> <domains-table />
</section> </section>
`, `,
changeDetection: ChangeDetectionStrategy.OnPush, changeDetection: ChangeDetectionStrategy.OnPush,
@@ -85,11 +85,11 @@ import { AcmeTableComponent } from './acme/table.component'
i18nPipe, i18nPipe,
DocsLinkDirective, DocsLinkDirective,
DomainsTableComponent, DomainsTableComponent,
AcmeTableComponent, AuthoritiesTableComponent,
], ],
providers: [AcmeService, DomainsService], providers: [AuthorityService, DomainService],
}) })
export default class SystemDomainsComponent { export default class SystemDomainsComponent {
protected readonly acmeService = inject(AcmeService) protected readonly authorityService = inject(AuthorityService)
protected readonly domainsService = inject(DomainsService) 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 { configBuilderToSpec } from 'src/app/utils/configBuilderToSpec'
import { PatchDB } from 'patch-db-client' import { PatchDB } from 'patch-db-client'
import { DataModel } from 'src/app/services/patch-db/data-model' 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 // @TODO translations
@Injectable() @Injectable()
export class DomainsService { export class DomainService {
private readonly patch = inject<PatchDB<DataModel>>(PatchDB) private readonly patch = inject<PatchDB<DataModel>>(PatchDB)
private readonly loader = inject(LoadingService) private readonly loader = inject(LoadingService)
private readonly errorService = inject(ErrorService) private readonly errorService = inject(ErrorService)
@@ -46,32 +46,32 @@ export class DomainsService {
{ {
domain: 'blog.mydomain.com', domain: 'blog.mydomain.com',
gateway: { gateway: {
id: '', id: 'wireguard1',
name: 'StartTunnel', name: 'StartTunnel',
}, },
acme: { authority: {
url: '', url: 'https://acme-v02.api.letsencrypt.org/directory',
name: `Lert's Encrypt`, name: `Let's Encrypt`,
}, },
}, },
{ {
domain: 'store.mydomain.com', domain: 'store.mydomain.com',
gateway: { gateway: {
id: '', id: 'eth0',
name: 'Ethernet', name: 'Ethernet',
}, },
acme: { authority: {
url: null, url: 'local',
name: 'System', 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) => ({
...obj, ...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, default: null,
patterns: [utils.Patterns.domain], patterns: [utils.Patterns.domain],
}), }),
...this.gatewaysAndAcme(), ...this.gatewaysAndAuthorities(),
}) })
this.formDialog.open(FormComponent, { this.formDialog.open(FormComponent, {
@@ -107,11 +107,11 @@ export class DomainsService {
async edit(domain: any) { async edit(domain: any) {
const editSpec = ISB.InputSpec.of({ const editSpec = ISB.InputSpec.of({
...this.gatewaysAndAcme(), ...this.gatewaysAndAuthorities(),
}) })
this.formDialog.open(FormComponent, { this.formDialog.open(FormComponent, {
label: 'Edit Domain' as any, // @TODO translation label: 'Edit Domain',
data: { data: {
spec: await configBuilderToSpec(editSpec), spec: await configBuilderToSpec(editSpec),
buttons: [ buttons: [
@@ -126,7 +126,7 @@ export class DomainsService {
], ],
value: { value: {
gateway: domain.gateway.id, gateway: domain.gateway.id,
acme: domain.acme.url, authority: domain.authority.url,
}, },
}, },
}) })
@@ -172,7 +172,7 @@ export class DomainsService {
} }
} }
private gatewaysAndAcme() { private gatewaysAndAuthorities() {
return { return {
gateway: ISB.Value.select({ gateway: ISB.Value.select({
name: 'Gateway', name: 'Gateway',
@@ -181,11 +181,11 @@ export class DomainsService {
values: this.data()!.gateways, values: this.data()!.gateways,
default: '', default: '',
}), }),
acme: ISB.Value.select({ authority: ISB.Value.select({
name: 'Default ACME', name: 'Default Certificate Authority',
description: description:
'Select the default ACME provider for this domain. This can be overridden on a case-by-case basis.', '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()!.acme, values: this.data()!.authorities,
default: '', default: '',
}), }),
} }

View File

@@ -11,14 +11,15 @@ import {
TuiDropdown, TuiDropdown,
TuiTextfield, TuiTextfield,
} from '@taiga-ui/core' } from '@taiga-ui/core'
import { DomainsService } from './domains.service' import { DomainService } from './domain.service'
@Component({ @Component({
selector: 'tr[domain]', selector: 'tr[domain]',
template: ` template: `
<td>{{ domain().domain }}</td> @if (domain(); as domain) {
<td [style.order]="-1">{{ domain().gateway.name }}</td> <td>{{ domain.domain }}</td>
<td>{{ domain().acme.name }}</td> <td [style.order]="-1">{{ domain.gateway.name }}</td>
<td>{{ domain.authority.name }}</td>
<td> <td>
<button <button
tuiIconButton tuiIconButton
@@ -36,23 +37,23 @@ import { DomainsService } from './domains.service'
tuiOption tuiOption
new new
iconStart="@tui.pencil" iconStart="@tui.pencil"
(click)="domainsService.edit(domain())" (click)="domainService.edit(domain)"
> >
{{ 'Edit' | i18n }} {{ 'Edit' | i18n }}
</button> </button>
<button <button
tuiOption tuiOption
new new
iconStart="@tui.shield" iconStart="@tui.eye"
(click)="domainsService.showDns(domain())" (click)="domainService.showDns(domain)"
> >
{{ 'Show DNS' | i18n }} {{ 'Show DNS' | i18n }}
</button> </button>
<button <button
tuiOption tuiOption
new new
iconStart="@tui.shield" iconStart="@tui.arrow-up-down"
(click)="domainsService.testDns(domain())" (click)="domainService.testDns(domain)"
> >
{{ 'Test DNS' | i18n }} {{ 'Test DNS' | i18n }}
</button> </button>
@@ -63,7 +64,7 @@ import { DomainsService } from './domains.service'
new new
iconStart="@tui.trash" iconStart="@tui.trash"
class="g-negative" class="g-negative"
(click)="domainsService.remove(domain())" (click)="domainService.remove(domain)"
> >
{{ 'Delete' | i18n }} {{ 'Delete' | i18n }}
</button> </button>
@@ -71,6 +72,7 @@ import { DomainsService } from './domains.service'
</tui-data-list> </tui-data-list>
</button> </button>
</td> </td>
}
`, `,
styles: ` styles: `
td:last-child { td:last-child {
@@ -91,8 +93,8 @@ import { DomainsService } from './domains.service'
changeDetection: ChangeDetectionStrategy.OnPush, changeDetection: ChangeDetectionStrategy.OnPush,
imports: [TuiButton, i18nPipe, TuiDropdown, TuiDataList, TuiTextfield], imports: [TuiButton, i18nPipe, TuiDropdown, TuiDataList, TuiTextfield],
}) })
export class DomainsItemComponent { export class DomainItemComponent {
protected readonly domainsService = inject(DomainsService) protected readonly domainService = inject(DomainService)
readonly domain = input.required<any>() 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 { i18nPipe } from '@start9labs/shared'
import { TuiSkeleton } from '@taiga-ui/kit' import { TuiSkeleton } from '@taiga-ui/kit'
import { PlaceholderComponent } from 'src/app/routes/portal/components/placeholder.component' import { PlaceholderComponent } from 'src/app/routes/portal/components/placeholder.component'
import { TableComponent } from 'src/app/routes/portal/components/table.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({ @Component({
selector: 'domains-table', selector: 'domains-table',
template: ` template: `
<table [appTable]="['Domain', 'Gateway', 'Default ACME', null]"> <table
@for (domain of domains(); track $index) { [appTable]="['Domain', 'Gateway', 'Default Certificate Authority', null]"
>
@for (domain of domainService.data()?.domains; track $index) {
<tr [domain]="domain"></tr> <tr [domain]="domain"></tr>
} @empty { } @empty {
<tr> <tr>
<td [attr.colspan]="4"> <td [attr.colspan]="4">
@if (domains()) { @if (domainService.data()?.domains) {
<app-placeholder icon="@tui.globe"> <app-placeholder icon="@tui.globe">
{{ 'No domains' | i18n }} {{ 'No domains' | i18n }}
</app-placeholder> </app-placeholder>
@@ -32,10 +35,9 @@ import { DomainsItemComponent } from './item.component'
i18nPipe, i18nPipe,
TableComponent, TableComponent,
PlaceholderComponent, PlaceholderComponent,
DomainsItemComponent, DomainItemComponent,
], ],
}) })
export class DomainsTableComponent { export class DomainsTableComponent {
// @TODO Alex proper types protected readonly domainService = inject(DomainService)
readonly domains = input<readonly any[] | null>()
} }

View File

@@ -12,7 +12,6 @@ import { TuiIcon } from '@taiga-ui/core'
import { TuiCheckbox, TuiFade, TuiSkeleton } from '@taiga-ui/kit' import { TuiCheckbox, TuiFade, TuiSkeleton } from '@taiga-ui/kit'
import { TableComponent } from 'src/app/routes/portal/components/table.component' import { TableComponent } from 'src/app/routes/portal/components/table.component'
import { Session } from 'src/app/services/api/api.types' import { Session } from 'src/app/services/api/api.types'
import { toAcmeName } from 'src/app/utils/acme'
import { PlatformInfoPipe } from './platform-info.pipe' import { PlatformInfoPipe } from './platform-info.pipe'
import { i18nPipe } from '@start9labs/shared' import { i18nPipe } from '@start9labs/shared'
@@ -182,6 +181,4 @@ export class SessionsTableComponent<T extends Session> implements OnChanges {
this.selected.update(selected => [...selected, session]) 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', icon: '@tui.door-open',
item: 'Gateways', item: 'Gateways',
link: '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 { T } from '@start9labs/start-sdk'
import { MarketplacePkg } from '@start9labs/marketplace' import { MarketplacePkg } from '@start9labs/marketplace'
import { WebSocketSubject } from 'rxjs/webSocket' 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' import markdown from './md-sample.md'
@@ -1396,7 +1396,7 @@ export class MockApiService extends ApiService {
op: PatchOp.ADD, op: PatchOp.ADD,
path: `/serverInfo/acme`, path: `/serverInfo/acme`,
value: { 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 { DataModel } from 'src/app/services/patch-db/data-model'
import { Mock } from './api.fixures' 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 const version = require('../../../../../../package.json').version
export const mockPatchData: DataModel = { export const mockPatchData: DataModel = {
@@ -28,7 +28,7 @@ export const mockPatchData: DataModel = {
lastRegion: null, lastRegion: null,
}, },
acme: { acme: {
[knownACME[0].url]: { [knownAuthorities[0].url]: {
contact: ['mailto:support@start9.com'], contact: ['mailto:support@start9.com'],
}, },
}, },
@@ -160,6 +160,20 @@ export const mockPatchData: DataModel = {
ntpServers: [], 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, unreadNotificationCount: 4,

View File

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