start service interface page, WIP

This commit is contained in:
Matt Hill
2025-08-06 17:55:21 -06:00
parent d6dfaf8feb
commit 177232ab28
43 changed files with 816 additions and 1178 deletions

View File

@@ -15,7 +15,7 @@ import {
} from '@taiga-ui/core'
import { PolymorpheusComponent } from '@taiga-ui/polymorpheus'
import { QRModal } from 'src/app/routes/portal/modals/qr.component'
import { InterfaceComponent } from './interface.component'
import { InterfaceComponent } from '../interface.component'
@Component({
selector: 'td[actions]',
@@ -114,7 +114,7 @@ import { InterfaceComponent } from './interface.component'
providers: [tuiButtonOptionsProvider({ appearance: 'icon' })],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class InterfaceActionsComponent {
export class AddressActionsComponent {
private readonly document = inject(DOCUMENT)
readonly isMobile = inject(TUI_IS_MOBILE)

View File

@@ -0,0 +1,85 @@
import { ChangeDetectionStrategy, Component, input } from '@angular/core'
import { i18nPipe } from '@start9labs/shared'
import { TuiButton, TuiDataList, TuiDropdown } from '@taiga-ui/core'
import { PlaceholderComponent } from 'src/app/routes/portal/components/placeholder.component'
import { TableComponent } from 'src/app/routes/portal/components/table.component'
import { MappedServiceInterface } from '../interface.utils'
import { AddressActionsComponent } from './actions.component'
@Component({
selector: 'section[addresses]',
template: `
<header>{{ 'Addresses' | i18n }}</header>
@if (addresses().common.length) {
<section class="g-card">
<header>{{ 'Common' | i18n }}</header>
<table [appTable]="[null, 'Type', 'Gateway', 'URL', null]">
@for (address of addresses().common; track $index) {
<tr>
<td>
<button
tuiIconButton
appearance="flat-grayscale"
iconStart="@tui.eye"
(click)="instructions()"
>
{{ 'View instructions' | i18n }}
</button>
</td>
<td>{{ address.type }}</td>
<td>{{ address.gateway }}</td>
<td>{{ address.url }}</td>
<td actions [disabled]="!isRunning()" [href]="address.url"></td>
</tr>
}
</table>
</section>
} @else {
<app-placeholder icon="@tui.app-window">
{{ 'No addresses' | i18n }}
</app-placeholder>
}
@if (addresses().uncommon.length) {
<section class="g-card">
<header>{{ 'Uncommon' | i18n }}</header>
<table [appTable]="[null, 'Type', 'Gateway', 'URL', null]">
@for (address of addresses().uncommon; track $index) {
<tr>
<td>
<button
tuiIconButton
appearance="flat-grayscale"
iconStart="@tui.eye"
(click)="instructions()"
>
{{ 'View instructions' | i18n }}
</button>
</td>
<td>{{ address.type }}</td>
<td>{{ address.gateway }}</td>
<td>{{ address.url }}</td>
<td actions [disabled]="!isRunning()" [href]="address.url"></td>
</tr>
}
</table>
</section>
}
`,
imports: [
TableComponent,
PlaceholderComponent,
i18nPipe,
TuiDropdown,
TuiDataList,
AddressActionsComponent,
TuiButton,
],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class InterfaceAddressesComponent {
readonly addresses = input.required<MappedServiceInterface['addresses']>()
readonly isRunning = input.required<boolean>()
instructions() {}
}

View File

@@ -0,0 +1,166 @@
import {
ChangeDetectionStrategy,
Component,
inject,
input,
} from '@angular/core'
import {
DialogService,
DocsLinkDirective,
ErrorService,
i18nPipe,
LoadingService,
} from '@start9labs/shared'
import { ISB, utils } from '@start9labs/start-sdk'
import {
TuiAppearance,
TuiButton,
TuiDataList,
TuiDropdown,
TuiLink,
} from '@taiga-ui/core'
import { filter } from 'rxjs'
import {
FormComponent,
FormContext,
} from 'src/app/routes/portal/components/form.component'
import { InterfaceComponent } from 'src/app/routes/portal/components/interfaces/interface.component'
import { PlaceholderComponent } from 'src/app/routes/portal/components/placeholder.component'
import { TableComponent } from 'src/app/routes/portal/components/table.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 { ClearnetDomain } from './interface.utils'
@Component({
selector: 'section[clearnetDomains]',
template: `
<header>
{{ 'Clearnet Domains' | i18n }}
<a
tuiLink
docsLink
path="/user-manual/connecting-remotely/clearnet.html"
appearance="action-grayscale"
iconEnd="@tui.external-link"
[pseudo]="true"
></a>
<button
tuiButton
iconStart="@tui.plus"
[style.margin-inline-start]="'auto'"
(click)="add()"
>
{{ 'Add' | i18n }}
</button>
</header>
@if (clearnetDomains().length) {
<table [appTable]="['Domain', 'Certificate Authority', 'Type', null]">
@for (domain of clearnetDomains(); track $index) {
<tr>
<td>{{ domain.fqdn }}</td>
<td>{{ domain.authority }}</td>
<td>{{ domain.public ? 'public' : 'private' }}</td>
<td>
<button
tuiIconButton
tuiDropdown
size="s"
appearance="flat-grayscale"
iconStart="@tui.ellipsis-vertical"
[tuiAppearanceState]="open ? 'hover' : null"
[(tuiDropdownOpen)]="open"
>
{{ 'More' | i18n }}
<tui-data-list size="s" *tuiTextfieldDropdown>
<tui-opt-group>
<button
tuiOption
new
iconStart="@tui.pencil"
(click)="edit(domain)"
>
{{ 'Edit' | i18n }}
</button>
</tui-opt-group>
<tui-opt-group>
<button
tuiOption
new
iconStart="@tui.trash"
class="g-negative"
(click)="remove(domain.fqdn)"
>
{{ 'Delete' | i18n }}
</button>
</tui-opt-group>
</tui-data-list>
</button>
</td>
</tr>
}
</table>
} @else {
<app-placeholder icon="@tui.app-window">
{{ 'No clearnet domains' | i18n }}
</app-placeholder>
}
`,
imports: [
TuiButton,
TuiLink,
TuiAppearance,
TableComponent,
PlaceholderComponent,
i18nPipe,
DocsLinkDirective,
TuiDropdown,
TuiDataList,
],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class InterfaceClearnetDomainsComponent {
private readonly dialog = inject(DialogService)
private readonly formDialog = inject(FormDialogService)
private readonly loader = inject(LoadingService)
private readonly errorService = inject(ErrorService)
private readonly api = inject(ApiService)
private readonly interface = inject(InterfaceComponent)
private readonly i18n = inject(i18nPipe)
readonly clearnetDomains = input.required<readonly ClearnetDomain[]>()
open = false
async add() {}
async edit(domain: ClearnetDomain) {}
async remove(fqdn: string) {
this.dialog
.openConfirm({ label: 'Are you sure?', size: 's' })
.pipe(filter(Boolean))
.subscribe(async () => {
const loader = this.loader.open('Removing').subscribe()
const params = { fqdn }
try {
if (this.interface.packageId()) {
await this.api.pkgRemoveDomain({
...params,
package: this.interface.packageId(),
host: this.interface.value().addressInfo.hostId,
})
} else {
await this.api.osUiRemoveDomain(params)
}
return true
} catch (e: any) {
this.errorService.handleError(e)
return false
} finally {
loader.unsubscribe()
}
})
}
}

View File

@@ -1,305 +0,0 @@
import {
ChangeDetectionStrategy,
Component,
inject,
input,
} from '@angular/core'
import { toSignal } from '@angular/core/rxjs-interop'
import {
DialogService,
DocsLinkDirective,
ErrorService,
i18nPipe,
LoadingService,
} from '@start9labs/shared'
import { ISB, utils } from '@start9labs/start-sdk'
import {
TuiAppearance,
TuiButton,
TuiDataList,
TuiIcon,
TuiLink,
TuiNotification,
} from '@taiga-ui/core'
import { TuiTooltip } from '@taiga-ui/kit'
import { PatchDB } from 'patch-db-client'
import { defaultIfEmpty, firstValueFrom, map } from 'rxjs'
import {
FormComponent,
FormContext,
} from 'src/app/routes/portal/components/form.component'
import { AuthorityNamePipe } from 'src/app/routes/portal/components/interfaces/acme.pipe'
import { InterfaceComponent } from 'src/app/routes/portal/components/interfaces/interface.component'
import { PlaceholderComponent } from 'src/app/routes/portal/components/placeholder.component'
import { TableComponent } from 'src/app/routes/portal/components/table.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 { toAuthorityName } from 'src/app/utils/acme'
import { configBuilderToSpec } from 'src/app/utils/configBuilderToSpec'
import { InterfaceActionsComponent } from './actions.component'
import { ClearnetAddress } from './interface.utils'
import { MaskPipe } from './mask.pipe'
type ClearnetForm = {
domain: string
authority: string
}
@Component({
selector: 'section[clearnet]',
template: `
<header>
Clearnet
<tui-icon [tuiTooltip]="tooltip" />
<ng-template #tooltip>
{{
'Add a clearnet address to expose this interface on the Internet. Clearnet addresses are fully public and not anonymous.'
| i18n
}}
<a
tuiLink
docsLink
path="/user-manual/connecting-remotely/clearnet.html"
>
{{ 'Learn more' | i18n }}
</a>
</ng-template>
@if (clearnet().length) {
<button
tuiButton
iconStart="@tui.plus"
[style.margin-inline-start]="'auto'"
(click)="add()"
>
{{ 'Add' | i18n }}
</button>
}
</header>
@if (clearnet().length) {
@if (!isPublic()) {
<tui-notification appearance="negative" [style.margin-bottom]="'1rem'">
{{
'To publish clearnet domains, you must click "Make Public", above.'
| i18n
}}
</tui-notification>
}
<table [appTable]="['Certificate Authority', 'URL', null]">
@for (address of clearnet(); track $index) {
<tr>
<td [style.width.rem]="12">
{{
interface.value().addSsl
? (address.authority | authorityName)
: '-'
}}
</td>
<td [style.order]="-1">{{ address.url | mask }}</td>
<td
actions
[href]="address.url"
[disabled]="!isRunning() || !isPublic()"
>
@if (address.isDomain) {
<button
tuiIconButton
iconStart="@tui.trash"
appearance="flat-grayscale"
(click)="remove(address)"
>
{{ 'Delete' | i18n }}
</button>
}
@if (address.isDomain) {
<button
tuiOption
tuiAppearance="action-destructive"
iconStart="@tui.trash"
(click)="remove(address)"
>
{{ 'Delete' | i18n }}
</button>
}
</td>
</tr>
}
</table>
} @else {
<app-placeholder icon="@tui.app-window">
{{ 'No public addresses' | i18n }}
<button tuiButton iconStart="@tui.plus" (click)="add()">
{{ 'Add domain' | i18n }}
</button>
</app-placeholder>
}
`,
styles: `
:host-context(tui-root._mobile) {
td {
font-weight: bold;
color: var(--tui-text-primary);
&:first-child {
font-weight: normal;
color: var(--tui-text-secondary);
}
}
}
`,
host: { class: 'g-card' },
imports: [
TuiButton,
TuiIcon,
TuiTooltip,
TuiLink,
TuiDataList,
TuiAppearance,
PlaceholderComponent,
TableComponent,
MaskPipe,
AuthorityNamePipe,
InterfaceActionsComponent,
i18nPipe,
DocsLinkDirective,
TuiNotification,
],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class InterfaceClearnetComponent {
private readonly dialog = inject(DialogService)
private readonly formDialog = inject(FormDialogService)
private readonly loader = inject(LoadingService)
private readonly errorService = inject(ErrorService)
private readonly api = inject(ApiService)
readonly interface = inject(InterfaceComponent)
readonly clearnet = input.required<readonly ClearnetAddress[]>()
readonly isRunning = input.required<boolean>()
readonly isPublic = input.required<boolean>()
readonly authorityUrls = toSignal(
inject<PatchDB<DataModel>>(PatchDB)
.watch$('serverInfo', 'network', 'acme')
.pipe(map(acme => Object.keys(acme))),
{ initialValue: [] },
)
async remove({ url }: ClearnetAddress) {
const confirm = await firstValueFrom(
this.dialog
.openConfirm({
label: 'Confirm',
size: 's',
data: {
yes: 'Delete',
no: 'Cancel',
content: 'Are you sure you want to delete this address?',
},
})
.pipe(defaultIfEmpty(false)),
)
if (!confirm) {
return
}
const loader = this.loader.open('Removing').subscribe()
if (!/^[a-zA-Z][a-zA-Z\d+\-.]*:\/\//.test(url)) {
url = 'http://' + url
}
const params = { domain: new URL(url).hostname }
try {
if (this.interface.packageId()) {
await this.api.pkgRemoveDomain({
...params,
package: this.interface.packageId(),
host: this.interface.value().addressInfo.hostId,
})
} else {
await this.api.osUiRemoveDomain(params)
}
return true
} catch (e: any) {
this.errorService.handleError(e)
return false
} finally {
loader.unsubscribe()
}
}
async add() {
const domain = ISB.Value.text({
name: 'Domain',
description: 'The domain or subdomain you want to use',
placeholder: `e.g. 'mydomain.com' or 'sub.mydomain.com'`,
required: true,
default: null,
patterns: [utils.Patterns.domain],
})
const authority = ISB.Value.select({
name: 'Certificate Authority',
description:
'Select which Certificate authority to use for obtaining your SSL certificate. Add new authority in the System tab. Optionally use your local= Root CA. Note: only devices that have trusted your Root CA will be able to access the domain without security warnings.',
values: this.authorityUrls().reduce<Record<string, string>>(
(obj, url) => ({
...obj,
[url]: toAuthorityName(url),
}),
{ local: toAuthorityName(null) },
),
default: '',
})
this.formDialog.open<FormContext<ClearnetForm>>(FormComponent, {
label: 'Select domain',
data: {
spec: await configBuilderToSpec(
ISB.InputSpec.of(
this.interface.value().addSsl ? { domain, authority } : { domain },
),
),
buttons: [
{
text: 'Save',
handler: async value => this.save(value),
},
],
},
})
}
private async save(domainInfo: ClearnetForm): Promise<boolean> {
const loader = this.loader.open('Saving').subscribe()
const { domain, authority } = domainInfo
const params = {
domain,
acme: authority === 'local' ? null : authority,
private: false,
}
try {
if (this.interface.packageId()) {
await this.api.pkgAddDomain({
...params,
package: this.interface.packageId(),
host: this.interface.value().addressInfo.hostId,
})
} else {
await this.api.osUiAddDomain(params)
}
return true
} catch (e: any) {
this.errorService.handleError(e)
return false
} finally {
loader.unsubscribe()
}
}
}

View File

@@ -0,0 +1,35 @@
import { CommonModule } from '@angular/common'
import { ChangeDetectionStrategy, Component, input } from '@angular/core'
import { TuiSwitch } from '@taiga-ui/kit'
import { FormsModule } from '@angular/forms'
import { i18nPipe } from '@start9labs/shared'
@Component({
selector: 'section[gateways]',
template: `
<header>{{ 'Gateways' | i18n }}</header>
<ul>
@for (gateway of gateways(); track $index) {
<li>
{{ gateway.name }}
<input
type="checkbox"
tuiSwitch
[style.margin-inline-start]="'auto'"
[showIcons]="false"
[ngModel]="gateway.enabled"
(ngModelChange)="onToggle(gateway)"
/>
</li>
}
<ul></ul>
</ul>
`,
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [CommonModule, FormsModule, TuiSwitch, i18nPipe],
})
export class InterfaceGatewaysComponent {
readonly gateways = input.required<any>()
async onToggle(event: any) {}
}

View File

@@ -1,43 +1,28 @@
import {
ChangeDetectionStrategy,
Component,
inject,
input,
} from '@angular/core'
import { ErrorService, i18nPipe, LoadingService } from '@start9labs/shared'
import { TuiButton, tuiButtonOptionsProvider } from '@taiga-ui/core'
import { InterfaceClearnetComponent } from 'src/app/routes/portal/components/interfaces/clearnet.component'
import { InterfaceLocalComponent } from 'src/app/routes/portal/components/interfaces/local.component'
import { InterfaceTorComponent } from 'src/app/routes/portal/components/interfaces/tor.component'
import { ApiService } from 'src/app/services/api/embassy-api.service'
import { ChangeDetectionStrategy, Component, input } from '@angular/core'
import { tuiButtonOptionsProvider } from '@taiga-ui/core'
import { MappedServiceInterface } from './interface.utils'
import { InterfaceGatewaysComponent } from './gateways.component'
import { InterfaceTorDomainsComponent } from './tor-domains.component'
import { InterfaceClearnetDomainsComponent } from './clearnet-domains.component'
import { InterfaceAddressesComponent } from './addresses/addresses.component'
@Component({
selector: 'app-interface',
selector: 'service-interface',
template: `
<button
tuiButton
size="s"
[appearance]="value().public ? 'primary-destructive' : 'primary-success'"
[iconStart]="value().public ? '@tui.globe-lock' : '@tui.globe'"
(click)="toggle()"
>
{{ value().public ? ('Make private' | i18n) : ('Make public' | i18n) }}
</button>
<section class="g-card" [gateways]="value().gateways"></section>
<section class="g-card" [torDomains]="value().torDomains"></section>
<section
[clearnet]="value().addresses.clearnet"
[isPublic]="value().public"
[isRunning]="isRunning()"
class="g-card"
[clearnetDomains]="value().clearnetDomains"
></section>
<section [tor]="value().addresses.tor" [isRunning]="isRunning()"></section>
<section
[local]="value().addresses.local"
[isRunning]="isRunning()"
class="g-card"
[addresses]="value().addresses"
[isRunning]="true"
></section>
`,
styles: `
:host {
max-width: 56rem;
display: flex;
flex-direction: column;
gap: 1rem;
@@ -48,54 +33,18 @@ import { MappedServiceInterface } from './interface.utils'
overflow-wrap: anywhere;
}
}
button {
margin: -0.5rem auto 0 0;
}
`,
providers: [tuiButtonOptionsProvider({ size: 'xs' })],
changeDetection: ChangeDetectionStrategy.OnPush,
providers: [tuiButtonOptionsProvider({ size: 'xs' })],
imports: [
InterfaceClearnetComponent,
InterfaceTorComponent,
InterfaceLocalComponent,
TuiButton,
i18nPipe,
InterfaceGatewaysComponent,
InterfaceTorDomainsComponent,
InterfaceClearnetDomainsComponent,
InterfaceAddressesComponent,
],
})
export class InterfaceComponent {
private readonly loader = inject(LoadingService)
private readonly errorService = inject(ErrorService)
private readonly api = inject(ApiService)
readonly packageId = input('')
readonly value = input.required<MappedServiceInterface>()
readonly isRunning = input.required<boolean>()
async toggle() {
const loader = this.loader
.open(`Making ${this.value().public ? 'private' : 'public'}`)
.subscribe()
const params = {
internalPort: this.value().addressInfo.internalPort,
public: !this.value().public,
}
try {
if (this.packageId()) {
await this.api.pkgBindingSetPubic({
...params,
host: this.value().addressInfo.hostId,
package: this.packageId(),
})
} else {
await this.api.serverBindingSetPubic(params)
}
} catch (e: any) {
this.errorService.handleError(e)
} finally {
loader.unsubscribe()
}
}
}

View File

@@ -1,21 +1,11 @@
import { T, utils } from '@start9labs/start-sdk'
import { T } from '@start9labs/start-sdk'
import { ConfigService } from 'src/app/services/config.service'
export abstract class AddressesService {
abstract static: boolean
abstract add(): Promise<void>
abstract remove(): Promise<void>
}
export function getAddresses(
serviceInterface: T.ServiceInterface,
host: T.Host,
config: ConfigService,
): {
clearnet: ClearnetAddress[]
local: LocalAddress[]
tor: TorAddress[]
} {
): MappedServiceInterface['addresses'] {
const addressInfo = serviceInterface.addressInfo
const hostnames =
host.hostnameInfo[addressInfo.internalPort]?.filter(
@@ -46,60 +36,75 @@ export function getAddresses(
}
}
const clearnet: ClearnetAddress[] = []
const local: LocalAddress[] = []
const tor: TorAddress[] = []
const common: Address[] = [
{
type: 'Local',
description: '',
gateway: 'Wire Conenction 1',
url: 'https://test.local:1234',
},
{
type: 'IPv4 (LAN)',
description: '',
gateway: 'Wire Connction 1',
url: 'https://192.168.1.10.local:1234',
},
]
const uncommon: Address[] = [
{
type: 'IPv4 (WAN)',
description: '',
gateway: 'Wire Conenction 1',
url: 'https://72.72.72.72',
},
]
hostnames.forEach(h => {
const addresses = utils.addressHostToUrl(addressInfo, h)
// hostnames.forEach(h => {
// const addresses = utils.addressHostToUrl(addressInfo, h)
addresses.forEach(url => {
if (h.kind === 'onion') {
tor.push({
protocol: /^[a-zA-Z][a-zA-Z\d+\-.]*:\/\//.test(url)
? new URL(url).protocol.replace(':', '').toUpperCase()
: null,
url,
})
} else {
const hostnameKind = h.hostname.kind
// addresses.forEach(url => {
// if (h.kind === 'onion') {
// tor.push({
// protocol: /^[a-zA-Z][a-zA-Z\d+\-.]*:\/\//.test(url)
// ? new URL(url).protocol.replace(':', '').toUpperCase()
// : null,
// url,
// })
// } else {
// const hostnameKind = h.hostname.kind
if (
h.public ||
(hostnameKind === 'domain' && host.domains[h.hostname.domain]?.public)
) {
clearnet.push({
url,
disabled: !h.public,
isDomain: hostnameKind == 'domain',
authority:
hostnameKind == 'domain'
? host.domains[h.hostname.domain]?.acme || null
: null,
})
} else {
local.push({
nid:
hostnameKind === 'local'
? 'Local'
: `${h.gatewayId} (${hostnameKind})`,
url,
})
}
}
})
})
// if (
// h.public ||
// (hostnameKind === 'domain' && host.domains[h.hostname.domain]?.public)
// ) {
// clearnet.push({
// url,
// disabled: !h.public,
// isDomain: hostnameKind == 'domain',
// authority:
// hostnameKind == 'domain'
// ? host.domains[h.hostname.domain]?.acme || null
// : null,
// })
// } else {
// local.push({
// nid:
// hostnameKind === 'local'
// ? 'Local'
// : `${h.gatewayId} (${hostnameKind})`,
// url,
// })
// }
// }
// })
// })
return {
clearnet: clearnet.filter(
common: common.filter(
(value, index, self) =>
index === self.findIndex(t => t.url === value.url),
),
local: local.filter(
(value, index, self) =>
index === self.findIndex(t => t.url === value.url),
),
tor: tor.filter(
uncommon: uncommon.filter(
(value, index, self) =>
index === self.findIndex(t => t.url === value.url),
),
@@ -107,28 +112,28 @@ export function getAddresses(
}
export type MappedServiceInterface = T.ServiceInterface & {
addSsl?: T.AddSslOptions | null
public: boolean
gateways: {
id: string
name: string
enabled: boolean
}[]
torDomains: string[]
clearnetDomains: ClearnetDomain[]
addresses: {
clearnet: ClearnetAddress[]
local: LocalAddress[]
tor: TorAddress[]
common: Address[]
uncommon: Address[]
}
}
export type ClearnetAddress = {
url: string
export type ClearnetDomain = {
fqdn: string
authority: string | null
isDomain: boolean
disabled: boolean
public: boolean
}
export type LocalAddress = {
export type Address = {
type: string
gateway: string
url: string
nid: string
}
export type TorAddress = {
url: string
protocol: string | null
description: string
}

View File

@@ -1,52 +0,0 @@
import { ChangeDetectionStrategy, Component, input } from '@angular/core'
import { TuiIcon, TuiLink } from '@taiga-ui/core'
import { TuiTooltip } from '@taiga-ui/kit'
import { TableComponent } from 'src/app/routes/portal/components/table.component'
import { InterfaceActionsComponent } from './actions.component'
import { LocalAddress } from './interface.utils'
import { MaskPipe } from './mask.pipe'
import { DocsLinkDirective, i18nPipe } from '@start9labs/shared'
@Component({
selector: 'section[local]',
template: `
<header>
{{ 'Local' | i18n }}
<tui-icon [tuiTooltip]="tooltip" />
<ng-template #tooltip>
{{
'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 path="/user-manual/connecting-locally.html">
{{ 'Learn More' | i18n }}
</a>
</ng-template>
</header>
<table [appTable]="['Network Interface', 'URL', null]">
@for (address of local(); track $index) {
<tr>
<td [style.width.rem]="12">{{ address.nid }}</td>
<td>{{ address.url | mask }}</td>
<td actions [href]="address.url" [disabled]="!isRunning()"></td>
</tr>
}
</table>
`,
host: { class: 'g-card' },
imports: [
TuiIcon,
TuiTooltip,
TuiLink,
TableComponent,
InterfaceActionsComponent,
MaskPipe,
i18nPipe,
DocsLinkDirective,
],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class InterfaceLocalComponent {
readonly local = input.required<readonly LocalAddress[]>()
readonly isRunning = input.required<boolean>()
}

View File

@@ -1,26 +0,0 @@
import { ChangeDetectionStrategy, Component, input } from '@angular/core'
import { i18nPipe } from '@start9labs/shared'
import { TuiBadge } from '@taiga-ui/kit'
@Component({
selector: 'interface-status',
template: `
<tui-badge
size="l"
[iconStart]="public() ? '@tui.globe' : '@tui.lock'"
[appearance]="public() ? 'positive' : 'negative'"
>
{{ public() ? ('Public' | i18n) : ('Private' | i18n) }}
</tui-badge>
`,
styles: `
:host {
display: inline-flex;
}
`,
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [TuiBadge, i18nPipe],
})
export class InterfaceStatusComponent {
readonly public = input(false)
}

View File

@@ -0,0 +1,182 @@
import {
ChangeDetectionStrategy,
Component,
inject,
input,
} from '@angular/core'
import {
DialogService,
DocsLinkDirective,
ErrorService,
i18nPipe,
LoadingService,
} from '@start9labs/shared'
import { ISB, utils } from '@start9labs/start-sdk'
import { TuiAppearance, TuiButton, TuiLink } from '@taiga-ui/core'
import { filter } from 'rxjs'
import {
FormComponent,
FormContext,
} from 'src/app/routes/portal/components/form.component'
import { InterfaceComponent } from 'src/app/routes/portal/components/interfaces/interface.component'
import { PlaceholderComponent } from 'src/app/routes/portal/components/placeholder.component'
import { TableComponent } from 'src/app/routes/portal/components/table.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'
type OnionForm = {
key: string
}
@Component({
selector: 'section[torDomains]',
template: `
<header>
<!-- @TODO translation -->
Tor Domains
<a
tuiLink
docsLink
path="/user-manual/connecting-remotely/tor.html"
appearance="action-grayscale"
iconEnd="@tui.external-link"
[pseudo]="true"
></a>
<button
tuiButton
iconStart="@tui.plus"
[style.margin-inline-start]="'auto'"
(click)="add()"
>
{{ 'Add' | i18n }}
</button>
</header>
@if (torDomains().length) {
<table [appTable]="['Domain', null]">
@for (domain of torDomains(); track $index) {
<tr>
<td>{{ domain }}</td>
<td>
<button
tuiIconButton
iconStart="@tui.trash"
appearance="action-destructive"
(click)="remove(domain)"
>
{{ 'Delete' | i18n }}
</button>
</td>
</tr>
}
</table>
} @else {
<app-placeholder icon="@tui.app-window">
{{ 'No Tor domains' | i18n }}
</app-placeholder>
}
`,
imports: [
TuiButton,
TuiLink,
TuiAppearance,
TableComponent,
PlaceholderComponent,
i18nPipe,
DocsLinkDirective,
],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class InterfaceTorDomainsComponent {
private readonly dialog = inject(DialogService)
private readonly formDialog = inject(FormDialogService)
private readonly loader = inject(LoadingService)
private readonly errorService = inject(ErrorService)
private readonly api = inject(ApiService)
private readonly interface = inject(InterfaceComponent)
private readonly i18n = inject(i18nPipe)
readonly torDomains = input.required<readonly string[]>()
async remove(domain: string) {
this.dialog
.openConfirm({ label: 'Are you sure?', size: 's' })
.pipe(filter(Boolean))
.subscribe(async () => {
const loader = this.loader.open('Removing').subscribe()
const params = { onion: domain }
try {
if (this.interface.packageId()) {
await this.api.pkgRemoveOnion({
...params,
package: this.interface.packageId(),
host: this.interface.value().addressInfo.hostId,
})
} else {
await this.api.serverRemoveOnion(params)
}
return true
} catch (e: any) {
this.errorService.handleError(e)
return false
} finally {
loader.unsubscribe()
}
})
}
async add() {
this.formDialog.open<FormContext<OnionForm>>(FormComponent, {
label: 'New Tor domain',
data: {
spec: await configBuilderToSpec(
ISB.InputSpec.of({
key: ISB.Value.text({
name: this.i18n.transform('Private Key (optional)')!,
description: this.i18n.transform(
'Optionally provide a base64-encoded ed25519 private key for generating the Tor V3 (.onion) domain. If not provided, a random key will be generated.',
),
required: false,
default: null,
patterns: [utils.Patterns.base64],
}),
}),
),
buttons: [
{
text: this.i18n.transform('Save')!,
handler: async value => this.save(value),
},
],
},
})
}
private async save(form: OnionForm): Promise<boolean> {
const loader = this.loader.open('Saving').subscribe()
try {
let onion = form.key
? await this.api.addTorKey({ key: form.key })
: await this.api.generateTorKey({})
onion = `${onion}.onion`
if (this.interface.packageId) {
await this.api.pkgAddOnion({
onion,
package: this.interface.packageId(),
host: this.interface.value().addressInfo.hostId,
})
} else {
await this.api.serverAddOnion({ onion })
}
return true
} catch (e: any) {
this.errorService.handleError(e)
return false
} finally {
loader.unsubscribe()
}
}
}

View File

@@ -1,233 +0,0 @@
import {
ChangeDetectionStrategy,
Component,
inject,
input,
} from '@angular/core'
import {
DialogService,
DocsLinkDirective,
ErrorService,
i18nPipe,
LoadingService,
} from '@start9labs/shared'
import { ISB, utils } from '@start9labs/start-sdk'
import {
TuiAppearance,
TuiButton,
TuiIcon,
TuiLink,
TuiOption,
} from '@taiga-ui/core'
import { TuiTooltip } from '@taiga-ui/kit'
import { defaultIfEmpty, firstValueFrom } from 'rxjs'
import {
FormComponent,
FormContext,
} from 'src/app/routes/portal/components/form.component'
import { InterfaceComponent } from 'src/app/routes/portal/components/interfaces/interface.component'
import { PlaceholderComponent } from 'src/app/routes/portal/components/placeholder.component'
import { TableComponent } from 'src/app/routes/portal/components/table.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 { InterfaceActionsComponent } from './actions.component'
import { TorAddress } from './interface.utils'
import { MaskPipe } from './mask.pipe'
type OnionForm = {
key: string
}
@Component({
selector: 'section[tor]',
template: `
<header>
Tor
<tui-icon [tuiTooltip]="tooltip" />
<ng-template #tooltip>
{{
'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 path="/user-manual/connecting-remotely/tor.html">
{{ 'Learn More' | i18n }}
</a>
</ng-template>
@if (tor().length) {
<button
tuiButton
iconStart="@tui.plus"
[style.margin-inline-start]="'auto'"
(click)="add()"
>
{{ 'Add' | i18n }}
</button>
}
</header>
@if (tor().length) {
<table [appTable]="['Protocol', 'URL', null]">
@for (address of tor(); track $index) {
<tr>
<td [style.width.rem]="12">{{ address.protocol || '-' }}</td>
<td>{{ address.url | mask }}</td>
<td actions [href]="address.url" [disabled]="!isRunning()">
<button
tuiIconButton
iconStart="@tui.trash"
appearance="flat-grayscale"
(click)="remove(address)"
>
{{ 'Delete' | i18n }}
</button>
<button
tuiOption
tuiAppearance="action-destructive"
iconStart="@tui.trash"
(click)="remove(address)"
>
{{ 'Delete' | i18n }}
</button>
</td>
</tr>
}
</table>
} @else {
<app-placeholder icon="@tui.app-window">
{{ 'No onion addresses' | i18n }}
<button tuiButton iconStart="@tui.plus" (click)="add()">
{{ 'Add' | i18n }}
</button>
</app-placeholder>
}
`,
styles: `
[tuiFade] {
white-space: nowrap;
max-width: 30rem;
}
`,
host: { class: 'g-card' },
imports: [
TuiButton,
TuiIcon,
TuiTooltip,
TuiLink,
TuiAppearance,
TuiOption,
TableComponent,
PlaceholderComponent,
MaskPipe,
InterfaceActionsComponent,
i18nPipe,
DocsLinkDirective,
],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class InterfaceTorComponent {
private readonly dialog = inject(DialogService)
private readonly formDialog = inject(FormDialogService)
private readonly loader = inject(LoadingService)
private readonly errorService = inject(ErrorService)
private readonly api = inject(ApiService)
private readonly interface = inject(InterfaceComponent)
private readonly i18n = inject(i18nPipe)
readonly tor = input.required<readonly TorAddress[]>()
readonly isRunning = input.required<boolean>()
async remove({ url }: TorAddress) {
const confirm = await firstValueFrom(
this.dialog
.openConfirm({
label: 'Confirm',
size: 's',
data: {
yes: 'Delete',
no: 'Cancel',
content: 'Are you sure you want to delete this address?',
},
})
.pipe(defaultIfEmpty(false)),
)
if (!confirm) {
return
}
const loader = this.loader.open('Removing').subscribe()
const params = { onion: new URL(url).hostname }
try {
if (this.interface.packageId()) {
await this.api.pkgRemoveOnion({
...params,
package: this.interface.packageId(),
host: this.interface.value().addressInfo.hostId,
})
} else {
await this.api.serverRemoveOnion(params)
}
return true
} catch (e: any) {
this.errorService.handleError(e)
return false
} finally {
loader.unsubscribe()
}
}
async add() {
this.formDialog.open<FormContext<OnionForm>>(FormComponent, {
label: 'New onion address',
data: {
spec: await configBuilderToSpec(
ISB.InputSpec.of({
key: ISB.Value.text({
name: this.i18n.transform('Private Key (optional)')!,
description: this.i18n.transform(
'Optionally provide a base64-encoded ed25519 private key for generating the Tor V3 (.onion) address. If not provided, a random key will be generated and used.',
),
required: false,
default: null,
patterns: [utils.Patterns.base64],
}),
}),
),
buttons: [
{
text: this.i18n.transform('Save')!,
handler: async value => this.save(value),
},
],
},
})
}
private async save(form: OnionForm): Promise<boolean> {
const loader = this.loader.open('Saving').subscribe()
try {
let onion = form.key
? await this.api.addTorKey({ key: form.key })
: await this.api.generateTorKey({})
onion = `${onion}.onion`
if (this.interface.packageId) {
await this.api.pkgAddOnion({
onion,
package: this.interface.packageId(),
host: this.interface.value().addressInfo.hostId,
})
} else {
await this.api.serverAddOnion({ onion })
}
return true
} catch (e: any) {
this.errorService.handleError(e)
return false
} finally {
loader.unsubscribe()
}
}
}

View File

@@ -7,7 +7,7 @@ import {
} from '@angular/core'
import { RouterLink } from '@angular/router'
import { T } from '@start9labs/start-sdk'
import { TuiButton, TuiIcon } from '@taiga-ui/core'
import { TuiButton } from '@taiga-ui/core'
import { TuiBadge } from '@taiga-ui/kit'
import { ConfigService } from 'src/app/services/config.service'
import { PackageDataEntry } from 'src/app/services/patch-db/data-model'
@@ -21,13 +21,6 @@ import { PackageDataEntry } from 'src/app/services/patch-db/data-model'
<td>
<tui-badge size="m" [appearance]="appearance">{{ info.type }}</tui-badge>
</td>
<td [style.text-align]="'center'">
@if (info.public) {
<tui-icon class="g-positive" icon="@tui.globe" />
} @else {
<tui-icon class="g-negative" icon="@tui.lock" />
}
</td>
<td class="g-secondary" [style.grid-area]="'2 / span 4'">
{{ info.description }}
</td>
@@ -86,7 +79,7 @@ import { PackageDataEntry } from 'src/app/services/patch-db/data-model'
}
`,
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [TuiButton, TuiBadge, TuiIcon, RouterLink],
imports: [TuiButton, TuiBadge, RouterLink],
})
export class ServiceInterfaceItemComponent {
private readonly config = inject(ConfigService)
@@ -94,7 +87,6 @@ export class ServiceInterfaceItemComponent {
@Input({ required: true })
info!: T.ServiceInterface & {
public: boolean
routerLink: string
}

View File

@@ -2,27 +2,23 @@ import {
ChangeDetectionStrategy,
Component,
computed,
inject,
input,
} from '@angular/core'
import { TuiTable } from '@taiga-ui/addon-table'
import { tuiDefaultSort } from '@taiga-ui/cdk'
import { ConfigService } from 'src/app/services/config.service'
import { PackageDataEntry } from 'src/app/services/patch-db/data-model'
import { getAddresses } from '../../../components/interfaces/interface.utils'
import { ServiceInterfaceItemComponent } from './interface-item.component'
import { i18nPipe } from '@start9labs/shared'
@Component({
selector: 'service-interfaces',
template: `
<header>{{ 'Interfaces' | i18n }}</header>
<header>{{ 'Service Interfaces' | i18n }}</header>
<table tuiTable class="g-table">
<thead>
<tr>
<th tuiTh>{{ 'Name' | i18n }}</th>
<th tuiTh>{{ 'Type' | i18n }}</th>
<th tuiTh [style.text-align]="'center'">{{ 'Hosting' | i18n }}</th>
<th tuiTh>{{ 'Description' | i18n }}</th>
<th tuiTh></th>
</tr>
@@ -49,8 +45,6 @@ import { i18nPipe } from '@start9labs/shared'
imports: [ServiceInterfaceItemComponent, TuiTable, i18nPipe],
})
export class ServiceInterfacesComponent {
private readonly config = inject(ConfigService)
readonly pkg = input.required<PackageDataEntry>()
readonly disabled = input(false)
@@ -58,14 +52,8 @@ export class ServiceInterfacesComponent {
Object.entries(serviceInterfaces)
.sort((a, b) => tuiDefaultSort(a[1], b[1]))
.map(([id, value]) => {
const host = hosts[value.addressInfo.hostId]
const port = value.addressInfo.internalPort
return {
...value,
addSsl: host?.bindings[port]?.options.addSsl,
public: !!host?.bindings[port]?.net.public,
addresses: host ? getAddresses(value, host, this.config) : {},
routerLink: `./interface/${id}`,
}
}),

View File

@@ -16,7 +16,6 @@ import { TuiHeader } from '@taiga-ui/layout'
import { PatchDB } from 'patch-db-client'
import { InterfaceComponent } from 'src/app/routes/portal/components/interfaces/interface.component'
import { getAddresses } from 'src/app/routes/portal/components/interfaces/interface.utils'
import { InterfaceStatusComponent } from 'src/app/routes/portal/components/interfaces/status.component'
import { ConfigService } from 'src/app/services/config.service'
import { DataModel } from 'src/app/services/patch-db/data-model'
import { TitleDirective } from 'src/app/services/title.service'
@@ -28,10 +27,6 @@ import { TitleDirective } from 'src/app/services/title.service'
{{ 'Back' | i18n }}
</a>
{{ interface()?.name }}
<interface-status
[style.margin-left.rem]="0.5"
[public]="!!interface()?.public"
/>
</ng-container>
<tui-breadcrumbs size="l">
<a *tuiItem tuiLink appearance="action-grayscale" routerLink="../..">
@@ -47,12 +42,11 @@ import { TitleDirective } from 'src/app/services/title.service'
<tui-badge size="l" [appearance]="getAppearance(value.type)">
{{ value.type }}
</tui-badge>
<interface-status [public]="value.public" />
</h3>
<p tuiSubtitle>{{ value.description }}</p>
</hgroup>
</header>
<app-interface
<service-interface
[packageId]="pkgId"
[value]="value"
[isRunning]="isRunning()"
@@ -86,7 +80,6 @@ import { TitleDirective } from 'src/app/services/title.service'
TuiBreadcrumbs,
TuiItem,
TuiLink,
InterfaceStatusComponent,
i18nPipe,
TuiBadge,
TuiHeader,
@@ -127,9 +120,10 @@ export default class ServiceInterfaceRoute {
return {
...item,
addSsl: host?.bindings[port]?.options.addSsl,
public: !!host?.bindings[port]?.net.public,
addresses: getAddresses(item, host, this.config),
gateways: [],
torDomains: [],
clearnetDomains: [],
}
})

View File

@@ -0,0 +1,57 @@
import { ChangeDetectionStrategy, Component, inject } from '@angular/core'
import { RouterLink } from '@angular/router'
import { DocsLinkDirective, i18nPipe } from '@start9labs/shared'
import { TuiButton, TuiLink } from '@taiga-ui/core'
import { TitleDirective } from 'src/app/services/title.service'
import { AuthorityService } from './authority.service'
import { AuthoritiesTableComponent } from './table.component'
@Component({
template: `
<ng-container *title>
<a routerLink=".." tuiIconButton iconStart="@tui.arrow-left">
{{ 'Back' | i18n }}
</a>
{{ 'Certificate Authorities' | i18n }}
</ng-container>
<section class="g-card">
<header>
{{ 'Certificate Authorities' | i18n }}
<a
tuiLink
docsLink
path="/user-manual/authorities.html"
appearance="action-grayscale"
iconEnd="@tui.external-link"
[pseudo]="true"
></a>
@if (authorityService.authorities(); as authorities) {
<button
tuiButton
size="xs"
iconStart="@tui.plus"
[style.margin-inline-start]="'auto'"
(click)="authorityService.add(authorities)"
>
{{ 'Add' | i18n }}
</button>
}
</header>
<authorities-table />
</section>
`,
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [
TuiButton,
TuiLink,
RouterLink,
TitleDirective,
i18nPipe,
DocsLinkDirective,
AuthoritiesTableComponent,
],
providers: [AuthorityService],
})
export default class SystemAuthoritiesComponent {
protected readonly authorityService = inject(AuthorityService)
}

View File

@@ -14,7 +14,6 @@ import { FormDialogService } from 'src/app/services/form-dialog.service'
import { configBuilderToSpec } from 'src/app/utils/configBuilderToSpec'
import { PatchDB } from 'patch-db-client'
import { DataModel } from 'src/app/services/patch-db/data-model'
import { toAuthorityName } from 'src/app/utils/acme'
import { parse } from 'tldts'
import { RR } from 'src/app/services/api/api.types'
import { DNS } from './dns.component'
@@ -29,10 +28,6 @@ export type MappedDomain = {
name: string | null
ipInfo: T.IpInfo | null
}
authority: {
url: string | null
name: string | null
}
}
@Injectable()
@@ -64,19 +59,8 @@ export class DomainService {
id: gateway,
ipInfo: gateways[gateway]?.ipInfo || null,
},
authority: {
url: acme,
name: toAuthorityName(acme),
},
}) as MappedDomain,
),
authorities: Object.keys(acme).reduce<Record<string, string>>(
(obj, url) => ({
...obj,
[url]: toAuthorityName(url),
}),
{ local: toAuthorityName(null) },
),
})),
),
)
@@ -91,7 +75,7 @@ export class DomainService {
default: null,
patterns: [utils.Patterns.domain],
}),
...this.gatewaysAndAuthorities(),
...this.gatewaysSpec(),
})
this.formDialog.open(FormComponent, {
@@ -105,7 +89,6 @@ export class DomainService {
this.save({
fqdn: input.fqdn,
gateway: input.gateway,
acme: input.authority === 'local' ? null : input.authority,
}),
},
],
@@ -115,7 +98,7 @@ export class DomainService {
async edit(domain: MappedDomain) {
const editSpec = ISB.InputSpec.of({
...this.gatewaysAndAuthorities(),
...this.gatewaysSpec(),
})
this.formDialog.open(FormComponent, {
@@ -129,13 +112,11 @@ export class DomainService {
this.save({
fqdn: domain.fqdn,
gateway: input.gateway,
acme: input.authority === 'local' ? null : input.authority,
}),
},
],
value: {
gateway: domain.gateway.id,
authority: domain.authority.url || 'local',
},
},
})
@@ -178,22 +159,14 @@ export class DomainService {
}
}
private gatewaysAndAuthorities() {
private gatewaysSpec() {
return {
gateway: ISB.Value.select({
name: 'Gateway',
description:
'Select the public gateway for this domain. Whichever gateway you select is the IP address that will be exposed to the Internet.',
description: 'Select which gateway to use for this domain.',
values: this.data()!.gateways,
default: '',
}),
authority: ISB.Value.select({
name: 'Default Certificate Authority',
description:
'Select the default certificate authority that will sign certificates for this domain. You can override this on a case-by-case basis.',
values: this.data()!.authorities,
default: '',
}),
}
}
}

View File

@@ -1,13 +1,10 @@
import { ChangeDetectionStrategy, Component, inject } from '@angular/core'
import { RouterLink } from '@angular/router'
import { DocsLinkDirective, i18nPipe } from '@start9labs/shared'
import { TuiButton, TuiLink, TuiTitle } from '@taiga-ui/core'
import { TuiHeader } from '@taiga-ui/layout'
import { TuiButton, TuiLink } from '@taiga-ui/core'
import { TitleDirective } from 'src/app/services/title.service'
import { AuthorityService } from './authorities/authority.service'
import { DomainService } from './domains/domain.service'
import { DomainsTableComponent } from './domains/table.component'
import { AuthoritiesTableComponent } from './authorities/table.component'
import { DomainService } from './domain.service'
import { DomainsTableComponent } from './table.component'
@Component({
template: `
@@ -17,48 +14,18 @@ import { AuthoritiesTableComponent } from './authorities/table.component'
</a>
{{ 'Domains' | i18n }}
</ng-container>
<header tuiHeader>
<hgroup tuiTitle>
<h3>{{ 'Domains' | i18n }}</h3>
<p tuiSubtitle>
{{
'Adding a domain to StartOS means you can use it and its subdomains to host service interfaces on the public Internet.'
| i18n
}}
<a
tuiLink
docsLink
path="/user-manual/domains.html"
appearance="action-grayscale"
iconEnd="@tui.external-link"
[pseudo]="true"
[textContent]="'View instructions' | i18n"
></a>
</p>
</hgroup>
</header>
<section class="g-card">
<header>
{{ 'Certificate Authorities' | i18n }}
@if (authorityService.authorities(); as authorities) {
<button
tuiButton
size="xs"
iconStart="@tui.plus"
[style.margin-inline-start]="'auto'"
(click)="authorityService.add(authorities)"
>
{{ 'Add' | i18n }}
</button>
}
</header>
<authorities-table />
</section>
<section class="g-card">
<header>
{{ 'Domains' | i18n }}
<a
tuiLink
docsLink
path="/user-manual/domains.html"
appearance="action-grayscale"
iconEnd="@tui.external-link"
[pseudo]="true"
></a>
@if (domainService.data(); as value) {
<button
tuiButton
@@ -77,19 +44,15 @@ import { AuthoritiesTableComponent } from './authorities/table.component'
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [
TuiButton,
TuiTitle,
TuiHeader,
TuiLink,
RouterLink,
TitleDirective,
i18nPipe,
DocsLinkDirective,
DomainsTableComponent,
AuthoritiesTableComponent,
],
providers: [AuthorityService, DomainService],
providers: [DomainService],
})
export default class SystemDomainsComponent {
protected readonly authorityService = inject(AuthorityService)
protected readonly domainService = inject(DomainService)
}

View File

@@ -19,7 +19,6 @@ import { DomainService, MappedDomain } from './domain.service'
@if (domain(); as domain) {
<td>{{ domain.fqdn }}</td>
<td [style.order]="-1">{{ domain.gateway.ipInfo?.name || '-' }}</td>
<td>{{ domain.authority.name }}</td>
<td>
<button
tuiIconButton

View File

@@ -9,9 +9,7 @@ import { DomainService } from './domain.service'
@Component({
selector: 'domains-table',
template: `
<table
[appTable]="['Domain', 'Gateway', 'Default Certificate Authority', null]"
>
<table [appTable]="['Domain', 'Gateway', null]">
@for (domain of domainService.data()?.domains; track $index) {
<tr [domain]="domain"></tr>
} @empty {

View File

@@ -31,31 +31,21 @@ import { configBuilderToSpec } from 'src/app/utils/configBuilderToSpec'
</a>
{{ 'Email' | i18n }}
</ng-container>
<header tuiHeader>
<hgroup tuiTitle>
<h3>{{ 'Email' | i18n }}</h3>
<p tuiSubtitle>
{{
'Connecting an external SMTP server allows StartOS and your installed services to send you emails.'
| i18n
}}
<a
tuiLink
docsLink
path="/user-manual/smtp.html"
appearance="action-grayscale"
iconEnd="@tui.external-link"
[pseudo]="true"
[textContent]="'View instructions' | i18n"
></a>
</p>
</hgroup>
</header>
@if (form$ | async; as form) {
<form [formGroup]="form">
<header tuiHeader="body-l">
<h3 tuiTitle>
<b>{{ 'SMTP Credentials' | i18n }}</b>
<b>
{{ 'SMTP Credentials' | i18n }}
<a
tuiLink
docsLink
path="/user-manual/smtp.html"
appearance="action-grayscale"
iconEnd="@tui.external-link"
[pseudo]="true"
></a>
</b>
</h3>
</header>
@if (spec | async; as resolved) {

View File

@@ -15,7 +15,6 @@ import { ApiService } from 'src/app/services/api/embassy-api.service'
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 } from '@start9labs/start-sdk'
import { GatewayPlus } from './item.component'
@@ -28,30 +27,18 @@ import { GatewayPlus } from './item.component'
</a>
{{ 'Gateways' | i18n }}
</ng-container>
<header tuiHeader>
<hgroup tuiTitle>
<h3>{{ 'Gateways' | i18n }}</h3>
<p tuiSubtitle>
{{
'Gateways connect your server to the Internet. They process outbound traffic, and under certain conditions, they also permit inbound traffic.'
| i18n
}}
<a
tuiLink
docsLink
path="/user-manual/gateways.html"
appearance="action-grayscale"
iconEnd="@tui.external-link"
[pseudo]="true"
[textContent]="'View instructions' | i18n"
></a>
</p>
</hgroup>
</header>
<section class="g-card">
<header>
{{ 'Gateways' | i18n }}
<a
tuiLink
docsLink
path="/user-manual/gateways.html"
appearance="action-grayscale"
iconEnd="@tui.external-link"
[pseudo]="true"
></a>
<button
tuiButton
size="xs"
@@ -70,7 +57,6 @@ import { GatewayPlus } from './item.component'
CommonModule,
TuiButton,
GatewaysTableComponent,
TuiHeader,
TitleDirective,
i18nPipe,
TuiLink,

View File

@@ -38,7 +38,7 @@ import {
TuiButtonSelect,
TuiDataListWrapper,
} from '@taiga-ui/kit'
import { TuiCell, tuiCellOptionsProvider, TuiHeader } from '@taiga-ui/layout'
import { TuiCell, tuiCellOptionsProvider } from '@taiga-ui/layout'
import { PolymorpheusComponent } from '@taiga-ui/polymorpheus'
import { PatchDB } from 'patch-db-client'
import { filter } from 'rxjs'
@@ -59,14 +59,6 @@ import { SystemWipeComponent } from './wipe.component'
</a>
{{ 'General Settings' | i18n }}
</ng-container>
<header tuiHeader>
<hgroup tuiTitle>
<h3>{{ 'General Settings' | i18n }}</h3>
<p tuiSubtitle>
{{ 'Manage your overall setup and preferences' | i18n }}
</p>
</hgroup>
</header>
@if (server(); as server) {
<div tuiCell tuiAppearance="outline-grayscale">
<tui-icon icon="@tui.zap" />
@@ -138,16 +130,6 @@ import { SystemWipeComponent } from './wipe.component'
/>
</button>
</div>
<div tuiCell tuiAppearance="outline-grayscale">
<tui-icon icon="@tui.award" />
<span tuiTitle>
<strong>{{ 'Root Certificate Authority' | i18n }}</strong>
<span tuiSubtitle>{{ 'Download your Root CA' | i18n }}</span>
</span>
<button tuiButton iconStart="@tui.download" (click)="downloadCA()">
{{ 'Download' | i18n }}
</button>
</div>
<div tuiCell tuiAppearance="outline-grayscale">
<tui-icon icon="@tui.monitor" />
<span tuiTitle>
@@ -205,8 +187,6 @@ import { SystemWipeComponent } from './wipe.component'
src="assets/img/icons/snek.png"
/>
}
<!-- hidden element for downloading cert -->
<a id="download-ca" href="/static/local-root-ca.crt"></a>
`,
styles: `
:host {
@@ -239,7 +219,6 @@ import { SystemWipeComponent } from './wipe.component'
RouterLink,
i18nPipe,
TuiTitle,
TuiHeader,
TuiCell,
TuiAppearance,
TuiButton,
@@ -347,10 +326,6 @@ export default class SystemGeneralComponent {
.subscribe(() => this.resetTor(this.wipe))
}
downloadCA() {
this.document.getElementById('download-ca')?.click()
}
async tryToggleKiosk() {
if (
this.server()?.kiosk &&

View File

@@ -31,13 +31,10 @@ import { getServerInfo } from 'src/app/utils/get-server-info'
<hgroup tuiTitle>
<h3>{{ 'Change Password' | i18n }}</h3>
<p tuiSubtitle>
{{ 'Change your StartOS master password.' | i18n }}
<strong>
{{
'You will still need your current password to decrypt existing backups!'
| i18n
}}
</strong>
{{
'You will still need your current password to decrypt existing backups!'
| i18n
}}
</p>
</hgroup>
</header>

View File

@@ -7,8 +7,7 @@ import {
} from '@angular/core'
import { RouterLink } from '@angular/router'
import { ErrorService, i18nPipe, LoadingService } from '@start9labs/shared'
import { TuiButton, TuiTitle } from '@taiga-ui/core'
import { TuiHeader } from '@taiga-ui/layout'
import { TuiButton } from '@taiga-ui/core'
import { from, map, merge, Observable, Subject } from 'rxjs'
import { Session } from 'src/app/services/api/api.types'
import { ApiService } from 'src/app/services/api/embassy-api.service'
@@ -21,17 +20,7 @@ import { SessionsTableComponent } from './table.component'
<a routerLink=".." tuiIconButton iconStart="@tui.arrow-left">Back</a>
{{ 'Active Sessions' | i18n }}
</ng-container>
<header tuiHeader>
<hgroup tuiTitle>
<h3>{{ 'Active Sessions' | i18n }}</h3>
<p tuiSubtitle>
{{
'A session is a device that is currently logged into StartOS. For best security, terminate sessions you do not recognize or no longer use.'
| i18n
}}
</p>
</hgroup>
</header>
<section class="g-card">
<header>{{ 'Current session' | i18n }}</header>
<div [single]="true" [sessions]="current$ | async"></div>
@@ -62,8 +51,6 @@ import { SessionsTableComponent } from './table.component'
SessionsTableComponent,
RouterLink,
TitleDirective,
TuiHeader,
TuiTitle,
i18nPipe,
],
})

View File

@@ -14,8 +14,7 @@ import {
LoadingService,
} from '@start9labs/shared'
import { ISB } from '@start9labs/start-sdk'
import { TuiButton, TuiLink, TuiTitle } from '@taiga-ui/core'
import { TuiHeader } from '@taiga-ui/layout'
import { TuiButton, TuiLink } from '@taiga-ui/core'
import { filter, from, merge, Subject } from 'rxjs'
import { FormComponent } from 'src/app/routes/portal/components/form.component'
import { SSHKey } from 'src/app/services/api/api.types'
@@ -33,30 +32,18 @@ import { SSHTableComponent } from './table.component'
</a>
SSH
</ng-container>
<header tuiHeader>
<hgroup tuiTitle>
<h3>SSH</h3>
<p tuiSubtitle>
{{
'By default, you can SSH into your server from any device using your master password. Optionally add SSH public keys to grant specific devices access without needing to enter a password.'
| i18n
}}
<a
tuiLink
docsLink
path="/user-manual/ssh.html"
appearance="action-grayscale"
iconEnd="@tui.external-link"
[pseudo]="true"
[textContent]="'View instructions' | i18n"
></a>
</p>
</hgroup>
</header>
@let keys = keys$ | async;
<section class="g-card">
<header>
Saved Keys
{{ 'SSH Keys' | i18n }}
<a
tuiLink
docsLink
path="/user-manual/ssh.html"
appearance="action-grayscale"
iconEnd="@tui.external-link"
[pseudo]="true"
></a>
<button
tuiButton
size="xs"
@@ -95,8 +82,6 @@ import { SSHTableComponent } from './table.component'
SSHTableComponent,
RouterLink,
TitleDirective,
TuiHeader,
TuiTitle,
TuiLink,
i18nPipe,
DocsLinkDirective,
@@ -118,7 +103,7 @@ export default class SystemSSHComponent {
async add(all: readonly SSHKey[]) {
this.formDialog.open(FormComponent, {
label: 'Add SSH Public Key',
label: 'Add SSH key',
data: {
spec: await configBuilderToSpec(SSHSpec),
buttons: [

View File

@@ -47,7 +47,7 @@ import { SSHKey } from 'src/app/services/api/api.types'
} @empty {
@if (keys()) {
<tr>
<td colspan="5">{{ 'No keys' | i18n }}</td>
<td colspan="5">{{ 'No SSH keys' | i18n }}</td>
</tr>
} @else {
@for (i of ['', '']; track $index) {

View File

@@ -14,7 +14,6 @@ import { PatchDB } from 'patch-db-client'
import { map } from 'rxjs'
import { InterfaceComponent } from 'src/app/routes/portal/components/interfaces/interface.component'
import { getAddresses } from 'src/app/routes/portal/components/interfaces/interface.utils'
import { InterfaceStatusComponent } from 'src/app/routes/portal/components/interfaces/status.component'
import { ConfigService } from 'src/app/services/config.service'
import { DataModel } from 'src/app/services/patch-db/data-model'
import { TitleDirective } from 'src/app/services/title.service'
@@ -26,19 +25,17 @@ import { TitleDirective } from 'src/app/services/title.service'
{{ 'Back' | i18n }}
</a>
{{ iface.name }}
<interface-status [style.margin-left.rem]="0.5" [public]="public()" />
</ng-container>
<header tuiHeader>
<hgroup tuiTitle>
<h3>
{{ iface.name }}
<interface-status [public]="public()" />
</h3>
<p tuiSubtitle>{{ iface.description }}</p>
</hgroup>
</header>
@if (ui(); as ui) {
<app-interface [value]="ui" [isRunning]="true" />
<service-interface [value]="ui" [isRunning]="true" />
}
`,
host: { class: 'g-subpage' },
@@ -50,7 +47,6 @@ import { TitleDirective } from 'src/app/services/title.service'
TitleDirective,
TuiHeader,
TuiTitle,
InterfaceStatusComponent,
i18nPipe,
],
})
@@ -81,17 +77,14 @@ export default class StartOsUiComponent {
.watch$('serverInfo', 'network', 'host')
.pipe(
map(host => {
const port = this.iface.addressInfo.internalPort
return {
...this.iface,
addSsl: host.bindings[port]?.options.addSsl,
public: !!host.bindings[port]?.net.public,
addresses: getAddresses(this.iface, host, this.config),
gateways: [],
torDomains: [],
clearnetDomains: [],
}
}),
),
)
readonly public = computed((ui = this.ui()) => !!ui?.public)
}

View File

@@ -34,7 +34,7 @@ import { map } from 'rxjs'
<span tuiTitle>
<span>
{{ page.item | i18n }}
@if (page.item === 'General' && badge()) {
@if (page.item === 'General Settings' && badge()) {
<tui-badge-notification>{{ badge() }}</tui-badge-notification>
}
</span>

View File

@@ -1,10 +1,8 @@
import { i18nKey } from '@start9labs/shared'
export const SYSTEM_MENU = [
[
{
icon: '@tui.settings',
item: 'General',
icon: '@tui.wrench',
item: 'General Settings',
link: 'general',
},
],
@@ -43,6 +41,11 @@ export const SYSTEM_MENU = [
item: 'Gateways',
link: 'gateways',
},
{
icon: '@tui.award',
item: 'Certificate Authorities',
link: 'authorities',
},
{
icon: '@tui.globe',
item: 'Domains',
@@ -57,7 +60,7 @@ export const SYSTEM_MENU = [
},
{
icon: '@tui.terminal',
item: 'SSH' as i18nKey,
item: 'SSH Keys',
link: 'ssh',
},
{

View File

@@ -71,6 +71,12 @@ export default [
path: 'gateways',
loadComponent: () => import('./routes/gateways/gateways.component'),
},
{
path: 'authorities',
title: titleResolver,
loadComponent: () =>
import('./routes/authorities/authorities.component'),
},
{
path: 'domains',
title: titleResolver,

View File

@@ -239,7 +239,6 @@ export namespace RR {
export type AddDomainReq = {
fqdn: string
gateway: string
acme: string | null
} // net.domain.add
export type AddDomainRes = null
@@ -251,7 +250,7 @@ export namespace RR {
export type TestDomainReq = {
fqdn: string
gateway: string
} // net.domain.test
} // net.domain.test-dns
export type TestDomainRes = {
root: boolean
wildcard: boolean
@@ -293,12 +292,13 @@ export namespace RR {
export type GenerateTorKeyReq = {} // net.tor.key.generate
export type AddTorKeyRes = string // onion address without .onion suffix
export type ServerBindingSetPublicReq = {
// server.host.binding.set-public
internalPort: number
public: boolean | null // default true
export type ServerBindingToggleGatewayReq = {
// server.host.binding.set-gateway-enabled
gateway: T.GatewayId
internalPort: 80
enabled: boolean
}
export type BindingSetPublicRes = null
export type ServerBindingToggleGatewayRes = null
export type ServerAddOnionReq = {
// server.host.address.onion.add
@@ -311,23 +311,25 @@ export namespace RR {
export type OsUiAddDomainReq = {
// server.host.address.domain.add
domain: string // FQDN
fqdn: string // FQDN
private: boolean
acme: string | null // Url | null
acme: string | null // URL. null means local Root CA
}
export type OsUiAddDomainRes = null
export type OsUiRemoveDomainReq = {
// server.host.address.domain.remove
domain: string // FQDN
fqdn: string // FQDN
}
export type OsUiRemoveDomainRes = null
export type PkgBindingSetPublicReq = ServerBindingSetPublicReq & {
// package.host.binding.set-public
export type PkgBindingToggleGatewayReq = ServerBindingToggleGatewayReq & {
// package.host.binding.set-gateway-enabled
internalPort: number
package: T.PackageId // string
host: T.HostId // string
}
export type PkgBindingToggleGatewayRes = null
export type PkgAddOnionReq = ServerAddOnionReq & {
// package.host.address.onion.add

View File

@@ -359,9 +359,9 @@ export abstract class ApiService {
params: RR.GenerateTorKeyReq,
): Promise<RR.AddTorKeyRes>
abstract serverBindingSetPubic(
params: RR.ServerBindingSetPublicReq,
): Promise<RR.BindingSetPublicRes>
abstract serverBindingToggleGateway(
params: RR.ServerBindingToggleGatewayReq,
): Promise<RR.ServerBindingToggleGatewayRes>
abstract serverAddOnion(params: RR.ServerAddOnionReq): Promise<RR.AddOnionRes>
@@ -377,9 +377,9 @@ export abstract class ApiService {
params: RR.OsUiRemoveDomainReq,
): Promise<RR.OsUiRemoveDomainRes>
abstract pkgBindingSetPubic(
params: RR.PkgBindingSetPublicReq,
): Promise<RR.BindingSetPublicRes>
abstract pkgBindingToggleGateway(
params: RR.PkgBindingToggleGatewayReq,
): Promise<RR.PkgBindingToggleGatewayRes>
abstract pkgAddOnion(params: RR.PkgAddOnionReq): Promise<RR.AddOnionRes>

View File

@@ -369,7 +369,7 @@ export class LiveApiService extends ApiService {
}
async testDomain(params: RR.TestDomainReq): Promise<RR.TestDomainRes> {
return this.rpcRequest({ method: 'net.domain.test', params })
return this.rpcRequest({ method: 'net.domain.test-dns', params })
}
// wifi
@@ -638,11 +638,11 @@ export class LiveApiService extends ApiService {
})
}
async serverBindingSetPubic(
params: RR.ServerBindingSetPublicReq,
): Promise<RR.BindingSetPublicRes> {
async serverBindingToggleGateway(
params: RR.ServerBindingToggleGatewayReq,
): Promise<RR.ServerBindingToggleGatewayRes> {
return this.rpcRequest({
method: 'server.host.binding.set-public',
method: 'server.host.binding.set-gateway-enabled',
params,
})
}
@@ -681,11 +681,11 @@ export class LiveApiService extends ApiService {
})
}
async pkgBindingSetPubic(
params: RR.PkgBindingSetPublicReq,
): Promise<RR.BindingSetPublicRes> {
async pkgBindingToggleGateway(
params: RR.PkgBindingToggleGatewayReq,
): Promise<RR.PkgBindingToggleGatewayRes> {
return this.rpcRequest({
method: 'package.host.binding.set-public',
method: 'package.host.binding.set-gateway-enabled',
params,
})
}

View File

@@ -613,7 +613,6 @@ export class MockApiService extends ApiService {
value: {
[params.fqdn]: {
gateway: params.gateway,
acme: params.acme,
},
},
},
@@ -1369,16 +1368,16 @@ export class MockApiService extends ApiService {
return 'abcdefghijklmnopqrstuv'
}
async serverBindingSetPubic(
params: RR.PkgBindingSetPublicReq,
): Promise<RR.BindingSetPublicRes> {
async serverBindingToggleGateway(
params: RR.ServerBindingToggleGatewayReq,
): Promise<RR.ServerBindingToggleGatewayRes> {
await pauseFor(2000)
const patch = [
{
op: PatchOp.REPLACE,
path: `/serverInfo/host/bindings/${params.internalPort}/net/public`,
value: params.public,
path: `/serverInfo/network/host/bindings/${params.internalPort}/net/publicEnabled`,
value: params.enabled ? [params.gateway] : [],
},
]
this.mockRevision(patch)
@@ -1443,7 +1442,7 @@ export class MockApiService extends ApiService {
op: PatchOp.ADD,
path: `/serverInfo/host/domains`,
value: {
[params.domain]: { public: !params.private, acme: params.acme },
[params.fqdn]: { public: !params.private, acme: params.acme },
},
},
{
@@ -1455,7 +1454,7 @@ export class MockApiService extends ApiService {
public: false,
hostname: {
kind: 'domain',
domain: params.domain,
domain: params.fqdn,
subdomain: null,
port: null,
sslPort: 443,
@@ -1476,7 +1475,7 @@ export class MockApiService extends ApiService {
const patch: RemoveOperation[] = [
{
op: PatchOp.REMOVE,
path: `/serverInfo/host/domains/${params.domain}`,
path: `/serverInfo/host/domains/${params.fqdn}`,
},
{
op: PatchOp.REMOVE,
@@ -1488,16 +1487,16 @@ export class MockApiService extends ApiService {
return null
}
async pkgBindingSetPubic(
params: RR.PkgBindingSetPublicReq,
): Promise<RR.BindingSetPublicRes> {
async pkgBindingToggleGateway(
params: RR.PkgBindingToggleGatewayReq,
): Promise<RR.PkgBindingToggleGatewayRes> {
await pauseFor(2000)
const patch = [
{
op: PatchOp.REPLACE,
path: `/packageData/${params.package}/hosts/${params.host}/bindings/${params.internalPort}/net/public`,
value: params.public,
path: `/packageData/${params.package}/hosts/${params.host}/bindings/${params.internalPort}/net/privateDisabled`,
value: params.enabled ? [] : [params.gateway],
},
]
this.mockRevision(patch)
@@ -1560,7 +1559,7 @@ export class MockApiService extends ApiService {
op: PatchOp.ADD,
path: `/packageData/${params.package}/hosts/${params.host}/domains`,
value: {
[params.domain]: { public: !params.private, acme: params.acme },
[params.fqdn]: { public: !params.private, acme: params.acme },
},
},
{
@@ -1572,7 +1571,7 @@ export class MockApiService extends ApiService {
public: false,
hostname: {
kind: 'domain',
domain: params.domain,
domain: params.fqdn,
subdomain: null,
port: null,
sslPort: 443,
@@ -1593,7 +1592,7 @@ export class MockApiService extends ApiService {
const patch: RemoveOperation[] = [
{
op: PatchOp.REMOVE,
path: `/packageData/${params.package}/hosts/${params.host}/domains/${params.domain}`,
path: `/packageData/${params.package}/hosts/${params.host}/domains/${params.fqdn}`,
},
{
op: PatchOp.REMOVE,