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 1edd323c4..3f5567d99 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",
@@ -2348,7 +2348,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"
},
@@ -2609,6 +2609,9 @@
"machineClients": "Machine Clients",
"install": "Install",
"run": "Run",
+ "envFile": "Environment File",
+ "serviceFile": "Service File",
+ "enableAndStart": "Enable and Start",
"clientNameDescription": "The display name of the client that can be changed later.",
"clientAddress": "Client Address (Advanced)",
"setupFailedToFetchSubnet": "Failed to fetch default subnet",
diff --git a/server/private/lib/logStreaming/LogStreamingManager.ts b/server/private/lib/logStreaming/LogStreamingManager.ts
index 04e35ad00..0067c0690 100644
--- a/server/private/lib/logStreaming/LogStreamingManager.ts
+++ b/server/private/lib/logStreaming/LogStreamingManager.ts
@@ -127,7 +127,7 @@ export class LogStreamingManager {
start(): void {
if (this.isRunning) return;
this.isRunning = true;
- logger.info("LogStreamingManager: started");
+ logger.debug("LogStreamingManager: started");
this.schedulePoll(POLL_INTERVAL_MS);
}
@@ -770,4 +770,4 @@ export class LogStreamingManager {
function sleep(ms: number): Promise {
return new Promise((resolve) => setTimeout(resolve, ms));
-}
\ No newline at end of file
+}
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/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
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;
}
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)
diff --git a/server/setup/scriptsPg/1.17.0.ts b/server/setup/scriptsPg/1.17.0.ts
index ea66948ab..21dd3a224 100644
--- a/server/setup/scriptsPg/1.17.0.ts
+++ b/server/setup/scriptsPg/1.17.0.ts
@@ -104,6 +104,42 @@ export default async function migration() {
CONSTRAINT "userOrgRoles_userId_orgId_roleId_unique" UNIQUE("userId","orgId","roleId")
);
`);
+
+ await db.execute(sql`
+ CREATE TABLE "eventStreamingCursors" (
+ "cursorId" serial PRIMARY KEY NOT NULL,
+ "destinationId" integer NOT NULL,
+ "logType" varchar(50) NOT NULL,
+ "lastSentId" bigint DEFAULT 0 NOT NULL,
+ "lastSentAt" bigint
+ );
+ `);
+
+ await db.execute(sql`
+ CREATE TABLE "eventStreamingDestinations" (
+ "destinationId" serial PRIMARY KEY NOT NULL,
+ "orgId" varchar(255) NOT NULL,
+ "sendConnectionLogs" boolean DEFAULT false NOT NULL,
+ "sendRequestLogs" boolean DEFAULT false NOT NULL,
+ "sendActionLogs" boolean DEFAULT false NOT NULL,
+ "sendAccessLogs" boolean DEFAULT false NOT NULL,
+ "type" varchar(50) NOT NULL,
+ "config" text NOT NULL,
+ "enabled" boolean DEFAULT true NOT NULL,
+ "createdAt" bigint NOT NULL,
+ "updatedAt" bigint NOT NULL
+ );
+ `);
+
+ await db.execute(
+ sql`ALTER TABLE "eventStreamingCursors" ADD CONSTRAINT "eventStreamingCursors_destinationId_eventStreamingDestinations_destinationId_fk" FOREIGN KEY ("destinationId") REFERENCES "public"."eventStreamingDestinations"("destinationId") ON DELETE cascade ON UPDATE no action;`
+ );
+ await db.execute(
+ sql`ALTER TABLE "eventStreamingDestinations" ADD CONSTRAINT "eventStreamingDestinations_orgId_orgs_orgId_fk" FOREIGN KEY ("orgId") REFERENCES "public"."orgs"("orgId") ON DELETE cascade ON UPDATE no action;`
+ );
+ await db.execute(
+ sql`CREATE UNIQUE INDEX "idx_eventStreamingCursors_dest_type" ON "eventStreamingCursors" USING btree ("destinationId","logType");`
+ );
await db.execute(
sql`ALTER TABLE "userOrgs" DROP CONSTRAINT "userOrgs_roleId_roles_roleId_fk";`
);
@@ -177,8 +213,12 @@ export default async function migration() {
sql`CREATE INDEX "idx_accessAuditLog_siteResourceId" ON "connectionAuditLog" USING btree ("siteResourceId");`
);
await db.execute(sql`ALTER TABLE "userInvites" DROP COLUMN "roleId";`);
- await db.execute(sql`ALTER TABLE "siteProvisioningKeys" ADD COLUMN "approveNewSites" boolean DEFAULT true NOT NULL;`);
- await db.execute(sql`ALTER TABLE "sites" ADD COLUMN "status" varchar DEFAULT 'approved';`);
+ await db.execute(
+ sql`ALTER TABLE "siteProvisioningKeys" ADD COLUMN "approveNewSites" boolean DEFAULT true NOT NULL;`
+ );
+ await db.execute(
+ sql`ALTER TABLE "sites" ADD COLUMN "status" varchar DEFAULT 'approved';`
+ );
await db.execute(sql`COMMIT`);
console.log("Migrated database");
diff --git a/server/setup/scriptsSqlite/1.17.0.ts b/server/setup/scriptsSqlite/1.17.0.ts
index 28877f16e..28929fc63 100644
--- a/server/setup/scriptsSqlite/1.17.0.ts
+++ b/server/setup/scriptsSqlite/1.17.0.ts
@@ -76,9 +76,15 @@ export default async function migration() {
`
).run();
- db.prepare(`CREATE INDEX 'idx_accessAuditLog_startedAt' ON 'connectionAuditLog' ('startedAt');`).run();
- db.prepare(`CREATE INDEX 'idx_accessAuditLog_org_startedAt' ON 'connectionAuditLog' ('orgId','startedAt');`).run();
- db.prepare(`CREATE INDEX 'idx_accessAuditLog_siteResourceId' ON 'connectionAuditLog' ('siteResourceId');`).run();
+ db.prepare(
+ `CREATE INDEX 'idx_accessAuditLog_startedAt' ON 'connectionAuditLog' ('startedAt');`
+ ).run();
+ db.prepare(
+ `CREATE INDEX 'idx_accessAuditLog_org_startedAt' ON 'connectionAuditLog' ('orgId','startedAt');`
+ ).run();
+ db.prepare(
+ `CREATE INDEX 'idx_accessAuditLog_siteResourceId' ON 'connectionAuditLog' ('siteResourceId');`
+ ).run();
db.prepare(
`
@@ -168,6 +174,42 @@ export default async function migration() {
);
`
).run();
+
+ db.prepare(
+ `
+ CREATE TABLE 'eventStreamingCursors' (
+ 'cursorId' integer PRIMARY KEY AUTOINCREMENT NOT NULL,
+ 'destinationId' integer NOT NULL,
+ 'logType' text NOT NULL,
+ 'lastSentId' integer DEFAULT 0 NOT NULL,
+ 'lastSentAt' integer,
+ FOREIGN KEY ('destinationId') REFERENCES 'eventStreamingDestinations'('destinationId') ON UPDATE no action ON DELETE cascade
+ );
+ `
+ ).run();
+ db.prepare(
+ `
+ CREATE UNIQUE INDEX 'idx_eventStreamingCursors_dest_type' ON 'eventStreamingCursors' ('destinationId','logType');--> statement-breakpoint
+ `
+ ).run();
+ db.prepare(
+ `
+ CREATE TABLE 'eventStreamingDestinations' (
+ 'destinationId' integer PRIMARY KEY AUTOINCREMENT NOT NULL,
+ 'orgId' text NOT NULL,
+ 'sendConnectionLogs' integer DEFAULT false NOT NULL,
+ 'sendRequestLogs' integer DEFAULT false NOT NULL,
+ 'sendActionLogs' integer DEFAULT false NOT NULL,
+ 'sendAccessLogs' integer DEFAULT false NOT NULL,
+ 'type' text NOT NULL,
+ 'config' text NOT NULL,
+ 'enabled' integer DEFAULT true NOT NULL,
+ 'createdAt' integer NOT NULL,
+ 'updatedAt' integer NOT NULL,
+ FOREIGN KEY ('orgId') REFERENCES 'orgs'('orgId') ON UPDATE no action ON DELETE cascade
+ );
+ `
+ ).run();
db.prepare(
`INSERT INTO '__new_userInvites'("inviteId", "orgId", "email", "expiresAt", "token") SELECT "inviteId", "orgId", "email", "expiresAt", "token" FROM 'userInvites';`
).run();
@@ -191,8 +233,12 @@ export default async function migration() {
`ALTER TABLE 'user' ADD 'marketingEmailConsent' integer DEFAULT false;`
).run();
db.prepare(`ALTER TABLE 'user' ADD 'locale' text;`).run();
- db.prepare(`ALTER TABLE 'siteProvisioningKeys' ADD COLUMN 'approveNewSites' integer DEFAULT 1 NOT NULL;`).run();
- db.prepare(`ALTER TABLE 'sites' ADD COLUMN 'status' text DEFAULT 'approved';`).run();
+ db.prepare(
+ `ALTER TABLE 'siteProvisioningKeys' ADD COLUMN 'approveNewSites' integer DEFAULT 1 NOT NULL;`
+ ).run();
+ db.prepare(
+ `ALTER TABLE 'sites' ADD COLUMN 'status' text DEFAULT 'approved';`
+ ).run();
})();
db.pragma("foreign_keys = ON");
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) {
+
+
- updateTarget(row.original.targetId, config)
+ updateTarget(row.original.targetId,
+ config.path === null && config.pathMatchType === null
+ ? { ...config, rewritePath: null, rewritePathType: null }
+ : config
+ )
}
trigger={