mirror of
https://github.com/fosrl/pangolin.git
synced 2026-10-29 06:59:06 +01:00
@@ -48,6 +48,9 @@ jobs:
|
||||
- name: Test with tsc
|
||||
run: npx tsc --noEmit
|
||||
|
||||
- name: Run unit tests
|
||||
run: npm test
|
||||
|
||||
- name: Start app in background
|
||||
run: |
|
||||
nohup npm run dev > app.log 2>&1 &
|
||||
|
||||
@@ -11,7 +11,7 @@ experimental:
|
||||
plugins:
|
||||
badger:
|
||||
moduleName: github.com/fosrl/badger
|
||||
version: v1.8.0
|
||||
version: v1.7.0
|
||||
log:
|
||||
level: INFO
|
||||
format: common
|
||||
|
||||
@@ -50,7 +50,7 @@ services:
|
||||
- 80:80{{end}}
|
||||
|
||||
traefik:
|
||||
image: docker.io/traefik:v3.7
|
||||
image: docker.io/traefik:v3.7.16
|
||||
container_name: traefik
|
||||
restart: unless-stopped
|
||||
{{if .InstallGerbil}}network_mode: service:gerbil # Ports appear on the gerbil service{{end}}{{if not .InstallGerbil}}
|
||||
|
||||
+42
-3
@@ -604,6 +604,10 @@
|
||||
"licenseNoUserLimit": "Няма лимит за броя на потребителите за този хост.",
|
||||
"licenseUsersUsedMax": "{usedUsers} от {maxUsers} използвани потребители",
|
||||
"licenseUsersUsed": "{count, plural, =0 {# потребители} one {# потребител} other {# потребители}}",
|
||||
"licenseUsageMachines": "Машинни клиенти",
|
||||
"licenseNoMachineLimit": "Няма ограничение за броя на машинните клиенти за този хост.",
|
||||
"licenseMachinesUsedMax": "{usedMachines} от {maxMachines} машинни клиенти използвани",
|
||||
"licenseMachinesUsed": "{count, plural, =0 {# машинни клиенти} one {# машинен клиент} other {# машинни клиенти}}",
|
||||
"licenseUnlimited": "Неограничено",
|
||||
"licenseTierLabel": "Ниво",
|
||||
"licensePurchaseDescription": "Изберете колко сайтове искате да {selectedMode, select, license {закупите лиценз за. Можете винаги да добавите повече сайтове по-късно.} other {добавите към съществуващия си лиценз.}}",
|
||||
@@ -801,7 +805,7 @@
|
||||
"loadBalancingStrategy_wrr_description": "Разпределете заявките равномерно по цели в ротация.",
|
||||
"loadBalancingStrategy_p2c": "Избор на две случайни цели",
|
||||
"loadBalancingStrategy_p2c_description": "Изберете две случайни цели и изпратете заявката към тази с по-малко активни връзки.",
|
||||
"loadBalancingStrategy_hrw": "Клиентски IP хеш",
|
||||
"loadBalancingStrategy_hrw": "Най-високата случайна тежест",
|
||||
"loadBalancingStrategy_hrw_description": "Хаширане на клиентски IP, така че същият клиент последователно да се маршрутира към същата цел.",
|
||||
"loadBalancingStrategy_leasttime": "Най-малко време за отговор",
|
||||
"loadBalancingStrategy_leasttime_description": "Изпратете заявки към целта с най-бързо време за отговор и най-малко активни връзки.",
|
||||
@@ -829,6 +833,8 @@
|
||||
"save": "Запази",
|
||||
"proxyAdditional": "Допълнителни настройки на прокси",
|
||||
"proxyAdditionalDescription": "Конфигурирайте как ресурсът обработва настройките на прокси",
|
||||
"proxyForwardIdentityHeaders": "Пренасочване на идентификационни заглавки",
|
||||
"proxyForwardIdentityHeadersDescription": "Изпращане на автентифицирания идентификатор на потребителя към бекенда в заглавките Remote-User, Remote-Email, Remote-Name, Remote-Role и Remote-User-Id. Деактивирайте за бекенди, които отхвърлят заявки с неразпознати заглавки.",
|
||||
"proxyCustomHeader": "Персонализиран хост заглавие",
|
||||
"proxyCustomHeaderDescription": "Хост заглавието, което да зададете при прокси заявките. Оставете празно, за да използвате подразбиране.",
|
||||
"proxyAdditionalSubmit": "Запазване на прокси настройките",
|
||||
@@ -933,6 +939,9 @@
|
||||
"policyAuthHeaderAuthTitle": "Базово удостоверяване чрез заглавие",
|
||||
"policyAuthHeaderAuthDescription": "Валидирайте собствено HTTP заглавие и стойност при всяка заявка",
|
||||
"policyAuthHeaderAuthSummary": "Конфигурирано заглавие",
|
||||
"policyAuthMtlsTitle": "Клиентски сертификат (mTLS)",
|
||||
"policyAuthMtlsDescription": "Изискване на клиентски сертификат, подписан от доверен CA по време на TLS ръкостискане",
|
||||
"policyAuthMtlsSummary": "{count} доверени CA сертификати",
|
||||
"policyAuthHeaderName": "Потребителско име",
|
||||
"policyAuthHeaderValue": "Парола",
|
||||
"policyAuthSetPasscode": "Задайте парола",
|
||||
@@ -1500,10 +1509,15 @@
|
||||
"actionDeleteOrgDomain": "Изтрий домейн",
|
||||
"actionGetDNSRecords": "Вземи DNS записи",
|
||||
"actionRestartOrgDomain": "Рестартирай домейн",
|
||||
"actionListCustomDnsRecords": "Списък с персонализирани DNS записи",
|
||||
"actionCreateCustomDnsRecord": "Създаване на персонализиран DNS запис",
|
||||
"actionUpdateCustomDnsRecord": "Актуализиране на персонализиран DNS запис",
|
||||
"actionDeleteCustomDnsRecord": "Изтриване на персонализиран DNS запис",
|
||||
"actionCreateSite": "Създаване на сайт",
|
||||
"actionDeleteSite": "Изтриване на сайта",
|
||||
"actionGetSite": "Вземете сайт",
|
||||
"actionListSites": "Изброяване на сайтове",
|
||||
"actionReGenerateSecret": "Генериране на нови удостоверителни данни",
|
||||
"actionCreateAiProvider": "Създаване на AI Доставчик",
|
||||
"actionDeleteAiProvider": "Изтриване на AI Доставчик",
|
||||
"actionGetAiProvider": "Получаване на AI Доставчик",
|
||||
@@ -3727,6 +3741,27 @@
|
||||
"TTL": "TTL",
|
||||
"howToAddRecords": "Как да добавите записи",
|
||||
"dnsRecord": "DNS записи",
|
||||
"customDnsRecords": "Персонализирани DNS записи",
|
||||
"customDnsRecordsAdd": "Добавяне на запис",
|
||||
"customDnsRecordsSearch": "Търсене на записи...",
|
||||
"customDnsRecordsUnverifiedTitle": "Записите все още не се обслужват",
|
||||
"customDnsRecordsUnverifiedDescription": "Този домейн не е потвърден. Можете да добавяте записи сега, но сървърите на имената на Pangolin няма да ги обслужват, докато сървърите за имена на домейна не сочат към Pangolin и потвърждението не завърши.",
|
||||
"customDnsRecordCreate": "Добавяне на DNS запис",
|
||||
"customDnsRecordEdit": "Редактиране на DNS запис",
|
||||
"customDnsRecordFormDescription": "Този запис ще се обслужва за {domain}. Промените могат да отнемат няколко минути, за да се разпространят.",
|
||||
"customDnsRecordNameDescription": "Използвайте @ за корена на домейна или * като първи етикет за масов запис.",
|
||||
"customDnsRecordValueDescription": "Въведете една стойност на ред, напр. и двата сървъра на имена на NS запис.",
|
||||
"customDnsRecordPriority": "Приоритет",
|
||||
"customDnsRecordWeight": "Тегло",
|
||||
"customDnsRecordPort": "Порт",
|
||||
"customDnsRecordCreated": "DNS запис създаден",
|
||||
"customDnsRecordUpdated": "DNS запис актуализиран",
|
||||
"customDnsRecordSaveError": "Неуспешно запазване на DNS запис",
|
||||
"customDnsRecordDeleted": "DNS запис изтрит",
|
||||
"customDnsRecordDeleteError": "Неуспешно изтриване на DNS запис",
|
||||
"customDnsRecordDeleteTitle": "Изтриване на DNS запис",
|
||||
"customDnsRecordDeleteQuestion": "Сигурни ли сте, че искате да изтриете записа от тип {type} за {name}? Той ще спре да разрешава след изтичане на кешираните отговори.",
|
||||
"customDnsRecordDeleteConfirm": "Изтриване на запис",
|
||||
"required": "Задължително",
|
||||
"domainSettingsUpdated": "Настройките на домейна са успешно актуализирани",
|
||||
"orgOrDomainIdMissing": "Липсва идентификатор на организация или домейн",
|
||||
@@ -3938,7 +3973,7 @@
|
||||
"backToHome": "Връщане към началната страница.",
|
||||
"needToSignInToOrg": "Трябва ли да използвате доставчика на идентичност на организацията си?",
|
||||
"mtls": "mTLS",
|
||||
"mtlsCaCertificates": "Доверени CA сертификати",
|
||||
"mtlsCaCertificates": "Задаване на клиентски сертификати",
|
||||
"mtlsCaCertificatesDescription": "Клиентите трябва да представят сертификат, подписан от един от тези органи за издаване на сертификати, за да се свържат. Качете само публични CA сертификати, никога частен ключ.",
|
||||
"mtlsCertificateName": "Име (по избор)",
|
||||
"mtlsCertificateNamePlaceholder": "Корпоративно устройство CA",
|
||||
@@ -3955,6 +3990,10 @@
|
||||
"mtlsCertificateErrorRemoveDescription": "Сертификатът не може да бъде премахнат.",
|
||||
"mtlsLastCertificateRemoved": "mTLS беше изключен, защото последният CA сертификат беше премахнат.",
|
||||
"mtlsNoCertificates": "Все още няма добавени CA сертификати",
|
||||
"mtlsPendingCertificate": "CA сертификат {number}",
|
||||
"mtlsCertificateInvalidPem": "Не е намерен PEM-кодиран сертификат. Очаква се блок, започващ с -----BEGIN CERTIFICATE-----.",
|
||||
"mtlsCertificatePrivateKey": "Това изглежда като частен ключ, а не сертификат. Качете само обществен сертификат на CA.",
|
||||
"mtlsCertificateDuplicate": "Този CA сертификат вече е добавен.",
|
||||
"mtlsRemoveCertificate": "Премахване на CA сертификат",
|
||||
"mtlsExpired": "Изтекло",
|
||||
"mtlsExpiresSoon": "Скоро ще изтече",
|
||||
@@ -4488,7 +4527,7 @@
|
||||
"rdpFull": "Пълен",
|
||||
"rdpReal": "Реален",
|
||||
"rdpMeta": "Мета",
|
||||
"rdpUploadFiles": "Качване на файловете",
|
||||
"rdpUploadFiles": "Качване на файлове",
|
||||
"rdpFilesReadyToPaste": "Файлове готови за поставяне",
|
||||
"rdpFilesReadyToPasteDescription": "{count} файл(ове) копирани в отдалечения клипборд — натиснете Ctrl+V на отдалечения работен плот за поставяне.",
|
||||
"rdpUploadFailed": "Качването неуспешно",
|
||||
|
||||
+42
-3
@@ -604,6 +604,10 @@
|
||||
"licenseNoUserLimit": "Neexistuje žádný limit na počet uživatelů pro tohoto hostitele.",
|
||||
"licenseUsersUsedMax": "{usedUsers} z {maxUsers} použitých uživatelů",
|
||||
"licenseUsersUsed": "{count, plural, =0 {# uživatelů} one {# uživatel} few {# uživatelé} many {# uživatelů} other {# uživatelů}}",
|
||||
"licenseUsageMachines": "Strojoví klienti",
|
||||
"licenseNoMachineLimit": "Neexistuje žádný limit na počet strojových klientů pro tohoto hostitele.",
|
||||
"licenseMachinesUsedMax": "{usedMachines} z {maxMachines} strojových klientů použito",
|
||||
"licenseMachinesUsed": "{count, plural, =0 {# strojových klientů} one {# strojový klient} few {# strojové klienty} many {# strojových klientů} other {# strojových klientů}}",
|
||||
"licenseUnlimited": "Bez omezení",
|
||||
"licenseTierLabel": "Úroveň",
|
||||
"licensePurchaseDescription": "Vyberte kolik stránek chcete {selectedMode, select, license {Zakupte si licenci. Vždy můžete přidat více webů později.} other {Přidejte k vaší existující licenci.}}",
|
||||
@@ -801,7 +805,7 @@
|
||||
"loadBalancingStrategy_wrr_description": "Rozdělte požadavky rovnoměrně mezi cíle.",
|
||||
"loadBalancingStrategy_p2c": "Síla dvou volby",
|
||||
"loadBalancingStrategy_p2c_description": "Vyberte náhodně dva cíle a odeslat požadavek tomu s menším počtem aktivních spojení.",
|
||||
"loadBalancingStrategy_hrw": "Hash klientské IP",
|
||||
"loadBalancingStrategy_hrw": "Nejvyšší náhodná váha",
|
||||
"loadBalancingStrategy_hrw_description": "Hash klientské IP tedy stejný klient je konzistentně směrován na stejný cíl.",
|
||||
"loadBalancingStrategy_leasttime": "Nejmenší doba odezvy",
|
||||
"loadBalancingStrategy_leasttime_description": "Odeslání požadavků na cíl s nejrychlejší dobou odezvy a nejméně aktivními spojeními.",
|
||||
@@ -829,6 +833,8 @@
|
||||
"save": "Uložit",
|
||||
"proxyAdditional": "Další nastavení proxy",
|
||||
"proxyAdditionalDescription": "Konfigurovat nastavení proxy",
|
||||
"proxyForwardIdentityHeaders": "Předávat identifikační hlavičky",
|
||||
"proxyForwardIdentityHeadersDescription": "Odeslat identitu autentizovaného uživatele na backend v hlavičkách Remote-User, Remote-Email, Remote-Name, Remote-Role a Remote-User-Id. Zakázat pro backendy, které odmítají požadavky s neznámými hlavičkami.",
|
||||
"proxyCustomHeader": "Vlastní hlavička hostitele",
|
||||
"proxyCustomHeaderDescription": "Hlavička hostitele bude nastavena při proxování požadavků. Nechte prázdné pro použití výchozího nastavení.",
|
||||
"proxyAdditionalSubmit": "Uložit nastavení proxy",
|
||||
@@ -933,6 +939,9 @@
|
||||
"policyAuthHeaderAuthTitle": "Základní Ověření Záhlaví",
|
||||
"policyAuthHeaderAuthDescription": "Ověřit vlastní HTTP hlavičku názvu a hodnoty při každém požadavku",
|
||||
"policyAuthHeaderAuthSummary": "Nastaveno hlavička",
|
||||
"policyAuthMtlsTitle": "Klientský certifikát (mTLS)",
|
||||
"policyAuthMtlsDescription": "Vyžadovat klientský certifikát podepsaný důvěryhodnou CA během TLS handshake",
|
||||
"policyAuthMtlsSummary": "{count} důvěryhodných certifikátů CA",
|
||||
"policyAuthHeaderName": "Uživatelské jméno",
|
||||
"policyAuthHeaderValue": "Heslo",
|
||||
"policyAuthSetPasscode": "Nastavit přístupový kód",
|
||||
@@ -1500,10 +1509,15 @@
|
||||
"actionDeleteOrgDomain": "Odstranit doménu",
|
||||
"actionGetDNSRecords": "Získat záznamy DNS",
|
||||
"actionRestartOrgDomain": "Restartovat doménu",
|
||||
"actionListCustomDnsRecords": "Seznam vlastních záznamů DNS",
|
||||
"actionCreateCustomDnsRecord": "Vytvořit vlastní záznam DNS",
|
||||
"actionUpdateCustomDnsRecord": "Aktualizovat vlastní záznam DNS",
|
||||
"actionDeleteCustomDnsRecord": "Smazat vlastní záznam DNS",
|
||||
"actionCreateSite": "Vytvořit lokalitu",
|
||||
"actionDeleteSite": "Odstranění lokality",
|
||||
"actionGetSite": "Získat web",
|
||||
"actionListSites": "Seznam stránek",
|
||||
"actionReGenerateSecret": "Obnovit přihlašovací údaje",
|
||||
"actionCreateAiProvider": "Vytvořit poskytovatele AI",
|
||||
"actionDeleteAiProvider": "Smazat poskytovatele AI",
|
||||
"actionGetAiProvider": "Získat poskytovatele AI",
|
||||
@@ -3727,6 +3741,27 @@
|
||||
"TTL": "TTL",
|
||||
"howToAddRecords": "Jak přidat záznamy",
|
||||
"dnsRecord": "Záznamy DNS",
|
||||
"customDnsRecords": "Vlastní záznamy DNS",
|
||||
"customDnsRecordsAdd": "Přidat záznam",
|
||||
"customDnsRecordsSearch": "Prohledat záznamy...",
|
||||
"customDnsRecordsUnverifiedTitle": "Záznamy zatím nejsou poskytovány",
|
||||
"customDnsRecordsUnverifiedDescription": "Tato doména nebyla ověřena. Nyní můžete přidat záznamy, ale jmenné servery Pangolin je neposkytnou, dokud jmenné servery domény nebudou ukazovat na Pangolin a ověření nebude dokončeno.",
|
||||
"customDnsRecordCreate": "Přidat DNS záznam",
|
||||
"customDnsRecordEdit": "Upravit DNS záznam",
|
||||
"customDnsRecordFormDescription": "Tento záznam bude poskytován pro {domain}. Změny se mohou začít projevovat během několika minut.",
|
||||
"customDnsRecordNameDescription": "Použijte @ pro kořen domény nebo * jako první label pro záznam všech jmen.",
|
||||
"customDnsRecordValueDescription": "Zadejte jednu hodnotu na řádek, např. oba jmenné servery záznamu NS.",
|
||||
"customDnsRecordPriority": "Priorita",
|
||||
"customDnsRecordWeight": "Váha",
|
||||
"customDnsRecordPort": "Port (v kontekstu: customDnsRecordPort)",
|
||||
"customDnsRecordCreated": "DNS záznam byl vytvořen",
|
||||
"customDnsRecordUpdated": "DNS záznam byl aktualizován",
|
||||
"customDnsRecordSaveError": "Nepodařilo se uložit DNS záznam",
|
||||
"customDnsRecordDeleted": "DNS záznam byl smazán",
|
||||
"customDnsRecordDeleteError": "Nepodařilo se smazat DNS záznam",
|
||||
"customDnsRecordDeleteTitle": "Smazat DNS záznam",
|
||||
"customDnsRecordDeleteQuestion": "Opravdu chcete smazat {type} záznam pro {name}? Přestane se zobrazovat, jakmile vyprší odpovědi v cache.",
|
||||
"customDnsRecordDeleteConfirm": "Smazat záznam",
|
||||
"required": "Požadováno",
|
||||
"domainSettingsUpdated": "Nastavení domény bylo úspěšně aktualizováno",
|
||||
"orgOrDomainIdMissing": "Chybí ID organizace nebo domény",
|
||||
@@ -3938,14 +3973,14 @@
|
||||
"backToHome": "Zpět na domovskou stránku",
|
||||
"needToSignInToOrg": "Potřebujete použít identitního poskytovatele vaší organizace?",
|
||||
"mtls": "mTLS",
|
||||
"mtlsCaCertificates": "Důvěryhodné certifikáty CA",
|
||||
"mtlsCaCertificates": "Nastavit klientské certifikáty",
|
||||
"mtlsCaCertificatesDescription": "Klienti musí předložit certifikát podepsaný jedním z těchto certifikačních autorit, aby se připojili. Nahrávejte pouze veřejné certifikáty CA, nikdy soukromý klíč.",
|
||||
"mtlsCertificateName": "Jméno (nepovinné)",
|
||||
"mtlsCertificateNamePlaceholder": "CA certifikát podnikového zařízení",
|
||||
"mtlsCertificatePem": "CA certifikát (PEM)",
|
||||
"mtlsCertificatePemDescription": "Vložte certifikát kódovaný v PEM nebo svazek certifikátů, nebo nahrajte soubor .pem, .crt nebo .cer.",
|
||||
"mtlsUploadFile": "Nahrát soubor",
|
||||
"mtlsAddCertificate": "Přidat CA certifikát",
|
||||
"mtlsAddCertificate": "Přidat certifikát CA",
|
||||
"mtlsCertificateAdded": "CA certifikát přidán",
|
||||
"mtlsCertificateAddedDescription": "Klienti s certifikáty podepsanými touto CA mohou nyní být ověřeni.",
|
||||
"mtlsCertificateErrorAdd": "Nepodařilo se přidat CA certifikát",
|
||||
@@ -3955,6 +3990,10 @@
|
||||
"mtlsCertificateErrorRemoveDescription": "Certifikát nebylo možné odstranit.",
|
||||
"mtlsLastCertificateRemoved": "mTLS byl vypnut, protože byl odstraněn poslední certifikát CA.",
|
||||
"mtlsNoCertificates": "Zatím nebyly přidány žádné certifikáty CA",
|
||||
"mtlsPendingCertificate": "CA certifikát {number}",
|
||||
"mtlsCertificateInvalidPem": "Nebyly nalezeny žádné certifikáty ve formátu PEM. Očekávaný blok začínající příkazem -----BEGIN CERTIFICATE-----.",
|
||||
"mtlsCertificatePrivateKey": "Vypadá to jako soukromý klíč, ne certifikát. Nahrajte pouze veřejný certifikát CA.",
|
||||
"mtlsCertificateDuplicate": "Tento certifikát CA byl již přidán.",
|
||||
"mtlsRemoveCertificate": "Odstranit CA certifikát",
|
||||
"mtlsExpired": "Vypršelo",
|
||||
"mtlsExpiresSoon": "Brzy vyprší",
|
||||
|
||||
+42
-3
@@ -604,6 +604,10 @@
|
||||
"licenseNoUserLimit": "Der er ingen grænse for antallet af brugere for denne host.",
|
||||
"licenseUsersUsedMax": "{usedUsers} af {maxUsers} brugere brugt",
|
||||
"licenseUsersUsed": "{count, plural, =0 {ingen brugere} one {én bruger} other {# brugere}}",
|
||||
"licenseUsageMachines": "Maskinklienter",
|
||||
"licenseNoMachineLimit": "Der er ingen grænse for antallet af maskinklienter til denne host.",
|
||||
"licenseMachinesUsedMax": "{usedMachines} af {maxMachines} maskinklienter brugt",
|
||||
"licenseMachinesUsed": "{count, plural, =0 {# maskinklienter} one {# maskinklient} other {# maskinklienter}}",
|
||||
"licenseUnlimited": "Ubegrænset",
|
||||
"licenseTierLabel": "Niveau",
|
||||
"licensePurchaseDescription": "Vælg hvor mange sites du vil {selectedMode, select, license {købe en licens for. Du kan altid tilføje flere sites senere.} other {tilføje din eksisterende licens.}}",
|
||||
@@ -801,7 +805,7 @@
|
||||
"loadBalancingStrategy_wrr_description": "Fordel forespørgsler jævnt på tværs af målene i rotation.",
|
||||
"loadBalancingStrategy_p2c": "Power of Two Choices",
|
||||
"loadBalancingStrategy_p2c_description": "Vælg to tilfældige mål og send forespørgslen til det med færre aktive forbindelser.",
|
||||
"loadBalancingStrategy_hrw": "Klient IP Hash",
|
||||
"loadBalancingStrategy_hrw": "Højeste tilfældige vægt",
|
||||
"loadBalancingStrategy_hrw_description": "Hash klientens IP, så den samme klient konsekvent dirigeres til det samme mål.",
|
||||
"loadBalancingStrategy_leasttime": "Mindste svarstid",
|
||||
"loadBalancingStrategy_leasttime_description": "Send forespørgsler til målet med den hurtigste svartid og færrest aktive forbindelser.",
|
||||
@@ -829,6 +833,8 @@
|
||||
"save": "Gem",
|
||||
"proxyAdditional": "Yderligere Proxyindstillinger",
|
||||
"proxyAdditionalDescription": "Konfigurer hvordan ressourcen håndterer proxy-indstillingerne",
|
||||
"proxyForwardIdentityHeaders": "Videresend identitetshoveder",
|
||||
"proxyForwardIdentityHeadersDescription": "Send den autentificerede brugers identitet til backend i Remote-User, Remote-Email, Remote-Name, Remote-Role og Remote-User-Id hoveder. Deaktiver for backends, der afviser forespørgsler med ugenkendte hoveder.",
|
||||
"proxyCustomHeader": "Tilpasset host-header",
|
||||
"proxyCustomHeaderDescription": "Host-header som skal settes ved videresendelse af forespørgsler. Lad stå tom for at bruge standardinnstillingen.",
|
||||
"proxyAdditionalSubmit": "Gem proxyindstillinger",
|
||||
@@ -933,6 +939,9 @@
|
||||
"policyAuthHeaderAuthTitle": "Grunnleggende Header Autentificering",
|
||||
"policyAuthHeaderAuthDescription": "Bekræft et tilpasset HTTP-headernavn og værdi ved hver forespørgsel",
|
||||
"policyAuthHeaderAuthSummary": "Header konfigureret",
|
||||
"policyAuthMtlsTitle": "Kundecertifikat (mTLS)",
|
||||
"policyAuthMtlsDescription": "Kræv et kundecertifikat underskrevet af en betroet CA under TLS-håndtrykket",
|
||||
"policyAuthMtlsSummary": "{count} betroede CA-certifikater",
|
||||
"policyAuthHeaderName": "Brugernavn",
|
||||
"policyAuthHeaderValue": "Adgangskode",
|
||||
"policyAuthSetPasscode": "Angiv adgangskode",
|
||||
@@ -1500,10 +1509,15 @@
|
||||
"actionDeleteOrgDomain": "Slet domæne",
|
||||
"actionGetDNSRecords": "Hent DNS-poster",
|
||||
"actionRestartOrgDomain": "Genstart Domæne",
|
||||
"actionListCustomDnsRecords": "Liste over tilpassede DNS-poster",
|
||||
"actionCreateCustomDnsRecord": "Opret tilpasset DNS-post",
|
||||
"actionUpdateCustomDnsRecord": "Opdater tilpasset DNS-post",
|
||||
"actionDeleteCustomDnsRecord": "Slet tilpasset DNS-post",
|
||||
"actionCreateSite": "Opret site",
|
||||
"actionDeleteSite": "Slet site",
|
||||
"actionGetSite": "Hent site",
|
||||
"actionListSites": "Vis sites",
|
||||
"actionReGenerateSecret": "Regenerer brugeroplysninger",
|
||||
"actionCreateAiProvider": "Opret AI-udbyder",
|
||||
"actionDeleteAiProvider": "Slet AI-udbyder",
|
||||
"actionGetAiProvider": "Få AI-udbyder",
|
||||
@@ -3727,6 +3741,27 @@
|
||||
"TTL": "TTL",
|
||||
"howToAddRecords": "Hvordan tilføje poster",
|
||||
"dnsRecord": "DNS registre",
|
||||
"customDnsRecords": "Tilpassede DNS-poster",
|
||||
"customDnsRecordsAdd": "Tilføj post",
|
||||
"customDnsRecordsSearch": "Søg poster...",
|
||||
"customDnsRecordsUnverifiedTitle": "Poster serveres endnu ikke",
|
||||
"customDnsRecordsUnverifiedDescription": "Dette domæne er ikke verificeret. Du kan tilføje poster nu, men Pangolin's navneservere vil ikke servere dem, før domænenavneservere peger mod Pangolin, og verifikationen er færdig.",
|
||||
"customDnsRecordCreate": "Tilføj DNS-post",
|
||||
"customDnsRecordEdit": "Rediger DNS-post",
|
||||
"customDnsRecordFormDescription": "Denne post vil blive serveret for {domain}. Ændringer kan tage et par minutter at blive opdateret.",
|
||||
"customDnsRecordNameDescription": "Brug @ for rodens domæne eller * som første etiket for et wildcard.",
|
||||
"customDnsRecordValueDescription": "Indtast én værdi per linje, f.eks. begge navneservere for en NS-post.",
|
||||
"customDnsRecordPriority": "Prioritet",
|
||||
"customDnsRecordWeight": "Vægt",
|
||||
"customDnsRecordPort": "Porten",
|
||||
"customDnsRecordCreated": "DNS-post oprettet",
|
||||
"customDnsRecordUpdated": "DNS-post opdateret",
|
||||
"customDnsRecordSaveError": "Kunne ikke gemme DNS-post",
|
||||
"customDnsRecordDeleted": "DNS-post slettet",
|
||||
"customDnsRecordDeleteError": "Kunne ikke slette DNS-post",
|
||||
"customDnsRecordDeleteTitle": "Slet DNS-post",
|
||||
"customDnsRecordDeleteQuestion": "Er du sikker på, at du vil slette {type}-posten for {name}? Den vil stoppe med at blive løst, når cachede svar udløber.",
|
||||
"customDnsRecordDeleteConfirm": "Slet post",
|
||||
"required": "Påkrævet",
|
||||
"domainSettingsUpdated": "Domæne indstillinger blev opdateret",
|
||||
"orgOrDomainIdMissing": "ID for organisation eller domæne mangler",
|
||||
@@ -3938,7 +3973,7 @@
|
||||
"backToHome": "Gå tilbage til start",
|
||||
"needToSignInToOrg": "Behøver du at bruge organisationens identitetsudbyder?",
|
||||
"mtls": "mTLS",
|
||||
"mtlsCaCertificates": "Betroede CA-certifikater",
|
||||
"mtlsCaCertificates": "Indstil kundecertifikater",
|
||||
"mtlsCaCertificatesDescription": "Klienter skal præsentere et certifikat underskrevet af en af disse certifikatautoriteter for at oprette forbindelse. Kun upload offentlige CA-certifikater, aldrig en privat nøgle.",
|
||||
"mtlsCertificateName": "Navn (valgfrit)",
|
||||
"mtlsCertificateNamePlaceholder": "Virksomhedsenhed CA",
|
||||
@@ -3955,6 +3990,10 @@
|
||||
"mtlsCertificateErrorRemoveDescription": "Certifikatet kunne ikke fjernes.",
|
||||
"mtlsLastCertificateRemoved": "mTLS blev slået fra fordi det sidste CA-certifikat blev fjernet.",
|
||||
"mtlsNoCertificates": "Ingen CA-certifikater tilføjet endnu",
|
||||
"mtlsPendingCertificate": "CA-certifikat {number}",
|
||||
"mtlsCertificateInvalidPem": "Ingen PEM-kodet certifikat fundet. Forventet en blok der starter med -----BEGIN CERTIFICATE-----.",
|
||||
"mtlsCertificatePrivateKey": "Dette ligner en privat nøgle, ikke et certifikat. Upload kun CA's offentlige certifikat.",
|
||||
"mtlsCertificateDuplicate": "Dette CA-certifikat er allerede tilføjet.",
|
||||
"mtlsRemoveCertificate": "Fjern CA-certifikat",
|
||||
"mtlsExpired": "Udløbet",
|
||||
"mtlsExpiresSoon": "Udløber snart",
|
||||
@@ -4488,7 +4527,7 @@
|
||||
"rdpFull": "Fuldt",
|
||||
"rdpReal": "Ekte",
|
||||
"rdpMeta": "Meta",
|
||||
"rdpUploadFiles": "Upload Filer",
|
||||
"rdpUploadFiles": "Upload filer",
|
||||
"rdpFilesReadyToPaste": "Filer klare til at limes ind",
|
||||
"rdpFilesReadyToPasteDescription": "{count} fil(er) kopieret til fjernudklipsholderen — trykk Ctrl+V på fjernskrivebordet for at indsætte.",
|
||||
"rdpUploadFailed": "Uploaden mislykkedes",
|
||||
|
||||
+41
-2
@@ -604,6 +604,10 @@
|
||||
"licenseNoUserLimit": "Es gibt keine Begrenzung hinsichtlich der Anzahl der Benutzer für diesen Host.",
|
||||
"licenseUsersUsedMax": "{usedUsers} von {maxUsers} Benutzern verwendet",
|
||||
"licenseUsersUsed": "{count, plural, =0 {# Benutzer} one {# Benutzer} other {# Benutzer}}",
|
||||
"licenseUsageMachines": "Maschinen-Clients",
|
||||
"licenseNoMachineLimit": "Die Anzahl der Maschinen-Clients für diesen Host ist unbegrenzt.",
|
||||
"licenseMachinesUsedMax": "{usedMachines} von {maxMachines} Maschinen-Clients verwendet",
|
||||
"licenseMachinesUsed": "{count,plural,=0 {<b>#</b> Maschinen-Clients} one {<b>#</b> Maschinen-Client} other {<b>#</b> Maschinen-Clients}}",
|
||||
"licenseUnlimited": "Unbegrenzt",
|
||||
"licenseTierLabel": "Stufe",
|
||||
"licensePurchaseDescription": "Wähle aus, für wie viele Standorte du möchtest {selectedMode, select, license {kaufe eine Lizenz. Du kannst später immer weitere Standorte hinzufügen.} other {Füge zu deiner bestehenden Lizenz hinzu.}}",
|
||||
@@ -801,7 +805,7 @@
|
||||
"loadBalancingStrategy_wrr_description": "Verteilen Sie Anfragen gleichmäßig über Ziele in Rotation.",
|
||||
"loadBalancingStrategy_p2c": "Macht der Zwei Wahlmöglichkeiten",
|
||||
"loadBalancingStrategy_p2c_description": "Wählen Sie zwei zufällige Ziele und senden Sie die Anfrage an dasjenige mit weniger aktiven Verbindungen.",
|
||||
"loadBalancingStrategy_hrw": "Client-IP-Hash",
|
||||
"loadBalancingStrategy_hrw": "Höchstes Zufallsgewicht",
|
||||
"loadBalancingStrategy_hrw_description": "Hashen Sie die Client-IP, damit derselbe Client konsistent zum selben Ziel geleitet wird.",
|
||||
"loadBalancingStrategy_leasttime": "Wenigste Antwortzeit",
|
||||
"loadBalancingStrategy_leasttime_description": "Senden Sie Anfragen an das Ziel mit der schnellsten Antwortzeit und den wenigsten aktiven Verbindungen.",
|
||||
@@ -829,6 +833,8 @@
|
||||
"save": "Speichern",
|
||||
"proxyAdditional": "Zusätzliche Proxy-Einstellungen",
|
||||
"proxyAdditionalDescription": "Konfigurieren Sie die Proxy-Einstellungen der Ressource",
|
||||
"proxyForwardIdentityHeaders": "Identitäts-Header weiterleiten",
|
||||
"proxyForwardIdentityHeadersDescription": "Die Identität des authentifizierten Benutzers an das Backend in den Remote-User, Remote-Email, Remote-Name, Remote-Role und Remote-User-Id Headern senden. Deaktivieren für Backends, die Anfragen mit unbekannten Headern ablehnen.",
|
||||
"proxyCustomHeader": "Benutzerdefinierter Host-Header",
|
||||
"proxyCustomHeaderDescription": "Der Host-Header, der beim Weiterleiten von Anfragen gesetzt werden soll. Leer lassen, um den Standard zu verwenden.",
|
||||
"proxyAdditionalSubmit": "Proxy-Einstellungen speichern",
|
||||
@@ -933,6 +939,9 @@
|
||||
"policyAuthHeaderAuthTitle": "Grundlegende Header-Authentifizierung",
|
||||
"policyAuthHeaderAuthDescription": "Überprüfen Sie einen benutzerdefinierten HTTP-Headernamen und -wert bei jeder Anfrage",
|
||||
"policyAuthHeaderAuthSummary": "Header konfiguriert",
|
||||
"policyAuthMtlsTitle": "Client-Zertifikat (mTLS)",
|
||||
"policyAuthMtlsDescription": "Ein Client-Zertifikat von einer vertrauenswürdigen CA während des TLS-Handshakes verlangen",
|
||||
"policyAuthMtlsSummary": "{count,plural,one {<b>#</b> vertrauenswürdiges CA-Zertifikat} other {<b>#</b> vertrauenswürdige CA-Zertifikate}}",
|
||||
"policyAuthHeaderName": "Benutzername",
|
||||
"policyAuthHeaderValue": "Passwort",
|
||||
"policyAuthSetPasscode": "Passcode setzen",
|
||||
@@ -1500,10 +1509,15 @@
|
||||
"actionDeleteOrgDomain": "Domain löschen",
|
||||
"actionGetDNSRecords": "DNS-Einträge abrufen",
|
||||
"actionRestartOrgDomain": "Domain neu starten",
|
||||
"actionListCustomDnsRecords": "Benutzerdefinierte DNS-Einträge auflisten",
|
||||
"actionCreateCustomDnsRecord": "Benutzerdefinierten DNS-Eintrag erstellen",
|
||||
"actionUpdateCustomDnsRecord": "Benutzerdefinierten DNS-Eintrag aktualisieren",
|
||||
"actionDeleteCustomDnsRecord": "Benutzerdefinierten DNS-Eintrag löschen",
|
||||
"actionCreateSite": "Standort erstellen",
|
||||
"actionDeleteSite": "Standort löschen",
|
||||
"actionGetSite": "Standort abrufen",
|
||||
"actionListSites": "Standorte auflisten",
|
||||
"actionReGenerateSecret": "Zugangsdaten neu generieren",
|
||||
"actionCreateAiProvider": "KI-Anbieter erstellen",
|
||||
"actionDeleteAiProvider": "KI-Anbieter löschen",
|
||||
"actionGetAiProvider": "KI-Anbieter abrufen",
|
||||
@@ -3727,6 +3741,27 @@
|
||||
"TTL": "TTL",
|
||||
"howToAddRecords": "So kann man Datensätze hinzufügen",
|
||||
"dnsRecord": "DNS-Einträge",
|
||||
"customDnsRecords": "Benutzerdefinierte DNS-Einträge",
|
||||
"customDnsRecordsAdd": "Eintrag hinzufügen",
|
||||
"customDnsRecordsSearch": "Einträge durchsuchen...",
|
||||
"customDnsRecordsUnverifiedTitle": "Einträge werden noch nicht bereitgestellt",
|
||||
"customDnsRecordsUnverifiedDescription": "Diese Domain wurde nicht verifiziert. Sie können jetzt Einträge hinzufügen, aber die Nameserver von Pangolin werden sie nicht bereitstellen, bis die Nameserver der Domain auf Pangolin zeigen und die Verifizierung abgeschlossen ist.",
|
||||
"customDnsRecordCreate": "DNS-Eintrag hinzufügen",
|
||||
"customDnsRecordEdit": "DNS-Eintrag bearbeiten",
|
||||
"customDnsRecordFormDescription": "Dieser Eintrag wird für {domain} bereitgestellt. Änderungen können einige Minuten dauern, um sich zu verbreiten.",
|
||||
"customDnsRecordNameDescription": "Verwende @ für die Wurzel der Domain oder * als erstes Label für eine Platzhalterfunktion.",
|
||||
"customDnsRecordValueDescription": "Geben Sie einen Wert pro Zeile ein, z.B. beide Nameserver eines NS-Eintrags.",
|
||||
"customDnsRecordPriority": "Priorität",
|
||||
"customDnsRecordWeight": "Gewicht",
|
||||
"customDnsRecordPort": "Port",
|
||||
"customDnsRecordCreated": "DNS-Eintrag erstellt",
|
||||
"customDnsRecordUpdated": "DNS-Eintrag aktualisiert",
|
||||
"customDnsRecordSaveError": "Fehler beim Speichern des DNS-Eintrags",
|
||||
"customDnsRecordDeleted": "DNS-Eintrag gelöscht",
|
||||
"customDnsRecordDeleteError": "Fehler beim Löschen des DNS-Eintrags",
|
||||
"customDnsRecordDeleteTitle": "DNS-Eintrag löschen",
|
||||
"customDnsRecordDeleteQuestion": "Sind Sie sicher, dass Sie den {type}-Eintrag für {name} löschen möchten? Er wird aufhören, aufgelöst zu werden, sobald die zwischengespeicherten Antworten ablaufen.",
|
||||
"customDnsRecordDeleteConfirm": "Eintrag löschen",
|
||||
"required": "Benötigt",
|
||||
"domainSettingsUpdated": "Domain-Einstellungen erfolgreich aktualisiert",
|
||||
"orgOrDomainIdMissing": "Organisation oder Domänen-ID fehlt",
|
||||
@@ -3938,7 +3973,7 @@
|
||||
"backToHome": "Zurück zur Startseite",
|
||||
"needToSignInToOrg": "Benötigen Sie den Identitätsanbieter Ihres Unternehmens?",
|
||||
"mtls": "mTLS",
|
||||
"mtlsCaCertificates": "Vertrauenswürdige CA-Zertifikate",
|
||||
"mtlsCaCertificates": "Client-Zertifikate festlegen",
|
||||
"mtlsCaCertificatesDescription": "Clients müssen ein Zertifikat vorlegen, das von einer dieser Zertifizierungsstellen signiert wurde, um eine Verbindung herzustellen. Laden Sie nur öffentliche CA-Zertifikate hoch, niemals einen privaten Schlüssel.",
|
||||
"mtlsCertificateName": "Name (optional)",
|
||||
"mtlsCertificateNamePlaceholder": "Unternehmensgerät-CA",
|
||||
@@ -3955,6 +3990,10 @@
|
||||
"mtlsCertificateErrorRemoveDescription": "Das Zertifikat konnte nicht entfernt werden.",
|
||||
"mtlsLastCertificateRemoved": "mTLS wurde deaktiviert, da das letzte CA-Zertifikat entfernt wurde.",
|
||||
"mtlsNoCertificates": "Noch keine CA-Zertifikate hinzugefügt",
|
||||
"mtlsPendingCertificate": "CA-Zertifikat {number}",
|
||||
"mtlsCertificateInvalidPem": "Kein PEM-kodiertes Zertifikat gefunden. Erwarte einen Block, der mit -----BEGIN CERTIFICATE----- beginnt.",
|
||||
"mtlsCertificatePrivateKey": "Das sieht wie ein privater Schlüssel aus, nicht wie ein Zertifikat. Laden Sie nur das öffentliche Zertifikat der CA hoch.",
|
||||
"mtlsCertificateDuplicate": "Dieses CA-Zertifikat wurde bereits hinzugefügt.",
|
||||
"mtlsRemoveCertificate": "CA-Zertifikat entfernen",
|
||||
"mtlsExpired": "Abgelaufen",
|
||||
"mtlsExpiresSoon": "Läuft bald ab",
|
||||
|
||||
@@ -1509,6 +1509,10 @@
|
||||
"actionDeleteOrgDomain": "Delete Domain",
|
||||
"actionGetDNSRecords": "Get DNS Records",
|
||||
"actionRestartOrgDomain": "Restart Domain",
|
||||
"actionListCustomDnsRecords": "List Custom DNS Records",
|
||||
"actionCreateCustomDnsRecord": "Create Custom DNS Record",
|
||||
"actionUpdateCustomDnsRecord": "Update Custom DNS Record",
|
||||
"actionDeleteCustomDnsRecord": "Delete Custom DNS Record",
|
||||
"actionCreateSite": "Create Site",
|
||||
"actionDeleteSite": "Delete Site",
|
||||
"actionGetSite": "Get Site",
|
||||
@@ -3737,6 +3741,27 @@
|
||||
"TTL": "TTL",
|
||||
"howToAddRecords": "How to Add Records",
|
||||
"dnsRecord": "DNS Records",
|
||||
"customDnsRecords": "Custom DNS Records",
|
||||
"customDnsRecordsAdd": "Add Record",
|
||||
"customDnsRecordsSearch": "Search records...",
|
||||
"customDnsRecordsUnverifiedTitle": "Records are not being served yet",
|
||||
"customDnsRecordsUnverifiedDescription": "This domain hasn't been verified. You can add records now, but Pangolin's nameservers won't serve them until the domain's nameservers point to Pangolin and verification completes.",
|
||||
"customDnsRecordCreate": "Add DNS Record",
|
||||
"customDnsRecordEdit": "Edit DNS Record",
|
||||
"customDnsRecordFormDescription": "This record will be served for {domain}. Changes may take a few minutes to propagate.",
|
||||
"customDnsRecordNameDescription": "Use @ for the root of the domain or * as the first label for a wildcard.",
|
||||
"customDnsRecordValueDescription": "Enter one value per line, e.g. both nameservers of an NS record.",
|
||||
"customDnsRecordPriority": "Priority",
|
||||
"customDnsRecordWeight": "Weight",
|
||||
"customDnsRecordPort": "Port",
|
||||
"customDnsRecordCreated": "DNS record created",
|
||||
"customDnsRecordUpdated": "DNS record updated",
|
||||
"customDnsRecordSaveError": "Failed to save DNS record",
|
||||
"customDnsRecordDeleted": "DNS record deleted",
|
||||
"customDnsRecordDeleteError": "Failed to delete DNS record",
|
||||
"customDnsRecordDeleteTitle": "Delete DNS Record",
|
||||
"customDnsRecordDeleteQuestion": "Are you sure you want to delete the {type} record for {name}? It will stop resolving once cached answers expire.",
|
||||
"customDnsRecordDeleteConfirm": "Delete Record",
|
||||
"required": "Required",
|
||||
"domainSettingsUpdated": "Domain settings updated successfully",
|
||||
"orgOrDomainIdMissing": "Organization or Domain ID is missing",
|
||||
|
||||
+41
-2
@@ -604,6 +604,10 @@
|
||||
"licenseNoUserLimit": "No hay límite en el número de usuarios para este host.",
|
||||
"licenseUsersUsedMax": "{usedUsers} de {maxUsers} usuarios usados",
|
||||
"licenseUsersUsed": "{count, plural, =0 {# usuarios} one {# usuario} other {# usuarios}}",
|
||||
"licenseUsageMachines": "Clientes de la máquina",
|
||||
"licenseNoMachineLimit": "No hay límite en el número de clientes de máquina para este host.",
|
||||
"licenseMachinesUsedMax": "{usedMachines} de {maxMachines} clientes de máquina usados",
|
||||
"licenseMachinesUsed": "{count, plural, =0 {# clientes de máquina} one {# cliente de máquina} other {# clientes de máquina}}",
|
||||
"licenseUnlimited": "Ilimitado",
|
||||
"licenseTierLabel": "Categoría",
|
||||
"licensePurchaseDescription": "Elige cuántos sitios quieres {selectedMode, select, license {compra una licencia para. Siempre puedes añadir más sitios más tarde.} other {añadir a tu licencia existente.}}",
|
||||
@@ -801,7 +805,7 @@
|
||||
"loadBalancingStrategy_wrr_description": "Distribuir solicitudes uniformemente entre los objetivos en rotación.",
|
||||
"loadBalancingStrategy_p2c": "Poder de Dos Opciones",
|
||||
"loadBalancingStrategy_p2c_description": "Escoge dos objetivos al azar y envía la solicitud al que tenga menos conexiones activas.",
|
||||
"loadBalancingStrategy_hrw": "Hash de IP del Cliente",
|
||||
"loadBalancingStrategy_hrw": "Mayor peso aleatorio",
|
||||
"loadBalancingStrategy_hrw_description": "Hashea la IP del cliente para que el mismo cliente sea dirigido constantemente al mismo objetivo.",
|
||||
"loadBalancingStrategy_leasttime": "Menor Tiempo de Respuesta",
|
||||
"loadBalancingStrategy_leasttime_description": "Envía solicitudes al objetivo con el tiempo de respuesta más rápido y menos conexiones activas.",
|
||||
@@ -829,6 +833,8 @@
|
||||
"save": "Guardar",
|
||||
"proxyAdditional": "Ajustes adicionales del proxy",
|
||||
"proxyAdditionalDescription": "Configurar cómo maneja el recurso la configuración del proxy",
|
||||
"proxyForwardIdentityHeaders": "Encabezados de identidad enviados al proxy",
|
||||
"proxyForwardIdentityHeadersDescription": "Enviar la identidad del usuario autenticado al backend en los encabezados Remote-User, Remote-Email, Remote-Name, Remote-Role y Remote-User-Id. Desactivar para backends que rechazan solicitudes con encabezados no reconocidos.",
|
||||
"proxyCustomHeader": "Cabecera de host personalizada",
|
||||
"proxyCustomHeaderDescription": "La cabecera del host a establecer cuando se realizan peticiones de reemplazo. Deje en blanco para usar el valor predeterminado.",
|
||||
"proxyAdditionalSubmit": "Guardar ajustes de proxy",
|
||||
@@ -933,6 +939,9 @@
|
||||
"policyAuthHeaderAuthTitle": "Autenticación Básica del Encabezado",
|
||||
"policyAuthHeaderAuthDescription": "Valida un nombre y valor de encabezado HTTP personalizado en cada petición",
|
||||
"policyAuthHeaderAuthSummary": "Encabezado configurado",
|
||||
"policyAuthMtlsTitle": "Certificado de cliente (mTLS)",
|
||||
"policyAuthMtlsDescription": "Requiere un certificado de cliente firmado por una CA de confianza durante el intercambio TLS",
|
||||
"policyAuthMtlsSummary": "{count} certificados CA de confianza",
|
||||
"policyAuthHeaderName": "Usuario",
|
||||
"policyAuthHeaderValue": "Contraseña",
|
||||
"policyAuthSetPasscode": "Establecer Código de Acceso",
|
||||
@@ -1500,10 +1509,15 @@
|
||||
"actionDeleteOrgDomain": "Eliminar dominio",
|
||||
"actionGetDNSRecords": "Obtener registros DNS",
|
||||
"actionRestartOrgDomain": "Reiniciar dominio",
|
||||
"actionListCustomDnsRecords": "Listar registros DNS personalizados",
|
||||
"actionCreateCustomDnsRecord": "Crear registro DNS personalizado",
|
||||
"actionUpdateCustomDnsRecord": "Actualizar registro DNS personalizado",
|
||||
"actionDeleteCustomDnsRecord": "Eliminar registro DNS personalizado",
|
||||
"actionCreateSite": "Crear sitio",
|
||||
"actionDeleteSite": "Eliminar sitio",
|
||||
"actionGetSite": "Obtener sitio",
|
||||
"actionListSites": "Listar sitios",
|
||||
"actionReGenerateSecret": "Regenerar credenciales",
|
||||
"actionCreateAiProvider": "Crear Proveedor de IA",
|
||||
"actionDeleteAiProvider": "Eliminar Proveedor de IA",
|
||||
"actionGetAiProvider": "Obtener Proveedor de IA",
|
||||
@@ -3727,6 +3741,27 @@
|
||||
"TTL": "TTL",
|
||||
"howToAddRecords": "Cómo añadir registros",
|
||||
"dnsRecord": "Registros DNS",
|
||||
"customDnsRecords": "Registros DNS personalizados",
|
||||
"customDnsRecordsAdd": "Agregar registro",
|
||||
"customDnsRecordsSearch": "Buscar registros...",
|
||||
"customDnsRecordsUnverifiedTitle": "Los registros aún no están siendo servidos",
|
||||
"customDnsRecordsUnverifiedDescription": "Este dominio no ha sido verificado. Puede agregar registros ahora, pero los servidores de nombres de Pangolin no los servirán hasta que los servidores de nombres del dominio apunten a Pangolin y la verificación se complete.",
|
||||
"customDnsRecordCreate": "Agregar registro DNS",
|
||||
"customDnsRecordEdit": "Editar registro DNS",
|
||||
"customDnsRecordFormDescription": "Este registro se servirá para {domain}. Los cambios pueden tardar unos minutos en propagarse.",
|
||||
"customDnsRecordNameDescription": "Use @ para la raíz del dominio o * como la primera etiqueta para un comodín.",
|
||||
"customDnsRecordValueDescription": "Ingrese un valor por línea, por ejemplo, ambos servidores de nombres de un registro NS.",
|
||||
"customDnsRecordPriority": "Prioridad",
|
||||
"customDnsRecordWeight": "Peso",
|
||||
"customDnsRecordPort": "Puerto",
|
||||
"customDnsRecordCreated": "Registro DNS creado",
|
||||
"customDnsRecordUpdated": "Registro DNS actualizado",
|
||||
"customDnsRecordSaveError": "Error al guardar el registro DNS",
|
||||
"customDnsRecordDeleted": "Registro DNS eliminado",
|
||||
"customDnsRecordDeleteError": "Error al eliminar el registro DNS",
|
||||
"customDnsRecordDeleteTitle": "Eliminar registro DNS",
|
||||
"customDnsRecordDeleteQuestion": "¿Está seguro de que desea eliminar el registro {type} para {name}? Dejará de resolverse una vez que expiren las respuestas en caché.",
|
||||
"customDnsRecordDeleteConfirm": "Eliminar registro",
|
||||
"required": "Requerido",
|
||||
"domainSettingsUpdated": "Configuración de dominio actualizada correctamente",
|
||||
"orgOrDomainIdMissing": "Falta el ID de organización o dominio",
|
||||
@@ -3938,7 +3973,7 @@
|
||||
"backToHome": "Volver a inicio",
|
||||
"needToSignInToOrg": "¿Necesita usar el proveedor de identidad de su organización?",
|
||||
"mtls": "mTLS",
|
||||
"mtlsCaCertificates": "Certificados de CA de confianza",
|
||||
"mtlsCaCertificates": "Establecer certificados de cliente",
|
||||
"mtlsCaCertificatesDescription": "Los clientes deben presentar un certificado firmado por una de estas autoridades de certificación para conectarse. Sube solo certificados públicos de CA, nunca una clave privada.",
|
||||
"mtlsCertificateName": "Nombre (opcional)",
|
||||
"mtlsCertificateNamePlaceholder": "CA del dispositivo corporativo",
|
||||
@@ -3955,6 +3990,10 @@
|
||||
"mtlsCertificateErrorRemoveDescription": "El certificado no pudo ser eliminado.",
|
||||
"mtlsLastCertificateRemoved": "mTLS fue desactivado porque se eliminó el último certificado CA.",
|
||||
"mtlsNoCertificates": "Aún no se han agregado certificados CA",
|
||||
"mtlsPendingCertificate": "Certificado CA {number}",
|
||||
"mtlsCertificateInvalidPem": "No se encontró un certificado codificado PEM. Se esperaba un bloque que comenzara con -----BEGIN CERTIFICATE-----.",
|
||||
"mtlsCertificatePrivateKey": "Esto parece una clave privada, no un certificado. Suba solo el certificado público del CA.",
|
||||
"mtlsCertificateDuplicate": "Este certificado CA ya ha sido añadido.",
|
||||
"mtlsRemoveCertificate": "Eliminar certificado CA",
|
||||
"mtlsExpired": "Caducado",
|
||||
"mtlsExpiresSoon": "Caducará pronto",
|
||||
|
||||
+41
-2
@@ -604,6 +604,10 @@
|
||||
"licenseNoUserLimit": "Il n'y a pas de limite sur le nombre d'utilisateurs pour cet hôte.",
|
||||
"licenseUsersUsedMax": "{usedUsers} des {maxUsers} utilisateurs utilisés",
|
||||
"licenseUsersUsed": "{count, plural, =0 {# utilisateurs} one {# utilisateur} other {# utilisateurs}}",
|
||||
"licenseUsageMachines": "Clients Machines",
|
||||
"licenseNoMachineLimit": "Il n'y a pas de limite sur le nombre de clients machines pour cet hôte.",
|
||||
"licenseMachinesUsedMax": "{usedMachines} de {maxMachines} clients machines utilisés",
|
||||
"licenseMachinesUsed": "{count, plural, =0 {# clients machine} one {# client machine} other {# clients machine}}",
|
||||
"licenseUnlimited": "Illimité",
|
||||
"licenseTierLabel": "Niveau",
|
||||
"licensePurchaseDescription": "Choisissez le nombre de sites que vous voulez {selectedMode, select, license {achetez une licence. Vous pouvez toujours ajouter plus de sites plus tard.} other {ajouter à votre licence existante.}}",
|
||||
@@ -801,7 +805,7 @@
|
||||
"loadBalancingStrategy_wrr_description": "Distribuez les requêtes uniformément entre les cibles en rotation.",
|
||||
"loadBalancingStrategy_p2c": "Puissance de deux choix",
|
||||
"loadBalancingStrategy_p2c_description": "Choisissez deux cibles au hasard et envoyez la demande à celle qui a le moins de connexions actives.",
|
||||
"loadBalancingStrategy_hrw": "Hachage de l'adresse IP du client",
|
||||
"loadBalancingStrategy_hrw": "Poids aléatoire le plus élevé",
|
||||
"loadBalancingStrategy_hrw_description": "Hachez l'IP du client pour que le même client soit systématiquement routé vers la même cible.",
|
||||
"loadBalancingStrategy_leasttime": "Temps de réponse le plus court",
|
||||
"loadBalancingStrategy_leasttime_description": "Envoyer les demandes à la cible avec le temps de réponse le plus rapide et le moins de connexions actives.",
|
||||
@@ -829,6 +833,8 @@
|
||||
"save": "Enregistrer",
|
||||
"proxyAdditional": "Paramètres de proxy supplémentaires",
|
||||
"proxyAdditionalDescription": "Configurer comment la ressource gère les paramètres du proxy",
|
||||
"proxyForwardIdentityHeaders": "Transférer les en-têtes d'identité",
|
||||
"proxyForwardIdentityHeadersDescription": "Envoyez l'identité de l'utilisateur authentifié au backend dans les en-têtes Remote-User, Remote-Email, Remote-Name, Remote-Role et Remote-User-Id. Désactiver pour les backends qui rejettent les requêtes avec des en-têtes non reconnus.",
|
||||
"proxyCustomHeader": "En-tête Host personnalisé",
|
||||
"proxyCustomHeaderDescription": "L'en-tête host à définir lors du proxy des requêtes. Laissez vide pour utiliser la valeur par défaut.",
|
||||
"proxyAdditionalSubmit": "Enregistrer les paramètres de proxy",
|
||||
@@ -933,6 +939,9 @@
|
||||
"policyAuthHeaderAuthTitle": "Authentification de l'en-tête de base",
|
||||
"policyAuthHeaderAuthDescription": "Validez un nom et une valeur d'en-tête HTTP personnalisé à chaque requête",
|
||||
"policyAuthHeaderAuthSummary": "En-tête configuré",
|
||||
"policyAuthMtlsTitle": "Certificat Client (mTLS)",
|
||||
"policyAuthMtlsDescription": "Exiger un certificat client signé par une CA de confiance lors de l'échange TLS",
|
||||
"policyAuthMtlsSummary": "{count} certificats CA de confiance",
|
||||
"policyAuthHeaderName": "Nom d'utilisateur",
|
||||
"policyAuthHeaderValue": "Mot de passe",
|
||||
"policyAuthSetPasscode": "Définir le code confidentiel",
|
||||
@@ -1500,10 +1509,15 @@
|
||||
"actionDeleteOrgDomain": "Supprimer le domaine",
|
||||
"actionGetDNSRecords": "Récupérer les enregistrements DNS",
|
||||
"actionRestartOrgDomain": "Redémarrer le domaine",
|
||||
"actionListCustomDnsRecords": "Lister les enregistrements DNS personnalisés",
|
||||
"actionCreateCustomDnsRecord": "Créer un enregistrement DNS personnalisé",
|
||||
"actionUpdateCustomDnsRecord": "Mettre à jour l'enregistrement DNS personnalisé",
|
||||
"actionDeleteCustomDnsRecord": "Supprimer l'enregistrement DNS personnalisé",
|
||||
"actionCreateSite": "Créer un site",
|
||||
"actionDeleteSite": "Supprimer un site",
|
||||
"actionGetSite": "Obtenir un site",
|
||||
"actionListSites": "Lister les sites",
|
||||
"actionReGenerateSecret": "Régénérer les identifiants",
|
||||
"actionCreateAiProvider": "Créer un fournisseur AI",
|
||||
"actionDeleteAiProvider": "Supprimer un fournisseur AI",
|
||||
"actionGetAiProvider": "Obtenir un fournisseur AI",
|
||||
@@ -3727,6 +3741,27 @@
|
||||
"TTL": "TTL",
|
||||
"howToAddRecords": "Comment ajouter des enregistrements",
|
||||
"dnsRecord": "Enregistrements DNS",
|
||||
"customDnsRecords": "Enregistrements DNS personnalisés",
|
||||
"customDnsRecordsAdd": "Ajouter un enregistrement",
|
||||
"customDnsRecordsSearch": "Rechercher des enregistrements...",
|
||||
"customDnsRecordsUnverifiedTitle": "Les enregistrements ne sont pas encore servis",
|
||||
"customDnsRecordsUnverifiedDescription": "Ce domaine n'a pas été vérifié. Vous pouvez ajouter des enregistrements maintenant, mais les serveurs de noms de Pangolin ne les serviront pas tant que les serveurs de noms du domaine ne pointent pas vers Pangolin et que la vérification n'est pas terminée.",
|
||||
"customDnsRecordCreate": "Ajouter un enregistrement DNS",
|
||||
"customDnsRecordEdit": "Modifier l'enregistrement DNS",
|
||||
"customDnsRecordFormDescription": "Cet enregistrement sera servi pour {domain}. Les modifications peuvent prendre quelques minutes pour se propager.",
|
||||
"customDnsRecordNameDescription": "Utilisez @ pour la racine du domaine ou * comme premier label pour un joker.",
|
||||
"customDnsRecordValueDescription": "Entrez une valeur par ligne, par exemple les deux serveurs de noms d'un enregistrement NS.",
|
||||
"customDnsRecordPriority": "Priorité",
|
||||
"customDnsRecordWeight": "Poids",
|
||||
"customDnsRecordPort": "Port",
|
||||
"customDnsRecordCreated": "Enregistrement DNS créé",
|
||||
"customDnsRecordUpdated": "Enregistrement DNS mis à jour",
|
||||
"customDnsRecordSaveError": "Échec de l'enregistrement de l'enregistrement DNS",
|
||||
"customDnsRecordDeleted": "Enregistrement DNS supprimé",
|
||||
"customDnsRecordDeleteError": "Impossible de supprimer l'enregistrement DNS",
|
||||
"customDnsRecordDeleteTitle": "Supprimer l'enregistrement DNS",
|
||||
"customDnsRecordDeleteQuestion": "Êtes-vous sûr de vouloir supprimer l'enregistrement {type} pour {name} ? Il cessera d'être résolu une fois que les réponses mises en cache auront expiré.",
|
||||
"customDnsRecordDeleteConfirm": "Supprimer l'enregistrement",
|
||||
"required": "Requis",
|
||||
"domainSettingsUpdated": "Paramètres de domaine mis à jour avec succès",
|
||||
"orgOrDomainIdMissing": "L'organisation ou l'identifiant de domaine est manquant",
|
||||
@@ -3938,7 +3973,7 @@
|
||||
"backToHome": "Retour à l'accueil",
|
||||
"needToSignInToOrg": "Besoin d'utiliser le fournisseur d'identité de votre organisation ?",
|
||||
"mtls": "mTLS",
|
||||
"mtlsCaCertificates": "Certificats CA de confiance",
|
||||
"mtlsCaCertificates": "Configurer les certificats clients",
|
||||
"mtlsCaCertificatesDescription": "Les clients doivent présenter un certificat signé par l'une de ces autorités de certification pour se connecter. Téléchargez uniquement des certificats CA publics, jamais une clé privée.",
|
||||
"mtlsCertificateName": "Nom (optionnel)",
|
||||
"mtlsCertificateNamePlaceholder": "Dispositif d'entreprise CA",
|
||||
@@ -3955,6 +3990,10 @@
|
||||
"mtlsCertificateErrorRemoveDescription": "Le certificat n'a pas pu être supprimé.",
|
||||
"mtlsLastCertificateRemoved": "mTLS a été désactivé car le dernier certificat CA a été supprimé.",
|
||||
"mtlsNoCertificates": "Aucun certificat CA ajouté pour le moment",
|
||||
"mtlsPendingCertificate": "Certificat CA {number}",
|
||||
"mtlsCertificateInvalidPem": "Aucun certificat encodé en PEM trouvé. Un bloc commençant par -----BEGIN CERTIFICATE----- était attendu.",
|
||||
"mtlsCertificatePrivateKey": "Cela ressemble à une clé privée, pas à un certificat. Chargez uniquement le certificat public de la CA.",
|
||||
"mtlsCertificateDuplicate": "Ce certificat CA a déjà été ajouté.",
|
||||
"mtlsRemoveCertificate": "Supprimer le certificat CA",
|
||||
"mtlsExpired": "Expiré",
|
||||
"mtlsExpiresSoon": "Expire bientôt",
|
||||
|
||||
+44
-5
@@ -604,6 +604,10 @@
|
||||
"licenseNoUserLimit": "Non c'è alcun limite al numero di utenti per questo host.",
|
||||
"licenseUsersUsedMax": "{usedUsers} di {maxUsers} utenti utilizzati",
|
||||
"licenseUsersUsed": "{count, plural, =0 {# utenti} one {# utente} other {# utenti}}",
|
||||
"licenseUsageMachines": "Client Macchina",
|
||||
"licenseNoMachineLimit": "Non c'è alcun limite al numero di client macchina per questo host.",
|
||||
"licenseMachinesUsedMax": "{usedMachines} di {maxMachines} client macchina utilizzati",
|
||||
"licenseMachinesUsed": "{count, plural, =0 {# client macchina} one {# client macchina} other {# client macchina}}",
|
||||
"licenseUnlimited": "Illimitato",
|
||||
"licenseTierLabel": "Livello",
|
||||
"licensePurchaseDescription": "Scegli quanti siti vuoi {selectedMode, select, license {acquista una licenza. Puoi sempre aggiungere altri siti più tardi.} other {aggiungi alla tua licenza esistente.}}",
|
||||
@@ -801,7 +805,7 @@
|
||||
"loadBalancingStrategy_wrr_description": "Distribuisci le richieste uniformemente tra i bersagli in rotazione.",
|
||||
"loadBalancingStrategy_p2c": "Power of Two Choices",
|
||||
"loadBalancingStrategy_p2c_description": "Scegli due destinatari a caso e invia la richiesta a quello con meno connessioni attive.",
|
||||
"loadBalancingStrategy_hrw": "Hash IP Client",
|
||||
"loadBalancingStrategy_hrw": "Peso Casuale Massimo",
|
||||
"loadBalancingStrategy_hrw_description": "Hashera l'IP del client in modo che lo stesso client sia instradato costantemente allo stesso target.",
|
||||
"loadBalancingStrategy_leasttime": "Tempo di Risposta Minimo",
|
||||
"loadBalancingStrategy_leasttime_description": "Invia richieste al target con il tempo di risposta più veloce e meno connessioni attive.",
|
||||
@@ -829,6 +833,8 @@
|
||||
"save": "Salva",
|
||||
"proxyAdditional": "Impostazioni Proxy Aggiuntive",
|
||||
"proxyAdditionalDescription": "Configura come la risorsa gestisce le impostazioni del proxy",
|
||||
"proxyForwardIdentityHeaders": "Inoltra Intestazioni di Identità",
|
||||
"proxyForwardIdentityHeadersDescription": "Invia l'identità dell'utente autenticato al backend nelle intestazioni Remote-User, Remote-Email, Remote-Name, Remote-Role e Remote-User-Id. Disabilita per i backend che rigettano richieste con intestazioni non riconosciute.",
|
||||
"proxyCustomHeader": "Intestazione Host Personalizzata",
|
||||
"proxyCustomHeaderDescription": "L'intestazione host da impostare durante il proxy delle richieste. Lascia vuoto per usare quella predefinita.",
|
||||
"proxyAdditionalSubmit": "Salva Impostazioni Proxy",
|
||||
@@ -933,6 +939,9 @@
|
||||
"policyAuthHeaderAuthTitle": "Autenticazione Header Base",
|
||||
"policyAuthHeaderAuthDescription": "Convalida un nome e un valore di intestazione HTTP personalizzato su ogni richiesta",
|
||||
"policyAuthHeaderAuthSummary": "Intestazione configurata",
|
||||
"policyAuthMtlsTitle": "Certificato Client (mTLS)",
|
||||
"policyAuthMtlsDescription": "Richiede un certificato client firmato da una CA fidata durante il handshake TLS",
|
||||
"policyAuthMtlsSummary": "{count} certificati CA fidati",
|
||||
"policyAuthHeaderName": "Nome utente",
|
||||
"policyAuthHeaderValue": "Password",
|
||||
"policyAuthSetPasscode": "Imposta Codice di Accesso",
|
||||
@@ -1500,10 +1509,15 @@
|
||||
"actionDeleteOrgDomain": "Elimina Dominio",
|
||||
"actionGetDNSRecords": "Ottieni Record DNS",
|
||||
"actionRestartOrgDomain": "Riavvia Dominio",
|
||||
"actionListCustomDnsRecords": "Elenca Record DNS Personalizzati",
|
||||
"actionCreateCustomDnsRecord": "Crea Record DNS Personalizzato",
|
||||
"actionUpdateCustomDnsRecord": "Aggiorna Record DNS Personalizzato",
|
||||
"actionDeleteCustomDnsRecord": "Elimina Record DNS Personalizzato",
|
||||
"actionCreateSite": "Crea Sito",
|
||||
"actionDeleteSite": "Elimina Sito",
|
||||
"actionGetSite": "Ottieni Sito",
|
||||
"actionListSites": "Elenca Siti",
|
||||
"actionReGenerateSecret": "Rigenera Credenziali",
|
||||
"actionCreateAiProvider": "Crea Provider AI",
|
||||
"actionDeleteAiProvider": "Elimina Provider AI",
|
||||
"actionGetAiProvider": "Ottieni Provider AI",
|
||||
@@ -3727,6 +3741,27 @@
|
||||
"TTL": "TTL",
|
||||
"howToAddRecords": "Come aggiungere record",
|
||||
"dnsRecord": "Record DNS",
|
||||
"customDnsRecords": "Record DNS Personalizzati",
|
||||
"customDnsRecordsAdd": "Aggiungi Record",
|
||||
"customDnsRecordsSearch": "Cerca record...",
|
||||
"customDnsRecordsUnverifiedTitle": "I record non sono ancora serviti",
|
||||
"customDnsRecordsUnverifiedDescription": "Questo dominio non è stato verificato. Puoi aggiungere i record ora, ma i nameserver di Pangolin non li serviranno fino a quando i nameserver del dominio non punteranno a Pangolin e la verifica non sarà completata.",
|
||||
"customDnsRecordCreate": "Aggiungi Record DNS",
|
||||
"customDnsRecordEdit": "Modifica Record DNS",
|
||||
"customDnsRecordFormDescription": "Questo record verrà servito per {domain}. Le modifiche potrebbero richiedere alcuni minuti per propagarsi.",
|
||||
"customDnsRecordNameDescription": "Usa @ per la radice del dominio o * come primo etichetta per un jolly.",
|
||||
"customDnsRecordValueDescription": "Inserisci un valore per riga, ad es. entrambi i nameserver di un record NS.",
|
||||
"customDnsRecordPriority": "Priorità",
|
||||
"customDnsRecordWeight": "Peso",
|
||||
"customDnsRecordPort": "Porta",
|
||||
"customDnsRecordCreated": "Record DNS creato",
|
||||
"customDnsRecordUpdated": "Record DNS aggiornato",
|
||||
"customDnsRecordSaveError": "Impossibile salvare il record DNS",
|
||||
"customDnsRecordDeleted": "Record DNS eliminato",
|
||||
"customDnsRecordDeleteError": "Impossibile eliminare il record DNS",
|
||||
"customDnsRecordDeleteTitle": "Elimina Record DNS",
|
||||
"customDnsRecordDeleteQuestion": "Sei sicuro di voler eliminare il record {type} per {name}? Smetterà di risolversi una volta che le risposte memorizzate nella cache scadranno.",
|
||||
"customDnsRecordDeleteConfirm": "Elimina Record",
|
||||
"required": "Richiesto",
|
||||
"domainSettingsUpdated": "Impostazioni dominio aggiornate con successo",
|
||||
"orgOrDomainIdMissing": "Manca l'ID dell'organizzazione o del dominio",
|
||||
@@ -3938,14 +3973,14 @@
|
||||
"backToHome": "Torna alla home",
|
||||
"needToSignInToOrg": "Hai bisogno di utilizzare il provider di identità della tua organizzazione?",
|
||||
"mtls": "mTLS",
|
||||
"mtlsCaCertificates": "Certificati CA fidati",
|
||||
"mtlsCaCertificates": "Imposta Certificati Client",
|
||||
"mtlsCaCertificatesDescription": "I client devono presentare un certificato firmato da una di queste autorità di certificazione per connettersi. Carica solo certificati CA pubblici, mai una chiave privata.",
|
||||
"mtlsCertificateName": "Nome (Opzionale)",
|
||||
"mtlsCertificateNamePlaceholder": "Dispositivo aziendale CA",
|
||||
"mtlsCertificatePem": "Certificato CA (PEM)",
|
||||
"mtlsCertificatePemDescription": "Incolla un certificato codificato PEM o un insieme di certificati, o carica un file .pem, .crt o .cer.",
|
||||
"mtlsUploadFile": "Carica file",
|
||||
"mtlsAddCertificate": "Aggiungi certificato CA",
|
||||
"mtlsUploadFile": "Carica File",
|
||||
"mtlsAddCertificate": "Aggiungi Certificato CA",
|
||||
"mtlsCertificateAdded": "Certificato CA aggiunto",
|
||||
"mtlsCertificateAddedDescription": "I client con certificati firmati da questa CA possono ora essere verificati.",
|
||||
"mtlsCertificateErrorAdd": "Impossibile aggiungere il certificato CA",
|
||||
@@ -3955,6 +3990,10 @@
|
||||
"mtlsCertificateErrorRemoveDescription": "Il certificato non può essere rimosso.",
|
||||
"mtlsLastCertificateRemoved": "mTLS è stato disattivato perché l'ultimo certificato CA è stato rimosso.",
|
||||
"mtlsNoCertificates": "Nessun certificato CA aggiunto ancora",
|
||||
"mtlsPendingCertificate": "Certificato CA {number}",
|
||||
"mtlsCertificateInvalidPem": "Nessun certificato codificato in PEM trovato. Previsto un blocco che inizia con -----BEGIN CERTIFICATE-----.",
|
||||
"mtlsCertificatePrivateKey": "Questo sembra essere una chiave privata, non un certificato. Carica solo il certificato pubblico della CA.",
|
||||
"mtlsCertificateDuplicate": "Questo certificato CA è già stato aggiunto.",
|
||||
"mtlsRemoveCertificate": "Rimuovi certificato CA",
|
||||
"mtlsExpired": "Scaduto",
|
||||
"mtlsExpiresSoon": "Scade presto",
|
||||
@@ -4488,7 +4527,7 @@
|
||||
"rdpFull": "Completo",
|
||||
"rdpReal": "Reale",
|
||||
"rdpMeta": "Meta",
|
||||
"rdpUploadFiles": "Carica file",
|
||||
"rdpUploadFiles": "Carica File",
|
||||
"rdpFilesReadyToPaste": "File pronti per essere incollati",
|
||||
"rdpFilesReadyToPasteDescription": "{count} file(s) copiati negli appunti remoti — premi Ctrl+V sul desktop remoto per incollare.",
|
||||
"rdpUploadFailed": "Caricamento fallito",
|
||||
|
||||
+41
-2
@@ -604,6 +604,10 @@
|
||||
"licenseNoUserLimit": "이 호스트에 대한 사용자 수에 제한이 없습니다.",
|
||||
"licenseUsersUsedMax": "{maxUsers}명의 사용자 중 {usedUsers}명 사용 중",
|
||||
"licenseUsersUsed": "{count, plural, =0 {# 사용자} other {# 사용자}}",
|
||||
"licenseUsageMachines": "기계 클라이언트",
|
||||
"licenseNoMachineLimit": "이 호스트의 기계 클라이언트 수에는 제한이 없습니다.",
|
||||
"licenseMachinesUsedMax": "{usedMachines} / {maxMachines} 기계 클라이언트 사용됨",
|
||||
"licenseMachinesUsed": "{count, plural, =0 {기계 클라이언트 #개} one {기계 클라이언트 #개} other {기계 클라이언트 #개}}",
|
||||
"licenseUnlimited": "무제한",
|
||||
"licenseTierLabel": "등급",
|
||||
"licensePurchaseDescription": "구매할 사이트 수를 선택하세요 {selectedMode, select, license {라이센스를 구매합니다. 나중에 더 많은 사이트를 추가할 수 있습니다.} other {기존 라이센스에 추가합니다.}}",
|
||||
@@ -801,7 +805,7 @@
|
||||
"loadBalancingStrategy_wrr_description": "대상 사이에서 요청을 균등하게 회전시킵니다.",
|
||||
"loadBalancingStrategy_p2c": "두 가지 선택의 힘",
|
||||
"loadBalancingStrategy_p2c_description": "두 개의 무작위 대상을 선택하여 더 적은 활성 연결을 가진 대상에 요청을 보냅니다.",
|
||||
"loadBalancingStrategy_hrw": "클라이언트 IP 해시",
|
||||
"loadBalancingStrategy_hrw": "가장 높은 무작위 가중치",
|
||||
"loadBalancingStrategy_hrw_description": "클라이언트 IP를 해시하여 동일한 클라이언트가 일관되게 동일한 대상으로 라우팅되도록 합니다.",
|
||||
"loadBalancingStrategy_leasttime": "최소 응답 시간",
|
||||
"loadBalancingStrategy_leasttime_description": "가장 빠른 응답 시간과 가장 적은 활성 연결을 가진 대상으로 요청을 보냅니다.",
|
||||
@@ -829,6 +833,8 @@
|
||||
"save": "저장",
|
||||
"proxyAdditional": "추가 프록시 설정",
|
||||
"proxyAdditionalDescription": "리소스가 프록시 설정을 처리하는 방법 구성",
|
||||
"proxyForwardIdentityHeaders": "아이덴티티 헤더 포워딩",
|
||||
"proxyForwardIdentityHeadersDescription": "인증된 사용자의 아이덴티티를 Remote-User, Remote-Email, Remote-Name, Remote-Role, 및 Remote-User-Id 헤더로 백엔드에 전송합니다. 인식되지 않는 헤더로 요청을 거부하는 백엔드에는 비활성화하십시오.",
|
||||
"proxyCustomHeader": "사용자 정의 호스트 헤더",
|
||||
"proxyCustomHeaderDescription": "요청을 프록시할 때 설정할 호스트 헤더입니다. 기본값을 사용하려면 비워 두십시오.",
|
||||
"proxyAdditionalSubmit": "프록시 설정 저장",
|
||||
@@ -933,6 +939,9 @@
|
||||
"policyAuthHeaderAuthTitle": "기본 헤더 인증",
|
||||
"policyAuthHeaderAuthDescription": "각 요청에서 맞춤 HTTP 헤더 이름 및 값을 검증",
|
||||
"policyAuthHeaderAuthSummary": "헤더 구성됨",
|
||||
"policyAuthMtlsTitle": "클라이언트 인증서 (mTLS)",
|
||||
"policyAuthMtlsDescription": "TLS 핸드셰이크 동안 신뢰할 수 있는 CA가 서명한 클라이언트 인증서가 필요합니다",
|
||||
"policyAuthMtlsSummary": "{count}개의 신뢰할 수 있는 CA 인증서",
|
||||
"policyAuthHeaderName": "사용자 이름",
|
||||
"policyAuthHeaderValue": "비밀번호",
|
||||
"policyAuthSetPasscode": "패스코드 설정",
|
||||
@@ -1500,10 +1509,15 @@
|
||||
"actionDeleteOrgDomain": "도메인 삭제",
|
||||
"actionGetDNSRecords": "DNS 레코드 가져오기",
|
||||
"actionRestartOrgDomain": "도메인 재시작",
|
||||
"actionListCustomDnsRecords": "사용자 정의 DNS 레코드 목록 보기",
|
||||
"actionCreateCustomDnsRecord": "사용자 정의 DNS 레코드 생성하기",
|
||||
"actionUpdateCustomDnsRecord": "사용자 정의 DNS 레코드 업데이트하기",
|
||||
"actionDeleteCustomDnsRecord": "사용자 정의 DNS 레코드 삭제하기",
|
||||
"actionCreateSite": "사이트 생성",
|
||||
"actionDeleteSite": "사이트 삭제",
|
||||
"actionGetSite": "사이트 가져오기",
|
||||
"actionListSites": "사이트 목록",
|
||||
"actionReGenerateSecret": "자격 증명 다시 생성",
|
||||
"actionCreateAiProvider": "AI 공급자 생성",
|
||||
"actionDeleteAiProvider": "AI 공급자 삭제",
|
||||
"actionGetAiProvider": "AI 공급자 가져오기",
|
||||
@@ -3727,6 +3741,27 @@
|
||||
"TTL": "TTL",
|
||||
"howToAddRecords": "레코드 추가 방법",
|
||||
"dnsRecord": "DNS 레코드",
|
||||
"customDnsRecords": "사용자 정의 DNS 레코드",
|
||||
"customDnsRecordsAdd": "레코드 추가",
|
||||
"customDnsRecordsSearch": "레코드 검색...",
|
||||
"customDnsRecordsUnverifiedTitle": "레코드가 아직 제공되지 않았습니다",
|
||||
"customDnsRecordsUnverifiedDescription": "이 도메인은 아직 확인되지 않았습니다. 지금 레코드를 추가할 수 있지만, 도메인의 네임서버가 Pangolin으로 설정되며 검증이 완료될 때까지 Pangolin의 네임서버는 이를 제공하지 않습니다.",
|
||||
"customDnsRecordCreate": "DNS 레코드 추가",
|
||||
"customDnsRecordEdit": "DNS 레코드 편집",
|
||||
"customDnsRecordFormDescription": "이 레코드는 {domain}에 대해 제공됩니다. 변경 사항이 반영되기까지 몇 분 정도 걸릴 수 있습니다.",
|
||||
"customDnsRecordNameDescription": "도메인의 루트에는 @를 사용하고, 와일드카드의 첫 라벨에는 * 을 사용합니다.",
|
||||
"customDnsRecordValueDescription": "라인당 하나의 값을 입력하세요, 예를 들어 NS 레코드의 두 네임서버 모두를 입력하세요.",
|
||||
"customDnsRecordPriority": "우선순위",
|
||||
"customDnsRecordWeight": "가중치",
|
||||
"customDnsRecordPort": "포트",
|
||||
"customDnsRecordCreated": "DNS 레코드 생성 완료",
|
||||
"customDnsRecordUpdated": "DNS 레코드 업데이트 완료",
|
||||
"customDnsRecordSaveError": "DNS 레코드 저장 실패",
|
||||
"customDnsRecordDeleted": "DNS 레코드 삭제 완료",
|
||||
"customDnsRecordDeleteError": "DNS 레코드 삭제 실패",
|
||||
"customDnsRecordDeleteTitle": "DNS 레코드 삭제",
|
||||
"customDnsRecordDeleteQuestion": "{name}의 {type} 레코드를 삭제하시겠습니까? 만료된 캐시 응답이 없으면 더 이상 해결되지 않습니다.",
|
||||
"customDnsRecordDeleteConfirm": "레코드 삭제",
|
||||
"required": "필수",
|
||||
"domainSettingsUpdated": "도메인 설정이 성공적으로 업데이트되었습니다",
|
||||
"orgOrDomainIdMissing": "조직 ID 또는 도메인 ID가 누락되었습니다",
|
||||
@@ -3938,7 +3973,7 @@
|
||||
"backToHome": "홈으로 돌아가기",
|
||||
"needToSignInToOrg": "조직의 아이덴티티 공급자를 사용해야 합니까?",
|
||||
"mtls": "mTLS",
|
||||
"mtlsCaCertificates": "신뢰할 수 있는 CA 인증서",
|
||||
"mtlsCaCertificates": "클라이언트 인증서 설정",
|
||||
"mtlsCaCertificatesDescription": "클라이언트는 이러한 인증 기관 중 하나가 서명한 인증서를 제시해야 연결할 수 있습니다. 공개 CA 인증서만 업로드하고, 비공개 키는 절대 업로드하지 마세요.",
|
||||
"mtlsCertificateName": "이름 (선택 사항)",
|
||||
"mtlsCertificateNamePlaceholder": "회사 장치 CA",
|
||||
@@ -3955,6 +3990,10 @@
|
||||
"mtlsCertificateErrorRemoveDescription": "인증서를 제거할 수 없습니다.",
|
||||
"mtlsLastCertificateRemoved": "마지막 CA 인증서가 제거되어 mTLS가 꺼졌습니다.",
|
||||
"mtlsNoCertificates": "아직 CA 인증서가 추가되지 않았습니다",
|
||||
"mtlsPendingCertificate": "CA 인증서 {number}",
|
||||
"mtlsCertificateInvalidPem": "PEM으로 인코딩된 인증서를 찾을 수 없습니다. -----BEGIN CERTIFICATE-----로 시작하는 블록을 예상합니다.",
|
||||
"mtlsCertificatePrivateKey": "이는 인증서가 아니라 개인 키처럼 보입니다. CA의 공용 인증서만 업로드하십시오.",
|
||||
"mtlsCertificateDuplicate": "이 CA 인증서는 이미 추가되었습니다.",
|
||||
"mtlsRemoveCertificate": "CA 인증서 제거하기",
|
||||
"mtlsExpired": "만료됨",
|
||||
"mtlsExpiresSoon": "곧 만료됨",
|
||||
|
||||
+41
-2
@@ -604,6 +604,10 @@
|
||||
"licenseNoUserLimit": "Det er ingen begrensning på antall brukere for denne verten.",
|
||||
"licenseUsersUsedMax": "{usedUsers} av {maxUsers} brukere brukt",
|
||||
"licenseUsersUsed": "{count, plural, =0 {ingen brukere} one {en bruker} other {# brukere}}",
|
||||
"licenseUsageMachines": "Maskinklienter",
|
||||
"licenseNoMachineLimit": "Det er ingen grense på antall maskinklienter for denne verten.",
|
||||
"licenseMachinesUsedMax": "{usedMachines} av {maxMachines} maskinklienter brukt",
|
||||
"licenseMachinesUsed": "{count, plural, =0 {# maskinklienter} one {# maskinklient} other {# maskinklienter}}",
|
||||
"licenseUnlimited": "Ubegrenset",
|
||||
"licenseTierLabel": "Nivå",
|
||||
"licensePurchaseDescription": "Velg hvor mange områder du vil {selectedMode, select, license {kjøpe en lisens for. Du kan alltid legge til flere områder senere.} other {legge til din eksisterende lisens.}}",
|
||||
@@ -801,7 +805,7 @@
|
||||
"loadBalancingStrategy_wrr_description": "Distribuer forespørsler jevnt over mål i rotasjon.",
|
||||
"loadBalancingStrategy_p2c": "To tilfeldig valg",
|
||||
"loadBalancingStrategy_p2c_description": "Velg to tilfeldige mål og send forespørselen til den med færre aktive forbindelser.",
|
||||
"loadBalancingStrategy_hrw": "Klient IP-hash",
|
||||
"loadBalancingStrategy_hrw": "Høyeste tilfeldige vekt",
|
||||
"loadBalancingStrategy_hrw_description": "Hash klient-IP-en slik at samme klient konsekvent rutes til samme mål.",
|
||||
"loadBalancingStrategy_leasttime": "Raskest svartid",
|
||||
"loadBalancingStrategy_leasttime_description": "Send forespørsler til målet med raskest svartid og færrest aktive forbindelser.",
|
||||
@@ -829,6 +833,8 @@
|
||||
"save": "Lagre",
|
||||
"proxyAdditional": "Ytterligere Proxy-innstillinger",
|
||||
"proxyAdditionalDescription": "Konfigurer hvordan ressursen håndterer proxy-innstillingene",
|
||||
"proxyForwardIdentityHeaders": "Videresend identitetsoverskrifter",
|
||||
"proxyForwardIdentityHeadersDescription": "Send den autentiserte brukerens identitet til backend i overskriftene Remote-User, Remote-Email, Remote-Name, Remote-Role og Remote-User-Id. Deaktiver for backend-systemer som avviser forespørsler med ukjente overskrifter.",
|
||||
"proxyCustomHeader": "Tilpasset verts-header",
|
||||
"proxyCustomHeaderDescription": "Verts-header som skal settes ved videresending av forespørsler. La stå tom for å bruke standardinnstillingen.",
|
||||
"proxyAdditionalSubmit": "Lagre proxy-innstillinger",
|
||||
@@ -933,6 +939,9 @@
|
||||
"policyAuthHeaderAuthTitle": "Grunnleggende Header Autentisering",
|
||||
"policyAuthHeaderAuthDescription": "Bekreft et tilpasset HTTP-headernavn og verdi ved hver forespørsel",
|
||||
"policyAuthHeaderAuthSummary": "Header konfigurert",
|
||||
"policyAuthMtlsTitle": "Klientsertifikat (mTLS)",
|
||||
"policyAuthMtlsDescription": "Krev et klientsertifikat signert av en betrodd CA under TLS-håndtrykket",
|
||||
"policyAuthMtlsSummary": "{count} betrodde CA-sertifikater",
|
||||
"policyAuthHeaderName": "Brukernavn",
|
||||
"policyAuthHeaderValue": "Passord",
|
||||
"policyAuthSetPasscode": "Angi passordkode",
|
||||
@@ -1500,10 +1509,15 @@
|
||||
"actionDeleteOrgDomain": "Slett domene",
|
||||
"actionGetDNSRecords": "Hent DNS-oppføringer",
|
||||
"actionRestartOrgDomain": "Omstart Domene",
|
||||
"actionListCustomDnsRecords": "List tilpassede DNS-oppføringer",
|
||||
"actionCreateCustomDnsRecord": "Opprett tilpasset DNS-oppføring",
|
||||
"actionUpdateCustomDnsRecord": "Oppdater tilpasset DNS-oppføring",
|
||||
"actionDeleteCustomDnsRecord": "Slett tilpasset DNS-oppføring",
|
||||
"actionCreateSite": "Opprett område",
|
||||
"actionDeleteSite": "Slett område",
|
||||
"actionGetSite": "Hent område",
|
||||
"actionListSites": "List opp områder",
|
||||
"actionReGenerateSecret": "Regenerer brukeropplysninger",
|
||||
"actionCreateAiProvider": "Opprett AI-leverandør",
|
||||
"actionDeleteAiProvider": "Slett AI-leverandør",
|
||||
"actionGetAiProvider": "Hent AI-leverandør",
|
||||
@@ -3727,6 +3741,27 @@
|
||||
"TTL": "TTL",
|
||||
"howToAddRecords": "Hvordan legge til poster",
|
||||
"dnsRecord": "DNS registre",
|
||||
"customDnsRecords": "Tilpassede DNS-oppføringer",
|
||||
"customDnsRecordsAdd": "Legg til oppføring",
|
||||
"customDnsRecordsSearch": "Søk i oppføringer...",
|
||||
"customDnsRecordsUnverifiedTitle": "Oppføringer serveres ikke ennå",
|
||||
"customDnsRecordsUnverifiedDescription": "Dette domenet er ikke verifisert. Du kan legge til oppføringer nå, men Pangolins navneservere vil ikke tjene dem før domenets navneservere peker til Pangolin og verifiseringen er fullført.",
|
||||
"customDnsRecordCreate": "Legg til DNS-oppføring",
|
||||
"customDnsRecordEdit": "Rediger DNS-oppføring",
|
||||
"customDnsRecordFormDescription": "Denne oppføringen vil bli servert for {domain}. Endringer kan ta noen minutter å oppdatere.",
|
||||
"customDnsRecordNameDescription": "Bruk @ for roten av domenet eller * som den første etiketten for et jokertegn.",
|
||||
"customDnsRecordValueDescription": "Skriv inn én verdi per linje, f.eks. begge navneservere av en NS-oppføring.",
|
||||
"customDnsRecordPriority": "Prioritet",
|
||||
"customDnsRecordWeight": "Vekt",
|
||||
"customDnsRecordPort": "Port",
|
||||
"customDnsRecordCreated": "DNS-oppføring opprettet",
|
||||
"customDnsRecordUpdated": "DNS-oppføring oppdatert",
|
||||
"customDnsRecordSaveError": "Klarte ikke å lagre DNS-oppføring",
|
||||
"customDnsRecordDeleted": "DNS-oppføring slettet",
|
||||
"customDnsRecordDeleteError": "Klarte ikke å slette DNS-oppføring",
|
||||
"customDnsRecordDeleteTitle": "Slett DNS-oppføring",
|
||||
"customDnsRecordDeleteQuestion": "Er du sikker på at du vil slette {type}-oppføringen for {name}? Den vil slutte å løse seg opp når hurtigbufrede svar utløper.",
|
||||
"customDnsRecordDeleteConfirm": "Slett oppføring",
|
||||
"required": "Påkrevd",
|
||||
"domainSettingsUpdated": "Domene innstillinger ble oppdatert",
|
||||
"orgOrDomainIdMissing": "ID for organisasjon eller domene mangler",
|
||||
@@ -3938,7 +3973,7 @@
|
||||
"backToHome": "Gå tilbake til start",
|
||||
"needToSignInToOrg": "Trenger du å bruke organisasjonens identitetsleverandør?",
|
||||
"mtls": "mTLS",
|
||||
"mtlsCaCertificates": "Pålitelige CA-sertifikater",
|
||||
"mtlsCaCertificates": "Angi klientsertifikater",
|
||||
"mtlsCaCertificatesDescription": "Klienter må presentere et sertifikat signert av en av disse sertifikatmyndighetene for å koble til. Last kun opp offentlige CA-sertifikater, aldri en privat nøkkel.",
|
||||
"mtlsCertificateName": "Navn (valgfritt)",
|
||||
"mtlsCertificateNamePlaceholder": "Bedriftsenhet CA",
|
||||
@@ -3955,6 +3990,10 @@
|
||||
"mtlsCertificateErrorRemoveDescription": "Sertifikatet kunne ikke fjernes.",
|
||||
"mtlsLastCertificateRemoved": "mTLS ble slått av fordi det siste CA-sertifikatet ble fjernet.",
|
||||
"mtlsNoCertificates": "Ingen CA-sertifikater er lagt til ennå",
|
||||
"mtlsPendingCertificate": "CA-sertifikat {number}",
|
||||
"mtlsCertificateInvalidPem": "Fant ingen sertifikat kodet i PEM-format. Forventet en blokk som starter med -----BEGIN CERTIFICATE-----.",
|
||||
"mtlsCertificatePrivateKey": "Dette ser ut som en privat nøkkel, ikke et sertifikat. Last opp bare CAs offentlige sertifikat.",
|
||||
"mtlsCertificateDuplicate": "Dette CA-sertifikatet er allerede lagt til.",
|
||||
"mtlsRemoveCertificate": "Fjern CA-sertifikat",
|
||||
"mtlsExpired": "Utløpt",
|
||||
"mtlsExpiresSoon": "Utløper snart",
|
||||
|
||||
+41
-2
@@ -604,6 +604,10 @@
|
||||
"licenseNoUserLimit": "Er is geen limiet op het aantal gebruikers voor deze host.",
|
||||
"licenseUsersUsedMax": "{usedUsers} van {maxUsers} gebruikers gebruikt",
|
||||
"licenseUsersUsed": "{count, plural, =0 {# gebruikers} one {# gebruiker} other {# gebruikers}}",
|
||||
"licenseUsageMachines": "Machineclients",
|
||||
"licenseNoMachineLimit": "Er is geen limiet op het aantal machineclients voor deze host.",
|
||||
"licenseMachinesUsedMax": "{usedMachines} van {maxMachines} machineclients gebruikt",
|
||||
"licenseMachinesUsed": "{count, plural, =0 {# machineclients} one {# machineclient} other {# machineclients}}",
|
||||
"licenseUnlimited": "Onbeperkt",
|
||||
"licenseTierLabel": "Niveau",
|
||||
"licensePurchaseDescription": "Kies hoeveel sites je wilt {selectedMode, select, license {Koop een licentie. Je kunt later altijd meer sites toevoegen.} other {Voeg je bestaande licentie toe}}",
|
||||
@@ -801,7 +805,7 @@
|
||||
"loadBalancingStrategy_wrr_description": "Verdeel aanvragen gelijkmatig over doelen in rotatie.",
|
||||
"loadBalancingStrategy_p2c": "Kracht van Twee Kiezen",
|
||||
"loadBalancingStrategy_p2c_description": "Kies twee willekeurige doelen en stuur het verzoek naar degene met de minste actieve verbindingen.",
|
||||
"loadBalancingStrategy_hrw": "Client IP-hash",
|
||||
"loadBalancingStrategy_hrw": "Hoogste Willekeurige Gewicht",
|
||||
"loadBalancingStrategy_hrw_description": "Hash het client-IP, zodat dezelfde client consequent naar hetzelfde doel wordt gerouteerd.",
|
||||
"loadBalancingStrategy_leasttime": "Minimum reactietijd",
|
||||
"loadBalancingStrategy_leasttime_description": "Verstuur aanvragen naar het doel met de snelste reactietijd en de minste actieve verbindingen.",
|
||||
@@ -829,6 +833,8 @@
|
||||
"save": "Opslaan",
|
||||
"proxyAdditional": "Extra Proxy-instellingen",
|
||||
"proxyAdditionalDescription": "Configureer hoe de bron omgaat met proxy-instellingen",
|
||||
"proxyForwardIdentityHeaders": "Identiteitsheaders doorsturen",
|
||||
"proxyForwardIdentityHeadersDescription": "Stuur de identiteit van de geauthenticeerde gebruiker naar de backend in de headers Remote-User, Remote-Email, Remote-Name, Remote-Role en Remote-User-Id. Uitschakelen voor backends die verzoeken met onbekende headers afwijzen.",
|
||||
"proxyCustomHeader": "Aangepaste Host-header",
|
||||
"proxyCustomHeaderDescription": "De hostkop om in te stellen bij proxying verzoeken. Laat leeg om de standaard te gebruiken.",
|
||||
"proxyAdditionalSubmit": "Proxyinstellingen opslaan",
|
||||
@@ -933,6 +939,9 @@
|
||||
"policyAuthHeaderAuthTitle": "Basic Header Authenticatie",
|
||||
"policyAuthHeaderAuthDescription": "Valideer een aangepaste HTTP-headernaam en waarde bij elk verzoek",
|
||||
"policyAuthHeaderAuthSummary": "Header geconfigureerd",
|
||||
"policyAuthMtlsTitle": "Clientcertificaat (mTLS)",
|
||||
"policyAuthMtlsDescription": "Vereis een clientcertificaat ondertekend door een vertrouwde CA tijdens de TLS-handshake",
|
||||
"policyAuthMtlsSummary": "{count} vertrouwde CA-certificaten",
|
||||
"policyAuthHeaderName": "Gebruikersnaam",
|
||||
"policyAuthHeaderValue": "Wachtwoord",
|
||||
"policyAuthSetPasscode": "Stel toegangscode in",
|
||||
@@ -1500,10 +1509,15 @@
|
||||
"actionDeleteOrgDomain": "Domein verwijderen",
|
||||
"actionGetDNSRecords": "Krijg DNS Records",
|
||||
"actionRestartOrgDomain": "Domein opnieuw starten",
|
||||
"actionListCustomDnsRecords": "Lijst aangepaste DNS-records",
|
||||
"actionCreateCustomDnsRecord": "Aangepast DNS-record maken",
|
||||
"actionUpdateCustomDnsRecord": "Aangepast DNS-record bijwerken",
|
||||
"actionDeleteCustomDnsRecord": "Aangepast DNS-record verwijderen",
|
||||
"actionCreateSite": "Site aanmaken",
|
||||
"actionDeleteSite": "Site verwijderen",
|
||||
"actionGetSite": "Site ophalen",
|
||||
"actionListSites": "Sites weergeven",
|
||||
"actionReGenerateSecret": "Referenties opnieuw genereren",
|
||||
"actionCreateAiProvider": "AI-provider aanmaken",
|
||||
"actionDeleteAiProvider": "Verwijder AI-provider",
|
||||
"actionGetAiProvider": "AI-provider ophalen",
|
||||
@@ -3727,6 +3741,27 @@
|
||||
"TTL": "TTL",
|
||||
"howToAddRecords": "Hoe voeg ik Records toe",
|
||||
"dnsRecord": "DNS-records",
|
||||
"customDnsRecords": "Aangepaste DNS-records",
|
||||
"customDnsRecordsAdd": "Record toevoegen",
|
||||
"customDnsRecordsSearch": "Zoek records...",
|
||||
"customDnsRecordsUnverifiedTitle": "Records worden nog niet geserveerd",
|
||||
"customDnsRecordsUnverifiedDescription": "Dit domein is niet geverifieerd. U kunt nu records toevoegen, maar Pangolin's nameservers zullen ze pas serveren wanneer de nameservers van het domein naar Pangolin wijzen en de verificatie voltooid is.",
|
||||
"customDnsRecordCreate": "DNS-record toevoegen",
|
||||
"customDnsRecordEdit": "DNS-record bewerken",
|
||||
"customDnsRecordFormDescription": "Dit record wordt bediend voor {domain}. Wijzigingen kunnen enkele minuten duren om zich te propageren.",
|
||||
"customDnsRecordNameDescription": "Gebruik @ voor de root van het domein of * als eerste label voor een wildcard.",
|
||||
"customDnsRecordValueDescription": "Voer één waarde per regel in, bijvoorbeeld beide nameservers van een NS-record.",
|
||||
"customDnsRecordPriority": "Prioriteit",
|
||||
"customDnsRecordWeight": "Gewicht",
|
||||
"customDnsRecordPort": "Poort",
|
||||
"customDnsRecordCreated": "DNS-record aangemaakt",
|
||||
"customDnsRecordUpdated": "DNS-record bijgewerkt",
|
||||
"customDnsRecordSaveError": "Kon DNS-record niet opslaan",
|
||||
"customDnsRecordDeleted": "DNS-record verwijderd",
|
||||
"customDnsRecordDeleteError": "Kon DNS-record niet verwijderen",
|
||||
"customDnsRecordDeleteTitle": "DNS-record verwijderen",
|
||||
"customDnsRecordDeleteQuestion": "Weet u zeker dat u het {type} record voor {name} wilt verwijderen? Het zal ophouden met oplossen zodra de gecachte antwoorden verlopen.",
|
||||
"customDnsRecordDeleteConfirm": "Record verwijderen",
|
||||
"required": "vereist",
|
||||
"domainSettingsUpdated": "Domeininstellingen succesvol bijgewerkt",
|
||||
"orgOrDomainIdMissing": "Organisatie of domein ID ontbreekt",
|
||||
@@ -3938,7 +3973,7 @@
|
||||
"backToHome": "Ga terug naar de startpagina",
|
||||
"needToSignInToOrg": "Moet u de identiteit provider van uw organisatie gebruiken?",
|
||||
"mtls": "mTLS",
|
||||
"mtlsCaCertificates": "Vertrouwde CA-certificaten",
|
||||
"mtlsCaCertificates": "Clientcertificaten instellen",
|
||||
"mtlsCaCertificatesDescription": "Clients moeten een certificaat presenteren dat is ondertekend door een van deze certificeringsinstanties om verbinding te maken. Upload alleen openbare CA-certificaten, nooit een privésleutel.",
|
||||
"mtlsCertificateName": "Naam (optioneel)",
|
||||
"mtlsCertificateNamePlaceholder": "CA-certificaat apparaat",
|
||||
@@ -3955,6 +3990,10 @@
|
||||
"mtlsCertificateErrorRemoveDescription": "Het certificaat kon niet worden verwijderd.",
|
||||
"mtlsLastCertificateRemoved": "mTLS is uitgeschakeld omdat het laatste CA-certificaat is verwijderd.",
|
||||
"mtlsNoCertificates": "Geen CA-certificaten toegevoegd",
|
||||
"mtlsPendingCertificate": "CA-certificaat {number}",
|
||||
"mtlsCertificateInvalidPem": "Geen PEM-gecodeerd certificaat gevonden. Verwacht een blok beginnend met -----BEGIN CERTIFICATE-----.",
|
||||
"mtlsCertificatePrivateKey": "Dit lijkt op een privésleutel, niet op een certificaat. Upload alleen het publieke certificaat van de CA.",
|
||||
"mtlsCertificateDuplicate": "Dit CA-certificaat is al toegevoegd.",
|
||||
"mtlsRemoveCertificate": "CA-certificaat verwijderen",
|
||||
"mtlsExpired": "Verlopen",
|
||||
"mtlsExpiresSoon": "Verloopt binnenkort",
|
||||
|
||||
+41
-2
@@ -604,6 +604,10 @@
|
||||
"licenseNoUserLimit": "Nie ma limitu liczby użytkowników dla tego hosta.",
|
||||
"licenseUsersUsedMax": "{usedUsers} z {maxUsers} użytkowników wykorzystano",
|
||||
"licenseUsersUsed": "{count, plural, =0 {# użytkowników} one {# użytkownik} few {# użytkownicy} many {# użytkowników} other {# użytkowników}}",
|
||||
"licenseUsageMachines": "Klienci maszyn",
|
||||
"licenseNoMachineLimit": "Nie ma limitu liczby klientów maszyn dla tego hosta.",
|
||||
"licenseMachinesUsedMax": "{usedMachines} z {maxMachines} klientów maszyn używanych",
|
||||
"licenseMachinesUsed": "{count, plural, =0 {# klientów maszyn} one {# klient maszyny} few {# klienci maszyn} many {# klientów maszyn} other {# klientów maszyn}}",
|
||||
"licenseUnlimited": "Nieograniczona",
|
||||
"licenseTierLabel": "Poziom",
|
||||
"licensePurchaseDescription": "Wybierz ile witryn chcesz {selectedMode, select, license {kupić licencję. Zawsze możesz dodać więcej witryn później.} other {dodaj do swojej istniejącej licencji.}}",
|
||||
@@ -801,7 +805,7 @@
|
||||
"loadBalancingStrategy_wrr_description": "Równomiernie rozdzielaj żądania między cele w rotacji.",
|
||||
"loadBalancingStrategy_p2c": "Moc dwóch wyborów",
|
||||
"loadBalancingStrategy_p2c_description": "Wybierz dwa losowe cele i wyślij żądanie do tego z mniejszą liczbą aktywnych połączeń.",
|
||||
"loadBalancingStrategy_hrw": "Hash IP klienta",
|
||||
"loadBalancingStrategy_hrw": "Najwyższa losowa waga",
|
||||
"loadBalancingStrategy_hrw_description": "Zahasuj IP klienta, tak aby ten sam klient był konsekwentnie kierowany do tego samego celu.",
|
||||
"loadBalancingStrategy_leasttime": "Najmniejszy czas odpowiedzi",
|
||||
"loadBalancingStrategy_leasttime_description": "Wyślij żądania do celu z najszybszym czasem odpowiedzi i najmniejszą liczbą aktywnych połączeń.",
|
||||
@@ -829,6 +833,8 @@
|
||||
"save": "Zapisz",
|
||||
"proxyAdditional": "Dodatkowe ustawienia proxy",
|
||||
"proxyAdditionalDescription": "Skonfiguruj sposób obsługi ustawień proxy",
|
||||
"proxyForwardIdentityHeaders": "Przekaż nagłówki tożsamości",
|
||||
"proxyForwardIdentityHeadersDescription": "Przekaż tożsamość uwierzytelnionego użytkownika do zaplecza w nagłówkach Remote-User, Remote-Email, Remote-Name, Remote-Role i Remote-User-Id. Wyłącz dla zapleczy, które odrzucają żądania z niezrozumiałymi nagłówkami.",
|
||||
"proxyCustomHeader": "Niestandardowy nagłówek hosta",
|
||||
"proxyCustomHeaderDescription": "Nagłówek hosta do ustawienia podczas proxy żądań. Pozostaw puste, aby użyć domyślnego.",
|
||||
"proxyAdditionalSubmit": "Zapisz ustawienia proxy",
|
||||
@@ -933,6 +939,9 @@
|
||||
"policyAuthHeaderAuthTitle": "Podstawowe Uwierzytelnianie Nagłówka",
|
||||
"policyAuthHeaderAuthDescription": "Walidacja niestandardowej nazwy i wartości nagłówka HTTP przy każdym żądaniu",
|
||||
"policyAuthHeaderAuthSummary": "Skonfigurowany nagłówek",
|
||||
"policyAuthMtlsTitle": "Certyfikat klienta (mTLS)",
|
||||
"policyAuthMtlsDescription": "Wymagaj certyfikatu klienta podpisanego przez zaufany urząd certyfikacji podczas uścisku TLS",
|
||||
"policyAuthMtlsSummary": "{count} zaufanych certyfikatów CA",
|
||||
"policyAuthHeaderName": "Nazwa użytkownika",
|
||||
"policyAuthHeaderValue": "Hasło",
|
||||
"policyAuthSetPasscode": "Ustaw hasło dostępu",
|
||||
@@ -1500,10 +1509,15 @@
|
||||
"actionDeleteOrgDomain": "Usuń domenę",
|
||||
"actionGetDNSRecords": "Pobierz rekordy DNS",
|
||||
"actionRestartOrgDomain": "Zrestartuj domenę",
|
||||
"actionListCustomDnsRecords": "Lista niestandardowych rekordów DNS",
|
||||
"actionCreateCustomDnsRecord": "Utwórz niestandardowy rekord DNS",
|
||||
"actionUpdateCustomDnsRecord": "Aktualizuj niestandardowy rekord DNS",
|
||||
"actionDeleteCustomDnsRecord": "Usuń niestandardowy rekord DNS",
|
||||
"actionCreateSite": "Utwórz witrynę",
|
||||
"actionDeleteSite": "Usuń witrynę",
|
||||
"actionGetSite": "Pobierz witrynę",
|
||||
"actionListSites": "Lista witryn",
|
||||
"actionReGenerateSecret": "Wygeneruj dane logowania",
|
||||
"actionCreateAiProvider": "Utwórz Dostawcę AI",
|
||||
"actionDeleteAiProvider": "Usuń Dostawcę AI",
|
||||
"actionGetAiProvider": "Pobierz Dostawcę AI",
|
||||
@@ -3727,6 +3741,27 @@
|
||||
"TTL": "TTL",
|
||||
"howToAddRecords": "Jak dodać rekordy",
|
||||
"dnsRecord": "Wpisy DNS",
|
||||
"customDnsRecords": "Niestandardowe rekordy DNS",
|
||||
"customDnsRecordsAdd": "Dodaj rekord",
|
||||
"customDnsRecordsSearch": "Szukaj rekordów...",
|
||||
"customDnsRecordsUnverifiedTitle": "Rekordy nie są jeszcze obsługiwane",
|
||||
"customDnsRecordsUnverifiedDescription": "Ta domena nie została zweryfikowana. Możesz teraz dodać rekordy, ale serwery nazw Pangolin nie będą ich obsługiwać, dopóki serwery nazw domeny nie będą wskazywać na Pangolin i weryfikacja nie zostanie zakończona.",
|
||||
"customDnsRecordCreate": "Dodaj rekord DNS",
|
||||
"customDnsRecordEdit": "Edytuj rekord DNS",
|
||||
"customDnsRecordFormDescription": "Ten rekord będzie obsługiwany dla {domain}. Zmiany mogą zająć kilka minut, aby się rozpropagować.",
|
||||
"customDnsRecordNameDescription": "Użyj @ dla korzenia domeny lub * jako pierwszej etykiety dla symbolu wieloznacznego.",
|
||||
"customDnsRecordValueDescription": "Wprowadź jedną wartość na linię, np. oba serwery nazw rekordu NS.",
|
||||
"customDnsRecordPriority": "Priorytet",
|
||||
"customDnsRecordWeight": "Waga",
|
||||
"customDnsRecordPort": "Port",
|
||||
"customDnsRecordCreated": "Rekord DNS utworzony",
|
||||
"customDnsRecordUpdated": "Rekord DNS zaktualizowany",
|
||||
"customDnsRecordSaveError": "Nie udało się zapisać rekordu DNS",
|
||||
"customDnsRecordDeleted": "Rekord DNS usunięty",
|
||||
"customDnsRecordDeleteError": "Nie udało się usunąć rekordu DNS",
|
||||
"customDnsRecordDeleteTitle": "Usuń rekord DNS",
|
||||
"customDnsRecordDeleteQuestion": "Czy na pewno chcesz usunąć rekord typu {type} dla {name}? Przestanie być rozwiązywany, gdy tylko wygaśnie pamięć podręczna odpowiedzi.",
|
||||
"customDnsRecordDeleteConfirm": "Usuń rekord",
|
||||
"required": "Wymagane",
|
||||
"domainSettingsUpdated": "Ustawienia domeny zaktualizowane pomyślnie",
|
||||
"orgOrDomainIdMissing": "Brakuje identyfikatora organizacji lub domeny",
|
||||
@@ -3938,7 +3973,7 @@
|
||||
"backToHome": "Wróć do strony głównej",
|
||||
"needToSignInToOrg": "Czy potrzebujesz użyć dostawcy tożsamości organizacji?",
|
||||
"mtls": "mTLS",
|
||||
"mtlsCaCertificates": "Zaufane certyfikaty CA",
|
||||
"mtlsCaCertificates": "Ustaw certyfikaty klienta",
|
||||
"mtlsCaCertificatesDescription": "Klienci muszą przedstawić certyfikat podpisany przez jedną z tych władz certyfikacyjnych, aby nawiązać połączenie. Przesyłaj tylko publiczne certyfikaty CA, nigdy klucz prywatny.",
|
||||
"mtlsCertificateName": "Nazwa (opcjonalnie)",
|
||||
"mtlsCertificateNamePlaceholder": "Firmowy certyfikat urządzenia CA",
|
||||
@@ -3955,6 +3990,10 @@
|
||||
"mtlsCertificateErrorRemoveDescription": "Nie można było usunąć certyfikatu.",
|
||||
"mtlsLastCertificateRemoved": "mTLS został wyłączony, ponieważ usunięto ostatni certyfikat CA.",
|
||||
"mtlsNoCertificates": "Nie dodano jeszcze żadnych certyfikatów CA",
|
||||
"mtlsPendingCertificate": "Certyfikat CA {number}",
|
||||
"mtlsCertificateInvalidPem": "Nie znaleziono certyfikatu zakodowanego za pomocą PEM. Oczekiwano bloku zaczynającego się od -----BEGIN CERTIFICATE-----.",
|
||||
"mtlsCertificatePrivateKey": "Wygląda na to, że to klucz prywatny, a nie certyfikat. Prześlij tylko publiczny certyfikat urzędu CA.",
|
||||
"mtlsCertificateDuplicate": "Ten certyfikat CA został już dodany.",
|
||||
"mtlsRemoveCertificate": "Usuń certyfikat CA",
|
||||
"mtlsExpired": "Wygasło",
|
||||
"mtlsExpiresSoon": "Wygasa wkrótce",
|
||||
|
||||
+44
-5
@@ -604,6 +604,10 @@
|
||||
"licenseNoUserLimit": "Não há limite para o número de usuários para este host.",
|
||||
"licenseUsersUsedMax": "{usedUsers} de {maxUsers} utilizadores utilizados",
|
||||
"licenseUsersUsed": "{count, plural, =0 {# utilizadores} one {# utilizador} other {# utilizadores}}",
|
||||
"licenseUsageMachines": "Clientes de máquina",
|
||||
"licenseNoMachineLimit": "Não há limite para o número de clientes de máquina para este host.",
|
||||
"licenseMachinesUsedMax": "{usedMachines} de {maxMachines} clientes de máquina utilizados",
|
||||
"licenseMachinesUsed": "{count, plural, =0 {# clientes de máquina} one {# cliente de máquina} other {# clientes de máquina}}",
|
||||
"licenseUnlimited": "Ilimitado",
|
||||
"licenseTierLabel": "Nível",
|
||||
"licensePurchaseDescription": "Escolha quantos sites você quer {selectedMode, select, license {Compre uma licença. Você sempre pode adicionar mais sites depois.} other {adicione à sua licença existente.}}",
|
||||
@@ -801,7 +805,7 @@
|
||||
"loadBalancingStrategy_wrr_description": "Distribua as solicitações uniformemente entre os alvos em rotação.",
|
||||
"loadBalancingStrategy_p2c": "Poder de Duas Escolhas",
|
||||
"loadBalancingStrategy_p2c_description": "Escolha dois alvos aleatórios e envie a solicitação para o que tiver menos conexões ativas.",
|
||||
"loadBalancingStrategy_hrw": "Hash de IP do Cliente",
|
||||
"loadBalancingStrategy_hrw": "Maior Peso Aleatório",
|
||||
"loadBalancingStrategy_hrw_description": "Hashee o IP do cliente para que o mesmo cliente seja consistentemente roteado para o mesmo alvo.",
|
||||
"loadBalancingStrategy_leasttime": "Menor Tempo de Resposta",
|
||||
"loadBalancingStrategy_leasttime_description": "Envie solicitações ao destino com o tempo de resposta mais rápido e menos conexões ativas.",
|
||||
@@ -829,6 +833,8 @@
|
||||
"save": "Guardar",
|
||||
"proxyAdditional": "Configurações Adicionais de Proxy",
|
||||
"proxyAdditionalDescription": "Configurar como o recurso lida com as configurações de proxy",
|
||||
"proxyForwardIdentityHeaders": "Encaminhar Cabeçalhos de Identidade",
|
||||
"proxyForwardIdentityHeadersDescription": "Enviar a identidade do usuário autenticado para o backend nos cabeçalhos Remote-User, Remote-Email, Remote-Name, Remote-Role e Remote-User-Id. Desative para backends que rejeitam pedidos com cabeçalhos não reconhecidos.",
|
||||
"proxyCustomHeader": "Cabeçalho Host Personalizado",
|
||||
"proxyCustomHeaderDescription": "O cabeçalho host para definir ao fazer proxy de requisições. Deixe vazio para usar o padrão.",
|
||||
"proxyAdditionalSubmit": "Guardar Configurações de Proxy",
|
||||
@@ -933,6 +939,9 @@
|
||||
"policyAuthHeaderAuthTitle": "Autenticação de Cabeçalho Básico",
|
||||
"policyAuthHeaderAuthDescription": "Valide um nome e valor de cabeçalho HTTP personalizado em cada solicitação",
|
||||
"policyAuthHeaderAuthSummary": "Cabeçalho configurado",
|
||||
"policyAuthMtlsTitle": "Certificado de Cliente (mTLS)",
|
||||
"policyAuthMtlsDescription": "Exigir um certificado de cliente assinado por uma CA confiável durante o handshake TLS",
|
||||
"policyAuthMtlsSummary": "{count, plural, one {<b>#</b> certificado de CA confiável} other {<b>#</b> certificados de CA confiáveis}}",
|
||||
"policyAuthHeaderName": "Nome de usuário",
|
||||
"policyAuthHeaderValue": "Palavra-passe",
|
||||
"policyAuthSetPasscode": "Definir Código de Acesso",
|
||||
@@ -1500,10 +1509,15 @@
|
||||
"actionDeleteOrgDomain": "Excluir domínio",
|
||||
"actionGetDNSRecords": "Obter registros de DNS",
|
||||
"actionRestartOrgDomain": "Reiniciar domínio",
|
||||
"actionListCustomDnsRecords": "Listar Registros DNS Personalizados",
|
||||
"actionCreateCustomDnsRecord": "Criar Registro DNS Personalizado",
|
||||
"actionUpdateCustomDnsRecord": "Atualizar Registro DNS Personalizado",
|
||||
"actionDeleteCustomDnsRecord": "Excluir Registro DNS Personalizado",
|
||||
"actionCreateSite": "Criar Site",
|
||||
"actionDeleteSite": "Eliminar Site",
|
||||
"actionGetSite": "Obter Site",
|
||||
"actionListSites": "Listar Sites",
|
||||
"actionReGenerateSecret": "Regerar Credenciais",
|
||||
"actionCreateAiProvider": "Criar Provedor de IA",
|
||||
"actionDeleteAiProvider": "Excluir Provedor de IA",
|
||||
"actionGetAiProvider": "Obter Provedor de IA",
|
||||
@@ -3727,6 +3741,27 @@
|
||||
"TTL": "TTL",
|
||||
"howToAddRecords": "Como adicionar registros",
|
||||
"dnsRecord": "Registros DNS",
|
||||
"customDnsRecords": "Registros DNS Personalizados",
|
||||
"customDnsRecordsAdd": "Adicionar Registro",
|
||||
"customDnsRecordsSearch": "Pesquisar registros...",
|
||||
"customDnsRecordsUnverifiedTitle": "Os registros ainda não estão sendo servidos",
|
||||
"customDnsRecordsUnverifiedDescription": "Este domínio ainda não foi verificado. Você pode adicionar registros agora, mas os nameservers do Pangolin não os servirão até que os nameservers do domínio apontem para o Pangolin e a verificação seja concluída.",
|
||||
"customDnsRecordCreate": "Adicionar Registro DNS",
|
||||
"customDnsRecordEdit": "Editar Registro DNS",
|
||||
"customDnsRecordFormDescription": "Este registro será servido para {domain}. As alterações podem demorar alguns minutos para se propagarem.",
|
||||
"customDnsRecordNameDescription": "Use @ para a raiz do domínio ou * como o primeiro rótulo para um curinga.",
|
||||
"customDnsRecordValueDescription": "Insira um valor por linha, por exemplo, ambos os nameservers de um registro NS.",
|
||||
"customDnsRecordPriority": "Prioridade",
|
||||
"customDnsRecordWeight": "Peso",
|
||||
"customDnsRecordPort": "Porta",
|
||||
"customDnsRecordCreated": "Registro DNS criado",
|
||||
"customDnsRecordUpdated": "Registro DNS atualizado",
|
||||
"customDnsRecordSaveError": "Falha ao salvar o registro DNS",
|
||||
"customDnsRecordDeleted": "Registro DNS excluído",
|
||||
"customDnsRecordDeleteError": "Falha ao excluir o registro DNS",
|
||||
"customDnsRecordDeleteTitle": "Excluir Registro DNS",
|
||||
"customDnsRecordDeleteQuestion": "Tem certeza de que deseja excluir o registro {type} para {name}? Ele deixará de resolver quando as respostas em cache expirarem.",
|
||||
"customDnsRecordDeleteConfirm": "Excluir Registro",
|
||||
"required": "Obrigatório",
|
||||
"domainSettingsUpdated": "Configurações de domínio atualizadas com sucesso",
|
||||
"orgOrDomainIdMissing": "ID da organização ou domínio está faltando",
|
||||
@@ -3938,14 +3973,14 @@
|
||||
"backToHome": "Voltar para a página inicial",
|
||||
"needToSignInToOrg": "Precisa usar o provedor de identidade da sua organização?",
|
||||
"mtls": "mTLS",
|
||||
"mtlsCaCertificates": "Certificados CA confiáveis",
|
||||
"mtlsCaCertificates": "Definir Certificados de Cliente",
|
||||
"mtlsCaCertificatesDescription": "Os clientes devem apresentar um certificado assinado por uma destas autoridades certificadoras para se conectar. Carregue apenas certificados CA públicos, nunca uma chave privada.",
|
||||
"mtlsCertificateName": "Nome (Opcional)",
|
||||
"mtlsCertificateNamePlaceholder": "Dispositivo Corporativo CA",
|
||||
"mtlsCertificatePem": "Certificado CA (PEM)",
|
||||
"mtlsCertificatePemDescription": "Cole um certificado codificado em PEM ou um conjunto de certificados, ou carregue um arquivo .pem, .crt ou .cer.",
|
||||
"mtlsUploadFile": "Carregar arquivo",
|
||||
"mtlsAddCertificate": "Adicionar certificado CA",
|
||||
"mtlsUploadFile": "Carregar Arquivo",
|
||||
"mtlsAddCertificate": "Adicionar Certificado CA",
|
||||
"mtlsCertificateAdded": "Certificado CA adicionado",
|
||||
"mtlsCertificateAddedDescription": "Clientes com certificados assinados por este CA agora podem ser verificados.",
|
||||
"mtlsCertificateErrorAdd": "Falha ao adicionar certificado CA",
|
||||
@@ -3955,6 +3990,10 @@
|
||||
"mtlsCertificateErrorRemoveDescription": "O certificado não pôde ser removido.",
|
||||
"mtlsLastCertificateRemoved": "mTLS foi desativado porque o último certificado CA foi removido.",
|
||||
"mtlsNoCertificates": "Ainda não foram adicionados certificados CA",
|
||||
"mtlsPendingCertificate": "Certificado CA {number}",
|
||||
"mtlsCertificateInvalidPem": "Certificado em formato PEM não encontrado. Esperado um bloco começando com -----BEGIN CERTIFICATE-----.",
|
||||
"mtlsCertificatePrivateKey": "Parece ser uma chave privada, e não um certificado. Carregue apenas o certificado público da CA.",
|
||||
"mtlsCertificateDuplicate": "Este certificado CA já foi adicionado.",
|
||||
"mtlsRemoveCertificate": "Remover certificado CA",
|
||||
"mtlsExpired": "Expirado",
|
||||
"mtlsExpiresSoon": "Expira em breve",
|
||||
@@ -4488,7 +4527,7 @@
|
||||
"rdpFull": "Completo",
|
||||
"rdpReal": "Real",
|
||||
"rdpMeta": "Meta",
|
||||
"rdpUploadFiles": "Upload de arquivos",
|
||||
"rdpUploadFiles": "Carregar Arquivos",
|
||||
"rdpFilesReadyToPaste": "Arquivos prontos para colar",
|
||||
"rdpFilesReadyToPasteDescription": "{count} arquivo(s) copiado(s) para a área de transferência remota — pressione Ctrl+V na área de trabalho remota para colar.",
|
||||
"rdpUploadFailed": "Falha no upload",
|
||||
|
||||
+42
-3
@@ -604,6 +604,10 @@
|
||||
"licenseNoUserLimit": "Нет ограничения на количество пользователей для этого хоста.",
|
||||
"licenseUsersUsedMax": "{usedUsers} из {maxUsers} пользователей использовано",
|
||||
"licenseUsersUsed": "{count, plural, =0 {# пользователей} one {# пользователь} few {# пользователя} many {# пользователей} other {# пользователей}}",
|
||||
"licenseUsageMachines": "Машинные клиенты",
|
||||
"licenseNoMachineLimit": "Нет ограничения на количество машинных клиентов для этого хоста.",
|
||||
"licenseMachinesUsedMax": "Использовано {usedMachines} из {maxMachines} машинных клиентов",
|
||||
"licenseMachinesUsed": "{count, plural, =0 {# машинных клиентов} one {# машинный клиент} few {# машинных клиента} many {# машинных клиентов} other {# машинных клиентов}}",
|
||||
"licenseUnlimited": "Неограниченный",
|
||||
"licenseTierLabel": "Уровень",
|
||||
"licensePurchaseDescription": "Выберите, для скольких сайтов вы хотите {selectedMode, select, license {приобрести лицензию. Вы всегда можете добавить больше сайтов позже.} other {добавить к существующей лицензии.}}",
|
||||
@@ -801,7 +805,7 @@
|
||||
"loadBalancingStrategy_wrr_description": "Распределяйте запросы равномерно между целями по очереди.",
|
||||
"loadBalancingStrategy_p2c": "Сила двух выборов",
|
||||
"loadBalancingStrategy_p2c_description": "Выберите две случайные цели и отправьте запрос той, у которой меньше активных соединений.",
|
||||
"loadBalancingStrategy_hrw": "Хэш клиентского IP",
|
||||
"loadBalancingStrategy_hrw": "Наивысший случайный вес",
|
||||
"loadBalancingStrategy_hrw_description": "Хэшируйте IP клиента, чтобы один и тот же клиент постоянно маршрутизировался на одну и ту же цель.",
|
||||
"loadBalancingStrategy_leasttime": "Наименьшее время ответа",
|
||||
"loadBalancingStrategy_leasttime_description": "Отправляйте запросы к цели с самым быстрым временем отклика и наименьшим количеством активных соединений.",
|
||||
@@ -829,6 +833,8 @@
|
||||
"save": "Сохранить",
|
||||
"proxyAdditional": "Дополнительные настройки прокси",
|
||||
"proxyAdditionalDescription": "Настроить обработку параметров прокси ресурса",
|
||||
"proxyForwardIdentityHeaders": "Переслать заголовки удостоверения",
|
||||
"proxyForwardIdentityHeadersDescription": "Отправьте удостоверение аутентифицированного пользователя в бекенд в заголовках Remote-User, Remote-Email, Remote-Name, Remote-Role и Remote-User-Id. Отключите для бекендов, которые отклоняют запросы с неизвестными заголовками.",
|
||||
"proxyCustomHeader": "Пользовательский заголовок Host",
|
||||
"proxyCustomHeaderDescription": "Заголовок host для установки при проксировании запросов. Оставьте пустым для использования по умолчанию.",
|
||||
"proxyAdditionalSubmit": "Сохранить настройки прокси",
|
||||
@@ -933,6 +939,9 @@
|
||||
"policyAuthHeaderAuthTitle": "Базовая аутентификация заголовка",
|
||||
"policyAuthHeaderAuthDescription": "Проверка пользовательского имени и значения HTTP-заголовка для каждого запроса",
|
||||
"policyAuthHeaderAuthSummary": "Заголовок настроен",
|
||||
"policyAuthMtlsTitle": "Клиентский сертификат (mTLS)",
|
||||
"policyAuthMtlsDescription": "Требуется клиентский сертификат, подписанный доверенным УЦ, во время рукопожатия TLS",
|
||||
"policyAuthMtlsSummary": "{count} доверенных сертификатов УЦ",
|
||||
"policyAuthHeaderName": "Имя пользователя",
|
||||
"policyAuthHeaderValue": "Пароль",
|
||||
"policyAuthSetPasscode": "Установить пароль",
|
||||
@@ -1500,10 +1509,15 @@
|
||||
"actionDeleteOrgDomain": "Удалить домен",
|
||||
"actionGetDNSRecords": "Получить записи DNS",
|
||||
"actionRestartOrgDomain": "Перезапустить домен",
|
||||
"actionListCustomDnsRecords": "Список пользовательских записей DNS",
|
||||
"actionCreateCustomDnsRecord": "Создать пользовательскую запись DNS",
|
||||
"actionUpdateCustomDnsRecord": "Обновить пользовательскую запись DNS",
|
||||
"actionDeleteCustomDnsRecord": "Удалить пользовательскую запись DNS",
|
||||
"actionCreateSite": "Создать сайт",
|
||||
"actionDeleteSite": "Удалить сайт",
|
||||
"actionGetSite": "Получить сайт",
|
||||
"actionListSites": "Список сайтов",
|
||||
"actionReGenerateSecret": "Пересоздать учетные данные",
|
||||
"actionCreateAiProvider": "Создать поставщика ИИ",
|
||||
"actionDeleteAiProvider": "Удалить поставщика ИИ",
|
||||
"actionGetAiProvider": "Получить поставщика ИИ",
|
||||
@@ -3727,6 +3741,27 @@
|
||||
"TTL": "TTL",
|
||||
"howToAddRecords": "Как добавить записи",
|
||||
"dnsRecord": "DNS записи",
|
||||
"customDnsRecords": "Пользовательские записи DNS",
|
||||
"customDnsRecordsAdd": "Добавить запись",
|
||||
"customDnsRecordsSearch": "Поиск записей...",
|
||||
"customDnsRecordsUnverifiedTitle": "Записи еще не обслуживаются",
|
||||
"customDnsRecordsUnverifiedDescription": "Этот домен не подтвержден. Вы можете добавить записи сейчас, но серверы имен Pangolin не будут их обслуживать, пока серверы имен домена не укажут на Pangolin и подтверждение не завершится.",
|
||||
"customDnsRecordCreate": "Добавить запись DNS",
|
||||
"customDnsRecordEdit": "Редактировать запись DNS",
|
||||
"customDnsRecordFormDescription": "Эта запись будет обслуживаться для {domain}. Изменения могут занять несколько минут для распространения.",
|
||||
"customDnsRecordNameDescription": "Используйте @ для корня домена или * в качестве первой метки для универсального символа.",
|
||||
"customDnsRecordValueDescription": "Введите одно значение на строку, например, оба сервера имен для записи NS.",
|
||||
"customDnsRecordPriority": "Приоритет",
|
||||
"customDnsRecordWeight": "Вес",
|
||||
"customDnsRecordPort": "Порт",
|
||||
"customDnsRecordCreated": "Запись DNS создана",
|
||||
"customDnsRecordUpdated": "Запись DNS обновлена",
|
||||
"customDnsRecordSaveError": "Не удалось сохранить запись DNS",
|
||||
"customDnsRecordDeleted": "Запись DNS удалена",
|
||||
"customDnsRecordDeleteError": "Не удалось удалить запись DNS",
|
||||
"customDnsRecordDeleteTitle": "Удалить запись DNS",
|
||||
"customDnsRecordDeleteQuestion": "Вы уверены, что хотите удалить запись {type} для {name}? Она прекратит разрешение после истечения срока кэша ответов.",
|
||||
"customDnsRecordDeleteConfirm": "Удалить запись",
|
||||
"required": "Требуется",
|
||||
"domainSettingsUpdated": "Настройки домена успешно обновлены",
|
||||
"orgOrDomainIdMissing": "Отсутствует организация или ID домена",
|
||||
@@ -3938,14 +3973,14 @@
|
||||
"backToHome": "Вернуться домой",
|
||||
"needToSignInToOrg": "Нужно использовать провайдера идентификаций вашей организации?",
|
||||
"mtls": "mTLS",
|
||||
"mtlsCaCertificates": "Доверенные сертификаты CA",
|
||||
"mtlsCaCertificates": "Установить клиентские сертификаты",
|
||||
"mtlsCaCertificatesDescription": "Клиенты должны представить сертификат, подписанный одним из этих удостоверяющих центров, чтобы подключиться. Загружайте только открытые сертификаты CA, никогда не загружайте закрытый ключ.",
|
||||
"mtlsCertificateName": "Имя (необязательно)",
|
||||
"mtlsCertificateNamePlaceholder": "Корпоративное устройство CA",
|
||||
"mtlsCertificatePem": "Сертификат CA (PEM)",
|
||||
"mtlsCertificatePemDescription": "Вставьте сертификат в кодировке PEM или комплект сертификатов, либо загрузите файл .pem, .crt или .cer.",
|
||||
"mtlsUploadFile": "Загрузить файл",
|
||||
"mtlsAddCertificate": "Добавить сертификат CA",
|
||||
"mtlsAddCertificate": "Добавить сертификат УЦ",
|
||||
"mtlsCertificateAdded": "Сертификат CA добавлен",
|
||||
"mtlsCertificateAddedDescription": "Те, у кого сертификаты, подписанные этим CA, теперь могут быть проверены.",
|
||||
"mtlsCertificateErrorAdd": "Не удалось добавить сертификат CA",
|
||||
@@ -3955,6 +3990,10 @@
|
||||
"mtlsCertificateErrorRemoveDescription": "Сертификат не может быть удалён.",
|
||||
"mtlsLastCertificateRemoved": "mTLS был отключён, потому что последний сертификат CA был удалён.",
|
||||
"mtlsNoCertificates": "Сертификаты CA ещё не добавлены",
|
||||
"mtlsPendingCertificate": "Сертификат УЦ {number}",
|
||||
"mtlsCertificateInvalidPem": "PEM-кодированный сертификат не найден. Ожидался блок, начинающийся с -----BEGIN CERTIFICATE-----.",
|
||||
"mtlsCertificatePrivateKey": "Похоже на закрытый ключ, а не сертификат. Загрузите только публичный сертификат УЦ.",
|
||||
"mtlsCertificateDuplicate": "Этот сертификат УЦ уже был добавлен.",
|
||||
"mtlsRemoveCertificate": "Удалить сертификат CA",
|
||||
"mtlsExpired": "Истёк",
|
||||
"mtlsExpiresSoon": "Скоро истечёт",
|
||||
|
||||
+44
-5
@@ -604,6 +604,10 @@
|
||||
"licenseNoUserLimit": "Bu ana bilgisayar için kullanıcı sayısında bir sınır yoktur.",
|
||||
"licenseUsersUsedMax": "{usedUsers} / {maxUsers} kullanıcılar kullanıldı",
|
||||
"licenseUsersUsed": "{count, plural, =0 {# kullanıcı} one {# kullanıcı} other {# kullanıcılar}}",
|
||||
"licenseUsageMachines": "Makine İstemcileri",
|
||||
"licenseNoMachineLimit": "Bu ana bilgisayar için makine istemcisi sayısında bir sınır yoktur.",
|
||||
"licenseMachinesUsedMax": "{usedMachines} / {maxMachines} makine istemcisi kullanıldı",
|
||||
"licenseMachinesUsed": "{count, plural, =0 {# makine istemcisi} one {# makine istemcisi} other {# makine istemcisi}}",
|
||||
"licenseUnlimited": "Sınırsız",
|
||||
"licenseTierLabel": "Seviye",
|
||||
"licensePurchaseDescription": "{selectedMode, select, license {Lisans satın almak için kaç site istediğinizi seçin. Daha sonra daha fazla site ekleyebilirsiniz.} other {mevcut lisansınıza kaç site ekleneceğini seçin.}}",
|
||||
@@ -801,7 +805,7 @@
|
||||
"loadBalancingStrategy_wrr_description": "İstekleri hedefler arasında sırayla eşit olarak dağıtın.",
|
||||
"loadBalancingStrategy_p2c": "İki Seçeneğin Gücü",
|
||||
"loadBalancingStrategy_p2c_description": "İki rasgele hedef seçin ve istekleri daha az aktif bağlantıya sahip olana gönderin.",
|
||||
"loadBalancingStrategy_hrw": "İstemci IP Hash",
|
||||
"loadBalancingStrategy_hrw": "En Yüksek Rastgele Ağırlık",
|
||||
"loadBalancingStrategy_hrw_description": "Aynı istemcinin aynı hedefe sürekli olarak yönlendirilmesi için istemci IP'sine hash uygulayın.",
|
||||
"loadBalancingStrategy_leasttime": "En Az Yanıt Süresi",
|
||||
"loadBalancingStrategy_leasttime_description": "Hedefe en hızlı yanıt süresine ve en az aktif bağlantıya sahip istekleri gönderin.",
|
||||
@@ -829,6 +833,8 @@
|
||||
"save": "Kaydet",
|
||||
"proxyAdditional": "Ek Proxy Ayarları",
|
||||
"proxyAdditionalDescription": "Kaynağın proxy ayarlarını nasıl yöneteceği yapılandırın",
|
||||
"proxyForwardIdentityHeaders": "Kimlik Başlıklarını İlet",
|
||||
"proxyForwardIdentityHeadersDescription": "Kimliği doğrulanmış kullanıcının kimliğini Remote-User, Remote-Email, Remote-Name, Remote-Role ve Remote-User-Id başlıklarında arka uca gönderin. Tanınmayan başlıklarla istekleri reddeden arka uçlar için devre dışı bırakın.",
|
||||
"proxyCustomHeader": "Özel Ana Bilgisayar Başlığı",
|
||||
"proxyCustomHeaderDescription": "İstekleri proxy'lerken ayarlanacak ana bilgisayar başlığı. Varsayılanı kullanmak için boş bırakılır.",
|
||||
"proxyAdditionalSubmit": "Proxy Ayarlarını Kaydet",
|
||||
@@ -933,6 +939,9 @@
|
||||
"policyAuthHeaderAuthTitle": "Temel Başlık Kimlik Doğrulama",
|
||||
"policyAuthHeaderAuthDescription": "Her istekte özel bir HTTP başlık adını ve değerini doğrulayın",
|
||||
"policyAuthHeaderAuthSummary": "Başlık yapılandırıldı",
|
||||
"policyAuthMtlsTitle": "İstemci Sertifikası (mTLS)",
|
||||
"policyAuthMtlsDescription": "TLS el sıkışması sırasında güvenilir bir CA tarafından imzalanmış istemci sertifikası gereklidir",
|
||||
"policyAuthMtlsSummary": "{count} güvenilir CA sertifikası",
|
||||
"policyAuthHeaderName": "Kullanıcı Adı",
|
||||
"policyAuthHeaderValue": "Şifre",
|
||||
"policyAuthSetPasscode": "Şifreyi Ayarla",
|
||||
@@ -1500,10 +1509,15 @@
|
||||
"actionDeleteOrgDomain": "Alan Adını Sil",
|
||||
"actionGetDNSRecords": "DNS Kayıtlarını Al",
|
||||
"actionRestartOrgDomain": "Alanı Yeniden Başlat",
|
||||
"actionListCustomDnsRecords": "Özel DNS Kayıtlarını Listele",
|
||||
"actionCreateCustomDnsRecord": "Özel DNS Kaydı Oluştur",
|
||||
"actionUpdateCustomDnsRecord": "Özel DNS Kaydını Güncelle",
|
||||
"actionDeleteCustomDnsRecord": "Özel DNS Kaydını Sil",
|
||||
"actionCreateSite": "Site Oluştur",
|
||||
"actionDeleteSite": "Siteyi Sil",
|
||||
"actionGetSite": "Siteyi Al",
|
||||
"actionListSites": "Siteleri Listele",
|
||||
"actionReGenerateSecret": "Kimlik Bilgilerini Yeniden Oluştur",
|
||||
"actionCreateAiProvider": "AI Sağlayıcı Oluştur",
|
||||
"actionDeleteAiProvider": "AI Sağlayıcı Sil",
|
||||
"actionGetAiProvider": "AI Sağlayıcı Al",
|
||||
@@ -3727,6 +3741,27 @@
|
||||
"TTL": "TTL",
|
||||
"howToAddRecords": "Kayıtları Nasıl Ekleyebilirsiniz",
|
||||
"dnsRecord": "DNS Kayıtları",
|
||||
"customDnsRecords": "Özel DNS Kayıtları",
|
||||
"customDnsRecordsAdd": "Kayıt Ekle",
|
||||
"customDnsRecordsSearch": "Kayıtları ara...",
|
||||
"customDnsRecordsUnverifiedTitle": "Kayıtlar henüz sunulmuyor",
|
||||
"customDnsRecordsUnverifiedDescription": "Bu alan adı doğrulanmadı. Şimdi kayıt ekleyebilirsiniz, ancak Pangolin isim sunucuları bunları alan adının isim sunucuları Pangolin'e yönlendirilene ve doğrulama tamamlanıncaya kadar sunmayacaktır.",
|
||||
"customDnsRecordCreate": "DNS Kaydı Ekle",
|
||||
"customDnsRecordEdit": "DNS Kaydını Düzenle",
|
||||
"customDnsRecordFormDescription": "Bu kayıt {domain} için sunulacaktır. Değişikliklerin yayılması birkaç dakika sürebilir.",
|
||||
"customDnsRecordNameDescription": "Alan adının kökü için @ veya genel bir ad için ilk etiket olarak * kullanın.",
|
||||
"customDnsRecordValueDescription": "Satır başına bir değer girin, örneğin NS kaydının her iki isim sunucusu.",
|
||||
"customDnsRecordPriority": "Öncelik",
|
||||
"customDnsRecordWeight": "Ağırlık",
|
||||
"customDnsRecordPort": "Bağlantı Noktası",
|
||||
"customDnsRecordCreated": "DNS kaydı oluşturuldu",
|
||||
"customDnsRecordUpdated": "DNS kaydı güncellendi",
|
||||
"customDnsRecordSaveError": "DNS kaydı kaydedilemedi",
|
||||
"customDnsRecordDeleted": "DNS kaydı silindi",
|
||||
"customDnsRecordDeleteError": "DNS kaydı silinemedi",
|
||||
"customDnsRecordDeleteTitle": "DNS Kaydını Sil",
|
||||
"customDnsRecordDeleteQuestion": "{name} için {type} kaydını silmek istediğinizden emin misiniz? Önbellekteki yanıtlar süresi dolduktan sonra çözülmeyecektir.",
|
||||
"customDnsRecordDeleteConfirm": "Kaydı Sil",
|
||||
"required": "Gerekli",
|
||||
"domainSettingsUpdated": "Alan adına yönelik ayarlar başarıyla güncellendi",
|
||||
"orgOrDomainIdMissing": "Organizasyon veya Alan Adı Kimliği eksik",
|
||||
@@ -3938,14 +3973,14 @@
|
||||
"backToHome": "Ana sayfaya geri dön",
|
||||
"needToSignInToOrg": "Kuruluşunuzun kimlik sağlayıcısını kullanmanız mı gerekiyor?",
|
||||
"mtls": "mTLS",
|
||||
"mtlsCaCertificates": "Güvenilir CA Sertifikaları",
|
||||
"mtlsCaCertificates": "İstemci Sertifikalarını Ayarla",
|
||||
"mtlsCaCertificatesDescription": "Bağlanmak için bu sertifika otoritelerinden biri tarafından imzalanmış bir sertifika sunmalıdır. Sadece genel CA sertifikaları yükleyin, asla özel anahtar yüklemeyin.",
|
||||
"mtlsCertificateName": "İsim (İsteğe Bağlı)",
|
||||
"mtlsCertificateNamePlaceholder": "Kurumsal cihaz CA",
|
||||
"mtlsCertificatePem": "CA sertifikası (PEM)",
|
||||
"mtlsCertificatePemDescription": "Bir PEM kodlamalı sertifika veya bir sertifika yığını yapıştırın ya da bir .pem, .crt veya .cer dosyası yükleyin.",
|
||||
"mtlsUploadFile": "Dosya yükle",
|
||||
"mtlsAddCertificate": "CA sertifikası ekle",
|
||||
"mtlsUploadFile": "Dosya Yükle",
|
||||
"mtlsAddCertificate": "CA Sertifikası Ekle",
|
||||
"mtlsCertificateAdded": "CA sertifikası eklendi",
|
||||
"mtlsCertificateAddedDescription": "Bu CA tarafından imzalanan sertifikalara sahip istemciler artık doğrulanabilir.",
|
||||
"mtlsCertificateErrorAdd": "CA sertifikası eklenemedi",
|
||||
@@ -3955,6 +3990,10 @@
|
||||
"mtlsCertificateErrorRemoveDescription": "Sertifika kaldırılamadı.",
|
||||
"mtlsLastCertificateRemoved": "mTLS son CA sertifikası kaldırıldığı için kapatıldı.",
|
||||
"mtlsNoCertificates": "Henüz CA sertifikası eklenmedi",
|
||||
"mtlsPendingCertificate": "CA sertifikası {number}",
|
||||
"mtlsCertificateInvalidPem": "PEM kodlu sertifika bulunamadı. -----BEGIN CERTIFICATE----- ile başlayan bir blok bekleniyor.",
|
||||
"mtlsCertificatePrivateKey": "Bu, bir sertifikadan çok özel anahtar benziyor. Yalnızca CA'nın genel sertifikasını yükleyin.",
|
||||
"mtlsCertificateDuplicate": "Bu CA sertifikası zaten eklenmiş.",
|
||||
"mtlsRemoveCertificate": "CA sertifikasını kaldır",
|
||||
"mtlsExpired": "Süresi doldu",
|
||||
"mtlsExpiresSoon": "Yakında süresi dolacak",
|
||||
@@ -4488,7 +4527,7 @@
|
||||
"rdpFull": "Tam",
|
||||
"rdpReal": "Gerçek",
|
||||
"rdpMeta": "Meta",
|
||||
"rdpUploadFiles": "Dosya yükle",
|
||||
"rdpUploadFiles": "Dosyaları Yükle",
|
||||
"rdpFilesReadyToPaste": "Yapıştırmak üzere dosyalar hazır",
|
||||
"rdpFilesReadyToPasteDescription": "{count} dosya uzak panoya kopyalandı — yapıştırmak için uzak masaüstünde Ctrl+V tuşlarına basın.",
|
||||
"rdpUploadFailed": "Yükleme başarısız",
|
||||
|
||||
+42
-3
@@ -604,6 +604,10 @@
|
||||
"licenseNoUserLimit": "此主机的用户数量没有限制。",
|
||||
"licenseUsersUsedMax": "使用了 {usedUsers}/{maxUsers} 个用户",
|
||||
"licenseUsersUsed": "{count, plural, =0 {# 用户} other {# 用户}}",
|
||||
"licenseUsageMachines": "机器客户端",
|
||||
"licenseNoMachineLimit": "对于此主机,机器客户端的数量没有限制。",
|
||||
"licenseMachinesUsedMax": "已使用 {usedMachines} 个机器客户端,共有 {maxMachines} 个机器客户端",
|
||||
"licenseMachinesUsed": "{count, plural, =0 {# 个机器客户端} one {# 个机器客户端} other {# 个机器客户端}}",
|
||||
"licenseUnlimited": "无限制",
|
||||
"licenseTierLabel": "级别",
|
||||
"licensePurchaseDescription": "请选择您希望 {selectedMode, select, license {直接购买许可证,您可以随时增加更多站点。} other {为现有许可证购买更多站点}}",
|
||||
@@ -801,7 +805,7 @@
|
||||
"loadBalancingStrategy_wrr_description": "按顺序均匀分配请求到目标。",
|
||||
"loadBalancingStrategy_p2c": "二选一",
|
||||
"loadBalancingStrategy_p2c_description": "随机选择两个目标,并将请求发送到连接较少的那个。",
|
||||
"loadBalancingStrategy_hrw": "客户端 IP 哈希",
|
||||
"loadBalancingStrategy_hrw": "最高随机权重",
|
||||
"loadBalancingStrategy_hrw_description": "散列客户端 IP,因此同一客户端始终路由到相同的目标。",
|
||||
"loadBalancingStrategy_leasttime": "最短响应时间",
|
||||
"loadBalancingStrategy_leasttime_description": "将请求发送到响应时间最快且活动连接最少的目标。",
|
||||
@@ -829,6 +833,8 @@
|
||||
"save": "保存",
|
||||
"proxyAdditional": "附加代理设置",
|
||||
"proxyAdditionalDescription": "配置资源如何处理代理设置",
|
||||
"proxyForwardIdentityHeaders": "转发身份头信息",
|
||||
"proxyForwardIdentityHeadersDescription": "在Remote-User、Remote-Email、Remote-Name、Remote-Role和Remote-User-Id头中将经过身份验证的用户身份发送到后端。对于拒绝带有未识别头部请求的后端,请禁用。",
|
||||
"proxyCustomHeader": "自定义主机标题",
|
||||
"proxyCustomHeaderDescription": "代理请求时设置的主机头。留空则使用默认值。",
|
||||
"proxyAdditionalSubmit": "保存代理设置",
|
||||
@@ -933,6 +939,9 @@
|
||||
"policyAuthHeaderAuthTitle": "基础标题认证",
|
||||
"policyAuthHeaderAuthDescription": "在每次请求上验证自定义 HTTP 标头名称和值",
|
||||
"policyAuthHeaderAuthSummary": "标头已配置",
|
||||
"policyAuthMtlsTitle": "客户端证书(mTLS)",
|
||||
"policyAuthMtlsDescription": "在TLS握手过程中需要一个受信任CA签署的客户端证书",
|
||||
"policyAuthMtlsSummary": "{count} 个受信任的CA证书",
|
||||
"policyAuthHeaderName": "用户名",
|
||||
"policyAuthHeaderValue": "密码",
|
||||
"policyAuthSetPasscode": "设定密码",
|
||||
@@ -1500,10 +1509,15 @@
|
||||
"actionDeleteOrgDomain": "删除域",
|
||||
"actionGetDNSRecords": "获取 DNS 记录",
|
||||
"actionRestartOrgDomain": "重新启动域",
|
||||
"actionListCustomDnsRecords": "列出自定义DNS记录",
|
||||
"actionCreateCustomDnsRecord": "创建自定义DNS记录",
|
||||
"actionUpdateCustomDnsRecord": "更新自定义DNS记录",
|
||||
"actionDeleteCustomDnsRecord": "删除自定义DNS记录",
|
||||
"actionCreateSite": "创建站点",
|
||||
"actionDeleteSite": "删除站点",
|
||||
"actionGetSite": "获取站点",
|
||||
"actionListSites": "站点列表",
|
||||
"actionReGenerateSecret": "重新生成证书",
|
||||
"actionCreateAiProvider": "创建AI提供者",
|
||||
"actionDeleteAiProvider": "删除AI提供者",
|
||||
"actionGetAiProvider": "获取AI提供者",
|
||||
@@ -3727,6 +3741,27 @@
|
||||
"TTL": "TTL",
|
||||
"howToAddRecords": "如何添加记录",
|
||||
"dnsRecord": "DNS记录",
|
||||
"customDnsRecords": "自定义DNS记录",
|
||||
"customDnsRecordsAdd": "添加记录",
|
||||
"customDnsRecordsSearch": "搜索记录……",
|
||||
"customDnsRecordsUnverifiedTitle": "记录尚未被服务",
|
||||
"customDnsRecordsUnverifiedDescription": "该域名尚未验证。您现在可以添加记录,但Pangolin的名称服务器不会提供这些记录,直到该域名的名称服务器指向Pangolin并完成验证。",
|
||||
"customDnsRecordCreate": "添加DNS记录",
|
||||
"customDnsRecordEdit": "编辑DNS记录",
|
||||
"customDnsRecordFormDescription": "此记录将为{domain}提供服务。更改可能需要几分钟才能传播。",
|
||||
"customDnsRecordNameDescription": "使用@表示域名根,或使用*作为第一个标签来表示通配符。",
|
||||
"customDnsRecordValueDescription": "每行输入一个值,例如NS记录的两个名称服务器。",
|
||||
"customDnsRecordPriority": "优先级",
|
||||
"customDnsRecordWeight": "权重",
|
||||
"customDnsRecordPort": "端口",
|
||||
"customDnsRecordCreated": "DNS记录已创建",
|
||||
"customDnsRecordUpdated": "DNS记录已更新",
|
||||
"customDnsRecordSaveError": "保存DNS记录失败",
|
||||
"customDnsRecordDeleted": "DNS记录已删除",
|
||||
"customDnsRecordDeleteError": "删除DNS记录失败",
|
||||
"customDnsRecordDeleteTitle": "删除DNS记录",
|
||||
"customDnsRecordDeleteQuestion": "您确定要删除类型为{type}的<b>{name}</b>记录吗?缓存答案到期后,它将停止解析。",
|
||||
"customDnsRecordDeleteConfirm": "删除记录",
|
||||
"required": "必填",
|
||||
"domainSettingsUpdated": "域设置更新成功",
|
||||
"orgOrDomainIdMissing": "缺少机构或域 ID",
|
||||
@@ -3938,14 +3973,14 @@
|
||||
"backToHome": "返回首页",
|
||||
"needToSignInToOrg": "需要使用您组织的身份提供商吗?",
|
||||
"mtls": "mTLS",
|
||||
"mtlsCaCertificates": "可信 CA 证书",
|
||||
"mtlsCaCertificates": "设置客户端证书",
|
||||
"mtlsCaCertificatesDescription": "客户端必须出示由这些证书颁发机构签名的证书才能连接。仅上传公共 CA 证书,切勿上传私钥。",
|
||||
"mtlsCertificateName": "名称(可选)",
|
||||
"mtlsCertificateNamePlaceholder": "企业设备 CA",
|
||||
"mtlsCertificatePem": "CA 证书(PEM)",
|
||||
"mtlsCertificatePemDescription": "粘贴 PEM 编码的证书或证书束,或上传 .pem、 .crt 或 .cer 文件。",
|
||||
"mtlsUploadFile": "上传文件",
|
||||
"mtlsAddCertificate": "添加 CA 证书",
|
||||
"mtlsAddCertificate": "添加CA证书",
|
||||
"mtlsCertificateAdded": "CA 证书已添加",
|
||||
"mtlsCertificateAddedDescription": "现在可以验证由此 CA 签名证书的客户端。",
|
||||
"mtlsCertificateErrorAdd": "添加 CA 证书失败",
|
||||
@@ -3955,6 +3990,10 @@
|
||||
"mtlsCertificateErrorRemoveDescription": "无法移除证书。",
|
||||
"mtlsLastCertificateRemoved": "因为最后一个 CA 证书被移除,mTLS 已关闭。",
|
||||
"mtlsNoCertificates": "尚未添加 CA 证书",
|
||||
"mtlsPendingCertificate": "CA证书 {number}",
|
||||
"mtlsCertificateInvalidPem": "找不到PEM编码的证书。预期的块以-----BEGIN CERTIFICATE-----开始。",
|
||||
"mtlsCertificatePrivateKey": "这看起来像是私钥,而不是证书。仅上传CA的公钥证书。",
|
||||
"mtlsCertificateDuplicate": "该CA证书已被添加。",
|
||||
"mtlsRemoveCertificate": "移除 CA 证书",
|
||||
"mtlsExpired": "已过期",
|
||||
"mtlsExpiresSoon": "即将到期",
|
||||
|
||||
Generated
+8
-8
@@ -11,8 +11,8 @@
|
||||
"dependencies": {
|
||||
"@asteasolutions/zod-to-openapi": "9.1.0",
|
||||
"@aws-sdk/client-s3": "3.1152.0",
|
||||
"@devolutions/iron-remote-desktop": "https://static.pangolin.net/packages/devolutions-iron-remote-desktop-0.0.0.tgz",
|
||||
"@devolutions/iron-remote-desktop-rdp": "https://static.pangolin.net/packages/devolutions-iron-remote-desktop-rdp-0.0.1.tgz",
|
||||
"@devolutions/iron-remote-desktop": "https://static.pangolin.net/packages/devolutions-iron-remote-desktop-0.0.1.tgz",
|
||||
"@devolutions/iron-remote-desktop-rdp": "https://static.pangolin.net/packages/devolutions-iron-remote-desktop-rdp-0.0.2.tgz",
|
||||
"@headlessui/react": "2.2.10",
|
||||
"@hookform/resolvers": "5.9.1",
|
||||
"@monaco-editor/react": "4.7.0",
|
||||
@@ -827,14 +827,14 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@devolutions/iron-remote-desktop": {
|
||||
"version": "0.0.0",
|
||||
"resolved": "https://static.pangolin.net/packages/devolutions-iron-remote-desktop-0.0.0.tgz",
|
||||
"integrity": "sha512-9o7PkCw9fdvGTPs0hgsUJG10QleGgcdsSCw1ekLpUOlVXtWCuiuPH+0bPDFhLWxqbVA+8pyVhwqdOI+t1T3TNA=="
|
||||
"version": "0.0.1",
|
||||
"resolved": "https://static.pangolin.net/packages/devolutions-iron-remote-desktop-0.0.1.tgz",
|
||||
"integrity": "sha512-WWS0D/5op8/+HEwKuXrDOt5TYtMaWqqNGER8Ut8GaSOdKZCSaGlOSAwBh492t3ESy0BD454GdmcsXxCYwR3ufQ=="
|
||||
},
|
||||
"node_modules/@devolutions/iron-remote-desktop-rdp": {
|
||||
"version": "0.0.1",
|
||||
"resolved": "https://static.pangolin.net/packages/devolutions-iron-remote-desktop-rdp-0.0.1.tgz",
|
||||
"integrity": "sha512-sIHllJ6rJfAgClsUFjJfVGFjIxm6ZQRyGfinwCzHYgwnHAMFE+Kvm0YzR9tg811Fhjm5F6gNUSLAll+7Eh0Deg=="
|
||||
"version": "0.0.2",
|
||||
"resolved": "https://static.pangolin.net/packages/devolutions-iron-remote-desktop-rdp-0.0.2.tgz",
|
||||
"integrity": "sha512-n4m2FMmeAtsYt0wVXd/8JvV85Sd5/nwt2aPoFp9NBV7prx8/YCU+QGNX1ht8Qfey/YgUagKEz0V9uAzChl4IrA=="
|
||||
},
|
||||
"node_modules/@dotenvx/dotenvx": {
|
||||
"version": "2.37.0",
|
||||
|
||||
+4
-3
@@ -29,13 +29,14 @@
|
||||
"email": "email dev --dir server/emails/templates --port 3005",
|
||||
"build:cli": "node esbuild.mjs -e cli/index.ts -o dist/cli.mjs",
|
||||
"format:check": "prettier --check .",
|
||||
"format": "prettier --write ."
|
||||
"format": "prettier --write .",
|
||||
"test": "node scripts/run-tests.mjs"
|
||||
},
|
||||
"dependencies": {
|
||||
"@asteasolutions/zod-to-openapi": "9.1.0",
|
||||
"@aws-sdk/client-s3": "3.1152.0",
|
||||
"@devolutions/iron-remote-desktop": "https://static.pangolin.net/packages/devolutions-iron-remote-desktop-0.0.0.tgz",
|
||||
"@devolutions/iron-remote-desktop-rdp": "https://static.pangolin.net/packages/devolutions-iron-remote-desktop-rdp-0.0.1.tgz",
|
||||
"@devolutions/iron-remote-desktop": "https://static.pangolin.net/packages/devolutions-iron-remote-desktop-0.0.1.tgz",
|
||||
"@devolutions/iron-remote-desktop-rdp": "https://static.pangolin.net/packages/devolutions-iron-remote-desktop-rdp-0.0.2.tgz",
|
||||
"@headlessui/react": "2.2.10",
|
||||
"@hookform/resolvers": "5.9.1",
|
||||
"@monaco-editor/react": "4.7.0",
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
import { spawnSync } from "node:child_process";
|
||||
import { readdirSync, statSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
|
||||
// Test files that fail today. Give each a reason, fix it, then remove it.
|
||||
const knownFailing = new Map([
|
||||
[
|
||||
"server/setup/migrationsSqlite.backup.test.ts",
|
||||
"migration 1.24.0 fails: missing resources table in seeded db"
|
||||
],
|
||||
[
|
||||
"server/lib/traefik/traefikConfig.test.ts",
|
||||
"config undefined on import (logger circular dependency)"
|
||||
]
|
||||
]);
|
||||
const skipDirs = new Set(["node_modules", ".next", "dist", ".git"]);
|
||||
|
||||
function find(dir) {
|
||||
return readdirSync(dir).flatMap((name) => {
|
||||
if (skipDirs.has(name) || name.startsWith("private")) return [];
|
||||
const p = join(dir, name);
|
||||
if (statSync(p).isDirectory()) return find(p);
|
||||
return p.endsWith(".test.ts") ? [p] : [];
|
||||
});
|
||||
}
|
||||
|
||||
const files = find(".");
|
||||
const failed = [];
|
||||
for (const f of files) {
|
||||
if (knownFailing.has(f)) {
|
||||
console.log(`\n== SKIPPED ${f}: ${knownFailing.get(f)}`);
|
||||
continue;
|
||||
}
|
||||
console.log(`\n== ${f}`);
|
||||
const r = spawnSync("npx", ["tsx", f], { stdio: "inherit", shell: process.platform === "win32" });
|
||||
if (r.status !== 0) failed.push(f);
|
||||
}
|
||||
const ran = files.length - knownFailing.size;
|
||||
console.log(`\n${ran - failed.length}/${ran} test files passed`);
|
||||
if (failed.length) {
|
||||
console.log("FAILED:\n" + failed.join("\n"));
|
||||
process.exit(1);
|
||||
}
|
||||
@@ -134,6 +134,10 @@ export enum ActionsEnum {
|
||||
createOrgDomain = "createOrgDomain",
|
||||
deleteOrgDomain = "deleteOrgDomain",
|
||||
restartOrgDomain = "restartOrgDomain",
|
||||
listCustomDnsRecords = "listCustomDnsRecords",
|
||||
createCustomDnsRecord = "createCustomDnsRecord",
|
||||
updateCustomDnsRecord = "updateCustomDnsRecord",
|
||||
deleteCustomDnsRecord = "deleteCustomDnsRecord",
|
||||
createRemoteExitNode = "createRemoteExitNode",
|
||||
updateRemoteExitNode = "updateRemoteExitNode",
|
||||
getRemoteExitNode = "getRemoteExitNode",
|
||||
|
||||
@@ -168,6 +168,7 @@
|
||||
"iPhone18,5": "iPhone 17e",
|
||||
"iPhone19,2": "iPhone 18 Pro",
|
||||
"iPhone19,3": "iPhone 18 Pro Max",
|
||||
"iPhone19,4": "iPhone Duo",
|
||||
"iPhone19,7": "iPhone 18 Pro Max",
|
||||
"iPod1,1": "iPod touch Original",
|
||||
"iPod2,1": "iPod touch 2nd",
|
||||
|
||||
@@ -41,6 +41,36 @@ export const dnsChallenge = pgTable("dnsChallenges", {
|
||||
completed: boolean("completed").default(false)
|
||||
});
|
||||
|
||||
// User-defined records served by the authoritative DNS server for a domain
|
||||
// delegated to Pangolin (type "ns"). fullDomain is the lowercase FQDN without
|
||||
// a trailing dot, precomputed so the DNS server can look records up directly.
|
||||
export const customDnsRecords = pgTable(
|
||||
"customDnsRecords",
|
||||
{
|
||||
customDnsRecordId: serial("customDnsRecordId").primaryKey(),
|
||||
domainId: varchar("domainId")
|
||||
.notNull()
|
||||
.references(() => domains.domainId, { onDelete: "cascade" }),
|
||||
name: varchar("name", { length: 255 }).notNull(), // relative to the domain, "@" for the apex
|
||||
fullDomain: varchar("fullDomain", { length: 255 }).notNull(),
|
||||
type: varchar("type", { length: 10 })
|
||||
.$type<"A" | "AAAA" | "CNAME" | "MX" | "NS" | "SRV" | "TXT">()
|
||||
.notNull(),
|
||||
values: text("values").notNull(), // JSON array of strings, one resource record each
|
||||
ttl: integer("ttl").notNull().default(300),
|
||||
priority: integer("priority"), // MX and SRV
|
||||
weight: integer("weight"), // SRV
|
||||
port: integer("port"), // SRV
|
||||
enabled: boolean("enabled").notNull().default(true),
|
||||
createdAt: bigint("createdAt", { mode: "number" }).notNull(),
|
||||
updatedAt: bigint("updatedAt", { mode: "number" }).notNull()
|
||||
},
|
||||
(t) => [
|
||||
index("idx_customDnsRecords_fullDomain").on(t.fullDomain),
|
||||
index("idx_customDnsRecords_domainId").on(t.domainId)
|
||||
]
|
||||
);
|
||||
|
||||
export const account = pgTable("account", {
|
||||
accountId: serial("accountId").primaryKey(),
|
||||
userId: varchar("userId")
|
||||
@@ -701,6 +731,7 @@ export type Approval = InferSelectModel<typeof approvals>;
|
||||
export type Limit = InferSelectModel<typeof limits>;
|
||||
export type Account = InferSelectModel<typeof account>;
|
||||
export type DnsChallenge = InferSelectModel<typeof dnsChallenge>;
|
||||
export type CustomDnsRecord = InferSelectModel<typeof customDnsRecords>;
|
||||
export type Customer = InferSelectModel<typeof customers>;
|
||||
export type Subscription = InferSelectModel<typeof subscriptions>;
|
||||
export type SubscriptionItem = InferSelectModel<typeof subscriptionItems>;
|
||||
|
||||
@@ -37,6 +37,40 @@ export const dnsChallenge = sqliteTable("dnsChallenges", {
|
||||
completed: integer("completed", { mode: "boolean" }).default(false)
|
||||
});
|
||||
|
||||
// User-defined records served by the authoritative DNS server for a domain
|
||||
// delegated to Pangolin (type "ns"). fullDomain is the lowercase FQDN without
|
||||
// a trailing dot, precomputed so the DNS server can look records up directly.
|
||||
export const customDnsRecords = sqliteTable(
|
||||
"customDnsRecords",
|
||||
{
|
||||
customDnsRecordId: integer("customDnsRecordId").primaryKey({
|
||||
autoIncrement: true
|
||||
}),
|
||||
domainId: text("domainId")
|
||||
.notNull()
|
||||
.references(() => domains.domainId, { onDelete: "cascade" }),
|
||||
name: text("name").notNull(), // relative to the domain, "@" for the apex
|
||||
fullDomain: text("fullDomain").notNull(),
|
||||
type: text("type")
|
||||
.$type<"A" | "AAAA" | "CNAME" | "MX" | "NS" | "SRV" | "TXT">()
|
||||
.notNull(),
|
||||
values: text("values").notNull(), // JSON array of strings, one resource record each
|
||||
ttl: integer("ttl").notNull().default(300),
|
||||
priority: integer("priority"), // MX and SRV
|
||||
weight: integer("weight"), // SRV
|
||||
port: integer("port"), // SRV
|
||||
enabled: integer("enabled", { mode: "boolean" })
|
||||
.notNull()
|
||||
.default(true),
|
||||
createdAt: integer("createdAt").notNull(),
|
||||
updatedAt: integer("updatedAt").notNull()
|
||||
},
|
||||
(t) => [
|
||||
index("idx_customDnsRecords_fullDomain").on(t.fullDomain),
|
||||
index("idx_customDnsRecords_domainId").on(t.domainId)
|
||||
]
|
||||
);
|
||||
|
||||
export const account = sqliteTable("account", {
|
||||
accountId: integer("accountId").primaryKey({ autoIncrement: true }),
|
||||
userId: text("userId")
|
||||
@@ -700,6 +734,7 @@ export type Approval = InferSelectModel<typeof approvals>;
|
||||
export type Limit = InferSelectModel<typeof limits>;
|
||||
export type Account = InferSelectModel<typeof account>;
|
||||
export type DnsChallenge = InferSelectModel<typeof dnsChallenge>;
|
||||
export type CustomDnsRecord = InferSelectModel<typeof customDnsRecords>;
|
||||
export type Customer = InferSelectModel<typeof customers>;
|
||||
export type Subscription = InferSelectModel<typeof subscriptions>;
|
||||
export type SubscriptionItem = InferSelectModel<typeof subscriptionItems>;
|
||||
|
||||
@@ -25,7 +25,8 @@ export enum TierFeature {
|
||||
WildcardSubdomain = "wildcardSubdomain",
|
||||
NewtAutoUpdate = "newtAutoUpdate",
|
||||
ResourcePolicies = "resourcePolicies",
|
||||
RoleBasedSSHControls = "roleBasedSSHControls"
|
||||
RoleBasedSSHControls = "roleBasedSSHControls",
|
||||
CustomDnsRecords = "customDnsRecords" // handle downgrade by disabling custom DNS records
|
||||
}
|
||||
|
||||
export const tierMatrix: Record<TierFeature, Tier[]> = {
|
||||
@@ -68,5 +69,6 @@ export const tierMatrix: Record<TierFeature, Tier[]> = {
|
||||
[TierFeature.WildcardSubdomain]: ["tier1", "tier2", "tier3", "enterprise"],
|
||||
[TierFeature.NewtAutoUpdate]: ["tier1", "tier2", "tier3", "enterprise"],
|
||||
[TierFeature.ResourcePolicies]: ["tier3", "enterprise"],
|
||||
[TierFeature.RoleBasedSSHControls]: ["tier3", "enterprise"]
|
||||
[TierFeature.RoleBasedSSHControls]: ["tier3", "enterprise"],
|
||||
[TierFeature.CustomDnsRecords]: ["tier1", "tier2", "tier3", "enterprise"]
|
||||
};
|
||||
|
||||
@@ -6,7 +6,7 @@ import { MtlsSchema } from "#dynamic/lib/blueprints/MtlsSchema";
|
||||
import { resourceModeSupportsMtls } from "@server/lib/mtlsModes";
|
||||
import { isValidRegionId } from "@server/db/regions";
|
||||
import { isValidHttpMethodList } from "@server/lib/validators";
|
||||
import { wildcardSubdomainSchema } from "@server/lib/schemas";
|
||||
import { routePathSchema, wildcardSubdomainSchema } from "@server/lib/schemas";
|
||||
import config from "@server/lib/config";
|
||||
import {
|
||||
aiBudgetEnforcementSchema,
|
||||
@@ -91,7 +91,7 @@ export const TargetSchema = z.object({
|
||||
port: z.int().min(1).max(65535),
|
||||
enabled: z.boolean().optional().default(true),
|
||||
"internal-port": z.int().min(1).max(65535).optional(),
|
||||
path: z.string().optional(),
|
||||
path: routePathSchema.optional(),
|
||||
"path-match": z.enum(["exact", "prefix", "regex"]).optional().nullable(),
|
||||
healthcheck: TargetHealthCheckSchema.optional(),
|
||||
rewritePath: z.string().optional(), // deprecated alias
|
||||
@@ -328,6 +328,24 @@ export const AuthDaemonSchema = z
|
||||
}
|
||||
);
|
||||
|
||||
// A full-domain must be made of hostname labels ("*" is allowed as a label;
|
||||
// where it may appear is checked by the resource schemas). Anything else
|
||||
// would end up in a Traefik Host() rule and certificate requests verbatim.
|
||||
const fullDomainSchema = z
|
||||
.string()
|
||||
.refine(
|
||||
(val) =>
|
||||
val === "" ||
|
||||
val
|
||||
.split(".")
|
||||
.every(
|
||||
(label) =>
|
||||
label === "*" ||
|
||||
/^[a-zA-Z0-9]([a-zA-Z0-9-]*[a-zA-Z0-9])?$/.test(label)
|
||||
),
|
||||
{ message: "full-domain must be a valid hostname" }
|
||||
);
|
||||
|
||||
// Schema for individual resource
|
||||
export const PublicResourceSchema = z
|
||||
.object({
|
||||
@@ -341,7 +359,7 @@ export const PublicResourceSchema = z
|
||||
policy: z.string().optional(),
|
||||
ssl: z.boolean().optional(),
|
||||
scheme: z.enum(["http", "https"]).optional(),
|
||||
"full-domain": z.string().optional(),
|
||||
"full-domain": fullDomainSchema.optional(),
|
||||
"proxy-port": z.int().min(1).max(65535).optional(),
|
||||
enabled: z.boolean().optional(),
|
||||
targets: z.array(TargetSchema.nullable()).optional().default([]),
|
||||
@@ -713,7 +731,7 @@ export const PrivateResourceSchema = z
|
||||
"tcp-ports": portRangeStringSchema.optional().default("*"),
|
||||
"udp-ports": portRangeStringSchema.optional().default("*"),
|
||||
"disable-icmp": z.boolean().optional().default(false),
|
||||
"full-domain": z.string().optional(),
|
||||
"full-domain": fullDomainSchema.optional(),
|
||||
ssl: z.boolean().optional(),
|
||||
scheme: z.enum(["http", "https"]).optional().nullable(),
|
||||
alias: z
|
||||
|
||||
@@ -124,6 +124,16 @@ export async function validateAndConstructDomain(
|
||||
|
||||
if (domainRes.domains.type === "ns") {
|
||||
if (subdomain) {
|
||||
if (!isWildcard) {
|
||||
const parsedSubdomain =
|
||||
subdomainSchema.safeParse(subdomain);
|
||||
if (!parsedSubdomain.success) {
|
||||
return {
|
||||
success: false,
|
||||
error: fromError(parsedSubdomain.error).toString()
|
||||
};
|
||||
}
|
||||
}
|
||||
fullDomain = `${subdomain}.${domainRes.domains.baseDomain}`;
|
||||
} else {
|
||||
fullDomain = domainRes.domains.baseDomain;
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import "../extendZod";
|
||||
import { cidrToRange, findNextAvailableCidr } from "./ip";
|
||||
import { assertEquals } from "@test/assert";
|
||||
|
||||
|
||||
@@ -62,3 +62,14 @@ export const privateNamespaceSubdomainSchema = z
|
||||
.min(1, "Namespace subdomain must be at least 1 character long")
|
||||
.max(32, "Namespace subdomain must be at most 32 characters long")
|
||||
.transform((val) => val.toLowerCase());
|
||||
|
||||
/**
|
||||
* A path matched by a Traefik router (target path, redirect match path).
|
||||
* Backticks and control characters are never valid in a URL path and are
|
||||
* rejected so the value can't break out of its literal in the router rule.
|
||||
*/
|
||||
export const routePathSchema = z
|
||||
.string()
|
||||
.refine((val) => !/[`\u0000-\u001f\u007f]/.test(val), {
|
||||
message: "Path cannot contain backticks or control characters"
|
||||
});
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import config from "@server/lib/config";
|
||||
import { applyMtlsConfig } from "./mtls";
|
||||
import { buildHostRule } from "./rule";
|
||||
import { sanitize } from "./utils";
|
||||
|
||||
export type BrowserGatewayResourceRow = {
|
||||
@@ -174,7 +175,7 @@ export function buildBrowserGatewayConfig(params: {
|
||||
...additionalMiddlewares
|
||||
];
|
||||
|
||||
const hostRule = `Host(\`${fullDomain}\`)`;
|
||||
const hostRule = buildHostRule(fullDomain);
|
||||
|
||||
// Build TLS config
|
||||
let tls = resolveTls({
|
||||
|
||||
@@ -50,7 +50,12 @@ import {
|
||||
import { applyPathRewriteMiddleware } from "./middleware";
|
||||
import { applyMtlsConfig, getMtlsCaCertificatesByResource } from "./mtls";
|
||||
import { buildRedirectConfig, RedirectRouteRow } from "./redirect";
|
||||
import { appendPathMatch, buildHostRule, computeRoutePriority } from "./rule";
|
||||
import {
|
||||
appendPathMatch,
|
||||
buildHostRule,
|
||||
computeRoutePriority,
|
||||
ruleValue
|
||||
} from "./rule";
|
||||
import { buildSiteResourceAliasCertPlaceholders } from "./siteResourceAlias";
|
||||
import { TargetWithSite } from "./types";
|
||||
import { encodePath, sanitize, validatePathRewriteConfig } from "./utils";
|
||||
@@ -857,7 +862,7 @@ export async function getTraefikConfig(
|
||||
const srKey = `inference-sr${sr.siteResourceId}`;
|
||||
const routerName = `${srKey}-router`;
|
||||
const serviceName = `${srKey}-service`;
|
||||
const rule = `Host(\`${fullDomain}\`) && ClientIP(\`${exitNode.address}\`)`; // restrict to coming from the exit node ip range that the client is connected to
|
||||
const rule = `${buildHostRule(fullDomain)} && ClientIP(${ruleValue(exitNode.address)})`; // restrict to coming from the exit node ip range that the client is connected to
|
||||
|
||||
// siteResource aliases don't have a per-domain cert resolver
|
||||
// stored, so always fall back to the global defaults.
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
import { assertEquals } from "../../../test/assert";
|
||||
import {
|
||||
appendPathMatch,
|
||||
buildHostRule,
|
||||
computeRedirectPriority,
|
||||
computeRoutePriority,
|
||||
matchesPath
|
||||
matchesPath,
|
||||
ruleValue
|
||||
} from "./rule";
|
||||
|
||||
function runTests() {
|
||||
@@ -128,6 +131,58 @@ function runTests() {
|
||||
);
|
||||
|
||||
console.log("All priority tests passed!");
|
||||
|
||||
console.log("Running rule quoting tests...");
|
||||
|
||||
// Ordinary values keep the backtick form
|
||||
assertEquals(ruleValue("/api"), "`/api`", "plain value");
|
||||
assertEquals(
|
||||
buildHostRule("app.example.com"),
|
||||
"Host(`app.example.com`)",
|
||||
"plain host"
|
||||
);
|
||||
assertEquals(
|
||||
buildHostRule("*.example.com", true),
|
||||
"HostRegexp(`^[^.]+\\.example\\.com$`)",
|
||||
"wildcard host"
|
||||
);
|
||||
assertEquals(
|
||||
appendPathMatch("Host(`a.com`)", "/x", "exact"),
|
||||
"Host(`a.com`) && Path(`/x`)",
|
||||
"plain exact path"
|
||||
);
|
||||
assertEquals(
|
||||
appendPathMatch("Host(`a.com`)", "^/v[0-9]+/", "regex"),
|
||||
"Host(`a.com`) && PathRegexp(`^/v[0-9]+/`)",
|
||||
"plain regex path"
|
||||
);
|
||||
|
||||
// A backtick must not be able to close the literal and inject matchers
|
||||
const injectedPath = "/a`) || Host(`victim.example.com`) && PathPrefix(`/";
|
||||
assertEquals(
|
||||
appendPathMatch("Host(`attacker.example.com`)", injectedPath, "exact"),
|
||||
'Host(`attacker.example.com`) && Path("/a`) || Host(`victim.example.com`) && PathPrefix(`/")',
|
||||
"injected exact path stays one literal"
|
||||
);
|
||||
assertEquals(
|
||||
appendPathMatch("Host(`a.com`)", "`) || HostRegexp(`.+", "regex"),
|
||||
'Host(`a.com`) && PathRegexp("`) || HostRegexp(`.+")',
|
||||
"injected regex path stays one literal"
|
||||
);
|
||||
assertEquals(
|
||||
buildHostRule("x`) || Host(`victim.example.com"),
|
||||
'Host("x`) || Host(`victim.example.com")',
|
||||
"injected host stays one literal"
|
||||
);
|
||||
|
||||
// Double-quoted form escapes quotes, backslashes and control characters
|
||||
assertEquals(
|
||||
ruleValue('`"\\\n'),
|
||||
'"`\\"\\\\\\n"',
|
||||
"escapes in quoted form"
|
||||
);
|
||||
|
||||
console.log("All rule quoting tests passed!");
|
||||
}
|
||||
|
||||
try {
|
||||
|
||||
@@ -1,3 +1,27 @@
|
||||
// Characters that cannot appear inside a Go raw (backtick) string literal:
|
||||
// the backtick itself would terminate it, and control characters are
|
||||
// rejected by the scanner Traefik uses to parse rules.
|
||||
const UNSAFE_RAW_RULE_VALUE = /[`\u0000-\u001f\u007f]/;
|
||||
|
||||
/**
|
||||
* Quote a value for use as a matcher argument in a Traefik rule.
|
||||
*
|
||||
* Every value interpolated into a rule must go through this. Rules are
|
||||
* parsed as Go expressions, so a value containing a backtick could otherwise
|
||||
* close its literal and inject arbitrary matchers (e.g. a Host() clause for
|
||||
* another org's domain). Ordinary values keep the conventional backtick form;
|
||||
* anything else is emitted as a double-quoted Go string literal, which
|
||||
* Traefik unquotes back to exactly the original value.
|
||||
*/
|
||||
export function ruleValue(value: string): string {
|
||||
if (!UNSAFE_RAW_RULE_VALUE.test(value)) {
|
||||
return `\`${value}\``;
|
||||
}
|
||||
// JSON string escapes are a subset of Go's interpreted string escapes.
|
||||
// Lone surrogates are replaced since Go rejects them in \u escapes.
|
||||
return JSON.stringify(value.toWellFormed());
|
||||
}
|
||||
|
||||
// Build the Host()/HostRegexp() Traefik rule for a resource's domain.
|
||||
// Wildcard resources match any single subdomain via HostRegexp.
|
||||
export function buildHostRule(
|
||||
@@ -7,9 +31,9 @@ export function buildHostRule(
|
||||
if (wildcard && fullDomain.startsWith("*.")) {
|
||||
// Convert *.foo.bar.com -> HostRegexp(`^[^.]+\.foo\.bar\.com$`)
|
||||
const escaped = fullDomain.slice(2).replace(/\./g, "\\.");
|
||||
return `HostRegexp(\`^[^.]+\\.${escaped}$\`)`;
|
||||
return `HostRegexp(${ruleValue(`^[^.]+\\.${escaped}$`)})`;
|
||||
}
|
||||
return `Host(\`${fullDomain}\`)`;
|
||||
return `Host(${ruleValue(fullDomain)})`;
|
||||
}
|
||||
|
||||
// Append a path-matching clause to a Traefik rule based on the resource's
|
||||
@@ -27,11 +51,11 @@ export function appendPathMatch(
|
||||
}
|
||||
|
||||
if (pathMatchType === "exact") {
|
||||
return `${rule} && Path(\`${p}\`)`;
|
||||
return `${rule} && Path(${ruleValue(p)})`;
|
||||
} else if (pathMatchType === "prefix") {
|
||||
return `${rule} && PathPrefix(\`${p}\`)`;
|
||||
return `${rule} && PathPrefix(${ruleValue(p)})`;
|
||||
} else if (pathMatchType === "regex") {
|
||||
return `${rule} && PathRegexp(\`${path}\`)`; // this is the raw path because it's a regex
|
||||
return `${rule} && PathRegexp(${ruleValue(path)})`; // this is the raw path because it's a regex
|
||||
}
|
||||
return rule;
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import config from "@server/lib/config";
|
||||
import { buildHostRule } from "./rule";
|
||||
|
||||
export type SiteResourceAliasRow = {
|
||||
siteResourceId: number;
|
||||
@@ -82,7 +83,7 @@ export function buildSiteResourceAliasCertPlaceholders(params: {
|
||||
entryPoints: [config.getRawConfig().traefik.http_entrypoint],
|
||||
middlewares: [redirectHttpsMiddlewareName],
|
||||
service: siteResourceServiceName,
|
||||
rule: `Host(\`${fullDomain}\`)`,
|
||||
rule: buildHostRule(fullDomain),
|
||||
priority: 100
|
||||
};
|
||||
|
||||
@@ -97,7 +98,7 @@ export function buildSiteResourceAliasCertPlaceholders(params: {
|
||||
entryPoints: [config.getRawConfig().traefik.https_entrypoint],
|
||||
service: siteResourceServiceName,
|
||||
middlewares: [siteResourceRewriteMiddlewareName],
|
||||
rule: `Host(\`${fullDomain}\`)`,
|
||||
rule: buildHostRule(fullDomain),
|
||||
priority: 100,
|
||||
tls
|
||||
};
|
||||
@@ -106,7 +107,7 @@ export function buildSiteResourceAliasCertPlaceholders(params: {
|
||||
config_output.http.routers[`${siteResourceRouterName}-assets`] = {
|
||||
entryPoints: [config.getRawConfig().traefik.https_entrypoint],
|
||||
service: siteResourceServiceName,
|
||||
rule: `Host(\`${fullDomain}\`) && (PathPrefix(\`/_next\`) || PathRegexp(\`^/__nextjs*\`) || Path(\`/favicon.ico\`))`,
|
||||
rule: `${buildHostRule(fullDomain)} && (PathPrefix(\`/_next\`) || PathRegexp(\`^/__nextjs*\`) || Path(\`/favicon.ico\`))`,
|
||||
priority: 101,
|
||||
tls
|
||||
};
|
||||
|
||||
@@ -2,7 +2,6 @@ import next from "next";
|
||||
import express from "express";
|
||||
import fs from "fs";
|
||||
import path from "path";
|
||||
import { parse } from "url";
|
||||
import logger from "@server/logger";
|
||||
import config from "@server/lib/config";
|
||||
import { stripDuplicateSesions } from "./middlewares/stripDuplicateSessions";
|
||||
@@ -43,8 +42,7 @@ export async function createNextServer() {
|
||||
nextServer.use(stripDuplicateSesions);
|
||||
|
||||
nextServer.all("/{*splat}", (req, res) => {
|
||||
const parsedUrl = parse(req.url!, true);
|
||||
return handle(req, res, parsedUrl);
|
||||
return handle(req, res);
|
||||
});
|
||||
|
||||
nextServer.listen(nextPort, (err?: any) => {
|
||||
|
||||
@@ -30,12 +30,14 @@ import {
|
||||
sites,
|
||||
targets,
|
||||
dnsChallenge,
|
||||
domains
|
||||
domains,
|
||||
customDnsRecords
|
||||
} from "@server/db";
|
||||
import { privateConfig as config } from "#private/lib/config";
|
||||
import * as dnsResolver from "dns";
|
||||
import logger from "@server/logger";
|
||||
import { listExitNodes } from "../exitNodes";
|
||||
import { parseCustomDnsRecordValues } from "@server/routers/customDnsRecord/types";
|
||||
import { rateLimitService } from "../rateLimit";
|
||||
import license from "#private/license/license";
|
||||
|
||||
@@ -50,7 +52,18 @@ type DNSRecord = {
|
||||
enabled: boolean;
|
||||
};
|
||||
|
||||
type CustomRecord = {
|
||||
type: string;
|
||||
value: string;
|
||||
ttl: number;
|
||||
priority: number | null;
|
||||
weight: number | null;
|
||||
port: number | null;
|
||||
};
|
||||
|
||||
const DNS_CACHE_TTL_SECONDS = 300;
|
||||
// Kept short so edits to custom records propagate quickly
|
||||
const CUSTOM_RECORD_CACHE_TTL_SECONDS = 60;
|
||||
|
||||
export class AuthoritativeDNSServer {
|
||||
private cache: LocalCache = createLocalCache(10_000, DNS_CACHE_TTL_SECONDS);
|
||||
@@ -162,6 +175,10 @@ export class AuthoritativeDNSServer {
|
||||
const originalQueryName = queryName;
|
||||
const queryType = question.type;
|
||||
let baseDomain: string | null;
|
||||
// True when baseDomain is the customer zone the queried name itself
|
||||
// lives in (not a synthetic cname-extension lookup), i.e. when
|
||||
// user-defined custom records may apply.
|
||||
let isCustomerZone = false;
|
||||
|
||||
logger.info(
|
||||
`DNS Query: ${queryName} ${queryType} from ${rinfo.address}:${rinfo.port}`
|
||||
@@ -284,6 +301,34 @@ export class AuthoritativeDNSServer {
|
||||
this.sendResponse(packet, [], rinfo, false, 3, []); // NXDOMAIN rcode=3 - not authoritative
|
||||
return;
|
||||
}
|
||||
isCustomerZone = true;
|
||||
}
|
||||
|
||||
// A subdomain the user delegated elsewhere with NS records is no
|
||||
// longer ours to answer for - refer the resolver to its nameservers
|
||||
// for the cut and everything beneath it, ahead of any other handling.
|
||||
if (isCustomerZone) {
|
||||
const delegation = await this.findDelegation(queryName, baseDomain);
|
||||
if (delegation) {
|
||||
logger.info(
|
||||
`Referring ${originalQueryName} (${queryType}) to delegated zone ${delegation.cut}`
|
||||
);
|
||||
const [authorities, additionals] = await this.buildReferral(
|
||||
delegation,
|
||||
baseDomain
|
||||
);
|
||||
// Referrals are non-authoritative (AA=0) with no answers
|
||||
this.sendResponse(
|
||||
packet,
|
||||
[],
|
||||
rinfo,
|
||||
false,
|
||||
0,
|
||||
authorities,
|
||||
additionals
|
||||
);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Special handling for _acme-challenge CNAME requests
|
||||
@@ -452,11 +497,42 @@ export class AuthoritativeDNSServer {
|
||||
return;
|
||||
}
|
||||
|
||||
// User-defined records come after everything Pangolin manages itself,
|
||||
// so they can never shadow a resource, login page or redirect.
|
||||
const customRecords = isCustomerZone
|
||||
? await this.getCustomRecords(queryName, baseDomain!)
|
||||
: [];
|
||||
|
||||
if (customRecords.length > 0) {
|
||||
let matchingRecords = customRecords.filter(
|
||||
(record) => record.type === queryType
|
||||
);
|
||||
// A CNAME answers queries for every other type at its name
|
||||
if (matchingRecords.length === 0 && queryType !== "CNAME") {
|
||||
matchingRecords = customRecords.filter(
|
||||
(record) => record.type === "CNAME"
|
||||
);
|
||||
}
|
||||
|
||||
if (matchingRecords.length > 0) {
|
||||
const answers = this.convertCustomRecordsToAnswers(
|
||||
matchingRecords,
|
||||
originalQueryName
|
||||
);
|
||||
logger.info(
|
||||
`Returning ${answers.length} custom answers for ${originalQueryName} (${queryType})`
|
||||
);
|
||||
this.sendResponse(packet, answers, rinfo, true, 0, []);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Check if the domain exists (for NODATA vs NXDOMAIN)
|
||||
const [domainExists, soaRecord] = await Promise.all([
|
||||
const [resourceExists, soaRecord] = await Promise.all([
|
||||
this.domainExists(queryName, baseDomain!),
|
||||
this.getSOARecord(baseDomain!)
|
||||
]);
|
||||
const domainExists = resourceExists || customRecords.length > 0;
|
||||
|
||||
if (!domainExists) {
|
||||
// NXDOMAIN: Name does not exist
|
||||
@@ -1117,9 +1193,9 @@ export class AuthoritativeDNSServer {
|
||||
}
|
||||
|
||||
case "MX":
|
||||
// MX records need priority and exchange
|
||||
// MX records need preference and exchange
|
||||
return {
|
||||
priority: record.priority,
|
||||
preference: record.priority,
|
||||
exchange: record.value
|
||||
};
|
||||
|
||||
@@ -1146,13 +1222,262 @@ export class AuthoritativeDNSServer {
|
||||
}
|
||||
}
|
||||
|
||||
// User-defined records for `name`. Only served when the record's domain
|
||||
// is the zone this query resolved to and is a verified NS delegation, so
|
||||
// records can never surface under another org's zone or a domain whose
|
||||
// ownership hasn't been proven. An exact name match takes precedence over
|
||||
// a wildcard (RFC 4592).
|
||||
private async getCustomRecords(
|
||||
name: string,
|
||||
zone: string
|
||||
): Promise<CustomRecord[]> {
|
||||
const normalized = name.replace(/\.$/, "").toLowerCase();
|
||||
const cacheKey = `customRecords:${zone}:${normalized}`;
|
||||
|
||||
const cached = this.cache.get<CustomRecord[]>(cacheKey);
|
||||
if (cached !== undefined) {
|
||||
return cached;
|
||||
}
|
||||
|
||||
return this.withInFlightDedup<CustomRecord[]>(cacheKey, async () => {
|
||||
try {
|
||||
const wildcardPattern = this.getWildcardPattern(normalized);
|
||||
const names =
|
||||
wildcardPattern && wildcardPattern !== normalized
|
||||
? [normalized, wildcardPattern]
|
||||
: [normalized];
|
||||
|
||||
const rows = await db
|
||||
.select({
|
||||
fullDomain: customDnsRecords.fullDomain,
|
||||
type: customDnsRecords.type,
|
||||
values: customDnsRecords.values,
|
||||
ttl: customDnsRecords.ttl,
|
||||
priority: customDnsRecords.priority,
|
||||
weight: customDnsRecords.weight,
|
||||
port: customDnsRecords.port
|
||||
})
|
||||
.from(customDnsRecords)
|
||||
.innerJoin(
|
||||
domains,
|
||||
eq(domains.domainId, customDnsRecords.domainId)
|
||||
)
|
||||
.where(
|
||||
and(
|
||||
inArray(customDnsRecords.fullDomain, names),
|
||||
eq(customDnsRecords.enabled, true),
|
||||
eq(domains.baseDomain, zone),
|
||||
eq(domains.type, "ns"),
|
||||
eq(domains.verified, true)
|
||||
)
|
||||
);
|
||||
|
||||
const exactRows = rows.filter(
|
||||
(row) => row.fullDomain === normalized
|
||||
);
|
||||
// Each stored value is served as its own resource record
|
||||
const result: CustomRecord[] = (
|
||||
exactRows.length > 0 ? exactRows : rows
|
||||
).flatMap(({ fullDomain, values, ...record }) =>
|
||||
parseCustomDnsRecordValues(values).map((value) => ({
|
||||
...record,
|
||||
value
|
||||
}))
|
||||
);
|
||||
|
||||
this.cache.set(
|
||||
cacheKey,
|
||||
result,
|
||||
CUSTOM_RECORD_CACHE_TTL_SECONDS
|
||||
);
|
||||
return result;
|
||||
} catch (error) {
|
||||
logger.error("Error querying custom DNS records:", error);
|
||||
return [];
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Finds the user-defined delegation (NS records on a subdomain) that
|
||||
// `name` falls at or beneath within `zone`. When cuts are nested the
|
||||
// topmost one wins, since everything below it belongs to the child zone.
|
||||
// Subject to the same verified-NS-zone scoping as getCustomRecords.
|
||||
private async findDelegation(
|
||||
name: string,
|
||||
zone: string
|
||||
): Promise<{ cut: string; nameservers: CustomRecord[] } | null> {
|
||||
const normalized = name.replace(/\.$/, "").toLowerCase();
|
||||
if (!normalized.endsWith(`.${zone}`)) {
|
||||
return null; // the apex itself is never delegated
|
||||
}
|
||||
|
||||
const cacheKey = `delegation:${zone}:${normalized}`;
|
||||
const cached = this.cache.get<{
|
||||
cut: string;
|
||||
nameservers: CustomRecord[];
|
||||
} | null>(cacheKey);
|
||||
if (cached !== undefined) {
|
||||
return cached;
|
||||
}
|
||||
|
||||
return this.withInFlightDedup(cacheKey, async () => {
|
||||
try {
|
||||
// Every ancestor of the name below the zone apex, plus the
|
||||
// name itself
|
||||
const labels = normalized.split(".");
|
||||
const zoneLabelCount = zone.split(".").length;
|
||||
const candidates: string[] = [];
|
||||
for (let i = 0; i < labels.length - zoneLabelCount; i++) {
|
||||
candidates.push(labels.slice(i).join("."));
|
||||
}
|
||||
|
||||
const rows = await db
|
||||
.select({
|
||||
fullDomain: customDnsRecords.fullDomain,
|
||||
type: customDnsRecords.type,
|
||||
values: customDnsRecords.values,
|
||||
ttl: customDnsRecords.ttl,
|
||||
priority: customDnsRecords.priority,
|
||||
weight: customDnsRecords.weight,
|
||||
port: customDnsRecords.port
|
||||
})
|
||||
.from(customDnsRecords)
|
||||
.innerJoin(
|
||||
domains,
|
||||
eq(domains.domainId, customDnsRecords.domainId)
|
||||
)
|
||||
.where(
|
||||
and(
|
||||
inArray(customDnsRecords.fullDomain, candidates),
|
||||
eq(customDnsRecords.type, "NS"),
|
||||
eq(customDnsRecords.enabled, true),
|
||||
eq(domains.baseDomain, zone),
|
||||
eq(domains.type, "ns"),
|
||||
eq(domains.verified, true)
|
||||
)
|
||||
);
|
||||
|
||||
let result: {
|
||||
cut: string;
|
||||
nameservers: CustomRecord[];
|
||||
} | null = null;
|
||||
|
||||
if (rows.length > 0) {
|
||||
const cut = rows.reduce((a, b) =>
|
||||
a.fullDomain.length <= b.fullDomain.length ? a : b
|
||||
).fullDomain;
|
||||
const nameservers = rows
|
||||
.filter((row) => row.fullDomain === cut)
|
||||
.flatMap(({ fullDomain, values, ...record }) =>
|
||||
parseCustomDnsRecordValues(values).map((value) => ({
|
||||
...record,
|
||||
value
|
||||
}))
|
||||
);
|
||||
if (nameservers.length > 0) {
|
||||
result = { cut, nameservers };
|
||||
}
|
||||
}
|
||||
|
||||
this.cache.set(
|
||||
cacheKey,
|
||||
result,
|
||||
CUSTOM_RECORD_CACHE_TTL_SECONDS
|
||||
);
|
||||
return result;
|
||||
} catch (error) {
|
||||
logger.error("Error looking up DNS delegation:", error);
|
||||
return null;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Builds the authority (NS) and additional (glue) sections of a referral
|
||||
// to a delegated subdomain. Glue is only needed - and only included -
|
||||
// for nameservers named inside the delegated subdomain itself, which a
|
||||
// resolver could not otherwise find the address of.
|
||||
private async buildReferral(
|
||||
delegation: { cut: string; nameservers: CustomRecord[] },
|
||||
zone: string
|
||||
): Promise<[dns.Answer[], dns.Answer[]]> {
|
||||
const authorities = this.convertCustomRecordsToAnswers(
|
||||
delegation.nameservers,
|
||||
delegation.cut
|
||||
);
|
||||
|
||||
const additionals: dns.Answer[] = [];
|
||||
for (const nameserver of delegation.nameservers) {
|
||||
const target = nameserver.value;
|
||||
if (
|
||||
target !== delegation.cut &&
|
||||
!target.endsWith(`.${delegation.cut}`)
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
const glue = (await this.getCustomRecords(target, zone)).filter(
|
||||
(record) => record.type === "A" || record.type === "AAAA"
|
||||
);
|
||||
additionals.push(
|
||||
...this.convertCustomRecordsToAnswers(glue, target)
|
||||
);
|
||||
}
|
||||
|
||||
return [authorities, additionals];
|
||||
}
|
||||
|
||||
private convertCustomRecordsToAnswers(
|
||||
records: CustomRecord[],
|
||||
queryName: string
|
||||
): dns.Answer[] {
|
||||
return records.map((record) => {
|
||||
let data: any;
|
||||
switch (record.type) {
|
||||
case "MX":
|
||||
data = {
|
||||
preference: record.priority ?? 0,
|
||||
exchange: record.value
|
||||
};
|
||||
break;
|
||||
case "SRV":
|
||||
data = {
|
||||
priority: record.priority ?? 0,
|
||||
weight: record.weight ?? 0,
|
||||
port: record.port ?? 0,
|
||||
target: record.value
|
||||
};
|
||||
break;
|
||||
case "TXT": {
|
||||
// A single TXT character-string is at most 255 bytes;
|
||||
// longer values are split across several strings, which
|
||||
// clients concatenate. Values are printable ASCII only.
|
||||
const chunks: string[] = [];
|
||||
for (let i = 0; i < record.value.length; i += 255) {
|
||||
chunks.push(record.value.slice(i, i + 255));
|
||||
}
|
||||
data = chunks;
|
||||
break;
|
||||
}
|
||||
default:
|
||||
data = record.value;
|
||||
}
|
||||
|
||||
return {
|
||||
name: queryName,
|
||||
type: record.type,
|
||||
ttl: record.ttl,
|
||||
data
|
||||
} as dns.Answer;
|
||||
});
|
||||
}
|
||||
|
||||
private sendResponse(
|
||||
originalPacket: dns.Packet,
|
||||
answers: dns.Answer[],
|
||||
rinfo: dgram.RemoteInfo,
|
||||
authoritative: boolean = true,
|
||||
rcode: number = 0,
|
||||
authorities: dns.Answer[] = []
|
||||
authorities: dns.Answer[] = [],
|
||||
additionals: dns.Answer[] = []
|
||||
): void {
|
||||
const response: dns.Packet = {
|
||||
id: originalPacket.id,
|
||||
@@ -1161,7 +1486,7 @@ export class AuthoritativeDNSServer {
|
||||
questions: originalPacket.questions,
|
||||
answers: answers,
|
||||
authorities: authorities,
|
||||
additionals: []
|
||||
additionals: additionals
|
||||
};
|
||||
|
||||
// Set authoritative flag if this is an authoritative response
|
||||
|
||||
@@ -71,7 +71,8 @@ import {
|
||||
import {
|
||||
appendPathMatch,
|
||||
buildHostRule,
|
||||
computeRoutePriority
|
||||
computeRoutePriority,
|
||||
ruleValue
|
||||
} from "@server/lib/traefik/rule";
|
||||
import { buildSiteResourceAliasCertPlaceholders } from "@server/lib/traefik/siteResourceAlias";
|
||||
import { ResourceWithTargets } from "@server/lib/traefik/types";
|
||||
@@ -1189,7 +1190,7 @@ export async function getTraefikConfig(
|
||||
const srKey = `inference-sr${sr.siteResourceId}`;
|
||||
const routerName = `${srKey}-router`;
|
||||
const serviceName = `${srKey}-service`;
|
||||
const rule = `Host(\`${fullDomain}\`) && ClientIP(\`${exitNode.address}\`)`; // restrict to coming from the exit node ip range that the client is connected to
|
||||
const rule = `${buildHostRule(fullDomain)} && ClientIP(${ruleValue(exitNode.address)})`; // restrict to coming from the exit node ip range that the client is connected to
|
||||
|
||||
let tls: any = {};
|
||||
if (!pangolinCertModeEnabled) {
|
||||
@@ -1345,7 +1346,7 @@ export async function getTraefikConfig(
|
||||
],
|
||||
middlewares: [landingRateLimitMiddlewareName],
|
||||
service: "landing-service",
|
||||
rule: `Host(\`${fullDomain}\`) && (PathRegexp(\`^/auth/resource/[^/]+$\`) || PathRegexp(\`^/auth/idp/[0-9]+/oidc/callback\`) || PathPrefix(\`/_next\`) || Path(\`/auth/org\`) || PathRegexp(\`^/__nextjs*\`) || Path(\`/favicon.ico\`))`,
|
||||
rule: `${buildHostRule(fullDomain)} && (PathRegexp(\`^/auth/resource/[^/]+$\`) || PathRegexp(\`^/auth/idp/[0-9]+/oidc/callback\`) || PathPrefix(\`/_next\`) || Path(\`/auth/org\`) || PathRegexp(\`^/__nextjs*\`) || Path(\`/favicon.ico\`))`,
|
||||
priority: 203,
|
||||
tls: tls
|
||||
};
|
||||
@@ -1367,7 +1368,7 @@ export async function getTraefikConfig(
|
||||
redirectToRootMiddlewareName
|
||||
],
|
||||
service: "landing-service",
|
||||
rule: `Host(\`${fullDomain}\`)`,
|
||||
rule: buildHostRule(fullDomain),
|
||||
priority: 202,
|
||||
tls: tls
|
||||
};
|
||||
@@ -1379,7 +1380,7 @@ export async function getTraefikConfig(
|
||||
],
|
||||
middlewares: [redirectHttpsMiddlewareName],
|
||||
service: "landing-service",
|
||||
rule: `Host(\`${fullDomain}\`)`,
|
||||
rule: buildHostRule(fullDomain),
|
||||
priority: 201
|
||||
};
|
||||
}
|
||||
|
||||
@@ -32,9 +32,11 @@ import {
|
||||
siteProvisioningKeyOrg,
|
||||
siteProvisioningKeys,
|
||||
alertRules,
|
||||
targetHealthCheck
|
||||
targetHealthCheck,
|
||||
customDnsRecords,
|
||||
orgDomains
|
||||
} from "@server/db";
|
||||
import { and, eq, isNull } from "drizzle-orm";
|
||||
import { and, eq, inArray, isNull } from "drizzle-orm";
|
||||
|
||||
/**
|
||||
* Get the maximum allowed retention days for a given tier
|
||||
@@ -336,6 +338,10 @@ async function disableFeature(
|
||||
await disableStandaloneHealthChecks(orgId);
|
||||
break;
|
||||
|
||||
case TierFeature.CustomDnsRecords:
|
||||
await disableCustomDnsRecords(orgId);
|
||||
break;
|
||||
|
||||
default:
|
||||
logger.warn(
|
||||
`Unknown feature ${feature} for org ${orgId}, skipping`
|
||||
@@ -575,6 +581,25 @@ async function disableAlertingRules(orgId: string): Promise<void> {
|
||||
logger.info(`Disabled all alert rules for org ${orgId}`);
|
||||
}
|
||||
|
||||
async function disableCustomDnsRecords(orgId: string): Promise<void> {
|
||||
// Records are kept (just stop being served) so re-subscribing restores
|
||||
// them by re-enabling
|
||||
await db
|
||||
.update(customDnsRecords)
|
||||
.set({ enabled: false, updatedAt: Date.now() })
|
||||
.where(
|
||||
inArray(
|
||||
customDnsRecords.domainId,
|
||||
db
|
||||
.select({ domainId: orgDomains.domainId })
|
||||
.from(orgDomains)
|
||||
.where(eq(orgDomains.orgId, orgId))
|
||||
)
|
||||
);
|
||||
|
||||
logger.info(`Disabled all custom DNS records for org ${orgId}`);
|
||||
}
|
||||
|
||||
async function disableStandaloneHealthChecks(orgId: string): Promise<void> {
|
||||
await db
|
||||
.update(targetHealthCheck)
|
||||
|
||||
@@ -0,0 +1,155 @@
|
||||
/*
|
||||
* This file is part of a proprietary work.
|
||||
*
|
||||
* Copyright (c) 2025-2026 Fossorial, Inc.
|
||||
* All rights reserved.
|
||||
*
|
||||
* This file is licensed under the Fossorial Commercial License.
|
||||
* You may not use this file except in compliance with the License.
|
||||
* Unauthorized use, copying, modification, or distribution is strictly prohibited.
|
||||
*
|
||||
* This file is not licensed under the AGPLv3.
|
||||
*/
|
||||
|
||||
import { Request, Response, NextFunction } from "express";
|
||||
import { z } from "zod";
|
||||
import { db, customDnsRecords } from "@server/db";
|
||||
import response from "@server/lib/response";
|
||||
import HttpCode from "@server/types/HttpCode";
|
||||
import createHttpError from "http-errors";
|
||||
import logger from "@server/logger";
|
||||
import { fromError } from "zod-validation-error";
|
||||
import { OpenAPITags, registry } from "@server/openApi";
|
||||
import {
|
||||
CreateOrUpdateCustomDnsRecordResponse,
|
||||
toCustomDnsRecordRow
|
||||
} from "@server/routers/customDnsRecord/types";
|
||||
import {
|
||||
checkCustomDnsRecordConflicts,
|
||||
customDnsRecordFieldsSchema,
|
||||
getManageableDomain,
|
||||
normalizeCustomDnsRecord
|
||||
} from "./validation";
|
||||
|
||||
const paramsSchema = z.strictObject({
|
||||
orgId: z.string().nonempty(),
|
||||
domainId: z.string().nonempty()
|
||||
});
|
||||
|
||||
const bodySchema = customDnsRecordFieldsSchema.extend({
|
||||
ttl: customDnsRecordFieldsSchema.shape.ttl.optional().default(300),
|
||||
enabled: z.boolean().optional().default(true)
|
||||
});
|
||||
|
||||
registry.registerPath({
|
||||
method: "put",
|
||||
path: "/org/{orgId}/domain/{domainId}/custom-dns-record",
|
||||
description:
|
||||
"Create a custom DNS record served by Pangolin's nameservers for a domain. Each entry in `values` is served as a separate resource record (e.g. both nameservers of an NS record).",
|
||||
tags: [OpenAPITags.Domain],
|
||||
request: {
|
||||
params: paramsSchema,
|
||||
body: {
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: bodySchema
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
responses: {
|
||||
200: {
|
||||
description: "Successful response",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: z.object({
|
||||
data: z.record(z.string(), z.any()).nullable(),
|
||||
success: z.boolean(),
|
||||
error: z.boolean(),
|
||||
message: z.string(),
|
||||
status: z.number()
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
export async function createCustomDnsRecord(
|
||||
req: Request,
|
||||
res: Response,
|
||||
next: NextFunction
|
||||
): Promise<any> {
|
||||
try {
|
||||
const parsedParams = paramsSchema.safeParse(req.params);
|
||||
if (!parsedParams.success) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.BAD_REQUEST,
|
||||
fromError(parsedParams.error).toString()
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
const { orgId, domainId } = parsedParams.data;
|
||||
|
||||
const parsedBody = bodySchema.safeParse(req.body);
|
||||
if (!parsedBody.success) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.BAD_REQUEST,
|
||||
fromError(parsedBody.error).toString()
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
const domain = await getManageableDomain(orgId, domainId);
|
||||
if (!domain.ok) {
|
||||
return next(createHttpError(domain.status, domain.error));
|
||||
}
|
||||
|
||||
const normalized = normalizeCustomDnsRecord(
|
||||
parsedBody.data,
|
||||
domain.baseDomain
|
||||
);
|
||||
if (!normalized.ok) {
|
||||
return next(
|
||||
createHttpError(HttpCode.BAD_REQUEST, normalized.error)
|
||||
);
|
||||
}
|
||||
|
||||
const conflict = await checkCustomDnsRecordConflicts(
|
||||
domainId,
|
||||
domain.baseDomain,
|
||||
normalized.value
|
||||
);
|
||||
if (conflict) {
|
||||
return next(createHttpError(HttpCode.CONFLICT, conflict));
|
||||
}
|
||||
|
||||
const now = Date.now();
|
||||
const [record] = await db
|
||||
.insert(customDnsRecords)
|
||||
.values({
|
||||
domainId,
|
||||
...normalized.value,
|
||||
values: JSON.stringify(normalized.value.values),
|
||||
createdAt: now,
|
||||
updatedAt: now
|
||||
})
|
||||
.returning();
|
||||
|
||||
return response<CreateOrUpdateCustomDnsRecordResponse>(res, {
|
||||
data: toCustomDnsRecordRow(record),
|
||||
success: true,
|
||||
error: false,
|
||||
message: "Custom DNS record created successfully",
|
||||
status: HttpCode.CREATED
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error(error);
|
||||
return next(
|
||||
createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred")
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
/*
|
||||
* This file is part of a proprietary work.
|
||||
*
|
||||
* Copyright (c) 2025-2026 Fossorial, Inc.
|
||||
* All rights reserved.
|
||||
*
|
||||
* This file is licensed under the Fossorial Commercial License.
|
||||
* You may not use this file except in compliance with the License.
|
||||
* Unauthorized use, copying, modification, or distribution is strictly prohibited.
|
||||
*
|
||||
* This file is not licensed under the AGPLv3.
|
||||
*/
|
||||
|
||||
import { Request, Response, NextFunction } from "express";
|
||||
import { z } from "zod";
|
||||
import { db, customDnsRecords, orgDomains } from "@server/db";
|
||||
import response from "@server/lib/response";
|
||||
import HttpCode from "@server/types/HttpCode";
|
||||
import createHttpError from "http-errors";
|
||||
import logger from "@server/logger";
|
||||
import { fromError } from "zod-validation-error";
|
||||
import { OpenAPITags, registry } from "@server/openApi";
|
||||
import { and, eq } from "drizzle-orm";
|
||||
|
||||
const paramsSchema = z.strictObject({
|
||||
orgId: z.string().nonempty(),
|
||||
domainId: z.string().nonempty(),
|
||||
customDnsRecordId: z.coerce.number<string>().int().positive()
|
||||
});
|
||||
|
||||
registry.registerPath({
|
||||
method: "delete",
|
||||
path: "/org/{orgId}/domain/{domainId}/custom-dns-record/{customDnsRecordId}",
|
||||
description: "Delete a custom DNS record from a domain.",
|
||||
tags: [OpenAPITags.Domain],
|
||||
request: {
|
||||
params: paramsSchema
|
||||
},
|
||||
responses: {
|
||||
200: {
|
||||
description: "Successful response",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: z.object({
|
||||
data: z.record(z.string(), z.any()).nullable(),
|
||||
success: z.boolean(),
|
||||
error: z.boolean(),
|
||||
message: z.string(),
|
||||
status: z.number()
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
export async function deleteCustomDnsRecord(
|
||||
req: Request,
|
||||
res: Response,
|
||||
next: NextFunction
|
||||
): Promise<any> {
|
||||
try {
|
||||
const parsedParams = paramsSchema.safeParse(req.params);
|
||||
if (!parsedParams.success) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.BAD_REQUEST,
|
||||
fromError(parsedParams.error).toString()
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
const { orgId, domainId, customDnsRecordId } = parsedParams.data;
|
||||
|
||||
// Scope the record to a domain this org owns - the record id alone
|
||||
// must never be enough to reach another org's records.
|
||||
const [existing] = await db
|
||||
.select({ customDnsRecordId: customDnsRecords.customDnsRecordId })
|
||||
.from(customDnsRecords)
|
||||
.innerJoin(
|
||||
orgDomains,
|
||||
eq(orgDomains.domainId, customDnsRecords.domainId)
|
||||
)
|
||||
.where(
|
||||
and(
|
||||
eq(customDnsRecords.customDnsRecordId, customDnsRecordId),
|
||||
eq(customDnsRecords.domainId, domainId),
|
||||
eq(orgDomains.orgId, orgId)
|
||||
)
|
||||
)
|
||||
.limit(1);
|
||||
|
||||
if (!existing) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.NOT_FOUND,
|
||||
`Custom DNS record with ID ${customDnsRecordId} not found`
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
await db
|
||||
.delete(customDnsRecords)
|
||||
.where(
|
||||
and(
|
||||
eq(customDnsRecords.customDnsRecordId, customDnsRecordId),
|
||||
eq(customDnsRecords.domainId, domainId)
|
||||
)
|
||||
);
|
||||
|
||||
return response<null>(res, {
|
||||
data: null,
|
||||
success: true,
|
||||
error: false,
|
||||
message: "Custom DNS record deleted successfully",
|
||||
status: HttpCode.OK
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error(error);
|
||||
return next(
|
||||
createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred")
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
/*
|
||||
* This file is part of a proprietary work.
|
||||
*
|
||||
* Copyright (c) 2025-2026 Fossorial, Inc.
|
||||
* All rights reserved.
|
||||
*
|
||||
* This file is licensed under the Fossorial Commercial License.
|
||||
* You may not use this file except in compliance with the License.
|
||||
* Unauthorized use, copying, modification, or distribution is strictly prohibited.
|
||||
*
|
||||
* This file is not licensed under the AGPLv3.
|
||||
*/
|
||||
|
||||
export * from "./createCustomDnsRecord";
|
||||
export * from "./updateCustomDnsRecord";
|
||||
export * from "./deleteCustomDnsRecord";
|
||||
export * from "./listCustomDnsRecords";
|
||||
@@ -0,0 +1,115 @@
|
||||
/*
|
||||
* This file is part of a proprietary work.
|
||||
*
|
||||
* Copyright (c) 2025-2026 Fossorial, Inc.
|
||||
* All rights reserved.
|
||||
*
|
||||
* This file is licensed under the Fossorial Commercial License.
|
||||
* You may not use this file except in compliance with the License.
|
||||
* Unauthorized use, copying, modification, or distribution is strictly prohibited.
|
||||
*
|
||||
* This file is not licensed under the AGPLv3.
|
||||
*/
|
||||
|
||||
import { Request, Response, NextFunction } from "express";
|
||||
import { z } from "zod";
|
||||
import { db, customDnsRecords, orgDomains } from "@server/db";
|
||||
import response from "@server/lib/response";
|
||||
import HttpCode from "@server/types/HttpCode";
|
||||
import createHttpError from "http-errors";
|
||||
import logger from "@server/logger";
|
||||
import { fromError } from "zod-validation-error";
|
||||
import { OpenAPITags, registry } from "@server/openApi";
|
||||
import { and, asc, eq } from "drizzle-orm";
|
||||
import {
|
||||
ListCustomDnsRecordsResponse,
|
||||
toCustomDnsRecordRow
|
||||
} from "@server/routers/customDnsRecord/types";
|
||||
|
||||
const paramsSchema = z.strictObject({
|
||||
orgId: z.string().nonempty(),
|
||||
domainId: z.string().nonempty()
|
||||
});
|
||||
|
||||
registry.registerPath({
|
||||
method: "get",
|
||||
path: "/org/{orgId}/domain/{domainId}/custom-dns-records",
|
||||
description:
|
||||
"List the custom DNS records served by Pangolin's nameservers for a domain.",
|
||||
tags: [OpenAPITags.Domain],
|
||||
request: {
|
||||
params: paramsSchema
|
||||
},
|
||||
responses: {
|
||||
200: {
|
||||
description: "Successful response",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: z.object({
|
||||
data: z.record(z.string(), z.any()).nullable(),
|
||||
success: z.boolean(),
|
||||
error: z.boolean(),
|
||||
message: z.string(),
|
||||
status: z.number()
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
export async function listCustomDnsRecords(
|
||||
req: Request,
|
||||
res: Response,
|
||||
next: NextFunction
|
||||
): Promise<any> {
|
||||
try {
|
||||
const parsedParams = paramsSchema.safeParse(req.params);
|
||||
if (!parsedParams.success) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.BAD_REQUEST,
|
||||
fromError(parsedParams.error).toString()
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
const { orgId, domainId } = parsedParams.data;
|
||||
|
||||
// Records per domain are capped (MAX_CUSTOM_DNS_RECORDS_PER_DOMAIN),
|
||||
// so no pagination is needed.
|
||||
const rows = await db
|
||||
.select({ record: customDnsRecords })
|
||||
.from(customDnsRecords)
|
||||
.innerJoin(
|
||||
orgDomains,
|
||||
eq(orgDomains.domainId, customDnsRecords.domainId)
|
||||
)
|
||||
.where(
|
||||
and(
|
||||
eq(customDnsRecords.domainId, domainId),
|
||||
eq(orgDomains.orgId, orgId)
|
||||
)
|
||||
)
|
||||
.orderBy(
|
||||
asc(customDnsRecords.fullDomain),
|
||||
asc(customDnsRecords.type),
|
||||
asc(customDnsRecords.customDnsRecordId)
|
||||
);
|
||||
|
||||
return response<ListCustomDnsRecordsResponse>(res, {
|
||||
data: {
|
||||
records: rows.map((r) => toCustomDnsRecordRow(r.record))
|
||||
},
|
||||
success: true,
|
||||
error: false,
|
||||
message: "Custom DNS records retrieved successfully",
|
||||
status: HttpCode.OK
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error(error);
|
||||
return next(
|
||||
createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred")
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,190 @@
|
||||
/*
|
||||
* This file is part of a proprietary work.
|
||||
*
|
||||
* Copyright (c) 2025-2026 Fossorial, Inc.
|
||||
* All rights reserved.
|
||||
*
|
||||
* This file is licensed under the Fossorial Commercial License.
|
||||
* You may not use this file except in compliance with the License.
|
||||
* Unauthorized use, copying, modification, or distribution is strictly prohibited.
|
||||
*
|
||||
* This file is not licensed under the AGPLv3.
|
||||
*/
|
||||
|
||||
import { Request, Response, NextFunction } from "express";
|
||||
import { z } from "zod";
|
||||
import { db, customDnsRecords } from "@server/db";
|
||||
import response from "@server/lib/response";
|
||||
import HttpCode from "@server/types/HttpCode";
|
||||
import createHttpError from "http-errors";
|
||||
import logger from "@server/logger";
|
||||
import { fromError } from "zod-validation-error";
|
||||
import { OpenAPITags, registry } from "@server/openApi";
|
||||
import { and, eq } from "drizzle-orm";
|
||||
import {
|
||||
CreateOrUpdateCustomDnsRecordResponse,
|
||||
parseCustomDnsRecordValues,
|
||||
toCustomDnsRecordRow
|
||||
} from "@server/routers/customDnsRecord/types";
|
||||
import {
|
||||
checkCustomDnsRecordConflicts,
|
||||
customDnsRecordFieldsSchema,
|
||||
getManageableDomain,
|
||||
normalizeCustomDnsRecord
|
||||
} from "./validation";
|
||||
|
||||
const paramsSchema = z.strictObject({
|
||||
orgId: z.string().nonempty(),
|
||||
domainId: z.string().nonempty(),
|
||||
customDnsRecordId: z.coerce.number<string>().int().positive()
|
||||
});
|
||||
|
||||
const bodySchema = customDnsRecordFieldsSchema.partial();
|
||||
|
||||
registry.registerPath({
|
||||
method: "post",
|
||||
path: "/org/{orgId}/domain/{domainId}/custom-dns-record/{customDnsRecordId}",
|
||||
description: "Update a custom DNS record for a domain.",
|
||||
tags: [OpenAPITags.Domain],
|
||||
request: {
|
||||
params: paramsSchema,
|
||||
body: {
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: bodySchema
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
responses: {
|
||||
200: {
|
||||
description: "Successful response",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: z.object({
|
||||
data: z.record(z.string(), z.any()).nullable(),
|
||||
success: z.boolean(),
|
||||
error: z.boolean(),
|
||||
message: z.string(),
|
||||
status: z.number()
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
export async function updateCustomDnsRecord(
|
||||
req: Request,
|
||||
res: Response,
|
||||
next: NextFunction
|
||||
): Promise<any> {
|
||||
try {
|
||||
const parsedParams = paramsSchema.safeParse(req.params);
|
||||
if (!parsedParams.success) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.BAD_REQUEST,
|
||||
fromError(parsedParams.error).toString()
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
const { orgId, domainId, customDnsRecordId } = parsedParams.data;
|
||||
|
||||
const parsedBody = bodySchema.safeParse(req.body);
|
||||
if (!parsedBody.success) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.BAD_REQUEST,
|
||||
fromError(parsedBody.error).toString()
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
const domain = await getManageableDomain(orgId, domainId);
|
||||
if (!domain.ok) {
|
||||
return next(createHttpError(domain.status, domain.error));
|
||||
}
|
||||
|
||||
const [existing] = await db
|
||||
.select()
|
||||
.from(customDnsRecords)
|
||||
.where(
|
||||
and(
|
||||
eq(customDnsRecords.customDnsRecordId, customDnsRecordId),
|
||||
eq(customDnsRecords.domainId, domainId)
|
||||
)
|
||||
)
|
||||
.limit(1);
|
||||
|
||||
if (!existing) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.NOT_FOUND,
|
||||
`Custom DNS record with ID ${customDnsRecordId} not found`
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
// Validate the record as a whole, not just the changed fields, so a
|
||||
// partial update can't leave it in an invalid state.
|
||||
const normalized = normalizeCustomDnsRecord(
|
||||
{
|
||||
name: existing.name,
|
||||
type: existing.type,
|
||||
values: parseCustomDnsRecordValues(existing.values),
|
||||
ttl: existing.ttl,
|
||||
priority: existing.priority,
|
||||
weight: existing.weight,
|
||||
port: existing.port,
|
||||
enabled: existing.enabled,
|
||||
...parsedBody.data
|
||||
},
|
||||
domain.baseDomain
|
||||
);
|
||||
if (!normalized.ok) {
|
||||
return next(
|
||||
createHttpError(HttpCode.BAD_REQUEST, normalized.error)
|
||||
);
|
||||
}
|
||||
|
||||
const conflict = await checkCustomDnsRecordConflicts(
|
||||
domainId,
|
||||
domain.baseDomain,
|
||||
normalized.value,
|
||||
customDnsRecordId
|
||||
);
|
||||
if (conflict) {
|
||||
return next(createHttpError(HttpCode.CONFLICT, conflict));
|
||||
}
|
||||
|
||||
const [record] = await db
|
||||
.update(customDnsRecords)
|
||||
.set({
|
||||
...normalized.value,
|
||||
values: JSON.stringify(normalized.value.values),
|
||||
updatedAt: Date.now()
|
||||
})
|
||||
.where(
|
||||
and(
|
||||
eq(customDnsRecords.customDnsRecordId, customDnsRecordId),
|
||||
eq(customDnsRecords.domainId, domainId)
|
||||
)
|
||||
)
|
||||
.returning();
|
||||
|
||||
return response<CreateOrUpdateCustomDnsRecordResponse>(res, {
|
||||
data: toCustomDnsRecordRow(record),
|
||||
success: true,
|
||||
error: false,
|
||||
message: "Custom DNS record updated successfully",
|
||||
status: HttpCode.OK
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error(error);
|
||||
return next(
|
||||
createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred")
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,577 @@
|
||||
/*
|
||||
* This file is part of a proprietary work.
|
||||
*
|
||||
* Copyright (c) 2025-2026 Fossorial, Inc.
|
||||
* All rights reserved.
|
||||
*
|
||||
* This file is licensed under the Fossorial Commercial License.
|
||||
* You may not use this file except in compliance with the License.
|
||||
* Unauthorized use, copying, modification, or distribution is strictly prohibited.
|
||||
*
|
||||
* This file is not licensed under the AGPLv3.
|
||||
*/
|
||||
|
||||
import { isIPv4, isIPv6 } from "net";
|
||||
import { z } from "zod";
|
||||
import {
|
||||
db,
|
||||
customDnsRecords,
|
||||
domains,
|
||||
domainNamespaces,
|
||||
loginPage,
|
||||
orgDomains,
|
||||
redirects,
|
||||
resources
|
||||
} from "@server/db";
|
||||
import { and, eq, inArray, isNull } from "drizzle-orm";
|
||||
import HttpCode from "@server/types/HttpCode";
|
||||
import { privateConfig as config } from "#private/lib/config";
|
||||
import {
|
||||
CUSTOM_DNS_RECORD_TYPES,
|
||||
CustomDnsRecordType,
|
||||
parseCustomDnsRecordValues
|
||||
} from "@server/routers/customDnsRecord/types";
|
||||
|
||||
export const MAX_CUSTOM_DNS_RECORDS_PER_DOMAIN = 100;
|
||||
// Caps the number of values (resource records) any single RRset the DNS
|
||||
// server can be made to return, which bounds how much a spoofed UDP query
|
||||
// can be amplified.
|
||||
export const MAX_CUSTOM_DNS_VALUES_PER_RRSET = 10;
|
||||
export const MAX_TXT_VALUE_LENGTH = 1024;
|
||||
export const MIN_TTL = 60;
|
||||
export const MAX_TTL = 86400;
|
||||
|
||||
// Underscores are allowed so service labels like _dmarc, _sip._tcp and
|
||||
// selector._domainkey can be used.
|
||||
const LABEL_REGEX = /^[a-z0-9_](?:[a-z0-9_-]{0,61}[a-z0-9_])?$/;
|
||||
// Printable ASCII only - no control characters or non-ASCII bytes in TXT data.
|
||||
const TXT_REGEX = /^[\x20-\x7e]+$/;
|
||||
|
||||
const uint16 = z.number().int().min(0).max(65535);
|
||||
|
||||
export const customDnsRecordFieldsSchema = z.strictObject({
|
||||
name: z.string().max(253),
|
||||
type: z.enum(CUSTOM_DNS_RECORD_TYPES),
|
||||
values: z
|
||||
.array(
|
||||
z
|
||||
.string()
|
||||
.min(1)
|
||||
.max(MAX_TXT_VALUE_LENGTH + 2) // + surrounding quotes
|
||||
)
|
||||
.min(1)
|
||||
.max(MAX_CUSTOM_DNS_VALUES_PER_RRSET),
|
||||
ttl: z.number().int().min(MIN_TTL).max(MAX_TTL),
|
||||
priority: uint16.nullable().optional(),
|
||||
weight: uint16.nullable().optional(),
|
||||
port: uint16.nullable().optional(),
|
||||
enabled: z.boolean()
|
||||
});
|
||||
|
||||
export type CustomDnsRecordInput = z.infer<typeof customDnsRecordFieldsSchema>;
|
||||
|
||||
export type NormalizedCustomDnsRecord = {
|
||||
name: string;
|
||||
fullDomain: string;
|
||||
type: CustomDnsRecordType;
|
||||
values: string[];
|
||||
ttl: number;
|
||||
priority: number | null;
|
||||
weight: number | null;
|
||||
port: number | null;
|
||||
enabled: boolean;
|
||||
};
|
||||
|
||||
type Result<T> = { ok: true; value: T } | { ok: false; error: string };
|
||||
|
||||
type ManageableDomainResult =
|
||||
| { ok: true; domainId: string; baseDomain: string }
|
||||
| { ok: false; status: number; error: string };
|
||||
|
||||
function isValidHostname(host: string): boolean {
|
||||
if (host.length === 0 || host.length > 253 || !host.includes(".")) {
|
||||
return false;
|
||||
}
|
||||
return host.split(".").every((label) => LABEL_REGEX.test(label));
|
||||
}
|
||||
|
||||
function normalizeHostname(value: string): string {
|
||||
return value.trim().toLowerCase().replace(/\.$/, "");
|
||||
}
|
||||
|
||||
function isAtOrBelow(name: string, zone: string): boolean {
|
||||
const normalizedZone = zone.toLowerCase().replace(/\.$/, "");
|
||||
return name === normalizedZone || name.endsWith(`.${normalizedZone}`);
|
||||
}
|
||||
|
||||
// Loads the domain and makes sure custom records may be managed on it: it
|
||||
// must belong to the org, be delegated to our nameservers (type "ns"), and
|
||||
// belong to this org alone so one org can never publish records under a
|
||||
// domain another org also uses (e.g. a shared/provided namespace domain).
|
||||
export async function getManageableDomain(
|
||||
orgId: string,
|
||||
domainId: string
|
||||
): Promise<ManageableDomainResult> {
|
||||
const [row] = await db
|
||||
.select({
|
||||
domainId: domains.domainId,
|
||||
baseDomain: domains.baseDomain,
|
||||
type: domains.type
|
||||
})
|
||||
.from(domains)
|
||||
.innerJoin(orgDomains, eq(orgDomains.domainId, domains.domainId))
|
||||
.where(and(eq(domains.domainId, domainId), eq(orgDomains.orgId, orgId)))
|
||||
.limit(1);
|
||||
|
||||
if (!row) {
|
||||
return {
|
||||
ok: false,
|
||||
status: HttpCode.NOT_FOUND,
|
||||
error: `Domain with ID ${domainId} not found`
|
||||
};
|
||||
}
|
||||
|
||||
if (row.type !== "ns") {
|
||||
return {
|
||||
ok: false,
|
||||
status: HttpCode.BAD_REQUEST,
|
||||
error: "Custom DNS records can only be added to domains delegated to Pangolin's nameservers (NS domains)"
|
||||
};
|
||||
}
|
||||
|
||||
const [namespace] = await db
|
||||
.select({ domainNamespaceId: domainNamespaces.domainNamespaceId })
|
||||
.from(domainNamespaces)
|
||||
.where(eq(domainNamespaces.domainId, domainId))
|
||||
.limit(1);
|
||||
|
||||
const linkedOrgs = await db
|
||||
.select({ orgId: orgDomains.orgId })
|
||||
.from(orgDomains)
|
||||
.where(eq(orgDomains.domainId, domainId));
|
||||
|
||||
if (namespace || linkedOrgs.length !== 1) {
|
||||
return {
|
||||
ok: false,
|
||||
status: HttpCode.FORBIDDEN,
|
||||
error: "Custom DNS records cannot be added to shared domains"
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
domainId: row.domainId,
|
||||
baseDomain: row.baseDomain.toLowerCase()
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeName(
|
||||
name: string,
|
||||
baseDomain: string
|
||||
): Result<{ name: string; fullDomain: string }> {
|
||||
let relative = name.trim().toLowerCase().replace(/\.$/, "");
|
||||
|
||||
if (relative === "" || relative === "@" || relative === baseDomain) {
|
||||
return { ok: true, value: { name: "@", fullDomain: baseDomain } };
|
||||
}
|
||||
|
||||
// Accept the name typed as an FQDN within this domain as well
|
||||
if (relative.endsWith(`.${baseDomain}`)) {
|
||||
relative = relative.slice(0, -(baseDomain.length + 1));
|
||||
}
|
||||
|
||||
const labels = relative.split(".");
|
||||
for (let i = 0; i < labels.length; i++) {
|
||||
const label = labels[i];
|
||||
if (label === "*" && i === 0) {
|
||||
continue;
|
||||
}
|
||||
if (!LABEL_REGEX.test(label)) {
|
||||
return {
|
||||
ok: false,
|
||||
error: `Invalid record name "${name}". Labels may only contain letters, numbers, hyphens and underscores, and "*" is only allowed as the leftmost label.`
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// ACME challenge names are served by Pangolin itself for certificate
|
||||
// issuance - letting users write there would let them influence DNS-01
|
||||
// validation.
|
||||
if (labels.includes("_acme-challenge")) {
|
||||
return {
|
||||
ok: false,
|
||||
error: "_acme-challenge records are managed by Pangolin and cannot be added"
|
||||
};
|
||||
}
|
||||
|
||||
const fullDomain = `${relative}.${baseDomain}`;
|
||||
if (fullDomain.length > 253) {
|
||||
return { ok: false, error: "Record name is too long" };
|
||||
}
|
||||
|
||||
return { ok: true, value: { name: relative, fullDomain } };
|
||||
}
|
||||
|
||||
function normalizeValue(
|
||||
type: CustomDnsRecordType,
|
||||
rawValue: string
|
||||
): Result<string> {
|
||||
switch (type) {
|
||||
case "A": {
|
||||
const value = rawValue.trim();
|
||||
if (!isIPv4(value)) {
|
||||
return {
|
||||
ok: false,
|
||||
error: `"${value}" is not an IPv4 address`
|
||||
};
|
||||
}
|
||||
return { ok: true, value };
|
||||
}
|
||||
case "AAAA": {
|
||||
const value = rawValue.trim().toLowerCase();
|
||||
if (!isIPv6(value)) {
|
||||
return {
|
||||
ok: false,
|
||||
error: `"${value}" is not an IPv6 address`
|
||||
};
|
||||
}
|
||||
return { ok: true, value };
|
||||
}
|
||||
case "CNAME":
|
||||
case "NS":
|
||||
case "MX":
|
||||
case "SRV": {
|
||||
const value = normalizeHostname(rawValue);
|
||||
if (!isValidHostname(value)) {
|
||||
return {
|
||||
ok: false,
|
||||
error: `${type} records require fully qualified hostnames as values`
|
||||
};
|
||||
}
|
||||
return { ok: true, value };
|
||||
}
|
||||
case "TXT": {
|
||||
let value = rawValue;
|
||||
// Strip one pair of surrounding quotes, as commonly pasted from
|
||||
// zone files and provider instructions
|
||||
if (
|
||||
value.length >= 2 &&
|
||||
value.startsWith('"') &&
|
||||
value.endsWith('"')
|
||||
) {
|
||||
value = value.slice(1, -1);
|
||||
}
|
||||
if (value.length === 0 || value.length > MAX_TXT_VALUE_LENGTH) {
|
||||
return {
|
||||
ok: false,
|
||||
error: `TXT values must be between 1 and ${MAX_TXT_VALUE_LENGTH} characters`
|
||||
};
|
||||
}
|
||||
if (!TXT_REGEX.test(value)) {
|
||||
return {
|
||||
ok: false,
|
||||
error: "TXT values may only contain printable ASCII characters"
|
||||
};
|
||||
}
|
||||
return { ok: true, value };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Validates and normalizes a record's fields without touching the database.
|
||||
export function normalizeCustomDnsRecord(
|
||||
input: CustomDnsRecordInput,
|
||||
baseDomain: string
|
||||
): Result<NormalizedCustomDnsRecord> {
|
||||
const nameResult = normalizeName(input.name, baseDomain);
|
||||
if (!nameResult.ok) {
|
||||
return nameResult;
|
||||
}
|
||||
const { name, fullDomain } = nameResult.value;
|
||||
const isApex = name === "@";
|
||||
const isWildcard = name.startsWith("*");
|
||||
|
||||
let priority: number | null = null;
|
||||
let weight: number | null = null;
|
||||
let port: number | null = null;
|
||||
|
||||
const values: string[] = [];
|
||||
for (const rawValue of input.values) {
|
||||
const valueResult = normalizeValue(input.type, rawValue);
|
||||
if (!valueResult.ok) {
|
||||
return valueResult;
|
||||
}
|
||||
if (values.includes(valueResult.value)) {
|
||||
return {
|
||||
ok: false,
|
||||
error: `Duplicate value "${valueResult.value}"`
|
||||
};
|
||||
}
|
||||
values.push(valueResult.value);
|
||||
}
|
||||
|
||||
if (input.type === "CNAME") {
|
||||
if (isApex) {
|
||||
return {
|
||||
ok: false,
|
||||
error: "CNAME records cannot be added at the root of the domain"
|
||||
};
|
||||
}
|
||||
if (values.length > 1) {
|
||||
return {
|
||||
ok: false,
|
||||
error: "A CNAME record can only have one value"
|
||||
};
|
||||
}
|
||||
if (values[0] === fullDomain) {
|
||||
return {
|
||||
ok: false,
|
||||
error: "A CNAME record cannot point to itself"
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
if (input.type === "NS" && (isApex || isWildcard)) {
|
||||
return {
|
||||
ok: false,
|
||||
error: "NS records can only be added for a specific subdomain, not the root or a wildcard"
|
||||
};
|
||||
}
|
||||
|
||||
if (input.type === "MX" || input.type === "SRV") {
|
||||
if (input.priority == null) {
|
||||
return {
|
||||
ok: false,
|
||||
error: `${input.type} records require a priority`
|
||||
};
|
||||
}
|
||||
priority = input.priority;
|
||||
}
|
||||
|
||||
if (input.type === "SRV") {
|
||||
if (input.weight == null || input.port == null) {
|
||||
return {
|
||||
ok: false,
|
||||
error: "SRV records require a weight and a port"
|
||||
};
|
||||
}
|
||||
weight = input.weight;
|
||||
port = input.port;
|
||||
}
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
value: {
|
||||
name,
|
||||
fullDomain,
|
||||
type: input.type,
|
||||
values,
|
||||
ttl: input.ttl,
|
||||
priority,
|
||||
weight,
|
||||
port,
|
||||
enabled: input.enabled
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
// Checks a normalized record against everything else that lives in DNS:
|
||||
// infrastructure zones, other customers' zones, Pangolin-managed names and
|
||||
// the domain's other custom records. Returns an error message, or null.
|
||||
export async function checkCustomDnsRecordConflicts(
|
||||
domainId: string,
|
||||
baseDomain: string,
|
||||
record: NormalizedCustomDnsRecord,
|
||||
excludeRecordId?: number
|
||||
): Promise<string | null> {
|
||||
const { fullDomain, type } = record;
|
||||
|
||||
// Names the DNS server answers for itself (nameservers, cname/site
|
||||
// extensions, configured zones) are never user-writable.
|
||||
const dnsConfig = config.getRawConfig().dns;
|
||||
const reservedZones = [
|
||||
dnsConfig?.nameserver_name,
|
||||
dnsConfig?.cname_extension,
|
||||
dnsConfig?.site_extension,
|
||||
...(dnsConfig?.cname_alternate_extensions ?? []),
|
||||
...(dnsConfig?.zones ?? [])
|
||||
].filter((z): z is string => !!z);
|
||||
if (reservedZones.some((zone) => isAtOrBelow(fullDomain, zone))) {
|
||||
return "This name is reserved and cannot be used";
|
||||
}
|
||||
|
||||
// A name inside a more specific domain (e.g. sub.example.com added to
|
||||
// Pangolin separately, possibly by another org) belongs to that zone,
|
||||
// not this one.
|
||||
const labels = fullDomain.split(".");
|
||||
const baseLabelCount = baseDomain.split(".").length;
|
||||
const childZoneCandidates: string[] = [];
|
||||
for (let i = 0; i < labels.length - baseLabelCount; i++) {
|
||||
childZoneCandidates.push(labels.slice(i).join("."));
|
||||
}
|
||||
if (childZoneCandidates.length > 0) {
|
||||
const [childZone] = await db
|
||||
.select({ domainId: domains.domainId })
|
||||
.from(domains)
|
||||
.where(inArray(domains.baseDomain, childZoneCandidates))
|
||||
.limit(1);
|
||||
if (childZone) {
|
||||
return "This name belongs to a different domain in Pangolin";
|
||||
}
|
||||
}
|
||||
|
||||
const existing = (
|
||||
await db
|
||||
.select()
|
||||
.from(customDnsRecords)
|
||||
.where(eq(customDnsRecords.domainId, domainId))
|
||||
).filter((r) => r.customDnsRecordId !== excludeRecordId);
|
||||
|
||||
// existing excludes the record being updated, so this only trips when
|
||||
// adding a new record would go over the limit
|
||||
if (existing.length >= MAX_CUSTOM_DNS_RECORDS_PER_DOMAIN) {
|
||||
return `A domain can have at most ${MAX_CUSTOM_DNS_RECORDS_PER_DOMAIN} custom DNS records`;
|
||||
}
|
||||
|
||||
const sameName = existing.filter((r) => r.fullDomain === fullDomain);
|
||||
|
||||
if (type === "CNAME" && sameName.length > 0) {
|
||||
return "A CNAME record cannot be added to a name that already has other records";
|
||||
}
|
||||
if (type !== "CNAME" && sameName.some((r) => r.type === "CNAME")) {
|
||||
return "This name already has a CNAME record, which cannot be combined with other records";
|
||||
}
|
||||
|
||||
// One record per name and type (per priority/weight/port for MX and
|
||||
// SRV) - more values go into that record rather than a second one, so
|
||||
// the table shows the whole set in one place.
|
||||
const sameRRset = sameName.filter((r) => r.type === type);
|
||||
if (
|
||||
sameRRset.some(
|
||||
(r) =>
|
||||
r.priority === record.priority &&
|
||||
r.weight === record.weight &&
|
||||
r.port === record.port
|
||||
)
|
||||
) {
|
||||
return `A ${type} record for this name already exists. Edit it to add more values.`;
|
||||
}
|
||||
|
||||
// MX and SRV can still be split across several records (one per
|
||||
// priority), so cap the values across all of them
|
||||
const totalValues =
|
||||
record.values.length +
|
||||
sameRRset.reduce(
|
||||
(sum, r) => sum + parseCustomDnsRecordValues(r.values).length,
|
||||
0
|
||||
);
|
||||
if (totalValues > MAX_CUSTOM_DNS_VALUES_PER_RRSET) {
|
||||
return `A name can have at most ${MAX_CUSTOM_DNS_VALUES_PER_RRSET} ${type} values`;
|
||||
}
|
||||
|
||||
// Delegations: a subdomain with NS records is handed off to other
|
||||
// nameservers, and the DNS server refers every query at or beneath it
|
||||
// there. Nothing else may live at or beneath it except A/AAAA glue for
|
||||
// nameservers named inside it, or it would silently stop resolving.
|
||||
const isStrictlyBelow = (name: string, parent: string) =>
|
||||
name.endsWith(`.${parent}`);
|
||||
const isGlue =
|
||||
(type === "A" || type === "AAAA") && !record.name.startsWith("*");
|
||||
|
||||
const enclosingCut = existing.find(
|
||||
(r) => r.type === "NS" && isStrictlyBelow(fullDomain, r.fullDomain)
|
||||
);
|
||||
if (enclosingCut) {
|
||||
if (type === "NS") {
|
||||
return `This name is inside the delegated subdomain ${enclosingCut.fullDomain}, so it cannot be delegated again`;
|
||||
}
|
||||
if (!isGlue) {
|
||||
return `This name is inside the delegated subdomain ${enclosingCut.fullDomain}, where only A and AAAA records for its nameservers can be added`;
|
||||
}
|
||||
}
|
||||
|
||||
if (type === "NS") {
|
||||
if (sameName.some((r) => r.type !== "NS")) {
|
||||
return "A delegated subdomain can only have NS records. Remove the other records for this name first.";
|
||||
}
|
||||
if (
|
||||
existing.some(
|
||||
(r) =>
|
||||
isStrictlyBelow(r.fullDomain, fullDomain) &&
|
||||
((r.type !== "A" && r.type !== "AAAA") ||
|
||||
r.name.startsWith("*"))
|
||||
)
|
||||
) {
|
||||
return "Records exist beneath this name that would stop resolving once it is delegated. Remove them first.";
|
||||
}
|
||||
|
||||
const isAtOrBelowCut = (name: string | null) =>
|
||||
!!name &&
|
||||
(name.toLowerCase() === fullDomain ||
|
||||
isStrictlyBelow(name.toLowerCase(), fullDomain));
|
||||
const resourceNames = await db
|
||||
.select({ fullDomain: resources.fullDomain })
|
||||
.from(resources)
|
||||
.where(eq(resources.domainId, domainId));
|
||||
const pageNames = await db
|
||||
.select({ fullDomain: loginPage.fullDomain })
|
||||
.from(loginPage)
|
||||
.where(eq(loginPage.domainId, domainId));
|
||||
const redirectNames = (
|
||||
await db
|
||||
.select({ subdomain: redirects.subdomain })
|
||||
.from(redirects)
|
||||
.where(
|
||||
and(
|
||||
eq(redirects.domainId, domainId),
|
||||
isNull(redirects.resourceId)
|
||||
)
|
||||
)
|
||||
).map((r) => (r.subdomain ? `${r.subdomain}.${baseDomain}` : null));
|
||||
|
||||
if (
|
||||
[...resourceNames, ...pageNames].some((r) =>
|
||||
isAtOrBelowCut(r.fullDomain)
|
||||
) ||
|
||||
redirectNames.some(isAtOrBelowCut)
|
||||
) {
|
||||
return "Pangolin resources use names at or beneath this subdomain, so it cannot be delegated";
|
||||
}
|
||||
} else if (sameName.some((r) => r.type === "NS")) {
|
||||
return "This name is delegated with NS records, so other records cannot be added to it";
|
||||
}
|
||||
|
||||
// Pangolin answers address queries for resources, login pages and
|
||||
// redirects itself, so a custom A/AAAA/CNAME there would never be served
|
||||
// (or, for CNAME, would conflict with the managed addresses).
|
||||
if (type === "A" || type === "AAAA" || type === "CNAME") {
|
||||
const [resource] = await db
|
||||
.select({ resourceId: resources.resourceId })
|
||||
.from(resources)
|
||||
.where(eq(resources.fullDomain, fullDomain))
|
||||
.limit(1);
|
||||
const [page] = await db
|
||||
.select({ loginPageId: loginPage.loginPageId })
|
||||
.from(loginPage)
|
||||
.where(eq(loginPage.fullDomain, fullDomain))
|
||||
.limit(1);
|
||||
const [redirect] = await db
|
||||
.select({ redirectId: redirects.redirectId })
|
||||
.from(redirects)
|
||||
.where(
|
||||
and(
|
||||
eq(redirects.domainId, domainId),
|
||||
record.name === "@"
|
||||
? isNull(redirects.subdomain)
|
||||
: eq(redirects.subdomain, record.name)
|
||||
)
|
||||
)
|
||||
.limit(1);
|
||||
|
||||
if (resource || page || redirect) {
|
||||
return `This name is already in use by Pangolin, so ${type} records cannot be added to it`;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
@@ -34,8 +34,10 @@ import * as eventStreamingDestination from "#private/routers/eventStreamingDesti
|
||||
import * as alertRule from "#private/routers/alertRule";
|
||||
import * as healthChecks from "#private/routers/healthChecks";
|
||||
import * as policy from "#private/routers/policy";
|
||||
import * as customDnsRecord from "#private/routers/customDnsRecord";
|
||||
import {
|
||||
verifyOrgAccess,
|
||||
verifyDomainAccess,
|
||||
verifyUserHasAction,
|
||||
verifyUserIsServerAdmin,
|
||||
verifySiteAccess,
|
||||
@@ -263,6 +265,49 @@ authenticated.get(
|
||||
domain.checkDomainNamespaceAvailability
|
||||
);
|
||||
|
||||
authenticated.get(
|
||||
"/org/:orgId/domain/:domainId/custom-dns-records",
|
||||
verifyValidLicense,
|
||||
verifyOrgAccess,
|
||||
verifyDomainAccess,
|
||||
verifyUserHasAction(ActionsEnum.listCustomDnsRecords),
|
||||
customDnsRecord.listCustomDnsRecords
|
||||
);
|
||||
|
||||
authenticated.put(
|
||||
"/org/:orgId/domain/:domainId/custom-dns-record",
|
||||
verifyValidLicense,
|
||||
verifyValidSubscription(tierMatrix.customDnsRecords),
|
||||
verifyOrgAccess,
|
||||
verifyDomainAccess,
|
||||
verifyLimits,
|
||||
verifyUserHasAction(ActionsEnum.createCustomDnsRecord),
|
||||
logActionAudit(ActionsEnum.createCustomDnsRecord),
|
||||
customDnsRecord.createCustomDnsRecord
|
||||
);
|
||||
|
||||
authenticated.post(
|
||||
"/org/:orgId/domain/:domainId/custom-dns-record/:customDnsRecordId",
|
||||
verifyValidLicense,
|
||||
verifyValidSubscription(tierMatrix.customDnsRecords),
|
||||
verifyOrgAccess,
|
||||
verifyDomainAccess,
|
||||
verifyUserHasAction(ActionsEnum.updateCustomDnsRecord),
|
||||
logActionAudit(ActionsEnum.updateCustomDnsRecord),
|
||||
customDnsRecord.updateCustomDnsRecord
|
||||
);
|
||||
|
||||
authenticated.delete(
|
||||
"/org/:orgId/domain/:domainId/custom-dns-record/:customDnsRecordId",
|
||||
verifyValidLicense,
|
||||
verifyValidSubscription(tierMatrix.customDnsRecords),
|
||||
verifyOrgAccess,
|
||||
verifyDomainAccess,
|
||||
verifyUserHasAction(ActionsEnum.deleteCustomDnsRecord),
|
||||
logActionAudit(ActionsEnum.deleteCustomDnsRecord),
|
||||
customDnsRecord.deleteCustomDnsRecord
|
||||
);
|
||||
|
||||
authenticated.put(
|
||||
"/org/:orgId/remote-exit-node",
|
||||
verifyValidLicense,
|
||||
|
||||
@@ -22,8 +22,10 @@ import * as eventStreamingDestination from "#private/routers/eventStreamingDesti
|
||||
import * as alertRule from "#private/routers/alertRule";
|
||||
import * as healthChecks from "#private/routers/healthChecks";
|
||||
import * as resource from "#private/routers/resource";
|
||||
import * as customDnsRecord from "#private/routers/customDnsRecord";
|
||||
import {
|
||||
verifyApiKeyHasAction,
|
||||
verifyApiKeyDomainAccess,
|
||||
verifyApiKeyIsRoot,
|
||||
verifyApiKeyOrgAccess,
|
||||
verifyApiKeyIdpAccess,
|
||||
@@ -179,6 +181,49 @@ authenticated.get(
|
||||
exportAiSessionLogs
|
||||
);
|
||||
|
||||
authenticated.get(
|
||||
"/org/:orgId/domain/:domainId/custom-dns-records",
|
||||
verifyValidLicense,
|
||||
verifyApiKeyOrgAccess,
|
||||
verifyApiKeyDomainAccess,
|
||||
verifyApiKeyHasAction(ActionsEnum.listCustomDnsRecords),
|
||||
customDnsRecord.listCustomDnsRecords
|
||||
);
|
||||
|
||||
authenticated.put(
|
||||
"/org/:orgId/domain/:domainId/custom-dns-record",
|
||||
verifyValidLicense,
|
||||
verifyValidSubscription(tierMatrix.customDnsRecords),
|
||||
verifyApiKeyOrgAccess,
|
||||
verifyApiKeyDomainAccess,
|
||||
verifyLimits,
|
||||
verifyApiKeyHasAction(ActionsEnum.createCustomDnsRecord),
|
||||
logActionAudit(ActionsEnum.createCustomDnsRecord),
|
||||
customDnsRecord.createCustomDnsRecord
|
||||
);
|
||||
|
||||
authenticated.post(
|
||||
"/org/:orgId/domain/:domainId/custom-dns-record/:customDnsRecordId",
|
||||
verifyValidLicense,
|
||||
verifyValidSubscription(tierMatrix.customDnsRecords),
|
||||
verifyApiKeyOrgAccess,
|
||||
verifyApiKeyDomainAccess,
|
||||
verifyApiKeyHasAction(ActionsEnum.updateCustomDnsRecord),
|
||||
logActionAudit(ActionsEnum.updateCustomDnsRecord),
|
||||
customDnsRecord.updateCustomDnsRecord
|
||||
);
|
||||
|
||||
authenticated.delete(
|
||||
"/org/:orgId/domain/:domainId/custom-dns-record/:customDnsRecordId",
|
||||
verifyValidLicense,
|
||||
verifyValidSubscription(tierMatrix.customDnsRecords),
|
||||
verifyApiKeyOrgAccess,
|
||||
verifyApiKeyDomainAccess,
|
||||
verifyApiKeyHasAction(ActionsEnum.deleteCustomDnsRecord),
|
||||
logActionAudit(ActionsEnum.deleteCustomDnsRecord),
|
||||
customDnsRecord.deleteCustomDnsRecord
|
||||
);
|
||||
|
||||
authenticated.put(
|
||||
"/org/:orgId/idp/oidc",
|
||||
verifyValidLicense,
|
||||
|
||||
@@ -230,6 +230,7 @@ export async function startRegistration(
|
||||
rpID,
|
||||
userID: isoBase64URL.toBuffer(user.userId),
|
||||
userName: user.email || user.username,
|
||||
userDisplayName: user.name || user.email || user.username, // Safari fails to register a passkey with empty display name - #3931
|
||||
attestationType: "none",
|
||||
excludeCredentials,
|
||||
authenticatorSelection: {
|
||||
|
||||
@@ -720,7 +720,7 @@ export async function verifyResourceSession(
|
||||
);
|
||||
|
||||
if (resourceSessionToken) {
|
||||
const sessionCacheKey = `session:${resourceSessionToken}`;
|
||||
const sessionCacheKey = `session:${resource.resourceId}:${resourceSessionToken}`;
|
||||
let resourceSession: ResourceSession | null | undefined =
|
||||
localCache.get(sessionCacheKey);
|
||||
|
||||
@@ -736,6 +736,14 @@ export async function verifyResourceSession(
|
||||
}
|
||||
}
|
||||
|
||||
// sessions are scoped to a single resource; never honor one issued for another
|
||||
if (
|
||||
resourceSession &&
|
||||
resourceSession.resourceId !== resource.resourceId
|
||||
) {
|
||||
resourceSession = null;
|
||||
}
|
||||
|
||||
if (resourceSession?.isRequestToken) {
|
||||
logger.debug(
|
||||
"Resource not allowed because session is a temporary request token"
|
||||
@@ -1245,7 +1253,7 @@ async function allowAccessToken(
|
||||
);
|
||||
|
||||
if (resourceSessionToken) {
|
||||
const sessionCacheKey = `session:${resourceSessionToken}`;
|
||||
const sessionCacheKey = `session:${resource.resourceId}:${resourceSessionToken}`;
|
||||
let resourceSession: ResourceSession | null | undefined =
|
||||
localCache.get(sessionCacheKey);
|
||||
|
||||
@@ -1262,6 +1270,7 @@ async function allowAccessToken(
|
||||
|
||||
if (
|
||||
resourceSession &&
|
||||
resourceSession.resourceId === resource.resourceId &&
|
||||
!resourceSession.isRequestToken &&
|
||||
resourceSession.accessTokenId === tokenItem.accessTokenId
|
||||
) {
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
export const CUSTOM_DNS_RECORD_TYPES = [
|
||||
"A",
|
||||
"AAAA",
|
||||
"CNAME",
|
||||
"MX",
|
||||
"NS",
|
||||
"SRV",
|
||||
"TXT"
|
||||
] as const;
|
||||
|
||||
export type CustomDnsRecordType = (typeof CUSTOM_DNS_RECORD_TYPES)[number];
|
||||
|
||||
// One record as the user sees it: a name, type and settings with one or more
|
||||
// values (e.g. both nameservers of an NS record). The DNS server answers with
|
||||
// one resource record per value.
|
||||
export type CustomDnsRecordRow = {
|
||||
customDnsRecordId: number;
|
||||
domainId: string;
|
||||
name: string;
|
||||
fullDomain: string;
|
||||
type: CustomDnsRecordType;
|
||||
values: string[];
|
||||
ttl: number;
|
||||
priority: number | null;
|
||||
weight: number | null;
|
||||
port: number | null;
|
||||
enabled: boolean;
|
||||
createdAt: number;
|
||||
updatedAt: number;
|
||||
};
|
||||
|
||||
export type ListCustomDnsRecordsResponse = {
|
||||
records: CustomDnsRecordRow[];
|
||||
};
|
||||
|
||||
export type CreateOrUpdateCustomDnsRecordResponse = CustomDnsRecordRow;
|
||||
|
||||
// The values column holds a JSON-encoded string array
|
||||
export function parseCustomDnsRecordValues(raw: string): string[] {
|
||||
try {
|
||||
const parsed = JSON.parse(raw);
|
||||
return Array.isArray(parsed)
|
||||
? parsed.filter((v): v is string => typeof v === "string")
|
||||
: [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
export function toCustomDnsRecordRow(
|
||||
row: Omit<CustomDnsRecordRow, "values"> & { values: string }
|
||||
): CustomDnsRecordRow {
|
||||
return { ...row, values: parseCustomDnsRecordValues(row.values) };
|
||||
}
|
||||
@@ -7,6 +7,7 @@ import HttpCode from "@server/types/HttpCode";
|
||||
import createHttpError from "http-errors";
|
||||
import logger from "@server/logger";
|
||||
import { fromError } from "zod-validation-error";
|
||||
import { subdomainSchema } from "@server/lib/schemas";
|
||||
import { OpenAPITags, registry } from "@server/openApi";
|
||||
import { and, eq } from "drizzle-orm";
|
||||
import {
|
||||
@@ -35,7 +36,7 @@ const bodySchema = z
|
||||
name: z.string().nonempty(),
|
||||
resourceId: z.number().int().positive().optional().nullable(),
|
||||
domainId: z.string().nonempty().optional().nullable(),
|
||||
subdomain: z.string().nonempty().optional().nullable(),
|
||||
subdomain: subdomainSchema.optional().nullable(),
|
||||
destinationHost: redirectDestinationHostSchema,
|
||||
pathMatchType: redirectPathMatchTypeSchema.optional(),
|
||||
matchPath: redirectMatchPathSchema.optional().nullable(),
|
||||
|
||||
@@ -7,6 +7,7 @@ import HttpCode from "@server/types/HttpCode";
|
||||
import createHttpError from "http-errors";
|
||||
import logger from "@server/logger";
|
||||
import { fromError } from "zod-validation-error";
|
||||
import { subdomainSchema } from "@server/lib/schemas";
|
||||
import { OpenAPITags, registry } from "@server/openApi";
|
||||
import { and, eq, ne } from "drizzle-orm";
|
||||
import {
|
||||
@@ -36,7 +37,7 @@ const bodySchema = z.strictObject({
|
||||
niceId: redirectNiceIdSchema.optional(),
|
||||
resourceId: z.number().int().positive().optional().nullable(),
|
||||
domainId: z.string().nonempty().optional().nullable(),
|
||||
subdomain: z.string().nonempty().optional().nullable(),
|
||||
subdomain: subdomainSchema.optional().nullable(),
|
||||
destinationHost: redirectDestinationHostSchema.optional(),
|
||||
pathMatchType: redirectPathMatchTypeSchema.optional(),
|
||||
matchPath: redirectMatchPathSchema.optional().nullable(),
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { z } from "zod";
|
||||
import { isValidDomain } from "@server/lib/validators";
|
||||
import { build } from "@server/build";
|
||||
import { routePathSchema } from "@server/lib/schemas";
|
||||
|
||||
export const redirectNiceIdSchema = z
|
||||
.string()
|
||||
@@ -20,7 +21,7 @@ export const redirectRewritePathTypeSchema = z.enum([
|
||||
"stripPrefix"
|
||||
]);
|
||||
|
||||
export const redirectMatchPathSchema = z.string().nonempty();
|
||||
export const redirectMatchPathSchema = routePathSchema.nonempty();
|
||||
|
||||
export function isValidRegex(pattern: string): boolean {
|
||||
try {
|
||||
|
||||
@@ -416,6 +416,26 @@ export async function updateResource(
|
||||
}
|
||||
}
|
||||
|
||||
// a resource may only be assigned a shared (global) policy from its own org
|
||||
async function getAssignableResourcePolicy(
|
||||
resourcePolicyId: number,
|
||||
orgId: string
|
||||
) {
|
||||
const [policy] = await db
|
||||
.select()
|
||||
.from(resourcePolicies)
|
||||
.where(
|
||||
and(
|
||||
eq(resourcePolicies.resourcePolicyId, resourcePolicyId),
|
||||
eq(resourcePolicies.orgId, orgId),
|
||||
eq(resourcePolicies.scope, "global")
|
||||
)
|
||||
)
|
||||
.limit(1);
|
||||
|
||||
return policy;
|
||||
}
|
||||
|
||||
async function clearResourceSpecificSettings(
|
||||
resourceId: number,
|
||||
orgId: string,
|
||||
@@ -513,16 +533,10 @@ async function updateHttpResource(
|
||||
);
|
||||
}
|
||||
|
||||
const [existingPolicy] = await db
|
||||
.select()
|
||||
.from(resourcePolicies)
|
||||
.where(
|
||||
eq(
|
||||
resourcePolicies.resourcePolicyId,
|
||||
updateData.resourcePolicyId
|
||||
)
|
||||
)
|
||||
.limit(1);
|
||||
const existingPolicy = await getAssignableResourcePolicy(
|
||||
updateData.resourcePolicyId,
|
||||
resource.orgId
|
||||
);
|
||||
|
||||
if (!existingPolicy) {
|
||||
return next(
|
||||
@@ -920,6 +934,35 @@ async function updateRawResource(
|
||||
const updateData = parsedBody.data;
|
||||
let updatedResource: Resource | null = null;
|
||||
|
||||
if (updateData.resourcePolicyId != null) {
|
||||
const isLicensed = await isLicensedOrSubscribed(
|
||||
resource.orgId,
|
||||
tierMatrix.resourcePolicies
|
||||
);
|
||||
if (!isLicensed) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.FORBIDDEN,
|
||||
"Resource policies are not supported on your current plan. Please upgrade to access this feature."
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
const existingPolicy = await getAssignableResourcePolicy(
|
||||
updateData.resourcePolicyId,
|
||||
resource.orgId
|
||||
);
|
||||
|
||||
if (!existingPolicy) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.NOT_FOUND,
|
||||
`Resource policy with ID ${updateData.resourcePolicyId} not found`
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const [existingResource] = await db
|
||||
.select()
|
||||
.from(resources)
|
||||
|
||||
@@ -97,6 +97,15 @@ const listAllSiteResourcesByOrgQuerySchema = z.strictObject({
|
||||
enum: ["pending", "approved"],
|
||||
description: "Filter by site resource status"
|
||||
}),
|
||||
enabled: z
|
||||
.enum(["true", "false"])
|
||||
.transform((v) => v === "true")
|
||||
.optional()
|
||||
.catch(undefined)
|
||||
.openapi({
|
||||
type: "boolean",
|
||||
description: "Filter site resources based on enabled status"
|
||||
}),
|
||||
labels: z
|
||||
.preprocess((val) => {
|
||||
if (val === undefined || val === null || val === "") {
|
||||
@@ -322,6 +331,7 @@ export async function listAllSiteResourcesByOrg(
|
||||
order,
|
||||
siteId,
|
||||
status,
|
||||
enabled,
|
||||
labels: labelFilter
|
||||
} = parsedQuery.data;
|
||||
|
||||
@@ -399,6 +409,10 @@ export async function listAllSiteResourcesByOrg(
|
||||
conditions.push(eq(siteResources.status, status));
|
||||
}
|
||||
|
||||
if (typeof enabled !== "undefined") {
|
||||
conditions.push(eq(siteResources.enabled, enabled));
|
||||
}
|
||||
|
||||
if (labelFilter && labelFilter.length > 0) {
|
||||
conditions.push(
|
||||
inArray(
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Request, Response, NextFunction } from "express";
|
||||
import { z } from "zod";
|
||||
import { routePathSchema } from "@server/lib/schemas";
|
||||
import {
|
||||
db,
|
||||
statusHistory,
|
||||
@@ -76,7 +77,7 @@ const createTargetSchema = z
|
||||
hcTlsServerName: z.string().optional().nullable(),
|
||||
hcHealthyThreshold: z.int().positive().min(1).optional().nullable(),
|
||||
hcUnhealthyThreshold: z.int().positive().min(1).optional().nullable(),
|
||||
path: z.string().optional().nullable(),
|
||||
path: routePathSchema.optional().nullable(),
|
||||
pathMatchType: z
|
||||
.enum(["exact", "prefix", "regex"])
|
||||
.optional()
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Request, Response, NextFunction } from "express";
|
||||
import { z } from "zod";
|
||||
import { routePathSchema } from "@server/lib/schemas";
|
||||
import { db, targetHealthCheck } from "@server/db";
|
||||
import { aiProviders, newts, resources, sites, targets } from "@server/db";
|
||||
import { eq } from "drizzle-orm";
|
||||
@@ -54,7 +55,7 @@ const updateTargetBodySchema = z
|
||||
hcTlsServerName: z.string().optional().nullable(),
|
||||
hcHealthyThreshold: z.int().positive().min(1).optional().nullable(),
|
||||
hcUnhealthyThreshold: z.int().positive().min(1).optional().nullable(),
|
||||
path: z.string().optional().nullable(),
|
||||
path: routePathSchema.optional().nullable(),
|
||||
pathMatchType: z
|
||||
.enum(["exact", "prefix", "regex"])
|
||||
.optional()
|
||||
|
||||
@@ -117,7 +117,9 @@ export async function getOrgUser(
|
||||
|
||||
const { orgId, userId } = parsedParams.data;
|
||||
|
||||
if (!req.userOrg) {
|
||||
// API keys never have a userOrg; their org access and action are
|
||||
// already checked by verifyApiKeyOrgAccess / verifyApiKeyHasAction
|
||||
if (req.user && !req.userOrg) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.FORBIDDEN,
|
||||
@@ -150,7 +152,7 @@ export async function getOrgUser(
|
||||
);
|
||||
}
|
||||
|
||||
if (req.user && user.userId !== req.userOrg.userId) {
|
||||
if (req.user && user.userId !== req.user.userId) {
|
||||
const hasPermission = await checkUserActionPermission(
|
||||
ActionsEnum.getOrgUser,
|
||||
req
|
||||
|
||||
@@ -40,6 +40,7 @@ import type { ResourceContextType } from "@app/contexts/resourceContext";
|
||||
import { useEnvContext } from "@app/hooks/useEnvContext";
|
||||
import { toast } from "@app/hooks/useToast";
|
||||
import { createApiClient } from "@app/lib/api";
|
||||
import { cn } from "@app/lib/cn";
|
||||
import { formatAxiosError } from "@app/lib/api/formatAxiosError";
|
||||
import { DockerManager, DockerState } from "@app/lib/docker";
|
||||
import { orgQueries, resourceQueries, aiProviderQueries } from "@app/lib/queries";
|
||||
@@ -959,28 +960,32 @@ export const ProxyResourceTargetsForm = forwardRef<
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
{hasTargets && (
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div className="flex items-center justify-between w-full gap-2">
|
||||
{addTargetButton}
|
||||
{!disableAdvancedMode && (
|
||||
<div className="flex items-center gap-2">
|
||||
<Switch
|
||||
id={advancedModeToggleId}
|
||||
checked={isAdvancedMode}
|
||||
onCheckedChange={setIsAdvancedMode}
|
||||
/>
|
||||
<label
|
||||
htmlFor={advancedModeToggleId}
|
||||
className="text-sm"
|
||||
>
|
||||
{t("advancedMode")}
|
||||
</label>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{/* The empty state (and its add button) is hidden on mobile, so always show the add button there */}
|
||||
<div
|
||||
className={cn(
|
||||
"flex items-center justify-between mb-4",
|
||||
!hasTargets && "sm:hidden"
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center justify-between w-full gap-2">
|
||||
{addTargetButton}
|
||||
{hasTargets && !disableAdvancedMode && (
|
||||
<div className="flex items-center gap-2">
|
||||
<Switch
|
||||
id={advancedModeToggleId}
|
||||
checked={isAdvancedMode}
|
||||
onCheckedChange={setIsAdvancedMode}
|
||||
/>
|
||||
<label
|
||||
htmlFor={advancedModeToggleId}
|
||||
className="text-sm"
|
||||
>
|
||||
{t("advancedMode")}
|
||||
</label>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{build === "saas" &&
|
||||
!isAiProvider &&
|
||||
targets.length > 1 &&
|
||||
|
||||
@@ -0,0 +1,383 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
Credenza,
|
||||
CredenzaBody,
|
||||
CredenzaClose,
|
||||
CredenzaContent,
|
||||
CredenzaDescription,
|
||||
CredenzaFooter,
|
||||
CredenzaHeader,
|
||||
CredenzaTitle
|
||||
} from "@app/components/Credenza";
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormDescription,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage
|
||||
} from "@app/components/ui/form";
|
||||
import { Button } from "@app/components/ui/button";
|
||||
import { Input } from "@app/components/ui/input";
|
||||
import { Textarea } from "@app/components/ui/textarea";
|
||||
import { Switch } from "@app/components/ui/switch";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue
|
||||
} from "@app/components/ui/select";
|
||||
import { useEnvContext } from "@app/hooks/useEnvContext";
|
||||
import { toast } from "@app/hooks/useToast";
|
||||
import { createApiClient, formatAxiosError } from "@app/lib/api";
|
||||
import {
|
||||
CUSTOM_DNS_RECORD_TYPES,
|
||||
CustomDnsRecordRow,
|
||||
CustomDnsRecordType
|
||||
} from "@server/routers/customDnsRecord/types";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { z } from "zod";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
|
||||
const FORM_ID = "custom-dns-record-form";
|
||||
|
||||
const VALUE_PLACEHOLDERS: Record<CustomDnsRecordType, string> = {
|
||||
A: "192.0.2.1",
|
||||
AAAA: "2001:db8::1",
|
||||
CNAME: "target.example.net",
|
||||
MX: "mail.example.net",
|
||||
NS: "ns1.example.net",
|
||||
SRV: "sip.example.net",
|
||||
TXT: "v=spf1 include:example.net ~all"
|
||||
};
|
||||
|
||||
const optionalUint16 = z.number().int().min(0).max(65535).optional();
|
||||
|
||||
const formSchema = z.object({
|
||||
type: z.enum(CUSTOM_DNS_RECORD_TYPES),
|
||||
name: z.string().max(253),
|
||||
value: z.string().min(1),
|
||||
ttl: z.number().int().min(60).max(86400),
|
||||
priority: optionalUint16,
|
||||
weight: optionalUint16,
|
||||
port: optionalUint16,
|
||||
enabled: z.boolean()
|
||||
});
|
||||
|
||||
type FormValues = z.infer<typeof formSchema>;
|
||||
|
||||
type CustomDnsRecordCredenzaProps = {
|
||||
open: boolean;
|
||||
setOpen: (open: boolean) => void;
|
||||
orgId: string;
|
||||
domainId: string;
|
||||
baseDomain: string;
|
||||
// null to create a new record
|
||||
record: CustomDnsRecordRow | null;
|
||||
onSaved: () => void;
|
||||
};
|
||||
|
||||
export default function CustomDnsRecordCredenza({
|
||||
open,
|
||||
setOpen,
|
||||
orgId,
|
||||
domainId,
|
||||
baseDomain,
|
||||
record,
|
||||
onSaved
|
||||
}: CustomDnsRecordCredenzaProps) {
|
||||
const t = useTranslations();
|
||||
const api = createApiClient(useEnvContext());
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const form = useForm<FormValues>({
|
||||
resolver: zodResolver(formSchema),
|
||||
defaultValues: {
|
||||
type: "A",
|
||||
name: "",
|
||||
value: "",
|
||||
ttl: 300,
|
||||
enabled: true
|
||||
}
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
return;
|
||||
}
|
||||
form.reset({
|
||||
type: record?.type ?? "A",
|
||||
name: record?.name ?? "",
|
||||
value: record?.values.join("\n") ?? "",
|
||||
ttl: record?.ttl ?? 300,
|
||||
priority: record?.priority ?? undefined,
|
||||
weight: record?.weight ?? undefined,
|
||||
port: record?.port ?? undefined,
|
||||
enabled: record?.enabled ?? true
|
||||
});
|
||||
}, [open, record, form]);
|
||||
|
||||
const type = form.watch("type");
|
||||
const needsPriority = type === "MX" || type === "SRV";
|
||||
const isSrv = type === "SRV";
|
||||
|
||||
async function onSubmit(data: FormValues) {
|
||||
setLoading(true);
|
||||
// Each non-empty line is one value of the record
|
||||
const values = data.value
|
||||
.split("\n")
|
||||
.map((line) => line.trim())
|
||||
.filter((line) => line.length > 0);
|
||||
|
||||
const payload = {
|
||||
type: data.type,
|
||||
name: data.name,
|
||||
values,
|
||||
ttl: data.ttl,
|
||||
priority: needsPriority ? (data.priority ?? null) : null,
|
||||
weight: isSrv ? (data.weight ?? null) : null,
|
||||
port: isSrv ? (data.port ?? null) : null,
|
||||
enabled: data.enabled
|
||||
};
|
||||
|
||||
try {
|
||||
const res = record
|
||||
? await api.post(
|
||||
`/org/${orgId}/domain/${domainId}/custom-dns-record/${record.customDnsRecordId}`,
|
||||
payload
|
||||
)
|
||||
: await api.put(
|
||||
`/org/${orgId}/domain/${domainId}/custom-dns-record`,
|
||||
payload
|
||||
);
|
||||
|
||||
if (res.status === 200 || res.status === 201) {
|
||||
toast({
|
||||
title: record
|
||||
? t("customDnsRecordUpdated")
|
||||
: t("customDnsRecordCreated")
|
||||
});
|
||||
setOpen(false);
|
||||
onSaved();
|
||||
}
|
||||
} catch (e) {
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: t("customDnsRecordSaveError"),
|
||||
description: formatAxiosError(e)
|
||||
});
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
const numberField = (
|
||||
name: "ttl" | "priority" | "weight" | "port",
|
||||
label: string
|
||||
) => (
|
||||
<FormField
|
||||
control={form.control}
|
||||
name={name}
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex-1">
|
||||
<FormLabel>{label}</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
type="number"
|
||||
autoComplete="off"
|
||||
name={field.name}
|
||||
ref={field.ref}
|
||||
onBlur={field.onBlur}
|
||||
value={field.value ?? ""}
|
||||
onChange={(e) => {
|
||||
const v = e.target.value;
|
||||
field.onChange(
|
||||
v === "" ? undefined : Number(v)
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
|
||||
return (
|
||||
<Credenza open={open} onOpenChange={setOpen}>
|
||||
<CredenzaContent>
|
||||
<CredenzaHeader>
|
||||
<CredenzaTitle>
|
||||
{record
|
||||
? t("customDnsRecordEdit")
|
||||
: t("customDnsRecordCreate")}
|
||||
</CredenzaTitle>
|
||||
<CredenzaDescription>
|
||||
{t("customDnsRecordFormDescription", {
|
||||
domain: baseDomain
|
||||
})}
|
||||
</CredenzaDescription>
|
||||
</CredenzaHeader>
|
||||
<CredenzaBody>
|
||||
<Form {...form}>
|
||||
<form
|
||||
id={FORM_ID}
|
||||
onSubmit={form.handleSubmit(onSubmit)}
|
||||
className="space-y-4"
|
||||
>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="type"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t("type")}</FormLabel>
|
||||
<Select
|
||||
onValueChange={field.onChange}
|
||||
value={field.value}
|
||||
>
|
||||
<FormControl>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
</FormControl>
|
||||
<SelectContent>
|
||||
{CUSTOM_DNS_RECORD_TYPES.map(
|
||||
(recordType) => (
|
||||
<SelectItem
|
||||
key={recordType}
|
||||
value={recordType}
|
||||
>
|
||||
{recordType}
|
||||
</SelectItem>
|
||||
)
|
||||
)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="name"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t("name")}</FormLabel>
|
||||
<div className="flex items-center gap-2">
|
||||
<FormControl>
|
||||
<Input
|
||||
autoComplete="off"
|
||||
placeholder="@"
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<span className="text-sm text-muted-foreground whitespace-nowrap">
|
||||
.{baseDomain}
|
||||
</span>
|
||||
</div>
|
||||
<FormDescription>
|
||||
{t(
|
||||
"customDnsRecordNameDescription"
|
||||
)}
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="value"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t("value")}</FormLabel>
|
||||
<FormControl>
|
||||
{type !== "CNAME" ? (
|
||||
<Textarea
|
||||
autoComplete="off"
|
||||
placeholder={
|
||||
VALUE_PLACEHOLDERS[type]
|
||||
}
|
||||
{...field}
|
||||
/>
|
||||
) : (
|
||||
<Input
|
||||
autoComplete="off"
|
||||
placeholder={
|
||||
VALUE_PLACEHOLDERS[type]
|
||||
}
|
||||
{...field}
|
||||
/>
|
||||
)}
|
||||
</FormControl>
|
||||
{type !== "CNAME" && (
|
||||
<FormDescription>
|
||||
{t(
|
||||
"customDnsRecordValueDescription"
|
||||
)}
|
||||
</FormDescription>
|
||||
)}
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
{needsPriority && (
|
||||
<div className="flex gap-4">
|
||||
{numberField(
|
||||
"priority",
|
||||
t("customDnsRecordPriority")
|
||||
)}
|
||||
{isSrv &&
|
||||
numberField(
|
||||
"weight",
|
||||
t("customDnsRecordWeight")
|
||||
)}
|
||||
{isSrv &&
|
||||
numberField(
|
||||
"port",
|
||||
t("customDnsRecordPort")
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{numberField("ttl", t("TTL"))}
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="enabled"
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex flex-row items-center gap-3 space-y-0">
|
||||
<FormControl>
|
||||
<Switch
|
||||
checked={field.value}
|
||||
onCheckedChange={field.onChange}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormLabel className="!mt-0">
|
||||
{t("enabled")}
|
||||
</FormLabel>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</form>
|
||||
</Form>
|
||||
</CredenzaBody>
|
||||
<CredenzaFooter>
|
||||
<CredenzaClose asChild>
|
||||
<Button variant="outline">{t("close")}</Button>
|
||||
</CredenzaClose>
|
||||
<Button
|
||||
type="submit"
|
||||
form={FORM_ID}
|
||||
loading={loading}
|
||||
disabled={loading}
|
||||
>
|
||||
{t("save")}
|
||||
</Button>
|
||||
</CredenzaFooter>
|
||||
</CredenzaContent>
|
||||
</Credenza>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,326 @@
|
||||
"use client";
|
||||
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useState } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { AlertTriangle, ArrowUpDown, MoreHorizontal } from "lucide-react";
|
||||
import { Alert, AlertDescription, AlertTitle } from "@app/components/ui/alert";
|
||||
import { DataTable, ExtendedColumnDef } from "@app/components/ui/data-table";
|
||||
import { Button } from "@app/components/ui/button";
|
||||
import { Badge } from "@app/components/ui/badge";
|
||||
import { Switch } from "@app/components/ui/switch";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger
|
||||
} from "@app/components/ui/dropdown-menu";
|
||||
import CopyToClipboard from "@app/components/CopyToClipboard";
|
||||
import ConfirmDeleteDialog from "@app/components/ConfirmDeleteDialog";
|
||||
import CustomDnsRecordCredenza from "@app/components/CustomDnsRecordCredenza";
|
||||
import { PaidFeaturesAlert } from "@app/components/PaidFeaturesAlert";
|
||||
import { usePaidStatus } from "@app/hooks/usePaidStatus";
|
||||
import { tierMatrix } from "@server/lib/billing/tierMatrix";
|
||||
import { domainQueries } from "@app/lib/queries";
|
||||
import { useEnvContext } from "@app/hooks/useEnvContext";
|
||||
import { toast } from "@app/hooks/useToast";
|
||||
import { createApiClient, formatAxiosError } from "@app/lib/api";
|
||||
import { CustomDnsRecordRow } from "@server/routers/customDnsRecord/types";
|
||||
|
||||
type Props = {
|
||||
orgId: string;
|
||||
domainId: string;
|
||||
baseDomain: string;
|
||||
// The DNS server only serves custom records for verified domains
|
||||
verified: boolean;
|
||||
};
|
||||
|
||||
function formatValue(record: CustomDnsRecordRow, value: string): string {
|
||||
if (record.type === "MX") {
|
||||
return `${record.priority} ${value}`;
|
||||
}
|
||||
if (record.type === "SRV") {
|
||||
return `${record.priority} ${record.weight} ${record.port} ${value}`;
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
export default function CustomDnsRecordsTable({
|
||||
orgId,
|
||||
domainId,
|
||||
baseDomain,
|
||||
verified
|
||||
}: Props) {
|
||||
const t = useTranslations();
|
||||
const api = createApiClient(useEnvContext());
|
||||
const { isPaidUser } = usePaidStatus();
|
||||
const isDisabled = !isPaidUser(tierMatrix.customDnsRecords);
|
||||
|
||||
const {
|
||||
data: records = [],
|
||||
isRefetching,
|
||||
refetch
|
||||
} = useQuery(domainQueries.listCustomDnsRecords({ orgId, domainId }));
|
||||
|
||||
const [editorOpen, setEditorOpen] = useState(false);
|
||||
const [editingRecord, setEditingRecord] =
|
||||
useState<CustomDnsRecordRow | null>(null);
|
||||
const [deleteOpen, setDeleteOpen] = useState(false);
|
||||
const [deletingRecord, setDeletingRecord] =
|
||||
useState<CustomDnsRecordRow | null>(null);
|
||||
|
||||
function openEditor(record: CustomDnsRecordRow | null) {
|
||||
setEditingRecord(record);
|
||||
setEditorOpen(true);
|
||||
}
|
||||
|
||||
async function toggleEnabled(record: CustomDnsRecordRow, enabled: boolean) {
|
||||
try {
|
||||
await api.post(
|
||||
`/org/${orgId}/domain/${domainId}/custom-dns-record/${record.customDnsRecordId}`,
|
||||
{ enabled }
|
||||
);
|
||||
refetch();
|
||||
} catch (e) {
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: t("customDnsRecordSaveError"),
|
||||
description: formatAxiosError(e)
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteRecord() {
|
||||
if (!deletingRecord) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await api.delete(
|
||||
`/org/${orgId}/domain/${domainId}/custom-dns-record/${deletingRecord.customDnsRecordId}`
|
||||
);
|
||||
toast({ title: t("customDnsRecordDeleted") });
|
||||
setDeleteOpen(false);
|
||||
setDeletingRecord(null);
|
||||
refetch();
|
||||
} catch (e) {
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: t("customDnsRecordDeleteError"),
|
||||
description: formatAxiosError(e)
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const columns: ExtendedColumnDef<CustomDnsRecordRow>[] = [
|
||||
{
|
||||
accessorKey: "fullDomain",
|
||||
enableHiding: false,
|
||||
friendlyName: t("name"),
|
||||
header: ({ column }) => {
|
||||
return (
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={() =>
|
||||
column.toggleSorting(column.getIsSorted() === "asc")
|
||||
}
|
||||
>
|
||||
{t("name")}
|
||||
<ArrowUpDown className="ml-2 h-4 w-4" />
|
||||
</Button>
|
||||
);
|
||||
},
|
||||
cell: ({ row }) => {
|
||||
const fullDomain = row.original.fullDomain;
|
||||
return (
|
||||
<CopyToClipboard
|
||||
text={fullDomain}
|
||||
displayText={fullDomain}
|
||||
isLink={false}
|
||||
/>
|
||||
);
|
||||
}
|
||||
},
|
||||
{
|
||||
accessorKey: "type",
|
||||
friendlyName: t("type"),
|
||||
header: ({ column }) => {
|
||||
return (
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={() =>
|
||||
column.toggleSorting(column.getIsSorted() === "asc")
|
||||
}
|
||||
>
|
||||
{t("type")}
|
||||
<ArrowUpDown className="ml-2 h-4 w-4" />
|
||||
</Button>
|
||||
);
|
||||
},
|
||||
cell: ({ row }) => {
|
||||
return <Badge variant="secondary">{row.original.type}</Badge>;
|
||||
}
|
||||
},
|
||||
{
|
||||
accessorKey: "values",
|
||||
friendlyName: t("value"),
|
||||
header: () => <span className="p-3">{t("value")}</span>,
|
||||
cell: ({ row }) => {
|
||||
const record = row.original;
|
||||
return (
|
||||
<div className="flex flex-col gap-1 max-w-md">
|
||||
{record.values.map((value) => (
|
||||
<span key={value} className="break-all">
|
||||
{formatValue(record, value)}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
},
|
||||
{
|
||||
accessorKey: "ttl",
|
||||
friendlyName: t("TTL"),
|
||||
header: ({ column }) => {
|
||||
return (
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={() =>
|
||||
column.toggleSorting(column.getIsSorted() === "asc")
|
||||
}
|
||||
>
|
||||
{t("TTL")}
|
||||
<ArrowUpDown className="ml-2 h-4 w-4" />
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
},
|
||||
{
|
||||
accessorKey: "enabled",
|
||||
friendlyName: t("enabled"),
|
||||
header: () => <span className="p-3">{t("enabled")}</span>,
|
||||
cell: ({ row }) => {
|
||||
const record = row.original;
|
||||
return (
|
||||
<Switch
|
||||
checked={record.enabled}
|
||||
disabled={isDisabled}
|
||||
onCheckedChange={(checked) =>
|
||||
toggleEnabled(record, checked)
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
},
|
||||
{
|
||||
id: "actions",
|
||||
enableHiding: false,
|
||||
header: () => <span className="p-3"></span>,
|
||||
cell: ({ row }) => {
|
||||
const record = row.original;
|
||||
return (
|
||||
<div className="flex items-center gap-2 justify-end">
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" className="h-8 w-8 p-0">
|
||||
<span className="sr-only">
|
||||
{t("openMenu")}
|
||||
</span>
|
||||
<MoreHorizontal className="h-4 w-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem
|
||||
disabled={isDisabled}
|
||||
onClick={() => {
|
||||
setDeletingRecord(record);
|
||||
setDeleteOpen(true);
|
||||
}}
|
||||
>
|
||||
<span className="text-red-500">
|
||||
{t("delete")}
|
||||
</span>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
<Button
|
||||
variant="outline"
|
||||
disabled={isDisabled}
|
||||
onClick={() => openEditor(record)}
|
||||
>
|
||||
{t("edit")}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
];
|
||||
|
||||
return (
|
||||
<>
|
||||
<PaidFeaturesAlert tiers={tierMatrix.customDnsRecords} />
|
||||
|
||||
{!verified && (
|
||||
<Alert variant="warning">
|
||||
<AlertTriangle className="h-4 w-4" />
|
||||
<AlertTitle>
|
||||
{t("customDnsRecordsUnverifiedTitle")}
|
||||
</AlertTitle>
|
||||
<AlertDescription>
|
||||
{t("customDnsRecordsUnverifiedDescription")}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{deletingRecord && !isDisabled && (
|
||||
<ConfirmDeleteDialog
|
||||
open={deleteOpen}
|
||||
setOpen={(open) => {
|
||||
setDeleteOpen(open);
|
||||
if (!open) {
|
||||
setDeletingRecord(null);
|
||||
}
|
||||
}}
|
||||
string={deletingRecord.fullDomain}
|
||||
title={t("customDnsRecordDeleteTitle")}
|
||||
dialog={
|
||||
<p>
|
||||
{t("customDnsRecordDeleteQuestion", {
|
||||
type: deletingRecord.type,
|
||||
name: deletingRecord.fullDomain
|
||||
})}
|
||||
</p>
|
||||
}
|
||||
buttonText={t("customDnsRecordDeleteConfirm")}
|
||||
onConfirm={deleteRecord}
|
||||
/>
|
||||
)}
|
||||
|
||||
<CustomDnsRecordCredenza
|
||||
open={editorOpen && !isDisabled}
|
||||
setOpen={setEditorOpen}
|
||||
orgId={orgId}
|
||||
domainId={domainId}
|
||||
baseDomain={baseDomain}
|
||||
record={editingRecord}
|
||||
onSaved={() => refetch()}
|
||||
/>
|
||||
|
||||
<DataTable
|
||||
columns={columns}
|
||||
data={records}
|
||||
persistPageSize="custom-dns-records-table"
|
||||
title={t("customDnsRecords")}
|
||||
searchPlaceholder={t("customDnsRecordsSearch")}
|
||||
searchColumn="fullDomain"
|
||||
addButtonText={t("customDnsRecordsAdd")}
|
||||
onAdd={() => openEditor(null)}
|
||||
addButtonDisabled={isDisabled}
|
||||
onRefresh={refetch}
|
||||
isRefreshing={isRefetching}
|
||||
enableColumnVisibility={true}
|
||||
stickyLeftColumn="fullDomain"
|
||||
stickyRightColumn="actions"
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -10,6 +10,7 @@ import RestartDomainButton from "@app/components/RestartDomainButton";
|
||||
import RefreshButton from "@app/components/RefreshButton";
|
||||
import SettingsSectionTitle from "@app/components/SettingsSectionTitle";
|
||||
import DomainCertForm from "@app/components/DomainCertForm";
|
||||
import CustomDnsRecordsTable from "@app/components/CustomDnsRecordsTable";
|
||||
import { build } from "@server/build";
|
||||
import { useEnvContext } from "@app/hooks/useEnvContext";
|
||||
import { useTranslations } from "next-intl";
|
||||
@@ -97,6 +98,18 @@ export default function DomainPageClient({
|
||||
type={domain.type}
|
||||
/>
|
||||
|
||||
{build !== "oss" &&
|
||||
env.flags.usePangolinDns &&
|
||||
!env.flags.disableEnterpriseFeatures &&
|
||||
domain.type === "ns" && (
|
||||
<CustomDnsRecordsTable
|
||||
orgId={orgId}
|
||||
domainId={domain.domainId}
|
||||
baseDomain={domain.baseDomain}
|
||||
verified={domain.verified}
|
||||
/>
|
||||
)}
|
||||
|
||||
{domain.type === "wildcard" && !domain.configManaged && (
|
||||
<DomainCertForm
|
||||
orgId={orgId}
|
||||
|
||||
@@ -47,7 +47,11 @@ function getActionsCategories(root: boolean) {
|
||||
[t("actionUpdateOrgDomain")]: "updateOrgDomain",
|
||||
[t("actionDeleteOrgDomain")]: "deleteOrgDomain",
|
||||
[t("actionGetDNSRecords")]: "getDNSRecords",
|
||||
[t("actionRestartOrgDomain")]: "restartOrgDomain"
|
||||
[t("actionRestartOrgDomain")]: "restartOrgDomain",
|
||||
[t("actionListCustomDnsRecords")]: "listCustomDnsRecords",
|
||||
[t("actionCreateCustomDnsRecord")]: "createCustomDnsRecord",
|
||||
[t("actionUpdateCustomDnsRecord")]: "updateCustomDnsRecord",
|
||||
[t("actionDeleteCustomDnsRecord")]: "deleteCustomDnsRecord"
|
||||
},
|
||||
|
||||
Redirect: {
|
||||
|
||||
@@ -118,6 +118,9 @@ const booleanSearchFilterSchema = z
|
||||
|
||||
const RESOURCE_STATUS_HISTORY_DAYS = 30;
|
||||
|
||||
// Modes whose targets can have health checks, so health and uptime apply
|
||||
const HEALTH_CHECK_MODES = ["http", "tcp", "udp"];
|
||||
|
||||
export default function PublicResourcesTable({
|
||||
resources,
|
||||
orgId,
|
||||
@@ -149,9 +152,12 @@ export default function PublicResourcesTable({
|
||||
const [isRefreshing, startTransition] = useTransition();
|
||||
const [isNavigatingToAddPage, startNavigation] = useTransition();
|
||||
|
||||
// Only http resources show an uptime bar, so don't ask for the others
|
||||
// Only health-checked resources show an uptime bar, so don't ask for the others
|
||||
const statusHistoryResourceIds = useMemo(
|
||||
() => resources.filter((r) => r.mode === "http").map((r) => r.id),
|
||||
() =>
|
||||
resources
|
||||
.filter((r) => HEALTH_CHECK_MODES.includes(r.mode || ""))
|
||||
.map((r) => r.id),
|
||||
[resources]
|
||||
);
|
||||
|
||||
@@ -391,7 +397,7 @@ export default function PublicResourcesTable({
|
||||
),
|
||||
cell: ({ row }) => {
|
||||
const resourceRow = row.original;
|
||||
if (resourceRow.mode !== "http") {
|
||||
if (!HEALTH_CHECK_MODES.includes(resourceRow.mode || "")) {
|
||||
return <span>-</span>;
|
||||
}
|
||||
return (
|
||||
@@ -422,7 +428,7 @@ export default function PublicResourcesTable({
|
||||
header: () => <span className="p-3">{t("uptime30d")}</span>,
|
||||
cell: ({ row }) => {
|
||||
const resourceRow = row.original;
|
||||
if (resourceRow.mode !== "http") {
|
||||
if (!HEALTH_CHECK_MODES.includes(resourceRow.mode || "")) {
|
||||
return <span>-</span>;
|
||||
}
|
||||
return (
|
||||
|
||||
@@ -37,6 +37,7 @@ import {
|
||||
import { UseFormReturn, useForm, useWatch } from "react-hook-form";
|
||||
import { useResourcePolicyContext } from "@app/providers/ResourcePolicyProvider";
|
||||
import { createApiClient, formatAxiosError } from "@app/lib/api";
|
||||
import { cn } from "@app/lib/cn";
|
||||
import { useEnvContext } from "@app/hooks/useEnvContext";
|
||||
import { resourceQueries } from "@app/lib/queries";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
@@ -174,7 +175,10 @@ function PolicyAccessRulesSectionLayout({
|
||||
resourceOverlayMode={resourceOverlayMode}
|
||||
emptyStateAction={addRuleButton}
|
||||
/>
|
||||
{hasRules && addRuleButton}
|
||||
{/* The empty state (and its add button) is hidden on mobile, so always show the add button there */}
|
||||
<div className={cn(!hasRules && "sm:hidden")}>
|
||||
{addRuleButton}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -43,6 +43,7 @@ import type {
|
||||
ListDomainsResponse
|
||||
} from "@server/routers/domain";
|
||||
import type { GetDomainResponse } from "@server/routers/domain/getDomain";
|
||||
import type { ListCustomDnsRecordsResponse } from "@server/routers/customDnsRecord/types";
|
||||
import { ListHealthChecksResponse } from "@server/routers/healthChecks/types";
|
||||
import type { ListOrgLabelsResponse } from "@server/routers/labels/types";
|
||||
import type { ListLauncherAiModelsResponse } from "@server/routers/launcher/listLauncherAiModels";
|
||||
@@ -1878,6 +1879,30 @@ export const domainQueries = {
|
||||
return res.data.data;
|
||||
},
|
||||
refetchInterval: durationToMs(10, "seconds")
|
||||
}),
|
||||
listCustomDnsRecords: ({
|
||||
orgId,
|
||||
domainId
|
||||
}: {
|
||||
orgId: string;
|
||||
domainId: string;
|
||||
}) =>
|
||||
queryOptions({
|
||||
queryKey: [
|
||||
"ORG",
|
||||
orgId,
|
||||
"DOMAIN",
|
||||
domainId,
|
||||
"CUSTOM_DNS_RECORDS"
|
||||
] as const,
|
||||
queryFn: async ({ signal, meta }) => {
|
||||
const res = await meta!.api.get<
|
||||
AxiosResponse<ListCustomDnsRecordsResponse>
|
||||
>(`/org/${orgId}/domain/${domainId}/custom-dns-records`, {
|
||||
signal
|
||||
});
|
||||
return res.data.data.records;
|
||||
}
|
||||
})
|
||||
};
|
||||
|
||||
|
||||
+14
-1
@@ -53,8 +53,21 @@ if (process.env.TRACE_VERBOSE) {
|
||||
);
|
||||
}
|
||||
|
||||
// Packages whose static assets are served from disk at runtime (e.g. via
|
||||
// express.static) rather than required, so the tracer cannot see them
|
||||
const assetPackages = ["swagger-ui-dist"];
|
||||
const files = new Set(fileList);
|
||||
for (const pkg of assetPackages) {
|
||||
const pkgDir = path.join("node_modules", pkg);
|
||||
for (const entry of fs.readdirSync(path.join(base, pkgDir), {
|
||||
recursive: true
|
||||
})) {
|
||||
if (!entry.endsWith(".map")) files.add(path.join(pkgDir, entry));
|
||||
}
|
||||
}
|
||||
|
||||
let count = 0;
|
||||
for (const file of fileList) {
|
||||
for (const file of files) {
|
||||
if (!file.startsWith("node_modules/")) continue;
|
||||
|
||||
const src = path.join(base, file);
|
||||
|
||||
Reference in New Issue
Block a user