Compare commits

...

53 Commits

Author SHA1 Message Date
dependabot[bot] ae6f501756 Bump the docker-dependencies group across 1 directory with 2 updates
Bumps the docker-dependencies group with 2 updates in the / directory: docker/library/node and node.


Updates `docker/library/node` from 24.18.1-slim to 26.9.0-slim

Updates `node` from 24.18.1-alpine to 26.9.0-alpine

---
updated-dependencies:
- dependency-name: docker/library/node
  dependency-version: 26.9.0-slim
  dependency-type: direct:production
  update-type: version-update:semver-major
  dependency-group: docker-dependencies
- dependency-name: node
  dependency-version: 26.9.0-alpine
  dependency-type: direct:production
  update-type: version-update:semver-major
  dependency-group: docker-dependencies
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-09-22 01:34:09 +00:00
Owen Schwartz 8fcffe2202 Merge pull request #3590 from shleeable/patch-2
Add guard on resource policy change
2026-09-21 15:05:22 -04:00
Owen Schwartz af1cda6278 Merge pull request #3510 from shubhamsinnh/codex/fix-integration-site-resource
Fix integration API site resource lookup
2026-09-21 15:01:37 -04:00
Owen Schwartz ae34796072 Merge pull request #3585 from moyaqoob/fix-cli-import-style
Normalize CLI command import paths in pangctl entrypoint
2026-09-21 14:55:43 -04:00
Owen Schwartz ee08bc8927 Merge pull request #3599 from shleeable/patch-13
Typo: Update verifyApiKeyAccess to use generated apiKey.apiKeyOrg?.orgId data
2026-09-21 14:36:38 -04:00
Owen Schwartz cf22bd1a13 Merge pull request #3600 from shleeable/patch-14
Typo: Fix error to be correctly related to newt
2026-09-21 14:35:26 -04:00
Owen Schwartz 085e85f652 Merge pull request #3592 from shleeable/patch-6
Typo: eq(sites.orgId, sites.orgId), same same
2026-09-21 14:34:36 -04:00
Owen Schwartz c7222b67b9 Merge pull request #3591 from shleeable/patch-5
Typo: Update exportConnectionAuditLog.ts to use MAX_EXPORT_LIMIT
2026-09-21 14:33:22 -04:00
Owen Schwartz bdb4b0c16d Merge pull request #3500 from Tim5965/patch-6
Update nl-NL.json
2026-09-21 14:29:09 -04:00
Owen Schwartz e5edab546b Merge branch 'main' into patch-6 2026-09-21 14:28:48 -04:00
Owen Schwartz 7e188ffae6 Merge pull request #3738 from breken-ai/fix/path-rule-percent-encoding
▚▚ fix(rules): decode percent-encoded PATH rule patterns before matching
2026-09-21 13:55:25 -04:00
Owen Schwartz f5018073ef Merge pull request #3785 from Tyagiquamar/fix/sqlite-single-backup-per-upgrade
fix(sqlite): tag per-migration database backups with version and prevent collisions
2026-09-21 11:39:40 -04:00
Owen Schwartz 178af0fd7f Merge pull request #3616 from m1handr/fix/integration-api-site-subnet-prefix
fix(site): include org subnet prefix when auto-allocating site address (#3523)
2026-09-21 09:47:47 -04:00
Owen Schwartz f691abb2b3 Merge pull request #3796 from fosrl/fix-reference-deployment
Use postgresql
2026-09-21 09:16:11 -04:00
Owen 610c228d56 Use postgresql
Fix #3794
2026-09-21 09:15:49 -04:00
Tyagiquamar b74ded3a9c fix(sqlite): tag per-migration database backups with version and prevent collisions 2026-09-18 21:31:00 +05:30
Tyagiquamar 803fbb2ea2 fix(sqlite): take a single database backup per upgrade instead of one per migration 2026-09-17 22:45:42 +05:30
Owen Schwartz b0e2fcadd4 Merge pull request #3780 from fosrl/dev
Improve license billing
2026-09-16 12:12:09 -04:00
Owen 65ccd5a89d Enhance error handling for subscription lifecycle events in billing hooks 2026-09-16 12:11:25 -04:00
Owen 76a4f50ccf Update how billing is presented for license keys 2026-09-16 11:42:19 -04:00
miloschwartz 1dfe0124be add expanded resource laucher screenshot 2026-09-16 10:52:35 -04:00
miloschwartz aa52174f96 update resource launcher screenshot 2026-09-16 10:52:35 -04:00
Owen e23ae5707f Link direct to self hosted 2026-09-16 10:43:46 -04:00
Owen 17053ac2bd Fix agent display again 2026-09-16 10:38:52 -04:00
Owen Schwartz 2733ae1122 Merge pull request #3779 from fosrl/dev
Fix #3778
2026-09-16 10:11:43 -04:00
Owen 735787b183 Fix #3778 2026-09-16 10:11:20 -04:00
Owen Schwartz 2fb3507aa5 Merge pull request #3775 from fosrl/dev
Add domain validation for inference mode in resource forms
2026-09-16 09:53:21 -04:00
Owen 262ca8a1d0 Add domain validation for inference mode in resource forms 2026-09-16 09:41:46 -04:00
Owen Schwartz 66c9bdbfa3 Merge pull request #3774 from fosrl/dev
Make migration idempotent
2026-09-16 09:11:14 -04:00
Owen 4716a2a647 Make migration idempotent
Ref #3759
2026-09-16 09:08:20 -04:00
Owen Schwartz 847f44197b Merge pull request #3772 from Hayyan612/fix/site-type-badge-fallback
fix(sites): fall back to newtVersion so the Type badge is never empty
2026-09-16 08:59:07 -04:00
Owen Schwartz 6aacd4d185 Merge pull request #3770 from Hayyan612/fix/systemd-exec-path
fix(ui): point the manual systemd unit at the installed CLI path
2026-09-16 08:57:37 -04:00
Owen Schwartz ab3db3bc68 Merge pull request #3769 from Hayyan612/fix/backup-filename-timestamp
fix(setup): correct month index and zero-pad database backup file names
2026-09-16 08:57:12 -04:00
Hayyan Hajwani b52baceb50 fix(sites): fall back to newtVersion so the Type badge is never empty
The site overview rendered an empty badge for any site whose newt has not
reported the newer agent fields. The early return only bails out when both
agent and newtVersion are missing, so a site with newtVersion set but agent
null fell through to a badge whose label came solely from agent and whose
version came solely from agentVersion, leaving both blank.

Label such a site Newt and fall back to newtVersion for the version. Updating
the newt populated the new fields, which is why the badge appeared to fix
itself on upgrade.

Closes #3766
2026-09-16 13:55:24 +05:30
Hayyan Hajwani 0d2c8a37ef fix(ui): point the manual systemd unit at the installed CLI path
The service file offered on the site install screen hardcoded
/home/owen/fossorial/cli/bin/pangolin, a developer machine path, so the unit
fails to start on a normal install.

get-cli.sh installs to /usr/local/bin ("Prefer /usr/local/bin for system-wide
installation"), which is also where the bare `pangolin` calls in the surrounding
commands resolve from.

Closes #3768
2026-09-16 13:54:14 +05:30
Hayyan Hajwani 9298ec7cdb fix(setup): correct month index and zero-pad database backup file names
Backup names were built inline from Date#getMonth, which is zero-indexed, so a
backup taken on 12 September 2026 was written as db_2026-8-12_20-35-56.sqlite.
No field was zero-padded either, giving names like db_2026-8-12_20-36-2.sqlite.

Extract formatBackupTimestamp into server/lib and use it from both places that
built the string: the backupDb helper in migrationsSqlite.ts and the inline copy
in the 1.0.0-beta9 setup script. Padding every field also makes the names sort
lexicographically in the order the backups were taken.

Adds tests covering both reported names, single-digit padding and sort order.
Reverting the helper to the old formula fails them with the exact name from the
report.
2026-09-16 13:46:57 +05:30
Owen Schwartz 0fdff2feee Merge pull request #3765 from fosrl/dev
Show the version
2026-09-15 22:31:22 -04:00
Owen 8e042e6433 Show the version 2026-09-15 22:31:05 -04:00
Owen Schwartz 883ad14326 Merge pull request #3764 from fosrl/dev
fix premature migration execution
2026-09-15 22:30:19 -04:00
miloschwartz 3cb41211ee fix premature migration execution 2026-09-15 20:44:17 -04:00
Owen Schwartz ee4a1a6b18 Merge pull request #3758 from fosrl/dev
Update link in reference deployment
2026-09-15 17:17:22 -04:00
Owen Schwartz a10972990a Merge pull request #3757 from fosrl/dev
Install go 1.26
2026-09-15 16:57:15 -04:00
Owen Schwartz 6d6e105711 Merge pull request #3756 from fosrl/dev
1.23.0
2026-09-15 16:48:42 -04:00
breken 64ae230d23 fix(rules): decode percent-encoded PATH rule patterns before matching
isPathAllowed decodes the incoming request path (and Badger already sends
Go's decoded req.URL.Path), but compared it against the rule pattern as raw
text. isValidUrlGlobPattern rejects raw spaces and non-ASCII and only accepts
them percent-encoded, so a PATH rule such as `/my%20docs/*` or `/caf%C3%A9`
was stored as `my%20docs` / `caf%C3%A9` and compared against `my docs` /
`café`, and could therefore never match any request.

Run the pattern through the same decodeAndResolvePath normalisation as the
request path so both sides are compared in decoded form.

Claude-Session: https://claude.ai/code/session_0134ujLF81GyXsCByibLcYsz
2026-09-13 19:00:59 -07:00
m1handr 46538eb606 fix(site): include org subnet prefix when auto-allocating site address (#3523) 2026-08-19 20:29:27 +03:00
Shlee 64aa7292e6 Update peers.ts 2026-08-16 22:10:57 +09:30
Shlee 2a6bd21029 Update verifyApiKeyAccess.ts 2026-08-16 22:10:07 +09:30
Shlee 5cd98e2790 Update updateSite.ts 2026-08-16 21:37:00 +09:30
Shlee 4bf5efdab5 Update exportConnectionAuditLog.ts 2026-08-16 21:34:35 +09:30
Shlee e9e8457d06 Update updateResource.ts 2026-08-16 21:29:44 +09:30
yaqoob 8ec9fb3cb9 Normalize CLI command import paths in pangctl entrypoint 2026-08-15 03:49:08 +05:30
Shubham Singh c11fc86b63 Fix integration site resource lookup 2026-07-30 15:34:00 +05:30
Tim 3564674bed Update nl-NL.json
Also did them on crowdin, but think there is some difference between the files
2026-07-29 15:29:31 +02:00
42 changed files with 1081 additions and 230 deletions
+2 -2
View File
@@ -1,5 +1,5 @@
# FROM node:24.18.1-slim AS base # FROM node:24.18.1-slim AS base
FROM public.ecr.aws/docker/library/node:24.18.1-slim AS base FROM public.ecr.aws/docker/library/node:26.9.0-slim AS base
WORKDIR /app WORKDIR /app
@@ -33,7 +33,7 @@ FROM base AS builder
RUN npm ci --omit=dev RUN npm ci --omit=dev
# FROM node:24.18.1-slim AS runner # FROM node:24.18.1-slim AS runner
FROM public.ecr.aws/docker/library/node:24.18.1-slim AS runner FROM public.ecr.aws/docker/library/node:26.9.0-slim AS runner
WORKDIR /app WORKDIR /app
+1 -1
View File
@@ -1,4 +1,4 @@
FROM node:24.18.1-alpine FROM node:26.9.0-alpine
WORKDIR /app WORKDIR /app
+1
View File
@@ -142,6 +142,7 @@ Give users a landing page to quickly find and open the resources they can access
* Create reusable views for common access patterns * Create reusable views for common access patterns
<img src="public/screenshots/resource-launcher.png" alt="Resource Launcher" width="100%" /> <img src="public/screenshots/resource-launcher.png" alt="Resource Launcher" width="100%" />
<img src="public/screenshots/resource-launcher-expanded.png" alt="Resource Launcher Details Panel" width="100%" />
## Download Clients ## Download Clients
+2 -2
View File
@@ -2,8 +2,8 @@
import yargs from "yargs"; import yargs from "yargs";
import { hideBin } from "yargs/helpers"; import { hideBin } from "yargs/helpers";
import { setAdminCredentials } from "@cli/commands/setAdminCredentials"; import { setAdminCredentials } from "./commands/setAdminCredentials";
import { resetUserSecurityKeys } from "@cli/commands/resetUserSecurityKeys"; import { resetUserSecurityKeys } from "./commands/resetUserSecurityKeys";
import { clearExitNodes } from "./commands/clearExitNodes"; import { clearExitNodes } from "./commands/clearExitNodes";
import { rotateServerSecret } from "./commands/rotateServerSecret"; import { rotateServerSecret } from "./commands/rotateServerSecret";
import { clearLicenseKeys } from "./commands/clearLicenseKeys"; import { clearLicenseKeys } from "./commands/clearLicenseKeys";
+1 -1
View File
@@ -1,7 +1,7 @@
name: pangolin name: pangolin
services: services:
pangolin: pangolin:
image: docker.io/fosrl/pangolin:ee-latest image: docker.io/fosrl/pangolin:ee-postgresql-latest
container_name: pangolin container_name: pangolin
restart: unless-stopped restart: unless-stopped
volumes: volumes:
+1 -1
View File
@@ -1,7 +1,7 @@
name: pangolin name: pangolin
services: services:
pangolin: pangolin:
image: docker.io/fosrl/pangolin:ee-latest image: docker.io/fosrl/pangolin:ee-postgresql-latest
container_name: pangolin container_name: pangolin
restart: unless-stopped restart: unless-stopped
volumes: volumes:
+3
View File
@@ -234,6 +234,9 @@
"clientResourceDescription": "Create and manage resources that are only accessible through a connected client", "clientResourceDescription": "Create and manage resources that are only accessible through a connected client",
"privateResourcesBannerTitle": "Zero-Trust Private Access", "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.", "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.",
"licenseBillingBannerTitle": "Manage License Billing",
"licenseBillingBannerDescription": "To manage billing for your license keys, including payment methods and invoices, visit the billing page.",
"licenseBillingBannerButton": "Go to Billing",
"resourcesSearch": "Search resources...", "resourcesSearch": "Search resources...",
"resourceAdd": "Add Resource", "resourceAdd": "Add Resource",
"resourceErrorDelte": "Error deleting resource", "resourceErrorDelte": "Error deleting resource",
+4 -4
View File
@@ -459,13 +459,13 @@
"searchApiKeys": "API sleutels zoeken...", "searchApiKeys": "API sleutels zoeken...",
"apiKeysAdd": "API sleutel genereren", "apiKeysAdd": "API sleutel genereren",
"apiKeysErrorDelete": "Fout bij verwijderen API sleutel", "apiKeysErrorDelete": "Fout bij verwijderen API sleutel",
"apiKeysErrorDeleteMessage": "Fout bij verwijderen API sleutel", "apiKeysErrorDeleteMessage": "Fout bij verwijderen API- leutel",
"apiKeysQuestionRemove": "Weet u zeker dat u de API sleutel van de organisatie wilt verwijderen?", "apiKeysQuestionRemove": "Weet u zeker dat u de API sleutel van de organisatie wilt verwijderen?",
"apiKeysMessageRemove": "Eenmaal verwijderd, kan de API sleutel niet meer worden gebruikt.", "apiKeysMessageRemove": "Eenmaal verwijderd, kan de APIsleutel niet meer worden gebruikt.",
"apiKeysDeleteConfirm": "Bevestig Verwijderen API sleutel", "apiKeysDeleteConfirm": "Bevestig verwijderen API sleutel",
"apiKeysDelete": "API sleutel verwijderen", "apiKeysDelete": "API sleutel verwijderen",
"apiKeysManage": "API sleutels beheren", "apiKeysManage": "API sleutels beheren",
"apiKeysDescription": "API sleutels worden gebruikt om toegang te verifiëren met de integratie API ", "apiKeysDescription": "API sleutels worden gebruikt om te verifiëren met de integratie-API",
"orgsManage": "Organisaties Beheren", "orgsManage": "Organisaties Beheren",
"orgsDescription": "Bekijk en beheer alle organisaties op dit systeem", "orgsDescription": "Bekijk en beheer alle organisaties op dit systeem",
"provisioningKeysTitle": "Vertrekkende sleutel", "provisioningKeysTitle": "Vertrekkende sleutel",
Binary file not shown.

After

Width:  |  Height:  |  Size: 711 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 620 KiB

After

Width:  |  Height:  |  Size: 713 KiB

@@ -93,9 +93,8 @@ export const EnterpriseEditionKeyGenerated = ({
</EmailSection> </EmailSection>
<EmailText> <EmailText>
If you need to purchase additional license keys or For any questions or concerns regarding your license
modify your existing license, please reach out to or billing, please reach out to support at{" "}
our support team at{" "}
<a <a
href="mailto:support@pangolin.net" href="mailto:support@pangolin.net"
className="text-primary font-medium" className="text-primary font-medium"
+138
View File
@@ -0,0 +1,138 @@
import { formatBackupFileName, formatBackupTimestamp } from "./backupFileName";
import { assertEquals } from "@test/assert";
// Local-time constructors are used throughout, matching formatBackupTimestamp,
// so these cases do not depend on the machine's timezone.
function testMonthIsOneIndexed() {
console.log("Running month indexing tests...");
// The case from the report: a backup taken on 12 September 2026 was named
// db_2026-8-12_... because Date#getMonth is zero-indexed.
{
const result = formatBackupTimestamp(new Date(2026, 8, 12, 20, 35, 56));
assertEquals(
result,
"2026-09-12_20-35-56",
"September must render as 09, not 8"
);
}
// The other reported name, db_2026-0-23_..., was a January backup.
{
const result = formatBackupTimestamp(new Date(2026, 0, 23, 20, 25, 49));
assertEquals(
result,
"2026-01-23_20-25-49",
"January must render as 01, not 0"
);
}
{
const result = formatBackupTimestamp(
new Date(2026, 11, 31, 23, 59, 59)
);
assertEquals(
result,
"2026-12-31_23-59-59",
"December must render as 12"
);
}
}
function testEveryFieldIsZeroPadded() {
console.log("Running zero padding tests...");
// db_2026-8-12_20-36-2 in the report: a single-digit second was not padded.
{
const result = formatBackupTimestamp(new Date(2026, 8, 12, 20, 36, 2));
assertEquals(
result,
"2026-09-12_20-36-02",
"Single-digit seconds must be padded"
);
}
{
const result = formatBackupTimestamp(new Date(2026, 0, 1, 0, 0, 0));
assertEquals(
result,
"2026-01-01_00-00-00",
"Midnight on the first of the month must pad every field"
);
}
}
function testNamesSortChronologically() {
console.log("Running sort order tests...");
// Zero padding means a plain lexicographic sort of the backups directory
// lists the backups in the order they were taken.
const taken = [
new Date(2026, 8, 12, 20, 36, 2),
new Date(2026, 0, 23, 20, 25, 49),
new Date(2026, 8, 12, 20, 35, 56),
new Date(2026, 11, 31, 23, 59, 59)
];
const sorted = taken.map((date) => formatBackupTimestamp(date)).sort();
assertEquals(
sorted.join(","),
[
"2026-01-23_20-25-49",
"2026-09-12_20-35-56",
"2026-09-12_20-36-02",
"2026-12-31_23-59-59"
].join(","),
"Backup names must sort into the order the backups were taken"
);
}
function testFormatBackupFileName() {
console.log("Running backup file name formatting tests...");
const date = new Date(2026, 8, 12, 20, 35, 56);
// With semver version string without leading 'v'
assertEquals(
formatBackupFileName("1.22.0", date),
"db_2026-09-12_20-35-56_v1.22.0.sqlite",
"Filename must include timestamp and prefixed version tag"
);
// With version string already containing 'v'
assertEquals(
formatBackupFileName("v1.22.0", date),
"db_2026-09-12_20-35-56_v1.22.0.sqlite",
"Filename must not duplicate 'v' prefix if already present"
);
// Without version (fallback/default)
assertEquals(
formatBackupFileName(undefined, date),
"db_2026-09-12_20-35-56.sqlite",
"Filename without version must match default timestamped format"
);
// Distinct versions within the exact same second do not collide
const sameSecondFile1 = formatBackupFileName("1.21.0", date);
const sameSecondFile2 = formatBackupFileName("1.22.0", date);
if (sameSecondFile1 === sameSecondFile2) {
throw new Error(
"Backup file names for different versions in the same second must not collide"
);
}
}
// Run all tests
try {
testMonthIsOneIndexed();
testEveryFieldIsZeroPadded();
testNamesSortChronologically();
testFormatBackupFileName();
console.log("All tests passed successfully!");
} catch (error) {
console.error("Test failed:", error);
process.exit(1);
}
+51
View File
@@ -0,0 +1,51 @@
/**
* Builds the timestamp segment of a database backup file name.
*
* `Date#getMonth` is zero-indexed, so building this inline produced names like
* `db_2026-8-12_...` for a backup taken on 12 September 2026. Every field is
* also zero-padded, which keeps the names unambiguous and makes them sort
* lexicographically in the order they were taken.
*
* @param date The moment the backup is being taken. Defaults to now.
* @returns A timestamp of the form `YYYY-MM-DD_HH-MM-SS`.
*/
export function formatBackupTimestamp(date: Date = new Date()): string {
const pad = (value: number): string => String(value).padStart(2, "0");
const datePart = [
date.getFullYear(),
pad(date.getMonth() + 1),
pad(date.getDate())
].join("-");
const timePart = [
pad(date.getHours()),
pad(date.getMinutes()),
pad(date.getSeconds())
].join("-");
return `${datePart}_${timePart}`;
}
/**
* Builds the full database backup file name, including timestamp and optional version tag.
*
* When a migration version is provided, the filename includes `_v<version>`,
* preventing collisions between multiple migrations running in the same second and making it easy
* to identify the migration state contained in the backup.
*
* @param version Optional migration version being run.
* @param date The moment the backup is being taken. Defaults to now.
* @returns A filename of the form `db_YYYY-MM-DD_HH-MM-SS_v<version>.sqlite` or `db_YYYY-MM-DD_HH-MM-SS.sqlite`.
*/
export function formatBackupFileName(
version?: string,
date: Date = new Date()
): string {
const timestamp = formatBackupTimestamp(date);
if (version) {
const versionTag = version.startsWith("v") ? version : `v${version}`;
return `db_${timestamp}_${versionTag}.sqlite`;
}
return `db_${timestamp}.sqlite`;
}
+6 -1
View File
@@ -20,6 +20,11 @@ function getSegmentRegex(patternPart: string): RegExp {
// resolves `.` / `..` segments, so a request like `/public%2F..%2Fadmin/` // resolves `.` / `..` segments, so a request like `/public%2F..%2Fadmin/`
// or `/public/../admin/` is matched as `/admin/`, not as a literal segment // or `/public/../admin/` is matched as `/admin/`, not as a literal segment
// or a wildcard-swallowed sequence under `/public/*`. // or a wildcard-swallowed sequence under `/public/*`.
//
// Applied to both the request path and the rule pattern: the pattern
// validator only accepts spaces / non-ASCII in percent-encoded form, so a
// rule like `/my%20docs/*` must be compared against the decoded segment
// `my docs`, not the literal text `my%20docs`.
function decodeAndResolvePath(p: string): string[] { function decodeAndResolvePath(p: string): string[] {
const rawParts = p.split("/").filter(Boolean); const rawParts = p.split("/").filter(Boolean);
@@ -48,7 +53,7 @@ function decodeAndResolvePath(p: string): string[] {
} }
export function isPathAllowed(pattern: string, path: string): boolean { export function isPathAllowed(pattern: string, path: string): boolean {
const patternParts = pattern.split("/").filter(Boolean); const patternParts = decodeAndResolvePath(pattern);
const pathParts = decodeAndResolvePath(path); const pathParts = decodeAndResolvePath(path);
function matchSegments( function matchSegments(
+3 -8
View File
@@ -70,13 +70,8 @@ export async function verifyApiKeyAccess(
); );
} }
if (!apiKeyOrg.orgId) { if (!apiKey.apiKeyOrg?.orgId) {
return next( return next(createHttpError(HttpCode.INTERNAL_SERVER_ERROR, `API key with ID ${apiKeyId} does not have an organization ID`));
createHttpError(
HttpCode.INTERNAL_SERVER_ERROR,
`API key with ID ${apiKeyId} does not have an organization ID`
)
);
} }
if (!req.userOrg) { if (!req.userOrg) {
@@ -86,7 +81,7 @@ export async function verifyApiKeyAccess(
.where( .where(
and( and(
eq(userOrgs.userId, userId), eq(userOrgs.userId, userId),
eq(userOrgs.orgId, apiKeyOrg.orgId) eq(userOrgs.orgId, apiKey.apiKeyOrg.orgId)
) )
) )
.limit(1); .limit(1);
@@ -97,7 +97,7 @@ export async function exportConnectionAuditLogs(
const baseQuery = queryConnection(data); const baseQuery = queryConnection(data);
const log = await baseQuery.limit(data.limit).offset(data.offset); const log = await baseQuery.limit(MAX_EXPORT_LIMIT);
const csvData = generateCSV(log); const csvData = generateCSV(log);
@@ -243,6 +243,14 @@ export async function handleSubscriptionCreated(
`License type determined: ${numUsers} users, ${numSites} sites for subscription ${subscription.id}` `License type determined: ${numUsers} users, ${numSites} sites for subscription ${subscription.id}`
); );
// Grace period of 5 days added on top of the current billing
// period end (usually ~1 year out) before the license expires
const currentPeriodEnd =
fullSubscription.items.data[0]?.current_period_end;
const expiresAt =
(currentPeriodEnd ?? subscription.created) +
5 * 24 * 60 * 60;
const response = await fetch( const response = await fetch(
`${privateConfig.getRawPrivateConfig().server.fossorial_api}/api/v1/license-internal/enterprise/paid-for`, `${privateConfig.getRawPrivateConfig().server.fossorial_api}/api/v1/license-internal/enterprise/paid-for`,
{ {
@@ -258,7 +266,8 @@ export async function handleSubscriptionCreated(
paidFor: true, paidFor: true,
users: numUsers, users: numUsers,
sites: numSites, sites: numSites,
tier: tier tier: tier,
expiresAt: expiresAt
}) })
} }
); );
@@ -267,6 +276,13 @@ export async function handleSubscriptionCreated(
logger.debug(`Fossorial API response: ${JSON.stringify(data)}`); logger.debug(`Fossorial API response: ${JSON.stringify(data)}`);
if (!response.ok || !data.success) {
logger.error(
`Fossorial API returned ${response.status} when setting paid-for for orgId ${customer.orgId} and subscription ID ${subscription.id}: ${JSON.stringify(data)}`
);
return;
}
if (customer.email) { if (customer.email) {
logger.debug( logger.debug(
`Sending license key email to ${customer.email} for subscription ${subscription.id}` `Sending license key email to ${customer.email} for subscription ${subscription.id}`
@@ -125,9 +125,7 @@ export async function handleSubscriptionDeleted(
`Handling license subscription deletion for orgId ${customer.orgId} and subscription ID ${subscription.id}` `Handling license subscription deletion for orgId ${customer.orgId} and subscription ID ${subscription.id}`
); );
try { try {
// WARNING: const invalidateResponse = await fetch(
// this invalidates ALL OF THE ENTERPRISE LICENSES for this orgId
await fetch(
`${privateConfig.getRawPrivateConfig().server.fossorial_api}/api/v1/license-internal/enterprise/invalidate`, `${privateConfig.getRawPrivateConfig().server.fossorial_api}/api/v1/license-internal/enterprise/invalidate`,
{ {
method: "POST", method: "POST",
@@ -139,9 +137,18 @@ export async function handleSubscriptionDeleted(
}, },
body: JSON.stringify({ body: JSON.stringify({
orgId: customer.orgId, orgId: customer.orgId,
licenseKeyId: parseInt(
subscription.metadata.licenseKeyId
)
}) })
} }
); );
if (!invalidateResponse.ok) {
logger.error(
`Fossorial API returned ${invalidateResponse.status} when invalidating license for orgId ${customer.orgId} and subscription ID ${subscription.id}: ${await invalidateResponse.text()}`
);
}
} catch (error) { } catch (error) {
logger.error( logger.error(
`Error notifying Fossorial API of license subscription deletion for orgId ${customer.orgId} and subscription ID ${subscription.id}:`, `Error notifying Fossorial API of license subscription deletion for orgId ${customer.orgId} and subscription ID ${subscription.id}:`,
@@ -23,7 +23,10 @@ import {
} from "@server/db"; } from "@server/db";
import { eq, and } from "drizzle-orm"; import { eq, and } from "drizzle-orm";
import logger from "@server/logger"; import logger from "@server/logger";
import { getFeatureIdByMetricId, getFeatureIdByPriceId } from "@server/lib/billing/features"; import {
getFeatureIdByMetricId,
getFeatureIdByPriceId
} from "@server/lib/billing/features";
import stripe from "#private/lib/stripe"; import stripe from "#private/lib/stripe";
import { handleSubscriptionLifesycle } from "../subscriptionLifecycle"; import { handleSubscriptionLifesycle } from "../subscriptionLifecycle";
import { getSubType, SubscriptionType } from "./getSubType"; import { getSubType, SubscriptionType } from "./getSubType";
@@ -66,7 +69,8 @@ export async function handleSubscriptionUpdated(
.limit(1); .limit(1);
const type = getSubType(fullSubscription); const type = getSubType(fullSubscription);
const previousType = existingSubscription.type as SubscriptionType | null; const previousType =
existingSubscription.type as SubscriptionType | null;
// If the subscription has been manually overridden, we lock the // If the subscription has been manually overridden, we lock the
// status down so Stripe webhooks can no longer change it. // status down so Stripe webhooks can no longer change it.
@@ -100,7 +104,11 @@ export async function handleSubscriptionUpdated(
logger.info( logger.info(
`Tier change detected for org ${customer.orgId}: ${previousType} -> ${type}` `Tier change detected for org ${customer.orgId}: ${previousType} -> ${type}`
); );
await handleTierChange(customer.orgId, type, previousType ?? undefined); await handleTierChange(
customer.orgId,
type,
previousType ?? undefined
);
} }
// Upsert subscription items // Upsert subscription items
@@ -113,7 +121,8 @@ export async function handleSubscriptionUpdated(
const itemsToUpsert = fullSubscription.items.data.map((item) => { const itemsToUpsert = fullSubscription.items.data.map((item) => {
// Try to get featureId from price // Try to get featureId from price
let featureId: string | null = getFeatureIdByPriceId(item.price.id) || null; let featureId: string | null =
getFeatureIdByPriceId(item.price.id) || null;
// If no match, try to preserve existing featureId // If no match, try to preserve existing featureId
if (!featureId) { if (!featureId) {
@@ -302,14 +311,20 @@ export async function handleSubscriptionUpdated(
logger.info( logger.info(
`Subscription ${subscription.id} for org ${customer.orgId} is ${effectiveStatus}, disabling paid features` `Subscription ${subscription.id} for org ${customer.orgId} is ${effectiveStatus}, disabling paid features`
); );
await handleTierChange(customer.orgId, null, previousType ?? undefined); await handleTierChange(
customer.orgId,
null,
previousType ?? undefined
);
} }
} else if (type === "license") { } else if (type === "license") {
if (effectiveStatus === "canceled" || effectiveStatus == "unpaid" || effectiveStatus == "incomplete_expired") { if (
effectiveStatus === "canceled" ||
effectiveStatus == "unpaid" ||
effectiveStatus == "incomplete_expired"
) {
try { try {
// WARNING: const invalidateResponse = await fetch(
// this invalidates ALL OF THE ENTERPRISE LICENSES for this orgId
await fetch(
`${privateConfig.getRawPrivateConfig().server.fossorial_api}/api/v1/license-internal/enterprise/invalidate`, `${privateConfig.getRawPrivateConfig().server.fossorial_api}/api/v1/license-internal/enterprise/invalidate`,
{ {
method: "POST", method: "POST",
@@ -320,16 +335,95 @@ export async function handleSubscriptionUpdated(
"Content-Type": "application/json" "Content-Type": "application/json"
}, },
body: JSON.stringify({ body: JSON.stringify({
orgId: customer.orgId orgId: customer.orgId,
licenseKeyId: parseInt(
subscription.metadata.licenseKeyId
)
}) })
} }
); );
if (!invalidateResponse.ok) {
logger.error(
`Fossorial API returned ${invalidateResponse.status} when invalidating license for orgId ${customer.orgId} and subscription ID ${subscription.id}: ${await invalidateResponse.text()}`
);
}
} catch (error) { } catch (error) {
logger.error( logger.error(
`Error notifying Fossorial API of license subscription deletion for orgId ${customer.orgId} and subscription ID ${subscription.id}:`, `Error notifying Fossorial API of license subscription deletion for orgId ${customer.orgId} and subscription ID ${subscription.id}:`,
error error
); );
} }
} else if (effectiveStatus === "active" && previousAttributes) {
// Detect a successful renewal: the billing period rolled
// forward (the invoice was paid and the new period began
// right where the previous one ended).
const currentItem = fullSubscription.items.data[0];
const prevItems = previousAttributes.items?.data;
const prevItem = Array.isArray(prevItems)
? prevItems.find(
(pi: any) => pi.id === currentItem?.id
)
: undefined;
const renewed =
currentItem &&
prevItem?.current_period_end &&
currentItem.current_period_start ===
prevItem.current_period_end &&
currentItem.current_period_start >
prevItem.current_period_start;
if (renewed) {
const licenseKeyId =
subscription.metadata.licenseKeyId;
if (!licenseKeyId) {
logger.error(
`No licenseKeyId in metadata for subscription ${subscription.id}, cannot extend license.`
);
} else {
// Grace period of 5 days added on top of the new
// billing period end (usually ~1 year out)
const expiresAt =
currentItem.current_period_end +
5 * 24 * 60 * 60;
try {
const extendResponse = await fetch(
`${privateConfig.getRawPrivateConfig().server.fossorial_api}/api/v1/license-internal/enterprise/extend`,
{
method: "POST",
headers: {
"api-key":
privateConfig.getRawPrivateConfig()
.server.fossorial_api_key!,
"Content-Type": "application/json"
},
body: JSON.stringify({
licenseId: parseInt(licenseKeyId),
expiresAt: expiresAt
})
}
);
if (!extendResponse.ok) {
logger.error(
`Fossorial API returned ${extendResponse.status} when extending license ${licenseKeyId} for subscription ${subscription.id}: ${await extendResponse.text()}`
);
} else {
logger.info(
`Extended license ${licenseKeyId} for subscription ${subscription.id} to expire at ${expiresAt}.`
);
}
} catch (error) {
logger.error(
`Error notifying Fossorial API of license renewal for subscription ${subscription.id}:`,
error
);
}
}
}
} }
} }
} }
@@ -96,6 +96,8 @@ export async function generateNewEnterpriseLicense(
); );
} }
const licenseKeyValue = apiResponse?.data?.licenseKey?.licenseKey;
// check if we already have a customer for this org // check if we already have a customer for this org
const [customer] = await db const [customer] = await db
.select() .select()
@@ -129,6 +131,16 @@ export async function generateNewEnterpriseLicense(
], // Start with the standard feature set that matches the free limits ], // Start with the standard feature set that matches the free limits
customer: customer.customerId, customer: customer.customerId,
mode: "subscription", mode: "subscription",
subscription_data: {
description: licenseKeyValue
? `License ${licenseKeyValue}`
: `License key ID ${keyId}`,
metadata: {
licenseKeyId: keyId.toString(),
licenseKey: licenseKeyValue ?? "",
tier: licenseData.tier
}
},
allow_promotion_codes: true, allow_promotion_codes: true,
success_url: `${config.getRawConfig().app.dashboard_url}/${orgId}/settings/license?success=true&session_id={CHECKOUT_SESSION_ID}`, success_url: `${config.getRawConfig().app.dashboard_url}/${orgId}/settings/license?success=true&session_id={CHECKOUT_SESSION_ID}`,
cancel_url: `${config.getRawConfig().app.dashboard_url}/${orgId}/settings/license?canceled=true` cancel_url: `${config.getRawConfig().app.dashboard_url}/${orgId}/settings/license?canceled=true`
@@ -386,6 +386,38 @@ function runSpecialCharacterTests() {
console.log("All special character tests passed!"); console.log("All special character tests passed!");
} }
function runEncodedPatternTests() {
console.log("\nRunning percent-encoded pattern tests...");
// isValidUrlGlobPattern accepts percent-encoded sequences and rejects
// raw spaces / non-ASCII, so `%20` and `%C3%A9` are the only way to write
// a PATH rule for such a path. Badger sends the request path already
// decoded (Go's req.URL.Path), and isPathAllowed decodes it again, so the
// rule pattern must be decoded the same way or it can never match.
assertEquals(
isPathAllowed("/my%20docs/*", "/my docs/report.pdf"),
true,
"Percent-encoded space in pattern should match decoded request path"
);
assertEquals(
isPathAllowed("/my%20docs/*", "/my%20docs/report.pdf"),
true,
"Percent-encoded space in pattern should match raw-encoded request path"
);
assertEquals(
isPathAllowed("/caf%C3%A9", "/café"),
true,
"Percent-encoded UTF-8 in pattern should match decoded request path"
);
assertEquals(
isPathAllowed("/my%20docs/*", "/my-docs/report.pdf"),
false,
"Decoded pattern must still reject a different path"
);
console.log("All percent-encoded pattern tests passed!");
}
function runRegionTests() { function runRegionTests() {
console.log("\nRunning isIpInRegion tests..."); console.log("\nRunning isIpInRegion tests...");
@@ -446,6 +478,7 @@ function runRegionTests() {
try { try {
runTests(); runTests();
runSpecialCharacterTests(); runSpecialCharacterTests();
runEncodedPatternTests();
runRegionTests(); runRegionTests();
console.log("\n✅ All tests passed!"); console.log("\n✅ All tests passed!");
} catch (error) { } catch (error) {
+1 -1
View File
@@ -31,7 +31,7 @@ export async function addPeer(
.where(eq(newts.siteId, siteId)) .where(eq(newts.siteId, siteId))
.limit(1); .limit(1);
if (!newt) { if (!newt) {
throw new Error(`Site found for site ${siteId}`); throw new Error(`Newt not found for site ${siteId}`);
} }
newtId = newt.newtId; newtId = newt.newtId;
} }
+2 -1
View File
@@ -500,7 +500,8 @@ async function updateHttpResource(
} }
// catch when the resource policy changes or gets cleared // catch when the resource policy changes or gets cleared
if (resource.resourcePolicyId != updateData.resourcePolicyId) { if (updateData.resourcePolicyId !== undefined &&
resource.resourcePolicyId !== updateData.resourcePolicyId) {
await clearResourceSpecificSettings( await clearResourceSpecificSettings(
resource.resourceId, resource.resourceId,
resource.orgId, resource.orgId,
+1 -1
View File
@@ -263,7 +263,7 @@ export async function createSite(
const { value: newClientAddress, release } = const { value: newClientAddress, release } =
await getNextAvailableClientSubnet(orgId); await getNextAvailableClientSubnet(orgId);
releaseSubnetLock = release; releaseSubnetLock = release;
updatedAddress = newClientAddress.split("/")[0]; updatedAddress = `${newClientAddress.split("/")[0]}/${org.subnet ? org.subnet.split("/")[1] : "32"}`;
} }
let newSite: Site | undefined; let newSite: Site | undefined;
+1 -1
View File
@@ -113,7 +113,7 @@ export async function updateSite(
.where( .where(
and( and(
eq(sites.niceId, updateData.niceId), eq(sites.niceId, updateData.niceId),
eq(sites.orgId, sites.orgId), eq(sites.orgId, existingSite.orgId),
ne(sites.siteId, siteId) ne(sites.siteId, siteId)
) )
) )
@@ -0,0 +1,58 @@
import { assertEquals } from "@test/assert";
import { getSiteResourceParamsSchema } from "./getSiteResource";
function testSiteResourceIdOnlyParams() {
const result = getSiteResourceParamsSchema.safeParse({
siteResourceId: "42"
});
assertEquals(
result.success,
true,
"siteResourceId-only integration routes should pass validation"
);
if (result.success) {
assertEquals(
result.data.siteResourceId,
42,
"siteResourceId should be parsed as a number"
);
assertEquals(
result.data.orgId,
undefined,
"orgId should remain optional"
);
}
}
function testOrgScopedParamsRemainSupported() {
const result = getSiteResourceParamsSchema.safeParse({
siteResourceId: "42",
orgId: "org-id"
});
assertEquals(
result.success,
true,
"org-scoped routes should continue to pass validation"
);
}
function testInvalidSiteResourceId() {
const result = getSiteResourceParamsSchema.safeParse({
siteResourceId: "not-a-number"
});
assertEquals(
result.success,
false,
"non-numeric siteResourceIds should fail validation"
);
}
testSiteResourceIdOnlyParams();
testOrgScopedParamsRemainSupported();
testInvalidSiteResourceId();
console.log("All getSiteResource parameter validation tests passed.");
@@ -10,7 +10,7 @@ import { fromError } from "zod-validation-error";
import logger from "@server/logger"; import logger from "@server/logger";
import { OpenAPITags, registry } from "@server/openApi"; import { OpenAPITags, registry } from "@server/openApi";
const getSiteResourceParamsSchema = z.strictObject({ export const getSiteResourceParamsSchema = z.strictObject({
siteResourceId: z siteResourceId: z
.string() .string()
.optional() .optional()
@@ -22,15 +22,17 @@ const getSiteResourceParamsSchema = z.strictObject({
}); });
async function query(siteResourceId?: number, niceId?: string, orgId?: string) { async function query(siteResourceId?: number, niceId?: string, orgId?: string) {
if (siteResourceId && orgId) { if (siteResourceId) {
const [siteResource] = await db const [siteResource] = await db
.select() .select()
.from(siteResources) .from(siteResources)
.where( .where(
and( orgId
eq(siteResources.siteResourceId, siteResourceId), ? and(
eq(siteResources.orgId, orgId) eq(siteResources.siteResourceId, siteResourceId),
) eq(siteResources.orgId, orgId)
)
: eq(siteResources.siteResourceId, siteResourceId)
) )
.limit(1); .limit(1);
return siteResource; return siteResource;
@@ -0,0 +1,342 @@
import { execFileSync } from "child_process";
import fs from "fs";
import os from "os";
import path from "path";
import { fileURLToPath } from "url";
import Database from "better-sqlite3";
import { assertEquals } from "@test/assert";
const here = path.dirname(fileURLToPath(import.meta.url));
const repoRoot = path.resolve(here, "..", "..");
const migrationsScript = path.join(here, "migrationsSqlite.ts");
const SEED_STATEMENTS = [
`CREATE TABLE versionMigrations (version TEXT PRIMARY KEY, executedAt INTEGER NOT NULL)`,
`INSERT INTO versionMigrations (version, executedAt) VALUES ('1.21.0', 1750000000000)`,
`CREATE TABLE sites (siteId INTEGER PRIMARY KEY AUTOINCREMENT, subnet TEXT)`,
`INSERT INTO sites (subnet) VALUES ('10.0.0.0/24')`,
`CREATE TABLE roles (roleId INTEGER PRIMARY KEY AUTOINCREMENT, orgId TEXT, isAdmin INTEGER DEFAULT 0, sshSudoMode TEXT DEFAULT 'none')`,
`INSERT INTO roles (orgId, isAdmin, sshSudoMode) VALUES ('org1', 0, 'none')`,
`CREATE TABLE licenseKey (licenseKeyId INTEGER PRIMARY KEY AUTOINCREMENT)`,
`CREATE TABLE targets (targetId INTEGER PRIMARY KEY AUTOINCREMENT, resourceId INTEGER, siteId INTEGER NOT NULL, ip TEXT NOT NULL, method TEXT, port INTEGER NOT NULL, internalPort INTEGER, enabled INTEGER DEFAULT 1, path TEXT, pathMatchType TEXT, rewritePath TEXT, rewritePathType TEXT, priority INTEGER DEFAULT 100, mode TEXT DEFAULT 'http', authToken TEXT)`,
`CREATE TABLE subscriptions (subscriptionId INTEGER PRIMARY KEY AUTOINCREMENT)`,
`CREATE TABLE clients (clientId INTEGER PRIMARY KEY AUTOINCREMENT)`,
`CREATE TABLE orgs (orgId TEXT PRIMARY KEY)`,
`INSERT INTO orgs (orgId) VALUES ('org1')`,
`CREATE TABLE siteResources (siteResourceId INTEGER PRIMARY KEY AUTOINCREMENT)`,
`CREATE TABLE eventStreamingDestinations (destinationId INTEGER PRIMARY KEY AUTOINCREMENT)`,
`CREATE TABLE roleActions (roleId INTEGER, actionId TEXT, orgId TEXT)`,
`CREATE TABLE newt (newtId INTEGER PRIMARY KEY AUTOINCREMENT)`
];
function seedDatabase(dbPath: string) {
const db = new Database(dbPath);
try {
for (const statement of SEED_STATEMENTS) {
db.exec(statement);
}
} finally {
db.close();
}
}
function tableColumns(dbPath: string, tableName: string): string[] {
const db = new Database(dbPath, { readonly: true });
try {
return (
db.prepare(`PRAGMA table_info(${tableName})`).all() as Array<{
name: unknown;
}>
).map((row) => String(row.name));
} finally {
db.close();
}
}
function executedMigrationVersions(dbPath: string): string[] {
const db = new Database(dbPath, { readonly: true });
try {
return (
db.prepare(`SELECT version FROM versionMigrations`).all() as Array<{
version: unknown;
}>
).map((row) => String(row.version));
} finally {
db.close();
}
}
function runMigrations(
workdir: string,
env: Record<string, string> = {}
): {
exitCode: number;
output: string;
} {
const tsconfig = ["tsconfig.json", "tsconfig.oss.json"]
.map((file) => path.join(repoRoot, file))
.find((file) => fs.existsSync(file));
if (!tsconfig) {
throw new Error("No tsconfig found for @server path aliases");
}
const tsxCli = path.join(
repoRoot,
"node_modules",
"tsx",
"dist",
"cli.mjs"
);
if (!fs.existsSync(tsxCli)) {
throw new Error("tsx is not installed; run npm ci first");
}
try {
const output = execFileSync(
process.execPath,
[tsxCli, "--tsconfig", tsconfig, migrationsScript],
{
cwd: workdir,
timeout: 120000,
encoding: "utf8",
env: { ...process.env, NODE_ENV: "test", ...env }
}
);
return { exitCode: 0, output };
} catch (error) {
const output =
error instanceof Error
? (error as Error & { stdout?: unknown }).stdout
: "";
return { exitCode: 1, output: String(output ?? "") };
}
}
function createTestEnvironment(): string {
for (const generated of ["server/build.ts", "server/db/index.ts"]) {
if (!fs.existsSync(path.join(repoRoot, generated))) {
throw new Error(
`Missing ${generated}; run npm run set:oss && npm run set:sqlite first`
);
}
}
const workdir = fs.mkdtempSync(
path.join(os.tmpdir(), "pangolin-backup-test-")
);
fs.mkdirSync(path.join(workdir, "config", "db"), { recursive: true });
fs.copyFileSync(
path.join(repoRoot, "config", "config.example.yml"),
path.join(workdir, "config", "config.yml")
);
const traefikSrc = path.join(repoRoot, "config", "traefik");
if (fs.existsSync(traefikSrc)) {
fs.cpSync(traefikSrc, path.join(workdir, "config", "traefik"), {
recursive: true
});
}
fs.symlinkSync(
path.join(repoRoot, "server"),
path.join(workdir, "server"),
process.platform === "win32" ? "junction" : "dir"
);
return workdir;
}
function testMultipleSequentialMigrations() {
console.log("Running multiple sequential migrations test...");
const workdir = createTestEnvironment();
try {
seedDatabase(path.join(workdir, "config", "db", "db.sqlite"));
const result = runMigrations(workdir);
assertEquals(result.exitCode, 0, "Seeded migrations must run cleanly");
if (!result.output.includes("All migrations completed successfully")) {
throw new Error(
"Seeded migrations did not complete; the backup assertions below would be vacuous"
);
}
const backupsDir = path.join(workdir, "config", "db", "backups");
const backups = fs.existsSync(backupsDir)
? fs
.readdirSync(backupsDir)
.filter((file) => file.endsWith(".sqlite"))
: [];
// Upgrading from 1.21.0 runs 1.22.0 and 1.23.0 -> produces 2 distinct backups
assertEquals(
backups.length,
2,
"Each migration must have its own distinct backup snapshot"
);
const v122Backup = backups.find((file) =>
file.includes("_v1.22.0.sqlite")
);
const v123Backup = backups.find((file) =>
file.includes("_v1.23.0.sqlite")
);
if (!v122Backup || !v123Backup) {
throw new Error(
`Expected backups for v1.22.0 and v1.23.0, found: ${backups.join(", ")}`
);
}
// Verify pre-1.22.0 snapshot state: sites has 'subnet' (not exitNodeSubnet), versions = [1.21.0]
const v122Columns = tableColumns(
path.join(backupsDir, v122Backup),
"sites"
);
assertEquals(
v122Columns.includes("subnet") &&
!v122Columns.includes("exitNodeSubnet"),
true,
"Backup before 1.22.0 must retain pre-1.22.0 schema (sites.subnet)"
);
const v122Versions = executedMigrationVersions(
path.join(backupsDir, v122Backup)
);
assertEquals(
v122Versions.includes("1.21.0") && !v122Versions.includes("1.22.0"),
true,
"Backup before 1.22.0 must only record version 1.21.0"
);
// Verify pre-1.23.0 snapshot state: sites has 'exitNodeSubnet' (1.22.0 applied), newt has no agent
const v123Columns = tableColumns(
path.join(backupsDir, v123Backup),
"sites"
);
assertEquals(
v123Columns.includes("exitNodeSubnet"),
true,
"Backup before 1.23.0 must contain successfully applied 1.22.0 schema (sites.exitNodeSubnet)"
);
const v123NewtCols = tableColumns(
path.join(backupsDir, v123Backup),
"newt"
);
assertEquals(
!v123NewtCols.includes("agent"),
true,
"Backup before 1.23.0 must not contain 1.23.0 schema changes yet"
);
const v123Versions = executedMigrationVersions(
path.join(backupsDir, v123Backup)
);
assertEquals(
v123Versions.includes("1.21.0") && v123Versions.includes("1.22.0"),
true,
"Backup before 1.23.0 must record both 1.21.0 and 1.22.0"
);
} finally {
fs.rmSync(workdir, { recursive: true, force: true });
}
}
function testFailureInLaterMigrationPreservesRestorePoints() {
console.log("Running failure in later migration test...");
const workdir = createTestEnvironment();
try {
const dbPath = path.join(workdir, "config", "db", "db.sqlite");
seedDatabase(dbPath);
// Intentionally drop table 'newt' so migration 1.23.0 fails on ALTER TABLE newt ADD COLUMN agent
const db = new Database(dbPath);
db.exec("DROP TABLE newt;");
db.close();
const result = runMigrations(workdir);
assertEquals(
result.exitCode,
1,
"Migration suite must fail when 1.23.0 errors"
);
const backupsDir = path.join(workdir, "config", "db", "backups");
const backups = fs.existsSync(backupsDir)
? fs
.readdirSync(backupsDir)
.filter((file) => file.endsWith(".sqlite"))
: [];
// Both pre-1.22.0 and pre-1.23.0 backups must exist
assertEquals(
backups.length,
2,
"Backups for earlier successful migration and the failed migration must both exist"
);
const v122Backup = backups.find((file) =>
file.includes("_v1.22.0.sqlite")
);
const v123Backup = backups.find((file) =>
file.includes("_v1.23.0.sqlite")
);
if (!v122Backup || !v123Backup) {
throw new Error(
`Expected restore points for v1.22.0 and v1.23.0, found: ${backups.join(", ")}`
);
}
// Verify pre-1.23.0 backup is a valid restore point with 1.22.0 changes applied
const v123SitesCols = tableColumns(
path.join(backupsDir, v123Backup),
"sites"
);
assertEquals(
v123SitesCols.includes("exitNodeSubnet"),
true,
"Pre-failure restore point must have 1.22.0 changes intact"
);
const v123Versions = executedMigrationVersions(
path.join(backupsDir, v123Backup)
);
assertEquals(
v123Versions.includes("1.22.0"),
true,
"Pre-failure restore point must record successful 1.22.0 migration"
);
} finally {
fs.rmSync(workdir, { recursive: true, force: true });
}
}
function testDisableBackupOnMigration() {
console.log("Running DISABLE_BACKUP_ON_MIGRATION test...");
const workdir = createTestEnvironment();
try {
seedDatabase(path.join(workdir, "config", "db", "db.sqlite"));
const result = runMigrations(workdir, {
DISABLE_BACKUP_ON_MIGRATION: "1"
});
assertEquals(
result.exitCode,
0,
"Migrations must succeed with backups disabled"
);
const backupsDir = path.join(workdir, "config", "db", "backups");
const backups = fs.existsSync(backupsDir)
? fs
.readdirSync(backupsDir)
.filter((file) => file.endsWith(".sqlite"))
: [];
assertEquals(
backups.length,
0,
"No backup files should be created when DISABLE_BACKUP_ON_MIGRATION is set"
);
} finally {
fs.rmSync(workdir, { recursive: true, force: true });
}
}
try {
testMultipleSequentialMigrations();
testFailureInLaterMigrationPreservesRestorePoints();
testDisableBackupOnMigration();
console.log("All backup migration regression tests passed successfully!");
} catch (error) {
console.error("Test failed:", error);
process.exit(1);
}
+12 -6
View File
@@ -5,6 +5,7 @@ import path from "path";
import semver from "semver"; import semver from "semver";
import { versionMigrations } from "../db/sqlite"; import { versionMigrations } from "../db/sqlite";
import { __DIRNAME, APP_PATH, APP_VERSION } from "@server/lib/consts"; import { __DIRNAME, APP_PATH, APP_VERSION } from "@server/lib/consts";
import { formatBackupFileName } from "@server/lib/backupFileName";
import { SqliteError } from "better-sqlite3"; import { SqliteError } from "better-sqlite3";
import fs from "fs"; import fs from "fs";
import { build } from "@server/build"; import { build } from "@server/build";
@@ -106,7 +107,7 @@ async function run() {
await runMigrations(); await runMigrations();
} }
function backupDb() { function backupDb(version?: string) {
// make dir config/db/backups // make dir config/db/backups
const appPath = APP_PATH; const appPath = APP_PATH;
const dbDir = path.join(appPath, "db"); const dbDir = path.join(appPath, "db");
@@ -119,11 +120,10 @@ function backupDb() {
} }
// copy the db.sqlite file to backups // copy the db.sqlite file to backups
// add the date to the filename // add the date and migration version to the filename
const date = new Date(); const fileName = formatBackupFileName(version);
const dateString = `${date.getFullYear()}-${date.getMonth()}-${date.getDate()}_${date.getHours()}-${date.getMinutes()}-${date.getSeconds()}`;
const dbPath = path.join(dbDir, "db.sqlite"); const dbPath = path.join(dbDir, "db.sqlite");
const backupPath = path.join(backupsDir, `db_${dateString}.sqlite`); const backupPath = path.join(backupsDir, fileName);
fs.copyFileSync(dbPath, backupPath); fs.copyFileSync(dbPath, backupPath);
} }
@@ -162,6 +162,12 @@ export async function runMigrations() {
} }
} catch (e) { } catch (e) {
console.error("Error running migrations:", e); console.error("Error running migrations:", e);
if (
process.env.NODE_ENV === "test" ||
process.env.ENVIRONMENT === "test"
) {
throw e;
}
await new Promise((resolve) => await new Promise((resolve) =>
setTimeout(resolve, 1000 * 60 * 60 * 24 * 1) setTimeout(resolve, 1000 * 60 * 60 * 24 * 1)
); );
@@ -196,7 +202,7 @@ async function executeScripts() {
try { try {
if (!process.env.DISABLE_BACKUP_ON_MIGRATION) { if (!process.env.DISABLE_BACKUP_ON_MIGRATION) {
// Backup the database before running the migration // Backup the database before running the migration
backupDb(); backupDb(migration.version);
} }
await migration.run(); await migration.run();
+2 -4
View File
@@ -3,8 +3,6 @@ import { sql } from "drizzle-orm";
const version = "1.23.0"; const version = "1.23.0";
await migration();
export default async function migration() { export default async function migration() {
console.log(`Running setup script ${version}...`); console.log(`Running setup script ${version}...`);
@@ -12,11 +10,11 @@ export default async function migration() {
await db.execute(sql`BEGIN`); await db.execute(sql`BEGIN`);
await db.execute(sql` await db.execute(sql`
ALTER TABLE "newt" ADD COLUMN "agent" varchar; ALTER TABLE "newt" ADD COLUMN IF NOT EXISTS "agent" varchar;
`); `);
await db.execute(sql` await db.execute(sql`
ALTER TABLE "newt" ADD COLUMN "agentVersion" varchar; ALTER TABLE "newt" ADD COLUMN IF NOT EXISTS "agentVersion" varchar;
`); `);
await db.execute(sql`COMMIT`); await db.execute(sql`COMMIT`);
-19
View File
@@ -20,25 +20,6 @@ import { fromZodError } from "zod-validation-error";
export default async function migration() { export default async function migration() {
console.log("Running setup script 1.0.0-beta.9..."); console.log("Running setup script 1.0.0-beta.9...");
// make dir config/db/backups
const appPath = APP_PATH;
const dbDir = path.join(appPath, "db");
const backupsDir = path.join(dbDir, "backups");
// check if the backups directory exists and create it if it doesn't
if (!fs.existsSync(backupsDir)) {
fs.mkdirSync(backupsDir, { recursive: true });
}
// copy the db.sqlite file to backups
// add the date to the filename
const date = new Date();
const dateString = `${date.getFullYear()}-${date.getMonth()}-${date.getDate()}_${date.getHours()}-${date.getMinutes()}-${date.getSeconds()}`;
const dbPath = path.join(dbDir, "db.sqlite");
const backupPath = path.join(backupsDir, `db_${dateString}.sqlite`);
fs.copyFileSync(dbPath, backupPath);
await db.transaction(async (trx) => { await db.transaction(async (trx) => {
try { try {
// Determine which config file exists // Determine which config file exists
+2 -2
View File
@@ -16,13 +16,13 @@ export default async function migration() {
db.transaction(() => { db.transaction(() => {
db.prepare( db.prepare(
` `
ALTER TABLE 'newt' ADD 'agent' text; ALTER TABLE 'newt' ADD COLUMN 'agent' text;
` `
).run(); ).run();
db.prepare( db.prepare(
` `
ALTER TABLE 'newt' ADD 'agentVersion' text; ALTER TABLE 'newt' ADD COLUMN 'agentVersion' text;
` `
).run(); ).run();
})(); })();
@@ -749,12 +749,6 @@ export default function BillingPage() {
return 0; return 0;
}; };
// Get license key count
const getLicenseKeyCount = (): number => {
if (!licenseSubscription?.items) return 0;
return licenseSubscription.items.length;
};
// Check if downgrading to a tier would violate current usage limits // Check if downgrading to a tier would violate current usage limits
const checkLimitViolations = ( const checkLimitViolations = (
targetTier: Tier | "basic" targetTier: Tier | "basic"
@@ -1545,7 +1539,7 @@ export default function BillingPage() {
</SettingsSection> </SettingsSection>
{/* Paid License Keys Section */} {/* Paid License Keys Section */}
{(licenseSubscription || getLicenseKeyCount() > 0) && ( {licenseSubscription && (
<SettingsSection> <SettingsSection>
<SettingsSectionHeader> <SettingsSectionHeader>
<SettingsSectionTitle> <SettingsSectionTitle>
@@ -1561,22 +1555,6 @@ export default function BillingPage() {
<SettingsFormGrid> <SettingsFormGrid>
<SettingsFormCell span="full"> <SettingsFormCell span="full">
<div className="flex flex-col md:flex-row items-start md:items-center justify-between gap-4 border rounded-lg p-4"> <div className="flex flex-col md:flex-row items-start md:items-center justify-between gap-4 border rounded-lg p-4">
<div>
<div className="text-sm text-muted-foreground mb-1">
{t("billingCurrentKeys") ||
"Current Keys"}
</div>
<div className="flex items-baseline gap-2">
<span className="text-3xl font-semibold">
{getLicenseKeyCount()}
</span>
<span className="text-lg">
{getLicenseKeyCount() === 1
? "key"
: "keys"}
</span>
</div>
</div>
<Button <Button
variant="outline" variant="outline"
onClick={handleModifySubscription} onClick={handleModifySubscription}
@@ -1,4 +1,5 @@
import GenerateLicenseKeysTable from "@app/components/GenerateLicenseKeysTable"; import GenerateLicenseKeysTable from "@app/components/GenerateLicenseKeysTable";
import LicenseBillingBanner from "@app/components/LicenseBillingBanner";
import { internal } from "@app/lib/api"; import { internal } from "@app/lib/api";
import { authCookieHeader } from "@app/lib/api/cookies"; import { authCookieHeader } from "@app/lib/api/cookies";
import { ListGeneratedLicenseKeysResponse } from "@server/routers/generatedLicense/types"; import { ListGeneratedLicenseKeysResponse } from "@server/routers/generatedLicense/types";
@@ -26,5 +27,16 @@ export default async function Page({ params }: Props) {
licenseKeys = data.data.data; licenseKeys = data.data.data;
} catch {} } catch {}
return <GenerateLicenseKeysTable licenseKeys={licenseKeys} orgId={orgId} />; const hasNonPersonalLicenseKey = licenseKeys.some(
(key) => key.tier !== "personal"
);
return (
<>
{hasNonPersonalLicenseKey && (
<LicenseBillingBanner orgId={orgId} />
)}
<GenerateLicenseKeysTable licenseKeys={licenseKeys} orgId={orgId} />
</>
);
} }
@@ -76,11 +76,13 @@ export default function PrivateResourceInferencePage() {
}) })
), ),
httpConfigSubdomain: z.string().nullish(), httpConfigSubdomain: z.string().nullish(),
httpConfigDomainId: z.string().nullish(), httpConfigDomainId: z
.string()
.min(1, { message: t("domainRequired") }),
httpConfigFullDomain: z.string().nullish(), httpConfigFullDomain: z.string().nullish(),
ssl: z.boolean().optional() ssl: z.boolean().optional()
}), }),
[] [t]
); );
type FormValues = z.infer<typeof formSchema>; type FormValues = z.infer<typeof formSchema>;
@@ -103,7 +105,7 @@ export default function PrivateResourceInferencePage() {
defaultValues: { defaultValues: {
providers: [], providers: [],
httpConfigSubdomain: siteResource.subdomain ?? null, httpConfigSubdomain: siteResource.subdomain ?? null,
httpConfigDomainId: siteResource.domainId ?? null, httpConfigDomainId: siteResource.domainId ?? "",
httpConfigFullDomain: siteResource.fullDomain ?? null, httpConfigFullDomain: siteResource.fullDomain ?? null,
ssl: siteResource.ssl ?? false ssl: siteResource.ssl ?? false
} }
@@ -289,50 +291,74 @@ export default function PrivateResourceInferencePage() {
</SettingsSubsectionHeader> </SettingsSubsectionHeader>
</SettingsFormCell> </SettingsFormCell>
<SettingsFormCell span="full"> <SettingsFormCell span="full">
<DomainPicker <FormField
key={`inference-domain-${siteResource.id}`} control={form.control}
orgId={siteResource.orgId} name="httpConfigDomainId"
cols={2} render={() => (
hideFreeDomain <FormItem>
defaultSubdomain={ <DomainPicker
httpConfigSubdomain ?? undefined key={`inference-domain-${siteResource.id}`}
} orgId={
defaultDomainId={ siteResource.orgId
httpConfigDomainId ?? undefined }
} cols={2}
defaultFullDomain={ hideFreeDomain
httpConfigFullDomain ?? defaultSubdomain={
undefined httpConfigSubdomain ??
} undefined
onDomainChange={(res) => { }
if (res === null) { defaultDomainId={
form.setValue( httpConfigDomainId ??
"httpConfigSubdomain", undefined
null }
); defaultFullDomain={
form.setValue( httpConfigFullDomain ??
"httpConfigDomainId", undefined
null }
); onDomainChange={(
form.setValue( res
"httpConfigFullDomain", ) => {
null if (res === null) {
); form.setValue(
return; "httpConfigSubdomain",
} null
form.setValue( );
"httpConfigSubdomain", form.setValue(
res.subdomain ?? null "httpConfigDomainId",
); "",
form.setValue( {
"httpConfigDomainId", shouldValidate:
res.domainId true
); }
form.setValue( );
"httpConfigFullDomain", form.setValue(
res.fullDomain "httpConfigFullDomain",
); null
}} );
return;
}
form.setValue(
"httpConfigSubdomain",
res.subdomain ??
null
);
form.setValue(
"httpConfigDomainId",
res.domainId,
{
shouldValidate:
true
}
);
form.setValue(
"httpConfigFullDomain",
res.fullDomain
);
}}
/>
<FormMessage />
</FormItem>
)}
/> />
</SettingsFormCell> </SettingsFormCell>
<SettingsFormCell span="half"> <SettingsFormCell span="half">
@@ -139,6 +139,22 @@ export default function GeneralForm() {
: "Port number should not be set for HTTP resources", : "Port number should not be set for HTTP resources",
path: ["proxyPort"] path: ["proxyPort"]
} }
)
.refine(
(data) => {
if (
["http", "ssh", "rdp", "vnc", "inference"].includes(
resource.mode
)
) {
return !!data.domainId;
}
return true;
},
{
message: t("domainRequired"),
path: ["domainId"]
}
); );
type GeneralFormValues = z.infer<typeof GeneralFormSchema>; type GeneralFormValues = z.infer<typeof GeneralFormSchema>;
@@ -434,63 +450,87 @@ export default function GeneralForm() {
resource.mode resource.mode
) && ( ) && (
<SettingsFormCell span="full"> <SettingsFormCell span="full">
<div id="resource-domain-picker"> <FormField
<DomainPicker control={form.control}
allowWildcard={ name="domainId"
resource.mode !== render={() => (
"inference" <FormItem>
} <div id="resource-domain-picker">
key={ <DomainPicker
resource.resourceId allowWildcard={
} resource.mode !==
orgId={orgId as string} "inference"
cols={2} }
defaultSubdomain={ key={
form.watch( resource.resourceId
"subdomain" }
) ?? undefined orgId={
} orgId as string
defaultDomainId={ }
form.watch( cols={2}
"domainId" defaultSubdomain={
) ?? undefined form.watch(
} "subdomain"
defaultFullDomain={ ) ??
resourceFullDomainName || undefined
undefined }
} defaultDomainId={
onDomainChange={( form.watch(
res "domainId"
) => { ) ??
if (res === null) { undefined
form.setValue( }
"domainId", defaultFullDomain={
undefined resourceFullDomainName ||
); undefined
form.setValue( }
"subdomain", onDomainChange={(
undefined res
); ) => {
setResourceFullDomain( if (
`${resource.ssl ? "https" : "http"}://` res ===
); null
return; ) {
} form.setValue(
form.setValue( "domainId",
"domainId", undefined,
res.domainId {
); shouldValidate:
form.setValue( true
"subdomain", }
res.subdomain ?? );
undefined form.setValue(
); "subdomain",
setResourceFullDomain( undefined
`${resource.ssl ? "https" : "http"}://${toUnicode(res.fullDomain)}` );
); setResourceFullDomain(
}} `${resource.ssl ? "https" : "http"}://`
/> );
</div> return;
}
form.setValue(
"domainId",
res.domainId,
{
shouldValidate:
true
}
);
form.setValue(
"subdomain",
res.subdomain ??
undefined
);
setResourceFullDomain(
`${resource.ssl ? "https" : "http"}://${toUnicode(res.fullDomain)}`
);
}}
/>
</div>
<FormMessage />
</FormItem>
)}
/>
</SettingsFormCell> </SettingsFormCell>
)} )}
{!["tcp", "udp", "inference"].includes( {!["tcp", "udp", "inference"].includes(
+39
View File
@@ -0,0 +1,39 @@
"use client";
import { Globe, CreditCard, ArrowRight } from "lucide-react";
import { useTranslations } from "next-intl";
import Link from "next/link";
import { Button } from "@app/components/ui/button";
import DismissableBanner from "./DismissableBanner";
type LicenseBillingBannerProps = {
orgId: string;
};
export const LicenseBillingBanner = ({ orgId }: LicenseBillingBannerProps) => {
const t = useTranslations();
return (
<DismissableBanner
storageKey="license-billing-banner-dismissed"
version={1}
title={t("licenseBillingBannerTitle")}
titleIcon={<Globe className="w-5 h-5 text-primary" />}
description={t("licenseBillingBannerDescription")}
>
<Link href={`/${orgId}/settings/billing`}>
<Button
variant="outline"
size="sm"
className="gap-2 hover:bg-primary/10 hover:border-primary/50 transition-colors"
>
<CreditCard className="w-4 h-4" />
{t("licenseBillingBannerButton")}
<ArrowRight className="w-4 h-4" />
</Button>
</Link>
</DismissableBanner>
);
};
export default LicenseBillingBanner;
+1 -1
View File
@@ -330,7 +330,7 @@ export default function NewPricingLicenseForm({
cols={2} cols={2}
/> />
<a <a
href="https://pangolin.net/pricing" href="https://pangolin.net/pricing#Self-Hosted"
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
className="text-sm text-primary hover:underline" className="text-sm text-primary hover:underline"
+12 -7
View File
@@ -63,6 +63,12 @@ export default function SiteInfoCard({}: SiteInfoCardProps) {
) : null; ) : null;
if (site.type === "newt") { if (site.type === "newt") {
// agent and agentVersion were added after newtVersion, so a
// site still running an older Newt reports only newtVersion.
// Without these fallbacks the badge renders with no label and
// no version at all.
const agentLabel = site.agent == "cli" ? "Pangolin CLI" : "Newt";
const agentVersion = site.agentVersion ?? site.newtVersion;
return ( return (
<Alert> <Alert>
<AlertDescription> <AlertDescription>
@@ -79,13 +85,12 @@ export default function SiteInfoCard({}: SiteInfoCardProps) {
<InfoSection> <InfoSection>
<InfoSectionTitle>{t("agent")}</InfoSectionTitle> <InfoSectionTitle>{t("agent")}</InfoSectionTitle>
<InfoSectionContent> <InfoSectionContent>
{site.agent == "newt" ? "Newt" : null} <div className="flex items-center space-x-1">
{site.agent == "cli" <span>{agentLabel}</span>
? "Pangolin CLI" {agentVersion && (
: null}{" "} <span>v{agentVersion}</span>
{site.agentVersion )}
? `v${site.agentVersion}` </div>
: "-"}
</InfoSectionContent> </InfoSectionContent>
</InfoSection> </InfoSection>
{endpointSection} {endpointSection}
+16 -14
View File
@@ -373,7 +373,7 @@ export default function SitesTable({
accessorKey: "type", accessorKey: "type",
friendlyName: t("agent"), friendlyName: t("agent"),
header: () => { header: () => {
return <span className="p-3">{t("type")}</span>; return <span className="p-3">{t("agent")}</span>;
}, },
cell: ({ row }) => { cell: ({ row }) => {
const originalRow = row.original; const originalRow = row.original;
@@ -386,25 +386,27 @@ export default function SitesTable({
); );
if (originalRow.type === "newt") { if (originalRow.type === "newt") {
if (!originalRow.agent) { if (!originalRow.agent && !originalRow.newtVersion) {
// it has not checked in yet
return <span>-</span>; return <span>-</span>;
} }
// agent and agentVersion were added after newtVersion, so a
// site still running an older Newt reports only newtVersion.
// Without these fallbacks the badge renders with no label and
// no version at all.
const agentLabel =
originalRow.agent == "cli"
? "Pangolin CLI"
: "Newt";
const agentVersion =
originalRow.agentVersion ?? originalRow.newtVersion;
return ( return (
<div className="flex items-center space-x-1"> <div className="flex items-center space-x-1">
<Badge variant="secondary"> <Badge variant="secondary">
<div className="flex items-center space-x-1"> <div className="flex items-center space-x-1">
<span> <span>{agentLabel}</span>
{originalRow.agent == "newt" {agentVersion && (
? "Newt" <span>v{agentVersion}</span>
: null}
{originalRow.agent == "cli"
? "Pangolin CLI"
: null}
</span>
{originalRow.agentVersion && (
<span>
v{originalRow.agentVersion}
</span>
)} )}
</div> </div>
</Badge> </Badge>
+1 -1
View File
@@ -150,7 +150,7 @@ Type=simple
User=root User=root
Group=root Group=root
EnvironmentFile=/etc/pangolin/pangolin-site.env EnvironmentFile=/etc/pangolin/pangolin-site.env
ExecStart=/home/owen/fossorial/cli/bin/pangolin up site ExecStart=/usr/local/bin/pangolin up site
Restart=always Restart=always
RestartSec=2 RestartSec=2
UMask=0077 UMask=0077
+7
View File
@@ -523,6 +523,13 @@ export function createCreateFormSchema(t: TranslateFn) {
}); });
} }
} }
if (data.mode === "inference" && !data.httpConfigDomainId) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: t("domainRequired"),
path: ["httpConfigDomainId"]
});
}
}); });
} }