mirror of
https://github.com/fosrl/pangolin.git
synced 2026-04-10 03:46:37 +00:00
Compare commits
113 Commits
revert-276
...
private-ht
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8a47d69d0d | ||
|
|
73482c2a05 | ||
|
|
79751c208d | ||
|
|
510931e7d6 | ||
|
|
584a8e7d1d | ||
|
|
a74378e1d3 | ||
|
|
c027c8958b | ||
|
|
a730f4da1d | ||
|
|
d73796b92e | ||
|
|
e4cbf088b4 | ||
|
|
333ccb8438 | ||
|
|
eb771ceda4 | ||
|
|
1efd2af44b | ||
|
|
466f137590 | ||
|
|
28ef5238c9 | ||
|
|
d948d2ec33 | ||
|
|
6b8a3c8d77 | ||
|
|
ba9794c067 | ||
|
|
eb4b2daaab | ||
|
|
8cbc8dec89 | ||
|
|
e89e60d50b | ||
|
|
c45308f234 | ||
|
|
40205c40c5 | ||
|
|
f3fe2dd33b | ||
|
|
8edcc45033 | ||
|
|
91471a4aca | ||
|
|
ae2c37a2f6 | ||
|
|
c8208f0a88 | ||
|
|
e11dfbd29c | ||
|
|
b375d20598 | ||
|
|
c4b82c69f8 | ||
|
|
c9a00420a0 | ||
|
|
36ef9cd442 | ||
|
|
5e08779ab0 | ||
|
|
16a0e1ce7b | ||
|
|
8b03484ade | ||
|
|
9da9974adf | ||
|
|
6f80cf3db2 | ||
|
|
76d8f44779 | ||
|
|
700c92efcb | ||
|
|
d17e0c9f50 | ||
|
|
f00b9794f5 | ||
|
|
daff59c93f | ||
|
|
aa8954366c | ||
|
|
87464d53bd | ||
|
|
e04f17c9aa | ||
|
|
b25e3499d8 | ||
|
|
2e6f74a6f8 | ||
|
|
8eee0ca5a5 | ||
|
|
c2ebc0a0ff | ||
|
|
03c905a7af | ||
|
|
8ce45a1acd | ||
|
|
a331dd3fb4 | ||
|
|
e3e2938b28 | ||
|
|
73e96b1b28 | ||
|
|
b8194295ec | ||
|
|
382a46dfff | ||
|
|
fee780cb81 | ||
|
|
5056cba85d | ||
|
|
dab38ff82c | ||
|
|
d83fa63af5 | ||
|
|
d5837ab718 | ||
|
|
f85cfc4c68 | ||
|
|
0b2aceafe0 | ||
|
|
059db34a53 | ||
|
|
bc1ea86b4e | ||
|
|
9f2ced1933 | ||
|
|
013cff9b6e | ||
|
|
aa19437031 | ||
|
|
e848ef848b | ||
|
|
bb6605337f | ||
|
|
8df8383468 | ||
|
|
a7e9de3ac4 | ||
|
|
8df41f514e | ||
|
|
c2bf50b121 | ||
|
|
4e7dcbd7b5 | ||
|
|
b7ccb92236 | ||
|
|
23a151dd45 | ||
|
|
122079ddb2 | ||
|
|
1d0b0ae6ec | ||
|
|
f1a0bc97e3 | ||
|
|
a57dfd1d12 | ||
|
|
c0a8304b91 | ||
|
|
ab7b968e28 | ||
|
|
f10b40c3b0 | ||
|
|
7878ac9c76 | ||
|
|
0752951842 | ||
|
|
06bb6636a1 | ||
|
|
2fdd332a31 | ||
|
|
98b1e9546a | ||
|
|
184aa65c6d | ||
|
|
70b3a432a4 | ||
|
|
fb4fc75bd8 | ||
|
|
0479ed9e7f | ||
|
|
8f3fbb94d2 | ||
|
|
e8c35bec1c | ||
|
|
728e7252eb | ||
|
|
1218507f7d | ||
|
|
a2dff0a35d | ||
|
|
f411180908 | ||
|
|
231a19b679 | ||
|
|
58a87a986a | ||
|
|
61a78ef352 | ||
|
|
e28e5ebb4e | ||
|
|
19cef8c453 | ||
|
|
1290d6cd5c | ||
|
|
ad301074db | ||
|
|
30a756d254 | ||
|
|
0fc1aa9191 | ||
|
|
ddf417f4ca | ||
|
|
d08be59055 | ||
|
|
322c136d1f | ||
|
|
e06f2f47b1 |
1
.github/CODEOWNERS
vendored
Normal file
1
.github/CODEOWNERS
vendored
Normal file
@@ -0,0 +1 @@
|
|||||||
|
* @oschwartz10612 @miloschwartz
|
||||||
@@ -86,6 +86,8 @@ entryPoints:
|
|||||||
http:
|
http:
|
||||||
tls:
|
tls:
|
||||||
certResolver: "letsencrypt"
|
certResolver: "letsencrypt"
|
||||||
|
middlewares:
|
||||||
|
- crowdsec@file
|
||||||
encodedCharacters:
|
encodedCharacters:
|
||||||
allowEncodedSlash: true
|
allowEncodedSlash: true
|
||||||
allowEncodedQuestionMark: true
|
allowEncodedQuestionMark: true
|
||||||
|
|||||||
@@ -371,10 +371,10 @@
|
|||||||
"provisioningKeysUpdated": "Ключът за осигуряване е актуализиран",
|
"provisioningKeysUpdated": "Ключът за осигуряване е актуализиран",
|
||||||
"provisioningKeysUpdatedDescription": "Вашите промени бяха запазени.",
|
"provisioningKeysUpdatedDescription": "Вашите промени бяха запазени.",
|
||||||
"provisioningKeysBannerTitle": "Ключове за осигуряване на сайта",
|
"provisioningKeysBannerTitle": "Ключове за осигуряване на сайта",
|
||||||
"provisioningKeysBannerDescription": "Генерирайте ключ за осигуряване и го използвайте с Newt конектора за автоматично създаване на сайтове при първото стартиране — няма нужда от създаване на отделни идентификационни данни за всеки сайт.",
|
"provisioningKeysBannerDescription": "Генерирайте ключ за осигуряване и го използвайте със съединителя Newt за автоматично създаване на сайтове при първоначално стартиране - не е необходимо да се създават отделни идентификационни данни за всеки сайт.",
|
||||||
"provisioningKeysBannerButtonText": "Научете повече",
|
"provisioningKeysBannerButtonText": "Научете повече",
|
||||||
"pendingSitesBannerTitle": "Чакащи сайтове",
|
"pendingSitesBannerTitle": "Чакащи сайтове",
|
||||||
"pendingSitesBannerDescription": "Сайтовете, които се свързват чрез ключ за осигуряване, се появяват тук за преглед. Одобрете всеки сайт, преди да стане активен и да получи достъп до вашите ресурси.",
|
"pendingSitesBannerDescription": "Сайтовете, които се свързват с ключ за осигуряване, ще се появят тук за преглед.",
|
||||||
"pendingSitesBannerButtonText": "Научете повече",
|
"pendingSitesBannerButtonText": "Научете повече",
|
||||||
"apiKeysSettings": "Настройки на {apiKeyName}",
|
"apiKeysSettings": "Настройки на {apiKeyName}",
|
||||||
"userTitle": "Управление на всички потребители",
|
"userTitle": "Управление на всички потребители",
|
||||||
@@ -624,6 +624,8 @@
|
|||||||
"targetErrorInvalidPortDescription": "Моля, въведете валиден номер на порт",
|
"targetErrorInvalidPortDescription": "Моля, въведете валиден номер на порт",
|
||||||
"targetErrorNoSite": "Няма избран сайт",
|
"targetErrorNoSite": "Няма избран сайт",
|
||||||
"targetErrorNoSiteDescription": "Моля, изберете сайт за целта",
|
"targetErrorNoSiteDescription": "Моля, изберете сайт за целта",
|
||||||
|
"targetTargetsCleared": "Мишените са премахнати",
|
||||||
|
"targetTargetsClearedDescription": "Всички цели са били премахнати от този ресурс",
|
||||||
"targetCreated": "Целта е създадена",
|
"targetCreated": "Целта е създадена",
|
||||||
"targetCreatedDescription": "Целта беше успешно създадена",
|
"targetCreatedDescription": "Целта беше успешно създадена",
|
||||||
"targetErrorCreate": "Неуспешно създаване на целта",
|
"targetErrorCreate": "Неуспешно създаване на целта",
|
||||||
@@ -2346,7 +2348,7 @@
|
|||||||
"description": "Предприятие, 50 потребители, 50 сайта и приоритетна поддръжка."
|
"description": "Предприятие, 50 потребители, 50 сайта и приоритетна поддръжка."
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"personalUseOnly": "Само за лична употреба (безплатен лиценз — без плащане)",
|
"personalUseOnly": "Само за лична употреба (безплатен лиценз - без проверка)",
|
||||||
"buttons": {
|
"buttons": {
|
||||||
"continueToCheckout": "Продължете към плащане"
|
"continueToCheckout": "Продължете към плащане"
|
||||||
},
|
},
|
||||||
@@ -2607,6 +2609,9 @@
|
|||||||
"machineClients": "Машинни клиенти",
|
"machineClients": "Машинни клиенти",
|
||||||
"install": "Инсталирай",
|
"install": "Инсталирай",
|
||||||
"run": "Изпълни",
|
"run": "Изпълни",
|
||||||
|
"envFile": "Файл за среда",
|
||||||
|
"serviceFile": "Файл за услуга",
|
||||||
|
"enableAndStart": "Активиране и стартиране",
|
||||||
"clientNameDescription": "Показваното име на клиента, което може да се промени по-късно.",
|
"clientNameDescription": "Показваното име на клиента, което може да се промени по-късно.",
|
||||||
"clientAddress": "Клиентски адрес (Разширено)",
|
"clientAddress": "Клиентски адрес (Разширено)",
|
||||||
"setupFailedToFetchSubnet": "Неуспешно извличане на подмрежа по подразбиране",
|
"setupFailedToFetchSubnet": "Неуспешно извличане на подмрежа по подразбиране",
|
||||||
@@ -2845,10 +2850,10 @@
|
|||||||
"httpDestAuthNoneTitle": "Без удостоверяване",
|
"httpDestAuthNoneTitle": "Без удостоверяване",
|
||||||
"httpDestAuthNoneDescription": "Изпращане на заявки без заглавие за удостоверяване.",
|
"httpDestAuthNoneDescription": "Изпращане на заявки без заглавие за удостоверяване.",
|
||||||
"httpDestAuthBearerTitle": "Bearer Токен",
|
"httpDestAuthBearerTitle": "Bearer Токен",
|
||||||
"httpDestAuthBearerDescription": "Добавя заглавие за удостоверяване Bearer <token> към всяка заявка.",
|
"httpDestAuthBearerDescription": "Добавя заглавие Authorization: Bearer '<token>' към всяка заявка.",
|
||||||
"httpDestAuthBearerPlaceholder": "Вашият API ключ или токен",
|
"httpDestAuthBearerPlaceholder": "Вашият API ключ или токен",
|
||||||
"httpDestAuthBasicTitle": "Основно удостоверяване",
|
"httpDestAuthBasicTitle": "Основно удостоверяване",
|
||||||
"httpDestAuthBasicDescription": "Добавя заглавие за удостоверяване Basic <credentials> към всяка заявка. Осигурете идентификационни данни като потребителско име:парола.",
|
"httpDestAuthBasicDescription": "Добавя заглавие Authorization: Basic '<credentials>'. Осигурете идентификационни данни като потребителско име:парола.",
|
||||||
"httpDestAuthBasicPlaceholder": "потребителско име:парола",
|
"httpDestAuthBasicPlaceholder": "потребителско име:парола",
|
||||||
"httpDestAuthCustomTitle": "Персонализирано заглавие",
|
"httpDestAuthCustomTitle": "Персонализирано заглавие",
|
||||||
"httpDestAuthCustomDescription": "Посочете персонализирано име и стойност на заглавието за удостоверяване (например X-API-Key).",
|
"httpDestAuthCustomDescription": "Посочете персонализирано име и стойност на заглавието за удостоверяване (например X-API-Key).",
|
||||||
|
|||||||
@@ -371,10 +371,10 @@
|
|||||||
"provisioningKeysUpdated": "Zajišťovací klíč byl aktualizován",
|
"provisioningKeysUpdated": "Zajišťovací klíč byl aktualizován",
|
||||||
"provisioningKeysUpdatedDescription": "Vaše změny byly uloženy.",
|
"provisioningKeysUpdatedDescription": "Vaše změny byly uloženy.",
|
||||||
"provisioningKeysBannerTitle": "Klíče pro poskytování webu",
|
"provisioningKeysBannerTitle": "Klíče pro poskytování webu",
|
||||||
"provisioningKeysBannerDescription": "Vygenerujte konfigurační klíč a používejte jej pomocí nového konektoru k automatickému vytváření stránek při prvním startu – není třeba nastavovat samostatné přihlašovací údaje pro každý web.",
|
"provisioningKeysBannerDescription": "Vygenerujte klíč pro zřízení a použijte ho s Newt konektorem k automatickému vytvoření stránek při prvním spuštění – není potřeba nastavit samostatné přihlašovací údaje pro každou stránku.",
|
||||||
"provisioningKeysBannerButtonText": "Zjistit více",
|
"provisioningKeysBannerButtonText": "Zjistit více",
|
||||||
"pendingSitesBannerTitle": "Nevyřízené weby",
|
"pendingSitesBannerTitle": "Nevyřízené weby",
|
||||||
"pendingSitesBannerDescription": "Zde se zobrazují stránky, které se připojují pomocí doplňovacího klíče. Schválte každý web předtím, než bude aktivní, a získejte přístup k vašim zdrojům.",
|
"pendingSitesBannerDescription": "Stránky, které se připojují pomocí klíče pro zřízení, se zde objeví ke kontrole.",
|
||||||
"pendingSitesBannerButtonText": "Zjistit více",
|
"pendingSitesBannerButtonText": "Zjistit více",
|
||||||
"apiKeysSettings": "Nastavení {apiKeyName}",
|
"apiKeysSettings": "Nastavení {apiKeyName}",
|
||||||
"userTitle": "Spravovat všechny uživatele",
|
"userTitle": "Spravovat všechny uživatele",
|
||||||
@@ -624,6 +624,8 @@
|
|||||||
"targetErrorInvalidPortDescription": "Zadejte platné číslo portu",
|
"targetErrorInvalidPortDescription": "Zadejte platné číslo portu",
|
||||||
"targetErrorNoSite": "Není vybrán žádný web",
|
"targetErrorNoSite": "Není vybrán žádný web",
|
||||||
"targetErrorNoSiteDescription": "Vyberte prosím web pro cíl",
|
"targetErrorNoSiteDescription": "Vyberte prosím web pro cíl",
|
||||||
|
"targetTargetsCleared": "Cíle vymazány",
|
||||||
|
"targetTargetsClearedDescription": "Všechny cíle byly odstraněny z tohoto zdroje",
|
||||||
"targetCreated": "Cíl byl vytvořen",
|
"targetCreated": "Cíl byl vytvořen",
|
||||||
"targetCreatedDescription": "Cíl byl úspěšně vytvořen",
|
"targetCreatedDescription": "Cíl byl úspěšně vytvořen",
|
||||||
"targetErrorCreate": "Nepodařilo se vytvořit cíl",
|
"targetErrorCreate": "Nepodařilo se vytvořit cíl",
|
||||||
@@ -2346,7 +2348,7 @@
|
|||||||
"description": "Podnikové funkce, 50 uživatelů, 50 míst a prioritní podpory."
|
"description": "Podnikové funkce, 50 uživatelů, 50 míst a prioritní podpory."
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"personalUseOnly": "Pouze osobní použití (bezplatná licence – bez platby)",
|
"personalUseOnly": "Pouze pro osobní použití (zdarma licence - bez ověření)",
|
||||||
"buttons": {
|
"buttons": {
|
||||||
"continueToCheckout": "Pokračovat do pokladny"
|
"continueToCheckout": "Pokračovat do pokladny"
|
||||||
},
|
},
|
||||||
@@ -2607,6 +2609,9 @@
|
|||||||
"machineClients": "Strojoví klienti",
|
"machineClients": "Strojoví klienti",
|
||||||
"install": "Instalovat",
|
"install": "Instalovat",
|
||||||
"run": "Spustit",
|
"run": "Spustit",
|
||||||
|
"envFile": "Konfigurační soubor prostředí",
|
||||||
|
"serviceFile": "Služební soubor",
|
||||||
|
"enableAndStart": "Povolit a spustit",
|
||||||
"clientNameDescription": "Zobrazované jméno klienta, které lze později změnit.",
|
"clientNameDescription": "Zobrazované jméno klienta, které lze později změnit.",
|
||||||
"clientAddress": "Adresa klienta (Rozšířeno)",
|
"clientAddress": "Adresa klienta (Rozšířeno)",
|
||||||
"setupFailedToFetchSubnet": "Nepodařilo se načíst výchozí podsíť",
|
"setupFailedToFetchSubnet": "Nepodařilo se načíst výchozí podsíť",
|
||||||
@@ -2845,10 +2850,10 @@
|
|||||||
"httpDestAuthNoneTitle": "Žádné ověření",
|
"httpDestAuthNoneTitle": "Žádné ověření",
|
||||||
"httpDestAuthNoneDescription": "Odešle žádosti bez záhlaví autorizace.",
|
"httpDestAuthNoneDescription": "Odešle žádosti bez záhlaví autorizace.",
|
||||||
"httpDestAuthBearerTitle": "Token na doručitele",
|
"httpDestAuthBearerTitle": "Token na doručitele",
|
||||||
"httpDestAuthBearerDescription": "Přidá autorizaci: Hlavička Bearer <token> ke každému požadavku.",
|
"httpDestAuthBearerDescription": "Přidává hlavičku Authorization: Bearer '<token>' k každému požadavku.",
|
||||||
"httpDestAuthBearerPlaceholder": "Váš API klíč nebo token",
|
"httpDestAuthBearerPlaceholder": "Váš API klíč nebo token",
|
||||||
"httpDestAuthBasicTitle": "Základní ověření",
|
"httpDestAuthBasicTitle": "Základní ověření",
|
||||||
"httpDestAuthBasicDescription": "Přidá autorizaci: Základní <credentials> hlavička. Poskytněte přihlašovací údaje jako uživatelské jméno:password.",
|
"httpDestAuthBasicDescription": "Přidává hlavičku Authorization: Basic '<credentials>'. Poskytněte přihlašovací údaje ve formátu uživatelské jméno:heslo.",
|
||||||
"httpDestAuthBasicPlaceholder": "uživatelské jméno:heslo",
|
"httpDestAuthBasicPlaceholder": "uživatelské jméno:heslo",
|
||||||
"httpDestAuthCustomTitle": "Vlastní záhlaví",
|
"httpDestAuthCustomTitle": "Vlastní záhlaví",
|
||||||
"httpDestAuthCustomDescription": "Zadejte název a hodnotu vlastního HTTP hlavičky pro ověření (např. X-API-Key).",
|
"httpDestAuthCustomDescription": "Zadejte název a hodnotu vlastního HTTP hlavičky pro ověření (např. X-API-Key).",
|
||||||
|
|||||||
@@ -371,10 +371,10 @@
|
|||||||
"provisioningKeysUpdated": "Bereitstellungsschlüssel aktualisiert",
|
"provisioningKeysUpdated": "Bereitstellungsschlüssel aktualisiert",
|
||||||
"provisioningKeysUpdatedDescription": "Ihre Änderungen wurden gespeichert.",
|
"provisioningKeysUpdatedDescription": "Ihre Änderungen wurden gespeichert.",
|
||||||
"provisioningKeysBannerTitle": "Website-Bereitstellungsschlüssel",
|
"provisioningKeysBannerTitle": "Website-Bereitstellungsschlüssel",
|
||||||
"provisioningKeysBannerDescription": "Generieren Sie einen Bereitstellungsschlüssel und verwenden Sie ihn mit dem Newt-Konnektor, um beim ersten Start automatisch Sites zu erstellen – keine Notwendigkeit, separate Anmeldeinformationen für jede Seite einzurichten.",
|
"provisioningKeysBannerDescription": "Generieren Sie einen Bereitstellungsschlüssel und verwenden Sie ihn mit dem Newt-Connector, um Standorte beim ersten Start automatisch zu erstellen - keine Notwendigkeit, separate Anmeldedaten für jede Seite einzurichten.",
|
||||||
"provisioningKeysBannerButtonText": "Mehr erfahren",
|
"provisioningKeysBannerButtonText": "Mehr erfahren",
|
||||||
"pendingSitesBannerTitle": "Ausstehende Seiten",
|
"pendingSitesBannerTitle": "Ausstehende Seiten",
|
||||||
"pendingSitesBannerDescription": "Sites, die sich mit einem Bereitstellungsschlüssel verbinden, erscheinen hier zur Überprüfung. Bestätigen Sie jede Site, bevor sie aktiv wird und erhalten Zugriff auf Ihre Ressourcen.",
|
"pendingSitesBannerDescription": "Websites, die mit einem Bereitstellungsschlüssel verbunden sind, erscheinen hier zur Überprüfung.",
|
||||||
"pendingSitesBannerButtonText": "Mehr erfahren",
|
"pendingSitesBannerButtonText": "Mehr erfahren",
|
||||||
"apiKeysSettings": "{apiKeyName} Einstellungen",
|
"apiKeysSettings": "{apiKeyName} Einstellungen",
|
||||||
"userTitle": "Alle Benutzer verwalten",
|
"userTitle": "Alle Benutzer verwalten",
|
||||||
@@ -624,6 +624,8 @@
|
|||||||
"targetErrorInvalidPortDescription": "Bitte geben Sie eine gültige Portnummer ein",
|
"targetErrorInvalidPortDescription": "Bitte geben Sie eine gültige Portnummer ein",
|
||||||
"targetErrorNoSite": "Kein Standort ausgewählt",
|
"targetErrorNoSite": "Kein Standort ausgewählt",
|
||||||
"targetErrorNoSiteDescription": "Bitte wähle einen Standort für das Ziel aus",
|
"targetErrorNoSiteDescription": "Bitte wähle einen Standort für das Ziel aus",
|
||||||
|
"targetTargetsCleared": "Ziele gelöscht",
|
||||||
|
"targetTargetsClearedDescription": "Alle Ziele wurden aus dieser Ressource entfernt",
|
||||||
"targetCreated": "Ziel erstellt",
|
"targetCreated": "Ziel erstellt",
|
||||||
"targetCreatedDescription": "Ziel wurde erfolgreich erstellt",
|
"targetCreatedDescription": "Ziel wurde erfolgreich erstellt",
|
||||||
"targetErrorCreate": "Fehler beim Erstellen des Ziels",
|
"targetErrorCreate": "Fehler beim Erstellen des Ziels",
|
||||||
@@ -2346,7 +2348,7 @@
|
|||||||
"description": "Enterprise Features, 50 Benutzer, 50 Sites und Prioritätsunterstützung."
|
"description": "Enterprise Features, 50 Benutzer, 50 Sites und Prioritätsunterstützung."
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"personalUseOnly": "Nur persönliche Nutzung (kostenlose Lizenz — keine Kasse)",
|
"personalUseOnly": "Nur persönliche Nutzung (kostenlose Lizenz - kein Checkout)",
|
||||||
"buttons": {
|
"buttons": {
|
||||||
"continueToCheckout": "Weiter zur Kasse"
|
"continueToCheckout": "Weiter zur Kasse"
|
||||||
},
|
},
|
||||||
@@ -2607,6 +2609,9 @@
|
|||||||
"machineClients": "Maschinen-Clients",
|
"machineClients": "Maschinen-Clients",
|
||||||
"install": "Installieren",
|
"install": "Installieren",
|
||||||
"run": "Ausführen",
|
"run": "Ausführen",
|
||||||
|
"envFile": "Umgebungsdatei",
|
||||||
|
"serviceFile": "Servicedatei",
|
||||||
|
"enableAndStart": "Aktivieren und Starten",
|
||||||
"clientNameDescription": "Der Anzeigename des Clients, der später geändert werden kann.",
|
"clientNameDescription": "Der Anzeigename des Clients, der später geändert werden kann.",
|
||||||
"clientAddress": "Clientadresse (Erweitert)",
|
"clientAddress": "Clientadresse (Erweitert)",
|
||||||
"setupFailedToFetchSubnet": "Fehler beim Abrufen des Standard-Subnetzes",
|
"setupFailedToFetchSubnet": "Fehler beim Abrufen des Standard-Subnetzes",
|
||||||
@@ -2845,10 +2850,10 @@
|
|||||||
"httpDestAuthNoneTitle": "Keine Authentifizierung",
|
"httpDestAuthNoneTitle": "Keine Authentifizierung",
|
||||||
"httpDestAuthNoneDescription": "Sendet Anfragen ohne Autorisierungs-Header.",
|
"httpDestAuthNoneDescription": "Sendet Anfragen ohne Autorisierungs-Header.",
|
||||||
"httpDestAuthBearerTitle": "Bären-Token",
|
"httpDestAuthBearerTitle": "Bären-Token",
|
||||||
"httpDestAuthBearerDescription": "Fügt eine Berechtigung hinzu: Bearer <token> Header zu jeder Anfrage.",
|
"httpDestAuthBearerDescription": "Fügt jedem Anfrage-Header eine \"Authorization: Bearer '<token>'\" hinzu.",
|
||||||
"httpDestAuthBearerPlaceholder": "Ihr API-Schlüssel oder Token",
|
"httpDestAuthBearerPlaceholder": "Ihr API-Schlüssel oder Token",
|
||||||
"httpDestAuthBasicTitle": "Einfacher Auth",
|
"httpDestAuthBasicTitle": "Einfacher Auth",
|
||||||
"httpDestAuthBasicDescription": "Fügt eine Autorisierung hinzu: Basic <credentials> Kopfzeile hinzu. Geben Sie Anmeldedaten als Benutzername:password an.",
|
"httpDestAuthBasicDescription": "Fügt einen \"Authorization: Basic '<credentials>'\"-Header hinzu. Geben Sie die Anmeldedaten als Benutzername:Passwort an.",
|
||||||
"httpDestAuthBasicPlaceholder": "benutzername:password",
|
"httpDestAuthBasicPlaceholder": "benutzername:password",
|
||||||
"httpDestAuthCustomTitle": "Eigene Kopfzeile",
|
"httpDestAuthCustomTitle": "Eigene Kopfzeile",
|
||||||
"httpDestAuthCustomDescription": "Geben Sie einen eigenen HTTP-Header-Namen und einen Wert für die Authentifizierung an (z.B. X-API-Key).",
|
"httpDestAuthCustomDescription": "Geben Sie einen eigenen HTTP-Header-Namen und einen Wert für die Authentifizierung an (z.B. X-API-Key).",
|
||||||
|
|||||||
@@ -624,6 +624,8 @@
|
|||||||
"targetErrorInvalidPortDescription": "Please enter a valid port number",
|
"targetErrorInvalidPortDescription": "Please enter a valid port number",
|
||||||
"targetErrorNoSite": "No site selected",
|
"targetErrorNoSite": "No site selected",
|
||||||
"targetErrorNoSiteDescription": "Please select a site for the target",
|
"targetErrorNoSiteDescription": "Please select a site for the target",
|
||||||
|
"targetTargetsCleared": "Targets cleared",
|
||||||
|
"targetTargetsClearedDescription": "All targets have been removed from this resource",
|
||||||
"targetCreated": "Target created",
|
"targetCreated": "Target created",
|
||||||
"targetCreatedDescription": "Target has been created successfully",
|
"targetCreatedDescription": "Target has been created successfully",
|
||||||
"targetErrorCreate": "Failed to create target",
|
"targetErrorCreate": "Failed to create target",
|
||||||
@@ -1815,6 +1817,11 @@
|
|||||||
"editInternalResourceDialogModePort": "Port",
|
"editInternalResourceDialogModePort": "Port",
|
||||||
"editInternalResourceDialogModeHost": "Host",
|
"editInternalResourceDialogModeHost": "Host",
|
||||||
"editInternalResourceDialogModeCidr": "CIDR",
|
"editInternalResourceDialogModeCidr": "CIDR",
|
||||||
|
"editInternalResourceDialogModeHttp": "HTTP",
|
||||||
|
"editInternalResourceDialogModeHttps": "HTTPS",
|
||||||
|
"editInternalResourceDialogScheme": "Scheme",
|
||||||
|
"editInternalResourceDialogEnableSsl": "Enable SSL",
|
||||||
|
"editInternalResourceDialogEnableSslDescription": "Enable SSL/TLS encryption for secure HTTPS connections to the destination.",
|
||||||
"editInternalResourceDialogDestination": "Destination",
|
"editInternalResourceDialogDestination": "Destination",
|
||||||
"editInternalResourceDialogDestinationHostDescription": "The IP address or hostname of the resource on the site's network.",
|
"editInternalResourceDialogDestinationHostDescription": "The IP address or hostname of the resource on the site's network.",
|
||||||
"editInternalResourceDialogDestinationIPDescription": "The IP or hostname address of the resource on the site's network.",
|
"editInternalResourceDialogDestinationIPDescription": "The IP or hostname address of the resource on the site's network.",
|
||||||
@@ -1858,11 +1865,19 @@
|
|||||||
"createInternalResourceDialogModePort": "Port",
|
"createInternalResourceDialogModePort": "Port",
|
||||||
"createInternalResourceDialogModeHost": "Host",
|
"createInternalResourceDialogModeHost": "Host",
|
||||||
"createInternalResourceDialogModeCidr": "CIDR",
|
"createInternalResourceDialogModeCidr": "CIDR",
|
||||||
|
"createInternalResourceDialogModeHttp": "HTTP",
|
||||||
|
"createInternalResourceDialogModeHttps": "HTTPS",
|
||||||
|
"scheme": "Scheme",
|
||||||
|
"createInternalResourceDialogScheme": "Scheme",
|
||||||
|
"createInternalResourceDialogEnableSsl": "Enable SSL",
|
||||||
|
"createInternalResourceDialogEnableSslDescription": "Enable SSL/TLS encryption for secure HTTPS connections to the destination.",
|
||||||
"createInternalResourceDialogDestination": "Destination",
|
"createInternalResourceDialogDestination": "Destination",
|
||||||
"createInternalResourceDialogDestinationHostDescription": "The IP address or hostname of the resource on the site's network.",
|
"createInternalResourceDialogDestinationHostDescription": "The IP address or hostname of the resource on the site's network.",
|
||||||
"createInternalResourceDialogDestinationCidrDescription": "The CIDR range of the resource on the site's network.",
|
"createInternalResourceDialogDestinationCidrDescription": "The CIDR range of the resource on the site's network.",
|
||||||
"createInternalResourceDialogAlias": "Alias",
|
"createInternalResourceDialogAlias": "Alias",
|
||||||
"createInternalResourceDialogAliasDescription": "An optional internal DNS alias for this resource.",
|
"createInternalResourceDialogAliasDescription": "An optional internal DNS alias for this resource.",
|
||||||
|
"internalResourceDownstreamSchemeRequired": "Scheme is required for HTTP resources",
|
||||||
|
"internalResourceHttpPortRequired": "Destination port is required for HTTP resources",
|
||||||
"siteConfiguration": "Configuration",
|
"siteConfiguration": "Configuration",
|
||||||
"siteAcceptClientConnections": "Accept Client Connections",
|
"siteAcceptClientConnections": "Accept Client Connections",
|
||||||
"siteAcceptClientConnectionsDescription": "Allow user devices and clients to access resources on this site. This can be changed later.",
|
"siteAcceptClientConnectionsDescription": "Allow user devices and clients to access resources on this site. This can be changed later.",
|
||||||
@@ -2114,6 +2129,7 @@
|
|||||||
"domainPickerFreeProvidedDomain": "Free Provided Domain",
|
"domainPickerFreeProvidedDomain": "Free Provided Domain",
|
||||||
"domainPickerVerified": "Verified",
|
"domainPickerVerified": "Verified",
|
||||||
"domainPickerUnverified": "Unverified",
|
"domainPickerUnverified": "Unverified",
|
||||||
|
"domainPickerManual": "Manual",
|
||||||
"domainPickerInvalidSubdomainStructure": "This subdomain contains invalid characters or structure. It will be sanitized automatically when you save.",
|
"domainPickerInvalidSubdomainStructure": "This subdomain contains invalid characters or structure. It will be sanitized automatically when you save.",
|
||||||
"domainPickerError": "Error",
|
"domainPickerError": "Error",
|
||||||
"domainPickerErrorLoadDomains": "Failed to load organization domains",
|
"domainPickerErrorLoadDomains": "Failed to load organization domains",
|
||||||
@@ -2607,6 +2623,9 @@
|
|||||||
"machineClients": "Machine Clients",
|
"machineClients": "Machine Clients",
|
||||||
"install": "Install",
|
"install": "Install",
|
||||||
"run": "Run",
|
"run": "Run",
|
||||||
|
"envFile": "Environment File",
|
||||||
|
"serviceFile": "Service File",
|
||||||
|
"enableAndStart": "Enable and Start",
|
||||||
"clientNameDescription": "The display name of the client that can be changed later.",
|
"clientNameDescription": "The display name of the client that can be changed later.",
|
||||||
"clientAddress": "Client Address (Advanced)",
|
"clientAddress": "Client Address (Advanced)",
|
||||||
"setupFailedToFetchSubnet": "Failed to fetch default subnet",
|
"setupFailedToFetchSubnet": "Failed to fetch default subnet",
|
||||||
@@ -2654,8 +2673,12 @@
|
|||||||
"editInternalResourceDialogAddUsers": "Add Users",
|
"editInternalResourceDialogAddUsers": "Add Users",
|
||||||
"editInternalResourceDialogAddClients": "Add Clients",
|
"editInternalResourceDialogAddClients": "Add Clients",
|
||||||
"editInternalResourceDialogDestinationLabel": "Destination",
|
"editInternalResourceDialogDestinationLabel": "Destination",
|
||||||
"editInternalResourceDialogDestinationDescription": "Specify the destination address for the internal resource. This can be a hostname, IP address, or CIDR range depending on the selected mode. Optionally set an internal DNS alias for easier identification.",
|
"editInternalResourceDialogDestinationDescription": "Choose where this resource runs and how clients reach it, then complete the settings that apply to your setup.",
|
||||||
"editInternalResourceDialogPortRestrictionsDescription": "Restrict access to specific TCP/UDP ports or allow/block all ports.",
|
"editInternalResourceDialogPortRestrictionsDescription": "Restrict access to specific TCP/UDP ports or allow/block all ports.",
|
||||||
|
"createInternalResourceDialogHttpConfiguration": "HTTP configuration",
|
||||||
|
"createInternalResourceDialogHttpConfigurationDescription": "Choose the domain clients will use to reach this resource over HTTP or HTTPS.",
|
||||||
|
"editInternalResourceDialogHttpConfiguration": "HTTP configuration",
|
||||||
|
"editInternalResourceDialogHttpConfigurationDescription": "Choose the domain clients will use to reach this resource over HTTP or HTTPS.",
|
||||||
"editInternalResourceDialogTcp": "TCP",
|
"editInternalResourceDialogTcp": "TCP",
|
||||||
"editInternalResourceDialogUdp": "UDP",
|
"editInternalResourceDialogUdp": "UDP",
|
||||||
"editInternalResourceDialogIcmp": "ICMP",
|
"editInternalResourceDialogIcmp": "ICMP",
|
||||||
@@ -2694,6 +2717,8 @@
|
|||||||
"maintenancePageMessagePlaceholder": "We'll be back soon! Our site is currently undergoing scheduled maintenance.",
|
"maintenancePageMessagePlaceholder": "We'll be back soon! Our site is currently undergoing scheduled maintenance.",
|
||||||
"maintenancePageMessageDescription": "Detailed message explaining the maintenance",
|
"maintenancePageMessageDescription": "Detailed message explaining the maintenance",
|
||||||
"maintenancePageTimeTitle": "Estimated Completion Time (Optional)",
|
"maintenancePageTimeTitle": "Estimated Completion Time (Optional)",
|
||||||
|
"privateMaintenanceScreenTitle": "Private Placeholder Screen",
|
||||||
|
"privateMaintenanceScreenMessage": "This domain is being used on a private resource. Please connect using the Pangolin client to access this resource.",
|
||||||
"maintenanceTime": "e.g., 2 hours, Nov 1 at 5:00 PM",
|
"maintenanceTime": "e.g., 2 hours, Nov 1 at 5:00 PM",
|
||||||
"maintenanceEstimatedTimeDescription": "When you expect maintenance to be completed",
|
"maintenanceEstimatedTimeDescription": "When you expect maintenance to be completed",
|
||||||
"editDomain": "Edit Domain",
|
"editDomain": "Edit Domain",
|
||||||
@@ -2845,10 +2870,10 @@
|
|||||||
"httpDestAuthNoneTitle": "No Authentication",
|
"httpDestAuthNoneTitle": "No Authentication",
|
||||||
"httpDestAuthNoneDescription": "Sends requests without an Authorization header.",
|
"httpDestAuthNoneDescription": "Sends requests without an Authorization header.",
|
||||||
"httpDestAuthBearerTitle": "Bearer Token",
|
"httpDestAuthBearerTitle": "Bearer Token",
|
||||||
"httpDestAuthBearerDescription": "Adds an Authorization: Bearer <token> header to each request.",
|
"httpDestAuthBearerDescription": "Adds an Authorization: Bearer '<token>' header to each request.",
|
||||||
"httpDestAuthBearerPlaceholder": "Your API key or token",
|
"httpDestAuthBearerPlaceholder": "Your API key or token",
|
||||||
"httpDestAuthBasicTitle": "Basic Auth",
|
"httpDestAuthBasicTitle": "Basic Auth",
|
||||||
"httpDestAuthBasicDescription": "Adds an Authorization: Basic <credentials> header. Provide credentials as username:password.",
|
"httpDestAuthBasicDescription": "Adds an Authorization: Basic '<credentials>' header. Provide credentials as username:password.",
|
||||||
"httpDestAuthBasicPlaceholder": "username:password",
|
"httpDestAuthBasicPlaceholder": "username:password",
|
||||||
"httpDestAuthCustomTitle": "Custom Header",
|
"httpDestAuthCustomTitle": "Custom Header",
|
||||||
"httpDestAuthCustomDescription": "Specify a custom HTTP header name and value for authentication (e.g. X-API-Key).",
|
"httpDestAuthCustomDescription": "Specify a custom HTTP header name and value for authentication (e.g. X-API-Key).",
|
||||||
|
|||||||
@@ -371,10 +371,10 @@
|
|||||||
"provisioningKeysUpdated": "Clave de aprovisionamiento actualizada",
|
"provisioningKeysUpdated": "Clave de aprovisionamiento actualizada",
|
||||||
"provisioningKeysUpdatedDescription": "Sus cambios han sido guardados.",
|
"provisioningKeysUpdatedDescription": "Sus cambios han sido guardados.",
|
||||||
"provisioningKeysBannerTitle": "Claves de aprovisionamiento del sitio",
|
"provisioningKeysBannerTitle": "Claves de aprovisionamiento del sitio",
|
||||||
"provisioningKeysBannerDescription": "Generar una clave de aprovisionamiento y usarla con el conector Newt para crear automáticamente sitios en el primer inicio — no es necesario configurar credenciales separadas para cada sitio.",
|
"provisioningKeysBannerDescription": "Genere una clave de aprovisionamiento y utilícela con el conector Newt para crear automáticamente sitios en el primer inicio: no es necesario configurar credenciales separadas para cada sitio.",
|
||||||
"provisioningKeysBannerButtonText": "Saber más",
|
"provisioningKeysBannerButtonText": "Saber más",
|
||||||
"pendingSitesBannerTitle": "Sitios pendientes",
|
"pendingSitesBannerTitle": "Sitios pendientes",
|
||||||
"pendingSitesBannerDescription": "Los sitios que se conectan usando una clave de aprovisionamiento aparecen aquí para su revisión. Aprobar cada sitio antes de que se active y obtenga acceso a sus recursos.",
|
"pendingSitesBannerDescription": "Los sitios que se conectan utilizando una clave de aprovisionamiento aparecerán aquí para su revisión.",
|
||||||
"pendingSitesBannerButtonText": "Saber más",
|
"pendingSitesBannerButtonText": "Saber más",
|
||||||
"apiKeysSettings": "Ajustes {apiKeyName}",
|
"apiKeysSettings": "Ajustes {apiKeyName}",
|
||||||
"userTitle": "Administrar todos los usuarios",
|
"userTitle": "Administrar todos los usuarios",
|
||||||
@@ -624,6 +624,8 @@
|
|||||||
"targetErrorInvalidPortDescription": "Por favor, introduzca un número de puerto válido",
|
"targetErrorInvalidPortDescription": "Por favor, introduzca un número de puerto válido",
|
||||||
"targetErrorNoSite": "Ningún sitio seleccionado",
|
"targetErrorNoSite": "Ningún sitio seleccionado",
|
||||||
"targetErrorNoSiteDescription": "Por favor, seleccione un sitio para el objetivo",
|
"targetErrorNoSiteDescription": "Por favor, seleccione un sitio para el objetivo",
|
||||||
|
"targetTargetsCleared": "Objetivos eliminados",
|
||||||
|
"targetTargetsClearedDescription": "Todos los objetivos han sido eliminados de este recurso",
|
||||||
"targetCreated": "Objetivo creado",
|
"targetCreated": "Objetivo creado",
|
||||||
"targetCreatedDescription": "El objetivo se ha creado correctamente",
|
"targetCreatedDescription": "El objetivo se ha creado correctamente",
|
||||||
"targetErrorCreate": "Error al crear el objetivo",
|
"targetErrorCreate": "Error al crear el objetivo",
|
||||||
@@ -2346,7 +2348,7 @@
|
|||||||
"description": "Características de la empresa, 50 usuarios, 50 sitios y soporte prioritario."
|
"description": "Características de la empresa, 50 usuarios, 50 sitios y soporte prioritario."
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"personalUseOnly": "Solo uso personal (licencia gratuita, sin pago)",
|
"personalUseOnly": "Solo uso personal (licencia gratuita - sin salida)",
|
||||||
"buttons": {
|
"buttons": {
|
||||||
"continueToCheckout": "Continuar con el pago"
|
"continueToCheckout": "Continuar con el pago"
|
||||||
},
|
},
|
||||||
@@ -2607,6 +2609,9 @@
|
|||||||
"machineClients": "Clientes de la máquina",
|
"machineClients": "Clientes de la máquina",
|
||||||
"install": "Instalar",
|
"install": "Instalar",
|
||||||
"run": "Ejecutar",
|
"run": "Ejecutar",
|
||||||
|
"envFile": "Archivo de Entorno",
|
||||||
|
"serviceFile": "Archivo de Servicio",
|
||||||
|
"enableAndStart": "Habilitar y empezar",
|
||||||
"clientNameDescription": "El nombre mostrado del cliente que se puede cambiar más adelante.",
|
"clientNameDescription": "El nombre mostrado del cliente que se puede cambiar más adelante.",
|
||||||
"clientAddress": "Dirección del cliente (Avanzado)",
|
"clientAddress": "Dirección del cliente (Avanzado)",
|
||||||
"setupFailedToFetchSubnet": "No se pudo obtener la subred por defecto",
|
"setupFailedToFetchSubnet": "No se pudo obtener la subred por defecto",
|
||||||
@@ -2845,10 +2850,10 @@
|
|||||||
"httpDestAuthNoneTitle": "Sin autenticación",
|
"httpDestAuthNoneTitle": "Sin autenticación",
|
||||||
"httpDestAuthNoneDescription": "Envía solicitudes sin un encabezado de autorización.",
|
"httpDestAuthNoneDescription": "Envía solicitudes sin un encabezado de autorización.",
|
||||||
"httpDestAuthBearerTitle": "Tóken de portador",
|
"httpDestAuthBearerTitle": "Tóken de portador",
|
||||||
"httpDestAuthBearerDescription": "Añade una autorización: portador <token> encabezado a cada solicitud.",
|
"httpDestAuthBearerDescription": "Añade un encabezado Authorization: Bearer '<token>' a cada solicitud.",
|
||||||
"httpDestAuthBearerPlaceholder": "Tu clave o token API",
|
"httpDestAuthBearerPlaceholder": "Tu clave o token API",
|
||||||
"httpDestAuthBasicTitle": "Auth Básica",
|
"httpDestAuthBasicTitle": "Auth Básica",
|
||||||
"httpDestAuthBasicDescription": "Añade una Autorización: encabezado básico <credentials> . Proporcione credenciales como nombre de usuario: contraseña.",
|
"httpDestAuthBasicDescription": "Añade un encabezado Authorization: Basic '<credenciales>'. Proporcione las credenciales como nombredeusuario:contraseña.",
|
||||||
"httpDestAuthBasicPlaceholder": "usuario:contraseña",
|
"httpDestAuthBasicPlaceholder": "usuario:contraseña",
|
||||||
"httpDestAuthCustomTitle": "Cabecera personalizada",
|
"httpDestAuthCustomTitle": "Cabecera personalizada",
|
||||||
"httpDestAuthCustomDescription": "Especifique un nombre de cabecera HTTP personalizado y un valor para la autenticación (por ejemplo, X-API-Key).",
|
"httpDestAuthCustomDescription": "Especifique un nombre de cabecera HTTP personalizado y un valor para la autenticación (por ejemplo, X-API-Key).",
|
||||||
|
|||||||
@@ -371,10 +371,10 @@
|
|||||||
"provisioningKeysUpdated": "Clé de provisioning mise à jour",
|
"provisioningKeysUpdated": "Clé de provisioning mise à jour",
|
||||||
"provisioningKeysUpdatedDescription": "Vos modifications ont été enregistrées.",
|
"provisioningKeysUpdatedDescription": "Vos modifications ont été enregistrées.",
|
||||||
"provisioningKeysBannerTitle": "Clés de provisioning du site",
|
"provisioningKeysBannerTitle": "Clés de provisioning du site",
|
||||||
"provisioningKeysBannerDescription": "Générez une clé de provisioning et utilisez-la avec le connecteur Newt pour créer automatiquement des sites au premier démarrage — pas besoin de configurer des identifiants distincts pour chaque site.",
|
"provisioningKeysBannerDescription": "Générez une clé de provisionnement et utilisez-la avec le connecteur Newt pour créer automatiquement des sites lors du premier démarrage - sans besoin de configurer des identifiants séparés pour chaque site.",
|
||||||
"provisioningKeysBannerButtonText": "En savoir plus",
|
"provisioningKeysBannerButtonText": "En savoir plus",
|
||||||
"pendingSitesBannerTitle": "Sites en attente",
|
"pendingSitesBannerTitle": "Sites en attente",
|
||||||
"pendingSitesBannerDescription": "Les sites qui se connectent à l'aide d'une clé de provisioning apparaissent ici pour être revus. Approuver chaque site avant qu'il ne devienne actif et qu'il accède à vos ressources.",
|
"pendingSitesBannerDescription": "Les sites qui se connectent en utilisant une clé de provisionnement apparaissent ici pour révision.",
|
||||||
"pendingSitesBannerButtonText": "En savoir plus",
|
"pendingSitesBannerButtonText": "En savoir plus",
|
||||||
"apiKeysSettings": "Paramètres de {apiKeyName}",
|
"apiKeysSettings": "Paramètres de {apiKeyName}",
|
||||||
"userTitle": "Gérer tous les utilisateurs",
|
"userTitle": "Gérer tous les utilisateurs",
|
||||||
@@ -624,6 +624,8 @@
|
|||||||
"targetErrorInvalidPortDescription": "Veuillez entrer un numéro de port valide",
|
"targetErrorInvalidPortDescription": "Veuillez entrer un numéro de port valide",
|
||||||
"targetErrorNoSite": "Aucun site sélectionné",
|
"targetErrorNoSite": "Aucun site sélectionné",
|
||||||
"targetErrorNoSiteDescription": "Veuillez sélectionner un site pour la cible",
|
"targetErrorNoSiteDescription": "Veuillez sélectionner un site pour la cible",
|
||||||
|
"targetTargetsCleared": "Cibles effacées",
|
||||||
|
"targetTargetsClearedDescription": "Toutes les cibles ont été retirées de cette ressource",
|
||||||
"targetCreated": "Cible créée",
|
"targetCreated": "Cible créée",
|
||||||
"targetCreatedDescription": "La cible a été créée avec succès",
|
"targetCreatedDescription": "La cible a été créée avec succès",
|
||||||
"targetErrorCreate": "Impossible de créer la cible",
|
"targetErrorCreate": "Impossible de créer la cible",
|
||||||
@@ -2346,7 +2348,7 @@
|
|||||||
"description": "Fonctionnalités d'entreprise, 50 utilisateurs, 50 sites et une prise en charge prioritaire."
|
"description": "Fonctionnalités d'entreprise, 50 utilisateurs, 50 sites et une prise en charge prioritaire."
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"personalUseOnly": "Utilisation personnelle uniquement (licence gratuite — sans checkout)",
|
"personalUseOnly": "Usage personnel uniquement (licence gratuite - pas de validation)",
|
||||||
"buttons": {
|
"buttons": {
|
||||||
"continueToCheckout": "Continuer vers le paiement"
|
"continueToCheckout": "Continuer vers le paiement"
|
||||||
},
|
},
|
||||||
@@ -2607,6 +2609,9 @@
|
|||||||
"machineClients": "Clients Machines",
|
"machineClients": "Clients Machines",
|
||||||
"install": "Installer",
|
"install": "Installer",
|
||||||
"run": "Exécuter",
|
"run": "Exécuter",
|
||||||
|
"envFile": "Fichier Environnement",
|
||||||
|
"serviceFile": "Fichier de Service",
|
||||||
|
"enableAndStart": "Activer et Démarrer",
|
||||||
"clientNameDescription": "Le nom d'affichage du client qui peut être modifié plus tard.",
|
"clientNameDescription": "Le nom d'affichage du client qui peut être modifié plus tard.",
|
||||||
"clientAddress": "Adresse du client (Avancé)",
|
"clientAddress": "Adresse du client (Avancé)",
|
||||||
"setupFailedToFetchSubnet": "Impossible de récupérer le sous-réseau par défaut",
|
"setupFailedToFetchSubnet": "Impossible de récupérer le sous-réseau par défaut",
|
||||||
@@ -2845,10 +2850,10 @@
|
|||||||
"httpDestAuthNoneTitle": "Aucune authentification",
|
"httpDestAuthNoneTitle": "Aucune authentification",
|
||||||
"httpDestAuthNoneDescription": "Envoie des requêtes sans en-tête d'autorisation.",
|
"httpDestAuthNoneDescription": "Envoie des requêtes sans en-tête d'autorisation.",
|
||||||
"httpDestAuthBearerTitle": "Jeton de Porteur",
|
"httpDestAuthBearerTitle": "Jeton de Porteur",
|
||||||
"httpDestAuthBearerDescription": "Ajoute un en-tête Authorization: Bearer <token> à chaque requête.",
|
"httpDestAuthBearerDescription": "Ajoute un en-tête Authorization: Bearer '<token>' à chaque requête.",
|
||||||
"httpDestAuthBearerPlaceholder": "Votre clé API ou votre jeton",
|
"httpDestAuthBearerPlaceholder": "Votre clé API ou votre jeton",
|
||||||
"httpDestAuthBasicTitle": "Authentification basique",
|
"httpDestAuthBasicTitle": "Authentification basique",
|
||||||
"httpDestAuthBasicDescription": "Ajoute une autorisation : en-tête de base <credentials> . Fournissez des informations d'identification comme nom d'utilisateur:mot de passe.",
|
"httpDestAuthBasicDescription": "Ajoute un en-tête Authorization: Basic '<credentials>'. Fournissez les identifiants sous la forme nom d'utilisateur:mot de passe.",
|
||||||
"httpDestAuthBasicPlaceholder": "nom d'utilisateur:mot de passe",
|
"httpDestAuthBasicPlaceholder": "nom d'utilisateur:mot de passe",
|
||||||
"httpDestAuthCustomTitle": "En-tête personnalisé",
|
"httpDestAuthCustomTitle": "En-tête personnalisé",
|
||||||
"httpDestAuthCustomDescription": "Spécifiez un nom d'en-tête HTTP personnalisé et une valeur pour l'authentification (par exemple X-API-Key).",
|
"httpDestAuthCustomDescription": "Spécifiez un nom d'en-tête HTTP personnalisé et une valeur pour l'authentification (par exemple X-API-Key).",
|
||||||
|
|||||||
@@ -371,10 +371,10 @@
|
|||||||
"provisioningKeysUpdated": "Chiave di accantonamento aggiornata",
|
"provisioningKeysUpdated": "Chiave di accantonamento aggiornata",
|
||||||
"provisioningKeysUpdatedDescription": "Le tue modifiche sono state salvate.",
|
"provisioningKeysUpdatedDescription": "Le tue modifiche sono state salvate.",
|
||||||
"provisioningKeysBannerTitle": "Chiavi Di Provvedimento Sito",
|
"provisioningKeysBannerTitle": "Chiavi Di Provvedimento Sito",
|
||||||
"provisioningKeysBannerDescription": "Generare una chiave di provisioning e usarla con il connettore Newt per creare automaticamente siti al primo avvio — non è necessario impostare credenziali separate per ogni sito.",
|
"provisioningKeysBannerDescription": "Genera una chiave di provisioning e usala con il connettore Newt per creare automaticamente i siti al primo avvio - non è necessario configurare credenziali separate per ogni sito.",
|
||||||
"provisioningKeysBannerButtonText": "Scopri di più",
|
"provisioningKeysBannerButtonText": "Scopri di più",
|
||||||
"pendingSitesBannerTitle": "Siti In Attesa",
|
"pendingSitesBannerTitle": "Siti In Attesa",
|
||||||
"pendingSitesBannerDescription": "I siti che si connettono utilizzando una chiave di provisioning appaiono qui per la revisione. Approva ogni sito prima che diventi attivo e ottenga l'accesso alle tue risorse.",
|
"pendingSitesBannerDescription": "I siti che si connettono utilizzando una chiave di provisioning vengono visualizzati qui per la revisione.",
|
||||||
"pendingSitesBannerButtonText": "Scopri di più",
|
"pendingSitesBannerButtonText": "Scopri di più",
|
||||||
"apiKeysSettings": "Impostazioni {apiKeyName}",
|
"apiKeysSettings": "Impostazioni {apiKeyName}",
|
||||||
"userTitle": "Gestisci Tutti Gli Utenti",
|
"userTitle": "Gestisci Tutti Gli Utenti",
|
||||||
@@ -624,6 +624,8 @@
|
|||||||
"targetErrorInvalidPortDescription": "Inserisci un numero di porta valido",
|
"targetErrorInvalidPortDescription": "Inserisci un numero di porta valido",
|
||||||
"targetErrorNoSite": "Nessun sito selezionato",
|
"targetErrorNoSite": "Nessun sito selezionato",
|
||||||
"targetErrorNoSiteDescription": "Si prega di selezionare un sito per l'obiettivo",
|
"targetErrorNoSiteDescription": "Si prega di selezionare un sito per l'obiettivo",
|
||||||
|
"targetTargetsCleared": "Obiettivi cancellati",
|
||||||
|
"targetTargetsClearedDescription": "Tutti gli obiettivi sono stati rimossi da questa risorsa",
|
||||||
"targetCreated": "Destinazione creata",
|
"targetCreated": "Destinazione creata",
|
||||||
"targetCreatedDescription": "L'obiettivo è stato creato con successo",
|
"targetCreatedDescription": "L'obiettivo è stato creato con successo",
|
||||||
"targetErrorCreate": "Impossibile creare l'obiettivo",
|
"targetErrorCreate": "Impossibile creare l'obiettivo",
|
||||||
@@ -2346,7 +2348,7 @@
|
|||||||
"description": "Funzionalità aziendali, 50 utenti, 50 siti e supporto prioritario."
|
"description": "Funzionalità aziendali, 50 utenti, 50 siti e supporto prioritario."
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"personalUseOnly": "Solo uso personale (licenza gratuita — nessun checkout)",
|
"personalUseOnly": "Uso personale esclusivo (licenza gratuita - nessun pagamento)",
|
||||||
"buttons": {
|
"buttons": {
|
||||||
"continueToCheckout": "Continua al Checkout"
|
"continueToCheckout": "Continua al Checkout"
|
||||||
},
|
},
|
||||||
@@ -2607,6 +2609,9 @@
|
|||||||
"machineClients": "Machine Clients",
|
"machineClients": "Machine Clients",
|
||||||
"install": "Installa",
|
"install": "Installa",
|
||||||
"run": "Esegui",
|
"run": "Esegui",
|
||||||
|
"envFile": "File di ambiente",
|
||||||
|
"serviceFile": "File di servizio",
|
||||||
|
"enableAndStart": "Abilita e avvia",
|
||||||
"clientNameDescription": "Il nome visualizzato del client che può essere modificato in seguito.",
|
"clientNameDescription": "Il nome visualizzato del client che può essere modificato in seguito.",
|
||||||
"clientAddress": "Indirizzo Client (Avanzato)",
|
"clientAddress": "Indirizzo Client (Avanzato)",
|
||||||
"setupFailedToFetchSubnet": "Recupero della sottorete predefinita non riuscito",
|
"setupFailedToFetchSubnet": "Recupero della sottorete predefinita non riuscito",
|
||||||
@@ -2845,10 +2850,10 @@
|
|||||||
"httpDestAuthNoneTitle": "Nessuna Autenticazione",
|
"httpDestAuthNoneTitle": "Nessuna Autenticazione",
|
||||||
"httpDestAuthNoneDescription": "Invia richieste senza intestazione autorizzazione.",
|
"httpDestAuthNoneDescription": "Invia richieste senza intestazione autorizzazione.",
|
||||||
"httpDestAuthBearerTitle": "Token Del Portatore",
|
"httpDestAuthBearerTitle": "Token Del Portatore",
|
||||||
"httpDestAuthBearerDescription": "Aggiunge un'intestazione Autorizzazione: Bearer <token> ad ogni richiesta.",
|
"httpDestAuthBearerDescription": "Aggiunge un'intestazione Authorization: Bearer '<token>' a ogni richiesta.",
|
||||||
"httpDestAuthBearerPlaceholder": "La tua chiave API o token",
|
"httpDestAuthBearerPlaceholder": "La tua chiave API o token",
|
||||||
"httpDestAuthBasicTitle": "Autenticazione Base",
|
"httpDestAuthBasicTitle": "Autenticazione Base",
|
||||||
"httpDestAuthBasicDescription": "Aggiunge un'autorizzazione: intestazione di base <credentials> . Fornisce le credenziali come username:password.",
|
"httpDestAuthBasicDescription": "Aggiunge un'intestazione Authorization: Basic '<credentials>'. Fornire le credenziali come username:password.",
|
||||||
"httpDestAuthBasicPlaceholder": "username:password",
|
"httpDestAuthBasicPlaceholder": "username:password",
|
||||||
"httpDestAuthCustomTitle": "Intestazione Personalizzata",
|
"httpDestAuthCustomTitle": "Intestazione Personalizzata",
|
||||||
"httpDestAuthCustomDescription": "Specifica un nome e un valore di intestazione HTTP personalizzati per l'autenticazione (ad esempio X-API-Key).",
|
"httpDestAuthCustomDescription": "Specifica un nome e un valore di intestazione HTTP personalizzati per l'autenticazione (ad esempio X-API-Key).",
|
||||||
|
|||||||
@@ -371,10 +371,10 @@
|
|||||||
"provisioningKeysUpdated": "프로비저닝 키가 업데이트되었습니다",
|
"provisioningKeysUpdated": "프로비저닝 키가 업데이트되었습니다",
|
||||||
"provisioningKeysUpdatedDescription": "변경 사항이 저장되었습니다.",
|
"provisioningKeysUpdatedDescription": "변경 사항이 저장되었습니다.",
|
||||||
"provisioningKeysBannerTitle": "사이트 프로비저닝 키",
|
"provisioningKeysBannerTitle": "사이트 프로비저닝 키",
|
||||||
"provisioningKeysBannerDescription": "프로비저닝 키를 생성하여 Newt 커넥터와 함께 사용해 첫 실행 시 자동으로 사이트를 생성하세요 — 각 사이트마다 별도의 인증을 설정할 필요가 없습니다.",
|
"provisioningKeysBannerDescription": "프로비저닝 키를 생성하고 Newt 커넥터와 함께 사용하여 첫 시작 시 사이트를 자동 생성 - 각 사이트에 대한 별도 자격 증명이 필요 없습니다.",
|
||||||
"provisioningKeysBannerButtonText": "자세히 알아보기",
|
"provisioningKeysBannerButtonText": "자세히 알아보기",
|
||||||
"pendingSitesBannerTitle": "대기중인 사이트",
|
"pendingSitesBannerTitle": "대기중인 사이트",
|
||||||
"pendingSitesBannerDescription": "프로비저닝 키를 사용하여 연결하는 사이트는 검토 대기 중입니다. 사이트가 활성화되어 리소스에 액세스하기 전에 각 사이트를 승인하세요.",
|
"pendingSitesBannerDescription": "프로비저닝 키를 사용하여 연결된 사이트가 검토를 위해 여기에 표시됩니다.",
|
||||||
"pendingSitesBannerButtonText": "자세히 알아보기",
|
"pendingSitesBannerButtonText": "자세히 알아보기",
|
||||||
"apiKeysSettings": "{apiKeyName} 설정",
|
"apiKeysSettings": "{apiKeyName} 설정",
|
||||||
"userTitle": "모든 사용자 관리",
|
"userTitle": "모든 사용자 관리",
|
||||||
@@ -624,6 +624,8 @@
|
|||||||
"targetErrorInvalidPortDescription": "유효한 포트 번호를 입력하세요.",
|
"targetErrorInvalidPortDescription": "유효한 포트 번호를 입력하세요.",
|
||||||
"targetErrorNoSite": "선택된 사이트 없음",
|
"targetErrorNoSite": "선택된 사이트 없음",
|
||||||
"targetErrorNoSiteDescription": "대상을 위해 사이트를 선택하세요.",
|
"targetErrorNoSiteDescription": "대상을 위해 사이트를 선택하세요.",
|
||||||
|
"targetTargetsCleared": "대상이 제거됨",
|
||||||
|
"targetTargetsClearedDescription": "이 리소스에서 모든 대상이 제거되었습니다",
|
||||||
"targetCreated": "대상 생성",
|
"targetCreated": "대상 생성",
|
||||||
"targetCreatedDescription": "대상이 성공적으로 생성되었습니다.",
|
"targetCreatedDescription": "대상이 성공적으로 생성되었습니다.",
|
||||||
"targetErrorCreate": "대상 생성 실패",
|
"targetErrorCreate": "대상 생성 실패",
|
||||||
@@ -2346,7 +2348,7 @@
|
|||||||
"description": "기업 기능, 50명의 사용자, 50개의 사이트, 우선 지원."
|
"description": "기업 기능, 50명의 사용자, 50개의 사이트, 우선 지원."
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"personalUseOnly": "개인 사용 전용 (무료 라이센스 — 체크아웃 없음)",
|
"personalUseOnly": "개인용으로만 사용 (무료 라이선스 - 결제 없음)",
|
||||||
"buttons": {
|
"buttons": {
|
||||||
"continueToCheckout": "결제로 진행"
|
"continueToCheckout": "결제로 진행"
|
||||||
},
|
},
|
||||||
@@ -2607,6 +2609,9 @@
|
|||||||
"machineClients": "기계 클라이언트",
|
"machineClients": "기계 클라이언트",
|
||||||
"install": "설치",
|
"install": "설치",
|
||||||
"run": "실행",
|
"run": "실행",
|
||||||
|
"envFile": "환경 파일",
|
||||||
|
"serviceFile": "서비스 파일",
|
||||||
|
"enableAndStart": "활성화 및 시작",
|
||||||
"clientNameDescription": "나중에 변경할 수 있는 클라이언트의 표시 이름입니다.",
|
"clientNameDescription": "나중에 변경할 수 있는 클라이언트의 표시 이름입니다.",
|
||||||
"clientAddress": "클라이언트 주소(고급)",
|
"clientAddress": "클라이언트 주소(고급)",
|
||||||
"setupFailedToFetchSubnet": "기본값 로드 실패",
|
"setupFailedToFetchSubnet": "기본값 로드 실패",
|
||||||
@@ -2845,10 +2850,10 @@
|
|||||||
"httpDestAuthNoneTitle": "인증 없음",
|
"httpDestAuthNoneTitle": "인증 없음",
|
||||||
"httpDestAuthNoneDescription": "Authorization 헤더 없이 요청을 보냅니다.",
|
"httpDestAuthNoneDescription": "Authorization 헤더 없이 요청을 보냅니다.",
|
||||||
"httpDestAuthBearerTitle": "Bearer 토큰",
|
"httpDestAuthBearerTitle": "Bearer 토큰",
|
||||||
"httpDestAuthBearerDescription": "모든 요청에 Authorization: Bearer <token> 헤더를 추가합니다.",
|
"httpDestAuthBearerDescription": "각 요청에 Authorization: Bearer '<token>' 헤더를 추가합니다.",
|
||||||
"httpDestAuthBearerPlaceholder": "API 키 또는 토큰",
|
"httpDestAuthBearerPlaceholder": "API 키 또는 토큰",
|
||||||
"httpDestAuthBasicTitle": "기본 인증",
|
"httpDestAuthBasicTitle": "기본 인증",
|
||||||
"httpDestAuthBasicDescription": "Authorization: Basic <credentials> 헤더를 추가합니다. 자격 증명은 username:password 형식으로 제공하세요.",
|
"httpDestAuthBasicDescription": "Authorization: Basic '<credentials>' 헤더를 추가합니다. 자격 증명은 사용자 이름:비밀번호로 제공합니다.",
|
||||||
"httpDestAuthBasicPlaceholder": "사용자 이름:비밀번호",
|
"httpDestAuthBasicPlaceholder": "사용자 이름:비밀번호",
|
||||||
"httpDestAuthCustomTitle": "사용자 정의 헤더",
|
"httpDestAuthCustomTitle": "사용자 정의 헤더",
|
||||||
"httpDestAuthCustomDescription": "인증을 위한 사용자 정의 HTTP 헤더 이름 및 값을 지정하세요 (예: X-API-Key).",
|
"httpDestAuthCustomDescription": "인증을 위한 사용자 정의 HTTP 헤더 이름 및 값을 지정하세요 (예: X-API-Key).",
|
||||||
|
|||||||
@@ -371,10 +371,10 @@
|
|||||||
"provisioningKeysUpdated": "Foreslå nøkkel oppdatert",
|
"provisioningKeysUpdated": "Foreslå nøkkel oppdatert",
|
||||||
"provisioningKeysUpdatedDescription": "Dine endringer er lagret.",
|
"provisioningKeysUpdatedDescription": "Dine endringer er lagret.",
|
||||||
"provisioningKeysBannerTitle": "Sidens bestemmende nøkler",
|
"provisioningKeysBannerTitle": "Sidens bestemmende nøkler",
|
||||||
"provisioningKeysBannerDescription": "Generer en foreløpig nøkkel og bruk den med Nyhetskontakten for å automatisk opprette sider ved første oppstart — trenger ikke å sette opp separat innloggingsinformasjon for hver side.",
|
"provisioningKeysBannerDescription": "Generer en provisjonsnøkkel og bruk den med Newt-kontakten for automatisk opprettelse av nettsteder ved første oppstart - ingen behov for å sette opp separate legitimasjoner for hvert nettsted.",
|
||||||
"provisioningKeysBannerButtonText": "Lær mer",
|
"provisioningKeysBannerButtonText": "Lær mer",
|
||||||
"pendingSitesBannerTitle": "Ventende nettsteder",
|
"pendingSitesBannerTitle": "Ventende nettsteder",
|
||||||
"pendingSitesBannerDescription": "Nettsteder som kobler deg til ved hjelp av en bestemmelsestekst, vises her for gjennomgang. Godkjenn hvert nettsted før det blir aktivt og får tilgang til ressursene dine.",
|
"pendingSitesBannerDescription": "Nettsteder som kobler seg til ved bruk av en provisjonsnøkkel vises her for vurdering.",
|
||||||
"pendingSitesBannerButtonText": "Lær mer",
|
"pendingSitesBannerButtonText": "Lær mer",
|
||||||
"apiKeysSettings": "{apiKeyName} Innstillinger",
|
"apiKeysSettings": "{apiKeyName} Innstillinger",
|
||||||
"userTitle": "Administrer alle brukere",
|
"userTitle": "Administrer alle brukere",
|
||||||
@@ -624,6 +624,8 @@
|
|||||||
"targetErrorInvalidPortDescription": "Vennligst skriv inn et gyldig portnummer",
|
"targetErrorInvalidPortDescription": "Vennligst skriv inn et gyldig portnummer",
|
||||||
"targetErrorNoSite": "Ingen nettsted valgt",
|
"targetErrorNoSite": "Ingen nettsted valgt",
|
||||||
"targetErrorNoSiteDescription": "Velg et nettsted for målet",
|
"targetErrorNoSiteDescription": "Velg et nettsted for målet",
|
||||||
|
"targetTargetsCleared": "Mål ryddet",
|
||||||
|
"targetTargetsClearedDescription": "Alle mål har blitt fjernet fra denne ressursen",
|
||||||
"targetCreated": "Mål opprettet",
|
"targetCreated": "Mål opprettet",
|
||||||
"targetCreatedDescription": "Målet har blitt opprettet",
|
"targetCreatedDescription": "Målet har blitt opprettet",
|
||||||
"targetErrorCreate": "Kunne ikke opprette målet",
|
"targetErrorCreate": "Kunne ikke opprette målet",
|
||||||
@@ -2346,7 +2348,7 @@
|
|||||||
"description": "Enterprise features, 50 brukere, 50 nettsteder og prioritetsstøtte."
|
"description": "Enterprise features, 50 brukere, 50 nettsteder og prioritetsstøtte."
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"personalUseOnly": "Kun personlig bruk (gratis lisens - ingen utsjekking)",
|
"personalUseOnly": "Kun personlig bruk (gratis lisens - ingen kasse)",
|
||||||
"buttons": {
|
"buttons": {
|
||||||
"continueToCheckout": "Fortsett til kassen"
|
"continueToCheckout": "Fortsett til kassen"
|
||||||
},
|
},
|
||||||
@@ -2607,6 +2609,9 @@
|
|||||||
"machineClients": "Maskinklienter",
|
"machineClients": "Maskinklienter",
|
||||||
"install": "Installer",
|
"install": "Installer",
|
||||||
"run": "Kjør",
|
"run": "Kjør",
|
||||||
|
"envFile": "Miljøfil",
|
||||||
|
"serviceFile": "Tjenestefil",
|
||||||
|
"enableAndStart": "Aktiver og start",
|
||||||
"clientNameDescription": "Visningsnavnet til klienten som kan endres senere.",
|
"clientNameDescription": "Visningsnavnet til klienten som kan endres senere.",
|
||||||
"clientAddress": "Klientadresse (avansert)",
|
"clientAddress": "Klientadresse (avansert)",
|
||||||
"setupFailedToFetchSubnet": "Kunne ikke hente standard undernett",
|
"setupFailedToFetchSubnet": "Kunne ikke hente standard undernett",
|
||||||
@@ -2845,10 +2850,10 @@
|
|||||||
"httpDestAuthNoneTitle": "Ingen godkjenning",
|
"httpDestAuthNoneTitle": "Ingen godkjenning",
|
||||||
"httpDestAuthNoneDescription": "Sender forespørsler uten autorisasjonsoverskrift.",
|
"httpDestAuthNoneDescription": "Sender forespørsler uten autorisasjonsoverskrift.",
|
||||||
"httpDestAuthBearerTitle": "Bærer Symbol",
|
"httpDestAuthBearerTitle": "Bærer Symbol",
|
||||||
"httpDestAuthBearerDescription": "Legger til en autorisasjon: Bearer <token> header til hver forespørsel.",
|
"httpDestAuthBearerDescription": "Legger til en Autorisasjon: Bearer '<token>' header til hver forespørsel.",
|
||||||
"httpDestAuthBearerPlaceholder": "Din API-nøkkel eller token",
|
"httpDestAuthBearerPlaceholder": "Din API-nøkkel eller token",
|
||||||
"httpDestAuthBasicTitle": "Standard Auth",
|
"httpDestAuthBasicTitle": "Standard Auth",
|
||||||
"httpDestAuthBasicDescription": "Legger til en godkjenning: Grunnleggende <credentials> overskrift. Angi legitimasjon som brukernavn:passord.",
|
"httpDestAuthBasicDescription": "Legger til en Autorisasjon: Basic '<credentials>' header. Gi legitimasjon som brukernavn:passord.",
|
||||||
"httpDestAuthBasicPlaceholder": "brukernavn:passord",
|
"httpDestAuthBasicPlaceholder": "brukernavn:passord",
|
||||||
"httpDestAuthCustomTitle": "Egendefinert topptekst",
|
"httpDestAuthCustomTitle": "Egendefinert topptekst",
|
||||||
"httpDestAuthCustomDescription": "Angi et egendefinert HTTP headers navn og verdi for autentisering (f.eks X-API-Key).",
|
"httpDestAuthCustomDescription": "Angi et egendefinert HTTP headers navn og verdi for autentisering (f.eks X-API-Key).",
|
||||||
|
|||||||
@@ -371,10 +371,10 @@
|
|||||||
"provisioningKeysUpdated": "Provisie sleutel bijgewerkt",
|
"provisioningKeysUpdated": "Provisie sleutel bijgewerkt",
|
||||||
"provisioningKeysUpdatedDescription": "Uw wijzigingen zijn opgeslagen.",
|
"provisioningKeysUpdatedDescription": "Uw wijzigingen zijn opgeslagen.",
|
||||||
"provisioningKeysBannerTitle": "Bewerkingssleutels voor websites",
|
"provisioningKeysBannerTitle": "Bewerkingssleutels voor websites",
|
||||||
"provisioningKeysBannerDescription": "Genereer een provisioning-sleutel en gebruik deze met de Newt-connector om automatisch sites aan te maken bij het opstarten van de eerste opstart- het is niet nodig om afzonderlijke inloggegevens in te stellen voor elke site.",
|
"provisioningKeysBannerDescription": "Genereer een inrichtingssleutel en gebruik deze met de Newt-connector om automatisch sites te maken bij de eerste opstart - er is geen behoefte om aparte inloggegevens voor elke site in te stellen.",
|
||||||
"provisioningKeysBannerButtonText": "Meer informatie",
|
"provisioningKeysBannerButtonText": "Meer informatie",
|
||||||
"pendingSitesBannerTitle": "Openstaande sites",
|
"pendingSitesBannerTitle": "Openstaande sites",
|
||||||
"pendingSitesBannerDescription": "Sites die met elkaar verbinden met behulp van een provisioning-sleutel verschijnen hier voor beoordeling. Accepteer elke site voordat deze actief wordt en krijgt toegang tot uw bronnen.",
|
"pendingSitesBannerDescription": "Sites die verbinding maken met een inrichtingssleutel verschijnen hier voor beoordeling.",
|
||||||
"pendingSitesBannerButtonText": "Meer informatie",
|
"pendingSitesBannerButtonText": "Meer informatie",
|
||||||
"apiKeysSettings": "{apiKeyName} instellingen",
|
"apiKeysSettings": "{apiKeyName} instellingen",
|
||||||
"userTitle": "Alle gebruikers beheren",
|
"userTitle": "Alle gebruikers beheren",
|
||||||
@@ -624,6 +624,8 @@
|
|||||||
"targetErrorInvalidPortDescription": "Voer een geldig poortnummer in",
|
"targetErrorInvalidPortDescription": "Voer een geldig poortnummer in",
|
||||||
"targetErrorNoSite": "Geen site geselecteerd",
|
"targetErrorNoSite": "Geen site geselecteerd",
|
||||||
"targetErrorNoSiteDescription": "Selecteer een site voor het doel",
|
"targetErrorNoSiteDescription": "Selecteer een site voor het doel",
|
||||||
|
"targetTargetsCleared": "Doelen gewist",
|
||||||
|
"targetTargetsClearedDescription": "Alle doelen zijn verwijderd van deze bron",
|
||||||
"targetCreated": "Doel aangemaakt",
|
"targetCreated": "Doel aangemaakt",
|
||||||
"targetCreatedDescription": "Doel is succesvol aangemaakt",
|
"targetCreatedDescription": "Doel is succesvol aangemaakt",
|
||||||
"targetErrorCreate": "Kan doel niet aanmaken",
|
"targetErrorCreate": "Kan doel niet aanmaken",
|
||||||
@@ -2346,7 +2348,7 @@
|
|||||||
"description": "Enterprise functies, 50 gebruikers, 50 sites en prioriteit ondersteuning."
|
"description": "Enterprise functies, 50 gebruikers, 50 sites en prioriteit ondersteuning."
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"personalUseOnly": "Alleen persoonlijk gebruik (gratis licentie - geen afrekenen)",
|
"personalUseOnly": "Alleen voor persoonlijk gebruik (gratis licentie - geen afrekening)",
|
||||||
"buttons": {
|
"buttons": {
|
||||||
"continueToCheckout": "Doorgaan naar afrekenen"
|
"continueToCheckout": "Doorgaan naar afrekenen"
|
||||||
},
|
},
|
||||||
@@ -2607,6 +2609,9 @@
|
|||||||
"machineClients": "Machine Clienten",
|
"machineClients": "Machine Clienten",
|
||||||
"install": "Installeren",
|
"install": "Installeren",
|
||||||
"run": "Uitvoeren",
|
"run": "Uitvoeren",
|
||||||
|
"envFile": "Omgevingsbestand",
|
||||||
|
"serviceFile": "Servicebestand",
|
||||||
|
"enableAndStart": "Inschakelen en Starten",
|
||||||
"clientNameDescription": "De weergavenaam van de client die later gewijzigd kan worden.",
|
"clientNameDescription": "De weergavenaam van de client die later gewijzigd kan worden.",
|
||||||
"clientAddress": "Klant adres (Geavanceerd)",
|
"clientAddress": "Klant adres (Geavanceerd)",
|
||||||
"setupFailedToFetchSubnet": "Kan standaard subnet niet ophalen",
|
"setupFailedToFetchSubnet": "Kan standaard subnet niet ophalen",
|
||||||
@@ -2845,10 +2850,10 @@
|
|||||||
"httpDestAuthNoneTitle": "Geen authenticatie",
|
"httpDestAuthNoneTitle": "Geen authenticatie",
|
||||||
"httpDestAuthNoneDescription": "Stuurt verzoeken zonder toestemmingskop.",
|
"httpDestAuthNoneDescription": "Stuurt verzoeken zonder toestemmingskop.",
|
||||||
"httpDestAuthBearerTitle": "Betere Token",
|
"httpDestAuthBearerTitle": "Betere Token",
|
||||||
"httpDestAuthBearerDescription": "Voegt een machtiging toe: Drager <token> header aan elke aanvraag.",
|
"httpDestAuthBearerDescription": "Voegt een Authorization: Bearer '<token>' header toe aan elk verzoek.",
|
||||||
"httpDestAuthBearerPlaceholder": "Uw API-sleutel of -token",
|
"httpDestAuthBearerPlaceholder": "Uw API-sleutel of -token",
|
||||||
"httpDestAuthBasicTitle": "Basis authenticatie",
|
"httpDestAuthBasicTitle": "Basis authenticatie",
|
||||||
"httpDestAuthBasicDescription": "Voegt een Authorizatie toe: Basis <credentials> kop. Geef inloggegevens op als gebruikersnaam:wachtwoord.",
|
"httpDestAuthBasicDescription": "Voegt een Authorization: Basic '<credentials>' header toe. Verstrek inloggegevens als gebruikersnaam:wachtwoord.",
|
||||||
"httpDestAuthBasicPlaceholder": "Gebruikersnaam:wachtwoord",
|
"httpDestAuthBasicPlaceholder": "Gebruikersnaam:wachtwoord",
|
||||||
"httpDestAuthCustomTitle": "Aangepaste koptekst",
|
"httpDestAuthCustomTitle": "Aangepaste koptekst",
|
||||||
"httpDestAuthCustomDescription": "Specificeer een aangepaste HTTP header naam en waarde voor authenticatie (bijv. X-API-Key).",
|
"httpDestAuthCustomDescription": "Specificeer een aangepaste HTTP header naam en waarde voor authenticatie (bijv. X-API-Key).",
|
||||||
|
|||||||
@@ -371,10 +371,10 @@
|
|||||||
"provisioningKeysUpdated": "Klucz zaopatrzenia zaktualizowany",
|
"provisioningKeysUpdated": "Klucz zaopatrzenia zaktualizowany",
|
||||||
"provisioningKeysUpdatedDescription": "Twoje zmiany zostały zapisane.",
|
"provisioningKeysUpdatedDescription": "Twoje zmiany zostały zapisane.",
|
||||||
"provisioningKeysBannerTitle": "Klucze Zaopatrzenia witryny",
|
"provisioningKeysBannerTitle": "Klucze Zaopatrzenia witryny",
|
||||||
"provisioningKeysBannerDescription": "Wygeneruj klucz tworzenia rezerw i użyj go z konektorem Newt do automatycznego tworzenia witryn przy pierwszym uruchomieniu — nie ma potrzeby ustawiania oddzielnych poświadczeń dla każdej witryny.",
|
"provisioningKeysBannerDescription": "Wygeneruj klucz provisioning i użyj go z konektorem Newt do automatycznego tworzenia witryn przy pierwszym uruchomieniu - nie ma potrzeby konfigurowania oddzielnych poświadczeń dla każdej witryny.",
|
||||||
"provisioningKeysBannerButtonText": "Dowiedz się więcej",
|
"provisioningKeysBannerButtonText": "Dowiedz się więcej",
|
||||||
"pendingSitesBannerTitle": "Witryny oczekujące",
|
"pendingSitesBannerTitle": "Witryny oczekujące",
|
||||||
"pendingSitesBannerDescription": "Witryny, które łączą się przy użyciu klucza zaopatrzenia, pojawiają się tutaj, aby przejrzeć. Zatwierdź każdą witrynę, zanim stanie się aktywna i uzyska dostęp do twoich zasobów.",
|
"pendingSitesBannerDescription": "Witryny, które łączą się za pomocą klucza provisioning, pojawią się tutaj do przeglądu.",
|
||||||
"pendingSitesBannerButtonText": "Dowiedz się więcej",
|
"pendingSitesBannerButtonText": "Dowiedz się więcej",
|
||||||
"apiKeysSettings": "Ustawienia {apiKeyName}",
|
"apiKeysSettings": "Ustawienia {apiKeyName}",
|
||||||
"userTitle": "Zarządzaj wszystkimi użytkownikami",
|
"userTitle": "Zarządzaj wszystkimi użytkownikami",
|
||||||
@@ -624,6 +624,8 @@
|
|||||||
"targetErrorInvalidPortDescription": "Wprowadź prawidłowy numer portu",
|
"targetErrorInvalidPortDescription": "Wprowadź prawidłowy numer portu",
|
||||||
"targetErrorNoSite": "Nie wybrano witryny",
|
"targetErrorNoSite": "Nie wybrano witryny",
|
||||||
"targetErrorNoSiteDescription": "Wybierz witrynę docelową",
|
"targetErrorNoSiteDescription": "Wybierz witrynę docelową",
|
||||||
|
"targetTargetsCleared": "Cele wyczyszczone",
|
||||||
|
"targetTargetsClearedDescription": "Wszystkie cele zostały usunięte z tego zasobu",
|
||||||
"targetCreated": "Cel utworzony",
|
"targetCreated": "Cel utworzony",
|
||||||
"targetCreatedDescription": "Cel został utworzony pomyślnie",
|
"targetCreatedDescription": "Cel został utworzony pomyślnie",
|
||||||
"targetErrorCreate": "Nie udało się utworzyć celu",
|
"targetErrorCreate": "Nie udało się utworzyć celu",
|
||||||
@@ -2346,7 +2348,7 @@
|
|||||||
"description": "Cechy przedsiębiorstw, 50 użytkowników, 50 obiektów i wsparcie priorytetowe."
|
"description": "Cechy przedsiębiorstw, 50 użytkowników, 50 obiektów i wsparcie priorytetowe."
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"personalUseOnly": "Wyłącznie do użytku osobistego (bezpłatna licencja – brak zamówień)",
|
"personalUseOnly": "Tylko do użytku osobistego (darmowa licencja - bez płatności)",
|
||||||
"buttons": {
|
"buttons": {
|
||||||
"continueToCheckout": "Przejdź do zamówienia"
|
"continueToCheckout": "Przejdź do zamówienia"
|
||||||
},
|
},
|
||||||
@@ -2607,6 +2609,9 @@
|
|||||||
"machineClients": "Klienci maszyn",
|
"machineClients": "Klienci maszyn",
|
||||||
"install": "Zainstaluj",
|
"install": "Zainstaluj",
|
||||||
"run": "Uruchom",
|
"run": "Uruchom",
|
||||||
|
"envFile": "Plik środowiska",
|
||||||
|
"serviceFile": "Plik serwisu",
|
||||||
|
"enableAndStart": "Włącz i Uruchom",
|
||||||
"clientNameDescription": "Wyświetlana nazwa klienta, która może zostać zmieniona później.",
|
"clientNameDescription": "Wyświetlana nazwa klienta, która może zostać zmieniona później.",
|
||||||
"clientAddress": "Adres klienta (Zaawansowany)",
|
"clientAddress": "Adres klienta (Zaawansowany)",
|
||||||
"setupFailedToFetchSubnet": "Nie udało się pobrać domyślnej podsieci",
|
"setupFailedToFetchSubnet": "Nie udało się pobrać domyślnej podsieci",
|
||||||
@@ -2845,10 +2850,10 @@
|
|||||||
"httpDestAuthNoneTitle": "Brak uwierzytelniania",
|
"httpDestAuthNoneTitle": "Brak uwierzytelniania",
|
||||||
"httpDestAuthNoneDescription": "Wysyła żądania bez nagłówka autoryzacji.",
|
"httpDestAuthNoneDescription": "Wysyła żądania bez nagłówka autoryzacji.",
|
||||||
"httpDestAuthBearerTitle": "Token Bearer",
|
"httpDestAuthBearerTitle": "Token Bearer",
|
||||||
"httpDestAuthBearerDescription": "Dodaje autoryzację: nagłówek Bearer <token> do każdego żądania.",
|
"httpDestAuthBearerDescription": "Dodaje nagłówek Authorization: Bearer '<token>' do każdego żądania.",
|
||||||
"httpDestAuthBearerPlaceholder": "Twój klucz API lub token",
|
"httpDestAuthBearerPlaceholder": "Twój klucz API lub token",
|
||||||
"httpDestAuthBasicTitle": "Podstawowa Autoryzacja",
|
"httpDestAuthBasicTitle": "Podstawowa Autoryzacja",
|
||||||
"httpDestAuthBasicDescription": "Dodaje Autoryzacja: Nagłówek Basic <credentials> . Podaj poświadczenia jako nazwę użytkownika: hasło.",
|
"httpDestAuthBasicDescription": "Dodaje nagłówek Authorization: Basic '<credentials>'. Podaj poświadczenia w formacie użytkownik:hasło.",
|
||||||
"httpDestAuthBasicPlaceholder": "Nazwa użytkownika:hasło",
|
"httpDestAuthBasicPlaceholder": "Nazwa użytkownika:hasło",
|
||||||
"httpDestAuthCustomTitle": "Niestandardowy nagłówek",
|
"httpDestAuthCustomTitle": "Niestandardowy nagłówek",
|
||||||
"httpDestAuthCustomDescription": "Określ niestandardową nazwę nagłówka HTTP i wartość dla uwierzytelniania (np. X-API-Key).",
|
"httpDestAuthCustomDescription": "Określ niestandardową nazwę nagłówka HTTP i wartość dla uwierzytelniania (np. X-API-Key).",
|
||||||
|
|||||||
@@ -371,10 +371,10 @@
|
|||||||
"provisioningKeysUpdated": "Chave de provisionamento atualizada",
|
"provisioningKeysUpdated": "Chave de provisionamento atualizada",
|
||||||
"provisioningKeysUpdatedDescription": "Suas alterações foram salvas.",
|
"provisioningKeysUpdatedDescription": "Suas alterações foram salvas.",
|
||||||
"provisioningKeysBannerTitle": "Chaves de provisionamento do site",
|
"provisioningKeysBannerTitle": "Chaves de provisionamento do site",
|
||||||
"provisioningKeysBannerDescription": "Gerar uma chave de provisionamento e usá-la com o conector de Newt para criar automaticamente sites na primeira inicialização — não é necessário configurar credenciais separadas para cada site.",
|
"provisioningKeysBannerDescription": "Gere uma chave de provisionamento e use-a com o conector Newt para criar sites automaticamente na primeira inicialização - sem necessidade de configurar credenciais separadas para cada site.",
|
||||||
"provisioningKeysBannerButtonText": "Saiba mais",
|
"provisioningKeysBannerButtonText": "Saiba mais",
|
||||||
"pendingSitesBannerTitle": "Sites pendentes",
|
"pendingSitesBannerTitle": "Sites pendentes",
|
||||||
"pendingSitesBannerDescription": "Sites que conectam usando uma chave de provisionamento aparecem aqui para revisão. Aprovar cada site antes de se tornar ativo e ganhar acesso a seus recursos.",
|
"pendingSitesBannerDescription": "Sites que se conectam usando uma chave de provisionamento aparecem aqui para revisão.",
|
||||||
"pendingSitesBannerButtonText": "Saiba mais",
|
"pendingSitesBannerButtonText": "Saiba mais",
|
||||||
"apiKeysSettings": "Configurações de {apiKeyName}",
|
"apiKeysSettings": "Configurações de {apiKeyName}",
|
||||||
"userTitle": "Gerir Todos os Utilizadores",
|
"userTitle": "Gerir Todos os Utilizadores",
|
||||||
@@ -624,6 +624,8 @@
|
|||||||
"targetErrorInvalidPortDescription": "Por favor, digite um número de porta válido",
|
"targetErrorInvalidPortDescription": "Por favor, digite um número de porta válido",
|
||||||
"targetErrorNoSite": "Nenhum site selecionado",
|
"targetErrorNoSite": "Nenhum site selecionado",
|
||||||
"targetErrorNoSiteDescription": "Selecione um site para o destino",
|
"targetErrorNoSiteDescription": "Selecione um site para o destino",
|
||||||
|
"targetTargetsCleared": "Alvos limpos",
|
||||||
|
"targetTargetsClearedDescription": "Todos os alvos foram removidos deste recurso",
|
||||||
"targetCreated": "Destino criado",
|
"targetCreated": "Destino criado",
|
||||||
"targetCreatedDescription": "O alvo foi criado com sucesso",
|
"targetCreatedDescription": "O alvo foi criado com sucesso",
|
||||||
"targetErrorCreate": "Falha ao criar destino",
|
"targetErrorCreate": "Falha ao criar destino",
|
||||||
@@ -2346,7 +2348,7 @@
|
|||||||
"description": "Recursos de empresa, 50 usuários, 50 sites e apoio prioritário."
|
"description": "Recursos de empresa, 50 usuários, 50 sites e apoio prioritário."
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"personalUseOnly": "Apenas uso pessoal (licença gratuita — sem check-out)",
|
"personalUseOnly": "Uso pessoal apenas (licença gratuita - sem checkout)",
|
||||||
"buttons": {
|
"buttons": {
|
||||||
"continueToCheckout": "Continuar com checkout"
|
"continueToCheckout": "Continuar com checkout"
|
||||||
},
|
},
|
||||||
@@ -2607,6 +2609,9 @@
|
|||||||
"machineClients": "Clientes de máquina",
|
"machineClients": "Clientes de máquina",
|
||||||
"install": "Instale",
|
"install": "Instale",
|
||||||
"run": "Executar",
|
"run": "Executar",
|
||||||
|
"envFile": "Arquivo de Ambiente",
|
||||||
|
"serviceFile": "Arquivo de Serviço",
|
||||||
|
"enableAndStart": "Ativar e Iniciar",
|
||||||
"clientNameDescription": "O nome de exibição do cliente que pode ser alterado mais tarde.",
|
"clientNameDescription": "O nome de exibição do cliente que pode ser alterado mais tarde.",
|
||||||
"clientAddress": "Endereço do Cliente (Avançado)",
|
"clientAddress": "Endereço do Cliente (Avançado)",
|
||||||
"setupFailedToFetchSubnet": "Falha ao buscar a subrede padrão",
|
"setupFailedToFetchSubnet": "Falha ao buscar a subrede padrão",
|
||||||
@@ -2845,10 +2850,10 @@
|
|||||||
"httpDestAuthNoneTitle": "Sem Autenticação",
|
"httpDestAuthNoneTitle": "Sem Autenticação",
|
||||||
"httpDestAuthNoneDescription": "Envia pedidos sem um cabeçalho de autorização.",
|
"httpDestAuthNoneDescription": "Envia pedidos sem um cabeçalho de autorização.",
|
||||||
"httpDestAuthBearerTitle": "Token do portador",
|
"httpDestAuthBearerTitle": "Token do portador",
|
||||||
"httpDestAuthBearerDescription": "Adiciona uma autorização: Bearer <token> header a cada requisição.",
|
"httpDestAuthBearerDescription": "Adiciona um cabeçalho Authorization: Bearer '<token>' a cada solicitação.",
|
||||||
"httpDestAuthBearerPlaceholder": "Sua chave de API ou token",
|
"httpDestAuthBearerPlaceholder": "Sua chave de API ou token",
|
||||||
"httpDestAuthBasicTitle": "Autenticação básica",
|
"httpDestAuthBasicTitle": "Autenticação básica",
|
||||||
"httpDestAuthBasicDescription": "Adiciona uma Autorização: cabeçalho <credentials> básico. Forneça credenciais como nome de usuário:senha.",
|
"httpDestAuthBasicDescription": "Adiciona um cabeçalho Authorization: Basic '<credentials>'. Forneça as credenciais como username:password.",
|
||||||
"httpDestAuthBasicPlaceholder": "Usuário:password",
|
"httpDestAuthBasicPlaceholder": "Usuário:password",
|
||||||
"httpDestAuthCustomTitle": "Cabeçalho personalizado",
|
"httpDestAuthCustomTitle": "Cabeçalho personalizado",
|
||||||
"httpDestAuthCustomDescription": "Especifique um nome e valor de cabeçalho HTTP personalizado para autenticação (por exemplo, X-API-Key).",
|
"httpDestAuthCustomDescription": "Especifique um nome e valor de cabeçalho HTTP personalizado para autenticação (por exemplo, X-API-Key).",
|
||||||
|
|||||||
@@ -371,10 +371,10 @@
|
|||||||
"provisioningKeysUpdated": "Ключ подготовки обновлен",
|
"provisioningKeysUpdated": "Ключ подготовки обновлен",
|
||||||
"provisioningKeysUpdatedDescription": "Ваши изменения были сохранены.",
|
"provisioningKeysUpdatedDescription": "Ваши изменения были сохранены.",
|
||||||
"provisioningKeysBannerTitle": "Ключи подготовки сайта",
|
"provisioningKeysBannerTitle": "Ключи подготовки сайта",
|
||||||
"provisioningKeysBannerDescription": "Генерировать подготовительный ключ и использовать его вместе с Новым коннектором для автоматического создания сайтов при первом запуске — нет необходимости настраивать отдельные учетные данные для каждого сайта.",
|
"provisioningKeysBannerDescription": "Создайте ключ настройки и используйте его с соединителем Newt для автоматического создания сайтов при первом запуске — нет необходимости настраивать отдельные учетные данные для каждого сайта.",
|
||||||
"provisioningKeysBannerButtonText": "Узнать больше",
|
"provisioningKeysBannerButtonText": "Узнать больше",
|
||||||
"pendingSitesBannerTitle": "Ожидающие сайты",
|
"pendingSitesBannerTitle": "Ожидающие сайты",
|
||||||
"pendingSitesBannerDescription": "Сайты, связанные с использованием ключа подготовки, появляются здесь для проверки. Одобрите каждый сайт, прежде чем он станет активным и получит доступ к вашим ресурсам.",
|
"pendingSitesBannerDescription": "Сайты, подключающиеся с помощью ключа настройки, отображаются здесь для проверки.",
|
||||||
"pendingSitesBannerButtonText": "Узнать больше",
|
"pendingSitesBannerButtonText": "Узнать больше",
|
||||||
"apiKeysSettings": "Настройки {apiKeyName}",
|
"apiKeysSettings": "Настройки {apiKeyName}",
|
||||||
"userTitle": "Управление всеми пользователями",
|
"userTitle": "Управление всеми пользователями",
|
||||||
@@ -624,6 +624,8 @@
|
|||||||
"targetErrorInvalidPortDescription": "Пожалуйста, введите правильный номер порта",
|
"targetErrorInvalidPortDescription": "Пожалуйста, введите правильный номер порта",
|
||||||
"targetErrorNoSite": "Сайт не выбран",
|
"targetErrorNoSite": "Сайт не выбран",
|
||||||
"targetErrorNoSiteDescription": "Пожалуйста, выберите сайт для цели",
|
"targetErrorNoSiteDescription": "Пожалуйста, выберите сайт для цели",
|
||||||
|
"targetTargetsCleared": "Цели очищены",
|
||||||
|
"targetTargetsClearedDescription": "Все цели удалены из этого ресурса",
|
||||||
"targetCreated": "Цель создана",
|
"targetCreated": "Цель создана",
|
||||||
"targetCreatedDescription": "Цель была успешно создана",
|
"targetCreatedDescription": "Цель была успешно создана",
|
||||||
"targetErrorCreate": "Не удалось создать цель",
|
"targetErrorCreate": "Не удалось создать цель",
|
||||||
@@ -2346,7 +2348,7 @@
|
|||||||
"description": "Функции предприятия, 50 пользователей, 50 сайтов, а также приоритетная поддержка."
|
"description": "Функции предприятия, 50 пользователей, 50 сайтов, а также приоритетная поддержка."
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"personalUseOnly": "Только для личного пользования (бесплатная лицензия — без оформления)",
|
"personalUseOnly": "Только для личного использования (бесплатная лицензия - без оформления на кассе)",
|
||||||
"buttons": {
|
"buttons": {
|
||||||
"continueToCheckout": "Продолжить оформление заказа"
|
"continueToCheckout": "Продолжить оформление заказа"
|
||||||
},
|
},
|
||||||
@@ -2607,6 +2609,9 @@
|
|||||||
"machineClients": "Машинные клиенты",
|
"machineClients": "Машинные клиенты",
|
||||||
"install": "Установить",
|
"install": "Установить",
|
||||||
"run": "Запустить",
|
"run": "Запустить",
|
||||||
|
"envFile": "Файл окружения",
|
||||||
|
"serviceFile": "Сервисный файл",
|
||||||
|
"enableAndStart": "Включить и запустить",
|
||||||
"clientNameDescription": "Отображаемое имя клиента, которое может быть изменено позже.",
|
"clientNameDescription": "Отображаемое имя клиента, которое может быть изменено позже.",
|
||||||
"clientAddress": "Адрес клиента (Дополнительно)",
|
"clientAddress": "Адрес клиента (Дополнительно)",
|
||||||
"setupFailedToFetchSubnet": "Не удалось получить подсеть по умолчанию",
|
"setupFailedToFetchSubnet": "Не удалось получить подсеть по умолчанию",
|
||||||
@@ -2845,10 +2850,10 @@
|
|||||||
"httpDestAuthNoneTitle": "Нет аутентификации",
|
"httpDestAuthNoneTitle": "Нет аутентификации",
|
||||||
"httpDestAuthNoneDescription": "Отправляет запросы без заголовка авторизации.",
|
"httpDestAuthNoneDescription": "Отправляет запросы без заголовка авторизации.",
|
||||||
"httpDestAuthBearerTitle": "Жетон носителя",
|
"httpDestAuthBearerTitle": "Жетон носителя",
|
||||||
"httpDestAuthBearerDescription": "Добавляет заголовок Authorization: Bearer <token> к каждому запросу.",
|
"httpDestAuthBearerDescription": "Добавляет заголовок Authorization: Bearer '<token>' к каждому запросу.",
|
||||||
"httpDestAuthBearerPlaceholder": "Ваш ключ API или токен",
|
"httpDestAuthBearerPlaceholder": "Ваш ключ API или токен",
|
||||||
"httpDestAuthBasicTitle": "Базовая авторизация",
|
"httpDestAuthBasicTitle": "Базовая авторизация",
|
||||||
"httpDestAuthBasicDescription": "Добавляет Authorization: Basic <credentials> header. Предоставьте учетные данные в качестве имени пользователя:password.",
|
"httpDestAuthBasicDescription": "Добавляет заголовок Authorization: Basic '<credentials>'. Укажите учетные данные в формате username:password.",
|
||||||
"httpDestAuthBasicPlaceholder": "имя пользователя:пароль",
|
"httpDestAuthBasicPlaceholder": "имя пользователя:пароль",
|
||||||
"httpDestAuthCustomTitle": "Пользовательский заголовок",
|
"httpDestAuthCustomTitle": "Пользовательский заголовок",
|
||||||
"httpDestAuthCustomDescription": "Укажите пользовательское имя заголовка HTTP и значение для аутентификации (например, X-API-Key).",
|
"httpDestAuthCustomDescription": "Укажите пользовательское имя заголовка HTTP и значение для аутентификации (например, X-API-Key).",
|
||||||
|
|||||||
@@ -371,10 +371,10 @@
|
|||||||
"provisioningKeysUpdated": "Tedarik anahtarı güncellendi",
|
"provisioningKeysUpdated": "Tedarik anahtarı güncellendi",
|
||||||
"provisioningKeysUpdatedDescription": "Değişiklikleriniz kaydedildi.",
|
"provisioningKeysUpdatedDescription": "Değişiklikleriniz kaydedildi.",
|
||||||
"provisioningKeysBannerTitle": "Site Tedarik Anahtarları",
|
"provisioningKeysBannerTitle": "Site Tedarik Anahtarları",
|
||||||
"provisioningKeysBannerDescription": "Tedarik anahtarı oluşturun ve ilk başlangıçta siteleri otomatik olarak oluşturmak için Newt konektörüyle kullanın — her site için ayrı kimlik bilgileri ayarlamaya gerek yoktur.",
|
"provisioningKeysBannerDescription": "Bir sağlama anahtarı oluşturun ve ilk başlangıçta siteleri otomatik olarak oluşturmak için Newt bağlayıcısını kullanın - her site için ayrı kimlik bilgileri ayarlamaya gerek yok.",
|
||||||
"provisioningKeysBannerButtonText": "Daha fazla bilgi",
|
"provisioningKeysBannerButtonText": "Daha fazla bilgi",
|
||||||
"pendingSitesBannerTitle": "Bekleyen Siteler",
|
"pendingSitesBannerTitle": "Bekleyen Siteler",
|
||||||
"pendingSitesBannerDescription": "Tedarik anahtarı kullanarak bağlanan siteler burada incelenmek için görünür. Aktif hale gelmeden ve kaynaklarınıza erişim kazanmadan önce her siteyi onaylayın.",
|
"pendingSitesBannerDescription": "Bir sağlama anahtarı kullanarak bağlanan siteler, inceleme için burada görünür.",
|
||||||
"pendingSitesBannerButtonText": "Daha fazla bilgi",
|
"pendingSitesBannerButtonText": "Daha fazla bilgi",
|
||||||
"apiKeysSettings": "{apiKeyName} Ayarları",
|
"apiKeysSettings": "{apiKeyName} Ayarları",
|
||||||
"userTitle": "Tüm Kullanıcıları Yönet",
|
"userTitle": "Tüm Kullanıcıları Yönet",
|
||||||
@@ -624,6 +624,8 @@
|
|||||||
"targetErrorInvalidPortDescription": "Lütfen geçerli bir port numarası girin",
|
"targetErrorInvalidPortDescription": "Lütfen geçerli bir port numarası girin",
|
||||||
"targetErrorNoSite": "Hiçbir site seçili değil",
|
"targetErrorNoSite": "Hiçbir site seçili değil",
|
||||||
"targetErrorNoSiteDescription": "Lütfen hedef için bir site seçin",
|
"targetErrorNoSiteDescription": "Lütfen hedef için bir site seçin",
|
||||||
|
"targetTargetsCleared": "Hedefler temizlendi",
|
||||||
|
"targetTargetsClearedDescription": "Bu kaynaktan tüm hedefler kaldırıldı",
|
||||||
"targetCreated": "Hedef oluşturuldu",
|
"targetCreated": "Hedef oluşturuldu",
|
||||||
"targetCreatedDescription": "Hedef başarıyla oluşturuldu",
|
"targetCreatedDescription": "Hedef başarıyla oluşturuldu",
|
||||||
"targetErrorCreate": "Hedef oluşturma başarısız oldu",
|
"targetErrorCreate": "Hedef oluşturma başarısız oldu",
|
||||||
@@ -2346,7 +2348,7 @@
|
|||||||
"description": "Kurumsal özellikler, 50 kullanıcı, 50 site ve öncelikli destek."
|
"description": "Kurumsal özellikler, 50 kullanıcı, 50 site ve öncelikli destek."
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"personalUseOnly": "Yalnızca kişisel kullanım (ücretsiz lisans — ödeme yapılmaz)",
|
"personalUseOnly": "Kişisel kullanım için (ücretsiz lisans - ödeme yok)",
|
||||||
"buttons": {
|
"buttons": {
|
||||||
"continueToCheckout": "Ödemeye Devam Et"
|
"continueToCheckout": "Ödemeye Devam Et"
|
||||||
},
|
},
|
||||||
@@ -2607,6 +2609,9 @@
|
|||||||
"machineClients": "Makine İstemcileri",
|
"machineClients": "Makine İstemcileri",
|
||||||
"install": "Yükle",
|
"install": "Yükle",
|
||||||
"run": "Çalıştır",
|
"run": "Çalıştır",
|
||||||
|
"envFile": "Ortam Dosyası",
|
||||||
|
"serviceFile": "Servis Dosyası",
|
||||||
|
"enableAndStart": "Etkinleştir ve Başlat",
|
||||||
"clientNameDescription": "Daha sonra değiştirilebilecek istemcinin görünen adı.",
|
"clientNameDescription": "Daha sonra değiştirilebilecek istemcinin görünen adı.",
|
||||||
"clientAddress": "İstemci Adresi (Gelişmiş)",
|
"clientAddress": "İstemci Adresi (Gelişmiş)",
|
||||||
"setupFailedToFetchSubnet": "Varsayılan alt ağ alınamadı",
|
"setupFailedToFetchSubnet": "Varsayılan alt ağ alınamadı",
|
||||||
@@ -2845,10 +2850,10 @@
|
|||||||
"httpDestAuthNoneTitle": "Kimlik Doğrulama Yok",
|
"httpDestAuthNoneTitle": "Kimlik Doğrulama Yok",
|
||||||
"httpDestAuthNoneDescription": "Yetkilendirme başlığı olmadan istekler gönderir.",
|
"httpDestAuthNoneDescription": "Yetkilendirme başlığı olmadan istekler gönderir.",
|
||||||
"httpDestAuthBearerTitle": "Taşıyıcı Jetonu",
|
"httpDestAuthBearerTitle": "Taşıyıcı Jetonu",
|
||||||
"httpDestAuthBearerDescription": "Her isteğe bir Yetkilendirme: Taşıyıcı <token> başlığı ekler.",
|
"httpDestAuthBearerDescription": "Her isteğe bir Yetkilendirme: Taşıyıcı '<token>' üst bilgisi ekler.",
|
||||||
"httpDestAuthBearerPlaceholder": "API anahtarınız veya jetonunuz",
|
"httpDestAuthBearerPlaceholder": "API anahtarınız veya jetonunuz",
|
||||||
"httpDestAuthBasicTitle": "Temel Kimlik Doğrulama",
|
"httpDestAuthBasicTitle": "Temel Kimlik Doğrulama",
|
||||||
"httpDestAuthBasicDescription": "Authorization: Temel <belirtecikler> başlığı ekler. Yetkilendirmeleri kullanıcı adı:şifre olarak sağlayın.",
|
"httpDestAuthBasicDescription": "Bir Yetkilendirme: Temel '<credentials>' üst bilgisi ekler. Kimlik bilgilerini kullanıcı adı:şifre olarak sağlayın.",
|
||||||
"httpDestAuthBasicPlaceholder": "kullanıcı adı:şifre",
|
"httpDestAuthBasicPlaceholder": "kullanıcı adı:şifre",
|
||||||
"httpDestAuthCustomTitle": "Özel Başlık",
|
"httpDestAuthCustomTitle": "Özel Başlık",
|
||||||
"httpDestAuthCustomDescription": "Kimlik doğrulama için özel bir HTTP başlık adı ve değer belirtin (örn. X-API-Key).",
|
"httpDestAuthCustomDescription": "Kimlik doğrulama için özel bir HTTP başlık adı ve değer belirtin (örn. X-API-Key).",
|
||||||
|
|||||||
@@ -371,10 +371,10 @@
|
|||||||
"provisioningKeysUpdated": "置备密钥已更新",
|
"provisioningKeysUpdated": "置备密钥已更新",
|
||||||
"provisioningKeysUpdatedDescription": "您的更改已保存。",
|
"provisioningKeysUpdatedDescription": "您的更改已保存。",
|
||||||
"provisioningKeysBannerTitle": "站点置备密钥",
|
"provisioningKeysBannerTitle": "站点置备密钥",
|
||||||
"provisioningKeysBannerDescription": "生成一个预配键并使用它来在首次启动时自动创建站点——无需为每个站点设置单独的凭证。",
|
"provisioningKeysBannerDescription": "生成一个供应密钥,并将其与 Newt 连接器一起使用,以在首次启动时自动创建站点 - 无需为每个站点设置单独的凭据。",
|
||||||
"provisioningKeysBannerButtonText": "了解更多",
|
"provisioningKeysBannerButtonText": "了解更多",
|
||||||
"pendingSitesBannerTitle": "待定站点",
|
"pendingSitesBannerTitle": "待定站点",
|
||||||
"pendingSitesBannerDescription": "使用预配键连接的站点会出现在这里供审核。在站点开始运行之前批准并获取对您资源的访问权限。",
|
"pendingSitesBannerDescription": "使用供应密钥连接的站点将在此显示以供审核。",
|
||||||
"pendingSitesBannerButtonText": "了解更多",
|
"pendingSitesBannerButtonText": "了解更多",
|
||||||
"apiKeysSettings": "{apiKeyName} 设置",
|
"apiKeysSettings": "{apiKeyName} 设置",
|
||||||
"userTitle": "管理所有用户",
|
"userTitle": "管理所有用户",
|
||||||
@@ -624,6 +624,8 @@
|
|||||||
"targetErrorInvalidPortDescription": "请输入有效的端口号",
|
"targetErrorInvalidPortDescription": "请输入有效的端口号",
|
||||||
"targetErrorNoSite": "没有选择站点",
|
"targetErrorNoSite": "没有选择站点",
|
||||||
"targetErrorNoSiteDescription": "请选择目标站点",
|
"targetErrorNoSiteDescription": "请选择目标站点",
|
||||||
|
"targetTargetsCleared": "目标已清除",
|
||||||
|
"targetTargetsClearedDescription": "所有目标已从此资源中移除",
|
||||||
"targetCreated": "目标已创建",
|
"targetCreated": "目标已创建",
|
||||||
"targetCreatedDescription": "目标已成功创建",
|
"targetCreatedDescription": "目标已成功创建",
|
||||||
"targetErrorCreate": "创建目标失败",
|
"targetErrorCreate": "创建目标失败",
|
||||||
@@ -2346,7 +2348,7 @@
|
|||||||
"description": "企业特征、50个用户、50个站点和优先支持。"
|
"description": "企业特征、50个用户、50个站点和优先支持。"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"personalUseOnly": "仅供个人使用 (免费许可证-无签出)",
|
"personalUseOnly": "仅限个人使用(免费许可 - 无需结账)",
|
||||||
"buttons": {
|
"buttons": {
|
||||||
"continueToCheckout": "继续签出"
|
"continueToCheckout": "继续签出"
|
||||||
},
|
},
|
||||||
@@ -2607,6 +2609,9 @@
|
|||||||
"machineClients": "机器客户端",
|
"machineClients": "机器客户端",
|
||||||
"install": "安装",
|
"install": "安装",
|
||||||
"run": "运行",
|
"run": "运行",
|
||||||
|
"envFile": "环境文件",
|
||||||
|
"serviceFile": "服务文件",
|
||||||
|
"enableAndStart": "启用并启动",
|
||||||
"clientNameDescription": "可以稍后更改的客户端的显示名称。",
|
"clientNameDescription": "可以稍后更改的客户端的显示名称。",
|
||||||
"clientAddress": "客户端地址 (高级)",
|
"clientAddress": "客户端地址 (高级)",
|
||||||
"setupFailedToFetchSubnet": "获取默认子网失败",
|
"setupFailedToFetchSubnet": "获取默认子网失败",
|
||||||
@@ -2845,10 +2850,10 @@
|
|||||||
"httpDestAuthNoneTitle": "无身份验证",
|
"httpDestAuthNoneTitle": "无身份验证",
|
||||||
"httpDestAuthNoneDescription": "在没有授权头的情况下发送请求。",
|
"httpDestAuthNoneDescription": "在没有授权头的情况下发送请求。",
|
||||||
"httpDestAuthBearerTitle": "持有者令牌",
|
"httpDestAuthBearerTitle": "持有者令牌",
|
||||||
"httpDestAuthBearerDescription": "添加授权:每个请求的标题为 <token>。",
|
"httpDestAuthBearerDescription": "在每个请求中添加授权:Bearer “<token>” 头。",
|
||||||
"httpDestAuthBearerPlaceholder": "您的 API 密钥或令牌",
|
"httpDestAuthBearerPlaceholder": "您的 API 密钥或令牌",
|
||||||
"httpDestAuthBasicTitle": "基本认证",
|
"httpDestAuthBasicTitle": "基本认证",
|
||||||
"httpDestAuthBasicDescription": "添加授权:基本 <credentials> 头。提供用户名:密码的凭据。",
|
"httpDestAuthBasicDescription": "添加一个Authorization: Basic \"<凭据>\" 标头。 以用户名:密码形式提供凭据。",
|
||||||
"httpDestAuthBasicPlaceholder": "用户名:密码",
|
"httpDestAuthBasicPlaceholder": "用户名:密码",
|
||||||
"httpDestAuthCustomTitle": "自定义标题",
|
"httpDestAuthCustomTitle": "自定义标题",
|
||||||
"httpDestAuthCustomDescription": "指定自定义 HTTP 头名称和身份验证值 (例如,X-API 键)。",
|
"httpDestAuthCustomDescription": "指定自定义 HTTP 头名称和身份验证值 (例如,X-API 键)。",
|
||||||
|
|||||||
@@ -57,7 +57,9 @@ export const orgs = pgTable("orgs", {
|
|||||||
settingsLogRetentionDaysAction: integer("settingsLogRetentionDaysAction") // where 0 = dont keep logs and -1 = keep forever and 9001 = end of the following year
|
settingsLogRetentionDaysAction: integer("settingsLogRetentionDaysAction") // where 0 = dont keep logs and -1 = keep forever and 9001 = end of the following year
|
||||||
.notNull()
|
.notNull()
|
||||||
.default(0),
|
.default(0),
|
||||||
settingsLogRetentionDaysConnection: integer("settingsLogRetentionDaysConnection") // where 0 = dont keep logs and -1 = keep forever and 9001 = end of the following year
|
settingsLogRetentionDaysConnection: integer(
|
||||||
|
"settingsLogRetentionDaysConnection"
|
||||||
|
) // where 0 = dont keep logs and -1 = keep forever and 9001 = end of the following year
|
||||||
.notNull()
|
.notNull()
|
||||||
.default(0),
|
.default(0),
|
||||||
sshCaPrivateKey: text("sshCaPrivateKey"), // Encrypted SSH CA private key (PEM format)
|
sshCaPrivateKey: text("sshCaPrivateKey"), // Encrypted SSH CA private key (PEM format)
|
||||||
@@ -101,7 +103,9 @@ export const sites = pgTable("sites", {
|
|||||||
lastHolePunch: bigint("lastHolePunch", { mode: "number" }),
|
lastHolePunch: bigint("lastHolePunch", { mode: "number" }),
|
||||||
listenPort: integer("listenPort"),
|
listenPort: integer("listenPort"),
|
||||||
dockerSocketEnabled: boolean("dockerSocketEnabled").notNull().default(true),
|
dockerSocketEnabled: boolean("dockerSocketEnabled").notNull().default(true),
|
||||||
status: varchar("status").$type<"pending" | "approved">().default("approved")
|
status: varchar("status")
|
||||||
|
.$type<"pending" | "approved">()
|
||||||
|
.default("approved")
|
||||||
});
|
});
|
||||||
|
|
||||||
export const resources = pgTable("resources", {
|
export const resources = pgTable("resources", {
|
||||||
@@ -230,8 +234,9 @@ export const siteResources = pgTable("siteResources", {
|
|||||||
.references(() => orgs.orgId, { onDelete: "cascade" }),
|
.references(() => orgs.orgId, { onDelete: "cascade" }),
|
||||||
niceId: varchar("niceId").notNull(),
|
niceId: varchar("niceId").notNull(),
|
||||||
name: varchar("name").notNull(),
|
name: varchar("name").notNull(),
|
||||||
mode: varchar("mode").$type<"host" | "cidr">().notNull(), // "host" | "cidr" | "port"
|
ssl: boolean("ssl").notNull().default(false),
|
||||||
protocol: varchar("protocol"), // only for port mode
|
mode: varchar("mode").$type<"host" | "cidr" | "http">().notNull(), // "host" | "cidr" | "http"
|
||||||
|
scheme: varchar("scheme").$type<"http" | "https">(), // only for when we are doing https or http mode
|
||||||
proxyPort: integer("proxyPort"), // only for port mode
|
proxyPort: integer("proxyPort"), // only for port mode
|
||||||
destinationPort: integer("destinationPort"), // only for port mode
|
destinationPort: integer("destinationPort"), // only for port mode
|
||||||
destination: varchar("destination").notNull(), // ip, cidr, hostname; validate against the mode
|
destination: varchar("destination").notNull(), // ip, cidr, hostname; validate against the mode
|
||||||
|
|||||||
@@ -54,7 +54,9 @@ export const orgs = sqliteTable("orgs", {
|
|||||||
settingsLogRetentionDaysAction: integer("settingsLogRetentionDaysAction") // where 0 = dont keep logs and -1 = keep forever and 9001 = end of the following year
|
settingsLogRetentionDaysAction: integer("settingsLogRetentionDaysAction") // where 0 = dont keep logs and -1 = keep forever and 9001 = end of the following year
|
||||||
.notNull()
|
.notNull()
|
||||||
.default(0),
|
.default(0),
|
||||||
settingsLogRetentionDaysConnection: integer("settingsLogRetentionDaysConnection") // where 0 = dont keep logs and -1 = keep forever and 9001 = end of the following year
|
settingsLogRetentionDaysConnection: integer(
|
||||||
|
"settingsLogRetentionDaysConnection"
|
||||||
|
) // where 0 = dont keep logs and -1 = keep forever and 9001 = end of the following year
|
||||||
.notNull()
|
.notNull()
|
||||||
.default(0),
|
.default(0),
|
||||||
sshCaPrivateKey: text("sshCaPrivateKey"), // Encrypted SSH CA private key (PEM format)
|
sshCaPrivateKey: text("sshCaPrivateKey"), // Encrypted SSH CA private key (PEM format)
|
||||||
@@ -258,8 +260,9 @@ export const siteResources = sqliteTable("siteResources", {
|
|||||||
.references(() => orgs.orgId, { onDelete: "cascade" }),
|
.references(() => orgs.orgId, { onDelete: "cascade" }),
|
||||||
niceId: text("niceId").notNull(),
|
niceId: text("niceId").notNull(),
|
||||||
name: text("name").notNull(),
|
name: text("name").notNull(),
|
||||||
mode: text("mode").$type<"host" | "cidr">().notNull(), // "host" | "cidr" | "port"
|
ssl: integer("ssl", { mode: "boolean" }).notNull().default(false),
|
||||||
protocol: text("protocol"), // only for port mode
|
mode: text("mode").$type<"host" | "cidr" | "http">().notNull(), // "host" | "cidr" | "http"
|
||||||
|
scheme: text("scheme").$type<"http" | "https">(), // only for when we are doing https or http mode
|
||||||
proxyPort: integer("proxyPort"), // only for port mode
|
proxyPort: integer("proxyPort"), // only for port mode
|
||||||
destinationPort: integer("destinationPort"), // only for port mode
|
destinationPort: integer("destinationPort"), // only for port mode
|
||||||
destination: text("destination").notNull(), // ip, cidr, hostname
|
destination: text("destination").notNull(), // ip, cidr, hostname
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ import { TraefikConfigManager } from "@server/lib/traefik/TraefikConfigManager";
|
|||||||
import { initCleanup } from "#dynamic/cleanup";
|
import { initCleanup } from "#dynamic/cleanup";
|
||||||
import license from "#dynamic/license/license";
|
import license from "#dynamic/license/license";
|
||||||
import { initLogCleanupInterval } from "@server/lib/cleanupLogs";
|
import { initLogCleanupInterval } from "@server/lib/cleanupLogs";
|
||||||
|
import { initAcmeCertSync } from "#dynamic/lib/acmeCertSync";
|
||||||
import { fetchServerIp } from "@server/lib/serverIpService";
|
import { fetchServerIp } from "@server/lib/serverIpService";
|
||||||
|
|
||||||
async function startServers() {
|
async function startServers() {
|
||||||
@@ -39,6 +40,7 @@ async function startServers() {
|
|||||||
initTelemetryClient();
|
initTelemetryClient();
|
||||||
|
|
||||||
initLogCleanupInterval();
|
initLogCleanupInterval();
|
||||||
|
initAcmeCertSync();
|
||||||
|
|
||||||
// Start all servers
|
// Start all servers
|
||||||
const apiServer = createApiServer();
|
const apiServer = createApiServer();
|
||||||
|
|||||||
3
server/lib/acmeCertSync.ts
Normal file
3
server/lib/acmeCertSync.ts
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
export function initAcmeCertSync(): void {
|
||||||
|
// stub
|
||||||
|
}
|
||||||
@@ -16,6 +16,20 @@ import { Config } from "./types";
|
|||||||
import logger from "@server/logger";
|
import logger from "@server/logger";
|
||||||
import { getNextAvailableAliasAddress } from "../ip";
|
import { getNextAvailableAliasAddress } from "../ip";
|
||||||
|
|
||||||
|
function siteResourceModeForDb(mode: "host" | "cidr" | "http" | "https"): {
|
||||||
|
mode: "host" | "cidr" | "http";
|
||||||
|
ssl: boolean;
|
||||||
|
scheme: "http" | "https" | null;
|
||||||
|
} {
|
||||||
|
if (mode === "https") {
|
||||||
|
return { mode: "http", ssl: true, scheme: "https" };
|
||||||
|
}
|
||||||
|
if (mode === "http") {
|
||||||
|
return { mode: "http", ssl: false, scheme: "http" };
|
||||||
|
}
|
||||||
|
return { mode, ssl: false, scheme: null };
|
||||||
|
}
|
||||||
|
|
||||||
export type ClientResourcesResults = {
|
export type ClientResourcesResults = {
|
||||||
newSiteResource: SiteResource;
|
newSiteResource: SiteResource;
|
||||||
oldSiteResource?: SiteResource;
|
oldSiteResource?: SiteResource;
|
||||||
@@ -76,14 +90,18 @@ export async function updateClientResources(
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (existingResource) {
|
if (existingResource) {
|
||||||
|
const mappedMode = siteResourceModeForDb(resourceData.mode);
|
||||||
// Update existing resource
|
// Update existing resource
|
||||||
const [updatedResource] = await trx
|
const [updatedResource] = await trx
|
||||||
.update(siteResources)
|
.update(siteResources)
|
||||||
.set({
|
.set({
|
||||||
name: resourceData.name || resourceNiceId,
|
name: resourceData.name || resourceNiceId,
|
||||||
siteId: site.siteId,
|
siteId: site.siteId,
|
||||||
mode: resourceData.mode,
|
mode: mappedMode.mode,
|
||||||
|
ssl: mappedMode.ssl,
|
||||||
|
scheme: mappedMode.scheme,
|
||||||
destination: resourceData.destination,
|
destination: resourceData.destination,
|
||||||
|
destinationPort: resourceData["destination-port"],
|
||||||
enabled: true, // hardcoded for now
|
enabled: true, // hardcoded for now
|
||||||
// enabled: resourceData.enabled ?? true,
|
// enabled: resourceData.enabled ?? true,
|
||||||
alias: resourceData.alias || null,
|
alias: resourceData.alias || null,
|
||||||
@@ -207,9 +225,9 @@ export async function updateClientResources(
|
|||||||
oldSiteResource: existingResource
|
oldSiteResource: existingResource
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
|
const mappedMode = siteResourceModeForDb(resourceData.mode);
|
||||||
let aliasAddress: string | null = null;
|
let aliasAddress: string | null = null;
|
||||||
if (resourceData.mode == "host") {
|
if (mappedMode.mode === "host" || mappedMode.mode === "http") {
|
||||||
// we can only have an alias on a host
|
|
||||||
aliasAddress = await getNextAvailableAliasAddress(orgId);
|
aliasAddress = await getNextAvailableAliasAddress(orgId);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -221,8 +239,11 @@ export async function updateClientResources(
|
|||||||
siteId: site.siteId,
|
siteId: site.siteId,
|
||||||
niceId: resourceNiceId,
|
niceId: resourceNiceId,
|
||||||
name: resourceData.name || resourceNiceId,
|
name: resourceData.name || resourceNiceId,
|
||||||
mode: resourceData.mode,
|
mode: mappedMode.mode,
|
||||||
|
ssl: mappedMode.ssl,
|
||||||
|
scheme: mappedMode.scheme,
|
||||||
destination: resourceData.destination,
|
destination: resourceData.destination,
|
||||||
|
destinationPort: resourceData["destination-port"],
|
||||||
enabled: true, // hardcoded for now
|
enabled: true, // hardcoded for now
|
||||||
// enabled: resourceData.enabled ?? true,
|
// enabled: resourceData.enabled ?? true,
|
||||||
alias: resourceData.alias || null,
|
alias: resourceData.alias || null,
|
||||||
|
|||||||
@@ -325,11 +325,11 @@ export function isTargetsOnlyResource(resource: any): boolean {
|
|||||||
export const ClientResourceSchema = z
|
export const ClientResourceSchema = z
|
||||||
.object({
|
.object({
|
||||||
name: z.string().min(1).max(255),
|
name: z.string().min(1).max(255),
|
||||||
mode: z.enum(["host", "cidr"]),
|
mode: z.enum(["host", "cidr", "http", "https"]),
|
||||||
site: z.string(),
|
site: z.string(),
|
||||||
// protocol: z.enum(["tcp", "udp"]).optional(),
|
// protocol: z.enum(["tcp", "udp"]).optional(),
|
||||||
// proxyPort: z.int().positive().optional(),
|
// proxyPort: z.int().positive().optional(),
|
||||||
// destinationPort: z.int().positive().optional(),
|
"destination-port": z.int().positive().optional(),
|
||||||
destination: z.string().min(1),
|
destination: z.string().min(1),
|
||||||
// enabled: z.boolean().default(true),
|
// enabled: z.boolean().default(true),
|
||||||
"tcp-ports": portRangeStringSchema.optional().default("*"),
|
"tcp-ports": portRangeStringSchema.optional().default("*"),
|
||||||
|
|||||||
@@ -582,6 +582,16 @@ export type SubnetProxyTargetV2 = {
|
|||||||
protocol: "tcp" | "udp";
|
protocol: "tcp" | "udp";
|
||||||
}[];
|
}[];
|
||||||
resourceId?: number;
|
resourceId?: number;
|
||||||
|
protocol?: "http" | "https"; // if set, this target only applies to the specified protocol
|
||||||
|
httpTargets?: HTTPTarget[];
|
||||||
|
tlsCert?: string;
|
||||||
|
tlsKey?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type HTTPTarget = {
|
||||||
|
destAddr: string; // must be an IP or hostname
|
||||||
|
destPort: number;
|
||||||
|
scheme: "http" | "https";
|
||||||
};
|
};
|
||||||
|
|
||||||
export function generateSubnetProxyTargetV2(
|
export function generateSubnetProxyTargetV2(
|
||||||
@@ -619,7 +629,7 @@ export function generateSubnetProxyTargetV2(
|
|||||||
destPrefix: destination,
|
destPrefix: destination,
|
||||||
portRange,
|
portRange,
|
||||||
disableIcmp,
|
disableIcmp,
|
||||||
resourceId: siteResource.siteResourceId,
|
resourceId: siteResource.siteResourceId
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -631,7 +641,7 @@ export function generateSubnetProxyTargetV2(
|
|||||||
rewriteTo: destination,
|
rewriteTo: destination,
|
||||||
portRange,
|
portRange,
|
||||||
disableIcmp,
|
disableIcmp,
|
||||||
resourceId: siteResource.siteResourceId,
|
resourceId: siteResource.siteResourceId
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
} else if (siteResource.mode == "cidr") {
|
} else if (siteResource.mode == "cidr") {
|
||||||
@@ -640,7 +650,46 @@ export function generateSubnetProxyTargetV2(
|
|||||||
destPrefix: siteResource.destination,
|
destPrefix: siteResource.destination,
|
||||||
portRange,
|
portRange,
|
||||||
disableIcmp,
|
disableIcmp,
|
||||||
|
resourceId: siteResource.siteResourceId
|
||||||
|
};
|
||||||
|
} else if (siteResource.mode == "http") {
|
||||||
|
let destination = siteResource.destination;
|
||||||
|
// check if this is a valid ip
|
||||||
|
const ipSchema = z.union([z.ipv4(), z.ipv6()]);
|
||||||
|
if (ipSchema.safeParse(destination).success) {
|
||||||
|
destination = `${destination}/32`;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
!siteResource.alias ||
|
||||||
|
!siteResource.aliasAddress ||
|
||||||
|
!siteResource.destinationPort ||
|
||||||
|
!siteResource.scheme
|
||||||
|
) {
|
||||||
|
logger.debug(
|
||||||
|
`Site resource ${siteResource.siteResourceId} is in HTTP mode but is missing alias or alias address or destinationPort or scheme, skipping alias target generation.`
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const publicProtocol = siteResource.ssl ? "https" : "http";
|
||||||
|
// also push a match for the alias address
|
||||||
|
target = {
|
||||||
|
sourcePrefixes: [],
|
||||||
|
destPrefix: `${siteResource.aliasAddress}/32`,
|
||||||
|
rewriteTo: destination,
|
||||||
|
portRange,
|
||||||
|
disableIcmp,
|
||||||
resourceId: siteResource.siteResourceId,
|
resourceId: siteResource.siteResourceId,
|
||||||
|
protocol: publicProtocol,
|
||||||
|
httpTargets: [
|
||||||
|
{
|
||||||
|
destAddr: siteResource.destination,
|
||||||
|
destPort: siteResource.destinationPort,
|
||||||
|
scheme: siteResource.scheme
|
||||||
|
}
|
||||||
|
]
|
||||||
|
// tlsCert: "",
|
||||||
|
// tlsKey: ""
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -670,33 +719,31 @@ export function generateSubnetProxyTargetV2(
|
|||||||
return target;
|
return target;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Converts a SubnetProxyTargetV2 to an array of SubnetProxyTarget (v1)
|
* Converts a SubnetProxyTargetV2 to an array of SubnetProxyTarget (v1)
|
||||||
* by expanding each source prefix into its own target entry.
|
* by expanding each source prefix into its own target entry.
|
||||||
* @param targetV2 - The v2 target to convert
|
* @param targetV2 - The v2 target to convert
|
||||||
* @returns Array of v1 SubnetProxyTarget objects
|
* @returns Array of v1 SubnetProxyTarget objects
|
||||||
*/
|
*/
|
||||||
export function convertSubnetProxyTargetsV2ToV1(
|
export function convertSubnetProxyTargetsV2ToV1(
|
||||||
targetsV2: SubnetProxyTargetV2[]
|
targetsV2: SubnetProxyTargetV2[]
|
||||||
): SubnetProxyTarget[] {
|
): SubnetProxyTarget[] {
|
||||||
return targetsV2.flatMap((targetV2) =>
|
return targetsV2.flatMap((targetV2) =>
|
||||||
targetV2.sourcePrefixes.map((sourcePrefix) => ({
|
targetV2.sourcePrefixes.map((sourcePrefix) => ({
|
||||||
sourcePrefix,
|
sourcePrefix,
|
||||||
destPrefix: targetV2.destPrefix,
|
destPrefix: targetV2.destPrefix,
|
||||||
...(targetV2.disableIcmp !== undefined && {
|
...(targetV2.disableIcmp !== undefined && {
|
||||||
disableIcmp: targetV2.disableIcmp
|
disableIcmp: targetV2.disableIcmp
|
||||||
}),
|
}),
|
||||||
...(targetV2.rewriteTo !== undefined && {
|
...(targetV2.rewriteTo !== undefined && {
|
||||||
rewriteTo: targetV2.rewriteTo
|
rewriteTo: targetV2.rewriteTo
|
||||||
}),
|
}),
|
||||||
...(targetV2.portRange !== undefined && {
|
...(targetV2.portRange !== undefined && {
|
||||||
portRange: targetV2.portRange
|
portRange: targetV2.portRange
|
||||||
})
|
})
|
||||||
}))
|
}))
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
// Custom schema for validating port range strings
|
// Custom schema for validating port range strings
|
||||||
// Format: "80,443,8000-9000" or "*" for all ports, or empty string
|
// Format: "80,443,8000-9000" or "*" for all ports, or empty string
|
||||||
|
|||||||
@@ -479,10 +479,7 @@ export async function getTraefikConfig(
|
|||||||
|
|
||||||
// TODO: HOW TO HANDLE ^^^^^^ BETTER
|
// TODO: HOW TO HANDLE ^^^^^^ BETTER
|
||||||
const anySitesOnline = targets.some(
|
const anySitesOnline = targets.some(
|
||||||
(target) =>
|
(target) => target.site.online
|
||||||
target.site.online ||
|
|
||||||
target.site.type === "local" ||
|
|
||||||
target.site.type === "wireguard"
|
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -495,7 +492,7 @@ export async function getTraefikConfig(
|
|||||||
if (target.health == "unhealthy") {
|
if (target.health == "unhealthy") {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
// If any sites are online, exclude offline sites
|
// If any sites are online, exclude offline sites
|
||||||
if (anySitesOnline && !target.site.online) {
|
if (anySitesOnline && !target.site.online) {
|
||||||
return false;
|
return false;
|
||||||
@@ -610,10 +607,7 @@ export async function getTraefikConfig(
|
|||||||
servers: (() => {
|
servers: (() => {
|
||||||
// Check if any sites are online
|
// Check if any sites are online
|
||||||
const anySitesOnline = targets.some(
|
const anySitesOnline = targets.some(
|
||||||
(target) =>
|
(target) => target.site.online
|
||||||
target.site.online ||
|
|
||||||
target.site.type === "local" ||
|
|
||||||
target.site.type === "wireguard"
|
|
||||||
);
|
);
|
||||||
|
|
||||||
return targets
|
return targets
|
||||||
@@ -621,7 +615,7 @@ export async function getTraefikConfig(
|
|||||||
if (!target.enabled) {
|
if (!target.enabled) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
// If any sites are online, exclude offline sites
|
// If any sites are online, exclude offline sites
|
||||||
if (anySitesOnline && !target.site.online) {
|
if (anySitesOnline && !target.site.online) {
|
||||||
return false;
|
return false;
|
||||||
|
|||||||
277
server/private/lib/acmeCertSync.ts
Normal file
277
server/private/lib/acmeCertSync.ts
Normal file
@@ -0,0 +1,277 @@
|
|||||||
|
/*
|
||||||
|
* This file is part of a proprietary work.
|
||||||
|
*
|
||||||
|
* Copyright (c) 2025 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 fs from "fs";
|
||||||
|
import crypto from "crypto";
|
||||||
|
import { certificates, domains, db } from "@server/db";
|
||||||
|
import { and, eq } from "drizzle-orm";
|
||||||
|
import { encryptData, decryptData } from "@server/lib/encryption";
|
||||||
|
import logger from "@server/logger";
|
||||||
|
import config from "#private/lib/config";
|
||||||
|
|
||||||
|
interface AcmeCert {
|
||||||
|
domain: { main: string; sans?: string[] };
|
||||||
|
certificate: string;
|
||||||
|
key: string;
|
||||||
|
Store: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface AcmeJson {
|
||||||
|
[resolver: string]: {
|
||||||
|
Certificates: AcmeCert[];
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function getEncryptionKey(): Buffer {
|
||||||
|
const keyHex = config.getRawPrivateConfig().server.encryption_key;
|
||||||
|
if (!keyHex) {
|
||||||
|
throw new Error("acmeCertSync: encryption key is not configured");
|
||||||
|
}
|
||||||
|
return Buffer.from(keyHex, "hex");
|
||||||
|
}
|
||||||
|
|
||||||
|
async function findDomainId(certDomain: string): Promise<string | null> {
|
||||||
|
// Strip wildcard prefix before lookup (*.example.com -> example.com)
|
||||||
|
const lookupDomain = certDomain.startsWith("*.")
|
||||||
|
? certDomain.slice(2)
|
||||||
|
: certDomain;
|
||||||
|
|
||||||
|
// 1. Exact baseDomain match (any domain type)
|
||||||
|
const exactMatch = await db
|
||||||
|
.select({ domainId: domains.domainId })
|
||||||
|
.from(domains)
|
||||||
|
.where(eq(domains.baseDomain, lookupDomain))
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
if (exactMatch.length > 0) {
|
||||||
|
return exactMatch[0].domainId;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Walk up the domain hierarchy looking for a wildcard-type domain whose
|
||||||
|
// baseDomain is a suffix of the cert domain. e.g. cert "sub.example.com"
|
||||||
|
// matches a wildcard domain with baseDomain "example.com".
|
||||||
|
const parts = lookupDomain.split(".");
|
||||||
|
for (let i = 1; i < parts.length; i++) {
|
||||||
|
const candidate = parts.slice(i).join(".");
|
||||||
|
if (!candidate) continue;
|
||||||
|
|
||||||
|
const wildcardMatch = await db
|
||||||
|
.select({ domainId: domains.domainId })
|
||||||
|
.from(domains)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(domains.baseDomain, candidate),
|
||||||
|
eq(domains.type, "wildcard")
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
if (wildcardMatch.length > 0) {
|
||||||
|
return wildcardMatch[0].domainId;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractFirstCert(pemBundle: string): string | null {
|
||||||
|
const match = pemBundle.match(
|
||||||
|
/-----BEGIN CERTIFICATE-----[\s\S]+?-----END CERTIFICATE-----/
|
||||||
|
);
|
||||||
|
return match ? match[0] : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function syncAcmeCerts(
|
||||||
|
acmeJsonPath: string,
|
||||||
|
resolver: string
|
||||||
|
): Promise<void> {
|
||||||
|
let raw: string;
|
||||||
|
try {
|
||||||
|
raw = fs.readFileSync(acmeJsonPath, "utf8");
|
||||||
|
} catch (err) {
|
||||||
|
logger.debug(
|
||||||
|
`acmeCertSync: could not read ${acmeJsonPath}: ${err}`
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let acmeJson: AcmeJson;
|
||||||
|
try {
|
||||||
|
acmeJson = JSON.parse(raw);
|
||||||
|
} catch (err) {
|
||||||
|
logger.debug(`acmeCertSync: could not parse acme.json: ${err}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const resolverData = acmeJson[resolver];
|
||||||
|
if (!resolverData || !Array.isArray(resolverData.Certificates)) {
|
||||||
|
logger.debug(
|
||||||
|
`acmeCertSync: no certificates found for resolver "${resolver}"`
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const encryptionKey = getEncryptionKey();
|
||||||
|
|
||||||
|
for (const cert of resolverData.Certificates) {
|
||||||
|
const domain = cert.domain?.main;
|
||||||
|
|
||||||
|
if (!domain) {
|
||||||
|
logger.debug(
|
||||||
|
`acmeCertSync: skipping cert with missing domain`
|
||||||
|
);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!cert.certificate || !cert.key) {
|
||||||
|
logger.debug(
|
||||||
|
`acmeCertSync: skipping cert for ${domain} - empty certificate or key field`
|
||||||
|
);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const certPem = Buffer.from(cert.certificate, "base64").toString(
|
||||||
|
"utf8"
|
||||||
|
);
|
||||||
|
const keyPem = Buffer.from(cert.key, "base64").toString("utf8");
|
||||||
|
|
||||||
|
if (!certPem.trim() || !keyPem.trim()) {
|
||||||
|
logger.debug(
|
||||||
|
`acmeCertSync: skipping cert for ${domain} - blank PEM after base64 decode`
|
||||||
|
);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if cert already exists in DB
|
||||||
|
const existing = await db
|
||||||
|
.select()
|
||||||
|
.from(certificates)
|
||||||
|
.where(eq(certificates.domain, domain))
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
if (existing.length > 0 && existing[0].certFile) {
|
||||||
|
try {
|
||||||
|
const storedCertPem = decryptData(
|
||||||
|
existing[0].certFile,
|
||||||
|
encryptionKey
|
||||||
|
);
|
||||||
|
if (storedCertPem === certPem) {
|
||||||
|
logger.debug(
|
||||||
|
`acmeCertSync: cert for ${domain} is unchanged, skipping`
|
||||||
|
);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
// Decryption failure means we should proceed with the update
|
||||||
|
logger.debug(
|
||||||
|
`acmeCertSync: could not decrypt stored cert for ${domain}, will update: ${err}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse cert expiry from the first cert in the PEM bundle
|
||||||
|
let expiresAt: number | null = null;
|
||||||
|
const firstCertPem = extractFirstCert(certPem);
|
||||||
|
if (firstCertPem) {
|
||||||
|
try {
|
||||||
|
const x509 = new crypto.X509Certificate(firstCertPem);
|
||||||
|
expiresAt = Math.floor(
|
||||||
|
new Date(x509.validTo).getTime() / 1000
|
||||||
|
);
|
||||||
|
} catch (err) {
|
||||||
|
logger.debug(
|
||||||
|
`acmeCertSync: could not parse cert expiry for ${domain}: ${err}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const wildcard = domain.startsWith("*.");
|
||||||
|
const encryptedCert = encryptData(certPem, encryptionKey);
|
||||||
|
const encryptedKey = encryptData(keyPem, encryptionKey);
|
||||||
|
const now = Math.floor(Date.now() / 1000);
|
||||||
|
|
||||||
|
const domainId = await findDomainId(domain);
|
||||||
|
if (domainId) {
|
||||||
|
logger.debug(
|
||||||
|
`acmeCertSync: resolved domainId "${domainId}" for cert domain "${domain}"`
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
logger.debug(
|
||||||
|
`acmeCertSync: no matching domain record found for cert domain "${domain}"`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (existing.length > 0) {
|
||||||
|
await db
|
||||||
|
.update(certificates)
|
||||||
|
.set({
|
||||||
|
certFile: encryptedCert,
|
||||||
|
keyFile: encryptedKey,
|
||||||
|
status: "valid",
|
||||||
|
expiresAt,
|
||||||
|
updatedAt: now,
|
||||||
|
wildcard,
|
||||||
|
...(domainId !== null && { domainId })
|
||||||
|
})
|
||||||
|
.where(eq(certificates.domain, domain));
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
`acmeCertSync: updated certificate for ${domain} (expires ${expiresAt ? new Date(expiresAt * 1000).toISOString() : "unknown"})`
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
await db.insert(certificates).values({
|
||||||
|
domain,
|
||||||
|
domainId,
|
||||||
|
certFile: encryptedCert,
|
||||||
|
keyFile: encryptedKey,
|
||||||
|
status: "valid",
|
||||||
|
expiresAt,
|
||||||
|
createdAt: now,
|
||||||
|
updatedAt: now,
|
||||||
|
wildcard
|
||||||
|
});
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
`acmeCertSync: inserted new certificate for ${domain} (expires ${expiresAt ? new Date(expiresAt * 1000).toISOString() : "unknown"})`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function initAcmeCertSync(): void {
|
||||||
|
const privateConfig = config.getRawPrivateConfig();
|
||||||
|
|
||||||
|
if (!privateConfig.flags?.enable_acme_cert_sync) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const acmeJsonPath =
|
||||||
|
privateConfig.acme?.acme_json_path ?? "config/letsencrypt/acme.json";
|
||||||
|
const resolver = privateConfig.acme?.resolver ?? "letsencrypt";
|
||||||
|
const intervalMs = privateConfig.acme?.sync_interval_ms ?? 5000;
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
`acmeCertSync: starting ACME cert sync from "${acmeJsonPath}" using resolver "${resolver}" every ${intervalMs}ms`
|
||||||
|
);
|
||||||
|
|
||||||
|
// Run immediately on init, then on the configured interval
|
||||||
|
syncAcmeCerts(acmeJsonPath, resolver).catch((err) => {
|
||||||
|
logger.error(`acmeCertSync: error during initial sync: ${err}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
setInterval(() => {
|
||||||
|
syncAcmeCerts(acmeJsonPath, resolver).catch((err) => {
|
||||||
|
logger.error(`acmeCertSync: error during sync: ${err}`);
|
||||||
|
});
|
||||||
|
}, intervalMs);
|
||||||
|
}
|
||||||
@@ -23,6 +23,8 @@ import {
|
|||||||
} from "@server/db";
|
} from "@server/db";
|
||||||
import logger from "@server/logger";
|
import logger from "@server/logger";
|
||||||
import { and, eq, gt, desc, max, sql } from "drizzle-orm";
|
import { and, eq, gt, desc, max, sql } from "drizzle-orm";
|
||||||
|
import { decrypt } from "@server/lib/crypto";
|
||||||
|
import config from "@server/lib/config";
|
||||||
import {
|
import {
|
||||||
LogType,
|
LogType,
|
||||||
LOG_TYPES,
|
LOG_TYPES,
|
||||||
@@ -127,7 +129,7 @@ export class LogStreamingManager {
|
|||||||
start(): void {
|
start(): void {
|
||||||
if (this.isRunning) return;
|
if (this.isRunning) return;
|
||||||
this.isRunning = true;
|
this.isRunning = true;
|
||||||
logger.info("LogStreamingManager: started");
|
logger.debug("LogStreamingManager: started");
|
||||||
this.schedulePoll(POLL_INTERVAL_MS);
|
this.schedulePoll(POLL_INTERVAL_MS);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -272,19 +274,20 @@ export class LogStreamingManager {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Parse config – skip destination if config is unparseable
|
// Decrypt and parse config – skip destination if either step fails
|
||||||
let config: HttpConfig;
|
let configFromDb: HttpConfig;
|
||||||
try {
|
try {
|
||||||
config = JSON.parse(dest.config) as HttpConfig;
|
const decryptedConfig = decrypt(dest.config, config.getRawConfig().server.secret!);
|
||||||
|
configFromDb = JSON.parse(decryptedConfig) as HttpConfig;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
logger.error(
|
logger.error(
|
||||||
`LogStreamingManager: destination ${dest.destinationId} has invalid JSON config`,
|
`LogStreamingManager: destination ${dest.destinationId} has invalid or undecryptable config`,
|
||||||
err
|
err
|
||||||
);
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const provider = this.createProvider(dest.type, config);
|
const provider = this.createProvider(dest.type, configFromDb);
|
||||||
if (!provider) {
|
if (!provider) {
|
||||||
logger.warn(
|
logger.warn(
|
||||||
`LogStreamingManager: unsupported destination type "${dest.type}" ` +
|
`LogStreamingManager: unsupported destination type "${dest.type}" ` +
|
||||||
@@ -770,4 +773,4 @@ export class LogStreamingManager {
|
|||||||
|
|
||||||
function sleep(ms: number): Promise<void> {
|
function sleep(ms: number): Promise<void> {
|
||||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -95,10 +95,21 @@ export const privateConfigSchema = z.object({
|
|||||||
.object({
|
.object({
|
||||||
enable_redis: z.boolean().optional().default(false),
|
enable_redis: z.boolean().optional().default(false),
|
||||||
use_pangolin_dns: z.boolean().optional().default(false),
|
use_pangolin_dns: z.boolean().optional().default(false),
|
||||||
use_org_only_idp: z.boolean().optional()
|
use_org_only_idp: z.boolean().optional(),
|
||||||
|
enable_acme_cert_sync: z.boolean().optional().default(false)
|
||||||
})
|
})
|
||||||
.optional()
|
.optional()
|
||||||
.prefault({}),
|
.prefault({}),
|
||||||
|
acme: z
|
||||||
|
.object({
|
||||||
|
acme_json_path: z
|
||||||
|
.string()
|
||||||
|
.optional()
|
||||||
|
.default("config/letsencrypt/acme.json"),
|
||||||
|
resolver: z.string().optional().default("letsencrypt"),
|
||||||
|
sync_interval_ms: z.number().optional().default(5000)
|
||||||
|
})
|
||||||
|
.optional(),
|
||||||
branding: z
|
branding: z
|
||||||
.object({
|
.object({
|
||||||
app_name: z.string().optional(),
|
app_name: z.string().optional(),
|
||||||
|
|||||||
@@ -33,7 +33,7 @@ import {
|
|||||||
} from "drizzle-orm";
|
} from "drizzle-orm";
|
||||||
import logger from "@server/logger";
|
import logger from "@server/logger";
|
||||||
import config from "@server/lib/config";
|
import config from "@server/lib/config";
|
||||||
import { orgs, resources, sites, Target, targets } from "@server/db";
|
import { orgs, resources, sites, siteResources, Target, targets } from "@server/db";
|
||||||
import {
|
import {
|
||||||
sanitize,
|
sanitize,
|
||||||
encodePath,
|
encodePath,
|
||||||
@@ -267,6 +267,34 @@ export async function getTraefikConfig(
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Query siteResources in HTTP mode with SSL enabled and aliases — cert generation / HTTPS edge
|
||||||
|
const siteResourcesWithAliases = await db
|
||||||
|
.select({
|
||||||
|
siteResourceId: siteResources.siteResourceId,
|
||||||
|
alias: siteResources.alias,
|
||||||
|
mode: siteResources.mode
|
||||||
|
})
|
||||||
|
.from(siteResources)
|
||||||
|
.innerJoin(sites, eq(sites.siteId, siteResources.siteId))
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(siteResources.enabled, true),
|
||||||
|
isNotNull(siteResources.alias),
|
||||||
|
eq(siteResources.mode, "http"),
|
||||||
|
eq(siteResources.ssl, true),
|
||||||
|
or(
|
||||||
|
eq(sites.exitNodeId, exitNodeId),
|
||||||
|
and(
|
||||||
|
isNull(sites.exitNodeId),
|
||||||
|
sql`(${siteTypes.includes("local") ? 1 : 0} = 1)`,
|
||||||
|
eq(sites.type, "local"),
|
||||||
|
sql`(${build != "saas" ? 1 : 0} = 1)`
|
||||||
|
)
|
||||||
|
),
|
||||||
|
inArray(sites.type, siteTypes)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
let validCerts: CertificateResult[] = [];
|
let validCerts: CertificateResult[] = [];
|
||||||
if (privateConfig.getRawPrivateConfig().flags.use_pangolin_dns) {
|
if (privateConfig.getRawPrivateConfig().flags.use_pangolin_dns) {
|
||||||
// create a list of all domains to get certs for
|
// create a list of all domains to get certs for
|
||||||
@@ -276,6 +304,12 @@ export async function getTraefikConfig(
|
|||||||
domains.add(resource.fullDomain);
|
domains.add(resource.fullDomain);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// Include siteResource aliases so pangolin-dns also fetches certs for them
|
||||||
|
for (const sr of siteResourcesWithAliases) {
|
||||||
|
if (sr.alias) {
|
||||||
|
domains.add(sr.alias);
|
||||||
|
}
|
||||||
|
}
|
||||||
// get the valid certs for these domains
|
// get the valid certs for these domains
|
||||||
validCerts = await getValidCertificatesForDomains(domains, true); // we are caching here because this is called often
|
validCerts = await getValidCertificatesForDomains(domains, true); // we are caching here because this is called often
|
||||||
// logger.debug(`Valid certs for domains: ${JSON.stringify(validCerts)}`);
|
// logger.debug(`Valid certs for domains: ${JSON.stringify(validCerts)}`);
|
||||||
@@ -671,10 +705,7 @@ export async function getTraefikConfig(
|
|||||||
|
|
||||||
// TODO: HOW TO HANDLE ^^^^^^ BETTER
|
// TODO: HOW TO HANDLE ^^^^^^ BETTER
|
||||||
const anySitesOnline = targets.some(
|
const anySitesOnline = targets.some(
|
||||||
(target) =>
|
(target) => target.site.online
|
||||||
target.site.online ||
|
|
||||||
target.site.type === "local" ||
|
|
||||||
target.site.type === "wireguard"
|
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -802,10 +833,7 @@ export async function getTraefikConfig(
|
|||||||
servers: (() => {
|
servers: (() => {
|
||||||
// Check if any sites are online
|
// Check if any sites are online
|
||||||
const anySitesOnline = targets.some(
|
const anySitesOnline = targets.some(
|
||||||
(target) =>
|
(target) => target.site.online
|
||||||
target.site.online ||
|
|
||||||
target.site.type === "local" ||
|
|
||||||
target.site.type === "wireguard"
|
|
||||||
);
|
);
|
||||||
|
|
||||||
return targets
|
return targets
|
||||||
@@ -873,6 +901,128 @@ export async function getTraefikConfig(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Add Traefik routes for siteResource aliases (HTTP mode + SSL) so that
|
||||||
|
// Traefik generates TLS certificates for those domains even when no
|
||||||
|
// matching resource exists yet.
|
||||||
|
if (siteResourcesWithAliases.length > 0) {
|
||||||
|
// Build a set of domains already covered by normal resources
|
||||||
|
const existingFullDomains = new Set<string>();
|
||||||
|
for (const resource of resourcesMap.values()) {
|
||||||
|
if (resource.fullDomain) {
|
||||||
|
existingFullDomains.add(resource.fullDomain);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const sr of siteResourcesWithAliases) {
|
||||||
|
if (!sr.alias) continue;
|
||||||
|
|
||||||
|
// Skip if this alias is already handled by a resource router
|
||||||
|
if (existingFullDomains.has(sr.alias)) continue;
|
||||||
|
|
||||||
|
const alias = sr.alias;
|
||||||
|
const srKey = `site-resource-cert-${sr.siteResourceId}`;
|
||||||
|
const siteResourceServiceName = `${srKey}-service`;
|
||||||
|
const siteResourceRouterName = `${srKey}-router`;
|
||||||
|
const siteResourceRewriteMiddlewareName = `${srKey}-rewrite`;
|
||||||
|
|
||||||
|
const maintenancePort = config.getRawConfig().server.next_port;
|
||||||
|
const maintenanceHost =
|
||||||
|
config.getRawConfig().server.internal_hostname;
|
||||||
|
|
||||||
|
if (!config_output.http.routers) {
|
||||||
|
config_output.http.routers = {};
|
||||||
|
}
|
||||||
|
if (!config_output.http.services) {
|
||||||
|
config_output.http.services = {};
|
||||||
|
}
|
||||||
|
if (!config_output.http.middlewares) {
|
||||||
|
config_output.http.middlewares = {};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Service pointing at the internal maintenance/Next.js page
|
||||||
|
config_output.http.services[siteResourceServiceName] = {
|
||||||
|
loadBalancer: {
|
||||||
|
servers: [
|
||||||
|
{
|
||||||
|
url: `http://${maintenanceHost}:${maintenancePort}`
|
||||||
|
}
|
||||||
|
],
|
||||||
|
passHostHeader: true
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Middleware that rewrites any path to /maintenance-screen
|
||||||
|
config_output.http.middlewares[
|
||||||
|
siteResourceRewriteMiddlewareName
|
||||||
|
] = {
|
||||||
|
replacePathRegex: {
|
||||||
|
regex: "^/(.*)",
|
||||||
|
replacement: "/private-maintenance-screen"
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// HTTP -> HTTPS redirect so the ACME challenge can be served
|
||||||
|
config_output.http.routers[
|
||||||
|
`${siteResourceRouterName}-redirect`
|
||||||
|
] = {
|
||||||
|
entryPoints: [
|
||||||
|
config.getRawConfig().traefik.http_entrypoint
|
||||||
|
],
|
||||||
|
middlewares: [redirectHttpsMiddlewareName],
|
||||||
|
service: siteResourceServiceName,
|
||||||
|
rule: `Host(\`${alias}\`)`,
|
||||||
|
priority: 100
|
||||||
|
};
|
||||||
|
|
||||||
|
// Determine TLS / cert-resolver configuration
|
||||||
|
let tls: any = {};
|
||||||
|
if (
|
||||||
|
!privateConfig.getRawPrivateConfig().flags.use_pangolin_dns
|
||||||
|
) {
|
||||||
|
const domainParts = alias.split(".");
|
||||||
|
const wildCard =
|
||||||
|
domainParts.length <= 2
|
||||||
|
? `*.${domainParts.join(".")}`
|
||||||
|
: `*.${domainParts.slice(1).join(".")}`;
|
||||||
|
|
||||||
|
const globalDefaultResolver =
|
||||||
|
config.getRawConfig().traefik.cert_resolver;
|
||||||
|
const globalDefaultPreferWildcard =
|
||||||
|
config.getRawConfig().traefik.prefer_wildcard_cert;
|
||||||
|
|
||||||
|
tls = {
|
||||||
|
certResolver: globalDefaultResolver,
|
||||||
|
...(globalDefaultPreferWildcard
|
||||||
|
? { domains: [{ main: wildCard }] }
|
||||||
|
: {})
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
// pangolin-dns: only add route if we already have a valid cert
|
||||||
|
const matchingCert = validCerts.find(
|
||||||
|
(cert) => cert.queriedDomain === alias
|
||||||
|
);
|
||||||
|
if (!matchingCert) {
|
||||||
|
logger.debug(
|
||||||
|
`No matching certificate found for siteResource alias: ${alias}`
|
||||||
|
);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// HTTPS router — presence of this entry triggers cert generation
|
||||||
|
config_output.http.routers[siteResourceRouterName] = {
|
||||||
|
entryPoints: [
|
||||||
|
config.getRawConfig().traefik.https_entrypoint
|
||||||
|
],
|
||||||
|
service: siteResourceServiceName,
|
||||||
|
middlewares: [siteResourceRewriteMiddlewareName],
|
||||||
|
rule: `Host(\`${alias}\`)`,
|
||||||
|
priority: 100,
|
||||||
|
tls
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (generateLoginPageRouters) {
|
if (generateLoginPageRouters) {
|
||||||
const exitNodeLoginPages = await db
|
const exitNodeLoginPages = await db
|
||||||
.select({
|
.select({
|
||||||
|
|||||||
@@ -22,6 +22,8 @@ import createHttpError from "http-errors";
|
|||||||
import logger from "@server/logger";
|
import logger from "@server/logger";
|
||||||
import { fromError } from "zod-validation-error";
|
import { fromError } from "zod-validation-error";
|
||||||
import { OpenAPITags, registry } from "@server/openApi";
|
import { OpenAPITags, registry } from "@server/openApi";
|
||||||
|
import { encrypt } from "@server/lib/crypto";
|
||||||
|
import config from "@server/lib/config";
|
||||||
|
|
||||||
const paramsSchema = z.strictObject({
|
const paramsSchema = z.strictObject({
|
||||||
orgId: z.string().nonempty()
|
orgId: z.string().nonempty()
|
||||||
@@ -87,7 +89,10 @@ export async function createEventStreamingDestination(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const { type, config, enabled } = parsedBody.data;
|
const { type, config: configToSet, enabled } = parsedBody.data;
|
||||||
|
|
||||||
|
const key = config.getRawConfig().server.secret!;
|
||||||
|
const encryptedConfig = encrypt(configToSet, key);
|
||||||
|
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
|
|
||||||
@@ -96,7 +101,7 @@ export async function createEventStreamingDestination(
|
|||||||
.values({
|
.values({
|
||||||
orgId,
|
orgId,
|
||||||
type,
|
type,
|
||||||
config,
|
config: encryptedConfig,
|
||||||
enabled,
|
enabled,
|
||||||
createdAt: now,
|
createdAt: now,
|
||||||
updatedAt: now,
|
updatedAt: now,
|
||||||
|
|||||||
@@ -22,6 +22,8 @@ import logger from "@server/logger";
|
|||||||
import { fromError } from "zod-validation-error";
|
import { fromError } from "zod-validation-error";
|
||||||
import { OpenAPITags, registry } from "@server/openApi";
|
import { OpenAPITags, registry } from "@server/openApi";
|
||||||
import { eq, sql } from "drizzle-orm";
|
import { eq, sql } from "drizzle-orm";
|
||||||
|
import { decrypt } from "@server/lib/crypto";
|
||||||
|
import config from "@server/lib/config";
|
||||||
|
|
||||||
const paramsSchema = z.strictObject({
|
const paramsSchema = z.strictObject({
|
||||||
orgId: z.string().nonempty()
|
orgId: z.string().nonempty()
|
||||||
@@ -121,9 +123,22 @@ export async function listEventStreamingDestinations(
|
|||||||
.from(eventStreamingDestinations)
|
.from(eventStreamingDestinations)
|
||||||
.where(eq(eventStreamingDestinations.orgId, orgId));
|
.where(eq(eventStreamingDestinations.orgId, orgId));
|
||||||
|
|
||||||
|
const key = config.getRawConfig().server.secret!;
|
||||||
|
const decryptedList = list.map((dest) => {
|
||||||
|
try {
|
||||||
|
return { ...dest, config: decrypt(dest.config, key) };
|
||||||
|
} catch (err) {
|
||||||
|
logger.error(
|
||||||
|
`listEventStreamingDestinations: failed to decrypt config for destination ${dest.destinationId}`,
|
||||||
|
err
|
||||||
|
);
|
||||||
|
return { ...dest, config: "" };
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
return response<ListEventStreamingDestinationsResponse>(res, {
|
return response<ListEventStreamingDestinationsResponse>(res, {
|
||||||
data: {
|
data: {
|
||||||
destinations: list,
|
destinations: decryptedList,
|
||||||
pagination: {
|
pagination: {
|
||||||
total: count,
|
total: count,
|
||||||
limit,
|
limit,
|
||||||
|
|||||||
@@ -22,7 +22,8 @@ import logger from "@server/logger";
|
|||||||
import { fromError } from "zod-validation-error";
|
import { fromError } from "zod-validation-error";
|
||||||
import { OpenAPITags, registry } from "@server/openApi";
|
import { OpenAPITags, registry } from "@server/openApi";
|
||||||
import { and, eq } from "drizzle-orm";
|
import { and, eq } from "drizzle-orm";
|
||||||
|
import { encrypt } from "@server/lib/crypto";
|
||||||
|
import config from "@server/lib/config";
|
||||||
|
|
||||||
const paramsSchema = z
|
const paramsSchema = z
|
||||||
.object({
|
.object({
|
||||||
@@ -110,14 +111,17 @@ export async function updateEventStreamingDestination(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const { type, config, enabled, sendAccessLogs, sendActionLogs, sendConnectionLogs, sendRequestLogs } = parsedBody.data;
|
const { type, config: configToUpdate, enabled, sendAccessLogs, sendActionLogs, sendConnectionLogs, sendRequestLogs } = parsedBody.data;
|
||||||
|
|
||||||
const updateData: Record<string, unknown> = {
|
const updateData: Record<string, unknown> = {
|
||||||
updatedAt: Date.now()
|
updatedAt: Date.now()
|
||||||
};
|
};
|
||||||
|
|
||||||
if (type !== undefined) updateData.type = type;
|
if (type !== undefined) updateData.type = type;
|
||||||
if (config !== undefined) updateData.config = config;
|
if (configToUpdate !== undefined) {
|
||||||
|
const key = config.getRawConfig().server.secret!;
|
||||||
|
updateData.config = encrypt(configToUpdate, key);
|
||||||
|
}
|
||||||
if (enabled !== undefined) updateData.enabled = enabled;
|
if (enabled !== undefined) updateData.enabled = enabled;
|
||||||
if (sendAccessLogs !== undefined) updateData.sendAccessLogs = sendAccessLogs;
|
if (sendAccessLogs !== undefined) updateData.sendAccessLogs = sendAccessLogs;
|
||||||
if (sendActionLogs !== undefined) updateData.sendActionLogs = sendActionLogs;
|
if (sendActionLogs !== undefined) updateData.sendActionLogs = sendActionLogs;
|
||||||
|
|||||||
@@ -171,9 +171,8 @@ export async function flushSiteBandwidthToDb(): Promise<void> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// PostgreSQL: batch UPDATE … FROM (VALUES …) — single round-trip per chunk.
|
// PostgreSQL: batch UPDATE … FROM (VALUES …) — single round-trip per chunk.
|
||||||
const valuesList = chunk.map(
|
const valuesList = chunk.map(([publicKey, { bytesIn, bytesOut }]) =>
|
||||||
([publicKey, { bytesIn, bytesOut }]) =>
|
sql`(${publicKey}::text, ${bytesIn}::real, ${bytesOut}::real)`
|
||||||
sql`(${publicKey}, ${bytesIn}, ${bytesOut})`
|
|
||||||
);
|
);
|
||||||
const valuesClause = sql.join(valuesList, sql`, `);
|
const valuesClause = sql.join(valuesList, sql`, `);
|
||||||
return dbQueryRows<{ orgId: string; pubKey: string }>(sql`
|
return dbQueryRows<{ orgId: string; pubKey: string }>(sql`
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { db } from "@server/db";
|
import { db } from "@server/db";
|
||||||
import { sites, clients, olms } from "@server/db";
|
import { sites, clients, olms } from "@server/db";
|
||||||
import { eq, inArray } from "drizzle-orm";
|
import { inArray } from "drizzle-orm";
|
||||||
import logger from "@server/logger";
|
import logger from "@server/logger";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -21,7 +21,7 @@ import logger from "@server/logger";
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
const FLUSH_INTERVAL_MS = 10_000; // Flush every 10 seconds
|
const FLUSH_INTERVAL_MS = 10_000; // Flush every 10 seconds
|
||||||
const MAX_RETRIES = 2;
|
const MAX_RETRIES = 5;
|
||||||
const BASE_DELAY_MS = 50;
|
const BASE_DELAY_MS = 50;
|
||||||
|
|
||||||
// ── Site (newt) pings ──────────────────────────────────────────────────
|
// ── Site (newt) pings ──────────────────────────────────────────────────
|
||||||
@@ -36,6 +36,14 @@ const pendingOlmArchiveResets: Set<string> = new Set();
|
|||||||
|
|
||||||
let flushTimer: NodeJS.Timeout | null = null;
|
let flushTimer: NodeJS.Timeout | null = null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Guard that prevents two flush cycles from running concurrently.
|
||||||
|
* setInterval does not await async callbacks, so without this a slow flush
|
||||||
|
* (e.g. due to DB latency) would overlap with the next scheduled cycle and
|
||||||
|
* the two concurrent bulk UPDATEs would deadlock each other.
|
||||||
|
*/
|
||||||
|
let isFlushing = false;
|
||||||
|
|
||||||
// ── Public API ─────────────────────────────────────────────────────────
|
// ── Public API ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -72,6 +80,12 @@ export function recordClientPing(
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Flush all accumulated site pings to the database.
|
* Flush all accumulated site pings to the database.
|
||||||
|
*
|
||||||
|
* Each batch of up to BATCH_SIZE rows is written with a **single** UPDATE
|
||||||
|
* statement. We use the maximum timestamp across the batch so that `lastPing`
|
||||||
|
* reflects the most recent ping seen for any site in the group. This avoids
|
||||||
|
* the multi-statement transaction that previously created additional
|
||||||
|
* row-lock ordering hazards.
|
||||||
*/
|
*/
|
||||||
async function flushSitePingsToDb(): Promise<void> {
|
async function flushSitePingsToDb(): Promise<void> {
|
||||||
if (pendingSitePings.size === 0) {
|
if (pendingSitePings.size === 0) {
|
||||||
@@ -83,55 +97,35 @@ async function flushSitePingsToDb(): Promise<void> {
|
|||||||
const pingsToFlush = new Map(pendingSitePings);
|
const pingsToFlush = new Map(pendingSitePings);
|
||||||
pendingSitePings.clear();
|
pendingSitePings.clear();
|
||||||
|
|
||||||
// Sort by siteId for consistent lock ordering (prevents deadlocks)
|
const entries = Array.from(pingsToFlush.entries());
|
||||||
const sortedEntries = Array.from(pingsToFlush.entries()).sort(
|
|
||||||
([a], [b]) => a - b
|
|
||||||
);
|
|
||||||
|
|
||||||
const BATCH_SIZE = 50;
|
const BATCH_SIZE = 50;
|
||||||
for (let i = 0; i < sortedEntries.length; i += BATCH_SIZE) {
|
for (let i = 0; i < entries.length; i += BATCH_SIZE) {
|
||||||
const batch = sortedEntries.slice(i, i + BATCH_SIZE);
|
const batch = entries.slice(i, i + BATCH_SIZE);
|
||||||
|
|
||||||
|
// Use the latest timestamp in the batch so that `lastPing` always
|
||||||
|
// moves forward. Using a single timestamp for the whole batch means
|
||||||
|
// we only ever need one UPDATE statement (no transaction).
|
||||||
|
const maxTimestamp = Math.max(...batch.map(([, ts]) => ts));
|
||||||
|
const siteIds = batch.map(([id]) => id);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await withRetry(async () => {
|
await withRetry(async () => {
|
||||||
// Group by timestamp for efficient bulk updates
|
await db
|
||||||
const byTimestamp = new Map<number, number[]>();
|
.update(sites)
|
||||||
for (const [siteId, timestamp] of batch) {
|
.set({
|
||||||
const group = byTimestamp.get(timestamp) || [];
|
online: true,
|
||||||
group.push(siteId);
|
lastPing: maxTimestamp
|
||||||
byTimestamp.set(timestamp, group);
|
})
|
||||||
}
|
.where(inArray(sites.siteId, siteIds));
|
||||||
|
|
||||||
if (byTimestamp.size === 1) {
|
|
||||||
const [timestamp, siteIds] = Array.from(
|
|
||||||
byTimestamp.entries()
|
|
||||||
)[0];
|
|
||||||
await db
|
|
||||||
.update(sites)
|
|
||||||
.set({
|
|
||||||
online: true,
|
|
||||||
lastPing: timestamp
|
|
||||||
})
|
|
||||||
.where(inArray(sites.siteId, siteIds));
|
|
||||||
} else {
|
|
||||||
await db.transaction(async (tx) => {
|
|
||||||
for (const [timestamp, siteIds] of byTimestamp) {
|
|
||||||
await tx
|
|
||||||
.update(sites)
|
|
||||||
.set({
|
|
||||||
online: true,
|
|
||||||
lastPing: timestamp
|
|
||||||
})
|
|
||||||
.where(inArray(sites.siteId, siteIds));
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}, "flushSitePingsToDb");
|
}, "flushSitePingsToDb");
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error(
|
logger.error(
|
||||||
`Failed to flush site ping batch (${batch.length} sites), re-queuing for next cycle`,
|
`Failed to flush site ping batch (${batch.length} sites), re-queuing for next cycle`,
|
||||||
{ error }
|
{ error }
|
||||||
);
|
);
|
||||||
|
// Re-queue only if the preserved timestamp is newer than any
|
||||||
|
// update that may have landed since we snapshotted.
|
||||||
for (const [siteId, timestamp] of batch) {
|
for (const [siteId, timestamp] of batch) {
|
||||||
const existing = pendingSitePings.get(siteId);
|
const existing = pendingSitePings.get(siteId);
|
||||||
if (!existing || existing < timestamp) {
|
if (!existing || existing < timestamp) {
|
||||||
@@ -144,6 +138,8 @@ async function flushSitePingsToDb(): Promise<void> {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Flush all accumulated client (OLM) pings to the database.
|
* Flush all accumulated client (OLM) pings to the database.
|
||||||
|
*
|
||||||
|
* Same single-UPDATE-per-batch approach as `flushSitePingsToDb`.
|
||||||
*/
|
*/
|
||||||
async function flushClientPingsToDb(): Promise<void> {
|
async function flushClientPingsToDb(): Promise<void> {
|
||||||
if (pendingClientPings.size === 0 && pendingOlmArchiveResets.size === 0) {
|
if (pendingClientPings.size === 0 && pendingOlmArchiveResets.size === 0) {
|
||||||
@@ -159,51 +155,25 @@ async function flushClientPingsToDb(): Promise<void> {
|
|||||||
|
|
||||||
// ── Flush client pings ─────────────────────────────────────────────
|
// ── Flush client pings ─────────────────────────────────────────────
|
||||||
if (pingsToFlush.size > 0) {
|
if (pingsToFlush.size > 0) {
|
||||||
const sortedEntries = Array.from(pingsToFlush.entries()).sort(
|
const entries = Array.from(pingsToFlush.entries());
|
||||||
([a], [b]) => a - b
|
|
||||||
);
|
|
||||||
|
|
||||||
const BATCH_SIZE = 50;
|
const BATCH_SIZE = 50;
|
||||||
for (let i = 0; i < sortedEntries.length; i += BATCH_SIZE) {
|
for (let i = 0; i < entries.length; i += BATCH_SIZE) {
|
||||||
const batch = sortedEntries.slice(i, i + BATCH_SIZE);
|
const batch = entries.slice(i, i + BATCH_SIZE);
|
||||||
|
|
||||||
|
const maxTimestamp = Math.max(...batch.map(([, ts]) => ts));
|
||||||
|
const clientIds = batch.map(([id]) => id);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await withRetry(async () => {
|
await withRetry(async () => {
|
||||||
const byTimestamp = new Map<number, number[]>();
|
await db
|
||||||
for (const [clientId, timestamp] of batch) {
|
.update(clients)
|
||||||
const group = byTimestamp.get(timestamp) || [];
|
.set({
|
||||||
group.push(clientId);
|
lastPing: maxTimestamp,
|
||||||
byTimestamp.set(timestamp, group);
|
online: true,
|
||||||
}
|
archived: false
|
||||||
|
})
|
||||||
if (byTimestamp.size === 1) {
|
.where(inArray(clients.clientId, clientIds));
|
||||||
const [timestamp, clientIds] = Array.from(
|
|
||||||
byTimestamp.entries()
|
|
||||||
)[0];
|
|
||||||
await db
|
|
||||||
.update(clients)
|
|
||||||
.set({
|
|
||||||
lastPing: timestamp,
|
|
||||||
online: true,
|
|
||||||
archived: false
|
|
||||||
})
|
|
||||||
.where(inArray(clients.clientId, clientIds));
|
|
||||||
} else {
|
|
||||||
await db.transaction(async (tx) => {
|
|
||||||
for (const [timestamp, clientIds] of byTimestamp) {
|
|
||||||
await tx
|
|
||||||
.update(clients)
|
|
||||||
.set({
|
|
||||||
lastPing: timestamp,
|
|
||||||
online: true,
|
|
||||||
archived: false
|
|
||||||
})
|
|
||||||
.where(
|
|
||||||
inArray(clients.clientId, clientIds)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}, "flushClientPingsToDb");
|
}, "flushClientPingsToDb");
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error(
|
logger.error(
|
||||||
@@ -260,7 +230,12 @@ export async function flushPingsToDb(): Promise<void> {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Simple retry wrapper with exponential backoff for transient errors
|
* Simple retry wrapper with exponential backoff for transient errors
|
||||||
* (connection timeouts, unexpected disconnects).
|
* (deadlocks, connection timeouts, unexpected disconnects).
|
||||||
|
*
|
||||||
|
* PostgreSQL deadlocks (40P01) are always safe to retry: the database
|
||||||
|
* guarantees exactly one winner per deadlock pair, so the loser just needs
|
||||||
|
* to try again. MAX_RETRIES is intentionally higher than typical connection
|
||||||
|
* retry budgets to give deadlock victims enough chances to succeed.
|
||||||
*/
|
*/
|
||||||
async function withRetry<T>(
|
async function withRetry<T>(
|
||||||
operation: () => Promise<T>,
|
operation: () => Promise<T>,
|
||||||
@@ -277,7 +252,8 @@ async function withRetry<T>(
|
|||||||
const jitter = Math.random() * baseDelay;
|
const jitter = Math.random() * baseDelay;
|
||||||
const delay = baseDelay + jitter;
|
const delay = baseDelay + jitter;
|
||||||
logger.warn(
|
logger.warn(
|
||||||
`Transient DB error in ${context}, retrying attempt ${attempt}/${MAX_RETRIES} after ${delay.toFixed(0)}ms`
|
`Transient DB error in ${context}, retrying attempt ${attempt}/${MAX_RETRIES} after ${delay.toFixed(0)}ms`,
|
||||||
|
{ code: error?.code ?? error?.cause?.code }
|
||||||
);
|
);
|
||||||
await new Promise((resolve) => setTimeout(resolve, delay));
|
await new Promise((resolve) => setTimeout(resolve, delay));
|
||||||
continue;
|
continue;
|
||||||
@@ -288,14 +264,14 @@ async function withRetry<T>(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Detect transient connection errors that are safe to retry.
|
* Detect transient errors that are safe to retry.
|
||||||
*/
|
*/
|
||||||
function isTransientError(error: any): boolean {
|
function isTransientError(error: any): boolean {
|
||||||
if (!error) return false;
|
if (!error) return false;
|
||||||
|
|
||||||
const message = (error.message || "").toLowerCase();
|
const message = (error.message || "").toLowerCase();
|
||||||
const causeMessage = (error.cause?.message || "").toLowerCase();
|
const causeMessage = (error.cause?.message || "").toLowerCase();
|
||||||
const code = error.code || "";
|
const code = error.code || error.cause?.code || "";
|
||||||
|
|
||||||
// Connection timeout / terminated
|
// Connection timeout / terminated
|
||||||
if (
|
if (
|
||||||
@@ -308,12 +284,17 @@ function isTransientError(error: any): boolean {
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
// PostgreSQL deadlock
|
// PostgreSQL deadlock detected — always safe to retry (one winner guaranteed)
|
||||||
if (code === "40P01" || message.includes("deadlock")) {
|
if (code === "40P01" || message.includes("deadlock")) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ECONNRESET, ECONNREFUSED, EPIPE
|
// PostgreSQL serialization failure
|
||||||
|
if (code === "40001") {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ECONNRESET, ECONNREFUSED, EPIPE, ETIMEDOUT
|
||||||
if (
|
if (
|
||||||
code === "ECONNRESET" ||
|
code === "ECONNRESET" ||
|
||||||
code === "ECONNREFUSED" ||
|
code === "ECONNREFUSED" ||
|
||||||
@@ -337,12 +318,26 @@ export function startPingAccumulator(): void {
|
|||||||
}
|
}
|
||||||
|
|
||||||
flushTimer = setInterval(async () => {
|
flushTimer = setInterval(async () => {
|
||||||
|
// Skip this tick if the previous flush is still in progress.
|
||||||
|
// setInterval does not await async callbacks, so without this guard
|
||||||
|
// two flush cycles can run concurrently and deadlock each other on
|
||||||
|
// overlapping bulk UPDATE statements.
|
||||||
|
if (isFlushing) {
|
||||||
|
logger.debug(
|
||||||
|
"Ping accumulator: previous flush still in progress, skipping cycle"
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
isFlushing = true;
|
||||||
try {
|
try {
|
||||||
await flushPingsToDb();
|
await flushPingsToDb();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error("Unhandled error in ping accumulator flush", {
|
logger.error("Unhandled error in ping accumulator flush", {
|
||||||
error
|
error
|
||||||
});
|
});
|
||||||
|
} finally {
|
||||||
|
isFlushing = false;
|
||||||
}
|
}
|
||||||
}, FLUSH_INTERVAL_MS);
|
}, FLUSH_INTERVAL_MS);
|
||||||
|
|
||||||
@@ -364,7 +359,22 @@ export async function stopPingAccumulator(): Promise<void> {
|
|||||||
flushTimer = null;
|
flushTimer = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Final flush to persist any remaining pings
|
// Final flush to persist any remaining pings.
|
||||||
|
// Wait for any in-progress flush to finish first so we don't race.
|
||||||
|
if (isFlushing) {
|
||||||
|
logger.debug(
|
||||||
|
"Ping accumulator: waiting for in-progress flush before stopping…"
|
||||||
|
);
|
||||||
|
await new Promise<void>((resolve) => {
|
||||||
|
const poll = setInterval(() => {
|
||||||
|
if (!isFlushing) {
|
||||||
|
clearInterval(poll);
|
||||||
|
resolve();
|
||||||
|
}
|
||||||
|
}, 50);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await flushPingsToDb();
|
await flushPingsToDb();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -379,4 +389,4 @@ export async function stopPingAccumulator(): Promise<void> {
|
|||||||
*/
|
*/
|
||||||
export function getPendingPingCount(): number {
|
export function getPendingPingCount(): number {
|
||||||
return pendingSitePings.size + pendingClientPings.size;
|
return pendingSitePings.size + pendingClientPings.size;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ import { build } from "@server/build";
|
|||||||
import { usageService } from "@server/lib/billing/usageService";
|
import { usageService } from "@server/lib/billing/usageService";
|
||||||
import { FeatureId } from "@server/lib/billing";
|
import { FeatureId } from "@server/lib/billing";
|
||||||
import { INSPECT_MAX_BYTES } from "buffer";
|
import { INSPECT_MAX_BYTES } from "buffer";
|
||||||
import { v } from "@faker-js/faker/dist/airline-Dz1uGqgJ";
|
import { getNextAvailableClientSubnet } from "@server/lib/ip";
|
||||||
|
|
||||||
const bodySchema = z.object({
|
const bodySchema = z.object({
|
||||||
provisioningKey: z.string().nonempty(),
|
provisioningKey: z.string().nonempty(),
|
||||||
@@ -152,6 +152,11 @@ export async function registerNewt(
|
|||||||
createHttpError(HttpCode.NOT_FOUND, "Organization not found")
|
createHttpError(HttpCode.NOT_FOUND, "Organization not found")
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
if (!org.subnet) {
|
||||||
|
return next(
|
||||||
|
createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "Organization subnet not found")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// SaaS billing check
|
// SaaS billing check
|
||||||
if (build == "saas") {
|
if (build == "saas") {
|
||||||
@@ -190,6 +195,20 @@ export async function registerNewt(
|
|||||||
let newSiteId: number | undefined;
|
let newSiteId: number | undefined;
|
||||||
|
|
||||||
await db.transaction(async (trx) => {
|
await db.transaction(async (trx) => {
|
||||||
|
|
||||||
|
const newClientAddress = await getNextAvailableClientSubnet(orgId);
|
||||||
|
if (!newClientAddress) {
|
||||||
|
return next(
|
||||||
|
createHttpError(
|
||||||
|
HttpCode.INTERNAL_SERVER_ERROR,
|
||||||
|
"No available subnet found"
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
let clientAddress = newClientAddress.split("/")[0];
|
||||||
|
clientAddress = `${clientAddress}/${org.subnet!.split("/")[1]}`; // we want the block size of the whole org
|
||||||
|
|
||||||
// Create the site (type "newt", name = niceId)
|
// Create the site (type "newt", name = niceId)
|
||||||
const [newSite] = await trx
|
const [newSite] = await trx
|
||||||
.insert(sites)
|
.insert(sites)
|
||||||
@@ -197,6 +216,7 @@ export async function registerNewt(
|
|||||||
orgId,
|
orgId,
|
||||||
name: name || niceId,
|
name: name || niceId,
|
||||||
niceId,
|
niceId,
|
||||||
|
address: clientAddress,
|
||||||
type: "newt",
|
type: "newt",
|
||||||
dockerSocketEnabled: true,
|
dockerSocketEnabled: true,
|
||||||
status: keyRecord.approveNewSites ? "approved" : "pending",
|
status: keyRecord.approveNewSites ? "approved" : "pending",
|
||||||
|
|||||||
@@ -144,7 +144,7 @@ export async function getUserResources(
|
|||||||
name: string;
|
name: string;
|
||||||
destination: string;
|
destination: string;
|
||||||
mode: string;
|
mode: string;
|
||||||
protocol: string | null;
|
scheme: string | null;
|
||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
alias: string | null;
|
alias: string | null;
|
||||||
aliasAddress: string | null;
|
aliasAddress: string | null;
|
||||||
@@ -156,7 +156,7 @@ export async function getUserResources(
|
|||||||
name: siteResources.name,
|
name: siteResources.name,
|
||||||
destination: siteResources.destination,
|
destination: siteResources.destination,
|
||||||
mode: siteResources.mode,
|
mode: siteResources.mode,
|
||||||
protocol: siteResources.protocol,
|
scheme: siteResources.scheme,
|
||||||
enabled: siteResources.enabled,
|
enabled: siteResources.enabled,
|
||||||
alias: siteResources.alias,
|
alias: siteResources.alias,
|
||||||
aliasAddress: siteResources.aliasAddress
|
aliasAddress: siteResources.aliasAddress
|
||||||
@@ -240,7 +240,7 @@ export async function getUserResources(
|
|||||||
name: siteResource.name,
|
name: siteResource.name,
|
||||||
destination: siteResource.destination,
|
destination: siteResource.destination,
|
||||||
mode: siteResource.mode,
|
mode: siteResource.mode,
|
||||||
protocol: siteResource.protocol,
|
protocol: siteResource.scheme,
|
||||||
enabled: siteResource.enabled,
|
enabled: siteResource.enabled,
|
||||||
alias: siteResource.alias,
|
alias: siteResource.alias,
|
||||||
aliasAddress: siteResource.aliasAddress,
|
aliasAddress: siteResource.aliasAddress,
|
||||||
@@ -289,7 +289,7 @@ export type GetUserResourcesResponse = {
|
|||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
alias: string | null;
|
alias: string | null;
|
||||||
aliasAddress: string | null;
|
aliasAddress: string | null;
|
||||||
type: 'site';
|
type: "site";
|
||||||
}>;
|
}>;
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -36,11 +36,12 @@ const createSiteResourceParamsSchema = z.strictObject({
|
|||||||
const createSiteResourceSchema = z
|
const createSiteResourceSchema = z
|
||||||
.strictObject({
|
.strictObject({
|
||||||
name: z.string().min(1).max(255),
|
name: z.string().min(1).max(255),
|
||||||
mode: z.enum(["host", "cidr", "port"]),
|
mode: z.enum(["host", "cidr", "http"]),
|
||||||
|
ssl: z.boolean().optional(), // only used for http mode
|
||||||
siteId: z.int(),
|
siteId: z.int(),
|
||||||
// protocol: z.enum(["tcp", "udp"]).optional(),
|
scheme: z.enum(["http", "https"]).optional(),
|
||||||
// proxyPort: z.int().positive().optional(),
|
// proxyPort: z.int().positive().optional(),
|
||||||
// destinationPort: z.int().positive().optional(),
|
destinationPort: z.int().positive().optional(),
|
||||||
destination: z.string().min(1),
|
destination: z.string().min(1),
|
||||||
enabled: z.boolean().default(true),
|
enabled: z.boolean().default(true),
|
||||||
alias: z
|
alias: z
|
||||||
@@ -62,15 +63,20 @@ const createSiteResourceSchema = z
|
|||||||
.strict()
|
.strict()
|
||||||
.refine(
|
.refine(
|
||||||
(data) => {
|
(data) => {
|
||||||
if (data.mode === "host") {
|
if (
|
||||||
// Check if it's a valid IP address using zod (v4 or v6)
|
data.mode === "host" ||
|
||||||
const isValidIP = z
|
data.mode == "http"
|
||||||
// .union([z.ipv4(), z.ipv6()])
|
) {
|
||||||
.union([z.ipv4()]) // for now lets just do ipv4 until we verify ipv6 works everywhere
|
if (data.mode == "host") {
|
||||||
.safeParse(data.destination).success;
|
// Check if it's a valid IP address using zod (v4 or v6)
|
||||||
|
const isValidIP = z
|
||||||
|
// .union([z.ipv4(), z.ipv6()])
|
||||||
|
.union([z.ipv4()]) // for now lets just do ipv4 until we verify ipv6 works everywhere
|
||||||
|
.safeParse(data.destination).success;
|
||||||
|
|
||||||
if (isValidIP) {
|
if (isValidIP) {
|
||||||
return true;
|
return true;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if it's a valid domain (hostname pattern, TLD not required)
|
// Check if it's a valid domain (hostname pattern, TLD not required)
|
||||||
@@ -105,6 +111,21 @@ const createSiteResourceSchema = z
|
|||||||
{
|
{
|
||||||
message: "Destination must be a valid CIDR notation for cidr mode"
|
message: "Destination must be a valid CIDR notation for cidr mode"
|
||||||
}
|
}
|
||||||
|
)
|
||||||
|
.refine(
|
||||||
|
(data) => {
|
||||||
|
if (data.mode !== "http") return true;
|
||||||
|
return (
|
||||||
|
data.scheme !== undefined &&
|
||||||
|
data.destinationPort !== undefined &&
|
||||||
|
data.destinationPort >= 1 &&
|
||||||
|
data.destinationPort <= 65535
|
||||||
|
);
|
||||||
|
},
|
||||||
|
{
|
||||||
|
message:
|
||||||
|
"HTTP mode requires scheme (http or https) and a valid destination port"
|
||||||
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
export type CreateSiteResourceBody = z.infer<typeof createSiteResourceSchema>;
|
export type CreateSiteResourceBody = z.infer<typeof createSiteResourceSchema>;
|
||||||
@@ -161,11 +182,12 @@ export async function createSiteResource(
|
|||||||
name,
|
name,
|
||||||
siteId,
|
siteId,
|
||||||
mode,
|
mode,
|
||||||
// protocol,
|
scheme,
|
||||||
// proxyPort,
|
// proxyPort,
|
||||||
// destinationPort,
|
destinationPort,
|
||||||
destination,
|
destination,
|
||||||
enabled,
|
enabled,
|
||||||
|
ssl,
|
||||||
alias,
|
alias,
|
||||||
userIds,
|
userIds,
|
||||||
roleIds,
|
roleIds,
|
||||||
@@ -226,30 +248,6 @@ export async function createSiteResource(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// // check if resource with same protocol and proxy port already exists (only for port mode)
|
|
||||||
// if (mode === "port" && protocol && proxyPort) {
|
|
||||||
// const [existingResource] = await db
|
|
||||||
// .select()
|
|
||||||
// .from(siteResources)
|
|
||||||
// .where(
|
|
||||||
// and(
|
|
||||||
// eq(siteResources.siteId, siteId),
|
|
||||||
// eq(siteResources.orgId, orgId),
|
|
||||||
// eq(siteResources.protocol, protocol),
|
|
||||||
// eq(siteResources.proxyPort, proxyPort)
|
|
||||||
// )
|
|
||||||
// )
|
|
||||||
// .limit(1);
|
|
||||||
// if (existingResource && existingResource.siteResourceId) {
|
|
||||||
// return next(
|
|
||||||
// createHttpError(
|
|
||||||
// HttpCode.CONFLICT,
|
|
||||||
// "A resource with the same protocol and proxy port already exists"
|
|
||||||
// )
|
|
||||||
// );
|
|
||||||
// }
|
|
||||||
// }
|
|
||||||
|
|
||||||
// make sure the alias is unique within the org if provided
|
// make sure the alias is unique within the org if provided
|
||||||
if (alias) {
|
if (alias) {
|
||||||
const [conflict] = await db
|
const [conflict] = await db
|
||||||
@@ -280,8 +278,7 @@ export async function createSiteResource(
|
|||||||
|
|
||||||
const niceId = await getUniqueSiteResourceName(orgId);
|
const niceId = await getUniqueSiteResourceName(orgId);
|
||||||
let aliasAddress: string | null = null;
|
let aliasAddress: string | null = null;
|
||||||
if (mode == "host") {
|
if (mode === "host" || mode === "http") {
|
||||||
// we can only have an alias on a host
|
|
||||||
aliasAddress = await getNextAvailableAliasAddress(orgId);
|
aliasAddress = await getNextAvailableAliasAddress(orgId);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -293,8 +290,11 @@ export async function createSiteResource(
|
|||||||
niceId,
|
niceId,
|
||||||
orgId,
|
orgId,
|
||||||
name,
|
name,
|
||||||
mode: mode as "host" | "cidr",
|
mode,
|
||||||
|
ssl,
|
||||||
destination,
|
destination,
|
||||||
|
scheme,
|
||||||
|
destinationPort,
|
||||||
enabled,
|
enabled,
|
||||||
alias,
|
alias,
|
||||||
aliasAddress,
|
aliasAddress,
|
||||||
|
|||||||
@@ -41,12 +41,12 @@ const listAllSiteResourcesByOrgQuerySchema = z.object({
|
|||||||
}),
|
}),
|
||||||
query: z.string().optional(),
|
query: z.string().optional(),
|
||||||
mode: z
|
mode: z
|
||||||
.enum(["host", "cidr"])
|
.enum(["host", "cidr", "http"])
|
||||||
.optional()
|
.optional()
|
||||||
.catch(undefined)
|
.catch(undefined)
|
||||||
.openapi({
|
.openapi({
|
||||||
type: "string",
|
type: "string",
|
||||||
enum: ["host", "cidr"],
|
enum: ["host", "cidr", "http"],
|
||||||
description: "Filter site resources by mode"
|
description: "Filter site resources by mode"
|
||||||
}),
|
}),
|
||||||
sort_by: z
|
sort_by: z
|
||||||
@@ -88,7 +88,8 @@ function querySiteResourcesBase() {
|
|||||||
niceId: siteResources.niceId,
|
niceId: siteResources.niceId,
|
||||||
name: siteResources.name,
|
name: siteResources.name,
|
||||||
mode: siteResources.mode,
|
mode: siteResources.mode,
|
||||||
protocol: siteResources.protocol,
|
ssl: siteResources.ssl,
|
||||||
|
scheme: siteResources.scheme,
|
||||||
proxyPort: siteResources.proxyPort,
|
proxyPort: siteResources.proxyPort,
|
||||||
destinationPort: siteResources.destinationPort,
|
destinationPort: siteResources.destinationPort,
|
||||||
destination: siteResources.destination,
|
destination: siteResources.destination,
|
||||||
@@ -193,7 +194,9 @@ export async function listAllSiteResourcesByOrg(
|
|||||||
const baseQuery = querySiteResourcesBase().where(and(...conditions));
|
const baseQuery = querySiteResourcesBase().where(and(...conditions));
|
||||||
|
|
||||||
const countQuery = db.$count(
|
const countQuery = db.$count(
|
||||||
querySiteResourcesBase().where(and(...conditions)).as("filtered_site_resources")
|
querySiteResourcesBase()
|
||||||
|
.where(and(...conditions))
|
||||||
|
.as("filtered_site_resources")
|
||||||
);
|
);
|
||||||
|
|
||||||
const [siteResourcesList, totalCount] = await Promise.all([
|
const [siteResourcesList, totalCount] = await Promise.all([
|
||||||
|
|||||||
@@ -51,10 +51,11 @@ const updateSiteResourceSchema = z
|
|||||||
)
|
)
|
||||||
.optional(),
|
.optional(),
|
||||||
// mode: z.enum(["host", "cidr", "port"]).optional(),
|
// mode: z.enum(["host", "cidr", "port"]).optional(),
|
||||||
mode: z.enum(["host", "cidr"]).optional(),
|
mode: z.enum(["host", "cidr", "http"]).optional(),
|
||||||
// protocol: z.enum(["tcp", "udp"]).nullish(),
|
ssl: z.boolean().optional(),
|
||||||
|
scheme: z.enum(["http", "https"]).nullish(),
|
||||||
// proxyPort: z.int().positive().nullish(),
|
// proxyPort: z.int().positive().nullish(),
|
||||||
// destinationPort: z.int().positive().nullish(),
|
destinationPort: z.int().positive().nullish(),
|
||||||
destination: z.string().min(1).optional(),
|
destination: z.string().min(1).optional(),
|
||||||
enabled: z.boolean().optional(),
|
enabled: z.boolean().optional(),
|
||||||
alias: z
|
alias: z
|
||||||
@@ -76,14 +77,20 @@ const updateSiteResourceSchema = z
|
|||||||
.strict()
|
.strict()
|
||||||
.refine(
|
.refine(
|
||||||
(data) => {
|
(data) => {
|
||||||
if (data.mode === "host" && data.destination) {
|
if (
|
||||||
const isValidIP = z
|
(data.mode === "host" ||
|
||||||
// .union([z.ipv4(), z.ipv6()])
|
data.mode == "http") &&
|
||||||
.union([z.ipv4()]) // for now lets just do ipv4 until we verify ipv6 works everywhere
|
data.destination
|
||||||
.safeParse(data.destination).success;
|
) {
|
||||||
|
if (data.mode == "host") {
|
||||||
|
const isValidIP = z
|
||||||
|
// .union([z.ipv4(), z.ipv6()])
|
||||||
|
.union([z.ipv4()]) // for now lets just do ipv4 until we verify ipv6 works everywhere
|
||||||
|
.safeParse(data.destination).success;
|
||||||
|
|
||||||
if (isValidIP) {
|
if (isValidIP) {
|
||||||
return true;
|
return true;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if it's a valid domain (hostname pattern, TLD not required)
|
// Check if it's a valid domain (hostname pattern, TLD not required)
|
||||||
@@ -118,6 +125,23 @@ const updateSiteResourceSchema = z
|
|||||||
{
|
{
|
||||||
message: "Destination must be a valid CIDR notation for cidr mode"
|
message: "Destination must be a valid CIDR notation for cidr mode"
|
||||||
}
|
}
|
||||||
|
)
|
||||||
|
.refine(
|
||||||
|
(data) => {
|
||||||
|
if (data.mode !== "http") return true;
|
||||||
|
return (
|
||||||
|
data.scheme !== undefined &&
|
||||||
|
data.scheme !== null &&
|
||||||
|
data.destinationPort !== undefined &&
|
||||||
|
data.destinationPort !== null &&
|
||||||
|
data.destinationPort >= 1 &&
|
||||||
|
data.destinationPort <= 65535
|
||||||
|
);
|
||||||
|
},
|
||||||
|
{
|
||||||
|
message:
|
||||||
|
"HTTP mode requires scheme (http or https) and a valid destination port"
|
||||||
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
export type UpdateSiteResourceBody = z.infer<typeof updateSiteResourceSchema>;
|
export type UpdateSiteResourceBody = z.infer<typeof updateSiteResourceSchema>;
|
||||||
@@ -175,8 +199,11 @@ export async function updateSiteResource(
|
|||||||
siteId, // because it can change
|
siteId, // because it can change
|
||||||
niceId,
|
niceId,
|
||||||
mode,
|
mode,
|
||||||
|
scheme,
|
||||||
destination,
|
destination,
|
||||||
|
destinationPort,
|
||||||
alias,
|
alias,
|
||||||
|
ssl,
|
||||||
enabled,
|
enabled,
|
||||||
userIds,
|
userIds,
|
||||||
roleIds,
|
roleIds,
|
||||||
@@ -346,7 +373,10 @@ export async function updateSiteResource(
|
|||||||
siteId,
|
siteId,
|
||||||
niceId,
|
niceId,
|
||||||
mode,
|
mode,
|
||||||
|
scheme,
|
||||||
|
ssl,
|
||||||
destination,
|
destination,
|
||||||
|
destinationPort,
|
||||||
enabled,
|
enabled,
|
||||||
alias: alias && alias.trim() ? alias : null,
|
alias: alias && alias.trim() ? alias : null,
|
||||||
tcpPortRangeString,
|
tcpPortRangeString,
|
||||||
@@ -449,7 +479,10 @@ export async function updateSiteResource(
|
|||||||
name: name,
|
name: name,
|
||||||
siteId: siteId,
|
siteId: siteId,
|
||||||
mode: mode,
|
mode: mode,
|
||||||
|
scheme,
|
||||||
|
ssl,
|
||||||
destination: destination,
|
destination: destination,
|
||||||
|
destinationPort: destinationPort,
|
||||||
enabled: enabled,
|
enabled: enabled,
|
||||||
alias: alias && alias.trim() ? alias : null,
|
alias: alias && alias.trim() ? alias : null,
|
||||||
tcpPortRangeString: tcpPortRangeString,
|
tcpPortRangeString: tcpPortRangeString,
|
||||||
|
|||||||
@@ -104,6 +104,42 @@ export default async function migration() {
|
|||||||
CONSTRAINT "userOrgRoles_userId_orgId_roleId_unique" UNIQUE("userId","orgId","roleId")
|
CONSTRAINT "userOrgRoles_userId_orgId_roleId_unique" UNIQUE("userId","orgId","roleId")
|
||||||
);
|
);
|
||||||
`);
|
`);
|
||||||
|
|
||||||
|
await db.execute(sql`
|
||||||
|
CREATE TABLE "eventStreamingCursors" (
|
||||||
|
"cursorId" serial PRIMARY KEY NOT NULL,
|
||||||
|
"destinationId" integer NOT NULL,
|
||||||
|
"logType" varchar(50) NOT NULL,
|
||||||
|
"lastSentId" bigint DEFAULT 0 NOT NULL,
|
||||||
|
"lastSentAt" bigint
|
||||||
|
);
|
||||||
|
`);
|
||||||
|
|
||||||
|
await db.execute(sql`
|
||||||
|
CREATE TABLE "eventStreamingDestinations" (
|
||||||
|
"destinationId" serial PRIMARY KEY NOT NULL,
|
||||||
|
"orgId" varchar(255) NOT NULL,
|
||||||
|
"sendConnectionLogs" boolean DEFAULT false NOT NULL,
|
||||||
|
"sendRequestLogs" boolean DEFAULT false NOT NULL,
|
||||||
|
"sendActionLogs" boolean DEFAULT false NOT NULL,
|
||||||
|
"sendAccessLogs" boolean DEFAULT false NOT NULL,
|
||||||
|
"type" varchar(50) NOT NULL,
|
||||||
|
"config" text NOT NULL,
|
||||||
|
"enabled" boolean DEFAULT true NOT NULL,
|
||||||
|
"createdAt" bigint NOT NULL,
|
||||||
|
"updatedAt" bigint NOT NULL
|
||||||
|
);
|
||||||
|
`);
|
||||||
|
|
||||||
|
await db.execute(
|
||||||
|
sql`ALTER TABLE "eventStreamingCursors" ADD CONSTRAINT "eventStreamingCursors_destinationId_eventStreamingDestinations_destinationId_fk" FOREIGN KEY ("destinationId") REFERENCES "public"."eventStreamingDestinations"("destinationId") ON DELETE cascade ON UPDATE no action;`
|
||||||
|
);
|
||||||
|
await db.execute(
|
||||||
|
sql`ALTER TABLE "eventStreamingDestinations" ADD CONSTRAINT "eventStreamingDestinations_orgId_orgs_orgId_fk" FOREIGN KEY ("orgId") REFERENCES "public"."orgs"("orgId") ON DELETE cascade ON UPDATE no action;`
|
||||||
|
);
|
||||||
|
await db.execute(
|
||||||
|
sql`CREATE UNIQUE INDEX "idx_eventStreamingCursors_dest_type" ON "eventStreamingCursors" USING btree ("destinationId","logType");`
|
||||||
|
);
|
||||||
await db.execute(
|
await db.execute(
|
||||||
sql`ALTER TABLE "userOrgs" DROP CONSTRAINT "userOrgs_roleId_roles_roleId_fk";`
|
sql`ALTER TABLE "userOrgs" DROP CONSTRAINT "userOrgs_roleId_roles_roleId_fk";`
|
||||||
);
|
);
|
||||||
@@ -177,8 +213,12 @@ export default async function migration() {
|
|||||||
sql`CREATE INDEX "idx_accessAuditLog_siteResourceId" ON "connectionAuditLog" USING btree ("siteResourceId");`
|
sql`CREATE INDEX "idx_accessAuditLog_siteResourceId" ON "connectionAuditLog" USING btree ("siteResourceId");`
|
||||||
);
|
);
|
||||||
await db.execute(sql`ALTER TABLE "userInvites" DROP COLUMN "roleId";`);
|
await db.execute(sql`ALTER TABLE "userInvites" DROP COLUMN "roleId";`);
|
||||||
await db.execute(sql`ALTER TABLE "siteProvisioningKeys" ADD COLUMN "approveNewSites" boolean DEFAULT true NOT NULL;`);
|
await db.execute(
|
||||||
await db.execute(sql`ALTER TABLE "sites" ADD COLUMN "status" varchar DEFAULT 'approved';`);
|
sql`ALTER TABLE "siteProvisioningKeys" ADD COLUMN "approveNewSites" boolean DEFAULT true NOT NULL;`
|
||||||
|
);
|
||||||
|
await db.execute(
|
||||||
|
sql`ALTER TABLE "sites" ADD COLUMN "status" varchar DEFAULT 'approved';`
|
||||||
|
);
|
||||||
|
|
||||||
await db.execute(sql`COMMIT`);
|
await db.execute(sql`COMMIT`);
|
||||||
console.log("Migrated database");
|
console.log("Migrated database");
|
||||||
@@ -195,7 +235,9 @@ export default async function migration() {
|
|||||||
for (const row of existingUserInviteRoles) {
|
for (const row of existingUserInviteRoles) {
|
||||||
await db.execute(sql`
|
await db.execute(sql`
|
||||||
INSERT INTO "userInviteRoles" ("inviteId", "roleId")
|
INSERT INTO "userInviteRoles" ("inviteId", "roleId")
|
||||||
VALUES (${row.inviteId}, ${row.roleId})
|
SELECT ${row.inviteId}, ${row.roleId}
|
||||||
|
WHERE EXISTS (SELECT 1 FROM "userInvites" WHERE "inviteId" = ${row.inviteId})
|
||||||
|
AND EXISTS (SELECT 1 FROM "roles" WHERE "roleId" = ${row.roleId})
|
||||||
ON CONFLICT DO NOTHING
|
ON CONFLICT DO NOTHING
|
||||||
`);
|
`);
|
||||||
}
|
}
|
||||||
@@ -218,7 +260,10 @@ export default async function migration() {
|
|||||||
for (const row of existingUserOrgRoles) {
|
for (const row of existingUserOrgRoles) {
|
||||||
await db.execute(sql`
|
await db.execute(sql`
|
||||||
INSERT INTO "userOrgRoles" ("userId", "orgId", "roleId")
|
INSERT INTO "userOrgRoles" ("userId", "orgId", "roleId")
|
||||||
VALUES (${row.userId}, ${row.orgId}, ${row.roleId})
|
SELECT ${row.userId}, ${row.orgId}, ${row.roleId}
|
||||||
|
WHERE EXISTS (SELECT 1 FROM "user" WHERE "id" = ${row.userId})
|
||||||
|
AND EXISTS (SELECT 1 FROM "orgs" WHERE "orgId" = ${row.orgId})
|
||||||
|
AND EXISTS (SELECT 1 FROM "roles" WHERE "roleId" = ${row.roleId})
|
||||||
ON CONFLICT DO NOTHING
|
ON CONFLICT DO NOTHING
|
||||||
`);
|
`);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -76,9 +76,15 @@ export default async function migration() {
|
|||||||
`
|
`
|
||||||
).run();
|
).run();
|
||||||
|
|
||||||
db.prepare(`CREATE INDEX 'idx_accessAuditLog_startedAt' ON 'connectionAuditLog' ('startedAt');`).run();
|
db.prepare(
|
||||||
db.prepare(`CREATE INDEX 'idx_accessAuditLog_org_startedAt' ON 'connectionAuditLog' ('orgId','startedAt');`).run();
|
`CREATE INDEX 'idx_accessAuditLog_startedAt' ON 'connectionAuditLog' ('startedAt');`
|
||||||
db.prepare(`CREATE INDEX 'idx_accessAuditLog_siteResourceId' ON 'connectionAuditLog' ('siteResourceId');`).run();
|
).run();
|
||||||
|
db.prepare(
|
||||||
|
`CREATE INDEX 'idx_accessAuditLog_org_startedAt' ON 'connectionAuditLog' ('orgId','startedAt');`
|
||||||
|
).run();
|
||||||
|
db.prepare(
|
||||||
|
`CREATE INDEX 'idx_accessAuditLog_siteResourceId' ON 'connectionAuditLog' ('siteResourceId');`
|
||||||
|
).run();
|
||||||
|
|
||||||
db.prepare(
|
db.prepare(
|
||||||
`
|
`
|
||||||
@@ -139,7 +145,7 @@ export default async function migration() {
|
|||||||
).run();
|
).run();
|
||||||
|
|
||||||
db.prepare(
|
db.prepare(
|
||||||
`INSERT INTO '__new_userOrgs'("userId", "orgId", "isOwner", "autoProvisioned", "pamUsername") SELECT "userId", "orgId", "isOwner", "autoProvisioned", "pamUsername" FROM 'userOrgs';`
|
`INSERT INTO '__new_userOrgs'("userId", "orgId", "isOwner", "autoProvisioned", "pamUsername") SELECT "userId", "orgId", "isOwner", "autoProvisioned", "pamUsername" FROM 'userOrgs' WHERE EXISTS (SELECT 1 FROM 'user' WHERE id = userOrgs.userId) AND EXISTS (SELECT 1 FROM 'orgs' WHERE orgId = userOrgs.orgId);`
|
||||||
).run();
|
).run();
|
||||||
db.prepare(`DROP TABLE 'userOrgs';`).run();
|
db.prepare(`DROP TABLE 'userOrgs';`).run();
|
||||||
db.prepare(
|
db.prepare(
|
||||||
@@ -168,6 +174,42 @@ export default async function migration() {
|
|||||||
);
|
);
|
||||||
`
|
`
|
||||||
).run();
|
).run();
|
||||||
|
|
||||||
|
db.prepare(
|
||||||
|
`
|
||||||
|
CREATE TABLE 'eventStreamingCursors' (
|
||||||
|
'cursorId' integer PRIMARY KEY AUTOINCREMENT NOT NULL,
|
||||||
|
'destinationId' integer NOT NULL,
|
||||||
|
'logType' text NOT NULL,
|
||||||
|
'lastSentId' integer DEFAULT 0 NOT NULL,
|
||||||
|
'lastSentAt' integer,
|
||||||
|
FOREIGN KEY ('destinationId') REFERENCES 'eventStreamingDestinations'('destinationId') ON UPDATE no action ON DELETE cascade
|
||||||
|
);
|
||||||
|
`
|
||||||
|
).run();
|
||||||
|
db.prepare(
|
||||||
|
`
|
||||||
|
CREATE UNIQUE INDEX 'idx_eventStreamingCursors_dest_type' ON 'eventStreamingCursors' ('destinationId','logType');--> statement-breakpoint
|
||||||
|
`
|
||||||
|
).run();
|
||||||
|
db.prepare(
|
||||||
|
`
|
||||||
|
CREATE TABLE 'eventStreamingDestinations' (
|
||||||
|
'destinationId' integer PRIMARY KEY AUTOINCREMENT NOT NULL,
|
||||||
|
'orgId' text NOT NULL,
|
||||||
|
'sendConnectionLogs' integer DEFAULT false NOT NULL,
|
||||||
|
'sendRequestLogs' integer DEFAULT false NOT NULL,
|
||||||
|
'sendActionLogs' integer DEFAULT false NOT NULL,
|
||||||
|
'sendAccessLogs' integer DEFAULT false NOT NULL,
|
||||||
|
'type' text NOT NULL,
|
||||||
|
'config' text NOT NULL,
|
||||||
|
'enabled' integer DEFAULT true NOT NULL,
|
||||||
|
'createdAt' integer NOT NULL,
|
||||||
|
'updatedAt' integer NOT NULL,
|
||||||
|
FOREIGN KEY ('orgId') REFERENCES 'orgs'('orgId') ON UPDATE no action ON DELETE cascade
|
||||||
|
);
|
||||||
|
`
|
||||||
|
).run();
|
||||||
db.prepare(
|
db.prepare(
|
||||||
`INSERT INTO '__new_userInvites'("inviteId", "orgId", "email", "expiresAt", "token") SELECT "inviteId", "orgId", "email", "expiresAt", "token" FROM 'userInvites';`
|
`INSERT INTO '__new_userInvites'("inviteId", "orgId", "email", "expiresAt", "token") SELECT "inviteId", "orgId", "email", "expiresAt", "token" FROM 'userInvites';`
|
||||||
).run();
|
).run();
|
||||||
@@ -191,8 +233,12 @@ export default async function migration() {
|
|||||||
`ALTER TABLE 'user' ADD 'marketingEmailConsent' integer DEFAULT false;`
|
`ALTER TABLE 'user' ADD 'marketingEmailConsent' integer DEFAULT false;`
|
||||||
).run();
|
).run();
|
||||||
db.prepare(`ALTER TABLE 'user' ADD 'locale' text;`).run();
|
db.prepare(`ALTER TABLE 'user' ADD 'locale' text;`).run();
|
||||||
db.prepare(`ALTER TABLE 'siteProvisioningKeys' ADD COLUMN 'approveNewSites' integer DEFAULT 1 NOT NULL;`).run();
|
db.prepare(
|
||||||
db.prepare(`ALTER TABLE 'sites' ADD COLUMN 'status' text DEFAULT 'approved';`).run();
|
`ALTER TABLE 'siteProvisioningKeys' ADD COLUMN 'approveNewSites' integer DEFAULT 1 NOT NULL;`
|
||||||
|
).run();
|
||||||
|
db.prepare(
|
||||||
|
`ALTER TABLE 'sites' ADD COLUMN 'status' text DEFAULT 'approved';`
|
||||||
|
).run();
|
||||||
})();
|
})();
|
||||||
|
|
||||||
db.pragma("foreign_keys = ON");
|
db.pragma("foreign_keys = ON");
|
||||||
@@ -200,12 +246,15 @@ export default async function migration() {
|
|||||||
// Re-insert the preserved invite role assignments into the new userInviteRoles table
|
// Re-insert the preserved invite role assignments into the new userInviteRoles table
|
||||||
if (existingUserInviteRoles.length > 0) {
|
if (existingUserInviteRoles.length > 0) {
|
||||||
const insertUserInviteRole = db.prepare(
|
const insertUserInviteRole = db.prepare(
|
||||||
`INSERT OR IGNORE INTO 'userInviteRoles' ("inviteId", "roleId") VALUES (?, ?)`
|
`INSERT OR IGNORE INTO 'userInviteRoles' ("inviteId", "roleId")
|
||||||
|
SELECT ?, ?
|
||||||
|
WHERE EXISTS (SELECT 1 FROM 'userInvites' WHERE inviteId = ?)
|
||||||
|
AND EXISTS (SELECT 1 FROM 'roles' WHERE roleId = ?)`
|
||||||
);
|
);
|
||||||
|
|
||||||
const insertAll = db.transaction(() => {
|
const insertAll = db.transaction(() => {
|
||||||
for (const row of existingUserInviteRoles) {
|
for (const row of existingUserInviteRoles) {
|
||||||
insertUserInviteRole.run(row.inviteId, row.roleId);
|
insertUserInviteRole.run(row.inviteId, row.roleId, row.inviteId, row.roleId);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -219,12 +268,16 @@ export default async function migration() {
|
|||||||
// Re-insert the preserved role assignments into the new userOrgRoles table
|
// Re-insert the preserved role assignments into the new userOrgRoles table
|
||||||
if (existingUserOrgRoles.length > 0) {
|
if (existingUserOrgRoles.length > 0) {
|
||||||
const insertUserOrgRole = db.prepare(
|
const insertUserOrgRole = db.prepare(
|
||||||
`INSERT OR IGNORE INTO 'userOrgRoles' ("userId", "orgId", "roleId") VALUES (?, ?, ?)`
|
`INSERT OR IGNORE INTO 'userOrgRoles' ("userId", "orgId", "roleId")
|
||||||
|
SELECT ?, ?, ?
|
||||||
|
WHERE EXISTS (SELECT 1 FROM 'user' WHERE id = ?)
|
||||||
|
AND EXISTS (SELECT 1 FROM 'orgs' WHERE orgId = ?)
|
||||||
|
AND EXISTS (SELECT 1 FROM 'roles' WHERE roleId = ?)`
|
||||||
);
|
);
|
||||||
|
|
||||||
const insertAll = db.transaction(() => {
|
const insertAll = db.transaction(() => {
|
||||||
for (const row of existingUserOrgRoles) {
|
for (const row of existingUserOrgRoles) {
|
||||||
insertUserOrgRole.run(row.userId, row.orgId, row.roleId);
|
insertUserOrgRole.run(row.userId, row.orgId, row.roleId, row.userId, row.orgId, row.roleId);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -491,7 +491,7 @@ export default function ConnectionLogsPage() {
|
|||||||
);
|
);
|
||||||
},
|
},
|
||||||
cell: ({ row }) => {
|
cell: ({ row }) => {
|
||||||
const clientType = row.original.clientType === "olm" ? "machine" : "user";
|
const clientType = row.original.userId ? "user" : "machine";
|
||||||
if (row.original.clientName && row.original.clientNiceId) {
|
if (row.original.clientName && row.original.clientNiceId) {
|
||||||
return (
|
return (
|
||||||
<Link
|
<Link
|
||||||
|
|||||||
@@ -106,7 +106,9 @@ function DestinationCard({
|
|||||||
{/* URL preview */}
|
{/* URL preview */}
|
||||||
<p className="text-xs text-muted-foreground truncate">
|
<p className="text-xs text-muted-foreground truncate">
|
||||||
{cfg.url || (
|
{cfg.url || (
|
||||||
<span className="italic">{t("streamingNoUrlConfigured")}</span>
|
<span className="italic">
|
||||||
|
{t("streamingNoUrlConfigured")}
|
||||||
|
</span>
|
||||||
)}
|
)}
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
@@ -160,7 +162,9 @@ function AddDestinationCard({ onClick }: { onClick: () => void }) {
|
|||||||
<div className="flex items-center justify-center w-9 h-9 rounded-md border-2 border-dashed border-current">
|
<div className="flex items-center justify-center w-9 h-9 rounded-md border-2 border-dashed border-current">
|
||||||
<Plus className="h-4 w-4" />
|
<Plus className="h-4 w-4" />
|
||||||
</div>
|
</div>
|
||||||
<span className="text-sm font-medium">{t("streamingAddDestination")}</span>
|
<span className="text-sm font-medium">
|
||||||
|
{t("streamingAddDestination")}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
@@ -186,7 +190,9 @@ function DestinationTypePicker({
|
|||||||
const t = useTranslations();
|
const t = useTranslations();
|
||||||
const [selected, setSelected] = useState<DestinationType>("http");
|
const [selected, setSelected] = useState<DestinationType>("http");
|
||||||
|
|
||||||
const destinationTypeOptions: ReadonlyArray<StrategyOption<DestinationType>> = [
|
const destinationTypeOptions: ReadonlyArray<
|
||||||
|
StrategyOption<DestinationType>
|
||||||
|
> = [
|
||||||
{
|
{
|
||||||
id: "http",
|
id: "http",
|
||||||
title: t("streamingHttpWebhookTitle"),
|
title: t("streamingHttpWebhookTitle"),
|
||||||
@@ -233,13 +239,19 @@ function DestinationTypePicker({
|
|||||||
<Credenza open={open} onOpenChange={onOpenChange}>
|
<Credenza open={open} onOpenChange={onOpenChange}>
|
||||||
<CredenzaContent className="sm:max-w-lg">
|
<CredenzaContent className="sm:max-w-lg">
|
||||||
<CredenzaHeader>
|
<CredenzaHeader>
|
||||||
<CredenzaTitle>{t("streamingAddDestination")}</CredenzaTitle>
|
<CredenzaTitle>
|
||||||
|
{t("streamingAddDestination")}
|
||||||
|
</CredenzaTitle>
|
||||||
<CredenzaDescription>
|
<CredenzaDescription>
|
||||||
{t("streamingTypePickerDescription")}
|
{t("streamingTypePickerDescription")}
|
||||||
</CredenzaDescription>
|
</CredenzaDescription>
|
||||||
</CredenzaHeader>
|
</CredenzaHeader>
|
||||||
<CredenzaBody>
|
<CredenzaBody>
|
||||||
<div className={isPaywalled ? "pointer-events-none opacity-50" : ""}>
|
<div
|
||||||
|
className={
|
||||||
|
isPaywalled ? "pointer-events-none opacity-50" : ""
|
||||||
|
}
|
||||||
|
>
|
||||||
<StrategySelect
|
<StrategySelect
|
||||||
options={destinationTypeOptions}
|
options={destinationTypeOptions}
|
||||||
value={selected}
|
value={selected}
|
||||||
@@ -301,10 +313,7 @@ export default function StreamingDestinationsPage() {
|
|||||||
toast({
|
toast({
|
||||||
variant: "destructive",
|
variant: "destructive",
|
||||||
title: t("streamingFailedToLoad"),
|
title: t("streamingFailedToLoad"),
|
||||||
description: formatAxiosError(
|
description: formatAxiosError(e, t("streamingUnexpectedError"))
|
||||||
e,
|
|
||||||
t("streamingUnexpectedError")
|
|
||||||
)
|
|
||||||
});
|
});
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
@@ -341,10 +350,7 @@ export default function StreamingDestinationsPage() {
|
|||||||
toast({
|
toast({
|
||||||
variant: "destructive",
|
variant: "destructive",
|
||||||
title: t("streamingFailedToUpdate"),
|
title: t("streamingFailedToUpdate"),
|
||||||
description: formatAxiosError(
|
description: formatAxiosError(e, t("streamingUnexpectedError"))
|
||||||
e,
|
|
||||||
t("streamingUnexpectedError")
|
|
||||||
)
|
|
||||||
});
|
});
|
||||||
} finally {
|
} finally {
|
||||||
setTogglingIds((prev) => {
|
setTogglingIds((prev) => {
|
||||||
@@ -375,10 +381,7 @@ export default function StreamingDestinationsPage() {
|
|||||||
toast({
|
toast({
|
||||||
variant: "destructive",
|
variant: "destructive",
|
||||||
title: t("streamingFailedToDelete"),
|
title: t("streamingFailedToDelete"),
|
||||||
description: formatAxiosError(
|
description: formatAxiosError(e, t("streamingUnexpectedError"))
|
||||||
e,
|
|
||||||
t("streamingUnexpectedError")
|
|
||||||
)
|
|
||||||
});
|
});
|
||||||
} finally {
|
} finally {
|
||||||
setDeleting(false);
|
setDeleting(false);
|
||||||
@@ -459,13 +462,14 @@ export default function StreamingDestinationsPage() {
|
|||||||
if (!v) setDeleteTarget(null);
|
if (!v) setDeleteTarget(null);
|
||||||
}}
|
}}
|
||||||
string={
|
string={
|
||||||
parseHttpConfig(deleteTarget.config).name || t("streamingDeleteDialogThisDestination")
|
parseHttpConfig(deleteTarget.config).name ||
|
||||||
|
t("streamingDeleteDialogThisDestination")
|
||||||
}
|
}
|
||||||
title={t("streamingDeleteTitle")}
|
title={t("streamingDeleteTitle")}
|
||||||
dialog={
|
dialog={
|
||||||
<p className="text-sm text-muted-foreground">
|
<p>
|
||||||
{t("streamingDeleteDialogAreYouSure")}{" "}
|
{t("streamingDeleteDialogAreYouSure")}{" "}
|
||||||
<span className="font-semibold text-foreground">
|
<span>
|
||||||
{parseHttpConfig(deleteTarget.config).name ||
|
{parseHttpConfig(deleteTarget.config).name ||
|
||||||
t("streamingDeleteDialogThisDestination")}
|
t("streamingDeleteDialogThisDestination")}
|
||||||
</span>
|
</span>
|
||||||
@@ -478,4 +482,4 @@ export default function StreamingDestinationsPage() {
|
|||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -56,18 +56,30 @@ export default async function ClientResourcesPage(
|
|||||||
|
|
||||||
const internalResourceRows: InternalResourceRow[] = siteResources.map(
|
const internalResourceRows: InternalResourceRow[] = siteResources.map(
|
||||||
(siteResource) => {
|
(siteResource) => {
|
||||||
|
const rawMode = siteResource.mode as string | undefined;
|
||||||
|
const normalizedMode =
|
||||||
|
rawMode === "https"
|
||||||
|
? ("http" as const)
|
||||||
|
: rawMode === "host" || rawMode === "cidr" || rawMode === "http"
|
||||||
|
? rawMode
|
||||||
|
: ("host" as const);
|
||||||
return {
|
return {
|
||||||
id: siteResource.siteResourceId,
|
id: siteResource.siteResourceId,
|
||||||
name: siteResource.name,
|
name: siteResource.name,
|
||||||
orgId: params.orgId,
|
orgId: params.orgId,
|
||||||
siteName: siteResource.siteName,
|
siteName: siteResource.siteName,
|
||||||
siteAddress: siteResource.siteAddress || null,
|
siteAddress: siteResource.siteAddress || null,
|
||||||
mode: siteResource.mode || ("port" as any),
|
mode: normalizedMode,
|
||||||
|
scheme:
|
||||||
|
siteResource.scheme ??
|
||||||
|
(rawMode === "https" ? ("https" as const) : null),
|
||||||
|
ssl:
|
||||||
|
siteResource.ssl === true || rawMode === "https",
|
||||||
// protocol: siteResource.protocol,
|
// protocol: siteResource.protocol,
|
||||||
// proxyPort: siteResource.proxyPort,
|
// proxyPort: siteResource.proxyPort,
|
||||||
siteId: siteResource.siteId,
|
siteId: siteResource.siteId,
|
||||||
destination: siteResource.destination,
|
destination: siteResource.destination,
|
||||||
// destinationPort: siteResource.destinationPort,
|
httpHttpsPort: siteResource.destinationPort ?? null,
|
||||||
alias: siteResource.alias || null,
|
alias: siteResource.alias || null,
|
||||||
aliasAddress: siteResource.aliasAddress || null,
|
aliasAddress: siteResource.aliasAddress || null,
|
||||||
siteNiceId: siteResource.siteNiceId,
|
siteNiceId: siteResource.siteNiceId,
|
||||||
|
|||||||
@@ -400,7 +400,11 @@ function ProxyResourceTargetsForm({
|
|||||||
pathMatchType: row.original.pathMatchType
|
pathMatchType: row.original.pathMatchType
|
||||||
}}
|
}}
|
||||||
onChange={(config) =>
|
onChange={(config) =>
|
||||||
updateTarget(row.original.targetId, config)
|
updateTarget(row.original.targetId,
|
||||||
|
config.path === null && config.pathMatchType === null
|
||||||
|
? { ...config, rewritePath: null, rewritePathType: null }
|
||||||
|
: config
|
||||||
|
)
|
||||||
}
|
}
|
||||||
trigger={
|
trigger={
|
||||||
<Button
|
<Button
|
||||||
@@ -424,7 +428,11 @@ function ProxyResourceTargetsForm({
|
|||||||
pathMatchType: row.original.pathMatchType
|
pathMatchType: row.original.pathMatchType
|
||||||
}}
|
}}
|
||||||
onChange={(config) =>
|
onChange={(config) =>
|
||||||
updateTarget(row.original.targetId, config)
|
updateTarget(row.original.targetId,
|
||||||
|
config.path === null && config.pathMatchType === null
|
||||||
|
? { ...config, rewritePath: null, rewritePathType: null }
|
||||||
|
: config
|
||||||
|
)
|
||||||
}
|
}
|
||||||
trigger={
|
trigger={
|
||||||
<Button
|
<Button
|
||||||
@@ -774,8 +782,12 @@ function ProxyResourceTargetsForm({
|
|||||||
}
|
}
|
||||||
|
|
||||||
toast({
|
toast({
|
||||||
title: t("settingsUpdated"),
|
title: targets.length === 0
|
||||||
description: t("settingsUpdatedDescription")
|
? t("targetTargetsCleared")
|
||||||
|
: t("settingsUpdated"),
|
||||||
|
description: targets.length === 0
|
||||||
|
? t("targetTargetsClearedDescription")
|
||||||
|
: t("settingsUpdatedDescription")
|
||||||
});
|
});
|
||||||
|
|
||||||
setTargetsToRemove([]);
|
setTargetsToRemove([]);
|
||||||
|
|||||||
@@ -776,7 +776,11 @@ export default function Page() {
|
|||||||
pathMatchType: row.original.pathMatchType
|
pathMatchType: row.original.pathMatchType
|
||||||
}}
|
}}
|
||||||
onChange={(config) =>
|
onChange={(config) =>
|
||||||
updateTarget(row.original.targetId, config)
|
updateTarget(row.original.targetId,
|
||||||
|
config.path === null && config.pathMatchType === null
|
||||||
|
? { ...config, rewritePath: null, rewritePathType: null }
|
||||||
|
: config
|
||||||
|
)
|
||||||
}
|
}
|
||||||
trigger={
|
trigger={
|
||||||
<Button
|
<Button
|
||||||
@@ -800,7 +804,11 @@ export default function Page() {
|
|||||||
pathMatchType: row.original.pathMatchType
|
pathMatchType: row.original.pathMatchType
|
||||||
}}
|
}}
|
||||||
onChange={(config) =>
|
onChange={(config) =>
|
||||||
updateTarget(row.original.targetId, config)
|
updateTarget(row.original.targetId,
|
||||||
|
config.path === null && config.pathMatchType === null
|
||||||
|
? { ...config, rewritePath: null, rewritePathType: null }
|
||||||
|
: config
|
||||||
|
)
|
||||||
}
|
}
|
||||||
trigger={
|
trigger={
|
||||||
<Button
|
<Button
|
||||||
|
|||||||
32
src/app/private-maintenance-screen/page.tsx
Normal file
32
src/app/private-maintenance-screen/page.tsx
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
import { Metadata } from "next";
|
||||||
|
import { getTranslations } from "next-intl/server";
|
||||||
|
import {
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
CardHeader,
|
||||||
|
CardTitle
|
||||||
|
} from "@app/components/ui/card";
|
||||||
|
|
||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
|
export const metadata: Metadata = {
|
||||||
|
title: "Private Placeholder"
|
||||||
|
};
|
||||||
|
|
||||||
|
export default async function MaintenanceScreen() {
|
||||||
|
const t = await getTranslations();
|
||||||
|
|
||||||
|
let title = t("privateMaintenanceScreenTitle");
|
||||||
|
let message = t("privateMaintenanceScreenMessage");
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen flex items-center justify-center p-4">
|
||||||
|
<Card className="w-full max-w-md">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>{title}</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">{message}</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -46,13 +46,15 @@ export type InternalResourceRow = {
|
|||||||
siteName: string;
|
siteName: string;
|
||||||
siteAddress: string | null;
|
siteAddress: string | null;
|
||||||
// mode: "host" | "cidr" | "port";
|
// mode: "host" | "cidr" | "port";
|
||||||
mode: "host" | "cidr";
|
mode: "host" | "cidr" | "http";
|
||||||
|
scheme: "http" | "https" | null;
|
||||||
|
ssl: boolean;
|
||||||
// protocol: string | null;
|
// protocol: string | null;
|
||||||
// proxyPort: number | null;
|
// proxyPort: number | null;
|
||||||
siteId: number;
|
siteId: number;
|
||||||
siteNiceId: string;
|
siteNiceId: string;
|
||||||
destination: string;
|
destination: string;
|
||||||
// destinationPort: number | null;
|
httpHttpsPort: number | null;
|
||||||
alias: string | null;
|
alias: string | null;
|
||||||
aliasAddress: string | null;
|
aliasAddress: string | null;
|
||||||
niceId: string;
|
niceId: string;
|
||||||
@@ -63,6 +65,39 @@ export type InternalResourceRow = {
|
|||||||
authDaemonPort?: number | null;
|
authDaemonPort?: number | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
function resolveHttpHttpsDisplayPort(
|
||||||
|
mode: "http",
|
||||||
|
httpHttpsPort: number | null
|
||||||
|
): number {
|
||||||
|
if (httpHttpsPort != null) {
|
||||||
|
return httpHttpsPort;
|
||||||
|
}
|
||||||
|
return 80;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDestinationDisplay(row: InternalResourceRow): string {
|
||||||
|
const { mode, destination, httpHttpsPort, scheme } = row;
|
||||||
|
if (mode !== "http") {
|
||||||
|
return destination;
|
||||||
|
}
|
||||||
|
const port = resolveHttpHttpsDisplayPort(mode, httpHttpsPort);
|
||||||
|
const downstreamScheme = scheme ?? "http";
|
||||||
|
const hostPart =
|
||||||
|
destination.includes(":") && !destination.startsWith("[")
|
||||||
|
? `[${destination}]`
|
||||||
|
: destination;
|
||||||
|
return `${downstreamScheme}://${hostPart}:${port}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isSafeUrlForLink(href: string): boolean {
|
||||||
|
try {
|
||||||
|
void new URL(href);
|
||||||
|
return true;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
type ClientResourcesTableProps = {
|
type ClientResourcesTableProps = {
|
||||||
internalResources: InternalResourceRow[];
|
internalResources: InternalResourceRow[];
|
||||||
orgId: string;
|
orgId: string;
|
||||||
@@ -215,6 +250,10 @@ export default function ClientResourcesTable({
|
|||||||
{
|
{
|
||||||
value: "cidr",
|
value: "cidr",
|
||||||
label: t("editInternalResourceDialogModeCidr")
|
label: t("editInternalResourceDialogModeCidr")
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: "http",
|
||||||
|
label: t("editInternalResourceDialogModeHttp")
|
||||||
}
|
}
|
||||||
]}
|
]}
|
||||||
selectedValue={searchParams.get("mode") ?? undefined}
|
selectedValue={searchParams.get("mode") ?? undefined}
|
||||||
@@ -227,10 +266,14 @@ export default function ClientResourcesTable({
|
|||||||
),
|
),
|
||||||
cell: ({ row }) => {
|
cell: ({ row }) => {
|
||||||
const resourceRow = row.original;
|
const resourceRow = row.original;
|
||||||
const modeLabels: Record<"host" | "cidr" | "port", string> = {
|
const modeLabels: Record<
|
||||||
|
"host" | "cidr" | "port" | "http",
|
||||||
|
string
|
||||||
|
> = {
|
||||||
host: t("editInternalResourceDialogModeHost"),
|
host: t("editInternalResourceDialogModeHost"),
|
||||||
cidr: t("editInternalResourceDialogModeCidr"),
|
cidr: t("editInternalResourceDialogModeCidr"),
|
||||||
port: t("editInternalResourceDialogModePort")
|
port: t("editInternalResourceDialogModePort"),
|
||||||
|
http: t("editInternalResourceDialogModeHttp")
|
||||||
};
|
};
|
||||||
return <span>{modeLabels[resourceRow.mode]}</span>;
|
return <span>{modeLabels[resourceRow.mode]}</span>;
|
||||||
}
|
}
|
||||||
@@ -243,11 +286,12 @@ export default function ClientResourcesTable({
|
|||||||
),
|
),
|
||||||
cell: ({ row }) => {
|
cell: ({ row }) => {
|
||||||
const resourceRow = row.original;
|
const resourceRow = row.original;
|
||||||
|
const display = formatDestinationDisplay(resourceRow);
|
||||||
return (
|
return (
|
||||||
<CopyToClipboard
|
<CopyToClipboard
|
||||||
text={resourceRow.destination}
|
text={display}
|
||||||
isLink={false}
|
isLink={false}
|
||||||
displayText={resourceRow.destination}
|
displayText={display}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -260,15 +304,26 @@ export default function ClientResourcesTable({
|
|||||||
),
|
),
|
||||||
cell: ({ row }) => {
|
cell: ({ row }) => {
|
||||||
const resourceRow = row.original;
|
const resourceRow = row.original;
|
||||||
return resourceRow.mode === "host" && resourceRow.alias ? (
|
if (resourceRow.mode === "host" && resourceRow.alias) {
|
||||||
<CopyToClipboard
|
return (
|
||||||
text={resourceRow.alias}
|
<CopyToClipboard
|
||||||
isLink={false}
|
text={resourceRow.alias}
|
||||||
displayText={resourceRow.alias}
|
isLink={false}
|
||||||
/>
|
displayText={resourceRow.alias}
|
||||||
) : (
|
/>
|
||||||
<span>-</span>
|
);
|
||||||
);
|
}
|
||||||
|
if (resourceRow.mode === "http" && resourceRow.alias) {
|
||||||
|
const url = `${resourceRow.ssl ? "https" : "http"}://${resourceRow.alias}`;
|
||||||
|
return (
|
||||||
|
<CopyToClipboard
|
||||||
|
text={url}
|
||||||
|
isLink={isSafeUrlForLink(url)}
|
||||||
|
displayText={url}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return <span>-</span>;
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -50,7 +50,10 @@ export default function CreateInternalResourceDialog({
|
|||||||
setIsSubmitting(true);
|
setIsSubmitting(true);
|
||||||
try {
|
try {
|
||||||
let data = { ...values };
|
let data = { ...values };
|
||||||
if (data.mode === "host" && isHostname(data.destination)) {
|
if (
|
||||||
|
(data.mode === "host" || data.mode === "http") &&
|
||||||
|
isHostname(data.destination)
|
||||||
|
) {
|
||||||
const currentAlias = data.alias?.trim() || "";
|
const currentAlias = data.alias?.trim() || "";
|
||||||
if (!currentAlias) {
|
if (!currentAlias) {
|
||||||
let aliasValue = data.destination;
|
let aliasValue = data.destination;
|
||||||
@@ -69,21 +72,42 @@ export default function CreateInternalResourceDialog({
|
|||||||
mode: data.mode,
|
mode: data.mode,
|
||||||
destination: data.destination,
|
destination: data.destination,
|
||||||
enabled: true,
|
enabled: true,
|
||||||
alias: data.alias && typeof data.alias === "string" && data.alias.trim() ? data.alias : undefined,
|
...(data.mode === "http" && {
|
||||||
|
scheme: data.scheme,
|
||||||
|
ssl: data.ssl ?? false,
|
||||||
|
destinationPort: data.httpHttpsPort ?? undefined
|
||||||
|
}),
|
||||||
|
alias:
|
||||||
|
data.alias &&
|
||||||
|
typeof data.alias === "string" &&
|
||||||
|
data.alias.trim()
|
||||||
|
? data.alias
|
||||||
|
: undefined,
|
||||||
tcpPortRangeString: data.tcpPortRangeString,
|
tcpPortRangeString: data.tcpPortRangeString,
|
||||||
udpPortRangeString: data.udpPortRangeString,
|
udpPortRangeString: data.udpPortRangeString,
|
||||||
disableIcmp: data.disableIcmp ?? false,
|
disableIcmp: data.disableIcmp ?? false,
|
||||||
...(data.authDaemonMode != null && { authDaemonMode: data.authDaemonMode }),
|
...(data.authDaemonMode != null && {
|
||||||
...(data.authDaemonMode === "remote" && data.authDaemonPort != null && { authDaemonPort: data.authDaemonPort }),
|
authDaemonMode: data.authDaemonMode
|
||||||
roleIds: data.roles ? data.roles.map((r) => parseInt(r.id)) : [],
|
}),
|
||||||
|
...(data.authDaemonMode === "remote" &&
|
||||||
|
data.authDaemonPort != null && {
|
||||||
|
authDaemonPort: data.authDaemonPort
|
||||||
|
}),
|
||||||
|
roleIds: data.roles
|
||||||
|
? data.roles.map((r) => parseInt(r.id))
|
||||||
|
: [],
|
||||||
userIds: data.users ? data.users.map((u) => u.id) : [],
|
userIds: data.users ? data.users.map((u) => u.id) : [],
|
||||||
clientIds: data.clients ? data.clients.map((c) => parseInt(c.id)) : []
|
clientIds: data.clients
|
||||||
|
? data.clients.map((c) => parseInt(c.id))
|
||||||
|
: []
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
toast({
|
toast({
|
||||||
title: t("createInternalResourceDialogSuccess"),
|
title: t("createInternalResourceDialogSuccess"),
|
||||||
description: t("createInternalResourceDialogInternalResourceCreatedSuccessfully"),
|
description: t(
|
||||||
|
"createInternalResourceDialogInternalResourceCreatedSuccessfully"
|
||||||
|
),
|
||||||
variant: "default"
|
variant: "default"
|
||||||
});
|
});
|
||||||
setOpen(false);
|
setOpen(false);
|
||||||
@@ -93,7 +117,9 @@ export default function CreateInternalResourceDialog({
|
|||||||
title: t("createInternalResourceDialogError"),
|
title: t("createInternalResourceDialogError"),
|
||||||
description: formatAxiosError(
|
description: formatAxiosError(
|
||||||
error,
|
error,
|
||||||
t("createInternalResourceDialogFailedToCreateInternalResource")
|
t(
|
||||||
|
"createInternalResourceDialogFailedToCreateInternalResource"
|
||||||
|
)
|
||||||
),
|
),
|
||||||
variant: "destructive"
|
variant: "destructive"
|
||||||
});
|
});
|
||||||
@@ -106,9 +132,13 @@ export default function CreateInternalResourceDialog({
|
|||||||
<Credenza open={open} onOpenChange={setOpen}>
|
<Credenza open={open} onOpenChange={setOpen}>
|
||||||
<CredenzaContent className="max-w-3xl">
|
<CredenzaContent className="max-w-3xl">
|
||||||
<CredenzaHeader>
|
<CredenzaHeader>
|
||||||
<CredenzaTitle>{t("createInternalResourceDialogCreateClientResource")}</CredenzaTitle>
|
<CredenzaTitle>
|
||||||
|
{t("createInternalResourceDialogCreateClientResource")}
|
||||||
|
</CredenzaTitle>
|
||||||
<CredenzaDescription>
|
<CredenzaDescription>
|
||||||
{t("createInternalResourceDialogCreateClientResourceDescription")}
|
{t(
|
||||||
|
"createInternalResourceDialogCreateClientResourceDescription"
|
||||||
|
)}
|
||||||
</CredenzaDescription>
|
</CredenzaDescription>
|
||||||
</CredenzaHeader>
|
</CredenzaHeader>
|
||||||
<CredenzaBody>
|
<CredenzaBody>
|
||||||
@@ -123,7 +153,11 @@ export default function CreateInternalResourceDialog({
|
|||||||
</CredenzaBody>
|
</CredenzaBody>
|
||||||
<CredenzaFooter>
|
<CredenzaFooter>
|
||||||
<CredenzaClose asChild>
|
<CredenzaClose asChild>
|
||||||
<Button variant="outline" onClick={() => setOpen(false)} disabled={isSubmitting}>
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => setOpen(false)}
|
||||||
|
disabled={isSubmitting}
|
||||||
|
>
|
||||||
{t("createInternalResourceDialogCancel")}
|
{t("createInternalResourceDialogCancel")}
|
||||||
</Button>
|
</Button>
|
||||||
</CredenzaClose>
|
</CredenzaClose>
|
||||||
|
|||||||
@@ -163,15 +163,18 @@ export default function DomainPicker({
|
|||||||
domainId: firstOrExistingDomain.domainId
|
domainId: firstOrExistingDomain.domainId
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const base = firstOrExistingDomain.baseDomain;
|
||||||
|
const sub =
|
||||||
|
firstOrExistingDomain.type !== "cname"
|
||||||
|
? defaultSubdomain?.trim() || undefined
|
||||||
|
: undefined;
|
||||||
|
|
||||||
onDomainChange?.({
|
onDomainChange?.({
|
||||||
domainId: firstOrExistingDomain.domainId,
|
domainId: firstOrExistingDomain.domainId,
|
||||||
type: "organization",
|
type: "organization",
|
||||||
subdomain:
|
subdomain: sub,
|
||||||
firstOrExistingDomain.type !== "cname"
|
fullDomain: sub ? `${sub}.${base}` : base,
|
||||||
? defaultSubdomain || undefined
|
baseDomain: base
|
||||||
: undefined,
|
|
||||||
fullDomain: firstOrExistingDomain.baseDomain,
|
|
||||||
baseDomain: firstOrExistingDomain.baseDomain
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -509,9 +512,11 @@ export default function DomainPicker({
|
|||||||
<span className="truncate">
|
<span className="truncate">
|
||||||
{selectedBaseDomain.domain}
|
{selectedBaseDomain.domain}
|
||||||
</span>
|
</span>
|
||||||
{selectedBaseDomain.verified && (
|
{selectedBaseDomain.verified &&
|
||||||
<CheckCircle2 className="h-3 w-3 text-green-500 shrink-0" />
|
selectedBaseDomain.domainType !==
|
||||||
)}
|
"wildcard" && (
|
||||||
|
<CheckCircle2 className="h-3 w-3 text-green-500 shrink-0" />
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
t("domainPickerSelectBaseDomain")
|
t("domainPickerSelectBaseDomain")
|
||||||
@@ -574,14 +579,23 @@ export default function DomainPicker({
|
|||||||
}
|
}
|
||||||
</span>
|
</span>
|
||||||
<span className="text-xs text-muted-foreground">
|
<span className="text-xs text-muted-foreground">
|
||||||
{orgDomain.type.toUpperCase()}{" "}
|
{orgDomain.type ===
|
||||||
•{" "}
|
"wildcard"
|
||||||
{orgDomain.verified
|
|
||||||
? t(
|
? t(
|
||||||
"domainPickerVerified"
|
"domainPickerManual"
|
||||||
)
|
)
|
||||||
: t(
|
: (
|
||||||
"domainPickerUnverified"
|
<>
|
||||||
|
{orgDomain.type.toUpperCase()}{" "}
|
||||||
|
•{" "}
|
||||||
|
{orgDomain.verified
|
||||||
|
? t(
|
||||||
|
"domainPickerVerified"
|
||||||
|
)
|
||||||
|
: t(
|
||||||
|
"domainPickerUnverified"
|
||||||
|
)}
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -54,7 +54,10 @@ export default function EditInternalResourceDialog({
|
|||||||
async function handleSubmit(values: InternalResourceFormValues) {
|
async function handleSubmit(values: InternalResourceFormValues) {
|
||||||
try {
|
try {
|
||||||
let data = { ...values };
|
let data = { ...values };
|
||||||
if (data.mode === "host" && isHostname(data.destination)) {
|
if (
|
||||||
|
(data.mode === "host" || data.mode === "http") &&
|
||||||
|
isHostname(data.destination)
|
||||||
|
) {
|
||||||
const currentAlias = data.alias?.trim() || "";
|
const currentAlias = data.alias?.trim() || "";
|
||||||
if (!currentAlias) {
|
if (!currentAlias) {
|
||||||
let aliasValue = data.destination;
|
let aliasValue = data.destination;
|
||||||
@@ -71,6 +74,11 @@ export default function EditInternalResourceDialog({
|
|||||||
mode: data.mode,
|
mode: data.mode,
|
||||||
niceId: data.niceId,
|
niceId: data.niceId,
|
||||||
destination: data.destination,
|
destination: data.destination,
|
||||||
|
...(data.mode === "http" && {
|
||||||
|
scheme: data.scheme,
|
||||||
|
ssl: data.ssl ?? false,
|
||||||
|
destinationPort: data.httpHttpsPort ?? null
|
||||||
|
}),
|
||||||
alias:
|
alias:
|
||||||
data.alias &&
|
data.alias &&
|
||||||
typeof data.alias === "string" &&
|
typeof data.alias === "string" &&
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -10,14 +10,14 @@ import {
|
|||||||
import { CheckboxWithLabel } from "./ui/checkbox";
|
import { CheckboxWithLabel } from "./ui/checkbox";
|
||||||
import { OptionSelect, type OptionSelectOption } from "./OptionSelect";
|
import { OptionSelect, type OptionSelectOption } from "./OptionSelect";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { FaCubes, FaDocker, FaWindows } from "react-icons/fa";
|
import { FaApple, FaCubes, FaDocker, FaLinux, FaWindows } from "react-icons/fa";
|
||||||
import { Terminal } from "lucide-react";
|
|
||||||
import { SiKubernetes, SiNixos } from "react-icons/si";
|
import { SiKubernetes, SiNixos } from "react-icons/si";
|
||||||
|
|
||||||
export type CommandItem = string | { title: string; command: string };
|
export type CommandItem = string | { title: string; command: string };
|
||||||
|
|
||||||
const PLATFORMS = [
|
const PLATFORMS = [
|
||||||
"unix",
|
"linux",
|
||||||
|
"macos",
|
||||||
"docker",
|
"docker",
|
||||||
"kubernetes",
|
"kubernetes",
|
||||||
"podman",
|
"podman",
|
||||||
@@ -43,7 +43,7 @@ export function NewtSiteInstallCommands({
|
|||||||
const t = useTranslations();
|
const t = useTranslations();
|
||||||
|
|
||||||
const [acceptClients, setAcceptClients] = useState(true);
|
const [acceptClients, setAcceptClients] = useState(true);
|
||||||
const [platform, setPlatform] = useState<Platform>("unix");
|
const [platform, setPlatform] = useState<Platform>("linux");
|
||||||
const [architecture, setArchitecture] = useState(
|
const [architecture, setArchitecture] = useState(
|
||||||
() => getArchitectures(platform)[0]
|
() => getArchitectures(platform)[0]
|
||||||
);
|
);
|
||||||
@@ -54,8 +54,68 @@ export function NewtSiteInstallCommands({
|
|||||||
: "";
|
: "";
|
||||||
|
|
||||||
const commandList: Record<Platform, Record<string, CommandItem[]>> = {
|
const commandList: Record<Platform, Record<string, CommandItem[]>> = {
|
||||||
unix: {
|
linux: {
|
||||||
All: [
|
Run: [
|
||||||
|
{
|
||||||
|
title: t("install"),
|
||||||
|
command: `curl -fsSL https://static.pangolin.net/get-newt.sh | bash`
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: t("run"),
|
||||||
|
command: `newt --id ${id} --secret ${secret} --endpoint ${endpoint}${acceptClientsFlag}`
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"Systemd Service": [
|
||||||
|
{
|
||||||
|
title: t("install"),
|
||||||
|
command: `curl -fsSL https://static.pangolin.net/get-newt.sh | bash`
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: t("envFile"),
|
||||||
|
command: `# Create the directory and environment file
|
||||||
|
sudo install -d -m 0755 /etc/newt
|
||||||
|
sudo tee /etc/newt/newt.env > /dev/null << 'EOF'
|
||||||
|
NEWT_ID=${id}
|
||||||
|
NEWT_SECRET=${secret}
|
||||||
|
PANGOLIN_ENDPOINT=${endpoint}${!acceptClients ? `
|
||||||
|
DISABLE_CLIENTS=true` : ""}
|
||||||
|
EOF
|
||||||
|
sudo chmod 600 /etc/newt/newt.env`
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: t("serviceFile"),
|
||||||
|
command: `sudo tee /etc/systemd/system/newt.service > /dev/null << 'EOF'
|
||||||
|
[Unit]
|
||||||
|
Description=Newt
|
||||||
|
Wants=network-online.target
|
||||||
|
After=network-online.target
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=simple
|
||||||
|
User=root
|
||||||
|
Group=root
|
||||||
|
EnvironmentFile=/etc/newt/newt.env
|
||||||
|
ExecStart=/usr/local/bin/newt
|
||||||
|
Restart=always
|
||||||
|
RestartSec=2
|
||||||
|
UMask=0077
|
||||||
|
|
||||||
|
NoNewPrivileges=true
|
||||||
|
PrivateTmp=true
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=multi-user.target
|
||||||
|
EOF`
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: t("enableAndStart"),
|
||||||
|
command: `sudo systemctl daemon-reload
|
||||||
|
sudo systemctl enable --now newt`
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
macos: {
|
||||||
|
Run: [
|
||||||
{
|
{
|
||||||
title: t("install"),
|
title: t("install"),
|
||||||
command: `curl -fsSL https://static.pangolin.net/get-newt.sh | bash`
|
command: `curl -fsSL https://static.pangolin.net/get-newt.sh | bash`
|
||||||
@@ -131,7 +191,7 @@ WantedBy=default.target`
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
nixos: {
|
nixos: {
|
||||||
All: [
|
Flake: [
|
||||||
`nix run 'nixpkgs#fosrl-newt' -- --id ${id} --secret ${secret} --endpoint ${endpoint}${acceptClientsFlag}`
|
`nix run 'nixpkgs#fosrl-newt' -- --id ${id} --secret ${secret} --endpoint ${endpoint}${acceptClientsFlag}`
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -172,9 +232,9 @@ WantedBy=default.target`
|
|||||||
|
|
||||||
<OptionSelect<string>
|
<OptionSelect<string>
|
||||||
label={
|
label={
|
||||||
["docker", "podman"].includes(platform)
|
platform === "windows"
|
||||||
? t("method")
|
? t("architecture")
|
||||||
: t("architecture")
|
: t("method")
|
||||||
}
|
}
|
||||||
options={getArchitectures(platform).map((arch) => ({
|
options={getArchitectures(platform).map((arch) => ({
|
||||||
value: arch,
|
value: arch,
|
||||||
@@ -261,8 +321,10 @@ function getPlatformIcon(platformName: Platform) {
|
|||||||
switch (platformName) {
|
switch (platformName) {
|
||||||
case "windows":
|
case "windows":
|
||||||
return <FaWindows className="h-4 w-4 mr-2" />;
|
return <FaWindows className="h-4 w-4 mr-2" />;
|
||||||
case "unix":
|
case "linux":
|
||||||
return <Terminal className="h-4 w-4 mr-2" />;
|
return <FaLinux className="h-4 w-4 mr-2" />;
|
||||||
|
case "macos":
|
||||||
|
return <FaApple className="h-4 w-4 mr-2" />;
|
||||||
case "docker":
|
case "docker":
|
||||||
return <FaDocker className="h-4 w-4 mr-2" />;
|
return <FaDocker className="h-4 w-4 mr-2" />;
|
||||||
case "kubernetes":
|
case "kubernetes":
|
||||||
@@ -272,7 +334,7 @@ function getPlatformIcon(platformName: Platform) {
|
|||||||
case "nixos":
|
case "nixos":
|
||||||
return <SiNixos className="h-4 w-4 mr-2" />;
|
return <SiNixos className="h-4 w-4 mr-2" />;
|
||||||
default:
|
default:
|
||||||
return <Terminal className="h-4 w-4 mr-2" />;
|
return <FaLinux className="h-4 w-4 mr-2" />;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -280,8 +342,10 @@ function getPlatformName(platformName: Platform) {
|
|||||||
switch (platformName) {
|
switch (platformName) {
|
||||||
case "windows":
|
case "windows":
|
||||||
return "Windows";
|
return "Windows";
|
||||||
case "unix":
|
case "linux":
|
||||||
return "Unix & macOS";
|
return "Linux";
|
||||||
|
case "macos":
|
||||||
|
return "macOS";
|
||||||
case "docker":
|
case "docker":
|
||||||
return "Docker";
|
return "Docker";
|
||||||
case "kubernetes":
|
case "kubernetes":
|
||||||
@@ -291,14 +355,16 @@ function getPlatformName(platformName: Platform) {
|
|||||||
case "nixos":
|
case "nixos":
|
||||||
return "NixOS";
|
return "NixOS";
|
||||||
default:
|
default:
|
||||||
return "Unix / macOS";
|
return "Linux";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function getArchitectures(platform: Platform) {
|
function getArchitectures(platform: Platform) {
|
||||||
switch (platform) {
|
switch (platform) {
|
||||||
case "unix":
|
case "linux":
|
||||||
return ["All"];
|
return ["Run", "Systemd Service"];
|
||||||
|
case "macos":
|
||||||
|
return ["Run"];
|
||||||
case "windows":
|
case "windows":
|
||||||
return ["x64"];
|
return ["x64"];
|
||||||
case "docker":
|
case "docker":
|
||||||
@@ -308,8 +374,8 @@ function getArchitectures(platform: Platform) {
|
|||||||
case "podman":
|
case "podman":
|
||||||
return ["Podman Quadlet", "Podman Run"];
|
return ["Podman Quadlet", "Podman Run"];
|
||||||
case "nixos":
|
case "nixos":
|
||||||
return ["All"];
|
return ["Flake"];
|
||||||
default:
|
default:
|
||||||
return ["x64"];
|
return ["Run"];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,12 +22,21 @@ export async function getUserLocale(): Promise<Locale> {
|
|||||||
const res = await internal.get("/user", await authCookieHeader());
|
const res = await internal.get("/user", await authCookieHeader());
|
||||||
const userLocale = res.data?.data?.locale;
|
const userLocale = res.data?.data?.locale;
|
||||||
if (userLocale && locales.includes(userLocale as Locale)) {
|
if (userLocale && locales.includes(userLocale as Locale)) {
|
||||||
// Set the cookie so subsequent requests don't need the API call
|
// Try to cache in a cookie so subsequent requests skip the API
|
||||||
(await cookies()).set(COOKIE_NAME, userLocale, {
|
// call. cookies().set() is only permitted in Server Actions and
|
||||||
maxAge: COOKIE_MAX_AGE,
|
// Route Handlers — not during rendering — so we isolate it so
|
||||||
path: "/",
|
// that a write failure doesn't prevent the locale from being
|
||||||
sameSite: "lax"
|
// returned for the current request.
|
||||||
});
|
try {
|
||||||
|
(await cookies()).set(COOKIE_NAME, userLocale, {
|
||||||
|
maxAge: COOKIE_MAX_AGE,
|
||||||
|
path: "/",
|
||||||
|
sameSite: "lax"
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
// Cannot set cookies in this context (e.g. during rendering);
|
||||||
|
// the correct locale is still returned below.
|
||||||
|
}
|
||||||
return userLocale as Locale;
|
return userLocale as Locale;
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
|
|||||||
Reference in New Issue
Block a user