Compare commits

...

40 Commits

Author SHA1 Message Date
Owen Schwartz 74d10acad9 Merge pull request #3704 from Blacks-Army/feat/http-method-rules
Add HTTP method matching to resource rules
2026-09-21 11:44:54 -04:00
Owen 887c6e4e8b Remove linting until 7.1 release 2026-09-21 11:10:37 -04:00
Owen f71b9e7c54 Merge branch 'dev' of github.com:fosrl/pangolin into dev 2026-09-21 10:48:32 -04:00
Owen Schwartz 5dd4c54ad0 Merge pull request #3750 from kah-ja/fix/resource-auth-redirect-scheme
Only accept http(s) targets for the resource auth redirect
2026-09-21 10:45:03 -04:00
Owen 6f3e0cf5a6 Time is un utc 2026-09-21 10:18:45 -04:00
miloschwartz d4488ec125 update ios identifiers 2026-09-21 09:57:03 -04:00
Owen Schwartz b8572524a4 Merge pull request #3790 from Bolex80/fix/resource-auth-idp-variant
fix: use idp variant for resource auth login page icons
2026-09-21 09:45:41 -04:00
Owen b054f90149 Merge branch 'main' into dev 2026-09-21 09:14:51 -04:00
Owen c290239894 Use postgresql
Fix #3794
2026-09-21 09:14:16 -04:00
Blacks-Army 8e2f9ea5ef Add HTTP method matching to resource rules
Resolves #1408.

A rule with match "METHOD" carries a comma-separated list of HTTP
methods in its value, e.g. "POST,PUT", and applies when the request
method is in that list. This makes it possible to leave GET public
while sending POST and PUT to auth, which rules could not express
before because both share the same path.

No new columns: the methods live in the existing rule value, so this
needs no migration and every existing rule keeps working unchanged.

The UI offers the ten registered methods. Blueprints and the API
accept any method token, so extension methods such as the WebDAV verbs
can be targeted too, and the UI preserves them when a rule set that
way is edited later.
2026-09-19 20:00:01 +02:00
Alex Benthem 032eeb2656 fix: use idp variant for resource auth login page icons
The resource auth login page (auth/resource/[resourceGuid]) loads IdPs via
the global /idp list in the non-saas/non-org path and passed idp.type as the
icon variant. Since type is always 'oidc' for OIDC-backed providers (Google,
Azure), the branded logos were never selected, showing the generic OIDC icon
instead.

Use idp.variant (with type as fallback), matching the fix already applied to
the main login page (auth/login) and org login page (auth/org/[orgId]).

Fixes #3631
2026-09-19 12:28:16 +02:00
miloschwartz 5ca08d71f0 change restart site toast text 2026-09-17 13:31:00 -04:00
Owen 1f453dc04f Send out of address space errors to sites and clients 2026-09-17 09:24:15 -04:00
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
Jan Kahmen a7d4745f93 Only accept http(s) targets for the resource auth redirect
The resource auth page copies the redirect query parameter into
redirectUrl when its host matches the resource host
(src/app/auth/resource/[resourceGuid]/page.tsx:121-150). URL parses a
host out of every scheme that uses "//", so a target such as
javascript://resource-host/... passes that comparison. The value is
handed to ResourceAuthPortal as the redirect prop and assigned to
window.location.href after a successful login
(src/components/ResourceAuthPortal.tsx:213,247,281).

Parse the target once and require http: or https: before the host
comparisons. The three branches that assigned the same value are folded
into one condition; the accepted set of http(s) targets is unchanged.
2026-09-15 10:46:05 +00:00
45 changed files with 968 additions and 287 deletions
+18 -11
View File
@@ -3,18 +3,25 @@ name: ESLint
permissions: permissions:
contents: read contents: read
# Disabled from running on PRs: typescript-eslint does not yet support
# TypeScript 7 (which this repo is on), so eslint currently crashes on
# every run. Kept as workflow_dispatch so it can still be triggered
# manually, and re-enabled on pull_request once upstream support lands.
# https://github.com/typescript-eslint/typescript-eslint/issues/10940
# on:
# pull_request:
# paths:
# - '**/*.js'
# - '**/*.jsx'
# - '**/*.ts'
# - '**/*.tsx'
# - '.eslintrc*'
# - 'package.json'
# - 'yarn.lock'
# - 'pnpm-lock.yaml'
# - 'package-lock.json'
on: on:
pull_request: workflow_dispatch:
paths:
- '**/*.js'
- '**/*.jsx'
- '**/*.ts'
- '**/*.tsx'
- '.eslintrc*'
- 'package.json'
- 'yarn.lock'
- 'pnpm-lock.yaml'
- 'package-lock.json'
jobs: jobs:
Linter: Linter:
+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
+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:
+9 -3
View File
@@ -132,7 +132,7 @@
"siteRestartDialogMessage": "Are you sure you want to restart the WireGuard tunnel for <b>{name}</b>? The site will briefly lose connectivity.", "siteRestartDialogMessage": "Are you sure you want to restart the WireGuard tunnel for <b>{name}</b>? The site will briefly lose connectivity.",
"siteRestartWarning": "The site will briefly disconnect while the tunnel restarts.", "siteRestartWarning": "The site will briefly disconnect while the tunnel restarts.",
"siteRestarted": "Site restarted", "siteRestarted": "Site restarted",
"siteRestartedDescription": "The WireGuard tunnel has been restarted.", "siteRestartedDescription": "The site has been restarted.",
"siteErrorRestart": "Failed to restart site", "siteErrorRestart": "Failed to restart site",
"siteErrorRestartDescription": "An error occurred while restarting the site.", "siteErrorRestartDescription": "An error occurred while restarting the site.",
"siteSettingDescription": "Configure the settings on the site", "siteSettingDescription": "Configure the settings on the site",
@@ -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",
@@ -831,7 +834,7 @@
"rulesErrorDuplicatePriorityDescription": "Each rule must have a unique priority number.", "rulesErrorDuplicatePriorityDescription": "Each rule must have a unique priority number.",
"rulesErrorValidation": "Invalid rules", "rulesErrorValidation": "Invalid rules",
"rulesErrorValidationRuleDescription": "Rule {ruleNumber}: {message}", "rulesErrorValidationRuleDescription": "Rule {ruleNumber}: {message}",
"rulesErrorInvalidMatchTypeDescription": "Select a valid match type (path, IP, CIDR, country, region, or ASN).", "rulesErrorInvalidMatchTypeDescription": "Select a valid match type (path, IP, CIDR, country, region, ASN, or method).",
"rulesErrorValueRequired": "Enter a value for this rule.", "rulesErrorValueRequired": "Enter a value for this rule.",
"rulesErrorInvalidCountry": "Invalid country", "rulesErrorInvalidCountry": "Invalid country",
"rulesErrorInvalidCountryDescription": "Select a valid country.", "rulesErrorInvalidCountryDescription": "Select a valid country.",
@@ -4397,5 +4400,8 @@
"sessionToolbarShow": "Show toolbar", "sessionToolbarShow": "Show toolbar",
"sessionToolbarHide": "Hide toolbar", "sessionToolbarHide": "Hide toolbar",
"actionUpdateSiteApprovals": "Update Site Approvals", "actionUpdateSiteApprovals": "Update Site Approvals",
"check": "Check" "check": "Check",
"rulesErrorInvalidMethod": "Invalid HTTP method",
"rulesErrorInvalidMethodDescription": "Select at least one HTTP method.",
"rulesSelectMethods": "Select methods"
} }
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

+76 -47
View File
@@ -4,42 +4,18 @@
"iPad2,2": "iPad 2", "iPad2,2": "iPad 2",
"iPad2,3": "iPad 2", "iPad2,3": "iPad 2",
"iPad2,4": "iPad 2", "iPad2,4": "iPad 2",
"iPad3,1": "iPad 3rd Gen",
"iPad3,3": "iPad 3rd Gen",
"iPad3,2": "iPad 3rd Gen",
"iPad3,4": "iPad 4th Gen",
"iPad3,5": "iPad 4th Gen",
"iPad3,6": "iPad 4th Gen",
"iPad6,11": "iPad 9.7 5th Gen",
"iPad6,12": "iPad 9.7 5th Gen",
"iPad7,5": "iPad 9.7 6th Gen",
"iPad7,6": "iPad 9.7 6th Gen",
"iPad7,11": "iPad 10.2 7th Gen",
"iPad7,12": "iPad 10.2 7th Gen",
"iPad11,6": "iPad 10.2 8th Gen",
"iPad11,7": "iPad 10.2 8th Gen",
"iPad12,1": "iPad 10.2 9th Gen",
"iPad12,2": "iPad 10.2 9th Gen",
"iPad13,18": "iPad 10.9 10th Gen",
"iPad13,19": "iPad 10.9 10th Gen",
"iPad4,1": "iPad Air",
"iPad4,2": "iPad Air",
"iPad4,3": "iPad Air",
"iPad5,3": "iPad Air 2",
"iPad5,4": "iPad Air 2",
"iPad11,3": "iPad Air 3rd Gen",
"iPad11,4": "iPad Air 3rd Gen",
"iPad13,1": "iPad Air 4th Gen",
"iPad13,2": "iPad Air 4th Gen",
"iPad13,16": "iPad Air 5th Gen",
"iPad13,17": "iPad Air 5th Gen",
"iPad14,8": "iPad Air M2 11",
"iPad14,9": "iPad Air M2 11",
"iPad14,10": "iPad Air M2 13",
"iPad14,11": "iPad Air M2 13",
"iPad2,5": "iPad mini", "iPad2,5": "iPad mini",
"iPad2,6": "iPad mini", "iPad2,6": "iPad mini",
"iPad2,7": "iPad mini", "iPad2,7": "iPad mini",
"iPad3,1": "iPad 3rd Gen",
"iPad3,2": "iPad 3rd Gen",
"iPad3,3": "iPad 3rd Gen",
"iPad3,4": "iPad 4th Gen",
"iPad3,5": "iPad 4th Gen",
"iPad3,6": "iPad 4th Gen",
"iPad4,1": "iPad Air",
"iPad4,2": "iPad Air",
"iPad4,3": "iPad Air",
"iPad4,4": "iPad mini 2", "iPad4,4": "iPad mini 2",
"iPad4,5": "iPad mini 2", "iPad4,5": "iPad mini 2",
"iPad4,6": "iPad mini 2", "iPad4,6": "iPad mini 2",
@@ -48,18 +24,22 @@
"iPad4,9": "iPad mini 3", "iPad4,9": "iPad mini 3",
"iPad5,1": "iPad mini 4", "iPad5,1": "iPad mini 4",
"iPad5,2": "iPad mini 4", "iPad5,2": "iPad mini 4",
"iPad11,1": "iPad mini 5th Gen", "iPad5,3": "iPad Air 2",
"iPad11,2": "iPad mini 5th Gen", "iPad5,4": "iPad Air 2",
"iPad14,1": "iPad mini 6th Gen",
"iPad14,2": "iPad mini 6th Gen",
"iPad6,7": "iPad Pro 12.9",
"iPad6,8": "iPad Pro 12.9",
"iPad6,3": "iPad Pro 9.7", "iPad6,3": "iPad Pro 9.7",
"iPad6,4": "iPad Pro 9.7", "iPad6,4": "iPad Pro 9.7",
"iPad7,3": "iPad Pro 10.5", "iPad6,7": "iPad Pro 12.9",
"iPad7,4": "iPad Pro 10.5", "iPad6,8": "iPad Pro 12.9",
"iPad6,11": "iPad 9.7 5th Gen",
"iPad6,12": "iPad 9.7 5th Gen",
"iPad7,1": "iPad Pro 12.9", "iPad7,1": "iPad Pro 12.9",
"iPad7,2": "iPad Pro 12.9", "iPad7,2": "iPad Pro 12.9",
"iPad7,3": "iPad Pro 10.5",
"iPad7,4": "iPad Pro 10.5",
"iPad7,5": "iPad 9.7 6th Gen",
"iPad7,6": "iPad 9.7 6th Gen",
"iPad7,11": "iPad 10.2 7th Gen",
"iPad7,12": "iPad 10.2 7th Gen",
"iPad8,1": "iPad Pro 11", "iPad8,1": "iPad Pro 11",
"iPad8,2": "iPad Pro 11", "iPad8,2": "iPad Pro 11",
"iPad8,3": "iPad Pro 11", "iPad8,3": "iPad Pro 11",
@@ -72,6 +52,16 @@
"iPad8,10": "iPad Pro 11", "iPad8,10": "iPad Pro 11",
"iPad8,11": "iPad Pro 12.9", "iPad8,11": "iPad Pro 12.9",
"iPad8,12": "iPad Pro 12.9", "iPad8,12": "iPad Pro 12.9",
"iPad11,1": "iPad mini 5th Gen",
"iPad11,2": "iPad mini 5th Gen",
"iPad11,3": "iPad Air 3rd Gen",
"iPad11,4": "iPad Air 3rd Gen",
"iPad11,6": "iPad 10.2 8th Gen",
"iPad11,7": "iPad 10.2 8th Gen",
"iPad12,1": "iPad 10.2 9th Gen",
"iPad12,2": "iPad 10.2 9th Gen",
"iPad13,1": "iPad Air 4th Gen",
"iPad13,2": "iPad Air 4th Gen",
"iPad13,4": "iPad Pro 11", "iPad13,4": "iPad Pro 11",
"iPad13,5": "iPad Pro 11", "iPad13,5": "iPad Pro 11",
"iPad13,6": "iPad Pro 11", "iPad13,6": "iPad Pro 11",
@@ -80,14 +70,40 @@
"iPad13,9": "iPad Pro 12.9", "iPad13,9": "iPad Pro 12.9",
"iPad13,10": "iPad Pro 12.9", "iPad13,10": "iPad Pro 12.9",
"iPad13,11": "iPad Pro 12.9", "iPad13,11": "iPad Pro 12.9",
"iPad13,16": "iPad Air M1 5th Gen",
"iPad13,17": "iPad Air M1 5th Gen",
"iPad13,18": "iPad 10.9 10th Gen",
"iPad13,19": "iPad 10.9 10th Gen",
"iPad14,1": "iPad mini 6th Gen",
"iPad14,2": "iPad mini 6th Gen",
"iPad14,3": "iPad Pro 11", "iPad14,3": "iPad Pro 11",
"iPad14,4": "iPad Pro 11", "iPad14,4": "iPad Pro 11",
"iPad14,5": "iPad Pro 12.9", "iPad14,5": "iPad Pro 12.9",
"iPad14,6": "iPad Pro 12.9", "iPad14,6": "iPad Pro 12.9",
"iPad14,8": "iPad Air M2 11",
"iPad14,9": "iPad Air M2 11",
"iPad14,10": "iPad Air M2 13",
"iPad14,11": "iPad Air M2 13",
"iPad15,3": "iPad Air M3 11",
"iPad15,4": "iPad Air M3 11",
"iPad15,5": "iPad Air M3 13",
"iPad15,6": "iPad Air M3 13",
"iPad15,7": "iPad A16 - 11th Gen",
"iPad15,8": "iPad A16 - 11th Gen",
"iPad16,1": "iPad mini A17 Pro - 7th Gen",
"iPad16,2": "iPad mini A17 Pro - 7th Gen",
"iPad16,3": "iPad Pro M4 11", "iPad16,3": "iPad Pro M4 11",
"iPad16,4": "iPad Pro M4 11", "iPad16,4": "iPad Pro M4 11",
"iPad16,5": "iPad Pro M4 13", "iPad16,5": "iPad Pro M4 13",
"iPad16,6": "iPad Pro M4 13", "iPad16,6": "iPad Pro M4 13",
"iPad16,8": "iPad Air M4 11",
"iPad16,9": "iPad Air M4 11",
"iPad16,10": "iPad Air M4 13",
"iPad16,11": "iPad Air M4 13",
"iPad17,1": "iPad Pro M5 11",
"iPad17,2": "iPad Pro M5 11",
"iPad17,3": "iPad Pro M5 13",
"iPad17,4": "iPad Pro M5 13",
"iPhone1,1": "iPhone", "iPhone1,1": "iPhone",
"iPhone1,2": "iPhone 3G", "iPhone1,2": "iPhone 3G",
"iPhone2,1": "iPhone 3GS", "iPhone2,1": "iPhone 3GS",
@@ -101,20 +117,20 @@
"iPhone5,4": "iPhone 5c", "iPhone5,4": "iPhone 5c",
"iPhone6,1": "iPhone 5s", "iPhone6,1": "iPhone 5s",
"iPhone6,2": "iPhone 5s", "iPhone6,2": "iPhone 5s",
"iPhone7,2": "iPhone 6",
"iPhone7,1": "iPhone 6 Plus", "iPhone7,1": "iPhone 6 Plus",
"iPhone7,2": "iPhone 6",
"iPhone8,1": "iPhone 6s", "iPhone8,1": "iPhone 6s",
"iPhone8,2": "iPhone 6s Plus", "iPhone8,2": "iPhone 6s Plus",
"iPhone8,4": "iPhone SE", "iPhone8,4": "iPhone SE",
"iPhone9,1": "iPhone 7", "iPhone9,1": "iPhone 7",
"iPhone9,3": "iPhone 7",
"iPhone9,2": "iPhone 7 Plus", "iPhone9,2": "iPhone 7 Plus",
"iPhone9,3": "iPhone 7",
"iPhone9,4": "iPhone 7 Plus", "iPhone9,4": "iPhone 7 Plus",
"iPhone10,1": "iPhone 8", "iPhone10,1": "iPhone 8",
"iPhone10,4": "iPhone 8",
"iPhone10,2": "iPhone 8 Plus", "iPhone10,2": "iPhone 8 Plus",
"iPhone10,5": "iPhone 8 Plus",
"iPhone10,3": "iPhone X", "iPhone10,3": "iPhone X",
"iPhone10,4": "iPhone 8",
"iPhone10,5": "iPhone 8 Plus",
"iPhone10,6": "iPhone X", "iPhone10,6": "iPhone X",
"iPhone11,2": "iPhone Xs", "iPhone11,2": "iPhone Xs",
"iPhone11,6": "iPhone Xs Max", "iPhone11,6": "iPhone Xs Max",
@@ -127,10 +143,10 @@
"iPhone13,2": "iPhone 12", "iPhone13,2": "iPhone 12",
"iPhone13,3": "iPhone 12 Pro", "iPhone13,3": "iPhone 12 Pro",
"iPhone13,4": "iPhone 12 Pro Max", "iPhone13,4": "iPhone 12 Pro Max",
"iPhone14,4": "iPhone 13 mini",
"iPhone14,5": "iPhone 13",
"iPhone14,2": "iPhone 13 Pro", "iPhone14,2": "iPhone 13 Pro",
"iPhone14,3": "iPhone 13 Pro Max", "iPhone14,3": "iPhone 13 Pro Max",
"iPhone14,4": "iPhone 13 mini",
"iPhone14,5": "iPhone 13",
"iPhone14,6": "iPhone SE", "iPhone14,6": "iPhone SE",
"iPhone14,7": "iPhone 14", "iPhone14,7": "iPhone 14",
"iPhone14,8": "iPhone 14 Plus", "iPhone14,8": "iPhone 14 Plus",
@@ -140,6 +156,19 @@
"iPhone15,5": "iPhone 15 Plus", "iPhone15,5": "iPhone 15 Plus",
"iPhone16,1": "iPhone 15 Pro", "iPhone16,1": "iPhone 15 Pro",
"iPhone16,2": "iPhone 15 Pro Max", "iPhone16,2": "iPhone 15 Pro Max",
"iPhone17,1": "iPhone 16 Pro",
"iPhone17,2": "iPhone 16 Pro Max",
"iPhone17,3": "iPhone 16",
"iPhone17,4": "iPhone 16 Plus",
"iPhone17,5": "iPhone 16e",
"iPhone18,1": "iPhone 17 Pro",
"iPhone18,2": "iPhone 17 Pro Max",
"iPhone18,3": "iPhone 17",
"iPhone18,4": "iPhone Air",
"iPhone18,5": "iPhone 17e",
"iPhone19,2": "iPhone 18 Pro",
"iPhone19,3": "iPhone 18 Pro Max",
"iPhone19,7": "iPhone 18 Pro Max",
"iPod1,1": "iPod touch Original", "iPod1,1": "iPod touch Original",
"iPod2,1": "iPod touch 2nd", "iPod2,1": "iPod touch 2nd",
"iPod3,1": "iPod touch 3rd Gen", "iPod3,1": "iPod touch 3rd Gen",
+2
View File
@@ -1137,6 +1137,7 @@ export const resourceRules = pgTable("resourceRules", {
| "COUNTRY_IS_NOT" | "COUNTRY_IS_NOT"
| "ASN" | "ASN"
| "REGION" | "REGION"
| "METHOD"
>() >()
.notNull(), // CIDR, PATH, IP .notNull(), // CIDR, PATH, IP
value: varchar("value").notNull() value: varchar("value").notNull()
@@ -1161,6 +1162,7 @@ export const resourcePolicyRules = pgTable("resourcePolicyRules", {
| "COUNTRY_IS_NOT" | "COUNTRY_IS_NOT"
| "ASN" | "ASN"
| "REGION" | "REGION"
| "METHOD"
>() >()
.notNull(), .notNull(),
value: varchar("value").notNull() value: varchar("value").notNull()
+2
View File
@@ -1409,6 +1409,7 @@ export const resourceRules = sqliteTable("resourceRules", {
| "COUNTRY_IS_NOT" | "COUNTRY_IS_NOT"
| "ASN" | "ASN"
| "REGION" | "REGION"
| "METHOD"
>() >()
.notNull(), // CIDR, PATH, IP .notNull(), // CIDR, PATH, IP
value: text("value").notNull() value: text("value").notNull()
@@ -1465,6 +1466,7 @@ export const resourcePolicyRules = sqliteTable("resourcePolicyRules", {
| "COUNTRY_IS_NOT" | "COUNTRY_IS_NOT"
| "ASN" | "ASN"
| "REGION" | "REGION"
| "METHOD"
>() >()
.notNull(), .notNull(),
value: text("value").notNull() value: text("value").notNull()
@@ -235,7 +235,7 @@ export const AlertNotification = (props: AlertNotificationProps) => {
} }
] ]
: []), : []),
{ label: "Time", value: new Date().toUTCString() }, { label: "Time (UTC)", value: new Date().toUTCString() },
...dataItems ...dataItems
]; ];
@@ -265,8 +265,8 @@ export const AlertNotification = (props: AlertNotificationProps) => {
</EmailText> </EmailText>
{isTestAlert && ( {isTestAlert && (
<EmailText> <EmailText>
This is a test alert. No action is required, This is a test alert. No action is required, and
and no real event has occurred. no real event has occurred.
</EmailText> </EmailText>
)} )}
@@ -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"
+101
View File
@@ -0,0 +1,101 @@
import { 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"
);
}
// Run all tests
try {
testMonthIsOneIndexed();
testEveryFieldIsZeroPadded();
testNamesSortChronologically();
console.log("All tests passed successfully!");
} catch (error) {
console.error("Test failed:", error);
process.exit(1);
}
+28
View File
@@ -0,0 +1,28 @@
/**
* 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}`;
}
+15 -1
View File
@@ -48,7 +48,13 @@ import { defaultRoleAllowedActions } from "@server/routers/role/createRole";
import { pickPort } from "@server/routers/target/helpers"; import { pickPort } from "@server/routers/target/helpers";
import { and, asc, eq, isNotNull, ne } from "drizzle-orm"; import { and, asc, eq, isNotNull, ne } from "drizzle-orm";
import { tierMatrix } from "../billing/tierMatrix"; import { tierMatrix } from "../billing/tierMatrix";
import { isValidCIDR, isValidIP, isValidUrlGlobPattern } from "../validators"; import {
isValidCIDR,
isValidHttpMethodList,
isValidIP,
isValidUrlGlobPattern,
parseHttpMethodList
} from "../validators";
import { Config, isTargetsOnlyResource, TargetData } from "./types"; import { Config, isTargetsOnlyResource, TargetData } from "./types";
import { getOrCreateLabelIds, syncResourceLabels } from "./labels"; import { getOrCreateLabelIds, syncResourceLabels } from "./labels";
import { findOrgUsersByIdentifier } from "./findOrgUser"; import { findOrgUsersByIdentifier } from "./findOrgUser";
@@ -1453,6 +1459,10 @@ function getRuleValue(match: string, value: string) {
if (match === "COUNTRY" || match === "COUNTRY_IS_NOT") { if (match === "COUNTRY" || match === "COUNTRY_IS_NOT") {
return value.toUpperCase(); return value.toUpperCase();
} }
// normalize the method list so it is stored as "POST,PUT"
if (match === "METHOD") {
return parseHttpMethodList(value).join(",");
}
return value; return value;
} }
@@ -1473,6 +1483,10 @@ function validateRule(rule: any) {
if (!isValidRegionId(rule.value)) { if (!isValidRegionId(rule.value)) {
throw new Error(`Invalid region ID provided: ${rule.value}`); throw new Error(`Invalid region ID provided: ${rule.value}`);
} }
} else if (rule.match === "method") {
if (!isValidHttpMethodList(rule.value)) {
throw new Error(`Invalid HTTP method provided: ${rule.value}`);
}
} }
} }
+16 -12
View File
@@ -19,7 +19,13 @@ import logger from "@server/logger";
import { getUniqueResourcePolicyName } from "@server/db/names"; import { getUniqueResourcePolicyName } from "@server/db/names";
import { hashPassword } from "@server/auth/password"; import { hashPassword } from "@server/auth/password";
import { idpExistsForOrg } from "@server/lib/idp/idpExistsForOrg"; import { idpExistsForOrg } from "@server/lib/idp/idpExistsForOrg";
import { isValidCIDR, isValidIP, isValidUrlGlobPattern } from "../validators"; import {
isValidCIDR,
isValidHttpMethodList,
isValidIP,
isValidUrlGlobPattern,
ResourceRuleMatchType
} from "../validators";
import { isLicensedOrSubscribed } from "#dynamic/lib/isLicencedOrSubscribed"; import { isLicensedOrSubscribed } from "#dynamic/lib/isLicencedOrSubscribed";
import { tierMatrix } from "../billing/tierMatrix"; import { tierMatrix } from "../billing/tierMatrix";
import { findOrgUsersByIdentifier } from "./findOrgUser"; import { findOrgUsersByIdentifier } from "./findOrgUser";
@@ -66,6 +72,13 @@ export async function updateResourcePolicies(
throw new Error( throw new Error(
`Invalid URL glob pattern provided in resource policy '${policyNiceId}': ${rule.value}` `Invalid URL glob pattern provided in resource policy '${policyNiceId}': ${rule.value}`
); );
} else if (
rule.match === "method" &&
!isValidHttpMethodList(rule.value)
) {
throw new Error(
`Invalid HTTP method provided in resource policy '${policyNiceId}': ${rule.value}`
);
} }
} }
@@ -339,17 +352,8 @@ function getRuleAction(input: string): "ACCEPT" | "DROP" | "PASS" {
return "PASS"; return "PASS";
} }
function getRuleMatch( function getRuleMatch(input: string): ResourceRuleMatchType {
input: string return input.toUpperCase() as ResourceRuleMatchType;
): "CIDR" | "IP" | "PATH" | "COUNTRY" | "COUNTRY_IS_NOT" | "ASN" | "REGION" {
return input.toUpperCase() as
| "CIDR"
| "IP"
| "PATH"
| "COUNTRY"
| "COUNTRY_IS_NOT"
| "ASN"
| "REGION";
} }
async function syncRolePolicies( async function syncRolePolicies(
+24 -1
View File
@@ -3,6 +3,7 @@ import { existsSync } from "node:fs";
import { portRangeStringSchema } from "@server/lib/ip"; import { portRangeStringSchema } from "@server/lib/ip";
import { MaintenanceSchema } from "#dynamic/lib/blueprints/MaintenanceSchema"; import { MaintenanceSchema } from "#dynamic/lib/blueprints/MaintenanceSchema";
import { isValidRegionId } from "@server/db/regions"; import { isValidRegionId } from "@server/db/regions";
import { isValidHttpMethodList } from "@server/lib/validators";
import { wildcardSubdomainSchema } from "@server/lib/schemas"; import { wildcardSubdomainSchema } from "@server/lib/schemas";
import config from "@server/lib/config"; import config from "@server/lib/config";
import { import {
@@ -127,7 +128,16 @@ export const AuthSchema = z.object({
export const RuleSchema = z export const RuleSchema = z
.object({ .object({
action: z.enum(["allow", "deny", "pass"]), action: z.enum(["allow", "deny", "pass"]),
match: z.enum(["cidr", "path", "ip", "country", "country_is_not", "asn", "region"]), match: z.enum([
"cidr",
"path",
"ip",
"country",
"country_is_not",
"asn",
"region",
"method"
]),
value: z.coerce.string(), value: z.coerce.string(),
priority: z.int().optional(), priority: z.int().optional(),
enabled: z.boolean().optional().default(true) enabled: z.boolean().optional().default(true)
@@ -207,6 +217,19 @@ export const RuleSchema = z
message: message:
"Value must be a valid UN M.49 region or subregion ID when match is 'region'" "Value must be a valid UN M.49 region or subregion ID when match is 'region'"
} }
)
.refine(
(rule) => {
if (rule.match === "method") {
return isValidHttpMethodList(rule.value);
}
return true;
},
{
path: ["value"],
message:
"Value must be a comma-separated list of HTTP methods when match is 'method', e.g. 'POST,PUT'"
}
); );
export const HeaderSchema = z.object({ export const HeaderSchema = z.object({
+41 -2
View File
@@ -1,9 +1,10 @@
import { import {
getResourceRuleValueValidationError, getResourceRuleValueValidationError,
isValidDomain, isValidDomain,
isValidUrlGlobPattern isValidUrlGlobPattern,
parseHttpMethodList
} from "./validators"; } from "./validators";
import { assertEquals } from "@test/assert"; import { assertEquals, assertEqualsObj } from "@test/assert";
function runTests() { function runTests() {
console.log("Running domain validation tests..."); console.log("Running domain validation tests...");
@@ -295,6 +296,44 @@ function runTests() {
"Invalid ASN should return an error" "Invalid ASN should return an error"
); );
// HTTP method validation tests
assertEquals(
getResourceRuleValueValidationError("METHOD", "POST"),
null,
"Single HTTP method should be valid"
);
assertEquals(
getResourceRuleValueValidationError("METHOD", " post , Put "),
null,
"Method list should be valid with mixed case and whitespace"
);
assertEquals(
getResourceRuleValueValidationError("METHOD", "PROPFIND"),
null,
"Extension methods such as the WebDAV verbs should be valid"
);
assertEquals(
getResourceRuleValueValidationError("METHOD", ""),
"Invalid HTTP method provided",
"Empty method list should return an error"
);
assertEquals(
getResourceRuleValueValidationError("METHOD", ",,"),
"Invalid HTTP method provided",
"Method list of only separators should return an error"
);
assertEquals(
getResourceRuleValueValidationError("METHOD", "GET POST"),
"Invalid HTTP method provided",
"Space separated methods should return an error"
);
assertEqualsObj(
parseHttpMethodList(" get ,post, "),
["GET", "POST"],
"Method list should be normalized to uppercase without empty entries"
);
console.log("All tests passed!"); console.log("All tests passed!");
} }
+42 -1
View File
@@ -76,9 +76,46 @@ export const RESOURCE_RULE_MATCH_TYPES = [
"COUNTRY", "COUNTRY",
"COUNTRY_IS_NOT", "COUNTRY_IS_NOT",
"ASN", "ASN",
"REGION" "REGION",
"METHOD"
] as const; ] as const;
// The methods offered in the UI: the eight from RFC 9110 plus PATCH (RFC 5789)
// and QUERY (RFC 10008). A METHOD rule is not limited to these, since
// isValidHttpMethodList accepts any method token, so blueprints and the API can
// also target extension methods such as the WebDAV verbs.
export const HTTP_METHODS = [
"GET",
"HEAD",
"POST",
"PUT",
"PATCH",
"DELETE",
"OPTIONS",
"TRACE",
"CONNECT",
"QUERY"
] as const;
// RFC 9110 token, minus the characters that would collide with the
// comma-separated list encoding.
const HTTP_METHOD_REGEX = /^[!#$%&'*+\-.^_`|~0-9A-Za-z]+$/;
export function parseHttpMethodList(value: string): string[] {
return value
.split(",")
.map((method) => method.trim().toUpperCase())
.filter((method) => method.length > 0);
}
export function isValidHttpMethodList(value: string): boolean {
const methods = parseHttpMethodList(value);
return (
methods.length > 0 &&
methods.every((method) => HTTP_METHOD_REGEX.test(method))
);
}
export type ResourceRuleMatchType = (typeof RESOURCE_RULE_MATCH_TYPES)[number]; export type ResourceRuleMatchType = (typeof RESOURCE_RULE_MATCH_TYPES)[number];
export function getResourceRuleValueValidationError( export function getResourceRuleValueValidationError(
@@ -101,6 +138,10 @@ export function getResourceRuleValueValidationError(
return COUNTRIES.some((country) => country.code === value) return COUNTRIES.some((country) => country.code === value)
? null ? null
: "Invalid country code provided"; : "Invalid country code provided";
case "METHOD":
return isValidHttpMethodList(value)
? null
: "Invalid HTTP method provided";
case "ASN": case "ASN":
const normalizedValue = value.trim().toUpperCase(); const normalizedValue = value.trim().toUpperCase();
return /^AS\d+$/.test(normalizedValue) || return /^AS\d+$/.test(normalizedValue) ||
@@ -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`
+18 -2
View File
@@ -40,6 +40,7 @@ import {
import config from "@server/lib/config"; import config from "@server/lib/config";
import { isIpInCidr, stripPortFromHost } from "@server/lib/ip"; import { isIpInCidr, stripPortFromHost } from "@server/lib/ip";
import { isPathAllowed } from "@server/lib/pathMatch"; import { isPathAllowed } from "@server/lib/pathMatch";
import { parseHttpMethodList } from "@server/lib/validators";
import { response } from "@server/lib/response"; import { response } from "@server/lib/response";
import logger from "@server/logger"; import logger from "@server/logger";
import HttpCode from "@server/types/HttpCode"; import HttpCode from "@server/types/HttpCode";
@@ -163,6 +164,7 @@ export async function verifyResourceSession(
path, path,
headers, headers,
query, query,
method,
badgerVersion badgerVersion
} = parsedBody.data; } = parsedBody.data;
@@ -293,7 +295,8 @@ export async function verifyResourceSession(
clientIp, clientIp,
path, path,
ipCC, ipCC,
ipAsn ipAsn,
method
); );
if (action == "ACCEPT") { if (action == "ACCEPT") {
@@ -1429,7 +1432,8 @@ async function checkRules(
clientIp: string | undefined, clientIp: string | undefined,
path: string | undefined, path: string | undefined,
ipCC?: string, ipCC?: string,
ipAsn?: number ipAsn?: number,
method?: string
): Promise<"ACCEPT" | "DROP" | "PASS" | undefined> { ): Promise<"ACCEPT" | "DROP" | "PASS" | undefined> {
const ruleCacheKey = `rules:${resourceId}`; const ruleCacheKey = `rules:${resourceId}`;
@@ -1504,12 +1508,24 @@ async function checkRules(
(await isIpInRegion(ipCC, rule.value)) (await isIpInRegion(ipCC, rule.value))
) { ) {
return rule.action as any; return rule.action as any;
} else if (
method &&
rule.match == "METHOD" &&
isMethodAllowed(rule.value, method)
) {
return rule.action as any;
} }
} }
return; return;
} }
// rule.value holds a comma-separated list of HTTP methods, e.g. "POST,PUT".
function isMethodAllowed(ruleValue: string, method: string): boolean {
const requestMethod = method.toUpperCase();
return parseHttpMethodList(ruleValue).includes(requestMethod);
}
export { isPathAllowed }; export { isPathAllowed };
async function isIpInGeoIP( async function isIpInGeoIP(
+24
View File
@@ -0,0 +1,24 @@
import { sendToClient } from "#dynamic/routers/ws";
// Error codes for registration failures
export const NewtErrorCodes = {
NO_AVAILABLE_SUBNET: {
code: "NO_AVAILABLE_SUBNET",
message:
"No available subnet could be assigned to this site on its exit node. Please contact your administrator to increase the available address space for this exit node's subnet."
}
} as const;
// Helper function to send registration error
export async function sendNewtError(
error: (typeof NewtErrorCodes)[keyof typeof NewtErrorCodes],
newtId: string
) {
sendToClient(newtId, {
type: "newt/error",
data: {
code: error.code,
message: error.message
}
});
}
@@ -14,6 +14,7 @@ import { getUniqueSubnetForExitNode } from "@server/lib/exitNodes";
import { fetchContainers } from "./dockerSocket"; import { fetchContainers } from "./dockerSocket";
import { buildTargetConfigurationForNewtClient } from "./buildConfiguration"; import { buildTargetConfigurationForNewtClient } from "./buildConfiguration";
import { canCompress } from "@server/lib/clientVersionChecks"; import { canCompress } from "@server/lib/clientVersionChecks";
import { NewtErrorCodes, sendNewtError } from "./error";
export const handleNewtRegisterMessage: MessageHandler = async (context) => { export const handleNewtRegisterMessage: MessageHandler = async (context) => {
const { message, client, sendToClient } = context; const { message, client, sendToClient } = context;
@@ -116,6 +117,7 @@ export const handleNewtRegisterMessage: MessageHandler = async (context) => {
logger.error( logger.error(
`No available subnets found for the new exit node id ${exitNodeId} and site id ${siteId}` `No available subnets found for the new exit node id ${exitNodeId} and site id ${siteId}`
); );
sendNewtError(NewtErrorCodes.NO_AVAILABLE_SUBNET, newt.newtId);
return; return;
} }
+5
View File
@@ -94,6 +94,11 @@ export const OlmErrorCodes = {
HOLEPUNCH_MISSING: { HOLEPUNCH_MISSING: {
code: "HOLEPUNCH_MISSING", code: "HOLEPUNCH_MISSING",
message: `Unable to coordinate client P2P connection. Please ensure your client can reach the server on UDP port ${udpPort} and try registering again.` message: `Unable to coordinate client P2P connection. Please ensure your client can reach the server on UDP port ${udpPort} and try registering again.`
},
NO_AVAILABLE_SUBNET: {
code: "NO_AVAILABLE_SUBNET",
message:
"No available subnet could be assigned to this client on the selected exit node. Please contact your administrator to increase the available address space for this exit node's subnet."
} }
} as const; } as const;
@@ -347,6 +347,7 @@ export const handleOlmRegisterMessage: MessageHandler = async (context) => {
`[handleOlmRegisterMessage] No available subnets found for exit node id ${exitNodeId} and client id ${client.clientId}`, `[handleOlmRegisterMessage] No available subnets found for exit node id ${exitNodeId} and client id ${client.clientId}`,
{ orgId: client.orgId, clientId: client.clientId } { orgId: client.orgId, clientId: client.clientId }
); );
sendOlmError(OlmErrorCodes.NO_AVAILABLE_SUBNET, olm.olmId);
return; return;
} }
+2 -1
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 { formatBackupTimestamp } 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";
@@ -121,7 +122,7 @@ 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 to the filename
const date = new Date(); const date = new Date();
const dateString = `${date.getFullYear()}-${date.getMonth()}-${date.getDate()}_${date.getHours()}-${date.getMinutes()}-${date.getSeconds()}`; const dateString = formatBackupTimestamp(date);
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, `db_${dateString}.sqlite`);
fs.copyFileSync(dbPath, backupPath); fs.copyFileSync(dbPath, backupPath);
+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`);
+2 -1
View File
@@ -10,6 +10,7 @@ import {
users users
} from "../../db/sqlite"; } from "../../db/sqlite";
import { APP_PATH, configFilePath1, configFilePath2 } from "@server/lib/consts"; import { APP_PATH, configFilePath1, configFilePath2 } from "@server/lib/consts";
import { formatBackupTimestamp } from "@server/lib/backupFileName";
import { eq, sql } from "drizzle-orm"; import { eq, sql } from "drizzle-orm";
import fs from "fs"; import fs from "fs";
import * as yaml from "js-yaml"; import * as yaml from "js-yaml";
@@ -34,7 +35,7 @@ export default async function migration() {
// copy the db.sqlite file to backups // copy the db.sqlite file to backups
// add the date to the filename // add the date to the filename
const date = new Date(); const date = new Date();
const dateString = `${date.getFullYear()}-${date.getMonth()}-${date.getDate()}_${date.getHours()}-${date.getMinutes()}-${date.getSeconds()}`; const dateString = formatBackupTimestamp(date);
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, `db_${dateString}.sqlite`);
fs.copyFileSync(dbPath, backupPath); fs.copyFileSync(dbPath, backupPath);
+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,22 +291,33 @@ export default function PrivateResourceInferencePage() {
</SettingsSubsectionHeader> </SettingsSubsectionHeader>
</SettingsFormCell> </SettingsFormCell>
<SettingsFormCell span="full"> <SettingsFormCell span="full">
<FormField
control={form.control}
name="httpConfigDomainId"
render={() => (
<FormItem>
<DomainPicker <DomainPicker
key={`inference-domain-${siteResource.id}`} key={`inference-domain-${siteResource.id}`}
orgId={siteResource.orgId} orgId={
siteResource.orgId
}
cols={2} cols={2}
hideFreeDomain hideFreeDomain
defaultSubdomain={ defaultSubdomain={
httpConfigSubdomain ?? undefined httpConfigSubdomain ??
undefined
} }
defaultDomainId={ defaultDomainId={
httpConfigDomainId ?? undefined httpConfigDomainId ??
undefined
} }
defaultFullDomain={ defaultFullDomain={
httpConfigFullDomain ?? httpConfigFullDomain ??
undefined undefined
} }
onDomainChange={(res) => { onDomainChange={(
res
) => {
if (res === null) { if (res === null) {
form.setValue( form.setValue(
"httpConfigSubdomain", "httpConfigSubdomain",
@@ -312,7 +325,11 @@ export default function PrivateResourceInferencePage() {
); );
form.setValue( form.setValue(
"httpConfigDomainId", "httpConfigDomainId",
null "",
{
shouldValidate:
true
}
); );
form.setValue( form.setValue(
"httpConfigFullDomain", "httpConfigFullDomain",
@@ -322,11 +339,16 @@ export default function PrivateResourceInferencePage() {
} }
form.setValue( form.setValue(
"httpConfigSubdomain", "httpConfigSubdomain",
res.subdomain ?? null res.subdomain ??
null
); );
form.setValue( form.setValue(
"httpConfigDomainId", "httpConfigDomainId",
res.domainId res.domainId,
{
shouldValidate:
true
}
); );
form.setValue( form.setValue(
"httpConfigFullDomain", "httpConfigFullDomain",
@@ -334,6 +356,10 @@ export default function PrivateResourceInferencePage() {
); );
}} }}
/> />
<FormMessage />
</FormItem>
)}
/>
</SettingsFormCell> </SettingsFormCell>
<SettingsFormCell span="half"> <SettingsFormCell span="half">
<FormField <FormField
@@ -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,6 +450,11 @@ export default function GeneralForm() {
resource.mode resource.mode
) && ( ) && (
<SettingsFormCell span="full"> <SettingsFormCell span="full">
<FormField
control={form.control}
name="domainId"
render={() => (
<FormItem>
<div id="resource-domain-picker"> <div id="resource-domain-picker">
<DomainPicker <DomainPicker
allowWildcard={ allowWildcard={
@@ -443,17 +464,21 @@ export default function GeneralForm() {
key={ key={
resource.resourceId resource.resourceId
} }
orgId={orgId as string} orgId={
orgId as string
}
cols={2} cols={2}
defaultSubdomain={ defaultSubdomain={
form.watch( form.watch(
"subdomain" "subdomain"
) ?? undefined ) ??
undefined
} }
defaultDomainId={ defaultDomainId={
form.watch( form.watch(
"domainId" "domainId"
) ?? undefined ) ??
undefined
} }
defaultFullDomain={ defaultFullDomain={
resourceFullDomainName || resourceFullDomainName ||
@@ -462,10 +487,17 @@ export default function GeneralForm() {
onDomainChange={( onDomainChange={(
res res
) => { ) => {
if (res === null) { if (
res ===
null
) {
form.setValue( form.setValue(
"domainId", "domainId",
undefined undefined,
{
shouldValidate:
true
}
); );
form.setValue( form.setValue(
"subdomain", "subdomain",
@@ -478,7 +510,11 @@ export default function GeneralForm() {
} }
form.setValue( form.setValue(
"domainId", "domainId",
res.domainId res.domainId,
{
shouldValidate:
true
}
); );
form.setValue( form.setValue(
"subdomain", "subdomain",
@@ -491,6 +527,10 @@ export default function GeneralForm() {
}} }}
/> />
</div> </div>
<FormMessage />
</FormItem>
)}
/>
</SettingsFormCell> </SettingsFormCell>
)} )}
{!["tcp", "udp", "inference"].includes( {!["tcp", "udp", "inference"].includes(
+21 -10
View File
@@ -122,11 +122,20 @@ export default async function ResourceAuthPage(props: {
if (searchParams.redirect) { if (searchParams.redirect) {
try { try {
const redirectTarget = new URL(searchParams.redirect);
const serverResourceHost = new URL(authInfo.url).host; const serverResourceHost = new URL(authInfo.url).host;
const redirectHost = new URL(searchParams.redirect).host; const redirectHost = redirectTarget.host;
const redirectPort = new URL(searchParams.redirect).port; const redirectPort = redirectTarget.port;
const serverResourceHostWithPort = `${serverResourceHost}:${redirectPort}`; const serverResourceHostWithPort = `${serverResourceHost}:${redirectPort}`;
// URL parses a host out of any scheme that uses "//", so a target
// like javascript://resource-host/... matches the comparisons
// below. The target is later assigned to window.location, so only
// http(s) is accepted here.
const isHttpTarget =
redirectTarget.protocol === "http:" ||
redirectTarget.protocol === "https:";
const wildcardMatchesRedirect = ( const wildcardMatchesRedirect = (
wildcardDomain: string, wildcardDomain: string,
host: string host: string
@@ -136,14 +145,16 @@ export default async function ResourceAuthPage(props: {
return host.endsWith(suffix) && host.length > suffix.length; return host.endsWith(suffix) && host.length > suffix.length;
}; };
if (serverResourceHost === redirectHost) { if (
redirectUrl = searchParams.redirect; isHttpTarget &&
} else if (serverResourceHostWithPort === redirectHost) { (serverResourceHost === redirectHost ||
redirectUrl = searchParams.redirect; serverResourceHostWithPort === redirectHost ||
} else if ( (authInfo.wildcard &&
authInfo.wildcard &&
authInfo.fullDomain && authInfo.fullDomain &&
wildcardMatchesRedirect(authInfo.fullDomain, redirectHost) wildcardMatchesRedirect(
authInfo.fullDomain,
redirectHost
)))
) { ) {
redirectUrl = searchParams.redirect; redirectUrl = searchParams.redirect;
} }
@@ -283,7 +294,7 @@ export default async function ResourceAuthPage(props: {
loginIdps = idpsRes.data.data.idps.map((idp) => ({ loginIdps = idpsRes.data.data.idps.map((idp) => ({
idpId: idp.idpId, idpId: idp.idpId,
name: idp.name, name: idp.name,
variant: idp.type variant: idp.variant ?? idp.type
})) as LoginFormIDP[]; })) as LoginFormIDP[];
} }
+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
@@ -37,6 +37,7 @@ import { cn } from "@app/lib/cn";
import { MAJOR_ASNS } from "@server/db/asns"; import { MAJOR_ASNS } from "@server/db/asns";
import { COUNTRIES } from "@server/db/countries"; import { COUNTRIES } from "@server/db/countries";
import { REGIONS, getRegionNameById } from "@server/db/regions"; import { REGIONS, getRegionNameById } from "@server/db/regions";
import { HTTP_METHODS, parseHttpMethodList } from "@server/lib/validators";
import { import {
ColumnDef, ColumnDef,
flexRender, flexRender,
@@ -63,7 +64,8 @@ import {
} from "react"; } from "react";
import { import {
validatePolicyRulePriority, validatePolicyRulePriority,
validatePolicyRuleValue validatePolicyRuleValue,
type PolicyRuleMatchType
} from "./policy-access-rule-validation"; } from "./policy-access-rule-validation";
import { import {
buildDisplayPrioritiesForResourceOverlay, buildDisplayPrioritiesForResourceOverlay,
@@ -112,6 +114,80 @@ function getColumnClassName(columnId: string) {
return ""; return "";
} }
// A METHOD rule stores its methods as a comma-separated list in rule.value,
// e.g. "POST,PUT". Only the common methods are offered here; a value set
// through a blueprint or the API may contain other methods (the WebDAV verbs,
// for instance), so those are kept and shown rather than dropped on edit.
function RuleMethodSelect({
value,
disabled,
placeholder,
onChange
}: {
value: string;
disabled: boolean;
placeholder: string;
onChange: (value: string) => void;
}) {
const selected = parseHttpMethodList(value);
const knownMethods: readonly string[] = HTTP_METHODS;
const options = [
...knownMethods,
...selected.filter((method) => !knownMethods.includes(method))
];
function toggle(method: string) {
const next = selected.includes(method)
? selected.filter((m) => m !== method)
: [...selected, method];
// keep a stable order so the stored value does not churn on every edit
onChange(options.filter((m) => next.includes(m)).join(","));
}
return (
<Popover>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
disabled={disabled}
className="w-full min-w-0 justify-between"
>
<span className="truncate">
{selected.length > 0 ? selected.join(", ") : placeholder}
</span>
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="min-w-50 p-0">
<Command>
<CommandList>
<CommandGroup>
{options.map((method) => (
<CommandItem
key={method}
value={method}
onSelect={() => toggle(method)}
>
<Check
className={`mr-2 h-4 w-4 ${
selected.includes(method)
? "opacity-100"
: "opacity-0"
}`}
/>
{method}
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
);
}
export function PolicyAccessRulesTable({ export function PolicyAccessRulesTable({
rules, rules,
onRulesChange, onRulesChange,
@@ -233,7 +309,8 @@ export function PolicyAccessRulesTable({
COUNTRY: t("country"), COUNTRY: t("country"),
COUNTRY_IS_NOT: t("countryIsNot"), COUNTRY_IS_NOT: t("countryIsNot"),
ASN: "ASN", ASN: "ASN",
REGION: t("region") REGION: t("region"),
METHOD: t("method")
}), }),
[t] [t]
); );
@@ -438,16 +515,7 @@ export function PolicyAccessRulesTable({
<Select <Select
defaultValue={row.original.match} defaultValue={row.original.match}
disabled={readonly || isRuleLocked(row.original)} disabled={readonly || isRuleLocked(row.original)}
onValueChange={( onValueChange={(value: PolicyRuleMatchType) =>
value:
| "CIDR"
| "IP"
| "PATH"
| "COUNTRY"
| "COUNTRY_IS_NOT"
| "ASN"
| "REGION"
) =>
updateRule(row.original.ruleId, { updateRule(row.original.ruleId, {
match: value, match: value,
value: value:
@@ -458,6 +526,8 @@ export function PolicyAccessRulesTable({
? "AS15169" ? "AS15169"
: value === "REGION" : value === "REGION"
? "021" ? "021"
: value === "METHOD"
? "GET"
: row.original.value : row.original.value
}) })
} }
@@ -473,6 +543,9 @@ export function PolicyAccessRulesTable({
<SelectItem value="CIDR"> <SelectItem value="CIDR">
{RuleMatch.CIDR} {RuleMatch.CIDR}
</SelectItem> </SelectItem>
<SelectItem value="METHOD">
{RuleMatch.METHOD}
</SelectItem>
{isMaxmindAvailable && ( {isMaxmindAvailable && (
<> <>
<SelectItem value="COUNTRY"> <SelectItem value="COUNTRY">
@@ -779,6 +852,15 @@ export function PolicyAccessRulesTable({
</Command> </Command>
</PopoverContent> </PopoverContent>
</Popover> </Popover>
) : row.original.match === "METHOD" ? (
<RuleMethodSelect
value={row.original.value}
disabled={readonly || isRuleLocked(row.original)}
placeholder={t("rulesSelectMethods")}
onChange={(value) =>
updateRule(row.original.ruleId, { value })
}
/>
) : ( ) : (
<Input <Input
defaultValue={row.original.value} defaultValue={row.original.value}
@@ -2,6 +2,7 @@ import { COUNTRIES } from "@server/db/countries";
import { isValidRegionId } from "@server/db/regions"; import { isValidRegionId } from "@server/db/regions";
import { import {
isValidCIDR, isValidCIDR,
isValidHttpMethodList,
isValidIP, isValidIP,
isValidUrlGlobPattern isValidUrlGlobPattern
} from "@server/lib/validators"; } from "@server/lib/validators";
@@ -19,7 +20,8 @@ export const POLICY_RULE_MATCH_TYPES = [
"COUNTRY", "COUNTRY",
"COUNTRY_IS_NOT", "COUNTRY_IS_NOT",
"ASN", "ASN",
"REGION" "REGION",
"METHOD"
] as const; ] as const;
export type PolicyRuleMatchType = (typeof POLICY_RULE_MATCH_TYPES)[number]; export type PolicyRuleMatchType = (typeof POLICY_RULE_MATCH_TYPES)[number];
@@ -84,6 +86,10 @@ export function createPolicyRuleValueSchema(t: TranslateFn, match: string) {
(value) => COUNTRIES.some((country) => country.code === value), (value) => COUNTRIES.some((country) => country.code === value),
{ message: t("rulesErrorInvalidCountryDescription") } { message: t("rulesErrorInvalidCountryDescription") }
); );
case "METHOD":
return required.refine(isValidHttpMethodList, {
message: t("rulesErrorInvalidMethodDescription")
});
case "ASN": case "ASN":
return required.refine( return required.refine(
(value) => { (value) => {
+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"]
});
}
}); });
} }