Compare commits

..

2 Commits

Author SHA1 Message Date
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
52 changed files with 1001 additions and 1493 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

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

@@ -222,18 +222,12 @@ export const exitNodes = pgTable("exitNodes", {
export const siteResources = pgTable("siteResources", { export const siteResources = pgTable("siteResources", {
// this is for the clients // this is for the clients
siteResourceId: serial("siteResourceId").primaryKey(), siteResourceId: serial("siteResourceId").primaryKey(),
siteId: integer("siteId")
.notNull()
.references(() => sites.siteId, { onDelete: "cascade" }),
orgId: varchar("orgId") orgId: varchar("orgId")
.notNull() .notNull()
.references(() => orgs.orgId, { onDelete: "cascade" }), .references(() => orgs.orgId, { onDelete: "cascade" }),
networkId: integer("networkId").references(() => networks.networkId, {
onDelete: "set null"
}),
defaultNetworkId: integer("defaultNetworkId").references(
() => networks.networkId,
{
onDelete: "restrict"
}
),
niceId: varchar("niceId").notNull(), niceId: varchar("niceId").notNull(),
name: varchar("name").notNull(), name: varchar("name").notNull(),
mode: varchar("mode").$type<"host" | "cidr">().notNull(), // "host" | "cidr" | "port" mode: varchar("mode").$type<"host" | "cidr">().notNull(), // "host" | "cidr" | "port"
@@ -253,32 +247,6 @@ export const siteResources = pgTable("siteResources", {
.default("site") .default("site")
}); });
export const networks = pgTable("networks", {
networkId: serial("networkId").primaryKey(),
niceId: text("niceId"),
name: text("name"),
scope: varchar("scope")
.$type<"global" | "resource">()
.notNull()
.default("global"),
orgId: varchar("orgId")
.references(() => orgs.orgId, {
onDelete: "cascade"
})
.notNull()
});
export const siteNetworks = pgTable("siteNetworks", {
siteId: integer("siteId")
.notNull()
.references(() => sites.siteId, {
onDelete: "cascade"
}),
networkId: integer("networkId")
.notNull()
.references(() => networks.networkId, { onDelete: "cascade" })
});
export const clientSiteResources = pgTable("clientSiteResources", { export const clientSiteResources = pgTable("clientSiteResources", {
clientId: integer("clientId") clientId: integer("clientId")
.notNull() .notNull()
@@ -1138,4 +1106,3 @@ export type RequestAuditLog = InferSelectModel<typeof requestAuditLog>;
export type RoundTripMessageTracker = InferSelectModel< export type RoundTripMessageTracker = InferSelectModel<
typeof roundTripMessageTracker typeof roundTripMessageTracker
>; >;
export type Network = InferSelectModel<typeof networks>;

View File

@@ -92,9 +92,6 @@ export const sites = sqliteTable("sites", {
exitNodeId: integer("exitNode").references(() => exitNodes.exitNodeId, { exitNodeId: integer("exitNode").references(() => exitNodes.exitNodeId, {
onDelete: "set null" onDelete: "set null"
}), }),
networkId: integer("networkId").references(() => networks.networkId, {
onDelete: "set null"
}),
name: text("name").notNull(), name: text("name").notNull(),
pubKey: text("pubKey"), pubKey: text("pubKey"),
subnet: text("subnet"), subnet: text("subnet"),
@@ -253,16 +250,12 @@ export const siteResources = sqliteTable("siteResources", {
siteResourceId: integer("siteResourceId").primaryKey({ siteResourceId: integer("siteResourceId").primaryKey({
autoIncrement: true autoIncrement: true
}), }),
siteId: integer("siteId")
.notNull()
.references(() => sites.siteId, { onDelete: "cascade" }),
orgId: text("orgId") orgId: text("orgId")
.notNull() .notNull()
.references(() => orgs.orgId, { onDelete: "cascade" }), .references(() => orgs.orgId, { onDelete: "cascade" }),
networkId: integer("networkId").references(() => networks.networkId, {
onDelete: "set null"
}),
defaultNetworkId: integer("defaultNetworkId").references(
() => networks.networkId,
{ onDelete: "restrict" }
),
niceId: text("niceId").notNull(), niceId: text("niceId").notNull(),
name: text("name").notNull(), name: text("name").notNull(),
mode: text("mode").$type<"host" | "cidr">().notNull(), // "host" | "cidr" | "port" mode: text("mode").$type<"host" | "cidr">().notNull(), // "host" | "cidr" | "port"
@@ -284,30 +277,6 @@ export const siteResources = sqliteTable("siteResources", {
.default("site") .default("site")
}); });
export const networks = sqliteTable("networks", {
networkId: integer("networkId").primaryKey({ autoIncrement: true }),
niceId: text("niceId"),
name: text("name"),
scope: text("scope")
.$type<"global" | "resource">()
.notNull()
.default("global"),
orgId: text("orgId")
.notNull()
.references(() => orgs.orgId, { onDelete: "cascade" })
});
export const siteNetworks = sqliteTable("siteNetworks", {
siteId: integer("siteId")
.notNull()
.references(() => sites.siteId, {
onDelete: "cascade"
}),
networkId: integer("networkId")
.notNull()
.references(() => networks.networkId, { onDelete: "cascade" })
});
export const clientSiteResources = sqliteTable("clientSiteResources", { export const clientSiteResources = sqliteTable("clientSiteResources", {
clientId: integer("clientId") clientId: integer("clientId")
.notNull() .notNull()
@@ -1226,7 +1195,6 @@ export type ApiKey = InferSelectModel<typeof apiKeys>;
export type ApiKeyAction = InferSelectModel<typeof apiKeyActions>; export type ApiKeyAction = InferSelectModel<typeof apiKeyActions>;
export type ApiKeyOrg = InferSelectModel<typeof apiKeyOrg>; export type ApiKeyOrg = InferSelectModel<typeof apiKeyOrg>;
export type SiteResource = InferSelectModel<typeof siteResources>; export type SiteResource = InferSelectModel<typeof siteResources>;
export type Network = InferSelectModel<typeof networks>;
export type OrgDomains = InferSelectModel<typeof orgDomains>; export type OrgDomains = InferSelectModel<typeof orgDomains>;
export type SetupToken = InferSelectModel<typeof setupTokens>; export type SetupToken = InferSelectModel<typeof setupTokens>;
export type HostMeta = InferSelectModel<typeof hostMeta>; export type HostMeta = InferSelectModel<typeof hostMeta>;

View File

@@ -121,8 +121,8 @@ export async function applyBlueprint({
for (const result of clientResourcesResults) { for (const result of clientResourcesResults) {
if ( if (
result.oldSiteResource && result.oldSiteResource &&
JSON.stringify(result.newSites?.sort()) !== result.oldSiteResource.siteId !=
JSON.stringify(result.oldSites?.sort()) result.newSiteResource.siteId
) { ) {
// query existing associations // query existing associations
const existingRoleIds = await trx const existingRoleIds = await trx
@@ -222,46 +222,38 @@ export async function applyBlueprint({
trx trx
); );
} else { } else {
let good = true; const [newSite] = await trx
for (const newSite of result.newSites) { .select()
const [site] = await trx .from(sites)
.select() .innerJoin(newts, eq(sites.siteId, newts.siteId))
.from(sites) .where(
.innerJoin(newts, eq(sites.siteId, newts.siteId)) and(
.where( eq(sites.siteId, result.newSiteResource.siteId),
and( eq(sites.orgId, orgId),
eq(sites.siteId, newSite.siteId), eq(sites.type, "newt"),
eq(sites.orgId, orgId), isNotNull(sites.pubKey)
eq(sites.type, "newt"),
isNotNull(sites.pubKey)
)
) )
.limit(1); )
.limit(1);
if (!site) {
logger.debug(
`No newt sites found for client resource ${result.newSiteResource.siteResourceId}, skipping target update`
);
good = false;
break;
}
if (!newSite) {
logger.debug( logger.debug(
`Updating client resource ${result.newSiteResource.siteResourceId} on site ${newSite.siteId}` `No newt site found for client resource ${result.newSiteResource.siteResourceId}, skipping target update`
); );
}
if (!good) {
continue; continue;
} }
logger.debug(
`Updating client resource ${result.newSiteResource.siteResourceId} on site ${newSite.sites.siteId}`
);
await handleMessagingForUpdatedSiteResource( await handleMessagingForUpdatedSiteResource(
result.oldSiteResource, result.oldSiteResource,
result.newSiteResource, result.newSiteResource,
result.newSites.map((site) => ({ {
siteId: site.siteId, siteId: newSite.sites.siteId,
orgId: result.newSiteResource.orgId orgId: newSite.sites.orgId
})), },
trx trx
); );
} }

View File

@@ -3,15 +3,12 @@ import {
clientSiteResources, clientSiteResources,
roles, roles,
roleSiteResources, roleSiteResources,
Site,
SiteResource, SiteResource,
siteNetworks,
siteResources, siteResources,
Transaction, Transaction,
userOrgs, userOrgs,
users, users,
userSiteResources, userSiteResources
networks
} from "@server/db"; } from "@server/db";
import { sites } from "@server/db"; import { sites } from "@server/db";
import { eq, and, ne, inArray, or } from "drizzle-orm"; import { eq, and, ne, inArray, or } from "drizzle-orm";
@@ -22,8 +19,6 @@ import { getNextAvailableAliasAddress } from "../ip";
export type ClientResourcesResults = { export type ClientResourcesResults = {
newSiteResource: SiteResource; newSiteResource: SiteResource;
oldSiteResource?: SiteResource; oldSiteResource?: SiteResource;
newSites: { siteId: number }[];
oldSites: { siteId: number }[];
}[]; }[];
export async function updateClientResources( export async function updateClientResources(
@@ -48,70 +43,36 @@ export async function updateClientResources(
) )
.limit(1); .limit(1);
const existingSiteIds = existingResource?.networkId const resourceSiteId = resourceData.site;
? await trx let site;
.select({ siteId: sites.siteId })
.from(siteNetworks)
.where(eq(siteNetworks.networkId, existingResource.networkId))
: [];
let allSites: { siteId: number }[] = []; if (resourceSiteId) {
if (resourceData.site) { // Look up site by niceId
let siteSingle; [site] = await trx
const resourceSiteId = resourceData.site; .select({ siteId: sites.siteId })
.from(sites)
if (resourceSiteId) { .where(
// Look up site by niceId and(
[siteSingle] = await trx eq(sites.niceId, resourceSiteId),
.select({ siteId: sites.siteId }) eq(sites.orgId, orgId)
.from(sites)
.where(
and(
eq(sites.niceId, resourceSiteId),
eq(sites.orgId, orgId)
)
) )
.limit(1); )
} else if (siteId) { .limit(1);
// Use the provided siteId directly, but verify it belongs to the org } else if (siteId) {
[siteSingle] = await trx // Use the provided siteId directly, but verify it belongs to the org
.select({ siteId: sites.siteId }) [site] = await trx
.from(sites) .select({ siteId: sites.siteId })
.where( .from(sites)
and(eq(sites.siteId, siteId), eq(sites.orgId, orgId)) .where(and(eq(sites.siteId, siteId), eq(sites.orgId, orgId)))
) .limit(1);
.limit(1); } else {
} else { throw new Error(`Target site is required`);
throw new Error(`Target site is required`);
}
if (!siteSingle) {
throw new Error(
`Site not found: ${resourceSiteId} in org ${orgId}`
);
}
allSites.push(siteSingle);
} }
if (resourceData.sites) { if (!site) {
for (const siteNiceId of resourceData.sites) { throw new Error(
const [site] = await trx `Site not found: ${resourceSiteId} in org ${orgId}`
.select({ siteId: sites.siteId }) );
.from(sites)
.where(
and(
eq(sites.niceId, siteNiceId),
eq(sites.orgId, orgId)
)
)
.limit(1);
if (!site) {
throw new Error(
`Site not found: ${siteId} in org ${orgId}`
);
}
allSites.push(site);
}
} }
if (existingResource) { if (existingResource) {
@@ -120,6 +81,7 @@ export async function updateClientResources(
.update(siteResources) .update(siteResources)
.set({ .set({
name: resourceData.name || resourceNiceId, name: resourceData.name || resourceNiceId,
siteId: site.siteId,
mode: resourceData.mode, mode: resourceData.mode,
destination: resourceData.destination, destination: resourceData.destination,
enabled: true, // hardcoded for now enabled: true, // hardcoded for now
@@ -140,21 +102,6 @@ export async function updateClientResources(
const siteResourceId = existingResource.siteResourceId; const siteResourceId = existingResource.siteResourceId;
const orgId = existingResource.orgId; const orgId = existingResource.orgId;
if (updatedResource.networkId) {
await trx
.delete(siteNetworks)
.where(
eq(siteNetworks.networkId, updatedResource.networkId)
);
for (const site of allSites) {
await trx.insert(siteNetworks).values({
siteId: site.siteId,
networkId: updatedResource.networkId
});
}
}
await trx await trx
.delete(clientSiteResources) .delete(clientSiteResources)
.where(eq(clientSiteResources.siteResourceId, siteResourceId)); .where(eq(clientSiteResources.siteResourceId, siteResourceId));
@@ -257,9 +204,7 @@ export async function updateClientResources(
results.push({ results.push({
newSiteResource: updatedResource, newSiteResource: updatedResource,
oldSiteResource: existingResource, oldSiteResource: existingResource
newSites: allSites,
oldSites: existingSiteIds
}); });
} else { } else {
let aliasAddress: string | null = null; let aliasAddress: string | null = null;
@@ -268,22 +213,13 @@ export async function updateClientResources(
aliasAddress = await getNextAvailableAliasAddress(orgId); aliasAddress = await getNextAvailableAliasAddress(orgId);
} }
const [network] = await trx
.insert(networks)
.values({
scope: "resource",
orgId: orgId
})
.returning();
// Create new resource // Create new resource
const [newResource] = await trx const [newResource] = await trx
.insert(siteResources) .insert(siteResources)
.values({ .values({
orgId: orgId, orgId: orgId,
siteId: site.siteId,
niceId: resourceNiceId, niceId: resourceNiceId,
networkId: network.networkId,
defaultNetworkId: network.networkId,
name: resourceData.name || resourceNiceId, name: resourceData.name || resourceNiceId,
mode: resourceData.mode, mode: resourceData.mode,
destination: resourceData.destination, destination: resourceData.destination,
@@ -299,13 +235,6 @@ export async function updateClientResources(
const siteResourceId = newResource.siteResourceId; const siteResourceId = newResource.siteResourceId;
for (const site of allSites) {
await trx.insert(siteNetworks).values({
siteId: site.siteId,
networkId: network.networkId
});
}
const [adminRole] = await trx const [adminRole] = await trx
.select() .select()
.from(roles) .from(roles)
@@ -395,11 +324,7 @@ export async function updateClientResources(
`Created new client resource ${newResource.name} (${newResource.siteResourceId}) for org ${orgId}` `Created new client resource ${newResource.name} (${newResource.siteResourceId}) for org ${orgId}`
); );
results.push({ results.push({ newSiteResource: newResource });
newSiteResource: newResource,
newSites: allSites,
oldSites: existingSiteIds
});
} }
} }

View File

@@ -326,8 +326,7 @@ export const ClientResourceSchema = z
.object({ .object({
name: z.string().min(1).max(255), name: z.string().min(1).max(255),
mode: z.enum(["host", "cidr"]), mode: z.enum(["host", "cidr"]),
site: z.string(), // DEPRECATED IN FAVOR OF sites site: z.string(),
sites: z.array(z.string()).optional().default([]),
// protocol: z.enum(["tcp", "udp"]).optional(), // protocol: z.enum(["tcp", "udp"]).optional(),
// proxyPort: z.int().positive().optional(), // proxyPort: z.int().positive().optional(),
// destinationPort: z.int().positive().optional(), // destinationPort: z.int().positive().optional(),

View File

@@ -11,11 +11,11 @@ import {
roleSiteResources, roleSiteResources,
Site, Site,
SiteResource, SiteResource,
siteNetworks,
siteResources, siteResources,
sites, sites,
Transaction, Transaction,
userOrgRoles, userOrgRoles,
userOrgs,
userSiteResources userSiteResources
} from "@server/db"; } from "@server/db";
import { and, eq, inArray, ne } from "drizzle-orm"; import { and, eq, inArray, ne } from "drizzle-orm";
@@ -48,23 +48,15 @@ export async function getClientSiteResourceAccess(
siteResource: SiteResource, siteResource: SiteResource,
trx: Transaction | typeof db = db trx: Transaction | typeof db = db
) { ) {
// get all sites associated with this siteResource via its network // get the site
const sitesList = siteResource.networkId const [site] = await trx
? await trx .select()
.select() .from(sites)
.from(sites) .where(eq(sites.siteId, siteResource.siteId))
.innerJoin( .limit(1);
siteNetworks,
eq(siteNetworks.siteId, sites.siteId)
)
.where(eq(siteNetworks.networkId, siteResource.networkId))
.then((rows) => rows.map((row) => row.sites))
: [];
if (sitesList.length === 0) { if (!site) {
logger.warn( throw new Error(`Site with ID ${siteResource.siteId} not found`);
`No sites found for siteResource ${siteResource.siteResourceId} with networkId ${siteResource.networkId}`
);
} }
const roleIds = await trx const roleIds = await trx
@@ -145,7 +137,7 @@ export async function getClientSiteResourceAccess(
const mergedAllClientIds = mergedAllClients.map((c) => c.clientId); const mergedAllClientIds = mergedAllClients.map((c) => c.clientId);
return { return {
sitesList, site,
mergedAllClients, mergedAllClients,
mergedAllClientIds mergedAllClientIds
}; };
@@ -161,51 +153,40 @@ export async function rebuildClientAssociationsFromSiteResource(
subnet: string | null; subnet: string | null;
}[]; }[];
}> { }> {
const { sitesList, mergedAllClients, mergedAllClientIds } = const siteId = siteResource.siteId;
const { site, mergedAllClients, mergedAllClientIds } =
await getClientSiteResourceAccess(siteResource, trx); await getClientSiteResourceAccess(siteResource, trx);
/////////// process the client-siteResource associations /////////// /////////// process the client-siteResource associations ///////////
// get all of the clients associated with other resources in the same network, // get all of the clients associated with other resources on this site
// joined through siteNetworks so we know which siteId each client belongs to const allUpdatedClientsFromOtherResourcesOnThisSite = await trx
const allUpdatedClientsFromOtherResourcesOnThisSite = siteResource.networkId .select({
? await trx clientId: clientSiteResourcesAssociationsCache.clientId
.select({ })
clientId: clientSiteResourcesAssociationsCache.clientId, .from(clientSiteResourcesAssociationsCache)
siteId: siteNetworks.siteId .innerJoin(
}) siteResources,
.from(clientSiteResourcesAssociationsCache) eq(
.innerJoin( clientSiteResourcesAssociationsCache.siteResourceId,
siteResources, siteResources.siteResourceId
eq( )
clientSiteResourcesAssociationsCache.siteResourceId, )
siteResources.siteResourceId .where(
) and(
) eq(siteResources.siteId, siteId),
.innerJoin( ne(siteResources.siteResourceId, siteResource.siteResourceId)
siteNetworks, )
eq(siteNetworks.networkId, siteResources.networkId) );
)
.where(
and(
eq(siteResources.networkId, siteResource.networkId),
ne(
siteResources.siteResourceId,
siteResource.siteResourceId
)
)
)
: [];
// Build a per-site map so the loop below can check by siteId rather than const allClientIdsFromOtherResourcesOnThisSite = Array.from(
// across the entire network. new Set(
const clientsFromOtherResourcesBySite = new Map<number, Set<number>>(); allUpdatedClientsFromOtherResourcesOnThisSite.map(
for (const row of allUpdatedClientsFromOtherResourcesOnThisSite) { (row) => row.clientId
if (!clientsFromOtherResourcesBySite.has(row.siteId)) { )
clientsFromOtherResourcesBySite.set(row.siteId, new Set()); )
} );
clientsFromOtherResourcesBySite.get(row.siteId)!.add(row.clientId);
}
const existingClientSiteResources = await trx const existingClientSiteResources = await trx
.select({ .select({
@@ -279,90 +260,82 @@ export async function rebuildClientAssociationsFromSiteResource(
/////////// process the client-site associations /////////// /////////// process the client-site associations ///////////
for (const site of sitesList) { const existingClientSites = await trx
const siteId = site.siteId; .select({
clientId: clientSitesAssociationsCache.clientId
})
.from(clientSitesAssociationsCache)
.where(eq(clientSitesAssociationsCache.siteId, siteResource.siteId));
const existingClientSites = await trx const existingClientSiteIds = existingClientSites.map(
.select({ (row) => row.clientId
clientId: clientSitesAssociationsCache.clientId );
})
.from(clientSitesAssociationsCache)
.where(eq(clientSitesAssociationsCache.siteId, siteId));
const existingClientSiteIds = existingClientSites.map( // Get full client details for existing clients (needed for sending delete messages)
(row) => row.clientId const existingClients = await trx
); .select({
clientId: clients.clientId,
pubKey: clients.pubKey,
subnet: clients.subnet
})
.from(clients)
.where(inArray(clients.clientId, existingClientSiteIds));
// Get full client details for existing clients (needed for sending delete messages) const clientSitesToAdd = mergedAllClientIds.filter(
const existingClients = (clientId) =>
existingClientSiteIds.length > 0 !existingClientSiteIds.includes(clientId) &&
? await trx !allClientIdsFromOtherResourcesOnThisSite.includes(clientId) // dont remove if there is still another connection for another site resource
.select({ );
clientId: clients.clientId,
pubKey: clients.pubKey,
subnet: clients.subnet
})
.from(clients)
.where(inArray(clients.clientId, existingClientSiteIds))
: [];
const otherResourceClientIds = clientsFromOtherResourcesBySite.get(siteId) ?? new Set<number>(); const clientSitesToInsert = clientSitesToAdd.map((clientId) => ({
clientId,
siteId
}));
const clientSitesToAdd = mergedAllClientIds.filter( if (clientSitesToInsert.length > 0) {
(clientId) => await trx
!existingClientSiteIds.includes(clientId) && .insert(clientSitesAssociationsCache)
!otherResourceClientIds.has(clientId) // dont add if already connected via another site resource .values(clientSitesToInsert)
); .returning();
const clientSitesToInsert = clientSitesToAdd.map((clientId) => ({
clientId,
siteId
}));
if (clientSitesToInsert.length > 0) {
await trx
.insert(clientSitesAssociationsCache)
.values(clientSitesToInsert)
.returning();
}
// Now remove any client-site associations that should no longer exist
const clientSitesToRemove = existingClientSiteIds.filter(
(clientId) =>
!mergedAllClientIds.includes(clientId) &&
!otherResourceClientIds.has(clientId) // dont remove if there is still another connection for another site resource
);
if (clientSitesToRemove.length > 0) {
await trx
.delete(clientSitesAssociationsCache)
.where(
and(
eq(clientSitesAssociationsCache.siteId, siteId),
inArray(
clientSitesAssociationsCache.clientId,
clientSitesToRemove
)
)
);
}
// Now handle the messages to add/remove peers on both the newt and olm sides
await handleMessagesForSiteClients(
site,
siteId,
mergedAllClients,
existingClients,
clientSitesToAdd,
clientSitesToRemove,
trx
);
} }
// Now remove any client-site associations that should no longer exist
const clientSitesToRemove = existingClientSiteIds.filter(
(clientId) =>
!mergedAllClientIds.includes(clientId) &&
!allClientIdsFromOtherResourcesOnThisSite.includes(clientId) // dont remove if there is still another connection for another site resource
);
if (clientSitesToRemove.length > 0) {
await trx
.delete(clientSitesAssociationsCache)
.where(
and(
eq(clientSitesAssociationsCache.siteId, siteId),
inArray(
clientSitesAssociationsCache.clientId,
clientSitesToRemove
)
)
);
}
/////////// send the messages ///////////
// Now handle the messages to add/remove peers on both the newt and olm sides
await handleMessagesForSiteClients(
site,
siteId,
mergedAllClients,
existingClients,
clientSitesToAdd,
clientSitesToRemove,
trx
);
// Handle subnet proxy target updates for the resource associations // Handle subnet proxy target updates for the resource associations
await handleSubnetProxyTargetUpdates( await handleSubnetProxyTargetUpdates(
siteResource, siteResource,
sitesList,
mergedAllClients, mergedAllClients,
existingResourceClients, existingResourceClients,
clientSiteResourcesToAdd, clientSiteResourcesToAdd,
@@ -651,7 +624,6 @@ export async function updateClientSiteDestinations(
async function handleSubnetProxyTargetUpdates( async function handleSubnetProxyTargetUpdates(
siteResource: SiteResource, siteResource: SiteResource,
sitesList: Site[],
allClients: { allClients: {
clientId: number; clientId: number;
pubKey: string | null; pubKey: string | null;
@@ -666,138 +638,125 @@ async function handleSubnetProxyTargetUpdates(
clientSiteResourcesToRemove: number[], clientSiteResourcesToRemove: number[],
trx: Transaction | typeof db = db trx: Transaction | typeof db = db
): Promise<void> { ): Promise<void> {
const proxyJobs: Promise<any>[] = []; // Get the newt for this site
const olmJobs: Promise<any>[] = []; const [newt] = await trx
.select()
.from(newts)
.where(eq(newts.siteId, siteResource.siteId))
.limit(1);
for (const siteData of sitesList) { if (!newt) {
const siteId = siteData.siteId; logger.warn(
`Newt not found for site ${siteResource.siteId}, skipping subnet proxy target updates`
);
return;
}
// Get the newt for this site const proxyJobs = [];
const [newt] = await trx const olmJobs = [];
.select() // Generate targets for added associations
.from(newts) if (clientSiteResourcesToAdd.length > 0) {
.where(eq(newts.siteId, siteId)) const addedClients = allClients.filter((client) =>
.limit(1); clientSiteResourcesToAdd.includes(client.clientId)
);
if (!newt) { if (addedClients.length > 0) {
logger.warn( const targetToAdd = generateSubnetProxyTargetV2(
`Newt not found for site ${siteId}, skipping subnet proxy target updates` siteResource,
); addedClients
continue;
}
// Generate targets for added associations
if (clientSiteResourcesToAdd.length > 0) {
const addedClients = allClients.filter((client) =>
clientSiteResourcesToAdd.includes(client.clientId)
); );
if (addedClients.length > 0) { if (targetToAdd) {
const targetToAdd = generateSubnetProxyTargetV2( proxyJobs.push(
siteResource, addSubnetProxyTargets(
addedClients newt.newtId,
[targetToAdd],
newt.version
)
); );
}
if (targetToAdd) { for (const client of addedClients) {
proxyJobs.push( olmJobs.push(
addSubnetProxyTargets( addPeerData(
newt.newtId, client.clientId,
[targetToAdd], siteResource.siteId,
newt.version generateRemoteSubnets([siteResource]),
) generateAliasConfig([siteResource])
); )
} );
for (const client of addedClients) {
olmJobs.push(
addPeerData(
client.clientId,
siteId,
generateRemoteSubnets([siteResource]),
generateAliasConfig([siteResource])
)
);
}
} }
} }
}
// here we use the existingSiteResource from BEFORE we updated the destination so we dont need to worry about updating destinations here // here we use the existingSiteResource from BEFORE we updated the destination so we dont need to worry about updating destinations here
// Generate targets for removed associations // Generate targets for removed associations
if (clientSiteResourcesToRemove.length > 0) { if (clientSiteResourcesToRemove.length > 0) {
const removedClients = existingClients.filter((client) => const removedClients = existingClients.filter((client) =>
clientSiteResourcesToRemove.includes(client.clientId) clientSiteResourcesToRemove.includes(client.clientId)
);
if (removedClients.length > 0) {
const targetToRemove = generateSubnetProxyTargetV2(
siteResource,
removedClients
); );
if (removedClients.length > 0) { if (targetToRemove) {
const targetToRemove = generateSubnetProxyTargetV2( proxyJobs.push(
siteResource, removeSubnetProxyTargets(
removedClients newt.newtId,
[targetToRemove],
newt.version
)
); );
}
if (targetToRemove) { for (const client of removedClients) {
proxyJobs.push( // Check if this client still has access to another resource on this site with the same destination
removeSubnetProxyTargets( const destinationStillInUse = await trx
newt.newtId, .select()
[targetToRemove], .from(siteResources)
newt.version .innerJoin(
clientSiteResourcesAssociationsCache,
eq(
clientSiteResourcesAssociationsCache.siteResourceId,
siteResources.siteResourceId
) )
); )
} .where(
and(
for (const client of removedClients) {
// Check if this client still has access to another resource
// on this specific site with the same destination. We scope
// by siteId (via siteNetworks) rather than networkId because
// removePeerData operates per-site — a resource on a different
// site sharing the same network should not block removal here.
const destinationStillInUse = await trx
.select()
.from(siteResources)
.innerJoin(
clientSiteResourcesAssociationsCache,
eq( eq(
clientSiteResourcesAssociationsCache.siteResourceId, clientSiteResourcesAssociationsCache.clientId,
siteResources.siteResourceId client.clientId
),
eq(siteResources.siteId, siteResource.siteId),
eq(
siteResources.destination,
siteResource.destination
),
ne(
siteResources.siteResourceId,
siteResource.siteResourceId
) )
) )
.innerJoin(
siteNetworks,
eq(siteNetworks.networkId, siteResources.networkId)
)
.where(
and(
eq(
clientSiteResourcesAssociationsCache.clientId,
client.clientId
),
eq(siteNetworks.siteId, siteId),
eq(
siteResources.destination,
siteResource.destination
),
ne(
siteResources.siteResourceId,
siteResource.siteResourceId
)
)
);
// Only remove remote subnet if no other resource uses the same destination
const remoteSubnetsToRemove =
destinationStillInUse.length > 0
? []
: generateRemoteSubnets([siteResource]);
olmJobs.push(
removePeerData(
client.clientId,
siteId,
remoteSubnetsToRemove,
generateAliasConfig([siteResource])
)
); );
}
// Only remove remote subnet if no other resource uses the same destination
const remoteSubnetsToRemove =
destinationStillInUse.length > 0
? []
: generateRemoteSubnets([siteResource]);
olmJobs.push(
removePeerData(
client.clientId,
siteResource.siteId,
remoteSubnetsToRemove,
generateAliasConfig([siteResource])
)
);
} }
} }
} }
@@ -904,25 +863,10 @@ export async function rebuildClientAssociationsFromClient(
) )
: []; : [];
// Group by siteId for site-level associations — look up via siteNetworks since // Group by siteId for site-level associations
// siteResources no longer carries a direct siteId column. const newSiteIds = Array.from(
const networkIds = Array.from( new Set(newSiteResources.map((sr) => sr.siteId))
new Set(
newSiteResources
.map((sr) => sr.networkId)
.filter((id): id is number => id !== null)
)
); );
const newSiteIds =
networkIds.length > 0
? await trx
.select({ siteId: siteNetworks.siteId })
.from(siteNetworks)
.where(inArray(siteNetworks.networkId, networkIds))
.then((rows) =>
Array.from(new Set(rows.map((r) => r.siteId)))
)
: [];
/////////// Process client-siteResource associations /////////// /////////// Process client-siteResource associations ///////////
@@ -1195,45 +1139,13 @@ async function handleMessagesForClientResources(
resourcesToAdd.includes(r.siteResourceId) resourcesToAdd.includes(r.siteResourceId)
); );
// Build (resource, siteId) pairs by looking up siteNetworks for each resource's networkId
const addedNetworkIds = Array.from(
new Set(
addedResources
.map((r) => r.networkId)
.filter((id): id is number => id !== null)
)
);
const addedSiteNetworkRows =
addedNetworkIds.length > 0
? await trx
.select({
networkId: siteNetworks.networkId,
siteId: siteNetworks.siteId
})
.from(siteNetworks)
.where(inArray(siteNetworks.networkId, addedNetworkIds))
: [];
const addedNetworkToSites = new Map<number, number[]>();
for (const row of addedSiteNetworkRows) {
if (!addedNetworkToSites.has(row.networkId)) {
addedNetworkToSites.set(row.networkId, []);
}
addedNetworkToSites.get(row.networkId)!.push(row.siteId);
}
// Group by site for proxy updates // Group by site for proxy updates
const addedBySite = new Map<number, SiteResource[]>(); const addedBySite = new Map<number, SiteResource[]>();
for (const resource of addedResources) { for (const resource of addedResources) {
const siteIds = if (!addedBySite.has(resource.siteId)) {
resource.networkId != null addedBySite.set(resource.siteId, []);
? (addedNetworkToSites.get(resource.networkId) ?? [])
: [];
for (const siteId of siteIds) {
if (!addedBySite.has(siteId)) {
addedBySite.set(siteId, []);
}
addedBySite.get(siteId)!.push(resource);
} }
addedBySite.get(resource.siteId)!.push(resource);
} }
// Add subnet proxy targets for each site // Add subnet proxy targets for each site
@@ -1275,7 +1187,7 @@ async function handleMessagesForClientResources(
olmJobs.push( olmJobs.push(
addPeerData( addPeerData(
client.clientId, client.clientId,
siteId, resource.siteId,
generateRemoteSubnets([resource]), generateRemoteSubnets([resource]),
generateAliasConfig([resource]) generateAliasConfig([resource])
) )
@@ -1287,7 +1199,7 @@ async function handleMessagesForClientResources(
error.message.includes("not found") error.message.includes("not found")
) { ) {
logger.debug( logger.debug(
`Olm data not found for client ${client.clientId} and site ${siteId}, skipping addition` `Olm data not found for client ${client.clientId} and site ${resource.siteId}, skipping removal`
); );
} else { } else {
throw error; throw error;
@@ -1304,45 +1216,13 @@ async function handleMessagesForClientResources(
.from(siteResources) .from(siteResources)
.where(inArray(siteResources.siteResourceId, resourcesToRemove)); .where(inArray(siteResources.siteResourceId, resourcesToRemove));
// Build (resource, siteId) pairs via siteNetworks
const removedNetworkIds = Array.from(
new Set(
removedResources
.map((r) => r.networkId)
.filter((id): id is number => id !== null)
)
);
const removedSiteNetworkRows =
removedNetworkIds.length > 0
? await trx
.select({
networkId: siteNetworks.networkId,
siteId: siteNetworks.siteId
})
.from(siteNetworks)
.where(inArray(siteNetworks.networkId, removedNetworkIds))
: [];
const removedNetworkToSites = new Map<number, number[]>();
for (const row of removedSiteNetworkRows) {
if (!removedNetworkToSites.has(row.networkId)) {
removedNetworkToSites.set(row.networkId, []);
}
removedNetworkToSites.get(row.networkId)!.push(row.siteId);
}
// Group by site for proxy updates // Group by site for proxy updates
const removedBySite = new Map<number, SiteResource[]>(); const removedBySite = new Map<number, SiteResource[]>();
for (const resource of removedResources) { for (const resource of removedResources) {
const siteIds = if (!removedBySite.has(resource.siteId)) {
resource.networkId != null removedBySite.set(resource.siteId, []);
? (removedNetworkToSites.get(resource.networkId) ?? [])
: [];
for (const siteId of siteIds) {
if (!removedBySite.has(siteId)) {
removedBySite.set(siteId, []);
}
removedBySite.get(siteId)!.push(resource);
} }
removedBySite.get(resource.siteId)!.push(resource);
} }
// Remove subnet proxy targets for each site // Remove subnet proxy targets for each site
@@ -1380,11 +1260,7 @@ async function handleMessagesForClientResources(
} }
try { try {
// Check if this client still has access to another resource // Check if this client still has access to another resource on this site with the same destination
// on this specific site with the same destination. We scope
// by siteId (via siteNetworks) rather than networkId because
// removePeerData operates per-site — a resource on a different
// site sharing the same network should not block removal here.
const destinationStillInUse = await trx const destinationStillInUse = await trx
.select() .select()
.from(siteResources) .from(siteResources)
@@ -1395,17 +1271,13 @@ async function handleMessagesForClientResources(
siteResources.siteResourceId siteResources.siteResourceId
) )
) )
.innerJoin(
siteNetworks,
eq(siteNetworks.networkId, siteResources.networkId)
)
.where( .where(
and( and(
eq( eq(
clientSiteResourcesAssociationsCache.clientId, clientSiteResourcesAssociationsCache.clientId,
client.clientId client.clientId
), ),
eq(siteNetworks.siteId, siteId), eq(siteResources.siteId, resource.siteId),
eq( eq(
siteResources.destination, siteResources.destination,
resource.destination resource.destination
@@ -1427,7 +1299,7 @@ async function handleMessagesForClientResources(
olmJobs.push( olmJobs.push(
removePeerData( removePeerData(
client.clientId, client.clientId,
siteId, resource.siteId,
remoteSubnetsToRemove, remoteSubnetsToRemove,
generateAliasConfig([resource]) generateAliasConfig([resource])
) )
@@ -1439,7 +1311,7 @@ async function handleMessagesForClientResources(
error.message.includes("not found") error.message.includes("not found")
) { ) {
logger.debug( logger.debug(
`Olm data not found for client ${client.clientId} and site ${siteId}, skipping removal` `Olm data not found for client ${client.clientId} and site ${resource.siteId}, skipping removal`
); );
} else { } else {
throw error; throw error;

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 (
@@ -492,7 +495,7 @@ export async function getTraefikConfig(
if (target.health == "unhealthy") { if (target.health == "unhealthy") {
return false; return false;
} }
// If any sites are online, exclude offline sites // If any sites are online, exclude offline sites
if (anySitesOnline && !target.site.online) { if (anySitesOnline && !target.site.online) {
return false; return false;
@@ -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
@@ -615,7 +621,7 @@ export async function getTraefikConfig(
if (!target.enabled) { if (!target.enabled) {
return false; return false;
} }
// If any sites are online, exclude offline sites // If any sites are online, exclude offline sites
if (anySitesOnline && !target.site.online) { if (anySitesOnline && !target.site.online) {
return false; return false;

View File

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

@@ -671,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 (
@@ -799,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

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

@@ -21,7 +21,7 @@ import {
roles, roles,
roundTripMessageTracker, roundTripMessageTracker,
siteResources, siteResources,
siteNetworks, sites,
userOrgs userOrgs
} from "@server/db"; } from "@server/db";
import { logAccessAudit } from "#private/lib/logAccessAudit"; import { logAccessAudit } from "#private/lib/logAccessAudit";
@@ -63,12 +63,10 @@ const bodySchema = z
export type SignSshKeyResponse = { export type SignSshKeyResponse = {
certificate: string; certificate: string;
messageIds: number[];
messageId: number; messageId: number;
sshUsername: string; sshUsername: string;
sshHost: string; sshHost: string;
resourceId: number; resourceId: number;
siteIds: number[];
siteId: number; siteId: number;
keyId: string; keyId: string;
validPrincipals: string[]; validPrincipals: string[];
@@ -262,7 +260,10 @@ export async function signSshKey(
.update(userOrgs) .update(userOrgs)
.set({ pamUsername: usernameToUse }) .set({ pamUsername: usernameToUse })
.where( .where(
and(eq(userOrgs.orgId, orgId), eq(userOrgs.userId, userId)) and(
eq(userOrgs.orgId, orgId),
eq(userOrgs.userId, userId)
)
); );
} else { } else {
usernameToUse = userOrg.pamUsername; usernameToUse = userOrg.pamUsername;
@@ -394,12 +395,21 @@ export async function signSshKey(
homedir = roleRows[0].sshCreateHomeDir ?? null; homedir = roleRows[0].sshCreateHomeDir ?? null;
} }
const sites = await db // get the site
.select({ siteId: siteNetworks.siteId }) const [newt] = await db
.from(siteNetworks) .select()
.where(eq(siteNetworks.networkId, resource.networkId!)); .from(newts)
.where(eq(newts.siteId, resource.siteId))
.limit(1);
const siteIds = sites.map((site) => site.siteId); if (!newt) {
return next(
createHttpError(
HttpCode.INTERNAL_SERVER_ERROR,
"Site associated with resource not found"
)
);
}
// Sign the public key // Sign the public key
const now = BigInt(Math.floor(Date.now() / 1000)); const now = BigInt(Math.floor(Date.now() / 1000));
@@ -413,65 +423,44 @@ export async function signSshKey(
validBefore: now + validFor validBefore: now + validFor
}); });
const messageIds: number[] = []; const [message] = await db
for (const siteId of siteIds) { .insert(roundTripMessageTracker)
// get the site .values({
const [newt] = await db wsClientId: newt.newtId,
.select() messageType: `newt/pam/connection`,
.from(newts) sentAt: Math.floor(Date.now() / 1000)
.where(eq(newts.siteId, siteId)) })
.limit(1); .returning();
if (!newt) { if (!message) {
return next( return next(
createHttpError( createHttpError(
HttpCode.INTERNAL_SERVER_ERROR, HttpCode.INTERNAL_SERVER_ERROR,
"Site associated with resource not found" "Failed to create message tracker entry"
) )
); );
}
const [message] = await db
.insert(roundTripMessageTracker)
.values({
wsClientId: newt.newtId,
messageType: `newt/pam/connection`,
sentAt: Math.floor(Date.now() / 1000)
})
.returning();
if (!message) {
return next(
createHttpError(
HttpCode.INTERNAL_SERVER_ERROR,
"Failed to create message tracker entry"
)
);
}
messageIds.push(message.messageId);
await sendToClient(newt.newtId, {
type: `newt/pam/connection`,
data: {
messageId: message.messageId,
orgId: orgId,
agentPort: resource.authDaemonPort ?? 22123,
externalAuthDaemon: resource.authDaemonMode === "remote",
agentHost: resource.destination,
caCert: caKeys.publicKeyOpenSSH,
username: usernameToUse,
niceId: resource.niceId,
metadata: {
sudoMode: sudoMode,
sudoCommands: parsedSudoCommands,
homedir: homedir,
groups: parsedGroups
}
}
});
} }
await sendToClient(newt.newtId, {
type: `newt/pam/connection`,
data: {
messageId: message.messageId,
orgId: orgId,
agentPort: resource.authDaemonPort ?? 22123,
externalAuthDaemon: resource.authDaemonMode === "remote",
agentHost: resource.destination,
caCert: caKeys.publicKeyOpenSSH,
username: usernameToUse,
niceId: resource.niceId,
metadata: {
sudoMode: sudoMode,
sudoCommands: parsedSudoCommands,
homedir: homedir,
groups: parsedGroups
}
}
});
const expiresIn = Number(validFor); // seconds const expiresIn = Number(validFor); // seconds
let sshHost; let sshHost;
@@ -491,7 +480,7 @@ export async function signSshKey(
metadata: JSON.stringify({ metadata: JSON.stringify({
resourceId: resource.siteResourceId, resourceId: resource.siteResourceId,
resource: resource.name, resource: resource.name,
siteIds: siteIds siteId: resource.siteId,
}) })
}); });
@@ -516,13 +505,11 @@ export async function signSshKey(
return response<SignSshKeyResponse>(res, { return response<SignSshKeyResponse>(res, {
data: { data: {
certificate: cert.certificate, certificate: cert.certificate,
messageIds: messageIds, messageId: message.messageId,
messageId: messageIds[0], // just pick the first one for backward compatibility
sshUsername: usernameToUse, sshUsername: usernameToUse,
sshHost: sshHost, sshHost: sshHost,
resourceId: resource.siteResourceId, resourceId: resource.siteResourceId,
siteIds: siteIds, siteId: resource.siteId,
siteId: siteIds[0], // just pick the first one for backward compatibility
keyId: cert.keyId, keyId: cert.keyId,
validPrincipals: cert.validPrincipals, validPrincipals: cert.validPrincipals,
validAfter: cert.validAfter.toISOString(), validAfter: cert.validAfter.toISOString(),

View File

@@ -171,8 +171,9 @@ export async function flushSiteBandwidthToDb(): Promise<void> {
} }
// PostgreSQL: batch UPDATE … FROM (VALUES …) — single round-trip per chunk. // PostgreSQL: batch UPDATE … FROM (VALUES …) — single round-trip per chunk.
const valuesList = chunk.map(([publicKey, { bytesIn, bytesOut }]) => const valuesList = chunk.map(
sql`(${publicKey}::text, ${bytesIn}::real, ${bytesOut}::real)` ([publicKey, { bytesIn, bytesOut }]) =>
sql`(${publicKey}, ${bytesIn}::bigint, ${bytesOut}::bigint)`
); );
const valuesClause = sql.join(valuesList, sql`, `); const valuesClause = sql.join(valuesList, sql`, `);
return dbQueryRows<{ orgId: string; pubKey: string }>(sql` return dbQueryRows<{ orgId: string; pubKey: string }>(sql`

View File

@@ -4,10 +4,8 @@ import {
clientSitesAssociationsCache, clientSitesAssociationsCache,
db, db,
ExitNode, ExitNode,
networks,
resources, resources,
Site, Site,
siteNetworks,
siteResources, siteResources,
targetHealthCheck, targetHealthCheck,
targets targets
@@ -139,14 +137,11 @@ export async function buildClientConfigurationForNewtClient(
// Filter out any null values from peers that didn't have an olm // Filter out any null values from peers that didn't have an olm
const validPeers = peers.filter((peer) => peer !== null); const validPeers = peers.filter((peer) => peer !== null);
// Get all enabled site resources for this site by joining through siteNetworks and networks // Get all enabled site resources for this site
const allSiteResources = await db const allSiteResources = await db
.select() .select()
.from(siteResources) .from(siteResources)
.innerJoin(networks, eq(siteResources.networkId, networks.networkId)) .where(eq(siteResources.siteId, siteId));
.innerJoin(siteNetworks, eq(networks.networkId, siteNetworks.networkId))
.where(eq(siteNetworks.siteId, siteId))
.then((rows) => rows.map((r) => r.siteResources));
const targetsToSend: SubnetProxyTargetV2[] = []; const targetsToSend: SubnetProxyTargetV2[] = [];

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 () => {
await db // Group by timestamp for efficient bulk updates
.update(sites) const byTimestamp = new Map<number, number[]>();
.set({ for (const [siteId, timestamp] of batch) {
online: true, const group = byTimestamp.get(timestamp) || [];
lastPing: maxTimestamp group.push(siteId);
}) byTimestamp.set(timestamp, group);
.where(inArray(sites.siteId, siteIds)); }
if (byTimestamp.size === 1) {
const [timestamp, siteIds] = Array.from(
byTimestamp.entries()
)[0];
await db
.update(sites)
.set({
online: true,
lastPing: timestamp
})
.where(inArray(sites.siteId, siteIds));
} else {
await db.transaction(async (tx) => {
for (const [timestamp, siteIds] of byTimestamp) {
await tx
.update(sites)
.set({
online: true,
lastPing: timestamp
})
.where(inArray(sites.siteId, siteIds));
}
});
}
}, "flushSitePingsToDb"); }, "flushSitePingsToDb");
} catch (error) { } catch (error) {
logger.error( logger.error(
`Failed to flush site ping batch (${batch.length} sites), re-queuing for next cycle`, `Failed to flush site ping batch (${batch.length} sites), re-queuing for next cycle`,
{ error } { error }
); );
// Re-queue only if the preserved timestamp is newer than any
// update that may have landed since we snapshotted.
for (const [siteId, timestamp] of batch) { for (const [siteId, timestamp] of batch) {
const existing = pendingSitePings.get(siteId); const existing = pendingSitePings.get(siteId);
if (!existing || existing < timestamp) { if (!existing || existing < timestamp) {
@@ -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 () => {
await db const byTimestamp = new Map<number, number[]>();
.update(clients) for (const [clientId, timestamp] of batch) {
.set({ const group = byTimestamp.get(timestamp) || [];
lastPing: maxTimestamp, group.push(clientId);
online: true, byTimestamp.set(timestamp, group);
archived: false }
})
.where(inArray(clients.clientId, clientIds)); if (byTimestamp.size === 1) {
const [timestamp, clientIds] = Array.from(
byTimestamp.entries()
)[0];
await db
.update(clients)
.set({
lastPing: timestamp,
online: true,
archived: false
})
.where(inArray(clients.clientId, clientIds));
} else {
await db.transaction(async (tx) => {
for (const [timestamp, clientIds] of byTimestamp) {
await tx
.update(clients)
.set({
lastPing: timestamp,
online: true,
archived: false
})
.where(
inArray(clients.clientId, clientIds)
);
}
});
}
}, "flushClientPingsToDb"); }, "flushClientPingsToDb");
} catch (error) { } catch (error) {
logger.error( logger.error(
@@ -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) {
@@ -389,4 +379,4 @@ export async function stopPingAccumulator(): Promise<void> {
*/ */
export function getPendingPingCount(): number { export function getPendingPingCount(): number {
return pendingSitePings.size + pendingClientPings.size; return pendingSitePings.size + pendingClientPings.size;
} }

View File

@@ -27,7 +27,7 @@ import { build } from "@server/build";
import { usageService } from "@server/lib/billing/usageService"; import { usageService } from "@server/lib/billing/usageService";
import { FeatureId } from "@server/lib/billing"; import { FeatureId } from "@server/lib/billing";
import { INSPECT_MAX_BYTES } from "buffer"; import { INSPECT_MAX_BYTES } from "buffer";
import { 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

@@ -4,8 +4,6 @@ import {
clientSitesAssociationsCache, clientSitesAssociationsCache,
db, db,
exitNodes, exitNodes,
networks,
siteNetworks,
siteResources, siteResources,
sites sites
} from "@server/db"; } from "@server/db";
@@ -61,17 +59,9 @@ export async function buildSiteConfigurationForOlmClient(
clientSiteResourcesAssociationsCache.siteResourceId clientSiteResourcesAssociationsCache.siteResourceId
) )
) )
.innerJoin(
networks,
eq(siteResources.networkId, networks.networkId)
)
.innerJoin(
siteNetworks,
eq(networks.networkId, siteNetworks.networkId)
)
.where( .where(
and( and(
eq(siteNetworks.siteId, site.siteId), eq(siteResources.siteId, site.siteId),
eq( eq(
clientSiteResourcesAssociationsCache.clientId, clientSiteResourcesAssociationsCache.clientId,
client.clientId client.clientId
@@ -79,7 +69,6 @@ export async function buildSiteConfigurationForOlmClient(
) )
); );
if (jitMode) { if (jitMode) {
// Add site configuration to the array // Add site configuration to the array
siteConfigurations.push({ siteConfigurations.push({

View File

@@ -4,12 +4,10 @@ import {
db, db,
exitNodes, exitNodes,
Site, Site,
siteNetworks, siteResources
siteResources,
sites
} from "@server/db"; } from "@server/db";
import { MessageHandler } from "@server/routers/ws"; import { MessageHandler } from "@server/routers/ws";
import { clients, Olm } from "@server/db"; import { clients, Olm, sites } from "@server/db";
import { and, eq, or } from "drizzle-orm"; import { and, eq, or } from "drizzle-orm";
import logger from "@server/logger"; import logger from "@server/logger";
import { initPeerAddHandshake } from "./peers"; import { initPeerAddHandshake } from "./peers";
@@ -46,31 +44,20 @@ export const handleOlmServerInitAddPeerHandshake: MessageHandler = async (
const { siteId, resourceId, chainId } = message.data; const { siteId, resourceId, chainId } = message.data;
const sendCancel = async () => { let site: Site | null = null;
await sendToClient(
olm.olmId,
{
type: "olm/wg/peer/chain/cancel",
data: { chainId }
},
{ incrementConfigVersion: false }
).catch((error) => {
logger.warn(`Error sending message:`, error);
});
};
let sitesToProcess: Site[] = [];
if (siteId) { if (siteId) {
// get the site
const [siteRes] = await db const [siteRes] = await db
.select() .select()
.from(sites) .from(sites)
.where(eq(sites.siteId, siteId)) .where(eq(sites.siteId, siteId))
.limit(1); .limit(1);
if (siteRes) { if (siteRes) {
sitesToProcess = [siteRes]; site = siteRes;
} }
} else if (resourceId) { }
if (resourceId && !site) {
const resources = await db const resources = await db
.select() .select()
.from(siteResources) .from(siteResources)
@@ -85,17 +72,27 @@ export const handleOlmServerInitAddPeerHandshake: MessageHandler = async (
); );
if (!resources || resources.length === 0) { if (!resources || resources.length === 0) {
logger.error( logger.error(`handleOlmServerPeerAddMessage: Resource not found`);
`handleOlmServerInitAddPeerHandshake: Resource not found` // cancel the request from the olm side to not keep doing this
); await sendToClient(
await sendCancel(); olm.olmId,
{
type: "olm/wg/peer/chain/cancel",
data: {
chainId
}
},
{ incrementConfigVersion: false }
).catch((error) => {
logger.warn(`Error sending message:`, error);
});
return; return;
} }
if (resources.length > 1) { if (resources.length > 1) {
// error but this should not happen because the nice id cant contain a dot and the alias has to have a dot and both have to be unique within the org so there should never be multiple matches // error but this should not happen because the nice id cant contain a dot and the alias has to have a dot and both have to be unique within the org so there should never be multiple matches
logger.error( logger.error(
`handleOlmServerInitAddPeerHandshake: Multiple resources found matching the criteria` `handleOlmServerPeerAddMessage: Multiple resources found matching the criteria`
); );
return; return;
} }
@@ -120,120 +117,125 @@ export const handleOlmServerInitAddPeerHandshake: MessageHandler = async (
if (currentResourceAssociationCaches.length === 0) { if (currentResourceAssociationCaches.length === 0) {
logger.error( logger.error(
`handleOlmServerInitAddPeerHandshake: Client ${client.clientId} does not have access to resource ${resource.siteResourceId}` `handleOlmServerPeerAddMessage: Client ${client.clientId} does not have access to resource ${resource.siteResourceId}`
); );
await sendCancel(); // cancel the request from the olm side to not keep doing this
await sendToClient(
olm.olmId,
{
type: "olm/wg/peer/chain/cancel",
data: {
chainId
}
},
{ incrementConfigVersion: false }
).catch((error) => {
logger.warn(`Error sending message:`, error);
});
return; return;
} }
if (!resource.networkId) { const siteIdFromResource = resource.siteId;
// get the site
const [siteRes] = await db
.select()
.from(sites)
.where(eq(sites.siteId, siteIdFromResource));
if (!siteRes) {
logger.error( logger.error(
`handleOlmServerInitAddPeerHandshake: Resource ${resource.siteResourceId} has no network` `handleOlmServerPeerAddMessage: Site with ID ${site} not found`
); );
await sendCancel();
return; return;
} }
// Get all sites associated with this resource's network via siteNetworks site = siteRes;
const siteRows = await db
.select({ siteId: siteNetworks.siteId })
.from(siteNetworks)
.where(eq(siteNetworks.networkId, resource.networkId));
if (!siteRows || siteRows.length === 0) {
logger.error(
`handleOlmServerInitAddPeerHandshake: No sites found for resource ${resource.siteResourceId}`
);
await sendCancel();
return;
}
// Fetch full site objects for all network members
const foundSites = await Promise.all(
siteRows.map(async ({ siteId: sid }) => {
const [s] = await db
.select()
.from(sites)
.where(eq(sites.siteId, sid))
.limit(1);
return s ?? null;
})
);
sitesToProcess = foundSites.filter((s): s is Site => s !== null);
} }
if (sitesToProcess.length === 0) { if (!site) {
logger.error( logger.error(`handleOlmServerPeerAddMessage: Site not found`);
`handleOlmServerInitAddPeerHandshake: No sites to process`
);
await sendCancel();
return; return;
} }
let handshakeInitiated = false; // check if the client can access this site using the cache
const currentSiteAssociationCaches = await db
.select()
.from(clientSitesAssociationsCache)
.where(
and(
eq(clientSitesAssociationsCache.clientId, client.clientId),
eq(clientSitesAssociationsCache.siteId, site.siteId)
)
);
for (const site of sitesToProcess) { if (currentSiteAssociationCaches.length === 0) {
// Check if the client can access this site using the cache logger.error(
const currentSiteAssociationCaches = await db `handleOlmServerPeerAddMessage: Client ${client.clientId} does not have access to site ${site.siteId}`
.select() );
.from(clientSitesAssociationsCache) // cancel the request from the olm side to not keep doing this
.where( await sendToClient(
and( olm.olmId,
eq(clientSitesAssociationsCache.clientId, client.clientId),
eq(clientSitesAssociationsCache.siteId, site.siteId)
)
);
if (currentSiteAssociationCaches.length === 0) {
logger.warn(
`handleOlmServerInitAddPeerHandshake: Client ${client.clientId} does not have access to site ${site.siteId}, skipping`
);
continue;
}
if (!site.exitNodeId) {
logger.error(
`handleOlmServerInitAddPeerHandshake: Site ${site.siteId} has no exit node, skipping`
);
continue;
}
const [exitNode] = await db
.select()
.from(exitNodes)
.where(eq(exitNodes.exitNodeId, site.exitNodeId));
if (!exitNode) {
logger.error(
`handleOlmServerInitAddPeerHandshake: Exit node not found for site ${site.siteId}, skipping`
);
continue;
}
// Trigger the peer add handshake — if the peer was already added this will be a no-op
await initPeerAddHandshake(
client.clientId,
{ {
siteId: site.siteId, type: "olm/wg/peer/chain/cancel",
exitNode: { data: {
publicKey: exitNode.publicKey, chainId
endpoint: exitNode.endpoint
} }
}, },
olm.olmId, { incrementConfigVersion: false }
chainId ).catch((error) => {
); logger.warn(`Error sending message:`, error);
});
handshakeInitiated = true; return;
} }
if (!handshakeInitiated) { if (!site.exitNodeId) {
logger.error( logger.error(
`handleOlmServerInitAddPeerHandshake: No accessible sites with valid exit nodes found, cancelling chain` `handleOlmServerPeerAddMessage: Site with ID ${site.siteId} has no exit node`
); );
await sendCancel(); // cancel the request from the olm side to not keep doing this
await sendToClient(
olm.olmId,
{
type: "olm/wg/peer/chain/cancel",
data: {
chainId
}
},
{ incrementConfigVersion: false }
).catch((error) => {
logger.warn(`Error sending message:`, error);
});
return;
} }
// get the exit node from the side
const [exitNode] = await db
.select()
.from(exitNodes)
.where(eq(exitNodes.exitNodeId, site.exitNodeId));
if (!exitNode) {
logger.error(
`handleOlmServerPeerAddMessage: Site with ID ${site.siteId} has no exit node`
);
return;
}
// also trigger the peer add handshake in case the peer was not already added to the olm and we need to hole punch
// if it has already been added this will be a no-op
await initPeerAddHandshake(
// this will kick off the add peer process for the client
client.clientId,
{
siteId: site.siteId,
exitNode: {
publicKey: exitNode.publicKey,
endpoint: exitNode.endpoint
}
},
olm.olmId,
chainId
);
return; return;
}; };

View File

@@ -1,25 +1,43 @@
import { import {
Client,
clientSiteResourcesAssociationsCache, clientSiteResourcesAssociationsCache,
db, db,
networks, ExitNode,
siteNetworks, Org,
orgs,
roleClients,
roles,
siteResources, siteResources,
Transaction,
userClients,
userOrgs,
users
} from "@server/db"; } from "@server/db";
import { MessageHandler } from "@server/routers/ws"; import { MessageHandler } from "@server/routers/ws";
import { import {
clients, clients,
clientSitesAssociationsCache, clientSitesAssociationsCache,
exitNodes,
Olm, Olm,
olms,
sites sites
} from "@server/db"; } from "@server/db";
import { and, eq, inArray, isNotNull, isNull } from "drizzle-orm"; import { and, eq, inArray, isNotNull, isNull } from "drizzle-orm";
import { addPeer, deletePeer } from "../newt/peers";
import logger from "@server/logger"; import logger from "@server/logger";
import { listExitNodes } from "#dynamic/lib/exitNodes";
import { import {
generateAliasConfig, generateAliasConfig,
getNextAvailableClientSubnet
} from "@server/lib/ip"; } from "@server/lib/ip";
import { generateRemoteSubnets } from "@server/lib/ip"; import { generateRemoteSubnets } from "@server/lib/ip";
import { rebuildClientAssociationsFromClient } from "@server/lib/rebuildClientAssociations";
import { checkOrgAccessPolicy } from "#dynamic/lib/checkOrgAccessPolicy";
import { validateSessionToken } from "@server/auth/sessions/app";
import config from "@server/lib/config";
import { import {
addPeer as newtAddPeer, addPeer as newtAddPeer,
deletePeer as newtDeletePeer
} from "@server/routers/newt/peers"; } from "@server/routers/newt/peers";
export const handleOlmServerPeerAddMessage: MessageHandler = async ( export const handleOlmServerPeerAddMessage: MessageHandler = async (
@@ -135,21 +153,13 @@ export const handleOlmServerPeerAddMessage: MessageHandler = async (
clientSiteResourcesAssociationsCache.siteResourceId clientSiteResourcesAssociationsCache.siteResourceId
) )
) )
.innerJoin(
networks,
eq(siteResources.networkId, networks.networkId)
)
.innerJoin(
siteNetworks,
and(
eq(networks.networkId, siteNetworks.networkId),
eq(siteNetworks.siteId, site.siteId)
)
)
.where( .where(
eq( and(
clientSiteResourcesAssociationsCache.clientId, eq(siteResources.siteId, site.siteId),
client.clientId eq(
clientSiteResourcesAssociationsCache.clientId,
client.clientId
)
) )
); );

View File

@@ -1,6 +1,6 @@
import { Request, Response, NextFunction } from "express"; import { Request, Response, NextFunction } from "express";
import { z } from "zod"; import { z } from "zod";
import { db, Site, siteNetworks, siteResources } from "@server/db"; import { db, Site, siteResources } from "@server/db";
import { newts, newtSessions, sites } from "@server/db"; import { newts, newtSessions, sites } from "@server/db";
import { eq } from "drizzle-orm"; import { eq } from "drizzle-orm";
import response from "@server/lib/response"; import response from "@server/lib/response";
@@ -71,23 +71,18 @@ export async function deleteSite(
await deletePeer(site.exitNodeId!, site.pubKey); await deletePeer(site.exitNodeId!, site.pubKey);
} }
} else if (site.type == "newt") { } else if (site.type == "newt") {
const networks = await trx // delete all of the site resources on this site
.select({ networkId: siteNetworks.networkId }) const siteResourcesOnSite = trx
.from(siteNetworks) .delete(siteResources)
.where(eq(siteNetworks.siteId, siteId)); .where(eq(siteResources.siteId, siteId))
.returning();
// loop through them // loop through them
for (const network of await networks) { for (const removedSiteResource of await siteResourcesOnSite) {
const [siteResource] = await trx await rebuildClientAssociationsFromSiteResource(
.select() removedSiteResource,
.from(siteResources) trx
.where(eq(siteResources.networkId, network.networkId)); );
if (siteResource) {
await rebuildClientAssociationsFromSiteResource(
siteResource,
trx
);
}
} }
// get the newt on the site by querying the newt table for siteId // get the newt on the site by querying the newt table for siteId

View File

@@ -5,8 +5,6 @@ import {
orgs, orgs,
roles, roles,
roleSiteResources, roleSiteResources,
siteNetworks,
networks,
SiteResource, SiteResource,
siteResources, siteResources,
sites, sites,
@@ -25,7 +23,7 @@ import response from "@server/lib/response";
import logger from "@server/logger"; import logger from "@server/logger";
import { OpenAPITags, registry } from "@server/openApi"; import { OpenAPITags, registry } from "@server/openApi";
import HttpCode from "@server/types/HttpCode"; import HttpCode from "@server/types/HttpCode";
import { and, eq, inArray } from "drizzle-orm"; import { and, eq } from "drizzle-orm";
import { NextFunction, Request, Response } from "express"; import { NextFunction, Request, Response } from "express";
import createHttpError from "http-errors"; import createHttpError from "http-errors";
import { z } from "zod"; import { z } from "zod";
@@ -39,7 +37,7 @@ const createSiteResourceSchema = z
.strictObject({ .strictObject({
name: z.string().min(1).max(255), name: z.string().min(1).max(255),
mode: z.enum(["host", "cidr", "port"]), mode: z.enum(["host", "cidr", "port"]),
siteIds: z.array(z.int()), siteId: z.int(),
// protocol: z.enum(["tcp", "udp"]).optional(), // protocol: z.enum(["tcp", "udp"]).optional(),
// proxyPort: z.int().positive().optional(), // proxyPort: z.int().positive().optional(),
// destinationPort: z.int().positive().optional(), // destinationPort: z.int().positive().optional(),
@@ -161,7 +159,7 @@ export async function createSiteResource(
const { orgId } = parsedParams.data; const { orgId } = parsedParams.data;
const { const {
name, name,
siteIds, siteId,
mode, mode,
// protocol, // protocol,
// proxyPort, // proxyPort,
@@ -180,16 +178,14 @@ export async function createSiteResource(
} = parsedBody.data; } = parsedBody.data;
// Verify the site exists and belongs to the org // Verify the site exists and belongs to the org
const sitesToAssign = await db const [site] = await db
.select() .select()
.from(sites) .from(sites)
.where(and(inArray(sites.siteId, siteIds), eq(sites.orgId, orgId))) .where(and(eq(sites.siteId, siteId), eq(sites.orgId, orgId)))
.limit(1); .limit(1);
if (sitesToAssign.length !== siteIds.length) { if (!site) {
return next( return next(createHttpError(HttpCode.NOT_FOUND, "Site not found"));
createHttpError(HttpCode.NOT_FOUND, "Some site not found")
);
} }
const [org] = await db const [org] = await db
@@ -291,29 +287,12 @@ export async function createSiteResource(
let newSiteResource: SiteResource | undefined; let newSiteResource: SiteResource | undefined;
await db.transaction(async (trx) => { await db.transaction(async (trx) => {
const [network] = await trx
.insert(networks)
.values({
scope: "resource",
orgId: orgId
})
.returning();
if (!network) {
return next(
createHttpError(
HttpCode.INTERNAL_SERVER_ERROR,
`Failed to create network`
)
);
}
// Create the site resource // Create the site resource
const insertValues: typeof siteResources.$inferInsert = { const insertValues: typeof siteResources.$inferInsert = {
siteId,
niceId, niceId,
orgId, orgId,
name, name,
networkId: network.networkId,
mode: mode as "host" | "cidr", mode: mode as "host" | "cidr",
destination, destination,
enabled, enabled,
@@ -338,13 +317,6 @@ export async function createSiteResource(
//////////////////// update the associations //////////////////// //////////////////// update the associations ////////////////////
for (const siteId of siteIds) {
await trx.insert(siteNetworks).values({
siteId: siteId,
networkId: network.networkId
});
}
const [adminRole] = await trx const [adminRole] = await trx
.select() .select()
.from(roles) .from(roles)
@@ -387,21 +359,16 @@ export async function createSiteResource(
); );
} }
for (const siteToAssign of sitesToAssign) { const [newt] = await trx
const [newt] = await trx .select()
.select() .from(newts)
.from(newts) .where(eq(newts.siteId, site.siteId))
.where(eq(newts.siteId, siteToAssign.siteId)) .limit(1);
.limit(1);
if (!newt) { if (!newt) {
return next( return next(
createHttpError( createHttpError(HttpCode.NOT_FOUND, "Newt not found")
HttpCode.NOT_FOUND, );
`Newt not found for site ${siteToAssign.siteId}`
)
);
}
} }
await rebuildClientAssociationsFromSiteResource( await rebuildClientAssociationsFromSiteResource(
@@ -420,7 +387,7 @@ export async function createSiteResource(
} }
logger.info( logger.info(
`Created site resource ${newSiteResource.siteResourceId} for org ${orgId}` `Created site resource ${newSiteResource.siteResourceId} for site ${siteId}`
); );
return response(res, { return response(res, {

View File

@@ -70,18 +70,17 @@ export async function deleteSiteResource(
.where(and(eq(siteResources.siteResourceId, siteResourceId))) .where(and(eq(siteResources.siteResourceId, siteResourceId)))
.returning(); .returning();
// not sure why this is here... const [newt] = await trx
// const [newt] = await trx .select()
// .select() .from(newts)
// .from(newts) .where(eq(newts.siteId, removedSiteResource.siteId))
// .where(eq(newts.siteId, removedSiteResource.siteId)) .limit(1);
// .limit(1);
// if (!newt) { if (!newt) {
// return next( return next(
// createHttpError(HttpCode.NOT_FOUND, "Newt not found") createHttpError(HttpCode.NOT_FOUND, "Newt not found")
// ); );
// } }
await rebuildClientAssociationsFromSiteResource( await rebuildClientAssociationsFromSiteResource(
removedSiteResource, removedSiteResource,

View File

@@ -17,34 +17,38 @@ const getSiteResourceParamsSchema = z.strictObject({
.transform((val) => (val ? Number(val) : undefined)) .transform((val) => (val ? Number(val) : undefined))
.pipe(z.int().positive().optional()) .pipe(z.int().positive().optional())
.optional(), .optional(),
siteId: z.string().transform(Number).pipe(z.int().positive()),
niceId: z.string().optional(), niceId: z.string().optional(),
orgId: z.string() orgId: z.string()
}); });
async function query( async function query(
siteResourceId?: number, siteResourceId?: number,
siteId?: number,
niceId?: string, niceId?: string,
orgId?: string orgId?: string
) { ) {
if (siteResourceId && orgId) { if (siteResourceId && siteId && orgId) {
const [siteResource] = await db const [siteResource] = await db
.select() .select()
.from(siteResources) .from(siteResources)
.where( .where(
and( and(
eq(siteResources.siteResourceId, siteResourceId), eq(siteResources.siteResourceId, siteResourceId),
eq(siteResources.siteId, siteId),
eq(siteResources.orgId, orgId) eq(siteResources.orgId, orgId)
) )
) )
.limit(1); .limit(1);
return siteResource; return siteResource;
} else if (niceId && orgId) { } else if (niceId && siteId && orgId) {
const [siteResource] = await db const [siteResource] = await db
.select() .select()
.from(siteResources) .from(siteResources)
.where( .where(
and( and(
eq(siteResources.niceId, niceId), eq(siteResources.niceId, niceId),
eq(siteResources.siteId, siteId),
eq(siteResources.orgId, orgId) eq(siteResources.orgId, orgId)
) )
) )
@@ -80,6 +84,7 @@ registry.registerPath({
request: { request: {
params: z.object({ params: z.object({
niceId: z.string(), niceId: z.string(),
siteId: z.number(),
orgId: z.string() orgId: z.string()
}) })
}, },
@@ -102,10 +107,10 @@ export async function getSiteResource(
); );
} }
const { siteResourceId, niceId, orgId } = parsedParams.data; const { siteResourceId, siteId, niceId, orgId } = parsedParams.data;
// Get the site resource // Get the site resource
const siteResource = await query(siteResourceId, niceId, orgId); const siteResource = await query(siteResourceId, siteId, niceId, orgId);
if (!siteResource) { if (!siteResource) {
return next( return next(

View File

@@ -1,4 +1,4 @@
import { db, SiteResource, siteNetworks, siteResources, sites } from "@server/db"; import { db, SiteResource, siteResources, sites } from "@server/db";
import response from "@server/lib/response"; import response from "@server/lib/response";
import logger from "@server/logger"; import logger from "@server/logger";
import { OpenAPITags, registry } from "@server/openApi"; import { OpenAPITags, registry } from "@server/openApi";
@@ -73,10 +73,9 @@ const listAllSiteResourcesByOrgQuerySchema = z.object({
export type ListAllSiteResourcesByOrgResponse = PaginatedResponse<{ export type ListAllSiteResourcesByOrgResponse = PaginatedResponse<{
siteResources: (SiteResource & { siteResources: (SiteResource & {
siteIds: number[]; siteName: string;
siteNames: string[]; siteNiceId: string;
siteNiceIds: string[]; siteAddress: string | null;
siteAddresses: (string | null)[];
})[]; })[];
}>; }>;
@@ -84,6 +83,7 @@ function querySiteResourcesBase() {
return db return db
.select({ .select({
siteResourceId: siteResources.siteResourceId, siteResourceId: siteResources.siteResourceId,
siteId: siteResources.siteId,
orgId: siteResources.orgId, orgId: siteResources.orgId,
niceId: siteResources.niceId, niceId: siteResources.niceId,
name: siteResources.name, name: siteResources.name,
@@ -100,20 +100,14 @@ function querySiteResourcesBase() {
disableIcmp: siteResources.disableIcmp, disableIcmp: siteResources.disableIcmp,
authDaemonMode: siteResources.authDaemonMode, authDaemonMode: siteResources.authDaemonMode,
authDaemonPort: siteResources.authDaemonPort, authDaemonPort: siteResources.authDaemonPort,
networkId: siteResources.networkId, siteName: sites.name,
defaultNetworkId: siteResources.defaultNetworkId, siteNiceId: sites.niceId,
siteNames: sql<string[]>`array_agg(${sites.name})`, siteAddress: sites.address
siteNiceIds: sql<string[]>`array_agg(${sites.niceId})`,
siteIds: sql<number[]>`array_agg(${sites.siteId})`,
siteAddresses: sql<(string | null)[]>`array_agg(${sites.address})`
}) })
.from(siteResources) .from(siteResources)
.innerJoin(siteNetworks, eq(siteResources.networkId, siteNetworks.networkId)) .innerJoin(sites, eq(siteResources.siteId, sites.siteId));
.innerJoin(sites, eq(siteNetworks.siteId, sites.siteId))
.groupBy(siteResources.siteResourceId);
} }
registry.registerPath({ registry.registerPath({
method: "get", method: "get",
path: "/org/{orgId}/site-resources", path: "/org/{orgId}/site-resources",

View File

@@ -1,6 +1,6 @@
import { Request, Response, NextFunction } from "express"; import { Request, Response, NextFunction } from "express";
import { z } from "zod"; import { z } from "zod";
import { db, networks, siteNetworks } from "@server/db"; import { db } from "@server/db";
import { siteResources, sites, SiteResource } from "@server/db"; import { siteResources, sites, SiteResource } from "@server/db";
import response from "@server/lib/response"; import response from "@server/lib/response";
import HttpCode from "@server/types/HttpCode"; import HttpCode from "@server/types/HttpCode";
@@ -108,21 +108,13 @@ export async function listSiteResources(
return next(createHttpError(HttpCode.NOT_FOUND, "Site not found")); return next(createHttpError(HttpCode.NOT_FOUND, "Site not found"));
} }
// Get site resources by joining networks to siteResources via siteNetworks // Get site resources
const siteResourcesList = await db const siteResourcesList = await db
.select() .select()
.from(siteNetworks) .from(siteResources)
.innerJoin(
networks,
eq(siteNetworks.networkId, networks.networkId)
)
.innerJoin(
siteResources,
eq(siteResources.networkId, networks.networkId)
)
.where( .where(
and( and(
eq(siteNetworks.siteId, siteId), eq(siteResources.siteId, siteId),
eq(siteResources.orgId, orgId) eq(siteResources.orgId, orgId)
) )
) )
@@ -136,7 +128,6 @@ export async function listSiteResources(
.limit(limit) .limit(limit)
.offset(offset); .offset(offset);
return response(res, { return response(res, {
data: { siteResources: siteResourcesList }, data: { siteResources: siteResourcesList },
success: true, success: true,

View File

@@ -7,18 +7,12 @@ import {
orgs, orgs,
roles, roles,
roleSiteResources, roleSiteResources,
siteNetworks,
SiteResource, SiteResource,
siteResources, siteResources,
sites, sites,
networks,
Transaction, Transaction,
userSiteResources userSiteResources
} from "@server/db"; } from "@server/db";
import response from "@server/lib/response";
import { eq, and, ne, inArray } from "drizzle-orm";
import { OpenAPITags, registry } from "@server/openApi";
import { updatePeerData, updateTargets } from "@server/routers/client/targets";
import { tierMatrix } from "@server/lib/billing/tierMatrix"; import { tierMatrix } from "@server/lib/billing/tierMatrix";
import { import {
generateAliasConfig, generateAliasConfig,
@@ -28,8 +22,12 @@ import {
portRangeStringSchema portRangeStringSchema
} from "@server/lib/ip"; } from "@server/lib/ip";
import { rebuildClientAssociationsFromSiteResource } from "@server/lib/rebuildClientAssociations"; import { rebuildClientAssociationsFromSiteResource } from "@server/lib/rebuildClientAssociations";
import response from "@server/lib/response";
import logger from "@server/logger"; import logger from "@server/logger";
import { OpenAPITags, registry } from "@server/openApi";
import { updatePeerData, updateTargets } from "@server/routers/client/targets";
import HttpCode from "@server/types/HttpCode"; import HttpCode from "@server/types/HttpCode";
import { and, eq, ne } from "drizzle-orm";
import { NextFunction, Request, Response } from "express"; import { NextFunction, Request, Response } from "express";
import createHttpError from "http-errors"; import createHttpError from "http-errors";
import { z } from "zod"; import { z } from "zod";
@@ -42,8 +40,7 @@ const updateSiteResourceParamsSchema = z.strictObject({
const updateSiteResourceSchema = z const updateSiteResourceSchema = z
.strictObject({ .strictObject({
name: z.string().min(1).max(255).optional(), name: z.string().min(1).max(255).optional(),
siteIds: z.array(z.int()), siteId: z.int(),
// niceId: z.string().min(1).max(255).regex(/^[a-zA-Z0-9-]+$/, "niceId can only contain letters, numbers, and dashes").optional(),
niceId: z niceId: z
.string() .string()
.min(1) .min(1)
@@ -175,7 +172,7 @@ export async function updateSiteResource(
const { siteResourceId } = parsedParams.data; const { siteResourceId } = parsedParams.data;
const { const {
name, name,
siteIds, // because it can change siteId, // because it can change
niceId, niceId,
mode, mode,
destination, destination,
@@ -191,6 +188,16 @@ export async function updateSiteResource(
authDaemonMode authDaemonMode
} = parsedBody.data; } = parsedBody.data;
const [site] = await db
.select()
.from(sites)
.where(eq(sites.siteId, siteId))
.limit(1);
if (!site) {
return next(createHttpError(HttpCode.NOT_FOUND, "Site not found"));
}
// Check if site resource exists // Check if site resource exists
const [existingSiteResource] = await db const [existingSiteResource] = await db
.select() .select()
@@ -230,24 +237,6 @@ export async function updateSiteResource(
); );
} }
// Verify the site exists and belongs to the org
const sitesToAssign = await db
.select()
.from(sites)
.where(
and(
inArray(sites.siteId, siteIds),
eq(sites.orgId, existingSiteResource.orgId)
)
)
.limit(1);
if (sitesToAssign.length !== siteIds.length) {
return next(
createHttpError(HttpCode.NOT_FOUND, "Some site not found")
);
}
// Only check if destination is an IP address // Only check if destination is an IP address
const isIp = z const isIp = z
.union([z.ipv4(), z.ipv6()]) .union([z.ipv4(), z.ipv6()])
@@ -265,24 +254,25 @@ export async function updateSiteResource(
); );
} }
let sitesChanged = false; let existingSite = site;
const existingSiteIds = existingSiteResource.networkId let siteChanged = false;
? await db if (existingSiteResource.siteId !== siteId) {
.select() siteChanged = true;
.from(siteNetworks) // get the existing site
.where( [existingSite] = await db
eq(siteNetworks.networkId, existingSiteResource.networkId) .select()
) .from(sites)
: []; .where(eq(sites.siteId, existingSiteResource.siteId))
.limit(1);
const existingSiteIdSet = new Set(existingSiteIds.map((s) => s.siteId)); if (!existingSite) {
const newSiteIdSet = new Set(siteIds); return next(
createHttpError(
if ( HttpCode.NOT_FOUND,
existingSiteIdSet.size !== newSiteIdSet.size || "Existing site not found"
![...existingSiteIdSet].every((id) => newSiteIdSet.has(id)) )
) { );
sitesChanged = true; }
} }
// make sure the alias is unique within the org if provided // make sure the alias is unique within the org if provided
@@ -312,7 +302,7 @@ export async function updateSiteResource(
let updatedSiteResource: SiteResource | undefined; let updatedSiteResource: SiteResource | undefined;
await db.transaction(async (trx) => { await db.transaction(async (trx) => {
// if the site is changed we need to delete and recreate the resource to avoid complications with the rebuild function otherwise we can just update in place // if the site is changed we need to delete and recreate the resource to avoid complications with the rebuild function otherwise we can just update in place
if (sitesChanged) { if (siteChanged) {
// delete the existing site resource // delete the existing site resource
await trx await trx
.delete(siteResources) .delete(siteResources)
@@ -353,6 +343,7 @@ export async function updateSiteResource(
.update(siteResources) .update(siteResources)
.set({ .set({
name, name,
siteId,
niceId, niceId,
mode, mode,
destination, destination,
@@ -456,6 +447,7 @@ export async function updateSiteResource(
.update(siteResources) .update(siteResources)
.set({ .set({
name: name, name: name,
siteId: siteId,
mode: mode, mode: mode,
destination: destination, destination: destination,
enabled: enabled, enabled: enabled,
@@ -472,23 +464,6 @@ export async function updateSiteResource(
//////////////////// update the associations //////////////////// //////////////////// update the associations ////////////////////
// delete the site - site resources associations
await trx
.delete(siteNetworks)
.where(
eq(
siteNetworks.networkId,
updatedSiteResource.networkId!
)
);
for (const siteId of siteIds) {
await trx.insert(siteNetworks).values({
siteId: siteId,
networkId: updatedSiteResource.networkId!
});
}
await trx await trx
.delete(clientSiteResources) .delete(clientSiteResources)
.where( .where(
@@ -558,15 +533,14 @@ export async function updateSiteResource(
); );
} }
logger.info(`Updated site resource ${siteResourceId}`); logger.info(
`Updated site resource ${siteResourceId} for site ${siteId}`
);
await handleMessagingForUpdatedSiteResource( await handleMessagingForUpdatedSiteResource(
existingSiteResource, existingSiteResource,
updatedSiteResource, updatedSiteResource,
siteIds.map((siteId) => ({ { siteId: site.siteId, orgId: site.orgId },
siteId,
orgId: existingSiteResource.orgId
})),
trx trx
); );
} }
@@ -593,7 +567,7 @@ export async function updateSiteResource(
export async function handleMessagingForUpdatedSiteResource( export async function handleMessagingForUpdatedSiteResource(
existingSiteResource: SiteResource | undefined, existingSiteResource: SiteResource | undefined,
updatedSiteResource: SiteResource, updatedSiteResource: SiteResource,
sites: { siteId: number; orgId: string }[], site: { siteId: number; orgId: string },
trx: Transaction trx: Transaction
) { ) {
logger.debug( logger.debug(
@@ -630,112 +604,105 @@ export async function handleMessagingForUpdatedSiteResource(
// if the existingSiteResource is undefined (new resource) we don't need to do anything here, the rebuild above handled it all // if the existingSiteResource is undefined (new resource) we don't need to do anything here, the rebuild above handled it all
if (destinationChanged || aliasChanged || portRangesChanged) { if (destinationChanged || aliasChanged || portRangesChanged) {
for (const site of sites) { const [newt] = await trx
const [newt] = await trx .select()
.select() .from(newts)
.from(newts) .where(eq(newts.siteId, site.siteId))
.where(eq(newts.siteId, site.siteId)) .limit(1);
.limit(1);
if (!newt) { if (!newt) {
throw new Error( throw new Error(
"Newt not found for site during site resource update" "Newt not found for site during site resource update"
); );
}
// Only update targets on newt if destination changed
if (destinationChanged || portRangesChanged) {
const oldTarget = generateSubnetProxyTargetV2(
existingSiteResource,
mergedAllClients
);
const newTarget = generateSubnetProxyTargetV2(
updatedSiteResource,
mergedAllClients
);
await updateTargets(
newt.newtId,
{
oldTargets: oldTarget ? [oldTarget] : [],
newTargets: newTarget ? [newTarget] : []
},
newt.version
);
}
const olmJobs: Promise<void>[] = [];
for (const client of mergedAllClients) {
// does this client have access to another resource on this site that has the same destination still? if so we dont want to remove it from their olm yet
// todo: optimize this query if needed
const oldDestinationStillInUseSites = await trx
.select()
.from(siteResources)
.innerJoin(
clientSiteResourcesAssociationsCache,
eq(
clientSiteResourcesAssociationsCache.siteResourceId,
siteResources.siteResourceId
)
)
.innerJoin(
siteNetworks,
eq(siteNetworks.networkId, siteResources.networkId)
)
.where(
and(
eq(
clientSiteResourcesAssociationsCache.clientId,
client.clientId
),
eq(siteNetworks.siteId, site.siteId),
eq(
siteResources.destination,
existingSiteResource.destination
),
ne(
siteResources.siteResourceId,
existingSiteResource.siteResourceId
)
)
);
const oldDestinationStillInUseByASite =
oldDestinationStillInUseSites.length > 0;
// we also need to update the remote subnets on the olms for each client that has access to this site
olmJobs.push(
updatePeerData(
client.clientId,
site.siteId,
destinationChanged
? {
oldRemoteSubnets:
!oldDestinationStillInUseByASite
? generateRemoteSubnets([
existingSiteResource
])
: [],
newRemoteSubnets: generateRemoteSubnets([
updatedSiteResource
])
}
: undefined,
aliasChanged
? {
oldAliases: generateAliasConfig([
existingSiteResource
]),
newAliases: generateAliasConfig([
updatedSiteResource
])
}
: undefined
)
);
}
await Promise.all(olmJobs);
} }
// Only update targets on newt if destination changed
if (destinationChanged || portRangesChanged) {
const oldTarget = generateSubnetProxyTargetV2(
existingSiteResource,
mergedAllClients
);
const newTarget = generateSubnetProxyTargetV2(
updatedSiteResource,
mergedAllClients
);
await updateTargets(
newt.newtId,
{
oldTargets: oldTarget ? [oldTarget] : [],
newTargets: newTarget ? [newTarget] : []
},
newt.version
);
}
const olmJobs: Promise<void>[] = [];
for (const client of mergedAllClients) {
// does this client have access to another resource on this site that has the same destination still? if so we dont want to remove it from their olm yet
// todo: optimize this query if needed
const oldDestinationStillInUseSites = await trx
.select()
.from(siteResources)
.innerJoin(
clientSiteResourcesAssociationsCache,
eq(
clientSiteResourcesAssociationsCache.siteResourceId,
siteResources.siteResourceId
)
)
.where(
and(
eq(
clientSiteResourcesAssociationsCache.clientId,
client.clientId
),
eq(siteResources.siteId, site.siteId),
eq(
siteResources.destination,
existingSiteResource.destination
),
ne(
siteResources.siteResourceId,
existingSiteResource.siteResourceId
)
)
);
const oldDestinationStillInUseByASite =
oldDestinationStillInUseSites.length > 0;
// we also need to update the remote subnets on the olms for each client that has access to this site
olmJobs.push(
updatePeerData(
client.clientId,
updatedSiteResource.siteId,
destinationChanged
? {
oldRemoteSubnets: !oldDestinationStillInUseByASite
? generateRemoteSubnets([
existingSiteResource
])
: [],
newRemoteSubnets: generateRemoteSubnets([
updatedSiteResource
])
}
: undefined,
aliasChanged
? {
oldAliases: generateAliasConfig([
existingSiteResource
]),
newAliases: generateAliasConfig([
updatedSiteResource
])
}
: undefined
)
);
}
await Promise.all(olmJobs);
} }
} }

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

@@ -10,7 +10,6 @@ import { authCookieHeader } from "@app/lib/api/cookies";
import { GetDNSRecordsResponse } from "@server/routers/domain"; import { GetDNSRecordsResponse } from "@server/routers/domain";
import DNSRecordsTable from "@app/components/DNSRecordTable"; import DNSRecordsTable from "@app/components/DNSRecordTable";
import DomainCertForm from "@app/components/DomainCertForm"; import DomainCertForm from "@app/components/DomainCertForm";
import { build } from "@server/build";
interface DomainSettingsPageProps { interface DomainSettingsPageProps {
params: Promise<{ domainId: string; orgId: string }>; params: Promise<{ domainId: string; orgId: string }>;
@@ -66,14 +65,12 @@ export default async function DomainSettingsPage({
)} )}
</div> </div>
<div className="space-y-6"> <div className="space-y-6">
{build != "oss" && env.flags.usePangolinDns ? ( <DomainInfoCard
<DomainInfoCard failed={domain.failed}
failed={domain.failed} verified={domain.verified}
verified={domain.verified} type={domain.type}
type={domain.type} errorMessage={domain.errorMessage}
errorMessage={domain.errorMessage} />
/>
) : null}
<DNSRecordsTable records={dnsRecords} type={domain.type} /> <DNSRecordsTable records={dnsRecords} type={domain.type} />

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

@@ -60,17 +60,17 @@ export default async function ClientResourcesPage(
id: siteResource.siteResourceId, id: siteResource.siteResourceId,
name: siteResource.name, name: siteResource.name,
orgId: params.orgId, orgId: params.orgId,
siteNames: siteResource.siteNames, siteName: siteResource.siteName,
siteAddresses: siteResource.siteAddresses || null, siteAddress: siteResource.siteAddress || null,
mode: siteResource.mode || ("port" as any), mode: siteResource.mode || ("port" as any),
// protocol: siteResource.protocol, // protocol: siteResource.protocol,
// proxyPort: siteResource.proxyPort, // proxyPort: siteResource.proxyPort,
siteIds: siteResource.siteIds, siteId: siteResource.siteId,
destination: siteResource.destination, destination: siteResource.destination,
// destinationPort: siteResource.destinationPort, // destinationPort: siteResource.destinationPort,
alias: siteResource.alias || null, alias: siteResource.alias || null,
aliasAddress: siteResource.aliasAddress || null, aliasAddress: siteResource.aliasAddress || null,
siteNiceIds: siteResource.siteNiceIds, siteNiceId: siteResource.siteNiceId,
niceId: siteResource.niceId, niceId: siteResource.niceId,
tcpPortRangeString: siteResource.tcpPortRangeString || null, tcpPortRangeString: siteResource.tcpPortRangeString || null,
udpPortRangeString: siteResource.udpPortRangeString || null, udpPortRangeString: siteResource.udpPortRangeString || null,

View File

@@ -21,7 +21,6 @@ import {
ArrowUp10Icon, ArrowUp10Icon,
ArrowUpDown, ArrowUpDown,
ArrowUpRight, ArrowUpRight,
ChevronDown,
ChevronsUpDownIcon, ChevronsUpDownIcon,
MoreHorizontal MoreHorizontal
} from "lucide-react"; } from "lucide-react";
@@ -44,14 +43,14 @@ export type InternalResourceRow = {
id: number; id: number;
name: string; name: string;
orgId: string; orgId: string;
siteNames: string[]; siteName: string;
siteAddresses: (string | null)[]; siteAddress: string | null;
siteIds: number[];
siteNiceIds: string[];
// mode: "host" | "cidr" | "port"; // mode: "host" | "cidr" | "port";
mode: "host" | "cidr"; mode: "host" | "cidr";
// protocol: string | null; // protocol: string | null;
// proxyPort: number | null; // proxyPort: number | null;
siteId: number;
siteNiceId: string;
destination: string; destination: string;
// destinationPort: number | null; // destinationPort: number | null;
alias: string | null; alias: string | null;
@@ -137,60 +136,6 @@ export default function ClientResourcesTable({
} }
}; };
function SiteCell({ resourceRow }: { resourceRow: InternalResourceRow }) {
const { siteNames, siteNiceIds, orgId } = resourceRow;
if (!siteNames || siteNames.length === 0) {
return <span>-</span>;
}
if (siteNames.length === 1) {
return (
<Link
href={`/${orgId}/settings/sites/${siteNiceIds[0]}`}
>
<Button variant="outline">
{siteNames[0]}
<ArrowUpRight className="ml-2 h-4 w-4" />
</Button>
</Link>
);
}
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="outline"
size="sm"
className="flex items-center gap-2"
>
<span>
{siteNames.length} {t("sites")}
</span>
<ChevronDown className="h-3 w-3" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start">
{siteNames.map((siteName, idx) => (
<DropdownMenuItem
key={siteNiceIds[idx]}
asChild
>
<Link
href={`/${orgId}/settings/sites/${siteNiceIds[idx]}`}
className="flex items-center gap-2 cursor-pointer"
>
{siteName}
<ArrowUpRight className="h-3 w-3" />
</Link>
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
);
}
const internalColumns: ExtendedColumnDef<InternalResourceRow>[] = [ const internalColumns: ExtendedColumnDef<InternalResourceRow>[] = [
{ {
accessorKey: "name", accessorKey: "name",
@@ -240,11 +185,21 @@ export default function ClientResourcesTable({
} }
}, },
{ {
accessorKey: "siteNames", accessorKey: "siteName",
friendlyName: t("site"), friendlyName: t("site"),
header: () => <span className="p-3">{t("site")}</span>, header: () => <span className="p-3">{t("site")}</span>,
cell: ({ row }) => { cell: ({ row }) => {
return <SiteCell resourceRow={row.original} />; const resourceRow = row.original;
return (
<Link
href={`/${resourceRow.orgId}/settings/sites/${resourceRow.siteNiceId}`}
>
<Button variant="outline">
{resourceRow.siteName}
<ArrowUpRight className="ml-2 h-4 w-4" />
</Button>
</Link>
);
} }
}, },
{ {
@@ -444,7 +399,7 @@ export default function ClientResourcesTable({
onConfirm={async () => onConfirm={async () =>
deleteInternalResource( deleteInternalResource(
selectedInternalResource!.id, selectedInternalResource!.id,
selectedInternalResource!.siteIds[0] selectedInternalResource!.siteId
) )
} }
string={selectedInternalResource.name} string={selectedInternalResource.name}
@@ -478,11 +433,7 @@ export default function ClientResourcesTable({
<EditInternalResourceDialog <EditInternalResourceDialog
open={isEditDialogOpen} open={isEditDialogOpen}
setOpen={setIsEditDialogOpen} setOpen={setIsEditDialogOpen}
resource={{ resource={editingResource}
...editingResource,
siteName: editingResource.siteNames[0] ?? "",
siteId: editingResource.siteIds[0]
}}
orgId={orgId} orgId={orgId}
sites={sites} sites={sites}
onSuccess={() => { onSuccess={() => {

View File

@@ -154,7 +154,7 @@ export default function CreateDomainForm({
const punycodePreview = useMemo(() => { const punycodePreview = useMemo(() => {
if (!baseDomain) return ""; if (!baseDomain) return "";
const punycode = toPunycode(baseDomain.toLowerCase()); const punycode = toPunycode(baseDomain);
return punycode !== baseDomain.toLowerCase() ? punycode : ""; return punycode !== baseDomain.toLowerCase() ? punycode : "";
}, [baseDomain]); }, [baseDomain]);
@@ -239,24 +239,21 @@ export default function CreateDomainForm({
className="space-y-4" className="space-y-4"
id="create-domain-form" id="create-domain-form"
> >
{build != "oss" && env.flags.usePangolinDns ? ( <FormField
<FormField control={form.control}
control={form.control} name="type"
name="type" render={({ field }) => (
render={({ field }) => ( <FormItem>
<FormItem> <StrategySelect
<StrategySelect options={domainOptions}
options={domainOptions} defaultValue={field.value}
defaultValue={field.value} onChange={field.onChange}
onChange={field.onChange} cols={1}
cols={1} />
/> <FormMessage />
<FormMessage /> </FormItem>
</FormItem> )}
)} />
/>
) : null}
<FormField <FormField
control={form.control} control={form.control}
name="baseDomain" name="baseDomain"

View File

@@ -333,8 +333,7 @@ export default function PendingSitesTable({
"jupiter", "jupiter",
"saturn", "saturn",
"uranus", "uranus",
"neptune", "neptune"
"pluto"
].includes(originalRow.exitNodeName.toLowerCase()); ].includes(originalRow.exitNodeName.toLowerCase());
if (isCloudNode) { if (isCloudNode) {

View File

@@ -342,8 +342,7 @@ export default function SitesTable({
"jupiter", "jupiter",
"saturn", "saturn",
"uranus", "uranus",
"neptune", "neptune"
"pluto"
].includes(originalRow.exitNodeName.toLowerCase()); ].includes(originalRow.exitNodeName.toLowerCase());
if (isCloudNode) { if (isCloudNode) {