diff --git a/.github/workflows/cicd.yml b/.github/workflows/cicd.yml index 8af8625d..c2129493 100644 --- a/.github/workflows/cicd.yml +++ b/.github/workflows/cicd.yml @@ -107,7 +107,7 @@ jobs: - name: Build and push Docker images (Docker Hub) run: | TAG=${{ env.TAG }} - make build-release tag=$TAG + make -j4 build-release tag=$TAG echo "Built & pushed to: ${{ env.DOCKERHUB_IMAGE }}:${TAG}" shell: bash diff --git a/Makefile b/Makefile index 6c538a47..1519aec7 100644 --- a/Makefile +++ b/Makefile @@ -1,8 +1,13 @@ -.PHONY: build build-pg build-release build-arm build-x86 test clean +.PHONY: build dev-build-sqlite dev-build-pg build-release build-arm build-x86 test clean major_tag := $(shell echo $(tag) | cut -d. -f1) minor_tag := $(shell echo $(tag) | cut -d. -f1,2) -build-release: + +.PHONY: build-release build-sqlite build-postgresql build-ee-sqlite build-ee-postgresql + +build-release: build-sqlite build-postgresql build-ee-sqlite build-ee-postgresql + +build-sqlite: @if [ -z "$(tag)" ]; then \ echo "Error: tag is required. Usage: make build-release tag="; \ exit 1; \ @@ -16,6 +21,12 @@ build-release: --tag fosrl/pangolin:$(minor_tag) \ --tag fosrl/pangolin:$(tag) \ --push . + +build-postgresql: + @if [ -z "$(tag)" ]; then \ + echo "Error: tag is required. Usage: make build-release tag="; \ + exit 1; \ + fi docker buildx build \ --build-arg BUILD=oss \ --build-arg DATABASE=pg \ @@ -25,6 +36,12 @@ build-release: --tag fosrl/pangolin:postgresql-$(minor_tag) \ --tag fosrl/pangolin:postgresql-$(tag) \ --push . + +build-ee-sqlite: + @if [ -z "$(tag)" ]; then \ + echo "Error: tag is required. Usage: make build-release tag="; \ + exit 1; \ + fi docker buildx build \ --build-arg BUILD=enterprise \ --build-arg DATABASE=sqlite \ @@ -34,6 +51,12 @@ build-release: --tag fosrl/pangolin:ee-$(minor_tag) \ --tag fosrl/pangolin:ee-$(tag) \ --push . + +build-ee-postgresql: + @if [ -z "$(tag)" ]; then \ + echo "Error: tag is required. Usage: make build-release tag="; \ + exit 1; \ + fi docker buildx build \ --build-arg BUILD=enterprise \ --build-arg DATABASE=pg \ @@ -80,10 +103,10 @@ build-arm: build-x86: docker buildx build --platform linux/amd64 -t fosrl/pangolin:latest . -build-sqlite: +dev-build-sqlite: docker build --build-arg DATABASE=sqlite -t fosrl/pangolin:latest . -build-pg: +dev-build-pg: docker build --build-arg DATABASE=pg -t fosrl/pangolin:postgresql-latest . test: diff --git a/install/containers.go b/install/containers.go index 9993e117..464186c2 100644 --- a/install/containers.go +++ b/install/containers.go @@ -73,7 +73,7 @@ func installDocker() error { case strings.Contains(osRelease, "ID=ubuntu"): installCmd = exec.Command("bash", "-c", fmt.Sprintf(` apt-get update && - apt-get install -y apt-transport-https ca-certificates curl && + apt-get install -y apt-transport-https ca-certificates curl gpg && curl -fsSL https://download.docker.com/linux/ubuntu/gpg | gpg --dearmor -o /usr/share/keyrings/docker-archive-keyring.gpg && echo "deb [arch=%s signed-by=/usr/share/keyrings/docker-archive-keyring.gpg] https://download.docker.com/linux/ubuntu $(lsb_release -cs) stable" > /etc/apt/sources.list.d/docker.list && apt-get update && @@ -82,7 +82,7 @@ func installDocker() error { case strings.Contains(osRelease, "ID=debian"): installCmd = exec.Command("bash", "-c", fmt.Sprintf(` apt-get update && - apt-get install -y apt-transport-https ca-certificates curl && + apt-get install -y apt-transport-https ca-certificates curl gpg && curl -fsSL https://download.docker.com/linux/debian/gpg | gpg --dearmor -o /usr/share/keyrings/docker-archive-keyring.gpg && echo "deb [arch=%s signed-by=/usr/share/keyrings/docker-archive-keyring.gpg] https://download.docker.com/linux/debian $(lsb_release -cs) stable" > /etc/apt/sources.list.d/docker.list && apt-get update && diff --git a/messages/en-US.json b/messages/en-US.json index 432d853b..3601d6bd 100644 --- a/messages/en-US.json +++ b/messages/en-US.json @@ -2275,5 +2275,8 @@ "remoteExitNodeRegenerateAndDisconnectWarning": "This will regenerate the credentials and immediately disconnect the remote exit node. The remote exit node will need to be restarted with the new credentials.", "remoteExitNodeRegenerateCredentialsConfirmation": "Are you sure you want to regenerate the credentials for this remote exit node?", "remoteExitNodeRegenerateCredentialsWarning": "This will regenerate the credentials. The remote exit node will stay connected until you manually restart it and use the new credentials.", - "agent": "Agent" + "agent": "Agent", + "personalUseOnly": "Personal Use Only", + "loginPageLicenseWatermark": "This instance is licensed for personal use only.", + "instanceIsUnlicensed": "This instance is unlicensed." } diff --git a/package-lock.json b/package-lock.json index c6e60cef..b3a18c31 100644 --- a/package-lock.json +++ b/package-lock.json @@ -90,7 +90,7 @@ "qrcode.react": "4.2.0", "react": "19.2.3", "react-day-picker": "9.12.0", - "react-dom": "19.2.1", + "react-dom": "19.2.3", "react-easy-sort": "1.8.0", "react-hook-form": "7.68.0", "react-icons": "5.5.0", @@ -19768,16 +19768,16 @@ } }, "node_modules/react-dom": { - "version": "19.2.1", - "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.1.tgz", - "integrity": "sha512-ibrK8llX2a4eOskq1mXKu/TGZj9qzomO+sNfO98M6d9zIPOEhlBkMkBUBLd1vgS0gQsLDBzA+8jJBVXDnfHmJg==", + "version": "19.2.3", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.3.tgz", + "integrity": "sha512-yELu4WmLPw5Mr/lmeEpox5rw3RETacE++JgHqQzd2dg+YbJuat3jH4ingc+WPZhxaoFzdv9y33G+F7Nl5O0GBg==", "license": "MIT", "peer": true, "dependencies": { "scheduler": "^0.27.0" }, "peerDependencies": { - "react": "^19.2.1" + "react": "^19.2.3" } }, "node_modules/react-easy-sort": { diff --git a/package.json b/package.json index 5609b688..2aebc439 100644 --- a/package.json +++ b/package.json @@ -114,7 +114,7 @@ "qrcode.react": "4.2.0", "react": "19.2.3", "react-day-picker": "9.12.0", - "react-dom": "19.2.1", + "react-dom": "19.2.3", "react-easy-sort": "1.8.0", "react-hook-form": "7.68.0", "react-icons": "5.5.0", diff --git a/server/lib/consts.ts b/server/lib/consts.ts index d93cf224..d1f66a9e 100644 --- a/server/lib/consts.ts +++ b/server/lib/consts.ts @@ -2,7 +2,7 @@ import path from "path"; import { fileURLToPath } from "url"; // This is a placeholder value replaced by the build process -export const APP_VERSION = "1.13.0"; +export const APP_VERSION = "1.13.1"; export const __FILENAME = fileURLToPath(import.meta.url); export const __DIRNAME = path.dirname(__FILENAME); diff --git a/server/lib/ip.ts b/server/lib/ip.ts index 02683edc..9c412801 100644 --- a/server/lib/ip.ts +++ b/server/lib/ip.ts @@ -432,7 +432,12 @@ export function generateRemoteSubnets( ): string[] { const remoteSubnets = allSiteResources .filter((sr) => { - if (sr.mode === "cidr") return true; + if (sr.mode === "cidr") { + // check if its a valid CIDR using zod + const cidrSchema = z.union([z.cidrv4(), z.cidrv6()]); + const parseResult = cidrSchema.safeParse(sr.destination); + return parseResult.success; + } if (sr.mode === "host") { // check if its a valid IP using zod const ipSchema = z.union([z.ipv4(), z.ipv6()]); @@ -456,13 +461,12 @@ export function generateRemoteSubnets( export type Alias = { alias: string | null; aliasAddress: string | null }; export function generateAliasConfig(allSiteResources: SiteResource[]): Alias[] { - let aliasConfigs = allSiteResources + return allSiteResources .filter((sr) => sr.alias && sr.aliasAddress && sr.mode == "host") .map((sr) => ({ alias: sr.alias, aliasAddress: sr.aliasAddress })); - return aliasConfigs; } export type SubnetProxyTarget = { diff --git a/server/lib/rebuildClientAssociations.ts b/server/lib/rebuildClientAssociations.ts index e0867dc5..625e5793 100644 --- a/server/lib/rebuildClientAssociations.ts +++ b/server/lib/rebuildClientAssociations.ts @@ -955,28 +955,8 @@ export async function rebuildClientAssociationsFromClient( /////////// Send messages /////////// - // Get the olm for this client - const [olm] = await trx - .select({ olmId: olms.olmId }) - .from(olms) - .where(eq(olms.clientId, client.clientId)) - .limit(1); - - if (!olm) { - logger.warn( - `Olm not found for client ${client.clientId}, skipping peer updates` - ); - return; - } - // Handle messages for sites being added - await handleMessagesForClientSites( - client, - olm.olmId, - sitesToAdd, - sitesToRemove, - trx - ); + await handleMessagesForClientSites(client, sitesToAdd, sitesToRemove, trx); // Handle subnet proxy target updates for resources await handleMessagesForClientResources( @@ -996,11 +976,26 @@ async function handleMessagesForClientSites( userId: string | null; orgId: string; }, - olmId: string, sitesToAdd: number[], sitesToRemove: number[], trx: Transaction | typeof db = db ): Promise { + // Get the olm for this client + const [olm] = await trx + .select({ olmId: olms.olmId }) + .from(olms) + .where(eq(olms.clientId, client.clientId)) + .limit(1); + + if (!olm) { + logger.warn( + `Olm not found for client ${client.clientId}, skipping peer updates` + ); + return; + } + + const olmId = olm.olmId; + if (!client.subnet || !client.pubKey) { logger.warn( `Client ${client.clientId} missing subnet or pubKey, skipping peer updates` @@ -1021,9 +1016,9 @@ async function handleMessagesForClientSites( .leftJoin(newts, eq(sites.siteId, newts.siteId)) .where(inArray(sites.siteId, allSiteIds)); - let newtJobs: Promise[] = []; - let olmJobs: Promise[] = []; - let exitNodeJobs: Promise[] = []; + const newtJobs: Promise[] = []; + const olmJobs: Promise[] = []; + const exitNodeJobs: Promise[] = []; for (const siteData of sitesData) { const site = siteData.sites; @@ -1130,18 +1125,8 @@ async function handleMessagesForClientResources( resourcesToRemove: number[], trx: Transaction | typeof db = db ): Promise { - // Group resources by site - const resourcesBySite = new Map(); - - for (const resource of allNewResources) { - if (!resourcesBySite.has(resource.siteId)) { - resourcesBySite.set(resource.siteId, []); - } - resourcesBySite.get(resource.siteId)!.push(resource); - } - - let proxyJobs: Promise[] = []; - let olmJobs: Promise[] = []; + const proxyJobs: Promise[] = []; + const olmJobs: Promise[] = []; // Handle additions if (resourcesToAdd.length > 0) { diff --git a/server/private/lib/traefik/getTraefikConfig.ts b/server/private/lib/traefik/getTraefikConfig.ts index 8060ccad..82568216 100644 --- a/server/private/lib/traefik/getTraefikConfig.ts +++ b/server/private/lib/traefik/getTraefikConfig.ts @@ -823,7 +823,7 @@ export async function getTraefikConfig( (cert) => cert.queriedDomain === lp.fullDomain ); if (!matchingCert) { - logger.warn( + logger.debug( `No matching certificate found for login page domain: ${lp.fullDomain}` ); continue; diff --git a/server/routers/badger/logRequestAudit.ts b/server/routers/badger/logRequestAudit.ts index 1cf97f98..1343bdaa 100644 --- a/server/routers/badger/logRequestAudit.ts +++ b/server/routers/badger/logRequestAudit.ts @@ -148,7 +148,7 @@ export async function cleanUpOldLogs(orgId: string, retentionDays: number) { } } -export function logRequestAudit( +export async function logRequestAudit( data: { action: boolean; reason: number; @@ -174,14 +174,13 @@ export function logRequestAudit( } ) { try { - // Quick synchronous check - if org has 0 retention, skip immediately + // Check retention before buffering any logs if (data.orgId) { - const cached = cache.get(`org_${data.orgId}_retentionDays`); - if (cached === 0) { + const retentionDays = await getRetentionDays(data.orgId); + if (retentionDays === 0) { // do not log return; } - // If not cached or > 0, we'll log it (async retention check happens in background) } let actorType: string | undefined; @@ -261,16 +260,6 @@ export function logRequestAudit( } else { scheduleFlush(); } - - // Async retention check in background (don't await) - if ( - data.orgId && - cache.get(`org_${data.orgId}_retentionDays`) === undefined - ) { - getRetentionDays(data.orgId).catch((err) => - logger.error("Error checking retention days:", err) - ); - } } catch (error) { logger.error(error); } diff --git a/server/routers/gerbil/getConfig.ts b/server/routers/gerbil/getConfig.ts index ba3ab7ad..488ef75b 100644 --- a/server/routers/gerbil/getConfig.ts +++ b/server/routers/gerbil/getConfig.ts @@ -51,7 +51,10 @@ export async function getConfig( ); } - const exitNode = await createExitNode(publicKey, reachableAt); + // clean up the public key - keep only valid base64 characters (A-Z, a-z, 0-9, +, /, =) + const cleanedPublicKey = publicKey.replace(/[^A-Za-z0-9+/=]/g, ''); + + const exitNode = await createExitNode(cleanedPublicKey, reachableAt); if (!exitNode) { return next( diff --git a/src/app/[orgId]/settings/(private)/remote-exit-nodes/ExitNodesTable.tsx b/src/app/[orgId]/settings/(private)/remote-exit-nodes/ExitNodesTable.tsx index a38f3b86..e5250bea 100644 --- a/src/app/[orgId]/settings/(private)/remote-exit-nodes/ExitNodesTable.tsx +++ b/src/app/[orgId]/settings/(private)/remote-exit-nodes/ExitNodesTable.tsx @@ -304,7 +304,7 @@ export default function ExitNodesTable({ setSelectedNode(null); }} dialog={ -
+

{t("remoteExitNodeQuestionRemove")}

{t("remoteExitNodeMessageRemove")}

diff --git a/src/app/[orgId]/settings/general/page.tsx b/src/app/[orgId]/settings/general/page.tsx index e391922f..97dd4a03 100644 --- a/src/app/[orgId]/settings/general/page.tsx +++ b/src/app/[orgId]/settings/general/page.tsx @@ -289,7 +289,7 @@ export default function GeneralPage() { setIsDeleteModalOpen(val); }} dialog={ -
+

{t("orgQuestionRemove")}

{t("orgMessageRemove")}

@@ -303,7 +303,7 @@ export default function GeneralPage() { open={isSecurityPolicyConfirmOpen} setOpen={setIsSecurityPolicyConfirmOpen} dialog={ -
+

{t("securityPolicyChangeDescription")}

} diff --git a/src/app/[orgId]/settings/resources/proxy/[niceId]/rules/page.tsx b/src/app/[orgId]/settings/resources/proxy/[niceId]/rules/page.tsx index 39badea4..78a1b896 100644 --- a/src/app/[orgId]/settings/resources/proxy/[niceId]/rules/page.tsx +++ b/src/app/[orgId]/settings/resources/proxy/[niceId]/rules/page.tsx @@ -449,15 +449,16 @@ export default function ResourceRules(props: { type="number" onClick={(e) => e.currentTarget.focus()} onBlur={(e) => { - const parsed = z + const parsed = z.coerce + .number() .int() .optional() .safeParse(e.target.value); - if (!parsed.data) { + if (!parsed.success) { toast({ variant: "destructive", - title: t("rulesErrorInvalidIpAddress"), // correct priority or IP? + title: t("rulesErrorInvalidPriority"), // correct priority or IP? description: t( "rulesErrorInvalidPriorityDescription" ) diff --git a/src/app/admin/license/page.tsx b/src/app/admin/license/page.tsx index ac6d3e67..4e0586bd 100644 --- a/src/app/admin/license/page.tsx +++ b/src/app/admin/license/page.tsx @@ -315,7 +315,7 @@ export default function LicensePage() { setSelectedLicenseKey(null); }} dialog={ -
+

{t("licenseQuestionRemove")}

{t("licenseMessageRemove")} @@ -360,7 +360,8 @@ export default function LicensePage() {

- {t("licensed")} + {t("licensed") + + `${licenseStatus?.tier === "personal" ? ` (${t("personalUseOnly")})` : ""}`}
) : ( diff --git a/src/app/admin/users/AdminUsersTable.tsx b/src/app/admin/users/AdminUsersTable.tsx index efcf9484..1c7d1b7f 100644 --- a/src/app/admin/users/AdminUsersTable.tsx +++ b/src/app/admin/users/AdminUsersTable.tsx @@ -243,7 +243,7 @@ export default function UsersTable({ users }: Props) { setSelected(null); }} dialog={ -
+

{t("userQuestionRemove")}

{t("userMessageRemove")}

diff --git a/src/app/auth/layout.tsx b/src/app/auth/layout.tsx index 70439824..6a72006b 100644 --- a/src/app/auth/layout.tsx +++ b/src/app/auth/layout.tsx @@ -23,6 +23,7 @@ export default async function AuthLayout({ children }: AuthLayoutProps) { const t = await getTranslations(); let hideFooter = false; + let licenseStatus: GetLicenseStatusResponse | null = null; if (build == "enterprise") { const licenseStatusRes = await cache( async () => @@ -30,10 +31,12 @@ export default async function AuthLayout({ children }: AuthLayoutProps) { "/license/status" ) )(); + licenseStatus = licenseStatusRes.data.data; if ( env.branding.hideAuthLayoutFooter && licenseStatusRes.data.data.isHostLicensed && - licenseStatusRes.data.data.isLicenseValid + licenseStatusRes.data.data.isLicenseValid && + licenseStatusRes.data.data.tier !== "personal" ) { hideFooter = true; } @@ -83,6 +86,23 @@ export default async function AuthLayout({ children }: AuthLayoutProps) { ? t("enterpriseEdition") : t("pangolinCloud")} + {build === "enterprise" && + licenseStatus?.isHostLicensed && + licenseStatus?.isLicenseValid && + licenseStatus?.tier === "personal" ? ( + <> + + {t("personalUseOnly")} + + ) : null} + {build === "enterprise" && + (!licenseStatus?.isHostLicensed || + !licenseStatus?.isLicenseValid) ? ( + <> + + {t("unlicensed")} + + ) : null} {build === "saas" && ( <> diff --git a/src/components/AdminIdpTable.tsx b/src/components/AdminIdpTable.tsx index 76a0fdd7..75a7c545 100644 --- a/src/components/AdminIdpTable.tsx +++ b/src/components/AdminIdpTable.tsx @@ -196,7 +196,7 @@ export default function IdpTable({ idps }: Props) { setSelectedIdp(null); }} dialog={ -
+

{t("idpQuestionRemove", { name: selectedIdp.name diff --git a/src/components/AdminUsersTable.tsx b/src/components/AdminUsersTable.tsx index 9c741cee..327d4752 100644 --- a/src/components/AdminUsersTable.tsx +++ b/src/components/AdminUsersTable.tsx @@ -313,7 +313,7 @@ export default function UsersTable({ users }: Props) { setSelected(null); }} dialog={ -

+

{t("userQuestionRemove", { selectedUser: diff --git a/src/components/ApiKeysTable.tsx b/src/components/ApiKeysTable.tsx index c3202277..8987fa2c 100644 --- a/src/components/ApiKeysTable.tsx +++ b/src/components/ApiKeysTable.tsx @@ -182,7 +182,7 @@ export default function ApiKeysTable({ apiKeys }: ApiKeyTableProps) { setSelected(null); }} dialog={ -

+

{t("apiKeysQuestionRemove")}

{t("apiKeysMessageRemove")}

diff --git a/src/components/ClientResourcesTable.tsx b/src/components/ClientResourcesTable.tsx index a5e257c7..3f65c762 100644 --- a/src/components/ClientResourcesTable.tsx +++ b/src/components/ClientResourcesTable.tsx @@ -284,7 +284,7 @@ export default function ClientResourcesTable({ setSelectedInternalResource(null); }} dialog={ -
+

{t("resourceQuestionRemove")}

{t("resourceMessageRemove")}

diff --git a/src/components/DataTablePagination.tsx b/src/components/DataTablePagination.tsx index be12ca47..4abcf1c5 100644 --- a/src/components/DataTablePagination.tsx +++ b/src/components/DataTablePagination.tsx @@ -24,6 +24,8 @@ interface DataTablePaginationProps { isServerPagination?: boolean; isLoading?: boolean; disabled?: boolean; + pageSize?: number; + pageIndex?: number; } export function DataTablePagination({ @@ -33,10 +35,26 @@ export function DataTablePagination({ totalCount, isServerPagination = false, isLoading = false, - disabled = false + disabled = false, + pageSize: controlledPageSize, + pageIndex: controlledPageIndex }: DataTablePaginationProps) { const t = useTranslations(); + // Use controlled values if provided, otherwise fall back to table state + const pageSize = controlledPageSize ?? table.getState().pagination.pageSize; + const pageIndex = + controlledPageIndex ?? table.getState().pagination.pageIndex; + + // Calculate page boundaries based on controlled state + // For server-side pagination, use totalCount if available for accurate page count + const pageCount = + isServerPagination && totalCount !== undefined + ? Math.ceil(totalCount / pageSize) + : table.getPageCount(); + const canNextPage = pageIndex < pageCount - 1; + const canPreviousPage = pageIndex > 0; + const handlePageSizeChange = (value: string) => { const newPageSize = Number(value); table.setPageSize(newPageSize); @@ -51,7 +69,7 @@ export function DataTablePagination({ action: "first" | "previous" | "next" | "last" ) => { if (isServerPagination && onPageChange) { - const currentPage = table.getState().pagination.pageIndex; + const currentPage = pageIndex; const pageCount = table.getPageCount(); let newPage: number; @@ -77,18 +95,24 @@ export function DataTablePagination({ } } else { // Use table's built-in navigation for client-side pagination + // But add bounds checking to prevent going beyond page boundaries + const pageCount = table.getPageCount(); switch (action) { case "first": table.setPageIndex(0); break; case "previous": - table.previousPage(); + if (pageIndex > 0) { + table.previousPage(); + } break; case "next": - table.nextPage(); + if (pageIndex < pageCount - 1) { + table.nextPage(); + } break; case "last": - table.setPageIndex(table.getPageCount() - 1); + table.setPageIndex(Math.max(0, pageCount - 1)); break; } } @@ -98,14 +122,12 @@ export function DataTablePagination({