more translations

This commit is contained in:
Matt Hill
2025-08-20 11:45:17 -06:00
parent 931505ff08
commit d564471825
16 changed files with 576 additions and 281 deletions

View File

@@ -524,4 +524,55 @@ export default {
557: 'Keine privaten Domains',
558: 'Neue private Domain',
559: 'DNS-Server',
560: 'Geben Sie einen vollständig qualifizierten Domainnamen ein. Da die Domain für private Zwecke verwendet wird, kann es jede gewünschte Domain sein, auch eine, die Sie nicht kontrollieren.',
561: 'Geben Sie einen vollständig qualifizierten Domainnamen ein. Wenn Sie beispielsweise domain.com kontrollieren, könnten Sie domain.com oder subdomain.domain.com oder another.subdomain.domain.com eingeben.',
562: 'DNS-Einträge',
563: 'Erstellen Sie einen der unten aufgeführten DNS-Einträge.',
564: 'Kein DNS-Eintrag erkannt für',
565: 'Ungültiger DNS-Eintrag',
566: 'löst auf in',
567: 'DNS-Eintrag erkannt!',
568: 'Wählen Sie ein Gateway für diese Domain aus.',
569: 'Wählen Sie eine Zertifizierungsstelle aus, um SSL/TLS-Zertifikate für diese Domain auszustellen.',
570: 'Andere',
571: 'Ein Name zur einfachen Identifizierung des Gateways',
572: 'Wählen Sie diese Option, wenn das Gateway für den privaten Zugriff nur für autorisierte Clients konfiguriert ist. StartTunnel ist ein privates Gateway.',
573: 'Wählen Sie diese Option, wenn das Gateway für uneingeschränkten öffentlichen Zugriff konfiguriert ist.',
574: 'Datei',
575: 'Wireguard-Konfigurationsdatei',
576: 'Kopieren/Einfügen',
577: 'Dateiinhalt',
578: 'Öffentlicher Schlüssel',
579: 'muss ein gültiger SSH-Öffentlicher Schlüssel sein',
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: 'Nur nützlich für Clients, die HTTPS 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. Clearnet-Domains 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. VPN ist privater und sicherer',
605: 'wenn die Verwendung von IP-Adressen und Ports unerwünscht ist',
606: 'Host',
607: 'Wert',
608: 'Zweck',
609: 'Subdomains von',
610: 'Dynamisches DNS',
} satisfies i18n

View File

@@ -523,4 +523,55 @@ export const ENGLISH = {
'No private domains': 557,
'New private domain': 558,
'DNS Servers': 559,
'Enter a fully qualified domain name. Since the domain is for private use, it can be any domain you want, even one you do not control.': 560,
'Enter a fully qualified domain name. For example, if you control domain.com, you could enter domain.com or subdomain.domain.com or another.subdomain.domain.com.': 561,
'DNS Records': 562,
'Create one of the DNS records below.': 563,
'No DNS record detected for': 564, // this is a partial sentence. A domain name will be added after "for" to complete the sentence.
'Invalid DNS record': 565,
'resolves to': 566, // as in "domain.com 'resolves to' [IP address]"
'DNS record detected!': 567,
'Select a gateway to use for this domain.': 568,
'Select a Certificate Authority to issue SSL/TLS certificates for this domain': 569,
'Other': 570, // as in, a list option to indicate none of the options listed
'A name to easily identify the gateway': 571,
'select this option if the gateway is configured for private access to authorized clients only. StartTunnel is a private gateway.': 572,
'select this option if the gateway is configured for unfettered public access.': 573,
'File': 574, // as in, a computer file
'Wireguard Config File': 575,
'Copy/Paste': 576,
'File Contents': 577,
'Public Key': 578, // as in, a cryptographic public key
'must be a valid SSH public key': 579,
'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,
'Only useful for clients that enforce HTTPS': 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. Clearnet domains are 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. 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
'subdomains of': 609, // this is a partial sentence. A domain name will be added after "of" to complete the sentence.
'Dynamic DNS': 610,
} as const

View File

@@ -524,4 +524,55 @@ export default {
557: 'Sin dominios privados',
558: 'Nuevo dominio privado',
559: 'Servidores DNS',
560: 'Introduce un nombre de dominio completo. Dado que el dominio es para uso privado, puede ser cualquier dominio que desees, incluso uno que no controles.',
561: 'Introduce un nombre de dominio completo. Por ejemplo, si controlas domain.com, podrías introducir domain.com o subdomain.domain.com o another.subdomain.domain.com.',
562: 'Registros DNS',
563: 'Crea uno de los registros DNS a continuación.',
564: 'No se detectó ningún registro DNS para',
565: 'Registro DNS inválido',
566: 'se resuelve en',
567: '¡Registro DNS detectado!',
568: 'Selecciona una puerta de enlace para usar con este dominio.',
569: 'Selecciona una Autoridad Certificadora para emitir certificados SSL/TLS para este dominio.',
570: 'Otro',
571: 'Un nombre para identificar fácilmente la puerta de enlace',
572: 'Selecciona esta opción si la puerta de enlace está configurada para acceso privado solo a clientes autorizados. StartTunnel es una puerta de enlace privada.',
573: 'Selecciona esta opción si la puerta de enlace está configurada para acceso público sin restricciones.',
574: 'Archivo',
575: 'Archivo de configuración de Wireguard',
576: 'Copiar/Pegar',
577: 'Contenido del archivo',
578: 'Clave pública',
579: 'debe ser una clave pública SSH válida',
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 útil para clientes que imponen HTTPS',
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. Se prefieren los dominios de clearnet',
601: 'Local',
602: 'Se puede usar para acceso local',
603: 'Ideal para acceso público a través de Internet',
604: 'Se puede usar para acceso personal a través de Internet público. VPN es más privado y seguro',
605: 'cuando el uso de direcciones IP y puertos no es deseable',
606: 'Host',
607: 'Valor',
608: 'Propósito',
609: 'Subdominios de',
610: 'DNS dinámico',
} satisfies i18n

View File

@@ -524,4 +524,55 @@ export default {
557: 'Aucun domaine privé',
558: 'Nouveau domaine privé',
559: 'Serveurs DNS',
560: 'Entrez un nom de domaine complet. Comme le domaine est destiné à un usage privé, il peut sagir de nimporte quel domaine, même dun domaine que vous ne contrôlez pas.',
561: 'Entrez un nom de domaine complet. Par exemple, si vous contrôlez domain.com, vous pourriez entrer domain.com, subdomain.domain.com ou another.subdomain.domain.com.',
562: 'Enregistrements DNS',
563: 'Créez lun des enregistrements DNS ci-dessous.',
564: 'Aucun enregistrement DNS détecté pour',
565: 'Enregistrement DNS invalide',
566: 'se résout en',
567: 'Enregistrement DNS détecté !',
568: 'Sélectionnez une passerelle à utiliser pour ce domaine.',
569: 'Sélectionnez une Autorité de Certification pour émettre des certificats SSL/TLS pour ce domaine.',
570: 'Autre',
571: 'Un nom pour identifier facilement la passerelle',
572: 'Sélectionnez cette option si la passerelle est configurée pour un accès privé uniquement aux clients autorisés. StartTunnel est une passerelle privée.',
573: 'Sélectionnez cette option si la passerelle est configurée pour un accès public illimité.',
574: 'Fichier',
575: 'Fichier de configuration Wireguard',
576: 'Copier/Coller',
577: 'Contenu du fichier',
578: 'Clé publique',
579: 'doit être une clé publique SSH valide',
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: 'Utile uniquement pour les clients qui imposent HTTPS',
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 clearnet sont 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 Internet public. Le VPN est plus privé et sécurisé',
605: 'lorsque lutilisation des adresses IP et des ports est indésirable',
606: 'Hôte',
607: 'Valeur',
608: 'But',
609: 'Sous-domaines de',
610: 'DNS dynamique',
} satisfies i18n

View File

@@ -524,4 +524,55 @@ export default {
557: 'Brak domen prywatnych',
558: 'Nowa domena prywatna',
559: 'Serwery DNS',
560: 'Wprowadź w pełni kwalifikowaną nazwę domeny. Ponieważ domena jest przeznaczona do użytku prywatnego, może to być dowolna domena, nawet taka, której nie kontrolujesz.',
561: 'Wprowadź w pełni kwalifikowaną nazwę domeny. Na przykład, jeśli kontrolujesz domain.com, możesz wprowadzić domain.com, subdomain.domain.com lub another.subdomain.domain.com.',
562: 'Rekordy DNS',
563: 'Utwórz jeden z poniższych rekordów DNS.',
564: 'Nie wykryto rekordu DNS dla',
565: 'Nieprawidłowy rekord DNS',
566: 'rozwiązuje się na',
567: 'Wykryto rekord DNS!',
568: 'Wybierz bramę do użycia dla tej domeny.',
569: 'Wybierz Urząd Certyfikacji, aby wystawić certyfikaty SSL/TLS dla tej domeny.',
570: 'Inne',
571: 'Nazwa ułatwiająca identyfikację bramy',
572: 'Wybierz tę opcję, jeśli brama jest skonfigurowana do prywatnego dostępu tylko dla autoryzowanych klientów. StartTunnel to prywatna brama.',
573: 'Wybierz tę opcję, jeśli brama jest skonfigurowana do nieograniczonego publicznego dostępu.',
574: 'Plik',
575: 'Plik konfiguracyjny Wireguard',
576: 'Kopiuj/Wklej',
577: 'Zawartość pliku',
578: 'Klucz publiczny',
579: 'musi być prawidłowym kluczem publicznym SSH',
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: 'Przydatne tylko dla klientów wymuszających HTTPS',
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. Preferowane są domeny clearnet',
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. 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: 'Subdomeny',
610: 'Dynamiczny DNS',
} satisfies i18n

View File

@@ -10,9 +10,9 @@ import { I18N, i18nKey } from './i18n.providers'
export class i18nPipe implements PipeTransform {
private readonly i18n = inject(I18N)
transform(englishKey: i18nKey | null | undefined): string | undefined {
return englishKey
? this.i18n()?.[ENGLISH[englishKey as i18nKey]] || englishKey
: undefined
transform(englishKey: i18nKey | null | undefined): string {
englishKey = englishKey || ('' as i18nKey)
return this.i18n()?.[ENGLISH[englishKey]] || englishKey
}
}

View File

@@ -2,7 +2,7 @@ import { ChangeDetectionStrategy, Component, inject } from '@angular/core'
import { toSignal } from '@angular/core/rxjs-interop'
import { SwUpdate } from '@angular/service-worker'
import { WA_WINDOW } from '@ng-web-apis/common'
import { LoadingService } from '@start9labs/shared'
import { i18nPipe, LoadingService } from '@start9labs/shared'
import { Version } from '@start9labs/start-sdk'
import { TuiResponsiveDialog } from '@taiga-ui/addon-mobile'
import { TuiAutoFocus } from '@taiga-ui/cdk'
@@ -12,21 +12,23 @@ import { distinctUntilChanged, map, merge, Subject } from 'rxjs'
import { ConfigService } from 'src/app/services/config.service'
import { DataModel } from 'src/app/services/patch-db/data-model'
// @TODO translations
@Component({
selector: 'refresh-alert',
template: `
<ng-template
[tuiResponsiveDialog]="show()"
[tuiResponsiveDialogOptions]="{ label: 'Refresh Needed', size: 's' }"
[tuiResponsiveDialogOptions]="{
label: i18n.transform('Refresh Needed'),
size: 's',
}"
(tuiResponsiveDialogChange)="dismiss$.next()"
>
@if (isPwa) {
<p>
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.
{{
'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.'
| i18n
}}
</p>
<button
tuiButton
@@ -36,11 +38,13 @@ import { DataModel } from 'src/app/services/patch-db/data-model'
[tuiAppearanceFocus]="false"
(click)="pwaReload()"
>
Reload
{{ 'Refresh' | i18n }}
</button>
} @else {
Your user interface is cached and out of date. Hard refresh the page to
get the latest UI.
{{
'Your user interface is cached and out of date. Hard refresh the page to get the latest UI.'
| i18n
}}
<ul>
<li>
<b>On Mac</b>
@@ -59,13 +63,13 @@ import { DataModel } from 'src/app/services/patch-db/data-model'
[tuiAppearanceFocus]="false"
(click)="dismiss$.next()"
>
Ok
{{ 'Ok' | i18n }}
</button>
}
</ng-template>
`,
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [TuiResponsiveDialog, TuiButton, TuiAutoFocus],
imports: [TuiResponsiveDialog, TuiButton, TuiAutoFocus, i18nPipe],
})
export class RefreshAlertComponent {
private readonly win = inject(WA_WINDOW)
@@ -73,6 +77,8 @@ export class RefreshAlertComponent {
private readonly loader = inject(LoadingService)
private readonly version = Version.parse(inject(ConfigService).version)
readonly i18n = inject(i18nPipe)
readonly dismiss$ = new Subject<void>()
readonly isPwa = this.win.matchMedia('(display-mode: standalone)').matches

View File

@@ -7,8 +7,6 @@ import { PublicDomainsComponent } from './public-domains/pd.component'
import { InterfacePrivateDomainsComponent } from './private-domains.component'
import { InterfaceAddressesComponent } from './addresses/addresses.component'
// @TODO translations
@Component({
selector: 'service-interface',
template: `

View File

@@ -3,7 +3,7 @@ import { T, utils } from '@start9labs/start-sdk'
import { ConfigService } from 'src/app/services/config.service'
import { GatewayPlus } from 'src/app/services/gateway.service'
import { PublicDomain } from './public-domains/pd.service'
import { i18nKey } from '@start9labs/shared'
import { i18nKey, i18nPipe } from '@start9labs/shared'
type AddressWithInfo = {
url: URL
@@ -98,155 +98,6 @@ function cmpClearnet(
])
}
// @TODO translations
function toDisplayAddress(
{ info, url }: AddressWithInfo,
gateways: GatewayPlus[],
publicDomains: Record<string, T.PublicDomainConfig>,
): DisplayAddress {
let access: DisplayAddress['access']
let gatewayName: DisplayAddress['gatewayName']
let type: DisplayAddress['type']
let bullets: any[]
// let bullets: DisplayAddress['bullets']
const rootCaRequired = `Requires trusting your server's Root CA`
// ** Tor **
if (info.kind === 'onion') {
access = null
gatewayName = null
type = 'Tor'
bullets = [
'Connections can be slow or unreliable at times',
'Public if you share the address publicly, otherwise private',
'Requires using a Tor-enabled device or browser',
]
// Tor (HTTPS)
if (url.protocol.startsWith('https')) {
type = `${type} (HTTPS)`
bullets = [
'Only useful for clients that enforce HTTPS',
rootCaRequired,
...bullets,
]
// Tor (HTTP)
} else {
bullets.unshift(
'Ideal for anonymous, censorship-resistant hosting and remote access',
)
type = `${type} (HTTP)`
}
// ** Not Tor **
} else {
const port = info.hostname.sslPort || info.hostname.port
const gateway = gateways.find(g => g.id === info.gatewayId)!
gatewayName = gateway.ipInfo.name
const gatewayLanIpv4 = gateway.lanIpv4[0]
const isWireguard = gateway.ipInfo.deviceType === 'wireguard'
const localIdeal = 'Ideal for local access'
const lanRequired =
'Requires being connected to the same Local Area Network (LAN) as your server, either physically or via VPN'
const staticRequired = `Requires setting a static IP address for ${gatewayLanIpv4} in your gateway`
const vpnAccess = 'Ideal for VPN access via your'
// * Local *
if (info.hostname.kind === 'local') {
type = 'Local'
access = 'private'
bullets = [
localIdeal,
'Not recommended for VPN access. VPNs do not support ".local" domains without advanced configuration',
lanRequired,
rootCaRequired,
]
// * IPv4 *
} else if (info.hostname.kind === 'ipv4') {
type = 'IPv4'
if (info.public) {
access = 'public'
bullets = [
'Can be used for clearnet access',
'Not recommended in most cases. Clearnet domains are preferred',
rootCaRequired,
]
if (!gateway.public) {
bullets.push(
`Requires port forwarding in gateway "${gatewayName}": ${port} -> ${info.hostname.value}:${port}`,
)
}
} else {
access = 'private'
if (isWireguard) {
bullets = [`${vpnAccess} StartTunnel (or similar)`, rootCaRequired]
} else {
bullets = [
localIdeal,
`${vpnAccess} router's Wireguard server`,
lanRequired,
rootCaRequired,
staticRequired,
]
}
}
// * IPv6 *
} else if (info.hostname.kind === 'ipv6') {
type = 'IPv6'
access = 'private'
bullets = ['Can be used for local access', lanRequired, rootCaRequired]
// * Domain *
} else {
type = 'Domain'
if (info.public) {
access = 'public'
bullets = [
`Requires a DNS record for ${info.hostname.value} that resolves to ${gateway.ipInfo.wanIp}`,
`Requires port forwarding in gateway "${gatewayName}": ${port} -> ${info.hostname.value}:${port === 443 ? 5443 : port}`,
]
if (publicDomains[info.hostname.value]?.acme) {
bullets.unshift('Ideal for public access via the Internet')
} else {
bullets = [
'Can be used for personal access via the public Internet. VPN is more private and secure',
rootCaRequired,
...bullets,
]
}
} else {
access = 'private'
const ipPortBad = 'when using IP addresses and ports is undesirable'
const customDnsRequired = `Requires a DNS record for ${info.hostname.value} that resolves to ${gatewayLanIpv4}`
if (isWireguard) {
bullets = [
`${vpnAccess} StartTunnel (or similar) ${ipPortBad}`,
customDnsRequired,
rootCaRequired,
]
} else {
bullets = [
`${localIdeal} ${ipPortBad}`,
`${vpnAccess} router's Wireguard server ${ipPortBad}`,
customDnsRequired,
rootCaRequired,
lanRequired,
staticRequired,
]
}
}
}
}
return {
url: url.href,
access,
gatewayName,
type,
bullets,
}
}
export function getPublicDomains(
publicDomains: Record<string, T.PublicDomainConfig>,
gateways: GatewayPlus[],
@@ -263,6 +114,7 @@ export function getPublicDomains(
})
export class InterfaceService {
private readonly config = inject(ConfigService)
private readonly i18n = inject(i18nPipe)
getAddresses(
serviceInterface: T.ServiceInterface,
@@ -304,11 +156,11 @@ export class InterfaceService {
return {
common: bestAddrs.map(a =>
toDisplayAddress(a, gateways, host.publicDomains),
this.toDisplayAddress(a, gateways, host.publicDomains),
),
uncommon: allAddressesWithInfo
.filter(a => !bestAddrs.includes(a))
.map(a => toDisplayAddress(a, gateways, host.publicDomains)),
.map(a => this.toDisplayAddress(a, gateways, host.publicDomains)),
}
}
@@ -448,6 +300,183 @@ export class InterfaceService {
) || []
)
}
private toDisplayAddress(
{ info, url }: AddressWithInfo,
gateways: GatewayPlus[],
publicDomains: Record<string, T.PublicDomainConfig>,
): DisplayAddress {
let access: DisplayAddress['access']
let gatewayName: DisplayAddress['gatewayName']
let type: DisplayAddress['type']
let bullets: any[]
// let bullets: DisplayAddress['bullets']
const rootCaRequired = this.i18n.transform(
"Requires trusting your server's Root CA",
)
// ** Tor **
if (info.kind === 'onion') {
access = null
gatewayName = null
type = 'Tor'
bullets = [
this.i18n.transform('Connections can be slow or unreliable at times'),
this.i18n.transform(
'Public if you share the address publicly, otherwise private',
),
this.i18n.transform('Requires using a Tor-enabled device or browser'),
]
// Tor (HTTPS)
if (url.protocol.startsWith('https')) {
type = `${type} (HTTPS)`
bullets = [
this.i18n.transform('Only useful for clients that enforce HTTPS'),
rootCaRequired,
...bullets,
]
// Tor (HTTP)
} else {
bullets.unshift(
this.i18n.transform(
'Ideal for anonymous, censorship-resistant hosting and remote access',
),
)
type = `${type} (HTTP)`
}
// ** Not Tor **
} else {
const port = info.hostname.sslPort || info.hostname.port
const gateway = gateways.find(g => g.id === info.gatewayId)!
gatewayName = gateway.ipInfo.name
const gatewayLanIpv4 = gateway.lanIpv4[0]
const isWireguard = gateway.ipInfo.deviceType === 'wireguard'
const localIdeal = this.i18n.transform('Ideal for local access')
const lanRequired = this.i18n.transform(
'Requires being connected to the same Local Area Network (LAN) as your server, either physically or via VPN',
)
const staticRequired = `${this.i18n.transform('Requires setting a static IP address for')} ${gatewayLanIpv4} ${this.i18n.transform('in your gateway')}`
const vpnAccess = this.i18n.transform('Ideal for VPN access via')
const routerWireguard = this.i18n.transform(
"your router's Wireguard server",
)
const portForwarding = this.i18n.transform(
'Requires port forwarding in gateway',
)
const dnsFor = this.i18n.transform('Requires a DNS record for')
const resolvesTo = this.i18n.transform('that resolves to')
// * Local *
if (info.hostname.kind === 'local') {
type = this.i18n.transform('Local')
access = 'private'
bullets = [
localIdeal,
this.i18n.transform(
'Not recommended for VPN access. VPNs do not support ".local" domains without advanced configuration',
),
lanRequired,
rootCaRequired,
]
// * IPv4 *
} else if (info.hostname.kind === 'ipv4') {
type = 'IPv4'
if (info.public) {
access = 'public'
bullets = [
this.i18n.transform('Can be used for clearnet access'),
this.i18n.transform(
'Not recommended in most cases. Clearnet domains are preferred',
),
rootCaRequired,
]
if (!gateway.public) {
bullets.push(
`${portForwarding} "${gatewayName}": ${port} -> ${info.hostname.value}:${port}`,
)
}
} else {
access = 'private'
if (isWireguard) {
bullets = [`${vpnAccess} StartTunnel`, rootCaRequired]
} else {
bullets = [
localIdeal,
`${vpnAccess} ${routerWireguard}`,
lanRequired,
rootCaRequired,
staticRequired,
]
}
}
// * IPv6 *
} else if (info.hostname.kind === 'ipv6') {
type = 'IPv6'
access = 'private'
bullets = [
this.i18n.transform('Can be used for local access'),
lanRequired,
rootCaRequired,
]
// * Domain *
} else {
type = this.i18n.transform('Domain')
if (info.public) {
access = 'public'
bullets = [
`${dnsFor} ${info.hostname.value} ${resolvesTo} ${gateway.ipInfo.wanIp}`,
`${portForwarding} "${gatewayName}": ${port} -> ${info.hostname.value}:${port === 443 ? 5443 : port}`,
]
if (publicDomains[info.hostname.value]?.acme) {
bullets.unshift(
this.i18n.transform('Ideal for public access via the Internet'),
)
} else {
bullets = [
this.i18n.transform(
'Can be used for personal access via the public Internet. VPN is more private and secure',
),
rootCaRequired,
...bullets,
]
}
} else {
access = 'private'
const ipPortBad = this.i18n.transform(
'when using IP addresses and ports is undesirable',
)
const customDnsRequired = `${dnsFor} ${info.hostname.value} ${resolvesTo} ${gatewayLanIpv4}`
if (isWireguard) {
bullets = [
`${vpnAccess} StartTunnel ${ipPortBad}`,
customDnsRequired,
rootCaRequired,
]
} else {
bullets = [
`${localIdeal} ${ipPortBad}`,
`${vpnAccess} ${routerWireguard} ${ipPortBad}`,
customDnsRequired,
rootCaRequired,
lanRequired,
staticRequired,
]
}
}
}
}
return {
url: url.href,
access,
gatewayName,
type,
bullets,
}
}
}
export type MappedServiceInterface = T.ServiceInterface & {

View File

@@ -26,8 +26,6 @@ import { FormDialogService } from 'src/app/services/form-dialog.service'
import { configBuilderToSpec } from 'src/app/utils/configBuilderToSpec'
import { InterfaceComponent } from './interface.component'
// @TODO translations
@Component({
selector: 'section[privateDomains]',
template: `
@@ -113,9 +111,10 @@ export class InterfacePrivateDomainsComponent {
spec: await configBuilderToSpec(
ISB.InputSpec.of({
fqdn: ISB.Value.text({
name: 'Domain',
description:
name: this.i18n.transform('Domain'),
description: this.i18n.transform(
'Enter a fully qualified domain name. Since the domain is for private use, it can be any domain you want, even one you do not control.',
),
required: true,
default: null,
patterns: [utils.Patterns.domain],

View File

@@ -19,8 +19,6 @@ import { ApiService } from 'src/app/services/api/embassy-api.service'
import { parse } from 'tldts'
import { GatewayWithId } from './pd.service'
// @TODO translations
@Component({
selector: 'dns',
template: `
@@ -37,11 +35,11 @@ import { GatewayWithId } from './pd.service'
[(ngModel)]="ddns"
(ngModelChange)="pass.set(undefined)"
/>
Dynamic DNS
{{ 'Dynamic DNS' | i18n }}
</label>
}
<table [appTable]="['Type', $any('Host'), 'Value', 'Purpose']">
<table [appTable]="['Type', 'Host', 'Value', 'Purpose']">
@for (row of rows(); track $index) {
<tr>
<td>
@@ -98,6 +96,7 @@ import { GatewayWithId } from './pd.service'
export class DnsComponent {
private readonly errorService = inject(ErrorService)
private readonly api = inject(ApiService)
private readonly i18n = inject(i18nPipe)
readonly ddns = false
@@ -126,6 +125,8 @@ export class DnsComponent {
const segments = subdomain.split('.')
const subdomains = this.i18n.transform('subdomains of')
return [
{
host: subdomain,
@@ -135,12 +136,12 @@ export class DnsComponent {
const parent = segments.slice(i + 1).join('.')
return {
host: `*.${parent}`,
purpose: `subdomains of ${parent}`,
purpose: `${subdomains} ${parent}`,
}
}),
{
host: '*',
purpose: `subdomains of ${domain}`,
purpose: `${subdomains} ${domain}`,
},
]
})

View File

@@ -4,6 +4,7 @@ import {
ErrorService,
i18nKey,
LoadingService,
i18nPipe,
} from '@start9labs/shared'
import { toSignal } from '@angular/core/rxjs-interop'
import { ISB, T, utils } from '@start9labs/start-sdk'
@@ -18,8 +19,6 @@ import { toAuthorityName } from 'src/app/utils/acme'
import { InterfaceComponent } from '../interface.component'
import { DNS } from './dns.component'
// @TODO translations
export type PublicDomain = {
fqdn: string
gateway: GatewayWithId | null
@@ -40,6 +39,7 @@ export class PublicDomainService {
private readonly formDialog = inject(FormDialogService)
private readonly dialog = inject(DialogService)
private readonly interface = inject(InterfaceComponent)
private readonly i18n = inject(i18nPipe)
readonly data = toSignal(
this.patch.watch$('serverInfo', 'network').pipe(
@@ -61,9 +61,10 @@ export class PublicDomainService {
async add() {
const addSpec = ISB.InputSpec.of({
fqdn: ISB.Value.text({
name: 'Domain',
description:
name: this.i18n.transform('Domain'),
description: this.i18n.transform(
'Enter a fully qualified domain name. For example, if you control domain.com, you could enter domain.com or subdomain.domain.com or another.subdomain.domain.com.',
),
required: true,
default: null,
patterns: [utils.Patterns.domain],
@@ -140,7 +141,7 @@ export class PublicDomainService {
showDns(fqdn: string, gateway: GatewayWithId, message: i18nKey) {
this.dialog
.openComponent(DNS, {
label: 'DNS Records' as i18nKey,
label: 'DNS Records',
size: 'l',
data: {
fqdn,
@@ -177,7 +178,9 @@ export class PublicDomainService {
}
const wanIp = gateway.ipInfo.wanIp
let message = `Create one of the DNS records below to cause ${fqdn} to resolve to ${wanIp}`
let message = this.i18n.transform(
'Create one of the DNS records below.',
) as i18nKey
if (!ip) {
setTimeout(
@@ -185,7 +188,7 @@ export class PublicDomainService {
this.showDns(
fqdn,
gateway,
`No DNS detected for ${fqdn}. ${message}` as i18nKey,
`${this.i18n.transform('No DNS record detected for')} ${fqdn}. ${message}` as i18nKey,
),
250,
)
@@ -195,7 +198,7 @@ export class PublicDomainService {
this.showDns(
fqdn,
gateway,
`Invalid DNS. ${fqdn} is currently resolving to ${ip}. ${message}` as i18nKey,
`${this.i18n.transform('Invalid DNS record')}. ${fqdn} ${this.i18n.transform('resolves to')} ${ip}. ${message}` as i18nKey,
),
250,
)
@@ -203,8 +206,8 @@ export class PublicDomainService {
setTimeout(
() =>
this.dialog.openAlert(
`${fqdn} is successfully resolving to ${wanIp}` as i18nKey,
{ label: 'DNS detected!' as i18nKey, appearance: 'positive' },
`${fqdn} ${this.i18n.transform('resolves to')} ${wanIp}` as i18nKey,
{ label: 'DNS record detected!', appearance: 'positive' },
),
250,
)
@@ -224,8 +227,10 @@ export class PublicDomainService {
return {
gateway: ISB.Value.dynamicSelect(() => ({
name: 'Gateway',
description: 'Select a gateway to use for this domain.',
name: this.i18n.transform('Gateway'),
description: this.i18n.transform(
'Select a gateway to use for this domain.',
),
values: data.gateways.reduce<Record<string, string>>(
(obj, gateway) => ({
...obj,
@@ -241,9 +246,10 @@ export class PublicDomainService {
.map(g => g.id),
})),
authority: ISB.Value.select({
name: 'Certificate Authority',
description:
'Select the Certificate Authority that will issue the SSL/TLS certificate for this domain',
name: this.i18n.transform('Certificate Authority'),
description: this.i18n.transform(
'Select a Certificate Authority to issue SSL/TLS certificates for this domain',
),
values: data.authorities,
default: '',
}),

View File

@@ -55,7 +55,7 @@ export class AuthorityService {
const addSpec = ISB.InputSpec.of({
provider: ISB.Value.union({
name: 'Provider',
name: this.i18n.transform('Provider'),
default: (availableAuthorities[0]?.url as any) || 'other',
variants: ISB.Variants.of({
...availableAuthorities.reduce(
@@ -69,7 +69,7 @@ export class AuthorityService {
{},
),
other: {
name: 'Other',
name: this.i18n.transform('Other'),
spec: ISB.InputSpec.of({
url: ISB.Value.text({
name: 'URL',

View File

@@ -72,82 +72,82 @@ export default class GatewaysComponent {
private readonly errorService = inject(ErrorService)
private readonly api = inject(ApiService)
private readonly formDialog = inject(FormDialogService)
private readonly i18n = inject(i18nPipe)
async add() {
const spec = ISB.InputSpec.of({
name: ISB.Value.text({
name: this.i18n.transform('Name'),
description: this.i18n.transform(
'A name to easily identify the gateway',
),
required: true,
default: null,
}),
type: ISB.Value.select({
name: this.i18n.transform('Type'),
description: `-**${this.i18n.transform('private')}**: ${this.i18n.transform('select this option if the gateway is configured for private access to authorized clients only. StartTunnel is a private gateway.')}\n-**${this.i18n.transform('public')}**: ${this.i18n.transform('select this option if the gateway is configured for unfettered public access.')}`,
default: 'private',
values: {
private: this.i18n.transform('private'),
public: this.i18n.transform('public'),
},
}),
config: ISB.Value.union({
name: this.i18n.transform('Wireguard Config File'),
default: 'paste',
variants: ISB.Variants.of({
paste: {
name: this.i18n.transform('Copy/Paste'),
spec: ISB.InputSpec.of({
file: ISB.Value.textarea({
name: this.i18n.transform('File Contents'),
default: null,
required: true,
}),
}),
},
upload: {
name: this.i18n.transform('Upload'),
spec: ISB.InputSpec.of({
file: ISB.Value.file({
name: this.i18n.transform('File'),
required: true,
extensions: ['.conf'],
}),
}),
},
}),
}),
})
this.formDialog.open(FormComponent, {
label: 'Add gateway',
data: {
spec: await configBuilderToSpec(gatewaySpec),
spec: await configBuilderToSpec(spec),
buttons: [
{
text: 'Save',
handler: (input: typeof gatewaySpec._TYPE) => this.save(input),
text: this.i18n.transform('Save'),
handler: async (input: typeof spec._TYPE) => {
const loader = this.loader.open('Saving').subscribe()
try {
await this.api.addTunnel({
name: input.name,
config: '' as string, // @TODO alex/matt when types arrive
public: input.type === 'public',
})
return true
} catch (e: any) {
this.errorService.handleError(e)
return false
} finally {
loader.unsubscribe()
}
},
},
],
},
})
}
private async save(input: typeof gatewaySpec._TYPE): Promise<boolean> {
const loader = this.loader.open('Saving').subscribe()
try {
await this.api.addTunnel({
name: input.name,
config: '' as string, // @TODO alex/matt when types arrive
public: input.type === 'public',
})
return true
} catch (e: any) {
this.errorService.handleError(e)
return false
} finally {
loader.unsubscribe()
}
}
}
const gatewaySpec = ISB.InputSpec.of({
name: ISB.Value.text({
name: 'Name',
description: 'A name to easily identify the gateway',
required: true,
default: null,
}),
type: ISB.Value.select({
name: 'Type',
description:
'-**Private**: select this option if the gateway is configured for private access to authorized clients only. StartTunnel is a private gateway.\n-**Public**: select this option if the gateway is configured for unfettered public access.',
default: 'private',
values: {
private: 'Private',
public: 'Public',
},
}),
config: ISB.Value.union({
name: 'Wireguard Config',
default: 'paste',
variants: ISB.Variants.of({
paste: {
name: 'Paste File Contents',
spec: ISB.InputSpec.of({
file: ISB.Value.textarea({
name: 'Paste File Contents',
default: null,
required: true,
}),
}),
},
upload: {
name: 'Upload File',
spec: ISB.InputSpec.of({
file: ISB.Value.file({
name: 'File',
required: true,
extensions: ['.conf'],
}),
}),
},
}),
}),
})

View File

@@ -143,6 +143,7 @@ export class GatewaysItemComponent {
private readonly errorService = inject(ErrorService)
private readonly api = inject(ApiService)
private readonly formDialog = inject(FormDialogService)
private readonly i18n = inject(i18nPipe)
readonly gateway = input.required<GatewayPlus>()
@@ -169,7 +170,7 @@ export class GatewaysItemComponent {
const { ipInfo, id } = this.gateway()
const renameSpec = ISB.InputSpec.of({
label: ISB.Value.text({
name: 'Label',
name: this.i18n.transform('Name'),
required: true,
default: ipInfo?.name || null,
}),

View File

@@ -107,14 +107,29 @@ export default class SystemSSHComponent {
protected tableKeys = viewChild<SSHTableComponent<SSHKey>>('table')
async add(all: readonly SSHKey[]) {
const spec = ISB.InputSpec.of({
key: ISB.Value.text({
name: this.i18n.transform('Public Key'),
required: true,
default: null,
patterns: [
{
regex:
'^(ssh-(rsa|ed25519|dss|ecdsa)|ecdsa-sha2-nistp(256|384|521))\\s+[A-Za-z0-9+/=]+(\\s[^\\s]+)?$',
description: this.i18n.transform('must be a valid SSH public key'),
},
],
}),
})
this.formDialog.open(FormComponent, {
label: 'Add SSH key',
data: {
spec: await configBuilderToSpec(SSHSpec),
spec: await configBuilderToSpec(spec),
buttons: [
{
text: this.i18n.transform('Save'),
handler: async ({ key }: typeof SSHSpec._TYPE) => {
handler: async ({ key }: typeof spec._TYPE) => {
const loader = this.loader.open('Saving').subscribe()
try {
@@ -157,18 +172,3 @@ export default class SystemSSHComponent {
})
}
}
const SSHSpec = ISB.InputSpec.of({
key: ISB.Value.text({
name: 'Public Key',
required: true,
default: null,
patterns: [
{
regex:
'^(ssh-(rsa|ed25519|dss|ecdsa)|ecdsa-sha2-nistp(256|384|521))\\s+[A-Za-z0-9+/=]+(\\s[^\\s]+)?$',
description: 'must be a valid SSH public key',
},
],
}),
})