Compare commits

..

54 Commits

Author SHA1 Message Date
Owen
9e50569c31 Merge branch 'private-http' of github.com:fosrl/pangolin into private-http 2026-04-10 17:23:06 -04:00
Owen
a19f0acfb9 Working 2026-04-10 17:21:54 -04:00
miloschwartz
8a47d69d0d fix domain picker 2026-04-09 22:48:43 -04:00
miloschwartz
73482c2a05 disable ssh access tab on http mode 2026-04-09 22:38:04 -04:00
miloschwartz
79751c208d basic ui working 2026-04-09 22:24:39 -04:00
Owen
510931e7d6 Add ssl to schema 2026-04-09 21:02:20 -04:00
Owen
584a8e7d1d Generate certs and add placeholder screen 2026-04-09 20:53:03 -04:00
miloschwartz
a74378e1d3 show domain and destination with port in table 2026-04-09 18:17:08 -04:00
Owen
c027c8958b Add scheme 2026-04-09 17:54:17 -04:00
miloschwartz
a730f4da1d dont show wildcard in domain picker 2026-04-09 17:54:08 -04:00
miloschwartz
d73796b92e add new modes, port input, and domain picker 2026-04-09 17:49:22 -04:00
Owen
e4cbf088b4 Working on defining the schema to send down 2026-04-09 17:23:24 -04:00
Owen
333ccb8438 Restrict to make sure there is an alias 2026-04-09 17:10:48 -04:00
Owen
eb771ceda4 Add http to mode and put destinationPort back 2026-04-09 17:02:08 -04:00
Owen
1efd2af44b Sync acme certs into the database 2026-04-09 15:38:36 -04:00
Owen
466f137590 Fix migration by testing for orphans 2026-04-09 10:29:51 -04:00
Owen
28ef5238c9 Add CODEOWNERS 2026-04-07 11:36:02 -04:00
Owen
d948d2ec33 Try to prevent deadlocks 2026-04-03 22:55:04 -04:00
Owen
6b8a3c8d77 Revert #2570
Fix #2782
2026-04-03 22:37:42 -04:00
Owen
ba9794c067 Put middleware back
Fix #2781
2026-04-03 22:16:26 -04:00
Owen
eb4b2daaab Use the right encryption 2026-04-03 17:59:21 -04:00
Owen
8cbc8dec89 Generate address 2026-04-03 17:25:39 -04:00
Owen
e89e60d50b Encrypt the streaming data 2026-04-03 15:33:29 -04:00
Owen
c45308f234 Send to the right place 2026-04-03 15:33:29 -04:00
Owen Schwartz
40205c40c5 Merge pull request #2779 from fosrl/crowdin_dev
New Crowdin updates
2026-04-03 15:00:11 -04:00
Owen Schwartz
f3fe2dd33b New translations en-us.json (Spanish) 2026-04-03 14:58:56 -04:00
Owen Schwartz
8edcc45033 New translations en-us.json (Norwegian Bokmal) 2026-04-03 14:58:55 -04:00
Owen Schwartz
91471a4aca New translations en-us.json (Chinese Simplified) 2026-04-03 14:58:53 -04:00
Owen Schwartz
ae2c37a2f6 New translations en-us.json (Turkish) 2026-04-03 14:58:52 -04:00
Owen Schwartz
c8208f0a88 New translations en-us.json (Russian) 2026-04-03 14:58:50 -04:00
Owen Schwartz
e11dfbd29c New translations en-us.json (Portuguese) 2026-04-03 14:58:49 -04:00
Owen Schwartz
b375d20598 New translations en-us.json (Polish) 2026-04-03 14:58:48 -04:00
Owen Schwartz
c4b82c69f8 New translations en-us.json (Dutch) 2026-04-03 14:58:47 -04:00
Owen Schwartz
c9a00420a0 New translations en-us.json (Korean) 2026-04-03 14:58:45 -04:00
Owen Schwartz
36ef9cd442 New translations en-us.json (Italian) 2026-04-03 14:58:44 -04:00
Owen Schwartz
5e08779ab0 New translations en-us.json (German) 2026-04-03 14:58:42 -04:00
Owen Schwartz
16a0e1ce7b New translations en-us.json (Czech) 2026-04-03 14:58:41 -04:00
Owen Schwartz
8b03484ade New translations en-us.json (Bulgarian) 2026-04-03 14:58:39 -04:00
Owen Schwartz
9da9974adf New translations en-us.json (French) 2026-04-03 14:58:38 -04:00
Owen Schwartz
6f80cf3db2 New translations en-us.json (Spanish) 2026-04-03 13:03:44 -04:00
Owen Schwartz
76d8f44779 New translations en-us.json (Norwegian Bokmal) 2026-04-03 13:03:43 -04:00
Owen Schwartz
700c92efcb New translations en-us.json (Chinese Simplified) 2026-04-03 13:03:41 -04:00
Owen Schwartz
d17e0c9f50 New translations en-us.json (Turkish) 2026-04-03 13:03:39 -04:00
Owen Schwartz
f00b9794f5 New translations en-us.json (Russian) 2026-04-03 13:03:38 -04:00
Owen Schwartz
daff59c93f New translations en-us.json (Portuguese) 2026-04-03 13:03:36 -04:00
Owen Schwartz
aa8954366c New translations en-us.json (Polish) 2026-04-03 13:03:35 -04:00
Owen Schwartz
87464d53bd New translations en-us.json (Dutch) 2026-04-03 13:03:33 -04:00
Owen Schwartz
e04f17c9aa New translations en-us.json (Korean) 2026-04-03 13:03:32 -04:00
Owen Schwartz
b25e3499d8 New translations en-us.json (Italian) 2026-04-03 13:03:30 -04:00
Owen Schwartz
2e6f74a6f8 New translations en-us.json (German) 2026-04-03 13:03:28 -04:00
Owen Schwartz
8eee0ca5a5 New translations en-us.json (Czech) 2026-04-03 13:03:26 -04:00
Owen Schwartz
c2ebc0a0ff New translations en-us.json (Bulgarian) 2026-04-03 13:03:24 -04:00
Owen Schwartz
03c905a7af New translations en-us.json (French) 2026-04-03 13:03:22 -04:00
Owen
8ce45a1acd Update casting again 2026-04-03 12:34:37 -04:00
51 changed files with 1948 additions and 795 deletions

1
.github/CODEOWNERS vendored Normal file
View File

@@ -0,0 +1 @@
* @oschwartz10612 @miloschwartz

View File

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

View File

@@ -371,10 +371,10 @@
"provisioningKeysUpdated": "Ключът за осигуряване е актуализиран", "provisioningKeysUpdated": "Ключът за осигуряване е актуализиран",
"provisioningKeysUpdatedDescription": "Вашите промени бяха запазени.", "provisioningKeysUpdatedDescription": "Вашите промени бяха запазени.",
"provisioningKeysBannerTitle": "Ключове за осигуряване на сайта", "provisioningKeysBannerTitle": "Ключове за осигуряване на сайта",
"provisioningKeysBannerDescription": "Generate a provisioning key and use it with the Newt connector to automatically create sites on first startup - no need to set up separate credentials for each site.", "provisioningKeysBannerDescription": "Генерирайте ключ за осигуряване и го използвайте със съединителя Newt за автоматично създаване на сайтове при първоначално стартиране - не е необходимо да се създават отделни идентификационни данни за всеки сайт.",
"provisioningKeysBannerButtonText": "Научете повече", "provisioningKeysBannerButtonText": "Научете повече",
"pendingSitesBannerTitle": "Чакащи сайтове", "pendingSitesBannerTitle": "Чакащи сайтове",
"pendingSitesBannerDescription": "Sites that connect using a provisioning key appear here for review.", "pendingSitesBannerDescription": "Сайтовете, които се свързват с ключ за осигуряване, ще се появят тук за преглед.",
"pendingSitesBannerButtonText": "Научете повече", "pendingSitesBannerButtonText": "Научете повече",
"apiKeysSettings": "Настройки на {apiKeyName}", "apiKeysSettings": "Настройки на {apiKeyName}",
"userTitle": "Управление на всички потребители", "userTitle": "Управление на всички потребители",
@@ -624,8 +624,8 @@
"targetErrorInvalidPortDescription": "Моля, въведете валиден номер на порт", "targetErrorInvalidPortDescription": "Моля, въведете валиден номер на порт",
"targetErrorNoSite": "Няма избран сайт", "targetErrorNoSite": "Няма избран сайт",
"targetErrorNoSiteDescription": "Моля, изберете сайт за целта", "targetErrorNoSiteDescription": "Моля, изберете сайт за целта",
"targetTargetsCleared": "Targets cleared", "targetTargetsCleared": "Мишените са премахнати",
"targetTargetsClearedDescription": "All targets have been removed from this resource", "targetTargetsClearedDescription": "Всички цели са били премахнати от този ресурс",
"targetCreated": "Целта е създадена", "targetCreated": "Целта е създадена",
"targetCreatedDescription": "Целта беше успешно създадена", "targetCreatedDescription": "Целта беше успешно създадена",
"targetErrorCreate": "Неуспешно създаване на целта", "targetErrorCreate": "Неуспешно създаване на целта",
@@ -2348,7 +2348,7 @@
"description": "Предприятие, 50 потребители, 50 сайта и приоритетна поддръжка." "description": "Предприятие, 50 потребители, 50 сайта и приоритетна поддръжка."
} }
}, },
"personalUseOnly": "Personal use only (free license - no checkout)", "personalUseOnly": "Само за лична употреба (безплатен лиценз - без проверка)",
"buttons": { "buttons": {
"continueToCheckout": "Продължете към плащане" "continueToCheckout": "Продължете към плащане"
}, },
@@ -2609,9 +2609,9 @@
"machineClients": "Машинни клиенти", "machineClients": "Машинни клиенти",
"install": "Инсталирай", "install": "Инсталирай",
"run": "Изпълни", "run": "Изпълни",
"envFile": "Environment File", "envFile": "Файл за среда",
"serviceFile": "Service File", "serviceFile": "Файл за услуга",
"enableAndStart": "Enable and Start", "enableAndStart": "Активиране и стартиране",
"clientNameDescription": "Показваното име на клиента, което може да се промени по-късно.", "clientNameDescription": "Показваното име на клиента, което може да се промени по-късно.",
"clientAddress": "Клиентски адрес (Разширено)", "clientAddress": "Клиентски адрес (Разширено)",
"setupFailedToFetchSubnet": "Неуспешно извличане на подмрежа по подразбиране", "setupFailedToFetchSubnet": "Неуспешно извличане на подмрежа по подразбиране",
@@ -2850,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).",

View File

@@ -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": "Generate a provisioning key and use it with the Newt connector to automatically create sites on first startup - no need to set up separate credentials for each site.", "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": "Sites that connect using a provisioning key appear here for review.", "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,8 +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": "Targets cleared", "targetTargetsCleared": "Cíle vymazány",
"targetTargetsClearedDescription": "All targets have been removed from this resource", "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",
@@ -2348,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": "Personal use only (free license - no checkout)", "personalUseOnly": "Pouze pro osobní použití (zdarma licence - bez ověření)",
"buttons": { "buttons": {
"continueToCheckout": "Pokračovat do pokladny" "continueToCheckout": "Pokračovat do pokladny"
}, },
@@ -2609,9 +2609,9 @@
"machineClients": "Strojoví klienti", "machineClients": "Strojoví klienti",
"install": "Instalovat", "install": "Instalovat",
"run": "Spustit", "run": "Spustit",
"envFile": "Environment File", "envFile": "Konfigurační soubor prostředí",
"serviceFile": "Service File", "serviceFile": "Služební soubor",
"enableAndStart": "Enable and Start", "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íť",
@@ -2850,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).",

View File

@@ -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": "Generate a provisioning key and use it with the Newt connector to automatically create sites on first startup - no need to set up separate credentials for each site.", "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 that connect using a provisioning key appear here for review.", "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,8 +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": "Targets cleared", "targetTargetsCleared": "Ziele gelöscht",
"targetTargetsClearedDescription": "All targets have been removed from this resource", "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",
@@ -2348,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": "Personal use only (free license - no checkout)", "personalUseOnly": "Nur persönliche Nutzung (kostenlose Lizenz - kein Checkout)",
"buttons": { "buttons": {
"continueToCheckout": "Weiter zur Kasse" "continueToCheckout": "Weiter zur Kasse"
}, },
@@ -2609,9 +2609,9 @@
"machineClients": "Maschinen-Clients", "machineClients": "Maschinen-Clients",
"install": "Installieren", "install": "Installieren",
"run": "Ausführen", "run": "Ausführen",
"envFile": "Environment File", "envFile": "Umgebungsdatei",
"serviceFile": "Service File", "serviceFile": "Servicedatei",
"enableAndStart": "Enable and Start", "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",
@@ -2850,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).",

View File

@@ -1817,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.",
@@ -1860,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.",
@@ -2116,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",
@@ -2659,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",
@@ -2699,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",

View File

@@ -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": "Generate a provisioning key and use it with the Newt connector to automatically create sites on first startup - no need to set up separate credentials for each site.", "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": "Sites that connect using a provisioning key appear here for review.", "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,8 +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": "Targets cleared", "targetTargetsCleared": "Objetivos eliminados",
"targetTargetsClearedDescription": "All targets have been removed from this resource", "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",
@@ -2348,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": "Personal use only (free license - no checkout)", "personalUseOnly": "Solo uso personal (licencia gratuita - sin salida)",
"buttons": { "buttons": {
"continueToCheckout": "Continuar con el pago" "continueToCheckout": "Continuar con el pago"
}, },
@@ -2609,9 +2609,9 @@
"machineClients": "Clientes de la máquina", "machineClients": "Clientes de la máquina",
"install": "Instalar", "install": "Instalar",
"run": "Ejecutar", "run": "Ejecutar",
"envFile": "Environment File", "envFile": "Archivo de Entorno",
"serviceFile": "Service File", "serviceFile": "Archivo de Servicio",
"enableAndStart": "Enable and Start", "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",
@@ -2850,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).",

View File

@@ -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": "Generate a provisioning key and use it with the Newt connector to automatically create sites on first startup - no need to set up separate credentials for each 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": "Sites that connect using a provisioning key appear here for review.", "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,8 +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": "Targets cleared", "targetTargetsCleared": "Cibles effacées",
"targetTargetsClearedDescription": "All targets have been removed from this resource", "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",
@@ -2348,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": "Personal use only (free license - no checkout)", "personalUseOnly": "Usage personnel uniquement (licence gratuite - pas de validation)",
"buttons": { "buttons": {
"continueToCheckout": "Continuer vers le paiement" "continueToCheckout": "Continuer vers le paiement"
}, },
@@ -2609,9 +2609,9 @@
"machineClients": "Clients Machines", "machineClients": "Clients Machines",
"install": "Installer", "install": "Installer",
"run": "Exécuter", "run": "Exécuter",
"envFile": "Environment File", "envFile": "Fichier Environnement",
"serviceFile": "Service File", "serviceFile": "Fichier de Service",
"enableAndStart": "Enable and Start", "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",
@@ -2853,7 +2853,7 @@
"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).",

View File

@@ -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": "Generate a provisioning key and use it with the Newt connector to automatically create sites on first startup - no need to set up separate credentials for each site.", "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": "Sites that connect using a provisioning key appear here for review.", "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,8 +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": "Targets cleared", "targetTargetsCleared": "Obiettivi cancellati",
"targetTargetsClearedDescription": "All targets have been removed from this resource", "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",
@@ -2348,7 +2348,7 @@
"description": "Funzionalità aziendali, 50 utenti, 50 siti e supporto prioritario." "description": "Funzionalità aziendali, 50 utenti, 50 siti e supporto prioritario."
} }
}, },
"personalUseOnly": "Personal use only (free license - no checkout)", "personalUseOnly": "Uso personale esclusivo (licenza gratuita - nessun pagamento)",
"buttons": { "buttons": {
"continueToCheckout": "Continua al Checkout" "continueToCheckout": "Continua al Checkout"
}, },
@@ -2609,9 +2609,9 @@
"machineClients": "Machine Clients", "machineClients": "Machine Clients",
"install": "Installa", "install": "Installa",
"run": "Esegui", "run": "Esegui",
"envFile": "Environment File", "envFile": "File di ambiente",
"serviceFile": "Service File", "serviceFile": "File di servizio",
"enableAndStart": "Enable and Start", "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",
@@ -2850,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).",

View File

@@ -371,10 +371,10 @@
"provisioningKeysUpdated": "프로비저닝 키가 업데이트되었습니다", "provisioningKeysUpdated": "프로비저닝 키가 업데이트되었습니다",
"provisioningKeysUpdatedDescription": "변경 사항이 저장되었습니다.", "provisioningKeysUpdatedDescription": "변경 사항이 저장되었습니다.",
"provisioningKeysBannerTitle": "사이트 프로비저닝 키", "provisioningKeysBannerTitle": "사이트 프로비저닝 키",
"provisioningKeysBannerDescription": "Generate a provisioning key and use it with the Newt connector to automatically create sites on first startup - no need to set up separate credentials for each site.", "provisioningKeysBannerDescription": "프로비저닝 키를 생성하고 Newt 커넥터와 함께 사용하여 첫 시작 시 사이트를 자동 생성 - 각 사이트에 대한 별도 자격 증명이 필요 없습니다.",
"provisioningKeysBannerButtonText": "자세히 알아보기", "provisioningKeysBannerButtonText": "자세히 알아보기",
"pendingSitesBannerTitle": "대기중인 사이트", "pendingSitesBannerTitle": "대기중인 사이트",
"pendingSitesBannerDescription": "Sites that connect using a provisioning key appear here for review.", "pendingSitesBannerDescription": "프로비저닝 키를 사용하여 연결된 사이트가 검토를 위해 여기에 표시됩니다.",
"pendingSitesBannerButtonText": "자세히 알아보기", "pendingSitesBannerButtonText": "자세히 알아보기",
"apiKeysSettings": "{apiKeyName} 설정", "apiKeysSettings": "{apiKeyName} 설정",
"userTitle": "모든 사용자 관리", "userTitle": "모든 사용자 관리",
@@ -624,8 +624,8 @@
"targetErrorInvalidPortDescription": "유효한 포트 번호를 입력하세요.", "targetErrorInvalidPortDescription": "유효한 포트 번호를 입력하세요.",
"targetErrorNoSite": "선택된 사이트 없음", "targetErrorNoSite": "선택된 사이트 없음",
"targetErrorNoSiteDescription": "대상을 위해 사이트를 선택하세요.", "targetErrorNoSiteDescription": "대상을 위해 사이트를 선택하세요.",
"targetTargetsCleared": "Targets cleared", "targetTargetsCleared": "대상이 제거됨",
"targetTargetsClearedDescription": "All targets have been removed from this resource", "targetTargetsClearedDescription": "이 리소스에서 모든 대상이 제거되었습니다",
"targetCreated": "대상 생성", "targetCreated": "대상 생성",
"targetCreatedDescription": "대상이 성공적으로 생성되었습니다.", "targetCreatedDescription": "대상이 성공적으로 생성되었습니다.",
"targetErrorCreate": "대상 생성 실패", "targetErrorCreate": "대상 생성 실패",
@@ -2348,7 +2348,7 @@
"description": "기업 기능, 50명의 사용자, 50개의 사이트, 우선 지원." "description": "기업 기능, 50명의 사용자, 50개의 사이트, 우선 지원."
} }
}, },
"personalUseOnly": "Personal use only (free license - no checkout)", "personalUseOnly": "개인용으로만 사용 (무료 라이선스 - 결제 없음)",
"buttons": { "buttons": {
"continueToCheckout": "결제로 진행" "continueToCheckout": "결제로 진행"
}, },
@@ -2609,9 +2609,9 @@
"machineClients": "기계 클라이언트", "machineClients": "기계 클라이언트",
"install": "설치", "install": "설치",
"run": "실행", "run": "실행",
"envFile": "Environment File", "envFile": "환경 파일",
"serviceFile": "Service File", "serviceFile": "서비스 파일",
"enableAndStart": "Enable and Start", "enableAndStart": "활성화 및 시작",
"clientNameDescription": "나중에 변경할 수 있는 클라이언트의 표시 이름입니다.", "clientNameDescription": "나중에 변경할 수 있는 클라이언트의 표시 이름입니다.",
"clientAddress": "클라이언트 주소(고급)", "clientAddress": "클라이언트 주소(고급)",
"setupFailedToFetchSubnet": "기본값 로드 실패", "setupFailedToFetchSubnet": "기본값 로드 실패",
@@ -2850,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).",

View File

@@ -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": "Generate a provisioning key and use it with the Newt connector to automatically create sites on first startup - no need to set up separate credentials for each site.", "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": "Sites that connect using a provisioning key appear here for review.", "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,8 +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": "Targets cleared", "targetTargetsCleared": "Mål ryddet",
"targetTargetsClearedDescription": "All targets have been removed from this resource", "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",
@@ -2348,7 +2348,7 @@
"description": "Enterprise features, 50 brukere, 50 nettsteder og prioritetsstøtte." "description": "Enterprise features, 50 brukere, 50 nettsteder og prioritetsstøtte."
} }
}, },
"personalUseOnly": "Personal use only (free license - no checkout)", "personalUseOnly": "Kun personlig bruk (gratis lisens - ingen kasse)",
"buttons": { "buttons": {
"continueToCheckout": "Fortsett til kassen" "continueToCheckout": "Fortsett til kassen"
}, },
@@ -2609,9 +2609,9 @@
"machineClients": "Maskinklienter", "machineClients": "Maskinklienter",
"install": "Installer", "install": "Installer",
"run": "Kjør", "run": "Kjør",
"envFile": "Environment File", "envFile": "Miljøfil",
"serviceFile": "Service File", "serviceFile": "Tjenestefil",
"enableAndStart": "Enable and Start", "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",
@@ -2850,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).",

View File

@@ -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": "Generate a provisioning key and use it with the Newt connector to automatically create sites on first startup - no need to set up separate credentials for each 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 that connect using a provisioning key appear here for review.", "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,8 +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": "Targets cleared", "targetTargetsCleared": "Doelen gewist",
"targetTargetsClearedDescription": "All targets have been removed from this resource", "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",
@@ -2348,7 +2348,7 @@
"description": "Enterprise functies, 50 gebruikers, 50 sites en prioriteit ondersteuning." "description": "Enterprise functies, 50 gebruikers, 50 sites en prioriteit ondersteuning."
} }
}, },
"personalUseOnly": "Personal use only (free license - no checkout)", "personalUseOnly": "Alleen voor persoonlijk gebruik (gratis licentie - geen afrekening)",
"buttons": { "buttons": {
"continueToCheckout": "Doorgaan naar afrekenen" "continueToCheckout": "Doorgaan naar afrekenen"
}, },
@@ -2609,9 +2609,9 @@
"machineClients": "Machine Clienten", "machineClients": "Machine Clienten",
"install": "Installeren", "install": "Installeren",
"run": "Uitvoeren", "run": "Uitvoeren",
"envFile": "Environment File", "envFile": "Omgevingsbestand",
"serviceFile": "Service File", "serviceFile": "Servicebestand",
"enableAndStart": "Enable and Start", "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",
@@ -2850,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).",

View File

@@ -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": "Generate a provisioning key and use it with the Newt connector to automatically create sites on first startup - no need to set up separate credentials for each site.", "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": "Sites that connect using a provisioning key appear here for review.", "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,8 +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": "Targets cleared", "targetTargetsCleared": "Cele wyczyszczone",
"targetTargetsClearedDescription": "All targets have been removed from this resource", "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",
@@ -2348,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": "Personal use only (free license - no checkout)", "personalUseOnly": "Tylko do użytku osobistego (darmowa licencja - bez płatności)",
"buttons": { "buttons": {
"continueToCheckout": "Przejdź do zamówienia" "continueToCheckout": "Przejdź do zamówienia"
}, },
@@ -2609,9 +2609,9 @@
"machineClients": "Klienci maszyn", "machineClients": "Klienci maszyn",
"install": "Zainstaluj", "install": "Zainstaluj",
"run": "Uruchom", "run": "Uruchom",
"envFile": "Environment File", "envFile": "Plik środowiska",
"serviceFile": "Service File", "serviceFile": "Plik serwisu",
"enableAndStart": "Enable and Start", "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",
@@ -2850,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).",

View File

@@ -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": "Generate a provisioning key and use it with the Newt connector to automatically create sites on first startup - no need to set up separate credentials for each 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 that connect using a provisioning key appear here for review.", "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,8 +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": "Targets cleared", "targetTargetsCleared": "Alvos limpos",
"targetTargetsClearedDescription": "All targets have been removed from this resource", "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",
@@ -2348,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": "Personal use only (free license - no checkout)", "personalUseOnly": "Uso pessoal apenas (licença gratuita - sem checkout)",
"buttons": { "buttons": {
"continueToCheckout": "Continuar com checkout" "continueToCheckout": "Continuar com checkout"
}, },
@@ -2609,9 +2609,9 @@
"machineClients": "Clientes de máquina", "machineClients": "Clientes de máquina",
"install": "Instale", "install": "Instale",
"run": "Executar", "run": "Executar",
"envFile": "Environment File", "envFile": "Arquivo de Ambiente",
"serviceFile": "Service File", "serviceFile": "Arquivo de Serviço",
"enableAndStart": "Enable and Start", "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",
@@ -2850,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).",

View File

@@ -371,10 +371,10 @@
"provisioningKeysUpdated": "Ключ подготовки обновлен", "provisioningKeysUpdated": "Ключ подготовки обновлен",
"provisioningKeysUpdatedDescription": "Ваши изменения были сохранены.", "provisioningKeysUpdatedDescription": "Ваши изменения были сохранены.",
"provisioningKeysBannerTitle": "Ключи подготовки сайта", "provisioningKeysBannerTitle": "Ключи подготовки сайта",
"provisioningKeysBannerDescription": "Generate a provisioning key and use it with the Newt connector to automatically create sites on first startup - no need to set up separate credentials for each site.", "provisioningKeysBannerDescription": "Создайте ключ настройки и используйте его с соединителем Newt для автоматического создания сайтов при первом запуске — нет необходимости настраивать отдельные учетные данные для каждого сайта.",
"provisioningKeysBannerButtonText": "Узнать больше", "provisioningKeysBannerButtonText": "Узнать больше",
"pendingSitesBannerTitle": "Ожидающие сайты", "pendingSitesBannerTitle": "Ожидающие сайты",
"pendingSitesBannerDescription": "Sites that connect using a provisioning key appear here for review.", "pendingSitesBannerDescription": "Сайты, подключающиеся с помощью ключа настройки, отображаются здесь для проверки.",
"pendingSitesBannerButtonText": "Узнать больше", "pendingSitesBannerButtonText": "Узнать больше",
"apiKeysSettings": "Настройки {apiKeyName}", "apiKeysSettings": "Настройки {apiKeyName}",
"userTitle": "Управление всеми пользователями", "userTitle": "Управление всеми пользователями",
@@ -624,8 +624,8 @@
"targetErrorInvalidPortDescription": "Пожалуйста, введите правильный номер порта", "targetErrorInvalidPortDescription": "Пожалуйста, введите правильный номер порта",
"targetErrorNoSite": "Сайт не выбран", "targetErrorNoSite": "Сайт не выбран",
"targetErrorNoSiteDescription": "Пожалуйста, выберите сайт для цели", "targetErrorNoSiteDescription": "Пожалуйста, выберите сайт для цели",
"targetTargetsCleared": "Targets cleared", "targetTargetsCleared": "Цели очищены",
"targetTargetsClearedDescription": "All targets have been removed from this resource", "targetTargetsClearedDescription": "Все цели удалены из этого ресурса",
"targetCreated": "Цель создана", "targetCreated": "Цель создана",
"targetCreatedDescription": "Цель была успешно создана", "targetCreatedDescription": "Цель была успешно создана",
"targetErrorCreate": "Не удалось создать цель", "targetErrorCreate": "Не удалось создать цель",
@@ -2348,7 +2348,7 @@
"description": "Функции предприятия, 50 пользователей, 50 сайтов, а также приоритетная поддержка." "description": "Функции предприятия, 50 пользователей, 50 сайтов, а также приоритетная поддержка."
} }
}, },
"personalUseOnly": "Personal use only (free license - no checkout)", "personalUseOnly": "Только для личного использования (бесплатная лицензия - без оформления на кассе)",
"buttons": { "buttons": {
"continueToCheckout": "Продолжить оформление заказа" "continueToCheckout": "Продолжить оформление заказа"
}, },
@@ -2609,9 +2609,9 @@
"machineClients": "Машинные клиенты", "machineClients": "Машинные клиенты",
"install": "Установить", "install": "Установить",
"run": "Запустить", "run": "Запустить",
"envFile": "Environment File", "envFile": "Файл окружения",
"serviceFile": "Service File", "serviceFile": "Сервисный файл",
"enableAndStart": "Enable and Start", "enableAndStart": "Включить и запустить",
"clientNameDescription": "Отображаемое имя клиента, которое может быть изменено позже.", "clientNameDescription": "Отображаемое имя клиента, которое может быть изменено позже.",
"clientAddress": "Адрес клиента (Дополнительно)", "clientAddress": "Адрес клиента (Дополнительно)",
"setupFailedToFetchSubnet": "Не удалось получить подсеть по умолчанию", "setupFailedToFetchSubnet": "Не удалось получить подсеть по умолчанию",
@@ -2853,7 +2853,7 @@
"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).",

View File

@@ -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": "Generate a provisioning key and use it with the Newt connector to automatically create sites on first startup - no need to set up separate credentials for each site.", "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": "Sites that connect using a provisioning key appear here for review.", "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,8 +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": "Targets cleared", "targetTargetsCleared": "Hedefler temizlendi",
"targetTargetsClearedDescription": "All targets have been removed from this resource", "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",
@@ -2348,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": "Personal use only (free license - no checkout)", "personalUseOnly": "Kişisel kullanım için (ücretsiz lisans - ödeme yok)",
"buttons": { "buttons": {
"continueToCheckout": "Ödemeye Devam Et" "continueToCheckout": "Ödemeye Devam Et"
}, },
@@ -2609,9 +2609,9 @@
"machineClients": "Makine İstemcileri", "machineClients": "Makine İstemcileri",
"install": "Yükle", "install": "Yükle",
"run": "Çalıştır", "run": "Çalıştır",
"envFile": "Environment File", "envFile": "Ortam Dosyası",
"serviceFile": "Service File", "serviceFile": "Servis Dosyası",
"enableAndStart": "Enable and Start", "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ı",
@@ -2850,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).",

View File

@@ -371,10 +371,10 @@
"provisioningKeysUpdated": "置备密钥已更新", "provisioningKeysUpdated": "置备密钥已更新",
"provisioningKeysUpdatedDescription": "您的更改已保存。", "provisioningKeysUpdatedDescription": "您的更改已保存。",
"provisioningKeysBannerTitle": "站点置备密钥", "provisioningKeysBannerTitle": "站点置备密钥",
"provisioningKeysBannerDescription": "Generate a provisioning key and use it with the Newt connector to automatically create sites on first startup - no need to set up separate credentials for each site.", "provisioningKeysBannerDescription": "生成一个供应密钥,并将其与 Newt 连接器一起使用,以在首次启动时自动创建站点 - 无需为每个站点设置单独的凭据。",
"provisioningKeysBannerButtonText": "了解更多", "provisioningKeysBannerButtonText": "了解更多",
"pendingSitesBannerTitle": "待定站点", "pendingSitesBannerTitle": "待定站点",
"pendingSitesBannerDescription": "Sites that connect using a provisioning key appear here for review.", "pendingSitesBannerDescription": "使用供应密钥连接的站点将在此显示以供审核。",
"pendingSitesBannerButtonText": "了解更多", "pendingSitesBannerButtonText": "了解更多",
"apiKeysSettings": "{apiKeyName} 设置", "apiKeysSettings": "{apiKeyName} 设置",
"userTitle": "管理所有用户", "userTitle": "管理所有用户",
@@ -624,8 +624,8 @@
"targetErrorInvalidPortDescription": "请输入有效的端口号", "targetErrorInvalidPortDescription": "请输入有效的端口号",
"targetErrorNoSite": "没有选择站点", "targetErrorNoSite": "没有选择站点",
"targetErrorNoSiteDescription": "请选择目标站点", "targetErrorNoSiteDescription": "请选择目标站点",
"targetTargetsCleared": "Targets cleared", "targetTargetsCleared": "目标已清除",
"targetTargetsClearedDescription": "All targets have been removed from this resource", "targetTargetsClearedDescription": "所有目标已从此资源中移除",
"targetCreated": "目标已创建", "targetCreated": "目标已创建",
"targetCreatedDescription": "目标已成功创建", "targetCreatedDescription": "目标已成功创建",
"targetErrorCreate": "创建目标失败", "targetErrorCreate": "创建目标失败",
@@ -2348,7 +2348,7 @@
"description": "企业特征、50个用户、50个站点和优先支持。" "description": "企业特征、50个用户、50个站点和优先支持。"
} }
}, },
"personalUseOnly": "Personal use only (free license - no checkout)", "personalUseOnly": "仅限个人使用(免费许可 - 无需结账)",
"buttons": { "buttons": {
"continueToCheckout": "继续签出" "continueToCheckout": "继续签出"
}, },
@@ -2609,9 +2609,9 @@
"machineClients": "机器客户端", "machineClients": "机器客户端",
"install": "安装", "install": "安装",
"run": "运行", "run": "运行",
"envFile": "Environment File", "envFile": "环境文件",
"serviceFile": "Service File", "serviceFile": "服务文件",
"enableAndStart": "Enable and Start", "enableAndStart": "启用并启动",
"clientNameDescription": "可以稍后更改的客户端的显示名称。", "clientNameDescription": "可以稍后更改的客户端的显示名称。",
"clientAddress": "客户端地址 (高级)", "clientAddress": "客户端地址 (高级)",
"setupFailedToFetchSubnet": "获取默认子网失败", "setupFailedToFetchSubnet": "获取默认子网失败",
@@ -2850,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 键)。",

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,3 @@
export function initAcmeCertSync(): void {
// stub
}

View File

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

View File

@@ -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("*"),

View File

@@ -5,6 +5,7 @@ import config from "@server/lib/config";
import z from "zod"; import z from "zod";
import logger from "@server/logger"; import logger from "@server/logger";
import semver from "semver"; import semver from "semver";
import { getValidCertificatesForDomains } from "#private/lib/certificates";
interface IPRange { interface IPRange {
start: bigint; start: bigint;
@@ -582,16 +583,26 @@ 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 function generateSubnetProxyTargetV2( export type HTTPTarget = {
destAddr: string; // must be an IP or hostname
destPort: number;
scheme: "http" | "https";
};
export async function generateSubnetProxyTargetV2(
siteResource: SiteResource, siteResource: SiteResource,
clients: { clients: {
clientId: number; clientId: number;
pubKey: string | null; pubKey: string | null;
subnet: string | null; subnet: string | null;
}[] }[]
): SubnetProxyTargetV2 | undefined { ): Promise<SubnetProxyTargetV2 | undefined> {
if (clients.length === 0) { if (clients.length === 0) {
logger.debug( logger.debug(
`No clients have access to site resource ${siteResource.siteResourceId}, skipping target generation.` `No clients have access to site resource ${siteResource.siteResourceId}, skipping target generation.`
@@ -619,7 +630,7 @@ export function generateSubnetProxyTargetV2(
destPrefix: destination, destPrefix: destination,
portRange, portRange,
disableIcmp, disableIcmp,
resourceId: siteResource.siteResourceId, resourceId: siteResource.siteResourceId
}; };
} }
@@ -631,7 +642,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 +651,69 @@ 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
let tlsCert: string | undefined;
let tlsKey: string | undefined;
if (siteResource.ssl && siteResource.alias) {
try {
const certs = await getValidCertificatesForDomains(
new Set([siteResource.alias]),
true
);
if (certs.length > 0 && certs[0].certFile && certs[0].keyFile) {
tlsCert = certs[0].certFile;
tlsKey = certs[0].keyFile;
} else {
logger.warn(
`No valid certificate found for SSL site resource ${siteResource.siteResourceId} with domain ${siteResource.alias}`
);
}
} catch (err) {
logger.error(
`Failed to retrieve certificate for site resource ${siteResource.siteResourceId} domain ${siteResource.alias}: ${err}`
);
}
}
target = {
sourcePrefixes: [],
destPrefix: `${siteResource.aliasAddress}/32`,
rewriteTo: destination,
portRange,
disableIcmp,
resourceId: siteResource.siteResourceId, resourceId: siteResource.siteResourceId,
protocol: siteResource.ssl ? "https" : "http",
httpTargets: [
{
destAddr: siteResource.destination,
destPort: siteResource.destinationPort,
scheme: siteResource.scheme
}
],
...(tlsCert && tlsKey ? { tlsCert, tlsKey } : {})
}; };
} }
@@ -670,33 +743,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

View File

@@ -661,7 +661,7 @@ async function handleSubnetProxyTargetUpdates(
); );
if (addedClients.length > 0) { if (addedClients.length > 0) {
const targetToAdd = generateSubnetProxyTargetV2( const targetToAdd = await generateSubnetProxyTargetV2(
siteResource, siteResource,
addedClients addedClients
); );
@@ -698,7 +698,7 @@ async function handleSubnetProxyTargetUpdates(
); );
if (removedClients.length > 0) { if (removedClients.length > 0) {
const targetToRemove = generateSubnetProxyTargetV2( const targetToRemove = await generateSubnetProxyTargetV2(
siteResource, siteResource,
removedClients removedClients
); );
@@ -1164,7 +1164,7 @@ async function handleMessagesForClientResources(
} }
for (const resource of resources) { for (const resource of resources) {
const target = generateSubnetProxyTargetV2(resource, [ const target = await generateSubnetProxyTargetV2(resource, [
{ {
clientId: client.clientId, clientId: client.clientId,
pubKey: client.pubKey, pubKey: client.pubKey,
@@ -1241,7 +1241,7 @@ async function handleMessagesForClientResources(
} }
for (const resource of resources) { for (const resource of resources) {
const target = generateSubnetProxyTargetV2(resource, [ const target = await generateSubnetProxyTargetV2(resource, [
{ {
clientId: client.clientId, clientId: client.clientId,
pubKey: client.pubKey, pubKey: client.pubKey,

View File

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

View 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);
}

View File

@@ -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,
@@ -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}" ` +

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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}::bigint, ${bytesOut}::bigint)`
); );
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`

View File

@@ -168,7 +168,7 @@ export async function buildClientConfigurationForNewtClient(
) )
); );
const resourceTarget = generateSubnetProxyTargetV2( const resourceTarget = await generateSubnetProxyTargetV2(
resource, resource,
resourceClients resourceClients
); );

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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,
@@ -618,11 +651,11 @@ export async function handleMessagingForUpdatedSiteResource(
// Only update targets on newt if destination changed // Only update targets on newt if destination changed
if (destinationChanged || portRangesChanged) { if (destinationChanged || portRangesChanged) {
const oldTarget = generateSubnetProxyTargetV2( const oldTarget = await generateSubnetProxyTargetV2(
existingSiteResource, existingSiteResource,
mergedAllClients mergedAllClients
); );
const newTarget = generateSubnetProxyTargetV2( const newTarget = await generateSubnetProxyTargetV2(
updatedSiteResource, updatedSiteResource,
mergedAllClients mergedAllClients
); );

View File

@@ -235,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
`); `);
} }
@@ -258,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
`); `);
} }

View File

@@ -145,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(
@@ -246,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);
} }
}); });
@@ -265,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);
} }
}); });

View File

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

View File

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

View 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>
);
}

View File

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

View File

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

View File

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

View File

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