mirror of
https://github.com/fosrl/pangolin.git
synced 2026-09-11 13:31:27 +02:00
Compare commits
66 Commits
18579c0647
..
1.14.1
| Author | SHA1 | Date | |
|---|---|---|---|
| 2ca400ab16 | |||
| 4183067c77 | |||
| 5eb4691973 | |||
| d14dfbf360 | |||
| 493a5ad02a | |||
| 481beff028 | |||
| f1f7e438b4 | |||
| 00f84c9d8e | |||
| f75b9c6c86 | |||
| 31bc6d5773 | |||
| 51dc1450d3 | |||
| fcbea08c87 | |||
| 8d60a87aa1 | |||
| 956aa64519 | |||
| fd1cb6ca23 | |||
| 37082ae436 | |||
| bb47ca3d2e | |||
| 0dd3c84b24 | |||
| 848fca7e1b | |||
| 2500f99722 | |||
| c7737c444f | |||
| 4d1a7ed69b | |||
| 626d5df67e | |||
| e4c369deec | |||
| 307209e73f | |||
| dc84935ee6 | |||
| 998c1f52ca | |||
| 2766758c66 | |||
| 258d1d82f3 | |||
| 46aaadb76a | |||
| ea7a618810 | |||
| c0e503b31f | |||
| 55f5a41752 | |||
| b0be82be86 | |||
| 96a9bdb700 | |||
| 74e6d39c24 | |||
| 61dfa00222 | |||
| 476281db2b | |||
| f32e31c73d | |||
| ea72279080 | |||
| 16ba56af84 | |||
| f13ddde988 | |||
| 67dc10dfe9 | |||
| 5fd216adc2 | |||
| 6f0268f6c0 | |||
| 2996dfb33a | |||
| c92f2cd4ba | |||
| 8164d5c1ad | |||
| d9d8d85f6e | |||
| d49720703f | |||
| 2362a9b4dd | |||
| a8265a5286 | |||
| 9ea7431b73 | |||
| 37e6f320fe | |||
| c0c0d48edf | |||
| 284cccbe17 | |||
| 81a9a94264 | |||
| dccf101554 | |||
| a01c06bbc7 | |||
| db43cf1b30 | |||
| 2f561b5604 | |||
| 5a30f036ff | |||
| 768b9ffd09 | |||
| 8732e50047 | |||
| d6e0024c96 | |||
| 9759e86921 |
+3
-1
@@ -850,6 +850,7 @@
|
||||
"orgPolicyConfig": "Конфигуриране на достъп за организация",
|
||||
"idpUpdatedDescription": "Идентификационният доставчик беше актуализиран успешно",
|
||||
"redirectUrl": "URL за пренасочване",
|
||||
"orgIdpRedirectUrls": "URL адреси за пренасочване",
|
||||
"redirectUrlAbout": "За URL за пренасочване",
|
||||
"redirectUrlAboutDescription": "Това е URL адресът, към който потребителите ще бъдат пренасочени след удостоверяване. Трябва да конфигурирате този URL адрес в настройките на доставчика на идентичност.",
|
||||
"pangolinAuth": "Authent - Pangolin",
|
||||
@@ -1479,7 +1480,7 @@
|
||||
"IAgreeToThe": "Съгласен съм с",
|
||||
"termsOfService": "условията за ползване",
|
||||
"and": "и",
|
||||
"privacyPolicy": "политиката за поверителност"
|
||||
"privacyPolicy": "политика за поверителност."
|
||||
},
|
||||
"signUpMarketing": {
|
||||
"keepMeInTheLoop": "Дръж ме в течение с новини, актуализации и нови функции чрез имейл."
|
||||
@@ -2349,6 +2350,7 @@
|
||||
"enterConfirmation": "Въведете потвърждение.",
|
||||
"blueprintViewDetails": "Подробности.",
|
||||
"defaultIdentityProvider": "По подразбиране доставчик на идентичност.",
|
||||
"defaultIdentityProviderDescription": "Когато е избран основен доставчик на идентичност, потребителят ще бъде автоматично пренасочен към доставчика за удостоверяване.",
|
||||
"editInternalResourceDialogNetworkSettings": "Мрежови настройки.",
|
||||
"editInternalResourceDialogAccessPolicy": "Политика за достъп.",
|
||||
"editInternalResourceDialogAddRoles": "Добавяне на роли.",
|
||||
|
||||
+3
-1
@@ -850,6 +850,7 @@
|
||||
"orgPolicyConfig": "Konfigurace přístupu pro organizaci",
|
||||
"idpUpdatedDescription": "Poskytovatel identity byl úspěšně aktualizován",
|
||||
"redirectUrl": "Přesměrovat URL",
|
||||
"orgIdpRedirectUrls": "Přesměrovat URL",
|
||||
"redirectUrlAbout": "O přesměrování URL",
|
||||
"redirectUrlAboutDescription": "Toto je URL, na kterou budou uživatelé po ověření přesměrováni. Tuto URL je třeba nastavit v nastavení poskytovatele identity.",
|
||||
"pangolinAuth": "Auth - Pangolin",
|
||||
@@ -1479,7 +1480,7 @@
|
||||
"IAgreeToThe": "Souhlasím s",
|
||||
"termsOfService": "podmínky služby",
|
||||
"and": "a",
|
||||
"privacyPolicy": "zásady ochrany osobních údajů"
|
||||
"privacyPolicy": "zásady ochrany osobních údajů."
|
||||
},
|
||||
"signUpMarketing": {
|
||||
"keepMeInTheLoop": "Udržujte mě ve smyčce s novinkami, aktualizacemi a novými funkcemi e-mailem."
|
||||
@@ -2349,6 +2350,7 @@
|
||||
"enterConfirmation": "Zadejte potvrzení",
|
||||
"blueprintViewDetails": "Detaily",
|
||||
"defaultIdentityProvider": "Výchozí poskytovatel identity",
|
||||
"defaultIdentityProviderDescription": "Pokud je vybrán výchozí poskytovatel identity, uživatel bude automaticky přesměrován na poskytovatele pro ověření.",
|
||||
"editInternalResourceDialogNetworkSettings": "Nastavení sítě",
|
||||
"editInternalResourceDialogAccessPolicy": "Přístupová politika",
|
||||
"editInternalResourceDialogAddRoles": "Přidat role",
|
||||
|
||||
+3
-1
@@ -850,6 +850,7 @@
|
||||
"orgPolicyConfig": "Zugriff für eine Organisation konfigurieren",
|
||||
"idpUpdatedDescription": "Identitätsanbieter erfolgreich aktualisiert",
|
||||
"redirectUrl": "Weiterleitungs-URL",
|
||||
"orgIdpRedirectUrls": "Umleitungs-URLs",
|
||||
"redirectUrlAbout": "Über die Weiterleitungs-URL",
|
||||
"redirectUrlAboutDescription": "Dies ist die URL, zu der Benutzer nach der Authentifizierung umgeleitet werden. Sie müssen diese URL in den Einstellungen des Identity Providers konfigurieren.",
|
||||
"pangolinAuth": "Authentifizierung - Pangolin",
|
||||
@@ -1479,7 +1480,7 @@
|
||||
"IAgreeToThe": "Ich stimme den",
|
||||
"termsOfService": "Nutzungsbedingungen zu",
|
||||
"and": "und",
|
||||
"privacyPolicy": "Datenschutzrichtlinie"
|
||||
"privacyPolicy": "datenschutzrichtlinie."
|
||||
},
|
||||
"signUpMarketing": {
|
||||
"keepMeInTheLoop": "Halten Sie mich auf dem Laufenden mit Neuigkeiten, Updates und neuen Funktionen per E-Mail."
|
||||
@@ -2349,6 +2350,7 @@
|
||||
"enterConfirmation": "Bestätigung eingeben",
|
||||
"blueprintViewDetails": "Details",
|
||||
"defaultIdentityProvider": "Standard Identitätsanbieter",
|
||||
"defaultIdentityProviderDescription": "Wenn ein Standard-Identity Provider ausgewählt ist, wird der Benutzer zur Authentifizierung automatisch an den Anbieter weitergeleitet.",
|
||||
"editInternalResourceDialogNetworkSettings": "Netzwerkeinstellungen",
|
||||
"editInternalResourceDialogAccessPolicy": "Zugriffsrichtlinie",
|
||||
"editInternalResourceDialogAddRoles": "Rollen hinzufügen",
|
||||
|
||||
+1
-1
@@ -1480,7 +1480,7 @@
|
||||
"IAgreeToThe": "I agree to the",
|
||||
"termsOfService": "terms of service",
|
||||
"and": "and",
|
||||
"privacyPolicy": "privacy policy"
|
||||
"privacyPolicy": "privacy policy."
|
||||
},
|
||||
"signUpMarketing": {
|
||||
"keepMeInTheLoop": "Keep me in the loop with news, updates, and new features by email."
|
||||
|
||||
+3
-1
@@ -850,6 +850,7 @@
|
||||
"orgPolicyConfig": "Configurar acceso para una organización",
|
||||
"idpUpdatedDescription": "Proveedor de identidad actualizado correctamente",
|
||||
"redirectUrl": "URL de redirección",
|
||||
"orgIdpRedirectUrls": "Redirigir URL",
|
||||
"redirectUrlAbout": "Acerca de la URL de redirección",
|
||||
"redirectUrlAboutDescription": "Esta es la URL a la que los usuarios serán redireccionados después de la autenticación. Necesitas configurar esta URL en la configuración del proveedor de identidad.",
|
||||
"pangolinAuth": "Autenticación - Pangolin",
|
||||
@@ -1479,7 +1480,7 @@
|
||||
"IAgreeToThe": "Estoy de acuerdo con los",
|
||||
"termsOfService": "términos del servicio",
|
||||
"and": "y",
|
||||
"privacyPolicy": "política de privacidad"
|
||||
"privacyPolicy": "política de privacidad."
|
||||
},
|
||||
"signUpMarketing": {
|
||||
"keepMeInTheLoop": "Mantenerme en el bucle con noticias, actualizaciones y nuevas características por correo electrónico."
|
||||
@@ -2349,6 +2350,7 @@
|
||||
"enterConfirmation": "Ingresar confirmación",
|
||||
"blueprintViewDetails": "Detalles",
|
||||
"defaultIdentityProvider": "Proveedor de identidad predeterminado",
|
||||
"defaultIdentityProviderDescription": "Cuando se selecciona un proveedor de identidad por defecto, el usuario será redirigido automáticamente al proveedor de autenticación.",
|
||||
"editInternalResourceDialogNetworkSettings": "Configuración de red",
|
||||
"editInternalResourceDialogAccessPolicy": "Política de acceso",
|
||||
"editInternalResourceDialogAddRoles": "Agregar roles",
|
||||
|
||||
+3
-1
@@ -850,6 +850,7 @@
|
||||
"orgPolicyConfig": "Configurer l'accès pour une organisation",
|
||||
"idpUpdatedDescription": "Fournisseur d'identité mis à jour avec succès",
|
||||
"redirectUrl": "URL de redirection",
|
||||
"orgIdpRedirectUrls": "URL de redirection",
|
||||
"redirectUrlAbout": "À propos de l'URL de redirection",
|
||||
"redirectUrlAboutDescription": "C'est l'URL vers laquelle les utilisateurs seront redirigés après l'authentification. Vous devez configurer cette URL dans les paramètres du fournisseur d'identité.",
|
||||
"pangolinAuth": "Auth - Pangolin",
|
||||
@@ -1479,7 +1480,7 @@
|
||||
"IAgreeToThe": "Je suis d'accord avec",
|
||||
"termsOfService": "les conditions d'utilisation",
|
||||
"and": "et",
|
||||
"privacyPolicy": "la politique de confidentialité"
|
||||
"privacyPolicy": "politique de confidentialité."
|
||||
},
|
||||
"signUpMarketing": {
|
||||
"keepMeInTheLoop": "Gardez-moi dans la boucle avec des nouvelles, des mises à jour et de nouvelles fonctionnalités par courriel."
|
||||
@@ -2349,6 +2350,7 @@
|
||||
"enterConfirmation": "Entrez la confirmation",
|
||||
"blueprintViewDetails": "Détails",
|
||||
"defaultIdentityProvider": "Fournisseur d'identité par défaut",
|
||||
"defaultIdentityProviderDescription": "Lorsqu'un fournisseur d'identité par défaut est sélectionné, l'utilisateur sera automatiquement redirigé vers le fournisseur pour authentification.",
|
||||
"editInternalResourceDialogNetworkSettings": "Paramètres réseau",
|
||||
"editInternalResourceDialogAccessPolicy": "Politique d'accès",
|
||||
"editInternalResourceDialogAddRoles": "Ajouter des rôles",
|
||||
|
||||
+3
-1
@@ -850,6 +850,7 @@
|
||||
"orgPolicyConfig": "Configura l'accesso per un'organizzazione",
|
||||
"idpUpdatedDescription": "Provider di identità aggiornato con successo",
|
||||
"redirectUrl": "URL di Reindirizzamento",
|
||||
"orgIdpRedirectUrls": "Reindirizza URL",
|
||||
"redirectUrlAbout": "Informazioni sull'URL di Reindirizzamento",
|
||||
"redirectUrlAboutDescription": "Questo è l'URL a cui gli utenti saranno reindirizzati dopo l'autenticazione. È necessario configurare questo URL nelle impostazioni del provider di identità.",
|
||||
"pangolinAuth": "Autenticazione - Pangolina",
|
||||
@@ -1479,7 +1480,7 @@
|
||||
"IAgreeToThe": "Accetto i",
|
||||
"termsOfService": "termini di servizio",
|
||||
"and": "e",
|
||||
"privacyPolicy": "informativa sulla privacy"
|
||||
"privacyPolicy": "informativa sulla privacy."
|
||||
},
|
||||
"signUpMarketing": {
|
||||
"keepMeInTheLoop": "Tienimi in loop con notizie, aggiornamenti e nuove funzionalità via e-mail."
|
||||
@@ -2349,6 +2350,7 @@
|
||||
"enterConfirmation": "Inserisci conferma",
|
||||
"blueprintViewDetails": "Dettagli",
|
||||
"defaultIdentityProvider": "Provider di Identità Predefinito",
|
||||
"defaultIdentityProviderDescription": "Quando viene selezionato un provider di identità predefinito, l'utente verrà automaticamente reindirizzato al provider per l'autenticazione.",
|
||||
"editInternalResourceDialogNetworkSettings": "Impostazioni di Rete",
|
||||
"editInternalResourceDialogAccessPolicy": "Politica di Accesso",
|
||||
"editInternalResourceDialogAddRoles": "Aggiungi Ruoli",
|
||||
|
||||
+3
-1
@@ -850,6 +850,7 @@
|
||||
"orgPolicyConfig": "조직에 대한 접근을 구성하십시오.",
|
||||
"idpUpdatedDescription": "아이덴티티 제공자가 성공적으로 업데이트되었습니다",
|
||||
"redirectUrl": "리디렉션 URL",
|
||||
"orgIdpRedirectUrls": "리디렉션 URL",
|
||||
"redirectUrlAbout": "리디렉션 URL에 대한 정보",
|
||||
"redirectUrlAboutDescription": "사용자가 인증 후 리디렉션될 URL입니다. 이 URL을 신원 제공자 설정에서 구성해야 합니다.",
|
||||
"pangolinAuth": "인증 - 판골린",
|
||||
@@ -1479,7 +1480,7 @@
|
||||
"IAgreeToThe": "동의합니다",
|
||||
"termsOfService": "서비스 약관",
|
||||
"and": "및",
|
||||
"privacyPolicy": "개인 정보 보호 정책"
|
||||
"privacyPolicy": "개인 정보 보호 정책."
|
||||
},
|
||||
"signUpMarketing": {
|
||||
"keepMeInTheLoop": "이메일을 통해 소식, 업데이트 및 새로운 기능을 받아보세요."
|
||||
@@ -2349,6 +2350,7 @@
|
||||
"enterConfirmation": "확인 입력",
|
||||
"blueprintViewDetails": "세부 정보",
|
||||
"defaultIdentityProvider": "기본 아이덴티티 공급자",
|
||||
"defaultIdentityProviderDescription": "기본 ID 공급자가 선택되면, 사용자는 인증을 위해 자동으로 해당 공급자로 리디렉션됩니다.",
|
||||
"editInternalResourceDialogNetworkSettings": "네트워크 설정",
|
||||
"editInternalResourceDialogAccessPolicy": "액세스 정책",
|
||||
"editInternalResourceDialogAddRoles": "역할 추가",
|
||||
|
||||
+3
-1
@@ -850,6 +850,7 @@
|
||||
"orgPolicyConfig": "Konfigurer tilgang for en organisasjon",
|
||||
"idpUpdatedDescription": "Identitetsleverandør vellykket oppdatert",
|
||||
"redirectUrl": "Omdirigerings-URL",
|
||||
"orgIdpRedirectUrls": "Omadressere URL'er",
|
||||
"redirectUrlAbout": "Om omdirigerings-URL",
|
||||
"redirectUrlAboutDescription": "Dette er URLen som brukere vil bli omdirigert etter autentisering. Du må konfigurere denne URLen i identitetsleverandørens innstillinger.",
|
||||
"pangolinAuth": "Autentisering - Pangolin",
|
||||
@@ -1479,7 +1480,7 @@
|
||||
"IAgreeToThe": "Jeg godtar",
|
||||
"termsOfService": "brukervilkårene",
|
||||
"and": "og",
|
||||
"privacyPolicy": "personvernerklæringen"
|
||||
"privacyPolicy": "retningslinjer for personvern"
|
||||
},
|
||||
"signUpMarketing": {
|
||||
"keepMeInTheLoop": "Hold meg i løken med nyheter, oppdateringer og nye funksjoner via e-post."
|
||||
@@ -2349,6 +2350,7 @@
|
||||
"enterConfirmation": "Skriv inn bekreftelse",
|
||||
"blueprintViewDetails": "Detaljer",
|
||||
"defaultIdentityProvider": "Standard identitetsleverandør",
|
||||
"defaultIdentityProviderDescription": "Når en standard identitetsleverandør er valgt, vil brukeren automatisk bli omdirigert til leverandøren for autentisering.",
|
||||
"editInternalResourceDialogNetworkSettings": "Nettverksinnstillinger",
|
||||
"editInternalResourceDialogAccessPolicy": "Tilgangsregler for tilgang",
|
||||
"editInternalResourceDialogAddRoles": "Legg til roller",
|
||||
|
||||
+3
-1
@@ -850,6 +850,7 @@
|
||||
"orgPolicyConfig": "Toegang voor een organisatie configureren",
|
||||
"idpUpdatedDescription": "Identity provider succesvol bijgewerkt",
|
||||
"redirectUrl": "Omleidings URL",
|
||||
"orgIdpRedirectUrls": "URL's omleiden",
|
||||
"redirectUrlAbout": "Over omleidings-URL",
|
||||
"redirectUrlAboutDescription": "Dit is de URL waarnaar gebruikers worden doorverwezen na verificatie. U moet deze URL configureren in de instellingen van de identiteitsprovider.",
|
||||
"pangolinAuth": "Authenticatie - Pangolin",
|
||||
@@ -1479,7 +1480,7 @@
|
||||
"IAgreeToThe": "Ik ga akkoord met de",
|
||||
"termsOfService": "servicevoorwaarden",
|
||||
"and": "en",
|
||||
"privacyPolicy": "privacybeleid"
|
||||
"privacyPolicy": "privacy beleid"
|
||||
},
|
||||
"signUpMarketing": {
|
||||
"keepMeInTheLoop": "Houd me op de hoogte met nieuws, updates en nieuwe functies per e-mail."
|
||||
@@ -2349,6 +2350,7 @@
|
||||
"enterConfirmation": "Bevestiging invoeren",
|
||||
"blueprintViewDetails": "Details",
|
||||
"defaultIdentityProvider": "Standaard Identiteitsprovider",
|
||||
"defaultIdentityProviderDescription": "Wanneer een standaard identity provider is geselecteerd, zal de gebruiker automatisch worden doorgestuurd naar de provider voor authenticatie.",
|
||||
"editInternalResourceDialogNetworkSettings": "Netwerkinstellingen",
|
||||
"editInternalResourceDialogAccessPolicy": "Toegangsbeleid",
|
||||
"editInternalResourceDialogAddRoles": "Rollen toevoegen",
|
||||
|
||||
+3
-1
@@ -850,6 +850,7 @@
|
||||
"orgPolicyConfig": "Skonfiguruj dostęp dla organizacji",
|
||||
"idpUpdatedDescription": "Dostawca tożsamości został pomyślnie zaktualizowany",
|
||||
"redirectUrl": "URL przekierowania",
|
||||
"orgIdpRedirectUrls": "Przekieruj adresy URL",
|
||||
"redirectUrlAbout": "O URL przekierowania",
|
||||
"redirectUrlAboutDescription": "Jest to adres URL, na który użytkownicy zostaną przekierowani po uwierzytelnieniu. Musisz skonfigurować ten adres URL w ustawieniach dostawcy tożsamości.",
|
||||
"pangolinAuth": "Autoryzacja - Pangolin",
|
||||
@@ -1479,7 +1480,7 @@
|
||||
"IAgreeToThe": "Zgadzam się z",
|
||||
"termsOfService": "warunkami usługi",
|
||||
"and": "oraz",
|
||||
"privacyPolicy": "polityką prywatności"
|
||||
"privacyPolicy": "polityka prywatności."
|
||||
},
|
||||
"signUpMarketing": {
|
||||
"keepMeInTheLoop": "Zachowaj mnie w pętli z wiadomościami, aktualizacjami i nowymi funkcjami przez e-mail."
|
||||
@@ -2349,6 +2350,7 @@
|
||||
"enterConfirmation": "Wprowadź potwierdzenie",
|
||||
"blueprintViewDetails": "Szczegóły",
|
||||
"defaultIdentityProvider": "Domyślny dostawca tożsamości",
|
||||
"defaultIdentityProviderDescription": "Gdy zostanie wybrany domyślny dostawca tożsamości, użytkownik zostanie automatycznie przekierowany do dostawcy w celu uwierzytelnienia.",
|
||||
"editInternalResourceDialogNetworkSettings": "Ustawienia sieci",
|
||||
"editInternalResourceDialogAccessPolicy": "Polityka dostępowa",
|
||||
"editInternalResourceDialogAddRoles": "Dodaj role",
|
||||
|
||||
+3
-1
@@ -850,6 +850,7 @@
|
||||
"orgPolicyConfig": "Configurar acesso para uma organização",
|
||||
"idpUpdatedDescription": "Provedor de identidade atualizado com sucesso",
|
||||
"redirectUrl": "URL de Redirecionamento",
|
||||
"orgIdpRedirectUrls": "Redirecionar URLs",
|
||||
"redirectUrlAbout": "Sobre o URL de Redirecionamento",
|
||||
"redirectUrlAboutDescription": "Essa é a URL para a qual os usuários serão redirecionados após a autenticação. Você precisa configurar esta URL nas configurações do provedor de identidade.",
|
||||
"pangolinAuth": "Autenticação - Pangolin",
|
||||
@@ -1479,7 +1480,7 @@
|
||||
"IAgreeToThe": "Concordo com",
|
||||
"termsOfService": "os termos de serviço",
|
||||
"and": "e",
|
||||
"privacyPolicy": "política de privacidade"
|
||||
"privacyPolicy": "política de privacidade."
|
||||
},
|
||||
"signUpMarketing": {
|
||||
"keepMeInTheLoop": "Mantenha-me à disposição com notícias, atualizações e novos recursos por e-mail."
|
||||
@@ -2349,6 +2350,7 @@
|
||||
"enterConfirmation": "Inserir confirmação",
|
||||
"blueprintViewDetails": "Detalhes",
|
||||
"defaultIdentityProvider": "Provedor de Identidade Padrão",
|
||||
"defaultIdentityProviderDescription": "Quando um provedor de identidade padrão for selecionado, o usuário será automaticamente redirecionado para o provedor de autenticação.",
|
||||
"editInternalResourceDialogNetworkSettings": "Configurações de Rede",
|
||||
"editInternalResourceDialogAccessPolicy": "Política de Acesso",
|
||||
"editInternalResourceDialogAddRoles": "Adicionar Funções",
|
||||
|
||||
+3
-1
@@ -850,6 +850,7 @@
|
||||
"orgPolicyConfig": "Настроить доступ для организации",
|
||||
"idpUpdatedDescription": "Поставщик удостоверений успешно обновлён",
|
||||
"redirectUrl": "URL редиректа",
|
||||
"orgIdpRedirectUrls": "Перенаправление URL",
|
||||
"redirectUrlAbout": "О редиректе URL",
|
||||
"redirectUrlAboutDescription": "Это URL, на который пользователи будут перенаправлены после аутентификации. Вам нужно настроить этот URL в настройках провайдера.",
|
||||
"pangolinAuth": "Аутентификация - Pangolin",
|
||||
@@ -1479,7 +1480,7 @@
|
||||
"IAgreeToThe": "Я согласен с",
|
||||
"termsOfService": "условия использования",
|
||||
"and": "и",
|
||||
"privacyPolicy": "политика конфиденциальности"
|
||||
"privacyPolicy": "политика конфиденциальности."
|
||||
},
|
||||
"signUpMarketing": {
|
||||
"keepMeInTheLoop": "Держите меня в цикле с новостями, обновлениями и новыми функциями по электронной почте."
|
||||
@@ -2349,6 +2350,7 @@
|
||||
"enterConfirmation": "Введите подтверждение",
|
||||
"blueprintViewDetails": "Подробности",
|
||||
"defaultIdentityProvider": "Поставщик удостоверений по умолчанию",
|
||||
"defaultIdentityProviderDescription": "Когда выбран поставщик идентификации по умолчанию, пользователь будет автоматически перенаправлен на провайдер для аутентификации.",
|
||||
"editInternalResourceDialogNetworkSettings": "Настройки сети",
|
||||
"editInternalResourceDialogAccessPolicy": "Политика доступа",
|
||||
"editInternalResourceDialogAddRoles": "Добавить роли",
|
||||
|
||||
+3
-1
@@ -850,6 +850,7 @@
|
||||
"orgPolicyConfig": "Bir kuruluş için erişimi yapılandırın",
|
||||
"idpUpdatedDescription": "Kimlik sağlayıcı başarıyla güncellendi",
|
||||
"redirectUrl": "Yönlendirme URL'si",
|
||||
"orgIdpRedirectUrls": "Yönlendirme URL'leri",
|
||||
"redirectUrlAbout": "Yönlendirme URL'si Hakkında",
|
||||
"redirectUrlAboutDescription": "Bu, kimlik doğrulamasından sonra kullanıcıların yönlendirileceği URL'dir. Bu URL'yi kimlik sağlayıcınızın ayarlarında yapılandırmanız gerekir.",
|
||||
"pangolinAuth": "Yetkilendirme - Pangolin",
|
||||
@@ -1479,7 +1480,7 @@
|
||||
"IAgreeToThe": "Kabul ediyorum",
|
||||
"termsOfService": "hizmet şartları",
|
||||
"and": "ve",
|
||||
"privacyPolicy": "gizlilik politikası"
|
||||
"privacyPolicy": "gizlilik politikası."
|
||||
},
|
||||
"signUpMarketing": {
|
||||
"keepMeInTheLoop": "Bana e-posta yoluyla haberler, güncellemeler ve yeni özellikler hakkında bilgi verin."
|
||||
@@ -2349,6 +2350,7 @@
|
||||
"enterConfirmation": "Onayı girin",
|
||||
"blueprintViewDetails": "Detaylar",
|
||||
"defaultIdentityProvider": "Varsayılan Kimlik Sağlayıcı",
|
||||
"defaultIdentityProviderDescription": "Varsayılan bir kimlik sağlayıcı seçildiğinde, kullanıcı kimlik doğrulaması için otomatik olarak sağlayıcıya yönlendirilecektir.",
|
||||
"editInternalResourceDialogNetworkSettings": "Ağ Ayarları",
|
||||
"editInternalResourceDialogAccessPolicy": "Erişim Politikası",
|
||||
"editInternalResourceDialogAddRoles": "Roller Ekle",
|
||||
|
||||
+3
-1
@@ -850,6 +850,7 @@
|
||||
"orgPolicyConfig": "配置组织访问权限",
|
||||
"idpUpdatedDescription": "身份提供商更新成功",
|
||||
"redirectUrl": "重定向网址",
|
||||
"orgIdpRedirectUrls": "重定向URL",
|
||||
"redirectUrlAbout": "关于重定向网址",
|
||||
"redirectUrlAboutDescription": "这是用户在验证后将被重定向到的URL。您需要在身份提供者的设置中配置此URL。",
|
||||
"pangolinAuth": "认证 - Pangolin",
|
||||
@@ -1479,7 +1480,7 @@
|
||||
"IAgreeToThe": "我同意",
|
||||
"termsOfService": "服务条款",
|
||||
"and": "和",
|
||||
"privacyPolicy": "隐私政策"
|
||||
"privacyPolicy": "隐私政策。"
|
||||
},
|
||||
"signUpMarketing": {
|
||||
"keepMeInTheLoop": "通过电子邮件让我在循环中保持新闻、更新和新功能。"
|
||||
@@ -2349,6 +2350,7 @@
|
||||
"enterConfirmation": "输入确认",
|
||||
"blueprintViewDetails": "详细信息",
|
||||
"defaultIdentityProvider": "默认身份提供商",
|
||||
"defaultIdentityProviderDescription": "当选择默认身份提供商时,用户将自动重定向到提供商进行身份验证。",
|
||||
"editInternalResourceDialogNetworkSettings": "网络设置",
|
||||
"editInternalResourceDialogAccessPolicy": "访问策略",
|
||||
"editInternalResourceDialogAddRoles": "添加角色",
|
||||
|
||||
@@ -111,32 +111,30 @@ export const RuleSchema = z
|
||||
.refine(
|
||||
(rule) => {
|
||||
if (rule.match === "country") {
|
||||
// Check if it's a valid 2-letter country code
|
||||
return /^[A-Z]{2}$/.test(rule.value);
|
||||
// Check if it's a valid 2-letter country code or "ALL"
|
||||
return /^[A-Z]{2}$/.test(rule.value) || rule.value === "ALL";
|
||||
}
|
||||
return true;
|
||||
},
|
||||
{
|
||||
path: ["value"],
|
||||
message:
|
||||
"Value must be a 2-letter country code when match is 'country'"
|
||||
"Value must be a 2-letter country code or 'ALL' when match is 'country'"
|
||||
}
|
||||
)
|
||||
.refine(
|
||||
(rule) => {
|
||||
if (rule.match === "asn") {
|
||||
// Check if it's either AS<number> format or just a number
|
||||
// Check if it's either AS<number> format or "ALL"
|
||||
const asNumberPattern = /^AS\d+$/i;
|
||||
const isASFormat = asNumberPattern.test(rule.value);
|
||||
const isNumeric = /^\d+$/.test(rule.value);
|
||||
return isASFormat || isNumeric;
|
||||
return asNumberPattern.test(rule.value) || rule.value === "ALL";
|
||||
}
|
||||
return true;
|
||||
},
|
||||
{
|
||||
path: ["value"],
|
||||
message:
|
||||
"Value must be either 'AS<number>' format or a number when match is 'asn'"
|
||||
"Value must be 'AS<number>' format or 'ALL' when match is 'asn'"
|
||||
}
|
||||
);
|
||||
|
||||
|
||||
@@ -573,20 +573,6 @@ class RedisManager {
|
||||
}
|
||||
}
|
||||
|
||||
public async incr(key: string): Promise<number> {
|
||||
if (!this.isRedisEnabled() || !this.writeClient) return 0;
|
||||
|
||||
try {
|
||||
return await this.executeWithRetry(
|
||||
() => this.writeClient!.incr(key),
|
||||
"Redis INCR"
|
||||
);
|
||||
} catch (error) {
|
||||
logger.error("Redis INCR error:", error);
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
public async sadd(key: string, member: string): Promise<boolean> {
|
||||
if (!this.isRedisEnabled() || !this.writeClient) return false;
|
||||
|
||||
|
||||
@@ -618,6 +618,16 @@ hybridRouter.get(
|
||||
)
|
||||
.limit(1);
|
||||
|
||||
if (!result) {
|
||||
return response<LoginPage | null>(res, {
|
||||
data: null,
|
||||
success: true,
|
||||
error: false,
|
||||
message: "Login page not found",
|
||||
status: HttpCode.OK
|
||||
});
|
||||
}
|
||||
|
||||
if (
|
||||
await checkExitNodeOrg(
|
||||
remoteExitNode.exitNodeId,
|
||||
@@ -633,16 +643,6 @@ hybridRouter.get(
|
||||
);
|
||||
}
|
||||
|
||||
if (!result) {
|
||||
return response<LoginPage | null>(res, {
|
||||
data: null,
|
||||
success: true,
|
||||
error: false,
|
||||
message: "Login page not found",
|
||||
status: HttpCode.OK
|
||||
});
|
||||
}
|
||||
|
||||
return response<LoginPage>(res, {
|
||||
data: result.loginPage,
|
||||
success: true,
|
||||
|
||||
@@ -40,6 +40,11 @@ async function query(orgId: string | undefined, fullDomain: string) {
|
||||
eq(loginPage.loginPageId, loginPageOrg.loginPageId)
|
||||
)
|
||||
.limit(1);
|
||||
|
||||
if (!res) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
...res.loginPage,
|
||||
orgId: res.loginPageOrg.orgId
|
||||
@@ -65,6 +70,11 @@ async function query(orgId: string | undefined, fullDomain: string) {
|
||||
)
|
||||
)
|
||||
.limit(1);
|
||||
|
||||
if (!res) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
...res,
|
||||
orgId: orgLink.orgId
|
||||
|
||||
@@ -48,6 +48,11 @@ async function query(orgId: string) {
|
||||
)
|
||||
)
|
||||
.limit(1);
|
||||
|
||||
if (!res) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
...res,
|
||||
orgId: orgLink.orgs.orgId,
|
||||
|
||||
+16
-110
@@ -43,8 +43,7 @@ import {
|
||||
WSMessage,
|
||||
TokenPayload,
|
||||
WebSocketRequest,
|
||||
RedisMessage,
|
||||
SendMessageOptions
|
||||
RedisMessage
|
||||
} from "@server/routers/ws";
|
||||
import { validateSessionToken } from "@server/auth/sessions/app";
|
||||
|
||||
@@ -173,9 +172,6 @@ const REDIS_CHANNEL = "websocket_messages";
|
||||
// Client tracking map (local to this node)
|
||||
const connectedClients: Map<string, AuthenticatedWebSocket[]> = new Map();
|
||||
|
||||
// Config version tracking map (local to this node, resets on server restart)
|
||||
const clientConfigVersions: Map<string, number> = new Map();
|
||||
|
||||
// Recovery tracking
|
||||
let isRedisRecoveryInProgress = false;
|
||||
|
||||
@@ -186,7 +182,6 @@ const getClientMapKey = (clientId: string) => clientId;
|
||||
const getConnectionsKey = (clientId: string) => `ws:connections:${clientId}`;
|
||||
const getNodeConnectionsKey = (nodeId: string, clientId: string) =>
|
||||
`ws:node:${nodeId}:${clientId}`;
|
||||
const getConfigVersionKey = (clientId: string) => `ws:configVersion:${clientId}`;
|
||||
|
||||
// Initialize Redis subscription for cross-node messaging
|
||||
const initializeRedisSubscription = async (): Promise<void> => {
|
||||
@@ -382,76 +377,17 @@ const removeClient = async (
|
||||
}
|
||||
};
|
||||
|
||||
// Helper to get the current config version for a client
|
||||
const getClientConfigVersion = async (clientId: string): Promise<number> => {
|
||||
// Try Redis first if available
|
||||
if (redisManager.isRedisEnabled()) {
|
||||
try {
|
||||
const redisVersion = await redisManager.get(getConfigVersionKey(clientId));
|
||||
if (redisVersion !== null) {
|
||||
const version = parseInt(redisVersion, 10);
|
||||
// Sync local cache with Redis
|
||||
clientConfigVersions.set(clientId, version);
|
||||
return version;
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error("Failed to get config version from Redis:", error);
|
||||
}
|
||||
}
|
||||
|
||||
// Fall back to local cache
|
||||
return clientConfigVersions.get(clientId) || 0;
|
||||
};
|
||||
|
||||
// Helper to increment and get the new config version for a client
|
||||
const incrementClientConfigVersion = async (clientId: string): Promise<number> => {
|
||||
let newVersion: number;
|
||||
|
||||
if (redisManager.isRedisEnabled()) {
|
||||
try {
|
||||
// Use Redis INCR for atomic increment across nodes
|
||||
newVersion = await redisManager.incr(getConfigVersionKey(clientId));
|
||||
// Sync local cache
|
||||
clientConfigVersions.set(clientId, newVersion);
|
||||
return newVersion;
|
||||
} catch (error) {
|
||||
logger.error("Failed to increment config version in Redis:", error);
|
||||
// Fall through to local increment
|
||||
}
|
||||
}
|
||||
|
||||
// Local increment
|
||||
const currentVersion = clientConfigVersions.get(clientId) || 0;
|
||||
newVersion = currentVersion + 1;
|
||||
clientConfigVersions.set(clientId, newVersion);
|
||||
return newVersion;
|
||||
};
|
||||
|
||||
// Local message sending (within this node)
|
||||
const sendToClientLocal = async (
|
||||
clientId: string,
|
||||
message: WSMessage,
|
||||
options: SendMessageOptions = {}
|
||||
message: WSMessage
|
||||
): Promise<boolean> => {
|
||||
const mapKey = getClientMapKey(clientId);
|
||||
const clients = connectedClients.get(mapKey);
|
||||
if (!clients || clients.length === 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Handle config version
|
||||
let configVersion = await getClientConfigVersion(clientId);
|
||||
if (options.incrementConfigVersion) {
|
||||
configVersion = await incrementClientConfigVersion(clientId);
|
||||
}
|
||||
|
||||
// Add config version to message
|
||||
const messageWithVersion = {
|
||||
...message,
|
||||
configVersion
|
||||
};
|
||||
|
||||
const messageString = JSON.stringify(messageWithVersion);
|
||||
const messageString = JSON.stringify(message);
|
||||
clients.forEach((client) => {
|
||||
if (client.readyState === WebSocket.OPEN) {
|
||||
client.send(messageString);
|
||||
@@ -459,69 +395,43 @@ const sendToClientLocal = async (
|
||||
});
|
||||
|
||||
logger.debug(
|
||||
`sendToClient: Message type ${message.type} sent to clientId ${clientId} (configVersion: ${configVersion})`
|
||||
`sendToClient: Message type ${message.type} sent to clientId ${clientId}`
|
||||
);
|
||||
|
||||
|
||||
return true;
|
||||
};
|
||||
|
||||
const broadcastToAllExceptLocal = async (
|
||||
message: WSMessage,
|
||||
excludeClientId?: string,
|
||||
options: SendMessageOptions = {}
|
||||
excludeClientId?: string
|
||||
): Promise<void> => {
|
||||
for (const [mapKey, clients] of connectedClients.entries()) {
|
||||
connectedClients.forEach((clients, mapKey) => {
|
||||
const [type, id] = mapKey.split(":");
|
||||
const clientId = mapKey; // mapKey is the clientId
|
||||
if (!(excludeClientId && clientId === excludeClientId)) {
|
||||
// Handle config version per client
|
||||
let configVersion = await getClientConfigVersion(clientId);
|
||||
if (options.incrementConfigVersion) {
|
||||
configVersion = await incrementClientConfigVersion(clientId);
|
||||
}
|
||||
|
||||
// Add config version to message
|
||||
const messageWithVersion = {
|
||||
...message,
|
||||
configVersion
|
||||
};
|
||||
|
||||
if (!(excludeClientId && id === excludeClientId)) {
|
||||
clients.forEach((client) => {
|
||||
if (client.readyState === WebSocket.OPEN) {
|
||||
client.send(JSON.stringify(messageWithVersion));
|
||||
client.send(JSON.stringify(message));
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
// Cross-node message sending (via Redis)
|
||||
const sendToClient = async (
|
||||
clientId: string,
|
||||
message: WSMessage,
|
||||
options: SendMessageOptions = {}
|
||||
message: WSMessage
|
||||
): Promise<boolean> => {
|
||||
// Try to send locally first
|
||||
const localSent = await sendToClientLocal(clientId, message, options);
|
||||
const localSent = await sendToClientLocal(clientId, message);
|
||||
|
||||
// Only send via Redis if the client is not connected locally and Redis is enabled
|
||||
if (!localSent && redisManager.isRedisEnabled()) {
|
||||
try {
|
||||
// If we need to increment config version, do it before sending via Redis
|
||||
// so remote nodes send the correct version
|
||||
let configVersion = await getClientConfigVersion(clientId);
|
||||
if (options.incrementConfigVersion) {
|
||||
configVersion = await incrementClientConfigVersion(clientId);
|
||||
}
|
||||
|
||||
const redisMessage: RedisMessage = {
|
||||
type: "direct",
|
||||
targetClientId: clientId,
|
||||
message: {
|
||||
...message,
|
||||
configVersion
|
||||
},
|
||||
message,
|
||||
fromNodeId: NODE_ID
|
||||
};
|
||||
|
||||
@@ -548,22 +458,19 @@ const sendToClient = async (
|
||||
|
||||
const broadcastToAllExcept = async (
|
||||
message: WSMessage,
|
||||
excludeClientId?: string,
|
||||
options: SendMessageOptions = {}
|
||||
excludeClientId?: string
|
||||
): Promise<void> => {
|
||||
// Broadcast locally
|
||||
await broadcastToAllExceptLocal(message, excludeClientId, options);
|
||||
await broadcastToAllExceptLocal(message, excludeClientId);
|
||||
|
||||
// If Redis is enabled, also broadcast via Redis pub/sub to other nodes
|
||||
// Note: For broadcasts, we include the options so remote nodes can handle versioning
|
||||
if (redisManager.isRedisEnabled()) {
|
||||
try {
|
||||
const redisMessage: RedisMessage = {
|
||||
type: "broadcast",
|
||||
excludeClientId,
|
||||
message,
|
||||
fromNodeId: NODE_ID,
|
||||
options
|
||||
fromNodeId: NODE_ID
|
||||
};
|
||||
|
||||
await redisManager.publish(
|
||||
@@ -1029,6 +936,5 @@ export {
|
||||
getActiveNodes,
|
||||
disconnectClient,
|
||||
NODE_ID,
|
||||
cleanup,
|
||||
getClientConfigVersion
|
||||
cleanup
|
||||
};
|
||||
|
||||
@@ -36,7 +36,7 @@ async function query(clientId?: number, niceId?: string, orgId?: string) {
|
||||
.select()
|
||||
.from(clients)
|
||||
.where(and(eq(clients.niceId, niceId), eq(clients.orgId, orgId)))
|
||||
.leftJoin(olms, eq(olms.clientId, olms.clientId))
|
||||
.leftJoin(olms, eq(clients.clientId, olms.clientId))
|
||||
.limit(1);
|
||||
return res;
|
||||
}
|
||||
|
||||
@@ -7,8 +7,7 @@ import {
|
||||
ExitNode,
|
||||
exitNodes,
|
||||
siteResources,
|
||||
clientSiteResourcesAssociationsCache,
|
||||
Site
|
||||
clientSiteResourcesAssociationsCache
|
||||
} from "@server/db";
|
||||
import { clients, clientSitesAssociationsCache, Newt, sites } from "@server/db";
|
||||
import { eq } from "drizzle-orm";
|
||||
@@ -131,38 +130,6 @@ export const handleGetConfigMessage: MessageHandler = async (context) => {
|
||||
}
|
||||
}
|
||||
|
||||
const { peers, targets } = await buildClientConfigurationForNewtClient(
|
||||
site,
|
||||
exitNode
|
||||
);
|
||||
|
||||
// Build the configuration response
|
||||
const configResponse = {
|
||||
ipAddress: site.address,
|
||||
peers,
|
||||
targets
|
||||
};
|
||||
|
||||
logger.debug("Sending config: ", configResponse);
|
||||
|
||||
return {
|
||||
message: {
|
||||
type: "newt/wg/receive-config",
|
||||
data: {
|
||||
...configResponse
|
||||
}
|
||||
},
|
||||
broadcast: false,
|
||||
excludeSender: false,
|
||||
};
|
||||
};
|
||||
|
||||
export async function buildClientConfigurationForNewtClient(
|
||||
site: Site,
|
||||
exitNode?: ExitNode
|
||||
) {
|
||||
const siteId = site.siteId;
|
||||
|
||||
// Get all clients connected to this site
|
||||
const clientsRes = await db
|
||||
.select()
|
||||
@@ -311,8 +278,22 @@ export async function buildClientConfigurationForNewtClient(
|
||||
targetsToSend.push(...resourceTargets);
|
||||
}
|
||||
|
||||
return {
|
||||
// Build the configuration response
|
||||
const configResponse = {
|
||||
ipAddress: site.address,
|
||||
peers: validPeers,
|
||||
targets: targetsToSend
|
||||
};
|
||||
}
|
||||
|
||||
logger.debug("Sending config: ", configResponse);
|
||||
return {
|
||||
message: {
|
||||
type: "newt/wg/receive-config",
|
||||
data: {
|
||||
...configResponse
|
||||
}
|
||||
},
|
||||
broadcast: false,
|
||||
excludeSender: false
|
||||
};
|
||||
};
|
||||
|
||||
@@ -1,141 +0,0 @@
|
||||
import { db } from "@server/db";
|
||||
import { disconnectClient } from "#dynamic/routers/ws";
|
||||
import { getClientConfigVersion, MessageHandler } from "@server/routers/ws";
|
||||
import { clients, Newt } from "@server/db";
|
||||
import { eq, lt, isNull, and, or } from "drizzle-orm";
|
||||
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";
|
||||
|
||||
// Track if the offline checker interval is running
|
||||
// let offlineCheckerInterval: NodeJS.Timeout | null = null;
|
||||
// const OFFLINE_CHECK_INTERVAL = 30 * 1000; // Check every 30 seconds
|
||||
// const OFFLINE_THRESHOLD_MS = 2 * 60 * 1000; // 2 minutes
|
||||
|
||||
/**
|
||||
* Starts the background interval that checks for clients that haven't pinged recently
|
||||
* and marks them as offline
|
||||
*/
|
||||
// export const startNewtOfflineChecker = (): void => {
|
||||
// if (offlineCheckerInterval) {
|
||||
// return; // Already running
|
||||
// }
|
||||
|
||||
// offlineCheckerInterval = setInterval(async () => {
|
||||
// try {
|
||||
// const twoMinutesAgo = Math.floor(
|
||||
// (Date.now() - OFFLINE_THRESHOLD_MS) / 1000
|
||||
// );
|
||||
|
||||
// // TODO: WE NEED TO MAKE SURE THIS WORKS WITH DISTRIBUTED NODES ALL DOING THE SAME THING
|
||||
|
||||
// // Find clients that haven't pinged in the last 2 minutes and mark them as offline
|
||||
// const offlineClients = await db
|
||||
// .update(clients)
|
||||
// .set({ online: false })
|
||||
// .where(
|
||||
// and(
|
||||
// eq(clients.online, true),
|
||||
// or(
|
||||
// lt(clients.lastPing, twoMinutesAgo),
|
||||
// isNull(clients.lastPing)
|
||||
// )
|
||||
// )
|
||||
// )
|
||||
// .returning();
|
||||
|
||||
// for (const offlineClient of offlineClients) {
|
||||
// logger.info(
|
||||
// `Kicking offline newt client ${offlineClient.clientId} due to inactivity`
|
||||
// );
|
||||
|
||||
// if (!offlineClient.newtId) {
|
||||
// logger.warn(
|
||||
// `Offline client ${offlineClient.clientId} has no newtId, cannot disconnect`
|
||||
// );
|
||||
// continue;
|
||||
// }
|
||||
|
||||
// // 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 clients
|
||||
*/
|
||||
// export const stopNewtOfflineChecker = (): void => {
|
||||
// if (offlineCheckerInterval) {
|
||||
// clearInterval(offlineCheckerInterval);
|
||||
// offlineCheckerInterval = null;
|
||||
// logger.info("Stopped offline checker interval");
|
||||
// }
|
||||
// };
|
||||
|
||||
/**
|
||||
* Handles ping messages from clients and responds with pong
|
||||
*/
|
||||
export const handleNewtPingMessage: MessageHandler = async (context) => {
|
||||
const { message, client: c, sendToClient } = context;
|
||||
const newt = c as Newt;
|
||||
|
||||
if (!newt) {
|
||||
logger.warn("Newt not found");
|
||||
return;
|
||||
}
|
||||
|
||||
// get the version
|
||||
const configVersion = await getClientConfigVersion(newt.newtId);
|
||||
|
||||
if (message.configVersion && configVersion != message.configVersion) {
|
||||
logger.warn(`Newt ping with outdated config version: ${message.configVersion} (current: ${configVersion})`);
|
||||
|
||||
// TODO: sync the client
|
||||
}
|
||||
|
||||
// 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 {
|
||||
message: {
|
||||
type: "pong",
|
||||
data: {
|
||||
timestamp: new Date().toISOString()
|
||||
}
|
||||
},
|
||||
broadcast: false,
|
||||
excludeSender: false
|
||||
};
|
||||
};
|
||||
@@ -233,35 +233,6 @@ export const handleNewtRegisterMessage: MessageHandler = async (context) => {
|
||||
.where(eq(newts.newtId, newt.newtId));
|
||||
}
|
||||
|
||||
const { tcpTargets, udpTargets, validHealthCheckTargets } =
|
||||
await buildTargetConfigurationForNewtClient(siteId);
|
||||
|
||||
logger.debug(
|
||||
`Sending health check targets to newt ${newt.newtId}: ${JSON.stringify(validHealthCheckTargets)}`
|
||||
);
|
||||
|
||||
return {
|
||||
message: {
|
||||
type: "newt/wg/connect",
|
||||
data: {
|
||||
endpoint: `${exitNode.endpoint}:${exitNode.listenPort}`,
|
||||
relayPort: config.getRawConfig().gerbil.clients_start_port,
|
||||
publicKey: exitNode.publicKey,
|
||||
serverIP: exitNode.address.split("/")[0],
|
||||
tunnelIP: siteSubnet.split("/")[0],
|
||||
targets: {
|
||||
udp: udpTargets,
|
||||
tcp: tcpTargets
|
||||
},
|
||||
healthCheckTargets: validHealthCheckTargets
|
||||
}
|
||||
},
|
||||
broadcast: false, // Send to all clients
|
||||
excludeSender: false // Include sender in broadcast
|
||||
};
|
||||
};
|
||||
|
||||
export async function buildTargetConfigurationForNewtClient(siteId: number) {
|
||||
// Get all enabled targets with their resource protocol information
|
||||
const allTargets = await db
|
||||
.select({
|
||||
@@ -366,12 +337,30 @@ export async function buildTargetConfigurationForNewtClient(siteId: number) {
|
||||
(target) => target !== null
|
||||
);
|
||||
|
||||
logger.debug(
|
||||
`Sending health check targets to newt ${newt.newtId}: ${JSON.stringify(validHealthCheckTargets)}`
|
||||
);
|
||||
|
||||
return {
|
||||
validHealthCheckTargets,
|
||||
tcpTargets,
|
||||
udpTargets
|
||||
message: {
|
||||
type: "newt/wg/connect",
|
||||
data: {
|
||||
endpoint: `${exitNode.endpoint}:${exitNode.listenPort}`,
|
||||
relayPort: config.getRawConfig().gerbil.clients_start_port,
|
||||
publicKey: exitNode.publicKey,
|
||||
serverIP: exitNode.address.split("/")[0],
|
||||
tunnelIP: siteSubnet.split("/")[0],
|
||||
targets: {
|
||||
udp: udpTargets,
|
||||
tcp: tcpTargets
|
||||
},
|
||||
healthCheckTargets: validHealthCheckTargets
|
||||
}
|
||||
},
|
||||
broadcast: false, // Send to all clients
|
||||
excludeSender: false // Include sender in broadcast
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
async function getUniqueSubnetForSite(
|
||||
exitNode: ExitNode,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { db } from "@server/db";
|
||||
import { MessageHandler } from "@server/routers/ws";
|
||||
import { clients, Newt } from "@server/db";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { clients } from "@server/db";
|
||||
import { eq, sql } from "drizzle-orm";
|
||||
import logger from "@server/logger";
|
||||
|
||||
interface PeerBandwidth {
|
||||
@@ -10,13 +10,57 @@ interface PeerBandwidth {
|
||||
bytesOut: number;
|
||||
}
|
||||
|
||||
// Retry configuration for deadlock handling
|
||||
const MAX_RETRIES = 3;
|
||||
const BASE_DELAY_MS = 50;
|
||||
|
||||
/**
|
||||
* Check if an error is a deadlock error
|
||||
*/
|
||||
function isDeadlockError(error: any): boolean {
|
||||
return (
|
||||
error?.code === "40P01" ||
|
||||
error?.cause?.code === "40P01" ||
|
||||
(error?.message && error.message.includes("deadlock"))
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute a function with retry logic for deadlock handling
|
||||
*/
|
||||
async function withDeadlockRetry<T>(
|
||||
operation: () => Promise<T>,
|
||||
context: string
|
||||
): Promise<T> {
|
||||
let attempt = 0;
|
||||
while (true) {
|
||||
try {
|
||||
return await operation();
|
||||
} catch (error: any) {
|
||||
if (isDeadlockError(error) && attempt < MAX_RETRIES) {
|
||||
attempt++;
|
||||
const baseDelay = Math.pow(2, attempt - 1) * BASE_DELAY_MS;
|
||||
const jitter = Math.random() * baseDelay;
|
||||
const delay = baseDelay + jitter;
|
||||
logger.warn(
|
||||
`Deadlock detected in ${context}, retrying attempt ${attempt}/${MAX_RETRIES} after ${delay.toFixed(0)}ms`
|
||||
);
|
||||
await new Promise((resolve) => setTimeout(resolve, delay));
|
||||
continue;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const handleReceiveBandwidthMessage: MessageHandler = async (
|
||||
context
|
||||
) => {
|
||||
const { message, client, sendToClient } = context;
|
||||
const { message } = context;
|
||||
|
||||
if (!message.data.bandwidthData) {
|
||||
logger.warn("No bandwidth data provided");
|
||||
return;
|
||||
}
|
||||
|
||||
const bandwidthData: PeerBandwidth[] = message.data.bandwidthData;
|
||||
@@ -25,30 +69,40 @@ export const handleReceiveBandwidthMessage: MessageHandler = async (
|
||||
throw new Error("Invalid bandwidth data");
|
||||
}
|
||||
|
||||
await db.transaction(async (trx) => {
|
||||
for (const peer of bandwidthData) {
|
||||
const { publicKey, bytesIn, bytesOut } = peer;
|
||||
// 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 clients
|
||||
const sortedBandwidthData = [...bandwidthData].sort((a, b) =>
|
||||
a.publicKey.localeCompare(b.publicKey)
|
||||
);
|
||||
|
||||
// Find the client by public key
|
||||
const [client] = await trx
|
||||
.select()
|
||||
.from(clients)
|
||||
.where(eq(clients.pubKey, publicKey))
|
||||
.limit(1);
|
||||
const currentTime = new Date().toISOString();
|
||||
|
||||
if (!client) {
|
||||
continue;
|
||||
}
|
||||
// Update each client individually with retry logic
|
||||
// This reduces transaction scope and allows retries per-client
|
||||
for (const peer of sortedBandwidthData) {
|
||||
const { publicKey, bytesIn, bytesOut } = peer;
|
||||
|
||||
// Update the client's bandwidth usage
|
||||
await trx
|
||||
.update(clients)
|
||||
.set({
|
||||
megabytesOut: (client.megabytesIn || 0) + bytesIn,
|
||||
megabytesIn: (client.megabytesOut || 0) + bytesOut,
|
||||
lastBandwidthUpdate: new Date().toISOString()
|
||||
})
|
||||
.where(eq(clients.clientId, client.clientId));
|
||||
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
|
||||
}
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
@@ -6,4 +6,3 @@ export * from "./handleGetConfigMessage";
|
||||
export * from "./handleSocketMessages";
|
||||
export * from "./handleNewtPingRequestMessage";
|
||||
export * from "./handleApplyBlueprintMessage";
|
||||
export * from "./handleNewtPingMessage";
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { db } from "@server/db";
|
||||
import { disconnectClient } from "#dynamic/routers/ws";
|
||||
import { getClientConfigVersion, MessageHandler } from "@server/routers/ws";
|
||||
import { MessageHandler } from "@server/routers/ws";
|
||||
import { clients, Olm } from "@server/db";
|
||||
import { eq, lt, isNull, and, or } from "drizzle-orm";
|
||||
import logger from "@server/logger";
|
||||
@@ -108,15 +108,6 @@ export const handleOlmPingMessage: MessageHandler = async (context) => {
|
||||
return;
|
||||
}
|
||||
|
||||
// get the version
|
||||
const configVersion = await getClientConfigVersion(olm.olmId);
|
||||
|
||||
if (message.configVersion && configVersion != message.configVersion) {
|
||||
logger.warn(`Olm ping with outdated config version: ${message.configVersion} (current: ${configVersion})`);
|
||||
|
||||
// TODO: sync the client
|
||||
}
|
||||
|
||||
if (olm.userId) {
|
||||
// we need to check a user token to make sure its still valid
|
||||
const { session: userSession, user } =
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import {
|
||||
Client,
|
||||
clientSiteResourcesAssociationsCache,
|
||||
db,
|
||||
orgs,
|
||||
@@ -14,7 +13,7 @@ import {
|
||||
olms,
|
||||
sites
|
||||
} from "@server/db";
|
||||
import { and, count, eq, inArray, isNull } from "drizzle-orm";
|
||||
import { and, eq, inArray, isNull } from "drizzle-orm";
|
||||
import { addPeer, deletePeer } from "../newt/peers";
|
||||
import logger from "@server/logger";
|
||||
import { generateAliasConfig } from "@server/lib/ip";
|
||||
@@ -145,64 +144,6 @@ export const handleOlmRegisterMessage: MessageHandler = async (context) => {
|
||||
.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.
|
||||
// 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 ???
|
||||
if (now - (client.lastHolePunch || 0) > 5 && sitesCount > 0) {
|
||||
logger.warn(
|
||||
"Client last hole punch is too old and we have sites to send; skipping this register"
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const siteConfigurations = await buildSiteConfigurationForOlmClient(client, publicKey, relay);
|
||||
|
||||
// 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 {
|
||||
message: {
|
||||
type: "olm/wg/connect",
|
||||
data: {
|
||||
sites: siteConfigurations,
|
||||
tunnelIP: client.subnet,
|
||||
utilitySubnet: org.utilitySubnet
|
||||
}
|
||||
},
|
||||
broadcast: false,
|
||||
excludeSender: false
|
||||
};
|
||||
};
|
||||
|
||||
export async function buildSiteConfigurationForOlmClient(
|
||||
client: Client,
|
||||
publicKey: string,
|
||||
relay: boolean
|
||||
) {
|
||||
const siteConfigurations = [];
|
||||
|
||||
// Get all sites data
|
||||
const sitesData = await db
|
||||
.select()
|
||||
@@ -213,6 +154,22 @@ export async function buildSiteConfigurationForOlmClient(
|
||||
)
|
||||
.where(eq(clientSitesAssociationsCache.clientId, client.clientId));
|
||||
|
||||
// Prepare an array to store site configurations
|
||||
const siteConfigurations = [];
|
||||
logger.debug(
|
||||
`Found ${sitesData.length} sites for client ${client.clientId}`
|
||||
);
|
||||
|
||||
// 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
|
||||
// TODO: I still think there is a better way to do this rather than locking it out here but ???
|
||||
if (now - (client.lastHolePunch || 0) > 5 && sitesData.length > 0) {
|
||||
logger.warn(
|
||||
"Client last hole punch is too old and we have sites to send; skipping this register"
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Process each site
|
||||
for (const {
|
||||
sites: site,
|
||||
@@ -332,5 +289,23 @@ export async function buildSiteConfigurationForOlmClient(
|
||||
});
|
||||
}
|
||||
|
||||
return siteConfigurations;
|
||||
}
|
||||
// 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 {
|
||||
message: {
|
||||
type: "olm/wg/connect",
|
||||
data: {
|
||||
sites: siteConfigurations,
|
||||
tunnelIP: client.subnet,
|
||||
utilitySubnet: org.utilitySubnet
|
||||
}
|
||||
},
|
||||
broadcast: false,
|
||||
excludeSender: false
|
||||
};
|
||||
};
|
||||
|
||||
@@ -5,8 +5,7 @@ import {
|
||||
handleDockerStatusMessage,
|
||||
handleDockerContainersMessage,
|
||||
handleNewtPingRequestMessage,
|
||||
handleApplyBlueprintMessage,
|
||||
handleNewtPingMessage
|
||||
handleApplyBlueprintMessage
|
||||
} from "../newt";
|
||||
import {
|
||||
handleOlmRegisterMessage,
|
||||
@@ -25,7 +24,6 @@ export const messageHandlers: Record<string, MessageHandler> = {
|
||||
"olm/wg/relay": handleOlmRelayMessage,
|
||||
"olm/wg/unrelay": handleOlmUnRelayMessage,
|
||||
"olm/ping": handleOlmPingMessage,
|
||||
"newt/ping": handleNewtPingMessage,
|
||||
"newt/wg/register": handleNewtRegisterMessage,
|
||||
"newt/wg/get-config": handleGetConfigMessage,
|
||||
"newt/receive-bandwidth": handleReceiveBandwidthMessage,
|
||||
|
||||
@@ -25,7 +25,6 @@ export interface AuthenticatedWebSocket extends WebSocket {
|
||||
connectionId?: string;
|
||||
isFullyConnected?: boolean;
|
||||
pendingMessages?: Buffer[];
|
||||
configVersion?: number;
|
||||
}
|
||||
|
||||
export interface TokenPayload {
|
||||
@@ -37,7 +36,6 @@ export interface TokenPayload {
|
||||
export interface WSMessage {
|
||||
type: string;
|
||||
data: any;
|
||||
configVersion?: number;
|
||||
}
|
||||
|
||||
export interface HandlerResponse {
|
||||
@@ -52,15 +50,10 @@ export interface HandlerContext {
|
||||
senderWs: WebSocket;
|
||||
client: Newt | Olm | RemoteExitNode | undefined;
|
||||
clientType: ClientType;
|
||||
sendToClient: (
|
||||
clientId: string,
|
||||
message: WSMessage,
|
||||
options?: SendMessageOptions
|
||||
) => Promise<boolean>;
|
||||
sendToClient: (clientId: string, message: WSMessage) => Promise<boolean>;
|
||||
broadcastToAllExcept: (
|
||||
message: WSMessage,
|
||||
excludeClientId?: string,
|
||||
options?: SendMessageOptions
|
||||
excludeClientId?: string
|
||||
) => Promise<void>;
|
||||
connectedClients: Map<string, WebSocket[]>;
|
||||
}
|
||||
@@ -69,11 +62,6 @@ export type MessageHandler = (
|
||||
context: HandlerContext
|
||||
) => Promise<HandlerResponse | void>;
|
||||
|
||||
// Options for sending messages with config version tracking
|
||||
export interface SendMessageOptions {
|
||||
incrementConfigVersion?: boolean;
|
||||
}
|
||||
|
||||
// Redis message type for cross-node communication
|
||||
export interface RedisMessage {
|
||||
type: "direct" | "broadcast";
|
||||
@@ -81,5 +69,4 @@ export interface RedisMessage {
|
||||
excludeClientId?: string;
|
||||
message: WSMessage;
|
||||
fromNodeId: string;
|
||||
options?: SendMessageOptions;
|
||||
}
|
||||
|
||||
+11
-59
@@ -15,8 +15,7 @@ import {
|
||||
TokenPayload,
|
||||
WebSocketRequest,
|
||||
WSMessage,
|
||||
AuthenticatedWebSocket,
|
||||
SendMessageOptions
|
||||
AuthenticatedWebSocket
|
||||
} from "./types";
|
||||
import { validateSessionToken } from "@server/auth/sessions/app";
|
||||
|
||||
@@ -35,8 +34,6 @@ const NODE_ID = uuidv4();
|
||||
|
||||
// Client tracking map (local to this node)
|
||||
const connectedClients: Map<string, AuthenticatedWebSocket[]> = new Map();
|
||||
// Config version tracking map (clientId -> version)
|
||||
const clientConfigVersions: Map<string, number> = new Map();
|
||||
// Helper to get map key
|
||||
const getClientMapKey = (clientId: string) => clientId;
|
||||
|
||||
@@ -87,34 +84,14 @@ const removeClient = async (
|
||||
// Local message sending (within this node)
|
||||
const sendToClientLocal = async (
|
||||
clientId: string,
|
||||
message: WSMessage,
|
||||
options: SendMessageOptions = {}
|
||||
message: WSMessage
|
||||
): Promise<boolean> => {
|
||||
const mapKey = getClientMapKey(clientId);
|
||||
const clients = connectedClients.get(mapKey);
|
||||
if (!clients || clients.length === 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Increment config version if requested
|
||||
if (options.incrementConfigVersion) {
|
||||
const currentVersion = clientConfigVersions.get(clientId) || 0;
|
||||
const newVersion = currentVersion + 1;
|
||||
clientConfigVersions.set(clientId, newVersion);
|
||||
// Update version on all client connections
|
||||
clients.forEach((client) => {
|
||||
client.configVersion = newVersion;
|
||||
});
|
||||
}
|
||||
|
||||
// Include config version in message
|
||||
const configVersion = clientConfigVersions.get(clientId) || 0;
|
||||
const messageWithVersion = {
|
||||
...message,
|
||||
configVersion
|
||||
};
|
||||
|
||||
const messageString = JSON.stringify(messageWithVersion);
|
||||
const messageString = JSON.stringify(message);
|
||||
clients.forEach((client) => {
|
||||
if (client.readyState === WebSocket.OPEN) {
|
||||
client.send(messageString);
|
||||
@@ -125,31 +102,14 @@ const sendToClientLocal = async (
|
||||
|
||||
const broadcastToAllExceptLocal = async (
|
||||
message: WSMessage,
|
||||
excludeClientId?: string,
|
||||
options: SendMessageOptions = {}
|
||||
excludeClientId?: string
|
||||
): Promise<void> => {
|
||||
connectedClients.forEach((clients, mapKey) => {
|
||||
const [type, id] = mapKey.split(":");
|
||||
const clientId = mapKey; // mapKey is the clientId
|
||||
if (!(excludeClientId && clientId === excludeClientId)) {
|
||||
// Handle config version per client
|
||||
if (options.incrementConfigVersion) {
|
||||
const currentVersion = clientConfigVersions.get(clientId) || 0;
|
||||
const newVersion = currentVersion + 1;
|
||||
clientConfigVersions.set(clientId, newVersion);
|
||||
clients.forEach((client) => {
|
||||
client.configVersion = newVersion;
|
||||
});
|
||||
}
|
||||
// Include config version in message for this client
|
||||
const configVersion = clientConfigVersions.get(clientId) || 0;
|
||||
const messageWithVersion = {
|
||||
...message,
|
||||
configVersion
|
||||
};
|
||||
if (!(excludeClientId && id === excludeClientId)) {
|
||||
clients.forEach((client) => {
|
||||
if (client.readyState === WebSocket.OPEN) {
|
||||
client.send(JSON.stringify(messageWithVersion));
|
||||
client.send(JSON.stringify(message));
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -159,11 +119,10 @@ const broadcastToAllExceptLocal = async (
|
||||
// Cross-node message sending
|
||||
const sendToClient = async (
|
||||
clientId: string,
|
||||
message: WSMessage,
|
||||
options: SendMessageOptions = {}
|
||||
message: WSMessage
|
||||
): Promise<boolean> => {
|
||||
// Try to send locally first
|
||||
const localSent = await sendToClientLocal(clientId, message, options);
|
||||
const localSent = await sendToClientLocal(clientId, message);
|
||||
|
||||
logger.debug(
|
||||
`sendToClient: Message type ${message.type} sent to clientId ${clientId}`
|
||||
@@ -174,11 +133,10 @@ const sendToClient = async (
|
||||
|
||||
const broadcastToAllExcept = async (
|
||||
message: WSMessage,
|
||||
excludeClientId?: string,
|
||||
options: SendMessageOptions = {}
|
||||
excludeClientId?: string
|
||||
): Promise<void> => {
|
||||
// Broadcast locally
|
||||
await broadcastToAllExceptLocal(message, excludeClientId, options);
|
||||
await broadcastToAllExceptLocal(message, excludeClientId);
|
||||
};
|
||||
|
||||
// Check if a client has active connections across all nodes
|
||||
@@ -188,11 +146,6 @@ const hasActiveConnections = async (clientId: string): Promise<boolean> => {
|
||||
return !!(clients && clients.length > 0);
|
||||
};
|
||||
|
||||
// Get the current config version for a client
|
||||
const getClientConfigVersion = async (clientId: string): Promise<number> => {
|
||||
return clientConfigVersions.get(clientId) || 0;
|
||||
};
|
||||
|
||||
// Get all active nodes for a client
|
||||
const getActiveNodes = async (
|
||||
clientType: ClientType,
|
||||
@@ -481,6 +434,5 @@ export {
|
||||
getActiveNodes,
|
||||
disconnectClient,
|
||||
NODE_ID,
|
||||
cleanup,
|
||||
getClientConfigVersion
|
||||
cleanup
|
||||
};
|
||||
|
||||
@@ -285,7 +285,7 @@ export default function Page() {
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
router.push("/admin/idp");
|
||||
router.push(`/${params.orgId}/settings/idp`);
|
||||
}}
|
||||
>
|
||||
{t("idpSeeAll")}
|
||||
|
||||
@@ -1,17 +1,10 @@
|
||||
import { internal, priv } from "@app/lib/api";
|
||||
import { internal } from "@app/lib/api";
|
||||
import { authCookieHeader } from "@app/lib/api/cookies";
|
||||
import { AxiosResponse } from "axios";
|
||||
import SettingsSectionTitle from "@app/components/SettingsSectionTitle";
|
||||
import IdpTable, { IdpRow } from "@app/components/private/OrgIdpTable";
|
||||
import { getTranslations } from "next-intl/server";
|
||||
import { Alert, AlertDescription } from "@app/components/ui/alert";
|
||||
import { cache } from "react";
|
||||
import {
|
||||
GetOrgSubscriptionResponse,
|
||||
GetOrgTierResponse
|
||||
} from "@server/routers/billing/types";
|
||||
import { TierId } from "@server/lib/billing/tiers";
|
||||
import { build } from "@server/build";
|
||||
import { PaidFeaturesAlert } from "@app/components/PaidFeaturesAlert";
|
||||
|
||||
type OrgIdpPageProps = {
|
||||
params: Promise<{ orgId: string }>;
|
||||
@@ -35,21 +28,6 @@ export default async function OrgIdpPage(props: OrgIdpPageProps) {
|
||||
|
||||
const t = await getTranslations();
|
||||
|
||||
let subscriptionStatus: GetOrgTierResponse | null = null;
|
||||
try {
|
||||
const getSubscription = cache(() =>
|
||||
priv.get<AxiosResponse<GetOrgTierResponse>>(
|
||||
`/org/${params.orgId}/billing/tier`
|
||||
)
|
||||
);
|
||||
const subRes = await getSubscription();
|
||||
subscriptionStatus = subRes.data.data;
|
||||
} catch {}
|
||||
const subscribed =
|
||||
build === "enterprise"
|
||||
? true
|
||||
: subscriptionStatus?.tier === TierId.STANDARD;
|
||||
|
||||
return (
|
||||
<>
|
||||
<SettingsSectionTitle
|
||||
@@ -57,13 +35,7 @@ export default async function OrgIdpPage(props: OrgIdpPageProps) {
|
||||
description={t("idpManageDescription")}
|
||||
/>
|
||||
|
||||
{build === "saas" && !subscribed ? (
|
||||
<Alert variant="info" className="mb-6">
|
||||
<AlertDescription>
|
||||
{t("idpDisabled")} {t("subscriptionRequiredToUse")}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
) : null}
|
||||
<PaidFeaturesAlert />
|
||||
|
||||
<IdpTable idps={idps} orgId={params.orgId} />
|
||||
</>
|
||||
|
||||
@@ -164,6 +164,10 @@ function MaintenanceSectionForm({
|
||||
return isEnterpriseNotLicensed || isSaasNotSubscribed;
|
||||
};
|
||||
|
||||
if (!resource.http) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<SettingsSection>
|
||||
<SettingsSectionHeader>
|
||||
@@ -437,9 +441,16 @@ export default function GeneralForm() {
|
||||
);
|
||||
|
||||
const resourceFullDomainName = useMemo(() => {
|
||||
const url = new URL(resourceFullDomain);
|
||||
return url.hostname;
|
||||
}, [resourceFullDomain]);
|
||||
if (!resource.fullDomain) {
|
||||
return "";
|
||||
}
|
||||
try {
|
||||
const url = new URL(resourceFullDomain);
|
||||
return url.hostname;
|
||||
} catch {
|
||||
return "";
|
||||
}
|
||||
}, [resourceFullDomain, resource.fullDomain]);
|
||||
|
||||
const [selectedDomain, setSelectedDomain] = useState<{
|
||||
domainId: string;
|
||||
|
||||
@@ -338,7 +338,7 @@ function ProxyResourceTargetsForm({
|
||||
<div
|
||||
className={`flex items-center gap-2 ${status === "healthy" ? "text-green-500" : status === "unhealthy" ? "text-destructive" : ""}`}
|
||||
>
|
||||
<Settings className="h-3 w-3" />
|
||||
<Settings className="h-4 w-4 text-foreground" />
|
||||
{getStatusText(status)}
|
||||
</div>
|
||||
</Button>
|
||||
|
||||
@@ -178,4 +178,16 @@ p {
|
||||
.animate-dot-pulse {
|
||||
animation: dot-pulse 1.4s ease-in-out infinite;
|
||||
}
|
||||
|
||||
/* Use JavaScript-set viewport height for mobile to handle keyboard properly */
|
||||
.h-screen-safe {
|
||||
height: 100vh; /* Default for desktop and fallback */
|
||||
}
|
||||
|
||||
/* Only apply custom viewport height on mobile */
|
||||
@media (max-width: 767px) {
|
||||
.h-screen-safe {
|
||||
height: var(--vh, 100vh); /* Use CSS variable set by ViewportHeightFix on mobile */
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+3
-1
@@ -22,6 +22,7 @@ import { TopLoader } from "@app/components/Toploader";
|
||||
import Script from "next/script";
|
||||
import { TanstackQueryProvider } from "@app/components/TanstackQueryProvider";
|
||||
import { TailwindIndicator } from "@app/components/TailwindIndicator";
|
||||
import { ViewportHeightFix } from "@app/components/ViewportHeightFix";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: `Dashboard - ${process.env.BRANDING_APP_NAME || "Pangolin"}`,
|
||||
@@ -77,7 +78,7 @@ export default async function RootLayout({
|
||||
|
||||
return (
|
||||
<html suppressHydrationWarning lang={locale}>
|
||||
<body className={`${font.className} h-screen overflow-hidden`}>
|
||||
<body className={`${font.className} h-screen-safe overflow-hidden`}>
|
||||
<TopLoader />
|
||||
{build === "saas" && (
|
||||
<Script
|
||||
@@ -86,6 +87,7 @@ export default async function RootLayout({
|
||||
strategy="afterInteractive"
|
||||
/>
|
||||
)}
|
||||
<ViewportHeightFix />
|
||||
<NextIntlClientProvider>
|
||||
<ThemeProvider
|
||||
attribute="class"
|
||||
|
||||
@@ -37,7 +37,7 @@ export async function Layout({
|
||||
(sidebarStateCookie !== "expanded" && defaultSidebarCollapsed);
|
||||
|
||||
return (
|
||||
<div className="flex h-screen overflow-hidden">
|
||||
<div className="flex h-screen-safe overflow-hidden">
|
||||
{/* Desktop Sidebar */}
|
||||
{showSidebar && (
|
||||
<LayoutSidebar
|
||||
@@ -75,7 +75,7 @@ export async function Layout({
|
||||
<div
|
||||
className={cn(
|
||||
"container mx-auto max-w-12xl mb-12",
|
||||
showHeader && "pt-16 md:pt-16" // Add top padding on mobile and desktop to account for fixed header
|
||||
showHeader && "md:pt-16" // Add top padding only on desktop to account for fixed header
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
|
||||
@@ -48,7 +48,7 @@ export function LayoutMobileMenu({
|
||||
const t = useTranslations();
|
||||
|
||||
return (
|
||||
<div className="shrink-0 md:hidden fixed top-0 left-0 right-0 z-50 bg-card border-b border-border">
|
||||
<div className="shrink-0 md:hidden sticky top-0 z-50">
|
||||
<div className="h-16 flex items-center px-2">
|
||||
<div className="flex items-center gap-4">
|
||||
{showSidebar && (
|
||||
|
||||
@@ -198,7 +198,7 @@ export default function ProxyResourcesTable({
|
||||
|
||||
if (!targets || targets.length === 0) {
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<div id="LOOK_FOR_ME" className="flex items-center gap-2">
|
||||
<StatusIcon status="unknown" />
|
||||
<span className="text-sm">
|
||||
{t("resourcesTableNoTargets")}
|
||||
|
||||
@@ -32,12 +32,6 @@ export default function ResourceInfoBox({}: ResourceInfoBoxType) {
|
||||
<InfoSections
|
||||
cols={resource.http && env.flags.usePangolinDns ? 5 : 4}
|
||||
>
|
||||
<InfoSection>
|
||||
<InfoSectionTitle>URL</InfoSectionTitle>
|
||||
<InfoSectionContent>
|
||||
<CopyToClipboard text={fullUrl} isLink={true} />
|
||||
</InfoSectionContent>
|
||||
</InfoSection>
|
||||
<InfoSection>
|
||||
<InfoSectionTitle>{t("identifier")}</InfoSectionTitle>
|
||||
<InfoSectionContent>
|
||||
@@ -46,6 +40,12 @@ export default function ResourceInfoBox({}: ResourceInfoBoxType) {
|
||||
</InfoSection>
|
||||
{resource.http ? (
|
||||
<>
|
||||
<InfoSection>
|
||||
<InfoSectionTitle>URL</InfoSectionTitle>
|
||||
<InfoSectionContent>
|
||||
<CopyToClipboard text={fullUrl} isLink={true} />
|
||||
</InfoSectionContent>
|
||||
</InfoSection>
|
||||
<InfoSection>
|
||||
<InfoSectionTitle>
|
||||
{t("authentication")}
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect } from "react";
|
||||
|
||||
/**
|
||||
* Fixes mobile viewport height issues when keyboard opens/closes
|
||||
* by setting a CSS variable with a stable viewport height
|
||||
* Only applies on mobile devices (< 768px, matching Tailwind's md breakpoint)
|
||||
*/
|
||||
export function ViewportHeightFix() {
|
||||
useEffect(() => {
|
||||
// Check if we're on mobile (md breakpoint is typically 768px)
|
||||
const isMobile = () => window.innerWidth < 768;
|
||||
|
||||
// On desktop, don't set --vh at all, let CSS use 100vh directly
|
||||
if (!isMobile()) {
|
||||
// Remove --vh if it was set, so CSS falls back to 100vh
|
||||
document.documentElement.style.removeProperty("--vh");
|
||||
return;
|
||||
}
|
||||
|
||||
// Mobile-specific logic
|
||||
let maxHeight = window.innerHeight;
|
||||
let resizeTimer: NodeJS.Timeout;
|
||||
|
||||
// Set the viewport height as a CSS variable
|
||||
const setViewportHeight = (height: number) => {
|
||||
document.documentElement.style.setProperty("--vh", `${height}px`);
|
||||
};
|
||||
|
||||
// Set initial value
|
||||
setViewportHeight(maxHeight);
|
||||
|
||||
const handleResize = () => {
|
||||
// If we switched to desktop, remove --vh and stop
|
||||
if (!isMobile()) {
|
||||
document.documentElement.style.removeProperty("--vh");
|
||||
return;
|
||||
}
|
||||
|
||||
clearTimeout(resizeTimer);
|
||||
resizeTimer = setTimeout(() => {
|
||||
const currentHeight = window.innerHeight;
|
||||
|
||||
// Track the maximum height we've seen (when keyboard is closed)
|
||||
if (currentHeight > maxHeight) {
|
||||
maxHeight = currentHeight;
|
||||
setViewportHeight(maxHeight);
|
||||
}
|
||||
// If current height is close to max, update max (keyboard closed)
|
||||
else if (currentHeight >= maxHeight * 0.9) {
|
||||
maxHeight = currentHeight;
|
||||
setViewportHeight(maxHeight);
|
||||
}
|
||||
// Otherwise, keep using the max height (keyboard is open)
|
||||
}, 100);
|
||||
};
|
||||
|
||||
const handleOrientationChange = () => {
|
||||
// Reset on orientation change
|
||||
setTimeout(() => {
|
||||
maxHeight = window.innerHeight;
|
||||
setViewportHeight(maxHeight);
|
||||
}, 150);
|
||||
};
|
||||
|
||||
window.addEventListener("resize", handleResize);
|
||||
window.addEventListener("orientationchange", handleOrientationChange);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener("resize", handleResize);
|
||||
window.removeEventListener("orientationchange", handleOrientationChange);
|
||||
clearTimeout(resizeTimer);
|
||||
};
|
||||
}, []);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -73,35 +73,30 @@ const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
|
||||
>
|
||||
{asChild ? (
|
||||
props.children
|
||||
) : (
|
||||
) : loading ? (
|
||||
<span className="relative inline-flex items-center justify-center">
|
||||
<span
|
||||
className={cn(
|
||||
"inline-flex items-center justify-center",
|
||||
loading && "opacity-0"
|
||||
)}
|
||||
>
|
||||
<span className="inline-flex items-center justify-center opacity-0">
|
||||
{props.children}
|
||||
</span>
|
||||
{loading && (
|
||||
<span className="absolute inset-0 flex items-center justify-center">
|
||||
<span className="flex items-center gap-1">
|
||||
<span
|
||||
className="h-1 w-1 bg-current animate-dot-pulse"
|
||||
style={{ animationDelay: "0ms" }}
|
||||
/>
|
||||
<span
|
||||
className="h-1 w-1 bg-current animate-dot-pulse"
|
||||
style={{ animationDelay: "200ms" }}
|
||||
/>
|
||||
<span
|
||||
className="h-1 w-1 bg-current animate-dot-pulse"
|
||||
style={{ animationDelay: "400ms" }}
|
||||
/>
|
||||
</span>
|
||||
<span className="absolute inset-0 flex items-center justify-center">
|
||||
<span className="flex items-center gap-1.5">
|
||||
<span
|
||||
className="h-1 w-1 bg-current animate-dot-pulse"
|
||||
style={{ animationDelay: "0ms" }}
|
||||
/>
|
||||
<span
|
||||
className="h-1 w-1 bg-current animate-dot-pulse"
|
||||
style={{ animationDelay: "200ms" }}
|
||||
/>
|
||||
<span
|
||||
className="h-1 w-1 bg-current animate-dot-pulse"
|
||||
style={{ animationDelay: "400ms" }}
|
||||
/>
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
</span>
|
||||
) : (
|
||||
props.children
|
||||
)}
|
||||
</Comp>
|
||||
);
|
||||
|
||||
@@ -14,13 +14,13 @@ const checkboxVariants = cva(
|
||||
variants: {
|
||||
variant: {
|
||||
outlinePrimary:
|
||||
"border rounded-[5px] border-primary data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground",
|
||||
"border rounded-[5px] border-input data-[state=checked]:border-primary data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground",
|
||||
outline:
|
||||
"border rounded-[5px] border-input data-[state=checked]:bg-muted data-[state=checked]:text-accent-foreground",
|
||||
"border rounded-[5px] border-input data-[state=checked]:border-primary data-[state=checked]:bg-muted data-[state=checked]:text-accent-foreground",
|
||||
outlinePrimarySquare:
|
||||
"border rounded-[5px] border-primary data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground",
|
||||
"border rounded-[5px] border-input data-[state=checked]:border-primary data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground",
|
||||
outlineSquare:
|
||||
"border rounded-[5px] border-input data-[state=checked]:bg-muted data-[state=checked]:text-accent-foreground"
|
||||
"border rounded-[5px] border-input data-[state=checked]:border-primary data-[state=checked]:bg-muted data-[state=checked]:text-accent-foreground"
|
||||
}
|
||||
},
|
||||
defaultVariants: {
|
||||
@@ -30,8 +30,7 @@ const checkboxVariants = cva(
|
||||
);
|
||||
|
||||
interface CheckboxProps
|
||||
extends
|
||||
React.ComponentPropsWithoutRef<typeof CheckboxPrimitive.Root>,
|
||||
extends React.ComponentPropsWithoutRef<typeof CheckboxPrimitive.Root>,
|
||||
VariantProps<typeof checkboxVariants> {}
|
||||
|
||||
const Checkbox = React.forwardRef<
|
||||
@@ -50,9 +49,8 @@ const Checkbox = React.forwardRef<
|
||||
));
|
||||
Checkbox.displayName = CheckboxPrimitive.Root.displayName;
|
||||
|
||||
interface CheckboxWithLabelProps extends React.ComponentPropsWithoutRef<
|
||||
typeof Checkbox
|
||||
> {
|
||||
interface CheckboxWithLabelProps
|
||||
extends React.ComponentPropsWithoutRef<typeof Checkbox> {
|
||||
label: string;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user