From 3a9e79e6d56740652a26fa87e29bafe676aa84ac Mon Sep 17 00:00:00 2001 From: Owen Date: Tue, 31 Mar 2026 16:17:17 -0700 Subject: [PATCH 01/13] Filter only newt sites on private resources --- src/components/InternalResourceForm.tsx | 1 + src/components/site-selector.tsx | 10 +++++++--- 2 files changed, 8 insertions(+), 3 deletions(-) diff --git a/src/components/InternalResourceForm.tsx b/src/components/InternalResourceForm.tsx index 50847a489..a4a793753 100644 --- a/src/components/InternalResourceForm.tsx +++ b/src/components/InternalResourceForm.tsx @@ -614,6 +614,7 @@ export function InternalResourceForm({ { setSelectedSite(site); field.onChange(site.siteId); diff --git a/src/components/site-selector.tsx b/src/components/site-selector.tsx index 4c3c97651..db23362dc 100644 --- a/src/components/site-selector.tsx +++ b/src/components/site-selector.tsx @@ -24,12 +24,14 @@ export type SitesSelectorProps = { orgId: string; selectedSite?: Selectedsite | null; onSelectSite: (selected: Selectedsite) => void; + filterTypes?: string[]; }; export function SitesSelector({ orgId, selectedSite, - onSelectSite + onSelectSite, + filterTypes }: SitesSelectorProps) { const t = useTranslations(); const [siteSearchQuery, setSiteSearchQuery] = useState(""); @@ -45,7 +47,9 @@ export function SitesSelector({ // always include the selected site in the list of sites shown const sitesShown = useMemo(() => { - const allSites: Array = [...sites]; + const allSites: Array = filterTypes + ? sites.filter((s) => filterTypes.includes(s.type)) + : [...sites]; if ( debouncedQuery.trim().length === 0 && selectedSite && @@ -54,7 +58,7 @@ export function SitesSelector({ allSites.unshift(selectedSite); } return allSites; - }, [debouncedQuery, sites, selectedSite]); + }, [debouncedQuery, sites, selectedSite, filterTypes]); return ( From 547865e0dab4a97c128b9d86f20462ba255c5e27 Mon Sep 17 00:00:00 2001 From: Owen Date: Tue, 31 Mar 2026 16:24:53 -0700 Subject: [PATCH 02/13] Mark targets unhealthy when site is down Fix #2675 Fix #2700 Fix #1742 --- server/routers/newt/handleNewtPingMessage.ts | 31 ++++++++++++++++++-- 1 file changed, 29 insertions(+), 2 deletions(-) diff --git a/server/routers/newt/handleNewtPingMessage.ts b/server/routers/newt/handleNewtPingMessage.ts index c2c4e7762..32f665758 100644 --- a/server/routers/newt/handleNewtPingMessage.ts +++ b/server/routers/newt/handleNewtPingMessage.ts @@ -1,4 +1,4 @@ -import { db, newts, sites } from "@server/db"; +import { db, newts, sites, targetHealthCheck, targets } from "@server/db"; import { hasActiveConnections, getClientConfigVersion @@ -78,6 +78,32 @@ export const startNewtOfflineChecker = (): void => { .update(sites) .set({ online: false }) .where(eq(sites.siteId, staleSite.siteId)); + + const healthChecksOnSite = await db + .select() + .from(targetHealthCheck) + .innerJoin( + targets, + eq(targets.targetId, targetHealthCheck.targetId) + ) + .innerJoin(sites, eq(sites.siteId, targets.siteId)) + .where(eq(sites.siteId, staleSite.siteId)); + + for (const healthCheck of healthChecksOnSite) { + logger.info( + `Marking health check ${healthCheck.targetHealthCheck.targetHealthCheckId} offline due to site ${staleSite.siteId} being marked offline` + ); + await db + .update(targetHealthCheck) + .set({ hcHealth: "unknown" }) + .where( + eq( + targetHealthCheck.targetHealthCheckId, + healthCheck.targetHealthCheck + .targetHealthCheckId + ) + ); + } } // this part only effects self hosted. Its not efficient but we dont expect people to have very many wireguard sites @@ -102,7 +128,8 @@ export const startNewtOfflineChecker = (): void => { // loop over each one. If its offline and there is a new update then mark it online. If its online and there is no update then mark it offline for (const site of allWireguardSites) { - const lastBandwidthUpdate = new Date(site.lastBandwidthUpdate!).getTime() / 1000; + const lastBandwidthUpdate = + new Date(site.lastBandwidthUpdate!).getTime() / 1000; if ( lastBandwidthUpdate < wireguardOfflineThreshold && site.online From 69aa6e2d1dc837d67499c02230dc2b4d2b0f369c Mon Sep 17 00:00:00 2001 From: Owen Date: Tue, 31 Mar 2026 17:00:06 -0700 Subject: [PATCH 03/13] Prevent increase in writes on reconnect --- .../routers/target/handleHealthcheckStatusMessage.ts | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/server/routers/target/handleHealthcheckStatusMessage.ts b/server/routers/target/handleHealthcheckStatusMessage.ts index 01cbdea81..7ea1730ce 100644 --- a/server/routers/target/handleHealthcheckStatusMessage.ts +++ b/server/routers/target/handleHealthcheckStatusMessage.ts @@ -77,7 +77,8 @@ export const handleHealthcheckStatusMessage: MessageHandler = async ( const [targetCheck] = await db .select({ targetId: targets.targetId, - siteId: targets.siteId + siteId: targets.siteId, + hcStatus: targetHealthCheck.hcHealth }) .from(targets) .innerJoin( @@ -85,6 +86,7 @@ export const handleHealthcheckStatusMessage: MessageHandler = async ( eq(targets.resourceId, resources.resourceId) ) .innerJoin(sites, eq(targets.siteId, sites.siteId)) + .innerJoin(targetHealthCheck, eq(targets.targetId, targetHealthCheck.targetId)) .where( and( eq(targets.targetId, targetIdNum), @@ -101,6 +103,14 @@ export const handleHealthcheckStatusMessage: MessageHandler = async ( continue; } + // check if the status has changed + if (targetCheck.hcStatus === healthStatus.status) { + logger.debug( + `Health status for target ${targetId} is already ${healthStatus.status}, skipping update` + ); + continue; + } + // Update the target's health status in the database await db .update(targetHealthCheck) From 08e4afaef01f33f390e69180c4e642720f7ec19b Mon Sep 17 00:00:00 2001 From: Owen Date: Tue, 31 Mar 2026 17:06:56 -0700 Subject: [PATCH 04/13] Update hp log message --- server/routers/newt/handleGetConfigMessage.ts | 3 ++- server/routers/olm/handleOlmRegisterMessage.ts | 3 ++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/server/routers/newt/handleGetConfigMessage.ts b/server/routers/newt/handleGetConfigMessage.ts index fced51818..9c67f53ee 100644 --- a/server/routers/newt/handleGetConfigMessage.ts +++ b/server/routers/newt/handleGetConfigMessage.ts @@ -8,6 +8,7 @@ import { sendToExitNode } from "#dynamic/lib/exitNodes"; import { buildClientConfigurationForNewtClient } from "./buildConfiguration"; import { convertTargetsIfNessicary } from "../client/targets"; import { canCompress } from "@server/lib/clientVersionChecks"; +import config from "@server/lib/config"; export const handleGetConfigMessage: MessageHandler = async (context) => { const { message, client, sendToClient } = context; @@ -55,7 +56,7 @@ export const handleGetConfigMessage: MessageHandler = async (context) => { if (existingSite.lastHolePunch && now - existingSite.lastHolePunch > 5) { logger.warn( - `handleGetConfigMessage: Site ${existingSite.siteId} last hole punch is too old, skipping` + `Site last hole punch is too old; skipping this register. The site is failing to hole punch and identify its network address with the server. Can the client reach the server on UDP port ${config.getRawConfig().gerbil.clients_start_port}?` ); return; } diff --git a/server/routers/olm/handleOlmRegisterMessage.ts b/server/routers/olm/handleOlmRegisterMessage.ts index 26dbff1bd..01495de3b 100644 --- a/server/routers/olm/handleOlmRegisterMessage.ts +++ b/server/routers/olm/handleOlmRegisterMessage.ts @@ -20,6 +20,7 @@ import { handleFingerprintInsertion } from "./fingerprintingUtils"; import { Alias } from "@server/lib/ip"; import { build } from "@server/build"; import { canCompress } from "@server/lib/clientVersionChecks"; +import config from "@server/lib/config"; export const handleOlmRegisterMessage: MessageHandler = async (context) => { logger.info("Handling register olm message!"); @@ -274,7 +275,7 @@ export const handleOlmRegisterMessage: MessageHandler = async (context) => { // TODO: I still think there is a better way to do this rather than locking it out here but ??? if (now - (client.lastHolePunch || 0) > 5 && sitesCount > 0) { logger.warn( - "Client last hole punch is too old and we have sites to send; skipping this register" + `Client last hole punch is too old and we have sites to send; skipping this register. The client is failing to hole punch and identify its network address with the server. Can the client reach the server on UDP port ${config.getRawConfig().gerbil.clients_start_port}?` ); return; } From 363c13c3878b60349caab73f19e5630032cbfb21 Mon Sep 17 00:00:00 2001 From: Owen Date: Wed, 1 Apr 2026 09:53:49 -0700 Subject: [PATCH 05/13] Impvove communication --- README.md | 2 +- messages/en-US.json | 6 +++--- src/app/[orgId]/settings/provisioning/pending/page.tsx | 6 ++++++ src/components/PendingSitesTable.tsx | 7 +++++++ src/components/SiteProvisioningKeysTable.tsx | 1 + src/components/ui/controlled-data-table.tsx | 4 +++- src/components/ui/data-table.tsx | 4 +++- 7 files changed, 24 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index bac7b7e56..28fc991c8 100644 --- a/README.md +++ b/README.md @@ -60,7 +60,7 @@ Pangolin is an open-source, identity-based remote access platform built on WireG | | Description | |-----------------|--------------| -| **Pangolin Cloud** | Fully managed service with instant setup and pay-as-you-go pricing — no infrastructure required. Or, self-host your own [remote node](https://docs.pangolin.net/manage/remote-node/understanding-nodes) and connect to our control plane. | +| **Pangolin Cloud** | Fully managed service with instant setup and pay-as-you-go pricing - no infrastructure required. Or, self-host your own [remote node](https://docs.pangolin.net/manage/remote-node/understanding-nodes) and connect to our control plane. | | **Self-Host: Community Edition** | Free, open source, and licensed under AGPL-3. | | **Self-Host: Enterprise Edition** | Licensed under Fossorial Commercial License. Free for personal and hobbyist use, and for businesses earning under \$100K USD annually. | diff --git a/messages/en-US.json b/messages/en-US.json index 412d50179..51bb996af 100644 --- a/messages/en-US.json +++ b/messages/en-US.json @@ -371,10 +371,10 @@ "provisioningKeysUpdated": "Provisioning key updated", "provisioningKeysUpdatedDescription": "Your changes have been saved.", "provisioningKeysBannerTitle": "Site Provisioning Keys", - "provisioningKeysBannerDescription": "Generate a provisioning key and use it with the Newt connector to automatically create sites on first startup — no need to set up separate credentials for each site.", + "provisioningKeysBannerDescription": "Generate a provisioning key and use it with the Newt connector to automatically create sites on first startup - no need to set up separate credentials for each site.", "provisioningKeysBannerButtonText": "Learn More", "pendingSitesBannerTitle": "Pending Sites", - "pendingSitesBannerDescription": "Sites that connect using a provisioning key appear here for review. Approve each site before it becomes active and gains access to your resources.", + "pendingSitesBannerDescription": "Sites that connect using a provisioning key appear here for review.", "pendingSitesBannerButtonText": "Learn More", "apiKeysSettings": "{apiKeyName} Settings", "userTitle": "Manage All Users", @@ -2346,7 +2346,7 @@ "description": "Enterprise features, 50 users, 50 sites, and priority support." } }, - "personalUseOnly": "Personal use only (free license — no checkout)", + "personalUseOnly": "Personal use only (free license - no checkout)", "buttons": { "continueToCheckout": "Continue to Checkout" }, diff --git a/src/app/[orgId]/settings/provisioning/pending/page.tsx b/src/app/[orgId]/settings/provisioning/pending/page.tsx index 637f828b8..4669f9160 100644 --- a/src/app/[orgId]/settings/provisioning/pending/page.tsx +++ b/src/app/[orgId]/settings/provisioning/pending/page.tsx @@ -9,6 +9,8 @@ import DismissableBanner from "@app/components/DismissableBanner"; import Link from "next/link"; import { Button } from "@app/components/ui/button"; import { ArrowRight, Plug } from "lucide-react"; +import { PaidFeaturesAlert } from "@app/components/PaidFeaturesAlert"; +import { TierFeature, tierMatrix } from "@server/lib/billing/tierMatrix"; type PendingSitesPageProps = { params: Promise<{ orgId: string }>; @@ -96,6 +98,10 @@ export default async function PendingSitesPage(props: PendingSitesPageProps) { + + = { onAdd?: () => void; onRefresh?: () => void; isRefreshing?: boolean; + refreshButtonDisabled?: boolean; isNavigatingToAddPage?: boolean; searchPlaceholder?: string; filters?: DataTableFilter[]; @@ -91,6 +92,7 @@ export function ControlledDataTable({ onAdd, onRefresh, isRefreshing, + refreshButtonDisabled = false, searchPlaceholder = "Search...", filters, filterDisplayMode = "label", @@ -335,7 +337,7 @@ export function ControlledDataTable({