Compare commits

...

371 Commits

Author SHA1 Message Date
Owen
6df4bba3b6 Bump version 2026-05-29 17:12:26 -07:00
Owen
9f83c0a0e8 Show error right 2026-05-29 17:11:44 -07:00
Owen
0ab1854125 Fix import 2026-05-29 15:38:37 -07:00
Owen
b071fa2c9f Be able to pull users from the proxy 2026-05-29 15:34:34 -07:00
Owen
8e2a79a0f5 Move to private 2026-05-29 15:23:40 -07:00
Owen
71756812b6 Adjusting the ui 2026-05-29 10:57:28 -07:00
Owen
76cd716caa Add user id 2026-05-29 10:57:16 -07:00
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
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
322475fb5c Fix errors 2026-05-28 17:59:21 -07:00
Owen
2f124bffc4 Merge branch 'main' into dev 2026-05-28 17:46:42 -07:00
Owen Schwartz
86367383e7 Merge pull request #3026 from immanuwell/fix-validator-test-exit
fix: make validators test failures exit non-zero
2026-05-28 17:43:43 -07:00
Owen Schwartz
d22ba3566d Merge pull request #2618 from LunarECL/fix-docker-label-partial-validation
Skip invalid Docker resources instead of failing entire blueprint (#1784)
2026-05-28 17:41:15 -07:00
Owen Schwartz
c74b423bae Merge pull request #3119 from Adityakk9031/#3086
Sort resource filter options in audit logs
2026-05-28 15:50:27 -07:00
Owen
f8a757c55f Merge branch 'resource-policies' into dev 2026-05-28 15:30:16 -07:00
Owen
6aea3f1643 Merge branch 'auto-update' into dev 2026-05-28 13:59:34 -07:00
Owen
073dc34522 Merge branch 'rdp-ssh' into dev 2026-05-28 13:59:14 -07:00
Owen
3f5970a1f9 Merge branch 'dev' of github.com:fosrl/pangolin into dev 2026-05-28 13:56:18 -07:00
Owen
e2f2608358 Merge branch 'main' into dev 2026-05-28 13:56:08 -07:00
Owen Schwartz
6d17bb04c4 Merge pull request #3167 from shleeable/patch-1
Installer: format main.go
2026-05-28 12:13:45 -07:00
Owen Schwartz
957e7ba127 Merge pull request #3175 from shleeable/patch-4
Fix:  OLM token rate limit uses wrong field name
2026-05-28 12:13:04 -07:00
Owen Schwartz
def710cba8 Merge pull request #3176 from shleeable/patch-5
Fix: Update external.ts windowMs rate limit for milliseconds
2026-05-28 12:12:39 -07:00
Owen Schwartz
44da854575 Merge pull request #3177 from shleeable/patch-6
Fix: Missing return
2026-05-28 12:11:40 -07:00
miloschwartz
d3d2474855 update blueprints to say blueprints log 2026-05-28 12:11:20 -07:00
Owen Schwartz
d7d37c6f6e Merge pull request #3179 from fosrl/dependabot/npm_and_yarn/dev-minor-updates-545c73ecbb
Bump the dev-minor-updates group across 1 directory with 6 updates
2026-05-28 12:10:40 -07:00
dependabot[bot]
3c80b9a229 Bump the dev-minor-updates group across 1 directory with 6 updates
Bumps the dev-minor-updates group with 6 updates in the / directory:

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



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

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

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

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

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

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

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

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

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



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

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

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

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

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

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

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

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

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

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

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

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

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

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

Adjust description
2026-05-21 16:34:32 -07:00
Fred KISSIE
09cb20a084 push 2026-05-22 00:44:29 +02:00
Owen
6d4afd0953 Control updates from the ui 2026-05-21 15:43:31 -07:00
Owen
d1fb2e19d3 Fix cache import to be dynamic 2026-05-21 14:43:50 -07:00
Owen
dee0ca6864 Add permissions check, shasum check, & build info 2026-05-21 14:34:16 -07:00
Fred KISSIE
2934bbdd20 ♻️ use react query for network logs 2026-05-21 23:27:02 +02:00
Fred KISSIE
2b46e8eaba ♻️ use useQuery for network action logs 2026-05-21 23:23:49 +02:00
Owen
ed73d089d0 Auto update newt 2026-05-21 14:13:32 -07:00
Owen
3b89104a59 Add regional redis cache 2026-05-21 14:07:09 -07:00
Fred KISSIE
5bf8b336c5 ♻️ useQuery for fetching access logs 2026-05-21 23:05:34 +02:00
Fred KISSIE
21a144753d Add access react query 2026-05-21 22:50:15 +02:00
Fred KISSIE
c1b8dfc863 ♻️ refactor 2026-05-21 22:44:24 +02:00
Fred KISSIE
5efcd4479a Merge branch 'dev' into refactor/loading-animation-on-request-logs 2026-05-21 22:31:16 +02:00
Owen
e4e8b33e9f Enforce absolute paths for sudo commands 2026-05-21 12:14:52 -07:00
Owen Schwartz
35ad235f49 Merge pull request #3129 from fosrl/fix-site-delete
Improve delete function speed & order of ops
2026-05-21 12:06:18 -07:00
Owen
834672c846 Improve delete function speed & order of ops 2026-05-21 12:05:16 -07:00
Owen
af13790c93 Fix pasting the device code not working 2026-05-20 16:28:12 -07:00
Owen Schwartz
b8180d848a Merge pull request #3118 from Adityakk9031/#3105
Fix public resource health with unknown WireGuard targets
2026-05-20 16:20:25 -07:00
Owen Schwartz
fef7563e14 Merge pull request #3125 from fosrl/fix-3104
Fix #3104
2026-05-20 16:15:21 -07:00
Owen
6337cf4359 Fix #3104 2026-05-20 16:14:47 -07:00
Owen
87bcd8ec1b Merge branch 'main' into dev 2026-05-20 15:59:01 -07:00
Owen Schwartz
b3cfe82dff Merge pull request #3124 from fosrl/fix-logoUrl
Fix logo url
2026-05-20 14:19:29 -07:00
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
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
dependabot[bot]
e4fd2b656d Bump sigstore/cosign-installer from 4.1.1 to 4.1.2
Bumps [sigstore/cosign-installer](https://github.com/sigstore/cosign-installer) from 4.1.1 to 4.1.2.
- [Release notes](https://github.com/sigstore/cosign-installer/releases)
- [Commits](https://github.com/sigstore/cosign-installer/compare/v4.1.1...6f9f17788090df1f26f669e9d70d6ae9567deba6)

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

Signed-off-by: dependabot[bot] <support@github.com>
2026-05-16 21:55:15 +00:00
Owen
987b5d580e Sure up some things with browserAccessType 2026-05-15 17:26:58 -07:00
Owen
cb75ffc3b7 Adjust page to be editable 2026-05-15 16:46:43 -07:00
Owen
540f0a754d Temp credential storage 2026-05-15 16:11:23 -07:00
Owen
0f9a6fd968 Support private key 2026-05-15 16:07:14 -07:00
Owen
82112abc34 Clean up toasts 2026-05-15 15:01:37 -07:00
Owen
75b5afd544 Add crud for browser targets 2026-05-15 14:09:33 -07:00
Owen
00e1675f7b Remove extra fields 2026-05-15 12:08:40 -07:00
Owen
2ddbdf977b Standardize the ui 2026-05-15 12:06:05 -07:00
Owen
4c8f0cc9ec Pull in the destination from the api 2026-05-15 11:48:13 -07:00
Marc Schäfer
18d380ce30 fix(security): normalize request parameters and update dependencies
Signed-off-by: Marc Schäfer <git@marcschaeferger.de>
2026-05-15 18:35:58 +00:00
Owen
e822b681cd Merge branch 'dev' into rdp-ssh 2026-05-15 11:18:31 -07:00
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
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
e1583a58aa USe right table 2026-05-14 11:33:42 -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
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
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
Fred KISSIE
d321d7275c 🚧 tried to memo proxy resource table, failed 2026-05-11 21:06:20 +02: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
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
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
Fred KISSIE
1831ca4e75 ♻️ detach label from site/resoirce 2026-05-08 00:33:47 +02:00
immanuwell
0611ceb5c3 fix: make validators test failures exit non-zero 2026-05-07 20:13:56 +04:00
Owen
c4b3656fad Update UI to support additions on the resource 2026-05-06 10:09:05 -07:00
Owen
54c1dd3bae Make path the default 2026-05-05 21:05:42 -07:00
Owen
a8f4d2b7d1 Add new user and role selectors for pagination 2026-05-05 20:53:36 -07:00
Owen
51f1693dbd Merge branch 'dev' into resource-policies 2026-05-05 18:02:27 -07:00
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
dependabot[bot]
f286d66cbc Bump actions/setup-node from 6.3.0 to 6.4.0
Bumps [actions/setup-node](https://github.com/actions/setup-node) from 6.3.0 to 6.4.0.
- [Release notes](https://github.com/actions/setup-node/releases)
- [Commits](53b83947a5...48b55a011b)

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

6
.gitignore vendored
View File

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

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 { defineConfig } from "drizzle-kit";
import path from "path"; import path from "path";

View File

@@ -22,7 +22,8 @@ server:
methods: ["GET", "POST", "PUT", "DELETE", "PATCH"] methods: ["GET", "POST", "PUT", "DELETE", "PATCH"]
allowed_headers: ["X-CSRF-Token", "Content-Type"] allowed_headers: ["X-CSRF-Token", "Content-Type"]
credentials: false 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}} {{if .EnableEmail}}
email: email:
smtp_host: "{{.EmailSMTPHost}}" smtp_host: "{{.EmailSMTPHost}}"

View File

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

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/sync v0.15.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI= golang.org/x/sys v0.44.0 h1:ildZl3J4uzeKP07r2F++Op7E9B29JRUy+a27EibtBTQ=
golang.org/x/sys v0.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= golang.org/x/sys v0.44.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/term v0.42.0 h1:UiKe+zDFmJobeJ5ggPwOshJIVt6/Ft0rcfrXZDLWAWY= golang.org/x/term v0.43.0 h1:S4RLU2sB31O/NCl+zFN9Aru9A/Cq2aqKpTZJ6B+DwT4=
golang.org/x/term v0.42.0/go.mod h1:Dq/D+snpsbazcBG5+F9Q1n2rXV8Ma+71xEjTRufARgY= golang.org/x/term v0.43.0/go.mod h1:lrhlHNdQJHO+1qVYiHfFKVuVioJIheAc3fBSMFYEIsk=
golang.org/x/text v0.23.0 h1:D71I7dUrlY+VX0gQShAThNGHFxZ13dGLBHQLVl1mJlY= golang.org/x/text v0.23.0 h1:D71I7dUrlY+VX0gQShAThNGHFxZ13dGLBHQLVl1mJlY=
golang.org/x/text v0.23.0/go.mod h1:/BLNzu4aZCJ1+kcD0DNRotWKage4q2rGVAg4o22unh4= golang.org/x/text v0.23.0/go.mod h1:/BLNzu4aZCJ1+kcD0DNRotWKage4q2rGVAg4o22unh4=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=

View File

@@ -54,7 +54,7 @@ type Config struct {
InstallGerbil bool InstallGerbil bool
TraefikBouncerKey string TraefikBouncerKey string
DoCrowdsecInstall bool DoCrowdsecInstall bool
EnableGeoblocking bool EnableMaxMind bool
Secret string Secret string
IsEnterprise bool IsEnterprise bool
} }
@@ -123,11 +123,11 @@ func main() {
fmt.Println("\nConfiguration files created successfully!") fmt.Println("\nConfiguration files created successfully!")
// Download MaxMind database if requested // Download MaxMind Country / ASN database if requested
if config.EnableGeoblocking { if config.EnableMaxMind {
fmt.Println("\n=== Downloading MaxMind Database ===") fmt.Println("\n=== Downloading MaxMind Country and ASN Databases ===")
if err := downloadMaxMindDatabase(); err != nil { 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.") fmt.Println("You can download it manually later if needed.")
} }
} }
@@ -188,15 +188,15 @@ func main() {
fmt.Println("\n=== MaxMind Database Update ===") fmt.Println("\n=== MaxMind Database Update ===")
if _, err := os.Stat("config/GeoLite2-Country.mmdb"); err == nil { if _, err := os.Stat("config/GeoLite2-Country.mmdb"); err == nil {
fmt.Println("MaxMind GeoLite2 Country database found.") 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 { if err := downloadMaxMindDatabase(); err != nil {
fmt.Printf("Error updating MaxMind database: %v\n", err) fmt.Printf("Error updating MaxMind database: %v\n", err)
fmt.Println("You can try updating it manually later if needed.") fmt.Println("You can try updating it manually later if needed.")
} }
} }
} else { } else {
fmt.Println("MaxMind GeoLite2 Country database not found.") fmt.Println("MaxMind GeoLite2 Country and ASN databases not found.")
if readBool("Would you like to download the MaxMind GeoLite2 database for geoblocking functionality?", false) { if readBool("Would you like to download the MaxMind GeoLite2 databases for blocking functionality?", false) {
if err := downloadMaxMindDatabase(); err != nil { if err := downloadMaxMindDatabase(); err != nil {
fmt.Printf("Error downloading MaxMind database: %v\n", err) fmt.Printf("Error downloading MaxMind database: %v\n", err)
fmt.Println("You can try downloading it manually later if needed.") fmt.Println("You can try downloading it manually later if needed.")
@@ -204,8 +204,10 @@ func main() {
// Now you need to update your config file accordingly to enable geoblocking // 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") 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 // 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_db_path: \"./config/GeoLite2-Country.mmdb\"")
fmt.Println(" maxmind_asn_path: \"./config/GeoLite2-ASN.mmdb\"")
} }
} }
} }
@@ -527,7 +529,7 @@ func collectUserInput() Config {
fmt.Println("\n=== Advanced Configuration ===") fmt.Println("\n=== Advanced Configuration ===")
config.EnableIPv6 = readBool("Is your server IPv6 capable?", true) config.EnableIPv6 = readBool("Is your server IPv6 capable?", true)
config.EnableGeoblocking = readBool("Do you want to download the MaxMind GeoLite2 database for geoblocking functionality?", true) config.EnableMaxMind = readBool("Do you want to download the MaxMind GeoLite2 Country and ADN databases for blocking functionality?", true)
if config.DashboardDomain == "" { if config.DashboardDomain == "" {
fmt.Println("Error: Dashboard Domain name is required") fmt.Println("Error: Dashboard Domain name is required")
@@ -780,29 +782,42 @@ func checkPortsAvailable(port int) error {
} }
func downloadMaxMindDatabase() 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", 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 { "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 { 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 // 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 { 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 // Clean up the downloaded files
if err := run("rm", "-rf", "GeoLite2-Country.tar.gz", "GeoLite2-Country_*"); err != nil { if err := run("sh", "-c", "rm -rf GeoLite2-Country.tar.gz GeoLite2-Country_*"); err != nil {
fmt.Printf("Warning: failed to clean up temporary files: %v\n", err) 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 return nil
} }

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,17 +1,29 @@
import type { NextConfig } from "next"; import type { NextConfig } from "next";
import createNextIntlPlugin from "next-intl/plugin"; import createNextIntlPlugin from "next-intl/plugin";
import fs from "fs";
import path from "path";
const withNextIntl = createNextIntlPlugin(); 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);
console.log("Loaded allowed development origins:", allowedDevOrigins);
} catch {}
}
const nextConfig: NextConfig = { const nextConfig: NextConfig = {
reactStrictMode: false, reactStrictMode: false,
eslint: { reactCompiler: true,
ignoreDuringBuilds: true transpilePackages: ["@novnc/novnc"],
}, output: "standalone",
experimental: { allowedDevOrigins
reactCompiler: true
},
output: "standalone"
}; };
export default withNextIntl(nextConfig); export default withNextIntl(nextConfig);

6030
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

@@ -1,6 +1,12 @@
import { join } from "path"; import { join } from "path";
import { readFileSync } from "fs"; 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 { randomInt } from "crypto";
import { exitNodes, sites } from "@server/db"; import { exitNodes, sites } from "@server/db";
import { eq, and } from "drizzle-orm"; 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( export async function getUniqueSiteResourceName(
orgId: string orgId: string
): Promise<string> { ): Promise<string> {

View File

@@ -580,6 +580,24 @@ export const trialNotifications = pgTable("trialNotifications", {
sentAt: bigint("sentAt", { mode: "number" }).notNull() sentAt: bigint("sentAt", { mode: "number" }).notNull()
}); });
export const browserGatewayTarget = pgTable("browserGatewayTarget", {
browserGatewayTargetId: serial("browserGatewayTargetId").primaryKey(),
resourceId: integer("resourceId")
.references(() => resources.resourceId, {
onDelete: "cascade"
})
.notNull(),
siteId: integer("siteId")
.references(() => sites.siteId, {
onDelete: "cascade"
})
.notNull(),
authToken: varchar("authToken").notNull(),
type: varchar("type").notNull(), // "ssh", "rdp", "vnc"
destination: varchar("destination").notNull(),
destinationPort: integer("destinationPort").notNull()
});
export type Approval = InferSelectModel<typeof approvals>; export type Approval = InferSelectModel<typeof approvals>;
export type Limit = InferSelectModel<typeof limits>; export type Limit = InferSelectModel<typeof limits>;
export type Account = InferSelectModel<typeof account>; export type Account = InferSelectModel<typeof account>;
@@ -627,3 +645,6 @@ export type AlertEmailRecipients = InferSelectModel<
>; >;
export type AlertWebhookActions = InferSelectModel<typeof alertWebhookActions>; export type AlertWebhookActions = InferSelectModel<typeof alertWebhookActions>;
export type TrialNotification = InferSelectModel<typeof trialNotifications>; export type TrialNotification = InferSelectModel<typeof trialNotifications>;
export type BrowserGatewayTarget = InferSelectModel<
typeof browserGatewayTarget
>;

View File

@@ -65,7 +65,12 @@ export const orgs = pgTable("orgs", {
sshCaPrivateKey: text("sshCaPrivateKey"), // Encrypted SSH CA private key (PEM format) sshCaPrivateKey: text("sshCaPrivateKey"), // Encrypted SSH CA private key (PEM format)
sshCaPublicKey: text("sshCaPublicKey"), // SSH CA public key (OpenSSH format) sshCaPublicKey: text("sshCaPublicKey"), // SSH CA public key (OpenSSH format)
isBillingOrg: boolean("isBillingOrg"), isBillingOrg: boolean("isBillingOrg"),
billingOrgId: varchar("billingOrgId") billingOrgId: varchar("billingOrgId"),
settingsEnableGlobalNewtAutoUpdate: boolean(
"settingsEnableGlobalNewtAutoUpdate"
)
.notNull()
.default(false)
}); });
export const orgDomains = pgTable("orgDomains", { export const orgDomains = pgTable("orgDomains", {
@@ -103,6 +108,10 @@ export const sites = pgTable("sites", {
lastHolePunch: bigint("lastHolePunch", { mode: "number" }), lastHolePunch: bigint("lastHolePunch", { mode: "number" }),
listenPort: integer("listenPort"), listenPort: integer("listenPort"),
dockerSocketEnabled: boolean("dockerSocketEnabled").notNull().default(true), dockerSocketEnabled: boolean("dockerSocketEnabled").notNull().default(true),
autoUpdateEnabled: boolean("autoUpdateEnabled").notNull().default(false),
autoUpdateOverrideOrg: boolean("autoUpdateOverrideOrg")
.notNull()
.default(false),
status: varchar("status") status: varchar("status")
.$type<"pending" | "approved">() .$type<"pending" | "approved">()
.default("approved") .default("approved")
@@ -110,6 +119,16 @@ export const sites = pgTable("sites", {
export const resources = pgTable("resources", { export const resources = pgTable("resources", {
resourceId: serial("resourceId").primaryKey(), resourceId: serial("resourceId").primaryKey(),
resourcePolicyId: integer("resourcePolicyId").references(
() => resourcePolicies.resourcePolicyId,
{ onDelete: "set null" }
),
defaultResourcePolicyId: integer("defaultResourcePolicyId").references(
() => resourcePolicies.resourcePolicyId,
{
onDelete: "restrict"
}
),
resourceGuid: varchar("resourceGuid", { length: 36 }) resourceGuid: varchar("resourceGuid", { length: 36 })
.unique() .unique()
.notNull() .notNull()
@@ -129,8 +148,6 @@ export const resources = pgTable("resources", {
ssl: boolean("ssl").notNull().default(false), ssl: boolean("ssl").notNull().default(false),
blockAccess: boolean("blockAccess").notNull().default(false), blockAccess: boolean("blockAccess").notNull().default(false),
sso: boolean("sso").notNull().default(true), sso: boolean("sso").notNull().default(true),
http: boolean("http").notNull().default(true),
protocol: varchar("protocol").notNull(),
proxyPort: integer("proxyPort"), proxyPort: integer("proxyPort"),
emailWhitelistEnabled: boolean("emailWhitelistEnabled") emailWhitelistEnabled: boolean("emailWhitelistEnabled")
.notNull() .notNull()
@@ -147,7 +164,6 @@ export const resources = pgTable("resources", {
headers: text("headers"), // comma-separated list of headers to add to the request headers: text("headers"), // comma-separated list of headers to add to the request
proxyProtocol: boolean("proxyProtocol").notNull().default(false), proxyProtocol: boolean("proxyProtocol").notNull().default(false),
proxyProtocolVersion: integer("proxyProtocolVersion").default(1), proxyProtocolVersion: integer("proxyProtocolVersion").default(1),
maintenanceModeEnabled: boolean("maintenanceModeEnabled") maintenanceModeEnabled: boolean("maintenanceModeEnabled")
.notNull() .notNull()
.default(false), .default(false),
@@ -159,9 +175,100 @@ export const resources = pgTable("resources", {
maintenanceEstimatedTime: text("maintenanceEstimatedTime"), maintenanceEstimatedTime: text("maintenanceEstimatedTime"),
postAuthPath: text("postAuthPath"), postAuthPath: text("postAuthPath"),
health: varchar("health").default("unknown"), // "healthy", "unhealthy", "unknown" health: varchar("health").default("unknown"), // "healthy", "unhealthy", "unknown"
wildcard: boolean("wildcard").notNull().default(false) wildcard: boolean("wildcard").notNull().default(false),
mode: text("mode").default("http").notNull(), // rdp, ssh, http, vnc
pamMode: varchar("pamMode", { length: 32 })
.$type<"passthrough" | "push">()
.default("passthrough"),
authDaemonMode: varchar("authDaemonMode", { length: 32 })
.$type<"site" | "remote" | "native">()
.default("site"),
authDaemonPort: integer("authDaemonPort").default(22123)
}); });
export const labels = pgTable("labels", {
labelId: serial("labelId").primaryKey(),
name: varchar("name").notNull(),
color: varchar("color").notNull(),
orgId: varchar("orgId")
.references(() => orgs.orgId, {
onDelete: "cascade"
})
.notNull()
});
export const siteLabels = pgTable(
"siteLabels",
{
siteLabelId: serial("siteLabelId").primaryKey(),
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 = pgTable(
"resourceLabels",
{
resourceLabelId: serial("resourceLabelId").primaryKey(),
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 = pgTable(
"siteResourceLabels",
{
siteResourceLabelId: serial("siteResourceLabelId").primaryKey(),
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 = pgTable(
"clientLabels",
{
clientLabelId: serial("clientLabelId").primaryKey(),
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 = pgTable("targets", { export const targets = pgTable("targets", {
targetId: serial("targetId").primaryKey(), targetId: serial("targetId").primaryKey(),
resourceId: integer("resourceId") resourceId: integer("resourceId")
@@ -196,9 +303,11 @@ export const targetHealthCheck = pgTable("targetHealthCheck", {
onDelete: "cascade" onDelete: "cascade"
}) })
.notNull(), .notNull(),
siteId: integer("siteId").references(() => sites.siteId, { siteId: integer("siteId")
onDelete: "cascade" .references(() => sites.siteId, {
}).notNull(), onDelete: "cascade"
})
.notNull(),
name: varchar("name"), name: varchar("name"),
hcEnabled: boolean("hcEnabled").notNull().default(false), hcEnabled: boolean("hcEnabled").notNull().default(false),
hcPath: varchar("hcPath"), hcPath: varchar("hcPath"),
@@ -254,11 +363,11 @@ export const siteResources = pgTable("siteResources", {
niceId: varchar("niceId").notNull(), niceId: varchar("niceId").notNull(),
name: varchar("name").notNull(), name: varchar("name").notNull(),
ssl: boolean("ssl").notNull().default(false), ssl: boolean("ssl").notNull().default(false),
mode: varchar("mode").$type<"host" | "cidr" | "http">().notNull(), // "host" | "cidr" | "http" mode: varchar("mode").$type<"host" | "cidr" | "http" | "ssh">().notNull(), // "host" | "cidr" | "http"
scheme: varchar("scheme").$type<"http" | "https">(), // only for when we are doing https or http mode scheme: varchar("scheme").$type<"http" | "https">(), // only for when we are doing https or http mode
proxyPort: integer("proxyPort"), // only for port mode proxyPort: integer("proxyPort"), // only for port mode
destinationPort: integer("destinationPort"), // only for port mode destinationPort: integer("destinationPort"), // only for port mode
destination: varchar("destination").notNull(), // ip, cidr, hostname; validate against the mode destination: varchar("destination"), // ip, cidr, hostname; validate against the mode
enabled: boolean("enabled").notNull().default(true), enabled: boolean("enabled").notNull().default(true),
alias: varchar("alias"), alias: varchar("alias"),
aliasAddress: varchar("aliasAddress"), aliasAddress: varchar("aliasAddress"),
@@ -266,8 +375,11 @@ export const siteResources = pgTable("siteResources", {
udpPortRangeString: varchar("udpPortRangeString").notNull().default("*"), udpPortRangeString: varchar("udpPortRangeString").notNull().default("*"),
disableIcmp: boolean("disableIcmp").notNull().default(false), disableIcmp: boolean("disableIcmp").notNull().default(false),
authDaemonPort: integer("authDaemonPort").default(22123), authDaemonPort: integer("authDaemonPort").default(22123),
pamMode: varchar("pamMode", { length: 32 })
.$type<"passthrough" | "push">()
.default("passthrough"),
authDaemonMode: varchar("authDaemonMode", { length: 32 }) authDaemonMode: varchar("authDaemonMode", { length: 32 })
.$type<"site" | "remote">() .$type<"site" | "remote" | "native">()
.default("site"), .default("site"),
domainId: varchar("domainId").references(() => domains.domainId, { domainId: varchar("domainId").references(() => domains.domainId, {
onDelete: "set null" onDelete: "set null"
@@ -521,6 +633,38 @@ export const userResources = pgTable("userResources", {
.references(() => resources.resourceId, { onDelete: "cascade" }) .references(() => resources.resourceId, { onDelete: "cascade" })
}); });
export const rolePolicies = pgTable("rolePolicies", {
roleId: integer("roleId")
.notNull()
.references(() => roles.roleId, { onDelete: "cascade" }),
resourcePolicyId: integer("resourcePolicyId")
.notNull()
.references(() => resourcePolicies.resourcePolicyId, {
onDelete: "cascade"
})
});
export const userPolicies = pgTable("userPolicies", {
userId: varchar("userId")
.notNull()
.references(() => users.userId, { onDelete: "cascade" }),
resourcePolicyId: integer("resourcePolicyId")
.notNull()
.references(() => resourcePolicies.resourcePolicyId, {
onDelete: "cascade"
})
});
export const resourcePolicyWhiteList = pgTable("resourcePolicyWhitelist", {
whitelistId: serial("id").primaryKey(),
email: varchar("email").notNull(),
resourcePolicyId: integer("resourcePolicyId")
.notNull()
.references(() => resourcePolicies.resourcePolicyId, {
onDelete: "cascade"
})
});
export const userInvites = pgTable("userInvites", { export const userInvites = pgTable("userInvites", {
inviteId: varchar("inviteId").primaryKey(), inviteId: varchar("inviteId").primaryKey(),
orgId: varchar("orgId") orgId: varchar("orgId")
@@ -586,6 +730,40 @@ export const resourceHeaderAuthExtendedCompatibility = pgTable(
} }
); );
export const resourcePolicyPincode = pgTable("resourcePolicyPincode", {
pincodeId: serial("pincodeId").primaryKey(),
pincodeHash: varchar("pincodeHash").notNull(),
digitLength: integer("digitLength").notNull(),
resourcePolicyId: integer("resourcePolicyId")
.notNull()
.references(() => resourcePolicies.resourcePolicyId, {
onDelete: "cascade"
})
});
export const resourcePolicyPassword = pgTable("resourcePolicyPassword", {
passwordId: serial("passwordId").primaryKey(),
passwordHash: varchar("passwordHash").notNull(),
resourcePolicyId: integer("resourcePolicyId")
.notNull()
.references(() => resourcePolicies.resourcePolicyId, {
onDelete: "cascade"
})
});
export const resourcePolicyHeaderAuth = pgTable("resourcePolicyHeaderAuth", {
headerAuthId: serial("headerAuthId").primaryKey(),
headerAuthHash: varchar("headerAuthHash").notNull(),
extendedCompatibility: boolean("extendedCompatibility")
.notNull()
.default(true),
resourcePolicyId: integer("resourcePolicyId")
.notNull()
.references(() => resourcePolicies.resourcePolicyId, {
onDelete: "cascade"
})
});
export const resourceAccessToken = pgTable("resourceAccessToken", { export const resourceAccessToken = pgTable("resourceAccessToken", {
accessTokenId: varchar("accessTokenId").primaryKey(), accessTokenId: varchar("accessTokenId").primaryKey(),
orgId: varchar("orgId") orgId: varchar("orgId")
@@ -594,6 +772,7 @@ export const resourceAccessToken = pgTable("resourceAccessToken", {
resourceId: integer("resourceId") resourceId: integer("resourceId")
.notNull() .notNull()
.references(() => resources.resourceId, { onDelete: "cascade" }), .references(() => resources.resourceId, { onDelete: "cascade" }),
path: varchar("path"),
tokenHash: varchar("tokenHash").notNull(), tokenHash: varchar("tokenHash").notNull(),
sessionLength: bigint("sessionLength", { mode: "number" }).notNull(), sessionLength: bigint("sessionLength", { mode: "number" }).notNull(),
expiresAt: bigint("expiresAt", { mode: "number" }), expiresAt: bigint("expiresAt", { mode: "number" }),
@@ -679,6 +858,43 @@ export const resourceRules = pgTable("resourceRules", {
value: varchar("value").notNull() value: varchar("value").notNull()
}); });
export const resourcePolicyRules = pgTable("resourcePolicyRules", {
ruleId: serial("ruleId").primaryKey(),
resourcePolicyId: integer("resourcePolicyId")
.notNull()
.references(() => resourcePolicies.resourcePolicyId, {
onDelete: "cascade"
}),
enabled: boolean("enabled").notNull().default(true),
priority: integer("priority").notNull(),
action: varchar("action").$type<"ACCEPT" | "DROP" | "PASS">().notNull(),
match: varchar("match").$type<"CIDR" | "PATH" | "IP">().notNull(),
value: varchar("value").notNull()
});
export const resourcePolicies = pgTable("resourcePolicies", {
resourcePolicyId: serial("resourcePolicyId").primaryKey(),
sso: boolean("sso").notNull().default(true),
applyRules: boolean("applyRules").notNull().default(false),
scope: varchar("scope")
.$type<"global" | "resource">()
.notNull()
.default("global"),
emailWhitelistEnabled: boolean("emailWhitelistEnabled")
.notNull()
.default(false),
idpId: integer("idpId").references(() => idp.idpId, {
onDelete: "set null"
}),
niceId: text("niceId").notNull(),
name: varchar("name").notNull(),
orgId: varchar("orgId")
.references(() => orgs.orgId, {
onDelete: "cascade"
})
.notNull()
});
export const supporterKey = pgTable("supporterKey", { export const supporterKey = pgTable("supporterKey", {
keyId: serial("keyId").primaryKey(), keyId: serial("keyId").primaryKey(),
key: varchar("key").notNull(), key: varchar("key").notNull(),
@@ -1097,19 +1313,30 @@ export const roundTripMessageTracker = pgTable("roundTripMessageTracker", {
complete: boolean("complete").notNull().default(false) complete: boolean("complete").notNull().default(false)
}); });
export const statusHistory = pgTable("statusHistory", { export const statusHistory = pgTable(
id: serial("id").primaryKey(), "statusHistory",
entityType: varchar("entityType").notNull(), {
entityId: integer("entityId").notNull(), id: serial("id").primaryKey(),
orgId: varchar("orgId") entityType: varchar("entityType").notNull(),
.notNull() entityId: integer("entityId").notNull(),
.references(() => orgs.orgId, { onDelete: "cascade" }), orgId: varchar("orgId")
status: varchar("status").notNull(), .notNull()
timestamp: integer("timestamp").notNull(), .references(() => orgs.orgId, { onDelete: "cascade" }),
}, (table) => [ status: varchar("status").notNull(),
index("idx_statusHistory_entity").on(table.entityType, table.entityId, table.timestamp), timestamp: integer("timestamp").notNull()
index("idx_statusHistory_org_timestamp").on(table.orgId, table.timestamp), },
]); (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 Org = InferSelectModel<typeof orgs>;
export type User = InferSelectModel<typeof users>; export type User = InferSelectModel<typeof users>;
@@ -1179,3 +1406,7 @@ export type RoundTripMessageTracker = InferSelectModel<
>; >;
export type Network = InferSelectModel<typeof networks>; export type Network = InferSelectModel<typeof networks>;
export type StatusHistory = InferSelectModel<typeof statusHistory>; export type StatusHistory = InferSelectModel<typeof statusHistory>;
export type Label = InferSelectModel<typeof labels>;
export type ResourcePolicy = InferSelectModel<typeof resourcePolicies>;
export type RolePolicy = InferSelectModel<typeof rolePolicies>;
export type UserPolicy = InferSelectModel<typeof userPolicies>;

View File

@@ -17,10 +17,13 @@ import {
resourceHeaderAuth, resourceHeaderAuth,
ResourceHeaderAuth, ResourceHeaderAuth,
resourceRules, resourceRules,
resourcePolicyRules,
resources, resources,
roleResources, roleResources,
rolePolicies,
sessions, sessions,
userResources, userResources,
userPolicies,
users, users,
ResourceHeaderAuthExtendedCompatibility, ResourceHeaderAuthExtendedCompatibility,
resourceHeaderAuthExtendedCompatibility resourceHeaderAuthExtendedCompatibility
@@ -154,58 +157,126 @@ 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( export async function getRoleResourceAccess(
resourceId: number, resourceId: number,
roleIds: number[] roleIds: number[]
) { ) {
const roleResourceAccess = await db const [direct, viaPolicies] = await Promise.all([
.select() db
.from(roleResources) .select()
.where( .from(roleResources)
and( .where(
eq(roleResources.resourceId, resourceId), and(
inArray(roleResources.roleId, roleIds) eq(roleResources.resourceId, resourceId),
inArray(roleResources.roleId, roleIds)
)
),
db
.select({
roleId: rolePolicies.roleId,
resourcePolicyId: rolePolicies.resourcePolicyId
})
.from(rolePolicies)
.innerJoin(
resources,
eq(resources.resourcePolicyId, 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( export async function getUserResourceAccess(
userId: string, userId: string,
resourceId: number resourceId: number
) { ) {
const userResourceAccess = await db const [direct, viaPolicies] = await Promise.all([
.select() db
.from(userResources) .select()
.where( .from(userResources)
and( .where(
eq(userResources.userId, userId), and(
eq(userResources.resourceId, resourceId) eq(userResources.userId, userId),
eq(userResources.resourceId, resourceId)
)
) )
) .limit(1),
.limit(1); db
.select({
userId: userPolicies.userId,
resourcePolicyId: userPolicies.resourcePolicyId
})
.from(userPolicies)
.innerJoin(
resources,
eq(resources.resourcePolicyId, 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( export async function getResourceRules(
resourceId: number resourceId: number
): Promise<ResourceRule[]> { ): Promise<ResourceRule[]> {
const rules = await db const [directRules, policyRules] = await Promise.all([
.select() db
.from(resourceRules) .select()
.where(eq(resourceRules.resourceId, resourceId)); .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,
eq(
resources.resourcePolicyId,
resourcePolicyRules.resourcePolicyId
)
)
.where(eq(resources.resourceId, resourceId))
]);
return rules; const maxDirectPriority = directRules.reduce(
(max, r) => Math.max(max, r.priority),
0
);
const offsetPolicyRules = policyRules.map((r) => ({
...r,
priority: maxDirectPriority + r.priority
}));
return [...directRules, ...offsetPolicyRules] as ResourceRule[];
} }
/** /**

View File

@@ -588,6 +588,26 @@ export const trialNotifications = sqliteTable("trialNotifications", {
sentAt: integer("sentAt").notNull() sentAt: integer("sentAt").notNull()
}); });
export const browserGatewayTarget = sqliteTable("browserGatewayTarget", {
browserGatewayTargetId: integer("browserGatewayTargetId").primaryKey({
autoIncrement: true
}),
resourceId: integer("resourceId")
.references(() => resources.resourceId, {
onDelete: "cascade"
})
.notNull(),
siteId: integer("siteId")
.references(() => sites.siteId, {
onDelete: "cascade"
})
.notNull(),
authToken: text("authToken").notNull(),
type: text("type").notNull(), // "ssh", "rdp", "vnc"
destination: text("destination").notNull(),
destinationPort: integer("destinationPort").notNull()
});
export type Approval = InferSelectModel<typeof approvals>; export type Approval = InferSelectModel<typeof approvals>;
export type Limit = InferSelectModel<typeof limits>; export type Limit = InferSelectModel<typeof limits>;
export type Account = InferSelectModel<typeof account>; export type Account = InferSelectModel<typeof account>;
@@ -627,3 +647,6 @@ export type AlertEmailAction = InferSelectModel<typeof alertEmailActions>;
export type AlertEmailRecipient = InferSelectModel<typeof alertEmailRecipients>; export type AlertEmailRecipient = InferSelectModel<typeof alertEmailRecipients>;
export type AlertWebhookAction = InferSelectModel<typeof alertWebhookActions>; export type AlertWebhookAction = InferSelectModel<typeof alertWebhookActions>;
export type TrialNotification = InferSelectModel<typeof trialNotifications>; export type TrialNotification = InferSelectModel<typeof trialNotifications>;
export type BrowserGatewayTarget = InferSelectModel<
typeof browserGatewayTarget
>;

View File

@@ -62,7 +62,13 @@ export const orgs = sqliteTable("orgs", {
sshCaPrivateKey: text("sshCaPrivateKey"), // Encrypted SSH CA private key (PEM format) sshCaPrivateKey: text("sshCaPrivateKey"), // Encrypted SSH CA private key (PEM format)
sshCaPublicKey: text("sshCaPublicKey"), // SSH CA public key (OpenSSH format) sshCaPublicKey: text("sshCaPublicKey"), // SSH CA public key (OpenSSH format)
isBillingOrg: integer("isBillingOrg", { mode: "boolean" }), isBillingOrg: integer("isBillingOrg", { mode: "boolean" }),
billingOrgId: text("billingOrgId") billingOrgId: text("billingOrgId"),
settingsEnableGlobalNewtAutoUpdate: integer(
"settingsEnableGlobalNewtAutoUpdate",
{ mode: "boolean" }
)
.notNull()
.default(false)
}); });
export const userDomains = sqliteTable("userDomains", { export const userDomains = sqliteTable("userDomains", {
@@ -116,11 +122,29 @@ export const sites = sqliteTable("sites", {
dockerSocketEnabled: integer("dockerSocketEnabled", { mode: "boolean" }) dockerSocketEnabled: integer("dockerSocketEnabled", { mode: "boolean" })
.notNull() .notNull()
.default(true), .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") status: text("status").$type<"pending" | "approved">().default("approved")
}); });
export const resources = sqliteTable("resources", { export const resources = sqliteTable("resources", {
resourceId: integer("resourceId").primaryKey({ autoIncrement: true }), 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 }) resourceGuid: text("resourceGuid", { length: 36 })
.unique() .unique()
.notNull() .notNull()
@@ -142,8 +166,6 @@ export const resources = sqliteTable("resources", {
.notNull() .notNull()
.default(false), .default(false),
sso: integer("sso", { mode: "boolean" }).notNull().default(true), sso: integer("sso", { mode: "boolean" }).notNull().default(true),
http: integer("http", { mode: "boolean" }).notNull().default(true),
protocol: text("protocol").notNull(),
proxyPort: integer("proxyPort"), proxyPort: integer("proxyPort"),
emailWhitelistEnabled: integer("emailWhitelistEnabled", { mode: "boolean" }) emailWhitelistEnabled: integer("emailWhitelistEnabled", { mode: "boolean" })
.notNull() .notNull()
@@ -166,7 +188,6 @@ export const resources = sqliteTable("resources", {
.notNull() .notNull()
.default(false), .default(false),
proxyProtocolVersion: integer("proxyProtocolVersion").default(1), proxyProtocolVersion: integer("proxyProtocolVersion").default(1),
maintenanceModeEnabled: integer("maintenanceModeEnabled", { maintenanceModeEnabled: integer("maintenanceModeEnabled", {
mode: "boolean" mode: "boolean"
}) })
@@ -180,9 +201,106 @@ export const resources = sqliteTable("resources", {
maintenanceEstimatedTime: text("maintenanceEstimatedTime"), maintenanceEstimatedTime: text("maintenanceEstimatedTime"),
postAuthPath: text("postAuthPath"), postAuthPath: text("postAuthPath"),
health: text("health").default("unknown"), // "healthy", "unhealthy", "unknown" 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 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", { export const targets = sqliteTable("targets", {
targetId: integer("targetId").primaryKey({ autoIncrement: true }), targetId: integer("targetId").primaryKey({ autoIncrement: true }),
resourceId: integer("resourceId") resourceId: integer("resourceId")
@@ -219,9 +337,11 @@ export const targetHealthCheck = sqliteTable("targetHealthCheck", {
onDelete: "cascade" onDelete: "cascade"
}) })
.notNull(), .notNull(),
siteId: integer("siteId").references(() => sites.siteId, { siteId: integer("siteId")
onDelete: "cascade" .references(() => sites.siteId, {
}).notNull(), onDelete: "cascade"
})
.notNull(),
name: text("name"), name: text("name"),
hcEnabled: integer("hcEnabled", { mode: "boolean" }) hcEnabled: integer("hcEnabled", { mode: "boolean" })
.notNull() .notNull()
@@ -281,11 +401,11 @@ export const siteResources = sqliteTable("siteResources", {
niceId: text("niceId").notNull(), niceId: text("niceId").notNull(),
name: text("name").notNull(), name: text("name").notNull(),
ssl: integer("ssl", { mode: "boolean" }).notNull().default(false), 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 scheme: text("scheme").$type<"http" | "https">(), // only for when we are doing https or http mode
proxyPort: integer("proxyPort"), // only for port mode proxyPort: integer("proxyPort"), // only for port mode
destinationPort: integer("destinationPort"), // 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), enabled: integer("enabled", { mode: "boolean" }).notNull().default(true),
alias: text("alias"), alias: text("alias"),
aliasAddress: text("aliasAddress"), aliasAddress: text("aliasAddress"),
@@ -295,8 +415,11 @@ export const siteResources = sqliteTable("siteResources", {
.notNull() .notNull()
.default(false), .default(false),
authDaemonPort: integer("authDaemonPort").default(22123), authDaemonPort: integer("authDaemonPort").default(22123),
pamMode: text("pamMode")
.$type<"passthrough" | "push">()
.default("passthrough"),
authDaemonMode: text("authDaemonMode") authDaemonMode: text("authDaemonMode")
.$type<"site" | "remote">() .$type<"site" | "remote" | "native">()
.default("site"), .default("site"),
domainId: text("domainId").references(() => domains.domainId, { domainId: text("domainId").references(() => domains.domainId, {
onDelete: "set null" onDelete: "set null"
@@ -909,6 +1032,47 @@ export const resourceHeaderAuth = sqliteTable("resourceHeaderAuth", {
headerAuthHash: text("headerAuthHash").notNull() 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( export const resourceHeaderAuthExtendedCompatibility = sqliteTable(
"resourceHeaderAuthExtendedCompatibility", "resourceHeaderAuthExtendedCompatibility",
{ {
@@ -937,6 +1101,7 @@ export const resourceAccessToken = sqliteTable("resourceAccessToken", {
resourceId: integer("resourceId") resourceId: integer("resourceId")
.notNull() .notNull()
.references(() => resources.resourceId, { onDelete: "cascade" }), .references(() => resources.resourceId, { onDelete: "cascade" }),
path: text("path"),
tokenHash: text("tokenHash").notNull(), tokenHash: text("tokenHash").notNull(),
sessionLength: integer("sessionLength").notNull(), sessionLength: integer("sessionLength").notNull(),
expiresAt: integer("expiresAt"), expiresAt: integer("expiresAt"),
@@ -1023,6 +1188,77 @@ export const resourceRules = sqliteTable("resourceRules", {
value: text("value").notNull() value: text("value").notNull()
}); });
export const rolePolicies = sqliteTable("rolePolicies", {
roleId: integer("roleId")
.notNull()
.references(() => roles.roleId, { onDelete: "cascade" }),
resourcePolicyId: integer("resourcePolicyId")
.notNull()
.references(() => resourcePolicies.resourcePolicyId, {
onDelete: "cascade"
})
});
export const userPolicies = sqliteTable("userPolicies", {
userId: text("userId")
.notNull()
.references(() => users.userId, { onDelete: "cascade" }),
resourcePolicyId: integer("resourcePolicyId")
.notNull()
.references(() => resourcePolicies.resourcePolicyId, {
onDelete: "cascade"
})
});
export const resourcePolicyWhiteList = sqliteTable("resourcePolicyWhitelist", {
whitelistId: integer("id").primaryKey({ autoIncrement: true }),
email: text("email").notNull(),
resourcePolicyId: integer("resourcePolicyId")
.notNull()
.references(() => resourcePolicies.resourcePolicyId, {
onDelete: "cascade"
})
});
export const resourcePolicyRules = sqliteTable("resourcePolicyRules", {
ruleId: integer("ruleId").primaryKey({ autoIncrement: true }),
resourcePolicyId: integer("resourcePolicyId")
.notNull()
.references(() => resourcePolicies.resourcePolicyId, {
onDelete: "cascade"
}),
enabled: integer("enabled", { mode: "boolean" }).notNull().default(true),
priority: integer("priority").notNull(),
action: text("action").$type<"ACCEPT" | "DROP" | "PASS">().notNull(),
match: text("match").$type<"CIDR" | "PATH" | "IP">().notNull(),
value: text("value").notNull()
});
export const resourcePolicies = sqliteTable("resourcePolicies", {
resourcePolicyId: integer("resourcePolicyId").primaryKey(),
sso: integer("sso", { mode: "boolean" }).notNull().default(true),
applyRules: integer("applyRules", { mode: "boolean" })
.notNull()
.default(false),
scope: text("scope")
.$type<"global" | "resource">()
.notNull()
.default("global"),
emailWhitelistEnabled: integer("emailWhitelistEnabled", { mode: "boolean" })
.notNull()
.default(false),
niceId: text("niceId").notNull(),
idpId: integer("idpId").references(() => idp.idpId, {
onDelete: "set null"
}),
name: text("name").notNull(),
orgId: text("orgId")
.references(() => orgs.orgId, {
onDelete: "cascade"
})
.notNull()
});
export const supporterKey = sqliteTable("supporterKey", { export const supporterKey = sqliteTable("supporterKey", {
keyId: integer("keyId").primaryKey({ autoIncrement: true }), keyId: integer("keyId").primaryKey({ autoIncrement: true }),
key: text("key").notNull(), key: text("key").notNull(),
@@ -1196,19 +1432,30 @@ export const roundTripMessageTracker = sqliteTable("roundTripMessageTracker", {
complete: integer("complete", { mode: "boolean" }).notNull().default(false) complete: integer("complete", { mode: "boolean" }).notNull().default(false)
}); });
export const statusHistory = sqliteTable("statusHistory", { export const statusHistory = sqliteTable(
id: integer("id").primaryKey({ autoIncrement: true }), "statusHistory",
entityType: text("entityType").notNull(), // "site" | "healthCheck" {
entityId: integer("entityId").notNull(), // siteId or targetHealthCheckId id: integer("id").primaryKey({ autoIncrement: true }),
orgId: text("orgId") entityType: text("entityType").notNull(), // "site" | "healthCheck"
.notNull() entityId: integer("entityId").notNull(), // siteId or targetHealthCheckId
.references(() => orgs.orgId, { onDelete: "cascade" }), orgId: text("orgId")
status: text("status").notNull(), // "online"/"offline" for sites; "healthy"/"unhealthy"/"unknown" for healthChecks .notNull()
timestamp: integer("timestamp").notNull(), // unix epoch seconds .references(() => orgs.orgId, { onDelete: "cascade" }),
}, (table) => [ status: text("status").notNull(), // "online"/"offline" for sites; "healthy"/"unhealthy"/"unknown" for healthChecks
index("idx_statusHistory_entity").on(table.entityType, table.entityId, table.timestamp), timestamp: integer("timestamp").notNull() // unix epoch seconds
index("idx_statusHistory_org_timestamp").on(table.orgId, table.timestamp), },
]); (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 Org = InferSelectModel<typeof orgs>;
export type User = InferSelectModel<typeof users>; export type User = InferSelectModel<typeof users>;
@@ -1278,3 +1525,7 @@ export type RoundTripMessageTracker = InferSelectModel<
typeof roundTripMessageTracker typeof roundTripMessageTracker
>; >;
export type StatusHistory = InferSelectModel<typeof statusHistory>; export type StatusHistory = InferSelectModel<typeof statusHistory>;
export type Label = InferSelectModel<typeof labels>;
export type ResourcePolicy = InferSelectModel<typeof resourcePolicies>;
export type RolePolicy = InferSelectModel<typeof rolePolicies>;
export type UserPolicy = InferSelectModel<typeof userPolicies>;

View File

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

View File

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

View File

@@ -24,10 +24,14 @@ export enum TierFeature {
DomainNamespaces = "domainNamespaces", // handle downgrade by removing custom domain namespaces DomainNamespaces = "domainNamespaces", // handle downgrade by removing custom domain namespaces
StandaloneHealthChecks = "standaloneHealthChecks", StandaloneHealthChecks = "standaloneHealthChecks",
AlertingRules = "alertingRules", AlertingRules = "alertingRules",
WildcardSubdomain = "wildcardSubdomain" WildcardSubdomain = "wildcardSubdomain",
Labels = "labels",
NewtAutoUpdate = "newtAutoUpdate",
ResourcePolicies = "resourcePolicies"
} }
export const tierMatrix: Record<TierFeature, Tier[]> = { export const tierMatrix: Record<TierFeature, Tier[]> = {
[TierFeature.Labels]: ["tier2", "tier3", "enterprise"],
[TierFeature.OrgOidc]: ["tier1", "tier2", "tier3", "enterprise"], [TierFeature.OrgOidc]: ["tier1", "tier2", "tier3", "enterprise"],
[TierFeature.LoginPageDomain]: ["tier1", "tier2", "tier3", "enterprise"], [TierFeature.LoginPageDomain]: ["tier1", "tier2", "tier3", "enterprise"],
[TierFeature.DeviceApprovals]: ["tier1", "tier3", "enterprise"], [TierFeature.DeviceApprovals]: ["tier1", "tier3", "enterprise"],
@@ -66,5 +70,7 @@ export const tierMatrix: Record<TierFeature, Tier[]> = {
[TierFeature.DomainNamespaces]: ["tier1", "tier2", "tier3", "enterprise"], [TierFeature.DomainNamespaces]: ["tier1", "tier2", "tier3", "enterprise"],
[TierFeature.StandaloneHealthChecks]: ["tier3", "enterprise"], [TierFeature.StandaloneHealthChecks]: ["tier3", "enterprise"],
[TierFeature.AlertingRules]: ["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"]
}; };

View File

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

View File

@@ -1,10 +1,56 @@
import { sendToClient } from "#dynamic/routers/ws"; import { sendToClient } from "#dynamic/routers/ws";
import { processContainerLabels } from "./parseDockerContainers"; import { processContainerLabels } from "./parseDockerContainers";
import { applyBlueprint } from "./applyBlueprint"; import { applyBlueprint } from "./applyBlueprint";
import { PrivateResourceSchema, PublicResourceSchema } from "./types";
import { db, sites } from "@server/db"; import { db, sites } from "@server/db";
import { eq } from "drizzle-orm"; import { eq } from "drizzle-orm";
import logger from "@server/logger"; 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( export async function applyNewtDockerBlueprint(
siteId: number, siteId: number,
newtId: string, newtId: string,
@@ -21,17 +67,24 @@ export async function applyNewtDockerBlueprint(
return; return;
} }
// logger.debug(`Applying Docker blueprint to site: ${siteId}`); let skippedCount = 0;
// logger.debug(`Containers: ${JSON.stringify(containers, null, 2)}`); let skippedKeys: string[] = [];
try { try {
const blueprint = processContainerLabels(containers); const blueprint = processContainerLabels(containers);
logger.debug(`Received Docker blueprint: ${JSON.stringify(blueprint)}`); logger.debug(
`Received Docker blueprint with ${Object.keys(blueprint["proxy-resources"]).length} proxy, ${Object.keys(blueprint["client-resources"]).length} client resource(s)`
);
// make sure this is not an empty object const filterResult = filterInvalidResources(blueprint);
if (isEmptyObject(blueprint)) { skippedCount = filterResult.skippedCount;
return; skippedKeys = filterResult.skippedKeys;
if (skippedCount > 0) {
logger.warn(
`Filtered ${skippedCount} invalid resource(s) from Docker blueprint: ${skippedKeys.join(", ")}`
);
} }
if ( if (
@@ -40,6 +93,15 @@ export async function applyNewtDockerBlueprint(
isEmptyObject(blueprint["public-resources"]) && isEmptyObject(blueprint["public-resources"]) &&
isEmptyObject(blueprint["private-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; return;
} }
@@ -66,7 +128,10 @@ export async function applyNewtDockerBlueprint(
type: "newt/blueprint/results", type: "newt/blueprint/results",
data: { data: {
success: true, success: true,
message: "Config updated successfully" message:
skippedCount > 0
? `Config updated successfully. Skipped ${skippedCount} invalid resource(s): ${skippedKeys.join(", ")}`
: "Config updated successfully"
} }
}); });
} }

View File

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

File diff suppressed because it is too large Load Diff

View File

@@ -82,7 +82,7 @@ export const RuleSchema = z
.object({ .object({
action: z.enum(["allow", "deny", "pass"]), action: z.enum(["allow", "deny", "pass"]),
match: z.enum(["cidr", "path", "ip", "country", "asn", "region"]), match: z.enum(["cidr", "path", "ip", "country", "asn", "region"]),
value: z.string(), value: z.coerce.string(),
priority: z.int().optional() priority: z.int().optional()
}) })
.refine( .refine(
@@ -161,11 +161,34 @@ export const HeaderSchema = z.object({
value: z.string().min(1) 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 // Schema for individual resource
export const ResourceSchema = z export const PublicResourceSchema = z
.object({ .object({
name: z.string().optional(), 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(), ssl: z.boolean().optional(),
scheme: z.enum(["http", "https"]).optional(), scheme: z.enum(["http", "https"]).optional(),
"full-domain": z.string().optional(), "full-domain": z.string().optional(),
@@ -177,7 +200,8 @@ export const ResourceSchema = z
"tls-server-name": z.string().optional(), "tls-server-name": z.string().optional(),
headers: z.array(HeaderSchema).optional(), headers: z.array(HeaderSchema).optional(),
rules: z.array(RuleSchema).optional(), rules: z.array(RuleSchema).optional(),
maintenance: MaintenanceSchema.optional() maintenance: MaintenanceSchema.optional(),
"auth-daemon": AuthDaemonSchema.optional()
}) })
.refine( .refine(
(resource) => { (resource) => {
@@ -185,9 +209,10 @@ export const ResourceSchema = z
return true; return true;
} }
// Otherwise, require name and protocol for full resource definition // Otherwise, require name and protocol/mode for full resource definition
return ( return (
resource.name !== undefined && resource.protocol !== undefined resource.name !== undefined &&
(resource.mode !== undefined || resource.protocol !== undefined)
); );
}, },
{ {
@@ -201,8 +226,8 @@ export const ResourceSchema = z
return true; return true;
} }
// If protocol is http, all targets must have method field // If protocol/mode is http, all targets must have method field
if (resource.protocol === "http") { if ((resource.mode ?? resource.protocol) === "http") {
return resource.targets.every( return resource.targets.every(
(target) => target == null || target.method !== undefined (target) => target == null || target.method !== undefined
); );
@@ -220,8 +245,9 @@ export const ResourceSchema = z
return true; return true;
} }
// If protocol is tcp or udp, no target should have method field // If protocol/mode is tcp or udp, no target should have method field
if (resource.protocol === "tcp" || resource.protocol === "udp") { const effectiveProtocol1 = resource.mode ?? resource.protocol;
if (effectiveProtocol1 === "tcp" || effectiveProtocol1 === "udp") {
return resource.targets.every( return resource.targets.every(
(target) => target == null || target.method === undefined (target) => target == null || target.method === undefined
); );
@@ -239,8 +265,8 @@ export const ResourceSchema = z
return true; return true;
} }
// If protocol is http, it must have a full-domain // If protocol/mode is http, it must have a full-domain
if (resource.protocol === "http") { if ((resource.mode ?? resource.protocol) === "http") {
return ( return (
resource["full-domain"] !== undefined && resource["full-domain"] !== undefined &&
resource["full-domain"].length > 0 resource["full-domain"].length > 0
@@ -259,8 +285,9 @@ export const ResourceSchema = z
return true; return true;
} }
// If protocol is tcp or udp, it must have both proxy-port // If protocol/mode is tcp or udp, it must have both proxy-port
if (resource.protocol === "tcp" || resource.protocol === "udp") { const effectiveProtocol2 = resource.mode ?? resource.protocol;
if (effectiveProtocol2 === "tcp" || effectiveProtocol2 === "udp") {
return resource["proxy-port"] !== undefined; return resource["proxy-port"] !== undefined;
} }
return true; return true;
@@ -277,8 +304,9 @@ export const ResourceSchema = z
return true; return true;
} }
// If protocol is tcp or udp, it must not have auth // If protocol/mode is tcp or udp, it must not have auth
if (resource.protocol === "tcp" || resource.protocol === "udp") { const effectiveProtocol3 = resource.mode ?? resource.protocol;
if (effectiveProtocol3 === "tcp" || effectiveProtocol3 === "udp") {
return resource.auth === undefined; return resource.auth === undefined;
} }
return true; return true;
@@ -340,7 +368,8 @@ export const ResourceSchema = z
if (parts.includes("*", 1)) return false; // no further wildcards if (parts.includes("*", 1)) return false; // no further wildcards
if (parts.length < 3) return false; // need at least *.label.tld 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)); return parts.slice(1).every((label) => labelRegex.test(label));
}, },
{ {
@@ -348,22 +377,29 @@ export const ResourceSchema = z
message: 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.' '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.'
} }
); )
.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 { export function isTargetsOnlyResource(resource: any): boolean {
return Object.keys(resource).length === 1 && resource.targets; return Object.keys(resource).length === 1 && resource.targets;
} }
export const ClientResourceSchema = z export const PrivateResourceSchema = z
.object({ .object({
name: z.string().min(1).max(255), 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 site: z.string().optional(), // DEPRECATED IN FAVOR OF sites
sites: z.array(z.string()).optional().default([]), sites: z.array(z.string()).optional().default([]),
// protocol: z.enum(["tcp", "udp"]).optional(), // protocol: z.enum(["tcp", "udp"]).optional(),
// proxyPort: z.int().positive().optional(), // proxyPort: z.int().positive().optional(),
"destination-port": z.int().positive().optional(), "destination-port": z.int().positive().optional(),
destination: z.string().min(1), destination: z.string().min(1).optional(),
// enabled: z.boolean().default(true), // enabled: z.boolean().default(true),
"tcp-ports": portRangeStringSchema.optional().default("*"), "tcp-ports": portRangeStringSchema.optional().default("*"),
"udp-ports": portRangeStringSchema.optional().default("*"), "udp-ports": portRangeStringSchema.optional().default("*"),
@@ -386,11 +422,31 @@ export const ClientResourceSchema = z
error: "Admin role cannot be included in roles" error: "Admin role cannot be included in roles"
}), }),
users: z.array(z.string()).optional().default([]), 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( .refine(
(data) => { (data) => {
if (data.mode === "host") { 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) // Check if it's a valid IP address using zod (v4 or v6)
const isValidIP = z const isValidIP = z
.union([z.ipv4(), z.ipv6()]) .union([z.ipv4(), z.ipv6()])
@@ -418,6 +474,7 @@ export const ClientResourceSchema = z
.refine( .refine(
(data) => { (data) => {
if (data.mode === "cidr") { 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) // Check if it's a valid CIDR (v4 or v6)
const isValidCIDR = z const isValidCIDR = z
.union([z.cidrv4(), z.cidrv6()]) .union([z.cidrv4(), z.cidrv6()])
@@ -435,19 +492,19 @@ export const ClientResourceSchema = z
export const ConfigSchema = z export const ConfigSchema = z
.object({ .object({
"proxy-resources": z "proxy-resources": z
.record(z.string(), ResourceSchema) .record(z.string(), PublicResourceSchema)
.optional() .optional()
.prefault({}), .prefault({}),
"public-resources": z "public-resources": z
.record(z.string(), ResourceSchema) .record(z.string(), PublicResourceSchema)
.optional() .optional()
.prefault({}), .prefault({}),
"client-resources": z "client-resources": z
.record(z.string(), ClientResourceSchema) .record(z.string(), PrivateResourceSchema)
.optional() .optional()
.prefault({}), .prefault({}),
"private-resources": z "private-resources": z
.record(z.string(), ClientResourceSchema) .record(z.string(), PrivateResourceSchema)
.optional() .optional()
.prefault({}), .prefault({}),
sites: z.record(z.string(), SiteSchema).optional().prefault({}) sites: z.record(z.string(), SiteSchema).optional().prefault({})
@@ -472,10 +529,13 @@ export const ConfigSchema = z
} }
return data as { return data as {
"proxy-resources": Record<string, z.infer<typeof ResourceSchema>>; "proxy-resources": Record<
string,
z.infer<typeof PublicResourceSchema>
>;
"client-resources": Record< "client-resources": Record<
string, string,
z.infer<typeof ClientResourceSchema> z.infer<typeof PrivateResourceSchema>
>; >;
sites: Record<string, z.infer<typeof SiteSchema>>; sites: Record<string, z.infer<typeof SiteSchema>>;
}; };
@@ -614,5 +674,5 @@ export const ConfigSchema = z
// Type inference from the schema // Type inference from the schema
export type Site = z.infer<typeof SiteSchema>; export type Site = z.infer<typeof SiteSchema>;
export type Target = z.infer<typeof TargetSchema>; export type Target = z.infer<typeof TargetSchema>;
export type Resource = z.infer<typeof ResourceSchema>; export type Resource = z.infer<typeof PublicResourceSchema>;
export type Config = z.infer<typeof ConfigSchema>; export type Config = z.infer<typeof ConfigSchema>;

View File

@@ -154,8 +154,19 @@ class AdaptiveCache {
keys(): string[] { keys(): string[] {
return localCache.keys(); 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 singleton instance
export const cache = new AdaptiveCache(); export const cache = new AdaptiveCache();
export const regionalCache = cache; // Alias for compatability with the private version
export default cache; export default cache;

View File

@@ -331,16 +331,8 @@ export async function calculateUserClientsForOrgs(
]; ];
// Get next available subnet // Get next available subnet
const newSubnet = await getNextAvailableClientSubnet( const { value: newSubnet, release: releaseSubnetLock } =
orgId, await getNextAvailableClientSubnet(orgId, transaction);
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 subnet = newSubnet.split("/")[0];
const updatedSubnet = `${subnet}/${org.subnet.split("/")[1]}`; const updatedSubnet = `${subnet}/${org.subnet.split("/")[1]}`;
@@ -370,6 +362,7 @@ export async function calculateUserClientsForOrgs(
.insert(clients) .insert(clients)
.values(newClientData) .values(newClientData)
.returning(); .returning();
await releaseSubnetLock();
existingClientCache.set( existingClientCache.set(
getOrgOlmKey(orgId, olm.olmId), getOrgOlmKey(orgId, olm.olmId),
newClient newClient

View File

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

View File

@@ -327,127 +327,145 @@ export function doCidrsOverlap(cidr1: string, cidr2: string): boolean {
export async function getNextAvailableClientSubnet( export async function getNextAvailableClientSubnet(
orgId: string, orgId: string,
transaction: Transaction | typeof db = db transaction: Transaction | typeof db = db
): Promise<string> { ): Promise<{ value: string; release: () => Promise<void> }> {
return await lockManager.withLock( const lockKey = `client-subnet-allocation:${orgId}`;
`client-subnet-allocation:${orgId}`, const acquired = await lockManager.acquireLockWithRetry(lockKey, 6000);
async () => { if (!acquired) {
const [org] = await transaction throw new Error(`Failed to acquire lock: ${lockKey}`);
.select() }
.from(orgs) const release = () => lockManager.releaseLock(lockKey);
.where(eq(orgs.orgId, orgId));
if (!org) { try {
throw new Error(`Organization with ID ${orgId} not found`); const [org] = await transaction
} .select()
.from(orgs)
.where(eq(orgs.orgId, orgId));
if (!org.subnet) { if (!org) {
throw new Error( throw new Error(`Organization with ID ${orgId} not found`);
`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.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( export async function getNextAvailableAliasAddress(
orgId: string, orgId: string,
trx: Transaction | typeof db = db trx: Transaction | typeof db = db
): Promise<string> { ): Promise<{ value: string; release: () => Promise<void> }> {
return await lockManager.withLock( const lockKey = `alias-address-allocation:${orgId}`;
`alias-address-allocation:${orgId}`, const acquired = await lockManager.acquireLockWithRetry(lockKey, 6000);
async () => { if (!acquired) {
const [org] = await trx throw new Error(`Failed to acquire lock: ${lockKey}`);
.select() }
.from(orgs) const release = () => lockManager.releaseLock(lockKey);
.where(eq(orgs.orgId, orgId));
if (!org) { try {
throw new Error(`Organization with ID ${orgId} not found`); const [org] = await trx
} .select()
.from(orgs)
.where(eq(orgs.orgId, orgId));
if (!org.subnet) { if (!org) {
throw new Error( throw new Error(`Organization with ID ${orgId} not found`);
`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.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> { export async function getNextAvailableOrgSubnet(): Promise<{
return await lockManager.withLock("org-subnet-allocation", async () => { value: string;
release: () => Promise<void>;
}> {
const lockKey = "org-subnet-allocation";
const acquired = await lockManager.acquireLockWithRetry(lockKey, 6000);
if (!acquired) {
throw new Error(`Failed to acquire lock: ${lockKey}`);
}
const release = () => lockManager.releaseLock(lockKey);
try {
const existingAddresses = await db const existingAddresses = await db
.select({ .select({
subnet: orgs.subnet subnet: orgs.subnet
@@ -466,8 +484,11 @@ export async function getNextAvailableOrgSubnet(): Promise<string> {
throw new Error("No available subnets remaining in space"); throw new Error("No available subnets remaining in space");
} }
return subnet; return { value: subnet, release };
}); } catch (e) {
await release();
throw e;
}
} }
export function generateRemoteSubnets( export function generateRemoteSubnets(
@@ -475,6 +496,8 @@ export function generateRemoteSubnets(
): string[] { ): string[] {
const remoteSubnets = allSiteResources const remoteSubnets = allSiteResources
.filter((sr) => { .filter((sr) => {
if (!sr.destination) return false;
if (sr.mode === "cidr") { if (sr.mode === "cidr") {
// check if its a valid CIDR using zod // check if its a valid CIDR using zod
const cidrSchema = z.union([z.cidrv4(), z.cidrv6()]); const cidrSchema = z.union([z.cidrv4(), z.cidrv6()]);
@@ -496,7 +519,7 @@ export function generateRemoteSubnets(
} }
return ""; // This should never be reached due to filtering, but satisfies TypeScript 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 // remove duplicates
return Array.from(new Set(remoteSubnets)); return Array.from(new Set(remoteSubnets));
} }
@@ -581,7 +604,7 @@ export function generateSubnetProxyTargets(
targets.push({ targets.push({
sourcePrefix: clientPrefix, sourcePrefix: clientPrefix,
destPrefix: `${siteResource.aliasAddress}/32`, destPrefix: `${siteResource.aliasAddress}/32`,
rewriteTo: destination, rewriteTo: destination!,
portRange, portRange,
disableIcmp disableIcmp
}); });
@@ -589,7 +612,7 @@ export function generateSubnetProxyTargets(
} else if (siteResource.mode == "cidr") { } else if (siteResource.mode == "cidr") {
targets.push({ targets.push({
sourcePrefix: clientPrefix, sourcePrefix: clientPrefix,
destPrefix: siteResource.destination, destPrefix: siteResource.destination!,
portRange, portRange,
disableIcmp disableIcmp
}); });
@@ -671,7 +694,7 @@ export async function generateSubnetProxyTargetV2(
targets.push({ targets.push({
sourcePrefixes: [], sourcePrefixes: [],
destPrefix: `${siteResource.aliasAddress}/32`, destPrefix: `${siteResource.aliasAddress}/32`,
rewriteTo: destination, rewriteTo: destination!,
portRange, portRange,
disableIcmp, disableIcmp,
resourceId: siteResource.siteResourceId resourceId: siteResource.siteResourceId
@@ -680,7 +703,7 @@ export async function generateSubnetProxyTargetV2(
} else if (siteResource.mode == "cidr") { } else if (siteResource.mode == "cidr") {
targets.push({ targets.push({
sourcePrefixes: [], sourcePrefixes: [],
destPrefix: siteResource.destination, destPrefix: siteResource.destination!,
portRange, portRange,
disableIcmp, disableIcmp,
resourceId: siteResource.siteResourceId resourceId: siteResource.siteResourceId
@@ -738,7 +761,7 @@ export async function generateSubnetProxyTargetV2(
protocol: siteResource.ssl ? "https" : "http", protocol: siteResource.ssl ? "https" : "http",
httpTargets: [ httpTargets: [
{ {
destAddr: siteResource.destination, destAddr: siteResource.destination!,
destPort: siteResource.destinationPort, destPort: siteResource.destinationPort,
scheme: siteResource.scheme scheme: siteResource.scheme
} }

View File

@@ -890,6 +890,9 @@ async function handleSubnetProxyTargetUpdates(
} }
for (const client of removedClients) { for (const client of removedClients) {
if (!siteResource.destination) {
continue;
}
// Check if this client still has access to another resource // Check if this client still has access to another resource
// on this specific site with the same destination. We scope // on this specific site with the same destination. We scope
// by siteId (via siteNetworks) rather than networkId because // by siteId (via siteNetworks) rather than networkId because
@@ -1563,6 +1566,9 @@ async function handleMessagesForClientResources(
} }
try { try {
if (!resource.destination) {
continue;
}
// Check if this client still has access to another resource // Check if this client still has access to another resource
// on this specific site with the same destination. We scope // on this specific site with the same destination. We scope
// by siteId (via siteNetworks) rather than networkId because // by siteId (via siteNetworks) rather than networkId because
@@ -1826,3 +1832,77 @@ export async function verifyClientAssociationsCache(
extraSiteIds: extraSiteIds.sort((a, b) => a - b) extraSiteIds: extraSiteIds.sort((a, b) => a - b)
}; };
} }
// cleanupSiteAssociations efficiently removes all client associations for a
// site that is being deleted. Instead of calling
// rebuildClientAssociationsFromSiteResource once per site resource (which is
// O(resources) in DB round-trips and message fan-out), this function performs
// a single bulk lookup of affected clients and site resources, deletes all
// cache rows at once, and fires all peer/proxy removal messages in parallel.
//
// The caller is responsible for deleting the site row itself (and for sending
// the newt/wg/terminate signal to the newt process).
export async function cleanupSiteAssociations(
site: Site,
trx: Transaction | typeof db = db
): Promise<void> {
const siteId = site.siteId;
logger.debug(`cleanupSiteAssociations: START siteId=${siteId}`);
// 1. Find every client currently cached against this site.
const cachedSiteClientRows = await trx
.select({ clientId: clientSitesAssociationsCache.clientId })
.from(clientSitesAssociationsCache)
.where(eq(clientSitesAssociationsCache.siteId, siteId));
const cachedClientIds = cachedSiteClientRows.map((r) => r.clientId);
// 2. Load full client details (needed for WireGuard public-key references).
const allClients =
cachedClientIds.length > 0
? await trx
.select({
clientId: clients.clientId,
pubKey: clients.pubKey,
subnet: clients.subnet
})
.from(clients)
.where(inArray(clients.clientId, cachedClientIds))
: [];
// 6. Bulk-delete all cache entries for this site. Do this before sending
// destination-update messages so updateClientSiteDestinations computes
// the correct (post-deletion) set of destinations.
await trx
.delete(clientSitesAssociationsCache)
.where(eq(clientSitesAssociationsCache.siteId, siteId));
logger.debug(
`cleanupSiteAssociations: siteId=${siteId} cache cleared. clients=${allClients.length}`
);
// 7. Fire all removal messages in parallel.
const jobs: Promise<any>[] = [];
for (const client of allClients) {
// Tell each olm to drop the site's WireGuard peer.
if (site.publicKey) {
jobs.push(olmDeletePeer(client.clientId, siteId, site.publicKey));
}
// Recompute and push updated relay destinations (now excluding this site).
if (client.pubKey && client.subnet) {
jobs.push(updateClientSiteDestinations(client, trx));
}
}
await Promise.all(jobs).catch((error) => {
logger.error(
`cleanupSiteAssociations: error sending cleanup messages for siteId=${siteId}:`,
error
);
});
logger.debug(`cleanupSiteAssociations: DONE siteId=${siteId}`);
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -4,6 +4,7 @@ import { resourceAccessToken, resources, apiKeyOrg } from "@server/db";
import { and, eq } from "drizzle-orm"; import { and, eq } from "drizzle-orm";
import createHttpError from "http-errors"; import createHttpError from "http-errors";
import HttpCode from "@server/types/HttpCode"; import HttpCode from "@server/types/HttpCode";
import { getFirstString } from "@server/lib/requestParams";
export async function verifyApiKeyAccessTokenAccess( export async function verifyApiKeyAccessTokenAccess(
req: Request, req: Request,
@@ -12,7 +13,7 @@ export async function verifyApiKeyAccessTokenAccess(
) { ) {
try { try {
const apiKey = req.apiKey; const apiKey = req.apiKey;
const accessTokenId = req.params.accessTokenId; const accessTokenId = getFirstString(req.params.accessTokenId);
if (!apiKey) { if (!apiKey) {
return next( return next(
@@ -20,6 +21,12 @@ export async function verifyApiKeyAccessTokenAccess(
); );
} }
if (!accessTokenId) {
return next(
createHttpError(HttpCode.BAD_REQUEST, "Invalid access token ID")
);
}
const [accessToken] = await db const [accessToken] = await db
.select() .select()
.from(resourceAccessToken) .from(resourceAccessToken)

View File

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

View File

@@ -3,6 +3,7 @@ import { db, domains, orgDomains, apiKeyOrg } from "@server/db";
import { and, eq } from "drizzle-orm"; import { and, eq } from "drizzle-orm";
import createHttpError from "http-errors"; import createHttpError from "http-errors";
import HttpCode from "@server/types/HttpCode"; import HttpCode from "@server/types/HttpCode";
import { getFirstString } from "@server/lib/requestParams";
export async function verifyApiKeyDomainAccess( export async function verifyApiKeyDomainAccess(
req: Request, req: Request,
@@ -12,8 +13,10 @@ export async function verifyApiKeyDomainAccess(
try { try {
const apiKey = req.apiKey; const apiKey = req.apiKey;
const domainId = const domainId =
req.params.domainId || req.body.domainId || req.query.domainId; getFirstString(req.params.domainId) ||
const orgId = req.params.orgId; getFirstString(req.body.domainId) ||
getFirstString(req.query.domainId);
const orgId = getFirstString(req.params.orgId);
if (!apiKey) { if (!apiKey) {
return next( return next(
@@ -27,6 +30,12 @@ export async function verifyApiKeyDomainAccess(
); );
} }
if (!orgId) {
return next(
createHttpError(HttpCode.BAD_REQUEST, "Invalid organization ID")
);
}
if (apiKey.isRoot) { if (apiKey.isRoot) {
// Root keys can access any domain in any org // Root keys can access any domain in any org
return next(); return next();

View File

@@ -4,6 +4,7 @@ import { idp, idpOrg, apiKeyOrg } from "@server/db";
import { and, eq } from "drizzle-orm"; import { and, eq } from "drizzle-orm";
import createHttpError from "http-errors"; import createHttpError from "http-errors";
import HttpCode from "@server/types/HttpCode"; import HttpCode from "@server/types/HttpCode";
import { getFirstString } from "@server/lib/requestParams";
export async function verifyApiKeyIdpAccess( export async function verifyApiKeyIdpAccess(
req: Request, req: Request,
@@ -12,8 +13,12 @@ export async function verifyApiKeyIdpAccess(
) { ) {
try { try {
const apiKey = req.apiKey; const apiKey = req.apiKey;
const idpId = req.params.idpId || req.body.idpId || req.query.idpId; const idpIdRaw =
const orgId = req.params.orgId; getFirstString(req.params.idpId) ||
getFirstString(req.body.idpId) ||
getFirstString(req.query.idpId);
const idpId = Number.parseInt(idpIdRaw ?? "", 10);
const orgId = getFirstString(req.params.orgId);
if (!apiKey) { if (!apiKey) {
return next( return next(
@@ -27,7 +32,7 @@ export async function verifyApiKeyIdpAccess(
); );
} }
if (!idpId) { if (Number.isNaN(idpId)) {
return next( return next(
createHttpError(HttpCode.BAD_REQUEST, "Invalid IDP ID") createHttpError(HttpCode.BAD_REQUEST, "Invalid IDP ID")
); );

View File

@@ -4,6 +4,7 @@ import { apiKeyOrg } from "@server/db";
import { and, eq } from "drizzle-orm"; import { and, eq } from "drizzle-orm";
import createHttpError from "http-errors"; import createHttpError from "http-errors";
import HttpCode from "@server/types/HttpCode"; import HttpCode from "@server/types/HttpCode";
import { getFirstString } from "@server/lib/requestParams";
export async function verifyApiKeyOrgAccess( export async function verifyApiKeyOrgAccess(
req: Request, req: Request,
@@ -12,7 +13,7 @@ export async function verifyApiKeyOrgAccess(
) { ) {
try { try {
const apiKeyId = req.apiKey?.apiKeyId; const apiKeyId = req.apiKey?.apiKeyId;
const orgId = req.params.orgId; const orgId = getFirstString(req.params.orgId);
if (!apiKeyId) { if (!apiKeyId) {
return next( return next(
@@ -45,7 +46,7 @@ export async function verifyApiKeyOrgAccess(
} }
if (!req.apiKeyOrg) { if (!req.apiKeyOrg) {
next( return next(
createHttpError( createHttpError(
HttpCode.FORBIDDEN, HttpCode.FORBIDDEN,
"Key does not have access to this organization" "Key does not have access to this organization"

View File

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

View File

@@ -4,6 +4,7 @@ import { siteResources, apiKeyOrg } from "@server/db";
import { and, eq } from "drizzle-orm"; import { and, eq } from "drizzle-orm";
import createHttpError from "http-errors"; import createHttpError from "http-errors";
import HttpCode from "@server/types/HttpCode"; import HttpCode from "@server/types/HttpCode";
import { getFirstString } from "@server/lib/requestParams";
export async function verifyApiKeySiteResourceAccess( export async function verifyApiKeySiteResourceAccess(
req: Request, req: Request,
@@ -12,7 +13,8 @@ export async function verifyApiKeySiteResourceAccess(
) { ) {
try { try {
const apiKey = req.apiKey; const apiKey = req.apiKey;
const siteResourceId = parseInt(req.params.siteResourceId); const siteResourceIdRaw = getFirstString(req.params.siteResourceId);
const siteResourceId = Number.parseInt(siteResourceIdRaw ?? "", 10);
if (!apiKey) { if (!apiKey) {
return next( return next(
@@ -20,7 +22,7 @@ export async function verifyApiKeySiteResourceAccess(
); );
} }
if (!siteResourceId) { if (Number.isNaN(siteResourceId)) {
return next( return next(
createHttpError( createHttpError(
HttpCode.BAD_REQUEST, HttpCode.BAD_REQUEST,

View File

@@ -4,6 +4,7 @@ import { resources, targets, apiKeyOrg } from "@server/db";
import { and, eq } from "drizzle-orm"; import { and, eq } from "drizzle-orm";
import createHttpError from "http-errors"; import createHttpError from "http-errors";
import HttpCode from "@server/types/HttpCode"; import HttpCode from "@server/types/HttpCode";
import { getFirstString } from "@server/lib/requestParams";
export async function verifyApiKeyTargetAccess( export async function verifyApiKeyTargetAccess(
req: Request, req: Request,
@@ -12,7 +13,8 @@ export async function verifyApiKeyTargetAccess(
) { ) {
try { try {
const apiKey = req.apiKey; const apiKey = req.apiKey;
const targetId = parseInt(req.params.targetId); const targetIdRaw = getFirstString(req.params.targetId);
const targetId = Number.parseInt(targetIdRaw ?? "", 10);
if (!apiKey) { if (!apiKey) {
return next( return next(
@@ -20,7 +22,7 @@ export async function verifyApiKeyTargetAccess(
); );
} }
if (isNaN(targetId)) { if (Number.isNaN(targetId)) {
return next( return next(
createHttpError(HttpCode.BAD_REQUEST, "Invalid target ID") createHttpError(HttpCode.BAD_REQUEST, "Invalid target ID")
); );

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,104 @@
import { Response, NextFunction } from "express";
import { db } from "@server/db";
import { users } from "@server/db";
import { eq, or } from "drizzle-orm";
import createHttpError from "http-errors";
import HttpCode from "@server/types/HttpCode";
import { verifySession } from "@server/auth/sessions/verifySession";
import { getUserOrgRoleIds } from "@server/lib/userOrgRoles";
/**
* Middleware that populates req.user from either:
* 1. A valid session cookie (normal authenticated flow), or
* 2. Badger-injected headers: Remote-User-Id, Remote-User (username), Remote-Email
*
* If an orgId is present in req.params, req.userOrgRoleIds is also populated.
*
* If neither source yields a user, returns 401.
* If header-based lookup matches more than one user, returns 400.
*/
export const verifyUserFromSessionOrHeadersMiddleware = async (
req: any,
res: Response,
next: NextFunction
) => {
// 1. Try session-based auth first
if (!req.user) {
try {
const { session, user } = await verifySession(req);
if (session && user) {
const rows = await db
.select()
.from(users)
.where(eq(users.userId, user.userId));
if (rows[0]) {
req.user = rows[0];
req.session = session;
}
}
} catch {
// session lookup failure is not fatal; fall through to header auth
}
}
// 2. Fall back to Badger-injected headers
if (!req.user) {
const userId = req.headers["remote-user-id"] as string | undefined;
const username = req.headers["remote-user"] as string | undefined;
const email = req.headers["remote-email"] as string | undefined;
if (!userId && !username && !email) {
return next(
createHttpError(HttpCode.UNAUTHORIZED, "User not authenticated")
);
}
let foundUsers;
if (userId) {
// Most reliable: look up directly by ID
foundUsers = await db
.select()
.from(users)
.where(eq(users.userId, userId));
} else {
// Fall back to username / email (may be absent depending on badger version)
const conditions = [];
if (username) conditions.push(eq(users.username, username));
if (email) conditions.push(eq(users.email, email));
foundUsers = await db
.select()
.from(users)
.where(or(...conditions));
}
if (!foundUsers || foundUsers.length === 0) {
return next(
createHttpError(HttpCode.UNAUTHORIZED, "User not found")
);
}
if (foundUsers.length > 1) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
"Multiple users found matching the provided credentials"
)
);
}
req.user = foundUsers[0];
}
// 3. Populate userOrgRoleIds if an orgId is available in route params
if (req.user && req.params?.orgId && !req.userOrgRoleIds) {
req.userOrgRoleIds = await getUserOrgRoleIds(
req.user.userId,
req.params.orgId
);
}
next();
};

View File

@@ -4,6 +4,7 @@ import { userOrgs } from "@server/db";
import { and, eq } from "drizzle-orm"; import { and, eq } from "drizzle-orm";
import createHttpError from "http-errors"; import createHttpError from "http-errors";
import HttpCode from "@server/types/HttpCode"; import HttpCode from "@server/types/HttpCode";
import { getFirstString } from "@server/lib/requestParams";
export async function verifyUserIsOrgOwner( export async function verifyUserIsOrgOwner(
req: Request, req: Request,
@@ -11,7 +12,7 @@ export async function verifyUserIsOrgOwner(
next: NextFunction next: NextFunction
) { ) {
const userId = req.user!.userId; const userId = req.user!.userId;
const orgId = req.params.orgId; const orgId = getFirstString(req.params.orgId);
if (!userId) { if (!userId) {
return next( return next(

View File

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

View File

@@ -780,9 +780,9 @@ async function syncAcmeCerts(acmeJsonPath: string): Promise<void> {
} }
} }
logger.debug( // logger.debug(
`acmeCertSync: cert for ${mainDomain} covers ${allDomains.size} domain(s): ${[...allDomains].join(", ")}` // `acmeCertSync: cert for ${mainDomain} covers ${allDomains.size} domain(s): ${[...allDomains].join(", ")}`
); // );
for (const domain of allDomains) { for (const domain of allDomains) {
try { try {

View File

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

View File

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

View File

@@ -24,7 +24,8 @@ import { LogStreamingManager } from "./LogStreamingManager";
*/ */
export const logStreamingManager = new LogStreamingManager(); export const logStreamingManager = new LogStreamingManager();
if (build != "saas") { // this is handled separately in the saas build, so we don't want to start it here if (build !== "saas") {
// this is handled separately in the saas build, so we don't want to start it here
logStreamingManager.start(); logStreamingManager.start();
} }

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -25,7 +25,7 @@ export function verifyValidSubscription(tiers: Tier[]) {
next: NextFunction next: NextFunction
): Promise<any> { ): Promise<any> {
try { try {
if (build != "saas") { if (build !== "saas") {
return next(); return next();
} }

View File

@@ -93,6 +93,20 @@ export const queryAccessAuditLogsCombined = queryAccessAuditLogsQuery.merge(
); );
type Q = z.infer<typeof queryAccessAuditLogsCombined>; type Q = z.infer<typeof queryAccessAuditLogsCombined>;
function sortNamedFilterOptions<T extends { id: number; name: string | null }>(
items: T[]
): T[] {
return [...items].sort((a, b) => {
const nameA = a.name ?? "";
const nameB = b.name ?? "";
if (nameA < nameB) return -1;
if (nameA > nameB) return 1;
return a.id - b.id;
});
}
function getWhere(data: Q) { function getWhere(data: Q) {
return and( return and(
gt(accessAuditLog.timestamp, data.timeStart), gt(accessAuditLog.timestamp, data.timeStart),
@@ -308,7 +322,7 @@ async function queryUniqueFilterAttributes(
actors: uniqueActors actors: uniqueActors
.map((row) => row.actor) .map((row) => row.actor)
.filter((actor): actor is string => actor !== null), .filter((actor): actor is string => actor !== null),
resources: resourcesWithNames, resources: sortNamedFilterOptions(resourcesWithNames),
locations: uniqueLocations locations: uniqueLocations
.map((row) => row.locations) .map((row) => row.locations)
.filter((location): location is string => location !== null) .filter((location): location is string => location !== null)

View File

@@ -107,6 +107,20 @@ export const queryConnectionAuditLogsCombined =
queryConnectionAuditLogsQuery.merge(queryConnectionAuditLogsParams); queryConnectionAuditLogsQuery.merge(queryConnectionAuditLogsParams);
type Q = z.infer<typeof queryConnectionAuditLogsCombined>; type Q = z.infer<typeof queryConnectionAuditLogsCombined>;
function sortNamedFilterOptions<T extends { id: number; name: string | null }>(
items: T[]
): T[] {
return [...items].sort((a, b) => {
const nameA = a.name ?? "";
const nameB = b.name ?? "";
if (nameA < nameB) return -1;
if (nameA > nameB) return 1;
return a.id - b.id;
});
}
function getWhere(data: Q) { function getWhere(data: Q) {
return and( return and(
gt(connectionAuditLog.startedAt, data.timeStart), gt(connectionAuditLog.startedAt, data.timeStart),
@@ -425,7 +439,7 @@ async function queryUniqueFilterAttributes(
.map((row) => row.destAddr) .map((row) => row.destAddr)
.filter((addr): addr is string => addr !== null), .filter((addr): addr is string => addr !== null),
clients: clientsWithNames, clients: clientsWithNames,
resources: resourcesWithNames, resources: sortNamedFilterOptions(resourcesWithNames),
users: usersWithEmails users: usersWithEmails
}; };
} }

View File

@@ -0,0 +1,187 @@
/*
* This file is part of a proprietary work.
*
* Copyright (c) 2025-2026 Fossorial, Inc.
* All rights reserved.
*
* This file is licensed under the Fossorial Commercial License.
* You may not use this file except in compliance with the License.
* Unauthorized use, copying, modification, or distribution is strictly prohibited.
*
* This file is not licensed under the AGPLv3.
*/
import { Request, Response, NextFunction } from "express";
import { z } from "zod";
import {
browserGatewayTarget,
BrowserGatewayTarget,
db,
newts,
resources,
sites
} from "@server/db";
import { eq, and } from "drizzle-orm";
import response from "@server/lib/response";
import HttpCode from "@server/types/HttpCode";
import createHttpError from "http-errors";
import logger from "@server/logger";
import { fromError } from "zod-validation-error";
import { OpenAPITags, registry } from "@server/openApi";
import { encrypt } from "@server/lib/crypto";
import config from "@server/lib/config";
import { sendBrowserGatewayTargets } from "@server/routers/newt/targets";
import { generateId } from "@server/auth/sessions/app";
const paramsSchema = z.strictObject({
orgId: z.string().nonempty(),
resourceId: z.string().transform(Number).pipe(z.number().int().positive())
});
const bodySchema = z.strictObject({
siteId: z.number().int().positive(),
type: z.enum(["ssh", "rdp", "vnc"]),
destination: z.string().nonempty(),
destinationPort: z.number().int().min(1).max(65535)
});
export type CreateBrowserGatewayTargetResponse = BrowserGatewayTarget;
registry.registerPath({
method: "put",
path: "/org/{orgId}/resource/{resourceId}/browser-gateway-target",
description: "Create a browser gateway target for a resource.",
tags: [OpenAPITags.Org],
request: {
params: paramsSchema,
body: {
content: {
"application/json": {
schema: bodySchema
}
}
}
},
responses: {}
});
export async function createBrowserGatewayTarget(
req: Request,
res: Response,
next: NextFunction
): Promise<any> {
try {
const parsedParams = paramsSchema.safeParse(req.params);
if (!parsedParams.success) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
fromError(parsedParams.error).toString()
)
);
}
const { orgId, resourceId } = parsedParams.data;
const parsedBody = bodySchema.safeParse(req.body);
if (!parsedBody.success) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
fromError(parsedBody.error).toString()
)
);
}
const { siteId, type, destination, destinationPort } = parsedBody.data;
const [resource] = await db
.select()
.from(resources)
.where(
and(
eq(resources.resourceId, resourceId),
eq(resources.orgId, orgId)
)
)
.limit(1);
if (!resource) {
return next(
createHttpError(
HttpCode.NOT_FOUND,
`Resource with ID ${resourceId} not found in organization ${orgId}`
)
);
}
const [site] = await db
.select()
.from(sites)
.where(and(eq(sites.siteId, siteId), eq(sites.orgId, orgId)))
.limit(1);
if (!site) {
return next(
createHttpError(
HttpCode.NOT_FOUND,
`Site with ID ${siteId} not found in organization ${orgId}`
)
);
}
const plainToken = generateId(48);
const encryptedToken = encrypt(
plainToken,
config.getRawConfig().server.secret!
);
const [record] = await db
.insert(browserGatewayTarget)
.values({
resourceId,
siteId,
type,
destination,
destinationPort,
authToken: encryptedToken
})
.returning();
if (site.type === "newt") {
const [newt] = await db
.select()
.from(newts)
.where(eq(newts.siteId, siteId))
.limit(1);
if (newt) {
await sendBrowserGatewayTargets(
newt.newtId,
[record],
newt.version
);
}
}
logger.info(
`Created browser gateway target ${record.browserGatewayTargetId} for resource ${resourceId}`
);
return response<CreateBrowserGatewayTargetResponse>(res, {
data: record,
success: true,
error: false,
message: "Browser gateway target created successfully",
status: HttpCode.CREATED
});
} catch (error) {
logger.error(error);
return next(
createHttpError(
HttpCode.INTERNAL_SERVER_ERROR,
"Failed to create browser gateway target"
)
);
}
}

View File

@@ -0,0 +1,130 @@
/*
* This file is part of a proprietary work.
*
* Copyright (c) 2025-2026 Fossorial, Inc.
* All rights reserved.
*
* This file is licensed under the Fossorial Commercial License.
* You may not use this file except in compliance with the License.
* Unauthorized use, copying, modification, or distribution is strictly prohibited.
*
* This file is not licensed under the AGPLv3.
*/
import { Request, Response, NextFunction } from "express";
import { z } from "zod";
import { browserGatewayTarget, db, newts, sites } from "@server/db";
import { eq, and } from "drizzle-orm";
import response from "@server/lib/response";
import HttpCode from "@server/types/HttpCode";
import createHttpError from "http-errors";
import logger from "@server/logger";
import { fromError } from "zod-validation-error";
import { OpenAPITags, registry } from "@server/openApi";
import { removeBrowserGatewayTarget } from "@server/routers/newt/targets";
const paramsSchema = z.strictObject({
orgId: z.string().nonempty(),
browserGatewayTargetId: z
.string()
.transform(Number)
.pipe(z.number().int().positive())
});
registry.registerPath({
method: "delete",
path: "/org/{orgId}/browser-gateway-target/{browserGatewayTargetId}",
description: "Delete a browser gateway target.",
tags: [OpenAPITags.Org],
request: {
params: paramsSchema
},
responses: {}
});
export async function deleteBrowserGatewayTarget(
req: Request,
res: Response,
next: NextFunction
): Promise<any> {
try {
const parsedParams = paramsSchema.safeParse(req.params);
if (!parsedParams.success) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
fromError(parsedParams.error).toString()
)
);
}
const { orgId, browserGatewayTargetId } = parsedParams.data;
const [existing] = await db
.select({ bgt: browserGatewayTarget, site: sites })
.from(browserGatewayTarget)
.innerJoin(sites, eq(sites.siteId, browserGatewayTarget.siteId))
.where(
and(
eq(
browserGatewayTarget.browserGatewayTargetId,
browserGatewayTargetId
),
eq(sites.orgId, orgId)
)
)
.limit(1);
if (!existing) {
return next(
createHttpError(
HttpCode.NOT_FOUND,
`Browser gateway target with ID ${browserGatewayTargetId} not found`
)
);
}
await db
.delete(browserGatewayTarget)
.where(
eq(
browserGatewayTarget.browserGatewayTargetId,
browserGatewayTargetId
)
);
if (existing.site.type === "newt") {
const [newt] = await db
.select()
.from(newts)
.where(eq(newts.siteId, existing.bgt.siteId))
.limit(1);
if (newt) {
await removeBrowserGatewayTarget(
newt.newtId,
browserGatewayTargetId,
newt.version
);
}
}
logger.info(`Deleted browser gateway target ${browserGatewayTargetId}`);
return response(res, {
data: null,
success: true,
error: false,
message: "Browser gateway target deleted successfully",
status: HttpCode.OK
});
} catch (error) {
logger.error(error);
return next(
createHttpError(
HttpCode.INTERNAL_SERVER_ERROR,
"Failed to delete browser gateway target"
)
);
}
}

View File

@@ -0,0 +1,109 @@
/*
* This file is part of a proprietary work.
*
* Copyright (c) 2025-2026 Fossorial, Inc.
* All rights reserved.
*
* This file is licensed under the Fossorial Commercial License.
* You may not use this file except in compliance with the License.
* Unauthorized use, copying, modification, or distribution is strictly prohibited.
*
* This file is not licensed under the AGPLv3.
*/
import { Request, Response, NextFunction } from "express";
import { z } from "zod";
import {
browserGatewayTarget,
BrowserGatewayTarget,
db,
sites
} from "@server/db";
import { eq, and } from "drizzle-orm";
import response from "@server/lib/response";
import HttpCode from "@server/types/HttpCode";
import createHttpError from "http-errors";
import logger from "@server/logger";
import { fromError } from "zod-validation-error";
import { OpenAPITags, registry } from "@server/openApi";
const paramsSchema = z.strictObject({
orgId: z.string().nonempty(),
browserGatewayTargetId: z
.string()
.transform(Number)
.pipe(z.number().int().positive())
});
export type GetBrowserGatewayTargetResponse = BrowserGatewayTarget;
registry.registerPath({
method: "get",
path: "/org/{orgId}/browser-gateway-target/{browserGatewayTargetId}",
description: "Get a browser gateway target.",
tags: [OpenAPITags.Org],
request: {
params: paramsSchema
},
responses: {}
});
export async function getBrowserGatewayTarget(
req: Request,
res: Response,
next: NextFunction
): Promise<any> {
try {
const parsedParams = paramsSchema.safeParse(req.params);
if (!parsedParams.success) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
fromError(parsedParams.error).toString()
)
);
}
const { orgId, browserGatewayTargetId } = parsedParams.data;
const [result] = await db
.select({ bgt: browserGatewayTarget })
.from(browserGatewayTarget)
.innerJoin(sites, eq(sites.siteId, browserGatewayTarget.siteId))
.where(
and(
eq(
browserGatewayTarget.browserGatewayTargetId,
browserGatewayTargetId
),
eq(sites.orgId, orgId)
)
)
.limit(1);
if (!result) {
return next(
createHttpError(
HttpCode.NOT_FOUND,
`Browser gateway target with ID ${browserGatewayTargetId} not found`
)
);
}
return response<GetBrowserGatewayTargetResponse>(res, {
data: result.bgt,
success: true,
error: false,
message: "Browser gateway target retrieved successfully",
status: HttpCode.OK
});
} catch (error) {
logger.error(error);
return next(
createHttpError(
HttpCode.INTERNAL_SERVER_ERROR,
"Failed to retrieve browser gateway target"
)
);
}
}

View File

@@ -0,0 +1,112 @@
/*
* This file is part of a proprietary work.
*
* Copyright (c) 2025-2026 Fossorial, Inc.
* All rights reserved.
*
* This file is licensed under the Fossorial Commercial License.
* You may not use this file except in compliance with the License.
* Unauthorized use, copying, modification, or distribution is strictly prohibited.
*
* This file is not licensed under the AGPLv3.
*/
import { Request, Response, NextFunction } from "express";
import { z } from "zod";
import { browserGatewayTarget, db } from "@server/db";
import { resources, targets } from "@server/db";
import { eq } from "drizzle-orm";
import response from "@server/lib/response";
import HttpCode from "@server/types/HttpCode";
import createHttpError from "http-errors";
import { fromError } from "zod-validation-error";
import logger from "@server/logger";
import { decrypt } from "@server/lib/crypto";
import config from "@server/lib/config";
import { GetBrowserTargetResponse } from "@server/routers/browserGatewayTarget";
const getBrowserTargetSchema = z
.object({
fullDomain: z.string().min(1, "fullDomain is required")
})
.strict();
export async function getBrowserTarget(
req: Request,
res: Response,
next: NextFunction
): Promise<any> {
try {
const parsed = getBrowserTargetSchema.safeParse(req.query);
if (!parsed.success) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
fromError(parsed.error).toString()
)
);
}
const { fullDomain } = parsed.data;
logger.info(`Retrieving browser target for domain: ${fullDomain}`);
const [browserTarget] = await db
.select({
destination: browserGatewayTarget.destination,
destinationPort: browserGatewayTarget.destinationPort,
authToken: browserGatewayTarget.authToken,
resourceId: resources.resourceId,
niceId: resources.niceId,
orgId: resources.orgId,
pamMode: resources.pamMode,
authDaemonMode: resources.authDaemonMode
})
.from(browserGatewayTarget)
.innerJoin(
resources,
eq(browserGatewayTarget.resourceId, resources.resourceId)
)
.where(eq(resources.fullDomain, fullDomain))
.limit(1);
const decryptedAuthToken = decrypt(
browserTarget.authToken,
config.getRawConfig().server.secret!
);
if (!browserTarget) {
return next(
createHttpError(
HttpCode.NOT_FOUND,
"No resource found for this domain"
)
);
}
return response<GetBrowserTargetResponse>(res, {
data: {
ip: browserTarget.destination,
port: browserTarget.destinationPort,
authToken: decryptedAuthToken,
pamMode: browserTarget.pamMode,
authDaemonMode: browserTarget.authDaemonMode,
orgId: browserTarget.orgId,
resourceId: browserTarget.resourceId,
niceId: browserTarget.niceId
},
success: true,
error: false,
message: "Browser target retrieved successfully",
status: HttpCode.OK
});
} catch (error) {
logger.error(error);
return next(
createHttpError(
HttpCode.INTERNAL_SERVER_ERROR,
"An error occurred while retrieving the browser target"
)
);
}
}

View File

@@ -0,0 +1,19 @@
/*
* This file is part of a proprietary work.
*
* Copyright (c) 2025-2026 Fossorial, Inc.
* All rights reserved.
*
* This file is licensed under the Fossorial Commercial License.
* You may not use this file except in compliance with the License.
* Unauthorized use, copying, modification, or distribution is strictly prohibited.
*
* This file is not licensed under the AGPLv3.
*/
export * from "./createBrowserGatewayTarget";
export * from "./updateBrowserGatewayTarget";
export * from "./deleteBrowserGatewayTarget";
export * from "./getBrowserGatewayTarget";
export * from "./listBrowserGatewayTargets";
export * from "./getBrowserTarget";

View File

@@ -0,0 +1,148 @@
/*
* This file is part of a proprietary work.
*
* Copyright (c) 2025-2026 Fossorial, Inc.
* All rights reserved.
*
* This file is licensed under the Fossorial Commercial License.
* You may not use this file except in compliance with the License.
* Unauthorized use, copying, modification, or distribution is strictly prohibited.
*
* This file is not licensed under the AGPLv3.
*/
import { Request, Response, NextFunction } from "express";
import { z } from "zod";
import {
browserGatewayTarget,
BrowserGatewayTarget,
db,
resources,
sites
} from "@server/db";
import { eq, and } from "drizzle-orm";
import response from "@server/lib/response";
import HttpCode from "@server/types/HttpCode";
import createHttpError from "http-errors";
import logger from "@server/logger";
import { fromError } from "zod-validation-error";
import { OpenAPITags, registry } from "@server/openApi";
const paramsSchema = z.strictObject({
orgId: z.string().nonempty(),
resourceId: z.string().transform(Number).pipe(z.number().int().positive())
});
const querySchema = z.object({
limit: z
.string()
.optional()
.default("1000")
.transform(Number)
.pipe(z.number().int().positive()),
offset: z
.string()
.optional()
.default("0")
.transform(Number)
.pipe(z.number().int().nonnegative())
});
export type ListBrowserGatewayTargetsResponse = {
targets: BrowserGatewayTarget[];
total: number;
limit: number;
offset: number;
};
registry.registerPath({
method: "get",
path: "/org/{orgId}/resource/{resourceId}/browser-gateway-targets",
description: "List browser gateway targets for a resource.",
tags: [OpenAPITags.Org],
request: {
params: paramsSchema,
query: querySchema
},
responses: {}
});
export async function listBrowserGatewayTargets(
req: Request,
res: Response,
next: NextFunction
): Promise<any> {
try {
const parsedParams = paramsSchema.safeParse(req.params);
if (!parsedParams.success) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
fromError(parsedParams.error).toString()
)
);
}
const { orgId, resourceId } = parsedParams.data;
const parsedQuery = querySchema.safeParse(req.query);
if (!parsedQuery.success) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
fromError(parsedQuery.error).toString()
)
);
}
const { limit, offset } = parsedQuery.data;
const [resource] = await db
.select()
.from(resources)
.where(
and(
eq(resources.resourceId, resourceId),
eq(resources.orgId, orgId)
)
)
.limit(1);
if (!resource) {
return next(
createHttpError(
HttpCode.NOT_FOUND,
`Resource with ID ${resourceId} not found in organization ${orgId}`
)
);
}
const targets = await db
.select()
.from(browserGatewayTarget)
.where(eq(browserGatewayTarget.resourceId, resourceId))
.limit(limit)
.offset(offset);
return response<ListBrowserGatewayTargetsResponse>(res, {
data: {
targets: targets,
total: targets.length,
limit,
offset
},
success: true,
error: false,
message: "Browser gateway targets retrieved successfully",
status: HttpCode.OK
});
} catch (error) {
logger.error(error);
return next(
createHttpError(
HttpCode.INTERNAL_SERVER_ERROR,
"Failed to list browser gateway targets"
)
);
}
}

View File

@@ -0,0 +1,180 @@
/*
* This file is part of a proprietary work.
*
* Copyright (c) 2025-2026 Fossorial, Inc.
* All rights reserved.
*
* This file is licensed under the Fossorial Commercial License.
* You may not use this file except in compliance with the License.
* Unauthorized use, copying, modification, or distribution is strictly prohibited.
*
* This file is not licensed under the AGPLv3.
*/
import { Request, Response, NextFunction } from "express";
import { z } from "zod";
import {
browserGatewayTarget,
BrowserGatewayTarget,
db,
newts,
sites
} from "@server/db";
import { eq, and } from "drizzle-orm";
import response from "@server/lib/response";
import HttpCode from "@server/types/HttpCode";
import createHttpError from "http-errors";
import logger from "@server/logger";
import { fromError } from "zod-validation-error";
import { OpenAPITags, registry } from "@server/openApi";
import { sendBrowserGatewayTargets } from "@server/routers/newt/targets";
const paramsSchema = z.strictObject({
orgId: z.string().nonempty(),
browserGatewayTargetId: z
.string()
.transform(Number)
.pipe(z.number().int().positive())
});
const bodySchema = z.strictObject({
siteId: z.number().int().positive().optional(),
type: z.enum(["ssh", "rdp", "vnc"]).optional(),
destination: z.string().nonempty().optional(),
destinationPort: z.number().int().min(1).max(65535).optional()
});
export type UpdateBrowserGatewayTargetResponse = BrowserGatewayTarget;
registry.registerPath({
method: "post",
path: "/org/{orgId}/browser-gateway-target/{browserGatewayTargetId}",
description: "Update a browser gateway target.",
tags: [OpenAPITags.Org],
request: {
params: paramsSchema,
body: {
content: {
"application/json": {
schema: bodySchema
}
}
}
},
responses: {}
});
export async function updateBrowserGatewayTarget(
req: Request,
res: Response,
next: NextFunction
): Promise<any> {
try {
const parsedParams = paramsSchema.safeParse(req.params);
if (!parsedParams.success) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
fromError(parsedParams.error).toString()
)
);
}
const { orgId, browserGatewayTargetId } = parsedParams.data;
const parsedBody = bodySchema.safeParse(req.body);
if (!parsedBody.success) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
fromError(parsedBody.error).toString()
)
);
}
const { siteId, type, destination, destinationPort } = parsedBody.data;
const [existing] = await db
.select({ bgt: browserGatewayTarget, site: sites })
.from(browserGatewayTarget)
.innerJoin(sites, eq(sites.siteId, browserGatewayTarget.siteId))
.where(
and(
eq(
browserGatewayTarget.browserGatewayTargetId,
browserGatewayTargetId
),
eq(sites.orgId, orgId)
)
)
.limit(1);
if (!existing) {
return next(
createHttpError(
HttpCode.NOT_FOUND,
`Browser gateway target with ID ${browserGatewayTargetId} not found`
)
);
}
const updateValues: Partial<BrowserGatewayTarget> = {};
if (siteId !== undefined) updateValues.siteId = siteId;
if (type !== undefined) updateValues.type = type;
if (destination !== undefined) updateValues.destination = destination;
if (destinationPort !== undefined)
updateValues.destinationPort = destinationPort;
const [updated] = await db
.update(browserGatewayTarget)
.set(updateValues)
.where(
eq(
browserGatewayTarget.browserGatewayTargetId,
browserGatewayTargetId
)
)
.returning();
const targetSiteId = siteId ?? existing.bgt.siteId;
const [site] = await db
.select()
.from(sites)
.where(eq(sites.siteId, targetSiteId))
.limit(1);
if (site && site.type === "newt") {
const [newt] = await db
.select()
.from(newts)
.where(eq(newts.siteId, targetSiteId))
.limit(1);
if (newt) {
await sendBrowserGatewayTargets(
newt.newtId,
[updated],
newt.version
);
}
}
logger.info(`Updated browser gateway target ${browserGatewayTargetId}`);
return response<UpdateBrowserGatewayTargetResponse>(res, {
data: updated,
success: true,
error: false,
message: "Browser gateway target updated successfully",
status: HttpCode.OK
});
} catch (error) {
logger.error(error);
return next(
createHttpError(
HttpCode.INTERNAL_SERVER_ERROR,
"Failed to update browser gateway target"
)
);
}
}

View File

@@ -31,7 +31,11 @@ import * as siteProvisioning from "#private/routers/siteProvisioning";
import * as eventStreamingDestination from "#private/routers/eventStreamingDestination"; import * as eventStreamingDestination from "#private/routers/eventStreamingDestination";
import * as alertRule from "#private/routers/alertRule"; import * as alertRule from "#private/routers/alertRule";
import * as healthChecks from "#private/routers/healthChecks"; import * as healthChecks from "#private/routers/healthChecks";
import * as browserGatewayTarget from "#private/routers/browserGatewayTarget";
import * as labels from "#private/routers/labels";
import * as client from "@server/routers/client"; import * as client from "@server/routers/client";
import * as resource from "#private/routers/resource";
import * as policy from "#private/routers/policy";
import { import {
verifyOrgAccess, verifyOrgAccess,
@@ -45,7 +49,8 @@ import {
verifyUserCanSetUserOrgRoles, verifyUserCanSetUserOrgRoles,
verifySiteProvisioningKeyAccess, verifySiteProvisioningKeyAccess,
verifyIsLoggedInUser, verifyIsLoggedInUser,
verifyAdmin verifyAdmin,
verifyResourcePolicyAccess
} from "@server/middlewares"; } from "@server/middlewares";
import { ActionsEnum } from "@server/auth/actions"; import { ActionsEnum } from "@server/auth/actions";
import { import {
@@ -383,6 +388,39 @@ authenticated.get(
approval.countApprovals approval.countApprovals
); );
authenticated.delete(
"/resource-policy/:resourcePolicyId",
verifyResourcePolicyAccess,
verifyValidLicense,
verifyValidSubscription(tierMatrix.resourcePolicies),
verifyLimits,
verifyUserHasAction(ActionsEnum.deleteResourcePolicy),
logActionAudit(ActionsEnum.deleteResourcePolicy),
policy.deleteResourcePolicy
);
authenticated.get(
"/org/:orgId/resource-policies",
verifyValidLicense,
verifyValidSubscription(tierMatrix.resourcePolicies),
verifyOrgAccess,
verifyLimits,
verifyUserHasAction(ActionsEnum.listResourcePolicies),
logActionAudit(ActionsEnum.listResourcePolicies),
policy.listResourcePolicies
);
authenticated.post(
"/org/:orgId/resource-policy",
verifyValidLicense,
verifyValidSubscription(tierMatrix.resourcePolicies),
verifyOrgAccess,
verifyLimits,
verifyUserHasAction(ActionsEnum.createResourcePolicy),
logActionAudit(ActionsEnum.createResourcePolicy),
policy.createResourcePolicy
);
authenticated.put( authenticated.put(
"/org/:orgId/approvals/:approvalId", "/org/:orgId/approvals/:approvalId",
verifyValidLicense, verifyValidLicense,
@@ -733,6 +771,59 @@ authenticated.get(
alertRule.getAlertRule alertRule.getAlertRule
); );
authenticated.get(
"/org/:orgId/labels",
verifyValidLicense,
verifyOrgAccess,
verifyValidSubscription(tierMatrix.labels),
verifyUserHasAction(ActionsEnum.listOrgLabels),
labels.listOrgLabels
);
authenticated.post(
"/org/:orgId/labels",
verifyValidLicense,
verifyOrgAccess,
verifyValidSubscription(tierMatrix.labels),
verifyUserHasAction(ActionsEnum.createOrgLabel),
labels.createOrgLabel
);
authenticated.patch(
"/org/:orgId/label/:labelId",
verifyValidLicense,
verifyOrgAccess,
verifyValidSubscription(tierMatrix.labels),
verifyUserHasAction(ActionsEnum.updateOrgLabel),
labels.updateOrgLabel
);
authenticated.delete(
"/org/:orgId/label/:labelId",
verifyValidLicense,
verifyOrgAccess,
verifyUserHasAction(ActionsEnum.deleteOrgLabel),
labels.deleteOrgLabel
);
authenticated.put(
"/org/:orgId/label/:labelId/attach",
verifyValidLicense,
verifyOrgAccess,
verifyValidSubscription(tierMatrix.labels),
verifyUserHasAction(ActionsEnum.attachLabelToItem),
labels.attachLabelToItem
);
authenticated.put(
"/org/:orgId/label/:labelId/detach",
verifyValidLicense,
verifyOrgAccess,
verifyValidSubscription(tierMatrix.labels),
verifyUserHasAction(ActionsEnum.detachLabelFromItem),
labels.detachLabelFromItem
);
authenticated.get( authenticated.get(
"/org/:orgId/health-checks", "/org/:orgId/health-checks",
verifyValidLicense, verifyValidLicense,
@@ -788,3 +879,48 @@ authenticated.post(
verifyClientAccess, verifyClientAccess,
client.rebuildClientAssociationsCacheRoute client.rebuildClientAssociationsCacheRoute
); );
authenticated.put(
"/org/:orgId/resource/:resourceId/browser-gateway-target",
verifyValidLicense,
verifyOrgAccess,
verifyLimits,
verifyUserHasAction(ActionsEnum.createBrowserGatewayTarget),
logActionAudit(ActionsEnum.createBrowserGatewayTarget),
browserGatewayTarget.createBrowserGatewayTarget
);
authenticated.get(
"/org/:orgId/resource/:resourceId/browser-gateway-targets",
verifyValidLicense,
verifyOrgAccess,
verifyUserHasAction(ActionsEnum.listBrowserGatewayTargets),
browserGatewayTarget.listBrowserGatewayTargets
);
authenticated.get(
"/org/:orgId/browser-gateway-target/:browserGatewayTargetId",
verifyValidLicense,
verifyOrgAccess,
verifyUserHasAction(ActionsEnum.getBrowserGatewayTarget),
browserGatewayTarget.getBrowserGatewayTarget
);
authenticated.post(
"/org/:orgId/browser-gateway-target/:browserGatewayTargetId",
verifyValidLicense,
verifyOrgAccess,
verifyLimits,
verifyUserHasAction(ActionsEnum.updateBrowserGatewayTarget),
logActionAudit(ActionsEnum.updateBrowserGatewayTarget),
browserGatewayTarget.updateBrowserGatewayTarget
);
authenticated.delete(
"/org/:orgId/browser-gateway-target/:browserGatewayTargetId",
verifyValidLicense,
verifyOrgAccess,
verifyUserHasAction(ActionsEnum.deleteBrowserGatewayTarget),
logActionAudit(ActionsEnum.deleteBrowserGatewayTarget),
browserGatewayTarget.deleteBrowserGatewayTarget
);

View File

@@ -16,40 +16,44 @@ import HttpCode from "@server/types/HttpCode";
import createHttpError from "http-errors"; import createHttpError from "http-errors";
import logger from "@server/logger"; import logger from "@server/logger";
import { response as sendResponse } from "@server/lib/response"; import { response as sendResponse } from "@server/lib/response";
import { getFirstString } from "@server/lib/requestParams";
import privateConfig from "#private/lib/config"; import privateConfig from "#private/lib/config";
import { GenerateNewLicenseResponse } from "@server/routers/generatedLicense/types"; import { GenerateNewLicenseResponse } from "@server/routers/generatedLicense/types";
export interface CreateNewLicenseResponse { export interface CreateNewLicenseResponse {
data: Data data: Data;
success: boolean success: boolean;
error: boolean error: boolean;
message: string message: string;
status: number status: number;
} }
export interface Data { export interface Data {
licenseKey: LicenseKey licenseKey: LicenseKey;
} }
export interface LicenseKey { export interface LicenseKey {
id: number id: number;
instanceName: any instanceName: any;
instanceId: string instanceId: string;
licenseKey: string licenseKey: string;
tier: string tier: string;
type: string type: string;
quantity: number quantity: number;
quantity_2: number quantity_2: number;
isValid: boolean isValid: boolean;
updatedAt: string updatedAt: string;
createdAt: string createdAt: string;
expiresAt: string expiresAt: string;
paidFor: boolean paidFor: boolean;
orgId: string orgId: string;
metadata: string metadata: string;
} }
export async function createNewLicense(orgId: string, licenseData: any): Promise<CreateNewLicenseResponse> { export async function createNewLicense(
orgId: string,
licenseData: any
): Promise<CreateNewLicenseResponse> {
try { try {
const response = await fetch( const response = await fetch(
`${privateConfig.getRawPrivateConfig().server.fossorial_api}/api/v1/license-internal/enterprise/${orgId}/create`, // this says enterprise but it does both `${privateConfig.getRawPrivateConfig().server.fossorial_api}/api/v1/license-internal/enterprise/${orgId}/create`, // this says enterprise but it does both
@@ -80,7 +84,7 @@ export async function generateNewLicense(
next: NextFunction next: NextFunction
): Promise<any> { ): Promise<any> {
try { try {
const { orgId } = req.params; const orgId = getFirstString(req.params.orgId);
if (!orgId) { if (!orgId) {
return next( return next(

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