mirror of
https://github.com/fosrl/pangolin.git
synced 2026-08-16 01:09:40 +02:00
Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| abfab56082 |
Vendored
+1
-4
@@ -18,8 +18,5 @@
|
|||||||
"[json]": {
|
"[json]": {
|
||||||
"editor.defaultFormatter": "esbenp.prettier-vscode"
|
"editor.defaultFormatter": "esbenp.prettier-vscode"
|
||||||
},
|
},
|
||||||
"editor.formatOnSave": true,
|
"editor.formatOnSave": true
|
||||||
"cSpell.words": [
|
|
||||||
"nessicary"
|
|
||||||
]
|
|
||||||
}
|
}
|
||||||
+1
-1
@@ -1,4 +1,4 @@
|
|||||||
FROM node:24-alpine
|
FROM node:26-alpine
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
|
|||||||
@@ -66,15 +66,9 @@
|
|||||||
"local": "Локална",
|
"local": "Локална",
|
||||||
"edit": "Редактиране",
|
"edit": "Редактиране",
|
||||||
"siteConfirmDelete": "Потвърждение на изтриване на сайта",
|
"siteConfirmDelete": "Потвърждение на изтриване на сайта",
|
||||||
"siteConfirmDeleteAndResources": "Потвърдете изтриването на сайта и ресурсите",
|
|
||||||
"siteDelete": "Изтриване на сайта",
|
"siteDelete": "Изтриване на сайта",
|
||||||
"siteDeleteAndResources": "Изтриване на сайта и ресурсите",
|
|
||||||
"siteMessageRemove": "След премахване, сайтът вече няма да бъде достъпен. Всички цели, свързани със сайта, също ще бъдат премахнати.",
|
"siteMessageRemove": "След премахване, сайтът вече няма да бъде достъпен. Всички цели, свързани със сайта, също ще бъдат премахнати.",
|
||||||
"siteMessageRemoveAndResources": "Това ще изтрие окончателно всички публични и частни ресурси, свързани с този сайт, дори ако ресурсът е асоцииран и с други сайтове.",
|
|
||||||
"siteQuestionRemove": "Сигурни ли сте, че искате да премахнете сайта от организацията?",
|
"siteQuestionRemove": "Сигурни ли сте, че искате да премахнете сайта от организацията?",
|
||||||
"siteQuestionRemoveAndResources": "Наистина ли желаете да изтриете този сайт и всички свързани ресурси?",
|
|
||||||
"sitesTableDeleteSite": "Изтриване на сайта",
|
|
||||||
"sitesTableDeleteSiteAndResources": "Изтриване на сайта и ресурсите",
|
|
||||||
"siteManageSites": "Управление на сайтове",
|
"siteManageSites": "Управление на сайтове",
|
||||||
"siteDescription": "Създайте и управлявайте сайтове, за да осигурите свързаност със частни мрежи",
|
"siteDescription": "Създайте и управлявайте сайтове, за да осигурите свързаност със частни мрежи",
|
||||||
"sitesBannerTitle": "Свържете се с мрежа.",
|
"sitesBannerTitle": "Свържете се с мрежа.",
|
||||||
@@ -2344,7 +2338,6 @@
|
|||||||
"createInternalResourceDialogDestinationCidrDescription": "CIDR диапазонът на ресурса в мрежата на сайта.",
|
"createInternalResourceDialogDestinationCidrDescription": "CIDR диапазонът на ресурса в мрежата на сайта.",
|
||||||
"createInternalResourceDialogAlias": "Псевдоним",
|
"createInternalResourceDialogAlias": "Псевдоним",
|
||||||
"createInternalResourceDialogAliasDescription": "По избор вътрешен DNS псевдоним за този ресурс.",
|
"createInternalResourceDialogAliasDescription": "По избор вътрешен DNS псевдоним за този ресурс.",
|
||||||
"internalResourceAliasLocalWarning": "Синоними с окончание .local могат да причинят проблеми с резолюцията поради mDNS в някои мрежи.",
|
|
||||||
"internalResourceDownstreamSchemeRequired": "Методът е задължителен за HTTP ресурси",
|
"internalResourceDownstreamSchemeRequired": "Методът е задължителен за HTTP ресурси",
|
||||||
"internalResourceHttpPortRequired": "Портът към целта е задължителен за HTTP ресурси",
|
"internalResourceHttpPortRequired": "Портът към целта е задължителен за HTTP ресурси",
|
||||||
"siteConfiguration": "Конфигурация",
|
"siteConfiguration": "Конфигурация",
|
||||||
@@ -2974,7 +2967,6 @@
|
|||||||
"orgOrDomainIdMissing": "Липсва идентификатор на организация или домейн",
|
"orgOrDomainIdMissing": "Липсва идентификатор на организация или домейн",
|
||||||
"loadingDNSRecords": "Зареждане на DNS записи...",
|
"loadingDNSRecords": "Зареждане на DNS записи...",
|
||||||
"olmUpdateAvailableInfo": "Налична е актуализирана версия на Olm. Моля, актуализирайте до най-новата версия за най-добро преживяване.",
|
"olmUpdateAvailableInfo": "Налична е актуализирана версия на Olm. Моля, актуализирайте до най-новата версия за най-добро преживяване.",
|
||||||
"updateAvailableInfo": "На разположение е обновена версия. Моля, обновете до най-новата версия за най-добър опит.",
|
|
||||||
"client": "Клиент",
|
"client": "Клиент",
|
||||||
"proxyProtocol": "Настройки на прокси протокол",
|
"proxyProtocol": "Настройки на прокси протокол",
|
||||||
"proxyProtocolDescription": "Конфигурирайте Proxy Protocol, за да запазите IP адресите на клиентите за TCP услуги.",
|
"proxyProtocolDescription": "Конфигурирайте Proxy Protocol, за да запазите IP адресите на клиентите за TCP услуги.",
|
||||||
|
|||||||
@@ -66,15 +66,9 @@
|
|||||||
"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íť",
|
||||||
@@ -2344,7 +2338,6 @@
|
|||||||
"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",
|
||||||
@@ -2974,7 +2967,6 @@
|
|||||||
"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": "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
File diff suppressed because it is too large
Load Diff
@@ -66,15 +66,9 @@
|
|||||||
"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",
|
||||||
@@ -2344,7 +2338,6 @@
|
|||||||
"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",
|
||||||
@@ -2974,7 +2967,6 @@
|
|||||||
"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": "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.",
|
||||||
|
|||||||
+5
-14
@@ -66,15 +66,9 @@
|
|||||||
"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",
|
||||||
"siteDeleteAndResources": "Delete Site and Resources",
|
"siteMessageRemove": "Once removed the site will no longer be accessible. All targets associated with the site will also be removed.",
|
||||||
"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",
|
||||||
@@ -210,7 +204,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 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.",
|
"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.",
|
||||||
"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",
|
||||||
@@ -1644,7 +1638,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)",
|
||||||
@@ -2177,10 +2171,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, one per line. Absolute paths must be used.",
|
"sshSudoCommandsDescription": "List of commands the user is allowed to run with sudo, separated by commas, spaces, or new lines. 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, one per line.",
|
"sshUnixGroupsDescription": "Unix groups to add the user to on the target host, separated by commas, spaces, or new lines.",
|
||||||
"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}.",
|
||||||
@@ -2344,7 +2338,6 @@
|
|||||||
"createInternalResourceDialogDestinationCidrDescription": "The CIDR range of the resource on the site's network.",
|
"createInternalResourceDialogDestinationCidrDescription": "The CIDR range of the resource on the site's network.",
|
||||||
"createInternalResourceDialogAlias": "Alias",
|
"createInternalResourceDialogAlias": "Alias",
|
||||||
"createInternalResourceDialogAliasDescription": "An optional internal DNS alias for this resource.",
|
"createInternalResourceDialogAliasDescription": "An optional internal DNS alias for this resource.",
|
||||||
"internalResourceAliasLocalWarning": "Aliases ending in .local can cause resolution issues due to mDNS on some networks.",
|
|
||||||
"internalResourceDownstreamSchemeRequired": "Scheme is required for HTTP resources",
|
"internalResourceDownstreamSchemeRequired": "Scheme is required for HTTP resources",
|
||||||
"internalResourceHttpPortRequired": "Destination port is required for HTTP resources",
|
"internalResourceHttpPortRequired": "Destination port is required for HTTP resources",
|
||||||
"siteConfiguration": "Configuration",
|
"siteConfiguration": "Configuration",
|
||||||
@@ -2556,7 +2549,6 @@
|
|||||||
"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",
|
||||||
@@ -2975,7 +2967,6 @@
|
|||||||
"orgOrDomainIdMissing": "Organization or Domain ID is missing",
|
"orgOrDomainIdMissing": "Organization or Domain ID is missing",
|
||||||
"loadingDNSRecords": "Loading DNS records...",
|
"loadingDNSRecords": "Loading DNS records...",
|
||||||
"olmUpdateAvailableInfo": "An updated version of Olm is available. Please update to the latest version for the best experience.",
|
"olmUpdateAvailableInfo": "An updated version of Olm is available. Please update to the latest version for the best experience.",
|
||||||
"updateAvailableInfo": "An updated version is available. Please update to the latest version for the best experience.",
|
|
||||||
"client": "Client",
|
"client": "Client",
|
||||||
"proxyProtocol": "Proxy Protocol Settings",
|
"proxyProtocol": "Proxy Protocol Settings",
|
||||||
"proxyProtocolDescription": "Configure Proxy Protocol to preserve client IP addresses for TCP services.",
|
"proxyProtocolDescription": "Configure Proxy Protocol to preserve client IP addresses for TCP services.",
|
||||||
|
|||||||
@@ -66,15 +66,9 @@
|
|||||||
"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",
|
||||||
@@ -2344,7 +2338,6 @@
|
|||||||
"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",
|
||||||
@@ -2974,7 +2967,6 @@
|
|||||||
"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": "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.",
|
||||||
|
|||||||
@@ -66,15 +66,9 @@
|
|||||||
"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",
|
||||||
@@ -2344,7 +2338,6 @@
|
|||||||
"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",
|
||||||
@@ -2974,7 +2967,6 @@
|
|||||||
"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": "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.",
|
||||||
|
|||||||
@@ -66,15 +66,9 @@
|
|||||||
"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",
|
||||||
@@ -2344,7 +2338,6 @@
|
|||||||
"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",
|
||||||
@@ -2974,7 +2967,6 @@
|
|||||||
"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": "È 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.",
|
||||||
|
|||||||
@@ -66,15 +66,9 @@
|
|||||||
"local": "로컬",
|
"local": "로컬",
|
||||||
"edit": "편집",
|
"edit": "편집",
|
||||||
"siteConfirmDelete": "사이트 삭제 확인",
|
"siteConfirmDelete": "사이트 삭제 확인",
|
||||||
"siteConfirmDeleteAndResources": "사이트 및 리소스 삭제 확인",
|
|
||||||
"siteDelete": "사이트 삭제",
|
"siteDelete": "사이트 삭제",
|
||||||
"siteDeleteAndResources": "사이트 및 리소스 삭제",
|
|
||||||
"siteMessageRemove": "삭제되면 사이트에 더 이상 액세스할 수 없습니다. 사이트와 연결된 모든 대상도 삭제됩니다.",
|
"siteMessageRemove": "삭제되면 사이트에 더 이상 액세스할 수 없습니다. 사이트와 연결된 모든 대상도 삭제됩니다.",
|
||||||
"siteMessageRemoveAndResources": "이 사이트와 연결된 모든 공용 및 개인 리소스는 다른 사이트에도 연결되어 있더라도 영구적으로 삭제됩니다.",
|
|
||||||
"siteQuestionRemove": "조직에서 사이트를 제거하시겠습니까?",
|
"siteQuestionRemove": "조직에서 사이트를 제거하시겠습니까?",
|
||||||
"siteQuestionRemoveAndResources": "이 사이트와 모든 관련 리소스를 삭제하시겠습니까?",
|
|
||||||
"sitesTableDeleteSite": "사이트 삭제",
|
|
||||||
"sitesTableDeleteSiteAndResources": "사이트 및 리소스 삭제",
|
|
||||||
"siteManageSites": "사이트 관리",
|
"siteManageSites": "사이트 관리",
|
||||||
"siteDescription": "프라이빗 네트워크로의 연결을 활성화하려면 사이트를 생성하고 관리하세요.",
|
"siteDescription": "프라이빗 네트워크로의 연결을 활성화하려면 사이트를 생성하고 관리하세요.",
|
||||||
"sitesBannerTitle": "모든 네트워크 연결",
|
"sitesBannerTitle": "모든 네트워크 연결",
|
||||||
@@ -2344,7 +2338,6 @@
|
|||||||
"createInternalResourceDialogDestinationCidrDescription": "사이트 네트워크의 자원 IP 주소입니다.",
|
"createInternalResourceDialogDestinationCidrDescription": "사이트 네트워크의 자원 IP 주소입니다.",
|
||||||
"createInternalResourceDialogAlias": "별칭",
|
"createInternalResourceDialogAlias": "별칭",
|
||||||
"createInternalResourceDialogAliasDescription": "이 리소스에 대한 선택적 내부 DNS 별칭입니다.",
|
"createInternalResourceDialogAliasDescription": "이 리소스에 대한 선택적 내부 DNS 별칭입니다.",
|
||||||
"internalResourceAliasLocalWarning": ".local로 끝나는 별칭은 일부 네트워크에서 mDNS로 인해 해결 문제가 발생할 수 있습니다.",
|
|
||||||
"internalResourceDownstreamSchemeRequired": "HTTP 리소스에 스킴이 필요합니다",
|
"internalResourceDownstreamSchemeRequired": "HTTP 리소스에 스킴이 필요합니다",
|
||||||
"internalResourceHttpPortRequired": "HTTP 리소스에 목적지 포트가 필요합니다",
|
"internalResourceHttpPortRequired": "HTTP 리소스에 목적지 포트가 필요합니다",
|
||||||
"siteConfiguration": "설정",
|
"siteConfiguration": "설정",
|
||||||
@@ -2974,7 +2967,6 @@
|
|||||||
"orgOrDomainIdMissing": "조직 ID 또는 도메인 ID가 누락되었습니다",
|
"orgOrDomainIdMissing": "조직 ID 또는 도메인 ID가 누락되었습니다",
|
||||||
"loadingDNSRecords": "DNS 레코드를 로드하는 중...",
|
"loadingDNSRecords": "DNS 레코드를 로드하는 중...",
|
||||||
"olmUpdateAvailableInfo": "올름의 새 버전이 이용 가능합니다. 최상의 경험을 위해 최신 버전으로 업데이트하세요.",
|
"olmUpdateAvailableInfo": "올름의 새 버전이 이용 가능합니다. 최상의 경험을 위해 최신 버전으로 업데이트하세요.",
|
||||||
"updateAvailableInfo": "업데이트된 버전이 있습니다. 최상의 경험을 위해 최신 버전으로 업데이트하세요.",
|
|
||||||
"client": "클라이언트",
|
"client": "클라이언트",
|
||||||
"proxyProtocol": "프록시 프로토콜 설정",
|
"proxyProtocol": "프록시 프로토콜 설정",
|
||||||
"proxyProtocolDescription": "TCP 서비스에 대한 클라이언트 IP 주소를 유지하도록 프록시 프로토콜을 구성하세요.",
|
"proxyProtocolDescription": "TCP 서비스에 대한 클라이언트 IP 주소를 유지하도록 프록시 프로토콜을 구성하세요.",
|
||||||
|
|||||||
@@ -66,15 +66,9 @@
|
|||||||
"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",
|
||||||
@@ -2344,7 +2338,6 @@
|
|||||||
"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",
|
||||||
@@ -2974,7 +2967,6 @@
|
|||||||
"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": "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.",
|
||||||
|
|||||||
@@ -66,15 +66,9 @@
|
|||||||
"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",
|
||||||
@@ -2344,7 +2338,6 @@
|
|||||||
"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",
|
||||||
@@ -2974,7 +2967,6 @@
|
|||||||
"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": "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.",
|
||||||
|
|||||||
@@ -66,15 +66,9 @@
|
|||||||
"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ć",
|
||||||
@@ -2344,7 +2338,6 @@
|
|||||||
"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",
|
||||||
@@ -2974,7 +2967,6 @@
|
|||||||
"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": "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.",
|
||||||
|
|||||||
@@ -66,15 +66,9 @@
|
|||||||
"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",
|
||||||
@@ -2344,7 +2338,6 @@
|
|||||||
"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",
|
||||||
@@ -2974,7 +2967,6 @@
|
|||||||
"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": "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.",
|
||||||
|
|||||||
@@ -66,15 +66,9 @@
|
|||||||
"local": "Локальный",
|
"local": "Локальный",
|
||||||
"edit": "Редактировать",
|
"edit": "Редактировать",
|
||||||
"siteConfirmDelete": "Подтвердить удаление сайта",
|
"siteConfirmDelete": "Подтвердить удаление сайта",
|
||||||
"siteConfirmDeleteAndResources": "Подтвердите удаление сайта и ресурсов",
|
|
||||||
"siteDelete": "Удалить сайт",
|
"siteDelete": "Удалить сайт",
|
||||||
"siteDeleteAndResources": "Удалить сайт и ресурсы",
|
|
||||||
"siteMessageRemove": "После удаления сайт больше не будет доступен. Все цели, связанные с сайтом, также будут удалены.",
|
"siteMessageRemove": "После удаления сайт больше не будет доступен. Все цели, связанные с сайтом, также будут удалены.",
|
||||||
"siteMessageRemoveAndResources": "Это навсегда удалит все общественные и частные ресурсы, связанные с этим сайтом, даже если ресурс также связан с другими сайтами.",
|
|
||||||
"siteQuestionRemove": "Вы уверены, что хотите удалить сайт из организации?",
|
"siteQuestionRemove": "Вы уверены, что хотите удалить сайт из организации?",
|
||||||
"siteQuestionRemoveAndResources": "Вы уверены, что хотите удалить этот сайт и все связанные с ним ресурсы?",
|
|
||||||
"sitesTableDeleteSite": "Удалить сайт",
|
|
||||||
"sitesTableDeleteSiteAndResources": "Удалить сайт и ресурсы",
|
|
||||||
"siteManageSites": "Управление сайтами",
|
"siteManageSites": "Управление сайтами",
|
||||||
"siteDescription": "Создание и управление сайтами, чтобы включить подключение к приватным сетям",
|
"siteDescription": "Создание и управление сайтами, чтобы включить подключение к приватным сетям",
|
||||||
"sitesBannerTitle": "Подключить любую сеть",
|
"sitesBannerTitle": "Подключить любую сеть",
|
||||||
@@ -2344,7 +2338,6 @@
|
|||||||
"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": "Конфигурация",
|
||||||
@@ -2974,7 +2967,6 @@
|
|||||||
"orgOrDomainIdMissing": "Отсутствует организация или ID домена",
|
"orgOrDomainIdMissing": "Отсутствует организация или ID домена",
|
||||||
"loadingDNSRecords": "Загрузка записей DNS...",
|
"loadingDNSRecords": "Загрузка записей DNS...",
|
||||||
"olmUpdateAvailableInfo": "Доступна обновленная версия Олма. Пожалуйста, обновитесь до последней версии.",
|
"olmUpdateAvailableInfo": "Доступна обновленная версия Олма. Пожалуйста, обновитесь до последней версии.",
|
||||||
"updateAvailableInfo": "Доступна обновленная версия. Пожалуйста, обновитесь до последней версии для получения лучшего опыта.",
|
|
||||||
"client": "Клиент",
|
"client": "Клиент",
|
||||||
"proxyProtocol": "Настройки протокола прокси",
|
"proxyProtocol": "Настройки протокола прокси",
|
||||||
"proxyProtocolDescription": "Настроить Прокси-протокол для сохранения IP-адресов клиента для служб TCP.",
|
"proxyProtocolDescription": "Настроить Прокси-протокол для сохранения IP-адресов клиента для служб TCP.",
|
||||||
|
|||||||
@@ -66,15 +66,9 @@
|
|||||||
"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",
|
||||||
@@ -2344,7 +2338,6 @@
|
|||||||
"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",
|
||||||
@@ -2974,7 +2967,6 @@
|
|||||||
"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": "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.",
|
||||||
|
|||||||
+59
-67
@@ -17,7 +17,7 @@
|
|||||||
"componentsErrorNoMemberCreate": "您目前不是任何组织的成员。创建组织以开始操作。",
|
"componentsErrorNoMemberCreate": "您目前不是任何组织的成员。创建组织以开始操作。",
|
||||||
"componentsErrorNoMember": "您目前不是任何组织的成员。",
|
"componentsErrorNoMember": "您目前不是任何组织的成员。",
|
||||||
"welcome": "欢迎使用 Pangolin",
|
"welcome": "欢迎使用 Pangolin",
|
||||||
"welcomeTo": "欢迎使用",
|
"welcomeTo": "欢迎来到",
|
||||||
"componentsCreateOrg": "创建组织",
|
"componentsCreateOrg": "创建组织",
|
||||||
"componentsMember": "您属于{count, plural, =0 {没有组织} one {一个组织} other {# 个组织}}。",
|
"componentsMember": "您属于{count, plural, =0 {没有组织} one {一个组织} other {# 个组织}}。",
|
||||||
"componentsInvalidKey": "检测到无效或过期的许可证密钥。按照许可证条款操作以继续使用所有功能。",
|
"componentsInvalidKey": "检测到无效或过期的许可证密钥。按照许可证条款操作以继续使用所有功能。",
|
||||||
@@ -35,7 +35,7 @@
|
|||||||
"trialDaysRemaining": "{count, plural, other {# 天剩余}}",
|
"trialDaysRemaining": "{count, plural, other {# 天剩余}}",
|
||||||
"trialDaysLeftShort": "试用期剩余 {days} 天",
|
"trialDaysLeftShort": "试用期剩余 {days} 天",
|
||||||
"trialGoToBilling": "转到账单页面",
|
"trialGoToBilling": "转到账单页面",
|
||||||
"subscriptionViolationViewBilling": "查看账单",
|
"subscriptionViolationViewBilling": "查看计费",
|
||||||
"componentsLicenseViolation": "许可证超限:该服务器使用了 {usedSites} 个站点,已超过授权的 {maxSites} 个。请遵守许可证条款以继续使用全部功能。",
|
"componentsLicenseViolation": "许可证超限:该服务器使用了 {usedSites} 个站点,已超过授权的 {maxSites} 个。请遵守许可证条款以继续使用全部功能。",
|
||||||
"componentsSupporterMessage": "感谢您的支持!您现在是 Pangolin 的 {tier} 用户。",
|
"componentsSupporterMessage": "感谢您的支持!您现在是 Pangolin 的 {tier} 用户。",
|
||||||
"inviteErrorNotValid": "很抱歉,但看起来你试图访问的邀请尚未被接受或不再有效。",
|
"inviteErrorNotValid": "很抱歉,但看起来你试图访问的邀请尚未被接受或不再有效。",
|
||||||
@@ -58,27 +58,21 @@
|
|||||||
"name": "名称",
|
"name": "名称",
|
||||||
"online": "在线",
|
"online": "在线",
|
||||||
"offline": "离线的",
|
"offline": "离线的",
|
||||||
"site": "节点",
|
"site": "站点",
|
||||||
"dataIn": "数据输入",
|
"dataIn": "数据输入",
|
||||||
"dataOut": "数据输出",
|
"dataOut": "数据输出",
|
||||||
"connectionType": "连接类型",
|
"connectionType": "连接类型",
|
||||||
"tunnelType": "隧道类型",
|
"tunnelType": "隧道类型",
|
||||||
"local": "本地的",
|
"local": "本地的",
|
||||||
"edit": "编辑",
|
"edit": "编辑",
|
||||||
"siteConfirmDelete": "确认删除节点",
|
"siteConfirmDelete": "确认删除站点",
|
||||||
"siteConfirmDeleteAndResources": "确认删除站点及资源",
|
"siteDelete": "删除站点",
|
||||||
"siteDelete": "删除节点",
|
"siteMessageRemove": "一旦移除,站点将无法访问。与站点相关的所有目标也将被移除。",
|
||||||
"siteDeleteAndResources": "删除站点及资源",
|
"siteQuestionRemove": "您确定要从组织中删除该站点吗?",
|
||||||
"siteMessageRemove": "一旦移除,节点将无法访问。与节点相关的所有目标也将被移除。",
|
|
||||||
"siteMessageRemoveAndResources": "这将永久删除与该站点关联的所有公共和私人资源,即使资源也与其他站点相关联。",
|
|
||||||
"siteQuestionRemove": "您确定要从组织中删除该节点吗?",
|
|
||||||
"siteQuestionRemoveAndResources": "您确定要删除此站点及所有关联资源吗?",
|
|
||||||
"sitesTableDeleteSite": "删除站点",
|
|
||||||
"sitesTableDeleteSiteAndResources": "删除站点及资源",
|
|
||||||
"siteManageSites": "管理站点",
|
"siteManageSites": "管理站点",
|
||||||
"siteDescription": "创建和管理站点,启用与私人网络的连接",
|
"siteDescription": "创建和管理站点,启用与私人网络的连接",
|
||||||
"sitesBannerTitle": "连接任何网络",
|
"sitesBannerTitle": "连接任何网络",
|
||||||
"sitesBannerDescription": "站点是到远程网络的连接,使 Pangolin 能够向任何位置的用户提提供公共或私有的资源访问。你可以在任何能够运行二进制文件或容器的地方安装站点网络连接器(Newt),以建立连接。",
|
"sitesBannerDescription": "站点是连接到远程网络的链接,允许Pangolin为用户提供资源访问,无论是公共还是私人。可以在任何可以运行二进制文件或容器的地方安装站点网络连接器(Newt)以建立连接。",
|
||||||
"sitesBannerButtonText": "安装站点",
|
"sitesBannerButtonText": "安装站点",
|
||||||
"approvalsBannerTitle": "批准或拒绝设备访问",
|
"approvalsBannerTitle": "批准或拒绝设备访问",
|
||||||
"approvalsBannerDescription": "审核、批准或拒绝用户的设备访问请求。 当需要设备批准时,用户必须先获得管理员批准,然后他们的设备才能连接到您的组织资源。",
|
"approvalsBannerDescription": "审核、批准或拒绝用户的设备访问请求。 当需要设备批准时,用户必须先获得管理员批准,然后他们的设备才能连接到您的组织资源。",
|
||||||
@@ -140,7 +134,7 @@
|
|||||||
"siteResourcesHowToAccess": "如何访问",
|
"siteResourcesHowToAccess": "如何访问",
|
||||||
"siteResourcesTargetsOnSite": "此站点上的目标",
|
"siteResourcesTargetsOnSite": "此站点上的目标",
|
||||||
"siteSetting": "{siteName} 设置",
|
"siteSetting": "{siteName} 设置",
|
||||||
"siteNewtTunnel": "新节点 (推荐)",
|
"siteNewtTunnel": "新站点 (推荐)",
|
||||||
"siteNewtTunnelDescription": "最简单的方式来创建任何网络的入口。没有额外的设置。",
|
"siteNewtTunnelDescription": "最简单的方式来创建任何网络的入口。没有额外的设置。",
|
||||||
"siteWg": "基本 WireGuard",
|
"siteWg": "基本 WireGuard",
|
||||||
"siteWgDescription": "使用任何 WireGuard 客户端来建立隧道。需要手动配置 NAT。",
|
"siteWgDescription": "使用任何 WireGuard 客户端来建立隧道。需要手动配置 NAT。",
|
||||||
@@ -149,23 +143,23 @@
|
|||||||
"siteLocalDescriptionSaas": "仅本地资源。没有隧道。仅在远程节点上可用。",
|
"siteLocalDescriptionSaas": "仅本地资源。没有隧道。仅在远程节点上可用。",
|
||||||
"siteSeeAll": "查看所有站点",
|
"siteSeeAll": "查看所有站点",
|
||||||
"siteTunnelDescription": "确定如何连接到站点",
|
"siteTunnelDescription": "确定如何连接到站点",
|
||||||
"siteNewtCredentials": "凭证",
|
"siteNewtCredentials": "全权证书",
|
||||||
"siteNewtCredentialsDescription": "节点如何与服务器进行身份验证",
|
"siteNewtCredentialsDescription": "站点如何通过服务器进行身份验证",
|
||||||
"remoteNodeCredentialsDescription": "这是远程节点如何与服务器进行身份验证",
|
"remoteNodeCredentialsDescription": "这是远程节点如何与服务器进行身份验证",
|
||||||
"siteCredentialsSave": "保存证书",
|
"siteCredentialsSave": "保存证书",
|
||||||
"siteCredentialsSaveDescription": "您只能看到一次。请确保将其复制并保存到一个安全的地方。",
|
"siteCredentialsSaveDescription": "您只能看到一次。请确保将其复制并保存到一个安全的地方。",
|
||||||
"siteInfo": "站点信息",
|
"siteInfo": "站点信息",
|
||||||
"status": "状态",
|
"status": "状态",
|
||||||
"shareTitle": "管理共享链接",
|
"shareTitle": "管理可共享链接",
|
||||||
"shareDescription": "创建可共享的链接,允许临时或永久访问代理资源",
|
"shareDescription": "创建可共享的链接,允许临时或永久访问代理资源",
|
||||||
"shareSearch": "搜索共享链接……",
|
"shareSearch": "搜索可共享链接……",
|
||||||
"shareCreate": "创建共享链接",
|
"shareCreate": "创建可共享链接",
|
||||||
"shareErrorDelete": "删除链接失败",
|
"shareErrorDelete": "删除链接失败",
|
||||||
"shareErrorDeleteMessage": "删除链接时出错",
|
"shareErrorDeleteMessage": "删除链接时出错",
|
||||||
"shareDeleted": "链接已删除",
|
"shareDeleted": "链接已删除",
|
||||||
"shareDeletedDescription": "链接已删除",
|
"shareDeletedDescription": "链接已删除",
|
||||||
"shareDelete": "删除共享链接",
|
"shareDelete": "删除可共享链接",
|
||||||
"shareDeleteConfirm": "确认删除共享链接",
|
"shareDeleteConfirm": "确认删除可共享链接",
|
||||||
"shareQuestionRemove": "您确定要删除这个共享链接吗?",
|
"shareQuestionRemove": "您确定要删除这个共享链接吗?",
|
||||||
"shareMessageRemove": "删除后,该链接将不再可用,使用它的任何人将失去对资源的访问权限。",
|
"shareMessageRemove": "删除后,该链接将不再可用,使用它的任何人将失去对资源的访问权限。",
|
||||||
"shareTokenDescription": "访问令牌可以通过两种方式传递:作为查询参数或请求标题。 每次验证访问请求都必须从客户端传递。",
|
"shareTokenDescription": "访问令牌可以通过两种方式传递:作为查询参数或请求标题。 每次验证访问请求都必须从客户端传递。",
|
||||||
@@ -210,11 +204,11 @@
|
|||||||
"proxyResourceTitle": "管理公共资源",
|
"proxyResourceTitle": "管理公共资源",
|
||||||
"proxyResourceDescription": "创建和管理可通过 Web 浏览器公开访问的资源",
|
"proxyResourceDescription": "创建和管理可通过 Web 浏览器公开访问的资源",
|
||||||
"publicResourcesBannerTitle": "基于 Web 的公共访问",
|
"publicResourcesBannerTitle": "基于 Web 的公共访问",
|
||||||
"publicResourcesBannerDescription": "公共资源是 HTTPS 代理,可供互联网上的任何人通过 Web 浏览器访问。与私人资源不同,它们不需要客户端软件,并且可以包含身份和上下文感知的访问策略。",
|
"publicResourcesBannerDescription": "公共资源是 HTTPS 代理,可以通过网络浏览器在互联网上的任何人访问。与私人资源不同,它们不需要客户端软件,并且可以包含身份和上下文感知的访问策略。",
|
||||||
"clientResourceTitle": "管理私有资源",
|
"clientResourceTitle": "管理私有资源",
|
||||||
"clientResourceDescription": "创建和管理只能通过连接客户端访问的资源",
|
"clientResourceDescription": "创建和管理只能通过连接客户端访问的资源",
|
||||||
"privateResourcesBannerTitle": "零信任私有访问",
|
"privateResourcesBannerTitle": "零信任的私人访问",
|
||||||
"privateResourcesBannerDescription": "私有资源采用零信任安全机制,确保只有获得明确授权的用户和机器才能访问。用户设备或机器客户端连接后,即可通过安全的虚拟专用网络访问这些资源。",
|
"privateResourcesBannerDescription": "私人资源使用零信任安全性,确保只允许明确授予的用户和机器访问资源。可以连接用户设备或机器客户端,通过安全的虚拟专用网络访问这些资源。",
|
||||||
"resourcesSearch": "搜索资源...",
|
"resourcesSearch": "搜索资源...",
|
||||||
"resourceAdd": "添加资源",
|
"resourceAdd": "添加资源",
|
||||||
"resourceErrorDelte": "删除资源时出错",
|
"resourceErrorDelte": "删除资源时出错",
|
||||||
@@ -333,7 +327,7 @@
|
|||||||
"passToAuth": "传递至认证",
|
"passToAuth": "传递至认证",
|
||||||
"orgSettingsDescription": "配置组织设置",
|
"orgSettingsDescription": "配置组织设置",
|
||||||
"orgGeneralSettings": "组织设置",
|
"orgGeneralSettings": "组织设置",
|
||||||
"orgGeneralSettingsDescription": "管理组织的详细信息和配置",
|
"orgGeneralSettingsDescription": "管理机构的详细信息和配置",
|
||||||
"saveGeneralSettings": "保存常规设置",
|
"saveGeneralSettings": "保存常规设置",
|
||||||
"saveSettings": "保存设置",
|
"saveSettings": "保存设置",
|
||||||
"orgDangerZone": "危险区域",
|
"orgDangerZone": "危险区域",
|
||||||
@@ -387,7 +381,7 @@
|
|||||||
"accessApprovalsDescription": "查看和管理待审批的组织访问权限",
|
"accessApprovalsDescription": "查看和管理待审批的组织访问权限",
|
||||||
"description": "描述",
|
"description": "描述",
|
||||||
"inviteTitle": "打开邀请",
|
"inviteTitle": "打开邀请",
|
||||||
"inviteDescription": "管理其他用户加入组织的邀请",
|
"inviteDescription": "管理其他用户加入机构的邀请",
|
||||||
"inviteSearch": "搜索邀请...",
|
"inviteSearch": "搜索邀请...",
|
||||||
"minutes": "分钟",
|
"minutes": "分钟",
|
||||||
"hours": "小时",
|
"hours": "小时",
|
||||||
@@ -431,24 +425,24 @@
|
|||||||
"apiKeysDelete": "删除 API 密钥",
|
"apiKeysDelete": "删除 API 密钥",
|
||||||
"apiKeysManage": "管理 API 密钥",
|
"apiKeysManage": "管理 API 密钥",
|
||||||
"apiKeysDescription": "API 密钥用于认证集成 API",
|
"apiKeysDescription": "API 密钥用于认证集成 API",
|
||||||
"provisioningKeysTitle": "预配密钥",
|
"provisioningKeysTitle": "置备密钥",
|
||||||
"provisioningKeysManage": "管理预配密钥",
|
"provisioningKeysManage": "管理置备键",
|
||||||
"provisioningKeysDescription": "置备密钥用于验证您组织的自动站点配置。",
|
"provisioningKeysDescription": "置备密钥用于验证您组织的自动站点配置。",
|
||||||
"provisioningManage": "预配",
|
"provisioningManage": "置备中",
|
||||||
"provisioningDescription": "管理预配密钥,并审核待批准的站点。",
|
"provisioningDescription": "管理预配键和审查等待批准的站点。",
|
||||||
"pendingSites": "待审批站点",
|
"pendingSites": "待定站点",
|
||||||
"siteApproveSuccess": "站点批准成功",
|
"siteApproveSuccess": "站点批准成功",
|
||||||
"siteApproveError": "批准站点出错",
|
"siteApproveError": "批准站点出错",
|
||||||
"provisioningKeys": "置备键",
|
"provisioningKeys": "置备键",
|
||||||
"searchProvisioningKeys": "搜索配备密钥...",
|
"searchProvisioningKeys": "搜索配备密钥...",
|
||||||
"provisioningKeysAdd": "生成预配密钥",
|
"provisioningKeysAdd": "生成置备键",
|
||||||
"provisioningKeysErrorDelete": "删除预配键时出错",
|
"provisioningKeysErrorDelete": "删除预配键时出错",
|
||||||
"provisioningKeysErrorDeleteMessage": "删除预配键时出错",
|
"provisioningKeysErrorDeleteMessage": "删除预配键时出错",
|
||||||
"provisioningKeysQuestionRemove": "您确定要从组织中删除此预配键吗?",
|
"provisioningKeysQuestionRemove": "您确定要从组织中删除此预配键吗?",
|
||||||
"provisioningKeysMessageRemove": "一旦移除,密钥不能再用于站点预配。",
|
"provisioningKeysMessageRemove": "一旦移除,密钥不能再用于站点预配。",
|
||||||
"provisioningKeysDeleteConfirm": "确认删除置备键",
|
"provisioningKeysDeleteConfirm": "确认删除置备键",
|
||||||
"provisioningKeysDelete": "删除置备键",
|
"provisioningKeysDelete": "删除置备键",
|
||||||
"provisioningKeysCreate": "生成预配密钥",
|
"provisioningKeysCreate": "生成置备键",
|
||||||
"provisioningKeysCreateDescription": "为组织生成一个新的预置密钥",
|
"provisioningKeysCreateDescription": "为组织生成一个新的预置密钥",
|
||||||
"provisioningKeysSeeAll": "查看所有预配键",
|
"provisioningKeysSeeAll": "查看所有预配键",
|
||||||
"provisioningKeysSave": "保存预配键",
|
"provisioningKeysSave": "保存预配键",
|
||||||
@@ -468,16 +462,16 @@
|
|||||||
"provisioningKeysNeverUsed": "永不过期",
|
"provisioningKeysNeverUsed": "永不过期",
|
||||||
"provisioningKeysEdit": "编辑置备键",
|
"provisioningKeysEdit": "编辑置备键",
|
||||||
"provisioningKeysEditDescription": "更新此密钥的最大批量大小和过期时间。",
|
"provisioningKeysEditDescription": "更新此密钥的最大批量大小和过期时间。",
|
||||||
"provisioningKeysApproveNewSites": "批准新节点",
|
"provisioningKeysApproveNewSites": "批准新站点",
|
||||||
"provisioningKeysApproveNewSitesDescription": "自动批准使用此密钥注册的节点。",
|
"provisioningKeysApproveNewSitesDescription": "自动批准使用此密钥注册的站点。",
|
||||||
"provisioningKeysUpdateError": "更新预配键时出错",
|
"provisioningKeysUpdateError": "更新预配键时出错",
|
||||||
"provisioningKeysUpdated": "置备密钥已更新",
|
"provisioningKeysUpdated": "置备密钥已更新",
|
||||||
"provisioningKeysUpdatedDescription": "您的更改已保存。",
|
"provisioningKeysUpdatedDescription": "您的更改已保存。",
|
||||||
"provisioningKeysBannerTitle": "站点预配密钥",
|
"provisioningKeysBannerTitle": "站点置备密钥",
|
||||||
"provisioningKeysBannerDescription": "生成预配密钥,并将其与 Newt 连接器配合使用,即可在首次启动时自动创建站点,无需为每个站点单独配置凭据。",
|
"provisioningKeysBannerDescription": "生成一个供应密钥,并将其与 Newt 连接器一起使用,以在首次启动时自动创建站点 - 无需为每个站点设置单独的凭据。",
|
||||||
"provisioningKeysBannerButtonText": "了解更多",
|
"provisioningKeysBannerButtonText": "了解更多",
|
||||||
"pendingSitesBannerTitle": "待审批站点",
|
"pendingSitesBannerTitle": "待定站点",
|
||||||
"pendingSitesBannerDescription": "使用预配密钥连接的网站会在这里以供审核。",
|
"pendingSitesBannerDescription": "使用供应密钥连接的站点将在此显示以供审核。",
|
||||||
"pendingSitesBannerButtonText": "了解更多",
|
"pendingSitesBannerButtonText": "了解更多",
|
||||||
"apiKeysSettings": "{apiKeyName} 设置",
|
"apiKeysSettings": "{apiKeyName} 设置",
|
||||||
"userTitle": "管理所有用户",
|
"userTitle": "管理所有用户",
|
||||||
@@ -889,11 +883,11 @@
|
|||||||
"resourcesErrorUpdateDescription": "更新资源时出错",
|
"resourcesErrorUpdateDescription": "更新资源时出错",
|
||||||
"access": "访问权限",
|
"access": "访问权限",
|
||||||
"accessControl": "访问控制",
|
"accessControl": "访问控制",
|
||||||
"shareLink": "{resource} 的共享链接",
|
"shareLink": "{resource} 可共享链接",
|
||||||
"resourceSelect": "选择资源",
|
"resourceSelect": "选择资源",
|
||||||
"shareLinks": "共享链接",
|
"shareLinks": "可共享链接",
|
||||||
"share": "分享链接",
|
"share": "分享链接",
|
||||||
"shareDescription2": "创建资源的共享链接。链接提供了对您资源的临时或无限制访问。 当您创建链接时,您可以配置链接的到期时间。",
|
"shareDescription2": "创建资源的可共享链接。链接提供了对您资源的临时或无限制访问。 当您创建链接时,您可以配置链接的到期时间。",
|
||||||
"shareEasyCreate": "轻松创建和分享",
|
"shareEasyCreate": "轻松创建和分享",
|
||||||
"shareConfigurableExpirationDuration": "可配置的过期时间",
|
"shareConfigurableExpirationDuration": "可配置的过期时间",
|
||||||
"shareSecureAndRevocable": "安全和可撤销的",
|
"shareSecureAndRevocable": "安全和可撤销的",
|
||||||
@@ -1065,7 +1059,7 @@
|
|||||||
"network": "网络",
|
"network": "网络",
|
||||||
"manage": "管理",
|
"manage": "管理",
|
||||||
"sitesNotFound": "未找到站点。",
|
"sitesNotFound": "未找到站点。",
|
||||||
"pangolinServerAdmin": "服务器管理 - Pangolin",
|
"pangolinServerAdmin": "服务器管理员 - Pangolin",
|
||||||
"licenseTierProfessional": "专业许可证",
|
"licenseTierProfessional": "专业许可证",
|
||||||
"licenseTierEnterprise": "企业许可证",
|
"licenseTierEnterprise": "企业许可证",
|
||||||
"licenseTierPersonal": "个人许可证",
|
"licenseTierPersonal": "个人许可证",
|
||||||
@@ -1372,7 +1366,7 @@
|
|||||||
"supportKeyBuy": "购买支持者密钥",
|
"supportKeyBuy": "购买支持者密钥",
|
||||||
"logoutError": "注销错误",
|
"logoutError": "注销错误",
|
||||||
"signingAs": "登录为",
|
"signingAs": "登录为",
|
||||||
"serverAdmin": "服务器管理",
|
"serverAdmin": "服务器管理员",
|
||||||
"managedSelfhosted": "托管自托管",
|
"managedSelfhosted": "托管自托管",
|
||||||
"otpEnable": "启用双因子认证",
|
"otpEnable": "启用双因子认证",
|
||||||
"otpDisable": "禁用双因子认证",
|
"otpDisable": "禁用双因子认证",
|
||||||
@@ -1542,8 +1536,8 @@
|
|||||||
"sidebarSites": "站点",
|
"sidebarSites": "站点",
|
||||||
"sidebarApprovals": "审批请求",
|
"sidebarApprovals": "审批请求",
|
||||||
"sidebarResources": "资源",
|
"sidebarResources": "资源",
|
||||||
"sidebarProxyResources": "公开资源",
|
"sidebarProxyResources": "公开的",
|
||||||
"sidebarClientResources": "私有资源",
|
"sidebarClientResources": "非公开的",
|
||||||
"sidebarPolicies": "共享策略",
|
"sidebarPolicies": "共享策略",
|
||||||
"sidebarResourcePolicies": "公共资源",
|
"sidebarResourcePolicies": "公共资源",
|
||||||
"sidebarAccessControl": "访问控制",
|
"sidebarAccessControl": "访问控制",
|
||||||
@@ -1553,17 +1547,17 @@
|
|||||||
"sidebarAdmin": "管理员",
|
"sidebarAdmin": "管理员",
|
||||||
"sidebarInvitations": "邀请",
|
"sidebarInvitations": "邀请",
|
||||||
"sidebarRoles": "角色",
|
"sidebarRoles": "角色",
|
||||||
"sidebarShareableLinks": "共享链接",
|
"sidebarShareableLinks": "可共享链接",
|
||||||
"sidebarApiKeys": "API密钥",
|
"sidebarApiKeys": "API密钥",
|
||||||
"sidebarProvisioning": "预配",
|
"sidebarProvisioning": "置备中",
|
||||||
"sidebarSettings": "设置",
|
"sidebarSettings": "设置",
|
||||||
"sidebarAllUsers": "所有用户",
|
"sidebarAllUsers": "所有用户",
|
||||||
"sidebarIdentityProviders": "身份提供商",
|
"sidebarIdentityProviders": "身份提供商",
|
||||||
"sidebarLicense": "证书",
|
"sidebarLicense": "证书",
|
||||||
"sidebarClients": "客户端",
|
"sidebarClients": "客户端",
|
||||||
"sidebarUserDevices": "用户设备",
|
"sidebarUserDevices": "用户设备",
|
||||||
"sidebarMachineClients": "机器身份",
|
"sidebarMachineClients": "机",
|
||||||
"sidebarDomains": "域名",
|
"sidebarDomains": "域",
|
||||||
"sidebarGeneral": "管理",
|
"sidebarGeneral": "管理",
|
||||||
"sidebarLogAndAnalytics": "日志与分析",
|
"sidebarLogAndAnalytics": "日志与分析",
|
||||||
"sidebarBluePrints": "蓝图",
|
"sidebarBluePrints": "蓝图",
|
||||||
@@ -1695,8 +1689,8 @@
|
|||||||
"alertingTabHealthChecks": "健康检查",
|
"alertingTabHealthChecks": "健康检查",
|
||||||
"alertingRulesBannerTitle": "获取通知",
|
"alertingRulesBannerTitle": "获取通知",
|
||||||
"alertingRulesBannerDescription": "每条规则都连接要监视的对象(站点、健康检查或资源),触发时间(例如离线或不健康),以及如何通过电子邮件、Webhooks 或集成将通知发送给团队。使用此列表创建、启用和管理这些规则。",
|
"alertingRulesBannerDescription": "每条规则都连接要监视的对象(站点、健康检查或资源),触发时间(例如离线或不健康),以及如何通过电子邮件、Webhooks 或集成将通知发送给团队。使用此列表创建、启用和管理这些规则。",
|
||||||
"alertingHealthChecksBannerTitle": "资源与健康监控",
|
"alertingHealthChecksBannerTitle": "监视健康和资源",
|
||||||
"alertingHealthChecksBannerDescription": "通过 HTTP 或 TCP 检查目标状态,并在服务异常或恢复时发送通知。资源中配置的健康检查也会显示在这里。",
|
"alertingHealthChecksBannerDescription": "健康检查是您一次定义的 HTTP 或 TCP 监控。然后可以将它们用作告警规则中的来源,以便目标变得正常或不正常时得到通知。资源上的健康检查也会出现在此处。",
|
||||||
"standaloneHcTableTitle": "健康检查",
|
"standaloneHcTableTitle": "健康检查",
|
||||||
"standaloneHcSearchPlaceholder": "搜索健康检查…",
|
"standaloneHcSearchPlaceholder": "搜索健康检查…",
|
||||||
"standaloneHcAddButton": "创建健康检查",
|
"standaloneHcAddButton": "创建健康检查",
|
||||||
@@ -1797,17 +1791,17 @@
|
|||||||
"theme": "主题",
|
"theme": "主题",
|
||||||
"subnetRequired": "子网是必填项",
|
"subnetRequired": "子网是必填项",
|
||||||
"initialSetupTitle": "初始服务器设置",
|
"initialSetupTitle": "初始服务器设置",
|
||||||
"initialSetupDescription": "创建初始的管理员帐户。 只能存在一个服务器管理员。 您可以随时更改这些凭据。",
|
"initialSetupDescription": "创建初始服务器管理员帐户。 只能存在一个服务器管理员。 您可以随时更改这些凭据。",
|
||||||
"createAdminAccount": "创建管理员帐户",
|
"createAdminAccount": "创建管理员帐户",
|
||||||
"setupErrorCreateAdmin": "创建管理员账户时发生错误。",
|
"setupErrorCreateAdmin": "创建服务器管理员账户时发生错误。",
|
||||||
"certificateStatus": "证书",
|
"certificateStatus": "证书",
|
||||||
"certificateStatusAutoRefreshHint": "状态自动刷新。",
|
"certificateStatusAutoRefreshHint": "状态自动刷新。",
|
||||||
"loading": "加载中",
|
"loading": "加载中",
|
||||||
"loadingEllipsis": "加载中……",
|
"loadingEllipsis": "加载中……",
|
||||||
"loadingAnalytics": "加载分析",
|
"loadingAnalytics": "加载分析",
|
||||||
"restart": "重启",
|
"restart": "重启",
|
||||||
"domains": "域名",
|
"domains": "域",
|
||||||
"domainsDescription": "创建和管理组织中可用的域名",
|
"domainsDescription": "创建和管理组织中可用的域",
|
||||||
"domainsSearch": "搜索域...",
|
"domainsSearch": "搜索域...",
|
||||||
"domainAdd": "添加域",
|
"domainAdd": "添加域",
|
||||||
"domainAddDescription": "注册一个新域名到组织",
|
"domainAddDescription": "注册一个新域名到组织",
|
||||||
@@ -2171,12 +2165,12 @@
|
|||||||
"sshSudoMode": "Sudo 访问",
|
"sshSudoMode": "Sudo 访问",
|
||||||
"sshSudoModeNone": "无",
|
"sshSudoModeNone": "无",
|
||||||
"sshSudoModeNoneDescription": "用户不能用sudo运行命令。",
|
"sshSudoModeNoneDescription": "用户不能用sudo运行命令。",
|
||||||
"sshSudoModeFull": "完整 Sudo 权限",
|
"sshSudoModeFull": "全苏多",
|
||||||
"sshSudoModeFullDescription": "用户可以用 sudo 运行任何命令。",
|
"sshSudoModeFullDescription": "用户可以用 sudo 运行任何命令。",
|
||||||
"sshSudoModeCommands": "命令",
|
"sshSudoModeCommands": "命令",
|
||||||
"sshSudoModeCommandsDescription": "用户只能用 sudo 运行指定的命令。",
|
"sshSudoModeCommandsDescription": "用户只能用 sudo 运行指定的命令。",
|
||||||
"sshSudo": "允许Sudo",
|
"sshSudo": "允许Sudo",
|
||||||
"sshSudoCommands": "可用 Sudo 命令",
|
"sshSudoCommands": "Sudo 命令",
|
||||||
"sshSudoCommandsDescription": "用户可以使用 sudo 运行的命令列表,以逗号、空格或新行分隔。必须使用绝对路径。",
|
"sshSudoCommandsDescription": "用户可以使用 sudo 运行的命令列表,以逗号、空格或新行分隔。必须使用绝对路径。",
|
||||||
"sshCreateHomeDir": "创建主目录",
|
"sshCreateHomeDir": "创建主目录",
|
||||||
"sshUnixGroups": "Unix 组",
|
"sshUnixGroups": "Unix 组",
|
||||||
@@ -2189,7 +2183,7 @@
|
|||||||
"roleTextImportAppend": "附加到现有",
|
"roleTextImportAppend": "附加到现有",
|
||||||
"roleTextImportMode": "导入模式",
|
"roleTextImportMode": "导入模式",
|
||||||
"roleTextImportPreview": "预览",
|
"roleTextImportPreview": "预览",
|
||||||
"roleTextImportItemCount": "{count, plural, =0 {没有可导入的项目} one {1 个可导入项目} other {# 个可导入项目}}",
|
"roleTextImportItemCount": "{count, plural, =0 {No items to import} one {1 item to import} other {# items to import}}",
|
||||||
"roleTextImportTotalCount": "{existing} 个现有 + {imported} 个导入 = {total} 个总计",
|
"roleTextImportTotalCount": "{existing} 个现有 + {imported} 个导入 = {total} 个总计",
|
||||||
"roleTextImportConfirm": "导入",
|
"roleTextImportConfirm": "导入",
|
||||||
"roleTextImportInvalidFile": "不支持的文件类型",
|
"roleTextImportInvalidFile": "不支持的文件类型",
|
||||||
@@ -2241,8 +2235,8 @@
|
|||||||
"resourceEditDomain": "编辑域名",
|
"resourceEditDomain": "编辑域名",
|
||||||
"siteName": "站点名称",
|
"siteName": "站点名称",
|
||||||
"proxyPort": "端口",
|
"proxyPort": "端口",
|
||||||
"resourcesTableProxyResources": "",
|
"resourcesTableProxyResources": "公开的",
|
||||||
"resourcesTableClientResources": "私有资源",
|
"resourcesTableClientResources": "非公开的",
|
||||||
"resourcesTableNoProxyResourcesFound": "未找到代理资源。",
|
"resourcesTableNoProxyResourcesFound": "未找到代理资源。",
|
||||||
"resourcesTableNoInternalResourcesFound": "未找到内部资源。",
|
"resourcesTableNoInternalResourcesFound": "未找到内部资源。",
|
||||||
"resourcesTableDestination": "目标",
|
"resourcesTableDestination": "目标",
|
||||||
@@ -2344,7 +2338,6 @@
|
|||||||
"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": "配置",
|
||||||
@@ -2932,7 +2925,7 @@
|
|||||||
"logRetentionRequestDescription": "保留请求日志的时间",
|
"logRetentionRequestDescription": "保留请求日志的时间",
|
||||||
"logRetentionAccessLabel": "访问日志保留",
|
"logRetentionAccessLabel": "访问日志保留",
|
||||||
"logRetentionAccessDescription": "保留访问日志的时间",
|
"logRetentionAccessDescription": "保留访问日志的时间",
|
||||||
"logRetentionActionLabel": "审计日志保留",
|
"logRetentionActionLabel": "动作日志保留",
|
||||||
"logRetentionActionDescription": "保留操作日志的时间",
|
"logRetentionActionDescription": "保留操作日志的时间",
|
||||||
"logRetentionConnectionLabel": "连接日志保留",
|
"logRetentionConnectionLabel": "连接日志保留",
|
||||||
"logRetentionConnectionDescription": "保留连接日志的时间",
|
"logRetentionConnectionDescription": "保留连接日志的时间",
|
||||||
@@ -2945,11 +2938,11 @@
|
|||||||
"logRetentionForever": "永远的",
|
"logRetentionForever": "永远的",
|
||||||
"logRetentionEndOfFollowingYear": "下一年结束",
|
"logRetentionEndOfFollowingYear": "下一年结束",
|
||||||
"actionLogsDescription": "查看此机构执行的操作历史",
|
"actionLogsDescription": "查看此机构执行的操作历史",
|
||||||
"accessLogsDescription": "查看此组织资源的访问认证请求",
|
"accessLogsDescription": "查看此机构资源的访问认证请求",
|
||||||
"connectionLogs": "连接日志",
|
"connectionLogs": "连接日志",
|
||||||
"connectionLogsDescription": "查看此机构隧道的连接日志",
|
"connectionLogsDescription": "查看此机构隧道的连接日志",
|
||||||
"sidebarLogsConnection": "连接日志",
|
"sidebarLogsConnection": "连接日志",
|
||||||
"sidebarLogsStreaming": "事件流",
|
"sidebarLogsStreaming": "流流",
|
||||||
"sourceAddress": "源地址",
|
"sourceAddress": "源地址",
|
||||||
"destinationAddress": "目的地址",
|
"destinationAddress": "目的地址",
|
||||||
"duration": "期限",
|
"duration": "期限",
|
||||||
@@ -2974,7 +2967,6 @@
|
|||||||
"orgOrDomainIdMissing": "缺少机构或域 ID",
|
"orgOrDomainIdMissing": "缺少机构或域 ID",
|
||||||
"loadingDNSRecords": "正在载入DNS记录...",
|
"loadingDNSRecords": "正在载入DNS记录...",
|
||||||
"olmUpdateAvailableInfo": "有最新版本的 Olm 可用。请更新到最新版本以获取最佳体验。",
|
"olmUpdateAvailableInfo": "有最新版本的 Olm 可用。请更新到最新版本以获取最佳体验。",
|
||||||
"updateAvailableInfo": "有新版本可用。请更新到最新版本以获得最佳体验。",
|
|
||||||
"client": "客户端:",
|
"client": "客户端:",
|
||||||
"proxyProtocol": "代理协议设置",
|
"proxyProtocol": "代理协议设置",
|
||||||
"proxyProtocolDescription": "配置代理协议以保留TCP服务的客户端 IP 地址。",
|
"proxyProtocolDescription": "配置代理协议以保留TCP服务的客户端 IP 地址。",
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ import {
|
|||||||
users
|
users
|
||||||
} from "@server/db";
|
} from "@server/db";
|
||||||
import { db } from "@server/db";
|
import { db } from "@server/db";
|
||||||
import { and, eq, inArray, ne } from "drizzle-orm";
|
import { eq, inArray } 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,45 +136,6 @@ 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,
|
||||||
|
|||||||
@@ -795,13 +795,10 @@ export const COUNTRIES = [
|
|||||||
name: "Serbia",
|
name: "Serbia",
|
||||||
code: "RS"
|
code: "RS"
|
||||||
},
|
},
|
||||||
// Removed as this is a deprecated ISO country code, not supported anymore
|
{
|
||||||
// Also the individual flags for Serbia & Montenegro are already included in the list
|
name: "Serbia and Montenegro",
|
||||||
// more details: https://en.wikipedia.org/wiki/ISO_3166-2:CS
|
code: "CS"
|
||||||
// {
|
},
|
||||||
// name: "Serbia and Montenegro",
|
|
||||||
// code: "CS"
|
|
||||||
// },
|
|
||||||
{
|
{
|
||||||
name: "Seychelles",
|
name: "Seychelles",
|
||||||
code: "SC"
|
code: "SC"
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ import {
|
|||||||
pgTable,
|
pgTable,
|
||||||
serial,
|
serial,
|
||||||
varchar,
|
varchar,
|
||||||
unique,
|
|
||||||
boolean,
|
boolean,
|
||||||
integer,
|
integer,
|
||||||
bigint,
|
bigint,
|
||||||
@@ -12,7 +11,7 @@ import {
|
|||||||
primaryKey,
|
primaryKey,
|
||||||
uniqueIndex
|
uniqueIndex
|
||||||
} from "drizzle-orm/pg-core";
|
} from "drizzle-orm/pg-core";
|
||||||
import { InferSelectModel, sql } from "drizzle-orm";
|
import { InferSelectModel } from "drizzle-orm";
|
||||||
import {
|
import {
|
||||||
domains,
|
domains,
|
||||||
orgs,
|
orgs,
|
||||||
@@ -20,13 +19,12 @@ import {
|
|||||||
roles,
|
roles,
|
||||||
users,
|
users,
|
||||||
exitNodes,
|
exitNodes,
|
||||||
|
sessions,
|
||||||
|
clients,
|
||||||
resources,
|
resources,
|
||||||
siteResources,
|
siteResources,
|
||||||
targetHealthCheck,
|
targetHealthCheck,
|
||||||
sites,
|
sites
|
||||||
clients,
|
|
||||||
sessions,
|
|
||||||
labels
|
|
||||||
} from "./schema";
|
} from "./schema";
|
||||||
|
|
||||||
export const certificates = pgTable("certificates", {
|
export const certificates = pgTable("certificates", {
|
||||||
@@ -199,42 +197,6 @@ export const remoteExitNodes = pgTable("remoteExitNode", {
|
|||||||
})
|
})
|
||||||
});
|
});
|
||||||
|
|
||||||
export const remoteExitNodeResources = pgTable("remoteExitNodeResources", {
|
|
||||||
remoteExitNodeResourceId: serial("remoteExitNodeResourceId").primaryKey(),
|
|
||||||
remoteExitNodeId: varchar("remoteExitNodeId")
|
|
||||||
.notNull()
|
|
||||||
.references(() => remoteExitNodes.remoteExitNodeId, {
|
|
||||||
onDelete: "cascade"
|
|
||||||
}),
|
|
||||||
destination: varchar("destination").notNull() // a cidr range
|
|
||||||
});
|
|
||||||
|
|
||||||
export const remoteExitNodePreferenceLabels = pgTable(
|
|
||||||
// this controls what sites are enforced to connect to this node
|
|
||||||
"remoteExitNodePreferenceLabels",
|
|
||||||
{
|
|
||||||
remoteExitNodePreferenceLabelId: serial(
|
|
||||||
"remoteExitNodePreferenceLabelId"
|
|
||||||
).primaryKey(),
|
|
||||||
remoteExitNodeId: varchar("remoteExitNodeId")
|
|
||||||
.references(() => remoteExitNodes.remoteExitNodeId, {
|
|
||||||
onDelete: "cascade"
|
|
||||||
})
|
|
||||||
.notNull(),
|
|
||||||
labelId: integer("labelId")
|
|
||||||
.references(() => labels.labelId, {
|
|
||||||
onDelete: "cascade"
|
|
||||||
})
|
|
||||||
.notNull()
|
|
||||||
},
|
|
||||||
(t) => [
|
|
||||||
unique("remote_exit_node_preference_label_uniq").on(
|
|
||||||
t.remoteExitNodeId,
|
|
||||||
t.labelId
|
|
||||||
)
|
|
||||||
]
|
|
||||||
);
|
|
||||||
|
|
||||||
export const remoteExitNodeSessions = pgTable("remoteExitNodeSession", {
|
export const remoteExitNodeSessions = pgTable("remoteExitNodeSession", {
|
||||||
sessionId: varchar("id").primaryKey(),
|
sessionId: varchar("id").primaryKey(),
|
||||||
remoteExitNodeId: varchar("remoteExitNodeId")
|
remoteExitNodeId: varchar("remoteExitNodeId")
|
||||||
@@ -245,28 +207,17 @@ export const remoteExitNodeSessions = pgTable("remoteExitNodeSession", {
|
|||||||
expiresAt: bigint("expiresAt", { mode: "number" }).notNull()
|
expiresAt: bigint("expiresAt", { mode: "number" }).notNull()
|
||||||
});
|
});
|
||||||
|
|
||||||
export const loginPage = pgTable(
|
export const loginPage = pgTable("loginPage", {
|
||||||
"loginPage",
|
loginPageId: serial("loginPageId").primaryKey(),
|
||||||
{
|
subdomain: varchar("subdomain"),
|
||||||
loginPageId: serial("loginPageId").primaryKey(),
|
fullDomain: varchar("fullDomain"),
|
||||||
subdomain: varchar("subdomain"),
|
exitNodeId: integer("exitNodeId").references(() => exitNodes.exitNodeId, {
|
||||||
fullDomain: varchar("fullDomain"),
|
onDelete: "set null"
|
||||||
exitNodeId: integer("exitNodeId").references(
|
}),
|
||||||
() => exitNodes.exitNodeId,
|
domainId: varchar("domainId").references(() => domains.domainId, {
|
||||||
{
|
onDelete: "set null"
|
||||||
onDelete: "set null"
|
})
|
||||||
}
|
});
|
||||||
),
|
|
||||||
domainId: varchar("domainId").references(() => domains.domainId, {
|
|
||||||
onDelete: "set null"
|
|
||||||
})
|
|
||||||
},
|
|
||||||
(t) => [
|
|
||||||
index("idx_loginpage_fulldomain")
|
|
||||||
.on(t.fullDomain)
|
|
||||||
.where(sql`${t.fullDomain} IS NOT NULL`)
|
|
||||||
]
|
|
||||||
);
|
|
||||||
|
|
||||||
export const loginPageOrg = pgTable("loginPageOrg", {
|
export const loginPageOrg = pgTable("loginPageOrg", {
|
||||||
loginPageId: integer("loginPageId")
|
loginPageId: integer("loginPageId")
|
||||||
|
|||||||
+282
-392
@@ -1,5 +1,5 @@
|
|||||||
import { randomUUID } from "crypto";
|
import { randomUUID } from "crypto";
|
||||||
import { InferSelectModel, sql } from "drizzle-orm";
|
import { InferSelectModel } from "drizzle-orm";
|
||||||
import {
|
import {
|
||||||
bigint,
|
bigint,
|
||||||
boolean,
|
boolean,
|
||||||
@@ -82,130 +82,107 @@ export const orgDomains = pgTable("orgDomains", {
|
|||||||
.references(() => domains.domainId, { onDelete: "cascade" })
|
.references(() => domains.domainId, { onDelete: "cascade" })
|
||||||
});
|
});
|
||||||
|
|
||||||
export const sites = pgTable(
|
export const sites = pgTable("sites", {
|
||||||
"sites",
|
siteId: serial("siteId").primaryKey(),
|
||||||
{
|
orgId: varchar("orgId")
|
||||||
siteId: serial("siteId").primaryKey(),
|
.references(() => orgs.orgId, {
|
||||||
orgId: varchar("orgId")
|
onDelete: "cascade"
|
||||||
.references(() => orgs.orgId, {
|
})
|
||||||
onDelete: "cascade"
|
.notNull(),
|
||||||
})
|
niceId: varchar("niceId").notNull(),
|
||||||
.notNull(),
|
exitNodeId: integer("exitNode").references(() => exitNodes.exitNodeId, {
|
||||||
niceId: varchar("niceId").notNull(),
|
onDelete: "set null"
|
||||||
exitNodeId: integer("exitNode").references(() => exitNodes.exitNodeId, {
|
}),
|
||||||
onDelete: "set null"
|
name: varchar("name").notNull(),
|
||||||
}),
|
pubKey: varchar("pubKey"),
|
||||||
name: varchar("name").notNull(),
|
subnet: varchar("subnet"),
|
||||||
pubKey: varchar("pubKey"),
|
megabytesIn: real("bytesIn").default(0),
|
||||||
subnet: varchar("subnet"),
|
megabytesOut: real("bytesOut").default(0),
|
||||||
megabytesIn: real("bytesIn").default(0),
|
lastBandwidthUpdate: varchar("lastBandwidthUpdate"),
|
||||||
megabytesOut: real("bytesOut").default(0),
|
type: varchar("type").notNull(), // "newt" or "wireguard"
|
||||||
lastBandwidthUpdate: varchar("lastBandwidthUpdate"),
|
online: boolean("online").notNull().default(false),
|
||||||
type: varchar("type").notNull(), // "newt" or "wireguard"
|
lastPing: integer("lastPing"),
|
||||||
online: boolean("online").notNull().default(false),
|
address: varchar("address"),
|
||||||
lastPing: integer("lastPing"),
|
endpoint: varchar("endpoint"),
|
||||||
address: varchar("address"),
|
publicKey: varchar("publicKey"),
|
||||||
endpoint: varchar("endpoint"),
|
lastHolePunch: bigint("lastHolePunch", { mode: "number" }),
|
||||||
publicKey: varchar("publicKey"),
|
listenPort: integer("listenPort"),
|
||||||
lastHolePunch: bigint("lastHolePunch", { mode: "number" }),
|
dockerSocketEnabled: boolean("dockerSocketEnabled").notNull().default(true),
|
||||||
listenPort: integer("listenPort"),
|
autoUpdateEnabled: boolean("autoUpdateEnabled").notNull().default(false),
|
||||||
dockerSocketEnabled: boolean("dockerSocketEnabled")
|
autoUpdateOverrideOrg: boolean("autoUpdateOverrideOrg")
|
||||||
.notNull()
|
.notNull()
|
||||||
.default(true),
|
.default(false),
|
||||||
autoUpdateEnabled: boolean("autoUpdateEnabled")
|
status: varchar("status")
|
||||||
.notNull()
|
.$type<"pending" | "approved">()
|
||||||
.default(false),
|
.default("approved")
|
||||||
autoUpdateOverrideOrg: boolean("autoUpdateOverrideOrg")
|
});
|
||||||
.notNull()
|
|
||||||
.default(false),
|
|
||||||
status: varchar("status")
|
|
||||||
.$type<"pending" | "approved">()
|
|
||||||
.default("approved")
|
|
||||||
},
|
|
||||||
(t) => [
|
|
||||||
index("idx_sites_exitnodeid").on(t.exitNodeId),
|
|
||||||
index("idx_sites_exitnode_type_siteid").on(
|
|
||||||
t.exitNodeId,
|
|
||||||
t.type,
|
|
||||||
t.siteId
|
|
||||||
)
|
|
||||||
]
|
|
||||||
);
|
|
||||||
|
|
||||||
export const resources = pgTable(
|
export const resources = pgTable("resources", {
|
||||||
"resources",
|
resourceId: serial("resourceId").primaryKey(),
|
||||||
{
|
resourcePolicyId: integer("resourcePolicyId").references(
|
||||||
resourceId: serial("resourceId").primaryKey(),
|
() => resourcePolicies.resourcePolicyId,
|
||||||
resourcePolicyId: integer("resourcePolicyId").references(
|
{ onDelete: "set null" }
|
||||||
() => resourcePolicies.resourcePolicyId,
|
),
|
||||||
{ onDelete: "set null" }
|
defaultResourcePolicyId: integer("defaultResourcePolicyId").references(
|
||||||
),
|
() => resourcePolicies.resourcePolicyId,
|
||||||
defaultResourcePolicyId: integer("defaultResourcePolicyId").references(
|
{
|
||||||
() => resourcePolicies.resourcePolicyId,
|
onDelete: "restrict"
|
||||||
{
|
}
|
||||||
onDelete: "restrict"
|
),
|
||||||
}
|
resourceGuid: varchar("resourceGuid", { length: 36 })
|
||||||
),
|
.unique()
|
||||||
resourceGuid: varchar("resourceGuid", { length: 36 })
|
.notNull()
|
||||||
.unique()
|
.$defaultFn(() => randomUUID()),
|
||||||
.notNull()
|
orgId: varchar("orgId")
|
||||||
.$defaultFn(() => randomUUID()),
|
.references(() => orgs.orgId, {
|
||||||
orgId: varchar("orgId")
|
onDelete: "cascade"
|
||||||
.references(() => orgs.orgId, {
|
})
|
||||||
onDelete: "cascade"
|
.notNull(),
|
||||||
})
|
niceId: text("niceId").notNull(),
|
||||||
.notNull(),
|
name: varchar("name").notNull(),
|
||||||
niceId: text("niceId").notNull(),
|
subdomain: varchar("subdomain"),
|
||||||
name: varchar("name").notNull(),
|
fullDomain: varchar("fullDomain"),
|
||||||
subdomain: varchar("subdomain"),
|
domainId: varchar("domainId").references(() => domains.domainId, {
|
||||||
fullDomain: varchar("fullDomain"),
|
onDelete: "set null"
|
||||||
domainId: varchar("domainId").references(() => domains.domainId, {
|
}),
|
||||||
onDelete: "set null"
|
ssl: boolean("ssl").notNull().default(false),
|
||||||
}),
|
blockAccess: boolean("blockAccess").notNull().default(false),
|
||||||
ssl: boolean("ssl").notNull().default(false),
|
proxyPort: integer("proxyPort"),
|
||||||
blockAccess: boolean("blockAccess").notNull().default(false),
|
sso: boolean("sso"),
|
||||||
proxyPort: integer("proxyPort"),
|
emailWhitelistEnabled: boolean("emailWhitelistEnabled"),
|
||||||
sso: boolean("sso"),
|
applyRules: boolean("applyRules"),
|
||||||
emailWhitelistEnabled: boolean("emailWhitelistEnabled"),
|
enabled: boolean("enabled").notNull().default(true),
|
||||||
applyRules: boolean("applyRules"),
|
stickySession: boolean("stickySession").notNull().default(false),
|
||||||
enabled: boolean("enabled").notNull().default(true),
|
tlsServerName: varchar("tlsServerName"),
|
||||||
stickySession: boolean("stickySession").notNull().default(false),
|
setHostHeader: varchar("setHostHeader"),
|
||||||
tlsServerName: varchar("tlsServerName"),
|
enableProxy: boolean("enableProxy").default(true),
|
||||||
setHostHeader: varchar("setHostHeader"),
|
skipToIdpId: integer("skipToIdpId").references(() => idp.idpId, {
|
||||||
enableProxy: boolean("enableProxy").default(true),
|
onDelete: "set null"
|
||||||
skipToIdpId: integer("skipToIdpId").references(() => idp.idpId, {
|
}),
|
||||||
onDelete: "set null"
|
headers: text("headers"), // comma-separated list of headers to add to the request
|
||||||
}),
|
proxyProtocol: boolean("proxyProtocol").notNull().default(false),
|
||||||
headers: text("headers"), // comma-separated list of headers to add to the request
|
proxyProtocolVersion: integer("proxyProtocolVersion").default(1),
|
||||||
proxyProtocol: boolean("proxyProtocol").notNull().default(false),
|
maintenanceModeEnabled: boolean("maintenanceModeEnabled")
|
||||||
proxyProtocolVersion: integer("proxyProtocolVersion").default(1),
|
.notNull()
|
||||||
maintenanceModeEnabled: boolean("maintenanceModeEnabled")
|
.default(false),
|
||||||
.notNull()
|
maintenanceModeType: text("maintenanceModeType", {
|
||||||
.default(false),
|
enum: ["forced", "automatic"]
|
||||||
maintenanceModeType: text("maintenanceModeType", {
|
}).default("forced"), // "forced" = always show, "automatic" = only when down
|
||||||
enum: ["forced", "automatic"]
|
maintenanceTitle: text("maintenanceTitle"),
|
||||||
}).default("forced"), // "forced" = always show, "automatic" = only when down
|
maintenanceMessage: text("maintenanceMessage"),
|
||||||
maintenanceTitle: text("maintenanceTitle"),
|
maintenanceEstimatedTime: text("maintenanceEstimatedTime"),
|
||||||
maintenanceMessage: text("maintenanceMessage"),
|
postAuthPath: text("postAuthPath"),
|
||||||
maintenanceEstimatedTime: text("maintenanceEstimatedTime"),
|
health: varchar("health").default("unknown"), // "healthy", "unhealthy", "unknown"
|
||||||
postAuthPath: text("postAuthPath"),
|
wildcard: boolean("wildcard").notNull().default(false),
|
||||||
health: varchar("health").default("unknown"), // "healthy", "unhealthy", "unknown"
|
mode: text("mode").default("http").notNull(), // rdp, ssh, http, vnc
|
||||||
wildcard: boolean("wildcard").notNull().default(false),
|
pamMode: varchar("pamMode", { length: 32 })
|
||||||
mode: text("mode").default("http").notNull(), // rdp, ssh, http, vnc
|
.$type<"passthrough" | "push">()
|
||||||
pamMode: varchar("pamMode", { length: 32 })
|
.default("passthrough"),
|
||||||
.$type<"passthrough" | "push">()
|
authDaemonMode: varchar("authDaemonMode", { length: 32 })
|
||||||
.default("passthrough"),
|
.$type<"site" | "remote" | "native">()
|
||||||
authDaemonMode: varchar("authDaemonMode", { length: 32 })
|
.default("site"),
|
||||||
.$type<"site" | "remote" | "native">()
|
authDaemonPort: integer("authDaemonPort").default(22123)
|
||||||
.default("site"),
|
});
|
||||||
authDaemonPort: integer("authDaemonPort").default(22123)
|
|
||||||
},
|
|
||||||
(t) => [
|
|
||||||
index("idx_resources_fulldomain")
|
|
||||||
.on(t.fullDomain)
|
|
||||||
.where(sql`${t.fullDomain} IS NOT NULL`)
|
|
||||||
]
|
|
||||||
);
|
|
||||||
|
|
||||||
export const labels = pgTable("labels", {
|
export const labels = pgTable("labels", {
|
||||||
labelId: serial("labelId").primaryKey(),
|
labelId: serial("labelId").primaryKey(),
|
||||||
@@ -290,84 +267,71 @@ export const clientLabels = pgTable(
|
|||||||
(t) => [unique("client_label_uniq").on(t.clientId, t.labelId)]
|
(t) => [unique("client_label_uniq").on(t.clientId, t.labelId)]
|
||||||
);
|
);
|
||||||
|
|
||||||
export const targets = pgTable(
|
export const targets = pgTable("targets", {
|
||||||
"targets",
|
targetId: serial("targetId").primaryKey(),
|
||||||
{
|
resourceId: integer("resourceId")
|
||||||
targetId: serial("targetId").primaryKey(),
|
.references(() => resources.resourceId, {
|
||||||
resourceId: integer("resourceId")
|
|
||||||
.references(() => resources.resourceId, {
|
|
||||||
onDelete: "cascade"
|
|
||||||
})
|
|
||||||
.notNull(),
|
|
||||||
siteId: integer("siteId")
|
|
||||||
.references(() => sites.siteId, {
|
|
||||||
onDelete: "cascade"
|
|
||||||
})
|
|
||||||
.notNull(),
|
|
||||||
ip: varchar("ip").notNull(),
|
|
||||||
method: varchar("method"),
|
|
||||||
port: integer("port").notNull(),
|
|
||||||
internalPort: integer("internalPort"),
|
|
||||||
enabled: boolean("enabled").notNull().default(true),
|
|
||||||
path: text("path"),
|
|
||||||
pathMatchType: text("pathMatchType"), // exact, prefix, regex
|
|
||||||
rewritePath: text("rewritePath"), // if set, rewrites the path to this value before sending to the target
|
|
||||||
rewritePathType: text("rewritePathType"), // exact, prefix, regex, stripPrefix
|
|
||||||
priority: integer("priority").notNull().default(100),
|
|
||||||
mode: varchar("mode")
|
|
||||||
.$type<"http" | "tcp" | "udp" | "ssh" | "rdp" | "vnc">()
|
|
||||||
.notNull()
|
|
||||||
.default("http"),
|
|
||||||
authToken: varchar("authToken")
|
|
||||||
},
|
|
||||||
(t) => [
|
|
||||||
index("idx_targets_resourceid_siteid").on(t.resourceId, t.siteId),
|
|
||||||
index("idx_targets_site_enabled_priority_target_resource")
|
|
||||||
.on(t.siteId, t.priority.desc(), t.targetId, t.resourceId)
|
|
||||||
.where(sql`${t.enabled} = true`)
|
|
||||||
]
|
|
||||||
);
|
|
||||||
|
|
||||||
export const targetHealthCheck = pgTable(
|
|
||||||
"targetHealthCheck",
|
|
||||||
{
|
|
||||||
targetHealthCheckId: serial("targetHealthCheckId").primaryKey(),
|
|
||||||
targetId: integer("targetId").references(() => targets.targetId, {
|
|
||||||
onDelete: "cascade"
|
onDelete: "cascade"
|
||||||
}),
|
})
|
||||||
orgId: varchar("orgId")
|
.notNull(),
|
||||||
.references(() => orgs.orgId, {
|
siteId: integer("siteId")
|
||||||
onDelete: "cascade"
|
.references(() => sites.siteId, {
|
||||||
})
|
onDelete: "cascade"
|
||||||
.notNull(),
|
})
|
||||||
siteId: integer("siteId")
|
.notNull(),
|
||||||
.references(() => sites.siteId, {
|
ip: varchar("ip").notNull(),
|
||||||
onDelete: "cascade"
|
method: varchar("method"),
|
||||||
})
|
port: integer("port").notNull(),
|
||||||
.notNull(),
|
internalPort: integer("internalPort"),
|
||||||
name: varchar("name"),
|
enabled: boolean("enabled").notNull().default(true),
|
||||||
hcEnabled: boolean("hcEnabled").notNull().default(false),
|
path: text("path"),
|
||||||
hcPath: varchar("hcPath"),
|
pathMatchType: text("pathMatchType"), // exact, prefix, regex
|
||||||
hcScheme: varchar("hcScheme"),
|
rewritePath: text("rewritePath"), // if set, rewrites the path to this value before sending to the target
|
||||||
hcMode: varchar("hcMode").default("http"),
|
rewritePathType: text("rewritePathType"), // exact, prefix, regex, stripPrefix
|
||||||
hcHostname: varchar("hcHostname"),
|
priority: integer("priority").notNull().default(100),
|
||||||
hcPort: integer("hcPort"),
|
mode: varchar("mode")
|
||||||
hcInterval: integer("hcInterval").default(30), // in seconds
|
.$type<"http" | "tcp" | "udp" | "ssh" | "rdp" | "vnc">()
|
||||||
hcUnhealthyInterval: integer("hcUnhealthyInterval").default(30), // in seconds
|
.notNull()
|
||||||
hcTimeout: integer("hcTimeout").default(5), // in seconds
|
.default("http"),
|
||||||
hcHeaders: varchar("hcHeaders"),
|
authToken: varchar("authToken")
|
||||||
hcFollowRedirects: boolean("hcFollowRedirects").default(true),
|
});
|
||||||
hcMethod: varchar("hcMethod").default("GET"),
|
|
||||||
hcStatus: integer("hcStatus"), // http code
|
export const targetHealthCheck = pgTable("targetHealthCheck", {
|
||||||
hcHealth: text("hcHealth")
|
targetHealthCheckId: serial("targetHealthCheckId").primaryKey(),
|
||||||
.$type<"unknown" | "healthy" | "unhealthy">()
|
targetId: integer("targetId").references(() => targets.targetId, {
|
||||||
.default("unknown"), // "unknown", "healthy", "unhealthy"
|
onDelete: "cascade"
|
||||||
hcTlsServerName: text("hcTlsServerName"),
|
}),
|
||||||
hcHealthyThreshold: integer("hcHealthyThreshold").default(1),
|
orgId: varchar("orgId")
|
||||||
hcUnhealthyThreshold: integer("hcUnhealthyThreshold").default(1)
|
.references(() => orgs.orgId, {
|
||||||
},
|
onDelete: "cascade"
|
||||||
(t) => [index("idx_targethealthcheck_targetid").on(t.targetId)]
|
})
|
||||||
);
|
.notNull(),
|
||||||
|
siteId: integer("siteId")
|
||||||
|
.references(() => sites.siteId, {
|
||||||
|
onDelete: "cascade"
|
||||||
|
})
|
||||||
|
.notNull(),
|
||||||
|
name: varchar("name"),
|
||||||
|
hcEnabled: boolean("hcEnabled").notNull().default(false),
|
||||||
|
hcPath: varchar("hcPath"),
|
||||||
|
hcScheme: varchar("hcScheme"),
|
||||||
|
hcMode: varchar("hcMode").default("http"),
|
||||||
|
hcHostname: varchar("hcHostname"),
|
||||||
|
hcPort: integer("hcPort"),
|
||||||
|
hcInterval: integer("hcInterval").default(30), // in seconds
|
||||||
|
hcUnhealthyInterval: integer("hcUnhealthyInterval").default(30), // in seconds
|
||||||
|
hcTimeout: integer("hcTimeout").default(5), // in seconds
|
||||||
|
hcHeaders: varchar("hcHeaders"),
|
||||||
|
hcFollowRedirects: boolean("hcFollowRedirects").default(true),
|
||||||
|
hcMethod: varchar("hcMethod").default("GET"),
|
||||||
|
hcStatus: integer("hcStatus"), // http code
|
||||||
|
hcHealth: text("hcHealth")
|
||||||
|
.$type<"unknown" | "healthy" | "unhealthy">()
|
||||||
|
.default("unknown"), // "unknown", "healthy", "unhealthy"
|
||||||
|
hcTlsServerName: text("hcTlsServerName"),
|
||||||
|
hcHealthyThreshold: integer("hcHealthyThreshold").default(1),
|
||||||
|
hcUnhealthyThreshold: integer("hcUnhealthyThreshold").default(1)
|
||||||
|
});
|
||||||
|
|
||||||
export const exitNodes = pgTable("exitNodes", {
|
export const exitNodes = pgTable("exitNodes", {
|
||||||
exitNodeId: serial("exitNodeId").primaryKey(),
|
exitNodeId: serial("exitNodeId").primaryKey(),
|
||||||
@@ -442,74 +406,43 @@ export const networks = pgTable("networks", {
|
|||||||
.notNull()
|
.notNull()
|
||||||
});
|
});
|
||||||
|
|
||||||
export const siteNetworks = pgTable(
|
export const siteNetworks = pgTable("siteNetworks", {
|
||||||
"siteNetworks",
|
siteId: integer("siteId")
|
||||||
{
|
.notNull()
|
||||||
siteId: integer("siteId")
|
.references(() => sites.siteId, {
|
||||||
.notNull()
|
onDelete: "cascade"
|
||||||
.references(() => sites.siteId, {
|
}),
|
||||||
onDelete: "cascade"
|
networkId: integer("networkId")
|
||||||
}),
|
.notNull()
|
||||||
networkId: integer("networkId")
|
.references(() => networks.networkId, { onDelete: "cascade" })
|
||||||
.notNull()
|
});
|
||||||
.references(() => networks.networkId, { onDelete: "cascade" })
|
|
||||||
},
|
|
||||||
(t) => [
|
|
||||||
index("idx_sitenetworks_siteid").on(t.siteId),
|
|
||||||
index("idx_sitenetworks_networkid").on(t.networkId)
|
|
||||||
]
|
|
||||||
);
|
|
||||||
|
|
||||||
export const clientSiteResources = pgTable(
|
export const clientSiteResources = pgTable("clientSiteResources", {
|
||||||
"clientSiteResources",
|
clientId: integer("clientId")
|
||||||
{
|
.notNull()
|
||||||
clientId: integer("clientId")
|
.references(() => clients.clientId, { onDelete: "cascade" }),
|
||||||
.notNull()
|
siteResourceId: integer("siteResourceId")
|
||||||
.references(() => clients.clientId, { onDelete: "cascade" }),
|
.notNull()
|
||||||
siteResourceId: integer("siteResourceId")
|
.references(() => siteResources.siteResourceId, { onDelete: "cascade" })
|
||||||
.notNull()
|
});
|
||||||
.references(() => siteResources.siteResourceId, {
|
|
||||||
onDelete: "cascade"
|
|
||||||
})
|
|
||||||
},
|
|
||||||
(t) => [
|
|
||||||
index("idx_clientsiteresources_clientid").on(t.clientId),
|
|
||||||
index("idx_clientsiteresources_siteresourceid").on(t.siteResourceId)
|
|
||||||
]
|
|
||||||
);
|
|
||||||
|
|
||||||
export const roleSiteResources = pgTable(
|
export const roleSiteResources = pgTable("roleSiteResources", {
|
||||||
"roleSiteResources",
|
roleId: integer("roleId")
|
||||||
{
|
.notNull()
|
||||||
roleId: integer("roleId")
|
.references(() => roles.roleId, { onDelete: "cascade" }),
|
||||||
.notNull()
|
siteResourceId: integer("siteResourceId")
|
||||||
.references(() => roles.roleId, { onDelete: "cascade" }),
|
.notNull()
|
||||||
siteResourceId: integer("siteResourceId")
|
.references(() => siteResources.siteResourceId, { onDelete: "cascade" })
|
||||||
.notNull()
|
});
|
||||||
.references(() => siteResources.siteResourceId, {
|
|
||||||
onDelete: "cascade"
|
|
||||||
})
|
|
||||||
},
|
|
||||||
(t) => [index("idx_rolesiteresources_siteresourceid").on(t.siteResourceId)]
|
|
||||||
);
|
|
||||||
|
|
||||||
export const userSiteResources = pgTable(
|
export const userSiteResources = pgTable("userSiteResources", {
|
||||||
"userSiteResources",
|
userId: varchar("userId")
|
||||||
{
|
.notNull()
|
||||||
userId: varchar("userId")
|
.references(() => users.userId, { onDelete: "cascade" }),
|
||||||
.notNull()
|
siteResourceId: integer("siteResourceId")
|
||||||
.references(() => users.userId, { onDelete: "cascade" }),
|
.notNull()
|
||||||
siteResourceId: integer("siteResourceId")
|
.references(() => siteResources.siteResourceId, { onDelete: "cascade" })
|
||||||
.notNull()
|
});
|
||||||
.references(() => siteResources.siteResourceId, {
|
|
||||||
onDelete: "cascade"
|
|
||||||
})
|
|
||||||
},
|
|
||||||
(t) => [
|
|
||||||
index("idx_usersiteresources_userid").on(t.userId),
|
|
||||||
index("idx_usersiteresources_siteresourceid").on(t.siteResourceId)
|
|
||||||
]
|
|
||||||
);
|
|
||||||
|
|
||||||
export const users = pgTable("user", {
|
export const users = pgTable("user", {
|
||||||
userId: varchar("id").primaryKey(),
|
userId: varchar("id").primaryKey(),
|
||||||
@@ -534,19 +467,15 @@ export const users = pgTable("user", {
|
|||||||
locale: varchar("locale")
|
locale: varchar("locale")
|
||||||
});
|
});
|
||||||
|
|
||||||
export const newts = pgTable(
|
export const newts = pgTable("newt", {
|
||||||
"newt",
|
newtId: varchar("id").primaryKey(),
|
||||||
{
|
secretHash: varchar("secretHash").notNull(),
|
||||||
newtId: varchar("id").primaryKey(),
|
dateCreated: varchar("dateCreated").notNull(),
|
||||||
secretHash: varchar("secretHash").notNull(),
|
version: varchar("version"),
|
||||||
dateCreated: varchar("dateCreated").notNull(),
|
siteId: integer("siteId").references(() => sites.siteId, {
|
||||||
version: varchar("version"),
|
onDelete: "cascade"
|
||||||
siteId: integer("siteId").references(() => sites.siteId, {
|
})
|
||||||
onDelete: "cascade"
|
});
|
||||||
})
|
|
||||||
},
|
|
||||||
(t) => [index("idx_newt_siteid").on(t.siteId)]
|
|
||||||
);
|
|
||||||
|
|
||||||
export const twoFactorBackupCodes = pgTable("twoFactorBackupCodes", {
|
export const twoFactorBackupCodes = pgTable("twoFactorBackupCodes", {
|
||||||
codeId: serial("id").primaryKey(),
|
codeId: serial("id").primaryKey(),
|
||||||
@@ -647,49 +576,29 @@ export const userOrgRoles = pgTable(
|
|||||||
(t) => [unique().on(t.userId, t.orgId, t.roleId)]
|
(t) => [unique().on(t.userId, t.orgId, t.roleId)]
|
||||||
);
|
);
|
||||||
|
|
||||||
export const roleActions = pgTable(
|
export const roleActions = pgTable("roleActions", {
|
||||||
"roleActions",
|
roleId: integer("roleId")
|
||||||
{
|
.notNull()
|
||||||
roleId: integer("roleId")
|
.references(() => roles.roleId, { onDelete: "cascade" }),
|
||||||
.notNull()
|
actionId: varchar("actionId")
|
||||||
.references(() => roles.roleId, { onDelete: "cascade" }),
|
.notNull()
|
||||||
actionId: varchar("actionId")
|
.references(() => actions.actionId, { onDelete: "cascade" }),
|
||||||
.notNull()
|
orgId: varchar("orgId")
|
||||||
.references(() => actions.actionId, { onDelete: "cascade" }),
|
.notNull()
|
||||||
orgId: varchar("orgId")
|
.references(() => orgs.orgId, { onDelete: "cascade" })
|
||||||
.notNull()
|
});
|
||||||
.references(() => orgs.orgId, { onDelete: "cascade" })
|
|
||||||
},
|
|
||||||
(t) => [
|
|
||||||
index("idx_roleActions_roleId_orgId_actionId").on(
|
|
||||||
t.roleId,
|
|
||||||
t.orgId,
|
|
||||||
t.actionId
|
|
||||||
)
|
|
||||||
]
|
|
||||||
);
|
|
||||||
|
|
||||||
export const userActions = pgTable(
|
export const userActions = pgTable("userActions", {
|
||||||
"userActions",
|
userId: varchar("userId")
|
||||||
{
|
.notNull()
|
||||||
userId: varchar("userId")
|
.references(() => users.userId, { onDelete: "cascade" }),
|
||||||
.notNull()
|
actionId: varchar("actionId")
|
||||||
.references(() => users.userId, { onDelete: "cascade" }),
|
.notNull()
|
||||||
actionId: varchar("actionId")
|
.references(() => actions.actionId, { onDelete: "cascade" }),
|
||||||
.notNull()
|
orgId: varchar("orgId")
|
||||||
.references(() => actions.actionId, { onDelete: "cascade" }),
|
.notNull()
|
||||||
orgId: varchar("orgId")
|
.references(() => orgs.orgId, { onDelete: "cascade" })
|
||||||
.notNull()
|
});
|
||||||
.references(() => orgs.orgId, { onDelete: "cascade" })
|
|
||||||
},
|
|
||||||
(t) => [
|
|
||||||
index("idx_userActions_userId_orgId_actionId").on(
|
|
||||||
t.userId,
|
|
||||||
t.orgId,
|
|
||||||
t.actionId
|
|
||||||
)
|
|
||||||
]
|
|
||||||
);
|
|
||||||
|
|
||||||
export const roleSites = pgTable("roleSites", {
|
export const roleSites = pgTable("roleSites", {
|
||||||
roleId: integer("roleId")
|
roleId: integer("roleId")
|
||||||
@@ -1095,44 +1004,40 @@ export const idpOrg = pgTable("idpOrg", {
|
|||||||
orgMapping: varchar("orgMapping")
|
orgMapping: varchar("orgMapping")
|
||||||
});
|
});
|
||||||
|
|
||||||
export const clients = pgTable(
|
export const clients = pgTable("clients", {
|
||||||
"clients",
|
clientId: serial("clientId").primaryKey(),
|
||||||
{
|
orgId: varchar("orgId")
|
||||||
clientId: serial("clientId").primaryKey(),
|
.references(() => orgs.orgId, {
|
||||||
orgId: varchar("orgId")
|
|
||||||
.references(() => orgs.orgId, {
|
|
||||||
onDelete: "cascade"
|
|
||||||
})
|
|
||||||
.notNull(),
|
|
||||||
exitNodeId: integer("exitNode").references(() => exitNodes.exitNodeId, {
|
|
||||||
onDelete: "set null"
|
|
||||||
}),
|
|
||||||
userId: text("userId").references(() => users.userId, {
|
|
||||||
// optionally tied to a user and in this case delete when the user deletes
|
|
||||||
onDelete: "cascade"
|
onDelete: "cascade"
|
||||||
}),
|
})
|
||||||
niceId: varchar("niceId").notNull(),
|
.notNull(),
|
||||||
olmId: text("olmId"), // to lock it to a specific olm optionally
|
exitNodeId: integer("exitNode").references(() => exitNodes.exitNodeId, {
|
||||||
name: varchar("name").notNull(),
|
onDelete: "set null"
|
||||||
pubKey: varchar("pubKey"),
|
}),
|
||||||
subnet: varchar("subnet").notNull(),
|
userId: text("userId").references(() => users.userId, {
|
||||||
megabytesIn: real("bytesIn"),
|
// optionally tied to a user and in this case delete when the user deletes
|
||||||
megabytesOut: real("bytesOut"),
|
onDelete: "cascade"
|
||||||
lastBandwidthUpdate: varchar("lastBandwidthUpdate"),
|
}),
|
||||||
lastPing: integer("lastPing"),
|
niceId: varchar("niceId").notNull(),
|
||||||
type: varchar("type").notNull(), // "olm"
|
olmId: text("olmId"), // to lock it to a specific olm optionally
|
||||||
online: boolean("online").notNull().default(false),
|
name: varchar("name").notNull(),
|
||||||
// endpoint: varchar("endpoint"),
|
pubKey: varchar("pubKey"),
|
||||||
lastHolePunch: integer("lastHolePunch"),
|
subnet: varchar("subnet").notNull(),
|
||||||
maxConnections: integer("maxConnections"),
|
megabytesIn: real("bytesIn"),
|
||||||
archived: boolean("archived").notNull().default(false),
|
megabytesOut: real("bytesOut"),
|
||||||
blocked: boolean("blocked").notNull().default(false),
|
lastBandwidthUpdate: varchar("lastBandwidthUpdate"),
|
||||||
approvalState: varchar("approvalState").$type<
|
lastPing: integer("lastPing"),
|
||||||
"pending" | "approved" | "denied"
|
type: varchar("type").notNull(), // "olm"
|
||||||
>()
|
online: boolean("online").notNull().default(false),
|
||||||
},
|
// endpoint: varchar("endpoint"),
|
||||||
(t) => [index("idx_clients_userid").on(t.userId)]
|
lastHolePunch: integer("lastHolePunch"),
|
||||||
);
|
maxConnections: integer("maxConnections"),
|
||||||
|
archived: boolean("archived").notNull().default(false),
|
||||||
|
blocked: boolean("blocked").notNull().default(false),
|
||||||
|
approvalState: varchar("approvalState").$type<
|
||||||
|
"pending" | "approved" | "denied"
|
||||||
|
>()
|
||||||
|
});
|
||||||
|
|
||||||
export const clientSitesAssociationsCache = pgTable(
|
export const clientSitesAssociationsCache = pgTable(
|
||||||
"clientSitesAssociationsCache",
|
"clientSitesAssociationsCache",
|
||||||
@@ -1144,11 +1049,7 @@ export const clientSitesAssociationsCache = pgTable(
|
|||||||
isJitMode: boolean("isJitMode").notNull().default(false),
|
isJitMode: boolean("isJitMode").notNull().default(false),
|
||||||
endpoint: varchar("endpoint"),
|
endpoint: varchar("endpoint"),
|
||||||
publicKey: varchar("publicKey") // this will act as the session's public key for hole punching so we can track when it changes
|
publicKey: varchar("publicKey") // this will act as the session's public key for hole punching so we can track when it changes
|
||||||
},
|
}
|
||||||
(t) => [
|
|
||||||
primaryKey({ columns: [t.clientId, t.siteId] }),
|
|
||||||
index("idx_clientsitesassociationscache_siteid").on(t.siteId)
|
|
||||||
]
|
|
||||||
);
|
);
|
||||||
|
|
||||||
export const clientSiteResourcesAssociationsCache = pgTable(
|
export const clientSiteResourcesAssociationsCache = pgTable(
|
||||||
@@ -1157,14 +1058,7 @@ export const clientSiteResourcesAssociationsCache = pgTable(
|
|||||||
clientId: integer("clientId") // not a foreign key here so after its deleted the rebuild function can delete it and send the message
|
clientId: integer("clientId") // not a foreign key here so after its deleted the rebuild function can delete it and send the message
|
||||||
.notNull(),
|
.notNull(),
|
||||||
siteResourceId: integer("siteResourceId").notNull()
|
siteResourceId: integer("siteResourceId").notNull()
|
||||||
},
|
}
|
||||||
(t) => [
|
|
||||||
primaryKey({ columns: [t.clientId, t.siteResourceId] }),
|
|
||||||
index("idx_clientSiteResourcesAssociationsCache_siteResourceId").on(
|
|
||||||
t.siteResourceId,
|
|
||||||
t.clientId
|
|
||||||
)
|
|
||||||
]
|
|
||||||
);
|
);
|
||||||
|
|
||||||
export const clientPostureSnapshots = pgTable("clientPostureSnapshots", {
|
export const clientPostureSnapshots = pgTable("clientPostureSnapshots", {
|
||||||
@@ -1177,27 +1071,23 @@ export const clientPostureSnapshots = pgTable("clientPostureSnapshots", {
|
|||||||
collectedAt: integer("collectedAt").notNull()
|
collectedAt: integer("collectedAt").notNull()
|
||||||
});
|
});
|
||||||
|
|
||||||
export const olms = pgTable(
|
export const olms = pgTable("olms", {
|
||||||
"olms",
|
olmId: varchar("id").primaryKey(),
|
||||||
{
|
secretHash: varchar("secretHash").notNull(),
|
||||||
olmId: varchar("id").primaryKey(),
|
dateCreated: varchar("dateCreated").notNull(),
|
||||||
secretHash: varchar("secretHash").notNull(),
|
version: text("version"),
|
||||||
dateCreated: varchar("dateCreated").notNull(),
|
agent: text("agent"),
|
||||||
version: text("version"),
|
name: varchar("name"),
|
||||||
agent: text("agent"),
|
clientId: integer("clientId").references(() => clients.clientId, {
|
||||||
name: varchar("name"),
|
// we will switch this depending on the current org it wants to connect to
|
||||||
clientId: integer("clientId").references(() => clients.clientId, {
|
onDelete: "set null"
|
||||||
// we will switch this depending on the current org it wants to connect to
|
}),
|
||||||
onDelete: "set null"
|
userId: text("userId").references(() => users.userId, {
|
||||||
}),
|
// optionally tied to a user and in this case delete when the user deletes
|
||||||
userId: text("userId").references(() => users.userId, {
|
onDelete: "cascade"
|
||||||
// optionally tied to a user and in this case delete when the user deletes
|
}),
|
||||||
onDelete: "cascade"
|
archived: boolean("archived").notNull().default(false)
|
||||||
}),
|
});
|
||||||
archived: boolean("archived").notNull().default(false)
|
|
||||||
},
|
|
||||||
(t) => [index("idx_olms_clientid").on(t.clientId)]
|
|
||||||
);
|
|
||||||
|
|
||||||
export const currentFingerprint = pgTable("currentFingerprint", {
|
export const currentFingerprint = pgTable("currentFingerprint", {
|
||||||
fingerprintId: serial("id").primaryKey(),
|
fingerprintId: serial("id").primaryKey(),
|
||||||
|
|||||||
+50
-12
@@ -1,5 +1,6 @@
|
|||||||
import { drizzle as DrizzleSqlite } from "drizzle-orm/better-sqlite3";
|
import { drizzle as DrizzleSqlite } from "drizzle-orm/better-sqlite3";
|
||||||
import Database from "better-sqlite3";
|
import Database from "better-sqlite3";
|
||||||
|
import type BetterSqlite3 from "better-sqlite3";
|
||||||
import * as schema from "./schema/schema";
|
import * as schema from "./schema/schema";
|
||||||
import path from "path";
|
import path from "path";
|
||||||
import fs from "fs";
|
import fs from "fs";
|
||||||
@@ -11,31 +12,68 @@ export const exists = checkFileExists(location);
|
|||||||
|
|
||||||
bootstrapVolume();
|
bootstrapVolume();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wraps better-sqlite3 Statement to call `finalize()` immediately after
|
||||||
|
* execution, freeing native sqlite3_stmt memory deterministically instead
|
||||||
|
* of waiting for GC. Fixes steady off-heap growth under load (#2120).
|
||||||
|
* WARNING: Finalizes after first execution — incompatible with drizzle's
|
||||||
|
* reusable .prepare() builders. No such usage exists in this codebase.
|
||||||
|
*/
|
||||||
|
function autoFinalizeStatement(
|
||||||
|
stmt: BetterSqlite3.Statement
|
||||||
|
): BetterSqlite3.Statement {
|
||||||
|
const wrapExec = <T extends (...args: any[]) => any>(fn: T): T => {
|
||||||
|
return function (this: any, ...args: any[]) {
|
||||||
|
try {
|
||||||
|
return fn.apply(this, args);
|
||||||
|
} finally {
|
||||||
|
try {
|
||||||
|
// finalize() exists on the native Statement at runtime but
|
||||||
|
// is missing from @types/better-sqlite3.
|
||||||
|
(stmt as any).finalize();
|
||||||
|
} catch {
|
||||||
|
// Already finalized — harmless
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} as unknown as T;
|
||||||
|
};
|
||||||
|
|
||||||
|
stmt.run = wrapExec(stmt.run);
|
||||||
|
stmt.get = wrapExec(stmt.get);
|
||||||
|
stmt.all = wrapExec(stmt.all);
|
||||||
|
|
||||||
|
return stmt;
|
||||||
|
}
|
||||||
|
|
||||||
function createDb() {
|
function createDb() {
|
||||||
const sqlite = new Database(location);
|
const sqlite = new Database(location);
|
||||||
|
|
||||||
if (process.env.ENABLE_SQLITE_WAL_MODE == "true") {
|
if (process.env.ENABLE_SQLITE_WAL_MODE == "true") {
|
||||||
// Enable WAL mode — allows concurrent readers + single writer, preventing
|
// Enable WAL mode — allows concurrent readers + single writer, preventing
|
||||||
// contention across subsystems (verifySession, Traefik, audit, ping).
|
// contention across subsystems (verifySession, Traefik, audit, ping).
|
||||||
// NOTE: journal_mode persists in the DB file once set; unsetting this
|
|
||||||
// env var does NOT revert an existing WAL database.
|
|
||||||
sqlite.pragma("journal_mode = WAL");
|
sqlite.pragma("journal_mode = WAL");
|
||||||
// NORMAL sync mode: safe with WAL, reduces write lock hold time.
|
// NORMAL sync mode: safe with WAL, reduces write lock hold time.
|
||||||
sqlite.pragma("synchronous = NORMAL");
|
sqlite.pragma("synchronous = NORMAL");
|
||||||
}
|
}
|
||||||
|
|
||||||
// No busy_timeout pragma: better-sqlite3 already arms
|
// Wait up to 5s on SQLITE_BUSY instead of failing — prevents audit log
|
||||||
// sqlite3_busy_timeout(db, 5000) via its default `timeout` option
|
// retry loops that accumulate memory.
|
||||||
// (lib/database.js), so an explicit pragma is redundant.
|
sqlite.pragma("busy_timeout = 5000");
|
||||||
|
|
||||||
// Intentionally NOT setting cache_size or mmap_size: a large page cache plus
|
// 64 MB page cache (default 2 MB) — reduces I/O round-trips on large
|
||||||
// a multi-hundred-MB mmap region inflate RSS and cause page-cache thrashing
|
// TraefikConfigManager JOINs that block the event loop.
|
||||||
// on small (~1 GB) instances. Leave SQLite on its conservative defaults.
|
sqlite.pragma("cache_size = -65536");
|
||||||
|
|
||||||
// Intentionally NOT wrapping prepare()/statements: better-sqlite3 finalizes
|
// 256 MB memory-mapped I/O — OS serves reads from page cache directly,
|
||||||
// sqlite3_stmt in the Statement destructor at GC, and drizzle-orm prepares a
|
// reducing event-loop blocking.
|
||||||
// fresh statement per query (no statement cache), so statements cannot
|
sqlite.pragma("mmap_size = 268435456");
|
||||||
// accumulate. better-sqlite3 11.x exposes no Statement.finalize() at all.
|
|
||||||
|
// Wrap prepare() so every drizzle-orm statement is auto-finalized after
|
||||||
|
// first use, preventing sqlite3_stmt accumulation between GC cycles.
|
||||||
|
const originalPrepare = sqlite.prepare.bind(sqlite);
|
||||||
|
(sqlite as any).prepare = function autoFinalizePrepare(source: string) {
|
||||||
|
return autoFinalizeStatement(originalPrepare(source));
|
||||||
|
};
|
||||||
|
|
||||||
return DrizzleSqlite(sqlite, {
|
return DrizzleSqlite(sqlite, {
|
||||||
schema
|
schema
|
||||||
|
|||||||
@@ -12,7 +12,6 @@ import {
|
|||||||
clients,
|
clients,
|
||||||
domains,
|
domains,
|
||||||
exitNodes,
|
exitNodes,
|
||||||
labels,
|
|
||||||
orgs,
|
orgs,
|
||||||
resources,
|
resources,
|
||||||
roles,
|
roles,
|
||||||
@@ -22,6 +21,9 @@ import {
|
|||||||
targetHealthCheck,
|
targetHealthCheck,
|
||||||
users
|
users
|
||||||
} from "./schema";
|
} from "./schema";
|
||||||
|
import { serial, varchar } from "drizzle-orm/mysql-core";
|
||||||
|
import { pgTable } from "drizzle-orm/pg-core";
|
||||||
|
import { bigint } from "zod";
|
||||||
|
|
||||||
export const certificates = sqliteTable("certificates", {
|
export const certificates = sqliteTable("certificates", {
|
||||||
certId: integer("certId").primaryKey({ autoIncrement: true }),
|
certId: integer("certId").primaryKey({ autoIncrement: true }),
|
||||||
@@ -193,44 +195,6 @@ export const remoteExitNodes = sqliteTable("remoteExitNode", {
|
|||||||
})
|
})
|
||||||
});
|
});
|
||||||
|
|
||||||
export const remoteExitNodeResources = sqliteTable("remoteExitNodeResources", {
|
|
||||||
remoteExitNodeResourceId: integer("remoteExitNodeResourceId").primaryKey({
|
|
||||||
autoIncrement: true
|
|
||||||
}),
|
|
||||||
remoteExitNodeId: text("remoteExitNodeId")
|
|
||||||
.notNull()
|
|
||||||
.references(() => remoteExitNodes.remoteExitNodeId, {
|
|
||||||
onDelete: "cascade"
|
|
||||||
}),
|
|
||||||
destination: text("destination").notNull() // a cidr range
|
|
||||||
});
|
|
||||||
|
|
||||||
export const remoteExitNodePreferenceLabels = sqliteTable(
|
|
||||||
// this controls what sites are enforced to connect to this node
|
|
||||||
"remoteExitNodePreferenceLabels",
|
|
||||||
{
|
|
||||||
remoteExitNodePreferenceLabelId: integer(
|
|
||||||
"remoteExitNodePreferenceLabelId"
|
|
||||||
).primaryKey({ autoIncrement: true }),
|
|
||||||
remoteExitNodeId: text("remoteExitNodeId")
|
|
||||||
.references(() => remoteExitNodes.remoteExitNodeId, {
|
|
||||||
onDelete: "cascade"
|
|
||||||
})
|
|
||||||
.notNull(),
|
|
||||||
labelId: integer("labelId")
|
|
||||||
.references(() => labels.labelId, {
|
|
||||||
onDelete: "cascade"
|
|
||||||
})
|
|
||||||
.notNull()
|
|
||||||
},
|
|
||||||
(t) => [
|
|
||||||
uniqueIndex("remote_exit_node_preference_label_uniq").on(
|
|
||||||
t.remoteExitNodeId,
|
|
||||||
t.labelId
|
|
||||||
)
|
|
||||||
]
|
|
||||||
);
|
|
||||||
|
|
||||||
export const remoteExitNodeSessions = sqliteTable("remoteExitNodeSession", {
|
export const remoteExitNodeSessions = sqliteTable("remoteExitNodeSession", {
|
||||||
sessionId: text("id").primaryKey(),
|
sessionId: text("id").primaryKey(),
|
||||||
remoteExitNodeId: text("remoteExitNodeId")
|
remoteExitNodeId: text("remoteExitNodeId")
|
||||||
|
|||||||
@@ -24,7 +24,6 @@ import license from "#dynamic/license/license";
|
|||||||
import { initLogCleanupInterval } from "@server/lib/cleanupLogs";
|
import { initLogCleanupInterval } from "@server/lib/cleanupLogs";
|
||||||
import { initAcmeCertSync } from "#dynamic/lib/acmeCertSync";
|
import { initAcmeCertSync } from "#dynamic/lib/acmeCertSync";
|
||||||
import { fetchServerIp } from "@server/lib/serverIpService";
|
import { fetchServerIp } from "@server/lib/serverIpService";
|
||||||
import { startRebuildQueueProcessor } from "@server/lib/rebuildClientAssociations";
|
|
||||||
|
|
||||||
async function startServers() {
|
async function startServers() {
|
||||||
await setHostMeta();
|
await setHostMeta();
|
||||||
@@ -42,7 +41,6 @@ async function startServers() {
|
|||||||
|
|
||||||
initLogCleanupInterval();
|
initLogCleanupInterval();
|
||||||
initAcmeCertSync();
|
initAcmeCertSync();
|
||||||
startRebuildQueueProcessor();
|
|
||||||
|
|
||||||
// Start all servers
|
// Start all servers
|
||||||
const apiServer = createApiServer();
|
const apiServer = createApiServer();
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ import {
|
|||||||
import { FeatureId, getFeatureMeterId } from "./features";
|
import { FeatureId, getFeatureMeterId } from "./features";
|
||||||
import logger from "@server/logger";
|
import logger from "@server/logger";
|
||||||
import { build } from "@server/build";
|
import { build } from "@server/build";
|
||||||
import { regionalCache as cache } from "#dynamic/lib/cache";
|
import cache from "#dynamic/lib/cache";
|
||||||
|
|
||||||
export function noop() {
|
export function noop() {
|
||||||
if (build !== "saas") {
|
if (build !== "saas") {
|
||||||
@@ -22,6 +22,7 @@ export function noop() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export class UsageService {
|
export class UsageService {
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
if (noop()) {
|
if (noop()) {
|
||||||
return;
|
return;
|
||||||
@@ -56,10 +57,7 @@ export class UsageService {
|
|||||||
try {
|
try {
|
||||||
let usage;
|
let usage;
|
||||||
if (transaction) {
|
if (transaction) {
|
||||||
const orgIdToUse = await this.getBillingOrg(
|
const orgIdToUse = await this.getBillingOrg(orgId, transaction);
|
||||||
orgId,
|
|
||||||
transaction
|
|
||||||
);
|
|
||||||
usage = await this.internalAddUsage(
|
usage = await this.internalAddUsage(
|
||||||
orgIdToUse,
|
orgIdToUse,
|
||||||
featureId,
|
featureId,
|
||||||
@@ -276,12 +274,11 @@ export class UsageService {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
let orgIdToUse = orgId;
|
const orgIdToUse = await this.getBillingOrg(orgId, trx);
|
||||||
|
|
||||||
|
const usageId = `${orgIdToUse}-${featureId}`;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
orgIdToUse = await this.getBillingOrg(orgId, trx);
|
|
||||||
|
|
||||||
const usageId = `${orgIdToUse}-${featureId}`;
|
|
||||||
|
|
||||||
const [result] = await trx
|
const [result] = await trx
|
||||||
.select()
|
.select()
|
||||||
.from(usage)
|
.from(usage)
|
||||||
@@ -341,12 +338,8 @@ export class UsageService {
|
|||||||
`Failed to get usage for ${orgIdToUse}/${featureId}:`,
|
`Failed to get usage for ${orgIdToUse}/${featureId}:`,
|
||||||
error
|
error
|
||||||
);
|
);
|
||||||
if (process.env.NODE_ENV !== "development") {
|
throw error;
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public async getBillingOrg(
|
public async getBillingOrg(
|
||||||
@@ -389,13 +382,13 @@ export class UsageService {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const orgIdToUse = await this.getBillingOrg(orgId, trx);
|
||||||
|
|
||||||
// This method should check the current usage against the limits set for the organization
|
// This method should check the current usage against the limits set for the organization
|
||||||
// and kick out all of the sites on the org
|
// and kick out all of the sites on the org
|
||||||
let hasExceededLimits = false;
|
let hasExceededLimits = false;
|
||||||
let orgIdToUse = orgId;
|
|
||||||
try {
|
|
||||||
orgIdToUse = await this.getBillingOrg(orgId, trx);
|
|
||||||
|
|
||||||
|
try {
|
||||||
let orgLimits: Limit[] = [];
|
let orgLimits: Limit[] = [];
|
||||||
if (featureId) {
|
if (featureId) {
|
||||||
// Get all limits set for this organization
|
// Get all limits set for this organization
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import {
|
|||||||
newts,
|
newts,
|
||||||
blueprints,
|
blueprints,
|
||||||
Blueprint,
|
Blueprint,
|
||||||
|
Site,
|
||||||
siteResources,
|
siteResources,
|
||||||
roleSiteResources,
|
roleSiteResources,
|
||||||
userSiteResources,
|
userSiteResources,
|
||||||
@@ -29,11 +30,8 @@ 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 {
|
import { handleMessagingForUpdatedSiteResource } from "@server/routers/siteResource";
|
||||||
handleMessagingForUpdatedSiteResource,
|
import { rebuildClientAssociationsFromSiteResource } from "../rebuildClientAssociations";
|
||||||
rebuildClientAssociationsFromSiteResource,
|
|
||||||
waitForSiteResourceRebuildIdle
|
|
||||||
} from "../rebuildClientAssociations";
|
|
||||||
|
|
||||||
type ApplyBlueprintArgs = {
|
type ApplyBlueprintArgs = {
|
||||||
orgId: string;
|
orgId: string;
|
||||||
@@ -50,38 +48,42 @@ export async function applyBlueprint({
|
|||||||
name,
|
name,
|
||||||
source = "API"
|
source = "API"
|
||||||
}: ApplyBlueprintArgs): Promise<Blueprint> {
|
}: ApplyBlueprintArgs): Promise<Blueprint> {
|
||||||
|
// Validate the input data
|
||||||
|
const validationResult = ConfigSchema.safeParse(configData);
|
||||||
|
if (!validationResult.success) {
|
||||||
|
throw new Error(fromError(validationResult.error).toString());
|
||||||
|
}
|
||||||
|
|
||||||
|
const config: Config = validationResult.data;
|
||||||
let blueprintSucceeded: boolean = false;
|
let blueprintSucceeded: boolean = false;
|
||||||
let blueprintMessage = "";
|
let blueprintMessage: string;
|
||||||
let error: any | null = null;
|
let error: any | null = null;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const validationResult = ConfigSchema.safeParse(configData);
|
let proxyResourcesResults: PublicResourcesResults = [];
|
||||||
if (!validationResult.success) {
|
let clientResourcesResults: ClientResourcesResults = [];
|
||||||
throw new Error(fromError(validationResult.error).toString());
|
|
||||||
}
|
|
||||||
|
|
||||||
const config: Config = validationResult.data;
|
|
||||||
|
|
||||||
let publicResourcesResults: PublicResourcesResults = [];
|
|
||||||
let privateResourcesResults: ClientResourcesResults = [];
|
|
||||||
await db.transaction(async (trx) => {
|
await db.transaction(async (trx) => {
|
||||||
await updateResourcePolicies(orgId, config, trx);
|
await updateResourcePolicies(orgId, config, trx);
|
||||||
|
|
||||||
publicResourcesResults = await updatePublicResources(
|
proxyResourcesResults = await updatePublicResources(
|
||||||
orgId,
|
orgId,
|
||||||
config,
|
config,
|
||||||
trx,
|
trx,
|
||||||
siteId
|
siteId
|
||||||
);
|
);
|
||||||
privateResourcesResults = await updatePrivateResources(
|
clientResourcesResults = await updatePrivateResources(
|
||||||
orgId,
|
orgId,
|
||||||
config,
|
config,
|
||||||
trx,
|
trx,
|
||||||
siteId
|
siteId
|
||||||
);
|
);
|
||||||
|
|
||||||
|
logger.debug(
|
||||||
|
`Successfully updated proxy resources for org ${orgId}: ${JSON.stringify(proxyResourcesResults)}`
|
||||||
|
);
|
||||||
|
|
||||||
// We need to update the targets on the newts from the successfully updated information
|
// We need to update the targets on the newts from the successfully updated information
|
||||||
for (const result of publicResourcesResults) {
|
for (const result of proxyResourcesResults) {
|
||||||
for (const target of result.targetsToUpdate) {
|
for (const target of result.targetsToUpdate) {
|
||||||
const [site] = await trx
|
const [site] = await trx
|
||||||
.select()
|
.select()
|
||||||
@@ -134,37 +136,166 @@ export async function applyBlueprint({
|
|||||||
}
|
}
|
||||||
|
|
||||||
logger.debug(
|
logger.debug(
|
||||||
`Successfully updated public resources for org ${orgId}: ${JSON.stringify(publicResourcesResults)}`
|
`Successfully updated client resources for org ${orgId}: ${JSON.stringify(clientResourcesResults)}`
|
||||||
);
|
);
|
||||||
|
|
||||||
// We need to update the targets on the newts from the successfully updated information
|
// We need to update the targets on the newts from the successfully updated information
|
||||||
for (const result of privateResourcesResults) {
|
for (const result of clientResourcesResults) {
|
||||||
rebuildClientAssociationsFromSiteResource(
|
if (
|
||||||
result.newSiteResource
|
result.oldSiteResource &&
|
||||||
)
|
JSON.stringify(result.newSites?.sort()) !==
|
||||||
.then(() =>
|
JSON.stringify(result.oldSites?.sort())
|
||||||
waitForSiteResourceRebuildIdle(
|
) {
|
||||||
result.newSiteResource.siteResourceId
|
// query existing associations
|
||||||
|
const existingRoleIds = await trx
|
||||||
|
.select()
|
||||||
|
.from(roleSiteResources)
|
||||||
|
.where(
|
||||||
|
eq(
|
||||||
|
roleSiteResources.siteResourceId,
|
||||||
|
result.oldSiteResource.siteResourceId
|
||||||
|
)
|
||||||
)
|
)
|
||||||
)
|
.then((rows) => rows.map((row) => row.roleId));
|
||||||
.then(() =>
|
|
||||||
handleMessagingForUpdatedSiteResource(
|
|
||||||
result.oldSiteResource,
|
|
||||||
result.newSiteResource,
|
|
||||||
result.oldSites.map((s) => s.siteId),
|
|
||||||
result.newSites.map((s) => s.siteId)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
.catch((e) => {
|
|
||||||
logger.error(
|
|
||||||
`Failed to rebuild and handle messaging for site resource ${result.newSiteResource.siteResourceId}. Error: ${e}`
|
|
||||||
);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
logger.debug(
|
const existingUserIds = await trx
|
||||||
`Successfully updated private resources for org ${orgId}: ${JSON.stringify(privateResourcesResults)}`
|
.select()
|
||||||
);
|
.from(userSiteResources)
|
||||||
|
.where(
|
||||||
|
eq(
|
||||||
|
userSiteResources.siteResourceId,
|
||||||
|
result.oldSiteResource.siteResourceId
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.then((rows) => rows.map((row) => row.userId));
|
||||||
|
|
||||||
|
const existingClientIds = await trx
|
||||||
|
.select()
|
||||||
|
.from(clientSiteResources)
|
||||||
|
.where(
|
||||||
|
eq(
|
||||||
|
clientSiteResources.siteResourceId,
|
||||||
|
result.oldSiteResource.siteResourceId
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.then((rows) => rows.map((row) => row.clientId));
|
||||||
|
|
||||||
|
// delete the existing site resource
|
||||||
|
await trx
|
||||||
|
.delete(siteResources)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(
|
||||||
|
siteResources.siteResourceId,
|
||||||
|
result.oldSiteResource.siteResourceId
|
||||||
|
)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
await rebuildClientAssociationsFromSiteResource(
|
||||||
|
result.oldSiteResource,
|
||||||
|
trx
|
||||||
|
);
|
||||||
|
|
||||||
|
const [insertedSiteResource] = await trx
|
||||||
|
.insert(siteResources)
|
||||||
|
.values({
|
||||||
|
...result.newSiteResource
|
||||||
|
})
|
||||||
|
.returning();
|
||||||
|
|
||||||
|
// wait some time to allow for messages to be handled
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 750));
|
||||||
|
|
||||||
|
//////////////////// update the associations ////////////////////
|
||||||
|
|
||||||
|
if (existingRoleIds.length > 0) {
|
||||||
|
await trx.insert(roleSiteResources).values(
|
||||||
|
existingRoleIds.map((roleId) => ({
|
||||||
|
roleId,
|
||||||
|
siteResourceId:
|
||||||
|
insertedSiteResource!.siteResourceId
|
||||||
|
}))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (existingUserIds.length > 0) {
|
||||||
|
await trx.insert(userSiteResources).values(
|
||||||
|
existingUserIds.map((userId) => ({
|
||||||
|
userId,
|
||||||
|
siteResourceId:
|
||||||
|
insertedSiteResource!.siteResourceId
|
||||||
|
}))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (existingClientIds.length > 0) {
|
||||||
|
await trx.insert(clientSiteResources).values(
|
||||||
|
existingClientIds.map((clientId) => ({
|
||||||
|
clientId,
|
||||||
|
siteResourceId:
|
||||||
|
insertedSiteResource!.siteResourceId
|
||||||
|
}))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
await rebuildClientAssociationsFromSiteResource(
|
||||||
|
insertedSiteResource,
|
||||||
|
trx
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
let good = true;
|
||||||
|
for (const newSite of result.newSites) {
|
||||||
|
const [site] = await trx
|
||||||
|
.select()
|
||||||
|
.from(sites)
|
||||||
|
.innerJoin(newts, eq(sites.siteId, newts.siteId))
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(sites.siteId, newSite.siteId),
|
||||||
|
eq(sites.orgId, orgId),
|
||||||
|
eq(sites.type, "newt"),
|
||||||
|
isNotNull(sites.pubKey)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
if (!site) {
|
||||||
|
logger.debug(
|
||||||
|
`No newt sites found for client resource ${result.newSiteResource.siteResourceId}, skipping target update`
|
||||||
|
);
|
||||||
|
good = false;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.debug(
|
||||||
|
`Updating client resource ${result.newSiteResource.siteResourceId} on site ${newSite.siteId}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!good) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
await handleMessagingForUpdatedSiteResource(
|
||||||
|
result.oldSiteResource,
|
||||||
|
result.newSiteResource,
|
||||||
|
result.newSites.map((site) => ({
|
||||||
|
siteId: site.siteId,
|
||||||
|
orgId: result.newSiteResource.orgId
|
||||||
|
})),
|
||||||
|
trx
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// await addClientTargets(
|
||||||
|
// site.newt.newtId,
|
||||||
|
// result.resource.destination,
|
||||||
|
// result.resource.destinationPort,
|
||||||
|
// result.resource.protocol,
|
||||||
|
// result.resource.proxyPort
|
||||||
|
// );
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
blueprintSucceeded = true;
|
blueprintSucceeded = true;
|
||||||
@@ -172,9 +303,7 @@ export async function applyBlueprint({
|
|||||||
} catch (err) {
|
} catch (err) {
|
||||||
blueprintSucceeded = false;
|
blueprintSucceeded = false;
|
||||||
blueprintMessage = `Blueprint applied with errors: ${err}`;
|
blueprintMessage = `Blueprint applied with errors: ${err}`;
|
||||||
logger.debug(
|
logger.error(blueprintMessage);
|
||||||
`Org ${orgId} blueprint apply issues: ${blueprintMessage}`
|
|
||||||
);
|
|
||||||
error = err;
|
error = err;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -6,7 +6,6 @@ import {
|
|||||||
db,
|
db,
|
||||||
olms,
|
olms,
|
||||||
orgs,
|
orgs,
|
||||||
primaryDb,
|
|
||||||
roleClients,
|
roleClients,
|
||||||
roles,
|
roles,
|
||||||
Transaction,
|
Transaction,
|
||||||
@@ -24,427 +23,415 @@ import { rebuildClientAssociationsFromClient } from "./rebuildClientAssociations
|
|||||||
import { OlmErrorCodes } from "@server/routers/olm/error";
|
import { OlmErrorCodes } from "@server/routers/olm/error";
|
||||||
import { tierMatrix } from "./billing/tierMatrix";
|
import { tierMatrix } from "./billing/tierMatrix";
|
||||||
|
|
||||||
type ClientRow = typeof clients.$inferSelect;
|
|
||||||
|
|
||||||
function runQueuedClientAssociationRebuilds(
|
|
||||||
userId: string,
|
|
||||||
queuedClients: ClientRow[]
|
|
||||||
) {
|
|
||||||
if (queuedClients.length === 0) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const uniqueClientsById = new Map<number, ClientRow>();
|
|
||||||
for (const client of queuedClients) {
|
|
||||||
uniqueClientsById.set(client.clientId, client);
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const client of uniqueClientsById.values()) {
|
|
||||||
rebuildClientAssociationsFromClient(client).catch((error) => {
|
|
||||||
logger.error(
|
|
||||||
`Error rebuilding client associations for client ${client.clientId} (user ${userId}): ${String(
|
|
||||||
error
|
|
||||||
)}`
|
|
||||||
);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
logger.debug(
|
|
||||||
`Queued association rebuild completed for ${uniqueClientsById.size} client(s) (user ${userId})`
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function calculateUserClientsForOrgs(
|
export async function calculateUserClientsForOrgs(
|
||||||
userId: string
|
userId: string,
|
||||||
|
trx: Transaction | typeof db = db
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const trx = primaryDb;
|
const execute = async (transaction: Transaction | typeof db) => {
|
||||||
|
const orgCache = new Map<string, typeof orgs.$inferSelect | null>();
|
||||||
|
const adminRoleCache = new Map<
|
||||||
|
string,
|
||||||
|
typeof roles.$inferSelect | null
|
||||||
|
>();
|
||||||
|
const exitNodesCache = new Map<
|
||||||
|
string,
|
||||||
|
Awaited<ReturnType<typeof listExitNodes>>
|
||||||
|
>();
|
||||||
|
const isOrgLicensedCache = new Map<string, boolean>();
|
||||||
|
const existingClientCache = new Map<
|
||||||
|
string,
|
||||||
|
typeof clients.$inferSelect | null
|
||||||
|
>();
|
||||||
|
const roleClientAccessCache = new Map<string, boolean>();
|
||||||
|
const userClientAccessCache = new Map<string, boolean>();
|
||||||
|
|
||||||
const queuedAssociationRebuilds: ClientRow[] = [];
|
const getOrgOlmKey = (orgId: string, olmId: string) =>
|
||||||
const orgCache = new Map<string, typeof orgs.$inferSelect | null>();
|
`${orgId}:${olmId}`;
|
||||||
const adminRoleCache = new Map<string, typeof roles.$inferSelect | null>();
|
const getRoleClientKey = (roleId: number, clientId: number) =>
|
||||||
const exitNodesCache = new Map<
|
`${roleId}:${clientId}`;
|
||||||
string,
|
const getUserClientKey = (cachedUserId: string, clientId: number) =>
|
||||||
Awaited<ReturnType<typeof listExitNodes>>
|
`${cachedUserId}:${clientId}`;
|
||||||
>();
|
|
||||||
const isOrgLicensedCache = new Map<string, boolean>();
|
|
||||||
const existingClientCache = new Map<
|
|
||||||
string,
|
|
||||||
typeof clients.$inferSelect | null
|
|
||||||
>();
|
|
||||||
const roleClientAccessCache = new Map<string, boolean>();
|
|
||||||
const userClientAccessCache = new Map<string, boolean>();
|
|
||||||
|
|
||||||
const getOrgOlmKey = (orgId: string, olmId: string) => `${orgId}:${olmId}`;
|
const getOrg = async (orgId: string) => {
|
||||||
const getRoleClientKey = (roleId: number, clientId: number) =>
|
if (orgCache.has(orgId)) {
|
||||||
`${roleId}:${clientId}`;
|
return orgCache.get(orgId) ?? null;
|
||||||
const getUserClientKey = (cachedUserId: string, clientId: number) =>
|
|
||||||
`${cachedUserId}:${clientId}`;
|
|
||||||
|
|
||||||
const getOrg = async (orgId: string) => {
|
|
||||||
if (orgCache.has(orgId)) {
|
|
||||||
return orgCache.get(orgId) ?? null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const [org] = await trx
|
|
||||||
.select()
|
|
||||||
.from(orgs)
|
|
||||||
.where(eq(orgs.orgId, orgId));
|
|
||||||
orgCache.set(orgId, org ?? null);
|
|
||||||
|
|
||||||
return org ?? null;
|
|
||||||
};
|
|
||||||
|
|
||||||
const getAdminRole = async (orgId: string) => {
|
|
||||||
if (adminRoleCache.has(orgId)) {
|
|
||||||
return adminRoleCache.get(orgId) ?? null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const [adminRole] = await trx
|
|
||||||
.select()
|
|
||||||
.from(roles)
|
|
||||||
.where(and(eq(roles.isAdmin, true), eq(roles.orgId, orgId)))
|
|
||||||
.limit(1);
|
|
||||||
adminRoleCache.set(orgId, adminRole ?? null);
|
|
||||||
|
|
||||||
return adminRole ?? null;
|
|
||||||
};
|
|
||||||
|
|
||||||
const getExitNodes = async (orgId: string) => {
|
|
||||||
if (exitNodesCache.has(orgId)) {
|
|
||||||
return exitNodesCache.get(orgId)!;
|
|
||||||
}
|
|
||||||
|
|
||||||
const exitNodes = await listExitNodes(orgId);
|
|
||||||
exitNodesCache.set(orgId, exitNodes);
|
|
||||||
|
|
||||||
return exitNodes;
|
|
||||||
};
|
|
||||||
|
|
||||||
const getIsOrgLicensed = async (orgId: string) => {
|
|
||||||
if (isOrgLicensedCache.has(orgId)) {
|
|
||||||
return isOrgLicensedCache.get(orgId)!;
|
|
||||||
}
|
|
||||||
|
|
||||||
const isOrgLicensed = await isLicensedOrSubscribed(
|
|
||||||
orgId,
|
|
||||||
tierMatrix.deviceApprovals
|
|
||||||
);
|
|
||||||
isOrgLicensedCache.set(orgId, isOrgLicensed);
|
|
||||||
|
|
||||||
return isOrgLicensed;
|
|
||||||
};
|
|
||||||
|
|
||||||
const getExistingClient = async (orgId: string, olmId: string) => {
|
|
||||||
const key = getOrgOlmKey(orgId, olmId);
|
|
||||||
if (existingClientCache.has(key)) {
|
|
||||||
return existingClientCache.get(key) ?? null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const [existingClient] = await trx
|
|
||||||
.select()
|
|
||||||
.from(clients)
|
|
||||||
.where(
|
|
||||||
and(
|
|
||||||
eq(clients.userId, userId),
|
|
||||||
eq(clients.orgId, orgId),
|
|
||||||
eq(clients.olmId, olmId)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
.limit(1);
|
|
||||||
|
|
||||||
existingClientCache.set(key, existingClient ?? null);
|
|
||||||
|
|
||||||
return existingClient ?? null;
|
|
||||||
};
|
|
||||||
|
|
||||||
const hasRoleClientAccess = async (roleId: number, clientId: number) => {
|
|
||||||
const key = getRoleClientKey(roleId, clientId);
|
|
||||||
if (roleClientAccessCache.has(key)) {
|
|
||||||
return roleClientAccessCache.get(key)!;
|
|
||||||
}
|
|
||||||
|
|
||||||
const [existingRoleClient] = await trx
|
|
||||||
.select()
|
|
||||||
.from(roleClients)
|
|
||||||
.where(
|
|
||||||
and(
|
|
||||||
eq(roleClients.roleId, roleId),
|
|
||||||
eq(roleClients.clientId, clientId)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
.limit(1);
|
|
||||||
|
|
||||||
const hasAccess = Boolean(existingRoleClient);
|
|
||||||
roleClientAccessCache.set(key, hasAccess);
|
|
||||||
|
|
||||||
return hasAccess;
|
|
||||||
};
|
|
||||||
|
|
||||||
const hasUserClientAccess = async (
|
|
||||||
cachedUserId: string,
|
|
||||||
clientId: number
|
|
||||||
) => {
|
|
||||||
const key = getUserClientKey(cachedUserId, clientId);
|
|
||||||
if (userClientAccessCache.has(key)) {
|
|
||||||
return userClientAccessCache.get(key)!;
|
|
||||||
}
|
|
||||||
|
|
||||||
const [existingUserClient] = await trx
|
|
||||||
.select()
|
|
||||||
.from(userClients)
|
|
||||||
.where(
|
|
||||||
and(
|
|
||||||
eq(userClients.userId, cachedUserId),
|
|
||||||
eq(userClients.clientId, clientId)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
.limit(1);
|
|
||||||
|
|
||||||
const hasAccess = Boolean(existingUserClient);
|
|
||||||
userClientAccessCache.set(key, hasAccess);
|
|
||||||
|
|
||||||
return hasAccess;
|
|
||||||
};
|
|
||||||
|
|
||||||
// Get all OLMs for this user
|
|
||||||
const userOlms = await trx
|
|
||||||
.select()
|
|
||||||
.from(olms)
|
|
||||||
.where(eq(olms.userId, userId));
|
|
||||||
|
|
||||||
if (userOlms.length === 0) {
|
|
||||||
// No OLMs for this user, but we should still clean up any orphaned clients
|
|
||||||
await cleanupOrphanedClients(
|
|
||||||
userId,
|
|
||||||
trx,
|
|
||||||
[],
|
|
||||||
queuedAssociationRebuilds
|
|
||||||
);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get all user orgs with all roles (for org list and role-based logic)
|
|
||||||
const userOrgRoleRows = await trx
|
|
||||||
.select()
|
|
||||||
.from(userOrgs)
|
|
||||||
.innerJoin(
|
|
||||||
userOrgRoles,
|
|
||||||
and(
|
|
||||||
eq(userOrgs.userId, userOrgRoles.userId),
|
|
||||||
eq(userOrgs.orgId, userOrgRoles.orgId)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
.innerJoin(roles, eq(userOrgRoles.roleId, roles.roleId))
|
|
||||||
.where(eq(userOrgs.userId, userId));
|
|
||||||
|
|
||||||
const userOrgIds = [
|
|
||||||
...new Set(userOrgRoleRows.map((r) => r.userOrgs.orgId))
|
|
||||||
];
|
|
||||||
const orgIdToRoleRows = new Map<string, (typeof userOrgRoleRows)[0][]>();
|
|
||||||
for (const r of userOrgRoleRows) {
|
|
||||||
const list = orgIdToRoleRows.get(r.userOrgs.orgId) ?? [];
|
|
||||||
list.push(r);
|
|
||||||
orgIdToRoleRows.set(r.userOrgs.orgId, list);
|
|
||||||
}
|
|
||||||
const orgRequiresDeviceApprovalRole = new Map<string, boolean>();
|
|
||||||
for (const [orgId, roleRowsForOrg] of orgIdToRoleRows.entries()) {
|
|
||||||
orgRequiresDeviceApprovalRole.set(
|
|
||||||
orgId,
|
|
||||||
roleRowsForOrg.some((r) => r.roles.requireDeviceApproval)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// For each OLM, ensure there's a client in each org the user is in
|
|
||||||
for (const olm of userOlms) {
|
|
||||||
for (const orgId of orgIdToRoleRows.keys()) {
|
|
||||||
const roleRowsForOrg = orgIdToRoleRows.get(orgId)!;
|
|
||||||
const userOrg = roleRowsForOrg[0].userOrgs;
|
|
||||||
|
|
||||||
const org = await getOrg(orgId);
|
|
||||||
|
|
||||||
if (!org) {
|
|
||||||
logger.warn(
|
|
||||||
`Skipping org ${orgId} for OLM ${olm.olmId} (user ${userId}): org not found`
|
|
||||||
);
|
|
||||||
continue;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!org.subnet) {
|
const [org] = await transaction
|
||||||
logger.warn(
|
.select()
|
||||||
`Skipping org ${orgId} for OLM ${olm.olmId} (user ${userId}): org has no subnet configured`
|
.from(orgs)
|
||||||
);
|
.where(eq(orgs.orgId, orgId));
|
||||||
continue;
|
orgCache.set(orgId, org ?? null);
|
||||||
|
|
||||||
|
return org ?? null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const getAdminRole = async (orgId: string) => {
|
||||||
|
if (adminRoleCache.has(orgId)) {
|
||||||
|
return adminRoleCache.get(orgId) ?? null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get admin role for this org (needed for access grants)
|
const [adminRole] = await transaction
|
||||||
const adminRole = await getAdminRole(orgId);
|
.select()
|
||||||
|
.from(roles)
|
||||||
|
.where(and(eq(roles.isAdmin, true), eq(roles.orgId, orgId)))
|
||||||
|
.limit(1);
|
||||||
|
adminRoleCache.set(orgId, adminRole ?? null);
|
||||||
|
|
||||||
if (!adminRole) {
|
return adminRole ?? null;
|
||||||
logger.warn(
|
};
|
||||||
`Skipping org ${orgId} for OLM ${olm.olmId} (user ${userId}): no admin role found`
|
|
||||||
);
|
const getExitNodes = async (orgId: string) => {
|
||||||
continue;
|
if (exitNodesCache.has(orgId)) {
|
||||||
|
return exitNodesCache.get(orgId)!;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if a client already exists for this OLM+user+org combination
|
const exitNodes = await listExitNodes(orgId);
|
||||||
const existingClient = await getExistingClient(orgId, olm.olmId);
|
exitNodesCache.set(orgId, exitNodes);
|
||||||
|
|
||||||
if (existingClient) {
|
return exitNodes;
|
||||||
// Ensure admin role has access to the client
|
};
|
||||||
const hasRoleAccess = await hasRoleClientAccess(
|
|
||||||
adminRole.roleId,
|
|
||||||
existingClient.clientId
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!hasRoleAccess) {
|
const getIsOrgLicensed = async (orgId: string) => {
|
||||||
await trx.insert(roleClients).values({
|
if (isOrgLicensedCache.has(orgId)) {
|
||||||
roleId: adminRole.roleId,
|
return isOrgLicensedCache.get(orgId)!;
|
||||||
clientId: existingClient.clientId
|
}
|
||||||
});
|
|
||||||
roleClientAccessCache.set(
|
const isOrgLicensed = await isLicensedOrSubscribed(
|
||||||
getRoleClientKey(
|
orgId,
|
||||||
adminRole.roleId,
|
tierMatrix.deviceApprovals
|
||||||
existingClient.clientId
|
);
|
||||||
),
|
isOrgLicensedCache.set(orgId, isOrgLicensed);
|
||||||
true
|
|
||||||
);
|
return isOrgLicensed;
|
||||||
logger.debug(
|
};
|
||||||
`Granted admin role access to existing client ${existingClient.clientId} for OLM ${olm.olmId} in org ${orgId} (user ${userId})`
|
|
||||||
|
const getExistingClient = async (orgId: string, olmId: string) => {
|
||||||
|
const key = getOrgOlmKey(orgId, olmId);
|
||||||
|
if (existingClientCache.has(key)) {
|
||||||
|
return existingClientCache.get(key) ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const [existingClient] = await transaction
|
||||||
|
.select()
|
||||||
|
.from(clients)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(clients.userId, userId),
|
||||||
|
eq(clients.orgId, orgId),
|
||||||
|
eq(clients.olmId, olmId)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
existingClientCache.set(key, existingClient ?? null);
|
||||||
|
|
||||||
|
return existingClient ?? null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const hasRoleClientAccess = async (
|
||||||
|
roleId: number,
|
||||||
|
clientId: number
|
||||||
|
) => {
|
||||||
|
const key = getRoleClientKey(roleId, clientId);
|
||||||
|
if (roleClientAccessCache.has(key)) {
|
||||||
|
return roleClientAccessCache.get(key)!;
|
||||||
|
}
|
||||||
|
|
||||||
|
const [existingRoleClient] = await transaction
|
||||||
|
.select()
|
||||||
|
.from(roleClients)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(roleClients.roleId, roleId),
|
||||||
|
eq(roleClients.clientId, clientId)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
const hasAccess = Boolean(existingRoleClient);
|
||||||
|
roleClientAccessCache.set(key, hasAccess);
|
||||||
|
|
||||||
|
return hasAccess;
|
||||||
|
};
|
||||||
|
|
||||||
|
const hasUserClientAccess = async (
|
||||||
|
cachedUserId: string,
|
||||||
|
clientId: number
|
||||||
|
) => {
|
||||||
|
const key = getUserClientKey(cachedUserId, clientId);
|
||||||
|
if (userClientAccessCache.has(key)) {
|
||||||
|
return userClientAccessCache.get(key)!;
|
||||||
|
}
|
||||||
|
|
||||||
|
const [existingUserClient] = await transaction
|
||||||
|
.select()
|
||||||
|
.from(userClients)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(userClients.userId, cachedUserId),
|
||||||
|
eq(userClients.clientId, clientId)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
const hasAccess = Boolean(existingUserClient);
|
||||||
|
userClientAccessCache.set(key, hasAccess);
|
||||||
|
|
||||||
|
return hasAccess;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Get all OLMs for this user
|
||||||
|
const userOlms = await transaction
|
||||||
|
.select()
|
||||||
|
.from(olms)
|
||||||
|
.where(eq(olms.userId, userId));
|
||||||
|
|
||||||
|
if (userOlms.length === 0) {
|
||||||
|
// No OLMs for this user, but we should still clean up any orphaned clients
|
||||||
|
await cleanupOrphanedClients(userId, transaction);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get all user orgs with all roles (for org list and role-based logic)
|
||||||
|
const userOrgRoleRows = await transaction
|
||||||
|
.select()
|
||||||
|
.from(userOrgs)
|
||||||
|
.innerJoin(
|
||||||
|
userOrgRoles,
|
||||||
|
and(
|
||||||
|
eq(userOrgs.userId, userOrgRoles.userId),
|
||||||
|
eq(userOrgs.orgId, userOrgRoles.orgId)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.innerJoin(roles, eq(userOrgRoles.roleId, roles.roleId))
|
||||||
|
.where(eq(userOrgs.userId, userId));
|
||||||
|
|
||||||
|
const userOrgIds = [
|
||||||
|
...new Set(userOrgRoleRows.map((r) => r.userOrgs.orgId))
|
||||||
|
];
|
||||||
|
const orgIdToRoleRows = new Map<
|
||||||
|
string,
|
||||||
|
(typeof userOrgRoleRows)[0][]
|
||||||
|
>();
|
||||||
|
for (const r of userOrgRoleRows) {
|
||||||
|
const list = orgIdToRoleRows.get(r.userOrgs.orgId) ?? [];
|
||||||
|
list.push(r);
|
||||||
|
orgIdToRoleRows.set(r.userOrgs.orgId, list);
|
||||||
|
}
|
||||||
|
const orgRequiresDeviceApprovalRole = new Map<string, boolean>();
|
||||||
|
for (const [orgId, roleRowsForOrg] of orgIdToRoleRows.entries()) {
|
||||||
|
orgRequiresDeviceApprovalRole.set(
|
||||||
|
orgId,
|
||||||
|
roleRowsForOrg.some((r) => r.roles.requireDeviceApproval)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// For each OLM, ensure there's a client in each org the user is in
|
||||||
|
for (const olm of userOlms) {
|
||||||
|
for (const orgId of orgIdToRoleRows.keys()) {
|
||||||
|
const roleRowsForOrg = orgIdToRoleRows.get(orgId)!;
|
||||||
|
const userOrg = roleRowsForOrg[0].userOrgs;
|
||||||
|
|
||||||
|
const org = await getOrg(orgId);
|
||||||
|
|
||||||
|
if (!org) {
|
||||||
|
logger.warn(
|
||||||
|
`Skipping org ${orgId} for OLM ${olm.olmId} (user ${userId}): org not found`
|
||||||
);
|
);
|
||||||
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Ensure user has access to the client
|
if (!org.subnet) {
|
||||||
const hasUserAccess = await hasUserClientAccess(
|
logger.warn(
|
||||||
userId,
|
`Skipping org ${orgId} for OLM ${olm.olmId} (user ${userId}): org has no subnet configured`
|
||||||
existingClient.clientId
|
);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get admin role for this org (needed for access grants)
|
||||||
|
const adminRole = await getAdminRole(orgId);
|
||||||
|
|
||||||
|
if (!adminRole) {
|
||||||
|
logger.warn(
|
||||||
|
`Skipping org ${orgId} for OLM ${olm.olmId} (user ${userId}): no admin role found`
|
||||||
|
);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if a client already exists for this OLM+user+org combination
|
||||||
|
const existingClient = await getExistingClient(
|
||||||
|
orgId,
|
||||||
|
olm.olmId
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!hasUserAccess) {
|
if (existingClient) {
|
||||||
await trx.insert(userClients).values({
|
// Ensure admin role has access to the client
|
||||||
|
const hasRoleAccess = await hasRoleClientAccess(
|
||||||
|
adminRole.roleId,
|
||||||
|
existingClient.clientId
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!hasRoleAccess) {
|
||||||
|
await transaction.insert(roleClients).values({
|
||||||
|
roleId: adminRole.roleId,
|
||||||
|
clientId: existingClient.clientId
|
||||||
|
});
|
||||||
|
roleClientAccessCache.set(
|
||||||
|
getRoleClientKey(
|
||||||
|
adminRole.roleId,
|
||||||
|
existingClient.clientId
|
||||||
|
),
|
||||||
|
true
|
||||||
|
);
|
||||||
|
logger.debug(
|
||||||
|
`Granted admin role access to existing client ${existingClient.clientId} for OLM ${olm.olmId} in org ${orgId} (user ${userId})`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ensure user has access to the client
|
||||||
|
const hasUserAccess = await hasUserClientAccess(
|
||||||
userId,
|
userId,
|
||||||
clientId: existingClient.clientId
|
existingClient.clientId
|
||||||
});
|
|
||||||
userClientAccessCache.set(
|
|
||||||
getUserClientKey(userId, existingClient.clientId),
|
|
||||||
true
|
|
||||||
);
|
);
|
||||||
|
|
||||||
|
if (!hasUserAccess) {
|
||||||
|
await transaction.insert(userClients).values({
|
||||||
|
userId,
|
||||||
|
clientId: existingClient.clientId
|
||||||
|
});
|
||||||
|
userClientAccessCache.set(
|
||||||
|
getUserClientKey(userId, existingClient.clientId),
|
||||||
|
true
|
||||||
|
);
|
||||||
|
logger.debug(
|
||||||
|
`Granted user access to existing client ${existingClient.clientId} for OLM ${olm.olmId} in org ${orgId} (user ${userId})`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
logger.debug(
|
logger.debug(
|
||||||
`Granted user access to existing client ${existingClient.clientId} for OLM ${olm.olmId} in org ${orgId} (user ${userId})`
|
`Client already exists for OLM ${olm.olmId} in org ${orgId} (user ${userId}), skipping creation`
|
||||||
);
|
);
|
||||||
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Get exit nodes for this org
|
||||||
|
const exitNodesList = await getExitNodes(orgId);
|
||||||
|
|
||||||
|
if (exitNodesList.length === 0) {
|
||||||
|
logger.warn(
|
||||||
|
`Skipping org ${orgId} for OLM ${olm.olmId} (user ${userId}): no exit nodes found`
|
||||||
|
);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const randomExitNode =
|
||||||
|
exitNodesList[
|
||||||
|
Math.floor(Math.random() * exitNodesList.length)
|
||||||
|
];
|
||||||
|
|
||||||
|
// Get next available subnet
|
||||||
|
const { value: newSubnet, release: releaseSubnetLock } =
|
||||||
|
await getNextAvailableClientSubnet(orgId, transaction);
|
||||||
|
|
||||||
|
const subnet = newSubnet.split("/")[0];
|
||||||
|
const updatedSubnet = `${subnet}/${org.subnet.split("/")[1]}`;
|
||||||
|
|
||||||
|
const niceId = await getUniqueClientName(orgId);
|
||||||
|
|
||||||
|
const isOrgLicensed = await getIsOrgLicensed(userOrg.orgId);
|
||||||
|
const requireApproval =
|
||||||
|
build !== "oss" &&
|
||||||
|
isOrgLicensed &&
|
||||||
|
orgRequiresDeviceApprovalRole.get(orgId) === true;
|
||||||
|
|
||||||
|
const newClientData: InferInsertModel<typeof clients> = {
|
||||||
|
userId,
|
||||||
|
orgId: userOrg.orgId,
|
||||||
|
exitNodeId: randomExitNode.exitNodeId,
|
||||||
|
name: olm.name || "User Client",
|
||||||
|
subnet: updatedSubnet,
|
||||||
|
olmId: olm.olmId,
|
||||||
|
type: "olm",
|
||||||
|
niceId,
|
||||||
|
approvalState: requireApproval ? "pending" : null
|
||||||
|
};
|
||||||
|
|
||||||
|
// Create the client
|
||||||
|
const [newClient] = await transaction
|
||||||
|
.insert(clients)
|
||||||
|
.values(newClientData)
|
||||||
|
.returning();
|
||||||
|
await releaseSubnetLock();
|
||||||
|
existingClientCache.set(
|
||||||
|
getOrgOlmKey(orgId, olm.olmId),
|
||||||
|
newClient
|
||||||
|
);
|
||||||
|
|
||||||
|
// create approval request
|
||||||
|
if (requireApproval) {
|
||||||
|
await transaction
|
||||||
|
.insert(approvals)
|
||||||
|
.values({
|
||||||
|
timestamp: Math.floor(new Date().getTime() / 1000),
|
||||||
|
orgId: userOrg.orgId,
|
||||||
|
clientId: newClient.clientId,
|
||||||
|
userId,
|
||||||
|
type: "user_device"
|
||||||
|
})
|
||||||
|
.returning();
|
||||||
|
}
|
||||||
|
|
||||||
|
await rebuildClientAssociationsFromClient(
|
||||||
|
newClient,
|
||||||
|
transaction
|
||||||
|
);
|
||||||
|
|
||||||
|
// Grant admin role access to the client
|
||||||
|
await transaction.insert(roleClients).values({
|
||||||
|
roleId: adminRole.roleId,
|
||||||
|
clientId: newClient.clientId
|
||||||
|
});
|
||||||
|
roleClientAccessCache.set(
|
||||||
|
getRoleClientKey(adminRole.roleId, newClient.clientId),
|
||||||
|
true
|
||||||
|
);
|
||||||
|
|
||||||
|
// Grant user access to the client
|
||||||
|
await transaction.insert(userClients).values({
|
||||||
|
userId,
|
||||||
|
clientId: newClient.clientId
|
||||||
|
});
|
||||||
|
userClientAccessCache.set(
|
||||||
|
getUserClientKey(userId, newClient.clientId),
|
||||||
|
true
|
||||||
|
);
|
||||||
|
|
||||||
logger.debug(
|
logger.debug(
|
||||||
`Client already exists for OLM ${olm.olmId} in org ${orgId} (user ${userId}), skipping creation`
|
`Created client for OLM ${olm.olmId} in org ${orgId} (user ${userId}) with access granted to admin role and user`
|
||||||
);
|
);
|
||||||
continue;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get exit nodes for this org
|
|
||||||
const exitNodesList = await getExitNodes(orgId);
|
|
||||||
|
|
||||||
if (exitNodesList.length === 0) {
|
|
||||||
logger.warn(
|
|
||||||
`Skipping org ${orgId} for OLM ${olm.olmId} (user ${userId}): no exit nodes found`
|
|
||||||
);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
const randomExitNode =
|
|
||||||
exitNodesList[Math.floor(Math.random() * exitNodesList.length)];
|
|
||||||
|
|
||||||
// Get next available subnet
|
|
||||||
const { value: newSubnet, release: releaseSubnetLock } =
|
|
||||||
await getNextAvailableClientSubnet(orgId, trx);
|
|
||||||
|
|
||||||
const subnet = newSubnet.split("/")[0];
|
|
||||||
const updatedSubnet = `${subnet}/${org.subnet.split("/")[1]}`;
|
|
||||||
|
|
||||||
const niceId = await getUniqueClientName(orgId);
|
|
||||||
|
|
||||||
const isOrgLicensed = await getIsOrgLicensed(userOrg.orgId);
|
|
||||||
const requireApproval =
|
|
||||||
build !== "oss" &&
|
|
||||||
isOrgLicensed &&
|
|
||||||
orgRequiresDeviceApprovalRole.get(orgId) === true;
|
|
||||||
|
|
||||||
const newClientData: InferInsertModel<typeof clients> = {
|
|
||||||
userId,
|
|
||||||
orgId: userOrg.orgId,
|
|
||||||
exitNodeId: randomExitNode.exitNodeId,
|
|
||||||
name: olm.name || "User Client",
|
|
||||||
subnet: updatedSubnet,
|
|
||||||
olmId: olm.olmId,
|
|
||||||
type: "olm",
|
|
||||||
niceId,
|
|
||||||
approvalState: requireApproval ? "pending" : null
|
|
||||||
};
|
|
||||||
|
|
||||||
// Create the client
|
|
||||||
const [newClient] = await trx
|
|
||||||
.insert(clients)
|
|
||||||
.values(newClientData)
|
|
||||||
.returning();
|
|
||||||
await releaseSubnetLock();
|
|
||||||
existingClientCache.set(getOrgOlmKey(orgId, olm.olmId), newClient);
|
|
||||||
|
|
||||||
// create approval request
|
|
||||||
if (requireApproval) {
|
|
||||||
await trx
|
|
||||||
.insert(approvals)
|
|
||||||
.values({
|
|
||||||
timestamp: Math.floor(new Date().getTime() / 1000),
|
|
||||||
orgId: userOrg.orgId,
|
|
||||||
clientId: newClient.clientId,
|
|
||||||
userId,
|
|
||||||
type: "user_device"
|
|
||||||
})
|
|
||||||
.returning();
|
|
||||||
}
|
|
||||||
|
|
||||||
queuedAssociationRebuilds.push(newClient);
|
|
||||||
|
|
||||||
// Grant admin role access to the client
|
|
||||||
await trx.insert(roleClients).values({
|
|
||||||
roleId: adminRole.roleId,
|
|
||||||
clientId: newClient.clientId
|
|
||||||
});
|
|
||||||
roleClientAccessCache.set(
|
|
||||||
getRoleClientKey(adminRole.roleId, newClient.clientId),
|
|
||||||
true
|
|
||||||
);
|
|
||||||
|
|
||||||
// Grant user access to the client
|
|
||||||
await trx.insert(userClients).values({
|
|
||||||
userId,
|
|
||||||
clientId: newClient.clientId
|
|
||||||
});
|
|
||||||
userClientAccessCache.set(
|
|
||||||
getUserClientKey(userId, newClient.clientId),
|
|
||||||
true
|
|
||||||
);
|
|
||||||
|
|
||||||
logger.debug(
|
|
||||||
`Created client for OLM ${olm.olmId} in org ${orgId} (user ${userId}) with access granted to admin role and user`
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Clean up clients in orgs the user is no longer in
|
||||||
|
await cleanupOrphanedClients(userId, transaction, userOrgIds);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (trx) {
|
||||||
|
// Use provided transaction
|
||||||
|
await execute(trx);
|
||||||
|
} else {
|
||||||
|
// Create new transaction
|
||||||
|
await db.transaction(async (transaction) => {
|
||||||
|
await execute(transaction);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Clean up clients in orgs the user is no longer in
|
|
||||||
await cleanupOrphanedClients(
|
|
||||||
userId,
|
|
||||||
trx,
|
|
||||||
userOrgIds,
|
|
||||||
queuedAssociationRebuilds
|
|
||||||
);
|
|
||||||
|
|
||||||
runQueuedClientAssociationRebuilds(userId, queuedAssociationRebuilds);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function cleanupOrphanedClients(
|
async function cleanupOrphanedClients(
|
||||||
userId: string,
|
userId: string,
|
||||||
trx: Transaction | typeof db,
|
trx: Transaction | typeof db,
|
||||||
userOrgIds: string[] = [],
|
userOrgIds: string[] = []
|
||||||
queuedAssociationRebuilds: ClientRow[] = []
|
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
// Find all OLM clients for this user that should be deleted
|
// Find all OLM clients for this user that should be deleted
|
||||||
// If userOrgIds is empty, delete all OLM clients (user has no orgs)
|
// If userOrgIds is empty, delete all OLM clients (user has no orgs)
|
||||||
@@ -474,9 +461,9 @@ async function cleanupOrphanedClients(
|
|||||||
)
|
)
|
||||||
.returning();
|
.returning();
|
||||||
|
|
||||||
// Queue deleted clients for post-trx association cleanup.
|
// Rebuild associations for each deleted client to clean up related data
|
||||||
for (const deletedClient of deletedClients) {
|
for (const deletedClient of deletedClients) {
|
||||||
queuedAssociationRebuilds.push(deletedClient);
|
await rebuildClientAssociationsFromClient(deletedClient, trx);
|
||||||
|
|
||||||
if (deletedClient.olmId) {
|
if (deletedClient.olmId) {
|
||||||
await sendTerminateClient(
|
await sendTerminateClient(
|
||||||
|
|||||||
@@ -1,144 +0,0 @@
|
|||||||
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
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,126 +0,0 @@
|
|||||||
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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,53 +0,0 @@
|
|||||||
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
|
|
||||||
);
|
|
||||||
}
|
|
||||||
);
|
|
||||||
}
|
|
||||||
+7
-117
@@ -1,24 +1,4 @@
|
|||||||
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
|
||||||
@@ -27,57 +7,22 @@ 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> {
|
||||||
for (let attempt = 0; attempt < maxRetries; attempt++) {
|
return true;
|
||||||
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
|
||||||
@@ -90,20 +35,7 @@ export class LockManager {
|
|||||||
ttl: number;
|
ttl: number;
|
||||||
owner?: string;
|
owner?: string;
|
||||||
}> {
|
}> {
|
||||||
this.clearExpiredLocalLock(lockKey);
|
return { exists: true, ownedByMe: true, ttl: 0 };
|
||||||
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]
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -113,15 +45,6 @@ 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;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -139,26 +62,7 @@ export class LockManager {
|
|||||||
maxRetries: number = 5,
|
maxRetries: number = 5,
|
||||||
baseDelayMs: number = 100
|
baseDelayMs: number = 100
|
||||||
): Promise<boolean> {
|
): Promise<boolean> {
|
||||||
for (let attempt = 0; attempt <= maxRetries; attempt++) {
|
return true;
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -195,21 +99,7 @@ export class LockManager {
|
|||||||
activeLocksCount: number;
|
activeLocksCount: number;
|
||||||
locksOwnedByMe: number;
|
locksOwnedByMe: number;
|
||||||
}> {
|
}> {
|
||||||
const now = Date.now();
|
return { activeLocksCount: 0, locksOwnedByMe: 0 };
|
||||||
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 };
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -1,27 +0,0 @@
|
|||||||
export type RebuildJobType = "site-resource" | "client";
|
|
||||||
|
|
||||||
export interface RebuildJob {
|
|
||||||
type: RebuildJobType;
|
|
||||||
id: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface RebuildJobHandlers {
|
|
||||||
onSiteResource(siteResourceId: number): Promise<void>;
|
|
||||||
onClient(clientId: number): Promise<void>;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface RebuildQueueManager {
|
|
||||||
enqueue(job: RebuildJob): Promise<void>;
|
|
||||||
startProcessing(handlers: RebuildJobHandlers): void;
|
|
||||||
isQueued(job: RebuildJob): Promise<boolean>;
|
|
||||||
}
|
|
||||||
|
|
||||||
class NoopRebuildQueue implements RebuildQueueManager {
|
|
||||||
async enqueue(_job: RebuildJob): Promise<void> {}
|
|
||||||
startProcessing(_handlers: RebuildJobHandlers): void {}
|
|
||||||
async isQueued(_job: RebuildJob): Promise<boolean> {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export const rebuildQueue: RebuildQueueManager = new NoopRebuildQueue();
|
|
||||||
@@ -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, lt, asc, desc } from "drizzle-orm";
|
import { and, eq, gte, asc } 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,29 +42,7 @@ export async function getCachedStatusHistory(
|
|||||||
)
|
)
|
||||||
.orderBy(asc(statusHistory.timestamp));
|
.orderBy(asc(statusHistory.timestamp));
|
||||||
|
|
||||||
// Fetch the last known state before the window so that entities that
|
const { buckets, totalDowntime } = computeBuckets(events, days);
|
||||||
// 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
|
||||||
@@ -132,8 +110,7 @@ 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);
|
||||||
|
|
||||||
@@ -159,10 +136,7 @@ export function computeBuckets(
|
|||||||
.filter((e) => e.timestamp < dayStartSec)
|
.filter((e) => e.timestamp < dayStartSec)
|
||||||
.at(-1);
|
.at(-1);
|
||||||
|
|
||||||
// Fall back to the last known state before the entire query window
|
const currentStatus = lastBeforeDay?.status ?? null;
|
||||||
// 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 }[] =
|
||||||
[];
|
[];
|
||||||
|
|||||||
@@ -1,7 +1,4 @@
|
|||||||
import {
|
import { isValidUrlGlobPattern } from "./validators";
|
||||||
getResourceRuleValueValidationError,
|
|
||||||
isValidUrlGlobPattern
|
|
||||||
} from "./validators";
|
|
||||||
import { assertEquals } from "@test/assert";
|
import { assertEquals } from "@test/assert";
|
||||||
|
|
||||||
function runTests() {
|
function runTests() {
|
||||||
@@ -239,43 +236,6 @@ function runTests() {
|
|||||||
"Path with isolated percent sign should be invalid"
|
"Path with isolated percent sign should be invalid"
|
||||||
);
|
);
|
||||||
|
|
||||||
// ASN validation tests
|
|
||||||
assertEquals(
|
|
||||||
getResourceRuleValueValidationError("ASN", "AS15169"),
|
|
||||||
null,
|
|
||||||
"Standard ASN should be valid"
|
|
||||||
);
|
|
||||||
assertEquals(
|
|
||||||
getResourceRuleValueValidationError("ASN", " As15169 "),
|
|
||||||
null,
|
|
||||||
"Standard ASN should be valid with mixed case and whitespace"
|
|
||||||
);
|
|
||||||
assertEquals(
|
|
||||||
getResourceRuleValueValidationError("ASN", "ALL"),
|
|
||||||
null,
|
|
||||||
"ALL ASN selector should be valid"
|
|
||||||
);
|
|
||||||
assertEquals(
|
|
||||||
getResourceRuleValueValidationError("ASN", " all "),
|
|
||||||
null,
|
|
||||||
"ALL ASN selector should be valid with mixed case and whitespace"
|
|
||||||
);
|
|
||||||
assertEquals(
|
|
||||||
getResourceRuleValueValidationError("ASN", "AS0"),
|
|
||||||
null,
|
|
||||||
"AS0 alias should be valid"
|
|
||||||
);
|
|
||||||
assertEquals(
|
|
||||||
getResourceRuleValueValidationError("ASN", " as0 "),
|
|
||||||
null,
|
|
||||||
"AS0 alias should be valid with mixed case and whitespace"
|
|
||||||
);
|
|
||||||
assertEquals(
|
|
||||||
getResourceRuleValueValidationError("ASN", "not-an-asn"),
|
|
||||||
"Invalid ASN provided",
|
|
||||||
"Invalid ASN should return an error"
|
|
||||||
);
|
|
||||||
|
|
||||||
console.log("All tests passed!");
|
console.log("All tests passed!");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -100,10 +100,7 @@ export function getResourceRuleValueValidationError(
|
|||||||
? null
|
? null
|
||||||
: "Invalid country code provided";
|
: "Invalid country code provided";
|
||||||
case "ASN":
|
case "ASN":
|
||||||
const normalizedValue = value.trim().toUpperCase();
|
return /^AS\d+$/i.test(value.trim())
|
||||||
return /^AS\d+$/.test(normalizedValue) ||
|
|
||||||
normalizedValue === "ALL" ||
|
|
||||||
normalizedValue === "AS0"
|
|
||||||
? null
|
? null
|
||||||
: "Invalid ASN provided";
|
: "Invalid ASN provided";
|
||||||
default:
|
default:
|
||||||
|
|||||||
@@ -119,7 +119,8 @@ export async function verifyAccessTokenAccess(
|
|||||||
return next(
|
return next(
|
||||||
createHttpError(
|
createHttpError(
|
||||||
HttpCode.FORBIDDEN,
|
HttpCode.FORBIDDEN,
|
||||||
"" + (policyCheck.error || "Unknown error")
|
"Failed organization access policy check: " +
|
||||||
|
(policyCheck.error || "Unknown error")
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -56,7 +56,8 @@ export async function verifyAdmin(
|
|||||||
return next(
|
return next(
|
||||||
createHttpError(
|
createHttpError(
|
||||||
HttpCode.FORBIDDEN,
|
HttpCode.FORBIDDEN,
|
||||||
"" + (policyCheck.error || "Unknown error")
|
"Failed organization access policy check: " +
|
||||||
|
(policyCheck.error || "Unknown error")
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -113,7 +113,8 @@ export async function verifyApiKeyAccess(
|
|||||||
return next(
|
return next(
|
||||||
createHttpError(
|
createHttpError(
|
||||||
HttpCode.FORBIDDEN,
|
HttpCode.FORBIDDEN,
|
||||||
"" + (policyCheck.error || "Unknown error")
|
"Failed organization access policy check: " +
|
||||||
|
(policyCheck.error || "Unknown error")
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -107,7 +107,8 @@ export async function verifyClientAccess(
|
|||||||
return next(
|
return next(
|
||||||
createHttpError(
|
createHttpError(
|
||||||
HttpCode.FORBIDDEN,
|
HttpCode.FORBIDDEN,
|
||||||
"" + (policyCheck.error || "Unknown error")
|
"Failed organization access policy check: " +
|
||||||
|
(policyCheck.error || "Unknown error")
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -128,7 +129,10 @@ export async function verifyClientAccess(
|
|||||||
.where(
|
.where(
|
||||||
and(
|
and(
|
||||||
eq(roleClients.clientId, client.clientId),
|
eq(roleClients.clientId, client.clientId),
|
||||||
inArray(roleClients.roleId, req.userOrgRoleIds!)
|
inArray(
|
||||||
|
roleClients.roleId,
|
||||||
|
req.userOrgRoleIds!
|
||||||
|
)
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
.limit(1)
|
.limit(1)
|
||||||
|
|||||||
@@ -88,7 +88,8 @@ export async function verifyDomainAccess(
|
|||||||
return next(
|
return next(
|
||||||
createHttpError(
|
createHttpError(
|
||||||
HttpCode.FORBIDDEN,
|
HttpCode.FORBIDDEN,
|
||||||
"" + (policyCheck.error || "Unknown error")
|
"Failed organization access policy check: " +
|
||||||
|
(policyCheck.error || "Unknown error")
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,7 +7,6 @@ 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,
|
||||||
@@ -60,7 +59,8 @@ export async function verifyOrgAccess(
|
|||||||
return next(
|
return next(
|
||||||
createHttpError(
|
createHttpError(
|
||||||
HttpCode.FORBIDDEN,
|
HttpCode.FORBIDDEN,
|
||||||
"" + (policyCheck.error || "Unknown error")
|
"Failed organization access policy check: " +
|
||||||
|
(policyCheck.error || "Unknown error")
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -105,7 +105,8 @@ export async function verifyResourceAccess(
|
|||||||
return next(
|
return next(
|
||||||
createHttpError(
|
createHttpError(
|
||||||
HttpCode.FORBIDDEN,
|
HttpCode.FORBIDDEN,
|
||||||
"" + (policyCheck.error || "Unknown error")
|
"Failed organization access policy check: " +
|
||||||
|
(policyCheck.error || "Unknown error")
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -102,7 +102,8 @@ export async function verifyResourcePolicyAccess(
|
|||||||
return next(
|
return next(
|
||||||
createHttpError(
|
createHttpError(
|
||||||
HttpCode.FORBIDDEN,
|
HttpCode.FORBIDDEN,
|
||||||
"" + (policyCheck.error || "Unknown error")
|
"Failed organization access policy check: " +
|
||||||
|
(policyCheck.error || "Unknown error")
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -132,7 +132,8 @@ export async function verifyRoleAccess(
|
|||||||
return next(
|
return next(
|
||||||
createHttpError(
|
createHttpError(
|
||||||
HttpCode.FORBIDDEN,
|
HttpCode.FORBIDDEN,
|
||||||
"" + (policyCheck.error || "Unknown error")
|
"Failed organization access policy check: " +
|
||||||
|
(policyCheck.error || "Unknown error")
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -45,7 +45,8 @@ export async function verifySetResourceClients(
|
|||||||
return next(
|
return next(
|
||||||
createHttpError(
|
createHttpError(
|
||||||
HttpCode.FORBIDDEN,
|
HttpCode.FORBIDDEN,
|
||||||
"" + (policyCheck.error || "Unknown error")
|
"Failed organization access policy check: " +
|
||||||
|
(policyCheck.error || "Unknown error")
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -40,7 +40,8 @@ export async function verifySetResourceUsers(
|
|||||||
return next(
|
return next(
|
||||||
createHttpError(
|
createHttpError(
|
||||||
HttpCode.FORBIDDEN,
|
HttpCode.FORBIDDEN,
|
||||||
"" + (policyCheck.error || "Unknown error")
|
"Failed organization access policy check: " +
|
||||||
|
(policyCheck.error || "Unknown error")
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -115,7 +115,8 @@ export async function verifySiteAccess(
|
|||||||
return next(
|
return next(
|
||||||
createHttpError(
|
createHttpError(
|
||||||
HttpCode.FORBIDDEN,
|
HttpCode.FORBIDDEN,
|
||||||
"" + (policyCheck.error || "Unknown error")
|
"Failed organization access policy check: " +
|
||||||
|
(policyCheck.error || "Unknown error")
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -115,7 +115,8 @@ export async function verifySiteProvisioningKeyAccess(
|
|||||||
return next(
|
return next(
|
||||||
createHttpError(
|
createHttpError(
|
||||||
HttpCode.FORBIDDEN,
|
HttpCode.FORBIDDEN,
|
||||||
"" + (policyCheck.error || "Unknown error")
|
"Failed organization access policy check: " +
|
||||||
|
(policyCheck.error || "Unknown error")
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -103,7 +103,8 @@ export async function verifySiteResourceAccess(
|
|||||||
return next(
|
return next(
|
||||||
createHttpError(
|
createHttpError(
|
||||||
HttpCode.FORBIDDEN,
|
HttpCode.FORBIDDEN,
|
||||||
"" + (policyCheck.error || "Unknown error")
|
"Failed organization access policy check: " +
|
||||||
|
(policyCheck.error || "Unknown error")
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -122,7 +122,8 @@ export async function verifyTargetAccess(
|
|||||||
return next(
|
return next(
|
||||||
createHttpError(
|
createHttpError(
|
||||||
HttpCode.FORBIDDEN,
|
HttpCode.FORBIDDEN,
|
||||||
"" + (policyCheck.error || "Unknown error")
|
"Failed organization access policy check: " +
|
||||||
|
(policyCheck.error || "Unknown error")
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -59,7 +59,8 @@ export async function verifyUserAccess(
|
|||||||
return next(
|
return next(
|
||||||
createHttpError(
|
createHttpError(
|
||||||
HttpCode.FORBIDDEN,
|
HttpCode.FORBIDDEN,
|
||||||
"" + (policyCheck.error || "Unknown error")
|
"Failed organization access policy check: " +
|
||||||
|
(policyCheck.error || "Unknown error")
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ import { certificates, db } from "@server/db";
|
|||||||
import { and, eq, isNotNull, or, inArray, sql } from "drizzle-orm";
|
import { and, eq, isNotNull, or, inArray, sql } from "drizzle-orm";
|
||||||
import { decrypt } from "@server/lib/crypto";
|
import { decrypt } from "@server/lib/crypto";
|
||||||
import logger from "@server/logger";
|
import logger from "@server/logger";
|
||||||
import { regionalCache as cache } from "#private/lib/cache";
|
import cache from "#private/lib/cache";
|
||||||
import { build } from "@server/build";
|
import { build } from "@server/build";
|
||||||
|
|
||||||
// Define the return type for clarity and type safety
|
// Define the return type for clarity and type safety
|
||||||
|
|||||||
@@ -21,49 +21,6 @@ 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
|
||||||
@@ -79,17 +36,13 @@ export function enforceResourceSessionLength(
|
|||||||
if (sessionAgeMs > maxSessionLengthMs) {
|
if (sessionAgeMs > maxSessionLengthMs) {
|
||||||
return {
|
return {
|
||||||
valid: false,
|
valid: false,
|
||||||
error: `Your resource session has expired. ${formatMaxSessionLengthRequirement(
|
error: `Resource session has expired due to organization policy (max session length: ${maxSessionLengthHours} hours)`
|
||||||
maxSessionLengthHours
|
|
||||||
)}`
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
return {
|
return {
|
||||||
valid: false,
|
valid: false,
|
||||||
error: `Your resource session is invalid. ${formatMaxSessionLengthRequirement(
|
error: `Resource session is invalid due to organization policy (max session length: ${maxSessionLengthHours} hours)`
|
||||||
maxSessionLengthHours
|
|
||||||
)}`
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -107,20 +60,14 @@ export async function checkOrgAccessPolicy(
|
|||||||
if (!orgId) {
|
if (!orgId) {
|
||||||
return {
|
return {
|
||||||
allowed: false,
|
allowed: false,
|
||||||
error: "Unable to verify organization access. Organization information is missing."
|
error: "Organization ID is required"
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
if (!userId) {
|
if (!userId) {
|
||||||
return {
|
return { allowed: false, error: "User ID is required" };
|
||||||
allowed: false,
|
|
||||||
error: "Unable to verify organization access. User information is missing."
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
if (!sessionId) {
|
if (!sessionId) {
|
||||||
return {
|
return { allowed: false, error: "Session ID is required" };
|
||||||
allowed: false,
|
|
||||||
error: "Your session is invalid. Please log in again."
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (build === "enterprise") {
|
if (build === "enterprise") {
|
||||||
@@ -142,10 +89,7 @@ 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 {
|
return { allowed: false, error: "Organization not found" };
|
||||||
allowed: false,
|
|
||||||
error: "This organization could not be found."
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -156,10 +100,7 @@ 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 {
|
return { allowed: false, error: "User not found" };
|
||||||
allowed: false,
|
|
||||||
error: "Your account could not be found."
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -170,17 +111,14 @@ 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 {
|
return { allowed: false, error: "Session not found" };
|
||||||
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: "Your session is invalid. Please log in again."
|
error: "Session does not belong to the user"
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -249,14 +187,8 @@ 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.")
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,15 +18,12 @@ import {
|
|||||||
resources,
|
resources,
|
||||||
targets,
|
targets,
|
||||||
sites,
|
sites,
|
||||||
siteLabels,
|
|
||||||
remoteExitNodes,
|
|
||||||
remoteExitNodePreferenceLabels,
|
|
||||||
targetHealthCheck,
|
targetHealthCheck,
|
||||||
Transaction
|
Transaction
|
||||||
} from "@server/db";
|
} from "@server/db";
|
||||||
import logger from "@server/logger";
|
import logger from "@server/logger";
|
||||||
import { ExitNodePingResult } from "@server/routers/newt";
|
import { ExitNodePingResult } from "@server/routers/newt";
|
||||||
import { eq, and, or, ne, isNull, inArray } from "drizzle-orm";
|
import { eq, and, or, ne, isNull } from "drizzle-orm";
|
||||||
import axios from "axios";
|
import axios from "axios";
|
||||||
import config from "../config";
|
import config from "../config";
|
||||||
|
|
||||||
@@ -153,8 +150,7 @@ export async function verifyExitNodeOrgAccess(
|
|||||||
export async function listExitNodes(
|
export async function listExitNodes(
|
||||||
orgId: string,
|
orgId: string,
|
||||||
filterOnline = false,
|
filterOnline = false,
|
||||||
noCloud = false,
|
noCloud = false
|
||||||
siteId?: number
|
|
||||||
) {
|
) {
|
||||||
const allExitNodes = await db
|
const allExitNodes = await db
|
||||||
.select({
|
.select({
|
||||||
@@ -241,7 +237,7 @@ export async function listExitNodes(
|
|||||||
// })
|
// })
|
||||||
// );
|
// );
|
||||||
|
|
||||||
let remoteExitNodesList = allExitNodes.filter(
|
const remoteExitNodes = allExitNodes.filter(
|
||||||
(node) =>
|
(node) =>
|
||||||
node.type === "remoteExitNode" && (!filterOnline || node.online)
|
node.type === "remoteExitNode" && (!filterOnline || node.online)
|
||||||
);
|
);
|
||||||
@@ -250,82 +246,9 @@ export async function listExitNodes(
|
|||||||
node.type === "gerbil" && (!filterOnline || node.online) && !noCloud
|
node.type === "gerbil" && (!filterOnline || node.online) && !noCloud
|
||||||
);
|
);
|
||||||
|
|
||||||
// Apply label-based filtering to remote exit nodes if siteId is provided
|
|
||||||
if (siteId !== undefined && remoteExitNodesList.length > 0) {
|
|
||||||
// Get the site's labels
|
|
||||||
const siteLabelRows = await db
|
|
||||||
.select({ labelId: siteLabels.labelId })
|
|
||||||
.from(siteLabels)
|
|
||||||
.where(eq(siteLabels.siteId, siteId));
|
|
||||||
const siteLabelIds = new Set(siteLabelRows.map((r) => r.labelId));
|
|
||||||
|
|
||||||
// Get the remoteExitNode records for these exit nodes so we have the remoteExitNodeId
|
|
||||||
const exitNodeIds = remoteExitNodesList.map((n) => n.exitNodeId);
|
|
||||||
const remoteNodeRows = await db
|
|
||||||
.select({
|
|
||||||
exitNodeId: remoteExitNodes.exitNodeId,
|
|
||||||
remoteExitNodeId: remoteExitNodes.remoteExitNodeId
|
|
||||||
})
|
|
||||||
.from(remoteExitNodes)
|
|
||||||
.where(inArray(remoteExitNodes.exitNodeId, exitNodeIds));
|
|
||||||
|
|
||||||
const exitNodeIdToRemoteId = new Map(
|
|
||||||
remoteNodeRows
|
|
||||||
.filter((r) => r.exitNodeId !== null)
|
|
||||||
.map((r) => [r.exitNodeId!, r.remoteExitNodeId])
|
|
||||||
);
|
|
||||||
|
|
||||||
// Get preference labels for all remote exit nodes
|
|
||||||
const remoteExitNodeIds = remoteNodeRows.map((r) => r.remoteExitNodeId);
|
|
||||||
const prefLabelRows =
|
|
||||||
remoteExitNodeIds.length > 0
|
|
||||||
? await db
|
|
||||||
.select({
|
|
||||||
remoteExitNodeId:
|
|
||||||
remoteExitNodePreferenceLabels.remoteExitNodeId,
|
|
||||||
labelId: remoteExitNodePreferenceLabels.labelId
|
|
||||||
})
|
|
||||||
.from(remoteExitNodePreferenceLabels)
|
|
||||||
.where(
|
|
||||||
inArray(
|
|
||||||
remoteExitNodePreferenceLabels.remoteExitNodeId,
|
|
||||||
remoteExitNodeIds
|
|
||||||
)
|
|
||||||
)
|
|
||||||
: [];
|
|
||||||
|
|
||||||
// Build a map of remoteExitNodeId -> Set of labelIds
|
|
||||||
const prefLabelsMap = new Map<string, Set<number>>();
|
|
||||||
for (const row of prefLabelRows) {
|
|
||||||
if (!prefLabelsMap.has(row.remoteExitNodeId)) {
|
|
||||||
prefLabelsMap.set(row.remoteExitNodeId, new Set());
|
|
||||||
}
|
|
||||||
prefLabelsMap.get(row.remoteExitNodeId)!.add(row.labelId);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Filter: include node if it has no preference labels, or if site shares at least one label
|
|
||||||
const filtered = remoteExitNodesList.filter((node) => {
|
|
||||||
const remoteId = exitNodeIdToRemoteId.get(node.exitNodeId);
|
|
||||||
if (!remoteId) return true; // no remoteExitNode record, don't filter
|
|
||||||
const prefLabels = prefLabelsMap.get(remoteId);
|
|
||||||
if (!prefLabels || prefLabels.size === 0) return true; // no preference labels, include
|
|
||||||
// include only if site has at least one matching label
|
|
||||||
for (const labelId of siteLabelIds) {
|
|
||||||
if (prefLabels.has(labelId)) return true;
|
|
||||||
}
|
|
||||||
return false;
|
|
||||||
});
|
|
||||||
|
|
||||||
// Only apply the filtered list if at least one remote node remains;
|
|
||||||
// otherwise fall through to the gerbil fallback below
|
|
||||||
if (filtered.length > 0 || remoteExitNodesList.length === 0) {
|
|
||||||
remoteExitNodesList = filtered;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// THIS PROVIDES THE FALL
|
// THIS PROVIDES THE FALL
|
||||||
const exitNodesList =
|
const exitNodesList =
|
||||||
remoteExitNodesList.length > 0 ? remoteExitNodesList : gerbilExitNodes;
|
remoteExitNodes.length > 0 ? remoteExitNodes : gerbilExitNodes;
|
||||||
|
|
||||||
return exitNodesList;
|
return exitNodesList;
|
||||||
}
|
}
|
||||||
|
|||||||
+48
-105
@@ -11,31 +11,14 @@
|
|||||||
* 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
|
||||||
@@ -49,34 +32,12 @@ 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") {
|
||||||
for (let attempt = 0; attempt < maxRetries; attempt++) {
|
return true;
|
||||||
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 = `${instanceId}:${Date.now()}`;
|
const lockValue = `${
|
||||||
|
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++) {
|
||||||
@@ -92,7 +53,11 @@ export class LockManager {
|
|||||||
);
|
);
|
||||||
|
|
||||||
if (result === "OK") {
|
if (result === "OK") {
|
||||||
logger.debug(`Lock acquired: ${lockKey} by ${instanceId}`);
|
logger.debug(
|
||||||
|
`Lock acquired: ${lockKey} by ${
|
||||||
|
instanceId
|
||||||
|
}`
|
||||||
|
);
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -100,11 +65,17 @@ export class LockManager {
|
|||||||
const existingValue = await redis.get(redisKey);
|
const existingValue = await redis.get(redisKey);
|
||||||
if (
|
if (
|
||||||
existingValue &&
|
existingValue &&
|
||||||
existingValue.startsWith(`${instanceId}:`)
|
existingValue.startsWith(
|
||||||
|
`${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(`Lock extended: ${lockKey} by ${instanceId}`);
|
logger.debug(
|
||||||
|
`Lock extended: ${lockKey} by ${
|
||||||
|
instanceId
|
||||||
|
}`
|
||||||
|
);
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -117,10 +88,7 @@ export class LockManager {
|
|||||||
await new Promise((resolve) => setTimeout(resolve, delay));
|
await new Promise((resolve) => setTimeout(resolve, delay));
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error(
|
logger.error(`Failed to acquire lock ${lockKey} (attempt ${attempt + 1}/${maxRetries}):`, 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);
|
||||||
@@ -141,11 +109,6 @@ 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;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -173,7 +136,11 @@ export class LockManager {
|
|||||||
)) as number;
|
)) as number;
|
||||||
|
|
||||||
if (result === 1) {
|
if (result === 1) {
|
||||||
logger.debug(`Lock released: ${lockKey} by ${instanceId}`);
|
logger.debug(
|
||||||
|
`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 ${
|
||||||
@@ -192,7 +159,6 @@ 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;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -220,20 +186,7 @@ export class LockManager {
|
|||||||
owner?: string;
|
owner?: string;
|
||||||
}> {
|
}> {
|
||||||
if (!redis || !redis.status || redis.status !== "ready") {
|
if (!redis || !redis.status || redis.status !== "ready") {
|
||||||
this.clearExpiredLocalLock(lockKey);
|
return { exists: false, ownedByMe: true, ttl: 0 };
|
||||||
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}`;
|
||||||
@@ -245,7 +198,11 @@ export class LockManager {
|
|||||||
]);
|
]);
|
||||||
|
|
||||||
const exists = value !== null;
|
const exists = value !== null;
|
||||||
const ownedByMe = exists && value!.startsWith(`${instanceId}:`);
|
const ownedByMe =
|
||||||
|
exists &&
|
||||||
|
value!.startsWith(
|
||||||
|
`${instanceId}:`
|
||||||
|
);
|
||||||
const owner = exists ? value!.split(":")[0] : undefined;
|
const owner = exists ? value!.split(":")[0] : undefined;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -268,15 +225,6 @@ 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;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -307,7 +255,9 @@ export class LockManager {
|
|||||||
|
|
||||||
if (result === 1) {
|
if (result === 1) {
|
||||||
logger.debug(
|
logger.debug(
|
||||||
`Lock extended: ${lockKey} by ${instanceId} for ${ttlMs}ms`
|
`Lock extended: ${lockKey} by ${
|
||||||
|
instanceId
|
||||||
|
} for ${ttlMs}ms`
|
||||||
);
|
);
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
@@ -332,13 +282,12 @@ 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(
|
const acquired = await this.acquireLock(lockKey, ttlMs);
|
||||||
lockKey,
|
|
||||||
ttlMs,
|
|
||||||
1,
|
|
||||||
baseDelayMs
|
|
||||||
);
|
|
||||||
|
|
||||||
if (acquired) {
|
if (acquired) {
|
||||||
return true;
|
return true;
|
||||||
@@ -370,6 +319,10 @@ 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) {
|
||||||
@@ -393,21 +346,7 @@ export class LockManager {
|
|||||||
locksOwnedByMe: number;
|
locksOwnedByMe: number;
|
||||||
}> {
|
}> {
|
||||||
if (!redis || !redis.status || redis.status !== "ready") {
|
if (!redis || !redis.status || redis.status !== "ready") {
|
||||||
const now = Date.now();
|
return { activeLocksCount: 0, locksOwnedByMe: 0 };
|
||||||
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 {
|
||||||
@@ -417,7 +356,11 @@ 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.startsWith(`${instanceId}:`)
|
(value) =>
|
||||||
|
value &&
|
||||||
|
value.startsWith(
|
||||||
|
`${instanceId}:`
|
||||||
|
)
|
||||||
).length;
|
).length;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,209 +0,0 @@
|
|||||||
/*
|
|
||||||
* This file is part of a proprietary work.
|
|
||||||
*
|
|
||||||
* Copyright (c) 2025-2026 Fossorial, Inc.
|
|
||||||
* All rights reserved.
|
|
||||||
*
|
|
||||||
* This file is licensed under the Fossorial Commercial License.
|
|
||||||
* You may not use this file except in compliance with the License.
|
|
||||||
* Unauthorized use, copying, modification, or distribution is strictly prohibited.
|
|
||||||
*
|
|
||||||
* This file is not licensed under the AGPLv3.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { redis } from "#private/lib/redis";
|
|
||||||
import { lockManager } from "#private/lib/lock";
|
|
||||||
import logger from "@server/logger";
|
|
||||||
|
|
||||||
export type RebuildJobType = "site-resource" | "client";
|
|
||||||
|
|
||||||
export interface RebuildJob {
|
|
||||||
type: RebuildJobType;
|
|
||||||
id: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface RebuildJobHandlers {
|
|
||||||
onSiteResource(siteResourceId: number): Promise<void>;
|
|
||||||
onClient(clientId: number): Promise<void>;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Redis list holding pending rebuild jobs (RPUSH to enqueue, LPOP to dequeue — FIFO order).
|
|
||||||
const QUEUE_KEY = "rebuild-client-associations:queue";
|
|
||||||
const QUEUED_SET_KEY = "rebuild-client-associations:queued";
|
|
||||||
|
|
||||||
// Distributed lock that serialises queue consumption to a single server instance
|
|
||||||
// at a time. TTL is generous enough to cover a full batch of expensive rebuilds.
|
|
||||||
const PROCESSOR_LOCK_KEY = "rebuild-client-associations:processor";
|
|
||||||
|
|
||||||
// Each rebuild can take up to REBUILD_ASSOCIATIONS_LOCK_TTL_MS (120 s) per
|
|
||||||
// resource. Allow BATCH_SIZE resources per processor-lock acquisition, plus a
|
|
||||||
// small buffer.
|
|
||||||
const BATCH_SIZE = 5;
|
|
||||||
const PROCESSOR_LOCK_TTL_MS = 120000 * BATCH_SIZE + 30000; // ~630 s
|
|
||||||
|
|
||||||
const POLL_INTERVAL_MS = 500;
|
|
||||||
|
|
||||||
class RedisRebuildQueue {
|
|
||||||
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> {
|
|
||||||
if (!redis || redis.status !== "ready") {
|
|
||||||
logger.warn(
|
|
||||||
`Rebuild queue: Redis not available — rebuild for ${job.type}:${job.id} will not be retried`
|
|
||||||
);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const dedupeKey = `${job.type}:${job.id}`;
|
|
||||||
const added = await redis.sadd(QUEUED_SET_KEY, dedupeKey);
|
|
||||||
if (added === 0) {
|
|
||||||
logger.debug(
|
|
||||||
`Rebuild queue: skipped duplicate queued job ${job.type}:${job.id}`
|
|
||||||
);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
await redis.rpush(QUEUE_KEY, JSON.stringify(job));
|
|
||||||
logger.debug(
|
|
||||||
`Rebuild queue: enqueued ${job.type}:${job.id} (queue position: tail)`
|
|
||||||
);
|
|
||||||
} catch (err) {
|
|
||||||
await redis
|
|
||||||
.srem(QUEUED_SET_KEY, `${job.type}:${job.id}`)
|
|
||||||
.catch((cleanupErr) =>
|
|
||||||
logger.warn(
|
|
||||||
`Rebuild queue: failed to cleanup dedupe key for ${job.type}:${job.id} after enqueue failure:`,
|
|
||||||
cleanupErr
|
|
||||||
)
|
|
||||||
);
|
|
||||||
logger.error(
|
|
||||||
`Rebuild queue: failed to enqueue ${job.type}:${job.id}:`,
|
|
||||||
err
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
startProcessing(handlers: RebuildJobHandlers): void {
|
|
||||||
if (this.processingStarted) return;
|
|
||||||
this.processingStarted = true;
|
|
||||||
|
|
||||||
this.processLoop(handlers).catch((err) => {
|
|
||||||
logger.error("Rebuild queue processor loop crashed:", err);
|
|
||||||
});
|
|
||||||
|
|
||||||
logger.info("Rebuild queue processor started");
|
|
||||||
}
|
|
||||||
|
|
||||||
private async processLoop(handlers: RebuildJobHandlers): Promise<void> {
|
|
||||||
while (true) {
|
|
||||||
try {
|
|
||||||
await this.tryProcessBatch(handlers);
|
|
||||||
} catch (err) {
|
|
||||||
logger.error(
|
|
||||||
"Rebuild queue: unhandled error in process loop:",
|
|
||||||
err
|
|
||||||
);
|
|
||||||
}
|
|
||||||
await new Promise((resolve) =>
|
|
||||||
setTimeout(resolve, POLL_INTERVAL_MS)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private async tryProcessBatch(handlers: RebuildJobHandlers): Promise<void> {
|
|
||||||
if (!redis || redis.status !== "ready") return;
|
|
||||||
|
|
||||||
// Peek before acquiring the processor lock to avoid unnecessary Redis
|
|
||||||
// round-trips and lock contention when the queue is idle.
|
|
||||||
const queueLength = await redis.llen(QUEUE_KEY).catch(() => 0);
|
|
||||||
if (queueLength === 0) return;
|
|
||||||
|
|
||||||
try {
|
|
||||||
await lockManager.withLock(
|
|
||||||
PROCESSOR_LOCK_KEY,
|
|
||||||
async () => {
|
|
||||||
for (let i = 0; i < BATCH_SIZE; i++) {
|
|
||||||
if (!redis || redis.status !== "ready") break;
|
|
||||||
|
|
||||||
const payload = await redis.lpop(QUEUE_KEY);
|
|
||||||
if (payload === null) break; // queue drained
|
|
||||||
|
|
||||||
let job: RebuildJob;
|
|
||||||
try {
|
|
||||||
job = JSON.parse(payload) as RebuildJob;
|
|
||||||
} catch {
|
|
||||||
logger.error(
|
|
||||||
`Rebuild queue: could not parse job payload, discarding: ${payload}`
|
|
||||||
);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Remove from dedupe set once dequeued so the same job
|
|
||||||
// can be re-queued while this one is in progress.
|
|
||||||
await redis
|
|
||||||
.srem(QUEUED_SET_KEY, `${job.type}:${job.id}`)
|
|
||||||
.catch((cleanupErr) =>
|
|
||||||
logger.warn(
|
|
||||||
`Rebuild queue: failed to remove dedupe key for ${job.type}:${job.id} on dequeue:`,
|
|
||||||
cleanupErr
|
|
||||||
)
|
|
||||||
);
|
|
||||||
|
|
||||||
logger.debug(
|
|
||||||
`Rebuild queue: processing ${job.type}:${job.id}`
|
|
||||||
);
|
|
||||||
|
|
||||||
try {
|
|
||||||
if (job.type === "site-resource") {
|
|
||||||
await handlers.onSiteResource(job.id);
|
|
||||||
} else if (job.type === "client") {
|
|
||||||
await handlers.onClient(job.id);
|
|
||||||
} else {
|
|
||||||
logger.warn(
|
|
||||||
`Rebuild queue: unknown job type "${(job as any).type}", discarding`
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
logger.debug(
|
|
||||||
`Rebuild queue: completed ${job.type}:${job.id}`
|
|
||||||
);
|
|
||||||
} catch (err) {
|
|
||||||
logger.error(
|
|
||||||
`Rebuild queue: job ${job.type}:${job.id} threw an error:`,
|
|
||||||
err
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
PROCESSOR_LOCK_TTL_MS
|
|
||||||
);
|
|
||||||
} catch (err: any) {
|
|
||||||
if (
|
|
||||||
typeof err?.message === "string" &&
|
|
||||||
err.message.startsWith("Failed to acquire lock")
|
|
||||||
) {
|
|
||||||
// Another server instance currently holds the processor lock and
|
|
||||||
// is consuming the queue — nothing to do this cycle.
|
|
||||||
logger.debug(
|
|
||||||
"Rebuild queue: processor lock held by another instance, skipping this cycle"
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
throw err;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export const rebuildQueue: RedisRebuildQueue = new RedisRebuildQueue();
|
|
||||||
@@ -329,44 +329,6 @@ authenticated.delete(
|
|||||||
remoteExitNode.deleteRemoteExitNode
|
remoteExitNode.deleteRemoteExitNode
|
||||||
);
|
);
|
||||||
|
|
||||||
authenticated.get(
|
|
||||||
"/org/:orgId/remote-exit-node/:remoteExitNodeId/resources",
|
|
||||||
verifyValidLicense,
|
|
||||||
verifyOrgAccess,
|
|
||||||
verifyRemoteExitNodeAccess,
|
|
||||||
verifyUserHasAction(ActionsEnum.getRemoteExitNode),
|
|
||||||
remoteExitNode.listRemoteExitNodeResources
|
|
||||||
);
|
|
||||||
|
|
||||||
authenticated.post(
|
|
||||||
"/org/:orgId/remote-exit-node/:remoteExitNodeId/resources",
|
|
||||||
verifyValidLicense,
|
|
||||||
verifyOrgAccess,
|
|
||||||
verifyRemoteExitNodeAccess,
|
|
||||||
verifyUserHasAction(ActionsEnum.updateRemoteExitNode),
|
|
||||||
logActionAudit(ActionsEnum.updateRemoteExitNode),
|
|
||||||
remoteExitNode.setRemoteExitNodeResources
|
|
||||||
);
|
|
||||||
|
|
||||||
authenticated.get(
|
|
||||||
"/org/:orgId/remote-exit-node/:remoteExitNodeId/preference-labels",
|
|
||||||
verifyValidLicense,
|
|
||||||
verifyOrgAccess,
|
|
||||||
verifyRemoteExitNodeAccess,
|
|
||||||
verifyUserHasAction(ActionsEnum.getRemoteExitNode),
|
|
||||||
remoteExitNode.listRemoteExitNodePreferenceLabels
|
|
||||||
);
|
|
||||||
|
|
||||||
authenticated.post(
|
|
||||||
"/org/:orgId/remote-exit-node/:remoteExitNodeId/preference-labels",
|
|
||||||
verifyValidLicense,
|
|
||||||
verifyOrgAccess,
|
|
||||||
verifyRemoteExitNodeAccess,
|
|
||||||
verifyUserHasAction(ActionsEnum.updateRemoteExitNode),
|
|
||||||
logActionAudit(ActionsEnum.updateRemoteExitNode),
|
|
||||||
remoteExitNode.setRemoteExitNodePreferenceLabels
|
|
||||||
);
|
|
||||||
|
|
||||||
authenticated.put(
|
authenticated.put(
|
||||||
"/org/:orgId/login-page",
|
"/org/:orgId/login-page",
|
||||||
verifyValidLicense,
|
verifyValidLicense,
|
||||||
|
|||||||
@@ -29,40 +29,26 @@ const paramsSchema = z.strictObject({
|
|||||||
orgId: z.string().nonempty()
|
orgId: z.string().nonempty()
|
||||||
});
|
});
|
||||||
|
|
||||||
const bodySchema = z
|
const bodySchema = z.strictObject({
|
||||||
.strictObject({
|
name: z.string().nonempty(),
|
||||||
name: z.string().nonempty(),
|
siteId: z.number().int().positive(),
|
||||||
siteId: z.number().int().positive(),
|
hcEnabled: z.boolean().default(false),
|
||||||
hcEnabled: z.boolean().default(false),
|
hcMode: z.string().default("http"),
|
||||||
hcMode: z.string().default("http"),
|
hcHostname: z.string().optional(),
|
||||||
hcHostname: z.string().optional(),
|
hcPort: z.number().int().min(1).max(65535).optional(),
|
||||||
hcPort: z.number().int().min(1).max(65535).optional(),
|
hcPath: z.string().optional(),
|
||||||
hcPath: z.string().optional(),
|
hcScheme: z.string().optional(),
|
||||||
hcScheme: z.string().optional(),
|
hcMethod: z.string().default("GET"),
|
||||||
hcMethod: z.string().default("GET"),
|
hcInterval: z.number().int().positive().default(30),
|
||||||
hcInterval: z.number().int().positive().default(30),
|
hcUnhealthyInterval: z.number().int().positive().default(30),
|
||||||
hcUnhealthyInterval: z.number().int().positive().default(30),
|
hcTimeout: z.number().int().positive().default(1),
|
||||||
hcTimeout: z.number().int().positive().default(1),
|
hcHeaders: z.string().optional().nullable(),
|
||||||
hcHeaders: z.string().optional().nullable(),
|
hcFollowRedirects: z.boolean().default(true),
|
||||||
hcFollowRedirects: z.boolean().default(true),
|
hcStatus: z.number().int().optional().nullable(),
|
||||||
hcStatus: z.number().int().optional().nullable(),
|
hcTlsServerName: z.string().optional(),
|
||||||
hcTlsServerName: z.string().optional(),
|
hcHealthyThreshold: z.number().int().positive().default(1),
|
||||||
hcHealthyThreshold: z.number().int().positive().default(1),
|
hcUnhealthyThreshold: z.number().int().positive().default(1)
|
||||||
hcUnhealthyThreshold: z.number().int().positive().default(1)
|
});
|
||||||
})
|
|
||||||
.superRefine((data, ctx) => {
|
|
||||||
const hcHostnameMissing =
|
|
||||||
data.hcHostname === undefined ||
|
|
||||||
data.hcHostname.trim().length === 0;
|
|
||||||
|
|
||||||
if (data.hcEnabled === true && hcHostnameMissing) {
|
|
||||||
ctx.addIssue({
|
|
||||||
code: z.ZodIssueCode.custom,
|
|
||||||
path: ["hcHostname"],
|
|
||||||
message: "hcHostname is required when hcEnabled is true"
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
export type CreateHealthCheckResponse = {
|
export type CreateHealthCheckResponse = {
|
||||||
targetHealthCheckId: number;
|
targetHealthCheckId: number;
|
||||||
@@ -71,6 +57,7 @@ const CreateHealthCheckResponseDataSchema = z.object({
|
|||||||
targetHealthCheckId: z.number()
|
targetHealthCheckId: z.number()
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
||||||
registry.registerPath({
|
registry.registerPath({
|
||||||
method: "put",
|
method: "put",
|
||||||
path: "/org/{orgId}/health-check",
|
path: "/org/{orgId}/health-check",
|
||||||
@@ -91,9 +78,7 @@ registry.registerPath({
|
|||||||
description: "Successful response",
|
description: "Successful response",
|
||||||
content: {
|
content: {
|
||||||
"application/json": {
|
"application/json": {
|
||||||
schema: createApiResponseSchema(
|
schema: createApiResponseSchema(CreateHealthCheckResponseDataSchema)
|
||||||
CreateHealthCheckResponseDataSchema
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -105,6 +105,7 @@ const UpdateHealthCheckResponseDataSchema = z.object({
|
|||||||
hcUnhealthyThreshold: z.number().nullable()
|
hcUnhealthyThreshold: z.number().nullable()
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
||||||
registry.registerPath({
|
registry.registerPath({
|
||||||
method: "post",
|
method: "post",
|
||||||
path: "/org/{orgId}/health-check/{healthCheckId}",
|
path: "/org/{orgId}/health-check/{healthCheckId}",
|
||||||
@@ -125,9 +126,7 @@ registry.registerPath({
|
|||||||
description: "Successful response",
|
description: "Successful response",
|
||||||
content: {
|
content: {
|
||||||
"application/json": {
|
"application/json": {
|
||||||
schema: createApiResponseSchema(
|
schema: createApiResponseSchema(UpdateHealthCheckResponseDataSchema)
|
||||||
UpdateHealthCheckResponseDataSchema
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -216,32 +215,6 @@ export async function updateHealthCheck(
|
|||||||
)
|
)
|
||||||
.limit(1);
|
.limit(1);
|
||||||
|
|
||||||
if (!existingHealthCheck) {
|
|
||||||
return next(
|
|
||||||
createHttpError(
|
|
||||||
HttpCode.NOT_FOUND,
|
|
||||||
"Standalone health check not found"
|
|
||||||
)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const nextHcEnabled = hcEnabled ?? existingHealthCheck.hcEnabled;
|
|
||||||
const nextHcHostname =
|
|
||||||
hcHostname !== undefined
|
|
||||||
? hcHostname
|
|
||||||
: existingHealthCheck.hcHostname;
|
|
||||||
const hcHostnameMissing =
|
|
||||||
!nextHcHostname || nextHcHostname.trim().length === 0;
|
|
||||||
|
|
||||||
if (nextHcEnabled && hcHostnameMissing) {
|
|
||||||
return next(
|
|
||||||
createHttpError(
|
|
||||||
HttpCode.BAD_REQUEST,
|
|
||||||
"hcHostname is required when hcEnabled is true"
|
|
||||||
)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (name !== undefined) updateData.name = name;
|
if (name !== undefined) updateData.name = name;
|
||||||
if (siteId !== undefined) updateData.siteId = siteId;
|
if (siteId !== undefined) updateData.siteId = siteId;
|
||||||
if (hcEnabled !== undefined) updateData.hcEnabled = hcEnabled;
|
if (hcEnabled !== undefined) updateData.hcEnabled = hcEnabled;
|
||||||
|
|||||||
@@ -121,7 +121,7 @@ export async function unassociateOrgIdp(
|
|||||||
});
|
});
|
||||||
|
|
||||||
for (const userId of userIdsToRemove) {
|
for (const userId of userIdsToRemove) {
|
||||||
calculateUserClientsForOrgs(userId).catch((e) => {
|
calculateUserClientsForOrgs(userId, primaryDb).catch((e) => {
|
||||||
logger.error(
|
logger.error(
|
||||||
`Failed to calculate user clients after removing user ${userId} from org ${orgId} during IdP unassociation: ${e}`
|
`Failed to calculate user clients after removing user ${userId} from org ${orgId} during IdP unassociation: ${e}`
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -23,7 +23,3 @@ export * from "./pickRemoteExitNodeDefaults";
|
|||||||
export * from "./quickStartRemoteExitNode";
|
export * from "./quickStartRemoteExitNode";
|
||||||
export * from "./offlineChecker";
|
export * from "./offlineChecker";
|
||||||
export * from "./exitNodeReconnectScheduler";
|
export * from "./exitNodeReconnectScheduler";
|
||||||
export * from "./listRemoteExitNodeResources";
|
|
||||||
export * from "./setRemoteExitNodeResources";
|
|
||||||
export * from "./listRemoteExitNodePreferenceLabels";
|
|
||||||
export * from "./setRemoteExitNodePreferenceLabels";
|
|
||||||
|
|||||||
@@ -1,110 +0,0 @@
|
|||||||
/*
|
|
||||||
* This file is part of a proprietary work.
|
|
||||||
*
|
|
||||||
* Copyright (c) 2025-2026 Fossorial, Inc.
|
|
||||||
* All rights reserved.
|
|
||||||
*
|
|
||||||
* This file is licensed under the Fossorial Commercial License.
|
|
||||||
* You may not use this file except in compliance with the License.
|
|
||||||
* Unauthorized use, copying, modification, or distribution is strictly prohibited.
|
|
||||||
*
|
|
||||||
* This file is not licensed under the AGPLv3.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { NextFunction, Request, Response } from "express";
|
|
||||||
import { z } from "zod";
|
|
||||||
import {
|
|
||||||
db,
|
|
||||||
labels,
|
|
||||||
remoteExitNodePreferenceLabels,
|
|
||||||
remoteExitNodes
|
|
||||||
} from "@server/db";
|
|
||||||
import { eq } from "drizzle-orm";
|
|
||||||
import response from "@server/lib/response";
|
|
||||||
import HttpCode from "@server/types/HttpCode";
|
|
||||||
import createHttpError from "http-errors";
|
|
||||||
import logger from "@server/logger";
|
|
||||||
import { fromError } from "zod-validation-error";
|
|
||||||
|
|
||||||
const paramsSchema = z.strictObject({
|
|
||||||
orgId: z.string().min(1),
|
|
||||||
remoteExitNodeId: z.string().min(1)
|
|
||||||
});
|
|
||||||
|
|
||||||
export type ListRemoteExitNodePreferenceLabelsResponse = {
|
|
||||||
labels: {
|
|
||||||
remoteExitNodePreferenceLabelId: number;
|
|
||||||
labelId: number;
|
|
||||||
name: string;
|
|
||||||
color: string;
|
|
||||||
}[];
|
|
||||||
};
|
|
||||||
|
|
||||||
export async function listRemoteExitNodePreferenceLabels(
|
|
||||||
req: Request,
|
|
||||||
res: Response,
|
|
||||||
next: NextFunction
|
|
||||||
): Promise<any> {
|
|
||||||
try {
|
|
||||||
const parsedParams = paramsSchema.safeParse(req.params);
|
|
||||||
if (!parsedParams.success) {
|
|
||||||
return next(
|
|
||||||
createHttpError(
|
|
||||||
HttpCode.BAD_REQUEST,
|
|
||||||
fromError(parsedParams.error).toString()
|
|
||||||
)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const { remoteExitNodeId } = parsedParams.data;
|
|
||||||
|
|
||||||
const [remoteExitNode] = await db
|
|
||||||
.select()
|
|
||||||
.from(remoteExitNodes)
|
|
||||||
.where(eq(remoteExitNodes.remoteExitNodeId, remoteExitNodeId))
|
|
||||||
.limit(1);
|
|
||||||
|
|
||||||
if (!remoteExitNode) {
|
|
||||||
return next(
|
|
||||||
createHttpError(
|
|
||||||
HttpCode.NOT_FOUND,
|
|
||||||
`Remote exit node with ID ${remoteExitNodeId} not found`
|
|
||||||
)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const rows = await db
|
|
||||||
.select({
|
|
||||||
remoteExitNodePreferenceLabelId:
|
|
||||||
remoteExitNodePreferenceLabels.remoteExitNodePreferenceLabelId,
|
|
||||||
labelId: remoteExitNodePreferenceLabels.labelId,
|
|
||||||
name: labels.name,
|
|
||||||
color: labels.color
|
|
||||||
})
|
|
||||||
.from(remoteExitNodePreferenceLabels)
|
|
||||||
.innerJoin(
|
|
||||||
labels,
|
|
||||||
eq(labels.labelId, remoteExitNodePreferenceLabels.labelId)
|
|
||||||
)
|
|
||||||
.where(
|
|
||||||
eq(
|
|
||||||
remoteExitNodePreferenceLabels.remoteExitNodeId,
|
|
||||||
remoteExitNodeId
|
|
||||||
)
|
|
||||||
);
|
|
||||||
|
|
||||||
return response<ListRemoteExitNodePreferenceLabelsResponse>(res, {
|
|
||||||
data: { labels: rows },
|
|
||||||
success: true,
|
|
||||||
error: false,
|
|
||||||
message:
|
|
||||||
"Remote exit node preference labels retrieved successfully",
|
|
||||||
status: HttpCode.OK
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
logger.error(error);
|
|
||||||
return next(
|
|
||||||
createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred")
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,90 +0,0 @@
|
|||||||
/*
|
|
||||||
* This file is part of a proprietary work.
|
|
||||||
*
|
|
||||||
* Copyright (c) 2025-2026 Fossorial, Inc.
|
|
||||||
* All rights reserved.
|
|
||||||
*
|
|
||||||
* This file is licensed under the Fossorial Commercial License.
|
|
||||||
* You may not use this file except in compliance with the License.
|
|
||||||
* Unauthorized use, copying, modification, or distribution is strictly prohibited.
|
|
||||||
*
|
|
||||||
* This file is not licensed under the AGPLv3.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { NextFunction, Request, Response } from "express";
|
|
||||||
import { z } from "zod";
|
|
||||||
import { db, remoteExitNodeResources, remoteExitNodes } from "@server/db";
|
|
||||||
import { eq } from "drizzle-orm";
|
|
||||||
import response from "@server/lib/response";
|
|
||||||
import HttpCode from "@server/types/HttpCode";
|
|
||||||
import createHttpError from "http-errors";
|
|
||||||
import logger from "@server/logger";
|
|
||||||
import { fromError } from "zod-validation-error";
|
|
||||||
|
|
||||||
const paramsSchema = z.strictObject({
|
|
||||||
orgId: z.string().min(1),
|
|
||||||
remoteExitNodeId: z.string().min(1)
|
|
||||||
});
|
|
||||||
|
|
||||||
export type ListRemoteExitNodeResourcesResponse = {
|
|
||||||
resources: {
|
|
||||||
remoteExitNodeResourceId: number;
|
|
||||||
remoteExitNodeId: string;
|
|
||||||
destination: string;
|
|
||||||
}[];
|
|
||||||
};
|
|
||||||
|
|
||||||
export async function listRemoteExitNodeResources(
|
|
||||||
req: Request,
|
|
||||||
res: Response,
|
|
||||||
next: NextFunction
|
|
||||||
): Promise<any> {
|
|
||||||
try {
|
|
||||||
const parsedParams = paramsSchema.safeParse(req.params);
|
|
||||||
if (!parsedParams.success) {
|
|
||||||
return next(
|
|
||||||
createHttpError(
|
|
||||||
HttpCode.BAD_REQUEST,
|
|
||||||
fromError(parsedParams.error).toString()
|
|
||||||
)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const { remoteExitNodeId } = parsedParams.data;
|
|
||||||
|
|
||||||
const [remoteExitNode] = await db
|
|
||||||
.select()
|
|
||||||
.from(remoteExitNodes)
|
|
||||||
.where(eq(remoteExitNodes.remoteExitNodeId, remoteExitNodeId))
|
|
||||||
.limit(1);
|
|
||||||
|
|
||||||
if (!remoteExitNode) {
|
|
||||||
return next(
|
|
||||||
createHttpError(
|
|
||||||
HttpCode.NOT_FOUND,
|
|
||||||
`Remote exit node with ID ${remoteExitNodeId} not found`
|
|
||||||
)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const resources = await db
|
|
||||||
.select()
|
|
||||||
.from(remoteExitNodeResources)
|
|
||||||
.where(
|
|
||||||
eq(remoteExitNodeResources.remoteExitNodeId, remoteExitNodeId)
|
|
||||||
);
|
|
||||||
|
|
||||||
return response<ListRemoteExitNodeResourcesResponse>(res, {
|
|
||||||
data: { resources },
|
|
||||||
success: true,
|
|
||||||
error: false,
|
|
||||||
message: "Remote exit node resources retrieved successfully",
|
|
||||||
status: HttpCode.OK
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
logger.error(error);
|
|
||||||
return next(
|
|
||||||
createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred")
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -22,7 +22,7 @@ import createHttpError from "http-errors";
|
|||||||
import logger from "@server/logger";
|
import logger from "@server/logger";
|
||||||
import { fromError } from "zod-validation-error";
|
import { fromError } from "zod-validation-error";
|
||||||
import { ListRemoteExitNodesResponse } from "@server/routers/remoteExitNode/types";
|
import { ListRemoteExitNodesResponse } from "@server/routers/remoteExitNode/types";
|
||||||
import { regionalCache as cache } from "#private/lib/cache";
|
import cache from "#private/lib/cache";
|
||||||
import semver from "semver";
|
import semver from "semver";
|
||||||
|
|
||||||
let stalePangolinNodeVersion: string | null = null;
|
let stalePangolinNodeVersion: string | null = null;
|
||||||
|
|||||||
@@ -1,168 +0,0 @@
|
|||||||
/*
|
|
||||||
* This file is part of a proprietary work.
|
|
||||||
*
|
|
||||||
* Copyright (c) 2025-2026 Fossorial, Inc.
|
|
||||||
* All rights reserved.
|
|
||||||
*
|
|
||||||
* This file is licensed under the Fossorial Commercial License.
|
|
||||||
* You may not use this file except in compliance with the License.
|
|
||||||
* Unauthorized use, copying, modification, or distribution is strictly prohibited.
|
|
||||||
*
|
|
||||||
* This file is not licensed under the AGPLv3.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { NextFunction, Request, Response } from "express";
|
|
||||||
import { z } from "zod";
|
|
||||||
import {
|
|
||||||
db,
|
|
||||||
labels,
|
|
||||||
remoteExitNodePreferenceLabels,
|
|
||||||
remoteExitNodes
|
|
||||||
} from "@server/db";
|
|
||||||
import { and, eq, inArray } from "drizzle-orm";
|
|
||||||
import response from "@server/lib/response";
|
|
||||||
import HttpCode from "@server/types/HttpCode";
|
|
||||||
import createHttpError from "http-errors";
|
|
||||||
import logger from "@server/logger";
|
|
||||||
import { fromError } from "zod-validation-error";
|
|
||||||
|
|
||||||
const paramsSchema = z.strictObject({
|
|
||||||
orgId: z.string().min(1),
|
|
||||||
remoteExitNodeId: z.string().min(1)
|
|
||||||
});
|
|
||||||
|
|
||||||
const bodySchema = z.strictObject({
|
|
||||||
labelIds: z.array(z.number().int().positive())
|
|
||||||
});
|
|
||||||
|
|
||||||
export type SetRemoteExitNodePreferenceLabelsBody = z.infer<typeof bodySchema>;
|
|
||||||
|
|
||||||
export type SetRemoteExitNodePreferenceLabelsResponse = {
|
|
||||||
labels: {
|
|
||||||
remoteExitNodePreferenceLabelId: number;
|
|
||||||
labelId: number;
|
|
||||||
name: string;
|
|
||||||
color: string;
|
|
||||||
}[];
|
|
||||||
};
|
|
||||||
|
|
||||||
export async function setRemoteExitNodePreferenceLabels(
|
|
||||||
req: Request,
|
|
||||||
res: Response,
|
|
||||||
next: NextFunction
|
|
||||||
): Promise<any> {
|
|
||||||
try {
|
|
||||||
const parsedParams = paramsSchema.safeParse(req.params);
|
|
||||||
if (!parsedParams.success) {
|
|
||||||
return next(
|
|
||||||
createHttpError(
|
|
||||||
HttpCode.BAD_REQUEST,
|
|
||||||
fromError(parsedParams.error).toString()
|
|
||||||
)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const { orgId, remoteExitNodeId } = parsedParams.data;
|
|
||||||
|
|
||||||
const parsedBody = bodySchema.safeParse(req.body);
|
|
||||||
if (!parsedBody.success) {
|
|
||||||
return next(
|
|
||||||
createHttpError(
|
|
||||||
HttpCode.BAD_REQUEST,
|
|
||||||
fromError(parsedBody.error).toString()
|
|
||||||
)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const { labelIds } = parsedBody.data;
|
|
||||||
|
|
||||||
const [remoteExitNode] = await db
|
|
||||||
.select()
|
|
||||||
.from(remoteExitNodes)
|
|
||||||
.where(eq(remoteExitNodes.remoteExitNodeId, remoteExitNodeId))
|
|
||||||
.limit(1);
|
|
||||||
|
|
||||||
if (!remoteExitNode) {
|
|
||||||
return next(
|
|
||||||
createHttpError(
|
|
||||||
HttpCode.NOT_FOUND,
|
|
||||||
`Remote exit node with ID ${remoteExitNodeId} not found`
|
|
||||||
)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Validate all provided labelIds belong to this org
|
|
||||||
if (labelIds.length > 0) {
|
|
||||||
const existingLabels = await db
|
|
||||||
.select({ labelId: labels.labelId })
|
|
||||||
.from(labels)
|
|
||||||
.where(
|
|
||||||
and(
|
|
||||||
eq(labels.orgId, orgId),
|
|
||||||
inArray(labels.labelId, labelIds)
|
|
||||||
)
|
|
||||||
);
|
|
||||||
|
|
||||||
if (existingLabels.length !== labelIds.length) {
|
|
||||||
return next(
|
|
||||||
createHttpError(
|
|
||||||
HttpCode.BAD_REQUEST,
|
|
||||||
"One or more label IDs are invalid or do not belong to this organization"
|
|
||||||
)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Replace all preference labels atomically
|
|
||||||
await db
|
|
||||||
.delete(remoteExitNodePreferenceLabels)
|
|
||||||
.where(
|
|
||||||
eq(
|
|
||||||
remoteExitNodePreferenceLabels.remoteExitNodeId,
|
|
||||||
remoteExitNodeId
|
|
||||||
)
|
|
||||||
);
|
|
||||||
|
|
||||||
if (labelIds.length > 0) {
|
|
||||||
await db.insert(remoteExitNodePreferenceLabels).values(
|
|
||||||
labelIds.map((labelId) => ({
|
|
||||||
remoteExitNodeId,
|
|
||||||
labelId
|
|
||||||
}))
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const rows = await db
|
|
||||||
.select({
|
|
||||||
remoteExitNodePreferenceLabelId:
|
|
||||||
remoteExitNodePreferenceLabels.remoteExitNodePreferenceLabelId,
|
|
||||||
labelId: remoteExitNodePreferenceLabels.labelId,
|
|
||||||
name: labels.name,
|
|
||||||
color: labels.color
|
|
||||||
})
|
|
||||||
.from(remoteExitNodePreferenceLabels)
|
|
||||||
.innerJoin(
|
|
||||||
labels,
|
|
||||||
eq(labels.labelId, remoteExitNodePreferenceLabels.labelId)
|
|
||||||
)
|
|
||||||
.where(
|
|
||||||
eq(
|
|
||||||
remoteExitNodePreferenceLabels.remoteExitNodeId,
|
|
||||||
remoteExitNodeId
|
|
||||||
)
|
|
||||||
);
|
|
||||||
|
|
||||||
return response<SetRemoteExitNodePreferenceLabelsResponse>(res, {
|
|
||||||
data: { labels: rows },
|
|
||||||
success: true,
|
|
||||||
error: false,
|
|
||||||
message: "Remote exit node preference labels updated successfully",
|
|
||||||
status: HttpCode.OK
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
logger.error(error);
|
|
||||||
return next(
|
|
||||||
createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred")
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,155 +0,0 @@
|
|||||||
/*
|
|
||||||
* This file is part of a proprietary work.
|
|
||||||
*
|
|
||||||
* Copyright (c) 2025-2026 Fossorial, Inc.
|
|
||||||
* All rights reserved.
|
|
||||||
*
|
|
||||||
* This file is licensed under the Fossorial Commercial License.
|
|
||||||
* You may not use this file except in compliance with the License.
|
|
||||||
* Unauthorized use, copying, modification, or distribution is strictly prohibited.
|
|
||||||
*
|
|
||||||
* This file is not licensed under the AGPLv3.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { NextFunction, Request, Response } from "express";
|
|
||||||
import { z } from "zod";
|
|
||||||
import {
|
|
||||||
db,
|
|
||||||
newts,
|
|
||||||
remoteExitNodeResources,
|
|
||||||
remoteExitNodes,
|
|
||||||
sites
|
|
||||||
} from "@server/db";
|
|
||||||
import { eq } from "drizzle-orm";
|
|
||||||
import response from "@server/lib/response";
|
|
||||||
import HttpCode from "@server/types/HttpCode";
|
|
||||||
import createHttpError from "http-errors";
|
|
||||||
import logger from "@server/logger";
|
|
||||||
import { fromError } from "zod-validation-error";
|
|
||||||
import { sendToClientsBatch } from "#private/routers/ws";
|
|
||||||
|
|
||||||
const paramsSchema = z.strictObject({
|
|
||||||
orgId: z.string().min(1),
|
|
||||||
remoteExitNodeId: z.string().min(1)
|
|
||||||
});
|
|
||||||
|
|
||||||
const cidrRegex =
|
|
||||||
/^(([0-9]|[1-9][0-9]|1[0-9]{2}|2[0-4][0-9]|25[0-5])\.){3}([0-9]|[1-9][0-9]|1[0-9]{2}|2[0-4][0-9]|25[0-5])(\/([0-9]|[1-2][0-9]|3[0-2]))$|^(([0-9a-fA-F]{1,4}:){7}[0-9a-fA-F]{1,4}|([0-9a-fA-F]{1,4}:){1,7}:|([0-9a-fA-F]{1,4}:){1,6}:[0-9a-fA-F]{1,4}|([0-9a-fA-F]{1,4}:){1,5}(:[0-9a-fA-F]{1,4}){1,2}|([0-9a-fA-F]{1,4}:){1,4}(:[0-9a-fA-F]{1,4}){1,3}|([0-9a-fA-F]{1,4}:){1,3}(:[0-9a-fA-F]{1,4}){1,4}|([0-9a-fA-F]{1,4}:){1,2}(:[0-9a-fA-F]{1,4}){1,5}|[0-9a-fA-F]{1,4}:((:[0-9a-fA-F]{1,4}){1,6})|:((:[0-9a-fA-F]{1,4}){1,7}|:)|fe80:(:[0-9a-fA-F]{0,4}){0,4}%[0-9a-zA-Z]+|::(ffff(:0{1,4})?:)?((25[0-5]|(2[0-4]|1?[0-9])?[0-9])\.){3}(25[0-5]|(2[0-4]|1?[0-9])?[0-9])|([0-9a-fA-F]{1,4}:){1,4}:((25[0-5]|(2[0-4]|1?[0-9])?[0-9])\.){3}(25[0-5]|(2[0-4]|1?[0-9])?[0-9]))(\/([0-9]|[1-9][0-9]|1[0-1][0-9]|12[0-8]))$/;
|
|
||||||
|
|
||||||
const bodySchema = z.strictObject({
|
|
||||||
destinations: z.array(
|
|
||||||
z.string().regex(cidrRegex, "Must be a valid CIDR range")
|
|
||||||
)
|
|
||||||
});
|
|
||||||
|
|
||||||
export type SetRemoteExitNodeResourcesBody = z.infer<typeof bodySchema>;
|
|
||||||
|
|
||||||
export type SetRemoteExitNodeResourcesResponse = {
|
|
||||||
resources: {
|
|
||||||
remoteExitNodeResourceId: number;
|
|
||||||
remoteExitNodeId: string;
|
|
||||||
destination: string;
|
|
||||||
}[];
|
|
||||||
};
|
|
||||||
|
|
||||||
export async function setRemoteExitNodeResources(
|
|
||||||
req: Request,
|
|
||||||
res: Response,
|
|
||||||
next: NextFunction
|
|
||||||
): Promise<any> {
|
|
||||||
try {
|
|
||||||
const parsedParams = paramsSchema.safeParse(req.params);
|
|
||||||
if (!parsedParams.success) {
|
|
||||||
return next(
|
|
||||||
createHttpError(
|
|
||||||
HttpCode.BAD_REQUEST,
|
|
||||||
fromError(parsedParams.error).toString()
|
|
||||||
)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const { remoteExitNodeId } = parsedParams.data;
|
|
||||||
|
|
||||||
const parsedBody = bodySchema.safeParse(req.body);
|
|
||||||
if (!parsedBody.success) {
|
|
||||||
return next(
|
|
||||||
createHttpError(
|
|
||||||
HttpCode.BAD_REQUEST,
|
|
||||||
fromError(parsedBody.error).toString()
|
|
||||||
)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const { destinations } = parsedBody.data;
|
|
||||||
|
|
||||||
const [remoteExitNode] = await db
|
|
||||||
.select()
|
|
||||||
.from(remoteExitNodes)
|
|
||||||
.where(eq(remoteExitNodes.remoteExitNodeId, remoteExitNodeId))
|
|
||||||
.limit(1);
|
|
||||||
|
|
||||||
if (!remoteExitNode) {
|
|
||||||
return next(
|
|
||||||
createHttpError(
|
|
||||||
HttpCode.NOT_FOUND,
|
|
||||||
`Remote exit node with ID ${remoteExitNodeId} not found`
|
|
||||||
)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Replace all resources atomically
|
|
||||||
await db
|
|
||||||
.delete(remoteExitNodeResources)
|
|
||||||
.where(
|
|
||||||
eq(remoteExitNodeResources.remoteExitNodeId, remoteExitNodeId)
|
|
||||||
);
|
|
||||||
|
|
||||||
if (destinations.length > 0) {
|
|
||||||
await db.insert(remoteExitNodeResources).values(
|
|
||||||
destinations.map((destination) => ({
|
|
||||||
remoteExitNodeId,
|
|
||||||
destination
|
|
||||||
}))
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const resources = await db
|
|
||||||
.select()
|
|
||||||
.from(remoteExitNodeResources)
|
|
||||||
.where(
|
|
||||||
eq(remoteExitNodeResources.remoteExitNodeId, remoteExitNodeId)
|
|
||||||
);
|
|
||||||
|
|
||||||
// Notify all newts connected to this remote exit node's exit node
|
|
||||||
if (remoteExitNode.exitNodeId) {
|
|
||||||
const connectedNewts = await db
|
|
||||||
.select({ newtId: newts.newtId })
|
|
||||||
.from(newts)
|
|
||||||
.innerJoin(sites, eq(newts.siteId, sites.siteId))
|
|
||||||
.where(eq(sites.exitNodeId, remoteExitNode.exitNodeId));
|
|
||||||
|
|
||||||
await sendToClientsBatch(
|
|
||||||
connectedNewts.map(({ newtId }) => ({
|
|
||||||
clientId: newtId,
|
|
||||||
message: {
|
|
||||||
type: "newt/wg/subnets/update",
|
|
||||||
data: { subnets: destinations }
|
|
||||||
}
|
|
||||||
}))
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return response<SetRemoteExitNodeResourcesResponse>(res, {
|
|
||||||
data: { resources },
|
|
||||||
success: true,
|
|
||||||
error: false,
|
|
||||||
message: "Remote exit node resources updated successfully",
|
|
||||||
status: HttpCode.OK
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
logger.error(error);
|
|
||||||
return next(
|
|
||||||
createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred")
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -163,11 +163,13 @@ export async function addUserRole(
|
|||||||
});
|
});
|
||||||
|
|
||||||
for (const orgClient of orgClientsToRebuild) {
|
for (const orgClient of orgClientsToRebuild) {
|
||||||
rebuildClientAssociationsFromClient(orgClient).catch((e) => {
|
rebuildClientAssociationsFromClient(orgClient, primaryDb).catch(
|
||||||
logger.error(
|
(e) => {
|
||||||
`Failed to rebuild client associations for client ${orgClient.clientId} after adding role: ${e}`
|
logger.error(
|
||||||
);
|
`Failed to rebuild client associations for client ${orgClient.clientId} after adding role: ${e}`
|
||||||
});
|
);
|
||||||
|
}
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return response(res, {
|
return response(res, {
|
||||||
|
|||||||
@@ -170,11 +170,13 @@ export async function removeUserRole(
|
|||||||
});
|
});
|
||||||
|
|
||||||
for (const orgClient of orgClientsToRebuild) {
|
for (const orgClient of orgClientsToRebuild) {
|
||||||
rebuildClientAssociationsFromClient(orgClient).catch((e) => {
|
rebuildClientAssociationsFromClient(orgClient, primaryDb).catch(
|
||||||
logger.error(
|
(e) => {
|
||||||
`Failed to rebuild client associations for client ${orgClient.clientId} after removing role: ${e}`
|
logger.error(
|
||||||
);
|
`Failed to rebuild client associations for client ${orgClient.clientId} after removing role: ${e}`
|
||||||
});
|
);
|
||||||
|
}
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return response(res, {
|
return response(res, {
|
||||||
|
|||||||
@@ -150,11 +150,13 @@ export async function setUserOrgRoles(
|
|||||||
});
|
});
|
||||||
|
|
||||||
for (const orgClient of orgClientsToRebuild) {
|
for (const orgClient of orgClientsToRebuild) {
|
||||||
rebuildClientAssociationsFromClient(orgClient).catch((e) => {
|
rebuildClientAssociationsFromClient(orgClient, primaryDb).catch(
|
||||||
logger.error(
|
(e) => {
|
||||||
`Failed to rebuild client associations for client ${orgClient.clientId} after setting roles: ${e}`
|
logger.error(
|
||||||
);
|
`Failed to rebuild client associations for client ${orgClient.clientId} after setting roles: ${e}`
|
||||||
});
|
);
|
||||||
|
}
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return response(res, {
|
return response(res, {
|
||||||
|
|||||||
+48
-258
@@ -38,7 +38,6 @@ import { messageHandlers } from "@server/routers/ws/messageHandlers";
|
|||||||
import { messageHandlers as privateMessageHandlers } from "#private/routers/ws/messageHandlers";
|
import { messageHandlers as privateMessageHandlers } from "#private/routers/ws/messageHandlers";
|
||||||
import {
|
import {
|
||||||
AuthenticatedWebSocket,
|
AuthenticatedWebSocket,
|
||||||
BatchSendMessage,
|
|
||||||
ClientType,
|
ClientType,
|
||||||
WSMessage,
|
WSMessage,
|
||||||
TokenPayload,
|
TokenPayload,
|
||||||
@@ -188,8 +187,6 @@ const wss: WebSocketServer = new WebSocketServer({ noServer: true });
|
|||||||
// Generate unique node ID for this instance
|
// Generate unique node ID for this instance
|
||||||
const NODE_ID = uuidv4();
|
const NODE_ID = uuidv4();
|
||||||
const REDIS_CHANNEL = "websocket_messages";
|
const REDIS_CHANNEL = "websocket_messages";
|
||||||
const REDIS_DIRECT_BATCH_SIZE = 250;
|
|
||||||
const REDIS_DIRECT_FLUSH_INTERVAL_MS = 10;
|
|
||||||
|
|
||||||
// Client tracking map (local to this node)
|
// Client tracking map (local to this node)
|
||||||
const connectedClients: Map<string, AuthenticatedWebSocket[]> = new Map();
|
const connectedClients: Map<string, AuthenticatedWebSocket[]> = new Map();
|
||||||
@@ -200,15 +197,6 @@ const clientConfigVersions: Map<string, number> = new Map();
|
|||||||
// Recovery tracking
|
// Recovery tracking
|
||||||
let isRedisRecoveryInProgress = false;
|
let isRedisRecoveryInProgress = false;
|
||||||
|
|
||||||
interface RedisDirectBatchEntry {
|
|
||||||
targetClientId: string;
|
|
||||||
message: WSMessage;
|
|
||||||
resolve: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
let pendingRedisDirectMessages: RedisDirectBatchEntry[] = [];
|
|
||||||
let redisDirectFlushTimer: NodeJS.Timeout | null = null;
|
|
||||||
|
|
||||||
// Helper to get map key
|
// Helper to get map key
|
||||||
const getClientMapKey = (clientId: string) => clientId;
|
const getClientMapKey = (clientId: string) => clientId;
|
||||||
|
|
||||||
@@ -219,78 +207,6 @@ const getNodeConnectionsKey = (nodeId: string, clientId: string) =>
|
|||||||
const getConfigVersionKey = (clientId: string) =>
|
const getConfigVersionKey = (clientId: string) =>
|
||||||
`ws:configVersion:${clientId}`;
|
`ws:configVersion:${clientId}`;
|
||||||
|
|
||||||
const clearRedisDirectFlushTimer = (): void => {
|
|
||||||
if (redisDirectFlushTimer) {
|
|
||||||
clearTimeout(redisDirectFlushTimer);
|
|
||||||
redisDirectFlushTimer = null;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const publishDirectBatch = async (
|
|
||||||
entries: RedisDirectBatchEntry[]
|
|
||||||
): Promise<void> => {
|
|
||||||
const redisMessage: RedisMessage = {
|
|
||||||
type: "direct-batch",
|
|
||||||
messages: entries.map((entry) => ({
|
|
||||||
targetClientId: entry.targetClientId,
|
|
||||||
message: entry.message
|
|
||||||
})),
|
|
||||||
fromNodeId: NODE_ID
|
|
||||||
};
|
|
||||||
|
|
||||||
await redisManager.publish(REDIS_CHANNEL, JSON.stringify(redisMessage));
|
|
||||||
};
|
|
||||||
|
|
||||||
const flushPendingRedisDirectMessages = async (): Promise<void> => {
|
|
||||||
clearRedisDirectFlushTimer();
|
|
||||||
|
|
||||||
if (pendingRedisDirectMessages.length === 0) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const entries = pendingRedisDirectMessages;
|
|
||||||
pendingRedisDirectMessages = [];
|
|
||||||
|
|
||||||
if (!redisManager.isRedisEnabled()) {
|
|
||||||
entries.forEach((entry) => entry.resolve());
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
for (let i = 0; i < entries.length; i += REDIS_DIRECT_BATCH_SIZE) {
|
|
||||||
const batch = entries.slice(i, i + REDIS_DIRECT_BATCH_SIZE);
|
|
||||||
try {
|
|
||||||
await publishDirectBatch(batch);
|
|
||||||
} catch (error) {
|
|
||||||
logger.error(
|
|
||||||
"Failed to send batched direct messages via Redis, messages may be lost:",
|
|
||||||
error
|
|
||||||
);
|
|
||||||
} finally {
|
|
||||||
batch.forEach((entry) => entry.resolve());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const enqueueRedisDirectMessage = async (
|
|
||||||
targetClientId: string,
|
|
||||||
message: WSMessage
|
|
||||||
): Promise<void> => {
|
|
||||||
await new Promise<void>((resolve) => {
|
|
||||||
pendingRedisDirectMessages.push({ targetClientId, message, resolve });
|
|
||||||
|
|
||||||
if (pendingRedisDirectMessages.length >= REDIS_DIRECT_BATCH_SIZE) {
|
|
||||||
void flushPendingRedisDirectMessages();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!redisDirectFlushTimer) {
|
|
||||||
redisDirectFlushTimer = setTimeout(() => {
|
|
||||||
void flushPendingRedisDirectMessages();
|
|
||||||
}, REDIS_DIRECT_FLUSH_INTERVAL_MS);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
// Initialize Redis subscription for cross-node messaging
|
// Initialize Redis subscription for cross-node messaging
|
||||||
const initializeRedisSubscription = async (): Promise<void> => {
|
const initializeRedisSubscription = async (): Promise<void> => {
|
||||||
if (!redisManager.isRedisEnabled()) return;
|
if (!redisManager.isRedisEnabled()) return;
|
||||||
@@ -311,16 +227,7 @@ const initializeRedisSubscription = async (): Promise<void> => {
|
|||||||
// Send to specific client on this node
|
// Send to specific client on this node
|
||||||
await sendToClientLocal(
|
await sendToClientLocal(
|
||||||
redisMessage.targetClientId,
|
redisMessage.targetClientId,
|
||||||
redisMessage.message,
|
redisMessage.message
|
||||||
{},
|
|
||||||
redisMessage.message.configVersion
|
|
||||||
);
|
|
||||||
} else if (
|
|
||||||
redisMessage.type === "direct-batch" &&
|
|
||||||
redisMessage.messages
|
|
||||||
) {
|
|
||||||
await sendRedisDirectBatchToLocalClients(
|
|
||||||
redisMessage.messages
|
|
||||||
);
|
);
|
||||||
} else if (redisMessage.type === "broadcast") {
|
} else if (redisMessage.type === "broadcast") {
|
||||||
// Broadcast to all clients on this node except excluded
|
// Broadcast to all clients on this node except excluded
|
||||||
@@ -596,8 +503,7 @@ const incrementClientConfigVersion = async (
|
|||||||
const sendToClientLocal = async (
|
const sendToClientLocal = async (
|
||||||
clientId: string,
|
clientId: string,
|
||||||
message: WSMessage,
|
message: WSMessage,
|
||||||
options: SendMessageOptions = {},
|
options: SendMessageOptions = {}
|
||||||
preResolvedConfigVersion?: number
|
|
||||||
): Promise<boolean> => {
|
): Promise<boolean> => {
|
||||||
const mapKey = getClientMapKey(clientId);
|
const mapKey = getClientMapKey(clientId);
|
||||||
const clients = connectedClients.get(mapKey);
|
const clients = connectedClients.get(mapKey);
|
||||||
@@ -606,8 +512,7 @@ const sendToClientLocal = async (
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Handle config version
|
// Handle config version
|
||||||
const configVersion =
|
const configVersion = await getClientConfigVersion(clientId);
|
||||||
preResolvedConfigVersion ?? (await getClientConfigVersion(clientId));
|
|
||||||
|
|
||||||
// Add config version to message
|
// Add config version to message
|
||||||
const messageWithVersion = {
|
const messageWithVersion = {
|
||||||
@@ -640,71 +545,43 @@ const sendToClientLocal = async (
|
|||||||
return true;
|
return true;
|
||||||
};
|
};
|
||||||
|
|
||||||
const sendRedisDirectBatchToLocalClients = async (
|
|
||||||
entries: { targetClientId: string; message: WSMessage }[]
|
|
||||||
): Promise<void> => {
|
|
||||||
const jobs = entries.map((entry) =>
|
|
||||||
sendToClientLocal(
|
|
||||||
entry.targetClientId,
|
|
||||||
entry.message,
|
|
||||||
{},
|
|
||||||
entry.message.configVersion
|
|
||||||
)
|
|
||||||
);
|
|
||||||
await Promise.all(jobs);
|
|
||||||
};
|
|
||||||
|
|
||||||
const broadcastToAllExceptLocal = async (
|
const broadcastToAllExceptLocal = async (
|
||||||
message: WSMessage,
|
message: WSMessage,
|
||||||
excludeClientId?: string,
|
excludeClientId?: string,
|
||||||
options: SendMessageOptions = {}
|
options: SendMessageOptions = {}
|
||||||
): Promise<void> => {
|
): Promise<void> => {
|
||||||
const sendPlans = await Promise.all(
|
for (const [mapKey, clients] of connectedClients.entries()) {
|
||||||
Array.from(connectedClients.entries()).map(
|
const [type, id] = mapKey.split(":");
|
||||||
async ([mapKey, clients]) => {
|
const clientId = mapKey; // mapKey is the clientId
|
||||||
const clientId = mapKey; // mapKey is the clientId
|
if (!(excludeClientId && clientId === excludeClientId)) {
|
||||||
if (excludeClientId && clientId === excludeClientId) {
|
// Handle config version per client
|
||||||
return null;
|
let configVersion = await getClientConfigVersion(clientId);
|
||||||
}
|
if (options.incrementConfigVersion) {
|
||||||
|
configVersion = await incrementClientConfigVersion(clientId);
|
||||||
let configVersion = await getClientConfigVersion(clientId);
|
|
||||||
if (options.incrementConfigVersion) {
|
|
||||||
configVersion =
|
|
||||||
await incrementClientConfigVersion(clientId);
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
clients,
|
|
||||||
messageWithVersion: {
|
|
||||||
...message,
|
|
||||||
configVersion
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
)
|
|
||||||
);
|
|
||||||
|
|
||||||
for (const plan of sendPlans) {
|
// Add config version to message
|
||||||
if (!plan) {
|
const messageWithVersion = {
|
||||||
continue;
|
...message,
|
||||||
}
|
configVersion
|
||||||
|
};
|
||||||
|
|
||||||
if (options.compress) {
|
if (options.compress) {
|
||||||
const compressed = zlib.gzipSync(
|
const compressed = zlib.gzipSync(
|
||||||
Buffer.from(JSON.stringify(plan.messageWithVersion), "utf8")
|
Buffer.from(JSON.stringify(messageWithVersion), "utf8")
|
||||||
);
|
);
|
||||||
plan.clients.forEach((client) => {
|
clients.forEach((client) => {
|
||||||
if (client.readyState === WebSocket.OPEN) {
|
if (client.readyState === WebSocket.OPEN) {
|
||||||
client.send(compressed);
|
client.send(compressed);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
const messageString = JSON.stringify(plan.messageWithVersion);
|
clients.forEach((client) => {
|
||||||
plan.clients.forEach((client) => {
|
if (client.readyState === WebSocket.OPEN) {
|
||||||
if (client.readyState === WebSocket.OPEN) {
|
client.send(JSON.stringify(messageWithVersion));
|
||||||
client.send(messageString);
|
}
|
||||||
}
|
});
|
||||||
});
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -725,23 +602,28 @@ const sendToClient = async (
|
|||||||
);
|
);
|
||||||
|
|
||||||
// Try to send locally first
|
// Try to send locally first
|
||||||
const localSent = await sendToClientLocal(
|
const localSent = await sendToClientLocal(clientId, message, options);
|
||||||
clientId,
|
|
||||||
message,
|
|
||||||
options,
|
|
||||||
configVersion
|
|
||||||
);
|
|
||||||
|
|
||||||
// Only send via Redis if the client is not connected locally and Redis is enabled
|
// Only send via Redis if the client is not connected locally and Redis is enabled
|
||||||
if (!localSent && redisManager.isRedisEnabled()) {
|
if (!localSent && redisManager.isRedisEnabled()) {
|
||||||
try {
|
try {
|
||||||
await enqueueRedisDirectMessage(clientId, {
|
const redisMessage: RedisMessage = {
|
||||||
...message,
|
type: "direct",
|
||||||
configVersion
|
targetClientId: clientId,
|
||||||
});
|
message: {
|
||||||
|
...message,
|
||||||
|
configVersion
|
||||||
|
},
|
||||||
|
fromNodeId: NODE_ID
|
||||||
|
};
|
||||||
|
|
||||||
|
await redisManager.publish(
|
||||||
|
REDIS_CHANNEL,
|
||||||
|
JSON.stringify(redisMessage)
|
||||||
|
);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error(
|
logger.error(
|
||||||
"Failed to queue batched direct message for Redis delivery, message may be lost:",
|
"Failed to send message via Redis, message may be lost:",
|
||||||
error
|
error
|
||||||
);
|
);
|
||||||
// Continue execution - local delivery already attempted
|
// Continue execution - local delivery already attempted
|
||||||
@@ -756,95 +638,6 @@ const sendToClient = async (
|
|||||||
return localSent;
|
return localSent;
|
||||||
};
|
};
|
||||||
|
|
||||||
const sendToClientsBatch = async (
|
|
||||||
entries: BatchSendMessage[]
|
|
||||||
): Promise<void> => {
|
|
||||||
if (entries.length === 0) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const remoteEntries: { targetClientId: string; message: WSMessage }[] = [];
|
|
||||||
const clientsWithIncrement = new Set(
|
|
||||||
entries
|
|
||||||
.filter((entry) => !!entry.options?.incrementConfigVersion)
|
|
||||||
.map((entry) => entry.clientId)
|
|
||||||
);
|
|
||||||
const nonIncrementOnlyClientIds = Array.from(
|
|
||||||
new Set(
|
|
||||||
entries
|
|
||||||
.map((entry) => entry.clientId)
|
|
||||||
.filter((clientId) => !clientsWithIncrement.has(clientId))
|
|
||||||
)
|
|
||||||
);
|
|
||||||
const stableConfigVersionByClient = new Map<string, number | undefined>(
|
|
||||||
await Promise.all(
|
|
||||||
nonIncrementOnlyClientIds.map(
|
|
||||||
async (clientId) =>
|
|
||||||
[clientId, await getClientConfigVersion(clientId)] as const
|
|
||||||
)
|
|
||||||
)
|
|
||||||
);
|
|
||||||
|
|
||||||
for (const entry of entries) {
|
|
||||||
const options = entry.options || {};
|
|
||||||
const { clientId, message } = entry;
|
|
||||||
|
|
||||||
const configVersion = options.incrementConfigVersion
|
|
||||||
? await incrementClientConfigVersion(clientId)
|
|
||||||
: stableConfigVersionByClient.get(clientId);
|
|
||||||
|
|
||||||
logger.debug(
|
|
||||||
`sendToClientsBatch: Message type ${message.type} queued for clientId ${clientId} (new configVersion: ${configVersion})`
|
|
||||||
);
|
|
||||||
|
|
||||||
const localSent = await sendToClientLocal(
|
|
||||||
clientId,
|
|
||||||
message,
|
|
||||||
options,
|
|
||||||
configVersion
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!localSent && redisManager.isRedisEnabled()) {
|
|
||||||
remoteEntries.push({
|
|
||||||
targetClientId: clientId,
|
|
||||||
message: {
|
|
||||||
...message,
|
|
||||||
configVersion
|
|
||||||
}
|
|
||||||
});
|
|
||||||
} else if (!localSent && !redisManager.isRedisEnabled()) {
|
|
||||||
logger.debug(
|
|
||||||
`Could not deliver batch message to ${clientId} - not connected locally and Redis unavailable`
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!redisManager.isRedisEnabled() || remoteEntries.length === 0) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
for (let i = 0; i < remoteEntries.length; i += REDIS_DIRECT_BATCH_SIZE) {
|
|
||||||
const messages = remoteEntries.slice(i, i + REDIS_DIRECT_BATCH_SIZE);
|
|
||||||
try {
|
|
||||||
const redisMessage: RedisMessage = {
|
|
||||||
type: "direct-batch",
|
|
||||||
messages,
|
|
||||||
fromNodeId: NODE_ID
|
|
||||||
};
|
|
||||||
|
|
||||||
await redisManager.publish(
|
|
||||||
REDIS_CHANNEL,
|
|
||||||
JSON.stringify(redisMessage)
|
|
||||||
);
|
|
||||||
} catch (error) {
|
|
||||||
logger.error(
|
|
||||||
"Failed to send explicit direct batch via Redis, messages may be lost:",
|
|
||||||
error
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const broadcastToAllExcept = async (
|
const broadcastToAllExcept = async (
|
||||||
message: WSMessage,
|
message: WSMessage,
|
||||||
excludeClientId?: string,
|
excludeClientId?: string,
|
||||||
@@ -1316,8 +1109,6 @@ const disconnectClient = async (clientId: string): Promise<boolean> => {
|
|||||||
// Cleanup function for graceful shutdown
|
// Cleanup function for graceful shutdown
|
||||||
const cleanup = async (): Promise<void> => {
|
const cleanup = async (): Promise<void> => {
|
||||||
try {
|
try {
|
||||||
await flushPendingRedisDirectMessages();
|
|
||||||
|
|
||||||
// Close all WebSocket connections
|
// Close all WebSocket connections
|
||||||
connectedClients.forEach((clients) => {
|
connectedClients.forEach((clients) => {
|
||||||
clients.forEach((client) => {
|
clients.forEach((client) => {
|
||||||
@@ -1348,7 +1139,6 @@ export {
|
|||||||
router,
|
router,
|
||||||
handleWSUpgrade,
|
handleWSUpgrade,
|
||||||
sendToClient,
|
sendToClient,
|
||||||
sendToClientsBatch,
|
|
||||||
broadcastToAllExcept,
|
broadcastToAllExcept,
|
||||||
connectedClients,
|
connectedClients,
|
||||||
hasActiveConnections,
|
hasActiveConnections,
|
||||||
|
|||||||
@@ -30,7 +30,7 @@ const listAccessTokensParamsSchema = z
|
|||||||
error: "Either resourceId or orgId must be provided, but not both"
|
error: "Either resourceId or orgId must be provided, but not both"
|
||||||
});
|
});
|
||||||
|
|
||||||
const listAccessTokensSchema = z.strictObject({
|
const listAccessTokensSchema = z.object({
|
||||||
limit: z
|
limit: z
|
||||||
.string()
|
.string()
|
||||||
.optional()
|
.optional()
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ const paramsSchema = z.object({
|
|||||||
apiKeyId: z.string().nonempty()
|
apiKeyId: z.string().nonempty()
|
||||||
});
|
});
|
||||||
|
|
||||||
const querySchema = z.strictObject({
|
const querySchema = z.object({
|
||||||
limit: z
|
limit: z
|
||||||
.string()
|
.string()
|
||||||
.optional()
|
.optional()
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ import { eq, and } from "drizzle-orm";
|
|||||||
import { OpenAPITags, registry } from "@server/openApi";
|
import { OpenAPITags, registry } from "@server/openApi";
|
||||||
import { createApiResponseSchema } from "@server/lib/openapi/createApiResponseSchema";
|
import { createApiResponseSchema } from "@server/lib/openapi/createApiResponseSchema";
|
||||||
|
|
||||||
const querySchema = z.strictObject({
|
const querySchema = z.object({
|
||||||
limit: z
|
limit: z
|
||||||
.string()
|
.string()
|
||||||
.optional()
|
.optional()
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import { z } from "zod";
|
|||||||
import { fromError } from "zod-validation-error";
|
import { fromError } from "zod-validation-error";
|
||||||
import { eq } from "drizzle-orm";
|
import { eq } from "drizzle-orm";
|
||||||
|
|
||||||
const querySchema = z.strictObject({
|
const querySchema = z.object({
|
||||||
limit: z
|
limit: z
|
||||||
.string()
|
.string()
|
||||||
.optional()
|
.optional()
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ import response from "@server/lib/response";
|
|||||||
import logger from "@server/logger";
|
import logger from "@server/logger";
|
||||||
import { getSevenDaysAgo } from "@app/lib/getSevenDaysAgo";
|
import { getSevenDaysAgo } from "@app/lib/getSevenDaysAgo";
|
||||||
|
|
||||||
export const queryAccessAuditLogsQuery = z.strictObject({
|
export const queryAccessAuditLogsQuery = z.object({
|
||||||
// iso string just validate its a parseable date
|
// iso string just validate its a parseable date
|
||||||
timeStart: z
|
timeStart: z
|
||||||
.string()
|
.string()
|
||||||
|
|||||||
@@ -10,8 +10,9 @@ 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 { invalidateAllSessionsExceptCurrent } from "@server/auth/sessions/app";
|
import { invalidateAllSessions } from "@server/auth/sessions/app";
|
||||||
import { eq } from "drizzle-orm";
|
import { sessions, resourceSessions } from "@server/db";
|
||||||
|
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";
|
||||||
@@ -30,6 +31,48 @@ 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,
|
||||||
|
|||||||
@@ -224,7 +224,7 @@ export async function deleteMyAccount(
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
calculateUserClientsForOrgs(userId).catch((e) => {
|
calculateUserClientsForOrgs(userId, primaryDb).catch((e) => {
|
||||||
logger.error(
|
logger.error(
|
||||||
`Failed to calculate user clients after deleting account for user ${userId}: ${e}`
|
`Failed to calculate user clients after deleting account for user ${userId}: ${e}`
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -15,10 +15,6 @@ 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";
|
||||||
|
|
||||||
@@ -172,15 +168,6 @@ 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!,
|
||||||
|
|||||||
@@ -280,11 +280,13 @@ export async function createClient(
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (newClient) {
|
if (newClient) {
|
||||||
rebuildClientAssociationsFromClient(newClient).catch((e) => {
|
rebuildClientAssociationsFromClient(newClient, primaryDb).catch(
|
||||||
logger.error(
|
(e) => {
|
||||||
`Failed to rebuild client associations after creating client: ${e}`
|
logger.error(
|
||||||
);
|
`Failed to rebuild client associations after creating client: ${e}`
|
||||||
});
|
);
|
||||||
|
}
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return response<CreateClientResponse>(res, {
|
return response<CreateClientResponse>(res, {
|
||||||
|
|||||||
@@ -255,11 +255,13 @@ export async function createUserClient(
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (newClient) {
|
if (newClient) {
|
||||||
rebuildClientAssociationsFromClient(newClient).catch((e) => {
|
rebuildClientAssociationsFromClient(newClient, primaryDb).catch(
|
||||||
logger.error(
|
(e) => {
|
||||||
`Failed to rebuild client associations after creating user client: ${e}`
|
logger.error(
|
||||||
);
|
`Failed to rebuild client associations after creating user client: ${e}`
|
||||||
});
|
);
|
||||||
|
}
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return response<CreateClientAndOlmResponse>(res, {
|
return response<CreateClientAndOlmResponse>(res, {
|
||||||
|
|||||||
@@ -109,11 +109,13 @@ export async function deleteClient(
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (deletedClient) {
|
if (deletedClient) {
|
||||||
rebuildClientAssociationsFromClient(deletedClient).catch((e) => {
|
rebuildClientAssociationsFromClient(deletedClient, primaryDb).catch(
|
||||||
logger.error(
|
(e) => {
|
||||||
`Failed to rebuild client associations after deleting client ${clientId}: ${e}`
|
logger.error(
|
||||||
);
|
`Failed to rebuild client associations after deleting client ${clientId}: ${e}`
|
||||||
});
|
);
|
||||||
|
}
|
||||||
|
);
|
||||||
if (olm) {
|
if (olm) {
|
||||||
sendTerminateClient(
|
sendTerminateClient(
|
||||||
deletedClient.clientId,
|
deletedClient.clientId,
|
||||||
|
|||||||
@@ -41,7 +41,7 @@ const listClientsParamsSchema = z.strictObject({
|
|||||||
orgId: z.string()
|
orgId: z.string()
|
||||||
});
|
});
|
||||||
|
|
||||||
const listClientsSchema = z.strictObject({
|
const listClientsSchema = z.object({
|
||||||
pageSize: z.coerce
|
pageSize: z.coerce
|
||||||
.number<string>() // for prettier formatting
|
.number<string>() // for prettier formatting
|
||||||
.int()
|
.int()
|
||||||
|
|||||||
@@ -40,7 +40,7 @@ const listUserDevicesParamsSchema = z.strictObject({
|
|||||||
orgId: z.string()
|
orgId: z.string()
|
||||||
});
|
});
|
||||||
|
|
||||||
const listUserDevicesSchema = z.strictObject({
|
const listUserDevicesSchema = z.object({
|
||||||
pageSize: z.coerce
|
pageSize: z.coerce
|
||||||
.number<string>() // for prettier formatting
|
.number<string>() // for prettier formatting
|
||||||
.int()
|
.int()
|
||||||
@@ -420,6 +420,31 @@ export async function listUserDevices(
|
|||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// REMOVING THIS BECAUSE WE HAVE DIFFERENT TYPES OF CLIENTS NOW
|
||||||
|
// // Try to get the latest version, but don't block if it fails
|
||||||
|
// try {
|
||||||
|
// const latestOlmVersion = await getLatestOlmVersion();
|
||||||
|
|
||||||
|
// if (latestOlmVersion) {
|
||||||
|
// olmsWithUpdates.forEach((client) => {
|
||||||
|
// try {
|
||||||
|
// client.olmUpdateAvailable = semver.lt(
|
||||||
|
// client.olmVersion ? client.olmVersion : "",
|
||||||
|
// latestOlmVersion
|
||||||
|
// );
|
||||||
|
// } catch (error) {
|
||||||
|
// client.olmUpdateAvailable = false;
|
||||||
|
// }
|
||||||
|
// });
|
||||||
|
// }
|
||||||
|
// } catch (error) {
|
||||||
|
// // Log the error but don't let it block the response
|
||||||
|
// logger.warn(
|
||||||
|
// "Failed to check for OLM updates, continuing without update info:",
|
||||||
|
// error
|
||||||
|
// );
|
||||||
|
// }
|
||||||
|
|
||||||
return response<ListUserDevicesResponse>(res, {
|
return response<ListUserDevicesResponse>(res, {
|
||||||
data: {
|
data: {
|
||||||
devices: olmsWithUpdates,
|
devices: olmsWithUpdates,
|
||||||
|
|||||||
@@ -60,17 +60,13 @@ export async function rebuildClientAssociationsCacheRoute(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
rebuildClientAssociationsFromClient(client).catch((e) => {
|
await rebuildClientAssociationsFromClient(client);
|
||||||
logger.error(
|
|
||||||
`Failed to rebuild client associations for client ${clientId}: ${e}`
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
return response(res, {
|
return response(res, {
|
||||||
data: null,
|
data: null,
|
||||||
success: true,
|
success: true,
|
||||||
error: false,
|
error: false,
|
||||||
message: "Client association cache queued successfully",
|
message: "Client association cache rebuilt successfully",
|
||||||
status: HttpCode.OK
|
status: HttpCode.OK
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { sendToClient, sendToClientsBatch } from "#dynamic/routers/ws";
|
import { sendToClient } from "#dynamic/routers/ws";
|
||||||
import { db, newts, olms } from "@server/db";
|
import { db, newts, olms } from "@server/db";
|
||||||
import {
|
import {
|
||||||
Alias,
|
Alias,
|
||||||
@@ -8,7 +8,7 @@ import {
|
|||||||
} from "@server/lib/ip";
|
} from "@server/lib/ip";
|
||||||
import { canCompress } from "@server/lib/clientVersionChecks";
|
import { canCompress } from "@server/lib/clientVersionChecks";
|
||||||
import logger from "@server/logger";
|
import logger from "@server/logger";
|
||||||
import { eq, inArray } from "drizzle-orm";
|
import { eq } from "drizzle-orm";
|
||||||
import semver from "semver";
|
import semver from "semver";
|
||||||
|
|
||||||
const NEWT_V2_TARGETS_VERSION = ">=1.10.3";
|
const NEWT_V2_TARGETS_VERSION = ">=1.10.3";
|
||||||
@@ -59,42 +59,6 @@ export async function addTargets(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function addTargetsBatch(
|
|
||||||
entries: {
|
|
||||||
newtId: string;
|
|
||||||
targets: SubnetProxyTarget[] | SubnetProxyTargetV2[];
|
|
||||||
version?: string | null;
|
|
||||||
}[]
|
|
||||||
) {
|
|
||||||
if (entries.length === 0) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const resolved = await Promise.all(
|
|
||||||
entries.map(async (entry) => ({
|
|
||||||
...entry,
|
|
||||||
targets: await convertTargetsIfNecessary(
|
|
||||||
entry.newtId,
|
|
||||||
entry.targets
|
|
||||||
)
|
|
||||||
}))
|
|
||||||
);
|
|
||||||
|
|
||||||
await sendToClientsBatch(
|
|
||||||
resolved.map((entry) => ({
|
|
||||||
clientId: entry.newtId,
|
|
||||||
message: {
|
|
||||||
type: `newt/wg/targets/add`,
|
|
||||||
data: entry.targets
|
|
||||||
},
|
|
||||||
options: {
|
|
||||||
incrementConfigVersion: true,
|
|
||||||
compress: canCompress(entry.version, "newt")
|
|
||||||
}
|
|
||||||
}))
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function removeTargets(
|
export async function removeTargets(
|
||||||
newtId: string,
|
newtId: string,
|
||||||
targets: SubnetProxyTarget[] | SubnetProxyTargetV2[],
|
targets: SubnetProxyTarget[] | SubnetProxyTargetV2[],
|
||||||
@@ -112,42 +76,6 @@ export async function removeTargets(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function removeTargetsBatch(
|
|
||||||
entries: {
|
|
||||||
newtId: string;
|
|
||||||
targets: SubnetProxyTarget[] | SubnetProxyTargetV2[];
|
|
||||||
version?: string | null;
|
|
||||||
}[]
|
|
||||||
) {
|
|
||||||
if (entries.length === 0) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const resolved = await Promise.all(
|
|
||||||
entries.map(async (entry) => ({
|
|
||||||
...entry,
|
|
||||||
targets: await convertTargetsIfNecessary(
|
|
||||||
entry.newtId,
|
|
||||||
entry.targets
|
|
||||||
)
|
|
||||||
}))
|
|
||||||
);
|
|
||||||
|
|
||||||
await sendToClientsBatch(
|
|
||||||
resolved.map((entry) => ({
|
|
||||||
clientId: entry.newtId,
|
|
||||||
message: {
|
|
||||||
type: `newt/wg/targets/remove`,
|
|
||||||
data: entry.targets
|
|
||||||
},
|
|
||||||
options: {
|
|
||||||
incrementConfigVersion: true,
|
|
||||||
compress: canCompress(entry.version, "newt")
|
|
||||||
}
|
|
||||||
}))
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function updateTargets(
|
export async function updateTargets(
|
||||||
newtId: string,
|
newtId: string,
|
||||||
targets: {
|
targets: {
|
||||||
@@ -273,235 +201,6 @@ export async function removePeerData(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const resolveOlmTargets = async (
|
|
||||||
entries: {
|
|
||||||
clientId: number;
|
|
||||||
olmId?: string;
|
|
||||||
version?: string | null;
|
|
||||||
}[]
|
|
||||||
) => {
|
|
||||||
const unresolvedClientIds = entries
|
|
||||||
.filter((entry) => !entry.olmId)
|
|
||||||
.map((entry) => entry.clientId);
|
|
||||||
|
|
||||||
const olmMap = new Map<number, { olmId: string; version: string | null }>();
|
|
||||||
|
|
||||||
if (unresolvedClientIds.length > 0) {
|
|
||||||
const olmRows = await db
|
|
||||||
.select({
|
|
||||||
clientId: olms.clientId,
|
|
||||||
olmId: olms.olmId,
|
|
||||||
version: olms.version
|
|
||||||
})
|
|
||||||
.from(olms)
|
|
||||||
.where(inArray(olms.clientId, unresolvedClientIds));
|
|
||||||
|
|
||||||
for (const row of olmRows) {
|
|
||||||
if (row.clientId !== null) {
|
|
||||||
olmMap.set(row.clientId, {
|
|
||||||
olmId: row.olmId,
|
|
||||||
version: row.version
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return entries
|
|
||||||
.map((entry) => {
|
|
||||||
if (entry.olmId) {
|
|
||||||
return {
|
|
||||||
clientId: entry.clientId,
|
|
||||||
olmId: entry.olmId,
|
|
||||||
version: entry.version
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
const resolved = olmMap.get(entry.clientId);
|
|
||||||
if (!resolved) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
clientId: entry.clientId,
|
|
||||||
olmId: resolved.olmId,
|
|
||||||
version: entry.version ?? resolved.version
|
|
||||||
};
|
|
||||||
})
|
|
||||||
.filter((entry) => entry !== null);
|
|
||||||
};
|
|
||||||
|
|
||||||
export async function addPeerDataBatch(
|
|
||||||
entries: {
|
|
||||||
clientId: number;
|
|
||||||
siteId: number;
|
|
||||||
remoteSubnets: string[];
|
|
||||||
aliases: Alias[];
|
|
||||||
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/add`,
|
|
||||||
data: {
|
|
||||||
siteId: entry.siteId,
|
|
||||||
remoteSubnets: entry.remoteSubnets,
|
|
||||||
aliases: 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 removePeerDataBatch(
|
|
||||||
entries: {
|
|
||||||
clientId: number;
|
|
||||||
siteId: number;
|
|
||||||
remoteSubnets: string[];
|
|
||||||
aliases: Alias[];
|
|
||||||
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/remove`,
|
|
||||||
data: {
|
|
||||||
siteId: entry.siteId,
|
|
||||||
remoteSubnets: entry.remoteSubnets,
|
|
||||||
aliases: 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 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,
|
||||||
|
|||||||
@@ -635,7 +635,7 @@ export async function validateOidcCallback(
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
calculateUserClientsForOrgs(userId!).catch((err) => {
|
calculateUserClientsForOrgs(userId!, primaryDb).catch((err) => {
|
||||||
logger.error(
|
logger.error(
|
||||||
"Error calculating user clients after syncing orgs and roles for OIDC user",
|
"Error calculating user clients after syncing orgs and roles for OIDC user",
|
||||||
{ error: err }
|
{ error: err }
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ import {
|
|||||||
db,
|
db,
|
||||||
ExitNode,
|
ExitNode,
|
||||||
networks,
|
networks,
|
||||||
remoteExitNodeResources,
|
|
||||||
resources,
|
resources,
|
||||||
Site,
|
Site,
|
||||||
siteNetworks,
|
siteNetworks,
|
||||||
@@ -224,8 +223,7 @@ export async function buildClientConfigurationForNewtClient(
|
|||||||
|
|
||||||
export async function buildTargetConfigurationForNewtClient(
|
export async function buildTargetConfigurationForNewtClient(
|
||||||
siteId: number,
|
siteId: number,
|
||||||
version?: string | null,
|
version?: string | null
|
||||||
remoteExitNodeId?: string
|
|
||||||
) {
|
) {
|
||||||
// Get all enabled targets with their resource mode information
|
// Get all enabled targets with their resource mode information
|
||||||
const allTargets = await db
|
const allTargets = await db
|
||||||
@@ -381,24 +379,10 @@ export async function buildTargetConfigurationForNewtClient(
|
|||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
let remoteExitNodeSubnets: string[] = [];
|
|
||||||
if (remoteExitNodeId) {
|
|
||||||
const remoteNodeResources = await db
|
|
||||||
.select()
|
|
||||||
.from(remoteExitNodeResources)
|
|
||||||
.where(
|
|
||||||
eq(remoteExitNodeResources.remoteExitNodeId, remoteExitNodeId)
|
|
||||||
);
|
|
||||||
|
|
||||||
// filter through these and provide the subnets
|
|
||||||
remoteExitNodeSubnets = remoteNodeResources.map((r) => r.destination);
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
validHealthCheckTargets,
|
validHealthCheckTargets,
|
||||||
tcpTargets,
|
tcpTargets,
|
||||||
udpTargets,
|
udpTargets,
|
||||||
browserGatewayTargets,
|
browserGatewayTargets
|
||||||
remoteExitNodeSubnets
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import { verifyPassword } from "@server/auth/password";
|
|||||||
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 logger from "@server/logger";
|
import logger from "@server/logger";
|
||||||
import { regionalCache as cache } from "#dynamic/lib/cache";
|
import cache from "#dynamic/lib/cache";
|
||||||
import config from "@server/lib/config";
|
import config from "@server/lib/config";
|
||||||
|
|
||||||
// Stale-while-revalidate in-memory fallback for the releases API.
|
// Stale-while-revalidate in-memory fallback for the releases API.
|
||||||
|
|||||||
@@ -9,7 +9,6 @@ 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;
|
||||||
@@ -62,8 +61,6 @@ 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)
|
||||||
|
|||||||
@@ -49,22 +49,20 @@ 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})`
|
||||||
);
|
);
|
||||||
|
|
||||||
// TODO: IMPLEMENT THE SYNC ON THE NEWT SIDE AND COMMENT THIS BACK IN
|
const [site] = await db
|
||||||
|
.select()
|
||||||
|
.from(sites)
|
||||||
|
.where(eq(sites.siteId, newt.siteId))
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
// const [site] = await db
|
if (!site) {
|
||||||
// .select()
|
logger.warn(
|
||||||
// .from(sites)
|
`Newt ping message: site with ID ${newt.siteId} not found`
|
||||||
// .where(eq(sites.siteId, newt.siteId))
|
);
|
||||||
// .limit(1);
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// if (!site) {
|
await sendNewtSyncMessage(newt, site);
|
||||||
// logger.warn(
|
|
||||||
// `Newt ping message: site with ID ${newt.siteId} not found`
|
|
||||||
// );
|
|
||||||
// return;
|
|
||||||
// }
|
|
||||||
|
|
||||||
// await sendNewtSyncMessage(newt, site);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -38,8 +38,7 @@ export const handleNewtPingRequestMessage: MessageHandler = async (context) => {
|
|||||||
const exitNodesList = await listExitNodes(
|
const exitNodesList = await listExitNodes(
|
||||||
site.orgId,
|
site.orgId,
|
||||||
true,
|
true,
|
||||||
noCloud || false,
|
noCloud || false
|
||||||
newt.siteId
|
|
||||||
); // filter for only the online ones
|
); // filter for only the online ones
|
||||||
|
|
||||||
let lastExitNodeId = null;
|
let lastExitNodeId = null;
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { db, ExitNode, newts, remoteExitNodes, Transaction } from "@server/db";
|
import { db, ExitNode, newts, Transaction } from "@server/db";
|
||||||
import { MessageHandler } from "@server/routers/ws";
|
import { MessageHandler } from "@server/routers/ws";
|
||||||
import { exitNodes, Newt, sites } from "@server/db";
|
import { exitNodes, Newt, sites } from "@server/db";
|
||||||
import { eq } from "drizzle-orm";
|
import { eq } from "drizzle-orm";
|
||||||
@@ -196,29 +196,12 @@ export const handleNewtRegisterMessage: MessageHandler = async (context) => {
|
|||||||
.where(eq(newts.newtId, newt.newtId));
|
.where(eq(newts.newtId, newt.newtId));
|
||||||
}
|
}
|
||||||
|
|
||||||
let remoteExitNodeId: string | undefined;
|
|
||||||
if (exitNode.type == "remoteExitNode") {
|
|
||||||
// get the remote exit node ID associated with this exit node
|
|
||||||
const [remoteExitNode] = await db
|
|
||||||
.select()
|
|
||||||
.from(remoteExitNodes)
|
|
||||||
.where(eq(remoteExitNodes.exitNodeId, exitNode.exitNodeId))
|
|
||||||
.limit(1);
|
|
||||||
|
|
||||||
remoteExitNodeId = remoteExitNode?.remoteExitNodeId;
|
|
||||||
}
|
|
||||||
|
|
||||||
const {
|
const {
|
||||||
tcpTargets,
|
tcpTargets,
|
||||||
udpTargets,
|
udpTargets,
|
||||||
validHealthCheckTargets,
|
validHealthCheckTargets,
|
||||||
browserGatewayTargets,
|
browserGatewayTargets
|
||||||
remoteExitNodeSubnets
|
} = await buildTargetConfigurationForNewtClient(siteId, newtVersion);
|
||||||
} = await buildTargetConfigurationForNewtClient(
|
|
||||||
siteId,
|
|
||||||
newtVersion,
|
|
||||||
remoteExitNodeId // this is for the remote node resources
|
|
||||||
);
|
|
||||||
|
|
||||||
logger.debug(
|
logger.debug(
|
||||||
`Sending health check targets to newt ${newt.newtId}: ${JSON.stringify(validHealthCheckTargets)}`
|
`Sending health check targets to newt ${newt.newtId}: ${JSON.stringify(validHealthCheckTargets)}`
|
||||||
@@ -239,7 +222,6 @@ export const handleNewtRegisterMessage: MessageHandler = async (context) => {
|
|||||||
},
|
},
|
||||||
healthCheckTargets: validHealthCheckTargets,
|
healthCheckTargets: validHealthCheckTargets,
|
||||||
browserGatewayTargets: browserGatewayTargets,
|
browserGatewayTargets: browserGatewayTargets,
|
||||||
remoteExitNodeSubnets: remoteExitNodeSubnets,
|
|
||||||
chainId: chainId
|
chainId: chainId
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { MessageHandler } from "@server/routers/ws";
|
|||||||
import logger from "@server/logger";
|
import logger from "@server/logger";
|
||||||
import { Newt } from "@server/db";
|
import { Newt } from "@server/db";
|
||||||
import { applyNewtDockerBlueprint } from "@server/lib/blueprints/applyNewtDockerBlueprint";
|
import { applyNewtDockerBlueprint } from "@server/lib/blueprints/applyNewtDockerBlueprint";
|
||||||
import cache from "#dynamic/lib/cache"; // not using regional here because we dont know where the site is
|
import cache from "#dynamic/lib/cache";
|
||||||
|
|
||||||
export const handleDockerStatusMessage: MessageHandler = async (context) => {
|
export const handleDockerStatusMessage: MessageHandler = async (context) => {
|
||||||
const { message, client, sendToClient } = context;
|
const { message, client, sendToClient } = context;
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { db, Site } from "@server/db";
|
import { db, Site } from "@server/db";
|
||||||
import { newts, sites } from "@server/db";
|
import { newts, sites } from "@server/db";
|
||||||
import { eq } from "drizzle-orm";
|
import { eq } from "drizzle-orm";
|
||||||
import { sendToClient, sendToClientsBatch } from "#dynamic/routers/ws";
|
import { sendToClient } from "#dynamic/routers/ws";
|
||||||
import logger from "@server/logger";
|
import logger from "@server/logger";
|
||||||
|
|
||||||
export async function addPeer(
|
export async function addPeer(
|
||||||
@@ -36,14 +36,10 @@ export async function addPeer(
|
|||||||
newtId = newt.newtId;
|
newtId = newt.newtId;
|
||||||
}
|
}
|
||||||
|
|
||||||
await sendToClient(
|
await sendToClient(newtId, {
|
||||||
newtId,
|
type: "newt/wg/peer/add",
|
||||||
{
|
data: peer
|
||||||
type: "newt/wg/peer/add",
|
}, { incrementConfigVersion: true }).catch((error) => {
|
||||||
data: peer
|
|
||||||
},
|
|
||||||
{ incrementConfigVersion: true }
|
|
||||||
).catch((error) => {
|
|
||||||
logger.warn(`Error sending message:`, error);
|
logger.warn(`Error sending message:`, error);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -80,16 +76,12 @@ export async function deletePeer(
|
|||||||
newtId = newt.newtId;
|
newtId = newt.newtId;
|
||||||
}
|
}
|
||||||
|
|
||||||
await sendToClient(
|
await sendToClient(newtId, {
|
||||||
newtId,
|
type: "newt/wg/peer/remove",
|
||||||
{
|
data: {
|
||||||
type: "newt/wg/peer/remove",
|
publicKey
|
||||||
data: {
|
}
|
||||||
publicKey
|
}, { incrementConfigVersion: true }).catch((error) => {
|
||||||
}
|
|
||||||
},
|
|
||||||
{ incrementConfigVersion: true }
|
|
||||||
).catch((error) => {
|
|
||||||
logger.warn(`Error sending message:`, error);
|
logger.warn(`Error sending message:`, error);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -98,35 +90,6 @@ export async function deletePeer(
|
|||||||
return site;
|
return site;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function deletePeersBatch(
|
|
||||||
peers: {
|
|
||||||
siteId: number;
|
|
||||||
publicKey: string;
|
|
||||||
newtId: string;
|
|
||||||
}[]
|
|
||||||
) {
|
|
||||||
if (peers.length === 0) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
await sendToClientsBatch(
|
|
||||||
peers.map((peer) => ({
|
|
||||||
clientId: peer.newtId,
|
|
||||||
message: {
|
|
||||||
type: "newt/wg/peer/remove",
|
|
||||||
data: {
|
|
||||||
publicKey: peer.publicKey
|
|
||||||
}
|
|
||||||
},
|
|
||||||
options: { incrementConfigVersion: true }
|
|
||||||
}))
|
|
||||||
).catch((error) => {
|
|
||||||
logger.warn(`Error sending batched newt peer removals:`, error);
|
|
||||||
});
|
|
||||||
|
|
||||||
logger.info(`Deleted ${peers.length} peer(s) from newts (batch)`);
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function updatePeer(
|
export async function updatePeer(
|
||||||
siteId: number,
|
siteId: number,
|
||||||
publicKey: string,
|
publicKey: string,
|
||||||
@@ -159,17 +122,13 @@ export async function updatePeer(
|
|||||||
newtId = newt.newtId;
|
newtId = newt.newtId;
|
||||||
}
|
}
|
||||||
|
|
||||||
await sendToClient(
|
await sendToClient(newtId, {
|
||||||
newtId,
|
type: "newt/wg/peer/update",
|
||||||
{
|
data: {
|
||||||
type: "newt/wg/peer/update",
|
publicKey,
|
||||||
data: {
|
...peer
|
||||||
publicKey,
|
}
|
||||||
...peer
|
}, { incrementConfigVersion: true }).catch((error) => {
|
||||||
}
|
|
||||||
},
|
|
||||||
{ incrementConfigVersion: true }
|
|
||||||
).catch((error) => {
|
|
||||||
logger.warn(`Error sending message:`, error);
|
logger.warn(`Error sending message:`, error);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -104,7 +104,7 @@ export async function createUserOlm(
|
|||||||
dateCreated: moment().toISOString()
|
dateCreated: moment().toISOString()
|
||||||
});
|
});
|
||||||
|
|
||||||
calculateUserClientsForOrgs(userId).catch((e) => {
|
calculateUserClientsForOrgs(userId, primaryDb).catch((e) => {
|
||||||
console.error(
|
console.error(
|
||||||
"Error calculating user clients after creating olm:",
|
"Error calculating user clients after creating olm:",
|
||||||
e
|
e
|
||||||
|
|||||||
@@ -86,11 +86,13 @@ export async function deleteUserOlm(
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (deletedClient) {
|
if (deletedClient) {
|
||||||
rebuildClientAssociationsFromClient(deletedClient).catch((e) => {
|
rebuildClientAssociationsFromClient(deletedClient, primaryDb).catch(
|
||||||
logger.error(
|
(e) => {
|
||||||
`Failed to rebuild client-site associations after deleting OLM ${olmId}: ${e}`
|
logger.error(
|
||||||
);
|
`Failed to rebuild client-site associations after deleting OLM ${olmId}: ${e}`
|
||||||
});
|
);
|
||||||
|
}
|
||||||
|
);
|
||||||
sendTerminateClient(
|
sendTerminateClient(
|
||||||
deletedClient.clientId,
|
deletedClient.clientId,
|
||||||
OlmErrorCodes.TERMINATED_DELETED,
|
OlmErrorCodes.TERMINATED_DELETED,
|
||||||
|
|||||||
@@ -20,8 +20,7 @@ import { handleFingerprintInsertion } from "./fingerprintingUtils";
|
|||||||
import { build } from "@server/build";
|
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";
|
||||||
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;
|
||||||
@@ -386,8 +385,6 @@ 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,
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user