add domains and gateways, remove routers, fix docs links

This commit is contained in:
Matt Hill
2025-07-30 15:33:13 -06:00
parent e6b7390a61
commit daf584b33e
34 changed files with 645 additions and 1145 deletions

View File

@@ -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

View File

@@ -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()}`
}) })
} }

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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 laccès clearnet.', 299: 'Ajoutez des fournisseurs ACME pour générer des certificats SSL (https) pour laccè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 lACME par défaut',
545: 'Aucun domaine',
} satisfies i18n } satisfies i18n

View File

@@ -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

View File

@@ -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 }}

View File

@@ -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

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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

View File

@@ -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

View File

@@ -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>

View File

@@ -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],
},
),
)
}
}

View File

@@ -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"

View File

@@ -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),
}),
)
}

View File

@@ -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],
},
),
)
}
} }

View File

@@ -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 {}

View File

@@ -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
}

View File

@@ -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) => {},
},
],
},
})
} }
} }

View File

@@ -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"

View File

@@ -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

View File

@@ -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
} }

View File

@@ -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),
}, },
], ],
}, },

View File

@@ -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
}

View File

@@ -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] || ''
)
}
}

View File

@@ -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')
}

View File

@@ -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()
}
}
}

View File

@@ -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"

View File

@@ -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',
}, },
], ],
[ [

View File

@@ -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

View File

@@ -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: [],
}, },
}, },