looking good

This commit is contained in:
Matt Hill
2026-02-14 16:37:04 -07:00
parent 3a63f3b840
commit 2f19188dae
22 changed files with 4009 additions and 6738 deletions

9869
web/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -553,33 +553,8 @@ export default {
580: 'Aktualisierung erforderlich',
581: 'Ihre Benutzeroberfläche ist zwischengespeichert und veraltet. Versuchen Sie, die PWA mit der Schaltfläche unten neu zu laden. Wenn Sie diese Nachricht weiterhin sehen, deinstallieren und installieren Sie die PWA erneut.',
582: 'Ihre Benutzeroberfläche ist zwischengespeichert und veraltet. Führen Sie einen Hard-Refresh der Seite durch, um die neueste Benutzeroberfläche zu erhalten.',
583: 'Erfordert Vertrauen in die Root-CA Ihres Servers',
584: 'Verbindungen können manchmal langsam oder unzuverlässig sein',
585: 'Öffentlich, wenn Sie die Adresse öffentlich teilen, andernfalls privat',
586: 'Erfordert ein Tor-fähiges Gerät oder einen Browser',
587: 'Sollte nur für Apps benötigt werden, die SSL erzwingen',
588: 'Ideal für anonyme, zensurresistente Bereitstellung und Fernzugriff',
589: 'Ideal für lokalen Zugriff',
590: 'Erfordert die Verbindung mit demselben lokalen Netzwerk (LAN) wie Ihr Server, entweder physisch oder über VPN',
591: 'Erfordert die Einstellung einer statischen IP-Adresse für',
592: 'Ideal für VPN-Zugriff über',
593: 'in Ihrem Gateway',
594: 'der Wireguard-Server Ihres Routers',
595: 'Erfordert Portweiterleitung im Gateway',
596: 'Erfordert einen DNS-Eintrag für',
597: 'der sich auflöst zu',
598: 'Nicht empfohlen für VPN-Zugriff. VPNs unterstützen keine „.local“-Domains ohne erweiterte Konfiguration',
599: 'Kann für Clearnet-Zugriff verwendet werden',
600: 'In den meisten Fällen nicht empfohlen. Öffentliche Domains sind üblicher und werden bevorzugt',
601: 'Lokal',
602: 'Kann für lokalen Zugriff verwendet werden',
603: 'Ideal für öffentlichen Zugriff über das Internet',
604: 'Kann für persönlichen Zugriff über das öffentliche Internet verwendet werden, aber ein VPN ist privater und sicherer',
605: 'wenn die Verwendung von IP-Adressen und Ports unerwünscht ist',
606: 'Host',
607: 'Wert',
608: 'Zweck',
609: 'alle Subdomains von',
610: 'Dynamisches DNS',
611: 'Keine Service-Schnittstellen',
612: 'Grund',
@@ -697,4 +672,6 @@ export default {
732: '',
733: '',
734: '',
735: '',
736: '',
} satisfies i18n

View File

@@ -552,33 +552,8 @@ export const ENGLISH: Record<string, number> = {
'Refresh Needed': 580,
'Your user interface is cached and out of date. Attempt to reload the PWA using the button below. If you continue to see this message, uninstall and reinstall the PWA.': 581,
'Your user interface is cached and out of date. Hard refresh the page to get the latest UI.': 582,
"Requires trusting your server's Root CA": 583,
'Connections can be slow or unreliable at times': 584,
'Public if you share the address publicly, otherwise private': 585,
'Requires using a Tor-enabled device or browser': 586,
'Should only needed for apps that enforce SSL': 587,
'Ideal for anonymous, censorship-resistant hosting and remote access': 588,
'Ideal for local access': 589,
'Requires being connected to the same Local Area Network (LAN) as your server, either physically or via VPN': 590,
'Requires setting a static IP address for': 591, // this is a partial sentence. An IP address will be added after "for" to complete the sentence.
'Ideal for VPN access via': 592, // this is a partial sentence. A connection medium will be added after "via" to complete the sentence.
'in your gateway': 593, // this is a partial sentence. It is preceded by an instruction: e.g. "do something" in your gateway. Gateway refers to a router or VPN server.
"your router's WireGuard server": 594, // this is a partial sentence. It is preceded by "ideal for access via"
'Requires port forwarding in gateway': 595,
'Requires a DNS record for': 596, // this is a partial sentence. A domain name will be added after "for" to complete the sentence.
'that resolves to': 597, // this is a partial sentence. It is preceded by "requires a DNS record for [domain] "
'Not recommended for VPN access. VPNs do not support ".local" domains without advanced configuration': 598,
'Can be used for clearnet access': 599,
'Not recommended in most cases. Using a public domain is more common and preferred': 600,
'Local': 601, // as in, not remote
'Can be used for local access': 602,
'Ideal for public access via the Internet': 603,
'Can be used for personal access via the public Internet, but a VPN is more private and secure': 604,
'when using IP addresses and ports is undesirable': 605, // this is a partial sentence. It is preceded by "Good for connections "
'Host': 606, // as in, a network host
'Value': 607, // as in, the value in a column of a table
'Purpose': 608, // as in, the reason for a thing to exist
'all subdomains of': 609, // this is a partial sentence. A domain name will be added after "of" to complete the sentence.
'Dynamic DNS': 610,
'No service interfaces': 611, // as in, there are no available interfaces (API, UI, etc) for this software application
'Reason': 612, // as in, an explanation for something
@@ -696,5 +671,18 @@ export const ENGLISH: Record<string, number> = {
'Public Domain': 731,
'Private Domain': 732,
'Hide': 733,
'default outbound': 734
'default outbound': 734,
'Certificate': 735,
'Self signed': 736,
'Port Forwarding': 737,
'Domain Setup': 738,
'DNS': 739,
'Instructions': 740,
'In your domain registrar for': 741, // partial sentence, followed by a domain name
'create this DNS record': 742,
'In your gateway': 743, // partial sentence, followed by a gateway name
'create this port forwarding rule': 744,
'External Port': 745,
'Internal IP': 746,
'Internal Port': 747,
}

View File

@@ -553,33 +553,8 @@ export default {
580: 'Actualización necesaria',
581: 'Tu interfaz de usuario está en caché y desactualizada. Intenta recargar la PWA usando el botón de abajo. Si sigues viendo este mensaje, desinstala y vuelve a instalar la PWA.',
582: 'Tu interfaz de usuario está en caché y desactualizada. Haz un hard refresh de la página para obtener la última interfaz.',
583: 'Requiere confiar en la CA raíz de tu servidor',
584: 'Las conexiones pueden ser lentas o poco confiables a veces',
585: 'Público si compartes la dirección públicamente, de lo contrario privado',
586: 'Requiere un dispositivo o navegador habilitado para Tor',
587: 'Solo debería ser necesario para aplicaciones que imponen SSL',
588: 'Ideal para alojamiento y acceso remoto anónimo y resistente a la censura',
589: 'Ideal para acceso local',
590: 'Requiere estar conectado a la misma red de área local (LAN) que tu servidor, ya sea físicamente o mediante VPN',
591: 'Requiere configurar una dirección IP estática para',
592: 'Ideal para acceso VPN a través de',
593: 'en tu gateway',
594: 'el servidor Wireguard de tu router',
595: 'Requiere reenvío de puertos en el gateway',
596: 'Requiere un registro DNS para',
597: 'que se resuelva en',
598: 'No recomendado para acceso VPN. Las VPN no admiten dominios “.local” sin configuración avanzada',
599: 'Se puede usar para acceso a clearnet',
600: 'No recomendado en la mayoría de los casos. Los dominios públicos son más comunes y preferidos',
601: 'Local',
602: 'Se puede usar para acceso local',
603: 'Ideal para acceso público a través de Internet',
604: 'Puede usarse para acceso personal a través de Internet público, pero una VPN es más privada y segura',
605: 'cuando el uso de direcciones IP y puertos no es deseable',
606: 'Host',
607: 'Valor',
608: 'Propósito',
609: 'todos los subdominios de',
610: 'DNS dinámico',
611: 'Sin interfaces de servicio',
612: 'Razón',
@@ -697,4 +672,6 @@ export default {
732: '',
733: '',
734: '',
735: '',
736: '',
} satisfies i18n

View File

@@ -553,33 +553,8 @@ export default {
580: 'Actualisation nécessaire',
581: 'Votre interface utilisateur est mise en cache et obsolète. Essayez de recharger le PWA à laide du bouton ci-dessous. Si vous continuez à voir ce message, désinstallez puis réinstallez le PWA.',
582: 'Votre interface utilisateur est mise en cache et obsolète. Faites un rafraîchissement forcé de la page pour obtenir la dernière interface.',
583: 'Nécessite de faire confiance à lautorité de certification racine de votre serveur',
584: 'Les connexions peuvent parfois être lentes ou peu fiables',
585: 'Public si vous partagez ladresse publiquement, sinon privé',
586: 'Nécessite un appareil ou un navigateur compatible Tor',
587: 'Ne devrait être nécessaire que pour les applications qui imposent SSL',
588: 'Idéal pour lhébergement et laccès à distance anonymes et résistants à la censure',
589: 'Idéal pour un accès local',
590: 'Nécessite dêtre connecté au même réseau local (LAN) que votre serveur, soit physiquement, soit via VPN',
591: 'Nécessite de définir une adresse IP statique pour',
592: 'Idéal pour un accès VPN via',
593: 'dans votre passerelle',
594: 'le serveur Wireguard de votre routeur',
595: 'Nécessite un transfert de port dans la passerelle',
596: 'Nécessite un enregistrement DNS pour',
597: 'qui se résout en',
598: 'Non recommandé pour laccès VPN. Les VPN ne prennent pas en charge les domaines « .local » sans configuration avancée',
599: 'Peut être utilisé pour un accès clearnet',
600: 'Non recommandé dans la plupart des cas. Les domaines publics sont plus courants et préférés',
601: 'Local',
602: 'Peut être utilisé pour un accès local',
603: 'Idéal pour un accès public via Internet',
604: 'Peut être utilisé pour un accès personnel via lInternet public, mais un VPN est plus privé et plus sécurisé',
605: 'lorsque lutilisation des adresses IP et des ports est indésirable',
606: 'Hôte',
607: 'Valeur',
608: 'But',
609: 'tous les sous-domaines de',
610: 'DNS dynamique',
611: 'Aucune interface de service',
612: 'Raison',
@@ -697,4 +672,6 @@ export default {
732: '',
733: '',
734: '',
735: '',
736: '',
} satisfies i18n

View File

@@ -553,33 +553,8 @@ export default {
580: 'Wymagane odświeżenie',
581: 'Twój interfejs użytkownika jest w pamięci podręcznej i jest nieaktualny. Spróbuj ponownie załadować PWA za pomocą przycisku poniżej. Jeśli nadal widzisz ten komunikat, odinstaluj i ponownie zainstaluj PWA.',
582: 'Twój interfejs użytkownika jest w pamięci podręcznej i jest nieaktualny. Wykonaj twarde odświeżenie strony, aby uzyskać najnowszy interfejs.',
583: 'Wymaga zaufania do głównego CA twojego serwera',
584: 'Połączenia mogą być czasami wolne lub niestabilne',
585: 'Publiczne, jeśli udostępniasz adres publicznie, w przeciwnym razie prywatne',
586: 'Wymaga urządzenia lub przeglądarki obsługującej Tor',
587: 'Powinno być wymagane tylko dla aplikacji wymuszających SSL',
588: 'Idealne do anonimowego, odpornego na cenzurę hostingu i zdalnego dostępu',
589: 'Idealne do dostępu lokalnego',
590: 'Wymaga połączenia z tą samą siecią lokalną (LAN) co serwer, fizycznie lub przez VPN',
591: 'Wymaga ustawienia statycznego adresu IP dla',
592: 'Idealne do dostępu VPN przez',
593: 'w twojej bramie',
594: 'serwer Wireguard twojego routera',
595: 'Wymaga przekierowania portów w bramie',
596: 'Wymaga rekordu DNS dla',
597: 'który rozwiązuje się na',
598: 'Niezalecane do dostępu VPN. VPN-y nie obsługują domen „.local” bez zaawansowanej konfiguracji',
599: 'Może być używane do dostępu do clearnet',
600: 'Niezalecane w większości przypadków. Domeny publiczne są bardziej powszechne i preferowane',
601: 'Lokalne',
602: 'Może być używane do dostępu lokalnego',
603: 'Idealne do publicznego dostępu przez Internet',
604: 'Może być używane do osobistego dostępu przez publiczny Internet, ale VPN jest bardziej prywatny i bezpieczny',
605: 'gdy używanie adresów IP i portów jest niepożądane',
606: 'Host',
607: 'Wartość',
608: 'Cel',
609: 'wszystkie subdomeny',
610: 'Dynamiczny DNS',
611: 'Brak interfejsów usług',
612: 'Powód',
@@ -697,4 +672,6 @@ export default {
732: '',
733: '',
734: '',
735: '',
736: '',
} satisfies i18n

View File

@@ -118,7 +118,7 @@ import { MappedDevice, PortForwardsData } from './utils'
@if (show80) {
<label tuiLabel>
<input tuiCheckbox type="checkbox" formControlName="also80" />
Also forward port 80 to port 5443? This is needed for HTTP to HTTPS
Also forward port 80 to port 443? This is needed for HTTP to HTTPS
redirects (recommended)
</label>
}
@@ -173,7 +173,7 @@ export class PortForwardsAdd {
protected checkShow80() {
const { externalport, internalport } = this.form.getRawValue()
this.show80 = externalport === 443 && internalport === 5443
this.show80 = externalport === 443 && internalport === 443
}
protected async onSave() {
@@ -194,10 +194,10 @@ export class PortForwardsAdd {
target: `${device!.ip}:${internalport}`,
})
if (externalport === 443 && internalport === 5443 && also80) {
if (externalport === 443 && internalport === 443 && also80) {
await this.api.addForward({
source: `${externalip}:80`,
target: `${device!.ip}:5443`,
target: `${device!.ip}:443`,
})
}
} catch (e: any) {

View File

@@ -39,7 +39,7 @@ export const mockTunnelData: TunnelData = {
},
},
portForwards: {
'69.1.1.42:443': '10.59.0.2:5443',
'69.1.1.42:443': '10.59.0.2:443',
'69.1.1.42:3000': '10.59.0.2:3000',
},
gateways: {

View File

@@ -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

View File

@@ -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()
}

View File

@@ -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)
}
}
}

View File

@@ -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 = {

View File

@@ -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,
)

View File

@@ -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':

View File

@@ -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 {

View File

@@ -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>
}

View File

@@ -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

View File

@@ -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>

View File

@@ -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(

View File

@@ -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,

View File

@@ -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,
},
},
},

View File

@@ -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' : '-'
}
}