mirror of
https://github.com/Start9Labs/start-os.git
synced 2026-03-31 04:23:40 +00:00
add domains and gateways, remove routers, fix docs links
This commit is contained in:
@@ -44,7 +44,7 @@ import { DocsLinkDirective } from '@start9labs/shared'
|
|||||||
Download your server's Root CA and
|
Download your server's Root CA and
|
||||||
<a
|
<a
|
||||||
docsLink
|
docsLink
|
||||||
href="/user-manual/trust-ca.html"
|
path="/user-manual/trust-ca.html"
|
||||||
style="color: #6866cc; font-weight: bold; text-decoration: none"
|
style="color: #6866cc; font-weight: bold; text-decoration: none"
|
||||||
>
|
>
|
||||||
follow the instructions
|
follow the instructions
|
||||||
@@ -110,7 +110,7 @@ import { DocsLinkDirective } from '@start9labs/shared'
|
|||||||
This address will only work from a Tor-enabled browser.
|
This address will only work from a Tor-enabled browser.
|
||||||
<a
|
<a
|
||||||
docsLink
|
docsLink
|
||||||
href="/user-manual/connecting-remotely/tor.html"
|
path="/user-manual/connecting-remotely/tor.html"
|
||||||
style="color: #6866cc; font-weight: bold; text-decoration: none"
|
style="color: #6866cc; font-weight: bold; text-decoration: none"
|
||||||
>
|
>
|
||||||
Follow the instructions
|
Follow the instructions
|
||||||
|
|||||||
@@ -19,11 +19,13 @@ export const VERSION = new InjectionToken<string>('VERSION')
|
|||||||
export class DocsLinkDirective {
|
export class DocsLinkDirective {
|
||||||
private readonly version = inject(VERSION)
|
private readonly version = inject(VERSION)
|
||||||
|
|
||||||
readonly href = input.required<string>()
|
readonly path = input.required<string>()
|
||||||
|
|
||||||
|
readonly fragment = input<string>('')
|
||||||
|
|
||||||
protected readonly url = computed(() => {
|
protected readonly url = computed(() => {
|
||||||
const path = this.href()
|
const path = this.path()
|
||||||
const relative = path.startsWith('/') ? path : `/${path}`
|
const relative = path.startsWith('/') ? path : `/${path}`
|
||||||
return `https://docs.start9.com${relative}?os=${this.version}`
|
return `https://docs.start9.com${relative}?os=${this.version}${this.fragment()}`
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -296,8 +296,6 @@ export default {
|
|||||||
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: 'Fügen Sie ACME-Anbieter hinzu, um SSL-(https)-Zertifikate für den Clearnet-Zugriff zu generieren.',
|
||||||
300: 'Anleitung anzeigen',
|
300: 'Anleitung anzeigen',
|
||||||
301: 'Gespeicherte Anbieter',
|
|
||||||
302: 'Anbieter hinzufügen',
|
|
||||||
303: 'Kontakt',
|
303: 'Kontakt',
|
||||||
304: 'Bearbeiten',
|
304: 'Bearbeiten',
|
||||||
305: 'ACME-Anbieter hinzufügen',
|
305: 'ACME-Anbieter hinzufügen',
|
||||||
@@ -528,12 +526,17 @@ export default {
|
|||||||
530: 'StartOS-Paket',
|
530: 'StartOS-Paket',
|
||||||
531: 'Fehler beim Initialisieren des Servers',
|
531: 'Fehler beim Initialisieren des Servers',
|
||||||
532: 'Abgeschlossen',
|
532: 'Abgeschlossen',
|
||||||
533: 'Eingehende Proxys',
|
533: 'Gateways',
|
||||||
534: 'Eingehende Proxys ermöglichen den Fernzugriff auf Ihren Server und installierte Dienste.',
|
534: 'Gateways verbinden Ihren Server mit dem Internet. Sie verarbeiten ausgehenden Datenverkehr und erlauben unter bestimmten Bedingungen auch eingehenden Verkehr.',
|
||||||
535: 'Gespeicherte Proxys',
|
535: 'Gateway hinzufügen',
|
||||||
536: 'Proxy hinzufügen',
|
536: 'Umbenennen',
|
||||||
537: 'Bezeichnung',
|
537: 'Zugriff',
|
||||||
538: 'Keine Proxys',
|
538: 'Domains',
|
||||||
539: 'Bezeichnung aktualisieren',
|
539: 'ACME-Anbieter',
|
||||||
540: 'Umbenennen',
|
540: 'Domain',
|
||||||
|
541: 'Gateway',
|
||||||
|
542: 'Standard-ACME',
|
||||||
|
543: 'Gateway ändern',
|
||||||
|
544: 'Standard-ACME ändern',
|
||||||
|
545: 'Keine Domains',
|
||||||
} satisfies i18n
|
} satisfies i18n
|
||||||
|
|||||||
@@ -295,8 +295,6 @@ export const ENGLISH = {
|
|||||||
'Invalid package file': 298,
|
'Invalid package file': 298,
|
||||||
'Add ACME providers in order to generate SSL (https) certificates for clearnet access.': 299,
|
'Add ACME providers in order to generate SSL (https) certificates for clearnet access.': 299,
|
||||||
'View instructions': 300,
|
'View instructions': 300,
|
||||||
'Saved Providers': 301, // as in, ACME service provider, such as Let's Encrypt
|
|
||||||
'Add Provider': 302,
|
|
||||||
'Contact': 303, // as in, "contact us"
|
'Contact': 303, // as in, "contact us"
|
||||||
'Edit': 304,
|
'Edit': 304,
|
||||||
'Add ACME Provider': 305,
|
'Add ACME Provider': 305,
|
||||||
@@ -527,12 +525,17 @@ export const ENGLISH = {
|
|||||||
'StartOS package': 530, // as in, the URL of the source code for the StartOS package
|
'StartOS package': 530, // as in, the URL of the source code for the StartOS package
|
||||||
'Error initializing server': 531,
|
'Error initializing server': 531,
|
||||||
'Finished': 532, // an in, complete
|
'Finished': 532, // an in, complete
|
||||||
'Inbound Proxies': 533, // as in a service used to proxy internet traffic
|
'Gateways': 533, // as in, a device or software that connects two different networks
|
||||||
'Inbound proxies provide remote access to your server and installed services.': 534,
|
'Gateways connect your server to the Internet. They process outbound traffic, and under certain conditions, they also permit inbound traffic.': 534,
|
||||||
'Saved Proxies': 535, // as in, a list of proxies already added to StartOS
|
'Add Gateway': 535, // as in, add a new network gateway to StartOS
|
||||||
'Add Proxy': 536, // as in, add a new proxy to StartOS
|
'Rename': 536,
|
||||||
'Label': 537, // as in, a name given to something
|
'Access': 537, // as in, public or private access, almost "permission"
|
||||||
'No proxies': 538,
|
'Domains': 538, // as in, internet domains
|
||||||
'Update Label': 539,
|
'ACME Providers': 539,
|
||||||
'Rename': 540
|
'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
|
||||||
|
'No domains': 545,
|
||||||
} as const
|
} as const
|
||||||
|
|||||||
@@ -296,8 +296,6 @@ export default {
|
|||||||
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: 'Agrega proveedores ACME para generar certificados SSL (https) para el acceso desde clearnet.',
|
||||||
300: 'Ver instrucciones',
|
300: 'Ver instrucciones',
|
||||||
301: 'Proveedores guardados',
|
|
||||||
302: 'Agregar proveedor',
|
|
||||||
303: 'Contacto',
|
303: 'Contacto',
|
||||||
304: 'Editar',
|
304: 'Editar',
|
||||||
305: 'Agregar proveedor ACME',
|
305: 'Agregar proveedor ACME',
|
||||||
@@ -528,12 +526,17 @@ export default {
|
|||||||
530: 'Paquete StartOS',
|
530: 'Paquete StartOS',
|
||||||
531: 'Error al inicializar el servidor',
|
531: 'Error al inicializar el servidor',
|
||||||
532: 'Finalizado',
|
532: 'Finalizado',
|
||||||
533: 'Proxies entrantes',
|
533: 'Puertas de enlace',
|
||||||
534: 'Los proxies entrantes proporcionan acceso remoto a su servidor y servicios instalados.',
|
534: 'Las puertas de enlace conectan su servidor a Internet. Procesan el tráfico saliente y, en ciertas condiciones, también permiten tráfico entrante.',
|
||||||
535: 'Proxies guardados',
|
535: 'Agregar puerta de enlace',
|
||||||
536: 'Agregar proxy',
|
536: 'Renombrar',
|
||||||
537: 'Etiqueta',
|
537: 'Acceso',
|
||||||
538: 'Sin proxies',
|
538: 'Dominios',
|
||||||
539: 'Actualizar etiqueta',
|
539: 'Proveedores ACME',
|
||||||
540: 'Renombrar',
|
540: 'Dominio',
|
||||||
|
541: 'Puerta de enlace',
|
||||||
|
542: 'ACME predeterminado',
|
||||||
|
543: 'Cambiar puerta de enlace',
|
||||||
|
544: 'Cambiar ACME predeterminado',
|
||||||
|
545: 'Sin dominios',
|
||||||
} satisfies i18n
|
} satisfies i18n
|
||||||
|
|||||||
@@ -296,8 +296,6 @@ export default {
|
|||||||
298: 'Fichier paquet invalide',
|
298: 'Fichier paquet invalide',
|
||||||
299: 'Ajoutez des fournisseurs ACME pour générer des certificats SSL (https) pour l’accès clearnet.',
|
299: 'Ajoutez des fournisseurs ACME pour générer des certificats SSL (https) pour l’accès clearnet.',
|
||||||
300: 'Voir les instructions',
|
300: 'Voir les instructions',
|
||||||
301: 'Fournisseurs enregistrés',
|
|
||||||
302: 'Ajouter un fournisseur',
|
|
||||||
303: 'Contact',
|
303: 'Contact',
|
||||||
304: 'Modifier',
|
304: 'Modifier',
|
||||||
305: 'Ajouter un fournisseur ACME',
|
305: 'Ajouter un fournisseur ACME',
|
||||||
@@ -528,12 +526,17 @@ export default {
|
|||||||
530: 'Paquet StartOS',
|
530: 'Paquet StartOS',
|
||||||
531: "Erreur lors de l'initialisation du serveur",
|
531: "Erreur lors de l'initialisation du serveur",
|
||||||
532: 'Terminé',
|
532: 'Terminé',
|
||||||
533: 'Proxies entrants',
|
533: 'Passerelles',
|
||||||
534: 'Les proxies entrants permettent un accès à distance à votre serveur et aux services installés.',
|
534: 'Les passerelles connectent votre serveur à Internet. Elles traitent le trafic sortant et, dans certaines conditions, autorisent également le trafic entrant.',
|
||||||
535: 'Proxies enregistrés',
|
535: 'Ajouter une passerelle',
|
||||||
536: 'Ajouter un proxy',
|
536: 'Renommer',
|
||||||
537: 'Étiquette',
|
537: 'Accès',
|
||||||
538: 'Aucun proxy',
|
538: 'Domaines',
|
||||||
539: 'Mettre à jour l’étiquette',
|
539: 'Fournisseurs ACME',
|
||||||
540: 'Renommer',
|
540: 'Domaine',
|
||||||
|
541: 'Passerelle',
|
||||||
|
542: 'ACME par défaut',
|
||||||
|
543: 'Changer de passerelle',
|
||||||
|
544: 'Changer l’ACME par défaut',
|
||||||
|
545: 'Aucun domaine',
|
||||||
} satisfies i18n
|
} satisfies i18n
|
||||||
|
|||||||
@@ -296,8 +296,6 @@ export default {
|
|||||||
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: 'Dodaj dostawców ACME, aby wygenerować certyfikaty SSL (https) dla dostępu przez clearnet.',
|
||||||
300: 'Zobacz instrukcje',
|
300: 'Zobacz instrukcje',
|
||||||
301: 'Zapisani dostawcy',
|
|
||||||
302: 'Dodaj dostawcę',
|
|
||||||
303: 'Kontakt',
|
303: 'Kontakt',
|
||||||
304: 'Edytuj',
|
304: 'Edytuj',
|
||||||
305: 'Dodaj dostawcę ACME',
|
305: 'Dodaj dostawcę ACME',
|
||||||
@@ -528,12 +526,17 @@ export default {
|
|||||||
530: 'Pakiet StartOS',
|
530: 'Pakiet StartOS',
|
||||||
531: 'Błąd inicjalizacji serwera',
|
531: 'Błąd inicjalizacji serwera',
|
||||||
532: 'Zakończono',
|
532: 'Zakończono',
|
||||||
533: 'Proksy przychodzące',
|
533: 'Bramy sieciowe',
|
||||||
534: 'Proksy przychodzące zapewniają zdalny dostęp do twojego serwera i zainstalowanych usług.',
|
534: 'Bramy łączą twój serwer z Internetem. Przetwarzają ruch wychodzący, a w pewnych warunkach również dopuszczają ruch przychodzący.',
|
||||||
535: 'Zapisane proksy',
|
535: 'Dodaj bramę',
|
||||||
536: 'Dodaj proksy',
|
536: 'Zmień nazwę',
|
||||||
537: 'Etykieta',
|
537: 'Dostęp',
|
||||||
538: 'Brak proksy',
|
538: 'Domeny',
|
||||||
539: 'Aktualizuj etykietę',
|
539: 'Dostawcy ACME',
|
||||||
540: 'Zmień nazwę',
|
540: 'Domena',
|
||||||
|
541: 'Brama',
|
||||||
|
542: 'Domyślny ACME',
|
||||||
|
543: 'Zmień bramę',
|
||||||
|
544: 'Zmień domyślny ACME',
|
||||||
|
545: 'Brak domen',
|
||||||
} satisfies i18n
|
} satisfies i18n
|
||||||
|
|||||||
@@ -46,7 +46,7 @@
|
|||||||
tuiButton
|
tuiButton
|
||||||
docsLink
|
docsLink
|
||||||
size="s"
|
size="s"
|
||||||
href="/user-manual/trust-ca.html"
|
path="/user-manual/trust-ca.html"
|
||||||
iconEnd="@tui.external-link"
|
iconEnd="@tui.external-link"
|
||||||
>
|
>
|
||||||
{{ 'View instructions' | i18n }}
|
{{ 'View instructions' | i18n }}
|
||||||
|
|||||||
@@ -50,7 +50,7 @@ import { ABOUT } from './about.component'
|
|||||||
</button>
|
</button>
|
||||||
</tui-opt-group>
|
</tui-opt-group>
|
||||||
<tui-opt-group label="" safeLinks>
|
<tui-opt-group label="" safeLinks>
|
||||||
<a tuiOption docsLink iconStart="@tui.book-open" href="/user-manual">
|
<a tuiOption docsLink iconStart="@tui.book-open" path="/user-manual">
|
||||||
{{ 'User manual' | i18n }}
|
{{ 'User manual' | i18n }}
|
||||||
</a>
|
</a>
|
||||||
<a
|
<a
|
||||||
|
|||||||
@@ -60,7 +60,7 @@ type ClearnetForm = {
|
|||||||
<a
|
<a
|
||||||
tuiLink
|
tuiLink
|
||||||
docsLink
|
docsLink
|
||||||
href="/user-manual/connecting-remotely/clearnet.html"
|
path="/user-manual/connecting-remotely/clearnet.html"
|
||||||
>
|
>
|
||||||
{{ 'Learn more' | i18n }}
|
{{ 'Learn more' | i18n }}
|
||||||
</a>
|
</a>
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ import { DocsLinkDirective, i18nPipe } from '@start9labs/shared'
|
|||||||
'Local addresses can only be accessed by devices connected to the same LAN as your server, either directly or using a VPN.'
|
'Local addresses can only be accessed by devices connected to the same LAN as your server, either directly or using a VPN.'
|
||||||
| i18n
|
| i18n
|
||||||
}}
|
}}
|
||||||
<a tuiLink docsLink href="/user-manual/connecting-locally.html">
|
<a tuiLink docsLink path="/user-manual/connecting-locally.html">
|
||||||
{{ 'Learn More' | i18n }}
|
{{ 'Learn More' | i18n }}
|
||||||
</a>
|
</a>
|
||||||
</ng-template>
|
</ng-template>
|
||||||
|
|||||||
@@ -50,7 +50,7 @@ type OnionForm = {
|
|||||||
'Add an onion address to anonymously expose this interface on the darknet. Onion addresses can only be reached over the Tor network.'
|
'Add an onion address to anonymously expose this interface on the darknet. Onion addresses can only be reached over the Tor network.'
|
||||||
| i18n
|
| i18n
|
||||||
}}
|
}}
|
||||||
<a tuiLink docsLink href="/user-manual/connecting-remotely/tor.html">
|
<a tuiLink docsLink path="/user-manual/connecting-remotely/tor.html">
|
||||||
{{ 'Learn More' | i18n }}
|
{{ 'Learn More' | i18n }}
|
||||||
</a>
|
</a>
|
||||||
</ng-template>
|
</ng-template>
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ import { DocsLinkDirective } from 'projects/shared/src/public-api'
|
|||||||
Scheduling automatic backups is an excellent way to ensure your StartOS
|
Scheduling automatic backups is an excellent way to ensure your StartOS
|
||||||
data is safely backed up. StartOS will issue a notification whenever one
|
data is safely backed up. StartOS will issue a notification whenever one
|
||||||
of your scheduled backups succeeds or fails.
|
of your scheduled backups succeeds or fails.
|
||||||
<a tuiLink docsLink href="/@TODO">View instructions</a>
|
<a tuiLink docsLink path="/@TODO">View instructions</a>
|
||||||
</tui-notification>
|
</tui-notification>
|
||||||
<h3 class="g-title">
|
<h3 class="g-title">
|
||||||
Saved Jobs
|
Saved Jobs
|
||||||
|
|||||||
@@ -31,7 +31,7 @@ import { DocsLinkDirective } from 'projects/shared/src/public-api'
|
|||||||
backups. They can be physical drives plugged into your server, shared
|
backups. They can be physical drives plugged into your server, shared
|
||||||
folders on your Local Area Network (LAN), or third party clouds such as
|
folders on your Local Area Network (LAN), or third party clouds such as
|
||||||
Dropbox or Google Drive.
|
Dropbox or Google Drive.
|
||||||
<a tuiLink docsLink href="/@TODO">View instructions</a>
|
<a tuiLink docsLink path="/@TODO">View instructions</a>
|
||||||
</tui-notification>
|
</tui-notification>
|
||||||
<h3 class="g-title">
|
<h3 class="g-title">
|
||||||
Unknown Physical Drives
|
Unknown Physical Drives
|
||||||
|
|||||||
@@ -49,7 +49,8 @@ import { TimeService } from 'src/app/services/time.service'
|
|||||||
docsLink
|
docsLink
|
||||||
iconEnd="@tui.external-link"
|
iconEnd="@tui.external-link"
|
||||||
appearance=""
|
appearance=""
|
||||||
href="/help/common-issues.html#clock-sync-failure"
|
path="/help/common-issues.html"
|
||||||
|
fragment="#clock-sync-failure"
|
||||||
[pseudo]="true"
|
[pseudo]="true"
|
||||||
[textContent]="'the docs' | i18n"
|
[textContent]="'the docs' | i18n"
|
||||||
></a>
|
></a>
|
||||||
|
|||||||
@@ -1,290 +0,0 @@
|
|||||||
import { ChangeDetectionStrategy, Component, inject } from '@angular/core'
|
|
||||||
import { toSignal } from '@angular/core/rxjs-interop'
|
|
||||||
import { RouterLink } from '@angular/router'
|
|
||||||
import {
|
|
||||||
DocsLinkDirective,
|
|
||||||
ErrorService,
|
|
||||||
i18nPipe,
|
|
||||||
LoadingService,
|
|
||||||
} from '@start9labs/shared'
|
|
||||||
import { ISB, utils } from '@start9labs/start-sdk'
|
|
||||||
import { TuiButton, TuiLink, TuiLoader, TuiTitle } from '@taiga-ui/core'
|
|
||||||
import { TuiCell, TuiHeader } from '@taiga-ui/layout'
|
|
||||||
import { PatchDB } from 'patch-db-client'
|
|
||||||
import { map } from 'rxjs'
|
|
||||||
import { FormComponent } from 'src/app/routes/portal/components/form.component'
|
|
||||||
import { PlaceholderComponent } from 'src/app/routes/portal/components/placeholder.component'
|
|
||||||
import { ApiService } from 'src/app/services/api/embassy-api.service'
|
|
||||||
import { FormDialogService } from 'src/app/services/form-dialog.service'
|
|
||||||
import { DataModel } from 'src/app/services/patch-db/data-model'
|
|
||||||
import { TitleDirective } from 'src/app/services/title.service'
|
|
||||||
import { knownACME, toAcmeName } from 'src/app/utils/acme'
|
|
||||||
import { configBuilderToSpec } from 'src/app/utils/configBuilderToSpec'
|
|
||||||
|
|
||||||
@Component({
|
|
||||||
template: `
|
|
||||||
<ng-container *title>
|
|
||||||
<a routerLink=".." tuiIconButton iconStart="@tui.arrow-left">
|
|
||||||
{{ 'Back' | i18n }}
|
|
||||||
</a>
|
|
||||||
ACME
|
|
||||||
</ng-container>
|
|
||||||
<header tuiHeader>
|
|
||||||
<hgroup tuiTitle>
|
|
||||||
<h3>ACME</h3>
|
|
||||||
<p tuiSubtitle>
|
|
||||||
{{
|
|
||||||
'Add ACME providers in order to generate SSL (https) certificates for clearnet access.'
|
|
||||||
| i18n
|
|
||||||
}}
|
|
||||||
<a
|
|
||||||
tuiLink
|
|
||||||
docsLink
|
|
||||||
href="/user-manual/connecting-remotely/clearnet.html#adding-acme"
|
|
||||||
appearance="action-grayscale"
|
|
||||||
iconEnd="@tui.external-link"
|
|
||||||
[pseudo]="true"
|
|
||||||
[textContent]="'View instructions' | i18n"
|
|
||||||
></a>
|
|
||||||
</p>
|
|
||||||
</hgroup>
|
|
||||||
</header>
|
|
||||||
<section class="g-card">
|
|
||||||
<header>
|
|
||||||
{{ 'Saved Providers' | i18n }}
|
|
||||||
@if (acme(); as value) {
|
|
||||||
<button
|
|
||||||
tuiButton
|
|
||||||
size="xs"
|
|
||||||
iconStart="@tui.plus"
|
|
||||||
[style.margin-inline-start]="'auto'"
|
|
||||||
(click)="addAcme(value)"
|
|
||||||
>
|
|
||||||
{{ 'Add Provider' | i18n }}
|
|
||||||
</button>
|
|
||||||
}
|
|
||||||
</header>
|
|
||||||
@if (acme(); as value) {
|
|
||||||
@for (provider of value; track $index) {
|
|
||||||
<div tuiCell>
|
|
||||||
<span tuiTitle>
|
|
||||||
<strong>{{ toAcmeName(provider.url) }}</strong>
|
|
||||||
<span tuiSubtitle>
|
|
||||||
{{ 'Contact' | i18n }}: {{ provider.contactString }}
|
|
||||||
</span>
|
|
||||||
</span>
|
|
||||||
<button
|
|
||||||
tuiIconButton
|
|
||||||
iconStart="@tui.pencil"
|
|
||||||
appearance="icon"
|
|
||||||
(click)="editAcme(provider.url, provider.contact)"
|
|
||||||
>
|
|
||||||
{{ 'Edit' | i18n }}
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
tuiIconButton
|
|
||||||
iconStart="@tui.trash"
|
|
||||||
appearance="icon"
|
|
||||||
(click)="removeAcme(provider.url)"
|
|
||||||
>
|
|
||||||
{{ 'Edit' | i18n }}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
} @empty {
|
|
||||||
<app-placeholder icon="@tui.shield-question">
|
|
||||||
{{ 'No saved providers' | i18n }}
|
|
||||||
</app-placeholder>
|
|
||||||
}
|
|
||||||
} @else {
|
|
||||||
<tui-loader [style.height.rem]="5" />
|
|
||||||
}
|
|
||||||
</section>
|
|
||||||
`,
|
|
||||||
styles: `
|
|
||||||
:host {
|
|
||||||
max-width: 36rem;
|
|
||||||
}
|
|
||||||
`,
|
|
||||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
|
||||||
imports: [
|
|
||||||
TuiButton,
|
|
||||||
TuiLoader,
|
|
||||||
TuiCell,
|
|
||||||
TuiTitle,
|
|
||||||
TuiHeader,
|
|
||||||
TuiLink,
|
|
||||||
RouterLink,
|
|
||||||
TitleDirective,
|
|
||||||
i18nPipe,
|
|
||||||
DocsLinkDirective,
|
|
||||||
PlaceholderComponent,
|
|
||||||
],
|
|
||||||
})
|
|
||||||
export default class SystemAcmeComponent {
|
|
||||||
private readonly formDialog = inject(FormDialogService)
|
|
||||||
private readonly loader = inject(LoadingService)
|
|
||||||
private readonly errorService = inject(ErrorService)
|
|
||||||
private readonly patch = inject<PatchDB<DataModel>>(PatchDB)
|
|
||||||
private readonly api = inject(ApiService)
|
|
||||||
private readonly i18n = inject(i18nPipe)
|
|
||||||
|
|
||||||
acme = toSignal(
|
|
||||||
this.patch.watch$('serverInfo', 'network', 'acme').pipe(
|
|
||||||
map(acme =>
|
|
||||||
Object.keys(acme).map(url => {
|
|
||||||
const contact =
|
|
||||||
acme[url]?.contact.map(mailto => mailto.replace('mailto:', '')) ||
|
|
||||||
[]
|
|
||||||
return {
|
|
||||||
url,
|
|
||||||
contact,
|
|
||||||
contactString: contact.join(', '),
|
|
||||||
}
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
toAcmeName = toAcmeName
|
|
||||||
|
|
||||||
async addAcme(
|
|
||||||
providers: {
|
|
||||||
url: string
|
|
||||||
contact: string[]
|
|
||||||
contactString: string
|
|
||||||
}[],
|
|
||||||
) {
|
|
||||||
this.formDialog.open(FormComponent, {
|
|
||||||
label: 'Add ACME Provider',
|
|
||||||
data: {
|
|
||||||
spec: await configBuilderToSpec(
|
|
||||||
this.addAcmeSpec(providers.map(p => p.url)),
|
|
||||||
),
|
|
||||||
buttons: [
|
|
||||||
{
|
|
||||||
text: this.i18n.transform('Save'),
|
|
||||||
handler: async (
|
|
||||||
val: ReturnType<typeof this.addAcmeSpec>['_TYPE'],
|
|
||||||
) => {
|
|
||||||
const providerUrl =
|
|
||||||
val.provider.selection === 'other'
|
|
||||||
? val.provider.value.url
|
|
||||||
: val.provider.selection
|
|
||||||
|
|
||||||
return this.saveAcme(providerUrl, val.contact)
|
|
||||||
},
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
async editAcme(provider: string, contact: string[]) {
|
|
||||||
this.formDialog.open(FormComponent, {
|
|
||||||
label: 'Edit ACME Provider',
|
|
||||||
data: {
|
|
||||||
spec: await configBuilderToSpec(this.editAcmeSpec()),
|
|
||||||
buttons: [
|
|
||||||
{
|
|
||||||
text: this.i18n.transform('Save'),
|
|
||||||
handler: async (
|
|
||||||
val: ReturnType<typeof this.editAcmeSpec>['_TYPE'],
|
|
||||||
) => this.saveAcme(provider, val.contact),
|
|
||||||
},
|
|
||||||
],
|
|
||||||
value: { contact },
|
|
||||||
},
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
async removeAcme(provider: string) {
|
|
||||||
const loader = this.loader.open('Removing').subscribe()
|
|
||||||
|
|
||||||
try {
|
|
||||||
await this.api.removeAcme({ provider })
|
|
||||||
} catch (e: any) {
|
|
||||||
this.errorService.handleError(e)
|
|
||||||
} finally {
|
|
||||||
loader.unsubscribe()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private async saveAcme(providerUrl: string, contact: string[]) {
|
|
||||||
const loader = this.loader.open('Saving').subscribe()
|
|
||||||
|
|
||||||
try {
|
|
||||||
await this.api.initAcme({
|
|
||||||
provider: new URL(providerUrl).href,
|
|
||||||
contact: contact.map(address => `mailto:${address}`),
|
|
||||||
})
|
|
||||||
return true
|
|
||||||
} catch (e: any) {
|
|
||||||
this.errorService.handleError(e)
|
|
||||||
return false
|
|
||||||
} finally {
|
|
||||||
loader.unsubscribe()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private addAcmeSpec(providers: string[]) {
|
|
||||||
const availableAcme = knownACME.filter(
|
|
||||||
acme => !providers.includes(acme.url),
|
|
||||||
)
|
|
||||||
|
|
||||||
return ISB.InputSpec.of({
|
|
||||||
provider: ISB.Value.union({
|
|
||||||
name: 'Provider',
|
|
||||||
default: (availableAcme[0]?.url as any) || 'other',
|
|
||||||
variants: ISB.Variants.of({
|
|
||||||
...availableAcme.reduce(
|
|
||||||
(obj, curr) => ({
|
|
||||||
...obj,
|
|
||||||
[curr.url]: {
|
|
||||||
name: curr.name,
|
|
||||||
spec: ISB.InputSpec.of({}),
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
{},
|
|
||||||
),
|
|
||||||
other: {
|
|
||||||
name: 'Other',
|
|
||||||
spec: ISB.InputSpec.of({
|
|
||||||
url: ISB.Value.text({
|
|
||||||
name: 'URL',
|
|
||||||
default: null,
|
|
||||||
required: true,
|
|
||||||
inputmode: 'url',
|
|
||||||
patterns: [utils.Patterns.url],
|
|
||||||
}),
|
|
||||||
}),
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
}),
|
|
||||||
contact: this.emailListSpec(),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
private editAcmeSpec() {
|
|
||||||
return ISB.InputSpec.of({
|
|
||||||
contact: this.emailListSpec(),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
private emailListSpec() {
|
|
||||||
return ISB.Value.list(
|
|
||||||
ISB.List.text(
|
|
||||||
{
|
|
||||||
name: this.i18n.transform('Contact Emails')!,
|
|
||||||
description: this.i18n.transform(
|
|
||||||
'Needed to obtain a certificate from a Certificate Authority',
|
|
||||||
),
|
|
||||||
minLength: 1,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
inputmode: 'email',
|
|
||||||
patterns: [utils.Patterns.email],
|
|
||||||
},
|
|
||||||
),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -66,7 +66,7 @@ import { BACKUP_RESTORE } from './restore.component'
|
|||||||
<a
|
<a
|
||||||
tuiLink
|
tuiLink
|
||||||
docsLink
|
docsLink
|
||||||
href="/user-manual/backup-create.html"
|
path="/user-manual/backup-create.html"
|
||||||
appearance="action-grayscale"
|
appearance="action-grayscale"
|
||||||
iconEnd="@tui.external-link"
|
iconEnd="@tui.external-link"
|
||||||
[pseudo]="true"
|
[pseudo]="true"
|
||||||
@@ -80,7 +80,7 @@ import { BACKUP_RESTORE } from './restore.component'
|
|||||||
<a
|
<a
|
||||||
tuiLink
|
tuiLink
|
||||||
docsLink
|
docsLink
|
||||||
href="/user-manual/backup-restore.html"
|
path="/user-manual/backup-restore.html"
|
||||||
appearance="action-grayscale"
|
appearance="action-grayscale"
|
||||||
iconEnd="@tui.external-link"
|
iconEnd="@tui.external-link"
|
||||||
[pseudo]="true"
|
[pseudo]="true"
|
||||||
@@ -123,7 +123,8 @@ import { BACKUP_RESTORE } from './restore.component'
|
|||||||
<a
|
<a
|
||||||
tuiLink
|
tuiLink
|
||||||
docsLink
|
docsLink
|
||||||
href="/user-manual/backup-create.html#network-folder"
|
path="/user-manual/backup-create.html"
|
||||||
|
fragment="#network-folder"
|
||||||
appearance="action-grayscale"
|
appearance="action-grayscale"
|
||||||
iconEnd="@tui.external-link"
|
iconEnd="@tui.external-link"
|
||||||
[pseudo]="true"
|
[pseudo]="true"
|
||||||
|
|||||||
@@ -1,134 +0,0 @@
|
|||||||
import { ISB } from '@start9labs/start-sdk'
|
|
||||||
import { Proxy } from 'src/app/services/patch-db/data-model'
|
|
||||||
import { configBuilderToSpec } from 'src/app/utils/configBuilderToSpec'
|
|
||||||
|
|
||||||
const auth = ISB.InputSpec.of({
|
|
||||||
username: ISB.Value.text({
|
|
||||||
name: 'Username',
|
|
||||||
required: true,
|
|
||||||
default: null,
|
|
||||||
}),
|
|
||||||
password: ISB.Value.text({
|
|
||||||
name: 'Password',
|
|
||||||
required: true,
|
|
||||||
default: null,
|
|
||||||
masked: true,
|
|
||||||
}),
|
|
||||||
})
|
|
||||||
|
|
||||||
function getStrategyUnion(proxies: Proxy[]) {
|
|
||||||
const inboundProxies: Record<string, string> = proxies
|
|
||||||
.filter(p => p.type === 'inbound-outbound')
|
|
||||||
.reduce(
|
|
||||||
(prev, curr) => ({
|
|
||||||
[curr.id]: curr.name,
|
|
||||||
...prev,
|
|
||||||
}),
|
|
||||||
{},
|
|
||||||
)
|
|
||||||
|
|
||||||
return ISB.Value.union(
|
|
||||||
{
|
|
||||||
name: 'Networking Strategy',
|
|
||||||
default: 'local',
|
|
||||||
description: `<h5>Local</h5>Select this option if you do not mind exposing your home/business IP address to the Internet. This option requires configuring router settings, which StartOS can do automatically if you have an OpenWRT router
|
|
||||||
<h5>Proxy</h5>Select this option is you prefer to hide your home/business IP address from the Internet. This option requires running your own Virtual Private Server (VPS) <i>or</i> paying service provider such as Static Wire
|
|
||||||
`,
|
|
||||||
},
|
|
||||||
ISB.Variants.of({
|
|
||||||
local: {
|
|
||||||
name: 'Local',
|
|
||||||
spec: ISB.InputSpec.of({
|
|
||||||
ipStrategy: ISB.Value.select({
|
|
||||||
name: 'IP Strategy',
|
|
||||||
description: `<h5>IPv6 Only (recommended)</h5><b>Requirements</b>:<ol><li>ISP IPv6 support</li><li>OpenWRT (recommended) or Linksys router</li></ol><b>Pros</b>: Ready for IPv6 Internet. Enhanced privacy. Run multiple clearnet servers from the same network
|
|
||||||
<b>Cons</b>: Interfaces using this domain will only be accessible to people whose ISP supports IPv6
|
|
||||||
<h5>IPv6 and IPv4</h5><b>Pros</b>: Ready for IPv6 Internet. Accessible by anyone
|
|
||||||
<b>Cons</b>: Less private, as IPv4 addresses are closely correlated with geographic areas. Cannot run multiple clearnet servers from the same network
|
|
||||||
<h5>IPv4 Only</h5><b>Pros</b>: Accessible by anyone
|
|
||||||
<b>Cons</b>: Less private, as IPv4 addresses are closely correlated with geographic areas. Cannot run multiple clearnet servers from the same network
|
|
||||||
`,
|
|
||||||
default: 'ipv6',
|
|
||||||
values: {
|
|
||||||
ipv6: 'IPv6 Only',
|
|
||||||
ipv4: 'IPv4 Only',
|
|
||||||
dualstack: 'IPv6 and IPv4',
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
}),
|
|
||||||
},
|
|
||||||
proxy: {
|
|
||||||
name: 'Proxy',
|
|
||||||
spec: ISB.InputSpec.of({
|
|
||||||
proxyId: ISB.Value.select({
|
|
||||||
name: 'Select Proxy',
|
|
||||||
default: proxies.filter(p => p.type === 'inbound-outbound')[0].id,
|
|
||||||
values: inboundProxies,
|
|
||||||
}),
|
|
||||||
}),
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getStart9ToSpec(proxies: Proxy[]) {
|
|
||||||
return configBuilderToSpec(
|
|
||||||
ISB.InputSpec.of({
|
|
||||||
strategy: getStrategyUnion(proxies),
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getCustomSpec(proxies: Proxy[]) {
|
|
||||||
return configBuilderToSpec(
|
|
||||||
ISB.InputSpec.of({
|
|
||||||
hostname: ISB.Value.text({
|
|
||||||
name: 'Hostname',
|
|
||||||
required: true,
|
|
||||||
default: null,
|
|
||||||
placeholder: 'yourdomain.com',
|
|
||||||
}),
|
|
||||||
provider: ISB.Value.union(
|
|
||||||
{
|
|
||||||
name: 'Dynamic DNS Provider',
|
|
||||||
default: 'start9',
|
|
||||||
},
|
|
||||||
ISB.Variants.of({
|
|
||||||
start9: {
|
|
||||||
name: 'Start9',
|
|
||||||
spec: ISB.InputSpec.of({}),
|
|
||||||
},
|
|
||||||
njalla: {
|
|
||||||
name: 'Njalla',
|
|
||||||
spec: auth,
|
|
||||||
},
|
|
||||||
duckdns: {
|
|
||||||
name: 'Duck DNS',
|
|
||||||
spec: auth,
|
|
||||||
},
|
|
||||||
dyn: {
|
|
||||||
name: 'DynDNS',
|
|
||||||
spec: auth,
|
|
||||||
},
|
|
||||||
easydns: {
|
|
||||||
name: 'easyDNS',
|
|
||||||
spec: auth,
|
|
||||||
},
|
|
||||||
zoneedit: {
|
|
||||||
name: 'Zoneedit',
|
|
||||||
spec: auth,
|
|
||||||
},
|
|
||||||
googledomains: {
|
|
||||||
name: 'Google Domains (IPv4 or IPv6)',
|
|
||||||
spec: auth,
|
|
||||||
},
|
|
||||||
namecheap: {
|
|
||||||
name: 'Namecheap (IPv4 only)',
|
|
||||||
spec: auth,
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
strategy: getStrategyUnion(proxies),
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,190 +1,245 @@
|
|||||||
import { TUI_CONFIRM } from '@taiga-ui/kit'
|
|
||||||
import { CommonModule } from '@angular/common'
|
|
||||||
import { ChangeDetectionStrategy, Component, inject } from '@angular/core'
|
|
||||||
import { ErrorService, LoadingService } from '@start9labs/shared'
|
|
||||||
import { TuiDialogOptions, TuiDialogService, TuiButton } from '@taiga-ui/core'
|
|
||||||
import { PatchDB } from 'patch-db-client'
|
|
||||||
import { filter, firstValueFrom, map } from 'rxjs'
|
|
||||||
import {
|
import {
|
||||||
FormComponent,
|
ChangeDetectionStrategy,
|
||||||
FormContext,
|
Component,
|
||||||
} from 'src/app/routes/portal/components/form.component'
|
inject,
|
||||||
|
signal,
|
||||||
|
} from '@angular/core'
|
||||||
|
import { toSignal } from '@angular/core/rxjs-interop'
|
||||||
|
import { RouterLink } from '@angular/router'
|
||||||
|
import {
|
||||||
|
DocsLinkDirective,
|
||||||
|
ErrorService,
|
||||||
|
i18nPipe,
|
||||||
|
LoadingService,
|
||||||
|
} from '@start9labs/shared'
|
||||||
|
import { ISB, utils } from '@start9labs/start-sdk'
|
||||||
|
import { TuiButton, TuiLink, TuiLoader, TuiTitle } from '@taiga-ui/core'
|
||||||
|
import { TuiCell, TuiHeader } from '@taiga-ui/layout'
|
||||||
|
import { PatchDB } from 'patch-db-client'
|
||||||
|
import { map } from 'rxjs'
|
||||||
|
import { FormComponent } from 'src/app/routes/portal/components/form.component'
|
||||||
|
import { PlaceholderComponent } from 'src/app/routes/portal/components/placeholder.component'
|
||||||
import { ApiService } from 'src/app/services/api/embassy-api.service'
|
import { 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 { getCustomSpec, getStart9ToSpec } from './constants'
|
import { TitleDirective } from 'src/app/services/title.service'
|
||||||
import { DomainsInfoComponent } from './info.component'
|
import { knownACME, toAcmeName } from 'src/app/utils/acme'
|
||||||
|
import { configBuilderToSpec } from 'src/app/utils/configBuilderToSpec'
|
||||||
import { DomainsTableComponent } from './table.component'
|
import { DomainsTableComponent } from './table.component'
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
template: `
|
template: `
|
||||||
<domains-info />
|
<ng-container *title>
|
||||||
@if (domains$ | async; as domains) {
|
<a routerLink=".." tuiIconButton iconStart="@tui.arrow-left">
|
||||||
<h3 class="g-title">
|
{{ 'Back' | i18n }}
|
||||||
Start9.to
|
</a>
|
||||||
@if (!domains.start9To.length) {
|
{{ 'Domains' | i18n }}
|
||||||
<button tuiButton size="xs" iconStart="@tui.plus" (click)="claim()">
|
</ng-container>
|
||||||
Claim
|
<header tuiHeader>
|
||||||
|
<hgroup tuiTitle>
|
||||||
|
<h3>{{ 'Domains' | i18n }}</h3>
|
||||||
|
<p tuiSubtitle>
|
||||||
|
{{
|
||||||
|
'Add ACME providers in order to generate SSL (https) certificates for clearnet access.'
|
||||||
|
| i18n
|
||||||
|
}}
|
||||||
|
<a
|
||||||
|
tuiLink
|
||||||
|
docsLink
|
||||||
|
path="/user-manual/connecting-remotely/clearnet.html"
|
||||||
|
fragment="#adding-acme"
|
||||||
|
appearance="action-grayscale"
|
||||||
|
iconEnd="@tui.external-link"
|
||||||
|
[pseudo]="true"
|
||||||
|
[textContent]="'View instructions' | i18n"
|
||||||
|
></a>
|
||||||
|
</p>
|
||||||
|
</hgroup>
|
||||||
|
</header>
|
||||||
|
<section class="g-card">
|
||||||
|
<header>
|
||||||
|
{{ 'ACME Providers' | i18n }}
|
||||||
|
@if (acme(); as value) {
|
||||||
|
<button
|
||||||
|
tuiButton
|
||||||
|
size="xs"
|
||||||
|
iconStart="@tui.plus"
|
||||||
|
[style.margin-inline-start]="'auto'"
|
||||||
|
(click)="addAcme(value)"
|
||||||
|
>
|
||||||
|
{{ 'Add' | i18n }}
|
||||||
</button>
|
</button>
|
||||||
}
|
}
|
||||||
</h3>
|
</header>
|
||||||
<table
|
@if (acme(); as value) {
|
||||||
class="g-table"
|
@for (provider of value; track $index) {
|
||||||
[domains]="domains.start9To"
|
<div tuiCell>
|
||||||
(delete)="delete()"
|
<span tuiTitle>
|
||||||
></table>
|
<strong>{{ toAcmeName(provider.url) }}</strong>
|
||||||
<h3 class="g-title">
|
<span tuiSubtitle>
|
||||||
Custom Domains
|
{{ 'Contact' | i18n }}: {{ provider.contactString }}
|
||||||
<button tuiButton size="xs" iconStart="@tui.plus" (click)="add()">
|
</span>
|
||||||
Add Domain
|
</span>
|
||||||
|
<button
|
||||||
|
tuiIconButton
|
||||||
|
iconStart="@tui.pencil"
|
||||||
|
appearance="icon"
|
||||||
|
(click)="editAcme(provider.url, provider.contact)"
|
||||||
|
>
|
||||||
|
{{ 'Edit' | i18n }}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
tuiIconButton
|
||||||
|
iconStart="@tui.trash"
|
||||||
|
appearance="icon"
|
||||||
|
(click)="removeAcme(provider.url)"
|
||||||
|
>
|
||||||
|
{{ 'Edit' | i18n }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
} @empty {
|
||||||
|
<app-placeholder icon="@tui.shield-question">
|
||||||
|
{{ 'No saved providers' | i18n }}
|
||||||
|
</app-placeholder>
|
||||||
|
}
|
||||||
|
} @else {
|
||||||
|
<tui-loader [style.height.rem]="5" />
|
||||||
|
}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="g-card">
|
||||||
|
<header>
|
||||||
|
{{ 'Domains' | i18n }}
|
||||||
|
<button
|
||||||
|
tuiButton
|
||||||
|
size="xs"
|
||||||
|
[style.margin]="'0 0.5rem 0 auto'"
|
||||||
|
iconStart="@tui.plus"
|
||||||
|
(click)="addDomain()"
|
||||||
|
>
|
||||||
|
Add
|
||||||
</button>
|
</button>
|
||||||
</h3>
|
</header>
|
||||||
<table
|
<div #table [domains]="domains()"></div>
|
||||||
class="g-table"
|
</section>
|
||||||
[domains]="domains.custom"
|
|
||||||
(delete)="delete($event.value)"
|
|
||||||
></table>
|
|
||||||
}
|
|
||||||
`,
|
`,
|
||||||
|
styles: ``,
|
||||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||||
imports: [
|
imports: [
|
||||||
CommonModule,
|
|
||||||
TuiButton,
|
TuiButton,
|
||||||
|
TuiLoader,
|
||||||
|
TuiCell,
|
||||||
|
TuiTitle,
|
||||||
|
TuiHeader,
|
||||||
|
TuiLink,
|
||||||
|
RouterLink,
|
||||||
|
TitleDirective,
|
||||||
|
i18nPipe,
|
||||||
|
DocsLinkDirective,
|
||||||
|
PlaceholderComponent,
|
||||||
DomainsTableComponent,
|
DomainsTableComponent,
|
||||||
DomainsInfoComponent,
|
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
export default class SystemDomainsComponent {
|
export default class SystemDomainsComponent {
|
||||||
|
private readonly formDialog = inject(FormDialogService)
|
||||||
private readonly loader = inject(LoadingService)
|
private readonly loader = inject(LoadingService)
|
||||||
private readonly errorService = inject(ErrorService)
|
private readonly errorService = inject(ErrorService)
|
||||||
private readonly formDialog = inject(FormDialogService)
|
|
||||||
private readonly patch = inject<PatchDB<DataModel>>(PatchDB)
|
private readonly patch = inject<PatchDB<DataModel>>(PatchDB)
|
||||||
private readonly api = inject(ApiService)
|
private readonly api = inject(ApiService)
|
||||||
private readonly dialogs = inject(TuiDialogService)
|
private readonly i18n = inject(i18nPipe)
|
||||||
|
|
||||||
private readonly start9To$ = this.patch.watch$(
|
acme = toSignal(
|
||||||
'serverInfo',
|
this.patch.watch$('serverInfo', 'network', 'acme').pipe(
|
||||||
'network',
|
map(acme =>
|
||||||
'start9To',
|
Object.keys(acme).map(url => {
|
||||||
|
const contact =
|
||||||
|
acme[url]?.contact.map(mailto => mailto.replace('mailto:', '')) ||
|
||||||
|
[]
|
||||||
|
return {
|
||||||
|
url,
|
||||||
|
contact,
|
||||||
|
contactString: contact.join(', '),
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
readonly domains$ = this.patch.watch$('serverInfo', 'network', 'domains')
|
domains = signal([])
|
||||||
|
|
||||||
delete(hostname?: string) {
|
toAcmeName = toAcmeName
|
||||||
this.dialogs
|
|
||||||
.open(TUI_CONFIRM, {
|
|
||||||
label: 'Confirm',
|
|
||||||
size: 's',
|
|
||||||
data: {
|
|
||||||
content: `Delete ${hostname || 'start9.to'} domain?`,
|
|
||||||
yes: 'Delete',
|
|
||||||
no: 'Cancel',
|
|
||||||
},
|
|
||||||
})
|
|
||||||
.pipe(filter(Boolean))
|
|
||||||
.subscribe(() => this.deleteDomain(hostname))
|
|
||||||
}
|
|
||||||
|
|
||||||
async add() {
|
async addAcme(
|
||||||
const proxies = await firstValueFrom(
|
providers: {
|
||||||
this.patch.watch$('serverInfo', 'network', 'proxies'),
|
url: string
|
||||||
)
|
contact: string[]
|
||||||
|
contactString: string
|
||||||
const options: Partial<TuiDialogOptions<FormContext<any>>> = {
|
}[],
|
||||||
label: 'Custom Domain',
|
) {
|
||||||
|
this.formDialog.open(FormComponent, {
|
||||||
|
label: 'Add ACME Provider',
|
||||||
data: {
|
data: {
|
||||||
spec: await getCustomSpec(proxies),
|
spec: await configBuilderToSpec(
|
||||||
|
this.addAcmeSpec(providers.map(p => p.url)),
|
||||||
|
),
|
||||||
buttons: [
|
buttons: [
|
||||||
{
|
{
|
||||||
text: 'Manage proxies',
|
text: this.i18n.transform('Save'),
|
||||||
link: '/system/proxies',
|
handler: async (
|
||||||
},
|
val: ReturnType<typeof this.addAcmeSpec>['_TYPE'],
|
||||||
{
|
) => {
|
||||||
text: 'Save',
|
const providerUrl =
|
||||||
handler: async value => this.save(value),
|
val.provider.selection === 'other'
|
||||||
|
? val.provider.value.url
|
||||||
|
: val.provider.selection
|
||||||
|
|
||||||
|
return this.saveAcme(providerUrl, val.contact)
|
||||||
|
},
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
}
|
})
|
||||||
|
|
||||||
this.formDialog.open(FormComponent, options)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async claim() {
|
async addDomain() {}
|
||||||
const proxies = await firstValueFrom(
|
|
||||||
this.patch.watch$('serverInfo', 'network', 'proxies'),
|
|
||||||
)
|
|
||||||
|
|
||||||
const options: Partial<TuiDialogOptions<FormContext<any>>> = {
|
async editAcme(provider: string, contact: string[]) {
|
||||||
label: 'start9.to',
|
this.formDialog.open(FormComponent, {
|
||||||
|
label: 'Edit ACME Provider',
|
||||||
data: {
|
data: {
|
||||||
spec: await getStart9ToSpec(proxies),
|
spec: await configBuilderToSpec(this.editAcmeSpec()),
|
||||||
buttons: [
|
buttons: [
|
||||||
{
|
{
|
||||||
text: 'Manage proxies',
|
text: this.i18n.transform('Save'),
|
||||||
link: '/system/proxies',
|
handler: async (
|
||||||
},
|
val: ReturnType<typeof this.editAcmeSpec>['_TYPE'],
|
||||||
{
|
) => this.saveAcme(provider, val.contact),
|
||||||
text: 'Save',
|
|
||||||
handler: async value => this.claimDomain(value),
|
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
value: { contact },
|
||||||
},
|
},
|
||||||
}
|
})
|
||||||
|
|
||||||
this.formDialog.open(FormComponent, options)
|
|
||||||
}
|
|
||||||
// @TODO 041 figure out how to get types here
|
|
||||||
private getNetworkStrategy(strategy: any) {
|
|
||||||
return strategy.selection === 'local'
|
|
||||||
? { ipStrategy: strategy.value.ipStrategy }
|
|
||||||
: { proxy: strategy.value.proxyId }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private async deleteDomain(hostname?: string) {
|
async removeAcme(provider: string) {
|
||||||
const loader = this.loader.open('Deleting').subscribe()
|
const loader = this.loader.open('Removing').subscribe()
|
||||||
|
|
||||||
try {
|
try {
|
||||||
if (hostname) {
|
await this.api.removeAcme({ provider })
|
||||||
await this.api.deleteDomain({ hostname })
|
|
||||||
} else {
|
|
||||||
await this.api.deleteStart9ToDomain({})
|
|
||||||
}
|
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
this.errorService.handleError(e)
|
this.errorService.handleError(e)
|
||||||
} finally {
|
} finally {
|
||||||
loader.unsubscribe()
|
loader.unsubscribe()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// @TODO 041 figure out how to get types here
|
|
||||||
private async claimDomain({ strategy }: any): Promise<boolean> {
|
private async saveAcme(providerUrl: string, contact: string[]) {
|
||||||
const loader = this.loader.open('Saving').subscribe()
|
const loader = this.loader.open('Saving').subscribe()
|
||||||
const networkStrategy = this.getNetworkStrategy(strategy)
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await this.api.claimStart9ToDomain({ networkStrategy })
|
await this.api.initAcme({
|
||||||
return true
|
provider: new URL(providerUrl).href,
|
||||||
} catch (e: any) {
|
contact: contact.map(address => `mailto:${address}`),
|
||||||
this.errorService.handleError(e)
|
|
||||||
return false
|
|
||||||
} finally {
|
|
||||||
loader.unsubscribe()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// @TODO 041 figure out how to get types here
|
|
||||||
private async save({ provider, strategy, hostname }: any): Promise<boolean> {
|
|
||||||
const loader = this.loader.open('Saving').subscribe()
|
|
||||||
const name = provider.selection
|
|
||||||
|
|
||||||
try {
|
|
||||||
await this.api.addDomain({
|
|
||||||
hostname,
|
|
||||||
networkStrategy: this.getNetworkStrategy(strategy),
|
|
||||||
provider: {
|
|
||||||
name,
|
|
||||||
username: name === 'start9' ? null : provider.value.username,
|
|
||||||
password: name === 'start9' ? null : provider.value.password,
|
|
||||||
},
|
|
||||||
})
|
})
|
||||||
return true
|
return true
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
@@ -194,4 +249,66 @@ export default class SystemDomainsComponent {
|
|||||||
loader.unsubscribe()
|
loader.unsubscribe()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private addAcmeSpec(providers: string[]) {
|
||||||
|
const availableAcme = knownACME.filter(
|
||||||
|
acme => !providers.includes(acme.url),
|
||||||
|
)
|
||||||
|
|
||||||
|
return ISB.InputSpec.of({
|
||||||
|
provider: ISB.Value.union({
|
||||||
|
name: 'Provider',
|
||||||
|
default: (availableAcme[0]?.url as any) || 'other',
|
||||||
|
variants: ISB.Variants.of({
|
||||||
|
...availableAcme.reduce(
|
||||||
|
(obj, curr) => ({
|
||||||
|
...obj,
|
||||||
|
[curr.url]: {
|
||||||
|
name: curr.name,
|
||||||
|
spec: ISB.InputSpec.of({}),
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
{},
|
||||||
|
),
|
||||||
|
other: {
|
||||||
|
name: 'Other',
|
||||||
|
spec: ISB.InputSpec.of({
|
||||||
|
url: ISB.Value.text({
|
||||||
|
name: 'URL',
|
||||||
|
default: null,
|
||||||
|
required: true,
|
||||||
|
inputmode: 'url',
|
||||||
|
patterns: [utils.Patterns.url],
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
contact: this.emailListSpec(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
private editAcmeSpec() {
|
||||||
|
return ISB.InputSpec.of({
|
||||||
|
contact: this.emailListSpec(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
private emailListSpec() {
|
||||||
|
return ISB.Value.list(
|
||||||
|
ISB.List.text(
|
||||||
|
{
|
||||||
|
name: this.i18n.transform('Contact Emails')!,
|
||||||
|
description: this.i18n.transform(
|
||||||
|
'Needed to obtain a certificate from a Certificate Authority',
|
||||||
|
),
|
||||||
|
minLength: 1,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
inputmode: 'email',
|
||||||
|
patterns: [utils.Patterns.email],
|
||||||
|
},
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,16 +0,0 @@
|
|||||||
import { ChangeDetectionStrategy, Component } from '@angular/core'
|
|
||||||
import { TuiLink, TuiNotification } from '@taiga-ui/core'
|
|
||||||
import { DocsLinkDirective } from 'projects/shared/src/public-api'
|
|
||||||
|
|
||||||
@Component({
|
|
||||||
selector: 'domains-info',
|
|
||||||
template: `
|
|
||||||
<tui-notification>
|
|
||||||
Adding domains permits accessing your server and services over clearnet.
|
|
||||||
<a tuiLink docsLink href="/@TODO">View instructions</a>
|
|
||||||
</tui-notification>
|
|
||||||
`,
|
|
||||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
|
||||||
imports: [TuiNotification, TuiLink, DocsLinkDirective],
|
|
||||||
})
|
|
||||||
export class DomainsInfoComponent {}
|
|
||||||
@@ -0,0 +1,94 @@
|
|||||||
|
import {
|
||||||
|
ChangeDetectionStrategy,
|
||||||
|
Component,
|
||||||
|
input,
|
||||||
|
output,
|
||||||
|
} from '@angular/core'
|
||||||
|
import { i18nPipe } from '@start9labs/shared'
|
||||||
|
import {
|
||||||
|
TuiButton,
|
||||||
|
TuiDataList,
|
||||||
|
TuiDropdown,
|
||||||
|
TuiOptGroup,
|
||||||
|
} from '@taiga-ui/core'
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'tr[domain]',
|
||||||
|
template: `
|
||||||
|
<td></td>
|
||||||
|
<td></td>
|
||||||
|
<td></td>
|
||||||
|
<td>
|
||||||
|
<button
|
||||||
|
tuiIconButton
|
||||||
|
iconStart="@tui.ellipsis"
|
||||||
|
appearance="icon"
|
||||||
|
[tuiDropdown]="content"
|
||||||
|
[(tuiDropdownOpen)]="open"
|
||||||
|
[tuiDropdownMaxHeight]="9999"
|
||||||
|
></button>
|
||||||
|
<ng-template #content>
|
||||||
|
<tui-data-list [style.width.rem]="13">
|
||||||
|
<tui-opt-group>
|
||||||
|
<button
|
||||||
|
tuiOption
|
||||||
|
iconStart="@tui.globe"
|
||||||
|
(click)="onGateway.emit(domain())"
|
||||||
|
>
|
||||||
|
Change gateway
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
tuiOption
|
||||||
|
iconStart="@tui.shield"
|
||||||
|
(click)="onAcme.emit(domain())"
|
||||||
|
>
|
||||||
|
Change default ACME
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
tuiOption
|
||||||
|
appearance="negative"
|
||||||
|
iconStart="@tui.trash-2"
|
||||||
|
(click)="onRemove.emit(domain())"
|
||||||
|
>
|
||||||
|
{{ 'Delete' | i18n }}
|
||||||
|
</button>
|
||||||
|
</tui-opt-group>
|
||||||
|
</tui-data-list>
|
||||||
|
</ng-template>
|
||||||
|
</td>
|
||||||
|
`,
|
||||||
|
styles: `
|
||||||
|
td:last-child {
|
||||||
|
grid-area: 3 / span 4;
|
||||||
|
white-space: nowrap;
|
||||||
|
text-align: right;
|
||||||
|
flex-direction: row-reverse;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
:host-context(tui-root._mobile) {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(3, min-content) 1fr;
|
||||||
|
align-items: center;
|
||||||
|
padding: 1rem 0.5rem;
|
||||||
|
gap: 0.5rem;
|
||||||
|
|
||||||
|
td {
|
||||||
|
display: flex;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`,
|
||||||
|
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||||
|
imports: [TuiButton, i18nPipe, TuiDropdown, TuiDataList, TuiOptGroup],
|
||||||
|
})
|
||||||
|
export class DomainsItemComponent {
|
||||||
|
readonly domain = input.required<any>()
|
||||||
|
|
||||||
|
onGateway = output<any>()
|
||||||
|
onAcme = output<any>()
|
||||||
|
onRemove = output<any>()
|
||||||
|
|
||||||
|
open = false
|
||||||
|
}
|
||||||
@@ -1,134 +1,122 @@
|
|||||||
import {
|
import {
|
||||||
ChangeDetectionStrategy,
|
ChangeDetectionStrategy,
|
||||||
Component,
|
Component,
|
||||||
EventEmitter,
|
|
||||||
inject,
|
inject,
|
||||||
Input,
|
input,
|
||||||
Output,
|
|
||||||
} from '@angular/core'
|
} from '@angular/core'
|
||||||
import { TuiDialogService, TuiLink, TuiButton } from '@taiga-ui/core'
|
import {
|
||||||
import { Domain } from 'src/app/services/patch-db/data-model'
|
DialogService,
|
||||||
|
ErrorService,
|
||||||
|
i18nPipe,
|
||||||
|
LoadingService,
|
||||||
|
} from '@start9labs/shared'
|
||||||
|
import { ISB } from '@start9labs/start-sdk'
|
||||||
|
import { TuiSkeleton } from '@taiga-ui/kit'
|
||||||
|
import { filter } from 'rxjs'
|
||||||
|
import { FormComponent } from 'src/app/routes/portal/components/form.component'
|
||||||
|
import { ApiService } from 'src/app/services/api/embassy-api.service'
|
||||||
|
import { FormDialogService } from 'src/app/services/form-dialog.service'
|
||||||
|
import { configBuilderToSpec } from 'src/app/utils/configBuilderToSpec'
|
||||||
|
import { TableComponent } from 'src/app/routes/portal/components/table.component'
|
||||||
|
import { DomainsItemComponent } from './item.component'
|
||||||
|
import { PlaceholderComponent } from 'src/app/routes/portal/components/placeholder.component'
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'table[domains]',
|
selector: '[domains]',
|
||||||
template: `
|
template: `
|
||||||
<thead>
|
<table [appTable]="['Domain', 'Gateway', 'Default ACME', null]">
|
||||||
<tr>
|
@for (domain of domains(); track $index) {
|
||||||
<th>Domain</th>
|
<tr
|
||||||
<th>DDNS Provider</th>
|
[domain]="domain"
|
||||||
<th>Network Strategy</th>
|
(onGateway)="changeGateway($event)"
|
||||||
<th>Used By</th>
|
(onAcme)="changeAcme($event)"
|
||||||
<th></th>
|
(onRemove)="remove($event)"
|
||||||
</tr>
|
></tr>
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
@for (domain of domains; track $index) {
|
|
||||||
<tr>
|
|
||||||
<td class="title">{{ domain.value }}</td>
|
|
||||||
<td class="provider">{{ domain.provider }}</td>
|
|
||||||
<td class="strategy">{{ getStrategy(domain) }}</td>
|
|
||||||
<td class="used">
|
|
||||||
@if (domain.usedBy.length; as qty) {
|
|
||||||
<button tuiLink (click)="onUsedBy(domain)">
|
|
||||||
Used by: {{ qty }}
|
|
||||||
</button>
|
|
||||||
} @else {
|
|
||||||
N/A
|
|
||||||
}
|
|
||||||
</td>
|
|
||||||
<td class="actions">
|
|
||||||
<button
|
|
||||||
tuiIconButton
|
|
||||||
size="xs"
|
|
||||||
appearance="icon"
|
|
||||||
iconStart="@tui.trash-2"
|
|
||||||
(click)="delete.emit(domain)"
|
|
||||||
>
|
|
||||||
Delete
|
|
||||||
</button>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
} @empty {
|
} @empty {
|
||||||
<tr><td colspan="6">No domains</td></tr>
|
@if (domains()) {
|
||||||
|
<app-placeholder icon="@tui.award">
|
||||||
|
{{ 'No domains' | i18n }}
|
||||||
|
</app-placeholder>
|
||||||
|
} @else {
|
||||||
|
<tr>
|
||||||
|
<td colspan="5">
|
||||||
|
<div [tuiSkeleton]="true">{{ 'Loading' | i18n }}</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
}
|
||||||
}
|
}
|
||||||
</tbody>
|
</table>
|
||||||
`,
|
`,
|
||||||
styles: `
|
styles: `
|
||||||
:host-context(tui-root._mobile) {
|
:host {
|
||||||
tr {
|
grid-column: span 6;
|
||||||
grid-template-columns: 2fr 1fr;
|
|
||||||
}
|
|
||||||
|
|
||||||
td:only-child {
|
|
||||||
grid-column: span 2;
|
|
||||||
}
|
|
||||||
|
|
||||||
.title {
|
|
||||||
order: 1;
|
|
||||||
font-weight: bold;
|
|
||||||
}
|
|
||||||
|
|
||||||
.actions {
|
|
||||||
order: 2;
|
|
||||||
padding: 0;
|
|
||||||
text-align: right;
|
|
||||||
}
|
|
||||||
|
|
||||||
.strategy {
|
|
||||||
order: 3;
|
|
||||||
grid-column: span 2;
|
|
||||||
|
|
||||||
&::before {
|
|
||||||
content: 'Strategy: ';
|
|
||||||
color: var(--tui-text-secondary);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.provider {
|
|
||||||
order: 4;
|
|
||||||
|
|
||||||
&::before {
|
|
||||||
content: 'DDNS: ';
|
|
||||||
color: var(--tui-text-secondary);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.used {
|
|
||||||
order: 5;
|
|
||||||
text-align: right;
|
|
||||||
|
|
||||||
&:not(:has(button)) {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
`,
|
`,
|
||||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||||
imports: [TuiButton, TuiLink],
|
imports: [
|
||||||
|
TuiSkeleton,
|
||||||
|
i18nPipe,
|
||||||
|
TableComponent,
|
||||||
|
DomainsItemComponent,
|
||||||
|
PlaceholderComponent,
|
||||||
|
],
|
||||||
})
|
})
|
||||||
export class DomainsTableComponent {
|
export class DomainsTableComponent<T extends any> {
|
||||||
private readonly dialogs = inject(TuiDialogService)
|
readonly domains = input<readonly T[] | null>(null)
|
||||||
|
|
||||||
@Input()
|
private readonly dialog = inject(DialogService)
|
||||||
domains: readonly Domain[] = []
|
private readonly loader = inject(LoadingService)
|
||||||
|
private readonly errorService = inject(ErrorService)
|
||||||
|
private readonly api = inject(ApiService)
|
||||||
|
private readonly formDialog = inject(FormDialogService)
|
||||||
|
|
||||||
@Output()
|
remove(domain: any) {
|
||||||
readonly delete = new EventEmitter<Domain>()
|
this.dialog
|
||||||
|
.openConfirm({ label: 'Are you sure?', size: 's' })
|
||||||
|
.pipe(filter(Boolean))
|
||||||
|
.subscribe(async () => {
|
||||||
|
const loader = this.loader.open('Deleting').subscribe()
|
||||||
|
|
||||||
getStrategy(domain: any) {
|
try {
|
||||||
return domain.networkStrategy.ipStrategy || domain.networkStrategy.proxy
|
} catch (e: any) {
|
||||||
|
this.errorService.handleError(e)
|
||||||
|
} finally {
|
||||||
|
loader.unsubscribe()
|
||||||
|
}
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
onUsedBy({ value, usedBy }: Domain) {
|
async changeGateway(domain: any) {
|
||||||
const interfaces = usedBy.map(u =>
|
const renameSpec = ISB.InputSpec.of({})
|
||||||
u.interfaces.map(i => `<li>${u.service.title} - ${i.title}</li>`),
|
|
||||||
)
|
|
||||||
|
|
||||||
this.dialogs
|
this.formDialog.open(FormComponent, {
|
||||||
.open(`${value} is currently being used by:<ul>${interfaces}</ul>`, {
|
label: 'Change gateway',
|
||||||
label: 'Used by',
|
data: {
|
||||||
size: 's',
|
spec: await configBuilderToSpec(renameSpec),
|
||||||
})
|
buttons: [
|
||||||
.subscribe()
|
{
|
||||||
|
text: 'Save',
|
||||||
|
handler: (value: typeof renameSpec._TYPE) => {},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async changeAcme(domain: any) {
|
||||||
|
const renameSpec = ISB.InputSpec.of({})
|
||||||
|
|
||||||
|
this.formDialog.open(FormComponent, {
|
||||||
|
label: 'Change default ACME',
|
||||||
|
data: {
|
||||||
|
spec: await configBuilderToSpec(renameSpec),
|
||||||
|
buttons: [
|
||||||
|
{
|
||||||
|
text: 'Save',
|
||||||
|
handler: (value: typeof renameSpec._TYPE) => {},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -42,7 +42,7 @@ import { configBuilderToSpec } from 'src/app/utils/configBuilderToSpec'
|
|||||||
<a
|
<a
|
||||||
tuiLink
|
tuiLink
|
||||||
docsLink
|
docsLink
|
||||||
href="/user-manual/smtp"
|
path="/user-manual/smtp.html"
|
||||||
appearance="action-grayscale"
|
appearance="action-grayscale"
|
||||||
iconEnd="@tui.external-link"
|
iconEnd="@tui.external-link"
|
||||||
[pseudo]="true"
|
[pseudo]="true"
|
||||||
|
|||||||
@@ -12,13 +12,13 @@ import { FormComponent } from 'src/app/routes/portal/components/form.component'
|
|||||||
import { DataModel } from 'src/app/services/patch-db/data-model'
|
import { DataModel } from 'src/app/services/patch-db/data-model'
|
||||||
import { FormDialogService } from 'src/app/services/form-dialog.service'
|
import { FormDialogService } from 'src/app/services/form-dialog.service'
|
||||||
import { ApiService } from 'src/app/services/api/embassy-api.service'
|
import { ApiService } from 'src/app/services/api/embassy-api.service'
|
||||||
import { ProxiesTableComponent } from './table.component'
|
import { GatewaysTableComponent } from './table.component'
|
||||||
import { configBuilderToSpec } from 'src/app/utils/configBuilderToSpec'
|
import { configBuilderToSpec } from 'src/app/utils/configBuilderToSpec'
|
||||||
import { TitleDirective } from 'src/app/services/title.service'
|
import { TitleDirective } from 'src/app/services/title.service'
|
||||||
import { TuiHeader } from '@taiga-ui/layout'
|
import { TuiHeader } from '@taiga-ui/layout'
|
||||||
import { map } from 'rxjs'
|
import { map } from 'rxjs'
|
||||||
import { ISB, T } from '@start9labs/start-sdk'
|
import { ISB } from '@start9labs/start-sdk'
|
||||||
import { WireguardIpInfo, WireguardProxy } from './item.component'
|
import { GatewayWithID } from './item.component'
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
template: `
|
template: `
|
||||||
@@ -26,24 +26,24 @@ import { WireguardIpInfo, WireguardProxy } from './item.component'
|
|||||||
<a routerLink=".." tuiIconButton iconStart="@tui.arrow-left">
|
<a routerLink=".." tuiIconButton iconStart="@tui.arrow-left">
|
||||||
{{ 'Back' | i18n }}
|
{{ 'Back' | i18n }}
|
||||||
</a>
|
</a>
|
||||||
{{ 'Inbound Proxies' | i18n }}
|
{{ 'Gateways' | i18n }}
|
||||||
</ng-container>
|
</ng-container>
|
||||||
<header tuiHeader>
|
<header tuiHeader>
|
||||||
<hgroup tuiTitle>
|
<hgroup tuiTitle>
|
||||||
<h3>{{ 'Inbound Proxies' | i18n }}</h3>
|
<h3>{{ 'Gateways' | i18n }}</h3>
|
||||||
<p tuiSubtitle>
|
<p tuiSubtitle>
|
||||||
{{
|
{{
|
||||||
'Inbound proxies provide remote access to your server and installed services.'
|
'Gateways connect your server to the Internet. They process outbound traffic, and under certain conditions, they also permit inbound traffic.'
|
||||||
| i18n
|
| i18n
|
||||||
}}
|
}}
|
||||||
<a
|
<a
|
||||||
tuiLink
|
tuiLink
|
||||||
docsLink
|
docsLink
|
||||||
href="/user-manual/inbound-proxies"
|
path="/user-manual/gateways.html"
|
||||||
appearance="action-grayscale"
|
appearance="action-grayscale"
|
||||||
iconEnd="@tui.external-link"
|
iconEnd="@tui.external-link"
|
||||||
[pseudo]="true"
|
[pseudo]="true"
|
||||||
[textContent]="'View instructions'"
|
[textContent]="'view instructions'"
|
||||||
></a>
|
></a>
|
||||||
</p>
|
</p>
|
||||||
</hgroup>
|
</hgroup>
|
||||||
@@ -51,19 +51,25 @@ import { WireguardIpInfo, WireguardProxy } from './item.component'
|
|||||||
|
|
||||||
<section class="g-card">
|
<section class="g-card">
|
||||||
<header>
|
<header>
|
||||||
{{ 'Saved Proxies' | i18n }}
|
{{ 'Gateways' | i18n }}
|
||||||
<button tuiButton size="xs" iconStart="@tui.plus" (click)="add()">
|
<button
|
||||||
|
tuiButton
|
||||||
|
size="xs"
|
||||||
|
[style.margin]="'0 0.5rem 0 auto'"
|
||||||
|
iconStart="@tui.plus"
|
||||||
|
(click)="add()"
|
||||||
|
>
|
||||||
Add
|
Add
|
||||||
</button>
|
</button>
|
||||||
</header>
|
</header>
|
||||||
<div #table [proxies]="proxies$ | async"></div>
|
<div #table [gateways]="gateways$ | async"></div>
|
||||||
</section>
|
</section>
|
||||||
`,
|
`,
|
||||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||||
imports: [
|
imports: [
|
||||||
CommonModule,
|
CommonModule,
|
||||||
TuiButton,
|
TuiButton,
|
||||||
ProxiesTableComponent,
|
GatewaysTableComponent,
|
||||||
TuiHeader,
|
TuiHeader,
|
||||||
TitleDirective,
|
TitleDirective,
|
||||||
i18nPipe,
|
i18nPipe,
|
||||||
@@ -71,46 +77,37 @@ import { WireguardIpInfo, WireguardProxy } from './item.component'
|
|||||||
DocsLinkDirective,
|
DocsLinkDirective,
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
export default class ProxiesComponent {
|
export default class GatewaysComponent {
|
||||||
private readonly loader = inject(LoadingService)
|
private readonly loader = inject(LoadingService)
|
||||||
private readonly errorService = inject(ErrorService)
|
private readonly errorService = inject(ErrorService)
|
||||||
private readonly api = inject(ApiService)
|
private readonly api = inject(ApiService)
|
||||||
private readonly formDialog = inject(FormDialogService)
|
private readonly formDialog = inject(FormDialogService)
|
||||||
|
|
||||||
readonly proxies$ = inject<PatchDB<DataModel>>(PatchDB)
|
readonly gateways$ = inject<PatchDB<DataModel>>(PatchDB)
|
||||||
.watch$('serverInfo', 'network')
|
.watch$('serverInfo', 'network', 'networkInterfaces')
|
||||||
.pipe(
|
.pipe(
|
||||||
map(network =>
|
map(gateways =>
|
||||||
Object.entries(network.networkInterfaces)
|
Object.entries(gateways).map(
|
||||||
.filter(
|
([id, val]) =>
|
||||||
(
|
({
|
||||||
record,
|
...val,
|
||||||
): record is [
|
id,
|
||||||
string,
|
}) as GatewayWithID,
|
||||||
T.NetworkInterfaceInfo & { ipInfo: WireguardIpInfo },
|
),
|
||||||
] => record[1].ipInfo?.deviceType === 'wireguard',
|
|
||||||
)
|
|
||||||
.map(
|
|
||||||
([id, val]) =>
|
|
||||||
({
|
|
||||||
...val,
|
|
||||||
id,
|
|
||||||
}) as WireguardProxy,
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
readonly wireguardSpec = ISB.InputSpec.of({
|
readonly gatewaySpec = ISB.InputSpec.of({
|
||||||
label: ISB.Value.text({
|
name: ISB.Value.text({
|
||||||
name: 'Label',
|
name: 'Name',
|
||||||
description: 'To help identify this proxy',
|
description: 'A name to easily identify the gateway',
|
||||||
required: true,
|
required: true,
|
||||||
default: null,
|
default: null,
|
||||||
}),
|
}),
|
||||||
type: ISB.Value.select({
|
type: ISB.Value.select({
|
||||||
name: 'Type',
|
name: 'Type',
|
||||||
description:
|
description:
|
||||||
'-**Private**: a private inbound proxy is used to access your server and installed services privately. Only clients configured and authorized to use the proxy will be granted access.\n-**Public**: a public inbound proxy is used to expose service interfaces on a case-by-case basis to the public Internet without exposing your home IP address. Only service interfaces explicitly marked "Public" will be accessible via the proxy.',
|
'-**Private**: select this option if the gateway is configured for private access to authorized clients only, which usually means ports are closed and traffic blocked otherwise. StartTunnel is a private gateway.\n-**Public**: select this option if the gateway is configured for unfettered public access, which usually means ports are open and traffic forwarded.',
|
||||||
default: 'private',
|
default: 'private',
|
||||||
values: {
|
values: {
|
||||||
private: 'Private',
|
private: 'Private',
|
||||||
@@ -118,21 +115,11 @@ export default class ProxiesComponent {
|
|||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
config: ISB.Value.union({
|
config: ISB.Value.union({
|
||||||
name: 'Config',
|
name: 'Wireguard Config',
|
||||||
default: 'upload',
|
default: 'paste',
|
||||||
variants: ISB.Variants.of({
|
variants: ISB.Variants.of({
|
||||||
upload: {
|
|
||||||
name: 'File',
|
|
||||||
spec: ISB.InputSpec.of({
|
|
||||||
file: ISB.Value.file({
|
|
||||||
name: 'Wiregaurd Config',
|
|
||||||
required: true,
|
|
||||||
extensions: ['.conf'],
|
|
||||||
}),
|
|
||||||
}),
|
|
||||||
},
|
|
||||||
paste: {
|
paste: {
|
||||||
name: 'Copy/Paste',
|
name: 'Paste File Contents',
|
||||||
spec: ISB.InputSpec.of({
|
spec: ISB.InputSpec.of({
|
||||||
file: ISB.Value.textarea({
|
file: ISB.Value.textarea({
|
||||||
name: 'Paste File Contents',
|
name: 'Paste File Contents',
|
||||||
@@ -141,33 +128,42 @@ export default class ProxiesComponent {
|
|||||||
}),
|
}),
|
||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
|
upload: {
|
||||||
|
name: 'Upload File',
|
||||||
|
spec: ISB.InputSpec.of({
|
||||||
|
file: ISB.Value.file({
|
||||||
|
name: 'File',
|
||||||
|
required: true,
|
||||||
|
extensions: ['.conf'],
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
},
|
||||||
}),
|
}),
|
||||||
}),
|
}),
|
||||||
})
|
})
|
||||||
|
|
||||||
async add() {
|
async add() {
|
||||||
this.formDialog.open(FormComponent, {
|
this.formDialog.open(FormComponent, {
|
||||||
label: 'Add Proxy',
|
label: 'Add Gateway',
|
||||||
data: {
|
data: {
|
||||||
spec: await configBuilderToSpec(this.wireguardSpec),
|
spec: await configBuilderToSpec(this.gatewaySpec),
|
||||||
buttons: [
|
buttons: [
|
||||||
{
|
{
|
||||||
text: 'Save',
|
text: 'Save',
|
||||||
handler: (input: typeof this.wireguardSpec._TYPE) =>
|
handler: (input: typeof this.gatewaySpec._TYPE) => this.save(input),
|
||||||
this.save(input),
|
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
private async save(input: typeof this.wireguardSpec._TYPE): Promise<boolean> {
|
private async save(input: typeof this.gatewaySpec._TYPE): Promise<boolean> {
|
||||||
const loader = this.loader.open('Saving').subscribe()
|
const loader = this.loader.open('Saving').subscribe()
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await this.api.addTunnel({
|
await this.api.addTunnel({
|
||||||
name: input.label,
|
name: input.name,
|
||||||
config: input.config.value.file as string, // @TODO alex this is the file represented as a string
|
config: '' as string, // @TODO alex/matt when types arrive
|
||||||
public: input.type === 'public',
|
public: input.type === 'public',
|
||||||
})
|
})
|
||||||
return true
|
return true
|
||||||
@@ -13,23 +13,23 @@ import {
|
|||||||
TuiOptGroup,
|
TuiOptGroup,
|
||||||
} from '@taiga-ui/core'
|
} from '@taiga-ui/core'
|
||||||
|
|
||||||
export type WireguardProxy = T.NetworkInterfaceInfo & {
|
export type GatewayWithID = T.NetworkInterfaceInfo & {
|
||||||
id: string
|
id: string
|
||||||
ipInfo: WireguardIpInfo
|
ipInfo: T.IpInfo
|
||||||
}
|
|
||||||
|
|
||||||
export type WireguardIpInfo = T.IpInfo & {
|
|
||||||
deviceType: 'wireguard'
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'tr[proxy]',
|
selector: 'tr[proxy]',
|
||||||
template: `
|
template: `
|
||||||
<td class="label">{{ proxy().ipInfo.name }}</td>
|
<td>{{ proxy().ipInfo.name }}</td>
|
||||||
<td class="type">
|
<td>{{ proxy().ipInfo.deviceType || '-' }}</td>
|
||||||
|
<td>
|
||||||
{{ proxy().public ? ('Public' | i18n) : ('Private' | i18n) }}
|
{{ proxy().public ? ('Public' | i18n) : ('Private' | i18n) }}
|
||||||
</td>
|
</td>
|
||||||
<td class="actions">
|
<!-- // @TODO show both LAN IPs? -->
|
||||||
|
<td>{{ proxy().ipInfo.subnets[0] }}</td>
|
||||||
|
<td>{{ proxy().ipInfo.wanIp }}</td>
|
||||||
|
<td>
|
||||||
<button
|
<button
|
||||||
tuiIconButton
|
tuiIconButton
|
||||||
iconStart="@tui.ellipsis"
|
iconStart="@tui.ellipsis"
|
||||||
@@ -37,9 +37,7 @@ export type WireguardIpInfo = T.IpInfo & {
|
|||||||
[tuiDropdown]="content"
|
[tuiDropdown]="content"
|
||||||
[(tuiDropdownOpen)]="open"
|
[(tuiDropdownOpen)]="open"
|
||||||
[tuiDropdownMaxHeight]="9999"
|
[tuiDropdownMaxHeight]="9999"
|
||||||
>
|
></button>
|
||||||
<img [style.max-width.%]="60" src="assets/img/icon.png" alt="StartOS" />
|
|
||||||
</button>
|
|
||||||
<ng-template #content>
|
<ng-template #content>
|
||||||
<tui-data-list [style.width.rem]="13">
|
<tui-data-list [style.width.rem]="13">
|
||||||
<tui-opt-group>
|
<tui-opt-group>
|
||||||
@@ -50,14 +48,16 @@ export type WireguardIpInfo = T.IpInfo & {
|
|||||||
>
|
>
|
||||||
{{ 'Rename' | i18n }}
|
{{ 'Rename' | i18n }}
|
||||||
</button>
|
</button>
|
||||||
<button
|
@if (proxy().ipInfo.deviceType === 'wireguard') {
|
||||||
tuiOption
|
<button
|
||||||
appearance="negative"
|
tuiOption
|
||||||
iconStart="@tui.trash-2"
|
appearance="negative"
|
||||||
(click)="onRemove.emit(proxy())"
|
iconStart="@tui.trash-2"
|
||||||
>
|
(click)="onRemove.emit(proxy())"
|
||||||
{{ 'Delete' | i18n }}
|
>
|
||||||
</button>
|
{{ 'Delete' | i18n }}
|
||||||
|
</button>
|
||||||
|
}
|
||||||
</tui-opt-group>
|
</tui-opt-group>
|
||||||
</tui-data-list>
|
</tui-data-list>
|
||||||
</ng-template>
|
</ng-template>
|
||||||
@@ -89,11 +89,11 @@ export type WireguardIpInfo = T.IpInfo & {
|
|||||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||||
imports: [TuiButton, i18nPipe, TuiDropdown, TuiDataList, TuiOptGroup],
|
imports: [TuiButton, i18nPipe, TuiDropdown, TuiDataList, TuiOptGroup],
|
||||||
})
|
})
|
||||||
export class ProxiesItemComponent {
|
export class GatewaysItemComponent {
|
||||||
readonly proxy = input.required<WireguardProxy>()
|
readonly proxy = input.required<GatewayWithID>()
|
||||||
|
|
||||||
onRename = output<WireguardProxy>()
|
onRename = output<GatewayWithID>()
|
||||||
onRemove = output<WireguardProxy>()
|
onRemove = output<GatewayWithID>()
|
||||||
|
|
||||||
open = false
|
open = false
|
||||||
}
|
}
|
||||||
@@ -18,31 +18,34 @@ 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 { configBuilderToSpec } from 'src/app/utils/configBuilderToSpec'
|
import { configBuilderToSpec } from 'src/app/utils/configBuilderToSpec'
|
||||||
import { TableComponent } from 'src/app/routes/portal/components/table.component'
|
import { TableComponent } from 'src/app/routes/portal/components/table.component'
|
||||||
import { WireguardProxy } from './item.component'
|
import { GatewayWithID } from './item.component'
|
||||||
import { ProxiesItemComponent } from './item.component'
|
import { GatewaysItemComponent } from './item.component'
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: '[proxies]',
|
selector: '[gateways]',
|
||||||
template: `
|
template: `
|
||||||
<table [appTable]="['Label', 'Type', null]">
|
<table
|
||||||
@for (proxy of proxies(); track $index) {
|
[appTable]="[
|
||||||
|
'Name',
|
||||||
|
'Type',
|
||||||
|
'Access',
|
||||||
|
$any('LAN IPs'),
|
||||||
|
$any('WAN IP'),
|
||||||
|
null,
|
||||||
|
]"
|
||||||
|
>
|
||||||
|
@for (proxy of gateways(); track $index) {
|
||||||
<tr
|
<tr
|
||||||
[proxy]="proxy"
|
[proxy]="proxy"
|
||||||
(onRename)="rename($event)"
|
(onRename)="rename($event)"
|
||||||
(onRemove)="remove($event.id)"
|
(onRemove)="remove($event.id)"
|
||||||
></tr>
|
></tr>
|
||||||
} @empty {
|
} @empty {
|
||||||
@if (proxies()) {
|
<tr>
|
||||||
<tr>
|
<td colspan="5">
|
||||||
<td colspan="5">{{ 'No proxies' | i18n }}</td>
|
<div [tuiSkeleton]="true">{{ 'Loading' | i18n }}</div>
|
||||||
</tr>
|
</td>
|
||||||
} @else {
|
</tr>
|
||||||
<tr>
|
|
||||||
<td colspan="5">
|
|
||||||
<div [tuiSkeleton]="true">{{ 'Loading' | i18n }}</div>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
</table>
|
</table>
|
||||||
`,
|
`,
|
||||||
@@ -52,10 +55,10 @@ import { ProxiesItemComponent } from './item.component'
|
|||||||
}
|
}
|
||||||
`,
|
`,
|
||||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||||
imports: [TuiSkeleton, i18nPipe, TableComponent, ProxiesItemComponent],
|
imports: [TuiSkeleton, i18nPipe, TableComponent, GatewaysItemComponent],
|
||||||
})
|
})
|
||||||
export class ProxiesTableComponent<T extends WireguardProxy> {
|
export class GatewaysTableComponent<T extends GatewayWithID> {
|
||||||
readonly proxies = input<readonly T[] | null>(null)
|
readonly gateways = input<readonly T[] | null>(null)
|
||||||
|
|
||||||
private readonly dialog = inject(DialogService)
|
private readonly dialog = inject(DialogService)
|
||||||
private readonly loader = inject(LoadingService)
|
private readonly loader = inject(LoadingService)
|
||||||
@@ -80,24 +83,24 @@ export class ProxiesTableComponent<T extends WireguardProxy> {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
async rename(proxy: WireguardProxy) {
|
async rename(gateway: GatewayWithID) {
|
||||||
const renameSpec = ISB.InputSpec.of({
|
const renameSpec = ISB.InputSpec.of({
|
||||||
label: ISB.Value.text({
|
label: ISB.Value.text({
|
||||||
name: 'Label',
|
name: 'Label',
|
||||||
required: true,
|
required: true,
|
||||||
default: proxy.ipInfo?.name || null,
|
default: gateway.ipInfo?.name || null,
|
||||||
}),
|
}),
|
||||||
})
|
})
|
||||||
|
|
||||||
this.formDialog.open(FormComponent, {
|
this.formDialog.open(FormComponent, {
|
||||||
label: 'Update Label',
|
label: 'Rename',
|
||||||
data: {
|
data: {
|
||||||
spec: await configBuilderToSpec(renameSpec),
|
spec: await configBuilderToSpec(renameSpec),
|
||||||
buttons: [
|
buttons: [
|
||||||
{
|
{
|
||||||
text: 'Save',
|
text: 'Save',
|
||||||
handler: (value: typeof renameSpec._TYPE) =>
|
handler: (value: typeof renameSpec._TYPE) =>
|
||||||
this.update(proxy.id, value.label),
|
this.update(gateway.id, value.label),
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
@@ -1,43 +0,0 @@
|
|||||||
import { ChangeDetectionStrategy, Component, Input } from '@angular/core'
|
|
||||||
import { TuiLink, TuiNotification } from '@taiga-ui/core'
|
|
||||||
import { DocsLinkDirective } from 'projects/shared/src/public-api'
|
|
||||||
|
|
||||||
@Component({
|
|
||||||
selector: 'router-info',
|
|
||||||
template: `
|
|
||||||
<tui-notification [appearance]="enabled ? 'positive' : 'warning'">
|
|
||||||
@if (enabled) {
|
|
||||||
<strong>UPnP Enabled!</strong>
|
|
||||||
<p>
|
|
||||||
The ports below have been
|
|
||||||
<i>automatically</i>
|
|
||||||
forwarded in your router.
|
|
||||||
</p>
|
|
||||||
If you are running multiple servers, you may want to override specific
|
|
||||||
ports to suite your needs.
|
|
||||||
<a tuiLink docsLink href="/@TODO">View instructions</a>
|
|
||||||
} @else {
|
|
||||||
<strong>UPnP Disabled</strong>
|
|
||||||
<p>
|
|
||||||
Below are a list of ports that must be
|
|
||||||
<i>manually</i>
|
|
||||||
forwarded in your router in order to enable clearnet access.
|
|
||||||
</p>
|
|
||||||
Alternatively, you can enable UPnP in your router for automatic
|
|
||||||
configuration.
|
|
||||||
<a tuiLink docsLink href="/@TODO">View instructions</a>
|
|
||||||
}
|
|
||||||
</tui-notification>
|
|
||||||
`,
|
|
||||||
styles: `
|
|
||||||
strong {
|
|
||||||
font-size: 1rem;
|
|
||||||
}
|
|
||||||
`,
|
|
||||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
|
||||||
imports: [TuiNotification, TuiLink, DocsLinkDirective],
|
|
||||||
})
|
|
||||||
export class RouterInfoComponent {
|
|
||||||
@Input()
|
|
||||||
enabled = false
|
|
||||||
}
|
|
||||||
@@ -1,15 +0,0 @@
|
|||||||
import { Pipe, PipeTransform } from '@angular/core'
|
|
||||||
import { T } from '@start9labs/start-sdk'
|
|
||||||
|
|
||||||
@Pipe({
|
|
||||||
name: 'primaryIp',
|
|
||||||
})
|
|
||||||
export class PrimaryIpPipe implements PipeTransform {
|
|
||||||
transform(hostnames: T.HostnameInfo[]): string {
|
|
||||||
return (
|
|
||||||
hostnames.map(
|
|
||||||
h => h.kind === 'ip' && h.hostname.kind === 'ipv4' && h.hostname.value,
|
|
||||||
)[0] || ''
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,68 +0,0 @@
|
|||||||
import { TuiTextfieldControllerModule } from '@taiga-ui/legacy'
|
|
||||||
import { CommonModule } from '@angular/common'
|
|
||||||
import { ChangeDetectionStrategy, Component, inject } from '@angular/core'
|
|
||||||
import { PatchDB } from 'patch-db-client'
|
|
||||||
import { DataModel } from 'src/app/services/patch-db/data-model'
|
|
||||||
import { RouterInfoComponent } from './info.component'
|
|
||||||
import { PrimaryIpPipe } from './primary-ip.pipe'
|
|
||||||
import { RouterPortComponent } from './table.component'
|
|
||||||
|
|
||||||
@Component({
|
|
||||||
template: `
|
|
||||||
@if (server$ | async; as server) {
|
|
||||||
<router-info [enabled]="!server.network.wanConfig.upnp" />
|
|
||||||
@if (server.host.hostnameInfo[80] | primaryIp; as ip) {
|
|
||||||
<table
|
|
||||||
tuiTextfieldAppearance="unstyled"
|
|
||||||
tuiTextfieldSize="m"
|
|
||||||
[tuiTextfieldLabelOutside]="true"
|
|
||||||
>
|
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
<th [style.width.rem]="2.5"></th>
|
|
||||||
<th [style.padding-left.rem]="0.75">
|
|
||||||
<div class="g-title">Port</div>
|
|
||||||
</th>
|
|
||||||
<th>
|
|
||||||
<div class="g-title">Target</div>
|
|
||||||
</th>
|
|
||||||
<th [style.width.rem]="3"></th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
@for (
|
|
||||||
portForward of server.network.wanConfig.forwards;
|
|
||||||
track portForward
|
|
||||||
) {
|
|
||||||
<tr [portForward]="portForward" [ip]="ip"></tr>
|
|
||||||
}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
}
|
|
||||||
}
|
|
||||||
`,
|
|
||||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
|
||||||
styles: `
|
|
||||||
table {
|
|
||||||
width: 100%;
|
|
||||||
min-width: 30rem;
|
|
||||||
max-width: 40rem;
|
|
||||||
table-layout: fixed;
|
|
||||||
background: var(--tui-background-base-alt);
|
|
||||||
border-radius: 0.75rem;
|
|
||||||
font-size: 1rem;
|
|
||||||
margin: 2rem 0;
|
|
||||||
box-shadow: 0 1rem var(--tui-background-base-alt);
|
|
||||||
}
|
|
||||||
`,
|
|
||||||
imports: [
|
|
||||||
CommonModule,
|
|
||||||
RouterInfoComponent,
|
|
||||||
RouterPortComponent,
|
|
||||||
TuiTextfieldControllerModule,
|
|
||||||
PrimaryIpPipe,
|
|
||||||
],
|
|
||||||
})
|
|
||||||
export default class SystemRouterComponent {
|
|
||||||
readonly server$ = inject<PatchDB<DataModel>>(PatchDB).watch$('serverInfo')
|
|
||||||
}
|
|
||||||
@@ -1,143 +0,0 @@
|
|||||||
import {
|
|
||||||
ChangeDetectionStrategy,
|
|
||||||
Component,
|
|
||||||
inject,
|
|
||||||
Input,
|
|
||||||
OnChanges,
|
|
||||||
} from '@angular/core'
|
|
||||||
import { FormsModule } from '@angular/forms'
|
|
||||||
import { CopyService, ErrorService, LoadingService } from '@start9labs/shared'
|
|
||||||
import { TuiButton, TuiIcon, TuiNumberFormat } from '@taiga-ui/core'
|
|
||||||
import {
|
|
||||||
TuiInputModule,
|
|
||||||
TuiInputNumberModule,
|
|
||||||
TuiTextfieldControllerModule,
|
|
||||||
} from '@taiga-ui/legacy'
|
|
||||||
import { ApiService } from 'src/app/services/api/embassy-api.service'
|
|
||||||
import { PortForward } from 'src/app/services/patch-db/data-model'
|
|
||||||
|
|
||||||
@Component({
|
|
||||||
selector: 'tr[portForward]',
|
|
||||||
template: `
|
|
||||||
<td [style.text-align]="'right'">
|
|
||||||
@if (portForward.error) {
|
|
||||||
<tui-icon icon="@tui.x" [style.color]="'var(--tui-text-negative)'" />
|
|
||||||
} @else {
|
|
||||||
<tui-icon
|
|
||||||
icon="@tui.check"
|
|
||||||
[style.color]="'var(--tui-text-positive)'"
|
|
||||||
/>
|
|
||||||
}
|
|
||||||
</td>
|
|
||||||
<td>
|
|
||||||
<tui-input-number
|
|
||||||
[tuiNumberFormat]="{ precision: 0 }"
|
|
||||||
[(ngModel)]="value"
|
|
||||||
[readOnly]="!editing"
|
|
||||||
[min]="0"
|
|
||||||
[tuiTextfieldCustomContent]="buttons"
|
|
||||||
>
|
|
||||||
<input tuiTextfieldLegacy type="text" />
|
|
||||||
</tui-input-number>
|
|
||||||
<ng-template #buttons>
|
|
||||||
@if (!editing) {
|
|
||||||
<button
|
|
||||||
tuiIconButton
|
|
||||||
appearance="icon"
|
|
||||||
iconStart="@tui.pencil"
|
|
||||||
size="s"
|
|
||||||
(click)="toggle(true)"
|
|
||||||
>
|
|
||||||
Edit
|
|
||||||
</button>
|
|
||||||
} @else {
|
|
||||||
<button
|
|
||||||
tuiIconButton
|
|
||||||
appearance="icon"
|
|
||||||
iconStart="@tui.x"
|
|
||||||
size="s"
|
|
||||||
(click)="toggle(false)"
|
|
||||||
>
|
|
||||||
Cancel
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
tuiIconButton
|
|
||||||
appearance="icon"
|
|
||||||
iconStart="@tui.check"
|
|
||||||
size="s"
|
|
||||||
[disabled]="!value"
|
|
||||||
(click)="save()"
|
|
||||||
>
|
|
||||||
Save
|
|
||||||
</button>
|
|
||||||
}
|
|
||||||
</ng-template>
|
|
||||||
</td>
|
|
||||||
<td>{{ ip }}:{{ portForward.target }}</td>
|
|
||||||
<td>
|
|
||||||
<button
|
|
||||||
tuiIconButton
|
|
||||||
appearance="icon"
|
|
||||||
iconStart="@tui.copy"
|
|
||||||
size="s"
|
|
||||||
(click)="copyService.copy(ip + ':' + portForward.target)"
|
|
||||||
>
|
|
||||||
Copy
|
|
||||||
</button>
|
|
||||||
</td>
|
|
||||||
`,
|
|
||||||
styles: `
|
|
||||||
button {
|
|
||||||
pointer-events: auto;
|
|
||||||
}
|
|
||||||
`,
|
|
||||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
|
||||||
imports: [
|
|
||||||
FormsModule,
|
|
||||||
TuiIcon,
|
|
||||||
TuiInputModule,
|
|
||||||
TuiButton,
|
|
||||||
TuiInputNumberModule,
|
|
||||||
TuiTextfieldControllerModule,
|
|
||||||
TuiNumberFormat,
|
|
||||||
],
|
|
||||||
})
|
|
||||||
export class RouterPortComponent implements OnChanges {
|
|
||||||
private readonly loader = inject(LoadingService)
|
|
||||||
private readonly errorService = inject(ErrorService)
|
|
||||||
private readonly api = inject(ApiService)
|
|
||||||
readonly copyService = inject(CopyService)
|
|
||||||
|
|
||||||
@Input({ required: true })
|
|
||||||
portForward!: PortForward
|
|
||||||
|
|
||||||
@Input()
|
|
||||||
ip = ''
|
|
||||||
|
|
||||||
value = NaN
|
|
||||||
editing = false
|
|
||||||
|
|
||||||
ngOnChanges() {
|
|
||||||
this.value = this.portForward.override || this.portForward.assigned
|
|
||||||
}
|
|
||||||
|
|
||||||
toggle(editing: boolean) {
|
|
||||||
this.editing = editing
|
|
||||||
this.value = this.portForward.override || this.portForward.assigned
|
|
||||||
}
|
|
||||||
|
|
||||||
async save() {
|
|
||||||
const loader = this.loader.open('Saving').subscribe()
|
|
||||||
const { target } = this.portForward
|
|
||||||
|
|
||||||
try {
|
|
||||||
await this.api.overridePortForward({ target, port: this.value })
|
|
||||||
this.portForward.override = this.value
|
|
||||||
this.editing = false
|
|
||||||
} catch (e: any) {
|
|
||||||
this.errorService.handleError(e)
|
|
||||||
} finally {
|
|
||||||
loader.unsubscribe()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -44,7 +44,7 @@ import { SSHTableComponent } from './table.component'
|
|||||||
<a
|
<a
|
||||||
tuiLink
|
tuiLink
|
||||||
docsLink
|
docsLink
|
||||||
href="/user-manual/ssh"
|
path="/user-manual/ssh.html"
|
||||||
appearance="action-grayscale"
|
appearance="action-grayscale"
|
||||||
iconEnd="@tui.external-link"
|
iconEnd="@tui.external-link"
|
||||||
[pseudo]="true"
|
[pseudo]="true"
|
||||||
|
|||||||
@@ -39,14 +39,14 @@ export const SYSTEM_MENU = [
|
|||||||
],
|
],
|
||||||
[
|
[
|
||||||
{
|
{
|
||||||
icon: '@tui.award',
|
icon: '@tui.globe',
|
||||||
item: 'ACME',
|
item: 'Gateways',
|
||||||
link: 'acme',
|
link: 'gateways',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
icon: '@tui.hard-drive-download',
|
icon: '@tui.award',
|
||||||
item: 'Inbound Proxies',
|
item: 'Domains',
|
||||||
link: 'proxies',
|
link: 'domains',
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
[
|
[
|
||||||
|
|||||||
@@ -47,11 +47,6 @@ export default [
|
|||||||
title: titleResolver,
|
title: titleResolver,
|
||||||
loadComponent: () => import('./routes/startos-ui/startos-ui.component'),
|
loadComponent: () => import('./routes/startos-ui/startos-ui.component'),
|
||||||
},
|
},
|
||||||
{
|
|
||||||
path: 'acme',
|
|
||||||
title: titleResolver,
|
|
||||||
loadComponent: () => import('./routes/acme/acme.component'),
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
path: 'wifi',
|
path: 'wifi',
|
||||||
title: titleResolver,
|
title: titleResolver,
|
||||||
@@ -73,17 +68,14 @@ export default [
|
|||||||
loadComponent: () => import('./routes/password/password.component'),
|
loadComponent: () => import('./routes/password/password.component'),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: 'proxies',
|
path: 'gateways',
|
||||||
loadComponent: () => import('./routes/proxies/proxies.component'),
|
loadComponent: () => import('./routes/gateways/gateways.component'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'domains',
|
||||||
|
title: titleResolver,
|
||||||
|
loadComponent: () => import('./routes/domains/domains.component'),
|
||||||
},
|
},
|
||||||
// {
|
|
||||||
// path: 'domains',
|
|
||||||
// loadComponent: () => import('./routes/domains/domains.component')
|
|
||||||
// },
|
|
||||||
// {
|
|
||||||
// path: 'router',
|
|
||||||
// loadComponent: () => import('./routes/router/router.component')
|
|
||||||
// },
|
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
] satisfies Routes
|
] satisfies Routes
|
||||||
|
|||||||
@@ -142,7 +142,7 @@ export const mockPatchData: DataModel = {
|
|||||||
scopeId: 1,
|
scopeId: 1,
|
||||||
deviceType: 'ethernet',
|
deviceType: 'ethernet',
|
||||||
subnets: ['10.0.0.2/24'],
|
subnets: ['10.0.0.2/24'],
|
||||||
wanIp: null,
|
wanIp: '203.0.113.45',
|
||||||
ntpServers: [],
|
ntpServers: [],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -156,7 +156,7 @@ export const mockPatchData: DataModel = {
|
|||||||
'10.0.90.12/24',
|
'10.0.90.12/24',
|
||||||
'fe80::cd00:0000:0cde:1257:0000:211e:72cd/64',
|
'fe80::cd00:0000:0cde:1257:0000:211e:72cd/64',
|
||||||
],
|
],
|
||||||
wanIp: null,
|
wanIp: '203.0.113.45',
|
||||||
ntpServers: [],
|
ntpServers: [],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
Reference in New Issue
Block a user