Compare commits

..

27 Commits

Author SHA1 Message Date
Owen Schwartz
720db1a64d New translations en-us.json (Spanish) 2026-03-11 18:17:27 -07:00
Owen Schwartz
6ae907f8aa New translations en-us.json (Norwegian Bokmal) 2026-03-11 18:17:25 -07:00
Owen Schwartz
ef1041f3ce New translations en-us.json (Chinese Simplified) 2026-03-11 18:17:24 -07:00
Owen Schwartz
469132d487 New translations en-us.json (Turkish) 2026-03-11 18:17:23 -07:00
Owen Schwartz
798eb8aab4 New translations en-us.json (Russian) 2026-03-11 18:17:21 -07:00
Owen Schwartz
906ee8651a New translations en-us.json (Portuguese) 2026-03-11 18:17:20 -07:00
Owen Schwartz
2ad3fcc402 New translations en-us.json (Polish) 2026-03-11 18:17:18 -07:00
Owen Schwartz
9479df1627 New translations en-us.json (Dutch) 2026-03-11 18:17:17 -07:00
Owen Schwartz
e85ad5fedc New translations en-us.json (Korean) 2026-03-11 18:17:16 -07:00
Owen Schwartz
2975f99d48 New translations en-us.json (Italian) 2026-03-11 18:17:14 -07:00
Owen Schwartz
114a645323 New translations en-us.json (German) 2026-03-11 18:17:13 -07:00
Owen Schwartz
e118b29859 New translations en-us.json (Czech) 2026-03-11 18:17:11 -07:00
Owen Schwartz
f5ad78e04c New translations en-us.json (Bulgarian) 2026-03-11 18:17:10 -07:00
Owen Schwartz
1ee42633eb New translations en-us.json (French) 2026-03-11 18:17:08 -07:00
Owen Schwartz
e00704b9bb New translations en-us.json (Norwegian Bokmal) 2026-03-09 20:25:44 -07:00
Owen Schwartz
60111b1a0e New translations en-us.json (Chinese Simplified) 2026-03-09 20:25:43 -07:00
Owen Schwartz
b3c889dc2f New translations en-us.json (Turkish) 2026-03-09 20:25:41 -07:00
Owen Schwartz
59dac54a35 New translations en-us.json (Russian) 2026-03-09 20:25:40 -07:00
Owen Schwartz
e0fd58227f New translations en-us.json (Portuguese) 2026-03-09 20:25:37 -07:00
Owen Schwartz
88e3a89ddc New translations en-us.json (Polish) 2026-03-09 20:25:36 -07:00
Owen Schwartz
3825a35a6c New translations en-us.json (Dutch) 2026-03-09 20:25:35 -07:00
Owen Schwartz
71b74ea383 New translations en-us.json (Korean) 2026-03-09 20:25:33 -07:00
Owen Schwartz
ac1e1c127e New translations en-us.json (Italian) 2026-03-09 20:25:32 -07:00
Owen Schwartz
3496e270d0 New translations en-us.json (German) 2026-03-09 20:25:30 -07:00
Owen Schwartz
1bb0dc1cd7 New translations en-us.json (Czech) 2026-03-09 20:25:29 -07:00
Owen Schwartz
7c2b3bfb99 New translations en-us.json (Bulgarian) 2026-03-09 20:25:27 -07:00
Owen Schwartz
6238a19527 New translations en-us.json (French) 2026-03-09 20:25:26 -07:00
69 changed files with 3565 additions and 2761 deletions

View File

@@ -2342,8 +2342,8 @@
"logRetentionEndOfFollowingYear": "Край на следващата година", "logRetentionEndOfFollowingYear": "Край на следващата година",
"actionLogsDescription": "Прегледайте историята на действията, извършени в тази организация", "actionLogsDescription": "Прегледайте историята на действията, извършени в тази организация",
"accessLogsDescription": "Прегледайте заявките за удостоверяване на достъпа до ресурсите в тази организация", "accessLogsDescription": "Прегледайте заявките за удостоверяване на достъпа до ресурсите в тази организация",
"licenseRequiredToUse": "Изисква се лиценз за <enterpriseLicenseLink>Enterprise Edition</enterpriseLicenseLink>, за да използвате тази функция. Тази функция е също достъпна в <pangolinCloudLink>Pangolin Cloud</pangolinCloudLink>.", "licenseRequiredToUse": "An <enterpriseLicenseLink>Enterprise Edition</enterpriseLicenseLink> license or <pangolinCloudLink>Pangolin Cloud</pangolinCloudLink> is required to use this feature. <bookADemoLink>Book a demo or POC trial</bookADemoLink>.",
"ossEnterpriseEditionRequired": "Необходимо е <enterpriseEditionLink>изданието Enterprise</enterpriseEditionLink>, за да използвате тази функция. Тази функция е също достъпна в <pangolinCloudLink>Pangolin Cloud</pangolinCloudLink>.", "ossEnterpriseEditionRequired": "The <enterpriseEditionLink>Enterprise Edition</enterpriseEditionLink> is required to use this feature. This feature is also available in <pangolinCloudLink>Pangolin Cloud</pangolinCloudLink>. <bookADemoLink>Book a demo or POC trial</bookADemoLink>.",
"certResolver": "Решавач на сертификати", "certResolver": "Решавач на сертификати",
"certResolverDescription": "Изберете решавач на сертификати за използване за този ресурс.", "certResolverDescription": "Изберете решавач на сертификати за използване за този ресурс.",
"selectCertResolver": "Изберете решавач на сертификати", "selectCertResolver": "Изберете решавач на сертификати",
@@ -2680,5 +2680,6 @@
"approvalsEmptyStateStep2Title": "Активирайте одобрения на устройства", "approvalsEmptyStateStep2Title": "Активирайте одобрения на устройства",
"approvalsEmptyStateStep2Description": "Редактирайте ролята и активирайте опцията 'Изискване на одобрения за устройства'. Потребители с тази роля ще трябва администраторско одобрение за нови устройства.", "approvalsEmptyStateStep2Description": "Редактирайте ролята и активирайте опцията 'Изискване на одобрения за устройства'. Потребители с тази роля ще трябва администраторско одобрение за нови устройства.",
"approvalsEmptyStatePreviewDescription": "Преглед: Когато е активирано, чакащите заявки за устройства ще се появят тук за преглед", "approvalsEmptyStatePreviewDescription": "Преглед: Когато е активирано, чакащите заявки за устройства ще се появят тук за преглед",
"approvalsEmptyStateButtonText": "Управлявайте роли" "approvalsEmptyStateButtonText": "Управлявайте роли",
"domainErrorTitle": "We are having trouble verifying your domain"
} }

View File

@@ -2342,8 +2342,8 @@
"logRetentionEndOfFollowingYear": "Konec následujícího roku", "logRetentionEndOfFollowingYear": "Konec následujícího roku",
"actionLogsDescription": "Zobrazit historii akcí provedených v této organizaci", "actionLogsDescription": "Zobrazit historii akcí provedených v této organizaci",
"accessLogsDescription": "Zobrazit žádosti o ověření přístupu pro zdroje v této organizaci", "accessLogsDescription": "Zobrazit žádosti o ověření přístupu pro zdroje v této organizaci",
"licenseRequiredToUse": "Pro použití této funkce je vyžadována licence <enterpriseLicenseLink>Enterprise Edition</enterpriseLicenseLink> . Tato funkce je také dostupná v <pangolinCloudLink>Pangolin Cloud</pangolinCloudLink>.", "licenseRequiredToUse": "An <enterpriseLicenseLink>Enterprise Edition</enterpriseLicenseLink> license or <pangolinCloudLink>Pangolin Cloud</pangolinCloudLink> is required to use this feature. <bookADemoLink>Book a demo or POC trial</bookADemoLink>.",
"ossEnterpriseEditionRequired": "<enterpriseEditionLink>Enterprise Edition</enterpriseEditionLink> je vyžadována pro použití této funkce. Tato funkce je také k dispozici v <pangolinCloudLink>Pangolin Cloud</pangolinCloudLink>.", "ossEnterpriseEditionRequired": "The <enterpriseEditionLink>Enterprise Edition</enterpriseEditionLink> is required to use this feature. This feature is also available in <pangolinCloudLink>Pangolin Cloud</pangolinCloudLink>. <bookADemoLink>Book a demo or POC trial</bookADemoLink>.",
"certResolver": "Oddělovač certifikátů", "certResolver": "Oddělovač certifikátů",
"certResolverDescription": "Vyberte řešitele certifikátů pro tento dokument.", "certResolverDescription": "Vyberte řešitele certifikátů pro tento dokument.",
"selectCertResolver": "Vyberte řešič certifikátů", "selectCertResolver": "Vyberte řešič certifikátů",
@@ -2680,5 +2680,6 @@
"approvalsEmptyStateStep2Title": "Povolit schválení zařízení", "approvalsEmptyStateStep2Title": "Povolit schválení zařízení",
"approvalsEmptyStateStep2Description": "Upravte roli a povolte možnost 'Vyžadovat schválení zařízení'. Uživatelé s touto rolí budou potřebovat schválení pro nová zařízení správce.", "approvalsEmptyStateStep2Description": "Upravte roli a povolte možnost 'Vyžadovat schválení zařízení'. Uživatelé s touto rolí budou potřebovat schválení pro nová zařízení správce.",
"approvalsEmptyStatePreviewDescription": "Náhled: Pokud je povoleno, čekající na zařízení se zde zobrazí žádosti o recenzi", "approvalsEmptyStatePreviewDescription": "Náhled: Pokud je povoleno, čekající na zařízení se zde zobrazí žádosti o recenzi",
"approvalsEmptyStateButtonText": "Spravovat role" "approvalsEmptyStateButtonText": "Spravovat role",
"domainErrorTitle": "We are having trouble verifying your domain"
} }

View File

@@ -2342,8 +2342,8 @@
"logRetentionEndOfFollowingYear": "Ende des folgenden Jahres", "logRetentionEndOfFollowingYear": "Ende des folgenden Jahres",
"actionLogsDescription": "Verlauf der in dieser Organisation durchgeführten Aktionen anzeigen", "actionLogsDescription": "Verlauf der in dieser Organisation durchgeführten Aktionen anzeigen",
"accessLogsDescription": "Zugriffsauth-Anfragen für Ressourcen in dieser Organisation anzeigen", "accessLogsDescription": "Zugriffsauth-Anfragen für Ressourcen in dieser Organisation anzeigen",
"licenseRequiredToUse": "Um diese Funktion nutzen zu können, ist eine <enterpriseLicenseLink>Enterprise Edition</enterpriseLicenseLink> Lizenz erforderlich. Diese Funktion ist auch in der <pangolinCloudLink>Pangolin Cloud</pangolinCloudLink> verfügbar.", "licenseRequiredToUse": "An <enterpriseLicenseLink>Enterprise Edition</enterpriseLicenseLink> license or <pangolinCloudLink>Pangolin Cloud</pangolinCloudLink> is required to use this feature. <bookADemoLink>Book a demo or POC trial</bookADemoLink>.",
"ossEnterpriseEditionRequired": "Um diese Funktion nutzen zu können, ist die <enterpriseEditionLink>Enterprise Edition</enterpriseEditionLink> erforderlich. Diese Funktion ist auch in der <pangolinCloudLink>Pangolin Cloud</pangolinCloudLink> verfügbar.", "ossEnterpriseEditionRequired": "The <enterpriseEditionLink>Enterprise Edition</enterpriseEditionLink> is required to use this feature. This feature is also available in <pangolinCloudLink>Pangolin Cloud</pangolinCloudLink>. <bookADemoLink>Book a demo or POC trial</bookADemoLink>.",
"certResolver": "Zertifikatsauflöser", "certResolver": "Zertifikatsauflöser",
"certResolverDescription": "Wählen Sie den Zertifikatslöser aus, der für diese Ressource verwendet werden soll.", "certResolverDescription": "Wählen Sie den Zertifikatslöser aus, der für diese Ressource verwendet werden soll.",
"selectCertResolver": "Zertifikatsauflöser auswählen", "selectCertResolver": "Zertifikatsauflöser auswählen",
@@ -2680,5 +2680,6 @@
"approvalsEmptyStateStep2Title": "Gerätegenehmigungen aktivieren", "approvalsEmptyStateStep2Title": "Gerätegenehmigungen aktivieren",
"approvalsEmptyStateStep2Description": "Bearbeite eine Rolle und aktiviere die Option 'Gerätegenehmigung erforderlich'. Benutzer mit dieser Rolle benötigen Administrator-Genehmigung für neue Geräte.", "approvalsEmptyStateStep2Description": "Bearbeite eine Rolle und aktiviere die Option 'Gerätegenehmigung erforderlich'. Benutzer mit dieser Rolle benötigen Administrator-Genehmigung für neue Geräte.",
"approvalsEmptyStatePreviewDescription": "Vorschau: Wenn aktiviert, werden ausstehende Geräteanfragen hier zur Überprüfung angezeigt", "approvalsEmptyStatePreviewDescription": "Vorschau: Wenn aktiviert, werden ausstehende Geräteanfragen hier zur Überprüfung angezeigt",
"approvalsEmptyStateButtonText": "Rollen verwalten" "approvalsEmptyStateButtonText": "Rollen verwalten",
"domainErrorTitle": "We are having trouble verifying your domain"
} }

View File

@@ -2681,6 +2681,5 @@
"approvalsEmptyStateStep2Title": "Enable Device Approvals", "approvalsEmptyStateStep2Title": "Enable Device Approvals",
"approvalsEmptyStateStep2Description": "Edit a role and enable the 'Require Device Approvals' option. Users with this role will need admin approval for new devices.", "approvalsEmptyStateStep2Description": "Edit a role and enable the 'Require Device Approvals' option. Users with this role will need admin approval for new devices.",
"approvalsEmptyStatePreviewDescription": "Preview: When enabled, pending device requests will appear here for review", "approvalsEmptyStatePreviewDescription": "Preview: When enabled, pending device requests will appear here for review",
"approvalsEmptyStateButtonText": "Manage Roles", "approvalsEmptyStateButtonText": "Manage Roles"
"domainErrorTitle": "We are having trouble verifying your domain"
} }

View File

@@ -2342,8 +2342,8 @@
"logRetentionEndOfFollowingYear": "Fin del año siguiente", "logRetentionEndOfFollowingYear": "Fin del año siguiente",
"actionLogsDescription": "Ver un historial de acciones realizadas en esta organización", "actionLogsDescription": "Ver un historial de acciones realizadas en esta organización",
"accessLogsDescription": "Ver solicitudes de acceso a los recursos de esta organización", "accessLogsDescription": "Ver solicitudes de acceso a los recursos de esta organización",
"licenseRequiredToUse": "Se requiere una licencia <enterpriseLicenseLink>Enterprise Edition</enterpriseLicenseLink> para utilizar esta función. Esta característica también está disponible en <pangolinCloudLink>Pangolin Cloud</pangolinCloudLink>.", "licenseRequiredToUse": "An <enterpriseLicenseLink>Enterprise Edition</enterpriseLicenseLink> license or <pangolinCloudLink>Pangolin Cloud</pangolinCloudLink> is required to use this feature. <bookADemoLink>Book a demo or POC trial</bookADemoLink>.",
"ossEnterpriseEditionRequired": "La <enterpriseEditionLink>versión Enterprise</enterpriseEditionLink> es necesaria para utilizar esta función. Esta función también está disponible en <pangolinCloudLink>Pangolin Cloud</pangolinCloudLink>.", "ossEnterpriseEditionRequired": "The <enterpriseEditionLink>Enterprise Edition</enterpriseEditionLink> is required to use this feature. This feature is also available in <pangolinCloudLink>Pangolin Cloud</pangolinCloudLink>. <bookADemoLink>Book a demo or POC trial</bookADemoLink>.",
"certResolver": "Resolver certificado", "certResolver": "Resolver certificado",
"certResolverDescription": "Seleccione la resolución de certificados a utilizar para este recurso.", "certResolverDescription": "Seleccione la resolución de certificados a utilizar para este recurso.",
"selectCertResolver": "Seleccionar Resolver Certificado", "selectCertResolver": "Seleccionar Resolver Certificado",
@@ -2680,5 +2680,6 @@
"approvalsEmptyStateStep2Title": "Habilitar aprobaciones de dispositivo", "approvalsEmptyStateStep2Title": "Habilitar aprobaciones de dispositivo",
"approvalsEmptyStateStep2Description": "Editar un rol y habilitar la opción 'Requerir aprobaciones de dispositivos'. Los usuarios con este rol necesitarán la aprobación del administrador para nuevos dispositivos.", "approvalsEmptyStateStep2Description": "Editar un rol y habilitar la opción 'Requerir aprobaciones de dispositivos'. Los usuarios con este rol necesitarán la aprobación del administrador para nuevos dispositivos.",
"approvalsEmptyStatePreviewDescription": "Vista previa: Cuando está habilitado, las solicitudes de dispositivo pendientes aparecerán aquí para su revisión", "approvalsEmptyStatePreviewDescription": "Vista previa: Cuando está habilitado, las solicitudes de dispositivo pendientes aparecerán aquí para su revisión",
"approvalsEmptyStateButtonText": "Administrar roles" "approvalsEmptyStateButtonText": "Administrar roles",
"domainErrorTitle": "We are having trouble verifying your domain"
} }

View File

@@ -2342,8 +2342,8 @@
"logRetentionEndOfFollowingYear": "Fin de l'année suivante", "logRetentionEndOfFollowingYear": "Fin de l'année suivante",
"actionLogsDescription": "Voir l'historique des actions effectuées dans cette organisation", "actionLogsDescription": "Voir l'historique des actions effectuées dans cette organisation",
"accessLogsDescription": "Voir les demandes d'authentification d'accès aux ressources de cette organisation", "accessLogsDescription": "Voir les demandes d'authentification d'accès aux ressources de cette organisation",
"licenseRequiredToUse": "Une licence <enterpriseLicenseLink>Enterprise Edition</enterpriseLicenseLink> est nécessaire pour utiliser cette fonctionnalité. Cette fonctionnalité est également disponible dans <pangolinCloudLink>Pangolin Cloud</pangolinCloudLink>.", "licenseRequiredToUse": "An <enterpriseLicenseLink>Enterprise Edition</enterpriseLicenseLink> license or <pangolinCloudLink>Pangolin Cloud</pangolinCloudLink> is required to use this feature. <bookADemoLink>Book a demo or POC trial</bookADemoLink>.",
"ossEnterpriseEditionRequired": "La version <enterpriseEditionLink>Enterprise Edition</enterpriseEditionLink> est requise pour utiliser cette fonctionnalité. Cette fonctionnalité est également disponible dans <pangolinCloudLink>Pangolin Cloud</pangolinCloudLink>.", "ossEnterpriseEditionRequired": "The <enterpriseEditionLink>Enterprise Edition</enterpriseEditionLink> is required to use this feature. This feature is also available in <pangolinCloudLink>Pangolin Cloud</pangolinCloudLink>. <bookADemoLink>Book a demo or POC trial</bookADemoLink>.",
"certResolver": "Résolveur de certificat", "certResolver": "Résolveur de certificat",
"certResolverDescription": "Sélectionnez le solveur de certificat à utiliser pour cette ressource.", "certResolverDescription": "Sélectionnez le solveur de certificat à utiliser pour cette ressource.",
"selectCertResolver": "Sélectionnez le résolveur de certificat", "selectCertResolver": "Sélectionnez le résolveur de certificat",
@@ -2680,5 +2680,6 @@
"approvalsEmptyStateStep2Title": "Activer les autorisations de l'appareil", "approvalsEmptyStateStep2Title": "Activer les autorisations de l'appareil",
"approvalsEmptyStateStep2Description": "Modifier un rôle et activer l'option 'Exiger les autorisations de l'appareil'. Les utilisateurs avec ce rôle auront besoin de l'approbation de l'administrateur pour les nouveaux appareils.", "approvalsEmptyStateStep2Description": "Modifier un rôle et activer l'option 'Exiger les autorisations de l'appareil'. Les utilisateurs avec ce rôle auront besoin de l'approbation de l'administrateur pour les nouveaux appareils.",
"approvalsEmptyStatePreviewDescription": "Aperçu: Lorsque cette option est activée, les demandes de périphérique en attente apparaîtront ici pour vérification", "approvalsEmptyStatePreviewDescription": "Aperçu: Lorsque cette option est activée, les demandes de périphérique en attente apparaîtront ici pour vérification",
"approvalsEmptyStateButtonText": "Gérer les rôles" "approvalsEmptyStateButtonText": "Gérer les rôles",
"domainErrorTitle": "We are having trouble verifying your domain"
} }

View File

@@ -2342,8 +2342,8 @@
"logRetentionEndOfFollowingYear": "Fine dell'anno successivo", "logRetentionEndOfFollowingYear": "Fine dell'anno successivo",
"actionLogsDescription": "Visualizza una cronologia delle azioni eseguite in questa organizzazione", "actionLogsDescription": "Visualizza una cronologia delle azioni eseguite in questa organizzazione",
"accessLogsDescription": "Visualizza le richieste di autenticazione di accesso per le risorse in questa organizzazione", "accessLogsDescription": "Visualizza le richieste di autenticazione di accesso per le risorse in questa organizzazione",
"licenseRequiredToUse": "Per utilizzare questa funzione è necessaria una licenza <enterpriseLicenseLink>Enterprise Edition</enterpriseLicenseLink> . Questa funzionalità è disponibile anche in <pangolinCloudLink>Pangolin Cloud</pangolinCloudLink>.", "licenseRequiredToUse": "An <enterpriseLicenseLink>Enterprise Edition</enterpriseLicenseLink> license or <pangolinCloudLink>Pangolin Cloud</pangolinCloudLink> is required to use this feature. <bookADemoLink>Book a demo or POC trial</bookADemoLink>.",
"ossEnterpriseEditionRequired": "L' <enterpriseEditionLink>Enterprise Edition</enterpriseEditionLink> è necessaria per utilizzare questa funzione. Questa funzionalità è disponibile anche in <pangolinCloudLink>Pangolin Cloud</pangolinCloudLink>.", "ossEnterpriseEditionRequired": "The <enterpriseEditionLink>Enterprise Edition</enterpriseEditionLink> is required to use this feature. This feature is also available in <pangolinCloudLink>Pangolin Cloud</pangolinCloudLink>. <bookADemoLink>Book a demo or POC trial</bookADemoLink>.",
"certResolver": "Risolutore Di Certificato", "certResolver": "Risolutore Di Certificato",
"certResolverDescription": "Selezionare il risolutore di certificati da usare per questa risorsa.", "certResolverDescription": "Selezionare il risolutore di certificati da usare per questa risorsa.",
"selectCertResolver": "Seleziona Risolutore Di Certificato", "selectCertResolver": "Seleziona Risolutore Di Certificato",
@@ -2680,5 +2680,6 @@
"approvalsEmptyStateStep2Title": "Abilita Approvazioni Dispositivo", "approvalsEmptyStateStep2Title": "Abilita Approvazioni Dispositivo",
"approvalsEmptyStateStep2Description": "Modifica un ruolo e abilita l'opzione 'Richiedi l'approvazione del dispositivo'. Gli utenti con questo ruolo avranno bisogno dell'approvazione dell'amministratore per i nuovi dispositivi.", "approvalsEmptyStateStep2Description": "Modifica un ruolo e abilita l'opzione 'Richiedi l'approvazione del dispositivo'. Gli utenti con questo ruolo avranno bisogno dell'approvazione dell'amministratore per i nuovi dispositivi.",
"approvalsEmptyStatePreviewDescription": "Anteprima: quando abilitato, le richieste di dispositivo in attesa appariranno qui per la revisione", "approvalsEmptyStatePreviewDescription": "Anteprima: quando abilitato, le richieste di dispositivo in attesa appariranno qui per la revisione",
"approvalsEmptyStateButtonText": "Gestisci Ruoli" "approvalsEmptyStateButtonText": "Gestisci Ruoli",
"domainErrorTitle": "We are having trouble verifying your domain"
} }

View File

@@ -2342,8 +2342,8 @@
"logRetentionEndOfFollowingYear": "다음 연도 말", "logRetentionEndOfFollowingYear": "다음 연도 말",
"actionLogsDescription": "이 조직에서 수행된 작업의 기록을 봅니다", "actionLogsDescription": "이 조직에서 수행된 작업의 기록을 봅니다",
"accessLogsDescription": "이 조직의 자원에 대한 접근 인증 요청을 확인합니다", "accessLogsDescription": "이 조직의 자원에 대한 접근 인증 요청을 확인합니다",
"licenseRequiredToUse": "이 기능을 사용하려면 <enterpriseLicenseLink>엔터프라이즈 에디션</enterpriseLicenseLink> 라이선스가 필요합니다. 이 기능은 <pangolinCloudLink>판골린 클라우드</pangolinCloudLink>에서도 사용할 수 있습니다.", "licenseRequiredToUse": "An <enterpriseLicenseLink>Enterprise Edition</enterpriseLicenseLink> license or <pangolinCloudLink>Pangolin Cloud</pangolinCloudLink> is required to use this feature. <bookADemoLink>Book a demo or POC trial</bookADemoLink>.",
"ossEnterpriseEditionRequired": "이 기능을 사용하려면 <enterpriseEditionLink>엔터프라이즈 에디션</enterpriseEditionLink>이 필요합니다. 이 기능은 <pangolinCloudLink>판골린 클라우드</pangolinCloudLink>에서도 사용할 수 있습니다.", "ossEnterpriseEditionRequired": "The <enterpriseEditionLink>Enterprise Edition</enterpriseEditionLink> is required to use this feature. This feature is also available in <pangolinCloudLink>Pangolin Cloud</pangolinCloudLink>. <bookADemoLink>Book a demo or POC trial</bookADemoLink>.",
"certResolver": "인증서 해결사", "certResolver": "인증서 해결사",
"certResolverDescription": "이 리소스에 사용할 인증서 해결사를 선택하세요.", "certResolverDescription": "이 리소스에 사용할 인증서 해결사를 선택하세요.",
"selectCertResolver": "인증서 해결사 선택", "selectCertResolver": "인증서 해결사 선택",
@@ -2680,5 +2680,6 @@
"approvalsEmptyStateStep2Title": "장치 승인 활성화", "approvalsEmptyStateStep2Title": "장치 승인 활성화",
"approvalsEmptyStateStep2Description": "역할을 편집하고 '장치 승인 요구' 옵션을 활성화하세요. 이 역할을 가진 사용자는 새 장치에 대해 관리자의 승인이 필요합니다.", "approvalsEmptyStateStep2Description": "역할을 편집하고 '장치 승인 요구' 옵션을 활성화하세요. 이 역할을 가진 사용자는 새 장치에 대해 관리자의 승인이 필요합니다.",
"approvalsEmptyStatePreviewDescription": "미리 보기: 활성화된 경우, 승인 대기 중인 장치 요청이 검토용으로 여기에 표시됩니다.", "approvalsEmptyStatePreviewDescription": "미리 보기: 활성화된 경우, 승인 대기 중인 장치 요청이 검토용으로 여기에 표시됩니다.",
"approvalsEmptyStateButtonText": "역할 관리" "approvalsEmptyStateButtonText": "역할 관리",
"domainErrorTitle": "We are having trouble verifying your domain"
} }

View File

@@ -2342,8 +2342,8 @@
"logRetentionEndOfFollowingYear": "Slutt på neste år", "logRetentionEndOfFollowingYear": "Slutt på neste år",
"actionLogsDescription": "Vis historikk for handlinger som er utført i denne organisasjonen", "actionLogsDescription": "Vis historikk for handlinger som er utført i denne organisasjonen",
"accessLogsDescription": "Vis autoriseringsforespørsler for ressurser i denne organisasjonen", "accessLogsDescription": "Vis autoriseringsforespørsler for ressurser i denne organisasjonen",
"licenseRequiredToUse": "En <enterpriseLicenseLink>Enterprise Edition</enterpriseLicenseLink> lisens er påkrevd for å bruke denne funksjonen. Denne funksjonen er også tilgjengelig i <pangolinCloudLink>Pangolin Cloud</pangolinCloudLink>.", "licenseRequiredToUse": "An <enterpriseLicenseLink>Enterprise Edition</enterpriseLicenseLink> license or <pangolinCloudLink>Pangolin Cloud</pangolinCloudLink> is required to use this feature. <bookADemoLink>Book a demo or POC trial</bookADemoLink>.",
"ossEnterpriseEditionRequired": "<enterpriseEditionLink>Enterprise Edition</enterpriseEditionLink> er nødvendig for å bruke denne funksjonen. Denne funksjonen er også tilgjengelig i <pangolinCloudLink>Pangolin Cloud</pangolinCloudLink>.", "ossEnterpriseEditionRequired": "The <enterpriseEditionLink>Enterprise Edition</enterpriseEditionLink> is required to use this feature. This feature is also available in <pangolinCloudLink>Pangolin Cloud</pangolinCloudLink>. <bookADemoLink>Book a demo or POC trial</bookADemoLink>.",
"certResolver": "Sertifikat løser", "certResolver": "Sertifikat løser",
"certResolverDescription": "Velg sertifikatløser som skal brukes for denne ressursen.", "certResolverDescription": "Velg sertifikatløser som skal brukes for denne ressursen.",
"selectCertResolver": "Velg sertifikatløser", "selectCertResolver": "Velg sertifikatløser",
@@ -2680,5 +2680,6 @@
"approvalsEmptyStateStep2Title": "Aktiver enhetsgodkjenninger", "approvalsEmptyStateStep2Title": "Aktiver enhetsgodkjenninger",
"approvalsEmptyStateStep2Description": "Rediger en rolle og aktiver alternativet 'Kreve enhetsgodkjenninger'. Brukere med denne rollen vil trenge administratorgodkjenning for nye enheter.", "approvalsEmptyStateStep2Description": "Rediger en rolle og aktiver alternativet 'Kreve enhetsgodkjenninger'. Brukere med denne rollen vil trenge administratorgodkjenning for nye enheter.",
"approvalsEmptyStatePreviewDescription": "Forhåndsvisning: Når aktivert, ventende enhets forespørsler vil vises her for vurdering", "approvalsEmptyStatePreviewDescription": "Forhåndsvisning: Når aktivert, ventende enhets forespørsler vil vises her for vurdering",
"approvalsEmptyStateButtonText": "Administrer Roller" "approvalsEmptyStateButtonText": "Administrer Roller",
"domainErrorTitle": "We are having trouble verifying your domain"
} }

View File

@@ -2342,8 +2342,8 @@
"logRetentionEndOfFollowingYear": "Einde van volgend jaar", "logRetentionEndOfFollowingYear": "Einde van volgend jaar",
"actionLogsDescription": "Bekijk een geschiedenis van acties die worden uitgevoerd in deze organisatie", "actionLogsDescription": "Bekijk een geschiedenis van acties die worden uitgevoerd in deze organisatie",
"accessLogsDescription": "Toegangsverificatieverzoeken voor resources in deze organisatie bekijken", "accessLogsDescription": "Toegangsverificatieverzoeken voor resources in deze organisatie bekijken",
"licenseRequiredToUse": "Een <enterpriseLicenseLink>Enterprise Edition</enterpriseLicenseLink> licentie is vereist om deze functie te gebruiken. Deze functie is ook beschikbaar in <pangolinCloudLink>Pangolin Cloud</pangolinCloudLink>.", "licenseRequiredToUse": "An <enterpriseLicenseLink>Enterprise Edition</enterpriseLicenseLink> license or <pangolinCloudLink>Pangolin Cloud</pangolinCloudLink> is required to use this feature. <bookADemoLink>Book a demo or POC trial</bookADemoLink>.",
"ossEnterpriseEditionRequired": "De <enterpriseEditionLink>Enterprise Edition</enterpriseEditionLink> is vereist om deze functie te gebruiken. Deze functie is ook beschikbaar in <pangolinCloudLink>Pangolin Cloud</pangolinCloudLink>.", "ossEnterpriseEditionRequired": "The <enterpriseEditionLink>Enterprise Edition</enterpriseEditionLink> is required to use this feature. This feature is also available in <pangolinCloudLink>Pangolin Cloud</pangolinCloudLink>. <bookADemoLink>Book a demo or POC trial</bookADemoLink>.",
"certResolver": "Certificaat Resolver", "certResolver": "Certificaat Resolver",
"certResolverDescription": "Selecteer de certificaat resolver die moet worden gebruikt voor deze resource.", "certResolverDescription": "Selecteer de certificaat resolver die moet worden gebruikt voor deze resource.",
"selectCertResolver": "Certificaat Resolver selecteren", "selectCertResolver": "Certificaat Resolver selecteren",
@@ -2680,5 +2680,6 @@
"approvalsEmptyStateStep2Title": "Toestel goedkeuringen inschakelen", "approvalsEmptyStateStep2Title": "Toestel goedkeuringen inschakelen",
"approvalsEmptyStateStep2Description": "Bewerk een rol en schakel de optie 'Vereist Apparaat Goedkeuringen' in. Gebruikers met deze rol hebben admin goedkeuring nodig voor nieuwe apparaten.", "approvalsEmptyStateStep2Description": "Bewerk een rol en schakel de optie 'Vereist Apparaat Goedkeuringen' in. Gebruikers met deze rol hebben admin goedkeuring nodig voor nieuwe apparaten.",
"approvalsEmptyStatePreviewDescription": "Voorbeeld: Indien ingeschakeld, zullen in afwachting van apparaatverzoeken hier verschijnen om te beoordelen", "approvalsEmptyStatePreviewDescription": "Voorbeeld: Indien ingeschakeld, zullen in afwachting van apparaatverzoeken hier verschijnen om te beoordelen",
"approvalsEmptyStateButtonText": "Rollen beheren" "approvalsEmptyStateButtonText": "Rollen beheren",
"domainErrorTitle": "We are having trouble verifying your domain"
} }

View File

@@ -2342,8 +2342,8 @@
"logRetentionEndOfFollowingYear": "Koniec następnego roku", "logRetentionEndOfFollowingYear": "Koniec następnego roku",
"actionLogsDescription": "Zobacz historię działań wykonywanych w tej organizacji", "actionLogsDescription": "Zobacz historię działań wykonywanych w tej organizacji",
"accessLogsDescription": "Wyświetl prośby o autoryzację dostępu do zasobów w tej organizacji", "accessLogsDescription": "Wyświetl prośby o autoryzację dostępu do zasobów w tej organizacji",
"licenseRequiredToUse": "Do korzystania z tej funkcji wymagana jest licencja <enterpriseLicenseLink>Enterprise Edition</enterpriseLicenseLink> . Ta funkcja jest również dostępna w <pangolinCloudLink>Pangolin Cloud</pangolinCloudLink>.", "licenseRequiredToUse": "An <enterpriseLicenseLink>Enterprise Edition</enterpriseLicenseLink> license or <pangolinCloudLink>Pangolin Cloud</pangolinCloudLink> is required to use this feature. <bookADemoLink>Book a demo or POC trial</bookADemoLink>.",
"ossEnterpriseEditionRequired": "<enterpriseEditionLink>Enterprise Edition</enterpriseEditionLink> jest wymagany do korzystania z tej funkcji. Ta funkcja jest również dostępna w <pangolinCloudLink>Pangolin Cloud</pangolinCloudLink>.", "ossEnterpriseEditionRequired": "The <enterpriseEditionLink>Enterprise Edition</enterpriseEditionLink> is required to use this feature. This feature is also available in <pangolinCloudLink>Pangolin Cloud</pangolinCloudLink>. <bookADemoLink>Book a demo or POC trial</bookADemoLink>.",
"certResolver": "Rozwiązywanie certyfikatów", "certResolver": "Rozwiązywanie certyfikatów",
"certResolverDescription": "Wybierz resolver certyfikatów do użycia dla tego zasobu.", "certResolverDescription": "Wybierz resolver certyfikatów do użycia dla tego zasobu.",
"selectCertResolver": "Wybierz Resolver certyfikatów", "selectCertResolver": "Wybierz Resolver certyfikatów",
@@ -2680,5 +2680,6 @@
"approvalsEmptyStateStep2Title": "Włącz zatwierdzanie urządzenia", "approvalsEmptyStateStep2Title": "Włącz zatwierdzanie urządzenia",
"approvalsEmptyStateStep2Description": "Edytuj rolę i włącz opcję \"Wymagaj zatwierdzenia urządzenia\". Użytkownicy z tą rolą będą potrzebowali zatwierdzenia administratora dla nowych urządzeń.", "approvalsEmptyStateStep2Description": "Edytuj rolę i włącz opcję \"Wymagaj zatwierdzenia urządzenia\". Użytkownicy z tą rolą będą potrzebowali zatwierdzenia administratora dla nowych urządzeń.",
"approvalsEmptyStatePreviewDescription": "Podgląd: Gdy włączone, oczekujące prośby o sprawdzenie pojawią się tutaj", "approvalsEmptyStatePreviewDescription": "Podgląd: Gdy włączone, oczekujące prośby o sprawdzenie pojawią się tutaj",
"approvalsEmptyStateButtonText": "Zarządzaj rolami" "approvalsEmptyStateButtonText": "Zarządzaj rolami",
"domainErrorTitle": "We are having trouble verifying your domain"
} }

View File

@@ -2342,8 +2342,8 @@
"logRetentionEndOfFollowingYear": "Fim do ano seguinte", "logRetentionEndOfFollowingYear": "Fim do ano seguinte",
"actionLogsDescription": "Visualizar histórico de ações realizadas nesta organização", "actionLogsDescription": "Visualizar histórico de ações realizadas nesta organização",
"accessLogsDescription": "Ver solicitações de autenticação de recursos nesta organização", "accessLogsDescription": "Ver solicitações de autenticação de recursos nesta organização",
"licenseRequiredToUse": "Uma licença <enterpriseLicenseLink>Enterprise Edition</enterpriseLicenseLink> é necessária para usar este recurso. Este recurso também está disponível no <pangolinCloudLink>Pangolin Cloud</pangolinCloudLink>.", "licenseRequiredToUse": "An <enterpriseLicenseLink>Enterprise Edition</enterpriseLicenseLink> license or <pangolinCloudLink>Pangolin Cloud</pangolinCloudLink> is required to use this feature. <bookADemoLink>Book a demo or POC trial</bookADemoLink>.",
"ossEnterpriseEditionRequired": "O <enterpriseEditionLink>Enterprise Edition</enterpriseEditionLink> é necessário para usar este recurso. Este recurso também está disponível no <pangolinCloudLink>Pangolin Cloud</pangolinCloudLink>.", "ossEnterpriseEditionRequired": "The <enterpriseEditionLink>Enterprise Edition</enterpriseEditionLink> is required to use this feature. This feature is also available in <pangolinCloudLink>Pangolin Cloud</pangolinCloudLink>. <bookADemoLink>Book a demo or POC trial</bookADemoLink>.",
"certResolver": "Resolvedor de Certificado", "certResolver": "Resolvedor de Certificado",
"certResolverDescription": "Selecione o resolvedor de certificados para este recurso.", "certResolverDescription": "Selecione o resolvedor de certificados para este recurso.",
"selectCertResolver": "Selecionar solucionador de certificado", "selectCertResolver": "Selecionar solucionador de certificado",
@@ -2680,5 +2680,6 @@
"approvalsEmptyStateStep2Title": "Habilitar Aprovações do Dispositivo", "approvalsEmptyStateStep2Title": "Habilitar Aprovações do Dispositivo",
"approvalsEmptyStateStep2Description": "Editar uma função e habilitar a opção 'Exigir aprovação de dispositivos'. Usuários com essa função precisarão de aprovação de administrador para novos dispositivos.", "approvalsEmptyStateStep2Description": "Editar uma função e habilitar a opção 'Exigir aprovação de dispositivos'. Usuários com essa função precisarão de aprovação de administrador para novos dispositivos.",
"approvalsEmptyStatePreviewDescription": "Pré-visualização: Quando ativado, solicitações de dispositivo pendentes aparecerão aqui para revisão", "approvalsEmptyStatePreviewDescription": "Pré-visualização: Quando ativado, solicitações de dispositivo pendentes aparecerão aqui para revisão",
"approvalsEmptyStateButtonText": "Gerir Funções" "approvalsEmptyStateButtonText": "Gerir Funções",
"domainErrorTitle": "We are having trouble verifying your domain"
} }

View File

@@ -2342,8 +2342,8 @@
"logRetentionEndOfFollowingYear": "Конец следующего года", "logRetentionEndOfFollowingYear": "Конец следующего года",
"actionLogsDescription": "Просмотр истории действий, выполненных в этой организации", "actionLogsDescription": "Просмотр истории действий, выполненных в этой организации",
"accessLogsDescription": "Просмотр запросов авторизации доступа к ресурсам этой организации", "accessLogsDescription": "Просмотр запросов авторизации доступа к ресурсам этой организации",
"licenseRequiredToUse": "Лицензия на <enterpriseLicenseLink>Enterprise Edition</enterpriseLicenseLink> требуется для использования этой функции. Эта функция также доступна в <pangolinCloudLink>Pangolin Cloud</pangolinCloudLink>.", "licenseRequiredToUse": "An <enterpriseLicenseLink>Enterprise Edition</enterpriseLicenseLink> license or <pangolinCloudLink>Pangolin Cloud</pangolinCloudLink> is required to use this feature. <bookADemoLink>Book a demo or POC trial</bookADemoLink>.",
"ossEnterpriseEditionRequired": "Для использования этой функции требуется <enterpriseEditionLink>Enterprise Edition</enterpriseEditionLink>. Эта функция также доступна в <pangolinCloudLink>Pangolin Cloud</pangolinCloudLink>.", "ossEnterpriseEditionRequired": "The <enterpriseEditionLink>Enterprise Edition</enterpriseEditionLink> is required to use this feature. This feature is also available in <pangolinCloudLink>Pangolin Cloud</pangolinCloudLink>. <bookADemoLink>Book a demo or POC trial</bookADemoLink>.",
"certResolver": "Резольвер сертификата", "certResolver": "Резольвер сертификата",
"certResolverDescription": "Выберите резолвер сертификата, который будет использоваться для этого ресурса.", "certResolverDescription": "Выберите резолвер сертификата, который будет использоваться для этого ресурса.",
"selectCertResolver": "Выберите резолвер сертификата", "selectCertResolver": "Выберите резолвер сертификата",
@@ -2680,5 +2680,6 @@
"approvalsEmptyStateStep2Title": "Включить утверждения устройства", "approvalsEmptyStateStep2Title": "Включить утверждения устройства",
"approvalsEmptyStateStep2Description": "Редактировать роль и включить опцию 'Требовать утверждения устройств'. Пользователям с этой ролью потребуется подтверждение администратора для новых устройств.", "approvalsEmptyStateStep2Description": "Редактировать роль и включить опцию 'Требовать утверждения устройств'. Пользователям с этой ролью потребуется подтверждение администратора для новых устройств.",
"approvalsEmptyStatePreviewDescription": "Предпросмотр: Если включено, ожидающие запросы на устройство появятся здесь для проверки", "approvalsEmptyStatePreviewDescription": "Предпросмотр: Если включено, ожидающие запросы на устройство появятся здесь для проверки",
"approvalsEmptyStateButtonText": "Управление ролями" "approvalsEmptyStateButtonText": "Управление ролями",
"domainErrorTitle": "We are having trouble verifying your domain"
} }

View File

@@ -2342,8 +2342,8 @@
"logRetentionEndOfFollowingYear": "Bir sonraki yılın sonu", "logRetentionEndOfFollowingYear": "Bir sonraki yılın sonu",
"actionLogsDescription": "Bu organizasyondaki eylemler geçmişini görüntüleyin", "actionLogsDescription": "Bu organizasyondaki eylemler geçmişini görüntüleyin",
"accessLogsDescription": "Bu organizasyondaki kaynaklar için erişim kimlik doğrulama isteklerini görüntüleyin", "accessLogsDescription": "Bu organizasyondaki kaynaklar için erişim kimlik doğrulama isteklerini görüntüleyin",
"licenseRequiredToUse": "Bu özelliği kullanmak için bir <enterpriseLicenseLink>Enterprise Edition</enterpriseLicenseLink> lisansı gereklidir. Bu özellik ayrıca <pangolinCloudLink>Pangolin Cloud</pangolinCloudLink>'da da mevcuttur.", "licenseRequiredToUse": "An <enterpriseLicenseLink>Enterprise Edition</enterpriseLicenseLink> license or <pangolinCloudLink>Pangolin Cloud</pangolinCloudLink> is required to use this feature. <bookADemoLink>Book a demo or POC trial</bookADemoLink>.",
"ossEnterpriseEditionRequired": "Bu özelliği kullanmak için <enterpriseEditionLink>Enterprise Edition</enterpriseEditionLink> gereklidir. Bu özellik ayrıca <pangolinCloudLink>Pangolin Cloud</pangolinCloudLink>'da da mevcuttur.", "ossEnterpriseEditionRequired": "The <enterpriseEditionLink>Enterprise Edition</enterpriseEditionLink> is required to use this feature. This feature is also available in <pangolinCloudLink>Pangolin Cloud</pangolinCloudLink>. <bookADemoLink>Book a demo or POC trial</bookADemoLink>.",
"certResolver": "Sertifika Çözücü", "certResolver": "Sertifika Çözücü",
"certResolverDescription": "Bu kaynak için kullanılacak sertifika çözücüsünü seçin.", "certResolverDescription": "Bu kaynak için kullanılacak sertifika çözücüsünü seçin.",
"selectCertResolver": "Sertifika Çözücü Seçin", "selectCertResolver": "Sertifika Çözücü Seçin",
@@ -2680,5 +2680,6 @@
"approvalsEmptyStateStep2Title": "Cihaz Onaylarını Etkinleştir", "approvalsEmptyStateStep2Title": "Cihaz Onaylarını Etkinleştir",
"approvalsEmptyStateStep2Description": "Bir rolü düzenleyin ve 'Cihaz Onaylarını Gerektir' seçeneğini etkinleştirin. Bu role sahip kullanıcıların yeni cihazlar için yönetici onayına ihtiyacı olacaktır.", "approvalsEmptyStateStep2Description": "Bir rolü düzenleyin ve 'Cihaz Onaylarını Gerektir' seçeneğini etkinleştirin. Bu role sahip kullanıcıların yeni cihazlar için yönetici onayına ihtiyacı olacaktır.",
"approvalsEmptyStatePreviewDescription": "Önizleme: Etkinleştirildiğinde, bekleyen cihaz talepleri incelenmek üzere burada görünecektir.", "approvalsEmptyStatePreviewDescription": "Önizleme: Etkinleştirildiğinde, bekleyen cihaz talepleri incelenmek üzere burada görünecektir.",
"approvalsEmptyStateButtonText": "Rolleri Yönet" "approvalsEmptyStateButtonText": "Rolleri Yönet",
"domainErrorTitle": "We are having trouble verifying your domain"
} }

View File

@@ -2342,8 +2342,8 @@
"logRetentionEndOfFollowingYear": "下一年结束", "logRetentionEndOfFollowingYear": "下一年结束",
"actionLogsDescription": "查看此机构执行的操作历史", "actionLogsDescription": "查看此机构执行的操作历史",
"accessLogsDescription": "查看此机构资源的访问认证请求", "accessLogsDescription": "查看此机构资源的访问认证请求",
"licenseRequiredToUse": "需要 <enterpriseLicenseLink>Enterprise Edition</enterpriseLicenseLink> 许可才能使用此功能。此功能也可在 <pangolinCloudLink>Pangolin Cloud</pangolinCloudLink> 中使用。", "licenseRequiredToUse": "An <enterpriseLicenseLink>Enterprise Edition</enterpriseLicenseLink> license or <pangolinCloudLink>Pangolin Cloud</pangolinCloudLink> is required to use this feature. <bookADemoLink>Book a demo or POC trial</bookADemoLink>.",
"ossEnterpriseEditionRequired": "<enterpriseEditionLink>Enterprise Edition</enterpriseEditionLink> 需要使用此功能。此功能也可在 <pangolinCloudLink>Pangolin Cloud</pangolinCloudLink> 中使用。", "ossEnterpriseEditionRequired": "The <enterpriseEditionLink>Enterprise Edition</enterpriseEditionLink> is required to use this feature. This feature is also available in <pangolinCloudLink>Pangolin Cloud</pangolinCloudLink>. <bookADemoLink>Book a demo or POC trial</bookADemoLink>.",
"certResolver": "证书解决器", "certResolver": "证书解决器",
"certResolverDescription": "选择用于此资源的证书解析器。", "certResolverDescription": "选择用于此资源的证书解析器。",
"selectCertResolver": "选择证书解析", "selectCertResolver": "选择证书解析",
@@ -2680,5 +2680,6 @@
"approvalsEmptyStateStep2Title": "启用设备批准", "approvalsEmptyStateStep2Title": "启用设备批准",
"approvalsEmptyStateStep2Description": "编辑角色并启用“需要设备审批”选项。具有此角色的用户需要管理员批准新设备。", "approvalsEmptyStateStep2Description": "编辑角色并启用“需要设备审批”选项。具有此角色的用户需要管理员批准新设备。",
"approvalsEmptyStatePreviewDescription": "预览:如果启用,待处理设备请求将出现在这里供审核", "approvalsEmptyStatePreviewDescription": "预览:如果启用,待处理设备请求将出现在这里供审核",
"approvalsEmptyStateButtonText": "管理角色" "approvalsEmptyStateButtonText": "管理角色",
"domainErrorTitle": "We are having trouble verifying your domain"
} }

3948
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -33,7 +33,7 @@
}, },
"dependencies": { "dependencies": {
"@asteasolutions/zod-to-openapi": "8.4.1", "@asteasolutions/zod-to-openapi": "8.4.1",
"@aws-sdk/client-s3": "3.1004.0", "@aws-sdk/client-s3": "3.989.0",
"@faker-js/faker": "10.3.0", "@faker-js/faker": "10.3.0",
"@headlessui/react": "2.2.9", "@headlessui/react": "2.2.9",
"@hookform/resolvers": "5.2.2", "@hookform/resolvers": "5.2.2",
@@ -80,16 +80,16 @@
"d3": "7.9.0", "d3": "7.9.0",
"drizzle-orm": "0.45.1", "drizzle-orm": "0.45.1",
"express": "5.2.1", "express": "5.2.1",
"express-rate-limit": "8.3.0", "express-rate-limit": "8.2.1",
"glob": "13.0.6", "glob": "13.0.6",
"helmet": "8.1.0", "helmet": "8.1.0",
"http-errors": "2.0.1", "http-errors": "2.0.1",
"input-otp": "1.4.2", "input-otp": "1.4.2",
"ioredis": "5.10.0", "ioredis": "5.9.3",
"jmespath": "0.16.0", "jmespath": "0.16.0",
"js-yaml": "4.1.1", "js-yaml": "4.1.1",
"jsonwebtoken": "9.0.3", "jsonwebtoken": "9.0.3",
"lucide-react": "0.577.0", "lucide-react": "0.563.0",
"maxmind": "5.0.5", "maxmind": "5.0.5",
"moment": "2.30.1", "moment": "2.30.1",
"next": "15.5.12", "next": "15.5.12",
@@ -99,21 +99,20 @@
"node-cache": "5.1.2", "node-cache": "5.1.2",
"nodemailer": "8.0.1", "nodemailer": "8.0.1",
"oslo": "1.2.1", "oslo": "1.2.1",
"pg": "8.20.0", "pg": "8.19.0",
"posthog-node": "5.28.0", "posthog-node": "5.26.0",
"qrcode.react": "4.2.0", "qrcode.react": "4.2.0",
"react": "19.2.4", "react": "19.2.4",
"react-day-picker": "9.14.0", "react-day-picker": "9.13.2",
"react-dom": "19.2.4", "react-dom": "19.2.4",
"react-easy-sort": "1.8.0", "react-easy-sort": "1.8.0",
"react-hook-form": "7.71.2", "react-hook-form": "7.71.2",
"react-icons": "5.6.0", "react-icons": "5.5.0",
"recharts": "2.15.4", "recharts": "2.15.4",
"reodotdev": "1.1.0", "reodotdev": "1.0.0",
"resend": "6.9.2",
"semver": "7.7.4", "semver": "7.7.4",
"sshpk": "^1.18.0", "sshpk": "^1.18.0",
"stripe": "20.4.1", "stripe": "20.3.1",
"swagger-ui-express": "5.0.1", "swagger-ui-express": "5.0.1",
"tailwind-merge": "3.5.0", "tailwind-merge": "3.5.0",
"topojson-client": "3.1.0", "topojson-client": "3.1.0",
@@ -131,10 +130,10 @@
"zod-validation-error": "5.0.0" "zod-validation-error": "5.0.0"
}, },
"devDependencies": { "devDependencies": {
"@dotenvx/dotenvx": "1.54.1", "@dotenvx/dotenvx": "1.52.0",
"@esbuild-plugins/tsconfig-paths": "0.1.2", "@esbuild-plugins/tsconfig-paths": "0.1.2",
"@react-email/preview-server": "5.2.8", "@react-email/preview-server": "5.2.8",
"@tailwindcss/postcss": "4.2.1", "@tailwindcss/postcss": "4.1.18",
"@tanstack/react-query-devtools": "5.91.3", "@tanstack/react-query-devtools": "5.91.3",
"@types/better-sqlite3": "7.6.13", "@types/better-sqlite3": "7.6.13",
"@types/cookie-parser": "1.4.10", "@types/cookie-parser": "1.4.10",
@@ -146,10 +145,10 @@
"@types/jmespath": "0.15.2", "@types/jmespath": "0.15.2",
"@types/js-yaml": "4.0.9", "@types/js-yaml": "4.0.9",
"@types/jsonwebtoken": "9.0.10", "@types/jsonwebtoken": "9.0.10",
"@types/node": "25.3.5", "@types/node": "25.2.3",
"@types/nodemailer": "7.0.11", "@types/nodemailer": "7.0.11",
"@types/nprogress": "0.2.3", "@types/nprogress": "0.2.3",
"@types/pg": "8.18.0", "@types/pg": "8.16.0",
"@types/react": "19.2.14", "@types/react": "19.2.14",
"@types/react-dom": "19.2.3", "@types/react-dom": "19.2.3",
"@types/semver": "7.7.1", "@types/semver": "7.7.1",
@@ -167,14 +166,10 @@
"postcss": "8.5.6", "postcss": "8.5.6",
"prettier": "3.8.1", "prettier": "3.8.1",
"react-email": "5.2.8", "react-email": "5.2.8",
"tailwindcss": "4.2.1", "tailwindcss": "4.1.18",
"tsc-alias": "1.8.16", "tsc-alias": "1.8.16",
"tsx": "4.21.0", "tsx": "4.21.0",
"typescript": "5.9.3", "typescript": "5.9.3",
"typescript-eslint": "8.56.1" "typescript-eslint": "8.55.0"
},
"overrides": {
"esbuild": "0.27.3",
"dompurify": "3.3.2"
} }
} }

View File

@@ -1,10 +1,6 @@
import { flushBandwidthToDb } from "@server/routers/newt/handleReceiveBandwidthMessage";
import { flushSiteBandwidthToDb } from "@server/routers/gerbil/receiveBandwidth";
import { cleanup as wsCleanup } from "#dynamic/routers/ws"; import { cleanup as wsCleanup } from "#dynamic/routers/ws";
async function cleanup() { async function cleanup() {
await flushBandwidthToDb();
await flushSiteBandwidthToDb();
await wsCleanup(); await wsCleanup();
process.exit(0); process.exit(0);

View File

@@ -328,14 +328,6 @@ export const approvals = pgTable("approvals", {
.notNull() .notNull()
}); });
export const bannedEmails = pgTable("bannedEmails", {
email: varchar("email", { length: 255 }).primaryKey(),
});
export const bannedIps = pgTable("bannedIps", {
ip: varchar("ip", { length: 255 }).primaryKey(),
});
export type Approval = InferSelectModel<typeof approvals>; export type Approval = InferSelectModel<typeof approvals>;
export type Limit = InferSelectModel<typeof limits>; export type Limit = InferSelectModel<typeof limits>;
export type Account = InferSelectModel<typeof account>; export type Account = InferSelectModel<typeof account>;

View File

@@ -22,8 +22,7 @@ export const domains = pgTable("domains", {
tries: integer("tries").notNull().default(0), tries: integer("tries").notNull().default(0),
certResolver: varchar("certResolver"), certResolver: varchar("certResolver"),
customCertResolver: varchar("customCertResolver"), customCertResolver: varchar("customCertResolver"),
preferWildcardCert: boolean("preferWildcardCert"), preferWildcardCert: boolean("preferWildcardCert")
errorMessage: text("errorMessage")
}); });
export const dnsRecords = pgTable("dnsRecords", { export const dnsRecords = pgTable("dnsRecords", {
@@ -89,7 +88,6 @@ export const sites = pgTable("sites", {
lastBandwidthUpdate: varchar("lastBandwidthUpdate"), lastBandwidthUpdate: varchar("lastBandwidthUpdate"),
type: varchar("type").notNull(), // "newt" or "wireguard" type: varchar("type").notNull(), // "newt" or "wireguard"
online: boolean("online").notNull().default(false), online: boolean("online").notNull().default(false),
lastPing: integer("lastPing"),
address: varchar("address"), address: varchar("address"),
endpoint: varchar("endpoint"), endpoint: varchar("endpoint"),
publicKey: varchar("publicKey"), publicKey: varchar("publicKey"),
@@ -722,7 +720,6 @@ export const clientSitesAssociationsCache = pgTable(
.notNull(), .notNull(),
siteId: integer("siteId").notNull(), siteId: integer("siteId").notNull(),
isRelayed: boolean("isRelayed").notNull().default(false), isRelayed: boolean("isRelayed").notNull().default(false),
isJitMode: boolean("isJitMode").notNull().default(false),
endpoint: varchar("endpoint"), endpoint: varchar("endpoint"),
publicKey: varchar("publicKey") // this will act as the session's public key for hole punching so we can track when it changes publicKey: varchar("publicKey") // this will act as the session's public key for hole punching so we can track when it changes
} }

View File

@@ -318,15 +318,6 @@ export const approvals = sqliteTable("approvals", {
.notNull() .notNull()
}); });
export const bannedEmails = sqliteTable("bannedEmails", {
email: text("email").primaryKey()
});
export const bannedIps = sqliteTable("bannedIps", {
ip: text("ip").primaryKey()
});
export type Approval = InferSelectModel<typeof approvals>; export type Approval = InferSelectModel<typeof approvals>;
export type Limit = InferSelectModel<typeof limits>; export type Limit = InferSelectModel<typeof limits>;
export type Account = InferSelectModel<typeof account>; export type Account = InferSelectModel<typeof account>;

View File

@@ -13,8 +13,7 @@ export const domains = sqliteTable("domains", {
failed: integer("failed", { mode: "boolean" }).notNull().default(false), failed: integer("failed", { mode: "boolean" }).notNull().default(false),
tries: integer("tries").notNull().default(0), tries: integer("tries").notNull().default(0),
certResolver: text("certResolver"), certResolver: text("certResolver"),
preferWildcardCert: integer("preferWildcardCert", { mode: "boolean" }), preferWildcardCert: integer("preferWildcardCert", { mode: "boolean" })
errorMessage: text("errorMessage")
}); });
export const dnsRecords = sqliteTable("dnsRecords", { export const dnsRecords = sqliteTable("dnsRecords", {
@@ -90,7 +89,6 @@ export const sites = sqliteTable("sites", {
lastBandwidthUpdate: text("lastBandwidthUpdate"), lastBandwidthUpdate: text("lastBandwidthUpdate"),
type: text("type").notNull(), // "newt" or "wireguard" type: text("type").notNull(), // "newt" or "wireguard"
online: integer("online", { mode: "boolean" }).notNull().default(false), online: integer("online", { mode: "boolean" }).notNull().default(false),
lastPing: integer("lastPing"),
// exit node stuff that is how to connect to the site when it has a wg server // exit node stuff that is how to connect to the site when it has a wg server
address: text("address"), // this is the address of the wireguard interface in newt address: text("address"), // this is the address of the wireguard interface in newt
@@ -411,9 +409,6 @@ export const clientSitesAssociationsCache = sqliteTable(
isRelayed: integer("isRelayed", { mode: "boolean" }) isRelayed: integer("isRelayed", { mode: "boolean" })
.notNull() .notNull()
.default(false), .default(false),
isJitMode: integer("isJitMode", { mode: "boolean" })
.notNull()
.default(false),
endpoint: text("endpoint"), endpoint: text("endpoint"),
publicKey: text("publicKey") // this will act as the session's public key for hole punching so we can track when it changes publicKey: text("publicKey") // this will act as the session's public key for hole punching so we can track when it changes
} }

View File

@@ -107,7 +107,7 @@ export async function applyBlueprint({
[target], [target],
matchingHealthcheck ? [matchingHealthcheck] : [], matchingHealthcheck ? [matchingHealthcheck] : [],
result.proxyResource.protocol, result.proxyResource.protocol,
site.newt.version result.proxyResource.proxyPort
); );
} }
} }

View File

@@ -4,12 +4,8 @@ import { cleanUpOldLogs as cleanUpOldActionLogs } from "#dynamic/middlewares/log
import { cleanUpOldLogs as cleanUpOldRequestLogs } from "@server/routers/badger/logRequestAudit"; import { cleanUpOldLogs as cleanUpOldRequestLogs } from "@server/routers/badger/logRequestAudit";
import { gt, or } from "drizzle-orm"; import { gt, or } from "drizzle-orm";
import { cleanUpOldFingerprintSnapshots } from "@server/routers/olm/fingerprintingUtils"; import { cleanUpOldFingerprintSnapshots } from "@server/routers/olm/fingerprintingUtils";
import { build } from "@server/build";
export function initLogCleanupInterval() { export function initLogCleanupInterval() {
if (build == "saas") { // skip log cleanup for saas builds
return null;
}
return setInterval( return setInterval(
async () => { async () => {
const orgsToClean = await db const orgsToClean = await db

View File

@@ -1,20 +0,0 @@
import semver from "semver";
export function canCompress(
clientVersion: string | null | undefined,
type: "newt" | "olm"
): boolean {
try {
if (!clientVersion) return false;
// check if it is a valid semver
if (!semver.valid(clientVersion)) return false;
if (type === "newt") {
return semver.gte(clientVersion, "1.10.3");
} else if (type === "olm") {
return semver.gte(clientVersion, "1.4.3");
}
return false;
} catch {
return false;
}
}

View File

@@ -85,7 +85,9 @@ export async function deleteOrgById(
deletedNewtIds.push(deletedNewt.newtId); deletedNewtIds.push(deletedNewt.newtId);
await trx await trx
.delete(newtSessions) .delete(newtSessions)
.where(eq(newtSessions.newtId, deletedNewt.newtId)); .where(
eq(newtSessions.newtId, deletedNewt.newtId)
);
} }
} }
} }
@@ -119,38 +121,33 @@ export async function deleteOrgById(
eq(clientSitesAssociationsCache.clientId, client.clientId) eq(clientSitesAssociationsCache.clientId, client.clientId)
); );
} }
await trx.delete(resources).where(eq(resources.orgId, orgId));
const allOrgDomains = await trx const allOrgDomains = await trx
.select() .select()
.from(orgDomains) .from(orgDomains)
.innerJoin(domains, eq(orgDomains.domainId, domains.domainId)) .innerJoin(domains, eq(domains.domainId, orgDomains.domainId))
.where( .where(
and( and(
eq(orgDomains.orgId, orgId), eq(orgDomains.orgId, orgId),
eq(domains.configManaged, false) eq(domains.configManaged, false)
) )
); );
logger.info(`Found ${allOrgDomains.length} domains to delete`);
const domainIdsToDelete: string[] = []; const domainIdsToDelete: string[] = [];
for (const orgDomain of allOrgDomains) { for (const orgDomain of allOrgDomains) {
const domainId = orgDomain.domains.domainId; const domainId = orgDomain.domains.domainId;
const [orgCount] = await trx const orgCount = await trx
.select({ count: count() }) .select({ count: sql<number>`count(*)` })
.from(orgDomains) .from(orgDomains)
.where(eq(orgDomains.domainId, domainId)); .where(eq(orgDomains.domainId, domainId));
logger.info(`Found ${orgCount.count} orgs using domain ${domainId}`); if (orgCount[0].count === 1) {
if (orgCount.count === 1) {
domainIdsToDelete.push(domainId); domainIdsToDelete.push(domainId);
} }
} }
logger.info(`Found ${domainIdsToDelete.length} domains to delete`);
if (domainIdsToDelete.length > 0) { if (domainIdsToDelete.length > 0) {
await trx await trx
.delete(domains) .delete(domains)
.where(inArray(domains.domainId, domainIdsToDelete)); .where(inArray(domains.domainId, domainIdsToDelete));
} }
await trx.delete(resources).where(eq(resources.orgId, orgId));
await usageService.add(orgId, FeatureId.ORGINIZATIONS, -1, trx); // here we are decreasing the org count BEFORE deleting the org because we need to still be able to get the org to get the billing org inside of here await usageService.add(orgId, FeatureId.ORGINIZATIONS, -1, trx); // here we are decreasing the org count BEFORE deleting the org because we need to still be able to get the org to get the billing org inside of here
@@ -234,13 +231,15 @@ export function sendTerminationMessages(result: DeleteOrgByIdResult): void {
); );
} }
for (const olmId of result.olmsToTerminate) { for (const olmId of result.olmsToTerminate) {
sendTerminateClient(0, OlmErrorCodes.TERMINATED_REKEYED, olmId).catch( sendTerminateClient(
(error) => { 0,
OlmErrorCodes.TERMINATED_REKEYED,
olmId
).catch((error) => {
logger.error( logger.error(
"Failed to send termination message to olm:", "Failed to send termination message to olm:",
error error
); );
} });
);
} }
} }

View File

@@ -477,7 +477,6 @@ async function handleMessagesForSiteClients(
} }
if (isAdd) { if (isAdd) {
// TODO: if we are in jit mode here should we really be sending this?
await initPeerAddHandshake( await initPeerAddHandshake(
// this will kick off the add peer process for the client // this will kick off the add peer process for the client
client.clientId, client.clientId,
@@ -670,11 +669,7 @@ async function handleSubnetProxyTargetUpdates(
`Adding ${targetsToAdd.length} subnet proxy targets for siteResource ${siteResource.siteResourceId}` `Adding ${targetsToAdd.length} subnet proxy targets for siteResource ${siteResource.siteResourceId}`
); );
proxyJobs.push( proxyJobs.push(
addSubnetProxyTargets( addSubnetProxyTargets(newt.newtId, targetsToAdd)
newt.newtId,
targetsToAdd,
newt.version
)
); );
} }
@@ -710,11 +705,7 @@ async function handleSubnetProxyTargetUpdates(
`Removing ${targetsToRemove.length} subnet proxy targets for siteResource ${siteResource.siteResourceId}` `Removing ${targetsToRemove.length} subnet proxy targets for siteResource ${siteResource.siteResourceId}`
); );
proxyJobs.push( proxyJobs.push(
removeSubnetProxyTargets( removeSubnetProxyTargets(newt.newtId, targetsToRemove)
newt.newtId,
targetsToRemove,
newt.version
)
); );
} }
@@ -1089,7 +1080,6 @@ async function handleMessagesForClientSites(
continue; continue;
} }
// TODO: if we are in jit mode here should we really be sending this?
await initPeerAddHandshake( await initPeerAddHandshake(
// this will kick off the add peer process for the client // this will kick off the add peer process for the client
client.clientId, client.clientId,
@@ -1156,7 +1146,7 @@ async function handleMessagesForClientResources(
// Add subnet proxy targets for each site // Add subnet proxy targets for each site
for (const [siteId, resources] of addedBySite.entries()) { for (const [siteId, resources] of addedBySite.entries()) {
const [newt] = await trx const [newt] = await trx
.select({ newtId: newts.newtId, version: newts.version }) .select({ newtId: newts.newtId })
.from(newts) .from(newts)
.where(eq(newts.siteId, siteId)) .where(eq(newts.siteId, siteId))
.limit(1); .limit(1);
@@ -1178,13 +1168,7 @@ async function handleMessagesForClientResources(
]); ]);
if (targets.length > 0) { if (targets.length > 0) {
proxyJobs.push( proxyJobs.push(addSubnetProxyTargets(newt.newtId, targets));
addSubnetProxyTargets(
newt.newtId,
targets,
newt.version
)
);
} }
try { try {
@@ -1233,7 +1217,7 @@ async function handleMessagesForClientResources(
// Remove subnet proxy targets for each site // Remove subnet proxy targets for each site
for (const [siteId, resources] of removedBySite.entries()) { for (const [siteId, resources] of removedBySite.entries()) {
const [newt] = await trx const [newt] = await trx
.select({ newtId: newts.newtId, version: newts.version }) .select({ newtId: newts.newtId })
.from(newts) .from(newts)
.where(eq(newts.siteId, siteId)) .where(eq(newts.siteId, siteId))
.limit(1); .limit(1);
@@ -1256,11 +1240,7 @@ async function handleMessagesForClientResources(
if (targets.length > 0) { if (targets.length > 0) {
proxyJobs.push( proxyJobs.push(
removeSubnetProxyTargets( removeSubnetProxyTargets(newt.newtId, targets)
newt.newtId,
targets,
newt.version
)
); );
} }

View File

@@ -13,12 +13,8 @@
import { rateLimitService } from "#private/lib/rateLimit"; import { rateLimitService } from "#private/lib/rateLimit";
import { cleanup as wsCleanup } from "#private/routers/ws"; import { cleanup as wsCleanup } from "#private/routers/ws";
import { flushBandwidthToDb } from "@server/routers/newt/handleReceiveBandwidthMessage";
import { flushSiteBandwidthToDb } from "@server/routers/gerbil/receiveBandwidth";
async function cleanup() { async function cleanup() {
await flushBandwidthToDb();
await flushSiteBandwidthToDb();
await rateLimitService.cleanup(); await rateLimitService.cleanup();
await wsCleanup(); await wsCleanup();

View File

@@ -515,6 +515,6 @@ authenticated.post(
verifyOrgAccess, verifyOrgAccess,
verifyLimits, verifyLimits,
verifyUserHasAction(ActionsEnum.signSshKey), verifyUserHasAction(ActionsEnum.signSshKey),
// logActionAudit(ActionsEnum.signSshKey), // it is handled inside of the function below so we can include more metadata logActionAudit(ActionsEnum.signSshKey),
ssh.signSshKey ssh.signSshKey
); );

View File

@@ -14,9 +14,7 @@
import { Request, Response, NextFunction } from "express"; import { Request, Response, NextFunction } from "express";
import { z } from "zod"; import { z } from "zod";
import { import {
actionAuditLog,
db, db,
logsDb,
newts, newts,
roles, roles,
roundTripMessageTracker, roundTripMessageTracker,
@@ -31,12 +29,12 @@ import HttpCode from "@server/types/HttpCode";
import createHttpError from "http-errors"; import createHttpError from "http-errors";
import logger from "@server/logger"; import logger from "@server/logger";
import { fromError } from "zod-validation-error"; import { fromError } from "zod-validation-error";
import { OpenAPITags, registry } from "@server/openApi";
import { eq, or, and } from "drizzle-orm"; import { eq, or, and } from "drizzle-orm";
import { canUserAccessSiteResource } from "@server/auth/canUserAccessSiteResource"; import { canUserAccessSiteResource } from "@server/auth/canUserAccessSiteResource";
import { signPublicKey, getOrgCAKeys } from "@server/lib/sshCA"; import { signPublicKey, getOrgCAKeys } from "@server/lib/sshCA";
import config from "@server/lib/config"; import config from "@server/lib/config";
import { sendToClient } from "#private/routers/ws"; import { sendToClient } from "#private/routers/ws";
import { ActionsEnum } from "@server/auth/actions";
const paramsSchema = z.strictObject({ const paramsSchema = z.strictObject({
orgId: z.string().nonempty() orgId: z.string().nonempty()
@@ -66,7 +64,6 @@ export type SignSshKeyResponse = {
sshUsername: string; sshUsername: string;
sshHost: string; sshHost: string;
resourceId: number; resourceId: number;
siteId: number;
keyId: string; keyId: string;
validPrincipals: string[]; validPrincipals: string[];
validAfter: string; validAfter: string;
@@ -449,20 +446,6 @@ export async function signSshKey(
sshHost = resource.destination; sshHost = resource.destination;
} }
await logsDb.insert(actionAuditLog).values({
timestamp: Math.floor(Date.now() / 1000),
orgId: orgId,
actorType: "user",
actor: req.user?.username ?? "",
actorId: req.user?.userId ?? "",
action: ActionsEnum.signSshKey,
metadata: JSON.stringify({
resourceId: resource.siteResourceId,
resource: resource.name,
siteId: resource.siteId,
})
});
return response<SignSshKeyResponse>(res, { return response<SignSshKeyResponse>(res, {
data: { data: {
certificate: cert.certificate, certificate: cert.certificate,
@@ -470,7 +453,6 @@ export async function signSshKey(
sshUsername: usernameToUse, sshUsername: usernameToUse,
sshHost: sshHost, sshHost: sshHost,
resourceId: resource.siteResourceId, resourceId: resource.siteResourceId,
siteId: resource.siteId,
keyId: cert.keyId, keyId: cert.keyId,
validPrincipals: cert.validPrincipals, validPrincipals: cert.validPrincipals,
validAfter: cert.validAfter.toISOString(), validAfter: cert.validAfter.toISOString(),

View File

@@ -17,13 +17,10 @@ import {
startRemoteExitNodeOfflineChecker startRemoteExitNodeOfflineChecker
} from "#private/routers/remoteExitNode"; } from "#private/routers/remoteExitNode";
import { MessageHandler } from "@server/routers/ws"; import { MessageHandler } from "@server/routers/ws";
import { build } from "@server/build";
export const messageHandlers: Record<string, MessageHandler> = { export const messageHandlers: Record<string, MessageHandler> = {
"remoteExitNode/register": handleRemoteExitNodeRegisterMessage, "remoteExitNode/register": handleRemoteExitNodeRegisterMessage,
"remoteExitNode/ping": handleRemoteExitNodePingMessage "remoteExitNode/ping": handleRemoteExitNodePingMessage
}; };
if (build != "saas") { startRemoteExitNodeOfflineChecker(); // this is to handle the offline check for remote exit nodes
startRemoteExitNodeOfflineChecker(); // this is to handle the offline check for remote exit nodes
}

View File

@@ -12,7 +12,6 @@
*/ */
import { Router, Request, Response } from "express"; import { Router, Request, Response } from "express";
import zlib from "zlib";
import { Server as HttpServer } from "http"; import { Server as HttpServer } from "http";
import { WebSocket, WebSocketServer } from "ws"; import { WebSocket, WebSocketServer } from "ws";
import { Socket } from "net"; import { Socket } from "net";
@@ -25,8 +24,7 @@ import {
OlmSession, OlmSession,
RemoteExitNode, RemoteExitNode,
RemoteExitNodeSession, RemoteExitNodeSession,
remoteExitNodes, remoteExitNodes
sites
} from "@server/db"; } from "@server/db";
import { eq } from "drizzle-orm"; import { eq } from "drizzle-orm";
import { db } from "@server/db"; import { db } from "@server/db";
@@ -59,13 +57,11 @@ const MAX_PENDING_MESSAGES = 50; // Maximum messages to queue during connection
const processMessage = async ( const processMessage = async (
ws: AuthenticatedWebSocket, ws: AuthenticatedWebSocket,
data: Buffer, data: Buffer,
isBinary: boolean,
clientId: string, clientId: string,
clientType: ClientType clientType: ClientType
): Promise<void> => { ): Promise<void> => {
try { try {
const messageBuffer = isBinary ? zlib.gunzipSync(data) : data; const message: WSMessage = JSON.parse(data.toString());
const message: WSMessage = JSON.parse(messageBuffer.toString());
// logger.debug( // logger.debug(
// `Processing message from ${clientType.toUpperCase()} ID: ${clientId}, type: ${message.type}` // `Processing message from ${clientType.toUpperCase()} ID: ${clientId}, type: ${message.type}`
@@ -80,7 +76,7 @@ const processMessage = async (
clientId, clientId,
message.type, // Pass message type for granular limiting message.type, // Pass message type for granular limiting
100, // max requests per window 100, // max requests per window
100, // max requests per message type per window 20, // max requests per message type per window
60 * 1000 // window in milliseconds 60 * 1000 // window in milliseconds
); );
if (rateLimitResult.isLimited) { if (rateLimitResult.isLimited) {
@@ -167,16 +163,8 @@ const processPendingMessages = async (
); );
const jobs = []; const jobs = [];
for (const pending of ws.pendingMessages) { for (const messageData of ws.pendingMessages) {
jobs.push( jobs.push(processMessage(ws, messageData, clientId, clientType));
processMessage(
ws,
pending.data,
pending.isBinary,
clientId,
clientType
)
);
} }
await Promise.all(jobs); await Promise.all(jobs);
@@ -197,12 +185,6 @@ const connectedClients: Map<string, AuthenticatedWebSocket[]> = new Map();
// Config version tracking map (local to this node, resets on server restart) // Config version tracking map (local to this node, resets on server restart)
const clientConfigVersions: Map<string, number> = new Map(); const clientConfigVersions: Map<string, number> = new Map();
// Tracks the last Unix timestamp (seconds) at which a ping was flushed to the
// DB for a given siteId. Resets on server restart which is fine the first
// ping after startup will always write, re-establishing the online state.
const lastPingDbWrite: Map<number, number> = new Map();
const PING_DB_WRITE_INTERVAL = 45; // seconds
// Recovery tracking // Recovery tracking
let isRedisRecoveryInProgress = false; let isRedisRecoveryInProgress = false;
@@ -343,9 +325,7 @@ const addClient = async (
// Check Redis first if enabled // Check Redis first if enabled
if (redisManager.isRedisEnabled()) { if (redisManager.isRedisEnabled()) {
try { try {
const redisVersion = await redisManager.get( const redisVersion = await redisManager.get(getConfigVersionKey(clientId));
getConfigVersionKey(clientId)
);
if (redisVersion !== null) { if (redisVersion !== null) {
configVersion = parseInt(redisVersion, 10); configVersion = parseInt(redisVersion, 10);
// Sync to local cache // Sync to local cache
@@ -357,10 +337,7 @@ const addClient = async (
} else { } else {
// Use local cache version and sync to Redis // Use local cache version and sync to Redis
configVersion = clientConfigVersions.get(clientId) || 0; configVersion = clientConfigVersions.get(clientId) || 0;
await redisManager.set( await redisManager.set(getConfigVersionKey(clientId), configVersion.toString());
getConfigVersionKey(clientId),
configVersion.toString()
);
} }
} catch (error) { } catch (error) {
logger.error("Failed to get/set config version in Redis:", error); logger.error("Failed to get/set config version in Redis:", error);
@@ -455,9 +432,7 @@ const removeClient = async (
}; };
// Helper to get the current config version for a client // Helper to get the current config version for a client
const getClientConfigVersion = async ( const getClientConfigVersion = async (clientId: string): Promise<number | undefined> => {
clientId: string
): Promise<number | undefined> => {
// Try Redis first if available // Try Redis first if available
if (redisManager.isRedisEnabled()) { if (redisManager.isRedisEnabled()) {
try { try {
@@ -527,26 +502,11 @@ const sendToClientLocal = async (
}; };
const messageString = JSON.stringify(messageWithVersion); const messageString = JSON.stringify(messageWithVersion);
if (options.compress) {
logger.debug(
`Message size before compression: ${messageString.length} bytes`
);
const compressed = zlib.gzipSync(Buffer.from(messageString, "utf8"));
logger.debug(
`Message size after compression: ${compressed.length} bytes`
);
clients.forEach((client) => {
if (client.readyState === WebSocket.OPEN) {
client.send(compressed);
}
});
} else {
clients.forEach((client) => { clients.forEach((client) => {
if (client.readyState === WebSocket.OPEN) { if (client.readyState === WebSocket.OPEN) {
client.send(messageString); client.send(messageString);
} }
}); });
}
return true; return true;
}; };
@@ -572,16 +532,6 @@ const broadcastToAllExceptLocal = async (
configVersion configVersion
}; };
if (options.compress) {
const compressed = zlib.gzipSync(
Buffer.from(JSON.stringify(messageWithVersion), "utf8")
);
clients.forEach((client) => {
if (client.readyState === WebSocket.OPEN) {
client.send(compressed);
}
});
} else {
clients.forEach((client) => { clients.forEach((client) => {
if (client.readyState === WebSocket.OPEN) { if (client.readyState === WebSocket.OPEN) {
client.send(JSON.stringify(messageWithVersion)); client.send(JSON.stringify(messageWithVersion));
@@ -589,7 +539,6 @@ const broadcastToAllExceptLocal = async (
}); });
} }
} }
}
}; };
// Cross-node message sending (via Redis) // Cross-node message sending (via Redis)
@@ -813,7 +762,7 @@ const setupConnection = async (
} }
// Set up message handler FIRST to prevent race condition // Set up message handler FIRST to prevent race condition
ws.on("message", async (data, isBinary) => { ws.on("message", async (data) => {
if (!ws.isFullyConnected) { if (!ws.isFullyConnected) {
// Queue message for later processing with limits // Queue message for later processing with limits
ws.pendingMessages = ws.pendingMessages || []; ws.pendingMessages = ws.pendingMessages || [];
@@ -828,17 +777,11 @@ const setupConnection = async (
logger.debug( logger.debug(
`Queueing message from ${clientType.toUpperCase()} ID: ${clientId} (connection not fully established)` `Queueing message from ${clientType.toUpperCase()} ID: ${clientId} (connection not fully established)`
); );
ws.pendingMessages.push({ data: data as Buffer, isBinary }); ws.pendingMessages.push(data as Buffer);
return; return;
} }
await processMessage( await processMessage(ws, data as Buffer, clientId, clientType);
ws,
data as Buffer,
isBinary,
clientId,
clientType
);
}); });
// Set up other event handlers before async operations // Set up other event handlers before async operations
@@ -853,35 +796,6 @@ const setupConnection = async (
); );
}); });
// Handle WebSocket protocol-level pings from older newt clients that do
// not send application-level "newt/ping" messages. Update the site's
// online state and lastPing timestamp so the offline checker treats them
// the same as modern newt clients.
if (clientType === "newt") {
const newtClient = client as Newt;
ws.on("ping", async () => {
if (!newtClient.siteId) return;
const now = Math.floor(Date.now() / 1000);
const lastWrite = lastPingDbWrite.get(newtClient.siteId) ?? 0;
if (now - lastWrite < PING_DB_WRITE_INTERVAL) return;
lastPingDbWrite.set(newtClient.siteId, now);
try {
await db
.update(sites)
.set({
online: true,
lastPing: now
})
.where(eq(sites.siteId, newtClient.siteId));
} catch (error) {
logger.error(
"Error updating newt site online state on WS ping",
{ error }
);
}
});
}
ws.on("error", (error: Error) => { ws.on("error", (error: Error) => {
logger.error( logger.error(
`WebSocket error for ${clientType.toUpperCase()} ID ${clientId}:`, `WebSocket error for ${clientType.toUpperCase()} ID ${clientId}:`,

View File

@@ -1,5 +1,5 @@
import { NextFunction, Request, Response } from "express"; import { NextFunction, Request, Response } from "express";
import { bannedEmails, bannedIps, db, users } from "@server/db"; import { db, users } from "@server/db";
import HttpCode from "@server/types/HttpCode"; import HttpCode from "@server/types/HttpCode";
import { email, z } from "zod"; import { email, z } from "zod";
import { fromError } from "zod-validation-error"; import { fromError } from "zod-validation-error";
@@ -65,30 +65,6 @@ export async function signup(
skipVerificationEmail skipVerificationEmail
} = parsedBody.data; } = parsedBody.data;
const [bannedEmail] = await db
.select()
.from(bannedEmails)
.where(eq(bannedEmails.email, email))
.limit(1);
if (bannedEmail) {
return next(
createHttpError(HttpCode.FORBIDDEN, "Signup blocked. Do not attempt to continue to use this service.")
);
}
if (req.ip) {
const [bannedIp] = await db
.select()
.from(bannedIps)
.where(eq(bannedIps.ip, req.ip))
.limit(1);
if (bannedIp) {
return next(
createHttpError(HttpCode.FORBIDDEN, "Signup blocked. Do not attempt to continue to use this service.")
);
}
}
const passwordHash = await hashPassword(password); const passwordHash = await hashPassword(password);
const userId = generateId(15); const userId = generateId(15);

View File

@@ -1,38 +1,51 @@
import { sendToClient } from "#dynamic/routers/ws"; import { sendToClient } from "#dynamic/routers/ws";
import { db, olms, Transaction } from "@server/db"; import { db, olms, Transaction } from "@server/db";
import { canCompress } from "@server/lib/clientVersionChecks";
import { Alias, SubnetProxyTarget } from "@server/lib/ip"; import { Alias, SubnetProxyTarget } from "@server/lib/ip";
import logger from "@server/logger"; import logger from "@server/logger";
import { eq } from "drizzle-orm"; import { eq } from "drizzle-orm";
export async function addTargets( const BATCH_SIZE = 50;
newtId: string, const BATCH_DELAY_MS = 50;
targets: SubnetProxyTarget[],
version?: string | null function sleep(ms: number): Promise<void> {
) { return new Promise((resolve) => setTimeout(resolve, ms));
await sendToClient( }
newtId,
{ function chunkArray<T>(array: T[], size: number): T[][] {
const chunks: T[][] = [];
for (let i = 0; i < array.length; i += size) {
chunks.push(array.slice(i, i + size));
}
return chunks;
}
export async function addTargets(newtId: string, targets: SubnetProxyTarget[]) {
const batches = chunkArray(targets, BATCH_SIZE);
for (let i = 0; i < batches.length; i++) {
if (i > 0) {
await sleep(BATCH_DELAY_MS);
}
await sendToClient(newtId, {
type: `newt/wg/targets/add`, type: `newt/wg/targets/add`,
data: targets data: batches[i]
}, }, { incrementConfigVersion: true });
{ incrementConfigVersion: true, compress: canCompress(version, "newt") } }
);
} }
export async function removeTargets( export async function removeTargets(
newtId: string, newtId: string,
targets: SubnetProxyTarget[], targets: SubnetProxyTarget[]
version?: string | null
) { ) {
await sendToClient( const batches = chunkArray(targets, BATCH_SIZE);
newtId, for (let i = 0; i < batches.length; i++) {
{ if (i > 0) {
await sleep(BATCH_DELAY_MS);
}
await sendToClient(newtId, {
type: `newt/wg/targets/remove`, type: `newt/wg/targets/remove`,
data: targets data: batches[i]
}, },{ incrementConfigVersion: true });
{ incrementConfigVersion: true, compress: canCompress(version, "newt") } }
);
} }
export async function updateTargets( export async function updateTargets(
@@ -40,22 +53,26 @@ export async function updateTargets(
targets: { targets: {
oldTargets: SubnetProxyTarget[]; oldTargets: SubnetProxyTarget[];
newTargets: SubnetProxyTarget[]; newTargets: SubnetProxyTarget[];
}, }
version?: string | null
) { ) {
await sendToClient( const oldBatches = chunkArray(targets.oldTargets, BATCH_SIZE);
newtId, const newBatches = chunkArray(targets.newTargets, BATCH_SIZE);
{ const maxBatches = Math.max(oldBatches.length, newBatches.length);
for (let i = 0; i < maxBatches; i++) {
if (i > 0) {
await sleep(BATCH_DELAY_MS);
}
await sendToClient(newtId, {
type: `newt/wg/targets/update`, type: `newt/wg/targets/update`,
data: { data: {
oldTargets: targets.oldTargets, oldTargets: oldBatches[i] || [],
newTargets: targets.newTargets newTargets: newBatches[i] || []
} }
}, }, { incrementConfigVersion: true }).catch((error) => {
{ incrementConfigVersion: true, compress: canCompress(version, "newt") }
).catch((error) => {
logger.warn(`Error sending message:`, error); logger.warn(`Error sending message:`, error);
}); });
}
} }
export async function addPeerData( export async function addPeerData(
@@ -63,8 +80,7 @@ export async function addPeerData(
siteId: number, siteId: number,
remoteSubnets: string[], remoteSubnets: string[],
aliases: Alias[], aliases: Alias[],
olmId?: string, olmId?: string
version?: string | null
) { ) {
if (!olmId) { if (!olmId) {
const [olm] = await db const [olm] = await db
@@ -76,21 +92,16 @@ export async function addPeerData(
return; // ignore this because an olm might not be associated with the client anymore return; // ignore this because an olm might not be associated with the client anymore
} }
olmId = olm.olmId; olmId = olm.olmId;
version = olm.version;
} }
await sendToClient( await sendToClient(olmId, {
olmId,
{
type: `olm/wg/peer/data/add`, type: `olm/wg/peer/data/add`,
data: { data: {
siteId: siteId, siteId: siteId,
remoteSubnets: remoteSubnets, remoteSubnets: remoteSubnets,
aliases: aliases aliases: aliases
} }
}, }, { incrementConfigVersion: true }).catch((error) => {
{ incrementConfigVersion: true, compress: canCompress(version, "olm") }
).catch((error) => {
logger.warn(`Error sending message:`, error); logger.warn(`Error sending message:`, error);
}); });
} }
@@ -100,8 +111,7 @@ export async function removePeerData(
siteId: number, siteId: number,
remoteSubnets: string[], remoteSubnets: string[],
aliases: Alias[], aliases: Alias[],
olmId?: string, olmId?: string
version?: string | null
) { ) {
if (!olmId) { if (!olmId) {
const [olm] = await db const [olm] = await db
@@ -113,21 +123,16 @@ export async function removePeerData(
return; return;
} }
olmId = olm.olmId; olmId = olm.olmId;
version = olm.version;
} }
await sendToClient( await sendToClient(olmId, {
olmId,
{
type: `olm/wg/peer/data/remove`, type: `olm/wg/peer/data/remove`,
data: { data: {
siteId: siteId, siteId: siteId,
remoteSubnets: remoteSubnets, remoteSubnets: remoteSubnets,
aliases: aliases aliases: aliases
} }
}, }, { incrementConfigVersion: true }).catch((error) => {
{ incrementConfigVersion: true, compress: canCompress(version, "olm") }
).catch((error) => {
logger.warn(`Error sending message:`, error); logger.warn(`Error sending message:`, error);
}); });
} }
@@ -147,8 +152,7 @@ export async function updatePeerData(
newAliases: Alias[]; newAliases: Alias[];
} }
| undefined, | undefined,
olmId?: string, olmId?: string
version?: string | null
) { ) {
if (!olmId) { if (!olmId) {
const [olm] = await db const [olm] = await db
@@ -160,21 +164,16 @@ export async function updatePeerData(
return; return;
} }
olmId = olm.olmId; olmId = olm.olmId;
version = olm.version;
} }
await sendToClient( await sendToClient(olmId, {
olmId,
{
type: `olm/wg/peer/data/update`, type: `olm/wg/peer/data/update`,
data: { data: {
siteId: siteId, siteId: siteId,
...remoteSubnets, ...remoteSubnets,
...aliases ...aliases
} }
}, }, { incrementConfigVersion: true }).catch((error) => {
{ incrementConfigVersion: true, compress: canCompress(version, "olm") }
).catch((error) => {
logger.warn(`Error sending message:`, error); logger.warn(`Error sending message:`, error);
}); });
} }

View File

@@ -40,8 +40,7 @@ async function queryDomains(orgId: string, limit: number, offset: number) {
tries: domains.tries, tries: domains.tries,
configManaged: domains.configManaged, configManaged: domains.configManaged,
certResolver: domains.certResolver, certResolver: domains.certResolver,
preferWildcardCert: domains.preferWildcardCert, preferWildcardCert: domains.preferWildcardCert
errorMessage: domains.errorMessage
}) })
.from(orgDomains) .from(orgDomains)
.where(eq(orgDomains.orgId, orgId)) .where(eq(orgDomains.orgId, orgId))

View File

@@ -1,5 +1,5 @@
import { Request, Response, NextFunction } from "express"; import { Request, Response, NextFunction } from "express";
import { eq, sql } from "drizzle-orm"; import { eq, and, lt, inArray, sql } from "drizzle-orm";
import { sites } from "@server/db"; import { sites } from "@server/db";
import { db } from "@server/db"; import { db } from "@server/db";
import logger from "@server/logger"; import logger from "@server/logger";
@@ -11,30 +11,18 @@ import { FeatureId } from "@server/lib/billing/features";
import { checkExitNodeOrg } from "#dynamic/lib/exitNodes"; import { checkExitNodeOrg } from "#dynamic/lib/exitNodes";
import { build } from "@server/build"; import { build } from "@server/build";
interface PeerBandwidth { // Track sites that are already offline to avoid unnecessary queries
publicKey: string; const offlineSites = new Set<string>();
bytesIn: number;
bytesOut: number;
}
interface AccumulatorEntry {
bytesIn: number;
bytesOut: number;
/** Present when the update came through a remote exit node. */
exitNodeId?: number;
/** Whether to record egress usage for billing purposes. */
calcUsage: boolean;
}
// Retry configuration for deadlock handling // Retry configuration for deadlock handling
const MAX_RETRIES = 3; const MAX_RETRIES = 3;
const BASE_DELAY_MS = 50; const BASE_DELAY_MS = 50;
// How often to flush accumulated bandwidth data to the database interface PeerBandwidth {
const FLUSH_INTERVAL_MS = 30_000; // 30 seconds publicKey: string;
bytesIn: number;
// In-memory accumulator: publicKey -> AccumulatorEntry bytesOut: number;
let accumulator = new Map<string, AccumulatorEntry>(); }
/** /**
* Check if an error is a deadlock error * Check if an error is a deadlock error
@@ -75,220 +63,6 @@ async function withDeadlockRetry<T>(
} }
} }
/**
* Flush all accumulated site bandwidth data to the database.
*
* Swaps out the accumulator before writing so that any bandwidth messages
* received during the flush are captured in the new accumulator rather than
* being lost or causing contention. Entries that fail to write are re-queued
* back into the accumulator so they will be retried on the next flush.
*
* This function is exported so that the application's graceful-shutdown
* cleanup handler can call it before the process exits.
*/
export async function flushSiteBandwidthToDb(): Promise<void> {
if (accumulator.size === 0) {
return;
}
// Atomically swap out the accumulator so new data keeps flowing in
// while we write the snapshot to the database.
const snapshot = accumulator;
accumulator = new Map<string, AccumulatorEntry>();
const currentTime = new Date().toISOString();
// Sort by publicKey for consistent lock ordering across concurrent
// writers — deadlock-prevention strategy.
const sortedEntries = [...snapshot.entries()].sort(([a], [b]) =>
a.localeCompare(b)
);
logger.debug(
`Flushing accumulated bandwidth data for ${sortedEntries.length} site(s) to the database`
);
// Aggregate billing usage by org, collected during the DB update loop.
const orgUsageMap = new Map<string, number>();
for (const [publicKey, { bytesIn, bytesOut, exitNodeId, calcUsage }] of sortedEntries) {
try {
const updatedSite = await withDeadlockRetry(async () => {
const [result] = await db
.update(sites)
.set({
megabytesOut: sql`COALESCE(${sites.megabytesOut}, 0) + ${bytesIn}`,
megabytesIn: sql`COALESCE(${sites.megabytesIn}, 0) + ${bytesOut}`,
lastBandwidthUpdate: currentTime
})
.where(eq(sites.pubKey, publicKey))
.returning({
orgId: sites.orgId,
siteId: sites.siteId
});
return result;
}, `flush bandwidth for site ${publicKey}`);
if (updatedSite) {
if (exitNodeId) {
const notAllowed = await checkExitNodeOrg(
exitNodeId,
updatedSite.orgId
);
if (notAllowed) {
logger.warn(
`Exit node ${exitNodeId} is not allowed for org ${updatedSite.orgId}`
);
// Skip usage tracking for this site but continue
// processing the rest.
continue;
}
}
if (calcUsage) {
const totalBandwidth = bytesIn + bytesOut;
const current = orgUsageMap.get(updatedSite.orgId) ?? 0;
orgUsageMap.set(updatedSite.orgId, current + totalBandwidth);
}
}
} catch (error) {
logger.error(
`Failed to flush bandwidth for site ${publicKey}:`,
error
);
// Re-queue the failed entry so it is retried on the next flush
// rather than silently dropped.
const existing = accumulator.get(publicKey);
if (existing) {
existing.bytesIn += bytesIn;
existing.bytesOut += bytesOut;
} else {
accumulator.set(publicKey, {
bytesIn,
bytesOut,
exitNodeId,
calcUsage
});
}
}
}
// Process billing usage updates outside the site-update loop to keep
// lock scope small and concerns separated.
if (orgUsageMap.size > 0) {
// Sort org IDs for consistent lock ordering.
const sortedOrgIds = [...orgUsageMap.keys()].sort();
for (const orgId of sortedOrgIds) {
try {
const totalBandwidth = orgUsageMap.get(orgId)!;
const bandwidthUsage = await usageService.add(
orgId,
FeatureId.EGRESS_DATA_MB,
totalBandwidth
);
if (bandwidthUsage) {
// Fire-and-forget — don't block the flush on limit checking.
usageService
.checkLimitSet(
orgId,
FeatureId.EGRESS_DATA_MB,
bandwidthUsage
)
.catch((error: any) => {
logger.error(
`Error checking bandwidth limits for org ${orgId}:`,
error
);
});
}
} catch (error) {
logger.error(
`Error processing usage for org ${orgId}:`,
error
);
// Continue with other orgs.
}
}
}
}
// ---------------------------------------------------------------------------
// Periodic flush timer
// ---------------------------------------------------------------------------
const flushTimer = setInterval(async () => {
try {
await flushSiteBandwidthToDb();
} catch (error) {
logger.error(
"Unexpected error during periodic site bandwidth flush:",
error
);
}
}, FLUSH_INTERVAL_MS);
// Allow the process to exit normally even while the timer is pending.
// The graceful-shutdown path (see server/cleanup.ts) will call
// flushSiteBandwidthToDb() explicitly before process.exit(), so no data
// is lost.
flushTimer.unref();
// ---------------------------------------------------------------------------
// Public API
// ---------------------------------------------------------------------------
/**
* Accumulate bandwidth data reported by a gerbil or remote exit node.
*
* Only peers that actually transferred data (bytesIn > 0) are added to the
* accumulator; peers with no activity are silently ignored, which means the
* flush will only write rows that have genuinely changed.
*
* The function is intentionally synchronous in its fast path so that the
* HTTP handler can respond immediately without waiting for any I/O.
*/
export async function updateSiteBandwidth(
bandwidthData: PeerBandwidth[],
calcUsageAndLimits: boolean,
exitNodeId?: number
): Promise<void> {
for (const { publicKey, bytesIn, bytesOut } of bandwidthData) {
// Skip peers that haven't transferred any data — writing zeros to the
// database would be a no-op anyway.
if (bytesIn <= 0 && bytesOut <= 0) {
continue;
}
const existing = accumulator.get(publicKey);
if (existing) {
existing.bytesIn += bytesIn;
existing.bytesOut += bytesOut;
// Retain the most-recent exitNodeId for this peer.
if (exitNodeId !== undefined) {
existing.exitNodeId = exitNodeId;
}
// Once calcUsage has been requested for a peer, keep it set for
// the lifetime of this flush window.
if (calcUsageAndLimits) {
existing.calcUsage = true;
}
} else {
accumulator.set(publicKey, {
bytesIn,
bytesOut,
exitNodeId,
calcUsage: calcUsageAndLimits
});
}
}
}
// ---------------------------------------------------------------------------
// HTTP handler
// ---------------------------------------------------------------------------
export const receiveBandwidth = async ( export const receiveBandwidth = async (
req: Request, req: Request,
res: Response, res: Response,
@@ -301,9 +75,7 @@ export const receiveBandwidth = async (
throw new Error("Invalid bandwidth data"); throw new Error("Invalid bandwidth data");
} }
// Accumulate in memory; the periodic timer (and the shutdown hook) await updateSiteBandwidth(bandwidthData, build == "saas"); // we are checking the usage on saas only
// will write to the database.
await updateSiteBandwidth(bandwidthData, build == "saas");
return response(res, { return response(res, {
data: {}, data: {},
@@ -322,3 +94,201 @@ export const receiveBandwidth = async (
); );
} }
}; };
export async function updateSiteBandwidth(
bandwidthData: PeerBandwidth[],
calcUsageAndLimits: boolean,
exitNodeId?: number
) {
const currentTime = new Date();
const oneMinuteAgo = new Date(currentTime.getTime() - 60000); // 1 minute ago
// Sort bandwidth data by publicKey to ensure consistent lock ordering across all instances
// This is critical for preventing deadlocks when multiple instances update the same sites
const sortedBandwidthData = [...bandwidthData].sort((a, b) =>
a.publicKey.localeCompare(b.publicKey)
);
// First, handle sites that are actively reporting bandwidth
const activePeers = sortedBandwidthData.filter((peer) => peer.bytesIn > 0);
// Aggregate usage data by organization (collected outside transaction)
const orgUsageMap = new Map<string, number>();
if (activePeers.length > 0) {
// Remove any active peers from offline tracking since they're sending data
activePeers.forEach((peer) => offlineSites.delete(peer.publicKey));
// Update each active site individually with retry logic
// This reduces transaction scope and allows retries per-site
for (const peer of activePeers) {
try {
const updatedSite = await withDeadlockRetry(async () => {
const [result] = await db
.update(sites)
.set({
megabytesOut: sql`${sites.megabytesOut} + ${peer.bytesIn}`,
megabytesIn: sql`${sites.megabytesIn} + ${peer.bytesOut}`,
lastBandwidthUpdate: currentTime.toISOString(),
online: true
})
.where(eq(sites.pubKey, peer.publicKey))
.returning({
online: sites.online,
orgId: sites.orgId,
siteId: sites.siteId,
lastBandwidthUpdate: sites.lastBandwidthUpdate
});
return result;
}, `update active site ${peer.publicKey}`);
if (updatedSite) {
if (exitNodeId) {
const notAllowed = await checkExitNodeOrg(
exitNodeId,
updatedSite.orgId
);
if (notAllowed) {
logger.warn(
`Exit node ${exitNodeId} is not allowed for org ${updatedSite.orgId}`
);
// Skip this site but continue processing others
continue;
}
}
// Aggregate bandwidth usage for the org
const totalBandwidth = peer.bytesIn + peer.bytesOut;
const currentOrgUsage =
orgUsageMap.get(updatedSite.orgId) || 0;
orgUsageMap.set(
updatedSite.orgId,
currentOrgUsage + totalBandwidth
);
}
} catch (error) {
logger.error(
`Failed to update bandwidth for site ${peer.publicKey}:`,
error
);
// Continue with other sites
}
}
}
// Process usage updates outside of site update transactions
// This separates the concerns and reduces lock contention
if (calcUsageAndLimits && orgUsageMap.size > 0) {
// Sort org IDs to ensure consistent lock ordering
const allOrgIds = [...new Set([...orgUsageMap.keys()])].sort();
for (const orgId of allOrgIds) {
try {
// Process bandwidth usage for this org
const totalBandwidth = orgUsageMap.get(orgId);
if (totalBandwidth) {
const bandwidthUsage = await usageService.add(
orgId,
FeatureId.EGRESS_DATA_MB,
totalBandwidth
);
if (bandwidthUsage) {
// Fire and forget - don't block on limit checking
usageService
.checkLimitSet(
orgId,
FeatureId.EGRESS_DATA_MB,
bandwidthUsage
)
.catch((error: any) => {
logger.error(
`Error checking bandwidth limits for org ${orgId}:`,
error
);
});
}
}
} catch (error) {
logger.error(`Error processing usage for org ${orgId}:`, error);
// Continue with other orgs
}
}
}
// Handle sites that reported zero bandwidth but need online status updated
const zeroBandwidthPeers = sortedBandwidthData.filter(
(peer) => peer.bytesIn === 0 && !offlineSites.has(peer.publicKey)
);
if (zeroBandwidthPeers.length > 0) {
// Fetch all zero bandwidth sites in one query
const zeroBandwidthSites = await db
.select()
.from(sites)
.where(
inArray(
sites.pubKey,
zeroBandwidthPeers.map((p) => p.publicKey)
)
);
// Sort by siteId to ensure consistent lock ordering
const sortedZeroBandwidthSites = zeroBandwidthSites.sort(
(a, b) => a.siteId - b.siteId
);
for (const site of sortedZeroBandwidthSites) {
let newOnlineStatus = site.online;
// Check if site should go offline based on last bandwidth update WITH DATA
if (site.lastBandwidthUpdate) {
const lastUpdateWithData = new Date(site.lastBandwidthUpdate);
if (lastUpdateWithData < oneMinuteAgo) {
newOnlineStatus = false;
}
} else {
// No previous data update recorded, set to offline
newOnlineStatus = false;
}
// Only update online status if it changed
if (site.online !== newOnlineStatus) {
try {
const updatedSite = await withDeadlockRetry(async () => {
const [result] = await db
.update(sites)
.set({
online: newOnlineStatus
})
.where(eq(sites.siteId, site.siteId))
.returning();
return result;
}, `update offline status for site ${site.siteId}`);
if (updatedSite && exitNodeId) {
const notAllowed = await checkExitNodeOrg(
exitNodeId,
updatedSite.orgId
);
if (notAllowed) {
logger.warn(
`Exit node ${exitNodeId} is not allowed for org ${updatedSite.orgId}`
);
}
}
// If site went offline, add it to our tracking set
if (!newOnlineStatus && site.pubKey) {
offlineSites.add(site.pubKey);
}
} catch (error) {
logger.error(
`Failed to update offline status for site ${site.siteId}:`,
error
);
// Continue with other sites
}
}
}
}
}

View File

@@ -112,7 +112,7 @@ export async function updateHolePunch(
destinations: destinations destinations: destinations
}); });
} catch (error) { } catch (error) {
logger.error(error); // logger.error(error); // FIX THIS
return next( return next(
createHttpError( createHttpError(
HttpCode.INTERNAL_SERVER_ERROR, HttpCode.INTERNAL_SERVER_ERROR,
@@ -339,10 +339,10 @@ export async function updateAndGenerateEndpointDestinations(
handleSiteEndpointChange(newt.siteId, updatedSite.endpoint!); handleSiteEndpointChange(newt.siteId, updatedSite.endpoint!);
} }
// if (!updatedSite || !updatedSite.subnet) { if (!updatedSite || !updatedSite.subnet) {
// logger.warn(`Site not found: ${newt.siteId}`); logger.warn(`Site not found: ${newt.siteId}`);
// throw new Error("Site not found"); throw new Error("Site not found");
// } }
// Find all clients that connect to this site // Find all clients that connect to this site
// const sitesClientPairs = await db // const sitesClientPairs = await db

View File

@@ -1,15 +1,4 @@
import { import { clients, clientSiteResourcesAssociationsCache, clientSitesAssociationsCache, db, ExitNode, resources, Site, siteResources, targetHealthCheck, targets } from "@server/db";
clients,
clientSiteResourcesAssociationsCache,
clientSitesAssociationsCache,
db,
ExitNode,
resources,
Site,
siteResources,
targetHealthCheck,
targets
} from "@server/db";
import logger from "@server/logger"; import logger from "@server/logger";
import { initPeerAddHandshake, updatePeer } from "../olm/peers"; import { initPeerAddHandshake, updatePeer } from "../olm/peers";
import { eq, and } from "drizzle-orm"; import { eq, and } from "drizzle-orm";
@@ -80,7 +69,6 @@ export async function buildClientConfigurationForNewtClient(
// ) // )
// ); // );
if (!client.clientSitesAssociationsCache.isJitMode) { // if we are adding sites through jit then dont add the site to the olm
// update the peer info on the olm // update the peer info on the olm
// if the peer has not been added yet this will be a no-op // if the peer has not been added yet this will be a no-op
await updatePeer(client.clients.clientId, { await updatePeer(client.clients.clientId, {
@@ -115,7 +103,6 @@ export async function buildClientConfigurationForNewtClient(
} }
} }
); );
}
return { return {
publicKey: client.clients.pubKey!, publicKey: client.clients.pubKey!,
@@ -243,9 +230,9 @@ export async function buildTargetConfigurationForNewtClient(siteId: number) {
!target.hcInterval || !target.hcInterval ||
!target.hcMethod !target.hcMethod
) { ) {
// logger.debug( logger.debug(
// `Skipping adding target health check ${target.targetId} due to missing health check fields` `Skipping adding target health check ${target.targetId} due to missing health check fields`
// ); );
return null; // Skip targets with missing health check fields return null; // Skip targets with missing health check fields
} }

View File

@@ -6,7 +6,6 @@ import { db, ExitNode, exitNodes, Newt, sites } from "@server/db";
import { eq } from "drizzle-orm"; import { eq } from "drizzle-orm";
import { sendToExitNode } from "#dynamic/lib/exitNodes"; import { sendToExitNode } from "#dynamic/lib/exitNodes";
import { buildClientConfigurationForNewtClient } from "./buildConfiguration"; import { buildClientConfigurationForNewtClient } from "./buildConfiguration";
import { canCompress } from "@server/lib/clientVersionChecks";
const inputSchema = z.object({ const inputSchema = z.object({
publicKey: z.string(), publicKey: z.string(),
@@ -136,9 +135,6 @@ export const handleGetConfigMessage: MessageHandler = async (context) => {
targets targets
} }
}, },
options: {
compress: canCompress(newt.version, "newt")
},
broadcast: false, broadcast: false,
excludeSender: false excludeSender: false
}; };

View File

@@ -1,34 +0,0 @@
import { MessageHandler } from "@server/routers/ws";
import { db, Newt, sites } from "@server/db";
import { eq } from "drizzle-orm";
import logger from "@server/logger";
/**
* Handles disconnecting messages from sites to show disconnected in the ui
*/
export const handleNewtDisconnectingMessage: MessageHandler = async (context) => {
const { message, client: c, sendToClient } = context;
const newt = c as Newt;
if (!newt) {
logger.warn("Newt not found");
return;
}
if (!newt.siteId) {
logger.warn("Newt has no client ID!");
return;
}
try {
// Update the client's last ping timestamp
await db
.update(sites)
.set({
online: false
})
.where(eq(sites.siteId, sites.siteId));
} catch (error) {
logger.error("Error handling disconnecting message", { error });
}
};

View File

@@ -1,107 +1,105 @@
import { db, newts, sites } from "@server/db"; import { db, sites } from "@server/db";
import { hasActiveConnections, getClientConfigVersion } from "#dynamic/routers/ws"; import { disconnectClient, getClientConfigVersion } from "#dynamic/routers/ws";
import { MessageHandler } from "@server/routers/ws"; import { MessageHandler } from "@server/routers/ws";
import { Newt } from "@server/db"; import { clients, Newt } from "@server/db";
import { eq, lt, isNull, and, or } from "drizzle-orm"; import { eq, lt, isNull, and, or } from "drizzle-orm";
import logger from "@server/logger"; import logger from "@server/logger";
import { validateSessionToken } from "@server/auth/sessions/app";
import { checkOrgAccessPolicy } from "#dynamic/lib/checkOrgAccessPolicy";
import { sendTerminateClient } from "../client/terminate";
import { encodeHexLowerCase } from "@oslojs/encoding";
import { sha256 } from "@oslojs/crypto/sha2";
import { sendNewtSyncMessage } from "./sync"; import { sendNewtSyncMessage } from "./sync";
// Track if the offline checker interval is running // Track if the offline checker interval is running
let offlineCheckerInterval: NodeJS.Timeout | null = null; // let offlineCheckerInterval: NodeJS.Timeout | null = null;
const OFFLINE_CHECK_INTERVAL = 30 * 1000; // Check every 30 seconds // const OFFLINE_CHECK_INTERVAL = 30 * 1000; // Check every 30 seconds
const OFFLINE_THRESHOLD_MS = 2 * 60 * 1000; // 2 minutes // const OFFLINE_THRESHOLD_MS = 2 * 60 * 1000; // 2 minutes
/** /**
* Starts the background interval that checks for newt sites that haven't * Starts the background interval that checks for clients that haven't pinged recently
* pinged recently and marks them as offline. For backward compatibility, * and marks them as offline
* a site is only marked offline when there is no active WebSocket connection
* either — so older newt versions that don't send pings but remain connected
* continue to be treated as online.
*/ */
export const startNewtOfflineChecker = (): void => { // export const startNewtOfflineChecker = (): void => {
if (offlineCheckerInterval) { // if (offlineCheckerInterval) {
return; // Already running // return; // Already running
} // }
offlineCheckerInterval = setInterval(async () => { // offlineCheckerInterval = setInterval(async () => {
try { // try {
const twoMinutesAgo = Math.floor( // const twoMinutesAgo = Math.floor(
(Date.now() - OFFLINE_THRESHOLD_MS) / 1000 // (Date.now() - OFFLINE_THRESHOLD_MS) / 1000
); // );
// Find all online newt-type sites that haven't pinged recently // // TODO: WE NEED TO MAKE SURE THIS WORKS WITH DISTRIBUTED NODES ALL DOING THE SAME THING
// (or have never pinged at all). Join newts to obtain the newtId
// needed for the WebSocket connection check.
const staleSites = await db
.select({
siteId: sites.siteId,
newtId: newts.newtId,
lastPing: sites.lastPing
})
.from(sites)
.innerJoin(newts, eq(newts.siteId, sites.siteId))
.where(
and(
eq(sites.online, true),
eq(sites.type, "newt"),
or(
lt(sites.lastPing, twoMinutesAgo),
isNull(sites.lastPing)
)
)
);
for (const staleSite of staleSites) { // // Find clients that haven't pinged in the last 2 minutes and mark them as offline
// Backward-compatibility check: if the newt still has an // const offlineClients = await db
// active WebSocket connection (older clients that don't send // .update(clients)
// pings), keep the site online. // .set({ online: false })
const isConnected = await hasActiveConnections(staleSite.newtId); // .where(
if (isConnected) { // and(
logger.debug( // eq(clients.online, true),
`Newt ${staleSite.newtId} has not pinged recently but is still connected via WebSocket — keeping site ${staleSite.siteId} online` // or(
); // lt(clients.lastPing, twoMinutesAgo),
continue; // isNull(clients.lastPing)
} // )
// )
// )
// .returning();
logger.info( // for (const offlineClient of offlineClients) {
`Marking site ${staleSite.siteId} offline: newt ${staleSite.newtId} has no recent ping and no active WebSocket connection` // logger.info(
); // `Kicking offline newt client ${offlineClient.clientId} due to inactivity`
// );
await db // if (!offlineClient.newtId) {
.update(sites) // logger.warn(
.set({ online: false }) // `Offline client ${offlineClient.clientId} has no newtId, cannot disconnect`
.where(eq(sites.siteId, staleSite.siteId)); // );
} // continue;
} catch (error) { // }
logger.error("Error in newt offline checker interval", { error });
}
}, OFFLINE_CHECK_INTERVAL);
logger.debug("Started newt offline checker interval"); // // Send a disconnect message to the client if connected
}; // try {
// await sendTerminateClient(
// offlineClient.clientId,
// offlineClient.newtId
// ); // terminate first
// // wait a moment to ensure the message is sent
// await new Promise((resolve) => setTimeout(resolve, 1000));
// await disconnectClient(offlineClient.newtId);
// } catch (error) {
// logger.error(
// `Error sending disconnect to offline newt ${offlineClient.clientId}`,
// { error }
// );
// }
// }
// } catch (error) {
// logger.error("Error in offline checker interval", { error });
// }
// }, OFFLINE_CHECK_INTERVAL);
// logger.debug("Started offline checker interval");
// };
/** /**
* Stops the background interval that checks for offline newt sites. * Stops the background interval that checks for offline clients
*/ */
export const stopNewtOfflineChecker = (): void => { // export const stopNewtOfflineChecker = (): void => {
if (offlineCheckerInterval) { // if (offlineCheckerInterval) {
clearInterval(offlineCheckerInterval); // clearInterval(offlineCheckerInterval);
offlineCheckerInterval = null; // offlineCheckerInterval = null;
logger.info("Stopped newt offline checker interval"); // logger.info("Stopped offline checker interval");
} // }
}; // };
/** /**
* Handles ping messages from newt clients. * Handles ping messages from clients and responds with pong
*
* On each ping:
* - Marks the associated site as online.
* - Records the current timestamp as the newt's last-ping time.
* - Triggers a config sync if the newt is running an outdated config version.
* - Responds with a pong message.
*/ */
export const handleNewtPingMessage: MessageHandler = async (context) => { export const handleNewtPingMessage: MessageHandler = async (context) => {
const { message, client: c } = context; const { message, client: c, sendToClient } = context;
const newt = c as Newt; const newt = c as Newt;
if (!newt) { if (!newt) {
@@ -114,31 +112,15 @@ export const handleNewtPingMessage: MessageHandler = async (context) => {
return; return;
} }
try { // get the version
// Mark the site as online and record the ping timestamp.
await db
.update(sites)
.set({
online: true,
lastPing: Math.floor(Date.now() / 1000)
})
.where(eq(sites.siteId, newt.siteId));
} catch (error) {
logger.error("Error updating online state on newt ping", { error });
}
// Check config version and sync if stale.
const configVersion = await getClientConfigVersion(newt.newtId); const configVersion = await getClientConfigVersion(newt.newtId);
if ( if (message.configVersion && configVersion != null && configVersion != message.configVersion) {
message.configVersion != null &&
configVersion != null &&
configVersion !== message.configVersion
) {
logger.warn( logger.warn(
`Newt ping with outdated config version: ${message.configVersion} (current: ${configVersion})` `Newt ping with outdated config version: ${message.configVersion} (current: ${configVersion})`
); );
// get the site
const [site] = await db const [site] = await db
.select() .select()
.from(sites) .from(sites)
@@ -155,6 +137,19 @@ export const handleNewtPingMessage: MessageHandler = async (context) => {
await sendNewtSyncMessage(newt, site); await sendNewtSyncMessage(newt, site);
} }
// try {
// // Update the client's last ping timestamp
// await db
// .update(clients)
// .set({
// lastPing: Math.floor(Date.now() / 1000),
// online: true
// })
// .where(eq(clients.clientId, newt.clientId));
// } catch (error) {
// logger.error("Error handling ping message", { error });
// }
return { return {
message: { message: {
type: "pong", type: "pong",

View File

@@ -5,7 +5,9 @@ import { eq } from "drizzle-orm";
import { addPeer, deletePeer } from "../gerbil/peers"; import { addPeer, deletePeer } from "../gerbil/peers";
import logger from "@server/logger"; import logger from "@server/logger";
import config from "@server/lib/config"; import config from "@server/lib/config";
import { findNextAvailableCidr } from "@server/lib/ip"; import {
findNextAvailableCidr,
} from "@server/lib/ip";
import { import {
selectBestExitNode, selectBestExitNode,
verifyExitNodeOrgAccess verifyExitNodeOrgAccess
@@ -13,7 +15,6 @@ import {
import { fetchContainers } from "./dockerSocket"; import { fetchContainers } from "./dockerSocket";
import { lockManager } from "#dynamic/lib/lock"; import { lockManager } from "#dynamic/lib/lock";
import { buildTargetConfigurationForNewtClient } from "./buildConfiguration"; import { buildTargetConfigurationForNewtClient } from "./buildConfiguration";
import { canCompress } from "@server/lib/clientVersionChecks";
export type ExitNodePingResult = { export type ExitNodePingResult = {
exitNodeId: number; exitNodeId: number;
@@ -214,9 +215,6 @@ export const handleNewtRegisterMessage: MessageHandler = async (context) => {
healthCheckTargets: validHealthCheckTargets healthCheckTargets: validHealthCheckTargets
} }
}, },
options: {
compress: canCompress(newt.version, "newt")
},
broadcast: false, // Send to all clients broadcast: false, // Send to all clients
excludeSender: false // Include sender in broadcast excludeSender: false // Include sender in broadcast
}; };

View File

@@ -10,21 +10,10 @@ interface PeerBandwidth {
bytesOut: number; bytesOut: number;
} }
interface BandwidthAccumulator {
bytesIn: number;
bytesOut: number;
}
// Retry configuration for deadlock handling // Retry configuration for deadlock handling
const MAX_RETRIES = 3; const MAX_RETRIES = 3;
const BASE_DELAY_MS = 50; const BASE_DELAY_MS = 50;
// How often to flush accumulated bandwidth data to the database
const FLUSH_INTERVAL_MS = 120_000; // 120 seconds
// In-memory accumulator: publicKey -> { bytesIn, bytesOut }
let accumulator = new Map<string, BandwidthAccumulator>();
/** /**
* Check if an error is a deadlock error * Check if an error is a deadlock error
*/ */
@@ -64,90 +53,6 @@ async function withDeadlockRetry<T>(
} }
} }
/**
* Flush all accumulated bandwidth data to the database.
*
* Swaps out the accumulator before writing so that any bandwidth messages
* received during the flush are captured in the new accumulator rather than
* being lost or causing contention. Entries that fail to write are re-queued
* back into the accumulator so they will be retried on the next flush.
*
* This function is exported so that the application's graceful-shutdown
* cleanup handler can call it before the process exits.
*/
export async function flushBandwidthToDb(): Promise<void> {
if (accumulator.size === 0) {
return;
}
// Atomically swap out the accumulator so new data keeps flowing in
// while we write the snapshot to the database.
const snapshot = accumulator;
accumulator = new Map<string, BandwidthAccumulator>();
const currentTime = new Date().toISOString();
// Sort by publicKey for consistent lock ordering across concurrent
// writers — this is the same deadlock-prevention strategy used in the
// original per-message implementation.
const sortedEntries = [...snapshot.entries()].sort(([a], [b]) =>
a.localeCompare(b)
);
logger.debug(
`Flushing accumulated bandwidth data for ${sortedEntries.length} client(s) to the database`
);
for (const [publicKey, { bytesIn, bytesOut }] of sortedEntries) {
try {
await withDeadlockRetry(async () => {
// Use atomic SQL increment to avoid the SELECT-then-UPDATE
// anti-pattern and the races it would introduce.
await db
.update(clients)
.set({
// Note: bytesIn from peer goes to megabytesOut (data
// sent to client) and bytesOut from peer goes to
// megabytesIn (data received from client).
megabytesOut: sql`COALESCE(${clients.megabytesOut}, 0) + ${bytesIn}`,
megabytesIn: sql`COALESCE(${clients.megabytesIn}, 0) + ${bytesOut}`,
lastBandwidthUpdate: currentTime
})
.where(eq(clients.pubKey, publicKey));
}, `flush bandwidth for client ${publicKey}`);
} catch (error) {
logger.error(
`Failed to flush bandwidth for client ${publicKey}:`,
error
);
// Re-queue the failed entry so it is retried on the next flush
// rather than silently dropped.
const existing = accumulator.get(publicKey);
if (existing) {
existing.bytesIn += bytesIn;
existing.bytesOut += bytesOut;
} else {
accumulator.set(publicKey, { bytesIn, bytesOut });
}
}
}
}
const flushTimer = setInterval(async () => {
try {
await flushBandwidthToDb();
} catch (error) {
logger.error("Unexpected error during periodic bandwidth flush:", error);
}
}, FLUSH_INTERVAL_MS);
// Calling unref() means this timer will not keep the Node.js event loop alive
// on its own — the process can still exit normally when there is no other work
// left. The graceful-shutdown path (see server/cleanup.ts) will call
// flushBandwidthToDb() explicitly before process.exit(), so no data is lost.
flushTimer.unref();
export const handleReceiveBandwidthMessage: MessageHandler = async ( export const handleReceiveBandwidthMessage: MessageHandler = async (
context context
) => { ) => {
@@ -164,21 +69,40 @@ export const handleReceiveBandwidthMessage: MessageHandler = async (
throw new Error("Invalid bandwidth data"); throw new Error("Invalid bandwidth data");
} }
// Accumulate the incoming data in memory; the periodic timer (and the // Sort bandwidth data by publicKey to ensure consistent lock ordering across all instances
// shutdown hook) will take care of writing it to the database. // This is critical for preventing deadlocks when multiple instances update the same clients
for (const { publicKey, bytesIn, bytesOut } of bandwidthData) { const sortedBandwidthData = [...bandwidthData].sort((a, b) =>
// Skip peers that haven't transferred any data — writing zeros to the a.publicKey.localeCompare(b.publicKey)
// database would be a no-op anyway. );
if (bytesIn <= 0 && bytesOut <= 0) {
continue;
}
const existing = accumulator.get(publicKey); const currentTime = new Date().toISOString();
if (existing) {
existing.bytesIn += bytesIn; // Update each client individually with retry logic
existing.bytesOut += bytesOut; // This reduces transaction scope and allows retries per-client
} else { for (const peer of sortedBandwidthData) {
accumulator.set(publicKey, { bytesIn, bytesOut }); const { publicKey, bytesIn, bytesOut } = peer;
try {
await withDeadlockRetry(async () => {
// Use atomic SQL increment to avoid SELECT then UPDATE pattern
// This eliminates the need to read the current value first
await db
.update(clients)
.set({
// Note: bytesIn from peer goes to megabytesOut (data sent to client)
// and bytesOut from peer goes to megabytesIn (data received from client)
megabytesOut: sql`COALESCE(${clients.megabytesOut}, 0) + ${bytesIn}`,
megabytesIn: sql`COALESCE(${clients.megabytesIn}, 0) + ${bytesOut}`,
lastBandwidthUpdate: currentTime
})
.where(eq(clients.pubKey, publicKey));
}, `update client bandwidth ${publicKey}`);
} catch (error) {
logger.error(
`Failed to update bandwidth for client ${publicKey}:`,
error
);
// Continue with other clients even if one fails
} }
} }
}; };

View File

@@ -7,4 +7,3 @@ export * from "./handleSocketMessages";
export * from "./handleNewtPingRequestMessage"; export * from "./handleNewtPingRequestMessage";
export * from "./handleApplyBlueprintMessage"; export * from "./handleApplyBlueprintMessage";
export * from "./handleNewtPingMessage"; export * from "./handleNewtPingMessage";
export * from "./handleNewtDisconnectingMessage";

View File

@@ -6,7 +6,6 @@ import {
buildClientConfigurationForNewtClient, buildClientConfigurationForNewtClient,
buildTargetConfigurationForNewtClient buildTargetConfigurationForNewtClient
} from "./buildConfiguration"; } from "./buildConfiguration";
import { canCompress } from "@server/lib/clientVersionChecks";
export async function sendNewtSyncMessage(newt: Newt, site: Site) { export async function sendNewtSyncMessage(newt: Newt, site: Site) {
const { tcpTargets, udpTargets, validHealthCheckTargets } = const { tcpTargets, udpTargets, validHealthCheckTargets } =
@@ -25,9 +24,7 @@ export async function sendNewtSyncMessage(newt: Newt, site: Site) {
exitNode exitNode
); );
await sendToClient( await sendToClient(newt.newtId, {
newt.newtId,
{
type: "newt/sync", type: "newt/sync",
data: { data: {
proxyTargets: { proxyTargets: {
@@ -38,11 +35,7 @@ export async function sendNewtSyncMessage(newt: Newt, site: Site) {
peers: peers, peers: peers,
clientTargets: targets clientTargets: targets
} }
}, }).catch((error) => {
{
compress: canCompress(newt.version, "newt")
}
).catch((error) => {
logger.warn(`Error sending newt sync message:`, error); logger.warn(`Error sending newt sync message:`, error);
}); });
} }

View File

@@ -2,14 +2,13 @@ import { Target, TargetHealthCheck, db, targetHealthCheck } from "@server/db";
import { sendToClient } from "#dynamic/routers/ws"; import { sendToClient } from "#dynamic/routers/ws";
import logger from "@server/logger"; import logger from "@server/logger";
import { eq, inArray } from "drizzle-orm"; import { eq, inArray } from "drizzle-orm";
import { canCompress } from "@server/lib/clientVersionChecks";
export async function addTargets( export async function addTargets(
newtId: string, newtId: string,
targets: Target[], targets: Target[],
healthCheckData: TargetHealthCheck[], healthCheckData: TargetHealthCheck[],
protocol: string, protocol: string,
version?: string | null port: number | null = null
) { ) {
//create a list of udp and tcp targets //create a list of udp and tcp targets
const payloadTargets = targets.map((target) => { const payloadTargets = targets.map((target) => {
@@ -23,7 +22,7 @@ export async function addTargets(
data: { data: {
targets: payloadTargets targets: payloadTargets
} }
}, { incrementConfigVersion: true, compress: canCompress(version, "newt") }); }, { incrementConfigVersion: true });
// Create a map for quick lookup // Create a map for quick lookup
const healthCheckMap = new Map<number, TargetHealthCheck>(); const healthCheckMap = new Map<number, TargetHealthCheck>();
@@ -104,14 +103,14 @@ export async function addTargets(
data: { data: {
targets: validHealthCheckTargets targets: validHealthCheckTargets
} }
}, { incrementConfigVersion: true, compress: canCompress(version, "newt") }); }, { incrementConfigVersion: true });
} }
export async function removeTargets( export async function removeTargets(
newtId: string, newtId: string,
targets: Target[], targets: Target[],
protocol: string, protocol: string,
version?: string | null port: number | null = null
) { ) {
//create a list of udp and tcp targets //create a list of udp and tcp targets
const payloadTargets = targets.map((target) => { const payloadTargets = targets.map((target) => {
@@ -136,5 +135,5 @@ export async function removeTargets(
data: { data: {
ids: healthCheckTargets ids: healthCheckTargets
} }
}, { incrementConfigVersion: true, compress: canCompress(version, "newt") }); }, { incrementConfigVersion: true });
} }

View File

@@ -1,17 +1,5 @@
import { import { Client, clientSiteResourcesAssociationsCache, clientSitesAssociationsCache, db, exitNodes, siteResources, sites } from "@server/db";
Client, import { generateAliasConfig, generateRemoteSubnets } from "@server/lib/ip";
clientSiteResourcesAssociationsCache,
clientSitesAssociationsCache,
db,
exitNodes,
siteResources,
sites
} from "@server/db";
import {
Alias,
generateAliasConfig,
generateRemoteSubnets
} from "@server/lib/ip";
import logger from "@server/logger"; import logger from "@server/logger";
import { and, eq } from "drizzle-orm"; import { and, eq } from "drizzle-orm";
import { addPeer, deletePeer } from "../newt/peers"; import { addPeer, deletePeer } from "../newt/peers";
@@ -20,19 +8,9 @@ import config from "@server/lib/config";
export async function buildSiteConfigurationForOlmClient( export async function buildSiteConfigurationForOlmClient(
client: Client, client: Client,
publicKey: string | null, publicKey: string | null,
relay: boolean, relay: boolean
jitMode: boolean = false
) { ) {
const siteConfigurations: { const siteConfigurations = [];
siteId: number;
name?: string
endpoint?: string
publicKey?: string
serverIP?: string | null
serverPort?: number | null
remoteSubnets?: string[];
aliases: Alias[];
}[] = [];
// Get all sites data // Get all sites data
const sitesData = await db const sitesData = await db
@@ -49,40 +27,6 @@ export async function buildSiteConfigurationForOlmClient(
sites: site, sites: site,
clientSitesAssociationsCache: association clientSitesAssociationsCache: association
} of sitesData) { } of sitesData) {
const allSiteResources = await db // only get the site resources that this client has access to
.select()
.from(siteResources)
.innerJoin(
clientSiteResourcesAssociationsCache,
eq(
siteResources.siteResourceId,
clientSiteResourcesAssociationsCache.siteResourceId
)
)
.where(
and(
eq(siteResources.siteId, site.siteId),
eq(
clientSiteResourcesAssociationsCache.clientId,
client.clientId
)
)
);
if (jitMode) {
// Add site configuration to the array
siteConfigurations.push({
siteId: site.siteId,
// remoteSubnets: generateRemoteSubnets(
// allSiteResources.map(({ siteResources }) => siteResources)
// ),
aliases: generateAliasConfig(
allSiteResources.map(({ siteResources }) => siteResources)
)
});
continue;
}
if (!site.exitNodeId) { if (!site.exitNodeId) {
logger.warn( logger.warn(
`Site ${site.siteId} does not have exit node, skipping` `Site ${site.siteId} does not have exit node, skipping`
@@ -98,13 +42,6 @@ export async function buildSiteConfigurationForOlmClient(
continue; continue;
} }
if (!site.publicKey || site.publicKey == "") { // the site is not ready to accept new peers
logger.warn(
`Site ${site.siteId} has no public key, skipping`
);
continue;
}
// if (site.lastHolePunch && now - site.lastHolePunch > 6 && relay) { // if (site.lastHolePunch && now - site.lastHolePunch > 6 && relay) {
// logger.warn( // logger.warn(
// `Site ${site.siteId} last hole punch is too old, skipping` // `Site ${site.siteId} last hole punch is too old, skipping`
@@ -166,6 +103,26 @@ export async function buildSiteConfigurationForOlmClient(
relayEndpoint = `${exitNode.endpoint}:${config.getRawConfig().gerbil.clients_start_port}`; relayEndpoint = `${exitNode.endpoint}:${config.getRawConfig().gerbil.clients_start_port}`;
} }
const allSiteResources = await db // only get the site resources that this client has access to
.select()
.from(siteResources)
.innerJoin(
clientSiteResourcesAssociationsCache,
eq(
siteResources.siteResourceId,
clientSiteResourcesAssociationsCache.siteResourceId
)
)
.where(
and(
eq(siteResources.siteId, site.siteId),
eq(
clientSiteResourcesAssociationsCache.clientId,
client.clientId
)
)
);
// Add site configuration to the array // Add site configuration to the array
siteConfigurations.push({ siteConfigurations.push({
siteId: site.siteId, siteId: site.siteId,

View File

@@ -6,7 +6,7 @@ import logger from "@server/logger";
/** /**
* Handles disconnecting messages from clients to show disconnected in the ui * Handles disconnecting messages from clients to show disconnected in the ui
*/ */
export const handleOlmDisconnectingMessage: MessageHandler = async (context) => { export const handleOlmDisconnecingMessage: MessageHandler = async (context) => {
const { message, client: c, sendToClient } = context; const { message, client: c, sendToClient } = context;
const olm = c as Olm; const olm = c as Olm;

View File

@@ -17,9 +17,6 @@ import { getUserDeviceName } from "@server/db/names";
import { buildSiteConfigurationForOlmClient } from "./buildConfiguration"; import { buildSiteConfigurationForOlmClient } from "./buildConfiguration";
import { OlmErrorCodes, sendOlmError } from "./error"; import { OlmErrorCodes, sendOlmError } from "./error";
import { handleFingerprintInsertion } from "./fingerprintingUtils"; import { handleFingerprintInsertion } from "./fingerprintingUtils";
import { Alias } from "@server/lib/ip";
import { build } from "@server/build";
import { canCompress } from "@server/lib/clientVersionChecks";
export const handleOlmRegisterMessage: MessageHandler = async (context) => { export const handleOlmRegisterMessage: MessageHandler = async (context) => {
logger.info("Handling register olm message!"); logger.info("Handling register olm message!");
@@ -210,32 +207,6 @@ export const handleOlmRegisterMessage: MessageHandler = async (context) => {
} }
} }
// Get all sites data
const sitesCountResult = await db
.select({ count: count() })
.from(sites)
.innerJoin(
clientSitesAssociationsCache,
eq(sites.siteId, clientSitesAssociationsCache.siteId)
)
.where(eq(clientSitesAssociationsCache.clientId, client.clientId));
// Extract the count value from the result array
const sitesCount =
sitesCountResult.length > 0 ? sitesCountResult[0].count : 0;
// Prepare an array to store site configurations
logger.debug(`Found ${sitesCount} sites for client ${client.clientId}`);
let jitMode = false;
if (sitesCount > 250 && build == "saas") {
// THIS IS THE MAX ON THE BUSINESS TIER
// we have too many sites
// If we have too many sites we need to drop into fully JIT mode by not sending any of the sites
logger.info("Too many sites (%d), dropping into JIT mode", sitesCount);
jitMode = true;
}
logger.debug( logger.debug(
`Olm client ID: ${client.clientId}, Public Key: ${publicKey}, Relay: ${relay}` `Olm client ID: ${client.clientId}, Public Key: ${publicKey}, Relay: ${relay}`
); );
@@ -262,12 +233,28 @@ export const handleOlmRegisterMessage: MessageHandler = async (context) => {
await db await db
.update(clientSitesAssociationsCache) .update(clientSitesAssociationsCache)
.set({ .set({
isRelayed: relay == true, isRelayed: relay == true
isJitMode: jitMode
}) })
.where(eq(clientSitesAssociationsCache.clientId, client.clientId)); .where(eq(clientSitesAssociationsCache.clientId, client.clientId));
} }
// Get all sites data
const sitesCountResult = await db
.select({ count: count() })
.from(sites)
.innerJoin(
clientSitesAssociationsCache,
eq(sites.siteId, clientSitesAssociationsCache.siteId)
)
.where(eq(clientSitesAssociationsCache.clientId, client.clientId));
// Extract the count value from the result array
const sitesCount =
sitesCountResult.length > 0 ? sitesCountResult[0].count : 0;
// Prepare an array to store site configurations
logger.debug(`Found ${sitesCount} sites for client ${client.clientId}`);
// this prevents us from accepting a register from an olm that has not hole punched yet. // this prevents us from accepting a register from an olm that has not hole punched yet.
// the olm will pump the register so we can keep checking // the olm will pump the register so we can keep checking
// TODO: I still think there is a better way to do this rather than locking it out here but ??? // TODO: I still think there is a better way to do this rather than locking it out here but ???
@@ -282,10 +269,15 @@ export const handleOlmRegisterMessage: MessageHandler = async (context) => {
const siteConfigurations = await buildSiteConfigurationForOlmClient( const siteConfigurations = await buildSiteConfigurationForOlmClient(
client, client,
publicKey, publicKey,
relay, relay
jitMode
); );
// REMOVED THIS SO IT CREATES THE INTERFACE AND JUST WAITS FOR THE SITES
// if (siteConfigurations.length === 0) {
// logger.warn("No valid site configurations found");
// return;
// }
// Return connect message with all site configurations // Return connect message with all site configurations
return { return {
message: { message: {
@@ -296,9 +288,6 @@ export const handleOlmRegisterMessage: MessageHandler = async (context) => {
utilitySubnet: org.utilitySubnet utilitySubnet: org.utilitySubnet
} }
}, },
options: {
compress: canCompress(olm.version, "olm")
},
broadcast: false, broadcast: false,
excludeSender: false excludeSender: false
}; };

View File

@@ -18,7 +18,7 @@ export const handleOlmRelayMessage: MessageHandler = async (context) => {
} }
if (!olm.clientId) { if (!olm.clientId) {
logger.warn("Olm has no client!"); logger.warn("Olm has no site!"); // TODO: Maybe we create the site here?
return; return;
} }
@@ -41,7 +41,7 @@ export const handleOlmRelayMessage: MessageHandler = async (context) => {
return; return;
} }
const { siteId, chainId } = message.data; const { siteId } = message.data;
// Get the site // Get the site
const [site] = await db const [site] = await db
@@ -90,8 +90,7 @@ export const handleOlmRelayMessage: MessageHandler = async (context) => {
data: { data: {
siteId: siteId, siteId: siteId,
relayEndpoint: exitNode.endpoint, relayEndpoint: exitNode.endpoint,
relayPort: config.getRawConfig().gerbil.clients_start_port, relayPort: config.getRawConfig().gerbil.clients_start_port
chainId
} }
}, },
broadcast: false, broadcast: false,

View File

@@ -1,241 +0,0 @@
import {
clientSiteResourcesAssociationsCache,
clientSitesAssociationsCache,
db,
exitNodes,
Site,
siteResources
} from "@server/db";
import { MessageHandler } from "@server/routers/ws";
import { clients, Olm, sites } from "@server/db";
import { and, eq, or } from "drizzle-orm";
import logger from "@server/logger";
import { initPeerAddHandshake } from "./peers";
export const handleOlmServerInitAddPeerHandshake: MessageHandler = async (
context
) => {
logger.info("Handling register olm message!");
const { message, client: c, sendToClient } = context;
const olm = c as Olm;
if (!olm) {
logger.warn("Olm not found");
return;
}
if (!olm.clientId) {
logger.warn("Olm has no client!"); // TODO: Maybe we create the site here?
return;
}
const clientId = olm.clientId;
const [client] = await db
.select()
.from(clients)
.where(eq(clients.clientId, clientId))
.limit(1);
if (!client) {
logger.warn("Client not found");
return;
}
const { siteId, resourceId, chainId } = message.data;
let site: Site | null = null;
if (siteId) {
// get the site
const [siteRes] = await db
.select()
.from(sites)
.where(eq(sites.siteId, siteId))
.limit(1);
if (siteRes) {
site = siteRes;
}
}
if (resourceId && !site) {
const resources = await db
.select()
.from(siteResources)
.where(
and(
or(
eq(siteResources.niceId, resourceId),
eq(siteResources.alias, resourceId)
),
eq(siteResources.orgId, client.orgId)
)
);
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);
});
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`
);
return;
}
const resource = resources[0];
const currentResourceAssociationCaches = await db
.select()
.from(clientSiteResourcesAssociationsCache)
.where(
and(
eq(
clientSiteResourcesAssociationsCache.siteResourceId,
resource.siteResourceId
),
eq(
clientSiteResourcesAssociationsCache.clientId,
client.clientId
)
)
);
if (currentResourceAssociationCaches.length === 0) {
logger.error(
`handleOlmServerPeerAddMessage: 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);
});
return;
}
const siteIdFromResource = resource.siteId;
// get the site
const [siteRes] = await db
.select()
.from(sites)
.where(eq(sites.siteId, siteIdFromResource));
if (!siteRes) {
logger.error(
`handleOlmServerPeerAddMessage: Site with ID ${site} not found`
);
return;
}
site = siteRes;
}
if (!site) {
logger.error(`handleOlmServerPeerAddMessage: Site not found`);
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)
)
);
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,
{
type: "olm/wg/peer/chain/cancel",
data: {
chainId
}
},
{ 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`
);
return;
}
// also trigger the peer add handshake in case the peer was not already added to the olm and we need to hole punch
// if it has already been added this will be a no-op
await initPeerAddHandshake(
// this will kick off the add peer process for the client
client.clientId,
{
siteId: site.siteId,
exitNode: {
publicKey: exitNode.publicKey,
endpoint: exitNode.endpoint
}
},
olm.olmId,
chainId
);
return;
};

View File

@@ -54,7 +54,7 @@ export const handleOlmServerPeerAddMessage: MessageHandler = async (
return; return;
} }
const { siteId, chainId } = message.data; const { siteId } = message.data;
// get the site // get the site
const [site] = await db const [site] = await db
@@ -179,8 +179,7 @@ export const handleOlmServerPeerAddMessage: MessageHandler = async (
), ),
aliases: generateAliasConfig( aliases: generateAliasConfig(
allSiteResources.map(({ siteResources }) => siteResources) allSiteResources.map(({ siteResources }) => siteResources)
), )
chainId: chainId,
} }
}, },
broadcast: false, broadcast: false,

View File

@@ -17,7 +17,7 @@ export const handleOlmUnRelayMessage: MessageHandler = async (context) => {
} }
if (!olm.clientId) { if (!olm.clientId) {
logger.warn("Olm has no client!"); logger.warn("Olm has no site!"); // TODO: Maybe we create the site here?
return; return;
} }
@@ -40,7 +40,7 @@ export const handleOlmUnRelayMessage: MessageHandler = async (context) => {
return; return;
} }
const { siteId, chainId } = message.data; const { siteId } = message.data;
// Get the site // Get the site
const [site] = await db const [site] = await db
@@ -87,8 +87,7 @@ export const handleOlmUnRelayMessage: MessageHandler = async (context) => {
type: "olm/wg/peer/unrelay", type: "olm/wg/peer/unrelay",
data: { data: {
siteId: siteId, siteId: siteId,
endpoint: site.endpoint, endpoint: site.endpoint
chainId
} }
}, },
broadcast: false, broadcast: false,

View File

@@ -11,4 +11,3 @@ export * from "./handleOlmServerPeerAddMessage";
export * from "./handleOlmUnRelayMessage"; export * from "./handleOlmUnRelayMessage";
export * from "./recoverOlmWithFingerprint"; export * from "./recoverOlmWithFingerprint";
export * from "./handleOlmDisconnectingMessage"; export * from "./handleOlmDisconnectingMessage";
export * from "./handleOlmServerInitAddPeerHandshake";

View File

@@ -1,9 +1,8 @@
import { sendToClient } from "#dynamic/routers/ws"; import { sendToClient } from "#dynamic/routers/ws";
import { clientSitesAssociationsCache, db, olms } from "@server/db"; import { db, olms } from "@server/db";
import { canCompress } from "@server/lib/clientVersionChecks";
import config from "@server/lib/config"; import config from "@server/lib/config";
import logger from "@server/logger"; import logger from "@server/logger";
import { and, eq } from "drizzle-orm"; import { eq } from "drizzle-orm";
import { Alias } from "yaml"; import { Alias } from "yaml";
export async function addPeer( export async function addPeer(
@@ -19,8 +18,7 @@ export async function addPeer(
remoteSubnets: string[] | null; // optional, comma-separated list of subnets that this site can access remoteSubnets: string[] | null; // optional, comma-separated list of subnets that this site can access
aliases: Alias[]; aliases: Alias[];
}, },
olmId?: string, olmId?: string
version?: string | null
) { ) {
if (!olmId) { if (!olmId) {
const [olm] = await db const [olm] = await db
@@ -32,7 +30,6 @@ export async function addPeer(
return; // ignore this because an olm might not be associated with the client anymore return; // ignore this because an olm might not be associated with the client anymore
} }
olmId = olm.olmId; olmId = olm.olmId;
version = olm.version;
} }
await sendToClient( await sendToClient(
@@ -51,7 +48,7 @@ export async function addPeer(
aliases: peer.aliases aliases: peer.aliases
} }
}, },
{ incrementConfigVersion: true, compress: canCompress(version, "olm") } { incrementConfigVersion: true }
).catch((error) => { ).catch((error) => {
logger.warn(`Error sending message:`, error); logger.warn(`Error sending message:`, error);
}); });
@@ -63,8 +60,7 @@ export async function deletePeer(
clientId: number, clientId: number,
siteId: number, siteId: number,
publicKey: string, publicKey: string,
olmId?: string, olmId?: string
version?: string | null
) { ) {
if (!olmId) { if (!olmId) {
const [olm] = await db const [olm] = await db
@@ -76,7 +72,6 @@ export async function deletePeer(
return; return;
} }
olmId = olm.olmId; olmId = olm.olmId;
version = olm.version;
} }
await sendToClient( await sendToClient(
@@ -88,7 +83,7 @@ export async function deletePeer(
siteId: siteId siteId: siteId
} }
}, },
{ incrementConfigVersion: true, compress: canCompress(version, "olm") } { incrementConfigVersion: true }
).catch((error) => { ).catch((error) => {
logger.warn(`Error sending message:`, error); logger.warn(`Error sending message:`, error);
}); });
@@ -108,8 +103,7 @@ export async function updatePeer(
remoteSubnets?: string[] | null; // optional, comma-separated list of subnets that remoteSubnets?: string[] | null; // optional, comma-separated list of subnets that
aliases?: Alias[] | null; aliases?: Alias[] | null;
}, },
olmId?: string, olmId?: string
version?: string | null
) { ) {
if (!olmId) { if (!olmId) {
const [olm] = await db const [olm] = await db
@@ -121,7 +115,6 @@ export async function updatePeer(
return; return;
} }
olmId = olm.olmId; olmId = olm.olmId;
version = olm.version;
} }
await sendToClient( await sendToClient(
@@ -139,7 +132,7 @@ export async function updatePeer(
aliases: peer.aliases aliases: peer.aliases
} }
}, },
{ incrementConfigVersion: true, compress: canCompress(version, "olm") } { incrementConfigVersion: true }
).catch((error) => { ).catch((error) => {
logger.warn(`Error sending message:`, error); logger.warn(`Error sending message:`, error);
}); });
@@ -156,8 +149,7 @@ export async function initPeerAddHandshake(
endpoint: string; endpoint: string;
}; };
}, },
olmId?: string, olmId?: string
chainId?: string
) { ) {
if (!olmId) { if (!olmId) {
const [olm] = await db const [olm] = await db
@@ -181,8 +173,7 @@ export async function initPeerAddHandshake(
publicKey: peer.exitNode.publicKey, publicKey: peer.exitNode.publicKey,
relayPort: config.getRawConfig().gerbil.clients_start_port, relayPort: config.getRawConfig().gerbil.clients_start_port,
endpoint: peer.exitNode.endpoint endpoint: peer.exitNode.endpoint
}, }
chainId
} }
}, },
{ incrementConfigVersion: true } { incrementConfigVersion: true }
@@ -190,17 +181,6 @@ export async function initPeerAddHandshake(
logger.warn(`Error sending message:`, error); logger.warn(`Error sending message:`, error);
}); });
// update the clientSiteAssociationsCache to make the isJitMode flag false so that JIT mode is disabled for this site if it restarts or something after the connection
await db
.update(clientSitesAssociationsCache)
.set({ isJitMode: false })
.where(
and(
eq(clientSitesAssociationsCache.clientId, clientId),
eq(clientSitesAssociationsCache.siteId, peer.siteId)
)
);
logger.info( logger.info(
`Initiated peer add handshake for site ${peer.siteId} to olm ${olmId}` `Initiated peer add handshake for site ${peer.siteId} to olm ${olmId}`
); );

View File

@@ -1,17 +1,9 @@
import { import { Client, db, exitNodes, Olm, sites, clientSitesAssociationsCache } from "@server/db";
Client,
db,
exitNodes,
Olm,
sites,
clientSitesAssociationsCache
} from "@server/db";
import { buildSiteConfigurationForOlmClient } from "./buildConfiguration"; import { buildSiteConfigurationForOlmClient } from "./buildConfiguration";
import { sendToClient } from "#dynamic/routers/ws"; import { sendToClient } from "#dynamic/routers/ws";
import logger from "@server/logger"; import logger from "@server/logger";
import { eq, inArray } from "drizzle-orm"; import { eq, inArray } from "drizzle-orm";
import config from "@server/lib/config"; import config from "@server/lib/config";
import { canCompress } from "@server/lib/clientVersionChecks";
export async function sendOlmSyncMessage(olm: Olm, client: Client) { export async function sendOlmSyncMessage(olm: Olm, client: Client) {
// NOTE: WE ARE HARDCODING THE RELAY PARAMETER TO FALSE HERE BUT IN THE REGISTER MESSAGE ITS DEFINED BY THE CLIENT // NOTE: WE ARE HARDCODING THE RELAY PARAMETER TO FALSE HERE BUT IN THE REGISTER MESSAGE ITS DEFINED BY THE CLIENT
@@ -25,7 +17,10 @@ export async function sendOlmSyncMessage(olm: Olm, client: Client) {
const clientSites = await db const clientSites = await db
.select() .select()
.from(clientSitesAssociationsCache) .from(clientSitesAssociationsCache)
.innerJoin(sites, eq(sites.siteId, clientSitesAssociationsCache.siteId)) .innerJoin(
sites,
eq(sites.siteId, clientSitesAssociationsCache.siteId)
)
.where(eq(clientSitesAssociationsCache.clientId, client.clientId)); .where(eq(clientSitesAssociationsCache.clientId, client.clientId));
// Extract unique exit node IDs // Extract unique exit node IDs
@@ -73,20 +68,13 @@ export async function sendOlmSyncMessage(olm: Olm, client: Client) {
logger.debug("sendOlmSyncMessage: sending sync message"); logger.debug("sendOlmSyncMessage: sending sync message");
await sendToClient( await sendToClient(olm.olmId, {
olm.olmId,
{
type: "olm/sync", type: "olm/sync",
data: { data: {
sites: siteConfigurations, sites: siteConfigurations,
exitNodes: exitNodesData exitNodes: exitNodesData
} }
}, }).catch((error) => {
{
compress: canCompress(olm.version, "olm")
}
).catch((error) => {
logger.warn(`Error sending olm sync message:`, error); logger.warn(`Error sending olm sync message:`, error);
}); });
} }

View File

@@ -223,20 +223,6 @@ async function createHttpResource(
); );
} }
// Prevent creating resource with same domain as dashboard
const dashboardUrl = config.getRawConfig().app.dashboard_url;
if (dashboardUrl) {
const dashboardHost = new URL(dashboardUrl).hostname;
if (fullDomain === dashboardHost) {
return next(
createHttpError(
HttpCode.CONFLICT,
"Resource domain cannot be the same as the dashboard domain"
)
);
}
}
if (build != "oss") { if (build != "oss") {
const existingLoginPages = await db const existingLoginPages = await db
.select() .select()

View File

@@ -353,20 +353,6 @@ async function updateHttpResource(
); );
} }
// Prevent updating resource with same domain as dashboard
const dashboardUrl = config.getRawConfig().app.dashboard_url;
if (dashboardUrl) {
const dashboardHost = new URL(dashboardUrl).hostname;
if (fullDomain === dashboardHost) {
return next(
createHttpError(
HttpCode.CONFLICT,
"Resource domain cannot be the same as the dashboard domain"
)
);
}
}
if (build != "oss") { if (build != "oss") {
const existingLoginPages = await db const existingLoginPages = await db
.select() .select()

View File

@@ -620,7 +620,7 @@ export async function handleMessagingForUpdatedSiteResource(
await updateTargets(newt.newtId, { await updateTargets(newt.newtId, {
oldTargets: oldTargets, oldTargets: oldTargets,
newTargets: newTargets newTargets: newTargets
}, newt.version); });
} }
const olmJobs: Promise<void>[] = []; const olmJobs: Promise<void>[] = [];

View File

@@ -264,7 +264,7 @@ export async function createTarget(
newTarget, newTarget,
healthCheck, healthCheck,
resource.protocol, resource.protocol,
newt.version resource.proxyPort
); );
} }
} }

View File

@@ -262,7 +262,7 @@ export async function updateTarget(
[updatedTarget], [updatedTarget],
[updatedHc], [updatedHc],
resource.protocol, resource.protocol,
newt.version resource.proxyPort
); );
} }
} }

View File

@@ -1,4 +1,3 @@
import { build } from "@server/build";
import { import {
handleNewtRegisterMessage, handleNewtRegisterMessage,
handleReceiveBandwidthMessage, handleReceiveBandwidthMessage,
@@ -7,9 +6,7 @@ import {
handleDockerContainersMessage, handleDockerContainersMessage,
handleNewtPingRequestMessage, handleNewtPingRequestMessage,
handleApplyBlueprintMessage, handleApplyBlueprintMessage,
handleNewtPingMessage, handleNewtPingMessage
startNewtOfflineChecker,
handleNewtDisconnectingMessage
} from "../newt"; } from "../newt";
import { import {
handleOlmRegisterMessage, handleOlmRegisterMessage,
@@ -18,8 +15,7 @@ import {
startOlmOfflineChecker, startOlmOfflineChecker,
handleOlmServerPeerAddMessage, handleOlmServerPeerAddMessage,
handleOlmUnRelayMessage, handleOlmUnRelayMessage,
handleOlmDisconnectingMessage, handleOlmDisconnecingMessage
handleOlmServerInitAddPeerHandshake
} from "../olm"; } from "../olm";
import { handleHealthcheckStatusMessage } from "../target"; import { handleHealthcheckStatusMessage } from "../target";
import { handleRoundTripMessage } from "./handleRoundTripMessage"; import { handleRoundTripMessage } from "./handleRoundTripMessage";
@@ -27,13 +23,11 @@ import { MessageHandler } from "./types";
export const messageHandlers: Record<string, MessageHandler> = { export const messageHandlers: Record<string, MessageHandler> = {
"olm/wg/server/peer/add": handleOlmServerPeerAddMessage, "olm/wg/server/peer/add": handleOlmServerPeerAddMessage,
"olm/wg/server/peer/init": handleOlmServerInitAddPeerHandshake,
"olm/wg/register": handleOlmRegisterMessage, "olm/wg/register": handleOlmRegisterMessage,
"olm/wg/relay": handleOlmRelayMessage, "olm/wg/relay": handleOlmRelayMessage,
"olm/wg/unrelay": handleOlmUnRelayMessage, "olm/wg/unrelay": handleOlmUnRelayMessage,
"olm/ping": handleOlmPingMessage, "olm/ping": handleOlmPingMessage,
"olm/disconnecting": handleOlmDisconnectingMessage, "olm/disconnecting": handleOlmDisconnecingMessage,
"newt/disconnecting": handleNewtDisconnectingMessage,
"newt/ping": handleNewtPingMessage, "newt/ping": handleNewtPingMessage,
"newt/wg/register": handleNewtRegisterMessage, "newt/wg/register": handleNewtRegisterMessage,
"newt/wg/get-config": handleGetConfigMessage, "newt/wg/get-config": handleGetConfigMessage,
@@ -46,7 +40,4 @@ export const messageHandlers: Record<string, MessageHandler> = {
"ws/round-trip/complete": handleRoundTripMessage "ws/round-trip/complete": handleRoundTripMessage
}; };
if (build != "saas") { startOlmOfflineChecker(); // this is to handle the offline check for olms
startOlmOfflineChecker(); // this is to handle the offline check for olms
startNewtOfflineChecker(); // this is to handle the offline check for newts
}

View File

@@ -24,7 +24,7 @@ export interface AuthenticatedWebSocket extends WebSocket {
clientType?: ClientType; clientType?: ClientType;
connectionId?: string; connectionId?: string;
isFullyConnected?: boolean; isFullyConnected?: boolean;
pendingMessages?: { data: Buffer; isBinary: boolean }[]; pendingMessages?: Buffer[];
configVersion?: number; configVersion?: number;
} }
@@ -73,7 +73,6 @@ export type MessageHandler = (
// Options for sending messages with config version tracking // Options for sending messages with config version tracking
export interface SendMessageOptions { export interface SendMessageOptions {
incrementConfigVersion?: boolean; incrementConfigVersion?: boolean;
compress?: boolean;
} }
// Redis message type for cross-node communication // Redis message type for cross-node communication

View File

@@ -1,9 +1,8 @@
import { Router, Request, Response } from "express"; import { Router, Request, Response } from "express";
import zlib from "zlib";
import { Server as HttpServer } from "http"; import { Server as HttpServer } from "http";
import { WebSocket, WebSocketServer } from "ws"; import { WebSocket, WebSocketServer } from "ws";
import { Socket } from "net"; import { Socket } from "net";
import { Newt, newts, NewtSession, olms, Olm, OlmSession, sites } from "@server/db"; import { Newt, newts, NewtSession, olms, Olm, OlmSession } from "@server/db";
import { eq } from "drizzle-orm"; import { eq } from "drizzle-orm";
import { db } from "@server/db"; import { db } from "@server/db";
import { validateNewtSessionToken } from "@server/auth/sessions/newt"; import { validateNewtSessionToken } from "@server/auth/sessions/newt";
@@ -117,20 +116,11 @@ const sendToClientLocal = async (
}; };
const messageString = JSON.stringify(messageWithVersion); const messageString = JSON.stringify(messageWithVersion);
if (options.compress) {
const compressed = zlib.gzipSync(Buffer.from(messageString, "utf8"));
clients.forEach((client) => {
if (client.readyState === WebSocket.OPEN) {
client.send(compressed);
}
});
} else {
clients.forEach((client) => { clients.forEach((client) => {
if (client.readyState === WebSocket.OPEN) { if (client.readyState === WebSocket.OPEN) {
client.send(messageString); client.send(messageString);
} }
}); });
}
return true; return true;
}; };
@@ -157,23 +147,12 @@ const broadcastToAllExceptLocal = async (
...message, ...message,
configVersion configVersion
}; };
if (options.compress) {
const compressed = zlib.gzipSync(
Buffer.from(JSON.stringify(messageWithVersion), "utf8")
);
clients.forEach((client) => {
if (client.readyState === WebSocket.OPEN) {
client.send(compressed);
}
});
} else {
clients.forEach((client) => { clients.forEach((client) => {
if (client.readyState === WebSocket.OPEN) { if (client.readyState === WebSocket.OPEN) {
client.send(JSON.stringify(messageWithVersion)); client.send(JSON.stringify(messageWithVersion));
} }
}); });
} }
}
}); });
}; };
@@ -307,12 +286,9 @@ const setupConnection = async (
clientType === "newt" ? (client as Newt).newtId : (client as Olm).olmId; clientType === "newt" ? (client as Newt).newtId : (client as Olm).olmId;
await addClient(clientType, clientId, ws); await addClient(clientType, clientId, ws);
ws.on("message", async (data, isBinary) => { ws.on("message", async (data) => {
try { try {
const messageBuffer = isBinary const message: WSMessage = JSON.parse(data.toString());
? zlib.gunzipSync(data as Buffer)
: (data as Buffer);
const message: WSMessage = JSON.parse(messageBuffer.toString());
if (!message.type || typeof message.type !== "string") { if (!message.type || typeof message.type !== "string") {
throw new Error( throw new Error(
@@ -380,31 +356,6 @@ const setupConnection = async (
); );
}); });
// Handle WebSocket protocol-level pings from older newt clients that do
// not send application-level "newt/ping" messages. Update the site's
// online state and lastPing timestamp so the offline checker treats them
// the same as modern newt clients.
if (clientType === "newt") {
const newtClient = client as Newt;
ws.on("ping", async () => {
if (!newtClient.siteId) return;
try {
await db
.update(sites)
.set({
online: true,
lastPing: Math.floor(Date.now() / 1000)
})
.where(eq(sites.siteId, newtClient.siteId));
} catch (error) {
logger.error(
"Error updating newt site online state on WS ping",
{ error }
);
}
});
}
ws.on("error", (error: Error) => { ws.on("error", (error: Error) => {
logger.error( logger.error(
`WebSocket error for ${clientType.toUpperCase()} ID ${clientId}:`, `WebSocket error for ${clientType.toUpperCase()} ID ${clientId}:`,

View File

@@ -69,7 +69,6 @@ export default async function DomainSettingsPage({
failed={domain.failed} failed={domain.failed}
verified={domain.verified} verified={domain.verified}
type={domain.type} type={domain.type}
errorMessage={domain.errorMessage}
/> />
<DNSRecordsTable records={dnsRecords} type={domain.type} /> <DNSRecordsTable records={dnsRecords} type={domain.type} />

View File

@@ -54,7 +54,6 @@ import {
TooltipProvider, TooltipProvider,
TooltipTrigger TooltipTrigger
} from "@app/components/ui/tooltip"; } from "@app/components/ui/tooltip";
import { Alert, AlertDescription, AlertTitle } from "@app/components/ui/alert";
import { useEnvContext } from "@app/hooks/useEnvContext"; import { useEnvContext } from "@app/hooks/useEnvContext";
import { toast } from "@app/hooks/useToast"; import { toast } from "@app/hooks/useToast";
import { createApiClient, formatAxiosError } from "@app/lib/api"; import { createApiClient, formatAxiosError } from "@app/lib/api";
@@ -66,7 +65,6 @@ import { build } from "@server/build";
import { Resource } from "@server/db"; import { Resource } from "@server/db";
import { isTargetValid } from "@server/lib/validators"; import { isTargetValid } from "@server/lib/validators";
import { ListTargetsResponse } from "@server/routers/target"; import { ListTargetsResponse } from "@server/routers/target";
import { ListRemoteExitNodesResponse } from "@server/routers/remoteExitNode/types";
import { ArrayElement } from "@server/types/ArrayElement"; import { ArrayElement } from "@server/types/ArrayElement";
import { useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
import { import {
@@ -83,7 +81,6 @@ import {
CircleCheck, CircleCheck,
CircleX, CircleX,
Info, Info,
InfoIcon,
Plus, Plus,
Settings, Settings,
SquareArrowOutUpRight SquareArrowOutUpRight
@@ -213,13 +210,6 @@ export default function Page() {
orgQueries.sites({ orgId: orgId as string }) orgQueries.sites({ orgId: orgId as string })
); );
const [remoteExitNodes, setRemoteExitNodes] = useState<
ListRemoteExitNodesResponse["remoteExitNodes"]
>([]);
const [loadingExitNodes, setLoadingExitNodes] = useState(
build === "saas"
);
const [createLoading, setCreateLoading] = useState(false); const [createLoading, setCreateLoading] = useState(false);
const [showSnippets, setShowSnippets] = useState(false); const [showSnippets, setShowSnippets] = useState(false);
const [niceId, setNiceId] = useState<string>(""); const [niceId, setNiceId] = useState<string>("");
@@ -234,27 +224,6 @@ export default function Page() {
useState<LocalTarget | null>(null); useState<LocalTarget | null>(null);
const [healthCheckDialogOpen, setHealthCheckDialogOpen] = useState(false); const [healthCheckDialogOpen, setHealthCheckDialogOpen] = useState(false);
useEffect(() => {
if (build !== "saas") return;
const fetchExitNodes = async () => {
try {
const res = await api.get<
AxiosResponse<ListRemoteExitNodesResponse>
>(`/org/${orgId}/remote-exit-nodes`);
if (res && res.status === 200) {
setRemoteExitNodes(res.data.data.remoteExitNodes);
}
} catch (e) {
console.error("Failed to fetch remote exit nodes:", e);
} finally {
setLoadingExitNodes(false);
}
};
fetchExitNodes();
}, [orgId]);
const [isAdvancedMode, setIsAdvancedMode] = useState(() => { const [isAdvancedMode, setIsAdvancedMode] = useState(() => {
if (typeof window !== "undefined") { if (typeof window !== "undefined") {
const saved = localStorage.getItem("create-advanced-mode"); const saved = localStorage.getItem("create-advanced-mode");
@@ -319,26 +288,16 @@ export default function Page() {
description: t("resourceHTTPDescription") description: t("resourceHTTPDescription")
}, },
...(!env.flags.allowRawResources ...(!env.flags.allowRawResources
? []
: build === "saas" && remoteExitNodes.length === 0
? [] ? []
: [ : [
{ {
id: "raw" as ResourceType, id: "raw" as ResourceType,
title: t("resourceRaw"), title: t("resourceRaw"),
description: description: build == "saas" ? t("resourceRawDescriptionCloud") : t("resourceRawDescription")
build == "saas"
? t("resourceRawDescriptionCloud")
: t("resourceRawDescription")
} }
]) ])
]; ];
// In saas mode with no exit nodes, force HTTP
const showTypeSelector =
build !== "saas" ||
(!loadingExitNodes && remoteExitNodes.length > 0);
const baseForm = useForm({ const baseForm = useForm({
resolver: zodResolver(baseResourceFormSchema), resolver: zodResolver(baseResourceFormSchema),
defaultValues: { defaultValues: {
@@ -600,7 +559,7 @@ export default function Page() {
toast({ toast({
variant: "destructive", variant: "destructive",
title: t("resourceErrorCreate"), title: t("resourceErrorCreate"),
description: formatAxiosError(e, t("resourceErrorCreateMessageDescription")) description: t("resourceErrorCreateMessageDescription")
}); });
} }
@@ -1025,8 +984,7 @@ export default function Page() {
</SettingsSectionTitle> </SettingsSectionTitle>
</SettingsSectionHeader> </SettingsSectionHeader>
<SettingsSectionBody> <SettingsSectionBody>
{showTypeSelector && {resourceTypes.length > 1 && (
resourceTypes.length > 1 && (
<> <>
<div className="mb-2"> <div className="mb-2">
<span className="text-sm font-medium"> <span className="text-sm font-medium">

View File

@@ -84,7 +84,7 @@ const CredenzaContent = ({ className, children, ...props }: CredenzaProps) => {
return ( return (
<CredenzaContent <CredenzaContent
className={cn( className={cn(
"overflow-y-auto max-h-[100dvh] md:max-h-[calc(100vh-clamp(3rem,24vh,400px))] md:top-[clamp(1.5rem,12vh,200px)] md:translate-y-0", "overflow-y-auto max-h-[100dvh] md:max-h-screen md:top-[clamp(1.5rem,12vh,200px)] md:translate-y-0",
className className
)} )}
{...props} {...props}

View File

@@ -10,20 +10,17 @@ import {
import { useTranslations } from "next-intl"; import { useTranslations } from "next-intl";
import { Badge } from "./ui/badge"; import { Badge } from "./ui/badge";
import { useEnvContext } from "@app/hooks/useEnvContext"; import { useEnvContext } from "@app/hooks/useEnvContext";
import { AlertTriangle } from "lucide-react";
type DomainInfoCardProps = { type DomainInfoCardProps = {
failed: boolean; failed: boolean;
verified: boolean; verified: boolean;
type: string | null; type: string | null;
errorMessage?: string | null;
}; };
export default function DomainInfoCard({ export default function DomainInfoCard({
failed, failed,
verified, verified,
type, type
errorMessage
}: DomainInfoCardProps) { }: DomainInfoCardProps) {
const t = useTranslations(); const t = useTranslations();
const env = useEnvContext(); const env = useEnvContext();
@@ -42,7 +39,6 @@ export default function DomainInfoCard({
}; };
return ( return (
<div className="space-y-3">
<Alert> <Alert>
<AlertDescription> <AlertDescription>
<InfoSections cols={3}> <InfoSections cols={3}>
@@ -83,19 +79,5 @@ export default function DomainInfoCard({
</InfoSections> </InfoSections>
</AlertDescription> </AlertDescription>
</Alert> </Alert>
{errorMessage && (failed || !verified) && (
<Alert variant={failed ? "destructive" : "warning"}>
<AlertTriangle className="h-4 w-4" />
<AlertTitle>
{failed
? t("domainErrorTitle", { fallback: "Domain Error" })
: t("domainPendingErrorTitle", { fallback: "Verification Issue" })}
</AlertTitle>
<AlertDescription className="font-mono text-xs break-all">
{errorMessage}
</AlertDescription>
</Alert>
)}
</div>
); );
} }

View File

@@ -27,12 +27,6 @@ import {
DropdownMenuItem, DropdownMenuItem,
DropdownMenuTrigger DropdownMenuTrigger
} from "./ui/dropdown-menu"; } from "./ui/dropdown-menu";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger
} from "./ui/tooltip";
import Link from "next/link"; import Link from "next/link";
export type DomainRow = { export type DomainRow = {
@@ -45,7 +39,6 @@ export type DomainRow = {
configManaged: boolean; configManaged: boolean;
certResolver: string; certResolver: string;
preferWildcardCert: boolean; preferWildcardCert: boolean;
errorMessage?: string | null;
}; };
type Props = { type Props = {
@@ -182,7 +175,7 @@ export default function DomainsTable({ domains, orgId }: Props) {
); );
}, },
cell: ({ row }) => { cell: ({ row }) => {
const { verified, failed, type, errorMessage } = row.original; const { verified, failed, type } = row.original;
if (verified) { if (verified) {
return type == "wildcard" ? ( return type == "wildcard" ? (
<Badge variant="outlinePrimary">{t("manual")}</Badge> <Badge variant="outlinePrimary">{t("manual")}</Badge>
@@ -190,44 +183,12 @@ export default function DomainsTable({ domains, orgId }: Props) {
<Badge variant="green">{t("verified")}</Badge> <Badge variant="green">{t("verified")}</Badge>
); );
} else if (failed) { } else if (failed) {
if (errorMessage) {
return (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Badge variant="red" className="cursor-help">
{t("failed", { fallback: "Failed" })}
</Badge>
</TooltipTrigger>
<TooltipContent className="max-w-xs">
<p className="break-words">{errorMessage}</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
);
}
return ( return (
<Badge variant="red"> <Badge variant="red">
{t("failed", { fallback: "Failed" })} {t("failed", { fallback: "Failed" })}
</Badge> </Badge>
); );
} else { } else {
if (errorMessage) {
return (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Badge variant="yellow" className="cursor-help">
{t("pending")}
</Badge>
</TooltipTrigger>
<TooltipContent className="max-w-xs">
<p className="break-words">{errorMessage}</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
);
}
return <Badge variant="yellow">{t("pending")}</Badge>; return <Badge variant="yellow">{t("pending")}</Badge>;
} }
} }