diff --git a/.vscode/settings.json b/.vscode/settings.json index 5092cb6c18..a7da2a1cc2 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -18,5 +18,8 @@ "[json]": { "editor.defaultFormatter": "esbenp.prettier-vscode" }, - "editor.formatOnSave": true + "editor.formatOnSave": true, + "cSpell.words": [ + "nessicary" + ] } \ No newline at end of file diff --git a/messages/bg-BG.json b/messages/bg-BG.json index af2c2eca3c..6cefe4a4db 100644 --- a/messages/bg-BG.json +++ b/messages/bg-BG.json @@ -66,9 +66,15 @@ "local": "Локална", "edit": "Редактиране", "siteConfirmDelete": "Потвърждение на изтриване на сайта", + "siteConfirmDeleteAndResources": "Потвърдете изтриването на сайта и ресурсите", "siteDelete": "Изтриване на сайта", + "siteDeleteAndResources": "Изтриване на сайта и ресурсите", "siteMessageRemove": "След премахване, сайтът вече няма да бъде достъпен. Всички цели, свързани със сайта, също ще бъдат премахнати.", + "siteMessageRemoveAndResources": "Това ще изтрие окончателно всички публични и частни ресурси, свързани с този сайт, дори ако ресурсът е асоцииран и с други сайтове.", "siteQuestionRemove": "Сигурни ли сте, че искате да премахнете сайта от организацията?", + "siteQuestionRemoveAndResources": "Наистина ли желаете да изтриете този сайт и всички свързани ресурси?", + "sitesTableDeleteSite": "Изтриване на сайта", + "sitesTableDeleteSiteAndResources": "Изтриване на сайта и ресурсите", "siteManageSites": "Управление на сайтове", "siteDescription": "Създайте и управлявайте сайтове, за да осигурите свързаност със частни мрежи", "sitesBannerTitle": "Свържете се с мрежа.", @@ -2338,6 +2344,7 @@ "createInternalResourceDialogDestinationCidrDescription": "CIDR диапазонът на ресурса в мрежата на сайта.", "createInternalResourceDialogAlias": "Псевдоним", "createInternalResourceDialogAliasDescription": "По избор вътрешен DNS псевдоним за този ресурс.", + "internalResourceAliasLocalWarning": "Синоними с окончание .local могат да причинят проблеми с резолюцията поради mDNS в някои мрежи.", "internalResourceDownstreamSchemeRequired": "Методът е задължителен за HTTP ресурси", "internalResourceHttpPortRequired": "Портът към целта е задължителен за HTTP ресурси", "siteConfiguration": "Конфигурация", @@ -2967,6 +2974,7 @@ "orgOrDomainIdMissing": "Липсва идентификатор на организация или домейн", "loadingDNSRecords": "Зареждане на DNS записи...", "olmUpdateAvailableInfo": "Налична е актуализирана версия на Olm. Моля, актуализирайте до най-новата версия за най-добро преживяване.", + "updateAvailableInfo": "На разположение е обновена версия. Моля, обновете до най-новата версия за най-добър опит.", "client": "Клиент", "proxyProtocol": "Настройки на прокси протокол", "proxyProtocolDescription": "Конфигурирайте Proxy Protocol, за да запазите IP адресите на клиентите за TCP услуги.", diff --git a/messages/cs-CZ.json b/messages/cs-CZ.json index 581f37762b..9a175093e5 100644 --- a/messages/cs-CZ.json +++ b/messages/cs-CZ.json @@ -66,9 +66,15 @@ "local": "Místní", "edit": "Upravit", "siteConfirmDelete": "Potvrdit odstranění lokality", + "siteConfirmDeleteAndResources": "Potvrdit odstranění lokality a zdrojů", "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.", + "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?", + "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", "siteDescription": "Vytvořte a spravujte stránky pro povolení připojení k soukromým sítím", "sitesBannerTitle": "Připojit jakoukoli síť", @@ -2338,6 +2344,7 @@ "createInternalResourceDialogDestinationCidrDescription": "Rozsah zdrojů CIDR v síti webu.", "createInternalResourceDialogAlias": "Alias", "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", "internalResourceHttpPortRequired": "Přípoječný port je nutný pro HTTP zdroj", "siteConfiguration": "Konfigurace", @@ -2967,6 +2974,7 @@ "orgOrDomainIdMissing": "Chybí ID organizace nebo domény", "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.", + "updateAvailableInfo": "Je k dispozici aktualizovaná verze. Aktualizujte prosím na nejnovější verzi pro nejlepší zážitek.", "client": "Zákazník", "proxyProtocol": "Nastavení proxy protokolu", "proxyProtocolDescription": "Konfigurace Proxy protokolu pro zachování klientských IP adres pro služby TCP.", diff --git a/messages/da-DK.json b/messages/da-DK.json index cdf209ed07..b9db7f3235 100644 --- a/messages/da-DK.json +++ b/messages/da-DK.json @@ -58,7 +58,7 @@ "name": "Navn", "online": "Online", "offline": "Offline", - "site": "Site", + "site": "Websted", "dataIn": "Data ind", "dataOut": "Data ud", "connectionType": "Forbindelsestype", @@ -66,9 +66,15 @@ "local": "Lokal", "edit": "Rediger", "siteConfirmDelete": "Bekræft Sletning af Site", + "siteConfirmDeleteAndResources": "Bekræft sletning af sted og ressourcer", "siteDelete": "Slet Site", + "siteDeleteAndResources": "Slet Sted og Ressourcer", "siteMessageRemove": "Når sitet er fjernet, vil det ikke længere være tilgængeligt. Alle mål for sitet vil også blive fjernet.", + "siteMessageRemoveAndResources": "Dette vil permanent slette alle offentlige og private ressourcer knyttet til dette sted, selv hvis en ressource også er knyttet til andre steder.", "siteQuestionRemove": "Er du sikker på at du vil fjerne sitet fra organisationen?", + "siteQuestionRemoveAndResources": "Er du sikker på, at du vil slette dette sted og alle tilknyttede ressourcer?", + "sitesTableDeleteSite": "Slet sted", + "sitesTableDeleteSiteAndResources": "Slet sted og ressourcer", "siteManageSites": "Administrer Sites", "siteDescription": "Oprette og administrer sites for at aktivere forbindelse til private netværk", "sitesBannerTitle": "Opret forbindelse til alle netværk", @@ -711,7 +717,7 @@ "proxyUpdatedDescription": "Proxy indstillinger er blevet opdateret", "proxyErrorUpdate": "En fejl opstod under opdatering af proxyindstillinger", "proxyErrorUpdateDescription": "En fejl opstod under opdatering af proxyindstillinger", - "targetAddr": "Host", + "targetAddr": "Vært", "targetPort": "Port", "targetProtocol": "Protokol", "targetTlsSettings": "Sikker forbindelseskonfiguration", @@ -902,7 +908,7 @@ "newtSecretKey": "Sikkerhedsnøgle", "newtVersion": "Version", "architecture": "Arkitektur", - "sites": "Sites", + "sites": "Websteder", "siteWgAnyClients": "Brug hvilken som helst WireGuard klient til at oprette forbindelse til. Du skal adressere interne ressourcer ved hjælp af peer IP.", "siteWgCompatibleAllClients": "Kompatibel med alle WireGuard-klienter", "siteWgManualConfigurationRequired": "Manuel konfiguration påkrævet", @@ -1059,7 +1065,7 @@ "network": "Netværk", "manage": "Administrer", "sitesNotFound": "Ingen sites fundet.", - "pangolinServerAdmin": "Server Admin - Pangolin", + "pangolinServerAdmin": "Serveradmin - Pangolin", "licenseTierProfessional": "Professionel licens", "licenseTierEnterprise": "Enterprise-licens", "licenseTierPersonal": "Personlig licens", @@ -1186,7 +1192,7 @@ "emailVerifyResend": "Har du ikke modtaget en kode? Klik her for at sende igen", "passwordNotMatch": "Adgangskoderne matcher ikke", "signupError": "Det opstod en fejl ved registrering", - "pangolinLogoAlt": "Pangolin Logo", + "pangolinLogoAlt": "Pangolin-logo", "inviteAlready": "Ser ud til at du er blevet inviteret!", "inviteAlreadyDescription": "For at acceptere invitationen skal du logge ind eller oprette en konto.", "signupQuestion": "Har du allerede en konto?", @@ -1533,7 +1539,7 @@ "apiKeysErrorNoUpdate": "Ingen API-nøgle at opdatere", "sidebarOverview": "Oversigt", "sidebarHome": "Hjem", - "sidebarSites": "Sites", + "sidebarSites": "Websteder", "sidebarApprovals": "Godkendelsesanmodninger", "sidebarResources": "Ressourcer", "sidebarProxyResources": "Offentlig", @@ -1573,7 +1579,7 @@ "alertingSearchRules": "Søg i regler…", "alertingAddRule": "Opret regel", "alertingColumnSource": "Kilde", - "alertingColumnTrigger": "Trigger", + "alertingColumnTrigger": "Udløser", "alertingColumnActions": "Handlinger", "alertingColumnEnabled": "Aktiveret", "alertingDeleteQuestion": "Bekræft venligst, at du vil slette denne varslingsregel.", @@ -1589,9 +1595,9 @@ "alertingRuleEnabled": "Regel aktiveret", "alertingSectionSource": "Kilde", "alertingSourceType": "Kildetype", - "alertingSourceSite": "Site", + "alertingSourceSite": "Sted", "alertingSourceHealthCheck": "Sundhedstjek", - "alertingPickSites": "Sites", + "alertingPickSites": "Steder", "alertingPickHealthChecks": "Sundhedstjek", "alertingPickResources": "Ressourcer", "alertingAllSites": "Alle sites", @@ -1609,7 +1615,7 @@ "alertingSelectResources": "Vælg ressourcer…", "alertingResourcesSelected": "{count} ressourcer valgt", "alertingResourcesEmpty": "Ingen ressourcer med mål i de første 10 resultaterne.", - "alertingSectionTrigger": "Trigger", + "alertingSectionTrigger": "Udløser", "alertingTrigger": "Når skal det varsles", "alertingTriggerSiteOnline": "Site er online", "alertingTriggerSiteOffline": "Site er offline", @@ -1643,7 +1649,7 @@ "alertingWebhookMethod": "HTTP-metode", "alertingWebhookSecret": "Signeringshemmelighed (valgfrit)", "alertingWebhookSecretPlaceholder": "HMAC-hemmelig", - "alertingWebhookHeaders": "Headers", + "alertingWebhookHeaders": "Overskrifter", "alertingAddHeader": "Tilføj header", "alertingSelectSites": "Vælg sites…", "alertingSitesSelected": "{count} sites valgt", @@ -1655,7 +1661,7 @@ "alertingUsersSelected": "{count} brugere valgt", "alertingSelectRoles": "Vælg roller…", "alertingRolesSelected": "{count} roller valgt", - "alertingSummarySites": "Sites ({count})", + "alertingSummarySites": "Websteder ({count})", "alertingSummaryAllSites": "Alle sites", "alertingSummaryHealthChecks": "Sundhedstjek ({count})", "alertingSummaryAllHealthChecks": "Alle sundhedstjek", @@ -1683,7 +1689,7 @@ "alertingNodeNotConfigured": "Ikke konfigureret endnu", "alertingNodeActionsCount": "{count, plural, one {# handling} other {# handlinger}}", "alertingNodeRoleSource": "Kilde", - "alertingNodeRoleTrigger": "Trigger", + "alertingNodeRoleTrigger": "Udløser", "alertingNodeRoleAction": "Handling", "alertingTabRules": "Varslingsregler", "alertingTabHealthChecks": "Sundhedstjek", @@ -1720,7 +1726,7 @@ "standaloneHcFilterEnabled": "Aktiveret", "standaloneHcFilterEnabledOn": "Aktiveret", "standaloneHcFilterEnabledOff": "Deaktiveret", - "standaloneHcFilterSiteIdFallback": "Site {id}", + "standaloneHcFilterSiteIdFallback": "Sted {id}", "standaloneHcFilterResourceIdFallback": "Ressource {id}", "blueprints": "Blueprints", "blueprintsLog": "Blueprint-log", @@ -1893,7 +1899,7 @@ "billingUsageLimitsOverview": "Oversikt over forbrugsgrænser", "billingMonitorUsage": "Overvåg forbruget din i forhold til konfigurerte grænse. Hvis du behøver økte grænse, venligst kontakt support@pangolin.net.", "billingDataUsage": "Dataforbrug", - "billingSites": "Sites", + "billingSites": "Websteder", "billingUsers": "Brugere", "billingDomains": "Domæner", "billingOrganizations": "Orger", @@ -2112,7 +2118,7 @@ "healthSelectScheme": "Vælg metode", "healthCheckPortInvalid": "Porten skal være mellem 1 og 65535", "healthCheckPath": "Sti", - "healthHostname": "IP / Host", + "healthHostname": "IP / Vært", "healthPort": "Port", "healthCheckPathDescription": "Stien for at tjekke helsestatus.", "healthyIntervalSeconds": "Sund intervall (sek)", @@ -2165,7 +2171,7 @@ "sshSudoMode": "Sudo adgang", "sshSudoModeNone": "Ingen", "sshSudoModeNoneDescription": "Brugeren kan ikke køre kommandoer med sudo.", - "sshSudoModeFull": "Full Sudo", + "sshSudoModeFull": "Fuld Sudo", "sshSudoModeFullDescription": "Brugeren kan køre hvilken som helst kommando med sudo.", "sshSudoModeCommands": "Kommandoer", "sshSudoModeCommandsDescription": "Brugeren kan kun køre de angitte kommandoene med sudo.", @@ -2185,7 +2191,7 @@ "roleTextImportPreview": "Forhåndsvisning", "roleTextImportItemCount": "{count, plural, =0 {Ingen elementer at importere} one {ét element at importere} other {# elementer at importere}}", "roleTextImportTotalCount": "{existing} eksisterende + {imported} importert = {total} totalt", - "roleTextImportConfirm": "Import", + "roleTextImportConfirm": "Importer", "roleTextImportInvalidFile": "Ustøttet filtype", "roleTextImportInvalidFileDescription": "Kun.txt og.csv filer er støttet.", "roleTextImportEmpty": "Ingen elementer fundet i filen", @@ -2218,7 +2224,7 @@ "httpMethod": "HTTP-metode", "selectHttpMethod": "Vælg HTTP-metode", "domainPickerSubdomainLabel": "Underdomæne", - "domainPickerWildcard": "Wildcard", + "domainPickerWildcard": "Jokertegn", "domainPickerWildcardPaidOnly": "Wildcard-underdomæner er en betalt funktion. Opgrader venligst for at få adgang til denne funktion.", "domainPickerBaseDomainLabel": "Basisdomæne", "domainPickerSearchDomains": "Søg i domæner...", @@ -2274,7 +2280,7 @@ "editInternalResourceDialogPortModeRequired": "Protokol, proxy-port og målport er påkrævet for porttilstand", "editInternalResourceDialogMode": "Tilstand", "editInternalResourceDialogModePort": "Port", - "editInternalResourceDialogModeHost": "Host", + "editInternalResourceDialogModeHost": "Vært", "editInternalResourceDialogModeCidr": "CIDR", "editInternalResourceDialogModeHttp": "HTTP", "editInternalResourceDialogModeHttps": "HTTPS", @@ -2295,7 +2301,7 @@ "createInternalResourceDialogCreateClientResourceDescription": "Opret en ny ressource som kun vil være tilgængelig for kunder som er forbundet til organisationen", "createInternalResourceDialogResourceProperties": "Ressourceegenskaber", "createInternalResourceDialogName": "Navn", - "createInternalResourceDialogSite": "Site", + "createInternalResourceDialogSite": "Websted", "selectSite": "Vælg site...", "multiSitesSelectorSitesCount": "{count, plural, one {# sted} other {# steder}}", "noSitesFound": "Ingen sites fundet.", @@ -2324,7 +2330,7 @@ "createInternalResourceDialogPortModeRequired": "Protokol, proxy-port og målport er påkrævet for porttilstand", "createInternalResourceDialogMode": "Tilstand", "createInternalResourceDialogModePort": "Port", - "createInternalResourceDialogModeHost": "Host", + "createInternalResourceDialogModeHost": "Vært", "createInternalResourceDialogModeCidr": "CIDR", "createInternalResourceDialogModeHttp": "HTTP", "createInternalResourceDialogModeHttps": "HTTPS", @@ -2338,6 +2344,7 @@ "createInternalResourceDialogDestinationCidrDescription": "CIDR-området til ressourcen på sitets netværk.", "createInternalResourceDialogAlias": "Alias", "createInternalResourceDialogAliasDescription": "Et valgfrit internt DNS-alias for denne ressource.", + "internalResourceAliasLocalWarning": "Aliasser, der ender på .local, kan forårsage opløsningsproblemer på grund af mDNS på nogle netværk.", "internalResourceDownstreamSchemeRequired": "Skema er påkrævet for HTTP-ressourcer", "internalResourceHttpPortRequired": "Destinasjonsport er nødvendig for HTTP-ressourcer", "siteConfiguration": "Konfiguration", @@ -2435,7 +2442,7 @@ "regionAfrica": "Afrika", "regionNorthernAfrica": "[country name] Nord-Afrika", "regionEasternAfrica": "Øst-Afrika", - "regionMiddleAfrica": "Middle Africa", + "regionMiddleAfrica": "Mellemafrika", "regionSouthernAfrica": "Sør-Afrika", "regionWesternAfrica": "[country name] Vest-Afrika", "regionAmericas": "Amerika", @@ -2443,7 +2450,7 @@ "regionCentralAmerica": "Sentral-Amerika", "regionSouthAmerica": "Sør-Amerika", "regionNorthernAmerica": "Nord-Amerika", - "regionAsia": "Asia", + "regionAsia": "Asien", "regionCentralAsia": "Sentral-Asia", "regionEasternAsia": "Øst-Asia", "regionSouthEasternAsia": "Sørøst-Asia", @@ -2454,11 +2461,11 @@ "regionNorthernEurope": "Nord-Europa", "regionSouthernEurope": "Sydeuropa", "regionWesternEurope": "Vest-Europa", - "regionOceania": "Oceania", + "regionOceania": "Oceanien", "regionAustraliaAndNewZealand": "Australia og New Zealand", - "regionMelanesia": "Melanesia", - "regionMicronesia": "Micronesia", - "regionPolynesia": "Polynesia", + "regionMelanesia": "Melanesien", + "regionMicronesia": "Mikronesien", + "regionPolynesia": "Polynesien", "managedSelfHosted": { "title": "Administreret selvhostet", "description": "Sikre, selvhostede Pangolin-servere med lav vedligeholdelse og ekstra bells and whistles", @@ -2528,12 +2535,12 @@ "roleMappingRemoveRule": "Fjern", "idpGoogleConfiguration": "Google Konfiguration", "idpGoogleConfigurationDescription": "Konfigurer Google OAuth2 legitimationsoplysningerne", - "idpGoogleClientIdDescription": "Google OAuth2 Client ID", + "idpGoogleClientIdDescription": "Google OAuth2-klient-ID", "idpGoogleClientSecretDescription": "Google OAuth2-klienten hemmelighed", "idpAzureConfiguration": "Azure Entra ID konfiguration", "idpAzureConfigurationDescription": "Konfigurer Azure Entra ID OAuth2 legitimationsoplysninger", "idpTenantId": "Leietaker-ID", - "idpTenantIdPlaceholder": "tenant-id", + "idpTenantIdPlaceholder": "lejer-id", "idpAzureTenantIdDescription": "Azure leant ID (fundet i Azure Active Directory-oversikten)", "idpAzureClientIdDescription": "Azure App registrerings klient-ID", "idpAzureClientSecretDescription": "Azure App Registrering Klient Hemmelig", @@ -2547,7 +2554,7 @@ "idpAzureClientIdDescription2": "Azure App registrerings klient-ID", "idpAzureClientSecretDescription2": "Azure App Registrering Klient Hemmelig", "idpGoogleDescription": "Google OAuth2/OIDC udbyder", - "idpAzureDescription": "Microsoft Azure OAuth2/OIDC provider", + "idpAzureDescription": "Microsoft Azure OAuth2/OIDC leverandør", "subnet": "Subnet", "subnetDescription": "Subnettet for denne organisations netværkskonfiguration.", "customDomain": "Brugerdefineret domæne", @@ -2698,7 +2705,7 @@ "resourceHeaderAuthSetupTitleDescription": "Angiv grunnleggende auth legitimationsoplysninger (brugernavn og adgangskode) for at beskytte denne ressource med HTTP Header autentificering. Adgang til det ved hjælp af formatet HTTPS://username:password@resource.example.com", "resourceHeaderAuthSubmit": "Angiv header-godkendelse", "actionSetResourceHeaderAuth": "Angiv header-godkendelse", - "enterpriseEdition": "Enterprise Edition", + "enterpriseEdition": "Enterprise-udgave", "unlicensed": "Ikke licenseret", "beta": "beta", "manageUserDevices": "Bruger Enheder", @@ -2877,10 +2884,10 @@ "toConfirm": "at bekræfte.", "deleteClientQuestion": "Er du sikker på at du vil fjerne klienten fra sitet og organisationen?", "clientMessageRemove": "Når klienten er fjernet, kan den ikke længere oprette forbindelse til sitet.", - "sidebarLogs": "Logs", + "sidebarLogs": "Logfiler", "request": "Forespørgsel", "requests": "Forespørgsler", - "logs": "Logs", + "logs": "Logfiler", "logsSettingsDescription": "Overvåg logs samlet ind fra denne organisation", "searchLogs": "Søg i logs...", "action": "Handling", @@ -2894,11 +2901,11 @@ "allowedByRule": "Tilladt efter regel", "allowedNoAuth": "Tilladt Ingen Auth", "validAccessToken": "Gyldig adgangsnøgle", - "validHeaderAuth": "Valid header auth", + "validHeaderAuth": "Gyldig header-auth", "validPincode": "Valid PIN-kode", "validPassword": "Gyldig adgangskode", - "validEmail": "Valid email", - "validSSO": "Valid SSO", + "validEmail": "Gyldig e-mail", + "validSSO": "Gyldig SSO", "view": "Vis", "configManaged": "Konfiguration administrert", "connectedClient": "Tilsluttet klient", @@ -2906,12 +2913,12 @@ "droppedByRule": "Legg i reglen", "noSessions": "Ingen økter", "temporaryRequestToken": "Midlertidig forespørgsel Token", - "noMoreAuthMethods": "No Valid Auth", + "noMoreAuthMethods": "Ingen gyldig auth", "ip": "IP", "reason": "Grund", "requestLogs": "HTTP-forespørgselslogs", "requestAnalytics": "Be om analyser", - "host": "Host", + "host": "Vært", "location": "Sted", "actionLogs": "Handlingsloger", "sidebarLogsRequest": "HTTP-forespørgselslogs", @@ -2967,6 +2974,7 @@ "orgOrDomainIdMissing": "ID for organisation eller domæne mangler", "loadingDNSRecords": "Indlæser DNS-poster...", "olmUpdateAvailableInfo": "En opdateret version af Olm er tilgængeligt. Opdater til den nyeste version for at få den bedste oplevelse.", + "updateAvailableInfo": "En opdateret version er tilgængelig. Opdater til den nyeste version for at få den bedste oplevelse.", "client": "Klient", "proxyProtocol": "Protokol indstillinger for Protokol", "proxyProtocolDescription": "Konfigurer Proxy-protokol for at bevare klientens IP-adresser til TCP-tjenester.", @@ -3082,7 +3090,7 @@ "niceIdUpdateErrorDescription": "Det opstod en fejl under opdatering af Nice ID.", "niceIdCannotBeEmpty": "God ID kan ikke være tom", "enterIdentifier": "Angiv identifikator", - "identifier": "Identifier", + "identifier": "Identifikator", "deviceLoginUseDifferentAccount": "Ikke du? Brug en anden konto.", "deviceLoginDeviceRequestingAccessToAccount": "En enhed ber om adgang til denne kontoen.", "loginSelectAuthenticationMethod": "Vælg en autentificeringsmetode for at fortsætte.", @@ -3260,13 +3268,13 @@ "platform": "Platform", "macosVersion": "macOS version", "windowsVersion": "Windows version", - "iosVersion": "iOS Version", + "iosVersion": "iOS-version", "androidVersion": "Android version", "osVersion": "OS version", "kernelVersion": "Kjerne version", "deviceModel": "Enhedsmodel", "serialNumber": "Serienummer", - "hostname": "Hostname", + "hostname": "Hostnavn", "firstSeen": "Først sett", "lastSeen": "Sist sett", "biometricsEnabled": "Biometri aktiveret", @@ -3366,9 +3374,9 @@ "datadogDestEditDescription": "Opdater konfigurationen for denne Datadog-hændelsesstreamingdestination.", "datadogDestAddDescription": "Konfigurer et nyt Datadog-endpoint til at modtage organisationens hændelser.", "httpDestTabSettings": "Indstillinger", - "httpDestTabHeaders": "Headers", + "httpDestTabHeaders": "Overskrifter", "httpDestTabBody": "Indhold", - "httpDestTabLogs": "Logs", + "httpDestTabLogs": "Logfiler", "httpDestNamePlaceholder": "Min HTTP destination", "httpDestUrlLabel": "Destinasjons URL", "httpDestUrlErrorHttpRequired": "URL-adressen skal bruge httpp eller HTTPS", @@ -3454,7 +3462,7 @@ "idpUnassociatedDescription": "Identitetsudbyderen er frakoblet fra denne organisation", "idpUnassociateMenu": "Frakobl", "idpDeleteAllOrgsMenu": "Slet", - "publicIpEndpoint": "Endpoint", + "publicIpEndpoint": "Slutpunkt", "lastTriggeredAt": "Senest udløst", "reject": "Afvis", "uptimeDaysAgo": "{count} dage siden", @@ -3587,7 +3595,7 @@ "rdpNoConnectionTarget": "Intet forbindelsesmål tilgængeligt", "rdpConnectionFailed": "Forbindelsen mislykkedes", "rdpFit": "Tilpass", - "rdpFull": "Full", + "rdpFull": "Fuldt", "rdpReal": "Ekte", "rdpMeta": "Meta", "rdpUploadFiles": "Upload filer", @@ -3597,4 +3605,4 @@ "rdpUnicodeKeyboardMode": "Unicode tastaturtilstand", "sessionToolbarShow": "Vis værktøjslinje", "sessionToolbarHide": "Skjul værktøjslinje" -} \ No newline at end of file +} diff --git a/messages/de-DE.json b/messages/de-DE.json index 87afc5d684..38e8fafc4b 100644 --- a/messages/de-DE.json +++ b/messages/de-DE.json @@ -66,9 +66,15 @@ "local": "Lokal", "edit": "Bearbeiten", "siteConfirmDelete": "Löschen des Standorts bestätigen", + "siteConfirmDeleteAndResources": "Löschen von Standort und Ressourcen bestätigen", "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.", + "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?", + "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", "siteDescription": "Erstellen und Verwalten von Standorten, um die Verbindung zu privaten Netzwerken zu ermöglichen", "sitesBannerTitle": "Verbinde ein beliebiges Netzwerk", @@ -2338,6 +2344,7 @@ "createInternalResourceDialogDestinationCidrDescription": "Der CIDR-Bereich der Ressource im Netzwerk der Website.", "createInternalResourceDialogAlias": "Alias", "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", "internalResourceHttpPortRequired": "Zielport ist für HTTP-Ressourcen erforderlich", "siteConfiguration": "Konfiguration", @@ -2967,6 +2974,7 @@ "orgOrDomainIdMissing": "Organisation oder Domänen-ID fehlt", "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.", + "updateAvailableInfo": "Eine aktualisierte Version ist verfügbar. Bitte aktualisieren Sie auf die neueste Version für das beste Erlebnis.", "client": "Client", "proxyProtocol": "Proxy-Protokoll-Einstellungen", "proxyProtocolDescription": "Konfigurieren Sie das Proxy-Protokoll, um die IP-Adressen des Clients für TCP-Dienste zu erhalten.", diff --git a/messages/en-US.json b/messages/en-US.json index 5937595b57..83e8a488d7 100644 --- a/messages/en-US.json +++ b/messages/en-US.json @@ -66,9 +66,15 @@ "local": "Local", "edit": "Edit", "siteConfirmDelete": "Confirm Delete Site", + "siteConfirmDeleteAndResources": "Confirm Delete Site and Resources", "siteDelete": "Delete Site", - "siteMessageRemove": "Once removed the site will no longer be accessible. All targets associated with the site will also be removed.", + "siteDeleteAndResources": "Delete Site and Resources", + "siteMessageRemove": "Once removed the site will no longer be accessible. Targets associated with this site will be removed, but resources will remain.", + "siteMessageRemoveAndResources": "This will permanently delete all public and private resources linked to this site, even if a resource is also associated with other sites.", "siteQuestionRemove": "Are you sure you want to remove the site from the organization?", + "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", "siteDescription": "Create and manage sites to enable connectivity to private networks", "sitesBannerTitle": "Connect Any Network", @@ -204,7 +210,7 @@ "proxyResourceTitle": "Manage Public Resources", "proxyResourceDescription": "Create and manage resources that are publicly accessible through a web browser", "publicResourcesBannerTitle": "Web-based Public Access", - "publicResourcesBannerDescription": "Public resources are HTTPS proxies accessible to anyone on the internet through a web browser. Unlike private resources, they do not require client-side software and can include identity and context-aware access policies.", + "publicResourcesBannerDescription": "Public resources are proxies accessible to anyone on the internet through a web browser and include identity and context-aware access policies. Unlike private resources, they do not require client-side software.", "clientResourceTitle": "Manage Private Resources", "clientResourceDescription": "Create and manage resources that are only accessible through a connected client", "privateResourcesBannerTitle": "Zero-Trust Private Access", @@ -1638,7 +1644,7 @@ "alertingActionType": "Action type", "alertingNotifyUsers": "Users", "alertingNotifyRoles": "Roles", - "alertingNotifyEmails": "Email addresses", + "alertingNotifyEmails": "Email Addresses", "alertingEmailPlaceholder": "Add email and press Enter", "alertingWebhookMethod": "HTTP method", "alertingWebhookSecret": "Signing secret (optional)", @@ -2171,10 +2177,10 @@ "sshSudoModeCommandsDescription": "User can run only the specified commands with sudo.", "sshSudo": "Allow sudo", "sshSudoCommands": "Sudo Commands", - "sshSudoCommandsDescription": "List of commands the user is allowed to run with sudo, separated by commas, spaces, or new lines. Absolute paths must be used.", + "sshSudoCommandsDescription": "List of commands the user is allowed to run with sudo, one per line. Absolute paths must be used.", "sshCreateHomeDir": "Create Home Directory", "sshUnixGroups": "Unix Groups", - "sshUnixGroupsDescription": "Unix groups to add the user to on the target host, separated by commas, spaces, or new lines.", + "sshUnixGroupsDescription": "Unix groups to add the user to on the target host, one per line.", "roleTextFieldPlaceholder": "Enter values, or drop a .txt or .csv file", "roleTextImportTitle": "Import from File", "roleTextImportDescription": "Importing {fileName} into {fieldLabel}.", @@ -2338,6 +2344,7 @@ "createInternalResourceDialogDestinationCidrDescription": "The CIDR range of the resource on the site's network.", "createInternalResourceDialogAlias": "Alias", "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", "internalResourceHttpPortRequired": "Destination port is required for HTTP resources", "siteConfiguration": "Configuration", @@ -2967,6 +2974,7 @@ "orgOrDomainIdMissing": "Organization or Domain ID is missing", "loadingDNSRecords": "Loading DNS records...", "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", "proxyProtocol": "Proxy Protocol Settings", "proxyProtocolDescription": "Configure Proxy Protocol to preserve client IP addresses for TCP services.", diff --git a/messages/es-ES.json b/messages/es-ES.json index 19a6ab6e13..7895b15e59 100644 --- a/messages/es-ES.json +++ b/messages/es-ES.json @@ -66,9 +66,15 @@ "local": "Local", "edit": "Editar", "siteConfirmDelete": "Confirmar Borrar Sitio", + "siteConfirmDeleteAndResources": "Confirmar eliminación del sitio y recursos", "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.", + "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?", + "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", "siteDescription": "Crear y administrar sitios para permitir la conectividad a redes privadas", "sitesBannerTitle": "Conectar cualquier red", @@ -2338,6 +2344,7 @@ "createInternalResourceDialogDestinationCidrDescription": "El rango CIDR del recurso en la red del sitio.", "createInternalResourceDialogAlias": "Alias", "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", "internalResourceHttpPortRequired": "Se requiere el puerto de destino para recursos HTTP", "siteConfiguration": "Configuración", @@ -2967,6 +2974,7 @@ "orgOrDomainIdMissing": "Falta el ID de organización o dominio", "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.", + "updateAvailableInfo": "Hay una versión actualizada disponible. Actualice a la última versión para obtener la mejor experiencia.", "client": "Cliente", "proxyProtocol": "Configuración del Protocolo Proxy", "proxyProtocolDescription": "Configurar el protocolo de proxy para preservar las direcciones IP del cliente para los servicios TCP.", diff --git a/messages/fr-FR.json b/messages/fr-FR.json index c2abdbe02d..14a4a53653 100644 --- a/messages/fr-FR.json +++ b/messages/fr-FR.json @@ -66,9 +66,15 @@ "local": "Locale", "edit": "Modifier", "siteConfirmDelete": "Confirmer la suppression du nœud", + "siteConfirmDeleteAndResources": "Confirmer la suppression du site et des ressources", "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.", + "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 ?", + "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", "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", @@ -2338,6 +2344,7 @@ "createInternalResourceDialogDestinationCidrDescription": "La gamme CIDR de la ressource sur le réseau du site.", "createInternalResourceDialogAlias": "Alias", "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", "internalResourceHttpPortRequired": "Le port de destination est requis pour les ressources HTTP", "siteConfiguration": "Configuration", @@ -2967,6 +2974,7 @@ "orgOrDomainIdMissing": "L'organisation ou l'identifiant de domaine est manquant", "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.", + "updateAvailableInfo": "Une version mise à jour est disponible. Veuillez mettre à jour vers la dernière version pour une meilleure expérience.", "client": "Client", "proxyProtocol": "Paramètres du protocole proxy", "proxyProtocolDescription": "Configurer le protocole Proxy pour préserver les adresses IP du client pour les services TCP.", diff --git a/messages/it-IT.json b/messages/it-IT.json index 7623e8f644..74066721b2 100644 --- a/messages/it-IT.json +++ b/messages/it-IT.json @@ -66,9 +66,15 @@ "local": "Locale", "edit": "Modifica", "siteConfirmDelete": "Conferma Eliminazione Sito", + "siteConfirmDeleteAndResources": "Conferma Eliminazione Sito e Risorse", "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.", + "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?", + "siteQuestionRemoveAndResources": "Sei sicuro di voler eliminare questo sito e tutte le risorse associate?", + "sitesTableDeleteSite": "Elimina Sito", + "sitesTableDeleteSiteAndResources": "Elimina Sito e Risorse", "siteManageSites": "Gestisci Siti", "siteDescription": "Creare e gestire siti per abilitare la connettività a reti private", "sitesBannerTitle": "Connetti Qualsiasi Rete", @@ -2338,6 +2344,7 @@ "createInternalResourceDialogDestinationCidrDescription": "La gamma CIDR della risorsa sulla rete del sito.", "createInternalResourceDialogAlias": "Alias", "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", "internalResourceHttpPortRequired": "Porta di destinazione richiesta per risorse HTTP", "siteConfiguration": "Configurazione", @@ -2967,6 +2974,7 @@ "orgOrDomainIdMissing": "Manca l'ID dell'organizzazione o del dominio", "loadingDNSRecords": "Caricamento record DNS...", "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", "proxyProtocol": "Impostazioni Protocollo Proxy", "proxyProtocolDescription": "Configurare il protocollo proxy per preservare gli indirizzi IP client per i servizi TCP.", diff --git a/messages/ko-KR.json b/messages/ko-KR.json index f3829dd16c..a53e76d183 100644 --- a/messages/ko-KR.json +++ b/messages/ko-KR.json @@ -66,9 +66,15 @@ "local": "로컬", "edit": "편집", "siteConfirmDelete": "사이트 삭제 확인", + "siteConfirmDeleteAndResources": "사이트 및 리소스 삭제 확인", "siteDelete": "사이트 삭제", + "siteDeleteAndResources": "사이트 및 리소스 삭제", "siteMessageRemove": "삭제되면 사이트에 더 이상 액세스할 수 없습니다. 사이트와 연결된 모든 대상도 삭제됩니다.", + "siteMessageRemoveAndResources": "이 사이트와 연결된 모든 공용 및 개인 리소스는 다른 사이트에도 연결되어 있더라도 영구적으로 삭제됩니다.", "siteQuestionRemove": "조직에서 사이트를 제거하시겠습니까?", + "siteQuestionRemoveAndResources": "이 사이트와 모든 관련 리소스를 삭제하시겠습니까?", + "sitesTableDeleteSite": "사이트 삭제", + "sitesTableDeleteSiteAndResources": "사이트 및 리소스 삭제", "siteManageSites": "사이트 관리", "siteDescription": "프라이빗 네트워크로의 연결을 활성화하려면 사이트를 생성하고 관리하세요.", "sitesBannerTitle": "모든 네트워크 연결", @@ -2338,6 +2344,7 @@ "createInternalResourceDialogDestinationCidrDescription": "사이트 네트워크의 자원 IP 주소입니다.", "createInternalResourceDialogAlias": "별칭", "createInternalResourceDialogAliasDescription": "이 리소스에 대한 선택적 내부 DNS 별칭입니다.", + "internalResourceAliasLocalWarning": ".local로 끝나는 별칭은 일부 네트워크에서 mDNS로 인해 해결 문제가 발생할 수 있습니다.", "internalResourceDownstreamSchemeRequired": "HTTP 리소스에 스킴이 필요합니다", "internalResourceHttpPortRequired": "HTTP 리소스에 목적지 포트가 필요합니다", "siteConfiguration": "설정", @@ -2967,6 +2974,7 @@ "orgOrDomainIdMissing": "조직 ID 또는 도메인 ID가 누락되었습니다", "loadingDNSRecords": "DNS 레코드를 로드하는 중...", "olmUpdateAvailableInfo": "올름의 새 버전이 이용 가능합니다. 최상의 경험을 위해 최신 버전으로 업데이트하세요.", + "updateAvailableInfo": "업데이트된 버전이 있습니다. 최상의 경험을 위해 최신 버전으로 업데이트하세요.", "client": "클라이언트", "proxyProtocol": "프록시 프로토콜 설정", "proxyProtocolDescription": "TCP 서비스에 대한 클라이언트 IP 주소를 유지하도록 프록시 프로토콜을 구성하세요.", diff --git a/messages/nb-NO.json b/messages/nb-NO.json index 52ddf8e332..f26499819b 100644 --- a/messages/nb-NO.json +++ b/messages/nb-NO.json @@ -66,9 +66,15 @@ "local": "Lokal", "edit": "Rediger", "siteConfirmDelete": "Bekreft Sletting av Område", + "siteConfirmDeleteAndResources": "Bekreft sletting av nettsted og ressurser", "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.", + "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?", + "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", "siteDescription": "Opprette og administrere nettsteder for å aktivere tilkobling til private nettverk", "sitesBannerTitle": "Koble til alle nettverk", @@ -2338,6 +2344,7 @@ "createInternalResourceDialogDestinationCidrDescription": "CIDR-rekkevidden til ressursen på nettstedets nettverk.", "createInternalResourceDialogAlias": "Alias", "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", "internalResourceHttpPortRequired": "Destinasjonsport er nødvendig for HTTP-ressurser", "siteConfiguration": "Konfigurasjon", @@ -2967,6 +2974,7 @@ "orgOrDomainIdMissing": "ID for organisasjon eller domene mangler", "loadingDNSRecords": "Laster DNS-poster...", "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", "proxyProtocol": "Protokoll innstillinger for Protokoll", "proxyProtocolDescription": "Konfigurer Proxy-protokoll for å bevare klientens IP-adresser til TCP-tjenester.", diff --git a/messages/nl-NL.json b/messages/nl-NL.json index 642423454a..a483c147e0 100644 --- a/messages/nl-NL.json +++ b/messages/nl-NL.json @@ -66,9 +66,15 @@ "local": "Lokaal", "edit": "Bewerken", "siteConfirmDelete": "Verwijderen van site bevestigen", + "siteConfirmDeleteAndResources": "Bevestig Verwijderen van Site en Bronnen", "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.", + "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?", + "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", "siteDescription": "Maak en beheer sites om verbinding met privénetwerken in te schakelen", "sitesBannerTitle": "Verbind elk netwerk", @@ -2338,6 +2344,7 @@ "createInternalResourceDialogDestinationCidrDescription": "Het CIDR-bereik van het document op het netwerk van de site.", "createInternalResourceDialogAlias": "Alias", "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", "internalResourceHttpPortRequired": "Bestemmingspoort is vereist voor HTTP-bronnen", "siteConfiguration": "Configuratie", @@ -2967,6 +2974,7 @@ "orgOrDomainIdMissing": "Organisatie of domein ID ontbreekt", "loadingDNSRecords": "DNS-records laden...", "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", "proxyProtocol": "Proxy Protocol Instellingen", "proxyProtocolDescription": "Proxyprotocol configureren om de IP-adressen van de client voor TCP-diensten te bewaren.", diff --git a/messages/pl-PL.json b/messages/pl-PL.json index 3de2fe1658..bfe29df7d5 100644 --- a/messages/pl-PL.json +++ b/messages/pl-PL.json @@ -66,9 +66,15 @@ "local": "Lokalny", "edit": "Edytuj", "siteConfirmDelete": "Potwierdź usunięcie witryny", + "siteConfirmDeleteAndResources": "Potwierdź usunięcie witryny i zasobów", "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.", + "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?", + "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", "siteDescription": "Tworzenie stron i zarządzanie nimi, aby włączyć połączenia z prywatnymi sieciami", "sitesBannerTitle": "Połącz dowolną sieć", @@ -2338,6 +2344,7 @@ "createInternalResourceDialogDestinationCidrDescription": "Zakres CIDR zasobu w sieci witryny.", "createInternalResourceDialogAlias": "Alias", "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", "internalResourceHttpPortRequired": "Port docelowy jest wymagany dla zasobów HTTP", "siteConfiguration": "Konfiguracja", @@ -2967,6 +2974,7 @@ "orgOrDomainIdMissing": "Brakuje identyfikatora organizacji lub domeny", "loadingDNSRecords": "Ładowanie rekordów DNS...", "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", "proxyProtocol": "Ustawienia protokołu proxy", "proxyProtocolDescription": "Skonfiguruj protokół Proxy aby zachować adresy IP klienta dla usług TCP.", diff --git a/messages/pt-PT.json b/messages/pt-PT.json index 78105f4036..ff0d543fe9 100644 --- a/messages/pt-PT.json +++ b/messages/pt-PT.json @@ -66,9 +66,15 @@ "local": "Localização", "edit": "Alterar", "siteConfirmDelete": "Confirmar que pretende apagar o site", + "siteConfirmDeleteAndResources": "Confirmar Exclusão do Site e Recursos", "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.", + "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?", + "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", "siteDescription": "Criar e gerenciar sites para ativar a conectividade a redes privadas", "sitesBannerTitle": "Conectar a Qualquer Rede", @@ -2338,6 +2344,7 @@ "createInternalResourceDialogDestinationCidrDescription": "A faixa CIDR do recurso na rede do site.", "createInternalResourceDialogAlias": "Alias", "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", "internalResourceHttpPortRequired": "Porta de destino é obrigatória para recursos HTTP", "siteConfiguration": "Configuração", @@ -2967,6 +2974,7 @@ "orgOrDomainIdMissing": "ID da organização ou domínio está faltando", "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.", + "updateAvailableInfo": "Uma versão atualizada está disponível. Por favor, atualize para a versão mais recente para uma melhor experiência.", "client": "Cliente", "proxyProtocol": "Configurações de Protocolo Proxy", "proxyProtocolDescription": "Configurar o protocolo proxy para preservar endereços IP do cliente para serviços TCP.", diff --git a/messages/ru-RU.json b/messages/ru-RU.json index cacc4b895b..523f3c2eff 100644 --- a/messages/ru-RU.json +++ b/messages/ru-RU.json @@ -66,9 +66,15 @@ "local": "Локальный", "edit": "Редактировать", "siteConfirmDelete": "Подтвердить удаление сайта", + "siteConfirmDeleteAndResources": "Подтвердите удаление сайта и ресурсов", "siteDelete": "Удалить сайт", + "siteDeleteAndResources": "Удалить сайт и ресурсы", "siteMessageRemove": "После удаления сайт больше не будет доступен. Все цели, связанные с сайтом, также будут удалены.", + "siteMessageRemoveAndResources": "Это навсегда удалит все общественные и частные ресурсы, связанные с этим сайтом, даже если ресурс также связан с другими сайтами.", "siteQuestionRemove": "Вы уверены, что хотите удалить сайт из организации?", + "siteQuestionRemoveAndResources": "Вы уверены, что хотите удалить этот сайт и все связанные с ним ресурсы?", + "sitesTableDeleteSite": "Удалить сайт", + "sitesTableDeleteSiteAndResources": "Удалить сайт и ресурсы", "siteManageSites": "Управление сайтами", "siteDescription": "Создание и управление сайтами, чтобы включить подключение к приватным сетям", "sitesBannerTitle": "Подключить любую сеть", @@ -2338,6 +2344,7 @@ "createInternalResourceDialogDestinationCidrDescription": "Диапазон CIDR ресурса в сети сайта.", "createInternalResourceDialogAlias": "Alias", "createInternalResourceDialogAliasDescription": "Дополнительный внутренний DNS псевдоним для этого ресурса.", + "internalResourceAliasLocalWarning": "Псевдонимы, оканчивающиеся на .local, могут вызывать проблемы с разрешением из-за mDNS в некоторых сетях.", "internalResourceDownstreamSchemeRequired": "Схема обязательна для HTTP ресурсов", "internalResourceHttpPortRequired": "Порт назначения обязателен для HTTP ресурсов", "siteConfiguration": "Конфигурация", @@ -2967,6 +2974,7 @@ "orgOrDomainIdMissing": "Отсутствует организация или ID домена", "loadingDNSRecords": "Загрузка записей DNS...", "olmUpdateAvailableInfo": "Доступна обновленная версия Олма. Пожалуйста, обновитесь до последней версии.", + "updateAvailableInfo": "Доступна обновленная версия. Пожалуйста, обновитесь до последней версии для получения лучшего опыта.", "client": "Клиент", "proxyProtocol": "Настройки протокола прокси", "proxyProtocolDescription": "Настроить Прокси-протокол для сохранения IP-адресов клиента для служб TCP.", diff --git a/messages/tr-TR.json b/messages/tr-TR.json index d5fd66262f..db5f8158a4 100644 --- a/messages/tr-TR.json +++ b/messages/tr-TR.json @@ -66,9 +66,15 @@ "local": "Yerel", "edit": "Düzenle", "siteConfirmDelete": "Site Silmeyi Onayla", + "siteConfirmDeleteAndResources": "Site ve Kaynakları Silmeyi Onayla", "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.", + "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?", + "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", "siteDescription": "Özel ağlara erişimi etkinleştirmek için siteler oluşturun ve yönetin", "sitesBannerTitle": "Herhangi Bir Ağa Bağlan", @@ -2338,6 +2344,7 @@ "createInternalResourceDialogDestinationCidrDescription": "Site ağındaki kaynağın CIDR aralığı.", "createInternalResourceDialogAlias": "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", "internalResourceHttpPortRequired": "HTTP kaynakları için hedef bağlantı noktası gereklidir", "siteConfiguration": "Yapılandırma", @@ -2967,6 +2974,7 @@ "orgOrDomainIdMissing": "Organizasyon veya Alan Adı Kimliği eksik", "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.", + "updateAvailableInfo": "Güncellenmiş bir sürüm mevcut. En iyi deneyim için lütfen en son sürüme güncelleyin.", "client": "İstemci", "proxyProtocol": "Proxy Protokol Ayarları", "proxyProtocolDescription": "TCP hizmetleri için istemci IP adreslerini korumak amacıyla Proxy Protokolünü yapılandırın.", diff --git a/messages/zh-CN.json b/messages/zh-CN.json index e25c82e52b..d4f124f96e 100644 --- a/messages/zh-CN.json +++ b/messages/zh-CN.json @@ -17,7 +17,7 @@ "componentsErrorNoMemberCreate": "您目前不是任何组织的成员。创建组织以开始操作。", "componentsErrorNoMember": "您目前不是任何组织的成员。", "welcome": "欢迎使用 Pangolin", - "welcomeTo": "欢迎来到", + "welcomeTo": "欢迎使用", "componentsCreateOrg": "创建组织", "componentsMember": "您属于{count, plural, =0 {没有组织} one {一个组织} other {# 个组织}}。", "componentsInvalidKey": "检测到无效或过期的许可证密钥。按照许可证条款操作以继续使用所有功能。", @@ -35,7 +35,7 @@ "trialDaysRemaining": "{count, plural, other {# 天剩余}}", "trialDaysLeftShort": "试用期剩余 {days} 天", "trialGoToBilling": "转到账单页面", - "subscriptionViolationViewBilling": "查看计费", + "subscriptionViolationViewBilling": "查看账单", "componentsLicenseViolation": "许可证超限:该服务器使用了 {usedSites} 个站点,已超过授权的 {maxSites} 个。请遵守许可证条款以继续使用全部功能。", "componentsSupporterMessage": "感谢您的支持!您现在是 Pangolin 的 {tier} 用户。", "inviteErrorNotValid": "很抱歉,但看起来你试图访问的邀请尚未被接受或不再有效。", @@ -58,21 +58,27 @@ "name": "名称", "online": "在线", "offline": "离线的", - "site": "站点", + "site": "节点", "dataIn": "数据输入", "dataOut": "数据输出", "connectionType": "连接类型", "tunnelType": "隧道类型", "local": "本地的", "edit": "编辑", - "siteConfirmDelete": "确认删除站点", - "siteDelete": "删除站点", - "siteMessageRemove": "一旦移除,站点将无法访问。与站点相关的所有目标也将被移除。", - "siteQuestionRemove": "您确定要从组织中删除该站点吗?", + "siteConfirmDelete": "确认删除节点", + "siteConfirmDeleteAndResources": "确认删除站点及资源", + "siteDelete": "删除节点", + "siteDeleteAndResources": "删除站点及资源", + "siteMessageRemove": "一旦移除,节点将无法访问。与节点相关的所有目标也将被移除。", + "siteMessageRemoveAndResources": "这将永久删除与该站点关联的所有公共和私人资源,即使资源也与其他站点相关联。", + "siteQuestionRemove": "您确定要从组织中删除该节点吗?", + "siteQuestionRemoveAndResources": "您确定要删除此站点及所有关联资源吗?", + "sitesTableDeleteSite": "删除站点", + "sitesTableDeleteSiteAndResources": "删除站点及资源", "siteManageSites": "管理站点", "siteDescription": "创建和管理站点,启用与私人网络的连接", "sitesBannerTitle": "连接任何网络", - "sitesBannerDescription": "站点是连接到远程网络的链接,允许Pangolin为用户提供资源访问,无论是公共还是私人。可以在任何可以运行二进制文件或容器的地方安装站点网络连接器(Newt)以建立连接。", + "sitesBannerDescription": "站点是到远程网络的连接,使 Pangolin 能够向任何位置的用户提提供公共或私有的资源访问。你可以在任何能够运行二进制文件或容器的地方安装站点网络连接器(Newt),以建立连接。", "sitesBannerButtonText": "安装站点", "approvalsBannerTitle": "批准或拒绝设备访问", "approvalsBannerDescription": "审核、批准或拒绝用户的设备访问请求。 当需要设备批准时,用户必须先获得管理员批准,然后他们的设备才能连接到您的组织资源。", @@ -134,7 +140,7 @@ "siteResourcesHowToAccess": "如何访问", "siteResourcesTargetsOnSite": "此站点上的目标", "siteSetting": "{siteName} 设置", - "siteNewtTunnel": "新站点 (推荐)", + "siteNewtTunnel": "新节点 (推荐)", "siteNewtTunnelDescription": "最简单的方式来创建任何网络的入口。没有额外的设置。", "siteWg": "基本 WireGuard", "siteWgDescription": "使用任何 WireGuard 客户端来建立隧道。需要手动配置 NAT。", @@ -143,23 +149,23 @@ "siteLocalDescriptionSaas": "仅本地资源。没有隧道。仅在远程节点上可用。", "siteSeeAll": "查看所有站点", "siteTunnelDescription": "确定如何连接到站点", - "siteNewtCredentials": "全权证书", - "siteNewtCredentialsDescription": "站点如何通过服务器进行身份验证", + "siteNewtCredentials": "凭证", + "siteNewtCredentialsDescription": "节点如何与服务器进行身份验证", "remoteNodeCredentialsDescription": "这是远程节点如何与服务器进行身份验证", "siteCredentialsSave": "保存证书", "siteCredentialsSaveDescription": "您只能看到一次。请确保将其复制并保存到一个安全的地方。", "siteInfo": "站点信息", "status": "状态", - "shareTitle": "管理可共享链接", + "shareTitle": "管理共享链接", "shareDescription": "创建可共享的链接,允许临时或永久访问代理资源", - "shareSearch": "搜索可共享链接……", - "shareCreate": "创建可共享链接", + "shareSearch": "搜索共享链接……", + "shareCreate": "创建共享链接", "shareErrorDelete": "删除链接失败", "shareErrorDeleteMessage": "删除链接时出错", "shareDeleted": "链接已删除", "shareDeletedDescription": "链接已删除", - "shareDelete": "删除可共享链接", - "shareDeleteConfirm": "确认删除可共享链接", + "shareDelete": "删除共享链接", + "shareDeleteConfirm": "确认删除共享链接", "shareQuestionRemove": "您确定要删除这个共享链接吗?", "shareMessageRemove": "删除后,该链接将不再可用,使用它的任何人将失去对资源的访问权限。", "shareTokenDescription": "访问令牌可以通过两种方式传递:作为查询参数或请求标题。 每次验证访问请求都必须从客户端传递。", @@ -204,11 +210,11 @@ "proxyResourceTitle": "管理公共资源", "proxyResourceDescription": "创建和管理可通过 Web 浏览器公开访问的资源", "publicResourcesBannerTitle": "基于 Web 的公共访问", - "publicResourcesBannerDescription": "公共资源是 HTTPS 代理,可以通过网络浏览器在互联网上的任何人访问。与私人资源不同,它们不需要客户端软件,并且可以包含身份和上下文感知的访问策略。", + "publicResourcesBannerDescription": "公共资源是 HTTPS 代理,可供互联网上的任何人通过 Web 浏览器访问。与私人资源不同,它们不需要客户端软件,并且可以包含身份和上下文感知的访问策略。", "clientResourceTitle": "管理私有资源", "clientResourceDescription": "创建和管理只能通过连接客户端访问的资源", - "privateResourcesBannerTitle": "零信任的私人访问", - "privateResourcesBannerDescription": "私人资源使用零信任安全性,确保只允许明确授予的用户和机器访问资源。可以连接用户设备或机器客户端,通过安全的虚拟专用网络访问这些资源。", + "privateResourcesBannerTitle": "零信任私有访问", + "privateResourcesBannerDescription": "私有资源采用零信任安全机制,确保只有获得明确授权的用户和机器才能访问。用户设备或机器客户端连接后,即可通过安全的虚拟专用网络访问这些资源。", "resourcesSearch": "搜索资源...", "resourceAdd": "添加资源", "resourceErrorDelte": "删除资源时出错", @@ -327,7 +333,7 @@ "passToAuth": "传递至认证", "orgSettingsDescription": "配置组织设置", "orgGeneralSettings": "组织设置", - "orgGeneralSettingsDescription": "管理机构的详细信息和配置", + "orgGeneralSettingsDescription": "管理组织的详细信息和配置", "saveGeneralSettings": "保存常规设置", "saveSettings": "保存设置", "orgDangerZone": "危险区域", @@ -381,7 +387,7 @@ "accessApprovalsDescription": "查看和管理待审批的组织访问权限", "description": "描述", "inviteTitle": "打开邀请", - "inviteDescription": "管理其他用户加入机构的邀请", + "inviteDescription": "管理其他用户加入组织的邀请", "inviteSearch": "搜索邀请...", "minutes": "分钟", "hours": "小时", @@ -425,24 +431,24 @@ "apiKeysDelete": "删除 API 密钥", "apiKeysManage": "管理 API 密钥", "apiKeysDescription": "API 密钥用于认证集成 API", - "provisioningKeysTitle": "置备密钥", - "provisioningKeysManage": "管理置备键", + "provisioningKeysTitle": "预配密钥", + "provisioningKeysManage": "管理预配密钥", "provisioningKeysDescription": "置备密钥用于验证您组织的自动站点配置。", - "provisioningManage": "置备中", - "provisioningDescription": "管理预配键和审查等待批准的站点。", - "pendingSites": "待定站点", + "provisioningManage": "预配", + "provisioningDescription": "管理预配密钥,并审核待批准的站点。", + "pendingSites": "待审批站点", "siteApproveSuccess": "站点批准成功", "siteApproveError": "批准站点出错", "provisioningKeys": "置备键", "searchProvisioningKeys": "搜索配备密钥...", - "provisioningKeysAdd": "生成置备键", + "provisioningKeysAdd": "生成预配密钥", "provisioningKeysErrorDelete": "删除预配键时出错", "provisioningKeysErrorDeleteMessage": "删除预配键时出错", "provisioningKeysQuestionRemove": "您确定要从组织中删除此预配键吗?", "provisioningKeysMessageRemove": "一旦移除,密钥不能再用于站点预配。", "provisioningKeysDeleteConfirm": "确认删除置备键", "provisioningKeysDelete": "删除置备键", - "provisioningKeysCreate": "生成置备键", + "provisioningKeysCreate": "生成预配密钥", "provisioningKeysCreateDescription": "为组织生成一个新的预置密钥", "provisioningKeysSeeAll": "查看所有预配键", "provisioningKeysSave": "保存预配键", @@ -462,16 +468,16 @@ "provisioningKeysNeverUsed": "永不过期", "provisioningKeysEdit": "编辑置备键", "provisioningKeysEditDescription": "更新此密钥的最大批量大小和过期时间。", - "provisioningKeysApproveNewSites": "批准新站点", - "provisioningKeysApproveNewSitesDescription": "自动批准使用此密钥注册的站点。", + "provisioningKeysApproveNewSites": "批准新节点", + "provisioningKeysApproveNewSitesDescription": "自动批准使用此密钥注册的节点。", "provisioningKeysUpdateError": "更新预配键时出错", "provisioningKeysUpdated": "置备密钥已更新", "provisioningKeysUpdatedDescription": "您的更改已保存。", - "provisioningKeysBannerTitle": "站点置备密钥", - "provisioningKeysBannerDescription": "生成一个供应密钥,并将其与 Newt 连接器一起使用,以在首次启动时自动创建站点 - 无需为每个站点设置单独的凭据。", + "provisioningKeysBannerTitle": "站点预配密钥", + "provisioningKeysBannerDescription": "生成预配密钥,并将其与 Newt 连接器配合使用,即可在首次启动时自动创建站点,无需为每个站点单独配置凭据。", "provisioningKeysBannerButtonText": "了解更多", - "pendingSitesBannerTitle": "待定站点", - "pendingSitesBannerDescription": "使用供应密钥连接的站点将在此显示以供审核。", + "pendingSitesBannerTitle": "待审批站点", + "pendingSitesBannerDescription": "使用预配密钥连接的网站会在这里以供审核。", "pendingSitesBannerButtonText": "了解更多", "apiKeysSettings": "{apiKeyName} 设置", "userTitle": "管理所有用户", @@ -883,11 +889,11 @@ "resourcesErrorUpdateDescription": "更新资源时出错", "access": "访问权限", "accessControl": "访问控制", - "shareLink": "{resource} 可共享链接", + "shareLink": "{resource} 的共享链接", "resourceSelect": "选择资源", - "shareLinks": "可共享链接", + "shareLinks": "共享链接", "share": "分享链接", - "shareDescription2": "创建资源的可共享链接。链接提供了对您资源的临时或无限制访问。 当您创建链接时,您可以配置链接的到期时间。", + "shareDescription2": "创建资源的共享链接。链接提供了对您资源的临时或无限制访问。 当您创建链接时,您可以配置链接的到期时间。", "shareEasyCreate": "轻松创建和分享", "shareConfigurableExpirationDuration": "可配置的过期时间", "shareSecureAndRevocable": "安全和可撤销的", @@ -1059,7 +1065,7 @@ "network": "网络", "manage": "管理", "sitesNotFound": "未找到站点。", - "pangolinServerAdmin": "服务器管理员 - Pangolin", + "pangolinServerAdmin": "服务器管理 - Pangolin", "licenseTierProfessional": "专业许可证", "licenseTierEnterprise": "企业许可证", "licenseTierPersonal": "个人许可证", @@ -1366,7 +1372,7 @@ "supportKeyBuy": "购买支持者密钥", "logoutError": "注销错误", "signingAs": "登录为", - "serverAdmin": "服务器管理员", + "serverAdmin": "服务器管理", "managedSelfhosted": "托管自托管", "otpEnable": "启用双因子认证", "otpDisable": "禁用双因子认证", @@ -1536,8 +1542,8 @@ "sidebarSites": "站点", "sidebarApprovals": "审批请求", "sidebarResources": "资源", - "sidebarProxyResources": "公开的", - "sidebarClientResources": "非公开的", + "sidebarProxyResources": "公开资源", + "sidebarClientResources": "私有资源", "sidebarPolicies": "共享策略", "sidebarResourcePolicies": "公共资源", "sidebarAccessControl": "访问控制", @@ -1547,17 +1553,17 @@ "sidebarAdmin": "管理员", "sidebarInvitations": "邀请", "sidebarRoles": "角色", - "sidebarShareableLinks": "可共享链接", + "sidebarShareableLinks": "共享链接", "sidebarApiKeys": "API密钥", - "sidebarProvisioning": "置备中", + "sidebarProvisioning": "预配", "sidebarSettings": "设置", "sidebarAllUsers": "所有用户", "sidebarIdentityProviders": "身份提供商", "sidebarLicense": "证书", "sidebarClients": "客户端", "sidebarUserDevices": "用户设备", - "sidebarMachineClients": "机", - "sidebarDomains": "域", + "sidebarMachineClients": "机器身份", + "sidebarDomains": "域名", "sidebarGeneral": "管理", "sidebarLogAndAnalytics": "日志与分析", "sidebarBluePrints": "蓝图", @@ -1689,8 +1695,8 @@ "alertingTabHealthChecks": "健康检查", "alertingRulesBannerTitle": "获取通知", "alertingRulesBannerDescription": "每条规则都连接要监视的对象(站点、健康检查或资源),触发时间(例如离线或不健康),以及如何通过电子邮件、Webhooks 或集成将通知发送给团队。使用此列表创建、启用和管理这些规则。", - "alertingHealthChecksBannerTitle": "监视健康和资源", - "alertingHealthChecksBannerDescription": "健康检查是您一次定义的 HTTP 或 TCP 监控。然后可以将它们用作告警规则中的来源,以便目标变得正常或不正常时得到通知。资源上的健康检查也会出现在此处。", + "alertingHealthChecksBannerTitle": "资源与健康监控", + "alertingHealthChecksBannerDescription": "通过 HTTP 或 TCP 检查目标状态,并在服务异常或恢复时发送通知。资源中配置的健康检查也会显示在这里。", "standaloneHcTableTitle": "健康检查", "standaloneHcSearchPlaceholder": "搜索健康检查…", "standaloneHcAddButton": "创建健康检查", @@ -1791,17 +1797,17 @@ "theme": "主题", "subnetRequired": "子网是必填项", "initialSetupTitle": "初始服务器设置", - "initialSetupDescription": "创建初始服务器管理员帐户。 只能存在一个服务器管理员。 您可以随时更改这些凭据。", + "initialSetupDescription": "创建初始的管理员帐户。 只能存在一个服务器管理员。 您可以随时更改这些凭据。", "createAdminAccount": "创建管理员帐户", - "setupErrorCreateAdmin": "创建服务器管理员账户时发生错误。", + "setupErrorCreateAdmin": "创建管理员账户时发生错误。", "certificateStatus": "证书", "certificateStatusAutoRefreshHint": "状态自动刷新。", "loading": "加载中", "loadingEllipsis": "加载中……", "loadingAnalytics": "加载分析", "restart": "重启", - "domains": "域", - "domainsDescription": "创建和管理组织中可用的域", + "domains": "域名", + "domainsDescription": "创建和管理组织中可用的域名", "domainsSearch": "搜索域...", "domainAdd": "添加域", "domainAddDescription": "注册一个新域名到组织", @@ -2165,12 +2171,12 @@ "sshSudoMode": "Sudo 访问", "sshSudoModeNone": "无", "sshSudoModeNoneDescription": "用户不能用sudo运行命令。", - "sshSudoModeFull": "全苏多", + "sshSudoModeFull": "完整 Sudo 权限", "sshSudoModeFullDescription": "用户可以用 sudo 运行任何命令。", "sshSudoModeCommands": "命令", "sshSudoModeCommandsDescription": "用户只能用 sudo 运行指定的命令。", "sshSudo": "允许Sudo", - "sshSudoCommands": "Sudo 命令", + "sshSudoCommands": "可用 Sudo 命令", "sshSudoCommandsDescription": "用户可以使用 sudo 运行的命令列表,以逗号、空格或新行分隔。必须使用绝对路径。", "sshCreateHomeDir": "创建主目录", "sshUnixGroups": "Unix 组", @@ -2183,7 +2189,7 @@ "roleTextImportAppend": "附加到现有", "roleTextImportMode": "导入模式", "roleTextImportPreview": "预览", - "roleTextImportItemCount": "{count, plural, =0 {No items to import} one {1 item to import} other {# items to import}}", + "roleTextImportItemCount": "{count, plural, =0 {没有可导入的项目} one {1 个可导入项目} other {# 个可导入项目}}", "roleTextImportTotalCount": "{existing} 个现有 + {imported} 个导入 = {total} 个总计", "roleTextImportConfirm": "导入", "roleTextImportInvalidFile": "不支持的文件类型", @@ -2235,8 +2241,8 @@ "resourceEditDomain": "编辑域名", "siteName": "站点名称", "proxyPort": "端口", - "resourcesTableProxyResources": "公开的", - "resourcesTableClientResources": "非公开的", + "resourcesTableProxyResources": "", + "resourcesTableClientResources": "私有资源", "resourcesTableNoProxyResourcesFound": "未找到代理资源。", "resourcesTableNoInternalResourcesFound": "未找到内部资源。", "resourcesTableDestination": "目标", @@ -2338,6 +2344,7 @@ "createInternalResourceDialogDestinationCidrDescription": "站点网络上资源的 CIDR 范围。", "createInternalResourceDialogAlias": "Alias", "createInternalResourceDialogAliasDescription": "此资源可选的内部DNS别名。", + "internalResourceAliasLocalWarning": "以 .local 结尾的别名可能会因某些网络上的 mDNS 而导致解析问题。", "internalResourceDownstreamSchemeRequired": "HTTP 资源需要方案", "internalResourceHttpPortRequired": "HTTP 资源需要目的端口", "siteConfiguration": "配置", @@ -2925,7 +2932,7 @@ "logRetentionRequestDescription": "保留请求日志的时间", "logRetentionAccessLabel": "访问日志保留", "logRetentionAccessDescription": "保留访问日志的时间", - "logRetentionActionLabel": "动作日志保留", + "logRetentionActionLabel": "审计日志保留", "logRetentionActionDescription": "保留操作日志的时间", "logRetentionConnectionLabel": "连接日志保留", "logRetentionConnectionDescription": "保留连接日志的时间", @@ -2938,11 +2945,11 @@ "logRetentionForever": "永远的", "logRetentionEndOfFollowingYear": "下一年结束", "actionLogsDescription": "查看此机构执行的操作历史", - "accessLogsDescription": "查看此机构资源的访问认证请求", + "accessLogsDescription": "查看此组织资源的访问认证请求", "connectionLogs": "连接日志", "connectionLogsDescription": "查看此机构隧道的连接日志", "sidebarLogsConnection": "连接日志", - "sidebarLogsStreaming": "流流", + "sidebarLogsStreaming": "事件流", "sourceAddress": "源地址", "destinationAddress": "目的地址", "duration": "期限", @@ -2967,6 +2974,7 @@ "orgOrDomainIdMissing": "缺少机构或域 ID", "loadingDNSRecords": "正在载入DNS记录...", "olmUpdateAvailableInfo": "有最新版本的 Olm 可用。请更新到最新版本以获取最佳体验。", + "updateAvailableInfo": "有新版本可用。请更新到最新版本以获得最佳体验。", "client": "客户端:", "proxyProtocol": "代理协议设置", "proxyProtocolDescription": "配置代理协议以保留TCP服务的客户端 IP 地址。", diff --git a/server/auth/sessions/app.ts b/server/auth/sessions/app.ts index f6cae441b7..19875fc687 100644 --- a/server/auth/sessions/app.ts +++ b/server/auth/sessions/app.ts @@ -12,7 +12,7 @@ import { users } from "@server/db"; import { db } from "@server/db"; -import { eq, inArray } from "drizzle-orm"; +import { and, eq, inArray, ne } from "drizzle-orm"; import config from "@server/lib/config"; import type { RandomReader } from "@oslojs/crypto/random"; import { generateRandomString } from "@oslojs/crypto/random"; @@ -136,6 +136,45 @@ export async function invalidateAllSessions(userId: string): Promise { } } +export async function invalidateAllSessionsExceptCurrent( + userId: string, + currentSessionId: string +): Promise { + 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( token: string, isSecure: boolean, diff --git a/server/db/countries.ts b/server/db/countries.ts index 749f1183f3..c668ca2aeb 100644 --- a/server/db/countries.ts +++ b/server/db/countries.ts @@ -795,10 +795,13 @@ export const COUNTRIES = [ name: "Serbia", code: "RS" }, - { - name: "Serbia and Montenegro", - code: "CS" - }, + // 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 + // more details: https://en.wikipedia.org/wiki/ISO_3166-2:CS + // { + // name: "Serbia and Montenegro", + // code: "CS" + // }, { name: "Seychelles", code: "SC" diff --git a/server/db/pg/schema/privateSchema.ts b/server/db/pg/schema/privateSchema.ts index 229fc9ff08..ae73b97ac3 100644 --- a/server/db/pg/schema/privateSchema.ts +++ b/server/db/pg/schema/privateSchema.ts @@ -11,7 +11,7 @@ import { primaryKey, uniqueIndex } from "drizzle-orm/pg-core"; -import { InferSelectModel } from "drizzle-orm"; +import { InferSelectModel, sql } from "drizzle-orm"; import { domains, orgs, @@ -207,17 +207,28 @@ export const remoteExitNodeSessions = pgTable("remoteExitNodeSession", { expiresAt: bigint("expiresAt", { mode: "number" }).notNull() }); -export const loginPage = pgTable("loginPage", { - loginPageId: serial("loginPageId").primaryKey(), - subdomain: varchar("subdomain"), - fullDomain: varchar("fullDomain"), - exitNodeId: integer("exitNodeId").references(() => exitNodes.exitNodeId, { - onDelete: "set null" - }), - domainId: varchar("domainId").references(() => domains.domainId, { - onDelete: "set null" - }) -}); +export const loginPage = pgTable( + "loginPage", + { + loginPageId: serial("loginPageId").primaryKey(), + subdomain: varchar("subdomain"), + fullDomain: varchar("fullDomain"), + exitNodeId: integer("exitNodeId").references( + () => exitNodes.exitNodeId, + { + 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", { loginPageId: integer("loginPageId") diff --git a/server/db/pg/schema/schema.ts b/server/db/pg/schema/schema.ts index 025bdf9239..1b48aa5203 100644 --- a/server/db/pg/schema/schema.ts +++ b/server/db/pg/schema/schema.ts @@ -1,5 +1,5 @@ import { randomUUID } from "crypto"; -import { InferSelectModel } from "drizzle-orm"; +import { InferSelectModel, sql } from "drizzle-orm"; import { bigint, boolean, @@ -82,107 +82,130 @@ export const orgDomains = pgTable("orgDomains", { .references(() => domains.domainId, { onDelete: "cascade" }) }); -export const sites = pgTable("sites", { - siteId: serial("siteId").primaryKey(), - orgId: varchar("orgId") - .references(() => orgs.orgId, { - onDelete: "cascade" - }) - .notNull(), - niceId: varchar("niceId").notNull(), - exitNodeId: integer("exitNode").references(() => exitNodes.exitNodeId, { - onDelete: "set null" - }), - name: varchar("name").notNull(), - pubKey: varchar("pubKey"), - subnet: varchar("subnet"), - megabytesIn: real("bytesIn").default(0), - megabytesOut: real("bytesOut").default(0), - lastBandwidthUpdate: varchar("lastBandwidthUpdate"), - type: varchar("type").notNull(), // "newt" or "wireguard" - online: boolean("online").notNull().default(false), - lastPing: integer("lastPing"), - address: varchar("address"), - endpoint: varchar("endpoint"), - publicKey: varchar("publicKey"), - lastHolePunch: bigint("lastHolePunch", { mode: "number" }), - listenPort: integer("listenPort"), - dockerSocketEnabled: boolean("dockerSocketEnabled").notNull().default(true), - autoUpdateEnabled: boolean("autoUpdateEnabled").notNull().default(false), - autoUpdateOverrideOrg: boolean("autoUpdateOverrideOrg") - .notNull() - .default(false), - status: varchar("status") - .$type<"pending" | "approved">() - .default("approved") -}); +export const sites = pgTable( + "sites", + { + siteId: serial("siteId").primaryKey(), + orgId: varchar("orgId") + .references(() => orgs.orgId, { + onDelete: "cascade" + }) + .notNull(), + niceId: varchar("niceId").notNull(), + exitNodeId: integer("exitNode").references(() => exitNodes.exitNodeId, { + onDelete: "set null" + }), + name: varchar("name").notNull(), + pubKey: varchar("pubKey"), + subnet: varchar("subnet"), + megabytesIn: real("bytesIn").default(0), + megabytesOut: real("bytesOut").default(0), + lastBandwidthUpdate: varchar("lastBandwidthUpdate"), + type: varchar("type").notNull(), // "newt" or "wireguard" + online: boolean("online").notNull().default(false), + lastPing: integer("lastPing"), + address: varchar("address"), + endpoint: varchar("endpoint"), + publicKey: varchar("publicKey"), + lastHolePunch: bigint("lastHolePunch", { mode: "number" }), + listenPort: integer("listenPort"), + dockerSocketEnabled: boolean("dockerSocketEnabled") + .notNull() + .default(true), + autoUpdateEnabled: boolean("autoUpdateEnabled") + .notNull() + .default(false), + 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("resources", { - resourceId: serial("resourceId").primaryKey(), - resourcePolicyId: integer("resourcePolicyId").references( - () => resourcePolicies.resourcePolicyId, - { onDelete: "set null" } - ), - defaultResourcePolicyId: integer("defaultResourcePolicyId").references( - () => resourcePolicies.resourcePolicyId, - { - onDelete: "restrict" - } - ), - resourceGuid: varchar("resourceGuid", { length: 36 }) - .unique() - .notNull() - .$defaultFn(() => randomUUID()), - orgId: varchar("orgId") - .references(() => orgs.orgId, { - onDelete: "cascade" - }) - .notNull(), - niceId: text("niceId").notNull(), - name: varchar("name").notNull(), - subdomain: varchar("subdomain"), - fullDomain: varchar("fullDomain"), - domainId: varchar("domainId").references(() => domains.domainId, { - onDelete: "set null" - }), - ssl: boolean("ssl").notNull().default(false), - blockAccess: boolean("blockAccess").notNull().default(false), - proxyPort: integer("proxyPort"), - sso: boolean("sso"), - emailWhitelistEnabled: boolean("emailWhitelistEnabled"), - applyRules: boolean("applyRules"), - enabled: boolean("enabled").notNull().default(true), - stickySession: boolean("stickySession").notNull().default(false), - tlsServerName: varchar("tlsServerName"), - setHostHeader: varchar("setHostHeader"), - enableProxy: boolean("enableProxy").default(true), - 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), - proxyProtocolVersion: integer("proxyProtocolVersion").default(1), - maintenanceModeEnabled: boolean("maintenanceModeEnabled") - .notNull() - .default(false), - maintenanceModeType: text("maintenanceModeType", { - enum: ["forced", "automatic"] - }).default("forced"), // "forced" = always show, "automatic" = only when down - maintenanceTitle: text("maintenanceTitle"), - maintenanceMessage: text("maintenanceMessage"), - maintenanceEstimatedTime: text("maintenanceEstimatedTime"), - postAuthPath: text("postAuthPath"), - health: varchar("health").default("unknown"), // "healthy", "unhealthy", "unknown" - wildcard: boolean("wildcard").notNull().default(false), - mode: text("mode").default("http").notNull(), // rdp, ssh, http, vnc - pamMode: varchar("pamMode", { length: 32 }) - .$type<"passthrough" | "push">() - .default("passthrough"), - authDaemonMode: varchar("authDaemonMode", { length: 32 }) - .$type<"site" | "remote" | "native">() - .default("site"), - authDaemonPort: integer("authDaemonPort").default(22123) -}); +export const resources = pgTable( + "resources", + { + resourceId: serial("resourceId").primaryKey(), + resourcePolicyId: integer("resourcePolicyId").references( + () => resourcePolicies.resourcePolicyId, + { onDelete: "set null" } + ), + defaultResourcePolicyId: integer("defaultResourcePolicyId").references( + () => resourcePolicies.resourcePolicyId, + { + onDelete: "restrict" + } + ), + resourceGuid: varchar("resourceGuid", { length: 36 }) + .unique() + .notNull() + .$defaultFn(() => randomUUID()), + orgId: varchar("orgId") + .references(() => orgs.orgId, { + onDelete: "cascade" + }) + .notNull(), + niceId: text("niceId").notNull(), + name: varchar("name").notNull(), + subdomain: varchar("subdomain"), + fullDomain: varchar("fullDomain"), + domainId: varchar("domainId").references(() => domains.domainId, { + onDelete: "set null" + }), + ssl: boolean("ssl").notNull().default(false), + blockAccess: boolean("blockAccess").notNull().default(false), + proxyPort: integer("proxyPort"), + sso: boolean("sso"), + emailWhitelistEnabled: boolean("emailWhitelistEnabled"), + applyRules: boolean("applyRules"), + enabled: boolean("enabled").notNull().default(true), + stickySession: boolean("stickySession").notNull().default(false), + tlsServerName: varchar("tlsServerName"), + setHostHeader: varchar("setHostHeader"), + enableProxy: boolean("enableProxy").default(true), + 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), + proxyProtocolVersion: integer("proxyProtocolVersion").default(1), + maintenanceModeEnabled: boolean("maintenanceModeEnabled") + .notNull() + .default(false), + maintenanceModeType: text("maintenanceModeType", { + enum: ["forced", "automatic"] + }).default("forced"), // "forced" = always show, "automatic" = only when down + maintenanceTitle: text("maintenanceTitle"), + maintenanceMessage: text("maintenanceMessage"), + maintenanceEstimatedTime: text("maintenanceEstimatedTime"), + postAuthPath: text("postAuthPath"), + health: varchar("health").default("unknown"), // "healthy", "unhealthy", "unknown" + wildcard: boolean("wildcard").notNull().default(false), + mode: text("mode").default("http").notNull(), // rdp, ssh, http, vnc + pamMode: varchar("pamMode", { length: 32 }) + .$type<"passthrough" | "push">() + .default("passthrough"), + authDaemonMode: varchar("authDaemonMode", { length: 32 }) + .$type<"site" | "remote" | "native">() + .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", { labelId: serial("labelId").primaryKey(), @@ -267,71 +290,84 @@ export const clientLabels = pgTable( (t) => [unique("client_label_uniq").on(t.clientId, t.labelId)] ); -export const targets = pgTable("targets", { - targetId: serial("targetId").primaryKey(), - 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") -}); +export const targets = pgTable( + "targets", + { + targetId: serial("targetId").primaryKey(), + 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" - }), - orgId: varchar("orgId") - .references(() => orgs.orgId, { - onDelete: "cascade" - }) - .notNull(), - siteId: integer("siteId") - .references(() => sites.siteId, { +export const targetHealthCheck = pgTable( + "targetHealthCheck", + { + targetHealthCheckId: serial("targetHealthCheckId").primaryKey(), + targetId: integer("targetId").references(() => targets.targetId, { 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) -}); + }), + orgId: varchar("orgId") + .references(() => orgs.orgId, { + onDelete: "cascade" + }) + .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) + }, + (t) => [index("idx_targethealthcheck_targetid").on(t.targetId)] +); export const exitNodes = pgTable("exitNodes", { exitNodeId: serial("exitNodeId").primaryKey(), @@ -406,43 +442,74 @@ export const networks = pgTable("networks", { .notNull() }); -export const siteNetworks = pgTable("siteNetworks", { - siteId: integer("siteId") - .notNull() - .references(() => sites.siteId, { - onDelete: "cascade" - }), - networkId: integer("networkId") - .notNull() - .references(() => networks.networkId, { onDelete: "cascade" }) -}); +export const siteNetworks = pgTable( + "siteNetworks", + { + siteId: integer("siteId") + .notNull() + .references(() => sites.siteId, { + onDelete: "cascade" + }), + networkId: integer("networkId") + .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("clientSiteResources", { - clientId: integer("clientId") - .notNull() - .references(() => clients.clientId, { onDelete: "cascade" }), - siteResourceId: integer("siteResourceId") - .notNull() - .references(() => siteResources.siteResourceId, { onDelete: "cascade" }) -}); +export const clientSiteResources = pgTable( + "clientSiteResources", + { + clientId: integer("clientId") + .notNull() + .references(() => clients.clientId, { onDelete: "cascade" }), + siteResourceId: integer("siteResourceId") + .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("roleSiteResources", { - roleId: integer("roleId") - .notNull() - .references(() => roles.roleId, { onDelete: "cascade" }), - siteResourceId: integer("siteResourceId") - .notNull() - .references(() => siteResources.siteResourceId, { onDelete: "cascade" }) -}); +export const roleSiteResources = pgTable( + "roleSiteResources", + { + roleId: integer("roleId") + .notNull() + .references(() => roles.roleId, { onDelete: "cascade" }), + siteResourceId: integer("siteResourceId") + .notNull() + .references(() => siteResources.siteResourceId, { + onDelete: "cascade" + }) + }, + (t) => [index("idx_rolesiteresources_siteresourceid").on(t.siteResourceId)] +); -export const userSiteResources = pgTable("userSiteResources", { - userId: varchar("userId") - .notNull() - .references(() => users.userId, { onDelete: "cascade" }), - siteResourceId: integer("siteResourceId") - .notNull() - .references(() => siteResources.siteResourceId, { onDelete: "cascade" }) -}); +export const userSiteResources = pgTable( + "userSiteResources", + { + userId: varchar("userId") + .notNull() + .references(() => users.userId, { onDelete: "cascade" }), + siteResourceId: integer("siteResourceId") + .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", { userId: varchar("id").primaryKey(), @@ -467,15 +534,19 @@ export const users = pgTable("user", { locale: varchar("locale") }); -export const newts = pgTable("newt", { - newtId: varchar("id").primaryKey(), - secretHash: varchar("secretHash").notNull(), - dateCreated: varchar("dateCreated").notNull(), - version: varchar("version"), - siteId: integer("siteId").references(() => sites.siteId, { - onDelete: "cascade" - }) -}); +export const newts = pgTable( + "newt", + { + newtId: varchar("id").primaryKey(), + secretHash: varchar("secretHash").notNull(), + dateCreated: varchar("dateCreated").notNull(), + version: varchar("version"), + siteId: integer("siteId").references(() => sites.siteId, { + onDelete: "cascade" + }) + }, + (t) => [index("idx_newt_siteid").on(t.siteId)] +); export const twoFactorBackupCodes = pgTable("twoFactorBackupCodes", { codeId: serial("id").primaryKey(), @@ -576,29 +647,49 @@ export const userOrgRoles = pgTable( (t) => [unique().on(t.userId, t.orgId, t.roleId)] ); -export const roleActions = pgTable("roleActions", { - roleId: integer("roleId") - .notNull() - .references(() => roles.roleId, { onDelete: "cascade" }), - actionId: varchar("actionId") - .notNull() - .references(() => actions.actionId, { onDelete: "cascade" }), - orgId: varchar("orgId") - .notNull() - .references(() => orgs.orgId, { onDelete: "cascade" }) -}); +export const roleActions = pgTable( + "roleActions", + { + roleId: integer("roleId") + .notNull() + .references(() => roles.roleId, { onDelete: "cascade" }), + actionId: varchar("actionId") + .notNull() + .references(() => actions.actionId, { onDelete: "cascade" }), + orgId: varchar("orgId") + .notNull() + .references(() => orgs.orgId, { onDelete: "cascade" }) + }, + (t) => [ + index("idx_roleActions_roleId_orgId_actionId").on( + t.roleId, + t.orgId, + t.actionId + ) + ] +); -export const userActions = pgTable("userActions", { - userId: varchar("userId") - .notNull() - .references(() => users.userId, { onDelete: "cascade" }), - actionId: varchar("actionId") - .notNull() - .references(() => actions.actionId, { onDelete: "cascade" }), - orgId: varchar("orgId") - .notNull() - .references(() => orgs.orgId, { onDelete: "cascade" }) -}); +export const userActions = pgTable( + "userActions", + { + userId: varchar("userId") + .notNull() + .references(() => users.userId, { onDelete: "cascade" }), + actionId: varchar("actionId") + .notNull() + .references(() => actions.actionId, { onDelete: "cascade" }), + orgId: varchar("orgId") + .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", { roleId: integer("roleId") @@ -1004,40 +1095,44 @@ export const idpOrg = pgTable("idpOrg", { orgMapping: varchar("orgMapping") }); -export const clients = pgTable("clients", { - clientId: serial("clientId").primaryKey(), - orgId: varchar("orgId") - .references(() => orgs.orgId, { +export const clients = pgTable( + "clients", + { + clientId: serial("clientId").primaryKey(), + 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" - }) - .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" - }), - niceId: varchar("niceId").notNull(), - olmId: text("olmId"), // to lock it to a specific olm optionally - name: varchar("name").notNull(), - pubKey: varchar("pubKey"), - subnet: varchar("subnet").notNull(), - megabytesIn: real("bytesIn"), - megabytesOut: real("bytesOut"), - lastBandwidthUpdate: varchar("lastBandwidthUpdate"), - lastPing: integer("lastPing"), - type: varchar("type").notNull(), // "olm" - online: boolean("online").notNull().default(false), - // endpoint: varchar("endpoint"), - 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" - >() -}); + }), + niceId: varchar("niceId").notNull(), + olmId: text("olmId"), // to lock it to a specific olm optionally + name: varchar("name").notNull(), + pubKey: varchar("pubKey"), + subnet: varchar("subnet").notNull(), + megabytesIn: real("bytesIn"), + megabytesOut: real("bytesOut"), + lastBandwidthUpdate: varchar("lastBandwidthUpdate"), + lastPing: integer("lastPing"), + type: varchar("type").notNull(), // "olm" + online: boolean("online").notNull().default(false), + // endpoint: varchar("endpoint"), + 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" + >() + }, + (t) => [index("idx_clients_userid").on(t.userId)] +); export const clientSitesAssociationsCache = pgTable( "clientSitesAssociationsCache", @@ -1049,7 +1144,11 @@ export const clientSitesAssociationsCache = pgTable( isJitMode: boolean("isJitMode").notNull().default(false), 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 - } + }, + (t) => [ + primaryKey({ columns: [t.clientId, t.siteId] }), + index("idx_clientsitesassociationscache_siteid").on(t.siteId) + ] ); export const clientSiteResourcesAssociationsCache = pgTable( @@ -1058,7 +1157,14 @@ 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 .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", { @@ -1071,23 +1177,27 @@ export const clientPostureSnapshots = pgTable("clientPostureSnapshots", { collectedAt: integer("collectedAt").notNull() }); -export const olms = pgTable("olms", { - olmId: varchar("id").primaryKey(), - secretHash: varchar("secretHash").notNull(), - dateCreated: varchar("dateCreated").notNull(), - version: text("version"), - agent: text("agent"), - name: varchar("name"), - clientId: integer("clientId").references(() => clients.clientId, { - // 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 - onDelete: "cascade" - }), - archived: boolean("archived").notNull().default(false) -}); +export const olms = pgTable( + "olms", + { + olmId: varchar("id").primaryKey(), + secretHash: varchar("secretHash").notNull(), + dateCreated: varchar("dateCreated").notNull(), + version: text("version"), + agent: text("agent"), + name: varchar("name"), + clientId: integer("clientId").references(() => clients.clientId, { + // 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 + onDelete: "cascade" + }), + archived: boolean("archived").notNull().default(false) + }, + (t) => [index("idx_olms_clientid").on(t.clientId)] +); export const currentFingerprint = pgTable("currentFingerprint", { fingerprintId: serial("id").primaryKey(), diff --git a/server/db/sqlite/driver.ts b/server/db/sqlite/driver.ts index 0e50d1289c..a7eee52b70 100644 --- a/server/db/sqlite/driver.ts +++ b/server/db/sqlite/driver.ts @@ -1,6 +1,5 @@ import { drizzle as DrizzleSqlite } from "drizzle-orm/better-sqlite3"; import Database from "better-sqlite3"; -import type BetterSqlite3 from "better-sqlite3"; import * as schema from "./schema/schema"; import path from "path"; import fs from "fs"; @@ -12,68 +11,31 @@ export const exists = checkFileExists(location); 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 = 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() { const sqlite = new Database(location); if (process.env.ENABLE_SQLITE_WAL_MODE == "true") { // Enable WAL mode — allows concurrent readers + single writer, preventing // 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"); // NORMAL sync mode: safe with WAL, reduces write lock hold time. sqlite.pragma("synchronous = NORMAL"); } - // Wait up to 5s on SQLITE_BUSY instead of failing — prevents audit log - // retry loops that accumulate memory. - sqlite.pragma("busy_timeout = 5000"); - - // 64 MB page cache (default 2 MB) — reduces I/O round-trips on large - // TraefikConfigManager JOINs that block the event loop. - sqlite.pragma("cache_size = -65536"); + // No busy_timeout pragma: better-sqlite3 already arms + // sqlite3_busy_timeout(db, 5000) via its default `timeout` option + // (lib/database.js), so an explicit pragma is redundant. - // 256 MB memory-mapped I/O — OS serves reads from page cache directly, - // reducing event-loop blocking. - sqlite.pragma("mmap_size = 268435456"); + // Intentionally NOT setting cache_size or mmap_size: a large page cache plus + // a multi-hundred-MB mmap region inflate RSS and cause page-cache thrashing + // on small (~1 GB) instances. Leave SQLite on its conservative defaults. - // 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)); - }; + // Intentionally NOT wrapping prepare()/statements: better-sqlite3 finalizes + // sqlite3_stmt in the Statement destructor at GC, and drizzle-orm prepares a + // fresh statement per query (no statement cache), so statements cannot + // accumulate. better-sqlite3 11.x exposes no Statement.finalize() at all. return DrizzleSqlite(sqlite, { schema diff --git a/server/index.ts b/server/index.ts index 99fd201568..53b3e9a691 100644 --- a/server/index.ts +++ b/server/index.ts @@ -24,6 +24,7 @@ import license from "#dynamic/license/license"; import { initLogCleanupInterval } from "@server/lib/cleanupLogs"; import { initAcmeCertSync } from "#dynamic/lib/acmeCertSync"; import { fetchServerIp } from "@server/lib/serverIpService"; +import { startRebuildQueueProcessor } from "@server/lib/rebuildClientAssociations"; async function startServers() { await setHostMeta(); @@ -41,6 +42,7 @@ async function startServers() { initLogCleanupInterval(); initAcmeCertSync(); + startRebuildQueueProcessor(); // Start all servers const apiServer = createApiServer(); diff --git a/server/lib/billing/usageService.ts b/server/lib/billing/usageService.ts index 9cb24bbeb5..dd32a09adc 100644 --- a/server/lib/billing/usageService.ts +++ b/server/lib/billing/usageService.ts @@ -12,7 +12,7 @@ import { import { FeatureId, getFeatureMeterId } from "./features"; import logger from "@server/logger"; import { build } from "@server/build"; -import cache from "#dynamic/lib/cache"; +import { regionalCache as cache } from "#dynamic/lib/cache"; export function noop() { if (build !== "saas") { @@ -22,7 +22,6 @@ export function noop() { } export class UsageService { - constructor() { if (noop()) { return; @@ -57,7 +56,10 @@ export class UsageService { try { let usage; if (transaction) { - const orgIdToUse = await this.getBillingOrg(orgId, transaction); + const orgIdToUse = await this.getBillingOrg( + orgId, + transaction + ); usage = await this.internalAddUsage( orgIdToUse, featureId, diff --git a/server/lib/blueprints/applyBlueprint.ts b/server/lib/blueprints/applyBlueprint.ts index f2bb9b0c80..493831131e 100644 --- a/server/lib/blueprints/applyBlueprint.ts +++ b/server/lib/blueprints/applyBlueprint.ts @@ -3,7 +3,6 @@ import { newts, blueprints, Blueprint, - Site, siteResources, roleSiteResources, userSiteResources, @@ -30,8 +29,11 @@ import { updateResourcePolicies } from "./resourcePolicies"; import { BlueprintSource } from "@server/routers/blueprints/types"; import { stringify as stringifyYaml } from "yaml"; import { generateName } from "@server/db/names"; -import { handleMessagingForUpdatedSiteResource } from "@server/routers/siteResource"; -import { rebuildClientAssociationsFromSiteResource } from "../rebuildClientAssociations"; +import { + handleMessagingForUpdatedSiteResource, + rebuildClientAssociationsFromSiteResource, + waitForSiteResourceRebuildIdle +} from "../rebuildClientAssociations"; type ApplyBlueprintArgs = { orgId: string; @@ -48,42 +50,38 @@ export async function applyBlueprint({ name, source = "API" }: ApplyBlueprintArgs): Promise { - // 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 blueprintMessage: string; + let blueprintMessage = ""; let error: any | null = null; try { - let proxyResourcesResults: PublicResourcesResults = []; - let clientResourcesResults: ClientResourcesResults = []; + const validationResult = ConfigSchema.safeParse(configData); + if (!validationResult.success) { + throw new Error(fromError(validationResult.error).toString()); + } + + const config: Config = validationResult.data; + + let publicResourcesResults: PublicResourcesResults = []; + let privateResourcesResults: ClientResourcesResults = []; await db.transaction(async (trx) => { await updateResourcePolicies(orgId, config, trx); - proxyResourcesResults = await updatePublicResources( + publicResourcesResults = await updatePublicResources( orgId, config, trx, siteId ); - clientResourcesResults = await updatePrivateResources( + privateResourcesResults = await updatePrivateResources( orgId, config, trx, 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 - for (const result of proxyResourcesResults) { + for (const result of publicResourcesResults) { for (const target of result.targetsToUpdate) { const [site] = await trx .select() @@ -136,166 +134,37 @@ export async function applyBlueprint({ } logger.debug( - `Successfully updated client resources for org ${orgId}: ${JSON.stringify(clientResourcesResults)}` + `Successfully updated public resources for org ${orgId}: ${JSON.stringify(publicResourcesResults)}` ); // We need to update the targets on the newts from the successfully updated information - for (const result of clientResourcesResults) { - if ( - result.oldSiteResource && - JSON.stringify(result.newSites?.sort()) !== - JSON.stringify(result.oldSites?.sort()) - ) { - // query existing associations - const existingRoleIds = await trx - .select() - .from(roleSiteResources) - .where( - eq( - roleSiteResources.siteResourceId, - result.oldSiteResource.siteResourceId - ) + for (const result of privateResourcesResults) { + rebuildClientAssociationsFromSiteResource( + result.newSiteResource + ) + .then(() => + waitForSiteResourceRebuildIdle( + result.newSiteResource.siteResourceId ) - .then((rows) => rows.map((row) => row.roleId)); - - const existingUserIds = await trx - .select() - .from(userSiteResources) - .where( - eq( - userSiteResources.siteResourceId, - result.oldSiteResource.siteResourceId - ) + ) + .then(() => + handleMessagingForUpdatedSiteResource( + result.oldSiteResource, + result.newSiteResource, + result.oldSites.map((s) => s.siteId), + result.newSites.map((s) => s.siteId) ) - .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 - })) + ) + .catch((e) => { + logger.error( + `Failed to rebuild and handle messaging for site resource ${result.newSiteResource.siteResourceId}. Error: ${e}` ); - } - - 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 - // ); + }); } + + logger.debug( + `Successfully updated private resources for org ${orgId}: ${JSON.stringify(privateResourcesResults)}` + ); }); blueprintSucceeded = true; diff --git a/server/lib/calculateUserClientsForOrgs.ts b/server/lib/calculateUserClientsForOrgs.ts index 090bf4d8c2..9c6903ebc8 100644 --- a/server/lib/calculateUserClientsForOrgs.ts +++ b/server/lib/calculateUserClientsForOrgs.ts @@ -6,6 +6,7 @@ import { db, olms, orgs, + primaryDb, roleClients, roles, Transaction, @@ -23,10 +24,44 @@ import { rebuildClientAssociationsFromClient } from "./rebuildClientAssociations import { OlmErrorCodes } from "@server/routers/olm/error"; import { tierMatrix } from "./billing/tierMatrix"; -export async function calculateUserClientsForOrgs( +type ClientRow = typeof clients.$inferSelect; + +function runQueuedClientAssociationRebuilds( userId: string, - trx: Transaction | typeof db = db + queuedClients: ClientRow[] +): void { + if (queuedClients.length === 0) { + return; + } + + const uniqueClientsById = new Map(); + for (const client of queuedClients) { + uniqueClientsById.set(client.clientId, client); + } + + void (async () => { + for (const client of uniqueClientsById.values()) { + try { + await rebuildClientAssociationsFromClient(client); + } catch (error) { + logger.error( + `Failed rebuilding 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( + userId: string ): Promise { + const trx = primaryDb; + const queuedAssociationRebuilds: ClientRow[] = []; + const execute = async (transaction: Transaction | typeof db) => { const orgCache = new Map(); const adminRoleCache = new Map< @@ -189,7 +224,12 @@ export async function calculateUserClientsForOrgs( if (userOlms.length === 0) { // No OLMs for this user, but we should still clean up any orphaned clients - await cleanupOrphanedClients(userId, transaction); + await cleanupOrphanedClients( + userId, + transaction, + [], + queuedAssociationRebuilds + ); return; } @@ -382,10 +422,7 @@ export async function calculateUserClientsForOrgs( .returning(); } - await rebuildClientAssociationsFromClient( - newClient, - transaction - ); + queuedAssociationRebuilds.push(newClient); // Grant admin role access to the client await transaction.insert(roleClients).values({ @@ -414,24 +451,22 @@ export async function calculateUserClientsForOrgs( } // Clean up clients in orgs the user is no longer in - await cleanupOrphanedClients(userId, transaction, userOrgIds); + await cleanupOrphanedClients( + userId, + transaction, + userOrgIds, + queuedAssociationRebuilds + ); }; - if (trx) { - // Use provided transaction - await execute(trx); - } else { - // Create new transaction - await db.transaction(async (transaction) => { - await execute(transaction); - }); - } + runQueuedClientAssociationRebuilds(userId, queuedAssociationRebuilds); } async function cleanupOrphanedClients( userId: string, trx: Transaction | typeof db, - userOrgIds: string[] = [] + userOrgIds: string[] = [], + queuedAssociationRebuilds: ClientRow[] = [] ): Promise { // Find all OLM clients for this user that should be deleted // If userOrgIds is empty, delete all OLM clients (user has no orgs) @@ -461,9 +496,9 @@ async function cleanupOrphanedClients( ) .returning(); - // Rebuild associations for each deleted client to clean up related data + // Queue deleted clients for post-transaction association cleanup. for (const deletedClient of deletedClients) { - await rebuildClientAssociationsFromClient(deletedClient, trx); + queuedAssociationRebuilds.push(deletedClient); if (deletedClient.olmId) { await sendTerminateClient( diff --git a/server/lib/deleteResource.ts b/server/lib/deleteResource.ts new file mode 100644 index 0000000000..b2ffa0f0f1 --- /dev/null +++ b/server/lib/deleteResource.ts @@ -0,0 +1,144 @@ +import { eq, inArray } from "drizzle-orm"; +import { + db, + newts, + resourcePolicies, + resources, + sites, + targetHealthCheck, + targets, + type Resource, + type Target, + type TargetHealthCheck, + type Transaction +} from "@server/db"; +import logger from "@server/logger"; +import { removeTargets } from "@server/routers/newt/targets"; +import createHttpError from "http-errors"; +import HttpCode from "@server/types/HttpCode"; + +export type DeleteResourceResult = { + deletedResource: Resource; + targetsToBeRemoved: Target[]; + healthChecksToBeRemoved: TargetHealthCheck[]; +}; + +export async function performDeleteResources( + resourceIds: number[], + trx: Transaction | typeof db = db +): Promise { + 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(); + 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(); + 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 { + const [result] = await performDeleteResources([resourceId], trx); + return result ?? null; +} + +export async function runResourceDeleteSideEffects( + result: DeleteResourceResult +): Promise { + 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 + ); + } + } + } +} diff --git a/server/lib/deleteSiteAssociatedResources.ts b/server/lib/deleteSiteAssociatedResources.ts new file mode 100644 index 0000000000..61da82f58f --- /dev/null +++ b/server/lib/deleteSiteAssociatedResources.ts @@ -0,0 +1,126 @@ +import { and, eq, sql } from "drizzle-orm"; +import { + db, + siteNetworks, + siteResources, + targets, + type SiteResource, + type Transaction +} from "@server/db"; +import { + performDeleteResources, + runResourceDeleteSideEffects, + type DeleteResourceResult +} from "@server/lib/deleteResource"; +import { + performDeleteSiteResources, + runSiteResourceDeleteSideEffects +} from "@server/lib/deleteSiteResource"; +import logger from "@server/logger"; + +export const MAX_SITE_ASSOCIATED_RESOURCES_FOR_BULK_DELETE = 250; + +export type DeleteSiteAssociatedResourcesSideEffects = { + resources: DeleteResourceResult[]; + siteResources: SiteResource[]; +}; + +export async function getResourceIdsForSite( + siteId: number, + trx: Transaction | typeof db = db +): Promise { + 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 { + 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 { + const [publicCountResult, privateCountResult] = await Promise.all([ + trx + .select({ + count: sql`count(distinct ${targets.resourceId})` + }) + .from(targets) + .where(eq(targets.siteId, siteId)), + trx + .select({ + count: sql`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 { + 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 { + for (const result of sideEffects.resources) { + await runResourceDeleteSideEffects(result); + } + + for (const removed of sideEffects.siteResources) { + runSiteResourceDeleteSideEffects(removed); + } +} diff --git a/server/lib/deleteSiteResource.ts b/server/lib/deleteSiteResource.ts new file mode 100644 index 0000000000..9db5bd902e --- /dev/null +++ b/server/lib/deleteSiteResource.ts @@ -0,0 +1,53 @@ +import { inArray } from "drizzle-orm"; +import { + db, + siteResources, + type SiteResource, + type Transaction +} from "@server/db"; +import logger from "@server/logger"; +import { rebuildClientAssociationsFromSiteResource } from "@server/lib/rebuildClientAssociations"; + +export async function performDeleteSiteResources( + siteResourceIds: number[], + trx: Transaction | typeof db = db +): Promise { + 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 { + 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 + ); + } + ); +} diff --git a/server/lib/lock.ts b/server/lib/lock.ts index 7eea890845..3cd1b8704e 100644 --- a/server/lib/lock.ts +++ b/server/lib/lock.ts @@ -1,4 +1,24 @@ +const instanceId = `local-${Math.random().toString(36).slice(2)}-${Date.now()}`; + +type LocalLockRecord = { + owner: string; + expiresAt: number; +}; + +const localLocks = new Map(); + 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 * @param lockKey - Unique identifier for the lock @@ -7,22 +27,57 @@ export class LockManager { */ async acquireLock( lockKey: string, - ttlMs: number = 30000 + ttlMs: number = 30000, + maxRetries: number = 3, + retryDelayMs: number = 100 ): Promise { - return true; + for (let attempt = 0; attempt < maxRetries; attempt++) { + this.clearExpiredLocalLock(lockKey); + + const existing = localLocks.get(lockKey); + if (!existing) { + localLocks.set(lockKey, { + owner: this.getLocalOwnerToken(), + expiresAt: Date.now() + ttlMs + }); + return true; + } + + if (existing.owner === this.getLocalOwnerToken()) { + existing.expiresAt = Date.now() + ttlMs; + localLocks.set(lockKey, existing); + return true; + } + + if (attempt < maxRetries - 1) { + const delay = retryDelayMs * Math.pow(2, attempt); + await new Promise((resolve) => setTimeout(resolve, delay)); + } + } + + return false; } /** * Release a lock using Lua script to ensure atomicity * @param lockKey - Unique identifier for the lock */ - async releaseLock(lockKey: string): Promise {} + async releaseLock(lockKey: string): Promise { + 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) * @param lockKey - Unique identifier for the lock */ - async forceReleaseLock(lockKey: string): Promise {} + async forceReleaseLock(lockKey: string): Promise { + localLocks.delete(lockKey); + } /** * Check if a lock exists and get its info @@ -35,7 +90,20 @@ export class LockManager { ttl: number; owner?: string; }> { - return { exists: true, ownedByMe: true, ttl: 0 }; + this.clearExpiredLocalLock(lockKey); + const existing = localLocks.get(lockKey); + + if (!existing) { + return { exists: false, ownedByMe: false, ttl: 0 }; + } + + const ttl = Math.max(0, existing.expiresAt - Date.now()); + return { + exists: true, + ownedByMe: existing.owner === this.getLocalOwnerToken(), + ttl, + owner: existing.owner.split(":")[0] + }; } /** @@ -45,6 +113,15 @@ export class LockManager { * @returns Promise - true if extended successfully */ async extendLock(lockKey: string, ttlMs: number): Promise { + 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; } @@ -62,7 +139,26 @@ export class LockManager { maxRetries: number = 5, baseDelayMs: number = 100 ): Promise { - return true; + for (let attempt = 0; attempt <= maxRetries; attempt++) { + const acquired = await this.acquireLock( + lockKey, + ttlMs, + 1, + baseDelayMs + ); + + if (acquired) { + return true; + } + + if (attempt < maxRetries) { + const delay = + baseDelayMs * Math.pow(2, attempt) + Math.random() * 100; + await new Promise((resolve) => setTimeout(resolve, delay)); + } + } + + return false; } /** @@ -99,7 +195,21 @@ export class LockManager { activeLocksCount: number; locksOwnedByMe: number; }> { - return { activeLocksCount: 0, locksOwnedByMe: 0 }; + const now = Date.now(); + for (const [key, value] of localLocks.entries()) { + if (value.expiresAt <= now) { + localLocks.delete(key); + } + } + + let locksOwnedByMe = 0; + for (const value of localLocks.values()) { + if (value.owner === this.getLocalOwnerToken()) { + locksOwnedByMe++; + } + } + + return { activeLocksCount: localLocks.size, locksOwnedByMe }; } /** diff --git a/server/lib/rebuildClientAssociations.ts b/server/lib/rebuildClientAssociations.ts index 4efc72476a..1f675e81ed 100644 --- a/server/lib/rebuildClientAssociations.ts +++ b/server/lib/rebuildClientAssociations.ts @@ -8,6 +8,7 @@ import { exitNodes, newts, olms, + primaryDb, roleSiteResources, Site, SiteResource, @@ -20,10 +21,10 @@ import { } from "@server/db"; import { and, count, eq, inArray, ne } from "drizzle-orm"; -import { deletePeer as newtDeletePeer } from "@server/routers/newt/peers"; +import { deletePeersBatch as newtDeletePeersBatch } from "@server/routers/newt/peers"; import { - initPeerAddHandshake, - deletePeer as olmDeletePeer + initPeerAddHandshakeBatch, + deletePeersBatch as olmDeletePeersBatch } from "@server/routers/olm/peers"; import { sendToExitNode } from "#dynamic/lib/exitNodes"; import logger from "@server/logger"; @@ -35,16 +36,126 @@ import { } from "@server/lib/ip"; import { addPeerData, - addTargets as addSubnetProxyTargets, - removePeerData, - removeTargets as removeSubnetProxyTargets + addPeerDataBatch, + addTargetsBatch as addSubnetProxyTargetsBatch, + removePeerDataBatch, + removeTargetsBatch as removeSubnetProxyTargetsBatch, + updatePeerDataBatch, + updateTargets } from "@server/routers/client/targets"; import { lockManager } from "#dynamic/lib/lock"; +import { rebuildQueue } from "#dynamic/lib/rebuildQueue"; // TTL for rebuild-association locks. These functions can fan out into many // peer/proxy updates, so give them a generous window. const REBUILD_ASSOCIATIONS_LOCK_TTL_MS = 120000; +const REBUILD_IDLE_POLL_INTERVAL_MS = 300; +const REBUILD_IDLE_DEFAULT_TIMEOUT_MS = 130_000; // slightly longer than lock TTL +const REBUILD_IDLE_HANDLER_TIMEOUT_MS = 5_000; + +/** + * Returns true if a rebuild for the given site resource is currently active + * (holding the distributed lock) or is pending in the rebuild queue. + */ +export async function hasActiveSiteResourceRebuild( + siteResourceId: number +): Promise { + const lockKey = `rebuild-client-associations:site-resource:${siteResourceId}`; + const lockInfo = await lockManager.getLockInfo(lockKey); + if (lockInfo.exists) return true; + return rebuildQueue.isQueued({ type: "site-resource", id: siteResourceId }); +} + +/** + * Resolves once there is no active or queued rebuild for the given site resource. + * Logs a warning and resolves early if the timeout is reached. + */ +export async function waitForSiteResourceRebuildIdle( + siteResourceId: number, + timeoutMs = REBUILD_IDLE_DEFAULT_TIMEOUT_MS +): Promise { + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + if (!(await hasActiveSiteResourceRebuild(siteResourceId))) return; + await new Promise((r) => + setTimeout(r, REBUILD_IDLE_POLL_INTERVAL_MS) + ); + } + logger.warn( + `waitForSiteResourceRebuildIdle: timed out after ${timeoutMs}ms waiting for siteResourceId=${siteResourceId}` + ); +} + +/** + * Resolves once there are no active or queued rebuilds for any site resource + * associated with the given site. + */ +export async function waitForSiteRebuildIdle( + siteId: number, + timeoutMs = REBUILD_IDLE_HANDLER_TIMEOUT_MS +): Promise { + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + const resourceRows = await db + .select({ siteResourceId: siteResources.siteResourceId }) + .from(siteResources) + .innerJoin( + siteNetworks, + eq(siteNetworks.networkId, siteResources.networkId) + ) + .where(eq(siteNetworks.siteId, siteId)); + let allIdle = true; + for (const { siteResourceId } of resourceRows) { + if (await hasActiveSiteResourceRebuild(siteResourceId)) { + allIdle = false; + break; + } + } + if (allIdle) return; + await new Promise((r) => + setTimeout(r, REBUILD_IDLE_POLL_INTERVAL_MS) + ); + } + logger.warn( + `waitForSiteRebuildIdle: timed out after ${timeoutMs}ms waiting for siteId=${siteId}` + ); +} + +/** + * Resolves once there are no active or queued rebuilds for any site resource + * associated with the given client. + */ +export async function waitForClientRebuildIdle( + clientId: number, + timeoutMs = REBUILD_IDLE_HANDLER_TIMEOUT_MS +): Promise { + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + const resourceRows = await db + .select({ + siteResourceId: + clientSiteResourcesAssociationsCache.siteResourceId + }) + .from(clientSiteResourcesAssociationsCache) + .where(eq(clientSiteResourcesAssociationsCache.clientId, clientId)); + let allIdle = true; + for (const { siteResourceId } of resourceRows) { + if (await hasActiveSiteResourceRebuild(siteResourceId)) { + allIdle = false; + break; + } + } + if (allIdle) return; + await new Promise((r) => + setTimeout(r, REBUILD_IDLE_POLL_INTERVAL_MS) + ); + } + logger.warn( + `waitForClientRebuildIdle: timed out after ${timeoutMs}ms waiting for clientId=${clientId}` + ); +} + export async function getClientSiteResourceAccess( siteResource: SiteResource, trx: Transaction | typeof db = db @@ -158,32 +269,37 @@ export async function getClientSiteResourceAccess( } export async function rebuildClientAssociationsFromSiteResource( - siteResource: SiteResource, - trx: Transaction | typeof db = db -): Promise<{ - mergedAllClients: { - clientId: number; - pubKey: string | null; - subnet: string | null; - }[]; -}> { - return await lockManager.withLock( - `rebuild-client-associations:site-resource:${siteResource.siteResourceId}`, - () => rebuildClientAssociationsFromSiteResourceImpl(siteResource, trx), - REBUILD_ASSOCIATIONS_LOCK_TTL_MS - ); + siteResource: SiteResource +) { + try { + return await lockManager.withLock( + `rebuild-client-associations:site-resource:${siteResource.siteResourceId}`, + () => rebuildClientAssociationsFromSiteResourceImpl(siteResource), + REBUILD_ASSOCIATIONS_LOCK_TTL_MS + ); + } catch (err: any) { + if ( + typeof err?.message === "string" && + err.message.startsWith("Failed to acquire lock") + ) { + logger.warn( + `rebuildClientAssociations: could not acquire lock for site resource ${siteResource.siteResourceId}, queuing for deferred processing` + ); + await rebuildQueue.enqueue({ + type: "site-resource", + id: siteResource.siteResourceId + }); + return { mergedAllClients: [] }; + } + throw err; + } } async function rebuildClientAssociationsFromSiteResourceImpl( - siteResource: SiteResource, - trx: Transaction | typeof db = db -): Promise<{ - mergedAllClients: { - clientId: number; - pubKey: string | null; - subnet: string | null; - }[]; -}> { + siteResource: SiteResource +) { + const trx = primaryDb; + logger.debug( `rebuildClientAssociations: [rebuildClientAssociationsFromSiteResource] START siteResourceId=${siteResource.siteResourceId} networkId=${siteResource.networkId} orgId=${siteResource.orgId}` ); @@ -197,14 +313,62 @@ async function rebuildClientAssociationsFromSiteResourceImpl( /////////// process the client-siteResource associations /////////// + const existingClientSiteResources = await trx + .select({ + clientId: clientSiteResourcesAssociationsCache.clientId + }) + .from(clientSiteResourcesAssociationsCache) + .where( + eq( + clientSiteResourcesAssociationsCache.siteResourceId, + siteResource.siteResourceId + ) + ); + + const existingClientSiteResourceIds = existingClientSiteResources.map( + (row) => row.clientId + ); + // get all of the clients associated with other site resources that share // any of the same sites as this site resource (via siteNetworks). We can't // simply filter by networkId since each site resource has its own network; // two site resources serving the same site typically belong to different // networks that both happen to include the site through siteNetworks. const sitesListSiteIds = sitesList.map((s) => s.siteId); + + // We must also consider sites where these clients are currently cached, + // otherwise removing a site from this resource can leave stale + // client-site cache entries behind for the removed site. + const cachedSiteRowsForResourceClients = + existingClientSiteResourceIds.length > 0 + ? await trx + .select({ siteId: clientSitesAssociationsCache.siteId }) + .from(clientSitesAssociationsCache) + .where( + inArray( + clientSitesAssociationsCache.clientId, + existingClientSiteResourceIds + ) + ) + : []; + + const allCandidateSiteIds = Array.from( + new Set([ + ...sitesListSiteIds, + ...cachedSiteRowsForResourceClients.map((r) => r.siteId) + ]) + ); + + const sitesToProcess = + allCandidateSiteIds.length > 0 + ? await trx + .select() + .from(sites) + .where(inArray(sites.siteId, allCandidateSiteIds)) + : []; + const currentSiteIdSet = new Set(sitesListSiteIds); const allUpdatedClientsFromOtherResourcesOnThisSite = - sitesListSiteIds.length > 0 + allCandidateSiteIds.length > 0 ? await trx .select({ clientId: clientSiteResourcesAssociationsCache.clientId, @@ -224,7 +388,7 @@ async function rebuildClientAssociationsFromSiteResourceImpl( ) .where( and( - inArray(siteNetworks.siteId, sitesListSiteIds), + inArray(siteNetworks.siteId, allCandidateSiteIds), ne( siteResources.siteResourceId, siteResource.siteResourceId @@ -243,22 +407,6 @@ async function rebuildClientAssociationsFromSiteResourceImpl( clientsFromOtherResourcesBySite.get(row.siteId)!.add(row.clientId); } - const existingClientSiteResources = await trx - .select({ - clientId: clientSiteResourcesAssociationsCache.clientId - }) - .from(clientSiteResourcesAssociationsCache) - .where( - eq( - clientSiteResourcesAssociationsCache.siteResourceId, - siteResource.siteResourceId - ) - ); - - const existingClientSiteResourceIds = existingClientSiteResources.map( - (row) => row.clientId - ); - logger.debug( `rebuildClientAssociations: [rebuildClientAssociationsFromSiteResource] siteResourceId=${siteResource.siteResourceId} existingResourceClientIds=[${existingClientSiteResourceIds.join(", ")}]` ); @@ -341,10 +489,10 @@ async function rebuildClientAssociationsFromSiteResourceImpl( /////////// process the client-site associations /////////// logger.debug( - `rebuildClientAssociations: [rebuildClientAssociationsFromSiteResource] siteResourceId=${siteResource.siteResourceId} beginning client-site association loop over ${sitesList.length} site(s)` + `rebuildClientAssociations: [rebuildClientAssociationsFromSiteResource] siteResourceId=${siteResource.siteResourceId} beginning client-site association loop over ${sitesToProcess.length} site(s) (current=${sitesList.length})` ); - for (const site of sitesList) { + for (const site of sitesToProcess) { const siteId = site.siteId; logger.debug( @@ -386,7 +534,13 @@ async function rebuildClientAssociationsFromSiteResourceImpl( `rebuildClientAssociations: [rebuildClientAssociationsFromSiteResource] siteId=${siteId} otherResourceClientIds=[${[...otherResourceClientIds].join(", ")}] mergedAllClientIds=[${mergedAllClientIds.join(", ")}]` ); - const clientSitesToAdd = mergedAllClientIds.filter( + // Expected clients from this resource are site-scoped: if this site is + // no longer attached to the resource, the expected set is empty. + const expectedClientIdsForSite = currentSiteIdSet.has(siteId) + ? mergedAllClientIds + : []; + + const clientSitesToAdd = expectedClientIdsForSite.filter( (clientId) => !existingClientSiteIds.includes(clientId) && !otherResourceClientIds.has(clientId) // dont add if already connected via another site resource @@ -421,7 +575,7 @@ async function rebuildClientAssociationsFromSiteResourceImpl( // Now remove any client-site associations that should no longer exist const clientSitesToRemove = existingClientSiteIds.filter( (clientId) => - !mergedAllClientIds.includes(clientId) && + !expectedClientIdsForSite.includes(clientId) && !otherResourceClientIds.has(clientId) // dont remove if there is still another connection for another site resource ); @@ -468,10 +622,6 @@ async function rebuildClientAssociationsFromSiteResourceImpl( clientSiteResourcesToRemove, trx ); - - return { - mergedAllClients - }; } async function handleMessagesForSiteClients( @@ -536,6 +686,28 @@ async function handleMessagesForSiteClients( const newtJobs: Promise[] = []; const olmJobs: Promise[] = []; const exitNodeJobs: Promise[] = []; + const newtPeerDeletes: { + siteId: number; + publicKey: string; + newtId: string; + }[] = []; + const olmPeerDeletes: { + clientId: number; + siteId: number; + publicKey: string; + olmId: string; + }[] = []; + const olmPeerAddHandshakes: { + clientId: number; + peer: { + siteId: number; + exitNode: { + publicKey: string; + endpoint: string; + }; + }; + olmId: string; + }[] = []; // Combine all clients that need processing (those being added or removed) const clientsToProcess = new Map< @@ -584,6 +756,21 @@ async function handleMessagesForSiteClients( } } + // Batch-fetch all olm IDs for the clients we need to process + const clientIdsToProcess = Array.from(clientsToProcess.keys()); + const olmRows = + clientIdsToProcess.length > 0 + ? await trx + .select({ olmId: olms.olmId, clientId: olms.clientId }) + .from(olms) + .where(inArray(olms.clientId, clientIdsToProcess)) + : []; + const olmByClientId = new Map( + olmRows + .filter((r) => r.clientId !== null) + .map((r) => [r.clientId as number, r.olmId]) + ); + for (const client of clientsToProcess.values()) { // UPDATE THE NEWT if (!client.subnet || !client.pubKey) { @@ -600,14 +787,8 @@ async function handleMessagesForSiteClients( continue; } - const [olm] = await trx - .select({ - olmId: olms.olmId - }) - .from(olms) - .where(eq(olms.clientId, client.clientId)) - .limit(1); - if (!olm) { + const olmId = olmByClientId.get(client.clientId); + if (!olmId) { logger.warn( `Olm not found for client ${client.clientId} so cannot add/delete peers` ); @@ -615,15 +796,17 @@ async function handleMessagesForSiteClients( } if (isDelete) { - newtJobs.push(newtDeletePeer(siteId, client.pubKey, newt.newtId)); - olmJobs.push( - olmDeletePeer( - client.clientId, - siteId, - site.publicKey, - olm.olmId - ) - ); + newtPeerDeletes.push({ + siteId, + publicKey: client.pubKey, + newtId: newt.newtId + }); + olmPeerDeletes.push({ + clientId: client.clientId, + siteId, + publicKey: site.publicKey, + olmId + }); } if (isAdd) { @@ -635,23 +818,34 @@ async function handleMessagesForSiteClients( continue; } - await initPeerAddHandshake( - // this will kick off the add peer process for the client - client.clientId, - { + olmPeerAddHandshakes.push({ + clientId: client.clientId, + peer: { siteId, exitNode: { publicKey: exitNode.publicKey, endpoint: exitNode.endpoint } }, - olm.olmId - ); + olmId + }); } exitNodeJobs.push(updateClientSiteDestinations(client, trx)); } + if (newtPeerDeletes.length > 0) { + newtJobs.push(newtDeletePeersBatch(newtPeerDeletes)); + } + + if (olmPeerDeletes.length > 0) { + olmJobs.push(olmDeletePeersBatch(olmPeerDeletes)); + } + + if (olmPeerAddHandshakes.length > 0) { + olmJobs.push(initPeerAddHandshakeBatch(olmPeerAddHandshakes)); + } + Promise.all(exitNodeJobs).catch((error) => { logger.error( `rebuildClientAssociations: Error updating client site destinations for site ${site.siteId}:`, @@ -812,6 +1006,20 @@ async function handleSubnetProxyTargetUpdates( ): Promise { const proxyJobs: Promise[] = []; const olmJobs: Promise[] = []; + const targetsToAddBatch: { + newtId: string; + targets: NonNullable< + Awaited> + >; + version: string | null; + }[] = []; + const targetsToRemoveBatch: { + newtId: string; + targets: NonNullable< + Awaited> + >; + version: string | null; + }[] = []; for (const siteData of sitesList) { const siteId = siteData.siteId; @@ -843,25 +1051,25 @@ async function handleSubnetProxyTargetUpdates( ); if (targetsToAdd) { - proxyJobs.push( - addSubnetProxyTargets( - newt.newtId, - targetsToAdd, - newt.version - ) - ); + targetsToAddBatch.push({ + newtId: newt.newtId, + targets: targetsToAdd, + version: newt.version + }); } - for (const client of addedClients) { - olmJobs.push( - addPeerData( - client.clientId, + olmJobs.push( + addPeerDataBatch( + addedClients.map((client) => ({ + clientId: client.clientId, siteId, - generateRemoteSubnets([siteResource]), - generateAliasConfig([siteResource]) - ) - ); - } + remoteSubnets: generateRemoteSubnets([ + siteResource + ]), + aliases: generateAliasConfig([siteResource]) + })) + ) + ); } } @@ -880,15 +1088,20 @@ async function handleSubnetProxyTargetUpdates( ); if (targetsToRemove) { - proxyJobs.push( - removeSubnetProxyTargets( - newt.newtId, - targetsToRemove, - newt.version - ) - ); + targetsToRemoveBatch.push({ + newtId: newt.newtId, + targets: targetsToRemove, + version: newt.version + }); } + const peerDataRemovals: { + clientId: number; + siteId: number; + remoteSubnets: string[]; + aliases: ReturnType; + }[] = []; + for (const client of removedClients) { if (!siteResource.destination) { continue; @@ -936,31 +1149,518 @@ async function handleSubnetProxyTargetUpdates( ? [] : generateRemoteSubnets([siteResource]); - olmJobs.push( - removePeerData( - client.clientId, - siteId, - remoteSubnetsToRemove, - generateAliasConfig([siteResource]) - ) + peerDataRemovals.push({ + clientId: client.clientId, + siteId, + remoteSubnets: remoteSubnetsToRemove, + aliases: generateAliasConfig([siteResource]) + }); + } + + if (peerDataRemovals.length > 0) { + olmJobs.push(removePeerDataBatch(peerDataRemovals)); + } + } + } + } + + if (targetsToAddBatch.length > 0) { + proxyJobs.push(addSubnetProxyTargetsBatch(targetsToAddBatch)); + } + + if (targetsToRemoveBatch.length > 0) { + proxyJobs.push(removeSubnetProxyTargetsBatch(targetsToRemoveBatch)); + } + + await Promise.all([...proxyJobs, ...olmJobs]); +} + +export async function handleMessagingForUpdatedSiteResource( + existingSiteResource: SiteResource | undefined, + updatedSiteResource: SiteResource, + existingSiteIds: number[], + updatedSiteIds: number[] +) { + const trx = primaryDb; + + logger.debug( + `handleMessagingForUpdatedSiteResource: START siteResourceId=${updatedSiteResource.siteResourceId} existingSiteIds=[${existingSiteIds.join(", ")}] updatedSiteIds=[${updatedSiteIds.join(", ")}]` + ); + + logger.debug( + "handleMessagingForUpdatedSiteResource: existingSiteResource is: ", + existingSiteResource + ); + logger.debug( + "handleMessagingForUpdatedSiteResource: updatedSiteResource is: ", + updatedSiteResource + ); + + const allSiteIds = [...new Set([...existingSiteIds, ...updatedSiteIds])]; + + logger.debug( + `handleMessagingForUpdatedSiteResource: allSiteIds=[${allSiteIds.join(", ")}] count=${allSiteIds.length}` + ); + + const newtsForSites = + allSiteIds.length > 0 + ? await trx + .select() + .from(newts) + .where(inArray(newts.siteId, allSiteIds)) + : []; + const newtBySiteId = new Map( + newtsForSites.map((newt) => [newt.siteId, newt]) + ); + + logger.debug( + `handleMessagingForUpdatedSiteResource: fetched newts for ${newtsForSites.length}/${allSiteIds.length} site(s)` + ); + + // WARNING: THIS RELIES ON THE CACHE TABLES BEING UP TO DATE, SO CALL THIS AFTER THE ASSOCIATION CACHE IS UPDATED + const mergedAllClients = await trx + .select({ + clientId: clientSiteResourcesAssociationsCache.clientId, + pubKey: clients.pubKey, + subnet: clients.subnet + }) + .from(clientSiteResourcesAssociationsCache) + .innerJoin( + clients, + eq(clientSiteResourcesAssociationsCache.clientId, clients.clientId) + ) + .where( + eq( + clientSiteResourcesAssociationsCache.siteResourceId, + updatedSiteResource.siteResourceId + ) + ); + + logger.debug( + `handleMessagingForUpdatedSiteResource: resolved merged clients count=${mergedAllClients.length} clientIds=[${mergedAllClients.map((c) => c.clientId).join(", ")}]` + ); + + const targets = await generateSubnetProxyTargetV2( + updatedSiteResource, + mergedAllClients + ); + + logger.debug( + `handleMessagingForUpdatedSiteResource: generated updated targets count=${targets ? targets.length : 0}` + ); + + const oldDestinationStillInUseClientSitePairs = new Set(); + if ( + existingSiteResource?.destination && + allSiteIds.length > 0 && + mergedAllClients.length > 0 + ) { + logger.debug( + `handleMessagingForUpdatedSiteResource: checking old destination reuse destination=${existingSiteResource.destination} across siteCount=${allSiteIds.length} clientCount=${mergedAllClients.length}` + ); + + // we need to do this because the client only knows about peers not resources so we need to make sure that we dont remove it if there is still a another resource + const oldDestinationStillInUseRows = await trx + .select({ + clientId: clientSiteResourcesAssociationsCache.clientId, + siteId: siteNetworks.siteId + }) + .from(siteResources) + .innerJoin( + clientSiteResourcesAssociationsCache, + eq( + clientSiteResourcesAssociationsCache.siteResourceId, + siteResources.siteResourceId + ) + ) + .innerJoin( + siteNetworks, + eq(siteNetworks.networkId, siteResources.networkId) + ) + .where( + and( + inArray( + clientSiteResourcesAssociationsCache.clientId, + mergedAllClients.map((c) => c.clientId) + ), + inArray(siteNetworks.siteId, allSiteIds), + eq( + siteResources.destination, + existingSiteResource.destination + ), + ne( + siteResources.siteResourceId, + existingSiteResource.siteResourceId + ) + ) + ); + + for (const row of oldDestinationStillInUseRows) { + oldDestinationStillInUseClientSitePairs.add( + `${row.clientId}:${row.siteId}` + ); + } + + logger.debug( + `handleMessagingForUpdatedSiteResource: old destination still in use rows=${oldDestinationStillInUseRows.length} uniqueClientSitePairs=${oldDestinationStillInUseClientSitePairs.size}` + ); + } else { + logger.debug( + "handleMessagingForUpdatedSiteResource: skipping old destination reuse check (missing existing destination or no sites/clients)" + ); + } + + //////////////////////////// FROM HERE DOWN WE ARE DEALING WITH REMOVING SITES + const removedSiteIds = existingSiteIds.filter( + (id) => !updatedSiteIds.includes(id) + ); + + logger.debug( + `handleMessagingForUpdatedSiteResource: removing sites removedSiteIds=[${removedSiteIds.join(", ")}] count=${removedSiteIds.length}` + ); + + const targetsToRemoveBatch: { + newtId: string; + targets: any[]; + version: string | null; + }[] = []; + const peerDataRemoves: { + clientId: number; + siteId: number; + remoteSubnets: string[]; + aliases: ReturnType; + }[] = []; + if (targets) { + for (const siteId of removedSiteIds) { + const newt = newtBySiteId.get(siteId); + if (!newt) { + logger.debug( + `handleMessagingForUpdatedSiteResource: skipping remove for siteId=${siteId} because no newt found` + ); + continue; + } + + logger.debug( + `handleMessagingForUpdatedSiteResource: preparing remove batches for siteId=${siteId} newtId=${newt.newtId}` + ); + + targetsToRemoveBatch.push({ + newtId: newt.newtId, + targets: targets, + version: newt.version + }); + for (const client of mergedAllClients) { + // we need to do this because the client only knows about peers not resources so we need to make sure that we dont remove it if there is still a another resource + const oldDestinationStillInUseBySite = + oldDestinationStillInUseClientSitePairs.has( + `${client.clientId}:${siteId}` ); + + if (existingSiteResource) { + peerDataRemoves.push({ + // this might happen twice after the rebuild function but that is okay + clientId: client.clientId, + siteId, + remoteSubnets: !oldDestinationStillInUseBySite + ? generateRemoteSubnets([existingSiteResource]) + : [], + aliases: generateAliasConfig([existingSiteResource]) + }); } } } + } else { + logger.debug( + "handleMessagingForUpdatedSiteResource: skipping removal batch generation because targets were empty" + ); } - await Promise.all(proxyJobs); + logger.debug( + `handleMessagingForUpdatedSiteResource: remove batches prepared targetBatchCount=${targetsToRemoveBatch.length} peerDataCount=${peerDataRemoves.length}` + ); + + logger.debug( + "handleMessagingForUpdatedSiteResource: dispatching removeSubnetProxyTargetsBatch" + ); + + removeSubnetProxyTargetsBatch(targetsToRemoveBatch); + + logger.debug( + "handleMessagingForUpdatedSiteResource: dispatching removePeerDataBatch" + ); + + removePeerDataBatch(peerDataRemoves); + + //////////////////////////// FROM HERE DOWN WE ARE DEALING WITH ADDING NEW SITES + const addedSiteIds = updatedSiteIds.filter( + (id) => !existingSiteIds.includes(id) + ); + + logger.debug( + `handleMessagingForUpdatedSiteResource: adding sites addedSiteIds=[${addedSiteIds.join(", ")}] count=${addedSiteIds.length}` + ); + + const targetsToAddBatch: { + newtId: string; + targets: any[]; + version: string | null; + }[] = []; + const peerDataAdds: { + clientId: number; + siteId: number; + remoteSubnets: string[]; + aliases: ReturnType; + }[] = []; + if (targets) { + for (const siteId of addedSiteIds) { + const newt = newtBySiteId.get(siteId); + if (!newt) { + logger.debug( + `handleMessagingForUpdatedSiteResource: skipping add for siteId=${siteId} because no newt found` + ); + continue; + } + + logger.debug( + `handleMessagingForUpdatedSiteResource: preparing add batches for siteId=${siteId} newtId=${newt.newtId}` + ); + + targetsToAddBatch.push({ + newtId: newt.newtId, + targets: targets, + version: newt.version + }); + for (const client of mergedAllClients) { + peerDataAdds.push({ + clientId: client.clientId, + siteId, + remoteSubnets: generateRemoteSubnets([updatedSiteResource]), + aliases: generateAliasConfig([updatedSiteResource]) + }); + } + } + } else { + logger.debug( + "handleMessagingForUpdatedSiteResource: skipping add batch generation because targets were empty" + ); + } + + logger.debug( + `handleMessagingForUpdatedSiteResource: add batches prepared targetBatchCount=${targetsToAddBatch.length} peerDataCount=${peerDataAdds.length}` + ); + + logger.debug( + "handleMessagingForUpdatedSiteResource: dispatching addSubnetProxyTargetsBatch" + ); + + addSubnetProxyTargetsBatch(targetsToAddBatch); + + logger.debug( + "handleMessagingForUpdatedSiteResource: dispatching addPeerDataBatch" + ); + + addPeerDataBatch(peerDataAdds); + + //////////////////////////// FROM HERE DOWN WE ARE DEALING WITH UPDATING THE EXISTING SITES + + const unchangedSiteIds = existingSiteIds.filter((id) => + updatedSiteIds.includes(id) + ); + + logger.debug( + `handleMessagingForUpdatedSiteResource: unchangedSiteIds=[${unchangedSiteIds.join(", ")}] count=${unchangedSiteIds.length}` + ); + + // after everything is rebuilt above we still need to update the targets and remote subnets if the destination changed + const destinationChanged = + existingSiteResource && + existingSiteResource.destination !== updatedSiteResource.destination; + const destinationPortChanged = + existingSiteResource && + existingSiteResource.destinationPort !== + updatedSiteResource.destinationPort; + const aliasChanged = + existingSiteResource && + existingSiteResource.alias !== updatedSiteResource.alias; + const fullDomainChanged = + existingSiteResource && + existingSiteResource.fullDomain !== updatedSiteResource.fullDomain; + const sslChanged = + existingSiteResource && + existingSiteResource.ssl !== updatedSiteResource.ssl; + const portRangesChanged = + existingSiteResource && + (existingSiteResource.tcpPortRangeString !== + updatedSiteResource.tcpPortRangeString || + existingSiteResource.udpPortRangeString !== + updatedSiteResource.udpPortRangeString || + existingSiteResource.disableIcmp !== + updatedSiteResource.disableIcmp); + + logger.debug( + `handleMessagingForUpdatedSiteResource: change flags destinationChanged=${Boolean(destinationChanged)} destinationPortChanged=${Boolean(destinationPortChanged)} aliasChanged=${Boolean(aliasChanged)} fullDomainChanged=${Boolean(fullDomainChanged)} sslChanged=${Boolean(sslChanged)} portRangesChanged=${Boolean(portRangesChanged)}` + ); + + // if the existingSiteResource is undefined (new resource) we don't need to do anything here, the rebuild above handled it all + + if ( + destinationChanged || + aliasChanged || + fullDomainChanged || + sslChanged || + portRangesChanged || + destinationPortChanged + ) { + const shouldUpdateTargets = + destinationChanged || + sslChanged || + portRangesChanged || + fullDomainChanged || + destinationPortChanged; + + logger.debug( + `handleMessagingForUpdatedSiteResource: entering unchanged-site update path shouldUpdateTargets=${shouldUpdateTargets}` + ); + + const oldTargets = shouldUpdateTargets + ? await generateSubnetProxyTargetV2( + existingSiteResource, + mergedAllClients + ) + : []; + const newTargets = shouldUpdateTargets + ? await generateSubnetProxyTargetV2( + updatedSiteResource, + mergedAllClients + ) + : []; + + logger.debug( + `handleMessagingForUpdatedSiteResource: target update payload sizes oldTargets=${oldTargets ? oldTargets.length : 0} newTargets=${newTargets ? newTargets.length : 0}` + ); + + const peerDataUpdateBatch: Parameters[0] = + []; + + for (const siteId of unchangedSiteIds) { + const newt = newtBySiteId.get(siteId); + + logger.debug( + `handleMessagingForUpdatedSiteResource: processing unchanged siteId=${siteId}` + ); + + if (!newt) { + logger.error( + `handleMessagingForUpdatedSiteResource: missing newt for unchanged siteId=${siteId}` + ); + throw new Error( + "Newt not found for site during site resource update" + ); + } + + // Only update targets on newt if these items change + if (shouldUpdateTargets) { + logger.debug( + `handleMessagingForUpdatedSiteResource: updating targets for siteId=${siteId} newtId=${newt.newtId}` + ); + await updateTargets( + newt.newtId, + { + oldTargets: oldTargets ? oldTargets : [], + newTargets: newTargets ? newTargets : [] + }, + newt.version + ); + } + + for (const client of mergedAllClients) { + // does this client have access to another resource on this site that has the same destination still? if so we dont want to remove it from their olm yet + if (!existingSiteResource.destination) { + logger.debug( + `handleMessagingForUpdatedSiteResource: skipping peerData update for clientId=${client.clientId} siteId=${siteId} because existing destination is empty` + ); + continue; + } + + // we need to do this because the client only knows about peers not resources so we need to make sure that we dont remove it if there is still a another resource + const oldDestinationStillInUseBySite = + oldDestinationStillInUseClientSitePairs.has( + `${client.clientId}:${siteId}` + ); + + // we also need to update the remote subnets on the olms for each client that has access to this site + peerDataUpdateBatch.push({ + clientId: client.clientId, + siteId, + remoteSubnets: destinationChanged + ? { + oldRemoteSubnets: !oldDestinationStillInUseBySite + ? generateRemoteSubnets([ + existingSiteResource + ]) + : [], + newRemoteSubnets: generateRemoteSubnets([ + updatedSiteResource + ]) + } + : undefined, + aliases: + aliasChanged || fullDomainChanged // the full domain is sent down as an alias + ? { + oldAliases: generateAliasConfig([ + existingSiteResource + ]), + newAliases: generateAliasConfig([ + updatedSiteResource + ]) + } + : undefined + }); + } + } + + logger.debug( + `handleMessagingForUpdatedSiteResource: dispatching updatePeerDataBatch count=${peerDataUpdateBatch.length}` + ); + + updatePeerDataBatch(peerDataUpdateBatch); + } else { + logger.debug( + "handleMessagingForUpdatedSiteResource: no unchanged-site update required because no relevant fields changed" + ); + } + + logger.debug( + `handleMessagingForUpdatedSiteResource: DONE siteResourceId=${updatedSiteResource.siteResourceId}` + ); } export async function rebuildClientAssociationsFromClient( - client: Client, - trx: Transaction | typeof db = db + client: Client ): Promise { - return await lockManager.withLock( - `rebuild-client-associations:client:${client.clientId}`, - () => rebuildClientAssociationsFromClientImpl(client, trx), - REBUILD_ASSOCIATIONS_LOCK_TTL_MS - ); + const trx = primaryDb; + try { + return await lockManager.withLock( + `rebuild-client-associations:client:${client.clientId}`, + () => rebuildClientAssociationsFromClientImpl(client, trx), + REBUILD_ASSOCIATIONS_LOCK_TTL_MS + ); + } catch (err: any) { + if ( + typeof err?.message === "string" && + err.message.startsWith("Failed to acquire lock") + ) { + logger.warn( + `rebuildClientAssociations: could not acquire lock for client ${client.clientId}, queuing for deferred processing` + ); + await rebuildQueue.enqueue({ + type: "client", + id: client.clientId + }); + return; + } + throw err; + } } async function rebuildClientAssociationsFromClientImpl( @@ -1237,6 +1937,28 @@ async function handleMessagesForClientSites( const newtJobs: Promise[] = []; const olmJobs: Promise[] = []; const exitNodeJobs: Promise[] = []; + const newtPeerDeletes: { + siteId: number; + publicKey: string; + newtId: string; + }[] = []; + const olmPeerDeletes: { + clientId: number; + siteId: number; + publicKey: string; + olmId: string; + }[] = []; + const olmPeerAddHandshakes: { + clientId: number; + peer: { + siteId: number; + exitNode: { + publicKey: string; + endpoint: string; + }; + }; + olmId: string; + }[] = []; const totalSitesOnClient = await trx .select({ count: count(clientSitesAssociationsCache.siteId) }) @@ -1268,19 +1990,19 @@ async function handleMessagesForClientSites( if (isRemove) { // Remove peer from newt - newtJobs.push( - newtDeletePeer(site.siteId, client.pubKey, newt.newtId) - ); + newtPeerDeletes.push({ + siteId: site.siteId, + publicKey: client.pubKey, + newtId: newt.newtId + }); try { // Remove peer from olm - olmJobs.push( - olmDeletePeer( - client.clientId, - site.siteId, - site.publicKey, - olmId - ) - ); + olmPeerDeletes.push({ + clientId: client.clientId, + siteId: site.siteId, + publicKey: site.publicKey, + olmId + }); } catch (error) { // if the error includes not found then its just because the olm does not exist anymore or yet and its fine if we dont send if ( @@ -1312,10 +2034,9 @@ async function handleMessagesForClientSites( continue; } - await initPeerAddHandshake( - // this will kick off the add peer process for the client - client.clientId, - { + olmPeerAddHandshakes.push({ + clientId: client.clientId, + peer: { siteId: site.siteId, exitNode: { publicKey: exitNode.publicKey, @@ -1323,7 +2044,7 @@ async function handleMessagesForClientSites( } }, olmId - ); + }); } // Update exit node destinations @@ -1339,6 +2060,18 @@ async function handleMessagesForClientSites( ); } + if (newtPeerDeletes.length > 0) { + newtJobs.push(newtDeletePeersBatch(newtPeerDeletes)); + } + + if (olmPeerDeletes.length > 0) { + olmJobs.push(olmDeletePeersBatch(olmPeerDeletes)); + } + + if (olmPeerAddHandshakes.length > 0) { + olmJobs.push(initPeerAddHandshakeBatch(olmPeerAddHandshakes)); + } + Promise.all(exitNodeJobs).catch((error) => { logger.error( `rebuildClientAssociations: Error updating client site destinations for client ${client.clientId}:`, @@ -1437,6 +2170,20 @@ async function handleMessagesForClientResources( continue; } + const targetsToAddBatch: { + newtId: string; + targets: NonNullable< + Awaited> + >; + version: string | null; + }[] = []; + const peerDataAdds: { + clientId: number; + siteId: number; + remoteSubnets: string[]; + aliases: ReturnType; + }[] = []; + for (const resource of resources) { const targets = await generateSubnetProxyTargetV2(resource, [ { @@ -1447,25 +2194,21 @@ async function handleMessagesForClientResources( ]); if (targets) { - proxyJobs.push( - addSubnetProxyTargets( - newt.newtId, - targets, - newt.version - ) - ); + targetsToAddBatch.push({ + newtId: newt.newtId, + targets, + version: newt.version + }); } try { // Add peer data to olm - olmJobs.push( - addPeerData( - client.clientId, - siteId, - generateRemoteSubnets([resource]), - generateAliasConfig([resource]) - ) - ); + peerDataAdds.push({ + clientId: client.clientId, + siteId, + remoteSubnets: generateRemoteSubnets([resource]), + aliases: generateAliasConfig([resource]) + }); } catch (error) { // if the error includes not found then its just because the olm does not exist anymore or yet and its fine if we dont send if ( @@ -1480,6 +2223,14 @@ async function handleMessagesForClientResources( } } } + + if (targetsToAddBatch.length > 0) { + proxyJobs.push(addSubnetProxyTargetsBatch(targetsToAddBatch)); + } + + if (peerDataAdds.length > 0) { + olmJobs.push(addPeerDataBatch(peerDataAdds)); + } } } @@ -1546,6 +2297,20 @@ async function handleMessagesForClientResources( continue; } + const targetsToRemoveBatch: { + newtId: string; + targets: NonNullable< + Awaited> + >; + version: string | null; + }[] = []; + const peerDataRemovals: { + clientId: number; + siteId: number; + remoteSubnets: string[]; + aliases: ReturnType; + }[] = []; + for (const resource of resources) { const targets = await generateSubnetProxyTargetV2(resource, [ { @@ -1556,13 +2321,11 @@ async function handleMessagesForClientResources( ]); if (targets) { - proxyJobs.push( - removeSubnetProxyTargets( - newt.newtId, - targets, - newt.version - ) - ); + targetsToRemoveBatch.push({ + newtId: newt.newtId, + targets, + version: newt.version + }); } try { @@ -1606,21 +2369,19 @@ async function handleMessagesForClientResources( ) ); - // Only remove remote subnet if no other resource uses the same destination + // Only remove remote subnet if no other resource uses the same destination on the same site const remoteSubnetsToRemove = destinationStillInUse.length > 0 ? [] : generateRemoteSubnets([resource]); // Remove peer data from olm - olmJobs.push( - removePeerData( - client.clientId, - siteId, - remoteSubnetsToRemove, - generateAliasConfig([resource]) - ) - ); + peerDataRemovals.push({ + clientId: client.clientId, + siteId, + remoteSubnets: remoteSubnetsToRemove, + aliases: generateAliasConfig([resource]) + }); } catch (error) { // if the error includes not found then its just because the olm does not exist anymore or yet and its fine if we dont send if ( @@ -1635,6 +2396,16 @@ async function handleMessagesForClientResources( } } } + + if (targetsToRemoveBatch.length > 0) { + proxyJobs.push( + removeSubnetProxyTargetsBatch(targetsToRemoveBatch) + ); + } + + if (peerDataRemovals.length > 0) { + olmJobs.push(removePeerDataBatch(peerDataRemovals)); + } } } @@ -1884,11 +2655,20 @@ export async function cleanupSiteAssociations( // 7. Fire all removal messages in parallel. const jobs: Promise[] = []; + const olmPeerDeletes: { + clientId: number; + siteId: number; + publicKey: string; + }[] = []; for (const client of allClients) { // Tell each olm to drop the site's WireGuard peer. if (site.publicKey) { - jobs.push(olmDeletePeer(client.clientId, siteId, site.publicKey)); + olmPeerDeletes.push({ + clientId: client.clientId, + siteId, + publicKey: site.publicKey + }); } // Recompute and push updated relay destinations (now excluding this site). @@ -1897,6 +2677,10 @@ export async function cleanupSiteAssociations( } } + if (olmPeerDeletes.length > 0) { + jobs.push(olmDeletePeersBatch(olmPeerDeletes)); + } + await Promise.all(jobs).catch((error) => { logger.error( `cleanupSiteAssociations: error sending cleanup messages for siteId=${siteId}:`, @@ -1906,3 +2690,44 @@ export async function cleanupSiteAssociations( logger.debug(`cleanupSiteAssociations: DONE siteId=${siteId}`); } + +/** + * Start the background rebuild queue processor. This should be called once + * during server startup. Only one server instance at a time will actively + * consume the queue (enforced via a distributed Redis lock); all other + * instances will poll and wait until the lock becomes available. + */ +export function startRebuildQueueProcessor(): void { + rebuildQueue.startProcessing({ + onSiteResource: async (siteResourceId: number) => { + const [siteResource] = await primaryDb + .select() + .from(siteResources) + .where(eq(siteResources.siteResourceId, siteResourceId)); + + if (!siteResource) { + logger.warn( + `Rebuild queue: site resource ${siteResourceId} not found, skipping` + ); + return; + } + + await rebuildClientAssociationsFromSiteResource(siteResource); + }, + onClient: async (clientId: number) => { + const [client] = await primaryDb + .select() + .from(clients) + .where(eq(clients.clientId, clientId)); + + if (!client) { + logger.warn( + `Rebuild queue: client ${clientId} not found, skipping` + ); + return; + } + + await rebuildClientAssociationsFromClient(client); + } + }); +} diff --git a/server/lib/rebuildQueue.ts b/server/lib/rebuildQueue.ts new file mode 100644 index 0000000000..84dce96412 --- /dev/null +++ b/server/lib/rebuildQueue.ts @@ -0,0 +1,27 @@ +export type RebuildJobType = "site-resource" | "client"; + +export interface RebuildJob { + type: RebuildJobType; + id: number; +} + +export interface RebuildJobHandlers { + onSiteResource(siteResourceId: number): Promise; + onClient(clientId: number): Promise; +} + +export interface RebuildQueueManager { + enqueue(job: RebuildJob): Promise; + startProcessing(handlers: RebuildJobHandlers): void; + isQueued(job: RebuildJob): Promise; +} + +class NoopRebuildQueue implements RebuildQueueManager { + async enqueue(_job: RebuildJob): Promise {} + startProcessing(_handlers: RebuildJobHandlers): void {} + async isQueued(_job: RebuildJob): Promise { + return false; + } +} + +export const rebuildQueue: RebuildQueueManager = new NoopRebuildQueue(); diff --git a/server/lib/statusHistory.ts b/server/lib/statusHistory.ts index 8bb7e6a0c9..7c5b5c370a 100644 --- a/server/lib/statusHistory.ts +++ b/server/lib/statusHistory.ts @@ -1,6 +1,6 @@ import { z } from "zod"; import { db, logsDb, statusHistory } from "@server/db"; -import { and, eq, gte, asc } from "drizzle-orm"; +import { and, eq, gte, lt, asc, desc } from "drizzle-orm"; import { regionalCache as cache } from "#dynamic/lib/cache"; const STATUS_HISTORY_CACHE_TTL = 60; // seconds @@ -42,7 +42,29 @@ export async function getCachedStatusHistory( ) .orderBy(asc(statusHistory.timestamp)); - const { buckets, totalDowntime } = computeBuckets(events, days); + // Fetch the last known state before the window so that entities that + // haven't changed status recently still show the correct status rather + // than appearing as "no_data". + const [lastKnownEvent] = await logsDb + .select() + .from(statusHistory) + .where( + and( + eq(statusHistory.entityType, entityType), + eq(statusHistory.entityId, entityId), + lt(statusHistory.timestamp, startSec) + ) + ) + .orderBy(desc(statusHistory.timestamp)) + .limit(1); + + const priorStatus = lastKnownEvent?.status ?? null; + + const { buckets, totalDowntime } = computeBuckets( + events, + days, + priorStatus + ); const totalWindow = days * 86400; const overallUptime = totalWindow > 0 @@ -110,7 +132,8 @@ export function computeBuckets( timestamp: number; id: number; }[], - days: number + days: number, + priorStatus: string | null = null ): { buckets: StatusHistoryDayBucket[]; totalDowntime: number } { const nowSec = Math.floor(Date.now() / 1000); @@ -136,7 +159,10 @@ export function computeBuckets( .filter((e) => e.timestamp < dayStartSec) .at(-1); - const currentStatus = lastBeforeDay?.status ?? null; + // Fall back to the last known state before the entire query window + // so that entities that haven't generated events recently still show + // as their actual status rather than "no_data". + const currentStatus = lastBeforeDay?.status ?? priorStatus ?? null; const windows: { start: number; end: number | null; status: string }[] = []; diff --git a/server/lib/validators.test.ts b/server/lib/validators.test.ts index 00b6c75db4..ac95184a5c 100644 --- a/server/lib/validators.test.ts +++ b/server/lib/validators.test.ts @@ -1,4 +1,7 @@ -import { isValidUrlGlobPattern } from "./validators"; +import { + getResourceRuleValueValidationError, + isValidUrlGlobPattern +} from "./validators"; import { assertEquals } from "@test/assert"; function runTests() { @@ -236,6 +239,43 @@ function runTests() { "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!"); } diff --git a/server/lib/validators.ts b/server/lib/validators.ts index c179d3c914..ec19bd8523 100644 --- a/server/lib/validators.ts +++ b/server/lib/validators.ts @@ -100,7 +100,10 @@ export function getResourceRuleValueValidationError( ? null : "Invalid country code provided"; case "ASN": - return /^AS\d+$/i.test(value.trim()) + const normalizedValue = value.trim().toUpperCase(); + return /^AS\d+$/.test(normalizedValue) || + normalizedValue === "ALL" || + normalizedValue === "AS0" ? null : "Invalid ASN provided"; default: diff --git a/server/middlewares/verifyAccessTokenAccess.ts b/server/middlewares/verifyAccessTokenAccess.ts index 528298727f..07786e87d4 100644 --- a/server/middlewares/verifyAccessTokenAccess.ts +++ b/server/middlewares/verifyAccessTokenAccess.ts @@ -119,8 +119,7 @@ export async function verifyAccessTokenAccess( return next( createHttpError( HttpCode.FORBIDDEN, - "Failed organization access policy check: " + - (policyCheck.error || "Unknown error") + "" + (policyCheck.error || "Unknown error") ) ); } diff --git a/server/middlewares/verifyAdmin.ts b/server/middlewares/verifyAdmin.ts index 0dbeac2cb0..da4f88af91 100644 --- a/server/middlewares/verifyAdmin.ts +++ b/server/middlewares/verifyAdmin.ts @@ -56,8 +56,7 @@ export async function verifyAdmin( return next( createHttpError( HttpCode.FORBIDDEN, - "Failed organization access policy check: " + - (policyCheck.error || "Unknown error") + "" + (policyCheck.error || "Unknown error") ) ); } diff --git a/server/middlewares/verifyApiKeyAccess.ts b/server/middlewares/verifyApiKeyAccess.ts index 2522a1e8b3..ea1bdac183 100644 --- a/server/middlewares/verifyApiKeyAccess.ts +++ b/server/middlewares/verifyApiKeyAccess.ts @@ -113,8 +113,7 @@ export async function verifyApiKeyAccess( return next( createHttpError( HttpCode.FORBIDDEN, - "Failed organization access policy check: " + - (policyCheck.error || "Unknown error") + "" + (policyCheck.error || "Unknown error") ) ); } diff --git a/server/middlewares/verifyClientAccess.ts b/server/middlewares/verifyClientAccess.ts index 1d994b53f5..3aee4f38c1 100644 --- a/server/middlewares/verifyClientAccess.ts +++ b/server/middlewares/verifyClientAccess.ts @@ -107,8 +107,7 @@ export async function verifyClientAccess( return next( createHttpError( HttpCode.FORBIDDEN, - "Failed organization access policy check: " + - (policyCheck.error || "Unknown error") + "" + (policyCheck.error || "Unknown error") ) ); } @@ -129,10 +128,7 @@ export async function verifyClientAccess( .where( and( eq(roleClients.clientId, client.clientId), - inArray( - roleClients.roleId, - req.userOrgRoleIds! - ) + inArray(roleClients.roleId, req.userOrgRoleIds!) ) ) .limit(1) diff --git a/server/middlewares/verifyDomainAccess.ts b/server/middlewares/verifyDomainAccess.ts index 783132a1a2..173c3a54bd 100644 --- a/server/middlewares/verifyDomainAccess.ts +++ b/server/middlewares/verifyDomainAccess.ts @@ -88,8 +88,7 @@ export async function verifyDomainAccess( return next( createHttpError( HttpCode.FORBIDDEN, - "Failed organization access policy check: " + - (policyCheck.error || "Unknown error") + "" + (policyCheck.error || "Unknown error") ) ); } diff --git a/server/middlewares/verifyOrgAccess.ts b/server/middlewares/verifyOrgAccess.ts index e464f7b89d..be6242f6dc 100644 --- a/server/middlewares/verifyOrgAccess.ts +++ b/server/middlewares/verifyOrgAccess.ts @@ -7,6 +7,7 @@ import HttpCode from "@server/types/HttpCode"; import { checkOrgAccessPolicy } from "#dynamic/lib/checkOrgAccessPolicy"; import { getUserOrgRoleIds } from "@server/lib/userOrgRoles"; import { getFirstString } from "@server/lib/requestParams"; +import logger from "@server/logger"; export async function verifyOrgAccess( req: Request, @@ -59,8 +60,7 @@ export async function verifyOrgAccess( return next( createHttpError( HttpCode.FORBIDDEN, - "Failed organization access policy check: " + - (policyCheck.error || "Unknown error") + "" + (policyCheck.error || "Unknown error") ) ); } diff --git a/server/middlewares/verifyResourceAccess.ts b/server/middlewares/verifyResourceAccess.ts index f790a481a8..2689cdb2d7 100644 --- a/server/middlewares/verifyResourceAccess.ts +++ b/server/middlewares/verifyResourceAccess.ts @@ -105,8 +105,7 @@ export async function verifyResourceAccess( return next( createHttpError( HttpCode.FORBIDDEN, - "Failed organization access policy check: " + - (policyCheck.error || "Unknown error") + "" + (policyCheck.error || "Unknown error") ) ); } diff --git a/server/middlewares/verifyResourcePolicyAccess.ts b/server/middlewares/verifyResourcePolicyAccess.ts index 30fe48e8cb..667680c0f2 100644 --- a/server/middlewares/verifyResourcePolicyAccess.ts +++ b/server/middlewares/verifyResourcePolicyAccess.ts @@ -102,8 +102,7 @@ export async function verifyResourcePolicyAccess( return next( createHttpError( HttpCode.FORBIDDEN, - "Failed organization access policy check: " + - (policyCheck.error || "Unknown error") + "" + (policyCheck.error || "Unknown error") ) ); } diff --git a/server/middlewares/verifyRoleAccess.ts b/server/middlewares/verifyRoleAccess.ts index 380b820488..3264a3bd99 100644 --- a/server/middlewares/verifyRoleAccess.ts +++ b/server/middlewares/verifyRoleAccess.ts @@ -132,8 +132,7 @@ export async function verifyRoleAccess( return next( createHttpError( HttpCode.FORBIDDEN, - "Failed organization access policy check: " + - (policyCheck.error || "Unknown error") + "" + (policyCheck.error || "Unknown error") ) ); } diff --git a/server/middlewares/verifySetResourceClients.ts b/server/middlewares/verifySetResourceClients.ts index 8f9c1ecaf4..443483a286 100644 --- a/server/middlewares/verifySetResourceClients.ts +++ b/server/middlewares/verifySetResourceClients.ts @@ -45,8 +45,7 @@ export async function verifySetResourceClients( return next( createHttpError( HttpCode.FORBIDDEN, - "Failed organization access policy check: " + - (policyCheck.error || "Unknown error") + "" + (policyCheck.error || "Unknown error") ) ); } diff --git a/server/middlewares/verifySetResourceUsers.ts b/server/middlewares/verifySetResourceUsers.ts index 94600b9b40..cc9375e4ad 100644 --- a/server/middlewares/verifySetResourceUsers.ts +++ b/server/middlewares/verifySetResourceUsers.ts @@ -40,8 +40,7 @@ export async function verifySetResourceUsers( return next( createHttpError( HttpCode.FORBIDDEN, - "Failed organization access policy check: " + - (policyCheck.error || "Unknown error") + "" + (policyCheck.error || "Unknown error") ) ); } diff --git a/server/middlewares/verifySiteAccess.ts b/server/middlewares/verifySiteAccess.ts index c4d35a52fa..50a940855a 100644 --- a/server/middlewares/verifySiteAccess.ts +++ b/server/middlewares/verifySiteAccess.ts @@ -115,8 +115,7 @@ export async function verifySiteAccess( return next( createHttpError( HttpCode.FORBIDDEN, - "Failed organization access policy check: " + - (policyCheck.error || "Unknown error") + "" + (policyCheck.error || "Unknown error") ) ); } diff --git a/server/middlewares/verifySiteProvisioningKeyAccess.ts b/server/middlewares/verifySiteProvisioningKeyAccess.ts index 73393e1e90..9cb9a28f32 100644 --- a/server/middlewares/verifySiteProvisioningKeyAccess.ts +++ b/server/middlewares/verifySiteProvisioningKeyAccess.ts @@ -115,8 +115,7 @@ export async function verifySiteProvisioningKeyAccess( return next( createHttpError( HttpCode.FORBIDDEN, - "Failed organization access policy check: " + - (policyCheck.error || "Unknown error") + "" + (policyCheck.error || "Unknown error") ) ); } diff --git a/server/middlewares/verifySiteResourceAccess.ts b/server/middlewares/verifySiteResourceAccess.ts index 8d5bd656f4..c87518a9e6 100644 --- a/server/middlewares/verifySiteResourceAccess.ts +++ b/server/middlewares/verifySiteResourceAccess.ts @@ -103,8 +103,7 @@ export async function verifySiteResourceAccess( return next( createHttpError( HttpCode.FORBIDDEN, - "Failed organization access policy check: " + - (policyCheck.error || "Unknown error") + "" + (policyCheck.error || "Unknown error") ) ); } diff --git a/server/middlewares/verifyTargetAccess.ts b/server/middlewares/verifyTargetAccess.ts index 8bbed6fca7..24b8abd227 100644 --- a/server/middlewares/verifyTargetAccess.ts +++ b/server/middlewares/verifyTargetAccess.ts @@ -122,8 +122,7 @@ export async function verifyTargetAccess( return next( createHttpError( HttpCode.FORBIDDEN, - "Failed organization access policy check: " + - (policyCheck.error || "Unknown error") + "" + (policyCheck.error || "Unknown error") ) ); } diff --git a/server/middlewares/verifyUserAccess.ts b/server/middlewares/verifyUserAccess.ts index fcc4d0cb94..83c344ae06 100644 --- a/server/middlewares/verifyUserAccess.ts +++ b/server/middlewares/verifyUserAccess.ts @@ -59,8 +59,7 @@ export async function verifyUserAccess( return next( createHttpError( HttpCode.FORBIDDEN, - "Failed organization access policy check: " + - (policyCheck.error || "Unknown error") + "" + (policyCheck.error || "Unknown error") ) ); } diff --git a/server/private/lib/acmeCertSync.ts b/server/private/lib/acmeCertSync.ts index fb99e934e6..56105ac386 100644 --- a/server/private/lib/acmeCertSync.ts +++ b/server/private/lib/acmeCertSync.ts @@ -693,9 +693,9 @@ async function syncAcmeCerts(acmeJsonPath: string): Promise { ); continue; } - logger.debug( - `acmeCertSync: found ${resolverData.Certificates.length} certificate(s) for resolver "${resolver}"` - ); + // logger.debug( + // `acmeCertSync: found ${resolverData.Certificates.length} certificate(s) for resolver "${resolver}"` + // ); for (const cert of resolverData.Certificates) { allCerts.push(cert); } diff --git a/server/private/lib/certificates.ts b/server/private/lib/certificates.ts index 31e40ed556..03ea6a58ce 100644 --- a/server/private/lib/certificates.ts +++ b/server/private/lib/certificates.ts @@ -17,7 +17,7 @@ import { certificates, db } from "@server/db"; import { and, eq, isNotNull, or, inArray, sql } from "drizzle-orm"; import { decrypt } from "@server/lib/crypto"; import logger from "@server/logger"; -import cache from "#private/lib/cache"; +import { regionalCache as cache } from "#private/lib/cache"; import { build } from "@server/build"; // Define the return type for clarity and type safety diff --git a/server/private/lib/checkOrgAccessPolicy.ts b/server/private/lib/checkOrgAccessPolicy.ts index b861c1ae6a..9a03f9e099 100644 --- a/server/private/lib/checkOrgAccessPolicy.ts +++ b/server/private/lib/checkOrgAccessPolicy.ts @@ -21,6 +21,49 @@ import { } from "@server/lib/checkOrgAccessPolicy"; 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( resourceSession: ResourceSession, org: Org @@ -36,13 +79,17 @@ export function enforceResourceSessionLength( if (sessionAgeMs > maxSessionLengthMs) { return { valid: false, - error: `Resource session has expired due to organization policy (max session length: ${maxSessionLengthHours} hours)` + error: `Your resource session has expired. ${formatMaxSessionLengthRequirement( + maxSessionLengthHours + )}` }; } } else { return { valid: false, - error: `Resource session is invalid due to organization policy (max session length: ${maxSessionLengthHours} hours)` + error: `Your resource session is invalid. ${formatMaxSessionLengthRequirement( + maxSessionLengthHours + )}` }; } } @@ -60,14 +107,20 @@ export async function checkOrgAccessPolicy( if (!orgId) { return { allowed: false, - error: "Organization ID is required" + error: "Unable to verify organization access. Organization information is missing." }; } if (!userId) { - return { allowed: false, error: "User ID is required" }; + return { + allowed: false, + error: "Unable to verify organization access. User information is missing." + }; } if (!sessionId) { - return { allowed: false, error: "Session ID is required" }; + return { + allowed: false, + error: "Your session is invalid. Please log in again." + }; } if (build === "enterprise") { @@ -89,7 +142,10 @@ export async function checkOrgAccessPolicy( .where(eq(orgs.orgId, orgId)); props.org = orgQuery; if (!props.org) { - return { allowed: false, error: "Organization not found" }; + return { + allowed: false, + error: "This organization could not be found." + }; } } @@ -100,7 +156,10 @@ export async function checkOrgAccessPolicy( .where(eq(users.userId, userId)); props.user = userQuery; if (!props.user) { - return { allowed: false, error: "User not found" }; + return { + allowed: false, + error: "Your account could not be found." + }; } } @@ -111,14 +170,17 @@ export async function checkOrgAccessPolicy( .where(eq(sessions.sessionId, sessionId)); props.session = sessionQuery; if (!props.session) { - return { allowed: false, error: "Session not found" }; + return { + allowed: false, + error: "Your session has expired. Please log in again." + }; } } if (props.session.userId !== props.user.userId) { return { allowed: false, - error: "Session does not belong to the user" + error: "Your session is invalid. Please log in again." }; } @@ -187,8 +249,14 @@ export async function checkOrgAccessPolicy( allowed = false; } + const policyError = buildOrgAccessPolicyError(policies); + return { allowed, - policies + policies, + error: allowed + ? undefined + : (policyError ?? + "You do not meet this organization's security requirements.") }; } diff --git a/server/private/lib/lock.ts b/server/private/lib/lock.ts index a59bbc0511..26577b2b0a 100644 --- a/server/private/lib/lock.ts +++ b/server/private/lib/lock.ts @@ -11,14 +11,31 @@ * This file is not licensed under the AGPLv3. */ -import { config } from "@server/lib/config"; import logger from "@server/logger"; import { redis } from "#private/lib/redis"; import { v4 as uuidv4 } from "uuid"; const instanceId = uuidv4(); +type LocalLockRecord = { + owner: string; + expiresAt: number; +}; + +const localLocks = new Map(); + 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 * @param lockKey - Unique identifier for the lock @@ -32,12 +49,34 @@ export class LockManager { retryDelayMs: number = 100 ): Promise { if (!redis || !redis.status || redis.status !== "ready") { - return true; + for (let attempt = 0; attempt < maxRetries; attempt++) { + this.clearExpiredLocalLock(lockKey); + + const existing = localLocks.get(lockKey); + if (!existing) { + localLocks.set(lockKey, { + owner: this.getLocalOwnerToken(), + expiresAt: Date.now() + ttlMs + }); + return true; + } + + if (existing.owner === this.getLocalOwnerToken()) { + existing.expiresAt = Date.now() + ttlMs; + localLocks.set(lockKey, existing); + return true; + } + + if (attempt < maxRetries - 1) { + const delay = retryDelayMs * Math.pow(2, attempt); + await new Promise((resolve) => setTimeout(resolve, delay)); + } + } + + return false; } - const lockValue = `${ - instanceId - }:${Date.now()}`; + const lockValue = `${instanceId}:${Date.now()}`; const redisKey = `lock:${lockKey}`; for (let attempt = 0; attempt < maxRetries; attempt++) { @@ -53,11 +92,7 @@ export class LockManager { ); if (result === "OK") { - logger.debug( - `Lock acquired: ${lockKey} by ${ - instanceId - }` - ); + logger.debug(`Lock acquired: ${lockKey} by ${instanceId}`); return true; } @@ -65,17 +100,11 @@ export class LockManager { const existingValue = await redis.get(redisKey); if ( existingValue && - existingValue.startsWith( - `${instanceId}:` - ) + existingValue.startsWith(`${instanceId}:`) ) { // Extend the lock TTL since it's the same worker await redis.pexpire(redisKey, ttlMs); - logger.debug( - `Lock extended: ${lockKey} by ${ - instanceId - }` - ); + logger.debug(`Lock extended: ${lockKey} by ${instanceId}`); return true; } @@ -88,7 +117,10 @@ export class LockManager { await new Promise((resolve) => setTimeout(resolve, delay)); } } catch (error) { - logger.error(`Failed to acquire lock ${lockKey} (attempt ${attempt + 1}/${maxRetries}):`, error); + logger.error( + `Failed to acquire lock ${lockKey} (attempt ${attempt + 1}/${maxRetries}):`, + error + ); // On error, still retry if we have attempts left if (attempt < maxRetries - 1) { const delay = retryDelayMs * Math.pow(2, attempt); @@ -109,6 +141,11 @@ export class LockManager { */ async releaseLock(lockKey: string): Promise { 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; } @@ -136,11 +173,7 @@ export class LockManager { )) as number; if (result === 1) { - logger.debug( - `Lock released: ${lockKey} by ${ - instanceId - }` - ); + logger.debug(`Lock released: ${lockKey} by ${instanceId}`); } else { logger.warn( `Lock not released - not owned by worker: ${lockKey} by ${ @@ -159,6 +192,7 @@ export class LockManager { */ async forceReleaseLock(lockKey: string): Promise { if (!redis || !redis.status || redis.status !== "ready") { + localLocks.delete(lockKey); return; } @@ -186,7 +220,20 @@ export class LockManager { owner?: string; }> { if (!redis || !redis.status || redis.status !== "ready") { - return { exists: false, ownedByMe: true, ttl: 0 }; + this.clearExpiredLocalLock(lockKey); + const existing = localLocks.get(lockKey); + + if (!existing) { + return { exists: false, ownedByMe: false, ttl: 0 }; + } + + const ttl = Math.max(0, existing.expiresAt - Date.now()); + return { + exists: true, + ownedByMe: existing.owner === this.getLocalOwnerToken(), + ttl, + owner: existing.owner.split(":")[0] + }; } const redisKey = `lock:${lockKey}`; @@ -198,11 +245,7 @@ export class LockManager { ]); const exists = value !== null; - const ownedByMe = - exists && - value!.startsWith( - `${instanceId}:` - ); + const ownedByMe = exists && value!.startsWith(`${instanceId}:`); const owner = exists ? value!.split(":")[0] : undefined; return { @@ -225,6 +268,15 @@ export class LockManager { */ async extendLock(lockKey: string, ttlMs: number): Promise { 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; } @@ -255,9 +307,7 @@ export class LockManager { if (result === 1) { logger.debug( - `Lock extended: ${lockKey} by ${ - instanceId - } for ${ttlMs}ms` + `Lock extended: ${lockKey} by ${instanceId} for ${ttlMs}ms` ); return true; } @@ -282,12 +332,13 @@ export class LockManager { maxRetries: number = 5, baseDelayMs: number = 100 ): Promise { - if (!redis || !redis.status || redis.status !== "ready") { - return true; - } - for (let attempt = 0; attempt <= maxRetries; attempt++) { - const acquired = await this.acquireLock(lockKey, ttlMs); + const acquired = await this.acquireLock( + lockKey, + ttlMs, + 1, + baseDelayMs + ); if (acquired) { return true; @@ -319,10 +370,6 @@ export class LockManager { fn: () => Promise, ttlMs: number = 30000 ): Promise { - if (!redis || !redis.status || redis.status !== "ready") { - return await fn(); - } - const acquired = await this.acquireLock(lockKey, ttlMs); if (!acquired) { @@ -346,7 +393,21 @@ export class LockManager { locksOwnedByMe: number; }> { if (!redis || !redis.status || redis.status !== "ready") { - return { activeLocksCount: 0, locksOwnedByMe: 0 }; + const now = Date.now(); + for (const [key, value] of localLocks.entries()) { + if (value.expiresAt <= now) { + localLocks.delete(key); + } + } + + let locksOwnedByMe = 0; + for (const value of localLocks.values()) { + if (value.owner === this.getLocalOwnerToken()) { + locksOwnedByMe++; + } + } + + return { activeLocksCount: localLocks.size, locksOwnedByMe }; } try { @@ -356,11 +417,7 @@ export class LockManager { if (keys.length > 0) { const values = await redis.mget(...keys); locksOwnedByMe = values.filter( - (value) => - value && - value.startsWith( - `${instanceId}:` - ) + (value) => value && value.startsWith(`${instanceId}:`) ).length; } diff --git a/server/private/lib/rebuildQueue.ts b/server/private/lib/rebuildQueue.ts new file mode 100644 index 0000000000..b5e1125452 --- /dev/null +++ b/server/private/lib/rebuildQueue.ts @@ -0,0 +1,209 @@ +/* + * 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; + onClient(clientId: number): Promise; +} + +// 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 { + 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 { + 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 { + 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 { + 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(); diff --git a/server/private/routers/healthChecks/createHealthCheck.ts b/server/private/routers/healthChecks/createHealthCheck.ts index aa37068331..6f49f0f18e 100644 --- a/server/private/routers/healthChecks/createHealthCheck.ts +++ b/server/private/routers/healthChecks/createHealthCheck.ts @@ -29,26 +29,40 @@ const paramsSchema = z.strictObject({ orgId: z.string().nonempty() }); -const bodySchema = z.strictObject({ - name: z.string().nonempty(), - siteId: z.number().int().positive(), - hcEnabled: z.boolean().default(false), - hcMode: z.string().default("http"), - hcHostname: z.string().optional(), - hcPort: z.number().int().min(1).max(65535).optional(), - hcPath: z.string().optional(), - hcScheme: z.string().optional(), - hcMethod: z.string().default("GET"), - hcInterval: z.number().int().positive().default(30), - hcUnhealthyInterval: z.number().int().positive().default(30), - hcTimeout: z.number().int().positive().default(1), - hcHeaders: z.string().optional().nullable(), - hcFollowRedirects: z.boolean().default(true), - hcStatus: z.number().int().optional().nullable(), - hcTlsServerName: z.string().optional(), - hcHealthyThreshold: z.number().int().positive().default(1), - hcUnhealthyThreshold: z.number().int().positive().default(1) -}); +const bodySchema = z + .strictObject({ + name: z.string().nonempty(), + siteId: z.number().int().positive(), + hcEnabled: z.boolean().default(false), + hcMode: z.string().default("http"), + hcHostname: z.string().optional(), + hcPort: z.number().int().min(1).max(65535).optional(), + hcPath: z.string().optional(), + hcScheme: z.string().optional(), + hcMethod: z.string().default("GET"), + hcInterval: z.number().int().positive().default(30), + hcUnhealthyInterval: z.number().int().positive().default(30), + hcTimeout: z.number().int().positive().default(1), + hcHeaders: z.string().optional().nullable(), + hcFollowRedirects: z.boolean().default(true), + hcStatus: z.number().int().optional().nullable(), + hcTlsServerName: z.string().optional(), + hcHealthyThreshold: 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 = { targetHealthCheckId: number; @@ -57,7 +71,6 @@ const CreateHealthCheckResponseDataSchema = z.object({ targetHealthCheckId: z.number() }); - registry.registerPath({ method: "put", path: "/org/{orgId}/health-check", @@ -78,7 +91,9 @@ registry.registerPath({ description: "Successful response", content: { "application/json": { - schema: createApiResponseSchema(CreateHealthCheckResponseDataSchema) + schema: createApiResponseSchema( + CreateHealthCheckResponseDataSchema + ) } } } diff --git a/server/private/routers/healthChecks/updateHealthCheck.ts b/server/private/routers/healthChecks/updateHealthCheck.ts index f08324f9b5..4fb7a624bd 100644 --- a/server/private/routers/healthChecks/updateHealthCheck.ts +++ b/server/private/routers/healthChecks/updateHealthCheck.ts @@ -105,7 +105,6 @@ const UpdateHealthCheckResponseDataSchema = z.object({ hcUnhealthyThreshold: z.number().nullable() }); - registry.registerPath({ method: "post", path: "/org/{orgId}/health-check/{healthCheckId}", @@ -126,7 +125,9 @@ registry.registerPath({ description: "Successful response", content: { "application/json": { - schema: createApiResponseSchema(UpdateHealthCheckResponseDataSchema) + schema: createApiResponseSchema( + UpdateHealthCheckResponseDataSchema + ) } } } @@ -215,6 +216,32 @@ export async function updateHealthCheck( ) .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 (siteId !== undefined) updateData.siteId = siteId; if (hcEnabled !== undefined) updateData.hcEnabled = hcEnabled; diff --git a/server/private/routers/orgIdp/unassociateOrgIdp.ts b/server/private/routers/orgIdp/unassociateOrgIdp.ts index 41b2e6c895..0b5c1ed51c 100644 --- a/server/private/routers/orgIdp/unassociateOrgIdp.ts +++ b/server/private/routers/orgIdp/unassociateOrgIdp.ts @@ -121,7 +121,7 @@ export async function unassociateOrgIdp( }); for (const userId of userIdsToRemove) { - calculateUserClientsForOrgs(userId, primaryDb).catch((e) => { + calculateUserClientsForOrgs(userId).catch((e) => { logger.error( `Failed to calculate user clients after removing user ${userId} from org ${orgId} during IdP unassociation: ${e}` ); diff --git a/server/private/routers/remoteExitNode/listRemoteExitNodes.ts b/server/private/routers/remoteExitNode/listRemoteExitNodes.ts index 061be17925..872e62b2da 100644 --- a/server/private/routers/remoteExitNode/listRemoteExitNodes.ts +++ b/server/private/routers/remoteExitNode/listRemoteExitNodes.ts @@ -22,7 +22,7 @@ import createHttpError from "http-errors"; import logger from "@server/logger"; import { fromError } from "zod-validation-error"; import { ListRemoteExitNodesResponse } from "@server/routers/remoteExitNode/types"; -import cache from "#private/lib/cache"; +import { regionalCache as cache } from "#private/lib/cache"; import semver from "semver"; let stalePangolinNodeVersion: string | null = null; diff --git a/server/private/routers/user/addUserRole.ts b/server/private/routers/user/addUserRole.ts index c59a3d0f77..ce5a6dd50a 100644 --- a/server/private/routers/user/addUserRole.ts +++ b/server/private/routers/user/addUserRole.ts @@ -163,13 +163,11 @@ export async function addUserRole( }); for (const orgClient of orgClientsToRebuild) { - rebuildClientAssociationsFromClient(orgClient, primaryDb).catch( - (e) => { - logger.error( - `Failed to rebuild client associations for client ${orgClient.clientId} after adding role: ${e}` - ); - } - ); + rebuildClientAssociationsFromClient(orgClient).catch((e) => { + logger.error( + `Failed to rebuild client associations for client ${orgClient.clientId} after adding role: ${e}` + ); + }); } return response(res, { diff --git a/server/private/routers/user/removeUserRole.ts b/server/private/routers/user/removeUserRole.ts index b96670815c..79a5a522a3 100644 --- a/server/private/routers/user/removeUserRole.ts +++ b/server/private/routers/user/removeUserRole.ts @@ -170,13 +170,11 @@ export async function removeUserRole( }); for (const orgClient of orgClientsToRebuild) { - rebuildClientAssociationsFromClient(orgClient, primaryDb).catch( - (e) => { - logger.error( - `Failed to rebuild client associations for client ${orgClient.clientId} after removing role: ${e}` - ); - } - ); + rebuildClientAssociationsFromClient(orgClient).catch((e) => { + logger.error( + `Failed to rebuild client associations for client ${orgClient.clientId} after removing role: ${e}` + ); + }); } return response(res, { diff --git a/server/private/routers/user/setUserOrgRoles.ts b/server/private/routers/user/setUserOrgRoles.ts index 7790eacfbd..ef6bc1b4f5 100644 --- a/server/private/routers/user/setUserOrgRoles.ts +++ b/server/private/routers/user/setUserOrgRoles.ts @@ -150,13 +150,11 @@ export async function setUserOrgRoles( }); for (const orgClient of orgClientsToRebuild) { - rebuildClientAssociationsFromClient(orgClient, primaryDb).catch( - (e) => { - logger.error( - `Failed to rebuild client associations for client ${orgClient.clientId} after setting roles: ${e}` - ); - } - ); + rebuildClientAssociationsFromClient(orgClient).catch((e) => { + logger.error( + `Failed to rebuild client associations for client ${orgClient.clientId} after setting roles: ${e}` + ); + }); } return response(res, { diff --git a/server/private/routers/ws/ws.ts b/server/private/routers/ws/ws.ts index a592927ccb..5e38c709ed 100644 --- a/server/private/routers/ws/ws.ts +++ b/server/private/routers/ws/ws.ts @@ -38,6 +38,7 @@ import { messageHandlers } from "@server/routers/ws/messageHandlers"; import { messageHandlers as privateMessageHandlers } from "#private/routers/ws/messageHandlers"; import { AuthenticatedWebSocket, + BatchSendMessage, ClientType, WSMessage, TokenPayload, @@ -187,6 +188,8 @@ const wss: WebSocketServer = new WebSocketServer({ noServer: true }); // Generate unique node ID for this instance const NODE_ID = uuidv4(); 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) const connectedClients: Map = new Map(); @@ -197,6 +200,15 @@ const clientConfigVersions: Map = new Map(); // Recovery tracking 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 const getClientMapKey = (clientId: string) => clientId; @@ -207,6 +219,78 @@ const getNodeConnectionsKey = (nodeId: string, clientId: string) => const getConfigVersionKey = (clientId: string) => `ws:configVersion:${clientId}`; +const clearRedisDirectFlushTimer = (): void => { + if (redisDirectFlushTimer) { + clearTimeout(redisDirectFlushTimer); + redisDirectFlushTimer = null; + } +}; + +const publishDirectBatch = async ( + entries: RedisDirectBatchEntry[] +): Promise => { + 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 => { + 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 => { + await new Promise((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 const initializeRedisSubscription = async (): Promise => { if (!redisManager.isRedisEnabled()) return; @@ -227,7 +311,16 @@ const initializeRedisSubscription = async (): Promise => { // Send to specific client on this node await sendToClientLocal( 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") { // Broadcast to all clients on this node except excluded @@ -503,7 +596,8 @@ const incrementClientConfigVersion = async ( const sendToClientLocal = async ( clientId: string, message: WSMessage, - options: SendMessageOptions = {} + options: SendMessageOptions = {}, + preResolvedConfigVersion?: number ): Promise => { const mapKey = getClientMapKey(clientId); const clients = connectedClients.get(mapKey); @@ -512,7 +606,8 @@ const sendToClientLocal = async ( } // Handle config version - const configVersion = await getClientConfigVersion(clientId); + const configVersion = + preResolvedConfigVersion ?? (await getClientConfigVersion(clientId)); // Add config version to message const messageWithVersion = { @@ -545,43 +640,71 @@ const sendToClientLocal = async ( return true; }; +const sendRedisDirectBatchToLocalClients = async ( + entries: { targetClientId: string; message: WSMessage }[] +): Promise => { + const jobs = entries.map((entry) => + sendToClientLocal( + entry.targetClientId, + entry.message, + {}, + entry.message.configVersion + ) + ); + await Promise.all(jobs); +}; + const broadcastToAllExceptLocal = async ( message: WSMessage, excludeClientId?: string, options: SendMessageOptions = {} ): Promise => { - for (const [mapKey, clients] of connectedClients.entries()) { - const [type, id] = mapKey.split(":"); - const clientId = mapKey; // mapKey is the clientId - if (!(excludeClientId && clientId === excludeClientId)) { - // Handle config version per client - let configVersion = await getClientConfigVersion(clientId); - if (options.incrementConfigVersion) { - configVersion = await incrementClientConfigVersion(clientId); - } + const sendPlans = await Promise.all( + Array.from(connectedClients.entries()).map( + async ([mapKey, clients]) => { + const clientId = mapKey; // mapKey is the clientId + if (excludeClientId && clientId === excludeClientId) { + return null; + } - // Add config version to message - const messageWithVersion = { - ...message, - configVersion - }; + let configVersion = await getClientConfigVersion(clientId); + if (options.incrementConfigVersion) { + configVersion = + await incrementClientConfigVersion(clientId); + } - if (options.compress) { - const compressed = zlib.gzipSync( - Buffer.from(JSON.stringify(messageWithVersion), "utf8") - ); - clients.forEach((client) => { - if (client.readyState === WebSocket.OPEN) { - client.send(compressed); - } - }); - } else { - clients.forEach((client) => { - if (client.readyState === WebSocket.OPEN) { - client.send(JSON.stringify(messageWithVersion)); + return { + clients, + messageWithVersion: { + ...message, + configVersion } - }); + }; } + ) + ); + + for (const plan of sendPlans) { + if (!plan) { + continue; + } + + if (options.compress) { + const compressed = zlib.gzipSync( + Buffer.from(JSON.stringify(plan.messageWithVersion), "utf8") + ); + plan.clients.forEach((client) => { + if (client.readyState === WebSocket.OPEN) { + client.send(compressed); + } + }); + } else { + const messageString = JSON.stringify(plan.messageWithVersion); + plan.clients.forEach((client) => { + if (client.readyState === WebSocket.OPEN) { + client.send(messageString); + } + }); } } }; @@ -602,18 +725,110 @@ const sendToClient = async ( ); // Try to send locally first - const localSent = await sendToClientLocal(clientId, message, options); + const localSent = await sendToClientLocal( + clientId, + message, + options, + configVersion + ); // Only send via Redis if the client is not connected locally and Redis is enabled if (!localSent && redisManager.isRedisEnabled()) { try { - const redisMessage: RedisMessage = { - type: "direct", + await enqueueRedisDirectMessage(clientId, { + ...message, + configVersion + }); + } catch (error) { + logger.error( + "Failed to queue batched direct message for Redis delivery, message may be lost:", + error + ); + // Continue execution - local delivery already attempted + } + } else if (!localSent && !redisManager.isRedisEnabled()) { + // Redis is disabled or unavailable - log that we couldn't deliver to remote nodes + logger.debug( + `Could not deliver message to ${clientId} - not connected locally and Redis unavailable` + ); + } + + return localSent; +}; + +const sendToClientsBatch = async ( + entries: BatchSendMessage[] +): Promise => { + 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( + 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 }; @@ -623,19 +838,11 @@ const sendToClient = async ( ); } catch (error) { logger.error( - "Failed to send message via Redis, message may be lost:", + "Failed to send explicit direct batch via Redis, messages may be lost:", error ); - // Continue execution - local delivery already attempted } - } else if (!localSent && !redisManager.isRedisEnabled()) { - // Redis is disabled or unavailable - log that we couldn't deliver to remote nodes - logger.debug( - `Could not deliver message to ${clientId} - not connected locally and Redis unavailable` - ); } - - return localSent; }; const broadcastToAllExcept = async ( @@ -1109,6 +1316,8 @@ const disconnectClient = async (clientId: string): Promise => { // Cleanup function for graceful shutdown const cleanup = async (): Promise => { try { + await flushPendingRedisDirectMessages(); + // Close all WebSocket connections connectedClients.forEach((clients) => { clients.forEach((client) => { @@ -1139,6 +1348,7 @@ export { router, handleWSUpgrade, sendToClient, + sendToClientsBatch, broadcastToAllExcept, connectedClients, hasActiveConnections, diff --git a/server/routers/accessToken/listAccessTokens.ts b/server/routers/accessToken/listAccessTokens.ts index 0339cc2c47..472d9da406 100644 --- a/server/routers/accessToken/listAccessTokens.ts +++ b/server/routers/accessToken/listAccessTokens.ts @@ -30,7 +30,7 @@ const listAccessTokensParamsSchema = z error: "Either resourceId or orgId must be provided, but not both" }); -const listAccessTokensSchema = z.object({ +const listAccessTokensSchema = z.strictObject({ limit: z .string() .optional() diff --git a/server/routers/apiKeys/listApiKeyActions.ts b/server/routers/apiKeys/listApiKeyActions.ts index 364f3aee2f..3b5efa8b59 100644 --- a/server/routers/apiKeys/listApiKeyActions.ts +++ b/server/routers/apiKeys/listApiKeyActions.ts @@ -15,7 +15,7 @@ const paramsSchema = z.object({ apiKeyId: z.string().nonempty() }); -const querySchema = z.object({ +const querySchema = z.strictObject({ limit: z .string() .optional() diff --git a/server/routers/apiKeys/listOrgApiKeys.ts b/server/routers/apiKeys/listOrgApiKeys.ts index ba87a30333..68a7f9a257 100644 --- a/server/routers/apiKeys/listOrgApiKeys.ts +++ b/server/routers/apiKeys/listOrgApiKeys.ts @@ -11,7 +11,7 @@ import { eq, and } from "drizzle-orm"; import { OpenAPITags, registry } from "@server/openApi"; import { createApiResponseSchema } from "@server/lib/openapi/createApiResponseSchema"; -const querySchema = z.object({ +const querySchema = z.strictObject({ limit: z .string() .optional() diff --git a/server/routers/apiKeys/listRootApiKeys.ts b/server/routers/apiKeys/listRootApiKeys.ts index 654b830a63..434ff5a8bf 100644 --- a/server/routers/apiKeys/listRootApiKeys.ts +++ b/server/routers/apiKeys/listRootApiKeys.ts @@ -9,7 +9,7 @@ import { z } from "zod"; import { fromError } from "zod-validation-error"; import { eq } from "drizzle-orm"; -const querySchema = z.object({ +const querySchema = z.strictObject({ limit: z .string() .optional() diff --git a/server/routers/auditLogs/queryRequestAuditLog.ts b/server/routers/auditLogs/queryRequestAuditLog.ts index f14c28cf18..7f4a0ec165 100644 --- a/server/routers/auditLogs/queryRequestAuditLog.ts +++ b/server/routers/auditLogs/queryRequestAuditLog.ts @@ -20,7 +20,7 @@ import response from "@server/lib/response"; import logger from "@server/logger"; import { getSevenDaysAgo } from "@app/lib/getSevenDaysAgo"; -export const queryAccessAuditLogsQuery = z.object({ +export const queryAccessAuditLogsQuery = z.strictObject({ // iso string just validate its a parseable date timeStart: z .string() diff --git a/server/routers/auth/changePassword.ts b/server/routers/auth/changePassword.ts index 1a26b91170..2563967635 100644 --- a/server/routers/auth/changePassword.ts +++ b/server/routers/auth/changePassword.ts @@ -10,9 +10,8 @@ import { hashPassword, verifyPassword } from "@server/auth/password"; import { verifyTotpCode } from "@server/auth/totp"; import logger from "@server/logger"; import { unauthorized } from "@server/auth/unauthorizedResponse"; -import { invalidateAllSessions } from "@server/auth/sessions/app"; -import { sessions, resourceSessions } from "@server/db"; -import { and, eq, ne, inArray } from "drizzle-orm"; +import { invalidateAllSessionsExceptCurrent } from "@server/auth/sessions/app"; +import { eq } from "drizzle-orm"; import { passwordSchema } from "@server/auth/passwordSchema"; import { UserType } from "@server/types/UserTypes"; import { sendEmail } from "@server/emails"; @@ -31,48 +30,6 @@ export type ChangePasswordResponse = { codeRequested?: boolean; }; -async function invalidateAllSessionsExceptCurrent( - userId: string, - currentSessionId: string -): Promise { - 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( req: Request, res: Response, diff --git a/server/routers/auth/deleteMyAccount.ts b/server/routers/auth/deleteMyAccount.ts index d03af56313..d45486946e 100644 --- a/server/routers/auth/deleteMyAccount.ts +++ b/server/routers/auth/deleteMyAccount.ts @@ -224,7 +224,7 @@ export async function deleteMyAccount( } }); - calculateUserClientsForOrgs(userId, primaryDb).catch((e) => { + calculateUserClientsForOrgs(userId).catch((e) => { logger.error( `Failed to calculate user clients after deleting account for user ${userId}: ${e}` ); diff --git a/server/routers/auth/verifyTotp.ts b/server/routers/auth/verifyTotp.ts index 207287ea00..5fc6cf13c5 100644 --- a/server/routers/auth/verifyTotp.ts +++ b/server/routers/auth/verifyTotp.ts @@ -15,6 +15,10 @@ import TwoFactorAuthNotification from "@server/emails/templates/TwoFactorAuthNot import config from "@server/lib/config"; import { UserType } from "@server/types/UserTypes"; import { generateBackupCodes } from "@server/lib/totp"; +import { + invalidateAllSessions, + invalidateAllSessionsExceptCurrent +} from "@server/auth/sessions/app"; import { verifySession } from "@server/auth/sessions/verifySession"; import { unauthorized } from "@server/auth/unauthorizedResponse"; @@ -168,6 +172,15 @@ export async function verifyTotp( ); } + if (existingSession) { + await invalidateAllSessionsExceptCurrent( + user.userId, + existingSession.sessionId + ); + } else { + await invalidateAllSessions(user.userId); + } + sendEmail( TwoFactorAuthNotification({ email: user.email!, diff --git a/server/routers/client/createClient.ts b/server/routers/client/createClient.ts index ecda098c5b..bef47245dc 100644 --- a/server/routers/client/createClient.ts +++ b/server/routers/client/createClient.ts @@ -280,13 +280,11 @@ export async function createClient( }); if (newClient) { - rebuildClientAssociationsFromClient(newClient, primaryDb).catch( - (e) => { - logger.error( - `Failed to rebuild client associations after creating client: ${e}` - ); - } - ); + rebuildClientAssociationsFromClient(newClient).catch((e) => { + logger.error( + `Failed to rebuild client associations after creating client: ${e}` + ); + }); } return response(res, { diff --git a/server/routers/client/createUserClient.ts b/server/routers/client/createUserClient.ts index 09bec218a2..3c7d850181 100644 --- a/server/routers/client/createUserClient.ts +++ b/server/routers/client/createUserClient.ts @@ -255,13 +255,11 @@ export async function createUserClient( }); if (newClient) { - rebuildClientAssociationsFromClient(newClient, primaryDb).catch( - (e) => { - logger.error( - `Failed to rebuild client associations after creating user client: ${e}` - ); - } - ); + rebuildClientAssociationsFromClient(newClient).catch((e) => { + logger.error( + `Failed to rebuild client associations after creating user client: ${e}` + ); + }); } return response(res, { diff --git a/server/routers/client/deleteClient.ts b/server/routers/client/deleteClient.ts index 24ab9917a6..62765c2c11 100644 --- a/server/routers/client/deleteClient.ts +++ b/server/routers/client/deleteClient.ts @@ -109,13 +109,11 @@ export async function deleteClient( }); if (deletedClient) { - rebuildClientAssociationsFromClient(deletedClient, primaryDb).catch( - (e) => { - logger.error( - `Failed to rebuild client associations after deleting client ${clientId}: ${e}` - ); - } - ); + rebuildClientAssociationsFromClient(deletedClient).catch((e) => { + logger.error( + `Failed to rebuild client associations after deleting client ${clientId}: ${e}` + ); + }); if (olm) { sendTerminateClient( deletedClient.clientId, diff --git a/server/routers/client/listClients.ts b/server/routers/client/listClients.ts index 9178c27a59..98c0fc5506 100644 --- a/server/routers/client/listClients.ts +++ b/server/routers/client/listClients.ts @@ -41,7 +41,7 @@ const listClientsParamsSchema = z.strictObject({ orgId: z.string() }); -const listClientsSchema = z.object({ +const listClientsSchema = z.strictObject({ pageSize: z.coerce .number() // for prettier formatting .int() diff --git a/server/routers/client/listUserDevices.ts b/server/routers/client/listUserDevices.ts index 5a864f93b9..fb30049214 100644 --- a/server/routers/client/listUserDevices.ts +++ b/server/routers/client/listUserDevices.ts @@ -40,7 +40,7 @@ const listUserDevicesParamsSchema = z.strictObject({ orgId: z.string() }); -const listUserDevicesSchema = z.object({ +const listUserDevicesSchema = z.strictObject({ pageSize: z.coerce .number() // for prettier formatting .int() @@ -420,31 +420,6 @@ 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(res, { data: { devices: olmsWithUpdates, diff --git a/server/routers/client/rebuildClientAssociationsCacheRoute.ts b/server/routers/client/rebuildClientAssociationsCacheRoute.ts index 32a6a407ad..86cb5c4855 100644 --- a/server/routers/client/rebuildClientAssociationsCacheRoute.ts +++ b/server/routers/client/rebuildClientAssociationsCacheRoute.ts @@ -60,13 +60,17 @@ export async function rebuildClientAssociationsCacheRoute( ); } - await rebuildClientAssociationsFromClient(client); + rebuildClientAssociationsFromClient(client).catch((e) => { + logger.error( + `Failed to rebuild client associations for client ${clientId}: ${e}` + ); + }); return response(res, { data: null, success: true, error: false, - message: "Client association cache rebuilt successfully", + message: "Client association cache queued successfully", status: HttpCode.OK }); } catch (error) { diff --git a/server/routers/client/targets.ts b/server/routers/client/targets.ts index c208acd889..543293a106 100644 --- a/server/routers/client/targets.ts +++ b/server/routers/client/targets.ts @@ -1,4 +1,4 @@ -import { sendToClient } from "#dynamic/routers/ws"; +import { sendToClient, sendToClientsBatch } from "#dynamic/routers/ws"; import { db, newts, olms } from "@server/db"; import { Alias, @@ -8,7 +8,7 @@ import { } from "@server/lib/ip"; import { canCompress } from "@server/lib/clientVersionChecks"; import logger from "@server/logger"; -import { eq } from "drizzle-orm"; +import { eq, inArray } from "drizzle-orm"; import semver from "semver"; const NEWT_V2_TARGETS_VERSION = ">=1.10.3"; @@ -59,6 +59,42 @@ 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( newtId: string, targets: SubnetProxyTarget[] | SubnetProxyTargetV2[], @@ -76,6 +112,42 @@ 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( newtId: string, targets: { @@ -201,6 +273,235 @@ 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(); + + 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( clientId: number, siteId: number, diff --git a/server/routers/idp/validateOidcCallback.ts b/server/routers/idp/validateOidcCallback.ts index 6a82f2c126..f6cb656b31 100644 --- a/server/routers/idp/validateOidcCallback.ts +++ b/server/routers/idp/validateOidcCallback.ts @@ -635,7 +635,7 @@ export async function validateOidcCallback( } }); - calculateUserClientsForOrgs(userId!, primaryDb).catch((err) => { + calculateUserClientsForOrgs(userId!).catch((err) => { logger.error( "Error calculating user clients after syncing orgs and roles for OIDC user", { error: err } diff --git a/server/routers/newt/getNewtVersion.ts b/server/routers/newt/getNewtVersion.ts index 8a76bc3d2c..b36ec8c164 100644 --- a/server/routers/newt/getNewtVersion.ts +++ b/server/routers/newt/getNewtVersion.ts @@ -10,7 +10,7 @@ import { verifyPassword } from "@server/auth/password"; import response from "@server/lib/response"; import HttpCode from "@server/types/HttpCode"; import logger from "@server/logger"; -import cache from "#dynamic/lib/cache"; +import { regionalCache as cache } from "#dynamic/lib/cache"; import config from "@server/lib/config"; // Stale-while-revalidate in-memory fallback for the releases API. diff --git a/server/routers/newt/handleNewtGetConfigMessage.ts b/server/routers/newt/handleNewtGetConfigMessage.ts index ff5d837999..fd5e2b42e5 100644 --- a/server/routers/newt/handleNewtGetConfigMessage.ts +++ b/server/routers/newt/handleNewtGetConfigMessage.ts @@ -9,6 +9,7 @@ import { buildClientConfigurationForNewtClient } from "./buildConfiguration"; import { convertTargetsIfNecessary } from "../client/targets"; import { canCompress } from "@server/lib/clientVersionChecks"; import config from "@server/lib/config"; +import { waitForSiteRebuildIdle } from "@server/lib/rebuildClientAssociations"; export const handleNewtGetConfigMessage: MessageHandler = async (context) => { const { message, client, sendToClient } = context; @@ -61,6 +62,8 @@ export const handleNewtGetConfigMessage: MessageHandler = async (context) => { return; } + await waitForSiteRebuildIdle(siteId); + // update the endpoint and the public key const [site] = await db .update(sites) diff --git a/server/routers/newt/handleNewtPingMessage.ts b/server/routers/newt/handleNewtPingMessage.ts index 56b8a2a241..c56c5f6d4b 100644 --- a/server/routers/newt/handleNewtPingMessage.ts +++ b/server/routers/newt/handleNewtPingMessage.ts @@ -49,20 +49,22 @@ export const handleNewtPingMessage: MessageHandler = async (context) => { `Newt ping with outdated config version: ${message.configVersion} (current: ${configVersion})` ); - const [site] = await db - .select() - .from(sites) - .where(eq(sites.siteId, newt.siteId)) - .limit(1); + // TODO: IMPLEMENT THE SYNC ON THE NEWT SIDE AND COMMENT THIS BACK IN - if (!site) { - logger.warn( - `Newt ping message: site with ID ${newt.siteId} not found` - ); - return; - } + // const [site] = await db + // .select() + // .from(sites) + // .where(eq(sites.siteId, newt.siteId)) + // .limit(1); - await sendNewtSyncMessage(newt, site); + // if (!site) { + // logger.warn( + // `Newt ping message: site with ID ${newt.siteId} not found` + // ); + // return; + // } + + // await sendNewtSyncMessage(newt, site); } return { diff --git a/server/routers/newt/handleSocketMessages.ts b/server/routers/newt/handleSocketMessages.ts index 5d5497ee11..634dbb6a99 100644 --- a/server/routers/newt/handleSocketMessages.ts +++ b/server/routers/newt/handleSocketMessages.ts @@ -2,7 +2,7 @@ import { MessageHandler } from "@server/routers/ws"; import logger from "@server/logger"; import { Newt } from "@server/db"; import { applyNewtDockerBlueprint } from "@server/lib/blueprints/applyNewtDockerBlueprint"; -import cache from "#dynamic/lib/cache"; +import cache from "#dynamic/lib/cache"; // not using regional here because we dont know where the site is export const handleDockerStatusMessage: MessageHandler = async (context) => { const { message, client, sendToClient } = context; diff --git a/server/routers/newt/peers.ts b/server/routers/newt/peers.ts index 4b74d863df..6c38671f30 100644 --- a/server/routers/newt/peers.ts +++ b/server/routers/newt/peers.ts @@ -1,7 +1,7 @@ import { db, Site } from "@server/db"; import { newts, sites } from "@server/db"; import { eq } from "drizzle-orm"; -import { sendToClient } from "#dynamic/routers/ws"; +import { sendToClient, sendToClientsBatch } from "#dynamic/routers/ws"; import logger from "@server/logger"; export async function addPeer( @@ -36,10 +36,14 @@ export async function addPeer( newtId = newt.newtId; } - await sendToClient(newtId, { - type: "newt/wg/peer/add", - data: peer - }, { incrementConfigVersion: true }).catch((error) => { + await sendToClient( + newtId, + { + type: "newt/wg/peer/add", + data: peer + }, + { incrementConfigVersion: true } + ).catch((error) => { logger.warn(`Error sending message:`, error); }); @@ -76,12 +80,16 @@ export async function deletePeer( newtId = newt.newtId; } - await sendToClient(newtId, { - type: "newt/wg/peer/remove", - data: { - publicKey - } - }, { incrementConfigVersion: true }).catch((error) => { + await sendToClient( + newtId, + { + type: "newt/wg/peer/remove", + data: { + publicKey + } + }, + { incrementConfigVersion: true } + ).catch((error) => { logger.warn(`Error sending message:`, error); }); @@ -90,6 +98,35 @@ export async function deletePeer( 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( siteId: number, publicKey: string, @@ -122,13 +159,17 @@ export async function updatePeer( newtId = newt.newtId; } - await sendToClient(newtId, { - type: "newt/wg/peer/update", - data: { - publicKey, - ...peer - } - }, { incrementConfigVersion: true }).catch((error) => { + await sendToClient( + newtId, + { + type: "newt/wg/peer/update", + data: { + publicKey, + ...peer + } + }, + { incrementConfigVersion: true } + ).catch((error) => { logger.warn(`Error sending message:`, error); }); diff --git a/server/routers/olm/createUserOlm.ts b/server/routers/olm/createUserOlm.ts index 714fb4b35e..306317a0cd 100644 --- a/server/routers/olm/createUserOlm.ts +++ b/server/routers/olm/createUserOlm.ts @@ -104,7 +104,7 @@ export async function createUserOlm( dateCreated: moment().toISOString() }); - calculateUserClientsForOrgs(userId, primaryDb).catch((e) => { + calculateUserClientsForOrgs(userId).catch((e) => { console.error( "Error calculating user clients after creating olm:", e diff --git a/server/routers/olm/deleteUserOlm.ts b/server/routers/olm/deleteUserOlm.ts index 861a413d8e..addef32d9c 100644 --- a/server/routers/olm/deleteUserOlm.ts +++ b/server/routers/olm/deleteUserOlm.ts @@ -86,13 +86,11 @@ export async function deleteUserOlm( }); if (deletedClient) { - rebuildClientAssociationsFromClient(deletedClient, primaryDb).catch( - (e) => { - logger.error( - `Failed to rebuild client-site associations after deleting OLM ${olmId}: ${e}` - ); - } - ); + rebuildClientAssociationsFromClient(deletedClient).catch((e) => { + logger.error( + `Failed to rebuild client-site associations after deleting OLM ${olmId}: ${e}` + ); + }); sendTerminateClient( deletedClient.clientId, OlmErrorCodes.TERMINATED_DELETED, diff --git a/server/routers/olm/handleOlmRegisterMessage.ts b/server/routers/olm/handleOlmRegisterMessage.ts index 9fe09736f0..bef9938312 100644 --- a/server/routers/olm/handleOlmRegisterMessage.ts +++ b/server/routers/olm/handleOlmRegisterMessage.ts @@ -20,7 +20,8 @@ import { handleFingerprintInsertion } from "./fingerprintingUtils"; import { build } from "@server/build"; import { canCompress } from "@server/lib/clientVersionChecks"; import config from "@server/lib/config"; -import cache from "#dynamic/lib/cache"; +import cache from "#dynamic/lib/cache"; // not using regional here because we need this in the register message handler before we know where the client is +import { waitForClientRebuildIdle } from "@server/lib/rebuildClientAssociations"; const HOLEPUNCH_STALE_CHAIN_THRESHOLD = 18; const HOLEPUNCH_STALE_CHAIN_TTL_SECONDS = 1800; @@ -385,6 +386,8 @@ export const handleOlmRegisterMessage: MessageHandler = async (context) => { } // NOTE: its important that the client here is the old client and the public key is the new key + await waitForClientRebuildIdle(olm.clientId); + const siteConfigurations = await buildSiteConfigurationForOlmClient( client, publicKey, diff --git a/server/routers/olm/listUserOlms.ts b/server/routers/olm/listUserOlms.ts index b2db262e69..5549afc9f9 100644 --- a/server/routers/olm/listUserOlms.ts +++ b/server/routers/olm/listUserOlms.ts @@ -11,7 +11,7 @@ import logger from "@server/logger"; import { OpenAPITags, registry } from "@server/openApi"; import { getUserDeviceName } from "@server/db/names"; -const querySchema = z.object({ +const querySchema = z.strictObject({ limit: z .string() .optional() diff --git a/server/routers/olm/peers.ts b/server/routers/olm/peers.ts index 05e153feae..962d7367ea 100644 --- a/server/routers/olm/peers.ts +++ b/server/routers/olm/peers.ts @@ -1,9 +1,9 @@ -import { sendToClient } from "#dynamic/routers/ws"; +import { sendToClient, sendToClientsBatch } from "#dynamic/routers/ws"; import { clientSitesAssociationsCache, db, olms } from "@server/db"; import { canCompress } from "@server/lib/clientVersionChecks"; import config from "@server/lib/config"; import logger from "@server/logger"; -import { and, eq } from "drizzle-orm"; +import { and, eq, inArray } from "drizzle-orm"; import { Alias } from "yaml"; export async function addPeer( @@ -205,3 +205,150 @@ export async function initPeerAddHandshake( `Initiated peer add handshake for site ${peer.siteId} to olm ${olmId}` ); } + +export async function deletePeersBatch( + peers: { + clientId: number; + siteId: number; + publicKey: string; + olmId?: string; + version?: string | null; + }[] +) { + if (peers.length === 0) { + return; + } + + const unresolvedClientIds = peers + .filter((peer) => !peer.olmId) + .map((peer) => peer.clientId); + + const olmByClientId = 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) { + olmByClientId.set(row.clientId, { + olmId: row.olmId, + version: row.version + }); + } + } + } + + const batchPayloads = peers + .map((peer) => { + const resolved = peer.olmId + ? { olmId: peer.olmId, version: peer.version ?? null } + : olmByClientId.get(peer.clientId); + if (!resolved) { + return null; + } + + return { + clientId: resolved.olmId, + message: { + type: "olm/wg/peer/remove", + data: { + publicKey: peer.publicKey, + siteId: peer.siteId + } + }, + options: { + incrementConfigVersion: true, + compress: canCompress( + peer.version ?? resolved.version, + "olm" + ) + } + }; + }) + .filter((payload) => payload !== null); + + if (batchPayloads.length === 0) { + return; + } + + await sendToClientsBatch(batchPayloads).catch((error) => { + logger.warn(`Error sending batched olm peer removals:`, error); + }); + + logger.info(`Deleted ${batchPayloads.length} peer(s) from olms (batch)`); +} + +export async function initPeerAddHandshakeBatch( + handshakes: { + clientId: number; + peer: { + siteId: number; + exitNode: { + publicKey: string; + endpoint: string; + }; + }; + olmId: string; + chainId?: string; + }[] +) { + if (handshakes.length === 0) { + return; + } + + await sendToClientsBatch( + handshakes.map((item) => ({ + clientId: item.olmId, + message: { + type: "olm/wg/peer/holepunch/site/add", + data: { + siteId: item.peer.siteId, + exitNode: { + publicKey: item.peer.exitNode.publicKey, + relayPort: + config.getRawConfig().gerbil.clients_start_port, + endpoint: item.peer.exitNode.endpoint + }, + chainId: item.chainId + } + }, + options: { incrementConfigVersion: true } + })) + ).catch((error) => { + logger.warn(`Error sending batched olm handshakes:`, error); + }); + + await Promise.all( + handshakes.map((item) => + db + .update(clientSitesAssociationsCache) + .set({ isJitMode: false }) + .where( + and( + eq( + clientSitesAssociationsCache.clientId, + item.clientId + ), + eq( + clientSitesAssociationsCache.siteId, + item.peer.siteId + ) + ) + ) + ) + ); + + logger.info( + `Initiated ${handshakes.length} peer add handshake(s) to olms (batch)` + ); +} diff --git a/server/routers/org/createOrg.ts b/server/routers/org/createOrg.ts index 7b2b1f87aa..35466ebc01 100644 --- a/server/routers/org/createOrg.ts +++ b/server/routers/org/createOrg.ts @@ -1,6 +1,6 @@ import { Request, Response, NextFunction } from "express"; import { z } from "zod"; -import { db } from "@server/db"; +import { db, primaryDb } from "@server/db"; import { and, count, eq } from "drizzle-orm"; import { domains, @@ -233,6 +233,7 @@ export async function createOrg( let error = ""; let org: Org | null = null; let numOrgs: number | null = null; + let ownerUserId: string | null = null; await db.transaction(async (trx) => { const allDomains = await trx @@ -326,7 +327,6 @@ export async function createOrg( ); } - let ownerUserId: string | null = null; if (req.user) { await trx.insert(userOrgs).values({ userId: req.user!.userId, @@ -382,8 +382,6 @@ export async function createOrg( })) ); - await calculateUserClientsForOrgs(ownerUserId, trx); - if (billingOrgIdForNewOrg) { const [numOrgsResult] = await trx .select({ count: count() }) @@ -396,6 +394,14 @@ export async function createOrg( } }); + if (ownerUserId) { + calculateUserClientsForOrgs(ownerUserId).catch((e) => { + logger.error( + `Failed to calculate user clients after creating org ${orgId} for user ${ownerUserId}: ${e}` + ); + }); + } + if (!org) { return next( createHttpError( diff --git a/server/routers/org/listOrgs.ts b/server/routers/org/listOrgs.ts index 336592fd5e..88c05f61a1 100644 --- a/server/routers/org/listOrgs.ts +++ b/server/routers/org/listOrgs.ts @@ -11,7 +11,7 @@ import { fromZodError } from "zod-validation-error"; import { OpenAPITags, registry } from "@server/openApi"; import { createApiResponseSchema } from "@server/lib/openapi/createApiResponseSchema"; -const listOrgsSchema = z.object({ +const listOrgsSchema = z.strictObject({ limit: z .string() .optional() diff --git a/server/routers/org/listUserOrgs.ts b/server/routers/org/listUserOrgs.ts index c48f2fa91e..47d5409308 100644 --- a/server/routers/org/listUserOrgs.ts +++ b/server/routers/org/listUserOrgs.ts @@ -14,7 +14,7 @@ const listOrgsParamsSchema = z.object({ userId: z.string() }); -const listOrgsSchema = z.object({ +const listOrgsSchema = z.strictObject({ limit: z .string() .optional() diff --git a/server/routers/resource/deleteResource.ts b/server/routers/resource/deleteResource.ts index a959611ecd..766b25b041 100644 --- a/server/routers/resource/deleteResource.ts +++ b/server/routers/resource/deleteResource.ts @@ -1,13 +1,4 @@ -import { eq, inArray } from "drizzle-orm"; -import { - db, - newts, - resourcePolicies, - resources, - sites, - targetHealthCheck, - targets -} from "@server/db"; +import { db } from "@server/db"; import response from "@server/lib/response"; import logger from "@server/logger"; import { OpenAPITags, registry } from "@server/openApi"; @@ -16,9 +7,11 @@ import { NextFunction, Request, Response } from "express"; import createHttpError from "http-errors"; import { z } from "zod"; import { fromError } from "zod-validation-error"; -import { removeTargets } from "../newt/targets"; +import { + performDeleteResource, + runResourceDeleteSideEffects +} from "@server/lib/deleteResource"; -// Define Zod schema for request parameters validation const deleteResourceSchema = z.strictObject({ resourceId: z.coerce.number().int().positive() }); @@ -67,27 +60,13 @@ export async function deleteResource( const { resourceId } = parsedParams.data; - const targetsToBeRemoved = await db - .select() - .from(targets) - .where(eq(targets.resourceId, resourceId)); - - const healthChecksToBeRemoved = await db - .select() - .from(targetHealthCheck) - .where( - inArray( - targetHealthCheck.targetId, - targetsToBeRemoved.map((t) => t.targetId) - ) - ); + let deleteResult = null; - const [deletedResource] = await db - .delete(resources) - .where(eq(resources.resourceId, resourceId)) - .returning(); + await db.transaction(async (trx) => { + deleteResult = await performDeleteResource(resourceId, trx); + }); - if (!deletedResource) { + if (!deleteResult) { return next( createHttpError( HttpCode.NOT_FOUND, @@ -96,54 +75,7 @@ export async function deleteResource( ); } - for (const target of targetsToBeRemoved) { - const [site] = await db - .select() - .from(sites) - .where(eq(sites.siteId, target.siteId)) - .limit(1); - - if (!site) { - return next( - createHttpError( - HttpCode.NOT_FOUND, - `Site with ID ${target.siteId} not found` - ) - ); - } - - if (site.pubKey) { - if (site.type == "newt") { - // get the newt on the site by querying the newt table for siteId - const [newt] = await db - .select() - .from(newts) - .where(eq(newts.siteId, site.siteId)) - .limit(1); - - await removeTargets( - newt.newtId, - // [target], - [], // deleting the target from newt causes issues because we cant unbind the port. this needs to be fixed in newt before we can do this - healthChecksToBeRemoved, - deletedResource.mode === "udp" ? "udp" : "tcp", - newt.version - ); - } - } - } - - // Also delete default resource policy - if (deletedResource.defaultResourcePolicyId) { - await db - .delete(resourcePolicies) - .where( - eq( - resourcePolicies.resourcePolicyId, - deletedResource.defaultResourcePolicyId - ) - ); - } + await runResourceDeleteSideEffects(deleteResult); return response(res, { data: null, @@ -154,6 +86,9 @@ export async function deleteResource( }); } catch (error) { logger.error(error); + if (createHttpError.isHttpError(error)) { + return next(error); + } return next( createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred") ); diff --git a/server/routers/resource/getExchangeToken.ts b/server/routers/resource/getExchangeToken.ts index 23151d534e..0561fadbf1 100644 --- a/server/routers/resource/getExchangeToken.ts +++ b/server/routers/resource/getExchangeToken.ts @@ -80,8 +80,7 @@ export async function getExchangeToken( return next( createHttpError( HttpCode.FORBIDDEN, - "Failed organization access policy check: " + - (hasAccess.error || "Unknown error") + "" + (hasAccess.error || "Unknown error") ) ); } diff --git a/server/routers/resource/listResourceRules.ts b/server/routers/resource/listResourceRules.ts index 6b9df688a5..ec4cc332e5 100644 --- a/server/routers/resource/listResourceRules.ts +++ b/server/routers/resource/listResourceRules.ts @@ -14,7 +14,7 @@ const listResourceRulesParamsSchema = z.strictObject({ resourceId: z.coerce.number().int().positive() }); -const listResourceRulesSchema = z.object({ +const listResourceRulesSchema = z.strictObject({ limit: z .string() .optional() diff --git a/server/routers/resource/listResources.ts b/server/routers/resource/listResources.ts index 684c48159b..f15a3bedae 100644 --- a/server/routers/resource/listResources.ts +++ b/server/routers/resource/listResources.ts @@ -48,7 +48,7 @@ const listResourcesParamsSchema = z.strictObject({ orgId: z.string() }); -const listResourcesSchema = z.object({ +const listResourcesSchema = z.strictObject({ pageSize: z.coerce .number() // for prettier formatting .int() diff --git a/server/routers/resource/listUserResourceAliases.ts b/server/routers/resource/listUserResourceAliases.ts index d6e02b5228..75dc91166b 100644 --- a/server/routers/resource/listUserResourceAliases.ts +++ b/server/routers/resource/listUserResourceAliases.ts @@ -15,8 +15,7 @@ import logger from "@server/logger"; import { z } from "zod"; import { fromZodError } from "zod-validation-error"; import type { PaginatedResponse } from "@server/types/Pagination"; -import { OpenAPITags, registry } from "@server/openApi"; -import { localCache } from "#dynamic/lib/cache"; +import { regionalCache as cache } from "#dynamic/lib/cache"; const USER_RESOURCE_ALIASES_CACHE_TTL_SEC = 60; @@ -33,7 +32,7 @@ const listUserResourceAliasesParamsSchema = z.strictObject({ orgId: z.string() }); -const listUserResourceAliasesQuerySchema = z.object({ +const listUserResourceAliasesQuerySchema = z.strictObject({ pageSize: z.coerce .number() .int() @@ -153,7 +152,7 @@ export async function listUserResourceAliases( pageSize ); const cachedData: ListUserResourceAliasesResponse | undefined = - localCache.get(cacheKey); + await cache.get(cacheKey); if (cachedData) { return response(res, { @@ -211,7 +210,11 @@ export async function listUserResourceAliases( page } }; - localCache.set(cacheKey, data, USER_RESOURCE_ALIASES_CACHE_TTL_SEC); + await cache.set( + cacheKey, + data, + USER_RESOURCE_ALIASES_CACHE_TTL_SEC + ); return response(res, { data, success: true, @@ -256,7 +259,7 @@ export async function listUserResourceAliases( page } }; - localCache.set(cacheKey, data, USER_RESOURCE_ALIASES_CACHE_TTL_SEC); + await cache.set(cacheKey, data, USER_RESOURCE_ALIASES_CACHE_TTL_SEC); return response(res, { data, diff --git a/server/routers/resource/updateResourceRule.ts b/server/routers/resource/updateResourceRule.ts index cc2a6fc035..84afb38b6d 100644 --- a/server/routers/resource/updateResourceRule.ts +++ b/server/routers/resource/updateResourceRule.ts @@ -1,8 +1,8 @@ import { Request, Response, NextFunction } from "express"; import { z } from "zod"; import { db } from "@server/db"; -import { resourceRules, resources } from "@server/db"; -import { eq } from "drizzle-orm"; +import { resourcePolicyRules, resourceRules, resources } from "@server/db"; +import { and, eq } from "drizzle-orm"; import response from "@server/lib/response"; import HttpCode from "@server/types/HttpCode"; import createHttpError from "http-errors"; @@ -22,13 +22,20 @@ const updateResourceRuleParamsSchema = z.strictObject({ resourceId: z.coerce.number().int().positive() }); +const resourceRuleMatchSchema = z.enum([ + "CIDR", + "IP", + "PATH", + "COUNTRY", + "ASN", + "REGION" +]); + // Define Zod schema for request body validation const updateResourceRuleSchema = z .strictObject({ action: z.enum(["ACCEPT", "DROP", "PASS"]).optional(), - match: z - .enum(["CIDR", "IP", "PATH", "COUNTRY", "ASN", "REGION"]) - .optional(), + match: resourceRuleMatchSchema.optional(), value: z.string().min(1).optional(), priority: z.int(), enabled: z.boolean().optional() @@ -123,37 +130,102 @@ export async function updateResourceRule( return next( createHttpError( HttpCode.BAD_REQUEST, - "Cannot create rule for non-http resource" + "Cannot update rule for non-http resource" ) ); } - // Verify that the rule exists and belongs to the specified resource - const [existingRule] = await db - .select() - .from(resourceRules) - .where(eq(resourceRules.ruleId, ruleId)) - .limit(1); + const isInlinePolicy = + resource.resourcePolicyId === null && + resource.defaultResourcePolicyId !== null; - if (!existingRule) { - return next( - createHttpError( - HttpCode.NOT_FOUND, - `Resource rule with ID ${ruleId} not found` - ) + let existingMatch: + | "CIDR" + | "IP" + | "PATH" + | "COUNTRY" + | "ASN" + | "REGION"; + + if (isInlinePolicy) { + const policyId = resource.defaultResourcePolicyId!; + const [existingRule] = await db + .select() + .from(resourcePolicyRules) + .where(eq(resourcePolicyRules.ruleId, ruleId)) + .limit(1); + + if (!existingRule) { + return next( + createHttpError( + HttpCode.NOT_FOUND, + `Resource rule with ID ${ruleId} not found` + ) + ); + } + + if (existingRule.resourcePolicyId !== policyId) { + return next( + createHttpError( + HttpCode.FORBIDDEN, + `Resource rule ${ruleId} does not belong to resource ${resourceId}` + ) + ); + } + + const parsedExistingMatch = resourceRuleMatchSchema.safeParse( + existingRule.match ); - } + if (!parsedExistingMatch.success) { + return next( + createHttpError( + HttpCode.INTERNAL_SERVER_ERROR, + "Resource rule has invalid match type" + ) + ); + } + existingMatch = parsedExistingMatch.data; + } else { + // Verify that the rule exists and belongs to the specified resource + const [existingRule] = await db + .select() + .from(resourceRules) + .where(eq(resourceRules.ruleId, ruleId)) + .limit(1); - if (existingRule.resourceId !== resourceId) { - return next( - createHttpError( - HttpCode.FORBIDDEN, - `Resource rule ${ruleId} does not belong to resource ${resourceId}` - ) + if (!existingRule) { + return next( + createHttpError( + HttpCode.NOT_FOUND, + `Resource rule with ID ${ruleId} not found` + ) + ); + } + + if (existingRule.resourceId !== resourceId) { + return next( + createHttpError( + HttpCode.FORBIDDEN, + `Resource rule ${ruleId} does not belong to resource ${resourceId}` + ) + ); + } + + const parsedExistingMatch = resourceRuleMatchSchema.safeParse( + existingRule.match ); + if (!parsedExistingMatch.success) { + return next( + createHttpError( + HttpCode.INTERNAL_SERVER_ERROR, + "Resource rule has invalid match type" + ) + ); + } + existingMatch = parsedExistingMatch.data; } - const match = updateData.match || existingRule.match; + const match = updateData.match || existingMatch; const { value } = updateData; if (value !== undefined) { @@ -197,11 +269,30 @@ export async function updateResourceRule( } // Update the rule - const [updatedRule] = await db - .update(resourceRules) - .set(updateData) - .where(eq(resourceRules.ruleId, ruleId)) - .returning(); + const [updatedRule] = isInlinePolicy + ? await db + .update(resourcePolicyRules) + .set(updateData) + .where( + and( + eq(resourcePolicyRules.ruleId, ruleId), + eq( + resourcePolicyRules.resourcePolicyId, + resource.defaultResourcePolicyId! + ) + ) + ) + .returning() + : await db + .update(resourceRules) + .set(updateData) + .where( + and( + eq(resourceRules.ruleId, ruleId), + eq(resourceRules.resourceId, resourceId) + ) + ) + .returning(); return response(res, { data: updatedRule, diff --git a/server/routers/role/listRoles.ts b/server/routers/role/listRoles.ts index 248db5063d..d59ced2f7c 100644 --- a/server/routers/role/listRoles.ts +++ b/server/routers/role/listRoles.ts @@ -15,7 +15,7 @@ const listRolesParamsSchema = z.strictObject({ orgId: z.string() }); -const listRolesSchema = z.object({ +const listRolesSchema = z.strictObject({ pageSize: z.coerce .number() // for prettier formatting .int() diff --git a/server/routers/site/deleteSite.ts b/server/routers/site/deleteSite.ts index 0773762118..300c570d8a 100644 --- a/server/routers/site/deleteSite.ts +++ b/server/routers/site/deleteSite.ts @@ -14,18 +14,41 @@ import { OpenAPITags, registry } from "@server/openApi"; import { cleanupSiteAssociations } from "@server/lib/rebuildClientAssociations"; import { usageService } from "@server/lib/billing/usageService"; import { FeatureId } from "@server/lib/billing"; +import { ActionsEnum, checkUserActionPermission } from "@server/auth/actions"; +import { + deleteAssociatedResourcesForSite, + exceedsSiteAssociatedResourceDeleteLimit, + getAssociatedResourceCountForSite, + runDeleteSiteAssociatedResourcesSideEffects, + MAX_SITE_ASSOCIATED_RESOURCES_FOR_BULK_DELETE, + type DeleteSiteAssociatedResourcesSideEffects +} from "@server/lib/deleteSiteAssociatedResources"; const deleteSiteSchema = z.strictObject({ siteId: z.coerce.number().int().positive() }); +const deleteSiteQuerySchema = z.strictObject({ + deleteResources: z + .enum(["true", "false"]) + .transform((v) => v === "true") + .optional() + .catch(false) + .openapi({ + type: "boolean", + description: + "When true, also deletes all public and private resources associated with this site" + }) +}); + registry.registerPath({ method: "delete", path: "/site/{siteId}", description: "Delete a site and all its associated data.", tags: [OpenAPITags.Site], request: { - params: deleteSiteSchema + params: deleteSiteSchema, + query: deleteSiteQuerySchema }, responses: { 200: { @@ -61,7 +84,18 @@ export async function deleteSite( ); } + const parsedQuery = deleteSiteQuerySchema.safeParse(req.query); + if (!parsedQuery.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedQuery.error).toString() + ) + ); + } + const { siteId } = parsedParams.data; + const { deleteResources } = parsedQuery.data; const [site] = await db .select() @@ -78,20 +112,67 @@ export async function deleteSite( ); } + if (deleteResources) { + const canDeletePublic = await checkUserActionPermission( + ActionsEnum.deleteResource, + req + ); + const canDeletePrivate = await checkUserActionPermission( + ActionsEnum.deleteSiteResource, + req + ); + + if (!canDeletePublic || !canDeletePrivate) { + return next( + createHttpError( + HttpCode.FORBIDDEN, + "User does not have permission to delete associated resources" + ) + ); + } + + const associatedResourceCount = + await getAssociatedResourceCountForSite(siteId, site.orgId); + + if ( + exceedsSiteAssociatedResourceDeleteLimit( + associatedResourceCount + ) + ) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + `Cannot delete site and associated resources when the site has more than ${MAX_SITE_ASSOCIATED_RESOURCES_FOR_BULK_DELETE} resources` + ) + ); + } + } + const [deletedNewt] = await db .select() .from(newts) .where(eq(newts.siteId, siteId)) .limit(1); + let resourceSideEffects: DeleteSiteAssociatedResourcesSideEffects = { + resources: [], + siteResources: [] + }; + await db.transaction(async (trx) => { + if (deleteResources) { + resourceSideEffects = await deleteAssociatedResourcesForSite( + siteId, + site.orgId, + trx + ); + } + if (site.type == "wireguard") { if (site.pubKey) { await deletePeer(site.exitNodeId!, site.pubKey); } } else if (site.type == "newt") { - // Clean up all client associations and send peer/proxy removal - // messages in a single efficient pass before deleting the row. await cleanupSiteAssociations(site, trx); } @@ -99,13 +180,17 @@ export async function deleteSite( await usageService.add(site.orgId, FeatureId.SITES, -1, trx); }); - // Send termination message outside of transaction to prevent blocking + if (deleteResources) { + await runDeleteSiteAssociatedResourcesSideEffects( + resourceSideEffects + ); + } + if (deletedNewt) { const payload = { type: `newt/wg/terminate`, data: {} }; - // Don't await this to prevent blocking the response sendToClient(deletedNewt.newtId, payload).catch((error) => { logger.error( "Failed to send termination message to newt:", @@ -123,6 +208,9 @@ export async function deleteSite( }); } catch (error) { logger.error(error); + if (createHttpError.isHttpError(error)) { + return next(error); + } return next( createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred") ); diff --git a/server/routers/site/getSite.ts b/server/routers/site/getSite.ts index a671a47f95..885e3aa7a4 100644 --- a/server/routers/site/getSite.ts +++ b/server/routers/site/getSite.ts @@ -10,6 +10,7 @@ import logger from "@server/logger"; import stoi from "@server/lib/stoi"; import { fromError } from "zod-validation-error"; import { OpenAPITags, registry } from "@server/openApi"; +import { getCountryCodeForIp } from "@server/lib/geoip"; const getSiteSchema = z.strictObject({ siteId: z @@ -47,6 +48,7 @@ type SiteQueryRow = NonNullable>>; export type GetSiteResponse = SiteQueryRow["sites"] & { newtId: string | null; newtVersion: string | null; + countryCode: string | null; }; registry.registerPath({ @@ -134,7 +136,10 @@ export async function getSite( const data: GetSiteResponse = { ...site.sites, newtId: site.newt ? site.newt.newtId : null, - newtVersion: site.newt?.version ?? null + newtVersion: site.newt?.version ?? null, + countryCode: site.sites.endpoint + ? ((await getCountryCodeForIp(site.sites.endpoint)) ?? null) + : null }; return response(res, { diff --git a/server/routers/site/listSites.ts b/server/routers/site/listSites.ts index 86699feafc..86c555f936 100644 --- a/server/routers/site/listSites.ts +++ b/server/routers/site/listSites.ts @@ -1,20 +1,21 @@ import { db, exitNodes, + labels, newts, orgs, remoteExitNodes, roleSites, + siteLabels, siteNetworks, siteResources, - targets, sites, + targets, userSites, - labels, - siteLabels, type Label } from "@server/db"; -import cache from "#dynamic/lib/cache"; +import { regionalCache as cache } from "#dynamic/lib/cache"; +import { tierMatrix } from "@server/lib/billing/tierMatrix"; import response from "@server/lib/response"; import logger from "@server/logger"; import { OpenAPITags, registry } from "@server/openApi"; @@ -23,108 +24,15 @@ import type { PaginatedResponse } from "@server/types/Pagination"; import { and, asc, desc, eq, inArray, like, or, sql } from "drizzle-orm"; import { NextFunction, Request, Response } from "express"; import createHttpError from "http-errors"; -import semver from "semver"; import { z } from "zod"; import { fromError } from "zod-validation-error"; import { isLicensedOrSubscribed } from "#dynamic/lib/isLicencedOrSubscribed"; -import { tierMatrix } from "@server/lib/billing/tierMatrix"; - -// Stale-while-revalidate: keeps the last successfully fetched version so that -// a transient network failure / timeout does not flip every site back to -// newtUpdateAvailable: false. -let staleNewtVersion: string | null = null; - -async function getLatestNewtVersion(): Promise { - try { - const cachedVersion = await cache.get( - "cache:latestNewtVersion" - ); - if (cachedVersion) { - return cachedVersion; - } - - const controller = new AbortController(); - const timeoutId = setTimeout(() => controller.abort(), 1500); - - const response = await fetch( - "https://api.github.com/repos/fosrl/newt/tags", - { - signal: controller.signal - } - ); - - clearTimeout(timeoutId); - - if (!response.ok) { - logger.warn( - `Failed to fetch latest Newt version from GitHub: ${response.status} ${response.statusText}` - ); - return staleNewtVersion; - } - - let tags = await response.json(); - if (!Array.isArray(tags) || tags.length === 0) { - logger.warn("No tags found for Newt repository"); - return staleNewtVersion; - } - - // Remove release-candidates, then sort descending by semver so that - // duplicate tags (e.g. "1.10.3" and "v1.10.3") and any ordering quirks - // from the GitHub API do not cause an older tag to be selected. - tags = tags.filter((tag: any) => !tag.name.includes("rc")); - tags.sort((a: any, b: any) => { - const va = semver.coerce(a.name); - const vb = semver.coerce(b.name); - if (!va && !vb) return 0; - if (!va) return 1; - if (!vb) return -1; - return semver.rcompare(va, vb); - }); - - // Deduplicate: keep only the first (highest) entry per normalised version - const seen = new Set(); - tags = tags.filter((tag: any) => { - const normalised = semver.coerce(tag.name)?.version; - if (!normalised || seen.has(normalised)) return false; - seen.add(normalised); - return true; - }); - - if (tags.length === 0) { - logger.warn("No valid semver tags found for Newt repository"); - return staleNewtVersion; - } - - const latestVersion = tags[0].name; - - staleNewtVersion = latestVersion; - await cache.set("cache:latestNewtVersion", latestVersion, 3600); - - return latestVersion; - } catch (error: any) { - if (error.name === "AbortError") { - logger.warn( - "Request to fetch latest Newt version timed out (1.5s)" - ); - } else if (error.cause?.code === "UND_ERR_CONNECT_TIMEOUT") { - logger.warn( - "Connection timeout while fetching latest Newt version" - ); - } else { - logger.warn( - "Error fetching latest Newt version:", - error.message || error - ); - } - return staleNewtVersion; - } -} const listSitesParamsSchema = z.strictObject({ orgId: z.string() }); -const listSitesSchema = z.object({ +const listSitesSchema = z.strictObject({ pageSize: z.coerce .number() // for prettier formatting .int() @@ -449,9 +357,6 @@ export async function listSites( const totalCount = Number(countRows[0]?.count ?? 0); - // Get latest version asynchronously without blocking the response - const latestNewtVersionPromise = getLatestNewtVersion(); - const siteIds = rows.map((site) => site.siteId); let labelsForSites: Array<{ @@ -494,36 +399,6 @@ export async function listSites( return { ...siteWithUpdate, labels: labelsForSite }; }); - // Try to get the latest version, but don't block if it fails - try { - const latestNewtVersion = await latestNewtVersionPromise; - - if (latestNewtVersion) { - sitesWithUpdates.forEach((site) => { - if ( - site.type === "newt" && - site.newtVersion && - latestNewtVersion - ) { - try { - site.newtUpdateAvailable = semver.lt( - site.newtVersion, - latestNewtVersion - ); - } catch (error) { - site.newtUpdateAvailable = false; - } - } - }); - } - } catch (error) { - // Log the error but don't let it block the response - logger.warn( - "Failed to check for Newt updates, continuing without update info:", - error - ); - } - const sitesPayload = sitesWithUpdates.map((site) => site.type === "local" ? { ...site, online: undefined } : site ); diff --git a/server/routers/siteResource/addClientToSiteResource.ts b/server/routers/siteResource/addClientToSiteResource.ts index c43b755b22..4a6dd141ea 100644 --- a/server/routers/siteResource/addClientToSiteResource.ts +++ b/server/routers/siteResource/addClientToSiteResource.ts @@ -148,13 +148,15 @@ export async function addClientToSiteResource( ); } - await db.transaction(async (trx) => { - await trx.insert(clientSiteResources).values({ - clientId, - siteResourceId - }); + await db.insert(clientSiteResources).values({ + clientId, + siteResourceId + }); - await rebuildClientAssociationsFromSiteResource(siteResource, trx); + rebuildClientAssociationsFromSiteResource(siteResource).catch((e) => { + logger.error( + `Failed to rebuild client associations for site resource ${siteResourceId}. Error: ${e}` + ); }); return response(res, { diff --git a/server/routers/siteResource/addRoleToSiteResource.ts b/server/routers/siteResource/addRoleToSiteResource.ts index a7153b3e38..05186c3516 100644 --- a/server/routers/siteResource/addRoleToSiteResource.ts +++ b/server/routers/siteResource/addRoleToSiteResource.ts @@ -155,13 +155,15 @@ export async function addRoleToSiteResource( ); } - await db.transaction(async (trx) => { - await trx.insert(roleSiteResources).values({ - roleId, - siteResourceId - }); + await db.insert(roleSiteResources).values({ + roleId, + siteResourceId + }); - await rebuildClientAssociationsFromSiteResource(siteResource, trx); + rebuildClientAssociationsFromSiteResource(siteResource).catch((e) => { + logger.error( + `Failed to rebuild client associations for site resource ${siteResourceId}. Error: ${e}` + ); }); return response(res, { diff --git a/server/routers/siteResource/addUserToSiteResource.ts b/server/routers/siteResource/addUserToSiteResource.ts index 6300502af8..c35357993e 100644 --- a/server/routers/siteResource/addUserToSiteResource.ts +++ b/server/routers/siteResource/addUserToSiteResource.ts @@ -124,13 +124,15 @@ export async function addUserToSiteResource( ); } - await db.transaction(async (trx) => { - await trx.insert(userSiteResources).values({ - userId, - siteResourceId - }); + await db.insert(userSiteResources).values({ + userId, + siteResourceId + }); - await rebuildClientAssociationsFromSiteResource(siteResource, trx); + rebuildClientAssociationsFromSiteResource(siteResource).catch((e) => { + logger.error( + `Failed to rebuild client associations for site resource ${siteResourceId}. Error: ${e}` + ); }); return response(res, { diff --git a/server/routers/siteResource/batchAddClientToSiteResources.ts b/server/routers/siteResource/batchAddClientToSiteResources.ts index c8a8c90a67..1ebb3359d3 100644 --- a/server/routers/siteResource/batchAddClientToSiteResources.ts +++ b/server/routers/siteResource/batchAddClientToSiteResources.ts @@ -235,7 +235,7 @@ export async function batchAddClientToSiteResources( } }); - rebuildClientAssociationsFromClient(client, primaryDb).catch((e) => { + rebuildClientAssociationsFromClient(client).catch((e) => { logger.error( `Failed to rebuild client associations after batch adding site resources for client ${clientId}: ${e}` ); diff --git a/server/routers/siteResource/createSiteResource.ts b/server/routers/siteResource/createSiteResource.ts index 1eebbc01de..d0d018f84b 100644 --- a/server/routers/siteResource/createSiteResource.ts +++ b/server/routers/siteResource/createSiteResource.ts @@ -625,15 +625,14 @@ export async function createSiteResource( // own transaction so it always executes on the primary — avoiding any // replica-lag issues while still allowing the HTTP response to return // early. - rebuildClientAssociationsFromSiteResource( - newSiteResource!, - primaryDb - ).catch((err) => { - logger.error( - `Error rebuilding client associations for site resource ${newSiteResource!.siteResourceId}:`, - err - ); - }); + rebuildClientAssociationsFromSiteResource(newSiteResource!).catch( + (err) => { + logger.error( + `Error rebuilding client associations for site resource ${newSiteResource!.siteResourceId}:`, + err + ); + } + ); return response(res, { data: newSiteResource, diff --git a/server/routers/siteResource/deleteSiteResource.ts b/server/routers/siteResource/deleteSiteResource.ts index 8ff23405c5..cddeb490b9 100644 --- a/server/routers/siteResource/deleteSiteResource.ts +++ b/server/routers/siteResource/deleteSiteResource.ts @@ -1,15 +1,17 @@ import { Request, Response, NextFunction } from "express"; import { z } from "zod"; -import { db, newts, primaryDb, sites } from "@server/db"; -import { siteResources } from "@server/db"; +import { db, siteResources } from "@server/db"; import response from "@server/lib/response"; import HttpCode from "@server/types/HttpCode"; import createHttpError from "http-errors"; -import { eq, and } from "drizzle-orm"; +import { eq } from "drizzle-orm"; import { fromError } from "zod-validation-error"; import logger from "@server/logger"; import { OpenAPITags, registry } from "@server/openApi"; -import { rebuildClientAssociationsFromSiteResource } from "@server/lib/rebuildClientAssociations"; +import { + performDeleteSiteResource, + runSiteResourceDeleteSideEffects +} from "@server/lib/deleteSiteResource"; const deleteSiteResourceParamsSchema = z.strictObject({ siteResourceId: z.coerce.number().int().positive() @@ -65,11 +67,10 @@ export async function deleteSiteResource( const { siteResourceId } = parsedParams.data; - // Check if site resource exists const [existingSiteResource] = await db .select() .from(siteResources) - .where(and(eq(siteResources.siteResourceId, siteResourceId))) + .where(eq(siteResources.siteResourceId, siteResourceId)) .limit(1); if (!existingSiteResource) { @@ -78,27 +79,22 @@ export async function deleteSiteResource( ); } - // Delete the site resource - const [removedSiteResource] = await db - .delete(siteResources) - .where(eq(siteResources.siteResourceId, siteResourceId)) - .returning(); + let removedSiteResource = null; - // Run in the background after the response is sent. Wrapped in its - // own transaction so it always executes on the primary — avoiding any - // replica-lag issues while still allowing the HTTP response to return - // early. - rebuildClientAssociationsFromSiteResource( - removedSiteResource, - primaryDb - ).catch((err) => { - logger.error( - `Error rebuilding client associations for site resource ${removedSiteResource!.siteResourceId}:`, - err + await db.transaction(async (trx) => { + removedSiteResource = await performDeleteSiteResource( + siteResourceId, + trx ); }); - logger.info(`Deleted site resource ${siteResourceId}`); + if (!removedSiteResource) { + return next( + createHttpError(HttpCode.NOT_FOUND, "Site resource not found") + ); + } + + runSiteResourceDeleteSideEffects(removedSiteResource); return response(res, { data: { message: "Site resource deleted successfully" }, diff --git a/server/routers/siteResource/listAllSiteResourcesByOrg.ts b/server/routers/siteResource/listAllSiteResourcesByOrg.ts index 5c20bc5a75..721d76bf68 100644 --- a/server/routers/siteResource/listAllSiteResourcesByOrg.ts +++ b/server/routers/siteResource/listAllSiteResourcesByOrg.ts @@ -26,7 +26,7 @@ const listAllSiteResourcesByOrgParamsSchema = z.strictObject({ orgId: z.string() }); -const listAllSiteResourcesByOrgQuerySchema = z.object({ +const listAllSiteResourcesByOrgQuerySchema = z.strictObject({ pageSize: z.coerce .number() // for prettier formatting .int() diff --git a/server/routers/siteResource/listSiteResources.ts b/server/routers/siteResource/listSiteResources.ts index 311009dfa4..a9688a9c6c 100644 --- a/server/routers/siteResource/listSiteResources.ts +++ b/server/routers/siteResource/listSiteResources.ts @@ -15,7 +15,7 @@ const listSiteResourcesParamsSchema = z.strictObject({ orgId: z.string() }); -const listSiteResourcesQuerySchema = z.object({ +const listSiteResourcesQuerySchema = z.strictObject({ limit: z .string() .optional() diff --git a/server/routers/siteResource/removeClientFromSiteResource.ts b/server/routers/siteResource/removeClientFromSiteResource.ts index 35944ca15d..c53e214cd5 100644 --- a/server/routers/siteResource/removeClientFromSiteResource.ts +++ b/server/routers/siteResource/removeClientFromSiteResource.ts @@ -148,17 +148,19 @@ export async function removeClientFromSiteResource( ); } - await db.transaction(async (trx) => { - await trx - .delete(clientSiteResources) - .where( - and( - eq(clientSiteResources.siteResourceId, siteResourceId), - eq(clientSiteResources.clientId, clientId) - ) - ); - - await rebuildClientAssociationsFromSiteResource(siteResource, trx); + await db + .delete(clientSiteResources) + .where( + and( + eq(clientSiteResources.siteResourceId, siteResourceId), + eq(clientSiteResources.clientId, clientId) + ) + ); + + rebuildClientAssociationsFromSiteResource(siteResource).catch((e) => { + logger.error( + `Failed to rebuild client associations for site resource ${siteResourceId}. Error: ${e}` + ); }); return response(res, { diff --git a/server/routers/siteResource/removeRoleFromSiteResource.ts b/server/routers/siteResource/removeRoleFromSiteResource.ts index 2759a57e7b..6dd978e24e 100644 --- a/server/routers/siteResource/removeRoleFromSiteResource.ts +++ b/server/routers/siteResource/removeRoleFromSiteResource.ts @@ -156,17 +156,19 @@ export async function removeRoleFromSiteResource( ); } - await db.transaction(async (trx) => { - await trx - .delete(roleSiteResources) - .where( - and( - eq(roleSiteResources.siteResourceId, siteResourceId), - eq(roleSiteResources.roleId, roleId) - ) - ); - - await rebuildClientAssociationsFromSiteResource(siteResource, trx); + await db + .delete(roleSiteResources) + .where( + and( + eq(roleSiteResources.siteResourceId, siteResourceId), + eq(roleSiteResources.roleId, roleId) + ) + ); + + rebuildClientAssociationsFromSiteResource(siteResource).catch((e) => { + logger.error( + `Failed to rebuild client associations for site resource ${siteResourceId}. Error: ${e}` + ); }); return response(res, { diff --git a/server/routers/siteResource/removeUserFromSiteResource.ts b/server/routers/siteResource/removeUserFromSiteResource.ts index 473db41b53..67e6ac9606 100644 --- a/server/routers/siteResource/removeUserFromSiteResource.ts +++ b/server/routers/siteResource/removeUserFromSiteResource.ts @@ -126,17 +126,19 @@ export async function removeUserFromSiteResource( ); } - await db.transaction(async (trx) => { - await trx - .delete(userSiteResources) - .where( - and( - eq(userSiteResources.siteResourceId, siteResourceId), - eq(userSiteResources.userId, userId) - ) - ); + await db + .delete(userSiteResources) + .where( + and( + eq(userSiteResources.siteResourceId, siteResourceId), + eq(userSiteResources.userId, userId) + ) + ); - await rebuildClientAssociationsFromSiteResource(siteResource, trx); + rebuildClientAssociationsFromSiteResource(siteResource).catch((e) => { + logger.error( + `Failed to rebuild client associations for site resource ${siteResourceId} after removing user ${userId}: ${e}` + ); }); return response(res, { diff --git a/server/routers/siteResource/setSiteResourceClients.ts b/server/routers/siteResource/setSiteResourceClients.ts index 0f88f363fd..a4bc5b69e3 100644 --- a/server/routers/siteResource/setSiteResourceClients.ts +++ b/server/routers/siteResource/setSiteResourceClients.ts @@ -141,8 +141,12 @@ export async function setSiteResourceClients( })) ); } + }); - await rebuildClientAssociationsFromSiteResource(siteResource, trx); + rebuildClientAssociationsFromSiteResource(siteResource).catch((e) => { + logger.error( + `Failed to rebuild client associations for site resource ${siteResourceId}. Error: ${e}` + ); }); return response(res, { diff --git a/server/routers/siteResource/setSiteResourceRoles.ts b/server/routers/siteResource/setSiteResourceRoles.ts index e9878a320f..cad6da53b5 100644 --- a/server/routers/siteResource/setSiteResourceRoles.ts +++ b/server/routers/siteResource/setSiteResourceRoles.ts @@ -165,8 +165,12 @@ export async function setSiteResourceRoles( roleIds.map((roleId) => ({ roleId, siteResourceId })) ); } + }); - await rebuildClientAssociationsFromSiteResource(siteResource, trx); + rebuildClientAssociationsFromSiteResource(siteResource).catch((e) => { + logger.error( + `Failed to rebuild client associations for site resource ${siteResourceId}. Error: ${e}` + ); }); return response(res, { diff --git a/server/routers/siteResource/setSiteResourceUsers.ts b/server/routers/siteResource/setSiteResourceUsers.ts index 4fa6f2218e..cde5b4e66a 100644 --- a/server/routers/siteResource/setSiteResourceUsers.ts +++ b/server/routers/siteResource/setSiteResourceUsers.ts @@ -10,6 +10,7 @@ import { fromError } from "zod-validation-error"; import { eq } from "drizzle-orm"; import { OpenAPITags, registry } from "@server/openApi"; import { rebuildClientAssociationsFromSiteResource } from "@server/lib/rebuildClientAssociations"; +import { error } from "node:console"; const setSiteResourceUsersBodySchema = z .object({ @@ -120,8 +121,12 @@ export async function setSiteResourceUsers( userIds.map((userId) => ({ userId, siteResourceId })) ); } + }); - await rebuildClientAssociationsFromSiteResource(siteResource, trx); + rebuildClientAssociationsFromSiteResource(siteResource).catch((e) => { + logger.error( + `Failed to rebuild client associations for site resource ${siteResourceId}. Error: ${e}` + ); }); return response(res, { diff --git a/server/routers/siteResource/updateSiteResource.ts b/server/routers/siteResource/updateSiteResource.ts index db4d4445bd..434163f6fe 100644 --- a/server/routers/siteResource/updateSiteResource.ts +++ b/server/routers/siteResource/updateSiteResource.ts @@ -1,8 +1,6 @@ import { clientSiteResources, - clientSiteResourcesAssociationsCache, db, - newts, orgs, roles, roleSiteResources, @@ -10,8 +8,6 @@ import { SiteResource, siteResources, sites, - networks, - Transaction, userSiteResources } from "@server/db"; import { isLicensedOrSubscribed } from "#dynamic/lib/isLicencedOrSubscribed"; @@ -20,15 +16,12 @@ import { validateAndConstructDomain } from "@server/lib/domainUtils"; import response from "@server/lib/response"; import { eq, and, ne, inArray } from "drizzle-orm"; import { OpenAPITags, registry } from "@server/openApi"; -import { updatePeerData, updateTargets } from "@server/routers/client/targets"; +import { isIpInCidr, portRangeStringSchema } from "@server/lib/ip"; import { - generateAliasConfig, - generateRemoteSubnets, - generateSubnetProxyTargetV2, - isIpInCidr, - portRangeStringSchema -} from "@server/lib/ip"; -import { rebuildClientAssociationsFromSiteResource } from "@server/lib/rebuildClientAssociations"; + handleMessagingForUpdatedSiteResource, + rebuildClientAssociationsFromSiteResource, + waitForSiteResourceRebuildIdle +} from "@server/lib/rebuildClientAssociations"; import logger from "@server/logger"; import HttpCode from "@server/types/HttpCode"; import { NextFunction, Request, Response } from "express"; @@ -386,8 +379,7 @@ export async function updateSiteResource( ); } - let sitesChanged = false; - const existingSiteIds = existingSiteResource.networkId + const existingSiteNetworks = existingSiteResource.networkId ? await db .select() .from(siteNetworks) @@ -395,16 +387,7 @@ export async function updateSiteResource( eq(siteNetworks.networkId, existingSiteResource.networkId) ) : []; - - const existingSiteIdSet = new Set(existingSiteIds.map((s) => s.siteId)); - const newSiteIdSet = new Set(siteIds); - - if ( - existingSiteIdSet.size !== newSiteIdSet.size || - ![...existingSiteIdSet].every((id) => newSiteIdSet.has(id)) - ) { - sitesChanged = true; - } + const existingSiteIds = existingSiteNetworks.map((sn) => sn.siteId); let fullDomain: string | null = null; let finalSubdomain: string | null = null; @@ -470,348 +453,170 @@ export async function updateSiteResource( } let updatedSiteResource: SiteResource | undefined; + let updatedSiteIds: number[] = []; await db.transaction(async (trx) => { - // if the site is changed we need to delete and recreate the resource to avoid complications with the rebuild function otherwise we can just update in place - if (sitesChanged) { - // delete the existing site resource - await trx - .delete(siteResources) - .where( - and(eq(siteResources.siteResourceId, siteResourceId)) - ); + // Update the site resource + const sshPamSet = + isLicensedSshPam && + (authDaemonPort !== undefined || + authDaemonMode !== undefined || + pamMode !== undefined) + ? { + ...(authDaemonPort !== undefined && { + authDaemonPort + }), + ...(authDaemonMode !== undefined && { + authDaemonMode + }), + ...(pamMode !== undefined && { + pamMode + }) + } + : {}; + let tcpPortRangeStringAdjusted = tcpPortRangeString; + if (mode === "http") { + tcpPortRangeStringAdjusted = "443,80"; + } else if (mode === "ssh") { + tcpPortRangeStringAdjusted = destinationPort + ? destinationPort.toString() + : "22"; + } - await rebuildClientAssociationsFromSiteResource( - existingSiteResource, - trx + [updatedSiteResource] = await trx + .update(siteResources) + .set({ + name: name, + niceId: niceId, + mode: mode, + scheme, + ssl, + destination: destination, + destinationPort: destinationPort, + enabled: enabled, + alias: alias ? alias.trim() : null, + tcpPortRangeString: tcpPortRangeStringAdjusted, + udpPortRangeString: + mode == "http" || mode == "ssh" + ? "" + : udpPortRangeString, + disableIcmp: + disableIcmp || + (mode == "http" || mode == "ssh" ? true : false), + domainId, + subdomain: finalSubdomain, + fullDomain, + ...sshPamSet + }) + .where(and(eq(siteResources.siteResourceId, siteResourceId))) + .returning(); + + //////////////////// update the associations //////////////////// + + // delete the site - site resources associations + await trx + .delete(siteNetworks) + .where( + eq(siteNetworks.networkId, updatedSiteResource.networkId!) ); - // create the new site resource from the removed one - the ID should stay the same - const [insertedSiteResource] = await trx - .insert(siteResources) - .values({ - ...existingSiteResource - }) - .returning(); - - const sshPamSet = - isLicensedSshPam && - (authDaemonPort !== undefined || - authDaemonMode !== undefined || - pamMode !== undefined) - ? { - ...(authDaemonPort !== undefined && { - authDaemonPort - }), - ...(authDaemonMode !== undefined && { - authDaemonMode - }), - ...(pamMode !== undefined && { - pamMode - }) - } - : {}; - - let tcpPortRangeStringAdjusted = tcpPortRangeString; - if (mode === "http") { - tcpPortRangeStringAdjusted = "443,80"; - } else if (mode === "ssh") { - tcpPortRangeStringAdjusted = destinationPort - ? destinationPort.toString() - : "22"; - } - - [updatedSiteResource] = await trx - .update(siteResources) - .set({ - name, - niceId, - mode, - scheme, - ssl, - destination, - destinationPort, - enabled, - alias: alias ? alias.trim() : null, - tcpPortRangeString: tcpPortRangeStringAdjusted, - udpPortRangeString: - mode == "http" || mode == "ssh" - ? "" - : udpPortRangeString, - disableIcmp: - disableIcmp || - (mode == "http" || mode == "ssh" ? true : false), // default to true for http resources, otherwise false - domainId, - subdomain: finalSubdomain, - fullDomain, - ...sshPamSet - }) - .where( - and( - eq( - siteResources.siteResourceId, - insertedSiteResource.siteResourceId - ) - ) - ) - .returning(); + for (const siteId of siteIds) { + await trx.insert(siteNetworks).values({ + siteId: siteId, + networkId: updatedSiteResource.networkId! + }); + updatedSiteIds.push(siteId); + } - if (!updatedSiteResource) { - throw new Error( - "Failed to create updated site resource after site change" - ); - } + await trx + .delete(clientSiteResources) + .where(eq(clientSiteResources.siteResourceId, siteResourceId)); - //////////////////// update the associations //////////////////// + if (clientIds.length > 0) { + await trx.insert(clientSiteResources).values( + clientIds.map((clientId) => ({ + clientId, + siteResourceId + })) + ); + } - // delete the site - site resources associations - await trx - .delete(siteNetworks) - .where( - eq( - siteNetworks.networkId, - updatedSiteResource.networkId! - ) - ); + await trx + .delete(userSiteResources) + .where(eq(userSiteResources.siteResourceId, siteResourceId)); - for (const siteId of siteIds) { - await trx.insert(siteNetworks).values({ - siteId: siteId, - networkId: updatedSiteResource.networkId! - }); - } + if (userIds.length > 0) { + await trx.insert(userSiteResources).values( + userIds.map((userId) => ({ + userId, + siteResourceId + })) + ); + } - const [adminRole] = await trx - .select() - .from(roles) - .where( - and( - eq(roles.isAdmin, true), - eq(roles.orgId, updatedSiteResource.orgId) - ) + // Get all admin role IDs for this org to exclude from deletion + const adminRoles = await trx + .select() + .from(roles) + .where( + and( + eq(roles.isAdmin, true), + eq(roles.orgId, updatedSiteResource.orgId) ) - .limit(1); - - if (!adminRole) { - return next( - createHttpError( - HttpCode.NOT_FOUND, - `Admin role not found` - ) - ); - } - - await trx.insert(roleSiteResources).values({ - roleId: adminRole.roleId, - siteResourceId: updatedSiteResource.siteResourceId - }); - - if (roleIds.length > 0) { - await trx.insert(roleSiteResources).values( - roleIds.map((roleId) => ({ - roleId, - siteResourceId: updatedSiteResource!.siteResourceId - })) - ); - } - - if (userIds.length > 0) { - await trx.insert(userSiteResources).values( - userIds.map((userId) => ({ - userId, - siteResourceId: updatedSiteResource!.siteResourceId - })) - ); - } - - if (clientIds.length > 0) { - await trx.insert(clientSiteResources).values( - clientIds.map((clientId) => ({ - clientId, - siteResourceId: updatedSiteResource!.siteResourceId - })) - ); - } - } else { - // Update the site resource - const sshPamSet = - isLicensedSshPam && - (authDaemonPort !== undefined || - authDaemonMode !== undefined || - pamMode !== undefined) - ? { - ...(authDaemonPort !== undefined && { - authDaemonPort - }), - ...(authDaemonMode !== undefined && { - authDaemonMode - }), - ...(pamMode !== undefined && { - pamMode - }) - } - : {}; - let tcpPortRangeStringAdjusted = tcpPortRangeString; - if (mode === "http") { - tcpPortRangeStringAdjusted = "443,80"; - } else if (mode === "ssh") { - tcpPortRangeStringAdjusted = destinationPort - ? destinationPort.toString() - : "22"; - } + ); + const adminRoleIds = adminRoles.map((role) => role.roleId); - [updatedSiteResource] = await trx - .update(siteResources) - .set({ - name: name, - niceId: niceId, - mode: mode, - scheme, - ssl, - destination: destination, - destinationPort: destinationPort, - enabled: enabled, - alias: alias ? alias.trim() : null, - tcpPortRangeString: tcpPortRangeStringAdjusted, - udpPortRangeString: - mode == "http" || mode == "ssh" - ? "" - : udpPortRangeString, - disableIcmp: - disableIcmp || - (mode == "http" || mode == "ssh" ? true : false), - domainId, - subdomain: finalSubdomain, - fullDomain, - ...sshPamSet - }) - .where( - and(eq(siteResources.siteResourceId, siteResourceId)) + if (adminRoleIds.length > 0) { + await trx.delete(roleSiteResources).where( + and( + eq(roleSiteResources.siteResourceId, siteResourceId), + ne(roleSiteResources.roleId, adminRoleIds[0]) // delete all but the admin role ) - .returning(); - - //////////////////// update the associations //////////////////// - - // delete the site - site resources associations - await trx - .delete(siteNetworks) - .where( - eq( - siteNetworks.networkId, - updatedSiteResource.networkId! - ) - ); - - for (const siteId of siteIds) { - await trx.insert(siteNetworks).values({ - siteId: siteId, - networkId: updatedSiteResource.networkId! - }); - } - - await trx - .delete(clientSiteResources) - .where( - eq(clientSiteResources.siteResourceId, siteResourceId) - ); - - if (clientIds.length > 0) { - await trx.insert(clientSiteResources).values( - clientIds.map((clientId) => ({ - clientId, - siteResourceId - })) - ); - } - + ); + } else { await trx - .delete(userSiteResources) + .delete(roleSiteResources) .where( - eq(userSiteResources.siteResourceId, siteResourceId) + eq(roleSiteResources.siteResourceId, siteResourceId) ); + } - if (userIds.length > 0) { - await trx.insert(userSiteResources).values( - userIds.map((userId) => ({ - userId, - siteResourceId - })) - ); - } + if (roleIds.length > 0) { + await trx.insert(roleSiteResources).values( + roleIds.map((roleId) => ({ + roleId, + siteResourceId + })) + ); + } - // Get all admin role IDs for this org to exclude from deletion - const adminRoles = await trx - .select() - .from(roles) - .where( - and( - eq(roles.isAdmin, true), - eq(roles.orgId, updatedSiteResource.orgId) - ) - ); - const adminRoleIds = adminRoles.map((role) => role.roleId); - - if (adminRoleIds.length > 0) { - await trx.delete(roleSiteResources).where( - and( - eq( - roleSiteResources.siteResourceId, - siteResourceId - ), - ne(roleSiteResources.roleId, adminRoleIds[0]) // delete all but the admin role - ) - ); - } else { - await trx - .delete(roleSiteResources) - .where( - eq(roleSiteResources.siteResourceId, siteResourceId) - ); - } + logger.info(`Updated site resource ${siteResourceId}`); + }); - if (roleIds.length > 0) { - await trx.insert(roleSiteResources).values( - roleIds.map((roleId) => ({ - roleId, - siteResourceId - })) - ); - } + if (!updatedSiteResource) { + throw new Error("No updated resource found after update"); + } - logger.info(`Updated site resource ${siteResourceId}`); - } - }); + const finalUpdatedSiteResource = updatedSiteResource; - // Background: wait for removal messages to propagate, then rebuild - // associations for the re-created resource. Own transaction ensures - // execution on the primary against fully committed state. - (async () => { - await db.transaction(async (trx) => { - if (!updatedSiteResource) { - throw new Error("No updated resource found after update"); - } - if (sitesChanged) { - await new Promise((resolve) => setTimeout(resolve, 750)); - await rebuildClientAssociationsFromSiteResource( - updatedSiteResource, - trx - ); - } - await handleMessagingForUpdatedSiteResource( + rebuildClientAssociationsFromSiteResource(finalUpdatedSiteResource) + .then(() => + waitForSiteResourceRebuildIdle( + finalUpdatedSiteResource.siteResourceId + ) + ) + .then(() => + handleMessagingForUpdatedSiteResource( existingSiteResource, - updatedSiteResource, - siteIds.map((siteId) => ({ - siteId, - orgId: existingSiteResource.orgId - })), - trx + finalUpdatedSiteResource, + existingSiteIds, + updatedSiteIds + ) + ) + .catch((e) => { + logger.error( + `Failed to rebuild and handle messaging for site resource ${siteResourceId}. Error: ${e}` ); }); - })().catch((err) => { - logger.error( - `Error rebuilding client associations for site resource ${updatedSiteResource?.siteResourceId}:`, - err - ); - }); return response(res, { data: updatedSiteResource, @@ -830,180 +635,3 @@ export async function updateSiteResource( ); } } - -export async function handleMessagingForUpdatedSiteResource( - existingSiteResource: SiteResource | undefined, - updatedSiteResource: SiteResource, - sites: { siteId: number; orgId: string }[], - trx: Transaction -) { - logger.debug( - "handleMessagingForUpdatedSiteResource: existingSiteResource is: ", - existingSiteResource - ); - logger.debug( - "handleMessagingForUpdatedSiteResource: updatedSiteResource is: ", - updatedSiteResource - ); - - const { mergedAllClients } = - await rebuildClientAssociationsFromSiteResource( - existingSiteResource || updatedSiteResource, // we want to rebuild based on the existing resource then we will apply the change to the destination below - trx - ); - - // after everything is rebuilt above we still need to update the targets and remote subnets if the destination changed - const destinationChanged = - existingSiteResource && - existingSiteResource.destination !== updatedSiteResource.destination; - const destinationPortChanged = - existingSiteResource && - existingSiteResource.destinationPort !== - updatedSiteResource.destinationPort; - const aliasChanged = - existingSiteResource && - existingSiteResource.alias !== updatedSiteResource.alias; - const fullDomainChanged = - existingSiteResource && - existingSiteResource.fullDomain !== updatedSiteResource.fullDomain; - const sslChanged = - existingSiteResource && - existingSiteResource.ssl !== updatedSiteResource.ssl; - const portRangesChanged = - existingSiteResource && - (existingSiteResource.tcpPortRangeString !== - updatedSiteResource.tcpPortRangeString || - existingSiteResource.udpPortRangeString !== - updatedSiteResource.udpPortRangeString || - existingSiteResource.disableIcmp !== - updatedSiteResource.disableIcmp); - - // if the existingSiteResource is undefined (new resource) we don't need to do anything here, the rebuild above handled it all - - if ( - destinationChanged || - aliasChanged || - fullDomainChanged || - sslChanged || - portRangesChanged || - destinationPortChanged - ) { - for (const site of sites) { - const [newt] = await trx - .select() - .from(newts) - .where(eq(newts.siteId, site.siteId)) - .limit(1); - - if (!newt) { - throw new Error( - "Newt not found for site during site resource update" - ); - } - - // Only update targets on newt if these items change - if ( - destinationChanged || - sslChanged || // we need to push a new cert if the ssl changed - portRangesChanged || - fullDomainChanged || // if the domain changes we need to update the certs and stuff - destinationPortChanged - ) { - const oldTargets = await generateSubnetProxyTargetV2( - existingSiteResource, - mergedAllClients - ); - const newTargets = await generateSubnetProxyTargetV2( - updatedSiteResource, - mergedAllClients - ); - - await updateTargets( - newt.newtId, - { - oldTargets: oldTargets ? oldTargets : [], - newTargets: newTargets ? newTargets : [] - }, - newt.version - ); - } - - const olmJobs: Promise[] = []; - for (const client of mergedAllClients) { - // does this client have access to another resource on this site that has the same destination still? if so we dont want to remove it from their olm yet - // todo: optimize this query if needed - if (!existingSiteResource.destination) { - continue; - } - - const oldDestinationStillInUseSites = await trx - .select() - .from(siteResources) - .innerJoin( - clientSiteResourcesAssociationsCache, - eq( - clientSiteResourcesAssociationsCache.siteResourceId, - siteResources.siteResourceId - ) - ) - .innerJoin( - siteNetworks, - eq(siteNetworks.networkId, siteResources.networkId) - ) - .where( - and( - eq( - clientSiteResourcesAssociationsCache.clientId, - client.clientId - ), - eq(siteNetworks.siteId, site.siteId), - eq( - siteResources.destination, - existingSiteResource.destination - ), - ne( - siteResources.siteResourceId, - existingSiteResource.siteResourceId - ) - ) - ); - - const oldDestinationStillInUseByASite = - oldDestinationStillInUseSites.length > 0; - - // we also need to update the remote subnets on the olms for each client that has access to this site - olmJobs.push( - updatePeerData( - client.clientId, - site.siteId, - destinationChanged - ? { - oldRemoteSubnets: - !oldDestinationStillInUseByASite - ? generateRemoteSubnets([ - existingSiteResource - ]) - : [], - newRemoteSubnets: generateRemoteSubnets([ - updatedSiteResource - ]) - } - : undefined, - aliasChanged || fullDomainChanged // the full domain is sent down as an alias - ? { - oldAliases: generateAliasConfig([ - existingSiteResource - ]), - newAliases: generateAliasConfig([ - updatedSiteResource - ]) - } - : undefined - ) - ); - } - - await Promise.all(olmJobs); - } - } -} diff --git a/server/routers/target/createTarget.ts b/server/routers/target/createTarget.ts index 2b3f472e8e..289f47c767 100644 --- a/server/routers/target/createTarget.ts +++ b/server/routers/target/createTarget.ts @@ -33,41 +33,59 @@ const createTargetParamsSchema = z.strictObject({ resourceId: z.coerce.number().int().positive() }); -const createTargetSchema = z.strictObject({ - siteId: z.int().positive(), - ip: z.string().refine(isTargetValid), - mode: z.enum(["http", "tcp", "udp", "ssh", "rdp", "vnc"]).optional(), - method: z.string().optional().nullable(), - port: z.int().min(1).max(65535), - enabled: z.boolean().default(true), - hcEnabled: z.boolean().optional(), - hcPath: z.string().min(1).optional().nullable(), - hcScheme: z.string().optional().nullable(), - hcMode: z.string().optional().nullable(), - hcHostname: z.string().optional().nullable(), - hcPort: z.int().positive().optional().nullable(), - hcInterval: z.int().positive().min(1).optional().nullable(), - hcUnhealthyInterval: z.int().positive().min(1).optional().nullable(), - hcTimeout: z.int().positive().min(1).optional().nullable(), - hcHeaders: z - .array(z.strictObject({ name: z.string(), value: z.string() })) - .nullable() - .optional(), - hcFollowRedirects: z.boolean().optional().nullable(), - hcMethod: z.string().min(1).optional().nullable(), - hcStatus: z.int().optional().nullable(), - hcTlsServerName: z.string().optional().nullable(), - hcHealthyThreshold: z.int().positive().min(1).optional().nullable(), - hcUnhealthyThreshold: z.int().positive().min(1).optional().nullable(), - path: z.string().optional().nullable(), - pathMatchType: z.enum(["exact", "prefix", "regex"]).optional().nullable(), - rewritePath: z.string().optional().nullable(), - rewritePathType: z - .enum(["exact", "prefix", "regex", "stripPrefix"]) - .optional() - .nullable(), - priority: z.int().min(1).max(1000).optional().nullable() -}); +const createTargetSchema = z + .strictObject({ + siteId: z.int().positive(), + ip: z.string().refine(isTargetValid), + mode: z.enum(["http", "tcp", "udp", "ssh", "rdp", "vnc"]).optional(), + method: z.string().optional().nullable(), + port: z.int().min(1).max(65535), + enabled: z.boolean().default(true), + hcEnabled: z.boolean().optional(), + hcPath: z.string().min(1).optional().nullable(), + hcScheme: z.string().optional().nullable(), + hcMode: z.string().optional().nullable(), + hcHostname: z.string().optional().nullable(), + hcPort: z.int().positive().optional().nullable(), + hcInterval: z.int().positive().min(1).optional().nullable(), + hcUnhealthyInterval: z.int().positive().min(1).optional().nullable(), + hcTimeout: z.int().positive().min(1).optional().nullable(), + hcHeaders: z + .array(z.strictObject({ name: z.string(), value: z.string() })) + .nullable() + .optional(), + hcFollowRedirects: z.boolean().optional().nullable(), + hcMethod: z.string().min(1).optional().nullable(), + hcStatus: z.int().optional().nullable(), + hcTlsServerName: z.string().optional().nullable(), + hcHealthyThreshold: z.int().positive().min(1).optional().nullable(), + hcUnhealthyThreshold: z.int().positive().min(1).optional().nullable(), + path: z.string().optional().nullable(), + pathMatchType: z + .enum(["exact", "prefix", "regex"]) + .optional() + .nullable(), + rewritePath: z.string().optional().nullable(), + rewritePathType: z + .enum(["exact", "prefix", "regex", "stripPrefix"]) + .optional() + .nullable(), + priority: z.int().min(1).max(1000).optional().nullable() + }) + .superRefine((data, ctx) => { + const hcHostnameMissing = + data.hcHostname === undefined || + data.hcHostname === null || + 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 CreateTargetResponse = Target & TargetHealthCheck; diff --git a/server/routers/target/listTargets.ts b/server/routers/target/listTargets.ts index b097b1f6ee..68f80197a2 100644 --- a/server/routers/target/listTargets.ts +++ b/server/routers/target/listTargets.ts @@ -14,7 +14,7 @@ const listTargetsParamsSchema = z.strictObject({ resourceId: z.coerce.number().int().positive() }); -const listTargetsSchema = z.object({ +const listTargetsSchema = z.strictObject({ limit: z .string() .optional() diff --git a/server/routers/target/updateTarget.ts b/server/routers/target/updateTarget.ts index 1bed7b982e..52bf3e578f 100644 --- a/server/routers/target/updateTarget.ts +++ b/server/routers/target/updateTarget.ts @@ -188,6 +188,38 @@ export async function updateTarget( ); } + const [existingHc] = await db + .select() + .from(targetHealthCheck) + .where(eq(targetHealthCheck.targetId, targetId)) + .limit(1); + + if (!existingHc) { + return next( + createHttpError( + HttpCode.NOT_FOUND, + `Health check for target with ID ${targetId} not found` + ) + ); + } + + const nextHcEnabled = parsedBody.data.hcEnabled ?? existingHc.hcEnabled; + const nextHcHostname = + parsedBody.data.hcHostname !== undefined + ? parsedBody.data.hcHostname + : existingHc.hcHostname; + const hcHostnameMissing = + !nextHcHostname || nextHcHostname.trim().length === 0; + + if (nextHcEnabled && hcHostnameMissing) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + "hcHostname is required when hcEnabled is true" + ) + ); + } + const pathMatchTypeRemoved = parsedBody.data.pathMatchType === null; const nextMode = parsedBody.data.mode === null ? undefined : parsedBody.data.mode; @@ -218,21 +250,6 @@ export async function updateTarget( .where(eq(targets.targetId, targetId)) .returning(); - const [existingHc] = await trx - .select() - .from(targetHealthCheck) - .where(eq(targetHealthCheck.targetId, targetId)) - .limit(1); - - if (!existingHc) { - return next( - createHttpError( - HttpCode.NOT_FOUND, - `Health check for target with ID ${targetId} not found` - ) - ); - } - let hcHeaders = null; if (parsedBody.data.hcHeaders) { hcHeaders = JSON.stringify(parsedBody.data.hcHeaders); diff --git a/server/routers/user/acceptInvite.ts b/server/routers/user/acceptInvite.ts index e3366a0c58..ef7ddcdbd1 100644 --- a/server/routers/user/acceptInvite.ts +++ b/server/routers/user/acceptInvite.ts @@ -202,13 +202,11 @@ export async function acceptInvite( ); }); - calculateUserClientsForOrgs(existingUser[0].userId, primaryDb).catch( - (e) => { - logger.error( - `Failed to calculate user clients after accepting invite for user ${existingUser[0].userId}: ${e}` - ); - } - ); + calculateUserClientsForOrgs(existingUser[0].userId).catch((e) => { + logger.error( + `Failed to calculate user clients after accepting invite for user ${existingUser[0].userId}: ${e}` + ); + }); return response(res, { data: { accepted: true, orgId: existingInvite.orgId }, diff --git a/server/routers/user/addUserRoleLegacy.ts b/server/routers/user/addUserRoleLegacy.ts index bef69387ac..b3ff55a06e 100644 --- a/server/routers/user/addUserRoleLegacy.ts +++ b/server/routers/user/addUserRoleLegacy.ts @@ -159,13 +159,11 @@ export async function addUserRoleLegacy( }); for (const orgClient of orgClientsToRebuild) { - rebuildClientAssociationsFromClient(orgClient, primaryDb).catch( - (e) => { - logger.error( - `Failed to rebuild client associations for client ${orgClient.clientId} after adding role: ${e}` - ); - } - ); + rebuildClientAssociationsFromClient(orgClient).catch((e) => { + logger.error( + `Failed to rebuild client associations for client ${orgClient.clientId} after adding role: ${e}` + ); + }); } return response(res, { diff --git a/server/routers/user/adminRemoveUser.ts b/server/routers/user/adminRemoveUser.ts index 38713ce267..066848b077 100644 --- a/server/routers/user/adminRemoveUser.ts +++ b/server/routers/user/adminRemoveUser.ts @@ -55,7 +55,7 @@ export async function adminRemoveUser( await trx.delete(users).where(eq(users.userId, userId)); }); - calculateUserClientsForOrgs(userId, primaryDb).catch((e) => { + calculateUserClientsForOrgs(userId).catch((e) => { logger.error( `Failed to calculate user clients after removing user ${userId}: ${e}` ); diff --git a/server/routers/user/createOrgUser.ts b/server/routers/user/createOrgUser.ts index f03dd763ba..c6f25e085f 100644 --- a/server/routers/user/createOrgUser.ts +++ b/server/routers/user/createOrgUser.ts @@ -56,7 +56,6 @@ const bodySchema = z export type CreateOrgUserResponse = {}; const CreateOrgUserResponseDataSchema = z.object({}); - registry.registerPath({ method: "put", path: "/org/{orgId}/user", @@ -77,7 +76,9 @@ registry.registerPath({ description: "Successful response", content: { "application/json": { - schema: createApiResponseSchema(CreateOrgUserResponseDataSchema) + schema: createApiResponseSchema( + CreateOrgUserResponseDataSchema + ) } } } @@ -326,13 +327,11 @@ export async function createOrgUser( }); if (userIdForClients) { - calculateUserClientsForOrgs(userIdForClients, primaryDb).catch( - (e) => { - logger.error( - `Failed to calculate user clients after creating org user: ${e}` - ); - } - ); + calculateUserClientsForOrgs(userIdForClients).catch((e) => { + logger.error( + `Failed to calculate user clients after creating org user: ${e}` + ); + }); } } else { return next( diff --git a/server/routers/user/removeUserOrg.ts b/server/routers/user/removeUserOrg.ts index 58fc85b698..902aeed840 100644 --- a/server/routers/user/removeUserOrg.ts +++ b/server/routers/user/removeUserOrg.ts @@ -109,7 +109,7 @@ export async function removeUserOrg( await removeUserFromOrg(org, userId, trx); }); - calculateUserClientsForOrgs(userId, primaryDb).catch((e) => { + calculateUserClientsForOrgs(userId).catch((e) => { logger.error( `Failed to calculate user clients after removing user ${userId} from org ${orgId}: ${e}` ); diff --git a/server/routers/ws/types.ts b/server/routers/ws/types.ts index e539954ce0..eeb2724576 100644 --- a/server/routers/ws/types.ts +++ b/server/routers/ws/types.ts @@ -76,12 +76,32 @@ export interface SendMessageOptions { compress?: boolean; } -// Redis message type for cross-node communication -export interface RedisMessage { - type: "direct" | "broadcast"; - targetClientId?: string; - excludeClientId?: string; +export interface BatchSendMessage { + clientId: string; message: WSMessage; - fromNodeId: string; options?: SendMessageOptions; } + +// Redis message types for cross-node communication +export type RedisMessage = + | { + type: "direct"; + targetClientId: string; + message: WSMessage; + fromNodeId: string; + } + | { + type: "direct-batch"; + messages: { + targetClientId: string; + message: WSMessage; + }[]; + fromNodeId: string; + } + | { + type: "broadcast"; + excludeClientId?: string; + message: WSMessage; + fromNodeId: string; + options?: SendMessageOptions; + }; diff --git a/server/routers/ws/ws.ts b/server/routers/ws/ws.ts index e7dcfe9cbc..4ce337a204 100644 --- a/server/routers/ws/ws.ts +++ b/server/routers/ws/ws.ts @@ -26,7 +26,8 @@ import { WebSocketRequest, WSMessage, AuthenticatedWebSocket, - SendMessageOptions + SendMessageOptions, + BatchSendMessage } from "./types"; import { validateSessionToken } from "@server/auth/sessions/app"; @@ -212,6 +213,20 @@ const sendToClient = async ( return localSent; }; +const sendToClientsBatch = async ( + entries: BatchSendMessage[] +): Promise => { + if (entries.length === 0) { + return; + } + + await Promise.all( + entries.map((entry) => + sendToClient(entry.clientId, entry.message, entry.options) + ) + ); +}; + const broadcastToAllExcept = async ( message: WSMessage, excludeClientId?: string, @@ -552,6 +567,7 @@ export { router, handleWSUpgrade, sendToClient, + sendToClientsBatch, broadcastToAllExcept, connectedClients, hasActiveConnections, diff --git a/src/app/[orgId]/settings/clients/user/[niceId]/general/page.tsx b/src/app/[orgId]/settings/clients/user/[niceId]/general/page.tsx index cfdc5a9967..8573e81996 100644 --- a/src/app/[orgId]/settings/clients/user/[niceId]/general/page.tsx +++ b/src/app/[orgId]/settings/clients/user/[niceId]/general/page.tsx @@ -41,6 +41,13 @@ import { useParams } from "next/navigation"; import { FaApple, FaWindows, FaLinux } from "react-icons/fa"; import { SiAndroid } from "react-icons/si"; import { tierMatrix } from "@server/lib/billing/tierMatrix"; +import { + productUpdatesQueries, + type LatestVersionResponse +} from "@app/lib/queries"; +import { useQuery } from "@tanstack/react-query"; +import semver from "semver"; +import { InfoPopup } from "@app/components/ui/info-popup"; function formatTimestamp(timestamp: number | null | undefined): string { if (!timestamp) return "-"; @@ -166,6 +173,34 @@ export default function GeneralPage() { }>(null); const [isCheckingCache, setIsCheckingCache] = useState(false); const [isRebuildingCache, setIsRebuildingCache] = useState(false); + const data = useQuery(productUpdatesQueries.latestVersion(true)); + const latestPlatformVersions = data.data?.data; + + const agentVersionMap: Record = { + "Pangolin Windows": "windows", + "Pangolin Android": "android", + "Pangolin iOS": "ios", + "Pangolin iPadOS": "ios", + "Pangolin macOS": "mac", + "Pangolin CLI": "cli", + "Olm CLI": "olm" + }; + + let updateAvailable = false; + if (client.agent && client.olmVersion && latestPlatformVersions) { + const agent = agentVersionMap[ + client.agent + ] as keyof LatestVersionResponse; + + if (agent in latestPlatformVersions) { + const agentVersion = latestPlatformVersions[agent]; + + updateAvailable = semver.lt( + client.olmVersion, + agentVersion.latestVersion + ); + } + } // get "imp" from local storage to determine if we should show the verify button (imp = "1" means show) const showVerifyButton = @@ -451,11 +486,21 @@ export default function GeneralPage() { {t("agent")} - - {client.agent + - " v" + - client.olmVersion} - +
+ + {client.agent + + " v" + + client.olmVersion} + + + {updateAvailable && ( + + )} +
diff --git a/src/app/[orgId]/settings/logs/request/page.tsx b/src/app/[orgId]/settings/logs/request/page.tsx index e1249f9c7a..39c2196d0f 100644 --- a/src/app/[orgId]/settings/logs/request/page.tsx +++ b/src/app/[orgId]/settings/logs/request/page.tsx @@ -18,7 +18,6 @@ import Link from "next/link"; import { useParams, useRouter, useSearchParams } from "next/navigation"; import { useMemo, useState, useTransition } from "react"; import { useStoredPageSize } from "@app/hooks/useStoredPageSize"; -import { build } from "@server/build"; import type { QueryRequestAuditLogResponse } from "@server/routers/auditLogs/types"; import { ColumnFilterButton } from "@app/components/ColumnFilterButton"; @@ -122,8 +121,7 @@ export default function GeneralPage() { ...logQueries.requests({ orgId: orgId as string, filters: queryFilters - }), - enabled: build !== "oss" + }) }); const rows = isLoading ? generateSampleRequestLogs() : (data?.log ?? []); diff --git a/src/components/MachineClientsTable.tsx b/src/components/MachineClientsTable.tsx index eba0c97624..3094348e30 100644 --- a/src/components/MachineClientsTable.tsx +++ b/src/components/MachineClientsTable.tsx @@ -11,10 +11,10 @@ import { } from "@app/components/ui/dropdown-menu"; import { useEnvContext } from "@app/hooks/useEnvContext"; import { useNavigationContext } from "@app/hooks/useNavigationContext"; +import { useOptimisticLabels } from "@app/hooks/useOptimisticLabels"; import { usePaidStatus } from "@app/hooks/usePaidStatus"; import { toast } from "@app/hooks/useToast"; import { createApiClient, formatAxiosError } from "@app/lib/api"; -import { cn } from "@app/lib/cn"; import { getNextSortOrder, getSortDirection } from "@app/lib/sortColumn"; import { tierMatrix } from "@server/lib/billing/tierMatrix"; import type { PaginationState } from "@tanstack/react-table"; @@ -31,15 +31,18 @@ import Link from "next/link"; import { useRouter } from "next/navigation"; import { startTransition, useMemo, useState, useTransition } from "react"; import { useDebouncedCallback } from "use-debounce"; -import z from "zod"; import { ColumnFilterButton } from "./ColumnFilterButton"; -import { type SelectedLabel } from "./labels-selector"; +import { LabelColumnFilterButton } from "./LabelColumnFilterButton"; import { LabelsTableCell } from "./LabelsTableCell"; import { Badge } from "./ui/badge"; import { ControlledDataTable } from "./ui/controlled-data-table"; -import { LabelColumnFilterButton } from "./LabelColumnFilterButton"; -import { useLocalLabels } from "@app/hooks/useLocalLabels"; -import { useOptimisticLabels } from "@app/hooks/useOptimisticLabels"; +import { + productUpdatesQueries, + type LatestVersionResponse +} from "@app/lib/queries"; +import { useQuery } from "@tanstack/react-query"; +import semver from "semver"; +import { InfoPopup } from "./ui/info-popup"; export type ClientRow = { id: number; @@ -101,6 +104,9 @@ export default function MachineClientsTable({ const { isPaidUser } = usePaidStatus(); const isLabelFeatureEnabled = isPaidUser(tierMatrix.labels); + const data = useQuery(productUpdatesQueries.latestVersion(true)); + + const latestPlatformVersions = data.data?.data; const defaultMachineColumnVisibility = { subnet: false, @@ -375,6 +381,37 @@ export default function MachineClientsTable({ cell: ({ row }) => { const originalRow = row.original; + const agentVersionMap: Record = { + "Pangolin Windows": "windows", + "Pangolin Android": "android", + "Pangolin iOS": "ios", + "Pangolin iPadOS": "ios", + "Pangolin macOS": "mac", + "Pangolin CLI": "cli", + "Olm CLI": "olm" + }; + + let updateAvailable = false; + + if ( + originalRow.olmVersion && + originalRow.agent && + latestPlatformVersions + ) { + const agent = agentVersionMap[ + originalRow.agent + ] as keyof LatestVersionResponse; + + if (agent in latestPlatformVersions) { + const agentVersion = latestPlatformVersions[agent]; + + updateAvailable = semver.lt( + originalRow.olmVersion, + agentVersion.latestVersion + ); + } + } + return (
{originalRow.agent && originalRow.olmVersion ? ( @@ -386,9 +423,9 @@ export default function MachineClientsTable({ ) : ( "-" )} - {/*originalRow.olmUpdateAvailable && ( - - )*/} + {updateAvailable && ( + + )}
); } diff --git a/src/components/PrivateResourceForm.tsx b/src/components/PrivateResourceForm.tsx index 1f7d56b18a..23fd8a7b24 100644 --- a/src/components/PrivateResourceForm.tsx +++ b/src/components/PrivateResourceForm.tsx @@ -411,9 +411,9 @@ export function PrivateResourceForm({ type FormData = z.infer; - const rolesQuery = useQuery(orgQueries.roles({ orgId })); - const usersQuery = useQuery(orgQueries.users({ orgId })); - const clientsQuery = useQuery(orgQueries.machineClients({ orgId })); + const clientsQuery = useQuery( + orgQueries.machineClients({ orgId, perPage: 1 }) + ); const resourceRolesQuery = useQuery({ ...resourceQueries.siteResourceRoles({ siteResourceId: siteResourceId ?? 0 @@ -433,13 +433,6 @@ export function PrivateResourceForm({ enabled: siteResourceId != null }); - const allRoles = (rolesQuery.data ?? []) - .map((r) => ({ id: r.roleId.toString(), text: r.name })) - .filter((r) => r.text !== "Admin"); - const allUsers = (usersQuery.data ?? []).map((u) => ({ - id: u.id.toString(), - text: `${getUserDisplayName({ email: u.email, username: u.username })}${u.type !== UserType.Internal ? ` (${u.idpName})` : ""}` - })); const allClients = (clientsQuery.data ?? []) .filter((c) => !c.userId) .map((c) => ({ id: c.clientId.toString(), text: c.name })); @@ -478,8 +471,6 @@ export function PrivateResourceForm({ } const loadingRolesUsers = - rolesQuery.isLoading || - usersQuery.isLoading || clientsQuery.isLoading || (siteResourceId != null && (resourceRolesQuery.isLoading || @@ -488,16 +479,6 @@ export function PrivateResourceForm({ const hasMachineClients = allClients.length > 0; - const [activeRolesTagIndex, setActiveRolesTagIndex] = useState< - number | null - >(null); - const [activeUsersTagIndex, setActiveUsersTagIndex] = useState< - number | null - >(null); - const [activeClientsTagIndex, setActiveClientsTagIndex] = useState< - number | null - >(null); - const [sshServerMode, setSshServerMode] = useState<"standard" | "native">( () => { if (variant === "edit" && resource) { @@ -599,6 +580,7 @@ export function PrivateResourceForm({ }); const mode = form.watch("mode"); + const aliasValue = form.watch("alias"); const httpConfigSubdomain = form.watch("httpConfigSubdomain"); const httpConfigDomainId = form.watch("httpConfigDomainId"); const httpConfigFullDomain = form.watch("httpConfigFullDomain"); @@ -614,6 +596,9 @@ export function PrivateResourceForm({ !isNative && pamMode === "push" && authDaemonMode === "remote"; + const aliasEndsWithLocal = + typeof aliasValue === "string" && + aliasValue.trim().toLowerCase().endsWith(".local"); const hasInitialized = useRef(false); const previousResourceId = useRef(null); const initialSitesRef = useRef(initialSites); @@ -1228,6 +1213,13 @@ export function PrivateResourceForm({ } /> + {aliasEndsWithLocal && ( +

+ {t( + "internalResourceAliasLocalWarning" + )} +

+ )} )} @@ -1756,6 +1748,7 @@ export function PrivateResourceForm({ field.value ?? [] } orgId={orgId} + restrictAdminRole onSelectRoles={( newUsers ) => { diff --git a/src/components/ProductUpdates.tsx b/src/components/ProductUpdates.tsx index 0d88853a72..dad46e3b96 100644 --- a/src/components/ProductUpdates.tsx +++ b/src/components/ProductUpdates.tsx @@ -8,6 +8,7 @@ import { type ProductUpdate, productUpdatesQueries } from "@app/lib/queries"; +import { build } from "@server/build"; import { useQueries } from "@tanstack/react-query"; import { ArrowRight, @@ -39,22 +40,42 @@ export default function ProductUpdates({ }) { const { env } = useEnvContext(); + const productUpdatesEnabled = env.app.notifications.product_updates; + const versionCheckEnabled = + env.app.notifications.new_releases && build !== "saas"; + const data = useQueries({ queries: [ - productUpdatesQueries.list( - env.app.notifications.product_updates, - env.app.version - ), + productUpdatesQueries.list(productUpdatesEnabled, env.app.version), productUpdatesQueries.latestVersion( env.app.notifications.new_releases ) ], combine(result) { - if (result[0].isLoading || result[1].isLoading) return null; - return { - updates: result[0].data?.data ?? [], - latestVersion: result[1].data - }; + const [updatesQuery, versionQuery] = result; + + const updatesSettled = + !productUpdatesEnabled || + updatesQuery.isFetched || + updatesQuery.isError; + const versionSettled = + !versionCheckEnabled || + versionQuery.isFetched || + versionQuery.isError; + + if (!updatesSettled || !versionSettled) return null; + + const updates = updatesQuery.isError + ? [] + : Array.isArray(updatesQuery.data?.data) + ? updatesQuery.data.data + : []; + + const latestVersion = versionQuery.isError + ? undefined + : versionQuery.data; + + return { updates, latestVersion }; } }); const t = useTranslations(); @@ -76,19 +97,30 @@ export default function ProductUpdates({ if (!data) return null; - const latestVersion = data?.latestVersion?.data?.pangolin.latestVersion; + const versionResponse = data.latestVersion?.data; + const latestVersion = versionResponse?.pangolin?.latestVersion; const currentVersion = env.app.version; - const showNewVersionPopup = Boolean( + let showNewVersionPopup = false; + if ( latestVersion && - valid(latestVersion) && - valid(currentVersion) && - ignoredVersionUpdate !== latestVersion && - gt(latestVersion, currentVersion) - ); + valid(latestVersion) && + valid(currentVersion) && + ignoredVersionUpdate !== latestVersion + ) { + try { + showNewVersionPopup = gt(latestVersion, currentVersion); + } catch { + showNewVersionPopup = false; + } + } + + const readUpdateIds = Array.isArray(productUpdatesRead) + ? productUpdatesRead + : []; const filteredUpdates = data.updates.filter( - (update) => !productUpdatesRead.includes(update.id) + (update) => !readUpdateIds.includes(update.id) ); if (filteredUpdates.length === 0 && !showNewVersionPopup) { @@ -133,17 +165,14 @@ export default function ProductUpdates({ show={filteredUpdates.length > 0} onDimissAll={() => setProductUpdatesRead([ - ...productUpdatesRead, + ...readUpdateIds, ...filteredUpdates.map( (update) => update.id ) ]) } onDimiss={(id) => - setProductUpdatesRead([ - ...productUpdatesRead, - id - ]) + setProductUpdatesRead([...readUpdateIds, id]) } /> @@ -151,11 +180,9 @@ export default function ProductUpdates({ { - setIgnoredVersionUpdate( - data.latestVersion?.data?.pangolin.latestVersion ?? null - ); + setIgnoredVersionUpdate(latestVersion ?? null); }} show={showNewVersionPopup} /> @@ -346,6 +373,10 @@ function NewVersionAvailable({ } }, [show]); + if (!version?.pangolin?.latestVersion) { + return null; + } + return ( {version && ( diff --git a/src/components/RoleForm.tsx b/src/components/RoleForm.tsx index ea45817ae1..102aa8e3a8 100644 --- a/src/components/RoleForm.tsx +++ b/src/components/RoleForm.tsx @@ -61,7 +61,7 @@ export function parseUnixGroups(value: string | undefined): string[] { if (!value?.trim()) return []; return value - .split(/[,\s\n]+/) + .split(/\r?\n/) .map((group) => group.trim()) .filter(Boolean); } @@ -69,18 +69,10 @@ export function parseUnixGroups(value: string | undefined): string[] { export function parseSudoCommands(value: string | undefined): string[] { if (!value?.trim()) return []; - const commands: string[] = []; - for (const segment of value.split(/[,\n]+/)) { - const trimmed = segment.trim(); - if (!trimmed) continue; - - for (const part of trimmed.split(/ (?=\/)/)) { - const command = part.trim(); - if (command) commands.push(command); - } - } - - return commands; + return value + .split(/\r?\n/) + .map((command) => command.trim()) + .filter(Boolean); } function hasOnlyAbsoluteSudoCommands(value: string | undefined): boolean { diff --git a/src/components/SiteInfoCard.tsx b/src/components/SiteInfoCard.tsx index 21697d6973..a5e639c289 100644 --- a/src/components/SiteInfoCard.tsx +++ b/src/components/SiteInfoCard.tsx @@ -9,6 +9,7 @@ import { InfoSectionTitle } from "@app/components/InfoSection"; import { useTranslations } from "next-intl"; +import { countryCodeToFlagEmoji } from "@app/lib/countryCodeToFlagEmoji"; type SiteInfoCardProps = {}; @@ -52,7 +53,11 @@ export default function SiteInfoCard({}: SiteInfoCardProps) { {t("publicIpEndpoint")} - {formatPublicEndpoint(site.endpoint)} + {formatPublicEndpoint(site.endpoint)}  + + {site.countryCode && + countryCodeToFlagEmoji(site.countryCode)} + ) : null; diff --git a/src/components/SitesTable.tsx b/src/components/SitesTable.tsx index 8c3036c4a5..3dc7a56dae 100644 --- a/src/components/SitesTable.tsx +++ b/src/components/SitesTable.tsx @@ -19,6 +19,7 @@ import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, + DropdownMenuSeparator, DropdownMenuTrigger } from "@app/components/ui/dropdown-menu"; import { InfoPopup } from "@app/components/ui/info-popup"; @@ -55,6 +56,9 @@ import { usePaidStatus } from "@app/hooks/usePaidStatus"; import { tierMatrix } from "@server/lib/billing/tierMatrix"; import { LabelColumnFilterButton } from "./LabelColumnFilterButton"; import { LabelsTableCell } from "./LabelsTableCell"; +import { useQuery } from "@tanstack/react-query"; +import { productUpdatesQueries } from "@app/lib/queries"; +import semver from "semver"; export type SiteRow = { id: number; @@ -101,6 +105,7 @@ export default function SitesTable({ } = useNavigationContext(); const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false); + const [deleteWithResources, setDeleteWithResources] = useState(false); const [selectedSite, setSelectedSite] = useState(null); const [resourcesDialogSite, setResourcesDialogSite] = useState(null); @@ -113,12 +118,11 @@ export default function SitesTable({ const api = createApiClient(useEnvContext()); const t = useTranslations(); - // useEffect(() => { - // const interval = setInterval(() => { - // router.refresh(); - // }, 30_000); - // return () => clearInterval(interval); - // }, []); + const { data: latestVersions } = useQuery( + productUpdatesQueries.latestVersion(true) + ); + + const latestNewtVersion = latestVersions?.data?.newt?.latestVersion; const booleanSearchFilterSchema = z .enum(["true", "false"]) @@ -155,10 +159,12 @@ export default function SitesTable({ }); } - function deleteSite(siteId: number) { + function deleteSite(siteId: number, withResources: boolean) { startTransition(async () => { await api - .delete(`/site/${siteId}`) + .delete(`/site/${siteId}`, { + params: { deleteResources: withResources } + }) .catch((e) => { console.error(t("siteErrorDelete"), e); toast({ @@ -333,6 +339,11 @@ export default function SitesTable({ cell: ({ row }) => { const originalRow = row.original; + let updateAvailable = + latestNewtVersion && + originalRow.newtVersion && + semver.lt(originalRow.newtVersion, latestNewtVersion); + if (originalRow.type === "newt") { return (
@@ -346,7 +357,7 @@ export default function SitesTable({ )}
- {originalRow.newtUpdateAvailable && ( + {updateAvailable && ( @@ -514,16 +525,33 @@ export default function SitesTable({ )} + { setSelectedSite(siteRow); + setDeleteWithResources(false); setIsDeleteModalOpen(true); }} > - {t("delete")} + {t("sitesTableDeleteSite")} + {siteRow.resourceCount <= 250 && ( + { + setSelectedSite(siteRow); + setDeleteWithResources(true); + setIsDeleteModalOpen(true); + }} + > + + {t( + "sitesTableDeleteSiteAndResources" + )} + + + )} { setIsDeleteModalOpen(val); setSelectedSite(null); + setDeleteWithResources(false); }} dialog={
-

{t("siteQuestionRemove")}

-

{t("siteMessageRemove")}

+

+ {deleteWithResources + ? t("siteQuestionRemoveAndResources") + : t("siteQuestionRemove")} +

+

+ {deleteWithResources + ? t("siteMessageRemoveAndResources") + : t("siteMessageRemove")} +

} - buttonText={t("siteConfirmDelete")} + buttonText={ + deleteWithResources + ? t("siteConfirmDeleteAndResources") + : t("siteConfirmDelete") + } onConfirm={async () => - startTransition(() => deleteSite(selectedSite!.id)) + startTransition(() => + deleteSite(selectedSite!.id, deleteWithResources) + ) } string={selectedSite.name} - title={t("siteDelete")} + title={ + deleteWithResources + ? t("siteDeleteAndResources") + : t("siteDelete") + } /> )} diff --git a/src/components/UserDevicesTable.tsx b/src/components/UserDevicesTable.tsx index 8ee2ddb876..17a82dfc95 100644 --- a/src/components/UserDevicesTable.tsx +++ b/src/components/UserDevicesTable.tsx @@ -38,6 +38,12 @@ import { ColumnFilterButton } from "./ColumnFilterButton"; import IdpTypeBadge from "./IdpTypeBadge"; import { Badge } from "./ui/badge"; import { ControlledDataTable } from "./ui/controlled-data-table"; +import { + productUpdatesQueries, + type LatestVersionResponse +} from "@app/lib/queries"; +import { useQuery } from "@tanstack/react-query"; +import semver from "semver"; export type ClientRow = { id: number; @@ -100,6 +106,9 @@ export default function UserDevicesTable({ searchParams } = useNavigationContext(); const [isRefreshing, startTransition] = useTransition(); + const data = useQuery(productUpdatesQueries.latestVersion(true)); + + const latestPlatformVersions = data.data?.data; const defaultUserColumnVisibility = { subnet: false, @@ -555,6 +564,37 @@ export default function UserDevicesTable({ cell: ({ row }) => { const originalRow = row.original; + const agentVersionMap: Record = { + "Pangolin Windows": "windows", + "Pangolin Android": "android", + "Pangolin iOS": "ios", + "Pangolin iPadOS": "ios", + "Pangolin macOS": "mac", + "Pangolin CLI": "cli", + "Olm CLI": "olm" + }; + + let updateAvailable = false; + + if ( + originalRow.olmVersion && + originalRow.agent && + latestPlatformVersions + ) { + const agent = agentVersionMap[ + originalRow.agent + ] as keyof LatestVersionResponse; + + if (agent in latestPlatformVersions) { + const agentVersion = latestPlatformVersions[agent]; + + updateAvailable = semver.lt( + originalRow.olmVersion, + agentVersion.latestVersion + ); + } + } + return (
{originalRow.agent && originalRow.olmVersion ? ( @@ -567,9 +607,9 @@ export default function UserDevicesTable({ "-" )} - {/*originalRow.olmUpdateAvailable && ( - - )*/} + {updateAvailable && ( + + )}
); } @@ -714,7 +754,7 @@ export default function UserDevicesTable({ } return allOptions; - }, [t]); + }, [t, latestPlatformVersions]); function handleFilterChange( column: string, diff --git a/src/components/newt-install-commands.tsx b/src/components/newt-install-commands.tsx index badc174cc6..b720a84782 100644 --- a/src/components/newt-install-commands.tsx +++ b/src/components/newt-install-commands.tsx @@ -139,7 +139,6 @@ Restart=always RestartSec=2 UMask=0077 -NoNewPrivileges=true PrivateTmp=true [Install] diff --git a/src/components/resource-policy/PolicyAccessRulesTable.tsx b/src/components/resource-policy/PolicyAccessRulesTable.tsx index a701b92ff9..b8445a44cd 100644 --- a/src/components/resource-policy/PolicyAccessRulesTable.tsx +++ b/src/components/resource-policy/PolicyAccessRulesTable.tsx @@ -74,6 +74,7 @@ import { sortPolicyRulesForResourceOverlay, type PolicyAccessRule } from "./policy-access-rule-utils"; +import { countryCodeToFlagEmoji } from "@app/lib/countryCodeToFlagEmoji"; export type PolicyAccessRulesTableProps = { rules: PolicyAccessRule[]; @@ -490,8 +491,17 @@ export function PolicyAccessRulesTable({ { accessorKey: "value", header: () => {t("value")}, - cell: ({ row }) => - row.original.match === "COUNTRY" ? ( + cell: ({ row }) => { + let selectedCountry: (typeof COUNTRIES)[number] | undefined; + if ( + row.original.match === "COUNTRY" && + row.original.value + ) { + selectedCountry = COUNTRIES.find( + (c) => c.code === row.original.value + ); + } + return row.original.match === "COUNTRY" ? ( @@ -540,6 +557,13 @@ export function PolicyAccessRulesTable({ + + {country.code === "ALL" + ? "🌍" + : countryCodeToFlagEmoji( + country.code + )} + {country.name} ( {country.code}) @@ -767,7 +791,8 @@ export function PolicyAccessRulesTable({ }); }} /> - ) + ); + } }, { accessorKey: "enabled", diff --git a/src/components/resource-policy/policy-access-rule-validation.ts b/src/components/resource-policy/policy-access-rule-validation.ts index 387d2003b8..a5c9d32e0a 100644 --- a/src/components/resource-policy/policy-access-rule-validation.ts +++ b/src/components/resource-policy/policy-access-rule-validation.ts @@ -83,9 +83,19 @@ export function createPolicyRuleValueSchema(t: TranslateFn, match: string) { { message: t("rulesErrorInvalidCountryDescription") } ); case "ASN": - return required.refine((value) => /^AS\d+$/i.test(value.trim()), { - message: t("rulesErrorInvalidAsnDescription") - }); + return required.refine( + (value) => { + const normalizedValue = value.trim().toUpperCase(); + return ( + /^AS\d+$/.test(normalizedValue) || + normalizedValue === "ALL" || + normalizedValue === "AS0" + ); + }, + { + message: t("rulesErrorInvalidAsnDescription") + } + ); default: return required; } diff --git a/src/lib/queries.ts b/src/lib/queries.ts index 7d224c7b10..b8a50a9080 100644 --- a/src/lib/queries.ts +++ b/src/lib/queries.ts @@ -63,6 +63,34 @@ export type LatestVersionResponse = { latestVersion: string; releaseNotes: string; }; + newt: { + latestVersion: string; + releaseNotes: string; + }; + cli: { + latestVersion: string; + releaseNotes: string; + }; + "panglin-node": { + latestVersion: string; + releaseNotes: string; + }; + windows: { + latestVersion: string; + releaseNotes: string; + }; + android: { + latestVersion: string; + releaseNotes: string; + }; + mac: { + latestVersion: string; + releaseNotes: string; + }; + ios: { + latestVersion: string; + releaseNotes: string; + }; }; export const productUpdatesQueries = {