mirror of
https://github.com/Start9Labs/start-os.git
synced 2026-03-26 10:21:52 +00:00
new service interfacee page
This commit is contained in:
@@ -692,4 +692,9 @@ export default {
|
||||
727: '',
|
||||
728: '',
|
||||
729: '',
|
||||
730: '',
|
||||
731: '',
|
||||
732: '',
|
||||
733: '',
|
||||
734: '',
|
||||
} satisfies i18n
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -692,4 +692,9 @@ export default {
|
||||
727: '',
|
||||
728: '',
|
||||
729: '',
|
||||
730: '',
|
||||
731: '',
|
||||
732: '',
|
||||
733: '',
|
||||
734: '',
|
||||
} satisfies i18n
|
||||
|
||||
@@ -692,4 +692,9 @@ export default {
|
||||
727: '',
|
||||
728: '',
|
||||
729: '',
|
||||
730: '',
|
||||
731: '',
|
||||
732: '',
|
||||
733: '',
|
||||
734: '',
|
||||
} satisfies i18n
|
||||
|
||||
@@ -692,4 +692,9 @@ export default {
|
||||
727: '',
|
||||
728: '',
|
||||
729: '',
|
||||
730: '',
|
||||
731: '',
|
||||
732: '',
|
||||
733: '',
|
||||
734: '',
|
||||
} satisfies i18n
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
}
|
||||
}
|
||||
@@ -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('')
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
>
|
||||
>()
|
||||
|
||||
|
||||
@@ -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>()
|
||||
}
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
@@ -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: '',
|
||||
}),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
})
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()"
|
||||
>
|
||||
|
||||
@@ -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,
|
||||
]"
|
||||
>
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
})
|
||||
|
||||
@@ -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: [],
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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: [],
|
||||
|
||||
Reference in New Issue
Block a user