From 98c77ad7e25b0624a2450f54257e55a3e07bd479 Mon Sep 17 00:00:00 2001 From: Owen Schwartz Date: Sun, 14 Dec 2025 03:09:45 -0500 Subject: [PATCH 01/12] Update README.md --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index a842ed3b..27105c70 100644 --- a/README.md +++ b/README.md @@ -31,7 +31,7 @@ [![Slack](https://img.shields.io/badge/chat-slack-yellow?style=flat-square&logo=slack)](https://pangolin.net/slack) [![Docker](https://img.shields.io/docker/pulls/fosrl/pangolin?style=flat-square)](https://hub.docker.com/r/fosrl/pangolin) ![Stars](https://img.shields.io/github/stars/fosrl/pangolin?style=flat-square) -[![YouTube](https://img.shields.io/badge/YouTube-red?logo=youtube&logoColor=white&style=flat-square)](https://www.youtube.com/@fossorial-app) +[![YouTube](https://img.shields.io/badge/YouTube-red?logo=youtube&logoColor=white&style=flat-square)](https://www.youtube.com/@pangolin-net) From e94f21bc059aa180cbb6bafd46ad8ebb66560ede Mon Sep 17 00:00:00 2001 From: Varun Narravula Date: Mon, 15 Dec 2025 05:29:56 -0800 Subject: [PATCH 02/12] ci: parallelize test workflow --- .github/workflows/test.yml | 29 ++++++++++++++++++++++++----- 1 file changed, 24 insertions(+), 5 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 3b0627cc..41d43bd9 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -12,11 +12,12 @@ on: jobs: test: runs-on: ubuntu-latest - steps: - - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 + - name: Checkout repository + uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 - - uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v6.1.0 + - name: Install Node + uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v6.1.0 with: node-version: '22' @@ -57,8 +58,26 @@ jobs: echo "App failed to start" exit 1 + build-sqlite: + runs-on: ubuntu-latest + steps: + - name: Checkout repository + uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 + + - name: Copy config file + run: cp config/config.example.yml config/config.yml + - name: Build Docker image sqlite - run: make build-sqlite + run: make dev-build-sqlite + + build-postgres: + runs-on: ubuntu-latest + steps: + - name: Checkout repository + uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 + + - name: Copy config file + run: cp config/config.example.yml config/config.yml - name: Build Docker image pg - run: make build-pg + run: make dev-build-pg From 7ccde11e3ef6f9ef62d6b96cf614dc78b380c905 Mon Sep 17 00:00:00 2001 From: Owen Date: Thu, 18 Dec 2025 13:49:55 -0500 Subject: [PATCH 03/12] Fix crowdsec healthcheck Fixes #2118 --- install/config/crowdsec/docker-compose.yml | 13 +++++++++---- install/config/crowdsec/dynamic_config.yml | 13 +++++++++++-- 2 files changed, 20 insertions(+), 6 deletions(-) diff --git a/install/config/crowdsec/docker-compose.yml b/install/config/crowdsec/docker-compose.yml index 17289ef2..0fb95109 100644 --- a/install/config/crowdsec/docker-compose.yml +++ b/install/config/crowdsec/docker-compose.yml @@ -9,10 +9,15 @@ services: PARSERS: crowdsecurity/whitelists ENROLL_TAGS: docker healthcheck: - interval: 10s - retries: 15 - timeout: 10s - test: ["CMD", "cscli", "capi", "status"] + test: + - CMD + - cscli + - lapi + - status + interval: 10s + timeout: 5s + retries: 3 + start_period: 30s labels: - "traefik.enable=false" # Disable traefik for crowdsec volumes: diff --git a/install/config/crowdsec/dynamic_config.yml b/install/config/crowdsec/dynamic_config.yml index cac5fa6e..c58d5670 100644 --- a/install/config/crowdsec/dynamic_config.yml +++ b/install/config/crowdsec/dynamic_config.yml @@ -44,7 +44,7 @@ http: crowdsecAppsecUnreachableBlock: true # Block on unreachable crowdsecAppsecBodyLimit: 10485760 crowdsecLapiKey: "PUT_YOUR_BOUNCER_KEY_HERE_OR_IT_WILL_NOT_WORK" # CrowdSec API key which you noted down later - crowdsecLapiHost: crowdsec:8080 # CrowdSec + crowdsecLapiHost: crowdsec:8080 # CrowdSec crowdsecLapiScheme: http # CrowdSec API scheme forwardedHeadersTrustedIPs: # Forwarded headers trusted IPs - "0.0.0.0/0" # All IP addresses are trusted for forwarded headers (CHANGE MADE HERE) @@ -106,4 +106,13 @@ http: api-service: loadBalancer: servers: - - url: "http://pangolin:3000" # API/WebSocket server \ No newline at end of file + - url: "http://pangolin:3000" # API/WebSocket server + +tcp: + serversTransports: + pp-transport-v1: + proxyProtocol: + version: 1 + pp-transport-v2: + proxyProtocol: + version: 2 From 6b609bb07833f9f55bed23ef485b04d35ff58940 Mon Sep 17 00:00:00 2001 From: Owen Date: Thu, 18 Dec 2025 16:03:15 -0500 Subject: [PATCH 04/12] Force big queries onto primary db to prevent 40001 --- server/db/sqlite/driver.ts | 1 + .../auditLogs/queryRequestAnalytics.ts | 26 ++++- .../routers/auditLogs/queryRequestAuditLog.ts | 110 +++++++++++------- server/routers/olm/getOlmToken.ts | 14 ++- 4 files changed, 101 insertions(+), 50 deletions(-) diff --git a/server/db/sqlite/driver.ts b/server/db/sqlite/driver.ts index 0f696df6..5a4aa542 100644 --- a/server/db/sqlite/driver.ts +++ b/server/db/sqlite/driver.ts @@ -20,6 +20,7 @@ function createDb() { export const db = createDb(); export default db; +export const driver: "pg" | "sqlite" = "sqlite"; export type Transaction = Parameters< Parameters<(typeof db)["transaction"]>[0] >[0]; diff --git a/server/routers/auditLogs/queryRequestAnalytics.ts b/server/routers/auditLogs/queryRequestAnalytics.ts index d8ecd456..f4b4444c 100644 --- a/server/routers/auditLogs/queryRequestAnalytics.ts +++ b/server/routers/auditLogs/queryRequestAnalytics.ts @@ -12,6 +12,11 @@ import response from "@server/lib/response"; import logger from "@server/logger"; import { getSevenDaysAgo } from "@app/lib/getSevenDaysAgo"; +let primaryDb = db; +if (driver == "pg") { + primaryDb = db.$primary as typeof db; // select the primary instance in a replicated setup +} + const queryAccessAuditLogsQuery = z.object({ // iso string just validate its a parseable date timeStart: z @@ -74,12 +79,12 @@ async function query(query: Q) { ); } - const [all] = await db + const [all] = await primaryDb .select({ total: count() }) .from(requestAuditLog) .where(baseConditions); - const [blocked] = await db + const [blocked] = await primaryDb .select({ total: count() }) .from(requestAuditLog) .where(and(baseConditions, eq(requestAuditLog.action, false))); @@ -88,7 +93,9 @@ async function query(query: Q) { .mapWith(Number) .as("total"); - const requestsPerCountry = await db + const DISTINCT_LIMIT = 500; + + const requestsPerCountry = await primaryDb .selectDistinct({ code: requestAuditLog.location, count: totalQ @@ -96,7 +103,16 @@ async function query(query: Q) { .from(requestAuditLog) .where(and(baseConditions, not(isNull(requestAuditLog.location)))) .groupBy(requestAuditLog.location) - .orderBy(desc(totalQ)); + .orderBy(desc(totalQ)) + .limit(DISTINCT_LIMIT+1); + + if (requestsPerCountry.length > DISTINCT_LIMIT) { + // throw an error + throw createHttpError( + HttpCode.BAD_REQUEST, + `Too many distinct countries. Please narrow your query.` + ); + } const groupByDayFunction = driver === "pg" @@ -106,7 +122,7 @@ async function query(query: Q) { const booleanTrue = driver === "pg" ? sql`true` : sql`1`; const booleanFalse = driver === "pg" ? sql`false` : sql`0`; - const requestsPerDay = await db + const requestsPerDay = await primaryDb .select({ day: groupByDayFunction.as("day"), allowedCount: diff --git a/server/routers/auditLogs/queryRequestAuditLog.ts b/server/routers/auditLogs/queryRequestAuditLog.ts index b658dbb5..73f9fc43 100644 --- a/server/routers/auditLogs/queryRequestAuditLog.ts +++ b/server/routers/auditLogs/queryRequestAuditLog.ts @@ -1,4 +1,4 @@ -import { db, requestAuditLog, resources } from "@server/db"; +import { db, driver, requestAuditLog, resources } from "@server/db"; import { registry } from "@server/openApi"; import { NextFunction } from "express"; import { Request, Response } from "express"; @@ -13,6 +13,11 @@ import response from "@server/lib/response"; import logger from "@server/logger"; import { getSevenDaysAgo } from "@app/lib/getSevenDaysAgo"; +let primaryDb = db; +if (driver == "pg") { + primaryDb = db.$primary as typeof db; // select the primary instance in a replicated setup +} + export const queryAccessAuditLogsQuery = z.object({ // iso string just validate its a parseable date timeStart: z @@ -107,7 +112,7 @@ function getWhere(data: Q) { } export function queryRequest(data: Q) { - return db + return primaryDb .select({ id: requestAuditLog.id, timestamp: requestAuditLog.timestamp, @@ -143,7 +148,7 @@ export function queryRequest(data: Q) { } export function countRequestQuery(data: Q) { - const countQuery = db + const countQuery = primaryDb .select({ count: count() }) .from(requestAuditLog) .where(getWhere(data)); @@ -173,50 +178,61 @@ async function queryUniqueFilterAttributes( eq(requestAuditLog.orgId, orgId) ); - // Get unique actors - const uniqueActors = await db - .selectDistinct({ - actor: requestAuditLog.actor - }) - .from(requestAuditLog) - .where(baseConditions); + const DISTINCT_LIMIT = 500; - // Get unique locations - const uniqueLocations = await db - .selectDistinct({ - locations: requestAuditLog.location - }) - .from(requestAuditLog) - .where(baseConditions); + // TODO: SOMEONE PLEASE OPTIMIZE THIS!!!!! - // Get unique actors - const uniqueHosts = await db - .selectDistinct({ - hosts: requestAuditLog.host - }) - .from(requestAuditLog) - .where(baseConditions); + // Run all queries in parallel + const [ + uniqueActors, + uniqueLocations, + uniqueHosts, + uniquePaths, + uniqueResources + ] = await Promise.all([ + primaryDb + .selectDistinct({ actor: requestAuditLog.actor }) + .from(requestAuditLog) + .where(baseConditions) + .limit(DISTINCT_LIMIT+1), + primaryDb + .selectDistinct({ locations: requestAuditLog.location }) + .from(requestAuditLog) + .where(baseConditions) + .limit(DISTINCT_LIMIT+1), + primaryDb + .selectDistinct({ hosts: requestAuditLog.host }) + .from(requestAuditLog) + .where(baseConditions) + .limit(DISTINCT_LIMIT+1), + primaryDb + .selectDistinct({ paths: requestAuditLog.path }) + .from(requestAuditLog) + .where(baseConditions) + .limit(DISTINCT_LIMIT+1), + primaryDb + .selectDistinct({ + id: requestAuditLog.resourceId, + name: resources.name + }) + .from(requestAuditLog) + .leftJoin( + resources, + eq(requestAuditLog.resourceId, resources.resourceId) + ) + .where(baseConditions) + .limit(DISTINCT_LIMIT+1) + ]); - // Get unique actors - const uniquePaths = await db - .selectDistinct({ - paths: requestAuditLog.path - }) - .from(requestAuditLog) - .where(baseConditions); - - // Get unique resources with names - const uniqueResources = await db - .selectDistinct({ - id: requestAuditLog.resourceId, - name: resources.name - }) - .from(requestAuditLog) - .leftJoin( - resources, - eq(requestAuditLog.resourceId, resources.resourceId) - ) - .where(baseConditions); + if ( + uniqueActors.length > DISTINCT_LIMIT || + uniqueLocations.length > DISTINCT_LIMIT || + uniqueHosts.length > DISTINCT_LIMIT || + uniquePaths.length > DISTINCT_LIMIT || + uniqueResources.length > DISTINCT_LIMIT + ) { + throw new Error("Too many distinct filter attributes to retrieve. Please refine your time range."); + } return { actors: uniqueActors @@ -295,6 +311,12 @@ export async function queryRequestAuditLogs( }); } catch (error) { logger.error(error); + // if the message is "Too many distinct filter attributes to retrieve. Please refine your time range.", return a 400 and the message + if (error instanceof Error && error.message === "Too many distinct filter attributes to retrieve. Please refine your time range.") { + return next( + createHttpError(HttpCode.BAD_REQUEST, error.message) + ); + } return next( createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred") ); diff --git a/server/routers/olm/getOlmToken.ts b/server/routers/olm/getOlmToken.ts index b6dc8148..c8ede518 100644 --- a/server/routers/olm/getOlmToken.ts +++ b/server/routers/olm/getOlmToken.ts @@ -194,11 +194,23 @@ export async function getOlmToken( .where(inArray(exitNodes.exitNodeId, exitNodeIds)); } + // Map exitNodeId to siteIds + const exitNodeIdToSiteIds: Record = {}; + for (const { sites: site } of clientSites) { + if (site.exitNodeId !== null) { + if (!exitNodeIdToSiteIds[site.exitNodeId]) { + exitNodeIdToSiteIds[site.exitNodeId] = []; + } + exitNodeIdToSiteIds[site.exitNodeId].push(site.siteId); + } + } + const exitNodesHpData = allExitNodes.map((exitNode: ExitNode) => { return { publicKey: exitNode.publicKey, relayPort: config.getRawConfig().gerbil.clients_start_port, - endpoint: exitNode.endpoint + endpoint: exitNode.endpoint, + siteIds: exitNodeIdToSiteIds[exitNode.exitNodeId] ?? [] }; }); From 2479a3c53c65b0d2e1b9046727f9ad1dabe967f5 Mon Sep 17 00:00:00 2001 From: miloschwartz Date: Thu, 18 Dec 2025 16:13:15 -0500 Subject: [PATCH 05/12] improved private resource modal --- messages/en-US.json | 16 +- src/components/ClientResourcesTable.tsx | 12 +- .../CreateInternalResourceDialog.tsx | 1281 ++++++++------- src/components/EditInternalResourceDialog.tsx | 1438 +++++++++-------- src/components/HorizontalTabs.tsx | 76 +- src/components/ui/button.tsx | 2 +- src/lib/queries.ts | 6 +- 7 files changed, 1598 insertions(+), 1233 deletions(-) diff --git a/messages/en-US.json b/messages/en-US.json index 0cfd8f6f..df0907a5 100644 --- a/messages/en-US.json +++ b/messages/en-US.json @@ -2318,5 +2318,19 @@ "resourceLoginPageDescription": "Customize the login page for individual resources", "enterConfirmation": "Enter confirmation", "blueprintViewDetails": "Details", - "defaultIdentityProvider": "Default Identity Provider" + "defaultIdentityProvider": "Default Identity Provider", + "editInternalResourceDialogNetworkSettings": "Network Settings", + "editInternalResourceDialogAccessPolicy": "Access Policy", + "editInternalResourceDialogAddRoles": "Add Roles", + "editInternalResourceDialogAddUsers": "Add Users", + "editInternalResourceDialogAddClients": "Add Clients", + "editInternalResourceDialogDestinationLabel": "Destination", + "editInternalResourceDialogDestinationDescription": "Specify the destination address for the internal resource. This can be a hostname, IP address, or CIDR range depending on the selected mode. Optionally set an internal DNS alias for easier identification.", + "editInternalResourceDialogPortRestrictionsDescription": "Restrict access to specific TCP/UDP ports or allow/block all ports.", + "editInternalResourceDialogTcp": "TCP", + "editInternalResourceDialogUdp": "UDP", + "editInternalResourceDialogIcmp": "ICMP", + "editInternalResourceDialogAccessControl": "Access Control", + "editInternalResourceDialogAccessControlDescription": "Control which roles, users, and machine clients have access to this resource when connected. Admins always have access.", + "editInternalResourceDialogPortRangeValidationError": "Port range must be \"*\" for all ports, or a comma-separated list of ports and ranges (e.g., \"80,443,8000-9000\"). Ports must be between 1 and 65535." } diff --git a/src/components/ClientResourcesTable.tsx b/src/components/ClientResourcesTable.tsx index 023ef00c..5a7f031d 100644 --- a/src/components/ClientResourcesTable.tsx +++ b/src/components/ClientResourcesTable.tsx @@ -329,8 +329,11 @@ export default function ClientResourcesTable({ orgId={orgId} sites={sites} onSuccess={() => { - router.refresh(); - setEditingResource(null); + // Delay refresh to allow modal to close smoothly + setTimeout(() => { + router.refresh(); + setEditingResource(null); + }, 150); }} /> )} @@ -341,7 +344,10 @@ export default function ClientResourcesTable({ orgId={orgId} sites={sites} onSuccess={() => { - router.refresh(); + // Delay refresh to allow modal to close smoothly + setTimeout(() => { + router.refresh(); + }, 150); }} /> diff --git a/src/components/CreateInternalResourceDialog.tsx b/src/components/CreateInternalResourceDialog.tsx index 00e8ce96..afdaa77e 100644 --- a/src/components/CreateInternalResourceDialog.tsx +++ b/src/components/CreateInternalResourceDialog.tsx @@ -58,6 +58,7 @@ import { useTranslations } from "next-intl"; import { useEffect, useState } from "react"; import { useForm } from "react-hook-form"; import { z } from "zod"; +import { HorizontalTabs, TabItem } from "@app/components/HorizontalTabs"; // import { InfoPopup } from "@app/components/ui/info-popup"; // Helper to validate port range string format @@ -108,17 +109,18 @@ const isValidPortRangeString = (val: string | undefined | null): boolean => { }; // Port range string schema for client-side validation -const portRangeStringSchema = z - .string() - .optional() - .nullable() - .refine( - (val) => isValidPortRangeString(val), - { - message: - 'Port range must be "*" for all ports, or a comma-separated list of ports and ranges (e.g., "80,443,8000-9000"). Ports must be between 1 and 65535.' - } - ); +// Note: This schema is defined outside the component, so we'll use a function to get the message +const getPortRangeValidationMessage = (t: (key: string) => string) => + t("editInternalResourceDialogPortRangeValidationError"); + +const createPortRangeStringSchema = (t: (key: string) => string) => + z + .string() + .optional() + .nullable() + .refine((val) => isValidPortRangeString(val), { + message: getPortRangeValidationMessage(t) + }); // Helper to determine the port mode from a port range string type PortMode = "all" | "blocked" | "custom"; @@ -161,25 +163,18 @@ export default function CreateInternalResourceDialog({ .string() .min(1, t("createInternalResourceDialogNameRequired")) .max(255, t("createInternalResourceDialogNameMaxLength")), - // mode: z.enum(["host", "cidr", "port"]), - mode: z.enum(["host", "cidr"]), - destination: z.string().min(1), siteId: z .int() .positive(t("createInternalResourceDialogPleaseSelectSite")), - // protocol: z.enum(["tcp", "udp"]), - // proxyPort: z.int() - // .positive() - // .min(1, t("createInternalResourceDialogProxyPortMin")) - // .max(65535, t("createInternalResourceDialogProxyPortMax")), - // destinationPort: z.int() - // .positive() - // .min(1, t("createInternalResourceDialogDestinationPortMin")) - // .max(65535, t("createInternalResourceDialogDestinationPortMax")) - // .nullish(), + // mode: z.enum(["host", "cidr", "port"]), + mode: z.enum(["host", "cidr"]), + // protocol: z.enum(["tcp", "udp"]).nullish(), + // proxyPort: z.int().positive().min(1, t("createInternalResourceDialogProxyPortMin")).max(65535, t("createInternalResourceDialogProxyPortMax")).nullish(), + destination: z.string().min(1), + // destinationPort: z.int().positive().min(1, t("createInternalResourceDialogDestinationPortMin")).max(65535, t("createInternalResourceDialogDestinationPortMax")).nullish(), alias: z.string().nullish(), - tcpPortRangeString: portRangeStringSchema, - udpPortRangeString: portRangeStringSchema, + tcpPortRangeString: createPortRangeStringSchema(t), + udpPortRangeString: createPortRangeStringSchema(t), disableIcmp: z.boolean().optional(), roles: z .array( @@ -453,8 +448,8 @@ export default function CreateInternalResourceDialog({ variant: "default" }); - onSuccess?.(); setOpen(false); + onSuccess?.(); } catch (error) { console.error("Error creating internal resource:", error); toast({ @@ -498,7 +493,7 @@ export default function CreateInternalResourceDialog({ return ( - + {t("createInternalResourceDialogCreateClientResource")} @@ -516,179 +511,180 @@ export default function CreateInternalResourceDialog({ className="space-y-6" id="create-internal-resource-form" > - {/* Resource Properties Form */} -
-

- {t( - "createInternalResourceDialogResourceProperties" + {/* Name and Site - Side by Side */} +
+ ( + + + {t( + "createInternalResourceDialogName" + )} + + + + + + )} -

-
- ( - - - {t( - "createInternalResourceDialogName" - )} - - - - - - - )} - /> + /> - ( - - - {t( - "site" - )} - - - - - - - - - - - - - {t( - "noSitesFound" - )} - - - {availableSites.map( - ( - site - ) => ( - { - field.onChange( - site.siteId - ); - }} - > - - { - site.name - } - - ) - )} - - - - - - - - )} - /> - - ( - - - {t( - "createInternalResourceDialogMode" - )} - - - - - )} - /> - {/* - {mode === "port" && ( - <> -
+ /> + + + {t( + "noSitesFound" + )} + + + {availableSites.map( + (site) => ( + { + field.onChange( + site.siteId + ); + }} + > + + { + site.name + } + + ) + )} + + + + + + + + )} + /> +
+ + {/* Tabs for Network Settings and Access Control */} + + {/* Network Settings Tab */} +
+
+
+ +
+ {t( + "editInternalResourceDialogDestinationDescription" + )} +
+
+ +
+ {/* Mode - Smaller select */} +
( - {t("createInternalResourceDialogProtocol")} + {t( + "createInternalResourceDialogMode" + )} @@ -708,22 +708,29 @@ export default function CreateInternalResourceDialog({ )} /> +
+ {/* Destination - Larger input */} +
( - {t("createInternalResourceDialogSitePort")} + + {t( + "createInternalResourceDialogDestination" + )} + - field.onChange( - e.target.value === "" ? undefined : parseInt(e.target.value) - ) - } + {...field} /> @@ -731,418 +738,396 @@ export default function CreateInternalResourceDialog({ )} />
- - )} */} -
-
- {/* Target Configuration Form */} -
-

- {t( - "createInternalResourceDialogTargetConfiguration" - )} -

-
- ( - - - {t( - "createInternalResourceDialogDestination" - )} - - - - - - {mode === "host" && - t( - "createInternalResourceDialogDestinationHostDescription" + {/* Alias - Equally sized input (if allowed) */} + {mode !== "cidr" && ( +
+ ( + + + {t( + "createInternalResourceDialogAlias" + )} + + + + + + )} - {mode === "cidr" && - t( - "createInternalResourceDialogDestinationCidrDescription" - )} - {/* {mode === "port" && t("createInternalResourceDialogDestinationIPDescription")} */} - - - - )} - /> - - {/* {mode === "port" && ( - ( - - - {t("targetPort")} - - - - field.onChange( - e.target.value === "" ? undefined : parseInt(e.target.value) - ) - } - /> - - - {t("createInternalResourceDialogDestinationPortDescription")} - - - + /> +
)} - /> - )} */} -
-
+
+
- {/* Alias */} - {mode !== "cidr" && ( -
- ( - - + {/* Ports and Restrictions */} +
+ {/* TCP Ports */} +
+ +
+ {t( + "editInternalResourceDialogPortRestrictionsDescription" + )} +
+
+
+
+ {t( - "createInternalResourceDialogAlias" + "editInternalResourceDialogTcp" )} - - - - +
+
+ ( + +
+ {/**/} + + {tcpPortMode === + "custom" ? ( + + + setTcpCustomPorts( + e + .target + .value + ) + } + /> + + ) : ( + + )} +
+ +
+ )} + /> +
+
+ + {/* UDP Ports */} +
+
+ {t( - "createInternalResourceDialogAliasDescription" + "editInternalResourceDialogUdp" )} - - - - )} - /> -
- )} - - {/* Port Restrictions Section */} -
-

- {t("portRestrictions")} -

-
- {/* TCP Ports */} - ( - -
- - TCP - - {/**/} - - {tcpPortMode === "custom" ? ( - - - setTcpCustomPorts(e.target.value) - } - className="flex-1" - /> - - ) : ( - - )} -
- -
- )} - /> - - {/* UDP Ports */} - ( - -
- - UDP - - {/**/} - - {udpPortMode === "custom" ? ( - - - setUdpCustomPorts(e.target.value) - } - className="flex-1" - /> - - ) : ( - - )} -
- -
- )} - /> - - {/* ICMP Toggle */} - ( - -
- - ICMP - - - field.onChange(!checked)} - /> - - - {field.value ? t("blocked") : t("allowed")} - -
- -
- )} - /> -
-
- - {/* Access Control Section */} -
-

- {t("resourceUsersRoles")} -

-
- ( - - - {t("roles")} - - { - form.setValue( - "roles", - newRoles as [ - Tag, - ...Tag[] - ] - ); - }} - enableAutocomplete={ - true - } - autocompleteOptions={ - allRoles - } - allowDuplicates={false} - restrictTagsToAutocompleteOptions={ - true - } - sortTags={true} - /> - - - +
+
+ ( + +
+ {/**/} + + {udpPortMode === + "custom" ? ( + + + setUdpCustomPorts( + e + .target + .value + ) + } + /> + + ) : ( + + )} +
+ +
+ )} + /> +
+
+ + {/* ICMP Toggle */} +
+
+ {t( - "resourceRoleDescription" + "editInternalResourceDialogIcmp" )} - - - )} - /> - ( - - - {t("users")} - - { - form.setValue( - "users", - newUsers as [ - Tag, - ...Tag[] - ] - ); - }} - enableAutocomplete={ - true - } - autocompleteOptions={ - allUsers - } - allowDuplicates={false} - restrictTagsToAutocompleteOptions={ - true - } - sortTags={true} - /> - - - - )} - /> - {hasMachineClients && ( +
+
+ ( + +
+ + + field.onChange( + !checked + ) + } + /> + + + {field.value + ? t( + "blocked" + ) + : t( + "allowed" + )} + +
+ +
+ )} + /> +
+
+
+
+ + {/* Access Control Tab */} +
+
+ +
+ {t( + "editInternalResourceDialogAccessControlDescription" + )} +
+
+
+ {/* Roles */} ( - {t("machineClients")} + {t("roles")} { form.setValue( - "clients", - newClients as [ + "roles", + newRoles as [ Tag, ...Tag[] ] @@ -1152,7 +1137,70 @@ export default function CreateInternalResourceDialog({ true } autocompleteOptions={ - allClients + allRoles + } + allowDuplicates={ + false + } + restrictTagsToAutocompleteOptions={ + true + } + sortTags={true} + /> + + + + {t( + "resourceRoleDescription" + )} + + + )} + /> + + {/* Users */} + ( + + + {t("users")} + + + { + form.setValue( + "users", + newUsers as [ + Tag, + ...Tag[] + ] + ); + }} + enableAutocomplete={ + true + } + autocompleteOptions={ + allUsers } allowDuplicates={ false @@ -1167,9 +1215,76 @@ export default function CreateInternalResourceDialog({ )} /> - )} + + {/* Clients (Machines) */} + {hasMachineClients && ( + ( + + + {t( + "machineClients" + )} + + + { + form.setValue( + "clients", + newClients as [ + Tag, + ...Tag[] + ] + ); + }} + enableAutocomplete={ + true + } + autocompleteOptions={ + allClients + } + allowDuplicates={ + false + } + restrictTagsToAutocompleteOptions={ + true + } + sortTags={ + true + } + /> + + + + )} + /> + )} +
-
+ diff --git a/src/components/EditInternalResourceDialog.tsx b/src/components/EditInternalResourceDialog.tsx index 5d5745c7..88d98aa5 100644 --- a/src/components/EditInternalResourceDialog.tsx +++ b/src/components/EditInternalResourceDialog.tsx @@ -56,7 +56,14 @@ import { } from "@app/components/ui/popover"; import { cn } from "@app/lib/cn"; import { ListSitesResponse } from "@server/routers/site"; -import { Check, ChevronsUpDown } from "lucide-react"; +import { Check, ChevronsUpDown, ChevronDown } from "lucide-react"; +import { + Collapsible, + CollapsibleContent, + CollapsibleTrigger +} from "@app/components/ui/collapsible"; +import { HorizontalTabs, TabItem } from "@app/components/HorizontalTabs"; +import { Separator } from "@app/components/ui/separator"; // import { InfoPopup } from "@app/components/ui/info-popup"; // Helper to validate port range string format @@ -85,7 +92,12 @@ const isValidPortRangeString = (val: string | undefined | null): boolean => { return false; } - if (startPort < 1 || startPort > 65535 || endPort < 1 || endPort > 65535) { + if ( + startPort < 1 || + startPort > 65535 || + endPort < 1 || + endPort > 65535 + ) { return false; } @@ -107,17 +119,18 @@ const isValidPortRangeString = (val: string | undefined | null): boolean => { }; // Port range string schema for client-side validation -const portRangeStringSchema = z - .string() - .optional() - .nullable() - .refine( - (val) => isValidPortRangeString(val), - { - message: - 'Port range must be "*" for all ports, or a comma-separated list of ports and ranges (e.g., "80,443,8000-9000"). Ports must be between 1 and 65535.' - } - ); +// Note: This schema is defined outside the component, so we'll use a function to get the message +const getPortRangeValidationMessage = (t: (key: string) => string) => + t("editInternalResourceDialogPortRangeValidationError"); + +const createPortRangeStringSchema = (t: (key: string) => string) => + z + .string() + .optional() + .nullable() + .refine((val) => isValidPortRangeString(val), { + message: getPortRangeValidationMessage(t) + }); // Helper to determine the port mode from a port range string type PortMode = "all" | "blocked" | "custom"; @@ -128,7 +141,10 @@ const getPortModeFromString = (val: string | undefined | null): PortMode => { }; // Helper to get the port string for API from mode and custom value -const getPortStringFromMode = (mode: PortMode, customValue: string): string | undefined => { +const getPortStringFromMode = ( + mode: PortMode, + customValue: string +): string | undefined => { if (mode === "all") return "*"; if (mode === "blocked") return ""; return customValue; @@ -188,8 +204,8 @@ export default function EditInternalResourceDialog({ destination: z.string().min(1), // destinationPort: z.int().positive().min(1, t("editInternalResourceDialogDestinationPortMin")).max(65535, t("editInternalResourceDialogDestinationPortMax")).nullish(), alias: z.string().nullish(), - tcpPortRangeString: portRangeStringSchema, - udpPortRangeString: portRangeStringSchema, + tcpPortRangeString: createPortRangeStringSchema(t), + udpPortRangeString: createPortRangeStringSchema(t), disableIcmp: z.boolean().optional(), roles: z .array( @@ -352,6 +368,9 @@ export default function EditInternalResourceDialog({ number | null >(null); + // Collapsible state for ports and restrictions + const [isPortsExpanded, setIsPortsExpanded] = useState(false); + // Port restriction UI state const [tcpPortMode, setTcpPortMode] = useState( getPortModeFromString(resource.tcpPortRangeString) @@ -446,30 +465,27 @@ export default function EditInternalResourceDialog({ } // Update the site resource - await api.post( - `/site-resource/${resource.id}`, - { - name: data.name, - siteId: data.siteId, - mode: data.mode, - // protocol: data.mode === "port" ? data.protocol : null, - // proxyPort: data.mode === "port" ? data.proxyPort : null, - // destinationPort: data.mode === "port" ? data.destinationPort : null, - destination: data.destination, - alias: - data.alias && - typeof data.alias === "string" && - data.alias.trim() - ? data.alias - : null, - tcpPortRangeString: data.tcpPortRangeString, - udpPortRangeString: data.udpPortRangeString, - disableIcmp: data.disableIcmp ?? false, - roleIds: (data.roles || []).map((r) => parseInt(r.id)), - userIds: (data.users || []).map((u) => u.id), - clientIds: (data.clients || []).map((c) => parseInt(c.id)) - } - ); + await api.post(`/site-resource/${resource.id}`, { + name: data.name, + siteId: data.siteId, + mode: data.mode, + // protocol: data.mode === "port" ? data.protocol : null, + // proxyPort: data.mode === "port" ? data.proxyPort : null, + // destinationPort: data.mode === "port" ? data.destinationPort : null, + destination: data.destination, + alias: + data.alias && + typeof data.alias === "string" && + data.alias.trim() + ? data.alias + : null, + tcpPortRangeString: data.tcpPortRangeString, + udpPortRangeString: data.udpPortRangeString, + disableIcmp: data.disableIcmp ?? false, + roleIds: (data.roles || []).map((r) => parseInt(r.id)), + userIds: (data.users || []).map((u) => u.id), + clientIds: (data.clients || []).map((c) => parseInt(c.id)) + }); // Update roles, users, and clients // await Promise.all([ @@ -502,8 +518,8 @@ export default function EditInternalResourceDialog({ variant: "default" }); - onSuccess?.(); setOpen(false); + onSuccess?.(); } catch (error) { console.error("Error updating internal resource:", error); toast({ @@ -543,18 +559,26 @@ export default function EditInternalResourceDialog({ clients: [] }); // Reset port mode state - setTcpPortMode(getPortModeFromString(resource.tcpPortRangeString)); - setUdpPortMode(getPortModeFromString(resource.udpPortRangeString)); + setTcpPortMode( + getPortModeFromString(resource.tcpPortRangeString) + ); + setUdpPortMode( + getPortModeFromString(resource.udpPortRangeString) + ); setTcpCustomPorts( - resource.tcpPortRangeString && resource.tcpPortRangeString !== "*" + resource.tcpPortRangeString && + resource.tcpPortRangeString !== "*" ? resource.tcpPortRangeString : "" ); setUdpCustomPorts( - resource.udpPortRangeString && resource.udpPortRangeString !== "*" + resource.udpPortRangeString && + resource.udpPortRangeString !== "*" ? resource.udpPortRangeString : "" ); + // Reset visibility states + setIsPortsExpanded(false); previousResourceId.current = resource.id; } @@ -602,25 +626,33 @@ export default function EditInternalResourceDialog({ clients: [] }); // Reset port mode state - setTcpPortMode(getPortModeFromString(resource.tcpPortRangeString)); - setUdpPortMode(getPortModeFromString(resource.udpPortRangeString)); + setTcpPortMode( + getPortModeFromString(resource.tcpPortRangeString) + ); + setUdpPortMode( + getPortModeFromString(resource.udpPortRangeString) + ); setTcpCustomPorts( - resource.tcpPortRangeString && resource.tcpPortRangeString !== "*" + resource.tcpPortRangeString && + resource.tcpPortRangeString !== "*" ? resource.tcpPortRangeString : "" ); setUdpCustomPorts( - resource.udpPortRangeString && resource.udpPortRangeString !== "*" + resource.udpPortRangeString && + resource.udpPortRangeString !== "*" ? resource.udpPortRangeString : "" ); + // Reset visibility states + setIsPortsExpanded(false); // Reset previous resource ID to ensure clean state on next open previousResourceId.current = null; } setOpen(open); }} > - + {t("editInternalResourceDialogEditClientResource")} @@ -639,627 +671,628 @@ export default function EditInternalResourceDialog({ className="space-y-6" id="edit-internal-resource-form" > - {/* Resource Properties Form */} -
-

- {t( - "editInternalResourceDialogResourceProperties" + {/* Name and Site - Side by Side */} +
+ ( + + + {t( + "editInternalResourceDialogName" + )} + + + + + + )} -

-
- ( - - - {t( - "editInternalResourceDialogName" - )} - - - - - - - )} - /> + /> - ( - - - {t( - "site" - )} - - - - - - - - - - - - - {t( - "noSitesFound" - )} - - - {availableSites.map( - ( - site - ) => ( - { - field.onChange( - site.siteId - ); - }} - > - - { - site.name - } - - ) - )} - - - - - - - - )} - /> - - ( - - - {t( - "editInternalResourceDialogMode" - )} - - - - - )} - /> - - {/* {mode === "port" && ( -
- ( - - {t("editInternalResourceDialogProtocol")} - - - - )} - /> - - ( - - {t("editInternalResourceDialogSitePort")} - - field.onChange(e.target.value === "" ? undefined : parseInt(e.target.value) || 0)} - /> - - - - )} - /> -
- )} */} -
-
- - {/* Target Configuration Form */} -
-

- {t( - "editInternalResourceDialogTargetConfiguration" + {field.value + ? availableSites.find( + (site) => + site.siteId === + field.value + )?.name + : t( + "selectSite" + )} + + + + + + + + + + {t( + "noSitesFound" + )} + + + {availableSites.map( + (site) => ( + { + field.onChange( + site.siteId + ); + }} + > + + { + site.name + } + + ) + )} + + + + + + + )} -

-
- ( - - - {t( - "editInternalResourceDialogDestination" - )} - - - - - - {mode === "host" && - t( - "editInternalResourceDialogDestinationHostDescription" - )} - {mode === "cidr" && - t( - "editInternalResourceDialogDestinationCidrDescription" - )} - {/* {mode === "port" && t("editInternalResourceDialogDestinationIPDescription")} */} - - - - )} - /> - - {/* {mode === "port" && ( - ( - - {t("targetPort")} - - field.onChange(e.target.value === "" ? undefined : parseInt(e.target.value) || 0)} - /> - - - - )} - /> - )} */} -
+ />
- {/* Alias */} - {mode !== "cidr" && ( -
- ( - - - {t( - "editInternalResourceDialogAlias" + {/* Tabs for Network Settings and Access Control */} + + {/* Network Settings Tab */} +
+
+
+ +
+ {t( + "editInternalResourceDialogDestinationDescription" + )} +
+
+ +
+ {/* Mode - Smaller select */} +
+ ( + + + {t( + "editInternalResourceDialogMode" + )} + + + + )} - - - +
+ + {/* Destination - Larger input */} +
+ ( + + + {t( + "editInternalResourceDialogDestination" + )} + + + + + + + )} + /> +
+ + {/* Alias - Equally sized input (if allowed) */} + {mode !== "cidr" && ( +
+ ( + + + {t( + "editInternalResourceDialogAlias" + )} + + + + + + + )} /> - - - {t( - "editInternalResourceDialogAliasDescription" - )} - - - - )} - /> -
- )} - - {/* Port Restrictions Section */} -
-

- {t("portRestrictions")} -

-
- {/* TCP Ports */} - ( - -
- - TCP - - {/**/} - - {tcpPortMode === "custom" ? ( - - - setTcpCustomPorts(e.target.value) - } - className="flex-1" - /> - - ) : ( - - )}
- -
- )} - /> - - {/* UDP Ports */} - ( - -
- - UDP - - {/**/} - - {udpPortMode === "custom" ? ( - - - setUdpCustomPorts(e.target.value) - } - className="flex-1" - /> - - ) : ( - - )} -
- -
- )} - /> - - {/* ICMP Toggle */} - ( - -
- - ICMP - - - field.onChange(!checked)} - /> - - - {field.value ? t("blocked") : t("allowed")} - -
- -
- )} - /> -
-
- - {/* Access Control Section */} -
-

- {t("resourceUsersRoles")} -

- {loadingRolesUsers ? ( -
- {t("loading")} + )} +
- ) : ( + + {/* Ports and Restrictions */}
- ( - - - {t("roles")} - - - { - form.setValue( - "roles", - newRoles as [ - Tag, - ...Tag[] - ] - ); - }} - enableAutocomplete={ - true - } - autocompleteOptions={ - allRoles - } - allowDuplicates={ - false - } - restrictTagsToAutocompleteOptions={ - true - } - sortTags={true} - /> - - - - {t( - "resourceRoleDescription" - )} - - + {/* TCP Ports */} +
+ +
+ {t( + "editInternalResourceDialogPortRestrictionsDescription" + )} +
+
+
- ( - - - {t("users")} - - - { - form.setValue( - "users", - newUsers as [ - Tag, - ...Tag[] - ] - ); - }} - enableAutocomplete={ - true - } - autocompleteOptions={ - allUsers - } - allowDuplicates={ - false - } - restrictTagsToAutocompleteOptions={ - true - } - sortTags={true} - /> - - - + > +
+ + {t( + "editInternalResourceDialogTcp" + )} + +
+
+ ( + +
+ {/**/} + + {tcpPortMode === + "custom" ? ( + + + setTcpCustomPorts( + e + .target + .value + ) + } + /> + + ) : ( + + )} +
+ +
+ )} + /> +
+
+ + {/* UDP Ports */} +
- {hasMachineClients && ( + > +
+ + {t( + "editInternalResourceDialogUdp" + )} + +
+
+ ( + +
+ {/**/} + + {udpPortMode === + "custom" ? ( + + + setUdpCustomPorts( + e + .target + .value + ) + } + /> + + ) : ( + + )} +
+ +
+ )} + /> +
+
+ + {/* ICMP Toggle */} +
+
+ + {t( + "editInternalResourceDialogIcmp" + )} + +
+
+ ( + +
+ + + field.onChange( + !checked + ) + } + /> + + + {field.value + ? t( + "blocked" + ) + : t( + "allowed" + )} + +
+ +
+ )} + /> +
+
+
+
+ + {/* Access Control Tab */} +
+
+ +
+ {t( + "editInternalResourceDialogAccessControlDescription" + )} +
+
+ {loadingRolesUsers ? ( +
+ {t("loading")} +
+ ) : ( +
+ {/* Roles */} ( - {t( - "machineClients" - )} + {t("roles")} { form.setValue( - "clients", - newClients as [ + "roles", + newRoles as [ Tag, ...Tag[] ] @@ -1269,7 +1302,7 @@ export default function EditInternalResourceDialog({ true } autocompleteOptions={ - machineClients + allRoles } allowDuplicates={ false @@ -1284,10 +1317,135 @@ export default function EditInternalResourceDialog({ )} /> - )} -
- )} -
+ + {/* Users */} + ( + + + {t("users")} + + + { + form.setValue( + "users", + newUsers as [ + Tag, + ...Tag[] + ] + ); + }} + enableAutocomplete={ + true + } + autocompleteOptions={ + allUsers + } + allowDuplicates={ + false + } + restrictTagsToAutocompleteOptions={ + true + } + sortTags={true} + /> + + + + )} + /> + + {/* Clients (Machines) */} + {hasMachineClients && ( + ( + + + {t( + "machineClients" + )} + + + { + form.setValue( + "clients", + newClients as [ + Tag, + ...Tag[] + ] + ); + }} + enableAutocomplete={ + true + } + autocompleteOptions={ + machineClients + } + allowDuplicates={ + false + } + restrictTagsToAutocompleteOptions={ + true + } + sortTags={ + true + } + /> + + + + )} + /> + )} +
+ )} +
+
diff --git a/src/components/HorizontalTabs.tsx b/src/components/HorizontalTabs.tsx index 72093e0d..717a3c12 100644 --- a/src/components/HorizontalTabs.tsx +++ b/src/components/HorizontalTabs.tsx @@ -1,6 +1,6 @@ "use client"; -import React from "react"; +import React, { useState } from "react"; import Link from "next/link"; import { useParams, usePathname } from "next/navigation"; import { cn } from "@app/lib/cn"; @@ -20,17 +20,22 @@ interface HorizontalTabsProps { children: React.ReactNode; items: TabItem[]; disabled?: boolean; + clientSide?: boolean; + defaultTab?: number; } export function HorizontalTabs({ children, items, - disabled = false + disabled = false, + clientSide = false, + defaultTab = 0 }: HorizontalTabsProps) { const pathname = usePathname(); const params = useParams(); const { licenseStatus, isUnlocked } = useLicenseStatusContext(); const t = useTranslations(); + const [activeClientTab, setActiveClientTab] = useState(defaultTab); function hydrateHref(href: string) { return href @@ -43,6 +48,73 @@ export function HorizontalTabs({ .replace("{remoteExitNodeId}", params.remoteExitNodeId as string); } + // Client-side mode: render tabs as buttons with state management + if (clientSide) { + const childrenArray = React.Children.toArray(children); + const activeChild = childrenArray[activeClientTab] || null; + + return ( +
+
+
+
+ {items.map((item, index) => { + const isActive = activeClientTab === index; + const isProfessional = + item.showProfessional && !isUnlocked(); + const isDisabled = + disabled || + (isProfessional && !isUnlocked()); + + return ( + + ); + })} +
+
+
+
{activeChild}
+
+ ); + } + + // Server-side mode: original behavior with routing return (
diff --git a/src/components/ui/button.tsx b/src/components/ui/button.tsx index c3037250..f530ace1 100644 --- a/src/components/ui/button.tsx +++ b/src/components/ui/button.tsx @@ -15,7 +15,7 @@ const buttonVariants = cva( destructive: "bg-destructive text-white dark:text-destructive-foreground hover:bg-destructive/90 ", outline: - "border border-input bg-card hover:bg-accent hover:text-accent-foreground ", + "border border-input bg-transparent hover:bg-accent hover:text-accent-foreground ", outlinePrimary: "border border-primary bg-card hover:bg-primary/10 text-primary ", secondary: diff --git a/src/lib/queries.ts b/src/lib/queries.ts index 0dc44147..e90f6eea 100644 --- a/src/lib/queries.ts +++ b/src/lib/queries.ts @@ -228,7 +228,7 @@ export const resourceQueries = { queryFn: async ({ signal, meta }) => { const res = await meta!.api.get< AxiosResponse - >(`/resource/${resourceId}/users`, { signal }); + >(`/site-resource/${resourceId}/users`, { signal }); return res.data.data.users; } }), @@ -238,7 +238,7 @@ export const resourceQueries = { queryFn: async ({ signal, meta }) => { const res = await meta!.api.get< AxiosResponse - >(`/resource/${resourceId}/roles`, { signal }); + >(`/site-resource/${resourceId}/roles`, { signal }); return res.data.data.roles; } @@ -249,7 +249,7 @@ export const resourceQueries = { queryFn: async ({ signal, meta }) => { const res = await meta!.api.get< AxiosResponse - >(`/resource/${resourceId}/clients`, { signal }); + >(`/site-resource/${resourceId}/clients`, { signal }); return res.data.data.clients; } From 3e01bfef7d7e489dd4903f3d6692504f3ec4233e Mon Sep 17 00:00:00 2001 From: Owen Date: Thu, 18 Dec 2025 17:07:48 -0500 Subject: [PATCH 06/12] Move primaryDb into driver --- server/db/pg/driver.ts | 1 + server/db/sqlite/driver.ts | 2 +- server/routers/auditLogs/queryRequestAnalytics.ts | 7 +------ server/routers/auditLogs/queryRequestAuditLog.ts | 7 +------ 4 files changed, 4 insertions(+), 13 deletions(-) diff --git a/server/db/pg/driver.ts b/server/db/pg/driver.ts index 2ee34da6..5b357d06 100644 --- a/server/db/pg/driver.ts +++ b/server/db/pg/driver.ts @@ -81,6 +81,7 @@ function createDb() { export const db = createDb(); export default db; +export const primaryDb = db.$primary; export type Transaction = Parameters< Parameters<(typeof db)["transaction"]>[0] >[0]; diff --git a/server/db/sqlite/driver.ts b/server/db/sqlite/driver.ts index 5a4aa542..9cbc8d7b 100644 --- a/server/db/sqlite/driver.ts +++ b/server/db/sqlite/driver.ts @@ -20,7 +20,7 @@ function createDb() { export const db = createDb(); export default db; -export const driver: "pg" | "sqlite" = "sqlite"; +export const primaryDb = db; export type Transaction = Parameters< Parameters<(typeof db)["transaction"]>[0] >[0]; diff --git a/server/routers/auditLogs/queryRequestAnalytics.ts b/server/routers/auditLogs/queryRequestAnalytics.ts index f4b4444c..cd1218ce 100644 --- a/server/routers/auditLogs/queryRequestAnalytics.ts +++ b/server/routers/auditLogs/queryRequestAnalytics.ts @@ -1,4 +1,4 @@ -import { db, requestAuditLog, driver } from "@server/db"; +import { db, requestAuditLog, driver, primaryDb } from "@server/db"; import { registry } from "@server/openApi"; import { NextFunction } from "express"; import { Request, Response } from "express"; @@ -12,11 +12,6 @@ import response from "@server/lib/response"; import logger from "@server/logger"; import { getSevenDaysAgo } from "@app/lib/getSevenDaysAgo"; -let primaryDb = db; -if (driver == "pg") { - primaryDb = db.$primary as typeof db; // select the primary instance in a replicated setup -} - const queryAccessAuditLogsQuery = z.object({ // iso string just validate its a parseable date timeStart: z diff --git a/server/routers/auditLogs/queryRequestAuditLog.ts b/server/routers/auditLogs/queryRequestAuditLog.ts index 73f9fc43..602b4475 100644 --- a/server/routers/auditLogs/queryRequestAuditLog.ts +++ b/server/routers/auditLogs/queryRequestAuditLog.ts @@ -1,4 +1,4 @@ -import { db, driver, requestAuditLog, resources } from "@server/db"; +import { db, primaryDb, requestAuditLog, resources } from "@server/db"; import { registry } from "@server/openApi"; import { NextFunction } from "express"; import { Request, Response } from "express"; @@ -13,11 +13,6 @@ import response from "@server/lib/response"; import logger from "@server/logger"; import { getSevenDaysAgo } from "@app/lib/getSevenDaysAgo"; -let primaryDb = db; -if (driver == "pg") { - primaryDb = db.$primary as typeof db; // select the primary instance in a replicated setup -} - export const queryAccessAuditLogsQuery = z.object({ // iso string just validate its a parseable date timeStart: z From 6e7ba1dc52309d72c8f4a6b7d4ff5708256b7633 Mon Sep 17 00:00:00 2001 From: Owen Date: Thu, 18 Dec 2025 17:08:38 -0500 Subject: [PATCH 07/12] Prevent overlapping resources with org subnets --- .../siteResource/createSiteResource.ts | 39 +++++++++++++++++-- .../siteResource/updateSiteResource.ts | 38 +++++++++++++++++- 2 files changed, 72 insertions(+), 5 deletions(-) diff --git a/server/routers/siteResource/createSiteResource.ts b/server/routers/siteResource/createSiteResource.ts index c50a800b..d2196e87 100644 --- a/server/routers/siteResource/createSiteResource.ts +++ b/server/routers/siteResource/createSiteResource.ts @@ -2,6 +2,7 @@ import { clientSiteResources, db, newts, + orgs, roles, roleSiteResources, SiteResource, @@ -10,7 +11,7 @@ import { userSiteResources } from "@server/db"; import { getUniqueSiteResourceName } from "@server/db/names"; -import { getNextAvailableAliasAddress, portRangeStringSchema } from "@server/lib/ip"; +import { getNextAvailableAliasAddress, isIpInCidr, portRangeStringSchema } from "@server/lib/ip"; import { rebuildClientAssociationsFromSiteResource } from "@server/lib/rebuildClientAssociations"; import response from "@server/lib/response"; import logger from "@server/logger"; @@ -84,8 +85,7 @@ const createSiteResourceSchema = z if (data.mode === "cidr") { // Check if it's a valid CIDR (v4 or v6) const isValidCIDR = z - // .union([z.cidrv4(), z.cidrv6()]) - .union([z.cidrv4()]) // for now lets just do ipv4 until we verify ipv6 works everywhere + .union([z.cidrv4(), z.cidrv6()]) .safeParse(data.destination).success; return isValidCIDR; } @@ -175,6 +175,39 @@ export async function createSiteResource( return next(createHttpError(HttpCode.NOT_FOUND, "Site not found")); } + const [org] = await db + .select() + .from(orgs) + .where(eq(orgs.orgId, orgId)) + .limit(1); + + if (!org) { + return next(createHttpError(HttpCode.NOT_FOUND, "Organization not found")); + } + + if (!org.subnet || !org.utilitySubnet) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + `Organization with ID ${orgId} has no subnet or utilitySubnet defined defined` + ) + ); + } + + // Only check if destination is an IP address + const isIp = z.union([z.ipv4(), z.ipv6()]).safeParse(destination).success; + if ( + isIp && + (isIpInCidr(destination, org.subnet) || isIpInCidr(destination, org.utilitySubnet)) + ) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + "IP can not be in the CIDR range of the organization's subnet or utility subnet" + ) + ); + } + // // check if resource with same protocol and proxy port already exists (only for port mode) // if (mode === "port" && protocol && proxyPort) { // const [existingResource] = await db diff --git a/server/routers/siteResource/updateSiteResource.ts b/server/routers/siteResource/updateSiteResource.ts index 10708443..c0383616 100644 --- a/server/routers/siteResource/updateSiteResource.ts +++ b/server/routers/siteResource/updateSiteResource.ts @@ -5,6 +5,7 @@ import { clientSiteResourcesAssociationsCache, db, newts, + orgs, roles, roleSiteResources, sites, @@ -24,6 +25,7 @@ import { generateAliasConfig, generateRemoteSubnets, generateSubnetProxyTargets, + isIpInCidr, portRangeStringSchema } from "@server/lib/ip"; import { @@ -96,8 +98,7 @@ const updateSiteResourceSchema = z if (data.mode === "cidr" && data.destination) { // Check if it's a valid CIDR (v4 or v6) const isValidCIDR = z - // .union([z.cidrv4(), z.cidrv6()]) - .union([z.cidrv4()]) // for now lets just do ipv4 until we verify ipv6 works everywhere + .union([z.cidrv4(), z.cidrv6()]) .safeParse(data.destination).success; return isValidCIDR; } @@ -196,6 +197,39 @@ export async function updateSiteResource( ); } + const [org] = await db + .select() + .from(orgs) + .where(eq(orgs.orgId, existingSiteResource.orgId)) + .limit(1); + + if (!org) { + return next(createHttpError(HttpCode.NOT_FOUND, "Organization not found")); + } + + if (!org.subnet || !org.utilitySubnet) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + `Organization with ID ${existingSiteResource.orgId} has no subnet or utilitySubnet defined defined` + ) + ); + } + + // Only check if destination is an IP address + const isIp = z.union([z.ipv4(), z.ipv6()]).safeParse(destination).success; + if ( + isIp && + (isIpInCidr(destination!, org.subnet) || isIpInCidr(destination!, org.utilitySubnet)) + ) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + "IP can not be in the CIDR range of the organization's subnet or utility subnet" + ) + ); + } + let existingSite = site; let siteChanged = false; if (existingSiteResource.siteId !== siteId) { From fc924f707c6b9251fed3c51778511b695b038c79 Mon Sep 17 00:00:00 2001 From: miloschwartz Date: Thu, 18 Dec 2025 17:47:54 -0500 Subject: [PATCH 08/12] add banners --- messages/en-US.json | 14 +++ .../[orgId]/settings/clients/machine/page.tsx | 3 + .../settings/resources/client/page.tsx | 3 + .../[orgId]/settings/resources/proxy/page.tsx | 3 + src/app/[orgId]/settings/sites/page.tsx | 3 + src/components/ClientDownloadBanner.tsx | 69 +++++++++++++ src/components/DismissableBanner.tsx | 98 +++++++++++++++++++ src/components/MachineClientsBanner.tsx | 60 ++++++++++++ src/components/PrivateResourcesBanner.tsx | 54 ++++++++++ src/components/ProxyResourcesBanner.tsx | 23 +++++ src/components/SitesBanner.tsx | 40 ++++++++ src/components/UserDevicesTable.tsx | 3 + 12 files changed, 373 insertions(+) create mode 100644 src/components/ClientDownloadBanner.tsx create mode 100644 src/components/DismissableBanner.tsx create mode 100644 src/components/MachineClientsBanner.tsx create mode 100644 src/components/PrivateResourcesBanner.tsx create mode 100644 src/components/ProxyResourcesBanner.tsx create mode 100644 src/components/SitesBanner.tsx diff --git a/messages/en-US.json b/messages/en-US.json index b71eb202..e0728c94 100644 --- a/messages/en-US.json +++ b/messages/en-US.json @@ -51,6 +51,9 @@ "siteQuestionRemove": "Are you sure you want to remove the site from the organization?", "siteManageSites": "Manage Sites", "siteDescription": "Create and manage sites to enable connectivity to private networks", + "sitesBannerTitle": "Connect Any Network", + "sitesBannerDescription": "A site is a connection to a remote network that allows Pangolin to provide access to resources, whether public or private, to users anywhere. Install the site network connector (Newt) anywhere you can run a binary or container to establish the connection.", + "sitesBannerButtonText": "Install Site", "siteCreate": "Create Site", "siteCreateDescription2": "Follow the steps below to create and connect a new site", "siteCreateDescription": "Create a new site to start connecting resources", @@ -147,8 +150,12 @@ "shareErrorSelectResource": "Please select a resource", "proxyResourceTitle": "Manage Public Resources", "proxyResourceDescription": "Create and manage resources that are publicly accessible through a web browser", + "proxyResourcesBannerTitle": "Web-based Public Access", + "proxyResourcesBannerDescription": "Public resources are HTTPS or TCP/UDP proxies accessible to anyone on the internet through a web browser. Unlike private resources, they do not require client-side software and can include identity and context-aware access policies.", "clientResourceTitle": "Manage Private Resources", "clientResourceDescription": "Create and manage resources that are only accessible through a connected client", + "privateResourcesBannerTitle": "Zero-Trust Private Access", + "privateResourcesBannerDescription": "Private resources use zero-trust security, ensuring users and machines can only access resources you explicitly grant. Connect user devices or machine clients to access these resources over a secure virtual private network.", "resourcesSearch": "Search resources...", "resourceAdd": "Add Resource", "resourceErrorDelte": "Error deleting resource", @@ -1944,8 +1951,15 @@ "beta": "Beta", "manageUserDevices": "User Devices", "manageUserDevicesDescription": "View and manage devices that users use to privately connect to resources", + "downloadClientBannerTitle": "Download Pangolin Client", + "downloadClientBannerDescription": "Download the Pangolin client for your system to connect to the Pangolin network and access resources privately", "manageMachineClients": "Manage Machine Clients", "manageMachineClientsDescription": "Create and manage clients that servers and systems use to privately connect to resources", + "machineClientsBannerTitle": "Servers & Automated Systems", + "machineClientsBannerDescription": "Machine clients are for servers and automated systems that are not associated with a specific user. They authenticate with an ID and secret, and can run with Pangolin CLI, Olm CLI, or Olm as a container.", + "machineClientsBannerPangolinCLI": "Pangolin CLI", + "machineClientsBannerOlmCLI": "Olm CLI", + "machineClientsBannerOlmContainer": "Olm Container", "clientsTableUserClients": "User", "clientsTableMachineClients": "Machine", "licenseTableValidUntil": "Valid Until", diff --git a/src/app/[orgId]/settings/clients/machine/page.tsx b/src/app/[orgId]/settings/clients/machine/page.tsx index e1a904ad..f2618bc2 100644 --- a/src/app/[orgId]/settings/clients/machine/page.tsx +++ b/src/app/[orgId]/settings/clients/machine/page.tsx @@ -1,6 +1,7 @@ import type { ClientRow } from "@app/components/MachineClientsTable"; import MachineClientsTable from "@app/components/MachineClientsTable"; import SettingsSectionTitle from "@app/components/SettingsSectionTitle"; +import MachineClientsBanner from "@app/components/MachineClientsBanner"; import { internal } from "@app/lib/api"; import { authCookieHeader } from "@app/lib/api/cookies"; import { ListClientsResponse } from "@server/routers/client"; @@ -71,6 +72,8 @@ export default async function ClientsPage(props: ClientsPageProps) { description={t("manageMachineClientsDescription")} /> + + + + + + + + ); diff --git a/src/components/ClientDownloadBanner.tsx b/src/components/ClientDownloadBanner.tsx new file mode 100644 index 00000000..dcd572fd --- /dev/null +++ b/src/components/ClientDownloadBanner.tsx @@ -0,0 +1,69 @@ +"use client"; + +import React from "react"; +import { Button } from "@app/components/ui/button"; +import { Download } from "lucide-react"; +import { FaApple, FaWindows, FaLinux } from "react-icons/fa"; +import { useTranslations } from "next-intl"; +import Link from "next/link"; +import DismissableBanner from "./DismissableBanner"; + +export const ClientDownloadBanner = () => { + const t = useTranslations(); + + return ( + } + description={t("downloadClientBannerDescription")} + > + + + + + + + + + + + ); +}; + +export default ClientDownloadBanner; + diff --git a/src/components/DismissableBanner.tsx b/src/components/DismissableBanner.tsx new file mode 100644 index 00000000..6f49e036 --- /dev/null +++ b/src/components/DismissableBanner.tsx @@ -0,0 +1,98 @@ +"use client"; + +import React, { useState, useEffect, type ReactNode } from "react"; +import { Card, CardContent } from "@app/components/ui/card"; +import { X } from "lucide-react"; +import { useTranslations } from "next-intl"; + +type DismissableBannerProps = { + storageKey: string; + version: number; + title: string; + titleIcon: ReactNode; + description: string; + children?: ReactNode; +}; + +export const DismissableBanner = ({ + storageKey, + version, + title, + titleIcon, + description, + children +}: DismissableBannerProps) => { + const [isDismissed, setIsDismissed] = useState(true); + const t = useTranslations(); + + useEffect(() => { + const dismissedData = localStorage.getItem(storageKey); + if (dismissedData) { + try { + const parsed = JSON.parse(dismissedData); + // If version matches, use the dismissed state + if (parsed.version === version) { + setIsDismissed(parsed.dismissed); + } else { + // Version changed, show the banner again + setIsDismissed(false); + } + } catch { + // If parsing fails, check for old format (just "true" string) + if (dismissedData === "true") { + // Old format, show banner again for new version + setIsDismissed(false); + } else { + setIsDismissed(true); + } + } + } else { + setIsDismissed(false); + } + }, [storageKey, version]); + + const handleDismiss = () => { + setIsDismissed(true); + localStorage.setItem( + storageKey, + JSON.stringify({ dismissed: true, version }) + ); + }; + + if (isDismissed) { + return null; + } + + return ( + + + +
+
+

+ {titleIcon} + {title} +

+

+ {description} +

+
+ {children && ( +
+ {children} +
+ )} +
+
+
+ ); +}; + +export default DismissableBanner; + diff --git a/src/components/MachineClientsBanner.tsx b/src/components/MachineClientsBanner.tsx new file mode 100644 index 00000000..f69fa061 --- /dev/null +++ b/src/components/MachineClientsBanner.tsx @@ -0,0 +1,60 @@ +"use client"; + +import React from "react"; +import { Button } from "@app/components/ui/button"; +import { Server, Terminal, Container } from "lucide-react"; +import { useTranslations } from "next-intl"; +import Link from "next/link"; +import DismissableBanner from "./DismissableBanner"; + +type MachineClientsBannerProps = { + orgId: string; +}; + +export const MachineClientsBanner = ({ + orgId +}: MachineClientsBannerProps) => { + const t = useTranslations(); + + return ( + } + description={t("machineClientsBannerDescription")} + > + + + + + + + + ); +}; + +export default MachineClientsBanner; + diff --git a/src/components/PrivateResourcesBanner.tsx b/src/components/PrivateResourcesBanner.tsx new file mode 100644 index 00000000..8320178d --- /dev/null +++ b/src/components/PrivateResourcesBanner.tsx @@ -0,0 +1,54 @@ +"use client"; + +import React from "react"; +import { Button } from "@app/components/ui/button"; +import { Shield, ArrowRight, Laptop, Server } from "lucide-react"; +import { useTranslations } from "next-intl"; +import Link from "next/link"; +import DismissableBanner from "./DismissableBanner"; + +type PrivateResourcesBannerProps = { + orgId: string; +}; + +export const PrivateResourcesBanner = ({ + orgId +}: PrivateResourcesBannerProps) => { + const t = useTranslations(); + + return ( + } + description={t("privateResourcesBannerDescription")} + > + + + + + + + + ); +}; + +export default PrivateResourcesBanner; + diff --git a/src/components/ProxyResourcesBanner.tsx b/src/components/ProxyResourcesBanner.tsx new file mode 100644 index 00000000..40616758 --- /dev/null +++ b/src/components/ProxyResourcesBanner.tsx @@ -0,0 +1,23 @@ +"use client"; + +import React from "react"; +import { Globe } from "lucide-react"; +import { useTranslations } from "next-intl"; +import DismissableBanner from "./DismissableBanner"; + +export const ProxyResourcesBanner = () => { + const t = useTranslations(); + + return ( + } + description={t("proxyResourcesBannerDescription")} + /> + ); +}; + +export default ProxyResourcesBanner; + diff --git a/src/components/SitesBanner.tsx b/src/components/SitesBanner.tsx new file mode 100644 index 00000000..8ba7a232 --- /dev/null +++ b/src/components/SitesBanner.tsx @@ -0,0 +1,40 @@ +"use client"; + +import React from "react"; +import { Button } from "@app/components/ui/button"; +import { Plug, ArrowRight } from "lucide-react"; +import { useTranslations } from "next-intl"; +import Link from "next/link"; +import DismissableBanner from "./DismissableBanner"; + +export const SitesBanner = () => { + const t = useTranslations(); + + return ( + } + description={t("sitesBannerDescription")} + > + + + + + ); +}; + +export default SitesBanner; + diff --git a/src/components/UserDevicesTable.tsx b/src/components/UserDevicesTable.tsx index 71321bf8..e413207a 100644 --- a/src/components/UserDevicesTable.tsx +++ b/src/components/UserDevicesTable.tsx @@ -25,6 +25,7 @@ import { useRouter } from "next/navigation"; import { useMemo, useState, useTransition } from "react"; import { Badge } from "./ui/badge"; import { InfoPopup } from "./ui/info-popup"; +import ClientDownloadBanner from "./ClientDownloadBanner"; export type ClientRow = { id: number; @@ -413,6 +414,8 @@ export default function UserDevicesTable({ userClients }: ClientTableProps) { /> )} + + Date: Thu, 18 Dec 2025 17:54:29 -0500 Subject: [PATCH 09/12] sidebar enhancements --- src/components/SidebarNav.tsx | 99 +++++++++++++++++++---------------- 1 file changed, 53 insertions(+), 46 deletions(-) diff --git a/src/components/SidebarNav.tsx b/src/components/SidebarNav.tsx index 389f3978..84ce34eb 100644 --- a/src/components/SidebarNav.tsx +++ b/src/components/SidebarNav.tsx @@ -24,7 +24,7 @@ import { PopoverContent, PopoverTrigger } from "@app/components/ui/popover"; -import { ChevronDown } from "lucide-react"; +import { ChevronRight } from "lucide-react"; import { build } from "@server/build"; export type SidebarNavItem = { @@ -51,6 +51,7 @@ export interface SidebarNavProps extends React.HTMLAttributes { type CollapsibleNavItemProps = { item: SidebarNavItem; level: number; + isActive: boolean; isChildActive: boolean; isDisabled: boolean; isCollapsed: boolean; @@ -63,6 +64,7 @@ type CollapsibleNavItemProps = { function CollapsibleNavItem({ item, level, + isActive, isChildActive, isDisabled, isCollapsed, @@ -112,30 +114,30 @@ function CollapsibleNavItem({ + {version && ( + +
+ +

+ {t("pangolinUpdateAvailable")} +

+ +
+
+
+ + )} ); }