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

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