mirror of
https://github.com/fosrl/pangolin.git
synced 2026-07-18 11:36:30 +02:00
Compare commits
223 Commits
dev
..
0ef7c2f2ce
| Author | SHA1 | Date | |
|---|---|---|---|
| 0ef7c2f2ce | |||
| cbaa0e36d8 | |||
| 2d313df3af | |||
| 9fba2e08b0 | |||
| 26f770b426 | |||
| 6dad20bff0 | |||
| 957760ad49 | |||
| 6eefa224eb | |||
| 80ee00923b | |||
| 8383d20d48 | |||
| dad9fba602 | |||
| bfb0596414 | |||
| 2511f9f819 | |||
| ac6a90f61a | |||
| 3d5669208e | |||
| f0443173cc | |||
| af07692fbf | |||
| 11276c02cd | |||
| 1be4e93849 | |||
| 6ab41c9c5a | |||
| a5355390a3 | |||
| 5daab3d0be | |||
| 5364e2f419 | |||
| a63c1acf00 | |||
| 1ca442fc90 | |||
| 5f9b0d9f3c | |||
| 230fc39b66 | |||
| 1cbe8a914c | |||
| 0b22ccfda3 | |||
| e5a3b93f58 | |||
| 7a403b3deb | |||
| 1e968acede | |||
| 5d4a1ffd73 | |||
| 501770bee0 | |||
| 247f19ce4e | |||
| 2ad41154b3 | |||
| 8a59930886 | |||
| 02a6959e3e | |||
| e6f476dbf5 | |||
| 7837578672 | |||
| c603acacd7 | |||
| 021857c738 | |||
| b96fe34e83 | |||
| acda39d27a | |||
| 068b7f3217 | |||
| f3f1d8a161 | |||
| 9edb126594 | |||
| 1b8b231c69 | |||
| c7e27976c0 | |||
| b795021014 | |||
| 58d0f55480 | |||
| f99df78f26 | |||
| 2af68672a8 | |||
| 985aba5e67 | |||
| 8329a2d6d9 | |||
| da4559b28f | |||
| 1e9c2b5bfd | |||
| b5f5e2fa03 | |||
| 7748a07308 | |||
| ed79c98115 | |||
| afc582dd81 | |||
| 2c93b004bc | |||
| 2c9b812ba2 | |||
| c714e4fc9c | |||
| f6274fb789 | |||
| 2409165bfd | |||
| d52ae1f5e1 | |||
| f69d41ebc3 | |||
| 99a7dfa2e2 | |||
| 4ba08ba9ae | |||
| 8a83be49ee | |||
| f3a96f3db8 | |||
| b5dd8b2598 | |||
| abf3f3b0da | |||
| a44a762f64 | |||
| 259711990c | |||
| 58ff29700b | |||
| 6a4923a11d | |||
| 1b7c6629ad | |||
| be3f9819ac | |||
| 57c50dfc4c | |||
| e89dce930b | |||
| 423dfd683f | |||
| 806ec75a2b | |||
| 03fa092e9e | |||
| 7631555f00 | |||
| 297fb8f020 | |||
| d11a76bbef | |||
| 1323c11db9 | |||
| 6bbaad9fa2 | |||
| 1c2508013e | |||
| 7e76c27b19 | |||
| 210b00db05 | |||
| 9e71dd1c17 | |||
| cd8e16cab1 | |||
| e943ed33fc | |||
| f0f03c9da9 | |||
| b6fae4299e | |||
| 206a448752 | |||
| 29aae19cd5 | |||
| 6ea6d7ac0d | |||
| a8bb5c2972 | |||
| f1aa919b17 | |||
| 07196a2ae4 | |||
| 1cdfb3e765 | |||
| 751e295620 | |||
| f9d2c77b6c | |||
| 78726e42ad | |||
| 2af8ed695e | |||
| bf9c68b8de | |||
| a39a71c0af | |||
| e5dc402a7e | |||
| ce99fa18b4 | |||
| 265a51d22d | |||
| 0d06192e3e | |||
| 875c0acab7 | |||
| e9b214c3f3 | |||
| 372d6d685f | |||
| 0d09a63e81 | |||
| cb8605656c | |||
| 774147e8d2 | |||
| 3b13557f72 | |||
| 906d15a2cf | |||
| 0a465ebbc9 | |||
| f99a823e11 | |||
| 550bacf7ad | |||
| f6746d7e13 | |||
| e8b8234274 | |||
| 52709c04dc | |||
| 80d522bba6 | |||
| 899ffc89f0 | |||
| 69dd2513eb | |||
| f691dae8f8 | |||
| 36421e4216 | |||
| 7032feacbc | |||
| 56dc24440f | |||
| 24d94bebb5 | |||
| 901eea6ea1 | |||
| eac4b7cf67 | |||
| c887b5cf32 | |||
| 907012ee9b | |||
| c28e7cebf1 | |||
| 810abf6526 | |||
| 7103d32902 | |||
| 0014e55247 | |||
| 364d65ed00 | |||
| 2869227667 | |||
| 935be5fe14 | |||
| babe85094b | |||
| 00c02a5092 | |||
| fe2dc2f260 | |||
| a008291eba | |||
| 87ab601e6d | |||
| a25d64a18e | |||
| 05afeb60cd | |||
| d89d595735 | |||
| ef3c6f2533 | |||
| a8759b223f | |||
| 4e8802ea10 | |||
| 3d0882a9bb | |||
| c23927fd65 | |||
| c3c2a7182a | |||
| ac03fc9b6a | |||
| f9a26d0c3c | |||
| e631cafdf9 | |||
| 29a716b0cb | |||
| 9da1d05b9a | |||
| 47e9ceed16 | |||
| c29b80dafd | |||
| a741922672 | |||
| 93ab8c344b | |||
| b39ef73874 | |||
| 604dc7bb61 | |||
| 8cd0e85818 | |||
| e7026c40fd | |||
| 9335f1a7a6 | |||
| 0827d666e2 | |||
| 84a7a0edf0 | |||
| 344650a97f | |||
| 5b5e45765e | |||
| 1b02367adb | |||
| f44b3260e2 | |||
| 96825687fa | |||
| 33ee7e11ad | |||
| bc67bc69a2 | |||
| 8028c04789 | |||
| e531e02f2b | |||
| 7465f2b342 | |||
| 40f2909ca1 | |||
| 2bbbe9c880 | |||
| 20117a87d5 | |||
| 28667618dd | |||
| 60163bf3ae | |||
| 374e899216 | |||
| cc3c9344cc | |||
| a87c213927 | |||
| df72b65ced | |||
| b00c3c8b47 | |||
| d0211ca37d | |||
| 210057ddb1 | |||
| 34e32f14b8 | |||
| 0870576f4c | |||
| 2a50c1b4af | |||
| b18b574c5f | |||
| ee4572ed64 | |||
| b0f9fee4b0 | |||
| 2c3a11718d | |||
| d7b98feb51 | |||
| 3736e27a4f | |||
| edd320ce0b | |||
| 91b02e839d | |||
| 8f7274a8b4 | |||
| d9b7b8cec4 | |||
| 170624017d | |||
| 43d5c1d8df | |||
| f6a3951951 | |||
| 798497f437 | |||
| c090a52d6a | |||
| 44fb0afced | |||
| 0dbe95019b | |||
| 67c899e3a6 | |||
| 827707471e | |||
| ed221b09ac |
@@ -1,5 +0,0 @@
|
|||||||
---
|
|
||||||
alwaysApply: true
|
|
||||||
---
|
|
||||||
|
|
||||||
When adding submit buttons, don't change the text of the button during the loading state. Text should stay static and you should use the loading prop on the button.
|
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
---
|
|
||||||
alwaysApply: true
|
|
||||||
---
|
|
||||||
|
|
||||||
When creating UI for popup dialogs or modals, use the Credenza componennt. This component is mobile responsive and works on desktop and wraps the dialog component and sheet into one.
|
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
---
|
|
||||||
alwaysApply: true
|
|
||||||
---
|
|
||||||
|
|
||||||
Don't write or edit migrations in `server/setup` unless specificall instructed to do so.
|
|
||||||
@@ -1,7 +0,0 @@
|
|||||||
---
|
|
||||||
alwaysApply: true
|
|
||||||
---
|
|
||||||
|
|
||||||
When writing TypeScript:
|
|
||||||
|
|
||||||
Prefer to use types instead of interfaces.
|
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
---
|
|
||||||
alwaysApply: true
|
|
||||||
---
|
|
||||||
|
|
||||||
When creating forms, use React form for validation and use Zod schemas.
|
|
||||||
+1
-2
@@ -34,5 +34,4 @@ build.ts
|
|||||||
tsconfig.json
|
tsconfig.json
|
||||||
Dockerfile*
|
Dockerfile*
|
||||||
drizzle.config.ts
|
drizzle.config.ts
|
||||||
allowedDevOrigins.json
|
allowedDevOrigins.json
|
||||||
scratch/
|
|
||||||
+29
-19
@@ -1,42 +1,52 @@
|
|||||||
version: 2
|
version: 2
|
||||||
|
|
||||||
updates:
|
updates:
|
||||||
- package-ecosystem: "npm"
|
- package-ecosystem: "npm"
|
||||||
directory: "/"
|
directory: "/"
|
||||||
schedule:
|
schedule:
|
||||||
interval: "daily"
|
interval: "daily"
|
||||||
open-pull-requests-limit: 1
|
|
||||||
groups:
|
groups:
|
||||||
npm-dependencies:
|
dev-patch-updates:
|
||||||
patterns:
|
dependency-type: "development"
|
||||||
- "*"
|
update-types:
|
||||||
|
- "patch"
|
||||||
|
dev-minor-updates:
|
||||||
|
dependency-type: "development"
|
||||||
|
update-types:
|
||||||
|
- "minor"
|
||||||
|
prod-patch-updates:
|
||||||
|
dependency-type: "production"
|
||||||
|
update-types:
|
||||||
|
- "patch"
|
||||||
|
prod-minor-updates:
|
||||||
|
dependency-type: "production"
|
||||||
|
update-types:
|
||||||
|
- "minor"
|
||||||
|
|
||||||
- package-ecosystem: "docker"
|
- package-ecosystem: "docker"
|
||||||
directory: "/"
|
directory: "/"
|
||||||
schedule:
|
schedule:
|
||||||
interval: "daily"
|
interval: "daily"
|
||||||
open-pull-requests-limit: 1
|
|
||||||
groups:
|
groups:
|
||||||
docker-dependencies:
|
patch-updates:
|
||||||
patterns:
|
update-types:
|
||||||
- "*"
|
- "patch"
|
||||||
|
minor-updates:
|
||||||
|
update-types:
|
||||||
|
- "minor"
|
||||||
|
|
||||||
- package-ecosystem: "github-actions"
|
- package-ecosystem: "github-actions"
|
||||||
directory: "/"
|
directory: "/"
|
||||||
schedule:
|
schedule:
|
||||||
interval: "weekly"
|
interval: "weekly"
|
||||||
open-pull-requests-limit: 1
|
|
||||||
groups:
|
|
||||||
github-actions-dependencies:
|
|
||||||
patterns:
|
|
||||||
- "*"
|
|
||||||
|
|
||||||
- package-ecosystem: "gomod"
|
- package-ecosystem: "gomod"
|
||||||
directory: "/install"
|
directory: "/install"
|
||||||
schedule:
|
schedule:
|
||||||
interval: "daily"
|
interval: "daily"
|
||||||
open-pull-requests-limit: 1
|
|
||||||
groups:
|
groups:
|
||||||
go-install-dependencies:
|
patch-updates:
|
||||||
patterns:
|
update-types:
|
||||||
- "*"
|
- "patch"
|
||||||
|
minor-updates:
|
||||||
|
update-types:
|
||||||
|
- "minor"
|
||||||
|
|||||||
@@ -62,7 +62,7 @@ jobs:
|
|||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout code
|
- name: Checkout code
|
||||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||||
|
|
||||||
- name: Monitor storage space
|
- name: Monitor storage space
|
||||||
run: |
|
run: |
|
||||||
@@ -134,7 +134,7 @@ jobs:
|
|||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout code
|
- name: Checkout code
|
||||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||||
|
|
||||||
- name: Monitor storage space
|
- name: Monitor storage space
|
||||||
run: |
|
run: |
|
||||||
@@ -201,7 +201,7 @@ jobs:
|
|||||||
timeout-minutes: 30
|
timeout-minutes: 30
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout code
|
- name: Checkout code
|
||||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||||
|
|
||||||
- name: Log in to Docker Hub
|
- name: Log in to Docker Hub
|
||||||
uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0
|
uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0
|
||||||
@@ -256,7 +256,7 @@ jobs:
|
|||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout code
|
- name: Checkout code
|
||||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||||
|
|
||||||
- name: Extract tag name
|
- name: Extract tag name
|
||||||
id: get-tag
|
id: get-tag
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ jobs:
|
|||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout code
|
- name: Checkout code
|
||||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||||
|
|
||||||
- name: Set up Node.js
|
- name: Set up Node.js
|
||||||
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ jobs:
|
|||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||||
|
|
||||||
- name: Install Node
|
- name: Install Node
|
||||||
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
||||||
@@ -62,7 +62,7 @@ jobs:
|
|||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||||
|
|
||||||
- name: Build Docker image sqlite
|
- name: Build Docker image sqlite
|
||||||
run: make dev-build-sqlite
|
run: make dev-build-sqlite
|
||||||
@@ -71,7 +71,7 @@ jobs:
|
|||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||||
|
|
||||||
- name: Build Docker image pg
|
- name: Build Docker image pg
|
||||||
run: make dev-build-pg
|
run: make dev-build-pg
|
||||||
|
|||||||
Vendored
+1
-4
@@ -18,8 +18,5 @@
|
|||||||
"[json]": {
|
"[json]": {
|
||||||
"editor.defaultFormatter": "esbenp.prettier-vscode"
|
"editor.defaultFormatter": "esbenp.prettier-vscode"
|
||||||
},
|
},
|
||||||
"editor.formatOnSave": true,
|
"editor.formatOnSave": true
|
||||||
"cSpell.words": [
|
|
||||||
"nessicary"
|
|
||||||
]
|
|
||||||
}
|
}
|
||||||
@@ -41,7 +41,7 @@
|
|||||||
</strong>
|
</strong>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
Pangolin is an open-source, identity-based remote access platform built on WireGuard® that enables secure connectivity to infrastructure anywhere. It combines reverse-proxy and VPN capabilities into one platform, providing browser-based access to web applications and client-based access to private resources with NAT traversal, all with granular access control.
|
Pangolin is an open-source, identity-based remote access platform built on WireGuard® that enables secure, seamless connectivity to private and public resources. Pangolin combines reverse proxy and VPN capabilities into one platform, providing browser-based access to web applications and client-based access to any private resources with NAT traversal, all with granular access controls.
|
||||||
|
|
||||||
## Installation
|
## Installation
|
||||||
|
|
||||||
@@ -63,26 +63,11 @@ Pangolin is an open-source, identity-based remote access platform built on WireG
|
|||||||
|
|
||||||
Pangolin's site connectors provide gateways into networks so you can access any networked resources. Sites use outbound tunnels and intelligent NAT traversal to make networks behind restrictive firewalls available for authorized access without public IPs or open ports. Easily deploy a site as a binary or container on any platform.
|
Pangolin's site connectors provide gateways into networks so you can access any networked resources. Sites use outbound tunnels and intelligent NAT traversal to make networks behind restrictive firewalls available for authorized access without public IPs or open ports. Easily deploy a site as a binary or container on any platform.
|
||||||
|
|
||||||
* Lightweight user-space connector runs anywhere
|
|
||||||
* Punches through any firewall
|
|
||||||
* Doesn't require open ports or a public IP
|
|
||||||
* Strict network segmentation
|
|
||||||
* WireGuard-based
|
|
||||||
* Get alerts when a device or network resource goes down
|
|
||||||
|
|
||||||
<img src="public/screenshots/sites.png" alt="Sites" width="100%" />
|
<img src="public/screenshots/sites.png" alt="Sites" width="100%" />
|
||||||
|
|
||||||
### Browser-based reverse proxy access
|
### Browser-based reverse proxy access
|
||||||
|
|
||||||
Expose HTTPS web applications and connect to VNC, RDP, and SSH entirely in the browser through identity and context-aware tunneled reverse proxies. Users access resources with authentication and granular access control without installing a client. Pangolin handles routing, load balancing, health checking, and automatic SSL certificates without exposing your network directly to the internet.
|
Expose web applications through identity and context-aware tunneled reverse proxies. Users access applications through any web browser with authentication and granular access control without installing a client. Pangolin handles routing, load balancing, health checking, and automatic SSL certificates without exposing your network directly to the internet.
|
||||||
|
|
||||||
* Expose a web panel anywhere
|
|
||||||
* Access via any web browser
|
|
||||||
* Single sign-on across all resources
|
|
||||||
* HTTPS resources
|
|
||||||
* Remote desktop in the browser with VNC and RDP
|
|
||||||
* In-browser SSH terminal with privileged access management (PAM)
|
|
||||||
* PIN codes, passcodes, email OTP, geoblocking, allow-lists, and more
|
|
||||||
|
|
||||||
<img src="public/clip.gif" alt="Reverse proxy access" width="100%" />
|
<img src="public/clip.gif" alt="Reverse proxy access" width="100%" />
|
||||||
|
|
||||||
@@ -90,35 +75,14 @@ Expose HTTPS web applications and connect to VNC, RDP, and SSH entirely in the b
|
|||||||
|
|
||||||
Access private resources like SSH servers, databases, RDP, and entire network ranges through Pangolin clients. Intelligent NAT traversal enables connections even through restrictive firewalls, while DNS aliases provide friendly names and fast connections to resources across all your sites. Add redundancy by routing traffic through multiple connectors in your network.
|
Access private resources like SSH servers, databases, RDP, and entire network ranges through Pangolin clients. Intelligent NAT traversal enables connections even through restrictive firewalls, while DNS aliases provide friendly names and fast connections to resources across all your sites. Add redundancy by routing traffic through multiple connectors in your network.
|
||||||
|
|
||||||
* Peer-to-peer with intelligent NAT traversal
|
|
||||||
* Hosts/IPs and port ranges
|
|
||||||
* Network ranges/CIDRs
|
|
||||||
* Friendly DNS aliases for network addresses
|
|
||||||
* Privileged access management (PAM) with SSH resources
|
|
||||||
* Private HTTPS resources only accessible on the private network
|
|
||||||
|
|
||||||
<img src="public/screenshots/private-resources.png" alt="Private resources" width="100%" />
|
<img src="public/screenshots/private-resources.png" alt="Private resources" width="100%" />
|
||||||
|
|
||||||
### Give users and roles access to resources
|
### Give users and roles access to resources
|
||||||
|
|
||||||
Use Pangolin's built-in users or bring your own identity provider and set up role-based access control (RBAC). Grant users access to specific resources, not entire networks. Unlike traditional VPNs that expose full network access, Pangolin's zero-trust model ensures users can only reach the applications, services, and routes you explicitly define.
|
Use Pangolin's built in users or bring your own identity provider and set up role based access control (RBAC). Grant users access to specific resources, not entire networks. Unlike traditional VPNs that expose full network access, Pangolin's zero-trust model ensures users can only reach the applications, services, and routes you explicitly define.
|
||||||
|
|
||||||
* Bring your existing identity provider (IdP) or use Pangolin identities
|
|
||||||
* Sync users and roles from your IdP
|
|
||||||
* User- and role-based access control
|
|
||||||
* Full network audit and access logs
|
|
||||||
|
|
||||||
<img src="public/screenshots/users.png" alt="Users from identity provider with roles" width="100%" />
|
<img src="public/screenshots/users.png" alt="Users from identity provider with roles" width="100%" />
|
||||||
|
|
||||||
### Find and launch resources from a personalized home page
|
|
||||||
|
|
||||||
Give users a landing page to quickly find and open the resources they can access. Resources are grouped by site or label, searchable, and filterable, with grid or list views. Saved views capture filters, grouping, and layout as personal or organization-wide defaults.
|
|
||||||
|
|
||||||
* Single place for admins and non-admins to see accessible resources
|
|
||||||
* Create reusable views for common access patterns
|
|
||||||
|
|
||||||
<img src="public/screenshots/resource-launcher.png" alt="Resource Launcher" width="100%" />
|
|
||||||
|
|
||||||
## Download Clients
|
## Download Clients
|
||||||
|
|
||||||
Download the Pangolin client for your platform:
|
Download the Pangolin client for your platform:
|
||||||
|
|||||||
@@ -4,26 +4,19 @@ import { eq } from "drizzle-orm";
|
|||||||
|
|
||||||
type SetServerAdminArgs = {
|
type SetServerAdminArgs = {
|
||||||
email: string;
|
email: string;
|
||||||
remove: boolean;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export const setServerAdmin: CommandModule<{}, SetServerAdminArgs> = {
|
export const setServerAdmin: CommandModule<{}, SetServerAdminArgs> = {
|
||||||
command: "set-server-admin",
|
command: "set-server-admin",
|
||||||
describe: "Add or remove server admin by email address",
|
describe: "Mark any user as a server admin by email address",
|
||||||
builder: (yargs) => {
|
builder: (yargs) => {
|
||||||
return yargs
|
return yargs.option("email", {
|
||||||
.option("email", {
|
type: "string",
|
||||||
type: "string",
|
demandOption: true,
|
||||||
demandOption: true,
|
describe: "User email address"
|
||||||
describe: "User email address"
|
});
|
||||||
})
|
|
||||||
.option("remove", {
|
|
||||||
type: "boolean",
|
|
||||||
default: false,
|
|
||||||
describe: "Remove server admin status from the user"
|
|
||||||
});
|
|
||||||
},
|
},
|
||||||
handler: async (argv: SetServerAdminArgs) => {
|
handler: async (argv: { email: string }) => {
|
||||||
try {
|
try {
|
||||||
const email = argv.email.trim().toLowerCase();
|
const email = argv.email.trim().toLowerCase();
|
||||||
|
|
||||||
@@ -38,33 +31,6 @@ export const setServerAdmin: CommandModule<{}, SetServerAdminArgs> = {
|
|||||||
process.exit(1);
|
process.exit(1);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (argv.remove) {
|
|
||||||
if (!user.serverAdmin) {
|
|
||||||
console.log(`User '${email}' is not a server admin`);
|
|
||||||
process.exit(0);
|
|
||||||
}
|
|
||||||
|
|
||||||
const serverAdmins = await db
|
|
||||||
.select()
|
|
||||||
.from(users)
|
|
||||||
.where(eq(users.serverAdmin, true));
|
|
||||||
|
|
||||||
if (serverAdmins.length <= 1) {
|
|
||||||
console.error(
|
|
||||||
"Cannot remove server admin: at least one server admin must exist"
|
|
||||||
);
|
|
||||||
process.exit(1);
|
|
||||||
}
|
|
||||||
|
|
||||||
await db
|
|
||||||
.update(users)
|
|
||||||
.set({ serverAdmin: false })
|
|
||||||
.where(eq(users.userId, user.userId));
|
|
||||||
|
|
||||||
console.log(`Server admin status removed from user '${email}'`);
|
|
||||||
process.exit(0);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (user.serverAdmin) {
|
if (user.serverAdmin) {
|
||||||
console.log(`User '${email}' is already a server admin`);
|
console.log(`User '${email}' is already a server admin`);
|
||||||
process.exit(0);
|
process.exit(0);
|
||||||
|
|||||||
@@ -38,5 +38,7 @@ flags:
|
|||||||
disable_user_create_org: false
|
disable_user_create_org: false
|
||||||
allow_raw_resources: true
|
allow_raw_resources: true
|
||||||
|
|
||||||
{{if .IsPostgreSQL}}postgres:
|
{{if .IsPostgreSQL}}
|
||||||
connection_string: postgresql://pangolin:{{.IsPostgreSQLPass}}@postgres:5432/pangolin{{end}}
|
postgres:
|
||||||
|
connection_string: postgresql://pangolin:{{.IsPostgreSQLPass}}@postgres:5432/pangolin
|
||||||
|
{{end}}
|
||||||
|
|||||||
@@ -7,17 +7,23 @@ services:
|
|||||||
deploy:
|
deploy:
|
||||||
resources:
|
resources:
|
||||||
limits:
|
limits:
|
||||||
memory: 2g
|
memory: 1g
|
||||||
reservations:
|
reservations:
|
||||||
memory: 512m
|
memory: 256m
|
||||||
{{if or .IsPostgreSQL .IsRedis}}depends_on:
|
{{if or .IsPostgreSQL .IsRedis}}
|
||||||
{{if .IsPostgreSQL}}postgres:
|
depends_on:
|
||||||
condition: service_healthy{{end}}
|
{{if .IsPostgreSQL}}
|
||||||
{{if .IsRedis}}redis:
|
postgres:
|
||||||
condition: service_healthy{{end}}
|
condition: service_healthy
|
||||||
|
{{end}}
|
||||||
|
{{if .IsRedis}}
|
||||||
|
redis:
|
||||||
|
condition: service_healthy
|
||||||
|
{{end}}
|
||||||
networks:
|
networks:
|
||||||
- default
|
- default
|
||||||
- backend{{end}}
|
- backend
|
||||||
|
{{end}}
|
||||||
volumes:
|
volumes:
|
||||||
- ./config:/app/config
|
- ./config:/app/config
|
||||||
healthcheck:
|
healthcheck:
|
||||||
@@ -25,8 +31,8 @@ services:
|
|||||||
interval: "10s"
|
interval: "10s"
|
||||||
timeout: "10s"
|
timeout: "10s"
|
||||||
retries: 15
|
retries: 15
|
||||||
|
{{if .InstallGerbil}}
|
||||||
{{if .InstallGerbil}}gerbil:
|
gerbil:
|
||||||
image: docker.io/fosrl/gerbil:{{.GerbilVersion}}
|
image: docker.io/fosrl/gerbil:{{.GerbilVersion}}
|
||||||
container_name: gerbil
|
container_name: gerbil
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
@@ -47,16 +53,17 @@ services:
|
|||||||
- 21820:21820/udp
|
- 21820:21820/udp
|
||||||
- 443:443
|
- 443:443
|
||||||
- 443:443/udp # For http3 QUIC if desired
|
- 443:443/udp # For http3 QUIC if desired
|
||||||
- 80:80{{end}}
|
- 80:80
|
||||||
|
{{end}}
|
||||||
traefik:
|
traefik:
|
||||||
image: docker.io/traefik:v3.6
|
image: docker.io/traefik:v3.6
|
||||||
container_name: traefik
|
container_name: traefik
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
{{if .InstallGerbil}}network_mode: service:gerbil # Ports appear on the gerbil service{{end}}{{if not .InstallGerbil}}
|
{{if .InstallGerbil}} network_mode: service:gerbil # Ports appear on the gerbil service{{end}}{{if not .InstallGerbil}}
|
||||||
ports:
|
ports:
|
||||||
- 443:443
|
- 443:443
|
||||||
- 80:80{{end}}
|
- 80:80
|
||||||
|
{{end}}
|
||||||
depends_on:
|
depends_on:
|
||||||
pangolin:
|
pangolin:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
@@ -67,7 +74,8 @@ services:
|
|||||||
- ./config/letsencrypt:/letsencrypt # Volume to store the Let's Encrypt certificates
|
- ./config/letsencrypt:/letsencrypt # Volume to store the Let's Encrypt certificates
|
||||||
- ./config/traefik/logs:/var/log/traefik # Volume to store Traefik logs
|
- ./config/traefik/logs:/var/log/traefik # Volume to store Traefik logs
|
||||||
|
|
||||||
{{if .IsPostgreSQL}}postgres:
|
{{if .IsPostgreSQL}}
|
||||||
|
postgres:
|
||||||
image: postgres:18
|
image: postgres:18
|
||||||
container_name: postgres
|
container_name: postgres
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
@@ -83,9 +91,11 @@ services:
|
|||||||
timeout: 5s
|
timeout: 5s
|
||||||
retries: 5
|
retries: 5
|
||||||
networks:
|
networks:
|
||||||
- backend{{end}}
|
- backend
|
||||||
|
{{end}}
|
||||||
|
|
||||||
{{if .IsRedis}}redis:
|
{{if .IsRedis}}
|
||||||
|
redis:
|
||||||
image: redis:8-trixie
|
image: redis:8-trixie
|
||||||
container_name: redis
|
container_name: redis
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
@@ -103,14 +113,17 @@ services:
|
|||||||
retries: 3
|
retries: 3
|
||||||
start_period: 10s
|
start_period: 10s
|
||||||
networks:
|
networks:
|
||||||
- backend{{end}}
|
- backend
|
||||||
|
{{end}}
|
||||||
|
|
||||||
networks:
|
networks:
|
||||||
default:
|
default:
|
||||||
driver: bridge
|
driver: bridge
|
||||||
name: pangolin_frontend
|
name: pangolin_frontend
|
||||||
{{if .EnableIPv6}} enable_ipv6: true{{end}}
|
{{if .EnableIPv6}} enable_ipv6: true{{end}}
|
||||||
{{if or .IsPostgreSQL .IsRedis}} backend:
|
{{if or .IsPostgreSQL .IsRedis}}
|
||||||
|
backend:
|
||||||
driver: bridge
|
driver: bridge
|
||||||
name: pangolin_backend
|
name: pangolin_backend
|
||||||
internal: true{{end}}
|
internal: true
|
||||||
|
{{end}}
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
{{if .IsRedis}}redis:
|
{{if .IsRedis}}
|
||||||
|
redis:
|
||||||
host: "redis"
|
host: "redis"
|
||||||
port: 6379
|
port: 6379
|
||||||
password: "{{.IsRedisPass}}"{{end}}
|
password: "{{.IsRedisPass}}"
|
||||||
|
{{end}}
|
||||||
|
|||||||
+2
-2
@@ -5,7 +5,7 @@ go 1.25.0
|
|||||||
require (
|
require (
|
||||||
github.com/charmbracelet/huh v1.0.0
|
github.com/charmbracelet/huh v1.0.0
|
||||||
github.com/charmbracelet/lipgloss v1.1.0
|
github.com/charmbracelet/lipgloss v1.1.0
|
||||||
golang.org/x/term v0.44.0
|
golang.org/x/term v0.43.0
|
||||||
gopkg.in/yaml.v3 v3.0.1
|
gopkg.in/yaml.v3 v3.0.1
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -33,6 +33,6 @@ require (
|
|||||||
github.com/rivo/uniseg v0.4.7 // indirect
|
github.com/rivo/uniseg v0.4.7 // indirect
|
||||||
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
|
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
|
||||||
golang.org/x/sync v0.15.0 // indirect
|
golang.org/x/sync v0.15.0 // indirect
|
||||||
golang.org/x/sys v0.46.0 // indirect
|
golang.org/x/sys v0.44.0 // indirect
|
||||||
golang.org/x/text v0.23.0 // indirect
|
golang.org/x/text v0.23.0 // indirect
|
||||||
)
|
)
|
||||||
|
|||||||
+4
-4
@@ -69,10 +69,10 @@ golang.org/x/sync v0.15.0 h1:KWH3jNZsfyT6xfAfKiz6MRNmd46ByHDYaZ7KSkCtdW8=
|
|||||||
golang.org/x/sync v0.15.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
|
golang.org/x/sync v0.15.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
|
||||||
golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.46.0 h1:noSf2Fq6F8DBgS+LysIkx7rIExoNHJsxOAtPp4rthXw=
|
golang.org/x/sys v0.44.0 h1:ildZl3J4uzeKP07r2F++Op7E9B29JRUy+a27EibtBTQ=
|
||||||
golang.org/x/sys v0.46.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
golang.org/x/sys v0.44.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||||
golang.org/x/term v0.44.0 h1:0rLvDRCtNj0gZkyIXhCyOb2OAzEhLVqc4B+hrsBhrmc=
|
golang.org/x/term v0.43.0 h1:S4RLU2sB31O/NCl+zFN9Aru9A/Cq2aqKpTZJ6B+DwT4=
|
||||||
golang.org/x/term v0.44.0/go.mod h1:7ze4MdzUzLXpSAoFP1H0bOI9aXDqveSvatT5vKcFh2Y=
|
golang.org/x/term v0.43.0/go.mod h1:lrhlHNdQJHO+1qVYiHfFKVuVioJIheAc3fBSMFYEIsk=
|
||||||
golang.org/x/text v0.23.0 h1:D71I7dUrlY+VX0gQShAThNGHFxZ13dGLBHQLVl1mJlY=
|
golang.org/x/text v0.23.0 h1:D71I7dUrlY+VX0gQShAThNGHFxZ13dGLBHQLVl1mJlY=
|
||||||
golang.org/x/text v0.23.0/go.mod h1:/BLNzu4aZCJ1+kcD0DNRotWKage4q2rGVAg4o22unh4=
|
golang.org/x/text v0.23.0/go.mod h1:/BLNzu4aZCJ1+kcD0DNRotWKage4q2rGVAg4o22unh4=
|
||||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
|
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
|
||||||
|
|||||||
+4
-7
@@ -71,12 +71,9 @@ const (
|
|||||||
Undefined SupportedContainer = "undefined"
|
Undefined SupportedContainer = "undefined"
|
||||||
)
|
)
|
||||||
|
|
||||||
var redisFlag *bool
|
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
|
|
||||||
crowdsecFlag := flag.Bool("crowdsec", false, "Enable the CrowdSec installation prompt")
|
crowdsecFlag := flag.Bool("crowdsec", false, "Enable the CrowdSec installation prompt")
|
||||||
redisFlag = flag.Bool("redis", false, "Install Redis as caching solution. Required for HA. Not required for the Enterprise version.")
|
|
||||||
flag.Parse()
|
flag.Parse()
|
||||||
|
|
||||||
// print a banner about prerequisites - opening port 80, 443, 51820, and 21820 on the VPS and firewall and pointing your domain to the VPS IP with a records. Docs are at http://localhost:3000/Getting%20Started/dns-networking
|
// print a banner about prerequisites - opening port 80, 443, 51820, and 21820 on the VPS and firewall and pointing your domain to the VPS IP with a records. Docs are at http://localhost:3000/Getting%20Started/dns-networking
|
||||||
@@ -494,13 +491,13 @@ func collectUserInput() Config {
|
|||||||
|
|
||||||
config.IsEnterprise = readBoolNoDefault("Do you want to install the Enterprise version of Pangolin? The EE is free for personal use or for businesses making less than 100k USD annually.")
|
config.IsEnterprise = readBoolNoDefault("Do you want to install the Enterprise version of Pangolin? The EE is free for personal use or for businesses making less than 100k USD annually.")
|
||||||
if config.IsEnterprise {
|
if config.IsEnterprise {
|
||||||
if *redisFlag {
|
config.IsRedis = readBool("Do you want to run the Redis containers locally? Required for HA.")
|
||||||
config.IsRedis = true
|
if config.IsRedis {
|
||||||
config.IsRedisPass = readPassword("Enter a unique password for the Redis service.")
|
config.IsRedisPass = readPassword("Enter a unique password for the Redis service.")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
config.IsPostgreSQL = readBool("Do you want to use PostgreSQL (not recommended for most users)?", false)
|
config.IsPostgreSQL = readBool("Do you want to run the PostgreSQL containers locally? Otherwise, default to the local SQLite database only.", false)
|
||||||
if config.IsPostgreSQL {
|
if config.IsPostgreSQL {
|
||||||
config.IsPostgreSQLPass = readPassword("Enter a unique password for the PostgreSQL pangolin user.")
|
config.IsPostgreSQLPass = readPassword("Enter a unique password for the PostgreSQL pangolin user.")
|
||||||
}
|
}
|
||||||
@@ -547,7 +544,7 @@ func collectUserInput() Config {
|
|||||||
fmt.Println("\n=== Advanced Configuration ===")
|
fmt.Println("\n=== Advanced Configuration ===")
|
||||||
|
|
||||||
config.EnableIPv6 = readBool("Is your server IPv6 capable?", true)
|
config.EnableIPv6 = readBool("Is your server IPv6 capable?", true)
|
||||||
config.EnableMaxMind = readBool("Do you want to download the MaxMind GeoLite2 Country and ASN databases for blocking functionality?", true)
|
config.EnableMaxMind = readBool("Do you want to download the MaxMind GeoLite2 Country and ADN databases for blocking functionality?", true)
|
||||||
|
|
||||||
if config.DashboardDomain == "" {
|
if config.DashboardDomain == "" {
|
||||||
fmt.Println("Error: Dashboard Domain name is required")
|
fmt.Println("Error: Dashboard Domain name is required")
|
||||||
|
|||||||
+192
-413
File diff suppressed because it is too large
Load Diff
+189
-410
File diff suppressed because it is too large
Load Diff
-3820
File diff suppressed because it is too large
Load Diff
+188
-409
File diff suppressed because it is too large
Load Diff
+62
-430
@@ -66,15 +66,9 @@
|
|||||||
"local": "Local",
|
"local": "Local",
|
||||||
"edit": "Edit",
|
"edit": "Edit",
|
||||||
"siteConfirmDelete": "Confirm Delete Site",
|
"siteConfirmDelete": "Confirm Delete Site",
|
||||||
"siteConfirmDeleteAndResources": "Confirm Delete Site and Resources",
|
|
||||||
"siteDelete": "Delete Site",
|
"siteDelete": "Delete Site",
|
||||||
"siteDeleteAndResources": "Delete Site and Resources",
|
"siteMessageRemove": "Once removed the site will no longer be accessible. All targets associated with the site will also be removed.",
|
||||||
"siteMessageRemove": "Once removed the site will no longer be accessible. Targets associated with this site will be removed, but resources will remain.",
|
|
||||||
"siteMessageRemoveAndResources": "This will permanently delete all public and private resources linked to this site, even if a resource is also associated with other sites.",
|
|
||||||
"siteQuestionRemove": "Are you sure you want to remove the site from the organization?",
|
"siteQuestionRemove": "Are you sure you want to remove the site from the organization?",
|
||||||
"siteQuestionRemoveAndResources": "Are you sure you want to delete this site and all associated resources?",
|
|
||||||
"sitesTableDeleteSite": "Delete Site",
|
|
||||||
"sitesTableDeleteSiteAndResources": "Delete Site and Resources",
|
|
||||||
"siteManageSites": "Manage Sites",
|
"siteManageSites": "Manage Sites",
|
||||||
"siteDescription": "Create and manage sites to enable connectivity to private networks",
|
"siteDescription": "Create and manage sites to enable connectivity to private networks",
|
||||||
"sitesBannerTitle": "Connect Any Network",
|
"sitesBannerTitle": "Connect Any Network",
|
||||||
@@ -107,8 +101,6 @@
|
|||||||
"sitesTableViewPrivateResources": "View Private Resources",
|
"sitesTableViewPrivateResources": "View Private Resources",
|
||||||
"siteInstallNewt": "Install Site",
|
"siteInstallNewt": "Install Site",
|
||||||
"siteInstallNewtDescription": "Install the site connector for your system",
|
"siteInstallNewtDescription": "Install the site connector for your system",
|
||||||
"siteInstallKubernetesDocsDescription": "For more and up to date Kubernetes installation information, see <docsLink>docs.pangolin.net/manage/sites/install-kubernetes</docsLink>.",
|
|
||||||
"siteInstallAdvantechDocsDescription": "For Advantech modem installation instructions, see <docsLink>docs.pangolin.net/manage/sites/install-advantech</docsLink>.",
|
|
||||||
"WgConfiguration": "WireGuard Configuration",
|
"WgConfiguration": "WireGuard Configuration",
|
||||||
"WgConfigurationDescription": "Use the following configuration to connect to the network",
|
"WgConfigurationDescription": "Use the following configuration to connect to the network",
|
||||||
"operatingSystem": "Operating System",
|
"operatingSystem": "Operating System",
|
||||||
@@ -123,16 +115,6 @@
|
|||||||
"siteUpdated": "Site updated",
|
"siteUpdated": "Site updated",
|
||||||
"siteUpdatedDescription": "The site has been updated.",
|
"siteUpdatedDescription": "The site has been updated.",
|
||||||
"siteGeneralDescription": "Configure the general settings for this site",
|
"siteGeneralDescription": "Configure the general settings for this site",
|
||||||
"siteRestartTitle": "Restart Site",
|
|
||||||
"siteRestartDescription": "Restart the WireGuard tunnel for this site. This will briefly interrupt connectivity.",
|
|
||||||
"siteRestartBody": "Use this if the site tunnel is not functioning correctly and you want to force a reconnect without restarting the host.",
|
|
||||||
"siteRestartButton": "Restart Site",
|
|
||||||
"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.",
|
|
||||||
"siteRestarted": "Site restarted",
|
|
||||||
"siteRestartedDescription": "The WireGuard tunnel has been restarted.",
|
|
||||||
"siteErrorRestart": "Failed to restart site",
|
|
||||||
"siteErrorRestartDescription": "An error occurred while restarting the site.",
|
|
||||||
"siteSettingDescription": "Configure the settings on the site",
|
"siteSettingDescription": "Configure the settings on the site",
|
||||||
"siteResourcesTab": "Resources",
|
"siteResourcesTab": "Resources",
|
||||||
"siteResourcesNoneOnSite": "This site has no public or private resources yet.",
|
"siteResourcesNoneOnSite": "This site has no public or private resources yet.",
|
||||||
@@ -166,19 +148,19 @@
|
|||||||
"siteCredentialsSaveDescription": "You will only be able to see this once. Make sure to copy it to a secure place.",
|
"siteCredentialsSaveDescription": "You will only be able to see this once. Make sure to copy it to a secure place.",
|
||||||
"siteInfo": "Site Information",
|
"siteInfo": "Site Information",
|
||||||
"status": "Status",
|
"status": "Status",
|
||||||
"shareTitle": "Manage Shareable Links",
|
"shareTitle": "Manage Share Links",
|
||||||
"shareDescription": "Create shareable links to grant temporary or permanent access to proxy resources",
|
"shareDescription": "Create shareable links to grant temporary or permanent access to proxy resources",
|
||||||
"shareSearch": "Search shareable links...",
|
"shareSearch": "Search share links...",
|
||||||
"shareCreate": "Create Shareable Link",
|
"shareCreate": "Create Share Link",
|
||||||
"shareErrorDelete": "Failed to delete link",
|
"shareErrorDelete": "Failed to delete link",
|
||||||
"shareErrorDeleteMessage": "An error occurred deleting link",
|
"shareErrorDeleteMessage": "An error occurred deleting link",
|
||||||
"shareDeleted": "Link deleted",
|
"shareDeleted": "Link deleted",
|
||||||
"shareDeletedDescription": "The link has been deleted",
|
"shareDeletedDescription": "The link has been deleted",
|
||||||
"shareDelete": "Delete Shareable Link",
|
"shareDelete": "Delete Share Link",
|
||||||
"shareDeleteConfirm": "Confirm Delete Shareable Link",
|
"shareDeleteConfirm": "Confirm Delete Share Link",
|
||||||
"shareQuestionRemove": "Are you sure you want to delete this share link?",
|
"shareQuestionRemove": "Are you sure you want to delete this share link?",
|
||||||
"shareMessageRemove": "Once deleted, the link will no longer work and anyone using it will lose access to the resource.",
|
"shareMessageRemove": "Once deleted, the link will no longer work and anyone using it will lose access to the resource.",
|
||||||
"shareTokenDescription": "The access token can be passed as a query parameter or in request headers. By default it must be sent on every request. If session persistence is enabled, the first request exchanges it for a session cookie.",
|
"shareTokenDescription": "The access token can be passed in two ways: as a query parameter or in the request headers. These must be passed from the client on every request for authenticated access.",
|
||||||
"accessToken": "Access Token",
|
"accessToken": "Access Token",
|
||||||
"usageExamples": "Usage Examples",
|
"usageExamples": "Usage Examples",
|
||||||
"tokenId": "Token ID",
|
"tokenId": "Token ID",
|
||||||
@@ -195,15 +177,8 @@
|
|||||||
"shareCreateDescription": "Anyone with this link can access the resource",
|
"shareCreateDescription": "Anyone with this link can access the resource",
|
||||||
"shareTitleOptional": "Title (optional)",
|
"shareTitleOptional": "Title (optional)",
|
||||||
"sharePathOptional": "Path (optional)",
|
"sharePathOptional": "Path (optional)",
|
||||||
"sharePathDescription": "The link will redirect users to this path after authentication.",
|
|
||||||
"shareAssociateUserOptional": "Associate User (optional)",
|
|
||||||
"shareAssociateUserDescription": "When set, requests using this link are attributed to the user in access logs and identity headers. The link is removed if the user leaves the organization.",
|
|
||||||
"userSelect": "Select user",
|
|
||||||
"usersNotFound": "No users found",
|
|
||||||
"expireIn": "Expire In",
|
"expireIn": "Expire In",
|
||||||
"neverExpire": "Never expire",
|
"neverExpire": "Never expire",
|
||||||
"sharePersistSession": "Persist session after first use",
|
|
||||||
"sharePersistSessionDescription": "When enabled, the first request with this token via a query param or header sets a session cookie so later requests do not need the token. Leave off for API clients that should send the token on every request.",
|
|
||||||
"shareExpireDescription": "Expiration time is how long the link will be usable and provide access to the resource. After this time, the link will no longer work, and users who used this link will lose access to the resource.",
|
"shareExpireDescription": "Expiration time is how long the link will be usable and provide access to the resource. After this time, the link will no longer work, and users who used this link will lose access to the resource.",
|
||||||
"shareSeeOnce": "You will only be able to see this link once. Make sure to copy it.",
|
"shareSeeOnce": "You will only be able to see this link once. Make sure to copy it.",
|
||||||
"shareAccessHint": "Anyone with this link can access the resource. Share it with care.",
|
"shareAccessHint": "Anyone with this link can access the resource. Share it with care.",
|
||||||
@@ -225,8 +200,8 @@
|
|||||||
"shareErrorSelectResource": "Please select a resource",
|
"shareErrorSelectResource": "Please select a resource",
|
||||||
"proxyResourceTitle": "Manage Public Resources",
|
"proxyResourceTitle": "Manage Public Resources",
|
||||||
"proxyResourceDescription": "Create and manage resources that are publicly accessible through a web browser",
|
"proxyResourceDescription": "Create and manage resources that are publicly accessible through a web browser",
|
||||||
"publicResourcesBannerTitle": "Web-based Public Access",
|
"proxyResourcesBannerTitle": "Web-based Public Access",
|
||||||
"publicResourcesBannerDescription": "Public resources are proxies accessible to anyone on the internet through a web browser and include identity and context-aware access policies. Unlike private resources, they do not require client-side software.",
|
"proxyResourcesBannerDescription": "Public resources are HTTPS proxies accessible to anyone on the internet through a web browser. Unlike private resources, they do not require client-side software and can include identity and context-aware access policies.",
|
||||||
"clientResourceTitle": "Manage Private Resources",
|
"clientResourceTitle": "Manage Private Resources",
|
||||||
"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",
|
||||||
@@ -234,19 +209,15 @@
|
|||||||
"resourcesSearch": "Search resources...",
|
"resourcesSearch": "Search resources...",
|
||||||
"resourceAdd": "Add Resource",
|
"resourceAdd": "Add Resource",
|
||||||
"resourceErrorDelte": "Error deleting resource",
|
"resourceErrorDelte": "Error deleting resource",
|
||||||
"resourcePoliciesBannerTitle": "Re-use Authentication and Access Rules",
|
"resourcePoliciesTitle": "Manage Resource Policies",
|
||||||
"resourcePoliciesBannerDescription": "Shared resource policies let you define authentication methods and access rules once, then attach them to multiple public resources. When you update a policy, every linked resource inherits the change automatically.",
|
"resourcePoliciesAttachedResourcesColumnTitle": "Attached resources",
|
||||||
"resourcePoliciesBannerButtonText": "Learn More",
|
|
||||||
"resourcePoliciesTitle": "Manage Public Resource Policies",
|
|
||||||
"resourcePoliciesAttachedResourcesColumnTitle": "Resources",
|
|
||||||
"resourcePoliciesAttachedResources": "{count} resource(s)",
|
"resourcePoliciesAttachedResources": "{count} resource(s)",
|
||||||
"resourcePoliciesAttachedResourcesCount": "{count, plural, one {# resource} other {# resources}}",
|
|
||||||
"resourcePoliciesAttachedResourcesEmpty": "no resources",
|
"resourcePoliciesAttachedResourcesEmpty": "no resources",
|
||||||
"resourcePoliciesDescription": "Create and manage authentication policies to control access to your public resources",
|
"resourcePoliciesDescription": "Create and manage authentication policies to control access to your resources",
|
||||||
"resourcePoliciesSearch": "Search policies...",
|
"resourcePoliciesSearch": "Search policies...",
|
||||||
"resourcePoliciesAdd": "Add Policy",
|
"resourcePoliciesAdd": "Add Policy",
|
||||||
"resourcePoliciesDefaultBadgeText": "Default policy",
|
"resourcePoliciesDefaultBadgeText": "Default policy",
|
||||||
"resourcePoliciesCreate": "Create Public Resource Policy",
|
"resourcePoliciesCreate": "Create Resource Policy",
|
||||||
"resourcePoliciesCreateDescription": "Follow the steps below to create a new policy",
|
"resourcePoliciesCreateDescription": "Follow the steps below to create a new policy",
|
||||||
"resourcePolicyName": "Policy Name",
|
"resourcePolicyName": "Policy Name",
|
||||||
"resourcePolicyNameDescription": "Give this policy a name to identify it across your resources",
|
"resourcePolicyNameDescription": "Give this policy a name to identify it across your resources",
|
||||||
@@ -272,8 +243,6 @@
|
|||||||
"resourceRawDescriptionCloud": "Proxy requests over raw TCP/UDP using a port number. Requires sites to connect to a remote node.",
|
"resourceRawDescriptionCloud": "Proxy requests over raw TCP/UDP using a port number. Requires sites to connect to a remote node.",
|
||||||
"resourceCreate": "Create Resource",
|
"resourceCreate": "Create Resource",
|
||||||
"resourceCreateDescription": "Follow the steps below to create a new resource",
|
"resourceCreateDescription": "Follow the steps below to create a new resource",
|
||||||
"resourcePublicCreate": "Create Public Resource",
|
|
||||||
"resourcePublicCreateDescription": "Follow the steps below to create a new public resource that is accessible through a web browser",
|
|
||||||
"resourceCreateGeneralDescription": "Configure the basic resource settings including the name and the type",
|
"resourceCreateGeneralDescription": "Configure the basic resource settings including the name and the type",
|
||||||
"resourceSeeAll": "See All Resources",
|
"resourceSeeAll": "See All Resources",
|
||||||
"resourceCreateGeneral": "General",
|
"resourceCreateGeneral": "General",
|
||||||
@@ -305,7 +274,7 @@
|
|||||||
"back": "Back",
|
"back": "Back",
|
||||||
"cancel": "Cancel",
|
"cancel": "Cancel",
|
||||||
"resourceConfig": "Configuration Snippets",
|
"resourceConfig": "Configuration Snippets",
|
||||||
"resourceConfigDescription": "Copy and paste these configuration snippets to set up the TCP/UDP resource.",
|
"resourceConfigDescription": "Copy and paste these configuration snippets to set up the TCP/UDP resource",
|
||||||
"resourceAddEntrypoints": "Traefik: Add Entrypoints",
|
"resourceAddEntrypoints": "Traefik: Add Entrypoints",
|
||||||
"resourceExposePorts": "Gerbil: Expose Ports in Docker Compose",
|
"resourceExposePorts": "Gerbil: Expose Ports in Docker Compose",
|
||||||
"resourceLearnRaw": "Learn how to configure TCP/UDP resources",
|
"resourceLearnRaw": "Learn how to configure TCP/UDP resources",
|
||||||
@@ -318,8 +287,6 @@
|
|||||||
"labelDelete": "Delete Label",
|
"labelDelete": "Delete Label",
|
||||||
"labelAdd": "Add Label",
|
"labelAdd": "Add Label",
|
||||||
"labelCreateSuccessMessage": "Label Created Successfully",
|
"labelCreateSuccessMessage": "Label Created Successfully",
|
||||||
"labelDuplicateError": "Duplicate Label",
|
|
||||||
"labelDuplicateErrorDescription": "A label with this name already exists.",
|
|
||||||
"labelEditSuccessMessage": "Label Modified Successfully",
|
"labelEditSuccessMessage": "Label Modified Successfully",
|
||||||
"labelNameField": "Label Name",
|
"labelNameField": "Label Name",
|
||||||
"labelColorField": "Label Color",
|
"labelColorField": "Label Color",
|
||||||
@@ -344,7 +311,7 @@
|
|||||||
"rules": "Rules",
|
"rules": "Rules",
|
||||||
"resourceSettingDescription": "Configure the settings on the resource",
|
"resourceSettingDescription": "Configure the settings on the resource",
|
||||||
"resourceSetting": "{resourceName} Settings",
|
"resourceSetting": "{resourceName} Settings",
|
||||||
"resourcePolicySettingDescription": "Configure the settings on this public resource policy",
|
"resourcePolicySettingDescription": "Configure the settings on the resource policy",
|
||||||
"resourcePolicySetting": "{policyName} Settings",
|
"resourcePolicySetting": "{policyName} Settings",
|
||||||
"alwaysAllow": "Bypass Auth",
|
"alwaysAllow": "Bypass Auth",
|
||||||
"alwaysDeny": "Block Access",
|
"alwaysDeny": "Block Access",
|
||||||
@@ -455,14 +422,8 @@
|
|||||||
"provisioningManage": "Provisioning",
|
"provisioningManage": "Provisioning",
|
||||||
"provisioningDescription": "Manage provisioning keys and review pending sites awaiting approval.",
|
"provisioningDescription": "Manage provisioning keys and review pending sites awaiting approval.",
|
||||||
"pendingSites": "Pending Sites",
|
"pendingSites": "Pending Sites",
|
||||||
"siteApproveSuccess": "Site and associated resources approved successfully",
|
"siteApproveSuccess": "Site approved successfully",
|
||||||
"siteApproveError": "Error approving site",
|
"siteApproveError": "Error approving site",
|
||||||
"siteReject": "Reject Site",
|
|
||||||
"siteQuestionReject": "Are you sure you want to reject this site?",
|
|
||||||
"siteMessageReject": "This will permanently delete the site and any associated resources that are still pending.",
|
|
||||||
"siteConfirmReject": "Confirm Reject Site",
|
|
||||||
"siteRejectSuccess": "Site rejected successfully",
|
|
||||||
"siteRejectError": "Error rejecting site",
|
|
||||||
"provisioningKeys": "Provisioning Keys",
|
"provisioningKeys": "Provisioning Keys",
|
||||||
"searchProvisioningKeys": "Search provisioning keys...",
|
"searchProvisioningKeys": "Search provisioning keys...",
|
||||||
"provisioningKeysAdd": "Generate Provisioning Key",
|
"provisioningKeysAdd": "Generate Provisioning Key",
|
||||||
@@ -478,12 +439,12 @@
|
|||||||
"provisioningKeysSave": "Save the provisioning key",
|
"provisioningKeysSave": "Save the provisioning key",
|
||||||
"provisioningKeysSaveDescription": "You will only be able to see this once. Copy it to a secure place.",
|
"provisioningKeysSaveDescription": "You will only be able to see this once. Copy it to a secure place.",
|
||||||
"provisioningKeysErrorCreate": "Error creating provisioning key",
|
"provisioningKeysErrorCreate": "Error creating provisioning key",
|
||||||
"provisioningKeysList": "New Provisioning Key",
|
"provisioningKeysList": "New provisioning key",
|
||||||
"provisioningKeysMaxBatchSize": "Max Batch Size",
|
"provisioningKeysMaxBatchSize": "Max batch size",
|
||||||
"provisioningKeysUnlimitedBatchSize": "Unlimited batch size (no limit)",
|
"provisioningKeysUnlimitedBatchSize": "Unlimited batch size (no limit)",
|
||||||
"provisioningKeysMaxBatchUnlimited": "Unlimited",
|
"provisioningKeysMaxBatchUnlimited": "Unlimited",
|
||||||
"provisioningKeysMaxBatchSizeInvalid": "Enter a valid max batch size (1–1,000,000).",
|
"provisioningKeysMaxBatchSizeInvalid": "Enter a valid max batch size (1–1,000,000).",
|
||||||
"provisioningKeysValidUntil": "Valid Until",
|
"provisioningKeysValidUntil": "Valid until",
|
||||||
"provisioningKeysValidUntilHint": "Leave empty for no expiration.",
|
"provisioningKeysValidUntilHint": "Leave empty for no expiration.",
|
||||||
"provisioningKeysValidUntilInvalid": "Enter a valid date and time.",
|
"provisioningKeysValidUntilInvalid": "Enter a valid date and time.",
|
||||||
"provisioningKeysNumUsed": "Times used",
|
"provisioningKeysNumUsed": "Times used",
|
||||||
@@ -492,7 +453,7 @@
|
|||||||
"provisioningKeysNeverUsed": "Never",
|
"provisioningKeysNeverUsed": "Never",
|
||||||
"provisioningKeysEdit": "Edit Provisioning Key",
|
"provisioningKeysEdit": "Edit Provisioning Key",
|
||||||
"provisioningKeysEditDescription": "Update the max batch size and expiration time for this key.",
|
"provisioningKeysEditDescription": "Update the max batch size and expiration time for this key.",
|
||||||
"provisioningKeysApproveNewSites": "Approve New Sites",
|
"provisioningKeysApproveNewSites": "Approve new sites",
|
||||||
"provisioningKeysApproveNewSitesDescription": "Automatically approve sites that register with this key.",
|
"provisioningKeysApproveNewSitesDescription": "Automatically approve sites that register with this key.",
|
||||||
"provisioningKeysUpdateError": "Error updating provisioning key",
|
"provisioningKeysUpdateError": "Error updating provisioning key",
|
||||||
"provisioningKeysUpdated": "Provisioning key updated",
|
"provisioningKeysUpdated": "Provisioning key updated",
|
||||||
@@ -627,8 +588,7 @@
|
|||||||
"idpNameInternal": "Internal",
|
"idpNameInternal": "Internal",
|
||||||
"emailInvalid": "Invalid email address",
|
"emailInvalid": "Invalid email address",
|
||||||
"inviteValidityDuration": "Please select a duration",
|
"inviteValidityDuration": "Please select a duration",
|
||||||
"accessRoleSelectPlease": "A user must belong to at least one role.",
|
"accessRoleSelectPlease": "Please select a role",
|
||||||
"accessRoleRequired": "Role required",
|
|
||||||
"removeOwnAdminRoleConfirmTitle": "Remove your administrator access?",
|
"removeOwnAdminRoleConfirmTitle": "Remove your administrator access?",
|
||||||
"removeOwnAdminRoleConfirmDescription": "You will no longer have administrator permissions in this organization after saving. Another administrator can restore access if needed.",
|
"removeOwnAdminRoleConfirmDescription": "You will no longer have administrator permissions in this organization after saving. Another administrator can restore access if needed.",
|
||||||
"removeOwnAdminRoleConfirmButton": "Remove My Administrator Access",
|
"removeOwnAdminRoleConfirmButton": "Remove My Administrator Access",
|
||||||
@@ -759,7 +719,7 @@
|
|||||||
"targetSubmit": "Add Target",
|
"targetSubmit": "Add Target",
|
||||||
"targetNoOne": "This resource doesn't have any targets. Add a target to configure where to send requests to the backend.",
|
"targetNoOne": "This resource doesn't have any targets. Add a target to configure where to send requests to the backend.",
|
||||||
"targetNoOneDescription": "Adding more than one target above will enable load balancing.",
|
"targetNoOneDescription": "Adding more than one target above will enable load balancing.",
|
||||||
"targetsSubmit": "Save Settings",
|
"targetsSubmit": "Save Targets",
|
||||||
"addTarget": "Add Target",
|
"addTarget": "Add Target",
|
||||||
"proxyMultiSiteRoundRobinNodeHelp": "Round robin routing will not work between sites that are not connected to the same node, but failover will work.",
|
"proxyMultiSiteRoundRobinNodeHelp": "Round robin routing will not work between sites that are not connected to the same node, but failover will work.",
|
||||||
"targetErrorInvalidIp": "Invalid IP address",
|
"targetErrorInvalidIp": "Invalid IP address",
|
||||||
@@ -793,11 +753,11 @@
|
|||||||
"rulesErrorDuplicate": "Duplicate rule",
|
"rulesErrorDuplicate": "Duplicate rule",
|
||||||
"rulesErrorDuplicateDescription": "A rule with these settings already exists",
|
"rulesErrorDuplicateDescription": "A rule with these settings already exists",
|
||||||
"rulesErrorInvalidIpAddressRange": "Invalid CIDR",
|
"rulesErrorInvalidIpAddressRange": "Invalid CIDR",
|
||||||
"rulesErrorInvalidIpAddressRangeDescription": "Enter a valid CIDR range (e.g., 10.0.0.0/8).",
|
"rulesErrorInvalidIpAddressRangeDescription": "Please enter a valid CIDR value",
|
||||||
"rulesErrorInvalidUrl": "Invalid path",
|
"rulesErrorInvalidUrl": "Invalid URL path",
|
||||||
"rulesErrorInvalidUrlDescription": "Enter a valid URL path or pattern (e.g., /api/*).",
|
"rulesErrorInvalidUrlDescription": "Please enter a valid URL path value",
|
||||||
"rulesErrorInvalidIpAddress": "Invalid IP address",
|
"rulesErrorInvalidIpAddress": "Invalid IP",
|
||||||
"rulesErrorInvalidIpAddressDescription": "Enter a valid IPv4 or IPv6 address.",
|
"rulesErrorInvalidIpAddressDescription": "Please enter a valid IP address",
|
||||||
"rulesErrorUpdate": "Failed to update rules",
|
"rulesErrorUpdate": "Failed to update rules",
|
||||||
"rulesErrorUpdateDescription": "An error occurred while updating rules",
|
"rulesErrorUpdateDescription": "An error occurred while updating rules",
|
||||||
"rulesUpdated": "Enable Rules",
|
"rulesUpdated": "Enable Rules",
|
||||||
@@ -805,24 +765,15 @@
|
|||||||
"rulesMatchIpAddressRangeDescription": "Enter an address in CIDR format (e.g., 103.21.244.0/22)",
|
"rulesMatchIpAddressRangeDescription": "Enter an address in CIDR format (e.g., 103.21.244.0/22)",
|
||||||
"rulesMatchIpAddress": "Enter an IP address (e.g., 103.21.244.12)",
|
"rulesMatchIpAddress": "Enter an IP address (e.g., 103.21.244.12)",
|
||||||
"rulesMatchUrl": "Enter a URL path or pattern (e.g., /api/v1/todos or /api/v1/*)",
|
"rulesMatchUrl": "Enter a URL path or pattern (e.g., /api/v1/todos or /api/v1/*)",
|
||||||
"rulesErrorInvalidPriority": "Invalid priority",
|
"rulesErrorInvalidPriority": "Invalid Priority",
|
||||||
"rulesErrorInvalidPriorityDescription": "Enter a whole number of 1 or higher.",
|
"rulesErrorInvalidPriorityDescription": "Please enter a valid priority",
|
||||||
"rulesErrorDuplicatePriority": "Duplicate priorities",
|
"rulesErrorDuplicatePriority": "Duplicate Priorities",
|
||||||
"rulesErrorDuplicatePriorityDescription": "Each rule must have a unique priority number.",
|
"rulesErrorDuplicatePriorityDescription": "Please enter unique priorities",
|
||||||
"rulesErrorValidation": "Invalid rules",
|
|
||||||
"rulesErrorValidationRuleDescription": "Rule {ruleNumber}: {message}",
|
|
||||||
"rulesErrorInvalidMatchTypeDescription": "Select a valid match type (path, IP, CIDR, country, region, or ASN).",
|
|
||||||
"rulesErrorValueRequired": "Enter a value for this rule.",
|
|
||||||
"rulesErrorInvalidCountry": "Invalid country",
|
|
||||||
"rulesErrorInvalidCountryDescription": "Select a valid country.",
|
|
||||||
"rulesErrorInvalidAsn": "Invalid ASN",
|
|
||||||
"rulesErrorInvalidAsnDescription": "Enter a valid ASN (e.g., AS15169).",
|
|
||||||
"ruleUpdated": "Rules updated",
|
"ruleUpdated": "Rules updated",
|
||||||
"ruleUpdatedDescription": "Rules updated successfully",
|
"ruleUpdatedDescription": "Rules updated successfully",
|
||||||
"ruleErrorUpdate": "Operation failed",
|
"ruleErrorUpdate": "Operation failed",
|
||||||
"ruleErrorUpdateDescription": "An error occurred during the save operation",
|
"ruleErrorUpdateDescription": "An error occurred during the save operation",
|
||||||
"rulesPriority": "Priority",
|
"rulesPriority": "Priority",
|
||||||
"rulesReorderDragHandle": "Drag to reorder rule priority",
|
|
||||||
"rulesAction": "Action",
|
"rulesAction": "Action",
|
||||||
"rulesMatchType": "Match Type",
|
"rulesMatchType": "Match Type",
|
||||||
"value": "Value",
|
"value": "Value",
|
||||||
@@ -841,7 +792,7 @@
|
|||||||
"rulesResource": "Resource Rules Configuration",
|
"rulesResource": "Resource Rules Configuration",
|
||||||
"rulesResourceDescription": "Configure rules to control access to the resource",
|
"rulesResourceDescription": "Configure rules to control access to the resource",
|
||||||
"ruleSubmit": "Add Rule",
|
"ruleSubmit": "Add Rule",
|
||||||
"rulesNoOne": "No rules yet.",
|
"rulesNoOne": "No rules. Add a rule using the form.",
|
||||||
"rulesOrder": "Rules are evaluated by priority in ascending order.",
|
"rulesOrder": "Rules are evaluated by priority in ascending order.",
|
||||||
"rulesSubmit": "Save Rules",
|
"rulesSubmit": "Save Rules",
|
||||||
"policyErrorCreate": "Error creating policy",
|
"policyErrorCreate": "Error creating policy",
|
||||||
@@ -852,48 +803,7 @@
|
|||||||
"policyErrorUpdateMessageDescription": "An unexpected error occurred",
|
"policyErrorUpdateMessageDescription": "An unexpected error occurred",
|
||||||
"policyCreatedSuccess": "Resource policy succesfully created",
|
"policyCreatedSuccess": "Resource policy succesfully created",
|
||||||
"policyUpdatedSuccess": "Resource policy succesfully updated",
|
"policyUpdatedSuccess": "Resource policy succesfully updated",
|
||||||
"authMethodsSave": "Save Settings",
|
"authMethodsSave": "Save auth methods",
|
||||||
"policyAuthStackTitle": "Authentication",
|
|
||||||
"policyAuthStackDescription": "Control which authentication methods are required to access this resource",
|
|
||||||
"policyAuthOrLogicTitle": "Multiple authentication methods active",
|
|
||||||
"policyAuthOrLogicBanner": "Visitors may authenticate using any one of the active methods below. They do not need to complete all of them.",
|
|
||||||
"policyAuthMethodActive": "Active",
|
|
||||||
"policyAuthMethodOff": "Off",
|
|
||||||
"policyAuthSsoTitle": "Platform SSO",
|
|
||||||
"policyAuthSsoDescription": "Require sign-in through your organization's identity provider",
|
|
||||||
"policyAuthSsoSummary": "{idp} · {users} users, {roles} roles",
|
|
||||||
"policyAuthSsoDefaultIdp": "Default provider",
|
|
||||||
"policyAuthAddDefaultIdentityProvider": "Add Default Identity Provider",
|
|
||||||
"policyAuthOtherMethodsTitle": "Other Methods",
|
|
||||||
"policyAuthOtherMethodsDescription": "Optional methods visitors can use instead of or alongside platform SSO",
|
|
||||||
"policyAuthPasscodeTitle": "Passcode",
|
|
||||||
"policyAuthPasscodeDescription": "Require a shared alphanumeric passcode to access the resource",
|
|
||||||
"policyAuthPasscodeSummary": "Passcode set",
|
|
||||||
"policyAuthPincodeTitle": "PIN Code",
|
|
||||||
"policyAuthPincodeDescription": "A short numeric code required to access the resource",
|
|
||||||
"policyAuthPincodeSummary": "6-digit PIN set",
|
|
||||||
"policyAuthEmailTitle": "Email Whitelist",
|
|
||||||
"policyAuthEmailDescription": "Allow listed email addresses with one-time passwords",
|
|
||||||
"policyAuthEmailSummary": "{count} addresses allowed",
|
|
||||||
"policyAuthEmailOtpCallout": "Enabling email whitelist sends a one-time password to the visitor's email on login.",
|
|
||||||
"policyAuthHeaderAuthTitle": "Basic Header Auth",
|
|
||||||
"policyAuthHeaderAuthDescription": "Validate a custom HTTP header name and value on each request",
|
|
||||||
"policyAuthHeaderAuthSummary": "Header configured",
|
|
||||||
"policyAuthHeaderName": "Username",
|
|
||||||
"policyAuthHeaderValue": "Password",
|
|
||||||
"policyAuthSetPasscode": "Set Passcode",
|
|
||||||
"policyAuthSetPincode": "Set PIN Code",
|
|
||||||
"policyAuthSetEmailWhitelist": "Set Email Whitelist",
|
|
||||||
"policyAuthSetHeaderAuth": "Set Basic Header Auth",
|
|
||||||
"policyAccessRulesTitle": "Access Rules",
|
|
||||||
"policyAccessRulesEnableDescription": "When enabled, rules are evaluated in descending order until one evaluates as true.",
|
|
||||||
"policyAccessRulesFirstMatch": "Rules are evaluated top to bottom. The first matching rule decides the outcome.",
|
|
||||||
"policyAccessRulesHowItWorks": "Rules match requests by path, IP address, location, or other criteria. Each rule applies an action: bypass authentication, block access, or pass to authentication. If no rule matches, traffic continues to authentication.",
|
|
||||||
"policyAccessRulesFallthroughOff": "When rules are disabled, all traffic passes through to authentication.",
|
|
||||||
"policyAccessRulesFallthroughOn": "When no rule matches, traffic passes through to authentication.",
|
|
||||||
"rulesPlaceholderCidr": "10.0.0.0/8",
|
|
||||||
"rulesPlaceholderPath": "/admin/*",
|
|
||||||
"rulesPlaceholderGeo": "RU, KP",
|
|
||||||
"rulesSave": "Save Rules",
|
"rulesSave": "Save Rules",
|
||||||
"resourceErrorCreate": "Error creating resource",
|
"resourceErrorCreate": "Error creating resource",
|
||||||
"resourceErrorCreateDescription": "An error occurred when creating the resource",
|
"resourceErrorCreateDescription": "An error occurred when creating the resource",
|
||||||
@@ -914,9 +824,9 @@
|
|||||||
"resourcesErrorUpdateDescription": "An error occurred while updating the resource",
|
"resourcesErrorUpdateDescription": "An error occurred while updating the resource",
|
||||||
"access": "Access",
|
"access": "Access",
|
||||||
"accessControl": "Access Control",
|
"accessControl": "Access Control",
|
||||||
"shareLink": "{resource} Shareable Link",
|
"shareLink": "{resource} Share Link",
|
||||||
"resourceSelect": "Select resource",
|
"resourceSelect": "Select resource",
|
||||||
"shareLinks": "Shareable Links",
|
"shareLinks": "Share Links",
|
||||||
"share": "Shareable Links",
|
"share": "Shareable Links",
|
||||||
"shareDescription2": "Create shareable links to resources. Links provide temporary or unlimited access to your resource. You can configure the expiration duration of the link when you create one.",
|
"shareDescription2": "Create shareable links to resources. Links provide temporary or unlimited access to your resource. You can configure the expiration duration of the link when you create one.",
|
||||||
"shareEasyCreate": "Easy to create and share",
|
"shareEasyCreate": "Easy to create and share",
|
||||||
@@ -1006,18 +916,10 @@
|
|||||||
"resourceRoleDescription": "Admins can always access this resource.",
|
"resourceRoleDescription": "Admins can always access this resource.",
|
||||||
"resourcePolicySelectTitle": "Resource Access Policy",
|
"resourcePolicySelectTitle": "Resource Access Policy",
|
||||||
"resourcePolicySelectDescription": "Select the resource policy type for authentication",
|
"resourcePolicySelectDescription": "Select the resource policy type for authentication",
|
||||||
"resourcePolicyTypeLabel": "Policy type",
|
|
||||||
"resourcePolicyLabel": "Resource policy",
|
|
||||||
"resourcePolicyInline": "Inline Resource Policy",
|
"resourcePolicyInline": "Inline Resource Policy",
|
||||||
"resourcePolicyInlineDescription": "Access Policy scoped to only this resource",
|
"resourcePolicyInlineDescription": "Access Policy scoped to only this resource",
|
||||||
"resourcePolicyShared": "Shared Resource Policy",
|
"resourcePolicyShared": "Shared Resource Policy",
|
||||||
"resourcePolicySharedDescription": "This resource uses a shared policy.",
|
"resourcePolicySharedDescription": "This resource uses a shared policy. Policy-level settings (auth methods, email whitelist) are locked. You can add resource-specific rules, roles, and users below.",
|
||||||
"sharedPolicy": "Shared Policy",
|
|
||||||
"sharedPolicyNoneDescription": "This resource has its own policy.",
|
|
||||||
"resourceSharedPolicyOwnDescription": "This resource has its own authentication and access rules controls.",
|
|
||||||
"resourceSharedPolicyInheritedDescription": "This resource inherits from <policyLink>{policyName}</policyLink>.",
|
|
||||||
"resourceSharedPolicyAuthenticationNotice": "This resource is using a shared policy. Some authentication settings can be edited on this resource to add to the policy. To change the underlying policy, you must edit to <policyLink>{policyName}</policyLink>.",
|
|
||||||
"resourceSharedPolicyRulesNotice": "This resource is using a shared policy. Some access rules can be edited on this resource. To change the underlying policy, you must edit <policyLink>{policyName}</policyLink>.",
|
|
||||||
"resourceUsersRoles": "Access Controls",
|
"resourceUsersRoles": "Access Controls",
|
||||||
"resourceUsersRolesDescription": "Configure which users and roles can visit this resource",
|
"resourceUsersRolesDescription": "Configure which users and roles can visit this resource",
|
||||||
"resourceUsersRolesSubmit": "Save Access Controls",
|
"resourceUsersRolesSubmit": "Save Access Controls",
|
||||||
@@ -1042,14 +944,7 @@
|
|||||||
"resourceVisibilityTitle": "Visibility",
|
"resourceVisibilityTitle": "Visibility",
|
||||||
"resourceVisibilityTitleDescription": "Completely enable or disable resource visibility",
|
"resourceVisibilityTitleDescription": "Completely enable or disable resource visibility",
|
||||||
"resourceGeneral": "General Settings",
|
"resourceGeneral": "General Settings",
|
||||||
"resourceGeneralDescription": "Configure name, address, and access policy for this resource.",
|
"resourceGeneralDescription": "Configure the general settings for this resource",
|
||||||
"resourceGeneralDetailsSubsection": "Resource Details",
|
|
||||||
"resourceGeneralDetailsSubsectionDescription": "Set the display name, identifier, and publicly accessible domain for this resource.",
|
|
||||||
"resourceGeneralDetailsSubsectionPortDescription": "Set the display name, identifier, and public port for this resource.",
|
|
||||||
"resourceGeneralPublicAddressSubsection": "Public Address",
|
|
||||||
"resourceGeneralPublicAddressSubsectionDescription": "Configure how users reach this resource.",
|
|
||||||
"resourceGeneralAuthenticationAccessSubsection": "Authentication & Access",
|
|
||||||
"resourceGeneralAuthenticationAccessSubsectionDescription": "Choose whether this resource uses its own policy or inherits from a shared policy.",
|
|
||||||
"resourceEnable": "Enable Resource",
|
"resourceEnable": "Enable Resource",
|
||||||
"resourceTransfer": "Transfer Resource",
|
"resourceTransfer": "Transfer Resource",
|
||||||
"resourceTransferDescription": "Transfer this resource to a different site",
|
"resourceTransferDescription": "Transfer this resource to a different site",
|
||||||
@@ -1325,14 +1220,11 @@
|
|||||||
"addLabels": "Add labels",
|
"addLabels": "Add labels",
|
||||||
"siteLabelsTab": "Labels",
|
"siteLabelsTab": "Labels",
|
||||||
"siteLabelsDescription": "Manage labels associated with this site.",
|
"siteLabelsDescription": "Manage labels associated with this site.",
|
||||||
"labelsNotFound": "No labels found.",
|
"labelsNotFound": "Labels not found",
|
||||||
"labelsEmptyCreateHint": "Start typing above to create a label.",
|
|
||||||
"labelSearch": "Search labels",
|
"labelSearch": "Search labels",
|
||||||
"labelSearchOrCreate": "Search or create a label",
|
|
||||||
"accessLabelFilterCount": "{count, plural, one {# label} other {# labels}}",
|
"accessLabelFilterCount": "{count, plural, one {# label} other {# labels}}",
|
||||||
"labelOverflowCount": "+{count, plural, one {# label} other {# labels}}",
|
"labelOverflowCount": "+{count, plural, one {# label} other {# labels}}",
|
||||||
"accessLabelFilterClear": "Clear label filters",
|
"accessLabelFilterClear": "Clear label filters",
|
||||||
"accessFilterClear": "Clear filters",
|
|
||||||
"selectColor": "Select color",
|
"selectColor": "Select color",
|
||||||
"createNewLabel": "Create new org label \"{label}\"",
|
"createNewLabel": "Create new org label \"{label}\"",
|
||||||
"inviteInvalidDescription": "The invite link is invalid.",
|
"inviteInvalidDescription": "The invite link is invalid.",
|
||||||
@@ -1409,7 +1301,6 @@
|
|||||||
"createOrgUser": "Create Org User",
|
"createOrgUser": "Create Org User",
|
||||||
"actionUpdateOrg": "Update Organization",
|
"actionUpdateOrg": "Update Organization",
|
||||||
"actionRemoveInvitation": "Remove Invitation",
|
"actionRemoveInvitation": "Remove Invitation",
|
||||||
"actionRemoveUserRole": "Remove User Role",
|
|
||||||
"actionUpdateUser": "Update User",
|
"actionUpdateUser": "Update User",
|
||||||
"actionGetUser": "Get User",
|
"actionGetUser": "Get User",
|
||||||
"actionGetOrgUser": "Get Organization User",
|
"actionGetOrgUser": "Get Organization User",
|
||||||
@@ -1427,13 +1318,10 @@
|
|||||||
"actionApplyBlueprint": "Apply Blueprint",
|
"actionApplyBlueprint": "Apply Blueprint",
|
||||||
"actionListBlueprints": "List Blueprints",
|
"actionListBlueprints": "List Blueprints",
|
||||||
"actionGetBlueprint": "Get Blueprint",
|
"actionGetBlueprint": "Get Blueprint",
|
||||||
"actionCreateOrgWideLauncherView": "Create Org-Wide Launcher View",
|
|
||||||
"setupToken": "Setup Token",
|
"setupToken": "Setup Token",
|
||||||
"setupTokenDescription": "Enter the setup token from the server console.",
|
"setupTokenDescription": "Enter the setup token from the server console.",
|
||||||
"setupTokenRequired": "Setup token is required",
|
"setupTokenRequired": "Setup token is required",
|
||||||
"actionUpdateSite": "Update Site",
|
"actionUpdateSite": "Update Site",
|
||||||
"actionApproveSite": "Approve Site",
|
|
||||||
"actionRejectSite": "Reject Site",
|
|
||||||
"actionResetSiteBandwidth": "Reset Organization Bandwidth",
|
"actionResetSiteBandwidth": "Reset Organization Bandwidth",
|
||||||
"actionListSiteRoles": "List Allowed Site Roles",
|
"actionListSiteRoles": "List Allowed Site Roles",
|
||||||
"actionCreateResource": "Create Resource",
|
"actionCreateResource": "Create Resource",
|
||||||
@@ -1449,15 +1337,6 @@
|
|||||||
"actionSetResourcePincode": "Set Resource Pincode",
|
"actionSetResourcePincode": "Set Resource Pincode",
|
||||||
"actionSetResourceEmailWhitelist": "Set Resource Email Whitelist",
|
"actionSetResourceEmailWhitelist": "Set Resource Email Whitelist",
|
||||||
"actionGetResourceEmailWhitelist": "Get Resource Email Whitelist",
|
"actionGetResourceEmailWhitelist": "Get Resource Email Whitelist",
|
||||||
"actionGetResourcePolicy": "Get Resource Policy",
|
|
||||||
"actionUpdateResourcePolicy": "Update Resource Policy",
|
|
||||||
"actionSetResourcePolicyUsers": "Set Resource Policy Users",
|
|
||||||
"actionSetResourcePolicyRoles": "Set Resource Policy Roles",
|
|
||||||
"actionSetResourcePolicyPassword": "Set Resource Policy Password",
|
|
||||||
"actionSetResourcePolicyPincode": "Set Resource Policy Pincode",
|
|
||||||
"actionSetResourcePolicyHeaderAuth": "Set Resource Policy Header Authentication",
|
|
||||||
"actionSetResourcePolicyWhitelist": "Set Resource Policy Email Whitelist",
|
|
||||||
"actionSetResourcePolicyRules": "Set Resource Policy Rules",
|
|
||||||
"actionCreateTarget": "Create Target",
|
"actionCreateTarget": "Create Target",
|
||||||
"actionDeleteTarget": "Delete Target",
|
"actionDeleteTarget": "Delete Target",
|
||||||
"actionGetTarget": "Get Target",
|
"actionGetTarget": "Get Target",
|
||||||
@@ -1477,7 +1356,6 @@
|
|||||||
"actionGenerateAccessToken": "Generate Access Token",
|
"actionGenerateAccessToken": "Generate Access Token",
|
||||||
"actionDeleteAccessToken": "Delete Access Token",
|
"actionDeleteAccessToken": "Delete Access Token",
|
||||||
"actionListAccessTokens": "List Access Tokens",
|
"actionListAccessTokens": "List Access Tokens",
|
||||||
"actionCreateResourceSessionToken": "Create Resource Session Token",
|
|
||||||
"actionCreateResourceRule": "Create Resource Rule",
|
"actionCreateResourceRule": "Create Resource Rule",
|
||||||
"actionDeleteResourceRule": "Delete Resource Rule",
|
"actionDeleteResourceRule": "Delete Resource Rule",
|
||||||
"actionListResourceRules": "List Resource Rules",
|
"actionListResourceRules": "List Resource Rules",
|
||||||
@@ -1517,10 +1395,6 @@
|
|||||||
"actionListInvitations": "List Invitations",
|
"actionListInvitations": "List Invitations",
|
||||||
"actionExportLogs": "Export Logs",
|
"actionExportLogs": "Export Logs",
|
||||||
"actionViewLogs": "View Logs",
|
"actionViewLogs": "View Logs",
|
||||||
"actionCreateSiteProvisioningKey": "Create Site Provisioning Key",
|
|
||||||
"actionListSiteProvisioningKeys": "List Site Provisioning Keys",
|
|
||||||
"actionUpdateSiteProvisioningKey": "Update Site Provisioning Key",
|
|
||||||
"actionDeleteSiteProvisioningKey": "Delete Site Provisioning Key",
|
|
||||||
"noneSelected": "None selected",
|
"noneSelected": "None selected",
|
||||||
"orgNotFound2": "No organizations found.",
|
"orgNotFound2": "No organizations found.",
|
||||||
"search": "Search…",
|
"search": "Search…",
|
||||||
@@ -1535,35 +1409,10 @@
|
|||||||
"otpAuthDescription": "Enter the code from your authenticator app or one of your single-use backup codes.",
|
"otpAuthDescription": "Enter the code from your authenticator app or one of your single-use backup codes.",
|
||||||
"otpAuthSubmit": "Submit Code",
|
"otpAuthSubmit": "Submit Code",
|
||||||
"idpContinue": "Or continue with",
|
"idpContinue": "Or continue with",
|
||||||
"idpLastUsed": "Last Used",
|
|
||||||
"otpAuthBack": "Back to Password",
|
"otpAuthBack": "Back to Password",
|
||||||
"navbar": "Navigation Menu",
|
"navbar": "Navigation Menu",
|
||||||
"navbarDescription": "Main navigation menu for the application",
|
"navbarDescription": "Main navigation menu for the application",
|
||||||
"navbarDocsLink": "Documentation",
|
"navbarDocsLink": "Documentation",
|
||||||
"commandPaletteTitle": "Command Palette",
|
|
||||||
"commandPaletteDescription": "Search for pages, organizations, resources, and actions",
|
|
||||||
"commandPaletteSearchPlaceholder": "Search pages, resources, actions...",
|
|
||||||
"commandPaletteNoResults": "No results found.",
|
|
||||||
"commandPaletteSearching": "Searching...",
|
|
||||||
"commandPaletteNavigation": "Navigation",
|
|
||||||
"commandPaletteOrganizations": "Organizations",
|
|
||||||
"commandPaletteSites": "Sites",
|
|
||||||
"commandPaletteResources": "Resources",
|
|
||||||
"commandPaletteUsers": "Users",
|
|
||||||
"commandPaletteClients": "Machine Clients",
|
|
||||||
"commandPaletteActions": "Actions",
|
|
||||||
"commandPaletteCreateSite": "Create Site",
|
|
||||||
"commandPaletteCreateProxyResource": "Create Public Resource",
|
|
||||||
"commandPaletteCreatePrivateResource": "Create Private Resource",
|
|
||||||
"commandPaletteCreateUser": "Create User",
|
|
||||||
"commandPaletteCreateApiKey": "Create API Key",
|
|
||||||
"commandPaletteCreateMachineClient": "Create Machine Client",
|
|
||||||
"commandPaletteCreateAlertRule": "Create Alert Rule",
|
|
||||||
"commandPaletteCreateIdentityProvider": "Create Identity Provider",
|
|
||||||
"commandPaletteToggleTheme": "Toggle Theme",
|
|
||||||
"commandPaletteChooseOrganization": "Choose Organization",
|
|
||||||
"commandPaletteShortcutMac": "⌘K",
|
|
||||||
"commandPaletteShortcutWindows": "Ctrl K",
|
|
||||||
"otpErrorEnable": "Unable to enable 2FA",
|
"otpErrorEnable": "Unable to enable 2FA",
|
||||||
"otpErrorEnableDescription": "An error occurred while enabling 2FA",
|
"otpErrorEnableDescription": "An error occurred while enabling 2FA",
|
||||||
"otpSetupCheckCode": "Please enter a 6-digit code",
|
"otpSetupCheckCode": "Please enter a 6-digit code",
|
||||||
@@ -1612,8 +1461,8 @@
|
|||||||
"sidebarResources": "Resources",
|
"sidebarResources": "Resources",
|
||||||
"sidebarProxyResources": "Public",
|
"sidebarProxyResources": "Public",
|
||||||
"sidebarClientResources": "Private",
|
"sidebarClientResources": "Private",
|
||||||
"sidebarPolicies": "Shared Policies",
|
"sidebarPolicies": "Policies",
|
||||||
"sidebarResourcePolicies": "Public Resources",
|
"sidebarResourcePolicies": "Resources",
|
||||||
"sidebarAccessControl": "Access Control",
|
"sidebarAccessControl": "Access Control",
|
||||||
"sidebarLogsAndAnalytics": "Logs & Analytics",
|
"sidebarLogsAndAnalytics": "Logs & Analytics",
|
||||||
"sidebarTeam": "Team",
|
"sidebarTeam": "Team",
|
||||||
@@ -1621,7 +1470,7 @@
|
|||||||
"sidebarAdmin": "Admin",
|
"sidebarAdmin": "Admin",
|
||||||
"sidebarInvitations": "Invitations",
|
"sidebarInvitations": "Invitations",
|
||||||
"sidebarRoles": "Roles",
|
"sidebarRoles": "Roles",
|
||||||
"sidebarShareableLinks": "Shareable Links",
|
"sidebarShareableLinks": "Links",
|
||||||
"sidebarApiKeys": "API Keys",
|
"sidebarApiKeys": "API Keys",
|
||||||
"sidebarProvisioning": "Provisioning",
|
"sidebarProvisioning": "Provisioning",
|
||||||
"sidebarSettings": "Settings",
|
"sidebarSettings": "Settings",
|
||||||
@@ -1641,45 +1490,6 @@
|
|||||||
"sidebarManagement": "Management",
|
"sidebarManagement": "Management",
|
||||||
"sidebarBillingAndLicenses": "Billing & Licenses",
|
"sidebarBillingAndLicenses": "Billing & Licenses",
|
||||||
"sidebarLogsAnalytics": "Analytics",
|
"sidebarLogsAnalytics": "Analytics",
|
||||||
"commandSites": "Sites",
|
|
||||||
"commandActionModeInfo": "Type \">\" To Open Action Mode",
|
|
||||||
"commandResources": "Resources",
|
|
||||||
"commandProxyResources": "Public Resources",
|
|
||||||
"commandClientResources": "Private Resources",
|
|
||||||
"commandClients": "Clients",
|
|
||||||
"commandUserDevices": "User Devices",
|
|
||||||
"commandMachineClients": "Machine Clients",
|
|
||||||
"commandDomains": "Domains",
|
|
||||||
"commandRemoteExitNodes": "Remote Nodes",
|
|
||||||
"commandTeam": "Team",
|
|
||||||
"commandUsers": "Users",
|
|
||||||
"commandRoles": "Roles",
|
|
||||||
"commandInvitations": "Invitations",
|
|
||||||
"commandPolicies": "Shared Policies",
|
|
||||||
"commandResourcePolicies": "Public Resources Policies",
|
|
||||||
"commandIdentityProviders": "Identity Providers",
|
|
||||||
"commandApprovals": "Approval Requests",
|
|
||||||
"commandShareableLinks": "Shareable Links",
|
|
||||||
"commandOrganization": "Organization",
|
|
||||||
"commandLogsAndAnalytics": "Logs & Analytics",
|
|
||||||
"commandLogsAnalytics": "Analytics",
|
|
||||||
"commandLogsRequest": "HTTP Request Logs",
|
|
||||||
"commandLogsAccess": "Authentication Logs",
|
|
||||||
"commandLogsAction": "Admin Action Logs",
|
|
||||||
"commandLogsConnection": "Network Logs",
|
|
||||||
"commandLogsStreaming": "Event Streaming",
|
|
||||||
"commandManagement": "Management",
|
|
||||||
"commandAlerting": "Alerting",
|
|
||||||
"commandProvisioning": "Provisioning",
|
|
||||||
"commandBluePrints": "Blueprints",
|
|
||||||
"commandApiKeys": "API Keys",
|
|
||||||
"commandBillingAndLicenses": "Billing & Licenses",
|
|
||||||
"commandBilling": "Billing",
|
|
||||||
"commandEnterpriseLicenses": "Licenses",
|
|
||||||
"commandSettings": "Settings",
|
|
||||||
"commandLauncher": "Launcher",
|
|
||||||
"commandResourceLauncher": "Resource Launcher",
|
|
||||||
"commandSearchResults": "Search Results",
|
|
||||||
"alertingTitle": "Alerting",
|
"alertingTitle": "Alerting",
|
||||||
"alertingDescription": "Define sources, triggers, and actions for notifications",
|
"alertingDescription": "Define sources, triggers, and actions for notifications",
|
||||||
"alertingRules": "Alert rules",
|
"alertingRules": "Alert rules",
|
||||||
@@ -1751,7 +1561,7 @@
|
|||||||
"alertingActionType": "Action type",
|
"alertingActionType": "Action type",
|
||||||
"alertingNotifyUsers": "Users",
|
"alertingNotifyUsers": "Users",
|
||||||
"alertingNotifyRoles": "Roles",
|
"alertingNotifyRoles": "Roles",
|
||||||
"alertingNotifyEmails": "Email Addresses",
|
"alertingNotifyEmails": "Email addresses",
|
||||||
"alertingEmailPlaceholder": "Add email and press Enter",
|
"alertingEmailPlaceholder": "Add email and press Enter",
|
||||||
"alertingWebhookMethod": "HTTP method",
|
"alertingWebhookMethod": "HTTP method",
|
||||||
"alertingWebhookSecret": "Signing secret (optional)",
|
"alertingWebhookSecret": "Signing secret (optional)",
|
||||||
@@ -1837,7 +1647,7 @@
|
|||||||
"standaloneHcFilterResourceIdFallback": "Resource {id}",
|
"standaloneHcFilterResourceIdFallback": "Resource {id}",
|
||||||
"blueprints": "Blueprints",
|
"blueprints": "Blueprints",
|
||||||
"blueprintsLog": "Blueprints Log",
|
"blueprintsLog": "Blueprints Log",
|
||||||
"blueprintsDescription": "View past blueprint applications and their results or apply a new blueprint",
|
"blueprintsDescription": "View past blueprint applications and their results",
|
||||||
"blueprintAdd": "Add Blueprint",
|
"blueprintAdd": "Add Blueprint",
|
||||||
"blueprintGoBack": "See all Blueprints",
|
"blueprintGoBack": "See all Blueprints",
|
||||||
"blueprintCreate": "Create Blueprint",
|
"blueprintCreate": "Create Blueprint",
|
||||||
@@ -1857,10 +1667,10 @@
|
|||||||
"enableDockerSocket": "Enable Docker Blueprint",
|
"enableDockerSocket": "Enable Docker Blueprint",
|
||||||
"enableDockerSocketDescription": "Enable Docker Socket label scraping for blueprint labels. Socket path must be provided to the site connector. Read about how this works in <docsLink>the documentation</docsLink>.",
|
"enableDockerSocketDescription": "Enable Docker Socket label scraping for blueprint labels. Socket path must be provided to the site connector. Read about how this works in <docsLink>the documentation</docsLink>.",
|
||||||
"newtAutoUpdate": "Enable Site Auto-Update",
|
"newtAutoUpdate": "Enable Site Auto-Update",
|
||||||
"newtAutoUpdateDescription": "When enabled, site connectors will automatically download the latest version and restart themselves. This can be overridden on a per-site basis.",
|
"newtAutoUpdateDescription": "When enabled, site connectors will automatically update to the latest version when a new release is available.",
|
||||||
"siteAutoUpdate": "Site Auto-Update",
|
"siteAutoUpdate": "Site Auto-Update",
|
||||||
"siteAutoUpdateLabel": "Enable Auto-Update",
|
"siteAutoUpdateLabel": "Enable Auto-Update",
|
||||||
"siteAutoUpdateDescription": "When enabled, this site's connector will automatically download the latest version and restart itself.",
|
"siteAutoUpdateDescription": "Control whether this site's connector automatically downloads the latest version.",
|
||||||
"siteAutoUpdateOrgDefault": "Organization default: {state}",
|
"siteAutoUpdateOrgDefault": "Organization default: {state}",
|
||||||
"siteAutoUpdateOverriding": "Overriding organization setting",
|
"siteAutoUpdateOverriding": "Overriding organization setting",
|
||||||
"siteAutoUpdateResetToOrg": "Reset to Organization Default",
|
"siteAutoUpdateResetToOrg": "Reset to Organization Default",
|
||||||
@@ -1958,9 +1768,9 @@
|
|||||||
"accountSetupSuccess": "Account setup completed! Welcome to Pangolin!",
|
"accountSetupSuccess": "Account setup completed! Welcome to Pangolin!",
|
||||||
"documentation": "Documentation",
|
"documentation": "Documentation",
|
||||||
"saveAllSettings": "Save All Settings",
|
"saveAllSettings": "Save All Settings",
|
||||||
"saveResourceTargets": "Save Settings",
|
"saveResourceTargets": "Save Targets",
|
||||||
"saveResourceHttp": "Save Settings",
|
"saveResourceHttp": "Save Proxy Settings",
|
||||||
"saveProxyProtocol": "Save Settings",
|
"saveProxyProtocol": "Save Proxy protocol settings",
|
||||||
"settingsUpdated": "Settings updated",
|
"settingsUpdated": "Settings updated",
|
||||||
"settingsUpdatedDescription": "Settings updated successfully",
|
"settingsUpdatedDescription": "Settings updated successfully",
|
||||||
"settingsErrorUpdate": "Failed to update settings",
|
"settingsErrorUpdate": "Failed to update settings",
|
||||||
@@ -2011,9 +1821,6 @@
|
|||||||
"billingDomains": "Domains",
|
"billingDomains": "Domains",
|
||||||
"billingOrganizations": "Orgs",
|
"billingOrganizations": "Orgs",
|
||||||
"billingRemoteExitNodes": "Remote Nodes",
|
"billingRemoteExitNodes": "Remote Nodes",
|
||||||
"billingPublicResources": "Public Resources",
|
|
||||||
"billingPrivateResources": "Private Resources",
|
|
||||||
"billingMachineClients": "Machine Clients",
|
|
||||||
"billingNoLimitConfigured": "No limit configured",
|
"billingNoLimitConfigured": "No limit configured",
|
||||||
"billingEstimatedPeriod": "Estimated Billing Period",
|
"billingEstimatedPeriod": "Estimated Billing Period",
|
||||||
"billingIncludedUsage": "Included Usage",
|
"billingIncludedUsage": "Included Usage",
|
||||||
@@ -2042,9 +1849,6 @@
|
|||||||
"billingUsersInfo": "How many users you can use",
|
"billingUsersInfo": "How many users you can use",
|
||||||
"billingDomainInfo": "How many domains you can use",
|
"billingDomainInfo": "How many domains you can use",
|
||||||
"billingRemoteExitNodesInfo": "How many remote nodes you can use",
|
"billingRemoteExitNodesInfo": "How many remote nodes you can use",
|
||||||
"billingPublicResourcesInfo": "How many public resources you can use",
|
|
||||||
"billingPrivateResourcesInfo": "How many private resources you can use",
|
|
||||||
"billingMachineClientsInfo": "How many machine clients you can use",
|
|
||||||
"billingLicenseKeys": "License Keys",
|
"billingLicenseKeys": "License Keys",
|
||||||
"billingLicenseKeysDescription": "Manage your license key subscriptions",
|
"billingLicenseKeysDescription": "Manage your license key subscriptions",
|
||||||
"billingLicenseSubscription": "License Subscription",
|
"billingLicenseSubscription": "License Subscription",
|
||||||
@@ -2190,7 +1994,6 @@
|
|||||||
"subnetPlaceholder": "Subnet",
|
"subnetPlaceholder": "Subnet",
|
||||||
"addressDescription": "The internal address of the client. Must fall within the organization's subnet.",
|
"addressDescription": "The internal address of the client. Must fall within the organization's subnet.",
|
||||||
"selectSites": "Select sites",
|
"selectSites": "Select sites",
|
||||||
"selectLabels": "Select labels",
|
|
||||||
"sitesDescription": "The client will have connectivity to the selected sites",
|
"sitesDescription": "The client will have connectivity to the selected sites",
|
||||||
"clientInstallOlm": "Install Machine Client",
|
"clientInstallOlm": "Install Machine Client",
|
||||||
"clientInstallOlmDescription": "Install the machine client for your system",
|
"clientInstallOlmDescription": "Install the machine client for your system",
|
||||||
@@ -2224,13 +2027,13 @@
|
|||||||
"healthCheckUnknown": "Unknown",
|
"healthCheckUnknown": "Unknown",
|
||||||
"healthCheck": "Health Check",
|
"healthCheck": "Health Check",
|
||||||
"configureHealthCheck": "Configure Health Check",
|
"configureHealthCheck": "Configure Health Check",
|
||||||
"configureHealthCheckDescription": "Set up monitoring for your resource to ensure it is always available",
|
"configureHealthCheckDescription": "Set up health monitoring for {target}",
|
||||||
"enableHealthChecks": "Enable Health Checks",
|
"enableHealthChecks": "Enable Health Checks",
|
||||||
"healthCheckDisabledStateDescription": "When disabled, the site will not perform health checks and the state will be considered unknown.",
|
"healthCheckDisabledStateDescription": "When disabled, the site will not perform health checks and the state will be considered unknown.",
|
||||||
"enableHealthChecksDescription": "Monitor the health of this target. You can monitor a different endpoint than the target if required.",
|
"enableHealthChecksDescription": "Monitor the health of this target. You can monitor a different endpoint than the target if required.",
|
||||||
"healthScheme": "Method",
|
"healthScheme": "Method",
|
||||||
"healthSelectScheme": "Select Method",
|
"healthSelectScheme": "Select Method",
|
||||||
"healthCheckPortInvalid": "Port must be between 1 and 65535",
|
"healthCheckPortInvalid": "Health check port must be between 1 and 65535",
|
||||||
"healthCheckPath": "Path",
|
"healthCheckPath": "Path",
|
||||||
"healthHostname": "IP / Host",
|
"healthHostname": "IP / Host",
|
||||||
"healthPort": "Port",
|
"healthPort": "Port",
|
||||||
@@ -2243,7 +2046,6 @@
|
|||||||
"requireDeviceApproval": "Require Device Approvals",
|
"requireDeviceApproval": "Require Device Approvals",
|
||||||
"requireDeviceApprovalDescription": "Users with this role need new devices approved by an admin before they can connect and access resources.",
|
"requireDeviceApprovalDescription": "Users with this role need new devices approved by an admin before they can connect and access resources.",
|
||||||
"sshSettings": "SSH Settings",
|
"sshSettings": "SSH Settings",
|
||||||
"sshAccess": "SSH Access",
|
|
||||||
"rdpSettings": "RDP Settings",
|
"rdpSettings": "RDP Settings",
|
||||||
"vncSettings": "VNC Settings",
|
"vncSettings": "VNC Settings",
|
||||||
"sshServer": "SSH Server",
|
"sshServer": "SSH Server",
|
||||||
@@ -2270,13 +2072,8 @@
|
|||||||
"sshDaemonDisclaimer": "Ensure your target host is properly configured to run the auth daemon before completing this setup, or provisioning will fail.",
|
"sshDaemonDisclaimer": "Ensure your target host is properly configured to run the auth daemon before completing this setup, or provisioning will fail.",
|
||||||
"sshDaemonPort": "Daemon Port",
|
"sshDaemonPort": "Daemon Port",
|
||||||
"sshServerDestination": "Server Destination",
|
"sshServerDestination": "Server Destination",
|
||||||
"sshServerDestinationDescription": "Configure the destination of the SSH server",
|
"sshServerDestinationDescription": "Configure the destination and port of the SSH server",
|
||||||
"destination": "Destination",
|
"destination": "Destination",
|
||||||
"destinationRequired": "Destination is required.",
|
|
||||||
"domainRequired": "Domain is required.",
|
|
||||||
"proxyPortRequired": "Port is required.",
|
|
||||||
"invalidPathConfiguration": "Invalid path configuration.",
|
|
||||||
"invalidRewritePathConfiguration": "Invalid rewrite path configuration.",
|
|
||||||
"bgTargetMultiSiteDisclaimer": "Selecting multiple sites enables resilient routing and failover for high availability.",
|
"bgTargetMultiSiteDisclaimer": "Selecting multiple sites enables resilient routing and failover for high availability.",
|
||||||
"roleAllowSsh": "Allow SSH",
|
"roleAllowSsh": "Allow SSH",
|
||||||
"roleAllowSshAllow": "Allow",
|
"roleAllowSshAllow": "Allow",
|
||||||
@@ -2291,25 +2088,10 @@
|
|||||||
"sshSudoModeCommandsDescription": "User can run only the specified commands with sudo.",
|
"sshSudoModeCommandsDescription": "User can run only the specified commands with sudo.",
|
||||||
"sshSudo": "Allow sudo",
|
"sshSudo": "Allow sudo",
|
||||||
"sshSudoCommands": "Sudo Commands",
|
"sshSudoCommands": "Sudo Commands",
|
||||||
"sshSudoCommandsDescription": "List of commands the user is allowed to run with sudo, one per line. Absolute paths must be used.",
|
"sshSudoCommandsDescription": "Comma separated list of commands the user is allowed to run with sudo. Absolute paths must be used.",
|
||||||
"sshCreateHomeDir": "Create Home Directory",
|
"sshCreateHomeDir": "Create Home Directory",
|
||||||
"sshUnixGroups": "Unix Groups",
|
"sshUnixGroups": "Unix Groups",
|
||||||
"sshUnixGroupsDescription": "Unix groups to add the user to on the target host, one per line.",
|
"sshUnixGroupsDescription": "Comma separated Unix groups to add the user to on the target host.",
|
||||||
"roleTextFieldPlaceholder": "Enter values, or drop a .txt or .csv file",
|
|
||||||
"roleTextImportTitle": "Import from File",
|
|
||||||
"roleTextImportDescription": "Importing {fileName} into {fieldLabel}.",
|
|
||||||
"roleTextImportSkipHeader": "Skip First Row (Header)",
|
|
||||||
"roleTextImportOverride": "Replace Existing",
|
|
||||||
"roleTextImportAppend": "Append to Existing",
|
|
||||||
"roleTextImportMode": "Import Mode",
|
|
||||||
"roleTextImportPreview": "Preview",
|
|
||||||
"roleTextImportItemCount": "{count, plural, =0 {No items to import} one {1 item to import} other {# items to import}}",
|
|
||||||
"roleTextImportTotalCount": "{existing} existing + {imported} imported = {total} total",
|
|
||||||
"roleTextImportConfirm": "Import",
|
|
||||||
"roleTextImportInvalidFile": "Unsupported file type",
|
|
||||||
"roleTextImportInvalidFileDescription": "Only .txt and .csv files are supported.",
|
|
||||||
"roleTextImportEmpty": "No items found in file",
|
|
||||||
"roleTextImportEmptyDescription": "The file does not contain any importable items.",
|
|
||||||
"retryAttempts": "Retry Attempts",
|
"retryAttempts": "Retry Attempts",
|
||||||
"expectedResponseCodes": "Expected Response Codes",
|
"expectedResponseCodes": "Expected Response Codes",
|
||||||
"expectedResponseCodesDescription": "HTTP status code that indicates healthy status. If left blank, 200-300 is considered healthy.",
|
"expectedResponseCodesDescription": "HTTP status code that indicates healthy status. If left blank, 200-300 is considered healthy.",
|
||||||
@@ -2413,19 +2195,11 @@
|
|||||||
"createInternalResourceDialogClose": "Close",
|
"createInternalResourceDialogClose": "Close",
|
||||||
"createInternalResourceDialogCreateClientResource": "Create Private Resource",
|
"createInternalResourceDialogCreateClientResource": "Create Private Resource",
|
||||||
"createInternalResourceDialogCreateClientResourceDescription": "Create a new resource that will only be accessible to clients connected to the organization",
|
"createInternalResourceDialogCreateClientResourceDescription": "Create a new resource that will only be accessible to clients connected to the organization",
|
||||||
"privateResourceGeneralDescription": "Configure the name, identifier, and other general resource settings.",
|
|
||||||
"privateResourceCreatePageSeeAll": "See All Private Resources",
|
|
||||||
"privateResourceAllowIcmpPing": "Allow ICMP Ping",
|
|
||||||
"privateResourceNetworkAccess": "Network Access",
|
|
||||||
"privateResourceNetworkAccessDescription": "Control TCP/UDP port access and whether ICMP ping is allowed for this resource.",
|
|
||||||
"hostSettings": "Host Settings",
|
|
||||||
"cidrSettings": "CIDR Settings",
|
|
||||||
"createInternalResourceDialogResourceProperties": "Resource Properties",
|
"createInternalResourceDialogResourceProperties": "Resource Properties",
|
||||||
"createInternalResourceDialogName": "Name",
|
"createInternalResourceDialogName": "Name",
|
||||||
"createInternalResourceDialogSite": "Site",
|
"createInternalResourceDialogSite": "Site",
|
||||||
"selectSite": "Select site...",
|
"selectSite": "Select site...",
|
||||||
"multiSitesSelectorSitesCount": "{count, plural, one {# site} other {# sites}}",
|
"multiSitesSelectorSitesCount": "{count, plural, one {# site} other {# sites}}",
|
||||||
"labelsSelectorLabelsCount": "{count, plural, one {# label} other {# labels}}",
|
|
||||||
"noSitesFound": "No sites found.",
|
"noSitesFound": "No sites found.",
|
||||||
"createInternalResourceDialogProtocol": "Protocol",
|
"createInternalResourceDialogProtocol": "Protocol",
|
||||||
"createInternalResourceDialogTcp": "TCP",
|
"createInternalResourceDialogTcp": "TCP",
|
||||||
@@ -2466,7 +2240,6 @@
|
|||||||
"createInternalResourceDialogDestinationCidrDescription": "The CIDR range of the resource on the site's network.",
|
"createInternalResourceDialogDestinationCidrDescription": "The CIDR range of the resource on the site's network.",
|
||||||
"createInternalResourceDialogAlias": "Alias",
|
"createInternalResourceDialogAlias": "Alias",
|
||||||
"createInternalResourceDialogAliasDescription": "An optional internal DNS alias for this resource.",
|
"createInternalResourceDialogAliasDescription": "An optional internal DNS alias for this resource.",
|
||||||
"internalResourceAliasLocalWarning": "Aliases ending in .local can cause resolution issues due to mDNS on some networks.",
|
|
||||||
"internalResourceDownstreamSchemeRequired": "Scheme is required for HTTP resources",
|
"internalResourceDownstreamSchemeRequired": "Scheme is required for HTTP resources",
|
||||||
"internalResourceHttpPortRequired": "Destination port is required for HTTP resources",
|
"internalResourceHttpPortRequired": "Destination port is required for HTTP resources",
|
||||||
"siteConfiguration": "Configuration",
|
"siteConfiguration": "Configuration",
|
||||||
@@ -2500,21 +2273,6 @@
|
|||||||
"sidebarRemoteExitNodes": "Remote Nodes",
|
"sidebarRemoteExitNodes": "Remote Nodes",
|
||||||
"remoteExitNodeId": "ID",
|
"remoteExitNodeId": "ID",
|
||||||
"remoteExitNodeSecretKey": "Secret",
|
"remoteExitNodeSecretKey": "Secret",
|
||||||
"remoteExitNodeNetworkingTitle": "Network Settings",
|
|
||||||
"remoteExitNodeNetworkingDescription": "Configure how this remote exit node routes traffic and which sites prefer to connect through it. Advanced features to be used with backhaul networking configurations.",
|
|
||||||
"remoteExitNodeNetworkingSave": "Save Settings",
|
|
||||||
"remoteExitNodeNetworkingSaveSuccessTitle": "Network settings saved",
|
|
||||||
"remoteExitNodeNetworkingSaveSuccessDescription": "Network settings have been updated successfully.",
|
|
||||||
"remoteExitNodeNetworkingSaveError": "Failed to save network settings",
|
|
||||||
"remoteExitNodeNetworkingSubnetsTitle": "Remote Subnets",
|
|
||||||
"remoteExitNodeNetworkingSubnetsDescription": "Define the CIDR ranges that this remote exit node will route traffic to. Type a valid CIDR (e.g. <code>10.0.0.0/8</code>) and press Enter to add.",
|
|
||||||
"remoteExitNodeNetworkingSubnetsPlaceholder": "Add a CIDR range (e.g. 10.0.0.0/8)",
|
|
||||||
"remoteExitNodeNetworkingSubnetsLoadError": "Failed to load subnets",
|
|
||||||
"remoteExitNodeNetworkingLabelsTitle": "Preference Labels",
|
|
||||||
"remoteExitNodeNetworkingLabelsDescription": "Sites with these labels will be enforced to connect through this remote exit node.",
|
|
||||||
"remoteExitNodeNetworkingLabelsButtonText": "Select labels...",
|
|
||||||
"remoteExitNodeNetworkingLabelsSearchPlaceholder": "Search labels...",
|
|
||||||
"remoteExitNodeNetworkingLabelsLoadError": "Failed to load labels",
|
|
||||||
"remoteExitNodeCreate": {
|
"remoteExitNodeCreate": {
|
||||||
"title": "Create Remote Node",
|
"title": "Create Remote Node",
|
||||||
"description": "Create a new self-hosted remote relay and proxy server node",
|
"description": "Create a new self-hosted remote relay and proxy server node",
|
||||||
@@ -2568,7 +2326,6 @@
|
|||||||
"noRemoteExitNodesAvailableDescription": "No nodes are available for this organization. Create a node first to use local sites.",
|
"noRemoteExitNodesAvailableDescription": "No nodes are available for this organization. Create a node first to use local sites.",
|
||||||
"exitNode": "Exit Node",
|
"exitNode": "Exit Node",
|
||||||
"country": "Country",
|
"country": "Country",
|
||||||
"countryIsNot": "Country Is Not",
|
|
||||||
"rulesMatchCountry": "Currently based on source IP",
|
"rulesMatchCountry": "Currently based on source IP",
|
||||||
"region": "Region",
|
"region": "Region",
|
||||||
"selectRegion": "Select region",
|
"selectRegion": "Select region",
|
||||||
@@ -2694,7 +2451,6 @@
|
|||||||
"idpGoogleDescription": "Google OAuth2/OIDC provider",
|
"idpGoogleDescription": "Google OAuth2/OIDC provider",
|
||||||
"idpAzureDescription": "Microsoft Azure OAuth2/OIDC provider",
|
"idpAzureDescription": "Microsoft Azure OAuth2/OIDC provider",
|
||||||
"subnet": "Subnet",
|
"subnet": "Subnet",
|
||||||
"utilitySubnet": "Utility Subnet",
|
|
||||||
"subnetDescription": "The subnet for this organization's network configuration.",
|
"subnetDescription": "The subnet for this organization's network configuration.",
|
||||||
"customDomain": "Custom Domain",
|
"customDomain": "Custom Domain",
|
||||||
"authPage": "Authentication Pages",
|
"authPage": "Authentication Pages",
|
||||||
@@ -2778,9 +2534,6 @@
|
|||||||
"twoFactorSetupRequired": "Two-factor authentication setup is required. Please log in again via {dashboardUrl}/auth/login complete this step. Then, come back here.",
|
"twoFactorSetupRequired": "Two-factor authentication setup is required. Please log in again via {dashboardUrl}/auth/login complete this step. Then, come back here.",
|
||||||
"additionalSecurityRequired": "Additional Security Required",
|
"additionalSecurityRequired": "Additional Security Required",
|
||||||
"organizationRequiresAdditionalSteps": "This organization requires additional security steps before you can access resources.",
|
"organizationRequiresAdditionalSteps": "This organization requires additional security steps before you can access resources.",
|
||||||
"sessionExpired": "Session Expired",
|
|
||||||
"sessionExpiredReauthRequired": "Your session has expired per your organization's security policy. Please re-authenticate to continue.",
|
|
||||||
"reauthenticate": "Re-authenticate",
|
|
||||||
"completeTheseSteps": "Complete these steps",
|
"completeTheseSteps": "Complete these steps",
|
||||||
"enableTwoFactorAuthentication": "Enable two-factor authentication",
|
"enableTwoFactorAuthentication": "Enable two-factor authentication",
|
||||||
"completeSecuritySteps": "Complete Security Steps",
|
"completeSecuritySteps": "Complete Security Steps",
|
||||||
@@ -3095,8 +2848,8 @@
|
|||||||
"sourceAddress": "Source Address",
|
"sourceAddress": "Source Address",
|
||||||
"destinationAddress": "Destination Address",
|
"destinationAddress": "Destination Address",
|
||||||
"duration": "Duration",
|
"duration": "Duration",
|
||||||
"licenseRequiredToUse": "An <enterpriseLicenseLink>Enterprise Edition</enterpriseLicenseLink> license or <pangolinCloudLink>Pangolin Cloud</pangolinCloudLink> is required to use this feature. <bookADemoLink>Book a free demo or POC trial to learn more.</bookADemoLink>",
|
"licenseRequiredToUse": "An <enterpriseLicenseLink>Enterprise Edition</enterpriseLicenseLink> license or <pangolinCloudLink>Pangolin Cloud</pangolinCloudLink> is required to use this feature. <bookADemoLink>Book a free demo or POC trial to learn more</bookADemoLink>.",
|
||||||
"ossEnterpriseEditionRequired": "The <enterpriseEditionLink>Enterprise Edition</enterpriseEditionLink> is required to use this feature. This feature is also available in <pangolinCloudLink>Pangolin Cloud</pangolinCloudLink>. <bookADemoLink>Book a free demo or POC trial to learn more.</bookADemoLink>",
|
"ossEnterpriseEditionRequired": "The <enterpriseEditionLink>Enterprise Edition</enterpriseEditionLink> is required to use this feature. This feature is also available in <pangolinCloudLink>Pangolin Cloud</pangolinCloudLink>. <bookADemoLink>Book a free demo or POC trial to learn more</bookADemoLink>.",
|
||||||
"certResolver": "Certificate Resolver",
|
"certResolver": "Certificate Resolver",
|
||||||
"certResolverDescription": "Select the certificate resolver to use for this resource.",
|
"certResolverDescription": "Select the certificate resolver to use for this resource.",
|
||||||
"selectCertResolver": "Select Certificate Resolver",
|
"selectCertResolver": "Select Certificate Resolver",
|
||||||
@@ -3116,17 +2869,15 @@
|
|||||||
"orgOrDomainIdMissing": "Organization or Domain ID is missing",
|
"orgOrDomainIdMissing": "Organization or Domain ID is missing",
|
||||||
"loadingDNSRecords": "Loading DNS records...",
|
"loadingDNSRecords": "Loading DNS records...",
|
||||||
"olmUpdateAvailableInfo": "An updated version of Olm is available. Please update to the latest version for the best experience.",
|
"olmUpdateAvailableInfo": "An updated version of Olm is available. Please update to the latest version for the best experience.",
|
||||||
"updateAvailableInfo": "An updated version is available. Please update to the latest version for the best experience.",
|
|
||||||
"client": "Client",
|
"client": "Client",
|
||||||
"proxyProtocol": "Proxy Protocol Settings",
|
"proxyProtocol": "Proxy Protocol Settings",
|
||||||
"proxyProtocolDescription": "Configure Proxy Protocol to preserve client IP addresses for TCP services.",
|
"proxyProtocolDescription": "Configure Proxy Protocol to preserve client IP addresses for TCP services.",
|
||||||
"enableProxyProtocol": "Enable Proxy Protocol",
|
"enableProxyProtocol": "Enable Proxy Protocol",
|
||||||
"proxyProtocolInfo": "Preserve client IP addresses for TCP backends",
|
"proxyProtocolInfo": "Preserve client IP addresses for TCP backends",
|
||||||
"proxyProtocolVersion": "Proxy Protocol Version",
|
"proxyProtocolVersion": "Proxy Protocol Version",
|
||||||
"version1": "Version 1 (Recommended)",
|
"version1": " Version 1 (Recommended)",
|
||||||
"version2": "Version 2",
|
"version2": "Version 2",
|
||||||
"version1Description": "Text-based and widely supported. Make sure servers transport is added to dynamic config.",
|
"versionDescription": "Version 1 is text-based and widely supported. Version 2 is binary and more efficient but less compatible. Make sure servers transport is added to dynamic config.",
|
||||||
"version2Description": "Binary and more efficient but less compatible. Make sure servers transport is added to dynamic config.",
|
|
||||||
"warning": "Warning",
|
"warning": "Warning",
|
||||||
"proxyProtocolWarning": "The backend application must be configured to accept Proxy Protocol connections. If your backend doesn't support Proxy Protocol, enabling this will break all connections so only enable this if you know what you're doing. Make sure to configure your backend to trust Proxy Protocol headers from Traefik.",
|
"proxyProtocolWarning": "The backend application must be configured to accept Proxy Protocol connections. If your backend doesn't support Proxy Protocol, enabling this will break all connections so only enable this if you know what you're doing. Make sure to configure your backend to trust Proxy Protocol headers from Traefik.",
|
||||||
"restarting": "Restarting...",
|
"restarting": "Restarting...",
|
||||||
@@ -3283,14 +3034,14 @@
|
|||||||
"enterConfirmation": "Enter confirmation",
|
"enterConfirmation": "Enter confirmation",
|
||||||
"blueprintViewDetails": "Details",
|
"blueprintViewDetails": "Details",
|
||||||
"defaultIdentityProvider": "Default Identity Provider",
|
"defaultIdentityProvider": "Default Identity Provider",
|
||||||
"defaultIdentityProviderDescription": "The user will be automatically redirected to this identity provider for authentication.",
|
"defaultIdentityProviderDescription": "When a default identity provider is selected, the user will be automatically redirected to the provider for authentication.",
|
||||||
"editInternalResourceDialogNetworkSettings": "Network Settings",
|
"editInternalResourceDialogNetworkSettings": "Network Settings",
|
||||||
"editInternalResourceDialogAccessPolicy": "Access Policy",
|
"editInternalResourceDialogAccessPolicy": "Access Policy",
|
||||||
"editInternalResourceDialogAddRoles": "Add Roles",
|
"editInternalResourceDialogAddRoles": "Add Roles",
|
||||||
"editInternalResourceDialogAddUsers": "Add Users",
|
"editInternalResourceDialogAddUsers": "Add Users",
|
||||||
"editInternalResourceDialogAddClients": "Add Clients",
|
"editInternalResourceDialogAddClients": "Add Clients",
|
||||||
"editInternalResourceDialogDestinationLabel": "Destination",
|
"editInternalResourceDialogDestinationLabel": "Destination",
|
||||||
"editInternalResourceDialogDestinationDescription": "Configure how clients reach this resource.",
|
"editInternalResourceDialogDestinationDescription": "Choose where this resource runs and how clients reach it. Selecting multiple sites will create a high availability resource that can be accessed from any of the selected sites.",
|
||||||
"internalResourceFormMultiSiteRoutingHelp": "Selecting multiple sites enables resilient routing and failover for high availability.",
|
"internalResourceFormMultiSiteRoutingHelp": "Selecting multiple sites enables resilient routing and failover for high availability.",
|
||||||
"internalResourceFormMultiSiteRoutingHelpLearnMore": "Learn more",
|
"internalResourceFormMultiSiteRoutingHelpLearnMore": "Learn more",
|
||||||
"editInternalResourceDialogPortRestrictionsDescription": "Restrict access to specific TCP/UDP ports or allow/block all ports.",
|
"editInternalResourceDialogPortRestrictionsDescription": "Restrict access to specific TCP/UDP ports or allow/block all ports.",
|
||||||
@@ -3324,7 +3075,6 @@
|
|||||||
"maintenanceModeType": "Maintenance Mode Type",
|
"maintenanceModeType": "Maintenance Mode Type",
|
||||||
"showMaintenancePage": "Show a maintenance page to visitors",
|
"showMaintenancePage": "Show a maintenance page to visitors",
|
||||||
"enableMaintenanceMode": "Enable Maintenance Mode",
|
"enableMaintenanceMode": "Enable Maintenance Mode",
|
||||||
"enableMaintenanceModeDescription": "When enabled, visitors will see a maintenance page instead of your resource.",
|
|
||||||
"automatic": "Automatic",
|
"automatic": "Automatic",
|
||||||
"automaticModeDescription": " Show maintenance page only when all backend targets are down or unhealthy. Your resource continues working normally as long as at least one target is healthy.",
|
"automaticModeDescription": " Show maintenance page only when all backend targets are down or unhealthy. Your resource continues working normally as long as at least one target is healthy.",
|
||||||
"forced": "Forced",
|
"forced": "Forced",
|
||||||
@@ -3332,8 +3082,6 @@
|
|||||||
"warning:": "Warning:",
|
"warning:": "Warning:",
|
||||||
"forcedeModeWarning": "All traffic will be directed to the maintenance page. Your backend resources will not receive any requests.",
|
"forcedeModeWarning": "All traffic will be directed to the maintenance page. Your backend resources will not receive any requests.",
|
||||||
"pageTitle": "Page Title",
|
"pageTitle": "Page Title",
|
||||||
"maintenancePageContentSubsection": "Page Content",
|
|
||||||
"maintenancePageContentSubsectionDescription": "Customize the content displayed on the maintenance page",
|
|
||||||
"pageTitleDescription": "The main heading displayed on the maintenance page",
|
"pageTitleDescription": "The main heading displayed on the maintenance page",
|
||||||
"maintenancePageMessage": "Maintenance Message",
|
"maintenancePageMessage": "Maintenance Message",
|
||||||
"maintenancePageMessagePlaceholder": "We'll be back soon! Our site is currently undergoing scheduled maintenance.",
|
"maintenancePageMessagePlaceholder": "We'll be back soon! Our site is currently undergoing scheduled maintenance.",
|
||||||
@@ -3683,80 +3431,6 @@
|
|||||||
"memberPortalEmailWhitelist": "Email Whitelist",
|
"memberPortalEmailWhitelist": "Email Whitelist",
|
||||||
"memberPortalResourceDisabled": "Resource Disabled",
|
"memberPortalResourceDisabled": "Resource Disabled",
|
||||||
"memberPortalShowingResources": "Showing {start}-{end} of {total} resources",
|
"memberPortalShowingResources": "Showing {start}-{end} of {total} resources",
|
||||||
"resourceLauncherTitle": "Resource Launcher",
|
|
||||||
"resourceSidebarLauncherTitle": "Launcher",
|
|
||||||
"resourceLauncherDescription": "View all available resources and launch them from one central hub",
|
|
||||||
"resourceLauncherSearchPlaceholder": "Search your resources...",
|
|
||||||
"resourceLauncherDefaultView": "Default",
|
|
||||||
"resourceLauncherSaveView": "Save View",
|
|
||||||
"resourceLauncherSaveToCurrentView": "Save to Current View",
|
|
||||||
"resourceLauncherSaveDefaultPersonal": "Save for Me",
|
|
||||||
"resourceLauncherResetView": "Reset View",
|
|
||||||
"resourceLauncherResetSystemDefault": "Reset to System Default",
|
|
||||||
"resourceLauncherSystemDefaultRestored": "System default restored",
|
|
||||||
"resourceLauncherSystemDefaultRestoredDescription": "The default view has been reset to the original settings.",
|
|
||||||
"resourceLauncherSaveAsNewView": "Save as New View",
|
|
||||||
"resourceLauncherSaveAsNewViewDescription": "Give this view a name to save your current filters and layout.",
|
|
||||||
"resourceLauncherSaveForEveryone": "Save for Everyone",
|
|
||||||
"resourceLauncherSaveForEveryoneDescription": "Share this view with all organization members. When unchecked, the view is only visible to you.",
|
|
||||||
"resourceLauncherMakePersonal": "Make Personal",
|
|
||||||
"resourceLauncherFilter": "Filter",
|
|
||||||
"resourceLauncherFilterWithCount": "Filter, {count} applied",
|
|
||||||
"resourceLauncherSort": "Sort",
|
|
||||||
"resourceLauncherSortAscending": "Sort ascending",
|
|
||||||
"resourceLauncherSortDescending": "Sort descending",
|
|
||||||
"resourceLauncherSettings": "Settings",
|
|
||||||
"resourceLauncherGroupBy": "Group By",
|
|
||||||
"resourceLauncherGroupBySite": "Site",
|
|
||||||
"resourceLauncherGroupByLabel": "Label",
|
|
||||||
"resourceLauncherGroupByNone": "None",
|
|
||||||
"resourceLauncherLayout": "Layout",
|
|
||||||
"resourceLauncherLayoutGrid": "Grid",
|
|
||||||
"resourceLauncherLayoutList": "List",
|
|
||||||
"resourceLauncherShowLabels": "Show Labels",
|
|
||||||
"resourceLauncherShowSiteTags": "Show Site Tags",
|
|
||||||
"resourceLauncherShowRecents": "Show Recents",
|
|
||||||
"resourceLauncherDeleteView": "Delete View",
|
|
||||||
"resourceLauncherDeleteViewTitle": "Delete View",
|
|
||||||
"resourceLauncherDeleteViewQuestion": "Are you sure you want to delete this launcher view?",
|
|
||||||
"resourceLauncherDeleteViewConfirm": "Delete View",
|
|
||||||
"resourceLauncherViewAsAdmin": "View as Admin",
|
|
||||||
"resourceLauncherResourceDetailsDescription": "Connection information and status for this resource.",
|
|
||||||
"resourceLauncherResourceDetails": "Resource Details",
|
|
||||||
"resourceLauncherAuthMethodsDescription": "Authentication methods enabled for this resource.",
|
|
||||||
"resourceLauncherPrivateClientRequired": "Connect with a client on your device to access this resource privately.",
|
|
||||||
"resourceLauncherPrivateClientRequiredTitle": "Client Connection Required",
|
|
||||||
"resourceLauncherDownloadClient": "Download client",
|
|
||||||
"resourceLauncherFailedToLoadDetails": "Could not load resource details. You may no longer have access to this resource.",
|
|
||||||
"resourceLauncherNoPortRestrictions": "No port restrictions",
|
|
||||||
"resourceLauncherTcp": "TCP",
|
|
||||||
"resourceLauncherUdp": "UDP",
|
|
||||||
"resourceLauncherUnlabeled": "Unlabeled",
|
|
||||||
"resourceLauncherNoSite": "No Site",
|
|
||||||
"resourceLauncherNoResourcesInGroup": "No resources in this group",
|
|
||||||
"resourceLauncherEmptyStateTitle": "No Resources Available",
|
|
||||||
"resourceLauncherEmptyStateDescription": "You don't have access to any resources yet. Contact your administrator to request access.",
|
|
||||||
"resourceLauncherEmptyStateNoResultsTitle": "No Resources Found",
|
|
||||||
"resourceLauncherEmptyStateNoResultsDescription": "No resources match your current search or filters. Try adjusting them to find what you are looking for.",
|
|
||||||
"resourceLauncherEmptyStateNoResultsWithQuery": "No resources match \"{query}\". Try adjusting your search or clearing filters to see all resources.",
|
|
||||||
"resourceLauncherSearchFirstTitle": "Search or Filter to Browse",
|
|
||||||
"resourceLauncherSearchFirstDescription": "You have access to many resources. Use search or filter by site or label to find what you need.",
|
|
||||||
"resourceLauncherSiteGroupingDisabled": "Site grouping is unavailable at this scale. Filter by site to group a smaller set.",
|
|
||||||
"resourceLauncherLabelGroupingDisabled": "Label grouping is unavailable at this scale.",
|
|
||||||
"resourceLauncherCompactModeHint": "Showing a simplified list for faster browsing. Use search or filters to narrow results.",
|
|
||||||
"resourceLauncherCompactGroupingHint": "Apply site or label filters to enable grouping.",
|
|
||||||
"resourceLauncherCopiedToClipboard": "Copied to clipboard",
|
|
||||||
"resourceLauncherCopiedAccessDescription": "Resource access has been copied to your clipboard.",
|
|
||||||
"resourceLauncherViewNamePlaceholder": "View name",
|
|
||||||
"resourceLauncherViewNameLabel": "View Name",
|
|
||||||
"resourceLauncherViewSaved": "View saved",
|
|
||||||
"resourceLauncherViewSavedDescription": "Your launcher view has been saved.",
|
|
||||||
"resourceLauncherViewSaveFailed": "Failed to save view",
|
|
||||||
"resourceLauncherViewSaveFailedDescription": "Could not save the launcher view. Please try again.",
|
|
||||||
"resourceLauncherViewDeleted": "View deleted",
|
|
||||||
"resourceLauncherViewDeletedDescription": "The launcher view has been deleted.",
|
|
||||||
"resourceLauncherViewDeleteFailed": "Failed to delete view",
|
|
||||||
"resourceLauncherViewDeleteFailedDescription": "Could not delete the launcher view. Please try again.",
|
|
||||||
"memberPortalPrevious": "Previous",
|
"memberPortalPrevious": "Previous",
|
||||||
"memberPortalNext": "Next",
|
"memberPortalNext": "Next",
|
||||||
"httpSettings": "HTTP Settings",
|
"httpSettings": "HTTP Settings",
|
||||||
@@ -3767,60 +3441,18 @@
|
|||||||
"sshConnecting": "Connecting…",
|
"sshConnecting": "Connecting…",
|
||||||
"sshInitializing": "Initializing…",
|
"sshInitializing": "Initializing…",
|
||||||
"sshSignInTitle": "Sign in to SSH",
|
"sshSignInTitle": "Sign in to SSH",
|
||||||
"sshSignInDescription": "Enter your SSH credentials to connect",
|
"sshSignInDescription": "Enter your SSH credentials",
|
||||||
"sshPasswordTab": "Password",
|
"sshPasswordTab": "Password",
|
||||||
"sshPrivateKeyTab": "Private Key",
|
"sshPrivateKeyTab": "Private Key",
|
||||||
"sshPrivateKeyField": "Private Key",
|
"sshPrivateKeyField": "Private Key",
|
||||||
"sshPrivateKeyDisclaimer": "Your private key is not stored or visible to Pangolin. Alternatively, you can use short-lived certificates for seamless authentication using your existing Pangolin identity.",
|
"sshPrivateKeyDisclaimer": "Your private key is not stored or visible to Pangolin. Alternatively, you can use short-lived certificates for seamless authentication using your existing Pangolin identity.",
|
||||||
"sshLearnMore": "Learn more",
|
"sshLearnMore": "Learn more",
|
||||||
"sshPrivateKeyFile": "Private Key File",
|
"sshPrivateKeyFile": "Private Key File",
|
||||||
"sshAuthenticate": "Connect",
|
"sshAuthenticate": "Authenticate",
|
||||||
"sshTerminate": "Terminate",
|
"sshTerminate": "Terminate",
|
||||||
"sshPoweredBy": "Powered by",
|
"sshPoweredBy": "Powered by",
|
||||||
"sshErrorNoTarget": "No target specified",
|
"sshErrorNoTarget": "No target specified",
|
||||||
"sshErrorWebSocket": "WebSocket connection failed",
|
"sshErrorWebSocket": "WebSocket connection failed",
|
||||||
"sshErrorAuthFailed": "Authentication failed",
|
"sshErrorAuthFailed": "Authentication failed",
|
||||||
"sshErrorConnectionClosed": "Connection closed before authentication completed",
|
"sshErrorConnectionClosed": "Connection closed before authentication completed"
|
||||||
"sitePangolinSshDescription": "Allow SSH access to resources on this site. This can be changed later.",
|
|
||||||
"browserGatewayNoResourceForDomain": "No resource found for this domain",
|
|
||||||
"browserGatewayNoTarget": "No target",
|
|
||||||
"browserGatewayConnect": "Connect",
|
|
||||||
"browserGatewayCtrlAltDel": "Ctrl+Alt+Del",
|
|
||||||
"sshErrorSignKeyFailed": "Failed to sign SSH key for PAM push authentication. Did you sign in as a user?",
|
|
||||||
"sshTerminalError": "Error: {error}",
|
|
||||||
"sshConnectionClosedCode": "Connection closed (code {code})",
|
|
||||||
"sshPrivateKeyPlaceholder": "-----BEGIN OPENSSH PRIVATE KEY-----",
|
|
||||||
"sshPrivateKeyRequired": "Private key is required",
|
|
||||||
"vncTitle": "VNC",
|
|
||||||
"vncSignInDescription": "Enter your VNC credentials to connect",
|
|
||||||
"vncUsernameOptional": "Username (optional)",
|
|
||||||
"vncPasswordOptional": "Password (optional)",
|
|
||||||
"vncNoResourceTarget": "No resource target is available",
|
|
||||||
"vncFailedToLoadNovnc": "Failed to load noVNC",
|
|
||||||
"vncAuthFailedStatus": "Status {status}",
|
|
||||||
"vncPasteClipboard": "Paste clipboard",
|
|
||||||
"rdpTitle": "RDP",
|
|
||||||
"rdpSignInTitle": "Sign in to Remote Desktop",
|
|
||||||
"rdpSignInDescription": "Enter Windows credentials to connect",
|
|
||||||
"rdpLoadingModule": "Loading module...",
|
|
||||||
"rdpFailedToLoadModule": "Failed to load RDP module",
|
|
||||||
"rdpNotReady": "Not ready",
|
|
||||||
"rdpModuleInitializing": "RDP module is still initializing",
|
|
||||||
"rdpDownloadingFiles": "Downloading {count} file(s) from remote…",
|
|
||||||
"rdpDownloadFailed": "Download failed: {fileName}",
|
|
||||||
"rdpUploaded": "Uploaded: {fileName}",
|
|
||||||
"rdpNoConnectionTarget": "No connection target available",
|
|
||||||
"rdpConnectionFailed": "Connection failed",
|
|
||||||
"rdpFit": "Fit",
|
|
||||||
"rdpFull": "Full",
|
|
||||||
"rdpReal": "Real",
|
|
||||||
"rdpMeta": "Meta",
|
|
||||||
"rdpUploadFiles": "Upload files",
|
|
||||||
"rdpFilesReadyToPaste": "Files ready to paste",
|
|
||||||
"rdpFilesReadyToPasteDescription": "{count} file(s) copied to remote clipboard — press Ctrl+V on the remote desktop to paste.",
|
|
||||||
"rdpUploadFailed": "Upload failed",
|
|
||||||
"rdpUnicodeKeyboardMode": "Unicode keyboard mode",
|
|
||||||
"sessionToolbarShow": "Show toolbar",
|
|
||||||
"sessionToolbarHide": "Hide toolbar",
|
|
||||||
"actionUpdateSiteApprovals": "Update Site Approvals"
|
|
||||||
}
|
}
|
||||||
|
|||||||
+190
-411
File diff suppressed because it is too large
Load Diff
+191
-412
File diff suppressed because it is too large
Load Diff
+192
-413
File diff suppressed because it is too large
Load Diff
+189
-410
File diff suppressed because it is too large
Load Diff
+189
-410
File diff suppressed because it is too large
Load Diff
+187
-408
File diff suppressed because it is too large
Load Diff
+187
-408
File diff suppressed because it is too large
Load Diff
+189
-410
File diff suppressed because it is too large
Load Diff
+191
-412
File diff suppressed because it is too large
Load Diff
+191
-412
File diff suppressed because it is too large
Load Diff
+238
-459
File diff suppressed because it is too large
Load Diff
+2
-3
@@ -152,8 +152,8 @@
|
|||||||
"shareErrorSelectResource": "請選擇一個資源",
|
"shareErrorSelectResource": "請選擇一個資源",
|
||||||
"proxyResourceTitle": "管理公開資源",
|
"proxyResourceTitle": "管理公開資源",
|
||||||
"proxyResourceDescription": "建立和管理可透過網頁瀏覽器公開存取的資源",
|
"proxyResourceDescription": "建立和管理可透過網頁瀏覽器公開存取的資源",
|
||||||
"publicResourcesBannerTitle": "基於網頁的公開存取",
|
"proxyResourcesBannerTitle": "基於網頁的公開存取",
|
||||||
"publicResourcesBannerDescription": "公開資源是任何人都可以透過網頁瀏覽器存取的 HTTPS 或 TCP/UDP 代理。與私有資源不同,它們不需要客戶端軟體,並且可以包含基於身份和情境感知的存取策略。",
|
"proxyResourcesBannerDescription": "公開資源是任何人都可以透過網頁瀏覽器存取的 HTTPS 或 TCP/UDP 代理。與私有資源不同,它們不需要客戶端軟體,並且可以包含基於身份和情境感知的存取策略。",
|
||||||
"clientResourceTitle": "管理私有資源",
|
"clientResourceTitle": "管理私有資源",
|
||||||
"clientResourceDescription": "建立和管理只能透過已連接的客戶端存取的資源",
|
"clientResourceDescription": "建立和管理只能透過已連接的客戶端存取的資源",
|
||||||
"privateResourcesBannerTitle": "零信任私有存取",
|
"privateResourcesBannerTitle": "零信任私有存取",
|
||||||
@@ -1099,7 +1099,6 @@
|
|||||||
"actionGenerateAccessToken": "生成訪問令牌",
|
"actionGenerateAccessToken": "生成訪問令牌",
|
||||||
"actionDeleteAccessToken": "刪除訪問令牌",
|
"actionDeleteAccessToken": "刪除訪問令牌",
|
||||||
"actionListAccessTokens": "訪問令牌",
|
"actionListAccessTokens": "訪問令牌",
|
||||||
"actionCreateResourceSessionToken": "建立資源工作階段權杖",
|
|
||||||
"actionCreateResourceRule": "創建資源規則",
|
"actionCreateResourceRule": "創建資源規則",
|
||||||
"actionDeleteResourceRule": "刪除資源規則",
|
"actionDeleteResourceRule": "刪除資源規則",
|
||||||
"actionListResourceRules": "列出資源規則",
|
"actionListResourceRules": "列出資源規則",
|
||||||
|
|||||||
Generated
+242
-204
@@ -12,7 +12,7 @@
|
|||||||
"@asteasolutions/zod-to-openapi": "8.5.0",
|
"@asteasolutions/zod-to-openapi": "8.5.0",
|
||||||
"@aws-sdk/client-s3": "3.1056.0",
|
"@aws-sdk/client-s3": "3.1056.0",
|
||||||
"@devolutions/iron-remote-desktop": "https://static.pangolin.net/packages/devolutions-iron-remote-desktop-0.0.0.tgz",
|
"@devolutions/iron-remote-desktop": "https://static.pangolin.net/packages/devolutions-iron-remote-desktop-0.0.0.tgz",
|
||||||
"@devolutions/iron-remote-desktop-rdp": "https://static.pangolin.net/packages/devolutions-iron-remote-desktop-rdp-0.0.1.tgz",
|
"@devolutions/iron-remote-desktop-rdp": "https://static.pangolin.net/packages/devolutions-iron-remote-desktop-rdp-0.0.0.tgz",
|
||||||
"@headlessui/react": "2.2.10",
|
"@headlessui/react": "2.2.10",
|
||||||
"@hookform/resolvers": "5.4.0",
|
"@hookform/resolvers": "5.4.0",
|
||||||
"@monaco-editor/react": "4.7.0",
|
"@monaco-editor/react": "4.7.0",
|
||||||
@@ -70,7 +70,7 @@
|
|||||||
"input-otp": "1.4.2",
|
"input-otp": "1.4.2",
|
||||||
"ioredis": "5.11.0",
|
"ioredis": "5.11.0",
|
||||||
"jmespath": "0.16.0",
|
"jmespath": "0.16.0",
|
||||||
"js-yaml": "4.2.0",
|
"js-yaml": "4.1.1",
|
||||||
"jsonwebtoken": "9.0.3",
|
"jsonwebtoken": "9.0.3",
|
||||||
"lucide-react": "1.17.0",
|
"lucide-react": "1.17.0",
|
||||||
"maxmind": "5.0.6",
|
"maxmind": "5.0.6",
|
||||||
@@ -80,7 +80,7 @@
|
|||||||
"next-themes": "0.4.6",
|
"next-themes": "0.4.6",
|
||||||
"nextjs-toploader": "3.9.17",
|
"nextjs-toploader": "3.9.17",
|
||||||
"node-cache": "5.1.2",
|
"node-cache": "5.1.2",
|
||||||
"nodemailer": "9.0.1",
|
"nodemailer": "8.0.9",
|
||||||
"oslo": "1.2.1",
|
"oslo": "1.2.1",
|
||||||
"pg": "8.21.0",
|
"pg": "8.21.0",
|
||||||
"posthog-node": "5.35.6",
|
"posthog-node": "5.35.6",
|
||||||
@@ -142,7 +142,7 @@
|
|||||||
"@types/yargs": "17.0.35",
|
"@types/yargs": "17.0.35",
|
||||||
"babel-plugin-react-compiler": "1.0.0",
|
"babel-plugin-react-compiler": "1.0.0",
|
||||||
"drizzle-kit": "0.31.10",
|
"drizzle-kit": "0.31.10",
|
||||||
"esbuild": "0.28.1",
|
"esbuild": "0.28.0",
|
||||||
"esbuild-node-externals": "1.22.0",
|
"esbuild-node-externals": "1.22.0",
|
||||||
"eslint": "10.4.0",
|
"eslint": "10.4.0",
|
||||||
"eslint-config-next": "16.2.6",
|
"eslint-config-next": "16.2.6",
|
||||||
@@ -1124,9 +1124,9 @@
|
|||||||
"integrity": "sha512-9o7PkCw9fdvGTPs0hgsUJG10QleGgcdsSCw1ekLpUOlVXtWCuiuPH+0bPDFhLWxqbVA+8pyVhwqdOI+t1T3TNA=="
|
"integrity": "sha512-9o7PkCw9fdvGTPs0hgsUJG10QleGgcdsSCw1ekLpUOlVXtWCuiuPH+0bPDFhLWxqbVA+8pyVhwqdOI+t1T3TNA=="
|
||||||
},
|
},
|
||||||
"node_modules/@devolutions/iron-remote-desktop-rdp": {
|
"node_modules/@devolutions/iron-remote-desktop-rdp": {
|
||||||
"version": "0.0.1",
|
"version": "0.0.0",
|
||||||
"resolved": "https://static.pangolin.net/packages/devolutions-iron-remote-desktop-rdp-0.0.1.tgz",
|
"resolved": "https://static.pangolin.net/packages/devolutions-iron-remote-desktop-rdp-0.0.0.tgz",
|
||||||
"integrity": "sha512-sIHllJ6rJfAgClsUFjJfVGFjIxm6ZQRyGfinwCzHYgwnHAMFE+Kvm0YzR9tg811Fhjm5F6gNUSLAll+7Eh0Deg=="
|
"integrity": "sha512-O0YVpOJDwUzekH3N2QKj+48WP+56wI0sj4VmaJkGoW5XgyAj2ONn2k3i+vk17Eavx+Vg6vAg3lwYRAOK4kKIDQ=="
|
||||||
},
|
},
|
||||||
"node_modules/@dotenvx/dotenvx": {
|
"node_modules/@dotenvx/dotenvx": {
|
||||||
"version": "1.69.1",
|
"version": "1.69.1",
|
||||||
@@ -1248,9 +1248,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@esbuild/aix-ppc64": {
|
"node_modules/@esbuild/aix-ppc64": {
|
||||||
"version": "0.28.1",
|
"version": "0.28.0",
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.1.tgz",
|
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.0.tgz",
|
||||||
"integrity": "sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==",
|
"integrity": "sha512-lhRUCeuOyJQURhTxl4WkpFTjIsbDayJHih5kZC1giwE+MhIzAb7mEsQMqMf18rHLsrb5qI1tafG20mLxEWcWlA==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"ppc64"
|
"ppc64"
|
||||||
],
|
],
|
||||||
@@ -1265,9 +1265,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@esbuild/android-arm": {
|
"node_modules/@esbuild/android-arm": {
|
||||||
"version": "0.28.1",
|
"version": "0.28.0",
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.1.tgz",
|
"resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.0.tgz",
|
||||||
"integrity": "sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ==",
|
"integrity": "sha512-wqh0ByljabXLKHeWXYLqoJ5jKC4XBaw6Hk08OfMrCRd2nP2ZQ5eleDZC41XHyCNgktBGYMbqnrJKq/K/lzPMSQ==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm"
|
"arm"
|
||||||
],
|
],
|
||||||
@@ -1282,9 +1282,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@esbuild/android-arm64": {
|
"node_modules/@esbuild/android-arm64": {
|
||||||
"version": "0.28.1",
|
"version": "0.28.0",
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.1.tgz",
|
"resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.0.tgz",
|
||||||
"integrity": "sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg==",
|
"integrity": "sha512-+WzIXQOSaGs33tLEgYPYe/yQHf0WTU0X42Jca3y8NWMbUVhp7rUnw+vAsRC/QiDrdD31IszMrZy+qwPOPjd+rw==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
@@ -1299,9 +1299,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@esbuild/android-x64": {
|
"node_modules/@esbuild/android-x64": {
|
||||||
"version": "0.28.1",
|
"version": "0.28.0",
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.1.tgz",
|
"resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.0.tgz",
|
||||||
"integrity": "sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng==",
|
"integrity": "sha512-+VJggoaKhk2VNNqVL7f6S189UzShHC/mR9EE8rDdSkdpN0KflSwWY/gWjDrNxxisg8Fp1ZCD9jLMo4m0OUfeUA==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
@@ -1316,9 +1316,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@esbuild/darwin-arm64": {
|
"node_modules/@esbuild/darwin-arm64": {
|
||||||
"version": "0.28.1",
|
"version": "0.28.0",
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.1.tgz",
|
"resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.0.tgz",
|
||||||
"integrity": "sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q==",
|
"integrity": "sha512-0T+A9WZm+bZ84nZBtk1ckYsOvyA3x7e2Acj1KdVfV4/2tdG4fzUp91YHx+GArWLtwqp77pBXVCPn2We7Letr0Q==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
@@ -1333,9 +1333,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@esbuild/darwin-x64": {
|
"node_modules/@esbuild/darwin-x64": {
|
||||||
"version": "0.28.1",
|
"version": "0.28.0",
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.1.tgz",
|
"resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.0.tgz",
|
||||||
"integrity": "sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ==",
|
"integrity": "sha512-fyzLm/DLDl/84OCfp2f/XQ4flmORsjU7VKt8HLjvIXChJoFFOIL6pLJPH4Yhd1n1gGFF9mPwtlN5Wf82DZs+LQ==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
@@ -1350,9 +1350,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@esbuild/freebsd-arm64": {
|
"node_modules/@esbuild/freebsd-arm64": {
|
||||||
"version": "0.28.1",
|
"version": "0.28.0",
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.1.tgz",
|
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.0.tgz",
|
||||||
"integrity": "sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw==",
|
"integrity": "sha512-l9GeW5UZBT9k9brBYI+0WDffcRxgHQD8ShN2Ur4xWq/NFzUKm3k5lsH4PdaRgb2w7mI9u61nr2gI2mLI27Nh3Q==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
@@ -1367,9 +1367,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@esbuild/freebsd-x64": {
|
"node_modules/@esbuild/freebsd-x64": {
|
||||||
"version": "0.28.1",
|
"version": "0.28.0",
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.1.tgz",
|
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.0.tgz",
|
||||||
"integrity": "sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ==",
|
"integrity": "sha512-BXoQai/A0wPO6Es3yFJ7APCiKGc1tdAEOgeTNy3SsB491S3aHn4S4r3e976eUnPdU+NbdtmBuLncYir2tMU9Nw==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
@@ -1384,9 +1384,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@esbuild/linux-arm": {
|
"node_modules/@esbuild/linux-arm": {
|
||||||
"version": "0.28.1",
|
"version": "0.28.0",
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.1.tgz",
|
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.0.tgz",
|
||||||
"integrity": "sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ==",
|
"integrity": "sha512-CjaaREJagqJp7iTaNQjjidaNbCKYcd4IDkzbwwxtSvjI7NZm79qiHc8HqciMddQ6CKvJT6aBd8lO9kN/ZudLlw==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm"
|
"arm"
|
||||||
],
|
],
|
||||||
@@ -1401,9 +1401,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@esbuild/linux-arm64": {
|
"node_modules/@esbuild/linux-arm64": {
|
||||||
"version": "0.28.1",
|
"version": "0.28.0",
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.1.tgz",
|
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.0.tgz",
|
||||||
"integrity": "sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g==",
|
"integrity": "sha512-RVyzfb3FWsGA55n6WY0MEIEPURL1FcbhFE6BffZEMEekfCzCIMtB5yyDcFnVbTnwk+CLAgTujmV/Lgvih56W+A==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
@@ -1418,9 +1418,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@esbuild/linux-ia32": {
|
"node_modules/@esbuild/linux-ia32": {
|
||||||
"version": "0.28.1",
|
"version": "0.28.0",
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.1.tgz",
|
"resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.0.tgz",
|
||||||
"integrity": "sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w==",
|
"integrity": "sha512-KBnSTt1kxl9x70q+ydterVdl+Cn0H18ngRMRCEQfrbqdUuntQQ0LoMZv47uB97NljZFzY6HcfqEZ2SAyIUTQBQ==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"ia32"
|
"ia32"
|
||||||
],
|
],
|
||||||
@@ -1435,9 +1435,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@esbuild/linux-loong64": {
|
"node_modules/@esbuild/linux-loong64": {
|
||||||
"version": "0.28.1",
|
"version": "0.28.0",
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.1.tgz",
|
"resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.0.tgz",
|
||||||
"integrity": "sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg==",
|
"integrity": "sha512-zpSlUce1mnxzgBADvxKXX5sl8aYQHo2ezvMNI8I0lbblJtp8V4odlm3Yzlj7gPyt3T8ReksE6bK+pT3WD+aJRg==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"loong64"
|
"loong64"
|
||||||
],
|
],
|
||||||
@@ -1452,9 +1452,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@esbuild/linux-mips64el": {
|
"node_modules/@esbuild/linux-mips64el": {
|
||||||
"version": "0.28.1",
|
"version": "0.28.0",
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.1.tgz",
|
"resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.0.tgz",
|
||||||
"integrity": "sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ==",
|
"integrity": "sha512-2jIfP6mmjkdmeTlsX/9vmdmhBmKADrWqN7zcdtHIeNSCH1SqIoNI63cYsjQR8J+wGa4Y5izRcSHSm8K3QWmk3w==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"mips64el"
|
"mips64el"
|
||||||
],
|
],
|
||||||
@@ -1469,9 +1469,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@esbuild/linux-ppc64": {
|
"node_modules/@esbuild/linux-ppc64": {
|
||||||
"version": "0.28.1",
|
"version": "0.28.0",
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.1.tgz",
|
"resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.0.tgz",
|
||||||
"integrity": "sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ==",
|
"integrity": "sha512-bc0FE9wWeC0WBm49IQMPSPILRocGTQt3j5KPCA8os6VprfuJ7KD+5PzESSrJ6GmPIPJK965ZJHTUlSA6GNYEhg==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"ppc64"
|
"ppc64"
|
||||||
],
|
],
|
||||||
@@ -1486,9 +1486,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@esbuild/linux-riscv64": {
|
"node_modules/@esbuild/linux-riscv64": {
|
||||||
"version": "0.28.1",
|
"version": "0.28.0",
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.1.tgz",
|
"resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.0.tgz",
|
||||||
"integrity": "sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ==",
|
"integrity": "sha512-SQPZOwoTTT/HXFXQJG/vBX8sOFagGqvZyXcgLA3NhIqcBv1BJU1d46c0rGcrij2B56Z2rNiSLaZOYW5cUk7yLQ==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"riscv64"
|
"riscv64"
|
||||||
],
|
],
|
||||||
@@ -1503,9 +1503,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@esbuild/linux-s390x": {
|
"node_modules/@esbuild/linux-s390x": {
|
||||||
"version": "0.28.1",
|
"version": "0.28.0",
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.1.tgz",
|
"resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.0.tgz",
|
||||||
"integrity": "sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag==",
|
"integrity": "sha512-SCfR0HN8CEEjnYnySJTd2cw0k9OHB/YFzt5zgJEwa+wL/T/raGWYMBqwDNAC6dqFKmJYZoQBRfHjgwLHGSrn3Q==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"s390x"
|
"s390x"
|
||||||
],
|
],
|
||||||
@@ -1520,9 +1520,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@esbuild/linux-x64": {
|
"node_modules/@esbuild/linux-x64": {
|
||||||
"version": "0.28.1",
|
"version": "0.28.0",
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.1.tgz",
|
"resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.0.tgz",
|
||||||
"integrity": "sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA==",
|
"integrity": "sha512-us0dSb9iFxIi8srnpl931Nvs65it/Jd2a2K3qs7fz2WfGPHqzfzZTfec7oxZJRNPXPnNYZtanmRc4AL/JwVzHQ==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
@@ -1537,9 +1537,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@esbuild/netbsd-arm64": {
|
"node_modules/@esbuild/netbsd-arm64": {
|
||||||
"version": "0.28.1",
|
"version": "0.28.0",
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.1.tgz",
|
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.0.tgz",
|
||||||
"integrity": "sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw==",
|
"integrity": "sha512-CR/RYotgtCKwtftMwJlUU7xCVNg3lMYZ0RzTmAHSfLCXw3NtZtNpswLEj/Kkf6kEL3Gw+BpOekRX0BYCtklhUw==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
@@ -1554,9 +1554,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@esbuild/netbsd-x64": {
|
"node_modules/@esbuild/netbsd-x64": {
|
||||||
"version": "0.28.1",
|
"version": "0.28.0",
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.1.tgz",
|
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.0.tgz",
|
||||||
"integrity": "sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg==",
|
"integrity": "sha512-nU1yhmYutL+fQ71Kxnhg8uEOdC0pwEW9entHykTgEbna2pw2dkbFSMeqjjyHZoCmt8SBkOSvV+yNmm94aUrrqw==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
@@ -1571,9 +1571,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@esbuild/openbsd-arm64": {
|
"node_modules/@esbuild/openbsd-arm64": {
|
||||||
"version": "0.28.1",
|
"version": "0.28.0",
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.1.tgz",
|
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.0.tgz",
|
||||||
"integrity": "sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q==",
|
"integrity": "sha512-cXb5vApOsRsxsEl4mcZ1XY3D4DzcoMxR/nnc4IyqYs0rTI8ZKmW6kyyg+11Z8yvgMfAEldKzP7AdP64HnSC/6g==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
@@ -1588,9 +1588,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@esbuild/openbsd-x64": {
|
"node_modules/@esbuild/openbsd-x64": {
|
||||||
"version": "0.28.1",
|
"version": "0.28.0",
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.1.tgz",
|
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.0.tgz",
|
||||||
"integrity": "sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw==",
|
"integrity": "sha512-8wZM2qqtv9UP3mzy7HiGYNH/zjTA355mpeuA+859TyR+e+Tc08IHYpLJuMsfpDJwoLo1ikIJI8jC3GFjnRClzA==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
@@ -1605,9 +1605,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@esbuild/openharmony-arm64": {
|
"node_modules/@esbuild/openharmony-arm64": {
|
||||||
"version": "0.28.1",
|
"version": "0.28.0",
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.1.tgz",
|
"resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.0.tgz",
|
||||||
"integrity": "sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg==",
|
"integrity": "sha512-FLGfyizszcef5C3YtoyQDACyg95+dndv79i2EekILBofh5wpCa1KuBqOWKrEHZg3zrL3t5ouE5jgr94vA+Wb2w==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
@@ -1622,9 +1622,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@esbuild/sunos-x64": {
|
"node_modules/@esbuild/sunos-x64": {
|
||||||
"version": "0.28.1",
|
"version": "0.28.0",
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.1.tgz",
|
"resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.0.tgz",
|
||||||
"integrity": "sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ==",
|
"integrity": "sha512-1ZgjUoEdHZZl/YlV76TSCz9Hqj9h9YmMGAgAPYd+q4SicWNX3G5GCyx9uhQWSLcbvPW8Ni7lj4gDa1T40akdlw==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
@@ -1639,9 +1639,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@esbuild/win32-arm64": {
|
"node_modules/@esbuild/win32-arm64": {
|
||||||
"version": "0.28.1",
|
"version": "0.28.0",
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.1.tgz",
|
"resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.0.tgz",
|
||||||
"integrity": "sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA==",
|
"integrity": "sha512-Q9StnDmQ/enxnpxCCLSg0oo4+34B9TdXpuyPeTedN/6+iXBJ4J+zwfQI28u/Jl40nOYAxGoNi7mFP40RUtkmUA==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
@@ -1656,9 +1656,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@esbuild/win32-ia32": {
|
"node_modules/@esbuild/win32-ia32": {
|
||||||
"version": "0.28.1",
|
"version": "0.28.0",
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.1.tgz",
|
"resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.0.tgz",
|
||||||
"integrity": "sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg==",
|
"integrity": "sha512-zF3ag/gfiCe6U2iczcRzSYJKH1DCI+ByzSENHlM2FcDbEeo5Zd2C86Aq0tKUYAJJ1obRP84ymxIAksZUcdztHA==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"ia32"
|
"ia32"
|
||||||
],
|
],
|
||||||
@@ -1673,9 +1673,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@esbuild/win32-x64": {
|
"node_modules/@esbuild/win32-x64": {
|
||||||
"version": "0.28.1",
|
"version": "0.28.0",
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.1.tgz",
|
"resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.0.tgz",
|
||||||
"integrity": "sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A==",
|
"integrity": "sha512-pEl1bO9mfAmIC+tW5btTmrKaujg3zGtUmWNdCw/xs70FBjwAL3o9OEKNHvNmnyylD6ubxUERiEhdsL0xBQ9efw==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
@@ -2076,6 +2076,9 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"arm"
|
"arm"
|
||||||
],
|
],
|
||||||
|
"libc": [
|
||||||
|
"glibc"
|
||||||
|
],
|
||||||
"license": "LGPL-3.0-or-later",
|
"license": "LGPL-3.0-or-later",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -2092,6 +2095,9 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
|
"libc": [
|
||||||
|
"glibc"
|
||||||
|
],
|
||||||
"license": "LGPL-3.0-or-later",
|
"license": "LGPL-3.0-or-later",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -2108,6 +2114,9 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"ppc64"
|
"ppc64"
|
||||||
],
|
],
|
||||||
|
"libc": [
|
||||||
|
"glibc"
|
||||||
|
],
|
||||||
"license": "LGPL-3.0-or-later",
|
"license": "LGPL-3.0-or-later",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -2124,6 +2133,9 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"riscv64"
|
"riscv64"
|
||||||
],
|
],
|
||||||
|
"libc": [
|
||||||
|
"glibc"
|
||||||
|
],
|
||||||
"license": "LGPL-3.0-or-later",
|
"license": "LGPL-3.0-or-later",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -2140,6 +2152,9 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"s390x"
|
"s390x"
|
||||||
],
|
],
|
||||||
|
"libc": [
|
||||||
|
"glibc"
|
||||||
|
],
|
||||||
"license": "LGPL-3.0-or-later",
|
"license": "LGPL-3.0-or-later",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -2172,6 +2187,9 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
|
"libc": [
|
||||||
|
"musl"
|
||||||
|
],
|
||||||
"license": "LGPL-3.0-or-later",
|
"license": "LGPL-3.0-or-later",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -2204,6 +2222,9 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"arm"
|
"arm"
|
||||||
],
|
],
|
||||||
|
"libc": [
|
||||||
|
"glibc"
|
||||||
|
],
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -2226,6 +2247,9 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
|
"libc": [
|
||||||
|
"glibc"
|
||||||
|
],
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -2248,6 +2272,9 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"ppc64"
|
"ppc64"
|
||||||
],
|
],
|
||||||
|
"libc": [
|
||||||
|
"glibc"
|
||||||
|
],
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -2270,6 +2297,9 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"riscv64"
|
"riscv64"
|
||||||
],
|
],
|
||||||
|
"libc": [
|
||||||
|
"glibc"
|
||||||
|
],
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -2292,6 +2322,9 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"s390x"
|
"s390x"
|
||||||
],
|
],
|
||||||
|
"libc": [
|
||||||
|
"glibc"
|
||||||
|
],
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -2336,6 +2369,9 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
|
"libc": [
|
||||||
|
"musl"
|
||||||
|
],
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -2628,6 +2664,9 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
|
"libc": [
|
||||||
|
"glibc"
|
||||||
|
],
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -2644,6 +2683,9 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
|
"libc": [
|
||||||
|
"musl"
|
||||||
|
],
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -2899,6 +2941,9 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
|
"libc": [
|
||||||
|
"glibc"
|
||||||
|
],
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -2915,6 +2960,9 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
|
"libc": [
|
||||||
|
"musl"
|
||||||
|
],
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -3152,6 +3200,9 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
|
"libc": [
|
||||||
|
"glibc"
|
||||||
|
],
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -3168,6 +3219,9 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
|
"libc": [
|
||||||
|
"musl"
|
||||||
|
],
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -3528,6 +3582,9 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"arm"
|
"arm"
|
||||||
],
|
],
|
||||||
|
"libc": [
|
||||||
|
"glibc"
|
||||||
|
],
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -3548,6 +3605,9 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"arm"
|
"arm"
|
||||||
],
|
],
|
||||||
|
"libc": [
|
||||||
|
"musl"
|
||||||
|
],
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -3568,6 +3628,9 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
|
"libc": [
|
||||||
|
"glibc"
|
||||||
|
],
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -3588,6 +3651,9 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
|
"libc": [
|
||||||
|
"musl"
|
||||||
|
],
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -6807,6 +6873,9 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
|
"libc": [
|
||||||
|
"glibc"
|
||||||
|
],
|
||||||
"license": "Apache-2.0 AND MIT",
|
"license": "Apache-2.0 AND MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -6823,6 +6892,9 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
|
"libc": [
|
||||||
|
"musl"
|
||||||
|
],
|
||||||
"license": "Apache-2.0 AND MIT",
|
"license": "Apache-2.0 AND MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -6839,6 +6911,9 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"ppc64"
|
"ppc64"
|
||||||
],
|
],
|
||||||
|
"libc": [
|
||||||
|
"glibc"
|
||||||
|
],
|
||||||
"license": "Apache-2.0 AND MIT",
|
"license": "Apache-2.0 AND MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -6855,6 +6930,9 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"s390x"
|
"s390x"
|
||||||
],
|
],
|
||||||
|
"libc": [
|
||||||
|
"glibc"
|
||||||
|
],
|
||||||
"license": "Apache-2.0 AND MIT",
|
"license": "Apache-2.0 AND MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -7122,6 +7200,9 @@
|
|||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
|
"libc": [
|
||||||
|
"glibc"
|
||||||
|
],
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -7139,6 +7220,9 @@
|
|||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
|
"libc": [
|
||||||
|
"musl"
|
||||||
|
],
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -7212,72 +7296,6 @@
|
|||||||
"node": ">=14.0.0"
|
"node": ">=14.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/core": {
|
|
||||||
"version": "1.10.0",
|
|
||||||
"dev": true,
|
|
||||||
"inBundle": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"optional": true,
|
|
||||||
"dependencies": {
|
|
||||||
"@emnapi/wasi-threads": "1.2.1",
|
|
||||||
"tslib": "^2.4.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/runtime": {
|
|
||||||
"version": "1.10.0",
|
|
||||||
"dev": true,
|
|
||||||
"inBundle": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"optional": true,
|
|
||||||
"dependencies": {
|
|
||||||
"tslib": "^2.4.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/wasi-threads": {
|
|
||||||
"version": "1.2.1",
|
|
||||||
"dev": true,
|
|
||||||
"inBundle": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"optional": true,
|
|
||||||
"dependencies": {
|
|
||||||
"tslib": "^2.4.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@napi-rs/wasm-runtime": {
|
|
||||||
"version": "1.1.4",
|
|
||||||
"dev": true,
|
|
||||||
"inBundle": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"optional": true,
|
|
||||||
"dependencies": {
|
|
||||||
"@tybys/wasm-util": "^0.10.1"
|
|
||||||
},
|
|
||||||
"funding": {
|
|
||||||
"type": "github",
|
|
||||||
"url": "https://github.com/sponsors/Brooooooklyn"
|
|
||||||
},
|
|
||||||
"peerDependencies": {
|
|
||||||
"@emnapi/core": "^1.7.1",
|
|
||||||
"@emnapi/runtime": "^1.7.1"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@tybys/wasm-util": {
|
|
||||||
"version": "0.10.1",
|
|
||||||
"dev": true,
|
|
||||||
"inBundle": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"optional": true,
|
|
||||||
"dependencies": {
|
|
||||||
"tslib": "^2.4.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/tslib": {
|
|
||||||
"version": "2.8.1",
|
|
||||||
"dev": true,
|
|
||||||
"inBundle": true,
|
|
||||||
"license": "0BSD",
|
|
||||||
"optional": true
|
|
||||||
},
|
|
||||||
"node_modules/@tailwindcss/oxide-win32-arm64-msvc": {
|
"node_modules/@tailwindcss/oxide-win32-arm64-msvc": {
|
||||||
"version": "4.3.0",
|
"version": "4.3.0",
|
||||||
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.3.0.tgz",
|
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.3.0.tgz",
|
||||||
@@ -8430,6 +8448,9 @@
|
|||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
|
"libc": [
|
||||||
|
"glibc"
|
||||||
|
],
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -8444,6 +8465,9 @@
|
|||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
|
"libc": [
|
||||||
|
"musl"
|
||||||
|
],
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -8458,6 +8482,9 @@
|
|||||||
"ppc64"
|
"ppc64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
|
"libc": [
|
||||||
|
"glibc"
|
||||||
|
],
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -8472,6 +8499,9 @@
|
|||||||
"riscv64"
|
"riscv64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
|
"libc": [
|
||||||
|
"glibc"
|
||||||
|
],
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -8486,6 +8516,9 @@
|
|||||||
"riscv64"
|
"riscv64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
|
"libc": [
|
||||||
|
"musl"
|
||||||
|
],
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -8500,6 +8533,9 @@
|
|||||||
"s390x"
|
"s390x"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
|
"libc": [
|
||||||
|
"glibc"
|
||||||
|
],
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -11225,9 +11261,9 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"node_modules/esbuild": {
|
"node_modules/esbuild": {
|
||||||
"version": "0.28.1",
|
"version": "0.28.0",
|
||||||
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.1.tgz",
|
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.0.tgz",
|
||||||
"integrity": "sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==",
|
"integrity": "sha512-sNR9MHpXSUV/XB4zmsFKN+QgVG82Cc7+/aaxJ8Adi8hyOac+EXptIp45QBPaVyX3N70664wRbTcLTOemCAnyqw==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"hasInstallScript": true,
|
"hasInstallScript": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
@@ -11238,32 +11274,32 @@
|
|||||||
"node": ">=18"
|
"node": ">=18"
|
||||||
},
|
},
|
||||||
"optionalDependencies": {
|
"optionalDependencies": {
|
||||||
"@esbuild/aix-ppc64": "0.28.1",
|
"@esbuild/aix-ppc64": "0.28.0",
|
||||||
"@esbuild/android-arm": "0.28.1",
|
"@esbuild/android-arm": "0.28.0",
|
||||||
"@esbuild/android-arm64": "0.28.1",
|
"@esbuild/android-arm64": "0.28.0",
|
||||||
"@esbuild/android-x64": "0.28.1",
|
"@esbuild/android-x64": "0.28.0",
|
||||||
"@esbuild/darwin-arm64": "0.28.1",
|
"@esbuild/darwin-arm64": "0.28.0",
|
||||||
"@esbuild/darwin-x64": "0.28.1",
|
"@esbuild/darwin-x64": "0.28.0",
|
||||||
"@esbuild/freebsd-arm64": "0.28.1",
|
"@esbuild/freebsd-arm64": "0.28.0",
|
||||||
"@esbuild/freebsd-x64": "0.28.1",
|
"@esbuild/freebsd-x64": "0.28.0",
|
||||||
"@esbuild/linux-arm": "0.28.1",
|
"@esbuild/linux-arm": "0.28.0",
|
||||||
"@esbuild/linux-arm64": "0.28.1",
|
"@esbuild/linux-arm64": "0.28.0",
|
||||||
"@esbuild/linux-ia32": "0.28.1",
|
"@esbuild/linux-ia32": "0.28.0",
|
||||||
"@esbuild/linux-loong64": "0.28.1",
|
"@esbuild/linux-loong64": "0.28.0",
|
||||||
"@esbuild/linux-mips64el": "0.28.1",
|
"@esbuild/linux-mips64el": "0.28.0",
|
||||||
"@esbuild/linux-ppc64": "0.28.1",
|
"@esbuild/linux-ppc64": "0.28.0",
|
||||||
"@esbuild/linux-riscv64": "0.28.1",
|
"@esbuild/linux-riscv64": "0.28.0",
|
||||||
"@esbuild/linux-s390x": "0.28.1",
|
"@esbuild/linux-s390x": "0.28.0",
|
||||||
"@esbuild/linux-x64": "0.28.1",
|
"@esbuild/linux-x64": "0.28.0",
|
||||||
"@esbuild/netbsd-arm64": "0.28.1",
|
"@esbuild/netbsd-arm64": "0.28.0",
|
||||||
"@esbuild/netbsd-x64": "0.28.1",
|
"@esbuild/netbsd-x64": "0.28.0",
|
||||||
"@esbuild/openbsd-arm64": "0.28.1",
|
"@esbuild/openbsd-arm64": "0.28.0",
|
||||||
"@esbuild/openbsd-x64": "0.28.1",
|
"@esbuild/openbsd-x64": "0.28.0",
|
||||||
"@esbuild/openharmony-arm64": "0.28.1",
|
"@esbuild/openharmony-arm64": "0.28.0",
|
||||||
"@esbuild/sunos-x64": "0.28.1",
|
"@esbuild/sunos-x64": "0.28.0",
|
||||||
"@esbuild/win32-arm64": "0.28.1",
|
"@esbuild/win32-arm64": "0.28.0",
|
||||||
"@esbuild/win32-ia32": "0.28.1",
|
"@esbuild/win32-ia32": "0.28.0",
|
||||||
"@esbuild/win32-x64": "0.28.1"
|
"@esbuild/win32-x64": "0.28.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/esbuild-node-externals": {
|
"node_modules/esbuild-node-externals": {
|
||||||
@@ -12155,16 +12191,16 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/form-data": {
|
"node_modules/form-data": {
|
||||||
"version": "4.0.6",
|
"version": "4.0.5",
|
||||||
"resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.6.tgz",
|
"resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz",
|
||||||
"integrity": "sha512-vKatAh4SlVfgbv+YtmhiRjhEMJsYpsG1Y2rMQtR+SVSbytsSD1YGzDIcrAJmdFec88u/+VoGmxnl+80gL1tRCQ==",
|
"integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"asynckit": "^0.4.0",
|
"asynckit": "^0.4.0",
|
||||||
"combined-stream": "^1.0.8",
|
"combined-stream": "^1.0.8",
|
||||||
"es-set-tostringtag": "^2.1.0",
|
"es-set-tostringtag": "^2.1.0",
|
||||||
"hasown": "^2.0.4",
|
"hasown": "^2.0.2",
|
||||||
"mime-types": "^2.1.35"
|
"mime-types": "^2.1.12"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">= 6"
|
"node": ">= 6"
|
||||||
@@ -12593,9 +12629,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/hasown": {
|
"node_modules/hasown": {
|
||||||
"version": "2.0.4",
|
"version": "2.0.3",
|
||||||
"resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz",
|
"resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.3.tgz",
|
||||||
"integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==",
|
"integrity": "sha512-ej4AhfhfL2Q2zpMmLo7U1Uv9+PyhIZpgQLGT1F9miIGmiCJIoCgSmczFdrc97mWT4kVY72KA+WnnhJ5pghSvSg==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"function-bind": "^1.1.2"
|
"function-bind": "^1.1.2"
|
||||||
@@ -13389,19 +13425,9 @@
|
|||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/js-yaml": {
|
"node_modules/js-yaml": {
|
||||||
"version": "4.2.0",
|
"version": "4.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz",
|
||||||
"integrity": "sha512-ePWsvanv0DWuDRsW8dnt+R4jQ31SCRCQ7hhNcPXZPsoBZiemuZNYGf7adZdqX2D86j6rvKp3RpCxVTSb8WQlOw==",
|
"integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==",
|
||||||
"funding": [
|
|
||||||
{
|
|
||||||
"type": "github",
|
|
||||||
"url": "https://github.com/sponsors/puzrin"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"type": "github",
|
|
||||||
"url": "https://github.com/sponsors/nodeca"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"argparse": "^2.0.1"
|
"argparse": "^2.0.1"
|
||||||
@@ -13741,6 +13767,9 @@
|
|||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
|
"libc": [
|
||||||
|
"glibc"
|
||||||
|
],
|
||||||
"license": "MPL-2.0",
|
"license": "MPL-2.0",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -13762,6 +13791,9 @@
|
|||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
"dev": true,
|
||||||
|
"libc": [
|
||||||
|
"musl"
|
||||||
|
],
|
||||||
"license": "MPL-2.0",
|
"license": "MPL-2.0",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -14542,9 +14574,9 @@
|
|||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/nodemailer": {
|
"node_modules/nodemailer": {
|
||||||
"version": "9.0.1",
|
"version": "8.0.9",
|
||||||
"resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-9.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-8.0.9.tgz",
|
||||||
"integrity": "sha512-Gwv8SQewT616ZM/URn0H54b8PWo/Wum7md3EW2aWy1lO27+WZCX+Xyak3J+NlmHUjDh5ME+uesJUDRbR3Ye8Bw==",
|
"integrity": "sha512-5ofa7BUN8+C+Hckh5V2GjeeOGRQBx0CJQA6KxrvuZfC8iU4/q7sLn8XrtEEhJkjV6HdyIiQs7Bba6bTao8JhkA==",
|
||||||
"license": "MIT-0",
|
"license": "MIT-0",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=6.0.0"
|
"node": ">=6.0.0"
|
||||||
@@ -14969,6 +15001,9 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
|
"libc": [
|
||||||
|
"glibc"
|
||||||
|
],
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -14985,6 +15020,9 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
|
"libc": [
|
||||||
|
"musl"
|
||||||
|
],
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
|
|||||||
+5
-5
@@ -34,7 +34,7 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@asteasolutions/zod-to-openapi": "8.5.0",
|
"@asteasolutions/zod-to-openapi": "8.5.0",
|
||||||
"@devolutions/iron-remote-desktop": "https://static.pangolin.net/packages/devolutions-iron-remote-desktop-0.0.0.tgz",
|
"@devolutions/iron-remote-desktop": "https://static.pangolin.net/packages/devolutions-iron-remote-desktop-0.0.0.tgz",
|
||||||
"@devolutions/iron-remote-desktop-rdp": "https://static.pangolin.net/packages/devolutions-iron-remote-desktop-rdp-0.0.1.tgz",
|
"@devolutions/iron-remote-desktop-rdp": "https://static.pangolin.net/packages/devolutions-iron-remote-desktop-rdp-0.0.0.tgz",
|
||||||
"@aws-sdk/client-s3": "3.1056.0",
|
"@aws-sdk/client-s3": "3.1056.0",
|
||||||
"@headlessui/react": "2.2.10",
|
"@headlessui/react": "2.2.10",
|
||||||
"@hookform/resolvers": "5.4.0",
|
"@hookform/resolvers": "5.4.0",
|
||||||
@@ -93,7 +93,7 @@
|
|||||||
"input-otp": "1.4.2",
|
"input-otp": "1.4.2",
|
||||||
"ioredis": "5.11.0",
|
"ioredis": "5.11.0",
|
||||||
"jmespath": "0.16.0",
|
"jmespath": "0.16.0",
|
||||||
"js-yaml": "4.2.0",
|
"js-yaml": "4.1.1",
|
||||||
"jsonwebtoken": "9.0.3",
|
"jsonwebtoken": "9.0.3",
|
||||||
"lucide-react": "1.17.0",
|
"lucide-react": "1.17.0",
|
||||||
"maxmind": "5.0.6",
|
"maxmind": "5.0.6",
|
||||||
@@ -103,7 +103,7 @@
|
|||||||
"next-themes": "0.4.6",
|
"next-themes": "0.4.6",
|
||||||
"nextjs-toploader": "3.9.17",
|
"nextjs-toploader": "3.9.17",
|
||||||
"node-cache": "5.1.2",
|
"node-cache": "5.1.2",
|
||||||
"nodemailer": "9.0.1",
|
"nodemailer": "8.0.9",
|
||||||
"oslo": "1.2.1",
|
"oslo": "1.2.1",
|
||||||
"pg": "8.21.0",
|
"pg": "8.21.0",
|
||||||
"posthog-node": "5.35.6",
|
"posthog-node": "5.35.6",
|
||||||
@@ -165,7 +165,7 @@
|
|||||||
"@types/yargs": "17.0.35",
|
"@types/yargs": "17.0.35",
|
||||||
"babel-plugin-react-compiler": "1.0.0",
|
"babel-plugin-react-compiler": "1.0.0",
|
||||||
"drizzle-kit": "0.31.10",
|
"drizzle-kit": "0.31.10",
|
||||||
"esbuild": "0.28.1",
|
"esbuild": "0.28.0",
|
||||||
"esbuild-node-externals": "1.22.0",
|
"esbuild-node-externals": "1.22.0",
|
||||||
"eslint": "10.4.0",
|
"eslint": "10.4.0",
|
||||||
"eslint-config-next": "16.2.6",
|
"eslint-config-next": "16.2.6",
|
||||||
@@ -179,7 +179,7 @@
|
|||||||
"typescript-eslint": "8.60.0"
|
"typescript-eslint": "8.60.0"
|
||||||
},
|
},
|
||||||
"overrides": {
|
"overrides": {
|
||||||
"esbuild": "0.28.1",
|
"esbuild": "0.28.0",
|
||||||
"dompurify": "3.4.0",
|
"dompurify": "3.4.0",
|
||||||
"postcss": "8.5.15"
|
"postcss": "8.5.15"
|
||||||
}
|
}
|
||||||
|
|||||||
Binary file not shown.
|
Before Width: | Height: | Size: 556 KiB |
@@ -21,8 +21,6 @@ export enum ActionsEnum {
|
|||||||
getSite = "getSite",
|
getSite = "getSite",
|
||||||
listSites = "listSites",
|
listSites = "listSites",
|
||||||
updateSite = "updateSite",
|
updateSite = "updateSite",
|
||||||
updateSiteApprovals = "updateSiteApprovals",
|
|
||||||
restartSite = "restartSite",
|
|
||||||
resetSiteBandwidth = "resetSiteBandwidth",
|
resetSiteBandwidth = "resetSiteBandwidth",
|
||||||
reGenerateSecret = "reGenerateSecret",
|
reGenerateSecret = "reGenerateSecret",
|
||||||
createResource = "createResource",
|
createResource = "createResource",
|
||||||
@@ -72,7 +70,6 @@ export enum ActionsEnum {
|
|||||||
setResourceWhitelist = "setResourceWhitelist",
|
setResourceWhitelist = "setResourceWhitelist",
|
||||||
getResourceWhitelist = "getResourceWhitelist",
|
getResourceWhitelist = "getResourceWhitelist",
|
||||||
generateAccessToken = "generateAccessToken",
|
generateAccessToken = "generateAccessToken",
|
||||||
createResourceSessionToken = "createResourceSessionToken",
|
|
||||||
deleteAcessToken = "deleteAcessToken",
|
deleteAcessToken = "deleteAcessToken",
|
||||||
listAccessTokens = "listAccessTokens",
|
listAccessTokens = "listAccessTokens",
|
||||||
createResourceRule = "createResourceRule",
|
createResourceRule = "createResourceRule",
|
||||||
@@ -181,8 +178,7 @@ export enum ActionsEnum {
|
|||||||
setResourcePolicyPincode = "setResourcePolicyPincode",
|
setResourcePolicyPincode = "setResourcePolicyPincode",
|
||||||
setResourcePolicyHeaderAuth = "setResourcePolicyHeaderAuth",
|
setResourcePolicyHeaderAuth = "setResourcePolicyHeaderAuth",
|
||||||
setResourcePolicyWhitelist = "setResourcePolicyWhitelist",
|
setResourcePolicyWhitelist = "setResourcePolicyWhitelist",
|
||||||
setResourcePolicyRules = "setResourcePolicyRules",
|
setResourcePolicyRules = "setResourcePolicyRules"
|
||||||
createOrgWideLauncherView = "createOrgWideLauncherView"
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function checkUserActionPermission(
|
export async function checkUserActionPermission(
|
||||||
|
|||||||
@@ -1,12 +1,6 @@
|
|||||||
import { db } from "@server/db";
|
import { db } from "@server/db";
|
||||||
import { and, eq, inArray, isNull, or } from "drizzle-orm";
|
import { and, eq, inArray } from "drizzle-orm";
|
||||||
import {
|
import { roleResources, userResources } from "@server/db";
|
||||||
rolePolicies,
|
|
||||||
roleResources,
|
|
||||||
resources,
|
|
||||||
userPolicies,
|
|
||||||
userResources
|
|
||||||
} from "@server/db";
|
|
||||||
|
|
||||||
export async function canUserAccessResource({
|
export async function canUserAccessResource({
|
||||||
userId,
|
userId,
|
||||||
@@ -17,14 +11,9 @@ export async function canUserAccessResource({
|
|||||||
resourceId: number;
|
resourceId: number;
|
||||||
roleIds: number[];
|
roleIds: number[];
|
||||||
}): Promise<boolean> {
|
}): Promise<boolean> {
|
||||||
const [
|
const roleResourceAccess =
|
||||||
roleResourceAccess,
|
|
||||||
rolePolicyAccess,
|
|
||||||
userResourceAccess,
|
|
||||||
userPolicyAccess
|
|
||||||
] = await Promise.all([
|
|
||||||
roleIds.length > 0
|
roleIds.length > 0
|
||||||
? db
|
? await db
|
||||||
.select()
|
.select()
|
||||||
.from(roleResources)
|
.from(roleResources)
|
||||||
.where(
|
.where(
|
||||||
@@ -34,87 +23,26 @@ export async function canUserAccessResource({
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
.limit(1)
|
.limit(1)
|
||||||
: [],
|
: [];
|
||||||
roleIds.length > 0
|
|
||||||
? db
|
|
||||||
.select({
|
|
||||||
roleId: rolePolicies.roleId,
|
|
||||||
resourcePolicyId: rolePolicies.resourcePolicyId
|
|
||||||
})
|
|
||||||
.from(rolePolicies)
|
|
||||||
.innerJoin(
|
|
||||||
resources,
|
|
||||||
// Shared policy wins; only use default policy when no shared
|
|
||||||
// policy is assigned to the resource.
|
|
||||||
or(
|
|
||||||
eq(
|
|
||||||
resources.resourcePolicyId,
|
|
||||||
rolePolicies.resourcePolicyId
|
|
||||||
),
|
|
||||||
and(
|
|
||||||
isNull(resources.resourcePolicyId),
|
|
||||||
eq(
|
|
||||||
resources.defaultResourcePolicyId,
|
|
||||||
rolePolicies.resourcePolicyId
|
|
||||||
)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
.where(
|
|
||||||
and(
|
|
||||||
eq(resources.resourceId, resourceId),
|
|
||||||
inArray(rolePolicies.roleId, roleIds)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
.limit(1)
|
|
||||||
: [],
|
|
||||||
db
|
|
||||||
.select()
|
|
||||||
.from(userResources)
|
|
||||||
.where(
|
|
||||||
and(
|
|
||||||
eq(userResources.userId, userId),
|
|
||||||
eq(userResources.resourceId, resourceId)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
.limit(1),
|
|
||||||
db
|
|
||||||
.select({
|
|
||||||
userId: userPolicies.userId,
|
|
||||||
resourcePolicyId: userPolicies.resourcePolicyId
|
|
||||||
})
|
|
||||||
.from(userPolicies)
|
|
||||||
.innerJoin(
|
|
||||||
resources,
|
|
||||||
// Shared policy wins; only use default policy when no shared
|
|
||||||
// policy is assigned to the resource.
|
|
||||||
or(
|
|
||||||
eq(
|
|
||||||
resources.resourcePolicyId,
|
|
||||||
userPolicies.resourcePolicyId
|
|
||||||
),
|
|
||||||
and(
|
|
||||||
isNull(resources.resourcePolicyId),
|
|
||||||
eq(
|
|
||||||
resources.defaultResourcePolicyId,
|
|
||||||
userPolicies.resourcePolicyId
|
|
||||||
)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
.where(
|
|
||||||
and(
|
|
||||||
eq(resources.resourceId, resourceId),
|
|
||||||
eq(userPolicies.userId, userId)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
.limit(1)
|
|
||||||
]);
|
|
||||||
|
|
||||||
return (
|
if (roleResourceAccess.length > 0) {
|
||||||
roleResourceAccess.length > 0 ||
|
return true;
|
||||||
rolePolicyAccess.length > 0 ||
|
}
|
||||||
userResourceAccess.length > 0 ||
|
|
||||||
userPolicyAccess.length > 0
|
const userResourceAccess = await db
|
||||||
);
|
.select()
|
||||||
|
.from(userResources)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(userResources.userId, userId),
|
||||||
|
eq(userResources.resourceId, resourceId)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
if (userResourceAccess.length > 0) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ import {
|
|||||||
users
|
users
|
||||||
} from "@server/db";
|
} from "@server/db";
|
||||||
import { db } from "@server/db";
|
import { db } from "@server/db";
|
||||||
import { and, eq, inArray, ne } from "drizzle-orm";
|
import { eq, inArray } from "drizzle-orm";
|
||||||
import config from "@server/lib/config";
|
import config from "@server/lib/config";
|
||||||
import type { RandomReader } from "@oslojs/crypto/random";
|
import type { RandomReader } from "@oslojs/crypto/random";
|
||||||
import { generateRandomString } from "@oslojs/crypto/random";
|
import { generateRandomString } from "@oslojs/crypto/random";
|
||||||
@@ -136,45 +136,6 @@ export async function invalidateAllSessions(userId: string): Promise<void> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function invalidateAllSessionsExceptCurrent(
|
|
||||||
userId: string,
|
|
||||||
currentSessionId: string
|
|
||||||
): Promise<void> {
|
|
||||||
try {
|
|
||||||
await db.transaction(async (trx) => {
|
|
||||||
const userSessions = await trx
|
|
||||||
.select()
|
|
||||||
.from(sessions)
|
|
||||||
.where(
|
|
||||||
and(
|
|
||||||
eq(sessions.userId, userId),
|
|
||||||
ne(sessions.sessionId, currentSessionId)
|
|
||||||
)
|
|
||||||
);
|
|
||||||
|
|
||||||
if (userSessions.length > 0) {
|
|
||||||
await trx.delete(resourceSessions).where(
|
|
||||||
inArray(
|
|
||||||
resourceSessions.userSessionId,
|
|
||||||
userSessions.map((s) => s.sessionId)
|
|
||||||
)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
await trx
|
|
||||||
.delete(sessions)
|
|
||||||
.where(
|
|
||||||
and(
|
|
||||||
eq(sessions.userId, userId),
|
|
||||||
ne(sessions.sessionId, currentSessionId)
|
|
||||||
)
|
|
||||||
);
|
|
||||||
});
|
|
||||||
} catch (e) {
|
|
||||||
logger.error("Failed to invalidate user sessions except current", e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function serializeSessionCookie(
|
export function serializeSessionCookie(
|
||||||
token: string,
|
token: string,
|
||||||
isSecure: boolean,
|
isSecure: boolean,
|
||||||
|
|||||||
@@ -795,13 +795,10 @@ export const COUNTRIES = [
|
|||||||
name: "Serbia",
|
name: "Serbia",
|
||||||
code: "RS"
|
code: "RS"
|
||||||
},
|
},
|
||||||
// Removed as this is a deprecated ISO country code, not supported anymore
|
{
|
||||||
// Also the individual flags for Serbia & Montenegro are already included in the list
|
name: "Serbia and Montenegro",
|
||||||
// more details: https://en.wikipedia.org/wiki/ISO_3166-2:CS
|
code: "CS"
|
||||||
// {
|
},
|
||||||
// name: "Serbia and Montenegro",
|
|
||||||
// code: "CS"
|
|
||||||
// },
|
|
||||||
{
|
{
|
||||||
name: "Seychelles",
|
name: "Seychelles",
|
||||||
code: "SC"
|
code: "SC"
|
||||||
|
|||||||
@@ -87,7 +87,7 @@ function createDb() {
|
|||||||
|
|
||||||
export const db = createDb();
|
export const db = createDb();
|
||||||
export default db;
|
export default db;
|
||||||
export const primaryDb = db.$primary as typeof db; // is this typeof a problem - technically they are different types
|
export const primaryDb = db.$primary as typeof db; // is this typeof a problem - techincally they are different types
|
||||||
export type Transaction = Parameters<
|
export type Transaction = Parameters<
|
||||||
Parameters<(typeof db)["transaction"]>[0]
|
Parameters<(typeof db)["transaction"]>[0]
|
||||||
>[0];
|
>[0];
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { drizzle as DrizzlePostgres } from "drizzle-orm/node-postgres";
|
|||||||
import { readConfigFile } from "@server/lib/readConfigFile";
|
import { readConfigFile } from "@server/lib/readConfigFile";
|
||||||
import { withReplicas } from "drizzle-orm/pg-core";
|
import { withReplicas } from "drizzle-orm/pg-core";
|
||||||
import { build } from "@server/build";
|
import { build } from "@server/build";
|
||||||
import { db as mainDb } from "./driver";
|
import { db as mainDb, primaryDb as mainPrimaryDb } from "./driver";
|
||||||
import { createPool } from "./poolConfig";
|
import { createPool } from "./poolConfig";
|
||||||
|
|
||||||
function createLogsDb() {
|
function createLogsDb() {
|
||||||
@@ -63,7 +63,8 @@ function createLogsDb() {
|
|||||||
})
|
})
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
const maxReplicaConnections = poolConfig?.max_replica_connections || 20;
|
const maxReplicaConnections =
|
||||||
|
poolConfig?.max_replica_connections || 20;
|
||||||
for (const conn of replicaConnections) {
|
for (const conn of replicaConnections) {
|
||||||
const replicaPool = createPool(
|
const replicaPool = createPool(
|
||||||
conn.connection_string,
|
conn.connection_string,
|
||||||
@@ -90,4 +91,4 @@ function createLogsDb() {
|
|||||||
|
|
||||||
export const logsDb = createLogsDb();
|
export const logsDb = createLogsDb();
|
||||||
export default logsDb;
|
export default logsDb;
|
||||||
export const primaryLogsDb = logsDb.$primary;
|
export const primaryLogsDb = logsDb.$primary;
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import config from "@server/lib/config";
|
|
||||||
import { Pool, PoolConfig } from "pg";
|
import { Pool, PoolConfig } from "pg";
|
||||||
|
import logger from "@server/logger";
|
||||||
|
|
||||||
export function createPoolConfig(
|
export function createPoolConfig(
|
||||||
connectionString: string,
|
connectionString: string,
|
||||||
@@ -27,7 +27,7 @@ export function attachPoolErrorHandlers(pool: Pool, label: string): void {
|
|||||||
pool.on("error", (err) => {
|
pool.on("error", (err) => {
|
||||||
// This catches errors on idle clients in the pool. Without this
|
// This catches errors on idle clients in the pool. Without this
|
||||||
// handler an unexpected disconnect would crash the process.
|
// handler an unexpected disconnect would crash the process.
|
||||||
console.error(
|
logger.error(
|
||||||
`Unexpected error on idle ${label} database client: ${err.message}`
|
`Unexpected error on idle ${label} database client: ${err.message}`
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
@@ -36,32 +36,10 @@ export function attachPoolErrorHandlers(pool: Pool, label: string): void {
|
|||||||
// Set a statement timeout on every new connection so a single slow
|
// Set a statement timeout on every new connection so a single slow
|
||||||
// query can't block the pool forever
|
// query can't block the pool forever
|
||||||
client.query("SET statement_timeout = '30s'").catch((err: Error) => {
|
client.query("SET statement_timeout = '30s'").catch((err: Error) => {
|
||||||
console.warn(
|
logger.warn(
|
||||||
`Failed to set statement_timeout on ${label} client: ${err.message}`
|
`Failed to set statement_timeout on ${label} client: ${err.message}`
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Disable JIT compilation for this connection. Our hot-path queries
|
|
||||||
// (e.g. resource-by-domain lookups) join many tables but only ever
|
|
||||||
// return a handful of rows. When planner row estimates drift (e.g.
|
|
||||||
// due to autovacuum lag under write-heavy load), Postgres decides
|
|
||||||
// these plans are expensive enough to JIT-compile, which can add
|
|
||||||
// multiple seconds of pure compilation overhead per query and
|
|
||||||
// saturate the connection pool. JIT never pays off for these
|
|
||||||
// short-lived OLTP queries, so it's disabled outright rather than
|
|
||||||
// relying on statistics staying fresh.
|
|
||||||
//
|
|
||||||
// Set via a runtime SET command rather than the `options: "-c
|
|
||||||
// jit=off"` startup parameter: connections in SaaS mode go through
|
|
||||||
// a pooler (e.g. PgBouncer) that rejects arbitrary startup packet
|
|
||||||
// options with a protocol_violation (08P01) error.
|
|
||||||
if (config.getRawConfig().postgres?.pool.jit_mode == false) {
|
|
||||||
client.query("SET jit = off").catch((err: Error) => {
|
|
||||||
console.warn(
|
|
||||||
`Failed to set jit=off on ${label} client: ${err.message}`
|
|
||||||
);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -82,4 +60,4 @@ export function createPool(
|
|||||||
);
|
);
|
||||||
attachPoolErrorHandlers(pool, label);
|
attachPoolErrorHandlers(pool, label);
|
||||||
return pool;
|
return pool;
|
||||||
}
|
}
|
||||||
@@ -2,7 +2,6 @@ import {
|
|||||||
pgTable,
|
pgTable,
|
||||||
serial,
|
serial,
|
||||||
varchar,
|
varchar,
|
||||||
unique,
|
|
||||||
boolean,
|
boolean,
|
||||||
integer,
|
integer,
|
||||||
bigint,
|
bigint,
|
||||||
@@ -12,7 +11,7 @@ import {
|
|||||||
primaryKey,
|
primaryKey,
|
||||||
uniqueIndex
|
uniqueIndex
|
||||||
} from "drizzle-orm/pg-core";
|
} from "drizzle-orm/pg-core";
|
||||||
import { InferSelectModel, sql } from "drizzle-orm";
|
import { InferSelectModel } from "drizzle-orm";
|
||||||
import {
|
import {
|
||||||
domains,
|
domains,
|
||||||
orgs,
|
orgs,
|
||||||
@@ -20,13 +19,12 @@ import {
|
|||||||
roles,
|
roles,
|
||||||
users,
|
users,
|
||||||
exitNodes,
|
exitNodes,
|
||||||
|
sessions,
|
||||||
|
clients,
|
||||||
resources,
|
resources,
|
||||||
siteResources,
|
siteResources,
|
||||||
targetHealthCheck,
|
targetHealthCheck,
|
||||||
sites,
|
sites
|
||||||
clients,
|
|
||||||
sessions,
|
|
||||||
labels
|
|
||||||
} from "./schema";
|
} from "./schema";
|
||||||
|
|
||||||
export const certificates = pgTable("certificates", {
|
export const certificates = pgTable("certificates", {
|
||||||
@@ -199,42 +197,6 @@ export const remoteExitNodes = pgTable("remoteExitNode", {
|
|||||||
})
|
})
|
||||||
});
|
});
|
||||||
|
|
||||||
export const remoteExitNodeResources = pgTable("remoteExitNodeResources", {
|
|
||||||
remoteExitNodeResourceId: serial("remoteExitNodeResourceId").primaryKey(),
|
|
||||||
remoteExitNodeId: varchar("remoteExitNodeId")
|
|
||||||
.notNull()
|
|
||||||
.references(() => remoteExitNodes.remoteExitNodeId, {
|
|
||||||
onDelete: "cascade"
|
|
||||||
}),
|
|
||||||
destination: varchar("destination").notNull() // a cidr range
|
|
||||||
});
|
|
||||||
|
|
||||||
export const remoteExitNodePreferenceLabels = pgTable(
|
|
||||||
// this controls what sites are enforced to connect to this node
|
|
||||||
"remoteExitNodePreferenceLabels",
|
|
||||||
{
|
|
||||||
remoteExitNodePreferenceLabelId: serial(
|
|
||||||
"remoteExitNodePreferenceLabelId"
|
|
||||||
).primaryKey(),
|
|
||||||
remoteExitNodeId: varchar("remoteExitNodeId")
|
|
||||||
.references(() => remoteExitNodes.remoteExitNodeId, {
|
|
||||||
onDelete: "cascade"
|
|
||||||
})
|
|
||||||
.notNull(),
|
|
||||||
labelId: integer("labelId")
|
|
||||||
.references(() => labels.labelId, {
|
|
||||||
onDelete: "cascade"
|
|
||||||
})
|
|
||||||
.notNull()
|
|
||||||
},
|
|
||||||
(t) => [
|
|
||||||
unique("remote_exit_node_preference_label_uniq").on(
|
|
||||||
t.remoteExitNodeId,
|
|
||||||
t.labelId
|
|
||||||
)
|
|
||||||
]
|
|
||||||
);
|
|
||||||
|
|
||||||
export const remoteExitNodeSessions = pgTable("remoteExitNodeSession", {
|
export const remoteExitNodeSessions = pgTable("remoteExitNodeSession", {
|
||||||
sessionId: varchar("id").primaryKey(),
|
sessionId: varchar("id").primaryKey(),
|
||||||
remoteExitNodeId: varchar("remoteExitNodeId")
|
remoteExitNodeId: varchar("remoteExitNodeId")
|
||||||
@@ -245,28 +207,17 @@ export const remoteExitNodeSessions = pgTable("remoteExitNodeSession", {
|
|||||||
expiresAt: bigint("expiresAt", { mode: "number" }).notNull()
|
expiresAt: bigint("expiresAt", { mode: "number" }).notNull()
|
||||||
});
|
});
|
||||||
|
|
||||||
export const loginPage = pgTable(
|
export const loginPage = pgTable("loginPage", {
|
||||||
"loginPage",
|
loginPageId: serial("loginPageId").primaryKey(),
|
||||||
{
|
subdomain: varchar("subdomain"),
|
||||||
loginPageId: serial("loginPageId").primaryKey(),
|
fullDomain: varchar("fullDomain"),
|
||||||
subdomain: varchar("subdomain"),
|
exitNodeId: integer("exitNodeId").references(() => exitNodes.exitNodeId, {
|
||||||
fullDomain: varchar("fullDomain"),
|
onDelete: "set null"
|
||||||
exitNodeId: integer("exitNodeId").references(
|
}),
|
||||||
() => exitNodes.exitNodeId,
|
domainId: varchar("domainId").references(() => domains.domainId, {
|
||||||
{
|
onDelete: "set null"
|
||||||
onDelete: "set null"
|
})
|
||||||
}
|
});
|
||||||
),
|
|
||||||
domainId: varchar("domainId").references(() => domains.domainId, {
|
|
||||||
onDelete: "set null"
|
|
||||||
})
|
|
||||||
},
|
|
||||||
(t) => [
|
|
||||||
index("idx_loginpage_fulldomain")
|
|
||||||
.on(t.fullDomain)
|
|
||||||
.where(sql`${t.fullDomain} IS NOT NULL`)
|
|
||||||
]
|
|
||||||
);
|
|
||||||
|
|
||||||
export const loginPageOrg = pgTable("loginPageOrg", {
|
export const loginPageOrg = pgTable("loginPageOrg", {
|
||||||
loginPageId: integer("loginPageId")
|
loginPageId: integer("loginPageId")
|
||||||
@@ -629,6 +580,24 @@ export const trialNotifications = pgTable("trialNotifications", {
|
|||||||
sentAt: bigint("sentAt", { mode: "number" }).notNull()
|
sentAt: bigint("sentAt", { mode: "number" }).notNull()
|
||||||
});
|
});
|
||||||
|
|
||||||
|
export const browserGatewayTarget = pgTable("browserGatewayTarget", {
|
||||||
|
browserGatewayTargetId: serial("browserGatewayTargetId").primaryKey(),
|
||||||
|
resourceId: integer("resourceId")
|
||||||
|
.references(() => resources.resourceId, {
|
||||||
|
onDelete: "cascade"
|
||||||
|
})
|
||||||
|
.notNull(),
|
||||||
|
siteId: integer("siteId")
|
||||||
|
.references(() => sites.siteId, {
|
||||||
|
onDelete: "cascade"
|
||||||
|
})
|
||||||
|
.notNull(),
|
||||||
|
authToken: varchar("authToken").notNull(),
|
||||||
|
type: varchar("type").notNull(), // "ssh", "rdp", "vnc"
|
||||||
|
destination: varchar("destination").notNull(),
|
||||||
|
destinationPort: integer("destinationPort").notNull()
|
||||||
|
});
|
||||||
|
|
||||||
export type Approval = InferSelectModel<typeof approvals>;
|
export type Approval = InferSelectModel<typeof approvals>;
|
||||||
export type Limit = InferSelectModel<typeof limits>;
|
export type Limit = InferSelectModel<typeof limits>;
|
||||||
export type Account = InferSelectModel<typeof account>;
|
export type Account = InferSelectModel<typeof account>;
|
||||||
@@ -676,3 +645,6 @@ export type AlertEmailRecipients = InferSelectModel<
|
|||||||
>;
|
>;
|
||||||
export type AlertWebhookActions = InferSelectModel<typeof alertWebhookActions>;
|
export type AlertWebhookActions = InferSelectModel<typeof alertWebhookActions>;
|
||||||
export type TrialNotification = InferSelectModel<typeof trialNotifications>;
|
export type TrialNotification = InferSelectModel<typeof trialNotifications>;
|
||||||
|
export type BrowserGatewayTarget = InferSelectModel<
|
||||||
|
typeof browserGatewayTarget
|
||||||
|
>;
|
||||||
|
|||||||
+361
-547
File diff suppressed because it is too large
Load Diff
@@ -45,9 +45,9 @@ export type ResourceWithAuth = {
|
|||||||
password: ResourcePassword | ResourcePolicyPassword | null;
|
password: ResourcePassword | ResourcePolicyPassword | null;
|
||||||
headerAuth: ResourceHeaderAuth | ResourcePolicyHeaderAuth | null;
|
headerAuth: ResourceHeaderAuth | ResourcePolicyHeaderAuth | null;
|
||||||
headerAuthExtendedCompatibility: ResourceHeaderAuthExtendedCompatibility | null;
|
headerAuthExtendedCompatibility: ResourceHeaderAuthExtendedCompatibility | null;
|
||||||
applyRules: boolean | null;
|
applyRules: boolean;
|
||||||
sso: boolean | null;
|
sso: boolean;
|
||||||
emailWhitelistEnabled: boolean | null;
|
emailWhitelistEnabled: boolean;
|
||||||
org: Org;
|
org: Org;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
+50
-12
@@ -1,5 +1,6 @@
|
|||||||
import { drizzle as DrizzleSqlite } from "drizzle-orm/better-sqlite3";
|
import { drizzle as DrizzleSqlite } from "drizzle-orm/better-sqlite3";
|
||||||
import Database from "better-sqlite3";
|
import Database from "better-sqlite3";
|
||||||
|
import type BetterSqlite3 from "better-sqlite3";
|
||||||
import * as schema from "./schema/schema";
|
import * as schema from "./schema/schema";
|
||||||
import path from "path";
|
import path from "path";
|
||||||
import fs from "fs";
|
import fs from "fs";
|
||||||
@@ -11,31 +12,68 @@ export const exists = checkFileExists(location);
|
|||||||
|
|
||||||
bootstrapVolume();
|
bootstrapVolume();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wraps better-sqlite3 Statement to call `finalize()` immediately after
|
||||||
|
* execution, freeing native sqlite3_stmt memory deterministically instead
|
||||||
|
* of waiting for GC. Fixes steady off-heap growth under load (#2120).
|
||||||
|
* WARNING: Finalizes after first execution — incompatible with drizzle's
|
||||||
|
* reusable .prepare() builders. No such usage exists in this codebase.
|
||||||
|
*/
|
||||||
|
function autoFinalizeStatement(
|
||||||
|
stmt: BetterSqlite3.Statement
|
||||||
|
): BetterSqlite3.Statement {
|
||||||
|
const wrapExec = <T extends (...args: any[]) => any>(fn: T): T => {
|
||||||
|
return function (this: any, ...args: any[]) {
|
||||||
|
try {
|
||||||
|
return fn.apply(this, args);
|
||||||
|
} finally {
|
||||||
|
try {
|
||||||
|
// finalize() exists on the native Statement at runtime but
|
||||||
|
// is missing from @types/better-sqlite3.
|
||||||
|
(stmt as any).finalize();
|
||||||
|
} catch {
|
||||||
|
// Already finalized — harmless
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} as unknown as T;
|
||||||
|
};
|
||||||
|
|
||||||
|
stmt.run = wrapExec(stmt.run);
|
||||||
|
stmt.get = wrapExec(stmt.get);
|
||||||
|
stmt.all = wrapExec(stmt.all);
|
||||||
|
|
||||||
|
return stmt;
|
||||||
|
}
|
||||||
|
|
||||||
function createDb() {
|
function createDb() {
|
||||||
const sqlite = new Database(location);
|
const sqlite = new Database(location);
|
||||||
|
|
||||||
if (process.env.ENABLE_SQLITE_WAL_MODE == "true") {
|
if (process.env.ENABLE_SQLITE_WAL_MODE == "true") {
|
||||||
// Enable WAL mode — allows concurrent readers + single writer, preventing
|
// Enable WAL mode — allows concurrent readers + single writer, preventing
|
||||||
// contention across subsystems (verifySession, Traefik, audit, ping).
|
// contention across subsystems (verifySession, Traefik, audit, ping).
|
||||||
// NOTE: journal_mode persists in the DB file once set; unsetting this
|
|
||||||
// env var does NOT revert an existing WAL database.
|
|
||||||
sqlite.pragma("journal_mode = WAL");
|
sqlite.pragma("journal_mode = WAL");
|
||||||
// NORMAL sync mode: safe with WAL, reduces write lock hold time.
|
// NORMAL sync mode: safe with WAL, reduces write lock hold time.
|
||||||
sqlite.pragma("synchronous = NORMAL");
|
sqlite.pragma("synchronous = NORMAL");
|
||||||
}
|
}
|
||||||
|
|
||||||
// No busy_timeout pragma: better-sqlite3 already arms
|
// Wait up to 5s on SQLITE_BUSY instead of failing — prevents audit log
|
||||||
// sqlite3_busy_timeout(db, 5000) via its default `timeout` option
|
// retry loops that accumulate memory.
|
||||||
// (lib/database.js), so an explicit pragma is redundant.
|
sqlite.pragma("busy_timeout = 5000");
|
||||||
|
|
||||||
// Intentionally NOT setting cache_size or mmap_size: a large page cache plus
|
// 64 MB page cache (default 2 MB) — reduces I/O round-trips on large
|
||||||
// a multi-hundred-MB mmap region inflate RSS and cause page-cache thrashing
|
// TraefikConfigManager JOINs that block the event loop.
|
||||||
// on small (~1 GB) instances. Leave SQLite on its conservative defaults.
|
sqlite.pragma("cache_size = -65536");
|
||||||
|
|
||||||
// Intentionally NOT wrapping prepare()/statements: better-sqlite3 finalizes
|
// 256 MB memory-mapped I/O — OS serves reads from page cache directly,
|
||||||
// sqlite3_stmt in the Statement destructor at GC, and drizzle-orm prepares a
|
// reducing event-loop blocking.
|
||||||
// fresh statement per query (no statement cache), so statements cannot
|
sqlite.pragma("mmap_size = 268435456");
|
||||||
// accumulate. better-sqlite3 11.x exposes no Statement.finalize() at all.
|
|
||||||
|
// Wrap prepare() so every drizzle-orm statement is auto-finalized after
|
||||||
|
// first use, preventing sqlite3_stmt accumulation between GC cycles.
|
||||||
|
const originalPrepare = sqlite.prepare.bind(sqlite);
|
||||||
|
(sqlite as any).prepare = function autoFinalizePrepare(source: string) {
|
||||||
|
return autoFinalizeStatement(originalPrepare(source));
|
||||||
|
};
|
||||||
|
|
||||||
return DrizzleSqlite(sqlite, {
|
return DrizzleSqlite(sqlite, {
|
||||||
schema
|
schema
|
||||||
|
|||||||
@@ -12,7 +12,6 @@ import {
|
|||||||
clients,
|
clients,
|
||||||
domains,
|
domains,
|
||||||
exitNodes,
|
exitNodes,
|
||||||
labels,
|
|
||||||
orgs,
|
orgs,
|
||||||
resources,
|
resources,
|
||||||
roles,
|
roles,
|
||||||
@@ -22,6 +21,9 @@ import {
|
|||||||
targetHealthCheck,
|
targetHealthCheck,
|
||||||
users
|
users
|
||||||
} from "./schema";
|
} from "./schema";
|
||||||
|
import { serial, varchar } from "drizzle-orm/mysql-core";
|
||||||
|
import { pgTable } from "drizzle-orm/pg-core";
|
||||||
|
import { bigint } from "zod";
|
||||||
|
|
||||||
export const certificates = sqliteTable("certificates", {
|
export const certificates = sqliteTable("certificates", {
|
||||||
certId: integer("certId").primaryKey({ autoIncrement: true }),
|
certId: integer("certId").primaryKey({ autoIncrement: true }),
|
||||||
@@ -193,44 +195,6 @@ export const remoteExitNodes = sqliteTable("remoteExitNode", {
|
|||||||
})
|
})
|
||||||
});
|
});
|
||||||
|
|
||||||
export const remoteExitNodeResources = sqliteTable("remoteExitNodeResources", {
|
|
||||||
remoteExitNodeResourceId: integer("remoteExitNodeResourceId").primaryKey({
|
|
||||||
autoIncrement: true
|
|
||||||
}),
|
|
||||||
remoteExitNodeId: text("remoteExitNodeId")
|
|
||||||
.notNull()
|
|
||||||
.references(() => remoteExitNodes.remoteExitNodeId, {
|
|
||||||
onDelete: "cascade"
|
|
||||||
}),
|
|
||||||
destination: text("destination").notNull() // a cidr range
|
|
||||||
});
|
|
||||||
|
|
||||||
export const remoteExitNodePreferenceLabels = sqliteTable(
|
|
||||||
// this controls what sites are enforced to connect to this node
|
|
||||||
"remoteExitNodePreferenceLabels",
|
|
||||||
{
|
|
||||||
remoteExitNodePreferenceLabelId: integer(
|
|
||||||
"remoteExitNodePreferenceLabelId"
|
|
||||||
).primaryKey({ autoIncrement: true }),
|
|
||||||
remoteExitNodeId: text("remoteExitNodeId")
|
|
||||||
.references(() => remoteExitNodes.remoteExitNodeId, {
|
|
||||||
onDelete: "cascade"
|
|
||||||
})
|
|
||||||
.notNull(),
|
|
||||||
labelId: integer("labelId")
|
|
||||||
.references(() => labels.labelId, {
|
|
||||||
onDelete: "cascade"
|
|
||||||
})
|
|
||||||
.notNull()
|
|
||||||
},
|
|
||||||
(t) => [
|
|
||||||
uniqueIndex("remote_exit_node_preference_label_uniq").on(
|
|
||||||
t.remoteExitNodeId,
|
|
||||||
t.labelId
|
|
||||||
)
|
|
||||||
]
|
|
||||||
);
|
|
||||||
|
|
||||||
export const remoteExitNodeSessions = sqliteTable("remoteExitNodeSession", {
|
export const remoteExitNodeSessions = sqliteTable("remoteExitNodeSession", {
|
||||||
sessionId: text("id").primaryKey(),
|
sessionId: text("id").primaryKey(),
|
||||||
remoteExitNodeId: text("remoteExitNodeId")
|
remoteExitNodeId: text("remoteExitNodeId")
|
||||||
@@ -624,6 +588,26 @@ export const trialNotifications = sqliteTable("trialNotifications", {
|
|||||||
sentAt: integer("sentAt").notNull()
|
sentAt: integer("sentAt").notNull()
|
||||||
});
|
});
|
||||||
|
|
||||||
|
export const browserGatewayTarget = sqliteTable("browserGatewayTarget", {
|
||||||
|
browserGatewayTargetId: integer("browserGatewayTargetId").primaryKey({
|
||||||
|
autoIncrement: true
|
||||||
|
}),
|
||||||
|
resourceId: integer("resourceId")
|
||||||
|
.references(() => resources.resourceId, {
|
||||||
|
onDelete: "cascade"
|
||||||
|
})
|
||||||
|
.notNull(),
|
||||||
|
siteId: integer("siteId")
|
||||||
|
.references(() => sites.siteId, {
|
||||||
|
onDelete: "cascade"
|
||||||
|
})
|
||||||
|
.notNull(),
|
||||||
|
authToken: text("authToken").notNull(),
|
||||||
|
type: text("type").notNull(), // "ssh", "rdp", "vnc"
|
||||||
|
destination: text("destination").notNull(),
|
||||||
|
destinationPort: integer("destinationPort").notNull()
|
||||||
|
});
|
||||||
|
|
||||||
export type Approval = InferSelectModel<typeof approvals>;
|
export type Approval = InferSelectModel<typeof approvals>;
|
||||||
export type Limit = InferSelectModel<typeof limits>;
|
export type Limit = InferSelectModel<typeof limits>;
|
||||||
export type Account = InferSelectModel<typeof account>;
|
export type Account = InferSelectModel<typeof account>;
|
||||||
@@ -663,3 +647,6 @@ export type AlertEmailAction = InferSelectModel<typeof alertEmailActions>;
|
|||||||
export type AlertEmailRecipient = InferSelectModel<typeof alertEmailRecipients>;
|
export type AlertEmailRecipient = InferSelectModel<typeof alertEmailRecipients>;
|
||||||
export type AlertWebhookAction = InferSelectModel<typeof alertWebhookActions>;
|
export type AlertWebhookAction = InferSelectModel<typeof alertWebhookActions>;
|
||||||
export type TrialNotification = InferSelectModel<typeof trialNotifications>;
|
export type TrialNotification = InferSelectModel<typeof trialNotifications>;
|
||||||
|
export type BrowserGatewayTarget = InferSelectModel<
|
||||||
|
typeof browserGatewayTarget
|
||||||
|
>;
|
||||||
|
|||||||
@@ -20,10 +20,8 @@ export const domains = sqliteTable("domains", {
|
|||||||
failed: integer("failed", { mode: "boolean" }).notNull().default(false),
|
failed: integer("failed", { mode: "boolean" }).notNull().default(false),
|
||||||
tries: integer("tries").notNull().default(0),
|
tries: integer("tries").notNull().default(0),
|
||||||
certResolver: text("certResolver"),
|
certResolver: text("certResolver"),
|
||||||
customCertResolver: text("customCertResolver"),
|
|
||||||
preferWildcardCert: integer("preferWildcardCert", { mode: "boolean" }),
|
preferWildcardCert: integer("preferWildcardCert", { mode: "boolean" }),
|
||||||
errorMessage: text("errorMessage"),
|
errorMessage: text("errorMessage")
|
||||||
lastCheckedAt: integer("lastCheckedAt")
|
|
||||||
});
|
});
|
||||||
|
|
||||||
export const dnsRecords = sqliteTable("dnsRecords", {
|
export const dnsRecords = sqliteTable("dnsRecords", {
|
||||||
@@ -118,7 +116,6 @@ export const sites = sqliteTable("sites", {
|
|||||||
// exit node stuff that is how to connect to the site when it has a wg server
|
// exit node stuff that is how to connect to the site when it has a wg server
|
||||||
address: text("address"), // this is the address of the wireguard interface in newt
|
address: text("address"), // this is the address of the wireguard interface in newt
|
||||||
endpoint: text("endpoint"), // this is how to reach gerbil externally - gets put into the wireguard config
|
endpoint: text("endpoint"), // this is how to reach gerbil externally - gets put into the wireguard config
|
||||||
localEndpoints: text("localEndpoints"), // JSON encoded list of string ips on the local machine to try to connect to
|
|
||||||
publicKey: text("publicKey"), // TODO: Fix typo in publicKey
|
publicKey: text("publicKey"), // TODO: Fix typo in publicKey
|
||||||
lastHolePunch: integer("lastHolePunch"),
|
lastHolePunch: integer("lastHolePunch"),
|
||||||
listenPort: integer("listenPort"),
|
listenPort: integer("listenPort"),
|
||||||
@@ -168,12 +165,14 @@ export const resources = sqliteTable("resources", {
|
|||||||
blockAccess: integer("blockAccess", { mode: "boolean" })
|
blockAccess: integer("blockAccess", { mode: "boolean" })
|
||||||
.notNull()
|
.notNull()
|
||||||
.default(false),
|
.default(false),
|
||||||
|
sso: integer("sso", { mode: "boolean" }).notNull().default(true),
|
||||||
proxyPort: integer("proxyPort"),
|
proxyPort: integer("proxyPort"),
|
||||||
sso: integer("sso", { mode: "boolean" }),
|
emailWhitelistEnabled: integer("emailWhitelistEnabled", { mode: "boolean" })
|
||||||
emailWhitelistEnabled: integer("emailWhitelistEnabled", {
|
.notNull()
|
||||||
mode: "boolean"
|
.default(false),
|
||||||
}),
|
applyRules: integer("applyRules", { mode: "boolean" })
|
||||||
applyRules: integer("applyRules", { mode: "boolean" }),
|
.notNull()
|
||||||
|
.default(false),
|
||||||
enabled: integer("enabled", { mode: "boolean" }).notNull().default(true),
|
enabled: integer("enabled", { mode: "boolean" }).notNull().default(true),
|
||||||
stickySession: integer("stickySession", { mode: "boolean" })
|
stickySession: integer("stickySession", { mode: "boolean" })
|
||||||
.notNull()
|
.notNull()
|
||||||
@@ -210,8 +209,7 @@ export const resources = sqliteTable("resources", {
|
|||||||
authDaemonMode: text("authDaemonMode")
|
authDaemonMode: text("authDaemonMode")
|
||||||
.$type<"site" | "remote" | "native">()
|
.$type<"site" | "remote" | "native">()
|
||||||
.default("site"),
|
.default("site"),
|
||||||
authDaemonPort: integer("authDaemonPort").default(22123),
|
authDaemonPort: integer("authDaemonPort").default(22123)
|
||||||
status: text("status").$type<"pending" | "approved">().default("approved")
|
|
||||||
});
|
});
|
||||||
|
|
||||||
export const labels = sqliteTable("labels", {
|
export const labels = sqliteTable("labels", {
|
||||||
@@ -225,23 +223,6 @@ export const labels = sqliteTable("labels", {
|
|||||||
.notNull()
|
.notNull()
|
||||||
});
|
});
|
||||||
|
|
||||||
export const launcherViews = sqliteTable("launcherViews", {
|
|
||||||
viewId: integer("viewId").primaryKey({ autoIncrement: true }),
|
|
||||||
orgId: text("orgId")
|
|
||||||
.notNull()
|
|
||||||
.references(() => orgs.orgId, { onDelete: "cascade" }),
|
|
||||||
userId: text("userId").references(() => users.userId, {
|
|
||||||
onDelete: "cascade"
|
|
||||||
}),
|
|
||||||
name: text("name").notNull(),
|
|
||||||
config: text("config").notNull(),
|
|
||||||
isDefault: integer("isDefault", { mode: "boolean" })
|
|
||||||
.notNull()
|
|
||||||
.default(false),
|
|
||||||
createdAt: text("createdAt").notNull(),
|
|
||||||
updatedAt: text("updatedAt").notNull()
|
|
||||||
});
|
|
||||||
|
|
||||||
export const siteLabels = sqliteTable(
|
export const siteLabels = sqliteTable(
|
||||||
"siteLabels",
|
"siteLabels",
|
||||||
{
|
{
|
||||||
@@ -341,12 +322,7 @@ export const targets = sqliteTable("targets", {
|
|||||||
pathMatchType: text("pathMatchType"), // exact, prefix, regex
|
pathMatchType: text("pathMatchType"), // exact, prefix, regex
|
||||||
rewritePath: text("rewritePath"), // if set, rewrites the path to this value before sending to the target
|
rewritePath: text("rewritePath"), // if set, rewrites the path to this value before sending to the target
|
||||||
rewritePathType: text("rewritePathType"), // exact, prefix, regex, stripPrefix
|
rewritePathType: text("rewritePathType"), // exact, prefix, regex, stripPrefix
|
||||||
priority: integer("priority").notNull().default(100),
|
priority: integer("priority").notNull().default(100)
|
||||||
mode: text("mode")
|
|
||||||
.$type<"http" | "tcp" | "udp" | "ssh" | "rdp" | "vnc">()
|
|
||||||
.notNull()
|
|
||||||
.default("http"),
|
|
||||||
authToken: text("authToken")
|
|
||||||
});
|
});
|
||||||
|
|
||||||
export const targetHealthCheck = sqliteTable("targetHealthCheck", {
|
export const targetHealthCheck = sqliteTable("targetHealthCheck", {
|
||||||
@@ -449,8 +425,7 @@ export const siteResources = sqliteTable("siteResources", {
|
|||||||
onDelete: "set null"
|
onDelete: "set null"
|
||||||
}),
|
}),
|
||||||
subdomain: text("subdomain"),
|
subdomain: text("subdomain"),
|
||||||
fullDomain: text("fullDomain"),
|
fullDomain: text("fullDomain")
|
||||||
status: text("status").$type<"pending" | "approved">().default("approved")
|
|
||||||
});
|
});
|
||||||
|
|
||||||
export const networks = sqliteTable("networks", {
|
export const networks = sqliteTable("networks", {
|
||||||
@@ -1126,18 +1101,12 @@ export const resourceAccessToken = sqliteTable("resourceAccessToken", {
|
|||||||
resourceId: integer("resourceId")
|
resourceId: integer("resourceId")
|
||||||
.notNull()
|
.notNull()
|
||||||
.references(() => resources.resourceId, { onDelete: "cascade" }),
|
.references(() => resources.resourceId, { onDelete: "cascade" }),
|
||||||
userId: text("userId").references(() => users.userId, {
|
|
||||||
onDelete: "cascade"
|
|
||||||
}),
|
|
||||||
path: text("path"),
|
path: text("path"),
|
||||||
tokenHash: text("tokenHash").notNull(),
|
tokenHash: text("tokenHash").notNull(),
|
||||||
sessionLength: integer("sessionLength").notNull(),
|
sessionLength: integer("sessionLength").notNull(),
|
||||||
expiresAt: integer("expiresAt"),
|
expiresAt: integer("expiresAt"),
|
||||||
title: text("title"),
|
title: text("title"),
|
||||||
description: text("description"),
|
description: text("description"),
|
||||||
persistSession: integer("persistSession", { mode: "boolean" })
|
|
||||||
.notNull()
|
|
||||||
.default(false),
|
|
||||||
createdAt: integer("createdAt").notNull()
|
createdAt: integer("createdAt").notNull()
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -1233,17 +1202,7 @@ export const resourceRules = sqliteTable("resourceRules", {
|
|||||||
enabled: integer("enabled", { mode: "boolean" }).notNull().default(true),
|
enabled: integer("enabled", { mode: "boolean" }).notNull().default(true),
|
||||||
priority: integer("priority").notNull(),
|
priority: integer("priority").notNull(),
|
||||||
action: text("action").notNull(), // ACCEPT, DROP, PASS
|
action: text("action").notNull(), // ACCEPT, DROP, PASS
|
||||||
match: text("match")
|
match: text("match").notNull(), // CIDR, PATH, IP
|
||||||
.$type<
|
|
||||||
| "CIDR"
|
|
||||||
| "PATH"
|
|
||||||
| "IP"
|
|
||||||
| "COUNTRY"
|
|
||||||
| "COUNTRY_IS_NOT"
|
|
||||||
| "ASN"
|
|
||||||
| "REGION"
|
|
||||||
>()
|
|
||||||
.notNull(), // CIDR, PATH, IP
|
|
||||||
value: text("value").notNull()
|
value: text("value").notNull()
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -1289,17 +1248,7 @@ export const resourcePolicyRules = sqliteTable("resourcePolicyRules", {
|
|||||||
enabled: integer("enabled", { mode: "boolean" }).notNull().default(true),
|
enabled: integer("enabled", { mode: "boolean" }).notNull().default(true),
|
||||||
priority: integer("priority").notNull(),
|
priority: integer("priority").notNull(),
|
||||||
action: text("action").$type<"ACCEPT" | "DROP" | "PASS">().notNull(),
|
action: text("action").$type<"ACCEPT" | "DROP" | "PASS">().notNull(),
|
||||||
match: text("match")
|
match: text("match").$type<"CIDR" | "PATH" | "IP">().notNull(),
|
||||||
.$type<
|
|
||||||
| "CIDR"
|
|
||||||
| "PATH"
|
|
||||||
| "IP"
|
|
||||||
| "COUNTRY"
|
|
||||||
| "COUNTRY_IS_NOT"
|
|
||||||
| "ASN"
|
|
||||||
| "REGION"
|
|
||||||
>()
|
|
||||||
.notNull(),
|
|
||||||
value: text("value").notNull()
|
value: text("value").notNull()
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -1595,7 +1544,6 @@ export type RoundTripMessageTracker = InferSelectModel<
|
|||||||
>;
|
>;
|
||||||
export type StatusHistory = InferSelectModel<typeof statusHistory>;
|
export type StatusHistory = InferSelectModel<typeof statusHistory>;
|
||||||
export type Label = InferSelectModel<typeof labels>;
|
export type Label = InferSelectModel<typeof labels>;
|
||||||
export type LauncherView = InferSelectModel<typeof launcherViews>;
|
|
||||||
export type ResourcePolicy = InferSelectModel<typeof resourcePolicies>;
|
export type ResourcePolicy = InferSelectModel<typeof resourcePolicies>;
|
||||||
export type ResourcePolicyPincode = InferSelectModel<
|
export type ResourcePolicyPincode = InferSelectModel<
|
||||||
typeof resourcePolicyPincode
|
typeof resourcePolicyPincode
|
||||||
|
|||||||
@@ -24,7 +24,6 @@ import license from "#dynamic/license/license";
|
|||||||
import { initLogCleanupInterval } from "@server/lib/cleanupLogs";
|
import { initLogCleanupInterval } from "@server/lib/cleanupLogs";
|
||||||
import { initAcmeCertSync } from "#dynamic/lib/acmeCertSync";
|
import { initAcmeCertSync } from "#dynamic/lib/acmeCertSync";
|
||||||
import { fetchServerIp } from "@server/lib/serverIpService";
|
import { fetchServerIp } from "@server/lib/serverIpService";
|
||||||
import { startRebuildQueueProcessor } from "@server/lib/rebuildClientAssociations";
|
|
||||||
|
|
||||||
async function startServers() {
|
async function startServers() {
|
||||||
await setHostMeta();
|
await setHostMeta();
|
||||||
@@ -42,7 +41,6 @@ async function startServers() {
|
|||||||
|
|
||||||
initLogCleanupInterval();
|
initLogCleanupInterval();
|
||||||
initAcmeCertSync();
|
initAcmeCertSync();
|
||||||
startRebuildQueueProcessor();
|
|
||||||
|
|
||||||
// Start all servers
|
// Start all servers
|
||||||
const apiServer = createApiServer();
|
const apiServer = createApiServer();
|
||||||
|
|||||||
@@ -157,9 +157,7 @@ function getOpenApiDocumentation() {
|
|||||||
content: {
|
content: {
|
||||||
"application/json": {
|
"application/json": {
|
||||||
schema: z.object({
|
schema: z.object({
|
||||||
data: z
|
data: z.unknown().nullable(),
|
||||||
.record(z.string(), z.any())
|
|
||||||
.nullable(),
|
|
||||||
success: z.boolean(),
|
success: z.boolean(),
|
||||||
error: z.boolean(),
|
error: z.boolean(),
|
||||||
message: z.string(),
|
message: z.string(),
|
||||||
|
|||||||
@@ -1,39 +1,28 @@
|
|||||||
export enum LimitId {
|
export enum FeatureId {
|
||||||
USERS = "users",
|
USERS = "users",
|
||||||
SITES = "sites",
|
SITES = "sites",
|
||||||
EGRESS_DATA_MB = "egressDataMb",
|
EGRESS_DATA_MB = "egressDataMb",
|
||||||
DOMAINS = "domains",
|
DOMAINS = "domains",
|
||||||
REMOTE_EXIT_NODES = "remoteExitNodes",
|
REMOTE_EXIT_NODES = "remoteExitNodes",
|
||||||
ORGANIZATIONS = "organizations",
|
ORGINIZATIONS = "organizations",
|
||||||
PUBLIC_RESOURCES = "publicResources",
|
|
||||||
PRIVATE_RESOURCES = "privateResources",
|
|
||||||
MACHINE_CLIENTS = "machineClients",
|
|
||||||
TIER1 = "tier1"
|
TIER1 = "tier1"
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getFeatureDisplayName(
|
export async function getFeatureDisplayName(featureId: FeatureId): Promise<string> {
|
||||||
featureId: LimitId
|
|
||||||
): Promise<string> {
|
|
||||||
switch (featureId) {
|
switch (featureId) {
|
||||||
case LimitId.USERS:
|
case FeatureId.USERS:
|
||||||
return "Users";
|
return "Users";
|
||||||
case LimitId.SITES:
|
case FeatureId.SITES:
|
||||||
return "Sites";
|
return "Sites";
|
||||||
case LimitId.EGRESS_DATA_MB:
|
case FeatureId.EGRESS_DATA_MB:
|
||||||
return "Egress Data (MB)";
|
return "Egress Data (MB)";
|
||||||
case LimitId.DOMAINS:
|
case FeatureId.DOMAINS:
|
||||||
return "Domains";
|
return "Domains";
|
||||||
case LimitId.REMOTE_EXIT_NODES:
|
case FeatureId.REMOTE_EXIT_NODES:
|
||||||
return "Remote Exit Nodes";
|
return "Remote Exit Nodes";
|
||||||
case LimitId.ORGANIZATIONS:
|
case FeatureId.ORGINIZATIONS:
|
||||||
return "Organizations";
|
return "Organizations";
|
||||||
case LimitId.PUBLIC_RESOURCES:
|
case FeatureId.TIER1:
|
||||||
return "Public Resources";
|
|
||||||
case LimitId.PRIVATE_RESOURCES:
|
|
||||||
return "Private Resources";
|
|
||||||
case LimitId.MACHINE_CLIENTS:
|
|
||||||
return "Machine Clients";
|
|
||||||
case LimitId.TIER1:
|
|
||||||
return "Home Lab";
|
return "Home Lab";
|
||||||
default:
|
default:
|
||||||
return featureId;
|
return featureId;
|
||||||
@@ -41,16 +30,15 @@ export async function getFeatureDisplayName(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// this is from the old system
|
// this is from the old system
|
||||||
export const FeatureMeterIds: Partial<Record<LimitId, string>> = {
|
export const FeatureMeterIds: Partial<Record<FeatureId, string>> = { // right now we are not charging for any data
|
||||||
// right now we are not charging for any data
|
|
||||||
// [FeatureId.EGRESS_DATA_MB]: "mtr_61Srreh9eWrExDSCe41D3Ee2Ir7Wm5YW"
|
// [FeatureId.EGRESS_DATA_MB]: "mtr_61Srreh9eWrExDSCe41D3Ee2Ir7Wm5YW"
|
||||||
};
|
};
|
||||||
|
|
||||||
export const FeatureMeterIdsSandbox: Partial<Record<LimitId, string>> = {
|
export const FeatureMeterIdsSandbox: Partial<Record<FeatureId, string>> = {
|
||||||
// [FeatureId.EGRESS_DATA_MB]: "mtr_test_61Snh2a2m6qome5Kv41DCpkOb237B3dQ"
|
// [FeatureId.EGRESS_DATA_MB]: "mtr_test_61Snh2a2m6qome5Kv41DCpkOb237B3dQ"
|
||||||
};
|
};
|
||||||
|
|
||||||
export function getFeatureMeterId(featureId: LimitId): string | undefined {
|
export function getFeatureMeterId(featureId: FeatureId): string | undefined {
|
||||||
if (
|
if (
|
||||||
process.env.ENVIRONMENT == "prod" &&
|
process.env.ENVIRONMENT == "prod" &&
|
||||||
process.env.SANDBOX_MODE !== "true"
|
process.env.SANDBOX_MODE !== "true"
|
||||||
@@ -61,20 +49,22 @@ export function getFeatureMeterId(featureId: LimitId): string | undefined {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getFeatureIdByMetricId(metricId: string): LimitId | undefined {
|
export function getFeatureIdByMetricId(
|
||||||
return (Object.entries(FeatureMeterIds) as [LimitId, string][]).find(
|
metricId: string
|
||||||
|
): FeatureId | undefined {
|
||||||
|
return (Object.entries(FeatureMeterIds) as [FeatureId, string][]).find(
|
||||||
([_, v]) => v === metricId
|
([_, v]) => v === metricId
|
||||||
)?.[0];
|
)?.[0];
|
||||||
}
|
}
|
||||||
|
|
||||||
export type FeaturePriceSet = Partial<Record<LimitId, string>>;
|
export type FeaturePriceSet = Partial<Record<FeatureId, string>>;
|
||||||
|
|
||||||
export const tier1FeaturePriceSet: FeaturePriceSet = {
|
export const tier1FeaturePriceSet: FeaturePriceSet = {
|
||||||
[LimitId.TIER1]: "price_1SzVE3D3Ee2Ir7Wm6wT5Dl3G"
|
[FeatureId.TIER1]: "price_1SzVE3D3Ee2Ir7Wm6wT5Dl3G"
|
||||||
};
|
};
|
||||||
|
|
||||||
export const tier1FeaturePriceSetSandbox: FeaturePriceSet = {
|
export const tier1FeaturePriceSetSandbox: FeaturePriceSet = {
|
||||||
[LimitId.TIER1]: "price_1SxgpPDCpkOb237Bfo4rIsoT"
|
[FeatureId.TIER1]: "price_1SxgpPDCpkOb237Bfo4rIsoT"
|
||||||
};
|
};
|
||||||
|
|
||||||
export function getTier1FeaturePriceSet(): FeaturePriceSet {
|
export function getTier1FeaturePriceSet(): FeaturePriceSet {
|
||||||
@@ -89,11 +79,11 @@ export function getTier1FeaturePriceSet(): FeaturePriceSet {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const tier2FeaturePriceSet: FeaturePriceSet = {
|
export const tier2FeaturePriceSet: FeaturePriceSet = {
|
||||||
[LimitId.USERS]: "price_1SzVCcD3Ee2Ir7Wmn6U3KvPN"
|
[FeatureId.USERS]: "price_1SzVCcD3Ee2Ir7Wmn6U3KvPN"
|
||||||
};
|
};
|
||||||
|
|
||||||
export const tier2FeaturePriceSetSandbox: FeaturePriceSet = {
|
export const tier2FeaturePriceSetSandbox: FeaturePriceSet = {
|
||||||
[LimitId.USERS]: "price_1SxaEHDCpkOb237BD9lBkPiR"
|
[FeatureId.USERS]: "price_1SxaEHDCpkOb237BD9lBkPiR"
|
||||||
};
|
};
|
||||||
|
|
||||||
export function getTier2FeaturePriceSet(): FeaturePriceSet {
|
export function getTier2FeaturePriceSet(): FeaturePriceSet {
|
||||||
@@ -108,11 +98,11 @@ export function getTier2FeaturePriceSet(): FeaturePriceSet {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const tier3FeaturePriceSet: FeaturePriceSet = {
|
export const tier3FeaturePriceSet: FeaturePriceSet = {
|
||||||
[LimitId.USERS]: "price_1SzVDKD3Ee2Ir7WmPtOKNusv"
|
[FeatureId.USERS]: "price_1SzVDKD3Ee2Ir7WmPtOKNusv"
|
||||||
};
|
};
|
||||||
|
|
||||||
export const tier3FeaturePriceSetSandbox: FeaturePriceSet = {
|
export const tier3FeaturePriceSetSandbox: FeaturePriceSet = {
|
||||||
[LimitId.USERS]: "price_1SxaEODCpkOb237BiXdCBSfs"
|
[FeatureId.USERS]: "price_1SxaEODCpkOb237BiXdCBSfs"
|
||||||
};
|
};
|
||||||
|
|
||||||
export function getTier3FeaturePriceSet(): FeaturePriceSet {
|
export function getTier3FeaturePriceSet(): FeaturePriceSet {
|
||||||
@@ -126,7 +116,7 @@ export function getTier3FeaturePriceSet(): FeaturePriceSet {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getFeatureIdByPriceId(priceId: string): LimitId | undefined {
|
export function getFeatureIdByPriceId(priceId: string): FeatureId | undefined {
|
||||||
// Check all feature price sets
|
// Check all feature price sets
|
||||||
const allPriceSets = [
|
const allPriceSets = [
|
||||||
getTier1FeaturePriceSet(),
|
getTier1FeaturePriceSet(),
|
||||||
@@ -135,7 +125,7 @@ export function getFeatureIdByPriceId(priceId: string): LimitId | undefined {
|
|||||||
];
|
];
|
||||||
|
|
||||||
for (const priceSet of allPriceSets) {
|
for (const priceSet of allPriceSets) {
|
||||||
const entry = (Object.entries(priceSet) as [LimitId, string][]).find(
|
const entry = (Object.entries(priceSet) as [FeatureId, string][]).find(
|
||||||
([_, price]) => price === priceId
|
([_, price]) => price === priceId
|
||||||
);
|
);
|
||||||
if (entry) {
|
if (entry) {
|
||||||
|
|||||||
@@ -1,19 +1,19 @@
|
|||||||
import Stripe from "stripe";
|
import Stripe from "stripe";
|
||||||
import { LimitId, FeaturePriceSet } from "./features";
|
import { FeatureId, FeaturePriceSet } from "./features";
|
||||||
import { usageService } from "./usageService";
|
import { usageService } from "./usageService";
|
||||||
|
|
||||||
export async function getLineItems(
|
export async function getLineItems(
|
||||||
featurePriceSet: FeaturePriceSet,
|
featurePriceSet: FeaturePriceSet,
|
||||||
orgId: string
|
orgId: string,
|
||||||
): Promise<Stripe.Checkout.SessionCreateParams.LineItem[]> {
|
): Promise<Stripe.Checkout.SessionCreateParams.LineItem[]> {
|
||||||
const users = await usageService.getUsage(orgId, LimitId.USERS);
|
const users = await usageService.getUsage(orgId, FeatureId.USERS);
|
||||||
|
|
||||||
return Object.entries(featurePriceSet).map(([featureId, priceId]) => {
|
return Object.entries(featurePriceSet).map(([featureId, priceId]) => {
|
||||||
let quantity: number | undefined;
|
let quantity: number | undefined;
|
||||||
|
|
||||||
if (featureId === LimitId.USERS) {
|
if (featureId === FeatureId.USERS) {
|
||||||
quantity = users?.instantaneousValue || 1;
|
quantity = users?.instantaneousValue || 1;
|
||||||
} else if (featureId === LimitId.TIER1) {
|
} else if (featureId === FeatureId.TIER1) {
|
||||||
quantity = 1;
|
quantity = 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,82 +1,70 @@
|
|||||||
import { LimitId } from "./features";
|
import { FeatureId } from "./features";
|
||||||
|
|
||||||
export type LimitSet = Partial<{
|
export type LimitSet = Partial<{
|
||||||
[key in LimitId]: {
|
[key in FeatureId]: {
|
||||||
value: number | null; // null indicates no limit
|
value: number | null; // null indicates no limit
|
||||||
description?: string;
|
description?: string;
|
||||||
};
|
};
|
||||||
}>;
|
}>;
|
||||||
|
|
||||||
export const freeLimitSet: LimitSet = {
|
export const freeLimitSet: LimitSet = {
|
||||||
[LimitId.SITES]: { value: 5, description: "Basic limit" },
|
[FeatureId.SITES]: { value: 5, description: "Basic limit" },
|
||||||
[LimitId.USERS]: { value: 5, description: "Basic limit" },
|
[FeatureId.USERS]: { value: 5, description: "Basic limit" },
|
||||||
[LimitId.DOMAINS]: { value: 5, description: "Basic limit" },
|
[FeatureId.DOMAINS]: { value: 5, description: "Basic limit" },
|
||||||
[LimitId.REMOTE_EXIT_NODES]: { value: 1, description: "Basic limit" },
|
[FeatureId.REMOTE_EXIT_NODES]: { value: 1, description: "Basic limit" },
|
||||||
[LimitId.ORGANIZATIONS]: { value: 1, description: "Basic limit" },
|
[FeatureId.ORGINIZATIONS]: { value: 1, description: "Basic limit" },
|
||||||
[LimitId.PUBLIC_RESOURCES]: { value: 15, description: "Basic limit" },
|
|
||||||
[LimitId.PRIVATE_RESOURCES]: { value: 15, description: "Basic limit" },
|
|
||||||
[LimitId.MACHINE_CLIENTS]: { value: 5, description: "Basic limit" }
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export const tier1LimitSet: LimitSet = {
|
export const tier1LimitSet: LimitSet = {
|
||||||
[LimitId.USERS]: { value: 7, description: "Home limit" },
|
[FeatureId.USERS]: { value: 7, description: "Home limit" },
|
||||||
[LimitId.SITES]: { value: 10, description: "Home limit" },
|
[FeatureId.SITES]: { value: 10, description: "Home limit" },
|
||||||
[LimitId.DOMAINS]: { value: 10, description: "Home limit" },
|
[FeatureId.DOMAINS]: { value: 10, description: "Home limit" },
|
||||||
[LimitId.REMOTE_EXIT_NODES]: { value: 1, description: "Home limit" },
|
[FeatureId.REMOTE_EXIT_NODES]: { value: 1, description: "Home limit" },
|
||||||
[LimitId.ORGANIZATIONS]: { value: 1, description: "Home limit" },
|
[FeatureId.ORGINIZATIONS]: { value: 1, description: "Home limit" },
|
||||||
[LimitId.PUBLIC_RESOURCES]: { value: 30, description: "Home limit" },
|
|
||||||
[LimitId.PRIVATE_RESOURCES]: { value: 30, description: "Home limit" },
|
|
||||||
[LimitId.MACHINE_CLIENTS]: { value: 10, description: "Home limit" }
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export const tier2LimitSet: LimitSet = {
|
export const tier2LimitSet: LimitSet = {
|
||||||
[LimitId.USERS]: {
|
[FeatureId.USERS]: {
|
||||||
value: 50,
|
value: 50,
|
||||||
description: "Team limit"
|
description: "Team limit"
|
||||||
},
|
},
|
||||||
[LimitId.SITES]: {
|
[FeatureId.SITES]: {
|
||||||
value: 50,
|
value: 50,
|
||||||
description: "Team limit"
|
description: "Team limit"
|
||||||
},
|
},
|
||||||
[LimitId.DOMAINS]: {
|
[FeatureId.DOMAINS]: {
|
||||||
value: 50,
|
value: 50,
|
||||||
description: "Team limit"
|
description: "Team limit"
|
||||||
},
|
},
|
||||||
[LimitId.REMOTE_EXIT_NODES]: {
|
[FeatureId.REMOTE_EXIT_NODES]: {
|
||||||
value: 3,
|
value: 3,
|
||||||
description: "Team limit"
|
description: "Team limit"
|
||||||
},
|
},
|
||||||
[LimitId.ORGANIZATIONS]: {
|
[FeatureId.ORGINIZATIONS]: {
|
||||||
value: 1,
|
value: 1,
|
||||||
description: "Team limit"
|
description: "Team limit"
|
||||||
},
|
}
|
||||||
[LimitId.PUBLIC_RESOURCES]: { value: 150, description: "Team limit" },
|
|
||||||
[LimitId.PRIVATE_RESOURCES]: { value: 150, description: "Team limit" },
|
|
||||||
[LimitId.MACHINE_CLIENTS]: { value: 25, description: "Team limit" }
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export const tier3LimitSet: LimitSet = {
|
export const tier3LimitSet: LimitSet = {
|
||||||
[LimitId.USERS]: {
|
[FeatureId.USERS]: {
|
||||||
value: 250,
|
value: 250,
|
||||||
description: "Business limit"
|
description: "Business limit"
|
||||||
},
|
},
|
||||||
[LimitId.SITES]: {
|
[FeatureId.SITES]: {
|
||||||
value: 250,
|
value: 250,
|
||||||
description: "Business limit"
|
description: "Business limit"
|
||||||
},
|
},
|
||||||
[LimitId.DOMAINS]: {
|
[FeatureId.DOMAINS]: {
|
||||||
value: 100,
|
value: 100,
|
||||||
description: "Business limit"
|
description: "Business limit"
|
||||||
},
|
},
|
||||||
[LimitId.REMOTE_EXIT_NODES]: {
|
[FeatureId.REMOTE_EXIT_NODES]: {
|
||||||
value: 20,
|
value: 20,
|
||||||
description: "Business limit"
|
description: "Business limit"
|
||||||
},
|
},
|
||||||
[LimitId.ORGANIZATIONS]: {
|
[FeatureId.ORGINIZATIONS]: {
|
||||||
value: 5,
|
value: 5,
|
||||||
description: "Business limit"
|
description: "Business limit"
|
||||||
},
|
},
|
||||||
[LimitId.PUBLIC_RESOURCES]: { value: 750, description: "Business limit" },
|
|
||||||
[LimitId.PRIVATE_RESOURCES]: { value: 750, description: "Business limit" },
|
|
||||||
[LimitId.MACHINE_CLIENTS]: { value: 100, description: "Business limit" }
|
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { db, limits } from "@server/db";
|
import { db, limits } from "@server/db";
|
||||||
import { and, eq } from "drizzle-orm";
|
import { and, eq } from "drizzle-orm";
|
||||||
import { LimitSet } from "./limitSet";
|
import { LimitSet } from "./limitSet";
|
||||||
import { LimitId } from "./features";
|
import { FeatureId } from "./features";
|
||||||
import logger from "@server/logger";
|
import logger from "@server/logger";
|
||||||
|
|
||||||
class LimitService {
|
class LimitService {
|
||||||
@@ -38,7 +38,7 @@ class LimitService {
|
|||||||
|
|
||||||
async getOrgLimit(
|
async getOrgLimit(
|
||||||
orgId: string,
|
orgId: string,
|
||||||
featureId: LimitId
|
featureId: FeatureId
|
||||||
): Promise<number | null> {
|
): Promise<number | null> {
|
||||||
const limitId = `${orgId}-${featureId}`;
|
const limitId = `${orgId}-${featureId}`;
|
||||||
const [limit] = await db
|
const [limit] = await db
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ export enum TierFeature {
|
|||||||
StandaloneHealthChecks = "standaloneHealthChecks",
|
StandaloneHealthChecks = "standaloneHealthChecks",
|
||||||
AlertingRules = "alertingRules",
|
AlertingRules = "alertingRules",
|
||||||
WildcardSubdomain = "wildcardSubdomain",
|
WildcardSubdomain = "wildcardSubdomain",
|
||||||
|
Labels = "labels",
|
||||||
NewtAutoUpdate = "newtAutoUpdate",
|
NewtAutoUpdate = "newtAutoUpdate",
|
||||||
ResourcePolicies = "resourcePolicies",
|
ResourcePolicies = "resourcePolicies",
|
||||||
AdvancedPublicResources = "advancedPublicResources",
|
AdvancedPublicResources = "advancedPublicResources",
|
||||||
@@ -30,6 +31,7 @@ export enum TierFeature {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const tierMatrix: Record<TierFeature, Tier[]> = {
|
export const tierMatrix: Record<TierFeature, Tier[]> = {
|
||||||
|
[TierFeature.Labels]: ["tier2", "tier3", "enterprise"],
|
||||||
[TierFeature.OrgOidc]: ["tier1", "tier2", "tier3", "enterprise"],
|
[TierFeature.OrgOidc]: ["tier1", "tier2", "tier3", "enterprise"],
|
||||||
[TierFeature.LoginPageDomain]: ["tier1", "tier2", "tier3", "enterprise"],
|
[TierFeature.LoginPageDomain]: ["tier1", "tier2", "tier3", "enterprise"],
|
||||||
[TierFeature.DeviceApprovals]: ["tier1", "tier3", "enterprise"],
|
[TierFeature.DeviceApprovals]: ["tier1", "tier3", "enterprise"],
|
||||||
@@ -69,6 +71,16 @@ export const tierMatrix: Record<TierFeature, Tier[]> = {
|
|||||||
[TierFeature.WildcardSubdomain]: ["tier1", "tier2", "tier3", "enterprise"],
|
[TierFeature.WildcardSubdomain]: ["tier1", "tier2", "tier3", "enterprise"],
|
||||||
[TierFeature.NewtAutoUpdate]: ["tier1", "tier2", "tier3", "enterprise"],
|
[TierFeature.NewtAutoUpdate]: ["tier1", "tier2", "tier3", "enterprise"],
|
||||||
[TierFeature.ResourcePolicies]: ["tier3", "enterprise"],
|
[TierFeature.ResourcePolicies]: ["tier3", "enterprise"],
|
||||||
[TierFeature.AdvancedPublicResources]: ["tier3", "enterprise"],
|
[TierFeature.AdvancedPublicResources]: [
|
||||||
[TierFeature.AdvancedPrivateResources]: ["tier3", "enterprise"]
|
"tier1",
|
||||||
|
"tier2",
|
||||||
|
"tier3",
|
||||||
|
"enterprise"
|
||||||
|
],
|
||||||
|
[TierFeature.AdvancedPrivateResources]: [
|
||||||
|
"tier1",
|
||||||
|
"tier2",
|
||||||
|
"tier3",
|
||||||
|
"enterprise"
|
||||||
|
]
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -9,10 +9,10 @@ import {
|
|||||||
Transaction,
|
Transaction,
|
||||||
orgs
|
orgs
|
||||||
} from "@server/db";
|
} from "@server/db";
|
||||||
import { LimitId, getFeatureMeterId } from "./features";
|
import { FeatureId, getFeatureMeterId } from "./features";
|
||||||
import logger from "@server/logger";
|
import logger from "@server/logger";
|
||||||
import { build } from "@server/build";
|
import { build } from "@server/build";
|
||||||
import { regionalCache as cache } from "#dynamic/lib/cache";
|
import cache from "#dynamic/lib/cache";
|
||||||
|
|
||||||
export function noop() {
|
export function noop() {
|
||||||
if (build !== "saas") {
|
if (build !== "saas") {
|
||||||
@@ -22,6 +22,7 @@ export function noop() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export class UsageService {
|
export class UsageService {
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
if (noop()) {
|
if (noop()) {
|
||||||
return;
|
return;
|
||||||
@@ -37,7 +38,7 @@ export class UsageService {
|
|||||||
|
|
||||||
public async add(
|
public async add(
|
||||||
orgId: string,
|
orgId: string,
|
||||||
featureId: LimitId,
|
featureId: FeatureId,
|
||||||
value: number,
|
value: number,
|
||||||
transaction: any = null
|
transaction: any = null
|
||||||
): Promise<Usage | null> {
|
): Promise<Usage | null> {
|
||||||
@@ -56,10 +57,7 @@ export class UsageService {
|
|||||||
try {
|
try {
|
||||||
let usage;
|
let usage;
|
||||||
if (transaction) {
|
if (transaction) {
|
||||||
const orgIdToUse = await this.getBillingOrg(
|
const orgIdToUse = await this.getBillingOrg(orgId, transaction);
|
||||||
orgId,
|
|
||||||
transaction
|
|
||||||
);
|
|
||||||
usage = await this.internalAddUsage(
|
usage = await this.internalAddUsage(
|
||||||
orgIdToUse,
|
orgIdToUse,
|
||||||
featureId,
|
featureId,
|
||||||
@@ -114,7 +112,7 @@ export class UsageService {
|
|||||||
|
|
||||||
private async internalAddUsage(
|
private async internalAddUsage(
|
||||||
orgId: string, // here the orgId is the billing org already resolved by getBillingOrg in updateCount
|
orgId: string, // here the orgId is the billing org already resolved by getBillingOrg in updateCount
|
||||||
featureId: LimitId,
|
featureId: FeatureId,
|
||||||
value: number,
|
value: number,
|
||||||
trx: Transaction
|
trx: Transaction
|
||||||
): Promise<Usage> {
|
): Promise<Usage> {
|
||||||
@@ -163,7 +161,7 @@ export class UsageService {
|
|||||||
|
|
||||||
async updateCount(
|
async updateCount(
|
||||||
orgId: string,
|
orgId: string,
|
||||||
featureId: LimitId,
|
featureId: FeatureId,
|
||||||
value?: number,
|
value?: number,
|
||||||
customerId?: string
|
customerId?: string
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
@@ -227,7 +225,7 @@ export class UsageService {
|
|||||||
|
|
||||||
private async getCustomerId(
|
private async getCustomerId(
|
||||||
orgId: string,
|
orgId: string,
|
||||||
featureId: LimitId
|
featureId: FeatureId
|
||||||
): Promise<string | null> {
|
): Promise<string | null> {
|
||||||
const orgIdToUse = await this.getBillingOrg(orgId);
|
const orgIdToUse = await this.getBillingOrg(orgId);
|
||||||
|
|
||||||
@@ -269,19 +267,18 @@ export class UsageService {
|
|||||||
|
|
||||||
public async getUsage(
|
public async getUsage(
|
||||||
orgId: string,
|
orgId: string,
|
||||||
featureId: LimitId,
|
featureId: FeatureId,
|
||||||
trx: Transaction | typeof db = db
|
trx: Transaction | typeof db = db
|
||||||
): Promise<Usage | null> {
|
): Promise<Usage | null> {
|
||||||
if (noop()) {
|
if (noop()) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
let orgIdToUse = orgId;
|
const orgIdToUse = await this.getBillingOrg(orgId, trx);
|
||||||
|
|
||||||
|
const usageId = `${orgIdToUse}-${featureId}`;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
orgIdToUse = await this.getBillingOrg(orgId, trx);
|
|
||||||
|
|
||||||
const usageId = `${orgIdToUse}-${featureId}`;
|
|
||||||
|
|
||||||
const [result] = await trx
|
const [result] = await trx
|
||||||
.select()
|
.select()
|
||||||
.from(usage)
|
.from(usage)
|
||||||
@@ -341,12 +338,8 @@ export class UsageService {
|
|||||||
`Failed to get usage for ${orgIdToUse}/${featureId}:`,
|
`Failed to get usage for ${orgIdToUse}/${featureId}:`,
|
||||||
error
|
error
|
||||||
);
|
);
|
||||||
if (process.env.NODE_ENV !== "development") {
|
throw error;
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public async getBillingOrg(
|
public async getBillingOrg(
|
||||||
@@ -381,7 +374,7 @@ export class UsageService {
|
|||||||
|
|
||||||
public async checkLimitSet(
|
public async checkLimitSet(
|
||||||
orgId: string,
|
orgId: string,
|
||||||
featureId?: LimitId,
|
featureId?: FeatureId,
|
||||||
usage?: Usage,
|
usage?: Usage,
|
||||||
trx: Transaction | typeof db = db
|
trx: Transaction | typeof db = db
|
||||||
): Promise<boolean> {
|
): Promise<boolean> {
|
||||||
@@ -389,13 +382,13 @@ export class UsageService {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const orgIdToUse = await this.getBillingOrg(orgId, trx);
|
||||||
|
|
||||||
// This method should check the current usage against the limits set for the organization
|
// This method should check the current usage against the limits set for the organization
|
||||||
// and kick out all of the sites on the org
|
// and kick out all of the sites on the org
|
||||||
let hasExceededLimits = false;
|
let hasExceededLimits = false;
|
||||||
let orgIdToUse = orgId;
|
|
||||||
try {
|
|
||||||
orgIdToUse = await this.getBillingOrg(orgId, trx);
|
|
||||||
|
|
||||||
|
try {
|
||||||
let orgLimits: Limit[] = [];
|
let orgLimits: Limit[] = [];
|
||||||
if (featureId) {
|
if (featureId) {
|
||||||
// Get all limits set for this organization
|
// Get all limits set for this organization
|
||||||
@@ -429,7 +422,7 @@ export class UsageService {
|
|||||||
} else {
|
} else {
|
||||||
currentUsage = await this.getUsage(
|
currentUsage = await this.getUsage(
|
||||||
orgIdToUse,
|
orgIdToUse,
|
||||||
limit.featureId as LimitId,
|
limit.featureId as FeatureId,
|
||||||
trx
|
trx
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,37 +3,28 @@ import {
|
|||||||
newts,
|
newts,
|
||||||
blueprints,
|
blueprints,
|
||||||
Blueprint,
|
Blueprint,
|
||||||
|
Site,
|
||||||
siteResources,
|
siteResources,
|
||||||
roleSiteResources,
|
roleSiteResources,
|
||||||
userSiteResources,
|
userSiteResources,
|
||||||
clientSiteResources
|
clientSiteResources
|
||||||
} from "@server/db";
|
} from "@server/db";
|
||||||
import { Config, ConfigSchema, isTargetsOnlyResource } from "./types";
|
import { Config, ConfigSchema } from "./types";
|
||||||
import {
|
import { ProxyResourcesResults, updateProxyResources } from "./proxyResources";
|
||||||
PublicResourcesResults,
|
|
||||||
updatePublicResources
|
|
||||||
} from "./publicResources";
|
|
||||||
import { fromError } from "zod-validation-error";
|
import { fromError } from "zod-validation-error";
|
||||||
import logger from "@server/logger";
|
import logger from "@server/logger";
|
||||||
import { sites } from "@server/db";
|
import { sites } from "@server/db";
|
||||||
import { eq, and, isNotNull } from "drizzle-orm";
|
import { eq, and, isNotNull } from "drizzle-orm";
|
||||||
import {
|
import { addTargets as addProxyTargets } from "@server/routers/newt/targets";
|
||||||
addTargets as addProxyTargets,
|
|
||||||
sendBrowserGatewayTargets
|
|
||||||
} from "@server/routers/newt/targets";
|
|
||||||
import {
|
import {
|
||||||
ClientResourcesResults,
|
ClientResourcesResults,
|
||||||
updatePrivateResources
|
updateClientResources
|
||||||
} from "./privateResources";
|
} from "./clientResources";
|
||||||
import { updateResourcePolicies } from "./resourcePolicies";
|
|
||||||
import { BlueprintSource } from "@server/routers/blueprints/types";
|
import { BlueprintSource } from "@server/routers/blueprints/types";
|
||||||
import { stringify as stringifyYaml } from "yaml";
|
import { stringify as stringifyYaml } from "yaml";
|
||||||
import { generateName } from "@server/db/names";
|
import { generateName } from "@server/db/names";
|
||||||
import {
|
import { handleMessagingForUpdatedSiteResource } from "@server/routers/siteResource";
|
||||||
handleMessagingForUpdatedSiteResource,
|
import { rebuildClientAssociationsFromSiteResource } from "../rebuildClientAssociations";
|
||||||
rebuildClientAssociationsFromSiteResource,
|
|
||||||
waitForSiteResourceRebuildIdle
|
|
||||||
} from "../rebuildClientAssociations";
|
|
||||||
|
|
||||||
type ApplyBlueprintArgs = {
|
type ApplyBlueprintArgs = {
|
||||||
orgId: string;
|
orgId: string;
|
||||||
@@ -50,39 +41,40 @@ export async function applyBlueprint({
|
|||||||
name,
|
name,
|
||||||
source = "API"
|
source = "API"
|
||||||
}: ApplyBlueprintArgs): Promise<Blueprint> {
|
}: ApplyBlueprintArgs): Promise<Blueprint> {
|
||||||
|
// Validate the input data
|
||||||
|
const validationResult = ConfigSchema.safeParse(configData);
|
||||||
|
if (!validationResult.success) {
|
||||||
|
throw new Error(fromError(validationResult.error).toString());
|
||||||
|
}
|
||||||
|
|
||||||
|
const config: Config = validationResult.data;
|
||||||
let blueprintSucceeded: boolean = false;
|
let blueprintSucceeded: boolean = false;
|
||||||
let blueprintMessage = "";
|
let blueprintMessage: string;
|
||||||
let error: any | null = null;
|
let error: any | null = null;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const validationResult = ConfigSchema.safeParse(configData);
|
let proxyResourcesResults: ProxyResourcesResults = [];
|
||||||
if (!validationResult.success) {
|
let clientResourcesResults: ClientResourcesResults = [];
|
||||||
throw new Error(fromError(validationResult.error).toString());
|
|
||||||
}
|
|
||||||
|
|
||||||
const config: Config = validationResult.data;
|
|
||||||
|
|
||||||
let publicResourcesResults: PublicResourcesResults = [];
|
|
||||||
let privateResourcesResults: ClientResourcesResults = [];
|
|
||||||
|
|
||||||
await db.transaction(async (trx) => {
|
await db.transaction(async (trx) => {
|
||||||
await updateResourcePolicies(orgId, config, trx);
|
proxyResourcesResults = await updateProxyResources(
|
||||||
|
|
||||||
publicResourcesResults = await updatePublicResources(
|
|
||||||
orgId,
|
orgId,
|
||||||
config,
|
config,
|
||||||
trx,
|
trx,
|
||||||
siteId
|
siteId
|
||||||
);
|
);
|
||||||
privateResourcesResults = await updatePrivateResources(
|
clientResourcesResults = await updateClientResources(
|
||||||
orgId,
|
orgId,
|
||||||
config,
|
config,
|
||||||
trx,
|
trx,
|
||||||
siteId
|
siteId
|
||||||
);
|
);
|
||||||
|
|
||||||
|
logger.debug(
|
||||||
|
`Successfully updated proxy resources for org ${orgId}: ${JSON.stringify(proxyResourcesResults)}`
|
||||||
|
);
|
||||||
|
|
||||||
// We need to update the targets on the newts from the successfully updated information
|
// We need to update the targets on the newts from the successfully updated information
|
||||||
for (const result of publicResourcesResults) {
|
for (const result of proxyResourcesResults) {
|
||||||
for (const target of result.targetsToUpdate) {
|
for (const target of result.targetsToUpdate) {
|
||||||
const [site] = await trx
|
const [site] = await trx
|
||||||
.select()
|
.select()
|
||||||
@@ -109,63 +101,178 @@ export async function applyBlueprint({
|
|||||||
(hc) => hc.targetId === target.targetId
|
(hc) => hc.targetId === target.targetId
|
||||||
);
|
);
|
||||||
|
|
||||||
if (["http", "tcp", "udp"].includes(target.mode)) {
|
await addProxyTargets(
|
||||||
await addProxyTargets(
|
site.newt.newtId,
|
||||||
site.newt.newtId,
|
[target],
|
||||||
[target],
|
matchingHealthcheck ? [matchingHealthcheck] : [],
|
||||||
matchingHealthcheck
|
result.proxyResource.mode === "udp" ? "udp" : "tcp",
|
||||||
? [matchingHealthcheck]
|
site.newt.version
|
||||||
: [],
|
);
|
||||||
result.proxyResource.mode === "udp"
|
|
||||||
? "udp"
|
|
||||||
: "tcp",
|
|
||||||
site.newt.version
|
|
||||||
);
|
|
||||||
} else if (
|
|
||||||
["ssh", "rdp", "vnc"].includes(target.mode)
|
|
||||||
) {
|
|
||||||
await sendBrowserGatewayTargets(
|
|
||||||
site.newt.newtId,
|
|
||||||
[target],
|
|
||||||
site.newt.version
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
logger.debug(
|
logger.debug(
|
||||||
`Successfully updated public resources for org ${orgId}: ${JSON.stringify(publicResourcesResults)}`
|
`Successfully updated client resources for org ${orgId}: ${JSON.stringify(clientResourcesResults)}`
|
||||||
);
|
);
|
||||||
|
|
||||||
// We need to update the targets on the newts from the successfully updated information
|
// We need to update the targets on the newts from the successfully updated information
|
||||||
for (const result of privateResourcesResults) {
|
for (const result of clientResourcesResults) {
|
||||||
rebuildClientAssociationsFromSiteResource(
|
if (
|
||||||
result.newSiteResource
|
result.oldSiteResource &&
|
||||||
)
|
JSON.stringify(result.newSites?.sort()) !==
|
||||||
.then(() =>
|
JSON.stringify(result.oldSites?.sort())
|
||||||
waitForSiteResourceRebuildIdle(
|
) {
|
||||||
result.newSiteResource.siteResourceId
|
// query existing associations
|
||||||
|
const existingRoleIds = await trx
|
||||||
|
.select()
|
||||||
|
.from(roleSiteResources)
|
||||||
|
.where(
|
||||||
|
eq(
|
||||||
|
roleSiteResources.siteResourceId,
|
||||||
|
result.oldSiteResource.siteResourceId
|
||||||
|
)
|
||||||
)
|
)
|
||||||
)
|
.then((rows) => rows.map((row) => row.roleId));
|
||||||
.then(() =>
|
|
||||||
handleMessagingForUpdatedSiteResource(
|
|
||||||
result.oldSiteResource,
|
|
||||||
result.newSiteResource,
|
|
||||||
result.oldSites.map((s) => s.siteId),
|
|
||||||
result.newSites.map((s) => s.siteId)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
.catch((e) => {
|
|
||||||
logger.error(
|
|
||||||
`Failed to rebuild and handle messaging for site resource ${result.newSiteResource.siteResourceId}. Error: ${e}`
|
|
||||||
);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
logger.debug(
|
const existingUserIds = await trx
|
||||||
`Successfully updated private resources for org ${orgId}: ${JSON.stringify(privateResourcesResults)}`
|
.select()
|
||||||
);
|
.from(userSiteResources)
|
||||||
|
.where(
|
||||||
|
eq(
|
||||||
|
userSiteResources.siteResourceId,
|
||||||
|
result.oldSiteResource.siteResourceId
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.then((rows) => rows.map((row) => row.userId));
|
||||||
|
|
||||||
|
const existingClientIds = await trx
|
||||||
|
.select()
|
||||||
|
.from(clientSiteResources)
|
||||||
|
.where(
|
||||||
|
eq(
|
||||||
|
clientSiteResources.siteResourceId,
|
||||||
|
result.oldSiteResource.siteResourceId
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.then((rows) => rows.map((row) => row.clientId));
|
||||||
|
|
||||||
|
// delete the existing site resource
|
||||||
|
await trx
|
||||||
|
.delete(siteResources)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(
|
||||||
|
siteResources.siteResourceId,
|
||||||
|
result.oldSiteResource.siteResourceId
|
||||||
|
)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
await rebuildClientAssociationsFromSiteResource(
|
||||||
|
result.oldSiteResource,
|
||||||
|
trx
|
||||||
|
);
|
||||||
|
|
||||||
|
const [insertedSiteResource] = await trx
|
||||||
|
.insert(siteResources)
|
||||||
|
.values({
|
||||||
|
...result.newSiteResource
|
||||||
|
})
|
||||||
|
.returning();
|
||||||
|
|
||||||
|
// wait some time to allow for messages to be handled
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 750));
|
||||||
|
|
||||||
|
//////////////////// update the associations ////////////////////
|
||||||
|
|
||||||
|
if (existingRoleIds.length > 0) {
|
||||||
|
await trx.insert(roleSiteResources).values(
|
||||||
|
existingRoleIds.map((roleId) => ({
|
||||||
|
roleId,
|
||||||
|
siteResourceId:
|
||||||
|
insertedSiteResource!.siteResourceId
|
||||||
|
}))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (existingUserIds.length > 0) {
|
||||||
|
await trx.insert(userSiteResources).values(
|
||||||
|
existingUserIds.map((userId) => ({
|
||||||
|
userId,
|
||||||
|
siteResourceId:
|
||||||
|
insertedSiteResource!.siteResourceId
|
||||||
|
}))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (existingClientIds.length > 0) {
|
||||||
|
await trx.insert(clientSiteResources).values(
|
||||||
|
existingClientIds.map((clientId) => ({
|
||||||
|
clientId,
|
||||||
|
siteResourceId:
|
||||||
|
insertedSiteResource!.siteResourceId
|
||||||
|
}))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
await rebuildClientAssociationsFromSiteResource(
|
||||||
|
insertedSiteResource,
|
||||||
|
trx
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
let good = true;
|
||||||
|
for (const newSite of result.newSites) {
|
||||||
|
const [site] = await trx
|
||||||
|
.select()
|
||||||
|
.from(sites)
|
||||||
|
.innerJoin(newts, eq(sites.siteId, newts.siteId))
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(sites.siteId, newSite.siteId),
|
||||||
|
eq(sites.orgId, orgId),
|
||||||
|
eq(sites.type, "newt"),
|
||||||
|
isNotNull(sites.pubKey)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
if (!site) {
|
||||||
|
logger.debug(
|
||||||
|
`No newt sites found for client resource ${result.newSiteResource.siteResourceId}, skipping target update`
|
||||||
|
);
|
||||||
|
good = false;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.debug(
|
||||||
|
`Updating client resource ${result.newSiteResource.siteResourceId} on site ${newSite.siteId}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!good) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
await handleMessagingForUpdatedSiteResource(
|
||||||
|
result.oldSiteResource,
|
||||||
|
result.newSiteResource,
|
||||||
|
result.newSites.map((site) => ({
|
||||||
|
siteId: site.siteId,
|
||||||
|
orgId: result.newSiteResource.orgId
|
||||||
|
})),
|
||||||
|
trx
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// await addClientTargets(
|
||||||
|
// site.newt.newtId,
|
||||||
|
// result.resource.destination,
|
||||||
|
// result.resource.destinationPort,
|
||||||
|
// result.resource.protocol,
|
||||||
|
// result.resource.proxyPort
|
||||||
|
// );
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
blueprintSucceeded = true;
|
blueprintSucceeded = true;
|
||||||
@@ -173,9 +280,7 @@ export async function applyBlueprint({
|
|||||||
} catch (err) {
|
} catch (err) {
|
||||||
blueprintSucceeded = false;
|
blueprintSucceeded = false;
|
||||||
blueprintMessage = `Blueprint applied with errors: ${err}`;
|
blueprintMessage = `Blueprint applied with errors: ${err}`;
|
||||||
logger.debug(
|
logger.error(blueprintMessage);
|
||||||
`Org ${orgId} blueprint apply issues: ${blueprintMessage}`
|
|
||||||
);
|
|
||||||
error = err;
|
error = err;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -71,10 +71,7 @@ export async function applyNewtDockerBlueprint(
|
|||||||
let skippedKeys: string[] = [];
|
let skippedKeys: string[] = [];
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Some Newt clients can report null/undefined containers when Docker
|
const blueprint = processContainerLabels(containers);
|
||||||
// labels are unavailable. Treat that as an empty blueprint payload.
|
|
||||||
const safeContainers = Array.isArray(containers) ? containers : [];
|
|
||||||
const blueprint = processContainerLabels(safeContainers);
|
|
||||||
|
|
||||||
logger.debug(
|
logger.debug(
|
||||||
`Received Docker blueprint with ${Object.keys(blueprint["proxy-resources"]).length} proxy, ${Object.keys(blueprint["client-resources"]).length} client resource(s)`
|
`Received Docker blueprint with ${Object.keys(blueprint["proxy-resources"]).length} proxy, ${Object.keys(blueprint["client-resources"]).length} client resource(s)`
|
||||||
@@ -116,7 +113,7 @@ export async function applyNewtDockerBlueprint(
|
|||||||
source: "NEWT"
|
source: "NEWT"
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.debug(`Failed to update database from config: ${error}`);
|
logger.error(`Failed to update database from config: ${error}`);
|
||||||
await sendToClient(newtId, {
|
await sendToClient(newtId, {
|
||||||
type: "newt/blueprint/results",
|
type: "newt/blueprint/results",
|
||||||
data: {
|
data: {
|
||||||
|
|||||||
+11
-133
@@ -23,11 +23,6 @@ import logger from "@server/logger";
|
|||||||
import { defaultRoleAllowedActions } from "@server/routers/role/createRole";
|
import { defaultRoleAllowedActions } from "@server/routers/role/createRole";
|
||||||
import { getNextAvailableAliasAddress } from "../ip";
|
import { getNextAvailableAliasAddress } from "../ip";
|
||||||
import { createCertificate } from "#dynamic/routers/certificates/createCertificate";
|
import { createCertificate } from "#dynamic/routers/certificates/createCertificate";
|
||||||
import { isLicensedOrSubscribed } from "#dynamic/lib/isLicencedOrSubscribed";
|
|
||||||
import { tierMatrix } from "../billing/tierMatrix";
|
|
||||||
import { build } from "@server/build";
|
|
||||||
import { LimitId } from "../billing";
|
|
||||||
import { usageService } from "../billing/usageService";
|
|
||||||
|
|
||||||
async function getDomainForSiteResource(
|
async function getDomainForSiteResource(
|
||||||
siteResourceId: number | undefined,
|
siteResourceId: number | undefined,
|
||||||
@@ -108,7 +103,7 @@ export type ClientResourcesResults = {
|
|||||||
oldSites: { siteId: number }[];
|
oldSites: { siteId: number }[];
|
||||||
}[];
|
}[];
|
||||||
|
|
||||||
export async function updatePrivateResources(
|
export async function updateClientResources(
|
||||||
orgId: string,
|
orgId: string,
|
||||||
config: Config,
|
config: Config,
|
||||||
trx: Transaction,
|
trx: Transaction,
|
||||||
@@ -119,30 +114,6 @@ export async function updatePrivateResources(
|
|||||||
for (const [resourceNiceId, resourceData] of Object.entries(
|
for (const [resourceNiceId, resourceData] of Object.entries(
|
||||||
config["client-resources"]
|
config["client-resources"]
|
||||||
)) {
|
)) {
|
||||||
if (resourceData.mode === "http") {
|
|
||||||
const hasHttpFeature = await isLicensedOrSubscribed(
|
|
||||||
orgId,
|
|
||||||
tierMatrix.advancedPrivateResources
|
|
||||||
);
|
|
||||||
if (!hasHttpFeature) {
|
|
||||||
throw new Error(
|
|
||||||
"HTTP private resources are not included in your current plan. Please upgrade."
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (resourceData.mode === "ssh") {
|
|
||||||
const hasSshFeature = await isLicensedOrSubscribed(
|
|
||||||
orgId,
|
|
||||||
tierMatrix.advancedPrivateResources
|
|
||||||
);
|
|
||||||
if (!hasSshFeature) {
|
|
||||||
throw new Error(
|
|
||||||
"SSH private resources are not included in your current plan. Please upgrade."
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const [existingResource] = await trx
|
const [existingResource] = await trx
|
||||||
.select()
|
.select()
|
||||||
.from(siteResources)
|
.from(siteResources)
|
||||||
@@ -198,19 +169,17 @@ export async function updatePrivateResources(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let resourceStatusFromSite: "approved" | "pending" = "approved";
|
|
||||||
if (siteId && allSites.length === 0) {
|
if (siteId && allSites.length === 0) {
|
||||||
// only add if there are not provided sites
|
// only add if there are not provided sites
|
||||||
// Use the provided siteId directly, but verify it belongs to the org
|
// Use the provided siteId directly, but verify it belongs to the org
|
||||||
const [siteSingle] = await trx
|
const [siteSingle] = await trx
|
||||||
.select({ siteId: sites.siteId, status: sites.status })
|
.select({ siteId: sites.siteId })
|
||||||
.from(sites)
|
.from(sites)
|
||||||
.where(and(eq(sites.siteId, siteId), eq(sites.orgId, orgId)))
|
.where(and(eq(sites.siteId, siteId), eq(sites.orgId, orgId)))
|
||||||
.limit(1);
|
.limit(1);
|
||||||
if (siteSingle) {
|
if (siteSingle) {
|
||||||
allSites.push(siteSingle);
|
allSites.push(siteSingle);
|
||||||
}
|
}
|
||||||
resourceStatusFromSite = siteSingle.status ?? "approved";
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (allSites.length === 0) {
|
if (allSites.length === 0) {
|
||||||
@@ -219,13 +188,6 @@ export async function updatePrivateResources(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const resourceEnabled =
|
|
||||||
resourceData.enabled == undefined || resourceData.enabled == null
|
|
||||||
? true
|
|
||||||
: resourceStatusFromSite === "pending"
|
|
||||||
? false
|
|
||||||
: resourceData.enabled;
|
|
||||||
|
|
||||||
if (existingResource) {
|
if (existingResource) {
|
||||||
let domainInfo:
|
let domainInfo:
|
||||||
| { subdomain: string | null; domainId: string }
|
| { subdomain: string | null; domainId: string }
|
||||||
@@ -239,31 +201,6 @@ export async function updatePrivateResources(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (resourceData.alias) {
|
|
||||||
const [aliasConflict] = await trx
|
|
||||||
.select({
|
|
||||||
siteResourceId: siteResources.siteResourceId
|
|
||||||
})
|
|
||||||
.from(siteResources)
|
|
||||||
.where(
|
|
||||||
and(
|
|
||||||
eq(siteResources.orgId, orgId),
|
|
||||||
eq(siteResources.alias, resourceData.alias),
|
|
||||||
ne(
|
|
||||||
siteResources.siteResourceId,
|
|
||||||
existingResource.siteResourceId
|
|
||||||
)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
.limit(1);
|
|
||||||
|
|
||||||
if (aliasConflict) {
|
|
||||||
throw new Error(
|
|
||||||
`Alias ${resourceData.alias} already in use by another site resource in org ${orgId}`
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update existing resource
|
// Update existing resource
|
||||||
const [updatedResource] = await trx
|
const [updatedResource] = await trx
|
||||||
.update(siteResources)
|
.update(siteResources)
|
||||||
@@ -274,7 +211,8 @@ export async function updatePrivateResources(
|
|||||||
scheme: resourceData.scheme,
|
scheme: resourceData.scheme,
|
||||||
destination: resourceData.destination,
|
destination: resourceData.destination,
|
||||||
destinationPort: resourceData["destination-port"],
|
destinationPort: resourceData["destination-port"],
|
||||||
enabled: resourceEnabled,
|
enabled: true, // hardcoded for now
|
||||||
|
// enabled: resourceData.enabled ?? true,
|
||||||
alias: resourceData.alias || null,
|
alias: resourceData.alias || null,
|
||||||
disableIcmp:
|
disableIcmp:
|
||||||
resourceData["disable-icmp"] ||
|
resourceData["disable-icmp"] ||
|
||||||
@@ -293,8 +231,7 @@ export async function updatePrivateResources(
|
|||||||
pamMode: resourceData["auth-daemon"]?.pam || "passthrough",
|
pamMode: resourceData["auth-daemon"]?.pam || "passthrough",
|
||||||
authDaemonMode:
|
authDaemonMode:
|
||||||
resourceData["auth-daemon"]?.mode || "native",
|
resourceData["auth-daemon"]?.mode || "native",
|
||||||
authDaemonPort: resourceData["auth-daemon"]?.port || 22123,
|
authDaemonPort: resourceData["auth-daemon"]?.port || 22123
|
||||||
status: resourceStatusFromSite
|
|
||||||
})
|
})
|
||||||
.where(
|
.where(
|
||||||
eq(
|
eq(
|
||||||
@@ -429,9 +366,7 @@ export async function updatePrivateResources(
|
|||||||
}))
|
}))
|
||||||
);
|
);
|
||||||
existingRoles.push(created);
|
existingRoles.push(created);
|
||||||
logger.info(
|
logger.info(`Auto-created role "${name}" in org ${orgId} from blueprint`);
|
||||||
`Auto-created role "${name}" in org ${orgId} from blueprint`
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const roleIds = existingRoles.map((role) => role.roleId);
|
const roleIds = existingRoles.map((role) => role.roleId);
|
||||||
@@ -450,41 +385,9 @@ export async function updatePrivateResources(
|
|||||||
oldSites: existingSiteIds
|
oldSites: existingSiteIds
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
// create a brand new resource
|
|
||||||
|
|
||||||
if (build == "saas") {
|
|
||||||
const usage = await usageService.getUsage(
|
|
||||||
orgId,
|
|
||||||
LimitId.PRIVATE_RESOURCES
|
|
||||||
);
|
|
||||||
if (!usage) {
|
|
||||||
throw new Error(
|
|
||||||
`Usage data not found for org ${orgId} and limit ${LimitId.PRIVATE_RESOURCES}`
|
|
||||||
);
|
|
||||||
}
|
|
||||||
const rejectResource = await usageService.checkLimitSet(
|
|
||||||
orgId,
|
|
||||||
|
|
||||||
LimitId.PRIVATE_RESOURCES,
|
|
||||||
{
|
|
||||||
...usage,
|
|
||||||
instantaneousValue: (usage.instantaneousValue || 0) + 1
|
|
||||||
} // We need to add one to know if we are violating the limit
|
|
||||||
);
|
|
||||||
if (rejectResource) {
|
|
||||||
throw new Error(
|
|
||||||
"Private resource limit exceeded. Please upgrade your plan."
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let aliasAddress: string | null = null;
|
let aliasAddress: string | null = null;
|
||||||
let releaseAliasLock: (() => Promise<void>) | null = null;
|
let releaseAliasLock: (() => Promise<void>) | null = null;
|
||||||
if (
|
if (resourceData.mode === "host" || resourceData.mode === "http") {
|
||||||
resourceData.mode === "host" ||
|
|
||||||
resourceData.mode === "http" ||
|
|
||||||
resourceData.mode === "ssh"
|
|
||||||
) {
|
|
||||||
const { value, release } = await getNextAvailableAliasAddress(
|
const { value, release } = await getNextAvailableAliasAddress(
|
||||||
orgId,
|
orgId,
|
||||||
trx
|
trx
|
||||||
@@ -505,27 +408,6 @@ export async function updatePrivateResources(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (resourceData.alias) {
|
|
||||||
const [aliasConflict] = await trx
|
|
||||||
.select({
|
|
||||||
siteResourceId: siteResources.siteResourceId
|
|
||||||
})
|
|
||||||
.from(siteResources)
|
|
||||||
.where(
|
|
||||||
and(
|
|
||||||
eq(siteResources.orgId, orgId),
|
|
||||||
eq(siteResources.alias, resourceData.alias)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
.limit(1);
|
|
||||||
|
|
||||||
if (aliasConflict) {
|
|
||||||
throw new Error(
|
|
||||||
`Alias ${resourceData.alias} already in use by another site resource in org ${orgId}`
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const [network] = await trx
|
const [network] = await trx
|
||||||
.insert(networks)
|
.insert(networks)
|
||||||
.values({
|
.values({
|
||||||
@@ -548,7 +430,8 @@ export async function updatePrivateResources(
|
|||||||
scheme: resourceData.scheme,
|
scheme: resourceData.scheme,
|
||||||
destination: resourceData.destination,
|
destination: resourceData.destination,
|
||||||
destinationPort: resourceData["destination-port"],
|
destinationPort: resourceData["destination-port"],
|
||||||
enabled: resourceEnabled,
|
enabled: true, // hardcoded for now
|
||||||
|
// enabled: resourceData.enabled ?? true,
|
||||||
alias: resourceData.alias || null,
|
alias: resourceData.alias || null,
|
||||||
aliasAddress: aliasAddress,
|
aliasAddress: aliasAddress,
|
||||||
disableIcmp:
|
disableIcmp:
|
||||||
@@ -568,8 +451,7 @@ export async function updatePrivateResources(
|
|||||||
pamMode: resourceData["auth-daemon"]?.pam || "passthrough",
|
pamMode: resourceData["auth-daemon"]?.pam || "passthrough",
|
||||||
authDaemonMode:
|
authDaemonMode:
|
||||||
resourceData["auth-daemon"]?.mode || "native",
|
resourceData["auth-daemon"]?.mode || "native",
|
||||||
authDaemonPort: resourceData["auth-daemon"]?.port || 22123,
|
authDaemonPort: resourceData["auth-daemon"]?.port || 22123
|
||||||
status: resourceStatusFromSite
|
|
||||||
})
|
})
|
||||||
.returning();
|
.returning();
|
||||||
|
|
||||||
@@ -628,9 +510,7 @@ export async function updatePrivateResources(
|
|||||||
}))
|
}))
|
||||||
);
|
);
|
||||||
existingRoles.push(created);
|
existingRoles.push(created);
|
||||||
logger.info(
|
logger.info(`Auto-created role "${name}" in org ${orgId} from blueprint`);
|
||||||
`Auto-created role "${name}" in org ${orgId} from blueprint`
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const roleIds = existingRoles.map((role) => role.roleId);
|
const roleIds = existingRoles.map((role) => role.roleId);
|
||||||
@@ -695,8 +575,6 @@ export async function updatePrivateResources(
|
|||||||
`Created new client resource ${newResource.name} (${newResource.siteResourceId}) for org ${orgId}`
|
`Created new client resource ${newResource.name} (${newResource.siteResourceId}) for org ${orgId}`
|
||||||
);
|
);
|
||||||
|
|
||||||
await usageService.add(orgId, LimitId.PRIVATE_RESOURCES, 1, trx);
|
|
||||||
|
|
||||||
results.push({
|
results.push({
|
||||||
newSiteResource: newResource,
|
newSiteResource: newResource,
|
||||||
newSites: allSites,
|
newSites: allSites,
|
||||||
+62
-257
@@ -1,114 +1,85 @@
|
|||||||
import { isLicensedOrSubscribed } from "#dynamic/lib/isLicencedOrSubscribed";
|
|
||||||
import { createCertificate } from "#dynamic/routers/certificates/createCertificate";
|
|
||||||
import { hashPassword } from "@server/auth/password";
|
|
||||||
import { generateId } from "@server/auth/sessions/app";
|
|
||||||
import { build } from "@server/build";
|
|
||||||
import {
|
import {
|
||||||
domainNamespaces,
|
|
||||||
domains,
|
domains,
|
||||||
|
domainNamespaces,
|
||||||
orgDomains,
|
orgDomains,
|
||||||
Resource,
|
Resource,
|
||||||
resourceHeaderAuth,
|
resourceHeaderAuth,
|
||||||
resourceHeaderAuthExtendedCompatibility,
|
resourceHeaderAuthExtendedCompatibility,
|
||||||
resourcePassword,
|
|
||||||
resourcePincode,
|
resourcePincode,
|
||||||
resourcePolicies,
|
|
||||||
resourcePolicyHeaderAuth,
|
|
||||||
resourcePolicyPassword,
|
|
||||||
resourcePolicyPincode,
|
|
||||||
resourcePolicyRules,
|
|
||||||
resourcePolicyWhiteList,
|
|
||||||
resourceRules,
|
resourceRules,
|
||||||
resources,
|
|
||||||
resourceWhitelist,
|
resourceWhitelist,
|
||||||
roleActions,
|
roleActions,
|
||||||
rolePolicies,
|
|
||||||
roleResources,
|
roleResources,
|
||||||
roles,
|
roles,
|
||||||
Site,
|
|
||||||
sites,
|
|
||||||
Target,
|
Target,
|
||||||
TargetHealthCheck,
|
TargetHealthCheck,
|
||||||
targetHealthCheck,
|
targetHealthCheck,
|
||||||
targets,
|
|
||||||
Transaction,
|
Transaction,
|
||||||
userOrgs,
|
userOrgs,
|
||||||
userPolicies,
|
|
||||||
userResources,
|
userResources,
|
||||||
users,
|
users,
|
||||||
type ResourceRule
|
resourcePolicies,
|
||||||
|
resourcePolicyPassword,
|
||||||
|
resourcePolicyPincode,
|
||||||
|
resourcePolicyHeaderAuth,
|
||||||
|
resourcePolicyRules,
|
||||||
|
resourcePolicyWhiteList,
|
||||||
|
rolePolicies,
|
||||||
|
userPolicies
|
||||||
} from "@server/db";
|
} from "@server/db";
|
||||||
import { getUniqueResourcePolicyName } from "@server/db/names";
|
import { resources, targets, sites } from "@server/db";
|
||||||
import { isValidRegionId } from "@server/db/regions";
|
import { eq, and, asc, or, ne, count, isNotNull } from "drizzle-orm";
|
||||||
import { fireHealthCheckUnknownAlert } from "@server/lib/alerts";
|
import {
|
||||||
import serverConfig from "@server/lib/config";
|
Config,
|
||||||
import { encrypt } from "@server/lib/crypto";
|
ConfigSchema,
|
||||||
|
isTargetsOnlyResource,
|
||||||
|
TargetData
|
||||||
|
} from "./types";
|
||||||
import logger from "@server/logger";
|
import logger from "@server/logger";
|
||||||
import { defaultRoleAllowedActions } from "@server/routers/role/createRole";
|
import { createCertificate } from "#dynamic/routers/certificates/createCertificate";
|
||||||
import { pickPort } from "@server/routers/target/helpers";
|
import { pickPort } from "@server/routers/target/helpers";
|
||||||
import { and, asc, eq, isNotNull, ne, or } from "drizzle-orm";
|
import { resourcePassword } from "@server/db";
|
||||||
import { tierMatrix } from "../billing/tierMatrix";
|
import { getUniqueResourcePolicyName } from "@server/db/names";
|
||||||
|
import { hashPassword } from "@server/auth/password";
|
||||||
import { isValidCIDR, isValidIP, isValidUrlGlobPattern } from "../validators";
|
import { isValidCIDR, isValidIP, isValidUrlGlobPattern } from "../validators";
|
||||||
import { Config, isTargetsOnlyResource, TargetData } from "./types";
|
import { isValidRegionId } from "@server/db/regions";
|
||||||
import HttpCode from "@server/types/HttpCode";
|
import { isLicensedOrSubscribed } from "#dynamic/lib/isLicencedOrSubscribed";
|
||||||
import createHttpError from "http-errors";
|
import { fireHealthCheckUnknownAlert } from "@server/lib/alerts";
|
||||||
import next from "next";
|
import { tierMatrix } from "../billing/tierMatrix";
|
||||||
import { LimitId } from "../billing";
|
import { defaultRoleAllowedActions } from "@server/routers/role/createRole";
|
||||||
import { usageService } from "../billing/usageService";
|
|
||||||
|
|
||||||
export type PublicResourcesResults = {
|
export type ProxyResourcesResults = {
|
||||||
proxyResource: Resource;
|
proxyResource: Resource;
|
||||||
targetsToUpdate: Target[];
|
targetsToUpdate: Target[];
|
||||||
healthchecksToUpdate: TargetHealthCheck[];
|
healthchecksToUpdate: TargetHealthCheck[];
|
||||||
}[];
|
}[];
|
||||||
|
|
||||||
export async function updatePublicResources(
|
export async function updateProxyResources(
|
||||||
orgId: string,
|
orgId: string,
|
||||||
config: Config,
|
config: Config,
|
||||||
trx: Transaction,
|
trx: Transaction,
|
||||||
siteId?: number
|
siteId?: number
|
||||||
): Promise<PublicResourcesResults> {
|
): Promise<ProxyResourcesResults> {
|
||||||
const results: PublicResourcesResults = [];
|
const results: ProxyResourcesResults = [];
|
||||||
|
|
||||||
for (const [resourceNiceId, resourceData] of Object.entries(
|
for (const [resourceNiceId, resourceData] of Object.entries(
|
||||||
config["proxy-resources"]
|
config["proxy-resources"]
|
||||||
)) {
|
)) {
|
||||||
const targetsToUpdate: Target[] = [];
|
const targetsToUpdate: Target[] = [];
|
||||||
const healthchecksToUpdate: TargetHealthCheck[] = [];
|
const healthchecksToUpdate: TargetHealthCheck[] = [];
|
||||||
|
|
||||||
let resource: Resource;
|
let resource: Resource;
|
||||||
let resourceStatusFromSite: "approved" | "pending" = "approved";
|
|
||||||
let providedSite: Partial<Site> | undefined;
|
|
||||||
if (siteId) {
|
|
||||||
// Use the provided siteId directly, but verify it belongs to the org
|
|
||||||
[providedSite] = await trx
|
|
||||||
.select({
|
|
||||||
siteId: sites.siteId,
|
|
||||||
type: sites.type,
|
|
||||||
status: sites.status
|
|
||||||
})
|
|
||||||
.from(sites)
|
|
||||||
.where(and(eq(sites.siteId, siteId), eq(sites.orgId, orgId)))
|
|
||||||
.limit(1);
|
|
||||||
|
|
||||||
resourceStatusFromSite = providedSite?.status ?? "approved";
|
|
||||||
}
|
|
||||||
|
|
||||||
async function createTarget( // reusable function to create a target
|
async function createTarget( // reusable function to create a target
|
||||||
resourceId: number,
|
resourceId: number,
|
||||||
targetData: TargetData
|
targetData: TargetData
|
||||||
) {
|
) {
|
||||||
const targetSiteId = targetData.site;
|
const targetSiteId = targetData.site;
|
||||||
let site: Partial<Site> | undefined;
|
let site;
|
||||||
|
|
||||||
if (targetSiteId) {
|
if (targetSiteId) {
|
||||||
// Look up site by niceId
|
// Look up site by niceId
|
||||||
[site] = await trx
|
[site] = await trx
|
||||||
.select({
|
.select({ siteId: sites.siteId })
|
||||||
siteId: sites.siteId,
|
|
||||||
type: sites.type,
|
|
||||||
status: sites.status
|
|
||||||
})
|
|
||||||
.from(sites)
|
.from(sites)
|
||||||
.where(
|
.where(
|
||||||
and(
|
and(
|
||||||
@@ -117,9 +88,15 @@ export async function updatePublicResources(
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
.limit(1);
|
.limit(1);
|
||||||
} else if (siteId && providedSite) {
|
} else if (siteId) {
|
||||||
// Use the provided siteId directly, but verify it belongs to the org
|
// Use the provided siteId directly, but verify it belongs to the org
|
||||||
site = providedSite;
|
[site] = await trx
|
||||||
|
.select({ siteId: sites.siteId })
|
||||||
|
.from(sites)
|
||||||
|
.where(
|
||||||
|
and(eq(sites.siteId, siteId), eq(sites.orgId, orgId))
|
||||||
|
)
|
||||||
|
.limit(1);
|
||||||
} else {
|
} else {
|
||||||
throw new Error(`Target site is required`);
|
throw new Error(`Target site is required`);
|
||||||
}
|
}
|
||||||
@@ -141,28 +118,17 @@ export async function updatePublicResources(
|
|||||||
internalPortToCreate = targetData["internal-port"];
|
internalPortToCreate = targetData["internal-port"];
|
||||||
}
|
}
|
||||||
|
|
||||||
let authToken: string | undefined;
|
|
||||||
if (site.type !== "local") {
|
|
||||||
const plainToken = generateId(48);
|
|
||||||
authToken = encrypt(
|
|
||||||
plainToken,
|
|
||||||
serverConfig.getRawConfig().server.secret!
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create target
|
// Create target
|
||||||
const [newTarget] = await trx
|
const [newTarget] = await trx
|
||||||
.insert(targets)
|
.insert(targets)
|
||||||
.values({
|
.values({
|
||||||
resourceId: resourceId,
|
resourceId: resourceId,
|
||||||
siteId: site.siteId!,
|
siteId: site.siteId,
|
||||||
ip: targetData.hostname,
|
ip: targetData.hostname,
|
||||||
mode: resourceData.mode as Target["mode"],
|
|
||||||
method: targetData.method,
|
method: targetData.method,
|
||||||
port: targetData.port,
|
port: targetData.port,
|
||||||
enabled: targetData.enabled,
|
enabled: targetData.enabled,
|
||||||
internalPort: internalPortToCreate,
|
internalPort: internalPortToCreate,
|
||||||
authToken: authToken,
|
|
||||||
path: targetData.path,
|
path: targetData.path,
|
||||||
pathMatchType: targetData["path-match"],
|
pathMatchType: targetData["path-match"],
|
||||||
rewritePath:
|
rewritePath:
|
||||||
@@ -188,7 +154,7 @@ export async function updatePublicResources(
|
|||||||
.insert(targetHealthCheck)
|
.insert(targetHealthCheck)
|
||||||
.values({
|
.values({
|
||||||
name: `${targetData.hostname}:${targetData.port}`,
|
name: `${targetData.hostname}:${targetData.port}`,
|
||||||
siteId: site.siteId!,
|
siteId: site.siteId,
|
||||||
targetId: newTarget.targetId,
|
targetId: newTarget.targetId,
|
||||||
orgId: orgId,
|
orgId: orgId,
|
||||||
hcEnabled: healthcheckData?.enabled || false,
|
hcEnabled: healthcheckData?.enabled || false,
|
||||||
@@ -246,10 +212,7 @@ export async function updatePublicResources(
|
|||||||
const resourceEnabled =
|
const resourceEnabled =
|
||||||
resourceData.enabled == undefined || resourceData.enabled == null
|
resourceData.enabled == undefined || resourceData.enabled == null
|
||||||
? true
|
? true
|
||||||
: resourceStatusFromSite === "pending"
|
: resourceData.enabled;
|
||||||
? false
|
|
||||||
: resourceData.enabled;
|
|
||||||
|
|
||||||
const resourceSsl =
|
const resourceSsl =
|
||||||
resourceData.ssl == undefined || resourceData.ssl == null
|
resourceData.ssl == undefined || resourceData.ssl == null
|
||||||
? true
|
? true
|
||||||
@@ -259,59 +222,17 @@ export async function updatePublicResources(
|
|||||||
headers = JSON.stringify(resourceData.headers);
|
headers = JSON.stringify(resourceData.headers);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (["ssh", "rdp", "vnc"].includes(resourceData.mode || "")) {
|
|
||||||
const isLicensed = await isLicensedOrSubscribed(
|
|
||||||
orgId,
|
|
||||||
tierMatrix.advancedPublicResources
|
|
||||||
);
|
|
||||||
if (!isLicensed) {
|
|
||||||
throw new Error(
|
|
||||||
"Your current subscription does not support browser gateway resources. Please upgrade to access this feature."
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (resourceData.policy) {
|
|
||||||
const isLicensed = await isLicensedOrSubscribed(
|
|
||||||
orgId,
|
|
||||||
tierMatrix.resourcePolicies
|
|
||||||
);
|
|
||||||
if (!isLicensed) {
|
|
||||||
throw new Error(
|
|
||||||
"Your current subscription does not support shared resource policies. Please upgrade to access this feature."
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (existingResource) {
|
if (existingResource) {
|
||||||
let domain;
|
let domain;
|
||||||
if (
|
if (
|
||||||
["http", "ssh", "rdp", "vnc"].includes(resourceData.mode || "")
|
["http", "ssh", "rdp", "vnc"].includes(resourceData.mode || "")
|
||||||
) {
|
) {
|
||||||
if (resourceData["full-domain"]?.startsWith("*.")) {
|
|
||||||
const isLicensed = await isLicensedOrSubscribed(
|
|
||||||
orgId,
|
|
||||||
tierMatrix.wildcardSubdomain
|
|
||||||
);
|
|
||||||
if (!isLicensed) {
|
|
||||||
throw new Error(
|
|
||||||
"Wildcard subdomains are not supported on your current plan. Please upgrade to access this feature."
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
domain = await getDomain(
|
domain = await getDomain(
|
||||||
existingResource.resourceId,
|
existingResource.resourceId,
|
||||||
resourceData["full-domain"]!,
|
resourceData["full-domain"]!,
|
||||||
orgId,
|
orgId,
|
||||||
trx
|
trx
|
||||||
);
|
);
|
||||||
|
|
||||||
await enforceDomainNamespacePaywall(
|
|
||||||
orgId,
|
|
||||||
domain.domainId,
|
|
||||||
trx
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// check if the only key in the resource is targets, if so, skip the update
|
// check if the only key in the resource is targets, if so, skip the update
|
||||||
@@ -425,8 +346,7 @@ export async function updatePublicResources(
|
|||||||
? (resourceData["proxy-protocol-version"] ??
|
? (resourceData["proxy-protocol-version"] ??
|
||||||
1)
|
1)
|
||||||
: 1,
|
: 1,
|
||||||
resourcePolicyId: sharedPolicy.resourcePolicyId,
|
resourcePolicyId: sharedPolicy.resourcePolicyId
|
||||||
status: resourceStatusFromSite
|
|
||||||
})
|
})
|
||||||
.where(
|
.where(
|
||||||
eq(
|
eq(
|
||||||
@@ -602,16 +522,8 @@ export async function updatePublicResources(
|
|||||||
? (resourceData["proxy-protocol-version"] ??
|
? (resourceData["proxy-protocol-version"] ??
|
||||||
1)
|
1)
|
||||||
: 1,
|
: 1,
|
||||||
pamMode:
|
|
||||||
resourceData["auth-daemon"]?.pam ||
|
|
||||||
"passthrough",
|
|
||||||
authDaemonMode:
|
|
||||||
resourceData["auth-daemon"]?.mode || "native",
|
|
||||||
authDaemonPort:
|
|
||||||
resourceData["auth-daemon"]?.port || 22123,
|
|
||||||
resourcePolicyId: null,
|
resourcePolicyId: null,
|
||||||
defaultResourcePolicyId: inlinePolicyId,
|
defaultResourcePolicyId: inlinePolicyId
|
||||||
status: resourceStatusFromSite
|
|
||||||
})
|
})
|
||||||
.where(
|
.where(
|
||||||
eq(
|
eq(
|
||||||
@@ -752,8 +664,7 @@ export async function updatePublicResources(
|
|||||||
? "/"
|
? "/"
|
||||||
: undefined),
|
: undefined),
|
||||||
rewritePathType: targetData["rewrite-match"],
|
rewritePathType: targetData["rewrite-match"],
|
||||||
priority: targetData.priority,
|
priority: targetData.priority
|
||||||
mode: resourceData.mode
|
|
||||||
})
|
})
|
||||||
.where(eq(targets.targetId, existingTarget.targetId))
|
.where(eq(targets.targetId, existingTarget.targetId))
|
||||||
.returning();
|
.returning();
|
||||||
@@ -928,7 +839,7 @@ export async function updatePublicResources(
|
|||||||
.update(resourceRules)
|
.update(resourceRules)
|
||||||
.set({
|
.set({
|
||||||
action: getRuleAction(rule.action),
|
action: getRuleAction(rule.action),
|
||||||
match: rule.match.toUpperCase() as ResourceRule["match"],
|
match: rule.match.toUpperCase(),
|
||||||
value: getRuleValue(
|
value: getRuleValue(
|
||||||
rule.match.toUpperCase(),
|
rule.match.toUpperCase(),
|
||||||
rule.value
|
rule.value
|
||||||
@@ -947,7 +858,7 @@ export async function updatePublicResources(
|
|||||||
await trx.insert(resourceRules).values({
|
await trx.insert(resourceRules).values({
|
||||||
resourceId: existingResource.resourceId,
|
resourceId: existingResource.resourceId,
|
||||||
action: getRuleAction(rule.action),
|
action: getRuleAction(rule.action),
|
||||||
match: rule.match.toUpperCase() as ResourceRule["match"],
|
match: rule.match.toUpperCase(),
|
||||||
value: getRuleValue(
|
value: getRuleValue(
|
||||||
rule.match.toUpperCase(),
|
rule.match.toUpperCase(),
|
||||||
rule.value
|
rule.value
|
||||||
@@ -969,45 +880,7 @@ export async function updatePublicResources(
|
|||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// INLINE POLICY MODE: sync rules into policy-level table
|
// INLINE POLICY MODE: sync rules into policy-level table
|
||||||
let inlinePolicyId = resource!.defaultResourcePolicyId;
|
const inlinePolicyId = resource!.defaultResourcePolicyId!;
|
||||||
|
|
||||||
// Targets-only updates skip the auth/policy update branch above,
|
|
||||||
// so pre-1.19 resources can still have no inline policy linked.
|
|
||||||
if (!inlinePolicyId) {
|
|
||||||
const [adminRole] = await trx
|
|
||||||
.select()
|
|
||||||
.from(roles)
|
|
||||||
.where(
|
|
||||||
and(eq(roles.isAdmin, true), eq(roles.orgId, orgId))
|
|
||||||
)
|
|
||||||
.limit(1);
|
|
||||||
|
|
||||||
if (!adminRole) {
|
|
||||||
throw new Error(`Admin role not found`);
|
|
||||||
}
|
|
||||||
|
|
||||||
inlinePolicyId = await ensureInlinePolicy(
|
|
||||||
existingResource.defaultResourcePolicyId,
|
|
||||||
orgId,
|
|
||||||
resourceNiceId,
|
|
||||||
adminRole.roleId,
|
|
||||||
trx
|
|
||||||
);
|
|
||||||
|
|
||||||
[resource] = await trx
|
|
||||||
.update(resources)
|
|
||||||
.set({
|
|
||||||
resourcePolicyId: null,
|
|
||||||
defaultResourcePolicyId: inlinePolicyId
|
|
||||||
})
|
|
||||||
.where(
|
|
||||||
eq(
|
|
||||||
resources.resourceId,
|
|
||||||
existingResource.resourceId
|
|
||||||
)
|
|
||||||
)
|
|
||||||
.returning();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Clear the old resource-level rules table
|
// Clear the old resource-level rules table
|
||||||
await trx
|
await trx
|
||||||
@@ -1029,61 +902,16 @@ export async function updatePublicResources(
|
|||||||
logger.debug(`Updated resource ${existingResource.resourceId}`);
|
logger.debug(`Updated resource ${existingResource.resourceId}`);
|
||||||
} else {
|
} else {
|
||||||
// create a brand new resource
|
// create a brand new resource
|
||||||
|
|
||||||
if (build === "saas") {
|
|
||||||
const usage = await usageService.getUsage(
|
|
||||||
orgId,
|
|
||||||
LimitId.PUBLIC_RESOURCES
|
|
||||||
);
|
|
||||||
if (!usage) {
|
|
||||||
throw new Error(
|
|
||||||
`Usage data not found for org ${orgId} and limit ${LimitId.PUBLIC_RESOURCES}`
|
|
||||||
);
|
|
||||||
}
|
|
||||||
const rejectResource = await usageService.checkLimitSet(
|
|
||||||
orgId,
|
|
||||||
|
|
||||||
LimitId.PUBLIC_RESOURCES,
|
|
||||||
{
|
|
||||||
...usage,
|
|
||||||
instantaneousValue: (usage.instantaneousValue || 0) + 1
|
|
||||||
} // We need to add one to know if we are violating the limit
|
|
||||||
);
|
|
||||||
if (rejectResource) {
|
|
||||||
throw new Error(
|
|
||||||
"Public resource limit exceeded. Please upgrade your plan."
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let domain;
|
let domain;
|
||||||
if (
|
if (
|
||||||
["http", "ssh", "rdp", "vnc"].includes(resourceData.mode || "")
|
["http", "ssh", "rdp", "vnc"].includes(resourceData.mode || "")
|
||||||
) {
|
) {
|
||||||
if (resourceData["full-domain"]?.startsWith("*.")) {
|
|
||||||
const isLicensed = await isLicensedOrSubscribed(
|
|
||||||
orgId,
|
|
||||||
tierMatrix.wildcardSubdomain
|
|
||||||
);
|
|
||||||
if (!isLicensed) {
|
|
||||||
throw new Error(
|
|
||||||
"Wildcard subdomains are not supported on your current plan. Please upgrade to access this feature."
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
domain = await getDomain(
|
domain = await getDomain(
|
||||||
undefined,
|
undefined,
|
||||||
resourceData["full-domain"]!,
|
resourceData["full-domain"]!,
|
||||||
orgId,
|
orgId,
|
||||||
trx
|
trx
|
||||||
);
|
);
|
||||||
|
|
||||||
await enforceDomainNamespacePaywall(
|
|
||||||
orgId,
|
|
||||||
domain.domainId,
|
|
||||||
trx
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const isLicensed = await isLicensedOrSubscribed(
|
const isLicensed = await isLicensedOrSubscribed(
|
||||||
@@ -1152,7 +980,6 @@ export async function updatePublicResources(
|
|||||||
.values({
|
.values({
|
||||||
orgId,
|
orgId,
|
||||||
niceId: resourceNiceId,
|
niceId: resourceNiceId,
|
||||||
status: resourceStatusFromSite,
|
|
||||||
name: resourceData.name || "Unnamed Resource",
|
name: resourceData.name || "Unnamed Resource",
|
||||||
mode: resourceData.mode,
|
mode: resourceData.mode,
|
||||||
proxyPort: ["http", "ssh", "rdp", "vnc"].includes(
|
proxyPort: ["http", "ssh", "rdp", "vnc"].includes(
|
||||||
@@ -1312,7 +1139,7 @@ export async function updatePublicResources(
|
|||||||
await trx.insert(resourceRules).values({
|
await trx.insert(resourceRules).values({
|
||||||
resourceId: newResource.resourceId,
|
resourceId: newResource.resourceId,
|
||||||
action: getRuleAction(rule.action),
|
action: getRuleAction(rule.action),
|
||||||
match: rule.match.toUpperCase() as ResourceRule["match"],
|
match: rule.match.toUpperCase(),
|
||||||
value: getRuleValue(
|
value: getRuleValue(
|
||||||
rule.match.toUpperCase(),
|
rule.match.toUpperCase(),
|
||||||
rule.value
|
rule.value
|
||||||
@@ -1346,8 +1173,6 @@ export async function updatePublicResources(
|
|||||||
await createTarget(newResource.resourceId, targetData);
|
await createTarget(newResource.resourceId, targetData);
|
||||||
}
|
}
|
||||||
|
|
||||||
await usageService.add(orgId, LimitId.PUBLIC_RESOURCES, 1, trx);
|
|
||||||
|
|
||||||
logger.debug(`Created resource ${newResource.resourceId}`);
|
logger.debug(`Created resource ${newResource.resourceId}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1375,7 +1200,7 @@ function getRuleAction(input: string) {
|
|||||||
|
|
||||||
function getRuleValue(match: string, value: string) {
|
function getRuleValue(match: string, value: string) {
|
||||||
// if the match is a country, uppercase the value
|
// if the match is a country, uppercase the value
|
||||||
if (match === "COUNTRY" || match === "COUNTRY_IS_NOT") {
|
if (match == "COUNTRY") {
|
||||||
return value.toUpperCase();
|
return value.toUpperCase();
|
||||||
}
|
}
|
||||||
return value;
|
return value;
|
||||||
@@ -1559,6 +1384,17 @@ async function syncWhitelistUsers(
|
|||||||
.where(eq(resourceWhitelist.resourceId, resourceId));
|
.where(eq(resourceWhitelist.resourceId, resourceId));
|
||||||
|
|
||||||
for (const email of whitelistUsers) {
|
for (const email of whitelistUsers) {
|
||||||
|
const [user] = await trx
|
||||||
|
.select()
|
||||||
|
.from(users)
|
||||||
|
.innerJoin(userOrgs, eq(users.userId, userOrgs.userId))
|
||||||
|
.where(and(eq(users.email, email), eq(userOrgs.orgId, orgId)))
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
throw new Error(`User not found: ${email} in org ${orgId}`);
|
||||||
|
}
|
||||||
|
|
||||||
const existingWhitelistEntry = existingWhitelist.find(
|
const existingWhitelistEntry = existingWhitelist.find(
|
||||||
(w) => w.email === email
|
(w) => w.email === email
|
||||||
);
|
);
|
||||||
@@ -2030,37 +1866,6 @@ function checkIfTargetChanged(
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function enforceDomainNamespacePaywall(
|
|
||||||
orgId: string,
|
|
||||||
domainId: string,
|
|
||||||
trx: Transaction
|
|
||||||
) {
|
|
||||||
if (build !== "saas") {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const hasDomainNamespaceAccess = await isLicensedOrSubscribed(
|
|
||||||
orgId,
|
|
||||||
tierMatrix.domainNamespaces
|
|
||||||
);
|
|
||||||
|
|
||||||
if (hasDomainNamespaceAccess) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const [namespaceDomain] = await trx
|
|
||||||
.select()
|
|
||||||
.from(domainNamespaces)
|
|
||||||
.where(eq(domainNamespaces.domainId, domainId))
|
|
||||||
.limit(1);
|
|
||||||
|
|
||||||
if (namespaceDomain) {
|
|
||||||
throw new Error(
|
|
||||||
"Your current subscription does not support custom domain namespaces. Please upgrade to access this feature."
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function getDomain(
|
export async function getDomain(
|
||||||
resourceId: number | undefined,
|
resourceId: number | undefined,
|
||||||
fullDomain: string,
|
fullDomain: string,
|
||||||
@@ -1,654 +0,0 @@
|
|||||||
import {
|
|
||||||
db,
|
|
||||||
idp,
|
|
||||||
idpOrg,
|
|
||||||
resourcePolicies,
|
|
||||||
resourcePolicyHeaderAuth,
|
|
||||||
resourcePolicyPassword,
|
|
||||||
resourcePolicyPincode,
|
|
||||||
resourcePolicyRules,
|
|
||||||
resourcePolicyWhiteList,
|
|
||||||
rolePolicies,
|
|
||||||
roles,
|
|
||||||
Transaction,
|
|
||||||
userOrgs,
|
|
||||||
userPolicies,
|
|
||||||
users
|
|
||||||
} from "@server/db";
|
|
||||||
import { eq, and, or } from "drizzle-orm";
|
|
||||||
import { Config, ResourcePolicyData } from "./types";
|
|
||||||
import logger from "@server/logger";
|
|
||||||
import { getUniqueResourcePolicyName } from "@server/db/names";
|
|
||||||
import { hashPassword } from "@server/auth/password";
|
|
||||||
import { isValidCIDR, isValidIP, isValidUrlGlobPattern } from "../validators";
|
|
||||||
import { isLicensedOrSubscribed } from "#dynamic/lib/isLicencedOrSubscribed";
|
|
||||||
import { tierMatrix } from "../billing/tierMatrix";
|
|
||||||
|
|
||||||
export type ResourcePoliciesResults = {
|
|
||||||
resourcePolicyId: number;
|
|
||||||
niceId: string;
|
|
||||||
}[];
|
|
||||||
|
|
||||||
export async function updateResourcePolicies(
|
|
||||||
orgId: string,
|
|
||||||
config: Config,
|
|
||||||
trx: Transaction
|
|
||||||
): Promise<ResourcePoliciesResults> {
|
|
||||||
const results: ResourcePoliciesResults = [];
|
|
||||||
|
|
||||||
for (const [policyNiceId, policyData] of Object.entries(
|
|
||||||
config["public-policies"]
|
|
||||||
)) {
|
|
||||||
const isLicensed = await isLicensedOrSubscribed(
|
|
||||||
orgId,
|
|
||||||
tierMatrix.resourcePolicies
|
|
||||||
);
|
|
||||||
if (!isLicensed) {
|
|
||||||
throw new Error(
|
|
||||||
"Your current subscription does not support shared resource policies. Please upgrade to access this feature."
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Validate rules
|
|
||||||
for (const rule of policyData.rules) {
|
|
||||||
if (rule.match === "cidr" && !isValidCIDR(rule.value)) {
|
|
||||||
throw new Error(
|
|
||||||
`Invalid CIDR provided in resource policy '${policyNiceId}': ${rule.value}`
|
|
||||||
);
|
|
||||||
} else if (rule.match === "ip" && !isValidIP(rule.value)) {
|
|
||||||
throw new Error(
|
|
||||||
`Invalid IP provided in resource policy '${policyNiceId}': ${rule.value}`
|
|
||||||
);
|
|
||||||
} else if (
|
|
||||||
rule.match === "path" &&
|
|
||||||
!isValidUrlGlobPattern(rule.value)
|
|
||||||
) {
|
|
||||||
throw new Error(
|
|
||||||
`Invalid URL glob pattern provided in resource policy '${policyNiceId}': ${rule.value}`
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Validate auto-login-idp if provided
|
|
||||||
if (policyData["auto-login-idp"]) {
|
|
||||||
const [provider] = await trx
|
|
||||||
.select()
|
|
||||||
.from(idp)
|
|
||||||
.innerJoin(idpOrg, eq(idpOrg.idpId, idp.idpId))
|
|
||||||
.where(
|
|
||||||
and(
|
|
||||||
eq(idp.idpId, policyData["auto-login-idp"]),
|
|
||||||
eq(idpOrg.orgId, orgId)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
.limit(1);
|
|
||||||
|
|
||||||
if (!provider) {
|
|
||||||
throw new Error(
|
|
||||||
`Identity provider not found for policy '${policyNiceId}' in this organization`
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Look up the admin role
|
|
||||||
const [adminRole] = await trx
|
|
||||||
.select()
|
|
||||||
.from(roles)
|
|
||||||
.where(and(eq(roles.isAdmin, true), eq(roles.orgId, orgId)))
|
|
||||||
.limit(1);
|
|
||||||
|
|
||||||
if (!adminRole) {
|
|
||||||
throw new Error("Admin role not found");
|
|
||||||
}
|
|
||||||
|
|
||||||
// Find existing policy by niceId and orgId
|
|
||||||
const [existingPolicy] = await trx
|
|
||||||
.select()
|
|
||||||
.from(resourcePolicies)
|
|
||||||
.where(
|
|
||||||
and(
|
|
||||||
eq(resourcePolicies.niceId, policyNiceId),
|
|
||||||
eq(resourcePolicies.orgId, orgId)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
.limit(1);
|
|
||||||
|
|
||||||
let resourcePolicyId: number;
|
|
||||||
|
|
||||||
if (existingPolicy) {
|
|
||||||
// Update the existing policy
|
|
||||||
await trx
|
|
||||||
.update(resourcePolicies)
|
|
||||||
.set({
|
|
||||||
name: policyData.name,
|
|
||||||
sso: policyData.sso ?? true,
|
|
||||||
idpId: policyData["auto-login-idp"] ?? null,
|
|
||||||
emailWhitelistEnabled:
|
|
||||||
policyData["email-whitelist-enabled"] ??
|
|
||||||
policyData["whitelist-users"].length > 0,
|
|
||||||
applyRules:
|
|
||||||
policyData["apply-rules"] || policyData.rules.length > 0
|
|
||||||
})
|
|
||||||
.where(
|
|
||||||
eq(
|
|
||||||
resourcePolicies.resourcePolicyId,
|
|
||||||
existingPolicy.resourcePolicyId
|
|
||||||
)
|
|
||||||
);
|
|
||||||
|
|
||||||
resourcePolicyId = existingPolicy.resourcePolicyId;
|
|
||||||
|
|
||||||
// Sync password
|
|
||||||
await trx
|
|
||||||
.delete(resourcePolicyPassword)
|
|
||||||
.where(
|
|
||||||
eq(
|
|
||||||
resourcePolicyPassword.resourcePolicyId,
|
|
||||||
resourcePolicyId
|
|
||||||
)
|
|
||||||
);
|
|
||||||
if (policyData.password) {
|
|
||||||
const passwordHash = await hashPassword(policyData.password);
|
|
||||||
await trx.insert(resourcePolicyPassword).values({
|
|
||||||
resourcePolicyId,
|
|
||||||
passwordHash
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Sync pincode
|
|
||||||
await trx
|
|
||||||
.delete(resourcePolicyPincode)
|
|
||||||
.where(
|
|
||||||
eq(resourcePolicyPincode.resourcePolicyId, resourcePolicyId)
|
|
||||||
);
|
|
||||||
if (policyData.pincode) {
|
|
||||||
const pincodeHash = await hashPassword(policyData.pincode);
|
|
||||||
await trx.insert(resourcePolicyPincode).values({
|
|
||||||
resourcePolicyId,
|
|
||||||
pincodeHash,
|
|
||||||
digitLength: 6
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Sync header auth
|
|
||||||
await trx
|
|
||||||
.delete(resourcePolicyHeaderAuth)
|
|
||||||
.where(
|
|
||||||
eq(
|
|
||||||
resourcePolicyHeaderAuth.resourcePolicyId,
|
|
||||||
resourcePolicyId
|
|
||||||
)
|
|
||||||
);
|
|
||||||
if (policyData["basic-auth"]) {
|
|
||||||
const basicAuth = policyData["basic-auth"];
|
|
||||||
const headerAuthHash = await hashPassword(
|
|
||||||
Buffer.from(
|
|
||||||
`${basicAuth.user}:${basicAuth.password}`
|
|
||||||
).toString("base64")
|
|
||||||
);
|
|
||||||
await trx.insert(resourcePolicyHeaderAuth).values({
|
|
||||||
resourcePolicyId,
|
|
||||||
headerAuthHash,
|
|
||||||
extendedCompatibility:
|
|
||||||
basicAuth["extended-compatibility"] ?? true
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Sync SSO roles
|
|
||||||
await syncRolePolicies(
|
|
||||||
resourcePolicyId,
|
|
||||||
policyData["sso-roles"],
|
|
||||||
orgId,
|
|
||||||
adminRole.roleId,
|
|
||||||
trx
|
|
||||||
);
|
|
||||||
|
|
||||||
// Sync SSO users
|
|
||||||
await syncUserPolicies(
|
|
||||||
resourcePolicyId,
|
|
||||||
policyData["sso-users"],
|
|
||||||
orgId,
|
|
||||||
trx
|
|
||||||
);
|
|
||||||
|
|
||||||
// Sync whitelist users
|
|
||||||
await syncWhitelistPolicyUsers(
|
|
||||||
resourcePolicyId,
|
|
||||||
policyData["whitelist-users"],
|
|
||||||
trx
|
|
||||||
);
|
|
||||||
|
|
||||||
// Sync rules
|
|
||||||
await syncPolicyRules(resourcePolicyId, policyData.rules, trx);
|
|
||||||
|
|
||||||
logger.debug(
|
|
||||||
`Updated resource policy ${resourcePolicyId} (${policyNiceId})`
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
// Create a new policy
|
|
||||||
const [newPolicy] = await trx
|
|
||||||
.insert(resourcePolicies)
|
|
||||||
.values({
|
|
||||||
niceId: policyNiceId,
|
|
||||||
orgId,
|
|
||||||
name: policyData.name,
|
|
||||||
sso: policyData.sso ?? true,
|
|
||||||
idpId: policyData["auto-login-idp"] ?? null,
|
|
||||||
emailWhitelistEnabled:
|
|
||||||
policyData["email-whitelist-enabled"] ??
|
|
||||||
policyData["whitelist-users"].length > 0,
|
|
||||||
applyRules:
|
|
||||||
policyData["apply-rules"] ||
|
|
||||||
policyData.rules.length > 0,
|
|
||||||
scope: "global"
|
|
||||||
})
|
|
||||||
.returning();
|
|
||||||
|
|
||||||
resourcePolicyId = newPolicy.resourcePolicyId;
|
|
||||||
|
|
||||||
// Always add admin role
|
|
||||||
await trx.insert(rolePolicies).values({
|
|
||||||
roleId: adminRole.roleId,
|
|
||||||
resourcePolicyId
|
|
||||||
});
|
|
||||||
|
|
||||||
// Add SSO roles
|
|
||||||
await addRolePolicies(
|
|
||||||
resourcePolicyId,
|
|
||||||
policyData["sso-roles"],
|
|
||||||
orgId,
|
|
||||||
adminRole.roleId,
|
|
||||||
trx
|
|
||||||
);
|
|
||||||
|
|
||||||
// Add SSO users
|
|
||||||
await addUserPolicies(
|
|
||||||
resourcePolicyId,
|
|
||||||
policyData["sso-users"],
|
|
||||||
orgId,
|
|
||||||
trx
|
|
||||||
);
|
|
||||||
|
|
||||||
// Add password
|
|
||||||
if (policyData.password) {
|
|
||||||
const passwordHash = await hashPassword(policyData.password);
|
|
||||||
await trx.insert(resourcePolicyPassword).values({
|
|
||||||
resourcePolicyId,
|
|
||||||
passwordHash
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add pincode
|
|
||||||
if (policyData.pincode) {
|
|
||||||
const pincodeHash = await hashPassword(policyData.pincode);
|
|
||||||
await trx.insert(resourcePolicyPincode).values({
|
|
||||||
resourcePolicyId,
|
|
||||||
pincodeHash,
|
|
||||||
digitLength: 6
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add header auth
|
|
||||||
if (policyData["basic-auth"]) {
|
|
||||||
const basicAuth = policyData["basic-auth"];
|
|
||||||
const headerAuthHash = await hashPassword(
|
|
||||||
Buffer.from(
|
|
||||||
`${basicAuth.user}:${basicAuth.password}`
|
|
||||||
).toString("base64")
|
|
||||||
);
|
|
||||||
await trx.insert(resourcePolicyHeaderAuth).values({
|
|
||||||
resourcePolicyId,
|
|
||||||
headerAuthHash,
|
|
||||||
extendedCompatibility:
|
|
||||||
basicAuth["extended-compatibility"] ?? true
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add whitelist users
|
|
||||||
if (policyData["whitelist-users"].length > 0) {
|
|
||||||
await trx.insert(resourcePolicyWhiteList).values(
|
|
||||||
policyData["whitelist-users"].map((email) => ({
|
|
||||||
email,
|
|
||||||
resourcePolicyId
|
|
||||||
}))
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add rules
|
|
||||||
if (policyData.rules.length > 0) {
|
|
||||||
await trx.insert(resourcePolicyRules).values(
|
|
||||||
policyData.rules.map((rule, index) => ({
|
|
||||||
resourcePolicyId,
|
|
||||||
action: getRuleAction(rule.action),
|
|
||||||
match: getRuleMatch(rule.match),
|
|
||||||
value: rule.value,
|
|
||||||
priority: rule.priority ?? index + 1,
|
|
||||||
enabled: rule.enabled ?? true
|
|
||||||
}))
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
logger.debug(
|
|
||||||
`Created resource policy ${resourcePolicyId} (${policyNiceId})`
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
results.push({ resourcePolicyId, niceId: policyNiceId });
|
|
||||||
}
|
|
||||||
|
|
||||||
return results;
|
|
||||||
}
|
|
||||||
|
|
||||||
function getRuleAction(input: string): "ACCEPT" | "DROP" | "PASS" {
|
|
||||||
if (input === "allow") return "ACCEPT";
|
|
||||||
if (input === "deny") return "DROP";
|
|
||||||
return "PASS";
|
|
||||||
}
|
|
||||||
|
|
||||||
function getRuleMatch(
|
|
||||||
input: string
|
|
||||||
): "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(
|
|
||||||
policyId: number,
|
|
||||||
ssoRoles: string[],
|
|
||||||
orgId: string,
|
|
||||||
adminRoleId: number,
|
|
||||||
trx: Transaction
|
|
||||||
) {
|
|
||||||
const existingRolePolicies = await trx
|
|
||||||
.select()
|
|
||||||
.from(rolePolicies)
|
|
||||||
.where(eq(rolePolicies.resourcePolicyId, policyId));
|
|
||||||
|
|
||||||
for (const roleName of ssoRoles) {
|
|
||||||
const [role] = await trx
|
|
||||||
.select()
|
|
||||||
.from(roles)
|
|
||||||
.where(and(eq(roles.name, roleName), eq(roles.orgId, orgId)))
|
|
||||||
.limit(1);
|
|
||||||
|
|
||||||
if (!role) {
|
|
||||||
logger.warn(
|
|
||||||
`Role '${roleName}' not found in org '${orgId}', skipping`
|
|
||||||
);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (role.isAdmin) {
|
|
||||||
continue; // admin role is always included, skip
|
|
||||||
}
|
|
||||||
|
|
||||||
const alreadyExists = existingRolePolicies.some(
|
|
||||||
(rp) => rp.roleId === role.roleId
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!alreadyExists) {
|
|
||||||
await trx.insert(rolePolicies).values({
|
|
||||||
roleId: role.roleId,
|
|
||||||
resourcePolicyId: policyId
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Remove roles no longer in the list (except admin)
|
|
||||||
for (const existingRolePolicy of existingRolePolicies) {
|
|
||||||
if (existingRolePolicy.roleId === adminRoleId) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
const [role] = await trx
|
|
||||||
.select()
|
|
||||||
.from(roles)
|
|
||||||
.where(eq(roles.roleId, existingRolePolicy.roleId))
|
|
||||||
.limit(1);
|
|
||||||
|
|
||||||
if (role?.isAdmin) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (role && !ssoRoles.includes(role.name)) {
|
|
||||||
await trx
|
|
||||||
.delete(rolePolicies)
|
|
||||||
.where(
|
|
||||||
and(
|
|
||||||
eq(rolePolicies.resourcePolicyId, policyId),
|
|
||||||
eq(rolePolicies.roleId, existingRolePolicy.roleId)
|
|
||||||
)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function addRolePolicies(
|
|
||||||
policyId: number,
|
|
||||||
ssoRoles: string[],
|
|
||||||
orgId: string,
|
|
||||||
adminRoleId: number,
|
|
||||||
trx: Transaction
|
|
||||||
) {
|
|
||||||
for (const roleName of ssoRoles) {
|
|
||||||
const [role] = await trx
|
|
||||||
.select()
|
|
||||||
.from(roles)
|
|
||||||
.where(and(eq(roles.name, roleName), eq(roles.orgId, orgId)))
|
|
||||||
.limit(1);
|
|
||||||
|
|
||||||
if (!role) {
|
|
||||||
logger.warn(
|
|
||||||
`Role '${roleName}' not found in org '${orgId}', skipping`
|
|
||||||
);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (role.isAdmin) {
|
|
||||||
continue; // admin already added
|
|
||||||
}
|
|
||||||
|
|
||||||
await trx.insert(rolePolicies).values({
|
|
||||||
roleId: role.roleId,
|
|
||||||
resourcePolicyId: policyId
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function syncUserPolicies(
|
|
||||||
policyId: number,
|
|
||||||
ssoUsers: string[],
|
|
||||||
orgId: string,
|
|
||||||
trx: Transaction
|
|
||||||
) {
|
|
||||||
const existingUserPolicies = await trx
|
|
||||||
.select()
|
|
||||||
.from(userPolicies)
|
|
||||||
.where(eq(userPolicies.resourcePolicyId, policyId));
|
|
||||||
|
|
||||||
for (const username of ssoUsers) {
|
|
||||||
const [user] = await trx
|
|
||||||
.select()
|
|
||||||
.from(users)
|
|
||||||
.innerJoin(userOrgs, eq(users.userId, userOrgs.userId))
|
|
||||||
.where(
|
|
||||||
and(
|
|
||||||
or(eq(users.username, username), eq(users.email, username)),
|
|
||||||
eq(userOrgs.orgId, orgId)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
.limit(1);
|
|
||||||
|
|
||||||
if (!user) {
|
|
||||||
logger.warn(
|
|
||||||
`User '${username}' not found in org '${orgId}', skipping`
|
|
||||||
);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
const alreadyExists = existingUserPolicies.some(
|
|
||||||
(up) => up.userId === user.user.userId
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!alreadyExists) {
|
|
||||||
await trx.insert(userPolicies).values({
|
|
||||||
userId: user.user.userId,
|
|
||||||
resourcePolicyId: policyId
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Remove users no longer in the list
|
|
||||||
for (const existingUserPolicy of existingUserPolicies) {
|
|
||||||
const [user] = await trx
|
|
||||||
.select()
|
|
||||||
.from(users)
|
|
||||||
.innerJoin(userOrgs, eq(users.userId, userOrgs.userId))
|
|
||||||
.where(
|
|
||||||
and(
|
|
||||||
eq(users.userId, existingUserPolicy.userId),
|
|
||||||
eq(userOrgs.orgId, orgId)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
.limit(1);
|
|
||||||
|
|
||||||
if (
|
|
||||||
user &&
|
|
||||||
user.user.username &&
|
|
||||||
!ssoUsers.includes(user.user.username) &&
|
|
||||||
!ssoUsers.includes(user.user.email ?? "")
|
|
||||||
) {
|
|
||||||
await trx
|
|
||||||
.delete(userPolicies)
|
|
||||||
.where(
|
|
||||||
and(
|
|
||||||
eq(userPolicies.resourcePolicyId, policyId),
|
|
||||||
eq(userPolicies.userId, existingUserPolicy.userId)
|
|
||||||
)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function addUserPolicies(
|
|
||||||
policyId: number,
|
|
||||||
ssoUsers: string[],
|
|
||||||
orgId: string,
|
|
||||||
trx: Transaction
|
|
||||||
) {
|
|
||||||
for (const username of ssoUsers) {
|
|
||||||
const [user] = await trx
|
|
||||||
.select()
|
|
||||||
.from(users)
|
|
||||||
.innerJoin(userOrgs, eq(users.userId, userOrgs.userId))
|
|
||||||
.where(
|
|
||||||
and(
|
|
||||||
or(eq(users.username, username), eq(users.email, username)),
|
|
||||||
eq(userOrgs.orgId, orgId)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
.limit(1);
|
|
||||||
|
|
||||||
if (!user) {
|
|
||||||
logger.warn(
|
|
||||||
`User '${username}' not found in org '${orgId}', skipping`
|
|
||||||
);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
await trx.insert(userPolicies).values({
|
|
||||||
userId: user.user.userId,
|
|
||||||
resourcePolicyId: policyId
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function syncWhitelistPolicyUsers(
|
|
||||||
policyId: number,
|
|
||||||
whitelistUsers: string[],
|
|
||||||
trx: Transaction
|
|
||||||
) {
|
|
||||||
const existingWhitelist = await trx
|
|
||||||
.select()
|
|
||||||
.from(resourcePolicyWhiteList)
|
|
||||||
.where(eq(resourcePolicyWhiteList.resourcePolicyId, policyId));
|
|
||||||
|
|
||||||
for (const email of whitelistUsers) {
|
|
||||||
const alreadyExists = existingWhitelist.some((w) => w.email === email);
|
|
||||||
|
|
||||||
if (!alreadyExists) {
|
|
||||||
await trx.insert(resourcePolicyWhiteList).values({
|
|
||||||
email,
|
|
||||||
resourcePolicyId: policyId
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const existingEntry of existingWhitelist) {
|
|
||||||
if (!whitelistUsers.includes(existingEntry.email)) {
|
|
||||||
await trx
|
|
||||||
.delete(resourcePolicyWhiteList)
|
|
||||||
.where(
|
|
||||||
and(
|
|
||||||
eq(resourcePolicyWhiteList.resourcePolicyId, policyId),
|
|
||||||
eq(resourcePolicyWhiteList.email, existingEntry.email)
|
|
||||||
)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function syncPolicyRules(
|
|
||||||
policyId: number,
|
|
||||||
rules: ResourcePolicyData["rules"],
|
|
||||||
trx: Transaction
|
|
||||||
) {
|
|
||||||
const existingRules = await trx
|
|
||||||
.select()
|
|
||||||
.from(resourcePolicyRules)
|
|
||||||
.where(eq(resourcePolicyRules.resourcePolicyId, policyId))
|
|
||||||
.orderBy(resourcePolicyRules.priority);
|
|
||||||
|
|
||||||
for (const [index, rule] of rules.entries()) {
|
|
||||||
const intendedPriority = rule.priority ?? index + 1;
|
|
||||||
const existingRule = existingRules[index];
|
|
||||||
|
|
||||||
if (existingRule) {
|
|
||||||
await trx
|
|
||||||
.update(resourcePolicyRules)
|
|
||||||
.set({
|
|
||||||
action: getRuleAction(rule.action),
|
|
||||||
match: getRuleMatch(rule.match),
|
|
||||||
value: rule.value,
|
|
||||||
priority: intendedPriority,
|
|
||||||
enabled: rule.enabled ?? true
|
|
||||||
})
|
|
||||||
.where(eq(resourcePolicyRules.ruleId, existingRule.ruleId));
|
|
||||||
} else {
|
|
||||||
await trx.insert(resourcePolicyRules).values({
|
|
||||||
resourcePolicyId: policyId,
|
|
||||||
action: getRuleAction(rule.action),
|
|
||||||
match: getRuleMatch(rule.match),
|
|
||||||
value: rule.value,
|
|
||||||
priority: intendedPriority,
|
|
||||||
enabled: rule.enabled ?? true
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Remove extra rules
|
|
||||||
if (existingRules.length > rules.length) {
|
|
||||||
const rulesToDelete = existingRules.slice(rules.length);
|
|
||||||
for (const rule of rulesToDelete) {
|
|
||||||
await trx
|
|
||||||
.delete(resourcePolicyRules)
|
|
||||||
.where(eq(resourcePolicyRules.ruleId, rule.ruleId));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,23 +1,8 @@
|
|||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
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 { wildcardSubdomainSchema } from "@server/lib/schemas";
|
import { wildcardSubdomainSchema } from "@server/lib/schemas";
|
||||||
import config from "@server/lib/config";
|
|
||||||
|
|
||||||
const maxmindDbPath = config.getRawConfig().server.maxmind_db_path;
|
|
||||||
const maxmindAsnPath = config.getRawConfig().server.maxmind_asn_path;
|
|
||||||
|
|
||||||
const hasMaxmindCountryDb =
|
|
||||||
typeof maxmindDbPath === "string" &&
|
|
||||||
maxmindDbPath.length > 0 &&
|
|
||||||
existsSync(maxmindDbPath);
|
|
||||||
|
|
||||||
const hasMaxmindAsnDb =
|
|
||||||
typeof maxmindAsnPath === "string" &&
|
|
||||||
maxmindAsnPath.length > 0 &&
|
|
||||||
existsSync(maxmindAsnPath);
|
|
||||||
|
|
||||||
export const SiteSchema = z.object({
|
export const SiteSchema = z.object({
|
||||||
name: z.string().min(1).max(100),
|
name: z.string().min(1).max(100),
|
||||||
@@ -98,8 +83,7 @@ export const RuleSchema = z
|
|||||||
action: z.enum(["allow", "deny", "pass"]),
|
action: z.enum(["allow", "deny", "pass"]),
|
||||||
match: z.enum(["cidr", "path", "ip", "country", "asn", "region"]),
|
match: z.enum(["cidr", "path", "ip", "country", "asn", "region"]),
|
||||||
value: z.coerce.string(),
|
value: z.coerce.string(),
|
||||||
priority: z.int().optional(),
|
priority: z.int().optional()
|
||||||
enabled: z.boolean().optional().default(true)
|
|
||||||
})
|
})
|
||||||
.refine(
|
.refine(
|
||||||
(rule) => {
|
(rule) => {
|
||||||
@@ -132,9 +116,6 @@ export const RuleSchema = z
|
|||||||
.refine(
|
.refine(
|
||||||
(rule) => {
|
(rule) => {
|
||||||
if (rule.match === "country") {
|
if (rule.match === "country") {
|
||||||
if (!hasMaxmindCountryDb) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
// Check if it's a valid 2-letter country code or "ALL"
|
// Check if it's a valid 2-letter country code or "ALL"
|
||||||
return /^[A-Z]{2}$/.test(rule.value) || rule.value === "ALL";
|
return /^[A-Z]{2}$/.test(rule.value) || rule.value === "ALL";
|
||||||
}
|
}
|
||||||
@@ -143,15 +124,12 @@ export const RuleSchema = z
|
|||||||
{
|
{
|
||||||
path: ["value"],
|
path: ["value"],
|
||||||
message:
|
message:
|
||||||
"Country rules require a valid existing server.maxmind_db_path and value must be a 2-letter country code or 'ALL'"
|
"Value must be a 2-letter country code or 'ALL' when match is 'country'"
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
.refine(
|
.refine(
|
||||||
(rule) => {
|
(rule) => {
|
||||||
if (rule.match === "asn") {
|
if (rule.match === "asn") {
|
||||||
if (!hasMaxmindCountryDb || !hasMaxmindAsnDb) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
// Check if it's either AS<number> format or "ALL"
|
// Check if it's either AS<number> format or "ALL"
|
||||||
const asNumberPattern = /^AS\d+$/i;
|
const asNumberPattern = /^AS\d+$/i;
|
||||||
return asNumberPattern.test(rule.value) || rule.value === "ALL";
|
return asNumberPattern.test(rule.value) || rule.value === "ALL";
|
||||||
@@ -161,7 +139,7 @@ export const RuleSchema = z
|
|||||||
{
|
{
|
||||||
path: ["value"],
|
path: ["value"],
|
||||||
message:
|
message:
|
||||||
"ASN rules require valid existing server.maxmind_db_path and server.maxmind_asn_path, and value must be 'AS<number>' format or 'ALL'"
|
"Value must be 'AS<number>' format or 'ALL' when match is 'asn'"
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
.refine(
|
.refine(
|
||||||
@@ -289,37 +267,8 @@ export const PublicResourceSchema = z
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
const effectiveProtocol = resource.mode ?? resource.protocol;
|
// If protocol/mode is http, it must have a full-domain
|
||||||
if (effectiveProtocol !== "ssh") {
|
if ((resource.mode ?? resource.protocol) === "http") {
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
const authDaemonMode = resource["auth-daemon"]?.mode;
|
|
||||||
if (authDaemonMode !== "native" && authDaemonMode !== "site") {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
resource.targets.filter((target) => target != null).length <= 1
|
|
||||||
);
|
|
||||||
},
|
|
||||||
{
|
|
||||||
path: ["targets"],
|
|
||||||
error: "When protocol is 'ssh' and auth-daemon mode is 'native' or 'site', only one target/site is allowed"
|
|
||||||
}
|
|
||||||
)
|
|
||||||
.refine(
|
|
||||||
(resource) => {
|
|
||||||
if (isTargetsOnlyResource(resource)) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
// If protocol/mode is http, ssh, rdp, or vnc, it must have a full-domain
|
|
||||||
const effectiveProtocol = resource.mode ?? resource.protocol;
|
|
||||||
if (
|
|
||||||
effectiveProtocol !== undefined &&
|
|
||||||
["http", "ssh", "rdp", "vnc"].includes(effectiveProtocol)
|
|
||||||
) {
|
|
||||||
return (
|
return (
|
||||||
resource["full-domain"] !== undefined &&
|
resource["full-domain"] !== undefined &&
|
||||||
resource["full-domain"].length > 0
|
resource["full-domain"].length > 0
|
||||||
@@ -329,7 +278,7 @@ export const PublicResourceSchema = z
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: ["full-domain"],
|
path: ["full-domain"],
|
||||||
error: "When protocol is 'http', 'ssh', 'rdp', or 'vnc', a 'full-domain' must be provided"
|
error: "When protocol is 'http', a 'full-domain' must be provided"
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
.refine(
|
.refine(
|
||||||
@@ -470,7 +419,7 @@ export const PrivateResourceSchema = z
|
|||||||
// proxyPort: z.int().positive().optional(),
|
// proxyPort: z.int().positive().optional(),
|
||||||
"destination-port": z.int().positive().optional(),
|
"destination-port": z.int().positive().optional(),
|
||||||
destination: z.string().min(1).optional(),
|
destination: z.string().min(1).optional(),
|
||||||
enabled: z.boolean().default(true),
|
// enabled: z.boolean().default(true),
|
||||||
"tcp-ports": portRangeStringSchema.optional().default("*"),
|
"tcp-ports": portRangeStringSchema.optional().default("*"),
|
||||||
"udp-ports": portRangeStringSchema.optional().default("*"),
|
"udp-ports": portRangeStringSchema.optional().default("*"),
|
||||||
"disable-icmp": z.boolean().optional().default(false),
|
"disable-icmp": z.boolean().optional().default(false),
|
||||||
@@ -556,90 +505,7 @@ export const PrivateResourceSchema = z
|
|||||||
{
|
{
|
||||||
message: "Destination must be a valid CIDR notation for cidr mode"
|
message: "Destination must be a valid CIDR notation for cidr mode"
|
||||||
}
|
}
|
||||||
)
|
);
|
||||||
.refine(
|
|
||||||
(data) => {
|
|
||||||
if (data.mode !== "ssh") {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
const authDaemonMode = data["auth-daemon"]?.mode;
|
|
||||||
if (authDaemonMode !== "native" && authDaemonMode !== "site") {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
const uniqueSites = new Set<string>();
|
|
||||||
if (data.site) {
|
|
||||||
uniqueSites.add(data.site);
|
|
||||||
}
|
|
||||||
for (const site of data.sites) {
|
|
||||||
uniqueSites.add(site);
|
|
||||||
}
|
|
||||||
|
|
||||||
return uniqueSites.size <= 1;
|
|
||||||
},
|
|
||||||
{
|
|
||||||
path: ["sites"],
|
|
||||||
message:
|
|
||||||
"When mode is 'ssh' and auth-daemon mode is 'native' or 'site', only one site/target is allowed"
|
|
||||||
}
|
|
||||||
)
|
|
||||||
.transform((data) => {
|
|
||||||
if (
|
|
||||||
data.mode === "ssh" &&
|
|
||||||
data.destination !== undefined &&
|
|
||||||
data["destination-port"] === undefined
|
|
||||||
) {
|
|
||||||
data["destination-port"] = 22;
|
|
||||||
}
|
|
||||||
return data;
|
|
||||||
});
|
|
||||||
|
|
||||||
export const ResourcePolicyRuleSchema = RuleSchema;
|
|
||||||
|
|
||||||
export const ResourcePolicySchema = z.object({
|
|
||||||
name: z.string().min(1).max(255),
|
|
||||||
sso: z.boolean().optional().default(true),
|
|
||||||
"auto-login-idp": z.int().positive().optional().nullable(),
|
|
||||||
"sso-roles": z
|
|
||||||
.array(z.string())
|
|
||||||
.optional()
|
|
||||||
.default([])
|
|
||||||
.refine((roles) => !roles.includes("Admin"), {
|
|
||||||
error: "Admin role cannot be included in sso-roles"
|
|
||||||
}),
|
|
||||||
"sso-users": z.array(z.string()).optional().default([]),
|
|
||||||
password: z.string().min(4).max(100).optional().nullable(),
|
|
||||||
pincode: z
|
|
||||||
.string()
|
|
||||||
.regex(/^\d{6}$/)
|
|
||||||
.optional()
|
|
||||||
.nullable(),
|
|
||||||
"basic-auth": z
|
|
||||||
.object({
|
|
||||||
user: z.string().min(4).max(100),
|
|
||||||
password: z.string().min(4).max(100),
|
|
||||||
"extended-compatibility": z.boolean().default(true)
|
|
||||||
})
|
|
||||||
.optional()
|
|
||||||
.nullable(),
|
|
||||||
"email-whitelist-enabled": z.boolean().optional().default(false),
|
|
||||||
"whitelist-users": z
|
|
||||||
.array(
|
|
||||||
z.email().or(
|
|
||||||
z.string().regex(/^\*@[\w.-]+\.[a-zA-Z]{2,}$/, {
|
|
||||||
error: "Invalid email address. Wildcard (*) must be the entire local part."
|
|
||||||
})
|
|
||||||
)
|
|
||||||
)
|
|
||||||
.max(50)
|
|
||||||
.transform((v) => v.map((e) => e.toLowerCase()))
|
|
||||||
.optional()
|
|
||||||
.default([]),
|
|
||||||
"apply-rules": z.boolean().optional().default(false),
|
|
||||||
rules: z.array(ResourcePolicyRuleSchema).optional().default([])
|
|
||||||
});
|
|
||||||
export type ResourcePolicyData = z.infer<typeof ResourcePolicySchema>;
|
|
||||||
|
|
||||||
// Schema for the entire configuration object
|
// Schema for the entire configuration object
|
||||||
export const ConfigSchema = z
|
export const ConfigSchema = z
|
||||||
@@ -660,10 +526,6 @@ export const ConfigSchema = z
|
|||||||
.record(z.string(), PrivateResourceSchema)
|
.record(z.string(), PrivateResourceSchema)
|
||||||
.optional()
|
.optional()
|
||||||
.prefault({}),
|
.prefault({}),
|
||||||
"public-policies": z
|
|
||||||
.record(z.string(), ResourcePolicySchema)
|
|
||||||
.optional()
|
|
||||||
.prefault({}),
|
|
||||||
sites: z.record(z.string(), SiteSchema).optional().prefault({})
|
sites: z.record(z.string(), SiteSchema).optional().prefault({})
|
||||||
})
|
})
|
||||||
.transform((data) => {
|
.transform((data) => {
|
||||||
@@ -694,10 +556,6 @@ export const ConfigSchema = z
|
|||||||
string,
|
string,
|
||||||
z.infer<typeof PrivateResourceSchema>
|
z.infer<typeof PrivateResourceSchema>
|
||||||
>;
|
>;
|
||||||
"public-policies": Record<
|
|
||||||
string,
|
|
||||||
z.infer<typeof ResourcePolicySchema>
|
|
||||||
>;
|
|
||||||
sites: Record<string, z.infer<typeof SiteSchema>>;
|
sites: Record<string, z.infer<typeof SiteSchema>>;
|
||||||
};
|
};
|
||||||
})
|
})
|
||||||
@@ -837,4 +695,3 @@ export type Site = z.infer<typeof SiteSchema>;
|
|||||||
export type Target = z.infer<typeof TargetSchema>;
|
export type Target = z.infer<typeof TargetSchema>;
|
||||||
export type Resource = z.infer<typeof PublicResourceSchema>;
|
export type Resource = z.infer<typeof PublicResourceSchema>;
|
||||||
export type Config = z.infer<typeof ConfigSchema>;
|
export type Config = z.infer<typeof ConfigSchema>;
|
||||||
export type BlueprintResourcePolicy = z.infer<typeof ResourcePolicySchema>;
|
|
||||||
|
|||||||
@@ -6,7 +6,6 @@ import {
|
|||||||
db,
|
db,
|
||||||
olms,
|
olms,
|
||||||
orgs,
|
orgs,
|
||||||
primaryDb,
|
|
||||||
roleClients,
|
roleClients,
|
||||||
roles,
|
roles,
|
||||||
Transaction,
|
Transaction,
|
||||||
@@ -24,427 +23,415 @@ import { rebuildClientAssociationsFromClient } from "./rebuildClientAssociations
|
|||||||
import { OlmErrorCodes } from "@server/routers/olm/error";
|
import { OlmErrorCodes } from "@server/routers/olm/error";
|
||||||
import { tierMatrix } from "./billing/tierMatrix";
|
import { tierMatrix } from "./billing/tierMatrix";
|
||||||
|
|
||||||
type ClientRow = typeof clients.$inferSelect;
|
|
||||||
|
|
||||||
function runQueuedClientAssociationRebuilds(
|
|
||||||
userId: string,
|
|
||||||
queuedClients: ClientRow[]
|
|
||||||
) {
|
|
||||||
if (queuedClients.length === 0) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const uniqueClientsById = new Map<number, ClientRow>();
|
|
||||||
for (const client of queuedClients) {
|
|
||||||
uniqueClientsById.set(client.clientId, client);
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const client of uniqueClientsById.values()) {
|
|
||||||
rebuildClientAssociationsFromClient(client).catch((error) => {
|
|
||||||
logger.error(
|
|
||||||
`Error rebuilding client associations for client ${client.clientId} (user ${userId}): ${String(
|
|
||||||
error
|
|
||||||
)}`
|
|
||||||
);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
logger.debug(
|
|
||||||
`Queued association rebuild completed for ${uniqueClientsById.size} client(s) (user ${userId})`
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function calculateUserClientsForOrgs(
|
export async function calculateUserClientsForOrgs(
|
||||||
userId: string
|
userId: string,
|
||||||
|
trx: Transaction | typeof db = db
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const trx = primaryDb;
|
const execute = async (transaction: Transaction | typeof db) => {
|
||||||
|
const orgCache = new Map<string, typeof orgs.$inferSelect | null>();
|
||||||
|
const adminRoleCache = new Map<
|
||||||
|
string,
|
||||||
|
typeof roles.$inferSelect | null
|
||||||
|
>();
|
||||||
|
const exitNodesCache = new Map<
|
||||||
|
string,
|
||||||
|
Awaited<ReturnType<typeof listExitNodes>>
|
||||||
|
>();
|
||||||
|
const isOrgLicensedCache = new Map<string, boolean>();
|
||||||
|
const existingClientCache = new Map<
|
||||||
|
string,
|
||||||
|
typeof clients.$inferSelect | null
|
||||||
|
>();
|
||||||
|
const roleClientAccessCache = new Map<string, boolean>();
|
||||||
|
const userClientAccessCache = new Map<string, boolean>();
|
||||||
|
|
||||||
const queuedAssociationRebuilds: ClientRow[] = [];
|
const getOrgOlmKey = (orgId: string, olmId: string) =>
|
||||||
const orgCache = new Map<string, typeof orgs.$inferSelect | null>();
|
`${orgId}:${olmId}`;
|
||||||
const adminRoleCache = new Map<string, typeof roles.$inferSelect | null>();
|
const getRoleClientKey = (roleId: number, clientId: number) =>
|
||||||
const exitNodesCache = new Map<
|
`${roleId}:${clientId}`;
|
||||||
string,
|
const getUserClientKey = (cachedUserId: string, clientId: number) =>
|
||||||
Awaited<ReturnType<typeof listExitNodes>>
|
`${cachedUserId}:${clientId}`;
|
||||||
>();
|
|
||||||
const isOrgLicensedCache = new Map<string, boolean>();
|
|
||||||
const existingClientCache = new Map<
|
|
||||||
string,
|
|
||||||
typeof clients.$inferSelect | null
|
|
||||||
>();
|
|
||||||
const roleClientAccessCache = new Map<string, boolean>();
|
|
||||||
const userClientAccessCache = new Map<string, boolean>();
|
|
||||||
|
|
||||||
const getOrgOlmKey = (orgId: string, olmId: string) => `${orgId}:${olmId}`;
|
const getOrg = async (orgId: string) => {
|
||||||
const getRoleClientKey = (roleId: number, clientId: number) =>
|
if (orgCache.has(orgId)) {
|
||||||
`${roleId}:${clientId}`;
|
return orgCache.get(orgId) ?? null;
|
||||||
const getUserClientKey = (cachedUserId: string, clientId: number) =>
|
|
||||||
`${cachedUserId}:${clientId}`;
|
|
||||||
|
|
||||||
const getOrg = async (orgId: string) => {
|
|
||||||
if (orgCache.has(orgId)) {
|
|
||||||
return orgCache.get(orgId) ?? null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const [org] = await trx
|
|
||||||
.select()
|
|
||||||
.from(orgs)
|
|
||||||
.where(eq(orgs.orgId, orgId));
|
|
||||||
orgCache.set(orgId, org ?? null);
|
|
||||||
|
|
||||||
return org ?? null;
|
|
||||||
};
|
|
||||||
|
|
||||||
const getAdminRole = async (orgId: string) => {
|
|
||||||
if (adminRoleCache.has(orgId)) {
|
|
||||||
return adminRoleCache.get(orgId) ?? null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const [adminRole] = await trx
|
|
||||||
.select()
|
|
||||||
.from(roles)
|
|
||||||
.where(and(eq(roles.isAdmin, true), eq(roles.orgId, orgId)))
|
|
||||||
.limit(1);
|
|
||||||
adminRoleCache.set(orgId, adminRole ?? null);
|
|
||||||
|
|
||||||
return adminRole ?? null;
|
|
||||||
};
|
|
||||||
|
|
||||||
const getExitNodes = async (orgId: string) => {
|
|
||||||
if (exitNodesCache.has(orgId)) {
|
|
||||||
return exitNodesCache.get(orgId)!;
|
|
||||||
}
|
|
||||||
|
|
||||||
const exitNodes = await listExitNodes(orgId);
|
|
||||||
exitNodesCache.set(orgId, exitNodes);
|
|
||||||
|
|
||||||
return exitNodes;
|
|
||||||
};
|
|
||||||
|
|
||||||
const getIsOrgLicensed = async (orgId: string) => {
|
|
||||||
if (isOrgLicensedCache.has(orgId)) {
|
|
||||||
return isOrgLicensedCache.get(orgId)!;
|
|
||||||
}
|
|
||||||
|
|
||||||
const isOrgLicensed = await isLicensedOrSubscribed(
|
|
||||||
orgId,
|
|
||||||
tierMatrix.deviceApprovals
|
|
||||||
);
|
|
||||||
isOrgLicensedCache.set(orgId, isOrgLicensed);
|
|
||||||
|
|
||||||
return isOrgLicensed;
|
|
||||||
};
|
|
||||||
|
|
||||||
const getExistingClient = async (orgId: string, olmId: string) => {
|
|
||||||
const key = getOrgOlmKey(orgId, olmId);
|
|
||||||
if (existingClientCache.has(key)) {
|
|
||||||
return existingClientCache.get(key) ?? null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const [existingClient] = await trx
|
|
||||||
.select()
|
|
||||||
.from(clients)
|
|
||||||
.where(
|
|
||||||
and(
|
|
||||||
eq(clients.userId, userId),
|
|
||||||
eq(clients.orgId, orgId),
|
|
||||||
eq(clients.olmId, olmId)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
.limit(1);
|
|
||||||
|
|
||||||
existingClientCache.set(key, existingClient ?? null);
|
|
||||||
|
|
||||||
return existingClient ?? null;
|
|
||||||
};
|
|
||||||
|
|
||||||
const hasRoleClientAccess = async (roleId: number, clientId: number) => {
|
|
||||||
const key = getRoleClientKey(roleId, clientId);
|
|
||||||
if (roleClientAccessCache.has(key)) {
|
|
||||||
return roleClientAccessCache.get(key)!;
|
|
||||||
}
|
|
||||||
|
|
||||||
const [existingRoleClient] = await trx
|
|
||||||
.select()
|
|
||||||
.from(roleClients)
|
|
||||||
.where(
|
|
||||||
and(
|
|
||||||
eq(roleClients.roleId, roleId),
|
|
||||||
eq(roleClients.clientId, clientId)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
.limit(1);
|
|
||||||
|
|
||||||
const hasAccess = Boolean(existingRoleClient);
|
|
||||||
roleClientAccessCache.set(key, hasAccess);
|
|
||||||
|
|
||||||
return hasAccess;
|
|
||||||
};
|
|
||||||
|
|
||||||
const hasUserClientAccess = async (
|
|
||||||
cachedUserId: string,
|
|
||||||
clientId: number
|
|
||||||
) => {
|
|
||||||
const key = getUserClientKey(cachedUserId, clientId);
|
|
||||||
if (userClientAccessCache.has(key)) {
|
|
||||||
return userClientAccessCache.get(key)!;
|
|
||||||
}
|
|
||||||
|
|
||||||
const [existingUserClient] = await trx
|
|
||||||
.select()
|
|
||||||
.from(userClients)
|
|
||||||
.where(
|
|
||||||
and(
|
|
||||||
eq(userClients.userId, cachedUserId),
|
|
||||||
eq(userClients.clientId, clientId)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
.limit(1);
|
|
||||||
|
|
||||||
const hasAccess = Boolean(existingUserClient);
|
|
||||||
userClientAccessCache.set(key, hasAccess);
|
|
||||||
|
|
||||||
return hasAccess;
|
|
||||||
};
|
|
||||||
|
|
||||||
// Get all OLMs for this user
|
|
||||||
const userOlms = await trx
|
|
||||||
.select()
|
|
||||||
.from(olms)
|
|
||||||
.where(eq(olms.userId, userId));
|
|
||||||
|
|
||||||
if (userOlms.length === 0) {
|
|
||||||
// No OLMs for this user, but we should still clean up any orphaned clients
|
|
||||||
await cleanupOrphanedClients(
|
|
||||||
userId,
|
|
||||||
trx,
|
|
||||||
[],
|
|
||||||
queuedAssociationRebuilds
|
|
||||||
);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get all user orgs with all roles (for org list and role-based logic)
|
|
||||||
const userOrgRoleRows = await trx
|
|
||||||
.select()
|
|
||||||
.from(userOrgs)
|
|
||||||
.innerJoin(
|
|
||||||
userOrgRoles,
|
|
||||||
and(
|
|
||||||
eq(userOrgs.userId, userOrgRoles.userId),
|
|
||||||
eq(userOrgs.orgId, userOrgRoles.orgId)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
.innerJoin(roles, eq(userOrgRoles.roleId, roles.roleId))
|
|
||||||
.where(eq(userOrgs.userId, userId));
|
|
||||||
|
|
||||||
const userOrgIds = [
|
|
||||||
...new Set(userOrgRoleRows.map((r) => r.userOrgs.orgId))
|
|
||||||
];
|
|
||||||
const orgIdToRoleRows = new Map<string, (typeof userOrgRoleRows)[0][]>();
|
|
||||||
for (const r of userOrgRoleRows) {
|
|
||||||
const list = orgIdToRoleRows.get(r.userOrgs.orgId) ?? [];
|
|
||||||
list.push(r);
|
|
||||||
orgIdToRoleRows.set(r.userOrgs.orgId, list);
|
|
||||||
}
|
|
||||||
const orgRequiresDeviceApprovalRole = new Map<string, boolean>();
|
|
||||||
for (const [orgId, roleRowsForOrg] of orgIdToRoleRows.entries()) {
|
|
||||||
orgRequiresDeviceApprovalRole.set(
|
|
||||||
orgId,
|
|
||||||
roleRowsForOrg.some((r) => r.roles.requireDeviceApproval)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// For each OLM, ensure there's a client in each org the user is in
|
|
||||||
for (const olm of userOlms) {
|
|
||||||
for (const orgId of orgIdToRoleRows.keys()) {
|
|
||||||
const roleRowsForOrg = orgIdToRoleRows.get(orgId)!;
|
|
||||||
const userOrg = roleRowsForOrg[0].userOrgs;
|
|
||||||
|
|
||||||
const org = await getOrg(orgId);
|
|
||||||
|
|
||||||
if (!org) {
|
|
||||||
logger.warn(
|
|
||||||
`Skipping org ${orgId} for OLM ${olm.olmId} (user ${userId}): org not found`
|
|
||||||
);
|
|
||||||
continue;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!org.subnet) {
|
const [org] = await transaction
|
||||||
logger.warn(
|
.select()
|
||||||
`Skipping org ${orgId} for OLM ${olm.olmId} (user ${userId}): org has no subnet configured`
|
.from(orgs)
|
||||||
);
|
.where(eq(orgs.orgId, orgId));
|
||||||
continue;
|
orgCache.set(orgId, org ?? null);
|
||||||
|
|
||||||
|
return org ?? null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const getAdminRole = async (orgId: string) => {
|
||||||
|
if (adminRoleCache.has(orgId)) {
|
||||||
|
return adminRoleCache.get(orgId) ?? null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get admin role for this org (needed for access grants)
|
const [adminRole] = await transaction
|
||||||
const adminRole = await getAdminRole(orgId);
|
.select()
|
||||||
|
.from(roles)
|
||||||
|
.where(and(eq(roles.isAdmin, true), eq(roles.orgId, orgId)))
|
||||||
|
.limit(1);
|
||||||
|
adminRoleCache.set(orgId, adminRole ?? null);
|
||||||
|
|
||||||
if (!adminRole) {
|
return adminRole ?? null;
|
||||||
logger.warn(
|
};
|
||||||
`Skipping org ${orgId} for OLM ${olm.olmId} (user ${userId}): no admin role found`
|
|
||||||
);
|
const getExitNodes = async (orgId: string) => {
|
||||||
continue;
|
if (exitNodesCache.has(orgId)) {
|
||||||
|
return exitNodesCache.get(orgId)!;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if a client already exists for this OLM+user+org combination
|
const exitNodes = await listExitNodes(orgId);
|
||||||
const existingClient = await getExistingClient(orgId, olm.olmId);
|
exitNodesCache.set(orgId, exitNodes);
|
||||||
|
|
||||||
if (existingClient) {
|
return exitNodes;
|
||||||
// Ensure admin role has access to the client
|
};
|
||||||
const hasRoleAccess = await hasRoleClientAccess(
|
|
||||||
adminRole.roleId,
|
|
||||||
existingClient.clientId
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!hasRoleAccess) {
|
const getIsOrgLicensed = async (orgId: string) => {
|
||||||
await trx.insert(roleClients).values({
|
if (isOrgLicensedCache.has(orgId)) {
|
||||||
roleId: adminRole.roleId,
|
return isOrgLicensedCache.get(orgId)!;
|
||||||
clientId: existingClient.clientId
|
}
|
||||||
});
|
|
||||||
roleClientAccessCache.set(
|
const isOrgLicensed = await isLicensedOrSubscribed(
|
||||||
getRoleClientKey(
|
orgId,
|
||||||
adminRole.roleId,
|
tierMatrix.deviceApprovals
|
||||||
existingClient.clientId
|
);
|
||||||
),
|
isOrgLicensedCache.set(orgId, isOrgLicensed);
|
||||||
true
|
|
||||||
);
|
return isOrgLicensed;
|
||||||
logger.debug(
|
};
|
||||||
`Granted admin role access to existing client ${existingClient.clientId} for OLM ${olm.olmId} in org ${orgId} (user ${userId})`
|
|
||||||
|
const getExistingClient = async (orgId: string, olmId: string) => {
|
||||||
|
const key = getOrgOlmKey(orgId, olmId);
|
||||||
|
if (existingClientCache.has(key)) {
|
||||||
|
return existingClientCache.get(key) ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const [existingClient] = await transaction
|
||||||
|
.select()
|
||||||
|
.from(clients)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(clients.userId, userId),
|
||||||
|
eq(clients.orgId, orgId),
|
||||||
|
eq(clients.olmId, olmId)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
existingClientCache.set(key, existingClient ?? null);
|
||||||
|
|
||||||
|
return existingClient ?? null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const hasRoleClientAccess = async (
|
||||||
|
roleId: number,
|
||||||
|
clientId: number
|
||||||
|
) => {
|
||||||
|
const key = getRoleClientKey(roleId, clientId);
|
||||||
|
if (roleClientAccessCache.has(key)) {
|
||||||
|
return roleClientAccessCache.get(key)!;
|
||||||
|
}
|
||||||
|
|
||||||
|
const [existingRoleClient] = await transaction
|
||||||
|
.select()
|
||||||
|
.from(roleClients)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(roleClients.roleId, roleId),
|
||||||
|
eq(roleClients.clientId, clientId)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
const hasAccess = Boolean(existingRoleClient);
|
||||||
|
roleClientAccessCache.set(key, hasAccess);
|
||||||
|
|
||||||
|
return hasAccess;
|
||||||
|
};
|
||||||
|
|
||||||
|
const hasUserClientAccess = async (
|
||||||
|
cachedUserId: string,
|
||||||
|
clientId: number
|
||||||
|
) => {
|
||||||
|
const key = getUserClientKey(cachedUserId, clientId);
|
||||||
|
if (userClientAccessCache.has(key)) {
|
||||||
|
return userClientAccessCache.get(key)!;
|
||||||
|
}
|
||||||
|
|
||||||
|
const [existingUserClient] = await transaction
|
||||||
|
.select()
|
||||||
|
.from(userClients)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(userClients.userId, cachedUserId),
|
||||||
|
eq(userClients.clientId, clientId)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
const hasAccess = Boolean(existingUserClient);
|
||||||
|
userClientAccessCache.set(key, hasAccess);
|
||||||
|
|
||||||
|
return hasAccess;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Get all OLMs for this user
|
||||||
|
const userOlms = await transaction
|
||||||
|
.select()
|
||||||
|
.from(olms)
|
||||||
|
.where(eq(olms.userId, userId));
|
||||||
|
|
||||||
|
if (userOlms.length === 0) {
|
||||||
|
// No OLMs for this user, but we should still clean up any orphaned clients
|
||||||
|
await cleanupOrphanedClients(userId, transaction);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get all user orgs with all roles (for org list and role-based logic)
|
||||||
|
const userOrgRoleRows = await transaction
|
||||||
|
.select()
|
||||||
|
.from(userOrgs)
|
||||||
|
.innerJoin(
|
||||||
|
userOrgRoles,
|
||||||
|
and(
|
||||||
|
eq(userOrgs.userId, userOrgRoles.userId),
|
||||||
|
eq(userOrgs.orgId, userOrgRoles.orgId)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.innerJoin(roles, eq(userOrgRoles.roleId, roles.roleId))
|
||||||
|
.where(eq(userOrgs.userId, userId));
|
||||||
|
|
||||||
|
const userOrgIds = [
|
||||||
|
...new Set(userOrgRoleRows.map((r) => r.userOrgs.orgId))
|
||||||
|
];
|
||||||
|
const orgIdToRoleRows = new Map<
|
||||||
|
string,
|
||||||
|
(typeof userOrgRoleRows)[0][]
|
||||||
|
>();
|
||||||
|
for (const r of userOrgRoleRows) {
|
||||||
|
const list = orgIdToRoleRows.get(r.userOrgs.orgId) ?? [];
|
||||||
|
list.push(r);
|
||||||
|
orgIdToRoleRows.set(r.userOrgs.orgId, list);
|
||||||
|
}
|
||||||
|
const orgRequiresDeviceApprovalRole = new Map<string, boolean>();
|
||||||
|
for (const [orgId, roleRowsForOrg] of orgIdToRoleRows.entries()) {
|
||||||
|
orgRequiresDeviceApprovalRole.set(
|
||||||
|
orgId,
|
||||||
|
roleRowsForOrg.some((r) => r.roles.requireDeviceApproval)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// For each OLM, ensure there's a client in each org the user is in
|
||||||
|
for (const olm of userOlms) {
|
||||||
|
for (const orgId of orgIdToRoleRows.keys()) {
|
||||||
|
const roleRowsForOrg = orgIdToRoleRows.get(orgId)!;
|
||||||
|
const userOrg = roleRowsForOrg[0].userOrgs;
|
||||||
|
|
||||||
|
const org = await getOrg(orgId);
|
||||||
|
|
||||||
|
if (!org) {
|
||||||
|
logger.warn(
|
||||||
|
`Skipping org ${orgId} for OLM ${olm.olmId} (user ${userId}): org not found`
|
||||||
);
|
);
|
||||||
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Ensure user has access to the client
|
if (!org.subnet) {
|
||||||
const hasUserAccess = await hasUserClientAccess(
|
logger.warn(
|
||||||
userId,
|
`Skipping org ${orgId} for OLM ${olm.olmId} (user ${userId}): org has no subnet configured`
|
||||||
existingClient.clientId
|
);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get admin role for this org (needed for access grants)
|
||||||
|
const adminRole = await getAdminRole(orgId);
|
||||||
|
|
||||||
|
if (!adminRole) {
|
||||||
|
logger.warn(
|
||||||
|
`Skipping org ${orgId} for OLM ${olm.olmId} (user ${userId}): no admin role found`
|
||||||
|
);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if a client already exists for this OLM+user+org combination
|
||||||
|
const existingClient = await getExistingClient(
|
||||||
|
orgId,
|
||||||
|
olm.olmId
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!hasUserAccess) {
|
if (existingClient) {
|
||||||
await trx.insert(userClients).values({
|
// Ensure admin role has access to the client
|
||||||
|
const hasRoleAccess = await hasRoleClientAccess(
|
||||||
|
adminRole.roleId,
|
||||||
|
existingClient.clientId
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!hasRoleAccess) {
|
||||||
|
await transaction.insert(roleClients).values({
|
||||||
|
roleId: adminRole.roleId,
|
||||||
|
clientId: existingClient.clientId
|
||||||
|
});
|
||||||
|
roleClientAccessCache.set(
|
||||||
|
getRoleClientKey(
|
||||||
|
adminRole.roleId,
|
||||||
|
existingClient.clientId
|
||||||
|
),
|
||||||
|
true
|
||||||
|
);
|
||||||
|
logger.debug(
|
||||||
|
`Granted admin role access to existing client ${existingClient.clientId} for OLM ${olm.olmId} in org ${orgId} (user ${userId})`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ensure user has access to the client
|
||||||
|
const hasUserAccess = await hasUserClientAccess(
|
||||||
userId,
|
userId,
|
||||||
clientId: existingClient.clientId
|
existingClient.clientId
|
||||||
});
|
|
||||||
userClientAccessCache.set(
|
|
||||||
getUserClientKey(userId, existingClient.clientId),
|
|
||||||
true
|
|
||||||
);
|
);
|
||||||
|
|
||||||
|
if (!hasUserAccess) {
|
||||||
|
await transaction.insert(userClients).values({
|
||||||
|
userId,
|
||||||
|
clientId: existingClient.clientId
|
||||||
|
});
|
||||||
|
userClientAccessCache.set(
|
||||||
|
getUserClientKey(userId, existingClient.clientId),
|
||||||
|
true
|
||||||
|
);
|
||||||
|
logger.debug(
|
||||||
|
`Granted user access to existing client ${existingClient.clientId} for OLM ${olm.olmId} in org ${orgId} (user ${userId})`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
logger.debug(
|
logger.debug(
|
||||||
`Granted user access to existing client ${existingClient.clientId} for OLM ${olm.olmId} in org ${orgId} (user ${userId})`
|
`Client already exists for OLM ${olm.olmId} in org ${orgId} (user ${userId}), skipping creation`
|
||||||
);
|
);
|
||||||
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Get exit nodes for this org
|
||||||
|
const exitNodesList = await getExitNodes(orgId);
|
||||||
|
|
||||||
|
if (exitNodesList.length === 0) {
|
||||||
|
logger.warn(
|
||||||
|
`Skipping org ${orgId} for OLM ${olm.olmId} (user ${userId}): no exit nodes found`
|
||||||
|
);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const randomExitNode =
|
||||||
|
exitNodesList[
|
||||||
|
Math.floor(Math.random() * exitNodesList.length)
|
||||||
|
];
|
||||||
|
|
||||||
|
// Get next available subnet
|
||||||
|
const { value: newSubnet, release: releaseSubnetLock } =
|
||||||
|
await getNextAvailableClientSubnet(orgId, transaction);
|
||||||
|
|
||||||
|
const subnet = newSubnet.split("/")[0];
|
||||||
|
const updatedSubnet = `${subnet}/${org.subnet.split("/")[1]}`;
|
||||||
|
|
||||||
|
const niceId = await getUniqueClientName(orgId);
|
||||||
|
|
||||||
|
const isOrgLicensed = await getIsOrgLicensed(userOrg.orgId);
|
||||||
|
const requireApproval =
|
||||||
|
build !== "oss" &&
|
||||||
|
isOrgLicensed &&
|
||||||
|
orgRequiresDeviceApprovalRole.get(orgId) === true;
|
||||||
|
|
||||||
|
const newClientData: InferInsertModel<typeof clients> = {
|
||||||
|
userId,
|
||||||
|
orgId: userOrg.orgId,
|
||||||
|
exitNodeId: randomExitNode.exitNodeId,
|
||||||
|
name: olm.name || "User Client",
|
||||||
|
subnet: updatedSubnet,
|
||||||
|
olmId: olm.olmId,
|
||||||
|
type: "olm",
|
||||||
|
niceId,
|
||||||
|
approvalState: requireApproval ? "pending" : null
|
||||||
|
};
|
||||||
|
|
||||||
|
// Create the client
|
||||||
|
const [newClient] = await transaction
|
||||||
|
.insert(clients)
|
||||||
|
.values(newClientData)
|
||||||
|
.returning();
|
||||||
|
await releaseSubnetLock();
|
||||||
|
existingClientCache.set(
|
||||||
|
getOrgOlmKey(orgId, olm.olmId),
|
||||||
|
newClient
|
||||||
|
);
|
||||||
|
|
||||||
|
// create approval request
|
||||||
|
if (requireApproval) {
|
||||||
|
await transaction
|
||||||
|
.insert(approvals)
|
||||||
|
.values({
|
||||||
|
timestamp: Math.floor(new Date().getTime() / 1000),
|
||||||
|
orgId: userOrg.orgId,
|
||||||
|
clientId: newClient.clientId,
|
||||||
|
userId,
|
||||||
|
type: "user_device"
|
||||||
|
})
|
||||||
|
.returning();
|
||||||
|
}
|
||||||
|
|
||||||
|
await rebuildClientAssociationsFromClient(
|
||||||
|
newClient,
|
||||||
|
transaction
|
||||||
|
);
|
||||||
|
|
||||||
|
// Grant admin role access to the client
|
||||||
|
await transaction.insert(roleClients).values({
|
||||||
|
roleId: adminRole.roleId,
|
||||||
|
clientId: newClient.clientId
|
||||||
|
});
|
||||||
|
roleClientAccessCache.set(
|
||||||
|
getRoleClientKey(adminRole.roleId, newClient.clientId),
|
||||||
|
true
|
||||||
|
);
|
||||||
|
|
||||||
|
// Grant user access to the client
|
||||||
|
await transaction.insert(userClients).values({
|
||||||
|
userId,
|
||||||
|
clientId: newClient.clientId
|
||||||
|
});
|
||||||
|
userClientAccessCache.set(
|
||||||
|
getUserClientKey(userId, newClient.clientId),
|
||||||
|
true
|
||||||
|
);
|
||||||
|
|
||||||
logger.debug(
|
logger.debug(
|
||||||
`Client already exists for OLM ${olm.olmId} in org ${orgId} (user ${userId}), skipping creation`
|
`Created client for OLM ${olm.olmId} in org ${orgId} (user ${userId}) with access granted to admin role and user`
|
||||||
);
|
);
|
||||||
continue;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get exit nodes for this org
|
|
||||||
const exitNodesList = await getExitNodes(orgId);
|
|
||||||
|
|
||||||
if (exitNodesList.length === 0) {
|
|
||||||
logger.warn(
|
|
||||||
`Skipping org ${orgId} for OLM ${olm.olmId} (user ${userId}): no exit nodes found`
|
|
||||||
);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
const randomExitNode =
|
|
||||||
exitNodesList[Math.floor(Math.random() * exitNodesList.length)];
|
|
||||||
|
|
||||||
// Get next available subnet
|
|
||||||
const { value: newSubnet, release: releaseSubnetLock } =
|
|
||||||
await getNextAvailableClientSubnet(orgId, trx);
|
|
||||||
|
|
||||||
const subnet = newSubnet.split("/")[0];
|
|
||||||
const updatedSubnet = `${subnet}/${org.subnet.split("/")[1]}`;
|
|
||||||
|
|
||||||
const niceId = await getUniqueClientName(orgId);
|
|
||||||
|
|
||||||
const isOrgLicensed = await getIsOrgLicensed(userOrg.orgId);
|
|
||||||
const requireApproval =
|
|
||||||
build !== "oss" &&
|
|
||||||
isOrgLicensed &&
|
|
||||||
orgRequiresDeviceApprovalRole.get(orgId) === true;
|
|
||||||
|
|
||||||
const newClientData: InferInsertModel<typeof clients> = {
|
|
||||||
userId,
|
|
||||||
orgId: userOrg.orgId,
|
|
||||||
exitNodeId: randomExitNode.exitNodeId,
|
|
||||||
name: olm.name || "User Client",
|
|
||||||
subnet: updatedSubnet,
|
|
||||||
olmId: olm.olmId,
|
|
||||||
type: "olm",
|
|
||||||
niceId,
|
|
||||||
approvalState: requireApproval ? "pending" : null
|
|
||||||
};
|
|
||||||
|
|
||||||
// Create the client
|
|
||||||
const [newClient] = await trx
|
|
||||||
.insert(clients)
|
|
||||||
.values(newClientData)
|
|
||||||
.returning();
|
|
||||||
await releaseSubnetLock();
|
|
||||||
existingClientCache.set(getOrgOlmKey(orgId, olm.olmId), newClient);
|
|
||||||
|
|
||||||
// create approval request
|
|
||||||
if (requireApproval) {
|
|
||||||
await trx
|
|
||||||
.insert(approvals)
|
|
||||||
.values({
|
|
||||||
timestamp: Math.floor(new Date().getTime() / 1000),
|
|
||||||
orgId: userOrg.orgId,
|
|
||||||
clientId: newClient.clientId,
|
|
||||||
userId,
|
|
||||||
type: "user_device"
|
|
||||||
})
|
|
||||||
.returning();
|
|
||||||
}
|
|
||||||
|
|
||||||
queuedAssociationRebuilds.push(newClient);
|
|
||||||
|
|
||||||
// Grant admin role access to the client
|
|
||||||
await trx.insert(roleClients).values({
|
|
||||||
roleId: adminRole.roleId,
|
|
||||||
clientId: newClient.clientId
|
|
||||||
});
|
|
||||||
roleClientAccessCache.set(
|
|
||||||
getRoleClientKey(adminRole.roleId, newClient.clientId),
|
|
||||||
true
|
|
||||||
);
|
|
||||||
|
|
||||||
// Grant user access to the client
|
|
||||||
await trx.insert(userClients).values({
|
|
||||||
userId,
|
|
||||||
clientId: newClient.clientId
|
|
||||||
});
|
|
||||||
userClientAccessCache.set(
|
|
||||||
getUserClientKey(userId, newClient.clientId),
|
|
||||||
true
|
|
||||||
);
|
|
||||||
|
|
||||||
logger.debug(
|
|
||||||
`Created client for OLM ${olm.olmId} in org ${orgId} (user ${userId}) with access granted to admin role and user`
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Clean up clients in orgs the user is no longer in
|
||||||
|
await cleanupOrphanedClients(userId, transaction, userOrgIds);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (trx) {
|
||||||
|
// Use provided transaction
|
||||||
|
await execute(trx);
|
||||||
|
} else {
|
||||||
|
// Create new transaction
|
||||||
|
await db.transaction(async (transaction) => {
|
||||||
|
await execute(transaction);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Clean up clients in orgs the user is no longer in
|
|
||||||
await cleanupOrphanedClients(
|
|
||||||
userId,
|
|
||||||
trx,
|
|
||||||
userOrgIds,
|
|
||||||
queuedAssociationRebuilds
|
|
||||||
);
|
|
||||||
|
|
||||||
runQueuedClientAssociationRebuilds(userId, queuedAssociationRebuilds);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function cleanupOrphanedClients(
|
async function cleanupOrphanedClients(
|
||||||
userId: string,
|
userId: string,
|
||||||
trx: Transaction | typeof db,
|
trx: Transaction | typeof db,
|
||||||
userOrgIds: string[] = [],
|
userOrgIds: string[] = []
|
||||||
queuedAssociationRebuilds: ClientRow[] = []
|
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
// Find all OLM clients for this user that should be deleted
|
// Find all OLM clients for this user that should be deleted
|
||||||
// If userOrgIds is empty, delete all OLM clients (user has no orgs)
|
// If userOrgIds is empty, delete all OLM clients (user has no orgs)
|
||||||
@@ -474,9 +461,9 @@ async function cleanupOrphanedClients(
|
|||||||
)
|
)
|
||||||
.returning();
|
.returning();
|
||||||
|
|
||||||
// Queue deleted clients for post-trx association cleanup.
|
// Rebuild associations for each deleted client to clean up related data
|
||||||
for (const deletedClient of deletedClients) {
|
for (const deletedClient of deletedClients) {
|
||||||
queuedAssociationRebuilds.push(deletedClient);
|
await rebuildClientAssociationsFromClient(deletedClient, trx);
|
||||||
|
|
||||||
if (deletedClient.olmId) {
|
if (deletedClient.olmId) {
|
||||||
await sendTerminateClient(
|
await sendTerminateClient(
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import path from "path";
|
|||||||
import { fileURLToPath } from "url";
|
import { fileURLToPath } from "url";
|
||||||
|
|
||||||
// This is a placeholder value replaced by the build process
|
// This is a placeholder value replaced by the build process
|
||||||
export const APP_VERSION = "1.21.0";
|
export const APP_VERSION = "1.19.0";
|
||||||
|
|
||||||
export const __FILENAME = fileURLToPath(import.meta.url);
|
export const __FILENAME = fileURLToPath(import.meta.url);
|
||||||
export const __DIRNAME = path.dirname(__FILENAME);
|
export const __DIRNAME = path.dirname(__FILENAME);
|
||||||
|
|||||||
@@ -1,83 +0,0 @@
|
|||||||
import logger from "@server/logger";
|
|
||||||
|
|
||||||
const MAX_RETRIES = 5;
|
|
||||||
const BASE_DELAY_MS = 50;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Detect transient errors that are safe to retry (connection drops, deadlocks,
|
|
||||||
* serialization failures). PostgreSQL deadlocks (40P01) are always safe to
|
|
||||||
* retry: the database guarantees exactly one winner per deadlock pair, so the
|
|
||||||
* loser just needs to try again.
|
|
||||||
*/
|
|
||||||
export function isTransientError(error: any): boolean {
|
|
||||||
if (!error) return false;
|
|
||||||
|
|
||||||
const message = (error.message || "").toLowerCase();
|
|
||||||
const causeMessage = (error.cause?.message || "").toLowerCase();
|
|
||||||
const code = error.code || error.cause?.code || "";
|
|
||||||
|
|
||||||
// Connection timeout / terminated
|
|
||||||
if (
|
|
||||||
message.includes("connection timeout") ||
|
|
||||||
message.includes("connection terminated") ||
|
|
||||||
message.includes("timeout exceeded when trying to connect") ||
|
|
||||||
causeMessage.includes("connection terminated unexpectedly") ||
|
|
||||||
causeMessage.includes("connection timeout")
|
|
||||||
) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
// PostgreSQL deadlock detected - always safe to retry (one winner guaranteed)
|
|
||||||
if (code === "40P01" || message.includes("deadlock")) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
// PostgreSQL serialization failure
|
|
||||||
if (code === "40001") {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ECONNRESET, ECONNREFUSED, EPIPE, ETIMEDOUT
|
|
||||||
if (
|
|
||||||
code === "ECONNRESET" ||
|
|
||||||
code === "ECONNREFUSED" ||
|
|
||||||
code === "EPIPE" ||
|
|
||||||
code === "ETIMEDOUT"
|
|
||||||
) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Simple retry wrapper with exponential backoff for transient errors
|
|
||||||
* (deadlocks, connection timeouts, unexpected disconnects).
|
|
||||||
*/
|
|
||||||
export async function withRetry<T>(
|
|
||||||
operation: () => Promise<T>,
|
|
||||||
context: string,
|
|
||||||
maxRetries: number = MAX_RETRIES,
|
|
||||||
baseDelayMs: number = BASE_DELAY_MS
|
|
||||||
): Promise<T> {
|
|
||||||
let attempt = 0;
|
|
||||||
while (true) {
|
|
||||||
try {
|
|
||||||
return await operation();
|
|
||||||
} catch (error: any) {
|
|
||||||
if (isTransientError(error) && attempt < maxRetries) {
|
|
||||||
attempt++;
|
|
||||||
const baseDelay = Math.pow(2, attempt - 1) * baseDelayMs;
|
|
||||||
const jitter = Math.random() * baseDelay;
|
|
||||||
const delay = baseDelay + jitter;
|
|
||||||
logger.warn(
|
|
||||||
`Transient DB error in ${context}, retrying attempt ${attempt}/${maxRetries} after ${delay.toFixed(0)}ms`,
|
|
||||||
{ code: error?.code ?? error?.cause?.code }
|
|
||||||
);
|
|
||||||
await new Promise((resolve) => setTimeout(resolve, delay));
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -24,7 +24,7 @@ import { deletePeer } from "@server/routers/gerbil/peers";
|
|||||||
import { OlmErrorCodes } from "@server/routers/olm/error";
|
import { OlmErrorCodes } from "@server/routers/olm/error";
|
||||||
import { sendTerminateClient } from "@server/routers/client/terminate";
|
import { sendTerminateClient } from "@server/routers/client/terminate";
|
||||||
import { usageService } from "./billing/usageService";
|
import { usageService } from "./billing/usageService";
|
||||||
import { LimitId } from "./billing";
|
import { FeatureId } from "./billing";
|
||||||
|
|
||||||
export type DeleteOrgByIdResult = {
|
export type DeleteOrgByIdResult = {
|
||||||
deletedNewtIds: string[];
|
deletedNewtIds: string[];
|
||||||
@@ -140,9 +140,7 @@ export async function deleteOrgById(
|
|||||||
.select({ count: count() })
|
.select({ count: count() })
|
||||||
.from(orgDomains)
|
.from(orgDomains)
|
||||||
.where(eq(orgDomains.domainId, domainId));
|
.where(eq(orgDomains.domainId, domainId));
|
||||||
logger.info(
|
logger.info(`Found ${orgCount.count} orgs using domain ${domainId}`);
|
||||||
`Found ${orgCount.count} orgs using domain ${domainId}`
|
|
||||||
);
|
|
||||||
if (orgCount.count === 1) {
|
if (orgCount.count === 1) {
|
||||||
domainIdsToDelete.push(domainId);
|
domainIdsToDelete.push(domainId);
|
||||||
}
|
}
|
||||||
@@ -154,7 +152,7 @@ export async function deleteOrgById(
|
|||||||
.where(inArray(domains.domainId, domainIdsToDelete));
|
.where(inArray(domains.domainId, domainIdsToDelete));
|
||||||
}
|
}
|
||||||
|
|
||||||
await usageService.add(orgId, LimitId.ORGANIZATIONS, -1, trx); // here we are decreasing the org count BEFORE deleting the org because we need to still be able to get the org to get the billing org inside of here
|
await usageService.add(orgId, FeatureId.ORGINIZATIONS, -1, trx); // here we are decreasing the org count BEFORE deleting the org because we need to still be able to get the org to get the billing org inside of here
|
||||||
|
|
||||||
await trx.delete(orgs).where(eq(orgs.orgId, orgId));
|
await trx.delete(orgs).where(eq(orgs.orgId, orgId));
|
||||||
|
|
||||||
@@ -201,22 +199,22 @@ export async function deleteOrgById(
|
|||||||
if (org.billingOrgId) {
|
if (org.billingOrgId) {
|
||||||
usageService.updateCount(
|
usageService.updateCount(
|
||||||
org.billingOrgId,
|
org.billingOrgId,
|
||||||
LimitId.DOMAINS,
|
FeatureId.DOMAINS,
|
||||||
domainCount ?? 0
|
domainCount ?? 0
|
||||||
);
|
);
|
||||||
usageService.updateCount(
|
usageService.updateCount(
|
||||||
org.billingOrgId,
|
org.billingOrgId,
|
||||||
LimitId.SITES,
|
FeatureId.SITES,
|
||||||
siteCount ?? 0
|
siteCount ?? 0
|
||||||
);
|
);
|
||||||
usageService.updateCount(
|
usageService.updateCount(
|
||||||
org.billingOrgId,
|
org.billingOrgId,
|
||||||
LimitId.USERS,
|
FeatureId.USERS,
|
||||||
userCount ?? 0
|
userCount ?? 0
|
||||||
);
|
);
|
||||||
usageService.updateCount(
|
usageService.updateCount(
|
||||||
org.billingOrgId,
|
org.billingOrgId,
|
||||||
LimitId.REMOTE_EXIT_NODES,
|
FeatureId.REMOTE_EXIT_NODES,
|
||||||
remoteExitNodeCount ?? 0
|
remoteExitNodeCount ?? 0
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,142 +0,0 @@
|
|||||||
import { eq, inArray } from "drizzle-orm";
|
|
||||||
import {
|
|
||||||
db,
|
|
||||||
newts,
|
|
||||||
resourcePolicies,
|
|
||||||
resources,
|
|
||||||
sites,
|
|
||||||
targetHealthCheck,
|
|
||||||
targets,
|
|
||||||
type Resource,
|
|
||||||
type Target,
|
|
||||||
type TargetHealthCheck,
|
|
||||||
type Transaction
|
|
||||||
} from "@server/db";
|
|
||||||
import logger from "@server/logger";
|
|
||||||
import { removeTargets } from "@server/routers/newt/targets";
|
|
||||||
|
|
||||||
export type DeleteResourceResult = {
|
|
||||||
deletedResource: Resource;
|
|
||||||
targetsToBeRemoved: Target[];
|
|
||||||
healthChecksToBeRemoved: TargetHealthCheck[];
|
|
||||||
};
|
|
||||||
|
|
||||||
export async function performDeleteResources(
|
|
||||||
resourceIds: number[],
|
|
||||||
trx: Transaction | typeof db = db
|
|
||||||
): Promise<DeleteResourceResult[]> {
|
|
||||||
if (resourceIds.length === 0) {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
|
|
||||||
const targetsToBeRemoved = await trx
|
|
||||||
.select()
|
|
||||||
.from(targets)
|
|
||||||
.where(inArray(targets.resourceId, resourceIds));
|
|
||||||
|
|
||||||
const targetIds = targetsToBeRemoved.map((t) => t.targetId);
|
|
||||||
const healthChecksToBeRemoved =
|
|
||||||
targetIds.length > 0
|
|
||||||
? await trx
|
|
||||||
.select()
|
|
||||||
.from(targetHealthCheck)
|
|
||||||
.where(inArray(targetHealthCheck.targetId, targetIds))
|
|
||||||
: [];
|
|
||||||
|
|
||||||
const deletedResources = await trx
|
|
||||||
.delete(resources)
|
|
||||||
.where(inArray(resources.resourceId, resourceIds))
|
|
||||||
.returning();
|
|
||||||
|
|
||||||
const policyIds = deletedResources
|
|
||||||
.map((resource) => resource.defaultResourcePolicyId)
|
|
||||||
.filter((id): id is number => id != null);
|
|
||||||
|
|
||||||
if (policyIds.length > 0) {
|
|
||||||
await trx
|
|
||||||
.delete(resourcePolicies)
|
|
||||||
.where(inArray(resourcePolicies.resourcePolicyId, policyIds));
|
|
||||||
}
|
|
||||||
|
|
||||||
if (deletedResources.length > 0) {
|
|
||||||
logger.debug(`Deleted ${deletedResources.length} resources`);
|
|
||||||
}
|
|
||||||
|
|
||||||
const targetsByResourceId = new Map<number, Target[]>();
|
|
||||||
for (const target of targetsToBeRemoved) {
|
|
||||||
const existing = targetsByResourceId.get(target.resourceId) ?? [];
|
|
||||||
existing.push(target);
|
|
||||||
targetsByResourceId.set(target.resourceId, existing);
|
|
||||||
}
|
|
||||||
|
|
||||||
const targetIdToResourceId = new Map(
|
|
||||||
targetsToBeRemoved.map((target) => [target.targetId, target.resourceId])
|
|
||||||
);
|
|
||||||
|
|
||||||
const healthChecksByResourceId = new Map<number, TargetHealthCheck[]>();
|
|
||||||
for (const healthCheck of healthChecksToBeRemoved) {
|
|
||||||
const resourceId = targetIdToResourceId.get(healthCheck.targetId!);
|
|
||||||
if (resourceId == null) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
const existing = healthChecksByResourceId.get(resourceId) ?? [];
|
|
||||||
existing.push(healthCheck);
|
|
||||||
healthChecksByResourceId.set(resourceId, existing);
|
|
||||||
}
|
|
||||||
|
|
||||||
return deletedResources.map((deletedResource) => ({
|
|
||||||
deletedResource,
|
|
||||||
targetsToBeRemoved:
|
|
||||||
targetsByResourceId.get(deletedResource.resourceId) ?? [],
|
|
||||||
healthChecksToBeRemoved:
|
|
||||||
healthChecksByResourceId.get(deletedResource.resourceId) ?? []
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function performDeleteResource(
|
|
||||||
resourceId: number,
|
|
||||||
trx: Transaction | typeof db = db
|
|
||||||
): Promise<DeleteResourceResult | null> {
|
|
||||||
const [result] = await performDeleteResources([resourceId], trx);
|
|
||||||
return result ?? null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function runResourceDeleteSideEffects(
|
|
||||||
result: DeleteResourceResult
|
|
||||||
): Promise<void> {
|
|
||||||
const { deletedResource, targetsToBeRemoved, healthChecksToBeRemoved } =
|
|
||||||
result;
|
|
||||||
|
|
||||||
for (const target of targetsToBeRemoved) {
|
|
||||||
const [site] = await db
|
|
||||||
.select()
|
|
||||||
.from(sites)
|
|
||||||
.where(eq(sites.siteId, target.siteId))
|
|
||||||
.limit(1);
|
|
||||||
|
|
||||||
if (!site) {
|
|
||||||
logger.debug(
|
|
||||||
`Site with ID ${target.siteId} not found during resource delete side effects; skipping target removal`
|
|
||||||
);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (site.pubKey && site.type === "newt") {
|
|
||||||
const [newt] = await db
|
|
||||||
.select()
|
|
||||||
.from(newts)
|
|
||||||
.where(eq(newts.siteId, site.siteId))
|
|
||||||
.limit(1);
|
|
||||||
|
|
||||||
if (newt) {
|
|
||||||
await removeTargets(
|
|
||||||
newt.newtId,
|
|
||||||
[],
|
|
||||||
healthChecksToBeRemoved,
|
|
||||||
deletedResource.mode === "udp" ? "udp" : "tcp",
|
|
||||||
newt.version
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,205 +0,0 @@
|
|||||||
import { and, eq, inArray, sql } from "drizzle-orm";
|
|
||||||
import {
|
|
||||||
db,
|
|
||||||
resources,
|
|
||||||
siteNetworks,
|
|
||||||
siteResources,
|
|
||||||
targets,
|
|
||||||
type SiteResource,
|
|
||||||
type Transaction
|
|
||||||
} from "@server/db";
|
|
||||||
import {
|
|
||||||
performDeleteResources,
|
|
||||||
runResourceDeleteSideEffects,
|
|
||||||
type DeleteResourceResult
|
|
||||||
} from "@server/lib/deleteResource";
|
|
||||||
import {
|
|
||||||
performDeleteSiteResources,
|
|
||||||
runSiteResourceDeleteSideEffects
|
|
||||||
} from "@server/lib/deleteSiteResource";
|
|
||||||
import logger from "@server/logger";
|
|
||||||
|
|
||||||
export const MAX_SITE_ASSOCIATED_RESOURCES_FOR_BULK_DELETE = 250;
|
|
||||||
|
|
||||||
export type DeleteSiteAssociatedResourcesSideEffects = {
|
|
||||||
resources: DeleteResourceResult[];
|
|
||||||
siteResources: SiteResource[];
|
|
||||||
};
|
|
||||||
|
|
||||||
export async function getResourceIdsForSite(
|
|
||||||
siteId: number,
|
|
||||||
trx: Transaction | typeof db = db
|
|
||||||
): Promise<number[]> {
|
|
||||||
const rows = await trx
|
|
||||||
.selectDistinct({ resourceId: targets.resourceId })
|
|
||||||
.from(targets)
|
|
||||||
.where(eq(targets.siteId, siteId));
|
|
||||||
|
|
||||||
return rows.map((row) => row.resourceId);
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function getSiteResourceIdsForSite(
|
|
||||||
siteId: number,
|
|
||||||
orgId: string,
|
|
||||||
trx: Transaction | typeof db = db
|
|
||||||
): Promise<number[]> {
|
|
||||||
const rows = await trx
|
|
||||||
.selectDistinct({ siteResourceId: siteResources.siteResourceId })
|
|
||||||
.from(siteNetworks)
|
|
||||||
.innerJoin(
|
|
||||||
siteResources,
|
|
||||||
eq(siteResources.networkId, siteNetworks.networkId)
|
|
||||||
)
|
|
||||||
.where(
|
|
||||||
and(eq(siteNetworks.siteId, siteId), eq(siteResources.orgId, orgId))
|
|
||||||
);
|
|
||||||
|
|
||||||
return rows.map((row) => row.siteResourceId);
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function getAssociatedResourceCountForSite(
|
|
||||||
siteId: number,
|
|
||||||
orgId: string,
|
|
||||||
trx: Transaction | typeof db = db
|
|
||||||
): Promise<number> {
|
|
||||||
const [publicCountResult, privateCountResult] = await Promise.all([
|
|
||||||
trx
|
|
||||||
.select({
|
|
||||||
count: sql<number>`count(distinct ${targets.resourceId})`
|
|
||||||
})
|
|
||||||
.from(targets)
|
|
||||||
.where(eq(targets.siteId, siteId)),
|
|
||||||
trx
|
|
||||||
.select({
|
|
||||||
count: sql<number>`count(distinct ${siteResources.siteResourceId})`
|
|
||||||
})
|
|
||||||
.from(siteNetworks)
|
|
||||||
.innerJoin(
|
|
||||||
siteResources,
|
|
||||||
eq(siteResources.networkId, siteNetworks.networkId)
|
|
||||||
)
|
|
||||||
.where(
|
|
||||||
and(
|
|
||||||
eq(siteNetworks.siteId, siteId),
|
|
||||||
eq(siteResources.orgId, orgId)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
Number(publicCountResult[0]?.count ?? 0) +
|
|
||||||
Number(privateCountResult[0]?.count ?? 0)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function exceedsSiteAssociatedResourceDeleteLimit(
|
|
||||||
resourceCount: number
|
|
||||||
): boolean {
|
|
||||||
return resourceCount > MAX_SITE_ASSOCIATED_RESOURCES_FOR_BULK_DELETE;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function getPendingResourceIdsForSite(
|
|
||||||
siteId: number,
|
|
||||||
trx: Transaction | typeof db = db
|
|
||||||
): Promise<number[]> {
|
|
||||||
const resourceIds = await getResourceIdsForSite(siteId, trx);
|
|
||||||
if (resourceIds.length === 0) {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
|
|
||||||
const rows = await trx
|
|
||||||
.select({ resourceId: resources.resourceId })
|
|
||||||
.from(resources)
|
|
||||||
.where(
|
|
||||||
and(
|
|
||||||
inArray(resources.resourceId, resourceIds),
|
|
||||||
eq(resources.status, "pending")
|
|
||||||
)
|
|
||||||
);
|
|
||||||
|
|
||||||
return rows.map((row) => row.resourceId);
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function getPendingSiteResourceIdsForSite(
|
|
||||||
siteId: number,
|
|
||||||
orgId: string,
|
|
||||||
trx: Transaction | typeof db = db
|
|
||||||
): Promise<number[]> {
|
|
||||||
const siteResourceIds = await getSiteResourceIdsForSite(siteId, orgId, trx);
|
|
||||||
if (siteResourceIds.length === 0) {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
|
|
||||||
const rows = await trx
|
|
||||||
.select({ siteResourceId: siteResources.siteResourceId })
|
|
||||||
.from(siteResources)
|
|
||||||
.where(
|
|
||||||
and(
|
|
||||||
inArray(siteResources.siteResourceId, siteResourceIds),
|
|
||||||
eq(siteResources.status, "pending")
|
|
||||||
)
|
|
||||||
);
|
|
||||||
|
|
||||||
return rows.map((row) => row.siteResourceId);
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function getPendingAssociatedResourceCountForSite(
|
|
||||||
siteId: number,
|
|
||||||
orgId: string,
|
|
||||||
trx: Transaction | typeof db = db
|
|
||||||
): Promise<number> {
|
|
||||||
const [resourceIds, siteResourceIds] = await Promise.all([
|
|
||||||
getPendingResourceIdsForSite(siteId, trx),
|
|
||||||
getPendingSiteResourceIdsForSite(siteId, orgId, trx)
|
|
||||||
]);
|
|
||||||
|
|
||||||
return resourceIds.length + siteResourceIds.length;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function deleteAssociatedResourcesForSite(
|
|
||||||
siteId: number,
|
|
||||||
orgId: string,
|
|
||||||
trx: Transaction | typeof db = db
|
|
||||||
): Promise<DeleteSiteAssociatedResourcesSideEffects> {
|
|
||||||
const resourceIds = await getResourceIdsForSite(siteId, trx);
|
|
||||||
const siteResourceIds = await getSiteResourceIdsForSite(siteId, orgId, trx);
|
|
||||||
|
|
||||||
const [deletedResources, siteResourcesDeleted] = await Promise.all([
|
|
||||||
performDeleteResources(resourceIds, trx),
|
|
||||||
performDeleteSiteResources(siteResourceIds, trx)
|
|
||||||
]);
|
|
||||||
|
|
||||||
return { resources: deletedResources, siteResources: siteResourcesDeleted };
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function deletePendingAssociatedResourcesForSite(
|
|
||||||
siteId: number,
|
|
||||||
orgId: string,
|
|
||||||
trx: Transaction | typeof db = db
|
|
||||||
): Promise<DeleteSiteAssociatedResourcesSideEffects> {
|
|
||||||
const resourceIds = await getPendingResourceIdsForSite(siteId, trx);
|
|
||||||
const siteResourceIds = await getPendingSiteResourceIdsForSite(
|
|
||||||
siteId,
|
|
||||||
orgId,
|
|
||||||
trx
|
|
||||||
);
|
|
||||||
|
|
||||||
const [deletedResources, siteResourcesDeleted] = await Promise.all([
|
|
||||||
performDeleteResources(resourceIds, trx),
|
|
||||||
performDeleteSiteResources(siteResourceIds, trx)
|
|
||||||
]);
|
|
||||||
|
|
||||||
return { resources: deletedResources, siteResources: siteResourcesDeleted };
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function runDeleteSiteAssociatedResourcesSideEffects(
|
|
||||||
sideEffects: DeleteSiteAssociatedResourcesSideEffects
|
|
||||||
): Promise<void> {
|
|
||||||
for (const result of sideEffects.resources) {
|
|
||||||
await runResourceDeleteSideEffects(result);
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const removed of sideEffects.siteResources) {
|
|
||||||
runSiteResourceDeleteSideEffects(removed);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,53 +0,0 @@
|
|||||||
import { inArray } from "drizzle-orm";
|
|
||||||
import {
|
|
||||||
db,
|
|
||||||
siteResources,
|
|
||||||
type SiteResource,
|
|
||||||
type Transaction
|
|
||||||
} from "@server/db";
|
|
||||||
import logger from "@server/logger";
|
|
||||||
import { rebuildClientAssociationsFromSiteResource } from "@server/lib/rebuildClientAssociations";
|
|
||||||
|
|
||||||
export async function performDeleteSiteResources(
|
|
||||||
siteResourceIds: number[],
|
|
||||||
trx: Transaction | typeof db = db
|
|
||||||
): Promise<SiteResource[]> {
|
|
||||||
if (siteResourceIds.length === 0) {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
|
|
||||||
const removedSiteResources = await trx
|
|
||||||
.delete(siteResources)
|
|
||||||
.where(inArray(siteResources.siteResourceId, siteResourceIds))
|
|
||||||
.returning();
|
|
||||||
|
|
||||||
if (removedSiteResources.length > 0) {
|
|
||||||
logger.debug(`Deleted ${removedSiteResources.length} site resources`);
|
|
||||||
}
|
|
||||||
|
|
||||||
return removedSiteResources;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function performDeleteSiteResource(
|
|
||||||
siteResourceId: number,
|
|
||||||
trx: Transaction | typeof db = db
|
|
||||||
): Promise<SiteResource | null> {
|
|
||||||
const [removedSiteResource] = await performDeleteSiteResources(
|
|
||||||
[siteResourceId],
|
|
||||||
trx
|
|
||||||
);
|
|
||||||
return removedSiteResource ?? null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function runSiteResourceDeleteSideEffects(
|
|
||||||
removedSiteResource: SiteResource
|
|
||||||
): void {
|
|
||||||
rebuildClientAssociationsFromSiteResource(removedSiteResource).catch(
|
|
||||||
(err) => {
|
|
||||||
logger.error(
|
|
||||||
`Error rebuilding client associations for site resource ${removedSiteResource.siteResourceId}:`,
|
|
||||||
err
|
|
||||||
);
|
|
||||||
}
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -19,11 +19,7 @@ export async function verifyExitNodeOrgAccess(
|
|||||||
export async function listExitNodes(
|
export async function listExitNodes(
|
||||||
orgId: string,
|
orgId: string,
|
||||||
filterOnline = false,
|
filterOnline = false,
|
||||||
noCloud = false,
|
noCloud = false
|
||||||
// Accepted for parity with the enterprise implementation (used there for
|
|
||||||
// site-label filtering of remote exit nodes). The OSS build has no remote
|
|
||||||
// exit nodes, so it is unused here.
|
|
||||||
siteId?: number
|
|
||||||
) {
|
) {
|
||||||
// TODO: pick which nodes to send and ping better than just all of them that are not remote
|
// TODO: pick which nodes to send and ping better than just all of them that are not remote
|
||||||
const allExitNodes = await db
|
const allExitNodes = await db
|
||||||
|
|||||||
@@ -1,55 +1,30 @@
|
|||||||
import { db, exitNodes, Transaction } from "@server/db";
|
import { db, exitNodes } from "@server/db";
|
||||||
import config from "@server/lib/config";
|
import config from "@server/lib/config";
|
||||||
import { findNextAvailableCidr } from "@server/lib/ip";
|
import { findNextAvailableCidr } from "@server/lib/ip";
|
||||||
import { lockManager } from "#dynamic/lib/lock";
|
|
||||||
|
|
||||||
/**
|
export async function getNextAvailableSubnet(): Promise<string> {
|
||||||
* Reserves the next available exit node subnet.
|
// Get all existing subnets from routes table
|
||||||
*
|
const existingAddresses = await db
|
||||||
* Exit node subnets must never overlap with one another - regardless of
|
.select({
|
||||||
* which org(s) they belong to - since HA exit nodes can end up routing for
|
address: exitNodes.address
|
||||||
* the same org. This acquires a lock that the caller MUST release (via the
|
})
|
||||||
* returned `release`) only after the chosen address has been durably
|
.from(exitNodes);
|
||||||
* persisted (e.g. after the enclosing transaction commits), otherwise
|
|
||||||
* concurrent callers can race and pick the same subnet.
|
const addresses = existingAddresses.map((a) => a.address);
|
||||||
*/
|
let subnet = findNextAvailableCidr(
|
||||||
export async function getNextAvailableSubnet(
|
addresses,
|
||||||
trx: Transaction | typeof db = db
|
config.getRawConfig().gerbil.block_size,
|
||||||
): Promise<{ value: string; release: () => Promise<void> }> {
|
config.getRawConfig().gerbil.subnet_group
|
||||||
const lockKey = "exit-node-subnet-allocation";
|
);
|
||||||
const acquired = await lockManager.acquireLockWithRetry(lockKey, 6000);
|
if (!subnet) {
|
||||||
if (!acquired) {
|
throw new Error("No available subnets remaining in space");
|
||||||
throw new Error(`Failed to acquire lock: ${lockKey}`);
|
|
||||||
}
|
}
|
||||||
const release = () => lockManager.releaseLock(lockKey, acquired);
|
|
||||||
|
|
||||||
try {
|
// replace the last octet with 1
|
||||||
// Get all existing subnets from routes table
|
subnet =
|
||||||
const existingAddresses = await trx
|
subnet.split(".").slice(0, 3).join(".") +
|
||||||
.select({
|
".1" +
|
||||||
address: exitNodes.address
|
"/" +
|
||||||
})
|
subnet.split("/")[1];
|
||||||
.from(exitNodes);
|
return subnet;
|
||||||
|
|
||||||
const addresses = existingAddresses.map((a) => a.address);
|
|
||||||
let subnet = findNextAvailableCidr(
|
|
||||||
addresses,
|
|
||||||
config.getRawConfig().gerbil.block_size,
|
|
||||||
config.getRawConfig().gerbil.subnet_group
|
|
||||||
);
|
|
||||||
if (!subnet) {
|
|
||||||
throw new Error("No available subnets remaining in space");
|
|
||||||
}
|
|
||||||
|
|
||||||
// replace the last octet with 1
|
|
||||||
subnet =
|
|
||||||
subnet.split(".").slice(0, 3).join(".") +
|
|
||||||
".1" +
|
|
||||||
"/" +
|
|
||||||
subnet.split("/")[1];
|
|
||||||
return { value: subnet, release };
|
|
||||||
} catch (e) {
|
|
||||||
await release();
|
|
||||||
throw e;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
+9
-27
@@ -333,7 +333,7 @@ export async function getNextAvailableClientSubnet(
|
|||||||
if (!acquired) {
|
if (!acquired) {
|
||||||
throw new Error(`Failed to acquire lock: ${lockKey}`);
|
throw new Error(`Failed to acquire lock: ${lockKey}`);
|
||||||
}
|
}
|
||||||
const release = () => lockManager.releaseLock(lockKey, acquired);
|
const release = () => lockManager.releaseLock(lockKey);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const [org] = await transaction
|
const [org] = await transaction
|
||||||
@@ -395,7 +395,7 @@ export async function getNextAvailableAliasAddress(
|
|||||||
if (!acquired) {
|
if (!acquired) {
|
||||||
throw new Error(`Failed to acquire lock: ${lockKey}`);
|
throw new Error(`Failed to acquire lock: ${lockKey}`);
|
||||||
}
|
}
|
||||||
const release = () => lockManager.releaseLock(lockKey, acquired);
|
const release = () => lockManager.releaseLock(lockKey);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const [org] = await trx
|
const [org] = await trx
|
||||||
@@ -463,7 +463,7 @@ export async function getNextAvailableOrgSubnet(): Promise<{
|
|||||||
if (!acquired) {
|
if (!acquired) {
|
||||||
throw new Error(`Failed to acquire lock: ${lockKey}`);
|
throw new Error(`Failed to acquire lock: ${lockKey}`);
|
||||||
}
|
}
|
||||||
const release = () => lockManager.releaseLock(lockKey, acquired);
|
const release = () => lockManager.releaseLock(lockKey);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const existingAddresses = await db
|
const existingAddresses = await db
|
||||||
@@ -496,7 +496,6 @@ export function generateRemoteSubnets(
|
|||||||
): string[] {
|
): string[] {
|
||||||
const remoteSubnets = allSiteResources
|
const remoteSubnets = allSiteResources
|
||||||
.filter((sr) => {
|
.filter((sr) => {
|
||||||
if (!sr.enabled) return false;
|
|
||||||
if (!sr.destination) return false;
|
if (!sr.destination) return false;
|
||||||
|
|
||||||
if (sr.mode === "cidr") {
|
if (sr.mode === "cidr") {
|
||||||
@@ -505,7 +504,7 @@ export function generateRemoteSubnets(
|
|||||||
const parseResult = cidrSchema.safeParse(sr.destination);
|
const parseResult = cidrSchema.safeParse(sr.destination);
|
||||||
return parseResult.success;
|
return parseResult.success;
|
||||||
}
|
}
|
||||||
if (sr.mode === "host" || sr.mode === "ssh") {
|
if (sr.mode === "host") {
|
||||||
// check if its a valid IP using zod
|
// check if its a valid IP using zod
|
||||||
const ipSchema = z.union([z.ipv4(), z.ipv6()]);
|
const ipSchema = z.union([z.ipv4(), z.ipv6()]);
|
||||||
const parseResult = ipSchema.safeParse(sr.destination);
|
const parseResult = ipSchema.safeParse(sr.destination);
|
||||||
@@ -515,7 +514,7 @@ export function generateRemoteSubnets(
|
|||||||
})
|
})
|
||||||
.map((sr) => {
|
.map((sr) => {
|
||||||
if (sr.mode === "cidr") return sr.destination;
|
if (sr.mode === "cidr") return sr.destination;
|
||||||
if (sr.mode === "host" || sr.mode === "ssh") {
|
if (sr.mode === "host") {
|
||||||
return `${sr.destination}/32`;
|
return `${sr.destination}/32`;
|
||||||
}
|
}
|
||||||
return ""; // This should never be reached due to filtering, but satisfies TypeScript
|
return ""; // This should never be reached due to filtering, but satisfies TypeScript
|
||||||
@@ -531,9 +530,8 @@ export function generateAliasConfig(allSiteResources: SiteResource[]): Alias[] {
|
|||||||
return allSiteResources
|
return allSiteResources
|
||||||
.filter(
|
.filter(
|
||||||
(sr) =>
|
(sr) =>
|
||||||
sr.enabled &&
|
|
||||||
sr.aliasAddress &&
|
sr.aliasAddress &&
|
||||||
((sr.alias && (sr.mode == "host" || sr.mode == "ssh")) ||
|
((sr.alias && sr.mode == "host") ||
|
||||||
(sr.fullDomain && sr.mode == "http"))
|
(sr.fullDomain && sr.mode == "http"))
|
||||||
)
|
)
|
||||||
.map((sr) => ({
|
.map((sr) => ({
|
||||||
@@ -579,10 +577,6 @@ export function generateSubnetProxyTargets(
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!siteResource.destination) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
const clientPrefix = `${clientSite.subnet.split("/")[0]}/32`;
|
const clientPrefix = `${clientSite.subnet.split("/")[0]}/32`;
|
||||||
const portRange = [
|
const portRange = [
|
||||||
...parsePortRangeString(siteResource.tcpPortRangeString, "tcp"),
|
...parsePortRangeString(siteResource.tcpPortRangeString, "tcp"),
|
||||||
@@ -590,7 +584,7 @@ export function generateSubnetProxyTargets(
|
|||||||
];
|
];
|
||||||
const disableIcmp = siteResource.disableIcmp ?? false;
|
const disableIcmp = siteResource.disableIcmp ?? false;
|
||||||
|
|
||||||
if (siteResource.mode == "host" || siteResource.mode == "ssh") {
|
if (siteResource.mode == "host") {
|
||||||
let destination = siteResource.destination;
|
let destination = siteResource.destination;
|
||||||
// check if this is a valid ip
|
// check if this is a valid ip
|
||||||
const ipSchema = z.union([z.ipv4(), z.ipv6()]);
|
const ipSchema = z.union([z.ipv4(), z.ipv6()]);
|
||||||
@@ -664,13 +658,6 @@ export async function generateSubnetProxyTargetV2(
|
|||||||
subnet: string | null;
|
subnet: string | null;
|
||||||
}[]
|
}[]
|
||||||
): Promise<SubnetProxyTargetV2[] | undefined> {
|
): Promise<SubnetProxyTargetV2[] | undefined> {
|
||||||
if (!siteResource.enabled) {
|
|
||||||
logger.debug(
|
|
||||||
`Site resource ${siteResource.siteResourceId} is disabled, skipping target generation.`
|
|
||||||
);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (clients.length === 0) {
|
if (clients.length === 0) {
|
||||||
logger.debug(
|
logger.debug(
|
||||||
`No clients have access to site resource ${siteResource.siteResourceId}, skipping target generation.`
|
`No clients have access to site resource ${siteResource.siteResourceId}, skipping target generation.`
|
||||||
@@ -678,12 +665,7 @@ export async function generateSubnetProxyTargetV2(
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!siteResource.destination) {
|
let targets: SubnetProxyTargetV2[] = [];
|
||||||
// ssh can have no destination
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const targets: SubnetProxyTargetV2[] = [];
|
|
||||||
|
|
||||||
const portRange = [
|
const portRange = [
|
||||||
...parsePortRangeString(siteResource.tcpPortRangeString, "tcp"),
|
...parsePortRangeString(siteResource.tcpPortRangeString, "tcp"),
|
||||||
@@ -691,7 +673,7 @@ export async function generateSubnetProxyTargetV2(
|
|||||||
];
|
];
|
||||||
const disableIcmp = siteResource.disableIcmp ?? false;
|
const disableIcmp = siteResource.disableIcmp ?? false;
|
||||||
|
|
||||||
if (siteResource.mode == "host" || siteResource.mode == "ssh") {
|
if (siteResource.mode == "host") {
|
||||||
let destination = siteResource.destination;
|
let destination = siteResource.destination;
|
||||||
// check if this is a valid ip
|
// check if this is a valid ip
|
||||||
const ipSchema = z.union([z.ipv4(), z.ipv6()]);
|
const ipSchema = z.union([z.ipv4(), z.ipv6()]);
|
||||||
|
|||||||
+18
-138
@@ -1,87 +1,28 @@
|
|||||||
import { randomUUID } from "crypto";
|
|
||||||
|
|
||||||
const instanceId = `local-${Math.random().toString(36).slice(2)}-${Date.now()}`;
|
|
||||||
|
|
||||||
type LocalLockRecord = {
|
|
||||||
owner: string;
|
|
||||||
expiresAt: number;
|
|
||||||
};
|
|
||||||
|
|
||||||
const localLocks = new Map<string, LocalLockRecord>();
|
|
||||||
|
|
||||||
export class LockManager {
|
export class LockManager {
|
||||||
private clearExpiredLocalLock(lockKey: string): void {
|
|
||||||
const current = localLocks.get(lockKey);
|
|
||||||
if (current && current.expiresAt <= Date.now()) {
|
|
||||||
localLocks.delete(lockKey);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Acquire a local in-process lock using an optimistic Map-based check.
|
* Acquire a distributed lock using Redis SET with NX and PX options
|
||||||
* @param lockKey - Unique identifier for the lock
|
* @param lockKey - Unique identifier for the lock
|
||||||
* @param ttlMs - Time to live in milliseconds
|
* @param ttlMs - Time to live in milliseconds
|
||||||
* @returns Promise<string | null> - a token identifying this specific acquisition
|
* @returns Promise<boolean> - true if lock acquired, false otherwise
|
||||||
* (truthy) on success, or null if the lock could not be acquired.
|
|
||||||
*/
|
*/
|
||||||
async acquireLock(
|
async acquireLock(
|
||||||
lockKey: string,
|
lockKey: string,
|
||||||
ttlMs: number = 30000,
|
ttlMs: number = 30000
|
||||||
maxRetries: number = 3,
|
): Promise<boolean> {
|
||||||
retryDelayMs: number = 100
|
return true;
|
||||||
): Promise<string | null> {
|
|
||||||
for (let attempt = 0; attempt < maxRetries; attempt++) {
|
|
||||||
this.clearExpiredLocalLock(lockKey);
|
|
||||||
|
|
||||||
const existing = localLocks.get(lockKey);
|
|
||||||
if (!existing) {
|
|
||||||
const token = `${instanceId}:${randomUUID()}`;
|
|
||||||
localLocks.set(lockKey, {
|
|
||||||
owner: token,
|
|
||||||
expiresAt: Date.now() + ttlMs
|
|
||||||
});
|
|
||||||
return token;
|
|
||||||
}
|
|
||||||
|
|
||||||
// The lock is currently held -- possibly by a different, unrelated
|
|
||||||
// caller in this same process. We intentionally do NOT treat
|
|
||||||
// same-process holders as automatically reentrant here: two
|
|
||||||
// independent logical operations (e.g. two different API requests)
|
|
||||||
// running concurrently in the same process must not both believe
|
|
||||||
// they hold the lock, or their writes under it can interleave
|
|
||||||
// unguarded. Just retry with backoff like any other contended lock.
|
|
||||||
if (attempt < maxRetries - 1) {
|
|
||||||
const delay = retryDelayMs * Math.pow(2, attempt);
|
|
||||||
await new Promise((resolve) => setTimeout(resolve, delay));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Release a lock previously acquired via acquireLock/acquireLockWithRetry.
|
* Release a lock using Lua script to ensure atomicity
|
||||||
* @param lockKey - Unique identifier for the lock
|
* @param lockKey - Unique identifier for the lock
|
||||||
* @param token - the exact token returned by the acquisition being released.
|
|
||||||
* Required so a caller whose TTL already expired can't delete a
|
|
||||||
* different, currently-active holder's lock.
|
|
||||||
*/
|
*/
|
||||||
async releaseLock(lockKey: string, token: string): Promise<void> {
|
async releaseLock(lockKey: string): Promise<void> {}
|
||||||
this.clearExpiredLocalLock(lockKey);
|
|
||||||
const existing = localLocks.get(lockKey);
|
|
||||||
|
|
||||||
if (existing && existing.owner === token) {
|
|
||||||
localLocks.delete(lockKey);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Force release a lock regardless of owner (use with caution)
|
* Force release a lock regardless of owner (use with caution)
|
||||||
* @param lockKey - Unique identifier for the lock
|
* @param lockKey - Unique identifier for the lock
|
||||||
*/
|
*/
|
||||||
async forceReleaseLock(lockKey: string): Promise<void> {
|
async forceReleaseLock(lockKey: string): Promise<void> {}
|
||||||
localLocks.delete(lockKey);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Check if a lock exists and get its info
|
* Check if a lock exists and get its info
|
||||||
@@ -94,44 +35,16 @@ export class LockManager {
|
|||||||
ttl: number;
|
ttl: number;
|
||||||
owner?: string;
|
owner?: string;
|
||||||
}> {
|
}> {
|
||||||
this.clearExpiredLocalLock(lockKey);
|
return { exists: true, ownedByMe: true, ttl: 0 };
|
||||||
const existing = localLocks.get(lockKey);
|
|
||||||
|
|
||||||
if (!existing) {
|
|
||||||
return { exists: false, ownedByMe: false, ttl: 0 };
|
|
||||||
}
|
|
||||||
|
|
||||||
const ttl = Math.max(0, existing.expiresAt - Date.now());
|
|
||||||
return {
|
|
||||||
exists: true,
|
|
||||||
ownedByMe: existing.owner.startsWith(`${instanceId}:`),
|
|
||||||
ttl,
|
|
||||||
owner: existing.owner.split(":")[0]
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Extend the TTL of an existing lock, provided the token matches the
|
* Extend the TTL of an existing lock owned by this worker
|
||||||
* acquisition currently holding it.
|
|
||||||
* @param lockKey - Unique identifier for the lock
|
* @param lockKey - Unique identifier for the lock
|
||||||
* @param ttlMs - New TTL in milliseconds
|
* @param ttlMs - New TTL in milliseconds
|
||||||
* @param token - the token returned by the acquisition being extended
|
|
||||||
* @returns Promise<boolean> - true if extended successfully
|
* @returns Promise<boolean> - true if extended successfully
|
||||||
*/
|
*/
|
||||||
async extendLock(
|
async extendLock(lockKey: string, ttlMs: number): Promise<boolean> {
|
||||||
lockKey: string,
|
|
||||||
ttlMs: number,
|
|
||||||
token: string
|
|
||||||
): Promise<boolean> {
|
|
||||||
this.clearExpiredLocalLock(lockKey);
|
|
||||||
const existing = localLocks.get(lockKey);
|
|
||||||
|
|
||||||
if (!existing || existing.owner !== token) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
existing.expiresAt = Date.now() + ttlMs;
|
|
||||||
localLocks.set(lockKey, existing);
|
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -141,34 +54,15 @@ export class LockManager {
|
|||||||
* @param ttlMs - Time to live in milliseconds
|
* @param ttlMs - Time to live in milliseconds
|
||||||
* @param maxRetries - Maximum number of retry attempts
|
* @param maxRetries - Maximum number of retry attempts
|
||||||
* @param baseDelayMs - Base delay between retries in milliseconds
|
* @param baseDelayMs - Base delay between retries in milliseconds
|
||||||
* @returns Promise<string | null> - token if acquired, null otherwise
|
* @returns Promise<boolean> - true if lock acquired
|
||||||
*/
|
*/
|
||||||
async acquireLockWithRetry(
|
async acquireLockWithRetry(
|
||||||
lockKey: string,
|
lockKey: string,
|
||||||
ttlMs: number = 30000,
|
ttlMs: number = 30000,
|
||||||
maxRetries: number = 5,
|
maxRetries: number = 5,
|
||||||
baseDelayMs: number = 100
|
baseDelayMs: number = 100
|
||||||
): Promise<string | null> {
|
): Promise<boolean> {
|
||||||
for (let attempt = 0; attempt <= maxRetries; attempt++) {
|
return true;
|
||||||
const acquired = await this.acquireLock(
|
|
||||||
lockKey,
|
|
||||||
ttlMs,
|
|
||||||
1,
|
|
||||||
baseDelayMs
|
|
||||||
);
|
|
||||||
|
|
||||||
if (acquired) {
|
|
||||||
return acquired;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (attempt < maxRetries) {
|
|
||||||
const delay =
|
|
||||||
baseDelayMs * Math.pow(2, attempt) + Math.random() * 100;
|
|
||||||
await new Promise((resolve) => setTimeout(resolve, delay));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -183,16 +77,16 @@ export class LockManager {
|
|||||||
fn: () => Promise<T>,
|
fn: () => Promise<T>,
|
||||||
ttlMs: number = 30000
|
ttlMs: number = 30000
|
||||||
): Promise<T> {
|
): Promise<T> {
|
||||||
const token = await this.acquireLock(lockKey, ttlMs);
|
const acquired = await this.acquireLock(lockKey, ttlMs);
|
||||||
|
|
||||||
if (!token) {
|
if (!acquired) {
|
||||||
throw new Error(`Failed to acquire lock: ${lockKey}`);
|
throw new Error(`Failed to acquire lock: ${lockKey}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
return await fn();
|
return await fn();
|
||||||
} finally {
|
} finally {
|
||||||
await this.releaseLock(lockKey, token);
|
await this.releaseLock(lockKey);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -205,21 +99,7 @@ export class LockManager {
|
|||||||
activeLocksCount: number;
|
activeLocksCount: number;
|
||||||
locksOwnedByMe: number;
|
locksOwnedByMe: number;
|
||||||
}> {
|
}> {
|
||||||
const now = Date.now();
|
return { activeLocksCount: 0, locksOwnedByMe: 0 };
|
||||||
for (const [key, value] of localLocks.entries()) {
|
|
||||||
if (value.expiresAt <= now) {
|
|
||||||
localLocks.delete(key);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let locksOwnedByMe = 0;
|
|
||||||
for (const value of localLocks.values()) {
|
|
||||||
if (value.owner.startsWith(`${instanceId}:`)) {
|
|
||||||
locksOwnedByMe++;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return { activeLocksCount: localLocks.size, locksOwnedByMe };
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -1,24 +0,0 @@
|
|||||||
export const ORG_REBUILD_CONCURRENCY_LIMIT = 10;
|
|
||||||
|
|
||||||
const orgActiveRebuilds = new Map<string, number>();
|
|
||||||
|
|
||||||
export async function incrementOrgRebuildCount(orgId: string): Promise<void> {
|
|
||||||
orgActiveRebuilds.set(orgId, (orgActiveRebuilds.get(orgId) ?? 0) + 1);
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function decrementOrgRebuildCount(orgId: string): Promise<void> {
|
|
||||||
const current = orgActiveRebuilds.get(orgId) ?? 0;
|
|
||||||
if (current <= 1) {
|
|
||||||
orgActiveRebuilds.delete(orgId);
|
|
||||||
} else {
|
|
||||||
orgActiveRebuilds.set(orgId, current - 1);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function getOrgActiveRebuildCount(orgId: string): Promise<number> {
|
|
||||||
return orgActiveRebuilds.get(orgId) ?? 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function checkOrgRebuildRateLimit(orgId: string): Promise<boolean> {
|
|
||||||
return (orgActiveRebuilds.get(orgId) ?? 0) >= ORG_REBUILD_CONCURRENCY_LIMIT;
|
|
||||||
}
|
|
||||||
@@ -1,74 +0,0 @@
|
|||||||
const MAX_RECURSION_DEPTH = 100;
|
|
||||||
|
|
||||||
const segmentRegexCache = new Map<string, RegExp>();
|
|
||||||
|
|
||||||
function getSegmentRegex(patternPart: string): RegExp {
|
|
||||||
let regex = segmentRegexCache.get(patternPart);
|
|
||||||
if (!regex) {
|
|
||||||
const regexPattern = patternPart
|
|
||||||
.replace(/[.+^${}()|[\]\\]/g, "\\$&")
|
|
||||||
.replace(/\*/g, ".*")
|
|
||||||
.replace(/\?/g, ".");
|
|
||||||
regex = new RegExp(`^${regexPattern}$`);
|
|
||||||
segmentRegexCache.set(patternPart, regex);
|
|
||||||
}
|
|
||||||
return regex;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function isPathAllowed(pattern: string, path: string): boolean {
|
|
||||||
const normalize = (p: string) => p.split("/").filter(Boolean);
|
|
||||||
const patternParts = normalize(pattern);
|
|
||||||
const pathParts = normalize(path);
|
|
||||||
|
|
||||||
function matchSegments(
|
|
||||||
patternIndex: number,
|
|
||||||
pathIndex: number,
|
|
||||||
depth: number = 0
|
|
||||||
): boolean {
|
|
||||||
if (depth > MAX_RECURSION_DEPTH) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
const currentPatternPart = patternParts[patternIndex];
|
|
||||||
const currentPathPart = pathParts[pathIndex];
|
|
||||||
|
|
||||||
if (patternIndex >= patternParts.length) {
|
|
||||||
return pathIndex >= pathParts.length;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (pathIndex >= pathParts.length) {
|
|
||||||
return patternParts.slice(patternIndex).every((p) => p === "*");
|
|
||||||
}
|
|
||||||
|
|
||||||
if (currentPatternPart === "*") {
|
|
||||||
if (matchSegments(patternIndex + 1, pathIndex, depth + 1)) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
if (matchSegments(patternIndex, pathIndex + 1, depth + 1)) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (currentPatternPart.includes("*")) {
|
|
||||||
const regex = getSegmentRegex(currentPatternPart);
|
|
||||||
|
|
||||||
if (regex.test(currentPathPart)) {
|
|
||||||
return matchSegments(
|
|
||||||
patternIndex + 1,
|
|
||||||
pathIndex + 1,
|
|
||||||
depth + 1
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (currentPatternPart !== currentPathPart) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
return matchSegments(patternIndex + 1, pathIndex + 1, depth + 1);
|
|
||||||
}
|
|
||||||
|
|
||||||
return matchSegments(0, 0, 0);
|
|
||||||
}
|
|
||||||
@@ -184,8 +184,7 @@ export const configSchema = z
|
|||||||
.number()
|
.number()
|
||||||
.positive()
|
.positive()
|
||||||
.optional()
|
.optional()
|
||||||
.default(5000),
|
.default(5000)
|
||||||
jit_mode: z.boolean().default(true)
|
|
||||||
})
|
})
|
||||||
.optional()
|
.optional()
|
||||||
.prefault({})
|
.prefault({})
|
||||||
|
|||||||
+274
-1205
File diff suppressed because it is too large
Load Diff
@@ -1,148 +0,0 @@
|
|||||||
import logger from "@server/logger";
|
|
||||||
import { isTransientError } from "@server/lib/dbRetry";
|
|
||||||
|
|
||||||
export type RebuildJobType = "site-resource" | "client";
|
|
||||||
|
|
||||||
export interface RebuildJob {
|
|
||||||
type: RebuildJobType;
|
|
||||||
id: number;
|
|
||||||
// Number of times this job has already been re-queued after a transient
|
|
||||||
// failure. Absent/0 means it has not failed yet.
|
|
||||||
attempt?: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface RebuildJobHandlers {
|
|
||||||
onSiteResource(siteResourceId: number): Promise<void>;
|
|
||||||
onClient(clientId: number): Promise<void>;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface RebuildQueueManager {
|
|
||||||
enqueue(job: RebuildJob): Promise<void>;
|
|
||||||
startProcessing(handlers: RebuildJobHandlers): void;
|
|
||||||
isQueued(job: RebuildJob): Promise<boolean>;
|
|
||||||
}
|
|
||||||
|
|
||||||
// In-process FIFO used when there is no Redis to back a distributed queue
|
|
||||||
// (OSS build, or Redis unavailable). A job that loses the per-resource
|
|
||||||
// rebuild lock race lands here instead of being silently dropped, and gets
|
|
||||||
// retried shortly after against fresh DB state.
|
|
||||||
const POLL_INTERVAL_MS = 500;
|
|
||||||
const BATCH_SIZE = 5;
|
|
||||||
// A job that fails with a transient DB error gets re-queued with backoff
|
|
||||||
// instead of being dropped, up to this many times.
|
|
||||||
const MAX_JOB_ATTEMPTS = 5;
|
|
||||||
const JOB_RETRY_BASE_DELAY_MS = 1000;
|
|
||||||
|
|
||||||
function dedupeKey(job: RebuildJob): string {
|
|
||||||
return `${job.type}:${job.id}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
class InMemoryRebuildQueue implements RebuildQueueManager {
|
|
||||||
private queue: RebuildJob[] = [];
|
|
||||||
private queuedSet = new Set<string>();
|
|
||||||
private processing = false;
|
|
||||||
private processingStarted = false;
|
|
||||||
private handlers: RebuildJobHandlers | null = null;
|
|
||||||
|
|
||||||
async isQueued(job: RebuildJob): Promise<boolean> {
|
|
||||||
return this.queuedSet.has(dedupeKey(job));
|
|
||||||
}
|
|
||||||
|
|
||||||
async enqueue(job: RebuildJob): Promise<void> {
|
|
||||||
const key = dedupeKey(job);
|
|
||||||
if (this.queuedSet.has(key)) {
|
|
||||||
logger.debug(
|
|
||||||
`Rebuild queue: skipped duplicate queued job ${job.type}:${job.id}`
|
|
||||||
);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
this.queuedSet.add(key);
|
|
||||||
this.queue.push(job);
|
|
||||||
logger.debug(
|
|
||||||
`Rebuild queue: enqueued ${job.type}:${job.id} (queue position: tail)`
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
startProcessing(handlers: RebuildJobHandlers): void {
|
|
||||||
if (this.processingStarted) return;
|
|
||||||
this.processingStarted = true;
|
|
||||||
this.handlers = handlers;
|
|
||||||
|
|
||||||
setInterval(() => {
|
|
||||||
this.tryProcessBatch().catch((err) => {
|
|
||||||
logger.error(
|
|
||||||
"Rebuild queue: unhandled error in process loop:",
|
|
||||||
err
|
|
||||||
);
|
|
||||||
});
|
|
||||||
}, POLL_INTERVAL_MS);
|
|
||||||
|
|
||||||
logger.info("Rebuild queue processor started (in-memory)");
|
|
||||||
}
|
|
||||||
|
|
||||||
private async tryProcessBatch(): Promise<void> {
|
|
||||||
if (this.processing || !this.handlers || this.queue.length === 0) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
this.processing = true;
|
|
||||||
try {
|
|
||||||
for (let i = 0; i < BATCH_SIZE; i++) {
|
|
||||||
const job = this.queue.shift();
|
|
||||||
if (!job) break; // queue drained
|
|
||||||
|
|
||||||
// Remove from the dedupe set once dequeued so the same job
|
|
||||||
// can be re-queued while this one is in progress.
|
|
||||||
this.queuedSet.delete(dedupeKey(job));
|
|
||||||
|
|
||||||
logger.debug(
|
|
||||||
`Rebuild queue: processing ${job.type}:${job.id}`
|
|
||||||
);
|
|
||||||
|
|
||||||
try {
|
|
||||||
if (job.type === "site-resource") {
|
|
||||||
await this.handlers.onSiteResource(job.id);
|
|
||||||
} else if (job.type === "client") {
|
|
||||||
await this.handlers.onClient(job.id);
|
|
||||||
} else {
|
|
||||||
logger.warn(
|
|
||||||
`Rebuild queue: unknown job type "${(job as any).type}", discarding`
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
logger.debug(
|
|
||||||
`Rebuild queue: completed ${job.type}:${job.id}`
|
|
||||||
);
|
|
||||||
} catch (err) {
|
|
||||||
const attempt = (job.attempt ?? 0) + 1;
|
|
||||||
if (isTransientError(err) && attempt <= MAX_JOB_ATTEMPTS) {
|
|
||||||
const delay =
|
|
||||||
JOB_RETRY_BASE_DELAY_MS * Math.pow(2, attempt - 1);
|
|
||||||
logger.warn(
|
|
||||||
`Rebuild queue: job ${job.type}:${job.id} hit a transient error (attempt ${attempt}/${MAX_JOB_ATTEMPTS}), re-queuing in ${delay}ms:`,
|
|
||||||
err
|
|
||||||
);
|
|
||||||
setTimeout(() => {
|
|
||||||
this.enqueue({ ...job, attempt }).catch(
|
|
||||||
(enqueueErr) =>
|
|
||||||
logger.error(
|
|
||||||
`Rebuild queue: failed to re-queue ${job.type}:${job.id} after transient error:`,
|
|
||||||
enqueueErr
|
|
||||||
)
|
|
||||||
);
|
|
||||||
}, delay);
|
|
||||||
} else {
|
|
||||||
logger.error(
|
|
||||||
`Rebuild queue: job ${job.type}:${job.id} threw an error:`,
|
|
||||||
err
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} finally {
|
|
||||||
this.processing = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export const rebuildQueue: RebuildQueueManager = new InMemoryRebuildQueue();
|
|
||||||
+20
-77
@@ -1,6 +1,6 @@
|
|||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { db, logsDb, statusHistory } from "@server/db";
|
import { db, logsDb, statusHistory } from "@server/db";
|
||||||
import { and, eq, gte, lt, asc, desc } from "drizzle-orm";
|
import { and, eq, gte, asc } from "drizzle-orm";
|
||||||
import { regionalCache as cache } from "#dynamic/lib/cache";
|
import { regionalCache as cache } from "#dynamic/lib/cache";
|
||||||
|
|
||||||
const STATUS_HISTORY_CACHE_TTL = 60; // seconds
|
const STATUS_HISTORY_CACHE_TTL = 60; // seconds
|
||||||
@@ -8,42 +8,26 @@ const STATUS_HISTORY_CACHE_TTL = 60; // seconds
|
|||||||
function statusHistoryCacheKey(
|
function statusHistoryCacheKey(
|
||||||
entityType: string,
|
entityType: string,
|
||||||
entityId: number,
|
entityId: number,
|
||||||
days: number,
|
days: number
|
||||||
tzOffsetMinutes: number
|
|
||||||
): string {
|
): string {
|
||||||
return `statusHistory:${entityType}:${entityId}:${days}:${tzOffsetMinutes}`;
|
return `statusHistory:${entityType}:${entityId}:${days}`;
|
||||||
}
|
|
||||||
|
|
||||||
// Returns the epoch seconds of the most recent local-calendar-day midnight,
|
|
||||||
// where "local" is defined by tzOffsetMinutes (minutes to ADD to UTC to get
|
|
||||||
// local time, e.g. Australia/Sydney standard time is 600). Defaults to 0
|
|
||||||
// (UTC) so callers that don't pass a timezone keep the original behavior.
|
|
||||||
function localMidnightSec(tzOffsetMinutes: number): number {
|
|
||||||
const localNow = new Date(Date.now() + tzOffsetMinutes * 60_000);
|
|
||||||
localNow.setUTCHours(0, 0, 0, 0);
|
|
||||||
return Math.floor(localNow.getTime() / 1000) - tzOffsetMinutes * 60;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getCachedStatusHistory(
|
export async function getCachedStatusHistory(
|
||||||
entityType: string,
|
entityType: string,
|
||||||
entityId: number,
|
entityId: number,
|
||||||
days: number,
|
days: number
|
||||||
tzOffsetMinutes: number = 0
|
|
||||||
): Promise<StatusHistoryResponse> {
|
): Promise<StatusHistoryResponse> {
|
||||||
const cacheKey = statusHistoryCacheKey(
|
const cacheKey = statusHistoryCacheKey(entityType, entityId, days);
|
||||||
entityType,
|
|
||||||
entityId,
|
|
||||||
days,
|
|
||||||
tzOffsetMinutes
|
|
||||||
);
|
|
||||||
const cached = await cache.get<StatusHistoryResponse>(cacheKey);
|
const cached = await cache.get<StatusHistoryResponse>(cacheKey);
|
||||||
if (cached !== undefined) {
|
if (cached !== undefined) {
|
||||||
return cached;
|
return cached;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Anchor to local midnight (UTC when tzOffsetMinutes is 0) so the query
|
// Anchor to UTC midnight so the query window aligns with stable calendar days
|
||||||
// window aligns with stable calendar days for the requesting client
|
const utcToday = new Date();
|
||||||
const todayMidnightSec = localMidnightSec(tzOffsetMinutes);
|
utcToday.setUTCHours(0, 0, 0, 0);
|
||||||
|
const todayMidnightSec = Math.floor(utcToday.getTime() / 1000);
|
||||||
const startSec = todayMidnightSec - days * 86400;
|
const startSec = todayMidnightSec - days * 86400;
|
||||||
|
|
||||||
const events = await logsDb
|
const events = await logsDb
|
||||||
@@ -58,30 +42,7 @@ export async function getCachedStatusHistory(
|
|||||||
)
|
)
|
||||||
.orderBy(asc(statusHistory.timestamp));
|
.orderBy(asc(statusHistory.timestamp));
|
||||||
|
|
||||||
// Fetch the last known state before the window so that entities that
|
const { buckets, totalDowntime } = computeBuckets(events, days);
|
||||||
// haven't changed status recently still show the correct status rather
|
|
||||||
// than appearing as "no_data".
|
|
||||||
const [lastKnownEvent] = await logsDb
|
|
||||||
.select()
|
|
||||||
.from(statusHistory)
|
|
||||||
.where(
|
|
||||||
and(
|
|
||||||
eq(statusHistory.entityType, entityType),
|
|
||||||
eq(statusHistory.entityId, entityId),
|
|
||||||
lt(statusHistory.timestamp, startSec)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
.orderBy(desc(statusHistory.timestamp))
|
|
||||||
.limit(1);
|
|
||||||
|
|
||||||
const priorStatus = lastKnownEvent?.status ?? null;
|
|
||||||
|
|
||||||
const { buckets, totalDowntime } = computeBuckets(
|
|
||||||
events,
|
|
||||||
days,
|
|
||||||
priorStatus,
|
|
||||||
tzOffsetMinutes
|
|
||||||
);
|
|
||||||
const totalWindow = days * 86400;
|
const totalWindow = days * 86400;
|
||||||
const overallUptime =
|
const overallUptime =
|
||||||
totalWindow > 0
|
totalWindow > 0
|
||||||
@@ -116,19 +77,11 @@ export const statusHistoryQuerySchema = z
|
|||||||
days: z
|
days: z
|
||||||
.string()
|
.string()
|
||||||
.optional()
|
.optional()
|
||||||
.transform((v) => (v ? parseInt(v, 10) : 90)),
|
.transform((v) => (v ? parseInt(v, 10) : 90))
|
||||||
// Minutes to add to UTC to get the requesting client's local time
|
|
||||||
// (e.g. Australia/Sydney standard time is 600). Optional and
|
|
||||||
// defaults to 0 (UTC) so older clients keep the prior behavior.
|
|
||||||
tzOffsetMinutes: z
|
|
||||||
.string()
|
|
||||||
.optional()
|
|
||||||
.transform((v) => (v ? parseInt(v, 10) : 0))
|
|
||||||
})
|
})
|
||||||
.pipe(
|
.pipe(
|
||||||
z.object({
|
z.object({
|
||||||
days: z.number().int().min(1).max(365),
|
days: z.number().int().min(1).max(365)
|
||||||
tzOffsetMinutes: z.number().int().min(-720).max(840)
|
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -157,16 +110,15 @@ export function computeBuckets(
|
|||||||
timestamp: number;
|
timestamp: number;
|
||||||
id: number;
|
id: number;
|
||||||
}[],
|
}[],
|
||||||
days: number,
|
days: number
|
||||||
priorStatus: string | null = null,
|
|
||||||
tzOffsetMinutes: number = 0
|
|
||||||
): { buckets: StatusHistoryDayBucket[]; totalDowntime: number } {
|
): { buckets: StatusHistoryDayBucket[]; totalDowntime: number } {
|
||||||
const nowSec = Math.floor(Date.now() / 1000);
|
const nowSec = Math.floor(Date.now() / 1000);
|
||||||
|
|
||||||
// Anchor bucket boundaries to local midnight (UTC when tzOffsetMinutes is
|
// Anchor bucket boundaries to UTC midnight so dates are stable calendar days
|
||||||
// 0) so dates are stable calendar days for the requesting client and
|
// and don't drift as the cache expires and is recomputed
|
||||||
// don't drift as the cache expires and is recomputed
|
const utcToday = new Date();
|
||||||
const todayMidnightSec = localMidnightSec(tzOffsetMinutes);
|
utcToday.setUTCHours(0, 0, 0, 0);
|
||||||
|
const todayMidnightSec = Math.floor(utcToday.getTime() / 1000);
|
||||||
|
|
||||||
const buckets: StatusHistoryDayBucket[] = [];
|
const buckets: StatusHistoryDayBucket[] = [];
|
||||||
let totalDowntime = 0;
|
let totalDowntime = 0;
|
||||||
@@ -184,10 +136,7 @@ export function computeBuckets(
|
|||||||
.filter((e) => e.timestamp < dayStartSec)
|
.filter((e) => e.timestamp < dayStartSec)
|
||||||
.at(-1);
|
.at(-1);
|
||||||
|
|
||||||
// Fall back to the last known state before the entire query window
|
const currentStatus = lastBeforeDay?.status ?? null;
|
||||||
// so that entities that haven't generated events recently still show
|
|
||||||
// as their actual status rather than "no_data".
|
|
||||||
const currentStatus = lastBeforeDay?.status ?? priorStatus ?? null;
|
|
||||||
|
|
||||||
const windows: { start: number; end: number | null; status: string }[] =
|
const windows: { start: number; end: number | null; status: string }[] =
|
||||||
[];
|
[];
|
||||||
@@ -262,13 +211,7 @@ export function computeBuckets(
|
|||||||
)
|
)
|
||||||
: 100;
|
: 100;
|
||||||
|
|
||||||
// Shift by the client's offset before formatting so the label reflects
|
const dateStr = new Date(dayStartSec * 1000).toISOString().slice(0, 10);
|
||||||
// their local calendar date rather than the UTC date of dayStartSec
|
|
||||||
const dateStr = new Date(
|
|
||||||
(dayStartSec + tzOffsetMinutes * 60) * 1000
|
|
||||||
)
|
|
||||||
.toISOString()
|
|
||||||
.slice(0, 10);
|
|
||||||
|
|
||||||
const hasAnyData = currentStatus !== null || dayEvents.length > 0;
|
const hasAnyData = currentStatus !== null || dayEvents.length > 0;
|
||||||
|
|
||||||
|
|||||||
@@ -181,7 +181,6 @@ class TelemetryClient {
|
|||||||
let numPrivResourceHosts = 0;
|
let numPrivResourceHosts = 0;
|
||||||
let numPrivResourceCidr = 0;
|
let numPrivResourceCidr = 0;
|
||||||
let numPrivResourceHttp = 0;
|
let numPrivResourceHttp = 0;
|
||||||
let numPrivResourceSsh = 0;
|
|
||||||
for (const res of allPrivateResources) {
|
for (const res of allPrivateResources) {
|
||||||
if (res.mode === "host") {
|
if (res.mode === "host") {
|
||||||
numPrivResourceHosts += 1;
|
numPrivResourceHosts += 1;
|
||||||
@@ -189,8 +188,6 @@ class TelemetryClient {
|
|||||||
numPrivResourceCidr += 1;
|
numPrivResourceCidr += 1;
|
||||||
} else if (res.mode === "http") {
|
} else if (res.mode === "http") {
|
||||||
numPrivResourceHttp += 1;
|
numPrivResourceHttp += 1;
|
||||||
} else if (res.mode === "ssh") {
|
|
||||||
numPrivResourceSsh += 1;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (res.alias) {
|
if (res.alias) {
|
||||||
@@ -210,7 +207,6 @@ class TelemetryClient {
|
|||||||
numPrivateResourceHosts: numPrivResourceHosts,
|
numPrivateResourceHosts: numPrivResourceHosts,
|
||||||
numPrivateResourceCidr: numPrivResourceCidr,
|
numPrivateResourceCidr: numPrivResourceCidr,
|
||||||
numPrivateResourceHttp: numPrivResourceHttp,
|
numPrivateResourceHttp: numPrivResourceHttp,
|
||||||
numPrivateResourceSsh: numPrivResourceSsh,
|
|
||||||
numAlertRules: numAlertRules.count,
|
numAlertRules: numAlertRules.count,
|
||||||
numUserDevices: userDevicesCount.count,
|
numUserDevices: userDevicesCount.count,
|
||||||
numMachineClients: machineClients.count,
|
numMachineClients: machineClients.count,
|
||||||
|
|||||||
@@ -511,12 +511,6 @@ export class TraefikConfigManager {
|
|||||||
let traefikConfig;
|
let traefikConfig;
|
||||||
try {
|
try {
|
||||||
const currentExitNode = await getCurrentExitNodeId();
|
const currentExitNode = await getCurrentExitNodeId();
|
||||||
|
|
||||||
const maintenancePort = config.getRawConfig().server.next_port;
|
|
||||||
const maintenanceHost =
|
|
||||||
config.getRawConfig().server.internal_hostname;
|
|
||||||
const pangolinUIUrl = `http://${maintenanceHost}:${maintenancePort}`;
|
|
||||||
|
|
||||||
// logger.debug(`Fetching traefik config for exit node: ${currentExitNode}`);
|
// logger.debug(`Fetching traefik config for exit node: ${currentExitNode}`);
|
||||||
traefikConfig = await getTraefikConfig(
|
traefikConfig = await getTraefikConfig(
|
||||||
// this is called by the local exit node to get its own config
|
// this is called by the local exit node to get its own config
|
||||||
@@ -527,8 +521,7 @@ export class TraefikConfigManager {
|
|||||||
build == "saas"
|
build == "saas"
|
||||||
? false
|
? false
|
||||||
: config.getRawConfig().traefik.allow_raw_resources, // dont allow raw resources on saas otherwise use config
|
: config.getRawConfig().traefik.allow_raw_resources, // dont allow raw resources on saas otherwise use config
|
||||||
pangolinUIUrl, // generate maintenance pages on cloud and hybrid
|
build != "oss" // generate browser gateway targets on cloud and enterprise
|
||||||
pangolinUIUrl // generate browser gateway targets on cloud and hybrid
|
|
||||||
);
|
);
|
||||||
|
|
||||||
const domains = new Set<string>();
|
const domains = new Set<string>();
|
||||||
|
|||||||
@@ -44,8 +44,7 @@ export async function getTraefikConfig(
|
|||||||
filterOutNamespaceDomains = false, // UNUSED BUT USED IN PRIVATE
|
filterOutNamespaceDomains = false, // UNUSED BUT USED IN PRIVATE
|
||||||
generateLoginPageRouters = false, // UNUSED BUT USED IN PRIVATE
|
generateLoginPageRouters = false, // UNUSED BUT USED IN PRIVATE
|
||||||
allowRawResources = true,
|
allowRawResources = true,
|
||||||
maintenancePageUiUrl: string | null = null, // UNUSED BUT USED IN PRIVATE
|
allowMaintenancePage = true // UNUSED BUT USED IN PRIVATE
|
||||||
browserGatewayUiUrl: string | null = null // UNUSED BUT USED IN PRIVATE
|
|
||||||
): Promise<any> {
|
): Promise<any> {
|
||||||
// Get resources with their targets and sites in a single optimized query
|
// Get resources with their targets and sites in a single optimized query
|
||||||
// Start from sites on this exit node, then join to targets and resources
|
// Start from sites on this exit node, then join to targets and resources
|
||||||
@@ -241,7 +240,7 @@ export async function getTraefikConfig(
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (resource.mode === "http") {
|
if (resource.http) {
|
||||||
if (!resource.domainId || !resource.fullDomain) {
|
if (!resource.domainId || !resource.fullDomain) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
@@ -573,7 +572,7 @@ export async function getTraefikConfig(
|
|||||||
serviceName
|
serviceName
|
||||||
].loadBalancer.serversTransport = transportName;
|
].loadBalancer.serversTransport = transportName;
|
||||||
}
|
}
|
||||||
} else if (resource.mode === "tcp" || resource.mode === "udp") {
|
} else {
|
||||||
// Non-HTTP (TCP/UDP) configuration
|
// Non-HTTP (TCP/UDP) configuration
|
||||||
if (!resource.enableProxy || !resource.proxyPort) {
|
if (!resource.enableProxy || !resource.proxyPort) {
|
||||||
continue;
|
continue;
|
||||||
|
|||||||
+3
-13
@@ -2,7 +2,6 @@ import {
|
|||||||
db,
|
db,
|
||||||
Org,
|
Org,
|
||||||
orgs,
|
orgs,
|
||||||
resourceAccessToken,
|
|
||||||
resources,
|
resources,
|
||||||
siteResources,
|
siteResources,
|
||||||
sites,
|
sites,
|
||||||
@@ -15,7 +14,7 @@ import {
|
|||||||
} from "@server/db";
|
} from "@server/db";
|
||||||
import { eq, and, inArray, ne, exists } from "drizzle-orm";
|
import { eq, and, inArray, ne, exists } from "drizzle-orm";
|
||||||
import { usageService } from "@server/lib/billing/usageService";
|
import { usageService } from "@server/lib/billing/usageService";
|
||||||
import { LimitId } from "@server/lib/billing";
|
import { FeatureId } from "@server/lib/billing";
|
||||||
|
|
||||||
export async function assignUserToOrg(
|
export async function assignUserToOrg(
|
||||||
org: Org,
|
org: Org,
|
||||||
@@ -62,7 +61,7 @@ export async function assignUserToOrg(
|
|||||||
);
|
);
|
||||||
|
|
||||||
if (orgsInBillingDomainThatTheUserIsStillIn.length === 0) {
|
if (orgsInBillingDomainThatTheUserIsStillIn.length === 0) {
|
||||||
await usageService.add(org.orgId, LimitId.USERS, 1, trx);
|
await usageService.add(org.orgId, FeatureId.USERS, 1, trx);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -84,15 +83,6 @@ export async function removeUserFromOrg(
|
|||||||
.delete(userOrgs)
|
.delete(userOrgs)
|
||||||
.where(and(eq(userOrgs.userId, userId), eq(userOrgs.orgId, org.orgId)));
|
.where(and(eq(userOrgs.userId, userId), eq(userOrgs.orgId, org.orgId)));
|
||||||
|
|
||||||
await trx
|
|
||||||
.delete(resourceAccessToken)
|
|
||||||
.where(
|
|
||||||
and(
|
|
||||||
eq(resourceAccessToken.userId, userId),
|
|
||||||
eq(resourceAccessToken.orgId, org.orgId)
|
|
||||||
)
|
|
||||||
);
|
|
||||||
|
|
||||||
await trx.delete(userResources).where(
|
await trx.delete(userResources).where(
|
||||||
and(
|
and(
|
||||||
eq(userResources.userId, userId),
|
eq(userResources.userId, userId),
|
||||||
@@ -167,7 +157,7 @@ export async function removeUserFromOrg(
|
|||||||
);
|
);
|
||||||
|
|
||||||
if (orgsInBillingDomainThatTheUserIsStillIn.length === 0) {
|
if (orgsInBillingDomainThatTheUserIsStillIn.length === 0) {
|
||||||
await usageService.add(org.orgId, LimitId.USERS, -1, trx);
|
await usageService.add(org.orgId, FeatureId.USERS, -1, trx);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,4 @@
|
|||||||
import {
|
import { isValidUrlGlobPattern } from "./validators";
|
||||||
getResourceRuleValueValidationError,
|
|
||||||
isValidUrlGlobPattern
|
|
||||||
} from "./validators";
|
|
||||||
import { assertEquals } from "@test/assert";
|
import { assertEquals } from "@test/assert";
|
||||||
|
|
||||||
function runTests() {
|
function runTests() {
|
||||||
@@ -239,43 +236,6 @@ function runTests() {
|
|||||||
"Path with isolated percent sign should be invalid"
|
"Path with isolated percent sign should be invalid"
|
||||||
);
|
);
|
||||||
|
|
||||||
// ASN validation tests
|
|
||||||
assertEquals(
|
|
||||||
getResourceRuleValueValidationError("ASN", "AS15169"),
|
|
||||||
null,
|
|
||||||
"Standard ASN should be valid"
|
|
||||||
);
|
|
||||||
assertEquals(
|
|
||||||
getResourceRuleValueValidationError("ASN", " As15169 "),
|
|
||||||
null,
|
|
||||||
"Standard ASN should be valid with mixed case and whitespace"
|
|
||||||
);
|
|
||||||
assertEquals(
|
|
||||||
getResourceRuleValueValidationError("ASN", "ALL"),
|
|
||||||
null,
|
|
||||||
"ALL ASN selector should be valid"
|
|
||||||
);
|
|
||||||
assertEquals(
|
|
||||||
getResourceRuleValueValidationError("ASN", " all "),
|
|
||||||
null,
|
|
||||||
"ALL ASN selector should be valid with mixed case and whitespace"
|
|
||||||
);
|
|
||||||
assertEquals(
|
|
||||||
getResourceRuleValueValidationError("ASN", "AS0"),
|
|
||||||
null,
|
|
||||||
"AS0 alias should be valid"
|
|
||||||
);
|
|
||||||
assertEquals(
|
|
||||||
getResourceRuleValueValidationError("ASN", " as0 "),
|
|
||||||
null,
|
|
||||||
"AS0 alias should be valid with mixed case and whitespace"
|
|
||||||
);
|
|
||||||
assertEquals(
|
|
||||||
getResourceRuleValueValidationError("ASN", "not-an-asn"),
|
|
||||||
"Invalid ASN provided",
|
|
||||||
"Invalid ASN should return an error"
|
|
||||||
);
|
|
||||||
|
|
||||||
console.log("All tests passed!");
|
console.log("All tests passed!");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,5 @@
|
|||||||
import z from "zod";
|
import z from "zod";
|
||||||
import ipaddr from "ipaddr.js";
|
import ipaddr from "ipaddr.js";
|
||||||
import { COUNTRIES } from "@server/db/countries";
|
|
||||||
import { isValidRegionId } from "@server/db/regions";
|
|
||||||
|
|
||||||
export function isValidCIDR(cidr: string): boolean {
|
export function isValidCIDR(cidr: string): boolean {
|
||||||
return (
|
return (
|
||||||
@@ -69,50 +67,6 @@ export function isValidUrlGlobPattern(pattern: string): boolean {
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const RESOURCE_RULE_MATCH_TYPES = [
|
|
||||||
"CIDR",
|
|
||||||
"IP",
|
|
||||||
"PATH",
|
|
||||||
"COUNTRY",
|
|
||||||
"COUNTRY_IS_NOT",
|
|
||||||
"ASN",
|
|
||||||
"REGION"
|
|
||||||
] as const;
|
|
||||||
|
|
||||||
export type ResourceRuleMatchType = (typeof RESOURCE_RULE_MATCH_TYPES)[number];
|
|
||||||
|
|
||||||
export function getResourceRuleValueValidationError(
|
|
||||||
match: ResourceRuleMatchType,
|
|
||||||
value: string
|
|
||||||
): string | null {
|
|
||||||
switch (match) {
|
|
||||||
case "CIDR":
|
|
||||||
return isValidCIDR(value) ? null : "Invalid CIDR provided";
|
|
||||||
case "IP":
|
|
||||||
return isValidIP(value) ? null : "Invalid IP provided";
|
|
||||||
case "PATH":
|
|
||||||
return isValidUrlGlobPattern(value)
|
|
||||||
? null
|
|
||||||
: "Invalid URL glob pattern provided";
|
|
||||||
case "REGION":
|
|
||||||
return isValidRegionId(value) ? null : "Invalid region ID provided";
|
|
||||||
case "COUNTRY":
|
|
||||||
case "COUNTRY_IS_NOT":
|
|
||||||
return COUNTRIES.some((country) => country.code === value)
|
|
||||||
? null
|
|
||||||
: "Invalid country code provided";
|
|
||||||
case "ASN":
|
|
||||||
const normalizedValue = value.trim().toUpperCase();
|
|
||||||
return /^AS\d+$/.test(normalizedValue) ||
|
|
||||||
normalizedValue === "ALL" ||
|
|
||||||
normalizedValue === "AS0"
|
|
||||||
? null
|
|
||||||
: "Invalid ASN provided";
|
|
||||||
default:
|
|
||||||
return "Invalid rule match type provided";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function isUrlValid(url: string | undefined) {
|
export function isUrlValid(url: string | undefined) {
|
||||||
if (!url) return true; // the link is optional in the schema so if it's empty it's valid
|
if (!url) return true; // the link is optional in the schema so if it's empty it's valid
|
||||||
var pattern = new RegExp(
|
var pattern = new RegExp(
|
||||||
|
|||||||
@@ -17,4 +17,3 @@ export * from "./verifyApiKeySiteResourceAccess";
|
|||||||
export * from "./verifyApiKeyIdpAccess";
|
export * from "./verifyApiKeyIdpAccess";
|
||||||
export * from "./verifyApiKeyDomainAccess";
|
export * from "./verifyApiKeyDomainAccess";
|
||||||
export * from "./verifyApiKeyResourcePolicyAccess";
|
export * from "./verifyApiKeyResourcePolicyAccess";
|
||||||
export * from "./verifyApiKeySiteProvisioningKeyAccess";
|
|
||||||
|
|||||||
@@ -1,111 +0,0 @@
|
|||||||
import { Request, Response, NextFunction } from "express";
|
|
||||||
import {
|
|
||||||
db,
|
|
||||||
siteProvisioningKeys,
|
|
||||||
siteProvisioningKeyOrg,
|
|
||||||
apiKeyOrg
|
|
||||||
} from "@server/db";
|
|
||||||
import { and, eq } from "drizzle-orm";
|
|
||||||
import createHttpError from "http-errors";
|
|
||||||
import HttpCode from "@server/types/HttpCode";
|
|
||||||
import { getFirstString } from "@server/lib/requestParams";
|
|
||||||
|
|
||||||
export async function verifyApiKeySiteProvisioningKeyAccess(
|
|
||||||
req: Request,
|
|
||||||
res: Response,
|
|
||||||
next: NextFunction
|
|
||||||
) {
|
|
||||||
try {
|
|
||||||
const apiKey = req.apiKey;
|
|
||||||
const siteProvisioningKeyId =
|
|
||||||
getFirstString(req.params.siteProvisioningKeyId) ||
|
|
||||||
getFirstString(req.body.siteProvisioningKeyId) ||
|
|
||||||
getFirstString(req.query.siteProvisioningKeyId);
|
|
||||||
const orgId = getFirstString(req.params.orgId);
|
|
||||||
|
|
||||||
if (!apiKey) {
|
|
||||||
return next(
|
|
||||||
createHttpError(HttpCode.UNAUTHORIZED, "Key not authenticated")
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!orgId) {
|
|
||||||
return next(
|
|
||||||
createHttpError(HttpCode.BAD_REQUEST, "Invalid organization ID")
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!siteProvisioningKeyId) {
|
|
||||||
return next(
|
|
||||||
createHttpError(HttpCode.BAD_REQUEST, "Invalid key ID")
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (apiKey.isRoot) {
|
|
||||||
// Root keys can access any site provisioning key in any org
|
|
||||||
return next();
|
|
||||||
}
|
|
||||||
|
|
||||||
const [row] = await db
|
|
||||||
.select()
|
|
||||||
.from(siteProvisioningKeys)
|
|
||||||
.innerJoin(
|
|
||||||
siteProvisioningKeyOrg,
|
|
||||||
and(
|
|
||||||
eq(
|
|
||||||
siteProvisioningKeys.siteProvisioningKeyId,
|
|
||||||
siteProvisioningKeyOrg.siteProvisioningKeyId
|
|
||||||
),
|
|
||||||
eq(siteProvisioningKeyOrg.orgId, orgId)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
.where(
|
|
||||||
eq(
|
|
||||||
siteProvisioningKeys.siteProvisioningKeyId,
|
|
||||||
siteProvisioningKeyId
|
|
||||||
)
|
|
||||||
)
|
|
||||||
.limit(1);
|
|
||||||
|
|
||||||
if (!row?.siteProvisioningKeys || !row.siteProvisioningKeyOrg) {
|
|
||||||
return next(
|
|
||||||
createHttpError(
|
|
||||||
HttpCode.NOT_FOUND,
|
|
||||||
`Site provisioning key with ID ${siteProvisioningKeyId} not found for organization ${orgId}`
|
|
||||||
)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!req.apiKeyOrg) {
|
|
||||||
const apiKeyOrgRes = await db
|
|
||||||
.select()
|
|
||||||
.from(apiKeyOrg)
|
|
||||||
.where(
|
|
||||||
and(
|
|
||||||
eq(apiKeyOrg.apiKeyId, apiKey.apiKeyId),
|
|
||||||
eq(apiKeyOrg.orgId, row.siteProvisioningKeyOrg.orgId)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
.limit(1);
|
|
||||||
req.apiKeyOrg = apiKeyOrgRes[0];
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!req.apiKeyOrg) {
|
|
||||||
return next(
|
|
||||||
createHttpError(
|
|
||||||
HttpCode.FORBIDDEN,
|
|
||||||
"Key does not have access to this organization"
|
|
||||||
)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return next();
|
|
||||||
} catch (error) {
|
|
||||||
return next(
|
|
||||||
createHttpError(
|
|
||||||
HttpCode.INTERNAL_SERVER_ERROR,
|
|
||||||
"Error verifying site provisioning key access"
|
|
||||||
)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -119,7 +119,8 @@ export async function verifyAccessTokenAccess(
|
|||||||
return next(
|
return next(
|
||||||
createHttpError(
|
createHttpError(
|
||||||
HttpCode.FORBIDDEN,
|
HttpCode.FORBIDDEN,
|
||||||
"" + (policyCheck.error || "Unknown error")
|
"Failed organization access policy check: " +
|
||||||
|
(policyCheck.error || "Unknown error")
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -56,7 +56,8 @@ export async function verifyAdmin(
|
|||||||
return next(
|
return next(
|
||||||
createHttpError(
|
createHttpError(
|
||||||
HttpCode.FORBIDDEN,
|
HttpCode.FORBIDDEN,
|
||||||
"" + (policyCheck.error || "Unknown error")
|
"Failed organization access policy check: " +
|
||||||
|
(policyCheck.error || "Unknown error")
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -113,7 +113,8 @@ export async function verifyApiKeyAccess(
|
|||||||
return next(
|
return next(
|
||||||
createHttpError(
|
createHttpError(
|
||||||
HttpCode.FORBIDDEN,
|
HttpCode.FORBIDDEN,
|
||||||
"" + (policyCheck.error || "Unknown error")
|
"Failed organization access policy check: " +
|
||||||
|
(policyCheck.error || "Unknown error")
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -107,7 +107,8 @@ export async function verifyClientAccess(
|
|||||||
return next(
|
return next(
|
||||||
createHttpError(
|
createHttpError(
|
||||||
HttpCode.FORBIDDEN,
|
HttpCode.FORBIDDEN,
|
||||||
"" + (policyCheck.error || "Unknown error")
|
"Failed organization access policy check: " +
|
||||||
|
(policyCheck.error || "Unknown error")
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -128,7 +129,10 @@ export async function verifyClientAccess(
|
|||||||
.where(
|
.where(
|
||||||
and(
|
and(
|
||||||
eq(roleClients.clientId, client.clientId),
|
eq(roleClients.clientId, client.clientId),
|
||||||
inArray(roleClients.roleId, req.userOrgRoleIds!)
|
inArray(
|
||||||
|
roleClients.roleId,
|
||||||
|
req.userOrgRoleIds!
|
||||||
|
)
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
.limit(1)
|
.limit(1)
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user