From 9759e86921e64e0f9536ff0bdf977a3f4703c2b3 Mon Sep 17 00:00:00 2001 From: miloschwartz Date: Tue, 23 Dec 2025 12:35:03 -0500 Subject: [PATCH 01/97] add stripPortFromHost and reuse everywhere --- server/lib/ip.ts | 33 ++++++++++++++++++++++ server/private/lib/logAccessAudit.ts | 15 ++-------- server/routers/auth/pollDeviceWebAuth.ts | 27 ++---------------- server/routers/auth/startDeviceWebAuth.ts | 27 ++---------------- server/routers/badger/exchangeSession.ts | 22 ++------------- server/routers/badger/logRequestAudit.ts | 22 ++------------- server/routers/badger/verifySession.ts | 34 ++--------------------- 7 files changed, 45 insertions(+), 135 deletions(-) diff --git a/server/lib/ip.ts b/server/lib/ip.ts index 280939f2..21ec78c1 100644 --- a/server/lib/ip.ts +++ b/server/lib/ip.ts @@ -4,6 +4,7 @@ import { and, eq, isNotNull } from "drizzle-orm"; import config from "@server/lib/config"; import z from "zod"; import logger from "@server/logger"; +import semver from "semver"; interface IPRange { start: bigint; @@ -683,3 +684,35 @@ export function parsePortRangeString( return result; } + +export function stripPortFromHost(ip: string, badgerVersion?: string): string { + const isNewerBadger = + badgerVersion && + semver.valid(badgerVersion) && + semver.gte(badgerVersion, "1.3.1"); + + if (isNewerBadger) { + return ip; + } + + if (ip.startsWith("[") && ip.includes("]")) { + // if brackets are found, extract the IPv6 address from between the brackets + const ipv6Match = ip.match(/\[(.*?)\]/); + if (ipv6Match) { + return ipv6Match[1]; + } + } + + // Check if it looks like IPv4 (contains dots and matches IPv4 pattern) + // IPv4 format: x.x.x.x where x is 0-255 + const ipv4Pattern = /^(\d{1,3}\.){3}\d{1,3}/; + if (ipv4Pattern.test(ip)) { + const lastColonIndex = ip.lastIndexOf(":"); + if (lastColonIndex !== -1) { + return ip.substring(0, lastColonIndex); + } + } + + // Return as is + return ip; +} diff --git a/server/private/lib/logAccessAudit.ts b/server/private/lib/logAccessAudit.ts index 5c423c60..33dcaf1f 100644 --- a/server/private/lib/logAccessAudit.ts +++ b/server/private/lib/logAccessAudit.ts @@ -17,6 +17,7 @@ import logger from "@server/logger"; import { and, eq, lt } from "drizzle-orm"; import cache from "@server/lib/cache"; import { calculateCutoffTimestamp } from "@server/lib/cleanupLogs"; +import { stripPortFromHost } from "@server/lib/ip"; async function getAccessDays(orgId: string): Promise { // check cache first @@ -116,19 +117,7 @@ export async function logAccessAudit(data: { } const clientIp = data.requestIp - ? (() => { - if ( - data.requestIp.startsWith("[") && - data.requestIp.includes("]") - ) { - // if brackets are found, extract the IPv6 address from between the brackets - const ipv6Match = data.requestIp.match(/\[(.*?)\]/); - if (ipv6Match) { - return ipv6Match[1]; - } - } - return data.requestIp; - })() + ? stripPortFromHost(data.requestIp) : undefined; const countryCode = data.requestIp diff --git a/server/routers/auth/pollDeviceWebAuth.ts b/server/routers/auth/pollDeviceWebAuth.ts index a5c71362..30d7183e 100644 --- a/server/routers/auth/pollDeviceWebAuth.ts +++ b/server/routers/auth/pollDeviceWebAuth.ts @@ -10,6 +10,7 @@ import { eq, and, gt } from "drizzle-orm"; import { createSession, generateSessionToken } from "@server/auth/sessions/app"; import { encodeHexLowerCase } from "@oslojs/encoding"; import { sha256 } from "@oslojs/crypto/sha2"; +import { stripPortFromHost } from "@server/lib/ip"; const paramsSchema = z.object({ code: z.string().min(1, "Code is required") @@ -27,30 +28,6 @@ export type PollDeviceWebAuthResponse = { token?: string; }; -// Helper function to extract IP from request (same as in startDeviceWebAuth) -function extractIpFromRequest(req: Request): string | undefined { - const ip = req.ip || req.socket.remoteAddress; - if (!ip) { - return undefined; - } - - // Handle IPv6 format [::1] or IPv4 format - if (ip.startsWith("[") && ip.includes("]")) { - const ipv6Match = ip.match(/\[(.*?)\]/); - if (ipv6Match) { - return ipv6Match[1]; - } - } - - // Handle IPv4 with port (split at last colon) - const lastColonIndex = ip.lastIndexOf(":"); - if (lastColonIndex !== -1) { - return ip.substring(0, lastColonIndex); - } - - return ip; -} - export async function pollDeviceWebAuth( req: Request, res: Response, @@ -70,7 +47,7 @@ export async function pollDeviceWebAuth( try { const { code } = parsedParams.data; const now = Date.now(); - const requestIp = extractIpFromRequest(req); + const requestIp = req.ip ? stripPortFromHost(req.ip) : undefined; // Hash the code before querying const hashedCode = hashDeviceCode(code); diff --git a/server/routers/auth/startDeviceWebAuth.ts b/server/routers/auth/startDeviceWebAuth.ts index 85fb5262..e28e750c 100644 --- a/server/routers/auth/startDeviceWebAuth.ts +++ b/server/routers/auth/startDeviceWebAuth.ts @@ -12,6 +12,7 @@ import { TimeSpan } from "oslo"; import { maxmindLookup } from "@server/db/maxmind"; import { encodeHexLowerCase } from "@oslojs/encoding"; import { sha256 } from "@oslojs/crypto/sha2"; +import { stripPortFromHost } from "@server/lib/ip"; const bodySchema = z .object({ @@ -39,30 +40,6 @@ function hashDeviceCode(code: string): string { return encodeHexLowerCase(sha256(new TextEncoder().encode(code))); } -// Helper function to extract IP from request -function extractIpFromRequest(req: Request): string | undefined { - const ip = req.ip; - if (!ip) { - return undefined; - } - - // Handle IPv6 format [::1] or IPv4 format - if (ip.startsWith("[") && ip.includes("]")) { - const ipv6Match = ip.match(/\[(.*?)\]/); - if (ipv6Match) { - return ipv6Match[1]; - } - } - - // Handle IPv4 with port (split at last colon) - const lastColonIndex = ip.lastIndexOf(":"); - if (lastColonIndex !== -1) { - return ip.substring(0, lastColonIndex); - } - - return ip; -} - // Helper function to get city from IP (if available) async function getCityFromIp(ip: string): Promise { try { @@ -112,7 +89,7 @@ export async function startDeviceWebAuth( const hashedCode = hashDeviceCode(code); // Extract IP from request - const ip = extractIpFromRequest(req); + const ip = req.ip ? stripPortFromHost(req.ip) : undefined; // Get city (optional, may return undefined) const city = ip ? await getCityFromIp(ip) : undefined; diff --git a/server/routers/badger/exchangeSession.ts b/server/routers/badger/exchangeSession.ts index 4017cfea..bde5518b 100644 --- a/server/routers/badger/exchangeSession.ts +++ b/server/routers/badger/exchangeSession.ts @@ -19,6 +19,7 @@ import { import { SESSION_COOKIE_EXPIRES as RESOURCE_SESSION_COOKIE_EXPIRES } from "@server/auth/sessions/resource"; import config from "@server/lib/config"; import { response } from "@server/lib/response"; +import { stripPortFromHost } from "@server/lib/ip"; const exchangeSessionBodySchema = z.object({ requestToken: z.string(), @@ -62,26 +63,7 @@ export async function exchangeSession( cleanHost = cleanHost.slice(0, -1 * matched.length); } - const clientIp = requestIp - ? (() => { - if (requestIp.startsWith("[") && requestIp.includes("]")) { - const ipv6Match = requestIp.match(/\[(.*?)\]/); - if (ipv6Match) { - return ipv6Match[1]; - } - } - - const ipv4Pattern = /^(\d{1,3}\.){3}\d{1,3}/; - if (ipv4Pattern.test(requestIp)) { - const lastColonIndex = requestIp.lastIndexOf(":"); - if (lastColonIndex !== -1) { - return requestIp.substring(0, lastColonIndex); - } - } - - return requestIp; - })() - : undefined; + const clientIp = requestIp ? stripPortFromHost(requestIp) : undefined; const [resource] = await db .select() diff --git a/server/routers/badger/logRequestAudit.ts b/server/routers/badger/logRequestAudit.ts index 80e3f419..aade2f98 100644 --- a/server/routers/badger/logRequestAudit.ts +++ b/server/routers/badger/logRequestAudit.ts @@ -3,6 +3,7 @@ import logger from "@server/logger"; import { and, eq, lt } from "drizzle-orm"; import cache from "@server/lib/cache"; import { calculateCutoffTimestamp } from "@server/lib/cleanupLogs"; +import { stripPortFromHost } from "@server/lib/ip"; /** @@ -208,26 +209,7 @@ export async function logRequestAudit( } const clientIp = body.requestIp - ? (() => { - if ( - body.requestIp.startsWith("[") && - body.requestIp.includes("]") - ) { - // if brackets are found, extract the IPv6 address from between the brackets - const ipv6Match = body.requestIp.match(/\[(.*?)\]/); - if (ipv6Match) { - return ipv6Match[1]; - } - } - - // ivp4 - // split at last colon - const lastColonIndex = body.requestIp.lastIndexOf(":"); - if (lastColonIndex !== -1) { - return body.requestIp.substring(0, lastColonIndex); - } - return body.requestIp; - })() + ? stripPortFromHost(body.requestIp) : undefined; // Add to buffer instead of writing directly to DB diff --git a/server/routers/badger/verifySession.ts b/server/routers/badger/verifySession.ts index ca7c913e..0da83c03 100644 --- a/server/routers/badger/verifySession.ts +++ b/server/routers/badger/verifySession.ts @@ -21,7 +21,7 @@ import { resourceSessions } from "@server/db"; import config from "@server/lib/config"; -import { isIpInCidr } from "@server/lib/ip"; +import { isIpInCidr, stripPortFromHost } from "@server/lib/ip"; import { response } from "@server/lib/response"; import logger from "@server/logger"; import HttpCode from "@server/types/HttpCode"; @@ -110,37 +110,7 @@ export async function verifyResourceSession( const clientHeaderAuth = extractBasicAuth(headers); const clientIp = requestIp - ? (() => { - const isNewerBadger = - badgerVersion && - semver.valid(badgerVersion) && - semver.gte(badgerVersion, "1.3.1"); - - if (isNewerBadger) { - return requestIp; - } - - if (requestIp.startsWith("[") && requestIp.includes("]")) { - // if brackets are found, extract the IPv6 address from between the brackets - const ipv6Match = requestIp.match(/\[(.*?)\]/); - if (ipv6Match) { - return ipv6Match[1]; - } - } - - // Check if it looks like IPv4 (contains dots and matches IPv4 pattern) - // IPv4 format: x.x.x.x where x is 0-255 - const ipv4Pattern = /^(\d{1,3}\.){3}\d{1,3}/; - if (ipv4Pattern.test(requestIp)) { - const lastColonIndex = requestIp.lastIndexOf(":"); - if (lastColonIndex !== -1) { - return requestIp.substring(0, lastColonIndex); - } - } - - // Return as is - return requestIp; - })() + ? stripPortFromHost(requestIp, badgerVersion) : undefined; logger.debug("Client IP:", { clientIp }); From d6e0024c961777884de6be298b65ebd6dde60dc7 Mon Sep 17 00:00:00 2001 From: miloschwartz Date: Tue, 23 Dec 2025 12:51:38 -0500 Subject: [PATCH 02/97] improved button loading animation --- src/app/globals.css | 17 +++++++++++++++++ src/components/ui/button.tsx | 31 ++++++++++++++++++++++++++----- 2 files changed, 43 insertions(+), 5 deletions(-) diff --git a/src/app/globals.css b/src/app/globals.css index bd5860a6..70c614c0 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -162,3 +162,20 @@ p { #nprogress .bar { background: var(--color-primary) !important; } + +@keyframes dot-pulse { + 0%, 80%, 100% { + opacity: 0.3; + transform: scale(0.8); + } + 40% { + opacity: 1; + transform: scale(1); + } +} + +@layer utilities { + .animate-dot-pulse { + animation: dot-pulse 1.4s ease-in-out infinite; + } +} diff --git a/src/components/ui/button.tsx b/src/components/ui/button.tsx index f530ace1..9f32e9ce 100644 --- a/src/components/ui/button.tsx +++ b/src/components/ui/button.tsx @@ -3,7 +3,6 @@ import { Slot } from "@radix-ui/react-slot"; import { cva, type VariantProps } from "class-variance-authority"; import { cn } from "@app/lib/cn"; -import { Loader2 } from "lucide-react"; const buttonVariants = cva( "cursor-pointer inline-flex items-center justify-center whitespace-nowrap text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-0 disabled:pointer-events-none disabled:opacity-50", @@ -75,12 +74,34 @@ const Button = React.forwardRef( {asChild ? ( props.children ) : ( - <> + + + {props.children} + {loading && ( - + + + + + + + )} - {props.children} - + )} ); From 8732e5004704e1355d0981aa06d7a5a9929c8c14 Mon Sep 17 00:00:00 2001 From: miloschwartz Date: Tue, 23 Dec 2025 13:33:24 -0500 Subject: [PATCH 03/97] add flag to disable product help banners --- server/lib/config.ts | 4 ++++ server/lib/readConfigFile.ts | 3 ++- src/components/DismissableBanner.tsx | 9 ++++++++- src/lib/pullEnv.ts | 6 +++++- src/lib/types/env.ts | 1 + 5 files changed, 20 insertions(+), 3 deletions(-) diff --git a/server/lib/config.ts b/server/lib/config.ts index 405db2d1..d3931ec3 100644 --- a/server/lib/config.ts +++ b/server/lib/config.ts @@ -84,6 +84,10 @@ export class Config { ?.disable_basic_wireguard_sites ? "true" : "false"; + process.env.FLAGS_DISABLE_PRODUCT_HELP_BANNERS = parsedConfig.flags + ?.disable_product_help_banners + ? "true" + : "false"; process.env.PRODUCT_UPDATES_NOTIFICATION_ENABLED = parsedConfig.app .notifications.product_updates diff --git a/server/lib/readConfigFile.ts b/server/lib/readConfigFile.ts index da567820..90ebdc89 100644 --- a/server/lib/readConfigFile.ts +++ b/server/lib/readConfigFile.ts @@ -330,7 +330,8 @@ export const configSchema = z enable_integration_api: z.boolean().optional(), disable_local_sites: z.boolean().optional(), disable_basic_wireguard_sites: z.boolean().optional(), - disable_config_managed_domains: z.boolean().optional() + disable_config_managed_domains: z.boolean().optional(), + disable_product_help_banners: z.boolean().optional() }) .optional(), dns: z diff --git a/src/components/DismissableBanner.tsx b/src/components/DismissableBanner.tsx index 9d8b246e..c4230c54 100644 --- a/src/components/DismissableBanner.tsx +++ b/src/components/DismissableBanner.tsx @@ -1,9 +1,10 @@ "use client"; -import React, { useState, useEffect, type ReactNode } from "react"; +import React, { useState, useEffect, type ReactNode, useEffectEvent } from "react"; import { Card, CardContent } from "@app/components/ui/card"; import { X } from "lucide-react"; import { useTranslations } from "next-intl"; +import { useEnvContext } from "@app/hooks/useEnvContext"; type DismissableBannerProps = { storageKey: string; @@ -25,6 +26,12 @@ export const DismissableBanner = ({ const [isDismissed, setIsDismissed] = useState(true); const t = useTranslations(); + const { env } = useEnvContext(); + + if (env.flags.disableProductHelpBanners) { + return null; + } + useEffect(() => { const dismissedData = localStorage.getItem(storageKey); if (dismissedData) { diff --git a/src/lib/pullEnv.ts b/src/lib/pullEnv.ts index dbe47bd5..ed0057de 100644 --- a/src/lib/pullEnv.ts +++ b/src/lib/pullEnv.ts @@ -59,7 +59,11 @@ export function pullEnv(): Env { hideSupporterKey: process.env.HIDE_SUPPORTER_KEY === "true" ? true : false, usePangolinDns: - process.env.USE_PANGOLIN_DNS === "true" ? true : false + process.env.USE_PANGOLIN_DNS === "true" ? true : false, + disableProductHelpBanners: + process.env.FLAGS_DISABLE_PRODUCT_HELP_BANNERS === "true" + ? true + : false }, branding: { diff --git a/src/lib/types/env.ts b/src/lib/types/env.ts index e40ac5d3..1f54f680 100644 --- a/src/lib/types/env.ts +++ b/src/lib/types/env.ts @@ -33,6 +33,7 @@ export type Env = { disableBasicWireguardSites: boolean; hideSupporterKey: boolean; usePangolinDns: boolean; + disableProductHelpBanners: boolean; }; branding: { appName?: string; From 768b9ffd091b70037f05c6db129ccc6a9fba9e50 Mon Sep 17 00:00:00 2001 From: miloschwartz Date: Tue, 23 Dec 2025 13:37:48 -0500 Subject: [PATCH 04/97] fix server admin spacing on mobile sidebar --- src/components/LayoutMobileMenu.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/LayoutMobileMenu.tsx b/src/components/LayoutMobileMenu.tsx index 7c4da0bc..7c491d40 100644 --- a/src/components/LayoutMobileMenu.tsx +++ b/src/components/LayoutMobileMenu.tsx @@ -83,7 +83,7 @@ export function LayoutMobileMenu({
{!isAdminPage && user.serverAdmin && ( -
+
Date: Tue, 23 Dec 2025 13:41:11 -0500 Subject: [PATCH 05/97] fade mobile footer --- src/components/LayoutMobileMenu.tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/components/LayoutMobileMenu.tsx b/src/components/LayoutMobileMenu.tsx index 7c491d40..f6898c0b 100644 --- a/src/components/LayoutMobileMenu.tsx +++ b/src/components/LayoutMobileMenu.tsx @@ -72,7 +72,7 @@ export function LayoutMobileMenu({ {t("navbarDescription")} -
+
+
From 2f561b56043af3fc5e3d0574ac5b4540652f4602 Mon Sep 17 00:00:00 2001 From: miloschwartz Date: Tue, 23 Dec 2025 13:57:44 -0500 Subject: [PATCH 06/97] adjustments to mobile header css closes #1930 --- src/components/Layout.tsx | 2 +- src/components/LayoutMobileMenu.tsx | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/components/Layout.tsx b/src/components/Layout.tsx index 3523dbd7..71dff6bf 100644 --- a/src/components/Layout.tsx +++ b/src/components/Layout.tsx @@ -75,7 +75,7 @@ export async function Layout({
{children} diff --git a/src/components/LayoutMobileMenu.tsx b/src/components/LayoutMobileMenu.tsx index f6898c0b..a7588f35 100644 --- a/src/components/LayoutMobileMenu.tsx +++ b/src/components/LayoutMobileMenu.tsx @@ -48,7 +48,7 @@ export function LayoutMobileMenu({ const t = useTranslations(); return ( -
+
{showSidebar && ( From db43cf1b302ad411b6f94b1f69a7b2c0728696c0 Mon Sep 17 00:00:00 2001 From: miloschwartz Date: Tue, 23 Dec 2025 14:58:58 -0500 Subject: [PATCH 07/97] add sticky actions col to org idp table --- src/components/private/OrgIdpDataTable.tsx | 2 ++ src/components/private/OrgIdpTable.tsx | 1 + 2 files changed, 3 insertions(+) diff --git a/src/components/private/OrgIdpDataTable.tsx b/src/components/private/OrgIdpDataTable.tsx index a7dc1850..9a45f49e 100644 --- a/src/components/private/OrgIdpDataTable.tsx +++ b/src/components/private/OrgIdpDataTable.tsx @@ -27,6 +27,8 @@ export function IdpDataTable({ searchColumn="name" addButtonText={t("idpAdd")} onAdd={onAdd} + enableColumnVisibility={true} + stickyRightColumn="actions" /> ); } diff --git a/src/components/private/OrgIdpTable.tsx b/src/components/private/OrgIdpTable.tsx index f5fdfe40..387fd3c7 100644 --- a/src/components/private/OrgIdpTable.tsx +++ b/src/components/private/OrgIdpTable.tsx @@ -118,6 +118,7 @@ export default function IdpTable({ idps, orgId }: Props) { }, { id: "actions", + enableHiding: false, header: () => {t("actions")}, cell: ({ row }) => { const siteRow = row.original; From a01c06bbc7e22b0261dbc1e3dd62117b263d002d Mon Sep 17 00:00:00 2001 From: Owen Date: Wed, 24 Dec 2025 10:47:01 -0500 Subject: [PATCH 08/97] Respect http status for url & maintenance mode Fixes #2164 --- .../resources/proxy/[niceId]/general/page.tsx | 17 ++++++++++++++--- src/components/ResourceInfoBox.tsx | 12 ++++++------ 2 files changed, 20 insertions(+), 9 deletions(-) diff --git a/src/app/[orgId]/settings/resources/proxy/[niceId]/general/page.tsx b/src/app/[orgId]/settings/resources/proxy/[niceId]/general/page.tsx index 7cf9339b..d8f050d3 100644 --- a/src/app/[orgId]/settings/resources/proxy/[niceId]/general/page.tsx +++ b/src/app/[orgId]/settings/resources/proxy/[niceId]/general/page.tsx @@ -164,6 +164,10 @@ function MaintenanceSectionForm({ return isEnterpriseNotLicensed || isSaasNotSubscribed; }; + if (!resource.http) { + return null; + } + return ( @@ -437,9 +441,16 @@ export default function GeneralForm() { ); const resourceFullDomainName = useMemo(() => { - const url = new URL(resourceFullDomain); - return url.hostname; - }, [resourceFullDomain]); + if (!resource.fullDomain) { + return ""; + } + try { + const url = new URL(resourceFullDomain); + return url.hostname; + } catch { + return ""; + } + }, [resourceFullDomain, resource.fullDomain]); const [selectedDomain, setSelectedDomain] = useState<{ domainId: string; diff --git a/src/components/ResourceInfoBox.tsx b/src/components/ResourceInfoBox.tsx index 6ef7521f..187edb5b 100644 --- a/src/components/ResourceInfoBox.tsx +++ b/src/components/ResourceInfoBox.tsx @@ -32,12 +32,6 @@ export default function ResourceInfoBox({}: ResourceInfoBoxType) { - - URL - - - - {t("identifier")} @@ -46,6 +40,12 @@ export default function ResourceInfoBox({}: ResourceInfoBoxType) { {resource.http ? ( <> + + URL + + + + {t("authentication")} From dccf101554d3d56197c30e917619c9b501d77dca Mon Sep 17 00:00:00 2001 From: Owen Date: Wed, 24 Dec 2025 10:49:18 -0500 Subject: [PATCH 09/97] Allow all in country in blueprints Fixes #2163 --- server/lib/blueprints/types.ts | 14 ++++++-------- 1 file changed, 6 insertions(+), 8 deletions(-) diff --git a/server/lib/blueprints/types.ts b/server/lib/blueprints/types.ts index cbd2553f..650d5b18 100644 --- a/server/lib/blueprints/types.ts +++ b/server/lib/blueprints/types.ts @@ -111,32 +111,30 @@ export const RuleSchema = z .refine( (rule) => { if (rule.match === "country") { - // Check if it's a valid 2-letter country code - return /^[A-Z]{2}$/.test(rule.value); + // Check if it's a valid 2-letter country code or "ALL" + return /^[A-Z]{2}$/.test(rule.value) || rule.value === "ALL"; } return true; }, { path: ["value"], message: - "Value must be a 2-letter country code when match is 'country'" + "Value must be a 2-letter country code or 'ALL' when match is 'country'" } ) .refine( (rule) => { if (rule.match === "asn") { - // Check if it's either AS format or just a number + // Check if it's either AS format or "ALL" const asNumberPattern = /^AS\d+$/i; - const isASFormat = asNumberPattern.test(rule.value); - const isNumeric = /^\d+$/.test(rule.value); - return isASFormat || isNumeric; + return asNumberPattern.test(rule.value) || rule.value === "ALL"; } return true; }, { path: ["value"], message: - "Value must be either 'AS' format or a number when match is 'asn'" + "Value must be 'AS' format or 'ALL' when match is 'asn'" } ); From 81a9a9426446a31185f14a1644f4b2c76b4698ae Mon Sep 17 00:00:00 2001 From: Owen Date: Wed, 24 Dec 2025 12:20:22 -0500 Subject: [PATCH 10/97] Try to remove deadlocks on client updates --- .../newt/handleReceiveBandwidthMessage.ts | 104 +++++++++++++----- 1 file changed, 79 insertions(+), 25 deletions(-) diff --git a/server/routers/newt/handleReceiveBandwidthMessage.ts b/server/routers/newt/handleReceiveBandwidthMessage.ts index 3d060a0c..eb930e68 100644 --- a/server/routers/newt/handleReceiveBandwidthMessage.ts +++ b/server/routers/newt/handleReceiveBandwidthMessage.ts @@ -1,7 +1,7 @@ import { db } from "@server/db"; import { MessageHandler } from "@server/routers/ws"; -import { clients, Newt } from "@server/db"; -import { eq } from "drizzle-orm"; +import { clients } from "@server/db"; +import { eq, sql } from "drizzle-orm"; import logger from "@server/logger"; interface PeerBandwidth { @@ -10,13 +10,57 @@ interface PeerBandwidth { bytesOut: number; } +// Retry configuration for deadlock handling +const MAX_RETRIES = 3; +const BASE_DELAY_MS = 50; + +/** + * Check if an error is a deadlock error + */ +function isDeadlockError(error: any): boolean { + return ( + error?.code === "40P01" || + error?.cause?.code === "40P01" || + (error?.message && error.message.includes("deadlock")) + ); +} + +/** + * Execute a function with retry logic for deadlock handling + */ +async function withDeadlockRetry( + operation: () => Promise, + context: string +): Promise { + let attempt = 0; + while (true) { + try { + return await operation(); + } catch (error: any) { + if (isDeadlockError(error) && attempt < MAX_RETRIES) { + attempt++; + const baseDelay = Math.pow(2, attempt - 1) * BASE_DELAY_MS; + const jitter = Math.random() * baseDelay; + const delay = baseDelay + jitter; + logger.warn( + `Deadlock detected in ${context}, retrying attempt ${attempt}/${MAX_RETRIES} after ${delay.toFixed(0)}ms` + ); + await new Promise((resolve) => setTimeout(resolve, delay)); + continue; + } + throw error; + } + } +} + export const handleReceiveBandwidthMessage: MessageHandler = async ( context ) => { - const { message, client, sendToClient } = context; + const { message } = context; if (!message.data.bandwidthData) { logger.warn("No bandwidth data provided"); + return; } const bandwidthData: PeerBandwidth[] = message.data.bandwidthData; @@ -25,30 +69,40 @@ export const handleReceiveBandwidthMessage: MessageHandler = async ( throw new Error("Invalid bandwidth data"); } - await db.transaction(async (trx) => { - for (const peer of bandwidthData) { - const { publicKey, bytesIn, bytesOut } = peer; + // Sort bandwidth data by publicKey to ensure consistent lock ordering across all instances + // This is critical for preventing deadlocks when multiple instances update the same clients + const sortedBandwidthData = [...bandwidthData].sort((a, b) => + a.publicKey.localeCompare(b.publicKey) + ); - // Find the client by public key - const [client] = await trx - .select() - .from(clients) - .where(eq(clients.pubKey, publicKey)) - .limit(1); + const currentTime = new Date().toISOString(); - if (!client) { - continue; - } + // Update each client individually with retry logic + // This reduces transaction scope and allows retries per-client + for (const peer of sortedBandwidthData) { + const { publicKey, bytesIn, bytesOut } = peer; - // Update the client's bandwidth usage - await trx - .update(clients) - .set({ - megabytesOut: (client.megabytesIn || 0) + bytesIn, - megabytesIn: (client.megabytesOut || 0) + bytesOut, - lastBandwidthUpdate: new Date().toISOString() - }) - .where(eq(clients.clientId, client.clientId)); + try { + await withDeadlockRetry(async () => { + // Use atomic SQL increment to avoid SELECT then UPDATE pattern + // This eliminates the need to read the current value first + await db + .update(clients) + .set({ + // Note: bytesIn from peer goes to megabytesOut (data sent to client) + // and bytesOut from peer goes to megabytesIn (data received from client) + megabytesOut: sql`COALESCE(${clients.megabytesOut}, 0) + ${bytesIn}`, + megabytesIn: sql`COALESCE(${clients.megabytesIn}, 0) + ${bytesOut}`, + lastBandwidthUpdate: currentTime + }) + .where(eq(clients.pubKey, publicKey)); + }, `update client bandwidth ${publicKey}`); + } catch (error) { + logger.error( + `Failed to update bandwidth for client ${publicKey}:`, + error + ); + // Continue with other clients even if one fails } - }); + } }; From 284cccbe17ebc0fa6fe6e29696db83f5aa016972 Mon Sep 17 00:00:00 2001 From: Owen Date: Wed, 24 Dec 2025 12:25:11 -0500 Subject: [PATCH 11/97] Attempt to fix loginPageOrg undefined error --- server/private/routers/hybrid.ts | 20 +++++++++---------- .../routers/loginPage/loadLoginPage.ts | 10 ++++++++++ .../loginPage/loadLoginPageBranding.ts | 5 +++++ 3 files changed, 25 insertions(+), 10 deletions(-) diff --git a/server/private/routers/hybrid.ts b/server/private/routers/hybrid.ts index bbc0e0c8..a398dfe6 100644 --- a/server/private/routers/hybrid.ts +++ b/server/private/routers/hybrid.ts @@ -618,6 +618,16 @@ hybridRouter.get( ) .limit(1); + if (!result) { + return response(res, { + data: null, + success: true, + error: false, + message: "Login page not found", + status: HttpCode.OK + }); + } + if ( await checkExitNodeOrg( remoteExitNode.exitNodeId, @@ -633,16 +643,6 @@ hybridRouter.get( ); } - if (!result) { - return response(res, { - data: null, - success: true, - error: false, - message: "Login page not found", - status: HttpCode.OK - }); - } - return response(res, { data: result.loginPage, success: true, diff --git a/server/private/routers/loginPage/loadLoginPage.ts b/server/private/routers/loginPage/loadLoginPage.ts index 1b10e205..7a631c8a 100644 --- a/server/private/routers/loginPage/loadLoginPage.ts +++ b/server/private/routers/loginPage/loadLoginPage.ts @@ -40,6 +40,11 @@ async function query(orgId: string | undefined, fullDomain: string) { eq(loginPage.loginPageId, loginPageOrg.loginPageId) ) .limit(1); + + if (!res) { + return null; + } + return { ...res.loginPage, orgId: res.loginPageOrg.orgId @@ -65,6 +70,11 @@ async function query(orgId: string | undefined, fullDomain: string) { ) ) .limit(1); + + if (!res) { + return null; + } + return { ...res, orgId: orgLink.orgId diff --git a/server/private/routers/loginPage/loadLoginPageBranding.ts b/server/private/routers/loginPage/loadLoginPageBranding.ts index 823f75a6..1197bb10 100644 --- a/server/private/routers/loginPage/loadLoginPageBranding.ts +++ b/server/private/routers/loginPage/loadLoginPageBranding.ts @@ -48,6 +48,11 @@ async function query(orgId: string) { ) ) .limit(1); + + if (!res) { + return null; + } + return { ...res, orgId: orgLink.orgs.orgId, From c0c0d48edf7f0b3c6d05a94f2b6e3eb04a7e1900 Mon Sep 17 00:00:00 2001 From: miloschwartz Date: Wed, 24 Dec 2025 15:53:08 -0500 Subject: [PATCH 12/97] ui enhancements --- messages/en-US.json | 2 +- server/routers/client/getClient.ts | 2 +- .../settings/(private)/idp/create/page.tsx | 2 +- .../[orgId]/settings/(private)/idp/page.tsx | 34 +------- .../resources/proxy/[niceId]/proxy/page.tsx | 2 +- src/app/globals.css | 12 +++ src/app/layout.tsx | 4 +- src/components/Layout.tsx | 4 +- src/components/LayoutMobileMenu.tsx | 2 +- src/components/ProxyResourcesTable.tsx | 2 +- src/components/ViewportHeightFix.tsx | 79 +++++++++++++++++++ src/components/ui/button.tsx | 43 +++++----- src/components/ui/checkbox.tsx | 16 ++-- 13 files changed, 131 insertions(+), 73 deletions(-) create mode 100644 src/components/ViewportHeightFix.tsx diff --git a/messages/en-US.json b/messages/en-US.json index 57821a6d..8b04e4ea 100644 --- a/messages/en-US.json +++ b/messages/en-US.json @@ -1480,7 +1480,7 @@ "IAgreeToThe": "I agree to the", "termsOfService": "terms of service", "and": "and", - "privacyPolicy": "privacy policy" + "privacyPolicy": "privacy policy." }, "signUpMarketing": { "keepMeInTheLoop": "Keep me in the loop with news, updates, and new features by email." diff --git a/server/routers/client/getClient.ts b/server/routers/client/getClient.ts index cfb2652b..f054ce80 100644 --- a/server/routers/client/getClient.ts +++ b/server/routers/client/getClient.ts @@ -36,7 +36,7 @@ async function query(clientId?: number, niceId?: string, orgId?: string) { .select() .from(clients) .where(and(eq(clients.niceId, niceId), eq(clients.orgId, orgId))) - .leftJoin(olms, eq(olms.clientId, olms.clientId)) + .leftJoin(olms, eq(clients.clientId, olms.clientId)) .limit(1); return res; } diff --git a/src/app/[orgId]/settings/(private)/idp/create/page.tsx b/src/app/[orgId]/settings/(private)/idp/create/page.tsx index 786c8635..f6260073 100644 --- a/src/app/[orgId]/settings/(private)/idp/create/page.tsx +++ b/src/app/[orgId]/settings/(private)/idp/create/page.tsx @@ -285,7 +285,7 @@ export default function Page() { diff --git a/src/app/globals.css b/src/app/globals.css index 70c614c0..731e1bff 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -178,4 +178,16 @@ p { .animate-dot-pulse { animation: dot-pulse 1.4s ease-in-out infinite; } + + /* Use JavaScript-set viewport height for mobile to handle keyboard properly */ + .h-screen-safe { + height: 100vh; /* Default for desktop and fallback */ + } + + /* Only apply custom viewport height on mobile */ + @media (max-width: 767px) { + .h-screen-safe { + height: var(--vh, 100vh); /* Use CSS variable set by ViewportHeightFix on mobile */ + } + } } diff --git a/src/app/layout.tsx b/src/app/layout.tsx index e76a5d2f..203dd778 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -22,6 +22,7 @@ import { TopLoader } from "@app/components/Toploader"; import Script from "next/script"; import { TanstackQueryProvider } from "@app/components/TanstackQueryProvider"; import { TailwindIndicator } from "@app/components/TailwindIndicator"; +import { ViewportHeightFix } from "@app/components/ViewportHeightFix"; export const metadata: Metadata = { title: `Dashboard - ${process.env.BRANDING_APP_NAME || "Pangolin"}`, @@ -77,7 +78,7 @@ export default async function RootLayout({ return ( - + {build === "saas" && (