mirror of
https://github.com/Start9Labs/start-os.git
synced 2026-03-26 02:11:53 +00:00
looking good
This commit is contained in:
9869
web/package-lock.json
generated
9869
web/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -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
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 à l’aide 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 à l’autorité de certification racine de votre serveur',
|
||||
584: 'Les connexions peuvent parfois être lentes ou peu fiables',
|
||||
585: 'Public si vous partagez l’adresse 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 l’hébergement et l’accè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 l’accè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 l’Internet public, mais un VPN est plus privé et plus sécurisé',
|
||||
605: 'lorsque l’utilisation 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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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