Compare commits

...

41 Commits

Author SHA1 Message Date
Owen Schwartz 7590e8d8a1 Merge pull request #3345 from fosrl/dev
Show utility subnet on org
2026-06-25 13:05:32 -07:00
Owen 053ff1e799 Add utility subnet 2026-06-25 15:39:04 -04:00
Owen Schwartz c5ffca499e Merge pull request #3330 from fosrl/dev
1.19.3
2026-06-25 11:54:25 -07:00
Owen Schwartz 9ae54f445d Merge pull request #3343 from fosrl/crowdin_dev
New Crowdin updates
2026-06-25 11:45:14 -07:00
Owen Schwartz 8183d19400 New translations en-us.json (Norwegian Bokmal)
[ci skip]
2026-06-25 11:44:44 -07:00
Owen Schwartz 7425daad3f New translations en-us.json (Chinese Simplified)
[ci skip]
2026-06-25 11:44:43 -07:00
Owen Schwartz 39d61a35eb New translations en-us.json (Turkish)
[ci skip]
2026-06-25 11:44:41 -07:00
Owen Schwartz f3fe11c136 New translations en-us.json (Russian)
[ci skip]
2026-06-25 11:44:39 -07:00
Owen Schwartz 66b1b385a3 New translations en-us.json (Portuguese)
[ci skip]
2026-06-25 11:44:37 -07:00
Owen Schwartz 822a07d48e New translations en-us.json (Polish)
[ci skip]
2026-06-25 11:44:35 -07:00
Owen Schwartz 3c13b1ea15 New translations en-us.json (Dutch)
[ci skip]
2026-06-25 11:44:33 -07:00
Owen Schwartz fee635b861 New translations en-us.json (Korean)
[ci skip]
2026-06-25 11:44:31 -07:00
Owen Schwartz 7e4dea918a New translations en-us.json (Italian)
[ci skip]
2026-06-25 11:44:29 -07:00
Owen Schwartz 56187d61d5 New translations en-us.json (German)
[ci skip]
2026-06-25 11:44:27 -07:00
Owen Schwartz 36460d4cc0 New translations en-us.json (Danish)
[ci skip]
2026-06-25 11:44:25 -07:00
Owen Schwartz 88a9b92dc3 New translations en-us.json (Czech)
[ci skip]
2026-06-25 11:44:23 -07:00
Owen Schwartz dd26518d6f New translations en-us.json (Bulgarian)
[ci skip]
2026-06-25 11:44:21 -07:00
Owen Schwartz 60339706bb New translations en-us.json (Spanish)
[ci skip]
2026-06-25 11:44:19 -07:00
Owen Schwartz 4b1b3d3d5b New translations en-us.json (French)
[ci skip]
2026-06-25 11:44:17 -07:00
Owen cf21bacd9c Merge branch 'main' into dev 2026-06-25 14:30:05 -04:00
Owen Schwartz 80d257b94b Merge pull request #3336 from mr1beast/main
New translations en-us.json (Danish)
2026-06-25 11:24:56 -07:00
Owen Schwartz e0d0c5dcbf Merge pull request #3331 from Fredkiss3/feat/geoip-tag-in-tables
feat: Show GeoIp country flags in site & rules page
2026-06-25 11:23:42 -07:00
Fred KISSIE 0f02d1bc02 ♻️ remove deprecated ISO CS country code 2026-06-25 18:01:25 +02:00
Owen f8591f27c5 Fix no data when last data was over 90 days ago 2026-06-25 10:08:37 -04:00
Owen 877985deb3 Spellcheck 2026-06-24 18:36:01 -04:00
Owen be3877a3ce Rename for clarity 2026-06-24 18:36:01 -04:00
Owen 79de64dc07 Fix removing site not removing peer 2026-06-24 18:36:01 -04:00
miloschwartz d0defa380a remove split by command and space in role form 2026-06-24 17:52:21 -04:00
miloschwartz 4eba51de72 support delete resources associated with site 2026-06-24 17:45:44 -04:00
mr1beast a48032adb3 New translations en-us.json (Danish)
New translations en-us.json (Danish)
2026-06-24 21:25:57 +00:00
miloschwartz 6fe4eee336 improve org policy error message responses 2026-06-24 16:32:58 -04:00
Owen 242123b875 Implement non-redis lock 2026-06-24 16:01:05 -04:00
miloschwartz 2b38658ea6 make sidebar notification failures more resilient 2026-06-24 15:55:29 -04:00
miloschwartz b18a41e4aa adjust translation 2026-06-24 15:55:29 -04:00
Owen d303fa05cb Comment out the sync 2026-06-24 15:50:54 -04:00
Owen 75b87ffba7 Quiet log message 2026-06-24 15:49:51 -04:00
Owen 62fc2edae9 Add logging and fix removing alias 2026-06-24 15:28:46 -04:00
Owen 80b66cf9b9 Add locks to rebuilds 2026-06-24 14:13:11 -04:00
Owen 034bcbd271 Reorg 2026-06-24 11:54:56 -04:00
Fred KISSIE bb7729df00 💄 show geoip flag in policy access rule tab 2026-06-23 23:45:59 +02:00
Fred KISSIE e104489257 💄 Show GeoIp flag in site details page 2026-06-23 23:28:46 +02:00
65 changed files with 5557 additions and 673 deletions
+4 -1
View File
@@ -18,5 +18,8 @@
"[json]": { "[json]": {
"editor.defaultFormatter": "esbenp.prettier-vscode" "editor.defaultFormatter": "esbenp.prettier-vscode"
}, },
"editor.formatOnSave": true "editor.formatOnSave": true,
"cSpell.words": [
"nessicary"
]
} }
+8 -1
View File
@@ -66,9 +66,15 @@
"local": "Локална", "local": "Локална",
"edit": "Редактиране", "edit": "Редактиране",
"siteConfirmDelete": "Потвърждение на изтриване на сайта", "siteConfirmDelete": "Потвърждение на изтриване на сайта",
"siteConfirmDeleteAndResources": "Потвърдете изтриването на сайта и ресурсите",
"siteDelete": "Изтриване на сайта", "siteDelete": "Изтриване на сайта",
"siteDeleteAndResources": "Изтриване на сайта и ресурсите",
"siteMessageRemove": "След премахване, сайтът вече няма да бъде достъпен. Всички цели, свързани със сайта, също ще бъдат премахнати.", "siteMessageRemove": "След премахване, сайтът вече няма да бъде достъпен. Всички цели, свързани със сайта, също ще бъдат премахнати.",
"siteMessageRemoveAndResources": "Това ще изтрие окончателно всички публични и частни ресурси, свързани с този сайт, дори ако ресурсът е асоцииран и с други сайтове.",
"siteQuestionRemove": "Сигурни ли сте, че искате да премахнете сайта от организацията?", "siteQuestionRemove": "Сигурни ли сте, че искате да премахнете сайта от организацията?",
"siteQuestionRemoveAndResources": "Наистина ли желаете да изтриете този сайт и всички свързани ресурси?",
"sitesTableDeleteSite": "Изтриване на сайта",
"sitesTableDeleteSiteAndResources": "Изтриване на сайта и ресурсите",
"siteManageSites": "Управление на сайтове", "siteManageSites": "Управление на сайтове",
"siteDescription": "Създайте и управлявайте сайтове, за да осигурите свързаност със частни мрежи", "siteDescription": "Създайте и управлявайте сайтове, за да осигурите свързаност със частни мрежи",
"sitesBannerTitle": "Свържете се с мрежа.", "sitesBannerTitle": "Свържете се с мрежа.",
@@ -2338,6 +2344,7 @@
"createInternalResourceDialogDestinationCidrDescription": "CIDR диапазонът на ресурса в мрежата на сайта.", "createInternalResourceDialogDestinationCidrDescription": "CIDR диапазонът на ресурса в мрежата на сайта.",
"createInternalResourceDialogAlias": "Псевдоним", "createInternalResourceDialogAlias": "Псевдоним",
"createInternalResourceDialogAliasDescription": "По избор вътрешен DNS псевдоним за този ресурс.", "createInternalResourceDialogAliasDescription": "По избор вътрешен DNS псевдоним за този ресурс.",
"internalResourceAliasLocalWarning": "Синоними с окончание .local могат да причинят проблеми с резолюцията поради mDNS в някои мрежи.",
"internalResourceDownstreamSchemeRequired": "Методът е задължителен за HTTP ресурси", "internalResourceDownstreamSchemeRequired": "Методът е задължителен за HTTP ресурси",
"internalResourceHttpPortRequired": "Портът към целта е задължителен за HTTP ресурси", "internalResourceHttpPortRequired": "Портът към целта е задължителен за HTTP ресурси",
"siteConfiguration": "Конфигурация", "siteConfiguration": "Конфигурация",
@@ -2967,7 +2974,7 @@
"orgOrDomainIdMissing": "Липсва идентификатор на организация или домейн", "orgOrDomainIdMissing": "Липсва идентификатор на организация или домейн",
"loadingDNSRecords": "Зареждане на DNS записи...", "loadingDNSRecords": "Зареждане на DNS записи...",
"olmUpdateAvailableInfo": "Налична е актуализирана версия на Olm. Моля, актуализирайте до най-новата версия за най-добро преживяване.", "olmUpdateAvailableInfo": "Налична е актуализирана версия на Olm. Моля, актуализирайте до най-новата версия за най-добро преживяване.",
"updateAvailableInfo": "An updated version is available. Please update to the latest version for the best experience.", "updateAvailableInfo": "На разположение е обновена версия. Моля, обновете до най-новата версия за най-добър опит.",
"client": "Клиент", "client": "Клиент",
"proxyProtocol": "Настройки на прокси протокол", "proxyProtocol": "Настройки на прокси протокол",
"proxyProtocolDescription": "Конфигурирайте Proxy Protocol, за да запазите IP адресите на клиентите за TCP услуги.", "proxyProtocolDescription": "Конфигурирайте Proxy Protocol, за да запазите IP адресите на клиентите за TCP услуги.",
+8 -1
View File
@@ -66,9 +66,15 @@
"local": "Místní", "local": "Místní",
"edit": "Upravit", "edit": "Upravit",
"siteConfirmDelete": "Potvrdit odstranění lokality", "siteConfirmDelete": "Potvrdit odstranění lokality",
"siteConfirmDeleteAndResources": "Potvrdit odstranění lokality a zdrojů",
"siteDelete": "Odstranění lokality", "siteDelete": "Odstranění lokality",
"siteDeleteAndResources": "Odstranit lokalitu a zdroje",
"siteMessageRemove": "Po odstranění webu již nebude přístupný. Všechny cíle spojené s webem budou také odstraněny.", "siteMessageRemove": "Po odstranění webu již nebude přístupný. Všechny cíle spojené s webem budou také odstraněny.",
"siteMessageRemoveAndResources": "Toto trvale odstraní všechny veřejné a soukromé zdroje spojené s touto lokalitou, i když je zdroj také přiřazen k jiným lokalitám.",
"siteQuestionRemove": "Jste si jisti, že chcete odstranit tuto stránku z organizace?", "siteQuestionRemove": "Jste si jisti, že chcete odstranit tuto stránku z organizace?",
"siteQuestionRemoveAndResources": "Opravdu chcete odstranit tuto lokalitu a všechny přidružené zdroje?",
"sitesTableDeleteSite": "Odstranění lokality",
"sitesTableDeleteSiteAndResources": "Odstranit lokalitu a zdroje",
"siteManageSites": "Správa lokalit", "siteManageSites": "Správa lokalit",
"siteDescription": "Vytvořte a spravujte stránky pro povolení připojení k soukromým sítím", "siteDescription": "Vytvořte a spravujte stránky pro povolení připojení k soukromým sítím",
"sitesBannerTitle": "Připojit jakoukoli síť", "sitesBannerTitle": "Připojit jakoukoli síť",
@@ -2338,6 +2344,7 @@
"createInternalResourceDialogDestinationCidrDescription": "Rozsah zdrojů CIDR v síti webu.", "createInternalResourceDialogDestinationCidrDescription": "Rozsah zdrojů CIDR v síti webu.",
"createInternalResourceDialogAlias": "Alias", "createInternalResourceDialogAlias": "Alias",
"createInternalResourceDialogAliasDescription": "Volitelný interní DNS alias pro tento dokument.", "createInternalResourceDialogAliasDescription": "Volitelný interní DNS alias pro tento dokument.",
"internalResourceAliasLocalWarning": "Aliasy končící na .local mohou způsobit problémy s vyřešením díky mDNS v některých sítích.",
"internalResourceDownstreamSchemeRequired": "HTTP metoda je vyžadována pro HTTP zdroje", "internalResourceDownstreamSchemeRequired": "HTTP metoda je vyžadována pro HTTP zdroje",
"internalResourceHttpPortRequired": "Přípoječný port je nutný pro HTTP zdroj", "internalResourceHttpPortRequired": "Přípoječný port je nutný pro HTTP zdroj",
"siteConfiguration": "Konfigurace", "siteConfiguration": "Konfigurace",
@@ -2967,7 +2974,7 @@
"orgOrDomainIdMissing": "Chybí ID organizace nebo domény", "orgOrDomainIdMissing": "Chybí ID organizace nebo domény",
"loadingDNSRecords": "Načítání DNS záznamů...", "loadingDNSRecords": "Načítání DNS záznamů...",
"olmUpdateAvailableInfo": "Je k dispozici aktualizovaná verze Olm. Pro nejlepší zážitek prosím aktualizujte na nejnovější verzi.", "olmUpdateAvailableInfo": "Je k dispozici aktualizovaná verze Olm. Pro nejlepší zážitek prosím aktualizujte na nejnovější verzi.",
"updateAvailableInfo": "An updated version is available. Please update to the latest version for the best experience.", "updateAvailableInfo": "Je k dispozici aktualizovaná verze. Aktualizujte prosím na nejnovější verzi pro nejlepší zážitek.",
"client": "Zákazník", "client": "Zákazník",
"proxyProtocol": "Nastavení proxy protokolu", "proxyProtocol": "Nastavení proxy protokolu",
"proxyProtocolDescription": "Konfigurace Proxy protokolu pro zachování klientských IP adres pro služby TCP.", "proxyProtocolDescription": "Konfigurace Proxy protokolu pro zachování klientských IP adres pro služby TCP.",
+3608
View File
File diff suppressed because it is too large Load Diff
+8 -1
View File
@@ -66,9 +66,15 @@
"local": "Lokal", "local": "Lokal",
"edit": "Bearbeiten", "edit": "Bearbeiten",
"siteConfirmDelete": "Löschen des Standorts bestätigen", "siteConfirmDelete": "Löschen des Standorts bestätigen",
"siteConfirmDeleteAndResources": "Löschen von Standort und Ressourcen bestätigen",
"siteDelete": "Standort löschen", "siteDelete": "Standort löschen",
"siteDeleteAndResources": "Standort und Ressourcen löschen",
"siteMessageRemove": "Sobald der Standort entfernt ist, wird er nicht mehr zugänglich sein. Alle mit dem Standort verbundenen Ziele werden ebenfalls entfernt.", "siteMessageRemove": "Sobald der Standort entfernt ist, wird er nicht mehr zugänglich sein. Alle mit dem Standort verbundenen Ziele werden ebenfalls entfernt.",
"siteMessageRemoveAndResources": "Dies wird dauerhaft alle öffentlichen und privaten Ressourcen, die mit diesem Standort verknüpft sind, löschen, selbst wenn eine Ressource auch mit anderen Standorten verbunden ist.",
"siteQuestionRemove": "Sind Sie sicher, dass Sie den Standort aus der Organisation entfernen möchten?", "siteQuestionRemove": "Sind Sie sicher, dass Sie den Standort aus der Organisation entfernen möchten?",
"siteQuestionRemoveAndResources": "Sind Sie sicher, dass Sie diesen Standort und alle zugehörigen Ressourcen löschen möchten?",
"sitesTableDeleteSite": "Standort löschen",
"sitesTableDeleteSiteAndResources": "Standort und Ressourcen löschen",
"siteManageSites": "Standorte verwalten", "siteManageSites": "Standorte verwalten",
"siteDescription": "Erstellen und Verwalten von Standorten, um die Verbindung zu privaten Netzwerken zu ermöglichen", "siteDescription": "Erstellen und Verwalten von Standorten, um die Verbindung zu privaten Netzwerken zu ermöglichen",
"sitesBannerTitle": "Verbinde ein beliebiges Netzwerk", "sitesBannerTitle": "Verbinde ein beliebiges Netzwerk",
@@ -2338,6 +2344,7 @@
"createInternalResourceDialogDestinationCidrDescription": "Der CIDR-Bereich der Ressource im Netzwerk der Website.", "createInternalResourceDialogDestinationCidrDescription": "Der CIDR-Bereich der Ressource im Netzwerk der Website.",
"createInternalResourceDialogAlias": "Alias", "createInternalResourceDialogAlias": "Alias",
"createInternalResourceDialogAliasDescription": "Ein optionaler interner DNS-Alias für diese Ressource.", "createInternalResourceDialogAliasDescription": "Ein optionaler interner DNS-Alias für diese Ressource.",
"internalResourceAliasLocalWarning": "Aliasse, die auf .local enden, können aufgrund von mDNS in einigen Netzwerken zu Auflösungsproblemen führen.",
"internalResourceDownstreamSchemeRequired": "Schema ist für HTTP-Ressourcen erforderlich", "internalResourceDownstreamSchemeRequired": "Schema ist für HTTP-Ressourcen erforderlich",
"internalResourceHttpPortRequired": "Zielport ist für HTTP-Ressourcen erforderlich", "internalResourceHttpPortRequired": "Zielport ist für HTTP-Ressourcen erforderlich",
"siteConfiguration": "Konfiguration", "siteConfiguration": "Konfiguration",
@@ -2967,7 +2974,7 @@
"orgOrDomainIdMissing": "Organisation oder Domänen-ID fehlt", "orgOrDomainIdMissing": "Organisation oder Domänen-ID fehlt",
"loadingDNSRecords": "Lade DNS-Einträge...", "loadingDNSRecords": "Lade DNS-Einträge...",
"olmUpdateAvailableInfo": "Eine aktualisierte Version von Olm ist verfügbar. Bitte aktualisieren Sie auf die neueste Version für die beste Erfahrung.", "olmUpdateAvailableInfo": "Eine aktualisierte Version von Olm ist verfügbar. Bitte aktualisieren Sie auf die neueste Version für die beste Erfahrung.",
"updateAvailableInfo": "An updated version is available. Please update to the latest version for the best experience.", "updateAvailableInfo": "Eine aktualisierte Version ist verfügbar. Bitte aktualisieren Sie auf die neueste Version für das beste Erlebnis.",
"client": "Client", "client": "Client",
"proxyProtocol": "Proxy-Protokoll-Einstellungen", "proxyProtocol": "Proxy-Protokoll-Einstellungen",
"proxyProtocolDescription": "Konfigurieren Sie das Proxy-Protokoll, um die IP-Adressen des Clients für TCP-Dienste zu erhalten.", "proxyProtocolDescription": "Konfigurieren Sie das Proxy-Protokoll, um die IP-Adressen des Clients für TCP-Dienste zu erhalten.",
+12 -5
View File
@@ -66,9 +66,15 @@
"local": "Local", "local": "Local",
"edit": "Edit", "edit": "Edit",
"siteConfirmDelete": "Confirm Delete Site", "siteConfirmDelete": "Confirm Delete Site",
"siteConfirmDeleteAndResources": "Confirm Delete Site and Resources",
"siteDelete": "Delete Site", "siteDelete": "Delete Site",
"siteMessageRemove": "Once removed the site will no longer be accessible. All targets associated with the site will also be removed.", "siteDeleteAndResources": "Delete Site and Resources",
"siteMessageRemove": "Once removed the site will no longer be accessible. Targets associated with this site will be removed, but resources will remain.",
"siteMessageRemoveAndResources": "This will permanently delete all public and private resources linked to this site, even if a resource is also associated with other sites.",
"siteQuestionRemove": "Are you sure you want to remove the site from the organization?", "siteQuestionRemove": "Are you sure you want to remove the site from the organization?",
"siteQuestionRemoveAndResources": "Are you sure you want to delete this site and all associated resources?",
"sitesTableDeleteSite": "Delete Site",
"sitesTableDeleteSiteAndResources": "Delete Site and Resources",
"siteManageSites": "Manage Sites", "siteManageSites": "Manage Sites",
"siteDescription": "Create and manage sites to enable connectivity to private networks", "siteDescription": "Create and manage sites to enable connectivity to private networks",
"sitesBannerTitle": "Connect Any Network", "sitesBannerTitle": "Connect Any Network",
@@ -204,7 +210,7 @@
"proxyResourceTitle": "Manage Public Resources", "proxyResourceTitle": "Manage Public Resources",
"proxyResourceDescription": "Create and manage resources that are publicly accessible through a web browser", "proxyResourceDescription": "Create and manage resources that are publicly accessible through a web browser",
"publicResourcesBannerTitle": "Web-based Public Access", "publicResourcesBannerTitle": "Web-based Public Access",
"publicResourcesBannerDescription": "Public resources are HTTPS proxies accessible to anyone on the internet through a web browser. Unlike private resources, they do not require client-side software and can include identity and context-aware access policies.", "publicResourcesBannerDescription": "Public resources are proxies accessible to anyone on the internet through a web browser and include identity and context-aware access policies. Unlike private resources, they do not require client-side software.",
"clientResourceTitle": "Manage Private Resources", "clientResourceTitle": "Manage Private Resources",
"clientResourceDescription": "Create and manage resources that are only accessible through a connected client", "clientResourceDescription": "Create and manage resources that are only accessible through a connected client",
"privateResourcesBannerTitle": "Zero-Trust Private Access", "privateResourcesBannerTitle": "Zero-Trust Private Access",
@@ -1638,7 +1644,7 @@
"alertingActionType": "Action type", "alertingActionType": "Action type",
"alertingNotifyUsers": "Users", "alertingNotifyUsers": "Users",
"alertingNotifyRoles": "Roles", "alertingNotifyRoles": "Roles",
"alertingNotifyEmails": "Email addresses", "alertingNotifyEmails": "Email Addresses",
"alertingEmailPlaceholder": "Add email and press Enter", "alertingEmailPlaceholder": "Add email and press Enter",
"alertingWebhookMethod": "HTTP method", "alertingWebhookMethod": "HTTP method",
"alertingWebhookSecret": "Signing secret (optional)", "alertingWebhookSecret": "Signing secret (optional)",
@@ -2171,10 +2177,10 @@
"sshSudoModeCommandsDescription": "User can run only the specified commands with sudo.", "sshSudoModeCommandsDescription": "User can run only the specified commands with sudo.",
"sshSudo": "Allow sudo", "sshSudo": "Allow sudo",
"sshSudoCommands": "Sudo Commands", "sshSudoCommands": "Sudo Commands",
"sshSudoCommandsDescription": "List of commands the user is allowed to run with sudo, separated by commas, spaces, or new lines. Absolute paths must be used.", "sshSudoCommandsDescription": "List of commands the user is allowed to run with sudo, one per line. Absolute paths must be used.",
"sshCreateHomeDir": "Create Home Directory", "sshCreateHomeDir": "Create Home Directory",
"sshUnixGroups": "Unix Groups", "sshUnixGroups": "Unix Groups",
"sshUnixGroupsDescription": "Unix groups to add the user to on the target host, separated by commas, spaces, or new lines.", "sshUnixGroupsDescription": "Unix groups to add the user to on the target host, one per line.",
"roleTextFieldPlaceholder": "Enter values, or drop a .txt or .csv file", "roleTextFieldPlaceholder": "Enter values, or drop a .txt or .csv file",
"roleTextImportTitle": "Import from File", "roleTextImportTitle": "Import from File",
"roleTextImportDescription": "Importing {fileName} into {fieldLabel}.", "roleTextImportDescription": "Importing {fileName} into {fieldLabel}.",
@@ -2550,6 +2556,7 @@
"idpGoogleDescription": "Google OAuth2/OIDC provider", "idpGoogleDescription": "Google OAuth2/OIDC provider",
"idpAzureDescription": "Microsoft Azure OAuth2/OIDC provider", "idpAzureDescription": "Microsoft Azure OAuth2/OIDC provider",
"subnet": "Subnet", "subnet": "Subnet",
"utilitySubnet": "Utility Subnet",
"subnetDescription": "The subnet for this organization's network configuration.", "subnetDescription": "The subnet for this organization's network configuration.",
"customDomain": "Custom Domain", "customDomain": "Custom Domain",
"authPage": "Authentication Pages", "authPage": "Authentication Pages",
+8 -1
View File
@@ -66,9 +66,15 @@
"local": "Local", "local": "Local",
"edit": "Editar", "edit": "Editar",
"siteConfirmDelete": "Confirmar Borrar Sitio", "siteConfirmDelete": "Confirmar Borrar Sitio",
"siteConfirmDeleteAndResources": "Confirmar eliminación del sitio y recursos",
"siteDelete": "Eliminar sitio", "siteDelete": "Eliminar sitio",
"siteDeleteAndResources": "Eliminar sitio y recursos",
"siteMessageRemove": "Una vez eliminado, el sitio ya no será accesible. Todos los objetivos asociados con el sitio también serán eliminados.", "siteMessageRemove": "Una vez eliminado, el sitio ya no será accesible. Todos los objetivos asociados con el sitio también serán eliminados.",
"siteMessageRemoveAndResources": "Esto eliminará permanentemente todos los recursos públicos y privados vinculados a este sitio, incluso si un recurso también está asociado con otros sitios.",
"siteQuestionRemove": "¿Está seguro que desea eliminar el sitio de la organización?", "siteQuestionRemove": "¿Está seguro que desea eliminar el sitio de la organización?",
"siteQuestionRemoveAndResources": "¿Está seguro de que desea eliminar este sitio y todos los recursos asociados?",
"sitesTableDeleteSite": "Eliminar sitio",
"sitesTableDeleteSiteAndResources": "Eliminar sitio y recursos",
"siteManageSites": "Administrar Sitios", "siteManageSites": "Administrar Sitios",
"siteDescription": "Crear y administrar sitios para permitir la conectividad a redes privadas", "siteDescription": "Crear y administrar sitios para permitir la conectividad a redes privadas",
"sitesBannerTitle": "Conectar cualquier red", "sitesBannerTitle": "Conectar cualquier red",
@@ -2338,6 +2344,7 @@
"createInternalResourceDialogDestinationCidrDescription": "El rango CIDR del recurso en la red del sitio.", "createInternalResourceDialogDestinationCidrDescription": "El rango CIDR del recurso en la red del sitio.",
"createInternalResourceDialogAlias": "Alias", "createInternalResourceDialogAlias": "Alias",
"createInternalResourceDialogAliasDescription": "Un alias DNS interno opcional para este recurso.", "createInternalResourceDialogAliasDescription": "Un alias DNS interno opcional para este recurso.",
"internalResourceAliasLocalWarning": "Los alias que terminan en .local pueden causar problemas de resolución debido a mDNS en algunas redes.",
"internalResourceDownstreamSchemeRequired": "Se requiere el método para recursos HTTP", "internalResourceDownstreamSchemeRequired": "Se requiere el método para recursos HTTP",
"internalResourceHttpPortRequired": "Se requiere el puerto de destino para recursos HTTP", "internalResourceHttpPortRequired": "Se requiere el puerto de destino para recursos HTTP",
"siteConfiguration": "Configuración", "siteConfiguration": "Configuración",
@@ -2967,7 +2974,7 @@
"orgOrDomainIdMissing": "Falta el ID de organización o dominio", "orgOrDomainIdMissing": "Falta el ID de organización o dominio",
"loadingDNSRecords": "Cargando registros DNS...", "loadingDNSRecords": "Cargando registros DNS...",
"olmUpdateAvailableInfo": "Una versión actualizada de Olm está disponible. Por favor, actualice a la última versión para obtener la mejor experiencia.", "olmUpdateAvailableInfo": "Una versión actualizada de Olm está disponible. Por favor, actualice a la última versión para obtener la mejor experiencia.",
"updateAvailableInfo": "An updated version is available. Please update to the latest version for the best experience.", "updateAvailableInfo": "Hay una versión actualizada disponible. Actualice a la última versión para obtener la mejor experiencia.",
"client": "Cliente", "client": "Cliente",
"proxyProtocol": "Configuración del Protocolo Proxy", "proxyProtocol": "Configuración del Protocolo Proxy",
"proxyProtocolDescription": "Configurar el protocolo de proxy para preservar las direcciones IP del cliente para los servicios TCP.", "proxyProtocolDescription": "Configurar el protocolo de proxy para preservar las direcciones IP del cliente para los servicios TCP.",
+8 -1
View File
@@ -66,9 +66,15 @@
"local": "Locale", "local": "Locale",
"edit": "Modifier", "edit": "Modifier",
"siteConfirmDelete": "Confirmer la suppression du nœud", "siteConfirmDelete": "Confirmer la suppression du nœud",
"siteConfirmDeleteAndResources": "Confirmer la suppression du site et des ressources",
"siteDelete": "Supprimer le nœud", "siteDelete": "Supprimer le nœud",
"siteDeleteAndResources": "Supprimer le site et les ressources",
"siteMessageRemove": "Une fois supprimé, le nœud ne sera plus accessible. Toutes les cibles associées au nœud seront également supprimées.", "siteMessageRemove": "Une fois supprimé, le nœud ne sera plus accessible. Toutes les cibles associées au nœud seront également supprimées.",
"siteMessageRemoveAndResources": "Cela supprimera définitivement toutes les ressources publiques et privées liées à ce site, même si une ressource est également associée à d'autres sites.",
"siteQuestionRemove": "Êtes-vous sûr de vouloir supprimer ce nœud de l'organisation ?", "siteQuestionRemove": "Êtes-vous sûr de vouloir supprimer ce nœud de l'organisation ?",
"siteQuestionRemoveAndResources": "Êtes-vous sûr de vouloir supprimer ce site et toutes les ressources associées?",
"sitesTableDeleteSite": "Supprimer le site",
"sitesTableDeleteSiteAndResources": "Supprimer le site et les ressources",
"siteManageSites": "Gérer les nœuds", "siteManageSites": "Gérer les nœuds",
"siteDescription": "Créer et gérer des sites pour activer la connectivité aux réseaux privés", "siteDescription": "Créer et gérer des sites pour activer la connectivité aux réseaux privés",
"sitesBannerTitle": "Se connecter à n'importe quel réseau", "sitesBannerTitle": "Se connecter à n'importe quel réseau",
@@ -2338,6 +2344,7 @@
"createInternalResourceDialogDestinationCidrDescription": "La gamme CIDR de la ressource sur le réseau du site.", "createInternalResourceDialogDestinationCidrDescription": "La gamme CIDR de la ressource sur le réseau du site.",
"createInternalResourceDialogAlias": "Alias", "createInternalResourceDialogAlias": "Alias",
"createInternalResourceDialogAliasDescription": "Un alias DNS interne optionnel pour cette ressource.", "createInternalResourceDialogAliasDescription": "Un alias DNS interne optionnel pour cette ressource.",
"internalResourceAliasLocalWarning": "Les alias se terminant par .local peuvent causer des problèmes de résolution dus au mDNS sur certains réseaux.",
"internalResourceDownstreamSchemeRequired": "Un schéma est requis pour les ressources HTTP", "internalResourceDownstreamSchemeRequired": "Un schéma est requis pour les ressources HTTP",
"internalResourceHttpPortRequired": "Le port de destination est requis pour les ressources HTTP", "internalResourceHttpPortRequired": "Le port de destination est requis pour les ressources HTTP",
"siteConfiguration": "Configuration", "siteConfiguration": "Configuration",
@@ -2967,7 +2974,7 @@
"orgOrDomainIdMissing": "L'organisation ou l'identifiant de domaine est manquant", "orgOrDomainIdMissing": "L'organisation ou l'identifiant de domaine est manquant",
"loadingDNSRecords": "Chargement des enregistrements DNS...", "loadingDNSRecords": "Chargement des enregistrements DNS...",
"olmUpdateAvailableInfo": "Une version mise à jour de Olm est disponible. Veuillez mettre à jour vers la dernière version pour la meilleure expérience.", "olmUpdateAvailableInfo": "Une version mise à jour de Olm est disponible. Veuillez mettre à jour vers la dernière version pour la meilleure expérience.",
"updateAvailableInfo": "An updated version is available. Please update to the latest version for the best experience.", "updateAvailableInfo": "Une version mise à jour est disponible. Veuillez mettre à jour vers la dernière version pour une meilleure expérience.",
"client": "Client", "client": "Client",
"proxyProtocol": "Paramètres du protocole proxy", "proxyProtocol": "Paramètres du protocole proxy",
"proxyProtocolDescription": "Configurer le protocole Proxy pour préserver les adresses IP du client pour les services TCP.", "proxyProtocolDescription": "Configurer le protocole Proxy pour préserver les adresses IP du client pour les services TCP.",
+8 -1
View File
@@ -66,9 +66,15 @@
"local": "Locale", "local": "Locale",
"edit": "Modifica", "edit": "Modifica",
"siteConfirmDelete": "Conferma Eliminazione Sito", "siteConfirmDelete": "Conferma Eliminazione Sito",
"siteConfirmDeleteAndResources": "Conferma Eliminazione Sito e Risorse",
"siteDelete": "Elimina Sito", "siteDelete": "Elimina Sito",
"siteDeleteAndResources": "Elimina Sito e Risorse",
"siteMessageRemove": "Una volta rimosso il sito non sarà più accessibile. Tutti gli oggetti associati al sito verranno rimossi.", "siteMessageRemove": "Una volta rimosso il sito non sarà più accessibile. Tutti gli oggetti associati al sito verranno rimossi.",
"siteMessageRemoveAndResources": "Questo eliminerà permanentemente tutte le risorse pubbliche e private collegate a questo sito, anche se una risorsa è anche associata ad altri siti.",
"siteQuestionRemove": "Sei sicuro di voler rimuovere il sito dall'organizzazione?", "siteQuestionRemove": "Sei sicuro di voler rimuovere il sito dall'organizzazione?",
"siteQuestionRemoveAndResources": "Sei sicuro di voler eliminare questo sito e tutte le risorse associate?",
"sitesTableDeleteSite": "Elimina Sito",
"sitesTableDeleteSiteAndResources": "Elimina Sito e Risorse",
"siteManageSites": "Gestisci Siti", "siteManageSites": "Gestisci Siti",
"siteDescription": "Creare e gestire siti per abilitare la connettività a reti private", "siteDescription": "Creare e gestire siti per abilitare la connettività a reti private",
"sitesBannerTitle": "Connetti Qualsiasi Rete", "sitesBannerTitle": "Connetti Qualsiasi Rete",
@@ -2338,6 +2344,7 @@
"createInternalResourceDialogDestinationCidrDescription": "La gamma CIDR della risorsa sulla rete del sito.", "createInternalResourceDialogDestinationCidrDescription": "La gamma CIDR della risorsa sulla rete del sito.",
"createInternalResourceDialogAlias": "Alias", "createInternalResourceDialogAlias": "Alias",
"createInternalResourceDialogAliasDescription": "Un alias DNS interno opzionale per questa risorsa.", "createInternalResourceDialogAliasDescription": "Un alias DNS interno opzionale per questa risorsa.",
"internalResourceAliasLocalWarning": "Gli alias che terminano in .local possono causare problemi di risoluzione a causa di mDNS su alcune reti.",
"internalResourceDownstreamSchemeRequired": "Il metodo è richiesto per risorse HTTP", "internalResourceDownstreamSchemeRequired": "Il metodo è richiesto per risorse HTTP",
"internalResourceHttpPortRequired": "Porta di destinazione richiesta per risorse HTTP", "internalResourceHttpPortRequired": "Porta di destinazione richiesta per risorse HTTP",
"siteConfiguration": "Configurazione", "siteConfiguration": "Configurazione",
@@ -2967,7 +2974,7 @@
"orgOrDomainIdMissing": "Manca l'ID dell'organizzazione o del dominio", "orgOrDomainIdMissing": "Manca l'ID dell'organizzazione o del dominio",
"loadingDNSRecords": "Caricamento record DNS...", "loadingDNSRecords": "Caricamento record DNS...",
"olmUpdateAvailableInfo": "È disponibile una versione aggiornata di Olm. Si prega di aggiornare all'ultima versione per la migliore esperienza.", "olmUpdateAvailableInfo": "È disponibile una versione aggiornata di Olm. Si prega di aggiornare all'ultima versione per la migliore esperienza.",
"updateAvailableInfo": "An updated version is available. Please update to the latest version for the best experience.", "updateAvailableInfo": "È disponibile una versione aggiornata. Si prega di aggiornare all'ultima versione per la migliore esperienza.",
"client": "Client", "client": "Client",
"proxyProtocol": "Impostazioni Protocollo Proxy", "proxyProtocol": "Impostazioni Protocollo Proxy",
"proxyProtocolDescription": "Configurare il protocollo proxy per preservare gli indirizzi IP client per i servizi TCP.", "proxyProtocolDescription": "Configurare il protocollo proxy per preservare gli indirizzi IP client per i servizi TCP.",
+8 -1
View File
@@ -66,9 +66,15 @@
"local": "로컬", "local": "로컬",
"edit": "편집", "edit": "편집",
"siteConfirmDelete": "사이트 삭제 확인", "siteConfirmDelete": "사이트 삭제 확인",
"siteConfirmDeleteAndResources": "사이트 및 리소스 삭제 확인",
"siteDelete": "사이트 삭제", "siteDelete": "사이트 삭제",
"siteDeleteAndResources": "사이트 및 리소스 삭제",
"siteMessageRemove": "삭제되면 사이트에 더 이상 액세스할 수 없습니다. 사이트와 연결된 모든 대상도 삭제됩니다.", "siteMessageRemove": "삭제되면 사이트에 더 이상 액세스할 수 없습니다. 사이트와 연결된 모든 대상도 삭제됩니다.",
"siteMessageRemoveAndResources": "이 사이트와 연결된 모든 공용 및 개인 리소스는 다른 사이트에도 연결되어 있더라도 영구적으로 삭제됩니다.",
"siteQuestionRemove": "조직에서 사이트를 제거하시겠습니까?", "siteQuestionRemove": "조직에서 사이트를 제거하시겠습니까?",
"siteQuestionRemoveAndResources": "이 사이트와 모든 관련 리소스를 삭제하시겠습니까?",
"sitesTableDeleteSite": "사이트 삭제",
"sitesTableDeleteSiteAndResources": "사이트 및 리소스 삭제",
"siteManageSites": "사이트 관리", "siteManageSites": "사이트 관리",
"siteDescription": "프라이빗 네트워크로의 연결을 활성화하려면 사이트를 생성하고 관리하세요.", "siteDescription": "프라이빗 네트워크로의 연결을 활성화하려면 사이트를 생성하고 관리하세요.",
"sitesBannerTitle": "모든 네트워크 연결", "sitesBannerTitle": "모든 네트워크 연결",
@@ -2338,6 +2344,7 @@
"createInternalResourceDialogDestinationCidrDescription": "사이트 네트워크의 자원 IP 주소입니다.", "createInternalResourceDialogDestinationCidrDescription": "사이트 네트워크의 자원 IP 주소입니다.",
"createInternalResourceDialogAlias": "별칭", "createInternalResourceDialogAlias": "별칭",
"createInternalResourceDialogAliasDescription": "이 리소스에 대한 선택적 내부 DNS 별칭입니다.", "createInternalResourceDialogAliasDescription": "이 리소스에 대한 선택적 내부 DNS 별칭입니다.",
"internalResourceAliasLocalWarning": ".local로 끝나는 별칭은 일부 네트워크에서 mDNS로 인해 해결 문제가 발생할 수 있습니다.",
"internalResourceDownstreamSchemeRequired": "HTTP 리소스에 스킴이 필요합니다", "internalResourceDownstreamSchemeRequired": "HTTP 리소스에 스킴이 필요합니다",
"internalResourceHttpPortRequired": "HTTP 리소스에 목적지 포트가 필요합니다", "internalResourceHttpPortRequired": "HTTP 리소스에 목적지 포트가 필요합니다",
"siteConfiguration": "설정", "siteConfiguration": "설정",
@@ -2967,7 +2974,7 @@
"orgOrDomainIdMissing": "조직 ID 또는 도메인 ID가 누락되었습니다", "orgOrDomainIdMissing": "조직 ID 또는 도메인 ID가 누락되었습니다",
"loadingDNSRecords": "DNS 레코드를 로드하는 중...", "loadingDNSRecords": "DNS 레코드를 로드하는 중...",
"olmUpdateAvailableInfo": "올름의 새 버전이 이용 가능합니다. 최상의 경험을 위해 최신 버전으로 업데이트하세요.", "olmUpdateAvailableInfo": "올름의 새 버전이 이용 가능합니다. 최상의 경험을 위해 최신 버전으로 업데이트하세요.",
"updateAvailableInfo": "An updated version is available. Please update to the latest version for the best experience.", "updateAvailableInfo": "업데이트된 버전이 있습니다. 최상의 경험을 위해 최신 버전으로 업데이트하세요.",
"client": "클라이언트", "client": "클라이언트",
"proxyProtocol": "프록시 프로토콜 설정", "proxyProtocol": "프록시 프로토콜 설정",
"proxyProtocolDescription": "TCP 서비스에 대한 클라이언트 IP 주소를 유지하도록 프록시 프로토콜을 구성하세요.", "proxyProtocolDescription": "TCP 서비스에 대한 클라이언트 IP 주소를 유지하도록 프록시 프로토콜을 구성하세요.",
+8 -1
View File
@@ -66,9 +66,15 @@
"local": "Lokal", "local": "Lokal",
"edit": "Rediger", "edit": "Rediger",
"siteConfirmDelete": "Bekreft Sletting av Område", "siteConfirmDelete": "Bekreft Sletting av Område",
"siteConfirmDeleteAndResources": "Bekreft sletting av nettsted og ressurser",
"siteDelete": "Slett Område", "siteDelete": "Slett Område",
"siteDeleteAndResources": "Slett nettsted og ressurser",
"siteMessageRemove": "Når nettstedet er fjernet, vil det ikke lenger være tilgjengelig. Alle målene for nettstedet vil også bli fjernet.", "siteMessageRemove": "Når nettstedet er fjernet, vil det ikke lenger være tilgjengelig. Alle målene for nettstedet vil også bli fjernet.",
"siteMessageRemoveAndResources": "Dette vil permanent slette alle offentlige og private ressurser tilknyttet dette nettstedet, selv om en ressurs også er tilknyttet andre nettsteder.",
"siteQuestionRemove": "Er du sikker på at du vil fjerne nettstedet fra organisasjonen?", "siteQuestionRemove": "Er du sikker på at du vil fjerne nettstedet fra organisasjonen?",
"siteQuestionRemoveAndResources": "Er du sikker på at du vil slette dette nettstedet og alle tilknyttede ressurser?",
"sitesTableDeleteSite": "Slett nettsted",
"sitesTableDeleteSiteAndResources": "Slett nettsted og ressurser",
"siteManageSites": "Administrer Områder", "siteManageSites": "Administrer Områder",
"siteDescription": "Opprette og administrere nettsteder for å aktivere tilkobling til private nettverk", "siteDescription": "Opprette og administrere nettsteder for å aktivere tilkobling til private nettverk",
"sitesBannerTitle": "Koble til alle nettverk", "sitesBannerTitle": "Koble til alle nettverk",
@@ -2338,6 +2344,7 @@
"createInternalResourceDialogDestinationCidrDescription": "CIDR-rekkevidden til ressursen på nettstedets nettverk.", "createInternalResourceDialogDestinationCidrDescription": "CIDR-rekkevidden til ressursen på nettstedets nettverk.",
"createInternalResourceDialogAlias": "Alias", "createInternalResourceDialogAlias": "Alias",
"createInternalResourceDialogAliasDescription": "Et valgfritt internt DNS-alias for denne ressursen.", "createInternalResourceDialogAliasDescription": "Et valgfritt internt DNS-alias for denne ressursen.",
"internalResourceAliasLocalWarning": "Alias som slutter på .local kan forårsake oppløsningsproblemer på grunn av mDNS på enkelte nettverk.",
"internalResourceDownstreamSchemeRequired": "Skjema er påkrevd for HTTP-ressurser", "internalResourceDownstreamSchemeRequired": "Skjema er påkrevd for HTTP-ressurser",
"internalResourceHttpPortRequired": "Destinasjonsport er nødvendig for HTTP-ressurser", "internalResourceHttpPortRequired": "Destinasjonsport er nødvendig for HTTP-ressurser",
"siteConfiguration": "Konfigurasjon", "siteConfiguration": "Konfigurasjon",
@@ -2967,7 +2974,7 @@
"orgOrDomainIdMissing": "ID for organisasjon eller domene mangler", "orgOrDomainIdMissing": "ID for organisasjon eller domene mangler",
"loadingDNSRecords": "Laster DNS-poster...", "loadingDNSRecords": "Laster DNS-poster...",
"olmUpdateAvailableInfo": "En oppdatert versjon av Olm er tilgjengelig. Oppdater til den nyeste versjonen for å få den beste opplevelsen.", "olmUpdateAvailableInfo": "En oppdatert versjon av Olm er tilgjengelig. Oppdater til den nyeste versjonen for å få den beste opplevelsen.",
"updateAvailableInfo": "An updated version is available. Please update to the latest version for the best experience.", "updateAvailableInfo": "En oppdatert versjon er tilgjengelig. Vennligst oppdater til den nyeste versjonen for den beste opplevelsen.",
"client": "Klient", "client": "Klient",
"proxyProtocol": "Protokoll innstillinger for Protokoll", "proxyProtocol": "Protokoll innstillinger for Protokoll",
"proxyProtocolDescription": "Konfigurer Proxy-protokoll for å bevare klientens IP-adresser til TCP-tjenester.", "proxyProtocolDescription": "Konfigurer Proxy-protokoll for å bevare klientens IP-adresser til TCP-tjenester.",
+8 -1
View File
@@ -66,9 +66,15 @@
"local": "Lokaal", "local": "Lokaal",
"edit": "Bewerken", "edit": "Bewerken",
"siteConfirmDelete": "Verwijderen van site bevestigen", "siteConfirmDelete": "Verwijderen van site bevestigen",
"siteConfirmDeleteAndResources": "Bevestig Verwijderen van Site en Bronnen",
"siteDelete": "Site verwijderen", "siteDelete": "Site verwijderen",
"siteDeleteAndResources": "Site en Bronnen verwijderen",
"siteMessageRemove": "Eenmaal verwijderd zal de site niet langer toegankelijk zijn. Alle aan de site gekoppelde doelen zullen ook worden verwijderd.", "siteMessageRemove": "Eenmaal verwijderd zal de site niet langer toegankelijk zijn. Alle aan de site gekoppelde doelen zullen ook worden verwijderd.",
"siteMessageRemoveAndResources": "Dit zal permanent alle publieke en private resources gekoppeld aan deze site verwijderen, zelfs als een resource ook aan andere sites is gekoppeld.",
"siteQuestionRemove": "Weet u zeker dat u de site wilt verwijderen uit de organisatie?", "siteQuestionRemove": "Weet u zeker dat u de site wilt verwijderen uit de organisatie?",
"siteQuestionRemoveAndResources": "Weet u zeker dat u deze site en alle gekoppelde resources wilt verwijderen?",
"sitesTableDeleteSite": "Site verwijderen",
"sitesTableDeleteSiteAndResources": "Site en Bronnen verwijderen",
"siteManageSites": "Sites beheren", "siteManageSites": "Sites beheren",
"siteDescription": "Maak en beheer sites om verbinding met privénetwerken in te schakelen", "siteDescription": "Maak en beheer sites om verbinding met privénetwerken in te schakelen",
"sitesBannerTitle": "Verbind elk netwerk", "sitesBannerTitle": "Verbind elk netwerk",
@@ -2338,6 +2344,7 @@
"createInternalResourceDialogDestinationCidrDescription": "Het CIDR-bereik van het document op het netwerk van de site.", "createInternalResourceDialogDestinationCidrDescription": "Het CIDR-bereik van het document op het netwerk van de site.",
"createInternalResourceDialogAlias": "Alias", "createInternalResourceDialogAlias": "Alias",
"createInternalResourceDialogAliasDescription": "Een optionele interne DNS-alias voor dit document.", "createInternalResourceDialogAliasDescription": "Een optionele interne DNS-alias voor dit document.",
"internalResourceAliasLocalWarning": "Aliassen die eindigen op .local kunnen resolutieproblemen veroorzaken vanwege mDNS op sommige netwerken.",
"internalResourceDownstreamSchemeRequired": "Schema is vereist voor HTTP-bronnen", "internalResourceDownstreamSchemeRequired": "Schema is vereist voor HTTP-bronnen",
"internalResourceHttpPortRequired": "Bestemmingspoort is vereist voor HTTP-bronnen", "internalResourceHttpPortRequired": "Bestemmingspoort is vereist voor HTTP-bronnen",
"siteConfiguration": "Configuratie", "siteConfiguration": "Configuratie",
@@ -2967,7 +2974,7 @@
"orgOrDomainIdMissing": "Organisatie of domein ID ontbreekt", "orgOrDomainIdMissing": "Organisatie of domein ID ontbreekt",
"loadingDNSRecords": "DNS-records laden...", "loadingDNSRecords": "DNS-records laden...",
"olmUpdateAvailableInfo": "Er is een bijgewerkte versie van Olm beschikbaar. Update alstublieft naar de nieuwste versie voor de beste ervaring.", "olmUpdateAvailableInfo": "Er is een bijgewerkte versie van Olm beschikbaar. Update alstublieft naar de nieuwste versie voor de beste ervaring.",
"updateAvailableInfo": "An updated version is available. Please update to the latest version for the best experience.", "updateAvailableInfo": "Er is een bijgewerkte versie beschikbaar. Update naar de nieuwste versie voor de beste ervaring.",
"client": "Klant", "client": "Klant",
"proxyProtocol": "Proxy Protocol Instellingen", "proxyProtocol": "Proxy Protocol Instellingen",
"proxyProtocolDescription": "Proxyprotocol configureren om de IP-adressen van de client voor TCP-diensten te bewaren.", "proxyProtocolDescription": "Proxyprotocol configureren om de IP-adressen van de client voor TCP-diensten te bewaren.",
+8 -1
View File
@@ -66,9 +66,15 @@
"local": "Lokalny", "local": "Lokalny",
"edit": "Edytuj", "edit": "Edytuj",
"siteConfirmDelete": "Potwierdź usunięcie witryny", "siteConfirmDelete": "Potwierdź usunięcie witryny",
"siteConfirmDeleteAndResources": "Potwierdź usunięcie witryny i zasobów",
"siteDelete": "Usuń witrynę", "siteDelete": "Usuń witrynę",
"siteDeleteAndResources": "Usuń witrynę i zasoby",
"siteMessageRemove": "Po usunięciu witryna nie będzie już dostępna. Wszystkie cele związane z witryną zostaną również usunięte.", "siteMessageRemove": "Po usunięciu witryna nie będzie już dostępna. Wszystkie cele związane z witryną zostaną również usunięte.",
"siteMessageRemoveAndResources": "To spowoduje trwałe usunięcie wszystkich zasobów publicznych i prywatnych powiązanych z tą witryną, nawet jeśli zasób jest także powiązany z innymi witrynami.",
"siteQuestionRemove": "Czy na pewno chcesz usunąć witrynę z organizacji?", "siteQuestionRemove": "Czy na pewno chcesz usunąć witrynę z organizacji?",
"siteQuestionRemoveAndResources": "Czy na pewno chcesz usunąć tę witrynę i wszystkie powiązane zasoby?",
"sitesTableDeleteSite": "Usuń witrynę",
"sitesTableDeleteSiteAndResources": "Usuń witrynę i zasoby",
"siteManageSites": "Zarządzaj stronami", "siteManageSites": "Zarządzaj stronami",
"siteDescription": "Tworzenie stron i zarządzanie nimi, aby włączyć połączenia z prywatnymi sieciami", "siteDescription": "Tworzenie stron i zarządzanie nimi, aby włączyć połączenia z prywatnymi sieciami",
"sitesBannerTitle": "Połącz dowolną sieć", "sitesBannerTitle": "Połącz dowolną sieć",
@@ -2338,6 +2344,7 @@
"createInternalResourceDialogDestinationCidrDescription": "Zakres CIDR zasobu w sieci witryny.", "createInternalResourceDialogDestinationCidrDescription": "Zakres CIDR zasobu w sieci witryny.",
"createInternalResourceDialogAlias": "Alias", "createInternalResourceDialogAlias": "Alias",
"createInternalResourceDialogAliasDescription": "Opcjonalny wewnętrzny alias DNS dla tego zasobu.", "createInternalResourceDialogAliasDescription": "Opcjonalny wewnętrzny alias DNS dla tego zasobu.",
"internalResourceAliasLocalWarning": "Alias kończący się na .local może powodować problemy z rozpoznawaniem z powodu mDNS w niektórych sieciach.",
"internalResourceDownstreamSchemeRequired": "Schemat jest wymagany dla zasobów HTTP", "internalResourceDownstreamSchemeRequired": "Schemat jest wymagany dla zasobów HTTP",
"internalResourceHttpPortRequired": "Port docelowy jest wymagany dla zasobów HTTP", "internalResourceHttpPortRequired": "Port docelowy jest wymagany dla zasobów HTTP",
"siteConfiguration": "Konfiguracja", "siteConfiguration": "Konfiguracja",
@@ -2967,7 +2974,7 @@
"orgOrDomainIdMissing": "Brakuje identyfikatora organizacji lub domeny", "orgOrDomainIdMissing": "Brakuje identyfikatora organizacji lub domeny",
"loadingDNSRecords": "Ładowanie rekordów DNS...", "loadingDNSRecords": "Ładowanie rekordów DNS...",
"olmUpdateAvailableInfo": "Dostępna jest zaktualizowana wersja Olm. Zaktualizuj do najnowszej wersji, aby uzyskać najlepsze doświadczenia.", "olmUpdateAvailableInfo": "Dostępna jest zaktualizowana wersja Olm. Zaktualizuj do najnowszej wersji, aby uzyskać najlepsze doświadczenia.",
"updateAvailableInfo": "An updated version is available. Please update to the latest version for the best experience.", "updateAvailableInfo": "Dostępna jest zaktualizowana wersja. Zaktualizuj do najnowszej wersji, aby uzyskać najlepsze wrażenia z użytkowania.",
"client": "Klient", "client": "Klient",
"proxyProtocol": "Ustawienia protokołu proxy", "proxyProtocol": "Ustawienia protokołu proxy",
"proxyProtocolDescription": "Skonfiguruj protokół Proxy aby zachować adresy IP klienta dla usług TCP.", "proxyProtocolDescription": "Skonfiguruj protokół Proxy aby zachować adresy IP klienta dla usług TCP.",
+8 -1
View File
@@ -66,9 +66,15 @@
"local": "Localização", "local": "Localização",
"edit": "Alterar", "edit": "Alterar",
"siteConfirmDelete": "Confirmar que pretende apagar o site", "siteConfirmDelete": "Confirmar que pretende apagar o site",
"siteConfirmDeleteAndResources": "Confirmar Exclusão do Site e Recursos",
"siteDelete": "Excluir site", "siteDelete": "Excluir site",
"siteDeleteAndResources": "Excluir Site e Recursos",
"siteMessageRemove": "Uma vez removido, o site não estará mais acessível. Todas as metas associadas ao site também serão removidas.", "siteMessageRemove": "Uma vez removido, o site não estará mais acessível. Todas as metas associadas ao site também serão removidas.",
"siteMessageRemoveAndResources": "Isso excluirá permanentemente todos os recursos públicos e privados vinculados a este site, mesmo que um recurso também esteja associado a outros sites.",
"siteQuestionRemove": "Você tem certeza que deseja remover este site da organização?", "siteQuestionRemove": "Você tem certeza que deseja remover este site da organização?",
"siteQuestionRemoveAndResources": "Tem certeza de que deseja excluir este site e todos os recursos associados?",
"sitesTableDeleteSite": "Excluir Site",
"sitesTableDeleteSiteAndResources": "Excluir Site e Recursos",
"siteManageSites": "Gerir sites", "siteManageSites": "Gerir sites",
"siteDescription": "Criar e gerenciar sites para ativar a conectividade a redes privadas", "siteDescription": "Criar e gerenciar sites para ativar a conectividade a redes privadas",
"sitesBannerTitle": "Conectar a Qualquer Rede", "sitesBannerTitle": "Conectar a Qualquer Rede",
@@ -2338,6 +2344,7 @@
"createInternalResourceDialogDestinationCidrDescription": "A faixa CIDR do recurso na rede do site.", "createInternalResourceDialogDestinationCidrDescription": "A faixa CIDR do recurso na rede do site.",
"createInternalResourceDialogAlias": "Alias", "createInternalResourceDialogAlias": "Alias",
"createInternalResourceDialogAliasDescription": "Um alias de DNS interno opcional para este recurso.", "createInternalResourceDialogAliasDescription": "Um alias de DNS interno opcional para este recurso.",
"internalResourceAliasLocalWarning": "Os aliases terminando em .local podem causar problemas de resolução devido ao mDNS em algumas redes.",
"internalResourceDownstreamSchemeRequired": "Esquema é obrigatório para recursos HTTP", "internalResourceDownstreamSchemeRequired": "Esquema é obrigatório para recursos HTTP",
"internalResourceHttpPortRequired": "Porta de destino é obrigatória para recursos HTTP", "internalResourceHttpPortRequired": "Porta de destino é obrigatória para recursos HTTP",
"siteConfiguration": "Configuração", "siteConfiguration": "Configuração",
@@ -2967,7 +2974,7 @@
"orgOrDomainIdMissing": "ID da organização ou domínio está faltando", "orgOrDomainIdMissing": "ID da organização ou domínio está faltando",
"loadingDNSRecords": "Carregando registros DNS...", "loadingDNSRecords": "Carregando registros DNS...",
"olmUpdateAvailableInfo": "Uma versão atualizada do Olm está disponível. Atualize para a versão mais recente para ter a melhor experiência.", "olmUpdateAvailableInfo": "Uma versão atualizada do Olm está disponível. Atualize para a versão mais recente para ter a melhor experiência.",
"updateAvailableInfo": "An updated version is available. Please update to the latest version for the best experience.", "updateAvailableInfo": "Uma versão atualizada está disponível. Por favor, atualize para a versão mais recente para uma melhor experiência.",
"client": "Cliente", "client": "Cliente",
"proxyProtocol": "Configurações de Protocolo Proxy", "proxyProtocol": "Configurações de Protocolo Proxy",
"proxyProtocolDescription": "Configurar o protocolo proxy para preservar endereços IP do cliente para serviços TCP.", "proxyProtocolDescription": "Configurar o protocolo proxy para preservar endereços IP do cliente para serviços TCP.",
+8 -1
View File
@@ -66,9 +66,15 @@
"local": "Локальный", "local": "Локальный",
"edit": "Редактировать", "edit": "Редактировать",
"siteConfirmDelete": "Подтвердить удаление сайта", "siteConfirmDelete": "Подтвердить удаление сайта",
"siteConfirmDeleteAndResources": "Подтвердите удаление сайта и ресурсов",
"siteDelete": "Удалить сайт", "siteDelete": "Удалить сайт",
"siteDeleteAndResources": "Удалить сайт и ресурсы",
"siteMessageRemove": "После удаления сайт больше не будет доступен. Все цели, связанные с сайтом, также будут удалены.", "siteMessageRemove": "После удаления сайт больше не будет доступен. Все цели, связанные с сайтом, также будут удалены.",
"siteMessageRemoveAndResources": "Это навсегда удалит все общественные и частные ресурсы, связанные с этим сайтом, даже если ресурс также связан с другими сайтами.",
"siteQuestionRemove": "Вы уверены, что хотите удалить сайт из организации?", "siteQuestionRemove": "Вы уверены, что хотите удалить сайт из организации?",
"siteQuestionRemoveAndResources": "Вы уверены, что хотите удалить этот сайт и все связанные с ним ресурсы?",
"sitesTableDeleteSite": "Удалить сайт",
"sitesTableDeleteSiteAndResources": "Удалить сайт и ресурсы",
"siteManageSites": "Управление сайтами", "siteManageSites": "Управление сайтами",
"siteDescription": "Создание и управление сайтами, чтобы включить подключение к приватным сетям", "siteDescription": "Создание и управление сайтами, чтобы включить подключение к приватным сетям",
"sitesBannerTitle": "Подключить любую сеть", "sitesBannerTitle": "Подключить любую сеть",
@@ -2338,6 +2344,7 @@
"createInternalResourceDialogDestinationCidrDescription": "Диапазон CIDR ресурса в сети сайта.", "createInternalResourceDialogDestinationCidrDescription": "Диапазон CIDR ресурса в сети сайта.",
"createInternalResourceDialogAlias": "Alias", "createInternalResourceDialogAlias": "Alias",
"createInternalResourceDialogAliasDescription": "Дополнительный внутренний DNS псевдоним для этого ресурса.", "createInternalResourceDialogAliasDescription": "Дополнительный внутренний DNS псевдоним для этого ресурса.",
"internalResourceAliasLocalWarning": "Псевдонимы, оканчивающиеся на .local, могут вызывать проблемы с разрешением из-за mDNS в некоторых сетях.",
"internalResourceDownstreamSchemeRequired": "Схема обязательна для HTTP ресурсов", "internalResourceDownstreamSchemeRequired": "Схема обязательна для HTTP ресурсов",
"internalResourceHttpPortRequired": "Порт назначения обязателен для HTTP ресурсов", "internalResourceHttpPortRequired": "Порт назначения обязателен для HTTP ресурсов",
"siteConfiguration": "Конфигурация", "siteConfiguration": "Конфигурация",
@@ -2967,7 +2974,7 @@
"orgOrDomainIdMissing": "Отсутствует организация или ID домена", "orgOrDomainIdMissing": "Отсутствует организация или ID домена",
"loadingDNSRecords": "Загрузка записей DNS...", "loadingDNSRecords": "Загрузка записей DNS...",
"olmUpdateAvailableInfo": "Доступна обновленная версия Олма. Пожалуйста, обновитесь до последней версии.", "olmUpdateAvailableInfo": "Доступна обновленная версия Олма. Пожалуйста, обновитесь до последней версии.",
"updateAvailableInfo": "An updated version is available. Please update to the latest version for the best experience.", "updateAvailableInfo": "Доступна обновленная версия. Пожалуйста, обновитесь до последней версии для получения лучшего опыта.",
"client": "Клиент", "client": "Клиент",
"proxyProtocol": "Настройки протокола прокси", "proxyProtocol": "Настройки протокола прокси",
"proxyProtocolDescription": "Настроить Прокси-протокол для сохранения IP-адресов клиента для служб TCP.", "proxyProtocolDescription": "Настроить Прокси-протокол для сохранения IP-адресов клиента для служб TCP.",
+8 -1
View File
@@ -66,9 +66,15 @@
"local": "Yerel", "local": "Yerel",
"edit": "Düzenle", "edit": "Düzenle",
"siteConfirmDelete": "Site Silmeyi Onayla", "siteConfirmDelete": "Site Silmeyi Onayla",
"siteConfirmDeleteAndResources": "Site ve Kaynakları Silmeyi Onayla",
"siteDelete": "Siteyi Sil", "siteDelete": "Siteyi Sil",
"siteDeleteAndResources": "Site ve Kaynakları Sil",
"siteMessageRemove": "Kaldırıldıktan sonra site artık erişilebilir olmayacaktır. Siteyle ilişkilendirilmiş tüm hedefler de kaldırılacaktır.", "siteMessageRemove": "Kaldırıldıktan sonra site artık erişilebilir olmayacaktır. Siteyle ilişkilendirilmiş tüm hedefler de kaldırılacaktır.",
"siteMessageRemoveAndResources": "Bu işlem, diğer sitelerle de ilişkilendirilmiş olsa bile, bu siteye bağlı tüm genel ve özel kaynakları kalıcı olarak silecektir.",
"siteQuestionRemove": "Siteyi organizasyondan kaldırmak istediğinizden emin misiniz?", "siteQuestionRemove": "Siteyi organizasyondan kaldırmak istediğinizden emin misiniz?",
"siteQuestionRemoveAndResources": "Bu siteyi ve tüm ilişkili kaynakları silmek istediğinizden emin misiniz?",
"sitesTableDeleteSite": "Siteyi Sil",
"sitesTableDeleteSiteAndResources": "Site ve Kaynakları Sil",
"siteManageSites": "Siteleri Yönet", "siteManageSites": "Siteleri Yönet",
"siteDescription": "Özel ağlara erişimi etkinleştirmek için siteler oluşturun ve yönetin", "siteDescription": "Özel ağlara erişimi etkinleştirmek için siteler oluşturun ve yönetin",
"sitesBannerTitle": "Herhangi Bir Ağa Bağlan", "sitesBannerTitle": "Herhangi Bir Ağa Bağlan",
@@ -2338,6 +2344,7 @@
"createInternalResourceDialogDestinationCidrDescription": "Site ağındaki kaynağın CIDR aralığı.", "createInternalResourceDialogDestinationCidrDescription": "Site ağındaki kaynağın CIDR aralığı.",
"createInternalResourceDialogAlias": "Takma Ad", "createInternalResourceDialogAlias": "Takma Ad",
"createInternalResourceDialogAliasDescription": "Bu kaynak için isteğe bağlı dahili DNS takma adı.", "createInternalResourceDialogAliasDescription": "Bu kaynak için isteğe bağlı dahili DNS takma adı.",
"internalResourceAliasLocalWarning": "Bazı ağlarda mDNS nedeniyle .local ile biten takma adlar çözümleme sorunlarına neden olabilir.",
"internalResourceDownstreamSchemeRequired": "HTTP kaynakları için şema gereklidir", "internalResourceDownstreamSchemeRequired": "HTTP kaynakları için şema gereklidir",
"internalResourceHttpPortRequired": "HTTP kaynakları için hedef bağlantı noktası gereklidir", "internalResourceHttpPortRequired": "HTTP kaynakları için hedef bağlantı noktası gereklidir",
"siteConfiguration": "Yapılandırma", "siteConfiguration": "Yapılandırma",
@@ -2967,7 +2974,7 @@
"orgOrDomainIdMissing": "Organizasyon veya Alan Adı Kimliği eksik", "orgOrDomainIdMissing": "Organizasyon veya Alan Adı Kimliği eksik",
"loadingDNSRecords": "DNS kayıtları yükleniyor...", "loadingDNSRecords": "DNS kayıtları yükleniyor...",
"olmUpdateAvailableInfo": "Olm'nin güncellenmiş bir sürümü mevcut. En iyi deneyim için lütfen en son sürüme güncelleyin.", "olmUpdateAvailableInfo": "Olm'nin güncellenmiş bir sürümü mevcut. En iyi deneyim için lütfen en son sürüme güncelleyin.",
"updateAvailableInfo": "An updated version is available. Please update to the latest version for the best experience.", "updateAvailableInfo": "Güncellenmiş bir sürüm mevcut. En iyi deneyim için lütfen en son sürüme güncelleyin.",
"client": "İstemci", "client": "İstemci",
"proxyProtocol": "Proxy Protokol Ayarları", "proxyProtocol": "Proxy Protokol Ayarları",
"proxyProtocolDescription": "TCP hizmetleri için istemci IP adreslerini korumak amacıyla Proxy Protokolünü yapılandırın.", "proxyProtocolDescription": "TCP hizmetleri için istemci IP adreslerini korumak amacıyla Proxy Protokolünü yapılandırın.",
+8 -1
View File
@@ -66,9 +66,15 @@
"local": "本地的", "local": "本地的",
"edit": "编辑", "edit": "编辑",
"siteConfirmDelete": "确认删除节点", "siteConfirmDelete": "确认删除节点",
"siteConfirmDeleteAndResources": "确认删除站点及资源",
"siteDelete": "删除节点", "siteDelete": "删除节点",
"siteDeleteAndResources": "删除站点及资源",
"siteMessageRemove": "一旦移除,节点将无法访问。与节点相关的所有目标也将被移除。", "siteMessageRemove": "一旦移除,节点将无法访问。与节点相关的所有目标也将被移除。",
"siteMessageRemoveAndResources": "这将永久删除与该站点关联的所有公共和私人资源,即使资源也与其他站点相关联。",
"siteQuestionRemove": "您确定要从组织中删除该节点吗?", "siteQuestionRemove": "您确定要从组织中删除该节点吗?",
"siteQuestionRemoveAndResources": "您确定要删除此站点及所有关联资源吗?",
"sitesTableDeleteSite": "删除站点",
"sitesTableDeleteSiteAndResources": "删除站点及资源",
"siteManageSites": "管理站点", "siteManageSites": "管理站点",
"siteDescription": "创建和管理站点,启用与私人网络的连接", "siteDescription": "创建和管理站点,启用与私人网络的连接",
"sitesBannerTitle": "连接任何网络", "sitesBannerTitle": "连接任何网络",
@@ -2338,6 +2344,7 @@
"createInternalResourceDialogDestinationCidrDescription": "站点网络上资源的 CIDR 范围。", "createInternalResourceDialogDestinationCidrDescription": "站点网络上资源的 CIDR 范围。",
"createInternalResourceDialogAlias": "Alias", "createInternalResourceDialogAlias": "Alias",
"createInternalResourceDialogAliasDescription": "此资源可选的内部DNS别名。", "createInternalResourceDialogAliasDescription": "此资源可选的内部DNS别名。",
"internalResourceAliasLocalWarning": "以 .local 结尾的别名可能会因某些网络上的 mDNS 而导致解析问题。",
"internalResourceDownstreamSchemeRequired": "HTTP 资源需要方案", "internalResourceDownstreamSchemeRequired": "HTTP 资源需要方案",
"internalResourceHttpPortRequired": "HTTP 资源需要目的端口", "internalResourceHttpPortRequired": "HTTP 资源需要目的端口",
"siteConfiguration": "配置", "siteConfiguration": "配置",
@@ -2967,7 +2974,7 @@
"orgOrDomainIdMissing": "缺少机构或域 ID", "orgOrDomainIdMissing": "缺少机构或域 ID",
"loadingDNSRecords": "正在载入DNS记录...", "loadingDNSRecords": "正在载入DNS记录...",
"olmUpdateAvailableInfo": "有最新版本的 Olm 可用。请更新到最新版本以获取最佳体验。", "olmUpdateAvailableInfo": "有最新版本的 Olm 可用。请更新到最新版本以获取最佳体验。",
"updateAvailableInfo": "An updated version is available. Please update to the latest version for the best experience.", "updateAvailableInfo": "有新版本可用。请更新到最新版本以获得最佳体验。",
"client": "客户端:", "client": "客户端:",
"proxyProtocol": "代理协议设置", "proxyProtocol": "代理协议设置",
"proxyProtocolDescription": "配置代理协议以保留TCP服务的客户端 IP 地址。", "proxyProtocolDescription": "配置代理协议以保留TCP服务的客户端 IP 地址。",
+40 -1
View File
@@ -12,7 +12,7 @@ import {
users users
} from "@server/db"; } from "@server/db";
import { db } from "@server/db"; import { db } from "@server/db";
import { eq, inArray } from "drizzle-orm"; import { and, eq, inArray, ne } from "drizzle-orm";
import config from "@server/lib/config"; import config from "@server/lib/config";
import type { RandomReader } from "@oslojs/crypto/random"; import type { RandomReader } from "@oslojs/crypto/random";
import { generateRandomString } from "@oslojs/crypto/random"; import { generateRandomString } from "@oslojs/crypto/random";
@@ -136,6 +136,45 @@ export async function invalidateAllSessions(userId: string): Promise<void> {
} }
} }
export async function invalidateAllSessionsExceptCurrent(
userId: string,
currentSessionId: string
): Promise<void> {
try {
await db.transaction(async (trx) => {
const userSessions = await trx
.select()
.from(sessions)
.where(
and(
eq(sessions.userId, userId),
ne(sessions.sessionId, currentSessionId)
)
);
if (userSessions.length > 0) {
await trx.delete(resourceSessions).where(
inArray(
resourceSessions.userSessionId,
userSessions.map((s) => s.sessionId)
)
);
}
await trx
.delete(sessions)
.where(
and(
eq(sessions.userId, userId),
ne(sessions.sessionId, currentSessionId)
)
);
});
} catch (e) {
logger.error("Failed to invalidate user sessions except current", e);
}
}
export function serializeSessionCookie( export function serializeSessionCookie(
token: string, token: string,
isSecure: boolean, isSecure: boolean,
+7 -4
View File
@@ -795,10 +795,13 @@ export const COUNTRIES = [
name: "Serbia", name: "Serbia",
code: "RS" code: "RS"
}, },
{ // Removed as this is a deprecated ISO country code, not supported anymore
name: "Serbia and Montenegro", // Also the individual flags for Serbia & Montenegro are already included in the list
code: "CS" // more details: https://en.wikipedia.org/wiki/ISO_3166-2:CS
}, // {
// name: "Serbia and Montenegro",
// code: "CS"
// },
{ {
name: "Seychelles", name: "Seychelles",
code: "SC" code: "SC"
+24 -22
View File
@@ -29,8 +29,11 @@ import { updateResourcePolicies } from "./resourcePolicies";
import { BlueprintSource } from "@server/routers/blueprints/types"; import { BlueprintSource } from "@server/routers/blueprints/types";
import { stringify as stringifyYaml } from "yaml"; import { stringify as stringifyYaml } from "yaml";
import { generateName } from "@server/db/names"; import { generateName } from "@server/db/names";
import { handleMessagingForUpdatedSiteResource } from "@server/routers/siteResource"; import {
import { rebuildClientAssociationsFromSiteResource } from "../rebuildClientAssociations"; handleMessagingForUpdatedSiteResource,
rebuildClientAssociationsFromSiteResource,
waitForSiteResourceRebuildIdle
} from "../rebuildClientAssociations";
type ApplyBlueprintArgs = { type ApplyBlueprintArgs = {
orgId: string; orgId: string;
@@ -138,26 +141,25 @@ export async function applyBlueprint({
for (const result of privateResourcesResults) { for (const result of privateResourcesResults) {
rebuildClientAssociationsFromSiteResource( rebuildClientAssociationsFromSiteResource(
result.newSiteResource result.newSiteResource
).catch((e) => { )
logger.error( .then(() =>
`Failed to rebuild client associations for site resource ${result.newSiteResource.siteResourceId}. Error: ${e}` waitForSiteResourceRebuildIdle(
); result.newSiteResource.siteResourceId
}); )
)
handleMessagingForUpdatedSiteResource( .then(() =>
result.oldSiteResource, handleMessagingForUpdatedSiteResource(
result.newSiteResource, result.oldSiteResource,
result.oldSites.map((site) => ({ result.newSiteResource,
// only need to run this on the old sites because the new sites are added above result.oldSites.map((s) => s.siteId),
siteId: site.siteId, result.newSites.map((s) => s.siteId)
orgId: result.newSiteResource.orgId )
})) )
).catch((err) => { .catch((e) => {
logger.error( logger.error(
`Error handling messaging for updated site resource ${result.newSiteResource.siteResourceId}:`, `Failed to rebuild and handle messaging for site resource ${result.newSiteResource.siteResourceId}. Error: ${e}`
err );
); });
});
} }
logger.debug( logger.debug(
+144
View File
@@ -0,0 +1,144 @@
import { eq, inArray } from "drizzle-orm";
import {
db,
newts,
resourcePolicies,
resources,
sites,
targetHealthCheck,
targets,
type Resource,
type Target,
type TargetHealthCheck,
type Transaction
} from "@server/db";
import logger from "@server/logger";
import { removeTargets } from "@server/routers/newt/targets";
import createHttpError from "http-errors";
import HttpCode from "@server/types/HttpCode";
export type DeleteResourceResult = {
deletedResource: Resource;
targetsToBeRemoved: Target[];
healthChecksToBeRemoved: TargetHealthCheck[];
};
export async function performDeleteResources(
resourceIds: number[],
trx: Transaction | typeof db = db
): Promise<DeleteResourceResult[]> {
if (resourceIds.length === 0) {
return [];
}
const targetsToBeRemoved = await trx
.select()
.from(targets)
.where(inArray(targets.resourceId, resourceIds));
const targetIds = targetsToBeRemoved.map((t) => t.targetId);
const healthChecksToBeRemoved =
targetIds.length > 0
? await trx
.select()
.from(targetHealthCheck)
.where(inArray(targetHealthCheck.targetId, targetIds))
: [];
const deletedResources = await trx
.delete(resources)
.where(inArray(resources.resourceId, resourceIds))
.returning();
const policyIds = deletedResources
.map((resource) => resource.defaultResourcePolicyId)
.filter((id): id is number => id != null);
if (policyIds.length > 0) {
await trx
.delete(resourcePolicies)
.where(inArray(resourcePolicies.resourcePolicyId, policyIds));
}
if (deletedResources.length > 0) {
logger.debug(`Deleted ${deletedResources.length} resources`);
}
const targetsByResourceId = new Map<number, Target[]>();
for (const target of targetsToBeRemoved) {
const existing = targetsByResourceId.get(target.resourceId) ?? [];
existing.push(target);
targetsByResourceId.set(target.resourceId, existing);
}
const targetIdToResourceId = new Map(
targetsToBeRemoved.map((target) => [target.targetId, target.resourceId])
);
const healthChecksByResourceId = new Map<number, TargetHealthCheck[]>();
for (const healthCheck of healthChecksToBeRemoved) {
const resourceId = targetIdToResourceId.get(healthCheck.targetId!);
if (resourceId == null) {
continue;
}
const existing = healthChecksByResourceId.get(resourceId) ?? [];
existing.push(healthCheck);
healthChecksByResourceId.set(resourceId, existing);
}
return deletedResources.map((deletedResource) => ({
deletedResource,
targetsToBeRemoved:
targetsByResourceId.get(deletedResource.resourceId) ?? [],
healthChecksToBeRemoved:
healthChecksByResourceId.get(deletedResource.resourceId) ?? []
}));
}
export async function performDeleteResource(
resourceId: number,
trx: Transaction | typeof db = db
): Promise<DeleteResourceResult | null> {
const [result] = await performDeleteResources([resourceId], trx);
return result ?? null;
}
export async function runResourceDeleteSideEffects(
result: DeleteResourceResult
): Promise<void> {
const { deletedResource, targetsToBeRemoved, healthChecksToBeRemoved } =
result;
for (const target of targetsToBeRemoved) {
const [site] = await db
.select()
.from(sites)
.where(eq(sites.siteId, target.siteId))
.limit(1);
if (!site) {
throw createHttpError(
HttpCode.NOT_FOUND,
`Site with ID ${target.siteId} not found`
);
}
if (site.pubKey && site.type === "newt") {
const [newt] = await db
.select()
.from(newts)
.where(eq(newts.siteId, site.siteId))
.limit(1);
if (newt) {
await removeTargets(
newt.newtId,
[],
healthChecksToBeRemoved,
deletedResource.mode === "udp" ? "udp" : "tcp",
newt.version
);
}
}
}
}
+126
View File
@@ -0,0 +1,126 @@
import { and, eq, sql } from "drizzle-orm";
import {
db,
siteNetworks,
siteResources,
targets,
type SiteResource,
type Transaction
} from "@server/db";
import {
performDeleteResources,
runResourceDeleteSideEffects,
type DeleteResourceResult
} from "@server/lib/deleteResource";
import {
performDeleteSiteResources,
runSiteResourceDeleteSideEffects
} from "@server/lib/deleteSiteResource";
import logger from "@server/logger";
export const MAX_SITE_ASSOCIATED_RESOURCES_FOR_BULK_DELETE = 250;
export type DeleteSiteAssociatedResourcesSideEffects = {
resources: DeleteResourceResult[];
siteResources: SiteResource[];
};
export async function getResourceIdsForSite(
siteId: number,
trx: Transaction | typeof db = db
): Promise<number[]> {
const rows = await trx
.selectDistinct({ resourceId: targets.resourceId })
.from(targets)
.where(eq(targets.siteId, siteId));
return rows.map((row) => row.resourceId);
}
export async function getSiteResourceIdsForSite(
siteId: number,
orgId: string,
trx: Transaction | typeof db = db
): Promise<number[]> {
const rows = await trx
.selectDistinct({ siteResourceId: siteResources.siteResourceId })
.from(siteNetworks)
.innerJoin(
siteResources,
eq(siteResources.networkId, siteNetworks.networkId)
)
.where(
and(eq(siteNetworks.siteId, siteId), eq(siteResources.orgId, orgId))
);
return rows.map((row) => row.siteResourceId);
}
export async function getAssociatedResourceCountForSite(
siteId: number,
orgId: string,
trx: Transaction | typeof db = db
): Promise<number> {
const [publicCountResult, privateCountResult] = await Promise.all([
trx
.select({
count: sql<number>`count(distinct ${targets.resourceId})`
})
.from(targets)
.where(eq(targets.siteId, siteId)),
trx
.select({
count: sql<number>`count(distinct ${siteResources.siteResourceId})`
})
.from(siteNetworks)
.innerJoin(
siteResources,
eq(siteResources.networkId, siteNetworks.networkId)
)
.where(
and(
eq(siteNetworks.siteId, siteId),
eq(siteResources.orgId, orgId)
)
)
]);
return (
Number(publicCountResult[0]?.count ?? 0) +
Number(privateCountResult[0]?.count ?? 0)
);
}
export function exceedsSiteAssociatedResourceDeleteLimit(
resourceCount: number
): boolean {
return resourceCount > MAX_SITE_ASSOCIATED_RESOURCES_FOR_BULK_DELETE;
}
export async function deleteAssociatedResourcesForSite(
siteId: number,
orgId: string,
trx: Transaction | typeof db = db
): Promise<DeleteSiteAssociatedResourcesSideEffects> {
const resourceIds = await getResourceIdsForSite(siteId, trx);
const siteResourceIds = await getSiteResourceIdsForSite(siteId, orgId, trx);
const [resources, siteResourcesDeleted] = await Promise.all([
performDeleteResources(resourceIds, trx),
performDeleteSiteResources(siteResourceIds, trx)
]);
return { resources, siteResources: siteResourcesDeleted };
}
export async function runDeleteSiteAssociatedResourcesSideEffects(
sideEffects: DeleteSiteAssociatedResourcesSideEffects
): Promise<void> {
for (const result of sideEffects.resources) {
await runResourceDeleteSideEffects(result);
}
for (const removed of sideEffects.siteResources) {
runSiteResourceDeleteSideEffects(removed);
}
}
+53
View File
@@ -0,0 +1,53 @@
import { inArray } from "drizzle-orm";
import {
db,
siteResources,
type SiteResource,
type Transaction
} from "@server/db";
import logger from "@server/logger";
import { rebuildClientAssociationsFromSiteResource } from "@server/lib/rebuildClientAssociations";
export async function performDeleteSiteResources(
siteResourceIds: number[],
trx: Transaction | typeof db = db
): Promise<SiteResource[]> {
if (siteResourceIds.length === 0) {
return [];
}
const removedSiteResources = await trx
.delete(siteResources)
.where(inArray(siteResources.siteResourceId, siteResourceIds))
.returning();
if (removedSiteResources.length > 0) {
logger.debug(`Deleted ${removedSiteResources.length} site resources`);
}
return removedSiteResources;
}
export async function performDeleteSiteResource(
siteResourceId: number,
trx: Transaction | typeof db = db
): Promise<SiteResource | null> {
const [removedSiteResource] = await performDeleteSiteResources(
[siteResourceId],
trx
);
return removedSiteResource ?? null;
}
export function runSiteResourceDeleteSideEffects(
removedSiteResource: SiteResource
): void {
rebuildClientAssociationsFromSiteResource(removedSiteResource).catch(
(err) => {
logger.error(
`Error rebuilding client associations for site resource ${removedSiteResource.siteResourceId}:`,
err
);
}
);
}
+117 -7
View File
@@ -1,4 +1,24 @@
const instanceId = `local-${Math.random().toString(36).slice(2)}-${Date.now()}`;
type LocalLockRecord = {
owner: string;
expiresAt: number;
};
const localLocks = new Map<string, LocalLockRecord>();
export class LockManager { export class LockManager {
private clearExpiredLocalLock(lockKey: string): void {
const current = localLocks.get(lockKey);
if (current && current.expiresAt <= Date.now()) {
localLocks.delete(lockKey);
}
}
private getLocalOwnerToken(): string {
return `${instanceId}:`;
}
/** /**
* Acquire a distributed lock using Redis SET with NX and PX options * Acquire a distributed lock using Redis SET with NX and PX options
* @param lockKey - Unique identifier for the lock * @param lockKey - Unique identifier for the lock
@@ -7,22 +27,57 @@ export class LockManager {
*/ */
async acquireLock( async acquireLock(
lockKey: string, lockKey: string,
ttlMs: number = 30000 ttlMs: number = 30000,
maxRetries: number = 3,
retryDelayMs: number = 100
): Promise<boolean> { ): Promise<boolean> {
return true; for (let attempt = 0; attempt < maxRetries; attempt++) {
this.clearExpiredLocalLock(lockKey);
const existing = localLocks.get(lockKey);
if (!existing) {
localLocks.set(lockKey, {
owner: this.getLocalOwnerToken(),
expiresAt: Date.now() + ttlMs
});
return true;
}
if (existing.owner === this.getLocalOwnerToken()) {
existing.expiresAt = Date.now() + ttlMs;
localLocks.set(lockKey, existing);
return true;
}
if (attempt < maxRetries - 1) {
const delay = retryDelayMs * Math.pow(2, attempt);
await new Promise((resolve) => setTimeout(resolve, delay));
}
}
return false;
} }
/** /**
* Release a lock using Lua script to ensure atomicity * Release a lock using Lua script to ensure atomicity
* @param lockKey - Unique identifier for the lock * @param lockKey - Unique identifier for the lock
*/ */
async releaseLock(lockKey: string): Promise<void> {} async releaseLock(lockKey: string): Promise<void> {
this.clearExpiredLocalLock(lockKey);
const existing = localLocks.get(lockKey);
if (existing && existing.owner === this.getLocalOwnerToken()) {
localLocks.delete(lockKey);
}
}
/** /**
* Force release a lock regardless of owner (use with caution) * Force release a lock regardless of owner (use with caution)
* @param lockKey - Unique identifier for the lock * @param lockKey - Unique identifier for the lock
*/ */
async forceReleaseLock(lockKey: string): Promise<void> {} async forceReleaseLock(lockKey: string): Promise<void> {
localLocks.delete(lockKey);
}
/** /**
* Check if a lock exists and get its info * Check if a lock exists and get its info
@@ -35,7 +90,20 @@ export class LockManager {
ttl: number; ttl: number;
owner?: string; owner?: string;
}> { }> {
return { exists: true, ownedByMe: true, ttl: 0 }; this.clearExpiredLocalLock(lockKey);
const existing = localLocks.get(lockKey);
if (!existing) {
return { exists: false, ownedByMe: false, ttl: 0 };
}
const ttl = Math.max(0, existing.expiresAt - Date.now());
return {
exists: true,
ownedByMe: existing.owner === this.getLocalOwnerToken(),
ttl,
owner: existing.owner.split(":")[0]
};
} }
/** /**
@@ -45,6 +113,15 @@ export class LockManager {
* @returns Promise<boolean> - true if extended successfully * @returns Promise<boolean> - true if extended successfully
*/ */
async extendLock(lockKey: string, ttlMs: number): Promise<boolean> { async extendLock(lockKey: string, ttlMs: number): Promise<boolean> {
this.clearExpiredLocalLock(lockKey);
const existing = localLocks.get(lockKey);
if (!existing || existing.owner !== this.getLocalOwnerToken()) {
return false;
}
existing.expiresAt = Date.now() + ttlMs;
localLocks.set(lockKey, existing);
return true; return true;
} }
@@ -62,7 +139,26 @@ export class LockManager {
maxRetries: number = 5, maxRetries: number = 5,
baseDelayMs: number = 100 baseDelayMs: number = 100
): Promise<boolean> { ): Promise<boolean> {
return true; for (let attempt = 0; attempt <= maxRetries; attempt++) {
const acquired = await this.acquireLock(
lockKey,
ttlMs,
1,
baseDelayMs
);
if (acquired) {
return true;
}
if (attempt < maxRetries) {
const delay =
baseDelayMs * Math.pow(2, attempt) + Math.random() * 100;
await new Promise((resolve) => setTimeout(resolve, delay));
}
}
return false;
} }
/** /**
@@ -99,7 +195,21 @@ export class LockManager {
activeLocksCount: number; activeLocksCount: number;
locksOwnedByMe: number; locksOwnedByMe: number;
}> { }> {
return { activeLocksCount: 0, locksOwnedByMe: 0 }; const now = Date.now();
for (const [key, value] of localLocks.entries()) {
if (value.expiresAt <= now) {
localLocks.delete(key);
}
}
let locksOwnedByMe = 0;
for (const value of localLocks.values()) {
if (value.owner === this.getLocalOwnerToken()) {
locksOwnedByMe++;
}
}
return { activeLocksCount: localLocks.size, locksOwnedByMe };
} }
/** /**
+636 -43
View File
@@ -35,10 +35,13 @@ import {
parseEndpoint parseEndpoint
} from "@server/lib/ip"; } from "@server/lib/ip";
import { import {
addPeerData,
addPeerDataBatch, addPeerDataBatch,
addTargetsBatch as addSubnetProxyTargetsBatch, addTargetsBatch as addSubnetProxyTargetsBatch,
removePeerDataBatch, removePeerDataBatch,
removeTargetsBatch as removeSubnetProxyTargetsBatch removeTargetsBatch as removeSubnetProxyTargetsBatch,
updatePeerDataBatch,
updateTargets
} from "@server/routers/client/targets"; } from "@server/routers/client/targets";
import { lockManager } from "#dynamic/lib/lock"; import { lockManager } from "#dynamic/lib/lock";
import { rebuildQueue } from "#dynamic/lib/rebuildQueue"; import { rebuildQueue } from "#dynamic/lib/rebuildQueue";
@@ -47,6 +50,112 @@ import { rebuildQueue } from "#dynamic/lib/rebuildQueue";
// peer/proxy updates, so give them a generous window. // peer/proxy updates, so give them a generous window.
const REBUILD_ASSOCIATIONS_LOCK_TTL_MS = 120000; const REBUILD_ASSOCIATIONS_LOCK_TTL_MS = 120000;
const REBUILD_IDLE_POLL_INTERVAL_MS = 300;
const REBUILD_IDLE_DEFAULT_TIMEOUT_MS = 130_000; // slightly longer than lock TTL
const REBUILD_IDLE_HANDLER_TIMEOUT_MS = 5_000;
/**
* Returns true if a rebuild for the given site resource is currently active
* (holding the distributed lock) or is pending in the rebuild queue.
*/
export async function hasActiveSiteResourceRebuild(
siteResourceId: number
): Promise<boolean> {
const lockKey = `rebuild-client-associations:site-resource:${siteResourceId}`;
const lockInfo = await lockManager.getLockInfo(lockKey);
if (lockInfo.exists) return true;
return rebuildQueue.isQueued({ type: "site-resource", id: siteResourceId });
}
/**
* Resolves once there is no active or queued rebuild for the given site resource.
* Logs a warning and resolves early if the timeout is reached.
*/
export async function waitForSiteResourceRebuildIdle(
siteResourceId: number,
timeoutMs = REBUILD_IDLE_DEFAULT_TIMEOUT_MS
): Promise<void> {
const deadline = Date.now() + timeoutMs;
while (Date.now() < deadline) {
if (!(await hasActiveSiteResourceRebuild(siteResourceId))) return;
await new Promise<void>((r) =>
setTimeout(r, REBUILD_IDLE_POLL_INTERVAL_MS)
);
}
logger.warn(
`waitForSiteResourceRebuildIdle: timed out after ${timeoutMs}ms waiting for siteResourceId=${siteResourceId}`
);
}
/**
* Resolves once there are no active or queued rebuilds for any site resource
* associated with the given site.
*/
export async function waitForSiteRebuildIdle(
siteId: number,
timeoutMs = REBUILD_IDLE_HANDLER_TIMEOUT_MS
): Promise<void> {
const deadline = Date.now() + timeoutMs;
while (Date.now() < deadline) {
const resourceRows = await db
.select({ siteResourceId: siteResources.siteResourceId })
.from(siteResources)
.innerJoin(
siteNetworks,
eq(siteNetworks.networkId, siteResources.networkId)
)
.where(eq(siteNetworks.siteId, siteId));
let allIdle = true;
for (const { siteResourceId } of resourceRows) {
if (await hasActiveSiteResourceRebuild(siteResourceId)) {
allIdle = false;
break;
}
}
if (allIdle) return;
await new Promise<void>((r) =>
setTimeout(r, REBUILD_IDLE_POLL_INTERVAL_MS)
);
}
logger.warn(
`waitForSiteRebuildIdle: timed out after ${timeoutMs}ms waiting for siteId=${siteId}`
);
}
/**
* Resolves once there are no active or queued rebuilds for any site resource
* associated with the given client.
*/
export async function waitForClientRebuildIdle(
clientId: number,
timeoutMs = REBUILD_IDLE_HANDLER_TIMEOUT_MS
): Promise<void> {
const deadline = Date.now() + timeoutMs;
while (Date.now() < deadline) {
const resourceRows = await db
.select({
siteResourceId:
clientSiteResourcesAssociationsCache.siteResourceId
})
.from(clientSiteResourcesAssociationsCache)
.where(eq(clientSiteResourcesAssociationsCache.clientId, clientId));
let allIdle = true;
for (const { siteResourceId } of resourceRows) {
if (await hasActiveSiteResourceRebuild(siteResourceId)) {
allIdle = false;
break;
}
}
if (allIdle) return;
await new Promise<void>((r) =>
setTimeout(r, REBUILD_IDLE_POLL_INTERVAL_MS)
);
}
logger.warn(
`waitForClientRebuildIdle: timed out after ${timeoutMs}ms waiting for clientId=${clientId}`
);
}
export async function getClientSiteResourceAccess( export async function getClientSiteResourceAccess(
siteResource: SiteResource, siteResource: SiteResource,
trx: Transaction | typeof db = db trx: Transaction | typeof db = db
@@ -162,15 +271,10 @@ export async function getClientSiteResourceAccess(
export async function rebuildClientAssociationsFromSiteResource( export async function rebuildClientAssociationsFromSiteResource(
siteResource: SiteResource siteResource: SiteResource
) { ) {
const trx = primaryDb;
try { try {
return await lockManager.withLock( return await lockManager.withLock(
`rebuild-client-associations:site-resource:${siteResource.siteResourceId}`, `rebuild-client-associations:site-resource:${siteResource.siteResourceId}`,
() => () => rebuildClientAssociationsFromSiteResourceImpl(siteResource),
rebuildClientAssociationsFromSiteResourceImpl(
siteResource,
trx
),
REBUILD_ASSOCIATIONS_LOCK_TTL_MS REBUILD_ASSOCIATIONS_LOCK_TTL_MS
); );
} catch (err: any) { } catch (err: any) {
@@ -192,15 +296,10 @@ export async function rebuildClientAssociationsFromSiteResource(
} }
async function rebuildClientAssociationsFromSiteResourceImpl( async function rebuildClientAssociationsFromSiteResourceImpl(
siteResource: SiteResource, siteResource: SiteResource
trx: Transaction | typeof db = db ) {
): Promise<{ const trx = primaryDb;
mergedAllClients: {
clientId: number;
pubKey: string | null;
subnet: string | null;
}[];
}> {
logger.debug( logger.debug(
`rebuildClientAssociations: [rebuildClientAssociationsFromSiteResource] START siteResourceId=${siteResource.siteResourceId} networkId=${siteResource.networkId} orgId=${siteResource.orgId}` `rebuildClientAssociations: [rebuildClientAssociationsFromSiteResource] START siteResourceId=${siteResource.siteResourceId} networkId=${siteResource.networkId} orgId=${siteResource.orgId}`
); );
@@ -214,14 +313,62 @@ async function rebuildClientAssociationsFromSiteResourceImpl(
/////////// process the client-siteResource associations /////////// /////////// process the client-siteResource associations ///////////
const existingClientSiteResources = await trx
.select({
clientId: clientSiteResourcesAssociationsCache.clientId
})
.from(clientSiteResourcesAssociationsCache)
.where(
eq(
clientSiteResourcesAssociationsCache.siteResourceId,
siteResource.siteResourceId
)
);
const existingClientSiteResourceIds = existingClientSiteResources.map(
(row) => row.clientId
);
// get all of the clients associated with other site resources that share // get all of the clients associated with other site resources that share
// any of the same sites as this site resource (via siteNetworks). We can't // any of the same sites as this site resource (via siteNetworks). We can't
// simply filter by networkId since each site resource has its own network; // simply filter by networkId since each site resource has its own network;
// two site resources serving the same site typically belong to different // two site resources serving the same site typically belong to different
// networks that both happen to include the site through siteNetworks. // networks that both happen to include the site through siteNetworks.
const sitesListSiteIds = sitesList.map((s) => s.siteId); const sitesListSiteIds = sitesList.map((s) => s.siteId);
// We must also consider sites where these clients are currently cached,
// otherwise removing a site from this resource can leave stale
// client-site cache entries behind for the removed site.
const cachedSiteRowsForResourceClients =
existingClientSiteResourceIds.length > 0
? await trx
.select({ siteId: clientSitesAssociationsCache.siteId })
.from(clientSitesAssociationsCache)
.where(
inArray(
clientSitesAssociationsCache.clientId,
existingClientSiteResourceIds
)
)
: [];
const allCandidateSiteIds = Array.from(
new Set([
...sitesListSiteIds,
...cachedSiteRowsForResourceClients.map((r) => r.siteId)
])
);
const sitesToProcess =
allCandidateSiteIds.length > 0
? await trx
.select()
.from(sites)
.where(inArray(sites.siteId, allCandidateSiteIds))
: [];
const currentSiteIdSet = new Set(sitesListSiteIds);
const allUpdatedClientsFromOtherResourcesOnThisSite = const allUpdatedClientsFromOtherResourcesOnThisSite =
sitesListSiteIds.length > 0 allCandidateSiteIds.length > 0
? await trx ? await trx
.select({ .select({
clientId: clientSiteResourcesAssociationsCache.clientId, clientId: clientSiteResourcesAssociationsCache.clientId,
@@ -241,7 +388,7 @@ async function rebuildClientAssociationsFromSiteResourceImpl(
) )
.where( .where(
and( and(
inArray(siteNetworks.siteId, sitesListSiteIds), inArray(siteNetworks.siteId, allCandidateSiteIds),
ne( ne(
siteResources.siteResourceId, siteResources.siteResourceId,
siteResource.siteResourceId siteResource.siteResourceId
@@ -260,22 +407,6 @@ async function rebuildClientAssociationsFromSiteResourceImpl(
clientsFromOtherResourcesBySite.get(row.siteId)!.add(row.clientId); clientsFromOtherResourcesBySite.get(row.siteId)!.add(row.clientId);
} }
const existingClientSiteResources = await trx
.select({
clientId: clientSiteResourcesAssociationsCache.clientId
})
.from(clientSiteResourcesAssociationsCache)
.where(
eq(
clientSiteResourcesAssociationsCache.siteResourceId,
siteResource.siteResourceId
)
);
const existingClientSiteResourceIds = existingClientSiteResources.map(
(row) => row.clientId
);
logger.debug( logger.debug(
`rebuildClientAssociations: [rebuildClientAssociationsFromSiteResource] siteResourceId=${siteResource.siteResourceId} existingResourceClientIds=[${existingClientSiteResourceIds.join(", ")}]` `rebuildClientAssociations: [rebuildClientAssociationsFromSiteResource] siteResourceId=${siteResource.siteResourceId} existingResourceClientIds=[${existingClientSiteResourceIds.join(", ")}]`
); );
@@ -358,10 +489,10 @@ async function rebuildClientAssociationsFromSiteResourceImpl(
/////////// process the client-site associations /////////// /////////// process the client-site associations ///////////
logger.debug( logger.debug(
`rebuildClientAssociations: [rebuildClientAssociationsFromSiteResource] siteResourceId=${siteResource.siteResourceId} beginning client-site association loop over ${sitesList.length} site(s)` `rebuildClientAssociations: [rebuildClientAssociationsFromSiteResource] siteResourceId=${siteResource.siteResourceId} beginning client-site association loop over ${sitesToProcess.length} site(s) (current=${sitesList.length})`
); );
for (const site of sitesList) { for (const site of sitesToProcess) {
const siteId = site.siteId; const siteId = site.siteId;
logger.debug( logger.debug(
@@ -403,7 +534,13 @@ async function rebuildClientAssociationsFromSiteResourceImpl(
`rebuildClientAssociations: [rebuildClientAssociationsFromSiteResource] siteId=${siteId} otherResourceClientIds=[${[...otherResourceClientIds].join(", ")}] mergedAllClientIds=[${mergedAllClientIds.join(", ")}]` `rebuildClientAssociations: [rebuildClientAssociationsFromSiteResource] siteId=${siteId} otherResourceClientIds=[${[...otherResourceClientIds].join(", ")}] mergedAllClientIds=[${mergedAllClientIds.join(", ")}]`
); );
const clientSitesToAdd = mergedAllClientIds.filter( // Expected clients from this resource are site-scoped: if this site is
// no longer attached to the resource, the expected set is empty.
const expectedClientIdsForSite = currentSiteIdSet.has(siteId)
? mergedAllClientIds
: [];
const clientSitesToAdd = expectedClientIdsForSite.filter(
(clientId) => (clientId) =>
!existingClientSiteIds.includes(clientId) && !existingClientSiteIds.includes(clientId) &&
!otherResourceClientIds.has(clientId) // dont add if already connected via another site resource !otherResourceClientIds.has(clientId) // dont add if already connected via another site resource
@@ -438,7 +575,7 @@ async function rebuildClientAssociationsFromSiteResourceImpl(
// Now remove any client-site associations that should no longer exist // Now remove any client-site associations that should no longer exist
const clientSitesToRemove = existingClientSiteIds.filter( const clientSitesToRemove = existingClientSiteIds.filter(
(clientId) => (clientId) =>
!mergedAllClientIds.includes(clientId) && !expectedClientIdsForSite.includes(clientId) &&
!otherResourceClientIds.has(clientId) // dont remove if there is still another connection for another site resource !otherResourceClientIds.has(clientId) // dont remove if there is still another connection for another site resource
); );
@@ -485,10 +622,6 @@ async function rebuildClientAssociationsFromSiteResourceImpl(
clientSiteResourcesToRemove, clientSiteResourcesToRemove,
trx trx
); );
return {
mergedAllClients
};
} }
async function handleMessagesForSiteClients( async function handleMessagesForSiteClients(
@@ -1042,6 +1175,466 @@ async function handleSubnetProxyTargetUpdates(
await Promise.all([...proxyJobs, ...olmJobs]); await Promise.all([...proxyJobs, ...olmJobs]);
} }
export async function handleMessagingForUpdatedSiteResource(
existingSiteResource: SiteResource | undefined,
updatedSiteResource: SiteResource,
existingSiteIds: number[],
updatedSiteIds: number[]
) {
const trx = primaryDb;
logger.debug(
`handleMessagingForUpdatedSiteResource: START siteResourceId=${updatedSiteResource.siteResourceId} existingSiteIds=[${existingSiteIds.join(", ")}] updatedSiteIds=[${updatedSiteIds.join(", ")}]`
);
logger.debug(
"handleMessagingForUpdatedSiteResource: existingSiteResource is: ",
existingSiteResource
);
logger.debug(
"handleMessagingForUpdatedSiteResource: updatedSiteResource is: ",
updatedSiteResource
);
const allSiteIds = [...new Set([...existingSiteIds, ...updatedSiteIds])];
logger.debug(
`handleMessagingForUpdatedSiteResource: allSiteIds=[${allSiteIds.join(", ")}] count=${allSiteIds.length}`
);
const newtsForSites =
allSiteIds.length > 0
? await trx
.select()
.from(newts)
.where(inArray(newts.siteId, allSiteIds))
: [];
const newtBySiteId = new Map(
newtsForSites.map((newt) => [newt.siteId, newt])
);
logger.debug(
`handleMessagingForUpdatedSiteResource: fetched newts for ${newtsForSites.length}/${allSiteIds.length} site(s)`
);
// WARNING: THIS RELIES ON THE CACHE TABLES BEING UP TO DATE, SO CALL THIS AFTER THE ASSOCIATION CACHE IS UPDATED
const mergedAllClients = await trx
.select({
clientId: clientSiteResourcesAssociationsCache.clientId,
pubKey: clients.pubKey,
subnet: clients.subnet
})
.from(clientSiteResourcesAssociationsCache)
.innerJoin(
clients,
eq(clientSiteResourcesAssociationsCache.clientId, clients.clientId)
)
.where(
eq(
clientSiteResourcesAssociationsCache.siteResourceId,
updatedSiteResource.siteResourceId
)
);
logger.debug(
`handleMessagingForUpdatedSiteResource: resolved merged clients count=${mergedAllClients.length} clientIds=[${mergedAllClients.map((c) => c.clientId).join(", ")}]`
);
const targets = await generateSubnetProxyTargetV2(
updatedSiteResource,
mergedAllClients
);
logger.debug(
`handleMessagingForUpdatedSiteResource: generated updated targets count=${targets ? targets.length : 0}`
);
const oldDestinationStillInUseClientSitePairs = new Set<string>();
if (
existingSiteResource?.destination &&
allSiteIds.length > 0 &&
mergedAllClients.length > 0
) {
logger.debug(
`handleMessagingForUpdatedSiteResource: checking old destination reuse destination=${existingSiteResource.destination} across siteCount=${allSiteIds.length} clientCount=${mergedAllClients.length}`
);
// we need to do this because the client only knows about peers not resources so we need to make sure that we dont remove it if there is still a another resource
const oldDestinationStillInUseRows = await trx
.select({
clientId: clientSiteResourcesAssociationsCache.clientId,
siteId: siteNetworks.siteId
})
.from(siteResources)
.innerJoin(
clientSiteResourcesAssociationsCache,
eq(
clientSiteResourcesAssociationsCache.siteResourceId,
siteResources.siteResourceId
)
)
.innerJoin(
siteNetworks,
eq(siteNetworks.networkId, siteResources.networkId)
)
.where(
and(
inArray(
clientSiteResourcesAssociationsCache.clientId,
mergedAllClients.map((c) => c.clientId)
),
inArray(siteNetworks.siteId, allSiteIds),
eq(
siteResources.destination,
existingSiteResource.destination
),
ne(
siteResources.siteResourceId,
existingSiteResource.siteResourceId
)
)
);
for (const row of oldDestinationStillInUseRows) {
oldDestinationStillInUseClientSitePairs.add(
`${row.clientId}:${row.siteId}`
);
}
logger.debug(
`handleMessagingForUpdatedSiteResource: old destination still in use rows=${oldDestinationStillInUseRows.length} uniqueClientSitePairs=${oldDestinationStillInUseClientSitePairs.size}`
);
} else {
logger.debug(
"handleMessagingForUpdatedSiteResource: skipping old destination reuse check (missing existing destination or no sites/clients)"
);
}
//////////////////////////// FROM HERE DOWN WE ARE DEALING WITH REMOVING SITES
const removedSiteIds = existingSiteIds.filter(
(id) => !updatedSiteIds.includes(id)
);
logger.debug(
`handleMessagingForUpdatedSiteResource: removing sites removedSiteIds=[${removedSiteIds.join(", ")}] count=${removedSiteIds.length}`
);
const targetsToRemoveBatch: {
newtId: string;
targets: any[];
version: string | null;
}[] = [];
const peerDataRemoves: {
clientId: number;
siteId: number;
remoteSubnets: string[];
aliases: ReturnType<typeof generateAliasConfig>;
}[] = [];
if (targets) {
for (const siteId of removedSiteIds) {
const newt = newtBySiteId.get(siteId);
if (!newt) {
logger.debug(
`handleMessagingForUpdatedSiteResource: skipping remove for siteId=${siteId} because no newt found`
);
continue;
}
logger.debug(
`handleMessagingForUpdatedSiteResource: preparing remove batches for siteId=${siteId} newtId=${newt.newtId}`
);
targetsToRemoveBatch.push({
newtId: newt.newtId,
targets: targets,
version: newt.version
});
for (const client of mergedAllClients) {
// we need to do this because the client only knows about peers not resources so we need to make sure that we dont remove it if there is still a another resource
const oldDestinationStillInUseBySite =
oldDestinationStillInUseClientSitePairs.has(
`${client.clientId}:${siteId}`
);
if (existingSiteResource) {
peerDataRemoves.push({
// this might happen twice after the rebuild function but that is okay
clientId: client.clientId,
siteId,
remoteSubnets: !oldDestinationStillInUseBySite
? generateRemoteSubnets([existingSiteResource])
: [],
aliases: generateAliasConfig([existingSiteResource])
});
}
}
}
} else {
logger.debug(
"handleMessagingForUpdatedSiteResource: skipping removal batch generation because targets were empty"
);
}
logger.debug(
`handleMessagingForUpdatedSiteResource: remove batches prepared targetBatchCount=${targetsToRemoveBatch.length} peerDataCount=${peerDataRemoves.length}`
);
logger.debug(
"handleMessagingForUpdatedSiteResource: dispatching removeSubnetProxyTargetsBatch"
);
removeSubnetProxyTargetsBatch(targetsToRemoveBatch);
logger.debug(
"handleMessagingForUpdatedSiteResource: dispatching removePeerDataBatch"
);
removePeerDataBatch(peerDataRemoves);
//////////////////////////// FROM HERE DOWN WE ARE DEALING WITH ADDING NEW SITES
const addedSiteIds = updatedSiteIds.filter(
(id) => !existingSiteIds.includes(id)
);
logger.debug(
`handleMessagingForUpdatedSiteResource: adding sites addedSiteIds=[${addedSiteIds.join(", ")}] count=${addedSiteIds.length}`
);
const targetsToAddBatch: {
newtId: string;
targets: any[];
version: string | null;
}[] = [];
const peerDataAdds: {
clientId: number;
siteId: number;
remoteSubnets: string[];
aliases: ReturnType<typeof generateAliasConfig>;
}[] = [];
if (targets) {
for (const siteId of addedSiteIds) {
const newt = newtBySiteId.get(siteId);
if (!newt) {
logger.debug(
`handleMessagingForUpdatedSiteResource: skipping add for siteId=${siteId} because no newt found`
);
continue;
}
logger.debug(
`handleMessagingForUpdatedSiteResource: preparing add batches for siteId=${siteId} newtId=${newt.newtId}`
);
targetsToAddBatch.push({
newtId: newt.newtId,
targets: targets,
version: newt.version
});
for (const client of mergedAllClients) {
peerDataAdds.push({
clientId: client.clientId,
siteId,
remoteSubnets: generateRemoteSubnets([updatedSiteResource]),
aliases: generateAliasConfig([updatedSiteResource])
});
}
}
} else {
logger.debug(
"handleMessagingForUpdatedSiteResource: skipping add batch generation because targets were empty"
);
}
logger.debug(
`handleMessagingForUpdatedSiteResource: add batches prepared targetBatchCount=${targetsToAddBatch.length} peerDataCount=${peerDataAdds.length}`
);
logger.debug(
"handleMessagingForUpdatedSiteResource: dispatching addSubnetProxyTargetsBatch"
);
addSubnetProxyTargetsBatch(targetsToAddBatch);
logger.debug(
"handleMessagingForUpdatedSiteResource: dispatching addPeerDataBatch"
);
addPeerDataBatch(peerDataAdds);
//////////////////////////// FROM HERE DOWN WE ARE DEALING WITH UPDATING THE EXISTING SITES
const unchangedSiteIds = existingSiteIds.filter((id) =>
updatedSiteIds.includes(id)
);
logger.debug(
`handleMessagingForUpdatedSiteResource: unchangedSiteIds=[${unchangedSiteIds.join(", ")}] count=${unchangedSiteIds.length}`
);
// after everything is rebuilt above we still need to update the targets and remote subnets if the destination changed
const destinationChanged =
existingSiteResource &&
existingSiteResource.destination !== updatedSiteResource.destination;
const destinationPortChanged =
existingSiteResource &&
existingSiteResource.destinationPort !==
updatedSiteResource.destinationPort;
const aliasChanged =
existingSiteResource &&
existingSiteResource.alias !== updatedSiteResource.alias;
const fullDomainChanged =
existingSiteResource &&
existingSiteResource.fullDomain !== updatedSiteResource.fullDomain;
const sslChanged =
existingSiteResource &&
existingSiteResource.ssl !== updatedSiteResource.ssl;
const portRangesChanged =
existingSiteResource &&
(existingSiteResource.tcpPortRangeString !==
updatedSiteResource.tcpPortRangeString ||
existingSiteResource.udpPortRangeString !==
updatedSiteResource.udpPortRangeString ||
existingSiteResource.disableIcmp !==
updatedSiteResource.disableIcmp);
logger.debug(
`handleMessagingForUpdatedSiteResource: change flags destinationChanged=${Boolean(destinationChanged)} destinationPortChanged=${Boolean(destinationPortChanged)} aliasChanged=${Boolean(aliasChanged)} fullDomainChanged=${Boolean(fullDomainChanged)} sslChanged=${Boolean(sslChanged)} portRangesChanged=${Boolean(portRangesChanged)}`
);
// if the existingSiteResource is undefined (new resource) we don't need to do anything here, the rebuild above handled it all
if (
destinationChanged ||
aliasChanged ||
fullDomainChanged ||
sslChanged ||
portRangesChanged ||
destinationPortChanged
) {
const shouldUpdateTargets =
destinationChanged ||
sslChanged ||
portRangesChanged ||
fullDomainChanged ||
destinationPortChanged;
logger.debug(
`handleMessagingForUpdatedSiteResource: entering unchanged-site update path shouldUpdateTargets=${shouldUpdateTargets}`
);
const oldTargets = shouldUpdateTargets
? await generateSubnetProxyTargetV2(
existingSiteResource,
mergedAllClients
)
: [];
const newTargets = shouldUpdateTargets
? await generateSubnetProxyTargetV2(
updatedSiteResource,
mergedAllClients
)
: [];
logger.debug(
`handleMessagingForUpdatedSiteResource: target update payload sizes oldTargets=${oldTargets ? oldTargets.length : 0} newTargets=${newTargets ? newTargets.length : 0}`
);
const peerDataUpdateBatch: Parameters<typeof updatePeerDataBatch>[0] =
[];
for (const siteId of unchangedSiteIds) {
const newt = newtBySiteId.get(siteId);
logger.debug(
`handleMessagingForUpdatedSiteResource: processing unchanged siteId=${siteId}`
);
if (!newt) {
logger.error(
`handleMessagingForUpdatedSiteResource: missing newt for unchanged siteId=${siteId}`
);
throw new Error(
"Newt not found for site during site resource update"
);
}
// Only update targets on newt if these items change
if (shouldUpdateTargets) {
logger.debug(
`handleMessagingForUpdatedSiteResource: updating targets for siteId=${siteId} newtId=${newt.newtId}`
);
await updateTargets(
newt.newtId,
{
oldTargets: oldTargets ? oldTargets : [],
newTargets: newTargets ? newTargets : []
},
newt.version
);
}
for (const client of mergedAllClients) {
// does this client have access to another resource on this site that has the same destination still? if so we dont want to remove it from their olm yet
if (!existingSiteResource.destination) {
logger.debug(
`handleMessagingForUpdatedSiteResource: skipping peerData update for clientId=${client.clientId} siteId=${siteId} because existing destination is empty`
);
continue;
}
// we need to do this because the client only knows about peers not resources so we need to make sure that we dont remove it if there is still a another resource
const oldDestinationStillInUseBySite =
oldDestinationStillInUseClientSitePairs.has(
`${client.clientId}:${siteId}`
);
// we also need to update the remote subnets on the olms for each client that has access to this site
peerDataUpdateBatch.push({
clientId: client.clientId,
siteId,
remoteSubnets: destinationChanged
? {
oldRemoteSubnets: !oldDestinationStillInUseBySite
? generateRemoteSubnets([
existingSiteResource
])
: [],
newRemoteSubnets: generateRemoteSubnets([
updatedSiteResource
])
}
: undefined,
aliases:
aliasChanged || fullDomainChanged // the full domain is sent down as an alias
? {
oldAliases: generateAliasConfig([
existingSiteResource
]),
newAliases: generateAliasConfig([
updatedSiteResource
])
}
: undefined
});
}
}
logger.debug(
`handleMessagingForUpdatedSiteResource: dispatching updatePeerDataBatch count=${peerDataUpdateBatch.length}`
);
updatePeerDataBatch(peerDataUpdateBatch);
} else {
logger.debug(
"handleMessagingForUpdatedSiteResource: no unchanged-site update required because no relevant fields changed"
);
}
logger.debug(
`handleMessagingForUpdatedSiteResource: DONE siteResourceId=${updatedSiteResource.siteResourceId}`
);
}
export async function rebuildClientAssociationsFromClient( export async function rebuildClientAssociationsFromClient(
client: Client client: Client
): Promise<void> { ): Promise<void> {
@@ -1776,7 +2369,7 @@ async function handleMessagesForClientResources(
) )
); );
// Only remove remote subnet if no other resource uses the same destination // Only remove remote subnet if no other resource uses the same destination on the same site
const remoteSubnetsToRemove = const remoteSubnetsToRemove =
destinationStillInUse.length > 0 destinationStillInUse.length > 0
? [] ? []
+4
View File
@@ -13,11 +13,15 @@ export interface RebuildJobHandlers {
export interface RebuildQueueManager { export interface RebuildQueueManager {
enqueue(job: RebuildJob): Promise<void>; enqueue(job: RebuildJob): Promise<void>;
startProcessing(handlers: RebuildJobHandlers): void; startProcessing(handlers: RebuildJobHandlers): void;
isQueued(job: RebuildJob): Promise<boolean>;
} }
class NoopRebuildQueue implements RebuildQueueManager { class NoopRebuildQueue implements RebuildQueueManager {
async enqueue(_job: RebuildJob): Promise<void> {} async enqueue(_job: RebuildJob): Promise<void> {}
startProcessing(_handlers: RebuildJobHandlers): void {} startProcessing(_handlers: RebuildJobHandlers): void {}
async isQueued(_job: RebuildJob): Promise<boolean> {
return false;
}
} }
export const rebuildQueue: RebuildQueueManager = new NoopRebuildQueue(); export const rebuildQueue: RebuildQueueManager = new NoopRebuildQueue();
+30 -4
View File
@@ -1,6 +1,6 @@
import { z } from "zod"; import { z } from "zod";
import { db, logsDb, statusHistory } from "@server/db"; import { db, logsDb, statusHistory } from "@server/db";
import { and, eq, gte, asc } from "drizzle-orm"; import { and, eq, gte, lt, asc, desc } from "drizzle-orm";
import { regionalCache as cache } from "#dynamic/lib/cache"; import { regionalCache as cache } from "#dynamic/lib/cache";
const STATUS_HISTORY_CACHE_TTL = 60; // seconds const STATUS_HISTORY_CACHE_TTL = 60; // seconds
@@ -42,7 +42,29 @@ export async function getCachedStatusHistory(
) )
.orderBy(asc(statusHistory.timestamp)); .orderBy(asc(statusHistory.timestamp));
const { buckets, totalDowntime } = computeBuckets(events, days); // Fetch the last known state before the window so that entities that
// haven't changed status recently still show the correct status rather
// than appearing as "no_data".
const [lastKnownEvent] = await logsDb
.select()
.from(statusHistory)
.where(
and(
eq(statusHistory.entityType, entityType),
eq(statusHistory.entityId, entityId),
lt(statusHistory.timestamp, startSec)
)
)
.orderBy(desc(statusHistory.timestamp))
.limit(1);
const priorStatus = lastKnownEvent?.status ?? null;
const { buckets, totalDowntime } = computeBuckets(
events,
days,
priorStatus
);
const totalWindow = days * 86400; const totalWindow = days * 86400;
const overallUptime = const overallUptime =
totalWindow > 0 totalWindow > 0
@@ -110,7 +132,8 @@ export function computeBuckets(
timestamp: number; timestamp: number;
id: number; id: number;
}[], }[],
days: number days: number,
priorStatus: string | null = null
): { buckets: StatusHistoryDayBucket[]; totalDowntime: number } { ): { buckets: StatusHistoryDayBucket[]; totalDowntime: number } {
const nowSec = Math.floor(Date.now() / 1000); const nowSec = Math.floor(Date.now() / 1000);
@@ -136,7 +159,10 @@ export function computeBuckets(
.filter((e) => e.timestamp < dayStartSec) .filter((e) => e.timestamp < dayStartSec)
.at(-1); .at(-1);
const currentStatus = lastBeforeDay?.status ?? null; // Fall back to the last known state before the entire query window
// so that entities that haven't generated events recently still show
// as their actual status rather than "no_data".
const currentStatus = lastBeforeDay?.status ?? priorStatus ?? null;
const windows: { start: number; end: number | null; status: string }[] = const windows: { start: number; end: number | null; status: string }[] =
[]; [];
@@ -119,8 +119,7 @@ export async function verifyAccessTokenAccess(
return next( return next(
createHttpError( createHttpError(
HttpCode.FORBIDDEN, HttpCode.FORBIDDEN,
"Failed organization access policy check: " + "" + (policyCheck.error || "Unknown error")
(policyCheck.error || "Unknown error")
) )
); );
} }
+1 -2
View File
@@ -56,8 +56,7 @@ export async function verifyAdmin(
return next( return next(
createHttpError( createHttpError(
HttpCode.FORBIDDEN, HttpCode.FORBIDDEN,
"Failed organization access policy check: " + "" + (policyCheck.error || "Unknown error")
(policyCheck.error || "Unknown error")
) )
); );
} }
+1 -2
View File
@@ -113,8 +113,7 @@ export async function verifyApiKeyAccess(
return next( return next(
createHttpError( createHttpError(
HttpCode.FORBIDDEN, HttpCode.FORBIDDEN,
"Failed organization access policy check: " + "" + (policyCheck.error || "Unknown error")
(policyCheck.error || "Unknown error")
) )
); );
} }
+2 -6
View File
@@ -107,8 +107,7 @@ export async function verifyClientAccess(
return next( return next(
createHttpError( createHttpError(
HttpCode.FORBIDDEN, HttpCode.FORBIDDEN,
"Failed organization access policy check: " + "" + (policyCheck.error || "Unknown error")
(policyCheck.error || "Unknown error")
) )
); );
} }
@@ -129,10 +128,7 @@ export async function verifyClientAccess(
.where( .where(
and( and(
eq(roleClients.clientId, client.clientId), eq(roleClients.clientId, client.clientId),
inArray( inArray(roleClients.roleId, req.userOrgRoleIds!)
roleClients.roleId,
req.userOrgRoleIds!
)
) )
) )
.limit(1) .limit(1)
+1 -2
View File
@@ -88,8 +88,7 @@ export async function verifyDomainAccess(
return next( return next(
createHttpError( createHttpError(
HttpCode.FORBIDDEN, HttpCode.FORBIDDEN,
"Failed organization access policy check: " + "" + (policyCheck.error || "Unknown error")
(policyCheck.error || "Unknown error")
) )
); );
} }
+2 -2
View File
@@ -7,6 +7,7 @@ import HttpCode from "@server/types/HttpCode";
import { checkOrgAccessPolicy } from "#dynamic/lib/checkOrgAccessPolicy"; import { checkOrgAccessPolicy } from "#dynamic/lib/checkOrgAccessPolicy";
import { getUserOrgRoleIds } from "@server/lib/userOrgRoles"; import { getUserOrgRoleIds } from "@server/lib/userOrgRoles";
import { getFirstString } from "@server/lib/requestParams"; import { getFirstString } from "@server/lib/requestParams";
import logger from "@server/logger";
export async function verifyOrgAccess( export async function verifyOrgAccess(
req: Request, req: Request,
@@ -59,8 +60,7 @@ export async function verifyOrgAccess(
return next( return next(
createHttpError( createHttpError(
HttpCode.FORBIDDEN, HttpCode.FORBIDDEN,
"Failed organization access policy check: " + "" + (policyCheck.error || "Unknown error")
(policyCheck.error || "Unknown error")
) )
); );
} }
+1 -2
View File
@@ -105,8 +105,7 @@ export async function verifyResourceAccess(
return next( return next(
createHttpError( createHttpError(
HttpCode.FORBIDDEN, HttpCode.FORBIDDEN,
"Failed organization access policy check: " + "" + (policyCheck.error || "Unknown error")
(policyCheck.error || "Unknown error")
) )
); );
} }
@@ -102,8 +102,7 @@ export async function verifyResourcePolicyAccess(
return next( return next(
createHttpError( createHttpError(
HttpCode.FORBIDDEN, HttpCode.FORBIDDEN,
"Failed organization access policy check: " + "" + (policyCheck.error || "Unknown error")
(policyCheck.error || "Unknown error")
) )
); );
} }
+1 -2
View File
@@ -132,8 +132,7 @@ export async function verifyRoleAccess(
return next( return next(
createHttpError( createHttpError(
HttpCode.FORBIDDEN, HttpCode.FORBIDDEN,
"Failed organization access policy check: " + "" + (policyCheck.error || "Unknown error")
(policyCheck.error || "Unknown error")
) )
); );
} }
@@ -45,8 +45,7 @@ export async function verifySetResourceClients(
return next( return next(
createHttpError( createHttpError(
HttpCode.FORBIDDEN, HttpCode.FORBIDDEN,
"Failed organization access policy check: " + "" + (policyCheck.error || "Unknown error")
(policyCheck.error || "Unknown error")
) )
); );
} }
+1 -2
View File
@@ -40,8 +40,7 @@ export async function verifySetResourceUsers(
return next( return next(
createHttpError( createHttpError(
HttpCode.FORBIDDEN, HttpCode.FORBIDDEN,
"Failed organization access policy check: " + "" + (policyCheck.error || "Unknown error")
(policyCheck.error || "Unknown error")
) )
); );
} }
+1 -2
View File
@@ -115,8 +115,7 @@ export async function verifySiteAccess(
return next( return next(
createHttpError( createHttpError(
HttpCode.FORBIDDEN, HttpCode.FORBIDDEN,
"Failed organization access policy check: " + "" + (policyCheck.error || "Unknown error")
(policyCheck.error || "Unknown error")
) )
); );
} }
@@ -115,8 +115,7 @@ export async function verifySiteProvisioningKeyAccess(
return next( return next(
createHttpError( createHttpError(
HttpCode.FORBIDDEN, HttpCode.FORBIDDEN,
"Failed organization access policy check: " + "" + (policyCheck.error || "Unknown error")
(policyCheck.error || "Unknown error")
) )
); );
} }
@@ -103,8 +103,7 @@ export async function verifySiteResourceAccess(
return next( return next(
createHttpError( createHttpError(
HttpCode.FORBIDDEN, HttpCode.FORBIDDEN,
"Failed organization access policy check: " + "" + (policyCheck.error || "Unknown error")
(policyCheck.error || "Unknown error")
) )
); );
} }
+1 -2
View File
@@ -122,8 +122,7 @@ export async function verifyTargetAccess(
return next( return next(
createHttpError( createHttpError(
HttpCode.FORBIDDEN, HttpCode.FORBIDDEN,
"Failed organization access policy check: " + "" + (policyCheck.error || "Unknown error")
(policyCheck.error || "Unknown error")
) )
); );
} }
+1 -2
View File
@@ -59,8 +59,7 @@ export async function verifyUserAccess(
return next( return next(
createHttpError( createHttpError(
HttpCode.FORBIDDEN, HttpCode.FORBIDDEN,
"Failed organization access policy check: " + "" + (policyCheck.error || "Unknown error")
(policyCheck.error || "Unknown error")
) )
); );
} }
+3 -3
View File
@@ -693,9 +693,9 @@ async function syncAcmeCerts(acmeJsonPath: string): Promise<void> {
); );
continue; continue;
} }
logger.debug( // logger.debug(
`acmeCertSync: found ${resolverData.Certificates.length} certificate(s) for resolver "${resolver}"` // `acmeCertSync: found ${resolverData.Certificates.length} certificate(s) for resolver "${resolver}"`
); // );
for (const cert of resolverData.Certificates) { for (const cert of resolverData.Certificates) {
allCerts.push(cert); allCerts.push(cert);
} }
+78 -10
View File
@@ -21,6 +21,49 @@ import {
} from "@server/lib/checkOrgAccessPolicy"; } from "@server/lib/checkOrgAccessPolicy";
import { UserType } from "@server/types/UserTypes"; import { UserType } from "@server/types/UserTypes";
function formatMaxSessionLengthRequirement(
maxSessionLengthHours: number
): string {
if (maxSessionLengthHours < 24) {
return `This organization requires you to log in every ${maxSessionLengthHours} hours.`;
}
const maxDays = Math.round(maxSessionLengthHours / 24);
return `This organization requires you to log in every ${maxDays} days.`;
}
function buildOrgAccessPolicyError(
policies: CheckOrgAccessPolicyResult["policies"]
): string | undefined {
if (!policies) {
return undefined;
}
const errors: string[] = [];
if (policies.requiredTwoFactor === false) {
errors.push(
"This organization requires two-factor authentication. Enable two-factor authentication on your account to continue."
);
}
if (policies.maxSessionLength?.compliant === false) {
errors.push(
`Your session has expired. ${formatMaxSessionLengthRequirement(
policies.maxSessionLength.maxSessionLengthHours
)}`
);
}
if (policies.passwordAge?.compliant === false) {
errors.push(
`Your password has expired. This organization requires you to change your password every ${policies.passwordAge.maxPasswordAgeDays} days.`
);
}
return errors.length > 0 ? errors.join(" ") : undefined;
}
export function enforceResourceSessionLength( export function enforceResourceSessionLength(
resourceSession: ResourceSession, resourceSession: ResourceSession,
org: Org org: Org
@@ -36,13 +79,17 @@ export function enforceResourceSessionLength(
if (sessionAgeMs > maxSessionLengthMs) { if (sessionAgeMs > maxSessionLengthMs) {
return { return {
valid: false, valid: false,
error: `Resource session has expired due to organization policy (max session length: ${maxSessionLengthHours} hours)` error: `Your resource session has expired. ${formatMaxSessionLengthRequirement(
maxSessionLengthHours
)}`
}; };
} }
} else { } else {
return { return {
valid: false, valid: false,
error: `Resource session is invalid due to organization policy (max session length: ${maxSessionLengthHours} hours)` error: `Your resource session is invalid. ${formatMaxSessionLengthRequirement(
maxSessionLengthHours
)}`
}; };
} }
} }
@@ -60,14 +107,20 @@ export async function checkOrgAccessPolicy(
if (!orgId) { if (!orgId) {
return { return {
allowed: false, allowed: false,
error: "Organization ID is required" error: "Unable to verify organization access. Organization information is missing."
}; };
} }
if (!userId) { if (!userId) {
return { allowed: false, error: "User ID is required" }; return {
allowed: false,
error: "Unable to verify organization access. User information is missing."
};
} }
if (!sessionId) { if (!sessionId) {
return { allowed: false, error: "Session ID is required" }; return {
allowed: false,
error: "Your session is invalid. Please log in again."
};
} }
if (build === "enterprise") { if (build === "enterprise") {
@@ -89,7 +142,10 @@ export async function checkOrgAccessPolicy(
.where(eq(orgs.orgId, orgId)); .where(eq(orgs.orgId, orgId));
props.org = orgQuery; props.org = orgQuery;
if (!props.org) { if (!props.org) {
return { allowed: false, error: "Organization not found" }; return {
allowed: false,
error: "This organization could not be found."
};
} }
} }
@@ -100,7 +156,10 @@ export async function checkOrgAccessPolicy(
.where(eq(users.userId, userId)); .where(eq(users.userId, userId));
props.user = userQuery; props.user = userQuery;
if (!props.user) { if (!props.user) {
return { allowed: false, error: "User not found" }; return {
allowed: false,
error: "Your account could not be found."
};
} }
} }
@@ -111,14 +170,17 @@ export async function checkOrgAccessPolicy(
.where(eq(sessions.sessionId, sessionId)); .where(eq(sessions.sessionId, sessionId));
props.session = sessionQuery; props.session = sessionQuery;
if (!props.session) { if (!props.session) {
return { allowed: false, error: "Session not found" }; return {
allowed: false,
error: "Your session has expired. Please log in again."
};
} }
} }
if (props.session.userId !== props.user.userId) { if (props.session.userId !== props.user.userId) {
return { return {
allowed: false, allowed: false,
error: "Session does not belong to the user" error: "Your session is invalid. Please log in again."
}; };
} }
@@ -187,8 +249,14 @@ export async function checkOrgAccessPolicy(
allowed = false; allowed = false;
} }
const policyError = buildOrgAccessPolicyError(policies);
return { return {
allowed, allowed,
policies policies,
error: allowed
? undefined
: (policyError ??
"You do not meet this organization's security requirements.")
}; };
} }
+105 -48
View File
@@ -11,14 +11,31 @@
* This file is not licensed under the AGPLv3. * This file is not licensed under the AGPLv3.
*/ */
import { config } from "@server/lib/config";
import logger from "@server/logger"; import logger from "@server/logger";
import { redis } from "#private/lib/redis"; import { redis } from "#private/lib/redis";
import { v4 as uuidv4 } from "uuid"; import { v4 as uuidv4 } from "uuid";
const instanceId = uuidv4(); const instanceId = uuidv4();
type LocalLockRecord = {
owner: string;
expiresAt: number;
};
const localLocks = new Map<string, LocalLockRecord>();
export class LockManager { export class LockManager {
private clearExpiredLocalLock(lockKey: string): void {
const current = localLocks.get(lockKey);
if (current && current.expiresAt <= Date.now()) {
localLocks.delete(lockKey);
}
}
private getLocalOwnerToken(): string {
return `${instanceId}:`;
}
/** /**
* Acquire a distributed lock using Redis SET with NX and PX options * Acquire a distributed lock using Redis SET with NX and PX options
* @param lockKey - Unique identifier for the lock * @param lockKey - Unique identifier for the lock
@@ -32,12 +49,34 @@ export class LockManager {
retryDelayMs: number = 100 retryDelayMs: number = 100
): Promise<boolean> { ): Promise<boolean> {
if (!redis || !redis.status || redis.status !== "ready") { if (!redis || !redis.status || redis.status !== "ready") {
return true; for (let attempt = 0; attempt < maxRetries; attempt++) {
this.clearExpiredLocalLock(lockKey);
const existing = localLocks.get(lockKey);
if (!existing) {
localLocks.set(lockKey, {
owner: this.getLocalOwnerToken(),
expiresAt: Date.now() + ttlMs
});
return true;
}
if (existing.owner === this.getLocalOwnerToken()) {
existing.expiresAt = Date.now() + ttlMs;
localLocks.set(lockKey, existing);
return true;
}
if (attempt < maxRetries - 1) {
const delay = retryDelayMs * Math.pow(2, attempt);
await new Promise((resolve) => setTimeout(resolve, delay));
}
}
return false;
} }
const lockValue = `${ const lockValue = `${instanceId}:${Date.now()}`;
instanceId
}:${Date.now()}`;
const redisKey = `lock:${lockKey}`; const redisKey = `lock:${lockKey}`;
for (let attempt = 0; attempt < maxRetries; attempt++) { for (let attempt = 0; attempt < maxRetries; attempt++) {
@@ -53,11 +92,7 @@ export class LockManager {
); );
if (result === "OK") { if (result === "OK") {
logger.debug( logger.debug(`Lock acquired: ${lockKey} by ${instanceId}`);
`Lock acquired: ${lockKey} by ${
instanceId
}`
);
return true; return true;
} }
@@ -65,17 +100,11 @@ export class LockManager {
const existingValue = await redis.get(redisKey); const existingValue = await redis.get(redisKey);
if ( if (
existingValue && existingValue &&
existingValue.startsWith( existingValue.startsWith(`${instanceId}:`)
`${instanceId}:`
)
) { ) {
// Extend the lock TTL since it's the same worker // Extend the lock TTL since it's the same worker
await redis.pexpire(redisKey, ttlMs); await redis.pexpire(redisKey, ttlMs);
logger.debug( logger.debug(`Lock extended: ${lockKey} by ${instanceId}`);
`Lock extended: ${lockKey} by ${
instanceId
}`
);
return true; return true;
} }
@@ -88,7 +117,10 @@ export class LockManager {
await new Promise((resolve) => setTimeout(resolve, delay)); await new Promise((resolve) => setTimeout(resolve, delay));
} }
} catch (error) { } catch (error) {
logger.error(`Failed to acquire lock ${lockKey} (attempt ${attempt + 1}/${maxRetries}):`, error); logger.error(
`Failed to acquire lock ${lockKey} (attempt ${attempt + 1}/${maxRetries}):`,
error
);
// On error, still retry if we have attempts left // On error, still retry if we have attempts left
if (attempt < maxRetries - 1) { if (attempt < maxRetries - 1) {
const delay = retryDelayMs * Math.pow(2, attempt); const delay = retryDelayMs * Math.pow(2, attempt);
@@ -109,6 +141,11 @@ export class LockManager {
*/ */
async releaseLock(lockKey: string): Promise<void> { async releaseLock(lockKey: string): Promise<void> {
if (!redis || !redis.status || redis.status !== "ready") { if (!redis || !redis.status || redis.status !== "ready") {
this.clearExpiredLocalLock(lockKey);
const existing = localLocks.get(lockKey);
if (existing && existing.owner === this.getLocalOwnerToken()) {
localLocks.delete(lockKey);
}
return; return;
} }
@@ -136,11 +173,7 @@ export class LockManager {
)) as number; )) as number;
if (result === 1) { if (result === 1) {
logger.debug( logger.debug(`Lock released: ${lockKey} by ${instanceId}`);
`Lock released: ${lockKey} by ${
instanceId
}`
);
} else { } else {
logger.warn( logger.warn(
`Lock not released - not owned by worker: ${lockKey} by ${ `Lock not released - not owned by worker: ${lockKey} by ${
@@ -159,6 +192,7 @@ export class LockManager {
*/ */
async forceReleaseLock(lockKey: string): Promise<void> { async forceReleaseLock(lockKey: string): Promise<void> {
if (!redis || !redis.status || redis.status !== "ready") { if (!redis || !redis.status || redis.status !== "ready") {
localLocks.delete(lockKey);
return; return;
} }
@@ -186,7 +220,20 @@ export class LockManager {
owner?: string; owner?: string;
}> { }> {
if (!redis || !redis.status || redis.status !== "ready") { if (!redis || !redis.status || redis.status !== "ready") {
return { exists: false, ownedByMe: true, ttl: 0 }; this.clearExpiredLocalLock(lockKey);
const existing = localLocks.get(lockKey);
if (!existing) {
return { exists: false, ownedByMe: false, ttl: 0 };
}
const ttl = Math.max(0, existing.expiresAt - Date.now());
return {
exists: true,
ownedByMe: existing.owner === this.getLocalOwnerToken(),
ttl,
owner: existing.owner.split(":")[0]
};
} }
const redisKey = `lock:${lockKey}`; const redisKey = `lock:${lockKey}`;
@@ -198,11 +245,7 @@ export class LockManager {
]); ]);
const exists = value !== null; const exists = value !== null;
const ownedByMe = const ownedByMe = exists && value!.startsWith(`${instanceId}:`);
exists &&
value!.startsWith(
`${instanceId}:`
);
const owner = exists ? value!.split(":")[0] : undefined; const owner = exists ? value!.split(":")[0] : undefined;
return { return {
@@ -225,6 +268,15 @@ export class LockManager {
*/ */
async extendLock(lockKey: string, ttlMs: number): Promise<boolean> { async extendLock(lockKey: string, ttlMs: number): Promise<boolean> {
if (!redis || !redis.status || redis.status !== "ready") { if (!redis || !redis.status || redis.status !== "ready") {
this.clearExpiredLocalLock(lockKey);
const existing = localLocks.get(lockKey);
if (!existing || existing.owner !== this.getLocalOwnerToken()) {
return false;
}
existing.expiresAt = Date.now() + ttlMs;
localLocks.set(lockKey, existing);
return true; return true;
} }
@@ -255,9 +307,7 @@ export class LockManager {
if (result === 1) { if (result === 1) {
logger.debug( logger.debug(
`Lock extended: ${lockKey} by ${ `Lock extended: ${lockKey} by ${instanceId} for ${ttlMs}ms`
instanceId
} for ${ttlMs}ms`
); );
return true; return true;
} }
@@ -282,12 +332,13 @@ export class LockManager {
maxRetries: number = 5, maxRetries: number = 5,
baseDelayMs: number = 100 baseDelayMs: number = 100
): Promise<boolean> { ): Promise<boolean> {
if (!redis || !redis.status || redis.status !== "ready") {
return true;
}
for (let attempt = 0; attempt <= maxRetries; attempt++) { for (let attempt = 0; attempt <= maxRetries; attempt++) {
const acquired = await this.acquireLock(lockKey, ttlMs); const acquired = await this.acquireLock(
lockKey,
ttlMs,
1,
baseDelayMs
);
if (acquired) { if (acquired) {
return true; return true;
@@ -319,10 +370,6 @@ export class LockManager {
fn: () => Promise<T>, fn: () => Promise<T>,
ttlMs: number = 30000 ttlMs: number = 30000
): Promise<T> { ): Promise<T> {
if (!redis || !redis.status || redis.status !== "ready") {
return await fn();
}
const acquired = await this.acquireLock(lockKey, ttlMs); const acquired = await this.acquireLock(lockKey, ttlMs);
if (!acquired) { if (!acquired) {
@@ -346,7 +393,21 @@ export class LockManager {
locksOwnedByMe: number; locksOwnedByMe: number;
}> { }> {
if (!redis || !redis.status || redis.status !== "ready") { if (!redis || !redis.status || redis.status !== "ready") {
return { activeLocksCount: 0, locksOwnedByMe: 0 }; const now = Date.now();
for (const [key, value] of localLocks.entries()) {
if (value.expiresAt <= now) {
localLocks.delete(key);
}
}
let locksOwnedByMe = 0;
for (const value of localLocks.values()) {
if (value.owner === this.getLocalOwnerToken()) {
locksOwnedByMe++;
}
}
return { activeLocksCount: localLocks.size, locksOwnedByMe };
} }
try { try {
@@ -356,11 +417,7 @@ export class LockManager {
if (keys.length > 0) { if (keys.length > 0) {
const values = await redis.mget(...keys); const values = await redis.mget(...keys);
locksOwnedByMe = values.filter( locksOwnedByMe = values.filter(
(value) => (value) => value && value.startsWith(`${instanceId}:`)
value &&
value.startsWith(
`${instanceId}:`
)
).length; ).length;
} }
+11
View File
@@ -46,6 +46,17 @@ const POLL_INTERVAL_MS = 500;
class RedisRebuildQueue { class RedisRebuildQueue {
private processingStarted = false; private processingStarted = false;
async isQueued(job: RebuildJob): Promise<boolean> {
if (!redis || redis.status !== "ready") return false;
const dedupeKey = `${job.type}:${job.id}`;
try {
const member = await redis.sismember(QUEUED_SET_KEY, dedupeKey);
return member === 1;
} catch {
return false;
}
}
async enqueue(job: RebuildJob): Promise<void> { async enqueue(job: RebuildJob): Promise<void> {
if (!redis || redis.status !== "ready") { if (!redis || redis.status !== "ready") {
logger.warn( logger.warn(
+2 -45
View File
@@ -10,9 +10,8 @@ import { hashPassword, verifyPassword } from "@server/auth/password";
import { verifyTotpCode } from "@server/auth/totp"; import { verifyTotpCode } from "@server/auth/totp";
import logger from "@server/logger"; import logger from "@server/logger";
import { unauthorized } from "@server/auth/unauthorizedResponse"; import { unauthorized } from "@server/auth/unauthorizedResponse";
import { invalidateAllSessions } from "@server/auth/sessions/app"; import { invalidateAllSessionsExceptCurrent } from "@server/auth/sessions/app";
import { sessions, resourceSessions } from "@server/db"; import { eq } from "drizzle-orm";
import { and, eq, ne, inArray } from "drizzle-orm";
import { passwordSchema } from "@server/auth/passwordSchema"; import { passwordSchema } from "@server/auth/passwordSchema";
import { UserType } from "@server/types/UserTypes"; import { UserType } from "@server/types/UserTypes";
import { sendEmail } from "@server/emails"; import { sendEmail } from "@server/emails";
@@ -31,48 +30,6 @@ export type ChangePasswordResponse = {
codeRequested?: boolean; codeRequested?: boolean;
}; };
async function invalidateAllSessionsExceptCurrent(
userId: string,
currentSessionId: string
): Promise<void> {
try {
await db.transaction(async (trx) => {
// Get all user sessions except the current one
const userSessions = await trx
.select()
.from(sessions)
.where(
and(
eq(sessions.userId, userId),
ne(sessions.sessionId, currentSessionId)
)
);
// Delete resource sessions for the sessions we're invalidating
if (userSessions.length > 0) {
await trx.delete(resourceSessions).where(
inArray(
resourceSessions.userSessionId,
userSessions.map((s) => s.sessionId)
)
);
}
// Delete the user sessions (except current)
await trx
.delete(sessions)
.where(
and(
eq(sessions.userId, userId),
ne(sessions.sessionId, currentSessionId)
)
);
});
} catch (e) {
logger.error("Failed to invalidate user sessions except current", e);
}
}
export async function changePassword( export async function changePassword(
req: Request, req: Request,
res: Response, res: Response,
+13
View File
@@ -15,6 +15,10 @@ import TwoFactorAuthNotification from "@server/emails/templates/TwoFactorAuthNot
import config from "@server/lib/config"; import config from "@server/lib/config";
import { UserType } from "@server/types/UserTypes"; import { UserType } from "@server/types/UserTypes";
import { generateBackupCodes } from "@server/lib/totp"; import { generateBackupCodes } from "@server/lib/totp";
import {
invalidateAllSessions,
invalidateAllSessionsExceptCurrent
} from "@server/auth/sessions/app";
import { verifySession } from "@server/auth/sessions/verifySession"; import { verifySession } from "@server/auth/sessions/verifySession";
import { unauthorized } from "@server/auth/unauthorizedResponse"; import { unauthorized } from "@server/auth/unauthorizedResponse";
@@ -168,6 +172,15 @@ export async function verifyTotp(
); );
} }
if (existingSession) {
await invalidateAllSessionsExceptCurrent(
user.userId,
existingSession.sessionId
);
} else {
await invalidateAllSessions(user.userId);
}
sendEmail( sendEmail(
TwoFactorAuthNotification({ TwoFactorAuthNotification({
email: user.email!, email: user.email!,
+64
View File
@@ -438,6 +438,70 @@ export async function removePeerDataBatch(
await sendToClientsBatch(payloads); await sendToClientsBatch(payloads);
} }
export async function updatePeerDataBatch(
entries: {
clientId: number;
siteId: number;
remoteSubnets:
| {
oldRemoteSubnets: string[];
newRemoteSubnets: string[];
}
| undefined;
aliases:
| {
oldAliases: Alias[];
newAliases: Alias[];
}
| undefined;
olmId?: string;
version?: string | null;
}[]
) {
if (entries.length === 0) {
return;
}
const resolvedTargets = await resolveOlmTargets(entries);
if (resolvedTargets.length === 0) {
return;
}
const payloads = entries
.map((entry) => {
const resolved = resolvedTargets.find(
(target) => target.clientId === entry.clientId
);
if (!resolved) {
return null;
}
return {
clientId: resolved.olmId,
message: {
type: `olm/wg/peer/data/update`,
data: {
siteId: entry.siteId,
...entry.remoteSubnets,
...entry.aliases
}
},
options: {
incrementConfigVersion: true,
compress: canCompress(resolved.version, "olm")
}
};
})
.filter((entry) => entry !== null);
if (payloads.length === 0) {
return;
}
await sendToClientsBatch(payloads);
}
export async function updatePeerData( export async function updatePeerData(
clientId: number, clientId: number,
siteId: number, siteId: number,
@@ -9,6 +9,7 @@ import { buildClientConfigurationForNewtClient } from "./buildConfiguration";
import { convertTargetsIfNecessary } from "../client/targets"; import { convertTargetsIfNecessary } from "../client/targets";
import { canCompress } from "@server/lib/clientVersionChecks"; import { canCompress } from "@server/lib/clientVersionChecks";
import config from "@server/lib/config"; import config from "@server/lib/config";
import { waitForSiteRebuildIdle } from "@server/lib/rebuildClientAssociations";
export const handleNewtGetConfigMessage: MessageHandler = async (context) => { export const handleNewtGetConfigMessage: MessageHandler = async (context) => {
const { message, client, sendToClient } = context; const { message, client, sendToClient } = context;
@@ -61,6 +62,8 @@ export const handleNewtGetConfigMessage: MessageHandler = async (context) => {
return; return;
} }
await waitForSiteRebuildIdle(siteId);
// update the endpoint and the public key // update the endpoint and the public key
const [site] = await db const [site] = await db
.update(sites) .update(sites)
+14 -12
View File
@@ -49,20 +49,22 @@ export const handleNewtPingMessage: MessageHandler = async (context) => {
`Newt ping with outdated config version: ${message.configVersion} (current: ${configVersion})` `Newt ping with outdated config version: ${message.configVersion} (current: ${configVersion})`
); );
const [site] = await db // TODO: IMPLEMENT THE SYNC ON THE NEWT SIDE AND COMMENT THIS BACK IN
.select()
.from(sites)
.where(eq(sites.siteId, newt.siteId))
.limit(1);
if (!site) { // const [site] = await db
logger.warn( // .select()
`Newt ping message: site with ID ${newt.siteId} not found` // .from(sites)
); // .where(eq(sites.siteId, newt.siteId))
return; // .limit(1);
}
await sendNewtSyncMessage(newt, site); // if (!site) {
// logger.warn(
// `Newt ping message: site with ID ${newt.siteId} not found`
// );
// return;
// }
// await sendNewtSyncMessage(newt, site);
} }
return { return {
@@ -21,6 +21,7 @@ import { build } from "@server/build";
import { canCompress } from "@server/lib/clientVersionChecks"; import { canCompress } from "@server/lib/clientVersionChecks";
import config from "@server/lib/config"; import config from "@server/lib/config";
import cache from "#dynamic/lib/cache"; // not using regional here because we need this in the register message handler before we know where the client is import cache from "#dynamic/lib/cache"; // not using regional here because we need this in the register message handler before we know where the client is
import { waitForClientRebuildIdle } from "@server/lib/rebuildClientAssociations";
const HOLEPUNCH_STALE_CHAIN_THRESHOLD = 18; const HOLEPUNCH_STALE_CHAIN_THRESHOLD = 18;
const HOLEPUNCH_STALE_CHAIN_TTL_SECONDS = 1800; const HOLEPUNCH_STALE_CHAIN_TTL_SECONDS = 1800;
@@ -385,6 +386,8 @@ export const handleOlmRegisterMessage: MessageHandler = async (context) => {
} }
// NOTE: its important that the client here is the old client and the public key is the new key // NOTE: its important that the client here is the old client and the public key is the new key
await waitForClientRebuildIdle(olm.clientId);
const siteConfigurations = await buildSiteConfigurationForOlmClient( const siteConfigurations = await buildSiteConfigurationForOlmClient(
client, client,
publicKey, publicKey,
+14 -79
View File
@@ -1,13 +1,4 @@
import { eq, inArray } from "drizzle-orm"; import { db } from "@server/db";
import {
db,
newts,
resourcePolicies,
resources,
sites,
targetHealthCheck,
targets
} from "@server/db";
import response from "@server/lib/response"; import response from "@server/lib/response";
import logger from "@server/logger"; import logger from "@server/logger";
import { OpenAPITags, registry } from "@server/openApi"; import { OpenAPITags, registry } from "@server/openApi";
@@ -16,9 +7,11 @@ import { NextFunction, Request, Response } from "express";
import createHttpError from "http-errors"; import createHttpError from "http-errors";
import { z } from "zod"; import { z } from "zod";
import { fromError } from "zod-validation-error"; import { fromError } from "zod-validation-error";
import { removeTargets } from "../newt/targets"; import {
performDeleteResource,
runResourceDeleteSideEffects
} from "@server/lib/deleteResource";
// Define Zod schema for request parameters validation
const deleteResourceSchema = z.strictObject({ const deleteResourceSchema = z.strictObject({
resourceId: z.coerce.number().int().positive() resourceId: z.coerce.number().int().positive()
}); });
@@ -67,27 +60,13 @@ export async function deleteResource(
const { resourceId } = parsedParams.data; const { resourceId } = parsedParams.data;
const targetsToBeRemoved = await db let deleteResult = null;
.select()
.from(targets)
.where(eq(targets.resourceId, resourceId));
const healthChecksToBeRemoved = await db await db.transaction(async (trx) => {
.select() deleteResult = await performDeleteResource(resourceId, trx);
.from(targetHealthCheck) });
.where(
inArray(
targetHealthCheck.targetId,
targetsToBeRemoved.map((t) => t.targetId)
)
);
const [deletedResource] = await db if (!deleteResult) {
.delete(resources)
.where(eq(resources.resourceId, resourceId))
.returning();
if (!deletedResource) {
return next( return next(
createHttpError( createHttpError(
HttpCode.NOT_FOUND, HttpCode.NOT_FOUND,
@@ -96,54 +75,7 @@ export async function deleteResource(
); );
} }
for (const target of targetsToBeRemoved) { await runResourceDeleteSideEffects(deleteResult);
const [site] = await db
.select()
.from(sites)
.where(eq(sites.siteId, target.siteId))
.limit(1);
if (!site) {
return next(
createHttpError(
HttpCode.NOT_FOUND,
`Site with ID ${target.siteId} not found`
)
);
}
if (site.pubKey) {
if (site.type == "newt") {
// get the newt on the site by querying the newt table for siteId
const [newt] = await db
.select()
.from(newts)
.where(eq(newts.siteId, site.siteId))
.limit(1);
await removeTargets(
newt.newtId,
// [target],
[], // deleting the target from newt causes issues because we cant unbind the port. this needs to be fixed in newt before we can do this
healthChecksToBeRemoved,
deletedResource.mode === "udp" ? "udp" : "tcp",
newt.version
);
}
}
}
// Also delete default resource policy
if (deletedResource.defaultResourcePolicyId) {
await db
.delete(resourcePolicies)
.where(
eq(
resourcePolicies.resourcePolicyId,
deletedResource.defaultResourcePolicyId
)
);
}
return response(res, { return response(res, {
data: null, data: null,
@@ -154,6 +86,9 @@ export async function deleteResource(
}); });
} catch (error) { } catch (error) {
logger.error(error); logger.error(error);
if (createHttpError.isHttpError(error)) {
return next(error);
}
return next( return next(
createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred") createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred")
); );
+1 -2
View File
@@ -80,8 +80,7 @@ export async function getExchangeToken(
return next( return next(
createHttpError( createHttpError(
HttpCode.FORBIDDEN, HttpCode.FORBIDDEN,
"Failed organization access policy check: " + "" + (hasAccess.error || "Unknown error")
(hasAccess.error || "Unknown error")
) )
); );
} }
+93 -5
View File
@@ -14,18 +14,41 @@ import { OpenAPITags, registry } from "@server/openApi";
import { cleanupSiteAssociations } from "@server/lib/rebuildClientAssociations"; import { cleanupSiteAssociations } from "@server/lib/rebuildClientAssociations";
import { usageService } from "@server/lib/billing/usageService"; import { usageService } from "@server/lib/billing/usageService";
import { FeatureId } from "@server/lib/billing"; import { FeatureId } from "@server/lib/billing";
import { ActionsEnum, checkUserActionPermission } from "@server/auth/actions";
import {
deleteAssociatedResourcesForSite,
exceedsSiteAssociatedResourceDeleteLimit,
getAssociatedResourceCountForSite,
runDeleteSiteAssociatedResourcesSideEffects,
MAX_SITE_ASSOCIATED_RESOURCES_FOR_BULK_DELETE,
type DeleteSiteAssociatedResourcesSideEffects
} from "@server/lib/deleteSiteAssociatedResources";
const deleteSiteSchema = z.strictObject({ const deleteSiteSchema = z.strictObject({
siteId: z.coerce.number().int().positive() siteId: z.coerce.number().int().positive()
}); });
const deleteSiteQuerySchema = z.strictObject({
deleteResources: z
.enum(["true", "false"])
.transform((v) => v === "true")
.optional()
.catch(false)
.openapi({
type: "boolean",
description:
"When true, also deletes all public and private resources associated with this site"
})
});
registry.registerPath({ registry.registerPath({
method: "delete", method: "delete",
path: "/site/{siteId}", path: "/site/{siteId}",
description: "Delete a site and all its associated data.", description: "Delete a site and all its associated data.",
tags: [OpenAPITags.Site], tags: [OpenAPITags.Site],
request: { request: {
params: deleteSiteSchema params: deleteSiteSchema,
query: deleteSiteQuerySchema
}, },
responses: { responses: {
200: { 200: {
@@ -61,7 +84,18 @@ export async function deleteSite(
); );
} }
const parsedQuery = deleteSiteQuerySchema.safeParse(req.query);
if (!parsedQuery.success) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
fromError(parsedQuery.error).toString()
)
);
}
const { siteId } = parsedParams.data; const { siteId } = parsedParams.data;
const { deleteResources } = parsedQuery.data;
const [site] = await db const [site] = await db
.select() .select()
@@ -78,20 +112,67 @@ export async function deleteSite(
); );
} }
if (deleteResources) {
const canDeletePublic = await checkUserActionPermission(
ActionsEnum.deleteResource,
req
);
const canDeletePrivate = await checkUserActionPermission(
ActionsEnum.deleteSiteResource,
req
);
if (!canDeletePublic || !canDeletePrivate) {
return next(
createHttpError(
HttpCode.FORBIDDEN,
"User does not have permission to delete associated resources"
)
);
}
const associatedResourceCount =
await getAssociatedResourceCountForSite(siteId, site.orgId);
if (
exceedsSiteAssociatedResourceDeleteLimit(
associatedResourceCount
)
) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
`Cannot delete site and associated resources when the site has more than ${MAX_SITE_ASSOCIATED_RESOURCES_FOR_BULK_DELETE} resources`
)
);
}
}
const [deletedNewt] = await db const [deletedNewt] = await db
.select() .select()
.from(newts) .from(newts)
.where(eq(newts.siteId, siteId)) .where(eq(newts.siteId, siteId))
.limit(1); .limit(1);
let resourceSideEffects: DeleteSiteAssociatedResourcesSideEffects = {
resources: [],
siteResources: []
};
await db.transaction(async (trx) => { await db.transaction(async (trx) => {
if (deleteResources) {
resourceSideEffects = await deleteAssociatedResourcesForSite(
siteId,
site.orgId,
trx
);
}
if (site.type == "wireguard") { if (site.type == "wireguard") {
if (site.pubKey) { if (site.pubKey) {
await deletePeer(site.exitNodeId!, site.pubKey); await deletePeer(site.exitNodeId!, site.pubKey);
} }
} else if (site.type == "newt") { } else if (site.type == "newt") {
// Clean up all client associations and send peer/proxy removal
// messages in a single efficient pass before deleting the row.
await cleanupSiteAssociations(site, trx); await cleanupSiteAssociations(site, trx);
} }
@@ -99,13 +180,17 @@ export async function deleteSite(
await usageService.add(site.orgId, FeatureId.SITES, -1, trx); await usageService.add(site.orgId, FeatureId.SITES, -1, trx);
}); });
// Send termination message outside of transaction to prevent blocking if (deleteResources) {
await runDeleteSiteAssociatedResourcesSideEffects(
resourceSideEffects
);
}
if (deletedNewt) { if (deletedNewt) {
const payload = { const payload = {
type: `newt/wg/terminate`, type: `newt/wg/terminate`,
data: {} data: {}
}; };
// Don't await this to prevent blocking the response
sendToClient(deletedNewt.newtId, payload).catch((error) => { sendToClient(deletedNewt.newtId, payload).catch((error) => {
logger.error( logger.error(
"Failed to send termination message to newt:", "Failed to send termination message to newt:",
@@ -123,6 +208,9 @@ export async function deleteSite(
}); });
} catch (error) { } catch (error) {
logger.error(error); logger.error(error);
if (createHttpError.isHttpError(error)) {
return next(error);
}
return next( return next(
createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred") createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred")
); );
+6 -1
View File
@@ -10,6 +10,7 @@ import logger from "@server/logger";
import stoi from "@server/lib/stoi"; import stoi from "@server/lib/stoi";
import { fromError } from "zod-validation-error"; import { fromError } from "zod-validation-error";
import { OpenAPITags, registry } from "@server/openApi"; import { OpenAPITags, registry } from "@server/openApi";
import { getCountryCodeForIp } from "@server/lib/geoip";
const getSiteSchema = z.strictObject({ const getSiteSchema = z.strictObject({
siteId: z siteId: z
@@ -47,6 +48,7 @@ type SiteQueryRow = NonNullable<Awaited<ReturnType<typeof query>>>;
export type GetSiteResponse = SiteQueryRow["sites"] & { export type GetSiteResponse = SiteQueryRow["sites"] & {
newtId: string | null; newtId: string | null;
newtVersion: string | null; newtVersion: string | null;
countryCode: string | null;
}; };
registry.registerPath({ registry.registerPath({
@@ -134,7 +136,10 @@ export async function getSite(
const data: GetSiteResponse = { const data: GetSiteResponse = {
...site.sites, ...site.sites,
newtId: site.newt ? site.newt.newtId : null, newtId: site.newt ? site.newt.newtId : null,
newtVersion: site.newt?.version ?? null newtVersion: site.newt?.version ?? null,
countryCode: site.sites.endpoint
? ((await getCountryCodeForIp(site.sites.endpoint)) ?? null)
: null
}; };
return response<GetSiteResponse>(res, { return response<GetSiteResponse>(res, {
@@ -1,15 +1,17 @@
import { Request, Response, NextFunction } from "express"; import { Request, Response, NextFunction } from "express";
import { z } from "zod"; import { z } from "zod";
import { db, newts, primaryDb, sites } from "@server/db"; import { db, siteResources } from "@server/db";
import { siteResources } from "@server/db";
import response from "@server/lib/response"; import response from "@server/lib/response";
import HttpCode from "@server/types/HttpCode"; import HttpCode from "@server/types/HttpCode";
import createHttpError from "http-errors"; import createHttpError from "http-errors";
import { eq, and } from "drizzle-orm"; import { eq } from "drizzle-orm";
import { fromError } from "zod-validation-error"; import { fromError } from "zod-validation-error";
import logger from "@server/logger"; import logger from "@server/logger";
import { OpenAPITags, registry } from "@server/openApi"; import { OpenAPITags, registry } from "@server/openApi";
import { rebuildClientAssociationsFromSiteResource } from "@server/lib/rebuildClientAssociations"; import {
performDeleteSiteResource,
runSiteResourceDeleteSideEffects
} from "@server/lib/deleteSiteResource";
const deleteSiteResourceParamsSchema = z.strictObject({ const deleteSiteResourceParamsSchema = z.strictObject({
siteResourceId: z.coerce.number().int().positive() siteResourceId: z.coerce.number().int().positive()
@@ -65,11 +67,10 @@ export async function deleteSiteResource(
const { siteResourceId } = parsedParams.data; const { siteResourceId } = parsedParams.data;
// Check if site resource exists
const [existingSiteResource] = await db const [existingSiteResource] = await db
.select() .select()
.from(siteResources) .from(siteResources)
.where(and(eq(siteResources.siteResourceId, siteResourceId))) .where(eq(siteResources.siteResourceId, siteResourceId))
.limit(1); .limit(1);
if (!existingSiteResource) { if (!existingSiteResource) {
@@ -78,26 +79,22 @@ export async function deleteSiteResource(
); );
} }
// Delete the site resource let removedSiteResource = null;
const [removedSiteResource] = await db
.delete(siteResources)
.where(eq(siteResources.siteResourceId, siteResourceId))
.returning();
// Run in the background after the response is sent. Wrapped in its await db.transaction(async (trx) => {
// own transaction so it always executes on the primary — avoiding any removedSiteResource = await performDeleteSiteResource(
// replica-lag issues while still allowing the HTTP response to return siteResourceId,
// early. trx
rebuildClientAssociationsFromSiteResource(removedSiteResource).catch( );
(err) => { });
logger.error(
`Error rebuilding client associations for site resource ${removedSiteResource!.siteResourceId}:`,
err
);
}
);
logger.info(`Deleted site resource ${siteResourceId}`); if (!removedSiteResource) {
return next(
createHttpError(HttpCode.NOT_FOUND, "Site resource not found")
);
}
runSiteResourceDeleteSideEffects(removedSiteResource);
return response(res, { return response(res, {
data: { message: "Site resource deleted successfully" }, data: { message: "Site resource deleted successfully" },
+29 -246
View File
@@ -1,8 +1,6 @@
import { import {
clientSiteResources, clientSiteResources,
clientSiteResourcesAssociationsCache,
db, db,
newts,
orgs, orgs,
roles, roles,
roleSiteResources, roleSiteResources,
@@ -10,10 +8,7 @@ import {
SiteResource, SiteResource,
siteResources, siteResources,
sites, sites,
networks, userSiteResources
Transaction,
userSiteResources,
primaryDb
} from "@server/db"; } from "@server/db";
import { isLicensedOrSubscribed } from "#dynamic/lib/isLicencedOrSubscribed"; import { isLicensedOrSubscribed } from "#dynamic/lib/isLicencedOrSubscribed";
import { TierFeature, tierMatrix } from "@server/lib/billing/tierMatrix"; import { TierFeature, tierMatrix } from "@server/lib/billing/tierMatrix";
@@ -21,17 +16,11 @@ import { validateAndConstructDomain } from "@server/lib/domainUtils";
import response from "@server/lib/response"; import response from "@server/lib/response";
import { eq, and, ne, inArray } from "drizzle-orm"; import { eq, and, ne, inArray } from "drizzle-orm";
import { OpenAPITags, registry } from "@server/openApi"; import { OpenAPITags, registry } from "@server/openApi";
import { updatePeerData, updateTargets } from "@server/routers/client/targets"; import { isIpInCidr, portRangeStringSchema } from "@server/lib/ip";
import { import {
generateAliasConfig, handleMessagingForUpdatedSiteResource,
generateRemoteSubnets, rebuildClientAssociationsFromSiteResource,
generateSubnetProxyTargetV2, waitForSiteResourceRebuildIdle
isIpInCidr,
portRangeStringSchema
} from "@server/lib/ip";
import {
getClientSiteResourceAccess,
rebuildClientAssociationsFromSiteResource
} from "@server/lib/rebuildClientAssociations"; } from "@server/lib/rebuildClientAssociations";
import logger from "@server/logger"; import logger from "@server/logger";
import HttpCode from "@server/types/HttpCode"; import HttpCode from "@server/types/HttpCode";
@@ -390,7 +379,7 @@ export async function updateSiteResource(
); );
} }
const existingSiteIds = existingSiteResource.networkId const existingSiteNetworks = existingSiteResource.networkId
? await db ? await db
.select() .select()
.from(siteNetworks) .from(siteNetworks)
@@ -398,7 +387,7 @@ export async function updateSiteResource(
eq(siteNetworks.networkId, existingSiteResource.networkId) eq(siteNetworks.networkId, existingSiteResource.networkId)
) )
: []; : [];
const existingSiteIdSet = new Set(existingSiteIds.map((s) => s.siteId)); const existingSiteIds = existingSiteNetworks.map((sn) => sn.siteId);
let fullDomain: string | null = null; let fullDomain: string | null = null;
let finalSubdomain: string | null = null; let finalSubdomain: string | null = null;
@@ -464,6 +453,7 @@ export async function updateSiteResource(
} }
let updatedSiteResource: SiteResource | undefined; let updatedSiteResource: SiteResource | undefined;
let updatedSiteIds: number[] = [];
await db.transaction(async (trx) => { await db.transaction(async (trx) => {
// Update the site resource // Update the site resource
const sshPamSet = const sshPamSet =
@@ -534,6 +524,7 @@ export async function updateSiteResource(
siteId: siteId, siteId: siteId,
networkId: updatedSiteResource.networkId! networkId: updatedSiteResource.networkId!
}); });
updatedSiteIds.push(siteId);
} }
await trx await trx
@@ -605,27 +596,27 @@ export async function updateSiteResource(
throw new Error("No updated resource found after update"); throw new Error("No updated resource found after update");
} }
rebuildClientAssociationsFromSiteResource(updatedSiteResource).catch( const finalUpdatedSiteResource = updatedSiteResource;
(e) => {
logger.error(
`Failed to rebuild client associations for site resource ${siteResourceId}. Error: ${e}`
);
}
);
handleMessagingForUpdatedSiteResource( rebuildClientAssociationsFromSiteResource(finalUpdatedSiteResource)
existingSiteResource, .then(() =>
updatedSiteResource, waitForSiteResourceRebuildIdle(
Array.from(existingSiteIdSet).map((siteId: number) => ({ finalUpdatedSiteResource.siteResourceId
// we already added to the new sites above in the rebuild function so we only need to update the ones that did not change )
siteId, )
orgId: existingSiteResource.orgId .then(() =>
})) handleMessagingForUpdatedSiteResource(
).catch((e) => { existingSiteResource,
logger.error( finalUpdatedSiteResource,
`Failed to handle messaging for updated site resource ${siteResourceId}. Error: ${e}` existingSiteIds,
); updatedSiteIds
}); )
)
.catch((e) => {
logger.error(
`Failed to rebuild and handle messaging for site resource ${siteResourceId}. Error: ${e}`
);
});
return response(res, { return response(res, {
data: updatedSiteResource, data: updatedSiteResource,
@@ -644,211 +635,3 @@ export async function updateSiteResource(
); );
} }
} }
export async function handleMessagingForUpdatedSiteResource(
existingSiteResource: SiteResource | undefined,
updatedSiteResource: SiteResource,
sites: { siteId: number; orgId: string }[]
) {
const trx = primaryDb;
logger.debug(
"handleMessagingForUpdatedSiteResource: existingSiteResource is: ",
existingSiteResource
);
logger.debug(
"handleMessagingForUpdatedSiteResource: updatedSiteResource is: ",
updatedSiteResource
);
const { sitesList, mergedAllClients, mergedAllClientIds } =
await getClientSiteResourceAccess(
existingSiteResource || updatedSiteResource,
trx
);
const siteIds = sites.map((site) => site.siteId);
// after everything is rebuilt above we still need to update the targets and remote subnets if the destination changed
const destinationChanged =
existingSiteResource &&
existingSiteResource.destination !== updatedSiteResource.destination;
const destinationPortChanged =
existingSiteResource &&
existingSiteResource.destinationPort !==
updatedSiteResource.destinationPort;
const aliasChanged =
existingSiteResource &&
existingSiteResource.alias !== updatedSiteResource.alias;
const fullDomainChanged =
existingSiteResource &&
existingSiteResource.fullDomain !== updatedSiteResource.fullDomain;
const sslChanged =
existingSiteResource &&
existingSiteResource.ssl !== updatedSiteResource.ssl;
const portRangesChanged =
existingSiteResource &&
(existingSiteResource.tcpPortRangeString !==
updatedSiteResource.tcpPortRangeString ||
existingSiteResource.udpPortRangeString !==
updatedSiteResource.udpPortRangeString ||
existingSiteResource.disableIcmp !==
updatedSiteResource.disableIcmp);
// if the existingSiteResource is undefined (new resource) we don't need to do anything here, the rebuild above handled it all
if (
destinationChanged ||
aliasChanged ||
fullDomainChanged ||
sslChanged ||
portRangesChanged ||
destinationPortChanged
) {
const newtsForSites =
siteIds.length > 0
? await trx
.select()
.from(newts)
.where(inArray(newts.siteId, siteIds))
: [];
const newtBySiteId = new Map(
newtsForSites.map((newt) => [newt.siteId, newt])
);
const oldDestinationStillInUseClientSitePairs = new Set<string>();
if (
existingSiteResource?.destination &&
siteIds.length > 0 &&
mergedAllClientIds.length > 0
) {
const oldDestinationStillInUseRows = await trx
.select({
clientId: clientSiteResourcesAssociationsCache.clientId,
siteId: siteNetworks.siteId
})
.from(siteResources)
.innerJoin(
clientSiteResourcesAssociationsCache,
eq(
clientSiteResourcesAssociationsCache.siteResourceId,
siteResources.siteResourceId
)
)
.innerJoin(
siteNetworks,
eq(siteNetworks.networkId, siteResources.networkId)
)
.where(
and(
inArray(
clientSiteResourcesAssociationsCache.clientId,
mergedAllClientIds
),
inArray(siteNetworks.siteId, siteIds),
eq(
siteResources.destination,
existingSiteResource.destination
),
ne(
siteResources.siteResourceId,
existingSiteResource.siteResourceId
)
)
);
for (const row of oldDestinationStillInUseRows) {
oldDestinationStillInUseClientSitePairs.add(
`${row.clientId}:${row.siteId}`
);
}
}
const shouldUpdateTargets =
destinationChanged ||
sslChanged ||
portRangesChanged ||
fullDomainChanged ||
destinationPortChanged;
const oldTargets = shouldUpdateTargets
? await generateSubnetProxyTargetV2(
existingSiteResource,
mergedAllClients
)
: [];
const newTargets = shouldUpdateTargets
? await generateSubnetProxyTargetV2(
updatedSiteResource,
mergedAllClients
)
: [];
for (const site of sites) {
const newt = newtBySiteId.get(site.siteId);
if (!newt) {
throw new Error(
"Newt not found for site during site resource update"
);
}
// Only update targets on newt if these items change
if (shouldUpdateTargets) {
await updateTargets(
newt.newtId,
{
oldTargets: oldTargets ? oldTargets : [],
newTargets: newTargets ? newTargets : []
},
newt.version
);
}
const olmJobs: Promise<void>[] = [];
for (const client of mergedAllClients) {
// does this client have access to another resource on this site that has the same destination still? if so we dont want to remove it from their olm yet
if (!existingSiteResource.destination) {
continue;
}
const oldDestinationStillInUseByASite =
oldDestinationStillInUseClientSitePairs.has(
`${client.clientId}:${site.siteId}`
);
// we also need to update the remote subnets on the olms for each client that has access to this site
olmJobs.push(
updatePeerData(
// TODO: THIS SHOULD BE UPDATED TO WORK I A BATCH
client.clientId,
site.siteId,
destinationChanged
? {
oldRemoteSubnets:
!oldDestinationStillInUseByASite
? generateRemoteSubnets([
existingSiteResource
])
: [],
newRemoteSubnets: generateRemoteSubnets([
updatedSiteResource
])
}
: undefined,
aliasChanged || fullDomainChanged // the full domain is sent down as an alias
? {
oldAliases: generateAliasConfig([
existingSiteResource
]),
newAliases: generateAliasConfig([
updatedSiteResource
])
}
: undefined
)
);
}
await Promise.all(olmJobs);
}
}
}
+9 -1
View File
@@ -19,7 +19,7 @@ export default function OrgInfoCard({}: OrgInfoCardProps) {
return ( return (
<Alert> <Alert>
<AlertDescription> <AlertDescription>
<InfoSections cols={3}> <InfoSections cols={4}>
<InfoSection> <InfoSection>
<InfoSectionTitle>{t("name")}</InfoSectionTitle> <InfoSectionTitle>{t("name")}</InfoSectionTitle>
<InfoSectionContent>{org.org.name}</InfoSectionContent> <InfoSectionContent>{org.org.name}</InfoSectionContent>
@@ -34,6 +34,14 @@ export default function OrgInfoCard({}: OrgInfoCardProps) {
{org.org.subnet || t("none")} {org.org.subnet || t("none")}
</InfoSectionContent> </InfoSectionContent>
</InfoSection> </InfoSection>
<InfoSection>
<InfoSectionTitle>
{t("utilitySubnet")}
</InfoSectionTitle>
<InfoSectionContent>
{org.org.utilitySubnet || t("none")}
</InfoSectionContent>
</InfoSection>
</InfoSections> </InfoSections>
</AlertDescription> </AlertDescription>
</Alert> </Alert>
+57 -26
View File
@@ -8,6 +8,7 @@ import {
type ProductUpdate, type ProductUpdate,
productUpdatesQueries productUpdatesQueries
} from "@app/lib/queries"; } from "@app/lib/queries";
import { build } from "@server/build";
import { useQueries } from "@tanstack/react-query"; import { useQueries } from "@tanstack/react-query";
import { import {
ArrowRight, ArrowRight,
@@ -39,22 +40,42 @@ export default function ProductUpdates({
}) { }) {
const { env } = useEnvContext(); const { env } = useEnvContext();
const productUpdatesEnabled = env.app.notifications.product_updates;
const versionCheckEnabled =
env.app.notifications.new_releases && build !== "saas";
const data = useQueries({ const data = useQueries({
queries: [ queries: [
productUpdatesQueries.list( productUpdatesQueries.list(productUpdatesEnabled, env.app.version),
env.app.notifications.product_updates,
env.app.version
),
productUpdatesQueries.latestVersion( productUpdatesQueries.latestVersion(
env.app.notifications.new_releases env.app.notifications.new_releases
) )
], ],
combine(result) { combine(result) {
if (result[0].isLoading || result[1].isLoading) return null; const [updatesQuery, versionQuery] = result;
return {
updates: result[0].data?.data ?? [], const updatesSettled =
latestVersion: result[1].data !productUpdatesEnabled ||
}; updatesQuery.isFetched ||
updatesQuery.isError;
const versionSettled =
!versionCheckEnabled ||
versionQuery.isFetched ||
versionQuery.isError;
if (!updatesSettled || !versionSettled) return null;
const updates = updatesQuery.isError
? []
: Array.isArray(updatesQuery.data?.data)
? updatesQuery.data.data
: [];
const latestVersion = versionQuery.isError
? undefined
: versionQuery.data;
return { updates, latestVersion };
} }
}); });
const t = useTranslations(); const t = useTranslations();
@@ -76,19 +97,30 @@ export default function ProductUpdates({
if (!data) return null; if (!data) return null;
const latestVersion = data?.latestVersion?.data?.pangolin.latestVersion; const versionResponse = data.latestVersion?.data;
const latestVersion = versionResponse?.pangolin?.latestVersion;
const currentVersion = env.app.version; const currentVersion = env.app.version;
const showNewVersionPopup = Boolean( let showNewVersionPopup = false;
if (
latestVersion && latestVersion &&
valid(latestVersion) && valid(latestVersion) &&
valid(currentVersion) && valid(currentVersion) &&
ignoredVersionUpdate !== latestVersion && ignoredVersionUpdate !== latestVersion
gt(latestVersion, currentVersion) ) {
); try {
showNewVersionPopup = gt(latestVersion, currentVersion);
} catch {
showNewVersionPopup = false;
}
}
const readUpdateIds = Array.isArray(productUpdatesRead)
? productUpdatesRead
: [];
const filteredUpdates = data.updates.filter( const filteredUpdates = data.updates.filter(
(update) => !productUpdatesRead.includes(update.id) (update) => !readUpdateIds.includes(update.id)
); );
if (filteredUpdates.length === 0 && !showNewVersionPopup) { if (filteredUpdates.length === 0 && !showNewVersionPopup) {
@@ -133,17 +165,14 @@ export default function ProductUpdates({
show={filteredUpdates.length > 0} show={filteredUpdates.length > 0}
onDimissAll={() => onDimissAll={() =>
setProductUpdatesRead([ setProductUpdatesRead([
...productUpdatesRead, ...readUpdateIds,
...filteredUpdates.map( ...filteredUpdates.map(
(update) => update.id (update) => update.id
) )
]) ])
} }
onDimiss={(id) => onDimiss={(id) =>
setProductUpdatesRead([ setProductUpdatesRead([...readUpdateIds, id])
...productUpdatesRead,
id
])
} }
/> />
</div> </div>
@@ -151,11 +180,9 @@ export default function ProductUpdates({
</div> </div>
<NewVersionAvailable <NewVersionAvailable
version={data.latestVersion?.data} version={versionResponse}
onDimiss={() => { onDimiss={() => {
setIgnoredVersionUpdate( setIgnoredVersionUpdate(latestVersion ?? null);
data.latestVersion?.data?.pangolin.latestVersion ?? null
);
}} }}
show={showNewVersionPopup} show={showNewVersionPopup}
/> />
@@ -346,6 +373,10 @@ function NewVersionAvailable({
} }
}, [show]); }, [show]);
if (!version?.pangolin?.latestVersion) {
return null;
}
return ( return (
<Transition show={open}> <Transition show={open}>
{version && ( {version && (
+5 -13
View File
@@ -61,7 +61,7 @@ export function parseUnixGroups(value: string | undefined): string[] {
if (!value?.trim()) return []; if (!value?.trim()) return [];
return value return value
.split(/[,\s\n]+/) .split(/\r?\n/)
.map((group) => group.trim()) .map((group) => group.trim())
.filter(Boolean); .filter(Boolean);
} }
@@ -69,18 +69,10 @@ export function parseUnixGroups(value: string | undefined): string[] {
export function parseSudoCommands(value: string | undefined): string[] { export function parseSudoCommands(value: string | undefined): string[] {
if (!value?.trim()) return []; if (!value?.trim()) return [];
const commands: string[] = []; return value
for (const segment of value.split(/[,\n]+/)) { .split(/\r?\n/)
const trimmed = segment.trim(); .map((command) => command.trim())
if (!trimmed) continue; .filter(Boolean);
for (const part of trimmed.split(/ (?=\/)/)) {
const command = part.trim();
if (command) commands.push(command);
}
}
return commands;
} }
function hasOnlyAbsoluteSudoCommands(value: string | undefined): boolean { function hasOnlyAbsoluteSudoCommands(value: string | undefined): boolean {
+6 -1
View File
@@ -9,6 +9,7 @@ import {
InfoSectionTitle InfoSectionTitle
} from "@app/components/InfoSection"; } from "@app/components/InfoSection";
import { useTranslations } from "next-intl"; import { useTranslations } from "next-intl";
import { countryCodeToFlagEmoji } from "@app/lib/countryCodeToFlagEmoji";
type SiteInfoCardProps = {}; type SiteInfoCardProps = {};
@@ -52,7 +53,11 @@ export default function SiteInfoCard({}: SiteInfoCardProps) {
<InfoSection> <InfoSection>
<InfoSectionTitle>{t("publicIpEndpoint")}</InfoSectionTitle> <InfoSectionTitle>{t("publicIpEndpoint")}</InfoSectionTitle>
<InfoSectionContent> <InfoSectionContent>
{formatPublicEndpoint(site.endpoint)} {formatPublicEndpoint(site.endpoint)}&nbsp;
<span className="text-lg">
{site.countryCode &&
countryCodeToFlagEmoji(site.countryCode)}
</span>
</InfoSectionContent> </InfoSectionContent>
</InfoSection> </InfoSection>
) : null; ) : null;
+48 -8
View File
@@ -19,6 +19,7 @@ import {
DropdownMenu, DropdownMenu,
DropdownMenuContent, DropdownMenuContent,
DropdownMenuItem, DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger DropdownMenuTrigger
} from "@app/components/ui/dropdown-menu"; } from "@app/components/ui/dropdown-menu";
import { InfoPopup } from "@app/components/ui/info-popup"; import { InfoPopup } from "@app/components/ui/info-popup";
@@ -104,6 +105,7 @@ export default function SitesTable({
} = useNavigationContext(); } = useNavigationContext();
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false); const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
const [deleteWithResources, setDeleteWithResources] = useState(false);
const [selectedSite, setSelectedSite] = useState<SiteRow | null>(null); const [selectedSite, setSelectedSite] = useState<SiteRow | null>(null);
const [resourcesDialogSite, setResourcesDialogSite] = const [resourcesDialogSite, setResourcesDialogSite] =
useState<SiteRow | null>(null); useState<SiteRow | null>(null);
@@ -157,10 +159,12 @@ export default function SitesTable({
}); });
} }
function deleteSite(siteId: number) { function deleteSite(siteId: number, withResources: boolean) {
startTransition(async () => { startTransition(async () => {
await api await api
.delete(`/site/${siteId}`) .delete(`/site/${siteId}`, {
params: { deleteResources: withResources }
})
.catch((e) => { .catch((e) => {
console.error(t("siteErrorDelete"), e); console.error(t("siteErrorDelete"), e);
toast({ toast({
@@ -521,16 +525,33 @@ export default function SitesTable({
)} )}
</DropdownMenuItem> </DropdownMenuItem>
</Link> </Link>
<DropdownMenuSeparator />
<DropdownMenuItem <DropdownMenuItem
onClick={() => { onClick={() => {
setSelectedSite(siteRow); setSelectedSite(siteRow);
setDeleteWithResources(false);
setIsDeleteModalOpen(true); setIsDeleteModalOpen(true);
}} }}
> >
<span className="text-red-500"> <span className="text-red-500">
{t("delete")} {t("sitesTableDeleteSite")}
</span> </span>
</DropdownMenuItem> </DropdownMenuItem>
{siteRow.resourceCount <= 250 && (
<DropdownMenuItem
onClick={() => {
setSelectedSite(siteRow);
setDeleteWithResources(true);
setIsDeleteModalOpen(true);
}}
>
<span className="text-red-500">
{t(
"sitesTableDeleteSiteAndResources"
)}
</span>
</DropdownMenuItem>
)}
</DropdownMenuContent> </DropdownMenuContent>
</DropdownMenu> </DropdownMenu>
<Link <Link
@@ -639,19 +660,38 @@ export default function SitesTable({
setOpen={(val) => { setOpen={(val) => {
setIsDeleteModalOpen(val); setIsDeleteModalOpen(val);
setSelectedSite(null); setSelectedSite(null);
setDeleteWithResources(false);
}} }}
dialog={ dialog={
<div className="space-y-2"> <div className="space-y-2">
<p>{t("siteQuestionRemove")}</p> <p>
<p>{t("siteMessageRemove")}</p> {deleteWithResources
? t("siteQuestionRemoveAndResources")
: t("siteQuestionRemove")}
</p>
<p>
{deleteWithResources
? t("siteMessageRemoveAndResources")
: t("siteMessageRemove")}
</p>
</div> </div>
} }
buttonText={t("siteConfirmDelete")} buttonText={
deleteWithResources
? t("siteConfirmDeleteAndResources")
: t("siteConfirmDelete")
}
onConfirm={async () => onConfirm={async () =>
startTransition(() => deleteSite(selectedSite!.id)) startTransition(() =>
deleteSite(selectedSite!.id, deleteWithResources)
)
} }
string={selectedSite.name} string={selectedSite.name}
title={t("siteDelete")} title={
deleteWithResources
? t("siteDeleteAndResources")
: t("siteDelete")
}
/> />
)} )}
@@ -74,6 +74,7 @@ import {
sortPolicyRulesForResourceOverlay, sortPolicyRulesForResourceOverlay,
type PolicyAccessRule type PolicyAccessRule
} from "./policy-access-rule-utils"; } from "./policy-access-rule-utils";
import { countryCodeToFlagEmoji } from "@app/lib/countryCodeToFlagEmoji";
export type PolicyAccessRulesTableProps = { export type PolicyAccessRulesTableProps = {
rules: PolicyAccessRule[]; rules: PolicyAccessRule[];
@@ -490,8 +491,17 @@ export function PolicyAccessRulesTable({
{ {
accessorKey: "value", accessorKey: "value",
header: () => <span className="p-3">{t("value")}</span>, header: () => <span className="p-3">{t("value")}</span>,
cell: ({ row }) => cell: ({ row }) => {
row.original.match === "COUNTRY" ? ( let selectedCountry: (typeof COUNTRIES)[number] | undefined;
if (
row.original.match === "COUNTRY" &&
row.original.value
) {
selectedCountry = COUNTRIES.find(
(c) => c.code === row.original.value
);
}
return row.original.match === "COUNTRY" ? (
<Popover> <Popover>
<PopoverTrigger asChild> <PopoverTrigger asChild>
<Button <Button
@@ -502,15 +512,22 @@ export function PolicyAccessRulesTable({
} }
className="w-full min-w-0 justify-between" className="w-full min-w-0 justify-between"
> >
{row.original.value {selectedCountry ? (
? COUNTRIES.find( <>
(c) => <span>
c.code === row.original.value {selectedCountry.code === "ALL"
)?.name + ? "🌍"
" (" + : countryCodeToFlagEmoji(
row.original.value + selectedCountry.code
")" )}
: t("selectCountry")} &nbsp;&nbsp;
{selectedCountry.name} (
{selectedCountry.code})
</span>
</>
) : (
t("selectCountry")
)}
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" /> <ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button> </Button>
</PopoverTrigger> </PopoverTrigger>
@@ -540,6 +557,13 @@ export function PolicyAccessRulesTable({
<Check <Check
className={`mr-2 h-4 w-4 ${row.original.value === country.code ? "opacity-100" : "opacity-0"}`} className={`mr-2 h-4 w-4 ${row.original.value === country.code ? "opacity-100" : "opacity-0"}`}
/> />
<span>
{country.code === "ALL"
? "🌍"
: countryCodeToFlagEmoji(
country.code
)}
</span>
{country.name} ( {country.name} (
{country.code}) {country.code})
</CommandItem> </CommandItem>
@@ -767,7 +791,8 @@ export function PolicyAccessRulesTable({
}); });
}} }}
/> />
) );
}
}, },
{ {
accessorKey: "enabled", accessorKey: "enabled",