mirror of
https://github.com/Start9Labs/start-os.git
synced 2026-03-31 04:23:40 +00:00
looking good
This commit is contained in:
@@ -29,6 +29,16 @@ import { GatewayAddress, MappedServiceInterface } from '../interface.service'
|
||||
selector: 'td[actions]',
|
||||
template: `
|
||||
<div class="desktop">
|
||||
@if (address().deletable) {
|
||||
<button
|
||||
tuiIconButton
|
||||
appearance="flat-grayscale"
|
||||
iconStart="@tui.trash"
|
||||
(click)="deleteDomain()"
|
||||
>
|
||||
{{ 'Delete' | i18n }}
|
||||
</button>
|
||||
}
|
||||
<button
|
||||
tuiIconButton
|
||||
appearance="flat-grayscale"
|
||||
@@ -45,16 +55,6 @@ import { GatewayAddress, MappedServiceInterface } from '../interface.service'
|
||||
>
|
||||
{{ '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
|
||||
@@ -70,17 +70,14 @@ import { GatewayAddress, MappedServiceInterface } from '../interface.service'
|
||||
<button
|
||||
tuiOption
|
||||
new
|
||||
[iconStart]="address().enabled ? '@tui.toggle-right' : '@tui.toggle-left'"
|
||||
[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()"
|
||||
>
|
||||
<button tuiOption new iconStart="@tui.qr-code" (click)="showQR()">
|
||||
{{ 'Show QR' | i18n }}
|
||||
</button>
|
||||
<button
|
||||
|
||||
@@ -8,7 +8,6 @@ import {
|
||||
import {
|
||||
DialogService,
|
||||
ErrorService,
|
||||
i18nKey,
|
||||
i18nPipe,
|
||||
LoadingService,
|
||||
} from '@start9labs/shared'
|
||||
@@ -36,7 +35,7 @@ import {
|
||||
GatewayAddressGroup,
|
||||
MappedServiceInterface,
|
||||
} from '../interface.service'
|
||||
import { DNS, DnsGateway } from '../public-domains/dns.component'
|
||||
import { DOMAIN_VALIDATION, DnsGateway } from '../public-domains/dns.component'
|
||||
import { InterfaceAddressItemComponent } from './item.component'
|
||||
|
||||
@Component({
|
||||
@@ -62,7 +61,16 @@ import { InterfaceAddressItemComponent } from './item.component'
|
||||
</tui-data-list>
|
||||
</button>
|
||||
</header>
|
||||
<table [appTable]="['Enabled', 'Type', 'Access', 'URL', null]">
|
||||
<table
|
||||
[appTable]="[
|
||||
'Enabled',
|
||||
'Type',
|
||||
'Access',
|
||||
'Certificate Authority',
|
||||
'URL',
|
||||
null,
|
||||
]"
|
||||
>
|
||||
@for (address of gatewayGroup().addresses; track $index) {
|
||||
<tr
|
||||
[address]="address"
|
||||
@@ -72,7 +80,7 @@ import { InterfaceAddressItemComponent } from './item.component'
|
||||
></tr>
|
||||
} @empty {
|
||||
<tr>
|
||||
<td colspan="5">
|
||||
<td colspan="6">
|
||||
<app-placeholder icon="@tui.list-x">
|
||||
{{ 'No addresses' | i18n }}
|
||||
</app-placeholder>
|
||||
@@ -86,6 +94,12 @@ import { InterfaceAddressItemComponent } from './item.component'
|
||||
th:first-child {
|
||||
width: 5rem;
|
||||
}
|
||||
|
||||
th:nth-child(2),
|
||||
th:nth-child(3),
|
||||
th:nth-child(4) {
|
||||
width: 11rem;
|
||||
}
|
||||
}
|
||||
`,
|
||||
host: { class: 'g-card' },
|
||||
@@ -120,6 +134,7 @@ export class InterfaceAddressesComponent {
|
||||
async addPrivateDomain() {
|
||||
this.formDialog.open<FormContext<{ fqdn: string }>>(FormComponent, {
|
||||
label: 'New private domain',
|
||||
size: 's',
|
||||
data: {
|
||||
spec: await configBuilderToSpec(
|
||||
ISB.InputSpec.of({
|
||||
@@ -173,22 +188,19 @@ export class InterfaceAddressesComponent {
|
||||
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> })),
|
||||
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: Object.keys(network.acme)[0] || 'local',
|
||||
}),
|
||||
})
|
||||
|
||||
this.formDialog.open(FormComponent, {
|
||||
label: 'Add public domain',
|
||||
size: 's',
|
||||
data: {
|
||||
spec: await configBuilderToSpec(addSpec),
|
||||
buttons: [
|
||||
@@ -251,55 +263,33 @@ export class InterfaceAddressesComponent {
|
||||
ip = await this.api.osUiAddPublicDomain(params)
|
||||
}
|
||||
|
||||
const network = await this.patch
|
||||
.watch$('serverInfo', 'network')
|
||||
.pipe()
|
||||
.toPromise()
|
||||
const gateway = network?.gateways[gatewayId]
|
||||
const [network, portPass] = await Promise.all([
|
||||
firstValueFrom(this.patch.watch$('serverInfo', 'network')),
|
||||
this.api
|
||||
.testPortForward({ gateway: gatewayId, port: 443 })
|
||||
.catch(() => false),
|
||||
])
|
||||
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,
|
||||
}
|
||||
const dnsPass = ip === gateway.ipInfo.wanIp
|
||||
|
||||
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,
|
||||
)
|
||||
}
|
||||
setTimeout(
|
||||
() =>
|
||||
this.showDomainValidation(
|
||||
fqdn,
|
||||
gatewayData,
|
||||
443,
|
||||
dnsPass,
|
||||
portPass,
|
||||
),
|
||||
250,
|
||||
)
|
||||
}
|
||||
|
||||
return true
|
||||
@@ -311,12 +301,18 @@ export class InterfaceAddressesComponent {
|
||||
}
|
||||
}
|
||||
|
||||
private showDns(fqdn: string, gateway: DnsGateway, message: i18nKey) {
|
||||
private showDomainValidation(
|
||||
fqdn: string,
|
||||
gateway: DnsGateway,
|
||||
port: number,
|
||||
dnsPass: boolean,
|
||||
portPass: boolean,
|
||||
) {
|
||||
this.dialog
|
||||
.openComponent(DNS, {
|
||||
label: 'DNS Records',
|
||||
size: 'l',
|
||||
data: { fqdn, gateway, message },
|
||||
.openComponent(DOMAIN_VALIDATION, {
|
||||
label: 'Domain Setup',
|
||||
size: 'm',
|
||||
data: { fqdn, gateway, port, dnsPass, portPass },
|
||||
})
|
||||
.subscribe()
|
||||
}
|
||||
|
||||
@@ -8,8 +8,7 @@ import {
|
||||
} from '@angular/core'
|
||||
import { ErrorService, i18nPipe, LoadingService } from '@start9labs/shared'
|
||||
import { TuiObfuscatePipe } from '@taiga-ui/cdk'
|
||||
import { TuiButton } from '@taiga-ui/core'
|
||||
import { TuiBadge } from '@taiga-ui/kit'
|
||||
import { TuiButton, TuiIcon } from '@taiga-ui/core'
|
||||
import { FormsModule } from '@angular/forms'
|
||||
import { TuiSwitch } from '@taiga-ui/kit'
|
||||
import { ApiService } from 'src/app/services/api/embassy-api.service'
|
||||
@@ -18,6 +17,9 @@ import { AddressActionsComponent } from './actions.component'
|
||||
|
||||
@Component({
|
||||
selector: 'tr[address]',
|
||||
host: {
|
||||
'[class._disabled]': '!address().enabled',
|
||||
},
|
||||
template: `
|
||||
@if (address(); as address) {
|
||||
<td>
|
||||
@@ -26,6 +28,7 @@ import { AddressActionsComponent } from './actions.component'
|
||||
tuiSwitch
|
||||
size="s"
|
||||
[showIcons]="false"
|
||||
[disabled]="toggling()"
|
||||
[ngModel]="address.enabled"
|
||||
(ngModelChange)="onToggleEnabled()"
|
||||
/>
|
||||
@@ -33,18 +36,16 @@ import { AddressActionsComponent } from './actions.component'
|
||||
<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 class="access">
|
||||
<tui-icon
|
||||
[icon]="address.access === 'public' ? '@tui.globe' : '@tui.house'"
|
||||
/>
|
||||
{{ address.access | i18n }}
|
||||
</td>
|
||||
<td [style.grid-area]="'2 / 1 / 2 / 3'">
|
||||
<td>
|
||||
{{ address.certificate }}
|
||||
</td>
|
||||
<td>
|
||||
<div class="url">
|
||||
<span
|
||||
[title]="address.masked && currentlyMasked() ? '' : address.url"
|
||||
@@ -79,6 +80,11 @@ import { AddressActionsComponent } from './actions.component'
|
||||
grid-template-columns: fit-content(10rem) 1fr 2rem 2rem;
|
||||
}
|
||||
|
||||
.access tui-icon {
|
||||
font-size: 1rem;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.url {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -95,6 +101,23 @@ import { AddressActionsComponent } from './actions.component'
|
||||
}
|
||||
|
||||
:host-context(tui-root._mobile) {
|
||||
padding-inline-start: 0.75rem !important;
|
||||
|
||||
&::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset-inline-start: 0;
|
||||
top: 0.25rem;
|
||||
bottom: 0.25rem;
|
||||
width: 4px;
|
||||
background: var(--tui-status-positive);
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
&._disabled::before {
|
||||
background: var(--tui-background-neutral-1-hover);
|
||||
}
|
||||
|
||||
td {
|
||||
width: auto !important;
|
||||
align-content: center;
|
||||
@@ -110,13 +133,27 @@ import { AddressActionsComponent } from './actions.component'
|
||||
color: var(--tui-text-primary);
|
||||
padding-inline-end: 0.5rem;
|
||||
}
|
||||
|
||||
td:nth-child(4) {
|
||||
grid-area: 2 / 1 / 2 / 3;
|
||||
}
|
||||
|
||||
td:nth-child(5) {
|
||||
grid-area: 3 / 1 / 3 / 3;
|
||||
}
|
||||
|
||||
td:last-child {
|
||||
grid-area: 1 / 3 / 4 / 5;
|
||||
align-self: center;
|
||||
justify-self: end;
|
||||
}
|
||||
}
|
||||
`,
|
||||
imports: [
|
||||
i18nPipe,
|
||||
AddressActionsComponent,
|
||||
TuiBadge,
|
||||
TuiButton,
|
||||
TuiIcon,
|
||||
TuiObfuscatePipe,
|
||||
TuiSwitch,
|
||||
FormsModule,
|
||||
@@ -125,14 +162,15 @@ import { AddressActionsComponent } from './actions.component'
|
||||
})
|
||||
export class InterfaceAddressItemComponent {
|
||||
private readonly api = inject(ApiService)
|
||||
private readonly loader = inject(LoadingService)
|
||||
private readonly errorService = inject(ErrorService)
|
||||
private readonly loader = inject(LoadingService)
|
||||
|
||||
readonly address = input.required<GatewayAddress>()
|
||||
readonly packageId = input('')
|
||||
readonly value = input<MappedServiceInterface | undefined>()
|
||||
readonly isRunning = input.required<boolean>()
|
||||
|
||||
readonly toggling = signal(false)
|
||||
readonly currentlyMasked = signal(true)
|
||||
readonly recipe = computed(() =>
|
||||
this.address()?.masked && this.currentlyMasked() ? 'mask' : 'none',
|
||||
@@ -143,6 +181,7 @@ export class InterfaceAddressItemComponent {
|
||||
const iface = this.value()
|
||||
if (!iface) return
|
||||
|
||||
this.toggling.set(true)
|
||||
const enabled = !addr.enabled
|
||||
const addressJson = JSON.stringify(addr.hostnameInfo)
|
||||
const loader = this.loader.open('Saving').subscribe()
|
||||
@@ -167,6 +206,7 @@ export class InterfaceAddressItemComponent {
|
||||
this.errorService.handleError(e)
|
||||
} finally {
|
||||
loader.unsubscribe()
|
||||
this.toggling.set(false)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,11 +2,10 @@ 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 { toAuthorityName } from 'src/app/utils/acme'
|
||||
|
||||
function isPublicIp(h: T.HostnameInfo): boolean {
|
||||
return (
|
||||
h.public && (h.metadata.kind === 'ipv4' || h.metadata.kind === 'ipv6')
|
||||
)
|
||||
return h.public && (h.metadata.kind === 'ipv4' || h.metadata.kind === 'ipv6')
|
||||
}
|
||||
|
||||
function isEnabled(addr: T.DerivedAddressInfo, h: T.HostnameInfo): boolean {
|
||||
@@ -38,6 +37,33 @@ function getGatewayIds(h: T.HostnameInfo): string[] {
|
||||
}
|
||||
}
|
||||
|
||||
function getCertificate(
|
||||
h: T.HostnameInfo,
|
||||
host: T.Host,
|
||||
addSsl: T.AddSslOptions | null,
|
||||
secure: T.Security | null,
|
||||
): string {
|
||||
if (!h.ssl) return '-'
|
||||
|
||||
if (h.metadata.kind === 'public-domain') {
|
||||
const config = host.publicDomains[h.host]
|
||||
return config ? toAuthorityName(config.acme) : toAuthorityName(null)
|
||||
}
|
||||
|
||||
if (addSsl) return toAuthorityName(null)
|
||||
if (secure?.ssl) return 'Self signed'
|
||||
|
||||
return '-'
|
||||
}
|
||||
|
||||
function sortDomainsFirst(a: GatewayAddress, b: GatewayAddress): number {
|
||||
const isDomain = (addr: GatewayAddress) =>
|
||||
addr.hostnameInfo.metadata.kind === 'public-domain' ||
|
||||
(addr.hostnameInfo.metadata.kind === 'private-domain' &&
|
||||
!addr.hostnameInfo.host.endsWith('.local'))
|
||||
return Number(isDomain(b)) - Number(isDomain(a))
|
||||
}
|
||||
|
||||
function getAddressType(h: T.HostnameInfo): string {
|
||||
switch (h.metadata.kind) {
|
||||
case 'ipv4':
|
||||
@@ -66,46 +92,40 @@ export class InterfaceService {
|
||||
host: T.Host,
|
||||
gateways: GatewayPlus[],
|
||||
): GatewayAddressGroup[] {
|
||||
const binding =
|
||||
host.bindings[serviceInterface.addressInfo.internalPort]
|
||||
const binding = host.bindings[serviceInterface.addressInfo.internalPort]
|
||||
if (!binding) return []
|
||||
|
||||
const addr = binding.addresses
|
||||
const masked = serviceInterface.masked
|
||||
const ui = serviceInterface.type === 'ui'
|
||||
const { addSsl, secure } = binding.options
|
||||
|
||||
const groupMap = new Map<string, GatewayAddress[]>()
|
||||
const gatewayMap = new Map<string, GatewayPlus>()
|
||||
|
||||
for (const gateway of gateways) {
|
||||
groupMap.set(gateway.id, [])
|
||||
gatewayMap.set(gateway.id, gateway)
|
||||
}
|
||||
|
||||
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 === 'mdns'
|
||||
|
||||
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)
|
||||
}
|
||||
if (!list) continue
|
||||
list.push({
|
||||
enabled: isEnabled(addr, h),
|
||||
type: getAddressType(h),
|
||||
access: h.public ? 'public' : 'private',
|
||||
url: utils.addressHostToUrl(serviceInterface.addressInfo, h),
|
||||
hostnameInfo: h,
|
||||
masked,
|
||||
ui,
|
||||
deletable:
|
||||
h.metadata.kind === 'private-domain' ||
|
||||
h.metadata.kind === 'public-domain',
|
||||
certificate: getCertificate(h, host, addSsl, secure),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -114,7 +134,7 @@ export class InterfaceService {
|
||||
.map(g => ({
|
||||
gatewayId: g.id,
|
||||
gatewayName: g.name,
|
||||
addresses: groupMap.get(g.id)!,
|
||||
addresses: groupMap.get(g.id)!.sort(sortDomainsFirst),
|
||||
}))
|
||||
}
|
||||
|
||||
@@ -122,8 +142,7 @@ export class InterfaceService {
|
||||
serviceInterface: T.ServiceInterface,
|
||||
host: T.Host,
|
||||
): PluginAddressGroup[] {
|
||||
const binding =
|
||||
host.bindings[serviceInterface.addressInfo.internalPort]
|
||||
const binding = host.bindings[serviceInterface.addressInfo.internalPort]
|
||||
if (!binding) return []
|
||||
|
||||
const addr = binding.addresses
|
||||
@@ -224,6 +243,7 @@ export type GatewayAddress = {
|
||||
masked: boolean
|
||||
ui: boolean
|
||||
deletable: boolean
|
||||
certificate: string
|
||||
}
|
||||
|
||||
export type GatewayAddressGroup = {
|
||||
|
||||
@@ -24,47 +24,115 @@ export type DnsGateway = T.NetworkInterfaceInfo & {
|
||||
ipInfo: T.IpInfo
|
||||
}
|
||||
|
||||
@Component({
|
||||
selector: 'dns',
|
||||
template: `
|
||||
<p>{{ context.data.message }}</p>
|
||||
export type DomainValidationData = {
|
||||
fqdn: string
|
||||
gateway: DnsGateway
|
||||
port: number
|
||||
dnsPass: boolean
|
||||
portPass: boolean
|
||||
}
|
||||
|
||||
@Component({
|
||||
selector: 'domain-validation',
|
||||
template: `
|
||||
@let wanIp = context.data.gateway.ipInfo.wanIp || ('Error' | i18n);
|
||||
@let gatewayName =
|
||||
context.data.gateway.name || context.data.gateway.ipInfo.name;
|
||||
@let internalIp = context.data.gateway.ipInfo.lanIp[0] || ('Error' | i18n);
|
||||
|
||||
<h3>{{ 'DNS' | i18n }}</h3>
|
||||
<p>
|
||||
{{ 'In your domain registrar for' | i18n }} {{ domain }},
|
||||
{{ 'create this DNS record' | i18n }}
|
||||
</p>
|
||||
|
||||
@if (context.data.gateway.ipInfo.deviceType !== 'wireguard') {
|
||||
<label>
|
||||
IP
|
||||
<input
|
||||
type="checkbox"
|
||||
appearance="flat"
|
||||
tuiSwitch
|
||||
[(ngModel)]="ddns"
|
||||
(ngModelChange)="pass.set(undefined)"
|
||||
(ngModelChange)="dnsPass.set(undefined)"
|
||||
/>
|
||||
{{ 'Dynamic DNS' | i18n }}
|
||||
</label>
|
||||
}
|
||||
|
||||
<table [appTable]="['Type', 'Host', 'Value', 'Purpose']">
|
||||
@for (row of rows(); track $index) {
|
||||
<tr>
|
||||
<td>
|
||||
@if (pass() === true) {
|
||||
<tui-icon class="g-positive" icon="@tui.check" />
|
||||
} @else if (pass() === false) {
|
||||
<tui-icon class="g-negative" icon="@tui.x" />
|
||||
}
|
||||
{{ ddns ? 'ALIAS' : 'A' }}
|
||||
</td>
|
||||
<td>{{ row.host }}</td>
|
||||
<td>{{ ddns ? '[DDNS Address]' : wanIp }}</td>
|
||||
<td>{{ row.purpose }}</td>
|
||||
</tr>
|
||||
}
|
||||
<table [appTable]="[null, 'Type', 'Host', 'Value', null]">
|
||||
<tr>
|
||||
<td class="status">
|
||||
@if (dnsPass() === true) {
|
||||
<tui-icon class="g-positive" icon="@tui.check" />
|
||||
} @else if (dnsPass() === false) {
|
||||
<tui-icon class="g-negative" icon="@tui.x" />
|
||||
}
|
||||
</td>
|
||||
<td>{{ ddns ? 'ALIAS' : 'A' }}</td>
|
||||
<td>*</td>
|
||||
<td>{{ ddns ? '[DDNS Address]' : wanIp }}</td>
|
||||
<td>
|
||||
<button
|
||||
tuiButton
|
||||
size="s"
|
||||
[loading]="dnsLoading()"
|
||||
(click)="testDns()"
|
||||
>
|
||||
{{ 'Test' | i18n }}
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
<h3>{{ 'Port Forwarding' | i18n }}</h3>
|
||||
<p>
|
||||
{{ 'In your gateway' | i18n }} "{{ gatewayName }}",
|
||||
{{ 'create this port forwarding rule' | i18n }}
|
||||
</p>
|
||||
|
||||
<table
|
||||
[appTable]="[null, 'External Port', 'Internal IP', 'Internal Port', null]"
|
||||
>
|
||||
<tr>
|
||||
<td class="status">
|
||||
@if (portPass() === true) {
|
||||
<tui-icon class="g-positive" icon="@tui.check" />
|
||||
} @else if (portPass() === false) {
|
||||
<tui-icon class="g-negative" icon="@tui.x" />
|
||||
}
|
||||
</td>
|
||||
<td>{{ context.data.port }}</td>
|
||||
<td>{{ internalIp }}</td>
|
||||
<td>{{ context.data.port }}</td>
|
||||
<td>
|
||||
<button
|
||||
tuiButton
|
||||
size="s"
|
||||
[loading]="portLoading()"
|
||||
(click)="testPort()"
|
||||
>
|
||||
{{ 'Test' | i18n }}
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
<footer class="g-buttons">
|
||||
<button tuiButton [loading]="loading()" (click)="testDns()">
|
||||
{{ 'Test' | i18n }}
|
||||
<button
|
||||
tuiButton
|
||||
appearance="flat"
|
||||
[disabled]="allPass()"
|
||||
(click)="context.completeWith()"
|
||||
>
|
||||
{{ 'Later' | i18n }}
|
||||
</button>
|
||||
<button
|
||||
tuiButton
|
||||
[disabled]="!allPass()"
|
||||
(click)="context.completeWith()"
|
||||
>
|
||||
{{ 'Done' | i18n }}
|
||||
</button>
|
||||
</footer>
|
||||
`,
|
||||
@@ -76,14 +144,57 @@ export type DnsGateway = T.NetworkInterfaceInfo & {
|
||||
margin: 1rem 0;
|
||||
}
|
||||
|
||||
h3 {
|
||||
margin: 1.5rem 0 0.5rem;
|
||||
|
||||
&:first-child {
|
||||
margin-top: 0;
|
||||
}
|
||||
}
|
||||
|
||||
tui-icon {
|
||||
font-size: 1rem;
|
||||
vertical-align: text-bottom;
|
||||
}
|
||||
|
||||
.status {
|
||||
width: 1.5rem;
|
||||
}
|
||||
|
||||
td:last-child {
|
||||
text-align: end;
|
||||
}
|
||||
|
||||
footer {
|
||||
margin-top: 1.5rem;
|
||||
}
|
||||
|
||||
:host-context(tui-root._mobile) table {
|
||||
thead {
|
||||
display: table-header-group !important;
|
||||
}
|
||||
|
||||
tr {
|
||||
display: table-row !important;
|
||||
box-shadow: none !important;
|
||||
}
|
||||
|
||||
td,
|
||||
th {
|
||||
padding: 0.5rem 0.5rem !important;
|
||||
font: var(--tui-font-text-s) !important;
|
||||
color: var(--tui-text-primary) !important;
|
||||
font-weight: normal !important;
|
||||
}
|
||||
|
||||
th {
|
||||
font-weight: bold !important;
|
||||
}
|
||||
}
|
||||
`,
|
||||
providers: [
|
||||
tuiSwitchOptionsProvider({
|
||||
appearance: () => 'primary',
|
||||
appearance: () => 'glass',
|
||||
icon: () => '',
|
||||
}),
|
||||
],
|
||||
@@ -98,75 +209,61 @@ export type DnsGateway = T.NetworkInterfaceInfo & {
|
||||
TuiIcon,
|
||||
],
|
||||
})
|
||||
export class DnsComponent {
|
||||
export class DomainValidationComponent {
|
||||
private readonly errorService = inject(ErrorService)
|
||||
private readonly api = inject(ApiService)
|
||||
private readonly i18n = inject(i18nPipe)
|
||||
|
||||
readonly ddns = false
|
||||
|
||||
readonly context =
|
||||
injectContext<
|
||||
TuiDialogContext<
|
||||
void,
|
||||
{ fqdn: string; gateway: DnsGateway; message: string }
|
||||
>
|
||||
>()
|
||||
injectContext<TuiDialogContext<void, DomainValidationData>>()
|
||||
|
||||
readonly loading = signal(false)
|
||||
readonly pass = signal<boolean | undefined>(undefined)
|
||||
readonly domain =
|
||||
parse(this.context.data.fqdn).domain || this.context.data.fqdn
|
||||
|
||||
readonly rows = computed<{ host: string; purpose: string }[]>(() => {
|
||||
const { domain, subdomain } = parse(this.context.data.fqdn)
|
||||
readonly dnsLoading = signal(false)
|
||||
readonly portLoading = signal(false)
|
||||
readonly dnsPass = signal<boolean | undefined>(this.context.data.dnsPass)
|
||||
readonly portPass = signal<boolean | undefined>(this.context.data.portPass)
|
||||
|
||||
if (!subdomain) {
|
||||
return [
|
||||
{
|
||||
host: '@',
|
||||
purpose: domain!,
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
const segments = subdomain.split('.').slice(1)
|
||||
|
||||
const subdomains = this.i18n.transform('all subdomains of')
|
||||
|
||||
return [
|
||||
{
|
||||
host: subdomain,
|
||||
purpose: `only ${subdomain}`,
|
||||
},
|
||||
...segments.map((_, i) => {
|
||||
const parent = segments.slice(i).join('.')
|
||||
return {
|
||||
host: `*.${parent}`,
|
||||
purpose: `${subdomains} ${parent}`,
|
||||
}
|
||||
}),
|
||||
{
|
||||
host: '*',
|
||||
purpose: `${subdomains} ${domain}`,
|
||||
},
|
||||
]
|
||||
})
|
||||
readonly allPass = computed(
|
||||
() => this.dnsPass() === true && this.portPass() === true,
|
||||
)
|
||||
|
||||
async testDns() {
|
||||
this.pass.set(undefined)
|
||||
this.loading.set(true)
|
||||
this.dnsLoading.set(true)
|
||||
|
||||
try {
|
||||
const ip = await this.api.queryDns({
|
||||
fqdn: this.context.data.fqdn,
|
||||
})
|
||||
|
||||
this.pass.set(ip === this.context.data.gateway.ipInfo.wanIp)
|
||||
this.dnsPass.set(ip === this.context.data.gateway.ipInfo.wanIp)
|
||||
} catch (e: any) {
|
||||
this.errorService.handleError(e)
|
||||
} finally {
|
||||
this.loading.set(false)
|
||||
this.dnsLoading.set(false)
|
||||
}
|
||||
}
|
||||
|
||||
async testPort() {
|
||||
this.portLoading.set(true)
|
||||
|
||||
try {
|
||||
const result = await this.api.testPortForward({
|
||||
gateway: this.context.data.gateway.id,
|
||||
port: this.context.data.port,
|
||||
})
|
||||
|
||||
this.portPass.set(result)
|
||||
} catch (e: any) {
|
||||
this.errorService.handleError(e)
|
||||
} finally {
|
||||
this.portLoading.set(false)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const DNS = new PolymorpheusComponent(DnsComponent)
|
||||
export const DOMAIN_VALIDATION = new PolymorpheusComponent(
|
||||
DomainValidationComponent,
|
||||
)
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { ChangeDetectionStrategy, Component, Input } from '@angular/core'
|
||||
import { ChangeDetectionStrategy, Component, input } from '@angular/core'
|
||||
import { RouterLink } from '@angular/router'
|
||||
import { T } from '@start9labs/start-sdk'
|
||||
import { TuiButton } from '@taiga-ui/core'
|
||||
import { TuiBadge } from '@taiga-ui/kit'
|
||||
|
||||
@Component({
|
||||
@@ -7,21 +9,26 @@ import { TuiBadge } from '@taiga-ui/kit'
|
||||
template: `
|
||||
<td><ng-content /></td>
|
||||
<td>
|
||||
<tui-badge size="m" [appearance]="appearance">{{ info.type }}</tui-badge>
|
||||
<tui-badge size="m" [appearance]="appearance">
|
||||
{{ info().type }}
|
||||
</tui-badge>
|
||||
</td>
|
||||
<td class="g-secondary" [style.grid-area]="'2 / 1 / 2 / 3'">
|
||||
{{ info.description }}
|
||||
{{ info().description }}
|
||||
</td>
|
||||
<td class="actions">
|
||||
<a
|
||||
tuiIconButton
|
||||
appearance="flat-grayscale"
|
||||
iconStart="@tui.settings"
|
||||
size="s"
|
||||
[routerLink]="link()"
|
||||
>
|
||||
Settings
|
||||
</a>
|
||||
</td>
|
||||
`,
|
||||
styles: `
|
||||
:host {
|
||||
cursor: pointer;
|
||||
|
||||
&:hover {
|
||||
background: var(--tui-background-neutral-1);
|
||||
}
|
||||
}
|
||||
|
||||
td:first-child {
|
||||
white-space: nowrap;
|
||||
}
|
||||
@@ -35,9 +42,13 @@ import { TuiBadge } from '@taiga-ui/kit'
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.actions {
|
||||
text-align: end;
|
||||
}
|
||||
|
||||
:host-context(tui-root._mobile) {
|
||||
display: grid;
|
||||
grid-template-columns: min-content;
|
||||
grid-template-columns: 1fr auto;
|
||||
align-items: center;
|
||||
padding: 1rem 0.5rem;
|
||||
gap: 0.5rem;
|
||||
@@ -45,17 +56,21 @@ import { TuiBadge } from '@taiga-ui/kit'
|
||||
td {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.actions {
|
||||
grid-area: 1 / 2 / 3 / 3;
|
||||
}
|
||||
}
|
||||
`,
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
imports: [TuiBadge],
|
||||
imports: [TuiBadge, TuiButton, RouterLink],
|
||||
})
|
||||
export class ServiceInterfaceItemComponent {
|
||||
@Input({ required: true })
|
||||
info!: T.ServiceInterface
|
||||
readonly info = input.required<T.ServiceInterface>()
|
||||
readonly link = input.required<string>()
|
||||
|
||||
get appearance(): string {
|
||||
switch (this.info.type) {
|
||||
switch (this.info().type) {
|
||||
case 'ui':
|
||||
return 'positive'
|
||||
case 'api':
|
||||
|
||||
@@ -4,7 +4,6 @@ import {
|
||||
computed,
|
||||
input,
|
||||
} from '@angular/core'
|
||||
import { RouterLink } from '@angular/router'
|
||||
import { TuiTable } from '@taiga-ui/addon-table'
|
||||
import { tuiDefaultSort } from '@taiga-ui/cdk'
|
||||
import { PackageDataEntry } from 'src/app/services/patch-db/data-model'
|
||||
@@ -22,19 +21,13 @@ import { PlaceholderComponent } from '../../../components/placeholder.component'
|
||||
<th tuiTh>{{ 'Name' | i18n }}</th>
|
||||
<th tuiTh>{{ 'Type' | i18n }}</th>
|
||||
<th tuiTh>{{ 'Description' | i18n }}</th>
|
||||
<th tuiTh></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@for (info of interfaces(); track $index) {
|
||||
<tr
|
||||
tabindex="-1"
|
||||
serviceInterface
|
||||
[info]="info"
|
||||
[routerLink]="info.routerLink"
|
||||
>
|
||||
<a [routerLink]="info.routerLink">
|
||||
<strong>{{ info.name }}</strong>
|
||||
</a>
|
||||
<tr serviceInterface [info]="info" [link]="info.routerLink">
|
||||
<strong>{{ info.name }}</strong>
|
||||
</tr>
|
||||
} @empty {
|
||||
<app-placeholder icon="@tui.monitor-x">
|
||||
@@ -56,7 +49,6 @@ import { PlaceholderComponent } from '../../../components/placeholder.component'
|
||||
TuiTable,
|
||||
i18nPipe,
|
||||
PlaceholderComponent,
|
||||
RouterLink,
|
||||
],
|
||||
})
|
||||
export class ServiceInterfacesComponent {
|
||||
|
||||
@@ -7,7 +7,7 @@ import { AuthorityService } from './authority.service'
|
||||
selector: 'authorities-table',
|
||||
template: `
|
||||
<table [appTable]="['Provider', 'URL', 'Contact', null]">
|
||||
<tr [authority]="{ name: 'Local Root CA' }"></tr>
|
||||
<tr [authority]="{ name: 'Root CA' }"></tr>
|
||||
@for (authority of authorityService.authorities(); track $index) {
|
||||
<tr [authority]="authority"></tr>
|
||||
}
|
||||
|
||||
@@ -120,6 +120,12 @@ export namespace RR {
|
||||
} // net.dns.query
|
||||
export type QueryDnsRes = string | null
|
||||
|
||||
export type TestPortForwardReq = {
|
||||
gateway: string
|
||||
port: number
|
||||
} // net.port-forward.test
|
||||
export type TestPortForwardRes = boolean
|
||||
|
||||
export type SetKeyboardReq = FullKeyboard // server.set-keyboard
|
||||
export type SetKeyboardRes = null
|
||||
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
import { MarketplacePkg } from '@start9labs/marketplace'
|
||||
import { T } from '@start9labs/start-sdk'
|
||||
import { RR } from './api.types'
|
||||
import { WebSocketSubject } from 'rxjs/webSocket'
|
||||
|
||||
@@ -119,6 +117,10 @@ export abstract class ApiService {
|
||||
|
||||
abstract queryDns(params: RR.QueryDnsReq): Promise<RR.QueryDnsRes>
|
||||
|
||||
abstract testPortForward(
|
||||
params: RR.TestPortForwardReq,
|
||||
): Promise<RR.TestPortForwardRes>
|
||||
|
||||
// smtp
|
||||
|
||||
abstract setSmtp(params: RR.SetSMTPReq): Promise<RR.SetSMTPRes>
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { DOCUMENT, Inject, Injectable } from '@angular/core'
|
||||
import { blake3 } from '@noble/hashes/blake3'
|
||||
import { MarketplacePkg } from '@start9labs/marketplace'
|
||||
import {
|
||||
HttpOptions,
|
||||
HttpService,
|
||||
@@ -8,7 +7,6 @@ import {
|
||||
RpcError,
|
||||
RPCOptions,
|
||||
} from '@start9labs/shared'
|
||||
import { T } from '@start9labs/start-sdk'
|
||||
import { Dump, pathFromArray } from 'patch-db-client'
|
||||
import { filter, firstValueFrom, Observable } from 'rxjs'
|
||||
import { webSocket, WebSocketSubject } from 'rxjs/webSocket'
|
||||
@@ -268,6 +266,15 @@ export class LiveApiService extends ApiService {
|
||||
})
|
||||
}
|
||||
|
||||
async testPortForward(
|
||||
params: RR.TestPortForwardReq,
|
||||
): Promise<RR.TestPortForwardRes> {
|
||||
return this.rpcRequest({
|
||||
method: 'net.port-forward.test',
|
||||
params,
|
||||
})
|
||||
}
|
||||
|
||||
// marketplace URLs
|
||||
|
||||
async checkOSUpdate(
|
||||
@@ -358,7 +365,10 @@ export class LiveApiService extends ApiService {
|
||||
async setDefaultOutbound(
|
||||
params: RR.SetDefaultOutboundReq,
|
||||
): Promise<RR.SetDefaultOutboundRes> {
|
||||
return this.rpcRequest({ method: 'net.gateway.set-default-outbound', params })
|
||||
return this.rpcRequest({
|
||||
method: 'net.gateway.set-default-outbound',
|
||||
params,
|
||||
})
|
||||
}
|
||||
|
||||
async setServiceOutbound(
|
||||
|
||||
@@ -483,6 +483,14 @@ export class MockApiService extends ApiService {
|
||||
return null
|
||||
}
|
||||
|
||||
async testPortForward(
|
||||
params: RR.TestPortForwardReq,
|
||||
): Promise<RR.TestPortForwardRes> {
|
||||
await pauseFor(2000)
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
// marketplace URLs
|
||||
|
||||
async checkOSUpdate(
|
||||
@@ -589,7 +597,7 @@ export class MockApiService extends ApiService {
|
||||
]
|
||||
|
||||
if (params.setAsDefaultOutbound) {
|
||||
;(patch as any[]).push({
|
||||
(patch as any[]).push({
|
||||
op: PatchOp.REPLACE,
|
||||
path: '/serverInfo/network/defaultOutbound',
|
||||
value: id,
|
||||
@@ -1394,7 +1402,9 @@ export class MockApiService extends ApiService {
|
||||
): Promise<RR.ServerBindingSetAddressEnabledRes> {
|
||||
await pauseFor(2000)
|
||||
|
||||
// Mock: no-op since address enable/disable modifies DerivedAddressInfo sets
|
||||
const basePath = `/serverInfo/network/host/bindings/${params.internalPort}/addresses`
|
||||
this.mockSetAddressEnabled(basePath, params.address, params.enabled)
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
@@ -1493,7 +1503,9 @@ export class MockApiService extends ApiService {
|
||||
): Promise<RR.PkgBindingSetAddressEnabledRes> {
|
||||
await pauseFor(2000)
|
||||
|
||||
// Mock: no-op since address enable/disable modifies DerivedAddressInfo sets
|
||||
const basePath = `/packageData/${params.package}/hosts/${params.host}/bindings/${params.internalPort}/addresses`
|
||||
this.mockSetAddressEnabled(basePath, params.address, params.enabled)
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
@@ -1813,6 +1825,63 @@ export class MockApiService extends ApiService {
|
||||
}, 1000)
|
||||
}
|
||||
|
||||
private mockSetAddressEnabled(
|
||||
basePath: string,
|
||||
addressJson: string,
|
||||
enabled: boolean | null,
|
||||
): void {
|
||||
const h: T.HostnameInfo = JSON.parse(addressJson)
|
||||
const isPublicIp =
|
||||
h.public && (h.metadata.kind === 'ipv4' || h.metadata.kind === 'ipv6')
|
||||
|
||||
const current = this.mockData(basePath) as T.DerivedAddressInfo
|
||||
|
||||
if (isPublicIp) {
|
||||
if (h.port === null) return
|
||||
const sa =
|
||||
h.metadata.kind === 'ipv6'
|
||||
? `[${h.host}]:${h.port}`
|
||||
: `${h.host}:${h.port}`
|
||||
|
||||
const arr = [...current.enabled]
|
||||
|
||||
if (enabled) {
|
||||
if (!arr.includes(sa)) arr.push(sa)
|
||||
} else {
|
||||
const idx = arr.indexOf(sa)
|
||||
if (idx >= 0) arr.splice(idx, 1)
|
||||
}
|
||||
|
||||
current.enabled = arr
|
||||
this.mockRevision([
|
||||
{ op: PatchOp.REPLACE, path: `${basePath}/enabled`, value: arr },
|
||||
])
|
||||
} else {
|
||||
const port = h.port ?? 0
|
||||
const arr = current.disabled.filter(
|
||||
([dHost, dPort]) => !(dHost === h.host && dPort === port),
|
||||
)
|
||||
|
||||
if (!enabled) {
|
||||
arr.push([h.host, port])
|
||||
}
|
||||
|
||||
current.disabled = arr
|
||||
this.mockRevision([
|
||||
{ op: PatchOp.REPLACE, path: `${basePath}/disabled`, value: arr },
|
||||
])
|
||||
}
|
||||
}
|
||||
|
||||
private mockData(path: string): any {
|
||||
const parts = path.split('/').filter(Boolean)
|
||||
let obj: any = mockPatchData
|
||||
for (const part of parts) {
|
||||
obj = obj[part]
|
||||
}
|
||||
return obj
|
||||
}
|
||||
|
||||
private async mockRevision<T>(patch: Operation<T>[]): Promise<void> {
|
||||
const revision = {
|
||||
id: ++this.sequence,
|
||||
|
||||
@@ -588,9 +588,13 @@ export const mockPatchData: DataModel = {
|
||||
],
|
||||
},
|
||||
options: {
|
||||
addSsl: null,
|
||||
preferredExternalPort: 443,
|
||||
secure: { ssl: true },
|
||||
addSsl: {
|
||||
preferredExternalPort: 443,
|
||||
alpn: { specified: ['http/1.1', 'h2'] },
|
||||
addXForwardedHeaders: false,
|
||||
},
|
||||
secure: null,
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
export function toAuthorityName(
|
||||
url: string | null,
|
||||
addSsl = true,
|
||||
): string | 'Local Root CA' | '-' {
|
||||
): string | 'Root CA' | '-' {
|
||||
if (url) {
|
||||
return knownAuthorities.find(ca => ca.url === url)?.name || url
|
||||
} else {
|
||||
return addSsl ? 'Local Root CA' : '-'
|
||||
return addSsl ? 'Root CA' : '-'
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user