mirror of
https://github.com/fosrl/pangolin.git
synced 2026-09-21 18:18:27 +02:00
Compare commits
40 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 74d10acad9 | |||
| 887c6e4e8b | |||
| f71b9e7c54 | |||
| 5dd4c54ad0 | |||
| 6f3e0cf5a6 | |||
| d4488ec125 | |||
| b8572524a4 | |||
| b054f90149 | |||
| c290239894 | |||
| 8e2f9ea5ef | |||
| 032eeb2656 | |||
| 5ca08d71f0 | |||
| 1f453dc04f | |||
| b0e2fcadd4 | |||
| 65ccd5a89d | |||
| 76a4f50ccf | |||
| 1dfe0124be | |||
| aa52174f96 | |||
| e23ae5707f | |||
| 17053ac2bd | |||
| 2733ae1122 | |||
| 735787b183 | |||
| 2fb3507aa5 | |||
| 262ca8a1d0 | |||
| 66c9bdbfa3 | |||
| 4716a2a647 | |||
| 847f44197b | |||
| 6aacd4d185 | |||
| ab3db3bc68 | |||
| b52baceb50 | |||
| 0d2c8a37ef | |||
| 9298ec7cdb | |||
| 0fdff2feee | |||
| 8e042e6433 | |||
| 883ad14326 | |||
| 3cb41211ee | |||
| ee4a1a6b18 | |||
| a10972990a | |||
| 6d6e105711 | |||
| a7d4745f93 |
@@ -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:
|
||||||
|
|||||||
@@ -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,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,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
@@ -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
@@ -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",
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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}`;
|
||||||
|
}
|
||||||
@@ -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}`);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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({
|
||||||
|
|||||||
@@ -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!");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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`
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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`);
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -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"
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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) => {
|
||||||
|
|||||||
@@ -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"]
|
||||||
|
});
|
||||||
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user