Compare commits

..

1098 Commits

Author SHA1 Message Date
dependabot[bot] 35787026c7 Bump golang.org/x/term in /install in the go-install-dependencies group
Bumps the go-install-dependencies group in /install with 1 update: [golang.org/x/term](https://github.com/golang/term).


Updates `golang.org/x/term` from 0.44.0 to 0.45.0
- [Commits](https://github.com/golang/term/compare/v0.44.0...v0.45.0)

---
updated-dependencies:
- dependency-name: golang.org/x/term
  dependency-version: 0.45.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: go-install-dependencies
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-07-09 01:33:14 +00:00
Milo Schwartz 09d5d9082e Merge pull request #3413 from fosrl/dev
1.20.0
2026-07-08 15:30:03 -04:00
Owen Schwartz 8eab3b1ee2 Merge pull request #3409 from fosrl/crowdin_dev
New Crowdin updates
2026-07-08 14:00:41 -04:00
Owen Schwartz 30b46ebb6e New translations en-us.json (Norwegian Bokmal)
[ci skip]
2026-07-08 13:59:57 -04:00
Owen Schwartz 91d5f89ab9 New translations en-us.json (Chinese Simplified)
[ci skip]
2026-07-08 13:59:55 -04:00
Owen Schwartz 703050f949 New translations en-us.json (Turkish)
[ci skip]
2026-07-08 13:59:53 -04:00
Owen Schwartz 97af17a993 New translations en-us.json (Russian)
[ci skip]
2026-07-08 13:59:51 -04:00
Owen Schwartz 09a8c457ac New translations en-us.json (Portuguese)
[ci skip]
2026-07-08 13:59:49 -04:00
Owen Schwartz 80317aca83 New translations en-us.json (Polish)
[ci skip]
2026-07-08 13:59:47 -04:00
Owen Schwartz bd10519598 New translations en-us.json (Dutch)
[ci skip]
2026-07-08 13:59:45 -04:00
Owen Schwartz 15c7c47b3c New translations en-us.json (Korean)
[ci skip]
2026-07-08 13:59:43 -04:00
Owen Schwartz 0a7e8c3cc2 New translations en-us.json (Italian)
[ci skip]
2026-07-08 13:59:42 -04:00
Owen Schwartz fc2668fca8 New translations en-us.json (German)
[ci skip]
2026-07-08 13:59:40 -04:00
Owen Schwartz 8f2c20a2bc New translations en-us.json (Danish)
[ci skip]
2026-07-08 13:59:38 -04:00
Owen Schwartz 462ae7ee8c New translations en-us.json (Czech)
[ci skip]
2026-07-08 13:59:36 -04:00
Owen Schwartz 808abd2038 New translations en-us.json (Bulgarian)
[ci skip]
2026-07-08 13:59:34 -04:00
Owen Schwartz 3de25e51ff New translations en-us.json (Spanish)
[ci skip]
2026-07-08 13:59:32 -04:00
Owen Schwartz ef25a9dbe0 New translations en-us.json (French)
[ci skip]
2026-07-08 13:59:30 -04:00
miloschwartz e21c3afb13 fix restart site styling 2026-07-08 12:32:29 -04:00
Milo Schwartz cb7962c25d Merge pull request #3200 from Adityakk9031/#3001
fix: redirect lost after Pangolin auth session timeout (#3001)
2026-07-08 12:30:39 -04:00
miloschwartz f667ee4feb fix api reference 2026-07-08 12:29:23 -04:00
miloschwartz 810119e4ee Merge branch 'dev' into #3001 2026-07-08 12:10:13 -04:00
miloschwartz d082de3d88 command palette tweaks 2026-07-07 21:55:15 -04:00
Milo Schwartz 1e7863ce4f Merge pull request #3342 from Fredkiss3/feat/geoip-country-is-not-rule
feat: add country `is not` rule in resource policies
2026-07-07 21:18:43 -04:00
Milo Schwartz a942871f3d Merge pull request #3263 from Fredkiss3/feat/command-bar
feat: Command Palette for searching accross the dashboard
2026-07-07 21:17:39 -04:00
Milo Schwartz da1205d38e Merge pull request #3407 from fosrl/fix/non-semver-version-error
fix: invalid version causing crashes in the frontend
2026-07-07 21:09:05 -04:00
Fred KISSIE 92775a51ca 🐛 fix invalid semver version causing crashes in frontend 2026-07-08 01:44:28 +02:00
Fred KISSIE ea60aa701e 🐛 fix sitetable page crashing because of invalid version 2026-07-08 01:39:52 +02:00
Fred KISSIE b1f7ee02b0 🐛 fix imports 2026-07-08 00:58:03 +02:00
Fred KISSIE affa3bbb6a Merge branch 'dev' into feat/command-bar 2026-07-08 00:33:28 +02:00
miloschwartz dbb1e7b274 dont show non sso resoruces 2026-07-07 18:10:12 -04:00
miloschwartz b2d1a66279 bump version 2026-07-07 17:08:49 -04:00
miloschwartz 202466792c fix search icon alignment 2026-07-07 16:34:37 -04:00
miloschwartz 4dcf684868 add 1.20.0 migration 2026-07-07 16:04:41 -04:00
miloschwartz 2b8f50af6f add missing api key perms to permissions selector 2026-07-07 15:23:41 -04:00
Owen a8c255ef71 Fix type issue 2026-07-07 15:21:23 -04:00
miloschwartz d9608bd408 improve role required feedback on user 2026-07-07 15:17:29 -04:00
Owen be193731c1 Reorder labels columns 2026-07-07 15:15:00 -04:00
Owen 0c3edc7560 Further remove labels 2026-07-07 15:12:14 -04:00
miloschwartz 813a3f07dc Merge branch 'dev' into private-resource-page 2026-07-07 15:07:35 -04:00
Owen 417438c209 Remove feature flags from labels 2026-07-07 15:04:41 -04:00
miloschwartz beaa5735ce move cert to info box and add network access to create wizard 2026-07-07 14:51:15 -04:00
Owen Schwartz 5cb316f4e9 Merge pull request #3404 from fosrl/dev
Add ? to support undefined
2026-07-07 10:59:56 -04:00
Owen 83de8576bf Add ? to support undefined 2026-07-07 10:59:01 -04:00
Owen Schwartz 6b6c9cf4d8 Merge pull request #3403 from fosrl/dev
Move jit mode to a config param
2026-07-07 10:41:59 -04:00
Owen e9d424eb80 Move jit mode to a config param
Ref #3262
2026-07-07 10:41:12 -04:00
Owen Schwartz 05617c63c0 Merge pull request #3402 from fosrl/dev
1.19.4-s.5
2026-07-07 10:21:57 -04:00
Owen 8cceed91cf Make feilds optional 2026-07-07 09:02:25 -04:00
Owen 633159fb77 Email whitelist should respect policies 2026-07-07 08:37:20 -04:00
miloschwartz 4c8bd4db0a includen on sso resources in launcher 2026-07-06 22:00:35 -04:00
miloschwartz d742300e82 add mising tooltips to collapsed sidebar 2026-07-06 21:52:22 -04:00
miloschwartz a521db91a2 migrate private resources to page 2026-07-06 21:48:29 -04:00
Owen d4549f1c33 log error 2026-07-06 14:44:30 -04:00
Owen 71da22328c Quiet logs 2026-07-06 14:44:30 -04:00
miloschwartz 0b5b732a48 Merge branch 'resource-launcher-compact' into dev 2026-07-06 14:19:58 -04:00
miloschwartz a82bae8f93 add resource info to side panel 2026-07-06 12:45:07 -04:00
Owen e59176149b Disable jit
Fixes #3262
2026-07-06 11:01:40 -04:00
Owen 2c3151da9b Fix #3374 2026-07-03 17:36:06 -04:00
Owen f60b8795ad Fix #3383 2026-07-03 17:26:49 -04:00
Owen 4054976388 Fix #3395 2026-07-03 17:15:48 -04:00
Fred KISSIE 11c30b8b27 Merge branch 'dev' into feat/geoip-country-is-not-rule 2026-07-03 22:50:56 +02:00
Owen Schwartz d4c52bbf2f Merge pull request #3396 from fosrl/dev
1.19.4-s.5
2026-07-03 16:15:18 -04:00
Owen 390c822bb4 Fix issue with overlapping site resources not sending 2026-07-03 16:13:50 -04:00
Owen 2bfc1901a6 Dont check the subnet because we dont use it 2026-07-03 14:38:01 -04:00
Owen 5da186b528 Quiet warning messages 2026-07-03 11:58:00 -04:00
Owen 600a96c13b Update rate limit to 10 2026-07-03 11:52:59 -04:00
Owen e4e0da3723 Add not exists check 2026-07-03 11:24:53 -04:00
miloschwartz 4087d7fb6b remove text from refresh button 2026-07-03 11:18:06 -04:00
miloschwartz 9edb86fd73 add invalidate launcher cache on refresh 2026-07-03 10:30:41 -04:00
Owen Schwartz 87f50bf0cc Merge pull request #3389 from fosrl/dev
1.19.4-s.3
2026-07-03 10:29:15 -04:00
Owen 440ebfe08e Clarify error messages 2026-07-03 10:26:13 -04:00
Owen b399d2a291 Add some retry and database confict mitigation 2026-07-03 10:23:32 -04:00
Owen Schwartz 2c66da1b19 Merge pull request #3386 from v1rusnl/main
Upgrade Traefik image to version 3.7
2026-07-03 10:18:44 -04:00
miloschwartz 811119a9a6 reduce filter dropdown page size 2026-07-03 10:13:58 -04:00
miloschwartz b53f80d317 use column for default view 2026-07-03 09:59:49 -04:00
Owen 1b1fba60f1 Make sure to retry rebuilds 2026-07-03 09:02:08 -04:00
v1rusnl ab19955502 Upgrade Traefik image to version 3.7 2026-07-03 08:22:13 +02:00
v1rusnl 1db9dcec81 Update Traefik image version to v3.7 2026-07-03 08:21:12 +02:00
miloschwartz a89509c8bd add more caching 2026-07-02 23:32:16 -04:00
miloschwartz f0546eb622 add save default view 2026-07-02 22:27:51 -04:00
miloschwartz 12f9ac94fd allow grouping when filter applied 2026-07-02 22:07:30 -04:00
miloschwartz 1717a99cee add compact mode for large orgs 2026-07-02 21:53:52 -04:00
Owen b93d26f09f Fix reading from replicas 2026-07-02 21:46:53 -04:00
Owen fc54ad49b5 Fix trial showing 2026-07-02 21:46:44 -04:00
Owen f87e136f6b Unique subnets for exit nodes 2026-07-02 20:54:59 -04:00
Owen 1bf3d2cdd6 Add back the sync with semver 2026-07-02 12:10:20 -04:00
Owen 5fc5a3ebca Adjust spacing 2026-07-02 11:49:49 -04:00
Owen Schwartz 49c2d3163e Merge pull request #3381 from fosrl/dev
dev
2026-07-02 10:56:39 -04:00
Owen e40f325703 Add new limits to the billing page 2026-07-02 10:55:46 -04:00
Owen 8f377a4fb2 Dont run on cloud 2026-07-01 22:07:33 -04:00
Owen Schwartz 45b9e13a13 Merge pull request #3378 from fosrl/dev
1.19.4-s.1
2026-07-01 21:48:01 -04:00
Owen Schwartz 3699f8f9cb Merge pull request #3380 from fosrl/resource-launcher
improve pagination
2026-07-01 21:47:14 -04:00
Owen 5a2388a1e6 Fix imports 2026-07-01 21:43:51 -04:00
Owen 86e6ebc8af Comment out sync again for now 2026-07-01 21:40:08 -04:00
miloschwartz 005f050a81 improve pagination 2026-07-01 21:36:23 -04:00
Owen Schwartz c82678852e Merge pull request #3377 from fosrl/crowdin_dev
New Crowdin updates
2026-07-01 21:30:26 -04:00
Owen Schwartz 2e3ab10f5e New translations en-us.json (Norwegian Bokmal)
[ci skip]
2026-07-01 21:28:16 -04:00
Owen Schwartz 4985ed02d3 New translations en-us.json (Chinese Simplified)
[ci skip]
2026-07-01 21:28:14 -04:00
Owen Schwartz a2fea7f714 New translations en-us.json (Turkish)
[ci skip]
2026-07-01 21:28:12 -04:00
Owen Schwartz ddba2aff21 New translations en-us.json (Russian)
[ci skip]
2026-07-01 21:28:11 -04:00
Owen Schwartz da9e668fb8 New translations en-us.json (Portuguese)
[ci skip]
2026-07-01 21:28:09 -04:00
Owen Schwartz 9c2d14d8c6 New translations en-us.json (Polish)
[ci skip]
2026-07-01 21:28:07 -04:00
Owen Schwartz c383e74df4 New translations en-us.json (Dutch)
[ci skip]
2026-07-01 21:28:05 -04:00
Owen Schwartz 40101f8bb0 New translations en-us.json (Korean)
[ci skip]
2026-07-01 21:28:03 -04:00
Owen Schwartz b20ed9efa9 New translations en-us.json (Italian)
[ci skip]
2026-07-01 21:28:02 -04:00
Owen Schwartz 56266e3b62 New translations en-us.json (German)
[ci skip]
2026-07-01 21:28:00 -04:00
Owen Schwartz 47eff8c948 New translations en-us.json (Danish)
[ci skip]
2026-07-01 21:27:58 -04:00
Owen Schwartz 15f36096ef New translations en-us.json (Czech)
[ci skip]
2026-07-01 21:27:57 -04:00
Owen Schwartz 064252586d New translations en-us.json (Bulgarian)
[ci skip]
2026-07-01 21:27:55 -04:00
Owen Schwartz 6181d46a1e New translations en-us.json (Spanish)
[ci skip]
2026-07-01 21:27:53 -04:00
Owen Schwartz 9747731668 New translations en-us.json (French)
[ci skip]
2026-07-01 21:27:51 -04:00
Owen Schwartz 2a478eef6f Merge pull request #3375 from fosrl/resource-launcher
Resource launcher
2026-07-01 21:19:19 -04:00
Owen 4ab101f8a9 Fix lock 2026-07-01 21:13:40 -04:00
miloschwartz e35878ee55 add caching 2026-07-01 19:59:38 -04:00
Owen 807613f28c Move rate limit up 2026-07-01 17:30:07 -04:00
miloschwartz 663244fa3a support labels in list alises 2026-07-01 16:17:27 -04:00
Owen bcc128aeb6 Fix ping function 2026-07-01 15:47:24 -04:00
Owen ba33cb9895 Increment config version for this 2026-07-01 15:44:08 -04:00
Owen 69e7fedcfc Add the remote exit node info 2026-07-01 15:28:26 -04:00
miloschwartz 023110b341 update en-us 2026-07-01 15:18:49 -04:00
miloschwartz 7fb95e1726 restore script 2026-07-01 15:15:43 -04:00
miloschwartz db0a7cc1ce fix toolbar responsiveness and disable side panel 2026-07-01 15:14:29 -04:00
Owen 61fc2e5ea7 Fix restartSite import 2026-07-01 14:48:36 -04:00
miloschwartz 0871a211ec open side panel on click 2026-07-01 14:30:43 -04:00
miloschwartz 5a1d5cb66e redirect to settings if org admin 2026-07-01 14:06:33 -04:00
miloschwartz 5a7ca5b542 add refresh button 2026-07-01 12:38:45 -04:00
miloschwartz 87e1a509ce show sites and labels for non admins 2026-07-01 12:17:36 -04:00
miloschwartz 75f481bc3d improve search bar debounce behavior 2026-07-01 11:53:11 -04:00
miloschwartz 97cdb2eb5a use proper label filter selector 2026-07-01 11:46:20 -04:00
miloschwartz 297fd2caf3 standardize permissions in api 2026-07-01 11:26:14 -04:00
miloschwartz 22dd4220fe fix ordering of buttons 2026-07-01 11:09:26 -04:00
Owen 108cb6216c Add migration to fix policy delete issues
Ref #3257
2026-07-01 10:39:41 -04:00
Owen e3ef592778 Adjust language 2026-07-01 10:39:41 -04:00
miloschwartz 3c37e10638 add mobile overflow menu 2026-07-01 10:11:17 -04:00
miloschwartz 561f75b6b1 improve responsiveness 2026-07-01 10:03:06 -04:00
Owen Schwartz e98bcb83ac Merge pull request #3371 from jaisinha77777/fix/oss-listexitnodes-siteid-signature
Fix OSS build break: add siteId param to OSS listExitNodes
2026-07-01 08:44:12 -04:00
Owen Schwartz 80284863bb Merge pull request #3369 from jaisinha77777/fix/private-resources-delete-error-toast
Fix broken toast on private resource delete failure
2026-07-01 08:43:50 -04:00
miloschwartz bc759c5c9e improve mobile responsiveness 2026-06-30 22:11:25 -04:00
miloschwartz f4854a3a74 include exit node endpoint in tcp/udp resources 2026-06-30 21:54:27 -04:00
miloschwartz 376dd465b3 show no site category 2026-06-30 21:49:08 -04:00
jaisinha77777 296439fd67 Fix OSS build break: add siteId param to OSS listExitNodes
listExitNodes is resolved via the #dynamic path alias, which maps to
server/lib in the OSS build and server/private/lib in enterprise/saas.
Commit 9c18936b added a 4th siteId argument to the shared caller
(handleNewtPingRequestMessage) and to the enterprise implementation, but
not to the OSS one, so under the OSS tsconfig the call fails:

  handleNewtPingRequestMessage.ts: error TS2554: Expected 1-3 arguments,
  but got 4.

This breaks 'npx tsc --noEmit' for the OSS build (the CI 'Test with tsc'
step runs it after set:oss). Add siteId?: number to the OSS signature
for parity. It is unused in OSS since that build has no remote exit
nodes to label-filter; accepting it keeps the two #dynamic
implementations interface-compatible.
2026-07-01 07:17:03 +05:30
jaisinha77777 31f675f38c Fix broken toast on private resource delete failure
The delete-error handler in PrivateResourcesTable referenced two i18n
keys that do not exist in the message catalog:

- console.error used "resourceErrorDelete" (the catalog key is
  "resourceErrorDelte"), logging a raw key instead of the message.
- the toast description passed "v" to formatAxiosError, so a failed
  delete showed the user a bogus fallback title.

Point both at the existing "resourceErrorDelte" key, matching the
delete handlers in PublicResourcesTable and ResourcePoliciesTable. No
catalog/translation changes, so nothing changes for Crowdin.
2026-07-01 07:04:54 +05:30
miloschwartz 9f68be2a9b add empty state 2026-06-30 21:18:46 -04:00
miloschwartz fed4ec42c4 add server side fetching 2026-06-30 21:15:03 -04:00
miloschwartz f0efa4203b basic functionality 2026-06-30 21:03:19 -04:00
Owen cfbbdedaf5 rework ui 2026-06-30 17:44:35 -04:00
Owen 686789ee4c Properly translate 2026-06-30 15:37:42 -04:00
Owen 3fda190ff6 Merge branch 'backhaul' into dev 2026-06-30 15:21:11 -04:00
Owen 0033f40f4d Fix typo preventing subscription cancelation 2026-06-30 15:06:35 -04:00
Owen af95052706 Update schema for tracking valid domains 2026-06-30 13:53:44 -04:00
Owen 9bb2d6cdc8 Prompt for the username on vnc 2026-06-30 12:16:58 -04:00
Owen 29563a13a4 Add indexes on the niceId and orgId 2026-06-30 09:49:53 -04:00
Owen b41c1f5b27 Add restart button 2026-06-29 21:10:49 -04:00
Owen e5652cdb8a Dont enable admin routes 2026-06-29 20:45:38 -04:00
Owen 7c2ea153c5 Use regional cache for rate limiting 2026-06-29 18:33:03 -04:00
Owen ccabddc225 Add logging for access for new public resources 2026-06-29 18:05:29 -04:00
Owen 42d98fa83b Comment back in the sync command 2026-06-29 16:34:10 -04:00
Owen 2f2b7f43c1 Add usage tracking to blueprints 2026-06-29 16:13:12 -04:00
Owen 528bbeca26 Implement usage tracking on resources, clients 2026-06-29 15:39:30 -04:00
Owen d60c15b0ae Fix typo 2026-06-29 15:24:16 -04:00
Owen ff89a64453 Rename to limit id 2026-06-29 15:22:35 -04:00
Owen 4718c489d3 Add concurrency guard calculateUserClientsForOrgs 2026-06-29 15:02:41 -04:00
Owen d5d99a4804 Add org rebuild rate limit 2026-06-29 14:59:05 -04:00
Fred KISSIE 8d29602929 🏷️ fix types 2026-06-29 18:06:03 +02:00
Owen 9c18936be7 Filter the nodes based on the preference labels 2026-06-29 11:40:25 -04:00
Owen cf07cceb5d Fix bad col in pg 2026-06-29 11:31:34 -04:00
Owen faee9e6330 Merge branch 'main' into dev 2026-06-29 11:29:12 -04:00
miloschwartz 31725eb3cc display resource type in info card 2026-06-29 10:45:38 -04:00
miloschwartz 04d4e298e8 fix spacing on endpoint field on site 2026-06-29 10:42:25 -04:00
Owen c9cc9581b1 Add batch update 2026-06-26 23:24:14 -04:00
Fred KISSIE 6c2a8bf6de 💄 Show the selected country in policy access rules country is not rule match 2026-06-27 02:12:09 +02:00
Fred KISSIE 697be01411 Merge branch 'dev' into feat/geoip-country-is-not-rule 2026-06-27 02:05:02 +02:00
Owen eac7c67dcc Send down remote subnets 2026-06-26 18:09:56 -04:00
Owen 633d9031af Add labels input 2026-06-26 18:02:20 -04:00
Owen 05dc558c4a Add basic resources input on the remote node 2026-06-26 18:01:24 -04:00
Fred KISSIE 1bd6f240cc ♻️ add Country is not rule to verifySession 2026-06-26 23:18:46 +02:00
miloschwartz 7506c0420d properly pass org policy error message in olm register 2026-06-26 17:11:32 -04:00
Owen Schwartz 5572822c4a Merge pull request #3351 from fosrl/copilot/fix-rest-ruleid-changing
fix: preserve rule IDs when saving policy rules via the GUI
2026-06-26 15:05:31 -04:00
Owen ea3f1c341b Move hashing outside of transaction 2026-06-26 14:40:39 -04:00
Owen 35dffe71cb Make error statement debug 2026-06-26 14:40:31 -04:00
copilot-swe-agent[bot] 5428bf4ed0 fix: preserve rule IDs when saving policy rules through the GUI
The `setResourcePolicyRules` endpoint was deleting all existing rules and
re-inserting them on every save, causing all ruleIDs to change.

Backend: Accept an optional `ruleId` per rule in the request body and
perform an upsert — update existing rules (matched by ruleId), insert
new ones (no ruleId), and delete only rules absent from the incoming list.

Frontend: Include `ruleId` in the rules payload for existing (non-new)
rules so the backend can match and preserve them.
2026-06-26 14:37:34 +00:00
copilot-swe-agent[bot] 9a89579e08 Initial plan 2026-06-26 14:33:35 +00:00
Owen Schwartz 784588cebc Merge pull request #3350 from fosrl/dev
Make sure the rebuild actually executes
2026-06-26 09:28:12 -04:00
Owen 2e628fe0e4 Make sure the rebuild actually executes 2026-06-26 09:26:43 -04:00
Owen Schwartz 7590e8d8a1 Merge pull request #3345 from fosrl/dev
Show utility subnet on org
2026-06-25 13:05:32 -07:00
Owen 053ff1e799 Add utility subnet 2026-06-25 15:39:04 -04:00
Fred KISSIE 278375f7be 🚧 More country is not rule 2026-06-25 21:26:13 +02:00
Owen Schwartz c5ffca499e Merge pull request #3330 from fosrl/dev
1.19.3
2026-06-25 11:54:25 -07:00
Fred KISSIE 65c383520b 💄 adjust match type column width 2026-06-25 20:49:55 +02:00
Owen Schwartz 9ae54f445d Merge pull request #3343 from fosrl/crowdin_dev
New Crowdin updates
2026-06-25 11:45:14 -07:00
Owen Schwartz 8183d19400 New translations en-us.json (Norwegian Bokmal)
[ci skip]
2026-06-25 11:44:44 -07:00
Owen Schwartz 7425daad3f New translations en-us.json (Chinese Simplified)
[ci skip]
2026-06-25 11:44:43 -07:00
Owen Schwartz 39d61a35eb New translations en-us.json (Turkish)
[ci skip]
2026-06-25 11:44:41 -07:00
Owen Schwartz f3fe11c136 New translations en-us.json (Russian)
[ci skip]
2026-06-25 11:44:39 -07:00
Owen Schwartz 66b1b385a3 New translations en-us.json (Portuguese)
[ci skip]
2026-06-25 11:44:37 -07:00
Owen Schwartz 822a07d48e New translations en-us.json (Polish)
[ci skip]
2026-06-25 11:44:35 -07:00
Owen Schwartz 3c13b1ea15 New translations en-us.json (Dutch)
[ci skip]
2026-06-25 11:44:33 -07:00
Owen Schwartz fee635b861 New translations en-us.json (Korean)
[ci skip]
2026-06-25 11:44:31 -07:00
Owen Schwartz 7e4dea918a New translations en-us.json (Italian)
[ci skip]
2026-06-25 11:44:29 -07:00
Owen Schwartz 56187d61d5 New translations en-us.json (German)
[ci skip]
2026-06-25 11:44:27 -07:00
Owen Schwartz 36460d4cc0 New translations en-us.json (Danish)
[ci skip]
2026-06-25 11:44:25 -07:00
Owen Schwartz 88a9b92dc3 New translations en-us.json (Czech)
[ci skip]
2026-06-25 11:44:23 -07:00
Owen Schwartz dd26518d6f New translations en-us.json (Bulgarian)
[ci skip]
2026-06-25 11:44:21 -07:00
Owen Schwartz 60339706bb New translations en-us.json (Spanish)
[ci skip]
2026-06-25 11:44:19 -07:00
Owen Schwartz 4b1b3d3d5b New translations en-us.json (French)
[ci skip]
2026-06-25 11:44:17 -07:00
Owen cf21bacd9c Merge branch 'main' into dev 2026-06-25 14:30:05 -04:00
Fred KISSIE e54bd25516 🚧 add country is not rule 2026-06-25 20:28:57 +02:00
Owen Schwartz 80d257b94b Merge pull request #3336 from mr1beast/main
New translations en-us.json (Danish)
2026-06-25 11:24:56 -07:00
Owen Schwartz e0d0c5dcbf Merge pull request #3331 from Fredkiss3/feat/geoip-tag-in-tables
feat: Show GeoIp country flags in site & rules page
2026-06-25 11:23:42 -07:00
Fred KISSIE 0f02d1bc02 ♻️ remove deprecated ISO CS country code 2026-06-25 18:01:25 +02:00
Owen f8591f27c5 Fix no data when last data was over 90 days ago 2026-06-25 10:08:37 -04:00
Owen 877985deb3 Spellcheck 2026-06-24 18:36:01 -04:00
Owen be3877a3ce Rename for clarity 2026-06-24 18:36:01 -04:00
Owen 79de64dc07 Fix removing site not removing peer 2026-06-24 18:36:01 -04:00
miloschwartz d0defa380a remove split by command and space in role form 2026-06-24 17:52:21 -04:00
miloschwartz 4eba51de72 support delete resources associated with site 2026-06-24 17:45:44 -04:00
mr1beast a48032adb3 New translations en-us.json (Danish)
New translations en-us.json (Danish)
2026-06-24 21:25:57 +00:00
miloschwartz 6fe4eee336 improve org policy error message responses 2026-06-24 16:32:58 -04:00
Owen 242123b875 Implement non-redis lock 2026-06-24 16:01:05 -04:00
miloschwartz 2b38658ea6 make sidebar notification failures more resilient 2026-06-24 15:55:29 -04:00
miloschwartz b18a41e4aa adjust translation 2026-06-24 15:55:29 -04:00
Owen d303fa05cb Comment out the sync 2026-06-24 15:50:54 -04:00
Owen 75b87ffba7 Quiet log message 2026-06-24 15:49:51 -04:00
Owen 62fc2edae9 Add logging and fix removing alias 2026-06-24 15:28:46 -04:00
Owen 80b66cf9b9 Add locks to rebuilds 2026-06-24 14:13:11 -04:00
Owen 034bcbd271 Reorg 2026-06-24 11:54:56 -04:00
Owen bc63747efe Refactor out transactions and always call rebuild on update 2026-06-23 18:12:51 -04:00
Fred KISSIE bb7729df00 💄 show geoip flag in policy access rule tab 2026-06-23 23:45:59 +02:00
Owen 2a8ceeec1b Restrict admin role 2026-06-23 17:45:42 -04:00
Owen 91ef0d0153 Show warning about the .local aliases 2026-06-23 17:44:18 -04:00
Fred KISSIE e104489257 💄 Show GeoIp flag in site details page 2026-06-23 23:28:46 +02:00
Owen b8101402cd Merge branch 'main' into dev 2026-06-23 17:14:53 -04:00
Owen 7731849a2f Standardize db rebuildClientAssociationsFromClient 2026-06-23 17:14:40 -04:00
Owen c11d24e10a Standardize db rebuildClientAssociationsFromClient 2026-06-23 17:14:40 -04:00
Owen a9b7cce49b Improve efficiency of calculateUserClientsForOrgs 2026-06-23 17:14:40 -04:00
Owen d78223b94f Fix import to be private 2026-06-23 17:14:40 -04:00
Owen Schwartz 963e9da7dd Merge pull request #3213 from ivenos/rename-to-compose-yaml
Rename docker-compose.yml to compose.yaml
2026-06-23 12:03:56 -07:00
Owen Schwartz 2cbc88fa05 Merge pull request #3326 from fosrl/dependabot/npm_and_yarn/js-yaml-4.2.0
Bump js-yaml from 4.1.1 to 4.2.0
2026-06-23 12:02:49 -07:00
Owen Schwartz 65bad456cb Merge pull request #3327 from fosrl/crowdin_dev
New Crowdin updates
2026-06-23 12:02:11 -07:00
Owen f48a4f7bc0 Enforce strick query params
Fixes #3313
2026-06-23 12:22:47 -04:00
Owen ce3c2f7583 Fix #3314 2026-06-23 12:05:47 -04:00
Owen 51c357e6c7 Merge branch 'main' into dev 2026-06-23 11:30:46 -04:00
Owen Schwartz 7ae29612d4 Merge pull request #3302 from RitwijParmar/codex/resource-update-inline-policy-response
Fix inline policy fields in resource update response
2026-06-23 08:30:31 -07:00
Owen da794adb7d Merge branch 'main' into dev 2026-06-23 11:26:03 -04:00
Owen 8004ae6870 Use the policy when updating rule
Fixes #3273
2026-06-23 11:25:53 -04:00
Owen Schwartz 1bff7bbc2f Merge pull request #3315 from fosrl/dependabot/npm_and_yarn/nodemailer-9.0.1
Bump nodemailer from 8.0.9 to 9.0.1
2026-06-23 07:58:58 -07:00
Owen Schwartz 50db5695fc Merge pull request #3264 from fosrl/dependabot/npm_and_yarn/esbuild-0.28.1
Bump esbuild from 0.28.0 to 0.28.1
2026-06-23 07:57:47 -07:00
Owen Schwartz babd90ae71 New translations en-us.json (Norwegian Bokmal)
[ci skip]
2026-06-22 15:22:46 -07:00
Owen Schwartz f7050ef989 New translations en-us.json (Chinese Simplified)
[ci skip]
2026-06-22 15:22:44 -07:00
Owen Schwartz b2778a2c49 New translations en-us.json (Turkish)
[ci skip]
2026-06-22 15:22:42 -07:00
Owen Schwartz 096940a152 New translations en-us.json (Russian)
[ci skip]
2026-06-22 15:22:40 -07:00
Owen Schwartz 73eb07de71 New translations en-us.json (Portuguese)
[ci skip]
2026-06-22 15:22:38 -07:00
Owen Schwartz 74ef844e27 New translations en-us.json (Polish)
[ci skip]
2026-06-22 15:22:36 -07:00
Owen Schwartz c58968536d New translations en-us.json (Dutch)
[ci skip]
2026-06-22 15:22:34 -07:00
Owen Schwartz 228efacfe0 New translations en-us.json (Korean)
[ci skip]
2026-06-22 15:22:32 -07:00
Owen Schwartz 1262030abb New translations en-us.json (Italian)
[ci skip]
2026-06-22 15:22:30 -07:00
Owen Schwartz b07fe6d18b New translations en-us.json (German)
[ci skip]
2026-06-22 15:22:28 -07:00
Owen Schwartz 63c3ee623b New translations en-us.json (Czech)
[ci skip]
2026-06-22 15:22:26 -07:00
Owen Schwartz 18ec6c8d92 New translations en-us.json (Bulgarian)
[ci skip]
2026-06-22 15:22:24 -07:00
Owen Schwartz d8acccbde4 New translations en-us.json (Spanish)
[ci skip]
2026-06-22 15:22:22 -07:00
Owen Schwartz 37eaf34e4d New translations en-us.json (French)
[ci skip]
2026-06-22 15:22:20 -07:00
dependabot[bot] cfb63f9742 Bump js-yaml from 4.1.1 to 4.2.0
Bumps [js-yaml](https://github.com/nodeca/js-yaml) from 4.1.1 to 4.2.0.
- [Changelog](https://github.com/nodeca/js-yaml/blob/master/CHANGELOG.md)
- [Commits](https://github.com/nodeca/js-yaml/compare/4.1.1...4.2.0)

---
updated-dependencies:
- dependency-name: js-yaml
  dependency-version: 4.2.0
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-06-22 21:04:39 +00:00
Owen Schwartz c76b4555e1 Merge pull request #3316 from fosrl/dependabot/npm_and_yarn/form-data-4.0.6
Bump form-data from 4.0.5 to 4.0.6
2026-06-22 14:03:06 -07:00
Owen Schwartz c25bfbad27 Merge pull request #3317 from fosrl/dependabot/github_actions/actions/checkout-7.0.0
Bump actions/checkout from 6.0.2 to 7.0.0
2026-06-22 14:02:51 -07:00
Owen Schwartz 44782f8963 Merge pull request #3323 from fosrl/dependabot/go_modules/install/go-install-dependencies-4dfeb96e78
Bump golang.org/x/term from 0.43.0 to 0.44.0 in /install in the go-install-dependencies group
2026-06-22 14:02:31 -07:00
Owen Schwartz e6f7cd6da9 Merge pull request #3206 from gmpinder/fix-idp-delete
fix: Add DELETE /idp/{idpId} to integration API
2026-06-22 14:01:47 -07:00
Owen Schwartz 19faa3a29c Merge pull request #3223 from Adityakk9031/#2867
fix: request logs not loading on initial page open in Community Editi…
2026-06-22 14:00:29 -07:00
Owen c284dc2e83 Merge branch 'Fredkiss3-refactor/show-if-client-needs-update' into dev 2026-06-22 16:58:55 -04:00
Owen 1b634955d8 Merge branch 'refactor/show-if-client-needs-update' of github.com:Fredkiss3/pangolin into dev 2026-06-22 16:58:50 -04:00
Fred KISSIE be888c3fc1 💄 Show the latest new update in machine client table 2026-06-22 16:57:47 -04:00
Fred KISSIE 3f2bb42221 ♻️ lt instead of lte 2026-06-22 16:57:47 -04:00
Fred KISSIE 5dc3ae4c7f ♻️ sites & clients should not get latest versions on the server 2026-06-22 16:57:45 -04:00
Fred KISSIE ffb6c64de0 💄 Show updates available in the frontend, on sites & user devices 2026-06-22 16:57:08 -04:00
Fred KISSIE 2cbc6fb128 🏷️ types 2026-06-22 16:57:08 -04:00
Fred KISSIE 75084028d7 ♻️ Remove queries that prefetch 1000 users/roles in private resources form 2026-06-22 16:57:08 -04:00
Owen f44a7c55dd Merge branch 'refactor/show-if-client-needs-update' of github.com:Fredkiss3/pangolin into Fredkiss3-refactor/show-if-client-needs-update 2026-06-22 16:56:52 -04:00
Owen Schwartz 72fa1d6a14 Merge pull request #3325 from fosrl/queue
Improve performance of rebuild functions
2026-06-22 13:49:20 -07:00
Owen c3820a4e70 Add missing queuing 2026-06-22 16:47:52 -04:00
Owen 6b56c00782 Pull the listing out of the queue 2026-06-22 15:24:31 -04:00
Fred KISSIE 8f0e17774f 💄 Update command bar 2026-06-22 21:22:55 +02:00
Owen 60c1b572ba Add drizzle indexes to match db 2026-06-22 15:12:07 -04:00
Owen 604dee9aa5 Batch get olm ids 2026-06-22 15:12:07 -04:00
Owen ee42846c90 Add batch messaging functions to rebuild function 2026-06-22 15:12:07 -04:00
copilot-swe-agent[bot] 22ac711dc6 refactor: tighten ws batch typing and queue cleanup logging 2026-06-22 15:12:07 -04:00
copilot-swe-agent[bot] d09668b20b feat: batch redis ws direct messages and dedupe rebuild queue jobs 2026-06-22 15:12:07 -04:00
Owen 16abe98fd9 Add queue 2026-06-22 15:12:07 -04:00
copilot-swe-agent[bot] d240201361 Initial plan 2026-06-22 15:12:07 -04:00
Josh Voyles b7081aff11 fix: remove no-op autoFinalizeStatement wrapper and redundant busy_timeout (#2120)
better-sqlite3 11.x exposes no Statement.finalize() — the wrapper threw and
swallowed a TypeError on every query (verified: 'Statement.finalize exists:
undefined' in the runner image) while adding +122% per-statement overhead
(3.90 -> 8.66 us/op, 200k-op in-container microbench) and freeing nothing.
Statement lifecycle is GC-managed by the driver; drizzle-orm prepares fresh
per query, so nothing accumulates unbounded.

busy_timeout=5000 duplicates better-sqlite3's default timeout option, which
already arms sqlite3_busy_timeout(db, 5000) at open (lib/database.js).

With ENABLE_SQLITE_WAL_MODE unset the driver is now runtime-identical to
pre-1.18.3 (zero pragmas). The env-gated WAL block stays: journal_mode is
sticky in the DB file, so removing it would strand opted-in databases on
WAL+synchronous=FULL.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-22 15:11:51 -04:00
Josh Voyles a55fb21e53 fix(sqlite): remove cache_size and mmap_size PRAGMAs (#2120)
A 64 MB page cache plus a 256 MB memory-mapped region inflate RSS and
cause page-cache thrashing on small (~1 GB) instances. The PRAGMAs were
added to reduce event-loop blocking on TraefikConfigManager JOINs but
the memory cost outweighs the I/O benefit on the deployment shapes that
hit #2120. Leave SQLite on its conservative defaults.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-22 15:11:51 -04:00
copilot-swe-agent[bot] e5e7b79712 test: add normalized ASN validation coverage 2026-06-22 15:11:51 -04:00
copilot-swe-agent[bot] de48a0529e refactor: normalize ASN validation value once 2026-06-22 15:11:51 -04:00
copilot-swe-agent[bot] 3f37408dae fix: allow ALL ASN values in policy rule validation 2026-06-22 15:11:51 -04:00
copilot-swe-agent[bot] a2882857ff Initial plan 2026-06-22 15:11:51 -04:00
Owen 476d92b3ac Convert things to regional cache 2026-06-22 15:11:51 -04:00
Owen bf604f25e9 Show the input validation in the error report 2026-06-22 15:11:50 -04:00
Owen 34a0d2a68b Remove NoNewPrivileges
Fixes https://github.com/fosrl/newt/issues/383
2026-06-22 15:11:50 -04:00
Owen Schwartz 62c7e0a13e Merge pull request #3251 from kshitijshresth/fix-path-rule-regex-escaping
Fix unescaped regex metacharacters in PATH rule matching causing request failures
2026-06-22 07:40:00 -07:00
dependabot[bot] 753358a17d Bump golang.org/x/term in /install in the go-install-dependencies group
Bumps the go-install-dependencies group in /install with 1 update: [golang.org/x/term](https://github.com/golang/term).


Updates `golang.org/x/term` from 0.43.0 to 0.44.0
- [Commits](https://github.com/golang/term/compare/v0.43.0...v0.44.0)

---
updated-dependencies:
- dependency-name: golang.org/x/term
  dependency-version: 0.44.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: go-install-dependencies
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-06-22 14:32:24 +00:00
Owen Schwartz c859393418 Merge pull request #3225 from fosrl/chore/dependabot-single-pr-groups
chore(dependabot): group dependency updates into single PRs per ecosystem
2026-06-22 07:31:25 -07:00
dependabot[bot] d747b45f0b Bump actions/checkout from 6.0.2 to 7.0.0
Bumps [actions/checkout](https://github.com/actions/checkout) from 6.0.2 to 7.0.0.
- [Release notes](https://github.com/actions/checkout/releases)
- [Changelog](https://github.com/actions/checkout/blob/main/CHANGELOG.md)
- [Commits](https://github.com/actions/checkout/compare/de0fac2e4500dabe0009e67214ff5f5447ce83dd...9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0)

---
updated-dependencies:
- dependency-name: actions/checkout
  dependency-version: 7.0.0
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-06-22 01:33:16 +00:00
dependabot[bot] a24091257a Bump form-data from 4.0.5 to 4.0.6
Bumps [form-data](https://github.com/form-data/form-data) from 4.0.5 to 4.0.6.
- [Changelog](https://github.com/form-data/form-data/blob/master/CHANGELOG.md)
- [Commits](https://github.com/form-data/form-data/compare/v4.0.5...v4.0.6)

---
updated-dependencies:
- dependency-name: form-data
  dependency-version: 4.0.6
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-06-21 19:40:11 +00:00
dependabot[bot] 1c60041390 Bump nodemailer from 8.0.9 to 9.0.1
Bumps [nodemailer](https://github.com/nodemailer/nodemailer) from 8.0.9 to 9.0.1.
- [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.9...v9.0.1)

---
updated-dependencies:
- dependency-name: nodemailer
  dependency-version: 9.0.1
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-06-21 09:46:17 +00:00
Fred KISSIE 2ab5540085 ♻️ add command palette trigger on the header 2026-06-19 18:08:36 +02:00
Ritwij Aryan Parmar 95c3f74a33 Fix inline policy fields in resource update response 2026-06-17 14:36:34 -04:00
Fred KISSIE 4e7328a1cc ♻️ refactor private resource page 2026-06-16 22:29:03 +02:00
Fred KISSIE 6380079239 Command palette with search & actions 2026-06-16 22:28:31 +02:00
Fred KISSIE b6aba13d3a ♻️ rename query, add query 2026-06-16 22:28:04 +02:00
Fred KISSIE c0a9db92ef 💄 update command bar UI 2026-06-16 19:28:22 +02:00
Owen Schwartz 16c0f4eef4 Merge pull request #3277 from fosrl/dev
Fix middleware and suppoter footer
2026-06-14 14:44:33 -07:00
Owen a08c6d70fe Comment out 2026-06-14 14:44:08 -07:00
miloschwartz a6568692b7 force set supporter status to true in server info endpoint 2026-06-14 14:40:37 -07:00
Owen a1196d3da6 Remove supporter warning 2026-06-14 14:34:39 -07:00
Owen 70bc4c0b30 Remove the path rewrite from the next route 2026-06-14 14:30:16 -07:00
Owen Schwartz a0fef89031 Merge pull request #3276 from fosrl/dev
Rewrite headers
2026-06-14 14:13:54 -07:00
Owen ea1badf4e0 Add middleware for rewriting host headers 2026-06-14 12:04:02 -07:00
Owen Schwartz f15654ed11 Merge pull request #3275 from fosrl/dev
Fill in missing ui urls from the passed params
2026-06-14 11:36:01 -07:00
Owen 4435a669a6 Fill in missing ui urls from the passed params 2026-06-14 11:35:27 -07:00
Owen Schwartz 0b41fe3d49 Merge pull request #3268 from fosrl/dev
Send browser gateway rsources to remote nodes
2026-06-14 11:11:06 -07:00
Owen 90eceb457a Clean up url passing 2026-06-14 11:10:05 -07:00
Owen f39cbc9bf4 Add same signature to oss 2026-06-14 11:03:14 -07:00
Owen 50da863bb7 Add maintence page support for remote nodes 2026-06-13 21:45:52 -07:00
Owen c6ddd5c402 Open up holepunch requirements 2026-06-13 14:14:34 -07:00
Owen 0fb5ace9c7 Support the browser gateways on the remote nodes 2026-06-13 14:08:03 -07:00
dependabot[bot] cedccd8cdb Bump esbuild from 0.28.0 to 0.28.1
Bumps [esbuild](https://github.com/evanw/esbuild) from 0.28.0 to 0.28.1.
- [Release notes](https://github.com/evanw/esbuild/releases)
- [Changelog](https://github.com/evanw/esbuild/blob/main/CHANGELOG.md)
- [Commits](https://github.com/evanw/esbuild/compare/v0.28.0...v0.28.1)

---
updated-dependencies:
- dependency-name: esbuild
  dependency-version: 0.28.1
  dependency-type: direct:development
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-06-13 01:23:46 +00:00
Fred KISSIE 3bc5ab2136 🚧 wip 2026-06-13 00:48:32 +02:00
Fred KISSIE ce77268c82 🚧 wip: block top position of command palette 2026-06-13 00:47:36 +02:00
Fred KISSIE abc0a41d9e ♻️ reorganize command bar items 2026-06-13 00:27:39 +02:00
Owen Schwartz b9db0a4490 Merge pull request #3261 from fosrl/dev
1.19.2
2026-06-12 15:02:58 -07:00
Owen 39f40e5160 Allow missing agent host 2026-06-12 15:01:09 -07:00
Fred KISSIE a68b57067c 🚧 wip: Use command bar nav items 2026-06-12 23:51:05 +02:00
Owen 3fd5c98def Fix #3252 2026-06-12 14:44:45 -07:00
Owen 5a8a48f9bf Enforce the action inside of the function 2026-06-12 14:22:17 -07:00
Owen 471ae98204 Pull roles from resource policies
Fixes #3256
2026-06-12 14:12:01 -07:00
Owen Schwartz d985bfd3a6 Merge pull request #3260 from fosrl/crowdin_dev
New Crowdin updates
2026-06-12 13:58:34 -07:00
Owen Schwartz aab51a999c New translations en-us.json (Norwegian Bokmal)
[ci skip]
2026-06-12 13:53:58 -07:00
Owen Schwartz ae4f5aa58d New translations en-us.json (Chinese Simplified)
[ci skip]
2026-06-12 13:53:57 -07:00
Owen Schwartz 70d5f55437 New translations en-us.json (Turkish)
[ci skip]
2026-06-12 13:53:55 -07:00
Owen Schwartz 08df4b93aa New translations en-us.json (Russian)
[ci skip]
2026-06-12 13:53:54 -07:00
Owen Schwartz 61f0bc95c7 New translations en-us.json (Portuguese)
[ci skip]
2026-06-12 13:53:52 -07:00
Owen Schwartz 5b0f79a8bc New translations en-us.json (Polish)
[ci skip]
2026-06-12 13:53:50 -07:00
Owen Schwartz 86ff272095 New translations en-us.json (Dutch)
[ci skip]
2026-06-12 13:53:49 -07:00
Owen Schwartz ef0575cc36 New translations en-us.json (Korean)
[ci skip]
2026-06-12 13:53:48 -07:00
Owen Schwartz 30a6889ba8 New translations en-us.json (Italian)
[ci skip]
2026-06-12 13:53:46 -07:00
Owen Schwartz ce43e717d5 New translations en-us.json (German)
[ci skip]
2026-06-12 13:53:44 -07:00
Owen Schwartz 1f0361e687 New translations en-us.json (Czech)
[ci skip]
2026-06-12 13:53:43 -07:00
Owen Schwartz 07ae57ea72 New translations en-us.json (Bulgarian)
[ci skip]
2026-06-12 13:53:41 -07:00
Owen Schwartz fc982232d6 New translations en-us.json (Spanish)
[ci skip]
2026-06-12 13:53:40 -07:00
Owen Schwartz afaf5f976e New translations en-us.json (French)
[ci skip]
2026-06-12 13:53:38 -07:00
Owen a8ca28acb2 Add default for target type
Fixes #3247
2026-06-12 11:29:16 -07:00
kshitijshresth b136bd2246 Escape regex metacharacters in PATH rule wildcard matching
isValidUrlGlobPattern accepts characters like ( ) [ ] { } | . + ^ $ in PATH rule values, but isPathAllowed converted wildcard segments to regex without escaping them. A rule value such as /(api* produced an invalid regex and threw on every request to the resource, surfacing as a 500 from verifySession. Literal characters like . and + also changed matching semantics. isPathAllowed is extracted to server/lib/pathMatch.ts as a pure module, metacharacters are escaped before wildcard substitution, compiled segment regexes are cached, and the test suite now imports the real implementation instead of a stale copy, with added coverage for special characters.
2026-06-12 11:21:21 +03:00
Owen Schwartz d9952b0762 Merge pull request #3250 from fosrl/dev
1.19.1
2026-06-11 22:05:24 -07:00
Owen 935593885a Adjust 1.19 and add 1.19.1 to ensure sso not null 2026-06-11 22:01:20 -07:00
miloschwartz 3fcfd3304f fix address input width 2026-06-11 18:34:22 -07:00
Owen Schwartz 6e271028f3 Merge pull request #3245 from fosrl/dev
Bugfixes
2026-06-11 16:17:41 -07:00
Owen 820f66e58f Properly hide things with disable enterprise flag 2026-06-11 16:10:29 -07:00
Owen b0fdc10e06 Properly hide things with disable enterprise flag 2026-06-11 16:01:32 -07:00
miloschwartz b82b41ed26 fix migration 2026-06-11 15:02:29 -07:00
Fred KISSIE 444d293a29 🚧 WIP: copy command bar from existing PR 2026-06-11 23:46:11 +02:00
miloschwartz 3e977ba00d make paid alert position more consistent on resource 2026-06-11 12:38:08 -07:00
Owen Schwartz a724b07846 Merge pull request #3244 from fosrl/dev
fix paywalling
2026-06-11 12:27:49 -07:00
Owen 5f0bc71bcd Merge branch 'main' into dev 2026-06-11 12:26:31 -07:00
miloschwartz aea7827c1a fix paywalling 2026-06-11 12:26:01 -07:00
Fred KISSIE 7a275c86c2 Merge branch 'dev' into refactor/show-if-client-needs-update 2026-06-11 21:05:31 +02:00
Fred KISSIE 4b703b5c11 💄 Show the latest new update in machine client table 2026-06-11 20:58:23 +02:00
Owen Schwartz d865c4c55b Merge pull request #3242 from fosrl/dev
Use ssh like mode host
2026-06-11 11:29:45 -07:00
Owen 5baf0c3c09 Use ssh like mode host 2026-06-11 11:11:50 -07:00
Fred KISSIE 1b6e9e8cfe ♻️ lt instead of lte 2026-06-11 19:55:48 +02:00
Owen Schwartz cfe33eb974 Merge pull request #3241 from fosrl/dev
dev
2026-06-10 21:47:44 -07:00
Owen 71273e1b1c Try to fix large query problem 2026-06-10 21:41:34 -07:00
Owen 02f6e2a8c3 Add ; fix lint 2026-06-10 20:56:26 -07:00
Owen Schwartz 3cc244a1d3 Merge pull request #3240 from fosrl/dev
Fix small bugs with paid features, ui, docs
2026-06-10 20:49:59 -07:00
Owen 1d9c4dd9e2 Fix padding 2026-06-10 20:46:53 -07:00
Owen b9dd0c8e43 Add advantech install link 2026-06-10 20:46:43 -07:00
Owen cd052976eb Properly paywall the edit policy screen 2026-06-10 20:38:59 -07:00
Owen cc498f0e33 Properly paywall ui for labels 2026-06-10 20:32:07 -07:00
Owen 1a942937e6 Remove precheck on websocket for now 2026-06-10 20:24:41 -07:00
Owen d81d1a6b7f Merge branch 'dev' of github.com:fosrl/pangolin into dev 2026-06-10 20:24:22 -07:00
Owen f64d04e827 Add loading back to create resource 2026-06-10 18:23:01 -07:00
miloschwartz 540aee3fe2 update docs links 2026-06-10 17:52:42 -07:00
Owen Schwartz 10542d7282 Merge pull request #3239 from fosrl/dev
1.19.0
2026-06-10 16:50:32 -07:00
Owen b1d52ad1a3 Update tiers 2026-06-10 16:27:25 -07:00
Owen ce2fbef805 Filter only newt sites in the browser gateway 2026-06-10 16:16:42 -07:00
Owen e312b31e02 Merge branch 'dev' of github.com:fosrl/pangolin into dev 2026-06-10 15:50:52 -07:00
Owen bc156c715d 24 hour requirement for updates 2026-06-10 15:50:43 -07:00
miloschwartz 9a4c1f23c6 support remove server admin 2026-06-10 15:10:58 -07:00
Fred KISSIE fe55956079 ♻️ sites & clients should not get latest versions on the server 2026-06-10 22:58:42 +02:00
Fred KISSIE 4cd0b9a0bb 💄 Show updates available in the frontend, on sites & user devices 2026-06-10 22:57:55 +02:00
Fred KISSIE ab4d567af9 🏷️ types 2026-06-10 20:56:24 +02:00
miloschwartz 6921447fab fix typo 2026-06-10 11:55:20 -07:00
Owen d47449b082 Add notes about inline policy to api endpoints 2026-06-10 10:24:31 -07:00
Owen 665806dfe8 Add some documentation; pull the override values 2026-06-10 10:03:16 -07:00
Owen e248571268 Merge branch 'dev' of github.com:fosrl/pangolin into dev 2026-06-09 22:02:24 -07:00
miloschwartz fcf03854ff fix tag input wrapping 2026-06-09 22:01:13 -07:00
Owen dd1fba4e45 Also clear the roles and users 2026-06-09 21:59:30 -07:00
miloschwartz a1ab8d8f35 standardize client titles 2026-06-09 21:47:15 -07:00
miloschwartz c789e967db standardize client titles 2026-06-09 21:36:54 -07:00
Owen d870b9ff49 Drop the not null on resource columns 2026-06-09 21:36:27 -07:00
miloschwartz 9c09019ddb add protocol filter 2026-06-09 21:33:56 -07:00
Owen 9d88683fc5 Reset resource info when on inline policy 2026-06-09 21:28:25 -07:00
miloschwartz dd2c9f2a02 check resource policy in verifyResourceAccess middleware 2026-06-09 17:52:31 -07:00
miloschwartz bdb38db5bc fix form responsiveness 2026-06-09 16:52:18 -07:00
Owen 96a54fc9cc Fix import issue in migrations 2026-06-09 16:51:55 -07:00
Owen 3a485f74f1 Move session migration out of the loop 2026-06-09 16:16:14 -07:00
Owen 92b0340324 Merge branch 'dev' of github.com:fosrl/pangolin into dev 2026-06-09 16:10:35 -07:00
miloschwartz 9257ac01c7 add learn more links to auto update 2026-06-09 16:08:07 -07:00
Owen 4d1d0d9fcb Add warning if we cant reach the vnc server 2026-06-09 16:02:52 -07:00
Owen f186e7e99e Dont allow asn or country without having maxmind 2026-06-09 16:02:52 -07:00
miloschwartz 1aa6e3511f dont show policy on tcp/udp resources 2026-06-09 15:56:24 -07:00
miloschwartz fb6f5b3953 form layout improvements 2026-06-09 15:42:28 -07:00
Owen c85a7f6ac5 Migrate unkown openapi response from string to {} 2026-06-09 15:35:08 -07:00
Owen dd54be523f Dont need to check user exists for the whitelist 2026-06-09 15:26:35 -07:00
Owen d57f064d4c Fix spelling 2026-06-09 15:26:35 -07:00
miloschwartz 34799b7de2 move maintenance page config to tab 2026-06-09 15:07:55 -07:00
miloschwartz 20a66bba6f fix resource context updating problem 2026-06-09 14:49:57 -07:00
miloschwartz cdb43d9658 dont set whitelist until click set button in dialog 2026-06-09 14:36:50 -07:00
miloschwartz 6581ccafa3 fix toggle on pin or passcode not working on policy form 2026-06-09 14:34:58 -07:00
miloschwartz a3a45b4239 add safe read 2026-06-09 14:09:36 -07:00
Owen d6634b6e8a Add types 2026-06-09 12:16:00 -07:00
Owen 1089cfbacc Update query to be more efficient 2026-06-09 11:54:46 -07:00
Owen 1907a3c93b Link to primary org only when you can see billing 2026-06-09 10:33:42 -07:00
Fred KISSIE 38203e522b ♻️ Remove queries that prefetch 1000 users/roles in private resources form 2026-06-09 19:29:00 +02:00
miloschwartz 407ba567a0 various visual changes 2026-06-08 22:07:53 -07:00
Owen f28571629f Make sure the pamMode is push for host resources 2026-06-08 21:54:06 -07:00
Owen 5a575c916b Handle backward compatability 2026-06-08 21:11:57 -07:00
Owen 9a7e534b10 Ssh session closed card 2026-06-08 17:44:48 -07:00
Owen 42974d1739 Make sure the skip to idp is pulled 2026-06-08 17:41:59 -07:00
Owen 780e8babe4 Perfect toolbar 2026-06-08 17:39:07 -07:00
Owen 2c7b8006cf Add gray bar 2026-06-08 16:07:36 -07:00
Owen 35066c1388 Add pulldown toolbar 2026-06-08 16:00:38 -07:00
miloschwartz 135a5d38af make form grids more consistent 2026-06-08 16:00:30 -07:00
Owen 1b7c1ffa70 Set the target port from the resource 2026-06-08 15:39:26 -07:00
Owen 641f643d2d Prefil the port with the best guess port 2026-06-08 15:39:26 -07:00
Owen b4ecfceb5e Show more information about error 2026-06-08 15:39:25 -07:00
Owen 08a84d4bb1 Add some connection feedback 2026-06-08 15:39:25 -07:00
Owen 4dbad7ab24 Close the tab when exiting 2026-06-08 15:39:25 -07:00
miloschwartz 859c0c9477 add description text to share link path input 2026-06-08 15:33:12 -07:00
miloschwartz d294bf8534 support uploading csv or txt to sudo commands and groups 2026-06-08 15:30:03 -07:00
miloschwartz 3c8fea382f improve unix group and sudo commands inputs 2026-06-08 14:36:10 -07:00
Owen b81bfcfcee Fix type error 2026-06-08 12:21:43 -07:00
Milo Schwartz 56c415ca05 Merge pull request #3219 from Fredkiss3/refactor/standardize-clear-buttons
feat: make clear filter buttons more consistent accross tables
2026-06-08 12:07:55 -07:00
Owen 74fdcceace Reconnect newts when a exit node comes back online 2026-06-08 12:02:12 -07:00
Owen 7dec8ba998 Add exit node if the sites dont have one 2026-06-08 12:02:12 -07:00
miloschwartz c9dc6affe7 Merge branch 'dev' into resource-policies-restyle 2026-06-08 12:00:08 -07:00
miloschwartz 8fe45ba78c prevent duplicate label names 2026-06-08 11:59:15 -07:00
Fred KISSIE 934886caea Merge branch 'dev' into refactor/standardize-clear-buttons 2026-06-08 20:42:11 +02:00
miloschwartz fae258b145 add labels to user-resources query 2026-06-08 10:55:24 -07:00
miloschwartz 9f224f655f Merge branch 'resource-policies-restyle' into dev 2026-06-08 10:38:13 -07:00
miloschwartz aea7df7dc2 rename share links 2026-06-08 10:37:46 -07:00
miloschwartz 3b675f7de1 policies and policy on resource structure in a good place 2026-06-07 12:19:33 -07:00
Owen 8daf7c2872 Rename and add browser target update 2026-06-07 12:07:08 -07:00
Owen c394490473 Update browser targets 2026-06-07 10:43:16 -07:00
Marc Schäfer 92d611df9a chore(dependabot): group dependency updates into single PRs per ecosystem 2026-06-07 11:10:53 +02:00
Owen 3b6b78b3e1 Update traefik config 2026-06-06 16:14:20 -07:00
miloschwartz aa47f522ef move toggle on general page 2026-06-06 15:34:34 -07:00
Owen 8658198a93 Remove unnessicary auth token 2026-06-06 13:47:23 -07:00
Owen 4b770d1385 Fix issues 2026-06-06 13:34:24 -07:00
miloschwartz cd4d7372a0 Merge branch 'resource-policies-restyle' into dev 2026-06-06 12:27:59 -07:00
Owen dc8243cb51 Fix form rendering issue 2026-06-06 12:27:14 -07:00
miloschwartz 7b1f8d98f3 make the rule rows draggable 2026-06-06 12:27:11 -07:00
miloschwartz dd8bcbb3e3 first pass restyle of auth methods and rules 2026-06-05 21:04:03 -07:00
Owen d1af7a153f Enforece some more things on the types 2026-06-05 16:57:53 -07:00
Owen 13efa47db7 Merge branch 'dev' of github.com:fosrl/pangolin into dev 2026-06-05 16:12:46 -07:00
Owen 69bd61c308 Update migrations 2026-06-05 16:02:28 -07:00
Owen 7b7ff51289 Add target mode and auth token 2026-06-05 15:37:21 -07:00
Owen 772ac8af73 Remove browser gateway targets for regular targets 2026-06-05 15:30:42 -07:00
miloschwartz 8ee520dbb5 form validation improvements 2026-06-05 14:55:27 -07:00
Owen 8e5d9e94a9 Fix delete site only working on newt site 2026-06-05 14:37:44 -07:00
Owen c9cb28af45 Rename to public-policies 2026-06-05 14:30:36 -07:00
Fred KISSIE a994f8ff07 💄 Column filter buttons for log tables 2026-06-05 21:47:08 +02:00
miloschwartz ea8eaf9736 adjust targets input styles 2026-06-05 12:43:00 -07:00
miloschwartz b78db3daef move policies routes 2026-06-05 12:43:00 -07:00
Owen 7cf3f8df92 Rename proxy -> public 2026-06-05 12:12:27 -07:00
Owen f2b5cff3f9 Fix resource protection status showing wrong 2026-06-05 12:12:27 -07:00
Owen 6de9ab8f05 Add watermark on missing login pages 2026-06-05 12:12:27 -07:00
Owen ad0e800d8d Fix validation error and bring alias back to table 2026-06-05 12:12:27 -07:00
Aditya kumar singh 13b691fd7d fix: request logs not loading on initial page open in Community Edition (#2867) 2026-06-06 00:34:48 +05:30
miloschwartz 65470fb64b adjust styles 2026-06-05 12:04:33 -07:00
miloschwartz f23142336b enable editing resource policy niceID 2026-06-05 11:57:55 -07:00
miloschwartz 2da4987cd3 rename policies 2026-06-05 11:52:51 -07:00
miloschwartz 253ba554a2 fix resources cell styling 2026-06-05 11:46:30 -07:00
Fred KISSIE 95ce91d94b Merge branch 'dev' into refactor/standardize-clear-buttons 2026-06-05 20:21:34 +02:00
Fred KISSIE a4548fd874 💄 Break all text 2026-06-05 19:59:28 +02:00
Fred KISSIE eb03fb7060 ♻️ standardize http request log data-tables 2026-06-05 19:28:30 +02:00
miloschwartz add9b8dfb0 many minor visual improvements 2026-06-04 22:25:18 -07:00
Owen 2adb7b64cb Add the resource name 2026-06-04 22:10:38 -07:00
Owen 84fef5f1d6 Resource policy api backward compatability 2026-06-04 22:02:42 -07:00
Owen def1e9c851 Add region back to the rules 2026-06-04 21:28:29 -07:00
Owen 67b08ca61e Properly do disable enterprise features this time 2026-06-04 21:18:04 -07:00
Owen 614df75880 Add policy to blueprints 2026-06-04 21:18:04 -07:00
Owen 676cf37ee2 Make sure things are paywalled in the blueprints 2026-06-04 21:18:04 -07:00
Owen 6b96e3dce6 Make sure the disableEnterpriseFeatures works 2026-06-04 21:18:04 -07:00
miloschwartz b67037e2ea use default animation speed on dialog 2026-06-04 18:19:23 -07:00
miloschwartz 5a5b77cf62 update project cursor rules 2026-06-04 18:13:26 -07:00
miloschwartz d2793dfad7 use react forms 2026-06-04 18:07:50 -07:00
miloschwartz ff507f1275 use standard error alert 2026-06-04 17:44:45 -07:00
miloschwartz 6b04bcb383 translate strings in auth pages for ssh, vnc, and rdp 2026-06-04 17:35:43 -07:00
miloschwartz b2f1115ef8 standardize and fix branding on new resources auth pages 2026-06-04 17:24:06 -07:00
Owen 567ef23ac4 Add initial advantech install commands 2026-06-04 16:59:58 -07:00
Owen 6affebc666 Finish adding ssh toggle 2026-06-04 16:59:58 -07:00
miloschwartz 889f78ddb8 use resource name in ssh/rdp/vnc page meta 2026-06-04 16:45:35 -07:00
Owen 9d3f96cf83 Add disable_private_http_placeholder 2026-06-04 16:41:07 -07:00
miloschwartz e5d0673bbf prefill site field on create private resource when filtering sites 2026-06-04 16:30:14 -07:00
miloschwartz 0907c0346f remove check on oidc login 2026-06-04 16:24:29 -07:00
Owen 6420a90d08 Replace tab component 2026-06-04 16:21:30 -07:00
Owen Schwartz 7fa1180d10 Merge pull request #3221 from fosrl/dev
1.19.0-rc.1
2026-06-04 15:45:27 -07:00
Owen 769d36e289 Fix http resources not being pulled 2026-06-04 15:36:25 -07:00
Owen a7a41b820e Add missing sshAccess key 2026-06-04 15:20:52 -07:00
Fred KISSIE 33fdc9a94f 🚧 wip: column filter button 2026-06-04 21:04:15 +02:00
Owen Schwartz 8b50f1fb65 Merge pull request #3218 from fosrl/dev
Fix installer
2026-06-04 11:21:59 -07:00
Owen 2d78a4b628 Fix installer 2026-06-04 11:21:40 -07:00
Fred KISSIE c86026c941 ♻️ refactor 2026-06-04 20:09:07 +02:00
Fred KISSIE db014e3446 ♻️ use the same clear filter text for clearing filters in the column filter buttons 2026-06-04 20:08:27 +02:00
Fred KISSIE feb8045643 ♻️ refactor 2026-06-04 19:54:43 +02:00
Fred KISSIE d485a09318 ♻️ use site label filter column 2026-06-04 19:45:54 +02:00
Fred KISSIE 9cff5f66b1 🚧 wip: site label column filter standardized 2026-06-04 19:40:24 +02:00
Owen Schwartz 527d4cc777 Merge pull request #3215 from fosrl/dev
1.19.0-rc.0
2026-06-04 10:34:20 -07:00
Owen Schwartz 01361884eb Potential fix for pull request finding 'CodeQL / Insecure randomness'
Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com>
2026-06-04 10:33:15 -07:00
Owen 6c4cbcab5d Fix eslint errors 2026-06-04 10:22:29 -07:00
Owen Schwartz aac25f0a53 Merge pull request #3214 from marcschaeferger/dev
Prevent cross-org site binding in target create/update
2026-06-04 10:11:53 -07:00
ivenos 89f3f3c8cd Rename docker-compose.yml to compose.yaml 2026-06-04 10:01:35 +02:00
miloschwartz 4a3c201741 improve mfa autofill 2026-06-03 21:54:55 -07:00
miloschwartz f5ab837cce remove idp user if unassociate idp, warn, and fix create user form bug 2026-06-03 21:42:18 -07:00
Owen Schwartz 00ec7a5c66 Merge pull request #3211 from fosrl/crowdin_dev
New Crowdin updates
2026-06-03 21:40:33 -07:00
Owen Schwartz 03df4d03ed New translations en-us.json (Spanish)
[ci skip]
2026-06-03 21:39:34 -07:00
Owen Schwartz 8488edd707 New translations en-us.json (Norwegian Bokmal)
[ci skip]
2026-06-03 21:39:32 -07:00
Owen Schwartz 920dbba2a3 New translations en-us.json (Chinese Simplified)
[ci skip]
2026-06-03 21:39:31 -07:00
Owen Schwartz 71e065a8bc New translations en-us.json (Turkish)
[ci skip]
2026-06-03 21:39:29 -07:00
Owen Schwartz e125c762b2 New translations en-us.json (Russian)
[ci skip]
2026-06-03 21:39:27 -07:00
Owen Schwartz 4f01f7c072 New translations en-us.json (Portuguese)
[ci skip]
2026-06-03 21:39:25 -07:00
Owen Schwartz 9a5ee9d489 New translations en-us.json (Polish)
[ci skip]
2026-06-03 21:39:24 -07:00
Owen Schwartz 665da931f1 New translations en-us.json (Dutch)
[ci skip]
2026-06-03 21:39:22 -07:00
Owen Schwartz 7595cf7ac7 New translations en-us.json (Korean)
[ci skip]
2026-06-03 21:39:20 -07:00
Owen Schwartz aa6232d0fc New translations en-us.json (Italian)
[ci skip]
2026-06-03 21:39:18 -07:00
Owen Schwartz beaf5dc843 New translations en-us.json (German)
[ci skip]
2026-06-03 21:39:17 -07:00
Owen Schwartz 7158855052 New translations en-us.json (Czech)
[ci skip]
2026-06-03 21:39:15 -07:00
Owen Schwartz 765b2d795f New translations en-us.json (Bulgarian)
[ci skip]
2026-06-03 21:39:13 -07:00
Owen Schwartz 66f00fcf94 New translations en-us.json (French)
[ci skip]
2026-06-03 21:39:11 -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
Gerald Pinder 44c16d69af fix: Add DELETE /idp/{idpId} to integration API 2026-06-03 12:48:34 -04: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
Aditya kumar singh 35d16ac683 fix: redirect lost after Pangolin auth session timeout (#3001) 2026-06-01 15:53:10 +05:30
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
Marc Schäfer f617f93a94 test(middleware): add regression tests for cross-org site binding prevention
Test the org-match logic in verifySiteAccess:
- Same org: allowed
- Cross-org: rejected with 403
- No prior org context (site-only routes): check skipped, normal flow

Test route stack ordering:
- verifySiteAccess runs after verifyResourceAccess/verifyTargetAccess
- verifySiteAccess runs before the target create/update handler

Test security scenarios for both WireGuard and newt site types.

Signed-off-by: Marc Schäfer <git@marcschaeferger.de>
2026-05-29 22:57:39 +00:00
Marc Schäfer 51629247a5 fix(middleware): prevent cross-org site binding in target create/update
Extend verifySiteAccess to check that when req.userOrgId is already set
by a prior middleware (e.g. verifyResourceAccess/verifyTargetAccess), the
site from req.body.siteId belongs to the same organization. This prevents
the cross-organization tunnel boundary bypass where an attacker with
resource access in one org binds that resource's target to a site in
another org.

Add verifySiteAccess to both target route stacks:
- PUT /resource/:resourceId/target (after verifyResourceAccess)
- POST /target/:targetId (after verifyTargetAccess)

The org-match check runs before req.userOrg is overwritten, so the
resource's organization context is preserved for comparison.

Signed-off-by: Marc Schäfer <git@marcschaeferger.de>
2026-05-29 22:44:16 +00: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](https://github.com/actions/stale/compare/b5d41d4e1d5dceea10e7104786b73624c18a190f...eb5cf3af3ac0a1aa4c9c45633dd1ae542a27a899)

---
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](https://github.com/docker/login-action/compare/4907a6ddec9925e35a0a9e82d7399ccc52663121...650006c6eb7dba73a995cc03b0b2d7f5ca915bee)

---
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 Schwartz c47c411161 Merge pull request #3114 from Fredkiss3/fix/tag-input-scroll
fix: make tag input wrap around instead of scrolling
2026-05-19 20:03:59 -07:00
Owen Schwartz e88e262abe Merge pull request #3004 from Fredkiss3/feat/labels-on-sites-and-resources
feat: site & resource labels
2026-05-19 20:03:22 -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
Fred KISSIE 6cacc9b83f 💄 limit tag width 2026-05-19 22:52:44 +02:00
Fred KISSIE 1f1791feb7 💄 make tag input wrap around instead of scrolling 2026-05-19 22:48:15 +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
Owen 08a08e73b3 derived only from roles that the user holds AND are assigned to the target resource 2026-05-19 10:53:54 -07:00
Fred KISSIE c500979099 Merge branch 'dev' into refactor/loading-animation-on-request-logs 2026-05-18 22:52:27 +02:00
Fred KISSIE 2d9c082607 💄 UI 2026-05-18 22:17:49 +02:00
Fred KISSIE 7968c4357b edit org label 2026-05-18 22:14:49 +02:00
Fred KISSIE 25c08e7279 Create label dialog 2026-05-18 21:57:44 +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
Owen 68e775659b Merge branch 'main' into dev 2026-05-16 20:45:39 -07:00
Owen 1c5e3000b6 Merge branch 'dev' of github.com:fosrl/pangolin into dev 2026-05-16 20:45:31 -07:00
Owen 3b93fd99a1 Remove workflows 2026-05-16 20:44:36 -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 Schwartz 159e91a07c Merge pull request #3090 from fosrl/github-action-cosign
Upgrade cosign installer to v4.1.2 and pin cosign version
2026-05-16 14:53:24 -07:00
miloschwartz 530b5082bd make online/connected styling consistent 2026-05-16 12:34:17 -07:00
Marc Schäfer 3322f1ccb4 Update cosign installer version in CI workflow 2026-05-16 16:21:13 +02:00
Marc Schäfer 1b17fba19f Upgrade cosign installer to v4.1.2 and pin cosign version
Updated cosign installer to version 4.1.2 and specified cosign release version.
2026-05-16 16:17:45 +02: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 dd1f7ba544 Make crowdsec --crowdsec 2026-05-14 21:46:26 -07:00
Owen Schwartz 8c2e6965f1 Merge pull request #3081 from fosrl/dev
Update sidebar
2026-05-14 21:21:03 -07:00
Owen b414f04cce Remove funding 2026-05-14 21:20:34 -07:00
Owen Schwartz 9c71922dda Merge pull request #3079 from fosrl/dev
Add site information to user api
2026-05-14 20:17:19 -07:00
Owen 6e4a28f227 Add site information as well 2026-05-14 18:02:42 -07:00
Owen Schwartz 64d8f035a2 Merge pull request #3077 from fosrl/dev
1.18.4-s.5
2026-05-14 17:41:51 -07:00
Owen 0a5780a3b3 Merge branch 'dev' of github.com:fosrl/pangolin into dev 2026-05-14 17:41:00 -07:00
Owen d58b96f4b1 Add port and icmp information to api endpoint 2026-05-14 17:39:22 -07:00
Owen Schwartz f778f5c941 Merge pull request #3071 from fosrl/crowdin_dev
New Crowdin updates
2026-05-14 17:20:50 -07:00
Owen 6422208f69 Optimize get all relays 2026-05-14 16:59:15 -07:00
Owen c3ebc423b5 Each node should only update its own sites 2026-05-14 16:51:09 -07:00
Fred KISSIE 68d7b0a416 🚧 wip: label 2026-05-14 22:43:29 +02:00
Fred KISSIE 43546c84eb 🚧 wip: create label dialog 2026-05-14 22:42:01 +02:00
Fred KISSIE eac36ee442 delete label 2026-05-14 22:15:43 +02:00
Owen Schwartz 92f992728f Merge pull request #3074 from fosrl/dev
Optimize building aliases in jit mode
2026-05-14 12:25:44 -07:00
Owen 78ad2d17c7 Optimize building aliases in jit mode 2026-05-14 12:25:05 -07:00
Fred KISSIE 9a88394efe 🛂 gate label endpoints behing subscription 2026-05-14 21:17:58 +02:00
Fred KISSIE 173562654b delete org label endpoint 2026-05-14 21:09:48 +02:00
Owen Schwartz b29bb7384d Merge pull request #3073 from fosrl/dev
Further optimizations
2026-05-14 12:00:25 -07:00
Owen 5a8de8210b Further optimizations 2026-05-14 11:59:59 -07:00
Owen Schwartz d5181454f4 Merge pull request #3072 from fosrl/dev
Optimize this
2026-05-14 11:34:56 -07:00
Owen 0e0666cacf Optimize this 2026-05-14 11:34:09 -07:00
Owen e1583a58aa USe right table 2026-05-14 11:33:42 -07:00
Owen Schwartz 02ba2393b9 New translations en-us.json (German)
[ci skip]
2026-05-14 11:08:12 -07:00
Fred KISSIE 8f7e5ab1ed 🚧 wip: org labels page 2026-05-14 19:31:53 +02:00
Fred KISSIE 4334480675 ♻️ refactor 2026-05-14 18:33:29 +02:00
Fred KISSIE 6aa406927a 🐛 fix error message 2026-05-14 18:20:26 +02:00
Fred KISSIE 5b50024712 Merge branch 'dev' into feat/labels-on-sites-and-resources 2026-05-14 18:15:14 +02: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 Schwartz daf260cf61 Merge pull request #3064 from fosrl/dev
1.18.4-s.1
2026-05-13 14:40:50 -07:00
Owen 92a06e0ea3 Handle jit mode with syncs 2026-05-13 14:00:43 -07:00
Owen c16d2ff2ed Fix log message 2026-05-13 13:52:35 -07:00
Owen 73a4d7d351 Quiet log message 2026-05-13 11:57:02 -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
Owen Schwartz 1860b4b862 Merge pull request #3061 from fosrl/dev
Add 1.18.4 migration
2026-05-12 21:01:39 -07:00
Owen efb1d69ac9 Add migration 2026-05-12 20:59:58 -07:00
Owen Schwartz 0601b55f22 Merge pull request #3060 from fosrl/dev
Translations
2026-05-12 20:48:55 -07:00
Owen Schwartz 107986d848 Merge pull request #3059 from fosrl/crowdin_dev
New Crowdin updates
2026-05-12 20:45:53 -07:00
Owen Schwartz b6c8fbe43b New translations en-us.json (Spanish)
[ci skip]
2026-05-12 20:41:30 -07:00
Owen Schwartz 4208a9f372 New translations en-us.json (Norwegian Bokmal)
[ci skip]
2026-05-12 20:41:28 -07:00
Owen Schwartz 3c82a228fb New translations en-us.json (Chinese Simplified)
[ci skip]
2026-05-12 20:41:26 -07:00
Owen Schwartz a4aa29e48a New translations en-us.json (Turkish)
[ci skip]
2026-05-12 20:41:25 -07:00
Owen Schwartz 0f82ba6627 New translations en-us.json (Russian)
[ci skip]
2026-05-12 20:41:23 -07:00
Owen Schwartz 1df5d9fac8 New translations en-us.json (Portuguese)
[ci skip]
2026-05-12 20:41:21 -07:00
Owen Schwartz 5189583d73 New translations en-us.json (Polish)
[ci skip]
2026-05-12 20:41:20 -07:00
Owen Schwartz b794d2aa40 New translations en-us.json (Dutch)
[ci skip]
2026-05-12 20:41:18 -07:00
Owen Schwartz c69059b227 New translations en-us.json (Korean)
[ci skip]
2026-05-12 20:41:17 -07:00
Owen Schwartz b27b62d4c8 New translations en-us.json (Italian)
[ci skip]
2026-05-12 20:41:15 -07:00
Owen Schwartz ee8290d68c New translations en-us.json (German)
[ci skip]
2026-05-12 20:41:13 -07:00
Owen Schwartz 82e8e79b16 New translations en-us.json (Czech)
[ci skip]
2026-05-12 20:41:12 -07:00
Owen Schwartz 2d428d2fa0 New translations en-us.json (Bulgarian)
[ci skip]
2026-05-12 20:41:10 -07:00
Owen Schwartz 0005c11a0a New translations en-us.json (French)
[ci skip]
2026-05-12 20:41:08 -07:00
Owen Schwartz 559cbeb7d5 Merge pull request #3058 from fosrl/dev
1.18.4
2026-05-12 20:20:10 -07:00
Owen f91d914ec6 Show when a domain is config managed 2026-05-12 20:14:12 -07:00
Owen Schwartz e975f56445 Merge pull request #3008 from AstralDestiny/patch-2
Update references of SSL alone to be TLS to align with proper wording…
2026-05-12 13:57:11 -07:00
Fred KISSIE ce746a2a21 Handle labels for machine clients 2026-05-12 22:32:56 +02:00
Fred KISSIE 7120ab4b22 ♻️ filter sites & resources by labels 2026-05-12 20:45:12 +02:00
Fred KISSIE 12e777b32e Add labels column to private resources table 2026-05-12 20:25:32 +02:00
Fred KISSIE 9378103ddd handle private resources filtering by labels 2026-05-12 20:24:34 +02:00
Fred KISSIE ec794d5de2 attach/detach private resources 2026-05-12 20:01:33 +02:00
Fred KISSIE 12b18a3e8c attach labels to private resources 2026-05-12 19:58:44 +02:00
Fred KISSIE 91e8a13e59 🗃️ Add site resource labels schema 2026-05-12 17:55:56 +02:00
Fred KISSIE 931ba0f540 💄 px-2 button 2026-05-12 17:46:46 +02:00
miloschwartz b6caeda0a5 improve targets round robin warning 2026-05-11 22:06:43 -07:00
Owen 77d17af15b Add global hide_powered_by and make it backward 2026-05-11 16:18:57 -07:00
Owen 264c6bf4e8 Use the right param for user 2026-05-11 12:06:36 -07:00
Fred KISSIE d321d7275c 🚧 tried to memo proxy resource table, failed 2026-05-11 21:06:20 +02:00
Owen 4aa72eb1a3 Confirm delete of share links 2026-05-11 11:49:51 -07:00
Owen a066a68e1a Pick the most specific domain
Fixes #3047
2026-05-11 11:28:32 -07:00
Fred KISSIE 3855486a00 ️ prevent SitetableCell from rerendering unnecessarily 2026-05-11 19:27:00 +02:00
Fred KISSIE ab494521b1 labels on proxy resources 2026-05-11 18:37:16 +02:00
Fred KISSIE 549e1ead1d handle labels in resources too 2026-05-11 18:30:23 +02:00
Fred KISSIE a0759a79a1 🗃️ add unique indexes to site & resource labels in sqlite 2026-05-11 18:28:40 +02:00
Fred KISSIE 14e1a119d3 🚧 WIP: showing labels in proxy resources table 2026-05-11 18:24:47 +02:00
Fred KISSIE 6e066d38b0 🚚 Make label badge its own component 2026-05-11 18:17:29 +02:00
Fred KISSIE 21f72639b6 🚧 make labels column paid, and cleanup 2026-05-11 18:13:19 +02:00
Fred KISSIE 8a0c2031d4 search list by labels too 2026-05-11 18:02:59 +02:00
Fred KISSIE 56d3a466e5 💄 make controlled data table input a search input 2026-05-11 18:02:44 +02:00
Fred KISSIE 563e505cc1 💸 add labels to paid features 2026-05-11 18:02:15 +02:00
Fred KISSIE c44c02b8ba 💄 make site labels column design nicer 2026-05-11 17:04:44 +02:00
Fred KISSIE b9ab35a05b 🐛 handle idempotency when adding/removing labels from sites/resources 2026-05-11 16:57:53 +02:00
miloschwartz 9fb677e952 allow editing self and owner user roles 2026-05-08 17:48:43 -07:00
Owen Schwartz e253195fdd Merge pull request #3035 from fosrl/dev
Add new log streaming and client endpoint to connection log
2026-05-08 17:18:16 -07:00
Owen Schwartz 88d8414eb8 Merge pull request #3016 from fosrl/crowdin_dev
New Crowdin updates
2026-05-08 17:17:30 -07:00
Owen Schwartz 5f3fafb1b0 New translations en-us.json (Spanish)
[ci skip]
2026-05-08 17:16:31 -07:00
Owen Schwartz de1338a8cd New translations en-us.json (Norwegian Bokmal)
[ci skip]
2026-05-08 17:16:29 -07:00
Owen Schwartz 0800aa2a61 New translations en-us.json (Chinese Simplified)
[ci skip]
2026-05-08 17:16:28 -07:00
Owen Schwartz 4959d66ac1 New translations en-us.json (Turkish)
[ci skip]
2026-05-08 17:16:26 -07:00
Owen Schwartz 9320df8be6 New translations en-us.json (Russian)
[ci skip]
2026-05-08 17:16:24 -07:00
Owen Schwartz 13ec6b6620 New translations en-us.json (Portuguese)
[ci skip]
2026-05-08 17:16:23 -07:00
Owen Schwartz 2ca3ef019c New translations en-us.json (Polish)
[ci skip]
2026-05-08 17:16:21 -07:00
Owen Schwartz 724e41a54f New translations en-us.json (Dutch)
[ci skip]
2026-05-08 17:16:19 -07:00
Owen Schwartz ce5e62d216 New translations en-us.json (Korean)
[ci skip]
2026-05-08 17:16:17 -07:00
Owen Schwartz 874dc2b33e New translations en-us.json (Italian)
[ci skip]
2026-05-08 17:16:16 -07:00
Owen Schwartz 3b2622d590 New translations en-us.json (German)
[ci skip]
2026-05-08 17:16:14 -07:00
Owen Schwartz c81d855741 New translations en-us.json (Czech)
[ci skip]
2026-05-08 17:16:12 -07:00
Owen Schwartz 3bce8d3596 New translations en-us.json (Bulgarian)
[ci skip]
2026-05-08 17:16:10 -07:00
Owen Schwartz ee2a1e2bc3 New translations en-us.json (French)
[ci skip]
2026-05-08 17:16:09 -07:00
Owen Schwartz a0f3ee74f9 New translations en-us.json (Spanish)
[ci skip]
2026-05-08 17:14:09 -07:00
Owen Schwartz 82a36fd632 New translations en-us.json (Norwegian Bokmal)
[ci skip]
2026-05-08 17:14:07 -07:00
Owen Schwartz c5084137ab New translations en-us.json (Chinese Simplified)
[ci skip]
2026-05-08 17:14:06 -07:00
Owen Schwartz 65ec8da100 New translations en-us.json (Turkish)
[ci skip]
2026-05-08 17:14:04 -07:00
Owen Schwartz e76e7581a5 New translations en-us.json (Russian)
[ci skip]
2026-05-08 17:14:02 -07:00
Owen Schwartz a97a4b6ec1 New translations en-us.json (Portuguese)
[ci skip]
2026-05-08 17:14:00 -07:00
Owen Schwartz e38bbde348 New translations en-us.json (Polish)
[ci skip]
2026-05-08 17:13:58 -07:00
Owen Schwartz 026260ddfb New translations en-us.json (Dutch)
[ci skip]
2026-05-08 17:13:57 -07:00
Owen Schwartz 97be5eb7d5 New translations en-us.json (Korean)
[ci skip]
2026-05-08 17:13:55 -07:00
Owen Schwartz d7b96ba3f5 New translations en-us.json (Italian)
[ci skip]
2026-05-08 17:13:53 -07:00
Owen Schwartz b42672530f New translations en-us.json (German)
[ci skip]
2026-05-08 17:13:51 -07:00
Owen Schwartz b6b2dbd8ab New translations en-us.json (Czech)
[ci skip]
2026-05-08 17:13:50 -07:00
Owen Schwartz 975f3a01f5 New translations en-us.json (Bulgarian)
[ci skip]
2026-05-08 17:13:48 -07:00
Owen Schwartz 4de2dfff85 New translations en-us.json (French)
[ci skip]
2026-05-08 17:13:46 -07:00
Owen 27d230647f Merge branch 's3' into dev 2026-05-08 17:05:39 -07:00
Owen 114486608e Add client endpoint to network log 2026-05-08 17:04:58 -07:00
Owen 10fa9274d0 Add streaming errors for debug 2026-05-08 16:27:40 -07:00
Fred KISSIE 2fd519e102 add and toggle site labels 2026-05-08 22:31:36 +02:00
Fred KISSIE a63c1ec364 💄 label selector (with create label) 2026-05-08 21:49:20 +02:00
Fred KISSIE e61ef2ca2a 🚧 wip: label selector 2026-05-08 20:06:42 +02:00
Fred KISSIE 39b09b7f3f Merge branch 'dev' into feat/labels-on-sites-and-resources 2026-05-08 18:21:46 +02:00
Fred KISSIE 840cc214e3 🚧 wip 2026-05-08 18:21:09 +02:00
Owen cbdc74768f Implement s3 streaming destination 2026-05-07 21:09:21 -07:00
Owen Schwartz 10f95896aa Merge pull request #3030 from fosrl/dev
1.18.3-s.2 fix
2026-05-07 20:08:05 -07:00
Owen 5b8994d143 Cange to use primaryDb 2026-05-07 20:07:06 -07:00
Owen c46ef2fe9c Fix ts type issue 2026-05-07 20:03:48 -07:00
Fred KISSIE 72524db52d 💄 shrink button 2026-05-08 02:48:47 +02:00
Fred KISSIE ab8fc11ab3 🚧 add labels button 2026-05-08 02:46:16 +02:00
Owen Schwartz 4cd025dd91 Merge pull request #3029 from fosrl/dev
1.18.3-s.2
2026-05-07 17:44:35 -07:00
Owen ce04ea9720 Fix not including today
Fixes #3028
2026-05-07 16:15:13 -07:00
Owen a3ce382725 Pick up other domains in the sans field 2026-05-07 15:49:12 -07:00
Owen 4eb49e3e60 Make the rebuild long running function background 2026-05-07 15:40:34 -07:00
Fred KISSIE 1831ca4e75 ♻️ detach label from site/resoirce 2026-05-08 00:33:47 +02:00
Owen 2a9481023a Dont show link when wildcard 2026-05-07 15:15:03 -07:00
Owen 8ed01372b8 Add org to logs 2026-05-07 15:14:44 -07:00
immanuwell 0611ceb5c3 fix: make validators test failures exit non-zero 2026-05-07 20:13:56 +04:00
Owen Schwartz 451f3d24a8 New translations en-us.json (French)
[ci skip]
2026-05-06 17:13:33 -07: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
AstralDestiny 9f67134ce2 Update references of SSL alone to be TLS to align with proper wording and terms. 2026-05-05 21:30:36 -04:00
Owen 51f1693dbd Merge branch 'dev' into resource-policies 2026-05-05 18:02:27 -07:00
Fred KISSIE 0d04cc365f attach label to item 2026-05-05 21:35:10 +02:00
Fred KISSIE 09baf2f32e 🗃️ add sqlite table for labels 2026-05-05 21:08:22 +02:00
Fred KISSIE 3253d60900 🚧 Add CRUD endpoints and tables for labels 2026-05-05 20:53:16 +02: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](https://github.com/actions/setup-node/compare/53b83947a5a98c8d113130e565377fae1a50d02f...48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e)

---
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
789 changed files with 84847 additions and 28078 deletions
+5
View File
@@ -0,0 +1,5 @@
---
alwaysApply: true
---
When adding submit buttons, don't change the text of the button during the loading state. Text should stay static and you should use the loading prop on the button.
+5
View File
@@ -0,0 +1,5 @@
---
alwaysApply: true
---
When creating UI for popup dialogs or modals, use the Credenza componennt. This component is mobile responsive and works on desktop and wraps the dialog component and sheet into one.
+5
View File
@@ -0,0 +1,5 @@
---
alwaysApply: true
---
Don't write or edit migrations in `server/setup` unless specificall instructed to do so.
+7
View File
@@ -0,0 +1,7 @@
---
alwaysApply: true
---
When writing TypeScript:
Prefer to use types instead of interfaces.
@@ -0,0 +1,5 @@
---
alwaysApply: true
---
When creating forms, use React form for validation and use Zod schemas.
+2
View File
@@ -34,3 +34,5 @@ build.ts
tsconfig.json
Dockerfile*
drizzle.config.ts
allowedDevOrigins.json
scratch/
-3
View File
@@ -1,3 +0,0 @@
# These are supported funding model platforms
github: [fosrl]
+3 -2
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
+19 -29
View File
@@ -1,52 +1,42 @@
version: 2
updates:
- package-ecosystem: "npm"
directory: "/"
schedule:
interval: "daily"
open-pull-requests-limit: 1
groups:
dev-patch-updates:
dependency-type: "development"
update-types:
- "patch"
dev-minor-updates:
dependency-type: "development"
update-types:
- "minor"
prod-patch-updates:
dependency-type: "production"
update-types:
- "patch"
prod-minor-updates:
dependency-type: "production"
update-types:
- "minor"
npm-dependencies:
patterns:
- "*"
- package-ecosystem: "docker"
directory: "/"
schedule:
interval: "daily"
open-pull-requests-limit: 1
groups:
patch-updates:
update-types:
- "patch"
minor-updates:
update-types:
- "minor"
docker-dependencies:
patterns:
- "*"
- package-ecosystem: "github-actions"
directory: "/"
schedule:
interval: "weekly"
open-pull-requests-limit: 1
groups:
github-actions-dependencies:
patterns:
- "*"
- package-ecosystem: "gomod"
directory: "/install"
schedule:
interval: "daily"
open-pull-requests-limit: 1
groups:
patch-updates:
update-types:
- "patch"
minor-updates:
update-types:
- "minor"
go-install-dependencies:
patterns:
- "*"
+11 -9
View File
@@ -62,7 +62,7 @@ jobs:
steps:
- name: Checkout code
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
- name: Monitor storage space
run: |
@@ -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 }}
@@ -134,7 +134,7 @@ jobs:
steps:
- name: Checkout code
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
- name: Monitor storage space
run: |
@@ -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 }}
@@ -201,10 +201,10 @@ jobs:
timeout-minutes: 30
steps:
- name: Checkout code
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
- 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 }}
@@ -256,7 +256,7 @@ jobs:
steps:
- name: Checkout code
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
- name: Extract tag name
id: get-tag
@@ -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 }}
@@ -415,7 +415,9 @@ jobs:
- name: Install cosign
# cosign is used to sign container images using keyless (OIDC) signing
uses: sigstore/cosign-installer@cad07c2e89fa2edd6e2d7bab4c1aa38e53f76003 # v4.1.1
uses: sigstore/cosign-installer@6f9f17788090df1f26f669e9d70d6ae9567deba6 # v4.1.2
with:
cosign-release: v3.0.6
- name: Sign (GHCR, keyless)
# Sign each GHCR image by digest using keyless (OIDC) signing via Sigstore/Rekor.
+2 -2
View File
@@ -21,10 +21,10 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
- 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'
+1 -1
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: |
-39
View File
@@ -1,39 +0,0 @@
name: Restart Runners
on:
schedule:
- cron: '0 0 */7 * *'
permissions:
id-token: write
contents: read
jobs:
ec2-maintenance-prod:
runs-on: ubuntu-latest
permissions: write-all
steps:
- name: Configure AWS credentials
uses: aws-actions/configure-aws-credentials@v6
with:
role-to-assume: arn:aws:iam::${{ secrets.AWS_ACCOUNT_ID }}:role/${{ secrets.AWS_ROLE_NAME }}
role-duration-seconds: 3600
aws-region: ${{ secrets.AWS_REGION }}
- name: Verify AWS identity
run: aws sts get-caller-identity
- name: Start EC2 instance
run: |
aws ec2 start-instances --instance-ids ${{ secrets.EC2_INSTANCE_ID_ARM_RUNNER }}
aws ec2 start-instances --instance-ids ${{ secrets.EC2_INSTANCE_ID_AMD_RUNNER }}
echo "EC2 instances started"
- name: Wait
run: sleep 600
- name: Stop EC2 instance
run: |
aws ec2 stop-instances --instance-ids ${{ secrets.EC2_INSTANCE_ID_ARM_RUNNER }}
aws ec2 stop-instances --instance-ids ${{ secrets.EC2_INSTANCE_ID_AMD_RUNNER }}
echo "EC2 instances stopped"
-160
View File
@@ -1,160 +0,0 @@
name: SAAS Pipeline
# CI/CD workflow for building, publishing, mirroring, signing container images and building release binaries.
# Actions are pinned to specific SHAs to reduce supply-chain risk. This workflow triggers on tag push events.
permissions:
contents: read
packages: write # for GHCR push
id-token: write # for Cosign Keyless (OIDC) Signing
on:
push:
tags:
- "[0-9]+.[0-9]+.[0-9]+-s.[0-9]+"
concurrency:
group: ${{ github.ref }}
cancel-in-progress: true
jobs:
pre-run:
runs-on: ubuntu-latest
permissions: write-all
steps:
- name: Configure AWS credentials
uses: aws-actions/configure-aws-credentials@v6
with:
role-to-assume: arn:aws:iam::${{ secrets.AWS_ACCOUNT_ID }}:role/${{ secrets.AWS_ROLE_NAME }}
role-duration-seconds: 3600
aws-region: ${{ secrets.AWS_REGION }}
- name: Verify AWS identity
run: aws sts get-caller-identity
- name: Start EC2 instances
run: |
aws ec2 start-instances --instance-ids ${{ secrets.EC2_INSTANCE_ID_ARM_RUNNER }}
echo "EC2 instances started"
release-arm:
name: Build and Release (ARM64)
runs-on: [self-hosted, linux, arm64, us-east-1]
needs: [pre-run]
if: >-
${{
needs.pre-run.result == 'success'
}}
# Job-level timeout to avoid runaway or stuck runs
timeout-minutes: 120
env:
# Target images
AWS_IMAGE: ${{ secrets.aws_account_id }}.dkr.ecr.us-east-1.amazonaws.com/${{ github.event.repository.name }}
steps:
- name: Checkout code
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Download MaxMind GeoLite2 databases
env:
MAXMIND_LICENSE_KEY: ${{ secrets.MAXMIND_LICENSE_KEY }}
run: |
echo "Downloading MaxMind GeoLite2 databases..."
# Download GeoLite2-Country
curl -L "https://download.maxmind.com/app/geoip_download?edition_id=GeoLite2-Country&license_key=${MAXMIND_LICENSE_KEY}&suffix=tar.gz" \
-o GeoLite2-Country.tar.gz
# Download GeoLite2-ASN
curl -L "https://download.maxmind.com/app/geoip_download?edition_id=GeoLite2-ASN&license_key=${MAXMIND_LICENSE_KEY}&suffix=tar.gz" \
-o GeoLite2-ASN.tar.gz
# Extract the .mmdb files
tar -xzf GeoLite2-Country.tar.gz --strip-components=1 --wildcards '*.mmdb'
tar -xzf GeoLite2-ASN.tar.gz --strip-components=1 --wildcards '*.mmdb'
# Verify files exist
if [ ! -f "GeoLite2-Country.mmdb" ]; then
echo "ERROR: Failed to download GeoLite2-Country.mmdb"
exit 1
fi
if [ ! -f "GeoLite2-ASN.mmdb" ]; then
echo "ERROR: Failed to download GeoLite2-ASN.mmdb"
exit 1
fi
# Clean up tar files
rm -f GeoLite2-Country.tar.gz GeoLite2-ASN.tar.gz
echo "MaxMind databases downloaded successfully"
ls -lh GeoLite2-*.mmdb
- name: Monitor storage space
run: |
THRESHOLD=75
USED_SPACE=$(df / | grep / | awk '{ print $5 }' | sed 's/%//g')
echo "Used space: $USED_SPACE%"
if [ "$USED_SPACE" -ge "$THRESHOLD" ]; then
echo "Used space is below the threshold of 75% free. Running Docker system prune."
echo y | docker system prune -a
else
echo "Storage space is above the threshold. No action needed."
fi
- name: Configure AWS credentials
uses: aws-actions/configure-aws-credentials@v6
with:
role-to-assume: arn:aws:iam::${{ secrets.aws_account_id }}:role/${{ secrets.AWS_ROLE_NAME }}
role-duration-seconds: 3600
aws-region: ${{ secrets.AWS_REGION }}
- name: Login to Amazon ECR
id: login-ecr
uses: aws-actions/amazon-ecr-login@v2
- name: Extract tag name
id: get-tag
run: echo "TAG=${GITHUB_REF#refs/tags/}" >> $GITHUB_ENV
shell: bash
- name: Update version in package.json
run: |
TAG=${{ env.TAG }}
sed -i "s/export const APP_VERSION = \".*\";/export const APP_VERSION = \"$TAG\";/" server/lib/consts.ts
cat server/lib/consts.ts
shell: bash
- name: Build and push Docker images (Docker Hub - ARM64)
run: |
TAG=${{ env.TAG }}
make build-saas tag=$TAG
echo "Built & pushed ARM64 images to: ${{ env.AWS_IMAGE }}:${TAG}"
shell: bash
post-run:
needs: [pre-run, release-arm]
if: >-
${{
always() &&
needs.pre-run.result == 'success' &&
(needs.release-arm.result == 'success' || needs.release-arm.result == 'skipped' || needs.release-arm.result == 'failure')
}}
runs-on: ubuntu-latest
permissions: write-all
steps:
- name: Configure AWS credentials
uses: aws-actions/configure-aws-credentials@v6
with:
role-to-assume: arn:aws:iam::${{ secrets.AWS_ACCOUNT_ID }}:role/${{ secrets.AWS_ROLE_NAME }}
role-duration-seconds: 3600
aws-region: ${{ secrets.AWS_REGION }}
- name: Verify AWS identity
run: aws sts get-caller-identity
- name: Stop EC2 instances
run: |
aws ec2 stop-instances --instance-ids ${{ secrets.EC2_INSTANCE_ID_ARM_RUNNER }}
echo "EC2 instances stopped"
+1 -1
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
+4 -4
View File
@@ -14,10 +14,10 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
- name: Install Node
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
node-version: '24'
@@ -62,7 +62,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
- name: Build Docker image sqlite
run: make dev-build-sqlite
@@ -71,7 +71,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
- name: Build Docker image pg
run: make dev-build-pg
+4 -2
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
+4 -1
View File
@@ -18,5 +18,8 @@
"[json]": {
"editor.defaultFormatter": "esbenp.prettier-vscode"
},
"editor.formatOnSave": true
"editor.formatOnSave": true,
"cSpell.words": [
"nessicary"
]
}
+1 -1
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
+60
View File
@@ -0,0 +1,60 @@
import { CommandModule } from "yargs";
import { db, users } from "@server/db";
import { eq } from "drizzle-orm";
/**
* Disable 2FA for a user by email address.
*/
type DisableUser2faArgs = {
email: string;
};
export const disableUser2fa: CommandModule<{}, DisableUser2faArgs> = {
command: "disable-user-2fa",
describe: "Disable 2FA for a user (sets twoFactorEnabled=false, clears secret)",
builder: (yargs) => {
return yargs.option("email", {
type: "string",
demandOption: true,
describe: "User email address"
});
},
handler: async (argv: { email: string }) => {
try {
const { email } = argv;
console.log(`Looking for user with email: ${email}`);
// Find the user by email
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.twoFactorEnabled) {
console.log(`2FA is already disabled for user '${email}'.`);
process.exit(0);
}
// Update user: disable 2FA and clear secret
await db.update(users)
.set({
twoFactorEnabled: false,
twoFactorSecret: null,
twoFactorSetupRequested: false
})
.where(eq(users.userId, user.userId));
console.log(`2FA disabled for user '${email}'.`);
process.exit(0);
} catch (error) {
console.error("Error disabling 2FA:", error);
process.exit(1);
}
}
};
+85
View File
@@ -0,0 +1,85 @@
import { CommandModule } from "yargs";
import { db, users } from "@server/db";
import { eq } from "drizzle-orm";
type SetServerAdminArgs = {
email: string;
remove: boolean;
};
export const setServerAdmin: CommandModule<{}, SetServerAdminArgs> = {
command: "set-server-admin",
describe: "Add or remove server admin by email address",
builder: (yargs) => {
return yargs
.option("email", {
type: "string",
demandOption: true,
describe: "User email address"
})
.option("remove", {
type: "boolean",
default: false,
describe: "Remove server admin status from the user"
});
},
handler: async (argv: SetServerAdminArgs) => {
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 (argv.remove) {
if (!user.serverAdmin) {
console.log(`User '${email}' is not a server admin`);
process.exit(0);
}
const serverAdmins = await db
.select()
.from(users)
.where(eq(users.serverAdmin, true));
if (serverAdmins.length <= 1) {
console.error(
"Cannot remove server admin: at least one server admin must exist"
);
process.exit(1);
}
await db
.update(users)
.set({ serverAdmin: false })
.where(eq(users.userId, user.userId));
console.log(`Server admin status removed from user '${email}'`);
process.exit(0);
}
if (user.serverAdmin) {
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);
}
}
};
+4
View File
@@ -10,6 +10,8 @@ import { clearLicenseKeys } from "./commands/clearLicenseKeys";
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")
@@ -21,5 +23,7 @@ yargs(hideBin(process.argv))
.command(deleteClient)
.command(generateOrgCaKeys)
.command(clearCertificates)
.command(disableUser2fa)
.command(setServerAdmin)
.demandCommand()
.help().argv;
@@ -41,7 +41,7 @@ services:
- 80:80 # Port for traefik because of the network_mode
traefik:
image: traefik:v3.6
image: traefik:v3.7
container_name: traefik
restart: unless-stopped
network_mode: service:gerbil # Ports appear on the gerbil service
View File
+15 -22
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
+1 -1
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";
+5 -1
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,6 @@ 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}}
+61 -12
View File
@@ -1,15 +1,23 @@
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:
resources:
limits:
memory: 1g
memory: 2g
reservations:
memory: 256m
memory: 512m
{{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:
@@ -17,8 +25,8 @@ services:
interval: "10s"
timeout: "10s"
retries: 15
{{if .InstallGerbil}}
gerbil:
{{if .InstallGerbil}}gerbil:
image: docker.io/fosrl/gerbil:{{.GerbilVersion}}
container_name: gerbil
restart: unless-stopped
@@ -39,17 +47,16 @@ services:
- 21820:21820/udp
- 443:443
- 443:443/udp # For http3 QUIC if desired
- 80:80
{{end}}
- 80:80{{end}}
traefik:
image: docker.io/traefik:v3.6
image: docker.io/traefik:v3.7
container_name: traefik
restart: unless-stopped
{{if .InstallGerbil}} network_mode: service:gerbil # Ports appear on the gerbil service{{end}}{{if not .InstallGerbil}}
{{if .InstallGerbil}}network_mode: service:gerbil # Ports appear on the gerbil service{{end}}{{if not .InstallGerbil}}
ports:
- 443:443
- 80:80
{{end}}
- 80:80{{end}}
depends_on:
pangolin:
condition: service_healthy
@@ -60,8 +67,50 @@ 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}}
+4
View File
@@ -0,0 +1,4 @@
{{if .IsRedis}}redis:
host: "redis"
port: 6379
password: "{{.IsRedisPass}}"{{end}}
+2 -2
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.45.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.47.0 // indirect
golang.org/x/text v0.23.0 // indirect
)
+4 -4
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.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/term v0.45.0 h1:NwWyBmoJCbfTHpxrWoZ9C6/VxOf7ic219I8xZZFdrf0=
golang.org/x/term v0.45.0/go.mod h1:9aqxs0blBcrm/n0L9QW0aRVD+ktan8ssZromtqJC43w=
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=
+57 -20
View File
@@ -4,6 +4,7 @@ import (
"crypto/rand"
"embed"
"encoding/base64"
"flag"
"fmt"
"io"
"io/fs"
@@ -53,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
@@ -66,8 +71,14 @@ const (
Undefined SupportedContainer = "undefined"
)
var redisFlag *bool
func main() {
crowdsecFlag := flag.Bool("crowdsec", false, "Enable the CrowdSec installation prompt")
redisFlag = flag.Bool("redis", false, "Install Redis as cacheing solution. Required for HA. Not required for the Enterprise version.")
flag.Parse()
// print a banner about prerequisites - opening port 80, 443, 51820, and 21820 on the VPS and firewall and pointing your domain to the VPS IP with a records. Docs are at http://localhost:3000/Getting%20Started/dns-networking
fmt.Println("Welcome to the Pangolin installer!")
@@ -119,11 +130,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.")
}
}
@@ -184,15 +195,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.")
@@ -200,13 +211,15 @@ 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\"")
}
}
}
if !checkIsCrowdsecInstalledInCompose() {
if *crowdsecFlag && !checkIsCrowdsecInstalledInCompose() {
fmt.Println("\n=== CrowdSec Install ===")
// check if crowdsec is installed
if readBool("Would you like to install CrowdSec?", false) {
@@ -480,6 +493,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 {
if *redisFlag {
config.IsRedis = true
config.IsRedisPass = readPassword("Enter a unique password for the Redis service.")
}
}
config.IsPostgreSQL = readBool("Do you want to use PostgreSQL (not recommended for most users)?", false)
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)", "")
@@ -523,7 +547,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 ASN databases for blocking functionality?", true)
if config.DashboardDomain == "" {
fmt.Println("Error: Dashboard Domain name is required")
@@ -776,29 +800,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
}
+594 -46
View File
File diff suppressed because it is too large Load Diff
+593 -45
View File
File diff suppressed because it is too large Load Diff
+3803
View File
File diff suppressed because it is too large Load Diff
+630 -82
View File
File diff suppressed because it is too large Load Diff
+595 -47
View File
File diff suppressed because it is too large Load Diff
+593 -45
View File
File diff suppressed because it is too large Load Diff
+590 -43
View File
File diff suppressed because it is too large Load Diff
+592 -44
View File
File diff suppressed because it is too large Load Diff
+592 -44
View File
File diff suppressed because it is too large Load Diff
+594 -46
View File
File diff suppressed because it is too large Load Diff
+592 -44
View File
File diff suppressed because it is too large Load Diff
+591 -43
View File
File diff suppressed because it is too large Load Diff
+593 -45
View File
File diff suppressed because it is too large Load Diff
+588 -40
View File
File diff suppressed because it is too large Load Diff
+593 -45
View File
File diff suppressed because it is too large Load Diff
+644 -96
View File
File diff suppressed because it is too large Load Diff
+4 -4
View File
@@ -152,8 +152,8 @@
"shareErrorSelectResource": "請選擇一個資源",
"proxyResourceTitle": "管理公開資源",
"proxyResourceDescription": "建立和管理可透過網頁瀏覽器公開存取的資源",
"proxyResourcesBannerTitle": "基於網頁的公開存取",
"proxyResourcesBannerDescription": "公開資源是任何人都可以透過網頁瀏覽器存取的 HTTPS 或 TCP/UDP 代理。與私有資源不同,它們不需要客戶端軟體,並且可以包含基於身份和情境感知的存取策略。",
"publicResourcesBannerTitle": "基於網頁的公開存取",
"publicResourcesBannerDescription": "公開資源是任何人都可以透過網頁瀏覽器存取的 HTTPS 或 TCP/UDP 代理。與私有資源不同,它們不需要客戶端軟體,並且可以包含基於身份和情境感知的存取策略。",
"clientResourceTitle": "管理私有資源",
"clientResourceDescription": "建立和管理只能透過已連接的客戶端存取的資源",
"privateResourcesBannerTitle": "零信任私有存取",
@@ -489,7 +489,7 @@
"createdAt": "創建於",
"proxyErrorInvalidHeader": "無效的自訂主機 Header。使用域名格式,或將空保存為取消自訂 Header。",
"proxyErrorTls": "無效的 TLS 伺服器名稱。使用域名格式,或保存空以刪除 TLS 伺服器名稱。",
"proxyEnableSSL": "啟用 SSL",
"proxyEnableSSL": "啟用 TLS",
"proxyEnableSSLDescription": "啟用 SSL/TLS 加密以確保您目標的 HTTPS 連接。",
"target": "目標",
"configureTarget": "配置目標",
@@ -1763,7 +1763,7 @@
"description": "更可靠、維護成本更低的自架 Pangolin 伺服器,並附帶額外的附加功能",
"introTitle": "託管式自架 Pangolin",
"introDescription": "這是一種部署選擇,為那些希望簡潔和額外可靠的人設計,同時仍然保持他們的數據的私密性和自我託管性。",
"introDetail": "通過此選項,您仍然運行您自己的 Pangolin 節點 - - 您的隧道、SSL 終止,並且流量在您的伺服器上保持所有狀態。 不同之處在於,管理和監測是通過我們的雲層儀錶板進行的,該儀錶板開啟了一些好處:",
"introDetail": "通過此選項,您仍然運行您自己的 Pangolin 節點 - - 您的隧道、TLS 終止,並且流量在您的伺服器上保持所有狀態。 不同之處在於,管理和監測是通過我們的雲層儀錶板進行的,該儀錶板開啟了一些好處:",
"benefitSimplerOperations": {
"title": "簡單的操作",
"description": "無需運行您自己的郵件伺服器或設置複雜的警報。您將從方框中獲得健康檢查和下限提醒。"
+32 -7
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);
+2279 -3617
View File
File diff suppressed because it is too large Load Diff
+64 -58
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",
"js-yaml": "4.2.0",
"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": "9.0.1",
"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.1",
"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.1",
"dompurify": "3.4.0",
"postcss": "8.5.15"
}
}
+47 -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",
@@ -20,6 +21,7 @@ export enum ActionsEnum {
getSite = "getSite",
listSites = "listSites",
updateSite = "updateSite",
restartSite = "restartSite",
resetSiteBandwidth = "resetSiteBandwidth",
reGenerateSecret = "reGenerateSecret",
createResource = "createResource",
@@ -148,11 +150,37 @@ export enum ActionsEnum {
updateAlertRule = "updateAlertRule",
deleteAlertRule = "deleteAlertRule",
listAlertRules = "listAlertRules",
listOrgLabels = "listOrgLabels",
createOrgLabel = "createOrgLabel",
updateOrgLabel = "updateOrgLabel",
deleteOrgLabel = "deleteOrgLabel",
attachLabelToItem = "attachLabelToItem",
detachLabelFromItem = "detachLabelFromItem",
getAlertRule = "getAlertRule",
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",
createOrgWideLauncherView = "createOrgWideLauncherView"
}
export async function checkUserActionPermission(
@@ -185,6 +213,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()
@@ -202,20 +247,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(
+96 -24
View File
@@ -1,6 +1,12 @@
import { db } from "@server/db";
import { and, eq, inArray } from "drizzle-orm";
import { roleResources, userResources } from "@server/db";
import { and, eq, inArray, isNull, or } from "drizzle-orm";
import {
rolePolicies,
roleResources,
resources,
userPolicies,
userResources
} from "@server/db";
export async function canUserAccessResource({
userId,
@@ -11,9 +17,14 @@ export async function canUserAccessResource({
resourceId: number;
roleIds: number[];
}): Promise<boolean> {
const roleResourceAccess =
const [
roleResourceAccess,
rolePolicyAccess,
userResourceAccess,
userPolicyAccess
] = await Promise.all([
roleIds.length > 0
? await db
? db
.select()
.from(roleResources)
.where(
@@ -23,26 +34,87 @@ export async function canUserAccessResource({
)
)
.limit(1)
: [];
if (roleResourceAccess.length > 0) {
return true;
}
const userResourceAccess = await db
.select()
.from(userResources)
.where(
and(
eq(userResources.userId, userId),
eq(userResources.resourceId, resourceId)
: [],
roleIds.length > 0
? db
.select({
roleId: rolePolicies.roleId,
resourcePolicyId: rolePolicies.resourcePolicyId
})
.from(rolePolicies)
.innerJoin(
resources,
// Shared policy wins; only use default policy when no shared
// policy is assigned to the resource.
or(
eq(
resources.resourcePolicyId,
rolePolicies.resourcePolicyId
),
and(
isNull(resources.resourcePolicyId),
eq(
resources.defaultResourcePolicyId,
rolePolicies.resourcePolicyId
)
)
)
)
.where(
and(
eq(resources.resourceId, resourceId),
inArray(rolePolicies.roleId, roleIds)
)
)
.limit(1)
: [],
db
.select()
.from(userResources)
.where(
and(
eq(userResources.userId, userId),
eq(userResources.resourceId, resourceId)
)
)
)
.limit(1);
.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)
]);
if (userResourceAccess.length > 0) {
return true;
}
return false;
return (
roleResourceAccess.length > 0 ||
rolePolicyAccess.length > 0 ||
userResourceAccess.length > 0 ||
userPolicyAccess.length > 0
);
}
+40 -1
View File
@@ -12,7 +12,7 @@ import {
users
} from "@server/db";
import { db } from "@server/db";
import { eq, inArray } from "drizzle-orm";
import { and, eq, inArray, ne } from "drizzle-orm";
import config from "@server/lib/config";
import type { RandomReader } from "@oslojs/crypto/random";
import { generateRandomString } from "@oslojs/crypto/random";
@@ -136,6 +136,45 @@ export async function invalidateAllSessions(userId: string): Promise<void> {
}
}
export async function invalidateAllSessionsExceptCurrent(
userId: string,
currentSessionId: string
): Promise<void> {
try {
await db.transaction(async (trx) => {
const userSessions = await trx
.select()
.from(sessions)
.where(
and(
eq(sessions.userId, userId),
ne(sessions.sessionId, currentSessionId)
)
);
if (userSessions.length > 0) {
await trx.delete(resourceSessions).where(
inArray(
resourceSessions.userSessionId,
userSessions.map((s) => s.sessionId)
)
);
}
await trx
.delete(sessions)
.where(
and(
eq(sessions.userId, userId),
ne(sessions.sessionId, currentSessionId)
)
);
});
} catch (e) {
logger.error("Failed to invalidate user sessions except current", e);
}
}
export function serializeSessionCookie(
token: string,
isSecure: boolean,
+10 -1
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()
+7 -4
View File
@@ -795,10 +795,13 @@ export const COUNTRIES = [
name: "Serbia",
code: "RS"
},
{
name: "Serbia and Montenegro",
code: "CS"
},
// Removed as this is a deprecated ISO country code, not supported anymore
// Also the individual flags for Serbia & Montenegro are already included in the list
// more details: https://en.wikipedia.org/wiki/ISO_3166-2:CS
// {
// name: "Serbia and Montenegro",
// code: "CS"
// },
{
name: "Seychelles",
code: "SC"
+36 -1
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> {
+1 -1
View File
@@ -87,7 +87,7 @@ function createDb() {
export const db = createDb();
export default db;
export const primaryDb = db.$primary;
export const primaryDb = db.$primary as typeof db; // is this typeof a problem - technically they are different types
export type Transaction = Parameters<
Parameters<(typeof db)["transaction"]>[0]
>[0];
+1
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";
+3 -4
View File
@@ -2,7 +2,7 @@ import { drizzle as DrizzlePostgres } from "drizzle-orm/node-postgres";
import { readConfigFile } from "@server/lib/readConfigFile";
import { withReplicas } from "drizzle-orm/pg-core";
import { build } from "@server/build";
import { db as mainDb, primaryDb as mainPrimaryDb } from "./driver";
import { db as mainDb } from "./driver";
import { createPool } from "./poolConfig";
function createLogsDb() {
@@ -63,8 +63,7 @@ function createLogsDb() {
})
);
} else {
const maxReplicaConnections =
poolConfig?.max_replica_connections || 20;
const maxReplicaConnections = poolConfig?.max_replica_connections || 20;
for (const conn of replicaConnections) {
const replicaPool = createPool(
conn.connection_string,
@@ -91,4 +90,4 @@ function createLogsDb() {
export const logsDb = createLogsDb();
export default logsDb;
export const primaryLogsDb = logsDb.$primary;
export const primaryLogsDb = logsDb.$primary;
+26 -4
View File
@@ -1,5 +1,5 @@
import config from "@server/lib/config";
import { Pool, PoolConfig } from "pg";
import logger from "@server/logger";
export function createPoolConfig(
connectionString: string,
@@ -27,7 +27,7 @@ export function attachPoolErrorHandlers(pool: Pool, label: string): void {
pool.on("error", (err) => {
// This catches errors on idle clients in the pool. Without this
// handler an unexpected disconnect would crash the process.
logger.error(
console.error(
`Unexpected error on idle ${label} database client: ${err.message}`
);
});
@@ -36,10 +36,32 @@ export function attachPoolErrorHandlers(pool: Pool, label: string): void {
// Set a statement timeout on every new connection so a single slow
// query can't block the pool forever
client.query("SET statement_timeout = '30s'").catch((err: Error) => {
logger.warn(
console.warn(
`Failed to set statement_timeout on ${label} client: ${err.message}`
);
});
// Disable JIT compilation for this connection. Our hot-path queries
// (e.g. resource-by-domain lookups) join many tables but only ever
// return a handful of rows. When planner row estimates drift (e.g.
// due to autovacuum lag under write-heavy load), Postgres decides
// these plans are expensive enough to JIT-compile, which can add
// multiple seconds of pure compilation overhead per query and
// saturate the connection pool. JIT never pays off for these
// short-lived OLTP queries, so it's disabled outright rather than
// relying on statistics staying fresh.
//
// Set via a runtime SET command rather than the `options: "-c
// jit=off"` startup parameter: connections in SaaS mode go through
// a pooler (e.g. PgBouncer) that rejects arbitrary startup packet
// options with a protocol_violation (08P01) error.
if (config.getRawConfig().postgres?.pool.jit_mode == false) {
client.query("SET jit = off").catch((err: Error) => {
console.warn(
`Failed to set jit=off on ${label} client: ${err.message}`
);
});
}
});
}
@@ -60,4 +82,4 @@ export function createPool(
);
attachPoolErrorHandlers(pool, label);
return pool;
}
}
+67 -15
View File
@@ -2,6 +2,7 @@ import {
pgTable,
serial,
varchar,
unique,
boolean,
integer,
bigint,
@@ -11,7 +12,7 @@ import {
primaryKey,
uniqueIndex
} from "drizzle-orm/pg-core";
import { InferSelectModel } from "drizzle-orm";
import { InferSelectModel, sql } from "drizzle-orm";
import {
domains,
orgs,
@@ -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(),
remoteExitNodeId: varchar("remoteExitNodeId")
.references(() => remoteExitNodes.remoteExitNodeId, {
onDelete: "cascade"
})
.notNull(),
labelId: integer("labelId")
.references(() => labels.labelId, {
onDelete: "cascade"
})
.notNull()
},
(t) => [
unique("remote_exit_node_preference_label_uniq").on(
t.remoteExitNodeId,
t.labelId
)
]
);
export const remoteExitNodeSessions = pgTable("remoteExitNodeSession", {
sessionId: varchar("id").primaryKey(),
remoteExitNodeId: varchar("remoteExitNodeId")
@@ -207,17 +245,28 @@ export const remoteExitNodeSessions = pgTable("remoteExitNodeSession", {
expiresAt: bigint("expiresAt", { mode: "number" }).notNull()
});
export const loginPage = pgTable("loginPage", {
loginPageId: serial("loginPageId").primaryKey(),
subdomain: varchar("subdomain"),
fullDomain: varchar("fullDomain"),
exitNodeId: integer("exitNodeId").references(() => exitNodes.exitNodeId, {
onDelete: "set null"
}),
domainId: varchar("domainId").references(() => domains.domainId, {
onDelete: "set null"
})
});
export const loginPage = pgTable(
"loginPage",
{
loginPageId: serial("loginPageId").primaryKey(),
subdomain: varchar("subdomain"),
fullDomain: varchar("fullDomain"),
exitNodeId: integer("exitNodeId").references(
() => exitNodes.exitNodeId,
{
onDelete: "set null"
}
),
domainId: varchar("domainId").references(() => domains.domainId, {
onDelete: "set null"
})
},
(t) => [
index("idx_loginpage_fulldomain")
.on(t.fullDomain)
.where(sql`${t.fullDomain} IS NOT NULL`)
]
);
export const loginPageOrg = pgTable("loginPageOrg", {
loginPageId: integer("loginPageId")
@@ -332,6 +381,7 @@ export const connectionAuditLog = pgTable(
clientId: integer("clientId").references(() => clients.clientId, {
onDelete: "cascade"
}),
clientEndpoint: text("clientEndpoint"),
userId: text("userId").references(() => users.userId, {
onDelete: "cascade"
}),
@@ -439,6 +489,8 @@ export const eventStreamingDestinations = pgTable(
type: varchar("type", { length: 50 }).notNull(), // e.g. "http", "kafka", etc.
config: text("config").notNull(), // JSON string with the configuration for the destination
enabled: boolean("enabled").notNull().default(true),
lastError: text("lastError"), // last send error message, null if healthy
lastErrorAt: bigint("lastErrorAt", { mode: "number" }), // epoch ms of last error, null if healthy
createdAt: bigint("createdAt", { mode: "number" }).notNull(),
updatedAt: bigint("updatedAt", { mode: "number" }).notNull()
}
File diff suppressed because it is too large Load Diff
+278 -38
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 | null;
sso: boolean | null;
emailWhitelistEnabled: boolean | null;
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[];
}
/**
+12 -50
View File
@@ -1,6 +1,5 @@
import { drizzle as DrizzleSqlite } from "drizzle-orm/better-sqlite3";
import Database from "better-sqlite3";
import type BetterSqlite3 from "better-sqlite3";
import * as schema from "./schema/schema";
import path from "path";
import fs from "fs";
@@ -12,68 +11,31 @@ export const exists = checkFileExists(location);
bootstrapVolume();
/**
* Wraps better-sqlite3 Statement to call `finalize()` immediately after
* execution, freeing native sqlite3_stmt memory deterministically instead
* of waiting for GC. Fixes steady off-heap growth under load (#2120).
* WARNING: Finalizes after first execution incompatible with drizzle's
* reusable .prepare() builders. No such usage exists in this codebase.
*/
function autoFinalizeStatement(
stmt: BetterSqlite3.Statement
): BetterSqlite3.Statement {
const wrapExec = <T extends (...args: any[]) => any>(fn: T): T => {
return function (this: any, ...args: any[]) {
try {
return fn.apply(this, args);
} finally {
try {
// finalize() exists on the native Statement at runtime but
// is missing from @types/better-sqlite3.
(stmt as any).finalize();
} catch {
// Already finalized — harmless
}
}
} as unknown as T;
};
stmt.run = wrapExec(stmt.run);
stmt.get = wrapExec(stmt.get);
stmt.all = wrapExec(stmt.all);
return stmt;
}
function createDb() {
const sqlite = new Database(location);
if (process.env.ENABLE_SQLITE_WAL_MODE == "true") {
// Enable WAL mode — allows concurrent readers + single writer, preventing
// contention across subsystems (verifySession, Traefik, audit, ping).
// NOTE: journal_mode persists in the DB file once set; unsetting this
// env var does NOT revert an existing WAL database.
sqlite.pragma("journal_mode = WAL");
// NORMAL sync mode: safe with WAL, reduces write lock hold time.
sqlite.pragma("synchronous = NORMAL");
}
// Wait up to 5s on SQLITE_BUSY instead of failing — prevents audit log
// retry loops that accumulate memory.
sqlite.pragma("busy_timeout = 5000");
// No busy_timeout pragma: better-sqlite3 already arms
// sqlite3_busy_timeout(db, 5000) via its default `timeout` option
// (lib/database.js), so an explicit pragma is redundant.
// 64 MB page cache (default 2 MB) — reduces I/O round-trips on large
// TraefikConfigManager JOINs that block the event loop.
sqlite.pragma("cache_size = -65536");
// Intentionally NOT setting cache_size or mmap_size: a large page cache plus
// a multi-hundred-MB mmap region inflate RSS and cause page-cache thrashing
// on small (~1 GB) instances. Leave SQLite on its conservative defaults.
// 256 MB memory-mapped I/O — OS serves reads from page cache directly,
// reducing event-loop blocking.
sqlite.pragma("mmap_size = 268435456");
// Wrap prepare() so every drizzle-orm statement is auto-finalized after
// first use, preventing sqlite3_stmt accumulation between GC cycles.
const originalPrepare = sqlite.prepare.bind(sqlite);
(sqlite as any).prepare = function autoFinalizePrepare(source: string) {
return autoFinalizeStatement(originalPrepare(source));
};
// Intentionally NOT wrapping prepare()/statements: better-sqlite3 finalizes
// sqlite3_stmt in the Statement destructor at GC, and drizzle-orm prepares a
// fresh statement per query (no statement cache), so statements cannot
// accumulate. better-sqlite3 11.x exposes no Statement.finalize() at all.
return DrizzleSqlite(sqlite, {
schema
+1
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";
+42 -3
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")
@@ -332,6 +368,7 @@ export const connectionAuditLog = sqliteTable(
clientId: integer("clientId").references(() => clients.clientId, {
onDelete: "cascade"
}),
clientEndpoint: text("clientEndpoint"),
userId: text("userId").references(() => users.userId, {
onDelete: "cascade"
}),
@@ -445,6 +482,8 @@ export const eventStreamingDestinations = sqliteTable(
enabled: integer("enabled", { mode: "boolean" })
.notNull()
.default(true),
lastError: text("lastError"), // last send error message, null if healthy
lastErrorAt: integer("lastErrorAt"), // epoch ms of last error, null if healthy
createdAt: integer("createdAt").notNull(),
updatedAt: integer("updatedAt").notNull()
}
+355 -34
View File
@@ -20,8 +20,10 @@ export const domains = sqliteTable("domains", {
failed: integer("failed", { mode: "boolean" }).notNull().default(false),
tries: integer("tries").notNull().default(0),
certResolver: text("certResolver"),
customCertResolver: text("customCertResolver"),
preferWildcardCert: integer("preferWildcardCert", { mode: "boolean" }),
errorMessage: text("errorMessage")
errorMessage: text("errorMessage"),
lastCheckedAt: integer("lastCheckedAt")
});
export const dnsRecords = sqliteTable("dnsRecords", {
@@ -62,7 +64,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 +124,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()
@@ -141,16 +167,12 @@ export const resources = sqliteTable("resources", {
blockAccess: integer("blockAccess", { mode: "boolean" })
.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()
.default(false),
applyRules: integer("applyRules", { mode: "boolean" })
.notNull()
.default(false),
sso: integer("sso", { mode: "boolean" }),
emailWhitelistEnabled: integer("emailWhitelistEnabled", {
mode: "boolean"
}),
applyRules: integer("applyRules", { mode: "boolean" }),
enabled: integer("enabled", { mode: "boolean" }).notNull().default(true),
stickySession: integer("stickySession", { 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,9 +201,123 @@ 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", {
labelId: integer("labelId").primaryKey({ autoIncrement: true }),
name: text("name").notNull(),
color: text("color").notNull(),
orgId: text("orgId")
.references(() => orgs.orgId, {
onDelete: "cascade"
})
.notNull()
});
export const launcherViews = sqliteTable("launcherViews", {
viewId: integer("viewId").primaryKey({ autoIncrement: true }),
orgId: text("orgId")
.notNull()
.references(() => orgs.orgId, { onDelete: "cascade" }),
userId: text("userId").references(() => users.userId, {
onDelete: "cascade"
}),
name: text("name").notNull(),
config: text("config").notNull(),
isDefault: integer("isDefault", { mode: "boolean" })
.notNull()
.default(false),
createdAt: text("createdAt").notNull(),
updatedAt: text("updatedAt").notNull()
});
export const siteLabels = sqliteTable(
"siteLabels",
{
siteLabelId: integer("siteLabelId").primaryKey({ autoIncrement: true }),
siteId: integer("siteId")
.references(() => sites.siteId, {
onDelete: "cascade"
})
.notNull(),
labelId: integer("labelId")
.references(() => labels.labelId, {
onDelete: "cascade"
})
.notNull()
},
(t) => [unique("site_label_uniq").on(t.siteId, t.labelId)]
);
export const resourceLabels = sqliteTable(
"resourceLabels",
{
resourceLabelId: integer("resourceLabelId").primaryKey({
autoIncrement: true
}),
resourceId: integer("resourceId")
.references(() => resources.resourceId, {
onDelete: "cascade"
})
.notNull(),
labelId: integer("labelId")
.references(() => labels.labelId, {
onDelete: "cascade"
})
.notNull()
},
(t) => [unique("resource_label_uniq").on(t.resourceId, t.labelId)]
);
export const siteResourceLabels = sqliteTable(
"siteResourceLabels",
{
siteResourceLabelId: integer("siteResourceLabelId").primaryKey({
autoIncrement: true
}),
siteResourceId: integer("siteResourceId")
.references(() => siteResources.siteResourceId, {
onDelete: "cascade"
})
.notNull(),
labelId: integer("labelId")
.references(() => labels.labelId, {
onDelete: "cascade"
})
.notNull()
},
(t) => [unique("site_resource_label_uniq").on(t.siteResourceId, t.labelId)]
);
export const clientLabels = sqliteTable(
"clientLabels",
{
clientLabelId: integer("clientLabelId").primaryKey({
autoIncrement: true
}),
clientId: integer("clientId")
.references(() => clients.clientId, {
onDelete: "cascade"
})
.notNull(),
labelId: integer("labelId")
.references(() => labels.labelId, {
onDelete: "cascade"
})
.notNull()
},
(t) => [unique("client_label_uniq").on(t.clientId, t.labelId)]
);
export const targets = sqliteTable("targets", {
targetId: integer("targetId").primaryKey({ autoIncrement: true }),
resourceId: integer("resourceId")
@@ -204,7 +339,12 @@ export const targets = sqliteTable("targets", {
pathMatchType: text("pathMatchType"), // exact, prefix, regex
rewritePath: text("rewritePath"), // if set, rewrites the path to this value before sending to the target
rewritePathType: text("rewritePathType"), // exact, prefix, regex, stripPrefix
priority: integer("priority").notNull().default(100)
priority: integer("priority").notNull().default(100),
mode: text("mode")
.$type<"http" | "tcp" | "udp" | "ssh" | "rdp" | "vnc">()
.notNull()
.default("http"),
authToken: text("authToken")
});
export const targetHealthCheck = sqliteTable("targetHealthCheck", {
@@ -219,9 +359,11 @@ export const targetHealthCheck = sqliteTable("targetHealthCheck", {
onDelete: "cascade"
})
.notNull(),
siteId: integer("siteId").references(() => sites.siteId, {
onDelete: "cascade"
}).notNull(),
siteId: integer("siteId")
.references(() => sites.siteId, {
onDelete: "cascade"
})
.notNull(),
name: text("name"),
hcEnabled: integer("hcEnabled", { mode: "boolean" })
.notNull()
@@ -281,11 +423,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"),
@@ -295,8 +437,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"
@@ -909,6 +1054,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",
{
@@ -937,6 +1123,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"),
@@ -983,6 +1170,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")
});
@@ -1019,10 +1224,101 @@ export const resourceRules = sqliteTable("resourceRules", {
enabled: integer("enabled", { mode: "boolean" }).notNull().default(true),
priority: integer("priority").notNull(),
action: text("action").notNull(), // ACCEPT, DROP, PASS
match: text("match").notNull(), // CIDR, PATH, IP
match: text("match")
.$type<
| "CIDR"
| "PATH"
| "IP"
| "COUNTRY"
| "COUNTRY_IS_NOT"
| "ASN"
| "REGION"
>()
.notNull(), // CIDR, PATH, IP
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"
| "COUNTRY"
| "COUNTRY_IS_NOT"
| "ASN"
| "REGION"
>()
.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(),
@@ -1196,19 +1492,30 @@ export const roundTripMessageTracker = sqliteTable("roundTripMessageTracker", {
complete: integer("complete", { mode: "boolean" }).notNull().default(false)
});
export const statusHistory = sqliteTable("statusHistory", {
id: integer("id").primaryKey({ autoIncrement: true }),
entityType: text("entityType").notNull(), // "site" | "healthCheck"
entityId: integer("entityId").notNull(), // siteId or targetHealthCheckId
orgId: text("orgId")
.notNull()
.references(() => orgs.orgId, { onDelete: "cascade" }),
status: text("status").notNull(), // "online"/"offline" for sites; "healthy"/"unhealthy"/"unknown" for healthChecks
timestamp: integer("timestamp").notNull(), // unix epoch seconds
}, (table) => [
index("idx_statusHistory_entity").on(table.entityType, table.entityId, table.timestamp),
index("idx_statusHistory_org_timestamp").on(table.orgId, table.timestamp),
]);
export const statusHistory = sqliteTable(
"statusHistory",
{
id: integer("id").primaryKey({ autoIncrement: true }),
entityType: text("entityType").notNull(), // "site" | "healthCheck"
entityId: integer("entityId").notNull(), // siteId or targetHealthCheckId
orgId: text("orgId")
.notNull()
.references(() => orgs.orgId, { onDelete: "cascade" }),
status: text("status").notNull(), // "online"/"offline" for sites; "healthy"/"unhealthy"/"unknown" for healthChecks
timestamp: integer("timestamp").notNull() // unix epoch seconds
},
(table) => [
index("idx_statusHistory_entity").on(
table.entityType,
table.entityId,
table.timestamp
),
index("idx_statusHistory_org_timestamp").on(
table.orgId,
table.timestamp
)
]
);
export type Org = InferSelectModel<typeof orgs>;
export type User = InferSelectModel<typeof users>;
@@ -1278,3 +1585,17 @@ export type RoundTripMessageTracker = InferSelectModel<
typeof roundTripMessageTracker
>;
export type StatusHistory = InferSelectModel<typeof statusHistory>;
export type Label = InferSelectModel<typeof labels>;
export type LauncherView = InferSelectModel<typeof launcherViews>;
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>;
+3 -1
View File
@@ -1,5 +1,5 @@
#! /usr/bin/env node
import "./extendZod.ts";
import "./extendZod";
import { runSetupFunctions } from "./setup";
import { createApiServer } from "./apiServer";
@@ -24,6 +24,7 @@ import license from "#dynamic/license/license";
import { initLogCleanupInterval } from "@server/lib/cleanupLogs";
import { initAcmeCertSync } from "#dynamic/lib/acmeCertSync";
import { fetchServerIp } from "@server/lib/serverIpService";
import { startRebuildQueueProcessor } from "@server/lib/rebuildClientAssociations";
async function startServers() {
await setHostMeta();
@@ -41,6 +42,7 @@ async function startServers() {
initLogCleanupInterval();
initAcmeCertSync();
startRebuildQueueProcessor();
// Start all servers
const apiServer = createApiServer();
+11 -3
View File
@@ -152,11 +152,19 @@ function getOpenApiDocumentation() {
if (!hasExistingResponses) {
def.route.responses = {
"*": {
description: "",
"200": {
description: "Successful response",
content: {
"application/json": {
schema: z.object({})
schema: z.object({
data: z
.record(z.string(), z.any())
.nullable(),
success: z.boolean(),
error: z.boolean(),
message: z.string(),
status: z.number()
})
}
}
}
+11 -3
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(
+36 -26
View File
@@ -1,28 +1,39 @@
export enum FeatureId {
export enum LimitId {
USERS = "users",
SITES = "sites",
EGRESS_DATA_MB = "egressDataMb",
DOMAINS = "domains",
REMOTE_EXIT_NODES = "remoteExitNodes",
ORGINIZATIONS = "organizations",
ORGANIZATIONS = "organizations",
PUBLIC_RESOURCES = "publicResources",
PRIVATE_RESOURCES = "privateResources",
MACHINE_CLIENTS = "machineClients",
TIER1 = "tier1"
}
export async function getFeatureDisplayName(featureId: FeatureId): Promise<string> {
export async function getFeatureDisplayName(
featureId: LimitId
): Promise<string> {
switch (featureId) {
case FeatureId.USERS:
case LimitId.USERS:
return "Users";
case FeatureId.SITES:
case LimitId.SITES:
return "Sites";
case FeatureId.EGRESS_DATA_MB:
case LimitId.EGRESS_DATA_MB:
return "Egress Data (MB)";
case FeatureId.DOMAINS:
case LimitId.DOMAINS:
return "Domains";
case FeatureId.REMOTE_EXIT_NODES:
case LimitId.REMOTE_EXIT_NODES:
return "Remote Exit Nodes";
case FeatureId.ORGINIZATIONS:
case LimitId.ORGANIZATIONS:
return "Organizations";
case FeatureId.TIER1:
case LimitId.PUBLIC_RESOURCES:
return "Public Resources";
case LimitId.PRIVATE_RESOURCES:
return "Private Resources";
case LimitId.MACHINE_CLIENTS:
return "Machine Clients";
case LimitId.TIER1:
return "Home Lab";
default:
return featureId;
@@ -30,15 +41,16 @@ export async function getFeatureDisplayName(featureId: FeatureId): Promise<strin
}
// this is from the old system
export const FeatureMeterIds: Partial<Record<FeatureId, string>> = { // right now we are not charging for any data
export const FeatureMeterIds: Partial<Record<LimitId, string>> = {
// right now we are not charging for any data
// [FeatureId.EGRESS_DATA_MB]: "mtr_61Srreh9eWrExDSCe41D3Ee2Ir7Wm5YW"
};
export const FeatureMeterIdsSandbox: Partial<Record<FeatureId, string>> = {
export const FeatureMeterIdsSandbox: Partial<Record<LimitId, string>> = {
// [FeatureId.EGRESS_DATA_MB]: "mtr_test_61Snh2a2m6qome5Kv41DCpkOb237B3dQ"
};
export function getFeatureMeterId(featureId: FeatureId): string | undefined {
export function getFeatureMeterId(featureId: LimitId): string | undefined {
if (
process.env.ENVIRONMENT == "prod" &&
process.env.SANDBOX_MODE !== "true"
@@ -49,22 +61,20 @@ export function getFeatureMeterId(featureId: FeatureId): string | undefined {
}
}
export function getFeatureIdByMetricId(
metricId: string
): FeatureId | undefined {
return (Object.entries(FeatureMeterIds) as [FeatureId, string][]).find(
export function getFeatureIdByMetricId(metricId: string): LimitId | undefined {
return (Object.entries(FeatureMeterIds) as [LimitId, string][]).find(
([_, v]) => v === metricId
)?.[0];
}
export type FeaturePriceSet = Partial<Record<FeatureId, string>>;
export type FeaturePriceSet = Partial<Record<LimitId, string>>;
export const tier1FeaturePriceSet: FeaturePriceSet = {
[FeatureId.TIER1]: "price_1SzVE3D3Ee2Ir7Wm6wT5Dl3G"
[LimitId.TIER1]: "price_1SzVE3D3Ee2Ir7Wm6wT5Dl3G"
};
export const tier1FeaturePriceSetSandbox: FeaturePriceSet = {
[FeatureId.TIER1]: "price_1SxgpPDCpkOb237Bfo4rIsoT"
[LimitId.TIER1]: "price_1SxgpPDCpkOb237Bfo4rIsoT"
};
export function getTier1FeaturePriceSet(): FeaturePriceSet {
@@ -79,11 +89,11 @@ export function getTier1FeaturePriceSet(): FeaturePriceSet {
}
export const tier2FeaturePriceSet: FeaturePriceSet = {
[FeatureId.USERS]: "price_1SzVCcD3Ee2Ir7Wmn6U3KvPN"
[LimitId.USERS]: "price_1SzVCcD3Ee2Ir7Wmn6U3KvPN"
};
export const tier2FeaturePriceSetSandbox: FeaturePriceSet = {
[FeatureId.USERS]: "price_1SxaEHDCpkOb237BD9lBkPiR"
[LimitId.USERS]: "price_1SxaEHDCpkOb237BD9lBkPiR"
};
export function getTier2FeaturePriceSet(): FeaturePriceSet {
@@ -98,11 +108,11 @@ export function getTier2FeaturePriceSet(): FeaturePriceSet {
}
export const tier3FeaturePriceSet: FeaturePriceSet = {
[FeatureId.USERS]: "price_1SzVDKD3Ee2Ir7WmPtOKNusv"
[LimitId.USERS]: "price_1SzVDKD3Ee2Ir7WmPtOKNusv"
};
export const tier3FeaturePriceSetSandbox: FeaturePriceSet = {
[FeatureId.USERS]: "price_1SxaEODCpkOb237BiXdCBSfs"
[LimitId.USERS]: "price_1SxaEODCpkOb237BiXdCBSfs"
};
export function getTier3FeaturePriceSet(): FeaturePriceSet {
@@ -116,7 +126,7 @@ export function getTier3FeaturePriceSet(): FeaturePriceSet {
}
}
export function getFeatureIdByPriceId(priceId: string): FeatureId | undefined {
export function getFeatureIdByPriceId(priceId: string): LimitId | undefined {
// Check all feature price sets
const allPriceSets = [
getTier1FeaturePriceSet(),
@@ -125,7 +135,7 @@ export function getFeatureIdByPriceId(priceId: string): FeatureId | undefined {
];
for (const priceSet of allPriceSets) {
const entry = (Object.entries(priceSet) as [FeatureId, string][]).find(
const entry = (Object.entries(priceSet) as [LimitId, string][]).find(
([_, price]) => price === priceId
);
if (entry) {
+5 -5
View File
@@ -1,19 +1,19 @@
import Stripe from "stripe";
import { FeatureId, FeaturePriceSet } from "./features";
import { LimitId, FeaturePriceSet } from "./features";
import { usageService } from "./usageService";
export async function getLineItems(
featurePriceSet: FeaturePriceSet,
orgId: string,
orgId: string
): Promise<Stripe.Checkout.SessionCreateParams.LineItem[]> {
const users = await usageService.getUsage(orgId, FeatureId.USERS);
const users = await usageService.getUsage(orgId, LimitId.USERS);
return Object.entries(featurePriceSet).map(([featureId, priceId]) => {
let quantity: number | undefined;
if (featureId === FeatureId.USERS) {
if (featureId === LimitId.USERS) {
quantity = users?.instantaneousValue || 1;
} else if (featureId === FeatureId.TIER1) {
} else if (featureId === LimitId.TIER1) {
quantity = 1;
}
+35 -23
View File
@@ -1,70 +1,82 @@
import { FeatureId } from "./features";
import { LimitId } from "./features";
export type LimitSet = Partial<{
[key in FeatureId]: {
[key in LimitId]: {
value: number | null; // null indicates no limit
description?: string;
};
}>;
export const freeLimitSet: LimitSet = {
[FeatureId.SITES]: { value: 5, description: "Basic limit" },
[FeatureId.USERS]: { value: 5, description: "Basic limit" },
[FeatureId.DOMAINS]: { value: 5, description: "Basic limit" },
[FeatureId.REMOTE_EXIT_NODES]: { value: 1, description: "Basic limit" },
[FeatureId.ORGINIZATIONS]: { value: 1, description: "Basic limit" },
[LimitId.SITES]: { value: 5, description: "Basic limit" },
[LimitId.USERS]: { value: 5, description: "Basic limit" },
[LimitId.DOMAINS]: { value: 5, description: "Basic limit" },
[LimitId.REMOTE_EXIT_NODES]: { value: 1, description: "Basic limit" },
[LimitId.ORGANIZATIONS]: { value: 1, description: "Basic limit" },
[LimitId.PUBLIC_RESOURCES]: { value: 15, description: "Basic limit" },
[LimitId.PRIVATE_RESOURCES]: { value: 15, description: "Basic limit" },
[LimitId.MACHINE_CLIENTS]: { value: 5, description: "Basic limit" }
};
export const tier1LimitSet: LimitSet = {
[FeatureId.USERS]: { value: 7, description: "Home limit" },
[FeatureId.SITES]: { value: 10, description: "Home limit" },
[FeatureId.DOMAINS]: { value: 10, description: "Home limit" },
[FeatureId.REMOTE_EXIT_NODES]: { value: 1, description: "Home limit" },
[FeatureId.ORGINIZATIONS]: { value: 1, description: "Home limit" },
[LimitId.USERS]: { value: 7, description: "Home limit" },
[LimitId.SITES]: { value: 10, description: "Home limit" },
[LimitId.DOMAINS]: { value: 10, description: "Home limit" },
[LimitId.REMOTE_EXIT_NODES]: { value: 1, description: "Home limit" },
[LimitId.ORGANIZATIONS]: { value: 1, description: "Home limit" },
[LimitId.PUBLIC_RESOURCES]: { value: 30, description: "Home limit" },
[LimitId.PRIVATE_RESOURCES]: { value: 30, description: "Home limit" },
[LimitId.MACHINE_CLIENTS]: { value: 10, description: "Home limit" }
};
export const tier2LimitSet: LimitSet = {
[FeatureId.USERS]: {
[LimitId.USERS]: {
value: 50,
description: "Team limit"
},
[FeatureId.SITES]: {
[LimitId.SITES]: {
value: 50,
description: "Team limit"
},
[FeatureId.DOMAINS]: {
[LimitId.DOMAINS]: {
value: 50,
description: "Team limit"
},
[FeatureId.REMOTE_EXIT_NODES]: {
[LimitId.REMOTE_EXIT_NODES]: {
value: 3,
description: "Team limit"
},
[FeatureId.ORGINIZATIONS]: {
[LimitId.ORGANIZATIONS]: {
value: 1,
description: "Team limit"
}
},
[LimitId.PUBLIC_RESOURCES]: { value: 150, description: "Team limit" },
[LimitId.PRIVATE_RESOURCES]: { value: 150, description: "Team limit" },
[LimitId.MACHINE_CLIENTS]: { value: 25, description: "Team limit" }
};
export const tier3LimitSet: LimitSet = {
[FeatureId.USERS]: {
[LimitId.USERS]: {
value: 250,
description: "Business limit"
},
[FeatureId.SITES]: {
[LimitId.SITES]: {
value: 250,
description: "Business limit"
},
[FeatureId.DOMAINS]: {
[LimitId.DOMAINS]: {
value: 100,
description: "Business limit"
},
[FeatureId.REMOTE_EXIT_NODES]: {
[LimitId.REMOTE_EXIT_NODES]: {
value: 20,
description: "Business limit"
},
[FeatureId.ORGINIZATIONS]: {
[LimitId.ORGANIZATIONS]: {
value: 5,
description: "Business limit"
},
[LimitId.PUBLIC_RESOURCES]: { value: 750, description: "Business limit" },
[LimitId.PRIVATE_RESOURCES]: { value: 750, description: "Business limit" },
[LimitId.MACHINE_CLIENTS]: { value: 100, description: "Business limit" }
};
+2 -2
View File
@@ -1,7 +1,7 @@
import { db, limits } from "@server/db";
import { and, eq } from "drizzle-orm";
import { LimitSet } from "./limitSet";
import { FeatureId } from "./features";
import { LimitId } from "./features";
import logger from "@server/logger";
class LimitService {
@@ -38,7 +38,7 @@ class LimitService {
async getOrgLimit(
orgId: string,
featureId: FeatureId
featureId: LimitId
): Promise<number | null> {
const limitId = `${orgId}-${featureId}`;
const [limit] = await db
+10 -6
View File
@@ -16,15 +16,17 @@ 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"
WildcardSubdomain = "wildcardSubdomain",
NewtAutoUpdate = "newtAutoUpdate",
ResourcePolicies = "resourcePolicies",
AdvancedPublicResources = "advancedPublicResources",
AdvancedPrivateResources = "advancedPrivateResources"
}
export const tierMatrix: Record<TierFeature, Tier[]> = {
@@ -58,13 +60,15 @@ 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]: ["tier3", "enterprise"],
[TierFeature.AdvancedPrivateResources]: ["tier3", "enterprise"]
};
+26 -19
View File
@@ -9,10 +9,10 @@ import {
Transaction,
orgs
} from "@server/db";
import { FeatureId, getFeatureMeterId } from "./features";
import { LimitId, getFeatureMeterId } from "./features";
import logger from "@server/logger";
import { build } from "@server/build";
import cache from "#dynamic/lib/cache";
import { regionalCache as cache } from "#dynamic/lib/cache";
export function noop() {
if (build !== "saas") {
@@ -22,7 +22,6 @@ export function noop() {
}
export class UsageService {
constructor() {
if (noop()) {
return;
@@ -38,7 +37,7 @@ export class UsageService {
public async add(
orgId: string,
featureId: FeatureId,
featureId: LimitId,
value: number,
transaction: any = null
): Promise<Usage | null> {
@@ -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,
@@ -112,7 +114,7 @@ export class UsageService {
private async internalAddUsage(
orgId: string, // here the orgId is the billing org already resolved by getBillingOrg in updateCount
featureId: FeatureId,
featureId: LimitId,
value: number,
trx: Transaction
): Promise<Usage> {
@@ -161,7 +163,7 @@ export class UsageService {
async updateCount(
orgId: string,
featureId: FeatureId,
featureId: LimitId,
value?: number,
customerId?: string
): Promise<void> {
@@ -225,7 +227,7 @@ export class UsageService {
private async getCustomerId(
orgId: string,
featureId: FeatureId
featureId: LimitId
): Promise<string | null> {
const orgIdToUse = await this.getBillingOrg(orgId);
@@ -267,18 +269,19 @@ export class UsageService {
public async getUsage(
orgId: string,
featureId: FeatureId,
featureId: LimitId,
trx: Transaction | typeof db = db
): Promise<Usage | null> {
if (noop()) {
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(
@@ -374,7 +381,7 @@ export class UsageService {
public async checkLimitSet(
orgId: string,
featureId?: FeatureId,
featureId?: LimitId,
usage?: Usage,
trx: Transaction | typeof db = db
): Promise<boolean> {
@@ -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
@@ -422,7 +429,7 @@ export class UsageService {
} else {
currentUsage = await this.getUsage(
orgIdToUse,
limit.featureId as FeatureId,
limit.featureId as LimitId,
trx
);
}
+98 -200
View File
@@ -3,29 +3,43 @@ import {
newts,
blueprints,
Blueprint,
Site,
siteResources,
roleSiteResources,
userSiteResources,
clientSiteResources
} from "@server/db";
import { Config, ConfigSchema } from "./types";
import { ProxyResourcesResults, updateProxyResources } from "./proxyResources";
import { Config, ConfigSchema, isTargetsOnlyResource } from "./types";
import {
PublicResourcesResults,
updatePublicResources
} from "./publicResources";
import { fromError } from "zod-validation-error";
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 {
addTargets as addProxyTargets,
sendBrowserGatewayTargets
} from "@server/routers/newt/targets";
import {
ClientResourcesResults,
updateClientResources
} from "./clientResources";
updatePrivateResources
} from "./privateResources";
import { updateResourcePolicies } from "./resourcePolicies";
import { BlueprintSource } from "@server/routers/blueprints/types";
import { stringify as stringifyYaml } from "yaml";
import { faker } from "@faker-js/faker";
import { handleMessagingForUpdatedSiteResource } from "@server/routers/siteResource";
import { rebuildClientAssociationsFromSiteResource } from "../rebuildClientAssociations";
import { generateName } from "@server/db/names";
import {
handleMessagingForUpdatedSiteResource,
rebuildClientAssociationsFromSiteResource,
waitForSiteResourceRebuildIdle
} from "../rebuildClientAssociations";
import { build } from "@server/build";
import HttpCode from "@server/types/HttpCode";
import createHttpError from "http-errors";
import next from "next";
import { LimitId } from "../billing";
import { usageService } from "../billing/usageService";
type ApplyBlueprintArgs = {
orgId: string;
@@ -42,40 +56,39 @@ export async function applyBlueprint({
name,
source = "API"
}: ApplyBlueprintArgs): Promise<Blueprint> {
// Validate the input data
const validationResult = ConfigSchema.safeParse(configData);
if (!validationResult.success) {
throw new Error(fromError(validationResult.error).toString());
}
const config: Config = validationResult.data;
let blueprintSucceeded: boolean = false;
let blueprintMessage: string;
let blueprintMessage = "";
let error: any | null = null;
try {
let proxyResourcesResults: ProxyResourcesResults = [];
let clientResourcesResults: ClientResourcesResults = [];
await db.transaction(async (trx) => {
proxyResourcesResults = await updateProxyResources(
orgId,
config,
trx,
siteId
);
clientResourcesResults = await updateClientResources(
orgId,
config,
trx,
siteId
);
const validationResult = ConfigSchema.safeParse(configData);
if (!validationResult.success) {
throw new Error(fromError(validationResult.error).toString());
}
logger.debug(
`Successfully updated proxy resources for org ${orgId}: ${JSON.stringify(proxyResourcesResults)}`
const config: Config = validationResult.data;
let publicResourcesResults: PublicResourcesResults = [];
let privateResourcesResults: ClientResourcesResults = [];
await db.transaction(async (trx) => {
await updateResourcePolicies(orgId, config, trx);
publicResourcesResults = await updatePublicResources(
orgId,
config,
trx,
siteId
);
privateResourcesResults = await updatePrivateResources(
orgId,
config,
trx,
siteId
);
// We need to update the targets on the newts from the successfully updated information
for (const result of proxyResourcesResults) {
for (const result of publicResourcesResults) {
for (const target of result.targetsToUpdate) {
const [site] = await trx
.select()
@@ -102,178 +115,63 @@ export async function applyBlueprint({
(hc) => hc.targetId === target.targetId
);
await addProxyTargets(
site.newt.newtId,
[target],
matchingHealthcheck ? [matchingHealthcheck] : [],
result.proxyResource.protocol,
site.newt.version
);
if (["http", "tcp", "udp"].includes(target.mode)) {
await addProxyTargets(
site.newt.newtId,
[target],
matchingHealthcheck
? [matchingHealthcheck]
: [],
result.proxyResource.mode === "udp"
? "udp"
: "tcp",
site.newt.version
);
} else if (
["ssh", "rdp", "vnc"].includes(target.mode)
) {
await sendBrowserGatewayTargets(
site.newt.newtId,
[target],
site.newt.version
);
}
}
}
}
logger.debug(
`Successfully updated client resources for org ${orgId}: ${JSON.stringify(clientResourcesResults)}`
`Successfully updated public resources for org ${orgId}: ${JSON.stringify(publicResourcesResults)}`
);
// We need to update the targets on the newts from the successfully updated information
for (const result of clientResourcesResults) {
if (
result.oldSiteResource &&
JSON.stringify(result.newSites?.sort()) !==
JSON.stringify(result.oldSites?.sort())
) {
// query existing associations
const existingRoleIds = await trx
.select()
.from(roleSiteResources)
.where(
eq(
roleSiteResources.siteResourceId,
result.oldSiteResource.siteResourceId
)
for (const result of privateResourcesResults) {
rebuildClientAssociationsFromSiteResource(
result.newSiteResource
)
.then(() =>
waitForSiteResourceRebuildIdle(
result.newSiteResource.siteResourceId
)
.then((rows) => rows.map((row) => row.roleId));
const existingUserIds = await trx
.select()
.from(userSiteResources)
.where(
eq(
userSiteResources.siteResourceId,
result.oldSiteResource.siteResourceId
)
)
.then(() =>
handleMessagingForUpdatedSiteResource(
result.oldSiteResource,
result.newSiteResource,
result.oldSites.map((s) => s.siteId),
result.newSites.map((s) => s.siteId)
)
.then((rows) => rows.map((row) => row.userId));
const existingClientIds = await trx
.select()
.from(clientSiteResources)
.where(
eq(
clientSiteResources.siteResourceId,
result.oldSiteResource.siteResourceId
)
)
.then((rows) => rows.map((row) => row.clientId));
// delete the existing site resource
await trx
.delete(siteResources)
.where(
and(
eq(
siteResources.siteResourceId,
result.oldSiteResource.siteResourceId
)
)
)
.catch((e) => {
logger.error(
`Failed to rebuild and handle messaging for site resource ${result.newSiteResource.siteResourceId}. Error: ${e}`
);
await rebuildClientAssociationsFromSiteResource(
result.oldSiteResource,
trx
);
const [insertedSiteResource] = await trx
.insert(siteResources)
.values({
...result.newSiteResource
})
.returning();
// wait some time to allow for messages to be handled
await new Promise((resolve) => setTimeout(resolve, 750));
//////////////////// update the associations ////////////////////
if (existingRoleIds.length > 0) {
await trx.insert(roleSiteResources).values(
existingRoleIds.map((roleId) => ({
roleId,
siteResourceId:
insertedSiteResource!.siteResourceId
}))
);
}
if (existingUserIds.length > 0) {
await trx.insert(userSiteResources).values(
existingUserIds.map((userId) => ({
userId,
siteResourceId:
insertedSiteResource!.siteResourceId
}))
);
}
if (existingClientIds.length > 0) {
await trx.insert(clientSiteResources).values(
existingClientIds.map((clientId) => ({
clientId,
siteResourceId:
insertedSiteResource!.siteResourceId
}))
);
}
await rebuildClientAssociationsFromSiteResource(
insertedSiteResource,
trx
);
} else {
let good = true;
for (const newSite of result.newSites) {
const [site] = await trx
.select()
.from(sites)
.innerJoin(newts, eq(sites.siteId, newts.siteId))
.where(
and(
eq(sites.siteId, newSite.siteId),
eq(sites.orgId, orgId),
eq(sites.type, "newt"),
isNotNull(sites.pubKey)
)
)
.limit(1);
if (!site) {
logger.debug(
`No newt sites found for client resource ${result.newSiteResource.siteResourceId}, skipping target update`
);
good = false;
break;
}
logger.debug(
`Updating client resource ${result.newSiteResource.siteResourceId} on site ${newSite.siteId}`
);
}
if (!good) {
continue;
}
await handleMessagingForUpdatedSiteResource(
result.oldSiteResource,
result.newSiteResource,
result.newSites.map((site) => ({
siteId: site.siteId,
orgId: result.newSiteResource.orgId
})),
trx
);
}
// await addClientTargets(
// site.newt.newtId,
// result.resource.destination,
// result.resource.destinationPort,
// result.resource.protocol,
// result.resource.proxyPort
// );
});
}
logger.debug(
`Successfully updated private resources for org ${orgId}: ${JSON.stringify(privateResourcesResults)}`
);
});
blueprintSucceeded = true;
@@ -281,7 +179,9 @@ export async function applyBlueprint({
} catch (err) {
blueprintSucceeded = false;
blueprintMessage = `Blueprint applied with errors: ${err}`;
logger.error(blueprintMessage);
logger.debug(
`Org ${orgId} blueprint apply issues: ${blueprintMessage}`
);
error = err;
}
@@ -291,9 +191,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,
@@ -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,27 @@ 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);
// Some Newt clients can report null/undefined containers when Docker
// labels are unavailable. Treat that as an empty blueprint payload.
const safeContainers = Array.isArray(containers) ? containers : [];
const blueprint = processContainerLabels(safeContainers);
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 +96,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;
}
@@ -51,7 +116,7 @@ export async function applyNewtDockerBlueprint(
source: "NEWT"
});
} catch (error) {
logger.error(`Failed to update database from config: ${error}`);
logger.debug(`Failed to update database from config: ${error}`);
await sendToClient(newtId, {
type: "newt/blueprint/results",
data: {
@@ -66,7 +131,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"
}
});
}
@@ -3,6 +3,7 @@ import {
clientSiteResources,
domains,
orgDomains,
roleActions,
roles,
roleSiteResources,
Site,
@@ -19,8 +20,17 @@ 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";
import { isLicensedOrSubscribed } from "#dynamic/lib/isLicencedOrSubscribed";
import { tierMatrix } from "../billing/tierMatrix";
import { build } from "@server/build";
import HttpCode from "@server/types/HttpCode";
import createHttpError from "http-errors";
import next from "next";
import { LimitId } from "../billing";
import { usageService } from "../billing/usageService";
async function getDomainForSiteResource(
siteResourceId: number | undefined,
@@ -101,7 +111,7 @@ export type ClientResourcesResults = {
oldSites: { siteId: number }[];
}[];
export async function updateClientResources(
export async function updatePrivateResources(
orgId: string,
config: Config,
trx: Transaction,
@@ -112,6 +122,30 @@ export async function updateClientResources(
for (const [resourceNiceId, resourceData] of Object.entries(
config["client-resources"]
)) {
if (resourceData.mode === "http") {
const hasHttpFeature = await isLicensedOrSubscribed(
orgId,
tierMatrix.advancedPrivateResources
);
if (!hasHttpFeature) {
throw new Error(
"HTTP private resources are not included in your current plan. Please upgrade."
);
}
}
if (resourceData.mode === "ssh") {
const hasSshFeature = await isLicensedOrSubscribed(
orgId,
tierMatrix.advancedPrivateResources
);
if (!hasSshFeature) {
throw new Error(
"SSH private resources are not included in your current plan. Please upgrade."
);
}
}
const [existingResource] = await trx
.select()
.from(siteResources)
@@ -225,7 +259,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 +370,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 +380,30 @@ 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)
@@ -359,9 +419,47 @@ export async function updateClientResources(
oldSites: existingSiteIds
});
} else {
// create a brand new resource
if (build == "saas") {
const usage = await usageService.getUsage(
orgId,
LimitId.PRIVATE_RESOURCES
);
if (!usage) {
throw new Error(
`Usage data not found for org ${orgId} and limit ${LimitId.PRIVATE_RESOURCES}`
);
}
const rejectResource = await usageService.checkLimitSet(
orgId,
LimitId.PRIVATE_RESOURCES,
{
...usage,
instantaneousValue: (usage.instantaneousValue || 0) + 1
} // We need to add one to know if we are violating the limit
);
if (rejectResource) {
throw new Error(
"Private resource limit exceeded. Please upgrade your plan."
);
}
}
let aliasAddress: string | null = null;
if (resourceData.mode === "host" || resourceData.mode === "http") {
aliasAddress = await getNextAvailableAliasAddress(orgId, trx);
let releaseAliasLock: (() => Promise<void>) | null = null;
if (
resourceData.mode === "host" ||
resourceData.mode === "http" ||
resourceData.mode === "ssh"
) {
const { value, release } = await getNextAvailableAliasAddress(
orgId,
trx
);
aliasAddress = value;
releaseAliasLock = release;
}
let domainInfo:
@@ -415,10 +513,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 +548,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 +558,30 @@ 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)
@@ -517,6 +643,8 @@ export async function updateClientResources(
`Created new client resource ${newResource.name} (${newResource.siteResourceId}) for org ${orgId}`
);
await usageService.add(orgId, LimitId.PRIVATE_RESOURCES, 1, trx);
results.push({
newSiteResource: newResource,
newSites: allSites,
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+654
View File
@@ -0,0 +1,654 @@
import {
db,
idp,
idpOrg,
resourcePolicies,
resourcePolicyHeaderAuth,
resourcePolicyPassword,
resourcePolicyPincode,
resourcePolicyRules,
resourcePolicyWhiteList,
rolePolicies,
roles,
Transaction,
userOrgs,
userPolicies,
users
} from "@server/db";
import { eq, and, or } from "drizzle-orm";
import { Config, ResourcePolicyData } from "./types";
import logger from "@server/logger";
import { getUniqueResourcePolicyName } from "@server/db/names";
import { hashPassword } from "@server/auth/password";
import { isValidCIDR, isValidIP, isValidUrlGlobPattern } from "../validators";
import { isLicensedOrSubscribed } from "#dynamic/lib/isLicencedOrSubscribed";
import { tierMatrix } from "../billing/tierMatrix";
export type ResourcePoliciesResults = {
resourcePolicyId: number;
niceId: string;
}[];
export async function updateResourcePolicies(
orgId: string,
config: Config,
trx: Transaction
): Promise<ResourcePoliciesResults> {
const results: ResourcePoliciesResults = [];
for (const [policyNiceId, policyData] of Object.entries(
config["public-policies"]
)) {
const isLicensed = await isLicensedOrSubscribed(
orgId,
tierMatrix.resourcePolicies
);
if (!isLicensed) {
throw new Error(
"Your current subscription does not support shared resource policies. Please upgrade to access this feature."
);
}
// Validate rules
for (const rule of policyData.rules) {
if (rule.match === "cidr" && !isValidCIDR(rule.value)) {
throw new Error(
`Invalid CIDR provided in resource policy '${policyNiceId}': ${rule.value}`
);
} else if (rule.match === "ip" && !isValidIP(rule.value)) {
throw new Error(
`Invalid IP provided in resource policy '${policyNiceId}': ${rule.value}`
);
} else if (
rule.match === "path" &&
!isValidUrlGlobPattern(rule.value)
) {
throw new Error(
`Invalid URL glob pattern provided in resource policy '${policyNiceId}': ${rule.value}`
);
}
}
// Validate auto-login-idp if provided
if (policyData["auto-login-idp"]) {
const [provider] = await trx
.select()
.from(idp)
.innerJoin(idpOrg, eq(idpOrg.idpId, idp.idpId))
.where(
and(
eq(idp.idpId, policyData["auto-login-idp"]),
eq(idpOrg.orgId, orgId)
)
)
.limit(1);
if (!provider) {
throw new Error(
`Identity provider not found for policy '${policyNiceId}' in this organization`
);
}
}
// Look up the admin role
const [adminRole] = await trx
.select()
.from(roles)
.where(and(eq(roles.isAdmin, true), eq(roles.orgId, orgId)))
.limit(1);
if (!adminRole) {
throw new Error("Admin role not found");
}
// Find existing policy by niceId and orgId
const [existingPolicy] = await trx
.select()
.from(resourcePolicies)
.where(
and(
eq(resourcePolicies.niceId, policyNiceId),
eq(resourcePolicies.orgId, orgId)
)
)
.limit(1);
let resourcePolicyId: number;
if (existingPolicy) {
// Update the existing policy
await trx
.update(resourcePolicies)
.set({
name: policyData.name,
sso: policyData.sso ?? true,
idpId: policyData["auto-login-idp"] ?? null,
emailWhitelistEnabled:
policyData["email-whitelist-enabled"] ??
policyData["whitelist-users"].length > 0,
applyRules:
policyData["apply-rules"] || policyData.rules.length > 0
})
.where(
eq(
resourcePolicies.resourcePolicyId,
existingPolicy.resourcePolicyId
)
);
resourcePolicyId = existingPolicy.resourcePolicyId;
// Sync password
await trx
.delete(resourcePolicyPassword)
.where(
eq(
resourcePolicyPassword.resourcePolicyId,
resourcePolicyId
)
);
if (policyData.password) {
const passwordHash = await hashPassword(policyData.password);
await trx.insert(resourcePolicyPassword).values({
resourcePolicyId,
passwordHash
});
}
// Sync pincode
await trx
.delete(resourcePolicyPincode)
.where(
eq(resourcePolicyPincode.resourcePolicyId, resourcePolicyId)
);
if (policyData.pincode) {
const pincodeHash = await hashPassword(policyData.pincode);
await trx.insert(resourcePolicyPincode).values({
resourcePolicyId,
pincodeHash,
digitLength: 6
});
}
// Sync header auth
await trx
.delete(resourcePolicyHeaderAuth)
.where(
eq(
resourcePolicyHeaderAuth.resourcePolicyId,
resourcePolicyId
)
);
if (policyData["basic-auth"]) {
const basicAuth = policyData["basic-auth"];
const headerAuthHash = await hashPassword(
Buffer.from(
`${basicAuth.user}:${basicAuth.password}`
).toString("base64")
);
await trx.insert(resourcePolicyHeaderAuth).values({
resourcePolicyId,
headerAuthHash,
extendedCompatibility:
basicAuth["extended-compatibility"] ?? true
});
}
// Sync SSO roles
await syncRolePolicies(
resourcePolicyId,
policyData["sso-roles"],
orgId,
adminRole.roleId,
trx
);
// Sync SSO users
await syncUserPolicies(
resourcePolicyId,
policyData["sso-users"],
orgId,
trx
);
// Sync whitelist users
await syncWhitelistPolicyUsers(
resourcePolicyId,
policyData["whitelist-users"],
trx
);
// Sync rules
await syncPolicyRules(resourcePolicyId, policyData.rules, trx);
logger.debug(
`Updated resource policy ${resourcePolicyId} (${policyNiceId})`
);
} else {
// Create a new policy
const [newPolicy] = await trx
.insert(resourcePolicies)
.values({
niceId: policyNiceId,
orgId,
name: policyData.name,
sso: policyData.sso ?? true,
idpId: policyData["auto-login-idp"] ?? null,
emailWhitelistEnabled:
policyData["email-whitelist-enabled"] ??
policyData["whitelist-users"].length > 0,
applyRules:
policyData["apply-rules"] ||
policyData.rules.length > 0,
scope: "global"
})
.returning();
resourcePolicyId = newPolicy.resourcePolicyId;
// Always add admin role
await trx.insert(rolePolicies).values({
roleId: adminRole.roleId,
resourcePolicyId
});
// Add SSO roles
await addRolePolicies(
resourcePolicyId,
policyData["sso-roles"],
orgId,
adminRole.roleId,
trx
);
// Add SSO users
await addUserPolicies(
resourcePolicyId,
policyData["sso-users"],
orgId,
trx
);
// Add password
if (policyData.password) {
const passwordHash = await hashPassword(policyData.password);
await trx.insert(resourcePolicyPassword).values({
resourcePolicyId,
passwordHash
});
}
// Add pincode
if (policyData.pincode) {
const pincodeHash = await hashPassword(policyData.pincode);
await trx.insert(resourcePolicyPincode).values({
resourcePolicyId,
pincodeHash,
digitLength: 6
});
}
// Add header auth
if (policyData["basic-auth"]) {
const basicAuth = policyData["basic-auth"];
const headerAuthHash = await hashPassword(
Buffer.from(
`${basicAuth.user}:${basicAuth.password}`
).toString("base64")
);
await trx.insert(resourcePolicyHeaderAuth).values({
resourcePolicyId,
headerAuthHash,
extendedCompatibility:
basicAuth["extended-compatibility"] ?? true
});
}
// Add whitelist users
if (policyData["whitelist-users"].length > 0) {
await trx.insert(resourcePolicyWhiteList).values(
policyData["whitelist-users"].map((email) => ({
email,
resourcePolicyId
}))
);
}
// Add rules
if (policyData.rules.length > 0) {
await trx.insert(resourcePolicyRules).values(
policyData.rules.map((rule, index) => ({
resourcePolicyId,
action: getRuleAction(rule.action),
match: getRuleMatch(rule.match),
value: rule.value,
priority: rule.priority ?? index + 1,
enabled: rule.enabled ?? true
}))
);
}
logger.debug(
`Created resource policy ${resourcePolicyId} (${policyNiceId})`
);
}
results.push({ resourcePolicyId, niceId: policyNiceId });
}
return results;
}
function getRuleAction(input: string): "ACCEPT" | "DROP" | "PASS" {
if (input === "allow") return "ACCEPT";
if (input === "deny") return "DROP";
return "PASS";
}
function getRuleMatch(
input: string
): "CIDR" | "IP" | "PATH" | "COUNTRY" | "COUNTRY_IS_NOT" | "ASN" | "REGION" {
return input.toUpperCase() as
| "CIDR"
| "IP"
| "PATH"
| "COUNTRY"
| "COUNTRY_IS_NOT"
| "ASN"
| "REGION";
}
async function syncRolePolicies(
policyId: number,
ssoRoles: string[],
orgId: string,
adminRoleId: number,
trx: Transaction
) {
const existingRolePolicies = await trx
.select()
.from(rolePolicies)
.where(eq(rolePolicies.resourcePolicyId, policyId));
for (const roleName of ssoRoles) {
const [role] = await trx
.select()
.from(roles)
.where(and(eq(roles.name, roleName), eq(roles.orgId, orgId)))
.limit(1);
if (!role) {
logger.warn(
`Role '${roleName}' not found in org '${orgId}', skipping`
);
continue;
}
if (role.isAdmin) {
continue; // admin role is always included, skip
}
const alreadyExists = existingRolePolicies.some(
(rp) => rp.roleId === role.roleId
);
if (!alreadyExists) {
await trx.insert(rolePolicies).values({
roleId: role.roleId,
resourcePolicyId: policyId
});
}
}
// Remove roles no longer in the list (except admin)
for (const existingRolePolicy of existingRolePolicies) {
if (existingRolePolicy.roleId === adminRoleId) {
continue;
}
const [role] = await trx
.select()
.from(roles)
.where(eq(roles.roleId, existingRolePolicy.roleId))
.limit(1);
if (role?.isAdmin) {
continue;
}
if (role && !ssoRoles.includes(role.name)) {
await trx
.delete(rolePolicies)
.where(
and(
eq(rolePolicies.resourcePolicyId, policyId),
eq(rolePolicies.roleId, existingRolePolicy.roleId)
)
);
}
}
}
async function addRolePolicies(
policyId: number,
ssoRoles: string[],
orgId: string,
adminRoleId: number,
trx: Transaction
) {
for (const roleName of ssoRoles) {
const [role] = await trx
.select()
.from(roles)
.where(and(eq(roles.name, roleName), eq(roles.orgId, orgId)))
.limit(1);
if (!role) {
logger.warn(
`Role '${roleName}' not found in org '${orgId}', skipping`
);
continue;
}
if (role.isAdmin) {
continue; // admin already added
}
await trx.insert(rolePolicies).values({
roleId: role.roleId,
resourcePolicyId: policyId
});
}
}
async function syncUserPolicies(
policyId: number,
ssoUsers: string[],
orgId: string,
trx: Transaction
) {
const existingUserPolicies = await trx
.select()
.from(userPolicies)
.where(eq(userPolicies.resourcePolicyId, policyId));
for (const username of ssoUsers) {
const [user] = await trx
.select()
.from(users)
.innerJoin(userOrgs, eq(users.userId, userOrgs.userId))
.where(
and(
or(eq(users.username, username), eq(users.email, username)),
eq(userOrgs.orgId, orgId)
)
)
.limit(1);
if (!user) {
logger.warn(
`User '${username}' not found in org '${orgId}', skipping`
);
continue;
}
const alreadyExists = existingUserPolicies.some(
(up) => up.userId === user.user.userId
);
if (!alreadyExists) {
await trx.insert(userPolicies).values({
userId: user.user.userId,
resourcePolicyId: policyId
});
}
}
// Remove users no longer in the list
for (const existingUserPolicy of existingUserPolicies) {
const [user] = await trx
.select()
.from(users)
.innerJoin(userOrgs, eq(users.userId, userOrgs.userId))
.where(
and(
eq(users.userId, existingUserPolicy.userId),
eq(userOrgs.orgId, orgId)
)
)
.limit(1);
if (
user &&
user.user.username &&
!ssoUsers.includes(user.user.username) &&
!ssoUsers.includes(user.user.email ?? "")
) {
await trx
.delete(userPolicies)
.where(
and(
eq(userPolicies.resourcePolicyId, policyId),
eq(userPolicies.userId, existingUserPolicy.userId)
)
);
}
}
}
async function addUserPolicies(
policyId: number,
ssoUsers: string[],
orgId: string,
trx: Transaction
) {
for (const username of ssoUsers) {
const [user] = await trx
.select()
.from(users)
.innerJoin(userOrgs, eq(users.userId, userOrgs.userId))
.where(
and(
or(eq(users.username, username), eq(users.email, username)),
eq(userOrgs.orgId, orgId)
)
)
.limit(1);
if (!user) {
logger.warn(
`User '${username}' not found in org '${orgId}', skipping`
);
continue;
}
await trx.insert(userPolicies).values({
userId: user.user.userId,
resourcePolicyId: policyId
});
}
}
async function syncWhitelistPolicyUsers(
policyId: number,
whitelistUsers: string[],
trx: Transaction
) {
const existingWhitelist = await trx
.select()
.from(resourcePolicyWhiteList)
.where(eq(resourcePolicyWhiteList.resourcePolicyId, policyId));
for (const email of whitelistUsers) {
const alreadyExists = existingWhitelist.some((w) => w.email === email);
if (!alreadyExists) {
await trx.insert(resourcePolicyWhiteList).values({
email,
resourcePolicyId: policyId
});
}
}
for (const existingEntry of existingWhitelist) {
if (!whitelistUsers.includes(existingEntry.email)) {
await trx
.delete(resourcePolicyWhiteList)
.where(
and(
eq(resourcePolicyWhiteList.resourcePolicyId, policyId),
eq(resourcePolicyWhiteList.email, existingEntry.email)
)
);
}
}
}
async function syncPolicyRules(
policyId: number,
rules: ResourcePolicyData["rules"],
trx: Transaction
) {
const existingRules = await trx
.select()
.from(resourcePolicyRules)
.where(eq(resourcePolicyRules.resourcePolicyId, policyId))
.orderBy(resourcePolicyRules.priority);
for (const [index, rule] of rules.entries()) {
const intendedPriority = rule.priority ?? index + 1;
const existingRule = existingRules[index];
if (existingRule) {
await trx
.update(resourcePolicyRules)
.set({
action: getRuleAction(rule.action),
match: getRuleMatch(rule.match),
value: rule.value,
priority: intendedPriority,
enabled: rule.enabled ?? true
})
.where(eq(resourcePolicyRules.ruleId, existingRule.ruleId));
} else {
await trx.insert(resourcePolicyRules).values({
resourcePolicyId: policyId,
action: getRuleAction(rule.action),
match: getRuleMatch(rule.match),
value: rule.value,
priority: intendedPriority,
enabled: rule.enabled ?? true
});
}
}
// Remove extra rules
if (existingRules.length > rules.length) {
const rulesToDelete = existingRules.slice(rules.length);
for (const rule of rulesToDelete) {
await trx
.delete(resourcePolicyRules)
.where(eq(resourcePolicyRules.ruleId, rule.ruleId));
}
}
}
+256 -34
View File
@@ -1,8 +1,23 @@
import { z } from "zod";
import { existsSync } from "node:fs";
import { portRangeStringSchema } from "@server/lib/ip";
import { MaintenanceSchema } from "#dynamic/lib/blueprints/MaintenanceSchema";
import { isValidRegionId } from "@server/db/regions";
import { wildcardSubdomainSchema } from "@server/lib/schemas";
import config from "@server/lib/config";
const maxmindDbPath = config.getRawConfig().server.maxmind_db_path;
const maxmindAsnPath = config.getRawConfig().server.maxmind_asn_path;
const hasMaxmindCountryDb =
typeof maxmindDbPath === "string" &&
maxmindDbPath.length > 0 &&
existsSync(maxmindDbPath);
const hasMaxmindAsnDb =
typeof maxmindAsnPath === "string" &&
maxmindAsnPath.length > 0 &&
existsSync(maxmindAsnPath);
export const SiteSchema = z.object({
name: z.string().min(1).max(100),
@@ -82,8 +97,9 @@ export const RuleSchema = z
.object({
action: z.enum(["allow", "deny", "pass"]),
match: z.enum(["cidr", "path", "ip", "country", "asn", "region"]),
value: z.string(),
priority: z.int().optional()
value: z.coerce.string(),
priority: z.int().optional(),
enabled: z.boolean().optional().default(true)
})
.refine(
(rule) => {
@@ -116,6 +132,9 @@ export const RuleSchema = z
.refine(
(rule) => {
if (rule.match === "country") {
if (!hasMaxmindCountryDb) {
return false;
}
// Check if it's a valid 2-letter country code or "ALL"
return /^[A-Z]{2}$/.test(rule.value) || rule.value === "ALL";
}
@@ -124,12 +143,15 @@ export const RuleSchema = z
{
path: ["value"],
message:
"Value must be a 2-letter country code or 'ALL' when match is 'country'"
"Country rules require a valid existing server.maxmind_db_path and value must be a 2-letter country code or 'ALL'"
}
)
.refine(
(rule) => {
if (rule.match === "asn") {
if (!hasMaxmindCountryDb || !hasMaxmindAsnDb) {
return false;
}
// Check if it's either AS<number> format or "ALL"
const asNumberPattern = /^AS\d+$/i;
return asNumberPattern.test(rule.value) || rule.value === "ALL";
@@ -139,7 +161,7 @@ export const RuleSchema = z
{
path: ["value"],
message:
"Value must be 'AS<number>' format or 'ALL' when match is 'asn'"
"ASN rules require valid existing server.maxmind_db_path and server.maxmind_asn_path, and value must be 'AS<number>' format or 'ALL'"
}
)
.refine(
@@ -161,11 +183,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 +222,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 +233,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 +250,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 +269,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 +289,37 @@ export const ResourceSchema = z
return true;
}
// If protocol is http, it must have a full-domain
if (resource.protocol === "http") {
const effectiveProtocol = resource.mode ?? resource.protocol;
if (effectiveProtocol !== "ssh") {
return true;
}
const authDaemonMode = resource["auth-daemon"]?.mode;
if (authDaemonMode !== "native" && authDaemonMode !== "site") {
return true;
}
return (
resource.targets.filter((target) => target != null).length <= 1
);
},
{
path: ["targets"],
error: "When protocol is 'ssh' and auth-daemon mode is 'native' or 'site', only one target/site is allowed"
}
)
.refine(
(resource) => {
if (isTargetsOnlyResource(resource)) {
return true;
}
// If protocol/mode is http, ssh, rdp, or vnc, it must have a full-domain
const effectiveProtocol = resource.mode ?? resource.protocol;
if (
effectiveProtocol !== undefined &&
["http", "ssh", "rdp", "vnc"].includes(effectiveProtocol)
) {
return (
resource["full-domain"] !== undefined &&
resource["full-domain"].length > 0
@@ -250,7 +329,7 @@ export const ResourceSchema = z
},
{
path: ["full-domain"],
error: "When protocol is 'http', a 'full-domain' must be provided"
error: "When protocol is 'http', 'ssh', 'rdp', or 'vnc', a 'full-domain' must be provided"
}
)
.refine(
@@ -259,8 +338,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 +357,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 +421,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 +430,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 +492,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 +544,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()])
@@ -429,25 +556,112 @@ export const ClientResourceSchema = z
{
message: "Destination must be a valid CIDR notation for cidr mode"
}
);
)
.refine(
(data) => {
if (data.mode !== "ssh") {
return true;
}
const authDaemonMode = data["auth-daemon"]?.mode;
if (authDaemonMode !== "native" && authDaemonMode !== "site") {
return true;
}
const uniqueSites = new Set<string>();
if (data.site) {
uniqueSites.add(data.site);
}
for (const site of data.sites) {
uniqueSites.add(site);
}
return uniqueSites.size <= 1;
},
{
path: ["sites"],
message:
"When mode is 'ssh' and auth-daemon mode is 'native' or 'site', only one site/target is allowed"
}
)
.transform((data) => {
if (
data.mode === "ssh" &&
data.destination !== undefined &&
data["destination-port"] === undefined
) {
data["destination-port"] = 22;
}
return data;
});
export const ResourcePolicyRuleSchema = RuleSchema;
export const ResourcePolicySchema = z.object({
name: z.string().min(1).max(255),
sso: z.boolean().optional().default(true),
"auto-login-idp": z.int().positive().optional().nullable(),
"sso-roles": z
.array(z.string())
.optional()
.default([])
.refine((roles) => !roles.includes("Admin"), {
error: "Admin role cannot be included in sso-roles"
}),
"sso-users": z.array(z.string()).optional().default([]),
password: z.string().min(4).max(100).optional().nullable(),
pincode: z
.string()
.regex(/^\d{6}$/)
.optional()
.nullable(),
"basic-auth": z
.object({
user: z.string().min(4).max(100),
password: z.string().min(4).max(100),
"extended-compatibility": z.boolean().default(true)
})
.optional()
.nullable(),
"email-whitelist-enabled": z.boolean().optional().default(false),
"whitelist-users": z
.array(
z.email().or(
z.string().regex(/^\*@[\w.-]+\.[a-zA-Z]{2,}$/, {
error: "Invalid email address. Wildcard (*) must be the entire local part."
})
)
)
.max(50)
.transform((v) => v.map((e) => e.toLowerCase()))
.optional()
.default([]),
"apply-rules": z.boolean().optional().default(false),
rules: z.array(ResourcePolicyRuleSchema).optional().default([])
});
export type ResourcePolicyData = z.infer<typeof ResourcePolicySchema>;
// Schema for the entire configuration object
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({}),
"public-policies": z
.record(z.string(), ResourcePolicySchema)
.optional()
.prefault({}),
sites: z.record(z.string(), SiteSchema).optional().prefault({})
@@ -472,10 +686,17 @@ 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>
>;
"public-policies": Record<
string,
z.infer<typeof ResourcePolicySchema>
>;
sites: Record<string, z.infer<typeof SiteSchema>>;
};
@@ -614,5 +835,6 @@ 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>;
export type BlueprintResourcePolicy = z.infer<typeof ResourcePolicySchema>;
+11
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;
+414 -408
View File
@@ -6,6 +6,7 @@ import {
db,
olms,
orgs,
primaryDb,
roleClients,
roles,
Transaction,
@@ -23,422 +24,427 @@ import { rebuildClientAssociationsFromClient } from "./rebuildClientAssociations
import { OlmErrorCodes } from "@server/routers/olm/error";
import { tierMatrix } from "./billing/tierMatrix";
export async function calculateUserClientsForOrgs(
type ClientRow = typeof clients.$inferSelect;
function runQueuedClientAssociationRebuilds(
userId: string,
trx?: Transaction
): Promise<void> {
const execute = async (transaction: Transaction) => {
const orgCache = new Map<string, typeof orgs.$inferSelect | null>();
const adminRoleCache = new Map<
string,
typeof roles.$inferSelect | null
>();
const exitNodesCache = new Map<
string,
Awaited<ReturnType<typeof listExitNodes>>
>();
const isOrgLicensedCache = new Map<string, boolean>();
const existingClientCache = new Map<
string,
typeof clients.$inferSelect | null
>();
const roleClientAccessCache = new Map<string, boolean>();
const userClientAccessCache = new Map<string, boolean>();
queuedClients: ClientRow[]
) {
if (queuedClients.length === 0) {
return;
}
const getOrgOlmKey = (orgId: string, olmId: string) =>
`${orgId}:${olmId}`;
const getRoleClientKey = (roleId: number, clientId: number) =>
`${roleId}:${clientId}`;
const getUserClientKey = (cachedUserId: string, clientId: number) =>
`${cachedUserId}:${clientId}`;
const uniqueClientsById = new Map<number, ClientRow>();
for (const client of queuedClients) {
uniqueClientsById.set(client.clientId, client);
}
const getOrg = async (orgId: string) => {
if (orgCache.has(orgId)) {
return orgCache.get(orgId) ?? null;
}
const [org] = await transaction
.select()
.from(orgs)
.where(eq(orgs.orgId, orgId));
orgCache.set(orgId, org ?? null);
return org ?? null;
};
const getAdminRole = async (orgId: string) => {
if (adminRoleCache.has(orgId)) {
return adminRoleCache.get(orgId) ?? null;
}
const [adminRole] = await transaction
.select()
.from(roles)
.where(and(eq(roles.isAdmin, true), eq(roles.orgId, orgId)))
.limit(1);
adminRoleCache.set(orgId, adminRole ?? null);
return adminRole ?? null;
};
const getExitNodes = async (orgId: string) => {
if (exitNodesCache.has(orgId)) {
return exitNodesCache.get(orgId)!;
}
const exitNodes = await listExitNodes(orgId);
exitNodesCache.set(orgId, exitNodes);
return exitNodes;
};
const getIsOrgLicensed = async (orgId: string) => {
if (isOrgLicensedCache.has(orgId)) {
return isOrgLicensedCache.get(orgId)!;
}
const isOrgLicensed = await isLicensedOrSubscribed(
orgId,
tierMatrix.deviceApprovals
for (const client of uniqueClientsById.values()) {
rebuildClientAssociationsFromClient(client).catch((error) => {
logger.error(
`Error rebuilding client associations for client ${client.clientId} (user ${userId}): ${String(
error
)}`
);
isOrgLicensedCache.set(orgId, isOrgLicensed);
return isOrgLicensed;
};
const getExistingClient = async (orgId: string, olmId: string) => {
const key = getOrgOlmKey(orgId, olmId);
if (existingClientCache.has(key)) {
return existingClientCache.get(key) ?? null;
}
const [existingClient] = await transaction
.select()
.from(clients)
.where(
and(
eq(clients.userId, userId),
eq(clients.orgId, orgId),
eq(clients.olmId, olmId)
)
)
.limit(1);
existingClientCache.set(key, existingClient ?? null);
return existingClient ?? null;
};
const hasRoleClientAccess = async (
roleId: number,
clientId: number
) => {
const key = getRoleClientKey(roleId, clientId);
if (roleClientAccessCache.has(key)) {
return roleClientAccessCache.get(key)!;
}
const [existingRoleClient] = await transaction
.select()
.from(roleClients)
.where(
and(
eq(roleClients.roleId, roleId),
eq(roleClients.clientId, clientId)
)
)
.limit(1);
const hasAccess = Boolean(existingRoleClient);
roleClientAccessCache.set(key, hasAccess);
return hasAccess;
};
const hasUserClientAccess = async (
cachedUserId: string,
clientId: number
) => {
const key = getUserClientKey(cachedUserId, clientId);
if (userClientAccessCache.has(key)) {
return userClientAccessCache.get(key)!;
}
const [existingUserClient] = await transaction
.select()
.from(userClients)
.where(
and(
eq(userClients.userId, cachedUserId),
eq(userClients.clientId, clientId)
)
)
.limit(1);
const hasAccess = Boolean(existingUserClient);
userClientAccessCache.set(key, hasAccess);
return hasAccess;
};
// Get all OLMs for this user
const userOlms = await transaction
.select()
.from(olms)
.where(eq(olms.userId, userId));
if (userOlms.length === 0) {
// No OLMs for this user, but we should still clean up any orphaned clients
await cleanupOrphanedClients(userId, transaction);
return;
}
// Get all user orgs with all roles (for org list and role-based logic)
const userOrgRoleRows = await transaction
.select()
.from(userOrgs)
.innerJoin(
userOrgRoles,
and(
eq(userOrgs.userId, userOrgRoles.userId),
eq(userOrgs.orgId, userOrgRoles.orgId)
)
)
.innerJoin(roles, eq(userOrgRoles.roleId, roles.roleId))
.where(eq(userOrgs.userId, userId));
const userOrgIds = [
...new Set(userOrgRoleRows.map((r) => r.userOrgs.orgId))
];
const orgIdToRoleRows = new Map<
string,
(typeof userOrgRoleRows)[0][]
>();
for (const r of userOrgRoleRows) {
const list = orgIdToRoleRows.get(r.userOrgs.orgId) ?? [];
list.push(r);
orgIdToRoleRows.set(r.userOrgs.orgId, list);
}
const orgRequiresDeviceApprovalRole = new Map<string, boolean>();
for (const [orgId, roleRowsForOrg] of orgIdToRoleRows.entries()) {
orgRequiresDeviceApprovalRole.set(
orgId,
roleRowsForOrg.some((r) => r.roles.requireDeviceApproval)
);
}
// For each OLM, ensure there's a client in each org the user is in
for (const olm of userOlms) {
for (const orgId of orgIdToRoleRows.keys()) {
const roleRowsForOrg = orgIdToRoleRows.get(orgId)!;
const userOrg = roleRowsForOrg[0].userOrgs;
const org = await getOrg(orgId);
if (!org) {
logger.warn(
`Skipping org ${orgId} for OLM ${olm.olmId} (user ${userId}): org not found`
);
continue;
}
if (!org.subnet) {
logger.warn(
`Skipping org ${orgId} for OLM ${olm.olmId} (user ${userId}): org has no subnet configured`
);
continue;
}
// Get admin role for this org (needed for access grants)
const adminRole = await getAdminRole(orgId);
if (!adminRole) {
logger.warn(
`Skipping org ${orgId} for OLM ${olm.olmId} (user ${userId}): no admin role found`
);
continue;
}
// Check if a client already exists for this OLM+user+org combination
const existingClient = await getExistingClient(
orgId,
olm.olmId
);
if (existingClient) {
// Ensure admin role has access to the client
const hasRoleAccess = await hasRoleClientAccess(
adminRole.roleId,
existingClient.clientId
);
if (!hasRoleAccess) {
await transaction.insert(roleClients).values({
roleId: adminRole.roleId,
clientId: existingClient.clientId
});
roleClientAccessCache.set(
getRoleClientKey(
adminRole.roleId,
existingClient.clientId
),
true
);
logger.debug(
`Granted admin role access to existing client ${existingClient.clientId} for OLM ${olm.olmId} in org ${orgId} (user ${userId})`
);
}
// Ensure user has access to the client
const hasUserAccess = await hasUserClientAccess(
userId,
existingClient.clientId
);
if (!hasUserAccess) {
await transaction.insert(userClients).values({
userId,
clientId: existingClient.clientId
});
userClientAccessCache.set(
getUserClientKey(userId, existingClient.clientId),
true
);
logger.debug(
`Granted user access to existing client ${existingClient.clientId} for OLM ${olm.olmId} in org ${orgId} (user ${userId})`
);
}
logger.debug(
`Client already exists for OLM ${olm.olmId} in org ${orgId} (user ${userId}), skipping creation`
);
continue;
}
// Get exit nodes for this org
const exitNodesList = await getExitNodes(orgId);
if (exitNodesList.length === 0) {
logger.warn(
`Skipping org ${orgId} for OLM ${olm.olmId} (user ${userId}): no exit nodes found`
);
continue;
}
const randomExitNode =
exitNodesList[
Math.floor(Math.random() * exitNodesList.length)
];
// Get next available subnet
const newSubnet = await getNextAvailableClientSubnet(
orgId,
transaction
);
if (!newSubnet) {
logger.warn(
`Skipping org ${orgId} for OLM ${olm.olmId} (user ${userId}): no available subnet found`
);
continue;
}
const subnet = newSubnet.split("/")[0];
const updatedSubnet = `${subnet}/${org.subnet.split("/")[1]}`;
const niceId = await getUniqueClientName(orgId);
const isOrgLicensed = await getIsOrgLicensed(userOrg.orgId);
const requireApproval =
build !== "oss" &&
isOrgLicensed &&
orgRequiresDeviceApprovalRole.get(orgId) === true;
const newClientData: InferInsertModel<typeof clients> = {
userId,
orgId: userOrg.orgId,
exitNodeId: randomExitNode.exitNodeId,
name: olm.name || "User Client",
subnet: updatedSubnet,
olmId: olm.olmId,
type: "olm",
niceId,
approvalState: requireApproval ? "pending" : null
};
// Create the client
const [newClient] = await transaction
.insert(clients)
.values(newClientData)
.returning();
existingClientCache.set(
getOrgOlmKey(orgId, olm.olmId),
newClient
);
// create approval request
if (requireApproval) {
await transaction
.insert(approvals)
.values({
timestamp: Math.floor(new Date().getTime() / 1000),
orgId: userOrg.orgId,
clientId: newClient.clientId,
userId,
type: "user_device"
})
.returning();
}
await rebuildClientAssociationsFromClient(
newClient,
transaction
);
// Grant admin role access to the client
await transaction.insert(roleClients).values({
roleId: adminRole.roleId,
clientId: newClient.clientId
});
roleClientAccessCache.set(
getRoleClientKey(adminRole.roleId, newClient.clientId),
true
);
// Grant user access to the client
await transaction.insert(userClients).values({
userId,
clientId: newClient.clientId
});
userClientAccessCache.set(
getUserClientKey(userId, newClient.clientId),
true
);
logger.debug(
`Created client for OLM ${olm.olmId} in org ${orgId} (user ${userId}) with access granted to admin role and user`
);
}
}
// Clean up clients in orgs the user is no longer in
await cleanupOrphanedClients(userId, transaction, userOrgIds);
};
if (trx) {
// Use provided transaction
await execute(trx);
} else {
// Create new transaction
await db.transaction(async (transaction) => {
await execute(transaction);
});
}
logger.debug(
`Queued association rebuild completed for ${uniqueClientsById.size} client(s) (user ${userId})`
);
}
export async function calculateUserClientsForOrgs(
userId: string
): Promise<void> {
const trx = primaryDb;
const queuedAssociationRebuilds: ClientRow[] = [];
const orgCache = new Map<string, typeof orgs.$inferSelect | null>();
const adminRoleCache = new Map<string, typeof roles.$inferSelect | null>();
const exitNodesCache = new Map<
string,
Awaited<ReturnType<typeof listExitNodes>>
>();
const isOrgLicensedCache = new Map<string, boolean>();
const existingClientCache = new Map<
string,
typeof clients.$inferSelect | null
>();
const roleClientAccessCache = new Map<string, boolean>();
const userClientAccessCache = new Map<string, boolean>();
const getOrgOlmKey = (orgId: string, olmId: string) => `${orgId}:${olmId}`;
const getRoleClientKey = (roleId: number, clientId: number) =>
`${roleId}:${clientId}`;
const getUserClientKey = (cachedUserId: string, clientId: number) =>
`${cachedUserId}:${clientId}`;
const getOrg = async (orgId: string) => {
if (orgCache.has(orgId)) {
return orgCache.get(orgId) ?? null;
}
const [org] = await trx
.select()
.from(orgs)
.where(eq(orgs.orgId, orgId));
orgCache.set(orgId, org ?? null);
return org ?? null;
};
const getAdminRole = async (orgId: string) => {
if (adminRoleCache.has(orgId)) {
return adminRoleCache.get(orgId) ?? null;
}
const [adminRole] = await trx
.select()
.from(roles)
.where(and(eq(roles.isAdmin, true), eq(roles.orgId, orgId)))
.limit(1);
adminRoleCache.set(orgId, adminRole ?? null);
return adminRole ?? null;
};
const getExitNodes = async (orgId: string) => {
if (exitNodesCache.has(orgId)) {
return exitNodesCache.get(orgId)!;
}
const exitNodes = await listExitNodes(orgId);
exitNodesCache.set(orgId, exitNodes);
return exitNodes;
};
const getIsOrgLicensed = async (orgId: string) => {
if (isOrgLicensedCache.has(orgId)) {
return isOrgLicensedCache.get(orgId)!;
}
const isOrgLicensed = await isLicensedOrSubscribed(
orgId,
tierMatrix.deviceApprovals
);
isOrgLicensedCache.set(orgId, isOrgLicensed);
return isOrgLicensed;
};
const getExistingClient = async (orgId: string, olmId: string) => {
const key = getOrgOlmKey(orgId, olmId);
if (existingClientCache.has(key)) {
return existingClientCache.get(key) ?? null;
}
const [existingClient] = await trx
.select()
.from(clients)
.where(
and(
eq(clients.userId, userId),
eq(clients.orgId, orgId),
eq(clients.olmId, olmId)
)
)
.limit(1);
existingClientCache.set(key, existingClient ?? null);
return existingClient ?? null;
};
const hasRoleClientAccess = async (roleId: number, clientId: number) => {
const key = getRoleClientKey(roleId, clientId);
if (roleClientAccessCache.has(key)) {
return roleClientAccessCache.get(key)!;
}
const [existingRoleClient] = await trx
.select()
.from(roleClients)
.where(
and(
eq(roleClients.roleId, roleId),
eq(roleClients.clientId, clientId)
)
)
.limit(1);
const hasAccess = Boolean(existingRoleClient);
roleClientAccessCache.set(key, hasAccess);
return hasAccess;
};
const hasUserClientAccess = async (
cachedUserId: string,
clientId: number
) => {
const key = getUserClientKey(cachedUserId, clientId);
if (userClientAccessCache.has(key)) {
return userClientAccessCache.get(key)!;
}
const [existingUserClient] = await trx
.select()
.from(userClients)
.where(
and(
eq(userClients.userId, cachedUserId),
eq(userClients.clientId, clientId)
)
)
.limit(1);
const hasAccess = Boolean(existingUserClient);
userClientAccessCache.set(key, hasAccess);
return hasAccess;
};
// Get all OLMs for this user
const userOlms = await trx
.select()
.from(olms)
.where(eq(olms.userId, userId));
if (userOlms.length === 0) {
// No OLMs for this user, but we should still clean up any orphaned clients
await cleanupOrphanedClients(
userId,
trx,
[],
queuedAssociationRebuilds
);
return;
}
// Get all user orgs with all roles (for org list and role-based logic)
const userOrgRoleRows = await trx
.select()
.from(userOrgs)
.innerJoin(
userOrgRoles,
and(
eq(userOrgs.userId, userOrgRoles.userId),
eq(userOrgs.orgId, userOrgRoles.orgId)
)
)
.innerJoin(roles, eq(userOrgRoles.roleId, roles.roleId))
.where(eq(userOrgs.userId, userId));
const userOrgIds = [
...new Set(userOrgRoleRows.map((r) => r.userOrgs.orgId))
];
const orgIdToRoleRows = new Map<string, (typeof userOrgRoleRows)[0][]>();
for (const r of userOrgRoleRows) {
const list = orgIdToRoleRows.get(r.userOrgs.orgId) ?? [];
list.push(r);
orgIdToRoleRows.set(r.userOrgs.orgId, list);
}
const orgRequiresDeviceApprovalRole = new Map<string, boolean>();
for (const [orgId, roleRowsForOrg] of orgIdToRoleRows.entries()) {
orgRequiresDeviceApprovalRole.set(
orgId,
roleRowsForOrg.some((r) => r.roles.requireDeviceApproval)
);
}
// For each OLM, ensure there's a client in each org the user is in
for (const olm of userOlms) {
for (const orgId of orgIdToRoleRows.keys()) {
const roleRowsForOrg = orgIdToRoleRows.get(orgId)!;
const userOrg = roleRowsForOrg[0].userOrgs;
const org = await getOrg(orgId);
if (!org) {
logger.warn(
`Skipping org ${orgId} for OLM ${olm.olmId} (user ${userId}): org not found`
);
continue;
}
if (!org.subnet) {
logger.warn(
`Skipping org ${orgId} for OLM ${olm.olmId} (user ${userId}): org has no subnet configured`
);
continue;
}
// Get admin role for this org (needed for access grants)
const adminRole = await getAdminRole(orgId);
if (!adminRole) {
logger.warn(
`Skipping org ${orgId} for OLM ${olm.olmId} (user ${userId}): no admin role found`
);
continue;
}
// Check if a client already exists for this OLM+user+org combination
const existingClient = await getExistingClient(orgId, olm.olmId);
if (existingClient) {
// Ensure admin role has access to the client
const hasRoleAccess = await hasRoleClientAccess(
adminRole.roleId,
existingClient.clientId
);
if (!hasRoleAccess) {
await trx.insert(roleClients).values({
roleId: adminRole.roleId,
clientId: existingClient.clientId
});
roleClientAccessCache.set(
getRoleClientKey(
adminRole.roleId,
existingClient.clientId
),
true
);
logger.debug(
`Granted admin role access to existing client ${existingClient.clientId} for OLM ${olm.olmId} in org ${orgId} (user ${userId})`
);
}
// Ensure user has access to the client
const hasUserAccess = await hasUserClientAccess(
userId,
existingClient.clientId
);
if (!hasUserAccess) {
await trx.insert(userClients).values({
userId,
clientId: existingClient.clientId
});
userClientAccessCache.set(
getUserClientKey(userId, existingClient.clientId),
true
);
logger.debug(
`Granted user access to existing client ${existingClient.clientId} for OLM ${olm.olmId} in org ${orgId} (user ${userId})`
);
}
logger.debug(
`Client already exists for OLM ${olm.olmId} in org ${orgId} (user ${userId}), skipping creation`
);
continue;
}
// Get exit nodes for this org
const exitNodesList = await getExitNodes(orgId);
if (exitNodesList.length === 0) {
logger.warn(
`Skipping org ${orgId} for OLM ${olm.olmId} (user ${userId}): no exit nodes found`
);
continue;
}
const randomExitNode =
exitNodesList[Math.floor(Math.random() * exitNodesList.length)];
// Get next available subnet
const { value: newSubnet, release: releaseSubnetLock } =
await getNextAvailableClientSubnet(orgId, trx);
const subnet = newSubnet.split("/")[0];
const updatedSubnet = `${subnet}/${org.subnet.split("/")[1]}`;
const niceId = await getUniqueClientName(orgId);
const isOrgLicensed = await getIsOrgLicensed(userOrg.orgId);
const requireApproval =
build !== "oss" &&
isOrgLicensed &&
orgRequiresDeviceApprovalRole.get(orgId) === true;
const newClientData: InferInsertModel<typeof clients> = {
userId,
orgId: userOrg.orgId,
exitNodeId: randomExitNode.exitNodeId,
name: olm.name || "User Client",
subnet: updatedSubnet,
olmId: olm.olmId,
type: "olm",
niceId,
approvalState: requireApproval ? "pending" : null
};
// Create the client
const [newClient] = await trx
.insert(clients)
.values(newClientData)
.returning();
await releaseSubnetLock();
existingClientCache.set(getOrgOlmKey(orgId, olm.olmId), newClient);
// create approval request
if (requireApproval) {
await trx
.insert(approvals)
.values({
timestamp: Math.floor(new Date().getTime() / 1000),
orgId: userOrg.orgId,
clientId: newClient.clientId,
userId,
type: "user_device"
})
.returning();
}
queuedAssociationRebuilds.push(newClient);
// Grant admin role access to the client
await trx.insert(roleClients).values({
roleId: adminRole.roleId,
clientId: newClient.clientId
});
roleClientAccessCache.set(
getRoleClientKey(adminRole.roleId, newClient.clientId),
true
);
// Grant user access to the client
await trx.insert(userClients).values({
userId,
clientId: newClient.clientId
});
userClientAccessCache.set(
getUserClientKey(userId, newClient.clientId),
true
);
logger.debug(
`Created client for OLM ${olm.olmId} in org ${orgId} (user ${userId}) with access granted to admin role and user`
);
}
}
// Clean up clients in orgs the user is no longer in
await cleanupOrphanedClients(
userId,
trx,
userOrgIds,
queuedAssociationRebuilds
);
runQueuedClientAssociationRebuilds(userId, queuedAssociationRebuilds);
}
async function cleanupOrphanedClients(
userId: string,
trx: Transaction,
userOrgIds: string[] = []
trx: Transaction | typeof db,
userOrgIds: string[] = [],
queuedAssociationRebuilds: ClientRow[] = []
): Promise<void> {
// Find all OLM clients for this user that should be deleted
// If userOrgIds is empty, delete all OLM clients (user has no orgs)
@@ -468,9 +474,9 @@ async function cleanupOrphanedClients(
)
.returning();
// Rebuild associations for each deleted client to clean up related data
// Queue deleted clients for post-trx association cleanup.
for (const deletedClient of deletedClients) {
await rebuildClientAssociationsFromClient(deletedClient, trx);
queuedAssociationRebuilds.push(deletedClient);
if (deletedClient.olmId) {
await sendTerminateClient(
+1 -1
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.3";
export const APP_VERSION = "1.20.0";
export const __FILENAME = fileURLToPath(import.meta.url);
export const __DIRNAME = path.dirname(__FILENAME);
+83
View File
@@ -0,0 +1,83 @@
import logger from "@server/logger";
const MAX_RETRIES = 5;
const BASE_DELAY_MS = 50;
/**
* Detect transient errors that are safe to retry (connection drops, deadlocks,
* serialization failures). PostgreSQL deadlocks (40P01) are always safe to
* retry: the database guarantees exactly one winner per deadlock pair, so the
* loser just needs to try again.
*/
export function isTransientError(error: any): boolean {
if (!error) return false;
const message = (error.message || "").toLowerCase();
const causeMessage = (error.cause?.message || "").toLowerCase();
const code = error.code || error.cause?.code || "";
// Connection timeout / terminated
if (
message.includes("connection timeout") ||
message.includes("connection terminated") ||
message.includes("timeout exceeded when trying to connect") ||
causeMessage.includes("connection terminated unexpectedly") ||
causeMessage.includes("connection timeout")
) {
return true;
}
// PostgreSQL deadlock detected - always safe to retry (one winner guaranteed)
if (code === "40P01" || message.includes("deadlock")) {
return true;
}
// PostgreSQL serialization failure
if (code === "40001") {
return true;
}
// ECONNRESET, ECONNREFUSED, EPIPE, ETIMEDOUT
if (
code === "ECONNRESET" ||
code === "ECONNREFUSED" ||
code === "EPIPE" ||
code === "ETIMEDOUT"
) {
return true;
}
return false;
}
/**
* Simple retry wrapper with exponential backoff for transient errors
* (deadlocks, connection timeouts, unexpected disconnects).
*/
export async function withRetry<T>(
operation: () => Promise<T>,
context: string,
maxRetries: number = MAX_RETRIES,
baseDelayMs: number = BASE_DELAY_MS
): Promise<T> {
let attempt = 0;
while (true) {
try {
return await operation();
} catch (error: any) {
if (isTransientError(error) && attempt < maxRetries) {
attempt++;
const baseDelay = Math.pow(2, attempt - 1) * baseDelayMs;
const jitter = Math.random() * baseDelay;
const delay = baseDelay + jitter;
logger.warn(
`Transient DB error in ${context}, retrying attempt ${attempt}/${maxRetries} after ${delay.toFixed(0)}ms`,
{ code: error?.code ?? error?.cause?.code }
);
await new Promise((resolve) => setTimeout(resolve, delay));
continue;
}
throw error;
}
}
}
+9 -7
View File
@@ -24,7 +24,7 @@ import { deletePeer } from "@server/routers/gerbil/peers";
import { OlmErrorCodes } from "@server/routers/olm/error";
import { sendTerminateClient } from "@server/routers/client/terminate";
import { usageService } from "./billing/usageService";
import { FeatureId } from "./billing";
import { LimitId } from "./billing";
export type DeleteOrgByIdResult = {
deletedNewtIds: string[];
@@ -140,7 +140,9 @@ export async function deleteOrgById(
.select({ count: count() })
.from(orgDomains)
.where(eq(orgDomains.domainId, domainId));
logger.info(`Found ${orgCount.count} orgs using domain ${domainId}`);
logger.info(
`Found ${orgCount.count} orgs using domain ${domainId}`
);
if (orgCount.count === 1) {
domainIdsToDelete.push(domainId);
}
@@ -152,7 +154,7 @@ export async function deleteOrgById(
.where(inArray(domains.domainId, domainIdsToDelete));
}
await usageService.add(orgId, FeatureId.ORGINIZATIONS, -1, trx); // here we are decreasing the org count BEFORE deleting the org because we need to still be able to get the org to get the billing org inside of here
await usageService.add(orgId, LimitId.ORGANIZATIONS, -1, trx); // here we are decreasing the org count BEFORE deleting the org because we need to still be able to get the org to get the billing org inside of here
await trx.delete(orgs).where(eq(orgs.orgId, orgId));
@@ -199,22 +201,22 @@ export async function deleteOrgById(
if (org.billingOrgId) {
usageService.updateCount(
org.billingOrgId,
FeatureId.DOMAINS,
LimitId.DOMAINS,
domainCount ?? 0
);
usageService.updateCount(
org.billingOrgId,
FeatureId.SITES,
LimitId.SITES,
siteCount ?? 0
);
usageService.updateCount(
org.billingOrgId,
FeatureId.USERS,
LimitId.USERS,
userCount ?? 0
);
usageService.updateCount(
org.billingOrgId,
FeatureId.REMOTE_EXIT_NODES,
LimitId.REMOTE_EXIT_NODES,
remoteExitNodeCount ?? 0
);
}
+144
View File
@@ -0,0 +1,144 @@
import { eq, inArray } from "drizzle-orm";
import {
db,
newts,
resourcePolicies,
resources,
sites,
targetHealthCheck,
targets,
type Resource,
type Target,
type TargetHealthCheck,
type Transaction
} from "@server/db";
import logger from "@server/logger";
import { removeTargets } from "@server/routers/newt/targets";
import createHttpError from "http-errors";
import HttpCode from "@server/types/HttpCode";
export type DeleteResourceResult = {
deletedResource: Resource;
targetsToBeRemoved: Target[];
healthChecksToBeRemoved: TargetHealthCheck[];
};
export async function performDeleteResources(
resourceIds: number[],
trx: Transaction | typeof db = db
): Promise<DeleteResourceResult[]> {
if (resourceIds.length === 0) {
return [];
}
const targetsToBeRemoved = await trx
.select()
.from(targets)
.where(inArray(targets.resourceId, resourceIds));
const targetIds = targetsToBeRemoved.map((t) => t.targetId);
const healthChecksToBeRemoved =
targetIds.length > 0
? await trx
.select()
.from(targetHealthCheck)
.where(inArray(targetHealthCheck.targetId, targetIds))
: [];
const deletedResources = await trx
.delete(resources)
.where(inArray(resources.resourceId, resourceIds))
.returning();
const policyIds = deletedResources
.map((resource) => resource.defaultResourcePolicyId)
.filter((id): id is number => id != null);
if (policyIds.length > 0) {
await trx
.delete(resourcePolicies)
.where(inArray(resourcePolicies.resourcePolicyId, policyIds));
}
if (deletedResources.length > 0) {
logger.debug(`Deleted ${deletedResources.length} resources`);
}
const targetsByResourceId = new Map<number, Target[]>();
for (const target of targetsToBeRemoved) {
const existing = targetsByResourceId.get(target.resourceId) ?? [];
existing.push(target);
targetsByResourceId.set(target.resourceId, existing);
}
const targetIdToResourceId = new Map(
targetsToBeRemoved.map((target) => [target.targetId, target.resourceId])
);
const healthChecksByResourceId = new Map<number, TargetHealthCheck[]>();
for (const healthCheck of healthChecksToBeRemoved) {
const resourceId = targetIdToResourceId.get(healthCheck.targetId!);
if (resourceId == null) {
continue;
}
const existing = healthChecksByResourceId.get(resourceId) ?? [];
existing.push(healthCheck);
healthChecksByResourceId.set(resourceId, existing);
}
return deletedResources.map((deletedResource) => ({
deletedResource,
targetsToBeRemoved:
targetsByResourceId.get(deletedResource.resourceId) ?? [],
healthChecksToBeRemoved:
healthChecksByResourceId.get(deletedResource.resourceId) ?? []
}));
}
export async function performDeleteResource(
resourceId: number,
trx: Transaction | typeof db = db
): Promise<DeleteResourceResult | null> {
const [result] = await performDeleteResources([resourceId], trx);
return result ?? null;
}
export async function runResourceDeleteSideEffects(
result: DeleteResourceResult
): Promise<void> {
const { deletedResource, targetsToBeRemoved, healthChecksToBeRemoved } =
result;
for (const target of targetsToBeRemoved) {
const [site] = await db
.select()
.from(sites)
.where(eq(sites.siteId, target.siteId))
.limit(1);
if (!site) {
throw createHttpError(
HttpCode.NOT_FOUND,
`Site with ID ${target.siteId} not found`
);
}
if (site.pubKey && site.type === "newt") {
const [newt] = await db
.select()
.from(newts)
.where(eq(newts.siteId, site.siteId))
.limit(1);
if (newt) {
await removeTargets(
newt.newtId,
[],
healthChecksToBeRemoved,
deletedResource.mode === "udp" ? "udp" : "tcp",
newt.version
);
}
}
}
}
+126
View File
@@ -0,0 +1,126 @@
import { and, eq, sql } from "drizzle-orm";
import {
db,
siteNetworks,
siteResources,
targets,
type SiteResource,
type Transaction
} from "@server/db";
import {
performDeleteResources,
runResourceDeleteSideEffects,
type DeleteResourceResult
} from "@server/lib/deleteResource";
import {
performDeleteSiteResources,
runSiteResourceDeleteSideEffects
} from "@server/lib/deleteSiteResource";
import logger from "@server/logger";
export const MAX_SITE_ASSOCIATED_RESOURCES_FOR_BULK_DELETE = 250;
export type DeleteSiteAssociatedResourcesSideEffects = {
resources: DeleteResourceResult[];
siteResources: SiteResource[];
};
export async function getResourceIdsForSite(
siteId: number,
trx: Transaction | typeof db = db
): Promise<number[]> {
const rows = await trx
.selectDistinct({ resourceId: targets.resourceId })
.from(targets)
.where(eq(targets.siteId, siteId));
return rows.map((row) => row.resourceId);
}
export async function getSiteResourceIdsForSite(
siteId: number,
orgId: string,
trx: Transaction | typeof db = db
): Promise<number[]> {
const rows = await trx
.selectDistinct({ siteResourceId: siteResources.siteResourceId })
.from(siteNetworks)
.innerJoin(
siteResources,
eq(siteResources.networkId, siteNetworks.networkId)
)
.where(
and(eq(siteNetworks.siteId, siteId), eq(siteResources.orgId, orgId))
);
return rows.map((row) => row.siteResourceId);
}
export async function getAssociatedResourceCountForSite(
siteId: number,
orgId: string,
trx: Transaction | typeof db = db
): Promise<number> {
const [publicCountResult, privateCountResult] = await Promise.all([
trx
.select({
count: sql<number>`count(distinct ${targets.resourceId})`
})
.from(targets)
.where(eq(targets.siteId, siteId)),
trx
.select({
count: sql<number>`count(distinct ${siteResources.siteResourceId})`
})
.from(siteNetworks)
.innerJoin(
siteResources,
eq(siteResources.networkId, siteNetworks.networkId)
)
.where(
and(
eq(siteNetworks.siteId, siteId),
eq(siteResources.orgId, orgId)
)
)
]);
return (
Number(publicCountResult[0]?.count ?? 0) +
Number(privateCountResult[0]?.count ?? 0)
);
}
export function exceedsSiteAssociatedResourceDeleteLimit(
resourceCount: number
): boolean {
return resourceCount > MAX_SITE_ASSOCIATED_RESOURCES_FOR_BULK_DELETE;
}
export async function deleteAssociatedResourcesForSite(
siteId: number,
orgId: string,
trx: Transaction | typeof db = db
): Promise<DeleteSiteAssociatedResourcesSideEffects> {
const resourceIds = await getResourceIdsForSite(siteId, trx);
const siteResourceIds = await getSiteResourceIdsForSite(siteId, orgId, trx);
const [resources, siteResourcesDeleted] = await Promise.all([
performDeleteResources(resourceIds, trx),
performDeleteSiteResources(siteResourceIds, trx)
]);
return { resources, siteResources: siteResourcesDeleted };
}
export async function runDeleteSiteAssociatedResourcesSideEffects(
sideEffects: DeleteSiteAssociatedResourcesSideEffects
): Promise<void> {
for (const result of sideEffects.resources) {
await runResourceDeleteSideEffects(result);
}
for (const removed of sideEffects.siteResources) {
runSiteResourceDeleteSideEffects(removed);
}
}
+53
View File
@@ -0,0 +1,53 @@
import { inArray } from "drizzle-orm";
import {
db,
siteResources,
type SiteResource,
type Transaction
} from "@server/db";
import logger from "@server/logger";
import { rebuildClientAssociationsFromSiteResource } from "@server/lib/rebuildClientAssociations";
export async function performDeleteSiteResources(
siteResourceIds: number[],
trx: Transaction | typeof db = db
): Promise<SiteResource[]> {
if (siteResourceIds.length === 0) {
return [];
}
const removedSiteResources = await trx
.delete(siteResources)
.where(inArray(siteResources.siteResourceId, siteResourceIds))
.returning();
if (removedSiteResources.length > 0) {
logger.debug(`Deleted ${removedSiteResources.length} site resources`);
}
return removedSiteResources;
}
export async function performDeleteSiteResource(
siteResourceId: number,
trx: Transaction | typeof db = db
): Promise<SiteResource | null> {
const [removedSiteResource] = await performDeleteSiteResources(
[siteResourceId],
trx
);
return removedSiteResource ?? null;
}
export function runSiteResourceDeleteSideEffects(
removedSiteResource: SiteResource
): void {
rebuildClientAssociationsFromSiteResource(removedSiteResource).catch(
(err) => {
logger.error(
`Error rebuilding client associations for site resource ${removedSiteResource.siteResourceId}:`,
err
);
}
);
}
+5 -1
View File
@@ -19,7 +19,11 @@ export async function verifyExitNodeOrgAccess(
export async function listExitNodes(
orgId: string,
filterOnline = false,
noCloud = false
noCloud = false,
// Accepted for parity with the enterprise implementation (used there for
// site-label filtering of remote exit nodes). The OSS build has no remote
// exit nodes, so it is unused here.
siteId?: number
) {
// TODO: pick which nodes to send and ping better than just all of them that are not remote
const allExitNodes = await db
+49 -24
View File
@@ -1,30 +1,55 @@
import { db, exitNodes } from "@server/db";
import { db, exitNodes, Transaction } from "@server/db";
import config from "@server/lib/config";
import { findNextAvailableCidr } from "@server/lib/ip";
import { lockManager } from "#dynamic/lib/lock";
export async function getNextAvailableSubnet(): Promise<string> {
// Get all existing subnets from routes table
const existingAddresses = await db
.select({
address: exitNodes.address
})
.from(exitNodes);
const addresses = existingAddresses.map((a) => a.address);
let subnet = findNextAvailableCidr(
addresses,
config.getRawConfig().gerbil.block_size,
config.getRawConfig().gerbil.subnet_group
);
if (!subnet) {
throw new Error("No available subnets remaining in space");
/**
* Reserves the next available exit node subnet.
*
* Exit node subnets must never overlap with one another - regardless of
* which org(s) they belong to - since HA exit nodes can end up routing for
* the same org. This acquires a lock that the caller MUST release (via the
* returned `release`) only after the chosen address has been durably
* persisted (e.g. after the enclosing transaction commits), otherwise
* concurrent callers can race and pick the same subnet.
*/
export async function getNextAvailableSubnet(
trx: Transaction | typeof db = db
): Promise<{ value: string; release: () => Promise<void> }> {
const lockKey = "exit-node-subnet-allocation";
const acquired = await lockManager.acquireLockWithRetry(lockKey, 6000);
if (!acquired) {
throw new Error(`Failed to acquire lock: ${lockKey}`);
}
const release = () => lockManager.releaseLock(lockKey, acquired);
// replace the last octet with 1
subnet =
subnet.split(".").slice(0, 3).join(".") +
".1" +
"/" +
subnet.split("/")[1];
return subnet;
try {
// Get all existing subnets from routes table
const existingAddresses = await trx
.select({
address: exitNodes.address
})
.from(exitNodes);
const addresses = existingAddresses.map((a) => a.address);
let subnet = findNextAvailableCidr(
addresses,
config.getRawConfig().gerbil.block_size,
config.getRawConfig().gerbil.subnet_group
);
if (!subnet) {
throw new Error("No available subnets remaining in space");
}
// replace the last octet with 1
subnet =
subnet.split(".").slice(0, 3).join(".") +
".1" +
"/" +
subnet.split("/")[1];
return { value: subnet, release };
} catch (e) {
await release();
throw e;
}
}
+161 -123
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, acquired);
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, acquired);
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, acquired);
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,13 +496,15 @@ 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()]);
const parseResult = cidrSchema.safeParse(sr.destination);
return parseResult.success;
}
if (sr.mode === "host") {
if (sr.mode === "host" || sr.mode === "ssh") {
// check if its a valid IP using zod
const ipSchema = z.union([z.ipv4(), z.ipv6()]);
const parseResult = ipSchema.safeParse(sr.destination);
@@ -491,12 +514,12 @@ export function generateRemoteSubnets(
})
.map((sr) => {
if (sr.mode === "cidr") return sr.destination;
if (sr.mode === "host") {
if (sr.mode === "host" || sr.mode === "ssh") {
return `${sr.destination}/32`;
}
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));
}
@@ -508,7 +531,7 @@ export function generateAliasConfig(allSiteResources: SiteResource[]): Alias[] {
.filter(
(sr) =>
sr.aliasAddress &&
((sr.alias && sr.mode == "host") ||
((sr.alias && (sr.mode == "host" || sr.mode == "ssh")) ||
(sr.fullDomain && sr.mode == "http"))
)
.map((sr) => ({
@@ -554,6 +577,10 @@ export function generateSubnetProxyTargets(
continue;
}
if (!siteResource.destination) {
continue;
}
const clientPrefix = `${clientSite.subnet.split("/")[0]}/32`;
const portRange = [
...parsePortRangeString(siteResource.tcpPortRangeString, "tcp"),
@@ -561,7 +588,7 @@ export function generateSubnetProxyTargets(
];
const disableIcmp = siteResource.disableIcmp ?? false;
if (siteResource.mode == "host") {
if (siteResource.mode == "host" || siteResource.mode == "ssh") {
let destination = siteResource.destination;
// check if this is a valid ip
const ipSchema = z.union([z.ipv4(), z.ipv6()]);
@@ -581,7 +608,7 @@ export function generateSubnetProxyTargets(
targets.push({
sourcePrefix: clientPrefix,
destPrefix: `${siteResource.aliasAddress}/32`,
rewriteTo: destination,
rewriteTo: destination!,
portRange,
disableIcmp
});
@@ -589,7 +616,7 @@ export function generateSubnetProxyTargets(
} else if (siteResource.mode == "cidr") {
targets.push({
sourcePrefix: clientPrefix,
destPrefix: siteResource.destination,
destPrefix: siteResource.destination!,
portRange,
disableIcmp
});
@@ -642,7 +669,12 @@ export async function generateSubnetProxyTargetV2(
return;
}
let targets: SubnetProxyTargetV2[] = [];
if (!siteResource.destination) {
// ssh can have no destination
return;
}
const targets: SubnetProxyTargetV2[] = [];
const portRange = [
...parsePortRangeString(siteResource.tcpPortRangeString, "tcp"),
@@ -650,7 +682,7 @@ export async function generateSubnetProxyTargetV2(
];
const disableIcmp = siteResource.disableIcmp ?? false;
if (siteResource.mode == "host") {
if (siteResource.mode == "host" || siteResource.mode == "ssh") {
let destination = siteResource.destination;
// check if this is a valid ip
const ipSchema = z.union([z.ipv4(), z.ipv6()]);
@@ -671,7 +703,7 @@ export async function generateSubnetProxyTargetV2(
targets.push({
sourcePrefixes: [],
destPrefix: `${siteResource.aliasAddress}/32`,
rewriteTo: destination,
rewriteTo: destination!,
portRange,
disableIcmp,
resourceId: siteResource.siteResourceId
@@ -680,7 +712,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 +770,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 +905,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
+144 -24
View File
@@ -1,28 +1,87 @@
import { randomUUID } from "crypto";
const instanceId = `local-${Math.random().toString(36).slice(2)}-${Date.now()}`;
type LocalLockRecord = {
owner: string;
expiresAt: number;
};
const localLocks = new Map<string, LocalLockRecord>();
export class LockManager {
/**
* Acquire a distributed lock using Redis SET with NX and PX options
* @param lockKey - Unique identifier for the lock
* @param ttlMs - Time to live in milliseconds
* @returns Promise<boolean> - true if lock acquired, false otherwise
*/
async acquireLock(
lockKey: string,
ttlMs: number = 30000
): Promise<boolean> {
return true;
private clearExpiredLocalLock(lockKey: string): void {
const current = localLocks.get(lockKey);
if (current && current.expiresAt <= Date.now()) {
localLocks.delete(lockKey);
}
}
/**
* Release a lock using Lua script to ensure atomicity
* Acquire a local in-process lock using an optimistic Map-based check.
* @param lockKey - Unique identifier for the lock
* @param ttlMs - Time to live in milliseconds
* @returns Promise<string | null> - a token identifying this specific acquisition
* (truthy) on success, or null if the lock could not be acquired.
*/
async releaseLock(lockKey: string): Promise<void> {}
async acquireLock(
lockKey: string,
ttlMs: number = 30000,
maxRetries: number = 3,
retryDelayMs: number = 100
): Promise<string | null> {
for (let attempt = 0; attempt < maxRetries; attempt++) {
this.clearExpiredLocalLock(lockKey);
const existing = localLocks.get(lockKey);
if (!existing) {
const token = `${instanceId}:${randomUUID()}`;
localLocks.set(lockKey, {
owner: token,
expiresAt: Date.now() + ttlMs
});
return token;
}
// The lock is currently held -- possibly by a different, unrelated
// caller in this same process. We intentionally do NOT treat
// same-process holders as automatically reentrant here: two
// independent logical operations (e.g. two different API requests)
// running concurrently in the same process must not both believe
// they hold the lock, or their writes under it can interleave
// unguarded. Just retry with backoff like any other contended lock.
if (attempt < maxRetries - 1) {
const delay = retryDelayMs * Math.pow(2, attempt);
await new Promise((resolve) => setTimeout(resolve, delay));
}
}
return null;
}
/**
* Release a lock previously acquired via acquireLock/acquireLockWithRetry.
* @param lockKey - Unique identifier for the lock
* @param token - the exact token returned by the acquisition being released.
* Required so a caller whose TTL already expired can't delete a
* different, currently-active holder's lock.
*/
async releaseLock(lockKey: string, token: string): Promise<void> {
this.clearExpiredLocalLock(lockKey);
const existing = localLocks.get(lockKey);
if (existing && existing.owner === token) {
localLocks.delete(lockKey);
}
}
/**
* Force release a lock regardless of owner (use with caution)
* @param lockKey - Unique identifier for the lock
*/
async forceReleaseLock(lockKey: string): Promise<void> {}
async forceReleaseLock(lockKey: string): Promise<void> {
localLocks.delete(lockKey);
}
/**
* Check if a lock exists and get its info
@@ -35,16 +94,44 @@ export class LockManager {
ttl: number;
owner?: string;
}> {
return { exists: true, ownedByMe: true, ttl: 0 };
this.clearExpiredLocalLock(lockKey);
const existing = localLocks.get(lockKey);
if (!existing) {
return { exists: false, ownedByMe: false, ttl: 0 };
}
const ttl = Math.max(0, existing.expiresAt - Date.now());
return {
exists: true,
ownedByMe: existing.owner.startsWith(`${instanceId}:`),
ttl,
owner: existing.owner.split(":")[0]
};
}
/**
* Extend the TTL of an existing lock owned by this worker
* Extend the TTL of an existing lock, provided the token matches the
* acquisition currently holding it.
* @param lockKey - Unique identifier for the lock
* @param ttlMs - New TTL in milliseconds
* @param token - the token returned by the acquisition being extended
* @returns Promise<boolean> - true if extended successfully
*/
async extendLock(lockKey: string, ttlMs: number): Promise<boolean> {
async extendLock(
lockKey: string,
ttlMs: number,
token: string
): Promise<boolean> {
this.clearExpiredLocalLock(lockKey);
const existing = localLocks.get(lockKey);
if (!existing || existing.owner !== token) {
return false;
}
existing.expiresAt = Date.now() + ttlMs;
localLocks.set(lockKey, existing);
return true;
}
@@ -54,15 +141,34 @@ export class LockManager {
* @param ttlMs - Time to live in milliseconds
* @param maxRetries - Maximum number of retry attempts
* @param baseDelayMs - Base delay between retries in milliseconds
* @returns Promise<boolean> - true if lock acquired
* @returns Promise<string | null> - token if acquired, null otherwise
*/
async acquireLockWithRetry(
lockKey: string,
ttlMs: number = 30000,
maxRetries: number = 5,
baseDelayMs: number = 100
): Promise<boolean> {
return true;
): Promise<string | null> {
for (let attempt = 0; attempt <= maxRetries; attempt++) {
const acquired = await this.acquireLock(
lockKey,
ttlMs,
1,
baseDelayMs
);
if (acquired) {
return acquired;
}
if (attempt < maxRetries) {
const delay =
baseDelayMs * Math.pow(2, attempt) + Math.random() * 100;
await new Promise((resolve) => setTimeout(resolve, delay));
}
}
return null;
}
/**
@@ -77,16 +183,16 @@ export class LockManager {
fn: () => Promise<T>,
ttlMs: number = 30000
): Promise<T> {
const acquired = await this.acquireLock(lockKey, ttlMs);
const token = await this.acquireLock(lockKey, ttlMs);
if (!acquired) {
if (!token) {
throw new Error(`Failed to acquire lock: ${lockKey}`);
}
try {
return await fn();
} finally {
await this.releaseLock(lockKey);
await this.releaseLock(lockKey, token);
}
}
@@ -99,7 +205,21 @@ export class LockManager {
activeLocksCount: number;
locksOwnedByMe: number;
}> {
return { activeLocksCount: 0, locksOwnedByMe: 0 };
const now = Date.now();
for (const [key, value] of localLocks.entries()) {
if (value.expiresAt <= now) {
localLocks.delete(key);
}
}
let locksOwnedByMe = 0;
for (const value of localLocks.values()) {
if (value.owner.startsWith(`${instanceId}:`)) {
locksOwnedByMe++;
}
}
return { activeLocksCount: localLocks.size, locksOwnedByMe };
}
/**

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