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={