new service interfacee page

This commit is contained in:
Matt Hill
2026-02-14 12:24:16 -07:00
parent d5c74bc22e
commit 098d9275f4
29 changed files with 1133 additions and 1759 deletions

View File

@@ -692,4 +692,9 @@ export default {
727: '',
728: '',
729: '',
730: '',
731: '',
732: '',
733: '',
734: '',
} satisfies i18n

View File

@@ -691,5 +691,10 @@ export const ENGLISH: Record<string, number> = {
'WireGuard Config File': 726,
'Inbound/Outbound': 727,
'StartTunnel (Inbound/Outbound)': 728,
'Ethernet': 729
'Ethernet': 729,
'Add Domain': 730,
'Public Domain': 731,
'Private Domain': 732,
'Hide': 733,
'default outbound': 734
}

View File

@@ -692,4 +692,9 @@ export default {
727: '',
728: '',
729: '',
730: '',
731: '',
732: '',
733: '',
734: '',
} satisfies i18n

View File

@@ -692,4 +692,9 @@ export default {
727: '',
728: '',
729: '',
730: '',
731: '',
732: '',
733: '',
734: '',
} satisfies i18n

View File

@@ -692,4 +692,9 @@ export default {
727: '',
728: '',
729: '',
730: '',
731: '',
732: '',
733: '',
734: '',
} satisfies i18n

View File

@@ -5,7 +5,13 @@ import {
input,
signal,
} from '@angular/core'
import { CopyService, DialogService, i18nPipe } from '@start9labs/shared'
import {
CopyService,
DialogService,
ErrorService,
i18nPipe,
LoadingService,
} from '@start9labs/shared'
import { TUI_IS_MOBILE } from '@taiga-ui/cdk'
import {
TuiButton,
@@ -16,37 +22,13 @@ import {
} from '@taiga-ui/core'
import { PolymorpheusComponent } from '@taiga-ui/polymorpheus'
import { QRModal } from 'src/app/routes/portal/modals/qr.component'
import { InterfaceAddressItemComponent } from './item.component'
import { ApiService } from 'src/app/services/api/embassy-api.service'
import { GatewayAddress, MappedServiceInterface } from '../interface.service'
@Component({
selector: 'td[actions]',
template: `
<div class="desktop">
@if (interface.address().masked) {
<button
tuiIconButton
appearance="flat-grayscale"
[iconStart]="
interface.currentlyMasked() ? '@tui.eye' : '@tui.eye-off'
"
(click)="interface.currentlyMasked.set(!interface.currentlyMasked())"
>
{{ 'Reveal/Hide' | i18n }}
</button>
}
@if (interface.address().ui) {
<a
tuiIconButton
appearance="flat-grayscale"
iconStart="@tui.external-link"
target="_blank"
rel="noopener noreferrer"
[href]="href()"
>
{{ 'Open' | i18n }}
</a>
}
<button
tuiIconButton
appearance="flat-grayscale"
@@ -59,10 +41,20 @@ import { InterfaceAddressItemComponent } from './item.component'
tuiIconButton
appearance="flat-grayscale"
iconStart="@tui.copy"
(click)="copyService.copy(href())"
(click)="copyService.copy(address().url)"
>
{{ 'Copy URL' | i18n }}
</button>
@if (address().deletable) {
<button
tuiIconButton
appearance="flat-destructive"
iconStart="@tui.trash"
(click)="deleteDomain()"
>
{{ 'Delete' | i18n }}
</button>
}
</div>
<div class="mobile">
<button
@@ -75,41 +67,40 @@ import { InterfaceAddressItemComponent } from './item.component'
>
{{ 'Actions' | i18n }}
<tui-data-list *tuiTextfieldDropdown (click)="open.set(false)">
@if (interface.address().ui) {
<a
tuiOption
new
iconStart="@tui.external-link"
target="_blank"
rel="noopener noreferrer"
[href]="href()"
>
{{ 'Open' | i18n }}
</a>
}
@if (interface.address().masked) {
<button
tuiOption
new
iconStart="@tui.eye"
(click)="
interface.currentlyMasked.set(!interface.currentlyMasked())
"
>
{{ 'Reveal/Hide' | i18n }}
</button>
}
<button tuiOption new iconStart="@tui.qr-code" (click)="showQR()">
<button
tuiOption
new
[iconStart]="address().enabled ? '@tui.toggle-right' : '@tui.toggle-left'"
(click)="toggleEnabled()"
>
{{ (address().enabled ? 'Disable' : 'Enable') | i18n }}
</button>
<button
tuiOption
new
iconStart="@tui.qr-code"
(click)="showQR()"
>
{{ 'Show QR' | i18n }}
</button>
<button
tuiOption
new
iconStart="@tui.copy"
(click)="copyService.copy(href())"
(click)="copyService.copy(address().url)"
>
{{ 'Copy URL' | i18n }}
</button>
@if (address().deletable) {
<button
tuiOption
new
iconStart="@tui.trash"
(click)="deleteDomain()"
>
{{ 'Delete' | i18n }}
</button>
}
</tui-data-list>
</button>
</div>
@@ -136,31 +127,23 @@ import { InterfaceAddressItemComponent } from './item.component'
display: block;
}
}
:host-context(tbody.uncommon-hidden) {
.desktop {
height: 0;
visibility: hidden;
}
.mobile {
display: none;
}
}
`,
imports: [TuiButton, TuiDropdown, TuiDataList, i18nPipe, TuiTextfield],
providers: [tuiButtonOptionsProvider({ appearance: 'icon' })],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class AddressActionsComponent {
readonly isMobile = inject(TUI_IS_MOBILE)
readonly dialog = inject(DialogService)
private readonly isMobile = inject(TUI_IS_MOBILE)
private readonly dialog = inject(DialogService)
private readonly api = inject(ApiService)
private readonly loader = inject(LoadingService)
private readonly errorService = inject(ErrorService)
readonly copyService = inject(CopyService)
readonly interface = inject(InterfaceAddressItemComponent)
readonly open = signal(false)
readonly href = input.required<string>()
readonly bullets = input.required<string[]>()
readonly address = input.required<GatewayAddress>()
readonly packageId = input('')
readonly value = input<MappedServiceInterface | undefined>()
readonly disabled = input.required<boolean>()
showQR() {
@@ -168,8 +151,84 @@ export class AddressActionsComponent {
.openComponent(new PolymorpheusComponent(QRModal), {
size: 'auto',
closeable: this.isMobile,
data: this.href(),
data: this.address().url,
})
.subscribe()
}
async toggleEnabled() {
const addr = this.address()
const iface = this.value()
if (!iface) return
const enabled = !addr.enabled
const addressJson = JSON.stringify(addr.hostnameInfo)
const loader = this.loader.open('Saving').subscribe()
try {
if (this.packageId()) {
await this.api.pkgBindingSetAddressEnabled({
internalPort: iface.addressInfo.internalPort,
address: addressJson,
enabled,
package: this.packageId(),
host: iface.addressInfo.hostId,
})
} else {
await this.api.serverBindingSetAddressEnabled({
internalPort: 80,
address: addressJson,
enabled,
})
}
} catch (e: any) {
this.errorService.handleError(e)
} finally {
loader.unsubscribe()
}
}
async deleteDomain() {
const addr = this.address()
const iface = this.value()
if (!iface) return
const confirmed = await this.dialog
.openConfirm({ label: 'Are you sure?', size: 's' })
.toPromise()
if (!confirmed) return
const loader = this.loader.open('Removing').subscribe()
try {
const host = addr.hostnameInfo.host
if (addr.hostnameInfo.metadata.kind === 'public-domain') {
if (this.packageId()) {
await this.api.pkgRemovePublicDomain({
fqdn: host,
package: this.packageId(),
host: iface.addressInfo.hostId,
})
} else {
await this.api.osUiRemovePublicDomain({ fqdn: host })
}
} else if (addr.hostnameInfo.metadata.kind === 'private-domain') {
if (this.packageId()) {
await this.api.pkgRemovePrivateDomain({
fqdn: host,
package: this.packageId(),
host: iface.addressInfo.hostId,
})
} else {
await this.api.osUiRemovePrivateDomain({ fqdn: host })
}
}
} catch (e: any) {
this.errorService.handleError(e)
} finally {
loader.unsubscribe()
}
}
}

View File

@@ -1,117 +1,323 @@
import { ChangeDetectionStrategy, Component, input } from '@angular/core'
import { i18nPipe } from '@start9labs/shared'
import { TuiButton } from '@taiga-ui/core'
import { TuiAccordion } from '@taiga-ui/experimental'
import { TuiElasticContainer, TuiSkeleton } from '@taiga-ui/kit'
import {
ChangeDetectionStrategy,
Component,
inject,
input,
signal,
} from '@angular/core'
import {
DialogService,
ErrorService,
i18nKey,
i18nPipe,
LoadingService,
} from '@start9labs/shared'
import { ISB, utils } from '@start9labs/start-sdk'
import {
TuiButton,
TuiDataList,
TuiDropdown,
TuiTextfield,
} from '@taiga-ui/core'
import { PatchDB } from 'patch-db-client'
import { firstValueFrom } from 'rxjs'
import {
FormComponent,
FormContext,
} from 'src/app/routes/portal/components/form.component'
import { PlaceholderComponent } from 'src/app/routes/portal/components/placeholder.component'
import { TableComponent } from 'src/app/routes/portal/components/table.component'
import { MappedServiceInterface } from '../interface.service'
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 {
GatewayAddressGroup,
MappedServiceInterface,
} from '../interface.service'
import { DNS, DnsGateway } from '../public-domains/dns.component'
import { InterfaceAddressItemComponent } from './item.component'
@Component({
selector: 'section[addresses]',
selector: 'section[gatewayGroup]',
template: `
<header>{{ 'Addresses' | i18n }}</header>
<tui-elastic-container>
<table [appTable]="['Type', 'Access', 'Gateway', 'URL', null]">
<th [style.width.rem]="2"></th>
@for (address of addresses()?.common; track $index) {
<tr [address]="address" [isRunning]="isRunning()"></tr>
} @empty {
@if (addresses()) {
<tr>
<td colspan="6">
<app-placeholder icon="@tui.list-x">
{{ 'No addresses' | i18n }}
</app-placeholder>
</td>
</tr>
} @else {
@for (_ of [0, 1]; track $index) {
<tr>
<td colspan="6">
<div [tuiSkeleton]="true">{{ 'Loading' | i18n }}</div>
</td>
</tr>
}
}
}
<tbody [class.uncommon-hidden]="!uncommon">
@if (addresses()?.uncommon?.length && uncommon) {
<tr [style.background]="'var(--tui-background-neutral-1)'">
<td colspan="6"></td>
</tr>
}
@for (address of addresses()?.uncommon; track $index) {
<tr [address]="address" [isRunning]="isRunning()"></tr>
}
</tbody>
@if (addresses()?.uncommon?.length) {
<caption [style.caption-side]="'bottom'">
<button
tuiButton
size="m"
appearance="secondary-grayscale"
(click)="uncommon = !uncommon"
>
@if (uncommon) {
Hide uncommon
} @else {
Show uncommon
}
</button>
</caption>
}
</table>
</tui-elastic-container>
<header>
{{ gatewayGroup().gatewayName }}
<button
tuiDropdown
tuiButton
iconStart="@tui.plus"
[style.margin-inline-start]="'auto'"
[(tuiDropdownOpen)]="addOpen"
>
{{ 'Add Domain' | i18n }}
<tui-data-list *tuiTextfieldDropdown (click)="addOpen.set(false)">
<button tuiOption new (click)="addPublicDomain()">
{{ 'Public Domain' | i18n }}
</button>
<button tuiOption new (click)="addPrivateDomain()">
{{ 'Private Domain' | i18n }}
</button>
</tui-data-list>
</button>
</header>
<table [appTable]="['Enabled', 'Type', 'Access', 'URL', null]">
@for (address of gatewayGroup().addresses; track $index) {
<tr
[address]="address"
[packageId]="packageId()"
[value]="value()"
[isRunning]="isRunning()"
></tr>
} @empty {
<tr>
<td colspan="5">
<app-placeholder icon="@tui.list-x">
{{ 'No addresses' | i18n }}
</app-placeholder>
</td>
</tr>
}
</table>
`,
styles: `
:host ::ng-deep {
th:nth-child(2) {
th:first-child {
width: 5rem;
}
th:nth-child(3) {
width: 4rem;
}
}
.g-table:has(caption) {
border-bottom-left-radius: 0;
border-bottom-right-radius: 0;
}
[tuiButton] {
width: 100%;
border-top-left-radius: 0;
border-top-right-radius: 0;
}
:host-context(tui-root._mobile) {
[tuiButton] {
border-radius: var(--tui-radius-xs);
margin-block-end: 0.75rem;
}
}
`,
host: { class: 'g-card' },
imports: [
TuiSkeleton,
TuiButton,
TuiDropdown,
TuiDataList,
TuiTextfield,
TableComponent,
PlaceholderComponent,
i18nPipe,
InterfaceAddressItemComponent,
TuiElasticContainer,
],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class InterfaceAddressesComponent {
readonly addresses = input.required<
MappedServiceInterface['addresses'] | undefined
>()
private readonly patch = inject<PatchDB<DataModel>>(PatchDB)
private readonly formDialog = inject(FormDialogService)
private readonly dialog = inject(DialogService)
private readonly loader = inject(LoadingService)
private readonly errorService = inject(ErrorService)
private readonly api = inject(ApiService)
private readonly i18n = inject(i18nPipe)
readonly gatewayGroup = input.required<GatewayAddressGroup>()
readonly packageId = input('')
readonly value = input<MappedServiceInterface | undefined>()
readonly isRunning = input.required<boolean>()
uncommon = false
readonly addOpen = signal(false)
async addPrivateDomain() {
this.formDialog.open<FormContext<{ fqdn: string }>>(FormComponent, {
label: 'New private domain',
data: {
spec: await configBuilderToSpec(
ISB.InputSpec.of({
fqdn: ISB.Value.text({
name: this.i18n.transform('Domain'),
description: this.i18n.transform(
'Enter a fully qualified domain name. Since the domain is for private use, it can be any domain you want, even one you do not control.',
),
required: true,
default: null,
patterns: [utils.Patterns.domain],
}),
}),
),
buttons: [
{
text: this.i18n.transform('Save')!,
handler: async (value: { fqdn: string }) =>
this.savePrivateDomain(value.fqdn),
},
],
},
})
}
async addPublicDomain() {
const iface = this.value()
if (!iface) return
const network = await firstValueFrom(
this.patch.watch$('serverInfo', 'network'),
)
const authorities = Object.keys(network.acme).reduce<
Record<string, string>
>(
(obj, url) => ({
...obj,
[url]: toAuthorityName(url),
}),
{ local: toAuthorityName(null) },
)
const addSpec = ISB.InputSpec.of({
fqdn: ISB.Value.text({
name: this.i18n.transform('Domain'),
description: this.i18n.transform(
'Enter a fully qualified domain name. For example, if you control domain.com, you could enter domain.com or subdomain.domain.com or another.subdomain.domain.com.',
),
required: true,
default: null,
patterns: [utils.Patterns.domain],
}).map(f => f.toLocaleLowerCase()),
...(iface.addSsl
? {
authority: ISB.Value.select({
name: this.i18n.transform('Certificate Authority'),
description: this.i18n.transform(
'Select a Certificate Authority to issue SSL/TLS certificates for this domain',
),
values: authorities,
default: '',
}),
}
: ({} as { authority: ReturnType<typeof ISB.Value.select> })),
})
this.formDialog.open(FormComponent, {
label: 'Add public domain',
data: {
spec: await configBuilderToSpec(addSpec),
buttons: [
{
text: this.i18n.transform('Save')!,
handler: (input: typeof addSpec._TYPE) =>
this.savePublicDomain(input.fqdn, input.authority),
},
],
},
})
}
private async savePrivateDomain(fqdn: string): Promise<boolean> {
const iface = this.value()
const loader = this.loader.open('Saving').subscribe()
try {
if (this.packageId()) {
await this.api.pkgAddPrivateDomain({
fqdn,
package: this.packageId(),
host: iface?.addressInfo.hostId || '',
})
} else {
await this.api.osUiAddPrivateDomain({ fqdn })
}
return true
} catch (e: any) {
this.errorService.handleError(e)
return false
} finally {
loader.unsubscribe()
}
}
private async savePublicDomain(
fqdn: string,
authority?: 'local' | string,
): Promise<boolean> {
const iface = this.value()
const gatewayId = this.gatewayGroup().gatewayId
const loader = this.loader.open('Saving').subscribe()
const params = {
fqdn,
gateway: gatewayId,
acme: !authority || authority === 'local' ? null : authority,
}
try {
let ip: string | null
if (this.packageId()) {
ip = await this.api.pkgAddPublicDomain({
...params,
package: this.packageId(),
host: iface?.addressInfo.hostId || '',
})
} else {
ip = await this.api.osUiAddPublicDomain(params)
}
const network = await this.patch
.watch$('serverInfo', 'network')
.pipe()
.toPromise()
const gateway = network?.gateways[gatewayId]
if (gateway?.ipInfo) {
const wanIp = gateway.ipInfo.wanIp
const message = this.i18n.transform(
'Create one of the DNS records below.',
) as i18nKey
const gatewayData = {
id: gatewayId,
...gateway,
ipInfo: gateway.ipInfo,
}
if (!ip) {
setTimeout(
() =>
this.showDns(
fqdn,
gatewayData,
`${this.i18n.transform('No DNS record detected for')} ${fqdn}. ${message}` as i18nKey,
),
250,
)
} else if (ip !== wanIp) {
setTimeout(
() =>
this.showDns(
fqdn,
gatewayData,
`${this.i18n.transform('Invalid DNS record')}. ${fqdn} ${this.i18n.transform('resolves to')} ${ip}. ${message}` as i18nKey,
),
250,
)
} else {
setTimeout(
() =>
this.dialog
.openAlert(
`${fqdn} ${this.i18n.transform('resolves to')} ${wanIp}` as i18nKey,
{ label: 'DNS record detected!', appearance: 'positive' },
)
.subscribe(),
250,
)
}
}
return true
} catch (e: any) {
this.errorService.handleError(e)
return false
} finally {
loader.unsubscribe()
}
}
private showDns(fqdn: string, gateway: DnsGateway, message: i18nKey) {
this.dialog
.openComponent(DNS, {
label: 'DNS Records',
size: 'l',
data: { fqdn, gateway, message },
})
.subscribe()
}
}

View File

@@ -6,113 +6,94 @@ import {
input,
signal,
} from '@angular/core'
import { DialogService, i18nKey, i18nPipe } from '@start9labs/shared'
import { ErrorService, i18nPipe, LoadingService } from '@start9labs/shared'
import { TuiObfuscatePipe } from '@taiga-ui/cdk'
import { TuiButton, TuiIcon } from '@taiga-ui/core'
import { TuiButton } from '@taiga-ui/core'
import { TuiBadge } from '@taiga-ui/kit'
import { DisplayAddress } from '../interface.service'
import { FormsModule } from '@angular/forms'
import { TuiSwitch } from '@taiga-ui/kit'
import { ApiService } from 'src/app/services/api/embassy-api.service'
import { GatewayAddress, MappedServiceInterface } from '../interface.service'
import { AddressActionsComponent } from './actions.component'
@Component({
selector: 'tr[address]',
template: `
@if (address(); as address) {
<td [style.padding-inline-end]="0">
<div class="wrapper">
<button
tuiIconButton
appearance="flat-grayscale"
(click)="viewDetails()"
<td>
<input
type="checkbox"
tuiSwitch
size="s"
[showIcons]="false"
[ngModel]="address.enabled"
(ngModelChange)="onToggleEnabled()"
/>
</td>
<td>
{{ address.type }}
</td>
<td>
@if (address.access === 'public') {
<tui-badge size="s" appearance="primary-success">
{{ 'public' | i18n }}
</tui-badge>
} @else {
<tui-badge size="s" appearance="primary-destructive">
{{ 'private' | i18n }}
</tui-badge>
}
</td>
<td [style.grid-area]="'2 / 1 / 2 / 3'">
<div class="url">
<span
[title]="address.masked && currentlyMasked() ? '' : address.url"
>
{{ 'Address details' | i18n }}
<tui-icon
class="info"
icon="@tui.info"
background="@tui.info-filled"
/>
</button>
</div>
</td>
<td>
<div class="wrapper">{{ address.type }}</div>
</td>
<td>
<div class="wrapper">
@if (address.access === 'public') {
<tui-badge size="s" appearance="primary-success">
{{ 'public' | i18n }}
</tui-badge>
} @else if (address.access === 'private') {
<tui-badge size="s" appearance="primary-destructive">
{{ 'private' | i18n }}
</tui-badge>
} @else {
-
{{ address.url | tuiObfuscate: recipe() }}
</span>
@if (address.masked) {
<button
tuiIconButton
appearance="flat-grayscale"
size="xs"
[iconStart]="currentlyMasked() ? '@tui.eye' : '@tui.eye-off'"
(click)="currentlyMasked.set(!currentlyMasked())"
>
{{ (currentlyMasked() ? 'Reveal' : 'Hide') | i18n }}
</button>
}
</div>
</td>
<td [style.grid-area]="'1 / 1 / 1 / 3'">
<div class="wrapper" [title]="address.gatewayName">
{{ address.gatewayName || '-' }}
</div>
</td>
<td [style.grid-area]="'3 / 1 / 3 / 3'">
<div
class="wrapper"
[title]="address.masked && currentlyMasked() ? '' : address.url"
>
{{ address.url | tuiObfuscate: recipe() }}
</div>
</td>
<td
actions
[address]="address"
[packageId]="packageId()"
[value]="value()"
[disabled]="!isRunning()"
[href]="address.url"
[bullets]="address.bullets"
[style.width.rem]="5"
></td>
}
`,
styles: `
:host {
white-space: nowrap;
grid-template-columns: fit-content(10rem) 1fr 2rem 2rem;
td:last-child {
padding-inline-start: 0;
}
}
.info {
background: var(--tui-status-info);
.url {
display: flex;
align-items: center;
gap: 0.25rem;
&::after {
mask-size: 1.5rem;
span {
white-space: normal;
word-break: break-all;
display: -webkit-box;
-webkit-box-orient: vertical;
-webkit-line-clamp: 1;
overflow: hidden;
}
}
:host-context(.uncommon-hidden) {
.wrapper {
height: 0;
visibility: hidden;
}
td,
& {
padding-block: 0 !important;
border: hidden;
}
}
div {
white-space: normal;
word-break: break-all;
display: -webkit-box;
-webkit-box-orient: vertical;
-webkit-line-clamp: 1;
overflow: hidden;
}
:host-context(tui-root._mobile) {
td {
width: auto !important;
@@ -120,7 +101,7 @@ import { AddressActionsComponent } from './actions.component'
}
td:first-child {
grid-area: 1 / 3 / 4 / 3;
display: none;
}
td:nth-child(2) {
@@ -135,16 +116,21 @@ import { AddressActionsComponent } from './actions.component'
i18nPipe,
AddressActionsComponent,
TuiBadge,
TuiObfuscatePipe,
TuiButton,
TuiIcon,
TuiObfuscatePipe,
TuiSwitch,
FormsModule,
],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class InterfaceAddressItemComponent {
private readonly dialogs = inject(DialogService)
private readonly api = inject(ApiService)
private readonly loader = inject(LoadingService)
private readonly errorService = inject(ErrorService)
readonly address = input.required<DisplayAddress>()
readonly address = input.required<GatewayAddress>()
readonly packageId = input('')
readonly value = input<MappedServiceInterface | undefined>()
readonly isRunning = input.required<boolean>()
readonly currentlyMasked = signal(true)
@@ -152,14 +138,35 @@ export class InterfaceAddressItemComponent {
this.address()?.masked && this.currentlyMasked() ? 'mask' : 'none',
)
viewDetails() {
this.dialogs
.openAlert(
`<ul>${this.address()
.bullets.map(b => `<li>${b}</li>`)
.join('')}</ul>` as i18nKey,
{ label: 'About this address' as i18nKey },
)
.subscribe()
async onToggleEnabled() {
const addr = this.address()
const iface = this.value()
if (!iface) return
const enabled = !addr.enabled
const addressJson = JSON.stringify(addr.hostnameInfo)
const loader = this.loader.open('Saving').subscribe()
try {
if (this.packageId()) {
await this.api.pkgBindingSetAddressEnabled({
internalPort: iface.addressInfo.internalPort,
address: addressJson,
enabled,
package: this.packageId(),
host: iface.addressInfo.hostId,
})
} else {
await this.api.serverBindingSetAddressEnabled({
internalPort: 80,
address: addressJson,
enabled,
})
}
} catch (e: any) {
this.errorService.handleError(e)
} finally {
loader.unsubscribe()
}
}
}

View File

@@ -0,0 +1,176 @@
import {
ChangeDetectionStrategy,
Component,
inject,
input,
signal,
} from '@angular/core'
import {
CopyService,
DialogService,
i18nPipe,
} from '@start9labs/shared'
import { TUI_IS_MOBILE } from '@taiga-ui/cdk'
import {
TuiButton,
tuiButtonOptionsProvider,
TuiDataList,
TuiDropdown,
TuiTextfield,
} from '@taiga-ui/core'
import { PolymorpheusComponent } from '@taiga-ui/polymorpheus'
import { PlaceholderComponent } from 'src/app/routes/portal/components/placeholder.component'
import { TableComponent } from 'src/app/routes/portal/components/table.component'
import { QRModal } from 'src/app/routes/portal/modals/qr.component'
import { PluginAddressGroup } from '../interface.service'
@Component({
selector: 'section[pluginGroup]',
template: `
<header>
{{ pluginGroup().pluginName }}
</header>
<table [appTable]="['Protocol', 'URL', null]">
@for (address of pluginGroup().addresses; track $index) {
<tr>
<td>{{ address.hostnameInfo.ssl ? 'HTTPS' : 'HTTP' }}</td>
<td [style.grid-area]="'2 / 1 / 2 / 2'">
<span class="url">{{ address.url }}</span>
</td>
<td [style.width.rem]="5">
<div class="desktop">
<button
tuiIconButton
appearance="flat-grayscale"
iconStart="@tui.qr-code"
(click)="showQR(address.url)"
>
{{ 'Show QR' | i18n }}
</button>
<button
tuiIconButton
appearance="flat-grayscale"
iconStart="@tui.copy"
(click)="copyService.copy(address.url)"
>
{{ 'Copy URL' | i18n }}
</button>
</div>
<div class="mobile">
<button
tuiDropdown
tuiIconButton
appearance="flat-grayscale"
iconStart="@tui.ellipsis-vertical"
[tuiAppearanceState]="open() ? 'hover' : null"
[(tuiDropdownOpen)]="open"
>
{{ 'Actions' | i18n }}
<tui-data-list *tuiTextfieldDropdown (click)="open.set(false)">
<button
tuiOption
new
iconStart="@tui.qr-code"
(click)="showQR(address.url)"
>
{{ 'Show QR' | i18n }}
</button>
<button
tuiOption
new
iconStart="@tui.copy"
(click)="copyService.copy(address.url)"
>
{{ 'Copy URL' | i18n }}
</button>
</tui-data-list>
</button>
</div>
</td>
</tr>
} @empty {
<tr>
<td colspan="3">
<app-placeholder icon="@tui.list-x">
{{ 'No addresses' | i18n }}
</app-placeholder>
</td>
</tr>
}
</table>
`,
styles: `
:host ::ng-deep {
th:first-child {
width: 5rem;
}
}
.desktop {
display: flex;
white-space: nowrap;
}
.url {
white-space: normal;
word-break: break-all;
display: -webkit-box;
-webkit-box-orient: vertical;
-webkit-line-clamp: 1;
overflow: hidden;
}
.mobile {
display: none;
}
:host-context(tui-root._mobile) {
.desktop {
display: none;
}
.mobile {
display: block;
}
tr {
grid-template-columns: 1fr auto;
}
td {
width: auto !important;
align-content: center;
}
}
`,
host: { class: 'g-card' },
imports: [
TuiButton,
TuiDropdown,
TuiDataList,
TuiTextfield,
TableComponent,
PlaceholderComponent,
i18nPipe,
],
providers: [tuiButtonOptionsProvider({ appearance: 'icon' })],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class PluginAddressesComponent {
private readonly isMobile = inject(TUI_IS_MOBILE)
private readonly dialog = inject(DialogService)
readonly copyService = inject(CopyService)
readonly open = signal(false)
readonly pluginGroup = input.required<PluginAddressGroup>()
showQR(url: string) {
this.dialog
.openComponent(new PolymorpheusComponent(QRModal), {
size: 'auto',
closeable: this.isMobile,
data: url,
})
.subscribe()
}
}

View File

@@ -1,90 +0,0 @@
import { CommonModule } from '@angular/common'
import {
ChangeDetectionStrategy,
Component,
input,
inject,
} from '@angular/core'
import { TuiIcon, TuiTitle } from '@taiga-ui/core'
import { TuiSkeleton, TuiSwitch, TuiTooltip } from '@taiga-ui/kit'
import { FormsModule } from '@angular/forms'
import { i18nPipe, LoadingService, ErrorService } from '@start9labs/shared'
import { TuiCell } from '@taiga-ui/layout'
import { InterfaceGateway } from './interface.service'
import { ApiService } from 'src/app/services/api/embassy-api.service'
import { InterfaceComponent } from './interface.component'
@Component({
selector: 'section[gateways]',
template: `
<header>{{ 'Gateways' | i18n }}</header>
@for (gateway of gateways(); track $index) {
<label tuiCell="s" [style.background]="">
<span tuiTitle [style.opacity]="1">{{ gateway.name }}</span>
@if (!interface.packageId() && !gateway.public) {
<tui-icon
[tuiTooltip]="
'Cannot disable private gateways for StartOS UI' | i18n
"
/>
}
<input
type="checkbox"
tuiSwitch
size="s"
[showIcons]="false"
[ngModel]="gateway.enabled"
(ngModelChange)="onToggle(gateway)"
[disabled]="!interface.packageId() && !gateway.public"
/>
</label>
} @empty {
@for (_ of [0, 1]; track $index) {
<label tuiCell="s">
<span tuiTitle [tuiSkeleton]="true">{{ 'Loading' | i18n }}</span>
</label>
}
}
`,
styles: `
:host {
grid-column: span 3;
}
[tuiCell]:has([tuiTooltip]) {
background: none !important;
}
:host-context(tui-root:not(._mobile)) {
&:has(+ section table) header {
background: transparent;
}
}
`,
host: { class: 'g-card' },
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [
CommonModule,
FormsModule,
TuiSwitch,
i18nPipe,
TuiCell,
TuiTitle,
TuiSkeleton,
TuiIcon,
TuiTooltip,
],
})
export class InterfaceGatewaysComponent {
private readonly loader = inject(LoadingService)
private readonly errorService = inject(ErrorService)
private readonly api = inject(ApiService)
readonly interface = inject(InterfaceComponent)
readonly gateways = input.required<InterfaceGateway[] | undefined>()
async onToggle(_gateway: InterfaceGateway) {
// TODO: Replace with per-address toggle UI (Section 6 frontend overhaul).
// Gateway-level toggle replaced by set-address-enabled RPC.
}
}

View File

@@ -1,24 +1,23 @@
import { ChangeDetectionStrategy, Component, input } from '@angular/core'
import { tuiButtonOptionsProvider } from '@taiga-ui/core'
import { MappedServiceInterface } from './interface.service'
import { InterfaceGatewaysComponent } from './gateways.component'
import { PublicDomainsComponent } from './public-domains/pd.component'
import { InterfacePrivateDomainsComponent } from './private-domains.component'
import { InterfaceAddressesComponent } from './addresses/addresses.component'
import { PluginAddressesComponent } from './addresses/plugin.component'
@Component({
selector: 'service-interface',
template: `
<div>
<section [gateways]="value()?.gateways"></section>
@for (group of value()?.gatewayGroups; track group.gatewayId) {
<section
[publicDomains]="value()?.publicDomains"
[addSsl]="value()?.addSsl || false"
[gatewayGroup]="group"
[packageId]="packageId()"
[value]="value()"
[isRunning]="isRunning()"
></section>
<section [privateDomains]="value()?.privateDomains"></section>
</div>
<hr [style.width.rem]="10" />
<section [addresses]="value()?.addresses" [isRunning]="true"></section>
}
@for (group of value()?.pluginGroups; track group.pluginId) {
<section [pluginGroup]="group"></section>
}
`,
styles: `
:host {
@@ -28,32 +27,16 @@ import { InterfaceAddressesComponent } from './addresses/addresses.component'
color: var(--tui-text-secondary);
font: var(--tui-font-text-l);
div {
display: grid;
grid-template-columns: repeat(10, 1fr);
gap: inherit;
flex-direction: column;
}
::ng-deep [tuiSkeleton] {
width: 100%;
height: 1rem;
border-radius: var(--tui-radius-s);
}
}
:host-context(tui-root._mobile) div {
display: flex;
}
`,
changeDetection: ChangeDetectionStrategy.OnPush,
providers: [tuiButtonOptionsProvider({ size: 'xs' })],
imports: [
InterfaceGatewaysComponent,
PublicDomainsComponent,
InterfacePrivateDomainsComponent,
InterfaceAddressesComponent,
],
imports: [InterfaceAddressesComponent, PluginAddressesComponent],
})
export class InterfaceComponent {
readonly packageId = input('')

View File

@@ -2,101 +2,54 @@ import { inject, Injectable } from '@angular/core'
import { T, utils } from '@start9labs/start-sdk'
import { ConfigService } from 'src/app/services/config.service'
import { GatewayPlus } from 'src/app/services/gateway.service'
import { PublicDomain } from './public-domains/pd.service'
import { i18nKey, i18nPipe } from '@start9labs/shared'
type AddressWithInfo = {
url: string
info: T.HostnameInfo
gateway?: GatewayPlus
showSsl: boolean
masked: boolean
ui: boolean
function isPublicIp(h: T.HostnameInfo): boolean {
return (
h.public && (h.metadata.kind === 'ipv4' || h.metadata.kind === 'ipv6')
)
}
function cmpWithRankedPredicates<T extends AddressWithInfo>(
a: T,
b: T,
preds: ((x: T) => boolean)[],
): -1 | 0 | 1 {
for (const pred of preds) {
for (let [x, y, sign] of [[a, b, 1] as const, [b, a, -1] as const]) {
if (pred(y) && !pred(x)) return sign
}
}
return 0
}
type LanAddress = AddressWithInfo & { info: { public: false } }
function filterLan(a: AddressWithInfo): a is LanAddress {
return !a.info.public
}
function cmpLan(host: T.Host, a: LanAddress, b: LanAddress): -1 | 0 | 1 {
return cmpWithRankedPredicates(a, b, [
x =>
x.info.hostname.kind === 'domain' &&
!!host.privateDomains.find(d => d === x.info.hostname.value), // private domain
x => x.info.hostname.kind === 'local', // .local
x => x.info.hostname.kind === 'ipv4', // ipv4
x => x.info.hostname.kind === 'ipv6', // ipv6
// remainder: public domains accessible privately
])
}
type VpnAddress = AddressWithInfo & {
info: {
public: false
hostname: { kind: 'ipv4' | 'ipv6' | 'domain' }
function isEnabled(addr: T.DerivedAddressInfo, h: T.HostnameInfo): boolean {
if (isPublicIp(h)) {
if (h.port === null) return true
const sa =
h.metadata.kind === 'ipv6'
? `[${h.host}]:${h.port}`
: `${h.host}:${h.port}`
return addr.enabled.includes(sa)
} else {
return !addr.disabled.some(
([host, port]) => host === h.host && port === (h.port ?? 0),
)
}
}
function filterVpn(a: AddressWithInfo): a is VpnAddress {
return !a.info.public && a.info.hostname.kind !== 'local'
}
function cmpVpn(host: T.Host, a: VpnAddress, b: VpnAddress): -1 | 0 | 1 {
return cmpWithRankedPredicates(a, b, [
x =>
x.info.hostname.kind === 'domain' &&
!!host.privateDomains.find(d => d === x.info.hostname.value), // private domain
x => x.info.hostname.kind === 'ipv4', // ipv4
x => x.info.hostname.kind === 'ipv6', // ipv6
// remainder: public domains accessible privately
])
}
type ClearnetAddress = AddressWithInfo & {
info: {
public: true
hostname: { kind: 'ipv4' | 'ipv6' | 'domain' }
function getGatewayIds(h: T.HostnameInfo): string[] {
switch (h.metadata.kind) {
case 'ipv4':
case 'ipv6':
case 'public-domain':
return [h.metadata.gateway]
case 'private-domain':
return h.metadata.gateways
case 'plugin':
return []
}
}
function filterClearnet(a: AddressWithInfo): a is ClearnetAddress {
return a.info.public
}
function cmpClearnet(
host: T.Host,
a: ClearnetAddress,
b: ClearnetAddress,
): -1 | 0 | 1 {
return cmpWithRankedPredicates(a, b, [
x =>
x.info.hostname.kind === 'domain' &&
x.info.gateway.id === host.publicDomains[x.info.hostname.value]?.gateway, // public domain for this gateway
x => x.gateway?.public ?? false, // public gateway
x => x.info.hostname.kind === 'ipv4', // ipv4
x => x.info.hostname.kind === 'ipv6', // ipv6
// remainder: private domains / domains public on other gateways
])
}
export function getPublicDomains(
publicDomains: Record<string, T.PublicDomainConfig>,
gateways: GatewayPlus[],
): PublicDomain[] {
return Object.entries(publicDomains).map(([fqdn, info]) => ({
fqdn,
acme: info.acme,
gateway: gateways.find(g => g.id === info.gateway) || null,
}))
function getAddressType(h: T.HostnameInfo): string {
switch (h.metadata.kind) {
case 'ipv4':
return 'IPv4'
case 'ipv6':
return 'IPv6'
case 'public-domain':
return 'Public Domain'
case 'private-domain':
return h.host.endsWith('.local') ? 'mDNS' : 'Private Domain'
case 'plugin':
return 'Plugin'
}
}
@Injectable({
@@ -104,90 +57,101 @@ export function getPublicDomains(
})
export class InterfaceService {
private readonly config = inject(ConfigService)
private readonly i18n = inject(i18nPipe)
getAddresses(
getGatewayGroups(
serviceInterface: T.ServiceInterface,
host: T.Host,
gateways: GatewayPlus[],
): MappedServiceInterface['addresses'] {
const hostnamesInfos = this.hostnameInfo(serviceInterface, host)
const addresses = {
common: [],
uncommon: [],
}
if (!hostnamesInfos.length) return addresses
): GatewayAddressGroup[] {
const binding =
host.bindings[serviceInterface.addressInfo.internalPort]
if (!binding) return []
const addr = binding.addresses
const masked = serviceInterface.masked
const ui = serviceInterface.type === 'ui'
const allAddressesWithInfo: AddressWithInfo[] = hostnamesInfos.flatMap(
h => {
const { url, sslUrl } = utils.addressHostToUrl(
serviceInterface.addressInfo,
h,
)
const info = h
const gateway = gateways.find(g => h.gateway.id === g.id)
const res = []
if (url) {
res.push({
url,
info,
gateway,
showSsl: false,
masked,
ui,
})
}
if (sslUrl) {
res.push({
url: sslUrl,
info,
gateway,
showSsl: !!url,
masked,
ui,
})
}
return res
},
)
const groupMap = new Map<string, GatewayAddress[]>()
const lanAddrs = allAddressesWithInfo
.filter(filterLan)
.sort((a, b) => cmpLan(host, a, b))
const vpnAddrs = allAddressesWithInfo
.filter(filterVpn)
.sort((a, b) => cmpVpn(host, a, b))
const clearnetAddrs = allAddressesWithInfo
.filter(filterClearnet)
.sort((a, b) => cmpClearnet(host, a, b))
let bestAddrs = [
(clearnetAddrs[0]?.gateway?.public ||
clearnetAddrs[0]?.info.hostname.kind === 'domain') &&
clearnetAddrs[0],
lanAddrs[0],
vpnAddrs[0],
]
.filter(a => !!a)
.reduce((acc, x) => {
if (!acc.includes(x)) acc.push(x)
return acc
}, [] as AddressWithInfo[])
return {
common: bestAddrs.map(a => this.toDisplayAddress(a, host.publicDomains)),
uncommon: allAddressesWithInfo
.filter(a => !bestAddrs.includes(a))
.map(a => this.toDisplayAddress(a, host.publicDomains)),
for (const gateway of gateways) {
groupMap.set(gateway.id, [])
}
for (const h of addr.available) {
const enabled = isEnabled(addr, h)
const url = utils.addressHostToUrl(serviceInterface.addressInfo, h)
const type = getAddressType(h)
const isDomain =
h.metadata.kind === 'private-domain' ||
h.metadata.kind === 'public-domain'
const isMdns =
h.metadata.kind === 'private-domain' && h.host.endsWith('.local')
const address: GatewayAddress = {
enabled,
type,
access: h.public ? 'public' : 'private',
url,
hostnameInfo: h,
masked,
ui,
deletable: isDomain && !isMdns,
}
const gatewayIds = getGatewayIds(h)
for (const gid of gatewayIds) {
const list = groupMap.get(gid)
if (list) {
list.push(address)
}
}
}
return gateways
.filter(g => (groupMap.get(g.id)?.length ?? 0) > 0)
.map(g => ({
gatewayId: g.id,
gatewayName: g.name,
addresses: groupMap.get(g.id)!,
}))
}
getPluginGroups(
serviceInterface: T.ServiceInterface,
host: T.Host,
): PluginAddressGroup[] {
const binding =
host.bindings[serviceInterface.addressInfo.internalPort]
if (!binding) return []
const addr = binding.addresses
const masked = serviceInterface.masked
const groupMap = new Map<string, PluginAddress[]>()
for (const h of addr.available) {
if (h.metadata.kind !== 'plugin') continue
const url = utils.addressHostToUrl(serviceInterface.addressInfo, h)
const pluginId = h.metadata.package
if (!groupMap.has(pluginId)) {
groupMap.set(pluginId, [])
}
groupMap.get(pluginId)!.push({
url,
hostnameInfo: h,
masked,
})
}
return Array.from(groupMap.entries()).map(([pluginId, addresses]) => ({
pluginId,
pluginName: pluginId.charAt(0).toUpperCase() + pluginId.slice(1),
addresses,
}))
}
/** ${scheme}://${username}@${host}:${externalPort}${suffix} */
launchableAddress(ui: T.ServiceInterface, host: T.Host): string {
const addresses = utils.filledAddress(host, ui.addressInfo)
@@ -217,7 +181,7 @@ export class InterfaceService {
matching = addresses.nonLocal
.filter({
kind: 'ipv4',
predicate: h => h.hostname.value === this.config.hostname,
predicate: h => h.host === this.config.hostname,
})
.format('urlstring')[0]
onLan = true
@@ -226,7 +190,7 @@ export class InterfaceService {
matching = addresses.nonLocal
.filter({
kind: 'ipv6',
predicate: h => h.hostname.value === this.config.hostname,
predicate: h => h.host === this.config.hostname,
})
.format('urlstring')[0]
break
@@ -247,216 +211,39 @@ export class InterfaceService {
if (bestPublic) return bestPublic
return ''
}
}
private hostnameInfo(
serviceInterface: T.ServiceInterface,
host: T.Host,
): T.HostnameInfo[] {
const binding =
host.bindings[serviceInterface.addressInfo.internalPort]
if (!binding) return []
const addr = binding.addresses
const enabled = addr.possible.filter(h =>
addr.enabled.some(e => utils.deepEqual(e, h)) ||
(!addr.disabled.some(d => utils.deepEqual(d, h)) &&
!(h.public && (h.hostname.kind === 'ipv4' || h.hostname.kind === 'ipv6'))),
)
return enabled.filter(
h =>
this.config.accessType === 'localhost' ||
!(
(h.hostname.kind === 'ipv6' &&
utils.IPV6_LINK_LOCAL.contains(h.hostname.value)) ||
h.gateway.id === 'lo'
),
)
}
export type GatewayAddress = {
enabled: boolean
type: string
access: 'public' | 'private'
url: string
hostnameInfo: T.HostnameInfo
masked: boolean
ui: boolean
deletable: boolean
}
private toDisplayAddress(
{ info, url, gateway, showSsl, masked, ui }: AddressWithInfo,
publicDomains: Record<string, T.PublicDomainConfig>,
): DisplayAddress {
let access: DisplayAddress['access']
let gatewayName: DisplayAddress['gatewayName']
let type: DisplayAddress['type']
let bullets: any[]
export type GatewayAddressGroup = {
gatewayId: string
gatewayName: string
addresses: GatewayAddress[]
}
const rootCaRequired = this.i18n.transform(
"Requires trusting your server's Root CA",
)
export type PluginAddress = {
url: string
hostnameInfo: T.HostnameInfo
masked: boolean
}
{
const port = info.hostname.sslPort || info.hostname.port
gatewayName = info.gateway.name
const gatewayLanIpv4 = gateway?.lanIpv4[0]
const isWireguard = gateway?.ipInfo.deviceType === 'wireguard'
const localIdeal = this.i18n.transform('Ideal for local access')
const lanRequired = this.i18n.transform(
'Requires being connected to the same Local Area Network (LAN) as your server, either physically or via VPN',
)
const staticRequired = `${this.i18n.transform('Requires setting a static IP address for')} ${gatewayLanIpv4} ${this.i18n.transform('in your gateway')}`
const vpnAccess = this.i18n.transform('Ideal for VPN access via')
const routerWireguard = this.i18n.transform(
"your router's Wireguard server",
)
const portForwarding = this.i18n.transform(
'Requires port forwarding in gateway',
)
const dnsFor = this.i18n.transform('Requires a DNS record for')
const resolvesTo = this.i18n.transform('that resolves to')
// * Local *
if (info.hostname.kind === 'local') {
type = this.i18n.transform('Local')
access = 'private'
bullets = [
localIdeal,
this.i18n.transform(
'Not recommended for VPN access. VPNs do not support ".local" domains without advanced configuration',
),
lanRequired,
rootCaRequired,
]
// * IPv4 *
} else if (info.hostname.kind === 'ipv4') {
type = 'IPv4'
if (info.public) {
access = 'public'
bullets = [
this.i18n.transform('Can be used for clearnet access'),
this.i18n.transform(
'Not recommended in most cases. Using a public domain is more common and preferred',
),
rootCaRequired,
]
if (!info.gateway.public) {
bullets.push(
`${portForwarding} "${gatewayName}": ${port} -> ${port}`,
)
}
} else {
access = 'private'
if (isWireguard) {
bullets = [`${vpnAccess} StartTunnel`, rootCaRequired]
} else {
bullets = [
localIdeal,
`${vpnAccess} ${routerWireguard}`,
lanRequired,
rootCaRequired,
staticRequired,
]
}
}
// * IPv6 *
} else if (info.hostname.kind === 'ipv6') {
type = 'IPv6'
access = 'private'
bullets = [
this.i18n.transform('Can be used for local access'),
lanRequired,
rootCaRequired,
]
// * Domain *
} else {
type = this.i18n.transform('Domain')
if (info.public) {
access = 'public'
bullets = [
`${dnsFor} ${info.hostname.value} ${resolvesTo} ${gateway?.ipInfo.wanIp}`,
]
if (!info.gateway.public) {
bullets.push(
`${portForwarding} "${gatewayName}": ${port} -> ${port === 443 ? 5443 : port}`,
)
}
if (publicDomains[info.hostname.value]?.acme) {
bullets.unshift(
this.i18n.transform('Ideal for public access via the Internet'),
)
} else {
bullets = [
this.i18n.transform(
'Can be used for personal access via the public Internet, but a VPN is more private and secure',
),
this.i18n.transform(
`Not good for public access, since the certificate is signed by your Server's Root CA`,
),
rootCaRequired,
...bullets,
]
}
} else {
access = 'private'
const ipPortBad = this.i18n.transform(
'when using IP addresses and ports is undesirable',
)
const customDnsRequired = `${dnsFor} ${info.hostname.value} ${resolvesTo} ${gatewayLanIpv4}`
if (isWireguard) {
bullets = [
`${vpnAccess} StartTunnel ${ipPortBad}`,
customDnsRequired,
rootCaRequired,
]
} else {
bullets = [
`${localIdeal} ${ipPortBad}`,
`${vpnAccess} ${routerWireguard} ${ipPortBad}`,
customDnsRequired,
rootCaRequired,
lanRequired,
staticRequired,
]
}
}
}
}
if (showSsl) {
type = `${type} (SSL)`
bullets.unshift(
this.i18n.transform('Should only needed for apps that enforce SSL'),
)
}
return {
url,
access,
gatewayName,
type,
bullets,
masked,
ui,
}
}
export type PluginAddressGroup = {
pluginId: string
pluginName: string
addresses: PluginAddress[]
}
export type MappedServiceInterface = T.ServiceInterface & {
gateways: InterfaceGateway[]
publicDomains: PublicDomain[]
privateDomains: string[]
addresses: {
common: DisplayAddress[]
uncommon: DisplayAddress[]
}
gatewayGroups: GatewayAddressGroup[]
pluginGroups: PluginAddressGroup[]
addSsl: boolean
}
export type InterfaceGateway = GatewayPlus & {
enabled: boolean
}
export type DisplayAddress = {
type: string
access: 'public' | 'private' | null
gatewayName: string | null
url: string
bullets: i18nKey[]
masked: boolean
ui: boolean
}

View File

@@ -1,184 +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 { TuiButton, TuiTitle } from '@taiga-ui/core'
import { TuiSkeleton } from '@taiga-ui/kit'
import { TuiCell } from '@taiga-ui/layout'
import { filter } from 'rxjs'
import {
FormComponent,
FormContext,
} 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 { configBuilderToSpec } from 'src/app/utils/configBuilderToSpec'
import { InterfaceComponent } from './interface.component'
@Component({
selector: 'section[privateDomains]',
template: `
<header>
{{ 'Private Domains' | i18n }}
<a
tuiIconButton
docsLink
path="/user-manual/connecting-locally.html"
fragment="private-domains"
appearance="icon"
iconStart="@tui.external-link"
>
{{ 'Documentation' | i18n }}
</a>
<button
tuiButton
iconStart="@tui.plus"
[style.margin-inline-start]="'auto'"
(click)="add()"
>
{{ 'Add' | i18n }}
</button>
</header>
@for (domain of privateDomains(); track domain) {
<div tuiCell="s">
<span tuiTitle>{{ domain }}</span>
<button
tuiIconButton
iconStart="@tui.trash"
appearance="action-destructive"
(click)="remove(domain)"
[disabled]="!privateDomains()"
>
{{ 'Delete' | i18n }}
</button>
</div>
} @empty {
@if (privateDomains()) {
<app-placeholder icon="@tui.globe-lock">
{{ 'No private domains' | i18n }}
</app-placeholder>
} @else {
@for (_ of [0, 1]; track $index) {
<label tuiCell="s">
<span tuiTitle [tuiSkeleton]="true">{{ 'Loading' | i18n }}</span>
</label>
}
}
}
`,
styles: `
:host {
grid-column: span 4;
overflow-wrap: break-word;
}
`,
host: { class: 'g-card' },
imports: [
TuiCell,
TuiTitle,
TuiButton,
PlaceholderComponent,
i18nPipe,
DocsLinkDirective,
TuiSkeleton,
],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class InterfacePrivateDomainsComponent {
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 privateDomains = input.required<readonly string[] | undefined>()
async add() {
this.formDialog.open<FormContext<{ fqdn: string }>>(FormComponent, {
label: 'New private domain',
data: {
spec: await configBuilderToSpec(
ISB.InputSpec.of({
fqdn: ISB.Value.text({
name: this.i18n.transform('Domain'),
description: this.i18n.transform(
'Enter a fully qualified domain name. Since the domain is for private use, it can be any domain you want, even one you do not control.',
),
required: true,
default: null,
patterns: [utils.Patterns.domain],
}),
}),
),
buttons: [
{
text: this.i18n.transform('Save')!,
handler: async value => this.save(value.fqdn),
},
],
},
})
}
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()
try {
if (this.interface.packageId()) {
await this.api.pkgRemovePrivateDomain({
fqdn,
package: this.interface.packageId(),
host: this.interface.value()?.addressInfo.hostId || '',
})
} else {
await this.api.osUiRemovePrivateDomain({ fqdn })
}
return true
} catch (e: any) {
this.errorService.handleError(e)
return false
} finally {
loader.unsubscribe()
}
})
}
private async save(fqdn: string): Promise<boolean> {
const loader = this.loader.open('Saving').subscribe()
try {
if (this.interface.packageId) {
await this.api.pkgAddPrivateDomain({
fqdn,
package: this.interface.packageId(),
host: this.interface.value()?.addressInfo.hostId || '',
})
} else {
await this.api.osUiAddPrivateDomain({ fqdn })
}
return true
} catch (e: any) {
this.errorService.handleError(e)
return false
} finally {
loader.unsubscribe()
}
}
}

View File

@@ -16,8 +16,13 @@ import {
import { injectContext, PolymorpheusComponent } from '@taiga-ui/polymorpheus'
import { TableComponent } from 'src/app/routes/portal/components/table.component'
import { ApiService } from 'src/app/services/api/embassy-api.service'
import { T } from '@start9labs/start-sdk'
import { parse } from 'tldts'
import { GatewayWithId } from './pd.service'
export type DnsGateway = T.NetworkInterfaceInfo & {
id: string
ipInfo: T.IpInfo
}
@Component({
selector: 'dns',
@@ -104,7 +109,7 @@ export class DnsComponent {
injectContext<
TuiDialogContext<
void,
{ fqdn: string; gateway: GatewayWithId; message: string }
{ fqdn: string; gateway: DnsGateway; message: string }
>
>()

View File

@@ -1,85 +0,0 @@
import {
ChangeDetectionStrategy,
Component,
inject,
input,
} from '@angular/core'
import { DocsLinkDirective, i18nPipe } from '@start9labs/shared'
import { TuiButton } from '@taiga-ui/core'
import { TuiSkeleton } from '@taiga-ui/kit'
import { PlaceholderComponent } from 'src/app/routes/portal/components/placeholder.component'
import { TableComponent } from 'src/app/routes/portal/components/table.component'
import { PublicDomainsItemComponent } from './pd.item.component'
import { PublicDomain, PublicDomainService } from './pd.service'
@Component({
selector: 'section[publicDomains]',
template: `
<header>
{{ 'Public Domains' | i18n }}
<a
tuiIconButton
docsLink
path="/user-manual/connecting-remotely/clearnet.html"
appearance="icon"
iconStart="@tui.external-link"
>
{{ 'Documentation' | i18n }}
</a>
@if (service.data()) {
<button
tuiButton
iconStart="@tui.plus"
[style.margin-inline-start]="'auto'"
(click)="service.add(addSsl())"
[disabled]="!publicDomains()"
>
{{ 'Add' | i18n }}
</button>
}
</header>
@if (publicDomains()?.length === 0) {
<app-placeholder icon="@tui.globe">
{{ 'No public domains' | i18n }}
</app-placeholder>
} @else {
<table [appTable]="['Domain', 'Gateway', 'Certificate Authority', null]">
@for (domain of publicDomains(); track $index) {
<tr [publicDomain]="domain" [addSsl]="addSsl()"></tr>
} @empty {
@for (_ of [0]; track $index) {
<tr>
<td colspan="4">
<div [tuiSkeleton]="true">{{ 'Loading' | i18n }}</div>
</td>
</tr>
}
}
</table>
}
`,
styles: `
:host {
grid-column: span 7;
}
`,
host: { class: 'g-card' },
providers: [PublicDomainService],
imports: [
TuiButton,
TableComponent,
PlaceholderComponent,
i18nPipe,
DocsLinkDirective,
PublicDomainsItemComponent,
TuiSkeleton,
],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class PublicDomainsComponent {
readonly service = inject(PublicDomainService)
readonly publicDomains = input.required<readonly PublicDomain[] | undefined>()
readonly addSsl = input.required<boolean>()
}

View File

@@ -1,119 +0,0 @@
import {
ChangeDetectionStrategy,
Component,
computed,
inject,
input,
} from '@angular/core'
import { i18nPipe, i18nKey } from '@start9labs/shared'
import {
TuiButton,
TuiDataList,
TuiDropdown,
TuiTextfield,
} from '@taiga-ui/core'
import { PublicDomain, PublicDomainService } from './pd.service'
import { toAuthorityName } from 'src/app/utils/acme'
@Component({
selector: 'tr[publicDomain]',
template: `
<td>{{ publicDomain().fqdn }}</td>
<td>{{ publicDomain().gateway?.name }}</td>
<td class="authority">{{ authority() }}</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 *tuiTextfieldDropdown>
<tui-opt-group>
<button
tuiOption
new
iconStart="@tui.eye"
(click)="
service.showDns(
publicDomain().fqdn,
publicDomain().gateway!,
dnsMessage()
)
"
>
{{ 'View DNS' | i18n }}
</button>
<button
tuiOption
new
iconStart="@tui.pencil"
(click)="service.edit(publicDomain(), addSsl())"
>
{{ 'Edit' | i18n }}
</button>
</tui-opt-group>
<tui-opt-group>
<button
tuiOption
new
iconStart="@tui.trash"
class="g-negative"
(click)="service.remove(publicDomain().fqdn)"
>
{{ 'Delete' | i18n }}
</button>
</tui-opt-group>
</tui-data-list>
</button>
</td>
`,
styles: `
:host {
grid-template-columns: min-content 1fr min-content;
}
td:nth-child(2) {
order: -1;
grid-column: span 2;
}
td:last-child {
grid-area: 1 / 3 / 3;
align-self: center;
text-align: right;
}
:host-context(tui-root._mobile) {
.authority {
grid-column: span 2;
}
tui-badge {
vertical-align: bottom;
margin-inline-start: 0.25rem;
}
}
`,
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [TuiButton, TuiDataList, TuiDropdown, i18nPipe, TuiTextfield],
})
export class PublicDomainsItemComponent {
protected readonly service = inject(PublicDomainService)
open = false
readonly publicDomain = input.required<PublicDomain>()
readonly addSsl = input.required<boolean>()
readonly authority = computed(() =>
toAuthorityName(this.publicDomain().acme, this.addSsl()),
)
readonly dnsMessage = computed<i18nKey>(
() =>
`Create one of the DNS records below to cause ${this.publicDomain().fqdn} to resolve to ${this.publicDomain().gateway?.ipInfo.wanIp}` as i18nKey,
)
}

View File

@@ -1,277 +0,0 @@
import { inject, Injectable } from '@angular/core'
import {
DialogService,
ErrorService,
i18nKey,
LoadingService,
i18nPipe,
} from '@start9labs/shared'
import { toSignal } from '@angular/core/rxjs-interop'
import { ISB, T, utils } from '@start9labs/start-sdk'
import { filter, map } 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 { PatchDB } from 'patch-db-client'
import { DataModel } from 'src/app/services/patch-db/data-model'
import { toAuthorityName } from 'src/app/utils/acme'
import { InterfaceComponent } from '../interface.component'
import { DNS } from './dns.component'
export type PublicDomain = {
fqdn: string
gateway: GatewayWithId | null
acme: string | null
}
export type GatewayWithId = T.NetworkInterfaceInfo & {
id: string
ipInfo: T.IpInfo
}
@Injectable()
export class PublicDomainService {
private readonly patch = inject<PatchDB<DataModel>>(PatchDB)
private readonly loader = inject(LoadingService)
private readonly errorService = inject(ErrorService)
private readonly api = inject(ApiService)
private readonly formDialog = inject(FormDialogService)
private readonly dialog = inject(DialogService)
private readonly interface = inject(InterfaceComponent)
private readonly i18n = inject(i18nPipe)
readonly data = toSignal(
this.patch.watch$('serverInfo', 'network').pipe(
map(({ gateways, acme }) => ({
gateways: Object.entries(gateways)
.filter(([_, g]) => g.ipInfo)
.map(([id, g]) => ({ id, ...g })) as GatewayWithId[],
authorities: Object.keys(acme).reduce<Record<string, string>>(
(obj, url) => ({
...obj,
[url]: toAuthorityName(url),
}),
{ local: toAuthorityName(null) },
),
})),
),
)
async add(addSsl: boolean) {
const addSpec = ISB.InputSpec.of({
fqdn: ISB.Value.text({
name: this.i18n.transform('Domain'),
description: this.i18n.transform(
'Enter a fully qualified domain name. For example, if you control domain.com, you could enter domain.com or subdomain.domain.com or another.subdomain.domain.com.',
),
required: true,
default: null,
patterns: [utils.Patterns.domain],
}).map(f => f.toLocaleLowerCase()),
...this.gatewaySpec(),
...(addSsl
? this.authoritySpec()
: ({} as ReturnType<typeof this.authoritySpec>)),
})
this.formDialog.open(FormComponent, {
label: 'Add public domain',
data: {
spec: await configBuilderToSpec(addSpec),
buttons: [
{
text: 'Save',
handler: (input: typeof addSpec._TYPE) =>
this.save(input.fqdn, input.gateway, input.authority),
},
],
},
})
}
async edit(domain: PublicDomain, addSsl: boolean) {
const editSpec = ISB.InputSpec.of({
...this.gatewaySpec(),
...(addSsl
? this.authoritySpec()
: ({} as ReturnType<typeof this.authoritySpec>)),
})
this.formDialog.open(FormComponent, {
label: 'Edit public domain',
data: {
spec: await configBuilderToSpec(editSpec),
buttons: [
{
text: 'Save',
handler: ({ gateway, authority }: typeof editSpec._TYPE) =>
this.save(domain.fqdn, gateway, authority),
},
],
value: {
gateway: domain.gateway!.id,
authority: domain.acme,
},
},
})
}
remove(fqdn: string) {
this.dialog
.openConfirm({ label: 'Are you sure?', size: 's' })
.pipe(filter(Boolean))
.subscribe(async () => {
const loader = this.loader.open('Deleting').subscribe()
try {
if (this.interface.packageId()) {
await this.api.pkgRemovePublicDomain({
fqdn,
package: this.interface.packageId(),
host: this.interface.value()?.addressInfo.hostId || '',
})
} else {
await this.api.osUiRemovePublicDomain({ fqdn })
}
return true
} catch (e: any) {
this.errorService.handleError(e)
return false
} finally {
loader.unsubscribe()
}
})
}
showDns(fqdn: string, gateway: GatewayWithId, message: i18nKey) {
this.dialog
.openComponent(DNS, {
label: 'DNS Records',
size: 'l',
data: {
fqdn,
gateway,
message,
},
})
.subscribe()
}
private async save(
fqdn: string,
gatewayId: string,
authority?: 'local' | string,
) {
const gateway = this.data()!.gateways.find(g => g.id === gatewayId)!
const loader = this.loader.open('Saving').subscribe()
const params = {
fqdn,
gateway: gatewayId,
acme: !authority || authority === 'local' ? null : authority,
}
try {
let ip: string | null
if (this.interface.packageId()) {
ip = await this.api.pkgAddPublicDomain({
...params,
package: this.interface.packageId(),
host: this.interface.value()?.addressInfo.hostId || '',
})
} else {
ip = await this.api.osUiAddPublicDomain(params)
}
const wanIp = gateway.ipInfo.wanIp
let message = this.i18n.transform(
'Create one of the DNS records below.',
) as i18nKey
if (!ip) {
setTimeout(
() =>
this.showDns(
fqdn,
gateway,
`${this.i18n.transform('No DNS record detected for')} ${fqdn}. ${message}` as i18nKey,
),
250,
)
} else if (ip !== wanIp) {
setTimeout(
() =>
this.showDns(
fqdn,
gateway,
`${this.i18n.transform('Invalid DNS record')}. ${fqdn} ${this.i18n.transform('resolves to')} ${ip}. ${message}` as i18nKey,
),
250,
)
} else {
setTimeout(
() =>
this.dialog
.openAlert(
`${fqdn} ${this.i18n.transform('resolves to')} ${wanIp}` as i18nKey,
{ label: 'DNS record detected!', appearance: 'positive' },
)
.subscribe(),
250,
)
}
return true
} catch (e: any) {
this.errorService.handleError(e)
return false
} finally {
loader.unsubscribe()
}
}
private gatewaySpec() {
const data = this.data()!
const gateways = data.gateways.filter(
({ ipInfo: { deviceType } }) =>
deviceType !== 'loopback' && deviceType !== 'bridge',
)
return {
gateway: ISB.Value.dynamicSelect(() => ({
name: this.i18n.transform('Gateway'),
description: this.i18n.transform(
'Select a gateway to use for this domain.',
),
values: gateways.reduce<Record<string, string>>(
(obj, gateway) => ({
[gateway.id]: gateway.name || gateway.ipInfo.name,
...obj,
}),
{ '~/system/gateways': this.i18n.transform('New gateway') },
),
default: '',
disabled: gateways
.filter(g => !g.ipInfo.wanIp || utils.CGNAT.contains(g.ipInfo.wanIp))
.map(g => g.id),
})),
}
}
private authoritySpec() {
const data = this.data()!
return {
authority: ISB.Value.select({
name: this.i18n.transform('Certificate Authority'),
description: this.i18n.transform(
'Select a Certificate Authority to issue SSL/TLS certificates for this domain',
),
values: data.authorities,
default: '',
}),
}
}
}

View File

@@ -17,10 +17,7 @@ import { PatchDB } from 'patch-db-client'
import { InterfaceComponent } from 'src/app/routes/portal/components/interfaces/interface.component'
import { DataModel } from 'src/app/services/patch-db/data-model'
import { TitleDirective } from 'src/app/services/title.service'
import {
getPublicDomains,
InterfaceService,
} from '../../../components/interfaces/interface.service'
import { InterfaceService } from '../../../components/interfaces/interface.service'
import { GatewayService } from 'src/app/services/gateway.service'
import { getInstalledBaseStatus } from 'src/app/services/pkg-status-rendering.service'
@@ -125,25 +122,16 @@ export default class ServiceInterfaceRoute {
}
const binding = host.bindings[port]
const gateways = this.gatewayService.gateways() || []
return {
...iFace,
addresses: this.interfaceService.getAddresses(iFace, host, gateways),
gateways:
gateways.map(g => ({
enabled:
(binding?.addresses.enabled.some(a => a.gateway.id === g.id) ||
(!binding?.addresses.disabled.some(a => a.gateway.id === g.id) &&
binding?.addresses.possible.some(a =>
a.gateway.id === g.id &&
!(a.public && (a.hostname.kind === 'ipv4' || a.hostname.kind === 'ipv6'))
))) ?? false,
...g,
})) || [],
publicDomains: getPublicDomains(host.publicDomains, gateways),
privateDomains: host.privateDomains,
gatewayGroups: this.interfaceService.getGatewayGroups(
iFace,
host,
gateways,
),
pluginGroups: this.interfaceService.getPluginGroups(iFace, host),
addSsl: !!binding?.options.addSsl,
}
})

View File

@@ -184,7 +184,7 @@ export default class SystemDnsComponent {
if (
Object.values(pkgs).some(p =>
Object.values(p.hosts).some(h => h?.privateDomains.length),
Object.values(p.hosts).some(h => Object.keys(h?.privateDomains || {}).length),
)
) {
Object.values(gateways)

View File

@@ -15,7 +15,6 @@ import { GatewaysTableComponent } from './table.component'
import { configBuilderToSpec } from 'src/app/utils/configBuilderToSpec'
import { TitleDirective } from 'src/app/services/title.service'
import { ISB } from '@start9labs/start-sdk'
import { RR } from 'src/app/services/api/api.types'
@Component({
template: `
@@ -81,17 +80,6 @@ export default class GatewaysComponent {
default: null,
placeholder: 'StartTunnel 1',
}),
type: ISB.Value.select({
name: this.i18n.transform('Type'),
description: this.i18n.transform('The type of gateway'),
default: 'inbound-outbound',
values: {
'inbound-outbound': this.i18n.transform(
'StartTunnel (Inbound/Outbound)',
),
'outbound-only': this.i18n.transform('Outbound Only'),
},
}),
config: ISB.Value.union({
name: this.i18n.transform('WireGuard Config File'),
default: 'paste',
@@ -103,8 +91,8 @@ export default class GatewaysComponent {
name: this.i18n.transform('File Contents'),
default: null,
required: true,
minRows: 16,
maxRows: 16,
minRows: 8,
maxRows: 8,
}),
}),
},
@@ -146,7 +134,6 @@ export default class GatewaysComponent {
input.config.selection === 'paste'
? input.config.value.file
: await (input.config.value.file as any as File).text(),
type: input.type as RR.GatewayType,
setAsDefaultOutbound: input.setAsDefaultOutbound,
})
return true

View File

@@ -34,7 +34,9 @@ import { TuiBadge } from '@taiga-ui/kit'
<td>
{{ gateway.name }}
@if (gateway.isDefaultOutbound) {
<span tuiBadge tuiStatus appearance="positive">Default outbound</span>
<tui-badge appearance="primary-success">
{{ 'default outbound' | i18n }}
</tui-badge>
}
</td>
<td>
@@ -49,7 +51,7 @@ import { TuiBadge } from '@taiga-ui/kit'
}
@case ('wireguard') {
<tui-icon icon="@tui.shield" />
WireGuard'
WireGuard
}
@default {
{{ gateway.ipInfo.deviceType }}
@@ -58,13 +60,14 @@ import { TuiBadge } from '@taiga-ui/kit'
</td>
<td>
@if (gateway.type === 'outbound-only') {
<tui-icon icon="@tui.arrow-up-right" />
<tui-icon icon="@tui.arrow-up" />
{{ 'Outbound Only' | i18n }}
} @else {
<tui-icon icon="@tui.arrow-left-right" />
<tui-icon icon="@tui.arrow-up-down" />
{{ 'Inbound/Outbound' | i18n }}
}
</td>
<td class="lan">{{ gateway.lanIpv4.join(', ') || '-' }}</td>
<td
class="wan"
[style.color]="
@@ -73,7 +76,6 @@ import { TuiBadge } from '@taiga-ui/kit'
>
{{ gateway.ipInfo.wanIp || ('Error' | i18n) }}
</td>
<td class="lan">{{ gateway.lanIpv4.join(', ') || '-' }}</td>
<td>
<button
tuiIconButton
@@ -87,7 +89,7 @@ import { TuiBadge } from '@taiga-ui/kit'
{{ 'More' | i18n }}
<tui-data-list *tuiTextfieldDropdown>
<tui-opt-group>
<button tuiOption new iconStart="@tui.pencil" (click)="rename()">
<button tuiOption new (click)="rename()">
{{ 'Rename' | i18n }}
</button>
</tui-opt-group>
@@ -96,7 +98,6 @@ import { TuiBadge } from '@taiga-ui/kit'
<button
tuiOption
new
iconStart="@tui.arrow-up-right"
(click)="setDefaultOutbound()"
>
{{ 'Set as default outbound' | i18n }}
@@ -108,7 +109,6 @@ import { TuiBadge } from '@taiga-ui/kit'
<button
tuiOption
new
iconStart="@tui.trash"
class="g-negative"
(click)="remove()"
>

View File

@@ -13,8 +13,8 @@ import { GatewayService } from 'src/app/services/gateway.service'
'Name',
'Connection',
'Type',
$any('WAN IP'),
$any('LAN IP'),
$any('WAN IP'),
null,
]"
>

View File

@@ -12,10 +12,7 @@ import { TuiButton, TuiTitle } from '@taiga-ui/core'
import { TuiHeader } from '@taiga-ui/layout'
import { PatchDB } from 'patch-db-client'
import { InterfaceComponent } from 'src/app/routes/portal/components/interfaces/interface.component'
import {
getPublicDomains,
InterfaceService,
} from 'src/app/routes/portal/components/interfaces/interface.service'
import { InterfaceService } from 'src/app/routes/portal/components/interfaces/interface.service'
import { GatewayService } from 'src/app/services/gateway.service'
import { DataModel } from 'src/app/services/patch-db/data-model'
import { TitleDirective } from 'src/app/services/title.service'
@@ -84,27 +81,17 @@ export default class StartOsUiComponent {
if (!network || !gateways) return
const binding = network.host.bindings['80']
return {
...this.iface,
addresses: this.interfaceService.getAddresses(
gatewayGroups: this.interfaceService.getGatewayGroups(
this.iface,
network.host,
gateways,
),
gateways: gateways.map(g => ({
enabled:
(binding?.addresses.enabled.some(a => a.gateway.id === g.id) ||
(!binding?.addresses.disabled.some(a => a.gateway.id === g.id) &&
binding?.addresses.possible.some(a =>
a.gateway.id === g.id &&
!(a.public && (a.hostname.kind === 'ipv4' || a.hostname.kind === 'ipv6'))
))) ?? false,
...g,
})),
publicDomains: getPublicDomains(network.host.publicDomains, gateways),
privateDomains: network.host.privateDomains,
pluginGroups: this.interfaceService.getPluginGroups(
this.iface,
network.host,
),
addSsl: true,
}
})

View File

@@ -2130,68 +2130,44 @@ export namespace Mock {
addresses: {
enabled: [],
disabled: [],
possible: [
available: [
{
gateway: { id: 'eth0', name: 'Ethernet', public: false },
ssl: true,
public: false,
hostname: {
kind: 'local',
value: 'adjective-noun.local',
port: null,
sslPort: 1234,
host: 'adjective-noun.local',
port: 1234,
metadata: {
kind: 'private-domain',
gateways: ['eth0', 'wlan0'],
},
},
{
gateway: { id: 'wlan0', name: 'Wireless', public: false },
ssl: true,
public: false,
hostname: {
kind: 'local',
value: 'adjective-noun.local',
port: null,
sslPort: 1234,
},
host: '192.168.10.11',
port: 1234,
metadata: { kind: 'ipv4', gateway: 'wlan0' },
},
{
gateway: { id: 'wlan0', name: 'Wireless', public: false },
ssl: true,
public: false,
hostname: {
kind: 'ipv4',
value: '192.168.10.11',
port: null,
sslPort: 1234,
},
host: '10.0.0.2',
port: 1234,
metadata: { kind: 'ipv4', gateway: 'wlan0' },
},
{
gateway: { id: 'wlan0', name: 'Wireless', public: false },
ssl: true,
public: false,
hostname: {
kind: 'ipv4',
value: '10.0.0.2',
port: null,
sslPort: 1234,
},
host: 'fe80:cd00:0000:0cde:1257:0000:211e:72cd',
port: 1234,
metadata: { kind: 'ipv6', gateway: 'eth0', scopeId: 2 },
},
{
gateway: { id: 'eth0', name: 'Ethernet', public: false },
ssl: true,
public: false,
hostname: {
kind: 'ipv6',
value: '[fe80:cd00:0000:0cde:1257:0000:211e:72cd]',
scopeId: 2,
port: null,
sslPort: 1234,
},
},
{
gateway: { id: 'wlan0', name: 'Wireless', public: false },
public: false,
hostname: {
kind: 'ipv6',
value: '[fe80:cd00:0000:0cde:1257:0000:211e:1234]',
scopeId: 3,
port: null,
sslPort: 1234,
},
host: 'fe80:cd00:0000:0cde:1257:0000:211e:1234',
port: 1234,
metadata: { kind: 'ipv6', gateway: 'wlan0', scopeId: 3 },
},
],
},
@@ -2203,7 +2179,7 @@ export namespace Mock {
},
},
publicDomains: {},
privateDomains: [],
privateDomains: {},
},
bcdefgh: {
bindings: {
@@ -2216,7 +2192,7 @@ export namespace Mock {
addresses: {
enabled: [],
disabled: [],
possible: [],
available: [],
},
options: {
addSsl: null,
@@ -2226,7 +2202,7 @@ export namespace Mock {
},
},
publicDomains: {},
privateDomains: [],
privateDomains: {},
},
cdefghi: {
bindings: {
@@ -2239,7 +2215,7 @@ export namespace Mock {
addresses: {
enabled: [],
disabled: [],
possible: [],
available: [],
},
options: {
addSsl: null,
@@ -2249,7 +2225,7 @@ export namespace Mock {
},
},
publicDomains: {},
privateDomains: [],
privateDomains: {},
},
},
storeExposedDependents: [],

View File

@@ -257,7 +257,6 @@ export namespace RR {
export type AddTunnelReq = {
name: string
config: string // file contents
type: GatewayType
setAsDefaultOutbound?: boolean
} // net.tunnel.add
export type AddTunnelRes = {

View File

@@ -583,7 +583,7 @@ export class MockApiService extends ApiService {
lanIp: ['192.168.1.10'],
dnsServers: [],
},
type: params.type,
type: 'inbound-outbound',
},
},
]
@@ -1406,24 +1406,20 @@ export class MockApiService extends ApiService {
const patch: Operation<any>[] = [
{
op: PatchOp.ADD,
path: `/serverInfo/host/publicDomains`,
path: `/serverInfo/network/host/publicDomains`,
value: {
[params.fqdn]: { gateway: params.gateway, acme: params.acme },
},
},
{
op: PatchOp.ADD,
path: `/serverInfo/network/host/bindings/80/addresses/possible/0`,
path: `/serverInfo/network/host/bindings/80/addresses/available/-`,
value: {
gateway: { id: 'eth0', name: 'Ethernet', public: false },
ssl: true,
public: true,
hostname: {
kind: 'domain',
domain: params.fqdn,
subdomain: null,
port: null,
sslPort: 443,
},
host: params.fqdn,
port: 443,
metadata: { kind: 'public-domain', gateway: params.gateway },
},
},
]
@@ -1440,11 +1436,7 @@ export class MockApiService extends ApiService {
const patch: RemoveOperation[] = [
{
op: PatchOp.REMOVE,
path: `/serverInfo/host/publicDomains/${params.fqdn}`,
},
{
op: PatchOp.REMOVE,
path: `/serverInfo/network/host/bindings/80/addresses/possible/0`,
path: `/serverInfo/network/host/publicDomains/${params.fqdn}`,
},
]
this.mockRevision(patch)
@@ -1459,23 +1451,19 @@ export class MockApiService extends ApiService {
const patch: Operation<any>[] = [
{
op: PatchOp.REPLACE,
path: `/serverInfo/host/privateDomains`,
value: [params.fqdn],
op: PatchOp.ADD,
path: `/serverInfo/network/host/privateDomains/${params.fqdn}`,
value: ['eth0'],
},
{
op: PatchOp.ADD,
path: `/serverInfo/network/host/bindings/80/addresses/possible/0`,
path: `/serverInfo/network/host/bindings/80/addresses/available/-`,
value: {
gateway: { id: 'eth0', name: 'Ethernet', public: false },
ssl: true,
public: false,
hostname: {
kind: 'domain',
domain: params.fqdn,
subdomain: null,
port: null,
sslPort: 443,
},
host: params.fqdn,
port: 443,
metadata: { kind: 'private-domain', gateways: ['eth0'] },
},
},
]
@@ -1489,15 +1477,10 @@ export class MockApiService extends ApiService {
): Promise<RR.OsUiRemovePrivateDomainRes> {
await pauseFor(2000)
const patch: Operation<any>[] = [
{
op: PatchOp.REPLACE,
path: `/serverInfo/host/privateDomains`,
value: [],
},
const patch: RemoveOperation[] = [
{
op: PatchOp.REMOVE,
path: `/serverInfo/network/host/bindings/80/addresses/possible/0`,
path: `/serverInfo/network/host/privateDomains/${params.fqdn}`,
},
]
this.mockRevision(patch)
@@ -1529,17 +1512,13 @@ export class MockApiService extends ApiService {
},
{
op: PatchOp.ADD,
path: `/packageData/${params.package}/hosts/${params.host}/bindings/80/addresses/possible/0`,
path: `/packageData/${params.package}/hosts/${params.host}/bindings/80/addresses/available/-`,
value: {
gateway: { id: 'eth0', name: 'Ethernet', public: false },
ssl: true,
public: true,
hostname: {
kind: 'domain',
domain: params.fqdn,
subdomain: null,
port: null,
sslPort: 443,
},
host: params.fqdn,
port: 443,
metadata: { kind: 'public-domain', gateway: params.gateway },
},
},
]
@@ -1558,10 +1537,6 @@ export class MockApiService extends ApiService {
op: PatchOp.REMOVE,
path: `/packageData/${params.package}/hosts/${params.host}/publicDomains/${params.fqdn}`,
},
{
op: PatchOp.REMOVE,
path: `/packageData/${params.package}/hosts/${params.host}/bindings/80/addresses/possible/0`,
},
]
this.mockRevision(patch)
@@ -1575,23 +1550,19 @@ export class MockApiService extends ApiService {
const patch: Operation<any>[] = [
{
op: PatchOp.REPLACE,
path: `/packageData/${params.package}/hosts/${params.host}/privateDomains`,
value: [params.fqdn],
op: PatchOp.ADD,
path: `/packageData/${params.package}/hosts/${params.host}/privateDomains/${params.fqdn}`,
value: ['eth0'],
},
{
op: PatchOp.ADD,
path: `/packageData/${params.package}/hosts/${params.host}/bindings/80/addresses/possible/0`,
path: `/packageData/${params.package}/hosts/${params.host}/bindings/80/addresses/available/-`,
value: {
gateway: { id: 'eth0', name: 'Ethernet', public: false },
ssl: true,
public: false,
hostname: {
kind: 'domain',
domain: params.fqdn,
subdomain: null,
port: null,
sslPort: 443,
},
host: params.fqdn,
port: 443,
metadata: { kind: 'private-domain', gateways: ['eth0'] },
},
},
]
@@ -1605,15 +1576,10 @@ export class MockApiService extends ApiService {
): Promise<RR.PkgRemovePrivateDomainRes> {
await pauseFor(2000)
const patch: Operation<any>[] = [
{
op: PatchOp.REPLACE,
path: `/packageData/${params.package}/hosts/${params.host}/privateDomains`,
value: [],
},
const patch: RemoveOperation[] = [
{
op: PatchOp.REMOVE,
path: `/packageData/${params.package}/hosts/${params.host}/bindings/80/addresses/possible/0`,
path: `/packageData/${params.package}/hosts/${params.host}/privateDomains/${params.fqdn}`,
},
]
this.mockRevision(patch)

View File

@@ -42,68 +42,58 @@ export const mockPatchData: DataModel = {
addresses: {
enabled: [],
disabled: [],
possible: [
available: [
{
gateway: { id: 'eth0', name: 'Ethernet', public: false },
ssl: true,
public: false,
hostname: {
kind: 'local',
value: 'adjective-noun.local',
port: null,
sslPort: 443,
host: 'adjective-noun.local',
port: 443,
metadata: {
kind: 'private-domain',
gateways: ['eth0', 'wlan0'],
},
},
{
gateway: { id: 'wlan0', name: 'Wireless', public: false },
ssl: true,
public: false,
hostname: {
kind: 'local',
value: 'adjective-noun.local',
port: null,
sslPort: 443,
},
host: '10.0.0.1',
port: 443,
metadata: { kind: 'ipv4', gateway: 'eth0' },
},
{
gateway: { id: 'eth0', name: 'Ethernet', public: false },
ssl: true,
public: false,
hostname: {
kind: 'ipv4',
value: '10.0.0.1',
port: null,
sslPort: 443,
},
host: '10.0.0.2',
port: 443,
metadata: { kind: 'ipv4', gateway: 'wlan0' },
},
{
gateway: { id: 'wlan0', name: 'Wireless', public: false },
ssl: true,
public: false,
hostname: {
kind: 'ipv4',
value: '10.0.0.2',
port: null,
sslPort: 443,
},
host: 'fe80::cd00:0000:0cde:1257:0000:211e:72cd',
port: 443,
metadata: { kind: 'ipv6', gateway: 'eth0', scopeId: 2 },
},
{
gateway: { id: 'eth0', name: 'Ethernet', public: false },
ssl: true,
public: false,
hostname: {
kind: 'ipv6',
value: 'fe80::cd00:0000:0cde:1257:0000:211e:72cd',
scopeId: 2,
port: null,
sslPort: 443,
},
host: 'fe80::cd00:0000:0cde:1257:0000:211e:1234',
port: 443,
metadata: { kind: 'ipv6', gateway: 'wlan0', scopeId: 3 },
},
{
gateway: { id: 'wlan0', name: 'Wireless', public: false },
ssl: false,
public: false,
hostname: {
kind: 'ipv6',
value: 'fe80::cd00:0000:0cde:1257:0000:211e:1234',
scopeId: 3,
port: null,
sslPort: 443,
},
host: 'abc123def456ghi789jkl012mno345pqr678stu901vwx234yz567abc.onion',
port: 80,
metadata: { kind: 'plugin', package: 'tor' },
},
{
ssl: true,
public: false,
host: 'abc123def456ghi789jkl012mno345pqr678stu901vwx234yz567abc.onion',
port: 443,
metadata: { kind: 'plugin', package: 'tor' },
},
],
},
@@ -119,7 +109,7 @@ export const mockPatchData: DataModel = {
},
},
publicDomains: {},
privateDomains: [],
privateDomains: {},
},
gateways: {
eth0: {
@@ -474,13 +464,13 @@ export const mockPatchData: DataModel = {
},
rpc: {
id: 'rpc',
masked: false,
masked: true,
name: 'RPC',
description:
'Used by dependent services and client wallets for connecting to your node',
type: 'api',
addressInfo: {
username: null,
username: 'rpcuser',
hostId: 'bcdefgh',
internalPort: 8332,
scheme: 'http',
@@ -516,70 +506,84 @@ export const mockPatchData: DataModel = {
assignedSslPort: 443,
},
addresses: {
enabled: [],
enabled: ['203.0.113.45:443'],
disabled: [],
possible: [
available: [
{
gateway: { id: 'eth0', name: 'Ethernet', public: false },
ssl: true,
public: false,
hostname: {
kind: 'local',
value: 'adjective-noun.local',
port: null,
sslPort: 1234,
host: 'adjective-noun.local',
port: 443,
metadata: {
kind: 'private-domain',
gateways: ['eth0'],
},
},
{
gateway: { id: 'wlan0', name: 'Wireless', public: false },
ssl: true,
public: false,
hostname: {
kind: 'local',
value: 'adjective-noun.local',
port: null,
sslPort: 1234,
host: '10.0.0.1',
port: 443,
metadata: { kind: 'ipv4', gateway: 'eth0' },
},
{
ssl: true,
public: false,
host: 'fe80::cd00:0cde:1257:211e:72cd',
port: 443,
metadata: { kind: 'ipv6', gateway: 'eth0', scopeId: 2 },
},
{
ssl: true,
public: true,
host: '203.0.113.45',
port: 443,
metadata: { kind: 'ipv4', gateway: 'eth0' },
},
{
ssl: true,
public: true,
host: 'bitcoin.example.com',
port: 443,
metadata: { kind: 'public-domain', gateway: 'eth0' },
},
{
ssl: true,
public: false,
host: '192.168.10.11',
port: 443,
metadata: { kind: 'ipv4', gateway: 'wlan0' },
},
{
ssl: true,
public: false,
host: 'fe80::cd00:0cde:1257:211e:1234',
port: 443,
metadata: { kind: 'ipv6', gateway: 'wlan0', scopeId: 3 },
},
{
ssl: true,
public: false,
host: 'my-bitcoin.home',
port: 443,
metadata: {
kind: 'private-domain',
gateways: ['wlan0'],
},
},
{
gateway: { id: 'eth0', name: 'Ethernet', public: false },
ssl: false,
public: false,
hostname: {
kind: 'ipv4',
value: '10.0.0.1',
port: null,
sslPort: 1234,
},
host: 'xyz789abc123def456ghi789jkl012mno345pqr678stu901vwx234.onion',
port: 80,
metadata: { kind: 'plugin', package: 'tor' },
},
{
gateway: { id: 'wlan0', name: 'Wireless', public: false },
ssl: true,
public: false,
hostname: {
kind: 'ipv4',
value: '10.0.0.2',
port: null,
sslPort: 1234,
},
},
{
gateway: { id: 'eth0', name: 'Ethernet', public: false },
public: false,
hostname: {
kind: 'ipv6',
value: 'fe80::cd00:0000:0cde:1257:0000:211e:72cd',
scopeId: 2,
port: null,
sslPort: 1234,
},
},
{
gateway: { id: 'wlan0', name: 'Wireless', public: false },
public: false,
hostname: {
kind: 'ipv6',
value: 'fe80::cd00:0000:0cde:1257:0000:211e:1234',
scopeId: 3,
port: null,
sslPort: 1234,
},
host: 'xyz789abc123def456ghi789jkl012mno345pqr678stu901vwx234.onion',
port: 443,
metadata: { kind: 'plugin', package: 'tor' },
},
],
},
@@ -590,8 +594,15 @@ export const mockPatchData: DataModel = {
},
},
},
publicDomains: {},
privateDomains: [],
publicDomains: {
'bitcoin.example.com': {
gateway: 'eth0',
acme: null,
},
},
privateDomains: {
'my-bitcoin.home': ['wlan0'],
},
},
bcdefgh: {
bindings: {
@@ -604,7 +615,25 @@ export const mockPatchData: DataModel = {
addresses: {
enabled: [],
disabled: [],
possible: [],
available: [
{
ssl: false,
public: false,
host: 'adjective-noun.local',
port: 8332,
metadata: {
kind: 'private-domain',
gateways: ['eth0'],
},
},
{
ssl: false,
public: false,
host: '10.0.0.1',
port: 8332,
metadata: { kind: 'ipv4', gateway: 'eth0' },
},
],
},
options: {
addSsl: null,
@@ -614,7 +643,7 @@ export const mockPatchData: DataModel = {
},
},
publicDomains: {},
privateDomains: [],
privateDomains: {},
},
cdefghi: {
bindings: {
@@ -627,7 +656,7 @@ export const mockPatchData: DataModel = {
addresses: {
enabled: [],
disabled: [],
possible: [],
available: [],
},
options: {
addSsl: null,
@@ -637,7 +666,7 @@ export const mockPatchData: DataModel = {
},
},
publicDomains: {},
privateDomains: [],
privateDomains: {},
},
},
storeExposedDependents: [],