mirror of
https://github.com/fosrl/pangolin.git
synced 2026-04-10 03:46:37 +00:00
Compare commits
58 Commits
1.17.0-s.1
...
private-si
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
96b9123306 | ||
|
|
f57012eb90 | ||
|
|
34387d9859 | ||
|
|
80f5914fdd | ||
|
|
eaa70da4dd | ||
|
|
466f137590 | ||
|
|
28ef5238c9 | ||
|
|
d948d2ec33 | ||
|
|
6b8a3c8d77 | ||
|
|
ba9794c067 | ||
|
|
eb4b2daaab | ||
|
|
8cbc8dec89 | ||
|
|
e89e60d50b | ||
|
|
c45308f234 | ||
|
|
40205c40c5 | ||
|
|
f3fe2dd33b | ||
|
|
8edcc45033 | ||
|
|
91471a4aca | ||
|
|
ae2c37a2f6 | ||
|
|
c8208f0a88 | ||
|
|
e11dfbd29c | ||
|
|
b375d20598 | ||
|
|
c4b82c69f8 | ||
|
|
c9a00420a0 | ||
|
|
36ef9cd442 | ||
|
|
5e08779ab0 | ||
|
|
16a0e1ce7b | ||
|
|
8b03484ade | ||
|
|
9da9974adf | ||
|
|
6f80cf3db2 | ||
|
|
76d8f44779 | ||
|
|
700c92efcb | ||
|
|
d17e0c9f50 | ||
|
|
f00b9794f5 | ||
|
|
daff59c93f | ||
|
|
aa8954366c | ||
|
|
87464d53bd | ||
|
|
e04f17c9aa | ||
|
|
b25e3499d8 | ||
|
|
2e6f74a6f8 | ||
|
|
8eee0ca5a5 | ||
|
|
c2ebc0a0ff | ||
|
|
03c905a7af | ||
|
|
8ce45a1acd | ||
|
|
02033f611f | ||
|
|
1366901e24 | ||
|
|
c4f48f5748 | ||
|
|
c48bc71443 | ||
|
|
d85496453f | ||
|
|
21b91374a3 | ||
|
|
a1ce7f54a0 | ||
|
|
87524fe8ae | ||
|
|
2093bb5357 | ||
|
|
6f2e37948c | ||
|
|
b7421e47cc | ||
|
|
7cbe3d42a1 | ||
|
|
d8b511b198 | ||
|
|
102a235407 |
1
.github/CODEOWNERS
vendored
Normal file
1
.github/CODEOWNERS
vendored
Normal file
@@ -0,0 +1 @@
|
||||
* @oschwartz10612 @miloschwartz
|
||||
@@ -86,6 +86,8 @@ entryPoints:
|
||||
http:
|
||||
tls:
|
||||
certResolver: "letsencrypt"
|
||||
middlewares:
|
||||
- crowdsec@file
|
||||
encodedCharacters:
|
||||
allowEncodedSlash: true
|
||||
allowEncodedQuestionMark: true
|
||||
|
||||
@@ -371,10 +371,10 @@
|
||||
"provisioningKeysUpdated": "Ключът за осигуряване е актуализиран",
|
||||
"provisioningKeysUpdatedDescription": "Вашите промени бяха запазени.",
|
||||
"provisioningKeysBannerTitle": "Ключове за осигуряване на сайта",
|
||||
"provisioningKeysBannerDescription": "Generate a provisioning key and use it with the Newt connector to automatically create sites on first startup - no need to set up separate credentials for each site.",
|
||||
"provisioningKeysBannerDescription": "Генерирайте ключ за осигуряване и го използвайте със съединителя Newt за автоматично създаване на сайтове при първоначално стартиране - не е необходимо да се създават отделни идентификационни данни за всеки сайт.",
|
||||
"provisioningKeysBannerButtonText": "Научете повече",
|
||||
"pendingSitesBannerTitle": "Чакащи сайтове",
|
||||
"pendingSitesBannerDescription": "Sites that connect using a provisioning key appear here for review.",
|
||||
"pendingSitesBannerDescription": "Сайтовете, които се свързват с ключ за осигуряване, ще се появят тук за преглед.",
|
||||
"pendingSitesBannerButtonText": "Научете повече",
|
||||
"apiKeysSettings": "Настройки на {apiKeyName}",
|
||||
"userTitle": "Управление на всички потребители",
|
||||
@@ -624,8 +624,8 @@
|
||||
"targetErrorInvalidPortDescription": "Моля, въведете валиден номер на порт",
|
||||
"targetErrorNoSite": "Няма избран сайт",
|
||||
"targetErrorNoSiteDescription": "Моля, изберете сайт за целта",
|
||||
"targetTargetsCleared": "Targets cleared",
|
||||
"targetTargetsClearedDescription": "All targets have been removed from this resource",
|
||||
"targetTargetsCleared": "Мишените са премахнати",
|
||||
"targetTargetsClearedDescription": "Всички цели са били премахнати от този ресурс",
|
||||
"targetCreated": "Целта е създадена",
|
||||
"targetCreatedDescription": "Целта беше успешно създадена",
|
||||
"targetErrorCreate": "Неуспешно създаване на целта",
|
||||
@@ -2348,7 +2348,7 @@
|
||||
"description": "Предприятие, 50 потребители, 50 сайта и приоритетна поддръжка."
|
||||
}
|
||||
},
|
||||
"personalUseOnly": "Personal use only (free license - no checkout)",
|
||||
"personalUseOnly": "Само за лична употреба (безплатен лиценз - без проверка)",
|
||||
"buttons": {
|
||||
"continueToCheckout": "Продължете към плащане"
|
||||
},
|
||||
@@ -2609,9 +2609,9 @@
|
||||
"machineClients": "Машинни клиенти",
|
||||
"install": "Инсталирай",
|
||||
"run": "Изпълни",
|
||||
"envFile": "Environment File",
|
||||
"serviceFile": "Service File",
|
||||
"enableAndStart": "Enable and Start",
|
||||
"envFile": "Файл за среда",
|
||||
"serviceFile": "Файл за услуга",
|
||||
"enableAndStart": "Активиране и стартиране",
|
||||
"clientNameDescription": "Показваното име на клиента, което може да се промени по-късно.",
|
||||
"clientAddress": "Клиентски адрес (Разширено)",
|
||||
"setupFailedToFetchSubnet": "Неуспешно извличане на подмрежа по подразбиране",
|
||||
@@ -2850,10 +2850,10 @@
|
||||
"httpDestAuthNoneTitle": "Без удостоверяване",
|
||||
"httpDestAuthNoneDescription": "Изпращане на заявки без заглавие за удостоверяване.",
|
||||
"httpDestAuthBearerTitle": "Bearer Токен",
|
||||
"httpDestAuthBearerDescription": "Добавя заглавие за удостоверяване Bearer '<token>' към всяка заявка.",
|
||||
"httpDestAuthBearerDescription": "Добавя заглавие Authorization: Bearer '<token>' към всяка заявка.",
|
||||
"httpDestAuthBearerPlaceholder": "Вашият API ключ или токен",
|
||||
"httpDestAuthBasicTitle": "Основно удостоверяване",
|
||||
"httpDestAuthBasicDescription": "Добавя заглавие за удостоверяване Basic '<credentials>' към всяка заявка. Осигурете идентификационни данни като потребителско име:парола.",
|
||||
"httpDestAuthBasicDescription": "Добавя заглавие Authorization: Basic '<credentials>'. Осигурете идентификационни данни като потребителско име:парола.",
|
||||
"httpDestAuthBasicPlaceholder": "потребителско име:парола",
|
||||
"httpDestAuthCustomTitle": "Персонализирано заглавие",
|
||||
"httpDestAuthCustomDescription": "Посочете персонализирано име и стойност на заглавието за удостоверяване (например X-API-Key).",
|
||||
|
||||
@@ -371,10 +371,10 @@
|
||||
"provisioningKeysUpdated": "Zajišťovací klíč byl aktualizován",
|
||||
"provisioningKeysUpdatedDescription": "Vaše změny byly uloženy.",
|
||||
"provisioningKeysBannerTitle": "Klíče pro poskytování webu",
|
||||
"provisioningKeysBannerDescription": "Generate a provisioning key and use it with the Newt connector to automatically create sites on first startup - no need to set up separate credentials for each site.",
|
||||
"provisioningKeysBannerDescription": "Vygenerujte klíč pro zřízení a použijte ho s Newt konektorem k automatickému vytvoření stránek při prvním spuštění – není potřeba nastavit samostatné přihlašovací údaje pro každou stránku.",
|
||||
"provisioningKeysBannerButtonText": "Zjistit více",
|
||||
"pendingSitesBannerTitle": "Nevyřízené weby",
|
||||
"pendingSitesBannerDescription": "Sites that connect using a provisioning key appear here for review.",
|
||||
"pendingSitesBannerDescription": "Stránky, které se připojují pomocí klíče pro zřízení, se zde objeví ke kontrole.",
|
||||
"pendingSitesBannerButtonText": "Zjistit více",
|
||||
"apiKeysSettings": "Nastavení {apiKeyName}",
|
||||
"userTitle": "Spravovat všechny uživatele",
|
||||
@@ -624,8 +624,8 @@
|
||||
"targetErrorInvalidPortDescription": "Zadejte platné číslo portu",
|
||||
"targetErrorNoSite": "Není vybrán žádný web",
|
||||
"targetErrorNoSiteDescription": "Vyberte prosím web pro cíl",
|
||||
"targetTargetsCleared": "Targets cleared",
|
||||
"targetTargetsClearedDescription": "All targets have been removed from this resource",
|
||||
"targetTargetsCleared": "Cíle vymazány",
|
||||
"targetTargetsClearedDescription": "Všechny cíle byly odstraněny z tohoto zdroje",
|
||||
"targetCreated": "Cíl byl vytvořen",
|
||||
"targetCreatedDescription": "Cíl byl úspěšně vytvořen",
|
||||
"targetErrorCreate": "Nepodařilo se vytvořit cíl",
|
||||
@@ -2348,7 +2348,7 @@
|
||||
"description": "Podnikové funkce, 50 uživatelů, 50 míst a prioritní podpory."
|
||||
}
|
||||
},
|
||||
"personalUseOnly": "Personal use only (free license - no checkout)",
|
||||
"personalUseOnly": "Pouze pro osobní použití (zdarma licence - bez ověření)",
|
||||
"buttons": {
|
||||
"continueToCheckout": "Pokračovat do pokladny"
|
||||
},
|
||||
@@ -2609,9 +2609,9 @@
|
||||
"machineClients": "Strojoví klienti",
|
||||
"install": "Instalovat",
|
||||
"run": "Spustit",
|
||||
"envFile": "Environment File",
|
||||
"serviceFile": "Service File",
|
||||
"enableAndStart": "Enable and Start",
|
||||
"envFile": "Konfigurační soubor prostředí",
|
||||
"serviceFile": "Služební soubor",
|
||||
"enableAndStart": "Povolit a spustit",
|
||||
"clientNameDescription": "Zobrazované jméno klienta, které lze později změnit.",
|
||||
"clientAddress": "Adresa klienta (Rozšířeno)",
|
||||
"setupFailedToFetchSubnet": "Nepodařilo se načíst výchozí podsíť",
|
||||
@@ -2850,10 +2850,10 @@
|
||||
"httpDestAuthNoneTitle": "Žádné ověření",
|
||||
"httpDestAuthNoneDescription": "Odešle žádosti bez záhlaví autorizace.",
|
||||
"httpDestAuthBearerTitle": "Token na doručitele",
|
||||
"httpDestAuthBearerDescription": "Přidá autorizaci: Hlavička Bearer '<token>' ke každému požadavku.",
|
||||
"httpDestAuthBearerDescription": "Přidává hlavičku Authorization: Bearer '<token>' k každému požadavku.",
|
||||
"httpDestAuthBearerPlaceholder": "Váš API klíč nebo token",
|
||||
"httpDestAuthBasicTitle": "Základní ověření",
|
||||
"httpDestAuthBasicDescription": "Přidá autorizaci: Základní '<credentials>' hlavička. Poskytněte přihlašovací údaje jako uživatelské jméno:password.",
|
||||
"httpDestAuthBasicDescription": "Přidává hlavičku Authorization: Basic '<credentials>'. Poskytněte přihlašovací údaje ve formátu uživatelské jméno:heslo.",
|
||||
"httpDestAuthBasicPlaceholder": "uživatelské jméno:heslo",
|
||||
"httpDestAuthCustomTitle": "Vlastní záhlaví",
|
||||
"httpDestAuthCustomDescription": "Zadejte název a hodnotu vlastního HTTP hlavičky pro ověření (např. X-API-Key).",
|
||||
|
||||
@@ -371,10 +371,10 @@
|
||||
"provisioningKeysUpdated": "Bereitstellungsschlüssel aktualisiert",
|
||||
"provisioningKeysUpdatedDescription": "Ihre Änderungen wurden gespeichert.",
|
||||
"provisioningKeysBannerTitle": "Website-Bereitstellungsschlüssel",
|
||||
"provisioningKeysBannerDescription": "Generate a provisioning key and use it with the Newt connector to automatically create sites on first startup - no need to set up separate credentials for each site.",
|
||||
"provisioningKeysBannerDescription": "Generieren Sie einen Bereitstellungsschlüssel und verwenden Sie ihn mit dem Newt-Connector, um Standorte beim ersten Start automatisch zu erstellen - keine Notwendigkeit, separate Anmeldedaten für jede Seite einzurichten.",
|
||||
"provisioningKeysBannerButtonText": "Mehr erfahren",
|
||||
"pendingSitesBannerTitle": "Ausstehende Seiten",
|
||||
"pendingSitesBannerDescription": "Sites that connect using a provisioning key appear here for review.",
|
||||
"pendingSitesBannerDescription": "Websites, die mit einem Bereitstellungsschlüssel verbunden sind, erscheinen hier zur Überprüfung.",
|
||||
"pendingSitesBannerButtonText": "Mehr erfahren",
|
||||
"apiKeysSettings": "{apiKeyName} Einstellungen",
|
||||
"userTitle": "Alle Benutzer verwalten",
|
||||
@@ -624,8 +624,8 @@
|
||||
"targetErrorInvalidPortDescription": "Bitte geben Sie eine gültige Portnummer ein",
|
||||
"targetErrorNoSite": "Kein Standort ausgewählt",
|
||||
"targetErrorNoSiteDescription": "Bitte wähle einen Standort für das Ziel aus",
|
||||
"targetTargetsCleared": "Targets cleared",
|
||||
"targetTargetsClearedDescription": "All targets have been removed from this resource",
|
||||
"targetTargetsCleared": "Ziele gelöscht",
|
||||
"targetTargetsClearedDescription": "Alle Ziele wurden aus dieser Ressource entfernt",
|
||||
"targetCreated": "Ziel erstellt",
|
||||
"targetCreatedDescription": "Ziel wurde erfolgreich erstellt",
|
||||
"targetErrorCreate": "Fehler beim Erstellen des Ziels",
|
||||
@@ -2348,7 +2348,7 @@
|
||||
"description": "Enterprise Features, 50 Benutzer, 50 Sites und Prioritätsunterstützung."
|
||||
}
|
||||
},
|
||||
"personalUseOnly": "Personal use only (free license - no checkout)",
|
||||
"personalUseOnly": "Nur persönliche Nutzung (kostenlose Lizenz - kein Checkout)",
|
||||
"buttons": {
|
||||
"continueToCheckout": "Weiter zur Kasse"
|
||||
},
|
||||
@@ -2609,9 +2609,9 @@
|
||||
"machineClients": "Maschinen-Clients",
|
||||
"install": "Installieren",
|
||||
"run": "Ausführen",
|
||||
"envFile": "Environment File",
|
||||
"serviceFile": "Service File",
|
||||
"enableAndStart": "Enable and Start",
|
||||
"envFile": "Umgebungsdatei",
|
||||
"serviceFile": "Servicedatei",
|
||||
"enableAndStart": "Aktivieren und Starten",
|
||||
"clientNameDescription": "Der Anzeigename des Clients, der später geändert werden kann.",
|
||||
"clientAddress": "Clientadresse (Erweitert)",
|
||||
"setupFailedToFetchSubnet": "Fehler beim Abrufen des Standard-Subnetzes",
|
||||
@@ -2850,10 +2850,10 @@
|
||||
"httpDestAuthNoneTitle": "Keine Authentifizierung",
|
||||
"httpDestAuthNoneDescription": "Sendet Anfragen ohne Autorisierungs-Header.",
|
||||
"httpDestAuthBearerTitle": "Bären-Token",
|
||||
"httpDestAuthBearerDescription": "Fügt eine Berechtigung hinzu: Bearer '<token>' Header zu jeder Anfrage.",
|
||||
"httpDestAuthBearerDescription": "Fügt jedem Anfrage-Header eine \"Authorization: Bearer '<token>'\" hinzu.",
|
||||
"httpDestAuthBearerPlaceholder": "Ihr API-Schlüssel oder Token",
|
||||
"httpDestAuthBasicTitle": "Einfacher Auth",
|
||||
"httpDestAuthBasicDescription": "Fügt eine Autorisierung hinzu: Basic '<credentials>' Kopfzeile hinzu. Geben Sie Anmeldedaten als Benutzername:password an.",
|
||||
"httpDestAuthBasicDescription": "Fügt einen \"Authorization: Basic '<credentials>'\"-Header hinzu. Geben Sie die Anmeldedaten als Benutzername:Passwort an.",
|
||||
"httpDestAuthBasicPlaceholder": "benutzername:password",
|
||||
"httpDestAuthCustomTitle": "Eigene Kopfzeile",
|
||||
"httpDestAuthCustomDescription": "Geben Sie einen eigenen HTTP-Header-Namen und einen Wert für die Authentifizierung an (z.B. X-API-Key).",
|
||||
|
||||
@@ -371,10 +371,10 @@
|
||||
"provisioningKeysUpdated": "Clave de aprovisionamiento actualizada",
|
||||
"provisioningKeysUpdatedDescription": "Sus cambios han sido guardados.",
|
||||
"provisioningKeysBannerTitle": "Claves de aprovisionamiento del sitio",
|
||||
"provisioningKeysBannerDescription": "Generate a provisioning key and use it with the Newt connector to automatically create sites on first startup - no need to set up separate credentials for each site.",
|
||||
"provisioningKeysBannerDescription": "Genere una clave de aprovisionamiento y utilícela con el conector Newt para crear automáticamente sitios en el primer inicio: no es necesario configurar credenciales separadas para cada sitio.",
|
||||
"provisioningKeysBannerButtonText": "Saber más",
|
||||
"pendingSitesBannerTitle": "Sitios pendientes",
|
||||
"pendingSitesBannerDescription": "Sites that connect using a provisioning key appear here for review.",
|
||||
"pendingSitesBannerDescription": "Los sitios que se conectan utilizando una clave de aprovisionamiento aparecerán aquí para su revisión.",
|
||||
"pendingSitesBannerButtonText": "Saber más",
|
||||
"apiKeysSettings": "Ajustes {apiKeyName}",
|
||||
"userTitle": "Administrar todos los usuarios",
|
||||
@@ -624,8 +624,8 @@
|
||||
"targetErrorInvalidPortDescription": "Por favor, introduzca un número de puerto válido",
|
||||
"targetErrorNoSite": "Ningún sitio seleccionado",
|
||||
"targetErrorNoSiteDescription": "Por favor, seleccione un sitio para el objetivo",
|
||||
"targetTargetsCleared": "Targets cleared",
|
||||
"targetTargetsClearedDescription": "All targets have been removed from this resource",
|
||||
"targetTargetsCleared": "Objetivos eliminados",
|
||||
"targetTargetsClearedDescription": "Todos los objetivos han sido eliminados de este recurso",
|
||||
"targetCreated": "Objetivo creado",
|
||||
"targetCreatedDescription": "El objetivo se ha creado correctamente",
|
||||
"targetErrorCreate": "Error al crear el objetivo",
|
||||
@@ -2348,7 +2348,7 @@
|
||||
"description": "Características de la empresa, 50 usuarios, 50 sitios y soporte prioritario."
|
||||
}
|
||||
},
|
||||
"personalUseOnly": "Personal use only (free license - no checkout)",
|
||||
"personalUseOnly": "Solo uso personal (licencia gratuita - sin salida)",
|
||||
"buttons": {
|
||||
"continueToCheckout": "Continuar con el pago"
|
||||
},
|
||||
@@ -2609,9 +2609,9 @@
|
||||
"machineClients": "Clientes de la máquina",
|
||||
"install": "Instalar",
|
||||
"run": "Ejecutar",
|
||||
"envFile": "Environment File",
|
||||
"serviceFile": "Service File",
|
||||
"enableAndStart": "Enable and Start",
|
||||
"envFile": "Archivo de Entorno",
|
||||
"serviceFile": "Archivo de Servicio",
|
||||
"enableAndStart": "Habilitar y empezar",
|
||||
"clientNameDescription": "El nombre mostrado del cliente que se puede cambiar más adelante.",
|
||||
"clientAddress": "Dirección del cliente (Avanzado)",
|
||||
"setupFailedToFetchSubnet": "No se pudo obtener la subred por defecto",
|
||||
@@ -2850,10 +2850,10 @@
|
||||
"httpDestAuthNoneTitle": "Sin autenticación",
|
||||
"httpDestAuthNoneDescription": "Envía solicitudes sin un encabezado de autorización.",
|
||||
"httpDestAuthBearerTitle": "Tóken de portador",
|
||||
"httpDestAuthBearerDescription": "Añade una autorización: portador '<token>' encabezado a cada solicitud.",
|
||||
"httpDestAuthBearerDescription": "Añade un encabezado Authorization: Bearer '<token>' a cada solicitud.",
|
||||
"httpDestAuthBearerPlaceholder": "Tu clave o token API",
|
||||
"httpDestAuthBasicTitle": "Auth Básica",
|
||||
"httpDestAuthBasicDescription": "Añade una Autorización: encabezado básico '<credentials>' . Proporcione credenciales como nombre de usuario: contraseña.",
|
||||
"httpDestAuthBasicDescription": "Añade un encabezado Authorization: Basic '<credenciales>'. Proporcione las credenciales como nombredeusuario:contraseña.",
|
||||
"httpDestAuthBasicPlaceholder": "usuario:contraseña",
|
||||
"httpDestAuthCustomTitle": "Cabecera personalizada",
|
||||
"httpDestAuthCustomDescription": "Especifique un nombre de cabecera HTTP personalizado y un valor para la autenticación (por ejemplo, X-API-Key).",
|
||||
|
||||
@@ -371,10 +371,10 @@
|
||||
"provisioningKeysUpdated": "Clé de provisioning mise à jour",
|
||||
"provisioningKeysUpdatedDescription": "Vos modifications ont été enregistrées.",
|
||||
"provisioningKeysBannerTitle": "Clés de provisioning du site",
|
||||
"provisioningKeysBannerDescription": "Generate a provisioning key and use it with the Newt connector to automatically create sites on first startup - no need to set up separate credentials for each site.",
|
||||
"provisioningKeysBannerDescription": "Générez une clé de provisionnement et utilisez-la avec le connecteur Newt pour créer automatiquement des sites lors du premier démarrage - sans besoin de configurer des identifiants séparés pour chaque site.",
|
||||
"provisioningKeysBannerButtonText": "En savoir plus",
|
||||
"pendingSitesBannerTitle": "Sites en attente",
|
||||
"pendingSitesBannerDescription": "Sites that connect using a provisioning key appear here for review.",
|
||||
"pendingSitesBannerDescription": "Les sites qui se connectent en utilisant une clé de provisionnement apparaissent ici pour révision.",
|
||||
"pendingSitesBannerButtonText": "En savoir plus",
|
||||
"apiKeysSettings": "Paramètres de {apiKeyName}",
|
||||
"userTitle": "Gérer tous les utilisateurs",
|
||||
@@ -624,8 +624,8 @@
|
||||
"targetErrorInvalidPortDescription": "Veuillez entrer un numéro de port valide",
|
||||
"targetErrorNoSite": "Aucun site sélectionné",
|
||||
"targetErrorNoSiteDescription": "Veuillez sélectionner un site pour la cible",
|
||||
"targetTargetsCleared": "Targets cleared",
|
||||
"targetTargetsClearedDescription": "All targets have been removed from this resource",
|
||||
"targetTargetsCleared": "Cibles effacées",
|
||||
"targetTargetsClearedDescription": "Toutes les cibles ont été retirées de cette ressource",
|
||||
"targetCreated": "Cible créée",
|
||||
"targetCreatedDescription": "La cible a été créée avec succès",
|
||||
"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."
|
||||
}
|
||||
},
|
||||
"personalUseOnly": "Personal use only (free license - no checkout)",
|
||||
"personalUseOnly": "Usage personnel uniquement (licence gratuite - pas de validation)",
|
||||
"buttons": {
|
||||
"continueToCheckout": "Continuer vers le paiement"
|
||||
},
|
||||
@@ -2609,9 +2609,9 @@
|
||||
"machineClients": "Clients Machines",
|
||||
"install": "Installer",
|
||||
"run": "Exécuter",
|
||||
"envFile": "Environment File",
|
||||
"serviceFile": "Service File",
|
||||
"enableAndStart": "Enable and Start",
|
||||
"envFile": "Fichier Environnement",
|
||||
"serviceFile": "Fichier de Service",
|
||||
"enableAndStart": "Activer et Démarrer",
|
||||
"clientNameDescription": "Le nom d'affichage du client qui peut être modifié plus tard.",
|
||||
"clientAddress": "Adresse du client (Avancé)",
|
||||
"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.",
|
||||
"httpDestAuthBearerPlaceholder": "Votre clé API ou votre jeton",
|
||||
"httpDestAuthBasicTitle": "Authentification basique",
|
||||
"httpDestAuthBasicDescription": "Ajoute une autorisation : en-tête de base '<credentials>' . Fournissez des informations d'identification comme nom d'utilisateur:mot de passe.",
|
||||
"httpDestAuthBasicDescription": "Ajoute un en-tête Authorization: Basic '<credentials>'. Fournissez les identifiants sous la forme nom d'utilisateur:mot de passe.",
|
||||
"httpDestAuthBasicPlaceholder": "nom d'utilisateur:mot de passe",
|
||||
"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).",
|
||||
|
||||
@@ -371,10 +371,10 @@
|
||||
"provisioningKeysUpdated": "Chiave di accantonamento aggiornata",
|
||||
"provisioningKeysUpdatedDescription": "Le tue modifiche sono state salvate.",
|
||||
"provisioningKeysBannerTitle": "Chiavi Di Provvedimento Sito",
|
||||
"provisioningKeysBannerDescription": "Generate a provisioning key and use it with the Newt connector to automatically create sites on first startup - no need to set up separate credentials for each site.",
|
||||
"provisioningKeysBannerDescription": "Genera una chiave di provisioning e usala con il connettore Newt per creare automaticamente i siti al primo avvio - non è necessario configurare credenziali separate per ogni sito.",
|
||||
"provisioningKeysBannerButtonText": "Scopri di più",
|
||||
"pendingSitesBannerTitle": "Siti In Attesa",
|
||||
"pendingSitesBannerDescription": "Sites that connect using a provisioning key appear here for review.",
|
||||
"pendingSitesBannerDescription": "I siti che si connettono utilizzando una chiave di provisioning vengono visualizzati qui per la revisione.",
|
||||
"pendingSitesBannerButtonText": "Scopri di più",
|
||||
"apiKeysSettings": "Impostazioni {apiKeyName}",
|
||||
"userTitle": "Gestisci Tutti Gli Utenti",
|
||||
@@ -624,8 +624,8 @@
|
||||
"targetErrorInvalidPortDescription": "Inserisci un numero di porta valido",
|
||||
"targetErrorNoSite": "Nessun sito selezionato",
|
||||
"targetErrorNoSiteDescription": "Si prega di selezionare un sito per l'obiettivo",
|
||||
"targetTargetsCleared": "Targets cleared",
|
||||
"targetTargetsClearedDescription": "All targets have been removed from this resource",
|
||||
"targetTargetsCleared": "Obiettivi cancellati",
|
||||
"targetTargetsClearedDescription": "Tutti gli obiettivi sono stati rimossi da questa risorsa",
|
||||
"targetCreated": "Destinazione creata",
|
||||
"targetCreatedDescription": "L'obiettivo è stato creato con successo",
|
||||
"targetErrorCreate": "Impossibile creare l'obiettivo",
|
||||
@@ -2348,7 +2348,7 @@
|
||||
"description": "Funzionalità aziendali, 50 utenti, 50 siti e supporto prioritario."
|
||||
}
|
||||
},
|
||||
"personalUseOnly": "Personal use only (free license - no checkout)",
|
||||
"personalUseOnly": "Uso personale esclusivo (licenza gratuita - nessun pagamento)",
|
||||
"buttons": {
|
||||
"continueToCheckout": "Continua al Checkout"
|
||||
},
|
||||
@@ -2609,9 +2609,9 @@
|
||||
"machineClients": "Machine Clients",
|
||||
"install": "Installa",
|
||||
"run": "Esegui",
|
||||
"envFile": "Environment File",
|
||||
"serviceFile": "Service File",
|
||||
"enableAndStart": "Enable and Start",
|
||||
"envFile": "File di ambiente",
|
||||
"serviceFile": "File di servizio",
|
||||
"enableAndStart": "Abilita e avvia",
|
||||
"clientNameDescription": "Il nome visualizzato del client che può essere modificato in seguito.",
|
||||
"clientAddress": "Indirizzo Client (Avanzato)",
|
||||
"setupFailedToFetchSubnet": "Recupero della sottorete predefinita non riuscito",
|
||||
@@ -2850,10 +2850,10 @@
|
||||
"httpDestAuthNoneTitle": "Nessuna Autenticazione",
|
||||
"httpDestAuthNoneDescription": "Invia richieste senza intestazione autorizzazione.",
|
||||
"httpDestAuthBearerTitle": "Token Del Portatore",
|
||||
"httpDestAuthBearerDescription": "Aggiunge un'intestazione Autorizzazione: Bearer '<token>' ad ogni richiesta.",
|
||||
"httpDestAuthBearerDescription": "Aggiunge un'intestazione Authorization: Bearer '<token>' a ogni richiesta.",
|
||||
"httpDestAuthBearerPlaceholder": "La tua chiave API o token",
|
||||
"httpDestAuthBasicTitle": "Autenticazione Base",
|
||||
"httpDestAuthBasicDescription": "Aggiunge un'autorizzazione: intestazione di base '<credentials>' . Fornisce le credenziali come username:password.",
|
||||
"httpDestAuthBasicDescription": "Aggiunge un'intestazione Authorization: Basic '<credentials>'. Fornire le credenziali come username:password.",
|
||||
"httpDestAuthBasicPlaceholder": "username:password",
|
||||
"httpDestAuthCustomTitle": "Intestazione Personalizzata",
|
||||
"httpDestAuthCustomDescription": "Specifica un nome e un valore di intestazione HTTP personalizzati per l'autenticazione (ad esempio X-API-Key).",
|
||||
|
||||
@@ -371,10 +371,10 @@
|
||||
"provisioningKeysUpdated": "프로비저닝 키가 업데이트되었습니다",
|
||||
"provisioningKeysUpdatedDescription": "변경 사항이 저장되었습니다.",
|
||||
"provisioningKeysBannerTitle": "사이트 프로비저닝 키",
|
||||
"provisioningKeysBannerDescription": "Generate a provisioning key and use it with the Newt connector to automatically create sites on first startup - no need to set up separate credentials for each site.",
|
||||
"provisioningKeysBannerDescription": "프로비저닝 키를 생성하고 Newt 커넥터와 함께 사용하여 첫 시작 시 사이트를 자동 생성 - 각 사이트에 대한 별도 자격 증명이 필요 없습니다.",
|
||||
"provisioningKeysBannerButtonText": "자세히 알아보기",
|
||||
"pendingSitesBannerTitle": "대기중인 사이트",
|
||||
"pendingSitesBannerDescription": "Sites that connect using a provisioning key appear here for review.",
|
||||
"pendingSitesBannerDescription": "프로비저닝 키를 사용하여 연결된 사이트가 검토를 위해 여기에 표시됩니다.",
|
||||
"pendingSitesBannerButtonText": "자세히 알아보기",
|
||||
"apiKeysSettings": "{apiKeyName} 설정",
|
||||
"userTitle": "모든 사용자 관리",
|
||||
@@ -624,8 +624,8 @@
|
||||
"targetErrorInvalidPortDescription": "유효한 포트 번호를 입력하세요.",
|
||||
"targetErrorNoSite": "선택된 사이트 없음",
|
||||
"targetErrorNoSiteDescription": "대상을 위해 사이트를 선택하세요.",
|
||||
"targetTargetsCleared": "Targets cleared",
|
||||
"targetTargetsClearedDescription": "All targets have been removed from this resource",
|
||||
"targetTargetsCleared": "대상이 제거됨",
|
||||
"targetTargetsClearedDescription": "이 리소스에서 모든 대상이 제거되었습니다",
|
||||
"targetCreated": "대상 생성",
|
||||
"targetCreatedDescription": "대상이 성공적으로 생성되었습니다.",
|
||||
"targetErrorCreate": "대상 생성 실패",
|
||||
@@ -2348,7 +2348,7 @@
|
||||
"description": "기업 기능, 50명의 사용자, 50개의 사이트, 우선 지원."
|
||||
}
|
||||
},
|
||||
"personalUseOnly": "Personal use only (free license - no checkout)",
|
||||
"personalUseOnly": "개인용으로만 사용 (무료 라이선스 - 결제 없음)",
|
||||
"buttons": {
|
||||
"continueToCheckout": "결제로 진행"
|
||||
},
|
||||
@@ -2609,9 +2609,9 @@
|
||||
"machineClients": "기계 클라이언트",
|
||||
"install": "설치",
|
||||
"run": "실행",
|
||||
"envFile": "Environment File",
|
||||
"serviceFile": "Service File",
|
||||
"enableAndStart": "Enable and Start",
|
||||
"envFile": "환경 파일",
|
||||
"serviceFile": "서비스 파일",
|
||||
"enableAndStart": "활성화 및 시작",
|
||||
"clientNameDescription": "나중에 변경할 수 있는 클라이언트의 표시 이름입니다.",
|
||||
"clientAddress": "클라이언트 주소(고급)",
|
||||
"setupFailedToFetchSubnet": "기본값 로드 실패",
|
||||
@@ -2850,10 +2850,10 @@
|
||||
"httpDestAuthNoneTitle": "인증 없음",
|
||||
"httpDestAuthNoneDescription": "Authorization 헤더 없이 요청을 보냅니다.",
|
||||
"httpDestAuthBearerTitle": "Bearer 토큰",
|
||||
"httpDestAuthBearerDescription": "모든 요청에 Authorization: Bearer '<token>' 헤더를 추가합니다.",
|
||||
"httpDestAuthBearerDescription": "각 요청에 Authorization: Bearer '<token>' 헤더를 추가합니다.",
|
||||
"httpDestAuthBearerPlaceholder": "API 키 또는 토큰",
|
||||
"httpDestAuthBasicTitle": "기본 인증",
|
||||
"httpDestAuthBasicDescription": "Authorization: Basic '<credentials>' 헤더를 추가합니다. 자격 증명은 username:password 형식으로 제공하세요.",
|
||||
"httpDestAuthBasicDescription": "Authorization: Basic '<credentials>' 헤더를 추가합니다. 자격 증명은 사용자 이름:비밀번호로 제공합니다.",
|
||||
"httpDestAuthBasicPlaceholder": "사용자 이름:비밀번호",
|
||||
"httpDestAuthCustomTitle": "사용자 정의 헤더",
|
||||
"httpDestAuthCustomDescription": "인증을 위한 사용자 정의 HTTP 헤더 이름 및 값을 지정하세요 (예: X-API-Key).",
|
||||
|
||||
@@ -371,10 +371,10 @@
|
||||
"provisioningKeysUpdated": "Foreslå nøkkel oppdatert",
|
||||
"provisioningKeysUpdatedDescription": "Dine endringer er lagret.",
|
||||
"provisioningKeysBannerTitle": "Sidens bestemmende nøkler",
|
||||
"provisioningKeysBannerDescription": "Generate a provisioning key and use it with the Newt connector to automatically create sites on first startup - no need to set up separate credentials for each site.",
|
||||
"provisioningKeysBannerDescription": "Generer en provisjonsnøkkel og bruk den med Newt-kontakten for automatisk opprettelse av nettsteder ved første oppstart - ingen behov for å sette opp separate legitimasjoner for hvert nettsted.",
|
||||
"provisioningKeysBannerButtonText": "Lær mer",
|
||||
"pendingSitesBannerTitle": "Ventende nettsteder",
|
||||
"pendingSitesBannerDescription": "Sites that connect using a provisioning key appear here for review.",
|
||||
"pendingSitesBannerDescription": "Nettsteder som kobler seg til ved bruk av en provisjonsnøkkel vises her for vurdering.",
|
||||
"pendingSitesBannerButtonText": "Lær mer",
|
||||
"apiKeysSettings": "{apiKeyName} Innstillinger",
|
||||
"userTitle": "Administrer alle brukere",
|
||||
@@ -624,8 +624,8 @@
|
||||
"targetErrorInvalidPortDescription": "Vennligst skriv inn et gyldig portnummer",
|
||||
"targetErrorNoSite": "Ingen nettsted valgt",
|
||||
"targetErrorNoSiteDescription": "Velg et nettsted for målet",
|
||||
"targetTargetsCleared": "Targets cleared",
|
||||
"targetTargetsClearedDescription": "All targets have been removed from this resource",
|
||||
"targetTargetsCleared": "Mål ryddet",
|
||||
"targetTargetsClearedDescription": "Alle mål har blitt fjernet fra denne ressursen",
|
||||
"targetCreated": "Mål opprettet",
|
||||
"targetCreatedDescription": "Målet har blitt opprettet",
|
||||
"targetErrorCreate": "Kunne ikke opprette målet",
|
||||
@@ -2348,7 +2348,7 @@
|
||||
"description": "Enterprise features, 50 brukere, 50 nettsteder og prioritetsstøtte."
|
||||
}
|
||||
},
|
||||
"personalUseOnly": "Personal use only (free license - no checkout)",
|
||||
"personalUseOnly": "Kun personlig bruk (gratis lisens - ingen kasse)",
|
||||
"buttons": {
|
||||
"continueToCheckout": "Fortsett til kassen"
|
||||
},
|
||||
@@ -2609,9 +2609,9 @@
|
||||
"machineClients": "Maskinklienter",
|
||||
"install": "Installer",
|
||||
"run": "Kjør",
|
||||
"envFile": "Environment File",
|
||||
"serviceFile": "Service File",
|
||||
"enableAndStart": "Enable and Start",
|
||||
"envFile": "Miljøfil",
|
||||
"serviceFile": "Tjenestefil",
|
||||
"enableAndStart": "Aktiver og start",
|
||||
"clientNameDescription": "Visningsnavnet til klienten som kan endres senere.",
|
||||
"clientAddress": "Klientadresse (avansert)",
|
||||
"setupFailedToFetchSubnet": "Kunne ikke hente standard undernett",
|
||||
@@ -2850,10 +2850,10 @@
|
||||
"httpDestAuthNoneTitle": "Ingen godkjenning",
|
||||
"httpDestAuthNoneDescription": "Sender forespørsler uten autorisasjonsoverskrift.",
|
||||
"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",
|
||||
"httpDestAuthBasicTitle": "Standard Auth",
|
||||
"httpDestAuthBasicDescription": "Legger til en godkjenning: Grunnleggende '<credentials>' overskrift. Angi legitimasjon som brukernavn:passord.",
|
||||
"httpDestAuthBasicDescription": "Legger til en Autorisasjon: Basic '<credentials>' header. Gi legitimasjon som brukernavn:passord.",
|
||||
"httpDestAuthBasicPlaceholder": "brukernavn:passord",
|
||||
"httpDestAuthCustomTitle": "Egendefinert topptekst",
|
||||
"httpDestAuthCustomDescription": "Angi et egendefinert HTTP headers navn og verdi for autentisering (f.eks X-API-Key).",
|
||||
|
||||
@@ -371,10 +371,10 @@
|
||||
"provisioningKeysUpdated": "Provisie sleutel bijgewerkt",
|
||||
"provisioningKeysUpdatedDescription": "Uw wijzigingen zijn opgeslagen.",
|
||||
"provisioningKeysBannerTitle": "Bewerkingssleutels voor websites",
|
||||
"provisioningKeysBannerDescription": "Generate a provisioning key and use it with the Newt connector to automatically create sites on first startup - no need to set up separate credentials for each site.",
|
||||
"provisioningKeysBannerDescription": "Genereer een inrichtingssleutel en gebruik deze met de Newt-connector om automatisch sites te maken bij de eerste opstart - er is geen behoefte om aparte inloggegevens voor elke site in te stellen.",
|
||||
"provisioningKeysBannerButtonText": "Meer informatie",
|
||||
"pendingSitesBannerTitle": "Openstaande sites",
|
||||
"pendingSitesBannerDescription": "Sites that connect using a provisioning key appear here for review.",
|
||||
"pendingSitesBannerDescription": "Sites die verbinding maken met een inrichtingssleutel verschijnen hier voor beoordeling.",
|
||||
"pendingSitesBannerButtonText": "Meer informatie",
|
||||
"apiKeysSettings": "{apiKeyName} instellingen",
|
||||
"userTitle": "Alle gebruikers beheren",
|
||||
@@ -624,8 +624,8 @@
|
||||
"targetErrorInvalidPortDescription": "Voer een geldig poortnummer in",
|
||||
"targetErrorNoSite": "Geen site geselecteerd",
|
||||
"targetErrorNoSiteDescription": "Selecteer een site voor het doel",
|
||||
"targetTargetsCleared": "Targets cleared",
|
||||
"targetTargetsClearedDescription": "All targets have been removed from this resource",
|
||||
"targetTargetsCleared": "Doelen gewist",
|
||||
"targetTargetsClearedDescription": "Alle doelen zijn verwijderd van deze bron",
|
||||
"targetCreated": "Doel aangemaakt",
|
||||
"targetCreatedDescription": "Doel is succesvol aangemaakt",
|
||||
"targetErrorCreate": "Kan doel niet aanmaken",
|
||||
@@ -2348,7 +2348,7 @@
|
||||
"description": "Enterprise functies, 50 gebruikers, 50 sites en prioriteit ondersteuning."
|
||||
}
|
||||
},
|
||||
"personalUseOnly": "Personal use only (free license - no checkout)",
|
||||
"personalUseOnly": "Alleen voor persoonlijk gebruik (gratis licentie - geen afrekening)",
|
||||
"buttons": {
|
||||
"continueToCheckout": "Doorgaan naar afrekenen"
|
||||
},
|
||||
@@ -2609,9 +2609,9 @@
|
||||
"machineClients": "Machine Clienten",
|
||||
"install": "Installeren",
|
||||
"run": "Uitvoeren",
|
||||
"envFile": "Environment File",
|
||||
"serviceFile": "Service File",
|
||||
"enableAndStart": "Enable and Start",
|
||||
"envFile": "Omgevingsbestand",
|
||||
"serviceFile": "Servicebestand",
|
||||
"enableAndStart": "Inschakelen en Starten",
|
||||
"clientNameDescription": "De weergavenaam van de client die later gewijzigd kan worden.",
|
||||
"clientAddress": "Klant adres (Geavanceerd)",
|
||||
"setupFailedToFetchSubnet": "Kan standaard subnet niet ophalen",
|
||||
@@ -2850,10 +2850,10 @@
|
||||
"httpDestAuthNoneTitle": "Geen authenticatie",
|
||||
"httpDestAuthNoneDescription": "Stuurt verzoeken zonder toestemmingskop.",
|
||||
"httpDestAuthBearerTitle": "Betere Token",
|
||||
"httpDestAuthBearerDescription": "Voegt een machtiging toe: Drager '<token>' header aan elke aanvraag.",
|
||||
"httpDestAuthBearerDescription": "Voegt een Authorization: Bearer '<token>' header toe aan elk verzoek.",
|
||||
"httpDestAuthBearerPlaceholder": "Uw API-sleutel of -token",
|
||||
"httpDestAuthBasicTitle": "Basis authenticatie",
|
||||
"httpDestAuthBasicDescription": "Voegt een Authorizatie toe: Basis '<credentials>' kop. Geef inloggegevens op als gebruikersnaam:wachtwoord.",
|
||||
"httpDestAuthBasicDescription": "Voegt een Authorization: Basic '<credentials>' header toe. Verstrek inloggegevens als gebruikersnaam:wachtwoord.",
|
||||
"httpDestAuthBasicPlaceholder": "Gebruikersnaam:wachtwoord",
|
||||
"httpDestAuthCustomTitle": "Aangepaste koptekst",
|
||||
"httpDestAuthCustomDescription": "Specificeer een aangepaste HTTP header naam en waarde voor authenticatie (bijv. X-API-Key).",
|
||||
|
||||
@@ -371,10 +371,10 @@
|
||||
"provisioningKeysUpdated": "Klucz zaopatrzenia zaktualizowany",
|
||||
"provisioningKeysUpdatedDescription": "Twoje zmiany zostały zapisane.",
|
||||
"provisioningKeysBannerTitle": "Klucze Zaopatrzenia witryny",
|
||||
"provisioningKeysBannerDescription": "Generate a provisioning key and use it with the Newt connector to automatically create sites on first startup - no need to set up separate credentials for each site.",
|
||||
"provisioningKeysBannerDescription": "Wygeneruj klucz provisioning i użyj go z konektorem Newt do automatycznego tworzenia witryn przy pierwszym uruchomieniu - nie ma potrzeby konfigurowania oddzielnych poświadczeń dla każdej witryny.",
|
||||
"provisioningKeysBannerButtonText": "Dowiedz się więcej",
|
||||
"pendingSitesBannerTitle": "Witryny oczekujące",
|
||||
"pendingSitesBannerDescription": "Sites that connect using a provisioning key appear here for review.",
|
||||
"pendingSitesBannerDescription": "Witryny, które łączą się za pomocą klucza provisioning, pojawią się tutaj do przeglądu.",
|
||||
"pendingSitesBannerButtonText": "Dowiedz się więcej",
|
||||
"apiKeysSettings": "Ustawienia {apiKeyName}",
|
||||
"userTitle": "Zarządzaj wszystkimi użytkownikami",
|
||||
@@ -624,8 +624,8 @@
|
||||
"targetErrorInvalidPortDescription": "Wprowadź prawidłowy numer portu",
|
||||
"targetErrorNoSite": "Nie wybrano witryny",
|
||||
"targetErrorNoSiteDescription": "Wybierz witrynę docelową",
|
||||
"targetTargetsCleared": "Targets cleared",
|
||||
"targetTargetsClearedDescription": "All targets have been removed from this resource",
|
||||
"targetTargetsCleared": "Cele wyczyszczone",
|
||||
"targetTargetsClearedDescription": "Wszystkie cele zostały usunięte z tego zasobu",
|
||||
"targetCreated": "Cel utworzony",
|
||||
"targetCreatedDescription": "Cel został utworzony pomyślnie",
|
||||
"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."
|
||||
}
|
||||
},
|
||||
"personalUseOnly": "Personal use only (free license - no checkout)",
|
||||
"personalUseOnly": "Tylko do użytku osobistego (darmowa licencja - bez płatności)",
|
||||
"buttons": {
|
||||
"continueToCheckout": "Przejdź do zamówienia"
|
||||
},
|
||||
@@ -2609,9 +2609,9 @@
|
||||
"machineClients": "Klienci maszyn",
|
||||
"install": "Zainstaluj",
|
||||
"run": "Uruchom",
|
||||
"envFile": "Environment File",
|
||||
"serviceFile": "Service File",
|
||||
"enableAndStart": "Enable and Start",
|
||||
"envFile": "Plik środowiska",
|
||||
"serviceFile": "Plik serwisu",
|
||||
"enableAndStart": "Włącz i Uruchom",
|
||||
"clientNameDescription": "Wyświetlana nazwa klienta, która może zostać zmieniona później.",
|
||||
"clientAddress": "Adres klienta (Zaawansowany)",
|
||||
"setupFailedToFetchSubnet": "Nie udało się pobrać domyślnej podsieci",
|
||||
@@ -2850,10 +2850,10 @@
|
||||
"httpDestAuthNoneTitle": "Brak uwierzytelniania",
|
||||
"httpDestAuthNoneDescription": "Wysyła żądania bez nagłówka autoryzacji.",
|
||||
"httpDestAuthBearerTitle": "Token Bearer",
|
||||
"httpDestAuthBearerDescription": "Dodaje autoryzację: nagłówek Bearer '<token>' do każdego żądania.",
|
||||
"httpDestAuthBearerDescription": "Dodaje nagłówek Authorization: Bearer '<token>' do każdego żądania.",
|
||||
"httpDestAuthBearerPlaceholder": "Twój klucz API lub token",
|
||||
"httpDestAuthBasicTitle": "Podstawowa Autoryzacja",
|
||||
"httpDestAuthBasicDescription": "Dodaje Autoryzacja: Nagłówek Basic '<credentials>' . Podaj poświadczenia jako nazwę użytkownika: hasło.",
|
||||
"httpDestAuthBasicDescription": "Dodaje nagłówek Authorization: Basic '<credentials>'. Podaj poświadczenia w formacie użytkownik:hasło.",
|
||||
"httpDestAuthBasicPlaceholder": "Nazwa użytkownika:hasło",
|
||||
"httpDestAuthCustomTitle": "Niestandardowy nagłówek",
|
||||
"httpDestAuthCustomDescription": "Określ niestandardową nazwę nagłówka HTTP i wartość dla uwierzytelniania (np. X-API-Key).",
|
||||
|
||||
@@ -371,10 +371,10 @@
|
||||
"provisioningKeysUpdated": "Chave de provisionamento atualizada",
|
||||
"provisioningKeysUpdatedDescription": "Suas alterações foram salvas.",
|
||||
"provisioningKeysBannerTitle": "Chaves de provisionamento do site",
|
||||
"provisioningKeysBannerDescription": "Generate a provisioning key and use it with the Newt connector to automatically create sites on first startup - no need to set up separate credentials for each site.",
|
||||
"provisioningKeysBannerDescription": "Gere uma chave de provisionamento e use-a com o conector Newt para criar sites automaticamente na primeira inicialização - sem necessidade de configurar credenciais separadas para cada site.",
|
||||
"provisioningKeysBannerButtonText": "Saiba mais",
|
||||
"pendingSitesBannerTitle": "Sites pendentes",
|
||||
"pendingSitesBannerDescription": "Sites that connect using a provisioning key appear here for review.",
|
||||
"pendingSitesBannerDescription": "Sites que se conectam usando uma chave de provisionamento aparecem aqui para revisão.",
|
||||
"pendingSitesBannerButtonText": "Saiba mais",
|
||||
"apiKeysSettings": "Configurações de {apiKeyName}",
|
||||
"userTitle": "Gerir Todos os Utilizadores",
|
||||
@@ -624,8 +624,8 @@
|
||||
"targetErrorInvalidPortDescription": "Por favor, digite um número de porta válido",
|
||||
"targetErrorNoSite": "Nenhum site selecionado",
|
||||
"targetErrorNoSiteDescription": "Selecione um site para o destino",
|
||||
"targetTargetsCleared": "Targets cleared",
|
||||
"targetTargetsClearedDescription": "All targets have been removed from this resource",
|
||||
"targetTargetsCleared": "Alvos limpos",
|
||||
"targetTargetsClearedDescription": "Todos os alvos foram removidos deste recurso",
|
||||
"targetCreated": "Destino criado",
|
||||
"targetCreatedDescription": "O alvo foi criado com sucesso",
|
||||
"targetErrorCreate": "Falha ao criar destino",
|
||||
@@ -2348,7 +2348,7 @@
|
||||
"description": "Recursos de empresa, 50 usuários, 50 sites e apoio prioritário."
|
||||
}
|
||||
},
|
||||
"personalUseOnly": "Personal use only (free license - no checkout)",
|
||||
"personalUseOnly": "Uso pessoal apenas (licença gratuita - sem checkout)",
|
||||
"buttons": {
|
||||
"continueToCheckout": "Continuar com checkout"
|
||||
},
|
||||
@@ -2609,9 +2609,9 @@
|
||||
"machineClients": "Clientes de máquina",
|
||||
"install": "Instale",
|
||||
"run": "Executar",
|
||||
"envFile": "Environment File",
|
||||
"serviceFile": "Service File",
|
||||
"enableAndStart": "Enable and Start",
|
||||
"envFile": "Arquivo de Ambiente",
|
||||
"serviceFile": "Arquivo de Serviço",
|
||||
"enableAndStart": "Ativar e Iniciar",
|
||||
"clientNameDescription": "O nome de exibição do cliente que pode ser alterado mais tarde.",
|
||||
"clientAddress": "Endereço do Cliente (Avançado)",
|
||||
"setupFailedToFetchSubnet": "Falha ao buscar a subrede padrão",
|
||||
@@ -2850,10 +2850,10 @@
|
||||
"httpDestAuthNoneTitle": "Sem Autenticação",
|
||||
"httpDestAuthNoneDescription": "Envia pedidos sem um cabeçalho de autorização.",
|
||||
"httpDestAuthBearerTitle": "Token do portador",
|
||||
"httpDestAuthBearerDescription": "Adiciona uma autorização: Bearer '<token>' header a cada requisição.",
|
||||
"httpDestAuthBearerDescription": "Adiciona um cabeçalho Authorization: Bearer '<token>' a cada solicitação.",
|
||||
"httpDestAuthBearerPlaceholder": "Sua chave de API ou token",
|
||||
"httpDestAuthBasicTitle": "Autenticação básica",
|
||||
"httpDestAuthBasicDescription": "Adiciona uma Autorização: cabeçalho '<credentials>' básico. Forneça credenciais como nome de usuário:senha.",
|
||||
"httpDestAuthBasicDescription": "Adiciona um cabeçalho Authorization: Basic '<credentials>'. Forneça as credenciais como username:password.",
|
||||
"httpDestAuthBasicPlaceholder": "Usuário:password",
|
||||
"httpDestAuthCustomTitle": "Cabeçalho personalizado",
|
||||
"httpDestAuthCustomDescription": "Especifique um nome e valor de cabeçalho HTTP personalizado para autenticação (por exemplo, X-API-Key).",
|
||||
|
||||
@@ -371,10 +371,10 @@
|
||||
"provisioningKeysUpdated": "Ключ подготовки обновлен",
|
||||
"provisioningKeysUpdatedDescription": "Ваши изменения были сохранены.",
|
||||
"provisioningKeysBannerTitle": "Ключи подготовки сайта",
|
||||
"provisioningKeysBannerDescription": "Generate a provisioning key and use it with the Newt connector to automatically create sites on first startup - no need to set up separate credentials for each site.",
|
||||
"provisioningKeysBannerDescription": "Создайте ключ настройки и используйте его с соединителем Newt для автоматического создания сайтов при первом запуске — нет необходимости настраивать отдельные учетные данные для каждого сайта.",
|
||||
"provisioningKeysBannerButtonText": "Узнать больше",
|
||||
"pendingSitesBannerTitle": "Ожидающие сайты",
|
||||
"pendingSitesBannerDescription": "Sites that connect using a provisioning key appear here for review.",
|
||||
"pendingSitesBannerDescription": "Сайты, подключающиеся с помощью ключа настройки, отображаются здесь для проверки.",
|
||||
"pendingSitesBannerButtonText": "Узнать больше",
|
||||
"apiKeysSettings": "Настройки {apiKeyName}",
|
||||
"userTitle": "Управление всеми пользователями",
|
||||
@@ -624,8 +624,8 @@
|
||||
"targetErrorInvalidPortDescription": "Пожалуйста, введите правильный номер порта",
|
||||
"targetErrorNoSite": "Сайт не выбран",
|
||||
"targetErrorNoSiteDescription": "Пожалуйста, выберите сайт для цели",
|
||||
"targetTargetsCleared": "Targets cleared",
|
||||
"targetTargetsClearedDescription": "All targets have been removed from this resource",
|
||||
"targetTargetsCleared": "Цели очищены",
|
||||
"targetTargetsClearedDescription": "Все цели удалены из этого ресурса",
|
||||
"targetCreated": "Цель создана",
|
||||
"targetCreatedDescription": "Цель была успешно создана",
|
||||
"targetErrorCreate": "Не удалось создать цель",
|
||||
@@ -2348,7 +2348,7 @@
|
||||
"description": "Функции предприятия, 50 пользователей, 50 сайтов, а также приоритетная поддержка."
|
||||
}
|
||||
},
|
||||
"personalUseOnly": "Personal use only (free license - no checkout)",
|
||||
"personalUseOnly": "Только для личного использования (бесплатная лицензия - без оформления на кассе)",
|
||||
"buttons": {
|
||||
"continueToCheckout": "Продолжить оформление заказа"
|
||||
},
|
||||
@@ -2609,9 +2609,9 @@
|
||||
"machineClients": "Машинные клиенты",
|
||||
"install": "Установить",
|
||||
"run": "Запустить",
|
||||
"envFile": "Environment File",
|
||||
"serviceFile": "Service File",
|
||||
"enableAndStart": "Enable and Start",
|
||||
"envFile": "Файл окружения",
|
||||
"serviceFile": "Сервисный файл",
|
||||
"enableAndStart": "Включить и запустить",
|
||||
"clientNameDescription": "Отображаемое имя клиента, которое может быть изменено позже.",
|
||||
"clientAddress": "Адрес клиента (Дополнительно)",
|
||||
"setupFailedToFetchSubnet": "Не удалось получить подсеть по умолчанию",
|
||||
@@ -2853,7 +2853,7 @@
|
||||
"httpDestAuthBearerDescription": "Добавляет заголовок Authorization: Bearer '<token>' к каждому запросу.",
|
||||
"httpDestAuthBearerPlaceholder": "Ваш ключ API или токен",
|
||||
"httpDestAuthBasicTitle": "Базовая авторизация",
|
||||
"httpDestAuthBasicDescription": "Добавляет Authorization: Basic '<credentials>' header. Предоставьте учетные данные в качестве имени пользователя:password.",
|
||||
"httpDestAuthBasicDescription": "Добавляет заголовок Authorization: Basic '<credentials>'. Укажите учетные данные в формате username:password.",
|
||||
"httpDestAuthBasicPlaceholder": "имя пользователя:пароль",
|
||||
"httpDestAuthCustomTitle": "Пользовательский заголовок",
|
||||
"httpDestAuthCustomDescription": "Укажите пользовательское имя заголовка HTTP и значение для аутентификации (например, X-API-Key).",
|
||||
|
||||
@@ -371,10 +371,10 @@
|
||||
"provisioningKeysUpdated": "Tedarik anahtarı güncellendi",
|
||||
"provisioningKeysUpdatedDescription": "Değişiklikleriniz kaydedildi.",
|
||||
"provisioningKeysBannerTitle": "Site Tedarik Anahtarları",
|
||||
"provisioningKeysBannerDescription": "Generate a provisioning key and use it with the Newt connector to automatically create sites on first startup - no need to set up separate credentials for each site.",
|
||||
"provisioningKeysBannerDescription": "Bir sağlama anahtarı oluşturun ve ilk başlangıçta siteleri otomatik olarak oluşturmak için Newt bağlayıcısını kullanın - her site için ayrı kimlik bilgileri ayarlamaya gerek yok.",
|
||||
"provisioningKeysBannerButtonText": "Daha fazla bilgi",
|
||||
"pendingSitesBannerTitle": "Bekleyen Siteler",
|
||||
"pendingSitesBannerDescription": "Sites that connect using a provisioning key appear here for review.",
|
||||
"pendingSitesBannerDescription": "Bir sağlama anahtarı kullanarak bağlanan siteler, inceleme için burada görünür.",
|
||||
"pendingSitesBannerButtonText": "Daha fazla bilgi",
|
||||
"apiKeysSettings": "{apiKeyName} Ayarları",
|
||||
"userTitle": "Tüm Kullanıcıları Yönet",
|
||||
@@ -624,8 +624,8 @@
|
||||
"targetErrorInvalidPortDescription": "Lütfen geçerli bir port numarası girin",
|
||||
"targetErrorNoSite": "Hiçbir site seçili değil",
|
||||
"targetErrorNoSiteDescription": "Lütfen hedef için bir site seçin",
|
||||
"targetTargetsCleared": "Targets cleared",
|
||||
"targetTargetsClearedDescription": "All targets have been removed from this resource",
|
||||
"targetTargetsCleared": "Hedefler temizlendi",
|
||||
"targetTargetsClearedDescription": "Bu kaynaktan tüm hedefler kaldırıldı",
|
||||
"targetCreated": "Hedef oluşturuldu",
|
||||
"targetCreatedDescription": "Hedef başarıyla oluşturuldu",
|
||||
"targetErrorCreate": "Hedef oluşturma başarısız oldu",
|
||||
@@ -2348,7 +2348,7 @@
|
||||
"description": "Kurumsal özellikler, 50 kullanıcı, 50 site ve öncelikli destek."
|
||||
}
|
||||
},
|
||||
"personalUseOnly": "Personal use only (free license - no checkout)",
|
||||
"personalUseOnly": "Kişisel kullanım için (ücretsiz lisans - ödeme yok)",
|
||||
"buttons": {
|
||||
"continueToCheckout": "Ödemeye Devam Et"
|
||||
},
|
||||
@@ -2609,9 +2609,9 @@
|
||||
"machineClients": "Makine İstemcileri",
|
||||
"install": "Yükle",
|
||||
"run": "Çalıştır",
|
||||
"envFile": "Environment File",
|
||||
"serviceFile": "Service File",
|
||||
"enableAndStart": "Enable and Start",
|
||||
"envFile": "Ortam Dosyası",
|
||||
"serviceFile": "Servis Dosyası",
|
||||
"enableAndStart": "Etkinleştir ve Başlat",
|
||||
"clientNameDescription": "Daha sonra değiştirilebilecek istemcinin görünen adı.",
|
||||
"clientAddress": "İstemci Adresi (Gelişmiş)",
|
||||
"setupFailedToFetchSubnet": "Varsayılan alt ağ alınamadı",
|
||||
@@ -2850,10 +2850,10 @@
|
||||
"httpDestAuthNoneTitle": "Kimlik Doğrulama Yok",
|
||||
"httpDestAuthNoneDescription": "Yetkilendirme başlığı olmadan istekler gönderir.",
|
||||
"httpDestAuthBearerTitle": "Taşıyıcı Jetonu",
|
||||
"httpDestAuthBearerDescription": "Her isteğe bir Yetkilendirme: Taşıyıcı '<token>' başlığı ekler.",
|
||||
"httpDestAuthBearerDescription": "Her isteğe bir Yetkilendirme: Taşıyıcı '<token>' üst bilgisi ekler.",
|
||||
"httpDestAuthBearerPlaceholder": "API anahtarınız veya jetonunuz",
|
||||
"httpDestAuthBasicTitle": "Temel Kimlik Doğrulama",
|
||||
"httpDestAuthBasicDescription": "Authorization: Temel '<belirtecikler>' başlığı ekler. Yetkilendirmeleri kullanıcı adı:şifre olarak sağlayın.",
|
||||
"httpDestAuthBasicDescription": "Bir Yetkilendirme: Temel '<credentials>' üst bilgisi ekler. Kimlik bilgilerini kullanıcı adı:şifre olarak sağlayın.",
|
||||
"httpDestAuthBasicPlaceholder": "kullanıcı adı:şifre",
|
||||
"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).",
|
||||
|
||||
@@ -371,10 +371,10 @@
|
||||
"provisioningKeysUpdated": "置备密钥已更新",
|
||||
"provisioningKeysUpdatedDescription": "您的更改已保存。",
|
||||
"provisioningKeysBannerTitle": "站点置备密钥",
|
||||
"provisioningKeysBannerDescription": "Generate a provisioning key and use it with the Newt connector to automatically create sites on first startup - no need to set up separate credentials for each site.",
|
||||
"provisioningKeysBannerDescription": "生成一个供应密钥,并将其与 Newt 连接器一起使用,以在首次启动时自动创建站点 - 无需为每个站点设置单独的凭据。",
|
||||
"provisioningKeysBannerButtonText": "了解更多",
|
||||
"pendingSitesBannerTitle": "待定站点",
|
||||
"pendingSitesBannerDescription": "Sites that connect using a provisioning key appear here for review.",
|
||||
"pendingSitesBannerDescription": "使用供应密钥连接的站点将在此显示以供审核。",
|
||||
"pendingSitesBannerButtonText": "了解更多",
|
||||
"apiKeysSettings": "{apiKeyName} 设置",
|
||||
"userTitle": "管理所有用户",
|
||||
@@ -624,8 +624,8 @@
|
||||
"targetErrorInvalidPortDescription": "请输入有效的端口号",
|
||||
"targetErrorNoSite": "没有选择站点",
|
||||
"targetErrorNoSiteDescription": "请选择目标站点",
|
||||
"targetTargetsCleared": "Targets cleared",
|
||||
"targetTargetsClearedDescription": "All targets have been removed from this resource",
|
||||
"targetTargetsCleared": "目标已清除",
|
||||
"targetTargetsClearedDescription": "所有目标已从此资源中移除",
|
||||
"targetCreated": "目标已创建",
|
||||
"targetCreatedDescription": "目标已成功创建",
|
||||
"targetErrorCreate": "创建目标失败",
|
||||
@@ -2348,7 +2348,7 @@
|
||||
"description": "企业特征、50个用户、50个站点和优先支持。"
|
||||
}
|
||||
},
|
||||
"personalUseOnly": "Personal use only (free license - no checkout)",
|
||||
"personalUseOnly": "仅限个人使用(免费许可 - 无需结账)",
|
||||
"buttons": {
|
||||
"continueToCheckout": "继续签出"
|
||||
},
|
||||
@@ -2609,9 +2609,9 @@
|
||||
"machineClients": "机器客户端",
|
||||
"install": "安装",
|
||||
"run": "运行",
|
||||
"envFile": "Environment File",
|
||||
"serviceFile": "Service File",
|
||||
"enableAndStart": "Enable and Start",
|
||||
"envFile": "环境文件",
|
||||
"serviceFile": "服务文件",
|
||||
"enableAndStart": "启用并启动",
|
||||
"clientNameDescription": "可以稍后更改的客户端的显示名称。",
|
||||
"clientAddress": "客户端地址 (高级)",
|
||||
"setupFailedToFetchSubnet": "获取默认子网失败",
|
||||
@@ -2850,10 +2850,10 @@
|
||||
"httpDestAuthNoneTitle": "无身份验证",
|
||||
"httpDestAuthNoneDescription": "在没有授权头的情况下发送请求。",
|
||||
"httpDestAuthBearerTitle": "持有者令牌",
|
||||
"httpDestAuthBearerDescription": "添加授权:每个请求的标题为 '<token>'。",
|
||||
"httpDestAuthBearerDescription": "在每个请求中添加授权:Bearer “<token>” 头。",
|
||||
"httpDestAuthBearerPlaceholder": "您的 API 密钥或令牌",
|
||||
"httpDestAuthBasicTitle": "基本认证",
|
||||
"httpDestAuthBasicDescription": "添加授权:基本 '<credentials>' 头。提供用户名:密码的凭据。",
|
||||
"httpDestAuthBasicDescription": "添加一个Authorization: Basic \"<凭据>\" 标头。 以用户名:密码形式提供凭据。",
|
||||
"httpDestAuthBasicPlaceholder": "用户名:密码",
|
||||
"httpDestAuthCustomTitle": "自定义标题",
|
||||
"httpDestAuthCustomDescription": "指定自定义 HTTP 头名称和身份验证值 (例如,X-API 键)。",
|
||||
|
||||
@@ -222,12 +222,18 @@ export const exitNodes = pgTable("exitNodes", {
|
||||
export const siteResources = pgTable("siteResources", {
|
||||
// this is for the clients
|
||||
siteResourceId: serial("siteResourceId").primaryKey(),
|
||||
siteId: integer("siteId")
|
||||
.notNull()
|
||||
.references(() => sites.siteId, { onDelete: "cascade" }),
|
||||
orgId: varchar("orgId")
|
||||
.notNull()
|
||||
.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(),
|
||||
name: varchar("name").notNull(),
|
||||
mode: varchar("mode").$type<"host" | "cidr">().notNull(), // "host" | "cidr" | "port"
|
||||
@@ -247,6 +253,32 @@ export const siteResources = pgTable("siteResources", {
|
||||
.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", {
|
||||
clientId: integer("clientId")
|
||||
.notNull()
|
||||
@@ -1106,3 +1138,4 @@ export type RequestAuditLog = InferSelectModel<typeof requestAuditLog>;
|
||||
export type RoundTripMessageTracker = InferSelectModel<
|
||||
typeof roundTripMessageTracker
|
||||
>;
|
||||
export type Network = InferSelectModel<typeof networks>;
|
||||
|
||||
@@ -92,6 +92,9 @@ export const sites = sqliteTable("sites", {
|
||||
exitNodeId: integer("exitNode").references(() => exitNodes.exitNodeId, {
|
||||
onDelete: "set null"
|
||||
}),
|
||||
networkId: integer("networkId").references(() => networks.networkId, {
|
||||
onDelete: "set null"
|
||||
}),
|
||||
name: text("name").notNull(),
|
||||
pubKey: text("pubKey"),
|
||||
subnet: text("subnet"),
|
||||
@@ -250,12 +253,16 @@ export const siteResources = sqliteTable("siteResources", {
|
||||
siteResourceId: integer("siteResourceId").primaryKey({
|
||||
autoIncrement: true
|
||||
}),
|
||||
siteId: integer("siteId")
|
||||
.notNull()
|
||||
.references(() => sites.siteId, { onDelete: "cascade" }),
|
||||
orgId: text("orgId")
|
||||
.notNull()
|
||||
.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(),
|
||||
name: text("name").notNull(),
|
||||
mode: text("mode").$type<"host" | "cidr">().notNull(), // "host" | "cidr" | "port"
|
||||
@@ -277,6 +284,30 @@ export const siteResources = sqliteTable("siteResources", {
|
||||
.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", {
|
||||
clientId: integer("clientId")
|
||||
.notNull()
|
||||
@@ -1195,6 +1226,7 @@ export type ApiKey = InferSelectModel<typeof apiKeys>;
|
||||
export type ApiKeyAction = InferSelectModel<typeof apiKeyActions>;
|
||||
export type ApiKeyOrg = InferSelectModel<typeof apiKeyOrg>;
|
||||
export type SiteResource = InferSelectModel<typeof siteResources>;
|
||||
export type Network = InferSelectModel<typeof networks>;
|
||||
export type OrgDomains = InferSelectModel<typeof orgDomains>;
|
||||
export type SetupToken = InferSelectModel<typeof setupTokens>;
|
||||
export type HostMeta = InferSelectModel<typeof hostMeta>;
|
||||
|
||||
@@ -121,8 +121,8 @@ export async function applyBlueprint({
|
||||
for (const result of clientResourcesResults) {
|
||||
if (
|
||||
result.oldSiteResource &&
|
||||
result.oldSiteResource.siteId !=
|
||||
result.newSiteResource.siteId
|
||||
JSON.stringify(result.newSites?.sort()) !==
|
||||
JSON.stringify(result.oldSites?.sort())
|
||||
) {
|
||||
// query existing associations
|
||||
const existingRoleIds = await trx
|
||||
@@ -222,38 +222,46 @@ export async function applyBlueprint({
|
||||
trx
|
||||
);
|
||||
} else {
|
||||
const [newSite] = await trx
|
||||
.select()
|
||||
.from(sites)
|
||||
.innerJoin(newts, eq(sites.siteId, newts.siteId))
|
||||
.where(
|
||||
and(
|
||||
eq(sites.siteId, result.newSiteResource.siteId),
|
||||
eq(sites.orgId, orgId),
|
||||
eq(sites.type, "newt"),
|
||||
isNotNull(sites.pubKey)
|
||||
let good = true;
|
||||
for (const newSite of result.newSites) {
|
||||
const [site] = await trx
|
||||
.select()
|
||||
.from(sites)
|
||||
.innerJoin(newts, eq(sites.siteId, newts.siteId))
|
||||
.where(
|
||||
and(
|
||||
eq(sites.siteId, newSite.siteId),
|
||||
eq(sites.orgId, orgId),
|
||||
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(
|
||||
`No newt site found for client resource ${result.newSiteResource.siteResourceId}, skipping target update`
|
||||
`Updating client resource ${result.newSiteResource.siteResourceId} on site ${newSite.siteId}`
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
logger.debug(
|
||||
`Updating client resource ${result.newSiteResource.siteResourceId} on site ${newSite.sites.siteId}`
|
||||
);
|
||||
if (!good) {
|
||||
continue;
|
||||
}
|
||||
|
||||
await handleMessagingForUpdatedSiteResource(
|
||||
result.oldSiteResource,
|
||||
result.newSiteResource,
|
||||
{
|
||||
siteId: newSite.sites.siteId,
|
||||
orgId: newSite.sites.orgId
|
||||
},
|
||||
result.newSites.map((site) => ({
|
||||
siteId: site.siteId,
|
||||
orgId: result.newSiteResource.orgId
|
||||
})),
|
||||
trx
|
||||
);
|
||||
}
|
||||
|
||||
@@ -3,12 +3,15 @@ import {
|
||||
clientSiteResources,
|
||||
roles,
|
||||
roleSiteResources,
|
||||
Site,
|
||||
SiteResource,
|
||||
siteNetworks,
|
||||
siteResources,
|
||||
Transaction,
|
||||
userOrgs,
|
||||
users,
|
||||
userSiteResources
|
||||
userSiteResources,
|
||||
networks
|
||||
} from "@server/db";
|
||||
import { sites } from "@server/db";
|
||||
import { eq, and, ne, inArray, or } from "drizzle-orm";
|
||||
@@ -19,6 +22,8 @@ import { getNextAvailableAliasAddress } from "../ip";
|
||||
export type ClientResourcesResults = {
|
||||
newSiteResource: SiteResource;
|
||||
oldSiteResource?: SiteResource;
|
||||
newSites: { siteId: number }[];
|
||||
oldSites: { siteId: number }[];
|
||||
}[];
|
||||
|
||||
export async function updateClientResources(
|
||||
@@ -43,36 +48,70 @@ export async function updateClientResources(
|
||||
)
|
||||
.limit(1);
|
||||
|
||||
const resourceSiteId = resourceData.site;
|
||||
let site;
|
||||
const existingSiteIds = existingResource?.networkId
|
||||
? await trx
|
||||
.select({ siteId: sites.siteId })
|
||||
.from(siteNetworks)
|
||||
.where(eq(siteNetworks.networkId, existingResource.networkId))
|
||||
: [];
|
||||
|
||||
if (resourceSiteId) {
|
||||
// Look up site by niceId
|
||||
[site] = await trx
|
||||
.select({ siteId: sites.siteId })
|
||||
.from(sites)
|
||||
.where(
|
||||
and(
|
||||
eq(sites.niceId, resourceSiteId),
|
||||
eq(sites.orgId, orgId)
|
||||
let allSites: { siteId: number }[] = [];
|
||||
if (resourceData.site) {
|
||||
let siteSingle;
|
||||
const resourceSiteId = resourceData.site;
|
||||
|
||||
if (resourceSiteId) {
|
||||
// Look up site by niceId
|
||||
[siteSingle] = await trx
|
||||
.select({ siteId: sites.siteId })
|
||||
.from(sites)
|
||||
.where(
|
||||
and(
|
||||
eq(sites.niceId, resourceSiteId),
|
||||
eq(sites.orgId, orgId)
|
||||
)
|
||||
)
|
||||
)
|
||||
.limit(1);
|
||||
} else if (siteId) {
|
||||
// Use the provided siteId directly, but verify it belongs to the org
|
||||
[site] = await trx
|
||||
.select({ siteId: sites.siteId })
|
||||
.from(sites)
|
||||
.where(and(eq(sites.siteId, siteId), eq(sites.orgId, orgId)))
|
||||
.limit(1);
|
||||
} else {
|
||||
throw new Error(`Target site is required`);
|
||||
.limit(1);
|
||||
} else if (siteId) {
|
||||
// Use the provided siteId directly, but verify it belongs to the org
|
||||
[siteSingle] = await trx
|
||||
.select({ siteId: sites.siteId })
|
||||
.from(sites)
|
||||
.where(
|
||||
and(eq(sites.siteId, siteId), eq(sites.orgId, orgId))
|
||||
)
|
||||
.limit(1);
|
||||
} else {
|
||||
throw new Error(`Target site is required`);
|
||||
}
|
||||
|
||||
if (!siteSingle) {
|
||||
throw new Error(
|
||||
`Site not found: ${resourceSiteId} in org ${orgId}`
|
||||
);
|
||||
}
|
||||
allSites.push(siteSingle);
|
||||
}
|
||||
|
||||
if (!site) {
|
||||
throw new Error(
|
||||
`Site not found: ${resourceSiteId} in org ${orgId}`
|
||||
);
|
||||
if (resourceData.sites) {
|
||||
for (const siteNiceId of resourceData.sites) {
|
||||
const [site] = await trx
|
||||
.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) {
|
||||
@@ -81,7 +120,6 @@ export async function updateClientResources(
|
||||
.update(siteResources)
|
||||
.set({
|
||||
name: resourceData.name || resourceNiceId,
|
||||
siteId: site.siteId,
|
||||
mode: resourceData.mode,
|
||||
destination: resourceData.destination,
|
||||
enabled: true, // hardcoded for now
|
||||
@@ -102,6 +140,21 @@ export async function updateClientResources(
|
||||
const siteResourceId = existingResource.siteResourceId;
|
||||
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
|
||||
.delete(clientSiteResources)
|
||||
.where(eq(clientSiteResources.siteResourceId, siteResourceId));
|
||||
@@ -204,7 +257,9 @@ export async function updateClientResources(
|
||||
|
||||
results.push({
|
||||
newSiteResource: updatedResource,
|
||||
oldSiteResource: existingResource
|
||||
oldSiteResource: existingResource,
|
||||
newSites: allSites,
|
||||
oldSites: existingSiteIds
|
||||
});
|
||||
} else {
|
||||
let aliasAddress: string | null = null;
|
||||
@@ -213,13 +268,22 @@ export async function updateClientResources(
|
||||
aliasAddress = await getNextAvailableAliasAddress(orgId);
|
||||
}
|
||||
|
||||
const [network] = await trx
|
||||
.insert(networks)
|
||||
.values({
|
||||
scope: "resource",
|
||||
orgId: orgId
|
||||
})
|
||||
.returning();
|
||||
|
||||
// Create new resource
|
||||
const [newResource] = await trx
|
||||
.insert(siteResources)
|
||||
.values({
|
||||
orgId: orgId,
|
||||
siteId: site.siteId,
|
||||
niceId: resourceNiceId,
|
||||
networkId: network.networkId,
|
||||
defaultNetworkId: network.networkId,
|
||||
name: resourceData.name || resourceNiceId,
|
||||
mode: resourceData.mode,
|
||||
destination: resourceData.destination,
|
||||
@@ -235,6 +299,13 @@ export async function updateClientResources(
|
||||
|
||||
const siteResourceId = newResource.siteResourceId;
|
||||
|
||||
for (const site of allSites) {
|
||||
await trx.insert(siteNetworks).values({
|
||||
siteId: site.siteId,
|
||||
networkId: network.networkId
|
||||
});
|
||||
}
|
||||
|
||||
const [adminRole] = await trx
|
||||
.select()
|
||||
.from(roles)
|
||||
@@ -324,7 +395,11 @@ export async function updateClientResources(
|
||||
`Created new client resource ${newResource.name} (${newResource.siteResourceId}) for org ${orgId}`
|
||||
);
|
||||
|
||||
results.push({ newSiteResource: newResource });
|
||||
results.push({
|
||||
newSiteResource: newResource,
|
||||
newSites: allSites,
|
||||
oldSites: existingSiteIds
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -326,7 +326,8 @@ export const ClientResourceSchema = z
|
||||
.object({
|
||||
name: z.string().min(1).max(255),
|
||||
mode: z.enum(["host", "cidr"]),
|
||||
site: z.string(),
|
||||
site: z.string(), // DEPRECATED IN FAVOR OF sites
|
||||
sites: z.array(z.string()).optional().default([]),
|
||||
// protocol: z.enum(["tcp", "udp"]).optional(),
|
||||
// proxyPort: z.int().positive().optional(),
|
||||
// destinationPort: z.int().positive().optional(),
|
||||
|
||||
@@ -11,11 +11,11 @@ import {
|
||||
roleSiteResources,
|
||||
Site,
|
||||
SiteResource,
|
||||
siteNetworks,
|
||||
siteResources,
|
||||
sites,
|
||||
Transaction,
|
||||
userOrgRoles,
|
||||
userOrgs,
|
||||
userSiteResources
|
||||
} from "@server/db";
|
||||
import { and, eq, inArray, ne } from "drizzle-orm";
|
||||
@@ -48,15 +48,23 @@ export async function getClientSiteResourceAccess(
|
||||
siteResource: SiteResource,
|
||||
trx: Transaction | typeof db = db
|
||||
) {
|
||||
// get the site
|
||||
const [site] = await trx
|
||||
.select()
|
||||
.from(sites)
|
||||
.where(eq(sites.siteId, siteResource.siteId))
|
||||
.limit(1);
|
||||
// get all sites associated with this siteResource via its network
|
||||
const sitesList = siteResource.networkId
|
||||
? await trx
|
||||
.select()
|
||||
.from(sites)
|
||||
.innerJoin(
|
||||
siteNetworks,
|
||||
eq(siteNetworks.siteId, sites.siteId)
|
||||
)
|
||||
.where(eq(siteNetworks.networkId, siteResource.networkId))
|
||||
.then((rows) => rows.map((row) => row.sites))
|
||||
: [];
|
||||
|
||||
if (!site) {
|
||||
throw new Error(`Site with ID ${siteResource.siteId} not found`);
|
||||
if (sitesList.length === 0) {
|
||||
logger.warn(
|
||||
`No sites found for siteResource ${siteResource.siteResourceId} with networkId ${siteResource.networkId}`
|
||||
);
|
||||
}
|
||||
|
||||
const roleIds = await trx
|
||||
@@ -137,7 +145,7 @@ export async function getClientSiteResourceAccess(
|
||||
const mergedAllClientIds = mergedAllClients.map((c) => c.clientId);
|
||||
|
||||
return {
|
||||
site,
|
||||
sitesList,
|
||||
mergedAllClients,
|
||||
mergedAllClientIds
|
||||
};
|
||||
@@ -153,40 +161,51 @@ export async function rebuildClientAssociationsFromSiteResource(
|
||||
subnet: string | null;
|
||||
}[];
|
||||
}> {
|
||||
const siteId = siteResource.siteId;
|
||||
|
||||
const { site, mergedAllClients, mergedAllClientIds } =
|
||||
const { sitesList, mergedAllClients, mergedAllClientIds } =
|
||||
await getClientSiteResourceAccess(siteResource, trx);
|
||||
|
||||
/////////// process the client-siteResource associations ///////////
|
||||
|
||||
// get all of the clients associated with other resources on this site
|
||||
const allUpdatedClientsFromOtherResourcesOnThisSite = await trx
|
||||
.select({
|
||||
clientId: clientSiteResourcesAssociationsCache.clientId
|
||||
})
|
||||
.from(clientSiteResourcesAssociationsCache)
|
||||
.innerJoin(
|
||||
siteResources,
|
||||
eq(
|
||||
clientSiteResourcesAssociationsCache.siteResourceId,
|
||||
siteResources.siteResourceId
|
||||
)
|
||||
)
|
||||
.where(
|
||||
and(
|
||||
eq(siteResources.siteId, siteId),
|
||||
ne(siteResources.siteResourceId, siteResource.siteResourceId)
|
||||
)
|
||||
);
|
||||
// get all of the clients associated with other resources in the same network,
|
||||
// joined through siteNetworks so we know which siteId each client belongs to
|
||||
const allUpdatedClientsFromOtherResourcesOnThisSite = siteResource.networkId
|
||||
? await trx
|
||||
.select({
|
||||
clientId: clientSiteResourcesAssociationsCache.clientId,
|
||||
siteId: siteNetworks.siteId
|
||||
})
|
||||
.from(clientSiteResourcesAssociationsCache)
|
||||
.innerJoin(
|
||||
siteResources,
|
||||
eq(
|
||||
clientSiteResourcesAssociationsCache.siteResourceId,
|
||||
siteResources.siteResourceId
|
||||
)
|
||||
)
|
||||
.innerJoin(
|
||||
siteNetworks,
|
||||
eq(siteNetworks.networkId, siteResources.networkId)
|
||||
)
|
||||
.where(
|
||||
and(
|
||||
eq(siteResources.networkId, siteResource.networkId),
|
||||
ne(
|
||||
siteResources.siteResourceId,
|
||||
siteResource.siteResourceId
|
||||
)
|
||||
)
|
||||
)
|
||||
: [];
|
||||
|
||||
const allClientIdsFromOtherResourcesOnThisSite = Array.from(
|
||||
new Set(
|
||||
allUpdatedClientsFromOtherResourcesOnThisSite.map(
|
||||
(row) => row.clientId
|
||||
)
|
||||
)
|
||||
);
|
||||
// Build a per-site map so the loop below can check by siteId rather than
|
||||
// across the entire network.
|
||||
const clientsFromOtherResourcesBySite = new Map<number, Set<number>>();
|
||||
for (const row of allUpdatedClientsFromOtherResourcesOnThisSite) {
|
||||
if (!clientsFromOtherResourcesBySite.has(row.siteId)) {
|
||||
clientsFromOtherResourcesBySite.set(row.siteId, new Set());
|
||||
}
|
||||
clientsFromOtherResourcesBySite.get(row.siteId)!.add(row.clientId);
|
||||
}
|
||||
|
||||
const existingClientSiteResources = await trx
|
||||
.select({
|
||||
@@ -260,82 +279,90 @@ export async function rebuildClientAssociationsFromSiteResource(
|
||||
|
||||
/////////// process the client-site associations ///////////
|
||||
|
||||
const existingClientSites = await trx
|
||||
.select({
|
||||
clientId: clientSitesAssociationsCache.clientId
|
||||
})
|
||||
.from(clientSitesAssociationsCache)
|
||||
.where(eq(clientSitesAssociationsCache.siteId, siteResource.siteId));
|
||||
for (const site of sitesList) {
|
||||
const siteId = site.siteId;
|
||||
|
||||
const existingClientSiteIds = existingClientSites.map(
|
||||
(row) => row.clientId
|
||||
);
|
||||
const existingClientSites = await trx
|
||||
.select({
|
||||
clientId: clientSitesAssociationsCache.clientId
|
||||
})
|
||||
.from(clientSitesAssociationsCache)
|
||||
.where(eq(clientSitesAssociationsCache.siteId, siteId));
|
||||
|
||||
// Get full client details for existing clients (needed for sending delete messages)
|
||||
const existingClients = await trx
|
||||
.select({
|
||||
clientId: clients.clientId,
|
||||
pubKey: clients.pubKey,
|
||||
subnet: clients.subnet
|
||||
})
|
||||
.from(clients)
|
||||
.where(inArray(clients.clientId, existingClientSiteIds));
|
||||
const existingClientSiteIds = existingClientSites.map(
|
||||
(row) => row.clientId
|
||||
);
|
||||
|
||||
const clientSitesToAdd = mergedAllClientIds.filter(
|
||||
(clientId) =>
|
||||
!existingClientSiteIds.includes(clientId) &&
|
||||
!allClientIdsFromOtherResourcesOnThisSite.includes(clientId) // dont remove if there is still another connection for another site resource
|
||||
);
|
||||
// Get full client details for existing clients (needed for sending delete messages)
|
||||
const existingClients =
|
||||
existingClientSiteIds.length > 0
|
||||
? await trx
|
||||
.select({
|
||||
clientId: clients.clientId,
|
||||
pubKey: clients.pubKey,
|
||||
subnet: clients.subnet
|
||||
})
|
||||
.from(clients)
|
||||
.where(inArray(clients.clientId, existingClientSiteIds))
|
||||
: [];
|
||||
|
||||
const clientSitesToInsert = clientSitesToAdd.map((clientId) => ({
|
||||
clientId,
|
||||
siteId
|
||||
}));
|
||||
const otherResourceClientIds = clientsFromOtherResourcesBySite.get(siteId) ?? new Set<number>();
|
||||
|
||||
if (clientSitesToInsert.length > 0) {
|
||||
await trx
|
||||
.insert(clientSitesAssociationsCache)
|
||||
.values(clientSitesToInsert)
|
||||
.returning();
|
||||
}
|
||||
const clientSitesToAdd = mergedAllClientIds.filter(
|
||||
(clientId) =>
|
||||
!existingClientSiteIds.includes(clientId) &&
|
||||
!otherResourceClientIds.has(clientId) // dont add if already connected via another site resource
|
||||
);
|
||||
|
||||
// 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
|
||||
);
|
||||
const clientSitesToInsert = clientSitesToAdd.map((clientId) => ({
|
||||
clientId,
|
||||
siteId
|
||||
}));
|
||||
|
||||
if (clientSitesToRemove.length > 0) {
|
||||
await trx
|
||||
.delete(clientSitesAssociationsCache)
|
||||
.where(
|
||||
and(
|
||||
eq(clientSitesAssociationsCache.siteId, siteId),
|
||||
inArray(
|
||||
clientSitesAssociationsCache.clientId,
|
||||
clientSitesToRemove
|
||||
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
|
||||
);
|
||||
}
|
||||
|
||||
/////////// 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
|
||||
await handleSubnetProxyTargetUpdates(
|
||||
siteResource,
|
||||
sitesList,
|
||||
mergedAllClients,
|
||||
existingResourceClients,
|
||||
clientSiteResourcesToAdd,
|
||||
@@ -624,6 +651,7 @@ export async function updateClientSiteDestinations(
|
||||
|
||||
async function handleSubnetProxyTargetUpdates(
|
||||
siteResource: SiteResource,
|
||||
sitesList: Site[],
|
||||
allClients: {
|
||||
clientId: number;
|
||||
pubKey: string | null;
|
||||
@@ -638,125 +666,138 @@ async function handleSubnetProxyTargetUpdates(
|
||||
clientSiteResourcesToRemove: number[],
|
||||
trx: Transaction | typeof db = db
|
||||
): Promise<void> {
|
||||
// Get the newt for this site
|
||||
const [newt] = await trx
|
||||
.select()
|
||||
.from(newts)
|
||||
.where(eq(newts.siteId, siteResource.siteId))
|
||||
.limit(1);
|
||||
const proxyJobs: Promise<any>[] = [];
|
||||
const olmJobs: Promise<any>[] = [];
|
||||
|
||||
if (!newt) {
|
||||
logger.warn(
|
||||
`Newt not found for site ${siteResource.siteId}, skipping subnet proxy target updates`
|
||||
);
|
||||
return;
|
||||
}
|
||||
for (const siteData of sitesList) {
|
||||
const siteId = siteData.siteId;
|
||||
|
||||
const proxyJobs = [];
|
||||
const olmJobs = [];
|
||||
// Generate targets for added associations
|
||||
if (clientSiteResourcesToAdd.length > 0) {
|
||||
const addedClients = allClients.filter((client) =>
|
||||
clientSiteResourcesToAdd.includes(client.clientId)
|
||||
);
|
||||
// Get the newt for this site
|
||||
const [newt] = await trx
|
||||
.select()
|
||||
.from(newts)
|
||||
.where(eq(newts.siteId, siteId))
|
||||
.limit(1);
|
||||
|
||||
if (addedClients.length > 0) {
|
||||
const targetToAdd = generateSubnetProxyTargetV2(
|
||||
siteResource,
|
||||
addedClients
|
||||
if (!newt) {
|
||||
logger.warn(
|
||||
`Newt not found for site ${siteId}, skipping subnet proxy target updates`
|
||||
);
|
||||
|
||||
if (targetToAdd) {
|
||||
proxyJobs.push(
|
||||
addSubnetProxyTargets(
|
||||
newt.newtId,
|
||||
[targetToAdd],
|
||||
newt.version
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
for (const client of addedClients) {
|
||||
olmJobs.push(
|
||||
addPeerData(
|
||||
client.clientId,
|
||||
siteResource.siteId,
|
||||
generateRemoteSubnets([siteResource]),
|
||||
generateAliasConfig([siteResource])
|
||||
)
|
||||
);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
// 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
|
||||
if (clientSiteResourcesToRemove.length > 0) {
|
||||
const removedClients = existingClients.filter((client) =>
|
||||
clientSiteResourcesToRemove.includes(client.clientId)
|
||||
);
|
||||
|
||||
if (removedClients.length > 0) {
|
||||
const targetToRemove = generateSubnetProxyTargetV2(
|
||||
siteResource,
|
||||
removedClients
|
||||
// Generate targets for added associations
|
||||
if (clientSiteResourcesToAdd.length > 0) {
|
||||
const addedClients = allClients.filter((client) =>
|
||||
clientSiteResourcesToAdd.includes(client.clientId)
|
||||
);
|
||||
|
||||
if (targetToRemove) {
|
||||
proxyJobs.push(
|
||||
removeSubnetProxyTargets(
|
||||
newt.newtId,
|
||||
[targetToRemove],
|
||||
newt.version
|
||||
)
|
||||
if (addedClients.length > 0) {
|
||||
const targetToAdd = generateSubnetProxyTargetV2(
|
||||
siteResource,
|
||||
addedClients
|
||||
);
|
||||
}
|
||||
|
||||
for (const client of removedClients) {
|
||||
// Check if this client still has access to another resource on this site with the same destination
|
||||
const destinationStillInUse = await trx
|
||||
.select()
|
||||
.from(siteResources)
|
||||
.innerJoin(
|
||||
clientSiteResourcesAssociationsCache,
|
||||
eq(
|
||||
clientSiteResourcesAssociationsCache.siteResourceId,
|
||||
siteResources.siteResourceId
|
||||
)
|
||||
)
|
||||
.where(
|
||||
and(
|
||||
eq(
|
||||
clientSiteResourcesAssociationsCache.clientId,
|
||||
client.clientId
|
||||
),
|
||||
eq(siteResources.siteId, siteResource.siteId),
|
||||
eq(
|
||||
siteResources.destination,
|
||||
siteResource.destination
|
||||
),
|
||||
ne(
|
||||
siteResources.siteResourceId,
|
||||
siteResource.siteResourceId
|
||||
)
|
||||
if (targetToAdd) {
|
||||
proxyJobs.push(
|
||||
addSubnetProxyTargets(
|
||||
newt.newtId,
|
||||
[targetToAdd],
|
||||
newt.version
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
// Only remove remote subnet if no other resource uses the same destination
|
||||
const remoteSubnetsToRemove =
|
||||
destinationStillInUse.length > 0
|
||||
? []
|
||||
: generateRemoteSubnets([siteResource]);
|
||||
for (const client of addedClients) {
|
||||
olmJobs.push(
|
||||
addPeerData(
|
||||
client.clientId,
|
||||
siteId,
|
||||
generateRemoteSubnets([siteResource]),
|
||||
generateAliasConfig([siteResource])
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
olmJobs.push(
|
||||
removePeerData(
|
||||
client.clientId,
|
||||
siteResource.siteId,
|
||||
remoteSubnetsToRemove,
|
||||
generateAliasConfig([siteResource])
|
||||
)
|
||||
// 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
|
||||
if (clientSiteResourcesToRemove.length > 0) {
|
||||
const removedClients = existingClients.filter((client) =>
|
||||
clientSiteResourcesToRemove.includes(client.clientId)
|
||||
);
|
||||
|
||||
if (removedClients.length > 0) {
|
||||
const targetToRemove = generateSubnetProxyTargetV2(
|
||||
siteResource,
|
||||
removedClients
|
||||
);
|
||||
|
||||
if (targetToRemove) {
|
||||
proxyJobs.push(
|
||||
removeSubnetProxyTargets(
|
||||
newt.newtId,
|
||||
[targetToRemove],
|
||||
newt.version
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
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(
|
||||
clientSiteResourcesAssociationsCache.siteResourceId,
|
||||
siteResources.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])
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -863,10 +904,25 @@ export async function rebuildClientAssociationsFromClient(
|
||||
)
|
||||
: [];
|
||||
|
||||
// Group by siteId for site-level associations
|
||||
const newSiteIds = Array.from(
|
||||
new Set(newSiteResources.map((sr) => sr.siteId))
|
||||
// Group by siteId for site-level associations — look up via siteNetworks since
|
||||
// siteResources no longer carries a direct siteId column.
|
||||
const networkIds = Array.from(
|
||||
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 ///////////
|
||||
|
||||
@@ -1139,13 +1195,45 @@ async function handleMessagesForClientResources(
|
||||
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
|
||||
const addedBySite = new Map<number, SiteResource[]>();
|
||||
for (const resource of addedResources) {
|
||||
if (!addedBySite.has(resource.siteId)) {
|
||||
addedBySite.set(resource.siteId, []);
|
||||
const siteIds =
|
||||
resource.networkId != null
|
||||
? (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
|
||||
@@ -1187,7 +1275,7 @@ async function handleMessagesForClientResources(
|
||||
olmJobs.push(
|
||||
addPeerData(
|
||||
client.clientId,
|
||||
resource.siteId,
|
||||
siteId,
|
||||
generateRemoteSubnets([resource]),
|
||||
generateAliasConfig([resource])
|
||||
)
|
||||
@@ -1199,7 +1287,7 @@ async function handleMessagesForClientResources(
|
||||
error.message.includes("not found")
|
||||
) {
|
||||
logger.debug(
|
||||
`Olm data not found for client ${client.clientId} and site ${resource.siteId}, skipping removal`
|
||||
`Olm data not found for client ${client.clientId} and site ${siteId}, skipping addition`
|
||||
);
|
||||
} else {
|
||||
throw error;
|
||||
@@ -1216,13 +1304,45 @@ async function handleMessagesForClientResources(
|
||||
.from(siteResources)
|
||||
.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
|
||||
const removedBySite = new Map<number, SiteResource[]>();
|
||||
for (const resource of removedResources) {
|
||||
if (!removedBySite.has(resource.siteId)) {
|
||||
removedBySite.set(resource.siteId, []);
|
||||
const siteIds =
|
||||
resource.networkId != null
|
||||
? (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
|
||||
@@ -1260,7 +1380,11 @@ async function handleMessagesForClientResources(
|
||||
}
|
||||
|
||||
try {
|
||||
// Check if this client still has access to another resource on this site with the same destination
|
||||
// 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)
|
||||
@@ -1271,13 +1395,17 @@ async function handleMessagesForClientResources(
|
||||
siteResources.siteResourceId
|
||||
)
|
||||
)
|
||||
.innerJoin(
|
||||
siteNetworks,
|
||||
eq(siteNetworks.networkId, siteResources.networkId)
|
||||
)
|
||||
.where(
|
||||
and(
|
||||
eq(
|
||||
clientSiteResourcesAssociationsCache.clientId,
|
||||
client.clientId
|
||||
),
|
||||
eq(siteResources.siteId, resource.siteId),
|
||||
eq(siteNetworks.siteId, siteId),
|
||||
eq(
|
||||
siteResources.destination,
|
||||
resource.destination
|
||||
@@ -1299,7 +1427,7 @@ async function handleMessagesForClientResources(
|
||||
olmJobs.push(
|
||||
removePeerData(
|
||||
client.clientId,
|
||||
resource.siteId,
|
||||
siteId,
|
||||
remoteSubnetsToRemove,
|
||||
generateAliasConfig([resource])
|
||||
)
|
||||
@@ -1311,7 +1439,7 @@ async function handleMessagesForClientResources(
|
||||
error.message.includes("not found")
|
||||
) {
|
||||
logger.debug(
|
||||
`Olm data not found for client ${client.clientId} and site ${resource.siteId}, skipping removal`
|
||||
`Olm data not found for client ${client.clientId} and site ${siteId}, skipping removal`
|
||||
);
|
||||
} else {
|
||||
throw error;
|
||||
|
||||
@@ -479,10 +479,7 @@ export async function getTraefikConfig(
|
||||
|
||||
// TODO: HOW TO HANDLE ^^^^^^ BETTER
|
||||
const anySitesOnline = targets.some(
|
||||
(target) =>
|
||||
target.site.online ||
|
||||
target.site.type === "local" ||
|
||||
target.site.type === "wireguard"
|
||||
(target) => target.site.online
|
||||
);
|
||||
|
||||
return (
|
||||
@@ -495,7 +492,7 @@ export async function getTraefikConfig(
|
||||
if (target.health == "unhealthy") {
|
||||
return false;
|
||||
}
|
||||
|
||||
|
||||
// If any sites are online, exclude offline sites
|
||||
if (anySitesOnline && !target.site.online) {
|
||||
return false;
|
||||
@@ -610,10 +607,7 @@ export async function getTraefikConfig(
|
||||
servers: (() => {
|
||||
// Check if any sites are online
|
||||
const anySitesOnline = targets.some(
|
||||
(target) =>
|
||||
target.site.online ||
|
||||
target.site.type === "local" ||
|
||||
target.site.type === "wireguard"
|
||||
(target) => target.site.online
|
||||
);
|
||||
|
||||
return targets
|
||||
@@ -621,7 +615,7 @@ export async function getTraefikConfig(
|
||||
if (!target.enabled) {
|
||||
return false;
|
||||
}
|
||||
|
||||
|
||||
// If any sites are online, exclude offline sites
|
||||
if (anySitesOnline && !target.site.online) {
|
||||
return false;
|
||||
|
||||
@@ -23,6 +23,8 @@ import {
|
||||
} from "@server/db";
|
||||
import logger from "@server/logger";
|
||||
import { and, eq, gt, desc, max, sql } from "drizzle-orm";
|
||||
import { decrypt } from "@server/lib/crypto";
|
||||
import config from "@server/lib/config";
|
||||
import {
|
||||
LogType,
|
||||
LOG_TYPES,
|
||||
@@ -272,19 +274,20 @@ export class LogStreamingManager {
|
||||
return;
|
||||
}
|
||||
|
||||
// Parse config – skip destination if config is unparseable
|
||||
let config: HttpConfig;
|
||||
// Decrypt and parse config – skip destination if either step fails
|
||||
let configFromDb: HttpConfig;
|
||||
try {
|
||||
config = JSON.parse(dest.config) as HttpConfig;
|
||||
const decryptedConfig = decrypt(dest.config, config.getRawConfig().server.secret!);
|
||||
configFromDb = JSON.parse(decryptedConfig) as HttpConfig;
|
||||
} catch (err) {
|
||||
logger.error(
|
||||
`LogStreamingManager: destination ${dest.destinationId} has invalid JSON config`,
|
||||
`LogStreamingManager: destination ${dest.destinationId} has invalid or undecryptable config`,
|
||||
err
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const provider = this.createProvider(dest.type, config);
|
||||
const provider = this.createProvider(dest.type, configFromDb);
|
||||
if (!provider) {
|
||||
logger.warn(
|
||||
`LogStreamingManager: unsupported destination type "${dest.type}" ` +
|
||||
|
||||
@@ -671,10 +671,7 @@ export async function getTraefikConfig(
|
||||
|
||||
// TODO: HOW TO HANDLE ^^^^^^ BETTER
|
||||
const anySitesOnline = targets.some(
|
||||
(target) =>
|
||||
target.site.online ||
|
||||
target.site.type === "local" ||
|
||||
target.site.type === "wireguard"
|
||||
(target) => target.site.online
|
||||
);
|
||||
|
||||
return (
|
||||
@@ -802,10 +799,7 @@ export async function getTraefikConfig(
|
||||
servers: (() => {
|
||||
// Check if any sites are online
|
||||
const anySitesOnline = targets.some(
|
||||
(target) =>
|
||||
target.site.online ||
|
||||
target.site.type === "local" ||
|
||||
target.site.type === "wireguard"
|
||||
(target) => target.site.online
|
||||
);
|
||||
|
||||
return targets
|
||||
|
||||
@@ -22,6 +22,8 @@ import createHttpError from "http-errors";
|
||||
import logger from "@server/logger";
|
||||
import { fromError } from "zod-validation-error";
|
||||
import { OpenAPITags, registry } from "@server/openApi";
|
||||
import { encrypt } from "@server/lib/crypto";
|
||||
import config from "@server/lib/config";
|
||||
|
||||
const paramsSchema = z.strictObject({
|
||||
orgId: z.string().nonempty()
|
||||
@@ -87,7 +89,10 @@ export async function createEventStreamingDestination(
|
||||
);
|
||||
}
|
||||
|
||||
const { type, config, enabled } = parsedBody.data;
|
||||
const { type, config: configToSet, enabled } = parsedBody.data;
|
||||
|
||||
const key = config.getRawConfig().server.secret!;
|
||||
const encryptedConfig = encrypt(configToSet, key);
|
||||
|
||||
const now = Date.now();
|
||||
|
||||
@@ -96,7 +101,7 @@ export async function createEventStreamingDestination(
|
||||
.values({
|
||||
orgId,
|
||||
type,
|
||||
config,
|
||||
config: encryptedConfig,
|
||||
enabled,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
|
||||
@@ -22,6 +22,8 @@ import logger from "@server/logger";
|
||||
import { fromError } from "zod-validation-error";
|
||||
import { OpenAPITags, registry } from "@server/openApi";
|
||||
import { eq, sql } from "drizzle-orm";
|
||||
import { decrypt } from "@server/lib/crypto";
|
||||
import config from "@server/lib/config";
|
||||
|
||||
const paramsSchema = z.strictObject({
|
||||
orgId: z.string().nonempty()
|
||||
@@ -121,9 +123,22 @@ export async function listEventStreamingDestinations(
|
||||
.from(eventStreamingDestinations)
|
||||
.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, {
|
||||
data: {
|
||||
destinations: list,
|
||||
destinations: decryptedList,
|
||||
pagination: {
|
||||
total: count,
|
||||
limit,
|
||||
|
||||
@@ -22,7 +22,8 @@ import logger from "@server/logger";
|
||||
import { fromError } from "zod-validation-error";
|
||||
import { OpenAPITags, registry } from "@server/openApi";
|
||||
import { and, eq } from "drizzle-orm";
|
||||
|
||||
import { encrypt } from "@server/lib/crypto";
|
||||
import config from "@server/lib/config";
|
||||
|
||||
const paramsSchema = z
|
||||
.object({
|
||||
@@ -110,14 +111,17 @@ export async function updateEventStreamingDestination(
|
||||
);
|
||||
}
|
||||
|
||||
const { type, config, enabled, sendAccessLogs, sendActionLogs, sendConnectionLogs, sendRequestLogs } = parsedBody.data;
|
||||
const { type, config: configToUpdate, enabled, sendAccessLogs, sendActionLogs, sendConnectionLogs, sendRequestLogs } = parsedBody.data;
|
||||
|
||||
const updateData: Record<string, unknown> = {
|
||||
updatedAt: Date.now()
|
||||
};
|
||||
|
||||
if (type !== undefined) updateData.type = type;
|
||||
if (config !== undefined) updateData.config = config;
|
||||
if (configToUpdate !== undefined) {
|
||||
const key = config.getRawConfig().server.secret!;
|
||||
updateData.config = encrypt(configToUpdate, key);
|
||||
}
|
||||
if (enabled !== undefined) updateData.enabled = enabled;
|
||||
if (sendAccessLogs !== undefined) updateData.sendAccessLogs = sendAccessLogs;
|
||||
if (sendActionLogs !== undefined) updateData.sendActionLogs = sendActionLogs;
|
||||
|
||||
@@ -21,7 +21,7 @@ import {
|
||||
roles,
|
||||
roundTripMessageTracker,
|
||||
siteResources,
|
||||
sites,
|
||||
siteNetworks,
|
||||
userOrgs
|
||||
} from "@server/db";
|
||||
import { logAccessAudit } from "#private/lib/logAccessAudit";
|
||||
@@ -63,10 +63,12 @@ const bodySchema = z
|
||||
|
||||
export type SignSshKeyResponse = {
|
||||
certificate: string;
|
||||
messageIds: number[];
|
||||
messageId: number;
|
||||
sshUsername: string;
|
||||
sshHost: string;
|
||||
resourceId: number;
|
||||
siteIds: number[];
|
||||
siteId: number;
|
||||
keyId: string;
|
||||
validPrincipals: string[];
|
||||
@@ -260,10 +262,7 @@ export async function signSshKey(
|
||||
.update(userOrgs)
|
||||
.set({ pamUsername: usernameToUse })
|
||||
.where(
|
||||
and(
|
||||
eq(userOrgs.orgId, orgId),
|
||||
eq(userOrgs.userId, userId)
|
||||
)
|
||||
and(eq(userOrgs.orgId, orgId), eq(userOrgs.userId, userId))
|
||||
);
|
||||
} else {
|
||||
usernameToUse = userOrg.pamUsername;
|
||||
@@ -395,21 +394,12 @@ export async function signSshKey(
|
||||
homedir = roleRows[0].sshCreateHomeDir ?? null;
|
||||
}
|
||||
|
||||
// get the site
|
||||
const [newt] = await db
|
||||
.select()
|
||||
.from(newts)
|
||||
.where(eq(newts.siteId, resource.siteId))
|
||||
.limit(1);
|
||||
const sites = await db
|
||||
.select({ siteId: siteNetworks.siteId })
|
||||
.from(siteNetworks)
|
||||
.where(eq(siteNetworks.networkId, resource.networkId!));
|
||||
|
||||
if (!newt) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.INTERNAL_SERVER_ERROR,
|
||||
"Site associated with resource not found"
|
||||
)
|
||||
);
|
||||
}
|
||||
const siteIds = sites.map((site) => site.siteId);
|
||||
|
||||
// Sign the public key
|
||||
const now = BigInt(Math.floor(Date.now() / 1000));
|
||||
@@ -423,43 +413,64 @@ export async function signSshKey(
|
||||
validBefore: now + validFor
|
||||
});
|
||||
|
||||
const [message] = await db
|
||||
.insert(roundTripMessageTracker)
|
||||
.values({
|
||||
wsClientId: newt.newtId,
|
||||
messageType: `newt/pam/connection`,
|
||||
sentAt: Math.floor(Date.now() / 1000)
|
||||
})
|
||||
.returning();
|
||||
const messageIds: number[] = [];
|
||||
for (const siteId of siteIds) {
|
||||
// get the site
|
||||
const [newt] = await db
|
||||
.select()
|
||||
.from(newts)
|
||||
.where(eq(newts.siteId, siteId))
|
||||
.limit(1);
|
||||
|
||||
if (!message) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.INTERNAL_SERVER_ERROR,
|
||||
"Failed to create message tracker entry"
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
if (!newt) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.INTERNAL_SERVER_ERROR,
|
||||
"Site associated with resource not found"
|
||||
)
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
const expiresIn = Number(validFor); // seconds
|
||||
|
||||
@@ -480,7 +491,7 @@ export async function signSshKey(
|
||||
metadata: JSON.stringify({
|
||||
resourceId: resource.siteResourceId,
|
||||
resource: resource.name,
|
||||
siteId: resource.siteId,
|
||||
siteIds: siteIds
|
||||
})
|
||||
});
|
||||
|
||||
@@ -505,11 +516,13 @@ export async function signSshKey(
|
||||
return response<SignSshKeyResponse>(res, {
|
||||
data: {
|
||||
certificate: cert.certificate,
|
||||
messageId: message.messageId,
|
||||
messageIds: messageIds,
|
||||
messageId: messageIds[0], // just pick the first one for backward compatibility
|
||||
sshUsername: usernameToUse,
|
||||
sshHost: sshHost,
|
||||
resourceId: resource.siteResourceId,
|
||||
siteId: resource.siteId,
|
||||
siteIds: siteIds,
|
||||
siteId: siteIds[0], // just pick the first one for backward compatibility
|
||||
keyId: cert.keyId,
|
||||
validPrincipals: cert.validPrincipals,
|
||||
validAfter: cert.validAfter.toISOString(),
|
||||
|
||||
@@ -171,9 +171,8 @@ export async function flushSiteBandwidthToDb(): Promise<void> {
|
||||
}
|
||||
|
||||
// PostgreSQL: batch UPDATE … FROM (VALUES …) — single round-trip per chunk.
|
||||
const valuesList = chunk.map(
|
||||
([publicKey, { bytesIn, bytesOut }]) =>
|
||||
sql`(${publicKey}, ${bytesIn}::bigint, ${bytesOut}::bigint)`
|
||||
const valuesList = chunk.map(([publicKey, { bytesIn, bytesOut }]) =>
|
||||
sql`(${publicKey}::text, ${bytesIn}::real, ${bytesOut}::real)`
|
||||
);
|
||||
const valuesClause = sql.join(valuesList, sql`, `);
|
||||
return dbQueryRows<{ orgId: string; pubKey: string }>(sql`
|
||||
|
||||
@@ -4,8 +4,10 @@ import {
|
||||
clientSitesAssociationsCache,
|
||||
db,
|
||||
ExitNode,
|
||||
networks,
|
||||
resources,
|
||||
Site,
|
||||
siteNetworks,
|
||||
siteResources,
|
||||
targetHealthCheck,
|
||||
targets
|
||||
@@ -137,11 +139,14 @@ export async function buildClientConfigurationForNewtClient(
|
||||
// Filter out any null values from peers that didn't have an olm
|
||||
const validPeers = peers.filter((peer) => peer !== null);
|
||||
|
||||
// Get all enabled site resources for this site
|
||||
// Get all enabled site resources for this site by joining through siteNetworks and networks
|
||||
const allSiteResources = await db
|
||||
.select()
|
||||
.from(siteResources)
|
||||
.where(eq(siteResources.siteId, siteId));
|
||||
.innerJoin(networks, eq(siteResources.networkId, networks.networkId))
|
||||
.innerJoin(siteNetworks, eq(networks.networkId, siteNetworks.networkId))
|
||||
.where(eq(siteNetworks.siteId, siteId))
|
||||
.then((rows) => rows.map((r) => r.siteResources));
|
||||
|
||||
const targetsToSend: SubnetProxyTargetV2[] = [];
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { db } from "@server/db";
|
||||
import { sites, clients, olms } from "@server/db";
|
||||
import { eq, inArray } from "drizzle-orm";
|
||||
import { inArray } from "drizzle-orm";
|
||||
import logger from "@server/logger";
|
||||
|
||||
/**
|
||||
@@ -21,7 +21,7 @@ import logger from "@server/logger";
|
||||
*/
|
||||
|
||||
const FLUSH_INTERVAL_MS = 10_000; // Flush every 10 seconds
|
||||
const MAX_RETRIES = 2;
|
||||
const MAX_RETRIES = 5;
|
||||
const BASE_DELAY_MS = 50;
|
||||
|
||||
// ── Site (newt) pings ──────────────────────────────────────────────────
|
||||
@@ -36,6 +36,14 @@ const pendingOlmArchiveResets: Set<string> = new Set();
|
||||
|
||||
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 ─────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
@@ -72,6 +80,12 @@ export function recordClientPing(
|
||||
|
||||
/**
|
||||
* 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> {
|
||||
if (pendingSitePings.size === 0) {
|
||||
@@ -83,55 +97,35 @@ async function flushSitePingsToDb(): Promise<void> {
|
||||
const pingsToFlush = new Map(pendingSitePings);
|
||||
pendingSitePings.clear();
|
||||
|
||||
// Sort by siteId for consistent lock ordering (prevents deadlocks)
|
||||
const sortedEntries = Array.from(pingsToFlush.entries()).sort(
|
||||
([a], [b]) => a - b
|
||||
);
|
||||
const entries = Array.from(pingsToFlush.entries());
|
||||
|
||||
const BATCH_SIZE = 50;
|
||||
for (let i = 0; i < sortedEntries.length; i += BATCH_SIZE) {
|
||||
const batch = sortedEntries.slice(i, i + BATCH_SIZE);
|
||||
for (let i = 0; i < entries.length; i += BATCH_SIZE) {
|
||||
const batch = entries.slice(i, i + BATCH_SIZE);
|
||||
|
||||
// Use the latest timestamp in the batch so that `lastPing` always
|
||||
// moves forward. Using a single timestamp for the whole batch means
|
||||
// we only ever need one UPDATE statement (no transaction).
|
||||
const maxTimestamp = Math.max(...batch.map(([, ts]) => ts));
|
||||
const siteIds = batch.map(([id]) => id);
|
||||
|
||||
try {
|
||||
await withRetry(async () => {
|
||||
// Group by timestamp for efficient bulk updates
|
||||
const byTimestamp = new Map<number, number[]>();
|
||||
for (const [siteId, timestamp] of batch) {
|
||||
const group = byTimestamp.get(timestamp) || [];
|
||||
group.push(siteId);
|
||||
byTimestamp.set(timestamp, group);
|
||||
}
|
||||
|
||||
if (byTimestamp.size === 1) {
|
||||
const [timestamp, siteIds] = Array.from(
|
||||
byTimestamp.entries()
|
||||
)[0];
|
||||
await db
|
||||
.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));
|
||||
}
|
||||
});
|
||||
}
|
||||
await db
|
||||
.update(sites)
|
||||
.set({
|
||||
online: true,
|
||||
lastPing: maxTimestamp
|
||||
})
|
||||
.where(inArray(sites.siteId, siteIds));
|
||||
}, "flushSitePingsToDb");
|
||||
} catch (error) {
|
||||
logger.error(
|
||||
`Failed to flush site ping batch (${batch.length} sites), re-queuing for next cycle`,
|
||||
{ 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) {
|
||||
const existing = pendingSitePings.get(siteId);
|
||||
if (!existing || existing < timestamp) {
|
||||
@@ -144,6 +138,8 @@ async function flushSitePingsToDb(): Promise<void> {
|
||||
|
||||
/**
|
||||
* Flush all accumulated client (OLM) pings to the database.
|
||||
*
|
||||
* Same single-UPDATE-per-batch approach as `flushSitePingsToDb`.
|
||||
*/
|
||||
async function flushClientPingsToDb(): Promise<void> {
|
||||
if (pendingClientPings.size === 0 && pendingOlmArchiveResets.size === 0) {
|
||||
@@ -159,51 +155,25 @@ async function flushClientPingsToDb(): Promise<void> {
|
||||
|
||||
// ── Flush client pings ─────────────────────────────────────────────
|
||||
if (pingsToFlush.size > 0) {
|
||||
const sortedEntries = Array.from(pingsToFlush.entries()).sort(
|
||||
([a], [b]) => a - b
|
||||
);
|
||||
const entries = Array.from(pingsToFlush.entries());
|
||||
|
||||
const BATCH_SIZE = 50;
|
||||
for (let i = 0; i < sortedEntries.length; i += BATCH_SIZE) {
|
||||
const batch = sortedEntries.slice(i, i + BATCH_SIZE);
|
||||
for (let i = 0; i < entries.length; i += BATCH_SIZE) {
|
||||
const batch = entries.slice(i, i + BATCH_SIZE);
|
||||
|
||||
const maxTimestamp = Math.max(...batch.map(([, ts]) => ts));
|
||||
const clientIds = batch.map(([id]) => id);
|
||||
|
||||
try {
|
||||
await withRetry(async () => {
|
||||
const byTimestamp = new Map<number, number[]>();
|
||||
for (const [clientId, timestamp] of batch) {
|
||||
const group = byTimestamp.get(timestamp) || [];
|
||||
group.push(clientId);
|
||||
byTimestamp.set(timestamp, group);
|
||||
}
|
||||
|
||||
if (byTimestamp.size === 1) {
|
||||
const [timestamp, clientIds] = Array.from(
|
||||
byTimestamp.entries()
|
||||
)[0];
|
||||
await db
|
||||
.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)
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
await db
|
||||
.update(clients)
|
||||
.set({
|
||||
lastPing: maxTimestamp,
|
||||
online: true,
|
||||
archived: false
|
||||
})
|
||||
.where(inArray(clients.clientId, clientIds));
|
||||
}, "flushClientPingsToDb");
|
||||
} catch (error) {
|
||||
logger.error(
|
||||
@@ -260,7 +230,12 @@ export async function flushPingsToDb(): Promise<void> {
|
||||
|
||||
/**
|
||||
* Simple retry wrapper with exponential backoff for transient errors
|
||||
* (connection timeouts, unexpected disconnects).
|
||||
* (deadlocks, connection timeouts, unexpected disconnects).
|
||||
*
|
||||
* PostgreSQL deadlocks (40P01) are always safe to retry: the database
|
||||
* guarantees exactly one winner per deadlock pair, so the loser just needs
|
||||
* to try again. MAX_RETRIES is intentionally higher than typical connection
|
||||
* retry budgets to give deadlock victims enough chances to succeed.
|
||||
*/
|
||||
async function withRetry<T>(
|
||||
operation: () => Promise<T>,
|
||||
@@ -277,7 +252,8 @@ async function withRetry<T>(
|
||||
const jitter = Math.random() * baseDelay;
|
||||
const delay = baseDelay + jitter;
|
||||
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));
|
||||
continue;
|
||||
@@ -288,14 +264,14 @@ async function withRetry<T>(
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect transient connection errors that are safe to retry.
|
||||
* Detect transient errors that are safe to retry.
|
||||
*/
|
||||
function isTransientError(error: any): boolean {
|
||||
if (!error) return false;
|
||||
|
||||
const message = (error.message || "").toLowerCase();
|
||||
const causeMessage = (error.cause?.message || "").toLowerCase();
|
||||
const code = error.code || "";
|
||||
const code = error.code || error.cause?.code || "";
|
||||
|
||||
// Connection timeout / terminated
|
||||
if (
|
||||
@@ -308,12 +284,17 @@ function isTransientError(error: any): boolean {
|
||||
return true;
|
||||
}
|
||||
|
||||
// PostgreSQL deadlock
|
||||
// PostgreSQL deadlock detected — always safe to retry (one winner guaranteed)
|
||||
if (code === "40P01" || message.includes("deadlock")) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// ECONNRESET, ECONNREFUSED, EPIPE
|
||||
// PostgreSQL serialization failure
|
||||
if (code === "40001") {
|
||||
return true;
|
||||
}
|
||||
|
||||
// ECONNRESET, ECONNREFUSED, EPIPE, ETIMEDOUT
|
||||
if (
|
||||
code === "ECONNRESET" ||
|
||||
code === "ECONNREFUSED" ||
|
||||
@@ -337,12 +318,26 @@ export function startPingAccumulator(): void {
|
||||
}
|
||||
|
||||
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 {
|
||||
await flushPingsToDb();
|
||||
} catch (error) {
|
||||
logger.error("Unhandled error in ping accumulator flush", {
|
||||
error
|
||||
});
|
||||
} finally {
|
||||
isFlushing = false;
|
||||
}
|
||||
}, FLUSH_INTERVAL_MS);
|
||||
|
||||
@@ -364,7 +359,22 @@ export async function stopPingAccumulator(): Promise<void> {
|
||||
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 {
|
||||
await flushPingsToDb();
|
||||
} catch (error) {
|
||||
@@ -379,4 +389,4 @@ export async function stopPingAccumulator(): Promise<void> {
|
||||
*/
|
||||
export function getPendingPingCount(): number {
|
||||
return pendingSitePings.size + pendingClientPings.size;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -27,7 +27,7 @@ import { build } from "@server/build";
|
||||
import { usageService } from "@server/lib/billing/usageService";
|
||||
import { FeatureId } from "@server/lib/billing";
|
||||
import { INSPECT_MAX_BYTES } from "buffer";
|
||||
import { v } from "@faker-js/faker/dist/airline-Dz1uGqgJ";
|
||||
import { getNextAvailableClientSubnet } from "@server/lib/ip";
|
||||
|
||||
const bodySchema = z.object({
|
||||
provisioningKey: z.string().nonempty(),
|
||||
@@ -152,6 +152,11 @@ export async function registerNewt(
|
||||
createHttpError(HttpCode.NOT_FOUND, "Organization not found")
|
||||
);
|
||||
}
|
||||
if (!org.subnet) {
|
||||
return next(
|
||||
createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "Organization subnet not found")
|
||||
);
|
||||
}
|
||||
|
||||
// SaaS billing check
|
||||
if (build == "saas") {
|
||||
@@ -190,6 +195,20 @@ export async function registerNewt(
|
||||
let newSiteId: number | undefined;
|
||||
|
||||
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)
|
||||
const [newSite] = await trx
|
||||
.insert(sites)
|
||||
@@ -197,6 +216,7 @@ export async function registerNewt(
|
||||
orgId,
|
||||
name: name || niceId,
|
||||
niceId,
|
||||
address: clientAddress,
|
||||
type: "newt",
|
||||
dockerSocketEnabled: true,
|
||||
status: keyRecord.approveNewSites ? "approved" : "pending",
|
||||
|
||||
@@ -4,6 +4,8 @@ import {
|
||||
clientSitesAssociationsCache,
|
||||
db,
|
||||
exitNodes,
|
||||
networks,
|
||||
siteNetworks,
|
||||
siteResources,
|
||||
sites
|
||||
} from "@server/db";
|
||||
@@ -59,9 +61,17 @@ export async function buildSiteConfigurationForOlmClient(
|
||||
clientSiteResourcesAssociationsCache.siteResourceId
|
||||
)
|
||||
)
|
||||
.innerJoin(
|
||||
networks,
|
||||
eq(siteResources.networkId, networks.networkId)
|
||||
)
|
||||
.innerJoin(
|
||||
siteNetworks,
|
||||
eq(networks.networkId, siteNetworks.networkId)
|
||||
)
|
||||
.where(
|
||||
and(
|
||||
eq(siteResources.siteId, site.siteId),
|
||||
eq(siteNetworks.siteId, site.siteId),
|
||||
eq(
|
||||
clientSiteResourcesAssociationsCache.clientId,
|
||||
client.clientId
|
||||
@@ -69,6 +79,7 @@ export async function buildSiteConfigurationForOlmClient(
|
||||
)
|
||||
);
|
||||
|
||||
|
||||
if (jitMode) {
|
||||
// Add site configuration to the array
|
||||
siteConfigurations.push({
|
||||
|
||||
@@ -4,10 +4,12 @@ import {
|
||||
db,
|
||||
exitNodes,
|
||||
Site,
|
||||
siteResources
|
||||
siteNetworks,
|
||||
siteResources,
|
||||
sites
|
||||
} from "@server/db";
|
||||
import { MessageHandler } from "@server/routers/ws";
|
||||
import { clients, Olm, sites } from "@server/db";
|
||||
import { clients, Olm } from "@server/db";
|
||||
import { and, eq, or } from "drizzle-orm";
|
||||
import logger from "@server/logger";
|
||||
import { initPeerAddHandshake } from "./peers";
|
||||
@@ -44,20 +46,31 @@ export const handleOlmServerInitAddPeerHandshake: MessageHandler = async (
|
||||
|
||||
const { siteId, resourceId, chainId } = message.data;
|
||||
|
||||
let site: Site | null = null;
|
||||
const sendCancel = async () => {
|
||||
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) {
|
||||
// get the site
|
||||
const [siteRes] = await db
|
||||
.select()
|
||||
.from(sites)
|
||||
.where(eq(sites.siteId, siteId))
|
||||
.limit(1);
|
||||
if (siteRes) {
|
||||
site = siteRes;
|
||||
sitesToProcess = [siteRes];
|
||||
}
|
||||
}
|
||||
|
||||
if (resourceId && !site) {
|
||||
} else if (resourceId) {
|
||||
const resources = await db
|
||||
.select()
|
||||
.from(siteResources)
|
||||
@@ -72,27 +85,17 @@ export const handleOlmServerInitAddPeerHandshake: MessageHandler = async (
|
||||
);
|
||||
|
||||
if (!resources || resources.length === 0) {
|
||||
logger.error(`handleOlmServerPeerAddMessage: Resource not found`);
|
||||
// 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);
|
||||
});
|
||||
logger.error(
|
||||
`handleOlmServerInitAddPeerHandshake: Resource not found`
|
||||
);
|
||||
await sendCancel();
|
||||
return;
|
||||
}
|
||||
|
||||
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
|
||||
logger.error(
|
||||
`handleOlmServerPeerAddMessage: Multiple resources found matching the criteria`
|
||||
`handleOlmServerInitAddPeerHandshake: Multiple resources found matching the criteria`
|
||||
);
|
||||
return;
|
||||
}
|
||||
@@ -117,125 +120,120 @@ export const handleOlmServerInitAddPeerHandshake: MessageHandler = async (
|
||||
|
||||
if (currentResourceAssociationCaches.length === 0) {
|
||||
logger.error(
|
||||
`handleOlmServerPeerAddMessage: Client ${client.clientId} does not have access to resource ${resource.siteResourceId}`
|
||||
`handleOlmServerInitAddPeerHandshake: Client ${client.clientId} does not have access to resource ${resource.siteResourceId}`
|
||||
);
|
||||
// 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);
|
||||
});
|
||||
await sendCancel();
|
||||
return;
|
||||
}
|
||||
|
||||
const siteIdFromResource = resource.siteId;
|
||||
|
||||
// get the site
|
||||
const [siteRes] = await db
|
||||
.select()
|
||||
.from(sites)
|
||||
.where(eq(sites.siteId, siteIdFromResource));
|
||||
if (!siteRes) {
|
||||
if (!resource.networkId) {
|
||||
logger.error(
|
||||
`handleOlmServerPeerAddMessage: Site with ID ${site} not found`
|
||||
`handleOlmServerInitAddPeerHandshake: Resource ${resource.siteResourceId} has no network`
|
||||
);
|
||||
await sendCancel();
|
||||
return;
|
||||
}
|
||||
|
||||
site = siteRes;
|
||||
// Get all sites associated with this resource's network via siteNetworks
|
||||
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 (!site) {
|
||||
logger.error(`handleOlmServerPeerAddMessage: Site not found`);
|
||||
if (sitesToProcess.length === 0) {
|
||||
logger.error(
|
||||
`handleOlmServerInitAddPeerHandshake: No sites to process`
|
||||
);
|
||||
await sendCancel();
|
||||
return;
|
||||
}
|
||||
|
||||
// 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)
|
||||
)
|
||||
);
|
||||
let handshakeInitiated = false;
|
||||
|
||||
if (currentSiteAssociationCaches.length === 0) {
|
||||
logger.error(
|
||||
`handleOlmServerPeerAddMessage: Client ${client.clientId} does not have access to site ${site.siteId}`
|
||||
);
|
||||
// cancel the request from the olm side to not keep doing this
|
||||
await sendToClient(
|
||||
olm.olmId,
|
||||
for (const site of sitesToProcess) {
|
||||
// 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)
|
||||
)
|
||||
);
|
||||
|
||||
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,
|
||||
{
|
||||
type: "olm/wg/peer/chain/cancel",
|
||||
data: {
|
||||
chainId
|
||||
siteId: site.siteId,
|
||||
exitNode: {
|
||||
publicKey: exitNode.publicKey,
|
||||
endpoint: exitNode.endpoint
|
||||
}
|
||||
},
|
||||
{ incrementConfigVersion: false }
|
||||
).catch((error) => {
|
||||
logger.warn(`Error sending message:`, error);
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (!site.exitNodeId) {
|
||||
logger.error(
|
||||
`handleOlmServerPeerAddMessage: Site with ID ${site.siteId} has no exit node`
|
||||
);
|
||||
// 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`
|
||||
chainId
|
||||
);
|
||||
return;
|
||||
|
||||
handshakeInitiated = true;
|
||||
}
|
||||
|
||||
// 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
|
||||
);
|
||||
if (!handshakeInitiated) {
|
||||
logger.error(
|
||||
`handleOlmServerInitAddPeerHandshake: No accessible sites with valid exit nodes found, cancelling chain`
|
||||
);
|
||||
await sendCancel();
|
||||
}
|
||||
|
||||
return;
|
||||
};
|
||||
};
|
||||
@@ -1,43 +1,25 @@
|
||||
import {
|
||||
Client,
|
||||
clientSiteResourcesAssociationsCache,
|
||||
db,
|
||||
ExitNode,
|
||||
Org,
|
||||
orgs,
|
||||
roleClients,
|
||||
roles,
|
||||
networks,
|
||||
siteNetworks,
|
||||
siteResources,
|
||||
Transaction,
|
||||
userClients,
|
||||
userOrgs,
|
||||
users
|
||||
} from "@server/db";
|
||||
import { MessageHandler } from "@server/routers/ws";
|
||||
import {
|
||||
clients,
|
||||
clientSitesAssociationsCache,
|
||||
exitNodes,
|
||||
Olm,
|
||||
olms,
|
||||
sites
|
||||
} from "@server/db";
|
||||
import { and, eq, inArray, isNotNull, isNull } from "drizzle-orm";
|
||||
import { addPeer, deletePeer } from "../newt/peers";
|
||||
import logger from "@server/logger";
|
||||
import { listExitNodes } from "#dynamic/lib/exitNodes";
|
||||
import {
|
||||
generateAliasConfig,
|
||||
getNextAvailableClientSubnet
|
||||
} 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 {
|
||||
addPeer as newtAddPeer,
|
||||
deletePeer as newtDeletePeer
|
||||
} from "@server/routers/newt/peers";
|
||||
|
||||
export const handleOlmServerPeerAddMessage: MessageHandler = async (
|
||||
@@ -153,13 +135,21 @@ export const handleOlmServerPeerAddMessage: MessageHandler = async (
|
||||
clientSiteResourcesAssociationsCache.siteResourceId
|
||||
)
|
||||
)
|
||||
.where(
|
||||
.innerJoin(
|
||||
networks,
|
||||
eq(siteResources.networkId, networks.networkId)
|
||||
)
|
||||
.innerJoin(
|
||||
siteNetworks,
|
||||
and(
|
||||
eq(siteResources.siteId, site.siteId),
|
||||
eq(
|
||||
clientSiteResourcesAssociationsCache.clientId,
|
||||
client.clientId
|
||||
)
|
||||
eq(networks.networkId, siteNetworks.networkId),
|
||||
eq(siteNetworks.siteId, site.siteId)
|
||||
)
|
||||
)
|
||||
.where(
|
||||
eq(
|
||||
clientSiteResourcesAssociationsCache.clientId,
|
||||
client.clientId
|
||||
)
|
||||
);
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Request, Response, NextFunction } from "express";
|
||||
import { z } from "zod";
|
||||
import { db, Site, siteResources } from "@server/db";
|
||||
import { db, Site, siteNetworks, siteResources } from "@server/db";
|
||||
import { newts, newtSessions, sites } from "@server/db";
|
||||
import { eq } from "drizzle-orm";
|
||||
import response from "@server/lib/response";
|
||||
@@ -71,18 +71,23 @@ export async function deleteSite(
|
||||
await deletePeer(site.exitNodeId!, site.pubKey);
|
||||
}
|
||||
} else if (site.type == "newt") {
|
||||
// delete all of the site resources on this site
|
||||
const siteResourcesOnSite = trx
|
||||
.delete(siteResources)
|
||||
.where(eq(siteResources.siteId, siteId))
|
||||
.returning();
|
||||
const networks = await trx
|
||||
.select({ networkId: siteNetworks.networkId })
|
||||
.from(siteNetworks)
|
||||
.where(eq(siteNetworks.siteId, siteId));
|
||||
|
||||
// loop through them
|
||||
for (const removedSiteResource of await siteResourcesOnSite) {
|
||||
await rebuildClientAssociationsFromSiteResource(
|
||||
removedSiteResource,
|
||||
trx
|
||||
);
|
||||
for (const network of await networks) {
|
||||
const [siteResource] = await trx
|
||||
.select()
|
||||
.from(siteResources)
|
||||
.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
|
||||
|
||||
@@ -5,6 +5,8 @@ import {
|
||||
orgs,
|
||||
roles,
|
||||
roleSiteResources,
|
||||
siteNetworks,
|
||||
networks,
|
||||
SiteResource,
|
||||
siteResources,
|
||||
sites,
|
||||
@@ -23,7 +25,7 @@ import response from "@server/lib/response";
|
||||
import logger from "@server/logger";
|
||||
import { OpenAPITags, registry } from "@server/openApi";
|
||||
import HttpCode from "@server/types/HttpCode";
|
||||
import { and, eq } from "drizzle-orm";
|
||||
import { and, eq, inArray } from "drizzle-orm";
|
||||
import { NextFunction, Request, Response } from "express";
|
||||
import createHttpError from "http-errors";
|
||||
import { z } from "zod";
|
||||
@@ -37,7 +39,7 @@ const createSiteResourceSchema = z
|
||||
.strictObject({
|
||||
name: z.string().min(1).max(255),
|
||||
mode: z.enum(["host", "cidr", "port"]),
|
||||
siteId: z.int(),
|
||||
siteIds: z.array(z.int()),
|
||||
// protocol: z.enum(["tcp", "udp"]).optional(),
|
||||
// proxyPort: z.int().positive().optional(),
|
||||
// destinationPort: z.int().positive().optional(),
|
||||
@@ -159,7 +161,7 @@ export async function createSiteResource(
|
||||
const { orgId } = parsedParams.data;
|
||||
const {
|
||||
name,
|
||||
siteId,
|
||||
siteIds,
|
||||
mode,
|
||||
// protocol,
|
||||
// proxyPort,
|
||||
@@ -178,14 +180,16 @@ export async function createSiteResource(
|
||||
} = parsedBody.data;
|
||||
|
||||
// Verify the site exists and belongs to the org
|
||||
const [site] = await db
|
||||
const sitesToAssign = await db
|
||||
.select()
|
||||
.from(sites)
|
||||
.where(and(eq(sites.siteId, siteId), eq(sites.orgId, orgId)))
|
||||
.where(and(inArray(sites.siteId, siteIds), eq(sites.orgId, orgId)))
|
||||
.limit(1);
|
||||
|
||||
if (!site) {
|
||||
return next(createHttpError(HttpCode.NOT_FOUND, "Site not found"));
|
||||
if (sitesToAssign.length !== siteIds.length) {
|
||||
return next(
|
||||
createHttpError(HttpCode.NOT_FOUND, "Some site not found")
|
||||
);
|
||||
}
|
||||
|
||||
const [org] = await db
|
||||
@@ -287,12 +291,29 @@ export async function createSiteResource(
|
||||
|
||||
let newSiteResource: SiteResource | undefined;
|
||||
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
|
||||
const insertValues: typeof siteResources.$inferInsert = {
|
||||
siteId,
|
||||
niceId,
|
||||
orgId,
|
||||
name,
|
||||
networkId: network.networkId,
|
||||
mode: mode as "host" | "cidr",
|
||||
destination,
|
||||
enabled,
|
||||
@@ -317,6 +338,13 @@ export async function createSiteResource(
|
||||
|
||||
//////////////////// update the associations ////////////////////
|
||||
|
||||
for (const siteId of siteIds) {
|
||||
await trx.insert(siteNetworks).values({
|
||||
siteId: siteId,
|
||||
networkId: network.networkId
|
||||
});
|
||||
}
|
||||
|
||||
const [adminRole] = await trx
|
||||
.select()
|
||||
.from(roles)
|
||||
@@ -359,16 +387,21 @@ export async function createSiteResource(
|
||||
);
|
||||
}
|
||||
|
||||
const [newt] = await trx
|
||||
.select()
|
||||
.from(newts)
|
||||
.where(eq(newts.siteId, site.siteId))
|
||||
.limit(1);
|
||||
for (const siteToAssign of sitesToAssign) {
|
||||
const [newt] = await trx
|
||||
.select()
|
||||
.from(newts)
|
||||
.where(eq(newts.siteId, siteToAssign.siteId))
|
||||
.limit(1);
|
||||
|
||||
if (!newt) {
|
||||
return next(
|
||||
createHttpError(HttpCode.NOT_FOUND, "Newt not found")
|
||||
);
|
||||
if (!newt) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.NOT_FOUND,
|
||||
`Newt not found for site ${siteToAssign.siteId}`
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
await rebuildClientAssociationsFromSiteResource(
|
||||
@@ -387,7 +420,7 @@ export async function createSiteResource(
|
||||
}
|
||||
|
||||
logger.info(
|
||||
`Created site resource ${newSiteResource.siteResourceId} for site ${siteId}`
|
||||
`Created site resource ${newSiteResource.siteResourceId} for org ${orgId}`
|
||||
);
|
||||
|
||||
return response(res, {
|
||||
|
||||
@@ -70,17 +70,18 @@ export async function deleteSiteResource(
|
||||
.where(and(eq(siteResources.siteResourceId, siteResourceId)))
|
||||
.returning();
|
||||
|
||||
const [newt] = await trx
|
||||
.select()
|
||||
.from(newts)
|
||||
.where(eq(newts.siteId, removedSiteResource.siteId))
|
||||
.limit(1);
|
||||
// not sure why this is here...
|
||||
// const [newt] = await trx
|
||||
// .select()
|
||||
// .from(newts)
|
||||
// .where(eq(newts.siteId, removedSiteResource.siteId))
|
||||
// .limit(1);
|
||||
|
||||
if (!newt) {
|
||||
return next(
|
||||
createHttpError(HttpCode.NOT_FOUND, "Newt not found")
|
||||
);
|
||||
}
|
||||
// if (!newt) {
|
||||
// return next(
|
||||
// createHttpError(HttpCode.NOT_FOUND, "Newt not found")
|
||||
// );
|
||||
// }
|
||||
|
||||
await rebuildClientAssociationsFromSiteResource(
|
||||
removedSiteResource,
|
||||
|
||||
@@ -17,38 +17,34 @@ const getSiteResourceParamsSchema = z.strictObject({
|
||||
.transform((val) => (val ? Number(val) : undefined))
|
||||
.pipe(z.int().positive().optional())
|
||||
.optional(),
|
||||
siteId: z.string().transform(Number).pipe(z.int().positive()),
|
||||
niceId: z.string().optional(),
|
||||
orgId: z.string()
|
||||
});
|
||||
|
||||
async function query(
|
||||
siteResourceId?: number,
|
||||
siteId?: number,
|
||||
niceId?: string,
|
||||
orgId?: string
|
||||
) {
|
||||
if (siteResourceId && siteId && orgId) {
|
||||
if (siteResourceId && orgId) {
|
||||
const [siteResource] = await db
|
||||
.select()
|
||||
.from(siteResources)
|
||||
.where(
|
||||
and(
|
||||
eq(siteResources.siteResourceId, siteResourceId),
|
||||
eq(siteResources.siteId, siteId),
|
||||
eq(siteResources.orgId, orgId)
|
||||
)
|
||||
)
|
||||
.limit(1);
|
||||
return siteResource;
|
||||
} else if (niceId && siteId && orgId) {
|
||||
} else if (niceId && orgId) {
|
||||
const [siteResource] = await db
|
||||
.select()
|
||||
.from(siteResources)
|
||||
.where(
|
||||
and(
|
||||
eq(siteResources.niceId, niceId),
|
||||
eq(siteResources.siteId, siteId),
|
||||
eq(siteResources.orgId, orgId)
|
||||
)
|
||||
)
|
||||
@@ -84,7 +80,6 @@ registry.registerPath({
|
||||
request: {
|
||||
params: z.object({
|
||||
niceId: z.string(),
|
||||
siteId: z.number(),
|
||||
orgId: z.string()
|
||||
})
|
||||
},
|
||||
@@ -107,10 +102,10 @@ export async function getSiteResource(
|
||||
);
|
||||
}
|
||||
|
||||
const { siteResourceId, siteId, niceId, orgId } = parsedParams.data;
|
||||
const { siteResourceId, niceId, orgId } = parsedParams.data;
|
||||
|
||||
// Get the site resource
|
||||
const siteResource = await query(siteResourceId, siteId, niceId, orgId);
|
||||
const siteResource = await query(siteResourceId, niceId, orgId);
|
||||
|
||||
if (!siteResource) {
|
||||
return next(
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { db, SiteResource, siteResources, sites } from "@server/db";
|
||||
import { db, SiteResource, siteNetworks, siteResources, sites } from "@server/db";
|
||||
import response from "@server/lib/response";
|
||||
import logger from "@server/logger";
|
||||
import { OpenAPITags, registry } from "@server/openApi";
|
||||
@@ -73,9 +73,10 @@ const listAllSiteResourcesByOrgQuerySchema = z.object({
|
||||
|
||||
export type ListAllSiteResourcesByOrgResponse = PaginatedResponse<{
|
||||
siteResources: (SiteResource & {
|
||||
siteName: string;
|
||||
siteNiceId: string;
|
||||
siteAddress: string | null;
|
||||
siteIds: number[];
|
||||
siteNames: string[];
|
||||
siteNiceIds: string[];
|
||||
siteAddresses: (string | null)[];
|
||||
})[];
|
||||
}>;
|
||||
|
||||
@@ -83,7 +84,6 @@ function querySiteResourcesBase() {
|
||||
return db
|
||||
.select({
|
||||
siteResourceId: siteResources.siteResourceId,
|
||||
siteId: siteResources.siteId,
|
||||
orgId: siteResources.orgId,
|
||||
niceId: siteResources.niceId,
|
||||
name: siteResources.name,
|
||||
@@ -100,14 +100,20 @@ function querySiteResourcesBase() {
|
||||
disableIcmp: siteResources.disableIcmp,
|
||||
authDaemonMode: siteResources.authDaemonMode,
|
||||
authDaemonPort: siteResources.authDaemonPort,
|
||||
siteName: sites.name,
|
||||
siteNiceId: sites.niceId,
|
||||
siteAddress: sites.address
|
||||
networkId: siteResources.networkId,
|
||||
defaultNetworkId: siteResources.defaultNetworkId,
|
||||
siteNames: sql<string[]>`array_agg(${sites.name})`,
|
||||
siteNiceIds: sql<string[]>`array_agg(${sites.niceId})`,
|
||||
siteIds: sql<number[]>`array_agg(${sites.siteId})`,
|
||||
siteAddresses: sql<(string | null)[]>`array_agg(${sites.address})`
|
||||
})
|
||||
.from(siteResources)
|
||||
.innerJoin(sites, eq(siteResources.siteId, sites.siteId));
|
||||
.innerJoin(siteNetworks, eq(siteResources.networkId, siteNetworks.networkId))
|
||||
.innerJoin(sites, eq(siteNetworks.siteId, sites.siteId))
|
||||
.groupBy(siteResources.siteResourceId);
|
||||
}
|
||||
|
||||
|
||||
registry.registerPath({
|
||||
method: "get",
|
||||
path: "/org/{orgId}/site-resources",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Request, Response, NextFunction } from "express";
|
||||
import { z } from "zod";
|
||||
import { db } from "@server/db";
|
||||
import { db, networks, siteNetworks } from "@server/db";
|
||||
import { siteResources, sites, SiteResource } from "@server/db";
|
||||
import response from "@server/lib/response";
|
||||
import HttpCode from "@server/types/HttpCode";
|
||||
@@ -108,13 +108,21 @@ export async function listSiteResources(
|
||||
return next(createHttpError(HttpCode.NOT_FOUND, "Site not found"));
|
||||
}
|
||||
|
||||
// Get site resources
|
||||
// Get site resources by joining networks to siteResources via siteNetworks
|
||||
const siteResourcesList = await db
|
||||
.select()
|
||||
.from(siteResources)
|
||||
.from(siteNetworks)
|
||||
.innerJoin(
|
||||
networks,
|
||||
eq(siteNetworks.networkId, networks.networkId)
|
||||
)
|
||||
.innerJoin(
|
||||
siteResources,
|
||||
eq(siteResources.networkId, networks.networkId)
|
||||
)
|
||||
.where(
|
||||
and(
|
||||
eq(siteResources.siteId, siteId),
|
||||
eq(siteNetworks.siteId, siteId),
|
||||
eq(siteResources.orgId, orgId)
|
||||
)
|
||||
)
|
||||
@@ -128,6 +136,7 @@ export async function listSiteResources(
|
||||
.limit(limit)
|
||||
.offset(offset);
|
||||
|
||||
|
||||
return response(res, {
|
||||
data: { siteResources: siteResourcesList },
|
||||
success: true,
|
||||
|
||||
@@ -7,12 +7,18 @@ import {
|
||||
orgs,
|
||||
roles,
|
||||
roleSiteResources,
|
||||
siteNetworks,
|
||||
SiteResource,
|
||||
siteResources,
|
||||
sites,
|
||||
networks,
|
||||
Transaction,
|
||||
userSiteResources
|
||||
} 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 {
|
||||
generateAliasConfig,
|
||||
@@ -22,12 +28,8 @@ import {
|
||||
portRangeStringSchema
|
||||
} from "@server/lib/ip";
|
||||
import { rebuildClientAssociationsFromSiteResource } from "@server/lib/rebuildClientAssociations";
|
||||
import response from "@server/lib/response";
|
||||
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 { and, eq, ne } from "drizzle-orm";
|
||||
import { NextFunction, Request, Response } from "express";
|
||||
import createHttpError from "http-errors";
|
||||
import { z } from "zod";
|
||||
@@ -40,7 +42,8 @@ const updateSiteResourceParamsSchema = z.strictObject({
|
||||
const updateSiteResourceSchema = z
|
||||
.strictObject({
|
||||
name: z.string().min(1).max(255).optional(),
|
||||
siteId: z.int(),
|
||||
siteIds: z.array(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
|
||||
.string()
|
||||
.min(1)
|
||||
@@ -172,7 +175,7 @@ export async function updateSiteResource(
|
||||
const { siteResourceId } = parsedParams.data;
|
||||
const {
|
||||
name,
|
||||
siteId, // because it can change
|
||||
siteIds, // because it can change
|
||||
niceId,
|
||||
mode,
|
||||
destination,
|
||||
@@ -188,16 +191,6 @@ export async function updateSiteResource(
|
||||
authDaemonMode
|
||||
} = 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
|
||||
const [existingSiteResource] = await db
|
||||
.select()
|
||||
@@ -237,6 +230,24 @@ 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
|
||||
const isIp = z
|
||||
.union([z.ipv4(), z.ipv6()])
|
||||
@@ -254,25 +265,24 @@ export async function updateSiteResource(
|
||||
);
|
||||
}
|
||||
|
||||
let existingSite = site;
|
||||
let siteChanged = false;
|
||||
if (existingSiteResource.siteId !== siteId) {
|
||||
siteChanged = true;
|
||||
// get the existing site
|
||||
[existingSite] = await db
|
||||
.select()
|
||||
.from(sites)
|
||||
.where(eq(sites.siteId, existingSiteResource.siteId))
|
||||
.limit(1);
|
||||
let sitesChanged = false;
|
||||
const existingSiteIds = existingSiteResource.networkId
|
||||
? await db
|
||||
.select()
|
||||
.from(siteNetworks)
|
||||
.where(
|
||||
eq(siteNetworks.networkId, existingSiteResource.networkId)
|
||||
)
|
||||
: [];
|
||||
|
||||
if (!existingSite) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.NOT_FOUND,
|
||||
"Existing site not found"
|
||||
)
|
||||
);
|
||||
}
|
||||
const existingSiteIdSet = new Set(existingSiteIds.map((s) => s.siteId));
|
||||
const newSiteIdSet = new Set(siteIds);
|
||||
|
||||
if (
|
||||
existingSiteIdSet.size !== newSiteIdSet.size ||
|
||||
![...existingSiteIdSet].every((id) => newSiteIdSet.has(id))
|
||||
) {
|
||||
sitesChanged = true;
|
||||
}
|
||||
|
||||
// make sure the alias is unique within the org if provided
|
||||
@@ -302,7 +312,7 @@ export async function updateSiteResource(
|
||||
let updatedSiteResource: SiteResource | undefined;
|
||||
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 (siteChanged) {
|
||||
if (sitesChanged) {
|
||||
// delete the existing site resource
|
||||
await trx
|
||||
.delete(siteResources)
|
||||
@@ -343,7 +353,6 @@ export async function updateSiteResource(
|
||||
.update(siteResources)
|
||||
.set({
|
||||
name,
|
||||
siteId,
|
||||
niceId,
|
||||
mode,
|
||||
destination,
|
||||
@@ -447,7 +456,6 @@ export async function updateSiteResource(
|
||||
.update(siteResources)
|
||||
.set({
|
||||
name: name,
|
||||
siteId: siteId,
|
||||
mode: mode,
|
||||
destination: destination,
|
||||
enabled: enabled,
|
||||
@@ -464,6 +472,23 @@ export async function updateSiteResource(
|
||||
|
||||
//////////////////// 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
|
||||
.delete(clientSiteResources)
|
||||
.where(
|
||||
@@ -533,14 +558,15 @@ export async function updateSiteResource(
|
||||
);
|
||||
}
|
||||
|
||||
logger.info(
|
||||
`Updated site resource ${siteResourceId} for site ${siteId}`
|
||||
);
|
||||
logger.info(`Updated site resource ${siteResourceId}`);
|
||||
|
||||
await handleMessagingForUpdatedSiteResource(
|
||||
existingSiteResource,
|
||||
updatedSiteResource,
|
||||
{ siteId: site.siteId, orgId: site.orgId },
|
||||
siteIds.map((siteId) => ({
|
||||
siteId,
|
||||
orgId: existingSiteResource.orgId
|
||||
})),
|
||||
trx
|
||||
);
|
||||
}
|
||||
@@ -567,7 +593,7 @@ export async function updateSiteResource(
|
||||
export async function handleMessagingForUpdatedSiteResource(
|
||||
existingSiteResource: SiteResource | undefined,
|
||||
updatedSiteResource: SiteResource,
|
||||
site: { siteId: number; orgId: string },
|
||||
sites: { siteId: number; orgId: string }[],
|
||||
trx: Transaction
|
||||
) {
|
||||
logger.debug(
|
||||
@@ -604,105 +630,112 @@ 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 (destinationChanged || aliasChanged || portRangesChanged) {
|
||||
const [newt] = await trx
|
||||
.select()
|
||||
.from(newts)
|
||||
.where(eq(newts.siteId, site.siteId))
|
||||
.limit(1);
|
||||
|
||||
if (!newt) {
|
||||
throw new Error(
|
||||
"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
|
||||
for (const site of sites) {
|
||||
const [newt] = 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
|
||||
)
|
||||
)
|
||||
.from(newts)
|
||||
.where(eq(newts.siteId, site.siteId))
|
||||
.limit(1);
|
||||
|
||||
if (!newt) {
|
||||
throw new Error(
|
||||
"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
|
||||
);
|
||||
|
||||
const oldDestinationStillInUseByASite =
|
||||
oldDestinationStillInUseSites.length > 0;
|
||||
await updateTargets(
|
||||
newt.newtId,
|
||||
{
|
||||
oldTargets: oldTarget ? [oldTarget] : [],
|
||||
newTargets: newTarget ? [newTarget] : []
|
||||
},
|
||||
newt.version
|
||||
);
|
||||
}
|
||||
|
||||
// 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
|
||||
)
|
||||
);
|
||||
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);
|
||||
}
|
||||
|
||||
await Promise.all(olmJobs);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -235,7 +235,9 @@ export default async function migration() {
|
||||
for (const row of existingUserInviteRoles) {
|
||||
await db.execute(sql`
|
||||
INSERT INTO "userInviteRoles" ("inviteId", "roleId")
|
||||
VALUES (${row.inviteId}, ${row.roleId})
|
||||
SELECT ${row.inviteId}, ${row.roleId}
|
||||
WHERE EXISTS (SELECT 1 FROM "userInvites" WHERE "inviteId" = ${row.inviteId})
|
||||
AND EXISTS (SELECT 1 FROM "roles" WHERE "roleId" = ${row.roleId})
|
||||
ON CONFLICT DO NOTHING
|
||||
`);
|
||||
}
|
||||
@@ -258,7 +260,10 @@ export default async function migration() {
|
||||
for (const row of existingUserOrgRoles) {
|
||||
await db.execute(sql`
|
||||
INSERT INTO "userOrgRoles" ("userId", "orgId", "roleId")
|
||||
VALUES (${row.userId}, ${row.orgId}, ${row.roleId})
|
||||
SELECT ${row.userId}, ${row.orgId}, ${row.roleId}
|
||||
WHERE EXISTS (SELECT 1 FROM "user" WHERE "id" = ${row.userId})
|
||||
AND EXISTS (SELECT 1 FROM "orgs" WHERE "orgId" = ${row.orgId})
|
||||
AND EXISTS (SELECT 1 FROM "roles" WHERE "roleId" = ${row.roleId})
|
||||
ON CONFLICT DO NOTHING
|
||||
`);
|
||||
}
|
||||
|
||||
@@ -145,7 +145,7 @@ export default async function migration() {
|
||||
).run();
|
||||
|
||||
db.prepare(
|
||||
`INSERT INTO '__new_userOrgs'("userId", "orgId", "isOwner", "autoProvisioned", "pamUsername") SELECT "userId", "orgId", "isOwner", "autoProvisioned", "pamUsername" FROM 'userOrgs';`
|
||||
`INSERT INTO '__new_userOrgs'("userId", "orgId", "isOwner", "autoProvisioned", "pamUsername") SELECT "userId", "orgId", "isOwner", "autoProvisioned", "pamUsername" FROM 'userOrgs' WHERE EXISTS (SELECT 1 FROM 'user' WHERE id = userOrgs.userId) AND EXISTS (SELECT 1 FROM 'orgs' WHERE orgId = userOrgs.orgId);`
|
||||
).run();
|
||||
db.prepare(`DROP TABLE 'userOrgs';`).run();
|
||||
db.prepare(
|
||||
@@ -246,12 +246,15 @@ export default async function migration() {
|
||||
// Re-insert the preserved invite role assignments into the new userInviteRoles table
|
||||
if (existingUserInviteRoles.length > 0) {
|
||||
const insertUserInviteRole = db.prepare(
|
||||
`INSERT OR IGNORE INTO 'userInviteRoles' ("inviteId", "roleId") VALUES (?, ?)`
|
||||
`INSERT OR IGNORE INTO 'userInviteRoles' ("inviteId", "roleId")
|
||||
SELECT ?, ?
|
||||
WHERE EXISTS (SELECT 1 FROM 'userInvites' WHERE inviteId = ?)
|
||||
AND EXISTS (SELECT 1 FROM 'roles' WHERE roleId = ?)`
|
||||
);
|
||||
|
||||
const insertAll = db.transaction(() => {
|
||||
for (const row of existingUserInviteRoles) {
|
||||
insertUserInviteRole.run(row.inviteId, row.roleId);
|
||||
insertUserInviteRole.run(row.inviteId, row.roleId, row.inviteId, row.roleId);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -265,12 +268,16 @@ export default async function migration() {
|
||||
// Re-insert the preserved role assignments into the new userOrgRoles table
|
||||
if (existingUserOrgRoles.length > 0) {
|
||||
const insertUserOrgRole = db.prepare(
|
||||
`INSERT OR IGNORE INTO 'userOrgRoles' ("userId", "orgId", "roleId") VALUES (?, ?, ?)`
|
||||
`INSERT OR IGNORE INTO 'userOrgRoles' ("userId", "orgId", "roleId")
|
||||
SELECT ?, ?, ?
|
||||
WHERE EXISTS (SELECT 1 FROM 'user' WHERE id = ?)
|
||||
AND EXISTS (SELECT 1 FROM 'orgs' WHERE orgId = ?)
|
||||
AND EXISTS (SELECT 1 FROM 'roles' WHERE roleId = ?)`
|
||||
);
|
||||
|
||||
const insertAll = db.transaction(() => {
|
||||
for (const row of existingUserOrgRoles) {
|
||||
insertUserOrgRole.run(row.userId, row.orgId, row.roleId);
|
||||
insertUserOrgRole.run(row.userId, row.orgId, row.roleId, row.userId, row.orgId, row.roleId);
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -10,6 +10,7 @@ import { authCookieHeader } from "@app/lib/api/cookies";
|
||||
import { GetDNSRecordsResponse } from "@server/routers/domain";
|
||||
import DNSRecordsTable from "@app/components/DNSRecordTable";
|
||||
import DomainCertForm from "@app/components/DomainCertForm";
|
||||
import { build } from "@server/build";
|
||||
|
||||
interface DomainSettingsPageProps {
|
||||
params: Promise<{ domainId: string; orgId: string }>;
|
||||
@@ -65,12 +66,14 @@ export default async function DomainSettingsPage({
|
||||
)}
|
||||
</div>
|
||||
<div className="space-y-6">
|
||||
<DomainInfoCard
|
||||
failed={domain.failed}
|
||||
verified={domain.verified}
|
||||
type={domain.type}
|
||||
errorMessage={domain.errorMessage}
|
||||
/>
|
||||
{build != "oss" && env.flags.usePangolinDns ? (
|
||||
<DomainInfoCard
|
||||
failed={domain.failed}
|
||||
verified={domain.verified}
|
||||
type={domain.type}
|
||||
errorMessage={domain.errorMessage}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
<DNSRecordsTable records={dnsRecords} type={domain.type} />
|
||||
|
||||
|
||||
@@ -491,7 +491,7 @@ export default function ConnectionLogsPage() {
|
||||
);
|
||||
},
|
||||
cell: ({ row }) => {
|
||||
const clientType = row.original.clientType === "olm" ? "machine" : "user";
|
||||
const clientType = row.original.userId ? "user" : "machine";
|
||||
if (row.original.clientName && row.original.clientNiceId) {
|
||||
return (
|
||||
<Link
|
||||
|
||||
@@ -60,17 +60,17 @@ export default async function ClientResourcesPage(
|
||||
id: siteResource.siteResourceId,
|
||||
name: siteResource.name,
|
||||
orgId: params.orgId,
|
||||
siteName: siteResource.siteName,
|
||||
siteAddress: siteResource.siteAddress || null,
|
||||
siteNames: siteResource.siteNames,
|
||||
siteAddresses: siteResource.siteAddresses || null,
|
||||
mode: siteResource.mode || ("port" as any),
|
||||
// protocol: siteResource.protocol,
|
||||
// proxyPort: siteResource.proxyPort,
|
||||
siteId: siteResource.siteId,
|
||||
siteIds: siteResource.siteIds,
|
||||
destination: siteResource.destination,
|
||||
// destinationPort: siteResource.destinationPort,
|
||||
alias: siteResource.alias || null,
|
||||
aliasAddress: siteResource.aliasAddress || null,
|
||||
siteNiceId: siteResource.siteNiceId,
|
||||
siteNiceIds: siteResource.siteNiceIds,
|
||||
niceId: siteResource.niceId,
|
||||
tcpPortRangeString: siteResource.tcpPortRangeString || null,
|
||||
udpPortRangeString: siteResource.udpPortRangeString || null,
|
||||
|
||||
@@ -21,6 +21,7 @@ import {
|
||||
ArrowUp10Icon,
|
||||
ArrowUpDown,
|
||||
ArrowUpRight,
|
||||
ChevronDown,
|
||||
ChevronsUpDownIcon,
|
||||
MoreHorizontal
|
||||
} from "lucide-react";
|
||||
@@ -43,14 +44,14 @@ export type InternalResourceRow = {
|
||||
id: number;
|
||||
name: string;
|
||||
orgId: string;
|
||||
siteName: string;
|
||||
siteAddress: string | null;
|
||||
siteNames: string[];
|
||||
siteAddresses: (string | null)[];
|
||||
siteIds: number[];
|
||||
siteNiceIds: string[];
|
||||
// mode: "host" | "cidr" | "port";
|
||||
mode: "host" | "cidr";
|
||||
// protocol: string | null;
|
||||
// proxyPort: number | null;
|
||||
siteId: number;
|
||||
siteNiceId: string;
|
||||
destination: string;
|
||||
// destinationPort: number | null;
|
||||
alias: string | null;
|
||||
@@ -136,6 +137,60 @@ 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>[] = [
|
||||
{
|
||||
accessorKey: "name",
|
||||
@@ -185,21 +240,11 @@ export default function ClientResourcesTable({
|
||||
}
|
||||
},
|
||||
{
|
||||
accessorKey: "siteName",
|
||||
accessorKey: "siteNames",
|
||||
friendlyName: t("site"),
|
||||
header: () => <span className="p-3">{t("site")}</span>,
|
||||
cell: ({ row }) => {
|
||||
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>
|
||||
);
|
||||
return <SiteCell resourceRow={row.original} />;
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -399,7 +444,7 @@ export default function ClientResourcesTable({
|
||||
onConfirm={async () =>
|
||||
deleteInternalResource(
|
||||
selectedInternalResource!.id,
|
||||
selectedInternalResource!.siteId
|
||||
selectedInternalResource!.siteIds[0]
|
||||
)
|
||||
}
|
||||
string={selectedInternalResource.name}
|
||||
@@ -433,7 +478,11 @@ export default function ClientResourcesTable({
|
||||
<EditInternalResourceDialog
|
||||
open={isEditDialogOpen}
|
||||
setOpen={setIsEditDialogOpen}
|
||||
resource={editingResource}
|
||||
resource={{
|
||||
...editingResource,
|
||||
siteName: editingResource.siteNames[0] ?? "",
|
||||
siteId: editingResource.siteIds[0]
|
||||
}}
|
||||
orgId={orgId}
|
||||
sites={sites}
|
||||
onSuccess={() => {
|
||||
|
||||
@@ -154,7 +154,7 @@ export default function CreateDomainForm({
|
||||
|
||||
const punycodePreview = useMemo(() => {
|
||||
if (!baseDomain) return "";
|
||||
const punycode = toPunycode(baseDomain);
|
||||
const punycode = toPunycode(baseDomain.toLowerCase());
|
||||
return punycode !== baseDomain.toLowerCase() ? punycode : "";
|
||||
}, [baseDomain]);
|
||||
|
||||
@@ -239,21 +239,24 @@ export default function CreateDomainForm({
|
||||
className="space-y-4"
|
||||
id="create-domain-form"
|
||||
>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="type"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<StrategySelect
|
||||
options={domainOptions}
|
||||
defaultValue={field.value}
|
||||
onChange={field.onChange}
|
||||
cols={1}
|
||||
/>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
{build != "oss" && env.flags.usePangolinDns ? (
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="type"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<StrategySelect
|
||||
options={domainOptions}
|
||||
defaultValue={field.value}
|
||||
onChange={field.onChange}
|
||||
cols={1}
|
||||
/>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="baseDomain"
|
||||
|
||||
@@ -333,7 +333,8 @@ export default function PendingSitesTable({
|
||||
"jupiter",
|
||||
"saturn",
|
||||
"uranus",
|
||||
"neptune"
|
||||
"neptune",
|
||||
"pluto"
|
||||
].includes(originalRow.exitNodeName.toLowerCase());
|
||||
|
||||
if (isCloudNode) {
|
||||
|
||||
@@ -342,7 +342,8 @@ export default function SitesTable({
|
||||
"jupiter",
|
||||
"saturn",
|
||||
"uranus",
|
||||
"neptune"
|
||||
"neptune",
|
||||
"pluto"
|
||||
].includes(originalRow.exitNodeName.toLowerCase());
|
||||
|
||||
if (isCloudNode) {
|
||||
|
||||
Reference in New Issue
Block a user