Compare commits

...

409 Commits

Author SHA1 Message Date
Owen
ff6c23e3e4 Add labels input 2026-06-03 21:02:17 -07:00
Owen
726deb4152 Add basic resources input on the remote node 2026-06-03 21:02:17 -07:00
Owen
e408e735be Make alias cross compatable 2026-06-03 17:58:59 -07:00
Owen
e826d0dea6 Add better loading spinner 2026-06-03 17:41:56 -07:00
Owen
bc6fd0b399 Get user resources from the right table 2026-06-03 16:53:39 -07:00
Owen
d00b737412 Pull the sso from the policies as well 2026-06-03 16:16:42 -07:00
Owen
1f43713986 Fix saving the rules 2026-06-03 16:02:17 -07:00
Owen
cc5bec1d83 Pull the rules and the policy information 2026-06-03 15:33:15 -07:00
Owen
40125c717c Pull things in proper order 2026-06-03 14:52:36 -07:00
Owen
2b402f8fec Quiet logs 2026-06-03 14:48:51 -07:00
Owen
8e9071a336 Converting to use both inline and shared policy 2026-06-03 14:41:43 -07:00
Owen
18bcf40174 Merge branch 'dev' of github.com:fosrl/pangolin into dev 2026-06-03 13:50:54 -07:00
Owen Schwartz
42e9b913f1 Merge pull request #3201 from Fredkiss3/refactor/standardize-dropdowns
refactor: standardize dropdowns accross tables
2026-06-03 13:50:40 -07:00
Fred KISSIE
fcb73f78ea 🏷️ fix type imports 2026-06-03 20:23:32 +02:00
Fred KISSIE
a21569bd00 🏷️ fix types imports from a client component 2026-06-03 20:14:43 +02:00
Fred KISSIE
565727ad36 🏷️ import types correctly 2026-06-03 19:51:44 +02:00
Fred KISSIE
00dce19997 Merge branch 'dev' into refactor/standardize-dropdowns 2026-06-03 19:34:22 +02:00
Fred KISSIE
29717e19db ♻️ update machine client labels cell 2026-06-03 19:17:24 +02:00
Fred KISSIE
97aeee541a ♻️ Label table cell optimization 2026-06-03 19:15:44 +02:00
Owen
b70a2bee58 Native ssh push users is working 2026-06-02 22:00:29 -07:00
Owen
f2f56dc6c2 Properly paywall the new resource types 2026-06-02 18:06:42 -07:00
Owen
128db20755 Remove migration test 2026-06-02 17:13:10 -07:00
Owen
12cbd40596 Fix types 2026-06-02 16:56:58 -07:00
Owen
ffd0d17b58 Add proxy protocl support in blueprints 2026-06-02 16:42:26 -07:00
Owen
33fad57bf7 Restrict the number of sites in the api 2026-06-02 16:38:04 -07:00
Owen
8bcc130947 Make sure the right type of select shows 2026-06-02 16:33:05 -07:00
Owen
19feaf4bf2 Add the policy information into missing places 2026-06-02 15:47:55 -07:00
Owen
88ea4391e0 Show new types of resources right 2026-06-02 15:31:33 -07:00
Owen
fba37b7ad0 Add command to make other user server admin 2026-06-02 15:31:21 -07:00
Fred KISSIE
6c1798a8c5 💄 use checkbox for column filter with dropdown 2026-06-02 17:43:45 +02:00
Owen
b6d688f15e Support pin,pass,whitelist correctly on login 2026-06-01 21:34:39 -07:00
Owen
8a57d8dd9c Resource rules go first 2026-06-01 21:02:59 -07:00
Owen
8e0e32c2be Overriding is working 2026-06-01 20:54:37 -07:00
Owen
6b3a0a2113 Remove the admin from the picker 2026-06-01 20:33:37 -07:00
Owen
4d6ed7eec5 Pull from the policies to show to users 2026-06-01 17:49:09 -07:00
Owen
1625dd1add Include the new policy tables in the data 2026-06-01 17:04:33 -07:00
Owen
605dd2f3c9 Add tcp and udp specific pages 2026-06-01 16:05:20 -07:00
Owen
51bb149fd5 Update to latest badger 2026-06-01 15:27:34 -07:00
Owen
2ae4c29418 Add missing set 2026-06-01 15:27:30 -07:00
Owen
ba71016f87 Add inline policy migration 2026-06-01 15:18:40 -07:00
Owen
85c2bd807e Handle the new added mode column 2026-06-01 14:49:41 -07:00
Owen
517e1d15c8 Add 1.19.0 migrations 2026-06-01 14:42:32 -07:00
Owen
3d6d5f176a Hide verify button 2026-06-01 14:10:08 -07:00
Owen
5dd19edb56 Hold the hp error message until after 18 tries 2026-06-01 14:05:19 -07:00
Owen
c6a52ffc75 Dont run migration again when rc 2026-06-01 13:58:04 -07:00
Owen
09b2671759 Send hp error to olm 2026-06-01 13:57:54 -07:00
Owen
d11a244caa Push mode and sign key adjustments for native mode 2026-06-01 11:41:55 -07:00
miloschwartz
bf79768e05 update links 2026-05-31 20:20:37 -07:00
Owen
08a2923cfc Move proxy and client to public and private 2026-05-31 17:30:31 -07:00
Owen
b99e9a6468 Working on ui 2026-05-31 17:25:03 -07:00
Owen
cb2ee9c489 Fixing visual issues 2026-05-31 16:36:13 -07:00
Owen
c1d933259a Fix some ui form issues 2026-05-31 11:57:01 -07:00
Owen
3cf6abdf27 Rename internal resource -> private 2026-05-31 11:28:04 -07:00
Owen
0f2132e565 Merge branch 'main' into dev 2026-05-31 11:12:30 -07:00
Owen
5cc88dc73f Pull the session from badger 2026-05-31 11:11:26 -07:00
Owen Schwartz
ebe1c7a297 Improve OpenAPI response payload typing for Swagger data schemas (#3102)
* Fix custom parser OpenAPI types and add structured default response schema

Agent-Logs-Url: https://github.com/fosrl/pangolin/sessions/73990123-9c27-444b-bc6e-77e890a0d57c

Co-authored-by: oschwartz10612 <4999704+oschwartz10612@users.noreply.github.com>

* Document all registerPath responses and normalize OpenAPI parser schemas

Agent-Logs-Url: https://github.com/fosrl/pangolin/sessions/73990123-9c27-444b-bc6e-77e890a0d57c

Co-authored-by: oschwartz10612 <4999704+oschwartz10612@users.noreply.github.com>

* Add concrete OpenAPI data schemas for selected routes

Agent-Logs-Url: https://github.com/fosrl/pangolin/sessions/7b395a8e-7fae-4f4d-952e-4030fea08262

Co-authored-by: oschwartz10612 <4999704+oschwartz10612@users.noreply.github.com>

* Reformat generated OpenAPI response schemas for readability

Agent-Logs-Url: https://github.com/fosrl/pangolin/sessions/7b395a8e-7fae-4f4d-952e-4030fea08262

Co-authored-by: oschwartz10612 <4999704+oschwartz10612@users.noreply.github.com>

* Remove obsolete stoi import from blueprint OpenAPI route

Agent-Logs-Url: https://github.com/fosrl/pangolin/sessions/7b395a8e-7fae-4f4d-952e-4030fea08262

Co-authored-by: oschwartz10612 <4999704+oschwartz10612@users.noreply.github.com>

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: oschwartz10612 <4999704+oschwartz10612@users.noreply.github.com>
2026-05-31 11:10:38 -07:00
Owen
0943cf5d4c Dont strip session 2026-05-30 12:10:06 -07:00
Owen
3b82ac568f Give auth feedback 2026-05-30 11:56:32 -07:00
Owen
b695f34dc8 Native ssh auth working 2026-05-30 11:54:01 -07:00
Owen
6df4bba3b6 Bump version 2026-05-29 17:12:26 -07:00
Owen
9f83c0a0e8 Show error right 2026-05-29 17:11:44 -07:00
Owen
0ab1854125 Fix import 2026-05-29 15:38:37 -07:00
Owen
b071fa2c9f Be able to pull users from the proxy 2026-05-29 15:34:34 -07:00
Owen
8e2a79a0f5 Move to private 2026-05-29 15:23:40 -07:00
Owen
71756812b6 Adjusting the ui 2026-05-29 10:57:28 -07:00
Owen
76cd716caa Add user id 2026-05-29 10:57:16 -07:00
Shlee
b0d1291cff Installer: Bootstrap optional PostgreSQL/Redis (#3152)
* Make optional postgres and redis in installer
2026-05-29 09:43:59 -07:00
Owen
9617eb2bd7 Add login formatting 2026-05-28 21:38:40 -07:00
Owen
c1ef5b4fbe Add allowedDevOrigins 2026-05-28 21:23:55 -07:00
Owen
8e14bdec95 Remove cloud 2026-05-28 21:20:12 -07:00
dependabot[bot]
b26dfaf57f Bump the prod-minor-updates group with 9 updates
Bumps the prod-minor-updates group with 9 updates:

| Package | From | To |
| --- | --- | --- |
| [@aws-sdk/client-s3](https://github.com/aws/aws-sdk-js-v3/tree/HEAD/clients/client-s3) | `3.1047.0` | `3.1056.0` |
| [@hookform/resolvers](https://github.com/react-hook-form/resolvers) | `5.2.2` | `5.4.0` |
| [helmet](https://github.com/helmetjs/helmet) | `8.1.0` | `8.2.0` |
| [ioredis](https://github.com/luin/ioredis) | `5.10.1` | `5.11.0` |
| [next-intl](https://github.com/amannn/next-intl) | `4.12.0` | `4.13.0` |
| [pg](https://github.com/brianc/node-postgres/tree/HEAD/packages/pg) | `8.20.0` | `8.21.0` |
| [posthog-node](https://github.com/PostHog/posthog-js/tree/HEAD/packages/node) | `5.34.1` | `5.35.6` |
| [react-hook-form](https://github.com/react-hook-form/react-hook-form) | `7.75.0` | `7.76.1` |
| [ws](https://github.com/websockets/ws) | `8.20.1` | `8.21.0` |

Updates `@aws-sdk/client-s3` from 3.1047.0 to 3.1056.0
- [Release notes](https://github.com/aws/aws-sdk-js-v3/releases)
- [Changelog](https://github.com/aws/aws-sdk-js-v3/blob/main/clients/client-s3/CHANGELOG.md)
- [Commits](https://github.com/aws/aws-sdk-js-v3/commits/v3.1056.0/clients/client-s3)

Updates `@hookform/resolvers` from 5.2.2 to 5.4.0
- [Release notes](https://github.com/react-hook-form/resolvers/releases)
- [Commits](https://github.com/react-hook-form/resolvers/compare/v5.2.2...v5.4.0)

Updates `helmet` from 8.1.0 to 8.2.0
- [Changelog](https://github.com/helmetjs/helmet/blob/main/CHANGELOG.md)
- [Commits](https://github.com/helmetjs/helmet/compare/v8.1.0...v8.2.0)

Updates `ioredis` from 5.10.1 to 5.11.0
- [Release notes](https://github.com/luin/ioredis/releases)
- [Changelog](https://github.com/redis/ioredis/blob/main/CHANGELOG.md)
- [Commits](https://github.com/luin/ioredis/compare/v5.10.1...v5.11.0)

Updates `next-intl` from 4.12.0 to 4.13.0
- [Release notes](https://github.com/amannn/next-intl/releases)
- [Changelog](https://github.com/amannn/next-intl/blob/main/CHANGELOG.md)
- [Commits](https://github.com/amannn/next-intl/compare/v4.12.0...v4.13.0)

Updates `pg` from 8.20.0 to 8.21.0
- [Changelog](https://github.com/brianc/node-postgres/blob/master/CHANGELOG.md)
- [Commits](https://github.com/brianc/node-postgres/commits/pg@8.21.0/packages/pg)

Updates `posthog-node` from 5.34.1 to 5.35.6
- [Release notes](https://github.com/PostHog/posthog-js/releases)
- [Changelog](https://github.com/PostHog/posthog-js/blob/main/packages/node/CHANGELOG.md)
- [Commits](https://github.com/PostHog/posthog-js/commits/posthog-node@5.35.6/packages/node)

Updates `react-hook-form` from 7.75.0 to 7.76.1
- [Release notes](https://github.com/react-hook-form/react-hook-form/releases)
- [Changelog](https://github.com/react-hook-form/react-hook-form/blob/master/CHANGELOG.md)
- [Commits](https://github.com/react-hook-form/react-hook-form/compare/v7.75.0...v7.76.1)

Updates `ws` from 8.20.1 to 8.21.0
- [Release notes](https://github.com/websockets/ws/releases)
- [Commits](https://github.com/websockets/ws/compare/8.20.1...8.21.0)

---
updated-dependencies:
- dependency-name: "@aws-sdk/client-s3"
  dependency-version: 3.1056.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: prod-minor-updates
- dependency-name: "@hookform/resolvers"
  dependency-version: 5.4.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: prod-minor-updates
- dependency-name: helmet
  dependency-version: 8.2.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: prod-minor-updates
- dependency-name: ioredis
  dependency-version: 5.11.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: prod-minor-updates
- dependency-name: next-intl
  dependency-version: 4.13.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: prod-minor-updates
- dependency-name: pg
  dependency-version: 8.21.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: prod-minor-updates
- dependency-name: posthog-node
  dependency-version: 5.35.6
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: prod-minor-updates
- dependency-name: react-hook-form
  dependency-version: 7.76.1
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: prod-minor-updates
- dependency-name: ws
  dependency-version: 8.21.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: prod-minor-updates
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-05-28 21:15:00 -07:00
dependabot[bot]
1a1c19b24e Bump the dev-patch-updates group with 4 updates
Bumps the dev-patch-updates group with 4 updates: [@tanstack/react-query-devtools](https://github.com/TanStack/query/tree/HEAD/packages/react-query-devtools), [@types/react](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/react), [postcss](https://github.com/postcss/postcss) and [tsx](https://github.com/privatenumber/tsx).


Updates `@tanstack/react-query-devtools` from 5.100.10 to 5.100.14
- [Release notes](https://github.com/TanStack/query/releases)
- [Changelog](https://github.com/TanStack/query/blob/main/packages/react-query-devtools/CHANGELOG.md)
- [Commits](https://github.com/TanStack/query/commits/@tanstack/react-query-devtools@5.100.14/packages/react-query-devtools)

Updates `@types/react` from 19.2.14 to 19.2.15
- [Release notes](https://github.com/DefinitelyTyped/DefinitelyTyped/releases)
- [Commits](https://github.com/DefinitelyTyped/DefinitelyTyped/commits/HEAD/types/react)

Updates `postcss` from 8.5.14 to 8.5.15
- [Release notes](https://github.com/postcss/postcss/releases)
- [Changelog](https://github.com/postcss/postcss/blob/main/CHANGELOG.md)
- [Commits](https://github.com/postcss/postcss/compare/8.5.14...8.5.15)

Updates `tsx` from 4.22.0 to 4.22.3
- [Release notes](https://github.com/privatenumber/tsx/releases)
- [Changelog](https://github.com/privatenumber/tsx/blob/master/release.config.cjs)
- [Commits](https://github.com/privatenumber/tsx/compare/v4.22.0...v4.22.3)

---
updated-dependencies:
- dependency-name: "@tanstack/react-query-devtools"
  dependency-version: 5.100.14
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: dev-patch-updates
- dependency-name: "@types/react"
  dependency-version: 19.2.15
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: dev-patch-updates
- dependency-name: postcss
  dependency-version: 8.5.15
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: dev-patch-updates
- dependency-name: tsx
  dependency-version: 4.22.3
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: dev-patch-updates
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-05-28 21:14:14 -07:00
dependabot[bot]
9d214b18af Bump stripe from 20.4.1 to 22.2.0
Bumps [stripe](https://github.com/stripe/stripe-node) from 20.4.1 to 22.2.0.
- [Release notes](https://github.com/stripe/stripe-node/releases)
- [Changelog](https://github.com/stripe/stripe-node/blob/master/CHANGELOG.md)
- [Commits](https://github.com/stripe/stripe-node/compare/v20.4.1...v22.2.0)

---
updated-dependencies:
- dependency-name: stripe
  dependency-version: 22.2.0
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-05-28 21:14:14 -07:00
dependabot[bot]
e67b50b356 Bump lucide-react from 0.577.0 to 1.17.0
Bumps [lucide-react](https://github.com/lucide-icons/lucide/tree/HEAD/packages/lucide-react) from 0.577.0 to 1.17.0.
- [Release notes](https://github.com/lucide-icons/lucide/releases)
- [Commits](https://github.com/lucide-icons/lucide/commits/1.17.0/packages/lucide-react)

---
updated-dependencies:
- dependency-name: lucide-react
  dependency-version: 1.17.0
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-05-28 21:14:14 -07:00
rinseaid
616caf76cb Auto-create roles referenced in blueprints
When a blueprint references a role that doesn't exist, create it
automatically with default permissions (getOrg, getResource,
listResources) instead of throwing an error or silently dropping
the association.
2026-05-28 21:14:14 -07:00
Owen
9a1db4948b Shape the ssh/vnc/rdp login ui to match auth 2026-05-28 21:14:13 -07:00
Owen Schwartz
1215aa8122 Merge pull request #3184 from fosrl/dependabot/npm_and_yarn/prod-minor-updates-1701004488
Bump the prod-minor-updates group with 9 updates
2026-05-28 20:36:43 -07:00
Owen Schwartz
d318a756a8 Merge pull request #3183 from fosrl/dependabot/npm_and_yarn/dev-patch-updates-60744307c2
Bump the dev-patch-updates group with 4 updates
2026-05-28 20:36:17 -07:00
Owen Schwartz
b3c1e49c0c Merge pull request #3185 from fosrl/dependabot/npm_and_yarn/stripe-22.2.0
Bump stripe from 20.4.1 to 22.2.0
2026-05-28 20:35:52 -07:00
Owen Schwartz
dc12b00502 Merge pull request #3186 from fosrl/dependabot/npm_and_yarn/lucide-react-1.17.0
Bump lucide-react from 0.577.0 to 1.17.0
2026-05-28 20:35:39 -07:00
Owen Schwartz
5b814e37c4 Merge pull request #2434 from NHClaessens/feature-share-link-redirect-path
feat: Add path setting to share links (resourceAccessToken)
2026-05-28 20:28:28 -07:00
Owen
8483616b04 Unstage ignored files 2026-05-28 20:27:25 -07:00
Owen
ffe198839a Reset translations 2026-05-28 20:25:34 -07:00
NHClaessens
db5d1d4a16 Update postgres schema 2026-05-28 20:20:34 -07:00
NHClaessens
ad7dcddf24 Add translations 2026-05-28 20:20:33 -07:00
Owen
94408aad21 Add path onto redirectUrl 2026-05-28 20:19:19 -07:00
NHClaessens
b84a7996a9 Adjust validation to allow creation with (optional) path 2026-05-28 20:15:22 -07:00
Owen
a9b0bd8b47 Alter schema + add form field 2026-05-28 20:15:13 -07:00
Owen
a32acf7c69 Fix ui 2026-05-28 20:14:39 -07:00
Owen Schwartz
1e27acbf88 Merge pull request #2980 from rinseaid/blueprint-auto-create-roles
Auto-create roles referenced in blueprints
2026-05-28 20:10:53 -07:00
dependabot[bot]
4012cc658d Bump lucide-react from 0.577.0 to 1.17.0
Bumps [lucide-react](https://github.com/lucide-icons/lucide/tree/HEAD/packages/lucide-react) from 0.577.0 to 1.17.0.
- [Release notes](https://github.com/lucide-icons/lucide/releases)
- [Commits](https://github.com/lucide-icons/lucide/commits/1.17.0/packages/lucide-react)

---
updated-dependencies:
- dependency-name: lucide-react
  dependency-version: 1.17.0
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-05-29 01:44:09 +00:00
dependabot[bot]
84d7a87609 Bump stripe from 20.4.1 to 22.2.0
Bumps [stripe](https://github.com/stripe/stripe-node) from 20.4.1 to 22.2.0.
- [Release notes](https://github.com/stripe/stripe-node/releases)
- [Changelog](https://github.com/stripe/stripe-node/blob/master/CHANGELOG.md)
- [Commits](https://github.com/stripe/stripe-node/compare/v20.4.1...v22.2.0)

---
updated-dependencies:
- dependency-name: stripe
  dependency-version: 22.2.0
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-05-29 01:43:42 +00:00
dependabot[bot]
9a92be532a Bump the prod-minor-updates group with 9 updates
Bumps the prod-minor-updates group with 9 updates:

| Package | From | To |
| --- | --- | --- |
| [@aws-sdk/client-s3](https://github.com/aws/aws-sdk-js-v3/tree/HEAD/clients/client-s3) | `3.1047.0` | `3.1056.0` |
| [@hookform/resolvers](https://github.com/react-hook-form/resolvers) | `5.2.2` | `5.4.0` |
| [helmet](https://github.com/helmetjs/helmet) | `8.1.0` | `8.2.0` |
| [ioredis](https://github.com/luin/ioredis) | `5.10.1` | `5.11.0` |
| [next-intl](https://github.com/amannn/next-intl) | `4.12.0` | `4.13.0` |
| [pg](https://github.com/brianc/node-postgres/tree/HEAD/packages/pg) | `8.20.0` | `8.21.0` |
| [posthog-node](https://github.com/PostHog/posthog-js/tree/HEAD/packages/node) | `5.34.1` | `5.35.6` |
| [react-hook-form](https://github.com/react-hook-form/react-hook-form) | `7.75.0` | `7.76.1` |
| [ws](https://github.com/websockets/ws) | `8.20.1` | `8.21.0` |


Updates `@aws-sdk/client-s3` from 3.1047.0 to 3.1056.0
- [Release notes](https://github.com/aws/aws-sdk-js-v3/releases)
- [Changelog](https://github.com/aws/aws-sdk-js-v3/blob/main/clients/client-s3/CHANGELOG.md)
- [Commits](https://github.com/aws/aws-sdk-js-v3/commits/v3.1056.0/clients/client-s3)

Updates `@hookform/resolvers` from 5.2.2 to 5.4.0
- [Release notes](https://github.com/react-hook-form/resolvers/releases)
- [Commits](https://github.com/react-hook-form/resolvers/compare/v5.2.2...v5.4.0)

Updates `helmet` from 8.1.0 to 8.2.0
- [Changelog](https://github.com/helmetjs/helmet/blob/main/CHANGELOG.md)
- [Commits](https://github.com/helmetjs/helmet/compare/v8.1.0...v8.2.0)

Updates `ioredis` from 5.10.1 to 5.11.0
- [Release notes](https://github.com/luin/ioredis/releases)
- [Changelog](https://github.com/redis/ioredis/blob/main/CHANGELOG.md)
- [Commits](https://github.com/luin/ioredis/compare/v5.10.1...v5.11.0)

Updates `next-intl` from 4.12.0 to 4.13.0
- [Release notes](https://github.com/amannn/next-intl/releases)
- [Changelog](https://github.com/amannn/next-intl/blob/main/CHANGELOG.md)
- [Commits](https://github.com/amannn/next-intl/compare/v4.12.0...v4.13.0)

Updates `pg` from 8.20.0 to 8.21.0
- [Changelog](https://github.com/brianc/node-postgres/blob/master/CHANGELOG.md)
- [Commits](https://github.com/brianc/node-postgres/commits/pg@8.21.0/packages/pg)

Updates `posthog-node` from 5.34.1 to 5.35.6
- [Release notes](https://github.com/PostHog/posthog-js/releases)
- [Changelog](https://github.com/PostHog/posthog-js/blob/main/packages/node/CHANGELOG.md)
- [Commits](https://github.com/PostHog/posthog-js/commits/posthog-node@5.35.6/packages/node)

Updates `react-hook-form` from 7.75.0 to 7.76.1
- [Release notes](https://github.com/react-hook-form/react-hook-form/releases)
- [Changelog](https://github.com/react-hook-form/react-hook-form/blob/master/CHANGELOG.md)
- [Commits](https://github.com/react-hook-form/react-hook-form/compare/v7.75.0...v7.76.1)

Updates `ws` from 8.20.1 to 8.21.0
- [Release notes](https://github.com/websockets/ws/releases)
- [Commits](https://github.com/websockets/ws/compare/8.20.1...8.21.0)

---
updated-dependencies:
- dependency-name: "@aws-sdk/client-s3"
  dependency-version: 3.1056.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: prod-minor-updates
- dependency-name: "@hookform/resolvers"
  dependency-version: 5.4.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: prod-minor-updates
- dependency-name: helmet
  dependency-version: 8.2.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: prod-minor-updates
- dependency-name: ioredis
  dependency-version: 5.11.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: prod-minor-updates
- dependency-name: next-intl
  dependency-version: 4.13.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: prod-minor-updates
- dependency-name: pg
  dependency-version: 8.21.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: prod-minor-updates
- dependency-name: posthog-node
  dependency-version: 5.35.6
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: prod-minor-updates
- dependency-name: react-hook-form
  dependency-version: 7.76.1
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: prod-minor-updates
- dependency-name: ws
  dependency-version: 8.21.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: prod-minor-updates
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-05-29 01:43:25 +00:00
dependabot[bot]
18ac542e30 Bump the dev-patch-updates group with 4 updates
Bumps the dev-patch-updates group with 4 updates: [@tanstack/react-query-devtools](https://github.com/TanStack/query/tree/HEAD/packages/react-query-devtools), [@types/react](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/react), [postcss](https://github.com/postcss/postcss) and [tsx](https://github.com/privatenumber/tsx).


Updates `@tanstack/react-query-devtools` from 5.100.10 to 5.100.14
- [Release notes](https://github.com/TanStack/query/releases)
- [Changelog](https://github.com/TanStack/query/blob/main/packages/react-query-devtools/CHANGELOG.md)
- [Commits](https://github.com/TanStack/query/commits/@tanstack/react-query-devtools@5.100.14/packages/react-query-devtools)

Updates `@types/react` from 19.2.14 to 19.2.15
- [Release notes](https://github.com/DefinitelyTyped/DefinitelyTyped/releases)
- [Commits](https://github.com/DefinitelyTyped/DefinitelyTyped/commits/HEAD/types/react)

Updates `postcss` from 8.5.14 to 8.5.15
- [Release notes](https://github.com/postcss/postcss/releases)
- [Changelog](https://github.com/postcss/postcss/blob/main/CHANGELOG.md)
- [Commits](https://github.com/postcss/postcss/compare/8.5.14...8.5.15)

Updates `tsx` from 4.22.0 to 4.22.3
- [Release notes](https://github.com/privatenumber/tsx/releases)
- [Changelog](https://github.com/privatenumber/tsx/blob/master/release.config.cjs)
- [Commits](https://github.com/privatenumber/tsx/compare/v4.22.0...v4.22.3)

---
updated-dependencies:
- dependency-name: "@tanstack/react-query-devtools"
  dependency-version: 5.100.14
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: dev-patch-updates
- dependency-name: "@types/react"
  dependency-version: 19.2.15
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: dev-patch-updates
- dependency-name: postcss
  dependency-version: 8.5.15
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: dev-patch-updates
- dependency-name: tsx
  dependency-version: 4.22.3
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: dev-patch-updates
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-05-29 01:36:20 +00:00
Owen
322475fb5c Fix errors 2026-05-28 17:59:21 -07:00
Owen
2f124bffc4 Merge branch 'main' into dev 2026-05-28 17:46:42 -07:00
Owen Schwartz
86367383e7 Merge pull request #3026 from immanuwell/fix-validator-test-exit
fix: make validators test failures exit non-zero
2026-05-28 17:43:43 -07:00
Owen Schwartz
d22ba3566d Merge pull request #2618 from LunarECL/fix-docker-label-partial-validation
Skip invalid Docker resources instead of failing entire blueprint (#1784)
2026-05-28 17:41:15 -07:00
Owen Schwartz
c74b423bae Merge pull request #3119 from Adityakk9031/#3086
Sort resource filter options in audit logs
2026-05-28 15:50:27 -07:00
Owen
f8a757c55f Merge branch 'resource-policies' into dev 2026-05-28 15:30:16 -07:00
Owen
6aea3f1643 Merge branch 'auto-update' into dev 2026-05-28 13:59:34 -07:00
Owen
073dc34522 Merge branch 'rdp-ssh' into dev 2026-05-28 13:59:14 -07:00
Owen
3f5970a1f9 Merge branch 'dev' of github.com:fosrl/pangolin into dev 2026-05-28 13:56:18 -07:00
Owen
e2f2608358 Merge branch 'main' into dev 2026-05-28 13:56:08 -07:00
Owen Schwartz
6d17bb04c4 Merge pull request #3167 from shleeable/patch-1
Installer: format main.go
2026-05-28 12:13:45 -07:00
Owen Schwartz
957e7ba127 Merge pull request #3175 from shleeable/patch-4
Fix:  OLM token rate limit uses wrong field name
2026-05-28 12:13:04 -07:00
Owen Schwartz
def710cba8 Merge pull request #3176 from shleeable/patch-5
Fix: Update external.ts windowMs rate limit for milliseconds
2026-05-28 12:12:39 -07:00
Owen Schwartz
44da854575 Merge pull request #3177 from shleeable/patch-6
Fix: Missing return
2026-05-28 12:11:40 -07:00
miloschwartz
d3d2474855 update blueprints to say blueprints log 2026-05-28 12:11:20 -07:00
Owen Schwartz
d7d37c6f6e Merge pull request #3179 from fosrl/dependabot/npm_and_yarn/dev-minor-updates-545c73ecbb
Bump the dev-minor-updates group across 1 directory with 6 updates
2026-05-28 12:10:40 -07:00
dependabot[bot]
3c80b9a229 Bump the dev-minor-updates group across 1 directory with 6 updates
Bumps the dev-minor-updates group with 6 updates in the / directory:

| Package | From | To |
| --- | --- | --- |
| [@dotenvx/dotenvx](https://github.com/dotenvx/dotenvx) | `1.66.0` | `1.69.1` |
| [@react-email/ui](https://github.com/resend/react-email/tree/HEAD/packages/ui) | `6.1.4` | `6.5.0` |
| [@types/node](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/node) | `25.8.0` | `25.9.1` |
| [eslint](https://github.com/eslint/eslint) | `10.3.0` | `10.4.0` |
| [react-email](https://github.com/resend/react-email/tree/HEAD/packages/react-email) | `6.1.4` | `6.5.0` |
| [typescript-eslint](https://github.com/typescript-eslint/typescript-eslint/tree/HEAD/packages/typescript-eslint) | `8.59.3` | `8.60.0` |



Updates `@dotenvx/dotenvx` from 1.66.0 to 1.69.1
- [Release notes](https://github.com/dotenvx/dotenvx/releases)
- [Changelog](https://github.com/dotenvx/dotenvx/blob/main/CHANGELOG.md)
- [Commits](https://github.com/dotenvx/dotenvx/compare/v1.66.0...v1.69.1)

Updates `@react-email/ui` from 6.1.4 to 6.5.0
- [Release notes](https://github.com/resend/react-email/releases)
- [Changelog](https://github.com/resend/react-email/blob/canary/packages/ui/CHANGELOG.md)
- [Commits](https://github.com/resend/react-email/commits/@react-email/ui@6.5.0/packages/ui)

Updates `@types/node` from 25.8.0 to 25.9.1
- [Release notes](https://github.com/DefinitelyTyped/DefinitelyTyped/releases)
- [Commits](https://github.com/DefinitelyTyped/DefinitelyTyped/commits/HEAD/types/node)

Updates `eslint` from 10.3.0 to 10.4.0
- [Release notes](https://github.com/eslint/eslint/releases)
- [Commits](https://github.com/eslint/eslint/compare/v10.3.0...v10.4.0)

Updates `react-email` from 6.1.4 to 6.5.0
- [Release notes](https://github.com/resend/react-email/releases)
- [Changelog](https://github.com/resend/react-email/blob/canary/packages/react-email/CHANGELOG.md)
- [Commits](https://github.com/resend/react-email/commits/react-email@6.5.0/packages/react-email)

Updates `typescript-eslint` from 8.59.3 to 8.60.0
- [Release notes](https://github.com/typescript-eslint/typescript-eslint/releases)
- [Changelog](https://github.com/typescript-eslint/typescript-eslint/blob/main/packages/typescript-eslint/CHANGELOG.md)
- [Commits](https://github.com/typescript-eslint/typescript-eslint/commits/v8.60.0/packages/typescript-eslint)

---
updated-dependencies:
- dependency-name: "@dotenvx/dotenvx"
  dependency-version: 1.69.1
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: dev-minor-updates
- dependency-name: "@react-email/ui"
  dependency-version: 6.5.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: dev-minor-updates
- dependency-name: "@types/node"
  dependency-version: 25.9.1
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: dev-minor-updates
- dependency-name: eslint
  dependency-version: 10.4.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: dev-minor-updates
- dependency-name: react-email
  dependency-version: 6.5.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: dev-minor-updates
- dependency-name: typescript-eslint
  dependency-version: 8.60.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: dev-minor-updates
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-05-28 19:10:09 +00:00
Owen Schwartz
a998a35482 Merge pull request #3181 from fosrl/remove-resend
Remove resend
2026-05-28 12:07:20 -07:00
Owen
20e0e5ebd0 Remove resend 2026-05-28 12:06:29 -07:00
Owen Schwartz
4d831effe1 Merge pull request #3180 from fosrl/dependabot/npm_and_yarn/prod-patch-updates-203742b32f
Bump the prod-patch-updates group across 1 directory with 5 updates
2026-05-28 12:06:08 -07:00
dependabot[bot]
80f4dd0e60 Bump the prod-patch-updates group across 1 directory with 5 updates
Bumps the prod-patch-updates group with 5 updates in the / directory:

| Package | From | To |
| --- | --- | --- |
| [@simplewebauthn/server](https://github.com/MasterKale/SimpleWebAuthn/tree/HEAD/packages/server) | `13.3.0` | `13.3.1` |
| [@tanstack/react-query](https://github.com/TanStack/query/tree/HEAD/packages/react-query) | `5.100.10` | `5.100.14` |
| [nodemailer](https://github.com/nodemailer/nodemailer) | `8.0.7` | `8.0.9` |
| [resend](https://github.com/resend/resend-node) | `6.12.3` | `6.12.4` |
| [semver](https://github.com/npm/node-semver) | `7.8.0` | `7.8.1` |



Updates `@simplewebauthn/server` from 13.3.0 to 13.3.1
- [Release notes](https://github.com/MasterKale/SimpleWebAuthn/releases)
- [Changelog](https://github.com/MasterKale/SimpleWebAuthn/blob/master/CHANGELOG.md)
- [Commits](https://github.com/MasterKale/SimpleWebAuthn/commits/v13.3.1/packages/server)

Updates `@tanstack/react-query` from 5.100.10 to 5.100.14
- [Release notes](https://github.com/TanStack/query/releases)
- [Changelog](https://github.com/TanStack/query/blob/main/packages/react-query/CHANGELOG.md)
- [Commits](https://github.com/TanStack/query/commits/@tanstack/react-query@5.100.14/packages/react-query)

Updates `nodemailer` from 8.0.7 to 8.0.9
- [Release notes](https://github.com/nodemailer/nodemailer/releases)
- [Changelog](https://github.com/nodemailer/nodemailer/blob/master/CHANGELOG.md)
- [Commits](https://github.com/nodemailer/nodemailer/compare/v8.0.7...v8.0.9)

Updates `resend` from 6.12.3 to 6.12.4
- [Release notes](https://github.com/resend/resend-node/releases)
- [Commits](https://github.com/resend/resend-node/compare/v6.12.3...v6.12.4)

Updates `semver` from 7.8.0 to 7.8.1
- [Release notes](https://github.com/npm/node-semver/releases)
- [Changelog](https://github.com/npm/node-semver/blob/main/CHANGELOG.md)
- [Commits](https://github.com/npm/node-semver/compare/v7.8.0...v7.8.1)

---
updated-dependencies:
- dependency-name: "@simplewebauthn/server"
  dependency-version: 13.3.1
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: prod-patch-updates
- dependency-name: "@tanstack/react-query"
  dependency-version: 5.100.14
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: prod-patch-updates
- dependency-name: nodemailer
  dependency-version: 8.0.9
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: prod-patch-updates
- dependency-name: resend
  dependency-version: 6.12.4
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: prod-patch-updates
- dependency-name: semver
  dependency-version: 7.8.1
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: prod-patch-updates
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-05-28 19:02:31 +00:00
Owen Schwartz
eafa3076d8 Merge pull request #3137 from fosrl/dependabot/npm_and_yarn/qs-6.15.2
Bump qs from 6.15.1 to 6.15.2
2026-05-28 12:01:50 -07:00
Owen Schwartz
fef3cd8354 Merge pull request #2908 from fosrl/dependabot/github_actions/actions/setup-node-6.4.0
Bump actions/setup-node from 6.3.0 to 6.4.0
2026-05-28 12:00:48 -07:00
Owen Schwartz
36ada0705e Merge pull request #3044 from fosrl/dependabot/github_actions/sigstore/cosign-installer-4.1.2
Bump sigstore/cosign-installer from 4.1.1 to 4.1.2
2026-05-28 12:00:38 -07:00
Owen Schwartz
8ae3c06df7 Merge pull request #3143 from fosrl/dependabot/github_actions/actions/stale-10.3.0
Bump actions/stale from 10.2.0 to 10.3.0
2026-05-28 12:00:25 -07:00
dependabot[bot]
ba127a8536 Bump qs from 6.15.1 to 6.15.2
Bumps [qs](https://github.com/ljharb/qs) from 6.15.1 to 6.15.2.
- [Changelog](https://github.com/ljharb/qs/blob/main/CHANGELOG.md)
- [Commits](https://github.com/ljharb/qs/compare/v6.15.1...v6.15.2)

---
updated-dependencies:
- dependency-name: qs
  dependency-version: 6.15.2
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-05-28 18:59:36 +00:00
Owen Schwartz
5c024f3a3a Merge pull request #3142 from fosrl/dependabot/github_actions/docker/login-action-4.2.0
Bump docker/login-action from 4.1.0 to 4.2.0
2026-05-28 11:57:53 -07:00
Owen Schwartz
4fdb8583f6 Merge pull request #3178 from fosrl/sec-updates
Advance security updates to main
2026-05-28 11:56:57 -07:00
Owen Schwartz
2946df3b8e Merge pull request #3085 from marcschaeferger-org/security-updates
Normalize request parameters and update dependencies for Security
2026-05-28 11:54:23 -07:00
Shlee
c3b0c4e5e9 Update verifyApiKeyOrgAccess.ts 2026-05-28 15:55:34 +09:30
Shlee
a79d0f1677 Update external.ts 2026-05-28 15:45:06 +09:30
Shlee
bfd7a7f561 Update external.ts 2026-05-28 15:31:45 +09:30
Owen
a5332bb0cc Update tsconfig 2026-05-27 21:38:19 -07:00
Owen
b3963cc34b Middleware -> proxy 2026-05-27 21:38:12 -07:00
Owen Schwartz
ddb132f9fa Merge pull request #3085 from marcschaeferger-org/security-updates
Normalize request parameters and update dependencies for Security
2026-05-27 21:37:50 -07:00
Owen
64c901d91f Properly lock the ip selection through writes to db 2026-05-27 21:08:45 -07:00
Owen
cd9e56fdb7 Make the destination optional 2026-05-27 17:52:04 -07:00
Owen
1b6b112e92 Add auth daemon to blueprints 2026-05-27 17:29:19 -07:00
Owen
0ff0e83c9f Complete removal of http and protocol from public 2026-05-27 17:19:04 -07:00
Owen
6d491b7bb9 Cache wildcard certs for easy lookup 2026-05-27 14:58:36 -07:00
miloschwartz
cdc50ed47a remove shadow from labels 2026-05-27 14:23:04 -07:00
Owen
06cc13c637 Moving to mode replacing http and protocol fields 2026-05-27 12:04:00 -07:00
Owen
464d4990df Fix cascading errors 2026-05-27 11:34:34 -07:00
miloschwartz
e2441ce284 adjust label overflow 2026-05-26 23:59:49 -07:00
miloschwartz
0b6a3234a5 auto close labels dropdown on select but not on checkbox 2026-05-26 22:47:05 -07:00
miloschwartz
ae8599c723 dont close label filter after select 2026-05-26 22:30:55 -07:00
miloschwartz
938e9b0d49 more ui/ux enhancements around labels and tables 2026-05-26 22:26:54 -07:00
miloschwartz
05e4ad3200 minor visual adjustments to tags 2026-05-26 21:34:15 -07:00
Owen
cb90672573 Trying to get these forms to work 2026-05-26 21:20:34 -07:00
Milo Schwartz
9eb55ba68c Merge pull request #3130 from Fredkiss3/feat/filter-on-label-column
feat: add label filter column to sites, resource & client tables
2026-05-26 21:01:22 -07:00
Owen
e19b6ebc82 Hide the destination and the alias 2026-05-26 20:38:04 -07:00
Owen
5a6de12f74 Revert to the mode on top and make it 2 x 2 2026-05-26 20:30:33 -07:00
Owen
6e6c91a27c Move site down 2026-05-26 20:16:54 -07:00
Shlee
cf12ab1ac3 Update main.go 2026-05-27 12:12:48 +09:30
Owen
aa7004b2ff Add new ssh config for private resources 2026-05-26 17:50:46 -07:00
Owen
eca87b66f0 Use the create api 2026-05-26 17:11:45 -07:00
Owen
cc8c89eeae Cleaning up some react 2026-05-26 16:53:22 -07:00
Fred KISSIE
6d14a4df49 💄 fix blueprint toast color 2026-05-27 01:36:45 +02:00
Owen
6ea4aa1920 Suppoter key 2026-05-26 16:35:28 -07:00
Owen
f12451b8f9 Consolidate target components 2026-05-26 16:33:54 -07:00
Owen
0d4bb65a92 Adjusting the create ui 2026-05-26 16:10:06 -07:00
Owen
d47ad9ac40 Fix height problem 2026-05-26 16:08:52 -07:00
Fred KISSIE
94949aa3fd ♻️ fix search params on other tables too 2026-05-27 00:44:18 +02:00
Fred KISSIE
df098f55ba ♻️ pass default user search params query to user devices table 2026-05-27 00:23:48 +02:00
Owen
f81ae24ba7 Clean up the ui a bit 2026-05-26 15:05:30 -07:00
Fred KISSIE
facbb8f0a4 label filter column on the clients table 2026-05-26 23:46:56 +02:00
Fred KISSIE
36fbd8818c label filter column for private resources 2026-05-26 23:36:07 +02:00
Owen
df1e28aabd Pass one on the new create screen 2026-05-26 14:29:13 -07:00
Fred KISSIE
91883397e6 label filter column 2026-05-26 22:45:41 +02:00
Fred KISSIE
fd1813f3a7 Merge branch 'dev' into feat/filter-on-label-column 2026-05-26 22:26:18 +02:00
Owen Schwartz
ddabfb5ca1 Merge pull request #3154 from RitwijParmar/codex/pangolin-refresh-live-log-window
fix(logs): refresh default end time
2026-05-26 11:52:10 -07:00
Owen Schwartz
ec0666a612 Merge pull request #3151 from shleeable/patch-1
Installer: Handle both Maxmind Country and ASN databases.
2026-05-26 09:50:08 -07:00
Shlee
bbf42c5802 Update main.go 2026-05-26 17:14:06 +09:30
Ritwij Aryan Parmar
6aa1d3b094 fix(logs): refresh default end time 2026-05-26 01:26:53 -04:00
miloschwartz
0d820df797 add ce or ee to issue template 2026-05-25 21:40:02 -07:00
Shlee
f1ec1a2fb1 Update docker-compose.yml 2026-05-26 13:49:06 +09:30
Shlee
32fcf90467 Update docker-compose.yml 2026-05-26 13:48:00 +09:30
Shlee
5a53f88fd6 Update main.go 2026-05-26 13:37:28 +09:30
Shlee
51971c7ef2 Update config.yml 2026-05-26 13:36:01 +09:30
Shlee
491096109a Update main.go 2026-05-26 13:31:07 +09:30
Shlee
802a41b1bd Update main.go 2026-05-26 13:25:53 +09:30
Shlee
f59fbabede Update main.go 2026-05-26 13:12:48 +09:30
Shlee
5a7d54058e Update main.go 2026-05-26 13:06:35 +09:30
Owen Schwartz
5ef4490692 Merge pull request #3148 from bishnubista/fix-audit-log-replica-routing
fix(audit-logs): route request audit log reads through logsDb
2026-05-25 12:02:24 -07:00
bishnubista
817e848d08 fix(audit-logs): route request audit log reads through logsDb
Route the read paths in queryRequestAuditLog.ts and
queryRequestAnalytics.ts through `logsDb` instead of
`primaryLogsDb`, matching the existing private audit log routes
(queryActionAuditLog, queryAccessAuditLog, queryConnectionAuditLog
all already use `logsDb`). In PostgreSQL deployments configured
with a read replica via `withReplicas` (see server/db/pg/logsDriver.ts),
this keeps high-volume audit log reads off the primary. No-op
in OSS-SQLite where `logsDb === primaryDb`.

Investigated rewriting `queryUniqueFilterAttributes` per the
in-line TODO ("SOMEONE PLEASE OPTIMIZE THIS!!!!!"). A candidate
rewrite using UNION ALL with six GROUP BY...LIMIT 500 arms
benchmarked 48-61% slower than the current SELECT DISTINCT
LIMIT 501 approach on SQLite (100k/300k/1M rows, 20 runs each):
each grouped arm materializes a temp B-tree before applying LIMIT,
while DISTINCT short-circuits via hash dedup with early exit.
A materialized facets table is likely the right long-term fix,
not a query-shape rewrite.
2026-05-25 10:37:47 -07:00
dependabot[bot]
166c8326c5 Bump actions/stale from 10.2.0 to 10.3.0
Bumps [actions/stale](https://github.com/actions/stale) from 10.2.0 to 10.3.0.
- [Release notes](https://github.com/actions/stale/releases)
- [Changelog](https://github.com/actions/stale/blob/main/CHANGELOG.md)
- [Commits](b5d41d4e1d...eb5cf3af3a)

---
updated-dependencies:
- dependency-name: actions/stale
  dependency-version: 10.3.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-05-25 01:52:46 +00:00
dependabot[bot]
673f1e93f4 Bump docker/login-action from 4.1.0 to 4.2.0
Bumps [docker/login-action](https://github.com/docker/login-action) from 4.1.0 to 4.2.0.
- [Release notes](https://github.com/docker/login-action/releases)
- [Commits](4907a6ddec...650006c6eb)

---
updated-dependencies:
- dependency-name: docker/login-action
  dependency-version: 4.2.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-05-25 01:52:42 +00:00
Owen
4c1e1daf07 All page types are there and look mostly correct 2026-05-22 17:37:37 -07:00
Owen
7c54df7ed1 Rework page to be functional 2026-05-22 16:09:02 -07:00
Owen
9d77fcc457 Make the first ssh page and conditional http page 2026-05-22 15:12:37 -07:00
Owen
454449ec8a Add support for push pam users 2026-05-22 12:12:55 -07:00
Owen
fe67e8e384 Clean up types 2026-05-22 12:12:46 -07:00
Owen
715b957660 Support not push ssh method 2026-05-22 11:19:35 -07:00
Owen
f1e4bf8d36 Add hint about invoices for the license key 2026-05-22 10:30:41 -07:00
Fred KISSIE
76aea311a4 add label filter column to sitesTable 2026-05-22 04:07:49 +02:00
Owen
3539b9ddb4 Working 2026-05-21 17:30:06 -07:00
Owen Schwartz
1a3cf2094b Merge pull request #3117 from Fredkiss3/refactor/loading-animation-on-request-logs
feat: Show a loading animation on http request logs table
2026-05-21 17:28:20 -07:00
Owen
4530aac4f3 Update setting is working
Adjust the ui

Adjust description
2026-05-21 16:34:32 -07:00
Fred KISSIE
09cb20a084 push 2026-05-22 00:44:29 +02:00
Owen
6d4afd0953 Control updates from the ui 2026-05-21 15:43:31 -07:00
Owen
d1fb2e19d3 Fix cache import to be dynamic 2026-05-21 14:43:50 -07:00
Owen
dee0ca6864 Add permissions check, shasum check, & build info 2026-05-21 14:34:16 -07:00
Fred KISSIE
2934bbdd20 ♻️ use react query for network logs 2026-05-21 23:27:02 +02:00
Fred KISSIE
2b46e8eaba ♻️ use useQuery for network action logs 2026-05-21 23:23:49 +02:00
Owen
ed73d089d0 Auto update newt 2026-05-21 14:13:32 -07:00
Owen
3b89104a59 Add regional redis cache 2026-05-21 14:07:09 -07:00
Fred KISSIE
5bf8b336c5 ♻️ useQuery for fetching access logs 2026-05-21 23:05:34 +02:00
Fred KISSIE
21a144753d Add access react query 2026-05-21 22:50:15 +02:00
Fred KISSIE
c1b8dfc863 ♻️ refactor 2026-05-21 22:44:24 +02:00
Fred KISSIE
5efcd4479a Merge branch 'dev' into refactor/loading-animation-on-request-logs 2026-05-21 22:31:16 +02:00
Owen
e4e8b33e9f Enforce absolute paths for sudo commands 2026-05-21 12:14:52 -07:00
Owen Schwartz
35ad235f49 Merge pull request #3129 from fosrl/fix-site-delete
Improve delete function speed & order of ops
2026-05-21 12:06:18 -07:00
Owen
834672c846 Improve delete function speed & order of ops 2026-05-21 12:05:16 -07:00
Owen
af13790c93 Fix pasting the device code not working 2026-05-20 16:28:12 -07:00
Owen Schwartz
b8180d848a Merge pull request #3118 from Adityakk9031/#3105
Fix public resource health with unknown WireGuard targets
2026-05-20 16:20:25 -07:00
Owen Schwartz
fef7563e14 Merge pull request #3125 from fosrl/fix-3104
Fix #3104
2026-05-20 16:15:21 -07:00
Owen
6337cf4359 Fix #3104 2026-05-20 16:14:47 -07:00
Owen
87bcd8ec1b Merge branch 'main' into dev 2026-05-20 15:59:01 -07:00
Owen Schwartz
b3cfe82dff Merge pull request #3124 from fosrl/fix-logoUrl
Fix logo url
2026-05-20 14:19:29 -07:00
Owen
d65128671c Fix logo url 2026-05-20 14:18:55 -07:00
Owen Schwartz
41fdd5de74 Merge pull request #3122 from fosrl/button-to-rebuild-association
Add button to rebuid cache
2026-05-20 12:08:47 -07:00
Owen
2704202ba9 Add button to rebuid cache 2026-05-20 12:08:20 -07:00
Owen Schwartz
72ef0ae020 Merge pull request #3121 from fosrl/patch-rebuild-sites
patch rebuild sites
2026-05-20 11:48:33 -07:00
Owen
1442faa740 Prevent concurrent rebuilds 2026-05-20 11:46:59 -07:00
Owen
6aa589e612 Block adds to clients in jit mode 2026-05-20 11:35:15 -07:00
Owen
4b1a8e14c4 Put long running into the background to end transaction 2026-05-20 11:18:47 -07:00
Owen
1a0db10b1a Verify button to verify cache 2026-05-20 11:15:15 -07:00
Owen
b7634086db Just accept any url for now 2026-05-20 10:47:37 -07:00
Aditya kumar singh
73e9e830c3 Sort resource filter options in audit logs 2026-05-20 11:13:50 +05:30
Aditya kumar singh
a6469e67a8 Fix public resource health with unknown WireGuard targets 2026-05-20 09:05:05 +05:30
Owen
23ca3efbf4 Merge branch 'dev' into rdp-ssh 2026-05-19 20:12:05 -07:00
Owen
0f9100fd3a Merge branch 'rdp-ssh' of github.com:fosrl/pangolin into rdp-ssh 2026-05-19 20:06:48 -07:00
Owen
832d45e32b Move pages back 2026-05-19 20:02:27 -07:00
Owen
69e3ac3cd4 Move login page locations 2026-05-19 20:02:27 -07:00
Owen
50865f4265 Remove terminate button 2026-05-19 20:02:27 -07:00
Owen
0d1a8d9695 Only switch if we are actually connected 2026-05-19 20:02:27 -07:00
Owen
5d8486dd7f Sure up some things with browserAccessType 2026-05-19 20:02:27 -07:00
Owen
3c25932787 Adjust page to be editable 2026-05-19 20:02:27 -07:00
Owen
1d0e1eb126 Temp credential storage 2026-05-19 20:02:27 -07:00
Owen
57c0dc8618 Support private key 2026-05-19 20:02:27 -07:00
Owen
526a147570 Clean up toasts 2026-05-19 20:02:27 -07:00
Owen
0938997548 Add crud for browser targets 2026-05-19 20:02:27 -07:00
Owen
0876b482f8 Remove extra fields 2026-05-19 20:02:27 -07:00
Owen
d558c31f88 Standardize the ui 2026-05-19 20:02:26 -07:00
Owen
6010515da0 Pull in the destination from the api 2026-05-19 20:02:26 -07:00
Owen
868bcd8e34 USe right table 2026-05-19 20:02:26 -07:00
Owen
20c4904965 Add internal api get for proxy information 2026-05-19 20:02:26 -07:00
Owen
5a5536b38c Reinstall packages 2026-05-19 20:02:26 -07:00
Owen
53e2296de8 Clean up forms a bit 2026-05-19 20:02:26 -07:00
Owen
d2423919e9 Add favicon passthrough 2026-05-19 20:02:26 -07:00
Owen
2250fcd177 Serve the resource from the right place 2026-05-19 20:02:26 -07:00
Owen
2a33256d17 Add gateway endpoints into the traefik config 2026-05-19 20:02:26 -07:00
Owen
117aa750f8 Working on new target type 2026-05-19 20:02:26 -07:00
Owen
15f161274f Add browserGatewayTarget table 2026-05-19 20:02:26 -07:00
Owen
09779aca3e Add basic vnc test 2026-05-19 20:02:25 -07:00
Owen
1d1f7cecf4 Support rdp 2026-05-19 20:02:25 -07:00
Owen
dc00668cbe Add first iteration of ssh proxy 2026-05-19 20:02:25 -07:00
Owen
57701e13eb Comment out some fields 2026-05-19 20:02:25 -07:00
Owen
46545cb003 Initial rdp working 2026-05-19 20:02:21 -07:00
Fred KISSIE
a163cc3678 💄 show loading animation on http request logs table 2026-05-20 04:50:49 +02:00
Fred KISSIE
1dfb3408e8 ♻️ use react query for fetching instead of useEfffect 2026-05-20 01:00:56 +02:00
Fred KISSIE
67fb2beba1 Merge branch 'dev' into refactor/loading-animation-on-request-logs 2026-05-19 23:54:21 +02:00
Owen Schwartz
1ba75092f9 Merge pull request #3113 from fosrl/dev
derived only from roles that the user holds AND are assigned to the target resource
2026-05-19 10:56:30 -07:00
Fred KISSIE
c500979099 Merge branch 'dev' into refactor/loading-animation-on-request-logs 2026-05-18 22:52:27 +02:00
copilot-swe-agent[bot]
81ed391efb Remove obsolete stoi import from blueprint OpenAPI route
Agent-Logs-Url: https://github.com/fosrl/pangolin/sessions/7b395a8e-7fae-4f4d-952e-4030fea08262

Co-authored-by: oschwartz10612 <4999704+oschwartz10612@users.noreply.github.com>
2026-05-17 21:30:23 +00:00
copilot-swe-agent[bot]
f3bee70c23 Reformat generated OpenAPI response schemas for readability
Agent-Logs-Url: https://github.com/fosrl/pangolin/sessions/7b395a8e-7fae-4f4d-952e-4030fea08262

Co-authored-by: oschwartz10612 <4999704+oschwartz10612@users.noreply.github.com>
2026-05-17 21:28:39 +00:00
copilot-swe-agent[bot]
15a9eb28d9 Add concrete OpenAPI data schemas for selected routes
Agent-Logs-Url: https://github.com/fosrl/pangolin/sessions/7b395a8e-7fae-4f4d-952e-4030fea08262

Co-authored-by: oschwartz10612 <4999704+oschwartz10612@users.noreply.github.com>
2026-05-17 21:25:53 +00:00
copilot-swe-agent[bot]
a0a093ed0b Document all registerPath responses and normalize OpenAPI parser schemas
Agent-Logs-Url: https://github.com/fosrl/pangolin/sessions/73990123-9c27-444b-bc6e-77e890a0d57c

Co-authored-by: oschwartz10612 <4999704+oschwartz10612@users.noreply.github.com>
2026-05-17 06:43:10 +00:00
copilot-swe-agent[bot]
9cec711427 Fix custom parser OpenAPI types and add structured default response schema
Agent-Logs-Url: https://github.com/fosrl/pangolin/sessions/73990123-9c27-444b-bc6e-77e890a0d57c

Co-authored-by: oschwartz10612 <4999704+oschwartz10612@users.noreply.github.com>
2026-05-17 06:38:44 +00:00
Owen Schwartz
82745c701a Merge pull request #3094 from fosrl/dev
Sync dev
2026-05-16 20:46:12 -07:00
dependabot[bot]
e4fd2b656d Bump sigstore/cosign-installer from 4.1.1 to 4.1.2
Bumps [sigstore/cosign-installer](https://github.com/sigstore/cosign-installer) from 4.1.1 to 4.1.2.
- [Release notes](https://github.com/sigstore/cosign-installer/releases)
- [Commits](https://github.com/sigstore/cosign-installer/compare/v4.1.1...6f9f17788090df1f26f669e9d70d6ae9567deba6)

---
updated-dependencies:
- dependency-name: sigstore/cosign-installer
  dependency-version: 4.1.2
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-05-16 21:55:15 +00:00
Owen
987b5d580e Sure up some things with browserAccessType 2026-05-15 17:26:58 -07:00
Owen
cb75ffc3b7 Adjust page to be editable 2026-05-15 16:46:43 -07:00
Owen
540f0a754d Temp credential storage 2026-05-15 16:11:23 -07:00
Owen
0f9a6fd968 Support private key 2026-05-15 16:07:14 -07:00
Owen
82112abc34 Clean up toasts 2026-05-15 15:01:37 -07:00
Owen
75b5afd544 Add crud for browser targets 2026-05-15 14:09:33 -07:00
Owen
00e1675f7b Remove extra fields 2026-05-15 12:08:40 -07:00
Owen
2ddbdf977b Standardize the ui 2026-05-15 12:06:05 -07:00
Owen
4c8f0cc9ec Pull in the destination from the api 2026-05-15 11:48:13 -07:00
Marc Schäfer
18d380ce30 fix(security): normalize request parameters and update dependencies
Signed-off-by: Marc Schäfer <git@marcschaeferger.de>
2026-05-15 18:35:58 +00:00
Owen
e822b681cd Merge branch 'dev' into rdp-ssh 2026-05-15 11:18:31 -07:00
Owen
e1583a58aa USe right table 2026-05-14 11:33:42 -07:00
Owen
7d922ac95f Add internal api get for proxy information 2026-05-13 21:54:58 -07:00
Owen
795a3d351e Reinstall packages 2026-05-13 21:16:40 -07:00
Owen
4b4c86b4b7 Clean up forms a bit 2026-05-13 21:16:00 -07:00
Owen
013af49137 Add favicon passthrough 2026-05-13 21:11:25 -07:00
Owen
a6ae9290f2 Serve the resource from the right place 2026-05-13 18:01:36 -07:00
Owen
de70d72e0d Add gateway endpoints into the traefik config 2026-05-13 17:33:16 -07:00
Owen
4e07e9c52c Working on new target type 2026-05-13 11:56:23 -07:00
Owen
743621eb25 Add browserGatewayTarget table 2026-05-12 21:48:59 -07:00
Owen
e9df995e76 Merge branch 'dev' into resource-policies 2026-05-12 21:12:40 -07:00
Owen
943923ff4b Add basic vnc test 2026-05-12 21:12:01 -07:00
Owen
3f17f1a468 Support rdp 2026-05-12 21:12:01 -07:00
Owen
436996a43d Add first iteration of ssh proxy 2026-05-12 21:12:01 -07:00
Owen
d42b6076d2 Comment out some fields 2026-05-12 21:12:01 -07:00
Owen
89cc99f915 Initial rdp working 2026-05-12 21:12:00 -07:00
immanuwell
0611ceb5c3 fix: make validators test failures exit non-zero 2026-05-07 20:13:56 +04:00
Owen
c4b3656fad Update UI to support additions on the resource 2026-05-06 10:09:05 -07:00
Owen
54c1dd3bae Make path the default 2026-05-05 21:05:42 -07:00
Owen
a8f4d2b7d1 Add new user and role selectors for pagination 2026-05-05 20:53:36 -07:00
Owen
51f1693dbd Merge branch 'dev' into resource-policies 2026-05-05 18:02:27 -07:00
Owen
b33a6e6fac Wipe the old tables if you are using inline 2026-05-04 20:54:43 -07:00
Owen
fc2c13a686 Add policies to blueprints 2026-05-04 20:44:04 -07:00
Owen
f4602a120e Merge branch 'dev' into resource-policies 2026-05-04 17:57:09 -07:00
Owen
7ccceeea0d Ignore extra sqlite files 2026-05-04 17:43:02 -07:00
Owen
f81f78f294 Merge branch 'dev' into resource-policies 2026-05-04 17:41:49 -07:00
Owen
6cab223f12 Adjust verify session queries to use policies 2026-05-04 17:30:10 -07:00
Owen
7b05c02508 Adjust translation 2026-05-04 16:19:04 -07:00
Owen
5922bfb1a0 Fix API endpoint action issues 2026-05-04 16:01:40 -07:00
Owen
43f2e32231 Paywall resource policies 2026-05-04 15:30:49 -07:00
Owen
20ebdc6289 Fix openapi zod issue error 2026-05-04 15:04:54 -07:00
Owen
a80ae49a33 Support multiple roles 2026-05-04 14:54:20 -07:00
Owen
660197eef1 Merge branch 'feat/resource-policies' into resource-policies 2026-05-04 14:40:44 -07:00
Fred KISSIE
81274960f6 🚧 refactor 2026-05-04 20:22:16 +02:00
rinseaid
4786fc3a31 Auto-create roles referenced in blueprints
When a blueprint references a role that doesn't exist, create it
automatically with default permissions (getOrg, getResource,
listResources) instead of throwing an error or silently dropping
the association.
2026-05-03 13:37:47 -04:00
dependabot[bot]
f286d66cbc Bump actions/setup-node from 6.3.0 to 6.4.0
Bumps [actions/setup-node](https://github.com/actions/setup-node) from 6.3.0 to 6.4.0.
- [Release notes](https://github.com/actions/setup-node/releases)
- [Commits](53b83947a5...48b55a011b)

---
updated-dependencies:
- dependency-name: actions/setup-node
  dependency-version: 6.4.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-04-27 01:36:02 +00:00
Fred KISSIE
f3eb823bc3 🐛 fix sqlite tables 2026-03-12 22:36:29 +01:00
Fred KISSIE
61c13db090 Merge branch 'dev' into feat/resource-policies 2026-03-12 22:19:37 +01:00
Fred KISSIE
ccbd793f52 💬 show error 2026-03-12 22:13:27 +01:00
Fred KISSIE
d13e6896a8 ♻️ update 2026-03-12 22:11:39 +01:00
Fred KISSIE
83a36ead10 ♻️ show success toast on resource policy update 2026-03-12 20:22:16 +01:00
Fred KISSIE
b61b74b0b5 💬 update text 2026-03-12 20:04:02 +01:00
Fred KISSIE
01b068c50f ♻️ do not edit tags if readonly 2026-03-12 18:53:18 +01:00
Fred KISSIE
fee44ce960 navigate to policy to edit 2026-03-12 18:52:13 +01:00
Fred KISSIE
1906504a86 update shared policy when selected 2026-03-12 18:35:50 +01:00
Fred KISSIE
36bcba332c 🚧 wip 2026-03-11 05:18:22 +01:00
Fred KISSIE
304ab1964c 🚧 wip 2026-03-11 04:21:55 +01:00
Fred KISSIE
b286096c7b 🌐 text 2026-03-11 03:47:31 +01:00
Fred KISSIE
a22a4b6e74 ♻️ mark forms as readonly 2026-03-11 03:47:15 +01:00
Fred KISSIE
9a680d2374 update resource should update policy 2026-03-11 03:46:40 +01:00
Fred KISSIE
f80e212b07 🚧 wip 2026-03-11 00:27:27 +01:00
Fred KISSIE
8a39b3fd45 🙈 do not include solo.yml to git 2026-03-10 18:55:12 +01:00
Fred KISSIE
61ec938b00 🚧 WIP 2026-03-10 18:54:26 +01:00
Fred KISSIE
6686de6788 ♻️ refactor 2026-03-10 17:48:17 +01:00
Fred KISSIE
79636cbb30 ♻️ delete default resource policy ID when deleting a resource 2026-03-10 17:38:19 +01:00
LunarECL
90d6178a0b Skip invalid Docker resources instead of failing entire blueprint (#1784) 2026-03-09 02:42:21 +09:00
Fred KISSIE
2fa1bc6cdc 🚧 wip 2026-03-07 03:55:30 +01:00
Fred KISSIE
c5f6d822ca ♻️ refactor auth info to use resource policies 2026-03-07 03:45:10 +01:00
Fred KISSIE
4de4bf9625 use resource policies for auth check 2026-03-07 03:35:26 +01:00
Fred KISSIE
5d956080f2 create default policy when creating a resource 2026-03-07 02:29:36 +01:00
Fred KISSIE
f8e18de2fc ♻️ prevent deleting resource policies if they have attached resources 2026-03-07 01:12:10 +01:00
Fred KISSIE
884482ec35 ♻️ delete resource policy endpoint 2026-03-06 23:57:23 +01:00
Fred KISSIE
9b43948fa4 delete resource policy endpoint 2026-03-06 22:39:44 +01:00
Fred KISSIE
bcd6cd99cc 🚧 wip 2026-03-06 04:37:57 +01:00
Fred KISSIE
37ceba6b81 💄 show attached resources in policy list 2026-03-06 04:36:12 +01:00
Fred KISSIE
dfe42e9016 ♻️ refactor 2026-03-06 04:03:40 +01:00
Fred KISSIE
38aa2dace8 ♻️ show list of resources on policy list 2026-03-06 04:03:25 +01:00
Fred KISSIE
136c3eff0c ♻️ padding bottom 2026-03-05 19:46:16 +01:00
Fred KISSIE
642999c8b1 ♻️ separate create form into multiple ones 2026-03-05 19:45:13 +01:00
Fred KISSIE
c5fc49b4fa 🚧 wip 2026-03-05 19:31:19 +01:00
Fred KISSIE
cd5a38b1eb 🚧 WIP: create policy form 2026-03-05 18:56:35 +01:00
Fred KISSIE
595842c2c9 finish create policy endpoint 2026-03-05 18:48:33 +01:00
Fred KISSIE
82d5276ade 🚧 wip: create resource policy 2026-03-05 18:24:04 +01:00
Fred KISSIE
51eb782831 🚧 wip 2026-03-05 18:14:46 +01:00
Fred KISSIE
de2980e1bc apply rules on resource policies 2026-03-05 18:13:30 +01:00
Fred KISSIE
8a3c0d9a08 ♻️ add openapi schema types 2026-03-05 17:51:55 +01:00
Fred KISSIE
1a5e9f1005 🚧 resource policy rules 2026-03-04 19:31:59 +01:00
Fred KISSIE
f42c013f33 ♻️ refactor 2026-03-04 17:41:55 +01:00
Fred KISSIE
42c9bda939 Merge branch 'dev' into feat/resource-policies 2026-03-04 16:46:33 +01:00
Fred KISSIE
cbce9fae3a 🚧 wip 2026-03-04 16:36:49 +01:00
Fred KISSIE
e44b15ecd5 set opt email whitelist 2026-03-04 01:54:50 +01:00
Fred KISSIE
7f6ca31757 🚧 Email whiteList for resource policy 2026-03-04 01:46:56 +01:00
Fred KISSIE
a1eb248474 🔨 remove docker compose mail 2026-03-04 01:10:48 +01:00
Fred KISSIE
be2b1fd1ce 🚧 wip: email whitelist 2026-03-03 20:26:17 +01:00
Fred KISSIE
20b65f549e Update resource policy pincode 2026-03-03 19:49:24 +01:00
Fred KISSIE
1dc8be373c 🚧 wip: add password 2026-03-03 18:54:35 +01:00
Fred KISSIE
22b2e6b3d4 🚧 wip: separating form sections 2026-03-03 18:41:04 +01:00
Fred KISSIE
89e7107a47 ♻️ use put and return 200 OK 2026-03-03 03:31:43 +01:00
Fred KISSIE
0a69131c38 ♻️ merge header auth & extended compability to one table 2026-03-03 03:27:02 +01:00
Fred KISSIE
590f2c29b3 🚧 prepare tables for auth methods 2026-03-03 03:20:03 +01:00
Fred KISSIE
0ddcce6fe1 🗃️ create resource policy specific tables for auth methods 2026-03-03 02:47:21 +01:00
Fred KISSIE
8a54fb7f23 🚧 auth methods 2026-03-03 02:11:05 +01:00
Fred KISSIE
5c280b024e update policy access control 2026-03-03 01:33:37 +01:00
Fred KISSIE
033cc62ce7 🚧 wip 2026-03-02 19:37:23 +01:00
Fred KISSIE
4c69b7a64e update policy access control 2026-03-02 19:26:51 +01:00
Fred KISSIE
e7ab9b3f37 🚧 wip 2026-03-02 18:32:08 +01:00
Fred KISSIE
3143662f82 Merge branch 'dev' into feat/resource-policies 2026-03-02 15:53:00 +01:00
Fred KISSIE
18964ba2a3 🚧 wip 2026-02-28 14:22:41 +01:00
Fred KISSIE
f862404c5c Merge branch 'dev' into feat/resource-policies 2026-02-28 01:17:51 +01:00
Fred KISSIE
c292578f80 Merge branch 'dev' into feat/resource-policies 2026-02-28 01:08:12 +01:00
Fred KISSIE
7b02d4104d 🚧 wip 2026-02-28 00:47:27 +01:00
Fred KISSIE
2ef5d90e13 ♻️ update policy in integration API 2026-02-27 04:24:33 +01:00
Fred KISSIE
d6a8021613 🚧 wip: update resource policy form 2026-02-27 04:21:20 +01:00
Fred KISSIE
c5231d37f6 🚧 wip 2026-02-26 19:20:15 +01:00
Fred KISSIE
4d803a40c9 🚧 wip 2026-02-25 06:00:19 +01:00
Fred KISSIE
1d709b551a create policy endpoitn 2026-02-24 06:31:43 +01:00
Fred KISSIE
335411de4c ♻️ create table for resource policies associations with users 2026-02-24 03:05:51 +01:00
Fred KISSIE
0e4abdf4b6 ♻️ usewatch 2026-02-20 02:06:23 +01:00
Fred KISSIE
267b40b73c 🚧 wip 2026-02-19 05:27:05 +01:00
Fred KISSIE
ba9a0c5e3c ♻️ refactor 2026-02-19 05:23:20 +01:00
Fred KISSIE
9e0b7ff0d7 ♻️ some other ux changes 2026-02-19 05:22:06 +01:00
Fred KISSIE
003bf7fdf3 🚸 hide otp, rules and resource rules config by default 2026-02-19 04:59:51 +01:00
Fred KISSIE
c3fdda026b ♻️ separate into diff components 2026-02-19 04:36:42 +01:00
Fred KISSIE
a53363d064 💄 include rules in create policy form 2026-02-19 03:23:54 +01:00
Fred KISSIE
ee21e1faa7 🚧 list authentication items from policy APIs 2026-02-18 05:08:42 +01:00
Fred KISSIE
e409a34a09 🚧 create policy form 2026-02-18 05:08:27 +01:00
Fred KISSIE
7177ab7f77 🚧 create resource policy table 2026-02-14 05:08:41 +01:00
Fred KISSIE
801f6fb661 🚚 move policies page to (private) folder 2026-02-14 05:03:40 +01:00
Fred KISSIE
805d82b8d9 policies table 2026-02-14 04:59:35 +01:00
Fred KISSIE
bd6d790495 Merge branch 'refactor/paginated-tables' into feat/resource-policies 2026-02-14 04:25:43 +01:00
Fred KISSIE
2305163474 🚧 wip 2026-02-14 03:24:01 +01:00
Fred KISSIE
dda53dcb16 Merge branch 'refactor/paginated-tables' into feat/resource-policies 2026-02-13 06:05:32 +01:00
Fred KISSIE
2c3e768867 🚧 wip: list resource endpoints finished 2026-02-13 05:54:45 +01:00
Fred KISSIE
8d682ed9ad 🚧 list policies endpoint + list policies table 2026-02-13 05:39:35 +01:00
Fred KISSIE
47fe497ca1 🚧 add sidebar item for policies 2026-02-13 05:39:16 +01:00
Fred KISSIE
4d5f364663 ♻️ use the correct types 2026-02-13 05:38:57 +01:00
Fred KISSIE
c3db8b972f ♻️ schema updates for policies 2026-02-13 05:36:42 +01:00
Fred KISSIE
cfced63ba1 Merge branch 'dev' into feat/resource-policies 2026-02-13 02:14:14 +01:00
Fred KISSIE
51aa55f963 revert changes already included in another PR 2026-02-13 00:25:00 +01:00
Fred KISSIE
e7df24841e ♻️ update sqlite DB 2026-02-12 03:50:30 +01:00
Fred KISSIE
e6fd4c32c4 ♻️ update DB 2026-02-12 03:50:09 +01:00
Fred KISSIE
f6590aedbd ♻️ add default sso: true to resource policy table 2026-02-12 03:22:24 +01:00
Fred KISSIE
3cb9e02533 ♻️ make resourcePolicyId non nullable 2026-02-12 02:56:45 +01:00
Fred KISSIE
4d792350ef 🗃️ add resource policy table 2026-02-12 02:53:04 +01:00
469 changed files with 37000 additions and 14042 deletions

View File

@@ -34,3 +34,4 @@ build.ts
tsconfig.json
Dockerfile*
drizzle.config.ts
allowedDevOrigins.json

View File

@@ -14,12 +14,13 @@ body:
label: Environment
description: Please fill out the relevant details below for your environment.
value: |
- OS Type & Version: (e.g., Ubuntu 22.04)
- OS Type & Version:
- Pangolin Version:
- Edition (Community or Enterprise):
- Gerbil Version:
- Traefik Version:
- Newt Version:
- Olm Version: (if applicable)
- Client Version:
validations:
required: true

View File

@@ -77,7 +77,7 @@ jobs:
fi
- name: Log in to Docker Hub
uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0
uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0
with:
registry: docker.io
username: ${{ secrets.DOCKER_HUB_USERNAME }}
@@ -149,7 +149,7 @@ jobs:
fi
- name: Log in to Docker Hub
uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0
uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0
with:
registry: docker.io
username: ${{ secrets.DOCKER_HUB_USERNAME }}
@@ -204,7 +204,7 @@ jobs:
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Log in to Docker Hub
uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0
uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0
with:
registry: docker.io
username: ${{ secrets.DOCKER_HUB_USERNAME }}
@@ -407,7 +407,7 @@ jobs:
shell: bash
- name: Login to GitHub Container Registry (for cosign)
uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0
uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0
with:
registry: ghcr.io
username: ${{ github.actor }}

View File

@@ -24,7 +24,7 @@ jobs:
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Set up Node.js
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
node-version: '24'

View File

@@ -23,7 +23,7 @@ jobs:
skopeo --version
- name: Install cosign
uses: sigstore/cosign-installer@cad07c2e89fa2edd6e2d7bab4c1aa38e53f76003 # v4.1.1
uses: sigstore/cosign-installer@6f9f17788090df1f26f669e9d70d6ae9567deba6 # v4.1.2
- name: Input check
run: |

View File

@@ -14,7 +14,7 @@ jobs:
stale:
runs-on: ubuntu-latest
steps:
- uses: actions/stale@b5d41d4e1d5dceea10e7104786b73624c18a190f # v10.2.0
- uses: actions/stale@eb5cf3af3ac0a1aa4c9c45633dd1ae542a27a899 # v10.3.0
with:
days-before-stale: 14
days-before-close: 14

View File

@@ -17,7 +17,7 @@ jobs:
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Install Node
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
node-version: '24'

6
.gitignore vendored
View File

@@ -17,9 +17,9 @@ yarn-error.log*
*.tsbuildinfo
next-env.d.ts
*.db
*.sqlite
*.sqlite*
!Dockerfile.sqlite
*.sqlite3
*.sqlite3*
*.log
.machinelogs*.json
*-audit.json
@@ -54,3 +54,5 @@ hydrateSaas.ts
CLAUDE.md
drizzle.config.ts
server/setup/migrations.ts
solo.yml
allowedDevOrigins.json

View File

@@ -107,7 +107,7 @@ the docs to illustrate some basic ideas.
## Licensing
Pangolin is dual licensed under the AGPL-3 and the [Fossorial Commercial License](https://pangolin.net/fcl.html). For inquiries about commercial licensing, please contact us at [contact@pangolin.net](mailto:contact@pangolin.net).
Pangolin is dual licensed under the AGPL-3 and the [Fossorial Commercial License](https://pangolin.net/fcl). For inquiries about commercial licensing, please contact us at [contact@pangolin.net](mailto:contact@pangolin.net).
## Contributions

View File

@@ -0,0 +1,51 @@
import { CommandModule } from "yargs";
import { db, users } from "@server/db";
import { eq } from "drizzle-orm";
type SetServerAdminArgs = {
email: string;
};
export const setServerAdmin: CommandModule<{}, SetServerAdminArgs> = {
command: "set-server-admin",
describe: "Mark any user as a server admin by email address",
builder: (yargs) => {
return yargs.option("email", {
type: "string",
demandOption: true,
describe: "User email address"
});
},
handler: async (argv: { email: string }) => {
try {
const email = argv.email.trim().toLowerCase();
const [user] = await db
.select()
.from(users)
.where(eq(users.email, email))
.limit(1);
if (!user) {
console.error(`User with email '${email}' not found`);
process.exit(1);
}
if (user.serverAdmin) {
console.log(`User '${email}' is already a server admin`);
process.exit(0);
}
await db
.update(users)
.set({ serverAdmin: true })
.where(eq(users.userId, user.userId));
console.log(`User '${email}' has been marked as a server admin`);
process.exit(0);
} catch (error) {
console.error("Error:", error);
process.exit(1);
}
}
};

View File

@@ -11,6 +11,7 @@ import { deleteClient } from "./commands/deleteClient";
import { generateOrgCaKeys } from "./commands/generateOrgCaKeys";
import { clearCertificates } from "./commands/clearCertificates";
import { disableUser2fa } from "./commands/disableUser2fa";
import { setServerAdmin } from "./commands/setServerAdmin";
yargs(hideBin(process.argv))
.scriptName("pangctl")
@@ -23,5 +24,6 @@ yargs(hideBin(process.argv))
.command(generateOrgCaKeys)
.command(clearCertificates)
.command(disableUser2fa)
.command(setServerAdmin)
.demandCommand()
.help().argv;

View File

@@ -1,54 +1,47 @@
api:
insecure: true
dashboard: true
providers:
http:
endpoint: "http://pangolin:3001/api/v1/traefik-config"
pollInterval: "5s"
endpoint: http://pangolin:3001/api/v1/traefik-config
pollInterval: 5s
file:
filename: "/etc/traefik/dynamic_config.yml"
filename: /etc/traefik/dynamic_config.yml
experimental:
plugins:
badger:
moduleName: "github.com/fosrl/badger"
version: "{{.BadgerVersion}}"
moduleName: github.com/fosrl/badger
version: v1.4.1
log:
level: "INFO"
format: "common"
level: INFO
format: common
maxSize: 100
maxBackups: 3
maxAge: 3
compress: true
certificatesResolvers:
letsencrypt:
acme:
httpChallenge:
entryPoint: web
email: "{{.LetsEncryptEmail}}"
storage: "/letsencrypt/acme.json"
caServer: "https://acme-v02.api.letsencrypt.org/directory"
email: '{{.LetsEncryptEmail}}'
storage: /letsencrypt/acme.json
caServer: https://acme-v02.api.letsencrypt.org/directory
entryPoints:
web:
address: ":80"
address: ':80'
websecure:
address: ":443"
address: ':443'
transport:
respondingTimeouts:
readTimeout: "30m"
readTimeout: 30m
http:
tls:
certResolver: "letsencrypt"
certResolver: letsencrypt
encodedCharacters:
allowEncodedSlash: true
allowEncodedQuestionMark: true
serversTransport:
insecureSkipVerify: true
ping:
entryPoint: "web"
entryPoint: web

View File

@@ -1,4 +1,4 @@
import { APP_PATH } from "@server/lib/consts";
import { APP_PATH } from "./server/lib/consts";
import { defineConfig } from "drizzle-kit";
import path from "path";

View File

@@ -22,7 +22,8 @@ server:
methods: ["GET", "POST", "PUT", "DELETE", "PATCH"]
allowed_headers: ["X-CSRF-Token", "Content-Type"]
credentials: false
{{if .EnableGeoblocking}}maxmind_db_path: "./config/GeoLite2-Country.mmdb"{{end}}
{{if .EnableMaxMind}}maxmind_db_path: "./config/GeoLite2-Country.mmdb"{{end}}
{{if .EnableMaxMind}}maxmind_asn_path: "./config/GeoLite2-ASN.mmdb"{{end}}
{{if .EnableEmail}}
email:
smtp_host: "{{.EmailSMTPHost}}"
@@ -36,3 +37,8 @@ flags:
disable_signup_without_invite: true
disable_user_create_org: false
allow_raw_resources: true
{{if .IsPostgreSQL}}
postgres:
connection_string: postgresql://pangolin:{{.IsPostgreSQLPass}}@postgres:5432/pangolin
{{end}}

View File

@@ -1,7 +1,7 @@
name: pangolin
services:
pangolin:
image: docker.io/fosrl/pangolin:{{if .IsEnterprise}}ee-{{end}}{{.PangolinVersion}}
image: docker.io/fosrl/pangolin:{{if .IsEnterprise}}ee-{{end}}{{if .IsPostgreSQL}}postgresql-{{end}}{{.PangolinVersion}}
container_name: pangolin
restart: unless-stopped
deploy:
@@ -10,6 +10,20 @@ services:
memory: 1g
reservations:
memory: 256m
{{if or .IsPostgreSQL .IsRedis}}
depends_on:
{{if .IsPostgreSQL}}
postgres:
condition: service_healthy
{{end}}
{{if .IsRedis}}
redis:
condition: service_healthy
{{end}}
networks:
- default
- backend
{{end}}
volumes:
- ./config:/app/config
healthcheck:
@@ -60,8 +74,56 @@ services:
- ./config/letsencrypt:/letsencrypt # Volume to store the Let's Encrypt certificates
- ./config/traefik/logs:/var/log/traefik # Volume to store Traefik logs
{{if .IsPostgreSQL}}
postgres:
image: postgres:18
container_name: postgres
restart: unless-stopped
environment:
POSTGRES_USER: pangolin
POSTGRES_PASSWORD: {{.IsPostgreSQLPass}}
POSTGRES_DB: pangolin
volumes:
- ./postgres18:/var/lib/postgresql
healthcheck:
test: ["CMD-SHELL", "pg_isready -U pangolin"]
interval: 10s
timeout: 5s
retries: 5
networks:
- backend
{{end}}
{{if .IsRedis}}
redis:
image: redis:8-trixie
container_name: redis
restart: unless-stopped
command: >
redis-server
--save 3600 1000
--appendonly yes
--requirepass {{.IsRedisPass}}
volumes:
- ./redis8:/data
healthcheck:
test: ["CMD", "redis-cli", "-a", "{{.IsRedisPass}}", "ping"]
interval: 10s
timeout: 3s
retries: 3
start_period: 10s
networks:
- backend
{{end}}
networks:
default:
driver: bridge
name: pangolin
name: pangolin_frontend
{{if .EnableIPv6}} enable_ipv6: true{{end}}
{{if or .IsPostgreSQL .IsRedis}}
backend:
driver: bridge
name: pangolin_backend
internal: true
{{end}}

View File

@@ -0,0 +1,6 @@
{{if .IsRedis}}
redis:
host: "redis"
port: 6379
password: "{{.IsRedisPass}}"
{{end}}

View File

@@ -5,7 +5,7 @@ go 1.25.0
require (
github.com/charmbracelet/huh v1.0.0
github.com/charmbracelet/lipgloss v1.1.0
golang.org/x/term v0.42.0
golang.org/x/term v0.43.0
gopkg.in/yaml.v3 v3.0.1
)
@@ -33,6 +33,6 @@ require (
github.com/rivo/uniseg v0.4.7 // indirect
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
golang.org/x/sync v0.15.0 // indirect
golang.org/x/sys v0.43.0 // indirect
golang.org/x/sys v0.44.0 // indirect
golang.org/x/text v0.23.0 // indirect
)

View File

@@ -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/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.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI=
golang.org/x/sys v0.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/term v0.42.0 h1:UiKe+zDFmJobeJ5ggPwOshJIVt6/Ft0rcfrXZDLWAWY=
golang.org/x/term v0.42.0/go.mod h1:Dq/D+snpsbazcBG5+F9Q1n2rXV8Ma+71xEjTRufARgY=
golang.org/x/sys v0.44.0 h1:ildZl3J4uzeKP07r2F++Op7E9B29JRUy+a27EibtBTQ=
golang.org/x/sys v0.44.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/term v0.43.0 h1:S4RLU2sB31O/NCl+zFN9Aru9A/Cq2aqKpTZJ6B+DwT4=
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/go.mod h1:/BLNzu4aZCJ1+kcD0DNRotWKage4q2rGVAg4o22unh4=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=

View File

@@ -54,9 +54,13 @@ type Config struct {
InstallGerbil bool
TraefikBouncerKey string
DoCrowdsecInstall bool
EnableGeoblocking bool
EnableMaxMind bool
Secret string
IsEnterprise bool
IsPostgreSQL bool
IsPostgreSQLPass string
IsRedis bool
IsRedisPass string
}
type SupportedContainer string
@@ -123,11 +127,11 @@ func main() {
fmt.Println("\nConfiguration files created successfully!")
// Download MaxMind database if requested
if config.EnableGeoblocking {
fmt.Println("\n=== Downloading MaxMind Database ===")
// Download MaxMind Country / ASN database if requested
if config.EnableMaxMind {
fmt.Println("\n=== Downloading MaxMind Country and ASN Databases ===")
if err := downloadMaxMindDatabase(); err != nil {
fmt.Printf("Error downloading MaxMind database: %v\n", err)
fmt.Printf("Error downloading MaxMind databases: %v\n", err)
fmt.Println("You can download it manually later if needed.")
}
}
@@ -188,15 +192,15 @@ func main() {
fmt.Println("\n=== MaxMind Database Update ===")
if _, err := os.Stat("config/GeoLite2-Country.mmdb"); err == nil {
fmt.Println("MaxMind GeoLite2 Country database found.")
if readBool("Would you like to update the MaxMind database to the latest version?", false) {
if readBool("Would you like to update the MaxMind databases (Country and ASN) to the latest version?", false) {
if err := downloadMaxMindDatabase(); err != nil {
fmt.Printf("Error updating MaxMind database: %v\n", err)
fmt.Println("You can try updating it manually later if needed.")
}
}
} else {
fmt.Println("MaxMind GeoLite2 Country database not found.")
if readBool("Would you like to download the MaxMind GeoLite2 database for geoblocking functionality?", false) {
fmt.Println("MaxMind GeoLite2 Country and ASN databases not found.")
if readBool("Would you like to download the MaxMind GeoLite2 databases for blocking functionality?", false) {
if err := downloadMaxMindDatabase(); err != nil {
fmt.Printf("Error downloading MaxMind database: %v\n", err)
fmt.Println("You can try downloading it manually later if needed.")
@@ -204,8 +208,10 @@ func main() {
// Now you need to update your config file accordingly to enable geoblocking
fmt.Print("Please remember to update your config/config.yml file to enable geoblocking! \n\n")
// add maxmind_db_path: "./config/GeoLite2-Country.mmdb" under server
fmt.Println("Add the following line under the 'server' section:")
// add maxmind_asn_path: "./config/GeoLite2-ASN.mmdb" under server
fmt.Println("Add the following lines under the 'server' section:")
fmt.Println(" maxmind_db_path: \"./config/GeoLite2-Country.mmdb\"")
fmt.Println(" maxmind_asn_path: \"./config/GeoLite2-ASN.mmdb\"")
}
}
}
@@ -484,6 +490,17 @@ func collectUserInput() Config {
fmt.Println("\n=== Basic Configuration ===")
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 {
config.IsRedis = readBool("Do you want to run the Redis containers locally? Required for HA.")
if config.IsRedis {
config.IsRedisPass = readPassword("Enter a unique password for the Redis service.")
}
}
config.IsPostgreSQL = readBool("Do you want to run the PostgreSQL containers locally? Otherwise, default to the local SQLite database only.", false)
if config.IsPostgreSQL {
config.IsPostgreSQLPass = readPassword("Enter a unique password for the PostgreSQL pangolin user.")
}
config.BaseDomain = readString("Enter your base domain (no subdomain e.g. example.com)", "")
@@ -527,7 +544,7 @@ func collectUserInput() Config {
fmt.Println("\n=== Advanced Configuration ===")
config.EnableIPv6 = readBool("Is your server IPv6 capable?", true)
config.EnableGeoblocking = readBool("Do you want to download the MaxMind GeoLite2 database for geoblocking functionality?", true)
config.EnableMaxMind = readBool("Do you want to download the MaxMind GeoLite2 Country and ADN databases for blocking functionality?", true)
if config.DashboardDomain == "" {
fmt.Println("Error: Dashboard Domain name is required")
@@ -780,29 +797,42 @@ func checkPortsAvailable(port int) error {
}
func downloadMaxMindDatabase() error {
fmt.Println("Downloading MaxMind GeoLite2 Country database...")
fmt.Println("Downloading MaxMind GeoLite2 Country and ASN databases...")
// Download the GeoLite2 Country database
// Download the GeoLite2 Country databases
if err := run("curl", "-L", "-o", "GeoLite2-Country.tar.gz",
"https://github.com/GitSquared/node-geolite2-redist/raw/refs/heads/master/redist/GeoLite2-Country.tar.gz"); err != nil {
return fmt.Errorf("failed to download GeoLite2 database: %v", err)
return fmt.Errorf("failed to download GeoLite2 Country database: %v", err)
}
if err := run("curl", "-L", "-o", "GeoLite2-ASN.tar.gz",
"https://github.com/GitSquared/node-geolite2-redist/raw/refs/heads/master/redist/GeoLite2-ASN.tar.gz"); err != nil {
return fmt.Errorf("failed to download GeoLite2 ASN database: %v", err)
}
// Extract the database
// Extract the Country database
if err := run("tar", "-xzf", "GeoLite2-Country.tar.gz"); err != nil {
return fmt.Errorf("failed to extract GeoLite2 database: %v", err)
return fmt.Errorf("failed to extract GeoLite2 Country database: %v", err)
}
if err := run("tar", "-xzf", "GeoLite2-ASN.tar.gz"); err != nil {
return fmt.Errorf("failed to extract GeoLite2 ASN database: %v", err)
}
// Find the .mmdb file and move it to the config directory
if err := run("bash", "-c", "mv GeoLite2-Country_*/GeoLite2-Country.mmdb config/"); err != nil {
return fmt.Errorf("failed to move GeoLite2 database to config directory: %v", err)
return fmt.Errorf("failed to move GeoLite2 Country database to config directory: %v", err)
}
if err := run("bash", "-c", "mv GeoLite2-ASN_*/GeoLite2-ASN.mmdb config/"); err != nil {
return fmt.Errorf("failed to move GeoLite2 ASN database to config directory: %v", err)
}
// Clean up the downloaded files
if err := run("rm", "-rf", "GeoLite2-Country.tar.gz", "GeoLite2-Country_*"); err != nil {
fmt.Printf("Warning: failed to clean up temporary files: %v\n", err)
if err := run("sh", "-c", "rm -rf GeoLite2-Country.tar.gz GeoLite2-Country_*"); err != nil {
fmt.Printf("Warning: failed to clean up temporary country files: %v\n", err)
}
if err := run("sh", "-c", "rm -rf GeoLite2-ASN.tar.gz GeoLite2-ASN_*"); err != nil {
fmt.Printf("Warning: failed to clean up temporary ASN files: %v\n", err)
}
fmt.Println("MaxMind GeoLite2 Country database downloaded successfully!")
fmt.Println("MaxMind GeoLite2 Country and ASN database downloaded successfully!")
return nil
}

View File

@@ -2049,6 +2049,7 @@
"editInternalResourceDialogModeCidr": "CIDR",
"editInternalResourceDialogModeHttp": "HTTP",
"editInternalResourceDialogModeHttps": "HTTPS",
"editInternalResourceDialogModeSsh": "SSH",
"editInternalResourceDialogScheme": "Метод",
"editInternalResourceDialogEnableSsl": "Активирайте TLS",
"editInternalResourceDialogEnableSslDescription": "Активирайте SSL/TLS криптиране за сигурни HTTPS връзки към целта.",
@@ -2098,6 +2099,7 @@
"createInternalResourceDialogModeCidr": "CIDR",
"createInternalResourceDialogModeHttp": "HTTP",
"createInternalResourceDialogModeHttps": "HTTPS",
"createInternalResourceDialogModeSsh": "SSH",
"scheme": "Метод",
"createInternalResourceDialogScheme": "Метод",
"createInternalResourceDialogEnableSsl": "Активирайте TLS",

View File

@@ -2049,6 +2049,7 @@
"editInternalResourceDialogModeCidr": "CIDR",
"editInternalResourceDialogModeHttp": "HTTP",
"editInternalResourceDialogModeHttps": "HTTPS",
"editInternalResourceDialogModeSsh": "SSH",
"editInternalResourceDialogScheme": "Schéma",
"editInternalResourceDialogEnableSsl": "Povolit SSL",
"editInternalResourceDialogEnableSslDescription": "Povolit šifrování SSL/TLS pro zabezpečené HTTPS připojení k cíli.",
@@ -2098,6 +2099,7 @@
"createInternalResourceDialogModeCidr": "CIDR",
"createInternalResourceDialogModeHttp": "HTTP",
"createInternalResourceDialogModeHttps": "HTTPS",
"createInternalResourceDialogModeSsh": "SSH",
"scheme": "Schéma",
"createInternalResourceDialogScheme": "Schéma",
"createInternalResourceDialogEnableSsl": "Povolit SSL",

View File

@@ -2049,6 +2049,7 @@
"editInternalResourceDialogModeCidr": "CIDR",
"editInternalResourceDialogModeHttp": "HTTP",
"editInternalResourceDialogModeHttps": "HTTPS",
"editInternalResourceDialogModeSsh": "SSH",
"editInternalResourceDialogScheme": "Schema",
"editInternalResourceDialogEnableSsl": "TLS aktivieren",
"editInternalResourceDialogEnableSslDescription": "SSL/TLS-Verschlüsselung für sichere HTTPS-Verbindungen zum Ziel aktivieren.",
@@ -2098,6 +2099,7 @@
"createInternalResourceDialogModeCidr": "CIDR",
"createInternalResourceDialogModeHttp": "HTTP",
"createInternalResourceDialogModeHttps": "HTTPS",
"createInternalResourceDialogModeSsh": "SSH",
"scheme": "Schema",
"createInternalResourceDialogScheme": "Schema",
"createInternalResourceDialogEnableSsl": "TLS aktivieren",

View File

@@ -176,6 +176,7 @@
"shareErrorCreateDescription": "An error occurred while creating the share link",
"shareCreateDescription": "Anyone with this link can access the resource",
"shareTitleOptional": "Title (optional)",
"sharePathOptional": "Path (optional)",
"expireIn": "Expire In",
"neverExpire": "Never expire",
"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.",
@@ -208,11 +209,33 @@
"resourcesSearch": "Search resources...",
"resourceAdd": "Add Resource",
"resourceErrorDelte": "Error deleting resource",
"resourcePoliciesTitle": "Manage Resource Policies",
"resourcePoliciesAttachedResourcesColumnTitle": "Attached resources",
"resourcePoliciesAttachedResources": "{count} resource(s)",
"resourcePoliciesAttachedResourcesEmpty": "no resources",
"resourcePoliciesDescription": "Create and manage authentication policies to control access to your resources",
"resourcePoliciesSearch": "Search policies...",
"resourcePoliciesAdd": "Add Policy",
"resourcePoliciesDefaultBadgeText": "Default policy",
"resourcePoliciesCreate": "Create Resource Policy",
"resourcePoliciesCreateDescription": "Follow the steps below to create a new policy",
"resourcePolicyName": "Policy Name",
"resourcePolicyNameDescription": "Give this policy a name to identify it across your resources",
"resourcePolicyNamePlaceholder": "e.g. Internal Access Policy",
"resourcePoliciesSeeAll": "See All Policies",
"resourcePolicyAuthMethodAdd": "Add Authentication Method",
"resourcePolicyOtpEmailAdd": "Add OTP emails",
"resourcePolicyRulesAdd": "Add Rules",
"resourcePolicyAuthMethodsDescription": "Allow access to resources via additional auth methods",
"resourcePolicyUsersRolesDescription": "Configure which users and roles can visit associated resources",
"rulesResourcePolicyDescription": "Configure rules to control access resources associated to this policy",
"authentication": "Authentication",
"protected": "Protected",
"notProtected": "Not Protected",
"resourceMessageRemove": "Once removed, the resource will no longer be accessible. All targets associated with the resource will also be removed.",
"resourceQuestionRemove": "Are you sure you want to remove the resource from the organization?",
"resourcePolicyMessageRemove": "Once removed, the resource policy will no longer be accessible. All resources associated with the resource will be unlinked and left without authentication.",
"resourcePolicyQuestionRemove": "Are you sure you want to remove the resource policy from the organization?",
"resourceHTTP": "HTTPS Resource",
"resourceHTTPDescription": "Proxy requests over HTTPS using a fully qualified domain name.",
"resourceRaw": "Raw TCP/UDP Resource",
@@ -220,8 +243,9 @@
"resourceRawDescriptionCloud": "Proxy requests over raw TCP/UDP using a port number. Requires sites to connect to a remote node.",
"resourceCreate": "Create Resource",
"resourceCreateDescription": "Follow the steps below to create a new resource",
"resourceCreateGeneralDescription": "Configure the basic resource settings including the name and the type",
"resourceSeeAll": "See All Resources",
"resourceInfo": "Resource Information",
"resourceCreateGeneral": "General",
"resourceNameDescription": "This is the display name for the resource.",
"siteSelect": "Select site",
"siteSearch": "Search site",
@@ -231,12 +255,15 @@
"noCountryFound": "No country found.",
"siteSelectionDescription": "This site will provide connectivity to the target.",
"resourceType": "Resource Type",
"resourceTypeDescription": "Determine how to access the resource",
"resourceTypeDescription": "This controls the resource protocol and how it will be rendered in the browser. This cant be changed later.",
"resourceDomainDescription": "The resource will be served at this fully qualified domain name.",
"resourceHTTPSSettings": "HTTPS Settings",
"resourceHTTPSSettingsDescription": "Configure how the resource will be accessed over HTTPS",
"resourcePortDescription": "The external port on the Pangolin instance or node where the resource will be accessible.",
"domainType": "Domain Type",
"subdomain": "Subdomain",
"baseDomain": "Base Domain",
"configure": "Configure",
"subdomnainDescription": "The subdomain where the resource will be accessible.",
"resourceRawSettings": "TCP/UDP Settings",
"resourceRawSettingsDescription": "Configure how the resource will be accessed over TCP/UDP",
@@ -253,6 +280,8 @@
"resourceLearnRaw": "Learn how to configure TCP/UDP resources",
"resourceBack": "Back to Resources",
"resourceGoTo": "Go to Resource",
"resourcePolicyDelete": "Delete Resource Policy",
"resourcePolicyDeleteConfirm": "Confirm Delete Resource Policy",
"resourceDelete": "Delete Resource",
"resourceDeleteConfirm": "Confirm Delete Resource",
"labelDelete": "Delete Label",
@@ -282,6 +311,8 @@
"rules": "Rules",
"resourceSettingDescription": "Configure the settings on the resource",
"resourceSetting": "{resourceName} Settings",
"resourcePolicySettingDescription": "Configure the settings on the resource policy",
"resourcePolicySetting": "{policyName} Settings",
"alwaysAllow": "Bypass Auth",
"alwaysDeny": "Block Access",
"passToAuth": "Pass to Auth",
@@ -764,6 +795,16 @@
"rulesNoOne": "No rules. Add a rule using the form.",
"rulesOrder": "Rules are evaluated by priority in ascending order.",
"rulesSubmit": "Save Rules",
"policyErrorCreate": "Error creating policy",
"policyErrorCreateDescription": "An error occurred when creating the policy",
"policyErrorCreateMessageDescription": "An unexpected error occurred",
"policyErrorUpdate": "Error updating policy",
"policyErrorUpdateDescription": "An error occurred when updating the policy",
"policyErrorUpdateMessageDescription": "An unexpected error occurred",
"policyCreatedSuccess": "Resource policy succesfully created",
"policyUpdatedSuccess": "Resource policy succesfully updated",
"authMethodsSave": "Save auth methods",
"rulesSave": "Save Rules",
"resourceErrorCreate": "Error creating resource",
"resourceErrorCreateDescription": "An error occurred when creating the resource",
"resourceErrorCreateMessage": "Error creating resource:",
@@ -827,6 +868,17 @@
"pincodeAdd": "Add PIN Code",
"pincodeRemove": "Remove PIN Code",
"resourceAuthMethods": "Authentication Methods",
"resourcePolicyAuthMethodsEmpty": "No authentication method",
"resourcePolicyOtpEmpty": "No one time password",
"resourcePolicyReadOnly": "This policy is Read only",
"resourcePolicyReadOnlyDescription": "This resource policy is shared accross multiple resources, you cannot edit it on this page.",
"editSharedPolicy": "Edit Shared Policy",
"resourcePolicyTypeSave": "Save Resource type",
"resourcePolicySelect": "Select resource policy",
"resourcePolicySelectError": "Select a resource policy",
"resourcePolicyNotFound": "Policy not found",
"resourcePolicySearch": "Search policies",
"resourcePolicyRulesEmpty": "No authentication rules",
"resourceAuthMethodsDescriptions": "Allow access to the resource via additional auth methods",
"resourceAuthSettingsSave": "Saved successfully",
"resourceAuthSettingsSaveDescription": "Authentication settings have been saved",
@@ -862,6 +914,12 @@
"resourcePincodeSetupTitle": "Set Pincode",
"resourcePincodeSetupTitleDescription": "Set a pincode to protect this resource",
"resourceRoleDescription": "Admins can always access this resource.",
"resourcePolicySelectTitle": "Resource Access Policy",
"resourcePolicySelectDescription": "Select the resource policy type for authentication",
"resourcePolicyInline": "Inline Resource Policy",
"resourcePolicyInlineDescription": "Access Policy scoped to only this resource",
"resourcePolicyShared": "Shared Resource 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.",
"resourceUsersRoles": "Access Controls",
"resourceUsersRolesDescription": "Configure which users and roles can visit this resource",
"resourceUsersRolesSubmit": "Save Access Controls",
@@ -1164,6 +1222,9 @@
"siteLabelsDescription": "Manage labels associated with this site.",
"labelsNotFound": "Labels not found",
"labelSearch": "Search labels",
"accessLabelFilterCount": "{count, plural, one {# label} other {# labels}}",
"labelOverflowCount": "+{count, plural, one {# label} other {# labels}}",
"accessLabelFilterClear": "Clear label filters",
"selectColor": "Select color",
"createNewLabel": "Create new org label \"{label}\"",
"inviteInvalidDescription": "The invite link is invalid.",
@@ -1400,6 +1461,8 @@
"sidebarResources": "Resources",
"sidebarProxyResources": "Public",
"sidebarClientResources": "Private",
"sidebarPolicies": "Policies",
"sidebarResourcePolicies": "Resources",
"sidebarAccessControl": "Access Control",
"sidebarLogsAndAnalytics": "Logs & Analytics",
"sidebarTeam": "Team",
@@ -1583,7 +1646,8 @@
"standaloneHcFilterSiteIdFallback": "Site {id}",
"standaloneHcFilterResourceIdFallback": "Resource {id}",
"blueprints": "Blueprints",
"blueprintsDescription": "Apply declarative configurations and view previous runs",
"blueprintsLog": "Blueprints Log",
"blueprintsDescription": "View past blueprint applications and their results",
"blueprintAdd": "Add Blueprint",
"blueprintGoBack": "See all Blueprints",
"blueprintCreate": "Create Blueprint",
@@ -1601,7 +1665,17 @@
"contents": "Contents",
"parsedContents": "Parsed Contents (Read Only)",
"enableDockerSocket": "Enable Docker Blueprint",
"enableDockerSocketDescription": "Enable Docker Socket label scraping for blueprint labels. Socket path must be provided to Newt. 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",
"newtAutoUpdateDescription": "When enabled, site connectors will automatically update to the latest version when a new release is available.",
"siteAutoUpdate": "Site Auto-Update",
"siteAutoUpdateLabel": "Enable Auto-Update",
"siteAutoUpdateDescription": "Control whether this site's connector automatically downloads the latest version.",
"siteAutoUpdateOrgDefault": "Organization default: {state}",
"siteAutoUpdateOverriding": "Overriding organization setting",
"siteAutoUpdateResetToOrg": "Reset to Organization Default",
"siteAutoUpdateEnabled": "enabled",
"siteAutoUpdateDisabled": "disabled",
"viewDockerContainers": "View Docker Containers",
"containersIn": "Containers in {siteName}",
"selectContainerDescription": "Select any container to use as a hostname for this target. Click a port to use a port.",
@@ -1646,6 +1720,7 @@
"certificateStatus": "Certificate",
"certificateStatusAutoRefreshHint": "Status refreshes automatically.",
"loading": "Loading",
"loadingEllipsis": "Loading...",
"loadingAnalytics": "Loading Analytics",
"restart": "Restart",
"domains": "Domains",
@@ -1872,6 +1947,7 @@
"billingManageLicenseSubscription": "Manage your subscription for paid self-hosted license keys",
"billingCurrentKeys": "Current Keys",
"billingModifyCurrentPlan": "Modify Current Plan",
"billingManageLicenseSubscriptionDescription": "Manage your subscription for paid self-hosted license keys and download invoices.",
"billingConfirmUpgrade": "Confirm Upgrade",
"billingConfirmDowngrade": "Confirm Downgrade",
"billingConfirmUpgradeDescription": "You are about to upgrade your plan. Review the new limits and pricing below.",
@@ -1969,7 +2045,36 @@
"timeIsInSeconds": "Time is in seconds",
"requireDeviceApproval": "Require Device Approvals",
"requireDeviceApprovalDescription": "Users with this role need new devices approved by an admin before they can connect and access resources.",
"sshAccess": "SSH Access",
"sshSettings": "SSH Settings",
"rdpSettings": "RDP Settings",
"vncSettings": "VNC Settings",
"sshServer": "SSH Server",
"rdpServer": "RDP Server",
"vncServer": "VNC Server",
"sshServerDescription": "Set up the authentication method, daemon location, and server destination",
"rdpServerDescription": "Configure the destination and port of the RDP server",
"vncServerDescription": "Configure the destination and port of the VNC server",
"sshServerMode": "Mode",
"sshServerModeStandard": "Standard SSH Server",
"sshServerModePangolin": "Pangolin SSH",
"sshServerModeStandardDescription": "Routes commands over network to an SSH server such as OpenSSH.",
"sshServerModeNative": "Native SSH Server",
"sshServerModeNativeDescription": "Executes commands directly on the host via the Site Connector. No network config required.",
"sshAuthenticationMethod": "Authentication Method",
"sshAuthMethodManual": "Manual Authentication",
"sshAuthMethodManualDescription": "Requires existing host credentials. Bypasses automatic provisioning.",
"sshAuthMethodAutomated": "Automated Provisioning",
"sshAuthMethodAutomatedDescription": "Automatically creates users, groups, and sudo permissions on host.",
"sshAuthDaemonLocation": "Auth Daemon Location",
"sshDaemonLocationSiteDescription": "Executes locally on the machine hosting the site connector.",
"sshDaemonLocationRemote": "On Remote Host",
"sshDaemonLocationRemoteDescription": "Executes on a separate target machine on the same network.",
"sshDaemonDisclaimer": "Ensure your target host is properly configured to run the auth daemon before completing this setup, or provisioning will fail.",
"sshDaemonPort": "Daemon Port",
"sshServerDestination": "Server Destination",
"sshServerDestinationDescription": "Configure the destination and port of the SSH server",
"destination": "Destination",
"bgTargetMultiSiteDisclaimer": "Selecting multiple sites enables resilient routing and failover for high availability.",
"roleAllowSsh": "Allow SSH",
"roleAllowSshAllow": "Allow",
"roleAllowSshDisallow": "Disallow",
@@ -1983,7 +2088,7 @@
"sshSudoModeCommandsDescription": "User can run only the specified commands with sudo.",
"sshSudo": "Allow sudo",
"sshSudoCommands": "Sudo Commands",
"sshSudoCommandsDescription": "Comma separated list of commands the user is allowed to run with sudo.",
"sshSudoCommandsDescription": "Comma separated list of commands the user is allowed to run with sudo. Absolute paths must be used.",
"sshCreateHomeDir": "Create Home Directory",
"sshUnixGroups": "Unix Groups",
"sshUnixGroupsDescription": "Comma separated Unix groups to add the user to on the target host.",
@@ -2075,6 +2180,7 @@
"editInternalResourceDialogModeCidr": "CIDR",
"editInternalResourceDialogModeHttp": "HTTP",
"editInternalResourceDialogModeHttps": "HTTPS",
"editInternalResourceDialogModeSsh": "SSH",
"editInternalResourceDialogScheme": "Scheme",
"editInternalResourceDialogEnableSsl": "Enable TLS",
"editInternalResourceDialogEnableSslDescription": "Enable SSL/TLS encryption for secure HTTPS connections to the destination.",
@@ -2124,6 +2230,7 @@
"createInternalResourceDialogModeCidr": "CIDR",
"createInternalResourceDialogModeHttp": "HTTP",
"createInternalResourceDialogModeHttps": "HTTPS",
"createInternalResourceDialogModeSsh": "SSH",
"scheme": "Scheme",
"createInternalResourceDialogScheme": "Scheme",
"createInternalResourceDialogEnableSsl": "Enable TLS",
@@ -2963,7 +3070,7 @@
"learnMore": "Learn more",
"backToHome": "Go back to home",
"needToSignInToOrg": "Need to use your organization's identity provider?",
"maintenanceMode": "Maintenance Mode",
"maintenanceMode": "Maintenance Page",
"maintenanceModeDescription": "Display a maintenance page to visitors",
"maintenanceModeType": "Maintenance Mode Type",
"showMaintenancePage": "Show a maintenance page to visitors",
@@ -2993,6 +3100,7 @@
"maintenanceScreenEstimatedCompletion": "Estimated Completion:",
"createInternalResourceDialogDestinationRequired": "Destination is required",
"available": "Available",
"disabledResourceDescription": "When disabled, the resource will be inaccessible by everyone.",
"archived": "Archived",
"noArchivedDevices": "No archived devices found",
"deviceArchived": "Device archived",
@@ -3322,5 +3430,27 @@
"memberPortalResourceDisabled": "Resource Disabled",
"memberPortalShowingResources": "Showing {start}-{end} of {total} resources",
"memberPortalPrevious": "Previous",
"memberPortalNext": "Next"
"memberPortalNext": "Next",
"httpSettings": "HTTP Settings",
"tcpSettings": "TCP Settings",
"udpSettings": "UDP Settings",
"sshTitle": "SSH",
"sshConnectingDescription": "Establishing a secure connection…",
"sshConnecting": "Connecting…",
"sshInitializing": "Initializing…",
"sshSignInTitle": "Sign in to SSH",
"sshSignInDescription": "Enter your SSH credentials",
"sshPasswordTab": "Password",
"sshPrivateKeyTab": "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.",
"sshLearnMore": "Learn more",
"sshPrivateKeyFile": "Private Key File",
"sshAuthenticate": "Authenticate",
"sshTerminate": "Terminate",
"sshPoweredBy": "Powered by",
"sshErrorNoTarget": "No target specified",
"sshErrorWebSocket": "WebSocket connection failed",
"sshErrorAuthFailed": "Authentication failed",
"sshErrorConnectionClosed": "Connection closed before authentication completed"
}

View File

@@ -2049,6 +2049,7 @@
"editInternalResourceDialogModeCidr": "CIDR",
"editInternalResourceDialogModeHttp": "HTTP",
"editInternalResourceDialogModeHttps": "HTTPS",
"editInternalResourceDialogModeSsh": "SSH",
"editInternalResourceDialogScheme": "Esquema",
"editInternalResourceDialogEnableSsl": "Activar TLS",
"editInternalResourceDialogEnableSslDescription": "Habilitar cifrado SSL/TLS para conexiones HTTPS seguras al destino.",
@@ -2098,6 +2099,7 @@
"createInternalResourceDialogModeCidr": "CIDR",
"createInternalResourceDialogModeHttp": "HTTP",
"createInternalResourceDialogModeHttps": "HTTPS",
"createInternalResourceDialogModeSsh": "SSH",
"scheme": "Esquema",
"createInternalResourceDialogScheme": "Esquema",
"createInternalResourceDialogEnableSsl": "Activar TLS",

View File

@@ -2049,6 +2049,7 @@
"editInternalResourceDialogModeCidr": "CIDR",
"editInternalResourceDialogModeHttp": "HTTP",
"editInternalResourceDialogModeHttps": "HTTPS",
"editInternalResourceDialogModeSsh": "SSH",
"editInternalResourceDialogScheme": "Méthode HTTP",
"editInternalResourceDialogEnableSsl": "Activer TLS",
"editInternalResourceDialogEnableSslDescription": "Activer le cryptage SSL/TLS pour des connexions HTTPS sécurisées vers la destination.",
@@ -2098,6 +2099,7 @@
"createInternalResourceDialogModeCidr": "CIDR",
"createInternalResourceDialogModeHttp": "HTTP",
"createInternalResourceDialogModeHttps": "HTTPS",
"createInternalResourceDialogModeSsh": "SSH",
"scheme": "Méthode HTTP",
"createInternalResourceDialogScheme": "Méthode HTTP",
"createInternalResourceDialogEnableSsl": "Activer TLS",

View File

@@ -2049,6 +2049,7 @@
"editInternalResourceDialogModeCidr": "CIDR",
"editInternalResourceDialogModeHttp": "HTTP",
"editInternalResourceDialogModeHttps": "HTTPS",
"editInternalResourceDialogModeSsh": "SSH",
"editInternalResourceDialogScheme": "Metodo HTTP",
"editInternalResourceDialogEnableSsl": "Abilitare TLS",
"editInternalResourceDialogEnableSslDescription": "Abilita la crittografia SSL/TLS per connessioni HTTPS sicure alla destinazione.",
@@ -2098,6 +2099,7 @@
"createInternalResourceDialogModeCidr": "CIDR",
"createInternalResourceDialogModeHttp": "HTTP",
"createInternalResourceDialogModeHttps": "HTTPS",
"createInternalResourceDialogModeSsh": "SSH",
"scheme": "Metodo HTTP",
"createInternalResourceDialogScheme": "Metodo HTTP",
"createInternalResourceDialogEnableSsl": "Abilitare TLS",

View File

@@ -2049,6 +2049,7 @@
"editInternalResourceDialogModeCidr": "CIDR",
"editInternalResourceDialogModeHttp": "HTTP",
"editInternalResourceDialogModeHttps": "HTTPS",
"editInternalResourceDialogModeSsh": "SSH",
"editInternalResourceDialogScheme": "스킴",
"editInternalResourceDialogEnableSsl": "TLS 활성화",
"editInternalResourceDialogEnableSslDescription": "목적지로의 안전한 HTTPS 연결을 위한 SSL/TLS 암호화 활성화.",
@@ -2098,6 +2099,7 @@
"createInternalResourceDialogModeCidr": "CIDR",
"createInternalResourceDialogModeHttp": "HTTP",
"createInternalResourceDialogModeHttps": "HTTPS",
"createInternalResourceDialogModeSsh": "SSH",
"scheme": "스킴",
"createInternalResourceDialogScheme": "스킴",
"createInternalResourceDialogEnableSsl": "TLS 활성화",

View File

@@ -2049,6 +2049,7 @@
"editInternalResourceDialogModeCidr": "CIDR",
"editInternalResourceDialogModeHttp": "HTTP",
"editInternalResourceDialogModeHttps": "HTTPS",
"editInternalResourceDialogModeSsh": "SSH",
"editInternalResourceDialogScheme": "Skjema",
"editInternalResourceDialogEnableSsl": "Aktiver TLS",
"editInternalResourceDialogEnableSslDescription": "Aktiver SSL/TLS-kryptering for sikre HTTPS-tilkoblinger til destinasjonen.",
@@ -2098,6 +2099,7 @@
"createInternalResourceDialogModeCidr": "CIDR",
"createInternalResourceDialogModeHttp": "HTTP",
"createInternalResourceDialogModeHttps": "HTTPS",
"createInternalResourceDialogModeSsh": "SSH",
"scheme": "Skjema",
"createInternalResourceDialogScheme": "Skjema",
"createInternalResourceDialogEnableSsl": "Aktiver TLS",

View File

@@ -2049,6 +2049,7 @@
"editInternalResourceDialogModeCidr": "CIDR",
"editInternalResourceDialogModeHttp": "HTTP",
"editInternalResourceDialogModeHttps": "HTTPS",
"editInternalResourceDialogModeSsh": "SSH",
"editInternalResourceDialogScheme": "Schema",
"editInternalResourceDialogEnableSsl": "TLS inschakelen",
"editInternalResourceDialogEnableSslDescription": "Schakel SSL/TLS-encryptie in voor beveiligde HTTPS-verbindingen met de bestemming.",
@@ -2098,6 +2099,7 @@
"createInternalResourceDialogModeCidr": "CIDR",
"createInternalResourceDialogModeHttp": "HTTP",
"createInternalResourceDialogModeHttps": "HTTPS",
"createInternalResourceDialogModeSsh": "SSH",
"scheme": "Schema",
"createInternalResourceDialogScheme": "Schema",
"createInternalResourceDialogEnableSsl": "TLS inschakelen",

View File

@@ -2049,6 +2049,7 @@
"editInternalResourceDialogModeCidr": "CIDR",
"editInternalResourceDialogModeHttp": "HTTP",
"editInternalResourceDialogModeHttps": "HTTPS",
"editInternalResourceDialogModeSsh": "SSH",
"editInternalResourceDialogScheme": "Schemat",
"editInternalResourceDialogEnableSsl": "Włącz TLS",
"editInternalResourceDialogEnableSslDescription": "Włącz szyfrowanie SSL/TLS dla bezpiecznych połączeń HTTPS z miejscem docelowym.",
@@ -2098,6 +2099,7 @@
"createInternalResourceDialogModeCidr": "CIDR",
"createInternalResourceDialogModeHttp": "HTTP",
"createInternalResourceDialogModeHttps": "HTTPS",
"createInternalResourceDialogModeSsh": "SSH",
"scheme": "Schemat",
"createInternalResourceDialogScheme": "Schemat",
"createInternalResourceDialogEnableSsl": "Włącz TLS",

View File

@@ -2049,6 +2049,7 @@
"editInternalResourceDialogModeCidr": "CIDR",
"editInternalResourceDialogModeHttp": "HTTP",
"editInternalResourceDialogModeHttps": "HTTPS",
"editInternalResourceDialogModeSsh": "SSH",
"editInternalResourceDialogScheme": "Esquema",
"editInternalResourceDialogEnableSsl": "Ativar TLS",
"editInternalResourceDialogEnableSslDescription": "Ativar criptografia SSL/TLS para conexões HTTPS seguras com o destino.",
@@ -2098,6 +2099,7 @@
"createInternalResourceDialogModeCidr": "CIDR",
"createInternalResourceDialogModeHttp": "HTTP",
"createInternalResourceDialogModeHttps": "HTTPS",
"createInternalResourceDialogModeSsh": "SSH",
"scheme": "Esquema",
"createInternalResourceDialogScheme": "Esquema",
"createInternalResourceDialogEnableSsl": "Ativar TLS",

View File

@@ -2049,6 +2049,7 @@
"editInternalResourceDialogModeCidr": "СИДР",
"editInternalResourceDialogModeHttp": "HTTP",
"editInternalResourceDialogModeHttps": "HTTPS",
"editInternalResourceDialogModeSsh": "SSH",
"editInternalResourceDialogScheme": "Схема",
"editInternalResourceDialogEnableSsl": "Включить TLS",
"editInternalResourceDialogEnableSslDescription": "Включите шифрование SSL/TLS для защищенных HTTPS соединений с конечной точкой.",
@@ -2098,6 +2099,7 @@
"createInternalResourceDialogModeCidr": "СИДР",
"createInternalResourceDialogModeHttp": "HTTP",
"createInternalResourceDialogModeHttps": "HTTPS",
"createInternalResourceDialogModeSsh": "SSH",
"scheme": "Схема",
"createInternalResourceDialogScheme": "Схема",
"createInternalResourceDialogEnableSsl": "Включить TLS",

View File

@@ -2049,6 +2049,7 @@
"editInternalResourceDialogModeCidr": "CIDR",
"editInternalResourceDialogModeHttp": "HTTP",
"editInternalResourceDialogModeHttps": "HTTPS",
"editInternalResourceDialogModeSsh": "SSH",
"editInternalResourceDialogScheme": "Şema",
"editInternalResourceDialogEnableSsl": "TLS Etkinleştir",
"editInternalResourceDialogEnableSslDescription": "Hedefe güvenli HTTPS bağlantıları için SSL/TLS şifrelemeyi etkinleştirin.",
@@ -2098,6 +2099,7 @@
"createInternalResourceDialogModeCidr": "CIDR",
"createInternalResourceDialogModeHttp": "HTTP",
"createInternalResourceDialogModeHttps": "HTTPS",
"createInternalResourceDialogModeSsh": "SSH",
"scheme": "Şema",
"createInternalResourceDialogScheme": "Şema",
"createInternalResourceDialogEnableSsl": "TLS'yi Etkinleştir",

View File

@@ -2049,6 +2049,7 @@
"editInternalResourceDialogModeCidr": "CIDR",
"editInternalResourceDialogModeHttp": "HTTP",
"editInternalResourceDialogModeHttps": "HTTPS",
"editInternalResourceDialogModeSsh": "SSH",
"editInternalResourceDialogScheme": "方案",
"editInternalResourceDialogEnableSsl": "启用 TLS",
"editInternalResourceDialogEnableSslDescription": "为目标的安全 HTTPS 连接启用 SSL/TLS 加密。",
@@ -2098,6 +2099,7 @@
"createInternalResourceDialogModeCidr": "CIDR",
"createInternalResourceDialogModeHttp": "HTTP",
"createInternalResourceDialogModeHttps": "HTTPS",
"createInternalResourceDialogModeSsh": "SSH",
"scheme": "方案",
"createInternalResourceDialogScheme": "方案",
"createInternalResourceDialogEnableSsl": "启用 TLS",

View File

@@ -1,17 +1,42 @@
import type { NextConfig } from "next";
import createNextIntlPlugin from "next-intl/plugin";
import fs from "fs";
import path from "path";
const withNextIntl = createNextIntlPlugin();
// read allowedDevOrigins.json if it exists
let allowedDevOrigins: string[] = [];
const allowedDevOriginsPath = path.join(
process.cwd(),
"allowedDevOrigins.json"
);
if (fs.existsSync(allowedDevOriginsPath)) {
try {
const data = fs.readFileSync(allowedDevOriginsPath, "utf-8");
allowedDevOrigins = JSON.parse(data);
} catch {}
}
const nextConfig: NextConfig = {
reactStrictMode: false,
eslint: {
ignoreDuringBuilds: true
},
experimental: {
reactCompiler: true
},
output: "standalone"
reactCompiler: true,
transpilePackages: ["@novnc/novnc"],
output: "standalone",
allowedDevOrigins,
async redirects() {
return [
{
source: "/:orgId/settings/resources/proxy/:path*",
destination: "/:orgId/settings/resources/public/:path*",
permanent: true
},
{
source: "/:orgId/settings/resources/client/:path*",
destination: "/:orgId/settings/resources/private/:path*",
permanent: true
}
];
}
};
export default withNextIntl(nextConfig);

6030
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -32,13 +32,15 @@
"format": "prettier --write ."
},
"dependencies": {
"@asteasolutions/zod-to-openapi": "8.4.1",
"@aws-sdk/client-s3": "3.1011.0",
"@faker-js/faker": "10.3.0",
"@headlessui/react": "2.2.9",
"@hookform/resolvers": "5.2.2",
"@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-rdp": "https://static.pangolin.net/packages/devolutions-iron-remote-desktop-rdp-0.0.0.tgz",
"@aws-sdk/client-s3": "3.1056.0",
"@headlessui/react": "2.2.10",
"@hookform/resolvers": "5.4.0",
"@monaco-editor/react": "4.7.0",
"@node-rs/argon2": "2.0.2",
"@novnc/novnc": "^1.7.0",
"@oslojs/crypto": "1.0.1",
"@oslojs/encoding": "1.1.0",
"@radix-ui/react-avatar": "1.1.11",
@@ -59,16 +61,20 @@
"@radix-ui/react-tabs": "1.1.13",
"@radix-ui/react-toast": "1.2.15",
"@radix-ui/react-tooltip": "1.2.8",
"@react-email/components": "1.0.8",
"@react-email/render": "2.0.4",
"@react-email/tailwind": "2.0.5",
"@react-email/body": "0.3.0",
"@react-email/components": "1.0.12",
"@react-email/render": "2.0.8",
"@react-email/tailwind": "2.0.7",
"@simplewebauthn/browser": "13.3.0",
"@simplewebauthn/server": "13.3.0",
"@simplewebauthn/server": "13.3.1",
"@tailwindcss/forms": "0.5.11",
"@tanstack/react-query": "5.90.21",
"@tanstack/react-query": "5.100.14",
"@tanstack/react-table": "8.21.3",
"@xterm/addon-fit": "^0.11.0",
"@xterm/addon-web-links": "^0.12.0",
"@xterm/xterm": "^6.0.0",
"arctic": "3.7.0",
"axios": "1.15.0",
"axios": "1.16.1",
"better-sqlite3": "11.9.1",
"canvas-confetti": "1.9.4",
"class-variance-authority": "0.7.1",
@@ -80,77 +86,76 @@
"d3": "7.9.0",
"drizzle-orm": "0.45.2",
"express": "5.2.1",
"express-rate-limit": "8.3.0",
"express-rate-limit": "8.5.2",
"glob": "13.0.6",
"helmet": "8.1.0",
"helmet": "8.2.0",
"http-errors": "2.0.1",
"input-otp": "1.4.2",
"ioredis": "5.10.0",
"ioredis": "5.11.0",
"jmespath": "0.16.0",
"js-yaml": "4.1.1",
"jsonwebtoken": "9.0.3",
"lucide-react": "0.577.0",
"maxmind": "5.0.5",
"lucide-react": "1.17.0",
"maxmind": "5.0.6",
"moment": "2.30.1",
"next": "15.5.15",
"next-intl": "4.8.3",
"next": "16.2.6",
"next-intl": "4.13.0",
"next-themes": "0.4.6",
"nextjs-toploader": "3.9.17",
"node-cache": "5.1.2",
"nodemailer": "8.0.5",
"nodemailer": "8.0.9",
"oslo": "1.2.1",
"pg": "8.20.0",
"posthog-node": "5.28.0",
"pg": "8.21.0",
"posthog-node": "5.35.6",
"qrcode.react": "4.2.0",
"react": "19.2.4",
"react": "19.2.6",
"react-day-picker": "9.14.0",
"react-dom": "19.2.4",
"react-dom": "19.2.6",
"react-easy-sort": "1.8.0",
"react-hook-form": "7.71.2",
"react-hook-form": "7.76.1",
"react-icons": "5.6.0",
"recharts": "2.15.4",
"recharts": "3.8.1",
"reodotdev": "1.1.0",
"resend": "6.9.2",
"semver": "7.7.4",
"semver": "7.8.1",
"sshpk": "1.18.0",
"stripe": "20.4.1",
"stripe": "22.2.0",
"swagger-ui-express": "5.0.1",
"tailwind-merge": "3.5.0",
"tailwind-merge": "3.6.0",
"topojson-client": "3.1.0",
"tw-animate-css": "1.4.0",
"use-debounce": "10.1.0",
"uuid": "13.0.0",
"use-debounce": "10.1.1",
"uuid": "14.0.0",
"vaul": "1.1.2",
"visionscarto-world-atlas": "1.0.0",
"winston": "3.19.0",
"winston-daily-rotate-file": "5.0.0",
"ws": "8.19.0",
"yaml": "2.8.3",
"ws": "8.21.0",
"yaml": "2.9.0",
"yargs": "18.0.0",
"zod": "4.3.6",
"zod": "4.4.3",
"zod-validation-error": "5.0.0"
},
"devDependencies": {
"@dotenvx/dotenvx": "1.54.1",
"@dotenvx/dotenvx": "1.69.1",
"@esbuild-plugins/tsconfig-paths": "0.1.2",
"@react-email/preview-server": "5.2.10",
"@tailwindcss/postcss": "4.2.2",
"@tanstack/react-query-devtools": "5.91.3",
"@react-email/ui": "^6.5.0",
"@tailwindcss/postcss": "4.3.0",
"@tanstack/react-query-devtools": "5.100.14",
"@types/better-sqlite3": "7.6.13",
"@types/cookie-parser": "1.4.10",
"@types/cors": "2.8.19",
"@types/crypto-js": "4.2.2",
"@types/d3": "7.4.3",
"@types/express": "5.0.6",
"@types/express-session": "1.18.2",
"@types/express-session": "1.19.0",
"@types/jmespath": "0.15.2",
"@types/js-yaml": "4.0.9",
"@types/jsonwebtoken": "9.0.10",
"@types/node": "25.3.5",
"@types/nodemailer": "7.0.11",
"@types/node": "25.9.1",
"@types/nodemailer": "8.0.0",
"@types/nprogress": "0.2.3",
"@types/pg": "8.18.0",
"@types/react": "19.2.14",
"@types/pg": "8.20.0",
"@types/react": "19.2.15",
"@types/react-dom": "19.2.3",
"@types/semver": "7.7.1",
"@types/sshpk": "1.17.4",
@@ -160,21 +165,22 @@
"@types/yargs": "17.0.35",
"babel-plugin-react-compiler": "1.0.0",
"drizzle-kit": "0.31.10",
"esbuild": "0.27.4",
"esbuild-node-externals": "1.20.1",
"eslint": "10.0.3",
"eslint-config-next": "16.1.7",
"postcss": "8.5.8",
"prettier": "3.8.1",
"react-email": "5.2.10",
"tailwindcss": "4.2.2",
"tsc-alias": "1.8.16",
"tsx": "4.21.0",
"typescript": "5.9.3",
"typescript-eslint": "8.56.1"
"esbuild": "0.28.0",
"esbuild-node-externals": "1.22.0",
"eslint": "10.4.0",
"eslint-config-next": "16.2.6",
"postcss": "8.5.15",
"prettier": "3.8.3",
"react-email": "6.5.0",
"tailwindcss": "4.3.0",
"tsc-alias": "1.8.17",
"tsx": "4.22.3",
"typescript": "6.0.3",
"typescript-eslint": "8.60.0"
},
"overrides": {
"esbuild": "0.27.4",
"dompurify": "3.3.2"
"esbuild": "0.28.0",
"dompurify": "3.4.0",
"postcss": "8.5.15"
}
}

View File

@@ -5,6 +5,7 @@ import { and, eq, inArray } from "drizzle-orm";
import createHttpError from "http-errors";
import HttpCode from "@server/types/HttpCode";
import { getUserOrgRoleIds } from "@server/lib/userOrgRoles";
import logger from "@server/logger";
export enum ActionsEnum {
createOrgUser = "createOrgUser",
@@ -158,7 +159,26 @@ export enum ActionsEnum {
createHealthCheck = "createHealthCheck",
updateHealthCheck = "updateHealthCheck",
deleteHealthCheck = "deleteHealthCheck",
listHealthChecks = "listHealthChecks"
listHealthChecks = "listHealthChecks",
createBrowserGatewayTarget = "createBrowserGatewayTarget",
updateBrowserGatewayTarget = "updateBrowserGatewayTarget",
deleteBrowserGatewayTarget = "deleteBrowserGatewayTarget",
getBrowserGatewayTarget = "getBrowserGatewayTarget",
listBrowserGatewayTargets = "listBrowserGatewayTargets",
listResourcePolicies = "listResourcePolicies",
getResourcePolicy = "getResourcePolicy",
createResourcePolicy = "createResourcePolicy",
updateResourcePolicy = "updateResourcePolicy",
deleteResourcePolicy = "deleteResourcePolicy",
listResourcePolicyRoles = "listResourcePolicyRoles",
setResourcePolicyRoles = "setResourcePolicyRoles",
listResourcePolicyUsers = "listResourcePolicyUsers",
setResourcePolicyUsers = "setResourcePolicyUsers",
setResourcePolicyPassword = "setResourcePolicyPassword",
setResourcePolicyPincode = "setResourcePolicyPincode",
setResourcePolicyHeaderAuth = "setResourcePolicyHeaderAuth",
setResourcePolicyWhitelist = "setResourcePolicyWhitelist",
setResourcePolicyRules = "setResourcePolicyRules"
}
export async function checkUserActionPermission(
@@ -191,6 +211,23 @@ export async function checkUserActionPermission(
}
}
// If no direct permission, check role-based permission (any of user's roles)
const roleActionPermission = await db
.select()
.from(roleActions)
.where(
and(
eq(roleActions.actionId, actionId),
inArray(roleActions.roleId, userOrgRoleIds),
eq(roleActions.orgId, req.userOrgId!)
)
)
.limit(1);
if (roleActionPermission.length > 0) {
return true;
}
// Check if the user has direct permission for the action in the current org
const userActionPermission = await db
.select()
@@ -208,20 +245,7 @@ export async function checkUserActionPermission(
return true;
}
// If no direct permission, check role-based permission (any of user's roles)
const roleActionPermission = await db
.select()
.from(roleActions)
.where(
and(
eq(roleActions.actionId, actionId),
inArray(roleActions.roleId, userOrgRoleIds),
eq(roleActions.orgId, req.userOrgId!)
)
)
.limit(1);
return roleActionPermission.length > 0;
return false;
} catch (error) {
console.error("Error checking user action permission:", error);
throw createHttpError(

View File

@@ -19,6 +19,9 @@ export async function createResourceSession(opts: {
userSessionId?: string | null;
whitelistId?: number | null;
accessTokenId?: string | null;
policyPasswordId?: number | null;
policyPincodeId?: number | null;
policyWhitelistId?: number | null;
doNotExtend?: boolean;
expiresAt?: number | null;
sessionLength?: number | null;
@@ -28,7 +31,10 @@ export async function createResourceSession(opts: {
!opts.pincodeId &&
!opts.whitelistId &&
!opts.accessTokenId &&
!opts.userSessionId
!opts.userSessionId &&
!opts.policyPasswordId &&
!opts.policyPincodeId &&
!opts.policyWhitelistId
) {
throw new Error("Auth method must be provided");
}
@@ -49,6 +55,9 @@ export async function createResourceSession(opts: {
whitelistId: opts.whitelistId || null,
doNotExtend: opts.doNotExtend || false,
accessTokenId: opts.accessTokenId || null,
policyPasswordId: opts.policyPasswordId || null,
policyPincodeId: opts.policyPincodeId || null,
policyWhitelistId: opts.policyWhitelistId || null,
isRequestToken: opts.isRequestToken || false,
userSessionId: opts.userSessionId || null,
issuedAt: new Date().getTime()

View File

@@ -1,6 +1,12 @@
import { join } from "path";
import { readFileSync } from "fs";
import { clients, db, resources, siteResources } from "@server/db";
import {
clients,
db,
resourcePolicies,
resources,
siteResources
} from "@server/db";
import { randomInt } from "crypto";
import { exitNodes, sites } from "@server/db";
import { eq, and } from "drizzle-orm";
@@ -107,6 +113,35 @@ export async function getUniqueResourceName(orgId: string): Promise<string> {
}
}
export async function getUniqueResourcePolicyName(
orgId: string
): Promise<string> {
let loops = 0;
while (true) {
if (loops > 100) {
throw new Error("Could not generate a unique name");
}
const name = generateName();
const policyCount = await db
.select({
niceId: resourcePolicies.niceId,
orgId: resourcePolicies.orgId
})
.from(resourcePolicies)
.where(
and(
eq(resourcePolicies.niceId, name),
eq(resourcePolicies.orgId, orgId)
)
);
if (policyCount.length === 0) {
return name;
}
loops++;
}
}
export async function getUniqueSiteResourceName(
orgId: string
): Promise<string> {

View File

@@ -4,3 +4,4 @@ export * from "./safeRead";
export * from "./schema/schema";
export * from "./schema/privateSchema";
export * from "./migrate";
export { alias } from "drizzle-orm/pg-core";

View File

@@ -2,6 +2,7 @@ import {
pgTable,
serial,
varchar,
unique,
boolean,
integer,
bigint,
@@ -19,12 +20,13 @@ import {
roles,
users,
exitNodes,
sessions,
clients,
resources,
siteResources,
targetHealthCheck,
sites
sites,
clients,
sessions,
labels
} from "./schema";
export const certificates = pgTable("certificates", {
@@ -197,6 +199,42 @@ 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(),
remoteExitNode: integer("remoteExitNode")
.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.remoteExitNode,
t.labelId
)
]
);
export const remoteExitNodeSessions = pgTable("remoteExitNodeSession", {
sessionId: varchar("id").primaryKey(),
remoteExitNodeId: varchar("remoteExitNodeId")
@@ -580,6 +618,24 @@ export const trialNotifications = pgTable("trialNotifications", {
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 Limit = InferSelectModel<typeof limits>;
export type Account = InferSelectModel<typeof account>;
@@ -627,3 +683,6 @@ export type AlertEmailRecipients = InferSelectModel<
>;
export type AlertWebhookActions = InferSelectModel<typeof alertWebhookActions>;
export type TrialNotification = InferSelectModel<typeof trialNotifications>;
export type BrowserGatewayTarget = InferSelectModel<
typeof browserGatewayTarget
>;

View File

@@ -65,7 +65,12 @@ export const orgs = pgTable("orgs", {
sshCaPrivateKey: text("sshCaPrivateKey"), // Encrypted SSH CA private key (PEM format)
sshCaPublicKey: text("sshCaPublicKey"), // SSH CA public key (OpenSSH format)
isBillingOrg: boolean("isBillingOrg"),
billingOrgId: varchar("billingOrgId")
billingOrgId: varchar("billingOrgId"),
settingsEnableGlobalNewtAutoUpdate: boolean(
"settingsEnableGlobalNewtAutoUpdate"
)
.notNull()
.default(false)
});
export const orgDomains = pgTable("orgDomains", {
@@ -103,6 +108,10 @@ export const sites = pgTable("sites", {
lastHolePunch: bigint("lastHolePunch", { mode: "number" }),
listenPort: integer("listenPort"),
dockerSocketEnabled: boolean("dockerSocketEnabled").notNull().default(true),
autoUpdateEnabled: boolean("autoUpdateEnabled").notNull().default(false),
autoUpdateOverrideOrg: boolean("autoUpdateOverrideOrg")
.notNull()
.default(false),
status: varchar("status")
.$type<"pending" | "approved">()
.default("approved")
@@ -110,6 +119,16 @@ export const sites = pgTable("sites", {
export const resources = pgTable("resources", {
resourceId: serial("resourceId").primaryKey(),
resourcePolicyId: integer("resourcePolicyId").references(
() => resourcePolicies.resourcePolicyId,
{ onDelete: "set null" }
),
defaultResourcePolicyId: integer("defaultResourcePolicyId").references(
() => resourcePolicies.resourcePolicyId,
{
onDelete: "restrict"
}
),
resourceGuid: varchar("resourceGuid", { length: 36 })
.unique()
.notNull()
@@ -129,8 +148,6 @@ export const resources = pgTable("resources", {
ssl: boolean("ssl").notNull().default(false),
blockAccess: boolean("blockAccess").notNull().default(false),
sso: boolean("sso").notNull().default(true),
http: boolean("http").notNull().default(true),
protocol: varchar("protocol").notNull(),
proxyPort: integer("proxyPort"),
emailWhitelistEnabled: boolean("emailWhitelistEnabled")
.notNull()
@@ -147,7 +164,6 @@ export const resources = pgTable("resources", {
headers: text("headers"), // comma-separated list of headers to add to the request
proxyProtocol: boolean("proxyProtocol").notNull().default(false),
proxyProtocolVersion: integer("proxyProtocolVersion").default(1),
maintenanceModeEnabled: boolean("maintenanceModeEnabled")
.notNull()
.default(false),
@@ -159,7 +175,15 @@ export const resources = pgTable("resources", {
maintenanceEstimatedTime: text("maintenanceEstimatedTime"),
postAuthPath: text("postAuthPath"),
health: varchar("health").default("unknown"), // "healthy", "unhealthy", "unknown"
wildcard: boolean("wildcard").notNull().default(false)
wildcard: boolean("wildcard").notNull().default(false),
mode: text("mode").default("http").notNull(), // rdp, ssh, http, vnc
pamMode: varchar("pamMode", { length: 32 })
.$type<"passthrough" | "push">()
.default("passthrough"),
authDaemonMode: varchar("authDaemonMode", { length: 32 })
.$type<"site" | "remote" | "native">()
.default("site"),
authDaemonPort: integer("authDaemonPort").default(22123)
});
export const labels = pgTable("labels", {
@@ -339,11 +363,11 @@ export const siteResources = pgTable("siteResources", {
niceId: varchar("niceId").notNull(),
name: varchar("name").notNull(),
ssl: boolean("ssl").notNull().default(false),
mode: varchar("mode").$type<"host" | "cidr" | "http">().notNull(), // "host" | "cidr" | "http"
mode: varchar("mode").$type<"host" | "cidr" | "http" | "ssh">().notNull(), // "host" | "cidr" | "http"
scheme: varchar("scheme").$type<"http" | "https">(), // only for when we are doing https or http mode
proxyPort: integer("proxyPort"), // only for port mode
destinationPort: integer("destinationPort"), // only for port mode
destination: varchar("destination").notNull(), // ip, cidr, hostname; validate against the mode
destination: varchar("destination"), // ip, cidr, hostname; validate against the mode
enabled: boolean("enabled").notNull().default(true),
alias: varchar("alias"),
aliasAddress: varchar("aliasAddress"),
@@ -351,8 +375,11 @@ export const siteResources = pgTable("siteResources", {
udpPortRangeString: varchar("udpPortRangeString").notNull().default("*"),
disableIcmp: boolean("disableIcmp").notNull().default(false),
authDaemonPort: integer("authDaemonPort").default(22123),
pamMode: varchar("pamMode", { length: 32 })
.$type<"passthrough" | "push">()
.default("passthrough"),
authDaemonMode: varchar("authDaemonMode", { length: 32 })
.$type<"site" | "remote">()
.$type<"site" | "remote" | "native">()
.default("site"),
domainId: varchar("domainId").references(() => domains.domainId, {
onDelete: "set null"
@@ -606,6 +633,38 @@ export const userResources = pgTable("userResources", {
.references(() => resources.resourceId, { onDelete: "cascade" })
});
export const rolePolicies = pgTable("rolePolicies", {
roleId: integer("roleId")
.notNull()
.references(() => roles.roleId, { onDelete: "cascade" }),
resourcePolicyId: integer("resourcePolicyId")
.notNull()
.references(() => resourcePolicies.resourcePolicyId, {
onDelete: "cascade"
})
});
export const userPolicies = pgTable("userPolicies", {
userId: varchar("userId")
.notNull()
.references(() => users.userId, { onDelete: "cascade" }),
resourcePolicyId: integer("resourcePolicyId")
.notNull()
.references(() => resourcePolicies.resourcePolicyId, {
onDelete: "cascade"
})
});
export const resourcePolicyWhiteList = pgTable("resourcePolicyWhitelist", {
whitelistId: serial("id").primaryKey(),
email: varchar("email").notNull(),
resourcePolicyId: integer("resourcePolicyId")
.notNull()
.references(() => resourcePolicies.resourcePolicyId, {
onDelete: "cascade"
})
});
export const userInvites = pgTable("userInvites", {
inviteId: varchar("inviteId").primaryKey(),
orgId: varchar("orgId")
@@ -671,6 +730,40 @@ export const resourceHeaderAuthExtendedCompatibility = pgTable(
}
);
export const resourcePolicyPincode = pgTable("resourcePolicyPincode", {
pincodeId: serial("pincodeId").primaryKey(),
pincodeHash: varchar("pincodeHash").notNull(),
digitLength: integer("digitLength").notNull(),
resourcePolicyId: integer("resourcePolicyId")
.notNull()
.references(() => resourcePolicies.resourcePolicyId, {
onDelete: "cascade"
})
});
export const resourcePolicyPassword = pgTable("resourcePolicyPassword", {
passwordId: serial("passwordId").primaryKey(),
passwordHash: varchar("passwordHash").notNull(),
resourcePolicyId: integer("resourcePolicyId")
.notNull()
.references(() => resourcePolicies.resourcePolicyId, {
onDelete: "cascade"
})
});
export const resourcePolicyHeaderAuth = pgTable("resourcePolicyHeaderAuth", {
headerAuthId: serial("headerAuthId").primaryKey(),
headerAuthHash: varchar("headerAuthHash").notNull(),
extendedCompatibility: boolean("extendedCompatibility")
.notNull()
.default(true),
resourcePolicyId: integer("resourcePolicyId")
.notNull()
.references(() => resourcePolicies.resourcePolicyId, {
onDelete: "cascade"
})
});
export const resourceAccessToken = pgTable("resourceAccessToken", {
accessTokenId: varchar("accessTokenId").primaryKey(),
orgId: varchar("orgId")
@@ -679,6 +772,7 @@ export const resourceAccessToken = pgTable("resourceAccessToken", {
resourceId: integer("resourceId")
.notNull()
.references(() => resources.resourceId, { onDelete: "cascade" }),
path: varchar("path"),
tokenHash: varchar("tokenHash").notNull(),
sessionLength: bigint("sessionLength", { mode: "number" }).notNull(),
expiresAt: bigint("expiresAt", { mode: "number" }),
@@ -726,6 +820,24 @@ export const resourceSessions = pgTable("resourceSessions", {
onDelete: "cascade"
}
),
policyPasswordId: integer("policyPasswordId").references(
() => resourcePolicyPassword.passwordId,
{
onDelete: "cascade"
}
),
policyPincodeId: integer("policyPincodeId").references(
() => resourcePolicyPincode.pincodeId,
{
onDelete: "cascade"
}
),
policyWhitelistId: integer("policyWhitelistId").references(
() => resourcePolicyWhiteList.whitelistId,
{
onDelete: "cascade"
}
),
issuedAt: bigint("issuedAt", { mode: "number" })
});
@@ -764,6 +876,43 @@ export const resourceRules = pgTable("resourceRules", {
value: varchar("value").notNull()
});
export const resourcePolicyRules = pgTable("resourcePolicyRules", {
ruleId: serial("ruleId").primaryKey(),
resourcePolicyId: integer("resourcePolicyId")
.notNull()
.references(() => resourcePolicies.resourcePolicyId, {
onDelete: "cascade"
}),
enabled: boolean("enabled").notNull().default(true),
priority: integer("priority").notNull(),
action: varchar("action").$type<"ACCEPT" | "DROP" | "PASS">().notNull(),
match: varchar("match").$type<"CIDR" | "PATH" | "IP">().notNull(),
value: varchar("value").notNull()
});
export const resourcePolicies = pgTable("resourcePolicies", {
resourcePolicyId: serial("resourcePolicyId").primaryKey(),
sso: boolean("sso").notNull().default(true),
applyRules: boolean("applyRules").notNull().default(false),
scope: varchar("scope")
.$type<"global" | "resource">()
.notNull()
.default("global"),
emailWhitelistEnabled: boolean("emailWhitelistEnabled")
.notNull()
.default(false),
idpId: integer("idpId").references(() => idp.idpId, {
onDelete: "set null"
}),
niceId: text("niceId").notNull(),
name: varchar("name").notNull(),
orgId: varchar("orgId")
.references(() => orgs.orgId, {
onDelete: "cascade"
})
.notNull()
});
export const supporterKey = pgTable("supporterKey", {
keyId: serial("keyId").primaryKey(),
key: varchar("key").notNull(),
@@ -1243,6 +1392,16 @@ export type ResourceHeaderAuthExtendedCompatibility = InferSelectModel<
export type ResourceOtp = InferSelectModel<typeof resourceOtp>;
export type ResourceAccessToken = InferSelectModel<typeof resourceAccessToken>;
export type ResourceWhitelist = InferSelectModel<typeof resourceWhitelist>;
export type ResourcePolicyPincode = InferSelectModel<
typeof resourcePolicyPincode
>;
export type ResourcePolicyPassword = InferSelectModel<
typeof resourcePolicyPassword
>;
export type ResourcePolicyHeaderAuth = InferSelectModel<
typeof resourcePolicyHeaderAuth
>;
export type VersionMigration = InferSelectModel<typeof versionMigrations>;
export type ResourceRule = InferSelectModel<typeof resourceRules>;
export type Domain = InferSelectModel<typeof domains>;
@@ -1276,3 +1435,6 @@ export type RoundTripMessageTracker = InferSelectModel<
export type Network = InferSelectModel<typeof networks>;
export type StatusHistory = InferSelectModel<typeof statusHistory>;
export type Label = InferSelectModel<typeof labels>;
export type ResourcePolicy = InferSelectModel<typeof resourcePolicies>;
export type RolePolicy = InferSelectModel<typeof rolePolicies>;
export type UserPolicy = InferSelectModel<typeof userPolicies>;

View File

@@ -17,22 +17,37 @@ import {
resourceHeaderAuth,
ResourceHeaderAuth,
resourceRules,
resourcePolicyRules,
resources,
roleResources,
rolePolicies,
sessions,
userResources,
userPolicies,
users,
ResourceHeaderAuthExtendedCompatibility,
resourceHeaderAuthExtendedCompatibility
resourceHeaderAuthExtendedCompatibility,
resourcePolicies,
resourcePolicyPincode,
ResourcePolicyPincode,
resourcePolicyPassword,
ResourcePolicyPassword,
resourcePolicyHeaderAuth,
ResourcePolicyHeaderAuth
} from "@server/db";
import { and, eq, inArray, or, sql } from "drizzle-orm";
import { alias } from "@server/db";
import { and, eq, inArray, isNull, or, sql } from "drizzle-orm";
import logger from "@server/logger";
export type ResourceWithAuth = {
resource: Resource | null;
pincode: ResourcePincode | null;
password: ResourcePassword | null;
headerAuth: ResourceHeaderAuth | null;
pincode: ResourcePincode | ResourcePolicyPincode | null;
password: ResourcePassword | ResourcePolicyPassword | null;
headerAuth: ResourceHeaderAuth | ResourcePolicyHeaderAuth | null;
headerAuthExtendedCompatibility: ResourceHeaderAuthExtendedCompatibility | null;
applyRules: boolean;
sso: boolean;
emailWhitelistEnabled: boolean;
org: Org;
};
@@ -57,6 +72,33 @@ export async function getResourceByDomain(
wildcardCandidates.push(`*.${parts.slice(i).join(".")}`);
}
const sharedPolicy = alias(resourcePolicies, "sharedPolicy");
const defaultPolicy = alias(resourcePolicies, "defaultPolicy");
const sharedPolicyPincode = alias(
resourcePolicyPincode,
"sharedPolicyPincode"
);
const defaultPolicyPincode = alias(
resourcePolicyPincode,
"defaultPolicyPincode"
);
const sharedPolicyPassword = alias(
resourcePolicyPassword,
"sharedPolicyPassword"
);
const defaultPolicyPassword = alias(
resourcePolicyPassword,
"defaultPolicyPassword"
);
const sharedPolicyHeaderAuth = alias(
resourcePolicyHeaderAuth,
"sharedPolicyHeaderAuth"
);
const defaultPolicyHeaderAuth = alias(
resourcePolicyHeaderAuth,
"defaultPolicyHeaderAuth"
);
const potentialResults = await db
.select()
.from(resources)
@@ -79,6 +121,59 @@ export async function getResourceByDomain(
resources.resourceId
)
)
.leftJoin(
sharedPolicy,
eq(sharedPolicy.resourcePolicyId, resources.resourcePolicyId)
)
.leftJoin(
sharedPolicyPincode,
eq(
sharedPolicyPincode.resourcePolicyId,
sharedPolicy.resourcePolicyId
)
)
.leftJoin(
sharedPolicyPassword,
eq(
sharedPolicyPassword.resourcePolicyId,
sharedPolicy.resourcePolicyId
)
)
.leftJoin(
sharedPolicyHeaderAuth,
eq(
sharedPolicyHeaderAuth.resourcePolicyId,
sharedPolicy.resourcePolicyId
)
)
.leftJoin(
defaultPolicy,
eq(
defaultPolicy.resourcePolicyId,
resources.defaultResourcePolicyId
)
)
.leftJoin(
defaultPolicyPincode,
eq(
defaultPolicyPincode.resourcePolicyId,
defaultPolicy.resourcePolicyId
)
)
.leftJoin(
defaultPolicyPassword,
eq(
defaultPolicyPassword.resourcePolicyId,
defaultPolicy.resourcePolicyId
)
)
.leftJoin(
defaultPolicyHeaderAuth,
eq(
defaultPolicyHeaderAuth.resourcePolicyId,
defaultPolicy.resourcePolicyId
)
)
.innerJoin(orgs, eq(orgs.orgId, resources.orgId))
.where(
or(
@@ -108,13 +203,51 @@ export async function getResourceByDomain(
return null;
}
// If a shared (custom) policy is assigned to the resource, use ONLY
// its values — do not fall back to the default policy. The default
// policy is only consulted when no shared policy is assigned at all.
const hasSharedPolicy = result.sharedPolicy !== null;
const effectivePolicyPincode = hasSharedPolicy
? result.sharedPolicyPincode
: (result.defaultPolicyPincode ?? null);
const effectivePolicyPassword = hasSharedPolicy
? result.sharedPolicyPassword
: (result.defaultPolicyPassword ?? null);
const effectivePolicyHeaderAuth = hasSharedPolicy
? result.sharedPolicyHeaderAuth
: (result.defaultPolicyHeaderAuth ?? null);
const selectedPolicy = hasSharedPolicy
? result.sharedPolicy
: result.defaultPolicy;
const effectiveApplyRules =
selectedPolicy?.applyRules ?? result.resources.applyRules;
const effectiveSSO = selectedPolicy?.sso ?? result.resources.sso;
const effectiveEmailWhitelistEnabled =
selectedPolicy?.emailWhitelistEnabled ??
result.resources.emailWhitelistEnabled;
return {
resource: result.resources,
pincode: result.resourcePincode,
password: result.resourcePassword,
headerAuth: result.resourceHeaderAuth,
headerAuthExtendedCompatibility:
result.resourceHeaderAuthExtendedCompatibility,
resource: {
...result.resources,
applyRules: effectiveApplyRules,
sso: effectiveSSO,
emailWhitelistEnabled: effectiveEmailWhitelistEnabled
}, // doing this for backward compatability so the remote nodes get the value as part of the resource struct
pincode: effectivePolicyPincode ?? result.resourcePincode,
password: effectivePolicyPassword ?? result.resourcePassword,
headerAuth: effectivePolicyHeaderAuth ?? result.resourceHeaderAuth,
headerAuthExtendedCompatibility: effectivePolicyHeaderAuth
? ({
headerAuthExtendedCompatibilityId: 0,
resourceId: result.resources.resourceId,
extendedCompatibilityIsActivated:
effectivePolicyHeaderAuth.extendedCompatibility
} as ResourceHeaderAuthExtendedCompatibility)
: result.resourceHeaderAuthExtendedCompatibility,
applyRules: effectiveApplyRules,
sso: effectiveSSO,
emailWhitelistEnabled: effectiveEmailWhitelistEnabled,
org: result.orgs
};
}
@@ -154,58 +287,165 @@ export async function getRoleName(roleId: number): Promise<string | null> {
}
/**
* Check if role has access to resource
* Check if role has access to resource (direct or via resource policy)
*/
export async function getRoleResourceAccess(
resourceId: number,
roleIds: number[]
) {
const roleResourceAccess = await db
.select()
.from(roleResources)
.where(
and(
eq(roleResources.resourceId, resourceId),
inArray(roleResources.roleId, roleIds)
const [direct, viaPolicies] = await Promise.all([
db
.select()
.from(roleResources)
.where(
and(
eq(roleResources.resourceId, resourceId),
inArray(roleResources.roleId, roleIds)
)
),
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)
)
)
]);
return roleResourceAccess.length > 0 ? roleResourceAccess : null;
const combined = [...direct, ...viaPolicies];
return combined.length > 0 ? combined : null;
}
/**
* Check if user has direct access to resource
* Check if user has access to resource (direct or via resource policy)
*/
export async function getUserResourceAccess(
userId: string,
resourceId: number
) {
const userResourceAccess = await db
.select()
.from(userResources)
.where(
and(
eq(userResources.userId, userId),
eq(userResources.resourceId, resourceId)
const [direct, viaPolicies] = await Promise.all([
db
.select()
.from(userResources)
.where(
and(
eq(userResources.userId, userId),
eq(userResources.resourceId, resourceId)
)
)
)
.limit(1);
.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 userResourceAccess.length > 0 ? userResourceAccess[0] : null;
return direct[0] ?? viaPolicies[0] ?? null;
}
/**
* Get resource rules for a given resource
* Get resource rules for a given resource (direct and via resource policy)
*/
export async function getResourceRules(
resourceId: number
): Promise<ResourceRule[]> {
const rules = await db
.select()
.from(resourceRules)
.where(eq(resourceRules.resourceId, resourceId));
const [directRules, policyRules] = await Promise.all([
db
.select()
.from(resourceRules)
.where(eq(resourceRules.resourceId, resourceId)),
db
.select({
ruleId: resourcePolicyRules.ruleId,
resourceId: sql<number>`${resourceId}`,
enabled: resourcePolicyRules.enabled,
priority: resourcePolicyRules.priority,
action: resourcePolicyRules.action,
match: resourcePolicyRules.match,
value: resourcePolicyRules.value
})
.from(resourcePolicyRules)
.innerJoin(
resources,
// Shared policy wins; only use default policy when no shared
// policy is assigned to the resource.
or(
eq(
resources.resourcePolicyId,
resourcePolicyRules.resourcePolicyId
),
and(
isNull(resources.resourcePolicyId),
eq(
resources.defaultResourcePolicyId,
resourcePolicyRules.resourcePolicyId
)
)
)
)
.where(eq(resources.resourceId, resourceId))
]);
return rules;
const maxDirectPriority = directRules.reduce(
(max, r) => Math.max(max, r.priority),
0
);
const offsetPolicyRules = policyRules.map((r) => ({
...r,
priority: maxDirectPriority + r.priority
}));
return [...directRules, ...offsetPolicyRules] as ResourceRule[];
}
/**

View File

@@ -4,3 +4,4 @@ export * from "./safeRead";
export * from "./schema/schema";
export * from "./schema/privateSchema";
export * from "./migrate";
export { alias } from "drizzle-orm/sqlite-core";

View File

@@ -12,6 +12,7 @@ import {
clients,
domains,
exitNodes,
labels,
orgs,
resources,
roles,
@@ -21,9 +22,6 @@ import {
targetHealthCheck,
users
} 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", {
certId: integer("certId").primaryKey({ autoIncrement: true }),
@@ -195,6 +193,44 @@ 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", {
sessionId: text("id").primaryKey(),
remoteExitNodeId: text("remoteExitNodeId")
@@ -588,6 +624,26 @@ export const trialNotifications = sqliteTable("trialNotifications", {
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 Limit = InferSelectModel<typeof limits>;
export type Account = InferSelectModel<typeof account>;
@@ -627,3 +683,6 @@ export type AlertEmailAction = InferSelectModel<typeof alertEmailActions>;
export type AlertEmailRecipient = InferSelectModel<typeof alertEmailRecipients>;
export type AlertWebhookAction = InferSelectModel<typeof alertWebhookActions>;
export type TrialNotification = InferSelectModel<typeof trialNotifications>;
export type BrowserGatewayTarget = InferSelectModel<
typeof browserGatewayTarget
>;

View File

@@ -62,7 +62,13 @@ export const orgs = sqliteTable("orgs", {
sshCaPrivateKey: text("sshCaPrivateKey"), // Encrypted SSH CA private key (PEM format)
sshCaPublicKey: text("sshCaPublicKey"), // SSH CA public key (OpenSSH format)
isBillingOrg: integer("isBillingOrg", { mode: "boolean" }),
billingOrgId: text("billingOrgId")
billingOrgId: text("billingOrgId"),
settingsEnableGlobalNewtAutoUpdate: integer(
"settingsEnableGlobalNewtAutoUpdate",
{ mode: "boolean" }
)
.notNull()
.default(false)
});
export const userDomains = sqliteTable("userDomains", {
@@ -116,11 +122,29 @@ export const sites = sqliteTable("sites", {
dockerSocketEnabled: integer("dockerSocketEnabled", { mode: "boolean" })
.notNull()
.default(true),
autoUpdateEnabled: integer("autoUpdateEnabled", { mode: "boolean" })
.notNull()
.default(false),
autoUpdateOverrideOrg: integer("autoUpdateOverrideOrg", {
mode: "boolean"
})
.notNull()
.default(false),
status: text("status").$type<"pending" | "approved">().default("approved")
});
export const resources = sqliteTable("resources", {
resourceId: integer("resourceId").primaryKey({ autoIncrement: true }),
resourcePolicyId: integer("resourcePolicyId").references(
() => resourcePolicies.resourcePolicyId,
{ onDelete: "set null" }
),
defaultResourcePolicyId: integer("defaultResourcePolicyId").references(
() => resourcePolicies.resourcePolicyId,
{
onDelete: "restrict"
}
),
resourceGuid: text("resourceGuid", { length: 36 })
.unique()
.notNull()
@@ -142,8 +166,6 @@ export const resources = sqliteTable("resources", {
.notNull()
.default(false),
sso: integer("sso", { mode: "boolean" }).notNull().default(true),
http: integer("http", { mode: "boolean" }).notNull().default(true),
protocol: text("protocol").notNull(),
proxyPort: integer("proxyPort"),
emailWhitelistEnabled: integer("emailWhitelistEnabled", { mode: "boolean" })
.notNull()
@@ -166,7 +188,6 @@ export const resources = sqliteTable("resources", {
.notNull()
.default(false),
proxyProtocolVersion: integer("proxyProtocolVersion").default(1),
maintenanceModeEnabled: integer("maintenanceModeEnabled", {
mode: "boolean"
})
@@ -180,7 +201,15 @@ export const resources = sqliteTable("resources", {
maintenanceEstimatedTime: text("maintenanceEstimatedTime"),
postAuthPath: text("postAuthPath"),
health: text("health").default("unknown"), // "healthy", "unhealthy", "unknown"
wildcard: integer("wildcard", { mode: "boolean" }).notNull().default(false)
wildcard: integer("wildcard", { mode: "boolean" }).notNull().default(false),
mode: text("mode").default("http").notNull(), // rdp, ssh, http, vnc
pamMode: text("pamMode")
.$type<"passthrough" | "push">()
.default("passthrough"),
authDaemonMode: text("authDaemonMode")
.$type<"site" | "remote" | "native">()
.default("site"),
authDaemonPort: integer("authDaemonPort").default(22123)
});
export const labels = sqliteTable("labels", {
@@ -372,11 +401,11 @@ export const siteResources = sqliteTable("siteResources", {
niceId: text("niceId").notNull(),
name: text("name").notNull(),
ssl: integer("ssl", { mode: "boolean" }).notNull().default(false),
mode: text("mode").$type<"host" | "cidr" | "http">().notNull(), // "host" | "cidr" | "http"
mode: text("mode").$type<"host" | "cidr" | "http" | "ssh">().notNull(), // "host" | "cidr" | "http"
scheme: text("scheme").$type<"http" | "https">(), // only for when we are doing https or http mode
proxyPort: integer("proxyPort"), // only for port mode
destinationPort: integer("destinationPort"), // only for port mode
destination: text("destination").notNull(), // ip, cidr, hostname
destination: text("destination"), // ip, cidr, hostname
enabled: integer("enabled", { mode: "boolean" }).notNull().default(true),
alias: text("alias"),
aliasAddress: text("aliasAddress"),
@@ -386,8 +415,11 @@ export const siteResources = sqliteTable("siteResources", {
.notNull()
.default(false),
authDaemonPort: integer("authDaemonPort").default(22123),
pamMode: text("pamMode")
.$type<"passthrough" | "push">()
.default("passthrough"),
authDaemonMode: text("authDaemonMode")
.$type<"site" | "remote">()
.$type<"site" | "remote" | "native">()
.default("site"),
domainId: text("domainId").references(() => domains.domainId, {
onDelete: "set null"
@@ -1000,6 +1032,47 @@ export const resourceHeaderAuth = sqliteTable("resourceHeaderAuth", {
headerAuthHash: text("headerAuthHash").notNull()
});
export const resourcePolicyPincode = sqliteTable("resourcePolicyPincode", {
pincodeId: integer("pincodeId").primaryKey({ autoIncrement: true }),
pincodeHash: text("pincodeHash").notNull(),
digitLength: integer("digitLength").notNull(),
resourcePolicyId: integer("resourcePolicyId")
.notNull()
.references(() => resourcePolicies.resourcePolicyId, {
onDelete: "cascade"
})
});
export const resourcePolicyPassword = sqliteTable("resourcePolicyPassword", {
passwordId: integer("passwordId").primaryKey({ autoIncrement: true }),
passwordHash: text("passwordHash").notNull(),
resourcePolicyId: integer("resourcePolicyId")
.notNull()
.references(() => resourcePolicies.resourcePolicyId, {
onDelete: "cascade"
})
});
export const resourcePolicyHeaderAuth = sqliteTable(
"resourcePolicyHeaderAuth",
{
headerAuthId: integer("headerAuthId").primaryKey({
autoIncrement: true
}),
headerAuthHash: text("headerAuthHash").notNull(),
extendedCompatibility: integer("extendedCompatibility", {
mode: "boolean"
})
.notNull()
.default(true),
resourcePolicyId: integer("resourcePolicyId")
.notNull()
.references(() => resourcePolicies.resourcePolicyId, {
onDelete: "cascade"
})
}
);
export const resourceHeaderAuthExtendedCompatibility = sqliteTable(
"resourceHeaderAuthExtendedCompatibility",
{
@@ -1028,6 +1101,7 @@ export const resourceAccessToken = sqliteTable("resourceAccessToken", {
resourceId: integer("resourceId")
.notNull()
.references(() => resources.resourceId, { onDelete: "cascade" }),
path: text("path"),
tokenHash: text("tokenHash").notNull(),
sessionLength: integer("sessionLength").notNull(),
expiresAt: integer("expiresAt"),
@@ -1074,6 +1148,24 @@ export const resourceSessions = sqliteTable("resourceSessions", {
onDelete: "cascade"
}
),
policyPasswordId: integer("policyPasswordId").references(
() => resourcePolicyPassword.passwordId,
{
onDelete: "cascade"
}
),
policyPincodeId: integer("policyPincodeId").references(
() => resourcePolicyPincode.pincodeId,
{
onDelete: "cascade"
}
),
policyWhitelistId: integer("policyWhitelistId").references(
() => resourcePolicyWhiteList.whitelistId,
{
onDelete: "cascade"
}
),
issuedAt: integer("issuedAt")
});
@@ -1114,6 +1206,77 @@ export const resourceRules = sqliteTable("resourceRules", {
value: text("value").notNull()
});
export const rolePolicies = sqliteTable("rolePolicies", {
roleId: integer("roleId")
.notNull()
.references(() => roles.roleId, { onDelete: "cascade" }),
resourcePolicyId: integer("resourcePolicyId")
.notNull()
.references(() => resourcePolicies.resourcePolicyId, {
onDelete: "cascade"
})
});
export const userPolicies = sqliteTable("userPolicies", {
userId: text("userId")
.notNull()
.references(() => users.userId, { onDelete: "cascade" }),
resourcePolicyId: integer("resourcePolicyId")
.notNull()
.references(() => resourcePolicies.resourcePolicyId, {
onDelete: "cascade"
})
});
export const resourcePolicyWhiteList = sqliteTable("resourcePolicyWhitelist", {
whitelistId: integer("id").primaryKey({ autoIncrement: true }),
email: text("email").notNull(),
resourcePolicyId: integer("resourcePolicyId")
.notNull()
.references(() => resourcePolicies.resourcePolicyId, {
onDelete: "cascade"
})
});
export const resourcePolicyRules = sqliteTable("resourcePolicyRules", {
ruleId: integer("ruleId").primaryKey({ autoIncrement: true }),
resourcePolicyId: integer("resourcePolicyId")
.notNull()
.references(() => resourcePolicies.resourcePolicyId, {
onDelete: "cascade"
}),
enabled: integer("enabled", { mode: "boolean" }).notNull().default(true),
priority: integer("priority").notNull(),
action: text("action").$type<"ACCEPT" | "DROP" | "PASS">().notNull(),
match: text("match").$type<"CIDR" | "PATH" | "IP">().notNull(),
value: text("value").notNull()
});
export const resourcePolicies = sqliteTable("resourcePolicies", {
resourcePolicyId: integer("resourcePolicyId").primaryKey(),
sso: integer("sso", { mode: "boolean" }).notNull().default(true),
applyRules: integer("applyRules", { mode: "boolean" })
.notNull()
.default(false),
scope: text("scope")
.$type<"global" | "resource">()
.notNull()
.default("global"),
emailWhitelistEnabled: integer("emailWhitelistEnabled", { mode: "boolean" })
.notNull()
.default(false),
niceId: text("niceId").notNull(),
idpId: integer("idpId").references(() => idp.idpId, {
onDelete: "set null"
}),
name: text("name").notNull(),
orgId: text("orgId")
.references(() => orgs.orgId, {
onDelete: "cascade"
})
.notNull()
});
export const supporterKey = sqliteTable("supporterKey", {
keyId: integer("keyId").primaryKey({ autoIncrement: true }),
key: text("key").notNull(),
@@ -1381,3 +1544,15 @@ export type RoundTripMessageTracker = InferSelectModel<
>;
export type StatusHistory = InferSelectModel<typeof statusHistory>;
export type Label = InferSelectModel<typeof labels>;
export type ResourcePolicy = InferSelectModel<typeof resourcePolicies>;
export type ResourcePolicyPincode = InferSelectModel<
typeof resourcePolicyPincode
>;
export type ResourcePolicyPassword = InferSelectModel<
typeof resourcePolicyPassword
>;
export type ResourcePolicyHeaderAuth = InferSelectModel<
typeof resourcePolicyHeaderAuth
>;
export type RolePolicy = InferSelectModel<typeof rolePolicies>;
export type UserPolicy = InferSelectModel<typeof userPolicies>;

View File

@@ -1,5 +1,5 @@
#! /usr/bin/env node
import "./extendZod.ts";
import "./extendZod";
import { runSetupFunctions } from "./setup";
import { createApiServer } from "./apiServer";

View File

@@ -152,11 +152,17 @@ function getOpenApiDocumentation() {
if (!hasExistingResponses) {
def.route.responses = {
"*": {
description: "",
"200": {
description: "Successful response",
content: {
"application/json": {
schema: z.object({})
schema: z.object({
data: z.unknown().nullable(),
success: z.boolean(),
error: z.boolean(),
message: z.string(),
status: z.number()
})
}
}
}

View File

@@ -221,10 +221,18 @@ async function handleResource(
)
.where(eq(targets.resourceId, resource.resourceId));
const monitoredTargets = otherTargets.filter(
(t) => t.hcHealth !== "unknown"
);
let health = "healthy";
const allUnknown = otherTargets.every((t) => t.hcHealth === "unknown");
const allHealthy = otherTargets.every((t) => t.hcHealth === "healthy");
const allUnhealthy = otherTargets.every((t) => t.hcHealth === "unhealthy");
const allUnknown = monitoredTargets.length === 0;
const allHealthy = monitoredTargets.every(
(t) => t.hcHealth === "healthy"
);
const allUnhealthy = monitoredTargets.every(
(t) => t.hcHealth === "unhealthy"
);
if (allUnknown) {
logger.debug(

View File

@@ -16,16 +16,18 @@ export enum TierFeature {
SessionDurationPolicies = "sessionDurationPolicies", // handle downgrade by setting to default duration
PasswordExpirationPolicies = "passwordExpirationPolicies", // handle downgrade by setting to default duration
AutoProvisioning = "autoProvisioning", // handle downgrade by disabling auto provisioning
SshPam = "sshPam",
FullRbac = "fullRbac",
SiteProvisioningKeys = "siteProvisioningKeys", // handle downgrade by revoking keys if needed
SIEM = "siem", // handle downgrade by disabling SIEM integrations
HTTPPrivateResources = "httpPrivateResources", // handle downgrade by disabling HTTP private resources
DomainNamespaces = "domainNamespaces", // handle downgrade by removing custom domain namespaces
StandaloneHealthChecks = "standaloneHealthChecks",
AlertingRules = "alertingRules",
WildcardSubdomain = "wildcardSubdomain",
Labels = "labels"
Labels = "labels",
NewtAutoUpdate = "newtAutoUpdate",
ResourcePolicies = "resourcePolicies",
AdvancedPublicResources = "advancedPublicResources",
AdvancedPrivateResources = "advancedPrivateResources"
}
export const tierMatrix: Record<TierFeature, Tier[]> = {
@@ -60,13 +62,25 @@ export const tierMatrix: Record<TierFeature, Tier[]> = {
"enterprise"
],
[TierFeature.AutoProvisioning]: ["tier1", "tier3", "enterprise"],
[TierFeature.SshPam]: ["tier1", "tier3", "enterprise"],
[TierFeature.FullRbac]: ["tier1", "tier2", "tier3", "enterprise"],
[TierFeature.SiteProvisioningKeys]: ["tier3", "enterprise"],
[TierFeature.SIEM]: ["enterprise"],
[TierFeature.HTTPPrivateResources]: ["tier3", "enterprise"],
[TierFeature.DomainNamespaces]: ["tier1", "tier2", "tier3", "enterprise"],
[TierFeature.StandaloneHealthChecks]: ["tier3", "enterprise"],
[TierFeature.AlertingRules]: ["tier3", "enterprise"],
[TierFeature.WildcardSubdomain]: ["tier1", "tier2", "tier3", "enterprise"]
[TierFeature.WildcardSubdomain]: ["tier1", "tier2", "tier3", "enterprise"],
[TierFeature.NewtAutoUpdate]: ["tier1", "tier2", "tier3", "enterprise"],
[TierFeature.ResourcePolicies]: ["tier3", "enterprise"],
[TierFeature.AdvancedPublicResources]: [
"tier1",
"tier2",
"tier3",
"enterprise"
],
[TierFeature.AdvancedPrivateResources]: [
"tier1",
"tier2",
"tier3",
"enterprise"
]
};

View File

@@ -22,7 +22,6 @@ export function noop() {
}
export class UsageService {
constructor() {
if (noop()) {
return;
@@ -57,7 +56,10 @@ export class UsageService {
try {
let usage;
if (transaction) {
const orgIdToUse = await this.getBillingOrg(orgId, transaction);
const orgIdToUse = await this.getBillingOrg(
orgId,
transaction
);
usage = await this.internalAddUsage(
orgIdToUse,
featureId,
@@ -274,11 +276,12 @@ export class UsageService {
return null;
}
const orgIdToUse = await this.getBillingOrg(orgId, trx);
const usageId = `${orgIdToUse}-${featureId}`;
let orgIdToUse = orgId;
try {
orgIdToUse = await this.getBillingOrg(orgId, trx);
const usageId = `${orgIdToUse}-${featureId}`;
const [result] = await trx
.select()
.from(usage)
@@ -338,8 +341,12 @@ export class UsageService {
`Failed to get usage for ${orgIdToUse}/${featureId}:`,
error
);
throw error;
if (process.env.NODE_ENV !== "development") {
throw error;
}
}
return null;
}
public async getBillingOrg(
@@ -382,13 +389,13 @@ export class UsageService {
return false;
}
const orgIdToUse = await this.getBillingOrg(orgId, trx);
// This method should check the current usage against the limits set for the organization
// and kick out all of the sites on the org
let hasExceededLimits = false;
let orgIdToUse = orgId;
try {
orgIdToUse = await this.getBillingOrg(orgId, trx);
let orgLimits: Limit[] = [];
if (featureId) {
// Get all limits set for this organization

View File

@@ -16,14 +16,13 @@ import logger from "@server/logger";
import { sites } from "@server/db";
import { eq, and, isNotNull } from "drizzle-orm";
import { addTargets as addProxyTargets } from "@server/routers/newt/targets";
import { addTargets as addClientTargets } from "@server/routers/client/targets";
import {
ClientResourcesResults,
updateClientResources
} from "./clientResources";
import { BlueprintSource } from "@server/routers/blueprints/types";
import { stringify as stringifyYaml } from "yaml";
import { faker } from "@faker-js/faker";
import { generateName } from "@server/db/names";
import { handleMessagingForUpdatedSiteResource } from "@server/routers/siteResource";
import { rebuildClientAssociationsFromSiteResource } from "../rebuildClientAssociations";
@@ -106,7 +105,7 @@ export async function applyBlueprint({
site.newt.newtId,
[target],
matchingHealthcheck ? [matchingHealthcheck] : [],
result.proxyResource.protocol,
result.proxyResource.mode === "udp" ? "udp" : "tcp",
site.newt.version
);
}
@@ -291,9 +290,7 @@ export async function applyBlueprint({
.insert(blueprints)
.values({
orgId,
name:
name ??
`${faker.word.adjective()}-${faker.word.adjective()}-${faker.word.noun()}`,
name: name ?? generateName(),
contents: stringifyYaml(configData),
createdAt: Math.floor(Date.now() / 1000),
succeeded: blueprintSucceeded,

View File

@@ -1,10 +1,56 @@
import { sendToClient } from "#dynamic/routers/ws";
import { processContainerLabels } from "./parseDockerContainers";
import { applyBlueprint } from "./applyBlueprint";
import { PrivateResourceSchema, PublicResourceSchema } from "./types";
import { db, sites } from "@server/db";
import { eq } from "drizzle-orm";
import logger from "@server/logger";
type BlueprintResult = ReturnType<typeof processContainerLabels>;
function filterInvalidResources(blueprint: BlueprintResult): {
skippedCount: number;
skippedKeys: string[];
} {
const skippedKeys: string[] = [];
for (const section of ["proxy-resources", "public-resources"] as const) {
const resources = blueprint[section];
for (const [key, value] of Object.entries(resources)) {
const result = PublicResourceSchema.safeParse(value);
if (!result.success) {
const errors = result.error.issues
.map((i) => `${i.path.join(".")}: ${i.message}`)
.join("; ");
logger.warn(
`Skipping invalid Docker ${section} "${key}": ${errors}`
);
delete resources[key];
skippedKeys.push(`${section}.${key}`);
}
}
}
for (const section of ["client-resources", "private-resources"] as const) {
const resources = blueprint[section];
for (const [key, value] of Object.entries(resources)) {
const result = PrivateResourceSchema.safeParse(value);
if (!result.success) {
const errors = result.error.issues
.map((i) => `${i.path.join(".")}: ${i.message}`)
.join("; ");
logger.warn(
`Skipping invalid Docker ${section} "${key}": ${errors}`
);
delete resources[key];
skippedKeys.push(`${section}.${key}`);
}
}
}
return { skippedCount: skippedKeys.length, skippedKeys };
}
export async function applyNewtDockerBlueprint(
siteId: number,
newtId: string,
@@ -21,17 +67,24 @@ export async function applyNewtDockerBlueprint(
return;
}
// logger.debug(`Applying Docker blueprint to site: ${siteId}`);
// logger.debug(`Containers: ${JSON.stringify(containers, null, 2)}`);
let skippedCount = 0;
let skippedKeys: string[] = [];
try {
const blueprint = processContainerLabels(containers);
logger.debug(`Received Docker blueprint: ${JSON.stringify(blueprint)}`);
logger.debug(
`Received Docker blueprint with ${Object.keys(blueprint["proxy-resources"]).length} proxy, ${Object.keys(blueprint["client-resources"]).length} client resource(s)`
);
// make sure this is not an empty object
if (isEmptyObject(blueprint)) {
return;
const filterResult = filterInvalidResources(blueprint);
skippedCount = filterResult.skippedCount;
skippedKeys = filterResult.skippedKeys;
if (skippedCount > 0) {
logger.warn(
`Filtered ${skippedCount} invalid resource(s) from Docker blueprint: ${skippedKeys.join(", ")}`
);
}
if (
@@ -40,6 +93,15 @@ export async function applyNewtDockerBlueprint(
isEmptyObject(blueprint["public-resources"]) &&
isEmptyObject(blueprint["private-resources"])
) {
if (skippedCount > 0) {
await sendToClient(newtId, {
type: "newt/blueprint/results",
data: {
success: false,
message: `All resources were invalid and skipped: ${skippedKeys.join(", ")}`
}
});
}
return;
}
@@ -66,7 +128,10 @@ export async function applyNewtDockerBlueprint(
type: "newt/blueprint/results",
data: {
success: true,
message: "Config updated successfully"
message:
skippedCount > 0
? `Config updated successfully. Skipped ${skippedCount} invalid resource(s): ${skippedKeys.join(", ")}`
: "Config updated successfully"
}
});
}

View File

@@ -3,6 +3,7 @@ import {
clientSiteResources,
domains,
orgDomains,
roleActions,
roles,
roleSiteResources,
Site,
@@ -19,6 +20,7 @@ import { sites } from "@server/db";
import { eq, and, ne, inArray, or, isNotNull } from "drizzle-orm";
import { Config } from "./types";
import logger from "@server/logger";
import { defaultRoleAllowedActions } from "@server/routers/role/createRole";
import { getNextAvailableAliasAddress } from "../ip";
import { createCertificate } from "#dynamic/routers/certificates/createCertificate";
@@ -225,7 +227,11 @@ export async function updateClientResources(
: resourceData["udp-ports"],
fullDomain: resourceData["full-domain"] || null,
subdomain: domainInfo ? domainInfo.subdomain : null,
domainId: domainInfo ? domainInfo.domainId : null
domainId: domainInfo ? domainInfo.domainId : null,
pamMode: resourceData["auth-daemon"]?.pam || "passthrough",
authDaemonMode:
resourceData["auth-daemon"]?.mode || "native",
authDaemonPort: resourceData["auth-daemon"]?.port || 22123
})
.where(
eq(
@@ -332,8 +338,7 @@ export async function updateClientResources(
}
if (resourceData.roles.length > 0) {
// Re-add specified roles but we need to get the roleIds from the role name in the array
const rolesToUpdate = await trx
const existingRoles = await trx
.select()
.from(roles)
.where(
@@ -343,7 +348,28 @@ export async function updateClientResources(
)
);
const roleIds = rolesToUpdate.map((role) => role.roleId);
const foundNames = new Set(existingRoles.map((r) => r.name));
const missingNames = resourceData.roles.filter(
(n) => !foundNames.has(n)
);
for (const name of missingNames) {
const [created] = await trx
.insert(roles)
.values({ name, orgId })
.returning();
await trx.insert(roleActions).values(
defaultRoleAllowedActions.map((action) => ({
roleId: created.roleId,
actionId: action,
orgId
}))
);
existingRoles.push(created);
logger.info(`Auto-created role "${name}" in org ${orgId} from blueprint`);
}
const roleIds = existingRoles.map((role) => role.roleId);
await trx
.insert(roleSiteResources)
@@ -360,8 +386,14 @@ export async function updateClientResources(
});
} else {
let aliasAddress: string | null = null;
let releaseAliasLock: (() => Promise<void>) | null = null;
if (resourceData.mode === "host" || resourceData.mode === "http") {
aliasAddress = await getNextAvailableAliasAddress(orgId, trx);
const { value, release } = await getNextAvailableAliasAddress(
orgId,
trx
);
aliasAddress = value;
releaseAliasLock = release;
}
let domainInfo:
@@ -415,10 +447,16 @@ export async function updateClientResources(
: resourceData["udp-ports"],
fullDomain: resourceData["full-domain"] || null,
subdomain: domainInfo ? domainInfo.subdomain : null,
domainId: domainInfo ? domainInfo.domainId : null
domainId: domainInfo ? domainInfo.domainId : null,
pamMode: resourceData["auth-daemon"]?.pam || "passthrough",
authDaemonMode:
resourceData["auth-daemon"]?.mode || "native",
authDaemonPort: resourceData["auth-daemon"]?.port || 22123
})
.returning();
await releaseAliasLock?.();
const siteResourceId = newResource.siteResourceId;
for (const site of allSites) {
@@ -444,8 +482,7 @@ export async function updateClientResources(
});
if (resourceData.roles.length > 0) {
// get roleIds from role names
const rolesToUpdate = await trx
const existingRoles = await trx
.select()
.from(roles)
.where(
@@ -455,7 +492,28 @@ export async function updateClientResources(
)
);
const roleIds = rolesToUpdate.map((role) => role.roleId);
const foundNames = new Set(existingRoles.map((r) => r.name));
const missingNames = resourceData.roles.filter(
(n) => !foundNames.has(n)
);
for (const name of missingNames) {
const [created] = await trx
.insert(roles)
.values({ name, orgId })
.returning();
await trx.insert(roleActions).values(
defaultRoleAllowedActions.map((action) => ({
roleId: created.roleId,
actionId: action,
orgId
}))
);
existingRoles.push(created);
logger.info(`Auto-created role "${name}" in org ${orgId} from blueprint`);
}
const roleIds = existingRoles.map((role) => role.roleId);
await trx
.insert(roleSiteResources)

File diff suppressed because it is too large Load Diff

View File

@@ -82,7 +82,7 @@ export const RuleSchema = z
.object({
action: z.enum(["allow", "deny", "pass"]),
match: z.enum(["cidr", "path", "ip", "country", "asn", "region"]),
value: z.string(),
value: z.coerce.string(),
priority: z.int().optional()
})
.refine(
@@ -161,11 +161,34 @@ export const HeaderSchema = z.object({
value: z.string().min(1)
});
export const AuthDaemonSchema = z
.object({
pam: z.enum(["passthrough", "push"]).optional().default("passthrough"),
mode: z.enum(["site", "remote", "native"]).optional().default("site"),
port: z.int().min(1).max(65535).optional()
})
.refine(
(data) => {
if (data.mode === "remote") {
return data.port !== undefined;
}
return true;
},
{
path: ["port"],
message: "port is required when auth-daemon mode is 'remote'"
}
);
// Schema for individual resource
export const ResourceSchema = z
export const PublicResourceSchema = z
.object({
name: z.string().optional(),
protocol: z.enum(["http", "tcp", "udp"]).optional(),
protocol: z
.enum(["http", "tcp", "udp", "ssh", "rdp", "vnc"])
.optional(), // this was the old one and is now DEPRECATED in favor of the mode
mode: z.enum(["http", "tcp", "udp", "ssh", "rdp", "vnc"]).optional(),
policy: z.string().optional(),
ssl: z.boolean().optional(),
scheme: z.enum(["http", "https"]).optional(),
"full-domain": z.string().optional(),
@@ -177,7 +200,10 @@ export const ResourceSchema = z
"tls-server-name": z.string().optional(),
headers: z.array(HeaderSchema).optional(),
rules: z.array(RuleSchema).optional(),
maintenance: MaintenanceSchema.optional()
maintenance: MaintenanceSchema.optional(),
"auth-daemon": AuthDaemonSchema.optional(),
"proxy-protocol": z.boolean().optional(),
"proxy-protocol-version": z.int().min(1).optional()
})
.refine(
(resource) => {
@@ -185,9 +211,10 @@ export const ResourceSchema = z
return true;
}
// Otherwise, require name and protocol for full resource definition
// Otherwise, require name and protocol/mode for full resource definition
return (
resource.name !== undefined && resource.protocol !== undefined
resource.name !== undefined &&
(resource.mode !== undefined || resource.protocol !== undefined)
);
},
{
@@ -201,8 +228,8 @@ export const ResourceSchema = z
return true;
}
// If protocol is http, all targets must have method field
if (resource.protocol === "http") {
// If protocol/mode is http, all targets must have method field
if ((resource.mode ?? resource.protocol) === "http") {
return resource.targets.every(
(target) => target == null || target.method !== undefined
);
@@ -220,8 +247,9 @@ export const ResourceSchema = z
return true;
}
// If protocol is tcp or udp, no target should have method field
if (resource.protocol === "tcp" || resource.protocol === "udp") {
// If protocol/mode is tcp or udp, no target should have method field
const effectiveProtocol1 = resource.mode ?? resource.protocol;
if (effectiveProtocol1 === "tcp" || effectiveProtocol1 === "udp") {
return resource.targets.every(
(target) => target == null || target.method === undefined
);
@@ -239,8 +267,8 @@ export const ResourceSchema = z
return true;
}
// If protocol is http, it must have a full-domain
if (resource.protocol === "http") {
// If protocol/mode is http, it must have a full-domain
if ((resource.mode ?? resource.protocol) === "http") {
return (
resource["full-domain"] !== undefined &&
resource["full-domain"].length > 0
@@ -259,8 +287,9 @@ export const ResourceSchema = z
return true;
}
// If protocol is tcp or udp, it must have both proxy-port
if (resource.protocol === "tcp" || resource.protocol === "udp") {
// If protocol/mode is tcp or udp, it must have both proxy-port
const effectiveProtocol2 = resource.mode ?? resource.protocol;
if (effectiveProtocol2 === "tcp" || effectiveProtocol2 === "udp") {
return resource["proxy-port"] !== undefined;
}
return true;
@@ -277,8 +306,9 @@ export const ResourceSchema = z
return true;
}
// If protocol is tcp or udp, it must not have auth
if (resource.protocol === "tcp" || resource.protocol === "udp") {
// If protocol/mode is tcp or udp, it must not have auth
const effectiveProtocol3 = resource.mode ?? resource.protocol;
if (effectiveProtocol3 === "tcp" || effectiveProtocol3 === "udp") {
return resource.auth === undefined;
}
return true;
@@ -340,7 +370,8 @@ export const ResourceSchema = z
if (parts.includes("*", 1)) return false; // no further wildcards
if (parts.length < 3) return false; // need at least *.label.tld
const labelRegex = /^[a-zA-Z0-9]([a-zA-Z0-9-]*[a-zA-Z0-9])?$|^[a-zA-Z0-9]$/;
const labelRegex =
/^[a-zA-Z0-9]([a-zA-Z0-9-]*[a-zA-Z0-9])?$|^[a-zA-Z0-9]$/;
return parts.slice(1).every((label) => labelRegex.test(label));
},
{
@@ -348,22 +379,46 @@ export const ResourceSchema = z
message:
'Wildcard full-domain must have "*" as the leftmost label only, followed by at least two valid hostname labels (e.g. "*.example.com" or "*.level1.example.com"). Patterns like "*example.com" or "level2.*.example.com" are not supported.'
}
);
)
.refine(
(resource) => {
const effectiveMode = resource.mode ?? resource.protocol;
if (effectiveMode !== "tcp") {
return (
resource["proxy-protocol"] === undefined &&
resource["proxy-protocol-version"] === undefined
);
}
return true;
},
{
path: ["proxy-protocol"],
message:
"'proxy-protocol' and 'proxy-protocol-version' can only be set when mode is 'tcp'"
}
)
.transform((resource) => {
// Normalize: prefer mode, fall back to protocol for backwards compatibility
if (resource.mode === undefined && resource.protocol !== undefined) {
resource.mode = resource.protocol;
}
return resource;
});
export function isTargetsOnlyResource(resource: any): boolean {
return Object.keys(resource).length === 1 && resource.targets;
}
export const ClientResourceSchema = z
export const PrivateResourceSchema = z
.object({
name: z.string().min(1).max(255),
mode: z.enum(["host", "cidr", "http"]),
mode: z.enum(["host", "cidr", "http", "ssh"]),
site: z.string().optional(), // DEPRECATED IN FAVOR OF sites
sites: z.array(z.string()).optional().default([]),
// protocol: z.enum(["tcp", "udp"]).optional(),
// proxyPort: z.int().positive().optional(),
"destination-port": z.int().positive().optional(),
destination: z.string().min(1),
destination: z.string().min(1).optional(),
// enabled: z.boolean().default(true),
"tcp-ports": portRangeStringSchema.optional().default("*"),
"udp-ports": portRangeStringSchema.optional().default("*"),
@@ -386,11 +441,31 @@ export const ClientResourceSchema = z
error: "Admin role cannot be included in roles"
}),
users: z.array(z.string()).optional().default([]),
machines: z.array(z.string()).optional().default([])
machines: z.array(z.string()).optional().default([]),
"auth-daemon": AuthDaemonSchema.optional()
})
.refine(
(data) => {
// destination is optional only for ssh+native; required for everything else
const isNativeSSH =
data.mode === "ssh" &&
(data["auth-daemon"] === undefined ||
data["auth-daemon"].mode === "native");
if (!isNativeSSH && !data.destination) {
return false;
}
return true;
},
{
path: ["destination"],
message:
"destination is required unless mode is 'ssh' with auth-daemon mode 'native'"
}
)
.refine(
(data) => {
if (data.mode === "host") {
if (!data.destination) return true; // caught by the destination-required refine
// Check if it's a valid IP address using zod (v4 or v6)
const isValidIP = z
.union([z.ipv4(), z.ipv6()])
@@ -418,6 +493,7 @@ export const ClientResourceSchema = z
.refine(
(data) => {
if (data.mode === "cidr") {
if (!data.destination) return true; // caught by the destination-required refine
// Check if it's a valid CIDR (v4 or v6)
const isValidCIDR = z
.union([z.cidrv4(), z.cidrv6()])
@@ -435,19 +511,19 @@ export const ClientResourceSchema = z
export const ConfigSchema = z
.object({
"proxy-resources": z
.record(z.string(), ResourceSchema)
.record(z.string(), PublicResourceSchema)
.optional()
.prefault({}),
"public-resources": z
.record(z.string(), ResourceSchema)
.record(z.string(), PublicResourceSchema)
.optional()
.prefault({}),
"client-resources": z
.record(z.string(), ClientResourceSchema)
.record(z.string(), PrivateResourceSchema)
.optional()
.prefault({}),
"private-resources": z
.record(z.string(), ClientResourceSchema)
.record(z.string(), PrivateResourceSchema)
.optional()
.prefault({}),
sites: z.record(z.string(), SiteSchema).optional().prefault({})
@@ -472,10 +548,13 @@ export const ConfigSchema = z
}
return data as {
"proxy-resources": Record<string, z.infer<typeof ResourceSchema>>;
"proxy-resources": Record<
string,
z.infer<typeof PublicResourceSchema>
>;
"client-resources": Record<
string,
z.infer<typeof ClientResourceSchema>
z.infer<typeof PrivateResourceSchema>
>;
sites: Record<string, z.infer<typeof SiteSchema>>;
};
@@ -614,5 +693,5 @@ export const ConfigSchema = z
// Type inference from the schema
export type Site = z.infer<typeof SiteSchema>;
export type Target = z.infer<typeof TargetSchema>;
export type Resource = z.infer<typeof ResourceSchema>;
export type Resource = z.infer<typeof PublicResourceSchema>;
export type Config = z.infer<typeof ConfigSchema>;

View File

@@ -154,8 +154,19 @@ class AdaptiveCache {
keys(): string[] {
return localCache.keys();
}
/**
* Get keys with a specific prefix
* @param prefix - Key prefix to match
* @returns Array of matching keys
*/
async keysWithPrefix(prefix: string): Promise<string[]> {
const allKeys = localCache.keys();
return allKeys.filter((key) => key.startsWith(prefix));
}
}
// Export singleton instance
export const cache = new AdaptiveCache();
export const regionalCache = cache; // Alias for compatability with the private version
export default cache;

View File

@@ -331,16 +331,8 @@ export async function calculateUserClientsForOrgs(
];
// Get next available subnet
const newSubnet = await getNextAvailableClientSubnet(
orgId,
transaction
);
if (!newSubnet) {
logger.warn(
`Skipping org ${orgId} for OLM ${olm.olmId} (user ${userId}): no available subnet found`
);
continue;
}
const { value: newSubnet, release: releaseSubnetLock } =
await getNextAvailableClientSubnet(orgId, transaction);
const subnet = newSubnet.split("/")[0];
const updatedSubnet = `${subnet}/${org.subnet.split("/")[1]}`;
@@ -370,6 +362,7 @@ export async function calculateUserClientsForOrgs(
.insert(clients)
.values(newClientData)
.returning();
await releaseSubnetLock();
existingClientCache.set(
getOrgOlmKey(orgId, olm.olmId),
newClient

View File

@@ -2,7 +2,7 @@ import path from "path";
import { fileURLToPath } from "url";
// This is a placeholder value replaced by the build process
export const APP_VERSION = "1.18.4";
export const APP_VERSION = "1.19.0";
export const __FILENAME = fileURLToPath(import.meta.url);
export const __DIRNAME = path.dirname(__FILENAME);

View File

@@ -327,127 +327,145 @@ export function doCidrsOverlap(cidr1: string, cidr2: string): boolean {
export async function getNextAvailableClientSubnet(
orgId: string,
transaction: Transaction | typeof db = db
): Promise<string> {
return await lockManager.withLock(
`client-subnet-allocation:${orgId}`,
async () => {
const [org] = await transaction
.select()
.from(orgs)
.where(eq(orgs.orgId, orgId));
): Promise<{ value: string; release: () => Promise<void> }> {
const lockKey = `client-subnet-allocation:${orgId}`;
const acquired = await lockManager.acquireLockWithRetry(lockKey, 6000);
if (!acquired) {
throw new Error(`Failed to acquire lock: ${lockKey}`);
}
const release = () => lockManager.releaseLock(lockKey);
if (!org) {
throw new Error(`Organization with ID ${orgId} not found`);
}
try {
const [org] = await transaction
.select()
.from(orgs)
.where(eq(orgs.orgId, orgId));
if (!org.subnet) {
throw new Error(
`Organization with ID ${orgId} has no subnet defined`
);
}
const existingAddressesSites = await transaction
.select({
address: sites.address
})
.from(sites)
.where(and(isNotNull(sites.address), eq(sites.orgId, orgId)));
const existingAddressesClients = await transaction
.select({
address: clients.subnet
})
.from(clients)
.where(
and(isNotNull(clients.subnet), eq(clients.orgId, orgId))
);
const addresses = [
...existingAddressesSites.map(
(site) => `${site.address?.split("/")[0]}/32`
), // we are overriding the 32 so that we pick individual addresses in the subnet of the org for the site and the client even though they are stored with the /block_size of the org
...existingAddressesClients.map(
(client) => `${client.address.split("/")}/32`
)
].filter((address) => address !== null) as string[];
const subnet = findNextAvailableCidr(addresses, 32, org.subnet); // pick the sites address in the org
if (!subnet) {
throw new Error("No available subnets remaining in space");
}
return subnet;
if (!org) {
throw new Error(`Organization with ID ${orgId} not found`);
}
);
if (!org.subnet) {
throw new Error(
`Organization with ID ${orgId} has no subnet defined`
);
}
const existingAddressesSites = await transaction
.select({
address: sites.address
})
.from(sites)
.where(and(isNotNull(sites.address), eq(sites.orgId, orgId)));
const existingAddressesClients = await transaction
.select({
address: clients.subnet
})
.from(clients)
.where(and(isNotNull(clients.subnet), eq(clients.orgId, orgId)));
const addresses = [
...existingAddressesSites.map(
(site) => `${site.address?.split("/")[0]}/32`
), // we are overriding the 32 so that we pick individual addresses in the subnet of the org for the site and the client even though they are stored with the /block_size of the org
...existingAddressesClients.map(
(client) => `${client.address.split("/")[0]}/32`
)
].filter((address) => address !== null) as string[];
const subnet = findNextAvailableCidr(addresses, 32, org.subnet); // pick the sites address in the org
if (!subnet) {
throw new Error("No available subnets remaining in space");
}
return { value: subnet, release };
} catch (e) {
await release();
throw e;
}
}
export async function getNextAvailableAliasAddress(
orgId: string,
trx: Transaction | typeof db = db
): Promise<string> {
return await lockManager.withLock(
`alias-address-allocation:${orgId}`,
async () => {
const [org] = await trx
.select()
.from(orgs)
.where(eq(orgs.orgId, orgId));
): Promise<{ value: string; release: () => Promise<void> }> {
const lockKey = `alias-address-allocation:${orgId}`;
const acquired = await lockManager.acquireLockWithRetry(lockKey, 6000);
if (!acquired) {
throw new Error(`Failed to acquire lock: ${lockKey}`);
}
const release = () => lockManager.releaseLock(lockKey);
if (!org) {
throw new Error(`Organization with ID ${orgId} not found`);
}
try {
const [org] = await trx
.select()
.from(orgs)
.where(eq(orgs.orgId, orgId));
if (!org.subnet) {
throw new Error(
`Organization with ID ${orgId} has no subnet defined`
);
}
if (!org.utilitySubnet) {
throw new Error(
`Organization with ID ${orgId} has no utility subnet defined`
);
}
const existingAddresses = await trx
.select({
aliasAddress: siteResources.aliasAddress
})
.from(siteResources)
.where(
and(
isNotNull(siteResources.aliasAddress),
eq(siteResources.orgId, orgId)
)
);
const addresses = [
...existingAddresses.map(
(site) => `${site.aliasAddress?.split("/")[0]}/32`
),
// reserve a /29 for the dns server and other stuff
`${org.utilitySubnet.split("/")[0]}/29`
].filter((address) => address !== null) as string[];
let subnet = findNextAvailableCidr(
addresses,
32,
org.utilitySubnet
);
if (!subnet) {
throw new Error("No available subnets remaining in space");
}
// remove the cidr
subnet = subnet.split("/")[0];
return subnet;
if (!org) {
throw new Error(`Organization with ID ${orgId} not found`);
}
);
if (!org.subnet) {
throw new Error(
`Organization with ID ${orgId} has no subnet defined`
);
}
if (!org.utilitySubnet) {
throw new Error(
`Organization with ID ${orgId} has no utility subnet defined`
);
}
const existingAddresses = await trx
.select({
aliasAddress: siteResources.aliasAddress
})
.from(siteResources)
.where(
and(
isNotNull(siteResources.aliasAddress),
eq(siteResources.orgId, orgId)
)
);
const addresses = [
...existingAddresses.map(
(site) => `${site.aliasAddress?.split("/")[0]}/32`
),
// reserve a /29 for the dns server and other stuff
`${org.utilitySubnet.split("/")[0]}/29`
].filter((address) => address !== null) as string[];
let subnet = findNextAvailableCidr(addresses, 32, org.utilitySubnet);
if (!subnet) {
throw new Error("No available subnets remaining in space");
}
// remove the cidr
subnet = subnet.split("/")[0];
return { value: subnet, release };
} catch (e) {
await release();
throw e;
}
}
export async function getNextAvailableOrgSubnet(): Promise<string> {
return await lockManager.withLock("org-subnet-allocation", async () => {
export async function getNextAvailableOrgSubnet(): Promise<{
value: string;
release: () => Promise<void>;
}> {
const lockKey = "org-subnet-allocation";
const acquired = await lockManager.acquireLockWithRetry(lockKey, 6000);
if (!acquired) {
throw new Error(`Failed to acquire lock: ${lockKey}`);
}
const release = () => lockManager.releaseLock(lockKey);
try {
const existingAddresses = await db
.select({
subnet: orgs.subnet
@@ -466,8 +484,11 @@ export async function getNextAvailableOrgSubnet(): Promise<string> {
throw new Error("No available subnets remaining in space");
}
return subnet;
});
return { value: subnet, release };
} catch (e) {
await release();
throw e;
}
}
export function generateRemoteSubnets(
@@ -475,6 +496,8 @@ export function generateRemoteSubnets(
): string[] {
const remoteSubnets = allSiteResources
.filter((sr) => {
if (!sr.destination) return false;
if (sr.mode === "cidr") {
// check if its a valid CIDR using zod
const cidrSchema = z.union([z.cidrv4(), z.cidrv6()]);
@@ -496,7 +519,7 @@ export function generateRemoteSubnets(
}
return ""; // This should never be reached due to filtering, but satisfies TypeScript
})
.filter((subnet) => subnet !== ""); // Remove empty strings just to be safe
.filter((subnet): subnet is string => subnet !== "" && subnet !== null); // Remove invalid values just to be safe
// remove duplicates
return Array.from(new Set(remoteSubnets));
}
@@ -581,7 +604,7 @@ export function generateSubnetProxyTargets(
targets.push({
sourcePrefix: clientPrefix,
destPrefix: `${siteResource.aliasAddress}/32`,
rewriteTo: destination,
rewriteTo: destination!,
portRange,
disableIcmp
});
@@ -589,7 +612,7 @@ export function generateSubnetProxyTargets(
} else if (siteResource.mode == "cidr") {
targets.push({
sourcePrefix: clientPrefix,
destPrefix: siteResource.destination,
destPrefix: siteResource.destination!,
portRange,
disableIcmp
});
@@ -671,7 +694,7 @@ export async function generateSubnetProxyTargetV2(
targets.push({
sourcePrefixes: [],
destPrefix: `${siteResource.aliasAddress}/32`,
rewriteTo: destination,
rewriteTo: destination!,
portRange,
disableIcmp,
resourceId: siteResource.siteResourceId
@@ -680,7 +703,7 @@ export async function generateSubnetProxyTargetV2(
} else if (siteResource.mode == "cidr") {
targets.push({
sourcePrefixes: [],
destPrefix: siteResource.destination,
destPrefix: siteResource.destination!,
portRange,
disableIcmp,
resourceId: siteResource.siteResourceId
@@ -738,7 +761,7 @@ export async function generateSubnetProxyTargetV2(
protocol: siteResource.ssl ? "https" : "http",
httpTargets: [
{
destAddr: siteResource.destination,
destAddr: siteResource.destination!,
destPort: siteResource.destinationPort,
scheme: siteResource.scheme
}
@@ -873,7 +896,13 @@ export const portRangeStringSchema = z
message:
'Port range must be "*" for all ports, or a comma-separated list of ports and ranges (e.g., "80,443,8000-9000"). Ports must be between 1 and 65535, and ranges must have start <= end.'
}
);
)
.openapi({
type: "string",
description:
'Port range string. Use "*" for all ports, a comma-separated list of ports, or ranges (e.g., "80,443,8000-9000"). Ports must be between 1 and 65535.',
example: "80,443,8000-9000"
});
/**
* Parses a port range string into an array of port range objects

View File

@@ -0,0 +1,11 @@
import { z } from "zod";
export function createApiResponseSchema<T extends z.ZodTypeAny>(dataSchema: T) {
return z.object({
data: dataSchema.nullable(),
success: z.boolean(),
error: z.boolean(),
message: z.string(),
status: z.number()
});
}

View File

@@ -18,7 +18,7 @@ import {
userOrgRoles,
userSiteResources
} from "@server/db";
import { and, eq, inArray, ne } from "drizzle-orm";
import { and, count, eq, inArray, ne } from "drizzle-orm";
import { deletePeer as newtDeletePeer } from "@server/routers/newt/peers";
import {
@@ -39,6 +39,11 @@ import {
removePeerData,
removeTargets as removeSubnetProxyTargets
} from "@server/routers/client/targets";
import { lockManager } from "#dynamic/lib/lock";
// TTL for rebuild-association locks. These functions can fan out into many
// peer/proxy updates, so give them a generous window.
const REBUILD_ASSOCIATIONS_LOCK_TTL_MS = 120000;
export async function getClientSiteResourceAccess(
siteResource: SiteResource,
@@ -161,6 +166,23 @@ export async function rebuildClientAssociationsFromSiteResource(
pubKey: string | null;
subnet: string | null;
}[];
}> {
return await lockManager.withLock(
`rebuild-client-associations:site-resource:${siteResource.siteResourceId}`,
() => rebuildClientAssociationsFromSiteResourceImpl(siteResource, trx),
REBUILD_ASSOCIATIONS_LOCK_TTL_MS
);
}
async function rebuildClientAssociationsFromSiteResourceImpl(
siteResource: SiteResource,
trx: Transaction | typeof db = db
): Promise<{
mergedAllClients: {
clientId: number;
pubKey: string | null;
subnet: string | null;
}[];
}> {
logger.debug(
`rebuildClientAssociations: [rebuildClientAssociationsFromSiteResource] START siteResourceId=${siteResource.siteResourceId} networkId=${siteResource.networkId} orgId=${siteResource.orgId}`
@@ -539,6 +561,29 @@ async function handleMessagesForSiteClients(
}
}
// get the number of sites on each of these clients so we can log it and make decisions about whether to send messages based on it
const clientSiteCounts: Record<number, number> = {};
if (clientsToProcess.size > 0) {
const clientIdsToProcess = Array.from(clientsToProcess.keys());
const siteCounts = await trx
.select({
clientId: clientSitesAssociationsCache.clientId,
siteCount: count(clientSitesAssociationsCache.siteId)
})
.from(clientSitesAssociationsCache)
.where(
inArray(
clientSitesAssociationsCache.clientId,
clientIdsToProcess
)
)
.groupBy(clientSitesAssociationsCache.clientId);
for (const row of siteCounts) {
clientSiteCounts[row.clientId] = Number(row.siteCount);
}
}
for (const client of clientsToProcess.values()) {
// UPDATE THE NEWT
if (!client.subnet || !client.pubKey) {
@@ -582,7 +627,14 @@ async function handleMessagesForSiteClients(
}
if (isAdd) {
// TODO: if we are in jit mode here should we really be sending this?
if (clientSiteCounts[client.clientId] > 250) {
// skip adding the peer if we have more than 250 sites because we are in jit mode anyway
logger.info(
`rebuildClientAssociations: Client ${client.clientId} has ${clientSiteCounts[client.clientId]} sites so skipping adding peer to newt and olm because it is likely in jit mode`
);
continue;
}
await initPeerAddHandshake(
// this will kick off the add peer process for the client
client.clientId,
@@ -600,9 +652,24 @@ async function handleMessagesForSiteClients(
exitNodeJobs.push(updateClientSiteDestinations(client, trx));
}
await Promise.all(exitNodeJobs);
await Promise.all(newtJobs); // do the servers first to make sure they are ready?
await Promise.all(olmJobs);
Promise.all(exitNodeJobs).catch((error) => {
logger.error(
`rebuildClientAssociations: Error updating client site destinations for site ${site.siteId}:`,
error
);
});
Promise.all(newtJobs).catch((error) => {
logger.error(
`rebuildClientAssociations: Error updating Newt peers for site ${site.siteId}:`,
error
);
});
Promise.all(olmJobs).catch((error) => {
logger.error(
`rebuildClientAssociations: Error updating Olm peers for site ${site.siteId}:`,
error
);
});
}
interface PeerDestination {
@@ -823,6 +890,9 @@ async function handleSubnetProxyTargetUpdates(
}
for (const client of removedClients) {
if (!siteResource.destination) {
continue;
}
// Check if this client still has access to another resource
// on this specific site with the same destination. We scope
// by siteId (via siteNetworks) rather than networkId because
@@ -885,6 +955,17 @@ async function handleSubnetProxyTargetUpdates(
export async function rebuildClientAssociationsFromClient(
client: Client,
trx: Transaction | typeof db = db
): Promise<void> {
return await lockManager.withLock(
`rebuild-client-associations:client:${client.clientId}`,
() => rebuildClientAssociationsFromClientImpl(client, trx),
REBUILD_ASSOCIATIONS_LOCK_TTL_MS
);
}
async function rebuildClientAssociationsFromClientImpl(
client: Client,
trx: Transaction | typeof db = db
): Promise<void> {
let newSiteResourceIds: number[] = [];
@@ -1157,6 +1238,12 @@ async function handleMessagesForClientSites(
const olmJobs: Promise<any>[] = [];
const exitNodeJobs: Promise<any>[] = [];
const totalSitesOnClient = await trx
.select({ count: count(clientSitesAssociationsCache.siteId) })
.from(clientSitesAssociationsCache)
.where(eq(clientSitesAssociationsCache.clientId, client.clientId))
.then((rows) => Number(rows[0].count));
for (const siteData of sitesData) {
const site = siteData.sites;
const exitNode = siteData.exitNodes;
@@ -1217,7 +1304,14 @@ async function handleMessagesForClientSites(
continue;
}
// TODO: if we are in jit mode here should we really be sending this?
if (totalSitesOnClient > 250) {
// skip adding the site if we have more than 250 because we are in jit mode anyway
logger.info(
`rebuildClientAssociations: Client ${client.clientId} has ${totalSitesOnClient} sites so skipping adding peer to newt and olm because it is likely in jit mode`
);
continue;
}
await initPeerAddHandshake(
// this will kick off the add peer process for the client
client.clientId,
@@ -1245,9 +1339,24 @@ async function handleMessagesForClientSites(
);
}
await Promise.all(exitNodeJobs);
await Promise.all(newtJobs);
await Promise.all(olmJobs);
Promise.all(exitNodeJobs).catch((error) => {
logger.error(
`rebuildClientAssociations: Error updating client site destinations for client ${client.clientId}:`,
error
);
});
Promise.all(newtJobs).catch((error) => {
logger.error(
`rebuildClientAssociations: Error updating Newt peers for client ${client.clientId}:`,
error
);
});
Promise.all(olmJobs).catch((error) => {
logger.error(
`rebuildClientAssociations: Error updating Olm peers for client ${client.clientId}:`,
error
);
});
}
async function handleMessagesForClientResources(
@@ -1457,6 +1566,9 @@ async function handleMessagesForClientResources(
}
try {
if (!resource.destination) {
continue;
}
// Check if this client still has access to another resource
// on this specific site with the same destination. We scope
// by siteId (via siteNetworks) rather than networkId because
@@ -1528,3 +1640,269 @@ async function handleMessagesForClientResources(
await Promise.all([...proxyJobs, ...olmJobs]);
}
export type ClientAssociationsCacheVerification = {
clientId: number;
consistent: boolean;
// What permissions say the cache should contain
expectedSiteResourceIds: number[];
expectedSiteIds: number[];
// What the cache currently contains
actualSiteResourceIds: number[];
actualSiteIds: number[];
// Diff
missingSiteResourceIds: number[]; // present in expected, missing from cache
extraSiteResourceIds: number[]; // present in cache, not in expected
missingSiteIds: number[];
extraSiteIds: number[];
};
// verifyClientAssociationsCache walks the same permission-derivation logic as
// rebuildClientAssociationsFromClient but does NOT modify the database. It
// returns the expected vs actual cache contents and a boolean indicating
// whether the cache is in sync with what permissions imply.
export async function verifyClientAssociationsCache(
client: Client,
trx: Transaction | typeof db = db
): Promise<ClientAssociationsCacheVerification> {
let newSiteResourceIds: number[] = [];
// 1. Direct client associations
const directSiteResources = await trx
.select({ siteResourceId: clientSiteResources.siteResourceId })
.from(clientSiteResources)
.innerJoin(
siteResources,
eq(siteResources.siteResourceId, clientSiteResources.siteResourceId)
)
.where(
and(
eq(clientSiteResources.clientId, client.clientId),
eq(siteResources.orgId, client.orgId)
)
);
newSiteResourceIds.push(
...directSiteResources.map((r) => r.siteResourceId)
);
// 2. User-based and role-based access (if client has a userId)
if (client.userId) {
const userSiteResourceIds = await trx
.select({ siteResourceId: userSiteResources.siteResourceId })
.from(userSiteResources)
.innerJoin(
siteResources,
eq(
siteResources.siteResourceId,
userSiteResources.siteResourceId
)
)
.where(
and(
eq(userSiteResources.userId, client.userId),
eq(siteResources.orgId, client.orgId)
)
);
newSiteResourceIds.push(
...userSiteResourceIds.map((r) => r.siteResourceId)
);
const roleIds = await trx
.select({ roleId: userOrgRoles.roleId })
.from(userOrgRoles)
.where(
and(
eq(userOrgRoles.userId, client.userId),
eq(userOrgRoles.orgId, client.orgId)
)
)
.then((rows) => rows.map((row) => row.roleId));
if (roleIds.length > 0) {
const roleSiteResourceIds = await trx
.select({ siteResourceId: roleSiteResources.siteResourceId })
.from(roleSiteResources)
.innerJoin(
siteResources,
eq(
siteResources.siteResourceId,
roleSiteResources.siteResourceId
)
)
.where(
and(
inArray(roleSiteResources.roleId, roleIds),
eq(siteResources.orgId, client.orgId)
)
);
newSiteResourceIds.push(
...roleSiteResourceIds.map((r) => r.siteResourceId)
);
}
}
newSiteResourceIds = Array.from(new Set(newSiteResourceIds));
const newSiteResources =
newSiteResourceIds.length > 0
? await trx
.select()
.from(siteResources)
.where(
inArray(siteResources.siteResourceId, newSiteResourceIds)
)
: [];
const networkIds = Array.from(
new Set(
newSiteResources
.map((sr) => sr.networkId)
.filter((id): id is number => id !== null)
)
);
const newSiteIds =
networkIds.length > 0
? await trx
.select({ siteId: siteNetworks.siteId })
.from(siteNetworks)
.where(inArray(siteNetworks.networkId, networkIds))
.then((rows) =>
Array.from(new Set(rows.map((r) => r.siteId)))
)
: [];
// Read the existing cache state
const existingResourceAssociations = await trx
.select({
siteResourceId: clientSiteResourcesAssociationsCache.siteResourceId
})
.from(clientSiteResourcesAssociationsCache)
.where(
eq(clientSiteResourcesAssociationsCache.clientId, client.clientId)
);
const existingSiteResourceIds = existingResourceAssociations.map(
(r) => r.siteResourceId
);
const existingSiteAssociations = await trx
.select({ siteId: clientSitesAssociationsCache.siteId })
.from(clientSitesAssociationsCache)
.where(eq(clientSitesAssociationsCache.clientId, client.clientId));
const existingSiteIds = existingSiteAssociations.map((s) => s.siteId);
const expectedSiteResourceSet = new Set(newSiteResourceIds);
const actualSiteResourceSet = new Set(existingSiteResourceIds);
const expectedSiteSet = new Set(newSiteIds);
const actualSiteSet = new Set(existingSiteIds);
const missingSiteResourceIds = newSiteResourceIds.filter(
(id) => !actualSiteResourceSet.has(id)
);
const extraSiteResourceIds = existingSiteResourceIds.filter(
(id) => !expectedSiteResourceSet.has(id)
);
const missingSiteIds = newSiteIds.filter((id) => !actualSiteSet.has(id));
const extraSiteIds = existingSiteIds.filter(
(id) => !expectedSiteSet.has(id)
);
const consistent =
missingSiteResourceIds.length === 0 &&
extraSiteResourceIds.length === 0 &&
missingSiteIds.length === 0 &&
extraSiteIds.length === 0;
return {
clientId: client.clientId,
consistent,
expectedSiteResourceIds: Array.from(expectedSiteResourceSet).sort(
(a, b) => a - b
),
expectedSiteIds: Array.from(expectedSiteSet).sort((a, b) => a - b),
actualSiteResourceIds: Array.from(actualSiteResourceSet).sort(
(a, b) => a - b
),
actualSiteIds: Array.from(actualSiteSet).sort((a, b) => a - b),
missingSiteResourceIds: missingSiteResourceIds.sort((a, b) => a - b),
extraSiteResourceIds: extraSiteResourceIds.sort((a, b) => a - b),
missingSiteIds: missingSiteIds.sort((a, b) => a - b),
extraSiteIds: extraSiteIds.sort((a, b) => a - b)
};
}
// cleanupSiteAssociations efficiently removes all client associations for a
// site that is being deleted. Instead of calling
// rebuildClientAssociationsFromSiteResource once per site resource (which is
// O(resources) in DB round-trips and message fan-out), this function performs
// a single bulk lookup of affected clients and site resources, deletes all
// cache rows at once, and fires all peer/proxy removal messages in parallel.
//
// The caller is responsible for deleting the site row itself (and for sending
// the newt/wg/terminate signal to the newt process).
export async function cleanupSiteAssociations(
site: Site,
trx: Transaction | typeof db = db
): Promise<void> {
const siteId = site.siteId;
logger.debug(`cleanupSiteAssociations: START siteId=${siteId}`);
// 1. Find every client currently cached against this site.
const cachedSiteClientRows = await trx
.select({ clientId: clientSitesAssociationsCache.clientId })
.from(clientSitesAssociationsCache)
.where(eq(clientSitesAssociationsCache.siteId, siteId));
const cachedClientIds = cachedSiteClientRows.map((r) => r.clientId);
// 2. Load full client details (needed for WireGuard public-key references).
const allClients =
cachedClientIds.length > 0
? await trx
.select({
clientId: clients.clientId,
pubKey: clients.pubKey,
subnet: clients.subnet
})
.from(clients)
.where(inArray(clients.clientId, cachedClientIds))
: [];
// 6. Bulk-delete all cache entries for this site. Do this before sending
// destination-update messages so updateClientSiteDestinations computes
// the correct (post-deletion) set of destinations.
await trx
.delete(clientSitesAssociationsCache)
.where(eq(clientSitesAssociationsCache.siteId, siteId));
logger.debug(
`cleanupSiteAssociations: siteId=${siteId} cache cleared. clients=${allClients.length}`
);
// 7. Fire all removal messages in parallel.
const jobs: Promise<any>[] = [];
for (const client of allClients) {
// Tell each olm to drop the site's WireGuard peer.
if (site.publicKey) {
jobs.push(olmDeletePeer(client.clientId, siteId, site.publicKey));
}
// Recompute and push updated relay destinations (now excluding this site).
if (client.pubKey && client.subnet) {
jobs.push(updateClientSiteDestinations(client, trx));
}
}
await Promise.all(jobs).catch((error) => {
logger.error(
`cleanupSiteAssociations: error sending cleanup messages for siteId=${siteId}:`,
error
);
});
logger.debug(`cleanupSiteAssociations: DONE siteId=${siteId}`);
}

View File

@@ -0,0 +1,11 @@
export function getFirstString(value: unknown): string | undefined {
if (typeof value === "string") {
return value;
}
if (Array.isArray(value) && typeof value[0] === "string") {
return value[0];
}
return undefined;
}

View File

@@ -1,7 +1,7 @@
import { z } from "zod";
import { db, logsDb, statusHistory } from "@server/db";
import { and, eq, gte, asc } from "drizzle-orm";
import cache from "@server/lib/cache";
import { regionalCache as cache } from "#dynamic/lib/cache";
const STATUS_HISTORY_CACHE_TTL = 60; // seconds
@@ -66,7 +66,7 @@ export async function invalidateStatusHistoryCache(
entityId: number
): Promise<void> {
const prefix = `statusHistory:${entityType}:${entityId}:`;
const keys = cache.keys().filter((k) => k.startsWith(prefix));
const keys = await cache.keysWithPrefix(prefix);
if (keys.length > 0) {
await cache.del(keys);
}

View File

@@ -2,7 +2,14 @@ import { PostHog } from "posthog-node";
import config from "./config";
import { getHostMeta } from "./hostMeta";
import logger from "@server/logger";
import { alertRules, apiKeys, blueprints, db, roles, siteResources } from "@server/db";
import {
alertRules,
apiKeys,
blueprints,
db,
roles,
siteResources
} from "@server/db";
import { sites, users, orgs, resources, clients, idp } from "@server/db";
import { eq, count, notInArray, and, isNotNull, isNull } from "drizzle-orm";
import { APP_VERSION } from "./consts";
@@ -143,8 +150,7 @@ class TelemetryClient {
.select({
name: resources.name,
sso: resources.sso,
protocol: resources.protocol,
http: resources.http
mode: resources.mode
})
.from(resources);
@@ -311,7 +317,7 @@ class TelemetryClient {
(r) => r.sso
).length,
num_resources_non_http: stats.resources.filter(
(r) => !r.http
(r) => r.mode !== "http"
).length,
num_newt_sites: stats.sites.filter((s) => s.type === "newt")
.length,

View File

@@ -520,7 +520,8 @@ export class TraefikConfigManager {
build != "oss", // generate the login pages on the cloud and hybrid,
build == "saas"
? 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
build != "oss" // generate browser gateway targets on cloud and enterprise
);
const domains = new Set<string>();

View File

@@ -55,9 +55,7 @@ export async function getTraefikConfig(
resourceName: resources.name,
fullDomain: resources.fullDomain,
ssl: resources.ssl,
http: resources.http,
proxyPort: resources.proxyPort,
protocol: resources.protocol,
subdomain: resources.subdomain,
domainId: resources.domainId,
enabled: resources.enabled,
@@ -68,6 +66,7 @@ export async function getTraefikConfig(
headers: resources.headers,
proxyProtocol: resources.proxyProtocol,
proxyProtocolVersion: resources.proxyProtocolVersion,
mode: resources.mode,
// Target fields
targetId: targets.targetId,
@@ -115,8 +114,8 @@ export async function getTraefikConfig(
),
inArray(sites.type, siteTypes),
allowRawResources
? isNotNull(resources.http) // ignore the http check if allow_raw_resources is true
: eq(resources.http, true)
? inArray(resources.mode, ["http", "udp", "tcp"]) // allow all three
: eq(resources.mode, "http")
)
)
.orderBy(desc(targets.priority), targets.targetId); // stable ordering
@@ -166,9 +165,8 @@ export async function getTraefikConfig(
key: key,
fullDomain: row.fullDomain,
ssl: row.ssl,
http: row.http,
mode: row.mode,
proxyPort: row.proxyPort,
protocol: row.protocol,
subdomain: row.subdomain,
domainId: row.domainId,
enabled: row.enabled,
@@ -580,7 +578,7 @@ export async function getTraefikConfig(
continue;
}
const protocol = resource.protocol.toLowerCase();
const protocol = resource.mode === "udp" ? "udp" : "tcp"; // all of the other ones are tcp
const port = resource.proxyPort;
if (!port) {

View File

@@ -244,4 +244,5 @@ try {
runTests();
} catch (error) {
console.error("Test failed:", error);
process.exit(1);
}

View File

@@ -28,7 +28,9 @@ export * from "./verifyApiKeyAccess";
export * from "./verifySiteProvisioningKeyAccess";
export * from "./verifyDomainAccess";
export * from "./verifyUserIsOrgOwner";
export * from "./verifyUserFromResourceSession";
export * from "./verifySiteResourceAccess";
export * from "./logActionAudit";
export * from "./verifyOlmAccess";
export * from "./verifyLimits";
export * from "./verifyResourcePolicyAccess";

View File

@@ -16,3 +16,4 @@ export * from "./verifyApiKeyClientAccess";
export * from "./verifyApiKeySiteResourceAccess";
export * from "./verifyApiKeyIdpAccess";
export * from "./verifyApiKeyDomainAccess";
export * from "./verifyApiKeyResourcePolicyAccess";

View File

@@ -4,6 +4,7 @@ import { resourceAccessToken, resources, 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 verifyApiKeyAccessTokenAccess(
req: Request,
@@ -12,7 +13,7 @@ export async function verifyApiKeyAccessTokenAccess(
) {
try {
const apiKey = req.apiKey;
const accessTokenId = req.params.accessTokenId;
const accessTokenId = getFirstString(req.params.accessTokenId);
if (!apiKey) {
return next(
@@ -20,6 +21,12 @@ export async function verifyApiKeyAccessTokenAccess(
);
}
if (!accessTokenId) {
return next(
createHttpError(HttpCode.BAD_REQUEST, "Invalid access token ID")
);
}
const [accessToken] = await db
.select()
.from(resourceAccessToken)

View File

@@ -4,6 +4,7 @@ import { apiKeys, apiKeyOrg } from "@server/db";
import { and, eq, or } from "drizzle-orm";
import createHttpError from "http-errors";
import HttpCode from "@server/types/HttpCode";
import { getFirstString } from "@server/lib/requestParams";
export async function verifyApiKeyApiKeyAccess(
req: Request,
@@ -14,8 +15,10 @@ export async function verifyApiKeyApiKeyAccess(
const { apiKey: callerApiKey } = req;
const apiKeyId =
req.params.apiKeyId || req.body.apiKeyId || req.query.apiKeyId;
const orgId = req.params.orgId;
getFirstString(req.params.apiKeyId) ||
getFirstString(req.body.apiKeyId) ||
getFirstString(req.query.apiKeyId);
const orgId = getFirstString(req.params.orgId);
if (!callerApiKey) {
return next(

View File

@@ -3,6 +3,7 @@ import { db, domains, orgDomains, 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 verifyApiKeyDomainAccess(
req: Request,
@@ -12,8 +13,10 @@ export async function verifyApiKeyDomainAccess(
try {
const apiKey = req.apiKey;
const domainId =
req.params.domainId || req.body.domainId || req.query.domainId;
const orgId = req.params.orgId;
getFirstString(req.params.domainId) ||
getFirstString(req.body.domainId) ||
getFirstString(req.query.domainId);
const orgId = getFirstString(req.params.orgId);
if (!apiKey) {
return next(
@@ -27,6 +30,12 @@ export async function verifyApiKeyDomainAccess(
);
}
if (!orgId) {
return next(
createHttpError(HttpCode.BAD_REQUEST, "Invalid organization ID")
);
}
if (apiKey.isRoot) {
// Root keys can access any domain in any org
return next();

View File

@@ -4,6 +4,7 @@ import { idp, idpOrg, 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 verifyApiKeyIdpAccess(
req: Request,
@@ -12,8 +13,12 @@ export async function verifyApiKeyIdpAccess(
) {
try {
const apiKey = req.apiKey;
const idpId = req.params.idpId || req.body.idpId || req.query.idpId;
const orgId = req.params.orgId;
const idpIdRaw =
getFirstString(req.params.idpId) ||
getFirstString(req.body.idpId) ||
getFirstString(req.query.idpId);
const idpId = Number.parseInt(idpIdRaw ?? "", 10);
const orgId = getFirstString(req.params.orgId);
if (!apiKey) {
return next(
@@ -27,7 +32,7 @@ export async function verifyApiKeyIdpAccess(
);
}
if (!idpId) {
if (Number.isNaN(idpId)) {
return next(
createHttpError(HttpCode.BAD_REQUEST, "Invalid IDP ID")
);

View File

@@ -4,6 +4,7 @@ import { 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 verifyApiKeyOrgAccess(
req: Request,
@@ -12,7 +13,7 @@ export async function verifyApiKeyOrgAccess(
) {
try {
const apiKeyId = req.apiKey?.apiKeyId;
const orgId = req.params.orgId;
const orgId = getFirstString(req.params.orgId);
if (!apiKeyId) {
return next(
@@ -45,7 +46,7 @@ export async function verifyApiKeyOrgAccess(
}
if (!req.apiKeyOrg) {
next(
return next(
createHttpError(
HttpCode.FORBIDDEN,
"Key does not have access to this organization"

View File

@@ -0,0 +1,92 @@
import { Request, Response, NextFunction } from "express";
import { db } from "@server/db";
import { resourcePolicies, apiKeyOrg } from "@server/db";
import { eq, and } from "drizzle-orm";
import createHttpError from "http-errors";
import HttpCode from "@server/types/HttpCode";
export async function verifyApiKeyResourcePolicyAccess(
req: Request,
res: Response,
next: NextFunction
) {
const apiKey = req.apiKey;
const resourcePolicyId =
req.params.resourcePolicyId ||
req.body.resourcePolicyId ||
req.query.resourcePolicyId;
if (!apiKey) {
return next(
createHttpError(HttpCode.UNAUTHORIZED, "Key not authenticated")
);
}
try {
// Retrieve the resource policy
const [policy] = await db
.select()
.from(resourcePolicies)
.where(eq(resourcePolicies.resourcePolicyId, resourcePolicyId))
.limit(1);
if (!policy) {
return next(
createHttpError(
HttpCode.NOT_FOUND,
`Resource policy with ID ${resourcePolicyId} not found`
)
);
}
if (apiKey.isRoot) {
// Root keys can access any resource policy in any org
return next();
}
if (!policy.orgId) {
return next(
createHttpError(
HttpCode.INTERNAL_SERVER_ERROR,
`Resource policy with ID ${resourcePolicyId} does not have an organization ID`
)
);
}
// Verify that the API key is linked to the resource policy's organization
if (!req.apiKeyOrg) {
const apiKeyOrgResult = await db
.select()
.from(apiKeyOrg)
.where(
and(
eq(apiKeyOrg.apiKeyId, apiKey.apiKeyId),
eq(apiKeyOrg.orgId, policy.orgId)
)
)
.limit(1);
if (apiKeyOrgResult.length > 0) {
req.apiKeyOrg = apiKeyOrgResult[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 resource policy access"
)
);
}
}

View File

@@ -4,6 +4,7 @@ import { siteResources, 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 verifyApiKeySiteResourceAccess(
req: Request,
@@ -12,7 +13,8 @@ export async function verifyApiKeySiteResourceAccess(
) {
try {
const apiKey = req.apiKey;
const siteResourceId = parseInt(req.params.siteResourceId);
const siteResourceIdRaw = getFirstString(req.params.siteResourceId);
const siteResourceId = Number.parseInt(siteResourceIdRaw ?? "", 10);
if (!apiKey) {
return next(
@@ -20,7 +22,7 @@ export async function verifyApiKeySiteResourceAccess(
);
}
if (!siteResourceId) {
if (Number.isNaN(siteResourceId)) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,

View File

@@ -4,6 +4,7 @@ import { resources, targets, 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 verifyApiKeyTargetAccess(
req: Request,
@@ -12,7 +13,8 @@ export async function verifyApiKeyTargetAccess(
) {
try {
const apiKey = req.apiKey;
const targetId = parseInt(req.params.targetId);
const targetIdRaw = getFirstString(req.params.targetId);
const targetId = Number.parseInt(targetIdRaw ?? "", 10);
if (!apiKey) {
return next(
@@ -20,7 +22,7 @@ export async function verifyApiKeyTargetAccess(
);
}
if (isNaN(targetId)) {
if (Number.isNaN(targetId)) {
return next(
createHttpError(HttpCode.BAD_REQUEST, "Invalid target ID")
);

View File

@@ -7,6 +7,7 @@ import HttpCode from "@server/types/HttpCode";
import { canUserAccessResource } from "@server/auth/canUserAccessResource";
import { checkOrgAccessPolicy } from "#dynamic/lib/checkOrgAccessPolicy";
import { getUserOrgRoleIds } from "@server/lib/userOrgRoles";
import { getFirstString } from "@server/lib/requestParams";
export async function verifyAccessTokenAccess(
req: Request,
@@ -14,7 +15,7 @@ export async function verifyAccessTokenAccess(
next: NextFunction
) {
const userId = req.user!.userId;
const accessTokenId = req.params.accessTokenId;
const accessTokenId = getFirstString(req.params.accessTokenId);
if (!userId) {
return next(
@@ -22,6 +23,12 @@ export async function verifyAccessTokenAccess(
);
}
if (!accessTokenId) {
return next(
createHttpError(HttpCode.BAD_REQUEST, "Invalid access token ID")
);
}
const [accessToken] = await db
.select()
.from(resourceAccessToken)
@@ -87,7 +94,7 @@ export async function verifyAccessTokenAccess(
}
if (!req.userOrg) {
next(
return next(
createHttpError(
HttpCode.FORBIDDEN,
"User does not have access to this organization"

View File

@@ -6,6 +6,7 @@ import createHttpError from "http-errors";
import HttpCode from "@server/types/HttpCode";
import { checkOrgAccessPolicy } from "#dynamic/lib/checkOrgAccessPolicy";
import { getUserOrgRoleIds } from "@server/lib/userOrgRoles";
import { getFirstString } from "@server/lib/requestParams";
export async function verifyApiKeyAccess(
req: Request,
@@ -14,9 +15,24 @@ export async function verifyApiKeyAccess(
) {
try {
const userId = req.user!.userId;
const apiKeyId =
req.params.apiKeyId || req.body.apiKeyId || req.query.apiKeyId;
const orgId = req.params.orgId;
const apiKeyIdFromParams = getFirstString(req.params?.apiKeyId);
const apiKeyIdFromBody = getFirstString(req.body?.apiKeyId);
if (
apiKeyIdFromParams &&
apiKeyIdFromBody &&
apiKeyIdFromParams !== apiKeyIdFromBody
) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
"API key ID provided in both URL and body with different values"
)
);
}
const apiKeyId = apiKeyIdFromParams || apiKeyIdFromBody;
const orgId = getFirstString(req.params.orgId);
if (!userId) {
return next(
@@ -104,10 +120,7 @@ export async function verifyApiKeyAccess(
}
}
req.userOrgRoleIds = await getUserOrgRoleIds(
req.userOrg.userId,
orgId
);
req.userOrgRoleIds = await getUserOrgRoleIds(req.userOrg.userId, orgId);
return next();
} catch (error) {

View File

@@ -6,6 +6,7 @@ import createHttpError from "http-errors";
import HttpCode from "@server/types/HttpCode";
import { checkOrgAccessPolicy } from "#dynamic/lib/checkOrgAccessPolicy";
import { getUserOrgRoleIds } from "@server/lib/userOrgRoles";
import { getFirstString } from "@server/lib/requestParams";
export async function verifyDomainAccess(
req: Request,
@@ -14,9 +15,8 @@ export async function verifyDomainAccess(
) {
try {
const userId = req.user!.userId;
const domainId =
req.params.domainId;
const orgId = req.params.orgId;
const domainId = getFirstString(req.params.domainId);
const orgId = getFirstString(req.params.orgId);
if (!userId) {
return next(
@@ -62,10 +62,7 @@ export async function verifyDomainAccess(
.select()
.from(userOrgs)
.where(
and(
eq(userOrgs.userId, userId),
eq(userOrgs.orgId, orgId)
)
and(eq(userOrgs.userId, userId), eq(userOrgs.orgId, orgId))
)
.limit(1);
req.userOrg = userOrgRole[0];

View File

@@ -3,6 +3,7 @@ import createHttpError from "http-errors";
import HttpCode from "@server/types/HttpCode";
import { usageService } from "@server/lib/billing/usageService";
import { build } from "@server/build";
import { getFirstString } from "@server/lib/requestParams";
export async function verifyLimits(
req: Request,
@@ -13,7 +14,10 @@ export async function verifyLimits(
return next();
}
const orgId = req.userOrgId || req.apiKeyOrg?.orgId || req.params.orgId;
const orgId =
req.userOrgId ||
req.apiKeyOrg?.orgId ||
getFirstString(req.params.orgId);
if (!orgId) {
return next(); // its fine if we silently fail here because this is not critical to operation or security and its better user experience if we dont fail

View File

@@ -6,6 +6,7 @@ import createHttpError from "http-errors";
import HttpCode from "@server/types/HttpCode";
import { checkOrgAccessPolicy } from "#dynamic/lib/checkOrgAccessPolicy";
import { getUserOrgRoleIds } from "@server/lib/userOrgRoles";
import { getFirstString } from "@server/lib/requestParams";
export async function verifyOrgAccess(
req: Request,
@@ -13,7 +14,7 @@ export async function verifyOrgAccess(
next: NextFunction
) {
const userId = req.user!.userId;
const orgId = req.params.orgId;
const orgId = getFirstString(req.params.orgId);
if (!userId) {
return next(

View File

@@ -0,0 +1,127 @@
import { Request, Response, NextFunction } from "express";
import { db } from "@server/db";
import { resourcePolicies, userOrgs } from "@server/db";
import { and, eq } from "drizzle-orm";
import createHttpError from "http-errors";
import HttpCode from "@server/types/HttpCode";
import { checkOrgAccessPolicy } from "#dynamic/lib/checkOrgAccessPolicy";
import { getUserOrgRoleIds } from "@server/lib/userOrgRoles";
export async function verifyResourcePolicyAccess(
req: Request,
res: Response,
next: NextFunction
) {
const userId = req.user!.userId;
const resourcePolicyIdStr =
req.params?.resourcePolicyId ||
req.body?.resourcePolicyId ||
req.query?.resourcePolicyId;
const niceId = req.params?.niceId || req.body?.niceId || req.query?.niceId;
const orgId = req.params?.orgId || req.body?.orgId || req.query?.orgId;
try {
if (!userId) {
return next(
createHttpError(HttpCode.UNAUTHORIZED, "User not authenticated")
);
}
let policy: typeof resourcePolicies.$inferSelect | null = null;
if (orgId && niceId) {
const [policyRes] = await db
.select()
.from(resourcePolicies)
.where(
and(
eq(resourcePolicies.niceId, niceId),
eq(resourcePolicies.orgId, orgId)
)
)
.limit(1);
policy = policyRes ?? null;
} else {
const resourcePolicyId = parseInt(resourcePolicyIdStr);
if (isNaN(resourcePolicyId)) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
"Invalid resource policy ID"
)
);
}
const [policyRes] = await db
.select()
.from(resourcePolicies)
.where(eq(resourcePolicies.resourcePolicyId, resourcePolicyId))
.limit(1);
policy = policyRes ?? null;
}
if (!policy) {
return next(
createHttpError(
HttpCode.NOT_FOUND,
`Resource policy with ID ${resourcePolicyIdStr ?? niceId} not found`
)
);
}
if (!req.userOrg) {
const userOrgRes = await db
.select()
.from(userOrgs)
.where(
and(
eq(userOrgs.userId, userId),
eq(userOrgs.orgId, policy.orgId)
)
)
.limit(1);
req.userOrg = userOrgRes[0];
}
if (!req.userOrg || req.userOrg.orgId !== policy.orgId) {
return next(
createHttpError(
HttpCode.FORBIDDEN,
"User does not have access to this organization"
)
);
}
if (req.orgPolicyAllowed === undefined && req.userOrg.orgId) {
const policyCheck = await checkOrgAccessPolicy({
orgId: req.userOrg.orgId,
userId,
session: req.session
});
req.orgPolicyAllowed = policyCheck.allowed;
if (!policyCheck.allowed || policyCheck.error) {
return next(
createHttpError(
HttpCode.FORBIDDEN,
"Failed organization access policy check: " +
(policyCheck.error || "Unknown error")
)
);
}
}
req.userOrgRoleIds = await getUserOrgRoleIds(
req.userOrg.userId,
policy.orgId
);
req.userOrgId = policy.orgId;
return next();
} catch (error) {
return next(
createHttpError(
HttpCode.INTERNAL_SERVER_ERROR,
"Error verifying resource policy access"
)
);
}
}

View File

@@ -1,10 +1,16 @@
import { Request, Response, NextFunction } from "express";
import { db, userOrgs, siteProvisioningKeys, siteProvisioningKeyOrg } from "@server/db";
import {
db,
userOrgs,
siteProvisioningKeys,
siteProvisioningKeyOrg
} from "@server/db";
import { and, eq } from "drizzle-orm";
import createHttpError from "http-errors";
import HttpCode from "@server/types/HttpCode";
import { checkOrgAccessPolicy } from "#dynamic/lib/checkOrgAccessPolicy";
import { getUserOrgRoleIds } from "@server/lib/userOrgRoles";
import { getFirstString } from "@server/lib/requestParams";
export async function verifySiteProvisioningKeyAccess(
req: Request,
@@ -13,8 +19,10 @@ export async function verifySiteProvisioningKeyAccess(
) {
try {
const userId = req.user!.userId;
const siteProvisioningKeyId = req.params.siteProvisioningKeyId;
const orgId = req.params.orgId;
const siteProvisioningKeyId = getFirstString(
req.params.siteProvisioningKeyId
);
const orgId = getFirstString(req.params.orgId);
if (!userId) {
return next(
@@ -80,10 +88,7 @@ export async function verifySiteProvisioningKeyAccess(
.where(
and(
eq(userOrgs.userId, userId),
eq(
userOrgs.orgId,
row.siteProvisioningKeyOrg.orgId
)
eq(userOrgs.orgId, row.siteProvisioningKeyOrg.orgId)
)
)
.limit(1);

View File

@@ -7,6 +7,7 @@ import HttpCode from "@server/types/HttpCode";
import { canUserAccessResource } from "../auth/canUserAccessResource";
import { checkOrgAccessPolicy } from "#dynamic/lib/checkOrgAccessPolicy";
import { getUserOrgRoleIds } from "@server/lib/userOrgRoles";
import { getFirstString } from "@server/lib/requestParams";
export async function verifyTargetAccess(
req: Request,
@@ -14,7 +15,8 @@ export async function verifyTargetAccess(
next: NextFunction
) {
const userId = req.user!.userId;
const targetId = parseInt(req.params.targetId);
const targetIdRaw = getFirstString(req.params.targetId);
const targetId = Number.parseInt(targetIdRaw ?? "", 10);
if (!userId) {
return next(

View File

@@ -38,7 +38,7 @@ export function verifyUserCanSetUserOrgRoles() {
return next(
createHttpError(
HttpCode.FORBIDDEN,
"User does not have permission perform this action"
"User does not have permission to set user organization roles"
)
);
} catch (error) {

View File

@@ -0,0 +1,102 @@
import { Response, NextFunction } from "express";
import { db } from "@server/db";
import { resourceSessions, users } from "@server/db";
import { eq } from "drizzle-orm";
import createHttpError from "http-errors";
import HttpCode from "@server/types/HttpCode";
import { getUserOrgRoleIds } from "@server/lib/userOrgRoles";
import { getUserSessionWithUser } from "@server/db/queries/verifySessionQueries";
import { encodeHexLowerCase } from "@oslojs/encoding";
import { sha256 } from "@oslojs/crypto/sha2";
import config from "@server/lib/config";
import logger from "@server/logger";
export const verifyUserFromResourceSessionMiddleware = async (
req: any,
res: Response,
next: NextFunction
) => {
if (!req.user) {
const sessionCookieName =
config.getRawConfig().server.session_cookie_name;
// Collect all resource session cookies (format: {name}[_s].{timestamp}=token)
const cookieHeader: string | undefined = req.headers.cookie;
const candidates: { timestamp: number; token: string }[] = [];
if (cookieHeader) {
for (const part of cookieHeader.split(";")) {
const trimmed = part.trim();
const eqIdx = trimmed.indexOf("=");
if (eqIdx === -1) continue;
const cookieName = trimmed.slice(0, eqIdx).trim();
const cookieValue = trimmed.slice(eqIdx + 1).trim();
// Match both secure (_s.timestamp) and non-secure (.timestamp) variants
const securePrefix = `${sessionCookieName}_s.`;
const httpPrefix = `${sessionCookieName}.`;
let timestampStr: string | null = null;
if (cookieName.startsWith(securePrefix)) {
timestampStr = cookieName.slice(securePrefix.length);
} else if (cookieName.startsWith(httpPrefix)) {
timestampStr = cookieName.slice(httpPrefix.length);
}
if (timestampStr !== null && /^\d+$/.test(timestampStr)) {
candidates.push({
timestamp: parseInt(timestampStr, 10),
token: cookieValue
});
}
}
}
// Pick the most recently issued session (highest timestamp)
candidates.sort((a, b) => b.timestamp - a.timestamp);
const best = candidates[0];
if (best) {
try {
const sessionId = encodeHexLowerCase(
sha256(new TextEncoder().encode(best.token))
);
const [resourceSession] = await db
.select()
.from(resourceSessions)
.where(eq(resourceSessions.sessionId, sessionId))
.limit(1);
if (resourceSession && Date.now() < resourceSession.expiresAt) {
if (resourceSession.userSessionId) {
const result = await getUserSessionWithUser(
resourceSession.userSessionId
);
if (result?.user && result?.session) {
req.user = result.user;
req.session = result.session;
}
}
}
} catch (e) {
logger.error(
"verifyUserFromResourceSessionMiddleware: failed to validate resource session",
e
);
}
}
}
// Populate userOrgRoleIds if an orgId is available in route params
if (req.user && req.params?.orgId && !req.userOrgRoleIds) {
req.userOrgRoleIds = await getUserOrgRoleIds(
req.user.userId,
req.params.orgId
);
}
next();
};

View File

@@ -4,6 +4,7 @@ import { userOrgs } 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 verifyUserIsOrgOwner(
req: Request,
@@ -11,7 +12,7 @@ export async function verifyUserIsOrgOwner(
next: NextFunction
) {
const userId = req.user!.userId;
const orgId = req.params.orgId;
const orgId = getFirstString(req.params.orgId);
if (!userId) {
return next(

View File

@@ -7,6 +7,7 @@ export enum OpenAPITags {
Org = "Organization",
PublicResource = "Public Resource",
PrivateResource = "Private Resource",
Policy = "Policy",
Role = "Role",
User = "User",
Invitation = "User Invitation",

View File

@@ -780,9 +780,9 @@ async function syncAcmeCerts(acmeJsonPath: string): Promise<void> {
}
}
logger.debug(
`acmeCertSync: cert for ${mainDomain} covers ${allDomains.size} domain(s): ${[...allDomains].join(", ")}`
);
// logger.debug(
// `acmeCertSync: cert for ${mainDomain} covers ${allDomains.size} domain(s): ${[...allDomains].join(", ")}`
// );
for (const domain of allDomains) {
try {
@@ -863,9 +863,9 @@ export function initAcmeCertSync(): void {
);
return;
}
logger.debug(
`acmeCertSync: found ${files.length} acme.json file(s) in directory "${acmeJsonPath}"`
);
// logger.debug(
// `acmeCertSync: found ${files.length} acme.json file(s) in directory "${acmeJsonPath}"`
// );
for (const file of files) {
syncAcmeCerts(file).catch((err) => {
logger.error(

View File

@@ -13,7 +13,7 @@
import NodeCache from "node-cache";
import logger from "@server/logger";
import { redisManager } from "@server/private/lib/redis";
import { redisManager, regionalRedisManager } from "@server/private/lib/redis";
// Create local cache with maxKeys limit to prevent memory leaks
// With ~10k requests/day and 5min TTL, 10k keys should be more than sufficient
@@ -298,3 +298,147 @@ class AdaptiveCache {
// Export singleton instance
export const cache = new AdaptiveCache();
export default cache;
/**
* Regional adaptive cache backed by the in-cluster Redis instance.
* Falls back to a local NodeCache when the regional Redis is unavailable.
* Use this for data that is regional in nature (e.g. status history) so
* reads are served from the same cluster the user is hitting.
*/
const regionalLocalCache = new NodeCache({
stdTTL: 3600,
checkperiod: 120,
maxKeys: 10000
});
class RegionalAdaptiveCache {
private useRedis(): boolean {
return (
regionalRedisManager.isRedisEnabled() &&
regionalRedisManager.getHealthStatus().isHealthy
);
}
async set(key: string, value: any, ttl?: number): Promise<boolean> {
const effectiveTtl = ttl === 0 ? undefined : ttl;
const redisTtl = ttl === 0 ? undefined : (ttl ?? 3600);
if (this.useRedis()) {
try {
const serialized = JSON.stringify(value);
const success = await regionalRedisManager.set(
key,
serialized,
redisTtl
);
if (success) {
logger.debug(`[regional] Set key in Redis: ${key}`);
return true;
}
} catch (error) {
logger.error(
`[regional] Redis set error for key ${key}:`,
error
);
}
}
const success = regionalLocalCache.set(key, value, effectiveTtl || 0);
if (success) logger.debug(`[regional] Set key in local cache: ${key}`);
return success;
}
async get<T = any>(key: string): Promise<T | undefined> {
if (this.useRedis()) {
try {
const value = await regionalRedisManager.get(key);
if (value !== null) {
logger.debug(`[regional] Cache hit in Redis: ${key}`);
return JSON.parse(value) as T;
}
logger.debug(`[regional] Cache miss in Redis: ${key}`);
return undefined;
} catch (error) {
logger.error(
`[regional] Redis get error for key ${key}:`,
error
);
}
}
const value = regionalLocalCache.get<T>(key);
if (value !== undefined) {
logger.debug(`[regional] Cache hit in local cache: ${key}`);
} else {
logger.debug(`[regional] Cache miss in local cache: ${key}`);
}
return value;
}
async del(key: string | string[]): Promise<number> {
const keys = Array.isArray(key) ? key : [key];
let deletedCount = 0;
if (this.useRedis()) {
try {
for (const k of keys) {
const success = await regionalRedisManager.del(k);
if (success) {
deletedCount++;
logger.debug(`[regional] Deleted key from Redis: ${k}`);
}
}
if (deletedCount === keys.length) return deletedCount;
deletedCount = 0;
} catch (error) {
logger.error(`[regional] Redis del error:`, error);
deletedCount = 0;
}
}
for (const k of keys) {
const count = regionalLocalCache.del(k);
if (count > 0) {
deletedCount++;
logger.debug(`[regional] Deleted key from local cache: ${k}`);
}
}
return deletedCount;
}
async has(key: string): Promise<boolean> {
if (this.useRedis()) {
try {
const value = await regionalRedisManager.get(key);
return value !== null;
} catch (error) {
logger.error(
`[regional] Redis has error for key ${key}:`,
error
);
}
}
return regionalLocalCache.has(key);
}
/**
* Returns keys matching the given prefix from whichever backend is active.
* Redis uses a KEYS scan; local cache filters in-memory keys.
*/
async keysWithPrefix(prefix: string): Promise<string[]> {
if (this.useRedis()) {
try {
return await regionalRedisManager.keys(`${prefix}*`);
} catch (error) {
logger.error(`[regional] Redis keys error:`, error);
}
}
return regionalLocalCache.keys().filter((k) => k.startsWith(prefix));
}
getCurrentBackend(): "redis" | "local" {
return this.useRedis() ? "redis" : "local";
}
}
export const regionalCache = new RegionalAdaptiveCache();

View File

@@ -36,8 +36,6 @@ export async function getValidCertificatesForDomains(
domains: Set<string>,
useCache: boolean = true
): Promise<Array<CertificateResult>> {
const finalResults: CertificateResult[] = [];
const domainsToQuery = new Set<string>();
@@ -49,7 +47,26 @@ export async function getValidCertificatesForDomains(
if (cachedCert) {
finalResults.push(cachedCert); // Valid cache hit
} else {
domainsToQuery.add(domain); // Cache miss or expired
// Also check for a wildcard cache entry covering this domain's parent
const parts = domain.split(".");
let wildcardHit = false;
if (parts.length > 1) {
const parentDomain = parts.slice(1).join(".");
const wildcardCacheKey = `cert:*.${parentDomain}`;
const cachedWildcard =
await cache.get<CertificateResult>(wildcardCacheKey);
if (cachedWildcard) {
// Re-stamp queriedDomain so callers see the originally requested domain
finalResults.push({
...cachedWildcard,
queriedDomain: domain
});
wildcardHit = true;
}
}
if (!wildcardHit) {
domainsToQuery.add(domain); // Cache miss or expired
}
}
}
} else {
@@ -59,7 +76,10 @@ export async function getValidCertificatesForDomains(
// 2. If all domains were resolved from the cache, return early
if (domainsToQuery.size === 0) {
const decryptedResults = decryptFinalResults(finalResults, config.getRawConfig().server.secret!);
const decryptedResults = decryptFinalResults(
finalResults,
config.getRawConfig().server.secret!
);
return decryptedResults;
}
@@ -78,7 +98,8 @@ export async function getValidCertificatesForDomains(
const parentDomainsArray = Array.from(parentDomainsToQuery);
// Build wildcard variants: for each parent domain "example.com", also query "*.example.com"
const wildcardPrefixedArray = build != "saas" ? parentDomainsArray.map((d) => `*.${d}`) : [];
const wildcardPrefixedArray =
build != "saas" ? parentDomainsArray.map((d) => `*.${d}`) : [];
// 4. Build and execute a single, efficient Drizzle query
// This query fetches all potential exact and wildcard matches in one database round-trip.
@@ -172,11 +193,24 @@ export async function getValidCertificatesForDomains(
if (useCache) {
const cacheKey = `cert:${domain}`;
await cache.set(cacheKey, resultCert, 180);
// Also cache wildcard certs under a pattern key so other subdomains
// can find them without a DB round-trip
if (resultCert.wildcard) {
const normalizedCertDomain = normalizeWildcardDomain(
resultCert.domain
);
const wildcardCacheKey = `cert:*.${normalizedCertDomain}`;
await cache.set(wildcardCacheKey, resultCert, 180);
}
}
}
}
const decryptedResults = decryptFinalResults(finalResults, config.getRawConfig().server.secret!);
const decryptedResults = decryptFinalResults(
finalResults,
config.getRawConfig().server.secret!
);
return decryptedResults;
}

View File

@@ -73,6 +73,25 @@ export const privateConfigSchema = z
.object({
rejectUnauthorized: z.boolean().optional().default(true)
})
.optional(),
regional_redis: z
.object({
host: z.string(),
port: portSchema,
password: z
.string()
.optional()
.transform(getEnvOrYaml("REGIONAL_REDIS_PASSWORD")),
db: z.int().nonnegative().optional().default(0),
tls: z
.object({
rejectUnauthorized: z
.boolean()
.optional()
.default(true)
})
.optional()
})
.optional()
})
.optional(),

View File

@@ -109,14 +109,14 @@ class RedisManager {
password: redisConfig.password,
db: redisConfig.db
};
// Enable TLS if configured (required for AWS ElastiCache in-transit encryption)
if (redisConfig.tls) {
opts.tls = {
rejectUnauthorized: redisConfig.tls.rejectUnauthorized ?? true
};
}
return opts;
}
@@ -135,14 +135,14 @@ class RedisManager {
password: replica.password,
db: replica.db || redisConfig.db
};
// Enable TLS if configured (required for AWS ElastiCache in-transit encryption)
if (redisConfig.tls) {
opts.tls = {
rejectUnauthorized: redisConfig.tls.rejectUnauthorized ?? true
};
}
return opts;
}
@@ -855,3 +855,163 @@ class RedisManager {
export const redisManager = new RedisManager();
export const redis = redisManager.getClient();
export default redisManager;
/**
* Lightweight Redis manager for the regional (in-cluster) Redis instance.
* Connects only when `redis.regional_redis` is present in the private config
* and `flags.enable_redis` is true. No pub/sub — designed for low-latency
* caching of regionally-scoped data.
*/
class RegionalRedisManager {
private writeClient: Redis | null = null;
private readClient: Redis | null = null;
private isEnabled: boolean = false;
private isHealthy: boolean = false;
private connectionTimeout: number = 5000;
private commandTimeout: number = 5000;
constructor() {
if (build === "oss") return;
const cfg = privateConfig.getRawPrivateConfig();
if (!cfg.flags.enable_redis || !cfg.redis?.regional_redis) return;
this.isEnabled = true;
this.initializeClients();
}
private getConfig(): RedisOptions {
const r = privateConfig.getRawPrivateConfig().redis!.regional_redis!;
const opts: RedisOptions = {
host: r.host,
port: r.port,
password: r.password,
db: r.db
};
if (r.tls) {
opts.tls = { rejectUnauthorized: r.tls.rejectUnauthorized ?? true };
}
return opts;
}
private initializeClients(): void {
const cfg = this.getConfig();
const baseOpts = {
...cfg,
enableReadyCheck: false,
maxRetriesPerRequest: 3,
keepAlive: 10000,
connectTimeout: this.connectionTimeout,
commandTimeout: this.commandTimeout
};
try {
this.writeClient = new Redis(baseOpts);
// redis-1 (replica) handles reads; fall back to primary if not resolvable
this.readClient = new Redis({
...baseOpts,
host: cfg.host!.replace(/^(.*?)(\.\S+)$/, (_, h, rest) => {
// Derive replica hostname from the headless service pattern:
// redis.redis.svc.cluster.local -> redis-1.redis-headless.redis.svc.cluster.local
// If it doesn't look like a k8s service, just use the same host
return h + rest;
})
});
// For simplicity use same host for both; callers can always read from primary
// The real replica routing is handled by the StatefulSet headless service
this.readClient = this.writeClient;
this.writeClient.on("ready", () => {
logger.info("Regional Redis client ready");
this.isHealthy = true;
});
this.writeClient.on("error", (err) => {
logger.error("Regional Redis client error:", err);
this.isHealthy = false;
});
this.writeClient.on("reconnecting", () => {
logger.info("Regional Redis client reconnecting...");
this.isHealthy = false;
});
logger.info("Regional Redis client initialized");
} catch (error) {
logger.error("Failed to initialize regional Redis client:", error);
this.isEnabled = false;
}
}
public isRedisEnabled(): boolean {
return this.isEnabled && this.writeClient !== null && this.isHealthy;
}
public getHealthStatus() {
return { isEnabled: this.isEnabled, isHealthy: this.isHealthy };
}
public async set(
key: string,
value: string,
ttl?: number
): Promise<boolean> {
if (!this.isRedisEnabled() || !this.writeClient) return false;
try {
if (ttl) {
await this.writeClient.setex(key, ttl, value);
} else {
await this.writeClient.set(key, value);
}
return true;
} catch (error) {
logger.error("Regional Redis SET error:", error);
return false;
}
}
public async get(key: string): Promise<string | null> {
if (!this.isRedisEnabled() || !this.readClient) return null;
try {
return await this.readClient.get(key);
} catch (error) {
logger.error("Regional Redis GET error:", error);
return null;
}
}
public async del(key: string): Promise<boolean> {
if (!this.isRedisEnabled() || !this.writeClient) return false;
try {
await this.writeClient.del(key);
return true;
} catch (error) {
logger.error("Regional Redis DEL error:", error);
return false;
}
}
public async keys(pattern: string): Promise<string[]> {
if (!this.isRedisEnabled() || !this.readClient) return [];
try {
return await this.readClient.keys(pattern);
} catch (error) {
logger.error("Regional Redis KEYS error:", error);
return [];
}
}
public async disconnect(): Promise<void> {
try {
if (this.writeClient) {
await this.writeClient.quit();
this.writeClient = null;
}
this.readClient = null;
logger.info("Regional Redis client disconnected");
} catch (error) {
logger.error("Error disconnecting regional Redis client:", error);
}
}
}
export const regionalRedisManager = new RegionalRedisManager();

View File

@@ -12,6 +12,7 @@
*/
import {
browserGatewayTarget,
certificates,
db,
domainNamespaces,
@@ -84,7 +85,8 @@ export async function getTraefikConfig(
filterOutNamespaceDomains = false,
generateLoginPageRouters = false,
allowRawResources = true,
allowMaintenancePage = true
allowMaintenancePage = true,
allowBrowserGatewayResources = true
): Promise<any> {
// 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
@@ -95,9 +97,7 @@ export async function getTraefikConfig(
resourceName: resources.name,
fullDomain: resources.fullDomain,
ssl: resources.ssl,
http: resources.http,
proxyPort: resources.proxyPort,
protocol: resources.protocol,
subdomain: resources.subdomain,
domainId: resources.domainId,
enabled: resources.enabled,
@@ -109,6 +109,7 @@ export async function getTraefikConfig(
proxyProtocol: resources.proxyProtocol,
proxyProtocolVersion: resources.proxyProtocolVersion,
wildcard: resources.wildcard,
mode: resources.mode,
maintenanceModeEnabled: resources.maintenanceModeEnabled,
maintenanceModeType: resources.maintenanceModeType,
@@ -171,8 +172,8 @@ export async function getTraefikConfig(
),
inArray(sites.type, siteTypes),
allowRawResources
? isNotNull(resources.http) // ignore the http check if allow_raw_resources is true
: eq(resources.http, true)
? inArray(resources.mode, ["http", "udp", "tcp"]) // allow all three
: eq(resources.mode, "http")
)
)
.orderBy(desc(targets.priority), targets.targetId); // stable ordering
@@ -226,9 +227,8 @@ export async function getTraefikConfig(
key: key,
fullDomain: row.fullDomain,
ssl: row.ssl,
http: row.http,
proxyPort: row.proxyPort,
protocol: row.protocol,
mode: row.mode,
subdomain: row.subdomain,
domainId: row.domainId,
enabled: row.enabled,
@@ -277,10 +277,138 @@ export async function getTraefikConfig(
});
});
// Group browser gateway targets by resource
type BrowserGatewayResourceEntry = {
resourceId: number;
name: string;
fullDomain: string | null;
ssl: boolean | null;
subdomain: string | null;
domainId: string | null;
enabled: boolean | null;
wildcard: boolean | null;
domainCertResolver: string | null;
preferWildcardCert: boolean | null;
maintenanceModeEnabled: boolean | null;
maintenanceModeType: string | null;
maintenanceTitle: string | null;
maintenanceMessage: string | null;
maintenanceEstimatedTime: string | null;
targets: {
browserGatewayTargetId: number;
bgType: string;
siteId: number;
siteType: string;
siteOnline: boolean | null;
subnet: string | null;
siteExitNodeId: number | null;
}[];
};
const browserGatewayResourcesMap = new Map<
number,
BrowserGatewayResourceEntry
>();
if (allowBrowserGatewayResources) {
// Query browser gateway targets for this exit node
const browserGatewayRows = await db
.select({
// Resource fields
resourceId: resources.resourceId,
resourceName: resources.name,
fullDomain: resources.fullDomain,
ssl: resources.ssl,
subdomain: resources.subdomain,
domainId: resources.domainId,
enabled: resources.enabled,
wildcard: resources.wildcard,
domainCertResolver: domains.certResolver,
preferWildcardCert: domains.preferWildcardCert,
domainNamespaceId: domainNamespaces.domainNamespaceId,
// Maintenance fields
maintenanceModeEnabled: resources.maintenanceModeEnabled,
maintenanceModeType: resources.maintenanceModeType,
maintenanceTitle: resources.maintenanceTitle,
maintenanceMessage: resources.maintenanceMessage,
maintenanceEstimatedTime: resources.maintenanceEstimatedTime,
// Browser gateway target fields
browserGatewayTargetId:
browserGatewayTarget.browserGatewayTargetId,
bgType: browserGatewayTarget.type,
// Site fields
siteId: sites.siteId,
siteType: sites.type,
siteOnline: sites.online,
subnet: sites.subnet,
siteExitNodeId: sites.exitNodeId
})
.from(browserGatewayTarget)
.innerJoin(sites, eq(sites.siteId, browserGatewayTarget.siteId))
.innerJoin(
resources,
eq(resources.resourceId, browserGatewayTarget.resourceId)
)
.leftJoin(domains, eq(domains.domainId, resources.domainId))
.leftJoin(
domainNamespaces,
eq(domainNamespaces.domainId, resources.domainId)
)
.where(
and(
eq(resources.enabled, true),
or(
eq(sites.exitNodeId, exitNodeId),
and(
isNull(sites.exitNodeId),
sql`(${siteTypes.includes("local") ? 1 : 0} = 1)`,
eq(sites.type, "local"),
sql`(${build != "saas" ? 1 : 0} = 1)`
)
),
inArray(sites.type, siteTypes)
)
);
for (const row of browserGatewayRows) {
if (filterOutNamespaceDomains && row.domainNamespaceId) {
continue;
}
if (!browserGatewayResourcesMap.has(row.resourceId)) {
browserGatewayResourcesMap.set(row.resourceId, {
resourceId: row.resourceId,
name: sanitize(row.resourceName) || "",
fullDomain: row.fullDomain,
ssl: row.ssl,
subdomain: row.subdomain,
domainId: row.domainId,
enabled: row.enabled,
wildcard: row.wildcard,
domainCertResolver: row.domainCertResolver,
preferWildcardCert: row.preferWildcardCert,
maintenanceModeEnabled: row.maintenanceModeEnabled,
maintenanceModeType: row.maintenanceModeType,
maintenanceTitle: row.maintenanceTitle,
maintenanceMessage: row.maintenanceMessage,
maintenanceEstimatedTime: row.maintenanceEstimatedTime,
targets: []
});
}
browserGatewayResourcesMap.get(row.resourceId)!.targets.push({
browserGatewayTargetId: row.browserGatewayTargetId,
bgType: row.bgType,
siteId: row.siteId,
siteType: row.siteType,
siteOnline: row.siteOnline,
subnet: row.subnet,
siteExitNodeId: row.siteExitNodeId
});
}
}
let siteResourcesWithFullDomain: {
siteResourceId: number;
fullDomain: string | null;
mode: "http" | "host" | "cidr";
mode: "http" | "host" | "cidr" | "ssh";
}[] = [];
if (build == "enterprise") {
// we dont want to do this on the cloud
@@ -324,6 +452,12 @@ export async function getTraefikConfig(
domains.add(sr.fullDomain);
}
}
// Include browser gateway resource domains
for (const bgResource of browserGatewayResourcesMap.values()) {
if (bgResource.enabled && bgResource.ssl && bgResource.fullDomain) {
domains.add(bgResource.fullDomain);
}
}
// get the valid certs for these domains
validCerts = await getValidCertificatesForDomains(domains, true); // we are caching here because this is called often
// logger.debug(`Valid certs for domains: ${JSON.stringify(validCerts)}`);
@@ -589,7 +723,7 @@ export async function getTraefikConfig(
resource.ssl ? entrypointHttps : entrypointHttp
],
service: maintenanceServiceName,
rule: `${rule} && (PathPrefix(\`/_next\`) || PathRegexp(\`^/__nextjs*\`))`,
rule: `${rule} && (PathPrefix(\`/_next\`) || PathRegexp(\`^/__nextjs*\`) || Path(\`/favicon.ico\`)) `,
priority: 2001,
...(resource.ssl ? { tls } : {})
};
@@ -830,7 +964,7 @@ export async function getTraefikConfig(
continue;
}
const protocol = resource.protocol.toLowerCase();
const protocol = resource.mode == "udp" ? "udp" : "tcp";
const port = resource.proxyPort;
if (!port) {
@@ -925,6 +1059,267 @@ export async function getTraefikConfig(
}
}
if (allowBrowserGatewayResources) {
// Generate Traefik config for browser gateway resources
const browserGatewayPort = 39999;
for (const [, bgResource] of browserGatewayResourcesMap.entries()) {
if (!bgResource.enabled) continue;
if (!bgResource.domainId) continue;
if (!bgResource.fullDomain) continue;
if (!config_output.http.routers) config_output.http.routers = {};
if (!config_output.http.services) config_output.http.services = {};
const fullDomain = bgResource.fullDomain;
const additionalMiddlewares =
config.getRawConfig().traefik.additional_middlewares || [];
const routerMiddlewares = [
badgerMiddlewareName,
...additionalMiddlewares
];
const hostRule = `Host(\`${fullDomain}\`)`;
// Build TLS config
let tls = {};
if (!privateConfig.getRawPrivateConfig().flags.use_pangolin_dns) {
const domainParts = fullDomain.split(".");
let wildCard: string;
if (domainParts.length <= 2) {
wildCard = `*.${domainParts.join(".")}`;
} else {
wildCard = `*.${domainParts.slice(1).join(".")}`;
}
if (!bgResource.subdomain) {
wildCard = fullDomain;
}
const globalDefaultResolver =
config.getRawConfig().traefik.cert_resolver;
const globalDefaultPreferWildcard =
config.getRawConfig().traefik.prefer_wildcard_cert;
const resolverName = bgResource.domainCertResolver
? bgResource.domainCertResolver.trim()
: globalDefaultResolver;
const preferWildcard =
bgResource.preferWildcardCert !== undefined &&
bgResource.preferWildcardCert !== null
? bgResource.preferWildcardCert
: globalDefaultPreferWildcard;
tls = {
certResolver: resolverName,
...(preferWildcard ? { domains: [{ main: wildCard }] } : {})
};
} else {
const matchingCert = validCerts.find(
(cert) => cert.queriedDomain === fullDomain
);
if (!matchingCert) {
logger.debug(
`No matching certificate found for browser gateway domain: ${fullDomain}`
);
continue;
}
}
const bgUiServiceName = `bg-r${bgResource.resourceId}-ui-service`;
if (bgResource.ssl) {
const redirectRouterName = `bg-r${bgResource.resourceId}-redirect`;
config_output.http.routers![redirectRouterName] = {
entryPoints: [
config.getRawConfig().traefik.http_entrypoint
],
middlewares: [redirectHttpsMiddlewareName],
service: bgUiServiceName,
rule: hostRule,
priority: 100
};
}
// Collect online sites for this resource (for any type)
const anySiteOnline = bgResource.targets.some((t) => t.siteOnline);
// Maintenance page logic for browser gateway resources
let showBgMaintenancePage = false;
if (bgResource.maintenanceModeEnabled) {
if (bgResource.maintenanceModeType === "forced") {
showBgMaintenancePage = true;
} else if (bgResource.maintenanceModeType === "automatic") {
showBgMaintenancePage = !anySiteOnline;
}
}
if (showBgMaintenancePage && allowMaintenancePage) {
const bgMaintenanceServiceName = `bg-r${bgResource.resourceId}-maintenance-service`;
const bgMaintenanceRouterName = `bg-r${bgResource.resourceId}-maintenance-router`;
const bgRewriteMiddlewareName = `bg-r${bgResource.resourceId}-maintenance-rewrite`;
const entrypointHttp =
config.getRawConfig().traefik.http_entrypoint;
const entrypointHttps =
config.getRawConfig().traefik.https_entrypoint;
const maintenancePort = config.getRawConfig().server.next_port;
const maintenanceHost =
config.getRawConfig().server.internal_hostname;
if (!config_output.http.services)
config_output.http.services = {};
if (!config_output.http.middlewares)
config_output.http.middlewares = {};
if (!config_output.http.routers)
config_output.http.routers = {};
config_output.http.services![bgMaintenanceServiceName] = {
loadBalancer: {
servers: [
{
url: `http://${maintenanceHost}:${maintenancePort}`
}
],
passHostHeader: true
}
};
config_output.http.middlewares![bgRewriteMiddlewareName] = {
replacePathRegex: {
regex: "^/(.*)",
replacement: "/maintenance-screen"
}
};
config_output.http.routers![bgMaintenanceRouterName] = {
entryPoints: [
bgResource.ssl ? entrypointHttps : entrypointHttp
],
service: bgMaintenanceServiceName,
middlewares: [bgRewriteMiddlewareName],
rule: hostRule,
priority: 2000,
...(bgResource.ssl ? { tls } : {})
};
config_output.http.routers![
`${bgMaintenanceRouterName}-assets`
] = {
entryPoints: [
bgResource.ssl ? entrypointHttps : entrypointHttp
],
service: bgMaintenanceServiceName,
rule: `${hostRule} && (PathPrefix(\`/_next\`) || PathRegexp(\`^/__nextjs*\`) || Path(\`/favicon.ico\`))`,
priority: 2001,
...(bgResource.ssl ? { tls } : {})
};
continue;
}
// Group targets by type and generate per-type websocket routers and services
const typeMap = new Map<string, typeof bgResource.targets>();
for (const t of bgResource.targets) {
if (!typeMap.has(t.bgType)) typeMap.set(t.bgType, []);
typeMap.get(t.bgType)!.push(t);
}
for (const [bgType, typedTargets] of typeMap.entries()) {
const bgKey = `bg-r${bgResource.resourceId}-${bgType}`;
const bgRouterName = `${bgKey}-router`;
const bgServiceName = `${bgKey}-service`;
const bgRule = `${hostRule} && PathPrefix(\`/gateway/${bgType}\`)`;
const servers = typedTargets
.filter((t) => {
if (!t.siteOnline && anySiteOnline) return false;
if (t.siteType === "newt") return !!t.subnet;
return false; // browser gateway only supported on newt sites
})
.map((t) => ({
url: `http://${t.subnet!.split("/")[0]}:${browserGatewayPort}`
}))
.filter(
(v, i, a) => a.findIndex((u) => u.url === v.url) === i
);
config_output.http.routers![bgRouterName] = {
entryPoints: [
bgResource.ssl
? config.getRawConfig().traefik.https_entrypoint
: config.getRawConfig().traefik.http_entrypoint
],
middlewares: routerMiddlewares,
service: bgServiceName,
rule: bgRule,
priority: 110, // highest - websocket path takes precedence
...(bgResource.ssl ? { tls } : {})
};
config_output.http.services![bgServiceName] = {
loadBalancer: {
servers
}
};
}
// UI: serve the browser gateway page from the internal pangolin instance.
// The primary type is used for the path rewrite (e.g. /rdp), mirroring
// how the maintenance page rewrites everything to /maintenance-screen.
const primaryType = typeMap.keys().next().value as string;
const internalHost = config.getRawConfig().server.internal_hostname;
const internalPort = config.getRawConfig().server.next_port;
const uiRewriteMiddlewareName = `bg-r${bgResource.resourceId}-ui-rewrite`;
const entrypoint = bgResource.ssl
? config.getRawConfig().traefik.https_entrypoint
: config.getRawConfig().traefik.http_entrypoint;
if (!config_output.http.middlewares) {
config_output.http.middlewares = {};
}
config_output.http.middlewares![uiRewriteMiddlewareName] = {
replacePathRegex: {
regex: "^/(.*)",
replacement: `/${primaryType}`
}
};
config_output.http.services![bgUiServiceName] = {
loadBalancer: {
servers: [
{
url: `http://${internalHost}:${internalPort}`
}
]
}
};
// Assets router at higher priority so /_next files load without rewrite
config_output.http.routers![
`bg-r${bgResource.resourceId}-assets-router`
] = {
entryPoints: [entrypoint],
middlewares: routerMiddlewares,
service: bgUiServiceName,
rule: `${hostRule} && (PathPrefix(\`/_next\`) || PathRegexp(\`^/__nextjs*\`) || Path(\`/favicon.ico\`))`,
priority: 101,
...(bgResource.ssl ? { tls } : {})
};
// Catch-all router rewrites everything on the domain to /{primaryType}
config_output.http.routers![
`bg-r${bgResource.resourceId}-ui-router`
] = {
entryPoints: [entrypoint],
middlewares: [...routerMiddlewares, uiRewriteMiddlewareName],
service: bgUiServiceName,
rule: hostRule,
priority: 100,
...(bgResource.ssl ? { tls } : {})
};
}
}
// Add Traefik routes for siteResource aliases (HTTP mode + SSL) so that
// Traefik generates TLS certificates for those domains even when no
// matching resource exists yet.
@@ -1040,7 +1435,7 @@ export async function getTraefikConfig(
config_output.http.routers[`${siteResourceRouterName}-assets`] = {
entryPoints: [config.getRawConfig().traefik.https_entrypoint],
service: siteResourceServiceName,
rule: `Host(\`${fullDomain}\`) && (PathPrefix(\`/_next\`) || PathRegexp(\`^/__nextjs*\`))`,
rule: `Host(\`${fullDomain}\`) && (PathPrefix(\`/_next\`) || PathRegexp(\`^/__nextjs*\`) || Path(\`/favicon.ico\`))`,
priority: 101,
tls
};
@@ -1143,7 +1538,7 @@ export async function getTraefikConfig(
config.getRawConfig().traefik.https_entrypoint
],
service: "landing-service",
rule: `Host(\`${fullDomain}\`) && (PathRegexp(\`^/auth/resource/[^/]+$\`) || PathRegexp(\`^/auth/idp/[0-9]+/oidc/callback\`) || PathPrefix(\`/_next\`) || Path(\`/auth/org\`) || PathRegexp(\`^/__nextjs*\`))`,
rule: `Host(\`${fullDomain}\`) && (PathRegexp(\`^/auth/resource/[^/]+$\`) || PathRegexp(\`^/auth/idp/[0-9]+/oidc/callback\`) || PathPrefix(\`/_next\`) || Path(\`/auth/org\`) || PathRegexp(\`^/__nextjs*\`) || Path(\`/favicon.ico\`))`,
priority: 203,
tls: tls
};

View File

@@ -19,6 +19,7 @@ import { eq, and } from "drizzle-orm";
import createHttpError from "http-errors";
import HttpCode from "@server/types/HttpCode";
import logger from "@server/logger";
import { getFirstString } from "@server/lib/requestParams";
export async function verifyCertificateAccess(
req: Request,
@@ -27,11 +28,43 @@ export async function verifyCertificateAccess(
) {
try {
// Assume user/org access is already verified
const orgId = req.params.orgId;
const certId =
req.params.certId || req.body?.certId || req.query?.certId;
let domainId =
req.params.domainId || req.body?.domainId || req.query?.domainId;
const orgId = getFirstString(req.params.orgId);
const certIdFromParams = getFirstString(req.params?.certId);
const certIdFromBody = getFirstString(req.body?.certId);
if (
certIdFromParams &&
certIdFromBody &&
certIdFromParams !== certIdFromBody
) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
"Certificate ID provided in both URL and body with different values"
)
);
}
const certId = certIdFromParams || certIdFromBody;
const domainIdFromParams = getFirstString(req.params?.domainId);
const domainIdFromBody = getFirstString(req.body?.domainId);
if (
domainIdFromParams &&
domainIdFromBody &&
domainIdFromParams !== domainIdFromBody
) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
"Domain ID provided in both URL and body with different values"
)
);
}
let domainId = domainIdFromParams || domainIdFromBody;
if (!orgId) {
return next(
@@ -65,7 +98,7 @@ export async function verifyCertificateAccess(
);
}
domainId = cert.domainId;
domainId = cert.domainId ?? undefined;
if (!domainId) {
return next(
createHttpError(

View File

@@ -17,6 +17,7 @@ import { and, eq } from "drizzle-orm";
import createHttpError from "http-errors";
import HttpCode from "@server/types/HttpCode";
import { getUserOrgRoleIds } from "@server/lib/userOrgRoles";
import { getFirstString } from "@server/lib/requestParams";
export async function verifyIdpAccess(
req: Request,
@@ -25,8 +26,12 @@ export async function verifyIdpAccess(
) {
try {
const userId = req.user!.userId;
const idpId = req.params.idpId || req.body.idpId || req.query.idpId;
const orgId = req.params.orgId;
const idpIdRaw =
getFirstString(req.params.idpId) ||
getFirstString(req.body?.idpId) ||
getFirstString(req.query?.idpId);
const idpId = Number.parseInt(idpIdRaw ?? "", 10);
const orgId = getFirstString(req.params.orgId);
if (!userId) {
return next(
@@ -40,7 +45,7 @@ export async function verifyIdpAccess(
);
}
if (!idpId) {
if (Number.isNaN(idpId)) {
return next(
createHttpError(HttpCode.BAD_REQUEST, "Invalid key ID")
);

View File

@@ -18,6 +18,7 @@ import { and, eq } from "drizzle-orm";
import createHttpError from "http-errors";
import HttpCode from "@server/types/HttpCode";
import { getUserOrgRoleIds } from "@server/lib/userOrgRoles";
import { getFirstString } from "@server/lib/requestParams";
export async function verifyRemoteExitNodeAccess(
req: Request,
@@ -25,11 +26,11 @@ export async function verifyRemoteExitNodeAccess(
next: NextFunction
) {
const userId = req.user!.userId; // Assuming you have user information in the request
const orgId = req.params.orgId;
const orgId = getFirstString(req.params.orgId);
const remoteExitNodeId =
req.params.remoteExitNodeId ||
req.body.remoteExitNodeId ||
req.query.remoteExitNodeId;
getFirstString(req.params.remoteExitNodeId) ||
getFirstString(req.body?.remoteExitNodeId) ||
getFirstString(req.query?.remoteExitNodeId);
if (!userId) {
return next(
@@ -37,6 +38,15 @@ export async function verifyRemoteExitNodeAccess(
);
}
if (!orgId || !remoteExitNodeId) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
"Invalid organization or remote exit node ID"
)
);
}
try {
const [remoteExitNode] = await db
.select()

Some files were not shown because too many files have changed in this diff Show More