From 13b691fd7d3af4cfe83251a0918b0ee79732c24a Mon Sep 17 00:00:00 2001 From: Aditya kumar singh <143548997+Adityakk9031@users.noreply.github.com> Date: Sat, 6 Jun 2026 00:24:24 +0530 Subject: [PATCH 01/88] fix: request logs not loading on initial page open in Community Edition (#2867) --- src/app/[orgId]/settings/logs/request/page.tsx | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/src/app/[orgId]/settings/logs/request/page.tsx b/src/app/[orgId]/settings/logs/request/page.tsx index ae11da78ab..1092ea388d 100644 --- a/src/app/[orgId]/settings/logs/request/page.tsx +++ b/src/app/[orgId]/settings/logs/request/page.tsx @@ -18,7 +18,6 @@ import Link from "next/link"; import { useParams, useRouter, useSearchParams } from "next/navigation"; import { useMemo, useState, useTransition } from "react"; import { useStoredPageSize } from "@app/hooks/useStoredPageSize"; -import { build } from "@server/build"; import type { QueryRequestAuditLogResponse } from "@server/routers/auditLogs/types"; export default function GeneralPage() { @@ -121,8 +120,7 @@ export default function GeneralPage() { ...logQueries.requests({ orgId: orgId as string, filters: queryFilters - }), - enabled: build !== "oss" + }) }); const rows = isLoading ? generateSampleRequestLogs() : (data?.log ?? []); From 38203e522b4dcb24cd4d7b677c212178aeeb1b65 Mon Sep 17 00:00:00 2001 From: Fred KISSIE Date: Tue, 9 Jun 2026 19:29:00 +0200 Subject: [PATCH 02/88] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20Remove=20queries=20t?= =?UTF-8?q?hat=20prefetch=201000=20users/roles=20in=20private=20resources?= =?UTF-8?q?=20form?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/PrivateResourceForm.tsx | 25 +++---------------------- 1 file changed, 3 insertions(+), 22 deletions(-) diff --git a/src/components/PrivateResourceForm.tsx b/src/components/PrivateResourceForm.tsx index 4a786954ce..b2298f534c 100644 --- a/src/components/PrivateResourceForm.tsx +++ b/src/components/PrivateResourceForm.tsx @@ -411,9 +411,9 @@ export function PrivateResourceForm({ type FormData = z.infer; - const rolesQuery = useQuery(orgQueries.roles({ orgId })); - const usersQuery = useQuery(orgQueries.users({ orgId })); - const clientsQuery = useQuery(orgQueries.machineClients({ orgId })); + const clientsQuery = useQuery( + orgQueries.machineClients({ orgId, perPage: 1 }) + ); const resourceRolesQuery = useQuery({ ...resourceQueries.siteResourceRoles({ siteResourceId: siteResourceId ?? 0 @@ -433,13 +433,6 @@ export function PrivateResourceForm({ enabled: siteResourceId != null }); - const allRoles = (rolesQuery.data ?? []) - .map((r) => ({ id: r.roleId.toString(), text: r.name })) - .filter((r) => r.text !== "Admin"); - const allUsers = (usersQuery.data ?? []).map((u) => ({ - id: u.id.toString(), - text: `${getUserDisplayName({ email: u.email, username: u.username })}${u.type !== UserType.Internal ? ` (${u.idpName})` : ""}` - })); const allClients = (clientsQuery.data ?? []) .filter((c) => !c.userId) .map((c) => ({ id: c.clientId.toString(), text: c.name })); @@ -478,8 +471,6 @@ export function PrivateResourceForm({ } const loadingRolesUsers = - rolesQuery.isLoading || - usersQuery.isLoading || clientsQuery.isLoading || (siteResourceId != null && (resourceRolesQuery.isLoading || @@ -488,16 +479,6 @@ export function PrivateResourceForm({ const hasMachineClients = allClients.length > 0; - const [activeRolesTagIndex, setActiveRolesTagIndex] = useState< - number | null - >(null); - const [activeUsersTagIndex, setActiveUsersTagIndex] = useState< - number | null - >(null); - const [activeClientsTagIndex, setActiveClientsTagIndex] = useState< - number | null - >(null); - const [sshServerMode, setSshServerMode] = useState<"standard" | "native">( () => { if (variant === "edit" && resource) { From ab4d567af9256849671cd736583d8de1e53178af Mon Sep 17 00:00:00 2001 From: Fred KISSIE Date: Wed, 10 Jun 2026 20:56:24 +0200 Subject: [PATCH 03/88] =?UTF-8?q?=F0=9F=8F=B7=EF=B8=8F=20types?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/lib/queries.ts | 28 ++++++++++++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/src/lib/queries.ts b/src/lib/queries.ts index 7d224c7b10..b8a50a9080 100644 --- a/src/lib/queries.ts +++ b/src/lib/queries.ts @@ -63,6 +63,34 @@ export type LatestVersionResponse = { latestVersion: string; releaseNotes: string; }; + newt: { + latestVersion: string; + releaseNotes: string; + }; + cli: { + latestVersion: string; + releaseNotes: string; + }; + "panglin-node": { + latestVersion: string; + releaseNotes: string; + }; + windows: { + latestVersion: string; + releaseNotes: string; + }; + android: { + latestVersion: string; + releaseNotes: string; + }; + mac: { + latestVersion: string; + releaseNotes: string; + }; + ios: { + latestVersion: string; + releaseNotes: string; + }; }; export const productUpdatesQueries = { From 4cd0b9a0bb2e474f9ec5409cadfd90db1150dedb Mon Sep 17 00:00:00 2001 From: Fred KISSIE Date: Wed, 10 Jun 2026 22:57:55 +0200 Subject: [PATCH 04/88] =?UTF-8?q?=F0=9F=92=84=20Show=20updates=20available?= =?UTF-8?q?=20in=20the=20frontend,=20on=20sites=20&=20user=20devices?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- messages/en-US.json | 1 + .../clients/user/[niceId]/general/page.tsx | 55 +++++++++++++++++-- src/components/SitesTable.tsx | 23 +++++--- src/components/UserDevicesTable.tsx | 48 ++++++++++++++-- 4 files changed, 110 insertions(+), 17 deletions(-) diff --git a/messages/en-US.json b/messages/en-US.json index 07ab4d6e8a..f389374c14 100644 --- a/messages/en-US.json +++ b/messages/en-US.json @@ -2957,6 +2957,7 @@ "orgOrDomainIdMissing": "Organization or Domain ID is missing", "loadingDNSRecords": "Loading DNS records...", "olmUpdateAvailableInfo": "An updated version of Olm is available. Please update to the latest version for the best experience.", + "updateAvailableInfo": "An updated version is available. Please update to the latest version for the best experience.", "client": "Client", "proxyProtocol": "Proxy Protocol Settings", "proxyProtocolDescription": "Configure Proxy Protocol to preserve client IP addresses for TCP services.", diff --git a/src/app/[orgId]/settings/clients/user/[niceId]/general/page.tsx b/src/app/[orgId]/settings/clients/user/[niceId]/general/page.tsx index cfdc5a9967..87c2d0dec6 100644 --- a/src/app/[orgId]/settings/clients/user/[niceId]/general/page.tsx +++ b/src/app/[orgId]/settings/clients/user/[niceId]/general/page.tsx @@ -41,6 +41,13 @@ import { useParams } from "next/navigation"; import { FaApple, FaWindows, FaLinux } from "react-icons/fa"; import { SiAndroid } from "react-icons/si"; import { tierMatrix } from "@server/lib/billing/tierMatrix"; +import { + productUpdatesQueries, + type LatestVersionResponse +} from "@app/lib/queries"; +import { useQuery } from "@tanstack/react-query"; +import semver from "semver"; +import { InfoPopup } from "@app/components/ui/info-popup"; function formatTimestamp(timestamp: number | null | undefined): string { if (!timestamp) return "-"; @@ -166,6 +173,34 @@ export default function GeneralPage() { }>(null); const [isCheckingCache, setIsCheckingCache] = useState(false); const [isRebuildingCache, setIsRebuildingCache] = useState(false); + const data = useQuery(productUpdatesQueries.latestVersion(true)); + const latestPlatformVersions = data.data?.data; + + const agentVersionMap: Record = { + "Pangolin Windows": "windows", + "Pangolin Android": "android", + "Pangolin iOS": "ios", + "Pangolin iPadOS": "ios", + "Pangolin macOS": "mac", + "Pangolin CLI": "cli", + "Olm CLI": "olm" + }; + + let updateAvailable = false; + if (client.agent && client.olmVersion && latestPlatformVersions) { + const agent = agentVersionMap[ + client.agent + ] as keyof LatestVersionResponse; + + if (agent in latestPlatformVersions) { + const agentVersion = latestPlatformVersions[agent]; + + updateAvailable = semver.lte( + client.olmVersion, + agentVersion.latestVersion + ); + } + } // get "imp" from local storage to determine if we should show the verify button (imp = "1" means show) const showVerifyButton = @@ -451,11 +486,21 @@ export default function GeneralPage() { {t("agent")} - - {client.agent + - " v" + - client.olmVersion} - +
+ + {client.agent + + " v" + + client.olmVersion} + + + {updateAvailable && ( + + )} +
diff --git a/src/components/SitesTable.tsx b/src/components/SitesTable.tsx index 8c3036c4a5..5b5ac1db14 100644 --- a/src/components/SitesTable.tsx +++ b/src/components/SitesTable.tsx @@ -55,6 +55,9 @@ import { usePaidStatus } from "@app/hooks/usePaidStatus"; import { tierMatrix } from "@server/lib/billing/tierMatrix"; import { LabelColumnFilterButton } from "./LabelColumnFilterButton"; import { LabelsTableCell } from "./LabelsTableCell"; +import { useQuery } from "@tanstack/react-query"; +import { productUpdatesQueries } from "@app/lib/queries"; +import semver from "semver"; export type SiteRow = { id: number; @@ -113,12 +116,11 @@ export default function SitesTable({ const api = createApiClient(useEnvContext()); const t = useTranslations(); - // useEffect(() => { - // const interval = setInterval(() => { - // router.refresh(); - // }, 30_000); - // return () => clearInterval(interval); - // }, []); + const { data: latestVersions } = useQuery( + productUpdatesQueries.latestVersion(true) + ); + + const latestNewtVersion = latestVersions?.data?.newt?.latestVersion; const booleanSearchFilterSchema = z .enum(["true", "false"]) @@ -333,6 +335,11 @@ export default function SitesTable({ cell: ({ row }) => { const originalRow = row.original; + let updateAvailable = + latestNewtVersion && + originalRow.newtVersion && + semver.lt(originalRow.newtVersion, latestNewtVersion); + if (originalRow.type === "newt") { return (
@@ -346,7 +353,7 @@ export default function SitesTable({ )}
- {originalRow.newtUpdateAvailable && ( + {updateAvailable && ( @@ -561,7 +568,7 @@ export default function SitesTable({ } return cols; - }, [isLabelFeatureEnabled, orgId, t, searchParams]); + }, [isLabelFeatureEnabled, orgId, t, searchParams, latestNewtVersion]); function toggleSort(column: string) { const newSearch = getNextSortOrder(column, searchParams); diff --git a/src/components/UserDevicesTable.tsx b/src/components/UserDevicesTable.tsx index 8ee2ddb876..17a82dfc95 100644 --- a/src/components/UserDevicesTable.tsx +++ b/src/components/UserDevicesTable.tsx @@ -38,6 +38,12 @@ import { ColumnFilterButton } from "./ColumnFilterButton"; import IdpTypeBadge from "./IdpTypeBadge"; import { Badge } from "./ui/badge"; import { ControlledDataTable } from "./ui/controlled-data-table"; +import { + productUpdatesQueries, + type LatestVersionResponse +} from "@app/lib/queries"; +import { useQuery } from "@tanstack/react-query"; +import semver from "semver"; export type ClientRow = { id: number; @@ -100,6 +106,9 @@ export default function UserDevicesTable({ searchParams } = useNavigationContext(); const [isRefreshing, startTransition] = useTransition(); + const data = useQuery(productUpdatesQueries.latestVersion(true)); + + const latestPlatformVersions = data.data?.data; const defaultUserColumnVisibility = { subnet: false, @@ -555,6 +564,37 @@ export default function UserDevicesTable({ cell: ({ row }) => { const originalRow = row.original; + const agentVersionMap: Record = { + "Pangolin Windows": "windows", + "Pangolin Android": "android", + "Pangolin iOS": "ios", + "Pangolin iPadOS": "ios", + "Pangolin macOS": "mac", + "Pangolin CLI": "cli", + "Olm CLI": "olm" + }; + + let updateAvailable = false; + + if ( + originalRow.olmVersion && + originalRow.agent && + latestPlatformVersions + ) { + const agent = agentVersionMap[ + originalRow.agent + ] as keyof LatestVersionResponse; + + if (agent in latestPlatformVersions) { + const agentVersion = latestPlatformVersions[agent]; + + updateAvailable = semver.lt( + originalRow.olmVersion, + agentVersion.latestVersion + ); + } + } + return (
{originalRow.agent && originalRow.olmVersion ? ( @@ -567,9 +607,9 @@ export default function UserDevicesTable({ "-" )} - {/*originalRow.olmUpdateAvailable && ( - - )*/} + {updateAvailable && ( + + )}
); } @@ -714,7 +754,7 @@ export default function UserDevicesTable({ } return allOptions; - }, [t]); + }, [t, latestPlatformVersions]); function handleFilterChange( column: string, From fe55956079ce3d02da3aa771e9fb10308430c2b6 Mon Sep 17 00:00:00 2001 From: Fred KISSIE Date: Wed, 10 Jun 2026 22:58:42 +0200 Subject: [PATCH 05/88] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20sites=20&=20clients?= =?UTF-8?q?=20should=20not=20get=20latest=20versions=20on=20the=20server?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- server/routers/client/listUserDevices.ts | 25 ----- server/routers/site/listSites.ts | 124 ----------------------- 2 files changed, 149 deletions(-) diff --git a/server/routers/client/listUserDevices.ts b/server/routers/client/listUserDevices.ts index 82b52577ed..f9af3af780 100644 --- a/server/routers/client/listUserDevices.ts +++ b/server/routers/client/listUserDevices.ts @@ -420,31 +420,6 @@ export async function listUserDevices( } ); - // REMOVING THIS BECAUSE WE HAVE DIFFERENT TYPES OF CLIENTS NOW - // // Try to get the latest version, but don't block if it fails - // try { - // const latestOlmVersion = await getLatestOlmVersion(); - - // if (latestOlmVersion) { - // olmsWithUpdates.forEach((client) => { - // try { - // client.olmUpdateAvailable = semver.lt( - // client.olmVersion ? client.olmVersion : "", - // latestOlmVersion - // ); - // } catch (error) { - // client.olmUpdateAvailable = false; - // } - // }); - // } - // } catch (error) { - // // Log the error but don't let it block the response - // logger.warn( - // "Failed to check for OLM updates, continuing without update info:", - // error - // ); - // } - return response(res, { data: { devices: olmsWithUpdates, diff --git a/server/routers/site/listSites.ts b/server/routers/site/listSites.ts index c6abace5f5..f72e3c19a6 100644 --- a/server/routers/site/listSites.ts +++ b/server/routers/site/listSites.ts @@ -29,97 +29,6 @@ import { fromError } from "zod-validation-error"; import { isLicensedOrSubscribed } from "#dynamic/lib/isLicencedOrSubscribed"; import { tierMatrix } from "@server/lib/billing/tierMatrix"; -// Stale-while-revalidate: keeps the last successfully fetched version so that -// a transient network failure / timeout does not flip every site back to -// newtUpdateAvailable: false. -let staleNewtVersion: string | null = null; - -async function getLatestNewtVersion(): Promise { - try { - const cachedVersion = await cache.get( - "cache:latestNewtVersion" - ); - if (cachedVersion) { - return cachedVersion; - } - - const controller = new AbortController(); - const timeoutId = setTimeout(() => controller.abort(), 1500); - - const response = await fetch( - "https://api.github.com/repos/fosrl/newt/tags", - { - signal: controller.signal - } - ); - - clearTimeout(timeoutId); - - if (!response.ok) { - logger.warn( - `Failed to fetch latest Newt version from GitHub: ${response.status} ${response.statusText}` - ); - return staleNewtVersion; - } - - let tags = await response.json(); - if (!Array.isArray(tags) || tags.length === 0) { - logger.warn("No tags found for Newt repository"); - return staleNewtVersion; - } - - // Remove release-candidates, then sort descending by semver so that - // duplicate tags (e.g. "1.10.3" and "v1.10.3") and any ordering quirks - // from the GitHub API do not cause an older tag to be selected. - tags = tags.filter((tag: any) => !tag.name.includes("rc")); - tags.sort((a: any, b: any) => { - const va = semver.coerce(a.name); - const vb = semver.coerce(b.name); - if (!va && !vb) return 0; - if (!va) return 1; - if (!vb) return -1; - return semver.rcompare(va, vb); - }); - - // Deduplicate: keep only the first (highest) entry per normalised version - const seen = new Set(); - tags = tags.filter((tag: any) => { - const normalised = semver.coerce(tag.name)?.version; - if (!normalised || seen.has(normalised)) return false; - seen.add(normalised); - return true; - }); - - if (tags.length === 0) { - logger.warn("No valid semver tags found for Newt repository"); - return staleNewtVersion; - } - - const latestVersion = tags[0].name; - - staleNewtVersion = latestVersion; - await cache.set("cache:latestNewtVersion", latestVersion, 3600); - - return latestVersion; - } catch (error: any) { - if (error.name === "AbortError") { - logger.warn( - "Request to fetch latest Newt version timed out (1.5s)" - ); - } else if (error.cause?.code === "UND_ERR_CONNECT_TIMEOUT") { - logger.warn( - "Connection timeout while fetching latest Newt version" - ); - } else { - logger.warn( - "Error fetching latest Newt version:", - error.message || error - ); - } - return staleNewtVersion; - } -} - const listSitesParamsSchema = z.strictObject({ orgId: z.string() }); @@ -446,9 +355,6 @@ export async function listSites( siteListQuery ]); - // Get latest version asynchronously without blocking the response - const latestNewtVersionPromise = getLatestNewtVersion(); - const siteIds = rows.map((site) => site.siteId); let labelsForSites: Array<{ @@ -491,36 +397,6 @@ export async function listSites( return { ...siteWithUpdate, labels: labelsForSite }; }); - // Try to get the latest version, but don't block if it fails - try { - const latestNewtVersion = await latestNewtVersionPromise; - - if (latestNewtVersion) { - sitesWithUpdates.forEach((site) => { - if ( - site.type === "newt" && - site.newtVersion && - latestNewtVersion - ) { - try { - site.newtUpdateAvailable = semver.lt( - site.newtVersion, - latestNewtVersion - ); - } catch (error) { - site.newtUpdateAvailable = false; - } - } - }); - } - } catch (error) { - // Log the error but don't let it block the response - logger.warn( - "Failed to check for Newt updates, continuing without update info:", - error - ); - } - const sitesPayload = sitesWithUpdates.map((site) => site.type === "local" ? { ...site, online: undefined } : site ); From 1b6e9e8cfe80ce41a67f92ce86cc6a355facfb0f Mon Sep 17 00:00:00 2001 From: Fred KISSIE Date: Thu, 11 Jun 2026 19:55:48 +0200 Subject: [PATCH 06/88] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20`lt`=20instead=20of?= =?UTF-8?q?=20`lte`?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app/[orgId]/settings/clients/user/[niceId]/general/page.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/app/[orgId]/settings/clients/user/[niceId]/general/page.tsx b/src/app/[orgId]/settings/clients/user/[niceId]/general/page.tsx index 87c2d0dec6..8573e81996 100644 --- a/src/app/[orgId]/settings/clients/user/[niceId]/general/page.tsx +++ b/src/app/[orgId]/settings/clients/user/[niceId]/general/page.tsx @@ -195,7 +195,7 @@ export default function GeneralPage() { if (agent in latestPlatformVersions) { const agentVersion = latestPlatformVersions[agent]; - updateAvailable = semver.lte( + updateAvailable = semver.lt( client.olmVersion, agentVersion.latestVersion ); From 4b703b5c11b47042c7225fc78598aea30ab6392d Mon Sep 17 00:00:00 2001 From: Fred KISSIE Date: Thu, 11 Jun 2026 20:58:23 +0200 Subject: [PATCH 07/88] =?UTF-8?q?=F0=9F=92=84=20Show=20the=20latest=20new?= =?UTF-8?q?=20update=20in=20machine=20client=20table?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/MachineClientsTable.tsx | 55 +++++++++++++++++++++----- 1 file changed, 46 insertions(+), 9 deletions(-) diff --git a/src/components/MachineClientsTable.tsx b/src/components/MachineClientsTable.tsx index eba0c97624..3094348e30 100644 --- a/src/components/MachineClientsTable.tsx +++ b/src/components/MachineClientsTable.tsx @@ -11,10 +11,10 @@ import { } from "@app/components/ui/dropdown-menu"; import { useEnvContext } from "@app/hooks/useEnvContext"; import { useNavigationContext } from "@app/hooks/useNavigationContext"; +import { useOptimisticLabels } from "@app/hooks/useOptimisticLabels"; import { usePaidStatus } from "@app/hooks/usePaidStatus"; import { toast } from "@app/hooks/useToast"; import { createApiClient, formatAxiosError } from "@app/lib/api"; -import { cn } from "@app/lib/cn"; import { getNextSortOrder, getSortDirection } from "@app/lib/sortColumn"; import { tierMatrix } from "@server/lib/billing/tierMatrix"; import type { PaginationState } from "@tanstack/react-table"; @@ -31,15 +31,18 @@ import Link from "next/link"; import { useRouter } from "next/navigation"; import { startTransition, useMemo, useState, useTransition } from "react"; import { useDebouncedCallback } from "use-debounce"; -import z from "zod"; import { ColumnFilterButton } from "./ColumnFilterButton"; -import { type SelectedLabel } from "./labels-selector"; +import { LabelColumnFilterButton } from "./LabelColumnFilterButton"; import { LabelsTableCell } from "./LabelsTableCell"; import { Badge } from "./ui/badge"; import { ControlledDataTable } from "./ui/controlled-data-table"; -import { LabelColumnFilterButton } from "./LabelColumnFilterButton"; -import { useLocalLabels } from "@app/hooks/useLocalLabels"; -import { useOptimisticLabels } from "@app/hooks/useOptimisticLabels"; +import { + productUpdatesQueries, + type LatestVersionResponse +} from "@app/lib/queries"; +import { useQuery } from "@tanstack/react-query"; +import semver from "semver"; +import { InfoPopup } from "./ui/info-popup"; export type ClientRow = { id: number; @@ -101,6 +104,9 @@ export default function MachineClientsTable({ const { isPaidUser } = usePaidStatus(); const isLabelFeatureEnabled = isPaidUser(tierMatrix.labels); + const data = useQuery(productUpdatesQueries.latestVersion(true)); + + const latestPlatformVersions = data.data?.data; const defaultMachineColumnVisibility = { subnet: false, @@ -375,6 +381,37 @@ export default function MachineClientsTable({ cell: ({ row }) => { const originalRow = row.original; + const agentVersionMap: Record = { + "Pangolin Windows": "windows", + "Pangolin Android": "android", + "Pangolin iOS": "ios", + "Pangolin iPadOS": "ios", + "Pangolin macOS": "mac", + "Pangolin CLI": "cli", + "Olm CLI": "olm" + }; + + let updateAvailable = false; + + if ( + originalRow.olmVersion && + originalRow.agent && + latestPlatformVersions + ) { + const agent = agentVersionMap[ + originalRow.agent + ] as keyof LatestVersionResponse; + + if (agent in latestPlatformVersions) { + const agentVersion = latestPlatformVersions[agent]; + + updateAvailable = semver.lt( + originalRow.olmVersion, + agentVersion.latestVersion + ); + } + } + return (
{originalRow.agent && originalRow.olmVersion ? ( @@ -386,9 +423,9 @@ export default function MachineClientsTable({ ) : ( "-" )} - {/*originalRow.olmUpdateAvailable && ( - - )*/} + {updateAvailable && ( + + )}
); } From 34a0d2a68bc6fec27e8a2a5d21b7e68c4112e9df Mon Sep 17 00:00:00 2001 From: Owen Date: Sun, 14 Jun 2026 15:02:18 -0700 Subject: [PATCH 08/88] Remove NoNewPrivileges Fixes https://github.com/fosrl/newt/issues/383 --- src/components/newt-install-commands.tsx | 1 - 1 file changed, 1 deletion(-) diff --git a/src/components/newt-install-commands.tsx b/src/components/newt-install-commands.tsx index badc174cc6..b720a84782 100644 --- a/src/components/newt-install-commands.tsx +++ b/src/components/newt-install-commands.tsx @@ -139,7 +139,6 @@ Restart=always RestartSec=2 UMask=0077 -NoNewPrivileges=true PrivateTmp=true [Install] From bf604f25e9c5c774cd172bcfc2499a6cf6ac4f23 Mon Sep 17 00:00:00 2001 From: Owen Date: Fri, 19 Jun 2026 13:02:38 -0400 Subject: [PATCH 09/88] Show the input validation in the error report --- server/lib/blueprints/applyBlueprint.ts | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/server/lib/blueprints/applyBlueprint.ts b/server/lib/blueprints/applyBlueprint.ts index f2bb9b0c80..ab095646ee 100644 --- a/server/lib/blueprints/applyBlueprint.ts +++ b/server/lib/blueprints/applyBlueprint.ts @@ -48,18 +48,18 @@ export async function applyBlueprint({ name, source = "API" }: ApplyBlueprintArgs): Promise { - // Validate the input data - const validationResult = ConfigSchema.safeParse(configData); - if (!validationResult.success) { - throw new Error(fromError(validationResult.error).toString()); - } - - const config: Config = validationResult.data; let blueprintSucceeded: boolean = false; - let blueprintMessage: string; + let blueprintMessage = ""; let error: any | null = null; try { + const validationResult = ConfigSchema.safeParse(configData); + if (!validationResult.success) { + throw new Error(fromError(validationResult.error).toString()); + } + + const config: Config = validationResult.data; + let proxyResourcesResults: PublicResourcesResults = []; let clientResourcesResults: ClientResourcesResults = []; await db.transaction(async (trx) => { From 476d92b3ac1d5a80f272669ec845f7b9e18bdb13 Mon Sep 17 00:00:00 2001 From: Owen Date: Sun, 21 Jun 2026 16:01:46 -0400 Subject: [PATCH 10/88] Convert things to regional cache --- server/lib/billing/usageService.ts | 8 +++++--- server/private/lib/certificates.ts | 2 +- .../routers/remoteExitNode/listRemoteExitNodes.ts | 2 +- server/routers/newt/getNewtVersion.ts | 2 +- server/routers/newt/handleSocketMessages.ts | 2 +- server/routers/olm/handleOlmRegisterMessage.ts | 2 +- server/routers/resource/listUserResourceAliases.ts | 13 ++++++++----- server/routers/site/listSites.ts | 2 +- 8 files changed, 19 insertions(+), 14 deletions(-) diff --git a/server/lib/billing/usageService.ts b/server/lib/billing/usageService.ts index 9cb24bbeb5..dd32a09adc 100644 --- a/server/lib/billing/usageService.ts +++ b/server/lib/billing/usageService.ts @@ -12,7 +12,7 @@ import { import { FeatureId, getFeatureMeterId } from "./features"; import logger from "@server/logger"; import { build } from "@server/build"; -import cache from "#dynamic/lib/cache"; +import { regionalCache as cache } from "#dynamic/lib/cache"; export function noop() { if (build !== "saas") { @@ -22,7 +22,6 @@ export function noop() { } export class UsageService { - constructor() { if (noop()) { return; @@ -57,7 +56,10 @@ export class UsageService { try { let usage; if (transaction) { - const orgIdToUse = await this.getBillingOrg(orgId, transaction); + const orgIdToUse = await this.getBillingOrg( + orgId, + transaction + ); usage = await this.internalAddUsage( orgIdToUse, featureId, diff --git a/server/private/lib/certificates.ts b/server/private/lib/certificates.ts index 31e40ed556..03ea6a58ce 100644 --- a/server/private/lib/certificates.ts +++ b/server/private/lib/certificates.ts @@ -17,7 +17,7 @@ import { certificates, db } from "@server/db"; import { and, eq, isNotNull, or, inArray, sql } from "drizzle-orm"; import { decrypt } from "@server/lib/crypto"; import logger from "@server/logger"; -import cache from "#private/lib/cache"; +import { regionalCache as cache } from "#private/lib/cache"; import { build } from "@server/build"; // Define the return type for clarity and type safety diff --git a/server/private/routers/remoteExitNode/listRemoteExitNodes.ts b/server/private/routers/remoteExitNode/listRemoteExitNodes.ts index 061be17925..872e62b2da 100644 --- a/server/private/routers/remoteExitNode/listRemoteExitNodes.ts +++ b/server/private/routers/remoteExitNode/listRemoteExitNodes.ts @@ -22,7 +22,7 @@ import createHttpError from "http-errors"; import logger from "@server/logger"; import { fromError } from "zod-validation-error"; import { ListRemoteExitNodesResponse } from "@server/routers/remoteExitNode/types"; -import cache from "#private/lib/cache"; +import { regionalCache as cache } from "#private/lib/cache"; import semver from "semver"; let stalePangolinNodeVersion: string | null = null; diff --git a/server/routers/newt/getNewtVersion.ts b/server/routers/newt/getNewtVersion.ts index 8a76bc3d2c..b36ec8c164 100644 --- a/server/routers/newt/getNewtVersion.ts +++ b/server/routers/newt/getNewtVersion.ts @@ -10,7 +10,7 @@ import { verifyPassword } from "@server/auth/password"; import response from "@server/lib/response"; import HttpCode from "@server/types/HttpCode"; import logger from "@server/logger"; -import cache from "#dynamic/lib/cache"; +import { regionalCache as cache } from "#dynamic/lib/cache"; import config from "@server/lib/config"; // Stale-while-revalidate in-memory fallback for the releases API. diff --git a/server/routers/newt/handleSocketMessages.ts b/server/routers/newt/handleSocketMessages.ts index 5d5497ee11..634dbb6a99 100644 --- a/server/routers/newt/handleSocketMessages.ts +++ b/server/routers/newt/handleSocketMessages.ts @@ -2,7 +2,7 @@ import { MessageHandler } from "@server/routers/ws"; import logger from "@server/logger"; import { Newt } from "@server/db"; import { applyNewtDockerBlueprint } from "@server/lib/blueprints/applyNewtDockerBlueprint"; -import cache from "#dynamic/lib/cache"; +import cache from "#dynamic/lib/cache"; // not using regional here because we dont know where the site is export const handleDockerStatusMessage: MessageHandler = async (context) => { const { message, client, sendToClient } = context; diff --git a/server/routers/olm/handleOlmRegisterMessage.ts b/server/routers/olm/handleOlmRegisterMessage.ts index 9fe09736f0..6bfc02aee0 100644 --- a/server/routers/olm/handleOlmRegisterMessage.ts +++ b/server/routers/olm/handleOlmRegisterMessage.ts @@ -20,7 +20,7 @@ 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 const HOLEPUNCH_STALE_CHAIN_THRESHOLD = 18; const HOLEPUNCH_STALE_CHAIN_TTL_SECONDS = 1800; diff --git a/server/routers/resource/listUserResourceAliases.ts b/server/routers/resource/listUserResourceAliases.ts index d6e02b5228..205c029f06 100644 --- a/server/routers/resource/listUserResourceAliases.ts +++ b/server/routers/resource/listUserResourceAliases.ts @@ -15,8 +15,7 @@ import logger from "@server/logger"; import { z } from "zod"; import { fromZodError } from "zod-validation-error"; import type { PaginatedResponse } from "@server/types/Pagination"; -import { OpenAPITags, registry } from "@server/openApi"; -import { localCache } from "#dynamic/lib/cache"; +import { regionalCache as cache } from "#dynamic/lib/cache"; const USER_RESOURCE_ALIASES_CACHE_TTL_SEC = 60; @@ -153,7 +152,7 @@ export async function listUserResourceAliases( pageSize ); const cachedData: ListUserResourceAliasesResponse | undefined = - localCache.get(cacheKey); + await cache.get(cacheKey); if (cachedData) { return response(res, { @@ -211,7 +210,11 @@ export async function listUserResourceAliases( page } }; - localCache.set(cacheKey, data, USER_RESOURCE_ALIASES_CACHE_TTL_SEC); + await cache.set( + cacheKey, + data, + USER_RESOURCE_ALIASES_CACHE_TTL_SEC + ); return response(res, { data, success: true, @@ -256,7 +259,7 @@ export async function listUserResourceAliases( page } }; - localCache.set(cacheKey, data, USER_RESOURCE_ALIASES_CACHE_TTL_SEC); + await cache.set(cacheKey, data, USER_RESOURCE_ALIASES_CACHE_TTL_SEC); return response(res, { data, diff --git a/server/routers/site/listSites.ts b/server/routers/site/listSites.ts index 86699feafc..6bb0950302 100644 --- a/server/routers/site/listSites.ts +++ b/server/routers/site/listSites.ts @@ -14,7 +14,7 @@ import { siteLabels, type Label } from "@server/db"; -import cache from "#dynamic/lib/cache"; +import { regionalCache as cache } from "#dynamic/lib/cache"; import response from "@server/lib/response"; import logger from "@server/logger"; import { OpenAPITags, registry } from "@server/openApi"; From a2882857ff55d5a536a87371827a9e5b06413cc8 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 16 Jun 2026 23:39:56 +0000 Subject: [PATCH 11/88] Initial plan From 3f37408daedebcf872e3d65d6c24e742ae3a9613 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 16 Jun 2026 23:44:35 +0000 Subject: [PATCH 12/88] fix: allow ALL ASN values in policy rule validation --- server/lib/validators.test.ts | 27 ++++++++++++++++++- server/lib/validators.ts | 4 ++- .../policy-access-rule-validation.ts | 16 ++++++++--- 3 files changed, 42 insertions(+), 5 deletions(-) diff --git a/server/lib/validators.test.ts b/server/lib/validators.test.ts index 00b6c75db4..99208a5d5a 100644 --- a/server/lib/validators.test.ts +++ b/server/lib/validators.test.ts @@ -1,4 +1,7 @@ -import { isValidUrlGlobPattern } from "./validators"; +import { + getResourceRuleValueValidationError, + isValidUrlGlobPattern +} from "./validators"; import { assertEquals } from "@test/assert"; function runTests() { @@ -236,6 +239,28 @@ 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", "ALL"), + null, + "ALL ASN selector should be valid" + ); + assertEquals( + getResourceRuleValueValidationError("ASN", "AS0"), + null, + "AS0 alias should be valid" + ); + assertEquals( + getResourceRuleValueValidationError("ASN", "not-an-asn"), + "Invalid ASN provided", + "Invalid ASN should return an error" + ); + console.log("All tests passed!"); } diff --git a/server/lib/validators.ts b/server/lib/validators.ts index c179d3c914..bdc0726678 100644 --- a/server/lib/validators.ts +++ b/server/lib/validators.ts @@ -100,7 +100,9 @@ export function getResourceRuleValueValidationError( ? null : "Invalid country code provided"; case "ASN": - return /^AS\d+$/i.test(value.trim()) + return /^AS\d+$/i.test(value.trim()) || + value.trim().toUpperCase() === "ALL" || + value.trim().toUpperCase() === "AS0" ? null : "Invalid ASN provided"; default: diff --git a/src/components/resource-policy/policy-access-rule-validation.ts b/src/components/resource-policy/policy-access-rule-validation.ts index 387d2003b8..2b3ddfb8fb 100644 --- a/src/components/resource-policy/policy-access-rule-validation.ts +++ b/src/components/resource-policy/policy-access-rule-validation.ts @@ -83,9 +83,19 @@ export function createPolicyRuleValueSchema(t: TranslateFn, match: string) { { message: t("rulesErrorInvalidCountryDescription") } ); case "ASN": - return required.refine((value) => /^AS\d+$/i.test(value.trim()), { - message: t("rulesErrorInvalidAsnDescription") - }); + return required.refine( + (value) => { + const normalizedValue = value.trim().toUpperCase(); + return ( + /^AS\d+$/i.test(normalizedValue) || + normalizedValue === "ALL" || + normalizedValue === "AS0" + ); + }, + { + message: t("rulesErrorInvalidAsnDescription") + } + ); default: return required; } From de48a0529eb8f7e42e2c2eb107949136602a027f Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 16 Jun 2026 23:46:44 +0000 Subject: [PATCH 13/88] refactor: normalize ASN validation value once --- server/lib/validators.ts | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/server/lib/validators.ts b/server/lib/validators.ts index bdc0726678..5251aae25c 100644 --- a/server/lib/validators.ts +++ b/server/lib/validators.ts @@ -100,9 +100,10 @@ export function getResourceRuleValueValidationError( ? null : "Invalid country code provided"; case "ASN": - return /^AS\d+$/i.test(value.trim()) || - value.trim().toUpperCase() === "ALL" || - value.trim().toUpperCase() === "AS0" + const normalizedValue = value.trim().toUpperCase(); + return /^AS\d+$/i.test(normalizedValue) || + normalizedValue === "ALL" || + normalizedValue === "AS0" ? null : "Invalid ASN provided"; default: From e5e7b7971221d46b1a74588ca447fe015f2b212a Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 16 Jun 2026 23:48:28 +0000 Subject: [PATCH 14/88] test: add normalized ASN validation coverage --- server/lib/validators.test.ts | 15 +++++++++++++++ server/lib/validators.ts | 2 +- .../policy-access-rule-validation.ts | 2 +- 3 files changed, 17 insertions(+), 2 deletions(-) diff --git a/server/lib/validators.test.ts b/server/lib/validators.test.ts index 99208a5d5a..ac95184a5c 100644 --- a/server/lib/validators.test.ts +++ b/server/lib/validators.test.ts @@ -245,16 +245,31 @@ function runTests() { 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", diff --git a/server/lib/validators.ts b/server/lib/validators.ts index 5251aae25c..ec19bd8523 100644 --- a/server/lib/validators.ts +++ b/server/lib/validators.ts @@ -101,7 +101,7 @@ export function getResourceRuleValueValidationError( : "Invalid country code provided"; case "ASN": const normalizedValue = value.trim().toUpperCase(); - return /^AS\d+$/i.test(normalizedValue) || + return /^AS\d+$/.test(normalizedValue) || normalizedValue === "ALL" || normalizedValue === "AS0" ? null diff --git a/src/components/resource-policy/policy-access-rule-validation.ts b/src/components/resource-policy/policy-access-rule-validation.ts index 2b3ddfb8fb..a5c9d32e0a 100644 --- a/src/components/resource-policy/policy-access-rule-validation.ts +++ b/src/components/resource-policy/policy-access-rule-validation.ts @@ -87,7 +87,7 @@ export function createPolicyRuleValueSchema(t: TranslateFn, match: string) { (value) => { const normalizedValue = value.trim().toUpperCase(); return ( - /^AS\d+$/i.test(normalizedValue) || + /^AS\d+$/.test(normalizedValue) || normalizedValue === "ALL" || normalizedValue === "AS0" ); From a55fb21e53c7a9adca9dbd580d41460f2efd2fa5 Mon Sep 17 00:00:00 2001 From: Josh Voyles <120749218+Josh-Voyles@users.noreply.github.com> Date: Sat, 13 Jun 2026 14:53:07 -0400 Subject: [PATCH 15/88] fix(sqlite): remove cache_size and mmap_size PRAGMAs (#2120) A 64 MB page cache plus a 256 MB memory-mapped region inflate RSS and cause page-cache thrashing on small (~1 GB) instances. The PRAGMAs were added to reduce event-loop blocking on TraefikConfigManager JOINs but the memory cost outweighs the I/O benefit on the deployment shapes that hit #2120. Leave SQLite on its conservative defaults. Co-Authored-By: Claude Opus 4.7 --- server/db/sqlite/driver.ts | 10 +++------- 1 file changed, 3 insertions(+), 7 deletions(-) diff --git a/server/db/sqlite/driver.ts b/server/db/sqlite/driver.ts index 0e50d1289c..644a160aa9 100644 --- a/server/db/sqlite/driver.ts +++ b/server/db/sqlite/driver.ts @@ -60,13 +60,9 @@ function createDb() { // retry loops that accumulate memory. sqlite.pragma("busy_timeout = 5000"); - // 64 MB page cache (default 2 MB) — reduces I/O round-trips on large - // TraefikConfigManager JOINs that block the event loop. - sqlite.pragma("cache_size = -65536"); - - // 256 MB memory-mapped I/O — OS serves reads from page cache directly, - // reducing event-loop blocking. - sqlite.pragma("mmap_size = 268435456"); + // Intentionally NOT setting cache_size or mmap_size: a large page cache plus + // a multi-hundred-MB mmap region inflate RSS and cause page-cache thrashing + // on small (~1 GB) instances. Leave SQLite on its conservative defaults. // Wrap prepare() so every drizzle-orm statement is auto-finalized after // first use, preventing sqlite3_stmt accumulation between GC cycles. From b7081aff119fd2c812d7b8e07f4be58965bf3f85 Mon Sep 17 00:00:00 2001 From: Josh Voyles <120749218+Josh-Voyles@users.noreply.github.com> Date: Wed, 10 Jun 2026 20:12:00 -0400 Subject: [PATCH 16/88] fix: remove no-op autoFinalizeStatement wrapper and redundant busy_timeout (#2120) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit better-sqlite3 11.x exposes no Statement.finalize() — the wrapper threw and swallowed a TypeError on every query (verified: 'Statement.finalize exists: undefined' in the runner image) while adding +122% per-statement overhead (3.90 -> 8.66 us/op, 200k-op in-container microbench) and freeing nothing. Statement lifecycle is GC-managed by the driver; drizzle-orm prepares fresh per query, so nothing accumulates unbounded. busy_timeout=5000 duplicates better-sqlite3's default timeout option, which already arms sqlite3_busy_timeout(db, 5000) at open (lib/database.js). With ENABLE_SQLITE_WAL_MODE unset the driver is now runtime-identical to pre-1.18.3 (zero pragmas). The env-gated WAL block stays: journal_mode is sticky in the DB file, so removing it would strand opted-in databases on WAL+synchronous=FULL. Co-Authored-By: Claude Fable 5 --- server/db/sqlite/driver.ts | 52 +++++++------------------------------- 1 file changed, 9 insertions(+), 43 deletions(-) diff --git a/server/db/sqlite/driver.ts b/server/db/sqlite/driver.ts index 644a160aa9..a7eee52b70 100644 --- a/server/db/sqlite/driver.ts +++ b/server/db/sqlite/driver.ts @@ -1,6 +1,5 @@ import { drizzle as DrizzleSqlite } from "drizzle-orm/better-sqlite3"; import Database from "better-sqlite3"; -import type BetterSqlite3 from "better-sqlite3"; import * as schema from "./schema/schema"; import path from "path"; import fs from "fs"; @@ -12,64 +11,31 @@ export const exists = checkFileExists(location); bootstrapVolume(); -/** - * Wraps better-sqlite3 Statement to call `finalize()` immediately after - * execution, freeing native sqlite3_stmt memory deterministically instead - * of waiting for GC. Fixes steady off-heap growth under load (#2120). - * WARNING: Finalizes after first execution — incompatible with drizzle's - * reusable .prepare() builders. No such usage exists in this codebase. - */ -function autoFinalizeStatement( - stmt: BetterSqlite3.Statement -): BetterSqlite3.Statement { - const wrapExec = any>(fn: T): T => { - return function (this: any, ...args: any[]) { - try { - return fn.apply(this, args); - } finally { - try { - // finalize() exists on the native Statement at runtime but - // is missing from @types/better-sqlite3. - (stmt as any).finalize(); - } catch { - // Already finalized — harmless - } - } - } as unknown as T; - }; - - stmt.run = wrapExec(stmt.run); - stmt.get = wrapExec(stmt.get); - stmt.all = wrapExec(stmt.all); - - return stmt; -} - function createDb() { const sqlite = new Database(location); if (process.env.ENABLE_SQLITE_WAL_MODE == "true") { // Enable WAL mode — allows concurrent readers + single writer, preventing // contention across subsystems (verifySession, Traefik, audit, ping). + // NOTE: journal_mode persists in the DB file once set; unsetting this + // env var does NOT revert an existing WAL database. sqlite.pragma("journal_mode = WAL"); // NORMAL sync mode: safe with WAL, reduces write lock hold time. sqlite.pragma("synchronous = NORMAL"); } - // Wait up to 5s on SQLITE_BUSY instead of failing — prevents audit log - // retry loops that accumulate memory. - sqlite.pragma("busy_timeout = 5000"); + // 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. // Intentionally NOT setting cache_size or mmap_size: a large page cache plus // a multi-hundred-MB mmap region inflate RSS and cause page-cache thrashing // on small (~1 GB) instances. Leave SQLite on its conservative defaults. - // Wrap prepare() so every drizzle-orm statement is auto-finalized after - // first use, preventing sqlite3_stmt accumulation between GC cycles. - const originalPrepare = sqlite.prepare.bind(sqlite); - (sqlite as any).prepare = function autoFinalizePrepare(source: string) { - return autoFinalizeStatement(originalPrepare(source)); - }; + // Intentionally NOT wrapping prepare()/statements: better-sqlite3 finalizes + // sqlite3_stmt in the Statement destructor at GC, and drizzle-orm prepares a + // fresh statement per query (no statement cache), so statements cannot + // accumulate. better-sqlite3 11.x exposes no Statement.finalize() at all. return DrizzleSqlite(sqlite, { schema From d240201361811eb7921eb6ec0f589e6237ea1b7d Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 16 Jun 2026 23:39:56 +0000 Subject: [PATCH 17/88] Initial plan From 16abe98fd98224329b07c9e72aed1925825bf972 Mon Sep 17 00:00:00 2001 From: Owen Date: Thu, 11 Jun 2026 12:25:57 -0700 Subject: [PATCH 18/88] Add queue --- server/index.ts | 2 + server/lib/rebuildClientAssociations.ts | 104 +++++++++++++-- server/lib/rebuildQueue.ts | 23 ++++ server/private/lib/rebuildQueue.ts | 169 ++++++++++++++++++++++++ 4 files changed, 288 insertions(+), 10 deletions(-) create mode 100644 server/lib/rebuildQueue.ts create mode 100644 server/private/lib/rebuildQueue.ts diff --git a/server/index.ts b/server/index.ts index 99fd201568..53b3e9a691 100644 --- a/server/index.ts +++ b/server/index.ts @@ -24,6 +24,7 @@ import license from "#dynamic/license/license"; import { initLogCleanupInterval } from "@server/lib/cleanupLogs"; import { initAcmeCertSync } from "#dynamic/lib/acmeCertSync"; import { fetchServerIp } from "@server/lib/serverIpService"; +import { startRebuildQueueProcessor } from "@server/lib/rebuildClientAssociations"; async function startServers() { await setHostMeta(); @@ -41,6 +42,7 @@ async function startServers() { initLogCleanupInterval(); initAcmeCertSync(); + startRebuildQueueProcessor(); // Start all servers const apiServer = createApiServer(); diff --git a/server/lib/rebuildClientAssociations.ts b/server/lib/rebuildClientAssociations.ts index 4efc72476a..8b601ae714 100644 --- a/server/lib/rebuildClientAssociations.ts +++ b/server/lib/rebuildClientAssociations.ts @@ -8,6 +8,7 @@ import { exitNodes, newts, olms, + primaryDb, roleSiteResources, Site, SiteResource, @@ -40,6 +41,7 @@ import { removeTargets as removeSubnetProxyTargets } from "@server/routers/client/targets"; import { lockManager } from "#dynamic/lib/lock"; +import { rebuildQueue } from "#dynamic/lib/rebuildQueue"; // TTL for rebuild-association locks. These functions can fan out into many // peer/proxy updates, so give them a generous window. @@ -167,11 +169,32 @@ export async function rebuildClientAssociationsFromSiteResource( subnet: string | null; }[]; }> { - return await lockManager.withLock( - `rebuild-client-associations:site-resource:${siteResource.siteResourceId}`, - () => rebuildClientAssociationsFromSiteResourceImpl(siteResource, trx), - REBUILD_ASSOCIATIONS_LOCK_TTL_MS - ); + try { + return await lockManager.withLock( + `rebuild-client-associations:site-resource:${siteResource.siteResourceId}`, + () => + rebuildClientAssociationsFromSiteResourceImpl( + siteResource, + trx + ), + REBUILD_ASSOCIATIONS_LOCK_TTL_MS + ); + } catch (err: any) { + if ( + typeof err?.message === "string" && + err.message.startsWith("Failed to acquire lock") + ) { + logger.warn( + `rebuildClientAssociations: could not acquire lock for site resource ${siteResource.siteResourceId}, queuing for deferred processing` + ); + await rebuildQueue.enqueue({ + type: "site-resource", + id: siteResource.siteResourceId + }); + return { mergedAllClients: [] }; + } + throw err; + } } async function rebuildClientAssociationsFromSiteResourceImpl( @@ -956,11 +979,28 @@ export async function rebuildClientAssociationsFromClient( client: Client, trx: Transaction | typeof db = db ): Promise { - return await lockManager.withLock( - `rebuild-client-associations:client:${client.clientId}`, - () => rebuildClientAssociationsFromClientImpl(client, trx), - REBUILD_ASSOCIATIONS_LOCK_TTL_MS - ); + try { + return await lockManager.withLock( + `rebuild-client-associations:client:${client.clientId}`, + () => rebuildClientAssociationsFromClientImpl(client, trx), + REBUILD_ASSOCIATIONS_LOCK_TTL_MS + ); + } catch (err: any) { + if ( + typeof err?.message === "string" && + err.message.startsWith("Failed to acquire lock") + ) { + logger.warn( + `rebuildClientAssociations: could not acquire lock for client ${client.clientId}, queuing for deferred processing` + ); + await rebuildQueue.enqueue({ + type: "client", + id: client.clientId + }); + return; + } + throw err; + } } async function rebuildClientAssociationsFromClientImpl( @@ -1906,3 +1946,47 @@ export async function cleanupSiteAssociations( logger.debug(`cleanupSiteAssociations: DONE siteId=${siteId}`); } + +/** + * Start the background rebuild queue processor. This should be called once + * during server startup. Only one server instance at a time will actively + * consume the queue (enforced via a distributed Redis lock); all other + * instances will poll and wait until the lock becomes available. + */ +export function startRebuildQueueProcessor(): void { + rebuildQueue.startProcessing({ + onSiteResource: async (siteResourceId: number) => { + const [siteResource] = await primaryDb + .select() + .from(siteResources) + .where(eq(siteResources.siteResourceId, siteResourceId)); + + if (!siteResource) { + logger.warn( + `Rebuild queue: site resource ${siteResourceId} not found, skipping` + ); + return; + } + + await rebuildClientAssociationsFromSiteResource( + siteResource, + primaryDb + ); + }, + onClient: async (clientId: number) => { + const [client] = await primaryDb + .select() + .from(clients) + .where(eq(clients.clientId, clientId)); + + if (!client) { + logger.warn( + `Rebuild queue: client ${clientId} not found, skipping` + ); + return; + } + + await rebuildClientAssociationsFromClient(client, primaryDb); + } + }); +} diff --git a/server/lib/rebuildQueue.ts b/server/lib/rebuildQueue.ts new file mode 100644 index 0000000000..4758581081 --- /dev/null +++ b/server/lib/rebuildQueue.ts @@ -0,0 +1,23 @@ +export type RebuildJobType = "site-resource" | "client"; + +export interface RebuildJob { + type: RebuildJobType; + id: number; +} + +export interface RebuildJobHandlers { + onSiteResource(siteResourceId: number): Promise; + onClient(clientId: number): Promise; +} + +export interface RebuildQueueManager { + enqueue(job: RebuildJob): Promise; + startProcessing(handlers: RebuildJobHandlers): void; +} + +class NoopRebuildQueue implements RebuildQueueManager { + async enqueue(_job: RebuildJob): Promise {} + startProcessing(_handlers: RebuildJobHandlers): void {} +} + +export const rebuildQueue: RebuildQueueManager = new NoopRebuildQueue(); diff --git a/server/private/lib/rebuildQueue.ts b/server/private/lib/rebuildQueue.ts new file mode 100644 index 0000000000..e5ee7e7cb8 --- /dev/null +++ b/server/private/lib/rebuildQueue.ts @@ -0,0 +1,169 @@ +/* + * 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 "#dynamic/lib/lock"; +import logger from "@server/logger"; + +export type RebuildJobType = "site-resource" | "client"; + +export interface RebuildJob { + type: RebuildJobType; + id: number; +} + +export interface RebuildJobHandlers { + onSiteResource(siteResourceId: number): Promise; + onClient(clientId: number): Promise; +} + +// Redis list holding pending rebuild jobs (RPUSH to enqueue, LPOP to dequeue — FIFO order). +const QUEUE_KEY = "rebuild-client-associations:queue"; + +// 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 enqueue(job: RebuildJob): Promise { + if (!redis || redis.status !== "ready") { + logger.warn( + `Rebuild queue: Redis not available — rebuild for ${job.type}:${job.id} will not be retried` + ); + return; + } + + try { + await redis.rpush(QUEUE_KEY, JSON.stringify(job)); + logger.debug( + `Rebuild queue: enqueued ${job.type}:${job.id} (queue position: tail)` + ); + } catch (err) { + logger.error( + `Rebuild queue: failed to enqueue ${job.type}:${job.id}:`, + err + ); + } + } + + startProcessing(handlers: RebuildJobHandlers): void { + if (this.processingStarted) return; + this.processingStarted = true; + + this.processLoop(handlers).catch((err) => { + logger.error("Rebuild queue processor loop crashed:", err); + }); + + logger.info("Rebuild queue processor started"); + } + + private async processLoop(handlers: RebuildJobHandlers): Promise { + while (true) { + try { + await this.tryProcessBatch(handlers); + } catch (err) { + logger.error( + "Rebuild queue: unhandled error in process loop:", + err + ); + } + await new Promise((resolve) => + setTimeout(resolve, POLL_INTERVAL_MS) + ); + } + } + + private async tryProcessBatch(handlers: RebuildJobHandlers): Promise { + if (!redis || redis.status !== "ready") return; + + // Peek before acquiring the processor lock to avoid unnecessary Redis + // round-trips and lock contention when the queue is idle. + const queueLength = await redis.llen(QUEUE_KEY).catch(() => 0); + if (queueLength === 0) return; + + try { + await lockManager.withLock( + PROCESSOR_LOCK_KEY, + async () => { + for (let i = 0; i < BATCH_SIZE; i++) { + if (!redis || redis.status !== "ready") break; + + const payload = await redis.lpop(QUEUE_KEY); + if (payload === null) break; // queue drained + + let job: RebuildJob; + try { + job = JSON.parse(payload) as RebuildJob; + } catch { + logger.error( + `Rebuild queue: could not parse job payload, discarding: ${payload}` + ); + continue; + } + + 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(); From d09668b20b6875467ac160fa5378d73e741a46ad Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 16 Jun 2026 22:46:12 +0000 Subject: [PATCH 19/88] feat: batch redis ws direct messages and dedupe rebuild queue jobs --- server/private/lib/rebuildQueue.ts | 19 ++++ server/private/routers/ws/ws.ts | 140 +++++++++++++++++++++++++---- server/routers/ws/types.ts | 6 +- 3 files changed, 146 insertions(+), 19 deletions(-) diff --git a/server/private/lib/rebuildQueue.ts b/server/private/lib/rebuildQueue.ts index e5ee7e7cb8..ad150f278c 100644 --- a/server/private/lib/rebuildQueue.ts +++ b/server/private/lib/rebuildQueue.ts @@ -29,6 +29,7 @@ export interface RebuildJobHandlers { // 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. @@ -54,11 +55,23 @@ class RedisRebuildQueue { } 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(() => undefined); logger.error( `Rebuild queue: failed to enqueue ${job.type}:${job.id}:`, err @@ -121,6 +134,12 @@ class RedisRebuildQueue { 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(() => undefined); + logger.debug( `Rebuild queue: processing ${job.type}:${job.id}` ); diff --git a/server/private/routers/ws/ws.ts b/server/private/routers/ws/ws.ts index a592927ccb..c58437b78d 100644 --- a/server/private/routers/ws/ws.ts +++ b/server/private/routers/ws/ws.ts @@ -187,6 +187,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_MS = 10; // Client tracking map (local to this node) const connectedClients: Map = new Map(); @@ -197,6 +199,15 @@ const clientConfigVersions: Map = new Map(); // Recovery tracking let isRedisRecoveryInProgress = false; +interface RedisDirectBatchEntry { + targetClientId: string; + message: WSMessage; + resolve: () => void; +} + +let pendingRedisDirectMessages: RedisDirectBatchEntry[] = []; +let redisDirectFlushTimer: NodeJS.Timeout | null = null; + // Helper to get map key const getClientMapKey = (clientId: string) => clientId; @@ -207,6 +218,82 @@ const getNodeConnectionsKey = (nodeId: string, clientId: string) => const getConfigVersionKey = (clientId: string) => `ws:configVersion:${clientId}`; +const clearRedisDirectFlushTimer = (): void => { + if (redisDirectFlushTimer) { + clearTimeout(redisDirectFlushTimer); + redisDirectFlushTimer = null; + } +}; + +const publishDirectBatch = async ( + entries: RedisDirectBatchEntry[] +): Promise => { + const redisMessage: RedisMessage = { + type: "direct-batch", + messages: entries.map((entry) => ({ + targetClientId: entry.targetClientId, + message: entry.message + })), + message: { + type: "batch", + data: {} + }, + fromNodeId: NODE_ID + }; + + await redisManager.publish(REDIS_CHANNEL, JSON.stringify(redisMessage)); +}; + +const flushPendingRedisDirectMessages = async (): Promise => { + clearRedisDirectFlushTimer(); + + if (pendingRedisDirectMessages.length === 0) { + return; + } + + const entries = pendingRedisDirectMessages; + pendingRedisDirectMessages = []; + + if (!redisManager.isRedisEnabled()) { + entries.forEach((entry) => entry.resolve()); + return; + } + + for (let i = 0; i < entries.length; i += REDIS_DIRECT_BATCH_SIZE) { + const batch = entries.slice(i, i + REDIS_DIRECT_BATCH_SIZE); + try { + await publishDirectBatch(batch); + } catch (error) { + logger.error( + "Failed to send batched direct messages via Redis, messages may be lost:", + error + ); + } finally { + batch.forEach((entry) => entry.resolve()); + } + } +}; + +const enqueueRedisDirectMessage = async ( + targetClientId: string, + message: WSMessage +): Promise => { + await new Promise((resolve) => { + pendingRedisDirectMessages.push({ targetClientId, message, resolve }); + + if (pendingRedisDirectMessages.length >= REDIS_DIRECT_BATCH_SIZE) { + void flushPendingRedisDirectMessages(); + return; + } + + if (!redisDirectFlushTimer) { + redisDirectFlushTimer = setTimeout(() => { + void flushPendingRedisDirectMessages(); + }, REDIS_DIRECT_FLUSH_MS); + } + }); +}; + // Initialize Redis subscription for cross-node messaging const initializeRedisSubscription = async (): Promise => { if (!redisManager.isRedisEnabled()) return; @@ -227,7 +314,16 @@ const initializeRedisSubscription = async (): Promise => { // Send to specific client on this node await sendToClientLocal( redisMessage.targetClientId, - redisMessage.message + redisMessage.message, + {}, + redisMessage.message.configVersion + ); + } else if ( + redisMessage.type === "direct-batch" && + redisMessage.messages + ) { + await sendRedisDirectBatchToLocalClients( + redisMessage.messages ); } else if (redisMessage.type === "broadcast") { // Broadcast to all clients on this node except excluded @@ -503,7 +599,8 @@ const incrementClientConfigVersion = async ( const sendToClientLocal = async ( clientId: string, message: WSMessage, - options: SendMessageOptions = {} + options: SendMessageOptions = {}, + preResolvedConfigVersion?: number ): Promise => { const mapKey = getClientMapKey(clientId); const clients = connectedClients.get(mapKey); @@ -512,7 +609,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,6 +643,20 @@ const sendToClientLocal = async ( return true; }; +const sendRedisDirectBatchToLocalClients = async ( + entries: { targetClientId: string; message: WSMessage }[] +): Promise => { + const jobs = entries.map((entry) => + sendToClientLocal( + entry.targetClientId, + entry.message, + {}, + entry.message.configVersion + ) + ); + await Promise.all(jobs); +}; + const broadcastToAllExceptLocal = async ( message: WSMessage, excludeClientId?: string, @@ -607,23 +719,13 @@ const sendToClient = async ( // 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 @@ -1109,6 +1211,8 @@ const disconnectClient = async (clientId: string): Promise => { // Cleanup function for graceful shutdown const cleanup = async (): Promise => { try { + await flushPendingRedisDirectMessages(); + // Close all WebSocket connections connectedClients.forEach((clients) => { clients.forEach((client) => { diff --git a/server/routers/ws/types.ts b/server/routers/ws/types.ts index e539954ce0..9e6504ce0c 100644 --- a/server/routers/ws/types.ts +++ b/server/routers/ws/types.ts @@ -78,10 +78,14 @@ export interface SendMessageOptions { // Redis message type for cross-node communication export interface RedisMessage { - type: "direct" | "broadcast"; + type: "direct" | "direct-batch" | "broadcast"; targetClientId?: string; excludeClientId?: string; message: WSMessage; + messages?: { + targetClientId: string; + message: WSMessage; + }[]; fromNodeId: string; options?: SendMessageOptions; } From 22ac711dc63c1e60ce4b09e7d837785bc12879c3 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 16 Jun 2026 22:50:03 +0000 Subject: [PATCH 20/88] refactor: tighten ws batch typing and queue cleanup logging --- server/private/lib/rebuildQueue.ts | 16 ++++++++++--- server/private/routers/ws/ws.ts | 8 ++----- server/routers/ws/types.ts | 36 +++++++++++++++++++----------- 3 files changed, 38 insertions(+), 22 deletions(-) diff --git a/server/private/lib/rebuildQueue.ts b/server/private/lib/rebuildQueue.ts index ad150f278c..2cd1dadc01 100644 --- a/server/private/lib/rebuildQueue.ts +++ b/server/private/lib/rebuildQueue.ts @@ -70,8 +70,13 @@ class RedisRebuildQueue { ); } catch (err) { await redis - ?.srem(QUEUED_SET_KEY, `${job.type}:${job.id}`) - .catch(() => undefined); + .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 @@ -138,7 +143,12 @@ class RedisRebuildQueue { // can be re-queued while this one is in progress. await redis .srem(QUEUED_SET_KEY, `${job.type}:${job.id}`) - .catch(() => undefined); + .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}` diff --git a/server/private/routers/ws/ws.ts b/server/private/routers/ws/ws.ts index c58437b78d..2db8f3140a 100644 --- a/server/private/routers/ws/ws.ts +++ b/server/private/routers/ws/ws.ts @@ -188,7 +188,7 @@ const wss: WebSocketServer = new WebSocketServer({ noServer: true }); const NODE_ID = uuidv4(); const REDIS_CHANNEL = "websocket_messages"; const REDIS_DIRECT_BATCH_SIZE = 250; -const REDIS_DIRECT_FLUSH_MS = 10; +const REDIS_DIRECT_FLUSH_INTERVAL_MS = 10; // Client tracking map (local to this node) const connectedClients: Map = new Map(); @@ -234,10 +234,6 @@ const publishDirectBatch = async ( targetClientId: entry.targetClientId, message: entry.message })), - message: { - type: "batch", - data: {} - }, fromNodeId: NODE_ID }; @@ -289,7 +285,7 @@ const enqueueRedisDirectMessage = async ( if (!redisDirectFlushTimer) { redisDirectFlushTimer = setTimeout(() => { void flushPendingRedisDirectMessages(); - }, REDIS_DIRECT_FLUSH_MS); + }, REDIS_DIRECT_FLUSH_INTERVAL_MS); } }); }; diff --git a/server/routers/ws/types.ts b/server/routers/ws/types.ts index 9e6504ce0c..d541d32762 100644 --- a/server/routers/ws/types.ts +++ b/server/routers/ws/types.ts @@ -76,16 +76,26 @@ export interface SendMessageOptions { compress?: boolean; } -// Redis message type for cross-node communication -export interface RedisMessage { - type: "direct" | "direct-batch" | "broadcast"; - targetClientId?: string; - excludeClientId?: string; - message: WSMessage; - messages?: { - targetClientId: string; - message: WSMessage; - }[]; - fromNodeId: string; - options?: SendMessageOptions; -} +// Redis message types for cross-node communication +export type RedisMessage = + | { + type: "direct"; + targetClientId: string; + message: WSMessage; + fromNodeId: string; + } + | { + type: "direct-batch"; + messages: { + targetClientId: string; + message: WSMessage; + }[]; + fromNodeId: string; + } + | { + type: "broadcast"; + excludeClientId?: string; + message: WSMessage; + fromNodeId: string; + options?: SendMessageOptions; + }; From ee42846c90c5a7f49e78e4c4d0b81573e296fbc0 Mon Sep 17 00:00:00 2001 From: Owen Date: Sun, 21 Jun 2026 17:20:07 -0400 Subject: [PATCH 21/88] Add batch messaging functions to rebuild function --- server/lib/rebuildClientAssociations.ts | 321 +++++++++++++++++------- server/private/routers/ws/ws.ts | 72 ++++++ server/routers/client/targets.ts | 241 +++++++++++++++++- server/routers/newt/peers.ts | 77 ++++-- server/routers/olm/peers.ts | 151 ++++++++++- server/routers/ws/types.ts | 6 + server/routers/ws/ws.ts | 18 +- 7 files changed, 767 insertions(+), 119 deletions(-) diff --git a/server/lib/rebuildClientAssociations.ts b/server/lib/rebuildClientAssociations.ts index 8b601ae714..f6a94e7b7b 100644 --- a/server/lib/rebuildClientAssociations.ts +++ b/server/lib/rebuildClientAssociations.ts @@ -21,10 +21,10 @@ import { } from "@server/db"; import { and, count, eq, inArray, ne } from "drizzle-orm"; -import { deletePeer as newtDeletePeer } from "@server/routers/newt/peers"; +import { deletePeersBatch as newtDeletePeersBatch } from "@server/routers/newt/peers"; import { - initPeerAddHandshake, - deletePeer as olmDeletePeer + initPeerAddHandshakeBatch, + deletePeersBatch as olmDeletePeersBatch } from "@server/routers/olm/peers"; import { sendToExitNode } from "#dynamic/lib/exitNodes"; import logger from "@server/logger"; @@ -35,10 +35,10 @@ import { parseEndpoint } from "@server/lib/ip"; import { - addPeerData, - addTargets as addSubnetProxyTargets, - removePeerData, - removeTargets as removeSubnetProxyTargets + addPeerDataBatch, + addTargetsBatch as addSubnetProxyTargetsBatch, + removePeerDataBatch, + removeTargetsBatch as removeSubnetProxyTargetsBatch } from "@server/routers/client/targets"; import { lockManager } from "#dynamic/lib/lock"; import { rebuildQueue } from "#dynamic/lib/rebuildQueue"; @@ -559,6 +559,28 @@ async function handleMessagesForSiteClients( const newtJobs: Promise[] = []; const olmJobs: Promise[] = []; const exitNodeJobs: Promise[] = []; + const newtPeerDeletes: { + siteId: number; + publicKey: string; + newtId: string; + }[] = []; + const olmPeerDeletes: { + clientId: number; + siteId: number; + publicKey: string; + olmId: string; + }[] = []; + const olmPeerAddHandshakes: { + clientId: number; + peer: { + siteId: number; + exitNode: { + publicKey: string; + endpoint: string; + }; + }; + olmId: string; + }[] = []; // Combine all clients that need processing (those being added or removed) const clientsToProcess = new Map< @@ -638,15 +660,17 @@ async function handleMessagesForSiteClients( } if (isDelete) { - newtJobs.push(newtDeletePeer(siteId, client.pubKey, newt.newtId)); - olmJobs.push( - olmDeletePeer( - client.clientId, - siteId, - site.publicKey, - olm.olmId - ) - ); + newtPeerDeletes.push({ + siteId, + publicKey: client.pubKey, + newtId: newt.newtId + }); + olmPeerDeletes.push({ + clientId: client.clientId, + siteId, + publicKey: site.publicKey, + olmId: olm.olmId + }); } if (isAdd) { @@ -658,23 +682,34 @@ async function handleMessagesForSiteClients( continue; } - await initPeerAddHandshake( - // this will kick off the add peer process for the client - client.clientId, - { + olmPeerAddHandshakes.push({ + clientId: client.clientId, + peer: { siteId, exitNode: { publicKey: exitNode.publicKey, endpoint: exitNode.endpoint } }, - olm.olmId - ); + olmId: olm.olmId + }); } exitNodeJobs.push(updateClientSiteDestinations(client, trx)); } + if (newtPeerDeletes.length > 0) { + newtJobs.push(newtDeletePeersBatch(newtPeerDeletes)); + } + + if (olmPeerDeletes.length > 0) { + olmJobs.push(olmDeletePeersBatch(olmPeerDeletes)); + } + + if (olmPeerAddHandshakes.length > 0) { + olmJobs.push(initPeerAddHandshakeBatch(olmPeerAddHandshakes)); + } + Promise.all(exitNodeJobs).catch((error) => { logger.error( `rebuildClientAssociations: Error updating client site destinations for site ${site.siteId}:`, @@ -867,24 +902,28 @@ async function handleSubnetProxyTargetUpdates( if (targetsToAdd) { proxyJobs.push( - addSubnetProxyTargets( - newt.newtId, - targetsToAdd, - newt.version - ) + addSubnetProxyTargetsBatch([ + { + newtId: newt.newtId, + targets: targetsToAdd, + version: newt.version + } + ]) ); } - for (const client of addedClients) { - olmJobs.push( - addPeerData( - client.clientId, + olmJobs.push( + addPeerDataBatch( + addedClients.map((client) => ({ + clientId: client.clientId, siteId, - generateRemoteSubnets([siteResource]), - generateAliasConfig([siteResource]) - ) - ); - } + remoteSubnets: generateRemoteSubnets([ + siteResource + ]), + aliases: generateAliasConfig([siteResource]) + })) + ) + ); } } @@ -904,14 +943,23 @@ async function handleSubnetProxyTargetUpdates( if (targetsToRemove) { proxyJobs.push( - removeSubnetProxyTargets( - newt.newtId, - targetsToRemove, - newt.version - ) + removeSubnetProxyTargetsBatch([ + { + newtId: newt.newtId, + targets: targetsToRemove, + version: newt.version + } + ]) ); } + const peerDataRemovals: { + clientId: number; + siteId: number; + remoteSubnets: string[]; + aliases: ReturnType; + }[] = []; + for (const client of removedClients) { if (!siteResource.destination) { continue; @@ -959,14 +1007,16 @@ async function handleSubnetProxyTargetUpdates( ? [] : generateRemoteSubnets([siteResource]); - olmJobs.push( - removePeerData( - client.clientId, - siteId, - remoteSubnetsToRemove, - generateAliasConfig([siteResource]) - ) - ); + peerDataRemovals.push({ + clientId: client.clientId, + siteId, + remoteSubnets: remoteSubnetsToRemove, + aliases: generateAliasConfig([siteResource]) + }); + } + + if (peerDataRemovals.length > 0) { + olmJobs.push(removePeerDataBatch(peerDataRemovals)); } } } @@ -1277,6 +1327,28 @@ async function handleMessagesForClientSites( const newtJobs: Promise[] = []; const olmJobs: Promise[] = []; const exitNodeJobs: Promise[] = []; + const newtPeerDeletes: { + siteId: number; + publicKey: string; + newtId: string; + }[] = []; + const olmPeerDeletes: { + clientId: number; + siteId: number; + publicKey: string; + olmId: string; + }[] = []; + const olmPeerAddHandshakes: { + clientId: number; + peer: { + siteId: number; + exitNode: { + publicKey: string; + endpoint: string; + }; + }; + olmId: string; + }[] = []; const totalSitesOnClient = await trx .select({ count: count(clientSitesAssociationsCache.siteId) }) @@ -1308,19 +1380,19 @@ async function handleMessagesForClientSites( if (isRemove) { // Remove peer from newt - newtJobs.push( - newtDeletePeer(site.siteId, client.pubKey, newt.newtId) - ); + newtPeerDeletes.push({ + siteId: site.siteId, + publicKey: client.pubKey, + newtId: newt.newtId + }); try { // Remove peer from olm - olmJobs.push( - olmDeletePeer( - client.clientId, - site.siteId, - site.publicKey, - olmId - ) - ); + olmPeerDeletes.push({ + clientId: client.clientId, + siteId: site.siteId, + publicKey: site.publicKey, + olmId + }); } catch (error) { // if the error includes not found then its just because the olm does not exist anymore or yet and its fine if we dont send if ( @@ -1352,10 +1424,9 @@ async function handleMessagesForClientSites( continue; } - await initPeerAddHandshake( - // this will kick off the add peer process for the client - client.clientId, - { + olmPeerAddHandshakes.push({ + clientId: client.clientId, + peer: { siteId: site.siteId, exitNode: { publicKey: exitNode.publicKey, @@ -1363,7 +1434,7 @@ async function handleMessagesForClientSites( } }, olmId - ); + }); } // Update exit node destinations @@ -1379,6 +1450,18 @@ async function handleMessagesForClientSites( ); } + if (newtPeerDeletes.length > 0) { + newtJobs.push(newtDeletePeersBatch(newtPeerDeletes)); + } + + if (olmPeerDeletes.length > 0) { + olmJobs.push(olmDeletePeersBatch(olmPeerDeletes)); + } + + if (olmPeerAddHandshakes.length > 0) { + olmJobs.push(initPeerAddHandshakeBatch(olmPeerAddHandshakes)); + } + Promise.all(exitNodeJobs).catch((error) => { logger.error( `rebuildClientAssociations: Error updating client site destinations for client ${client.clientId}:`, @@ -1477,6 +1560,20 @@ async function handleMessagesForClientResources( continue; } + const targetsToAddBatch: { + newtId: string; + targets: NonNullable< + Awaited> + >; + version: string | null; + }[] = []; + const peerDataAdds: { + clientId: number; + siteId: number; + remoteSubnets: string[]; + aliases: ReturnType; + }[] = []; + for (const resource of resources) { const targets = await generateSubnetProxyTargetV2(resource, [ { @@ -1487,25 +1584,21 @@ async function handleMessagesForClientResources( ]); if (targets) { - proxyJobs.push( - addSubnetProxyTargets( - newt.newtId, - targets, - newt.version - ) - ); + targetsToAddBatch.push({ + newtId: newt.newtId, + targets, + version: newt.version + }); } try { // Add peer data to olm - olmJobs.push( - addPeerData( - client.clientId, - siteId, - generateRemoteSubnets([resource]), - generateAliasConfig([resource]) - ) - ); + peerDataAdds.push({ + clientId: client.clientId, + siteId, + remoteSubnets: generateRemoteSubnets([resource]), + aliases: generateAliasConfig([resource]) + }); } catch (error) { // if the error includes not found then its just because the olm does not exist anymore or yet and its fine if we dont send if ( @@ -1520,6 +1613,14 @@ async function handleMessagesForClientResources( } } } + + if (targetsToAddBatch.length > 0) { + proxyJobs.push(addSubnetProxyTargetsBatch(targetsToAddBatch)); + } + + if (peerDataAdds.length > 0) { + olmJobs.push(addPeerDataBatch(peerDataAdds)); + } } } @@ -1586,6 +1687,20 @@ async function handleMessagesForClientResources( continue; } + const targetsToRemoveBatch: { + newtId: string; + targets: NonNullable< + Awaited> + >; + version: string | null; + }[] = []; + const peerDataRemovals: { + clientId: number; + siteId: number; + remoteSubnets: string[]; + aliases: ReturnType; + }[] = []; + for (const resource of resources) { const targets = await generateSubnetProxyTargetV2(resource, [ { @@ -1596,13 +1711,11 @@ async function handleMessagesForClientResources( ]); if (targets) { - proxyJobs.push( - removeSubnetProxyTargets( - newt.newtId, - targets, - newt.version - ) - ); + targetsToRemoveBatch.push({ + newtId: newt.newtId, + targets, + version: newt.version + }); } try { @@ -1653,14 +1766,12 @@ async function handleMessagesForClientResources( : generateRemoteSubnets([resource]); // Remove peer data from olm - olmJobs.push( - removePeerData( - client.clientId, - siteId, - remoteSubnetsToRemove, - generateAliasConfig([resource]) - ) - ); + peerDataRemovals.push({ + clientId: client.clientId, + siteId, + remoteSubnets: remoteSubnetsToRemove, + aliases: generateAliasConfig([resource]) + }); } catch (error) { // if the error includes not found then its just because the olm does not exist anymore or yet and its fine if we dont send if ( @@ -1675,6 +1786,16 @@ async function handleMessagesForClientResources( } } } + + if (targetsToRemoveBatch.length > 0) { + proxyJobs.push( + removeSubnetProxyTargetsBatch(targetsToRemoveBatch) + ); + } + + if (peerDataRemovals.length > 0) { + olmJobs.push(removePeerDataBatch(peerDataRemovals)); + } } } @@ -1928,7 +2049,15 @@ export async function cleanupSiteAssociations( for (const client of allClients) { // Tell each olm to drop the site's WireGuard peer. if (site.publicKey) { - jobs.push(olmDeletePeer(client.clientId, siteId, site.publicKey)); + jobs.push( + olmDeletePeersBatch([ + { + clientId: client.clientId, + siteId, + publicKey: site.publicKey + } + ]) + ); } // Recompute and push updated relay destinations (now excluding this site). diff --git a/server/private/routers/ws/ws.ts b/server/private/routers/ws/ws.ts index 2db8f3140a..8d222fd724 100644 --- a/server/private/routers/ws/ws.ts +++ b/server/private/routers/ws/ws.ts @@ -38,6 +38,7 @@ import { messageHandlers } from "@server/routers/ws/messageHandlers"; import { messageHandlers as privateMessageHandlers } from "#private/routers/ws/messageHandlers"; import { AuthenticatedWebSocket, + BatchSendMessage, ClientType, WSMessage, TokenPayload, @@ -736,6 +737,76 @@ const sendToClient = async ( return localSent; }; +const sendToClientsBatch = async ( + entries: BatchSendMessage[] +): Promise => { + if (entries.length === 0) { + return; + } + + const remoteEntries: { targetClientId: string; message: WSMessage }[] = []; + + for (const entry of entries) { + const options = entry.options || {}; + const { clientId, message } = entry; + + let configVersion = await getClientConfigVersion(clientId); + if (options.incrementConfigVersion) { + configVersion = await incrementClientConfigVersion(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, @@ -1239,6 +1310,7 @@ export { router, handleWSUpgrade, sendToClient, + sendToClientsBatch, broadcastToAllExcept, connectedClients, hasActiveConnections, diff --git a/server/routers/client/targets.ts b/server/routers/client/targets.ts index c208acd889..c62a64ae05 100644 --- a/server/routers/client/targets.ts +++ b/server/routers/client/targets.ts @@ -1,4 +1,4 @@ -import { sendToClient } from "#dynamic/routers/ws"; +import { sendToClient, sendToClientsBatch } from "#dynamic/routers/ws"; import { db, newts, olms } from "@server/db"; import { Alias, @@ -8,7 +8,7 @@ import { } from "@server/lib/ip"; import { canCompress } from "@server/lib/clientVersionChecks"; import logger from "@server/logger"; -import { eq } from "drizzle-orm"; +import { eq, inArray } from "drizzle-orm"; import semver from "semver"; const NEWT_V2_TARGETS_VERSION = ">=1.10.3"; @@ -59,6 +59,42 @@ export async function addTargets( ); } +export async function addTargetsBatch( + entries: { + newtId: string; + targets: SubnetProxyTarget[] | SubnetProxyTargetV2[]; + version?: string | null; + }[] +) { + if (entries.length === 0) { + return; + } + + const resolved = await Promise.all( + entries.map(async (entry) => ({ + ...entry, + targets: await convertTargetsIfNecessary( + entry.newtId, + entry.targets + ) + })) + ); + + await sendToClientsBatch( + resolved.map((entry) => ({ + clientId: entry.newtId, + message: { + type: `newt/wg/targets/add`, + data: entry.targets + }, + options: { + incrementConfigVersion: true, + compress: canCompress(entry.version, "newt") + } + })) + ); +} + export async function removeTargets( newtId: string, targets: SubnetProxyTarget[] | SubnetProxyTargetV2[], @@ -76,6 +112,42 @@ export async function removeTargets( ); } +export async function removeTargetsBatch( + entries: { + newtId: string; + targets: SubnetProxyTarget[] | SubnetProxyTargetV2[]; + version?: string | null; + }[] +) { + if (entries.length === 0) { + return; + } + + const resolved = await Promise.all( + entries.map(async (entry) => ({ + ...entry, + targets: await convertTargetsIfNecessary( + entry.newtId, + entry.targets + ) + })) + ); + + await sendToClientsBatch( + resolved.map((entry) => ({ + clientId: entry.newtId, + message: { + type: `newt/wg/targets/remove`, + data: entry.targets + }, + options: { + incrementConfigVersion: true, + compress: canCompress(entry.version, "newt") + } + })) + ); +} + export async function updateTargets( newtId: string, targets: { @@ -201,6 +273,171 @@ export async function removePeerData( }); } +const resolveOlmTargets = async ( + entries: { + clientId: number; + olmId?: string; + version?: string | null; + }[] +) => { + const unresolvedClientIds = entries + .filter((entry) => !entry.olmId) + .map((entry) => entry.clientId); + + const olmMap = new Map(); + + if (unresolvedClientIds.length > 0) { + const olmRows = await db + .select({ + clientId: olms.clientId, + olmId: olms.olmId, + version: olms.version + }) + .from(olms) + .where(inArray(olms.clientId, unresolvedClientIds)); + + for (const row of olmRows) { + if (row.clientId !== null) { + olmMap.set(row.clientId, { + olmId: row.olmId, + version: row.version + }); + } + } + } + + return entries + .map((entry) => { + if (entry.olmId) { + return { + clientId: entry.clientId, + olmId: entry.olmId, + version: entry.version + }; + } + + const resolved = olmMap.get(entry.clientId); + if (!resolved) { + return null; + } + + return { + clientId: entry.clientId, + olmId: resolved.olmId, + version: entry.version ?? resolved.version + }; + }) + .filter((entry) => entry !== null); +}; + +export async function addPeerDataBatch( + entries: { + clientId: number; + siteId: number; + remoteSubnets: string[]; + aliases: Alias[]; + olmId?: string; + version?: string | null; + }[] +) { + if (entries.length === 0) { + return; + } + + const resolvedTargets = await resolveOlmTargets(entries); + + if (resolvedTargets.length === 0) { + return; + } + + const payloads = entries + .map((entry) => { + const resolved = resolvedTargets.find( + (target) => target.clientId === entry.clientId + ); + if (!resolved) { + return null; + } + + return { + clientId: resolved.olmId, + message: { + type: `olm/wg/peer/data/add`, + data: { + siteId: entry.siteId, + remoteSubnets: entry.remoteSubnets, + aliases: entry.aliases + } + }, + options: { + incrementConfigVersion: true, + compress: canCompress(resolved.version, "olm") + } + }; + }) + .filter((entry) => entry !== null); + + if (payloads.length === 0) { + return; + } + + await sendToClientsBatch(payloads); +} + +export async function removePeerDataBatch( + entries: { + clientId: number; + siteId: number; + remoteSubnets: string[]; + aliases: Alias[]; + olmId?: string; + version?: string | null; + }[] +) { + if (entries.length === 0) { + return; + } + + const resolvedTargets = await resolveOlmTargets(entries); + + if (resolvedTargets.length === 0) { + return; + } + + const payloads = entries + .map((entry) => { + const resolved = resolvedTargets.find( + (target) => target.clientId === entry.clientId + ); + if (!resolved) { + return null; + } + + return { + clientId: resolved.olmId, + message: { + type: `olm/wg/peer/data/remove`, + data: { + siteId: entry.siteId, + remoteSubnets: entry.remoteSubnets, + aliases: entry.aliases + } + }, + options: { + incrementConfigVersion: true, + compress: canCompress(resolved.version, "olm") + } + }; + }) + .filter((entry) => entry !== null); + + if (payloads.length === 0) { + return; + } + + await sendToClientsBatch(payloads); +} + export async function updatePeerData( clientId: number, siteId: number, diff --git a/server/routers/newt/peers.ts b/server/routers/newt/peers.ts index 4b74d863df..6c38671f30 100644 --- a/server/routers/newt/peers.ts +++ b/server/routers/newt/peers.ts @@ -1,7 +1,7 @@ import { db, Site } from "@server/db"; import { newts, sites } from "@server/db"; import { eq } from "drizzle-orm"; -import { sendToClient } from "#dynamic/routers/ws"; +import { sendToClient, sendToClientsBatch } from "#dynamic/routers/ws"; import logger from "@server/logger"; export async function addPeer( @@ -36,10 +36,14 @@ export async function addPeer( newtId = newt.newtId; } - await sendToClient(newtId, { - type: "newt/wg/peer/add", - data: peer - }, { incrementConfigVersion: true }).catch((error) => { + await sendToClient( + newtId, + { + type: "newt/wg/peer/add", + data: peer + }, + { incrementConfigVersion: true } + ).catch((error) => { logger.warn(`Error sending message:`, error); }); @@ -76,12 +80,16 @@ export async function deletePeer( newtId = newt.newtId; } - await sendToClient(newtId, { - type: "newt/wg/peer/remove", - data: { - publicKey - } - }, { incrementConfigVersion: true }).catch((error) => { + await sendToClient( + newtId, + { + type: "newt/wg/peer/remove", + data: { + publicKey + } + }, + { incrementConfigVersion: true } + ).catch((error) => { logger.warn(`Error sending message:`, error); }); @@ -90,6 +98,35 @@ export async function deletePeer( return site; } +export async function deletePeersBatch( + peers: { + siteId: number; + publicKey: string; + newtId: string; + }[] +) { + if (peers.length === 0) { + return; + } + + await sendToClientsBatch( + peers.map((peer) => ({ + clientId: peer.newtId, + message: { + type: "newt/wg/peer/remove", + data: { + publicKey: peer.publicKey + } + }, + options: { incrementConfigVersion: true } + })) + ).catch((error) => { + logger.warn(`Error sending batched newt peer removals:`, error); + }); + + logger.info(`Deleted ${peers.length} peer(s) from newts (batch)`); +} + export async function updatePeer( siteId: number, publicKey: string, @@ -122,13 +159,17 @@ export async function updatePeer( newtId = newt.newtId; } - await sendToClient(newtId, { - type: "newt/wg/peer/update", - data: { - publicKey, - ...peer - } - }, { incrementConfigVersion: true }).catch((error) => { + await sendToClient( + newtId, + { + type: "newt/wg/peer/update", + data: { + publicKey, + ...peer + } + }, + { incrementConfigVersion: true } + ).catch((error) => { logger.warn(`Error sending message:`, error); }); diff --git a/server/routers/olm/peers.ts b/server/routers/olm/peers.ts index 05e153feae..962d7367ea 100644 --- a/server/routers/olm/peers.ts +++ b/server/routers/olm/peers.ts @@ -1,9 +1,9 @@ -import { sendToClient } from "#dynamic/routers/ws"; +import { sendToClient, sendToClientsBatch } from "#dynamic/routers/ws"; import { clientSitesAssociationsCache, db, olms } from "@server/db"; import { canCompress } from "@server/lib/clientVersionChecks"; import config from "@server/lib/config"; import logger from "@server/logger"; -import { and, eq } from "drizzle-orm"; +import { and, eq, inArray } from "drizzle-orm"; import { Alias } from "yaml"; export async function addPeer( @@ -205,3 +205,150 @@ export async function initPeerAddHandshake( `Initiated peer add handshake for site ${peer.siteId} to olm ${olmId}` ); } + +export async function deletePeersBatch( + peers: { + clientId: number; + siteId: number; + publicKey: string; + olmId?: string; + version?: string | null; + }[] +) { + if (peers.length === 0) { + return; + } + + const unresolvedClientIds = peers + .filter((peer) => !peer.olmId) + .map((peer) => peer.clientId); + + const olmByClientId = new Map< + number, + { olmId: string; version: string | null } + >(); + + if (unresolvedClientIds.length > 0) { + const olmRows = await db + .select({ + clientId: olms.clientId, + olmId: olms.olmId, + version: olms.version + }) + .from(olms) + .where(inArray(olms.clientId, unresolvedClientIds)); + + for (const row of olmRows) { + if (row.clientId !== null) { + olmByClientId.set(row.clientId, { + olmId: row.olmId, + version: row.version + }); + } + } + } + + const batchPayloads = peers + .map((peer) => { + const resolved = peer.olmId + ? { olmId: peer.olmId, version: peer.version ?? null } + : olmByClientId.get(peer.clientId); + if (!resolved) { + return null; + } + + return { + clientId: resolved.olmId, + message: { + type: "olm/wg/peer/remove", + data: { + publicKey: peer.publicKey, + siteId: peer.siteId + } + }, + options: { + incrementConfigVersion: true, + compress: canCompress( + peer.version ?? resolved.version, + "olm" + ) + } + }; + }) + .filter((payload) => payload !== null); + + if (batchPayloads.length === 0) { + return; + } + + await sendToClientsBatch(batchPayloads).catch((error) => { + logger.warn(`Error sending batched olm peer removals:`, error); + }); + + logger.info(`Deleted ${batchPayloads.length} peer(s) from olms (batch)`); +} + +export async function initPeerAddHandshakeBatch( + handshakes: { + clientId: number; + peer: { + siteId: number; + exitNode: { + publicKey: string; + endpoint: string; + }; + }; + olmId: string; + chainId?: string; + }[] +) { + if (handshakes.length === 0) { + return; + } + + await sendToClientsBatch( + handshakes.map((item) => ({ + clientId: item.olmId, + message: { + type: "olm/wg/peer/holepunch/site/add", + data: { + siteId: item.peer.siteId, + exitNode: { + publicKey: item.peer.exitNode.publicKey, + relayPort: + config.getRawConfig().gerbil.clients_start_port, + endpoint: item.peer.exitNode.endpoint + }, + chainId: item.chainId + } + }, + options: { incrementConfigVersion: true } + })) + ).catch((error) => { + logger.warn(`Error sending batched olm handshakes:`, error); + }); + + await Promise.all( + handshakes.map((item) => + db + .update(clientSitesAssociationsCache) + .set({ isJitMode: false }) + .where( + and( + eq( + clientSitesAssociationsCache.clientId, + item.clientId + ), + eq( + clientSitesAssociationsCache.siteId, + item.peer.siteId + ) + ) + ) + ) + ); + + logger.info( + `Initiated ${handshakes.length} peer add handshake(s) to olms (batch)` + ); +} diff --git a/server/routers/ws/types.ts b/server/routers/ws/types.ts index d541d32762..eeb2724576 100644 --- a/server/routers/ws/types.ts +++ b/server/routers/ws/types.ts @@ -76,6 +76,12 @@ export interface SendMessageOptions { compress?: boolean; } +export interface BatchSendMessage { + clientId: string; + message: WSMessage; + options?: SendMessageOptions; +} + // Redis message types for cross-node communication export type RedisMessage = | { diff --git a/server/routers/ws/ws.ts b/server/routers/ws/ws.ts index e7dcfe9cbc..4ce337a204 100644 --- a/server/routers/ws/ws.ts +++ b/server/routers/ws/ws.ts @@ -26,7 +26,8 @@ import { WebSocketRequest, WSMessage, AuthenticatedWebSocket, - SendMessageOptions + SendMessageOptions, + BatchSendMessage } from "./types"; import { validateSessionToken } from "@server/auth/sessions/app"; @@ -212,6 +213,20 @@ const sendToClient = async ( return localSent; }; +const sendToClientsBatch = async ( + entries: BatchSendMessage[] +): Promise => { + if (entries.length === 0) { + return; + } + + await Promise.all( + entries.map((entry) => + sendToClient(entry.clientId, entry.message, entry.options) + ) + ); +}; + const broadcastToAllExcept = async ( message: WSMessage, excludeClientId?: string, @@ -552,6 +567,7 @@ export { router, handleWSUpgrade, sendToClient, + sendToClientsBatch, broadcastToAllExcept, connectedClients, hasActiveConnections, From 604dee9aa5a34989f1e467a38c227675d53f6b14 Mon Sep 17 00:00:00 2001 From: Owen Date: Sun, 21 Jun 2026 17:34:46 -0400 Subject: [PATCH 22/88] Batch get olm ids --- server/lib/rebuildClientAssociations.ts | 29 ++++++++++++++++--------- 1 file changed, 19 insertions(+), 10 deletions(-) diff --git a/server/lib/rebuildClientAssociations.ts b/server/lib/rebuildClientAssociations.ts index f6a94e7b7b..8e235bc485 100644 --- a/server/lib/rebuildClientAssociations.ts +++ b/server/lib/rebuildClientAssociations.ts @@ -629,6 +629,21 @@ async function handleMessagesForSiteClients( } } + // Batch-fetch all olm IDs for the clients we need to process + const clientIdsToProcess = Array.from(clientsToProcess.keys()); + const olmRows = + clientIdsToProcess.length > 0 + ? await trx + .select({ olmId: olms.olmId, clientId: olms.clientId }) + .from(olms) + .where(inArray(olms.clientId, clientIdsToProcess)) + : []; + const olmByClientId = new Map( + olmRows + .filter((r) => r.clientId !== null) + .map((r) => [r.clientId as number, r.olmId]) + ); + for (const client of clientsToProcess.values()) { // UPDATE THE NEWT if (!client.subnet || !client.pubKey) { @@ -645,14 +660,8 @@ async function handleMessagesForSiteClients( continue; } - const [olm] = await trx - .select({ - olmId: olms.olmId - }) - .from(olms) - .where(eq(olms.clientId, client.clientId)) - .limit(1); - if (!olm) { + const olmId = olmByClientId.get(client.clientId); + if (!olmId) { logger.warn( `Olm not found for client ${client.clientId} so cannot add/delete peers` ); @@ -669,7 +678,7 @@ async function handleMessagesForSiteClients( clientId: client.clientId, siteId, publicKey: site.publicKey, - olmId: olm.olmId + olmId }); } @@ -691,7 +700,7 @@ async function handleMessagesForSiteClients( endpoint: exitNode.endpoint } }, - olmId: olm.olmId + olmId }); } From 60c1b572baf44b4a275fdf9de9997a6b6e7a5995 Mon Sep 17 00:00:00 2001 From: Owen Date: Sun, 21 Jun 2026 17:43:39 -0400 Subject: [PATCH 23/88] Add drizzle indexes to match db --- server/db/pg/schema/privateSchema.ts | 35 +- server/db/pg/schema/schema.ts | 672 ++++++++++++++++----------- 2 files changed, 414 insertions(+), 293 deletions(-) diff --git a/server/db/pg/schema/privateSchema.ts b/server/db/pg/schema/privateSchema.ts index 229fc9ff08..ae73b97ac3 100644 --- a/server/db/pg/schema/privateSchema.ts +++ b/server/db/pg/schema/privateSchema.ts @@ -11,7 +11,7 @@ import { primaryKey, uniqueIndex } from "drizzle-orm/pg-core"; -import { InferSelectModel } from "drizzle-orm"; +import { InferSelectModel, sql } from "drizzle-orm"; import { domains, orgs, @@ -207,17 +207,28 @@ export const remoteExitNodeSessions = pgTable("remoteExitNodeSession", { expiresAt: bigint("expiresAt", { mode: "number" }).notNull() }); -export const loginPage = pgTable("loginPage", { - loginPageId: serial("loginPageId").primaryKey(), - subdomain: varchar("subdomain"), - fullDomain: varchar("fullDomain"), - exitNodeId: integer("exitNodeId").references(() => exitNodes.exitNodeId, { - onDelete: "set null" - }), - domainId: varchar("domainId").references(() => domains.domainId, { - onDelete: "set null" - }) -}); +export const loginPage = pgTable( + "loginPage", + { + loginPageId: serial("loginPageId").primaryKey(), + subdomain: varchar("subdomain"), + fullDomain: varchar("fullDomain"), + exitNodeId: integer("exitNodeId").references( + () => exitNodes.exitNodeId, + { + onDelete: "set null" + } + ), + domainId: varchar("domainId").references(() => domains.domainId, { + onDelete: "set null" + }) + }, + (t) => [ + index("idx_loginpage_fulldomain") + .on(t.fullDomain) + .where(sql`${t.fullDomain} IS NOT NULL`) + ] +); export const loginPageOrg = pgTable("loginPageOrg", { loginPageId: integer("loginPageId") diff --git a/server/db/pg/schema/schema.ts b/server/db/pg/schema/schema.ts index 025bdf9239..1b48aa5203 100644 --- a/server/db/pg/schema/schema.ts +++ b/server/db/pg/schema/schema.ts @@ -1,5 +1,5 @@ import { randomUUID } from "crypto"; -import { InferSelectModel } from "drizzle-orm"; +import { InferSelectModel, sql } from "drizzle-orm"; import { bigint, boolean, @@ -82,107 +82,130 @@ export const orgDomains = pgTable("orgDomains", { .references(() => domains.domainId, { onDelete: "cascade" }) }); -export const sites = pgTable("sites", { - siteId: serial("siteId").primaryKey(), - orgId: varchar("orgId") - .references(() => orgs.orgId, { - onDelete: "cascade" - }) - .notNull(), - niceId: varchar("niceId").notNull(), - exitNodeId: integer("exitNode").references(() => exitNodes.exitNodeId, { - onDelete: "set null" - }), - name: varchar("name").notNull(), - pubKey: varchar("pubKey"), - subnet: varchar("subnet"), - megabytesIn: real("bytesIn").default(0), - megabytesOut: real("bytesOut").default(0), - lastBandwidthUpdate: varchar("lastBandwidthUpdate"), - type: varchar("type").notNull(), // "newt" or "wireguard" - online: boolean("online").notNull().default(false), - lastPing: integer("lastPing"), - address: varchar("address"), - endpoint: varchar("endpoint"), - publicKey: varchar("publicKey"), - lastHolePunch: bigint("lastHolePunch", { mode: "number" }), - listenPort: integer("listenPort"), - dockerSocketEnabled: boolean("dockerSocketEnabled").notNull().default(true), - autoUpdateEnabled: boolean("autoUpdateEnabled").notNull().default(false), - autoUpdateOverrideOrg: boolean("autoUpdateOverrideOrg") - .notNull() - .default(false), - status: varchar("status") - .$type<"pending" | "approved">() - .default("approved") -}); +export const sites = pgTable( + "sites", + { + siteId: serial("siteId").primaryKey(), + orgId: varchar("orgId") + .references(() => orgs.orgId, { + onDelete: "cascade" + }) + .notNull(), + niceId: varchar("niceId").notNull(), + exitNodeId: integer("exitNode").references(() => exitNodes.exitNodeId, { + onDelete: "set null" + }), + name: varchar("name").notNull(), + pubKey: varchar("pubKey"), + subnet: varchar("subnet"), + megabytesIn: real("bytesIn").default(0), + megabytesOut: real("bytesOut").default(0), + lastBandwidthUpdate: varchar("lastBandwidthUpdate"), + type: varchar("type").notNull(), // "newt" or "wireguard" + online: boolean("online").notNull().default(false), + lastPing: integer("lastPing"), + address: varchar("address"), + endpoint: varchar("endpoint"), + publicKey: varchar("publicKey"), + lastHolePunch: bigint("lastHolePunch", { mode: "number" }), + listenPort: integer("listenPort"), + dockerSocketEnabled: boolean("dockerSocketEnabled") + .notNull() + .default(true), + autoUpdateEnabled: boolean("autoUpdateEnabled") + .notNull() + .default(false), + autoUpdateOverrideOrg: boolean("autoUpdateOverrideOrg") + .notNull() + .default(false), + status: varchar("status") + .$type<"pending" | "approved">() + .default("approved") + }, + (t) => [ + index("idx_sites_exitnodeid").on(t.exitNodeId), + index("idx_sites_exitnode_type_siteid").on( + t.exitNodeId, + t.type, + t.siteId + ) + ] +); -export const resources = pgTable("resources", { - resourceId: serial("resourceId").primaryKey(), - resourcePolicyId: integer("resourcePolicyId").references( - () => resourcePolicies.resourcePolicyId, - { onDelete: "set null" } - ), - defaultResourcePolicyId: integer("defaultResourcePolicyId").references( - () => resourcePolicies.resourcePolicyId, - { - onDelete: "restrict" - } - ), - resourceGuid: varchar("resourceGuid", { length: 36 }) - .unique() - .notNull() - .$defaultFn(() => randomUUID()), - orgId: varchar("orgId") - .references(() => orgs.orgId, { - onDelete: "cascade" - }) - .notNull(), - niceId: text("niceId").notNull(), - name: varchar("name").notNull(), - subdomain: varchar("subdomain"), - fullDomain: varchar("fullDomain"), - domainId: varchar("domainId").references(() => domains.domainId, { - onDelete: "set null" - }), - ssl: boolean("ssl").notNull().default(false), - blockAccess: boolean("blockAccess").notNull().default(false), - proxyPort: integer("proxyPort"), - sso: boolean("sso"), - emailWhitelistEnabled: boolean("emailWhitelistEnabled"), - applyRules: boolean("applyRules"), - enabled: boolean("enabled").notNull().default(true), - stickySession: boolean("stickySession").notNull().default(false), - tlsServerName: varchar("tlsServerName"), - setHostHeader: varchar("setHostHeader"), - enableProxy: boolean("enableProxy").default(true), - skipToIdpId: integer("skipToIdpId").references(() => idp.idpId, { - onDelete: "set null" - }), - headers: text("headers"), // comma-separated list of headers to add to the request - proxyProtocol: boolean("proxyProtocol").notNull().default(false), - proxyProtocolVersion: integer("proxyProtocolVersion").default(1), - maintenanceModeEnabled: boolean("maintenanceModeEnabled") - .notNull() - .default(false), - maintenanceModeType: text("maintenanceModeType", { - enum: ["forced", "automatic"] - }).default("forced"), // "forced" = always show, "automatic" = only when down - maintenanceTitle: text("maintenanceTitle"), - maintenanceMessage: text("maintenanceMessage"), - maintenanceEstimatedTime: text("maintenanceEstimatedTime"), - postAuthPath: text("postAuthPath"), - health: varchar("health").default("unknown"), // "healthy", "unhealthy", "unknown" - wildcard: boolean("wildcard").notNull().default(false), - mode: text("mode").default("http").notNull(), // rdp, ssh, http, vnc - pamMode: varchar("pamMode", { length: 32 }) - .$type<"passthrough" | "push">() - .default("passthrough"), - authDaemonMode: varchar("authDaemonMode", { length: 32 }) - .$type<"site" | "remote" | "native">() - .default("site"), - authDaemonPort: integer("authDaemonPort").default(22123) -}); +export const resources = pgTable( + "resources", + { + resourceId: serial("resourceId").primaryKey(), + resourcePolicyId: integer("resourcePolicyId").references( + () => resourcePolicies.resourcePolicyId, + { onDelete: "set null" } + ), + defaultResourcePolicyId: integer("defaultResourcePolicyId").references( + () => resourcePolicies.resourcePolicyId, + { + onDelete: "restrict" + } + ), + resourceGuid: varchar("resourceGuid", { length: 36 }) + .unique() + .notNull() + .$defaultFn(() => randomUUID()), + orgId: varchar("orgId") + .references(() => orgs.orgId, { + onDelete: "cascade" + }) + .notNull(), + niceId: text("niceId").notNull(), + name: varchar("name").notNull(), + subdomain: varchar("subdomain"), + fullDomain: varchar("fullDomain"), + domainId: varchar("domainId").references(() => domains.domainId, { + onDelete: "set null" + }), + ssl: boolean("ssl").notNull().default(false), + blockAccess: boolean("blockAccess").notNull().default(false), + proxyPort: integer("proxyPort"), + sso: boolean("sso"), + emailWhitelistEnabled: boolean("emailWhitelistEnabled"), + applyRules: boolean("applyRules"), + enabled: boolean("enabled").notNull().default(true), + stickySession: boolean("stickySession").notNull().default(false), + tlsServerName: varchar("tlsServerName"), + setHostHeader: varchar("setHostHeader"), + enableProxy: boolean("enableProxy").default(true), + skipToIdpId: integer("skipToIdpId").references(() => idp.idpId, { + onDelete: "set null" + }), + headers: text("headers"), // comma-separated list of headers to add to the request + proxyProtocol: boolean("proxyProtocol").notNull().default(false), + proxyProtocolVersion: integer("proxyProtocolVersion").default(1), + maintenanceModeEnabled: boolean("maintenanceModeEnabled") + .notNull() + .default(false), + maintenanceModeType: text("maintenanceModeType", { + enum: ["forced", "automatic"] + }).default("forced"), // "forced" = always show, "automatic" = only when down + maintenanceTitle: text("maintenanceTitle"), + maintenanceMessage: text("maintenanceMessage"), + maintenanceEstimatedTime: text("maintenanceEstimatedTime"), + postAuthPath: text("postAuthPath"), + health: varchar("health").default("unknown"), // "healthy", "unhealthy", "unknown" + wildcard: boolean("wildcard").notNull().default(false), + mode: text("mode").default("http").notNull(), // rdp, ssh, http, vnc + pamMode: varchar("pamMode", { length: 32 }) + .$type<"passthrough" | "push">() + .default("passthrough"), + authDaemonMode: varchar("authDaemonMode", { length: 32 }) + .$type<"site" | "remote" | "native">() + .default("site"), + authDaemonPort: integer("authDaemonPort").default(22123) + }, + (t) => [ + index("idx_resources_fulldomain") + .on(t.fullDomain) + .where(sql`${t.fullDomain} IS NOT NULL`) + ] +); export const labels = pgTable("labels", { labelId: serial("labelId").primaryKey(), @@ -267,71 +290,84 @@ export const clientLabels = pgTable( (t) => [unique("client_label_uniq").on(t.clientId, t.labelId)] ); -export const targets = pgTable("targets", { - targetId: serial("targetId").primaryKey(), - resourceId: integer("resourceId") - .references(() => resources.resourceId, { - onDelete: "cascade" - }) - .notNull(), - siteId: integer("siteId") - .references(() => sites.siteId, { - onDelete: "cascade" - }) - .notNull(), - ip: varchar("ip").notNull(), - method: varchar("method"), - port: integer("port").notNull(), - internalPort: integer("internalPort"), - enabled: boolean("enabled").notNull().default(true), - path: text("path"), - pathMatchType: text("pathMatchType"), // exact, prefix, regex - rewritePath: text("rewritePath"), // if set, rewrites the path to this value before sending to the target - rewritePathType: text("rewritePathType"), // exact, prefix, regex, stripPrefix - priority: integer("priority").notNull().default(100), - mode: varchar("mode") - .$type<"http" | "tcp" | "udp" | "ssh" | "rdp" | "vnc">() - .notNull() - .default("http"), - authToken: varchar("authToken") -}); +export const targets = pgTable( + "targets", + { + targetId: serial("targetId").primaryKey(), + resourceId: integer("resourceId") + .references(() => resources.resourceId, { + onDelete: "cascade" + }) + .notNull(), + siteId: integer("siteId") + .references(() => sites.siteId, { + onDelete: "cascade" + }) + .notNull(), + ip: varchar("ip").notNull(), + method: varchar("method"), + port: integer("port").notNull(), + internalPort: integer("internalPort"), + enabled: boolean("enabled").notNull().default(true), + path: text("path"), + pathMatchType: text("pathMatchType"), // exact, prefix, regex + rewritePath: text("rewritePath"), // if set, rewrites the path to this value before sending to the target + rewritePathType: text("rewritePathType"), // exact, prefix, regex, stripPrefix + priority: integer("priority").notNull().default(100), + mode: varchar("mode") + .$type<"http" | "tcp" | "udp" | "ssh" | "rdp" | "vnc">() + .notNull() + .default("http"), + authToken: varchar("authToken") + }, + (t) => [ + index("idx_targets_resourceid_siteid").on(t.resourceId, t.siteId), + index("idx_targets_site_enabled_priority_target_resource") + .on(t.siteId, t.priority.desc(), t.targetId, t.resourceId) + .where(sql`${t.enabled} = true`) + ] +); -export const targetHealthCheck = pgTable("targetHealthCheck", { - targetHealthCheckId: serial("targetHealthCheckId").primaryKey(), - targetId: integer("targetId").references(() => targets.targetId, { - onDelete: "cascade" - }), - orgId: varchar("orgId") - .references(() => orgs.orgId, { - onDelete: "cascade" - }) - .notNull(), - siteId: integer("siteId") - .references(() => sites.siteId, { +export const targetHealthCheck = pgTable( + "targetHealthCheck", + { + targetHealthCheckId: serial("targetHealthCheckId").primaryKey(), + targetId: integer("targetId").references(() => targets.targetId, { onDelete: "cascade" - }) - .notNull(), - name: varchar("name"), - hcEnabled: boolean("hcEnabled").notNull().default(false), - hcPath: varchar("hcPath"), - hcScheme: varchar("hcScheme"), - hcMode: varchar("hcMode").default("http"), - hcHostname: varchar("hcHostname"), - hcPort: integer("hcPort"), - hcInterval: integer("hcInterval").default(30), // in seconds - hcUnhealthyInterval: integer("hcUnhealthyInterval").default(30), // in seconds - hcTimeout: integer("hcTimeout").default(5), // in seconds - hcHeaders: varchar("hcHeaders"), - hcFollowRedirects: boolean("hcFollowRedirects").default(true), - hcMethod: varchar("hcMethod").default("GET"), - hcStatus: integer("hcStatus"), // http code - hcHealth: text("hcHealth") - .$type<"unknown" | "healthy" | "unhealthy">() - .default("unknown"), // "unknown", "healthy", "unhealthy" - hcTlsServerName: text("hcTlsServerName"), - hcHealthyThreshold: integer("hcHealthyThreshold").default(1), - hcUnhealthyThreshold: integer("hcUnhealthyThreshold").default(1) -}); + }), + orgId: varchar("orgId") + .references(() => orgs.orgId, { + onDelete: "cascade" + }) + .notNull(), + siteId: integer("siteId") + .references(() => sites.siteId, { + onDelete: "cascade" + }) + .notNull(), + name: varchar("name"), + hcEnabled: boolean("hcEnabled").notNull().default(false), + hcPath: varchar("hcPath"), + hcScheme: varchar("hcScheme"), + hcMode: varchar("hcMode").default("http"), + hcHostname: varchar("hcHostname"), + hcPort: integer("hcPort"), + hcInterval: integer("hcInterval").default(30), // in seconds + hcUnhealthyInterval: integer("hcUnhealthyInterval").default(30), // in seconds + hcTimeout: integer("hcTimeout").default(5), // in seconds + hcHeaders: varchar("hcHeaders"), + hcFollowRedirects: boolean("hcFollowRedirects").default(true), + hcMethod: varchar("hcMethod").default("GET"), + hcStatus: integer("hcStatus"), // http code + hcHealth: text("hcHealth") + .$type<"unknown" | "healthy" | "unhealthy">() + .default("unknown"), // "unknown", "healthy", "unhealthy" + hcTlsServerName: text("hcTlsServerName"), + hcHealthyThreshold: integer("hcHealthyThreshold").default(1), + hcUnhealthyThreshold: integer("hcUnhealthyThreshold").default(1) + }, + (t) => [index("idx_targethealthcheck_targetid").on(t.targetId)] +); export const exitNodes = pgTable("exitNodes", { exitNodeId: serial("exitNodeId").primaryKey(), @@ -406,43 +442,74 @@ export const networks = pgTable("networks", { .notNull() }); -export const siteNetworks = pgTable("siteNetworks", { - siteId: integer("siteId") - .notNull() - .references(() => sites.siteId, { - onDelete: "cascade" - }), - networkId: integer("networkId") - .notNull() - .references(() => networks.networkId, { onDelete: "cascade" }) -}); +export const siteNetworks = pgTable( + "siteNetworks", + { + siteId: integer("siteId") + .notNull() + .references(() => sites.siteId, { + onDelete: "cascade" + }), + networkId: integer("networkId") + .notNull() + .references(() => networks.networkId, { onDelete: "cascade" }) + }, + (t) => [ + index("idx_sitenetworks_siteid").on(t.siteId), + index("idx_sitenetworks_networkid").on(t.networkId) + ] +); -export const clientSiteResources = pgTable("clientSiteResources", { - clientId: integer("clientId") - .notNull() - .references(() => clients.clientId, { onDelete: "cascade" }), - siteResourceId: integer("siteResourceId") - .notNull() - .references(() => siteResources.siteResourceId, { onDelete: "cascade" }) -}); +export const clientSiteResources = pgTable( + "clientSiteResources", + { + clientId: integer("clientId") + .notNull() + .references(() => clients.clientId, { onDelete: "cascade" }), + siteResourceId: integer("siteResourceId") + .notNull() + .references(() => siteResources.siteResourceId, { + onDelete: "cascade" + }) + }, + (t) => [ + index("idx_clientsiteresources_clientid").on(t.clientId), + index("idx_clientsiteresources_siteresourceid").on(t.siteResourceId) + ] +); -export const roleSiteResources = pgTable("roleSiteResources", { - roleId: integer("roleId") - .notNull() - .references(() => roles.roleId, { onDelete: "cascade" }), - siteResourceId: integer("siteResourceId") - .notNull() - .references(() => siteResources.siteResourceId, { onDelete: "cascade" }) -}); +export const roleSiteResources = pgTable( + "roleSiteResources", + { + roleId: integer("roleId") + .notNull() + .references(() => roles.roleId, { onDelete: "cascade" }), + siteResourceId: integer("siteResourceId") + .notNull() + .references(() => siteResources.siteResourceId, { + onDelete: "cascade" + }) + }, + (t) => [index("idx_rolesiteresources_siteresourceid").on(t.siteResourceId)] +); -export const userSiteResources = pgTable("userSiteResources", { - userId: varchar("userId") - .notNull() - .references(() => users.userId, { onDelete: "cascade" }), - siteResourceId: integer("siteResourceId") - .notNull() - .references(() => siteResources.siteResourceId, { onDelete: "cascade" }) -}); +export const userSiteResources = pgTable( + "userSiteResources", + { + userId: varchar("userId") + .notNull() + .references(() => users.userId, { onDelete: "cascade" }), + siteResourceId: integer("siteResourceId") + .notNull() + .references(() => siteResources.siteResourceId, { + onDelete: "cascade" + }) + }, + (t) => [ + index("idx_usersiteresources_userid").on(t.userId), + index("idx_usersiteresources_siteresourceid").on(t.siteResourceId) + ] +); export const users = pgTable("user", { userId: varchar("id").primaryKey(), @@ -467,15 +534,19 @@ export const users = pgTable("user", { locale: varchar("locale") }); -export const newts = pgTable("newt", { - newtId: varchar("id").primaryKey(), - secretHash: varchar("secretHash").notNull(), - dateCreated: varchar("dateCreated").notNull(), - version: varchar("version"), - siteId: integer("siteId").references(() => sites.siteId, { - onDelete: "cascade" - }) -}); +export const newts = pgTable( + "newt", + { + newtId: varchar("id").primaryKey(), + secretHash: varchar("secretHash").notNull(), + dateCreated: varchar("dateCreated").notNull(), + version: varchar("version"), + siteId: integer("siteId").references(() => sites.siteId, { + onDelete: "cascade" + }) + }, + (t) => [index("idx_newt_siteid").on(t.siteId)] +); export const twoFactorBackupCodes = pgTable("twoFactorBackupCodes", { codeId: serial("id").primaryKey(), @@ -576,29 +647,49 @@ export const userOrgRoles = pgTable( (t) => [unique().on(t.userId, t.orgId, t.roleId)] ); -export const roleActions = pgTable("roleActions", { - roleId: integer("roleId") - .notNull() - .references(() => roles.roleId, { onDelete: "cascade" }), - actionId: varchar("actionId") - .notNull() - .references(() => actions.actionId, { onDelete: "cascade" }), - orgId: varchar("orgId") - .notNull() - .references(() => orgs.orgId, { onDelete: "cascade" }) -}); +export const roleActions = pgTable( + "roleActions", + { + roleId: integer("roleId") + .notNull() + .references(() => roles.roleId, { onDelete: "cascade" }), + actionId: varchar("actionId") + .notNull() + .references(() => actions.actionId, { onDelete: "cascade" }), + orgId: varchar("orgId") + .notNull() + .references(() => orgs.orgId, { onDelete: "cascade" }) + }, + (t) => [ + index("idx_roleActions_roleId_orgId_actionId").on( + t.roleId, + t.orgId, + t.actionId + ) + ] +); -export const userActions = pgTable("userActions", { - userId: varchar("userId") - .notNull() - .references(() => users.userId, { onDelete: "cascade" }), - actionId: varchar("actionId") - .notNull() - .references(() => actions.actionId, { onDelete: "cascade" }), - orgId: varchar("orgId") - .notNull() - .references(() => orgs.orgId, { onDelete: "cascade" }) -}); +export const userActions = pgTable( + "userActions", + { + userId: varchar("userId") + .notNull() + .references(() => users.userId, { onDelete: "cascade" }), + actionId: varchar("actionId") + .notNull() + .references(() => actions.actionId, { onDelete: "cascade" }), + orgId: varchar("orgId") + .notNull() + .references(() => orgs.orgId, { onDelete: "cascade" }) + }, + (t) => [ + index("idx_userActions_userId_orgId_actionId").on( + t.userId, + t.orgId, + t.actionId + ) + ] +); export const roleSites = pgTable("roleSites", { roleId: integer("roleId") @@ -1004,40 +1095,44 @@ export const idpOrg = pgTable("idpOrg", { orgMapping: varchar("orgMapping") }); -export const clients = pgTable("clients", { - clientId: serial("clientId").primaryKey(), - orgId: varchar("orgId") - .references(() => orgs.orgId, { +export const clients = pgTable( + "clients", + { + clientId: serial("clientId").primaryKey(), + orgId: varchar("orgId") + .references(() => orgs.orgId, { + onDelete: "cascade" + }) + .notNull(), + exitNodeId: integer("exitNode").references(() => exitNodes.exitNodeId, { + onDelete: "set null" + }), + userId: text("userId").references(() => users.userId, { + // optionally tied to a user and in this case delete when the user deletes onDelete: "cascade" - }) - .notNull(), - exitNodeId: integer("exitNode").references(() => exitNodes.exitNodeId, { - onDelete: "set null" - }), - userId: text("userId").references(() => users.userId, { - // optionally tied to a user and in this case delete when the user deletes - onDelete: "cascade" - }), - niceId: varchar("niceId").notNull(), - olmId: text("olmId"), // to lock it to a specific olm optionally - name: varchar("name").notNull(), - pubKey: varchar("pubKey"), - subnet: varchar("subnet").notNull(), - megabytesIn: real("bytesIn"), - megabytesOut: real("bytesOut"), - lastBandwidthUpdate: varchar("lastBandwidthUpdate"), - lastPing: integer("lastPing"), - type: varchar("type").notNull(), // "olm" - online: boolean("online").notNull().default(false), - // endpoint: varchar("endpoint"), - lastHolePunch: integer("lastHolePunch"), - maxConnections: integer("maxConnections"), - archived: boolean("archived").notNull().default(false), - blocked: boolean("blocked").notNull().default(false), - approvalState: varchar("approvalState").$type< - "pending" | "approved" | "denied" - >() -}); + }), + niceId: varchar("niceId").notNull(), + olmId: text("olmId"), // to lock it to a specific olm optionally + name: varchar("name").notNull(), + pubKey: varchar("pubKey"), + subnet: varchar("subnet").notNull(), + megabytesIn: real("bytesIn"), + megabytesOut: real("bytesOut"), + lastBandwidthUpdate: varchar("lastBandwidthUpdate"), + lastPing: integer("lastPing"), + type: varchar("type").notNull(), // "olm" + online: boolean("online").notNull().default(false), + // endpoint: varchar("endpoint"), + lastHolePunch: integer("lastHolePunch"), + maxConnections: integer("maxConnections"), + archived: boolean("archived").notNull().default(false), + blocked: boolean("blocked").notNull().default(false), + approvalState: varchar("approvalState").$type< + "pending" | "approved" | "denied" + >() + }, + (t) => [index("idx_clients_userid").on(t.userId)] +); export const clientSitesAssociationsCache = pgTable( "clientSitesAssociationsCache", @@ -1049,7 +1144,11 @@ export const clientSitesAssociationsCache = pgTable( isJitMode: boolean("isJitMode").notNull().default(false), endpoint: varchar("endpoint"), publicKey: varchar("publicKey") // this will act as the session's public key for hole punching so we can track when it changes - } + }, + (t) => [ + primaryKey({ columns: [t.clientId, t.siteId] }), + index("idx_clientsitesassociationscache_siteid").on(t.siteId) + ] ); export const clientSiteResourcesAssociationsCache = pgTable( @@ -1058,7 +1157,14 @@ export const clientSiteResourcesAssociationsCache = pgTable( clientId: integer("clientId") // not a foreign key here so after its deleted the rebuild function can delete it and send the message .notNull(), siteResourceId: integer("siteResourceId").notNull() - } + }, + (t) => [ + primaryKey({ columns: [t.clientId, t.siteResourceId] }), + index("idx_clientSiteResourcesAssociationsCache_siteResourceId").on( + t.siteResourceId, + t.clientId + ) + ] ); export const clientPostureSnapshots = pgTable("clientPostureSnapshots", { @@ -1071,23 +1177,27 @@ export const clientPostureSnapshots = pgTable("clientPostureSnapshots", { collectedAt: integer("collectedAt").notNull() }); -export const olms = pgTable("olms", { - olmId: varchar("id").primaryKey(), - secretHash: varchar("secretHash").notNull(), - dateCreated: varchar("dateCreated").notNull(), - version: text("version"), - agent: text("agent"), - name: varchar("name"), - clientId: integer("clientId").references(() => clients.clientId, { - // we will switch this depending on the current org it wants to connect to - onDelete: "set null" - }), - userId: text("userId").references(() => users.userId, { - // optionally tied to a user and in this case delete when the user deletes - onDelete: "cascade" - }), - archived: boolean("archived").notNull().default(false) -}); +export const olms = pgTable( + "olms", + { + olmId: varchar("id").primaryKey(), + secretHash: varchar("secretHash").notNull(), + dateCreated: varchar("dateCreated").notNull(), + version: text("version"), + agent: text("agent"), + name: varchar("name"), + clientId: integer("clientId").references(() => clients.clientId, { + // we will switch this depending on the current org it wants to connect to + onDelete: "set null" + }), + userId: text("userId").references(() => users.userId, { + // optionally tied to a user and in this case delete when the user deletes + onDelete: "cascade" + }), + archived: boolean("archived").notNull().default(false) + }, + (t) => [index("idx_olms_clientid").on(t.clientId)] +); export const currentFingerprint = pgTable("currentFingerprint", { fingerprintId: serial("id").primaryKey(), From 6b56c00782fa9437c3da4491555d620f629239f3 Mon Sep 17 00:00:00 2001 From: Owen Date: Mon, 22 Jun 2026 15:24:31 -0400 Subject: [PATCH 24/88] Pull the listing out of the queue --- server/lib/rebuildClientAssociations.ts | 8 +------- .../routers/siteResource/updateSiteResource.ts | 16 ++++++++++++---- 2 files changed, 13 insertions(+), 11 deletions(-) diff --git a/server/lib/rebuildClientAssociations.ts b/server/lib/rebuildClientAssociations.ts index 8e235bc485..98c6c58f4a 100644 --- a/server/lib/rebuildClientAssociations.ts +++ b/server/lib/rebuildClientAssociations.ts @@ -162,13 +162,7 @@ export async function getClientSiteResourceAccess( export async function rebuildClientAssociationsFromSiteResource( siteResource: SiteResource, trx: Transaction | typeof db = db -): Promise<{ - mergedAllClients: { - clientId: number; - pubKey: string | null; - subnet: string | null; - }[]; -}> { +) { try { return await lockManager.withLock( `rebuild-client-associations:site-resource:${siteResource.siteResourceId}`, diff --git a/server/routers/siteResource/updateSiteResource.ts b/server/routers/siteResource/updateSiteResource.ts index db4d4445bd..3f271d2f97 100644 --- a/server/routers/siteResource/updateSiteResource.ts +++ b/server/routers/siteResource/updateSiteResource.ts @@ -28,7 +28,10 @@ import { isIpInCidr, portRangeStringSchema } from "@server/lib/ip"; -import { rebuildClientAssociationsFromSiteResource } from "@server/lib/rebuildClientAssociations"; +import { + getClientSiteResourceAccess, + rebuildClientAssociationsFromSiteResource +} from "@server/lib/rebuildClientAssociations"; import logger from "@server/logger"; import HttpCode from "@server/types/HttpCode"; import { NextFunction, Request, Response } from "express"; @@ -846,9 +849,14 @@ export async function handleMessagingForUpdatedSiteResource( updatedSiteResource ); - const { mergedAllClients } = - await rebuildClientAssociationsFromSiteResource( - existingSiteResource || updatedSiteResource, // we want to rebuild based on the existing resource then we will apply the change to the destination below + await rebuildClientAssociationsFromSiteResource( + existingSiteResource || updatedSiteResource, // we want to rebuild based on the existing resource then we will apply the change to the destination below + trx + ); + + const { sitesList, mergedAllClients, mergedAllClientIds } = + await getClientSiteResourceAccess( + existingSiteResource || updatedSiteResource, trx ); From c3820a4e7029d31454916973da02e77827dfbab4 Mon Sep 17 00:00:00 2001 From: Owen Date: Mon, 22 Jun 2026 16:47:52 -0400 Subject: [PATCH 25/88] Add missing queuing --- server/lib/rebuildClientAssociations.ts | 75 ++++++++++------- server/private/routers/ws/ws.ts | 104 ++++++++++++++++-------- 2 files changed, 118 insertions(+), 61 deletions(-) diff --git a/server/lib/rebuildClientAssociations.ts b/server/lib/rebuildClientAssociations.ts index 98c6c58f4a..7f271bbe5b 100644 --- a/server/lib/rebuildClientAssociations.ts +++ b/server/lib/rebuildClientAssociations.ts @@ -873,6 +873,20 @@ async function handleSubnetProxyTargetUpdates( ): Promise { const proxyJobs: Promise[] = []; const olmJobs: Promise[] = []; + const targetsToAddBatch: { + newtId: string; + targets: NonNullable< + Awaited> + >; + version: string | null; + }[] = []; + const targetsToRemoveBatch: { + newtId: string; + targets: NonNullable< + Awaited> + >; + version: string | null; + }[] = []; for (const siteData of sitesList) { const siteId = siteData.siteId; @@ -904,15 +918,11 @@ async function handleSubnetProxyTargetUpdates( ); if (targetsToAdd) { - proxyJobs.push( - addSubnetProxyTargetsBatch([ - { - newtId: newt.newtId, - targets: targetsToAdd, - version: newt.version - } - ]) - ); + targetsToAddBatch.push({ + newtId: newt.newtId, + targets: targetsToAdd, + version: newt.version + }); } olmJobs.push( @@ -945,15 +955,11 @@ async function handleSubnetProxyTargetUpdates( ); if (targetsToRemove) { - proxyJobs.push( - removeSubnetProxyTargetsBatch([ - { - newtId: newt.newtId, - targets: targetsToRemove, - version: newt.version - } - ]) - ); + targetsToRemoveBatch.push({ + newtId: newt.newtId, + targets: targetsToRemove, + version: newt.version + }); } const peerDataRemovals: { @@ -1025,7 +1031,15 @@ async function handleSubnetProxyTargetUpdates( } } - await Promise.all(proxyJobs); + if (targetsToAddBatch.length > 0) { + proxyJobs.push(addSubnetProxyTargetsBatch(targetsToAddBatch)); + } + + if (targetsToRemoveBatch.length > 0) { + proxyJobs.push(removeSubnetProxyTargetsBatch(targetsToRemoveBatch)); + } + + await Promise.all([...proxyJobs, ...olmJobs]); } export async function rebuildClientAssociationsFromClient( @@ -2048,19 +2062,20 @@ export async function cleanupSiteAssociations( // 7. Fire all removal messages in parallel. const jobs: Promise[] = []; + const olmPeerDeletes: { + clientId: number; + siteId: number; + publicKey: string; + }[] = []; for (const client of allClients) { // Tell each olm to drop the site's WireGuard peer. if (site.publicKey) { - jobs.push( - olmDeletePeersBatch([ - { - clientId: client.clientId, - siteId, - publicKey: site.publicKey - } - ]) - ); + olmPeerDeletes.push({ + clientId: client.clientId, + siteId, + publicKey: site.publicKey + }); } // Recompute and push updated relay destinations (now excluding this site). @@ -2069,6 +2084,10 @@ export async function cleanupSiteAssociations( } } + if (olmPeerDeletes.length > 0) { + jobs.push(olmDeletePeersBatch(olmPeerDeletes)); + } + await Promise.all(jobs).catch((error) => { logger.error( `cleanupSiteAssociations: error sending cleanup messages for siteId=${siteId}:`, diff --git a/server/private/routers/ws/ws.ts b/server/private/routers/ws/ws.ts index 8d222fd724..5e38c709ed 100644 --- a/server/private/routers/ws/ws.ts +++ b/server/private/routers/ws/ws.ts @@ -659,38 +659,52 @@ const broadcastToAllExceptLocal = async ( excludeClientId?: string, options: SendMessageOptions = {} ): Promise => { - for (const [mapKey, clients] of connectedClients.entries()) { - const [type, id] = mapKey.split(":"); - const clientId = mapKey; // mapKey is the clientId - if (!(excludeClientId && clientId === excludeClientId)) { - // Handle config version per client - let configVersion = await getClientConfigVersion(clientId); - if (options.incrementConfigVersion) { - configVersion = await incrementClientConfigVersion(clientId); - } + const sendPlans = await Promise.all( + Array.from(connectedClients.entries()).map( + async ([mapKey, clients]) => { + const clientId = mapKey; // mapKey is the clientId + if (excludeClientId && clientId === excludeClientId) { + return null; + } - // Add config version to message - const messageWithVersion = { - ...message, - configVersion - }; + let configVersion = await getClientConfigVersion(clientId); + if (options.incrementConfigVersion) { + configVersion = + await incrementClientConfigVersion(clientId); + } - if (options.compress) { - const compressed = zlib.gzipSync( - Buffer.from(JSON.stringify(messageWithVersion), "utf8") - ); - clients.forEach((client) => { - if (client.readyState === WebSocket.OPEN) { - client.send(compressed); + 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); + } + }); } } }; @@ -711,7 +725,12 @@ 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()) { @@ -745,15 +764,34 @@ const sendToClientsBatch = async ( } const remoteEntries: { targetClientId: string; message: WSMessage }[] = []; + const clientsWithIncrement = new Set( + entries + .filter((entry) => !!entry.options?.incrementConfigVersion) + .map((entry) => entry.clientId) + ); + const nonIncrementOnlyClientIds = Array.from( + new Set( + entries + .map((entry) => entry.clientId) + .filter((clientId) => !clientsWithIncrement.has(clientId)) + ) + ); + const stableConfigVersionByClient = new Map( + await Promise.all( + nonIncrementOnlyClientIds.map( + async (clientId) => + [clientId, await getClientConfigVersion(clientId)] as const + ) + ) + ); for (const entry of entries) { const options = entry.options || {}; const { clientId, message } = entry; - let configVersion = await getClientConfigVersion(clientId); - if (options.incrementConfigVersion) { - configVersion = await incrementClientConfigVersion(clientId); - } + const configVersion = options.incrementConfigVersion + ? await incrementClientConfigVersion(clientId) + : stableConfigVersionByClient.get(clientId); logger.debug( `sendToClientsBatch: Message type ${message.type} queued for clientId ${clientId} (new configVersion: ${configVersion})` From 75084028d79d461dae0354af120c7cb5751ce54d Mon Sep 17 00:00:00 2001 From: Fred KISSIE Date: Tue, 9 Jun 2026 19:29:00 +0200 Subject: [PATCH 26/88] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20Remove=20queries=20t?= =?UTF-8?q?hat=20prefetch=201000=20users/roles=20in=20private=20resources?= =?UTF-8?q?=20form?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/PrivateResourceForm.tsx | 25 +++---------------------- 1 file changed, 3 insertions(+), 22 deletions(-) diff --git a/src/components/PrivateResourceForm.tsx b/src/components/PrivateResourceForm.tsx index 1f7d56b18a..6082fd20eb 100644 --- a/src/components/PrivateResourceForm.tsx +++ b/src/components/PrivateResourceForm.tsx @@ -411,9 +411,9 @@ export function PrivateResourceForm({ type FormData = z.infer; - const rolesQuery = useQuery(orgQueries.roles({ orgId })); - const usersQuery = useQuery(orgQueries.users({ orgId })); - const clientsQuery = useQuery(orgQueries.machineClients({ orgId })); + const clientsQuery = useQuery( + orgQueries.machineClients({ orgId, perPage: 1 }) + ); const resourceRolesQuery = useQuery({ ...resourceQueries.siteResourceRoles({ siteResourceId: siteResourceId ?? 0 @@ -433,13 +433,6 @@ export function PrivateResourceForm({ enabled: siteResourceId != null }); - const allRoles = (rolesQuery.data ?? []) - .map((r) => ({ id: r.roleId.toString(), text: r.name })) - .filter((r) => r.text !== "Admin"); - const allUsers = (usersQuery.data ?? []).map((u) => ({ - id: u.id.toString(), - text: `${getUserDisplayName({ email: u.email, username: u.username })}${u.type !== UserType.Internal ? ` (${u.idpName})` : ""}` - })); const allClients = (clientsQuery.data ?? []) .filter((c) => !c.userId) .map((c) => ({ id: c.clientId.toString(), text: c.name })); @@ -478,8 +471,6 @@ export function PrivateResourceForm({ } const loadingRolesUsers = - rolesQuery.isLoading || - usersQuery.isLoading || clientsQuery.isLoading || (siteResourceId != null && (resourceRolesQuery.isLoading || @@ -488,16 +479,6 @@ export function PrivateResourceForm({ const hasMachineClients = allClients.length > 0; - const [activeRolesTagIndex, setActiveRolesTagIndex] = useState< - number | null - >(null); - const [activeUsersTagIndex, setActiveUsersTagIndex] = useState< - number | null - >(null); - const [activeClientsTagIndex, setActiveClientsTagIndex] = useState< - number | null - >(null); - const [sshServerMode, setSshServerMode] = useState<"standard" | "native">( () => { if (variant === "edit" && resource) { From 2cbc6fb128bac0b724827f4e04e9570fd7a6b36d Mon Sep 17 00:00:00 2001 From: Fred KISSIE Date: Wed, 10 Jun 2026 20:56:24 +0200 Subject: [PATCH 27/88] =?UTF-8?q?=F0=9F=8F=B7=EF=B8=8F=20types?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/lib/queries.ts | 28 ++++++++++++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/src/lib/queries.ts b/src/lib/queries.ts index 7d224c7b10..b8a50a9080 100644 --- a/src/lib/queries.ts +++ b/src/lib/queries.ts @@ -63,6 +63,34 @@ export type LatestVersionResponse = { latestVersion: string; releaseNotes: string; }; + newt: { + latestVersion: string; + releaseNotes: string; + }; + cli: { + latestVersion: string; + releaseNotes: string; + }; + "panglin-node": { + latestVersion: string; + releaseNotes: string; + }; + windows: { + latestVersion: string; + releaseNotes: string; + }; + android: { + latestVersion: string; + releaseNotes: string; + }; + mac: { + latestVersion: string; + releaseNotes: string; + }; + ios: { + latestVersion: string; + releaseNotes: string; + }; }; export const productUpdatesQueries = { From ffb6c64de038ecf0fba1fcf39e2085939fa104a9 Mon Sep 17 00:00:00 2001 From: Fred KISSIE Date: Wed, 10 Jun 2026 22:57:55 +0200 Subject: [PATCH 28/88] =?UTF-8?q?=F0=9F=92=84=20Show=20updates=20available?= =?UTF-8?q?=20in=20the=20frontend,=20on=20sites=20&=20user=20devices?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- messages/en-US.json | 1 + .../clients/user/[niceId]/general/page.tsx | 55 +++++++++++++++++-- src/components/SitesTable.tsx | 23 +++++--- src/components/UserDevicesTable.tsx | 48 ++++++++++++++-- 4 files changed, 110 insertions(+), 17 deletions(-) diff --git a/messages/en-US.json b/messages/en-US.json index 5937595b57..584a43d794 100644 --- a/messages/en-US.json +++ b/messages/en-US.json @@ -2967,6 +2967,7 @@ "orgOrDomainIdMissing": "Organization or Domain ID is missing", "loadingDNSRecords": "Loading DNS records...", "olmUpdateAvailableInfo": "An updated version of Olm is available. Please update to the latest version for the best experience.", + "updateAvailableInfo": "An updated version is available. Please update to the latest version for the best experience.", "client": "Client", "proxyProtocol": "Proxy Protocol Settings", "proxyProtocolDescription": "Configure Proxy Protocol to preserve client IP addresses for TCP services.", diff --git a/src/app/[orgId]/settings/clients/user/[niceId]/general/page.tsx b/src/app/[orgId]/settings/clients/user/[niceId]/general/page.tsx index cfdc5a9967..87c2d0dec6 100644 --- a/src/app/[orgId]/settings/clients/user/[niceId]/general/page.tsx +++ b/src/app/[orgId]/settings/clients/user/[niceId]/general/page.tsx @@ -41,6 +41,13 @@ import { useParams } from "next/navigation"; import { FaApple, FaWindows, FaLinux } from "react-icons/fa"; import { SiAndroid } from "react-icons/si"; import { tierMatrix } from "@server/lib/billing/tierMatrix"; +import { + productUpdatesQueries, + type LatestVersionResponse +} from "@app/lib/queries"; +import { useQuery } from "@tanstack/react-query"; +import semver from "semver"; +import { InfoPopup } from "@app/components/ui/info-popup"; function formatTimestamp(timestamp: number | null | undefined): string { if (!timestamp) return "-"; @@ -166,6 +173,34 @@ export default function GeneralPage() { }>(null); const [isCheckingCache, setIsCheckingCache] = useState(false); const [isRebuildingCache, setIsRebuildingCache] = useState(false); + const data = useQuery(productUpdatesQueries.latestVersion(true)); + const latestPlatformVersions = data.data?.data; + + const agentVersionMap: Record = { + "Pangolin Windows": "windows", + "Pangolin Android": "android", + "Pangolin iOS": "ios", + "Pangolin iPadOS": "ios", + "Pangolin macOS": "mac", + "Pangolin CLI": "cli", + "Olm CLI": "olm" + }; + + let updateAvailable = false; + if (client.agent && client.olmVersion && latestPlatformVersions) { + const agent = agentVersionMap[ + client.agent + ] as keyof LatestVersionResponse; + + if (agent in latestPlatformVersions) { + const agentVersion = latestPlatformVersions[agent]; + + updateAvailable = semver.lte( + client.olmVersion, + agentVersion.latestVersion + ); + } + } // get "imp" from local storage to determine if we should show the verify button (imp = "1" means show) const showVerifyButton = @@ -451,11 +486,21 @@ export default function GeneralPage() { {t("agent")} - - {client.agent + - " v" + - client.olmVersion} - +
+ + {client.agent + + " v" + + client.olmVersion} + + + {updateAvailable && ( + + )} +
diff --git a/src/components/SitesTable.tsx b/src/components/SitesTable.tsx index 8c3036c4a5..5b5ac1db14 100644 --- a/src/components/SitesTable.tsx +++ b/src/components/SitesTable.tsx @@ -55,6 +55,9 @@ import { usePaidStatus } from "@app/hooks/usePaidStatus"; import { tierMatrix } from "@server/lib/billing/tierMatrix"; import { LabelColumnFilterButton } from "./LabelColumnFilterButton"; import { LabelsTableCell } from "./LabelsTableCell"; +import { useQuery } from "@tanstack/react-query"; +import { productUpdatesQueries } from "@app/lib/queries"; +import semver from "semver"; export type SiteRow = { id: number; @@ -113,12 +116,11 @@ export default function SitesTable({ const api = createApiClient(useEnvContext()); const t = useTranslations(); - // useEffect(() => { - // const interval = setInterval(() => { - // router.refresh(); - // }, 30_000); - // return () => clearInterval(interval); - // }, []); + const { data: latestVersions } = useQuery( + productUpdatesQueries.latestVersion(true) + ); + + const latestNewtVersion = latestVersions?.data?.newt?.latestVersion; const booleanSearchFilterSchema = z .enum(["true", "false"]) @@ -333,6 +335,11 @@ export default function SitesTable({ cell: ({ row }) => { const originalRow = row.original; + let updateAvailable = + latestNewtVersion && + originalRow.newtVersion && + semver.lt(originalRow.newtVersion, latestNewtVersion); + if (originalRow.type === "newt") { return (
@@ -346,7 +353,7 @@ export default function SitesTable({ )}
- {originalRow.newtUpdateAvailable && ( + {updateAvailable && ( @@ -561,7 +568,7 @@ export default function SitesTable({ } return cols; - }, [isLabelFeatureEnabled, orgId, t, searchParams]); + }, [isLabelFeatureEnabled, orgId, t, searchParams, latestNewtVersion]); function toggleSort(column: string) { const newSearch = getNextSortOrder(column, searchParams); diff --git a/src/components/UserDevicesTable.tsx b/src/components/UserDevicesTable.tsx index 8ee2ddb876..17a82dfc95 100644 --- a/src/components/UserDevicesTable.tsx +++ b/src/components/UserDevicesTable.tsx @@ -38,6 +38,12 @@ import { ColumnFilterButton } from "./ColumnFilterButton"; import IdpTypeBadge from "./IdpTypeBadge"; import { Badge } from "./ui/badge"; import { ControlledDataTable } from "./ui/controlled-data-table"; +import { + productUpdatesQueries, + type LatestVersionResponse +} from "@app/lib/queries"; +import { useQuery } from "@tanstack/react-query"; +import semver from "semver"; export type ClientRow = { id: number; @@ -100,6 +106,9 @@ export default function UserDevicesTable({ searchParams } = useNavigationContext(); const [isRefreshing, startTransition] = useTransition(); + const data = useQuery(productUpdatesQueries.latestVersion(true)); + + const latestPlatformVersions = data.data?.data; const defaultUserColumnVisibility = { subnet: false, @@ -555,6 +564,37 @@ export default function UserDevicesTable({ cell: ({ row }) => { const originalRow = row.original; + const agentVersionMap: Record = { + "Pangolin Windows": "windows", + "Pangolin Android": "android", + "Pangolin iOS": "ios", + "Pangolin iPadOS": "ios", + "Pangolin macOS": "mac", + "Pangolin CLI": "cli", + "Olm CLI": "olm" + }; + + let updateAvailable = false; + + if ( + originalRow.olmVersion && + originalRow.agent && + latestPlatformVersions + ) { + const agent = agentVersionMap[ + originalRow.agent + ] as keyof LatestVersionResponse; + + if (agent in latestPlatformVersions) { + const agentVersion = latestPlatformVersions[agent]; + + updateAvailable = semver.lt( + originalRow.olmVersion, + agentVersion.latestVersion + ); + } + } + return (
{originalRow.agent && originalRow.olmVersion ? ( @@ -567,9 +607,9 @@ export default function UserDevicesTable({ "-" )} - {/*originalRow.olmUpdateAvailable && ( - - )*/} + {updateAvailable && ( + + )}
); } @@ -714,7 +754,7 @@ export default function UserDevicesTable({ } return allOptions; - }, [t]); + }, [t, latestPlatformVersions]); function handleFilterChange( column: string, From 5dc3ae4c7ff5420d1ea818ababa9566149ce4717 Mon Sep 17 00:00:00 2001 From: Fred KISSIE Date: Wed, 10 Jun 2026 22:58:42 +0200 Subject: [PATCH 29/88] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20sites=20&=20clients?= =?UTF-8?q?=20should=20not=20get=20latest=20versions=20on=20the=20server?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- server/routers/client/listUserDevices.ts | 25 ----- server/routers/site/listSites.ts | 126 ----------------------- 2 files changed, 151 deletions(-) diff --git a/server/routers/client/listUserDevices.ts b/server/routers/client/listUserDevices.ts index 5a864f93b9..e2a0359298 100644 --- a/server/routers/client/listUserDevices.ts +++ b/server/routers/client/listUserDevices.ts @@ -420,31 +420,6 @@ export async function listUserDevices( } ); - // REMOVING THIS BECAUSE WE HAVE DIFFERENT TYPES OF CLIENTS NOW - // // Try to get the latest version, but don't block if it fails - // try { - // const latestOlmVersion = await getLatestOlmVersion(); - - // if (latestOlmVersion) { - // olmsWithUpdates.forEach((client) => { - // try { - // client.olmUpdateAvailable = semver.lt( - // client.olmVersion ? client.olmVersion : "", - // latestOlmVersion - // ); - // } catch (error) { - // client.olmUpdateAvailable = false; - // } - // }); - // } - // } catch (error) { - // // Log the error but don't let it block the response - // logger.warn( - // "Failed to check for OLM updates, continuing without update info:", - // error - // ); - // } - return response(res, { data: { devices: olmsWithUpdates, diff --git a/server/routers/site/listSites.ts b/server/routers/site/listSites.ts index 6bb0950302..c9635b3467 100644 --- a/server/routers/site/listSites.ts +++ b/server/routers/site/listSites.ts @@ -29,97 +29,6 @@ import { fromError } from "zod-validation-error"; import { isLicensedOrSubscribed } from "#dynamic/lib/isLicencedOrSubscribed"; import { tierMatrix } from "@server/lib/billing/tierMatrix"; -// Stale-while-revalidate: keeps the last successfully fetched version so that -// a transient network failure / timeout does not flip every site back to -// newtUpdateAvailable: false. -let staleNewtVersion: string | null = null; - -async function getLatestNewtVersion(): Promise { - try { - const cachedVersion = await cache.get( - "cache:latestNewtVersion" - ); - if (cachedVersion) { - return cachedVersion; - } - - const controller = new AbortController(); - const timeoutId = setTimeout(() => controller.abort(), 1500); - - const response = await fetch( - "https://api.github.com/repos/fosrl/newt/tags", - { - signal: controller.signal - } - ); - - clearTimeout(timeoutId); - - if (!response.ok) { - logger.warn( - `Failed to fetch latest Newt version from GitHub: ${response.status} ${response.statusText}` - ); - return staleNewtVersion; - } - - let tags = await response.json(); - if (!Array.isArray(tags) || tags.length === 0) { - logger.warn("No tags found for Newt repository"); - return staleNewtVersion; - } - - // Remove release-candidates, then sort descending by semver so that - // duplicate tags (e.g. "1.10.3" and "v1.10.3") and any ordering quirks - // from the GitHub API do not cause an older tag to be selected. - tags = tags.filter((tag: any) => !tag.name.includes("rc")); - tags.sort((a: any, b: any) => { - const va = semver.coerce(a.name); - const vb = semver.coerce(b.name); - if (!va && !vb) return 0; - if (!va) return 1; - if (!vb) return -1; - return semver.rcompare(va, vb); - }); - - // Deduplicate: keep only the first (highest) entry per normalised version - const seen = new Set(); - tags = tags.filter((tag: any) => { - const normalised = semver.coerce(tag.name)?.version; - if (!normalised || seen.has(normalised)) return false; - seen.add(normalised); - return true; - }); - - if (tags.length === 0) { - logger.warn("No valid semver tags found for Newt repository"); - return staleNewtVersion; - } - - const latestVersion = tags[0].name; - - staleNewtVersion = latestVersion; - await cache.set("cache:latestNewtVersion", latestVersion, 3600); - - return latestVersion; - } catch (error: any) { - if (error.name === "AbortError") { - logger.warn( - "Request to fetch latest Newt version timed out (1.5s)" - ); - } else if (error.cause?.code === "UND_ERR_CONNECT_TIMEOUT") { - logger.warn( - "Connection timeout while fetching latest Newt version" - ); - } else { - logger.warn( - "Error fetching latest Newt version:", - error.message || error - ); - } - return staleNewtVersion; - } -} - const listSitesParamsSchema = z.strictObject({ orgId: z.string() }); @@ -447,11 +356,6 @@ export async function listSites( siteListQuery ]); - const totalCount = Number(countRows[0]?.count ?? 0); - - // Get latest version asynchronously without blocking the response - const latestNewtVersionPromise = getLatestNewtVersion(); - const siteIds = rows.map((site) => site.siteId); let labelsForSites: Array<{ @@ -494,36 +398,6 @@ export async function listSites( return { ...siteWithUpdate, labels: labelsForSite }; }); - // Try to get the latest version, but don't block if it fails - try { - const latestNewtVersion = await latestNewtVersionPromise; - - if (latestNewtVersion) { - sitesWithUpdates.forEach((site) => { - if ( - site.type === "newt" && - site.newtVersion && - latestNewtVersion - ) { - try { - site.newtUpdateAvailable = semver.lt( - site.newtVersion, - latestNewtVersion - ); - } catch (error) { - site.newtUpdateAvailable = false; - } - } - }); - } - } catch (error) { - // Log the error but don't let it block the response - logger.warn( - "Failed to check for Newt updates, continuing without update info:", - error - ); - } - const sitesPayload = sitesWithUpdates.map((site) => site.type === "local" ? { ...site, online: undefined } : site ); From 3f2bb422213eb7119428759e1327dd5cfaa941ac Mon Sep 17 00:00:00 2001 From: Fred KISSIE Date: Thu, 11 Jun 2026 19:55:48 +0200 Subject: [PATCH 30/88] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20`lt`=20instead=20of?= =?UTF-8?q?=20`lte`?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app/[orgId]/settings/clients/user/[niceId]/general/page.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/app/[orgId]/settings/clients/user/[niceId]/general/page.tsx b/src/app/[orgId]/settings/clients/user/[niceId]/general/page.tsx index 87c2d0dec6..8573e81996 100644 --- a/src/app/[orgId]/settings/clients/user/[niceId]/general/page.tsx +++ b/src/app/[orgId]/settings/clients/user/[niceId]/general/page.tsx @@ -195,7 +195,7 @@ export default function GeneralPage() { if (agent in latestPlatformVersions) { const agentVersion = latestPlatformVersions[agent]; - updateAvailable = semver.lte( + updateAvailable = semver.lt( client.olmVersion, agentVersion.latestVersion ); From be888c3fc1a9c329a88198d6a262a66468c836ee Mon Sep 17 00:00:00 2001 From: Fred KISSIE Date: Thu, 11 Jun 2026 20:58:23 +0200 Subject: [PATCH 31/88] =?UTF-8?q?=F0=9F=92=84=20Show=20the=20latest=20new?= =?UTF-8?q?=20update=20in=20machine=20client=20table?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/MachineClientsTable.tsx | 55 +++++++++++++++++++++----- 1 file changed, 46 insertions(+), 9 deletions(-) diff --git a/src/components/MachineClientsTable.tsx b/src/components/MachineClientsTable.tsx index eba0c97624..3094348e30 100644 --- a/src/components/MachineClientsTable.tsx +++ b/src/components/MachineClientsTable.tsx @@ -11,10 +11,10 @@ import { } from "@app/components/ui/dropdown-menu"; import { useEnvContext } from "@app/hooks/useEnvContext"; import { useNavigationContext } from "@app/hooks/useNavigationContext"; +import { useOptimisticLabels } from "@app/hooks/useOptimisticLabels"; import { usePaidStatus } from "@app/hooks/usePaidStatus"; import { toast } from "@app/hooks/useToast"; import { createApiClient, formatAxiosError } from "@app/lib/api"; -import { cn } from "@app/lib/cn"; import { getNextSortOrder, getSortDirection } from "@app/lib/sortColumn"; import { tierMatrix } from "@server/lib/billing/tierMatrix"; import type { PaginationState } from "@tanstack/react-table"; @@ -31,15 +31,18 @@ import Link from "next/link"; import { useRouter } from "next/navigation"; import { startTransition, useMemo, useState, useTransition } from "react"; import { useDebouncedCallback } from "use-debounce"; -import z from "zod"; import { ColumnFilterButton } from "./ColumnFilterButton"; -import { type SelectedLabel } from "./labels-selector"; +import { LabelColumnFilterButton } from "./LabelColumnFilterButton"; import { LabelsTableCell } from "./LabelsTableCell"; import { Badge } from "./ui/badge"; import { ControlledDataTable } from "./ui/controlled-data-table"; -import { LabelColumnFilterButton } from "./LabelColumnFilterButton"; -import { useLocalLabels } from "@app/hooks/useLocalLabels"; -import { useOptimisticLabels } from "@app/hooks/useOptimisticLabels"; +import { + productUpdatesQueries, + type LatestVersionResponse +} from "@app/lib/queries"; +import { useQuery } from "@tanstack/react-query"; +import semver from "semver"; +import { InfoPopup } from "./ui/info-popup"; export type ClientRow = { id: number; @@ -101,6 +104,9 @@ export default function MachineClientsTable({ const { isPaidUser } = usePaidStatus(); const isLabelFeatureEnabled = isPaidUser(tierMatrix.labels); + const data = useQuery(productUpdatesQueries.latestVersion(true)); + + const latestPlatformVersions = data.data?.data; const defaultMachineColumnVisibility = { subnet: false, @@ -375,6 +381,37 @@ export default function MachineClientsTable({ cell: ({ row }) => { const originalRow = row.original; + const agentVersionMap: Record = { + "Pangolin Windows": "windows", + "Pangolin Android": "android", + "Pangolin iOS": "ios", + "Pangolin iPadOS": "ios", + "Pangolin macOS": "mac", + "Pangolin CLI": "cli", + "Olm CLI": "olm" + }; + + let updateAvailable = false; + + if ( + originalRow.olmVersion && + originalRow.agent && + latestPlatformVersions + ) { + const agent = agentVersionMap[ + originalRow.agent + ] as keyof LatestVersionResponse; + + if (agent in latestPlatformVersions) { + const agentVersion = latestPlatformVersions[agent]; + + updateAvailable = semver.lt( + originalRow.olmVersion, + agentVersion.latestVersion + ); + } + } + return (
{originalRow.agent && originalRow.olmVersion ? ( @@ -386,9 +423,9 @@ export default function MachineClientsTable({ ) : ( "-" )} - {/*originalRow.olmUpdateAvailable && ( - - )*/} + {updateAvailable && ( + + )}
); } From 37eaf34e4d3ca21fc6c8f7037529eae1e483c429 Mon Sep 17 00:00:00 2001 From: Owen Schwartz Date: Mon, 22 Jun 2026 15:22:20 -0700 Subject: [PATCH 32/88] New translations en-us.json (French) [ci skip] --- messages/fr-FR.json | 1 + 1 file changed, 1 insertion(+) diff --git a/messages/fr-FR.json b/messages/fr-FR.json index c2abdbe02d..78ed0faa86 100644 --- a/messages/fr-FR.json +++ b/messages/fr-FR.json @@ -2967,6 +2967,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": "An updated version is available. Please update to the latest version for the best experience.", "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.", From d8acccbde44ebb8ba27b946254bf837698187efd Mon Sep 17 00:00:00 2001 From: Owen Schwartz Date: Mon, 22 Jun 2026 15:22:22 -0700 Subject: [PATCH 33/88] New translations en-us.json (Spanish) [ci skip] --- messages/es-ES.json | 1 + 1 file changed, 1 insertion(+) diff --git a/messages/es-ES.json b/messages/es-ES.json index 19a6ab6e13..ec22f9b0e5 100644 --- a/messages/es-ES.json +++ b/messages/es-ES.json @@ -2967,6 +2967,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": "An updated version is available. Please update to the latest version for the best experience.", "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.", From 18ec6c8d926fde0e6f42b06a01753b3eb9d6c808 Mon Sep 17 00:00:00 2001 From: Owen Schwartz Date: Mon, 22 Jun 2026 15:22:24 -0700 Subject: [PATCH 34/88] New translations en-us.json (Bulgarian) [ci skip] --- messages/bg-BG.json | 1 + 1 file changed, 1 insertion(+) diff --git a/messages/bg-BG.json b/messages/bg-BG.json index af2c2eca3c..2e0aea8ca8 100644 --- a/messages/bg-BG.json +++ b/messages/bg-BG.json @@ -2967,6 +2967,7 @@ "orgOrDomainIdMissing": "Липсва идентификатор на организация или домейн", "loadingDNSRecords": "Зареждане на DNS записи...", "olmUpdateAvailableInfo": "Налична е актуализирана версия на Olm. Моля, актуализирайте до най-новата версия за най-добро преживяване.", + "updateAvailableInfo": "An updated version is available. Please update to the latest version for the best experience.", "client": "Клиент", "proxyProtocol": "Настройки на прокси протокол", "proxyProtocolDescription": "Конфигурирайте Proxy Protocol, за да запазите IP адресите на клиентите за TCP услуги.", From 63c3ee623bdba5b3f470c5985eb2b591e1b9862c Mon Sep 17 00:00:00 2001 From: Owen Schwartz Date: Mon, 22 Jun 2026 15:22:26 -0700 Subject: [PATCH 35/88] New translations en-us.json (Czech) [ci skip] --- messages/cs-CZ.json | 1 + 1 file changed, 1 insertion(+) diff --git a/messages/cs-CZ.json b/messages/cs-CZ.json index 581f37762b..d819279778 100644 --- a/messages/cs-CZ.json +++ b/messages/cs-CZ.json @@ -2967,6 +2967,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": "An updated version is available. Please update to the latest version for the best experience.", "client": "Zákazník", "proxyProtocol": "Nastavení proxy protokolu", "proxyProtocolDescription": "Konfigurace Proxy protokolu pro zachování klientských IP adres pro služby TCP.", From b07fe6d18b8048401097fc25c1a19514a0cb8a84 Mon Sep 17 00:00:00 2001 From: Owen Schwartz Date: Mon, 22 Jun 2026 15:22:28 -0700 Subject: [PATCH 36/88] New translations en-us.json (German) [ci skip] --- messages/de-DE.json | 1 + 1 file changed, 1 insertion(+) diff --git a/messages/de-DE.json b/messages/de-DE.json index 87afc5d684..4e4ac91614 100644 --- a/messages/de-DE.json +++ b/messages/de-DE.json @@ -2967,6 +2967,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": "An updated version is available. Please update to the latest version for the best experience.", "client": "Client", "proxyProtocol": "Proxy-Protokoll-Einstellungen", "proxyProtocolDescription": "Konfigurieren Sie das Proxy-Protokoll, um die IP-Adressen des Clients für TCP-Dienste zu erhalten.", From 1262030abb278f79a6b3c978c006790155825f37 Mon Sep 17 00:00:00 2001 From: Owen Schwartz Date: Mon, 22 Jun 2026 15:22:30 -0700 Subject: [PATCH 37/88] New translations en-us.json (Italian) [ci skip] --- messages/it-IT.json | 1 + 1 file changed, 1 insertion(+) diff --git a/messages/it-IT.json b/messages/it-IT.json index 7623e8f644..2a6a65ac38 100644 --- a/messages/it-IT.json +++ b/messages/it-IT.json @@ -2967,6 +2967,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": "An updated version is available. Please update to the latest version for the best experience.", "client": "Client", "proxyProtocol": "Impostazioni Protocollo Proxy", "proxyProtocolDescription": "Configurare il protocollo proxy per preservare gli indirizzi IP client per i servizi TCP.", From 228efacfe0ad43b20d9acd340a2ce71aa1f945a7 Mon Sep 17 00:00:00 2001 From: Owen Schwartz Date: Mon, 22 Jun 2026 15:22:32 -0700 Subject: [PATCH 38/88] New translations en-us.json (Korean) [ci skip] --- messages/ko-KR.json | 1 + 1 file changed, 1 insertion(+) diff --git a/messages/ko-KR.json b/messages/ko-KR.json index f3829dd16c..d57050693d 100644 --- a/messages/ko-KR.json +++ b/messages/ko-KR.json @@ -2967,6 +2967,7 @@ "orgOrDomainIdMissing": "조직 ID 또는 도메인 ID가 누락되었습니다", "loadingDNSRecords": "DNS 레코드를 로드하는 중...", "olmUpdateAvailableInfo": "올름의 새 버전이 이용 가능합니다. 최상의 경험을 위해 최신 버전으로 업데이트하세요.", + "updateAvailableInfo": "An updated version is available. Please update to the latest version for the best experience.", "client": "클라이언트", "proxyProtocol": "프록시 프로토콜 설정", "proxyProtocolDescription": "TCP 서비스에 대한 클라이언트 IP 주소를 유지하도록 프록시 프로토콜을 구성하세요.", From c58968536d1626f3e067e283f8053ebb90c8e295 Mon Sep 17 00:00:00 2001 From: Owen Schwartz Date: Mon, 22 Jun 2026 15:22:34 -0700 Subject: [PATCH 39/88] New translations en-us.json (Dutch) [ci skip] --- messages/nl-NL.json | 1 + 1 file changed, 1 insertion(+) diff --git a/messages/nl-NL.json b/messages/nl-NL.json index 642423454a..bdb22ef52e 100644 --- a/messages/nl-NL.json +++ b/messages/nl-NL.json @@ -2967,6 +2967,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": "An updated version is available. Please update to the latest version for the best experience.", "client": "Klant", "proxyProtocol": "Proxy Protocol Instellingen", "proxyProtocolDescription": "Proxyprotocol configureren om de IP-adressen van de client voor TCP-diensten te bewaren.", From 74ef844e27715029c19109bdf273408edde4e95a Mon Sep 17 00:00:00 2001 From: Owen Schwartz Date: Mon, 22 Jun 2026 15:22:36 -0700 Subject: [PATCH 40/88] New translations en-us.json (Polish) [ci skip] --- messages/pl-PL.json | 1 + 1 file changed, 1 insertion(+) diff --git a/messages/pl-PL.json b/messages/pl-PL.json index 3de2fe1658..1d553fa3bf 100644 --- a/messages/pl-PL.json +++ b/messages/pl-PL.json @@ -2967,6 +2967,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": "An updated version is available. Please update to the latest version for the best experience.", "client": "Klient", "proxyProtocol": "Ustawienia protokołu proxy", "proxyProtocolDescription": "Skonfiguruj protokół Proxy aby zachować adresy IP klienta dla usług TCP.", From 73eb07de7111cc2fc72064a25cfa62e0f0f54f4d Mon Sep 17 00:00:00 2001 From: Owen Schwartz Date: Mon, 22 Jun 2026 15:22:38 -0700 Subject: [PATCH 41/88] New translations en-us.json (Portuguese) [ci skip] --- messages/pt-PT.json | 1 + 1 file changed, 1 insertion(+) diff --git a/messages/pt-PT.json b/messages/pt-PT.json index 78105f4036..345a21b246 100644 --- a/messages/pt-PT.json +++ b/messages/pt-PT.json @@ -2967,6 +2967,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": "An updated version is available. Please update to the latest version for the best experience.", "client": "Cliente", "proxyProtocol": "Configurações de Protocolo Proxy", "proxyProtocolDescription": "Configurar o protocolo proxy para preservar endereços IP do cliente para serviços TCP.", From 096940a1529b1758f1c787cf1a6ec1ad1568913c Mon Sep 17 00:00:00 2001 From: Owen Schwartz Date: Mon, 22 Jun 2026 15:22:40 -0700 Subject: [PATCH 42/88] New translations en-us.json (Russian) [ci skip] --- messages/ru-RU.json | 1 + 1 file changed, 1 insertion(+) diff --git a/messages/ru-RU.json b/messages/ru-RU.json index cacc4b895b..851f8153c5 100644 --- a/messages/ru-RU.json +++ b/messages/ru-RU.json @@ -2967,6 +2967,7 @@ "orgOrDomainIdMissing": "Отсутствует организация или ID домена", "loadingDNSRecords": "Загрузка записей DNS...", "olmUpdateAvailableInfo": "Доступна обновленная версия Олма. Пожалуйста, обновитесь до последней версии.", + "updateAvailableInfo": "An updated version is available. Please update to the latest version for the best experience.", "client": "Клиент", "proxyProtocol": "Настройки протокола прокси", "proxyProtocolDescription": "Настроить Прокси-протокол для сохранения IP-адресов клиента для служб TCP.", From b2778a2c4978eb38c368c642e37254fb3d2ab0cf Mon Sep 17 00:00:00 2001 From: Owen Schwartz Date: Mon, 22 Jun 2026 15:22:42 -0700 Subject: [PATCH 43/88] New translations en-us.json (Turkish) [ci skip] --- messages/tr-TR.json | 1 + 1 file changed, 1 insertion(+) diff --git a/messages/tr-TR.json b/messages/tr-TR.json index d5fd66262f..f04b80159d 100644 --- a/messages/tr-TR.json +++ b/messages/tr-TR.json @@ -2967,6 +2967,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": "An updated version is available. Please update to the latest version for the best experience.", "client": "İstemci", "proxyProtocol": "Proxy Protokol Ayarları", "proxyProtocolDescription": "TCP hizmetleri için istemci IP adreslerini korumak amacıyla Proxy Protokolünü yapılandırın.", From f7050ef9891e7a77a8b210bc472a2190e0a52a7c Mon Sep 17 00:00:00 2001 From: Owen Schwartz Date: Mon, 22 Jun 2026 15:22:44 -0700 Subject: [PATCH 44/88] New translations en-us.json (Chinese Simplified) [ci skip] --- messages/zh-CN.json | 119 ++++++++++++++++++++++---------------------- 1 file changed, 60 insertions(+), 59 deletions(-) diff --git a/messages/zh-CN.json b/messages/zh-CN.json index e25c82e52b..906cfc96e8 100644 --- a/messages/zh-CN.json +++ b/messages/zh-CN.json @@ -17,7 +17,7 @@ "componentsErrorNoMemberCreate": "您目前不是任何组织的成员。创建组织以开始操作。", "componentsErrorNoMember": "您目前不是任何组织的成员。", "welcome": "欢迎使用 Pangolin", - "welcomeTo": "欢迎来到", + "welcomeTo": "欢迎使用", "componentsCreateOrg": "创建组织", "componentsMember": "您属于{count, plural, =0 {没有组织} one {一个组织} other {# 个组织}}。", "componentsInvalidKey": "检测到无效或过期的许可证密钥。按照许可证条款操作以继续使用所有功能。", @@ -35,7 +35,7 @@ "trialDaysRemaining": "{count, plural, other {# 天剩余}}", "trialDaysLeftShort": "试用期剩余 {days} 天", "trialGoToBilling": "转到账单页面", - "subscriptionViolationViewBilling": "查看计费", + "subscriptionViolationViewBilling": "查看账单", "componentsLicenseViolation": "许可证超限:该服务器使用了 {usedSites} 个站点,已超过授权的 {maxSites} 个。请遵守许可证条款以继续使用全部功能。", "componentsSupporterMessage": "感谢您的支持!您现在是 Pangolin 的 {tier} 用户。", "inviteErrorNotValid": "很抱歉,但看起来你试图访问的邀请尚未被接受或不再有效。", @@ -58,21 +58,21 @@ "name": "名称", "online": "在线", "offline": "离线的", - "site": "站点", + "site": "节点", "dataIn": "数据输入", "dataOut": "数据输出", "connectionType": "连接类型", "tunnelType": "隧道类型", "local": "本地的", "edit": "编辑", - "siteConfirmDelete": "确认删除站点", - "siteDelete": "删除站点", - "siteMessageRemove": "一旦移除,站点将无法访问。与站点相关的所有目标也将被移除。", - "siteQuestionRemove": "您确定要从组织中删除该站点吗?", + "siteConfirmDelete": "确认删除节点", + "siteDelete": "删除节点", + "siteMessageRemove": "一旦移除,节点将无法访问。与节点相关的所有目标也将被移除。", + "siteQuestionRemove": "您确定要从组织中删除该节点吗?", "siteManageSites": "管理站点", "siteDescription": "创建和管理站点,启用与私人网络的连接", "sitesBannerTitle": "连接任何网络", - "sitesBannerDescription": "站点是连接到远程网络的链接,允许Pangolin为用户提供资源访问,无论是公共还是私人。可以在任何可以运行二进制文件或容器的地方安装站点网络连接器(Newt)以建立连接。", + "sitesBannerDescription": "站点是到远程网络的连接,使 Pangolin 能够向任何位置的用户提提供公共或私有的资源访问。你可以在任何能够运行二进制文件或容器的地方安装站点网络连接器(Newt),以建立连接。", "sitesBannerButtonText": "安装站点", "approvalsBannerTitle": "批准或拒绝设备访问", "approvalsBannerDescription": "审核、批准或拒绝用户的设备访问请求。 当需要设备批准时,用户必须先获得管理员批准,然后他们的设备才能连接到您的组织资源。", @@ -134,7 +134,7 @@ "siteResourcesHowToAccess": "如何访问", "siteResourcesTargetsOnSite": "此站点上的目标", "siteSetting": "{siteName} 设置", - "siteNewtTunnel": "新站点 (推荐)", + "siteNewtTunnel": "新节点 (推荐)", "siteNewtTunnelDescription": "最简单的方式来创建任何网络的入口。没有额外的设置。", "siteWg": "基本 WireGuard", "siteWgDescription": "使用任何 WireGuard 客户端来建立隧道。需要手动配置 NAT。", @@ -143,23 +143,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 +204,11 @@ "proxyResourceTitle": "管理公共资源", "proxyResourceDescription": "创建和管理可通过 Web 浏览器公开访问的资源", "publicResourcesBannerTitle": "基于 Web 的公共访问", - "publicResourcesBannerDescription": "公共资源是 HTTPS 代理,可以通过网络浏览器在互联网上的任何人访问。与私人资源不同,它们不需要客户端软件,并且可以包含身份和上下文感知的访问策略。", + "publicResourcesBannerDescription": "公共资源是 HTTPS 代理,可供互联网上的任何人通过 Web 浏览器访问。与私人资源不同,它们不需要客户端软件,并且可以包含身份和上下文感知的访问策略。", "clientResourceTitle": "管理私有资源", "clientResourceDescription": "创建和管理只能通过连接客户端访问的资源", - "privateResourcesBannerTitle": "零信任的私人访问", - "privateResourcesBannerDescription": "私人资源使用零信任安全性,确保只允许明确授予的用户和机器访问资源。可以连接用户设备或机器客户端,通过安全的虚拟专用网络访问这些资源。", + "privateResourcesBannerTitle": "零信任私有访问", + "privateResourcesBannerDescription": "私有资源采用零信任安全机制,确保只有获得明确授权的用户和机器才能访问。用户设备或机器客户端连接后,即可通过安全的虚拟专用网络访问这些资源。", "resourcesSearch": "搜索资源...", "resourceAdd": "添加资源", "resourceErrorDelte": "删除资源时出错", @@ -327,7 +327,7 @@ "passToAuth": "传递至认证", "orgSettingsDescription": "配置组织设置", "orgGeneralSettings": "组织设置", - "orgGeneralSettingsDescription": "管理机构的详细信息和配置", + "orgGeneralSettingsDescription": "管理组织的详细信息和配置", "saveGeneralSettings": "保存常规设置", "saveSettings": "保存设置", "orgDangerZone": "危险区域", @@ -381,7 +381,7 @@ "accessApprovalsDescription": "查看和管理待审批的组织访问权限", "description": "描述", "inviteTitle": "打开邀请", - "inviteDescription": "管理其他用户加入机构的邀请", + "inviteDescription": "管理其他用户加入组织的邀请", "inviteSearch": "搜索邀请...", "minutes": "分钟", "hours": "小时", @@ -425,24 +425,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 +462,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 +883,11 @@ "resourcesErrorUpdateDescription": "更新资源时出错", "access": "访问权限", "accessControl": "访问控制", - "shareLink": "{resource} 可共享链接", + "shareLink": "{resource} 的共享链接", "resourceSelect": "选择资源", - "shareLinks": "可共享链接", + "shareLinks": "共享链接", "share": "分享链接", - "shareDescription2": "创建资源的可共享链接。链接提供了对您资源的临时或无限制访问。 当您创建链接时,您可以配置链接的到期时间。", + "shareDescription2": "创建资源的共享链接。链接提供了对您资源的临时或无限制访问。 当您创建链接时,您可以配置链接的到期时间。", "shareEasyCreate": "轻松创建和分享", "shareConfigurableExpirationDuration": "可配置的过期时间", "shareSecureAndRevocable": "安全和可撤销的", @@ -1059,7 +1059,7 @@ "network": "网络", "manage": "管理", "sitesNotFound": "未找到站点。", - "pangolinServerAdmin": "服务器管理员 - Pangolin", + "pangolinServerAdmin": "服务器管理 - Pangolin", "licenseTierProfessional": "专业许可证", "licenseTierEnterprise": "企业许可证", "licenseTierPersonal": "个人许可证", @@ -1366,7 +1366,7 @@ "supportKeyBuy": "购买支持者密钥", "logoutError": "注销错误", "signingAs": "登录为", - "serverAdmin": "服务器管理员", + "serverAdmin": "服务器管理", "managedSelfhosted": "托管自托管", "otpEnable": "启用双因子认证", "otpDisable": "禁用双因子认证", @@ -1536,8 +1536,8 @@ "sidebarSites": "站点", "sidebarApprovals": "审批请求", "sidebarResources": "资源", - "sidebarProxyResources": "公开的", - "sidebarClientResources": "非公开的", + "sidebarProxyResources": "公开资源", + "sidebarClientResources": "私有资源", "sidebarPolicies": "共享策略", "sidebarResourcePolicies": "公共资源", "sidebarAccessControl": "访问控制", @@ -1547,17 +1547,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 +1689,8 @@ "alertingTabHealthChecks": "健康检查", "alertingRulesBannerTitle": "获取通知", "alertingRulesBannerDescription": "每条规则都连接要监视的对象(站点、健康检查或资源),触发时间(例如离线或不健康),以及如何通过电子邮件、Webhooks 或集成将通知发送给团队。使用此列表创建、启用和管理这些规则。", - "alertingHealthChecksBannerTitle": "监视健康和资源", - "alertingHealthChecksBannerDescription": "健康检查是您一次定义的 HTTP 或 TCP 监控。然后可以将它们用作告警规则中的来源,以便目标变得正常或不正常时得到通知。资源上的健康检查也会出现在此处。", + "alertingHealthChecksBannerTitle": "资源与健康监控", + "alertingHealthChecksBannerDescription": "通过 HTTP 或 TCP 检查目标状态,并在服务异常或恢复时发送通知。资源中配置的健康检查也会显示在这里。", "standaloneHcTableTitle": "健康检查", "standaloneHcSearchPlaceholder": "搜索健康检查…", "standaloneHcAddButton": "创建健康检查", @@ -1791,17 +1791,17 @@ "theme": "主题", "subnetRequired": "子网是必填项", "initialSetupTitle": "初始服务器设置", - "initialSetupDescription": "创建初始服务器管理员帐户。 只能存在一个服务器管理员。 您可以随时更改这些凭据。", + "initialSetupDescription": "创建初始的管理员帐户。 只能存在一个服务器管理员。 您可以随时更改这些凭据。", "createAdminAccount": "创建管理员帐户", - "setupErrorCreateAdmin": "创建服务器管理员账户时发生错误。", + "setupErrorCreateAdmin": "创建管理员账户时发生错误。", "certificateStatus": "证书", "certificateStatusAutoRefreshHint": "状态自动刷新。", "loading": "加载中", "loadingEllipsis": "加载中……", "loadingAnalytics": "加载分析", "restart": "重启", - "domains": "域", - "domainsDescription": "创建和管理组织中可用的域", + "domains": "域名", + "domainsDescription": "创建和管理组织中可用的域名", "domainsSearch": "搜索域...", "domainAdd": "添加域", "domainAddDescription": "注册一个新域名到组织", @@ -2165,12 +2165,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 +2183,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 +2235,8 @@ "resourceEditDomain": "编辑域名", "siteName": "站点名称", "proxyPort": "端口", - "resourcesTableProxyResources": "公开的", - "resourcesTableClientResources": "非公开的", + "resourcesTableProxyResources": "", + "resourcesTableClientResources": "私有资源", "resourcesTableNoProxyResourcesFound": "未找到代理资源。", "resourcesTableNoInternalResourcesFound": "未找到内部资源。", "resourcesTableDestination": "目标", @@ -2925,7 +2925,7 @@ "logRetentionRequestDescription": "保留请求日志的时间", "logRetentionAccessLabel": "访问日志保留", "logRetentionAccessDescription": "保留访问日志的时间", - "logRetentionActionLabel": "动作日志保留", + "logRetentionActionLabel": "审计日志保留", "logRetentionActionDescription": "保留操作日志的时间", "logRetentionConnectionLabel": "连接日志保留", "logRetentionConnectionDescription": "保留连接日志的时间", @@ -2938,11 +2938,11 @@ "logRetentionForever": "永远的", "logRetentionEndOfFollowingYear": "下一年结束", "actionLogsDescription": "查看此机构执行的操作历史", - "accessLogsDescription": "查看此机构资源的访问认证请求", + "accessLogsDescription": "查看此组织资源的访问认证请求", "connectionLogs": "连接日志", "connectionLogsDescription": "查看此机构隧道的连接日志", "sidebarLogsConnection": "连接日志", - "sidebarLogsStreaming": "流流", + "sidebarLogsStreaming": "事件流", "sourceAddress": "源地址", "destinationAddress": "目的地址", "duration": "期限", @@ -2967,6 +2967,7 @@ "orgOrDomainIdMissing": "缺少机构或域 ID", "loadingDNSRecords": "正在载入DNS记录...", "olmUpdateAvailableInfo": "有最新版本的 Olm 可用。请更新到最新版本以获取最佳体验。", + "updateAvailableInfo": "An updated version is available. Please update to the latest version for the best experience.", "client": "客户端:", "proxyProtocol": "代理协议设置", "proxyProtocolDescription": "配置代理协议以保留TCP服务的客户端 IP 地址。", From babd90ae719ebd2b94ed4b538134b8439a07b1b4 Mon Sep 17 00:00:00 2001 From: Owen Schwartz Date: Mon, 22 Jun 2026 15:22:46 -0700 Subject: [PATCH 45/88] New translations en-us.json (Norwegian Bokmal) [ci skip] --- messages/nb-NO.json | 1 + 1 file changed, 1 insertion(+) diff --git a/messages/nb-NO.json b/messages/nb-NO.json index 52ddf8e332..1660e12a44 100644 --- a/messages/nb-NO.json +++ b/messages/nb-NO.json @@ -2967,6 +2967,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": "An updated version is available. Please update to the latest version for the best experience.", "client": "Klient", "proxyProtocol": "Protokoll innstillinger for Protokoll", "proxyProtocolDescription": "Konfigurer Proxy-protokoll for å bevare klientens IP-adresser til TCP-tjenester.", From 8004ae68705268842200ad8e34ff25a91c175e4e Mon Sep 17 00:00:00 2001 From: Owen Date: Tue, 23 Jun 2026 11:25:09 -0400 Subject: [PATCH 46/88] Use the policy when updating rule Fixes #3273 --- server/routers/resource/updateResourceRule.ts | 153 ++++++++++++++---- 1 file changed, 122 insertions(+), 31 deletions(-) diff --git a/server/routers/resource/updateResourceRule.ts b/server/routers/resource/updateResourceRule.ts index cc2a6fc035..84afb38b6d 100644 --- a/server/routers/resource/updateResourceRule.ts +++ b/server/routers/resource/updateResourceRule.ts @@ -1,8 +1,8 @@ import { Request, Response, NextFunction } from "express"; import { z } from "zod"; import { db } from "@server/db"; -import { resourceRules, resources } from "@server/db"; -import { eq } from "drizzle-orm"; +import { resourcePolicyRules, resourceRules, resources } from "@server/db"; +import { and, eq } from "drizzle-orm"; import response from "@server/lib/response"; import HttpCode from "@server/types/HttpCode"; import createHttpError from "http-errors"; @@ -22,13 +22,20 @@ const updateResourceRuleParamsSchema = z.strictObject({ resourceId: z.coerce.number().int().positive() }); +const resourceRuleMatchSchema = z.enum([ + "CIDR", + "IP", + "PATH", + "COUNTRY", + "ASN", + "REGION" +]); + // Define Zod schema for request body validation const updateResourceRuleSchema = z .strictObject({ action: z.enum(["ACCEPT", "DROP", "PASS"]).optional(), - match: z - .enum(["CIDR", "IP", "PATH", "COUNTRY", "ASN", "REGION"]) - .optional(), + match: resourceRuleMatchSchema.optional(), value: z.string().min(1).optional(), priority: z.int(), enabled: z.boolean().optional() @@ -123,37 +130,102 @@ export async function updateResourceRule( return next( createHttpError( HttpCode.BAD_REQUEST, - "Cannot create rule for non-http resource" + "Cannot update rule for non-http resource" ) ); } - // Verify that the rule exists and belongs to the specified resource - const [existingRule] = await db - .select() - .from(resourceRules) - .where(eq(resourceRules.ruleId, ruleId)) - .limit(1); + const isInlinePolicy = + resource.resourcePolicyId === null && + resource.defaultResourcePolicyId !== null; - if (!existingRule) { - return next( - createHttpError( - HttpCode.NOT_FOUND, - `Resource rule with ID ${ruleId} not found` - ) + let existingMatch: + | "CIDR" + | "IP" + | "PATH" + | "COUNTRY" + | "ASN" + | "REGION"; + + if (isInlinePolicy) { + const policyId = resource.defaultResourcePolicyId!; + const [existingRule] = await db + .select() + .from(resourcePolicyRules) + .where(eq(resourcePolicyRules.ruleId, ruleId)) + .limit(1); + + if (!existingRule) { + return next( + createHttpError( + HttpCode.NOT_FOUND, + `Resource rule with ID ${ruleId} not found` + ) + ); + } + + if (existingRule.resourcePolicyId !== policyId) { + return next( + createHttpError( + HttpCode.FORBIDDEN, + `Resource rule ${ruleId} does not belong to resource ${resourceId}` + ) + ); + } + + const parsedExistingMatch = resourceRuleMatchSchema.safeParse( + existingRule.match ); - } + if (!parsedExistingMatch.success) { + return next( + createHttpError( + HttpCode.INTERNAL_SERVER_ERROR, + "Resource rule has invalid match type" + ) + ); + } + existingMatch = parsedExistingMatch.data; + } else { + // Verify that the rule exists and belongs to the specified resource + const [existingRule] = await db + .select() + .from(resourceRules) + .where(eq(resourceRules.ruleId, ruleId)) + .limit(1); - if (existingRule.resourceId !== resourceId) { - return next( - createHttpError( - HttpCode.FORBIDDEN, - `Resource rule ${ruleId} does not belong to resource ${resourceId}` - ) + if (!existingRule) { + return next( + createHttpError( + HttpCode.NOT_FOUND, + `Resource rule with ID ${ruleId} not found` + ) + ); + } + + if (existingRule.resourceId !== resourceId) { + return next( + createHttpError( + HttpCode.FORBIDDEN, + `Resource rule ${ruleId} does not belong to resource ${resourceId}` + ) + ); + } + + const parsedExistingMatch = resourceRuleMatchSchema.safeParse( + existingRule.match ); + if (!parsedExistingMatch.success) { + return next( + createHttpError( + HttpCode.INTERNAL_SERVER_ERROR, + "Resource rule has invalid match type" + ) + ); + } + existingMatch = parsedExistingMatch.data; } - const match = updateData.match || existingRule.match; + const match = updateData.match || existingMatch; const { value } = updateData; if (value !== undefined) { @@ -197,11 +269,30 @@ export async function updateResourceRule( } // Update the rule - const [updatedRule] = await db - .update(resourceRules) - .set(updateData) - .where(eq(resourceRules.ruleId, ruleId)) - .returning(); + const [updatedRule] = isInlinePolicy + ? await db + .update(resourcePolicyRules) + .set(updateData) + .where( + and( + eq(resourcePolicyRules.ruleId, ruleId), + eq( + resourcePolicyRules.resourcePolicyId, + resource.defaultResourcePolicyId! + ) + ) + ) + .returning() + : await db + .update(resourceRules) + .set(updateData) + .where( + and( + eq(resourceRules.ruleId, ruleId), + eq(resourceRules.resourceId, resourceId) + ) + ) + .returning(); return response(res, { data: updatedRule, From ce3c2f7583806cc65928d93a26e66378c0f5429a Mon Sep 17 00:00:00 2001 From: Owen Date: Tue, 23 Jun 2026 12:05:47 -0400 Subject: [PATCH 47/88] Fix #3314 --- .../routers/healthChecks/createHealthCheck.ts | 59 ++++++++----- .../routers/healthChecks/updateHealthCheck.ts | 31 ++++++- server/routers/target/createTarget.ts | 88 +++++++++++-------- server/routers/target/updateTarget.ts | 47 ++++++---- 4 files changed, 151 insertions(+), 74 deletions(-) diff --git a/server/private/routers/healthChecks/createHealthCheck.ts b/server/private/routers/healthChecks/createHealthCheck.ts index aa37068331..6f49f0f18e 100644 --- a/server/private/routers/healthChecks/createHealthCheck.ts +++ b/server/private/routers/healthChecks/createHealthCheck.ts @@ -29,26 +29,40 @@ const paramsSchema = z.strictObject({ orgId: z.string().nonempty() }); -const bodySchema = z.strictObject({ - name: z.string().nonempty(), - siteId: z.number().int().positive(), - hcEnabled: z.boolean().default(false), - hcMode: z.string().default("http"), - hcHostname: z.string().optional(), - hcPort: z.number().int().min(1).max(65535).optional(), - hcPath: z.string().optional(), - hcScheme: z.string().optional(), - hcMethod: z.string().default("GET"), - hcInterval: z.number().int().positive().default(30), - hcUnhealthyInterval: z.number().int().positive().default(30), - hcTimeout: z.number().int().positive().default(1), - hcHeaders: z.string().optional().nullable(), - hcFollowRedirects: z.boolean().default(true), - hcStatus: z.number().int().optional().nullable(), - hcTlsServerName: z.string().optional(), - hcHealthyThreshold: z.number().int().positive().default(1), - hcUnhealthyThreshold: z.number().int().positive().default(1) -}); +const bodySchema = z + .strictObject({ + name: z.string().nonempty(), + siteId: z.number().int().positive(), + hcEnabled: z.boolean().default(false), + hcMode: z.string().default("http"), + hcHostname: z.string().optional(), + hcPort: z.number().int().min(1).max(65535).optional(), + hcPath: z.string().optional(), + hcScheme: z.string().optional(), + hcMethod: z.string().default("GET"), + hcInterval: z.number().int().positive().default(30), + hcUnhealthyInterval: z.number().int().positive().default(30), + hcTimeout: z.number().int().positive().default(1), + hcHeaders: z.string().optional().nullable(), + hcFollowRedirects: z.boolean().default(true), + hcStatus: z.number().int().optional().nullable(), + hcTlsServerName: z.string().optional(), + hcHealthyThreshold: z.number().int().positive().default(1), + hcUnhealthyThreshold: z.number().int().positive().default(1) + }) + .superRefine((data, ctx) => { + const hcHostnameMissing = + data.hcHostname === undefined || + data.hcHostname.trim().length === 0; + + if (data.hcEnabled === true && hcHostnameMissing) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ["hcHostname"], + message: "hcHostname is required when hcEnabled is true" + }); + } + }); export type CreateHealthCheckResponse = { targetHealthCheckId: number; @@ -57,7 +71,6 @@ const CreateHealthCheckResponseDataSchema = z.object({ targetHealthCheckId: z.number() }); - registry.registerPath({ method: "put", path: "/org/{orgId}/health-check", @@ -78,7 +91,9 @@ registry.registerPath({ description: "Successful response", content: { "application/json": { - schema: createApiResponseSchema(CreateHealthCheckResponseDataSchema) + schema: createApiResponseSchema( + CreateHealthCheckResponseDataSchema + ) } } } diff --git a/server/private/routers/healthChecks/updateHealthCheck.ts b/server/private/routers/healthChecks/updateHealthCheck.ts index f08324f9b5..4fb7a624bd 100644 --- a/server/private/routers/healthChecks/updateHealthCheck.ts +++ b/server/private/routers/healthChecks/updateHealthCheck.ts @@ -105,7 +105,6 @@ const UpdateHealthCheckResponseDataSchema = z.object({ hcUnhealthyThreshold: z.number().nullable() }); - registry.registerPath({ method: "post", path: "/org/{orgId}/health-check/{healthCheckId}", @@ -126,7 +125,9 @@ registry.registerPath({ description: "Successful response", content: { "application/json": { - schema: createApiResponseSchema(UpdateHealthCheckResponseDataSchema) + schema: createApiResponseSchema( + UpdateHealthCheckResponseDataSchema + ) } } } @@ -215,6 +216,32 @@ export async function updateHealthCheck( ) .limit(1); + if (!existingHealthCheck) { + return next( + createHttpError( + HttpCode.NOT_FOUND, + "Standalone health check not found" + ) + ); + } + + const nextHcEnabled = hcEnabled ?? existingHealthCheck.hcEnabled; + const nextHcHostname = + hcHostname !== undefined + ? hcHostname + : existingHealthCheck.hcHostname; + const hcHostnameMissing = + !nextHcHostname || nextHcHostname.trim().length === 0; + + if (nextHcEnabled && hcHostnameMissing) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + "hcHostname is required when hcEnabled is true" + ) + ); + } + if (name !== undefined) updateData.name = name; if (siteId !== undefined) updateData.siteId = siteId; if (hcEnabled !== undefined) updateData.hcEnabled = hcEnabled; diff --git a/server/routers/target/createTarget.ts b/server/routers/target/createTarget.ts index 2b3f472e8e..289f47c767 100644 --- a/server/routers/target/createTarget.ts +++ b/server/routers/target/createTarget.ts @@ -33,41 +33,59 @@ const createTargetParamsSchema = z.strictObject({ resourceId: z.coerce.number().int().positive() }); -const createTargetSchema = z.strictObject({ - siteId: z.int().positive(), - ip: z.string().refine(isTargetValid), - mode: z.enum(["http", "tcp", "udp", "ssh", "rdp", "vnc"]).optional(), - method: z.string().optional().nullable(), - port: z.int().min(1).max(65535), - enabled: z.boolean().default(true), - hcEnabled: z.boolean().optional(), - hcPath: z.string().min(1).optional().nullable(), - hcScheme: z.string().optional().nullable(), - hcMode: z.string().optional().nullable(), - hcHostname: z.string().optional().nullable(), - hcPort: z.int().positive().optional().nullable(), - hcInterval: z.int().positive().min(1).optional().nullable(), - hcUnhealthyInterval: z.int().positive().min(1).optional().nullable(), - hcTimeout: z.int().positive().min(1).optional().nullable(), - hcHeaders: z - .array(z.strictObject({ name: z.string(), value: z.string() })) - .nullable() - .optional(), - hcFollowRedirects: z.boolean().optional().nullable(), - hcMethod: z.string().min(1).optional().nullable(), - hcStatus: z.int().optional().nullable(), - hcTlsServerName: z.string().optional().nullable(), - hcHealthyThreshold: z.int().positive().min(1).optional().nullable(), - hcUnhealthyThreshold: z.int().positive().min(1).optional().nullable(), - path: z.string().optional().nullable(), - pathMatchType: z.enum(["exact", "prefix", "regex"]).optional().nullable(), - rewritePath: z.string().optional().nullable(), - rewritePathType: z - .enum(["exact", "prefix", "regex", "stripPrefix"]) - .optional() - .nullable(), - priority: z.int().min(1).max(1000).optional().nullable() -}); +const createTargetSchema = z + .strictObject({ + siteId: z.int().positive(), + ip: z.string().refine(isTargetValid), + mode: z.enum(["http", "tcp", "udp", "ssh", "rdp", "vnc"]).optional(), + method: z.string().optional().nullable(), + port: z.int().min(1).max(65535), + enabled: z.boolean().default(true), + hcEnabled: z.boolean().optional(), + hcPath: z.string().min(1).optional().nullable(), + hcScheme: z.string().optional().nullable(), + hcMode: z.string().optional().nullable(), + hcHostname: z.string().optional().nullable(), + hcPort: z.int().positive().optional().nullable(), + hcInterval: z.int().positive().min(1).optional().nullable(), + hcUnhealthyInterval: z.int().positive().min(1).optional().nullable(), + hcTimeout: z.int().positive().min(1).optional().nullable(), + hcHeaders: z + .array(z.strictObject({ name: z.string(), value: z.string() })) + .nullable() + .optional(), + hcFollowRedirects: z.boolean().optional().nullable(), + hcMethod: z.string().min(1).optional().nullable(), + hcStatus: z.int().optional().nullable(), + hcTlsServerName: z.string().optional().nullable(), + hcHealthyThreshold: z.int().positive().min(1).optional().nullable(), + hcUnhealthyThreshold: z.int().positive().min(1).optional().nullable(), + path: z.string().optional().nullable(), + pathMatchType: z + .enum(["exact", "prefix", "regex"]) + .optional() + .nullable(), + rewritePath: z.string().optional().nullable(), + rewritePathType: z + .enum(["exact", "prefix", "regex", "stripPrefix"]) + .optional() + .nullable(), + priority: z.int().min(1).max(1000).optional().nullable() + }) + .superRefine((data, ctx) => { + const hcHostnameMissing = + data.hcHostname === undefined || + data.hcHostname === null || + data.hcHostname.trim().length === 0; + + if (data.hcEnabled === true && hcHostnameMissing) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ["hcHostname"], + message: "hcHostname is required when hcEnabled is true" + }); + } + }); export type CreateTargetResponse = Target & TargetHealthCheck; diff --git a/server/routers/target/updateTarget.ts b/server/routers/target/updateTarget.ts index 1bed7b982e..52bf3e578f 100644 --- a/server/routers/target/updateTarget.ts +++ b/server/routers/target/updateTarget.ts @@ -188,6 +188,38 @@ export async function updateTarget( ); } + const [existingHc] = await db + .select() + .from(targetHealthCheck) + .where(eq(targetHealthCheck.targetId, targetId)) + .limit(1); + + if (!existingHc) { + return next( + createHttpError( + HttpCode.NOT_FOUND, + `Health check for target with ID ${targetId} not found` + ) + ); + } + + const nextHcEnabled = parsedBody.data.hcEnabled ?? existingHc.hcEnabled; + const nextHcHostname = + parsedBody.data.hcHostname !== undefined + ? parsedBody.data.hcHostname + : existingHc.hcHostname; + const hcHostnameMissing = + !nextHcHostname || nextHcHostname.trim().length === 0; + + if (nextHcEnabled && hcHostnameMissing) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + "hcHostname is required when hcEnabled is true" + ) + ); + } + const pathMatchTypeRemoved = parsedBody.data.pathMatchType === null; const nextMode = parsedBody.data.mode === null ? undefined : parsedBody.data.mode; @@ -218,21 +250,6 @@ export async function updateTarget( .where(eq(targets.targetId, targetId)) .returning(); - const [existingHc] = await trx - .select() - .from(targetHealthCheck) - .where(eq(targetHealthCheck.targetId, targetId)) - .limit(1); - - if (!existingHc) { - return next( - createHttpError( - HttpCode.NOT_FOUND, - `Health check for target with ID ${targetId} not found` - ) - ); - } - let hcHeaders = null; if (parsedBody.data.hcHeaders) { hcHeaders = JSON.stringify(parsedBody.data.hcHeaders); From f48a4f7bc0a9c09c8377e15b9b759c1332dd1770 Mon Sep 17 00:00:00 2001 From: Owen Date: Tue, 23 Jun 2026 12:22:47 -0400 Subject: [PATCH 48/88] Enforce strick query params Fixes #3313 --- server/routers/accessToken/listAccessTokens.ts | 2 +- server/routers/apiKeys/listApiKeyActions.ts | 2 +- server/routers/apiKeys/listOrgApiKeys.ts | 2 +- server/routers/apiKeys/listRootApiKeys.ts | 2 +- server/routers/auditLogs/queryRequestAuditLog.ts | 2 +- server/routers/client/listClients.ts | 2 +- server/routers/client/listUserDevices.ts | 2 +- server/routers/olm/listUserOlms.ts | 2 +- server/routers/org/listOrgs.ts | 2 +- server/routers/org/listUserOrgs.ts | 2 +- server/routers/resource/listResourceRules.ts | 2 +- server/routers/resource/listResources.ts | 2 +- server/routers/resource/listUserResourceAliases.ts | 2 +- server/routers/role/listRoles.ts | 2 +- server/routers/site/listSites.ts | 2 +- server/routers/siteResource/listAllSiteResourcesByOrg.ts | 2 +- server/routers/siteResource/listSiteResources.ts | 2 +- server/routers/target/listTargets.ts | 2 +- 18 files changed, 18 insertions(+), 18 deletions(-) diff --git a/server/routers/accessToken/listAccessTokens.ts b/server/routers/accessToken/listAccessTokens.ts index 0339cc2c47..472d9da406 100644 --- a/server/routers/accessToken/listAccessTokens.ts +++ b/server/routers/accessToken/listAccessTokens.ts @@ -30,7 +30,7 @@ const listAccessTokensParamsSchema = z error: "Either resourceId or orgId must be provided, but not both" }); -const listAccessTokensSchema = z.object({ +const listAccessTokensSchema = z.strictObject({ limit: z .string() .optional() diff --git a/server/routers/apiKeys/listApiKeyActions.ts b/server/routers/apiKeys/listApiKeyActions.ts index 364f3aee2f..3b5efa8b59 100644 --- a/server/routers/apiKeys/listApiKeyActions.ts +++ b/server/routers/apiKeys/listApiKeyActions.ts @@ -15,7 +15,7 @@ const paramsSchema = z.object({ apiKeyId: z.string().nonempty() }); -const querySchema = z.object({ +const querySchema = z.strictObject({ limit: z .string() .optional() diff --git a/server/routers/apiKeys/listOrgApiKeys.ts b/server/routers/apiKeys/listOrgApiKeys.ts index ba87a30333..68a7f9a257 100644 --- a/server/routers/apiKeys/listOrgApiKeys.ts +++ b/server/routers/apiKeys/listOrgApiKeys.ts @@ -11,7 +11,7 @@ import { eq, and } from "drizzle-orm"; import { OpenAPITags, registry } from "@server/openApi"; import { createApiResponseSchema } from "@server/lib/openapi/createApiResponseSchema"; -const querySchema = z.object({ +const querySchema = z.strictObject({ limit: z .string() .optional() diff --git a/server/routers/apiKeys/listRootApiKeys.ts b/server/routers/apiKeys/listRootApiKeys.ts index 654b830a63..434ff5a8bf 100644 --- a/server/routers/apiKeys/listRootApiKeys.ts +++ b/server/routers/apiKeys/listRootApiKeys.ts @@ -9,7 +9,7 @@ import { z } from "zod"; import { fromError } from "zod-validation-error"; import { eq } from "drizzle-orm"; -const querySchema = z.object({ +const querySchema = z.strictObject({ limit: z .string() .optional() diff --git a/server/routers/auditLogs/queryRequestAuditLog.ts b/server/routers/auditLogs/queryRequestAuditLog.ts index f14c28cf18..7f4a0ec165 100644 --- a/server/routers/auditLogs/queryRequestAuditLog.ts +++ b/server/routers/auditLogs/queryRequestAuditLog.ts @@ -20,7 +20,7 @@ import response from "@server/lib/response"; import logger from "@server/logger"; import { getSevenDaysAgo } from "@app/lib/getSevenDaysAgo"; -export const queryAccessAuditLogsQuery = z.object({ +export const queryAccessAuditLogsQuery = z.strictObject({ // iso string just validate its a parseable date timeStart: z .string() diff --git a/server/routers/client/listClients.ts b/server/routers/client/listClients.ts index 9178c27a59..98c0fc5506 100644 --- a/server/routers/client/listClients.ts +++ b/server/routers/client/listClients.ts @@ -41,7 +41,7 @@ const listClientsParamsSchema = z.strictObject({ orgId: z.string() }); -const listClientsSchema = z.object({ +const listClientsSchema = z.strictObject({ pageSize: z.coerce .number() // for prettier formatting .int() diff --git a/server/routers/client/listUserDevices.ts b/server/routers/client/listUserDevices.ts index e2a0359298..fb30049214 100644 --- a/server/routers/client/listUserDevices.ts +++ b/server/routers/client/listUserDevices.ts @@ -40,7 +40,7 @@ const listUserDevicesParamsSchema = z.strictObject({ orgId: z.string() }); -const listUserDevicesSchema = z.object({ +const listUserDevicesSchema = z.strictObject({ pageSize: z.coerce .number() // for prettier formatting .int() diff --git a/server/routers/olm/listUserOlms.ts b/server/routers/olm/listUserOlms.ts index b2db262e69..5549afc9f9 100644 --- a/server/routers/olm/listUserOlms.ts +++ b/server/routers/olm/listUserOlms.ts @@ -11,7 +11,7 @@ import logger from "@server/logger"; import { OpenAPITags, registry } from "@server/openApi"; import { getUserDeviceName } from "@server/db/names"; -const querySchema = z.object({ +const querySchema = z.strictObject({ limit: z .string() .optional() diff --git a/server/routers/org/listOrgs.ts b/server/routers/org/listOrgs.ts index 336592fd5e..88c05f61a1 100644 --- a/server/routers/org/listOrgs.ts +++ b/server/routers/org/listOrgs.ts @@ -11,7 +11,7 @@ import { fromZodError } from "zod-validation-error"; import { OpenAPITags, registry } from "@server/openApi"; import { createApiResponseSchema } from "@server/lib/openapi/createApiResponseSchema"; -const listOrgsSchema = z.object({ +const listOrgsSchema = z.strictObject({ limit: z .string() .optional() diff --git a/server/routers/org/listUserOrgs.ts b/server/routers/org/listUserOrgs.ts index c48f2fa91e..47d5409308 100644 --- a/server/routers/org/listUserOrgs.ts +++ b/server/routers/org/listUserOrgs.ts @@ -14,7 +14,7 @@ const listOrgsParamsSchema = z.object({ userId: z.string() }); -const listOrgsSchema = z.object({ +const listOrgsSchema = z.strictObject({ limit: z .string() .optional() diff --git a/server/routers/resource/listResourceRules.ts b/server/routers/resource/listResourceRules.ts index 6b9df688a5..ec4cc332e5 100644 --- a/server/routers/resource/listResourceRules.ts +++ b/server/routers/resource/listResourceRules.ts @@ -14,7 +14,7 @@ const listResourceRulesParamsSchema = z.strictObject({ resourceId: z.coerce.number().int().positive() }); -const listResourceRulesSchema = z.object({ +const listResourceRulesSchema = z.strictObject({ limit: z .string() .optional() diff --git a/server/routers/resource/listResources.ts b/server/routers/resource/listResources.ts index 684c48159b..f15a3bedae 100644 --- a/server/routers/resource/listResources.ts +++ b/server/routers/resource/listResources.ts @@ -48,7 +48,7 @@ const listResourcesParamsSchema = z.strictObject({ orgId: z.string() }); -const listResourcesSchema = z.object({ +const listResourcesSchema = z.strictObject({ pageSize: z.coerce .number() // for prettier formatting .int() diff --git a/server/routers/resource/listUserResourceAliases.ts b/server/routers/resource/listUserResourceAliases.ts index 205c029f06..75dc91166b 100644 --- a/server/routers/resource/listUserResourceAliases.ts +++ b/server/routers/resource/listUserResourceAliases.ts @@ -32,7 +32,7 @@ const listUserResourceAliasesParamsSchema = z.strictObject({ orgId: z.string() }); -const listUserResourceAliasesQuerySchema = z.object({ +const listUserResourceAliasesQuerySchema = z.strictObject({ pageSize: z.coerce .number() .int() diff --git a/server/routers/role/listRoles.ts b/server/routers/role/listRoles.ts index 248db5063d..d59ced2f7c 100644 --- a/server/routers/role/listRoles.ts +++ b/server/routers/role/listRoles.ts @@ -15,7 +15,7 @@ const listRolesParamsSchema = z.strictObject({ orgId: z.string() }); -const listRolesSchema = z.object({ +const listRolesSchema = z.strictObject({ pageSize: z.coerce .number() // for prettier formatting .int() diff --git a/server/routers/site/listSites.ts b/server/routers/site/listSites.ts index 4ca28eda93..86c555f936 100644 --- a/server/routers/site/listSites.ts +++ b/server/routers/site/listSites.ts @@ -32,7 +32,7 @@ const listSitesParamsSchema = z.strictObject({ orgId: z.string() }); -const listSitesSchema = z.object({ +const listSitesSchema = z.strictObject({ pageSize: z.coerce .number() // for prettier formatting .int() diff --git a/server/routers/siteResource/listAllSiteResourcesByOrg.ts b/server/routers/siteResource/listAllSiteResourcesByOrg.ts index 5c20bc5a75..721d76bf68 100644 --- a/server/routers/siteResource/listAllSiteResourcesByOrg.ts +++ b/server/routers/siteResource/listAllSiteResourcesByOrg.ts @@ -26,7 +26,7 @@ const listAllSiteResourcesByOrgParamsSchema = z.strictObject({ orgId: z.string() }); -const listAllSiteResourcesByOrgQuerySchema = z.object({ +const listAllSiteResourcesByOrgQuerySchema = z.strictObject({ pageSize: z.coerce .number() // for prettier formatting .int() diff --git a/server/routers/siteResource/listSiteResources.ts b/server/routers/siteResource/listSiteResources.ts index 311009dfa4..a9688a9c6c 100644 --- a/server/routers/siteResource/listSiteResources.ts +++ b/server/routers/siteResource/listSiteResources.ts @@ -15,7 +15,7 @@ const listSiteResourcesParamsSchema = z.strictObject({ orgId: z.string() }); -const listSiteResourcesQuerySchema = z.object({ +const listSiteResourcesQuerySchema = z.strictObject({ limit: z .string() .optional() diff --git a/server/routers/target/listTargets.ts b/server/routers/target/listTargets.ts index b097b1f6ee..68f80197a2 100644 --- a/server/routers/target/listTargets.ts +++ b/server/routers/target/listTargets.ts @@ -14,7 +14,7 @@ const listTargetsParamsSchema = z.strictObject({ resourceId: z.coerce.number().int().positive() }); -const listTargetsSchema = z.object({ +const listTargetsSchema = z.strictObject({ limit: z .string() .optional() From d78223b94fa3911ec11e525420d7dc0e4caf1e00 Mon Sep 17 00:00:00 2001 From: Owen Date: Tue, 23 Jun 2026 14:52:37 -0400 Subject: [PATCH 49/88] Fix import to be private --- server/private/lib/rebuildQueue.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/server/private/lib/rebuildQueue.ts b/server/private/lib/rebuildQueue.ts index 2cd1dadc01..01082cc502 100644 --- a/server/private/lib/rebuildQueue.ts +++ b/server/private/lib/rebuildQueue.ts @@ -12,7 +12,7 @@ */ import { redis } from "#private/lib/redis"; -import { lockManager } from "#dynamic/lib/lock"; +import { lockManager } from "#private/lib/lock"; import logger from "@server/logger"; export type RebuildJobType = "site-resource" | "client"; From a9b7cce49bf9b48dbf7b3560924c6de66dc72909 Mon Sep 17 00:00:00 2001 From: Owen Date: Tue, 23 Jun 2026 15:41:38 -0400 Subject: [PATCH 50/88] Improve efficiency of calculateUserClientsForOrgs --- server/lib/calculateUserClientsForOrgs.ts | 75 ++++++++++++++----- .../routers/orgIdp/unassociateOrgIdp.ts | 2 +- server/routers/auth/deleteMyAccount.ts | 2 +- server/routers/idp/validateOidcCallback.ts | 2 +- server/routers/olm/createUserOlm.ts | 2 +- server/routers/org/createOrg.ts | 14 +++- server/routers/user/acceptInvite.ts | 12 ++- server/routers/user/adminRemoveUser.ts | 2 +- server/routers/user/createOrgUser.ts | 17 ++--- server/routers/user/removeUserOrg.ts | 2 +- 10 files changed, 84 insertions(+), 46 deletions(-) diff --git a/server/lib/calculateUserClientsForOrgs.ts b/server/lib/calculateUserClientsForOrgs.ts index 090bf4d8c2..fe09539a91 100644 --- a/server/lib/calculateUserClientsForOrgs.ts +++ b/server/lib/calculateUserClientsForOrgs.ts @@ -6,6 +6,7 @@ import { db, olms, orgs, + primaryDb, roleClients, roles, Transaction, @@ -23,10 +24,44 @@ import { rebuildClientAssociationsFromClient } from "./rebuildClientAssociations import { OlmErrorCodes } from "@server/routers/olm/error"; import { tierMatrix } from "./billing/tierMatrix"; -export async function calculateUserClientsForOrgs( +type ClientRow = typeof clients.$inferSelect; + +function runQueuedClientAssociationRebuilds( userId: string, - trx: Transaction | typeof db = db + queuedClients: ClientRow[] +): void { + if (queuedClients.length === 0) { + return; + } + + const uniqueClientsById = new Map(); + for (const client of queuedClients) { + uniqueClientsById.set(client.clientId, client); + } + + void (async () => { + for (const client of uniqueClientsById.values()) { + try { + await rebuildClientAssociationsFromClient(client, db); + } catch (error) { + logger.error( + `Failed rebuilding associations for client ${client.clientId} (user ${userId}): ${String(error)}` + ); + } + } + + logger.debug( + `Queued association rebuild completed for ${uniqueClientsById.size} client(s) (user ${userId})` + ); + })(); +} + +export async function calculateUserClientsForOrgs( + userId: string ): Promise { + const trx = primaryDb; + const queuedAssociationRebuilds: ClientRow[] = []; + const execute = async (transaction: Transaction | typeof db) => { const orgCache = new Map(); const adminRoleCache = new Map< @@ -189,7 +224,12 @@ export async function calculateUserClientsForOrgs( if (userOlms.length === 0) { // No OLMs for this user, but we should still clean up any orphaned clients - await cleanupOrphanedClients(userId, transaction); + await cleanupOrphanedClients( + userId, + transaction, + [], + queuedAssociationRebuilds + ); return; } @@ -382,10 +422,7 @@ export async function calculateUserClientsForOrgs( .returning(); } - await rebuildClientAssociationsFromClient( - newClient, - transaction - ); + queuedAssociationRebuilds.push(newClient); // Grant admin role access to the client await transaction.insert(roleClients).values({ @@ -414,24 +451,22 @@ export async function calculateUserClientsForOrgs( } // Clean up clients in orgs the user is no longer in - await cleanupOrphanedClients(userId, transaction, userOrgIds); + await cleanupOrphanedClients( + userId, + transaction, + userOrgIds, + queuedAssociationRebuilds + ); }; - if (trx) { - // Use provided transaction - await execute(trx); - } else { - // Create new transaction - await db.transaction(async (transaction) => { - await execute(transaction); - }); - } + runQueuedClientAssociationRebuilds(userId, queuedAssociationRebuilds); } async function cleanupOrphanedClients( userId: string, trx: Transaction | typeof db, - userOrgIds: string[] = [] + userOrgIds: string[] = [], + queuedAssociationRebuilds: ClientRow[] = [] ): Promise { // Find all OLM clients for this user that should be deleted // If userOrgIds is empty, delete all OLM clients (user has no orgs) @@ -461,9 +496,9 @@ async function cleanupOrphanedClients( ) .returning(); - // Rebuild associations for each deleted client to clean up related data + // Queue deleted clients for post-transaction association cleanup. for (const deletedClient of deletedClients) { - await rebuildClientAssociationsFromClient(deletedClient, trx); + queuedAssociationRebuilds.push(deletedClient); if (deletedClient.olmId) { await sendTerminateClient( diff --git a/server/private/routers/orgIdp/unassociateOrgIdp.ts b/server/private/routers/orgIdp/unassociateOrgIdp.ts index 41b2e6c895..0b5c1ed51c 100644 --- a/server/private/routers/orgIdp/unassociateOrgIdp.ts +++ b/server/private/routers/orgIdp/unassociateOrgIdp.ts @@ -121,7 +121,7 @@ export async function unassociateOrgIdp( }); for (const userId of userIdsToRemove) { - calculateUserClientsForOrgs(userId, primaryDb).catch((e) => { + calculateUserClientsForOrgs(userId).catch((e) => { logger.error( `Failed to calculate user clients after removing user ${userId} from org ${orgId} during IdP unassociation: ${e}` ); diff --git a/server/routers/auth/deleteMyAccount.ts b/server/routers/auth/deleteMyAccount.ts index d03af56313..d45486946e 100644 --- a/server/routers/auth/deleteMyAccount.ts +++ b/server/routers/auth/deleteMyAccount.ts @@ -224,7 +224,7 @@ export async function deleteMyAccount( } }); - calculateUserClientsForOrgs(userId, primaryDb).catch((e) => { + calculateUserClientsForOrgs(userId).catch((e) => { logger.error( `Failed to calculate user clients after deleting account for user ${userId}: ${e}` ); diff --git a/server/routers/idp/validateOidcCallback.ts b/server/routers/idp/validateOidcCallback.ts index 6a82f2c126..f6cb656b31 100644 --- a/server/routers/idp/validateOidcCallback.ts +++ b/server/routers/idp/validateOidcCallback.ts @@ -635,7 +635,7 @@ export async function validateOidcCallback( } }); - calculateUserClientsForOrgs(userId!, primaryDb).catch((err) => { + calculateUserClientsForOrgs(userId!).catch((err) => { logger.error( "Error calculating user clients after syncing orgs and roles for OIDC user", { error: err } diff --git a/server/routers/olm/createUserOlm.ts b/server/routers/olm/createUserOlm.ts index 714fb4b35e..306317a0cd 100644 --- a/server/routers/olm/createUserOlm.ts +++ b/server/routers/olm/createUserOlm.ts @@ -104,7 +104,7 @@ export async function createUserOlm( dateCreated: moment().toISOString() }); - calculateUserClientsForOrgs(userId, primaryDb).catch((e) => { + calculateUserClientsForOrgs(userId).catch((e) => { console.error( "Error calculating user clients after creating olm:", e diff --git a/server/routers/org/createOrg.ts b/server/routers/org/createOrg.ts index 7b2b1f87aa..35466ebc01 100644 --- a/server/routers/org/createOrg.ts +++ b/server/routers/org/createOrg.ts @@ -1,6 +1,6 @@ import { Request, Response, NextFunction } from "express"; import { z } from "zod"; -import { db } from "@server/db"; +import { db, primaryDb } from "@server/db"; import { and, count, eq } from "drizzle-orm"; import { domains, @@ -233,6 +233,7 @@ export async function createOrg( let error = ""; let org: Org | null = null; let numOrgs: number | null = null; + let ownerUserId: string | null = null; await db.transaction(async (trx) => { const allDomains = await trx @@ -326,7 +327,6 @@ export async function createOrg( ); } - let ownerUserId: string | null = null; if (req.user) { await trx.insert(userOrgs).values({ userId: req.user!.userId, @@ -382,8 +382,6 @@ export async function createOrg( })) ); - await calculateUserClientsForOrgs(ownerUserId, trx); - if (billingOrgIdForNewOrg) { const [numOrgsResult] = await trx .select({ count: count() }) @@ -396,6 +394,14 @@ export async function createOrg( } }); + if (ownerUserId) { + calculateUserClientsForOrgs(ownerUserId).catch((e) => { + logger.error( + `Failed to calculate user clients after creating org ${orgId} for user ${ownerUserId}: ${e}` + ); + }); + } + if (!org) { return next( createHttpError( diff --git a/server/routers/user/acceptInvite.ts b/server/routers/user/acceptInvite.ts index e3366a0c58..ef7ddcdbd1 100644 --- a/server/routers/user/acceptInvite.ts +++ b/server/routers/user/acceptInvite.ts @@ -202,13 +202,11 @@ export async function acceptInvite( ); }); - calculateUserClientsForOrgs(existingUser[0].userId, primaryDb).catch( - (e) => { - logger.error( - `Failed to calculate user clients after accepting invite for user ${existingUser[0].userId}: ${e}` - ); - } - ); + calculateUserClientsForOrgs(existingUser[0].userId).catch((e) => { + logger.error( + `Failed to calculate user clients after accepting invite for user ${existingUser[0].userId}: ${e}` + ); + }); return response(res, { data: { accepted: true, orgId: existingInvite.orgId }, diff --git a/server/routers/user/adminRemoveUser.ts b/server/routers/user/adminRemoveUser.ts index 38713ce267..066848b077 100644 --- a/server/routers/user/adminRemoveUser.ts +++ b/server/routers/user/adminRemoveUser.ts @@ -55,7 +55,7 @@ export async function adminRemoveUser( await trx.delete(users).where(eq(users.userId, userId)); }); - calculateUserClientsForOrgs(userId, primaryDb).catch((e) => { + calculateUserClientsForOrgs(userId).catch((e) => { logger.error( `Failed to calculate user clients after removing user ${userId}: ${e}` ); diff --git a/server/routers/user/createOrgUser.ts b/server/routers/user/createOrgUser.ts index f03dd763ba..c6f25e085f 100644 --- a/server/routers/user/createOrgUser.ts +++ b/server/routers/user/createOrgUser.ts @@ -56,7 +56,6 @@ const bodySchema = z export type CreateOrgUserResponse = {}; const CreateOrgUserResponseDataSchema = z.object({}); - registry.registerPath({ method: "put", path: "/org/{orgId}/user", @@ -77,7 +76,9 @@ registry.registerPath({ description: "Successful response", content: { "application/json": { - schema: createApiResponseSchema(CreateOrgUserResponseDataSchema) + schema: createApiResponseSchema( + CreateOrgUserResponseDataSchema + ) } } } @@ -326,13 +327,11 @@ export async function createOrgUser( }); if (userIdForClients) { - calculateUserClientsForOrgs(userIdForClients, primaryDb).catch( - (e) => { - logger.error( - `Failed to calculate user clients after creating org user: ${e}` - ); - } - ); + calculateUserClientsForOrgs(userIdForClients).catch((e) => { + logger.error( + `Failed to calculate user clients after creating org user: ${e}` + ); + }); } } else { return next( diff --git a/server/routers/user/removeUserOrg.ts b/server/routers/user/removeUserOrg.ts index 58fc85b698..902aeed840 100644 --- a/server/routers/user/removeUserOrg.ts +++ b/server/routers/user/removeUserOrg.ts @@ -109,7 +109,7 @@ export async function removeUserOrg( await removeUserFromOrg(org, userId, trx); }); - calculateUserClientsForOrgs(userId, primaryDb).catch((e) => { + calculateUserClientsForOrgs(userId).catch((e) => { logger.error( `Failed to calculate user clients after removing user ${userId} from org ${orgId}: ${e}` ); From c11d24e10a3b63bee2940c8f11f01045456f337d Mon Sep 17 00:00:00 2001 From: Owen Date: Tue, 23 Jun 2026 15:45:02 -0400 Subject: [PATCH 51/88] Standardize db rebuildClientAssociationsFromClient --- server/lib/calculateUserClientsForOrgs.ts | 2 +- server/lib/rebuildClientAssociations.ts | 6 +++--- server/private/routers/user/addUserRole.ts | 12 +++++------- server/private/routers/user/removeUserRole.ts | 12 +++++------- server/private/routers/user/setUserOrgRoles.ts | 12 +++++------- server/routers/client/createClient.ts | 12 +++++------- server/routers/client/createUserClient.ts | 12 +++++------- server/routers/client/deleteClient.ts | 12 +++++------- .../client/rebuildClientAssociationsCacheRoute.ts | 8 ++++++-- server/routers/olm/deleteUserOlm.ts | 12 +++++------- .../siteResource/batchAddClientToSiteResources.ts | 2 +- server/routers/user/addUserRoleLegacy.ts | 12 +++++------- 12 files changed, 51 insertions(+), 63 deletions(-) diff --git a/server/lib/calculateUserClientsForOrgs.ts b/server/lib/calculateUserClientsForOrgs.ts index fe09539a91..9c6903ebc8 100644 --- a/server/lib/calculateUserClientsForOrgs.ts +++ b/server/lib/calculateUserClientsForOrgs.ts @@ -42,7 +42,7 @@ function runQueuedClientAssociationRebuilds( void (async () => { for (const client of uniqueClientsById.values()) { try { - await rebuildClientAssociationsFromClient(client, db); + await rebuildClientAssociationsFromClient(client); } catch (error) { logger.error( `Failed rebuilding associations for client ${client.clientId} (user ${userId}): ${String(error)}` diff --git a/server/lib/rebuildClientAssociations.ts b/server/lib/rebuildClientAssociations.ts index 7f271bbe5b..83a03fa70d 100644 --- a/server/lib/rebuildClientAssociations.ts +++ b/server/lib/rebuildClientAssociations.ts @@ -1043,9 +1043,9 @@ async function handleSubnetProxyTargetUpdates( } export async function rebuildClientAssociationsFromClient( - client: Client, - trx: Transaction | typeof db = db + client: Client ): Promise { + const trx = primaryDb; try { return await lockManager.withLock( `rebuild-client-associations:client:${client.clientId}`, @@ -2137,7 +2137,7 @@ export function startRebuildQueueProcessor(): void { return; } - await rebuildClientAssociationsFromClient(client, primaryDb); + await rebuildClientAssociationsFromClient(client); } }); } diff --git a/server/private/routers/user/addUserRole.ts b/server/private/routers/user/addUserRole.ts index c59a3d0f77..ce5a6dd50a 100644 --- a/server/private/routers/user/addUserRole.ts +++ b/server/private/routers/user/addUserRole.ts @@ -163,13 +163,11 @@ export async function addUserRole( }); for (const orgClient of orgClientsToRebuild) { - rebuildClientAssociationsFromClient(orgClient, primaryDb).catch( - (e) => { - logger.error( - `Failed to rebuild client associations for client ${orgClient.clientId} after adding role: ${e}` - ); - } - ); + rebuildClientAssociationsFromClient(orgClient).catch((e) => { + logger.error( + `Failed to rebuild client associations for client ${orgClient.clientId} after adding role: ${e}` + ); + }); } return response(res, { diff --git a/server/private/routers/user/removeUserRole.ts b/server/private/routers/user/removeUserRole.ts index b96670815c..79a5a522a3 100644 --- a/server/private/routers/user/removeUserRole.ts +++ b/server/private/routers/user/removeUserRole.ts @@ -170,13 +170,11 @@ export async function removeUserRole( }); for (const orgClient of orgClientsToRebuild) { - rebuildClientAssociationsFromClient(orgClient, primaryDb).catch( - (e) => { - logger.error( - `Failed to rebuild client associations for client ${orgClient.clientId} after removing role: ${e}` - ); - } - ); + rebuildClientAssociationsFromClient(orgClient).catch((e) => { + logger.error( + `Failed to rebuild client associations for client ${orgClient.clientId} after removing role: ${e}` + ); + }); } return response(res, { diff --git a/server/private/routers/user/setUserOrgRoles.ts b/server/private/routers/user/setUserOrgRoles.ts index 7790eacfbd..ef6bc1b4f5 100644 --- a/server/private/routers/user/setUserOrgRoles.ts +++ b/server/private/routers/user/setUserOrgRoles.ts @@ -150,13 +150,11 @@ export async function setUserOrgRoles( }); for (const orgClient of orgClientsToRebuild) { - rebuildClientAssociationsFromClient(orgClient, primaryDb).catch( - (e) => { - logger.error( - `Failed to rebuild client associations for client ${orgClient.clientId} after setting roles: ${e}` - ); - } - ); + rebuildClientAssociationsFromClient(orgClient).catch((e) => { + logger.error( + `Failed to rebuild client associations for client ${orgClient.clientId} after setting roles: ${e}` + ); + }); } return response(res, { diff --git a/server/routers/client/createClient.ts b/server/routers/client/createClient.ts index ecda098c5b..bef47245dc 100644 --- a/server/routers/client/createClient.ts +++ b/server/routers/client/createClient.ts @@ -280,13 +280,11 @@ export async function createClient( }); if (newClient) { - rebuildClientAssociationsFromClient(newClient, primaryDb).catch( - (e) => { - logger.error( - `Failed to rebuild client associations after creating client: ${e}` - ); - } - ); + rebuildClientAssociationsFromClient(newClient).catch((e) => { + logger.error( + `Failed to rebuild client associations after creating client: ${e}` + ); + }); } return response(res, { diff --git a/server/routers/client/createUserClient.ts b/server/routers/client/createUserClient.ts index 09bec218a2..3c7d850181 100644 --- a/server/routers/client/createUserClient.ts +++ b/server/routers/client/createUserClient.ts @@ -255,13 +255,11 @@ export async function createUserClient( }); if (newClient) { - rebuildClientAssociationsFromClient(newClient, primaryDb).catch( - (e) => { - logger.error( - `Failed to rebuild client associations after creating user client: ${e}` - ); - } - ); + rebuildClientAssociationsFromClient(newClient).catch((e) => { + logger.error( + `Failed to rebuild client associations after creating user client: ${e}` + ); + }); } return response(res, { diff --git a/server/routers/client/deleteClient.ts b/server/routers/client/deleteClient.ts index 24ab9917a6..62765c2c11 100644 --- a/server/routers/client/deleteClient.ts +++ b/server/routers/client/deleteClient.ts @@ -109,13 +109,11 @@ export async function deleteClient( }); if (deletedClient) { - rebuildClientAssociationsFromClient(deletedClient, primaryDb).catch( - (e) => { - logger.error( - `Failed to rebuild client associations after deleting client ${clientId}: ${e}` - ); - } - ); + rebuildClientAssociationsFromClient(deletedClient).catch((e) => { + logger.error( + `Failed to rebuild client associations after deleting client ${clientId}: ${e}` + ); + }); if (olm) { sendTerminateClient( deletedClient.clientId, diff --git a/server/routers/client/rebuildClientAssociationsCacheRoute.ts b/server/routers/client/rebuildClientAssociationsCacheRoute.ts index 32a6a407ad..86cb5c4855 100644 --- a/server/routers/client/rebuildClientAssociationsCacheRoute.ts +++ b/server/routers/client/rebuildClientAssociationsCacheRoute.ts @@ -60,13 +60,17 @@ export async function rebuildClientAssociationsCacheRoute( ); } - await rebuildClientAssociationsFromClient(client); + rebuildClientAssociationsFromClient(client).catch((e) => { + logger.error( + `Failed to rebuild client associations for client ${clientId}: ${e}` + ); + }); return response(res, { data: null, success: true, error: false, - message: "Client association cache rebuilt successfully", + message: "Client association cache queued successfully", status: HttpCode.OK }); } catch (error) { diff --git a/server/routers/olm/deleteUserOlm.ts b/server/routers/olm/deleteUserOlm.ts index 861a413d8e..addef32d9c 100644 --- a/server/routers/olm/deleteUserOlm.ts +++ b/server/routers/olm/deleteUserOlm.ts @@ -86,13 +86,11 @@ export async function deleteUserOlm( }); if (deletedClient) { - rebuildClientAssociationsFromClient(deletedClient, primaryDb).catch( - (e) => { - logger.error( - `Failed to rebuild client-site associations after deleting OLM ${olmId}: ${e}` - ); - } - ); + rebuildClientAssociationsFromClient(deletedClient).catch((e) => { + logger.error( + `Failed to rebuild client-site associations after deleting OLM ${olmId}: ${e}` + ); + }); sendTerminateClient( deletedClient.clientId, OlmErrorCodes.TERMINATED_DELETED, diff --git a/server/routers/siteResource/batchAddClientToSiteResources.ts b/server/routers/siteResource/batchAddClientToSiteResources.ts index c8a8c90a67..1ebb3359d3 100644 --- a/server/routers/siteResource/batchAddClientToSiteResources.ts +++ b/server/routers/siteResource/batchAddClientToSiteResources.ts @@ -235,7 +235,7 @@ export async function batchAddClientToSiteResources( } }); - rebuildClientAssociationsFromClient(client, primaryDb).catch((e) => { + rebuildClientAssociationsFromClient(client).catch((e) => { logger.error( `Failed to rebuild client associations after batch adding site resources for client ${clientId}: ${e}` ); diff --git a/server/routers/user/addUserRoleLegacy.ts b/server/routers/user/addUserRoleLegacy.ts index bef69387ac..b3ff55a06e 100644 --- a/server/routers/user/addUserRoleLegacy.ts +++ b/server/routers/user/addUserRoleLegacy.ts @@ -159,13 +159,11 @@ export async function addUserRoleLegacy( }); for (const orgClient of orgClientsToRebuild) { - rebuildClientAssociationsFromClient(orgClient, primaryDb).catch( - (e) => { - logger.error( - `Failed to rebuild client associations for client ${orgClient.clientId} after adding role: ${e}` - ); - } - ); + rebuildClientAssociationsFromClient(orgClient).catch((e) => { + logger.error( + `Failed to rebuild client associations for client ${orgClient.clientId} after adding role: ${e}` + ); + }); } return response(res, { From 7731849a2f679f75c80c9f569882574b3c6c48fd Mon Sep 17 00:00:00 2001 From: Owen Date: Tue, 23 Jun 2026 17:14:29 -0400 Subject: [PATCH 52/88] Standardize db rebuildClientAssociationsFromClient --- server/lib/blueprints/applyBlueprint.ts | 195 +----- server/lib/rebuildClientAssociations.ts | 9 +- .../siteResource/addClientToSiteResource.ts | 6 +- .../siteResource/addRoleToSiteResource.ts | 6 +- .../siteResource/addUserToSiteResource.ts | 6 +- .../siteResource/createSiteResource.ts | 17 +- .../siteResource/deleteSiteResource.ts | 17 +- .../removeClientFromSiteResource.ts | 6 +- .../removeRoleFromSiteResource.ts | 6 +- .../removeUserFromSiteResource.ts | 6 +- .../siteResource/setSiteResourceClients.ts | 6 +- .../siteResource/setSiteResourceRoles.ts | 6 +- .../siteResource/setSiteResourceUsers.ts | 7 +- .../siteResource/updateSiteResource.ts | 611 +++++++----------- 14 files changed, 326 insertions(+), 578 deletions(-) diff --git a/server/lib/blueprints/applyBlueprint.ts b/server/lib/blueprints/applyBlueprint.ts index ab095646ee..fbd6f3fb0a 100644 --- a/server/lib/blueprints/applyBlueprint.ts +++ b/server/lib/blueprints/applyBlueprint.ts @@ -3,7 +3,6 @@ import { newts, blueprints, Blueprint, - Site, siteResources, roleSiteResources, userSiteResources, @@ -60,30 +59,26 @@ export async function applyBlueprint({ const config: Config = validationResult.data; - let proxyResourcesResults: PublicResourcesResults = []; - let clientResourcesResults: ClientResourcesResults = []; + 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 +131,38 @@ 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 - ) - ) - .then((rows) => rows.map((row) => row.roleId)); - - const existingUserIds = await trx - .select() - .from(userSiteResources) - .where( - eq( - userSiteResources.siteResourceId, - result.oldSiteResource.siteResourceId - ) - ) - .then((rows) => rows.map((row) => row.userId)); - - const existingClientIds = await trx - .select() - .from(clientSiteResources) - .where( - eq( - clientSiteResources.siteResourceId, - result.oldSiteResource.siteResourceId - ) - ) - .then((rows) => rows.map((row) => row.clientId)); - - // delete the existing site resource - await trx - .delete(siteResources) - .where( - and( - eq( - siteResources.siteResourceId, - result.oldSiteResource.siteResourceId - ) - ) - ); - - await rebuildClientAssociationsFromSiteResource( - result.oldSiteResource, - trx + for (const result of privateResourcesResults) { + rebuildClientAssociationsFromSiteResource( + result.newSiteResource + ).catch((e) => { + logger.error( + `Failed to rebuild client associations for site resource ${result.newSiteResource.siteResourceId}. Error: ${e}` ); - - 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 + }); + + handleMessagingForUpdatedSiteResource( + result.oldSiteResource, + result.newSiteResource, + result.oldSites.map((site) => ({ + // only need to run this on the old sites because the new sites are added above + siteId: site.siteId, + orgId: result.newSiteResource.orgId + })) + ).catch((err) => { + logger.error( + `Error handling messaging for updated site resource ${result.newSiteResource.siteResourceId}:`, + err ); - } - - // await addClientTargets( - // site.newt.newtId, - // result.resource.destination, - // result.resource.destinationPort, - // result.resource.protocol, - // result.resource.proxyPort - // ); + }); } + + logger.debug( + `Successfully updated private resources for org ${orgId}: ${JSON.stringify(privateResourcesResults)}` + ); }); blueprintSucceeded = true; diff --git a/server/lib/rebuildClientAssociations.ts b/server/lib/rebuildClientAssociations.ts index 83a03fa70d..42d6ff0b97 100644 --- a/server/lib/rebuildClientAssociations.ts +++ b/server/lib/rebuildClientAssociations.ts @@ -160,9 +160,9 @@ export async function getClientSiteResourceAccess( } export async function rebuildClientAssociationsFromSiteResource( - siteResource: SiteResource, - trx: Transaction | typeof db = db + siteResource: SiteResource ) { + const trx = primaryDb; try { return await lockManager.withLock( `rebuild-client-associations:site-resource:${siteResource.siteResourceId}`, @@ -2119,10 +2119,7 @@ export function startRebuildQueueProcessor(): void { return; } - await rebuildClientAssociationsFromSiteResource( - siteResource, - primaryDb - ); + await rebuildClientAssociationsFromSiteResource(siteResource); }, onClient: async (clientId: number) => { const [client] = await primaryDb diff --git a/server/routers/siteResource/addClientToSiteResource.ts b/server/routers/siteResource/addClientToSiteResource.ts index c43b755b22..3b32385272 100644 --- a/server/routers/siteResource/addClientToSiteResource.ts +++ b/server/routers/siteResource/addClientToSiteResource.ts @@ -153,8 +153,12 @@ export async function addClientToSiteResource( clientId, siteResourceId }); + }); - await rebuildClientAssociationsFromSiteResource(siteResource, trx); + rebuildClientAssociationsFromSiteResource(siteResource).catch((e) => { + logger.error( + `Failed to rebuild client associations for site resource ${siteResourceId}. Error: ${e}` + ); }); return response(res, { diff --git a/server/routers/siteResource/addRoleToSiteResource.ts b/server/routers/siteResource/addRoleToSiteResource.ts index a7153b3e38..31220df14d 100644 --- a/server/routers/siteResource/addRoleToSiteResource.ts +++ b/server/routers/siteResource/addRoleToSiteResource.ts @@ -160,8 +160,12 @@ export async function addRoleToSiteResource( roleId, siteResourceId }); + }); - await rebuildClientAssociationsFromSiteResource(siteResource, trx); + rebuildClientAssociationsFromSiteResource(siteResource).catch((e) => { + logger.error( + `Failed to rebuild client associations for site resource ${siteResourceId}. Error: ${e}` + ); }); return response(res, { diff --git a/server/routers/siteResource/addUserToSiteResource.ts b/server/routers/siteResource/addUserToSiteResource.ts index 6300502af8..51a7f980f5 100644 --- a/server/routers/siteResource/addUserToSiteResource.ts +++ b/server/routers/siteResource/addUserToSiteResource.ts @@ -129,8 +129,12 @@ export async function addUserToSiteResource( userId, siteResourceId }); + }); - await rebuildClientAssociationsFromSiteResource(siteResource, trx); + rebuildClientAssociationsFromSiteResource(siteResource).catch((e) => { + logger.error( + `Failed to rebuild client associations for site resource ${siteResourceId}. Error: ${e}` + ); }); return response(res, { diff --git a/server/routers/siteResource/createSiteResource.ts b/server/routers/siteResource/createSiteResource.ts index 1eebbc01de..d0d018f84b 100644 --- a/server/routers/siteResource/createSiteResource.ts +++ b/server/routers/siteResource/createSiteResource.ts @@ -625,15 +625,14 @@ export async function createSiteResource( // own transaction so it always executes on the primary — avoiding any // replica-lag issues while still allowing the HTTP response to return // early. - rebuildClientAssociationsFromSiteResource( - newSiteResource!, - primaryDb - ).catch((err) => { - logger.error( - `Error rebuilding client associations for site resource ${newSiteResource!.siteResourceId}:`, - err - ); - }); + rebuildClientAssociationsFromSiteResource(newSiteResource!).catch( + (err) => { + logger.error( + `Error rebuilding client associations for site resource ${newSiteResource!.siteResourceId}:`, + err + ); + } + ); return response(res, { data: newSiteResource, diff --git a/server/routers/siteResource/deleteSiteResource.ts b/server/routers/siteResource/deleteSiteResource.ts index 8ff23405c5..b9efc5ba80 100644 --- a/server/routers/siteResource/deleteSiteResource.ts +++ b/server/routers/siteResource/deleteSiteResource.ts @@ -88,15 +88,14 @@ export async function deleteSiteResource( // own transaction so it always executes on the primary — avoiding any // replica-lag issues while still allowing the HTTP response to return // early. - rebuildClientAssociationsFromSiteResource( - removedSiteResource, - primaryDb - ).catch((err) => { - logger.error( - `Error rebuilding client associations for site resource ${removedSiteResource!.siteResourceId}:`, - err - ); - }); + rebuildClientAssociationsFromSiteResource(removedSiteResource).catch( + (err) => { + logger.error( + `Error rebuilding client associations for site resource ${removedSiteResource!.siteResourceId}:`, + err + ); + } + ); logger.info(`Deleted site resource ${siteResourceId}`); diff --git a/server/routers/siteResource/removeClientFromSiteResource.ts b/server/routers/siteResource/removeClientFromSiteResource.ts index 35944ca15d..53cac4d933 100644 --- a/server/routers/siteResource/removeClientFromSiteResource.ts +++ b/server/routers/siteResource/removeClientFromSiteResource.ts @@ -157,8 +157,12 @@ export async function removeClientFromSiteResource( eq(clientSiteResources.clientId, clientId) ) ); + }); - await rebuildClientAssociationsFromSiteResource(siteResource, trx); + rebuildClientAssociationsFromSiteResource(siteResource).catch((e) => { + logger.error( + `Failed to rebuild client associations for site resource ${siteResourceId}. Error: ${e}` + ); }); return response(res, { diff --git a/server/routers/siteResource/removeRoleFromSiteResource.ts b/server/routers/siteResource/removeRoleFromSiteResource.ts index 2759a57e7b..2904edfaa7 100644 --- a/server/routers/siteResource/removeRoleFromSiteResource.ts +++ b/server/routers/siteResource/removeRoleFromSiteResource.ts @@ -165,8 +165,12 @@ export async function removeRoleFromSiteResource( eq(roleSiteResources.roleId, roleId) ) ); + }); - await rebuildClientAssociationsFromSiteResource(siteResource, trx); + rebuildClientAssociationsFromSiteResource(siteResource).catch((e) => { + logger.error( + `Failed to rebuild client associations for site resource ${siteResourceId}. Error: ${e}` + ); }); return response(res, { diff --git a/server/routers/siteResource/removeUserFromSiteResource.ts b/server/routers/siteResource/removeUserFromSiteResource.ts index 473db41b53..c7b79cd2eb 100644 --- a/server/routers/siteResource/removeUserFromSiteResource.ts +++ b/server/routers/siteResource/removeUserFromSiteResource.ts @@ -135,8 +135,12 @@ export async function removeUserFromSiteResource( eq(userSiteResources.userId, userId) ) ); + }); - await rebuildClientAssociationsFromSiteResource(siteResource, trx); + rebuildClientAssociationsFromSiteResource(siteResource).catch((e) => { + logger.error( + `Failed to rebuild client associations for site resource ${siteResourceId} after removing user ${userId}: ${e}` + ); }); return response(res, { diff --git a/server/routers/siteResource/setSiteResourceClients.ts b/server/routers/siteResource/setSiteResourceClients.ts index 0f88f363fd..a4bc5b69e3 100644 --- a/server/routers/siteResource/setSiteResourceClients.ts +++ b/server/routers/siteResource/setSiteResourceClients.ts @@ -141,8 +141,12 @@ export async function setSiteResourceClients( })) ); } + }); - await rebuildClientAssociationsFromSiteResource(siteResource, trx); + rebuildClientAssociationsFromSiteResource(siteResource).catch((e) => { + logger.error( + `Failed to rebuild client associations for site resource ${siteResourceId}. Error: ${e}` + ); }); return response(res, { diff --git a/server/routers/siteResource/setSiteResourceRoles.ts b/server/routers/siteResource/setSiteResourceRoles.ts index e9878a320f..cad6da53b5 100644 --- a/server/routers/siteResource/setSiteResourceRoles.ts +++ b/server/routers/siteResource/setSiteResourceRoles.ts @@ -165,8 +165,12 @@ export async function setSiteResourceRoles( roleIds.map((roleId) => ({ roleId, siteResourceId })) ); } + }); - await rebuildClientAssociationsFromSiteResource(siteResource, trx); + rebuildClientAssociationsFromSiteResource(siteResource).catch((e) => { + logger.error( + `Failed to rebuild client associations for site resource ${siteResourceId}. Error: ${e}` + ); }); return response(res, { diff --git a/server/routers/siteResource/setSiteResourceUsers.ts b/server/routers/siteResource/setSiteResourceUsers.ts index 4fa6f2218e..cde5b4e66a 100644 --- a/server/routers/siteResource/setSiteResourceUsers.ts +++ b/server/routers/siteResource/setSiteResourceUsers.ts @@ -10,6 +10,7 @@ import { fromError } from "zod-validation-error"; import { eq } from "drizzle-orm"; import { OpenAPITags, registry } from "@server/openApi"; import { rebuildClientAssociationsFromSiteResource } from "@server/lib/rebuildClientAssociations"; +import { error } from "node:console"; const setSiteResourceUsersBodySchema = z .object({ @@ -120,8 +121,12 @@ export async function setSiteResourceUsers( userIds.map((userId) => ({ userId, siteResourceId })) ); } + }); - await rebuildClientAssociationsFromSiteResource(siteResource, trx); + rebuildClientAssociationsFromSiteResource(siteResource).catch((e) => { + logger.error( + `Failed to rebuild client associations for site resource ${siteResourceId}. Error: ${e}` + ); }); return response(res, { diff --git a/server/routers/siteResource/updateSiteResource.ts b/server/routers/siteResource/updateSiteResource.ts index 3f271d2f97..af185c50c1 100644 --- a/server/routers/siteResource/updateSiteResource.ts +++ b/server/routers/siteResource/updateSiteResource.ts @@ -12,7 +12,8 @@ import { sites, networks, Transaction, - userSiteResources + userSiteResources, + primaryDb } from "@server/db"; import { isLicensedOrSubscribed } from "#dynamic/lib/isLicencedOrSubscribed"; import { TierFeature, tierMatrix } from "@server/lib/billing/tierMatrix"; @@ -474,345 +475,167 @@ export async function updateSiteResource( let updatedSiteResource: SiteResource | undefined; await db.transaction(async (trx) => { - // if the site is changed we need to delete and recreate the resource to avoid complications with the rebuild function otherwise we can just update in place - if (sitesChanged) { - // delete the existing site resource - await trx - .delete(siteResources) - .where( - and(eq(siteResources.siteResourceId, siteResourceId)) - ); + // Update the site resource + const sshPamSet = + isLicensedSshPam && + (authDaemonPort !== undefined || + authDaemonMode !== undefined || + pamMode !== undefined) + ? { + ...(authDaemonPort !== undefined && { + authDaemonPort + }), + ...(authDaemonMode !== undefined && { + authDaemonMode + }), + ...(pamMode !== undefined && { + pamMode + }) + } + : {}; + let tcpPortRangeStringAdjusted = tcpPortRangeString; + if (mode === "http") { + tcpPortRangeStringAdjusted = "443,80"; + } else if (mode === "ssh") { + tcpPortRangeStringAdjusted = destinationPort + ? destinationPort.toString() + : "22"; + } - await rebuildClientAssociationsFromSiteResource( - existingSiteResource, - trx + [updatedSiteResource] = await trx + .update(siteResources) + .set({ + name: name, + niceId: niceId, + mode: mode, + scheme, + ssl, + destination: destination, + destinationPort: destinationPort, + enabled: enabled, + alias: alias ? alias.trim() : null, + tcpPortRangeString: tcpPortRangeStringAdjusted, + udpPortRangeString: + mode == "http" || mode == "ssh" + ? "" + : udpPortRangeString, + disableIcmp: + disableIcmp || + (mode == "http" || mode == "ssh" ? true : false), + domainId, + subdomain: finalSubdomain, + fullDomain, + ...sshPamSet + }) + .where(and(eq(siteResources.siteResourceId, siteResourceId))) + .returning(); + + //////////////////// update the associations //////////////////// + + // delete the site - site resources associations + await trx + .delete(siteNetworks) + .where( + eq(siteNetworks.networkId, updatedSiteResource.networkId!) ); - // create the new site resource from the removed one - the ID should stay the same - const [insertedSiteResource] = await trx - .insert(siteResources) - .values({ - ...existingSiteResource - }) - .returning(); - - const sshPamSet = - isLicensedSshPam && - (authDaemonPort !== undefined || - authDaemonMode !== undefined || - pamMode !== undefined) - ? { - ...(authDaemonPort !== undefined && { - authDaemonPort - }), - ...(authDaemonMode !== undefined && { - authDaemonMode - }), - ...(pamMode !== undefined && { - pamMode - }) - } - : {}; - - let tcpPortRangeStringAdjusted = tcpPortRangeString; - if (mode === "http") { - tcpPortRangeStringAdjusted = "443,80"; - } else if (mode === "ssh") { - tcpPortRangeStringAdjusted = destinationPort - ? destinationPort.toString() - : "22"; - } - - [updatedSiteResource] = await trx - .update(siteResources) - .set({ - name, - niceId, - mode, - scheme, - ssl, - destination, - destinationPort, - enabled, - alias: alias ? alias.trim() : null, - tcpPortRangeString: tcpPortRangeStringAdjusted, - udpPortRangeString: - mode == "http" || mode == "ssh" - ? "" - : udpPortRangeString, - disableIcmp: - disableIcmp || - (mode == "http" || mode == "ssh" ? true : false), // default to true for http resources, otherwise false - domainId, - subdomain: finalSubdomain, - fullDomain, - ...sshPamSet - }) - .where( - and( - eq( - siteResources.siteResourceId, - insertedSiteResource.siteResourceId - ) - ) - ) - .returning(); + for (const siteId of siteIds) { + await trx.insert(siteNetworks).values({ + siteId: siteId, + networkId: updatedSiteResource.networkId! + }); + } - if (!updatedSiteResource) { - throw new Error( - "Failed to create updated site resource after site change" - ); - } + await trx + .delete(clientSiteResources) + .where(eq(clientSiteResources.siteResourceId, siteResourceId)); - //////////////////// update the associations //////////////////// + if (clientIds.length > 0) { + await trx.insert(clientSiteResources).values( + clientIds.map((clientId) => ({ + clientId, + siteResourceId + })) + ); + } - // delete the site - site resources associations - await trx - .delete(siteNetworks) - .where( - eq( - siteNetworks.networkId, - updatedSiteResource.networkId! - ) - ); + await trx + .delete(userSiteResources) + .where(eq(userSiteResources.siteResourceId, siteResourceId)); - for (const siteId of siteIds) { - await trx.insert(siteNetworks).values({ - siteId: siteId, - networkId: updatedSiteResource.networkId! - }); - } + if (userIds.length > 0) { + await trx.insert(userSiteResources).values( + userIds.map((userId) => ({ + userId, + siteResourceId + })) + ); + } - const [adminRole] = await trx - .select() - .from(roles) - .where( - and( - eq(roles.isAdmin, true), - eq(roles.orgId, updatedSiteResource.orgId) - ) + // Get all admin role IDs for this org to exclude from deletion + const adminRoles = await trx + .select() + .from(roles) + .where( + and( + eq(roles.isAdmin, true), + eq(roles.orgId, updatedSiteResource.orgId) ) - .limit(1); - - if (!adminRole) { - return next( - createHttpError( - HttpCode.NOT_FOUND, - `Admin role not found` - ) - ); - } - - await trx.insert(roleSiteResources).values({ - roleId: adminRole.roleId, - siteResourceId: updatedSiteResource.siteResourceId - }); - - if (roleIds.length > 0) { - await trx.insert(roleSiteResources).values( - roleIds.map((roleId) => ({ - roleId, - siteResourceId: updatedSiteResource!.siteResourceId - })) - ); - } - - if (userIds.length > 0) { - await trx.insert(userSiteResources).values( - userIds.map((userId) => ({ - userId, - siteResourceId: updatedSiteResource!.siteResourceId - })) - ); - } - - if (clientIds.length > 0) { - await trx.insert(clientSiteResources).values( - clientIds.map((clientId) => ({ - clientId, - siteResourceId: updatedSiteResource!.siteResourceId - })) - ); - } - } else { - // Update the site resource - const sshPamSet = - isLicensedSshPam && - (authDaemonPort !== undefined || - authDaemonMode !== undefined || - pamMode !== undefined) - ? { - ...(authDaemonPort !== undefined && { - authDaemonPort - }), - ...(authDaemonMode !== undefined && { - authDaemonMode - }), - ...(pamMode !== undefined && { - pamMode - }) - } - : {}; - let tcpPortRangeStringAdjusted = tcpPortRangeString; - if (mode === "http") { - tcpPortRangeStringAdjusted = "443,80"; - } else if (mode === "ssh") { - tcpPortRangeStringAdjusted = destinationPort - ? destinationPort.toString() - : "22"; - } + ); + const adminRoleIds = adminRoles.map((role) => role.roleId); - [updatedSiteResource] = await trx - .update(siteResources) - .set({ - name: name, - niceId: niceId, - mode: mode, - scheme, - ssl, - destination: destination, - destinationPort: destinationPort, - enabled: enabled, - alias: alias ? alias.trim() : null, - tcpPortRangeString: tcpPortRangeStringAdjusted, - udpPortRangeString: - mode == "http" || mode == "ssh" - ? "" - : udpPortRangeString, - disableIcmp: - disableIcmp || - (mode == "http" || mode == "ssh" ? true : false), - domainId, - subdomain: finalSubdomain, - fullDomain, - ...sshPamSet - }) - .where( - and(eq(siteResources.siteResourceId, siteResourceId)) + if (adminRoleIds.length > 0) { + await trx.delete(roleSiteResources).where( + and( + eq(roleSiteResources.siteResourceId, siteResourceId), + ne(roleSiteResources.roleId, adminRoleIds[0]) // delete all but the admin role ) - .returning(); - - //////////////////// update the associations //////////////////// - - // delete the site - site resources associations - await trx - .delete(siteNetworks) - .where( - eq( - siteNetworks.networkId, - updatedSiteResource.networkId! - ) - ); - - for (const siteId of siteIds) { - await trx.insert(siteNetworks).values({ - siteId: siteId, - networkId: updatedSiteResource.networkId! - }); - } - - await trx - .delete(clientSiteResources) - .where( - eq(clientSiteResources.siteResourceId, siteResourceId) - ); - - if (clientIds.length > 0) { - await trx.insert(clientSiteResources).values( - clientIds.map((clientId) => ({ - clientId, - siteResourceId - })) - ); - } - + ); + } else { await trx - .delete(userSiteResources) + .delete(roleSiteResources) .where( - eq(userSiteResources.siteResourceId, siteResourceId) - ); - - if (userIds.length > 0) { - await trx.insert(userSiteResources).values( - userIds.map((userId) => ({ - userId, - siteResourceId - })) + eq(roleSiteResources.siteResourceId, siteResourceId) ); - } - - // Get all admin role IDs for this org to exclude from deletion - const adminRoles = await trx - .select() - .from(roles) - .where( - and( - eq(roles.isAdmin, true), - eq(roles.orgId, updatedSiteResource.orgId) - ) - ); - const adminRoleIds = adminRoles.map((role) => role.roleId); - - if (adminRoleIds.length > 0) { - await trx.delete(roleSiteResources).where( - and( - eq( - roleSiteResources.siteResourceId, - siteResourceId - ), - ne(roleSiteResources.roleId, adminRoleIds[0]) // delete all but the admin role - ) - ); - } else { - await trx - .delete(roleSiteResources) - .where( - eq(roleSiteResources.siteResourceId, siteResourceId) - ); - } - - if (roleIds.length > 0) { - await trx.insert(roleSiteResources).values( - roleIds.map((roleId) => ({ - roleId, - siteResourceId - })) - ); - } + } - logger.info(`Updated site resource ${siteResourceId}`); + if (roleIds.length > 0) { + await trx.insert(roleSiteResources).values( + roleIds.map((roleId) => ({ + roleId, + siteResourceId + })) + ); } + + logger.info(`Updated site resource ${siteResourceId}`); }); - // Background: wait for removal messages to propagate, then rebuild - // associations for the re-created resource. Own transaction ensures - // execution on the primary against fully committed state. - (async () => { - await db.transaction(async (trx) => { - if (!updatedSiteResource) { - throw new Error("No updated resource found after update"); - } - if (sitesChanged) { - await new Promise((resolve) => setTimeout(resolve, 750)); - await rebuildClientAssociationsFromSiteResource( - updatedSiteResource, - trx - ); - } - await handleMessagingForUpdatedSiteResource( - existingSiteResource, - updatedSiteResource, - siteIds.map((siteId) => ({ - siteId, - orgId: existingSiteResource.orgId - })), - trx + if (!updatedSiteResource) { + throw new Error("No updated resource found after update"); + } + + if (sitesChanged) { + rebuildClientAssociationsFromSiteResource( + updatedSiteResource + ).catch((e) => { + logger.error( + `Failed to rebuild client associations for site resource ${siteResourceId}. Error: ${e}` ); }); - })().catch((err) => { + } + + handleMessagingForUpdatedSiteResource( + existingSiteResource, + updatedSiteResource, + Array.from(existingSiteIdSet).map((siteId: number) => ({ + // we already added to the new sites above in the rebuild function so we only need to update the ones that did not change + siteId, + orgId: existingSiteResource.orgId + })) + ).catch((e) => { logger.error( - `Error rebuilding client associations for site resource ${updatedSiteResource?.siteResourceId}:`, - err + `Failed to handle messaging for updated site resource ${siteResourceId}. Error: ${e}` ); }); @@ -837,9 +660,9 @@ export async function updateSiteResource( export async function handleMessagingForUpdatedSiteResource( existingSiteResource: SiteResource | undefined, updatedSiteResource: SiteResource, - sites: { siteId: number; orgId: string }[], - trx: Transaction + sites: { siteId: number; orgId: string }[] ) { + const trx = primaryDb; logger.debug( "handleMessagingForUpdatedSiteResource: existingSiteResource is: ", existingSiteResource @@ -849,17 +672,14 @@ export async function handleMessagingForUpdatedSiteResource( updatedSiteResource ); - await rebuildClientAssociationsFromSiteResource( - existingSiteResource || updatedSiteResource, // we want to rebuild based on the existing resource then we will apply the change to the destination below - trx - ); - const { sitesList, mergedAllClients, mergedAllClientIds } = await getClientSiteResourceAccess( existingSiteResource || updatedSiteResource, trx ); + const siteIds = sites.map((site) => site.siteId); + // after everything is rebuilt above we still need to update the targets and remote subnets if the destination changed const destinationChanged = existingSiteResource && @@ -896,12 +716,86 @@ export async function handleMessagingForUpdatedSiteResource( portRangesChanged || destinationPortChanged ) { + const newtsForSites = + siteIds.length > 0 + ? await trx + .select() + .from(newts) + .where(inArray(newts.siteId, siteIds)) + : []; + const newtBySiteId = new Map( + newtsForSites.map((newt) => [newt.siteId, newt]) + ); + + const oldDestinationStillInUseClientSitePairs = new Set(); + if ( + existingSiteResource?.destination && + siteIds.length > 0 && + mergedAllClientIds.length > 0 + ) { + const oldDestinationStillInUseRows = await trx + .select({ + clientId: clientSiteResourcesAssociationsCache.clientId, + siteId: siteNetworks.siteId + }) + .from(siteResources) + .innerJoin( + clientSiteResourcesAssociationsCache, + eq( + clientSiteResourcesAssociationsCache.siteResourceId, + siteResources.siteResourceId + ) + ) + .innerJoin( + siteNetworks, + eq(siteNetworks.networkId, siteResources.networkId) + ) + .where( + and( + inArray( + clientSiteResourcesAssociationsCache.clientId, + mergedAllClientIds + ), + inArray(siteNetworks.siteId, siteIds), + eq( + siteResources.destination, + existingSiteResource.destination + ), + ne( + siteResources.siteResourceId, + existingSiteResource.siteResourceId + ) + ) + ); + + for (const row of oldDestinationStillInUseRows) { + oldDestinationStillInUseClientSitePairs.add( + `${row.clientId}:${row.siteId}` + ); + } + } + + const shouldUpdateTargets = + destinationChanged || + sslChanged || + portRangesChanged || + fullDomainChanged || + destinationPortChanged; + const oldTargets = shouldUpdateTargets + ? await generateSubnetProxyTargetV2( + existingSiteResource, + mergedAllClients + ) + : []; + const newTargets = shouldUpdateTargets + ? await generateSubnetProxyTargetV2( + updatedSiteResource, + mergedAllClients + ) + : []; + for (const site of sites) { - const [newt] = await trx - .select() - .from(newts) - .where(eq(newts.siteId, site.siteId)) - .limit(1); + const newt = newtBySiteId.get(site.siteId); if (!newt) { throw new Error( @@ -910,22 +804,7 @@ export async function handleMessagingForUpdatedSiteResource( } // Only update targets on newt if these items change - if ( - destinationChanged || - sslChanged || // we need to push a new cert if the ssl changed - portRangesChanged || - fullDomainChanged || // if the domain changes we need to update the certs and stuff - destinationPortChanged - ) { - const oldTargets = await generateSubnetProxyTargetV2( - existingSiteResource, - mergedAllClients - ); - const newTargets = await generateSubnetProxyTargetV2( - updatedSiteResource, - mergedAllClients - ); - + if (shouldUpdateTargets) { await updateTargets( newt.newtId, { @@ -939,49 +818,19 @@ export async function handleMessagingForUpdatedSiteResource( const olmJobs: Promise[] = []; for (const client of mergedAllClients) { // does this client have access to another resource on this site that has the same destination still? if so we dont want to remove it from their olm yet - // todo: optimize this query if needed if (!existingSiteResource.destination) { continue; } - const oldDestinationStillInUseSites = await trx - .select() - .from(siteResources) - .innerJoin( - clientSiteResourcesAssociationsCache, - eq( - clientSiteResourcesAssociationsCache.siteResourceId, - siteResources.siteResourceId - ) - ) - .innerJoin( - siteNetworks, - eq(siteNetworks.networkId, siteResources.networkId) - ) - .where( - and( - eq( - clientSiteResourcesAssociationsCache.clientId, - client.clientId - ), - eq(siteNetworks.siteId, site.siteId), - eq( - siteResources.destination, - existingSiteResource.destination - ), - ne( - siteResources.siteResourceId, - existingSiteResource.siteResourceId - ) - ) - ); - const oldDestinationStillInUseByASite = - oldDestinationStillInUseSites.length > 0; + oldDestinationStillInUseClientSitePairs.has( + `${client.clientId}:${site.siteId}` + ); // we also need to update the remote subnets on the olms for each client that has access to this site olmJobs.push( updatePeerData( + // TODO: THIS SHOULD BE UPDATED TO WORK I A BATCH client.clientId, site.siteId, destinationChanged From e1044892575e1b1a3be34ecbe287d8fb77cf3dcd Mon Sep 17 00:00:00 2001 From: Fred KISSIE Date: Tue, 23 Jun 2026 23:28:46 +0200 Subject: [PATCH 53/88] =?UTF-8?q?=F0=9F=92=84=20Show=20GeoIp=20flag=20in?= =?UTF-8?q?=20site=20details=20page?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- server/routers/site/getSite.ts | 7 ++++++- src/components/SiteInfoCard.tsx | 7 ++++++- 2 files changed, 12 insertions(+), 2 deletions(-) diff --git a/server/routers/site/getSite.ts b/server/routers/site/getSite.ts index a671a47f95..885e3aa7a4 100644 --- a/server/routers/site/getSite.ts +++ b/server/routers/site/getSite.ts @@ -10,6 +10,7 @@ import logger from "@server/logger"; import stoi from "@server/lib/stoi"; import { fromError } from "zod-validation-error"; import { OpenAPITags, registry } from "@server/openApi"; +import { getCountryCodeForIp } from "@server/lib/geoip"; const getSiteSchema = z.strictObject({ siteId: z @@ -47,6 +48,7 @@ type SiteQueryRow = NonNullable>>; export type GetSiteResponse = SiteQueryRow["sites"] & { newtId: string | null; newtVersion: string | null; + countryCode: string | null; }; registry.registerPath({ @@ -134,7 +136,10 @@ export async function getSite( const data: GetSiteResponse = { ...site.sites, newtId: site.newt ? site.newt.newtId : null, - newtVersion: site.newt?.version ?? null + newtVersion: site.newt?.version ?? null, + countryCode: site.sites.endpoint + ? ((await getCountryCodeForIp(site.sites.endpoint)) ?? null) + : null }; return response(res, { diff --git a/src/components/SiteInfoCard.tsx b/src/components/SiteInfoCard.tsx index 21697d6973..a5e639c289 100644 --- a/src/components/SiteInfoCard.tsx +++ b/src/components/SiteInfoCard.tsx @@ -9,6 +9,7 @@ import { InfoSectionTitle } from "@app/components/InfoSection"; import { useTranslations } from "next-intl"; +import { countryCodeToFlagEmoji } from "@app/lib/countryCodeToFlagEmoji"; type SiteInfoCardProps = {}; @@ -52,7 +53,11 @@ export default function SiteInfoCard({}: SiteInfoCardProps) { {t("publicIpEndpoint")} - {formatPublicEndpoint(site.endpoint)} + {formatPublicEndpoint(site.endpoint)}  + + {site.countryCode && + countryCodeToFlagEmoji(site.countryCode)} + ) : null; From 91ef0d01531a7e4e78b7c6ad511ff5865179adef Mon Sep 17 00:00:00 2001 From: Owen Date: Tue, 23 Jun 2026 17:44:18 -0400 Subject: [PATCH 54/88] Show warning about the .local aliases --- messages/en-US.json | 1 + src/components/PrivateResourceForm.tsx | 11 +++++++++++ 2 files changed, 12 insertions(+) diff --git a/messages/en-US.json b/messages/en-US.json index 584a43d794..9d8a7fbdbb 100644 --- a/messages/en-US.json +++ b/messages/en-US.json @@ -2338,6 +2338,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", diff --git a/src/components/PrivateResourceForm.tsx b/src/components/PrivateResourceForm.tsx index 6082fd20eb..e25df64691 100644 --- a/src/components/PrivateResourceForm.tsx +++ b/src/components/PrivateResourceForm.tsx @@ -580,6 +580,7 @@ export function PrivateResourceForm({ }); const mode = form.watch("mode"); + const aliasValue = form.watch("alias"); const httpConfigSubdomain = form.watch("httpConfigSubdomain"); const httpConfigDomainId = form.watch("httpConfigDomainId"); const httpConfigFullDomain = form.watch("httpConfigFullDomain"); @@ -595,6 +596,9 @@ export function PrivateResourceForm({ !isNative && pamMode === "push" && authDaemonMode === "remote"; + const aliasEndsWithLocal = + typeof aliasValue === "string" && + aliasValue.trim().toLowerCase().endsWith(".local"); const hasInitialized = useRef(false); const previousResourceId = useRef(null); const initialSitesRef = useRef(initialSites); @@ -1209,6 +1213,13 @@ export function PrivateResourceForm({ } /> + {aliasEndsWithLocal && ( +

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

+ )} )} From 2a8ceeec1bfe00570ed58a773c7c6baa46b8f593 Mon Sep 17 00:00:00 2001 From: Owen Date: Tue, 23 Jun 2026 17:45:42 -0400 Subject: [PATCH 55/88] Restrict admin role --- src/components/PrivateResourceForm.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/src/components/PrivateResourceForm.tsx b/src/components/PrivateResourceForm.tsx index e25df64691..23fd8a7b24 100644 --- a/src/components/PrivateResourceForm.tsx +++ b/src/components/PrivateResourceForm.tsx @@ -1748,6 +1748,7 @@ export function PrivateResourceForm({ field.value ?? [] } orgId={orgId} + restrictAdminRole onSelectRoles={( newUsers ) => { From bb7729df00d2e52e3e92bbea466141be8d4ef642 Mon Sep 17 00:00:00 2001 From: Fred KISSIE Date: Tue, 23 Jun 2026 23:45:59 +0200 Subject: [PATCH 56/88] =?UTF-8?q?=F0=9F=92=84=20show=20geoip=20flag=20in?= =?UTF-8?q?=20policy=20access=20rule=20tab?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../PolicyAccessRulesTable.tsx | 49 ++++++++++++++----- 1 file changed, 37 insertions(+), 12 deletions(-) diff --git a/src/components/resource-policy/PolicyAccessRulesTable.tsx b/src/components/resource-policy/PolicyAccessRulesTable.tsx index a701b92ff9..b8445a44cd 100644 --- a/src/components/resource-policy/PolicyAccessRulesTable.tsx +++ b/src/components/resource-policy/PolicyAccessRulesTable.tsx @@ -74,6 +74,7 @@ import { sortPolicyRulesForResourceOverlay, type PolicyAccessRule } from "./policy-access-rule-utils"; +import { countryCodeToFlagEmoji } from "@app/lib/countryCodeToFlagEmoji"; export type PolicyAccessRulesTableProps = { rules: PolicyAccessRule[]; @@ -490,8 +491,17 @@ export function PolicyAccessRulesTable({ { accessorKey: "value", header: () => {t("value")}, - cell: ({ row }) => - row.original.match === "COUNTRY" ? ( + cell: ({ row }) => { + let selectedCountry: (typeof COUNTRIES)[number] | undefined; + if ( + row.original.match === "COUNTRY" && + row.original.value + ) { + selectedCountry = COUNTRIES.find( + (c) => c.code === row.original.value + ); + } + return row.original.match === "COUNTRY" ? ( @@ -540,6 +557,13 @@ export function PolicyAccessRulesTable({ + + {country.code === "ALL" + ? "🌍" + : countryCodeToFlagEmoji( + country.code + )} + {country.name} ( {country.code}) @@ -767,7 +791,8 @@ export function PolicyAccessRulesTable({ }); }} /> - ) + ); + } }, { accessorKey: "enabled", From bc63747efec6067931573f43e64f453c143329ae Mon Sep 17 00:00:00 2001 From: Owen Date: Tue, 23 Jun 2026 18:12:51 -0400 Subject: [PATCH 57/88] Refactor out transactions and always call rebuild on update --- .../siteResource/addClientToSiteResource.ts | 8 +++----- .../siteResource/addRoleToSiteResource.ts | 8 +++----- .../siteResource/addUserToSiteResource.ts | 8 +++----- .../removeClientFromSiteResource.ts | 18 ++++++++--------- .../removeRoleFromSiteResource.ts | 18 ++++++++--------- .../removeUserFromSiteResource.ts | 18 ++++++++--------- .../siteResource/updateSiteResource.ts | 20 ++++--------------- 7 files changed, 37 insertions(+), 61 deletions(-) diff --git a/server/routers/siteResource/addClientToSiteResource.ts b/server/routers/siteResource/addClientToSiteResource.ts index 3b32385272..4a6dd141ea 100644 --- a/server/routers/siteResource/addClientToSiteResource.ts +++ b/server/routers/siteResource/addClientToSiteResource.ts @@ -148,11 +148,9 @@ export async function addClientToSiteResource( ); } - await db.transaction(async (trx) => { - await trx.insert(clientSiteResources).values({ - clientId, - siteResourceId - }); + await db.insert(clientSiteResources).values({ + clientId, + siteResourceId }); rebuildClientAssociationsFromSiteResource(siteResource).catch((e) => { diff --git a/server/routers/siteResource/addRoleToSiteResource.ts b/server/routers/siteResource/addRoleToSiteResource.ts index 31220df14d..05186c3516 100644 --- a/server/routers/siteResource/addRoleToSiteResource.ts +++ b/server/routers/siteResource/addRoleToSiteResource.ts @@ -155,11 +155,9 @@ export async function addRoleToSiteResource( ); } - await db.transaction(async (trx) => { - await trx.insert(roleSiteResources).values({ - roleId, - siteResourceId - }); + await db.insert(roleSiteResources).values({ + roleId, + siteResourceId }); rebuildClientAssociationsFromSiteResource(siteResource).catch((e) => { diff --git a/server/routers/siteResource/addUserToSiteResource.ts b/server/routers/siteResource/addUserToSiteResource.ts index 51a7f980f5..c35357993e 100644 --- a/server/routers/siteResource/addUserToSiteResource.ts +++ b/server/routers/siteResource/addUserToSiteResource.ts @@ -124,11 +124,9 @@ export async function addUserToSiteResource( ); } - await db.transaction(async (trx) => { - await trx.insert(userSiteResources).values({ - userId, - siteResourceId - }); + await db.insert(userSiteResources).values({ + userId, + siteResourceId }); rebuildClientAssociationsFromSiteResource(siteResource).catch((e) => { diff --git a/server/routers/siteResource/removeClientFromSiteResource.ts b/server/routers/siteResource/removeClientFromSiteResource.ts index 53cac4d933..c53e214cd5 100644 --- a/server/routers/siteResource/removeClientFromSiteResource.ts +++ b/server/routers/siteResource/removeClientFromSiteResource.ts @@ -148,16 +148,14 @@ export async function removeClientFromSiteResource( ); } - await db.transaction(async (trx) => { - await trx - .delete(clientSiteResources) - .where( - and( - eq(clientSiteResources.siteResourceId, siteResourceId), - eq(clientSiteResources.clientId, clientId) - ) - ); - }); + await db + .delete(clientSiteResources) + .where( + and( + eq(clientSiteResources.siteResourceId, siteResourceId), + eq(clientSiteResources.clientId, clientId) + ) + ); rebuildClientAssociationsFromSiteResource(siteResource).catch((e) => { logger.error( diff --git a/server/routers/siteResource/removeRoleFromSiteResource.ts b/server/routers/siteResource/removeRoleFromSiteResource.ts index 2904edfaa7..6dd978e24e 100644 --- a/server/routers/siteResource/removeRoleFromSiteResource.ts +++ b/server/routers/siteResource/removeRoleFromSiteResource.ts @@ -156,16 +156,14 @@ export async function removeRoleFromSiteResource( ); } - await db.transaction(async (trx) => { - await trx - .delete(roleSiteResources) - .where( - and( - eq(roleSiteResources.siteResourceId, siteResourceId), - eq(roleSiteResources.roleId, roleId) - ) - ); - }); + await db + .delete(roleSiteResources) + .where( + and( + eq(roleSiteResources.siteResourceId, siteResourceId), + eq(roleSiteResources.roleId, roleId) + ) + ); rebuildClientAssociationsFromSiteResource(siteResource).catch((e) => { logger.error( diff --git a/server/routers/siteResource/removeUserFromSiteResource.ts b/server/routers/siteResource/removeUserFromSiteResource.ts index c7b79cd2eb..67e6ac9606 100644 --- a/server/routers/siteResource/removeUserFromSiteResource.ts +++ b/server/routers/siteResource/removeUserFromSiteResource.ts @@ -126,16 +126,14 @@ export async function removeUserFromSiteResource( ); } - await db.transaction(async (trx) => { - await trx - .delete(userSiteResources) - .where( - and( - eq(userSiteResources.siteResourceId, siteResourceId), - eq(userSiteResources.userId, userId) - ) - ); - }); + await db + .delete(userSiteResources) + .where( + and( + eq(userSiteResources.siteResourceId, siteResourceId), + eq(userSiteResources.userId, userId) + ) + ); rebuildClientAssociationsFromSiteResource(siteResource).catch((e) => { logger.error( diff --git a/server/routers/siteResource/updateSiteResource.ts b/server/routers/siteResource/updateSiteResource.ts index af185c50c1..9b79121fac 100644 --- a/server/routers/siteResource/updateSiteResource.ts +++ b/server/routers/siteResource/updateSiteResource.ts @@ -390,7 +390,6 @@ export async function updateSiteResource( ); } - let sitesChanged = false; const existingSiteIds = existingSiteResource.networkId ? await db .select() @@ -399,16 +398,7 @@ export async function updateSiteResource( eq(siteNetworks.networkId, existingSiteResource.networkId) ) : []; - const existingSiteIdSet = new Set(existingSiteIds.map((s) => s.siteId)); - const newSiteIdSet = new Set(siteIds); - - if ( - existingSiteIdSet.size !== newSiteIdSet.size || - ![...existingSiteIdSet].every((id) => newSiteIdSet.has(id)) - ) { - sitesChanged = true; - } let fullDomain: string | null = null; let finalSubdomain: string | null = null; @@ -615,15 +605,13 @@ export async function updateSiteResource( throw new Error("No updated resource found after update"); } - if (sitesChanged) { - rebuildClientAssociationsFromSiteResource( - updatedSiteResource - ).catch((e) => { + rebuildClientAssociationsFromSiteResource(updatedSiteResource).catch( + (e) => { logger.error( `Failed to rebuild client associations for site resource ${siteResourceId}. Error: ${e}` ); - }); - } + } + ); handleMessagingForUpdatedSiteResource( existingSiteResource, From 034bcbd2712cbb45737a563447f6a46f95487301 Mon Sep 17 00:00:00 2001 From: Owen Date: Wed, 24 Jun 2026 11:54:56 -0400 Subject: [PATCH 58/88] Reorg --- server/lib/rebuildClientAssociations.ts | 334 ++++++++++++++++-- server/routers/client/targets.ts | 64 ++++ .../siteResource/updateSiteResource.ts | 242 +------------ 3 files changed, 387 insertions(+), 253 deletions(-) diff --git a/server/lib/rebuildClientAssociations.ts b/server/lib/rebuildClientAssociations.ts index 42d6ff0b97..9e362fb512 100644 --- a/server/lib/rebuildClientAssociations.ts +++ b/server/lib/rebuildClientAssociations.ts @@ -38,7 +38,9 @@ import { addPeerDataBatch, addTargetsBatch as addSubnetProxyTargetsBatch, removePeerDataBatch, - removeTargetsBatch as removeSubnetProxyTargetsBatch + removeTargetsBatch as removeSubnetProxyTargetsBatch, + updatePeerDataBatch, + updateTargets } from "@server/routers/client/targets"; import { lockManager } from "#dynamic/lib/lock"; import { rebuildQueue } from "#dynamic/lib/rebuildQueue"; @@ -162,15 +164,10 @@ export async function getClientSiteResourceAccess( export async function rebuildClientAssociationsFromSiteResource( siteResource: SiteResource ) { - const trx = primaryDb; try { return await lockManager.withLock( `rebuild-client-associations:site-resource:${siteResource.siteResourceId}`, - () => - rebuildClientAssociationsFromSiteResourceImpl( - siteResource, - trx - ), + () => rebuildClientAssociationsFromSiteResourceImpl(siteResource), REBUILD_ASSOCIATIONS_LOCK_TTL_MS ); } catch (err: any) { @@ -192,15 +189,10 @@ export async function rebuildClientAssociationsFromSiteResource( } async function rebuildClientAssociationsFromSiteResourceImpl( - siteResource: SiteResource, - trx: Transaction | typeof db = db -): Promise<{ - mergedAllClients: { - clientId: number; - pubKey: string | null; - subnet: string | null; - }[]; -}> { + siteResource: SiteResource +) { + const trx = primaryDb; + logger.debug( `rebuildClientAssociations: [rebuildClientAssociationsFromSiteResource] START siteResourceId=${siteResource.siteResourceId} networkId=${siteResource.networkId} orgId=${siteResource.orgId}` ); @@ -485,10 +477,6 @@ async function rebuildClientAssociationsFromSiteResourceImpl( clientSiteResourcesToRemove, trx ); - - return { - mergedAllClients - }; } async function handleMessagesForSiteClients( @@ -1042,6 +1030,312 @@ async function handleSubnetProxyTargetUpdates( await Promise.all([...proxyJobs, ...olmJobs]); } +export async function handleMessagingForUpdatedSiteResource( + existingSiteResource: SiteResource | undefined, + updatedSiteResource: SiteResource, + existingSiteIds: number[], + updatedSiteIds: number[], + trx: Transaction | typeof db = db +) { + logger.debug( + "handleMessagingForUpdatedSiteResource: existingSiteResource is: ", + existingSiteResource + ); + logger.debug( + "handleMessagingForUpdatedSiteResource: updatedSiteResource is: ", + updatedSiteResource + ); + + const allSiteIds = [...new Set([...existingSiteIds, ...updatedSiteIds])]; + + const newtsForSites = + allSiteIds.length > 0 + ? await trx + .select() + .from(newts) + .where(inArray(newts.siteId, allSiteIds)) + : []; + const newtBySiteId = new Map( + newtsForSites.map((newt) => [newt.siteId, newt]) + ); + + // get all of the clients from the cache + + const targets = await generateSubnetProxyTargetV2( + updatedSiteResource, + mergedAllClients + ); + + const oldDestinationStillInUseClientSitePairs = new Set(); + if ( + existingSiteResource?.destination && + allSiteIds.length > 0 && + mergedAllClientIds.length > 0 + ) { + const oldDestinationStillInUseRows = await trx + .select({ + clientId: clientSiteResourcesAssociationsCache.clientId, + siteId: siteNetworks.siteId + }) + .from(siteResources) + .innerJoin( + clientSiteResourcesAssociationsCache, + eq( + clientSiteResourcesAssociationsCache.siteResourceId, + siteResources.siteResourceId + ) + ) + .innerJoin( + siteNetworks, + eq(siteNetworks.networkId, siteResources.networkId) + ) + .where( + and( + inArray( + clientSiteResourcesAssociationsCache.clientId, + mergedAllClientIds + ), + inArray(siteNetworks.siteId, allSiteIds), + eq( + siteResources.destination, + existingSiteResource.destination + ), + ne( + siteResources.siteResourceId, + existingSiteResource.siteResourceId + ) + ) + ); + + for (const row of oldDestinationStillInUseRows) { + oldDestinationStillInUseClientSitePairs.add( + `${row.clientId}:${row.siteId}` + ); + } + } + + //////////////////////////// FROM HERE DOWN WE ARE DEALING WITH REMOVING SITES + const removedSiteIds = existingSiteIds.filter( + (id) => !updatedSiteIds.includes(id) + ); + + const targetsToRemoveBatch: { + newtId: string; + targets: any[]; + version: string | null; + }[] = []; + const peerDataRemoves: { + clientId: number; + siteId: number; + remoteSubnets: string[]; + aliases: ReturnType; + }[] = []; + if (targets) { + for (const siteId of removedSiteIds) { + const newt = newtBySiteId.get(siteId); + if (!newt) { + continue; + } + targetsToRemoveBatch.push({ + newtId: newt.newtId, + targets: targets, + version: newt.version + }); + for (const client of mergedAllClients) { + const oldDestinationStillInUseByASite = + oldDestinationStillInUseClientSitePairs.has( + `${client.clientId}:${siteId}` + ); + peerDataRemoves.push({ + // this might happen twice after the rebuild function but that is okay + clientId: client.clientId, + siteId, + remoteSubnets: !oldDestinationStillInUseByASite + ? generateRemoteSubnets([updatedSiteResource]) + : [], + aliases: generateAliasConfig([updatedSiteResource]) + }); + } + } + } + + removeSubnetProxyTargetsBatch(targetsToRemoveBatch); + + removePeerDataBatch(peerDataRemoves); + + //////////////////////////// FROM HERE DOWN WE ARE DEALING WITH ADDING NEW SITES + const addedSiteIds = updatedSiteIds.filter( + (id) => !existingSiteIds.includes(id) + ); + + const targetsToAddBatch: { + newtId: string; + targets: any[]; + version: string | null; + }[] = []; + const peerDataAdds: { + clientId: number; + siteId: number; + remoteSubnets: string[]; + aliases: ReturnType; + }[] = []; + if (targets) { + for (const siteId of addedSiteIds) { + const newt = newtBySiteId.get(siteId); + if (!newt) { + continue; + } + targetsToAddBatch.push({ + newtId: newt.newtId, + targets: targets, + version: newt.version + }); + for (const client of mergedAllClients) { + peerDataAdds.push({ + clientId: client.clientId, + siteId, + remoteSubnets: generateRemoteSubnets([updatedSiteResource]), + aliases: generateAliasConfig([updatedSiteResource]) + }); + } + } + } + + addSubnetProxyTargetsBatch(targetsToAddBatch); + + addPeerDataBatch(peerDataAdds); + + //////////////////////////// FROM HERE DOWN WE ARE DEALING WITH UPDATING THE EXISTING SITES + + const unchangedSiteIds = existingSiteIds.filter((id) => + updatedSiteIds.includes(id) + ); + + // after everything is rebuilt above we still need to update the targets and remote subnets if the destination changed + const destinationChanged = + existingSiteResource && + existingSiteResource.destination !== updatedSiteResource.destination; + const destinationPortChanged = + existingSiteResource && + existingSiteResource.destinationPort !== + updatedSiteResource.destinationPort; + const aliasChanged = + existingSiteResource && + existingSiteResource.alias !== updatedSiteResource.alias; + const fullDomainChanged = + existingSiteResource && + existingSiteResource.fullDomain !== updatedSiteResource.fullDomain; + const sslChanged = + existingSiteResource && + existingSiteResource.ssl !== updatedSiteResource.ssl; + const portRangesChanged = + existingSiteResource && + (existingSiteResource.tcpPortRangeString !== + updatedSiteResource.tcpPortRangeString || + existingSiteResource.udpPortRangeString !== + updatedSiteResource.udpPortRangeString || + existingSiteResource.disableIcmp !== + updatedSiteResource.disableIcmp); + + // if the existingSiteResource is undefined (new resource) we don't need to do anything here, the rebuild above handled it all + + if ( + destinationChanged || + aliasChanged || + fullDomainChanged || + sslChanged || + portRangesChanged || + destinationPortChanged + ) { + const shouldUpdateTargets = + destinationChanged || + sslChanged || + portRangesChanged || + fullDomainChanged || + destinationPortChanged; + const oldTargets = shouldUpdateTargets + ? await generateSubnetProxyTargetV2( + existingSiteResource, + mergedAllClients + ) + : []; + const newTargets = shouldUpdateTargets + ? await generateSubnetProxyTargetV2( + updatedSiteResource, + mergedAllClients + ) + : []; + + const peerDataUpdateBatch: Parameters[0] = + []; + + for (const siteId of unchangedSiteIds) { + const newt = newtBySiteId.get(siteId); + + if (!newt) { + throw new Error( + "Newt not found for site during site resource update" + ); + } + + // Only update targets on newt if these items change + if (shouldUpdateTargets) { + await updateTargets( + newt.newtId, + { + oldTargets: oldTargets ? oldTargets : [], + newTargets: newTargets ? newTargets : [] + }, + newt.version + ); + } + + for (const client of mergedAllClients) { + // does this client have access to another resource on this site that has the same destination still? if so we dont want to remove it from their olm yet + if (!existingSiteResource.destination) { + continue; + } + + const oldDestinationStillInUseByASite = + oldDestinationStillInUseClientSitePairs.has( + `${client.clientId}:${siteId}` + ); + + // we also need to update the remote subnets on the olms for each client that has access to this site + peerDataUpdateBatch.push({ + clientId: client.clientId, + siteId, + remoteSubnets: destinationChanged + ? { + oldRemoteSubnets: !oldDestinationStillInUseByASite + ? generateRemoteSubnets([ + existingSiteResource + ]) + : [], + newRemoteSubnets: generateRemoteSubnets([ + updatedSiteResource + ]) + } + : undefined, + aliases: + aliasChanged || fullDomainChanged // the full domain is sent down as an alias + ? { + oldAliases: generateAliasConfig([ + existingSiteResource + ]), + newAliases: generateAliasConfig([ + updatedSiteResource + ]) + } + : undefined + }); + } + } + + updatePeerDataBatch(peerDataUpdateBatch); + } +} + export async function rebuildClientAssociationsFromClient( client: Client ): Promise { diff --git a/server/routers/client/targets.ts b/server/routers/client/targets.ts index c62a64ae05..543293a106 100644 --- a/server/routers/client/targets.ts +++ b/server/routers/client/targets.ts @@ -438,6 +438,70 @@ export async function removePeerDataBatch( await sendToClientsBatch(payloads); } +export async function updatePeerDataBatch( + entries: { + clientId: number; + siteId: number; + remoteSubnets: + | { + oldRemoteSubnets: string[]; + newRemoteSubnets: string[]; + } + | undefined; + aliases: + | { + oldAliases: Alias[]; + newAliases: Alias[]; + } + | undefined; + olmId?: string; + version?: string | null; + }[] +) { + if (entries.length === 0) { + return; + } + + const resolvedTargets = await resolveOlmTargets(entries); + + if (resolvedTargets.length === 0) { + return; + } + + const payloads = entries + .map((entry) => { + const resolved = resolvedTargets.find( + (target) => target.clientId === entry.clientId + ); + if (!resolved) { + return null; + } + + return { + clientId: resolved.olmId, + message: { + type: `olm/wg/peer/data/update`, + data: { + siteId: entry.siteId, + ...entry.remoteSubnets, + ...entry.aliases + } + }, + options: { + incrementConfigVersion: true, + compress: canCompress(resolved.version, "olm") + } + }; + }) + .filter((entry) => entry !== null); + + if (payloads.length === 0) { + return; + } + + await sendToClientsBatch(payloads); +} + export async function updatePeerData( clientId: number, siteId: number, diff --git a/server/routers/siteResource/updateSiteResource.ts b/server/routers/siteResource/updateSiteResource.ts index 9b79121fac..5a8ed2aa05 100644 --- a/server/routers/siteResource/updateSiteResource.ts +++ b/server/routers/siteResource/updateSiteResource.ts @@ -1,8 +1,6 @@ import { clientSiteResources, - clientSiteResourcesAssociationsCache, db, - newts, orgs, roles, roleSiteResources, @@ -10,10 +8,7 @@ import { SiteResource, siteResources, sites, - networks, - Transaction, - userSiteResources, - primaryDb + userSiteResources } from "@server/db"; import { isLicensedOrSubscribed } from "#dynamic/lib/isLicencedOrSubscribed"; import { TierFeature, tierMatrix } from "@server/lib/billing/tierMatrix"; @@ -21,18 +16,8 @@ import { validateAndConstructDomain } from "@server/lib/domainUtils"; import response from "@server/lib/response"; import { eq, and, ne, inArray } from "drizzle-orm"; import { OpenAPITags, registry } from "@server/openApi"; -import { updatePeerData, updateTargets } from "@server/routers/client/targets"; -import { - generateAliasConfig, - generateRemoteSubnets, - generateSubnetProxyTargetV2, - isIpInCidr, - portRangeStringSchema -} from "@server/lib/ip"; -import { - getClientSiteResourceAccess, - rebuildClientAssociationsFromSiteResource -} from "@server/lib/rebuildClientAssociations"; +import { isIpInCidr, portRangeStringSchema } from "@server/lib/ip"; +import { rebuildClientAssociationsFromSiteResource } from "@server/lib/rebuildClientAssociations"; import logger from "@server/logger"; import HttpCode from "@server/types/HttpCode"; import { NextFunction, Request, Response } from "express"; @@ -390,7 +375,7 @@ export async function updateSiteResource( ); } - const existingSiteIds = existingSiteResource.networkId + const existingSiteNetworks = existingSiteResource.networkId ? await db .select() .from(siteNetworks) @@ -398,7 +383,7 @@ export async function updateSiteResource( eq(siteNetworks.networkId, existingSiteResource.networkId) ) : []; - const existingSiteIdSet = new Set(existingSiteIds.map((s) => s.siteId)); + const existingSiteIds = existingSiteNetworks.map((sn) => sn.siteId); let fullDomain: string | null = null; let finalSubdomain: string | null = null; @@ -464,6 +449,7 @@ export async function updateSiteResource( } let updatedSiteResource: SiteResource | undefined; + let updatedSiteIds: number[] = []; await db.transaction(async (trx) => { // Update the site resource const sshPamSet = @@ -534,6 +520,7 @@ export async function updateSiteResource( siteId: siteId, networkId: updatedSiteResource.networkId! }); + updatedSiteIds.push(siteId); } await trx @@ -616,11 +603,8 @@ export async function updateSiteResource( handleMessagingForUpdatedSiteResource( existingSiteResource, updatedSiteResource, - Array.from(existingSiteIdSet).map((siteId: number) => ({ - // we already added to the new sites above in the rebuild function so we only need to update the ones that did not change - siteId, - orgId: existingSiteResource.orgId - })) + existingSiteIds, + updatedSiteIds ).catch((e) => { logger.error( `Failed to handle messaging for updated site resource ${siteResourceId}. Error: ${e}` @@ -644,211 +628,3 @@ export async function updateSiteResource( ); } } - -export async function handleMessagingForUpdatedSiteResource( - existingSiteResource: SiteResource | undefined, - updatedSiteResource: SiteResource, - sites: { siteId: number; orgId: string }[] -) { - const trx = primaryDb; - logger.debug( - "handleMessagingForUpdatedSiteResource: existingSiteResource is: ", - existingSiteResource - ); - logger.debug( - "handleMessagingForUpdatedSiteResource: updatedSiteResource is: ", - updatedSiteResource - ); - - const { sitesList, mergedAllClients, mergedAllClientIds } = - await getClientSiteResourceAccess( - existingSiteResource || updatedSiteResource, - trx - ); - - const siteIds = sites.map((site) => site.siteId); - - // after everything is rebuilt above we still need to update the targets and remote subnets if the destination changed - const destinationChanged = - existingSiteResource && - existingSiteResource.destination !== updatedSiteResource.destination; - const destinationPortChanged = - existingSiteResource && - existingSiteResource.destinationPort !== - updatedSiteResource.destinationPort; - const aliasChanged = - existingSiteResource && - existingSiteResource.alias !== updatedSiteResource.alias; - const fullDomainChanged = - existingSiteResource && - existingSiteResource.fullDomain !== updatedSiteResource.fullDomain; - const sslChanged = - existingSiteResource && - existingSiteResource.ssl !== updatedSiteResource.ssl; - const portRangesChanged = - existingSiteResource && - (existingSiteResource.tcpPortRangeString !== - updatedSiteResource.tcpPortRangeString || - existingSiteResource.udpPortRangeString !== - updatedSiteResource.udpPortRangeString || - existingSiteResource.disableIcmp !== - updatedSiteResource.disableIcmp); - - // if the existingSiteResource is undefined (new resource) we don't need to do anything here, the rebuild above handled it all - - if ( - destinationChanged || - aliasChanged || - fullDomainChanged || - sslChanged || - portRangesChanged || - destinationPortChanged - ) { - const newtsForSites = - siteIds.length > 0 - ? await trx - .select() - .from(newts) - .where(inArray(newts.siteId, siteIds)) - : []; - const newtBySiteId = new Map( - newtsForSites.map((newt) => [newt.siteId, newt]) - ); - - const oldDestinationStillInUseClientSitePairs = new Set(); - if ( - existingSiteResource?.destination && - siteIds.length > 0 && - mergedAllClientIds.length > 0 - ) { - const oldDestinationStillInUseRows = await trx - .select({ - clientId: clientSiteResourcesAssociationsCache.clientId, - siteId: siteNetworks.siteId - }) - .from(siteResources) - .innerJoin( - clientSiteResourcesAssociationsCache, - eq( - clientSiteResourcesAssociationsCache.siteResourceId, - siteResources.siteResourceId - ) - ) - .innerJoin( - siteNetworks, - eq(siteNetworks.networkId, siteResources.networkId) - ) - .where( - and( - inArray( - clientSiteResourcesAssociationsCache.clientId, - mergedAllClientIds - ), - inArray(siteNetworks.siteId, siteIds), - eq( - siteResources.destination, - existingSiteResource.destination - ), - ne( - siteResources.siteResourceId, - existingSiteResource.siteResourceId - ) - ) - ); - - for (const row of oldDestinationStillInUseRows) { - oldDestinationStillInUseClientSitePairs.add( - `${row.clientId}:${row.siteId}` - ); - } - } - - const shouldUpdateTargets = - destinationChanged || - sslChanged || - portRangesChanged || - fullDomainChanged || - destinationPortChanged; - const oldTargets = shouldUpdateTargets - ? await generateSubnetProxyTargetV2( - existingSiteResource, - mergedAllClients - ) - : []; - const newTargets = shouldUpdateTargets - ? await generateSubnetProxyTargetV2( - updatedSiteResource, - mergedAllClients - ) - : []; - - for (const site of sites) { - const newt = newtBySiteId.get(site.siteId); - - if (!newt) { - throw new Error( - "Newt not found for site during site resource update" - ); - } - - // Only update targets on newt if these items change - if (shouldUpdateTargets) { - await updateTargets( - newt.newtId, - { - oldTargets: oldTargets ? oldTargets : [], - newTargets: newTargets ? newTargets : [] - }, - newt.version - ); - } - - const olmJobs: Promise[] = []; - for (const client of mergedAllClients) { - // does this client have access to another resource on this site that has the same destination still? if so we dont want to remove it from their olm yet - if (!existingSiteResource.destination) { - continue; - } - - const oldDestinationStillInUseByASite = - oldDestinationStillInUseClientSitePairs.has( - `${client.clientId}:${site.siteId}` - ); - - // we also need to update the remote subnets on the olms for each client that has access to this site - olmJobs.push( - updatePeerData( - // TODO: THIS SHOULD BE UPDATED TO WORK I A BATCH - client.clientId, - site.siteId, - destinationChanged - ? { - oldRemoteSubnets: - !oldDestinationStillInUseByASite - ? generateRemoteSubnets([ - existingSiteResource - ]) - : [], - newRemoteSubnets: generateRemoteSubnets([ - updatedSiteResource - ]) - } - : undefined, - aliasChanged || fullDomainChanged // the full domain is sent down as an alias - ? { - oldAliases: generateAliasConfig([ - existingSiteResource - ]), - newAliases: generateAliasConfig([ - updatedSiteResource - ]) - } - : undefined - ) - ); - } - - await Promise.all(olmJobs); - } - } -} From 80b66cf9b9860433d8005c504a571ec7110ce775 Mon Sep 17 00:00:00 2001 From: Owen Date: Wed, 24 Jun 2026 13:50:33 -0400 Subject: [PATCH 59/88] Add locks to rebuilds --- server/lib/blueprints/applyBlueprint.ts | 46 ++++---- server/lib/lock.ts | 2 +- server/lib/rebuildClientAssociations.ts | 108 ++++++++++++++++++ server/lib/rebuildQueue.ts | 4 + server/private/lib/rebuildQueue.ts | 11 ++ .../newt/handleNewtGetConfigMessage.ts | 3 + .../routers/olm/handleOlmRegisterMessage.ts | 3 + .../siteResource/updateSiteResource.ts | 41 ++++--- 8 files changed, 178 insertions(+), 40 deletions(-) diff --git a/server/lib/blueprints/applyBlueprint.ts b/server/lib/blueprints/applyBlueprint.ts index fbd6f3fb0a..493831131e 100644 --- a/server/lib/blueprints/applyBlueprint.ts +++ b/server/lib/blueprints/applyBlueprint.ts @@ -29,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; @@ -138,26 +141,25 @@ export async function applyBlueprint({ for (const result of privateResourcesResults) { rebuildClientAssociationsFromSiteResource( result.newSiteResource - ).catch((e) => { - logger.error( - `Failed to rebuild client associations for site resource ${result.newSiteResource.siteResourceId}. Error: ${e}` - ); - }); - - handleMessagingForUpdatedSiteResource( - result.oldSiteResource, - result.newSiteResource, - result.oldSites.map((site) => ({ - // only need to run this on the old sites because the new sites are added above - siteId: site.siteId, - orgId: result.newSiteResource.orgId - })) - ).catch((err) => { - logger.error( - `Error handling messaging for updated site resource ${result.newSiteResource.siteResourceId}:`, - err - ); - }); + ) + .then(() => + waitForSiteResourceRebuildIdle( + result.newSiteResource.siteResourceId + ) + ) + .then(() => + handleMessagingForUpdatedSiteResource( + result.oldSiteResource, + result.newSiteResource, + result.oldSites.map((s) => s.siteId), + result.newSites.map((s) => s.siteId) + ) + ) + .catch((e) => { + logger.error( + `Failed to rebuild and handle messaging for site resource ${result.newSiteResource.siteResourceId}. Error: ${e}` + ); + }); } logger.debug( diff --git a/server/lib/lock.ts b/server/lib/lock.ts index 7eea890845..15d1f39e1b 100644 --- a/server/lib/lock.ts +++ b/server/lib/lock.ts @@ -35,7 +35,7 @@ export class LockManager { ttl: number; owner?: string; }> { - return { exists: true, ownedByMe: true, ttl: 0 }; + return { exists: false, ownedByMe: false, ttl: 0 }; } /** diff --git a/server/lib/rebuildClientAssociations.ts b/server/lib/rebuildClientAssociations.ts index 9e362fb512..daf639bf3a 100644 --- a/server/lib/rebuildClientAssociations.ts +++ b/server/lib/rebuildClientAssociations.ts @@ -49,6 +49,112 @@ import { rebuildQueue } from "#dynamic/lib/rebuildQueue"; // peer/proxy updates, so give them a generous window. const REBUILD_ASSOCIATIONS_LOCK_TTL_MS = 120000; +const REBUILD_IDLE_POLL_INTERVAL_MS = 300; +const REBUILD_IDLE_DEFAULT_TIMEOUT_MS = 130_000; // slightly longer than lock TTL +const REBUILD_IDLE_HANDLER_TIMEOUT_MS = 5_000; + +/** + * Returns true if a rebuild for the given site resource is currently active + * (holding the distributed lock) or is pending in the rebuild queue. + */ +export async function hasActiveSiteResourceRebuild( + siteResourceId: number +): Promise { + const lockKey = `rebuild-client-associations:site-resource:${siteResourceId}`; + const lockInfo = await lockManager.getLockInfo(lockKey); + if (lockInfo.exists) return true; + return rebuildQueue.isQueued({ type: "site-resource", id: siteResourceId }); +} + +/** + * Resolves once there is no active or queued rebuild for the given site resource. + * Logs a warning and resolves early if the timeout is reached. + */ +export async function waitForSiteResourceRebuildIdle( + siteResourceId: number, + timeoutMs = REBUILD_IDLE_DEFAULT_TIMEOUT_MS +): Promise { + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + if (!(await hasActiveSiteResourceRebuild(siteResourceId))) return; + await new Promise((r) => + setTimeout(r, REBUILD_IDLE_POLL_INTERVAL_MS) + ); + } + logger.warn( + `waitForSiteResourceRebuildIdle: timed out after ${timeoutMs}ms waiting for siteResourceId=${siteResourceId}` + ); +} + +/** + * Resolves once there are no active or queued rebuilds for any site resource + * associated with the given site. + */ +export async function waitForSiteRebuildIdle( + siteId: number, + timeoutMs = REBUILD_IDLE_HANDLER_TIMEOUT_MS +): Promise { + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + const resourceRows = await db + .select({ siteResourceId: siteResources.siteResourceId }) + .from(siteResources) + .innerJoin( + siteNetworks, + eq(siteNetworks.networkId, siteResources.networkId) + ) + .where(eq(siteNetworks.siteId, siteId)); + let allIdle = true; + for (const { siteResourceId } of resourceRows) { + if (await hasActiveSiteResourceRebuild(siteResourceId)) { + allIdle = false; + break; + } + } + if (allIdle) return; + await new Promise((r) => + setTimeout(r, REBUILD_IDLE_POLL_INTERVAL_MS) + ); + } + logger.warn( + `waitForSiteRebuildIdle: timed out after ${timeoutMs}ms waiting for siteId=${siteId}` + ); +} + +/** + * Resolves once there are no active or queued rebuilds for any site resource + * associated with the given client. + */ +export async function waitForClientRebuildIdle( + clientId: number, + timeoutMs = REBUILD_IDLE_HANDLER_TIMEOUT_MS +): Promise { + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + const resourceRows = await db + .select({ + siteResourceId: + clientSiteResourcesAssociationsCache.siteResourceId + }) + .from(clientSiteResourcesAssociationsCache) + .where(eq(clientSiteResourcesAssociationsCache.clientId, clientId)); + let allIdle = true; + for (const { siteResourceId } of resourceRows) { + if (await hasActiveSiteResourceRebuild(siteResourceId)) { + allIdle = false; + break; + } + } + if (allIdle) return; + await new Promise((r) => + setTimeout(r, REBUILD_IDLE_POLL_INTERVAL_MS) + ); + } + logger.warn( + `waitForClientRebuildIdle: timed out after ${timeoutMs}ms waiting for clientId=${clientId}` + ); +} + export async function getClientSiteResourceAccess( siteResource: SiteResource, trx: Transaction | typeof db = db @@ -1060,6 +1166,8 @@ export async function handleMessagingForUpdatedSiteResource( ); // get all of the clients from the cache + const { mergedAllClients, mergedAllClientIds } = + await getClientSiteResourceAccess(updatedSiteResource, trx); const targets = await generateSubnetProxyTargetV2( updatedSiteResource, diff --git a/server/lib/rebuildQueue.ts b/server/lib/rebuildQueue.ts index 4758581081..84dce96412 100644 --- a/server/lib/rebuildQueue.ts +++ b/server/lib/rebuildQueue.ts @@ -13,11 +13,15 @@ export interface RebuildJobHandlers { export interface RebuildQueueManager { enqueue(job: RebuildJob): Promise; startProcessing(handlers: RebuildJobHandlers): void; + isQueued(job: RebuildJob): Promise; } class NoopRebuildQueue implements RebuildQueueManager { async enqueue(_job: RebuildJob): Promise {} startProcessing(_handlers: RebuildJobHandlers): void {} + async isQueued(_job: RebuildJob): Promise { + return false; + } } export const rebuildQueue: RebuildQueueManager = new NoopRebuildQueue(); diff --git a/server/private/lib/rebuildQueue.ts b/server/private/lib/rebuildQueue.ts index 01082cc502..b5e1125452 100644 --- a/server/private/lib/rebuildQueue.ts +++ b/server/private/lib/rebuildQueue.ts @@ -46,6 +46,17 @@ const POLL_INTERVAL_MS = 500; class RedisRebuildQueue { private processingStarted = false; + async isQueued(job: RebuildJob): Promise { + if (!redis || redis.status !== "ready") return false; + const dedupeKey = `${job.type}:${job.id}`; + try { + const member = await redis.sismember(QUEUED_SET_KEY, dedupeKey); + return member === 1; + } catch { + return false; + } + } + async enqueue(job: RebuildJob): Promise { if (!redis || redis.status !== "ready") { logger.warn( diff --git a/server/routers/newt/handleNewtGetConfigMessage.ts b/server/routers/newt/handleNewtGetConfigMessage.ts index ff5d837999..fd5e2b42e5 100644 --- a/server/routers/newt/handleNewtGetConfigMessage.ts +++ b/server/routers/newt/handleNewtGetConfigMessage.ts @@ -9,6 +9,7 @@ import { buildClientConfigurationForNewtClient } from "./buildConfiguration"; import { convertTargetsIfNecessary } from "../client/targets"; import { canCompress } from "@server/lib/clientVersionChecks"; import config from "@server/lib/config"; +import { waitForSiteRebuildIdle } from "@server/lib/rebuildClientAssociations"; export const handleNewtGetConfigMessage: MessageHandler = async (context) => { const { message, client, sendToClient } = context; @@ -61,6 +62,8 @@ export const handleNewtGetConfigMessage: MessageHandler = async (context) => { return; } + await waitForSiteRebuildIdle(siteId); + // update the endpoint and the public key const [site] = await db .update(sites) diff --git a/server/routers/olm/handleOlmRegisterMessage.ts b/server/routers/olm/handleOlmRegisterMessage.ts index 6bfc02aee0..bef9938312 100644 --- a/server/routers/olm/handleOlmRegisterMessage.ts +++ b/server/routers/olm/handleOlmRegisterMessage.ts @@ -21,6 +21,7 @@ import { build } from "@server/build"; import { canCompress } from "@server/lib/clientVersionChecks"; import config from "@server/lib/config"; import cache from "#dynamic/lib/cache"; // not using regional here because we need this in the register message handler before we know where the client is +import { waitForClientRebuildIdle } from "@server/lib/rebuildClientAssociations"; const HOLEPUNCH_STALE_CHAIN_THRESHOLD = 18; const HOLEPUNCH_STALE_CHAIN_TTL_SECONDS = 1800; @@ -385,6 +386,8 @@ export const handleOlmRegisterMessage: MessageHandler = async (context) => { } // NOTE: its important that the client here is the old client and the public key is the new key + await waitForClientRebuildIdle(olm.clientId); + const siteConfigurations = await buildSiteConfigurationForOlmClient( client, publicKey, diff --git a/server/routers/siteResource/updateSiteResource.ts b/server/routers/siteResource/updateSiteResource.ts index 5a8ed2aa05..434163f6fe 100644 --- a/server/routers/siteResource/updateSiteResource.ts +++ b/server/routers/siteResource/updateSiteResource.ts @@ -17,7 +17,11 @@ import response from "@server/lib/response"; import { eq, and, ne, inArray } from "drizzle-orm"; import { OpenAPITags, registry } from "@server/openApi"; import { isIpInCidr, portRangeStringSchema } from "@server/lib/ip"; -import { rebuildClientAssociationsFromSiteResource } from "@server/lib/rebuildClientAssociations"; +import { + handleMessagingForUpdatedSiteResource, + rebuildClientAssociationsFromSiteResource, + waitForSiteResourceRebuildIdle +} from "@server/lib/rebuildClientAssociations"; import logger from "@server/logger"; import HttpCode from "@server/types/HttpCode"; import { NextFunction, Request, Response } from "express"; @@ -592,24 +596,27 @@ export async function updateSiteResource( throw new Error("No updated resource found after update"); } - rebuildClientAssociationsFromSiteResource(updatedSiteResource).catch( - (e) => { + const finalUpdatedSiteResource = updatedSiteResource; + + rebuildClientAssociationsFromSiteResource(finalUpdatedSiteResource) + .then(() => + waitForSiteResourceRebuildIdle( + finalUpdatedSiteResource.siteResourceId + ) + ) + .then(() => + handleMessagingForUpdatedSiteResource( + existingSiteResource, + finalUpdatedSiteResource, + existingSiteIds, + updatedSiteIds + ) + ) + .catch((e) => { logger.error( - `Failed to rebuild client associations for site resource ${siteResourceId}. Error: ${e}` + `Failed to rebuild and handle messaging for site resource ${siteResourceId}. Error: ${e}` ); - } - ); - - handleMessagingForUpdatedSiteResource( - existingSiteResource, - updatedSiteResource, - existingSiteIds, - updatedSiteIds - ).catch((e) => { - logger.error( - `Failed to handle messaging for updated site resource ${siteResourceId}. Error: ${e}` - ); - }); + }); return response(res, { data: updatedSiteResource, From 62fc2edae99cfd1495cbaabf9c7fcc18776f048e Mon Sep 17 00:00:00 2001 From: Owen Date: Wed, 24 Jun 2026 15:28:46 -0400 Subject: [PATCH 60/88] Add logging and fix removing alias --- server/lib/rebuildClientAssociations.ts | 172 ++++++++++++++++++++++-- 1 file changed, 164 insertions(+), 8 deletions(-) diff --git a/server/lib/rebuildClientAssociations.ts b/server/lib/rebuildClientAssociations.ts index daf639bf3a..72a16efcfc 100644 --- a/server/lib/rebuildClientAssociations.ts +++ b/server/lib/rebuildClientAssociations.ts @@ -1140,9 +1140,14 @@ export async function handleMessagingForUpdatedSiteResource( existingSiteResource: SiteResource | undefined, updatedSiteResource: SiteResource, existingSiteIds: number[], - updatedSiteIds: number[], - trx: Transaction | typeof db = db + updatedSiteIds: number[] ) { + const trx = primaryDb; + + logger.debug( + `handleMessagingForUpdatedSiteResource: START siteResourceId=${updatedSiteResource.siteResourceId} existingSiteIds=[${existingSiteIds.join(", ")}] updatedSiteIds=[${updatedSiteIds.join(", ")}]` + ); + logger.debug( "handleMessagingForUpdatedSiteResource: existingSiteResource is: ", existingSiteResource @@ -1154,6 +1159,10 @@ export async function handleMessagingForUpdatedSiteResource( const allSiteIds = [...new Set([...existingSiteIds, ...updatedSiteIds])]; + logger.debug( + `handleMessagingForUpdatedSiteResource: allSiteIds=[${allSiteIds.join(", ")}] count=${allSiteIds.length}` + ); + const newtsForSites = allSiteIds.length > 0 ? await trx @@ -1165,21 +1174,53 @@ export async function handleMessagingForUpdatedSiteResource( newtsForSites.map((newt) => [newt.siteId, newt]) ); - // get all of the clients from the cache - const { mergedAllClients, mergedAllClientIds } = - await getClientSiteResourceAccess(updatedSiteResource, trx); + logger.debug( + `handleMessagingForUpdatedSiteResource: fetched newts for ${newtsForSites.length}/${allSiteIds.length} site(s)` + ); + + // WARNING: THIS RELIES ON THE CACHE TABLES BEING UP TO DATE, SO CALL THIS AFTER THE ASSOCIATION CACHE IS UPDATED + const mergedAllClients = await trx + .select({ + clientId: clientSiteResourcesAssociationsCache.clientId, + pubKey: clients.pubKey, + subnet: clients.subnet + }) + .from(clientSiteResourcesAssociationsCache) + .innerJoin( + clients, + eq(clientSiteResourcesAssociationsCache.clientId, clients.clientId) + ) + .where( + eq( + clientSiteResourcesAssociationsCache.siteResourceId, + updatedSiteResource.siteResourceId + ) + ); + + logger.debug( + `handleMessagingForUpdatedSiteResource: resolved merged clients count=${mergedAllClients.length} clientIds=[${mergedAllClients.map((c) => c.clientId).join(", ")}]` + ); const targets = await generateSubnetProxyTargetV2( updatedSiteResource, mergedAllClients ); + logger.debug( + `handleMessagingForUpdatedSiteResource: generated updated targets count=${targets ? targets.length : 0}` + ); + const oldDestinationStillInUseClientSitePairs = new Set(); + const oldAliasStillInUseClientSitePairs = new Set(); if ( existingSiteResource?.destination && allSiteIds.length > 0 && - mergedAllClientIds.length > 0 + mergedAllClients.length > 0 ) { + logger.debug( + `handleMessagingForUpdatedSiteResource: checking old destination reuse destination=${existingSiteResource.destination} across siteCount=${allSiteIds.length} clientCount=${mergedAllClients.length}` + ); + const oldDestinationStillInUseRows = await trx .select({ clientId: clientSiteResourcesAssociationsCache.clientId, @@ -1201,7 +1242,7 @@ export async function handleMessagingForUpdatedSiteResource( and( inArray( clientSiteResourcesAssociationsCache.clientId, - mergedAllClientIds + mergedAllClients.map((c) => c.clientId) ), inArray(siteNetworks.siteId, allSiteIds), eq( @@ -1220,6 +1261,14 @@ export async function handleMessagingForUpdatedSiteResource( `${row.clientId}:${row.siteId}` ); } + + logger.debug( + `handleMessagingForUpdatedSiteResource: old destination still in use rows=${oldDestinationStillInUseRows.length} uniqueClientSitePairs=${oldDestinationStillInUseClientSitePairs.size}` + ); + } else { + logger.debug( + "handleMessagingForUpdatedSiteResource: skipping old destination reuse check (missing existing destination or no sites/clients)" + ); } //////////////////////////// FROM HERE DOWN WE ARE DEALING WITH REMOVING SITES @@ -1227,6 +1276,10 @@ export async function handleMessagingForUpdatedSiteResource( (id) => !updatedSiteIds.includes(id) ); + logger.debug( + `handleMessagingForUpdatedSiteResource: removing sites removedSiteIds=[${removedSiteIds.join(", ")}] count=${removedSiteIds.length}` + ); + const targetsToRemoveBatch: { newtId: string; targets: any[]; @@ -1242,8 +1295,16 @@ export async function handleMessagingForUpdatedSiteResource( for (const siteId of removedSiteIds) { const newt = newtBySiteId.get(siteId); if (!newt) { + logger.debug( + `handleMessagingForUpdatedSiteResource: skipping remove for siteId=${siteId} because no newt found` + ); continue; } + + logger.debug( + `handleMessagingForUpdatedSiteResource: preparing remove batches for siteId=${siteId} newtId=${newt.newtId}` + ); + targetsToRemoveBatch.push({ newtId: newt.newtId, targets: targets, @@ -1254,6 +1315,12 @@ export async function handleMessagingForUpdatedSiteResource( oldDestinationStillInUseClientSitePairs.has( `${client.clientId}:${siteId}` ); + + if (oldDestinationStillInUseByASite && allSiteIds.length > 0) { + // nothing in the message anyway lets just continue + continue; + } + peerDataRemoves.push({ // this might happen twice after the rebuild function but that is okay clientId: client.clientId, @@ -1261,14 +1328,33 @@ export async function handleMessagingForUpdatedSiteResource( remoteSubnets: !oldDestinationStillInUseByASite ? generateRemoteSubnets([updatedSiteResource]) : [], - aliases: generateAliasConfig([updatedSiteResource]) + aliases: + allSiteIds.length == 0 + ? generateAliasConfig([updatedSiteResource]) + : [] }); } } + } else { + logger.debug( + "handleMessagingForUpdatedSiteResource: skipping removal batch generation because targets were empty" + ); } + logger.debug( + `handleMessagingForUpdatedSiteResource: remove batches prepared targetBatchCount=${targetsToRemoveBatch.length} peerDataCount=${peerDataRemoves.length}` + ); + + logger.debug( + "handleMessagingForUpdatedSiteResource: dispatching removeSubnetProxyTargetsBatch" + ); + removeSubnetProxyTargetsBatch(targetsToRemoveBatch); + logger.debug( + "handleMessagingForUpdatedSiteResource: dispatching removePeerDataBatch" + ); + removePeerDataBatch(peerDataRemoves); //////////////////////////// FROM HERE DOWN WE ARE DEALING WITH ADDING NEW SITES @@ -1276,6 +1362,10 @@ export async function handleMessagingForUpdatedSiteResource( (id) => !existingSiteIds.includes(id) ); + logger.debug( + `handleMessagingForUpdatedSiteResource: adding sites addedSiteIds=[${addedSiteIds.join(", ")}] count=${addedSiteIds.length}` + ); + const targetsToAddBatch: { newtId: string; targets: any[]; @@ -1291,8 +1381,16 @@ export async function handleMessagingForUpdatedSiteResource( for (const siteId of addedSiteIds) { const newt = newtBySiteId.get(siteId); if (!newt) { + logger.debug( + `handleMessagingForUpdatedSiteResource: skipping add for siteId=${siteId} because no newt found` + ); continue; } + + logger.debug( + `handleMessagingForUpdatedSiteResource: preparing add batches for siteId=${siteId} newtId=${newt.newtId}` + ); + targetsToAddBatch.push({ newtId: newt.newtId, targets: targets, @@ -1307,10 +1405,26 @@ export async function handleMessagingForUpdatedSiteResource( }); } } + } else { + logger.debug( + "handleMessagingForUpdatedSiteResource: skipping add batch generation because targets were empty" + ); } + logger.debug( + `handleMessagingForUpdatedSiteResource: add batches prepared targetBatchCount=${targetsToAddBatch.length} peerDataCount=${peerDataAdds.length}` + ); + + logger.debug( + "handleMessagingForUpdatedSiteResource: dispatching addSubnetProxyTargetsBatch" + ); + addSubnetProxyTargetsBatch(targetsToAddBatch); + logger.debug( + "handleMessagingForUpdatedSiteResource: dispatching addPeerDataBatch" + ); + addPeerDataBatch(peerDataAdds); //////////////////////////// FROM HERE DOWN WE ARE DEALING WITH UPDATING THE EXISTING SITES @@ -1319,6 +1433,10 @@ export async function handleMessagingForUpdatedSiteResource( updatedSiteIds.includes(id) ); + logger.debug( + `handleMessagingForUpdatedSiteResource: unchangedSiteIds=[${unchangedSiteIds.join(", ")}] count=${unchangedSiteIds.length}` + ); + // after everything is rebuilt above we still need to update the targets and remote subnets if the destination changed const destinationChanged = existingSiteResource && @@ -1345,6 +1463,10 @@ export async function handleMessagingForUpdatedSiteResource( existingSiteResource.disableIcmp !== updatedSiteResource.disableIcmp); + logger.debug( + `handleMessagingForUpdatedSiteResource: change flags destinationChanged=${Boolean(destinationChanged)} destinationPortChanged=${Boolean(destinationPortChanged)} aliasChanged=${Boolean(aliasChanged)} fullDomainChanged=${Boolean(fullDomainChanged)} sslChanged=${Boolean(sslChanged)} portRangesChanged=${Boolean(portRangesChanged)}` + ); + // if the existingSiteResource is undefined (new resource) we don't need to do anything here, the rebuild above handled it all if ( @@ -1361,6 +1483,11 @@ export async function handleMessagingForUpdatedSiteResource( portRangesChanged || fullDomainChanged || destinationPortChanged; + + logger.debug( + `handleMessagingForUpdatedSiteResource: entering unchanged-site update path shouldUpdateTargets=${shouldUpdateTargets}` + ); + const oldTargets = shouldUpdateTargets ? await generateSubnetProxyTargetV2( existingSiteResource, @@ -1374,13 +1501,24 @@ export async function handleMessagingForUpdatedSiteResource( ) : []; + logger.debug( + `handleMessagingForUpdatedSiteResource: target update payload sizes oldTargets=${oldTargets ? oldTargets.length : 0} newTargets=${newTargets ? newTargets.length : 0}` + ); + const peerDataUpdateBatch: Parameters[0] = []; for (const siteId of unchangedSiteIds) { const newt = newtBySiteId.get(siteId); + logger.debug( + `handleMessagingForUpdatedSiteResource: processing unchanged siteId=${siteId}` + ); + if (!newt) { + logger.error( + `handleMessagingForUpdatedSiteResource: missing newt for unchanged siteId=${siteId}` + ); throw new Error( "Newt not found for site during site resource update" ); @@ -1388,6 +1526,9 @@ export async function handleMessagingForUpdatedSiteResource( // Only update targets on newt if these items change if (shouldUpdateTargets) { + logger.debug( + `handleMessagingForUpdatedSiteResource: updating targets for siteId=${siteId} newtId=${newt.newtId}` + ); await updateTargets( newt.newtId, { @@ -1401,6 +1542,9 @@ export async function handleMessagingForUpdatedSiteResource( for (const client of mergedAllClients) { // does this client have access to another resource on this site that has the same destination still? if so we dont want to remove it from their olm yet if (!existingSiteResource.destination) { + logger.debug( + `handleMessagingForUpdatedSiteResource: skipping peerData update for clientId=${client.clientId} siteId=${siteId} because existing destination is empty` + ); continue; } @@ -1440,8 +1584,20 @@ export async function handleMessagingForUpdatedSiteResource( } } + logger.debug( + `handleMessagingForUpdatedSiteResource: dispatching updatePeerDataBatch count=${peerDataUpdateBatch.length}` + ); + updatePeerDataBatch(peerDataUpdateBatch); + } else { + logger.debug( + "handleMessagingForUpdatedSiteResource: no unchanged-site update required because no relevant fields changed" + ); } + + logger.debug( + `handleMessagingForUpdatedSiteResource: DONE siteResourceId=${updatedSiteResource.siteResourceId}` + ); } export async function rebuildClientAssociationsFromClient( From 75b87ffba7240158ef80dc2006c90df26c9a5537 Mon Sep 17 00:00:00 2001 From: Owen Date: Wed, 24 Jun 2026 15:49:51 -0400 Subject: [PATCH 61/88] Quiet log message --- server/private/lib/acmeCertSync.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/server/private/lib/acmeCertSync.ts b/server/private/lib/acmeCertSync.ts index fb99e934e6..56105ac386 100644 --- a/server/private/lib/acmeCertSync.ts +++ b/server/private/lib/acmeCertSync.ts @@ -693,9 +693,9 @@ async function syncAcmeCerts(acmeJsonPath: string): Promise { ); continue; } - logger.debug( - `acmeCertSync: found ${resolverData.Certificates.length} certificate(s) for resolver "${resolver}"` - ); + // logger.debug( + // `acmeCertSync: found ${resolverData.Certificates.length} certificate(s) for resolver "${resolver}"` + // ); for (const cert of resolverData.Certificates) { allCerts.push(cert); } From d303fa05cb0e68ca1dfbaa02cdd55b3d1fea4a3c Mon Sep 17 00:00:00 2001 From: Owen Date: Wed, 24 Jun 2026 15:50:54 -0400 Subject: [PATCH 62/88] Comment out the sync --- server/routers/newt/handleNewtPingMessage.ts | 26 +++++++++++--------- 1 file changed, 14 insertions(+), 12 deletions(-) diff --git a/server/routers/newt/handleNewtPingMessage.ts b/server/routers/newt/handleNewtPingMessage.ts index 56b8a2a241..c56c5f6d4b 100644 --- a/server/routers/newt/handleNewtPingMessage.ts +++ b/server/routers/newt/handleNewtPingMessage.ts @@ -49,20 +49,22 @@ export const handleNewtPingMessage: MessageHandler = async (context) => { `Newt ping with outdated config version: ${message.configVersion} (current: ${configVersion})` ); - const [site] = await db - .select() - .from(sites) - .where(eq(sites.siteId, newt.siteId)) - .limit(1); + // TODO: IMPLEMENT THE SYNC ON THE NEWT SIDE AND COMMENT THIS BACK IN - if (!site) { - logger.warn( - `Newt ping message: site with ID ${newt.siteId} not found` - ); - return; - } + // const [site] = await db + // .select() + // .from(sites) + // .where(eq(sites.siteId, newt.siteId)) + // .limit(1); - await sendNewtSyncMessage(newt, site); + // if (!site) { + // logger.warn( + // `Newt ping message: site with ID ${newt.siteId} not found` + // ); + // return; + // } + + // await sendNewtSyncMessage(newt, site); } return { From b18a41e4aa59669e0266adedcd9e84ed99bae9e3 Mon Sep 17 00:00:00 2001 From: miloschwartz Date: Wed, 24 Jun 2026 15:36:45 -0400 Subject: [PATCH 63/88] adjust translation --- messages/en-US.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/messages/en-US.json b/messages/en-US.json index 9d8a7fbdbb..30173d651e 100644 --- a/messages/en-US.json +++ b/messages/en-US.json @@ -1638,7 +1638,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)", From 2b38658ea6df240e9a33d6c2b1c24cd5e38a51bb Mon Sep 17 00:00:00 2001 From: miloschwartz Date: Wed, 24 Jun 2026 15:55:12 -0400 Subject: [PATCH 64/88] make sidebar notification failures more resilient --- src/components/ProductUpdates.tsx | 83 +++++++++++++++++++++---------- 1 file changed, 57 insertions(+), 26 deletions(-) diff --git a/src/components/ProductUpdates.tsx b/src/components/ProductUpdates.tsx index 0d88853a72..dad46e3b96 100644 --- a/src/components/ProductUpdates.tsx +++ b/src/components/ProductUpdates.tsx @@ -8,6 +8,7 @@ import { type ProductUpdate, productUpdatesQueries } from "@app/lib/queries"; +import { build } from "@server/build"; import { useQueries } from "@tanstack/react-query"; import { ArrowRight, @@ -39,22 +40,42 @@ export default function ProductUpdates({ }) { const { env } = useEnvContext(); + const productUpdatesEnabled = env.app.notifications.product_updates; + const versionCheckEnabled = + env.app.notifications.new_releases && build !== "saas"; + const data = useQueries({ queries: [ - productUpdatesQueries.list( - env.app.notifications.product_updates, - env.app.version - ), + productUpdatesQueries.list(productUpdatesEnabled, env.app.version), productUpdatesQueries.latestVersion( env.app.notifications.new_releases ) ], combine(result) { - if (result[0].isLoading || result[1].isLoading) return null; - return { - updates: result[0].data?.data ?? [], - latestVersion: result[1].data - }; + const [updatesQuery, versionQuery] = result; + + const updatesSettled = + !productUpdatesEnabled || + updatesQuery.isFetched || + updatesQuery.isError; + const versionSettled = + !versionCheckEnabled || + versionQuery.isFetched || + versionQuery.isError; + + if (!updatesSettled || !versionSettled) return null; + + const updates = updatesQuery.isError + ? [] + : Array.isArray(updatesQuery.data?.data) + ? updatesQuery.data.data + : []; + + const latestVersion = versionQuery.isError + ? undefined + : versionQuery.data; + + return { updates, latestVersion }; } }); const t = useTranslations(); @@ -76,19 +97,30 @@ export default function ProductUpdates({ if (!data) return null; - const latestVersion = data?.latestVersion?.data?.pangolin.latestVersion; + const versionResponse = data.latestVersion?.data; + const latestVersion = versionResponse?.pangolin?.latestVersion; const currentVersion = env.app.version; - const showNewVersionPopup = Boolean( + let showNewVersionPopup = false; + if ( latestVersion && - valid(latestVersion) && - valid(currentVersion) && - ignoredVersionUpdate !== latestVersion && - gt(latestVersion, currentVersion) - ); + valid(latestVersion) && + valid(currentVersion) && + ignoredVersionUpdate !== latestVersion + ) { + try { + showNewVersionPopup = gt(latestVersion, currentVersion); + } catch { + showNewVersionPopup = false; + } + } + + const readUpdateIds = Array.isArray(productUpdatesRead) + ? productUpdatesRead + : []; const filteredUpdates = data.updates.filter( - (update) => !productUpdatesRead.includes(update.id) + (update) => !readUpdateIds.includes(update.id) ); if (filteredUpdates.length === 0 && !showNewVersionPopup) { @@ -133,17 +165,14 @@ export default function ProductUpdates({ show={filteredUpdates.length > 0} onDimissAll={() => setProductUpdatesRead([ - ...productUpdatesRead, + ...readUpdateIds, ...filteredUpdates.map( (update) => update.id ) ]) } onDimiss={(id) => - setProductUpdatesRead([ - ...productUpdatesRead, - id - ]) + setProductUpdatesRead([...readUpdateIds, id]) } /> @@ -151,11 +180,9 @@ export default function ProductUpdates({ { - setIgnoredVersionUpdate( - data.latestVersion?.data?.pangolin.latestVersion ?? null - ); + setIgnoredVersionUpdate(latestVersion ?? null); }} show={showNewVersionPopup} /> @@ -346,6 +373,10 @@ function NewVersionAvailable({ } }, [show]); + if (!version?.pangolin?.latestVersion) { + return null; + } + return ( {version && ( From 242123b8753e05487ef7e84b09f180fb6d777808 Mon Sep 17 00:00:00 2001 From: Owen Date: Wed, 24 Jun 2026 16:00:54 -0400 Subject: [PATCH 65/88] Implement non-redis lock --- server/lib/lock.ts | 124 ++++++++++++++++++++++++++++-- server/private/lib/lock.ts | 153 +++++++++++++++++++++++++------------ 2 files changed, 222 insertions(+), 55 deletions(-) diff --git a/server/lib/lock.ts b/server/lib/lock.ts index 15d1f39e1b..3cd1b8704e 100644 --- a/server/lib/lock.ts +++ b/server/lib/lock.ts @@ -1,4 +1,24 @@ +const instanceId = `local-${Math.random().toString(36).slice(2)}-${Date.now()}`; + +type LocalLockRecord = { + owner: string; + expiresAt: number; +}; + +const localLocks = new Map(); + export class LockManager { + private clearExpiredLocalLock(lockKey: string): void { + const current = localLocks.get(lockKey); + if (current && current.expiresAt <= Date.now()) { + localLocks.delete(lockKey); + } + } + + private getLocalOwnerToken(): string { + return `${instanceId}:`; + } + /** * Acquire a distributed lock using Redis SET with NX and PX options * @param lockKey - Unique identifier for the lock @@ -7,22 +27,57 @@ export class LockManager { */ async acquireLock( lockKey: string, - ttlMs: number = 30000 + ttlMs: number = 30000, + maxRetries: number = 3, + retryDelayMs: number = 100 ): Promise { - return true; + for (let attempt = 0; attempt < maxRetries; attempt++) { + this.clearExpiredLocalLock(lockKey); + + const existing = localLocks.get(lockKey); + if (!existing) { + localLocks.set(lockKey, { + owner: this.getLocalOwnerToken(), + expiresAt: Date.now() + ttlMs + }); + return true; + } + + if (existing.owner === this.getLocalOwnerToken()) { + existing.expiresAt = Date.now() + ttlMs; + localLocks.set(lockKey, existing); + return true; + } + + if (attempt < maxRetries - 1) { + const delay = retryDelayMs * Math.pow(2, attempt); + await new Promise((resolve) => setTimeout(resolve, delay)); + } + } + + return false; } /** * Release a lock using Lua script to ensure atomicity * @param lockKey - Unique identifier for the lock */ - async releaseLock(lockKey: string): Promise {} + async releaseLock(lockKey: string): Promise { + this.clearExpiredLocalLock(lockKey); + const existing = localLocks.get(lockKey); + + if (existing && existing.owner === this.getLocalOwnerToken()) { + localLocks.delete(lockKey); + } + } /** * Force release a lock regardless of owner (use with caution) * @param lockKey - Unique identifier for the lock */ - async forceReleaseLock(lockKey: string): Promise {} + async forceReleaseLock(lockKey: string): Promise { + localLocks.delete(lockKey); + } /** * Check if a lock exists and get its info @@ -35,7 +90,20 @@ export class LockManager { ttl: number; owner?: string; }> { - return { exists: false, ownedByMe: false, ttl: 0 }; + this.clearExpiredLocalLock(lockKey); + const existing = localLocks.get(lockKey); + + if (!existing) { + return { exists: false, ownedByMe: false, ttl: 0 }; + } + + const ttl = Math.max(0, existing.expiresAt - Date.now()); + return { + exists: true, + ownedByMe: existing.owner === this.getLocalOwnerToken(), + ttl, + owner: existing.owner.split(":")[0] + }; } /** @@ -45,6 +113,15 @@ export class LockManager { * @returns Promise - true if extended successfully */ async extendLock(lockKey: string, ttlMs: number): Promise { + this.clearExpiredLocalLock(lockKey); + const existing = localLocks.get(lockKey); + + if (!existing || existing.owner !== this.getLocalOwnerToken()) { + return false; + } + + existing.expiresAt = Date.now() + ttlMs; + localLocks.set(lockKey, existing); return true; } @@ -62,7 +139,26 @@ export class LockManager { maxRetries: number = 5, baseDelayMs: number = 100 ): Promise { - return true; + for (let attempt = 0; attempt <= maxRetries; attempt++) { + const acquired = await this.acquireLock( + lockKey, + ttlMs, + 1, + baseDelayMs + ); + + if (acquired) { + return true; + } + + if (attempt < maxRetries) { + const delay = + baseDelayMs * Math.pow(2, attempt) + Math.random() * 100; + await new Promise((resolve) => setTimeout(resolve, delay)); + } + } + + return false; } /** @@ -99,7 +195,21 @@ export class LockManager { activeLocksCount: number; locksOwnedByMe: number; }> { - return { activeLocksCount: 0, locksOwnedByMe: 0 }; + const now = Date.now(); + for (const [key, value] of localLocks.entries()) { + if (value.expiresAt <= now) { + localLocks.delete(key); + } + } + + let locksOwnedByMe = 0; + for (const value of localLocks.values()) { + if (value.owner === this.getLocalOwnerToken()) { + locksOwnedByMe++; + } + } + + return { activeLocksCount: localLocks.size, locksOwnedByMe }; } /** diff --git a/server/private/lib/lock.ts b/server/private/lib/lock.ts index a59bbc0511..26577b2b0a 100644 --- a/server/private/lib/lock.ts +++ b/server/private/lib/lock.ts @@ -11,14 +11,31 @@ * This file is not licensed under the AGPLv3. */ -import { config } from "@server/lib/config"; import logger from "@server/logger"; import { redis } from "#private/lib/redis"; import { v4 as uuidv4 } from "uuid"; const instanceId = uuidv4(); +type LocalLockRecord = { + owner: string; + expiresAt: number; +}; + +const localLocks = new Map(); + export class LockManager { + private clearExpiredLocalLock(lockKey: string): void { + const current = localLocks.get(lockKey); + if (current && current.expiresAt <= Date.now()) { + localLocks.delete(lockKey); + } + } + + private getLocalOwnerToken(): string { + return `${instanceId}:`; + } + /** * Acquire a distributed lock using Redis SET with NX and PX options * @param lockKey - Unique identifier for the lock @@ -32,12 +49,34 @@ export class LockManager { retryDelayMs: number = 100 ): Promise { if (!redis || !redis.status || redis.status !== "ready") { - return true; + for (let attempt = 0; attempt < maxRetries; attempt++) { + this.clearExpiredLocalLock(lockKey); + + const existing = localLocks.get(lockKey); + if (!existing) { + localLocks.set(lockKey, { + owner: this.getLocalOwnerToken(), + expiresAt: Date.now() + ttlMs + }); + return true; + } + + if (existing.owner === this.getLocalOwnerToken()) { + existing.expiresAt = Date.now() + ttlMs; + localLocks.set(lockKey, existing); + return true; + } + + if (attempt < maxRetries - 1) { + const delay = retryDelayMs * Math.pow(2, attempt); + await new Promise((resolve) => setTimeout(resolve, delay)); + } + } + + return false; } - const lockValue = `${ - instanceId - }:${Date.now()}`; + const lockValue = `${instanceId}:${Date.now()}`; const redisKey = `lock:${lockKey}`; for (let attempt = 0; attempt < maxRetries; attempt++) { @@ -53,11 +92,7 @@ export class LockManager { ); if (result === "OK") { - logger.debug( - `Lock acquired: ${lockKey} by ${ - instanceId - }` - ); + logger.debug(`Lock acquired: ${lockKey} by ${instanceId}`); return true; } @@ -65,17 +100,11 @@ export class LockManager { const existingValue = await redis.get(redisKey); if ( existingValue && - existingValue.startsWith( - `${instanceId}:` - ) + existingValue.startsWith(`${instanceId}:`) ) { // Extend the lock TTL since it's the same worker await redis.pexpire(redisKey, ttlMs); - logger.debug( - `Lock extended: ${lockKey} by ${ - instanceId - }` - ); + logger.debug(`Lock extended: ${lockKey} by ${instanceId}`); return true; } @@ -88,7 +117,10 @@ export class LockManager { await new Promise((resolve) => setTimeout(resolve, delay)); } } catch (error) { - logger.error(`Failed to acquire lock ${lockKey} (attempt ${attempt + 1}/${maxRetries}):`, error); + logger.error( + `Failed to acquire lock ${lockKey} (attempt ${attempt + 1}/${maxRetries}):`, + error + ); // On error, still retry if we have attempts left if (attempt < maxRetries - 1) { const delay = retryDelayMs * Math.pow(2, attempt); @@ -109,6 +141,11 @@ export class LockManager { */ async releaseLock(lockKey: string): Promise { if (!redis || !redis.status || redis.status !== "ready") { + this.clearExpiredLocalLock(lockKey); + const existing = localLocks.get(lockKey); + if (existing && existing.owner === this.getLocalOwnerToken()) { + localLocks.delete(lockKey); + } return; } @@ -136,11 +173,7 @@ export class LockManager { )) as number; if (result === 1) { - logger.debug( - `Lock released: ${lockKey} by ${ - instanceId - }` - ); + logger.debug(`Lock released: ${lockKey} by ${instanceId}`); } else { logger.warn( `Lock not released - not owned by worker: ${lockKey} by ${ @@ -159,6 +192,7 @@ export class LockManager { */ async forceReleaseLock(lockKey: string): Promise { if (!redis || !redis.status || redis.status !== "ready") { + localLocks.delete(lockKey); return; } @@ -186,7 +220,20 @@ export class LockManager { owner?: string; }> { if (!redis || !redis.status || redis.status !== "ready") { - return { exists: false, ownedByMe: true, ttl: 0 }; + this.clearExpiredLocalLock(lockKey); + const existing = localLocks.get(lockKey); + + if (!existing) { + return { exists: false, ownedByMe: false, ttl: 0 }; + } + + const ttl = Math.max(0, existing.expiresAt - Date.now()); + return { + exists: true, + ownedByMe: existing.owner === this.getLocalOwnerToken(), + ttl, + owner: existing.owner.split(":")[0] + }; } const redisKey = `lock:${lockKey}`; @@ -198,11 +245,7 @@ export class LockManager { ]); const exists = value !== null; - const ownedByMe = - exists && - value!.startsWith( - `${instanceId}:` - ); + const ownedByMe = exists && value!.startsWith(`${instanceId}:`); const owner = exists ? value!.split(":")[0] : undefined; return { @@ -225,6 +268,15 @@ export class LockManager { */ async extendLock(lockKey: string, ttlMs: number): Promise { if (!redis || !redis.status || redis.status !== "ready") { + this.clearExpiredLocalLock(lockKey); + const existing = localLocks.get(lockKey); + + if (!existing || existing.owner !== this.getLocalOwnerToken()) { + return false; + } + + existing.expiresAt = Date.now() + ttlMs; + localLocks.set(lockKey, existing); return true; } @@ -255,9 +307,7 @@ export class LockManager { if (result === 1) { logger.debug( - `Lock extended: ${lockKey} by ${ - instanceId - } for ${ttlMs}ms` + `Lock extended: ${lockKey} by ${instanceId} for ${ttlMs}ms` ); return true; } @@ -282,12 +332,13 @@ export class LockManager { maxRetries: number = 5, baseDelayMs: number = 100 ): Promise { - if (!redis || !redis.status || redis.status !== "ready") { - return true; - } - for (let attempt = 0; attempt <= maxRetries; attempt++) { - const acquired = await this.acquireLock(lockKey, ttlMs); + const acquired = await this.acquireLock( + lockKey, + ttlMs, + 1, + baseDelayMs + ); if (acquired) { return true; @@ -319,10 +370,6 @@ export class LockManager { fn: () => Promise, ttlMs: number = 30000 ): Promise { - if (!redis || !redis.status || redis.status !== "ready") { - return await fn(); - } - const acquired = await this.acquireLock(lockKey, ttlMs); if (!acquired) { @@ -346,7 +393,21 @@ export class LockManager { locksOwnedByMe: number; }> { if (!redis || !redis.status || redis.status !== "ready") { - return { activeLocksCount: 0, locksOwnedByMe: 0 }; + const now = Date.now(); + for (const [key, value] of localLocks.entries()) { + if (value.expiresAt <= now) { + localLocks.delete(key); + } + } + + let locksOwnedByMe = 0; + for (const value of localLocks.values()) { + if (value.owner === this.getLocalOwnerToken()) { + locksOwnedByMe++; + } + } + + return { activeLocksCount: localLocks.size, locksOwnedByMe }; } try { @@ -356,11 +417,7 @@ export class LockManager { if (keys.length > 0) { const values = await redis.mget(...keys); locksOwnedByMe = values.filter( - (value) => - value && - value.startsWith( - `${instanceId}:` - ) + (value) => value && value.startsWith(`${instanceId}:`) ).length; } From 6fe4eee336d78cabc822a858731eacf3bb0ad5bc Mon Sep 17 00:00:00 2001 From: miloschwartz Date: Wed, 24 Jun 2026 16:32:46 -0400 Subject: [PATCH 66/88] improve org policy error message responses --- server/auth/sessions/app.ts | 41 ++++++++- server/middlewares/verifyAccessTokenAccess.ts | 3 +- server/middlewares/verifyAdmin.ts | 3 +- server/middlewares/verifyApiKeyAccess.ts | 3 +- server/middlewares/verifyClientAccess.ts | 8 +- server/middlewares/verifyDomainAccess.ts | 3 +- server/middlewares/verifyOrgAccess.ts | 7 +- server/middlewares/verifyResourceAccess.ts | 3 +- .../middlewares/verifyResourcePolicyAccess.ts | 3 +- server/middlewares/verifyRoleAccess.ts | 3 +- .../middlewares/verifySetResourceClients.ts | 3 +- server/middlewares/verifySetResourceUsers.ts | 3 +- server/middlewares/verifySiteAccess.ts | 3 +- .../verifySiteProvisioningKeyAccess.ts | 3 +- .../middlewares/verifySiteResourceAccess.ts | 3 +- server/middlewares/verifyTargetAccess.ts | 3 +- server/middlewares/verifyUserAccess.ts | 3 +- server/private/lib/checkOrgAccessPolicy.ts | 88 ++++++++++++++++--- server/routers/auth/changePassword.ts | 47 +--------- server/routers/auth/verifyTotp.ts | 13 +++ server/routers/resource/getExchangeToken.ts | 3 +- 21 files changed, 155 insertions(+), 94 deletions(-) diff --git a/server/auth/sessions/app.ts b/server/auth/sessions/app.ts index f6cae441b7..19875fc687 100644 --- a/server/auth/sessions/app.ts +++ b/server/auth/sessions/app.ts @@ -12,7 +12,7 @@ import { users } from "@server/db"; import { db } from "@server/db"; -import { eq, inArray } from "drizzle-orm"; +import { and, eq, inArray, ne } from "drizzle-orm"; import config from "@server/lib/config"; import type { RandomReader } from "@oslojs/crypto/random"; import { generateRandomString } from "@oslojs/crypto/random"; @@ -136,6 +136,45 @@ export async function invalidateAllSessions(userId: string): Promise { } } +export async function invalidateAllSessionsExceptCurrent( + userId: string, + currentSessionId: string +): Promise { + try { + await db.transaction(async (trx) => { + const userSessions = await trx + .select() + .from(sessions) + .where( + and( + eq(sessions.userId, userId), + ne(sessions.sessionId, currentSessionId) + ) + ); + + if (userSessions.length > 0) { + await trx.delete(resourceSessions).where( + inArray( + resourceSessions.userSessionId, + userSessions.map((s) => s.sessionId) + ) + ); + } + + await trx + .delete(sessions) + .where( + and( + eq(sessions.userId, userId), + ne(sessions.sessionId, currentSessionId) + ) + ); + }); + } catch (e) { + logger.error("Failed to invalidate user sessions except current", e); + } +} + export function serializeSessionCookie( token: string, isSecure: boolean, diff --git a/server/middlewares/verifyAccessTokenAccess.ts b/server/middlewares/verifyAccessTokenAccess.ts index 528298727f..07786e87d4 100644 --- a/server/middlewares/verifyAccessTokenAccess.ts +++ b/server/middlewares/verifyAccessTokenAccess.ts @@ -119,8 +119,7 @@ export async function verifyAccessTokenAccess( return next( createHttpError( HttpCode.FORBIDDEN, - "Failed organization access policy check: " + - (policyCheck.error || "Unknown error") + "" + (policyCheck.error || "Unknown error") ) ); } diff --git a/server/middlewares/verifyAdmin.ts b/server/middlewares/verifyAdmin.ts index 0dbeac2cb0..da4f88af91 100644 --- a/server/middlewares/verifyAdmin.ts +++ b/server/middlewares/verifyAdmin.ts @@ -56,8 +56,7 @@ export async function verifyAdmin( return next( createHttpError( HttpCode.FORBIDDEN, - "Failed organization access policy check: " + - (policyCheck.error || "Unknown error") + "" + (policyCheck.error || "Unknown error") ) ); } diff --git a/server/middlewares/verifyApiKeyAccess.ts b/server/middlewares/verifyApiKeyAccess.ts index 2522a1e8b3..ea1bdac183 100644 --- a/server/middlewares/verifyApiKeyAccess.ts +++ b/server/middlewares/verifyApiKeyAccess.ts @@ -113,8 +113,7 @@ export async function verifyApiKeyAccess( return next( createHttpError( HttpCode.FORBIDDEN, - "Failed organization access policy check: " + - (policyCheck.error || "Unknown error") + "" + (policyCheck.error || "Unknown error") ) ); } diff --git a/server/middlewares/verifyClientAccess.ts b/server/middlewares/verifyClientAccess.ts index 1d994b53f5..3aee4f38c1 100644 --- a/server/middlewares/verifyClientAccess.ts +++ b/server/middlewares/verifyClientAccess.ts @@ -107,8 +107,7 @@ export async function verifyClientAccess( return next( createHttpError( HttpCode.FORBIDDEN, - "Failed organization access policy check: " + - (policyCheck.error || "Unknown error") + "" + (policyCheck.error || "Unknown error") ) ); } @@ -129,10 +128,7 @@ export async function verifyClientAccess( .where( and( eq(roleClients.clientId, client.clientId), - inArray( - roleClients.roleId, - req.userOrgRoleIds! - ) + inArray(roleClients.roleId, req.userOrgRoleIds!) ) ) .limit(1) diff --git a/server/middlewares/verifyDomainAccess.ts b/server/middlewares/verifyDomainAccess.ts index 783132a1a2..173c3a54bd 100644 --- a/server/middlewares/verifyDomainAccess.ts +++ b/server/middlewares/verifyDomainAccess.ts @@ -88,8 +88,7 @@ export async function verifyDomainAccess( return next( createHttpError( HttpCode.FORBIDDEN, - "Failed organization access policy check: " + - (policyCheck.error || "Unknown error") + "" + (policyCheck.error || "Unknown error") ) ); } diff --git a/server/middlewares/verifyOrgAccess.ts b/server/middlewares/verifyOrgAccess.ts index e464f7b89d..030b5f7025 100644 --- a/server/middlewares/verifyOrgAccess.ts +++ b/server/middlewares/verifyOrgAccess.ts @@ -7,6 +7,7 @@ import HttpCode from "@server/types/HttpCode"; import { checkOrgAccessPolicy } from "#dynamic/lib/checkOrgAccessPolicy"; import { getUserOrgRoleIds } from "@server/lib/userOrgRoles"; import { getFirstString } from "@server/lib/requestParams"; +import logger from "@server/logger"; export async function verifyOrgAccess( req: Request, @@ -54,13 +55,15 @@ export async function verifyOrgAccess( userId, session: req.session }); + logger.debug("failed policy check", { + policyCheck + }); req.orgPolicyAllowed = policyCheck.allowed; if (!policyCheck.allowed || policyCheck.error) { return next( createHttpError( HttpCode.FORBIDDEN, - "Failed organization access policy check: " + - (policyCheck.error || "Unknown error") + "" + (policyCheck.error || "Unknown error") ) ); } diff --git a/server/middlewares/verifyResourceAccess.ts b/server/middlewares/verifyResourceAccess.ts index f790a481a8..2689cdb2d7 100644 --- a/server/middlewares/verifyResourceAccess.ts +++ b/server/middlewares/verifyResourceAccess.ts @@ -105,8 +105,7 @@ export async function verifyResourceAccess( return next( createHttpError( HttpCode.FORBIDDEN, - "Failed organization access policy check: " + - (policyCheck.error || "Unknown error") + "" + (policyCheck.error || "Unknown error") ) ); } diff --git a/server/middlewares/verifyResourcePolicyAccess.ts b/server/middlewares/verifyResourcePolicyAccess.ts index 30fe48e8cb..667680c0f2 100644 --- a/server/middlewares/verifyResourcePolicyAccess.ts +++ b/server/middlewares/verifyResourcePolicyAccess.ts @@ -102,8 +102,7 @@ export async function verifyResourcePolicyAccess( return next( createHttpError( HttpCode.FORBIDDEN, - "Failed organization access policy check: " + - (policyCheck.error || "Unknown error") + "" + (policyCheck.error || "Unknown error") ) ); } diff --git a/server/middlewares/verifyRoleAccess.ts b/server/middlewares/verifyRoleAccess.ts index 380b820488..3264a3bd99 100644 --- a/server/middlewares/verifyRoleAccess.ts +++ b/server/middlewares/verifyRoleAccess.ts @@ -132,8 +132,7 @@ export async function verifyRoleAccess( return next( createHttpError( HttpCode.FORBIDDEN, - "Failed organization access policy check: " + - (policyCheck.error || "Unknown error") + "" + (policyCheck.error || "Unknown error") ) ); } diff --git a/server/middlewares/verifySetResourceClients.ts b/server/middlewares/verifySetResourceClients.ts index 8f9c1ecaf4..443483a286 100644 --- a/server/middlewares/verifySetResourceClients.ts +++ b/server/middlewares/verifySetResourceClients.ts @@ -45,8 +45,7 @@ export async function verifySetResourceClients( return next( createHttpError( HttpCode.FORBIDDEN, - "Failed organization access policy check: " + - (policyCheck.error || "Unknown error") + "" + (policyCheck.error || "Unknown error") ) ); } diff --git a/server/middlewares/verifySetResourceUsers.ts b/server/middlewares/verifySetResourceUsers.ts index 94600b9b40..cc9375e4ad 100644 --- a/server/middlewares/verifySetResourceUsers.ts +++ b/server/middlewares/verifySetResourceUsers.ts @@ -40,8 +40,7 @@ export async function verifySetResourceUsers( return next( createHttpError( HttpCode.FORBIDDEN, - "Failed organization access policy check: " + - (policyCheck.error || "Unknown error") + "" + (policyCheck.error || "Unknown error") ) ); } diff --git a/server/middlewares/verifySiteAccess.ts b/server/middlewares/verifySiteAccess.ts index c4d35a52fa..50a940855a 100644 --- a/server/middlewares/verifySiteAccess.ts +++ b/server/middlewares/verifySiteAccess.ts @@ -115,8 +115,7 @@ export async function verifySiteAccess( return next( createHttpError( HttpCode.FORBIDDEN, - "Failed organization access policy check: " + - (policyCheck.error || "Unknown error") + "" + (policyCheck.error || "Unknown error") ) ); } diff --git a/server/middlewares/verifySiteProvisioningKeyAccess.ts b/server/middlewares/verifySiteProvisioningKeyAccess.ts index 73393e1e90..9cb9a28f32 100644 --- a/server/middlewares/verifySiteProvisioningKeyAccess.ts +++ b/server/middlewares/verifySiteProvisioningKeyAccess.ts @@ -115,8 +115,7 @@ export async function verifySiteProvisioningKeyAccess( return next( createHttpError( HttpCode.FORBIDDEN, - "Failed organization access policy check: " + - (policyCheck.error || "Unknown error") + "" + (policyCheck.error || "Unknown error") ) ); } diff --git a/server/middlewares/verifySiteResourceAccess.ts b/server/middlewares/verifySiteResourceAccess.ts index 8d5bd656f4..c87518a9e6 100644 --- a/server/middlewares/verifySiteResourceAccess.ts +++ b/server/middlewares/verifySiteResourceAccess.ts @@ -103,8 +103,7 @@ export async function verifySiteResourceAccess( return next( createHttpError( HttpCode.FORBIDDEN, - "Failed organization access policy check: " + - (policyCheck.error || "Unknown error") + "" + (policyCheck.error || "Unknown error") ) ); } diff --git a/server/middlewares/verifyTargetAccess.ts b/server/middlewares/verifyTargetAccess.ts index 8bbed6fca7..24b8abd227 100644 --- a/server/middlewares/verifyTargetAccess.ts +++ b/server/middlewares/verifyTargetAccess.ts @@ -122,8 +122,7 @@ export async function verifyTargetAccess( return next( createHttpError( HttpCode.FORBIDDEN, - "Failed organization access policy check: " + - (policyCheck.error || "Unknown error") + "" + (policyCheck.error || "Unknown error") ) ); } diff --git a/server/middlewares/verifyUserAccess.ts b/server/middlewares/verifyUserAccess.ts index fcc4d0cb94..83c344ae06 100644 --- a/server/middlewares/verifyUserAccess.ts +++ b/server/middlewares/verifyUserAccess.ts @@ -59,8 +59,7 @@ export async function verifyUserAccess( return next( createHttpError( HttpCode.FORBIDDEN, - "Failed organization access policy check: " + - (policyCheck.error || "Unknown error") + "" + (policyCheck.error || "Unknown error") ) ); } diff --git a/server/private/lib/checkOrgAccessPolicy.ts b/server/private/lib/checkOrgAccessPolicy.ts index b861c1ae6a..9a03f9e099 100644 --- a/server/private/lib/checkOrgAccessPolicy.ts +++ b/server/private/lib/checkOrgAccessPolicy.ts @@ -21,6 +21,49 @@ import { } from "@server/lib/checkOrgAccessPolicy"; import { UserType } from "@server/types/UserTypes"; +function formatMaxSessionLengthRequirement( + maxSessionLengthHours: number +): string { + if (maxSessionLengthHours < 24) { + return `This organization requires you to log in every ${maxSessionLengthHours} hours.`; + } + + const maxDays = Math.round(maxSessionLengthHours / 24); + return `This organization requires you to log in every ${maxDays} days.`; +} + +function buildOrgAccessPolicyError( + policies: CheckOrgAccessPolicyResult["policies"] +): string | undefined { + if (!policies) { + return undefined; + } + + const errors: string[] = []; + + if (policies.requiredTwoFactor === false) { + errors.push( + "This organization requires two-factor authentication. Enable two-factor authentication on your account to continue." + ); + } + + if (policies.maxSessionLength?.compliant === false) { + errors.push( + `Your session has expired. ${formatMaxSessionLengthRequirement( + policies.maxSessionLength.maxSessionLengthHours + )}` + ); + } + + if (policies.passwordAge?.compliant === false) { + errors.push( + `Your password has expired. This organization requires you to change your password every ${policies.passwordAge.maxPasswordAgeDays} days.` + ); + } + + return errors.length > 0 ? errors.join(" ") : undefined; +} + export function enforceResourceSessionLength( resourceSession: ResourceSession, org: Org @@ -36,13 +79,17 @@ export function enforceResourceSessionLength( if (sessionAgeMs > maxSessionLengthMs) { return { valid: false, - error: `Resource session has expired due to organization policy (max session length: ${maxSessionLengthHours} hours)` + error: `Your resource session has expired. ${formatMaxSessionLengthRequirement( + maxSessionLengthHours + )}` }; } } else { return { valid: false, - error: `Resource session is invalid due to organization policy (max session length: ${maxSessionLengthHours} hours)` + error: `Your resource session is invalid. ${formatMaxSessionLengthRequirement( + maxSessionLengthHours + )}` }; } } @@ -60,14 +107,20 @@ export async function checkOrgAccessPolicy( if (!orgId) { return { allowed: false, - error: "Organization ID is required" + error: "Unable to verify organization access. Organization information is missing." }; } if (!userId) { - return { allowed: false, error: "User ID is required" }; + return { + allowed: false, + error: "Unable to verify organization access. User information is missing." + }; } if (!sessionId) { - return { allowed: false, error: "Session ID is required" }; + return { + allowed: false, + error: "Your session is invalid. Please log in again." + }; } if (build === "enterprise") { @@ -89,7 +142,10 @@ export async function checkOrgAccessPolicy( .where(eq(orgs.orgId, orgId)); props.org = orgQuery; if (!props.org) { - return { allowed: false, error: "Organization not found" }; + return { + allowed: false, + error: "This organization could not be found." + }; } } @@ -100,7 +156,10 @@ export async function checkOrgAccessPolicy( .where(eq(users.userId, userId)); props.user = userQuery; if (!props.user) { - return { allowed: false, error: "User not found" }; + return { + allowed: false, + error: "Your account could not be found." + }; } } @@ -111,14 +170,17 @@ export async function checkOrgAccessPolicy( .where(eq(sessions.sessionId, sessionId)); props.session = sessionQuery; if (!props.session) { - return { allowed: false, error: "Session not found" }; + return { + allowed: false, + error: "Your session has expired. Please log in again." + }; } } if (props.session.userId !== props.user.userId) { return { allowed: false, - error: "Session does not belong to the user" + error: "Your session is invalid. Please log in again." }; } @@ -187,8 +249,14 @@ export async function checkOrgAccessPolicy( allowed = false; } + const policyError = buildOrgAccessPolicyError(policies); + return { allowed, - policies + policies, + error: allowed + ? undefined + : (policyError ?? + "You do not meet this organization's security requirements.") }; } diff --git a/server/routers/auth/changePassword.ts b/server/routers/auth/changePassword.ts index 1a26b91170..2563967635 100644 --- a/server/routers/auth/changePassword.ts +++ b/server/routers/auth/changePassword.ts @@ -10,9 +10,8 @@ import { hashPassword, verifyPassword } from "@server/auth/password"; import { verifyTotpCode } from "@server/auth/totp"; import logger from "@server/logger"; import { unauthorized } from "@server/auth/unauthorizedResponse"; -import { invalidateAllSessions } from "@server/auth/sessions/app"; -import { sessions, resourceSessions } from "@server/db"; -import { and, eq, ne, inArray } from "drizzle-orm"; +import { invalidateAllSessionsExceptCurrent } from "@server/auth/sessions/app"; +import { eq } from "drizzle-orm"; import { passwordSchema } from "@server/auth/passwordSchema"; import { UserType } from "@server/types/UserTypes"; import { sendEmail } from "@server/emails"; @@ -31,48 +30,6 @@ export type ChangePasswordResponse = { codeRequested?: boolean; }; -async function invalidateAllSessionsExceptCurrent( - userId: string, - currentSessionId: string -): Promise { - try { - await db.transaction(async (trx) => { - // Get all user sessions except the current one - const userSessions = await trx - .select() - .from(sessions) - .where( - and( - eq(sessions.userId, userId), - ne(sessions.sessionId, currentSessionId) - ) - ); - - // Delete resource sessions for the sessions we're invalidating - if (userSessions.length > 0) { - await trx.delete(resourceSessions).where( - inArray( - resourceSessions.userSessionId, - userSessions.map((s) => s.sessionId) - ) - ); - } - - // Delete the user sessions (except current) - await trx - .delete(sessions) - .where( - and( - eq(sessions.userId, userId), - ne(sessions.sessionId, currentSessionId) - ) - ); - }); - } catch (e) { - logger.error("Failed to invalidate user sessions except current", e); - } -} - export async function changePassword( req: Request, res: Response, diff --git a/server/routers/auth/verifyTotp.ts b/server/routers/auth/verifyTotp.ts index 207287ea00..5fc6cf13c5 100644 --- a/server/routers/auth/verifyTotp.ts +++ b/server/routers/auth/verifyTotp.ts @@ -15,6 +15,10 @@ import TwoFactorAuthNotification from "@server/emails/templates/TwoFactorAuthNot import config from "@server/lib/config"; import { UserType } from "@server/types/UserTypes"; import { generateBackupCodes } from "@server/lib/totp"; +import { + invalidateAllSessions, + invalidateAllSessionsExceptCurrent +} from "@server/auth/sessions/app"; import { verifySession } from "@server/auth/sessions/verifySession"; import { unauthorized } from "@server/auth/unauthorizedResponse"; @@ -168,6 +172,15 @@ export async function verifyTotp( ); } + if (existingSession) { + await invalidateAllSessionsExceptCurrent( + user.userId, + existingSession.sessionId + ); + } else { + await invalidateAllSessions(user.userId); + } + sendEmail( TwoFactorAuthNotification({ email: user.email!, diff --git a/server/routers/resource/getExchangeToken.ts b/server/routers/resource/getExchangeToken.ts index 23151d534e..0561fadbf1 100644 --- a/server/routers/resource/getExchangeToken.ts +++ b/server/routers/resource/getExchangeToken.ts @@ -80,8 +80,7 @@ export async function getExchangeToken( return next( createHttpError( HttpCode.FORBIDDEN, - "Failed organization access policy check: " + - (hasAccess.error || "Unknown error") + "" + (hasAccess.error || "Unknown error") ) ); } From 4eba51de72e55a63fd7605430c449a848745bc7d Mon Sep 17 00:00:00 2001 From: miloschwartz Date: Wed, 24 Jun 2026 17:45:44 -0400 Subject: [PATCH 67/88] support delete resources associated with site --- messages/en-US.json | 10 +- server/lib/deleteResource.ts | 144 ++++++++++++++++++ server/lib/deleteSiteAssociatedResources.ts | 126 +++++++++++++++ server/lib/deleteSiteResource.ts | 53 +++++++ server/middlewares/verifyOrgAccess.ts | 3 - server/routers/resource/deleteResource.ts | 93 ++--------- server/routers/site/deleteSite.ts | 98 +++++++++++- .../siteResource/deleteSiteResource.ts | 45 +++--- src/components/SitesTable.tsx | 56 ++++++- 9 files changed, 507 insertions(+), 121 deletions(-) create mode 100644 server/lib/deleteResource.ts create mode 100644 server/lib/deleteSiteAssociatedResources.ts create mode 100644 server/lib/deleteSiteResource.ts diff --git a/messages/en-US.json b/messages/en-US.json index 30173d651e..2a33795160 100644 --- a/messages/en-US.json +++ b/messages/en-US.json @@ -66,9 +66,15 @@ "local": "Local", "edit": "Edit", "siteConfirmDelete": "Confirm Delete Site", + "siteConfirmDeleteAndResources": "Confirm Delete Site and Resources", "siteDelete": "Delete Site", - "siteMessageRemove": "Once removed the site will no longer be accessible. All targets associated with the site will also be removed.", + "siteDeleteAndResources": "Delete Site and Resources", + "siteMessageRemove": "Once removed the site will no longer be accessible. Targets associated with this site will be removed, but resources will remain.", + "siteMessageRemoveAndResources": "This will permanently delete all public and private resources linked to this site, even if a resource is also associated with other sites.", "siteQuestionRemove": "Are you sure you want to remove the site from the organization?", + "siteQuestionRemoveAndResources": "Are you sure you want to delete this site and all associated resources?", + "sitesTableDeleteSite": "Delete Site", + "sitesTableDeleteSiteAndResources": "Delete Site and Resources", "siteManageSites": "Manage Sites", "siteDescription": "Create and manage sites to enable connectivity to private networks", "sitesBannerTitle": "Connect Any Network", @@ -204,7 +210,7 @@ "proxyResourceTitle": "Manage Public Resources", "proxyResourceDescription": "Create and manage resources that are publicly accessible through a web browser", "publicResourcesBannerTitle": "Web-based Public Access", - "publicResourcesBannerDescription": "Public resources are HTTPS proxies accessible to anyone on the internet through a web browser. Unlike private resources, they do not require client-side software and can include identity and context-aware access policies.", + "publicResourcesBannerDescription": "Public resources are proxies accessible to anyone on the internet through a web browser and include identity and context-aware access policies. Unlike private resources, they do not require client-side software.", "clientResourceTitle": "Manage Private Resources", "clientResourceDescription": "Create and manage resources that are only accessible through a connected client", "privateResourcesBannerTitle": "Zero-Trust Private Access", diff --git a/server/lib/deleteResource.ts b/server/lib/deleteResource.ts new file mode 100644 index 0000000000..b2ffa0f0f1 --- /dev/null +++ b/server/lib/deleteResource.ts @@ -0,0 +1,144 @@ +import { eq, inArray } from "drizzle-orm"; +import { + db, + newts, + resourcePolicies, + resources, + sites, + targetHealthCheck, + targets, + type Resource, + type Target, + type TargetHealthCheck, + type Transaction +} from "@server/db"; +import logger from "@server/logger"; +import { removeTargets } from "@server/routers/newt/targets"; +import createHttpError from "http-errors"; +import HttpCode from "@server/types/HttpCode"; + +export type DeleteResourceResult = { + deletedResource: Resource; + targetsToBeRemoved: Target[]; + healthChecksToBeRemoved: TargetHealthCheck[]; +}; + +export async function performDeleteResources( + resourceIds: number[], + trx: Transaction | typeof db = db +): Promise { + if (resourceIds.length === 0) { + return []; + } + + const targetsToBeRemoved = await trx + .select() + .from(targets) + .where(inArray(targets.resourceId, resourceIds)); + + const targetIds = targetsToBeRemoved.map((t) => t.targetId); + const healthChecksToBeRemoved = + targetIds.length > 0 + ? await trx + .select() + .from(targetHealthCheck) + .where(inArray(targetHealthCheck.targetId, targetIds)) + : []; + + const deletedResources = await trx + .delete(resources) + .where(inArray(resources.resourceId, resourceIds)) + .returning(); + + const policyIds = deletedResources + .map((resource) => resource.defaultResourcePolicyId) + .filter((id): id is number => id != null); + + if (policyIds.length > 0) { + await trx + .delete(resourcePolicies) + .where(inArray(resourcePolicies.resourcePolicyId, policyIds)); + } + + if (deletedResources.length > 0) { + logger.debug(`Deleted ${deletedResources.length} resources`); + } + + const targetsByResourceId = new Map(); + for (const target of targetsToBeRemoved) { + const existing = targetsByResourceId.get(target.resourceId) ?? []; + existing.push(target); + targetsByResourceId.set(target.resourceId, existing); + } + + const targetIdToResourceId = new Map( + targetsToBeRemoved.map((target) => [target.targetId, target.resourceId]) + ); + + const healthChecksByResourceId = new Map(); + for (const healthCheck of healthChecksToBeRemoved) { + const resourceId = targetIdToResourceId.get(healthCheck.targetId!); + if (resourceId == null) { + continue; + } + const existing = healthChecksByResourceId.get(resourceId) ?? []; + existing.push(healthCheck); + healthChecksByResourceId.set(resourceId, existing); + } + + return deletedResources.map((deletedResource) => ({ + deletedResource, + targetsToBeRemoved: + targetsByResourceId.get(deletedResource.resourceId) ?? [], + healthChecksToBeRemoved: + healthChecksByResourceId.get(deletedResource.resourceId) ?? [] + })); +} + +export async function performDeleteResource( + resourceId: number, + trx: Transaction | typeof db = db +): Promise { + const [result] = await performDeleteResources([resourceId], trx); + return result ?? null; +} + +export async function runResourceDeleteSideEffects( + result: DeleteResourceResult +): Promise { + const { deletedResource, targetsToBeRemoved, healthChecksToBeRemoved } = + result; + + for (const target of targetsToBeRemoved) { + const [site] = await db + .select() + .from(sites) + .where(eq(sites.siteId, target.siteId)) + .limit(1); + + if (!site) { + throw createHttpError( + HttpCode.NOT_FOUND, + `Site with ID ${target.siteId} not found` + ); + } + + if (site.pubKey && site.type === "newt") { + const [newt] = await db + .select() + .from(newts) + .where(eq(newts.siteId, site.siteId)) + .limit(1); + + if (newt) { + await removeTargets( + newt.newtId, + [], + healthChecksToBeRemoved, + deletedResource.mode === "udp" ? "udp" : "tcp", + newt.version + ); + } + } + } +} diff --git a/server/lib/deleteSiteAssociatedResources.ts b/server/lib/deleteSiteAssociatedResources.ts new file mode 100644 index 0000000000..61da82f58f --- /dev/null +++ b/server/lib/deleteSiteAssociatedResources.ts @@ -0,0 +1,126 @@ +import { and, eq, sql } from "drizzle-orm"; +import { + db, + siteNetworks, + siteResources, + targets, + type SiteResource, + type Transaction +} from "@server/db"; +import { + performDeleteResources, + runResourceDeleteSideEffects, + type DeleteResourceResult +} from "@server/lib/deleteResource"; +import { + performDeleteSiteResources, + runSiteResourceDeleteSideEffects +} from "@server/lib/deleteSiteResource"; +import logger from "@server/logger"; + +export const MAX_SITE_ASSOCIATED_RESOURCES_FOR_BULK_DELETE = 250; + +export type DeleteSiteAssociatedResourcesSideEffects = { + resources: DeleteResourceResult[]; + siteResources: SiteResource[]; +}; + +export async function getResourceIdsForSite( + siteId: number, + trx: Transaction | typeof db = db +): Promise { + const rows = await trx + .selectDistinct({ resourceId: targets.resourceId }) + .from(targets) + .where(eq(targets.siteId, siteId)); + + return rows.map((row) => row.resourceId); +} + +export async function getSiteResourceIdsForSite( + siteId: number, + orgId: string, + trx: Transaction | typeof db = db +): Promise { + const rows = await trx + .selectDistinct({ siteResourceId: siteResources.siteResourceId }) + .from(siteNetworks) + .innerJoin( + siteResources, + eq(siteResources.networkId, siteNetworks.networkId) + ) + .where( + and(eq(siteNetworks.siteId, siteId), eq(siteResources.orgId, orgId)) + ); + + return rows.map((row) => row.siteResourceId); +} + +export async function getAssociatedResourceCountForSite( + siteId: number, + orgId: string, + trx: Transaction | typeof db = db +): Promise { + const [publicCountResult, privateCountResult] = await Promise.all([ + trx + .select({ + count: sql`count(distinct ${targets.resourceId})` + }) + .from(targets) + .where(eq(targets.siteId, siteId)), + trx + .select({ + count: sql`count(distinct ${siteResources.siteResourceId})` + }) + .from(siteNetworks) + .innerJoin( + siteResources, + eq(siteResources.networkId, siteNetworks.networkId) + ) + .where( + and( + eq(siteNetworks.siteId, siteId), + eq(siteResources.orgId, orgId) + ) + ) + ]); + + return ( + Number(publicCountResult[0]?.count ?? 0) + + Number(privateCountResult[0]?.count ?? 0) + ); +} + +export function exceedsSiteAssociatedResourceDeleteLimit( + resourceCount: number +): boolean { + return resourceCount > MAX_SITE_ASSOCIATED_RESOURCES_FOR_BULK_DELETE; +} + +export async function deleteAssociatedResourcesForSite( + siteId: number, + orgId: string, + trx: Transaction | typeof db = db +): Promise { + const resourceIds = await getResourceIdsForSite(siteId, trx); + const siteResourceIds = await getSiteResourceIdsForSite(siteId, orgId, trx); + + const [resources, siteResourcesDeleted] = await Promise.all([ + performDeleteResources(resourceIds, trx), + performDeleteSiteResources(siteResourceIds, trx) + ]); + + return { resources, siteResources: siteResourcesDeleted }; +} + +export async function runDeleteSiteAssociatedResourcesSideEffects( + sideEffects: DeleteSiteAssociatedResourcesSideEffects +): Promise { + for (const result of sideEffects.resources) { + await runResourceDeleteSideEffects(result); + } + + for (const removed of sideEffects.siteResources) { + runSiteResourceDeleteSideEffects(removed); + } +} diff --git a/server/lib/deleteSiteResource.ts b/server/lib/deleteSiteResource.ts new file mode 100644 index 0000000000..9db5bd902e --- /dev/null +++ b/server/lib/deleteSiteResource.ts @@ -0,0 +1,53 @@ +import { inArray } from "drizzle-orm"; +import { + db, + siteResources, + type SiteResource, + type Transaction +} from "@server/db"; +import logger from "@server/logger"; +import { rebuildClientAssociationsFromSiteResource } from "@server/lib/rebuildClientAssociations"; + +export async function performDeleteSiteResources( + siteResourceIds: number[], + trx: Transaction | typeof db = db +): Promise { + if (siteResourceIds.length === 0) { + return []; + } + + const removedSiteResources = await trx + .delete(siteResources) + .where(inArray(siteResources.siteResourceId, siteResourceIds)) + .returning(); + + if (removedSiteResources.length > 0) { + logger.debug(`Deleted ${removedSiteResources.length} site resources`); + } + + return removedSiteResources; +} + +export async function performDeleteSiteResource( + siteResourceId: number, + trx: Transaction | typeof db = db +): Promise { + const [removedSiteResource] = await performDeleteSiteResources( + [siteResourceId], + trx + ); + return removedSiteResource ?? null; +} + +export function runSiteResourceDeleteSideEffects( + removedSiteResource: SiteResource +): void { + rebuildClientAssociationsFromSiteResource(removedSiteResource).catch( + (err) => { + logger.error( + `Error rebuilding client associations for site resource ${removedSiteResource.siteResourceId}:`, + err + ); + } + ); +} diff --git a/server/middlewares/verifyOrgAccess.ts b/server/middlewares/verifyOrgAccess.ts index 030b5f7025..be6242f6dc 100644 --- a/server/middlewares/verifyOrgAccess.ts +++ b/server/middlewares/verifyOrgAccess.ts @@ -55,9 +55,6 @@ export async function verifyOrgAccess( userId, session: req.session }); - logger.debug("failed policy check", { - policyCheck - }); req.orgPolicyAllowed = policyCheck.allowed; if (!policyCheck.allowed || policyCheck.error) { return next( diff --git a/server/routers/resource/deleteResource.ts b/server/routers/resource/deleteResource.ts index a959611ecd..766b25b041 100644 --- a/server/routers/resource/deleteResource.ts +++ b/server/routers/resource/deleteResource.ts @@ -1,13 +1,4 @@ -import { eq, inArray } from "drizzle-orm"; -import { - db, - newts, - resourcePolicies, - resources, - sites, - targetHealthCheck, - targets -} from "@server/db"; +import { db } from "@server/db"; import response from "@server/lib/response"; import logger from "@server/logger"; import { OpenAPITags, registry } from "@server/openApi"; @@ -16,9 +7,11 @@ import { NextFunction, Request, Response } from "express"; import createHttpError from "http-errors"; import { z } from "zod"; import { fromError } from "zod-validation-error"; -import { removeTargets } from "../newt/targets"; +import { + performDeleteResource, + runResourceDeleteSideEffects +} from "@server/lib/deleteResource"; -// Define Zod schema for request parameters validation const deleteResourceSchema = z.strictObject({ resourceId: z.coerce.number().int().positive() }); @@ -67,27 +60,13 @@ export async function deleteResource( const { resourceId } = parsedParams.data; - const targetsToBeRemoved = await db - .select() - .from(targets) - .where(eq(targets.resourceId, resourceId)); - - const healthChecksToBeRemoved = await db - .select() - .from(targetHealthCheck) - .where( - inArray( - targetHealthCheck.targetId, - targetsToBeRemoved.map((t) => t.targetId) - ) - ); + let deleteResult = null; - const [deletedResource] = await db - .delete(resources) - .where(eq(resources.resourceId, resourceId)) - .returning(); + await db.transaction(async (trx) => { + deleteResult = await performDeleteResource(resourceId, trx); + }); - if (!deletedResource) { + if (!deleteResult) { return next( createHttpError( HttpCode.NOT_FOUND, @@ -96,54 +75,7 @@ export async function deleteResource( ); } - for (const target of targetsToBeRemoved) { - const [site] = await db - .select() - .from(sites) - .where(eq(sites.siteId, target.siteId)) - .limit(1); - - if (!site) { - return next( - createHttpError( - HttpCode.NOT_FOUND, - `Site with ID ${target.siteId} not found` - ) - ); - } - - if (site.pubKey) { - if (site.type == "newt") { - // get the newt on the site by querying the newt table for siteId - const [newt] = await db - .select() - .from(newts) - .where(eq(newts.siteId, site.siteId)) - .limit(1); - - await removeTargets( - newt.newtId, - // [target], - [], // deleting the target from newt causes issues because we cant unbind the port. this needs to be fixed in newt before we can do this - healthChecksToBeRemoved, - deletedResource.mode === "udp" ? "udp" : "tcp", - newt.version - ); - } - } - } - - // Also delete default resource policy - if (deletedResource.defaultResourcePolicyId) { - await db - .delete(resourcePolicies) - .where( - eq( - resourcePolicies.resourcePolicyId, - deletedResource.defaultResourcePolicyId - ) - ); - } + await runResourceDeleteSideEffects(deleteResult); return response(res, { data: null, @@ -154,6 +86,9 @@ export async function deleteResource( }); } catch (error) { logger.error(error); + if (createHttpError.isHttpError(error)) { + return next(error); + } return next( createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred") ); diff --git a/server/routers/site/deleteSite.ts b/server/routers/site/deleteSite.ts index 0773762118..300c570d8a 100644 --- a/server/routers/site/deleteSite.ts +++ b/server/routers/site/deleteSite.ts @@ -14,18 +14,41 @@ import { OpenAPITags, registry } from "@server/openApi"; import { cleanupSiteAssociations } from "@server/lib/rebuildClientAssociations"; import { usageService } from "@server/lib/billing/usageService"; import { FeatureId } from "@server/lib/billing"; +import { ActionsEnum, checkUserActionPermission } from "@server/auth/actions"; +import { + deleteAssociatedResourcesForSite, + exceedsSiteAssociatedResourceDeleteLimit, + getAssociatedResourceCountForSite, + runDeleteSiteAssociatedResourcesSideEffects, + MAX_SITE_ASSOCIATED_RESOURCES_FOR_BULK_DELETE, + type DeleteSiteAssociatedResourcesSideEffects +} from "@server/lib/deleteSiteAssociatedResources"; const deleteSiteSchema = z.strictObject({ siteId: z.coerce.number().int().positive() }); +const deleteSiteQuerySchema = z.strictObject({ + deleteResources: z + .enum(["true", "false"]) + .transform((v) => v === "true") + .optional() + .catch(false) + .openapi({ + type: "boolean", + description: + "When true, also deletes all public and private resources associated with this site" + }) +}); + registry.registerPath({ method: "delete", path: "/site/{siteId}", description: "Delete a site and all its associated data.", tags: [OpenAPITags.Site], request: { - params: deleteSiteSchema + params: deleteSiteSchema, + query: deleteSiteQuerySchema }, responses: { 200: { @@ -61,7 +84,18 @@ export async function deleteSite( ); } + const parsedQuery = deleteSiteQuerySchema.safeParse(req.query); + if (!parsedQuery.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedQuery.error).toString() + ) + ); + } + const { siteId } = parsedParams.data; + const { deleteResources } = parsedQuery.data; const [site] = await db .select() @@ -78,20 +112,67 @@ export async function deleteSite( ); } + if (deleteResources) { + const canDeletePublic = await checkUserActionPermission( + ActionsEnum.deleteResource, + req + ); + const canDeletePrivate = await checkUserActionPermission( + ActionsEnum.deleteSiteResource, + req + ); + + if (!canDeletePublic || !canDeletePrivate) { + return next( + createHttpError( + HttpCode.FORBIDDEN, + "User does not have permission to delete associated resources" + ) + ); + } + + const associatedResourceCount = + await getAssociatedResourceCountForSite(siteId, site.orgId); + + if ( + exceedsSiteAssociatedResourceDeleteLimit( + associatedResourceCount + ) + ) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + `Cannot delete site and associated resources when the site has more than ${MAX_SITE_ASSOCIATED_RESOURCES_FOR_BULK_DELETE} resources` + ) + ); + } + } + const [deletedNewt] = await db .select() .from(newts) .where(eq(newts.siteId, siteId)) .limit(1); + let resourceSideEffects: DeleteSiteAssociatedResourcesSideEffects = { + resources: [], + siteResources: [] + }; + await db.transaction(async (trx) => { + if (deleteResources) { + resourceSideEffects = await deleteAssociatedResourcesForSite( + siteId, + site.orgId, + trx + ); + } + if (site.type == "wireguard") { if (site.pubKey) { await deletePeer(site.exitNodeId!, site.pubKey); } } else if (site.type == "newt") { - // Clean up all client associations and send peer/proxy removal - // messages in a single efficient pass before deleting the row. await cleanupSiteAssociations(site, trx); } @@ -99,13 +180,17 @@ export async function deleteSite( await usageService.add(site.orgId, FeatureId.SITES, -1, trx); }); - // Send termination message outside of transaction to prevent blocking + if (deleteResources) { + await runDeleteSiteAssociatedResourcesSideEffects( + resourceSideEffects + ); + } + if (deletedNewt) { const payload = { type: `newt/wg/terminate`, data: {} }; - // Don't await this to prevent blocking the response sendToClient(deletedNewt.newtId, payload).catch((error) => { logger.error( "Failed to send termination message to newt:", @@ -123,6 +208,9 @@ export async function deleteSite( }); } catch (error) { logger.error(error); + if (createHttpError.isHttpError(error)) { + return next(error); + } return next( createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred") ); diff --git a/server/routers/siteResource/deleteSiteResource.ts b/server/routers/siteResource/deleteSiteResource.ts index b9efc5ba80..cddeb490b9 100644 --- a/server/routers/siteResource/deleteSiteResource.ts +++ b/server/routers/siteResource/deleteSiteResource.ts @@ -1,15 +1,17 @@ import { Request, Response, NextFunction } from "express"; import { z } from "zod"; -import { db, newts, primaryDb, sites } from "@server/db"; -import { siteResources } from "@server/db"; +import { db, siteResources } from "@server/db"; import response from "@server/lib/response"; import HttpCode from "@server/types/HttpCode"; import createHttpError from "http-errors"; -import { eq, and } from "drizzle-orm"; +import { eq } from "drizzle-orm"; import { fromError } from "zod-validation-error"; import logger from "@server/logger"; import { OpenAPITags, registry } from "@server/openApi"; -import { rebuildClientAssociationsFromSiteResource } from "@server/lib/rebuildClientAssociations"; +import { + performDeleteSiteResource, + runSiteResourceDeleteSideEffects +} from "@server/lib/deleteSiteResource"; const deleteSiteResourceParamsSchema = z.strictObject({ siteResourceId: z.coerce.number().int().positive() @@ -65,11 +67,10 @@ export async function deleteSiteResource( const { siteResourceId } = parsedParams.data; - // Check if site resource exists const [existingSiteResource] = await db .select() .from(siteResources) - .where(and(eq(siteResources.siteResourceId, siteResourceId))) + .where(eq(siteResources.siteResourceId, siteResourceId)) .limit(1); if (!existingSiteResource) { @@ -78,26 +79,22 @@ export async function deleteSiteResource( ); } - // Delete the site resource - const [removedSiteResource] = await db - .delete(siteResources) - .where(eq(siteResources.siteResourceId, siteResourceId)) - .returning(); + let removedSiteResource = null; - // Run in the background after the response is sent. Wrapped in its - // own transaction so it always executes on the primary — avoiding any - // replica-lag issues while still allowing the HTTP response to return - // early. - rebuildClientAssociationsFromSiteResource(removedSiteResource).catch( - (err) => { - logger.error( - `Error rebuilding client associations for site resource ${removedSiteResource!.siteResourceId}:`, - err - ); - } - ); + await db.transaction(async (trx) => { + removedSiteResource = await performDeleteSiteResource( + siteResourceId, + trx + ); + }); + + if (!removedSiteResource) { + return next( + createHttpError(HttpCode.NOT_FOUND, "Site resource not found") + ); + } - logger.info(`Deleted site resource ${siteResourceId}`); + runSiteResourceDeleteSideEffects(removedSiteResource); return response(res, { data: { message: "Site resource deleted successfully" }, diff --git a/src/components/SitesTable.tsx b/src/components/SitesTable.tsx index 5b5ac1db14..3dc7a56dae 100644 --- a/src/components/SitesTable.tsx +++ b/src/components/SitesTable.tsx @@ -19,6 +19,7 @@ import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, + DropdownMenuSeparator, DropdownMenuTrigger } from "@app/components/ui/dropdown-menu"; import { InfoPopup } from "@app/components/ui/info-popup"; @@ -104,6 +105,7 @@ export default function SitesTable({ } = useNavigationContext(); const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false); + const [deleteWithResources, setDeleteWithResources] = useState(false); const [selectedSite, setSelectedSite] = useState(null); const [resourcesDialogSite, setResourcesDialogSite] = useState(null); @@ -157,10 +159,12 @@ export default function SitesTable({ }); } - function deleteSite(siteId: number) { + function deleteSite(siteId: number, withResources: boolean) { startTransition(async () => { await api - .delete(`/site/${siteId}`) + .delete(`/site/${siteId}`, { + params: { deleteResources: withResources } + }) .catch((e) => { console.error(t("siteErrorDelete"), e); toast({ @@ -521,16 +525,33 @@ export default function SitesTable({ )} + { setSelectedSite(siteRow); + setDeleteWithResources(false); setIsDeleteModalOpen(true); }} > - {t("delete")} + {t("sitesTableDeleteSite")} + {siteRow.resourceCount <= 250 && ( + { + setSelectedSite(siteRow); + setDeleteWithResources(true); + setIsDeleteModalOpen(true); + }} + > + + {t( + "sitesTableDeleteSiteAndResources" + )} + + + )} { setIsDeleteModalOpen(val); setSelectedSite(null); + setDeleteWithResources(false); }} dialog={
-

{t("siteQuestionRemove")}

-

{t("siteMessageRemove")}

+

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

+

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

} - buttonText={t("siteConfirmDelete")} + buttonText={ + deleteWithResources + ? t("siteConfirmDeleteAndResources") + : t("siteConfirmDelete") + } onConfirm={async () => - startTransition(() => deleteSite(selectedSite!.id)) + startTransition(() => + deleteSite(selectedSite!.id, deleteWithResources) + ) } string={selectedSite.name} - title={t("siteDelete")} + title={ + deleteWithResources + ? t("siteDeleteAndResources") + : t("siteDelete") + } /> )} From d0defa380a10ed9e150bee99a8659b237b97ab57 Mon Sep 17 00:00:00 2001 From: miloschwartz Date: Wed, 24 Jun 2026 17:52:21 -0400 Subject: [PATCH 68/88] remove split by command and space in role form --- messages/en-US.json | 4 ++-- src/components/RoleForm.tsx | 18 +++++------------- 2 files changed, 7 insertions(+), 15 deletions(-) diff --git a/messages/en-US.json b/messages/en-US.json index 2a33795160..83e8a488d7 100644 --- a/messages/en-US.json +++ b/messages/en-US.json @@ -2177,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}.", diff --git a/src/components/RoleForm.tsx b/src/components/RoleForm.tsx index ea45817ae1..102aa8e3a8 100644 --- a/src/components/RoleForm.tsx +++ b/src/components/RoleForm.tsx @@ -61,7 +61,7 @@ export function parseUnixGroups(value: string | undefined): string[] { if (!value?.trim()) return []; return value - .split(/[,\s\n]+/) + .split(/\r?\n/) .map((group) => group.trim()) .filter(Boolean); } @@ -69,18 +69,10 @@ export function parseUnixGroups(value: string | undefined): string[] { export function parseSudoCommands(value: string | undefined): string[] { if (!value?.trim()) return []; - const commands: string[] = []; - for (const segment of value.split(/[,\n]+/)) { - const trimmed = segment.trim(); - if (!trimmed) continue; - - for (const part of trimmed.split(/ (?=\/)/)) { - const command = part.trim(); - if (command) commands.push(command); - } - } - - return commands; + return value + .split(/\r?\n/) + .map((command) => command.trim()) + .filter(Boolean); } function hasOnlyAbsoluteSudoCommands(value: string | undefined): boolean { From 79de64dc07358238e793382ea69401975f28745e Mon Sep 17 00:00:00 2001 From: Owen Date: Wed, 24 Jun 2026 16:40:58 -0400 Subject: [PATCH 69/88] Fix removing site not removing peer --- server/lib/rebuildClientAssociations.ts | 93 ++++++++++++++++--------- 1 file changed, 61 insertions(+), 32 deletions(-) diff --git a/server/lib/rebuildClientAssociations.ts b/server/lib/rebuildClientAssociations.ts index 72a16efcfc..1bc01238cd 100644 --- a/server/lib/rebuildClientAssociations.ts +++ b/server/lib/rebuildClientAssociations.ts @@ -312,14 +312,62 @@ async function rebuildClientAssociationsFromSiteResourceImpl( /////////// process the client-siteResource associations /////////// + const existingClientSiteResources = await trx + .select({ + clientId: clientSiteResourcesAssociationsCache.clientId + }) + .from(clientSiteResourcesAssociationsCache) + .where( + eq( + clientSiteResourcesAssociationsCache.siteResourceId, + siteResource.siteResourceId + ) + ); + + const existingClientSiteResourceIds = existingClientSiteResources.map( + (row) => row.clientId + ); + // get all of the clients associated with other site resources that share // any of the same sites as this site resource (via siteNetworks). We can't // simply filter by networkId since each site resource has its own network; // two site resources serving the same site typically belong to different // networks that both happen to include the site through siteNetworks. const sitesListSiteIds = sitesList.map((s) => s.siteId); + + // We must also consider sites where these clients are currently cached, + // otherwise removing a site from this resource can leave stale + // client-site cache entries behind for the removed site. + const cachedSiteRowsForResourceClients = + existingClientSiteResourceIds.length > 0 + ? await trx + .select({ siteId: clientSitesAssociationsCache.siteId }) + .from(clientSitesAssociationsCache) + .where( + inArray( + clientSitesAssociationsCache.clientId, + existingClientSiteResourceIds + ) + ) + : []; + + const allCandidateSiteIds = Array.from( + new Set([ + ...sitesListSiteIds, + ...cachedSiteRowsForResourceClients.map((r) => r.siteId) + ]) + ); + + const sitesToProcess = + allCandidateSiteIds.length > 0 + ? await trx + .select() + .from(sites) + .where(inArray(sites.siteId, allCandidateSiteIds)) + : []; + const currentSiteIdSet = new Set(sitesListSiteIds); const allUpdatedClientsFromOtherResourcesOnThisSite = - sitesListSiteIds.length > 0 + allCandidateSiteIds.length > 0 ? await trx .select({ clientId: clientSiteResourcesAssociationsCache.clientId, @@ -339,7 +387,7 @@ async function rebuildClientAssociationsFromSiteResourceImpl( ) .where( and( - inArray(siteNetworks.siteId, sitesListSiteIds), + inArray(siteNetworks.siteId, allCandidateSiteIds), ne( siteResources.siteResourceId, siteResource.siteResourceId @@ -358,22 +406,6 @@ async function rebuildClientAssociationsFromSiteResourceImpl( clientsFromOtherResourcesBySite.get(row.siteId)!.add(row.clientId); } - const existingClientSiteResources = await trx - .select({ - clientId: clientSiteResourcesAssociationsCache.clientId - }) - .from(clientSiteResourcesAssociationsCache) - .where( - eq( - clientSiteResourcesAssociationsCache.siteResourceId, - siteResource.siteResourceId - ) - ); - - const existingClientSiteResourceIds = existingClientSiteResources.map( - (row) => row.clientId - ); - logger.debug( `rebuildClientAssociations: [rebuildClientAssociationsFromSiteResource] siteResourceId=${siteResource.siteResourceId} existingResourceClientIds=[${existingClientSiteResourceIds.join(", ")}]` ); @@ -456,10 +488,10 @@ async function rebuildClientAssociationsFromSiteResourceImpl( /////////// process the client-site associations /////////// logger.debug( - `rebuildClientAssociations: [rebuildClientAssociationsFromSiteResource] siteResourceId=${siteResource.siteResourceId} beginning client-site association loop over ${sitesList.length} site(s)` + `rebuildClientAssociations: [rebuildClientAssociationsFromSiteResource] siteResourceId=${siteResource.siteResourceId} beginning client-site association loop over ${sitesToProcess.length} site(s) (current=${sitesList.length})` ); - for (const site of sitesList) { + for (const site of sitesToProcess) { const siteId = site.siteId; logger.debug( @@ -501,7 +533,13 @@ async function rebuildClientAssociationsFromSiteResourceImpl( `rebuildClientAssociations: [rebuildClientAssociationsFromSiteResource] siteId=${siteId} otherResourceClientIds=[${[...otherResourceClientIds].join(", ")}] mergedAllClientIds=[${mergedAllClientIds.join(", ")}]` ); - const clientSitesToAdd = mergedAllClientIds.filter( + // Expected clients from this resource are site-scoped: if this site is + // no longer attached to the resource, the expected set is empty. + const expectedClientIdsForSite = currentSiteIdSet.has(siteId) + ? mergedAllClientIds + : []; + + const clientSitesToAdd = expectedClientIdsForSite.filter( (clientId) => !existingClientSiteIds.includes(clientId) && !otherResourceClientIds.has(clientId) // dont add if already connected via another site resource @@ -536,7 +574,7 @@ async function rebuildClientAssociationsFromSiteResourceImpl( // Now remove any client-site associations that should no longer exist const clientSitesToRemove = existingClientSiteIds.filter( (clientId) => - !mergedAllClientIds.includes(clientId) && + !expectedClientIdsForSite.includes(clientId) && !otherResourceClientIds.has(clientId) // dont remove if there is still another connection for another site resource ); @@ -1211,7 +1249,6 @@ export async function handleMessagingForUpdatedSiteResource( ); const oldDestinationStillInUseClientSitePairs = new Set(); - const oldAliasStillInUseClientSitePairs = new Set(); if ( existingSiteResource?.destination && allSiteIds.length > 0 && @@ -1316,11 +1353,6 @@ export async function handleMessagingForUpdatedSiteResource( `${client.clientId}:${siteId}` ); - if (oldDestinationStillInUseByASite && allSiteIds.length > 0) { - // nothing in the message anyway lets just continue - continue; - } - peerDataRemoves.push({ // this might happen twice after the rebuild function but that is okay clientId: client.clientId, @@ -1328,10 +1360,7 @@ export async function handleMessagingForUpdatedSiteResource( remoteSubnets: !oldDestinationStillInUseByASite ? generateRemoteSubnets([updatedSiteResource]) : [], - aliases: - allSiteIds.length == 0 - ? generateAliasConfig([updatedSiteResource]) - : [] + aliases: generateAliasConfig([updatedSiteResource]) }); } } From be3877a3ce309100f26d495c481a725eea5d56cd Mon Sep 17 00:00:00 2001 From: Owen Date: Wed, 24 Jun 2026 18:35:42 -0400 Subject: [PATCH 70/88] Rename for clarity --- server/lib/rebuildClientAssociations.ts | 32 +++++++++++++++---------- 1 file changed, 19 insertions(+), 13 deletions(-) diff --git a/server/lib/rebuildClientAssociations.ts b/server/lib/rebuildClientAssociations.ts index 1bc01238cd..1f675e81ed 100644 --- a/server/lib/rebuildClientAssociations.ts +++ b/server/lib/rebuildClientAssociations.ts @@ -35,6 +35,7 @@ import { parseEndpoint } from "@server/lib/ip"; import { + addPeerData, addPeerDataBatch, addTargetsBatch as addSubnetProxyTargetsBatch, removePeerDataBatch, @@ -1258,6 +1259,7 @@ export async function handleMessagingForUpdatedSiteResource( `handleMessagingForUpdatedSiteResource: checking old destination reuse destination=${existingSiteResource.destination} across siteCount=${allSiteIds.length} clientCount=${mergedAllClients.length}` ); + // we need to do this because the client only knows about peers not resources so we need to make sure that we dont remove it if there is still a another resource const oldDestinationStillInUseRows = await trx .select({ clientId: clientSiteResourcesAssociationsCache.clientId, @@ -1348,20 +1350,23 @@ export async function handleMessagingForUpdatedSiteResource( version: newt.version }); for (const client of mergedAllClients) { - const oldDestinationStillInUseByASite = + // we need to do this because the client only knows about peers not resources so we need to make sure that we dont remove it if there is still a another resource + const oldDestinationStillInUseBySite = oldDestinationStillInUseClientSitePairs.has( `${client.clientId}:${siteId}` ); - peerDataRemoves.push({ - // this might happen twice after the rebuild function but that is okay - clientId: client.clientId, - siteId, - remoteSubnets: !oldDestinationStillInUseByASite - ? generateRemoteSubnets([updatedSiteResource]) - : [], - aliases: generateAliasConfig([updatedSiteResource]) - }); + if (existingSiteResource) { + peerDataRemoves.push({ + // this might happen twice after the rebuild function but that is okay + clientId: client.clientId, + siteId, + remoteSubnets: !oldDestinationStillInUseBySite + ? generateRemoteSubnets([existingSiteResource]) + : [], + aliases: generateAliasConfig([existingSiteResource]) + }); + } } } } else { @@ -1577,7 +1582,8 @@ export async function handleMessagingForUpdatedSiteResource( continue; } - const oldDestinationStillInUseByASite = + // we need to do this because the client only knows about peers not resources so we need to make sure that we dont remove it if there is still a another resource + const oldDestinationStillInUseBySite = oldDestinationStillInUseClientSitePairs.has( `${client.clientId}:${siteId}` ); @@ -1588,7 +1594,7 @@ export async function handleMessagingForUpdatedSiteResource( siteId, remoteSubnets: destinationChanged ? { - oldRemoteSubnets: !oldDestinationStillInUseByASite + oldRemoteSubnets: !oldDestinationStillInUseBySite ? generateRemoteSubnets([ existingSiteResource ]) @@ -2363,7 +2369,7 @@ async function handleMessagesForClientResources( ) ); - // Only remove remote subnet if no other resource uses the same destination + // Only remove remote subnet if no other resource uses the same destination on the same site const remoteSubnetsToRemove = destinationStillInUse.length > 0 ? [] From 877985deb3605674e8852c21b39733f252193226 Mon Sep 17 00:00:00 2001 From: Owen Date: Wed, 24 Jun 2026 18:35:52 -0400 Subject: [PATCH 71/88] Spellcheck --- .vscode/settings.json | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/.vscode/settings.json b/.vscode/settings.json index 5092cb6c18..a7da2a1cc2 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -18,5 +18,8 @@ "[json]": { "editor.defaultFormatter": "esbenp.prettier-vscode" }, - "editor.formatOnSave": true + "editor.formatOnSave": true, + "cSpell.words": [ + "nessicary" + ] } \ No newline at end of file From f8591f27c58ddc1916113eb80dae17e8975834c5 Mon Sep 17 00:00:00 2001 From: Owen Date: Thu, 25 Jun 2026 10:08:37 -0400 Subject: [PATCH 72/88] Fix no data when last data was over 90 days ago --- server/lib/statusHistory.ts | 34 ++++++++++++++++++++++++++++++---- 1 file changed, 30 insertions(+), 4 deletions(-) diff --git a/server/lib/statusHistory.ts b/server/lib/statusHistory.ts index 8bb7e6a0c9..7c5b5c370a 100644 --- a/server/lib/statusHistory.ts +++ b/server/lib/statusHistory.ts @@ -1,6 +1,6 @@ import { z } from "zod"; import { db, logsDb, statusHistory } from "@server/db"; -import { and, eq, gte, asc } from "drizzle-orm"; +import { and, eq, gte, lt, asc, desc } from "drizzle-orm"; import { regionalCache as cache } from "#dynamic/lib/cache"; const STATUS_HISTORY_CACHE_TTL = 60; // seconds @@ -42,7 +42,29 @@ export async function getCachedStatusHistory( ) .orderBy(asc(statusHistory.timestamp)); - const { buckets, totalDowntime } = computeBuckets(events, days); + // Fetch the last known state before the window so that entities that + // haven't changed status recently still show the correct status rather + // than appearing as "no_data". + const [lastKnownEvent] = await logsDb + .select() + .from(statusHistory) + .where( + and( + eq(statusHistory.entityType, entityType), + eq(statusHistory.entityId, entityId), + lt(statusHistory.timestamp, startSec) + ) + ) + .orderBy(desc(statusHistory.timestamp)) + .limit(1); + + const priorStatus = lastKnownEvent?.status ?? null; + + const { buckets, totalDowntime } = computeBuckets( + events, + days, + priorStatus + ); const totalWindow = days * 86400; const overallUptime = totalWindow > 0 @@ -110,7 +132,8 @@ export function computeBuckets( timestamp: number; id: number; }[], - days: number + days: number, + priorStatus: string | null = null ): { buckets: StatusHistoryDayBucket[]; totalDowntime: number } { const nowSec = Math.floor(Date.now() / 1000); @@ -136,7 +159,10 @@ export function computeBuckets( .filter((e) => e.timestamp < dayStartSec) .at(-1); - const currentStatus = lastBeforeDay?.status ?? null; + // Fall back to the last known state before the entire query window + // so that entities that haven't generated events recently still show + // as their actual status rather than "no_data". + const currentStatus = lastBeforeDay?.status ?? priorStatus ?? null; const windows: { start: number; end: number | null; status: string }[] = []; From 0f02d1bc0269cba43167abc0fbaedf34e807dc81 Mon Sep 17 00:00:00 2001 From: Fred KISSIE Date: Thu, 25 Jun 2026 18:01:25 +0200 Subject: [PATCH 73/88] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20remove=20deprecated?= =?UTF-8?q?=20ISO=20`CS`=20country=20code?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- server/db/countries.ts | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/server/db/countries.ts b/server/db/countries.ts index 749f1183f3..c668ca2aeb 100644 --- a/server/db/countries.ts +++ b/server/db/countries.ts @@ -795,10 +795,13 @@ export const COUNTRIES = [ name: "Serbia", code: "RS" }, - { - name: "Serbia and Montenegro", - code: "CS" - }, + // Removed as this is a deprecated ISO country code, not supported anymore + // Also the individual flags for Serbia & Montenegro are already included in the list + // more details: https://en.wikipedia.org/wiki/ISO_3166-2:CS + // { + // name: "Serbia and Montenegro", + // code: "CS" + // }, { name: "Seychelles", code: "SC" From 4b1b3d3d5bc71f29cee114d18b73fc9746df6424 Mon Sep 17 00:00:00 2001 From: Owen Schwartz Date: Thu, 25 Jun 2026 11:44:17 -0700 Subject: [PATCH 74/88] New translations en-us.json (French) [ci skip] --- messages/fr-FR.json | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/messages/fr-FR.json b/messages/fr-FR.json index 78ed0faa86..14a4a53653 100644 --- a/messages/fr-FR.json +++ b/messages/fr-FR.json @@ -66,9 +66,15 @@ "local": "Locale", "edit": "Modifier", "siteConfirmDelete": "Confirmer la suppression du nœud", + "siteConfirmDeleteAndResources": "Confirmer la suppression du site et des ressources", "siteDelete": "Supprimer le nœud", + "siteDeleteAndResources": "Supprimer le site et les ressources", "siteMessageRemove": "Une fois supprimé, le nœud ne sera plus accessible. Toutes les cibles associées au nœud seront également supprimées.", + "siteMessageRemoveAndResources": "Cela supprimera définitivement toutes les ressources publiques et privées liées à ce site, même si une ressource est également associée à d'autres sites.", "siteQuestionRemove": "Êtes-vous sûr de vouloir supprimer ce nœud de l'organisation ?", + "siteQuestionRemoveAndResources": "Êtes-vous sûr de vouloir supprimer ce site et toutes les ressources associées?", + "sitesTableDeleteSite": "Supprimer le site", + "sitesTableDeleteSiteAndResources": "Supprimer le site et les ressources", "siteManageSites": "Gérer les nœuds", "siteDescription": "Créer et gérer des sites pour activer la connectivité aux réseaux privés", "sitesBannerTitle": "Se connecter à n'importe quel réseau", @@ -2338,6 +2344,7 @@ "createInternalResourceDialogDestinationCidrDescription": "La gamme CIDR de la ressource sur le réseau du site.", "createInternalResourceDialogAlias": "Alias", "createInternalResourceDialogAliasDescription": "Un alias DNS interne optionnel pour cette ressource.", + "internalResourceAliasLocalWarning": "Les alias se terminant par .local peuvent causer des problèmes de résolution dus au mDNS sur certains réseaux.", "internalResourceDownstreamSchemeRequired": "Un schéma est requis pour les ressources HTTP", "internalResourceHttpPortRequired": "Le port de destination est requis pour les ressources HTTP", "siteConfiguration": "Configuration", @@ -2967,7 +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": "An updated version is available. Please update to the latest version for the best experience.", + "updateAvailableInfo": "Une version mise à jour est disponible. Veuillez mettre à jour vers la dernière version pour une meilleure expérience.", "client": "Client", "proxyProtocol": "Paramètres du protocole proxy", "proxyProtocolDescription": "Configurer le protocole Proxy pour préserver les adresses IP du client pour les services TCP.", From 60339706bb171cc807daa8f7b4732c3e1ee27178 Mon Sep 17 00:00:00 2001 From: Owen Schwartz Date: Thu, 25 Jun 2026 11:44:19 -0700 Subject: [PATCH 75/88] New translations en-us.json (Spanish) [ci skip] --- messages/es-ES.json | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/messages/es-ES.json b/messages/es-ES.json index ec22f9b0e5..7895b15e59 100644 --- a/messages/es-ES.json +++ b/messages/es-ES.json @@ -66,9 +66,15 @@ "local": "Local", "edit": "Editar", "siteConfirmDelete": "Confirmar Borrar Sitio", + "siteConfirmDeleteAndResources": "Confirmar eliminación del sitio y recursos", "siteDelete": "Eliminar sitio", + "siteDeleteAndResources": "Eliminar sitio y recursos", "siteMessageRemove": "Una vez eliminado, el sitio ya no será accesible. Todos los objetivos asociados con el sitio también serán eliminados.", + "siteMessageRemoveAndResources": "Esto eliminará permanentemente todos los recursos públicos y privados vinculados a este sitio, incluso si un recurso también está asociado con otros sitios.", "siteQuestionRemove": "¿Está seguro que desea eliminar el sitio de la organización?", + "siteQuestionRemoveAndResources": "¿Está seguro de que desea eliminar este sitio y todos los recursos asociados?", + "sitesTableDeleteSite": "Eliminar sitio", + "sitesTableDeleteSiteAndResources": "Eliminar sitio y recursos", "siteManageSites": "Administrar Sitios", "siteDescription": "Crear y administrar sitios para permitir la conectividad a redes privadas", "sitesBannerTitle": "Conectar cualquier red", @@ -2338,6 +2344,7 @@ "createInternalResourceDialogDestinationCidrDescription": "El rango CIDR del recurso en la red del sitio.", "createInternalResourceDialogAlias": "Alias", "createInternalResourceDialogAliasDescription": "Un alias DNS interno opcional para este recurso.", + "internalResourceAliasLocalWarning": "Los alias que terminan en .local pueden causar problemas de resolución debido a mDNS en algunas redes.", "internalResourceDownstreamSchemeRequired": "Se requiere el método para recursos HTTP", "internalResourceHttpPortRequired": "Se requiere el puerto de destino para recursos HTTP", "siteConfiguration": "Configuración", @@ -2967,7 +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": "An updated version is available. Please update to the latest version for the best experience.", + "updateAvailableInfo": "Hay una versión actualizada disponible. Actualice a la última versión para obtener la mejor experiencia.", "client": "Cliente", "proxyProtocol": "Configuración del Protocolo Proxy", "proxyProtocolDescription": "Configurar el protocolo de proxy para preservar las direcciones IP del cliente para los servicios TCP.", From dd26518d6f1453cfd284f3d3c85dd50c52c897e8 Mon Sep 17 00:00:00 2001 From: Owen Schwartz Date: Thu, 25 Jun 2026 11:44:21 -0700 Subject: [PATCH 76/88] New translations en-us.json (Bulgarian) [ci skip] --- messages/bg-BG.json | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/messages/bg-BG.json b/messages/bg-BG.json index 2e0aea8ca8..6cefe4a4db 100644 --- a/messages/bg-BG.json +++ b/messages/bg-BG.json @@ -66,9 +66,15 @@ "local": "Локална", "edit": "Редактиране", "siteConfirmDelete": "Потвърждение на изтриване на сайта", + "siteConfirmDeleteAndResources": "Потвърдете изтриването на сайта и ресурсите", "siteDelete": "Изтриване на сайта", + "siteDeleteAndResources": "Изтриване на сайта и ресурсите", "siteMessageRemove": "След премахване, сайтът вече няма да бъде достъпен. Всички цели, свързани със сайта, също ще бъдат премахнати.", + "siteMessageRemoveAndResources": "Това ще изтрие окончателно всички публични и частни ресурси, свързани с този сайт, дори ако ресурсът е асоцииран и с други сайтове.", "siteQuestionRemove": "Сигурни ли сте, че искате да премахнете сайта от организацията?", + "siteQuestionRemoveAndResources": "Наистина ли желаете да изтриете този сайт и всички свързани ресурси?", + "sitesTableDeleteSite": "Изтриване на сайта", + "sitesTableDeleteSiteAndResources": "Изтриване на сайта и ресурсите", "siteManageSites": "Управление на сайтове", "siteDescription": "Създайте и управлявайте сайтове, за да осигурите свързаност със частни мрежи", "sitesBannerTitle": "Свържете се с мрежа.", @@ -2338,6 +2344,7 @@ "createInternalResourceDialogDestinationCidrDescription": "CIDR диапазонът на ресурса в мрежата на сайта.", "createInternalResourceDialogAlias": "Псевдоним", "createInternalResourceDialogAliasDescription": "По избор вътрешен DNS псевдоним за този ресурс.", + "internalResourceAliasLocalWarning": "Синоними с окончание .local могат да причинят проблеми с резолюцията поради mDNS в някои мрежи.", "internalResourceDownstreamSchemeRequired": "Методът е задължителен за HTTP ресурси", "internalResourceHttpPortRequired": "Портът към целта е задължителен за HTTP ресурси", "siteConfiguration": "Конфигурация", @@ -2967,7 +2974,7 @@ "orgOrDomainIdMissing": "Липсва идентификатор на организация или домейн", "loadingDNSRecords": "Зареждане на DNS записи...", "olmUpdateAvailableInfo": "Налична е актуализирана версия на Olm. Моля, актуализирайте до най-новата версия за най-добро преживяване.", - "updateAvailableInfo": "An updated version is available. Please update to the latest version for the best experience.", + "updateAvailableInfo": "На разположение е обновена версия. Моля, обновете до най-новата версия за най-добър опит.", "client": "Клиент", "proxyProtocol": "Настройки на прокси протокол", "proxyProtocolDescription": "Конфигурирайте Proxy Protocol, за да запазите IP адресите на клиентите за TCP услуги.", From 88a9b92dc3332edcf0e316e5298fbf9e251c6674 Mon Sep 17 00:00:00 2001 From: Owen Schwartz Date: Thu, 25 Jun 2026 11:44:23 -0700 Subject: [PATCH 77/88] New translations en-us.json (Czech) [ci skip] --- messages/cs-CZ.json | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/messages/cs-CZ.json b/messages/cs-CZ.json index d819279778..9a175093e5 100644 --- a/messages/cs-CZ.json +++ b/messages/cs-CZ.json @@ -66,9 +66,15 @@ "local": "Místní", "edit": "Upravit", "siteConfirmDelete": "Potvrdit odstranění lokality", + "siteConfirmDeleteAndResources": "Potvrdit odstranění lokality a zdrojů", "siteDelete": "Odstranění lokality", + "siteDeleteAndResources": "Odstranit lokalitu a zdroje", "siteMessageRemove": "Po odstranění webu již nebude přístupný. Všechny cíle spojené s webem budou také odstraněny.", + "siteMessageRemoveAndResources": "Toto trvale odstraní všechny veřejné a soukromé zdroje spojené s touto lokalitou, i když je zdroj také přiřazen k jiným lokalitám.", "siteQuestionRemove": "Jste si jisti, že chcete odstranit tuto stránku z organizace?", + "siteQuestionRemoveAndResources": "Opravdu chcete odstranit tuto lokalitu a všechny přidružené zdroje?", + "sitesTableDeleteSite": "Odstranění lokality", + "sitesTableDeleteSiteAndResources": "Odstranit lokalitu a zdroje", "siteManageSites": "Správa lokalit", "siteDescription": "Vytvořte a spravujte stránky pro povolení připojení k soukromým sítím", "sitesBannerTitle": "Připojit jakoukoli síť", @@ -2338,6 +2344,7 @@ "createInternalResourceDialogDestinationCidrDescription": "Rozsah zdrojů CIDR v síti webu.", "createInternalResourceDialogAlias": "Alias", "createInternalResourceDialogAliasDescription": "Volitelný interní DNS alias pro tento dokument.", + "internalResourceAliasLocalWarning": "Aliasy končící na .local mohou způsobit problémy s vyřešením díky mDNS v některých sítích.", "internalResourceDownstreamSchemeRequired": "HTTP metoda je vyžadována pro HTTP zdroje", "internalResourceHttpPortRequired": "Přípoječný port je nutný pro HTTP zdroj", "siteConfiguration": "Konfigurace", @@ -2967,7 +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": "An updated version is available. Please update to the latest version for the best experience.", + "updateAvailableInfo": "Je k dispozici aktualizovaná verze. Aktualizujte prosím na nejnovější verzi pro nejlepší zážitek.", "client": "Zákazník", "proxyProtocol": "Nastavení proxy protokolu", "proxyProtocolDescription": "Konfigurace Proxy protokolu pro zachování klientských IP adres pro služby TCP.", From 36460d4cc05d463425495f3d35930296b9e15bec Mon Sep 17 00:00:00 2001 From: Owen Schwartz Date: Thu, 25 Jun 2026 11:44:25 -0700 Subject: [PATCH 78/88] New translations en-us.json (Danish) [ci skip] --- messages/da-DK.json | 102 ++++++++++++++++++++++++-------------------- 1 file changed, 55 insertions(+), 47 deletions(-) diff --git a/messages/da-DK.json b/messages/da-DK.json index cdf209ed07..b9db7f3235 100644 --- a/messages/da-DK.json +++ b/messages/da-DK.json @@ -58,7 +58,7 @@ "name": "Navn", "online": "Online", "offline": "Offline", - "site": "Site", + "site": "Websted", "dataIn": "Data ind", "dataOut": "Data ud", "connectionType": "Forbindelsestype", @@ -66,9 +66,15 @@ "local": "Lokal", "edit": "Rediger", "siteConfirmDelete": "Bekræft Sletning af Site", + "siteConfirmDeleteAndResources": "Bekræft sletning af sted og ressourcer", "siteDelete": "Slet Site", + "siteDeleteAndResources": "Slet Sted og Ressourcer", "siteMessageRemove": "Når sitet er fjernet, vil det ikke længere være tilgængeligt. Alle mål for sitet vil også blive fjernet.", + "siteMessageRemoveAndResources": "Dette vil permanent slette alle offentlige og private ressourcer knyttet til dette sted, selv hvis en ressource også er knyttet til andre steder.", "siteQuestionRemove": "Er du sikker på at du vil fjerne sitet fra organisationen?", + "siteQuestionRemoveAndResources": "Er du sikker på, at du vil slette dette sted og alle tilknyttede ressourcer?", + "sitesTableDeleteSite": "Slet sted", + "sitesTableDeleteSiteAndResources": "Slet sted og ressourcer", "siteManageSites": "Administrer Sites", "siteDescription": "Oprette og administrer sites for at aktivere forbindelse til private netværk", "sitesBannerTitle": "Opret forbindelse til alle netværk", @@ -711,7 +717,7 @@ "proxyUpdatedDescription": "Proxy indstillinger er blevet opdateret", "proxyErrorUpdate": "En fejl opstod under opdatering af proxyindstillinger", "proxyErrorUpdateDescription": "En fejl opstod under opdatering af proxyindstillinger", - "targetAddr": "Host", + "targetAddr": "Vært", "targetPort": "Port", "targetProtocol": "Protokol", "targetTlsSettings": "Sikker forbindelseskonfiguration", @@ -902,7 +908,7 @@ "newtSecretKey": "Sikkerhedsnøgle", "newtVersion": "Version", "architecture": "Arkitektur", - "sites": "Sites", + "sites": "Websteder", "siteWgAnyClients": "Brug hvilken som helst WireGuard klient til at oprette forbindelse til. Du skal adressere interne ressourcer ved hjælp af peer IP.", "siteWgCompatibleAllClients": "Kompatibel med alle WireGuard-klienter", "siteWgManualConfigurationRequired": "Manuel konfiguration påkrævet", @@ -1059,7 +1065,7 @@ "network": "Netværk", "manage": "Administrer", "sitesNotFound": "Ingen sites fundet.", - "pangolinServerAdmin": "Server Admin - Pangolin", + "pangolinServerAdmin": "Serveradmin - Pangolin", "licenseTierProfessional": "Professionel licens", "licenseTierEnterprise": "Enterprise-licens", "licenseTierPersonal": "Personlig licens", @@ -1186,7 +1192,7 @@ "emailVerifyResend": "Har du ikke modtaget en kode? Klik her for at sende igen", "passwordNotMatch": "Adgangskoderne matcher ikke", "signupError": "Det opstod en fejl ved registrering", - "pangolinLogoAlt": "Pangolin Logo", + "pangolinLogoAlt": "Pangolin-logo", "inviteAlready": "Ser ud til at du er blevet inviteret!", "inviteAlreadyDescription": "For at acceptere invitationen skal du logge ind eller oprette en konto.", "signupQuestion": "Har du allerede en konto?", @@ -1533,7 +1539,7 @@ "apiKeysErrorNoUpdate": "Ingen API-nøgle at opdatere", "sidebarOverview": "Oversigt", "sidebarHome": "Hjem", - "sidebarSites": "Sites", + "sidebarSites": "Websteder", "sidebarApprovals": "Godkendelsesanmodninger", "sidebarResources": "Ressourcer", "sidebarProxyResources": "Offentlig", @@ -1573,7 +1579,7 @@ "alertingSearchRules": "Søg i regler…", "alertingAddRule": "Opret regel", "alertingColumnSource": "Kilde", - "alertingColumnTrigger": "Trigger", + "alertingColumnTrigger": "Udløser", "alertingColumnActions": "Handlinger", "alertingColumnEnabled": "Aktiveret", "alertingDeleteQuestion": "Bekræft venligst, at du vil slette denne varslingsregel.", @@ -1589,9 +1595,9 @@ "alertingRuleEnabled": "Regel aktiveret", "alertingSectionSource": "Kilde", "alertingSourceType": "Kildetype", - "alertingSourceSite": "Site", + "alertingSourceSite": "Sted", "alertingSourceHealthCheck": "Sundhedstjek", - "alertingPickSites": "Sites", + "alertingPickSites": "Steder", "alertingPickHealthChecks": "Sundhedstjek", "alertingPickResources": "Ressourcer", "alertingAllSites": "Alle sites", @@ -1609,7 +1615,7 @@ "alertingSelectResources": "Vælg ressourcer…", "alertingResourcesSelected": "{count} ressourcer valgt", "alertingResourcesEmpty": "Ingen ressourcer med mål i de første 10 resultaterne.", - "alertingSectionTrigger": "Trigger", + "alertingSectionTrigger": "Udløser", "alertingTrigger": "Når skal det varsles", "alertingTriggerSiteOnline": "Site er online", "alertingTriggerSiteOffline": "Site er offline", @@ -1643,7 +1649,7 @@ "alertingWebhookMethod": "HTTP-metode", "alertingWebhookSecret": "Signeringshemmelighed (valgfrit)", "alertingWebhookSecretPlaceholder": "HMAC-hemmelig", - "alertingWebhookHeaders": "Headers", + "alertingWebhookHeaders": "Overskrifter", "alertingAddHeader": "Tilføj header", "alertingSelectSites": "Vælg sites…", "alertingSitesSelected": "{count} sites valgt", @@ -1655,7 +1661,7 @@ "alertingUsersSelected": "{count} brugere valgt", "alertingSelectRoles": "Vælg roller…", "alertingRolesSelected": "{count} roller valgt", - "alertingSummarySites": "Sites ({count})", + "alertingSummarySites": "Websteder ({count})", "alertingSummaryAllSites": "Alle sites", "alertingSummaryHealthChecks": "Sundhedstjek ({count})", "alertingSummaryAllHealthChecks": "Alle sundhedstjek", @@ -1683,7 +1689,7 @@ "alertingNodeNotConfigured": "Ikke konfigureret endnu", "alertingNodeActionsCount": "{count, plural, one {# handling} other {# handlinger}}", "alertingNodeRoleSource": "Kilde", - "alertingNodeRoleTrigger": "Trigger", + "alertingNodeRoleTrigger": "Udløser", "alertingNodeRoleAction": "Handling", "alertingTabRules": "Varslingsregler", "alertingTabHealthChecks": "Sundhedstjek", @@ -1720,7 +1726,7 @@ "standaloneHcFilterEnabled": "Aktiveret", "standaloneHcFilterEnabledOn": "Aktiveret", "standaloneHcFilterEnabledOff": "Deaktiveret", - "standaloneHcFilterSiteIdFallback": "Site {id}", + "standaloneHcFilterSiteIdFallback": "Sted {id}", "standaloneHcFilterResourceIdFallback": "Ressource {id}", "blueprints": "Blueprints", "blueprintsLog": "Blueprint-log", @@ -1893,7 +1899,7 @@ "billingUsageLimitsOverview": "Oversikt over forbrugsgrænser", "billingMonitorUsage": "Overvåg forbruget din i forhold til konfigurerte grænse. Hvis du behøver økte grænse, venligst kontakt support@pangolin.net.", "billingDataUsage": "Dataforbrug", - "billingSites": "Sites", + "billingSites": "Websteder", "billingUsers": "Brugere", "billingDomains": "Domæner", "billingOrganizations": "Orger", @@ -2112,7 +2118,7 @@ "healthSelectScheme": "Vælg metode", "healthCheckPortInvalid": "Porten skal være mellem 1 og 65535", "healthCheckPath": "Sti", - "healthHostname": "IP / Host", + "healthHostname": "IP / Vært", "healthPort": "Port", "healthCheckPathDescription": "Stien for at tjekke helsestatus.", "healthyIntervalSeconds": "Sund intervall (sek)", @@ -2165,7 +2171,7 @@ "sshSudoMode": "Sudo adgang", "sshSudoModeNone": "Ingen", "sshSudoModeNoneDescription": "Brugeren kan ikke køre kommandoer med sudo.", - "sshSudoModeFull": "Full Sudo", + "sshSudoModeFull": "Fuld Sudo", "sshSudoModeFullDescription": "Brugeren kan køre hvilken som helst kommando med sudo.", "sshSudoModeCommands": "Kommandoer", "sshSudoModeCommandsDescription": "Brugeren kan kun køre de angitte kommandoene med sudo.", @@ -2185,7 +2191,7 @@ "roleTextImportPreview": "Forhåndsvisning", "roleTextImportItemCount": "{count, plural, =0 {Ingen elementer at importere} one {ét element at importere} other {# elementer at importere}}", "roleTextImportTotalCount": "{existing} eksisterende + {imported} importert = {total} totalt", - "roleTextImportConfirm": "Import", + "roleTextImportConfirm": "Importer", "roleTextImportInvalidFile": "Ustøttet filtype", "roleTextImportInvalidFileDescription": "Kun.txt og.csv filer er støttet.", "roleTextImportEmpty": "Ingen elementer fundet i filen", @@ -2218,7 +2224,7 @@ "httpMethod": "HTTP-metode", "selectHttpMethod": "Vælg HTTP-metode", "domainPickerSubdomainLabel": "Underdomæne", - "domainPickerWildcard": "Wildcard", + "domainPickerWildcard": "Jokertegn", "domainPickerWildcardPaidOnly": "Wildcard-underdomæner er en betalt funktion. Opgrader venligst for at få adgang til denne funktion.", "domainPickerBaseDomainLabel": "Basisdomæne", "domainPickerSearchDomains": "Søg i domæner...", @@ -2274,7 +2280,7 @@ "editInternalResourceDialogPortModeRequired": "Protokol, proxy-port og målport er påkrævet for porttilstand", "editInternalResourceDialogMode": "Tilstand", "editInternalResourceDialogModePort": "Port", - "editInternalResourceDialogModeHost": "Host", + "editInternalResourceDialogModeHost": "Vært", "editInternalResourceDialogModeCidr": "CIDR", "editInternalResourceDialogModeHttp": "HTTP", "editInternalResourceDialogModeHttps": "HTTPS", @@ -2295,7 +2301,7 @@ "createInternalResourceDialogCreateClientResourceDescription": "Opret en ny ressource som kun vil være tilgængelig for kunder som er forbundet til organisationen", "createInternalResourceDialogResourceProperties": "Ressourceegenskaber", "createInternalResourceDialogName": "Navn", - "createInternalResourceDialogSite": "Site", + "createInternalResourceDialogSite": "Websted", "selectSite": "Vælg site...", "multiSitesSelectorSitesCount": "{count, plural, one {# sted} other {# steder}}", "noSitesFound": "Ingen sites fundet.", @@ -2324,7 +2330,7 @@ "createInternalResourceDialogPortModeRequired": "Protokol, proxy-port og målport er påkrævet for porttilstand", "createInternalResourceDialogMode": "Tilstand", "createInternalResourceDialogModePort": "Port", - "createInternalResourceDialogModeHost": "Host", + "createInternalResourceDialogModeHost": "Vært", "createInternalResourceDialogModeCidr": "CIDR", "createInternalResourceDialogModeHttp": "HTTP", "createInternalResourceDialogModeHttps": "HTTPS", @@ -2338,6 +2344,7 @@ "createInternalResourceDialogDestinationCidrDescription": "CIDR-området til ressourcen på sitets netværk.", "createInternalResourceDialogAlias": "Alias", "createInternalResourceDialogAliasDescription": "Et valgfrit internt DNS-alias for denne ressource.", + "internalResourceAliasLocalWarning": "Aliasser, der ender på .local, kan forårsage opløsningsproblemer på grund af mDNS på nogle netværk.", "internalResourceDownstreamSchemeRequired": "Skema er påkrævet for HTTP-ressourcer", "internalResourceHttpPortRequired": "Destinasjonsport er nødvendig for HTTP-ressourcer", "siteConfiguration": "Konfiguration", @@ -2435,7 +2442,7 @@ "regionAfrica": "Afrika", "regionNorthernAfrica": "[country name] Nord-Afrika", "regionEasternAfrica": "Øst-Afrika", - "regionMiddleAfrica": "Middle Africa", + "regionMiddleAfrica": "Mellemafrika", "regionSouthernAfrica": "Sør-Afrika", "regionWesternAfrica": "[country name] Vest-Afrika", "regionAmericas": "Amerika", @@ -2443,7 +2450,7 @@ "regionCentralAmerica": "Sentral-Amerika", "regionSouthAmerica": "Sør-Amerika", "regionNorthernAmerica": "Nord-Amerika", - "regionAsia": "Asia", + "regionAsia": "Asien", "regionCentralAsia": "Sentral-Asia", "regionEasternAsia": "Øst-Asia", "regionSouthEasternAsia": "Sørøst-Asia", @@ -2454,11 +2461,11 @@ "regionNorthernEurope": "Nord-Europa", "regionSouthernEurope": "Sydeuropa", "regionWesternEurope": "Vest-Europa", - "regionOceania": "Oceania", + "regionOceania": "Oceanien", "regionAustraliaAndNewZealand": "Australia og New Zealand", - "regionMelanesia": "Melanesia", - "regionMicronesia": "Micronesia", - "regionPolynesia": "Polynesia", + "regionMelanesia": "Melanesien", + "regionMicronesia": "Mikronesien", + "regionPolynesia": "Polynesien", "managedSelfHosted": { "title": "Administreret selvhostet", "description": "Sikre, selvhostede Pangolin-servere med lav vedligeholdelse og ekstra bells and whistles", @@ -2528,12 +2535,12 @@ "roleMappingRemoveRule": "Fjern", "idpGoogleConfiguration": "Google Konfiguration", "idpGoogleConfigurationDescription": "Konfigurer Google OAuth2 legitimationsoplysningerne", - "idpGoogleClientIdDescription": "Google OAuth2 Client ID", + "idpGoogleClientIdDescription": "Google OAuth2-klient-ID", "idpGoogleClientSecretDescription": "Google OAuth2-klienten hemmelighed", "idpAzureConfiguration": "Azure Entra ID konfiguration", "idpAzureConfigurationDescription": "Konfigurer Azure Entra ID OAuth2 legitimationsoplysninger", "idpTenantId": "Leietaker-ID", - "idpTenantIdPlaceholder": "tenant-id", + "idpTenantIdPlaceholder": "lejer-id", "idpAzureTenantIdDescription": "Azure leant ID (fundet i Azure Active Directory-oversikten)", "idpAzureClientIdDescription": "Azure App registrerings klient-ID", "idpAzureClientSecretDescription": "Azure App Registrering Klient Hemmelig", @@ -2547,7 +2554,7 @@ "idpAzureClientIdDescription2": "Azure App registrerings klient-ID", "idpAzureClientSecretDescription2": "Azure App Registrering Klient Hemmelig", "idpGoogleDescription": "Google OAuth2/OIDC udbyder", - "idpAzureDescription": "Microsoft Azure OAuth2/OIDC provider", + "idpAzureDescription": "Microsoft Azure OAuth2/OIDC leverandør", "subnet": "Subnet", "subnetDescription": "Subnettet for denne organisations netværkskonfiguration.", "customDomain": "Brugerdefineret domæne", @@ -2698,7 +2705,7 @@ "resourceHeaderAuthSetupTitleDescription": "Angiv grunnleggende auth legitimationsoplysninger (brugernavn og adgangskode) for at beskytte denne ressource med HTTP Header autentificering. Adgang til det ved hjælp af formatet HTTPS://username:password@resource.example.com", "resourceHeaderAuthSubmit": "Angiv header-godkendelse", "actionSetResourceHeaderAuth": "Angiv header-godkendelse", - "enterpriseEdition": "Enterprise Edition", + "enterpriseEdition": "Enterprise-udgave", "unlicensed": "Ikke licenseret", "beta": "beta", "manageUserDevices": "Bruger Enheder", @@ -2877,10 +2884,10 @@ "toConfirm": "at bekræfte.", "deleteClientQuestion": "Er du sikker på at du vil fjerne klienten fra sitet og organisationen?", "clientMessageRemove": "Når klienten er fjernet, kan den ikke længere oprette forbindelse til sitet.", - "sidebarLogs": "Logs", + "sidebarLogs": "Logfiler", "request": "Forespørgsel", "requests": "Forespørgsler", - "logs": "Logs", + "logs": "Logfiler", "logsSettingsDescription": "Overvåg logs samlet ind fra denne organisation", "searchLogs": "Søg i logs...", "action": "Handling", @@ -2894,11 +2901,11 @@ "allowedByRule": "Tilladt efter regel", "allowedNoAuth": "Tilladt Ingen Auth", "validAccessToken": "Gyldig adgangsnøgle", - "validHeaderAuth": "Valid header auth", + "validHeaderAuth": "Gyldig header-auth", "validPincode": "Valid PIN-kode", "validPassword": "Gyldig adgangskode", - "validEmail": "Valid email", - "validSSO": "Valid SSO", + "validEmail": "Gyldig e-mail", + "validSSO": "Gyldig SSO", "view": "Vis", "configManaged": "Konfiguration administrert", "connectedClient": "Tilsluttet klient", @@ -2906,12 +2913,12 @@ "droppedByRule": "Legg i reglen", "noSessions": "Ingen økter", "temporaryRequestToken": "Midlertidig forespørgsel Token", - "noMoreAuthMethods": "No Valid Auth", + "noMoreAuthMethods": "Ingen gyldig auth", "ip": "IP", "reason": "Grund", "requestLogs": "HTTP-forespørgselslogs", "requestAnalytics": "Be om analyser", - "host": "Host", + "host": "Vært", "location": "Sted", "actionLogs": "Handlingsloger", "sidebarLogsRequest": "HTTP-forespørgselslogs", @@ -2967,6 +2974,7 @@ "orgOrDomainIdMissing": "ID for organisation eller domæne mangler", "loadingDNSRecords": "Indlæser DNS-poster...", "olmUpdateAvailableInfo": "En opdateret version af Olm er tilgængeligt. Opdater til den nyeste version for at få den bedste oplevelse.", + "updateAvailableInfo": "En opdateret version er tilgængelig. Opdater til den nyeste version for at få den bedste oplevelse.", "client": "Klient", "proxyProtocol": "Protokol indstillinger for Protokol", "proxyProtocolDescription": "Konfigurer Proxy-protokol for at bevare klientens IP-adresser til TCP-tjenester.", @@ -3082,7 +3090,7 @@ "niceIdUpdateErrorDescription": "Det opstod en fejl under opdatering af Nice ID.", "niceIdCannotBeEmpty": "God ID kan ikke være tom", "enterIdentifier": "Angiv identifikator", - "identifier": "Identifier", + "identifier": "Identifikator", "deviceLoginUseDifferentAccount": "Ikke du? Brug en anden konto.", "deviceLoginDeviceRequestingAccessToAccount": "En enhed ber om adgang til denne kontoen.", "loginSelectAuthenticationMethod": "Vælg en autentificeringsmetode for at fortsætte.", @@ -3260,13 +3268,13 @@ "platform": "Platform", "macosVersion": "macOS version", "windowsVersion": "Windows version", - "iosVersion": "iOS Version", + "iosVersion": "iOS-version", "androidVersion": "Android version", "osVersion": "OS version", "kernelVersion": "Kjerne version", "deviceModel": "Enhedsmodel", "serialNumber": "Serienummer", - "hostname": "Hostname", + "hostname": "Hostnavn", "firstSeen": "Først sett", "lastSeen": "Sist sett", "biometricsEnabled": "Biometri aktiveret", @@ -3366,9 +3374,9 @@ "datadogDestEditDescription": "Opdater konfigurationen for denne Datadog-hændelsesstreamingdestination.", "datadogDestAddDescription": "Konfigurer et nyt Datadog-endpoint til at modtage organisationens hændelser.", "httpDestTabSettings": "Indstillinger", - "httpDestTabHeaders": "Headers", + "httpDestTabHeaders": "Overskrifter", "httpDestTabBody": "Indhold", - "httpDestTabLogs": "Logs", + "httpDestTabLogs": "Logfiler", "httpDestNamePlaceholder": "Min HTTP destination", "httpDestUrlLabel": "Destinasjons URL", "httpDestUrlErrorHttpRequired": "URL-adressen skal bruge httpp eller HTTPS", @@ -3454,7 +3462,7 @@ "idpUnassociatedDescription": "Identitetsudbyderen er frakoblet fra denne organisation", "idpUnassociateMenu": "Frakobl", "idpDeleteAllOrgsMenu": "Slet", - "publicIpEndpoint": "Endpoint", + "publicIpEndpoint": "Slutpunkt", "lastTriggeredAt": "Senest udløst", "reject": "Afvis", "uptimeDaysAgo": "{count} dage siden", @@ -3587,7 +3595,7 @@ "rdpNoConnectionTarget": "Intet forbindelsesmål tilgængeligt", "rdpConnectionFailed": "Forbindelsen mislykkedes", "rdpFit": "Tilpass", - "rdpFull": "Full", + "rdpFull": "Fuldt", "rdpReal": "Ekte", "rdpMeta": "Meta", "rdpUploadFiles": "Upload filer", @@ -3597,4 +3605,4 @@ "rdpUnicodeKeyboardMode": "Unicode tastaturtilstand", "sessionToolbarShow": "Vis værktøjslinje", "sessionToolbarHide": "Skjul værktøjslinje" -} \ No newline at end of file +} From 56187d61d531cfe706bc9155c1bc4351b8e89464 Mon Sep 17 00:00:00 2001 From: Owen Schwartz Date: Thu, 25 Jun 2026 11:44:27 -0700 Subject: [PATCH 79/88] New translations en-us.json (German) [ci skip] --- messages/de-DE.json | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/messages/de-DE.json b/messages/de-DE.json index 4e4ac91614..38e8fafc4b 100644 --- a/messages/de-DE.json +++ b/messages/de-DE.json @@ -66,9 +66,15 @@ "local": "Lokal", "edit": "Bearbeiten", "siteConfirmDelete": "Löschen des Standorts bestätigen", + "siteConfirmDeleteAndResources": "Löschen von Standort und Ressourcen bestätigen", "siteDelete": "Standort löschen", + "siteDeleteAndResources": "Standort und Ressourcen löschen", "siteMessageRemove": "Sobald der Standort entfernt ist, wird er nicht mehr zugänglich sein. Alle mit dem Standort verbundenen Ziele werden ebenfalls entfernt.", + "siteMessageRemoveAndResources": "Dies wird dauerhaft alle öffentlichen und privaten Ressourcen, die mit diesem Standort verknüpft sind, löschen, selbst wenn eine Ressource auch mit anderen Standorten verbunden ist.", "siteQuestionRemove": "Sind Sie sicher, dass Sie den Standort aus der Organisation entfernen möchten?", + "siteQuestionRemoveAndResources": "Sind Sie sicher, dass Sie diesen Standort und alle zugehörigen Ressourcen löschen möchten?", + "sitesTableDeleteSite": "Standort löschen", + "sitesTableDeleteSiteAndResources": "Standort und Ressourcen löschen", "siteManageSites": "Standorte verwalten", "siteDescription": "Erstellen und Verwalten von Standorten, um die Verbindung zu privaten Netzwerken zu ermöglichen", "sitesBannerTitle": "Verbinde ein beliebiges Netzwerk", @@ -2338,6 +2344,7 @@ "createInternalResourceDialogDestinationCidrDescription": "Der CIDR-Bereich der Ressource im Netzwerk der Website.", "createInternalResourceDialogAlias": "Alias", "createInternalResourceDialogAliasDescription": "Ein optionaler interner DNS-Alias für diese Ressource.", + "internalResourceAliasLocalWarning": "Aliasse, die auf .local enden, können aufgrund von mDNS in einigen Netzwerken zu Auflösungsproblemen führen.", "internalResourceDownstreamSchemeRequired": "Schema ist für HTTP-Ressourcen erforderlich", "internalResourceHttpPortRequired": "Zielport ist für HTTP-Ressourcen erforderlich", "siteConfiguration": "Konfiguration", @@ -2967,7 +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": "An updated version is available. Please update to the latest version for the best experience.", + "updateAvailableInfo": "Eine aktualisierte Version ist verfügbar. Bitte aktualisieren Sie auf die neueste Version für das beste Erlebnis.", "client": "Client", "proxyProtocol": "Proxy-Protokoll-Einstellungen", "proxyProtocolDescription": "Konfigurieren Sie das Proxy-Protokoll, um die IP-Adressen des Clients für TCP-Dienste zu erhalten.", From 7e4dea918aaa28e361e1609447263282f41ce4ec Mon Sep 17 00:00:00 2001 From: Owen Schwartz Date: Thu, 25 Jun 2026 11:44:29 -0700 Subject: [PATCH 80/88] New translations en-us.json (Italian) [ci skip] --- messages/it-IT.json | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/messages/it-IT.json b/messages/it-IT.json index 2a6a65ac38..74066721b2 100644 --- a/messages/it-IT.json +++ b/messages/it-IT.json @@ -66,9 +66,15 @@ "local": "Locale", "edit": "Modifica", "siteConfirmDelete": "Conferma Eliminazione Sito", + "siteConfirmDeleteAndResources": "Conferma Eliminazione Sito e Risorse", "siteDelete": "Elimina Sito", + "siteDeleteAndResources": "Elimina Sito e Risorse", "siteMessageRemove": "Una volta rimosso il sito non sarà più accessibile. Tutti gli oggetti associati al sito verranno rimossi.", + "siteMessageRemoveAndResources": "Questo eliminerà permanentemente tutte le risorse pubbliche e private collegate a questo sito, anche se una risorsa è anche associata ad altri siti.", "siteQuestionRemove": "Sei sicuro di voler rimuovere il sito dall'organizzazione?", + "siteQuestionRemoveAndResources": "Sei sicuro di voler eliminare questo sito e tutte le risorse associate?", + "sitesTableDeleteSite": "Elimina Sito", + "sitesTableDeleteSiteAndResources": "Elimina Sito e Risorse", "siteManageSites": "Gestisci Siti", "siteDescription": "Creare e gestire siti per abilitare la connettività a reti private", "sitesBannerTitle": "Connetti Qualsiasi Rete", @@ -2338,6 +2344,7 @@ "createInternalResourceDialogDestinationCidrDescription": "La gamma CIDR della risorsa sulla rete del sito.", "createInternalResourceDialogAlias": "Alias", "createInternalResourceDialogAliasDescription": "Un alias DNS interno opzionale per questa risorsa.", + "internalResourceAliasLocalWarning": "Gli alias che terminano in .local possono causare problemi di risoluzione a causa di mDNS su alcune reti.", "internalResourceDownstreamSchemeRequired": "Il metodo è richiesto per risorse HTTP", "internalResourceHttpPortRequired": "Porta di destinazione richiesta per risorse HTTP", "siteConfiguration": "Configurazione", @@ -2967,7 +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": "An updated version is available. Please update to the latest version for the best experience.", + "updateAvailableInfo": "È disponibile una versione aggiornata. Si prega di aggiornare all'ultima versione per la migliore esperienza.", "client": "Client", "proxyProtocol": "Impostazioni Protocollo Proxy", "proxyProtocolDescription": "Configurare il protocollo proxy per preservare gli indirizzi IP client per i servizi TCP.", From fee635b8611ad05c1d4a231a6a3d9c319eac1a98 Mon Sep 17 00:00:00 2001 From: Owen Schwartz Date: Thu, 25 Jun 2026 11:44:31 -0700 Subject: [PATCH 81/88] New translations en-us.json (Korean) [ci skip] --- messages/ko-KR.json | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/messages/ko-KR.json b/messages/ko-KR.json index d57050693d..a53e76d183 100644 --- a/messages/ko-KR.json +++ b/messages/ko-KR.json @@ -66,9 +66,15 @@ "local": "로컬", "edit": "편집", "siteConfirmDelete": "사이트 삭제 확인", + "siteConfirmDeleteAndResources": "사이트 및 리소스 삭제 확인", "siteDelete": "사이트 삭제", + "siteDeleteAndResources": "사이트 및 리소스 삭제", "siteMessageRemove": "삭제되면 사이트에 더 이상 액세스할 수 없습니다. 사이트와 연결된 모든 대상도 삭제됩니다.", + "siteMessageRemoveAndResources": "이 사이트와 연결된 모든 공용 및 개인 리소스는 다른 사이트에도 연결되어 있더라도 영구적으로 삭제됩니다.", "siteQuestionRemove": "조직에서 사이트를 제거하시겠습니까?", + "siteQuestionRemoveAndResources": "이 사이트와 모든 관련 리소스를 삭제하시겠습니까?", + "sitesTableDeleteSite": "사이트 삭제", + "sitesTableDeleteSiteAndResources": "사이트 및 리소스 삭제", "siteManageSites": "사이트 관리", "siteDescription": "프라이빗 네트워크로의 연결을 활성화하려면 사이트를 생성하고 관리하세요.", "sitesBannerTitle": "모든 네트워크 연결", @@ -2338,6 +2344,7 @@ "createInternalResourceDialogDestinationCidrDescription": "사이트 네트워크의 자원 IP 주소입니다.", "createInternalResourceDialogAlias": "별칭", "createInternalResourceDialogAliasDescription": "이 리소스에 대한 선택적 내부 DNS 별칭입니다.", + "internalResourceAliasLocalWarning": ".local로 끝나는 별칭은 일부 네트워크에서 mDNS로 인해 해결 문제가 발생할 수 있습니다.", "internalResourceDownstreamSchemeRequired": "HTTP 리소스에 스킴이 필요합니다", "internalResourceHttpPortRequired": "HTTP 리소스에 목적지 포트가 필요합니다", "siteConfiguration": "설정", @@ -2967,7 +2974,7 @@ "orgOrDomainIdMissing": "조직 ID 또는 도메인 ID가 누락되었습니다", "loadingDNSRecords": "DNS 레코드를 로드하는 중...", "olmUpdateAvailableInfo": "올름의 새 버전이 이용 가능합니다. 최상의 경험을 위해 최신 버전으로 업데이트하세요.", - "updateAvailableInfo": "An updated version is available. Please update to the latest version for the best experience.", + "updateAvailableInfo": "업데이트된 버전이 있습니다. 최상의 경험을 위해 최신 버전으로 업데이트하세요.", "client": "클라이언트", "proxyProtocol": "프록시 프로토콜 설정", "proxyProtocolDescription": "TCP 서비스에 대한 클라이언트 IP 주소를 유지하도록 프록시 프로토콜을 구성하세요.", From 3c13b1ea1550936151305b67504af06d76f070c2 Mon Sep 17 00:00:00 2001 From: Owen Schwartz Date: Thu, 25 Jun 2026 11:44:33 -0700 Subject: [PATCH 82/88] New translations en-us.json (Dutch) [ci skip] --- messages/nl-NL.json | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/messages/nl-NL.json b/messages/nl-NL.json index bdb22ef52e..a483c147e0 100644 --- a/messages/nl-NL.json +++ b/messages/nl-NL.json @@ -66,9 +66,15 @@ "local": "Lokaal", "edit": "Bewerken", "siteConfirmDelete": "Verwijderen van site bevestigen", + "siteConfirmDeleteAndResources": "Bevestig Verwijderen van Site en Bronnen", "siteDelete": "Site verwijderen", + "siteDeleteAndResources": "Site en Bronnen verwijderen", "siteMessageRemove": "Eenmaal verwijderd zal de site niet langer toegankelijk zijn. Alle aan de site gekoppelde doelen zullen ook worden verwijderd.", + "siteMessageRemoveAndResources": "Dit zal permanent alle publieke en private resources gekoppeld aan deze site verwijderen, zelfs als een resource ook aan andere sites is gekoppeld.", "siteQuestionRemove": "Weet u zeker dat u de site wilt verwijderen uit de organisatie?", + "siteQuestionRemoveAndResources": "Weet u zeker dat u deze site en alle gekoppelde resources wilt verwijderen?", + "sitesTableDeleteSite": "Site verwijderen", + "sitesTableDeleteSiteAndResources": "Site en Bronnen verwijderen", "siteManageSites": "Sites beheren", "siteDescription": "Maak en beheer sites om verbinding met privénetwerken in te schakelen", "sitesBannerTitle": "Verbind elk netwerk", @@ -2338,6 +2344,7 @@ "createInternalResourceDialogDestinationCidrDescription": "Het CIDR-bereik van het document op het netwerk van de site.", "createInternalResourceDialogAlias": "Alias", "createInternalResourceDialogAliasDescription": "Een optionele interne DNS-alias voor dit document.", + "internalResourceAliasLocalWarning": "Aliassen die eindigen op .local kunnen resolutieproblemen veroorzaken vanwege mDNS op sommige netwerken.", "internalResourceDownstreamSchemeRequired": "Schema is vereist voor HTTP-bronnen", "internalResourceHttpPortRequired": "Bestemmingspoort is vereist voor HTTP-bronnen", "siteConfiguration": "Configuratie", @@ -2967,7 +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": "An updated version is available. Please update to the latest version for the best experience.", + "updateAvailableInfo": "Er is een bijgewerkte versie beschikbaar. Update naar de nieuwste versie voor de beste ervaring.", "client": "Klant", "proxyProtocol": "Proxy Protocol Instellingen", "proxyProtocolDescription": "Proxyprotocol configureren om de IP-adressen van de client voor TCP-diensten te bewaren.", From 822a07d48e6b1003884b87ab6c65ae93f97a1f18 Mon Sep 17 00:00:00 2001 From: Owen Schwartz Date: Thu, 25 Jun 2026 11:44:35 -0700 Subject: [PATCH 83/88] New translations en-us.json (Polish) [ci skip] --- messages/pl-PL.json | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/messages/pl-PL.json b/messages/pl-PL.json index 1d553fa3bf..bfe29df7d5 100644 --- a/messages/pl-PL.json +++ b/messages/pl-PL.json @@ -66,9 +66,15 @@ "local": "Lokalny", "edit": "Edytuj", "siteConfirmDelete": "Potwierdź usunięcie witryny", + "siteConfirmDeleteAndResources": "Potwierdź usunięcie witryny i zasobów", "siteDelete": "Usuń witrynę", + "siteDeleteAndResources": "Usuń witrynę i zasoby", "siteMessageRemove": "Po usunięciu witryna nie będzie już dostępna. Wszystkie cele związane z witryną zostaną również usunięte.", + "siteMessageRemoveAndResources": "To spowoduje trwałe usunięcie wszystkich zasobów publicznych i prywatnych powiązanych z tą witryną, nawet jeśli zasób jest także powiązany z innymi witrynami.", "siteQuestionRemove": "Czy na pewno chcesz usunąć witrynę z organizacji?", + "siteQuestionRemoveAndResources": "Czy na pewno chcesz usunąć tę witrynę i wszystkie powiązane zasoby?", + "sitesTableDeleteSite": "Usuń witrynę", + "sitesTableDeleteSiteAndResources": "Usuń witrynę i zasoby", "siteManageSites": "Zarządzaj stronami", "siteDescription": "Tworzenie stron i zarządzanie nimi, aby włączyć połączenia z prywatnymi sieciami", "sitesBannerTitle": "Połącz dowolną sieć", @@ -2338,6 +2344,7 @@ "createInternalResourceDialogDestinationCidrDescription": "Zakres CIDR zasobu w sieci witryny.", "createInternalResourceDialogAlias": "Alias", "createInternalResourceDialogAliasDescription": "Opcjonalny wewnętrzny alias DNS dla tego zasobu.", + "internalResourceAliasLocalWarning": "Alias kończący się na .local może powodować problemy z rozpoznawaniem z powodu mDNS w niektórych sieciach.", "internalResourceDownstreamSchemeRequired": "Schemat jest wymagany dla zasobów HTTP", "internalResourceHttpPortRequired": "Port docelowy jest wymagany dla zasobów HTTP", "siteConfiguration": "Konfiguracja", @@ -2967,7 +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": "An updated version is available. Please update to the latest version for the best experience.", + "updateAvailableInfo": "Dostępna jest zaktualizowana wersja. Zaktualizuj do najnowszej wersji, aby uzyskać najlepsze wrażenia z użytkowania.", "client": "Klient", "proxyProtocol": "Ustawienia protokołu proxy", "proxyProtocolDescription": "Skonfiguruj protokół Proxy aby zachować adresy IP klienta dla usług TCP.", From 66b1b385a342ff05d82db7e85efa22645d7aa0cd Mon Sep 17 00:00:00 2001 From: Owen Schwartz Date: Thu, 25 Jun 2026 11:44:37 -0700 Subject: [PATCH 84/88] New translations en-us.json (Portuguese) [ci skip] --- messages/pt-PT.json | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/messages/pt-PT.json b/messages/pt-PT.json index 345a21b246..ff0d543fe9 100644 --- a/messages/pt-PT.json +++ b/messages/pt-PT.json @@ -66,9 +66,15 @@ "local": "Localização", "edit": "Alterar", "siteConfirmDelete": "Confirmar que pretende apagar o site", + "siteConfirmDeleteAndResources": "Confirmar Exclusão do Site e Recursos", "siteDelete": "Excluir site", + "siteDeleteAndResources": "Excluir Site e Recursos", "siteMessageRemove": "Uma vez removido, o site não estará mais acessível. Todas as metas associadas ao site também serão removidas.", + "siteMessageRemoveAndResources": "Isso excluirá permanentemente todos os recursos públicos e privados vinculados a este site, mesmo que um recurso também esteja associado a outros sites.", "siteQuestionRemove": "Você tem certeza que deseja remover este site da organização?", + "siteQuestionRemoveAndResources": "Tem certeza de que deseja excluir este site e todos os recursos associados?", + "sitesTableDeleteSite": "Excluir Site", + "sitesTableDeleteSiteAndResources": "Excluir Site e Recursos", "siteManageSites": "Gerir sites", "siteDescription": "Criar e gerenciar sites para ativar a conectividade a redes privadas", "sitesBannerTitle": "Conectar a Qualquer Rede", @@ -2338,6 +2344,7 @@ "createInternalResourceDialogDestinationCidrDescription": "A faixa CIDR do recurso na rede do site.", "createInternalResourceDialogAlias": "Alias", "createInternalResourceDialogAliasDescription": "Um alias de DNS interno opcional para este recurso.", + "internalResourceAliasLocalWarning": "Os aliases terminando em .local podem causar problemas de resolução devido ao mDNS em algumas redes.", "internalResourceDownstreamSchemeRequired": "Esquema é obrigatório para recursos HTTP", "internalResourceHttpPortRequired": "Porta de destino é obrigatória para recursos HTTP", "siteConfiguration": "Configuração", @@ -2967,7 +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": "An updated version is available. Please update to the latest version for the best experience.", + "updateAvailableInfo": "Uma versão atualizada está disponível. Por favor, atualize para a versão mais recente para uma melhor experiência.", "client": "Cliente", "proxyProtocol": "Configurações de Protocolo Proxy", "proxyProtocolDescription": "Configurar o protocolo proxy para preservar endereços IP do cliente para serviços TCP.", From f3fe11c136bc98bc9082f6b9d52a8007e531045e Mon Sep 17 00:00:00 2001 From: Owen Schwartz Date: Thu, 25 Jun 2026 11:44:39 -0700 Subject: [PATCH 85/88] New translations en-us.json (Russian) [ci skip] --- messages/ru-RU.json | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/messages/ru-RU.json b/messages/ru-RU.json index 851f8153c5..523f3c2eff 100644 --- a/messages/ru-RU.json +++ b/messages/ru-RU.json @@ -66,9 +66,15 @@ "local": "Локальный", "edit": "Редактировать", "siteConfirmDelete": "Подтвердить удаление сайта", + "siteConfirmDeleteAndResources": "Подтвердите удаление сайта и ресурсов", "siteDelete": "Удалить сайт", + "siteDeleteAndResources": "Удалить сайт и ресурсы", "siteMessageRemove": "После удаления сайт больше не будет доступен. Все цели, связанные с сайтом, также будут удалены.", + "siteMessageRemoveAndResources": "Это навсегда удалит все общественные и частные ресурсы, связанные с этим сайтом, даже если ресурс также связан с другими сайтами.", "siteQuestionRemove": "Вы уверены, что хотите удалить сайт из организации?", + "siteQuestionRemoveAndResources": "Вы уверены, что хотите удалить этот сайт и все связанные с ним ресурсы?", + "sitesTableDeleteSite": "Удалить сайт", + "sitesTableDeleteSiteAndResources": "Удалить сайт и ресурсы", "siteManageSites": "Управление сайтами", "siteDescription": "Создание и управление сайтами, чтобы включить подключение к приватным сетям", "sitesBannerTitle": "Подключить любую сеть", @@ -2338,6 +2344,7 @@ "createInternalResourceDialogDestinationCidrDescription": "Диапазон CIDR ресурса в сети сайта.", "createInternalResourceDialogAlias": "Alias", "createInternalResourceDialogAliasDescription": "Дополнительный внутренний DNS псевдоним для этого ресурса.", + "internalResourceAliasLocalWarning": "Псевдонимы, оканчивающиеся на .local, могут вызывать проблемы с разрешением из-за mDNS в некоторых сетях.", "internalResourceDownstreamSchemeRequired": "Схема обязательна для HTTP ресурсов", "internalResourceHttpPortRequired": "Порт назначения обязателен для HTTP ресурсов", "siteConfiguration": "Конфигурация", @@ -2967,7 +2974,7 @@ "orgOrDomainIdMissing": "Отсутствует организация или ID домена", "loadingDNSRecords": "Загрузка записей DNS...", "olmUpdateAvailableInfo": "Доступна обновленная версия Олма. Пожалуйста, обновитесь до последней версии.", - "updateAvailableInfo": "An updated version is available. Please update to the latest version for the best experience.", + "updateAvailableInfo": "Доступна обновленная версия. Пожалуйста, обновитесь до последней версии для получения лучшего опыта.", "client": "Клиент", "proxyProtocol": "Настройки протокола прокси", "proxyProtocolDescription": "Настроить Прокси-протокол для сохранения IP-адресов клиента для служб TCP.", From 39d61a35eb04ea6a49b92b988df282b2c516e9dd Mon Sep 17 00:00:00 2001 From: Owen Schwartz Date: Thu, 25 Jun 2026 11:44:41 -0700 Subject: [PATCH 86/88] New translations en-us.json (Turkish) [ci skip] --- messages/tr-TR.json | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/messages/tr-TR.json b/messages/tr-TR.json index f04b80159d..db5f8158a4 100644 --- a/messages/tr-TR.json +++ b/messages/tr-TR.json @@ -66,9 +66,15 @@ "local": "Yerel", "edit": "Düzenle", "siteConfirmDelete": "Site Silmeyi Onayla", + "siteConfirmDeleteAndResources": "Site ve Kaynakları Silmeyi Onayla", "siteDelete": "Siteyi Sil", + "siteDeleteAndResources": "Site ve Kaynakları Sil", "siteMessageRemove": "Kaldırıldıktan sonra site artık erişilebilir olmayacaktır. Siteyle ilişkilendirilmiş tüm hedefler de kaldırılacaktır.", + "siteMessageRemoveAndResources": "Bu işlem, diğer sitelerle de ilişkilendirilmiş olsa bile, bu siteye bağlı tüm genel ve özel kaynakları kalıcı olarak silecektir.", "siteQuestionRemove": "Siteyi organizasyondan kaldırmak istediğinizden emin misiniz?", + "siteQuestionRemoveAndResources": "Bu siteyi ve tüm ilişkili kaynakları silmek istediğinizden emin misiniz?", + "sitesTableDeleteSite": "Siteyi Sil", + "sitesTableDeleteSiteAndResources": "Site ve Kaynakları Sil", "siteManageSites": "Siteleri Yönet", "siteDescription": "Özel ağlara erişimi etkinleştirmek için siteler oluşturun ve yönetin", "sitesBannerTitle": "Herhangi Bir Ağa Bağlan", @@ -2338,6 +2344,7 @@ "createInternalResourceDialogDestinationCidrDescription": "Site ağındaki kaynağın CIDR aralığı.", "createInternalResourceDialogAlias": "Takma Ad", "createInternalResourceDialogAliasDescription": "Bu kaynak için isteğe bağlı dahili DNS takma adı.", + "internalResourceAliasLocalWarning": "Bazı ağlarda mDNS nedeniyle .local ile biten takma adlar çözümleme sorunlarına neden olabilir.", "internalResourceDownstreamSchemeRequired": "HTTP kaynakları için şema gereklidir", "internalResourceHttpPortRequired": "HTTP kaynakları için hedef bağlantı noktası gereklidir", "siteConfiguration": "Yapılandırma", @@ -2967,7 +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": "An updated version is available. Please update to the latest version for the best experience.", + "updateAvailableInfo": "Güncellenmiş bir sürüm mevcut. En iyi deneyim için lütfen en son sürüme güncelleyin.", "client": "İstemci", "proxyProtocol": "Proxy Protokol Ayarları", "proxyProtocolDescription": "TCP hizmetleri için istemci IP adreslerini korumak amacıyla Proxy Protokolünü yapılandırın.", From 7425daad3ff5b39286e097f876ad1e2b7b1c819b Mon Sep 17 00:00:00 2001 From: Owen Schwartz Date: Thu, 25 Jun 2026 11:44:43 -0700 Subject: [PATCH 87/88] New translations en-us.json (Chinese Simplified) [ci skip] --- messages/zh-CN.json | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/messages/zh-CN.json b/messages/zh-CN.json index 906cfc96e8..d4f124f96e 100644 --- a/messages/zh-CN.json +++ b/messages/zh-CN.json @@ -66,9 +66,15 @@ "local": "本地的", "edit": "编辑", "siteConfirmDelete": "确认删除节点", + "siteConfirmDeleteAndResources": "确认删除站点及资源", "siteDelete": "删除节点", + "siteDeleteAndResources": "删除站点及资源", "siteMessageRemove": "一旦移除,节点将无法访问。与节点相关的所有目标也将被移除。", + "siteMessageRemoveAndResources": "这将永久删除与该站点关联的所有公共和私人资源,即使资源也与其他站点相关联。", "siteQuestionRemove": "您确定要从组织中删除该节点吗?", + "siteQuestionRemoveAndResources": "您确定要删除此站点及所有关联资源吗?", + "sitesTableDeleteSite": "删除站点", + "sitesTableDeleteSiteAndResources": "删除站点及资源", "siteManageSites": "管理站点", "siteDescription": "创建和管理站点,启用与私人网络的连接", "sitesBannerTitle": "连接任何网络", @@ -2338,6 +2344,7 @@ "createInternalResourceDialogDestinationCidrDescription": "站点网络上资源的 CIDR 范围。", "createInternalResourceDialogAlias": "Alias", "createInternalResourceDialogAliasDescription": "此资源可选的内部DNS别名。", + "internalResourceAliasLocalWarning": "以 .local 结尾的别名可能会因某些网络上的 mDNS 而导致解析问题。", "internalResourceDownstreamSchemeRequired": "HTTP 资源需要方案", "internalResourceHttpPortRequired": "HTTP 资源需要目的端口", "siteConfiguration": "配置", @@ -2967,7 +2974,7 @@ "orgOrDomainIdMissing": "缺少机构或域 ID", "loadingDNSRecords": "正在载入DNS记录...", "olmUpdateAvailableInfo": "有最新版本的 Olm 可用。请更新到最新版本以获取最佳体验。", - "updateAvailableInfo": "An updated version is available. Please update to the latest version for the best experience.", + "updateAvailableInfo": "有新版本可用。请更新到最新版本以获得最佳体验。", "client": "客户端:", "proxyProtocol": "代理协议设置", "proxyProtocolDescription": "配置代理协议以保留TCP服务的客户端 IP 地址。", From 8183d19400505feee84f2f09ae33fbfe96d8ceea Mon Sep 17 00:00:00 2001 From: Owen Schwartz Date: Thu, 25 Jun 2026 11:44:44 -0700 Subject: [PATCH 88/88] New translations en-us.json (Norwegian Bokmal) [ci skip] --- messages/nb-NO.json | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/messages/nb-NO.json b/messages/nb-NO.json index 1660e12a44..f26499819b 100644 --- a/messages/nb-NO.json +++ b/messages/nb-NO.json @@ -66,9 +66,15 @@ "local": "Lokal", "edit": "Rediger", "siteConfirmDelete": "Bekreft Sletting av Område", + "siteConfirmDeleteAndResources": "Bekreft sletting av nettsted og ressurser", "siteDelete": "Slett Område", + "siteDeleteAndResources": "Slett nettsted og ressurser", "siteMessageRemove": "Når nettstedet er fjernet, vil det ikke lenger være tilgjengelig. Alle målene for nettstedet vil også bli fjernet.", + "siteMessageRemoveAndResources": "Dette vil permanent slette alle offentlige og private ressurser tilknyttet dette nettstedet, selv om en ressurs også er tilknyttet andre nettsteder.", "siteQuestionRemove": "Er du sikker på at du vil fjerne nettstedet fra organisasjonen?", + "siteQuestionRemoveAndResources": "Er du sikker på at du vil slette dette nettstedet og alle tilknyttede ressurser?", + "sitesTableDeleteSite": "Slett nettsted", + "sitesTableDeleteSiteAndResources": "Slett nettsted og ressurser", "siteManageSites": "Administrer Områder", "siteDescription": "Opprette og administrere nettsteder for å aktivere tilkobling til private nettverk", "sitesBannerTitle": "Koble til alle nettverk", @@ -2338,6 +2344,7 @@ "createInternalResourceDialogDestinationCidrDescription": "CIDR-rekkevidden til ressursen på nettstedets nettverk.", "createInternalResourceDialogAlias": "Alias", "createInternalResourceDialogAliasDescription": "Et valgfritt internt DNS-alias for denne ressursen.", + "internalResourceAliasLocalWarning": "Alias som slutter på .local kan forårsake oppløsningsproblemer på grunn av mDNS på enkelte nettverk.", "internalResourceDownstreamSchemeRequired": "Skjema er påkrevd for HTTP-ressurser", "internalResourceHttpPortRequired": "Destinasjonsport er nødvendig for HTTP-ressurser", "siteConfiguration": "Konfigurasjon", @@ -2967,7 +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": "An updated version is available. Please update to the latest version for the best experience.", + "updateAvailableInfo": "En oppdatert versjon er tilgjengelig. Vennligst oppdater til den nyeste versjonen for den beste opplevelsen.", "client": "Klient", "proxyProtocol": "Protokoll innstillinger for Protokoll", "proxyProtocolDescription": "Konfigurer Proxy-protokoll for å bevare klientens IP-adresser til TCP-tjenester.",