Compare commits

..

3 Commits

Author SHA1 Message Date
Owen Schwartz
035644eaf7 Merge pull request #2778 from fosrl/dev
1.17.0-s.2
2026-04-03 12:35:03 -04:00
Owen Schwartz
16e7233a3e Merge pull request #2777 from fosrl/dev
1.17.0-s.1
2026-04-03 12:19:23 -04:00
Owen Schwartz
1f74e1b320 Merge pull request #2776 from fosrl/dev
1.17.0-s.0
2026-04-03 11:39:35 -04:00
50 changed files with 790 additions and 1944 deletions

1
.github/CODEOWNERS vendored
View File

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

View File

@@ -86,8 +86,6 @@ 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": "Генерирайте ключ за осигуряване и го използвайте със съединителя Newt за автоматично създаване на сайтове при първоначално стартиране - не е необходимо да се създават отделни идентификационни данни за всеки сайт.", "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.",
"provisioningKeysBannerButtonText": "Научете повече", "provisioningKeysBannerButtonText": "Научете повече",
"pendingSitesBannerTitle": "Чакащи сайтове", "pendingSitesBannerTitle": "Чакащи сайтове",
"pendingSitesBannerDescription": "Сайтовете, които се свързват с ключ за осигуряване, ще се появят тук за преглед.", "pendingSitesBannerDescription": "Sites that connect using a provisioning key appear here for review.",
"pendingSitesBannerButtonText": "Научете повече", "pendingSitesBannerButtonText": "Научете повече",
"apiKeysSettings": "Настройки на {apiKeyName}", "apiKeysSettings": "Настройки на {apiKeyName}",
"userTitle": "Управление на всички потребители", "userTitle": "Управление на всички потребители",
@@ -624,8 +624,8 @@
"targetErrorInvalidPortDescription": "Моля, въведете валиден номер на порт", "targetErrorInvalidPortDescription": "Моля, въведете валиден номер на порт",
"targetErrorNoSite": "Няма избран сайт", "targetErrorNoSite": "Няма избран сайт",
"targetErrorNoSiteDescription": "Моля, изберете сайт за целта", "targetErrorNoSiteDescription": "Моля, изберете сайт за целта",
"targetTargetsCleared": "Мишените са премахнати", "targetTargetsCleared": "Targets cleared",
"targetTargetsClearedDescription": "Всички цели са били премахнати от този ресурс", "targetTargetsClearedDescription": "All targets have been removed from this resource",
"targetCreated": "Целта е създадена", "targetCreated": "Целта е създадена",
"targetCreatedDescription": "Целта беше успешно създадена", "targetCreatedDescription": "Целта беше успешно създадена",
"targetErrorCreate": "Неуспешно създаване на целта", "targetErrorCreate": "Неуспешно създаване на целта",
@@ -2348,7 +2348,7 @@
"description": "Предприятие, 50 потребители, 50 сайта и приоритетна поддръжка." "description": "Предприятие, 50 потребители, 50 сайта и приоритетна поддръжка."
} }
}, },
"personalUseOnly": "Само за лична употреба (безплатен лиценз - без проверка)", "personalUseOnly": "Personal use only (free license - no checkout)",
"buttons": { "buttons": {
"continueToCheckout": "Продължете към плащане" "continueToCheckout": "Продължете към плащане"
}, },
@@ -2609,9 +2609,9 @@
"machineClients": "Машинни клиенти", "machineClients": "Машинни клиенти",
"install": "Инсталирай", "install": "Инсталирай",
"run": "Изпълни", "run": "Изпълни",
"envFile": "Файл за среда", "envFile": "Environment File",
"serviceFile": "Файл за услуга", "serviceFile": "Service File",
"enableAndStart": "Активиране и стартиране", "enableAndStart": "Enable and Start",
"clientNameDescription": "Показваното име на клиента, което може да се промени по-късно.", "clientNameDescription": "Показваното име на клиента, което може да се промени по-късно.",
"clientAddress": "Клиентски адрес (Разширено)", "clientAddress": "Клиентски адрес (Разширено)",
"setupFailedToFetchSubnet": "Неуспешно извличане на подмрежа по подразбиране", "setupFailedToFetchSubnet": "Неуспешно извличане на подмрежа по подразбиране",
@@ -2850,10 +2850,10 @@
"httpDestAuthNoneTitle": "Без удостоверяване", "httpDestAuthNoneTitle": "Без удостоверяване",
"httpDestAuthNoneDescription": "Изпращане на заявки без заглавие за удостоверяване.", "httpDestAuthNoneDescription": "Изпращане на заявки без заглавие за удостоверяване.",
"httpDestAuthBearerTitle": "Bearer Токен", "httpDestAuthBearerTitle": "Bearer Токен",
"httpDestAuthBearerDescription": "Добавя заглавие Authorization: Bearer '<token>' към всяка заявка.", "httpDestAuthBearerDescription": "Добавя заглавие за удостоверяване Bearer '<token>' към всяка заявка.",
"httpDestAuthBearerPlaceholder": "Вашият API ключ или токен", "httpDestAuthBearerPlaceholder": "Вашият API ключ или токен",
"httpDestAuthBasicTitle": "Основно удостоверяване", "httpDestAuthBasicTitle": "Основно удостоверяване",
"httpDestAuthBasicDescription": "Добавя заглавие Authorization: Basic '<credentials>'. Осигурете идентификационни данни като потребителско име:парола.", "httpDestAuthBasicDescription": "Добавя заглавие за удостоверяване 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": "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.", "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.",
"provisioningKeysBannerButtonText": "Zjistit více", "provisioningKeysBannerButtonText": "Zjistit více",
"pendingSitesBannerTitle": "Nevyřízené weby", "pendingSitesBannerTitle": "Nevyřízené weby",
"pendingSitesBannerDescription": "Stránky, které se připojují pomocí klíče pro zřízení, se zde objeví ke kontrole.", "pendingSitesBannerDescription": "Sites that connect using a provisioning key appear here for review.",
"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": "Cíle vymazány", "targetTargetsCleared": "Targets cleared",
"targetTargetsClearedDescription": "Všechny cíle byly odstraněny z tohoto zdroje", "targetTargetsClearedDescription": "All targets have been removed from this resource",
"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": "Pouze pro osobní použití (zdarma licence - bez ověření)", "personalUseOnly": "Personal use only (free license - no checkout)",
"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": "Konfigurační soubor prostředí", "envFile": "Environment File",
"serviceFile": "Služební soubor", "serviceFile": "Service File",
"enableAndStart": "Povolit a spustit", "enableAndStart": "Enable and Start",
"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ává hlavičku Authorization: Bearer '<token>' k každému požadavku.", "httpDestAuthBearerDescription": "Přidá autorizaci: Hlavička Bearer '<token>' ke 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ává hlavičku Authorization: Basic '<credentials>'. Poskytněte přihlašovací údaje ve formátu uživatelské jméno:heslo.", "httpDestAuthBasicDescription": "Přidá autorizaci: Základní '<credentials>' hlavička. Poskytněte přihlašovací údaje jako uživatelské jméno:password.",
"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": "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.", "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.",
"provisioningKeysBannerButtonText": "Mehr erfahren", "provisioningKeysBannerButtonText": "Mehr erfahren",
"pendingSitesBannerTitle": "Ausstehende Seiten", "pendingSitesBannerTitle": "Ausstehende Seiten",
"pendingSitesBannerDescription": "Websites, die mit einem Bereitstellungsschlüssel verbunden sind, erscheinen hier zur Überprüfung.", "pendingSitesBannerDescription": "Sites that connect using a provisioning key appear here for review.",
"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": "Ziele gelöscht", "targetTargetsCleared": "Targets cleared",
"targetTargetsClearedDescription": "Alle Ziele wurden aus dieser Ressource entfernt", "targetTargetsClearedDescription": "All targets have been removed from this resource",
"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": "Nur persönliche Nutzung (kostenlose Lizenz - kein Checkout)", "personalUseOnly": "Personal use only (free license - no 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": "Umgebungsdatei", "envFile": "Environment File",
"serviceFile": "Servicedatei", "serviceFile": "Service File",
"enableAndStart": "Aktivieren und Starten", "enableAndStart": "Enable and Start",
"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 jedem Anfrage-Header eine \"Authorization: Bearer '<token>'\" hinzu.", "httpDestAuthBearerDescription": "Fügt eine Berechtigung hinzu: Bearer '<token>' Header zu jeder Anfrage.",
"httpDestAuthBearerPlaceholder": "Ihr API-Schlüssel oder Token", "httpDestAuthBearerPlaceholder": "Ihr API-Schlüssel oder Token",
"httpDestAuthBasicTitle": "Einfacher Auth", "httpDestAuthBasicTitle": "Einfacher Auth",
"httpDestAuthBasicDescription": "Fügt einen \"Authorization: Basic '<credentials>'\"-Header hinzu. Geben Sie die Anmeldedaten als Benutzername:Passwort an.", "httpDestAuthBasicDescription": "Fügt eine Autorisierung hinzu: Basic '<credentials>' Kopfzeile hinzu. Geben Sie Anmeldedaten als Benutzername:password 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,11 +1817,6 @@
"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.",
@@ -1865,19 +1860,11 @@
"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.",
@@ -2129,7 +2116,6 @@
"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",
@@ -2673,12 +2659,8 @@
"editInternalResourceDialogAddUsers": "Add Users", "editInternalResourceDialogAddUsers": "Add Users",
"editInternalResourceDialogAddClients": "Add Clients", "editInternalResourceDialogAddClients": "Add Clients",
"editInternalResourceDialogDestinationLabel": "Destination", "editInternalResourceDialogDestinationLabel": "Destination",
"editInternalResourceDialogDestinationDescription": "Choose where this resource runs and how clients reach it, then complete the settings that apply to your setup.", "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.",
"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",
@@ -2717,8 +2699,6 @@
"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": "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.", "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.",
"provisioningKeysBannerButtonText": "Saber más", "provisioningKeysBannerButtonText": "Saber más",
"pendingSitesBannerTitle": "Sitios pendientes", "pendingSitesBannerTitle": "Sitios pendientes",
"pendingSitesBannerDescription": "Los sitios que se conectan utilizando una clave de aprovisionamiento aparecerán aquí para su revisión.", "pendingSitesBannerDescription": "Sites that connect using a provisioning key appear here for review.",
"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": "Objetivos eliminados", "targetTargetsCleared": "Targets cleared",
"targetTargetsClearedDescription": "Todos los objetivos han sido eliminados de este recurso", "targetTargetsClearedDescription": "All targets have been removed from this resource",
"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": "Solo uso personal (licencia gratuita - sin salida)", "personalUseOnly": "Personal use only (free license - no checkout)",
"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": "Archivo de Entorno", "envFile": "Environment File",
"serviceFile": "Archivo de Servicio", "serviceFile": "Service File",
"enableAndStart": "Habilitar y empezar", "enableAndStart": "Enable and Start",
"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 un encabezado Authorization: Bearer '<token>' a cada solicitud.", "httpDestAuthBearerDescription": "Añade una autorización: portador '<token>' encabezado 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 un encabezado Authorization: Basic '<credenciales>'. Proporcione las credenciales como nombredeusuario:contraseña.", "httpDestAuthBasicDescription": "Añade una Autorización: encabezado básico '<credentials>' . Proporcione credenciales como nombre de usuario: 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": "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.", "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.",
"provisioningKeysBannerButtonText": "En savoir plus", "provisioningKeysBannerButtonText": "En savoir plus",
"pendingSitesBannerTitle": "Sites en attente", "pendingSitesBannerTitle": "Sites en attente",
"pendingSitesBannerDescription": "Les sites qui se connectent en utilisant une clé de provisionnement apparaissent ici pour révision.", "pendingSitesBannerDescription": "Sites that connect using a provisioning key appear here for review.",
"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": "Cibles effacées", "targetTargetsCleared": "Targets cleared",
"targetTargetsClearedDescription": "Toutes les cibles ont été retirées de cette ressource", "targetTargetsClearedDescription": "All targets have been removed from this resource",
"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": "Usage personnel uniquement (licence gratuite - pas de validation)", "personalUseOnly": "Personal use only (free license - no checkout)",
"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": "Fichier Environnement", "envFile": "Environment File",
"serviceFile": "Fichier de Service", "serviceFile": "Service File",
"enableAndStart": "Activer et Démarrer", "enableAndStart": "Enable and Start",
"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 un en-tête Authorization: Basic '<credentials>'. Fournissez les identifiants sous la forme nom d'utilisateur:mot de passe.", "httpDestAuthBasicDescription": "Ajoute une autorisation : en-tête de base '<credentials>' . Fournissez des informations d'identification comme 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": "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.", "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.",
"provisioningKeysBannerButtonText": "Scopri di più", "provisioningKeysBannerButtonText": "Scopri di più",
"pendingSitesBannerTitle": "Siti In Attesa", "pendingSitesBannerTitle": "Siti In Attesa",
"pendingSitesBannerDescription": "I siti che si connettono utilizzando una chiave di provisioning vengono visualizzati qui per la revisione.", "pendingSitesBannerDescription": "Sites that connect using a provisioning key appear here for review.",
"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": "Obiettivi cancellati", "targetTargetsCleared": "Targets cleared",
"targetTargetsClearedDescription": "Tutti gli obiettivi sono stati rimossi da questa risorsa", "targetTargetsClearedDescription": "All targets have been removed from this resource",
"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": "Uso personale esclusivo (licenza gratuita - nessun pagamento)", "personalUseOnly": "Personal use only (free license - no checkout)",
"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": "File di ambiente", "envFile": "Environment File",
"serviceFile": "File di servizio", "serviceFile": "Service File",
"enableAndStart": "Abilita e avvia", "enableAndStart": "Enable and Start",
"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 Authorization: Bearer '<token>' a ogni richiesta.", "httpDestAuthBearerDescription": "Aggiunge un'intestazione Autorizzazione: Bearer '<token>' ad 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'intestazione Authorization: Basic '<credentials>'. Fornire le credenziali come username:password.", "httpDestAuthBasicDescription": "Aggiunge un'autorizzazione: intestazione di base '<credentials>' . Fornisce 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": "프로비저닝 키를 생성하고 Newt 커넥터와 함께 사용하여 첫 시작 시 사이트를 자동 생성 - 각 사이트에 대한 별도 자격 증명이 필요 없습니다.", "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.",
"provisioningKeysBannerButtonText": "자세히 알아보기", "provisioningKeysBannerButtonText": "자세히 알아보기",
"pendingSitesBannerTitle": "대기중인 사이트", "pendingSitesBannerTitle": "대기중인 사이트",
"pendingSitesBannerDescription": "프로비저닝 키를 사용하여 연결된 사이트가 검토를 위해 여기에 표시됩니다.", "pendingSitesBannerDescription": "Sites that connect using a provisioning key appear here for review.",
"pendingSitesBannerButtonText": "자세히 알아보기", "pendingSitesBannerButtonText": "자세히 알아보기",
"apiKeysSettings": "{apiKeyName} 설정", "apiKeysSettings": "{apiKeyName} 설정",
"userTitle": "모든 사용자 관리", "userTitle": "모든 사용자 관리",
@@ -624,8 +624,8 @@
"targetErrorInvalidPortDescription": "유효한 포트 번호를 입력하세요.", "targetErrorInvalidPortDescription": "유효한 포트 번호를 입력하세요.",
"targetErrorNoSite": "선택된 사이트 없음", "targetErrorNoSite": "선택된 사이트 없음",
"targetErrorNoSiteDescription": "대상을 위해 사이트를 선택하세요.", "targetErrorNoSiteDescription": "대상을 위해 사이트를 선택하세요.",
"targetTargetsCleared": "대상이 제거됨", "targetTargetsCleared": "Targets cleared",
"targetTargetsClearedDescription": "이 리소스에서 모든 대상이 제거되었습니다", "targetTargetsClearedDescription": "All targets have been removed from this resource",
"targetCreated": "대상 생성", "targetCreated": "대상 생성",
"targetCreatedDescription": "대상이 성공적으로 생성되었습니다.", "targetCreatedDescription": "대상이 성공적으로 생성되었습니다.",
"targetErrorCreate": "대상 생성 실패", "targetErrorCreate": "대상 생성 실패",
@@ -2348,7 +2348,7 @@
"description": "기업 기능, 50명의 사용자, 50개의 사이트, 우선 지원." "description": "기업 기능, 50명의 사용자, 50개의 사이트, 우선 지원."
} }
}, },
"personalUseOnly": "개인용으로만 사용 (무료 라이선스 - 결제 없음)", "personalUseOnly": "Personal use only (free license - no checkout)",
"buttons": { "buttons": {
"continueToCheckout": "결제로 진행" "continueToCheckout": "결제로 진행"
}, },
@@ -2609,9 +2609,9 @@
"machineClients": "기계 클라이언트", "machineClients": "기계 클라이언트",
"install": "설치", "install": "설치",
"run": "실행", "run": "실행",
"envFile": "환경 파일", "envFile": "Environment File",
"serviceFile": "서비스 파일", "serviceFile": "Service File",
"enableAndStart": "활성화 및 시작", "enableAndStart": "Enable and Start",
"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>' 헤더를 추가합니다. 자격 증명은 사용자 이름:비밀번호로 제공합니다.", "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": "Foreslå nøkkel oppdatert", "provisioningKeysUpdated": "Foreslå nøkkel oppdatert",
"provisioningKeysUpdatedDescription": "Dine endringer er lagret.", "provisioningKeysUpdatedDescription": "Dine endringer er lagret.",
"provisioningKeysBannerTitle": "Sidens bestemmende nøkler", "provisioningKeysBannerTitle": "Sidens bestemmende nøkler",
"provisioningKeysBannerDescription": "Generer en 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.", "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.",
"provisioningKeysBannerButtonText": "Lær mer", "provisioningKeysBannerButtonText": "Lær mer",
"pendingSitesBannerTitle": "Ventende nettsteder", "pendingSitesBannerTitle": "Ventende nettsteder",
"pendingSitesBannerDescription": "Nettsteder som kobler seg til ved bruk av en provisjonsnøkkel vises her for vurdering.", "pendingSitesBannerDescription": "Sites that connect using a provisioning key appear here for review.",
"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": "Mål ryddet", "targetTargetsCleared": "Targets cleared",
"targetTargetsClearedDescription": "Alle mål har blitt fjernet fra denne ressursen", "targetTargetsClearedDescription": "All targets have been removed from this resource",
"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": "Kun personlig bruk (gratis lisens - ingen kasse)", "personalUseOnly": "Personal use only (free license - no checkout)",
"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": "Miljøfil", "envFile": "Environment File",
"serviceFile": "Tjenestefil", "serviceFile": "Service File",
"enableAndStart": "Aktiver og start", "enableAndStart": "Enable and 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 Autorisasjon: Basic '<credentials>' header. Gi legitimasjon som brukernavn:passord.", "httpDestAuthBasicDescription": "Legger til en godkjenning: Grunnleggende '<credentials>' overskrift. Angi 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": "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.", "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.",
"provisioningKeysBannerButtonText": "Meer informatie", "provisioningKeysBannerButtonText": "Meer informatie",
"pendingSitesBannerTitle": "Openstaande sites", "pendingSitesBannerTitle": "Openstaande sites",
"pendingSitesBannerDescription": "Sites die verbinding maken met een inrichtingssleutel verschijnen hier voor beoordeling.", "pendingSitesBannerDescription": "Sites that connect using a provisioning key appear here for review.",
"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": "Doelen gewist", "targetTargetsCleared": "Targets cleared",
"targetTargetsClearedDescription": "Alle doelen zijn verwijderd van deze bron", "targetTargetsClearedDescription": "All targets have been removed from this resource",
"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": "Alleen voor persoonlijk gebruik (gratis licentie - geen afrekening)", "personalUseOnly": "Personal use only (free license - no checkout)",
"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": "Omgevingsbestand", "envFile": "Environment File",
"serviceFile": "Servicebestand", "serviceFile": "Service File",
"enableAndStart": "Inschakelen en Starten", "enableAndStart": "Enable and Start",
"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 Authorization: Bearer '<token>' header toe aan elk verzoek.", "httpDestAuthBearerDescription": "Voegt een machtiging toe: Drager '<token>' header aan elke aanvraag.",
"httpDestAuthBearerPlaceholder": "Uw API-sleutel of -token", "httpDestAuthBearerPlaceholder": "Uw API-sleutel of -token",
"httpDestAuthBasicTitle": "Basis authenticatie", "httpDestAuthBasicTitle": "Basis authenticatie",
"httpDestAuthBasicDescription": "Voegt een Authorization: Basic '<credentials>' header toe. Verstrek inloggegevens als gebruikersnaam:wachtwoord.", "httpDestAuthBasicDescription": "Voegt een Authorizatie toe: Basis '<credentials>' kop. Geef inloggegevens op 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": "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.", "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.",
"provisioningKeysBannerButtonText": "Dowiedz się więcej", "provisioningKeysBannerButtonText": "Dowiedz się więcej",
"pendingSitesBannerTitle": "Witryny oczekujące", "pendingSitesBannerTitle": "Witryny oczekujące",
"pendingSitesBannerDescription": "Witryny, które łączą się za pomocą klucza provisioning, pojawią się tutaj do przeglądu.", "pendingSitesBannerDescription": "Sites that connect using a provisioning key appear here for review.",
"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": "Cele wyczyszczone", "targetTargetsCleared": "Targets cleared",
"targetTargetsClearedDescription": "Wszystkie cele zostały usunięte z tego zasobu", "targetTargetsClearedDescription": "All targets have been removed from this resource",
"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": "Tylko do użytku osobistego (darmowa licencja - bez płatności)", "personalUseOnly": "Personal use only (free license - no checkout)",
"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": "Plik środowiska", "envFile": "Environment File",
"serviceFile": "Plik serwisu", "serviceFile": "Service File",
"enableAndStart": "Włącz i Uruchom", "enableAndStart": "Enable and Start",
"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 nagłówek Authorization: Bearer '<token>' do każdego żądania.", "httpDestAuthBearerDescription": "Dodaje autoryzację: nagłówek 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 nagłówek Authorization: Basic '<credentials>'. Podaj poświadczenia w formacie użytkownik:hasło.", "httpDestAuthBasicDescription": "Dodaje Autoryzacja: Nagłówek Basic '<credentials>' . Podaj poświadczenia jako nazwę użytkownika: 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": "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.", "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.",
"provisioningKeysBannerButtonText": "Saiba mais", "provisioningKeysBannerButtonText": "Saiba mais",
"pendingSitesBannerTitle": "Sites pendentes", "pendingSitesBannerTitle": "Sites pendentes",
"pendingSitesBannerDescription": "Sites que se conectam usando uma chave de provisionamento aparecem aqui para revisão.", "pendingSitesBannerDescription": "Sites that connect using a provisioning key appear here for review.",
"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": "Alvos limpos", "targetTargetsCleared": "Targets cleared",
"targetTargetsClearedDescription": "Todos os alvos foram removidos deste recurso", "targetTargetsClearedDescription": "All targets have been removed from this resource",
"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": "Uso pessoal apenas (licença gratuita - sem checkout)", "personalUseOnly": "Personal use only (free license - no 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": "Arquivo de Ambiente", "envFile": "Environment File",
"serviceFile": "Arquivo de Serviço", "serviceFile": "Service File",
"enableAndStart": "Ativar e Iniciar", "enableAndStart": "Enable and Start",
"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 um cabeçalho Authorization: Bearer '<token>' a cada solicitação.", "httpDestAuthBearerDescription": "Adiciona uma autorização: Bearer '<token>' header a cada requisiçã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 um cabeçalho Authorization: Basic '<credentials>'. Forneça as credenciais como username:password.", "httpDestAuthBasicDescription": "Adiciona uma Autorização: cabeçalho '<credentials>' básico. Forneça credenciais como nome de usuário:senha.",
"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": "Создайте ключ настройки и используйте его с соединителем Newt для автоматического создания сайтов при первом запуске — нет необходимости настраивать отдельные учетные данные для каждого сайта.", "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.",
"provisioningKeysBannerButtonText": "Узнать больше", "provisioningKeysBannerButtonText": "Узнать больше",
"pendingSitesBannerTitle": "Ожидающие сайты", "pendingSitesBannerTitle": "Ожидающие сайты",
"pendingSitesBannerDescription": "Сайты, подключающиеся с помощью ключа настройки, отображаются здесь для проверки.", "pendingSitesBannerDescription": "Sites that connect using a provisioning key appear here for review.",
"pendingSitesBannerButtonText": "Узнать больше", "pendingSitesBannerButtonText": "Узнать больше",
"apiKeysSettings": "Настройки {apiKeyName}", "apiKeysSettings": "Настройки {apiKeyName}",
"userTitle": "Управление всеми пользователями", "userTitle": "Управление всеми пользователями",
@@ -624,8 +624,8 @@
"targetErrorInvalidPortDescription": "Пожалуйста, введите правильный номер порта", "targetErrorInvalidPortDescription": "Пожалуйста, введите правильный номер порта",
"targetErrorNoSite": "Сайт не выбран", "targetErrorNoSite": "Сайт не выбран",
"targetErrorNoSiteDescription": "Пожалуйста, выберите сайт для цели", "targetErrorNoSiteDescription": "Пожалуйста, выберите сайт для цели",
"targetTargetsCleared": "Цели очищены", "targetTargetsCleared": "Targets cleared",
"targetTargetsClearedDescription": "Все цели удалены из этого ресурса", "targetTargetsClearedDescription": "All targets have been removed from this resource",
"targetCreated": "Цель создана", "targetCreated": "Цель создана",
"targetCreatedDescription": "Цель была успешно создана", "targetCreatedDescription": "Цель была успешно создана",
"targetErrorCreate": "Не удалось создать цель", "targetErrorCreate": "Не удалось создать цель",
@@ -2348,7 +2348,7 @@
"description": "Функции предприятия, 50 пользователей, 50 сайтов, а также приоритетная поддержка." "description": "Функции предприятия, 50 пользователей, 50 сайтов, а также приоритетная поддержка."
} }
}, },
"personalUseOnly": "Только для личного использования (бесплатная лицензия - без оформления на кассе)", "personalUseOnly": "Personal use only (free license - no checkout)",
"buttons": { "buttons": {
"continueToCheckout": "Продолжить оформление заказа" "continueToCheckout": "Продолжить оформление заказа"
}, },
@@ -2609,9 +2609,9 @@
"machineClients": "Машинные клиенты", "machineClients": "Машинные клиенты",
"install": "Установить", "install": "Установить",
"run": "Запустить", "run": "Запустить",
"envFile": "Файл окружения", "envFile": "Environment File",
"serviceFile": "Сервисный файл", "serviceFile": "Service File",
"enableAndStart": "Включить и запустить", "enableAndStart": "Enable and Start",
"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>'. Укажите учетные данные в формате username:password.", "httpDestAuthBasicDescription": "Добавляет Authorization: Basic '<credentials>' header. Предоставьте учетные данные в качестве имени пользователя: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": "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.", "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.",
"provisioningKeysBannerButtonText": "Daha fazla bilgi", "provisioningKeysBannerButtonText": "Daha fazla bilgi",
"pendingSitesBannerTitle": "Bekleyen Siteler", "pendingSitesBannerTitle": "Bekleyen Siteler",
"pendingSitesBannerDescription": "Bir sağlama anahtarı kullanarak bağlanan siteler, inceleme için burada görünür.", "pendingSitesBannerDescription": "Sites that connect using a provisioning key appear here for review.",
"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": "Hedefler temizlendi", "targetTargetsCleared": "Targets cleared",
"targetTargetsClearedDescription": "Bu kaynaktan tüm hedefler kaldırıldı", "targetTargetsClearedDescription": "All targets have been removed from this resource",
"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": "Kişisel kullanım için (ücretsiz lisans - ödeme yok)", "personalUseOnly": "Personal use only (free license - no checkout)",
"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": "Ortam Dosyası", "envFile": "Environment File",
"serviceFile": "Servis Dosyası", "serviceFile": "Service File",
"enableAndStart": "Etkinleştir ve Başlat", "enableAndStart": "Enable and Start",
"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>' üst bilgisi ekler.", "httpDestAuthBearerDescription": "Her isteğe bir Yetkilendirme: Taşıyıcı '<token>' başlığı 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": "Bir Yetkilendirme: Temel '<credentials>' üst bilgisi ekler. Kimlik bilgilerini kullanıcı adı:şifre olarak sağlayın.", "httpDestAuthBasicDescription": "Authorization: Temel '<belirtecikler>' başlığı ekler. Yetkilendirmeleri 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": "生成一个供应密钥,并将其与 Newt 连接器一起使用,以在首次启动时自动创建站点 - 无需为每个站点设置单独的凭据。", "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.",
"provisioningKeysBannerButtonText": "了解更多", "provisioningKeysBannerButtonText": "了解更多",
"pendingSitesBannerTitle": "待定站点", "pendingSitesBannerTitle": "待定站点",
"pendingSitesBannerDescription": "使用供应密钥连接的站点将在此显示以供审核。", "pendingSitesBannerDescription": "Sites that connect using a provisioning key appear here for review.",
"pendingSitesBannerButtonText": "了解更多", "pendingSitesBannerButtonText": "了解更多",
"apiKeysSettings": "{apiKeyName} 设置", "apiKeysSettings": "{apiKeyName} 设置",
"userTitle": "管理所有用户", "userTitle": "管理所有用户",
@@ -624,8 +624,8 @@
"targetErrorInvalidPortDescription": "请输入有效的端口号", "targetErrorInvalidPortDescription": "请输入有效的端口号",
"targetErrorNoSite": "没有选择站点", "targetErrorNoSite": "没有选择站点",
"targetErrorNoSiteDescription": "请选择目标站点", "targetErrorNoSiteDescription": "请选择目标站点",
"targetTargetsCleared": "目标已清除", "targetTargetsCleared": "Targets cleared",
"targetTargetsClearedDescription": "所有目标已从此资源中移除", "targetTargetsClearedDescription": "All targets have been removed from this resource",
"targetCreated": "目标已创建", "targetCreated": "目标已创建",
"targetCreatedDescription": "目标已成功创建", "targetCreatedDescription": "目标已成功创建",
"targetErrorCreate": "创建目标失败", "targetErrorCreate": "创建目标失败",
@@ -2348,7 +2348,7 @@
"description": "企业特征、50个用户、50个站点和优先支持。" "description": "企业特征、50个用户、50个站点和优先支持。"
} }
}, },
"personalUseOnly": "仅限个人使用(免费许可 - 无需结账)", "personalUseOnly": "Personal use only (free license - no checkout)",
"buttons": { "buttons": {
"continueToCheckout": "继续签出" "continueToCheckout": "继续签出"
}, },
@@ -2609,9 +2609,9 @@
"machineClients": "机器客户端", "machineClients": "机器客户端",
"install": "安装", "install": "安装",
"run": "运行", "run": "运行",
"envFile": "环境文件", "envFile": "Environment File",
"serviceFile": "服务文件", "serviceFile": "Service File",
"enableAndStart": "启用并启动", "enableAndStart": "Enable and Start",
"clientNameDescription": "可以稍后更改的客户端的显示名称。", "clientNameDescription": "可以稍后更改的客户端的显示名称。",
"clientAddress": "客户端地址 (高级)", "clientAddress": "客户端地址 (高级)",
"setupFailedToFetchSubnet": "获取默认子网失败", "setupFailedToFetchSubnet": "获取默认子网失败",
@@ -2850,10 +2850,10 @@
"httpDestAuthNoneTitle": "无身份验证", "httpDestAuthNoneTitle": "无身份验证",
"httpDestAuthNoneDescription": "在没有授权头的情况下发送请求。", "httpDestAuthNoneDescription": "在没有授权头的情况下发送请求。",
"httpDestAuthBearerTitle": "持有者令牌", "httpDestAuthBearerTitle": "持有者令牌",
"httpDestAuthBearerDescription": "在每个请求中添加授权Bearer “<token>” 头。", "httpDestAuthBearerDescription": "添加授权:每个请求的标题为 '<token>'。",
"httpDestAuthBearerPlaceholder": "您的 API 密钥或令牌", "httpDestAuthBearerPlaceholder": "您的 API 密钥或令牌",
"httpDestAuthBasicTitle": "基本认证", "httpDestAuthBasicTitle": "基本认证",
"httpDestAuthBasicDescription": "添加一个Authorization: Basic \"<凭据>\" 标头。 以用户名:密码形式提供凭据。", "httpDestAuthBasicDescription": "添加授权:基本 '<credentials>' 头。提供用户名:密码凭据。",
"httpDestAuthBasicPlaceholder": "用户名:密码", "httpDestAuthBasicPlaceholder": "用户名:密码",
"httpDestAuthCustomTitle": "自定义标题", "httpDestAuthCustomTitle": "自定义标题",
"httpDestAuthCustomDescription": "指定自定义 HTTP 头名称和身份验证值 (例如X-API 键)。", "httpDestAuthCustomDescription": "指定自定义 HTTP 头名称和身份验证值 (例如X-API 键)。",

View File

@@ -57,9 +57,7 @@ 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: integer("settingsLogRetentionDaysConnection") // where 0 = dont keep logs and -1 = keep forever and 9001 = end of the following year
"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)
@@ -103,9 +101,7 @@ 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") status: varchar("status").$type<"pending" | "approved">().default("approved")
.$type<"pending" | "approved">()
.default("approved")
}); });
export const resources = pgTable("resources", { export const resources = pgTable("resources", {
@@ -234,9 +230,8 @@ 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(),
ssl: boolean("ssl").notNull().default(false), mode: varchar("mode").$type<"host" | "cidr">().notNull(), // "host" | "cidr" | "port"
mode: varchar("mode").$type<"host" | "cidr" | "http">().notNull(), // "host" | "cidr" | "http" protocol: varchar("protocol"), // only for port mode
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,9 +54,7 @@ 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: integer("settingsLogRetentionDaysConnection") // where 0 = dont keep logs and -1 = keep forever and 9001 = end of the following year
"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)
@@ -260,9 +258,8 @@ 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(),
ssl: integer("ssl", { mode: "boolean" }).notNull().default(false), mode: text("mode").$type<"host" | "cidr">().notNull(), // "host" | "cidr" | "port"
mode: text("mode").$type<"host" | "cidr" | "http">().notNull(), // "host" | "cidr" | "http" protocol: text("protocol"), // only for port mode
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,7 +22,6 @@ 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() {
@@ -40,7 +39,6 @@ async function startServers() {
initTelemetryClient(); initTelemetryClient();
initLogCleanupInterval(); initLogCleanupInterval();
initAcmeCertSync();
// Start all servers // Start all servers
const apiServer = createApiServer(); const apiServer = createApiServer();

View File

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

View File

@@ -16,20 +16,6 @@ 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;
@@ -90,18 +76,14 @@ 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: mappedMode.mode, mode: resourceData.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,
@@ -225,9 +207,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 (mappedMode.mode === "host" || mappedMode.mode === "http") { if (resourceData.mode == "host") {
// we can only have an alias on a host
aliasAddress = await getNextAvailableAliasAddress(orgId); aliasAddress = await getNextAvailableAliasAddress(orgId);
} }
@@ -239,11 +221,8 @@ export async function updateClientResources(
siteId: site.siteId, siteId: site.siteId,
niceId: resourceNiceId, niceId: resourceNiceId,
name: resourceData.name || resourceNiceId, name: resourceData.name || resourceNiceId,
mode: mappedMode.mode, mode: resourceData.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", "http", "https"]), mode: z.enum(["host", "cidr"]),
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(),
"destination-port": 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),
"tcp-ports": portRangeStringSchema.optional().default("*"), "tcp-ports": portRangeStringSchema.optional().default("*"),

View File

@@ -5,7 +5,6 @@ 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;
@@ -583,26 +582,16 @@ export type SubnetProxyTargetV2 = {
protocol: "tcp" | "udp"; protocol: "tcp" | "udp";
}[]; }[];
resourceId?: number; resourceId?: number;
protocol?: "http" | "https"; // if set, this target only applies to the specified protocol
httpTargets?: HTTPTarget[];
tlsCert?: string;
tlsKey?: string;
}; };
export type HTTPTarget = { export function generateSubnetProxyTargetV2(
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;
}[] }[]
): Promise<SubnetProxyTargetV2 | undefined> { ): 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.`
@@ -630,7 +619,7 @@ export async function generateSubnetProxyTargetV2(
destPrefix: destination, destPrefix: destination,
portRange, portRange,
disableIcmp, disableIcmp,
resourceId: siteResource.siteResourceId resourceId: siteResource.siteResourceId,
}; };
} }
@@ -642,7 +631,7 @@ export async 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") {
@@ -651,69 +640,7 @@ export async 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 } : {})
}; };
} }
@@ -743,15 +670,16 @@ export async 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,
@@ -767,7 +695,8 @@ export function convertSubnetProxyTargetsV2ToV1(
}) })
})) }))
); );
} }
// 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 = await generateSubnetProxyTargetV2( const targetToAdd = generateSubnetProxyTargetV2(
siteResource, siteResource,
addedClients addedClients
); );
@@ -698,7 +698,7 @@ async function handleSubnetProxyTargetUpdates(
); );
if (removedClients.length > 0) { if (removedClients.length > 0) {
const targetToRemove = await generateSubnetProxyTargetV2( const targetToRemove = generateSubnetProxyTargetV2(
siteResource, siteResource,
removedClients removedClients
); );
@@ -1164,7 +1164,7 @@ async function handleMessagesForClientResources(
} }
for (const resource of resources) { for (const resource of resources) {
const target = await generateSubnetProxyTargetV2(resource, [ const target = 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 = await generateSubnetProxyTargetV2(resource, [ const target = generateSubnetProxyTargetV2(resource, [
{ {
clientId: client.clientId, clientId: client.clientId,
pubKey: client.pubKey, pubKey: client.pubKey,

View File

@@ -479,7 +479,10 @@ export async function getTraefikConfig(
// TODO: HOW TO HANDLE ^^^^^^ BETTER // TODO: HOW TO HANDLE ^^^^^^ BETTER
const anySitesOnline = targets.some( const anySitesOnline = targets.some(
(target) => target.site.online (target) =>
target.site.online ||
target.site.type === "local" ||
target.site.type === "wireguard"
); );
return ( return (
@@ -607,7 +610,10 @@ 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.site.online (target) =>
target.site.online ||
target.site.type === "local" ||
target.site.type === "wireguard"
); );
return targets return targets

View File

@@ -1,277 +0,0 @@
/*
* 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,8 +23,6 @@ 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,
@@ -274,20 +272,19 @@ export class LogStreamingManager {
return; return;
} }
// Decrypt and parse config skip destination if either step fails // Parse config skip destination if config is unparseable
let configFromDb: HttpConfig; let config: HttpConfig;
try { try {
const decryptedConfig = decrypt(dest.config, config.getRawConfig().server.secret!); config = JSON.parse(dest.config) as HttpConfig;
configFromDb = JSON.parse(decryptedConfig) as HttpConfig;
} catch (err) { } catch (err) {
logger.error( logger.error(
`LogStreamingManager: destination ${dest.destinationId} has invalid or undecryptable config`, `LogStreamingManager: destination ${dest.destinationId} has invalid JSON config`,
err err
); );
return; return;
} }
const provider = this.createProvider(dest.type, configFromDb); const provider = this.createProvider(dest.type, config);
if (!provider) { if (!provider) {
logger.warn( logger.warn(
`LogStreamingManager: unsupported destination type "${dest.type}" ` + `LogStreamingManager: unsupported destination type "${dest.type}" ` +

View File

@@ -95,21 +95,10 @@ 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, siteResources, Target, targets } from "@server/db"; import { orgs, resources, sites, Target, targets } from "@server/db";
import { import {
sanitize, sanitize,
encodePath, encodePath,
@@ -267,34 +267,6 @@ 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
@@ -304,12 +276,6 @@ 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)}`);
@@ -705,7 +671,10 @@ export async function getTraefikConfig(
// TODO: HOW TO HANDLE ^^^^^^ BETTER // TODO: HOW TO HANDLE ^^^^^^ BETTER
const anySitesOnline = targets.some( const anySitesOnline = targets.some(
(target) => target.site.online (target) =>
target.site.online ||
target.site.type === "local" ||
target.site.type === "wireguard"
); );
return ( return (
@@ -833,7 +802,10 @@ 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.site.online (target) =>
target.site.online ||
target.site.type === "local" ||
target.site.type === "wireguard"
); );
return targets return targets
@@ -901,128 +873,6 @@ 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,8 +22,6 @@ 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()
@@ -89,10 +87,7 @@ export async function createEventStreamingDestination(
); );
} }
const { type, config: configToSet, enabled } = parsedBody.data; const { type, config, enabled } = parsedBody.data;
const key = config.getRawConfig().server.secret!;
const encryptedConfig = encrypt(configToSet, key);
const now = Date.now(); const now = Date.now();
@@ -101,7 +96,7 @@ export async function createEventStreamingDestination(
.values({ .values({
orgId, orgId,
type, type,
config: encryptedConfig, config,
enabled, enabled,
createdAt: now, createdAt: now,
updatedAt: now, updatedAt: now,

View File

@@ -22,8 +22,6 @@ 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()
@@ -123,22 +121,9 @@ 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: decryptedList, destinations: list,
pagination: { pagination: {
total: count, total: count,
limit, limit,

View File

@@ -22,8 +22,7 @@ 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({
@@ -111,17 +110,14 @@ export async function updateEventStreamingDestination(
); );
} }
const { type, config: configToUpdate, enabled, sendAccessLogs, sendActionLogs, sendConnectionLogs, sendRequestLogs } = parsedBody.data; const { type, config, 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 (configToUpdate !== undefined) { if (config !== undefined) updateData.config = config;
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

@@ -168,7 +168,7 @@ export async function buildClientConfigurationForNewtClient(
) )
); );
const resourceTarget = await generateSubnetProxyTargetV2( const resourceTarget = 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 { inArray } from "drizzle-orm"; import { eq, 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 = 5; const MAX_RETRIES = 2;
const BASE_DELAY_MS = 50; const BASE_DELAY_MS = 50;
// ── Site (newt) pings ────────────────────────────────────────────────── // ── Site (newt) pings ──────────────────────────────────────────────────
@@ -36,14 +36,6 @@ 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 ─────────────────────────────────────────────────────────
/** /**
@@ -80,12 +72,6 @@ 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) {
@@ -97,35 +83,55 @@ async function flushSitePingsToDb(): Promise<void> {
const pingsToFlush = new Map(pendingSitePings); const pingsToFlush = new Map(pendingSitePings);
pendingSitePings.clear(); pendingSitePings.clear();
const entries = Array.from(pingsToFlush.entries()); // Sort by siteId for consistent lock ordering (prevents deadlocks)
const sortedEntries = Array.from(pingsToFlush.entries()).sort(
([a], [b]) => a - b
);
const BATCH_SIZE = 50; const BATCH_SIZE = 50;
for (let i = 0; i < entries.length; i += BATCH_SIZE) { for (let i = 0; i < sortedEntries.length; i += BATCH_SIZE) {
const batch = entries.slice(i, i + BATCH_SIZE); const batch = sortedEntries.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
const byTimestamp = new Map<number, number[]>();
for (const [siteId, timestamp] of batch) {
const group = byTimestamp.get(timestamp) || [];
group.push(siteId);
byTimestamp.set(timestamp, group);
}
if (byTimestamp.size === 1) {
const [timestamp, siteIds] = Array.from(
byTimestamp.entries()
)[0];
await db await db
.update(sites) .update(sites)
.set({ .set({
online: true, online: true,
lastPing: maxTimestamp lastPing: timestamp
}) })
.where(inArray(sites.siteId, siteIds)); .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) {
@@ -138,8 +144,6 @@ 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) {
@@ -155,25 +159,51 @@ async function flushClientPingsToDb(): Promise<void> {
// ── Flush client pings ───────────────────────────────────────────── // ── Flush client pings ─────────────────────────────────────────────
if (pingsToFlush.size > 0) { if (pingsToFlush.size > 0) {
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 < entries.length; i += BATCH_SIZE) { for (let i = 0; i < sortedEntries.length; i += BATCH_SIZE) {
const batch = entries.slice(i, i + BATCH_SIZE); const batch = sortedEntries.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[]>();
for (const [clientId, timestamp] of batch) {
const group = byTimestamp.get(timestamp) || [];
group.push(clientId);
byTimestamp.set(timestamp, group);
}
if (byTimestamp.size === 1) {
const [timestamp, clientIds] = Array.from(
byTimestamp.entries()
)[0];
await db await db
.update(clients) .update(clients)
.set({ .set({
lastPing: maxTimestamp, lastPing: timestamp,
online: true, online: true,
archived: false archived: false
}) })
.where(inArray(clients.clientId, clientIds)); .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(
@@ -230,12 +260,7 @@ export async function flushPingsToDb(): Promise<void> {
/** /**
* Simple retry wrapper with exponential backoff for transient errors * Simple retry wrapper with exponential backoff for transient errors
* (deadlocks, connection timeouts, unexpected disconnects). * (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>,
@@ -252,8 +277,7 @@ 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;
@@ -264,14 +288,14 @@ async function withRetry<T>(
} }
/** /**
* Detect transient errors that are safe to retry. * Detect transient connection 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 || error.cause?.code || ""; const code = error.code || "";
// Connection timeout / terminated // Connection timeout / terminated
if ( if (
@@ -284,17 +308,12 @@ function isTransientError(error: any): boolean {
return true; return true;
} }
// PostgreSQL deadlock detected — always safe to retry (one winner guaranteed) // PostgreSQL deadlock
if (code === "40P01" || message.includes("deadlock")) { if (code === "40P01" || message.includes("deadlock")) {
return true; return true;
} }
// PostgreSQL serialization failure // ECONNRESET, ECONNREFUSED, EPIPE
if (code === "40001") {
return true;
}
// ECONNRESET, ECONNREFUSED, EPIPE, ETIMEDOUT
if ( if (
code === "ECONNRESET" || code === "ECONNRESET" ||
code === "ECONNREFUSED" || code === "ECONNREFUSED" ||
@@ -318,26 +337,12 @@ 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);
@@ -359,22 +364,7 @@ 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) {

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 { getNextAvailableClientSubnet } from "@server/lib/ip"; import { v } from "@faker-js/faker/dist/airline-Dz1uGqgJ";
const bodySchema = z.object({ const bodySchema = z.object({
provisioningKey: z.string().nonempty(), provisioningKey: z.string().nonempty(),
@@ -152,11 +152,6 @@ 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") {
@@ -195,20 +190,6 @@ 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)
@@ -216,7 +197,6 @@ 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;
scheme: string | null; protocol: 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,
scheme: siteResources.scheme, protocol: siteResources.protocol,
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.scheme, protocol: siteResource.protocol,
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,12 +36,11 @@ 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", "http"]), mode: z.enum(["host", "cidr", "port"]),
ssl: z.boolean().optional(), // only used for http mode
siteId: z.int(), siteId: z.int(),
scheme: z.enum(["http", "https"]).optional(), // protocol: z.enum(["tcp", "udp"]).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
@@ -63,11 +62,7 @@ const createSiteResourceSchema = z
.strict() .strict()
.refine( .refine(
(data) => { (data) => {
if ( if (data.mode === "host") {
data.mode === "host" ||
data.mode == "http"
) {
if (data.mode == "host") {
// Check if it's a valid IP address using zod (v4 or v6) // Check if it's a valid IP address using zod (v4 or v6)
const isValidIP = z const isValidIP = z
// .union([z.ipv4(), z.ipv6()]) // .union([z.ipv4(), z.ipv6()])
@@ -77,7 +72,6 @@ const createSiteResourceSchema = z
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)
const domainRegex = const domainRegex =
@@ -111,21 +105,6 @@ 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>;
@@ -182,12 +161,11 @@ export async function createSiteResource(
name, name,
siteId, siteId,
mode, mode,
scheme, // protocol,
// proxyPort, // proxyPort,
destinationPort, // destinationPort,
destination, destination,
enabled, enabled,
ssl,
alias, alias,
userIds, userIds,
roleIds, roleIds,
@@ -248,6 +226,30 @@ 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
@@ -278,7 +280,8 @@ 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" || mode === "http") { if (mode == "host") {
// we can only have an alias on a host
aliasAddress = await getNextAvailableAliasAddress(orgId); aliasAddress = await getNextAvailableAliasAddress(orgId);
} }
@@ -290,11 +293,8 @@ export async function createSiteResource(
niceId, niceId,
orgId, orgId,
name, name,
mode, mode: mode as "host" | "cidr",
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", "http"]) .enum(["host", "cidr"])
.optional() .optional()
.catch(undefined) .catch(undefined)
.openapi({ .openapi({
type: "string", type: "string",
enum: ["host", "cidr", "http"], enum: ["host", "cidr"],
description: "Filter site resources by mode" description: "Filter site resources by mode"
}), }),
sort_by: z sort_by: z
@@ -88,8 +88,7 @@ function querySiteResourcesBase() {
niceId: siteResources.niceId, niceId: siteResources.niceId,
name: siteResources.name, name: siteResources.name,
mode: siteResources.mode, mode: siteResources.mode,
ssl: siteResources.ssl, protocol: siteResources.protocol,
scheme: siteResources.scheme,
proxyPort: siteResources.proxyPort, proxyPort: siteResources.proxyPort,
destinationPort: siteResources.destinationPort, destinationPort: siteResources.destinationPort,
destination: siteResources.destination, destination: siteResources.destination,
@@ -194,9 +193,7 @@ 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() querySiteResourcesBase().where(and(...conditions)).as("filtered_site_resources")
.where(and(...conditions))
.as("filtered_site_resources")
); );
const [siteResourcesList, totalCount] = await Promise.all([ const [siteResourcesList, totalCount] = await Promise.all([

View File

@@ -51,11 +51,10 @@ const updateSiteResourceSchema = z
) )
.optional(), .optional(),
// mode: z.enum(["host", "cidr", "port"]).optional(), // mode: z.enum(["host", "cidr", "port"]).optional(),
mode: z.enum(["host", "cidr", "http"]).optional(), mode: z.enum(["host", "cidr"]).optional(),
ssl: z.boolean().optional(), // protocol: z.enum(["tcp", "udp"]).nullish(),
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
@@ -77,12 +76,7 @@ const updateSiteResourceSchema = z
.strict() .strict()
.refine( .refine(
(data) => { (data) => {
if ( if (data.mode === "host" && data.destination) {
(data.mode === "host" ||
data.mode == "http") &&
data.destination
) {
if (data.mode == "host") {
const isValidIP = z const isValidIP = z
// .union([z.ipv4(), z.ipv6()]) // .union([z.ipv4(), z.ipv6()])
.union([z.ipv4()]) // for now lets just do ipv4 until we verify ipv6 works everywhere .union([z.ipv4()]) // for now lets just do ipv4 until we verify ipv6 works everywhere
@@ -91,7 +85,6 @@ const updateSiteResourceSchema = z
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)
const domainRegex = const domainRegex =
@@ -125,23 +118,6 @@ 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>;
@@ -199,11 +175,8 @@ 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,
@@ -373,10 +346,7 @@ 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,
@@ -479,10 +449,7 @@ 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,
@@ -651,11 +618,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 = await generateSubnetProxyTargetV2( const oldTarget = generateSubnetProxyTargetV2(
existingSiteResource, existingSiteResource,
mergedAllClients mergedAllClients
); );
const newTarget = await generateSubnetProxyTargetV2( const newTarget = generateSubnetProxyTargetV2(
updatedSiteResource, updatedSiteResource,
mergedAllClients mergedAllClients
); );

View File

@@ -235,9 +235,7 @@ 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")
SELECT ${row.inviteId}, ${row.roleId} VALUES (${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
`); `);
} }
@@ -260,10 +258,7 @@ 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")
SELECT ${row.userId}, ${row.orgId}, ${row.roleId} VALUES (${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' WHERE EXISTS (SELECT 1 FROM 'user' WHERE id = userOrgs.userId) AND EXISTS (SELECT 1 FROM 'orgs' WHERE orgId = userOrgs.orgId);` `INSERT INTO '__new_userOrgs'("userId", "orgId", "isOwner", "autoProvisioned", "pamUsername") SELECT "userId", "orgId", "isOwner", "autoProvisioned", "pamUsername" FROM 'userOrgs';`
).run(); ).run();
db.prepare(`DROP TABLE 'userOrgs';`).run(); db.prepare(`DROP TABLE 'userOrgs';`).run();
db.prepare( db.prepare(
@@ -246,15 +246,12 @@ 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") `INSERT OR IGNORE INTO 'userInviteRoles' ("inviteId", "roleId") VALUES (?, ?)`
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, row.inviteId, row.roleId); insertUserInviteRole.run(row.inviteId, row.roleId);
} }
}); });
@@ -268,16 +265,12 @@ 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") `INSERT OR IGNORE INTO 'userOrgRoles' ("userId", "orgId", "roleId") VALUES (?, ?, ?)`
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, row.userId, row.orgId, row.roleId); insertUserOrgRole.run(row.userId, row.orgId, row.roleId);
} }
}); });

View File

@@ -491,7 +491,7 @@ export default function ConnectionLogsPage() {
); );
}, },
cell: ({ row }) => { cell: ({ row }) => {
const clientType = row.original.userId ? "user" : "machine"; const clientType = row.original.clientType === "olm" ? "machine" : "user";
if (row.original.clientName && row.original.clientNiceId) { if (row.original.clientName && row.original.clientNiceId) {
return ( return (
<Link <Link

View File

@@ -56,30 +56,18 @@ 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: normalizedMode, mode: siteResource.mode || ("port" as any),
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,
httpHttpsPort: siteResource.destinationPort ?? null, // destinationPort: siteResource.destinationPort,
alias: siteResource.alias || null, alias: siteResource.alias || null,
aliasAddress: siteResource.aliasAddress || null, aliasAddress: siteResource.aliasAddress || null,
siteNiceId: siteResource.siteNiceId, siteNiceId: siteResource.siteNiceId,

View File

@@ -1,32 +0,0 @@
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,15 +46,13 @@ export type InternalResourceRow = {
siteName: string; siteName: string;
siteAddress: string | null; siteAddress: string | null;
// mode: "host" | "cidr" | "port"; // mode: "host" | "cidr" | "port";
mode: "host" | "cidr" | "http"; mode: "host" | "cidr";
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;
httpHttpsPort: number | null; // destinationPort: number | null;
alias: string | null; alias: string | null;
aliasAddress: string | null; aliasAddress: string | null;
niceId: string; niceId: string;
@@ -65,39 +63,6 @@ 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;
@@ -250,10 +215,6 @@ 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}
@@ -266,14 +227,10 @@ export default function ClientResourcesTable({
), ),
cell: ({ row }) => { cell: ({ row }) => {
const resourceRow = row.original; const resourceRow = row.original;
const modeLabels: Record< const modeLabels: Record<"host" | "cidr" | "port", string> = {
"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>;
} }
@@ -286,12 +243,11 @@ 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={display} text={resourceRow.destination}
isLink={false} isLink={false}
displayText={display} displayText={resourceRow.destination}
/> />
); );
} }
@@ -304,27 +260,16 @@ export default function ClientResourcesTable({
), ),
cell: ({ row }) => { cell: ({ row }) => {
const resourceRow = row.original; const resourceRow = row.original;
if (resourceRow.mode === "host" && resourceRow.alias) { return resourceRow.mode === "host" && resourceRow.alias ? (
return (
<CopyToClipboard <CopyToClipboard
text={resourceRow.alias} text={resourceRow.alias}
isLink={false} isLink={false}
displayText={resourceRow.alias} 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>;
}
}, },
{ {
accessorKey: "aliasAddress", accessorKey: "aliasAddress",

View File

@@ -50,10 +50,7 @@ export default function CreateInternalResourceDialog({
setIsSubmitting(true); setIsSubmitting(true);
try { try {
let data = { ...values }; let data = { ...values };
if ( if (data.mode === "host" && isHostname(data.destination)) {
(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;
@@ -72,42 +69,21 @@ export default function CreateInternalResourceDialog({
mode: data.mode, mode: data.mode,
destination: data.destination, destination: data.destination,
enabled: true, enabled: true,
...(data.mode === "http" && { alias: data.alias && typeof data.alias === "string" && data.alias.trim() ? data.alias : undefined,
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 && { ...(data.authDaemonMode != null && { authDaemonMode: data.authDaemonMode }),
authDaemonMode: data.authDaemonMode ...(data.authDaemonMode === "remote" && data.authDaemonPort != null && { authDaemonPort: data.authDaemonPort }),
}), 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 clientIds: data.clients ? data.clients.map((c) => parseInt(c.id)) : []
? data.clients.map((c) => parseInt(c.id))
: []
} }
); );
toast({ toast({
title: t("createInternalResourceDialogSuccess"), title: t("createInternalResourceDialogSuccess"),
description: t( description: t("createInternalResourceDialogInternalResourceCreatedSuccessfully"),
"createInternalResourceDialogInternalResourceCreatedSuccessfully"
),
variant: "default" variant: "default"
}); });
setOpen(false); setOpen(false);
@@ -117,9 +93,7 @@ export default function CreateInternalResourceDialog({
title: t("createInternalResourceDialogError"), title: t("createInternalResourceDialogError"),
description: formatAxiosError( description: formatAxiosError(
error, error,
t( t("createInternalResourceDialogFailedToCreateInternalResource")
"createInternalResourceDialogFailedToCreateInternalResource"
)
), ),
variant: "destructive" variant: "destructive"
}); });
@@ -132,13 +106,9 @@ 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> <CredenzaTitle>{t("createInternalResourceDialogCreateClientResource")}</CredenzaTitle>
{t("createInternalResourceDialogCreateClientResource")}
</CredenzaTitle>
<CredenzaDescription> <CredenzaDescription>
{t( {t("createInternalResourceDialogCreateClientResourceDescription")}
"createInternalResourceDialogCreateClientResourceDescription"
)}
</CredenzaDescription> </CredenzaDescription>
</CredenzaHeader> </CredenzaHeader>
<CredenzaBody> <CredenzaBody>
@@ -153,11 +123,7 @@ export default function CreateInternalResourceDialog({
</CredenzaBody> </CredenzaBody>
<CredenzaFooter> <CredenzaFooter>
<CredenzaClose asChild> <CredenzaClose asChild>
<Button <Button variant="outline" onClick={() => setOpen(false)} disabled={isSubmitting}>
variant="outline"
onClick={() => setOpen(false)}
disabled={isSubmitting}
>
{t("createInternalResourceDialogCancel")} {t("createInternalResourceDialogCancel")}
</Button> </Button>
</CredenzaClose> </CredenzaClose>

View File

@@ -163,18 +163,15 @@ 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: sub, subdomain:
fullDomain: sub ? `${sub}.${base}` : base, firstOrExistingDomain.type !== "cname"
baseDomain: base ? defaultSubdomain || undefined
: undefined,
fullDomain: firstOrExistingDomain.baseDomain,
baseDomain: firstOrExistingDomain.baseDomain
}); });
} }
} }
@@ -512,9 +509,7 @@ export default function DomainPicker({
<span className="truncate"> <span className="truncate">
{selectedBaseDomain.domain} {selectedBaseDomain.domain}
</span> </span>
{selectedBaseDomain.verified && {selectedBaseDomain.verified && (
selectedBaseDomain.domainType !==
"wildcard" && (
<CheckCircle2 className="h-3 w-3 text-green-500 shrink-0" /> <CheckCircle2 className="h-3 w-3 text-green-500 shrink-0" />
)} )}
</div> </div>
@@ -579,13 +574,6 @@ export default function DomainPicker({
} }
</span> </span>
<span className="text-xs text-muted-foreground"> <span className="text-xs text-muted-foreground">
{orgDomain.type ===
"wildcard"
? t(
"domainPickerManual"
)
: (
<>
{orgDomain.type.toUpperCase()}{" "} {orgDomain.type.toUpperCase()}{" "}
{" "} {" "}
{orgDomain.verified {orgDomain.verified
@@ -595,8 +583,6 @@ export default function DomainPicker({
: t( : t(
"domainPickerUnverified" "domainPickerUnverified"
)} )}
</>
)}
</span> </span>
</div> </div>
<Check <Check

View File

@@ -54,10 +54,7 @@ export default function EditInternalResourceDialog({
async function handleSubmit(values: InternalResourceFormValues) { async function handleSubmit(values: InternalResourceFormValues) {
try { try {
let data = { ...values }; let data = { ...values };
if ( if (data.mode === "host" && isHostname(data.destination)) {
(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;
@@ -74,11 +71,6 @@ 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" &&

View File

@@ -1,10 +1,6 @@
"use client"; "use client";
import { HorizontalTabs } from "@app/components/HorizontalTabs"; import { HorizontalTabs } from "@app/components/HorizontalTabs";
import {
OptionSelect,
type OptionSelectOption
} from "@app/components/OptionSelect";
import { PaidFeaturesAlert } from "@app/components/PaidFeaturesAlert"; import { PaidFeaturesAlert } from "@app/components/PaidFeaturesAlert";
import { StrategySelect } from "@app/components/StrategySelect"; import { StrategySelect } from "@app/components/StrategySelect";
import { Tag, TagInput } from "@app/components/tags/tag-input"; import { Tag, TagInput } from "@app/components/tags/tag-input";
@@ -49,8 +45,6 @@ import { z } from "zod";
import { SitesSelector, type Selectedsite } from "./site-selector"; import { SitesSelector, type Selectedsite } from "./site-selector";
import { CaretSortIcon } from "@radix-ui/react-icons"; import { CaretSortIcon } from "@radix-ui/react-icons";
import { MachinesSelector } from "./machines-selector"; import { MachinesSelector } from "./machines-selector";
import DomainPicker from "@app/components/DomainPicker";
import { SwitchInput } from "@app/components/SwitchInput";
// --- Helpers (shared) --- // --- Helpers (shared) ---
@@ -126,14 +120,12 @@ export const cleanForFQDN = (name: string): string =>
type Site = ListSitesResponse["sites"][0]; type Site = ListSitesResponse["sites"][0];
export type InternalResourceMode = "host" | "cidr" | "http";
export type InternalResourceData = { export type InternalResourceData = {
id: number; id: number;
name: string; name: string;
orgId: string; orgId: string;
siteName: string; siteName: string;
mode: InternalResourceMode; mode: "host" | "cidr";
siteId: number; siteId: number;
niceId: string; niceId: string;
destination: string; destination: string;
@@ -143,12 +135,6 @@ export type InternalResourceData = {
disableIcmp?: boolean; disableIcmp?: boolean;
authDaemonMode?: "site" | "remote" | null; authDaemonMode?: "site" | "remote" | null;
authDaemonPort?: number | null; authDaemonPort?: number | null;
httpHttpsPort?: number | null;
scheme?: "http" | "https" | null;
ssl?: boolean;
httpConfigSubdomain?: string | null;
httpConfigDomainId?: string | null;
httpConfigFullDomain?: string | null;
}; };
const tagSchema = z.object({ id: z.string(), text: z.string() }); const tagSchema = z.object({ id: z.string(), text: z.string() });
@@ -156,7 +142,7 @@ const tagSchema = z.object({ id: z.string(), text: z.string() });
export type InternalResourceFormValues = { export type InternalResourceFormValues = {
name: string; name: string;
siteId: number; siteId: number;
mode: InternalResourceMode; mode: "host" | "cidr";
destination: string; destination: string;
alias?: string | null; alias?: string | null;
niceId?: string; niceId?: string;
@@ -165,12 +151,6 @@ export type InternalResourceFormValues = {
disableIcmp?: boolean; disableIcmp?: boolean;
authDaemonMode?: "site" | "remote" | null; authDaemonMode?: "site" | "remote" | null;
authDaemonPort?: number | null; authDaemonPort?: number | null;
httpHttpsPort?: number | null;
scheme?: "http" | "https";
ssl?: boolean;
httpConfigSubdomain?: string | null;
httpConfigDomainId?: string | null;
httpConfigFullDomain?: string | null;
roles?: z.infer<typeof tagSchema>[]; roles?: z.infer<typeof tagSchema>[];
users?: z.infer<typeof tagSchema>[]; users?: z.infer<typeof tagSchema>[];
clients?: z.infer<typeof tagSchema>[]; clients?: z.infer<typeof tagSchema>[];
@@ -231,22 +211,6 @@ export function InternalResourceForm({
variant === "create" variant === "create"
? "createInternalResourceDialogModeCidr" ? "createInternalResourceDialogModeCidr"
: "editInternalResourceDialogModeCidr"; : "editInternalResourceDialogModeCidr";
const modeHttpKey =
variant === "create"
? "createInternalResourceDialogModeHttp"
: "editInternalResourceDialogModeHttp";
const schemeLabelKey =
variant === "create"
? "createInternalResourceDialogScheme"
: "editInternalResourceDialogScheme";
const enableSslLabelKey =
variant === "create"
? "createInternalResourceDialogEnableSsl"
: "editInternalResourceDialogEnableSsl";
const enableSslDescriptionKey =
variant === "create"
? "createInternalResourceDialogEnableSslDescription"
: "editInternalResourceDialogEnableSslDescription";
const destinationLabelKey = const destinationLabelKey =
variant === "create" variant === "create"
? "createInternalResourceDialogDestination" ? "createInternalResourceDialogDestination"
@@ -259,27 +223,14 @@ export function InternalResourceForm({
variant === "create" variant === "create"
? "createInternalResourceDialogAlias" ? "createInternalResourceDialogAlias"
: "editInternalResourceDialogAlias"; : "editInternalResourceDialogAlias";
const httpHttpsPortLabelKey =
variant === "create"
? "createInternalResourceDialogModePort"
: "editInternalResourceDialogModePort";
const httpConfigurationTitleKey =
variant === "create"
? "createInternalResourceDialogHttpConfiguration"
: "editInternalResourceDialogHttpConfiguration";
const httpConfigurationDescriptionKey =
variant === "create"
? "createInternalResourceDialogHttpConfigurationDescription"
: "editInternalResourceDialogHttpConfigurationDescription";
const formSchema = z const formSchema = z.object({
.object({
name: z.string().min(1, t(nameRequiredKey)).max(255, t(nameMaxKey)), name: z.string().min(1, t(nameRequiredKey)).max(255, t(nameMaxKey)),
siteId: z siteId: z
.number() .number()
.int() .int()
.positive(siteRequiredKey ? t(siteRequiredKey) : undefined), .positive(siteRequiredKey ? t(siteRequiredKey) : undefined),
mode: z.enum(["host", "cidr", "http"]), mode: z.enum(["host", "cidr"]),
destination: z destination: z
.string() .string()
.min( .min(
@@ -289,18 +240,6 @@ export function InternalResourceForm({
: undefined : undefined
), ),
alias: z.string().nullish(), alias: z.string().nullish(),
httpHttpsPort: z
.number()
.int()
.min(1)
.max(65535)
.optional()
.nullable(),
scheme: z.enum(["http", "https"]).optional(),
ssl: z.boolean().optional(),
httpConfigSubdomain: z.string().nullish(),
httpConfigDomainId: z.string().nullish(),
httpConfigFullDomain: z.string().nullish(),
niceId: z niceId: z
.string() .string()
.min(1) .min(1)
@@ -322,27 +261,6 @@ export function InternalResourceForm({
}) })
) )
.optional() .optional()
})
.superRefine((data, ctx) => {
if (data.mode !== "http") return;
if (!data.scheme) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: t("internalResourceDownstreamSchemeRequired"),
path: ["scheme"]
});
}
if (
data.httpHttpsPort == null ||
!Number.isFinite(data.httpHttpsPort) ||
data.httpHttpsPort < 1
) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: t("internalResourceHttpPortRequired"),
path: ["httpHttpsPort"]
});
}
}); });
type FormData = z.infer<typeof formSchema>; type FormData = z.infer<typeof formSchema>;
@@ -476,12 +394,6 @@ export function InternalResourceForm({
disableIcmp: resource.disableIcmp ?? false, disableIcmp: resource.disableIcmp ?? false,
authDaemonMode: resource.authDaemonMode ?? "site", authDaemonMode: resource.authDaemonMode ?? "site",
authDaemonPort: resource.authDaemonPort ?? null, authDaemonPort: resource.authDaemonPort ?? null,
httpHttpsPort: resource.httpHttpsPort ?? null,
scheme: resource.scheme ?? "http",
ssl: resource.ssl ?? false,
httpConfigSubdomain: resource.httpConfigSubdomain ?? null,
httpConfigDomainId: resource.httpConfigDomainId ?? null,
httpConfigFullDomain: resource.httpConfigFullDomain ?? null,
niceId: resource.niceId, niceId: resource.niceId,
roles: [], roles: [],
users: [], users: [],
@@ -493,12 +405,6 @@ export function InternalResourceForm({
mode: "host", mode: "host",
destination: "", destination: "",
alias: null, alias: null,
httpHttpsPort: null,
scheme: "http",
ssl: true,
httpConfigSubdomain: null,
httpConfigDomainId: null,
httpConfigFullDomain: null,
tcpPortRangeString: "*", tcpPortRangeString: "*",
udpPortRangeString: "*", udpPortRangeString: "*",
disableIcmp: false, disableIcmp: false,
@@ -519,10 +425,6 @@ export function InternalResourceForm({
}); });
const mode = form.watch("mode"); const mode = form.watch("mode");
const httpConfigSubdomain = form.watch("httpConfigSubdomain");
const httpConfigDomainId = form.watch("httpConfigDomainId");
const httpConfigFullDomain = form.watch("httpConfigFullDomain");
const isHttpMode = mode === "http";
const authDaemonMode = form.watch("authDaemonMode") ?? "site"; const authDaemonMode = form.watch("authDaemonMode") ?? "site";
const hasInitialized = useRef(false); const hasInitialized = useRef(false);
const previousResourceId = useRef<number | null>(null); const previousResourceId = useRef<number | null>(null);
@@ -546,12 +448,6 @@ export function InternalResourceForm({
mode: "host", mode: "host",
destination: "", destination: "",
alias: null, alias: null,
httpHttpsPort: null,
scheme: "http",
ssl: true,
httpConfigSubdomain: null,
httpConfigDomainId: null,
httpConfigFullDomain: null,
tcpPortRangeString: "*", tcpPortRangeString: "*",
udpPortRangeString: "*", udpPortRangeString: "*",
disableIcmp: false, disableIcmp: false,
@@ -579,12 +475,6 @@ export function InternalResourceForm({
mode: resource.mode ?? "host", mode: resource.mode ?? "host",
destination: resource.destination ?? "", destination: resource.destination ?? "",
alias: resource.alias ?? null, alias: resource.alias ?? null,
httpHttpsPort: resource.httpHttpsPort ?? null,
scheme: resource.scheme ?? "http",
ssl: resource.ssl ?? false,
httpConfigSubdomain: resource.httpConfigSubdomain ?? null,
httpConfigDomainId: resource.httpConfigDomainId ?? null,
httpConfigFullDomain: resource.httpConfigFullDomain ?? null,
tcpPortRangeString: resource.tcpPortRangeString ?? "*", tcpPortRangeString: resource.tcpPortRangeString ?? "*",
udpPortRangeString: resource.udpPortRangeString ?? "*", udpPortRangeString: resource.udpPortRangeString ?? "*",
disableIcmp: resource.disableIcmp ?? false, disableIcmp: resource.disableIcmp ?? false,
@@ -691,50 +581,12 @@ export function InternalResourceForm({
)} )}
/> />
)} )}
</div>
<HorizontalTabs
clientSide
items={[
{
title: t(
"editInternalResourceDialogNetworkSettings"
),
href: "#"
},
{
title: t("editInternalResourceDialogAccessPolicy"),
href: "#"
},
...(disableEnterpriseFeatures || mode !== "host"
? []
: [{ title: t("sshAccess"), href: "#" }])
]}
>
<div className="space-y-4 mt-4 p-1">
<div>
<div className="mb-8">
<label className="font-medium block">
{t(
"editInternalResourceDialogDestinationLabel"
)}
</label>
<div className="text-sm text-muted-foreground">
{t(
"editInternalResourceDialogDestinationDescription"
)}
</div>
</div>
<div className="grid grid-cols-3 gap-4 items-start mb-4">
<div className="min-w-0 col-span-1">
<FormField <FormField
control={form.control} control={form.control}
name="siteId" name="siteId"
render={({ field }) => ( render={({ field }) => (
<FormItem className="flex flex-col"> <FormItem className="flex flex-col">
<FormLabel> <FormLabel>{t("site")}</FormLabel>
{t("site")}
</FormLabel>
<Popover> <Popover>
<PopoverTrigger asChild> <PopoverTrigger asChild>
<FormControl> <FormControl>
@@ -753,9 +605,7 @@ export function InternalResourceForm({
s.siteId === s.siteId ===
field.value field.value
)?.name )?.name
: t( : t("selectSite")}
"selectSite"
)}
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" /> <ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button> </Button>
</FormControl> </FormControl>
@@ -763,21 +613,11 @@ export function InternalResourceForm({
<PopoverContent className="w-full p-0"> <PopoverContent className="w-full p-0">
<SitesSelector <SitesSelector
orgId={orgId} orgId={orgId}
selectedSite={ selectedSite={selectedSite}
selectedSite filterTypes={["newt"]}
} onSelectSite={(site) => {
filterTypes={[ setSelectedSite(site);
"newt" field.onChange(site.siteId);
]}
onSelectSite={(
site
) => {
setSelectedSite(
site
);
field.onChange(
site.siteId
);
}} }}
/> />
</PopoverContent> </PopoverContent>
@@ -787,84 +627,79 @@ export function InternalResourceForm({
)} )}
/> />
</div> </div>
<div className="min-w-0 col-span-2">
<FormField <HorizontalTabs
control={form.control} clientSide
name="mode" items={[
render={({ field }) => {
const modeOptions: OptionSelectOption<InternalResourceMode>[] =
[
{ {
value: "host", title: t(
label: t(modeHostKey) "editInternalResourceDialogNetworkSettings"
),
href: "#"
}, },
{ {
value: "cidr", title: t("editInternalResourceDialogAccessPolicy"),
label: t(modeCidrKey) href: "#"
}, },
{ ...(disableEnterpriseFeatures || mode === "cidr"
value: "http", ? []
label: t(modeHttpKey) : [{ title: t("sshAccess"), href: "#" }])
} ]}
]; >
return ( <div className="space-y-4 mt-4 p-1">
<FormItem> <div>
<FormLabel> <div className="mb-8">
{t(modeLabelKey)} <label className="font-medium block">
</FormLabel> {t(
<OptionSelect<InternalResourceMode> "editInternalResourceDialogDestinationLabel"
options={modeOptions} )}
value={field.value} </label>
onChange={ <div className="text-sm text-muted-foreground">
field.onChange {t(
} "editInternalResourceDialogDestinationDescription"
cols={3} )}
/>
<FormMessage />
</FormItem>
);
}}
/>
</div> </div>
</div> </div>
<div <div
className={cn( className={cn(
"grid gap-4 items-start", "grid gap-4 items-start",
mode === "cidr" && "grid-cols-1", mode === "cidr"
mode === "http" && "grid-cols-3", ? "grid-cols-4"
mode === "host" && "grid-cols-2" : "grid-cols-12"
)} )}
> >
{mode === "http" && ( <div
<div className="min-w-0"> className={
mode === "cidr"
? "col-span-1"
: "col-span-3"
}
>
<FormField <FormField
control={form.control} control={form.control}
name="scheme" name="mode"
render={({ field }) => ( render={({ field }) => (
<FormItem> <FormItem>
<FormLabel> <FormLabel>
{t(schemeLabelKey)} {t(modeLabelKey)}
</FormLabel> </FormLabel>
<Select <Select
onValueChange={ onValueChange={
field.onChange field.onChange
} }
value={ value={field.value}
field.value ??
"http"
}
> >
<FormControl> <FormControl>
<SelectTrigger className="w-full"> <SelectTrigger>
<SelectValue /> <SelectValue />
</SelectTrigger> </SelectTrigger>
</FormControl> </FormControl>
<SelectContent> <SelectContent>
<SelectItem value="http"> <SelectItem value="host">
http {t(modeHostKey)}
</SelectItem> </SelectItem>
<SelectItem value="https"> <SelectItem value="cidr">
https {t(modeCidrKey)}
</SelectItem> </SelectItem>
</SelectContent> </SelectContent>
</Select> </Select>
@@ -873,13 +708,12 @@ export function InternalResourceForm({
)} )}
/> />
</div> </div>
)}
<div <div
className={cn( className={
mode === "cidr" && "col-span-1", mode === "cidr"
(mode === "http" || mode === "host") && ? "col-span-3"
"min-w-0" : "col-span-5"
)} }
> >
<FormField <FormField
control={form.control} control={form.control}
@@ -890,18 +724,15 @@ export function InternalResourceForm({
{t(destinationLabelKey)} {t(destinationLabelKey)}
</FormLabel> </FormLabel>
<FormControl> <FormControl>
<Input <Input {...field} />
{...field}
className="w-full"
/>
</FormControl> </FormControl>
<FormMessage /> <FormMessage />
</FormItem> </FormItem>
)} )}
/> />
</div> </div>
{mode === "host" && ( {mode !== "cidr" && (
<div className="min-w-0"> <div className="col-span-4">
<FormField <FormField
control={form.control} control={form.control}
name="alias" name="alias"
@@ -913,7 +744,6 @@ export function InternalResourceForm({
<FormControl> <FormControl>
<Input <Input
{...field} {...field}
className="w-full"
value={ value={
field.value ?? field.value ??
"" ""
@@ -926,144 +756,9 @@ export function InternalResourceForm({
/> />
</div> </div>
)} )}
{mode === "http" && (
<div className="min-w-0">
<FormField
control={form.control}
name="httpHttpsPort"
render={({ field }) => (
<FormItem>
<FormLabel>
{t(
httpHttpsPortLabelKey
)}
</FormLabel>
<FormControl>
<Input
className="w-full"
type="number"
min={1}
max={65535}
value={
field.value ??
""
}
onChange={(e) => {
const raw =
e.target
.value;
if (
raw === ""
) {
field.onChange(
null
);
return;
}
const n =
Number(raw);
field.onChange(
Number.isFinite(
n
)
? n
: null
);
}}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
)}
</div> </div>
</div> </div>
{isHttpMode ? (
<div className="space-y-4">
<div className="my-8">
<label className="font-medium block">
{t(httpConfigurationTitleKey)}
</label>
<div className="text-sm text-muted-foreground">
{t(httpConfigurationDescriptionKey)}
</div>
</div>
<DomainPicker
key={
variant === "edit" && siteResourceId
? `http-domain-${siteResourceId}`
: "http-domain-create"
}
orgId={orgId}
cols={2}
hideFreeDomain
defaultSubdomain={
httpConfigSubdomain ?? undefined
}
defaultDomainId={
httpConfigDomainId ?? undefined
}
defaultFullDomain={
httpConfigFullDomain ?? undefined
}
onDomainChange={(res) => {
if (res === null) {
form.setValue(
"httpConfigSubdomain",
null
);
form.setValue(
"httpConfigDomainId",
null
);
form.setValue(
"httpConfigFullDomain",
null
);
form.setValue("alias", null);
return;
}
form.setValue(
"httpConfigSubdomain",
res.subdomain ?? null
);
form.setValue(
"httpConfigDomainId",
res.domainId
);
form.setValue(
"httpConfigFullDomain",
res.fullDomain
);
form.setValue("alias", res.fullDomain);
}}
/>
<FormField
control={form.control}
name="ssl"
render={({ field }) => (
<FormItem>
<FormControl>
<SwitchInput
id="internal-resource-ssl"
label={t(enableSslLabelKey)}
description={t(
enableSslDescriptionKey
)}
checked={!!field.value}
onCheckedChange={
field.onChange
}
/>
</FormControl>
</FormItem>
)}
/>
</div>
) : (
<div className="space-y-4"> <div className="space-y-4">
<div className="my-8"> <div className="my-8">
<label className="font-medium block"> <label className="font-medium block">
@@ -1111,11 +806,7 @@ export function InternalResourceForm({
value={tcpPortMode} value={tcpPortMode}
onValueChange={( onValueChange={(
v: PortMode v: PortMode
) => ) => setTcpPortMode(v)}
setTcpPortMode(
v
)
}
> >
<FormControl> <FormControl>
<SelectTrigger className="w-[110px]"> <SelectTrigger className="w-[110px]">
@@ -1124,19 +815,13 @@ export function InternalResourceForm({
</FormControl> </FormControl>
<SelectContent> <SelectContent>
<SelectItem value="all"> <SelectItem value="all">
{t( {t("allPorts")}
"allPorts"
)}
</SelectItem> </SelectItem>
<SelectItem value="blocked"> <SelectItem value="blocked">
{t( {t("blocked")}
"blocked"
)}
</SelectItem> </SelectItem>
<SelectItem value="custom"> <SelectItem value="custom">
{t( {t("custom")}
"custom"
)}
</SelectItem> </SelectItem>
</SelectContent> </SelectContent>
</Select> </Select>
@@ -1148,12 +833,9 @@ export function InternalResourceForm({
value={ value={
tcpCustomPorts tcpCustomPorts
} }
onChange={( onChange={(e) =>
e
) =>
setTcpCustomPorts( setTcpCustomPorts(
e e.target
.target
.value .value
) )
} }
@@ -1217,11 +899,7 @@ export function InternalResourceForm({
value={udpPortMode} value={udpPortMode}
onValueChange={( onValueChange={(
v: PortMode v: PortMode
) => ) => setUdpPortMode(v)}
setUdpPortMode(
v
)
}
> >
<FormControl> <FormControl>
<SelectTrigger className="w-[110px]"> <SelectTrigger className="w-[110px]">
@@ -1230,19 +908,13 @@ export function InternalResourceForm({
</FormControl> </FormControl>
<SelectContent> <SelectContent>
<SelectItem value="all"> <SelectItem value="all">
{t( {t("allPorts")}
"allPorts"
)}
</SelectItem> </SelectItem>
<SelectItem value="blocked"> <SelectItem value="blocked">
{t( {t("blocked")}
"blocked"
)}
</SelectItem> </SelectItem>
<SelectItem value="custom"> <SelectItem value="custom">
{t( {t("custom")}
"custom"
)}
</SelectItem> </SelectItem>
</SelectContent> </SelectContent>
</Select> </Select>
@@ -1254,12 +926,9 @@ export function InternalResourceForm({
value={ value={
udpCustomPorts udpCustomPorts
} }
onChange={( onChange={(e) =>
e
) =>
setUdpCustomPorts( setUdpCustomPorts(
e e.target
.target
.value .value
) )
} }
@@ -1303,9 +972,7 @@ export function InternalResourceForm({
} }
> >
<FormLabel className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"> <FormLabel className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70">
{t( {t("editInternalResourceDialogIcmp")}
"editInternalResourceDialogIcmp"
)}
</FormLabel> </FormLabel>
</div> </div>
<div <div
@@ -1348,7 +1015,6 @@ export function InternalResourceForm({
</div> </div>
</div> </div>
</div> </div>
)}
</div> </div>
<div className="space-y-4 mt-4 p-1"> <div className="space-y-4 mt-4 p-1">
@@ -1547,8 +1213,8 @@ export function InternalResourceForm({
)} )}
</div> </div>
{/* SSH Access tab (host mode only) */} {/* SSH Access tab */}
{!disableEnterpriseFeatures && mode === "host" && ( {!disableEnterpriseFeatures && mode !== "cidr" && (
<div className="space-y-4 mt-4 p-1"> <div className="space-y-4 mt-4 p-1">
<PaidFeaturesAlert tiers={tierMatrix.sshPam} /> <PaidFeaturesAlert tiers={tierMatrix.sshPam} />
<div className="mb-8"> <div className="mb-8">