Merge pull request #3330 from fosrl/dev

1.19.3
This commit is contained in:
Owen Schwartz 2026-06-25 11:54:25 -07:00 committed by GitHub
commit c5ffca499e
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
141 changed files with 4740 additions and 2256 deletions

View file

@ -18,5 +18,8 @@
"[json]": {
"editor.defaultFormatter": "esbenp.prettier-vscode"
},
"editor.formatOnSave": true
"editor.formatOnSave": true,
"cSpell.words": [
"nessicary"
]
}

View file

@ -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 услуги.",

View file

@ -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.",

View file

@ -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"
}
}

View file

@ -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.",

View file

@ -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.",

View file

@ -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.",

View file

@ -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.",

View file

@ -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.",

View file

@ -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 주소를 유지하도록 프록시 프로토콜을 구성하세요.",

View file

@ -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.",

View file

@ -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.",

View file

@ -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.",

View file

@ -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.",

View file

@ -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.",

View file

@ -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.",

View file

@ -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 地址。",

View file

@ -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<void> {
}
}
export async function invalidateAllSessionsExceptCurrent(
userId: string,
currentSessionId: string
): Promise<void> {
try {
await db.transaction(async (trx) => {
const userSessions = await trx
.select()
.from(sessions)
.where(
and(
eq(sessions.userId, userId),
ne(sessions.sessionId, currentSessionId)
)
);
if (userSessions.length > 0) {
await trx.delete(resourceSessions).where(
inArray(
resourceSessions.userSessionId,
userSessions.map((s) => s.sessionId)
)
);
}
await trx
.delete(sessions)
.where(
and(
eq(sessions.userId, userId),
ne(sessions.sessionId, currentSessionId)
)
);
});
} catch (e) {
logger.error("Failed to invalidate user sessions except current", e);
}
}
export function serializeSessionCookie(
token: string,
isSecure: boolean,

View file

@ -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"

View file

@ -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")

View file

@ -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, {
export const targetHealthCheck = pgTable(
"targetHealthCheck",
{
targetHealthCheckId: serial("targetHealthCheckId").primaryKey(),
targetId: integer("targetId").references(() => targets.targetId, {
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)
});
}),
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(),

View file

@ -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 = <T extends (...args: any[]) => any>(fn: T): T => {
return function (this: any, ...args: any[]) {
try {
return fn.apply(this, args);
} finally {
try {
// finalize() exists on the native Statement at runtime but
// is missing from @types/better-sqlite3.
(stmt as any).finalize();
} catch {
// Already finalized — harmless
}
}
} as unknown as T;
};
stmt.run = wrapExec(stmt.run);
stmt.get = wrapExec(stmt.get);
stmt.all = wrapExec(stmt.all);
return stmt;
}
function createDb() {
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");
// 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.
// 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");
// 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.
// 256 MB memory-mapped I/O — OS serves reads from page cache directly,
// reducing event-loop blocking.
sqlite.pragma("mmap_size = 268435456");
// 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

View file

@ -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();

View file

@ -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,

View file

@ -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<Blueprint> {
// Validate the input data
const validationResult = ConfigSchema.safeParse(configData);
if (!validationResult.success) {
throw new Error(fromError(validationResult.error).toString());
}
const config: Config = validationResult.data;
let blueprintSucceeded: boolean = false;
let 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
)
)
)
.catch((e) => {
logger.error(
`Failed to rebuild and handle messaging for site resource ${result.newSiteResource.siteResourceId}. Error: ${e}`
);
await rebuildClientAssociationsFromSiteResource(
result.oldSiteResource,
trx
);
const [insertedSiteResource] = await trx
.insert(siteResources)
.values({
...result.newSiteResource
})
.returning();
// wait some time to allow for messages to be handled
await new Promise((resolve) => setTimeout(resolve, 750));
//////////////////// update the associations ////////////////////
if (existingRoleIds.length > 0) {
await trx.insert(roleSiteResources).values(
existingRoleIds.map((roleId) => ({
roleId,
siteResourceId:
insertedSiteResource!.siteResourceId
}))
);
}
if (existingUserIds.length > 0) {
await trx.insert(userSiteResources).values(
existingUserIds.map((userId) => ({
userId,
siteResourceId:
insertedSiteResource!.siteResourceId
}))
);
}
if (existingClientIds.length > 0) {
await trx.insert(clientSiteResources).values(
existingClientIds.map((clientId) => ({
clientId,
siteResourceId:
insertedSiteResource!.siteResourceId
}))
);
}
await rebuildClientAssociationsFromSiteResource(
insertedSiteResource,
trx
);
} else {
let good = true;
for (const newSite of result.newSites) {
const [site] = await trx
.select()
.from(sites)
.innerJoin(newts, eq(sites.siteId, newts.siteId))
.where(
and(
eq(sites.siteId, newSite.siteId),
eq(sites.orgId, orgId),
eq(sites.type, "newt"),
isNotNull(sites.pubKey)
)
)
.limit(1);
if (!site) {
logger.debug(
`No newt sites found for client resource ${result.newSiteResource.siteResourceId}, skipping target update`
);
good = false;
break;
}
logger.debug(
`Updating client resource ${result.newSiteResource.siteResourceId} on site ${newSite.siteId}`
);
}
if (!good) {
continue;
}
await handleMessagingForUpdatedSiteResource(
result.oldSiteResource,
result.newSiteResource,
result.newSites.map((site) => ({
siteId: site.siteId,
orgId: result.newSiteResource.orgId
})),
trx
);
}
// await addClientTargets(
// site.newt.newtId,
// result.resource.destination,
// result.resource.destinationPort,
// result.resource.protocol,
// result.resource.proxyPort
// );
});
}
logger.debug(
`Successfully updated private resources for org ${orgId}: ${JSON.stringify(privateResourcesResults)}`
);
});
blueprintSucceeded = true;

View file

@ -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<number, ClientRow>();
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<void> {
const trx = primaryDb;
const queuedAssociationRebuilds: ClientRow[] = [];
const execute = async (transaction: Transaction | typeof db) => {
const orgCache = new Map<string, typeof orgs.$inferSelect | null>();
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<void> {
// 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(

View file

@ -0,0 +1,144 @@
import { eq, inArray } from "drizzle-orm";
import {
db,
newts,
resourcePolicies,
resources,
sites,
targetHealthCheck,
targets,
type Resource,
type Target,
type TargetHealthCheck,
type Transaction
} from "@server/db";
import logger from "@server/logger";
import { removeTargets } from "@server/routers/newt/targets";
import createHttpError from "http-errors";
import HttpCode from "@server/types/HttpCode";
export type DeleteResourceResult = {
deletedResource: Resource;
targetsToBeRemoved: Target[];
healthChecksToBeRemoved: TargetHealthCheck[];
};
export async function performDeleteResources(
resourceIds: number[],
trx: Transaction | typeof db = db
): Promise<DeleteResourceResult[]> {
if (resourceIds.length === 0) {
return [];
}
const targetsToBeRemoved = await trx
.select()
.from(targets)
.where(inArray(targets.resourceId, resourceIds));
const targetIds = targetsToBeRemoved.map((t) => t.targetId);
const healthChecksToBeRemoved =
targetIds.length > 0
? await trx
.select()
.from(targetHealthCheck)
.where(inArray(targetHealthCheck.targetId, targetIds))
: [];
const deletedResources = await trx
.delete(resources)
.where(inArray(resources.resourceId, resourceIds))
.returning();
const policyIds = deletedResources
.map((resource) => resource.defaultResourcePolicyId)
.filter((id): id is number => id != null);
if (policyIds.length > 0) {
await trx
.delete(resourcePolicies)
.where(inArray(resourcePolicies.resourcePolicyId, policyIds));
}
if (deletedResources.length > 0) {
logger.debug(`Deleted ${deletedResources.length} resources`);
}
const targetsByResourceId = new Map<number, Target[]>();
for (const target of targetsToBeRemoved) {
const existing = targetsByResourceId.get(target.resourceId) ?? [];
existing.push(target);
targetsByResourceId.set(target.resourceId, existing);
}
const targetIdToResourceId = new Map(
targetsToBeRemoved.map((target) => [target.targetId, target.resourceId])
);
const healthChecksByResourceId = new Map<number, TargetHealthCheck[]>();
for (const healthCheck of healthChecksToBeRemoved) {
const resourceId = targetIdToResourceId.get(healthCheck.targetId!);
if (resourceId == null) {
continue;
}
const existing = healthChecksByResourceId.get(resourceId) ?? [];
existing.push(healthCheck);
healthChecksByResourceId.set(resourceId, existing);
}
return deletedResources.map((deletedResource) => ({
deletedResource,
targetsToBeRemoved:
targetsByResourceId.get(deletedResource.resourceId) ?? [],
healthChecksToBeRemoved:
healthChecksByResourceId.get(deletedResource.resourceId) ?? []
}));
}
export async function performDeleteResource(
resourceId: number,
trx: Transaction | typeof db = db
): Promise<DeleteResourceResult | null> {
const [result] = await performDeleteResources([resourceId], trx);
return result ?? null;
}
export async function runResourceDeleteSideEffects(
result: DeleteResourceResult
): Promise<void> {
const { deletedResource, targetsToBeRemoved, healthChecksToBeRemoved } =
result;
for (const target of targetsToBeRemoved) {
const [site] = await db
.select()
.from(sites)
.where(eq(sites.siteId, target.siteId))
.limit(1);
if (!site) {
throw createHttpError(
HttpCode.NOT_FOUND,
`Site with ID ${target.siteId} not found`
);
}
if (site.pubKey && site.type === "newt") {
const [newt] = await db
.select()
.from(newts)
.where(eq(newts.siteId, site.siteId))
.limit(1);
if (newt) {
await removeTargets(
newt.newtId,
[],
healthChecksToBeRemoved,
deletedResource.mode === "udp" ? "udp" : "tcp",
newt.version
);
}
}
}
}

View file

@ -0,0 +1,126 @@
import { and, eq, sql } from "drizzle-orm";
import {
db,
siteNetworks,
siteResources,
targets,
type SiteResource,
type Transaction
} from "@server/db";
import {
performDeleteResources,
runResourceDeleteSideEffects,
type DeleteResourceResult
} from "@server/lib/deleteResource";
import {
performDeleteSiteResources,
runSiteResourceDeleteSideEffects
} from "@server/lib/deleteSiteResource";
import logger from "@server/logger";
export const MAX_SITE_ASSOCIATED_RESOURCES_FOR_BULK_DELETE = 250;
export type DeleteSiteAssociatedResourcesSideEffects = {
resources: DeleteResourceResult[];
siteResources: SiteResource[];
};
export async function getResourceIdsForSite(
siteId: number,
trx: Transaction | typeof db = db
): Promise<number[]> {
const rows = await trx
.selectDistinct({ resourceId: targets.resourceId })
.from(targets)
.where(eq(targets.siteId, siteId));
return rows.map((row) => row.resourceId);
}
export async function getSiteResourceIdsForSite(
siteId: number,
orgId: string,
trx: Transaction | typeof db = db
): Promise<number[]> {
const rows = await trx
.selectDistinct({ siteResourceId: siteResources.siteResourceId })
.from(siteNetworks)
.innerJoin(
siteResources,
eq(siteResources.networkId, siteNetworks.networkId)
)
.where(
and(eq(siteNetworks.siteId, siteId), eq(siteResources.orgId, orgId))
);
return rows.map((row) => row.siteResourceId);
}
export async function getAssociatedResourceCountForSite(
siteId: number,
orgId: string,
trx: Transaction | typeof db = db
): Promise<number> {
const [publicCountResult, privateCountResult] = await Promise.all([
trx
.select({
count: sql<number>`count(distinct ${targets.resourceId})`
})
.from(targets)
.where(eq(targets.siteId, siteId)),
trx
.select({
count: sql<number>`count(distinct ${siteResources.siteResourceId})`
})
.from(siteNetworks)
.innerJoin(
siteResources,
eq(siteResources.networkId, siteNetworks.networkId)
)
.where(
and(
eq(siteNetworks.siteId, siteId),
eq(siteResources.orgId, orgId)
)
)
]);
return (
Number(publicCountResult[0]?.count ?? 0) +
Number(privateCountResult[0]?.count ?? 0)
);
}
export function exceedsSiteAssociatedResourceDeleteLimit(
resourceCount: number
): boolean {
return resourceCount > MAX_SITE_ASSOCIATED_RESOURCES_FOR_BULK_DELETE;
}
export async function deleteAssociatedResourcesForSite(
siteId: number,
orgId: string,
trx: Transaction | typeof db = db
): Promise<DeleteSiteAssociatedResourcesSideEffects> {
const resourceIds = await getResourceIdsForSite(siteId, trx);
const siteResourceIds = await getSiteResourceIdsForSite(siteId, orgId, trx);
const [resources, siteResourcesDeleted] = await Promise.all([
performDeleteResources(resourceIds, trx),
performDeleteSiteResources(siteResourceIds, trx)
]);
return { resources, siteResources: siteResourcesDeleted };
}
export async function runDeleteSiteAssociatedResourcesSideEffects(
sideEffects: DeleteSiteAssociatedResourcesSideEffects
): Promise<void> {
for (const result of sideEffects.resources) {
await runResourceDeleteSideEffects(result);
}
for (const removed of sideEffects.siteResources) {
runSiteResourceDeleteSideEffects(removed);
}
}

View file

@ -0,0 +1,53 @@
import { inArray } from "drizzle-orm";
import {
db,
siteResources,
type SiteResource,
type Transaction
} from "@server/db";
import logger from "@server/logger";
import { rebuildClientAssociationsFromSiteResource } from "@server/lib/rebuildClientAssociations";
export async function performDeleteSiteResources(
siteResourceIds: number[],
trx: Transaction | typeof db = db
): Promise<SiteResource[]> {
if (siteResourceIds.length === 0) {
return [];
}
const removedSiteResources = await trx
.delete(siteResources)
.where(inArray(siteResources.siteResourceId, siteResourceIds))
.returning();
if (removedSiteResources.length > 0) {
logger.debug(`Deleted ${removedSiteResources.length} site resources`);
}
return removedSiteResources;
}
export async function performDeleteSiteResource(
siteResourceId: number,
trx: Transaction | typeof db = db
): Promise<SiteResource | null> {
const [removedSiteResource] = await performDeleteSiteResources(
[siteResourceId],
trx
);
return removedSiteResource ?? null;
}
export function runSiteResourceDeleteSideEffects(
removedSiteResource: SiteResource
): void {
rebuildClientAssociationsFromSiteResource(removedSiteResource).catch(
(err) => {
logger.error(
`Error rebuilding client associations for site resource ${removedSiteResource.siteResourceId}:`,
err
);
}
);
}

View file

@ -1,4 +1,24 @@
const instanceId = `local-${Math.random().toString(36).slice(2)}-${Date.now()}`;
type LocalLockRecord = {
owner: string;
expiresAt: number;
};
const localLocks = new Map<string, LocalLockRecord>();
export class LockManager {
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<boolean> {
return true;
for (let attempt = 0; attempt < maxRetries; attempt++) {
this.clearExpiredLocalLock(lockKey);
const existing = localLocks.get(lockKey);
if (!existing) {
localLocks.set(lockKey, {
owner: this.getLocalOwnerToken(),
expiresAt: Date.now() + ttlMs
});
return true;
}
if (existing.owner === this.getLocalOwnerToken()) {
existing.expiresAt = Date.now() + ttlMs;
localLocks.set(lockKey, existing);
return true;
}
if (attempt < maxRetries - 1) {
const delay = retryDelayMs * Math.pow(2, attempt);
await new Promise((resolve) => setTimeout(resolve, delay));
}
}
return false;
}
/**
* Release a lock using Lua script to ensure atomicity
* @param lockKey - Unique identifier for the lock
*/
async releaseLock(lockKey: string): Promise<void> {}
async releaseLock(lockKey: string): Promise<void> {
this.clearExpiredLocalLock(lockKey);
const existing = localLocks.get(lockKey);
if (existing && existing.owner === this.getLocalOwnerToken()) {
localLocks.delete(lockKey);
}
}
/**
* Force release a lock regardless of owner (use with caution)
* @param lockKey - Unique identifier for the lock
*/
async forceReleaseLock(lockKey: string): Promise<void> {}
async forceReleaseLock(lockKey: string): Promise<void> {
localLocks.delete(lockKey);
}
/**
* Check if a lock exists and get its info
@ -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<boolean> - true if extended successfully
*/
async extendLock(lockKey: string, ttlMs: number): Promise<boolean> {
this.clearExpiredLocalLock(lockKey);
const existing = localLocks.get(lockKey);
if (!existing || existing.owner !== this.getLocalOwnerToken()) {
return false;
}
existing.expiresAt = Date.now() + ttlMs;
localLocks.set(lockKey, existing);
return true;
}
@ -62,7 +139,26 @@ export class LockManager {
maxRetries: number = 5,
baseDelayMs: number = 100
): Promise<boolean> {
return true;
for (let attempt = 0; attempt <= maxRetries; attempt++) {
const acquired = await this.acquireLock(
lockKey,
ttlMs,
1,
baseDelayMs
);
if (acquired) {
return true;
}
if (attempt < maxRetries) {
const delay =
baseDelayMs * Math.pow(2, attempt) + Math.random() * 100;
await new Promise((resolve) => setTimeout(resolve, delay));
}
}
return false;
}
/**
@ -99,7 +195,21 @@ export class LockManager {
activeLocksCount: number;
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 };
}
/**

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,27 @@
export type RebuildJobType = "site-resource" | "client";
export interface RebuildJob {
type: RebuildJobType;
id: number;
}
export interface RebuildJobHandlers {
onSiteResource(siteResourceId: number): Promise<void>;
onClient(clientId: number): Promise<void>;
}
export interface RebuildQueueManager {
enqueue(job: RebuildJob): Promise<void>;
startProcessing(handlers: RebuildJobHandlers): void;
isQueued(job: RebuildJob): Promise<boolean>;
}
class NoopRebuildQueue implements RebuildQueueManager {
async enqueue(_job: RebuildJob): Promise<void> {}
startProcessing(_handlers: RebuildJobHandlers): void {}
async isQueued(_job: RebuildJob): Promise<boolean> {
return false;
}
}
export const rebuildQueue: RebuildQueueManager = new NoopRebuildQueue();

View file

@ -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 }[] =
[];

View file

@ -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!");
}

View file

@ -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:

View file

@ -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")
)
);
}

View file

@ -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")
)
);
}

View file

@ -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")
)
);
}

View file

@ -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)

View file

@ -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")
)
);
}

View file

@ -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")
)
);
}

View file

@ -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")
)
);
}

View file

@ -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")
)
);
}

View file

@ -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")
)
);
}

View file

@ -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")
)
);
}

View file

@ -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")
)
);
}

View file

@ -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")
)
);
}

View file

@ -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")
)
);
}

View file

@ -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")
)
);
}

View file

@ -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")
)
);
}

View file

@ -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")
)
);
}

View file

@ -693,9 +693,9 @@ async function syncAcmeCerts(acmeJsonPath: string): Promise<void> {
);
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);
}

View file

@ -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

View file

@ -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.")
};
}

View file

@ -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<string, LocalLockRecord>();
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<boolean> {
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<void> {
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<void> {
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<boolean> {
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<boolean> {
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<T>,
ttlMs: number = 30000
): Promise<T> {
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;
}

View file

@ -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<void>;
onClient(clientId: number): Promise<void>;
}
// Redis list holding pending rebuild jobs (RPUSH to enqueue, LPOP to dequeue — FIFO order).
const QUEUE_KEY = "rebuild-client-associations:queue";
const QUEUED_SET_KEY = "rebuild-client-associations:queued";
// Distributed lock that serialises queue consumption to a single server instance
// at a time. TTL is generous enough to cover a full batch of expensive rebuilds.
const PROCESSOR_LOCK_KEY = "rebuild-client-associations:processor";
// Each rebuild can take up to REBUILD_ASSOCIATIONS_LOCK_TTL_MS (120 s) per
// resource. Allow BATCH_SIZE resources per processor-lock acquisition, plus a
// small buffer.
const BATCH_SIZE = 5;
const PROCESSOR_LOCK_TTL_MS = 120000 * BATCH_SIZE + 30000; // ~630 s
const POLL_INTERVAL_MS = 500;
class RedisRebuildQueue {
private processingStarted = false;
async isQueued(job: RebuildJob): Promise<boolean> {
if (!redis || redis.status !== "ready") return false;
const dedupeKey = `${job.type}:${job.id}`;
try {
const member = await redis.sismember(QUEUED_SET_KEY, dedupeKey);
return member === 1;
} catch {
return false;
}
}
async enqueue(job: RebuildJob): Promise<void> {
if (!redis || redis.status !== "ready") {
logger.warn(
`Rebuild queue: Redis not available — rebuild for ${job.type}:${job.id} will not be retried`
);
return;
}
try {
const dedupeKey = `${job.type}:${job.id}`;
const added = await redis.sadd(QUEUED_SET_KEY, dedupeKey);
if (added === 0) {
logger.debug(
`Rebuild queue: skipped duplicate queued job ${job.type}:${job.id}`
);
return;
}
await redis.rpush(QUEUE_KEY, JSON.stringify(job));
logger.debug(
`Rebuild queue: enqueued ${job.type}:${job.id} (queue position: tail)`
);
} catch (err) {
await redis
.srem(QUEUED_SET_KEY, `${job.type}:${job.id}`)
.catch((cleanupErr) =>
logger.warn(
`Rebuild queue: failed to cleanup dedupe key for ${job.type}:${job.id} after enqueue failure:`,
cleanupErr
)
);
logger.error(
`Rebuild queue: failed to enqueue ${job.type}:${job.id}:`,
err
);
}
}
startProcessing(handlers: RebuildJobHandlers): void {
if (this.processingStarted) return;
this.processingStarted = true;
this.processLoop(handlers).catch((err) => {
logger.error("Rebuild queue processor loop crashed:", err);
});
logger.info("Rebuild queue processor started");
}
private async processLoop(handlers: RebuildJobHandlers): Promise<void> {
while (true) {
try {
await this.tryProcessBatch(handlers);
} catch (err) {
logger.error(
"Rebuild queue: unhandled error in process loop:",
err
);
}
await new Promise((resolve) =>
setTimeout(resolve, POLL_INTERVAL_MS)
);
}
}
private async tryProcessBatch(handlers: RebuildJobHandlers): Promise<void> {
if (!redis || redis.status !== "ready") return;
// Peek before acquiring the processor lock to avoid unnecessary Redis
// round-trips and lock contention when the queue is idle.
const queueLength = await redis.llen(QUEUE_KEY).catch(() => 0);
if (queueLength === 0) return;
try {
await lockManager.withLock(
PROCESSOR_LOCK_KEY,
async () => {
for (let i = 0; i < BATCH_SIZE; i++) {
if (!redis || redis.status !== "ready") break;
const payload = await redis.lpop(QUEUE_KEY);
if (payload === null) break; // queue drained
let job: RebuildJob;
try {
job = JSON.parse(payload) as RebuildJob;
} catch {
logger.error(
`Rebuild queue: could not parse job payload, discarding: ${payload}`
);
continue;
}
// Remove from dedupe set once dequeued so the same job
// can be re-queued while this one is in progress.
await redis
.srem(QUEUED_SET_KEY, `${job.type}:${job.id}`)
.catch((cleanupErr) =>
logger.warn(
`Rebuild queue: failed to remove dedupe key for ${job.type}:${job.id} on dequeue:`,
cleanupErr
)
);
logger.debug(
`Rebuild queue: processing ${job.type}:${job.id}`
);
try {
if (job.type === "site-resource") {
await handlers.onSiteResource(job.id);
} else if (job.type === "client") {
await handlers.onClient(job.id);
} else {
logger.warn(
`Rebuild queue: unknown job type "${(job as any).type}", discarding`
);
}
logger.debug(
`Rebuild queue: completed ${job.type}:${job.id}`
);
} catch (err) {
logger.error(
`Rebuild queue: job ${job.type}:${job.id} threw an error:`,
err
);
}
}
},
PROCESSOR_LOCK_TTL_MS
);
} catch (err: any) {
if (
typeof err?.message === "string" &&
err.message.startsWith("Failed to acquire lock")
) {
// Another server instance currently holds the processor lock and
// is consuming the queue — nothing to do this cycle.
logger.debug(
"Rebuild queue: processor lock held by another instance, skipping this cycle"
);
} else {
throw err;
}
}
}
}
export const rebuildQueue: RedisRebuildQueue = new RedisRebuildQueue();

View file

@ -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
)
}
}
}

View file

@ -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;

View file

@ -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}`
);

View file

@ -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;

View file

@ -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, {

View file

@ -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, {

View file

@ -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, {

View file

@ -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<string, AuthenticatedWebSocket[]> = new Map();
@ -197,6 +200,15 @@ const clientConfigVersions: Map<string, number> = 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<void> => {
const redisMessage: RedisMessage = {
type: "direct-batch",
messages: entries.map((entry) => ({
targetClientId: entry.targetClientId,
message: entry.message
})),
fromNodeId: NODE_ID
};
await redisManager.publish(REDIS_CHANNEL, JSON.stringify(redisMessage));
};
const flushPendingRedisDirectMessages = async (): Promise<void> => {
clearRedisDirectFlushTimer();
if (pendingRedisDirectMessages.length === 0) {
return;
}
const entries = pendingRedisDirectMessages;
pendingRedisDirectMessages = [];
if (!redisManager.isRedisEnabled()) {
entries.forEach((entry) => entry.resolve());
return;
}
for (let i = 0; i < entries.length; i += REDIS_DIRECT_BATCH_SIZE) {
const batch = entries.slice(i, i + REDIS_DIRECT_BATCH_SIZE);
try {
await publishDirectBatch(batch);
} catch (error) {
logger.error(
"Failed to send batched direct messages via Redis, messages may be lost:",
error
);
} finally {
batch.forEach((entry) => entry.resolve());
}
}
};
const enqueueRedisDirectMessage = async (
targetClientId: string,
message: WSMessage
): Promise<void> => {
await new Promise<void>((resolve) => {
pendingRedisDirectMessages.push({ targetClientId, message, resolve });
if (pendingRedisDirectMessages.length >= REDIS_DIRECT_BATCH_SIZE) {
void flushPendingRedisDirectMessages();
return;
}
if (!redisDirectFlushTimer) {
redisDirectFlushTimer = setTimeout(() => {
void flushPendingRedisDirectMessages();
}, REDIS_DIRECT_FLUSH_INTERVAL_MS);
}
});
};
// Initialize Redis subscription for cross-node messaging
const initializeRedisSubscription = async (): Promise<void> => {
if (!redisManager.isRedisEnabled()) return;
@ -227,7 +311,16 @@ const initializeRedisSubscription = async (): Promise<void> => {
// 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<boolean> => {
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<void> => {
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<void> => {
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);
return {
clients,
messageWithVersion: {
...message,
configVersion
}
});
} else {
clients.forEach((client) => {
if (client.readyState === WebSocket.OPEN) {
client.send(JSON.stringify(messageWithVersion));
}
});
};
}
)
);
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,28 +725,23 @@ 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",
targetClientId: clientId,
message: {
...message,
configVersion
},
fromNodeId: NODE_ID
};
await redisManager.publish(
REDIS_CHANNEL,
JSON.stringify(redisMessage)
);
await enqueueRedisDirectMessage(clientId, {
...message,
configVersion
});
} catch (error) {
logger.error(
"Failed to send message via Redis, message may be lost:",
"Failed to queue batched direct message for Redis delivery, message may be lost:",
error
);
// Continue execution - local delivery already attempted
@ -638,6 +756,95 @@ const sendToClient = async (
return localSent;
};
const sendToClientsBatch = async (
entries: BatchSendMessage[]
): Promise<void> => {
if (entries.length === 0) {
return;
}
const remoteEntries: { targetClientId: string; message: WSMessage }[] = [];
const clientsWithIncrement = new Set(
entries
.filter((entry) => !!entry.options?.incrementConfigVersion)
.map((entry) => entry.clientId)
);
const nonIncrementOnlyClientIds = Array.from(
new Set(
entries
.map((entry) => entry.clientId)
.filter((clientId) => !clientsWithIncrement.has(clientId))
)
);
const stableConfigVersionByClient = new Map<string, number | undefined>(
await Promise.all(
nonIncrementOnlyClientIds.map(
async (clientId) =>
[clientId, await getClientConfigVersion(clientId)] as const
)
)
);
for (const entry of entries) {
const options = entry.options || {};
const { clientId, message } = entry;
const configVersion = options.incrementConfigVersion
? await incrementClientConfigVersion(clientId)
: stableConfigVersionByClient.get(clientId);
logger.debug(
`sendToClientsBatch: Message type ${message.type} queued for clientId ${clientId} (new configVersion: ${configVersion})`
);
const localSent = await sendToClientLocal(
clientId,
message,
options,
configVersion
);
if (!localSent && redisManager.isRedisEnabled()) {
remoteEntries.push({
targetClientId: clientId,
message: {
...message,
configVersion
}
});
} else if (!localSent && !redisManager.isRedisEnabled()) {
logger.debug(
`Could not deliver batch message to ${clientId} - not connected locally and Redis unavailable`
);
}
}
if (!redisManager.isRedisEnabled() || remoteEntries.length === 0) {
return;
}
for (let i = 0; i < remoteEntries.length; i += REDIS_DIRECT_BATCH_SIZE) {
const messages = remoteEntries.slice(i, i + REDIS_DIRECT_BATCH_SIZE);
try {
const redisMessage: RedisMessage = {
type: "direct-batch",
messages,
fromNodeId: NODE_ID
};
await redisManager.publish(
REDIS_CHANNEL,
JSON.stringify(redisMessage)
);
} catch (error) {
logger.error(
"Failed to send explicit direct batch via Redis, messages may be lost:",
error
);
}
}
};
const broadcastToAllExcept = async (
message: WSMessage,
excludeClientId?: string,
@ -1109,6 +1316,8 @@ const disconnectClient = async (clientId: string): Promise<boolean> => {
// Cleanup function for graceful shutdown
const cleanup = async (): Promise<void> => {
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,

View file

@ -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()

View file

@ -15,7 +15,7 @@ const paramsSchema = z.object({
apiKeyId: z.string().nonempty()
});
const querySchema = z.object({
const querySchema = z.strictObject({
limit: z
.string()
.optional()

View file

@ -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()

View file

@ -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()

View file

@ -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()

View file

@ -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<void> {
try {
await db.transaction(async (trx) => {
// Get all user sessions except the current one
const userSessions = await trx
.select()
.from(sessions)
.where(
and(
eq(sessions.userId, userId),
ne(sessions.sessionId, currentSessionId)
)
);
// Delete resource sessions for the sessions we're invalidating
if (userSessions.length > 0) {
await trx.delete(resourceSessions).where(
inArray(
resourceSessions.userSessionId,
userSessions.map((s) => s.sessionId)
)
);
}
// Delete the user sessions (except current)
await trx
.delete(sessions)
.where(
and(
eq(sessions.userId, userId),
ne(sessions.sessionId, currentSessionId)
)
);
});
} catch (e) {
logger.error("Failed to invalidate user sessions except current", e);
}
}
export async function changePassword(
req: Request,
res: Response,

View file

@ -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}`
);

View file

@ -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!,

View file

@ -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<CreateClientResponse>(res, {

View file

@ -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<CreateClientAndOlmResponse>(res, {

View file

@ -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,

View file

@ -41,7 +41,7 @@ const listClientsParamsSchema = z.strictObject({
orgId: z.string()
});
const listClientsSchema = z.object({
const listClientsSchema = z.strictObject({
pageSize: z.coerce
.number<string>() // for prettier formatting
.int()

View file

@ -40,7 +40,7 @@ const listUserDevicesParamsSchema = z.strictObject({
orgId: z.string()
});
const listUserDevicesSchema = z.object({
const listUserDevicesSchema = z.strictObject({
pageSize: z.coerce
.number<string>() // 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<ListUserDevicesResponse>(res, {
data: {
devices: olmsWithUpdates,

View file

@ -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) {

View file

@ -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<number, { olmId: string; version: string | null }>();
if (unresolvedClientIds.length > 0) {
const olmRows = await db
.select({
clientId: olms.clientId,
olmId: olms.olmId,
version: olms.version
})
.from(olms)
.where(inArray(olms.clientId, unresolvedClientIds));
for (const row of olmRows) {
if (row.clientId !== null) {
olmMap.set(row.clientId, {
olmId: row.olmId,
version: row.version
});
}
}
}
return entries
.map((entry) => {
if (entry.olmId) {
return {
clientId: entry.clientId,
olmId: entry.olmId,
version: entry.version
};
}
const resolved = olmMap.get(entry.clientId);
if (!resolved) {
return null;
}
return {
clientId: entry.clientId,
olmId: resolved.olmId,
version: entry.version ?? resolved.version
};
})
.filter((entry) => entry !== null);
};
export async function addPeerDataBatch(
entries: {
clientId: number;
siteId: number;
remoteSubnets: string[];
aliases: Alias[];
olmId?: string;
version?: string | null;
}[]
) {
if (entries.length === 0) {
return;
}
const resolvedTargets = await resolveOlmTargets(entries);
if (resolvedTargets.length === 0) {
return;
}
const payloads = entries
.map((entry) => {
const resolved = resolvedTargets.find(
(target) => target.clientId === entry.clientId
);
if (!resolved) {
return null;
}
return {
clientId: resolved.olmId,
message: {
type: `olm/wg/peer/data/add`,
data: {
siteId: entry.siteId,
remoteSubnets: entry.remoteSubnets,
aliases: entry.aliases
}
},
options: {
incrementConfigVersion: true,
compress: canCompress(resolved.version, "olm")
}
};
})
.filter((entry) => entry !== null);
if (payloads.length === 0) {
return;
}
await sendToClientsBatch(payloads);
}
export async function removePeerDataBatch(
entries: {
clientId: number;
siteId: number;
remoteSubnets: string[];
aliases: Alias[];
olmId?: string;
version?: string | null;
}[]
) {
if (entries.length === 0) {
return;
}
const resolvedTargets = await resolveOlmTargets(entries);
if (resolvedTargets.length === 0) {
return;
}
const payloads = entries
.map((entry) => {
const resolved = resolvedTargets.find(
(target) => target.clientId === entry.clientId
);
if (!resolved) {
return null;
}
return {
clientId: resolved.olmId,
message: {
type: `olm/wg/peer/data/remove`,
data: {
siteId: entry.siteId,
remoteSubnets: entry.remoteSubnets,
aliases: entry.aliases
}
},
options: {
incrementConfigVersion: true,
compress: canCompress(resolved.version, "olm")
}
};
})
.filter((entry) => entry !== null);
if (payloads.length === 0) {
return;
}
await sendToClientsBatch(payloads);
}
export async function updatePeerDataBatch(
entries: {
clientId: number;
siteId: number;
remoteSubnets:
| {
oldRemoteSubnets: string[];
newRemoteSubnets: string[];
}
| undefined;
aliases:
| {
oldAliases: Alias[];
newAliases: Alias[];
}
| undefined;
olmId?: string;
version?: string | null;
}[]
) {
if (entries.length === 0) {
return;
}
const resolvedTargets = await resolveOlmTargets(entries);
if (resolvedTargets.length === 0) {
return;
}
const payloads = entries
.map((entry) => {
const resolved = resolvedTargets.find(
(target) => target.clientId === entry.clientId
);
if (!resolved) {
return null;
}
return {
clientId: resolved.olmId,
message: {
type: `olm/wg/peer/data/update`,
data: {
siteId: entry.siteId,
...entry.remoteSubnets,
...entry.aliases
}
},
options: {
incrementConfigVersion: true,
compress: canCompress(resolved.version, "olm")
}
};
})
.filter((entry) => entry !== null);
if (payloads.length === 0) {
return;
}
await sendToClientsBatch(payloads);
}
export async function updatePeerData(
clientId: number,
siteId: number,

View file

@ -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 }

View file

@ -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.

View file

@ -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)

View file

@ -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 {

View file

@ -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;

View file

@ -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);
});

View file

@ -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

View file

@ -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,

View file

@ -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,

View file

@ -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()

View file

@ -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)`
);
}

View file

@ -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(

View file

@ -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()

View file

@ -14,7 +14,7 @@ const listOrgsParamsSchema = z.object({
userId: z.string()
});
const listOrgsSchema = z.object({
const listOrgsSchema = z.strictObject({
limit: z
.string()
.optional()

View file

@ -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));
let deleteResult = null;
const healthChecksToBeRemoved = await db
.select()
.from(targetHealthCheck)
.where(
inArray(
targetHealthCheck.targetId,
targetsToBeRemoved.map((t) => t.targetId)
)
);
await db.transaction(async (trx) => {
deleteResult = await performDeleteResource(resourceId, trx);
});
const [deletedResource] = await db
.delete(resources)
.where(eq(resources.resourceId, resourceId))
.returning();
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")
);

View file

@ -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")
)
);
}

View file

@ -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()

View file

@ -48,7 +48,7 @@ const listResourcesParamsSchema = z.strictObject({
orgId: z.string()
});
const listResourcesSchema = z.object({
const listResourcesSchema = z.strictObject({
pageSize: z.coerce
.number<string>() // for prettier formatting
.int()

View file

@ -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<string>()
.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<ListUserResourceAliasesResponse>(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<ListUserResourceAliasesResponse>(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<ListUserResourceAliasesResponse>(res, {
data,

View file

@ -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) {
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;
}
if (existingRule.resourceId !== resourceId) {
return next(
createHttpError(
HttpCode.FORBIDDEN,
`Resource rule ${ruleId} does not belong to resource ${resourceId}`
)
);
}
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,

View file

@ -15,7 +15,7 @@ const listRolesParamsSchema = z.strictObject({
orgId: z.string()
});
const listRolesSchema = z.object({
const listRolesSchema = z.strictObject({
pageSize: z.coerce
.number<string>() // for prettier formatting
.int()

Some files were not shown because too many files have changed in this diff Show more