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
<a
docsLink
href="/user-manual/trust-ca.html"
path="/user-manual/trust-ca.html"
style="color: #6866cc; font-weight: bold; text-decoration: none"
>
follow the instructions
@@ -110,7 +110,7 @@ import { DocsLinkDirective } from '@start9labs/shared'
This address will only work from a Tor-enabled browser.
<a
docsLink
href="/user-manual/connecting-remotely/tor.html"
path="/user-manual/connecting-remotely/tor.html"
style="color: #6866cc; font-weight: bold; text-decoration: none"
>
Follow the instructions

View File

@@ -19,11 +19,13 @@ export const VERSION = new InjectionToken<string>('VERSION')
export class DocsLinkDirective {
private readonly version = inject(VERSION)
readonly href = input.required<string>()
readonly path = input.required<string>()
readonly fragment = input<string>('')
protected readonly url = computed(() => {
const path = this.href()
const path = this.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',
299: 'Fügen Sie ACME-Anbieter hinzu, um SSL-(https)-Zertifikate für den Clearnet-Zugriff zu generieren.',
300: 'Anleitung anzeigen',
301: 'Gespeicherte Anbieter',
302: 'Anbieter hinzufügen',
303: 'Kontakt',
304: 'Bearbeiten',
305: 'ACME-Anbieter hinzufügen',
@@ -528,12 +526,17 @@ export default {
530: 'StartOS-Paket',
531: 'Fehler beim Initialisieren des Servers',
532: 'Abgeschlossen',
533: 'Eingehende Proxys',
534: 'Eingehende Proxys ermöglichen den Fernzugriff auf Ihren Server und installierte Dienste.',
535: 'Gespeicherte Proxys',
536: 'Proxy hinzufügen',
537: 'Bezeichnung',
538: 'Keine Proxys',
539: 'Bezeichnung aktualisieren',
540: 'Umbenennen',
533: 'Gateways',
534: 'Gateways verbinden Ihren Server mit dem Internet. Sie verarbeiten ausgehenden Datenverkehr und erlauben unter bestimmten Bedingungen auch eingehenden Verkehr.',
535: 'Gateway hinzufügen',
536: 'Umbenennen',
537: 'Zugriff',
538: 'Domains',
539: 'ACME-Anbieter',
540: 'Domain',
541: 'Gateway',
542: 'Standard-ACME',
543: 'Gateway ändern',
544: 'Standard-ACME ändern',
545: 'Keine Domains',
} satisfies i18n

View File

@@ -295,8 +295,6 @@ export const ENGLISH = {
'Invalid package file': 298,
'Add ACME providers in order to generate SSL (https) certificates for clearnet access.': 299,
'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"
'Edit': 304,
'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
'Error initializing server': 531,
'Finished': 532, // an in, complete
'Inbound Proxies': 533, // as in a service used to proxy internet traffic
'Inbound proxies provide remote access to your server and installed services.': 534,
'Saved Proxies': 535, // as in, a list of proxies already added to StartOS
'Add Proxy': 536, // as in, add a new proxy to StartOS
'Label': 537, // as in, a name given to something
'No proxies': 538,
'Update Label': 539,
'Rename': 540
'Gateways': 533, // as in, a device or software that connects two different networks
'Gateways connect your server to the Internet. They process outbound traffic, and under certain conditions, they also permit inbound traffic.': 534,
'Add Gateway': 535, // as in, add a new network gateway to StartOS
'Rename': 536,
'Access': 537, // as in, public or private access, almost "permission"
'Domains': 538, // as in, internet domains
'ACME Providers': 539,
'Domain': 540, // as in, an internat domain name
'Gateway': 541, // as in, a device or software that connects two different networks
'Default ACME': 542, // as in, the default ACME provider for signing certificates
'Change gateway': 543, // as in, change the network gateway for a computer
'Change default ACME': 544, // as in, change the default ACME provider for a domain
'No domains': 545,
} as const

View File

@@ -296,8 +296,6 @@ export default {
298: 'Archivo de paquete inválido',
299: 'Agrega proveedores ACME para generar certificados SSL (https) para el acceso desde clearnet.',
300: 'Ver instrucciones',
301: 'Proveedores guardados',
302: 'Agregar proveedor',
303: 'Contacto',
304: 'Editar',
305: 'Agregar proveedor ACME',
@@ -528,12 +526,17 @@ export default {
530: 'Paquete StartOS',
531: 'Error al inicializar el servidor',
532: 'Finalizado',
533: 'Proxies entrantes',
534: 'Los proxies entrantes proporcionan acceso remoto a su servidor y servicios instalados.',
535: 'Proxies guardados',
536: 'Agregar proxy',
537: 'Etiqueta',
538: 'Sin proxies',
539: 'Actualizar etiqueta',
540: 'Renombrar',
533: 'Puertas de enlace',
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: 'Agregar puerta de enlace',
536: 'Renombrar',
537: 'Acceso',
538: 'Dominios',
539: 'Proveedores ACME',
540: 'Dominio',
541: 'Puerta de enlace',
542: 'ACME predeterminado',
543: 'Cambiar puerta de enlace',
544: 'Cambiar ACME predeterminado',
545: 'Sin dominios',
} satisfies i18n

View File

@@ -296,8 +296,6 @@ export default {
298: 'Fichier paquet invalide',
299: 'Ajoutez des fournisseurs ACME pour générer des certificats SSL (https) pour laccès clearnet.',
300: 'Voir les instructions',
301: 'Fournisseurs enregistrés',
302: 'Ajouter un fournisseur',
303: 'Contact',
304: 'Modifier',
305: 'Ajouter un fournisseur ACME',
@@ -528,12 +526,17 @@ export default {
530: 'Paquet StartOS',
531: "Erreur lors de l'initialisation du serveur",
532: 'Terminé',
533: 'Proxies entrants',
534: 'Les proxies entrants permettent un accès à distance à votre serveur et aux services installés.',
535: 'Proxies enregistrés',
536: 'Ajouter un proxy',
537: 'Étiquette',
538: 'Aucun proxy',
539: 'Mettre à jour létiquette',
540: 'Renommer',
533: 'Passerelles',
534: 'Les passerelles connectent votre serveur à Internet. Elles traitent le trafic sortant et, dans certaines conditions, autorisent également le trafic entrant.',
535: 'Ajouter une passerelle',
536: 'Renommer',
537: 'Accès',
538: 'Domaines',
539: 'Fournisseurs ACME',
540: 'Domaine',
541: 'Passerelle',
542: 'ACME par défaut',
543: 'Changer de passerelle',
544: 'Changer lACME par défaut',
545: 'Aucun domaine',
} satisfies i18n

View File

@@ -296,8 +296,6 @@ export default {
298: 'Nieprawidłowy plik pakietu',
299: 'Dodaj dostawców ACME, aby wygenerować certyfikaty SSL (https) dla dostępu przez clearnet.',
300: 'Zobacz instrukcje',
301: 'Zapisani dostawcy',
302: 'Dodaj dostawcę',
303: 'Kontakt',
304: 'Edytuj',
305: 'Dodaj dostawcę ACME',
@@ -528,12 +526,17 @@ export default {
530: 'Pakiet StartOS',
531: 'Błąd inicjalizacji serwera',
532: 'Zakończono',
533: 'Proksy przychodzące',
534: 'Proksy przychodzące zapewniają zdalny dostęp do twojego serwera i zainstalowanych usług.',
535: 'Zapisane proksy',
536: 'Dodaj proksy',
537: 'Etykieta',
538: 'Brak proksy',
539: 'Aktualizuj etykietę',
540: 'Zmień nazwę',
533: 'Bramy sieciowe',
534: 'Bramy łączą twój serwer z Internetem. Przetwarzają ruch wychodzący, a w pewnych warunkach również dopuszczają ruch przychodzący.',
535: 'Dodaj bramę',
536: 'Zmień nazwę',
537: 'Dostęp',
538: 'Domeny',
539: 'Dostawcy ACME',
540: 'Domena',
541: 'Brama',
542: 'Domyślny ACME',
543: 'Zmień bramę',
544: 'Zmień domyślny ACME',
545: 'Brak domen',
} satisfies i18n

View File

@@ -46,7 +46,7 @@
tuiButton
docsLink
size="s"
href="/user-manual/trust-ca.html"
path="/user-manual/trust-ca.html"
iconEnd="@tui.external-link"
>
{{ 'View instructions' | i18n }}

View File

@@ -50,7 +50,7 @@ import { ABOUT } from './about.component'
</button>
</tui-opt-group>
<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 }}
</a>
<a

View File

@@ -60,7 +60,7 @@ type ClearnetForm = {
<a
tuiLink
docsLink
href="/user-manual/connecting-remotely/clearnet.html"
path="/user-manual/connecting-remotely/clearnet.html"
>
{{ 'Learn more' | i18n }}
</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.'
| i18n
}}
<a tuiLink docsLink href="/user-manual/connecting-locally.html">
<a tuiLink docsLink path="/user-manual/connecting-locally.html">
{{ 'Learn More' | i18n }}
</a>
</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.'
| i18n
}}
<a tuiLink docsLink href="/user-manual/connecting-remotely/tor.html">
<a tuiLink docsLink path="/user-manual/connecting-remotely/tor.html">
{{ 'Learn More' | i18n }}
</a>
</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
data is safely backed up. StartOS will issue a notification whenever one
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>
<h3 class="g-title">
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
folders on your Local Area Network (LAN), or third party clouds such as
Dropbox or Google Drive.
<a tuiLink docsLink href="/@TODO">View instructions</a>
<a tuiLink docsLink path="/@TODO">View instructions</a>
</tui-notification>
<h3 class="g-title">
Unknown Physical Drives

View File

@@ -49,7 +49,8 @@ import { TimeService } from 'src/app/services/time.service'
docsLink
iconEnd="@tui.external-link"
appearance=""
href="/help/common-issues.html#clock-sync-failure"
path="/help/common-issues.html"
fragment="#clock-sync-failure"
[pseudo]="true"
[textContent]="'the docs' | i18n"
></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
tuiLink
docsLink
href="/user-manual/backup-create.html"
path="/user-manual/backup-create.html"
appearance="action-grayscale"
iconEnd="@tui.external-link"
[pseudo]="true"
@@ -80,7 +80,7 @@ import { BACKUP_RESTORE } from './restore.component'
<a
tuiLink
docsLink
href="/user-manual/backup-restore.html"
path="/user-manual/backup-restore.html"
appearance="action-grayscale"
iconEnd="@tui.external-link"
[pseudo]="true"
@@ -123,7 +123,8 @@ import { BACKUP_RESTORE } from './restore.component'
<a
tuiLink
docsLink
href="/user-manual/backup-create.html#network-folder"
path="/user-manual/backup-create.html"
fragment="#network-folder"
appearance="action-grayscale"
iconEnd="@tui.external-link"
[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 {
FormComponent,
FormContext,
} from 'src/app/routes/portal/components/form.component'
ChangeDetectionStrategy,
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 { FormDialogService } from 'src/app/services/form-dialog.service'
import { DataModel } from 'src/app/services/patch-db/data-model'
import { getCustomSpec, getStart9ToSpec } from './constants'
import { DomainsInfoComponent } from './info.component'
import { TitleDirective } from 'src/app/services/title.service'
import { knownACME, toAcmeName } from 'src/app/utils/acme'
import { configBuilderToSpec } from 'src/app/utils/configBuilderToSpec'
import { DomainsTableComponent } from './table.component'
@Component({
template: `
<domains-info />
@if (domains$ | async; as domains) {
<h3 class="g-title">
Start9.to
@if (!domains.start9To.length) {
<button tuiButton size="xs" iconStart="@tui.plus" (click)="claim()">
Claim
<ng-container *title>
<a routerLink=".." tuiIconButton iconStart="@tui.arrow-left">
{{ 'Back' | i18n }}
</a>
{{ 'Domains' | i18n }}
</ng-container>
<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>
}
</h3>
<table
class="g-table"
[domains]="domains.start9To"
(delete)="delete()"
></table>
<h3 class="g-title">
Custom Domains
<button tuiButton size="xs" iconStart="@tui.plus" (click)="add()">
Add Domain
</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>
<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>
</h3>
<table
class="g-table"
[domains]="domains.custom"
(delete)="delete($event.value)"
></table>
}
</header>
<div #table [domains]="domains()"></div>
</section>
`,
styles: ``,
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [
CommonModule,
TuiButton,
TuiLoader,
TuiCell,
TuiTitle,
TuiHeader,
TuiLink,
RouterLink,
TitleDirective,
i18nPipe,
DocsLinkDirective,
PlaceholderComponent,
DomainsTableComponent,
DomainsInfoComponent,
],
})
export default class SystemDomainsComponent {
private readonly formDialog = inject(FormDialogService)
private readonly loader = inject(LoadingService)
private readonly errorService = inject(ErrorService)
private readonly formDialog = inject(FormDialogService)
private readonly patch = inject<PatchDB<DataModel>>(PatchDB)
private readonly api = inject(ApiService)
private readonly dialogs = inject(TuiDialogService)
private readonly i18n = inject(i18nPipe)
private readonly start9To$ = this.patch.watch$(
'serverInfo',
'network',
'start9To',
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(', '),
}
}),
),
),
)
readonly domains$ = this.patch.watch$('serverInfo', 'network', 'domains')
domains = signal([])
delete(hostname?: string) {
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))
}
toAcmeName = toAcmeName
async add() {
const proxies = await firstValueFrom(
this.patch.watch$('serverInfo', 'network', 'proxies'),
)
const options: Partial<TuiDialogOptions<FormContext<any>>> = {
label: 'Custom Domain',
async addAcme(
providers: {
url: string
contact: string[]
contactString: string
}[],
) {
this.formDialog.open(FormComponent, {
label: 'Add ACME Provider',
data: {
spec: await getCustomSpec(proxies),
spec: await configBuilderToSpec(
this.addAcmeSpec(providers.map(p => p.url)),
),
buttons: [
{
text: 'Manage proxies',
link: '/system/proxies',
},
{
text: 'Save',
handler: async value => this.save(value),
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)
},
},
],
},
}
this.formDialog.open(FormComponent, options)
})
}
async claim() {
const proxies = await firstValueFrom(
this.patch.watch$('serverInfo', 'network', 'proxies'),
)
async addDomain() {}
const options: Partial<TuiDialogOptions<FormContext<any>>> = {
label: 'start9.to',
async editAcme(provider: string, contact: string[]) {
this.formDialog.open(FormComponent, {
label: 'Edit ACME Provider',
data: {
spec: await getStart9ToSpec(proxies),
spec: await configBuilderToSpec(this.editAcmeSpec()),
buttons: [
{
text: 'Manage proxies',
link: '/system/proxies',
},
{
text: 'Save',
handler: async value => this.claimDomain(value),
text: this.i18n.transform('Save'),
handler: async (
val: ReturnType<typeof this.editAcmeSpec>['_TYPE'],
) => this.saveAcme(provider, val.contact),
},
],
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) {
const loader = this.loader.open('Deleting').subscribe()
async removeAcme(provider: string) {
const loader = this.loader.open('Removing').subscribe()
try {
if (hostname) {
await this.api.deleteDomain({ hostname })
} else {
await this.api.deleteStart9ToDomain({})
}
await this.api.removeAcme({ provider })
} catch (e: any) {
this.errorService.handleError(e)
} finally {
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 networkStrategy = this.getNetworkStrategy(strategy)
try {
await this.api.claimStart9ToDomain({ networkStrategy })
return true
} catch (e: any) {
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,
},
await this.api.initAcme({
provider: new URL(providerUrl).href,
contact: contact.map(address => `mailto:${address}`),
})
return true
} catch (e: any) {
@@ -194,4 +249,66 @@ export default class SystemDomainsComponent {
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 {
ChangeDetectionStrategy,
Component,
EventEmitter,
inject,
Input,
Output,
input,
} from '@angular/core'
import { TuiDialogService, TuiLink, TuiButton } from '@taiga-ui/core'
import { Domain } from 'src/app/services/patch-db/data-model'
import {
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({
selector: 'table[domains]',
selector: '[domains]',
template: `
<thead>
<tr>
<th>Domain</th>
<th>DDNS Provider</th>
<th>Network Strategy</th>
<th>Used By</th>
<th></th>
</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>
<table [appTable]="['Domain', 'Gateway', 'Default ACME', null]">
@for (domain of domains(); track $index) {
<tr
[domain]="domain"
(onGateway)="changeGateway($event)"
(onAcme)="changeAcme($event)"
(onRemove)="remove($event)"
></tr>
} @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: `
:host-context(tui-root._mobile) {
tr {
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;
}
}
:host {
grid-column: span 6;
}
`,
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [TuiButton, TuiLink],
imports: [
TuiSkeleton,
i18nPipe,
TableComponent,
DomainsItemComponent,
PlaceholderComponent,
],
})
export class DomainsTableComponent {
private readonly dialogs = inject(TuiDialogService)
export class DomainsTableComponent<T extends any> {
readonly domains = input<readonly T[] | null>(null)
@Input()
domains: readonly Domain[] = []
private readonly dialog = inject(DialogService)
private readonly loader = inject(LoadingService)
private readonly errorService = inject(ErrorService)
private readonly api = inject(ApiService)
private readonly formDialog = inject(FormDialogService)
@Output()
readonly delete = new EventEmitter<Domain>()
remove(domain: any) {
this.dialog
.openConfirm({ label: 'Are you sure?', size: 's' })
.pipe(filter(Boolean))
.subscribe(async () => {
const loader = this.loader.open('Deleting').subscribe()
getStrategy(domain: any) {
return domain.networkStrategy.ipStrategy || domain.networkStrategy.proxy
try {
} catch (e: any) {
this.errorService.handleError(e)
} finally {
loader.unsubscribe()
}
})
}
onUsedBy({ value, usedBy }: Domain) {
const interfaces = usedBy.map(u =>
u.interfaces.map(i => `<li>${u.service.title} - ${i.title}</li>`),
)
async changeGateway(domain: any) {
const renameSpec = ISB.InputSpec.of({})
this.dialogs
.open(`${value} is currently being used by:<ul>${interfaces}</ul>`, {
label: 'Used by',
size: 's',
})
.subscribe()
this.formDialog.open(FormComponent, {
label: 'Change gateway',
data: {
spec: await configBuilderToSpec(renameSpec),
buttons: [
{
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
tuiLink
docsLink
href="/user-manual/smtp"
path="/user-manual/smtp.html"
appearance="action-grayscale"
iconEnd="@tui.external-link"
[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 { FormDialogService } from 'src/app/services/form-dialog.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 { TitleDirective } from 'src/app/services/title.service'
import { TuiHeader } from '@taiga-ui/layout'
import { map } from 'rxjs'
import { ISB, T } from '@start9labs/start-sdk'
import { WireguardIpInfo, WireguardProxy } from './item.component'
import { ISB } from '@start9labs/start-sdk'
import { GatewayWithID } from './item.component'
@Component({
template: `
@@ -26,24 +26,24 @@ import { WireguardIpInfo, WireguardProxy } from './item.component'
<a routerLink=".." tuiIconButton iconStart="@tui.arrow-left">
{{ 'Back' | i18n }}
</a>
{{ 'Inbound Proxies' | i18n }}
{{ 'Gateways' | i18n }}
</ng-container>
<header tuiHeader>
<hgroup tuiTitle>
<h3>{{ 'Inbound Proxies' | i18n }}</h3>
<h3>{{ 'Gateways' | i18n }}</h3>
<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
}}
<a
tuiLink
docsLink
href="/user-manual/inbound-proxies"
path="/user-manual/gateways.html"
appearance="action-grayscale"
iconEnd="@tui.external-link"
[pseudo]="true"
[textContent]="'View instructions'"
[textContent]="'view instructions'"
></a>
</p>
</hgroup>
@@ -51,19 +51,25 @@ import { WireguardIpInfo, WireguardProxy } from './item.component'
<section class="g-card">
<header>
{{ 'Saved Proxies' | i18n }}
<button tuiButton size="xs" iconStart="@tui.plus" (click)="add()">
{{ 'Gateways' | i18n }}
<button
tuiButton
size="xs"
[style.margin]="'0 0.5rem 0 auto'"
iconStart="@tui.plus"
(click)="add()"
>
Add
</button>
</header>
<div #table [proxies]="proxies$ | async"></div>
<div #table [gateways]="gateways$ | async"></div>
</section>
`,
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [
CommonModule,
TuiButton,
ProxiesTableComponent,
GatewaysTableComponent,
TuiHeader,
TitleDirective,
i18nPipe,
@@ -71,46 +77,37 @@ import { WireguardIpInfo, WireguardProxy } from './item.component'
DocsLinkDirective,
],
})
export default class ProxiesComponent {
export default class GatewaysComponent {
private readonly loader = inject(LoadingService)
private readonly errorService = inject(ErrorService)
private readonly api = inject(ApiService)
private readonly formDialog = inject(FormDialogService)
readonly proxies$ = inject<PatchDB<DataModel>>(PatchDB)
.watch$('serverInfo', 'network')
readonly gateways$ = inject<PatchDB<DataModel>>(PatchDB)
.watch$('serverInfo', 'network', 'networkInterfaces')
.pipe(
map(network =>
Object.entries(network.networkInterfaces)
.filter(
(
record,
): record is [
string,
T.NetworkInterfaceInfo & { ipInfo: WireguardIpInfo },
] => record[1].ipInfo?.deviceType === 'wireguard',
)
.map(
([id, val]) =>
({
...val,
id,
}) as WireguardProxy,
),
map(gateways =>
Object.entries(gateways).map(
([id, val]) =>
({
...val,
id,
}) as GatewayWithID,
),
),
)
readonly wireguardSpec = ISB.InputSpec.of({
label: ISB.Value.text({
name: 'Label',
description: 'To help identify this proxy',
readonly gatewaySpec = ISB.InputSpec.of({
name: ISB.Value.text({
name: 'Name',
description: 'A name to easily identify the gateway',
required: true,
default: null,
}),
type: ISB.Value.select({
name: 'Type',
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',
values: {
private: 'Private',
@@ -118,21 +115,11 @@ export default class ProxiesComponent {
},
}),
config: ISB.Value.union({
name: 'Config',
default: 'upload',
name: 'Wireguard Config',
default: 'paste',
variants: ISB.Variants.of({
upload: {
name: 'File',
spec: ISB.InputSpec.of({
file: ISB.Value.file({
name: 'Wiregaurd Config',
required: true,
extensions: ['.conf'],
}),
}),
},
paste: {
name: 'Copy/Paste',
name: 'Paste File Contents',
spec: ISB.InputSpec.of({
file: ISB.Value.textarea({
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() {
this.formDialog.open(FormComponent, {
label: 'Add Proxy',
label: 'Add Gateway',
data: {
spec: await configBuilderToSpec(this.wireguardSpec),
spec: await configBuilderToSpec(this.gatewaySpec),
buttons: [
{
text: 'Save',
handler: (input: typeof this.wireguardSpec._TYPE) =>
this.save(input),
handler: (input: typeof this.gatewaySpec._TYPE) => 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()
try {
await this.api.addTunnel({
name: input.label,
config: input.config.value.file as string, // @TODO alex this is the file represented as a string
name: input.name,
config: '' as string, // @TODO alex/matt when types arrive
public: input.type === 'public',
})
return true

View File

@@ -13,23 +13,23 @@ import {
TuiOptGroup,
} from '@taiga-ui/core'
export type WireguardProxy = T.NetworkInterfaceInfo & {
export type GatewayWithID = T.NetworkInterfaceInfo & {
id: string
ipInfo: WireguardIpInfo
}
export type WireguardIpInfo = T.IpInfo & {
deviceType: 'wireguard'
ipInfo: T.IpInfo
}
@Component({
selector: 'tr[proxy]',
template: `
<td class="label">{{ proxy().ipInfo.name }}</td>
<td class="type">
<td>{{ proxy().ipInfo.name }}</td>
<td>{{ proxy().ipInfo.deviceType || '-' }}</td>
<td>
{{ proxy().public ? ('Public' | i18n) : ('Private' | i18n) }}
</td>
<td class="actions">
<!-- // @TODO show both LAN IPs? -->
<td>{{ proxy().ipInfo.subnets[0] }}</td>
<td>{{ proxy().ipInfo.wanIp }}</td>
<td>
<button
tuiIconButton
iconStart="@tui.ellipsis"
@@ -37,9 +37,7 @@ export type WireguardIpInfo = T.IpInfo & {
[tuiDropdown]="content"
[(tuiDropdownOpen)]="open"
[tuiDropdownMaxHeight]="9999"
>
<img [style.max-width.%]="60" src="assets/img/icon.png" alt="StartOS" />
</button>
></button>
<ng-template #content>
<tui-data-list [style.width.rem]="13">
<tui-opt-group>
@@ -50,14 +48,16 @@ export type WireguardIpInfo = T.IpInfo & {
>
{{ 'Rename' | i18n }}
</button>
<button
tuiOption
appearance="negative"
iconStart="@tui.trash-2"
(click)="onRemove.emit(proxy())"
>
{{ 'Delete' | i18n }}
</button>
@if (proxy().ipInfo.deviceType === 'wireguard') {
<button
tuiOption
appearance="negative"
iconStart="@tui.trash-2"
(click)="onRemove.emit(proxy())"
>
{{ 'Delete' | i18n }}
</button>
}
</tui-opt-group>
</tui-data-list>
</ng-template>
@@ -89,11 +89,11 @@ export type WireguardIpInfo = T.IpInfo & {
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [TuiButton, i18nPipe, TuiDropdown, TuiDataList, TuiOptGroup],
})
export class ProxiesItemComponent {
readonly proxy = input.required<WireguardProxy>()
export class GatewaysItemComponent {
readonly proxy = input.required<GatewayWithID>()
onRename = output<WireguardProxy>()
onRemove = output<WireguardProxy>()
onRename = output<GatewayWithID>()
onRemove = output<GatewayWithID>()
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 { configBuilderToSpec } from 'src/app/utils/configBuilderToSpec'
import { TableComponent } from 'src/app/routes/portal/components/table.component'
import { WireguardProxy } from './item.component'
import { ProxiesItemComponent } from './item.component'
import { GatewayWithID } from './item.component'
import { GatewaysItemComponent } from './item.component'
@Component({
selector: '[proxies]',
selector: '[gateways]',
template: `
<table [appTable]="['Label', 'Type', null]">
@for (proxy of proxies(); track $index) {
<table
[appTable]="[
'Name',
'Type',
'Access',
$any('LAN IPs'),
$any('WAN IP'),
null,
]"
>
@for (proxy of gateways(); track $index) {
<tr
[proxy]="proxy"
(onRename)="rename($event)"
(onRemove)="remove($event.id)"
></tr>
} @empty {
@if (proxies()) {
<tr>
<td colspan="5">{{ 'No proxies' | i18n }}</td>
</tr>
} @else {
<tr>
<td colspan="5">
<div [tuiSkeleton]="true">{{ 'Loading' | i18n }}</div>
</td>
</tr>
}
<tr>
<td colspan="5">
<div [tuiSkeleton]="true">{{ 'Loading' | i18n }}</div>
</td>
</tr>
}
</table>
`,
@@ -52,10 +55,10 @@ import { ProxiesItemComponent } from './item.component'
}
`,
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [TuiSkeleton, i18nPipe, TableComponent, ProxiesItemComponent],
imports: [TuiSkeleton, i18nPipe, TableComponent, GatewaysItemComponent],
})
export class ProxiesTableComponent<T extends WireguardProxy> {
readonly proxies = input<readonly T[] | null>(null)
export class GatewaysTableComponent<T extends GatewayWithID> {
readonly gateways = input<readonly T[] | null>(null)
private readonly dialog = inject(DialogService)
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({
label: ISB.Value.text({
name: 'Label',
required: true,
default: proxy.ipInfo?.name || null,
default: gateway.ipInfo?.name || null,
}),
})
this.formDialog.open(FormComponent, {
label: 'Update Label',
label: 'Rename',
data: {
spec: await configBuilderToSpec(renameSpec),
buttons: [
{
text: 'Save',
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
tuiLink
docsLink
href="/user-manual/ssh"
path="/user-manual/ssh.html"
appearance="action-grayscale"
iconEnd="@tui.external-link"
[pseudo]="true"

View File

@@ -39,14 +39,14 @@ export const SYSTEM_MENU = [
],
[
{
icon: '@tui.award',
item: 'ACME',
link: 'acme',
icon: '@tui.globe',
item: 'Gateways',
link: 'gateways',
},
{
icon: '@tui.hard-drive-download',
item: 'Inbound Proxies',
link: 'proxies',
icon: '@tui.award',
item: 'Domains',
link: 'domains',
},
],
[

View File

@@ -47,11 +47,6 @@ export default [
title: titleResolver,
loadComponent: () => import('./routes/startos-ui/startos-ui.component'),
},
{
path: 'acme',
title: titleResolver,
loadComponent: () => import('./routes/acme/acme.component'),
},
{
path: 'wifi',
title: titleResolver,
@@ -73,17 +68,14 @@ export default [
loadComponent: () => import('./routes/password/password.component'),
},
{
path: 'proxies',
loadComponent: () => import('./routes/proxies/proxies.component'),
path: 'gateways',
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

View File

@@ -142,7 +142,7 @@ export const mockPatchData: DataModel = {
scopeId: 1,
deviceType: 'ethernet',
subnets: ['10.0.0.2/24'],
wanIp: null,
wanIp: '203.0.113.45',
ntpServers: [],
},
},
@@ -156,7 +156,7 @@ export const mockPatchData: DataModel = {
'10.0.90.12/24',
'fe80::cd00:0000:0cde:1257:0000:211e:72cd/64',
],
wanIp: null,
wanIp: '203.0.113.45',
ntpServers: [],
},
},