Compare commits

...

71 Commits

Author SHA1 Message Date
dependabot[bot] 774a0730b9 Bump node in the docker-dependencies group across 1 directory
Bumps the docker-dependencies group with 1 update in the / directory: node.


Updates `node` from 24.18.1-alpine to 26.8.1-alpine

---
updated-dependencies:
- dependency-name: node
  dependency-version: 26.8.1-alpine
  dependency-type: direct:production
  update-type: version-update:semver-major
  dependency-group: docker-dependencies
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-09-01 01:34:28 +00:00
Owen Schwartz d6d923e972 Merge pull request #3628 from aithal007/perf/add-fk-indexes
perf: add secondary FK indexes to core OSS schemas
2026-08-31 17:05:04 -04:00
Parikshith 41139f2fd0 perf: add secondary FK indexes to core OSS schemas
Add 13 secondary indexes across 10 tables in the SQLite schema and
5 missing indexes to the PostgreSQL schema.

All list endpoints (listSites, listResources, listClients, listRoles,
listTargets, listUserOrgs) filter and join on these FK columns. In the
SQLite schema, no secondary indexes existed at all on these foreign keys,
forcing full sequential scans on every paginated request and session lookup.

SQLite changes (13 new indexes):
  - sites.orgId
  - resources.orgId
  - targets.resourceId, targets.siteId
  - newt.siteId
  - clients.orgId, clients.userId
  - labels.orgId
  - olms.userId
  - session.userId
  - userOrgs.userId, userOrgs.orgId
  - roles.orgId

PG changes (5 new indexes, rest already present):
  - labels.orgId
  - session.userId
  - userOrgs.userId, userOrgs.orgId
  - roles.orgId
  - olms.userId
2026-08-31 17:04:01 -04:00
Owen Schwartz ebcdeab414 Merge pull request #3597 from shleeable/patch-11
Fix: License.check can fail silently
2026-08-31 17:02:53 -04:00
Owen 2cc7d03ace Update to fall back but still use cache 2026-08-31 17:02:03 -04:00
Shlee 34671c6b13 Update license.ts 2026-08-31 16:57:36 -04:00
Owen Schwartz 8dfc95347f Merge pull request #3655 from moverq1337/fix/access-token-delete-root-key
Fix access token deletion for root API keys
2026-08-31 16:54:18 -04:00
Owen Schwartz 0dece5fef1 Merge pull request #3665 from fosrl/dev
Fix domain namespaces and country is not in blueprints
2026-08-31 11:03:06 -04:00
Owen e7f38c089f Merge branch 'main' into dev 2026-08-31 11:02:01 -04:00
miloschwartz dd0a5a359a check for namespace domain before blocking org check 2026-08-31 10:59:50 -04:00
moverq1337 1650ece0c3 Fix root API key access in verifyApiKeyAccessTokenAccess 2026-08-28 22:45:31 +03:00
Owen Schwartz bc56a2bed0 Merge pull request #3647 from argueta-xyz/country-is-not-rule-via-blueprint
Allow COUNTRY_IS_NOT rules to be created via Blueprints
2026-08-28 10:52:24 -04:00
Owen Schwartz 49dcc590ce Merge pull request #3651 from fosrl/dev
Update readme
2026-08-28 10:19:58 -04:00
Owen da3e3ff33f Fix typo 2026-08-28 10:19:35 -04:00
Owen Schwartz 69d539f107 Merge pull request #3650 from fosrl/dev
Update readme, tel, and fix EE feature flag
2026-08-28 09:55:40 -04:00
Owen 872e0f9ae1 Merge branch 'main' into dev 2026-08-28 09:54:46 -04:00
Owen 5b3713a72f Update readme 2026-08-28 09:54:07 -04:00
Owen Schwartz f02be1fdbf Merge pull request #3649 from fosrl/dependabot/npm_and_yarn/multi-2e40a8c091
Bump ws and socket.io-adapter
2026-08-28 09:21:25 -04:00
dependabot[bot] 0bf04cf0cd Bump ws and socket.io-adapter
Bumps [ws](https://github.com/websockets/ws) and [socket.io-adapter](https://github.com/socketio/socket.io). These dependencies needed to be updated together.

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

Updates `socket.io-adapter` from 2.5.6 to 2.5.8
- [Release notes](https://github.com/socketio/socket.io/releases)
- [Changelog](https://github.com/socketio/socket.io/blob/main/CHANGELOG.md)
- [Commits](https://github.com/socketio/socket.io/compare/socket.io-adapter@2.5.6...socket.io-adapter@2.5.8)

---
updated-dependencies:
- dependency-name: ws
  dependency-version: 8.21.0
  dependency-type: indirect
- dependency-name: socket.io-adapter
  dependency-version: 2.5.8
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-08-28 13:12:35 +00:00
Alejandro Argueta 7cda28d685 Update RuleSchema to allow COUNTRY_IS_NOT rules to be created via blueprints as well. 2026-08-27 14:18:48 -07:00
Owen 60bc74c4df Add AI provider and usage metrics to telemetry data collection 2026-08-27 16:54:04 -04:00
Owen 48ab6c501f Fix #3646 2026-08-27 16:37:53 -04:00
Milo Schwartz 7a95e543d8 Merge pull request #3645 from fosrl/dev
Dev
2026-08-27 14:53:42 -04:00
Owen b87b7c7e80 Fix timestamp handling in AI session logs to use seconds instead of milliseconds 2026-08-27 14:26:47 -04:00
miloschwartz a47a68d8e1 update badger version 2026-08-27 11:40:46 -04:00
Owen Schwartz ed0d6fb6b9 Merge pull request #3644 from fosrl/dev
1.22.0-s.2
2026-08-26 11:30:38 -04:00
Owen a02d16fd58 Handle compression of ai session logs 2026-08-26 11:28:14 -04:00
Owen 331fee24d4 Make the default session storage 0 2026-08-26 11:02:39 -04:00
Owen Schwartz 5bdb12dafe Merge pull request #3643 from fosrl/dev
1.22.0-s.1
2026-08-26 10:53:37 -04:00
Owen e57826d6e0 Session logs and usage logs should use seconds not ms 2026-08-26 10:50:16 -04:00
miloschwartz 3d4e143c1f normalize key in rate limiters 2026-08-26 10:25:29 -04:00
miloschwartz 10a25c184d fix get /site-resource/:siteResourceId always returning 400 2026-08-26 10:03:39 -04:00
miloschwartz 906099d1e1 fix org domain access check so unmapped domains are rejected 2026-08-26 09:55:40 -04:00
miloschwartz 9a5824900d strip encrypted ssh key from org response 2026-08-26 09:39:05 -04:00
Owen Schwartz f3474dac98 Merge pull request #3641 from fosrl/dev
1.22.0
2026-08-25 17:19:46 -04:00
Owen 72d2c79793 Add migration for streaming table 2026-08-25 10:42:56 -04:00
Owen 23764feb4f Move the messaging out of the transaction 2026-08-25 09:27:02 -04:00
Owen Schwartz d2809fbfd1 Merge pull request #3637 from fosrl/fix/generic-oidc-icons
Fix: Use variant instead of type only for the Org login IDPs
2026-08-24 16:46:38 -04:00
Owen Schwartz 7319bf84f7 Merge pull request #3481 from ThanatosDi/feat/zh-tw-for-1.21.0
feat: translate zh-tw for 1.21.0
2026-08-24 11:04:08 -04:00
Owen Schwartz 9ec9908ed7 Merge pull request #3509 from shubhamsinnh/codex/fix-zh-tw-language-detection
Fix regional locale detection
2026-08-24 11:03:09 -04:00
Milo Schwartz fd0a0818c1 Merge pull request #3617 from fosrl/dev
Dev
2026-08-19 16:41:48 -04:00
Milo Schwartz 929acc5b1c Merge pull request #3614 from fosrl/dev
Dev
2026-08-19 12:05:12 -04:00
Milo Schwartz 71348f45b2 Merge pull request #3611 from fosrl/dev
1.22.0
2026-08-19 11:26:08 -04:00
Milo Schwartz 21eb4d2876 Merge pull request #3598 from shleeable/patch-12
Fix: ensuring only internal users can request password resets.
2026-08-19 10:35:52 -04:00
Owen Schwartz 18270381c1 Merge pull request #3607 from fosrl/dependabot/npm_and_yarn/multi-ff8097116b
Bump sharp, next and @react-email/ui
2026-08-18 17:21:25 -04:00
dependabot[bot] 8e938a2723 Bump sharp, next and @react-email/ui
Bumps [sharp](https://github.com/lovell/sharp) to 0.35.3 and updates ancestor dependencies [sharp](https://github.com/lovell/sharp), [next](https://github.com/vercel/next.js) and [@react-email/ui](https://github.com/resend/react-email/tree/HEAD/packages/ui). These dependencies need to be updated together.


Updates `sharp` from 0.34.5 to 0.35.3
- [Release notes](https://github.com/lovell/sharp/releases)
- [Commits](https://github.com/lovell/sharp/compare/v0.34.5...v0.35.3)

Updates `next` from 16.2.11 to 16.3.1
- [Release notes](https://github.com/vercel/next.js/releases)
- [Commits](https://github.com/vercel/next.js/compare/v16.2.11...v16.3.1)

Updates `@react-email/ui` from 6.5.0 to 6.9.2
- [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.9.2/packages/ui)

---
updated-dependencies:
- dependency-name: sharp
  dependency-version: 0.35.3
  dependency-type: indirect
- dependency-name: next
  dependency-version: 16.3.1
  dependency-type: direct:production
- dependency-name: "@react-email/ui"
  dependency-version: 6.9.2
  dependency-type: direct:development
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-08-18 16:26:28 +00:00
Owen Schwartz 197f8f7ba5 Merge pull request #3503 from fosrl/dependabot/npm_and_yarn/engine.io-6.6.9
Bump engine.io from 6.6.7 to 6.6.9
2026-08-18 12:25:27 -04:00
Owen Schwartz 10b528642d Merge pull request #3517 from fosrl/dependabot/github_actions/github-actions-dependencies-7eb9e48ea9
Bump the github-actions-dependencies group across 1 directory with 2 updates
2026-08-18 12:24:54 -04:00
Owen Schwartz 60d6fff085 Merge pull request #3525 from fosrl/dependabot/npm_and_yarn/ip-address-10.4.0
Bump ip-address from 10.2.0 to 10.4.0
2026-08-18 12:24:30 -04:00
Owen Schwartz c664b3da91 Merge pull request #3526 from fosrl/dependabot/npm_and_yarn/multi-5e81c1b34f
Bump brace-expansion
2026-08-18 12:24:17 -04:00
Owen Schwartz 492282e758 Merge pull request #3530 from fosrl/dependabot/npm_and_yarn/socket.io-parser-4.2.7
Bump socket.io-parser from 4.2.6 to 4.2.7
2026-08-18 12:24:03 -04:00
Owen Schwartz 47f4aefc25 Merge pull request #3531 from fosrl/dependabot/npm_and_yarn/fast-uri-3.1.5
Bump fast-uri from 3.1.4 to 3.1.5
2026-08-18 12:23:43 -04:00
Owen Schwartz 7c0ff9ede7 Merge pull request #3569 from fosrl/dependabot/npm_and_yarn/js-yaml-4.3.1
Bump js-yaml from 4.3.0 to 4.3.1
2026-08-18 12:23:32 -04:00
Owen Schwartz 44e81ea979 Merge pull request #3570 from fosrl/dependabot/npm_and_yarn/nanoid-3.3.18
Bump nanoid from 3.3.12 to 3.3.18
2026-08-18 12:23:17 -04:00
Owen Schwartz 56dc10330a Merge pull request #3572 from fosrl/dependabot/npm_and_yarn/postcss-8.5.23
Bump postcss from 8.5.15 to 8.5.23
2026-08-18 12:23:03 -04:00
dependabot[bot] eb8ad6a181 Bump the github-actions-dependencies group across 1 directory with 2 updates
Bumps the github-actions-dependencies group with 2 updates in the / directory: [docker/login-action](https://github.com/docker/login-action) and [actions/stale](https://github.com/actions/stale).


Updates `docker/login-action` from 4.5.1 to 4.6.0
- [Release notes](https://github.com/docker/login-action/releases)
- [Commits](https://github.com/docker/login-action/compare/abd2ef45e78c5afb21d64d4ca52ee8550d9572c7...dbcb813823bdd20940b903addbd779551569679f)

Updates `actions/stale` from 10.4.0 to 11.0.0
- [Release notes](https://github.com/actions/stale/releases)
- [Changelog](https://github.com/actions/stale/blob/main/CHANGELOG.md)
- [Commits](https://github.com/actions/stale/compare/1e223db275d687790206a7acac4d1a11bd6fe629...4391f3da665fdf50b6810c1a66712fb9ba21aa93)

---
updated-dependencies:
- dependency-name: actions/stale
  dependency-version: 11.0.0
  dependency-type: direct:production
  update-type: version-update:semver-major
  dependency-group: github-actions-dependencies
- dependency-name: docker/login-action
  dependency-version: 4.6.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: github-actions-dependencies
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-08-17 01:34:14 +00:00
Shlee 4edd2e4d32 Update requestPasswordReset.ts 2026-08-16 22:21:37 +09:30
Shlee 813c3abe54 Update requestPasswordReset.ts 2026-08-16 22:03:00 +09:30
Owen Schwartz 048e4fc73c Merge pull request #3574 from fosrl/dev
1.21.1-s.5
2026-08-12 16:34:31 -04:00
dependabot[bot] 923371e5b4 Bump postcss from 8.5.15 to 8.5.23
Bumps [postcss](https://github.com/postcss/postcss) from 8.5.15 to 8.5.23.
- [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.15...8.5.23)

---
updated-dependencies:
- dependency-name: postcss
  dependency-version: 8.5.23
  dependency-type: direct:development
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-08-12 02:57:16 +00:00
dependabot[bot] 81430ba3d3 Bump nanoid from 3.3.12 to 3.3.18
Bumps [nanoid](https://github.com/ai/nanoid) from 3.3.12 to 3.3.18.
- [Release notes](https://github.com/ai/nanoid/releases)
- [Changelog](https://github.com/ai/nanoid/blob/3.3.18/CHANGELOG.md)
- [Commits](https://github.com/ai/nanoid/compare/3.3.12...3.3.18)

---
updated-dependencies:
- dependency-name: nanoid
  dependency-version: 3.3.18
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-08-11 23:54:36 +00:00
dependabot[bot] e4aaadc9f9 Bump js-yaml from 4.3.0 to 4.3.1
Bumps [js-yaml](https://github.com/nodeca/js-yaml) from 4.3.0 to 4.3.1.
- [Changelog](https://github.com/nodeca/js-yaml/blob/4.3.1/CHANGELOG.md)
- [Commits](https://github.com/nodeca/js-yaml/compare/4.3.0...4.3.1)

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

Signed-off-by: dependabot[bot] <support@github.com>
2026-08-11 20:19:18 +00:00
Owen Schwartz d04740fede Merge pull request #3537 from fosrl/dev
1.21.1-s.4
2026-08-06 14:07:42 -04:00
dependabot[bot] 4677a0d501 Bump fast-uri from 3.1.4 to 3.1.5
Bumps [fast-uri](https://github.com/fastify/fast-uri) from 3.1.4 to 3.1.5.
- [Release notes](https://github.com/fastify/fast-uri/releases)
- [Commits](https://github.com/fastify/fast-uri/compare/v3.1.4...v3.1.5)

---
updated-dependencies:
- dependency-name: fast-uri
  dependency-version: 3.1.5
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-08-05 18:18:41 +00:00
dependabot[bot] b4463f0e1a Bump socket.io-parser from 4.2.6 to 4.2.7
Bumps [socket.io-parser](https://github.com/socketio/socket.io) from 4.2.6 to 4.2.7.
- [Release notes](https://github.com/socketio/socket.io/releases)
- [Changelog](https://github.com/socketio/socket.io/blob/main/CHANGELOG.md)
- [Commits](https://github.com/socketio/socket.io/compare/socket.io-parser@4.2.6...socket.io-parser@4.2.7)

---
updated-dependencies:
- dependency-name: socket.io-parser
  dependency-version: 4.2.7
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-08-05 06:42:16 +00:00
Owen Schwartz b7c0669c38 Merge pull request #3528 from fosrl/dev
1.21.1-s.3
2026-08-04 17:46:49 -04:00
dependabot[bot] 152d2fb1d6 Bump brace-expansion
Bumps  and [brace-expansion](https://github.com/juliangruber/brace-expansion). These dependencies needed to be updated together.

Updates `brace-expansion` from 5.0.6 to 5.0.9
- [Release notes](https://github.com/juliangruber/brace-expansion/releases)
- [Commits](https://github.com/juliangruber/brace-expansion/compare/v5.0.6...v5.0.9)

Updates `brace-expansion` from 1.1.14 to 1.1.18
- [Release notes](https://github.com/juliangruber/brace-expansion/releases)
- [Commits](https://github.com/juliangruber/brace-expansion/compare/v5.0.6...v5.0.9)

---
updated-dependencies:
- dependency-name: brace-expansion
  dependency-version: 5.0.9
  dependency-type: indirect
- dependency-name: brace-expansion
  dependency-version: 1.1.18
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-08-04 05:33:39 +00:00
dependabot[bot] d374b4f66e Bump ip-address from 10.2.0 to 10.4.0
Bumps [ip-address](https://github.com/beaugunderson/ip-address) from 10.2.0 to 10.4.0.
- [Release notes](https://github.com/beaugunderson/ip-address/releases)
- [Commits](https://github.com/beaugunderson/ip-address/compare/v10.2.0...v10.4.0)

---
updated-dependencies:
- dependency-name: ip-address
  dependency-version: 10.4.0
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-08-04 01:45:48 +00:00
Shubham Singh cb3f0b49a8 Fix regional locale detection 2026-07-30 14:29:25 +05:30
dependabot[bot] 192542629f Bump engine.io from 6.6.7 to 6.6.9
Bumps [engine.io](https://github.com/socketio/socket.io) from 6.6.7 to 6.6.9.
- [Release notes](https://github.com/socketio/socket.io/releases)
- [Changelog](https://github.com/socketio/socket.io/blob/main/CHANGELOG.md)
- [Commits](https://github.com/socketio/socket.io/compare/engine.io@6.6.7...engine.io@6.6.9)

---
updated-dependencies:
- dependency-name: engine.io
  dependency-version: 6.6.9
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-07-29 21:59:23 +00:00
ThanatosDi 84d5a4b86c feat: translate zh-tw for 1.21.0 2026-07-23 14:40:42 +08:00
35 changed files with 3500 additions and 1605 deletions
+4 -4
View File
@@ -77,7 +77,7 @@ jobs:
fi
- name: Log in to Docker Hub
uses: docker/login-action@abd2ef45e78c5afb21d64d4ca52ee8550d9572c7 # v4.5.1
uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
with:
registry: docker.io
username: ${{ secrets.DOCKER_HUB_USERNAME }}
@@ -149,7 +149,7 @@ jobs:
fi
- name: Log in to Docker Hub
uses: docker/login-action@abd2ef45e78c5afb21d64d4ca52ee8550d9572c7 # v4.5.1
uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
with:
registry: docker.io
username: ${{ secrets.DOCKER_HUB_USERNAME }}
@@ -204,7 +204,7 @@ jobs:
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Log in to Docker Hub
uses: docker/login-action@abd2ef45e78c5afb21d64d4ca52ee8550d9572c7 # v4.5.1
uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
with:
registry: docker.io
username: ${{ secrets.DOCKER_HUB_USERNAME }}
@@ -407,7 +407,7 @@ jobs:
shell: bash
- name: Login to GitHub Container Registry (for cosign)
uses: docker/login-action@abd2ef45e78c5afb21d64d4ca52ee8550d9572c7 # v4.5.1
uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
with:
registry: ghcr.io
username: ${{ github.actor }}
+1 -1
View File
@@ -14,7 +14,7 @@ jobs:
stale:
runs-on: ubuntu-latest
steps:
- uses: actions/stale@1e223db275d687790206a7acac4d1a11bd6fe629 # v10.4.0
- uses: actions/stale@4391f3da665fdf50b6810c1a66712fb9ba21aa93 # v11.0.0
with:
days-before-stale: 14
days-before-close: 14
+1 -1
View File
@@ -1,4 +1,4 @@
FROM node:24.18.1-alpine
FROM node:26.8.1-alpine
WORKDIR /app
+13
View File
@@ -99,6 +99,19 @@ Access private resources like SSH servers, databases, RDP, and entire network ra
<img src="public/screenshots/private-resources.png" alt="Private resources" width="100%" />
### Identity-aware AI gateway
Put an identity-aware proxy in front of public cloud (OpenAI, Anthropic, Gemini, etc.) and self-hosted model servers (Ollama, vLLM, Mistral, etc.) so coding agents and AI clients call a single Pangolin URL. Publish it as a public resource with personal API keys, or keep it private on a client tunnel where the connected client is the credential for keyless access. Budgets, session history, and usage analytics sit in front of every call.
* Access self-hosted models (vLLM, Ollama, etc) alongside cloud models (OpenAI, Anthropic, etc) in one place
* Keyless access by authenticating users with the Pangolin desktop client
* Or, provide users with personal API keys
* Control costs and token usage by setting budgets
* Audit with detailed session history and analytics
* Integrate AI clients and coding agents (Claude Code, Codex, OpenCode, etc)
<img src="public/screenshots/expanded-session-logs.png" alt="AI Session Logs" width="100%" />
### Give users and roles access to resources
Use Pangolin's built-in users or bring your own identity provider and set up role-based access control (RBAC). Grant users access to specific resources, not entire networks. Unlike traditional VPNs that expose full network access, Pangolin's zero-trust model ensures users can only reach the applications, services, and routes you explicitly define.
+2213 -788
View File
File diff suppressed because it is too large Load Diff
+394 -307
View File
File diff suppressed because it is too large Load Diff
+5 -5
View File
@@ -94,12 +94,12 @@
"input-otp": "1.4.2",
"ioredis": "5.11.0",
"jmespath": "0.16.0",
"js-yaml": "4.3.0",
"js-yaml": "4.3.1",
"jsonwebtoken": "9.0.3",
"lucide-react": "1.17.0",
"maxmind": "5.0.6",
"moment": "2.30.1",
"next": "16.2.11",
"next": "16.3.1",
"next-intl": "4.13.0",
"next-themes": "0.4.6",
"nextjs-toploader": "3.9.17",
@@ -139,7 +139,7 @@
"devDependencies": {
"@dotenvx/dotenvx": "1.69.1",
"@esbuild-plugins/tsconfig-paths": "0.1.2",
"@react-email/ui": "^6.5.0",
"@react-email/ui": "^6.9.2",
"@tailwindcss/postcss": "4.3.0",
"@tanstack/react-query-devtools": "5.100.14",
"@types/better-sqlite3": "7.6.13",
@@ -170,7 +170,7 @@
"esbuild-node-externals": "1.22.0",
"eslint": "10.4.0",
"eslint-config-next": "16.2.6",
"postcss": "8.5.15",
"postcss": "8.5.23",
"prettier": "3.8.3",
"react-email": "6.5.0",
"tailwindcss": "4.3.0",
@@ -182,6 +182,6 @@
"overrides": {
"esbuild": "0.28.0",
"dompurify": "3.4.0",
"postcss": "8.5.15"
"postcss": "8.5.23"
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 790 KiB

+72 -50
View File
@@ -262,16 +262,20 @@ export const resourceAiModels = pgTable(
(t) => [primaryKey({ columns: [t.resourceId, t.modelId] })]
);
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 labels = pgTable(
"labels",
{
labelId: serial("labelId").primaryKey(),
name: varchar("name").notNull(),
color: varchar("color").notNull(),
orgId: varchar("orgId")
.references(() => orgs.orgId, {
onDelete: "cascade"
})
.notNull()
},
(t) => [index("idx_labels_orgid").on(t.orgId)]
);
export const launcherViews = pgTable("launcherViews", {
viewId: serial("viewId").primaryKey(),
@@ -693,15 +697,19 @@ export const twoFactorBackupCodes = pgTable("twoFactorBackupCodes", {
codeHash: varchar("codeHash").notNull()
});
export const sessions = pgTable("session", {
sessionId: varchar("id").primaryKey(),
userId: varchar("userId")
.notNull()
.references(() => users.userId, { onDelete: "cascade" }),
expiresAt: bigint("expiresAt", { mode: "number" }).notNull(),
issuedAt: bigint("issuedAt", { mode: "number" }),
deviceAuthUsed: boolean("deviceAuthUsed").notNull().default(false)
});
export const sessions = pgTable(
"session",
{
sessionId: varchar("id").primaryKey(),
userId: varchar("userId")
.notNull()
.references(() => users.userId, { onDelete: "cascade" }),
expiresAt: bigint("expiresAt", { mode: "number" }).notNull(),
issuedAt: bigint("issuedAt", { mode: "number" }),
deviceAuthUsed: boolean("deviceAuthUsed").notNull().default(false)
},
(t) => [index("idx_sessions_userid").on(t.userId)]
);
export const newtSessions = pgTable("newtSession", {
sessionId: varchar("id").primaryKey(),
@@ -711,19 +719,26 @@ export const newtSessions = pgTable("newtSession", {
expiresAt: bigint("expiresAt", { mode: "number" }).notNull()
});
export const userOrgs = pgTable("userOrgs", {
userId: varchar("userId")
.notNull()
.references(() => users.userId, { onDelete: "cascade" }),
orgId: varchar("orgId")
.references(() => orgs.orgId, {
onDelete: "cascade"
})
.notNull(),
isOwner: boolean("isOwner").notNull().default(false),
autoProvisioned: boolean("autoProvisioned").default(false),
pamUsername: varchar("pamUsername") // cleaned username for ssh and such
});
export const userOrgs = pgTable(
"userOrgs",
{
userId: varchar("userId")
.notNull()
.references(() => users.userId, { onDelete: "cascade" }),
orgId: varchar("orgId")
.references(() => orgs.orgId, {
onDelete: "cascade"
})
.notNull(),
isOwner: boolean("isOwner").notNull().default(false),
autoProvisioned: boolean("autoProvisioned").default(false),
pamUsername: varchar("pamUsername") // cleaned username for ssh and such
},
(t) => [
index("idx_userOrgs_userid").on(t.userId),
index("idx_userOrgs_orgid").on(t.orgId)
]
);
export const emailVerificationCodes = pgTable("emailVerificationCodes", {
codeId: serial("id").primaryKey(),
@@ -751,22 +766,26 @@ export const actions = pgTable("actions", {
description: varchar("description")
});
export const roles = pgTable("roles", {
roleId: serial("roleId").primaryKey(),
orgId: varchar("orgId")
.references(() => orgs.orgId, {
onDelete: "cascade"
})
.notNull(),
isAdmin: boolean("isAdmin"),
name: varchar("name").notNull(),
description: varchar("description"),
requireDeviceApproval: boolean("requireDeviceApproval").default(false),
sshSudoMode: varchar("sshSudoMode", { length: 32 }).default("full"), // "none" | "full" | "commands"
sshSudoCommands: text("sshSudoCommands").default("[]"),
sshCreateHomeDir: boolean("sshCreateHomeDir").default(true),
sshUnixGroups: text("sshUnixGroups").default("[]")
});
export const roles = pgTable(
"roles",
{
roleId: serial("roleId").primaryKey(),
orgId: varchar("orgId")
.references(() => orgs.orgId, {
onDelete: "cascade"
})
.notNull(),
isAdmin: boolean("isAdmin"),
name: varchar("name").notNull(),
description: varchar("description"),
requireDeviceApproval: boolean("requireDeviceApproval").default(false),
sshSudoMode: varchar("sshSudoMode", { length: 32 }).default("full"), // "none" | "full" | "commands"
sshSudoCommands: text("sshSudoCommands").default("[]"),
sshCreateHomeDir: boolean("sshCreateHomeDir").default(true),
sshUnixGroups: text("sshUnixGroups").default("[]")
},
(t) => [index("idx_roles_orgid").on(t.orgId)]
);
export const userOrgRoles = pgTable(
"userOrgRoles",
@@ -1409,7 +1428,10 @@ export const olms = pgTable(
}),
archived: boolean("archived").notNull().default(false)
},
(t) => [index("idx_olms_clientid").on(t.clientId)]
(t) => [
index("idx_olms_clientid").on(t.clientId),
index("idx_olms_userid").on(t.userId)
]
);
export const currentFingerprint = pgTable("currentFingerprint", {
@@ -1984,7 +2006,7 @@ export const aiSessionLog = pgTable(
// were cut short at AI_SESSION_LOG_MAX_BODY_CHARS before storage.
truncated: boolean("truncated").notNull().default(false),
statusCode: integer("statusCode"),
createdAt: bigint("createdAt", { mode: "number" }).notNull() // epoch ms
createdAt: bigint("createdAt", { mode: "number" }).notNull() // epoch seconds
},
(t) => [
index("idx_ai_session_log_org_created").on(t.orgId, t.createdAt),
+337 -268
View File
@@ -99,131 +99,147 @@ export const orgDomains = sqliteTable("orgDomains", {
.references(() => domains.domainId, { onDelete: "cascade" })
});
export const sites = sqliteTable("sites", {
siteId: integer("siteId").primaryKey({ autoIncrement: true }),
orgId: text("orgId")
.references(() => orgs.orgId, {
onDelete: "cascade"
})
.notNull(),
niceId: text("niceId").notNull(),
exitNodeId: integer("exitNode").references(() => exitNodes.exitNodeId, {
onDelete: "set null"
}),
networkId: integer("networkId").references(() => networks.networkId, {
onDelete: "set null"
}),
name: text("name").notNull(),
pubKey: text("pubKey"),
exitNodeSubnet: text("exitNodeSubnet"),
megabytesIn: integer("bytesIn").default(0),
megabytesOut: integer("bytesOut").default(0),
lastBandwidthUpdate: text("lastBandwidthUpdate"),
type: text("type").notNull(), // "newt" or "wireguard"
online: integer("online", { mode: "boolean" }).notNull().default(false),
lastPing: integer("lastPing"),
export const sites = sqliteTable(
"sites",
{
siteId: integer("siteId").primaryKey({ autoIncrement: true }),
orgId: text("orgId")
.references(() => orgs.orgId, {
onDelete: "cascade"
})
.notNull(),
niceId: text("niceId").notNull(),
exitNodeId: integer("exitNode").references(() => exitNodes.exitNodeId, {
onDelete: "set null"
}),
networkId: integer("networkId").references(() => networks.networkId, {
onDelete: "set null"
}),
name: text("name").notNull(),
pubKey: text("pubKey"),
exitNodeSubnet: text("exitNodeSubnet"),
megabytesIn: integer("bytesIn").default(0),
megabytesOut: integer("bytesOut").default(0),
lastBandwidthUpdate: text("lastBandwidthUpdate"),
type: text("type").notNull(), // "newt" or "wireguard"
online: integer("online", { mode: "boolean" }).notNull().default(false),
lastPing: integer("lastPing"),
// exit node stuff that is how to connect to the site when it has a wg server
address: text("address"), // this is the address of the wireguard interface in newt
endpoint: text("endpoint"), // this is how to reach gerbil externally - gets put into the wireguard config
localEndpoints: text("localEndpoints"), // JSON encoded list of string ips on the local machine to try to connect to
publicKey: text("publicKey"), // TODO: Fix typo in publicKey
lastHolePunch: integer("lastHolePunch"),
listenPort: integer("listenPort"),
dockerSocketEnabled: integer("dockerSocketEnabled", { mode: "boolean" })
.notNull()
.default(true),
autoUpdateEnabled: integer("autoUpdateEnabled", { mode: "boolean" })
.notNull()
.default(false),
autoUpdateOverrideOrg: integer("autoUpdateOverrideOrg", {
mode: "boolean"
})
.notNull()
.default(false),
status: text("status").$type<"pending" | "approved">().default("approved")
});
export const resources = sqliteTable("resources", {
resourceId: integer("resourceId").primaryKey({ autoIncrement: true }),
resourcePolicyId: integer("resourcePolicyId").references(
() => resourcePolicies.resourcePolicyId,
{ onDelete: "set null" }
),
defaultResourcePolicyId: integer("defaultResourcePolicyId").references(
() => resourcePolicies.resourcePolicyId,
{
onDelete: "restrict"
}
),
resourceGuid: text("resourceGuid", { length: 36 })
.unique()
.notNull()
.$defaultFn(() => randomUUID()),
orgId: text("orgId")
.references(() => orgs.orgId, {
onDelete: "cascade"
// exit node stuff that is how to connect to the site when it has a wg server
address: text("address"), // this is the address of the wireguard interface in newt
endpoint: text("endpoint"), // this is how to reach gerbil externally - gets put into the wireguard config
localEndpoints: text("localEndpoints"), // JSON encoded list of string ips on the local machine to try to connect to
publicKey: text("publicKey"), // TODO: Fix typo in publicKey
lastHolePunch: integer("lastHolePunch"),
listenPort: integer("listenPort"),
dockerSocketEnabled: integer("dockerSocketEnabled", { mode: "boolean" })
.notNull()
.default(true),
autoUpdateEnabled: integer("autoUpdateEnabled", { mode: "boolean" })
.notNull()
.default(false),
autoUpdateOverrideOrg: integer("autoUpdateOverrideOrg", {
mode: "boolean"
})
.notNull(),
niceId: text("niceId").notNull(),
name: text("name").notNull(),
subdomain: text("subdomain"),
fullDomain: text("fullDomain"),
domainId: text("domainId").references(() => domains.domainId, {
onDelete: "set null"
}),
ssl: integer("ssl", { mode: "boolean" }).notNull().default(false),
blockAccess: integer("blockAccess", { mode: "boolean" })
.notNull()
.default(false),
proxyPort: integer("proxyPort"),
sso: integer("sso", { mode: "boolean" }),
emailWhitelistEnabled: integer("emailWhitelistEnabled", {
mode: "boolean"
}),
applyRules: integer("applyRules", { mode: "boolean" }),
enabled: integer("enabled", { mode: "boolean" }).notNull().default(true),
stickySession: integer("stickySession", { mode: "boolean" })
.notNull()
.default(false),
tlsServerName: text("tlsServerName"),
setHostHeader: text("setHostHeader"),
enableProxy: integer("enableProxy", { mode: "boolean" }).default(true),
skipToIdpId: integer("skipToIdpId").references(() => idp.idpId, {
onDelete: "set null"
}),
headers: text("headers"), // comma-separated list of headers to add to the request
proxyProtocol: integer("proxyProtocol", { mode: "boolean" })
.notNull()
.default(false),
proxyProtocolVersion: integer("proxyProtocolVersion").default(1),
maintenanceModeEnabled: integer("maintenanceModeEnabled", {
mode: "boolean"
})
.notNull()
.default(false),
maintenanceModeType: text("maintenanceModeType", {
enum: ["forced", "automatic"]
}).default("forced"), // "forced" = always show, "automatic" = only when down
maintenanceTitle: text("maintenanceTitle"),
maintenanceMessage: text("maintenanceMessage"),
maintenanceEstimatedTime: text("maintenanceEstimatedTime"),
postAuthPath: text("postAuthPath"),
health: text("health").default("unknown"), // "healthy", "unhealthy", "unknown"
wildcard: integer("wildcard", { mode: "boolean" }).notNull().default(false),
mode: text("mode")
.default("http")
.$type<"rdp" | "ssh" | "http" | "vnc" | "inference" | "tcp" | "udp">()
.notNull(), // rdp, ssh, http, vnc, inference
pamMode: text("pamMode")
.$type<"passthrough" | "push">()
.default("passthrough"),
authDaemonMode: text("authDaemonMode")
.$type<"site" | "remote" | "native">()
.default("site"),
authDaemonPort: integer("authDaemonPort").default(22123),
status: text("status").$type<"pending" | "approved">().default("approved")
});
.notNull()
.default(false),
status: text("status")
.$type<"pending" | "approved">()
.default("approved")
},
(table) => [
index("idx_sites_orgId").on(table.orgId)
]
);
export const resources = sqliteTable(
"resources",
{
resourceId: integer("resourceId").primaryKey({ autoIncrement: true }),
resourcePolicyId: integer("resourcePolicyId").references(
() => resourcePolicies.resourcePolicyId,
{ onDelete: "set null" }
),
defaultResourcePolicyId: integer("defaultResourcePolicyId").references(
() => resourcePolicies.resourcePolicyId,
{
onDelete: "restrict"
}
),
resourceGuid: text("resourceGuid", { length: 36 })
.unique()
.notNull()
.$defaultFn(() => randomUUID()),
orgId: text("orgId")
.references(() => orgs.orgId, {
onDelete: "cascade"
})
.notNull(),
niceId: text("niceId").notNull(),
name: text("name").notNull(),
subdomain: text("subdomain"),
fullDomain: text("fullDomain"),
domainId: text("domainId").references(() => domains.domainId, {
onDelete: "set null"
}),
ssl: integer("ssl", { mode: "boolean" }).notNull().default(false),
blockAccess: integer("blockAccess", { mode: "boolean" })
.notNull()
.default(false),
proxyPort: integer("proxyPort"),
sso: integer("sso", { mode: "boolean" }),
emailWhitelistEnabled: integer("emailWhitelistEnabled", {
mode: "boolean"
}),
applyRules: integer("applyRules", { mode: "boolean" }),
enabled: integer("enabled", { mode: "boolean" }).notNull().default(true),
stickySession: integer("stickySession", { mode: "boolean" })
.notNull()
.default(false),
tlsServerName: text("tlsServerName"),
setHostHeader: text("setHostHeader"),
enableProxy: integer("enableProxy", { mode: "boolean" }).default(true),
skipToIdpId: integer("skipToIdpId").references(() => idp.idpId, {
onDelete: "set null"
}),
headers: text("headers"), // comma-separated list of headers to add to the request
proxyProtocol: integer("proxyProtocol", { mode: "boolean" })
.notNull()
.default(false),
proxyProtocolVersion: integer("proxyProtocolVersion").default(1),
maintenanceModeEnabled: integer("maintenanceModeEnabled", {
mode: "boolean"
})
.notNull()
.default(false),
maintenanceModeType: text("maintenanceModeType", {
enum: ["forced", "automatic"]
}).default("forced"), // "forced" = always show, "automatic" = only when down
maintenanceTitle: text("maintenanceTitle"),
maintenanceMessage: text("maintenanceMessage"),
maintenanceEstimatedTime: text("maintenanceEstimatedTime"),
postAuthPath: text("postAuthPath"),
health: text("health").default("unknown"), // "healthy", "unhealthy", "unknown"
wildcard: integer("wildcard", { mode: "boolean" }).notNull().default(false),
mode: text("mode")
.default("http")
.$type<"rdp" | "ssh" | "http" | "vnc" | "inference" | "tcp" | "udp">()
.notNull(), // rdp, ssh, http, vnc, inference
pamMode: text("pamMode")
.$type<"passthrough" | "push">()
.default("passthrough"),
authDaemonMode: text("authDaemonMode")
.$type<"site" | "remote" | "native">()
.default("site"),
authDaemonPort: integer("authDaemonPort").default(22123),
status: text("status")
.$type<"pending" | "approved">()
.default("approved")
},
(table) => [
index("idx_resources_orgId").on(table.orgId)
]
);
export const resourceAiProviders = sqliteTable(
"resourceAiProviders",
@@ -260,16 +276,22 @@ export const resourceAiModels = sqliteTable(
(t) => [primaryKey({ columns: [t.resourceId, t.modelId] })]
);
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 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()
},
(table) => [
index("idx_labels_orgId").on(table.orgId)
]
);
export const launcherViews = sqliteTable("launcherViews", {
viewId: integer("viewId").primaryKey({ autoIncrement: true }),
@@ -366,35 +388,44 @@ export const clientLabels = sqliteTable(
(t) => [unique("client_label_uniq").on(t.clientId, t.labelId)]
);
export const targets = sqliteTable("targets", {
targetId: integer("targetId").primaryKey({ autoIncrement: true }),
resourceId: integer("resourceId").references(() => resources.resourceId, {
onDelete: "cascade"
}),
providerId: integer("providerId").references(() => aiProviders.providerId, {
onDelete: "cascade"
}),
siteId: integer("siteId")
.references(() => sites.siteId, {
onDelete: "cascade"
})
.notNull(),
ip: text("ip").notNull(),
method: text("method"),
port: integer("port").notNull(),
internalPort: integer("internalPort"),
enabled: integer("enabled", { mode: "boolean" }).notNull().default(true),
path: text("path"),
pathMatchType: text("pathMatchType"), // exact, prefix, regex
rewritePath: text("rewritePath"), // if set, rewrites the path to this value before sending to the target
rewritePathType: text("rewritePathType"), // exact, prefix, regex, stripPrefix
priority: integer("priority").notNull().default(100),
mode: text("mode")
.$type<"http" | "tcp" | "udp" | "ssh" | "rdp" | "vnc">()
.notNull()
.default("http"),
authToken: text("authToken")
});
export const targets = sqliteTable(
"targets",
{
targetId: integer("targetId").primaryKey({ autoIncrement: true }),
resourceId: integer("resourceId").references(
() => resources.resourceId,
{ onDelete: "cascade" }
),
providerId: integer("providerId").references(
() => aiProviders.providerId,
{ onDelete: "cascade" }
),
siteId: integer("siteId")
.references(() => sites.siteId, {
onDelete: "cascade"
})
.notNull(),
ip: text("ip").notNull(),
method: text("method"),
port: integer("port").notNull(),
internalPort: integer("internalPort"),
enabled: integer("enabled", { mode: "boolean" }).notNull().default(true),
path: text("path"),
pathMatchType: text("pathMatchType"), // exact, prefix, regex
rewritePath: text("rewritePath"), // if set, rewrites the path to this value before sending to the target
rewritePathType: text("rewritePathType"), // exact, prefix, regex, stripPrefix
priority: integer("priority").notNull().default(100),
mode: text("mode")
.$type<"http" | "tcp" | "udp" | "ssh" | "rdp" | "vnc">()
.notNull()
.default("http"),
authToken: text("authToken")
},
(table) => [
index("idx_targets_resourceId").on(table.resourceId),
index("idx_targets_siteId").on(table.siteId)
]
);
export const targetHealthCheck = sqliteTable("targetHealthCheck", {
targetHealthCheckId: integer("targetHealthCheckId").primaryKey({
@@ -663,50 +694,63 @@ export const setupTokens = sqliteTable("setupTokens", {
dateUsed: text("dateUsed")
});
export const newts = sqliteTable("newt", {
newtId: text("id").primaryKey(),
secretHash: text("secretHash").notNull(),
dateCreated: text("dateCreated").notNull(),
version: text("version"),
siteId: integer("siteId").references(() => sites.siteId, {
onDelete: "cascade"
})
});
export const clients = sqliteTable("clients", {
clientId: integer("clientId").primaryKey({ autoIncrement: true }),
orgId: text("orgId")
.references(() => orgs.orgId, {
export const newts = sqliteTable(
"newt",
{
newtId: text("id").primaryKey(),
secretHash: text("secretHash").notNull(),
dateCreated: text("dateCreated").notNull(),
version: text("version"),
siteId: integer("siteId").references(() => sites.siteId, {
onDelete: "cascade"
})
.notNull(),
exitNodeId: integer("exitNode").references(() => exitNodes.exitNodeId, {
onDelete: "set null"
}),
userId: text("userId").references(() => users.userId, {
// optionally tied to a user and in this case delete when the user deletes
onDelete: "cascade"
}),
niceId: text("niceId").notNull(),
name: text("name").notNull(),
pubKey: text("pubKey"),
olmId: text("olmId"), // to lock it to a specific olm optionally
subnet: text("subnet").notNull(),
exitNodeSubnet: text("exitNodeSubnet"), // this is the subnet when connecting to an exit node
megabytesIn: integer("bytesIn"),
megabytesOut: integer("bytesOut"),
lastBandwidthUpdate: text("lastBandwidthUpdate"),
lastPing: integer("lastPing"),
type: text("type").notNull(), // "olm"
online: integer("online", { mode: "boolean" }).notNull().default(false),
// endpoint: text("endpoint"),
lastHolePunch: integer("lastHolePunch"),
archived: integer("archived", { mode: "boolean" }).notNull().default(false),
blocked: integer("blocked", { mode: "boolean" }).notNull().default(false),
approvalState: text("approvalState").$type<
"pending" | "approved" | "denied"
>()
});
},
(table) => [
index("idx_newts_siteId").on(table.siteId)
]
);
export const clients = sqliteTable(
"clients",
{
clientId: integer("clientId").primaryKey({ autoIncrement: true }),
orgId: text("orgId")
.references(() => orgs.orgId, {
onDelete: "cascade"
})
.notNull(),
exitNodeId: integer("exitNode").references(() => exitNodes.exitNodeId, {
onDelete: "set null"
}),
userId: text("userId").references(() => users.userId, {
// optionally tied to a user and in this case delete when the user deletes
onDelete: "cascade"
}),
niceId: text("niceId").notNull(),
name: text("name").notNull(),
pubKey: text("pubKey"),
olmId: text("olmId"), // to lock it to a specific olm optionally
subnet: text("subnet").notNull(),
exitNodeSubnet: text("exitNodeSubnet"), // this is the subnet when connecting to an exit node
megabytesIn: integer("bytesIn"),
megabytesOut: integer("bytesOut"),
lastBandwidthUpdate: text("lastBandwidthUpdate"),
lastPing: integer("lastPing"),
type: text("type").notNull(), // "olm"
online: integer("online", { mode: "boolean" }).notNull().default(false),
// endpoint: text("endpoint"),
lastHolePunch: integer("lastHolePunch"),
archived: integer("archived", { mode: "boolean" }).notNull().default(false),
blocked: integer("blocked", { mode: "boolean" }).notNull().default(false),
approvalState: text("approvalState").$type<
"pending" | "approved" | "denied"
>()
},
(table) => [
index("idx_clients_orgId").on(table.orgId),
index("idx_clients_userId").on(table.userId)
]
);
export const clientSitesAssociationsCache = sqliteTable(
"clientSitesAssociationsCache",
@@ -734,23 +778,29 @@ export const clientSiteResourcesAssociationsCache = sqliteTable(
}
);
export const olms = sqliteTable("olms", {
olmId: text("id").primaryKey(),
secretHash: text("secretHash").notNull(),
dateCreated: text("dateCreated").notNull(),
version: text("version"),
agent: text("agent"),
name: text("name"),
clientId: integer("clientId").references(() => clients.clientId, {
// we will switch this depending on the current org it wants to connect to
onDelete: "set null"
}),
userId: text("userId").references(() => users.userId, {
// optionally tied to a user and in this case delete when the user deletes
onDelete: "cascade"
}),
archived: integer("archived", { mode: "boolean" }).notNull().default(false)
});
export const olms = sqliteTable(
"olms",
{
olmId: text("id").primaryKey(),
secretHash: text("secretHash").notNull(),
dateCreated: text("dateCreated").notNull(),
version: text("version"),
agent: text("agent"),
name: text("name"),
clientId: integer("clientId").references(() => clients.clientId, {
// we will switch this depending on the current org it wants to connect to
onDelete: "set null"
}),
userId: text("userId").references(() => users.userId, {
// optionally tied to a user and in this case delete when the user deletes
onDelete: "cascade"
}),
archived: integer("archived", { mode: "boolean" }).notNull().default(false)
},
(table) => [
index("idx_olms_userId").on(table.userId)
]
);
export const currentFingerprint = sqliteTable("currentFingerprint", {
fingerprintId: integer("id").primaryKey({ autoIncrement: true }),
@@ -912,17 +962,23 @@ export const twoFactorBackupCodes = sqliteTable("twoFactorBackupCodes", {
codeHash: text("codeHash").notNull()
});
export const sessions = sqliteTable("session", {
sessionId: text("id").primaryKey(),
userId: text("userId")
.notNull()
.references(() => users.userId, { onDelete: "cascade" }),
expiresAt: integer("expiresAt").notNull(),
issuedAt: integer("issuedAt"),
deviceAuthUsed: integer("deviceAuthUsed", { mode: "boolean" })
.notNull()
.default(false)
});
export const sessions = sqliteTable(
"session",
{
sessionId: text("id").primaryKey(),
userId: text("userId")
.notNull()
.references(() => users.userId, { onDelete: "cascade" }),
expiresAt: integer("expiresAt").notNull(),
issuedAt: integer("issuedAt"),
deviceAuthUsed: integer("deviceAuthUsed", { mode: "boolean" })
.notNull()
.default(false)
},
(table) => [
index("idx_sessions_userId").on(table.userId)
]
);
export const newtSessions = sqliteTable("newtSession", {
sessionId: text("id").primaryKey(),
@@ -940,21 +996,28 @@ export const olmSessions = sqliteTable("clientSession", {
expiresAt: integer("expiresAt").notNull()
});
export const userOrgs = sqliteTable("userOrgs", {
userId: text("userId")
.notNull()
.references(() => users.userId, { onDelete: "cascade" }),
orgId: text("orgId")
.references(() => orgs.orgId, {
onDelete: "cascade"
})
.notNull(),
isOwner: integer("isOwner", { mode: "boolean" }).notNull().default(false),
autoProvisioned: integer("autoProvisioned", {
mode: "boolean"
}).default(false),
pamUsername: text("pamUsername") // cleaned username for ssh and such
});
export const userOrgs = sqliteTable(
"userOrgs",
{
userId: text("userId")
.notNull()
.references(() => users.userId, { onDelete: "cascade" }),
orgId: text("orgId")
.references(() => orgs.orgId, {
onDelete: "cascade"
})
.notNull(),
isOwner: integer("isOwner", { mode: "boolean" }).notNull().default(false),
autoProvisioned: integer("autoProvisioned", {
mode: "boolean"
}).default(false),
pamUsername: text("pamUsername") // cleaned username for ssh and such
},
(table) => [
index("idx_userOrgs_userId").on(table.userId),
index("idx_userOrgs_orgId").on(table.orgId)
]
);
export const emailVerificationCodes = sqliteTable("emailVerificationCodes", {
codeId: integer("id").primaryKey({ autoIncrement: true }),
@@ -982,26 +1045,32 @@ export const actions = sqliteTable("actions", {
description: text("description")
});
export const roles = sqliteTable("roles", {
roleId: integer("roleId").primaryKey({ autoIncrement: true }),
orgId: text("orgId")
.references(() => orgs.orgId, {
onDelete: "cascade"
})
.notNull(),
isAdmin: integer("isAdmin", { mode: "boolean" }),
name: text("name").notNull(),
description: text("description"),
requireDeviceApproval: integer("requireDeviceApproval", {
mode: "boolean"
}).default(false),
sshSudoMode: text("sshSudoMode").default("full"), // "none" | "full" | "commands"
sshSudoCommands: text("sshSudoCommands").default("[]"),
sshCreateHomeDir: integer("sshCreateHomeDir", { mode: "boolean" }).default(
true
),
sshUnixGroups: text("sshUnixGroups").default("[]")
});
export const roles = sqliteTable(
"roles",
{
roleId: integer("roleId").primaryKey({ autoIncrement: true }),
orgId: text("orgId")
.references(() => orgs.orgId, {
onDelete: "cascade"
})
.notNull(),
isAdmin: integer("isAdmin", { mode: "boolean" }),
name: text("name").notNull(),
description: text("description"),
requireDeviceApproval: integer("requireDeviceApproval", {
mode: "boolean"
}).default(false),
sshSudoMode: text("sshSudoMode").default("full"), // "none" | "full" | "commands"
sshSudoCommands: text("sshSudoCommands").default("[]"),
sshCreateHomeDir: integer("sshCreateHomeDir", { mode: "boolean" }).default(
true
),
sshUnixGroups: text("sshUnixGroups").default("[]")
},
(table) => [
index("idx_roles_orgId").on(table.orgId)
]
);
export const userOrgRoles = sqliteTable(
"userOrgRoles",
@@ -1980,7 +2049,7 @@ export const aiSessionLog = sqliteTable(
.notNull()
.default(false),
statusCode: integer("statusCode"),
createdAt: integer("createdAt").notNull() // epoch ms
createdAt: integer("createdAt").notNull() // epoch seconds
},
(t) => [
index("idx_ai_session_log_org_created").on(t.orgId, t.createdAt),
+3 -1
View File
@@ -580,6 +580,8 @@ export async function recordUsage(input: UsageRecordInput): Promise<void> {
);
}
const timestamp = Math.floor(Date.now() / 1000);
usageRecordBuffer.push({
orgId: input.orgId,
providerId: input.providerId,
@@ -597,7 +599,7 @@ export async function recordUsage(input: UsageRecordInput): Promise<void> {
totalTokens,
costUsd: input.costUsd,
estimated: usage.estimated,
createdAt: input.createdAt ?? Date.now()
createdAt: input.createdAt ?? timestamp
});
// Flush immediately if buffer is full, otherwise schedule a flush
+85 -74
View File
@@ -1,5 +1,6 @@
import {
db,
primaryDb,
newts,
blueprints,
Blueprint,
@@ -80,93 +81,103 @@ export async function applyBlueprint({
trx,
siteId
);
});
// We need to update the targets on the newts from the successfully updated information
for (const result of publicResourcesResults) {
for (const target of result.targetsToUpdate) {
const [site] = await trx
.select()
.from(sites)
.innerJoin(newts, eq(sites.siteId, newts.siteId))
.where(
and(
eq(sites.siteId, target.siteId),
eq(sites.orgId, orgId),
eq(sites.type, "newt"),
isNotNull(sites.pubKey)
)
// Push updates to newts/clients only after the transaction has
// committed. Doing this while the transaction is still open can
// race with the writes (e.g. newts requesting config before the
// new targets/resources are actually visible), leaving them out
// of sync until manually toggled.
// We need to update the targets on the newts from the successfully updated information
for (const result of publicResourcesResults) {
for (const target of result.targetsToUpdate) {
// read from the primary: this determines whether/how we push
// the just-created target to the newt, so a lagging replica
// returning stale or missing data here would silently skip
// the push
const [site] = await primaryDb
.select()
.from(sites)
.innerJoin(newts, eq(sites.siteId, newts.siteId))
.where(
and(
eq(sites.siteId, target.siteId),
eq(sites.orgId, orgId),
eq(sites.type, "newt"),
isNotNull(sites.pubKey)
)
.limit(1);
)
.limit(1);
if (site) {
logger.debug(
`Updating target ${target.targetId} on site ${site.sites.siteId}`
if (site) {
logger.debug(
`Updating target ${target.targetId} on site ${site.sites.siteId}`
);
// see if you can find a matching target health check from the healthchecksToUpdate array
const matchingHealthcheck =
result.healthchecksToUpdate.find(
(hc) => hc.targetId === target.targetId
);
// see if you can find a matching target health check from the healthchecksToUpdate array
const matchingHealthcheck =
result.healthchecksToUpdate.find(
(hc) => hc.targetId === target.targetId
);
if (["http", "tcp", "udp"].includes(target.mode)) {
await addProxyTargets(
site.newt.newtId,
[target],
matchingHealthcheck
? [matchingHealthcheck]
: [],
result.proxyResource.mode === "udp"
? "udp"
: "tcp",
site.newt.version
);
} else if (
["ssh", "rdp", "vnc"].includes(target.mode)
) {
await sendBrowserGatewayTargets(
site.newt.newtId,
[target],
site.newt.version
);
}
if (["http", "tcp", "udp"].includes(target.mode)) {
await addProxyTargets(
site.newt.newtId,
[target],
matchingHealthcheck
? [matchingHealthcheck]
: [],
result.proxyResource.mode === "udp"
? "udp"
: "tcp",
site.newt.version
);
} else if (
["ssh", "rdp", "vnc"].includes(target.mode)
) {
await sendBrowserGatewayTargets(
site.newt.newtId,
[target],
site.newt.version
);
}
}
}
}
logger.debug(
`Successfully updated public resources for org ${orgId}: ${JSON.stringify(publicResourcesResults)}`
);
logger.debug(
`Successfully updated public resources for org ${orgId}: ${JSON.stringify(publicResourcesResults)}`
);
// We need to update the targets on the newts from the successfully updated information
for (const result of privateResourcesResults) {
rebuildClientAssociationsFromSiteResource(
result.newSiteResource
// We need to update the targets on the newts from the successfully updated information
for (const result of privateResourcesResults) {
rebuildClientAssociationsFromSiteResource(
result.newSiteResource
)
.then(() =>
waitForSiteResourceRebuildIdle(
result.newSiteResource.siteResourceId
)
)
.then(() =>
waitForSiteResourceRebuildIdle(
result.newSiteResource.siteResourceId
)
.then(() =>
handleMessagingForUpdatedSiteResource(
result.oldSiteResource,
result.newSiteResource,
result.oldSites.map((s) => s.siteId),
result.newSites.map((s) => s.siteId)
)
.then(() =>
handleMessagingForUpdatedSiteResource(
result.oldSiteResource,
result.newSiteResource,
result.oldSites.map((s) => s.siteId),
result.newSites.map((s) => s.siteId)
)
)
.catch((e) => {
logger.error(
`Failed to rebuild and handle messaging for site resource ${result.newSiteResource.siteResourceId}. Error: ${e}`
);
});
}
)
.catch((e) => {
logger.error(
`Failed to rebuild and handle messaging for site resource ${result.newSiteResource.siteResourceId}. Error: ${e}`
);
});
}
logger.debug(
`Successfully updated private resources for org ${orgId}: ${JSON.stringify(privateResourcesResults)}`
);
});
logger.debug(
`Successfully updated private resources for org ${orgId}: ${JSON.stringify(privateResourcesResults)}`
);
blueprintSucceeded = true;
blueprintMessage = "Blueprint applied successfully";
+2 -2
View File
@@ -101,7 +101,7 @@ export const AuthSchema = z.object({
export const RuleSchema = z
.object({
action: z.enum(["allow", "deny", "pass"]),
match: z.enum(["cidr", "path", "ip", "country", "asn", "region"]),
match: z.enum(["cidr", "path", "ip", "country", "country_is_not", "asn", "region"]),
value: z.coerce.string(),
priority: z.int().optional(),
enabled: z.boolean().optional().default(true)
@@ -136,7 +136,7 @@ export const RuleSchema = z
)
.refine(
(rule) => {
if (rule.match === "country") {
if (rule.match === "country" || rule.match === "country_is_not") {
if (!hasMaxmindCountryDb) {
return false;
}
+10 -16
View File
@@ -31,7 +31,6 @@ export async function validateAndConstructDomain(
subdomain?: string | null
): Promise<DomainValidationResult> {
try {
// Query domain with organization access check
const [domainRes] = await db
.select()
.from(domains)
@@ -42,6 +41,10 @@ export async function validateAndConstructDomain(
eq(orgDomains.orgId, orgId),
eq(orgDomains.domainId, domainId)
)
)
.leftJoin(
domainNamespaces,
eq(domainNamespaces.domainId, domainId)
);
// Check if domain exists
@@ -52,8 +55,7 @@ export async function validateAndConstructDomain(
};
}
// Check if organization has access to domain
if (domainRes.orgDomains && domainRes.orgDomains.orgId !== orgId) {
if (!domainRes.orgDomains && !domainRes.domainNamespaces) {
return {
success: false,
error: `Organization does not have access to domain with ID ${domainId}`
@@ -84,19 +86,11 @@ export async function validateAndConstructDomain(
}
// Wildcard subdomains are not allowed on namespace (provided/free) domains
if (isWildcard) {
const [namespaceDomain] = await db
.select()
.from(domainNamespaces)
.where(eq(domainNamespaces.domainId, domainId))
.limit(1);
if (namespaceDomain) {
return {
success: false,
error: "Wildcard subdomains are not supported for provided or free domains. Use a specific subdomain instead."
};
}
if (isWildcard && domainRes.domainNamespaces) {
return {
success: false,
error: "Wildcard subdomains are not supported for provided or free domains. Use a specific subdomain instead."
};
}
if (
+50 -1
View File
@@ -3,6 +3,8 @@ import config from "./config";
import { getHostMeta } from "./hostMeta";
import logger from "@server/logger";
import {
aiProviders,
aiUsageRecords,
alertRules,
apiKeys,
blueprints,
@@ -11,7 +13,16 @@ import {
siteResources
} from "@server/db";
import { sites, users, orgs, resources, clients, idp } from "@server/db";
import { eq, count, notInArray, and, isNotNull, isNull } from "drizzle-orm";
import {
eq,
count,
countDistinct,
notInArray,
and,
isNotNull,
isNull,
gte
} from "drizzle-orm";
import { APP_VERSION } from "./consts";
import crypto from "crypto";
import { UserType } from "@server/types/UserTypes";
@@ -172,6 +183,25 @@ class TelemetryClient {
.select({ count: count() })
.from(blueprints);
const [aiProvidersCount] = await db
.select({ count: count() })
.from(aiProviders);
const [orgsWithAiProviders] = await db
.select({ count: countDistinct(aiProviders.orgId) })
.from(aiProviders);
const usageWindowStart =
Math.floor(Date.now() / 1000) -
this.collectionIntervalDays * 24 * 60 * 60;
const [aiUsageRecordsRecent] = await db
.select({ count: count() })
.from(aiUsageRecords)
.where(gte(aiUsageRecords.createdAt, usageWindowStart));
const [orgsWithRecentAiUsage] = await db
.select({ count: countDistinct(aiUsageRecords.orgId) })
.from(aiUsageRecords)
.where(gte(aiUsageRecords.createdAt, usageWindowStart));
const supporterKey = config.getSupporterData();
const allPrivateResources = await db.select().from(siteResources);
@@ -182,6 +212,7 @@ class TelemetryClient {
let numPrivResourceCidr = 0;
let numPrivResourceHttp = 0;
let numPrivResourceSsh = 0;
let numPrivResourceInference = 0;
for (const res of allPrivateResources) {
if (res.mode === "host") {
numPrivResourceHosts += 1;
@@ -191,6 +222,8 @@ class TelemetryClient {
numPrivResourceHttp += 1;
} else if (res.mode === "ssh") {
numPrivResourceSsh += 1;
} else if (res.mode === "inference") {
numPrivResourceInference += 1;
}
if (res.alias) {
@@ -211,6 +244,11 @@ class TelemetryClient {
numPrivateResourceCidr: numPrivResourceCidr,
numPrivateResourceHttp: numPrivResourceHttp,
numPrivateResourceSsh: numPrivResourceSsh,
numPrivateResourceInference: numPrivResourceInference,
numAiProviders: aiProvidersCount.count,
numOrgsWithAiProviders: orgsWithAiProviders.count,
numAiUsageRecordsRecent: aiUsageRecordsRecent.count,
numOrgsWithRecentAiUsage: orgsWithRecentAiUsage.count,
numAlertRules: numAlertRules.count,
numUserDevices: userDevicesCount.count,
numMachineClients: machineClients.count,
@@ -323,6 +361,17 @@ class TelemetryClient {
num_resources_non_http: stats.resources.filter(
(r) => r.mode !== "http"
).length,
num_resources_ai_gateway: stats.resources.filter(
(r) => r.mode === "inference"
).length,
num_private_resources_ai_gateway:
stats.numPrivateResourceInference,
num_ai_providers: stats.numAiProviders,
num_orgs_with_ai_providers: stats.numOrgsWithAiProviders,
num_ai_usage_records_recent:
stats.numAiUsageRecordsRecent,
num_orgs_with_recent_ai_usage:
stats.numOrgsWithRecentAiUsage,
num_newt_sites: stats.sites.filter((s) => s.type === "newt")
.length,
num_local_sites: stats.sites.filter(
+15
View File
@@ -0,0 +1,15 @@
import { gzipSync, gunzipSync } from "zlib";
/**
* Gzip a string and return it as base64 so it can be stored in a TEXT column.
*/
export function compressText(value: string): string {
return gzipSync(Buffer.from(value, "utf8")).toString("base64");
}
/**
* Reverse of compressText - base64-decode and gunzip back to the original string.
*/
export function decompressText(value: string): string {
return gunzipSync(Buffer.from(value, "base64")).toString("utf8");
}
@@ -68,6 +68,11 @@ export async function verifyApiKeyAccessTokenAccess(
);
}
if (apiKey.isRoot) {
// Root keys can access any access token in any org
return next();
}
if (!resource.orgId) {
return next(
createHttpError(
@@ -25,6 +25,7 @@ import {
import logger from "@server/logger";
import { and, eq, gt, desc, max, sql } from "drizzle-orm";
import { decrypt } from "@server/lib/crypto";
import { decompressText } from "@server/lib/textCompression";
import config from "@server/lib/config";
import {
LogType,
@@ -680,8 +681,8 @@ export class LogStreamingManager {
Record<string, unknown> & { id: number }
>;
case "aiSession":
return (await logsDb
case "aiSession": {
const rows = (await logsDb
.select()
.from(aiSessionLog)
.where(
@@ -694,6 +695,33 @@ export class LogStreamingManager {
.limit(limit)) as Array<
Record<string, unknown> & { id: number }
>;
const compressedFields = [
"requestBody",
"responseBody",
"normalizedRequest",
"normalizedResponse"
] as const;
for (const row of rows) {
for (const field of compressedFields) {
const value = row[field];
if (typeof value !== "string") {
continue;
}
try {
row[field] = decompressText(value);
} catch (error) {
logger.error(
`Failed to decompress AI session log field ${field}`,
{ error }
);
}
}
}
return rows;
}
}
}
+10 -14
View File
@@ -149,12 +149,8 @@ LQIDAQAB
}
// Count used sites and users for license comparison
const [siteCountRes] = await db
.select({ value: count() })
.from(sites);
const [userCountRes] = await db
.select({ value: count() })
.from(users);
const [siteCountRes] = await db.select({ value: count() }).from(sites);
const [userCountRes] = await db.select({ value: count() }).from(users);
const status: LicenseStatus = {
hostId: this.hostMeta.hostMetaId,
@@ -276,10 +272,13 @@ LQIDAQAB
logger.error(
`Allowing failure. Will retry one more time at next run interval.`
);
// return last known good status
return this.statusCache.get(
// Fall back to last known good status if we have
// one cached; otherwise return the freshly built
// status (with defaults) rather than undefined.
const lastKnownStatus = this.statusCache.get(
this.statusKey
) as LicenseStatus;
) as LicenseStatus | undefined;
return lastKnownStatus ?? status;
} else {
// Subsequent failures: fail abruptly
throw e;
@@ -368,10 +367,7 @@ LQIDAQAB
}
// Only consider quantity if defined and >= 0 (quantity = users, quantity_2 = sites)
if (
cached.quantity_2 !== undefined &&
cached.quantity_2 >= 0
) {
if (cached.quantity_2 !== undefined && cached.quantity_2 >= 0) {
status.maxSites =
(status.maxSites ?? 0) + cached.quantity_2;
}
@@ -561,7 +557,7 @@ LQIDAQAB
// Calculate exponential backoff delay
const retryDelay = Math.floor(
initialRetryDelay *
Math.pow(exponentialFactor, attempt - 1)
Math.pow(exponentialFactor, attempt - 1)
);
logger.debug(
@@ -18,6 +18,7 @@ import { and, eq, lt } from "drizzle-orm";
import cache from "#private/lib/cache";
import { calculateCutoffTimestamp } from "@server/lib/cleanupLogs";
import { sanitizeString } from "@server/lib/sanitize";
import { compressText } from "@server/lib/textCompression";
import type { AiCapability } from "@server/lib/aiCapabilities";
import {
normalizeAiRequest,
@@ -151,17 +152,14 @@ async function getRetentionDays(orgId: string): Promise<number> {
}
export async function cleanUpOldLogs(orgId: string, retentionDays: number) {
// calculateCutoffTimestamp returns a seconds-epoch cutoff (built for
// requestAuditLog.timestamp), but aiSessionLog.createdAt is ms-epoch to
// match aiUsageRecords - convert before comparing.
const cutoffTimestampMs = calculateCutoffTimestamp(retentionDays) * 1000;
const cutoffTimestamp = calculateCutoffTimestamp(retentionDays);
try {
await logsDb
.delete(aiSessionLog)
.where(
and(
lt(aiSessionLog.createdAt, cutoffTimestampMs),
lt(aiSessionLog.createdAt, cutoffTimestamp),
eq(aiSessionLog.orgId, orgId)
)
);
@@ -243,6 +241,8 @@ export function logAiSession(data: {
);
}
const timestamp = Math.floor(Date.now() / 1000);
sessionLogBuffer.push({
sessionId: data.sessionId,
orgId: sanitizeString(data.orgId),
@@ -256,13 +256,19 @@ export function logAiSession(data: {
),
requestedModel: sanitizeString(data.requestedModel),
isStream: data.isStream,
requestBody: sanitizeString(requestBodyText.value),
responseBody: sanitizeString(responseBodyText.value),
requestBody: compressText(
sanitizeString(requestBodyText.value)
),
responseBody: compressText(
sanitizeString(responseBodyText.value)
),
normalizedRequest: normalizedRequestText
? sanitizeString(normalizedRequestText.value)
? compressText(sanitizeString(normalizedRequestText.value))
: undefined,
normalizedResponse: normalizedResponseText
? sanitizeString(normalizedResponseText.value)
? compressText(
sanitizeString(normalizedResponseText.value)
)
: undefined,
truncated:
requestBodyText.truncated ||
@@ -270,7 +276,7 @@ export function logAiSession(data: {
(normalizedRequestText?.truncated ?? false) ||
(normalizedResponseText?.truncated ?? false),
statusCode: data.statusCode,
createdAt: Date.now()
createdAt: timestamp
});
// Flush immediately if buffer is full, otherwise schedule a flush
@@ -18,7 +18,7 @@ export const aiUsageAnalyticsFiltersQuery = z.object({
.refine((val) => !isNaN(Date.parse(val)), {
error: "timeStart must be a valid ISO date string"
})
.transform((val) => new Date(val).getTime())
.transform((val) => Math.floor(new Date(val).getTime() / 1000))
.prefault(() => getSevenDaysAgo().toISOString())
.openapi({
type: "string",
@@ -31,7 +31,7 @@ export const aiUsageAnalyticsFiltersQuery = z.object({
.refine((val) => !isNaN(Date.parse(val)), {
error: "timeEnd must be a valid ISO date string"
})
.transform((val) => new Date(val).getTime())
.transform((val) => Math.floor(new Date(val).getTime() / 1000))
.prefault(() => new Date().toISOString())
.openapi({
type: "string",
@@ -122,12 +122,12 @@ export function buildAiUsageWhere(
);
}
// Buckets createdAt (epoch ms) down to a per-day string, dialect-aware, same
// approach as the DATE_TRUNC/DATE branch in queryRequestAnalytics.ts.
// Buckets createdAt (epoch seconds) down to a per-day string, dialect-aware,
// same approach as the DATE_TRUNC/DATE branch in queryRequestAnalytics.ts.
export function dayBucketExpr() {
return driver === "pg"
? sql<string>`DATE_TRUNC('day', TO_TIMESTAMP(${aiUsageRecords.createdAt} / 1000.0))`
: sql<string>`DATE(${aiUsageRecords.createdAt} / 1000, 'unixepoch')`;
? sql<string>`DATE_TRUNC('day', TO_TIMESTAMP(${aiUsageRecords.createdAt}))`
: sql<string>`DATE(${aiUsageRecords.createdAt}, 'unixepoch')`;
}
export type DailyMetricRow<K extends string> = {
@@ -11,7 +11,8 @@ import {
queryAiSessionLogsQuery,
queryAiSessionLogsParams,
queryAiSession,
countAiSessionQuery
countAiSessionQuery,
decompressAiSessionLogRow
} from "./queryAiSessionLog";
import { generateCSV } from "./generateCSV";
@@ -87,7 +88,9 @@ export async function exportAiSessionLogs(
const baseQuery = queryAiSession(data);
const log = await baseQuery.limit(MAX_EXPORT_LIMIT);
const log = (await baseQuery.limit(MAX_EXPORT_LIMIT)).map(
decompressAiSessionLogRow
);
const csvData = generateCSV(log);
+35 -3
View File
@@ -24,6 +24,7 @@ import { AI_CAPABILITIES } from "@server/lib/aiCapabilities";
import response from "@server/lib/response";
import logger from "@server/logger";
import { getSevenDaysAgo } from "@app/lib/getSevenDaysAgo";
import { decompressText } from "@server/lib/textCompression";
export const queryAiSessionLogsQuery = z.strictObject({
// iso string just validate its a parseable date
@@ -32,7 +33,7 @@ export const queryAiSessionLogsQuery = z.strictObject({
.refine((val) => !isNaN(Date.parse(val)), {
error: "timeStart must be a valid ISO date string"
})
.transform((val) => new Date(val).getTime())
.transform((val) => Math.floor(new Date(val).getTime() / 1000))
.prefault(() => getSevenDaysAgo().toISOString())
.openapi({
type: "string",
@@ -45,7 +46,7 @@ export const queryAiSessionLogsQuery = z.strictObject({
.refine((val) => !isNaN(Date.parse(val)), {
error: "timeEnd must be a valid ISO date string"
})
.transform((val) => new Date(val).getTime())
.transform((val) => Math.floor(new Date(val).getTime() / 1000))
.optional()
.prefault(() => new Date().toISOString())
.openapi({
@@ -166,6 +167,35 @@ export function queryAiSession(data: Q) {
.orderBy(desc(aiSessionLog.createdAt));
}
function decompressField(value: string | null): string | null {
if (value == null) {
return value;
}
try {
return decompressText(value);
} catch (error) {
logger.error("Failed to decompress AI session log field", { error });
return value;
}
}
export function decompressAiSessionLogRow<
T extends {
requestBody: string | null;
responseBody: string | null;
normalizedRequest: string | null;
normalizedResponse: string | null;
}
>(row: T): T {
return {
...row,
requestBody: decompressField(row.requestBody),
responseBody: decompressField(row.responseBody),
normalizedRequest: decompressField(row.normalizedRequest),
normalizedResponse: decompressField(row.normalizedResponse)
};
}
async function enrichWithDetails(
logs: Awaited<ReturnType<typeof queryAiSession>>
) {
@@ -620,7 +650,9 @@ export async function queryAiSessionLogs(
const baseQuery = queryAiSession(data);
const logsRaw = await baseQuery.limit(data.limit).offset(data.offset);
const logsRaw = (
await baseQuery.limit(data.limit).offset(data.offset)
).map(decompressAiSessionLogRow);
const log = await enrichWithDetails(logsRaw);
@@ -30,14 +30,14 @@ const queryAiUsageFilterOptionsQuery = z.object({
.refine((val) => !isNaN(Date.parse(val)), {
error: "timeStart must be a valid ISO date string"
})
.transform((val) => new Date(val).getTime())
.transform((val) => Math.floor(new Date(val).getTime() / 1000))
.prefault(() => getSevenDaysAgo().toISOString()),
timeEnd: z
.string()
.refine((val) => !isNaN(Date.parse(val)), {
error: "timeEnd must be a valid ISO date string"
})
.transform((val) => new Date(val).getTime())
.transform((val) => Math.floor(new Date(val).getTime() / 1000))
.prefault(() => new Date().toISOString())
});
+7 -2
View File
@@ -6,7 +6,7 @@ import HttpCode from "@server/types/HttpCode";
import { response } from "@server/lib/response";
import { db } from "@server/db";
import { passwordResetTokens, users } from "@server/db";
import { eq } from "drizzle-orm";
import { eq, and } from "drizzle-orm";
import { alphabet, generateRandomString, sha256 } from "oslo/crypto";
import { createDate } from "oslo";
import logger from "@server/logger";
@@ -49,7 +49,12 @@ export async function requestPasswordReset(
const existingUser = await db
.select()
.from(users)
.where(eq(users.email, email));
.where(
and(
eq(users.email, email),
eq(users.type, UserType.Internal)
)
);
if (!existingUser || !existingUser.length) {
await randomDelay(2000);
+15 -11
View File
@@ -66,6 +66,10 @@ import * as aiBudget from "@server/routers/aiBudget";
import * as virtualApiKey from "@server/routers/virtualApiKey";
import * as certificates from "@server/routers/certificates";
function rateLimitIdentityKey(value: unknown): string {
return typeof value === "string" ? value.trim().toLowerCase() : "";
}
// Root routes
export const unauthenticated = Router();
@@ -1927,7 +1931,7 @@ authRouter.put(
windowMs: 15 * 60 * 1000,
max: 15,
keyGenerator: (req) =>
`signup:${ipKeyGenerator(req.ip || "")}:${req.body.email}`,
`signup:${ipKeyGenerator(req.ip || "")}:${rateLimitIdentityKey(req.body.email)}`,
handler: (req, res, next) => {
const message = `You can only sign up ${15} times every ${15} minutes. Please try again later.`;
return next(createHttpError(HttpCode.TOO_MANY_REQUESTS, message));
@@ -1942,7 +1946,7 @@ authRouter.post(
windowMs: 15 * 60 * 1000,
max: 15,
keyGenerator: (req) =>
`login:${req.body.email || ipKeyGenerator(req.ip || "")}`,
`login:${rateLimitIdentityKey(req.body.email) || ipKeyGenerator(req.ip || "")}`,
handler: (req, res, next) => {
const message = `You can only log in ${15} times every ${15} minutes. Please try again later.`;
return next(createHttpError(HttpCode.TOO_MANY_REQUESTS, message));
@@ -1959,7 +1963,7 @@ authRouter.post(
windowMs: 15 * 60 * 1000,
max: 15,
keyGenerator: (req) =>
`lookupUser:${req.body.identifier || ipKeyGenerator(req.ip || "")}`,
`lookupUser:${rateLimitIdentityKey(req.body.identifier) || ipKeyGenerator(req.ip || "")}`,
handler: (req, res, next) => {
const message = `You can only lookup users ${15} times every ${15} minutes. Please try again later.`;
return next(createHttpError(HttpCode.TOO_MANY_REQUESTS, message));
@@ -2037,7 +2041,7 @@ authRouter.post(
windowMs: 15 * 60 * 1000,
max: 15,
keyGenerator: (req) => {
return `signup:${req.body.email || req.user?.userId || ipKeyGenerator(req.ip || "")}`;
return `signup:${rateLimitIdentityKey(req.body.email) || req.user?.userId || ipKeyGenerator(req.ip || "")}`;
},
handler: (req, res, next) => {
const message = `You can only enable 2FA ${15} times every ${15} minutes. Please try again later.`;
@@ -2053,7 +2057,7 @@ authRouter.post(
windowMs: 15 * 60 * 1000,
max: 15,
keyGenerator: (req) => {
return `signup:${req.body.email || req.user?.userId || ipKeyGenerator(req.ip || "")}`;
return `signup:${rateLimitIdentityKey(req.body.email) || req.user?.userId || ipKeyGenerator(req.ip || "")}`;
},
handler: (req, res, next) => {
const message = `You can only request a 2FA code ${15} times every ${15} minutes. Please try again later.`;
@@ -2085,7 +2089,7 @@ authRouter.post(
windowMs: 15 * 60 * 1000,
max: 15,
keyGenerator: (req) =>
`signup:${req.body.email || ipKeyGenerator(req.ip || "")}`,
`signup:${rateLimitIdentityKey(req.body.email) || ipKeyGenerator(req.ip || "")}`,
handler: (req, res, next) => {
const message = `You can only sign up ${15} times every ${15} minutes. Please try again later.`;
return next(createHttpError(HttpCode.TOO_MANY_REQUESTS, message));
@@ -2103,7 +2107,7 @@ authRouter.post(
windowMs: 15 * 60 * 1000,
max: 15,
keyGenerator: (req) =>
`requestEmailVerificationCode:${req.user?.email || ipKeyGenerator(req.ip || "")}`,
`requestEmailVerificationCode:${rateLimitIdentityKey(req.user?.email) || ipKeyGenerator(req.ip || "")}`,
handler: (req, res, next) => {
const message = `You can only request an email verification code ${15} times every ${15} minutes. Please try again later.`;
return next(createHttpError(HttpCode.TOO_MANY_REQUESTS, message));
@@ -2125,7 +2129,7 @@ authRouter.post(
windowMs: 15 * 60 * 1000,
max: 15,
keyGenerator: (req) =>
`requestPasswordReset:${req.body.email || ipKeyGenerator(req.ip || "")}`,
`requestPasswordReset:${rateLimitIdentityKey(req.body.email) || ipKeyGenerator(req.ip || "")}`,
handler: (req, res, next) => {
const message = `You can only request a password reset ${15} times every ${15} minutes. Please try again later.`;
return next(createHttpError(HttpCode.TOO_MANY_REQUESTS, message));
@@ -2141,7 +2145,7 @@ authRouter.post(
windowMs: 15 * 60 * 1000,
max: 15,
keyGenerator: (req) =>
`resetPassword:${req.body.email || ipKeyGenerator(req.ip || "")}`,
`resetPassword:${rateLimitIdentityKey(req.body.email) || ipKeyGenerator(req.ip || "")}`,
handler: (req, res, next) => {
const message = `You can only request a password reset ${15} times every ${15} minutes. Please try again later.`;
return next(createHttpError(HttpCode.TOO_MANY_REQUESTS, message));
@@ -2188,7 +2192,7 @@ authRouter.post(
windowMs: 15 * 60 * 1000,
max: 15,
keyGenerator: (req) =>
`authWithWhitelist:${ipKeyGenerator(req.ip || "")}:${req.body.email}:${req.params.resourceId}`,
`authWithWhitelist:${ipKeyGenerator(req.ip || "")}:${rateLimitIdentityKey(req.body.email)}:${req.params.resourceId}`,
handler: (req, res, next) => {
const message = `You can only request an email OTP ${15} times every ${15} minutes. Please try again later.`;
return next(createHttpError(HttpCode.TOO_MANY_REQUESTS, message));
@@ -2240,7 +2244,7 @@ authRouter.post(
windowMs: 15 * 60 * 1000, // 15 minutes
max: 10, // Allow 10 authentication attempts per 15 minutes per IP
keyGenerator: (req) => {
return `securityKeyAuth:${req.body.email || ipKeyGenerator(req.ip || "")}`;
return `securityKeyAuth:${rateLimitIdentityKey(req.body.email) || ipKeyGenerator(req.ip || "")}`;
},
handler: (req, res, next) => {
const message = `You can only attempt security key authentication ${10} times every ${15} minutes. Please try again later.`;
+5 -3
View File
@@ -16,13 +16,12 @@ const getOrgSchema = z.strictObject({
});
export type GetOrgResponse = {
org: Org;
org: Omit<Org, "sshCaPrivateKey">;
};
const GetOrgResponseDataSchema = z.object({
org: z.object({}).passthrough()
});
registry.registerPath({
method: "get",
path: "/org/{orgId}",
@@ -76,9 +75,12 @@ export async function getOrg(
);
}
// sshCaPrivateKey is encrypted anyway but just to be safe
const { sshCaPrivateKey: _, ...orgWithoutPrivateKey } = org;
return response<GetOrgResponse>(res, {
data: {
org
org: orgWithoutPrivateKey
},
success: true,
error: false,
+10 -7
View File
@@ -18,7 +18,7 @@ const getSiteResourceParamsSchema = z.strictObject({
.pipe(z.int().positive().optional())
.optional(),
niceId: z.string().optional(),
orgId: z.string()
orgId: z.string().optional()
});
async function query(siteResourceId?: number, niceId?: string, orgId?: string) {
@@ -34,6 +34,13 @@ async function query(siteResourceId?: number, niceId?: string, orgId?: string) {
)
.limit(1);
return siteResource;
} else if (siteResourceId) {
const [siteResource] = await db
.select()
.from(siteResources)
.where(eq(siteResources.siteResourceId, siteResourceId))
.limit(1);
return siteResource;
} else if (niceId && orgId) {
const [siteResource] = await db
.select()
@@ -60,9 +67,7 @@ registry.registerPath({
tags: [OpenAPITags.PrivateResourceLegacy],
request: {
params: z.object({
siteResourceId: z.number(),
siteId: z.number(),
orgId: z.string()
siteResourceId: z.number()
})
},
responses: {
@@ -90,9 +95,7 @@ registry.registerPath({
tags: [OpenAPITags.PrivateResource],
request: {
params: z.object({
siteResourceId: z.number(),
siteId: z.number(),
orgId: z.string()
siteResourceId: z.number()
})
},
responses: {
+6 -3
View File
@@ -223,7 +223,7 @@ export default async function migration() {
sql`ALTER TABLE "subscriptions" ADD COLUMN "override" boolean DEFAULT false;`
);
await db.execute(
sql`ALTER TABLE "orgs" ADD COLUMN "settingsLogRetentionDaysAISessions" integer DEFAULT 7 NOT NULL;`
sql`ALTER TABLE "orgs" ADD COLUMN "settingsLogRetentionDaysAISessions" integer DEFAULT 0 NOT NULL;`
);
await db.execute(
sql`ALTER TABLE "siteResources" ADD COLUMN "requiresExitNodeConnection" boolean DEFAULT false NOT NULL;`
@@ -345,6 +345,9 @@ export default async function migration() {
await db.execute(
sql`ALTER TABLE "virtualApiKeys" ADD CONSTRAINT "virtualApiKeys_createdByUserId_user_id_fk" FOREIGN KEY ("createdByUserId") REFERENCES "public"."user"("id") ON DELETE set null ON UPDATE no action;`
);
await db.execute(
sql`ALTER TABLE "eventStreamingDestinations" ADD "sendAISessionLogs" boolean DEFAULT false NOT NULL;`
);
await db.execute(
sql`CREATE INDEX "idx_ai_budget_breach_events_budget_created" ON "aiBudgetBreachEvents" USING btree ("budgetId","createdAt");`
);
@@ -451,14 +454,14 @@ export default async function migration() {
throw new Error(fromZodError(parsedConfig.error).toString());
}
traefikConfig.experimental.plugins.badger.version = "v1.6.1";
traefikConfig.experimental.plugins.badger.version = "v1.7.0";
const updatedTraefikYaml = yaml.dump(traefikConfig);
fs.writeFileSync(traefikPath, updatedTraefikYaml, "utf8");
console.log(
"Updated the version of Badger in your Traefik configuration to v1.6.1"
"Updated the version of Badger in your Traefik configuration to v1.7.0"
);
} catch (e) {
console.log(
+6 -3
View File
@@ -397,11 +397,14 @@ export default async function migration() {
`ALTER TABLE 'clients' ADD 'exitNodeSubnet' text;`
).run();
db.prepare(
`ALTER TABLE 'orgs' ADD 'settingsLogRetentionDaysAISessions' integer DEFAULT 7 NOT NULL;`
`ALTER TABLE 'orgs' ADD 'settingsLogRetentionDaysAISessions' integer DEFAULT 0 NOT NULL;`
).run();
db.prepare(
`ALTER TABLE 'siteResources' ADD 'requiresExitNodeConnection' integer DEFAULT false NOT NULL;`
).run();
db.prepare(
`ALTER TABLE 'eventStreamingDestinations' ADD 'sendAISessionLogs' integer DEFAULT false NOT NULL;`
).run();
const insertRoleAction = db.prepare(`
INSERT INTO 'roleActions' ("roleId", "actionId", "orgId")
@@ -456,14 +459,14 @@ export default async function migration() {
throw new Error(fromZodError(parsedConfig.error).toString());
}
traefikConfig.experimental.plugins.badger.version = "v1.6.1";
traefikConfig.experimental.plugins.badger.version = "v1.7.0";
const updatedTraefikYaml = yaml.dump(traefikConfig);
fs.writeFileSync(traefikPath, updatedTraefikYaml, "utf8");
console.log(
"Updated the version of Badger in your Traefik configuration to v1.6.1"
"Updated the version of Badger in your Traefik configuration to v1.7.0"
);
} catch (e) {
console.log(
+5 -3
View File
@@ -276,7 +276,9 @@ export default function AiSessionLogsPage() {
cell: ({ row }) => {
return (
<div className="whitespace-nowrap">
{new Date(row.original.createdAt).toLocaleString()}
{new Date(
row.original.createdAt * 1000
).toLocaleString()}
</div>
);
}
@@ -715,8 +717,8 @@ function generateSampleAiSessionLogs(): QueryAiSessionLogResponse["log"] {
null
];
const now = Date.now();
const sevenDaysAgoMs = now - 7 * 24 * 60 * 60 * 1000;
const now = Math.floor(Date.now() / 1000);
const sevenDaysAgoMs = now - 7 * 24 * 60 * 60;
return Array.from({ length: 10 }, (_, i) => {
const provider =
@@ -50,8 +50,6 @@ import {
import { useEnvContext } from "@app/hooks/useEnvContext";
import { usePaidStatus } from "@app/hooks/usePaidStatus";
import { toast } from "@app/hooks/useToast";
import { PaidFeaturesAlert } from "@app/components/PaidFeaturesAlert";
import { tierMatrix, TierFeature } from "@server/lib/billing/tierMatrix";
import { createApiClient, formatAxiosError } from "@app/lib/api";
import {
createBrowserGatewayTargetFormSchema,
@@ -59,7 +57,6 @@ import {
selectedSiteSchema,
type SshSettingsFormValues
} from "@app/lib/browserGatewayTargetFormSchema";
import { DockerManager, DockerState } from "@app/lib/docker";
import { orgQueries } from "@app/lib/queries";
import { finalizeSubdomainSanitize } from "@app/lib/subdomain-utils";
import { zodResolver } from "@hookform/resolvers/zod";
@@ -328,19 +325,20 @@ export default function Page() {
const rawResourcesAllowed =
env.flags.allowRawResources &&
(build !== "saas" || remoteExitNodes.length > 0);
const enterpriseModesAllowed =
!env.flags.disableEnterpriseFeatures;
const availableTypes = useMemo((): NewResourceType[] => {
const base: NewResourceType[] = ["http", "inference"];
if (enterpriseModesAllowed) {
base.push("ssh", "rdp", "vnc");
}
const base: NewResourceType[] = [
"http",
"inference",
"ssh",
"rdp",
"vnc"
];
if (rawResourcesAllowed) {
base.push("tcp", "udp");
}
return base;
}, [enterpriseModesAllowed, rawResourcesAllowed]);
}, [rawResourcesAllowed]);
useEffect(() => {
if (!availableTypes.includes(resourceType)) {
+74
View File
@@ -0,0 +1,74 @@
import { assertEquals } from "@test/assert";
import { detectLocale } from "./detectLocale";
function runTests() {
assertEquals(
detectLocale("zh-TW,zh;q=0.9,en-US;q=0.8,en;q=0.7,ja;q=0.6"),
"zh-TW",
"An exact regional match should take precedence over a language fallback"
);
assertEquals(
detectLocale("ZH-tw"),
"zh-TW",
"Locale matching should be case-insensitive"
);
assertEquals(
detectLocale(" zh-TW ; q=1 , zh-CN;q=0.8 "),
"zh-TW",
"Whitespace and quality parameters should not prevent an exact match"
);
assertEquals(
detectLocale("zh-CN,zh-TW;q=0.9"),
"zh-CN",
"Simplified Chinese should still match exactly"
);
assertEquals(
detectLocale("zh"),
"zh-CN",
"A generic Chinese preference should retain the existing fallback"
);
assertEquals(
detectLocale("en-GB,en;q=0.9"),
"en-US",
"An unsupported region should fall back to a supported locale for the language"
);
assertEquals(
detectLocale("ja-JP,zh-TW;q=0.9"),
"zh-TW",
"The next preference should be used when a language is unsupported"
);
assertEquals(
detectLocale("zh-CN;q=0.5,zh-TW;q=0.9"),
"zh-TW",
"Preferences should be evaluated by quality"
);
assertEquals(
detectLocale("zh-TW;q=0,fr-FR;q=0.8"),
"fr-FR",
"Locales with zero quality should be excluded"
);
assertEquals(
detectLocale("*,de-DE;q=0.8"),
"de-DE",
"A wildcard should not obscure a supported preference"
);
assertEquals(
detectLocale("ja-JP"),
undefined,
"An unsupported language should not match"
);
assertEquals(
detectLocale(""),
undefined,
"An empty Accept-Language header should not match"
);
console.log("All locale detection tests passed!");
}
try {
runTests();
} catch (error) {
console.error("Locale detection test failed:", error);
process.exit(1);
}
+48
View File
@@ -0,0 +1,48 @@
import { Locale, locales } from "./config";
export function detectLocale(acceptLanguage: string): Locale | undefined {
const browserLocales = acceptLanguage
.split(",")
.map((entry, index) => {
const [locale, ...parameters] = entry.trim().split(";");
const qualityParameter = parameters.find((parameter) =>
parameter.trim().toLowerCase().startsWith("q=")
);
const quality = qualityParameter
? Number(qualityParameter.trim().slice(2))
: 1;
return {
locale: locale.trim().toLowerCase(),
quality,
index
};
})
.filter(
({ locale, quality }) =>
locale && locale !== "*" && quality > 0 && quality <= 1
)
.sort(
(left, right) =>
right.quality - left.quality || left.index - right.index
);
for (const { locale: browserLocale } of browserLocales) {
const exactMatch = locales.find(
(locale) => locale.toLowerCase() === browserLocale
);
if (exactMatch) {
return exactMatch;
}
const browserLanguage = browserLocale.split("-")[0];
const languageMatch = locales.find(
(locale) => locale.split("-")[0].toLowerCase() === browserLanguage
);
if (languageMatch) {
return languageMatch;
}
}
return undefined;
}
+2 -6
View File
@@ -2,6 +2,7 @@
import { cookies, headers } from "next/headers";
import { Locale, defaultLocale, locales } from "@/i18n/config";
import { detectLocale } from "@/i18n/detectLocale";
import { internal } from "@app/lib/api";
import { authCookieHeader } from "@app/lib/api/cookies";
@@ -47,12 +48,7 @@ export async function getUserLocale(): Promise<Locale> {
const acceptLang = headerList.get("accept-language");
if (acceptLang) {
const browserLang = acceptLang.split(",")[0];
const matched = locales.find((locale) =>
browserLang
.toLowerCase()
.startsWith(locale.split("-")[0].toLowerCase())
);
const matched = detectLocale(acceptLang);
if (matched) {
return matched;
}