Compare commits

...

68 Commits

Author SHA1 Message Date
Owen Schwartz 4c4c2fc249 New translations en-us.json (Norwegian Bokmal)
[ci skip]
2026-09-01 10:56:05 -04:00
Owen Schwartz 0b92108f41 New translations en-us.json (Chinese Simplified)
[ci skip]
2026-09-01 10:56:03 -04:00
Owen Schwartz 30321774e7 New translations en-us.json (Turkish)
[ci skip]
2026-09-01 10:56:01 -04:00
Owen Schwartz 60ecc56841 New translations en-us.json (Russian)
[ci skip]
2026-09-01 10:55:58 -04:00
Owen Schwartz 261bd5fc29 New translations en-us.json (Portuguese)
[ci skip]
2026-09-01 10:55:55 -04:00
Owen Schwartz d54973ab4b New translations en-us.json (Polish)
[ci skip]
2026-09-01 10:55:53 -04:00
Owen Schwartz d126c73a51 New translations en-us.json (Dutch)
[ci skip]
2026-09-01 10:55:51 -04:00
Owen Schwartz 487ff70ec6 New translations en-us.json (Korean)
[ci skip]
2026-09-01 10:55:48 -04:00
Owen Schwartz cb0fba5cad New translations en-us.json (Italian)
[ci skip]
2026-09-01 10:55:46 -04:00
Owen Schwartz 6cd818038c New translations en-us.json (German)
[ci skip]
2026-09-01 10:55:43 -04:00
Owen Schwartz 26c9d6941f New translations en-us.json (Danish)
[ci skip]
2026-09-01 10:55:41 -04:00
Owen Schwartz 0e17e5be97 New translations en-us.json (Czech)
[ci skip]
2026-09-01 10:55:38 -04:00
Owen Schwartz 8675ac44b7 New translations en-us.json (Bulgarian)
[ci skip]
2026-09-01 10:55:36 -04:00
Owen Schwartz 1c6b62673b New translations en-us.json (Spanish)
[ci skip]
2026-09-01 10:55:33 -04:00
Owen Schwartz 6eb5c75be1 New translations en-us.json (French)
[ci skip]
2026-09-01 10:55:31 -04:00
Owen Schwartz cda9ff0b4e New translations en-us.json (Norwegian Bokmal)
[ci skip]
2026-08-31 18:03:49 -04:00
Owen Schwartz 5f38178154 New translations en-us.json (Chinese Simplified)
[ci skip]
2026-08-31 18:03:47 -04:00
Owen Schwartz ab55e0a87f New translations en-us.json (Turkish)
[ci skip]
2026-08-31 18:03:45 -04:00
Owen Schwartz 41f4cf66f8 New translations en-us.json (Russian)
[ci skip]
2026-08-31 18:03:43 -04:00
Owen Schwartz 6c8247b6c0 New translations en-us.json (Portuguese)
[ci skip]
2026-08-31 18:03:40 -04:00
Owen Schwartz 07c09ec617 New translations en-us.json (Polish)
[ci skip]
2026-08-31 18:03:38 -04:00
Owen Schwartz 573922c0b6 New translations en-us.json (Dutch)
[ci skip]
2026-08-31 18:03:36 -04:00
Owen Schwartz ae516e57bf New translations en-us.json (Korean)
[ci skip]
2026-08-31 18:03:34 -04:00
Owen Schwartz c9b4776a07 New translations en-us.json (Italian)
[ci skip]
2026-08-31 18:03:32 -04:00
Owen Schwartz a0f0228d10 New translations en-us.json (German)
[ci skip]
2026-08-31 18:03:30 -04:00
Owen Schwartz a1066709c4 New translations en-us.json (Danish)
[ci skip]
2026-08-31 18:03:28 -04:00
Owen Schwartz 46a9ccc979 New translations en-us.json (Czech)
[ci skip]
2026-08-31 18:03:26 -04:00
Owen Schwartz 180b8236a6 New translations en-us.json (Bulgarian)
[ci skip]
2026-08-31 18:03:24 -04:00
Owen Schwartz 96ca6c2d20 New translations en-us.json (Spanish)
[ci skip]
2026-08-31 18:03:22 -04:00
Owen Schwartz 47c34f88b4 New translations en-us.json (French)
[ci skip]
2026-08-31 18:03:19 -04:00
Owen 780d767a65 Merge branch 'main' into dev 2026-08-31 17:07:56 -04: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
Owen Schwartz c36cf698c1 Merge pull request #3627 from fosrl/feat/ip-filtering
feat: Add IP column filtering to tables
2026-08-31 16:59:34 -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 22db0319c2 Merge pull request #3654 from iMord0/crowdsec-v1.7.1
Crowdsec v1.7.1
2026-08-31 16:50:59 -04:00
miloschwartz 39722d30af resolve security-key login only for a unique internal user 2026-08-31 14:39:36 -04:00
miloschwartz 8aef14cf9f harden initial server-admin setup against duplicate users and races 2026-08-31 12:36:42 -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
iMord0 7f94d99455 Update CrowdSec plugin version
Updated CrowdSec plugin version from v1.4.4 to v1.7.1
2026-08-28 20:51:51 +02:00
iMord0 1f9e99219d Updated CrowdSec plugin version
Updated CrowdSec plugin version from v1.4.4 to v1.7.8
2026-08-28 20:42:26 +02: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
Fred KISSIE d00b9478a2 Merge branch 'dev' into feat/ip-filtering 2026-08-24 22:06:55 +02:00
Fred KISSIE 4ddf36ebcc 💄 some last UI fixes 2026-08-24 20:39:30 +02:00
Fred KISSIE 28b32fe6f7 🏷️ fix types 2026-08-21 23:26:18 +02:00
Fred KISSIE adfb6003d9 Merge branch 'dev' into feat/ip-filtering 2026-08-21 23:24:09 +02:00
Fred KISSIE 6a5ecab013 Implement IP filtering for admin access logs 2026-08-21 22:49:45 +02:00
Fred KISSIE 2c197fab9f IP filtering on request log table finished 2026-08-21 21:04:40 +02:00
Fred KISSIE 65e4fe91b9 🚧 wip: add ip is column filter 2026-08-20 23:59:31 +02:00
Fred KISSIE 52c078a489 💄 ui 2026-08-18 23:28:31 +02:00
Fred KISSIE 195f67c6eb 💄 QoL for location column 2026-08-18 21:08:16 +02:00
Fred KISSIE 668a04bcd2 🚧 wip: IP column filtering 2026-08-14 21:52:16 +02:00
39 changed files with 893 additions and 501 deletions
+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.
+1 -1
View File
@@ -16,7 +16,7 @@ experimental:
version: "{{.BadgerVersion}}"
crowdsec: # CrowdSec plugin configuration added
moduleName: "github.com/maxlerebourg/crowdsec-bouncer-traefik-plugin"
version: "v1.4.4"
version: "v1.7.1"
log:
level: "INFO"
+4 -1
View File
@@ -1573,6 +1573,8 @@
"search": "Търси…",
"searchPlaceholder": "Търсене...",
"emptySearchOptions": "Няма намерени опции",
"ipFilterSearchPlaceholder": "Enter an IP address…",
"ipFilterEmptyMessage": "Enter an IP address to filter by",
"create": "Създаване",
"orgs": "Организации",
"loginError": "Възникна неочаквана грешка. Моля, опитайте отново.",
@@ -2596,6 +2598,7 @@
"createDomainType": "Тип:",
"createDomainName": "Име:",
"createDomainValue": "Стойност:",
"multiSelectFilterCount": "{count} selected",
"createDomainCnameRecords": "CNAME записи",
"createDomainARecords": "A записи",
"createDomainRecordNumber": "Запис {number}",
@@ -2993,7 +2996,7 @@
"remoteExitNodeNetworkingSubnetsPlaceholder": "Добавете CIDR диапазон (напр. 10.0.0.0/8)",
"remoteExitNodeNetworkingSubnetsLoadError": "Неуспешно зареждане на подмрежи",
"remoteExitNodeNetworkingLabelsTitle": "Етикети за Предпочитания",
"remoteExitNodeNetworkingLabelsDescription": "Сайтове с тези етикети ще бъдат принудени да се свържат чрез този отдалечен край.",
"remoteExitNodeNetworkingLabelsDescription": "Sites with these labels will prefer to connect through this remote exit node.",
"remoteExitNodeNetworkingLabelsButtonText": "Изберете етикети...",
"remoteExitNodeNetworkingLabelsSearchPlaceholder": "Търсене на етикети...",
"remoteExitNodeNetworkingLabelsLoadError": "Неуспешно зареждане на етикети",
+4 -1
View File
@@ -1573,6 +1573,8 @@
"search": "Vyhledávání…",
"searchPlaceholder": "Hledat...",
"emptySearchOptions": "Nebyly nalezeny žádné možnosti",
"ipFilterSearchPlaceholder": "Enter an IP address…",
"ipFilterEmptyMessage": "Enter an IP address to filter by",
"create": "Vytvořit",
"orgs": "Organizace",
"loginError": "Došlo k neočekávané chybě. Zkuste to prosím znovu.",
@@ -2596,6 +2598,7 @@
"createDomainType": "Typ:",
"createDomainName": "Jméno:",
"createDomainValue": "Hodnota:",
"multiSelectFilterCount": "{count} selected",
"createDomainCnameRecords": "Záznamy CNAME",
"createDomainARecords": "Záznamy",
"createDomainRecordNumber": "Nahrát {number}",
@@ -2993,7 +2996,7 @@
"remoteExitNodeNetworkingSubnetsPlaceholder": "Přidejte rozsah CIDR (např. 10.0.0.0/8)",
"remoteExitNodeNetworkingSubnetsLoadError": "Nepodařilo se načíst podsítě",
"remoteExitNodeNetworkingLabelsTitle": "Názvy preferencí",
"remoteExitNodeNetworkingLabelsDescription": "Weby s těmito názvy budou nuceny připojit se tímto vzdáleným výstupním uzlem.",
"remoteExitNodeNetworkingLabelsDescription": "Sites with these labels will prefer to connect through this remote exit node.",
"remoteExitNodeNetworkingLabelsButtonText": "Vyberte názvy...",
"remoteExitNodeNetworkingLabelsSearchPlaceholder": "Hledat názvy...",
"remoteExitNodeNetworkingLabelsLoadError": "Nepodařilo se načíst názvy",
+4 -1
View File
@@ -1573,6 +1573,8 @@
"search": "Søg…",
"searchPlaceholder": "Søg...",
"emptySearchOptions": "Ingen valg fundet",
"ipFilterSearchPlaceholder": "Enter an IP address…",
"ipFilterEmptyMessage": "Enter an IP address to filter by",
"create": "Opret",
"orgs": "Organisationer",
"loginError": "Der opstod en uventet fejl. Prøv venligst igen.",
@@ -2596,6 +2598,7 @@
"createDomainType": "Type:",
"createDomainName": "Navn:",
"createDomainValue": "Værdi:",
"multiSelectFilterCount": "{count} selected",
"createDomainCnameRecords": "CNAME-poster",
"createDomainARecords": "A-poster",
"createDomainRecordNumber": "Post {number}",
@@ -2993,7 +2996,7 @@
"remoteExitNodeNetworkingSubnetsPlaceholder": "Tilføj et CIDR-område (f.eks. 10.0.0.0/8)",
"remoteExitNodeNetworkingSubnetsLoadError": "Kunne ikke indlæse subnets",
"remoteExitNodeNetworkingLabelsTitle": "Præference Etiketter",
"remoteExitNodeNetworkingLabelsDescription": "Sites med disse etiketter vil blive tvunget til at oprette forbindelse gennem denne fjerne exit-node.",
"remoteExitNodeNetworkingLabelsDescription": "Sites with these labels will prefer to connect through this remote exit node.",
"remoteExitNodeNetworkingLabelsButtonText": "Vælg etiketter...",
"remoteExitNodeNetworkingLabelsSearchPlaceholder": "Søg efter etiketter...",
"remoteExitNodeNetworkingLabelsLoadError": "Kunne ikke indlæse etiketter",
+4 -1
View File
@@ -1573,6 +1573,8 @@
"search": "Suche…",
"searchPlaceholder": "Suche...",
"emptySearchOptions": "Keine Optionen gefunden",
"ipFilterSearchPlaceholder": "Enter an IP address…",
"ipFilterEmptyMessage": "Enter an IP address to filter by",
"create": "Erstellen",
"orgs": "Organisationen",
"loginError": "Ein unerwarteter Fehler ist aufgetreten. Bitte versuchen Sie es erneut.",
@@ -2596,6 +2598,7 @@
"createDomainType": "Typ:",
"createDomainName": "Name:",
"createDomainValue": "Wert:",
"multiSelectFilterCount": "{count} selected",
"createDomainCnameRecords": "CNAME-Einträge",
"createDomainARecords": "A-Aufzeichnungen",
"createDomainRecordNumber": "Eintrag {number}",
@@ -2993,7 +2996,7 @@
"remoteExitNodeNetworkingSubnetsPlaceholder": "Fügen Sie einen CIDR-Bereich hinzu (z.B. 10.0.0.0/8)",
"remoteExitNodeNetworkingSubnetsLoadError": "Fehler beim Laden der Subnetze",
"remoteExitNodeNetworkingLabelsTitle": "Präferenzetiketten",
"remoteExitNodeNetworkingLabelsDescription": "Standorte mit diesen Etiketten werden gezwungen, über diesen Remote Exit Node zu verbinden.",
"remoteExitNodeNetworkingLabelsDescription": "Sites with these labels will prefer to connect through this remote exit node.",
"remoteExitNodeNetworkingLabelsButtonText": "Etiketten auswählen...",
"remoteExitNodeNetworkingLabelsSearchPlaceholder": "Etiketten suchen...",
"remoteExitNodeNetworkingLabelsLoadError": "Fehler beim Laden der Etiketten",
+3
View File
@@ -1573,6 +1573,8 @@
"search": "Search…",
"searchPlaceholder": "Search...",
"emptySearchOptions": "No options found",
"ipFilterSearchPlaceholder": "Enter an IP address…",
"ipFilterEmptyMessage": "Enter an IP address to filter by",
"create": "Create",
"orgs": "Organizations",
"loginError": "An unexpected error occurred. Please try again.",
@@ -2596,6 +2598,7 @@
"createDomainType": "Type:",
"createDomainName": "Name:",
"createDomainValue": "Value:",
"multiSelectFilterCount": "{count} selected",
"createDomainCnameRecords": "CNAME Records",
"createDomainARecords": "A Records",
"createDomainRecordNumber": "Record {number}",
+4 -1
View File
@@ -1573,6 +1573,8 @@
"search": "Buscar…",
"searchPlaceholder": "Buscar...",
"emptySearchOptions": "No se encontraron opciones",
"ipFilterSearchPlaceholder": "Enter an IP address…",
"ipFilterEmptyMessage": "Enter an IP address to filter by",
"create": "Crear",
"orgs": "Organizaciones",
"loginError": "Ocurrió un error inesperado. Por favor, inténtelo de nuevo.",
@@ -2596,6 +2598,7 @@
"createDomainType": "Tipo:",
"createDomainName": "Nombre:",
"createDomainValue": "Valor:",
"multiSelectFilterCount": "{count} selected",
"createDomainCnameRecords": "Registros CNAME",
"createDomainARecords": "Registros A",
"createDomainRecordNumber": "Registro {number}",
@@ -2993,7 +2996,7 @@
"remoteExitNodeNetworkingSubnetsPlaceholder": "Añadir un rango CIDR (e.g. 10.0.0.0/8)",
"remoteExitNodeNetworkingSubnetsLoadError": "Error al cargar las subredes",
"remoteExitNodeNetworkingLabelsTitle": "Etiquetas de Preferencias",
"remoteExitNodeNetworkingLabelsDescription": "Los sitios con estas etiquetas se verán obligados a conectarse a través de este nodo de salida remoto.",
"remoteExitNodeNetworkingLabelsDescription": "Sites with these labels will prefer to connect through this remote exit node.",
"remoteExitNodeNetworkingLabelsButtonText": "Seleccionar etiquetas...",
"remoteExitNodeNetworkingLabelsSearchPlaceholder": "Buscar etiquetas...",
"remoteExitNodeNetworkingLabelsLoadError": "Error al cargar las etiquetas",
+4 -1
View File
@@ -1573,6 +1573,8 @@
"search": "Rechercher…",
"searchPlaceholder": "Recherche...",
"emptySearchOptions": "Aucune option trouvée",
"ipFilterSearchPlaceholder": "Enter an IP address…",
"ipFilterEmptyMessage": "Enter an IP address to filter by",
"create": "Créer",
"orgs": "Organisations",
"loginError": "Une erreur inattendue s'est produite. Veuillez réessayer.",
@@ -2596,6 +2598,7 @@
"createDomainType": "Type :",
"createDomainName": "Nom :",
"createDomainValue": "Valeur :",
"multiSelectFilterCount": "{count} selected",
"createDomainCnameRecords": "Enregistrements CNAME",
"createDomainARecords": "Enregistrements A",
"createDomainRecordNumber": "Enregistrement {number}",
@@ -2993,7 +2996,7 @@
"remoteExitNodeNetworkingSubnetsPlaceholder": "Ajouter une plage CIDR (par exemple 10.0.0.0/8)",
"remoteExitNodeNetworkingSubnetsLoadError": "Échec du chargement des sous-réseaux",
"remoteExitNodeNetworkingLabelsTitle": "Étiquettes de préférences",
"remoteExitNodeNetworkingLabelsDescription": "Les sites avec ces étiquettes devront se connecter via ce nœud de sortie distant.",
"remoteExitNodeNetworkingLabelsDescription": "Sites with these labels will prefer to connect through this remote exit node.",
"remoteExitNodeNetworkingLabelsButtonText": "Sélectionner des étiquettes...",
"remoteExitNodeNetworkingLabelsSearchPlaceholder": "Chercher des étiquettes...",
"remoteExitNodeNetworkingLabelsLoadError": "Échec du chargement des étiquettes",
+4 -1
View File
@@ -1573,6 +1573,8 @@
"search": "Cerca…",
"searchPlaceholder": "Cerca...",
"emptySearchOptions": "Nessuna opzione trovata",
"ipFilterSearchPlaceholder": "Enter an IP address…",
"ipFilterEmptyMessage": "Enter an IP address to filter by",
"create": "Crea",
"orgs": "Organizzazioni",
"loginError": "Si è verificato un errore imprevisto. Riprova.",
@@ -2596,6 +2598,7 @@
"createDomainType": "Tipo:",
"createDomainName": "Nome:",
"createDomainValue": "Valore:",
"multiSelectFilterCount": "{count} selected",
"createDomainCnameRecords": "Record CNAME",
"createDomainARecords": "Record A",
"createDomainRecordNumber": "Record {number}",
@@ -2993,7 +2996,7 @@
"remoteExitNodeNetworkingSubnetsPlaceholder": "Aggiungi un intervallo CIDR (ad esempio 10.0.0.0/8)",
"remoteExitNodeNetworkingSubnetsLoadError": "Caricamento sottoreti fallito",
"remoteExitNodeNetworkingLabelsTitle": "Etichette Preferenze",
"remoteExitNodeNetworkingLabelsDescription": "I siti con queste etichette saranno collegati attraverso questo nodo di uscita remoto.",
"remoteExitNodeNetworkingLabelsDescription": "Sites with these labels will prefer to connect through this remote exit node.",
"remoteExitNodeNetworkingLabelsButtonText": "Seleziona etichette...",
"remoteExitNodeNetworkingLabelsSearchPlaceholder": "Cerca etichette...",
"remoteExitNodeNetworkingLabelsLoadError": "Caricamento etichette fallito",
+4 -1
View File
@@ -1573,6 +1573,8 @@
"search": "검색…",
"searchPlaceholder": "검색...",
"emptySearchOptions": "옵션이 없습니다",
"ipFilterSearchPlaceholder": "Enter an IP address…",
"ipFilterEmptyMessage": "Enter an IP address to filter by",
"create": "생성",
"orgs": "조직",
"loginError": "예기치 않은 오류가 발생했습니다. 다시 시도해주세요.",
@@ -2596,6 +2598,7 @@
"createDomainType": "유형:",
"createDomainName": "이름:",
"createDomainValue": "값:",
"multiSelectFilterCount": "{count} selected",
"createDomainCnameRecords": "CNAME 레코드",
"createDomainARecords": "A 레코드",
"createDomainRecordNumber": "레코드 {number}",
@@ -2993,7 +2996,7 @@
"remoteExitNodeNetworkingSubnetsPlaceholder": "CIDR 범위 추가 (예: 10.0.0.0/8)",
"remoteExitNodeNetworkingSubnetsLoadError": "서브넷 로드 실패",
"remoteExitNodeNetworkingLabelsTitle": "우선순위 레이블",
"remoteExitNodeNetworkingLabelsDescription": "이 레이블이 있는 사이트는 이 원격 출구 노드를 통해 연결됩니다.",
"remoteExitNodeNetworkingLabelsDescription": "Sites with these labels will prefer to connect through this remote exit node.",
"remoteExitNodeNetworkingLabelsButtonText": "레이블 선택...",
"remoteExitNodeNetworkingLabelsSearchPlaceholder": "레이블 검색...",
"remoteExitNodeNetworkingLabelsLoadError": "레이블 로드 실패",
+4 -1
View File
@@ -1573,6 +1573,8 @@
"search": "Søk…",
"searchPlaceholder": "Søk...",
"emptySearchOptions": "Ingen valg funnet",
"ipFilterSearchPlaceholder": "Enter an IP address…",
"ipFilterEmptyMessage": "Enter an IP address to filter by",
"create": "Opprett",
"orgs": "Organisasjoner",
"loginError": "En uventet feil oppstod. Vennligst prøv igjen.",
@@ -2596,6 +2598,7 @@
"createDomainType": "Type:",
"createDomainName": "Navn:",
"createDomainValue": "Verdi:",
"multiSelectFilterCount": "{count} selected",
"createDomainCnameRecords": "CNAME-oppføringer",
"createDomainARecords": "A-oppføringer",
"createDomainRecordNumber": "Oppføring {number}",
@@ -2993,7 +2996,7 @@
"remoteExitNodeNetworkingSubnetsPlaceholder": "Legg til et CIDR-område (f.eks. 10.0.0.0/8)",
"remoteExitNodeNetworkingSubnetsLoadError": "Feil ved lasting av subnett",
"remoteExitNodeNetworkingLabelsTitle": "Preferanseetiketter",
"remoteExitNodeNetworkingLabelsDescription": "Områder med disse etikettene vil bli tvunget til å koble gjennom denne fjerne utgangsnoden.",
"remoteExitNodeNetworkingLabelsDescription": "Sites with these labels will prefer to connect through this remote exit node.",
"remoteExitNodeNetworkingLabelsButtonText": "Velg etiketter...",
"remoteExitNodeNetworkingLabelsSearchPlaceholder": "Søk etiketter...",
"remoteExitNodeNetworkingLabelsLoadError": "Feil ved lasting av etiketter",
+4 -1
View File
@@ -1573,6 +1573,8 @@
"search": "Zoeken…",
"searchPlaceholder": "Zoeken...",
"emptySearchOptions": "Geen opties gevonden",
"ipFilterSearchPlaceholder": "Enter an IP address…",
"ipFilterEmptyMessage": "Enter an IP address to filter by",
"create": "Aanmaken",
"orgs": "Organisaties",
"loginError": "Er is een onverwachte fout opgetreden. Probeer het opnieuw.",
@@ -2596,6 +2598,7 @@
"createDomainType": "Type:",
"createDomainName": "Naam:",
"createDomainValue": "Waarde:",
"multiSelectFilterCount": "{count} selected",
"createDomainCnameRecords": "CNAME-records",
"createDomainARecords": "A Records",
"createDomainRecordNumber": "Record {number}",
@@ -2993,7 +2996,7 @@
"remoteExitNodeNetworkingSubnetsPlaceholder": "Voeg een CIDR-bereik toe (bijv. 10.0.0.0/8)",
"remoteExitNodeNetworkingSubnetsLoadError": "Kon subnets niet laden",
"remoteExitNodeNetworkingLabelsTitle": "Voorkeurslabels",
"remoteExitNodeNetworkingLabelsDescription": "Sites met deze labels worden verplicht om verbinding te maken via dit externe exit-knooppunt.",
"remoteExitNodeNetworkingLabelsDescription": "Sites with these labels will prefer to connect through this remote exit node.",
"remoteExitNodeNetworkingLabelsButtonText": "Selecteer labels...",
"remoteExitNodeNetworkingLabelsSearchPlaceholder": "Labels zoeken...",
"remoteExitNodeNetworkingLabelsLoadError": "Kon labels niet laden",
+4 -1
View File
@@ -1573,6 +1573,8 @@
"search": "Szukaj…",
"searchPlaceholder": "Szukaj...",
"emptySearchOptions": "Nie znaleziono opcji",
"ipFilterSearchPlaceholder": "Enter an IP address…",
"ipFilterEmptyMessage": "Enter an IP address to filter by",
"create": "Utwórz",
"orgs": "Organizacje",
"loginError": "Wystąpił nieoczekiwany błąd. Spróbuj ponownie.",
@@ -2596,6 +2598,7 @@
"createDomainType": "Typ:",
"createDomainName": "Nazwa:",
"createDomainValue": "Wartość:",
"multiSelectFilterCount": "{count} selected",
"createDomainCnameRecords": "Rekordy CNAME",
"createDomainARecords": "Rekordy A",
"createDomainRecordNumber": "Rekord {number}",
@@ -2993,7 +2996,7 @@
"remoteExitNodeNetworkingSubnetsPlaceholder": "Dodaj zakres CIDR (np. 10.0.0.0/8)",
"remoteExitNodeNetworkingSubnetsLoadError": "Nie udało się załadować podsieci",
"remoteExitNodeNetworkingLabelsTitle": "Etykiety preferencji",
"remoteExitNodeNetworkingLabelsDescription": "Strony z tymi etykietami będą zmuszone do połączenia się przez ten zdalny węzeł wyjściowy.",
"remoteExitNodeNetworkingLabelsDescription": "Sites with these labels will prefer to connect through this remote exit node.",
"remoteExitNodeNetworkingLabelsButtonText": "Wybierz etykiety...",
"remoteExitNodeNetworkingLabelsSearchPlaceholder": "Szukaj etykiet...",
"remoteExitNodeNetworkingLabelsLoadError": "Nie udało się załadować etykiet",
+4 -1
View File
@@ -1573,6 +1573,8 @@
"search": "Pesquisar…",
"searchPlaceholder": "Buscar...",
"emptySearchOptions": "Nenhuma opção encontrada",
"ipFilterSearchPlaceholder": "Enter an IP address…",
"ipFilterEmptyMessage": "Enter an IP address to filter by",
"create": "Criar",
"orgs": "Organizações",
"loginError": "Ocorreu um erro inesperado. Por favor, tente novamente.",
@@ -2596,6 +2598,7 @@
"createDomainType": "Tipo:",
"createDomainName": "Nome:",
"createDomainValue": "Valor:",
"multiSelectFilterCount": "{count} selected",
"createDomainCnameRecords": "Registros CNAME",
"createDomainARecords": "Registros A",
"createDomainRecordNumber": "Registrar {number}",
@@ -2993,7 +2996,7 @@
"remoteExitNodeNetworkingSubnetsPlaceholder": "Adicione um intervalo de CIDR (por exemplo, 10.0.0.0/8)",
"remoteExitNodeNetworkingSubnetsLoadError": "Falha ao carregar sub-redes",
"remoteExitNodeNetworkingLabelsTitle": "Etiquetas de Preferência",
"remoteExitNodeNetworkingLabelsDescription": "Os sites com essas etiquetas serão forçados a se conectar através deste nó de saída remoto.",
"remoteExitNodeNetworkingLabelsDescription": "Sites with these labels will prefer to connect through this remote exit node.",
"remoteExitNodeNetworkingLabelsButtonText": "Selecionar etiquetas...",
"remoteExitNodeNetworkingLabelsSearchPlaceholder": "Pesquisar etiquetas...",
"remoteExitNodeNetworkingLabelsLoadError": "Falha ao carregar etiquetas",
+4 -1
View File
@@ -1573,6 +1573,8 @@
"search": "Поиск…",
"searchPlaceholder": "Поиск...",
"emptySearchOptions": "Опции не найдены",
"ipFilterSearchPlaceholder": "Enter an IP address…",
"ipFilterEmptyMessage": "Enter an IP address to filter by",
"create": "Создать",
"orgs": "Организации",
"loginError": "Произошла непредвиденная ошибка. Пожалуйста, попробуйте еще раз.",
@@ -2596,6 +2598,7 @@
"createDomainType": "Тип:",
"createDomainName": "Имя:",
"createDomainValue": "Значение:",
"multiSelectFilterCount": "{count} selected",
"createDomainCnameRecords": "CNAME Записи",
"createDomainARecords": "A Записи",
"createDomainRecordNumber": "Запись {number}",
@@ -2993,7 +2996,7 @@
"remoteExitNodeNetworkingSubnetsPlaceholder": "Добавить диапазон CIDR (например, 10.0.0.0/8)",
"remoteExitNodeNetworkingSubnetsLoadError": "Не удалось загрузить подсети",
"remoteExitNodeNetworkingLabelsTitle": "Этикетки предпочтений",
"remoteExitNodeNetworkingLabelsDescription": "Сайты с этими метками будут обязаны подключаться через этот удаленный узел выхода.",
"remoteExitNodeNetworkingLabelsDescription": "Sites with these labels will prefer to connect through this remote exit node.",
"remoteExitNodeNetworkingLabelsButtonText": "Выберите метки...",
"remoteExitNodeNetworkingLabelsSearchPlaceholder": "Поиск меток...",
"remoteExitNodeNetworkingLabelsLoadError": "Не удалось загрузить метки",
+4 -1
View File
@@ -1573,6 +1573,8 @@
"search": "Ara…",
"searchPlaceholder": "Ara...",
"emptySearchOptions": "Seçenek bulunamadı",
"ipFilterSearchPlaceholder": "Enter an IP address…",
"ipFilterEmptyMessage": "Enter an IP address to filter by",
"create": "Oluştur",
"orgs": "Organizasyonlar",
"loginError": "Beklenmeyen bir hata oluştu. Lütfen tekrar deneyin.",
@@ -2596,6 +2598,7 @@
"createDomainType": "Tür:",
"createDomainName": "Ad:",
"createDomainValue": "Değer:",
"multiSelectFilterCount": "{count} selected",
"createDomainCnameRecords": "CNAME Kayıtları",
"createDomainARecords": "A Kayıtları",
"createDomainRecordNumber": "Kayıt {number}",
@@ -2993,7 +2996,7 @@
"remoteExitNodeNetworkingSubnetsPlaceholder": "Bir CIDR aralığı ekle (örneğin, 10.0.0.0/8)",
"remoteExitNodeNetworkingSubnetsLoadError": "Alt ağlar yüklenemedi",
"remoteExitNodeNetworkingLabelsTitle": "Tercih Etiketleri",
"remoteExitNodeNetworkingLabelsDescription": "Bu etiketlere sahip siteler, bu uzak çıkış düğümü üzerinden bağlantı kurmaya zorlanacaktır.",
"remoteExitNodeNetworkingLabelsDescription": "Sites with these labels will prefer to connect through this remote exit node.",
"remoteExitNodeNetworkingLabelsButtonText": "Etiketleri seç...",
"remoteExitNodeNetworkingLabelsSearchPlaceholder": "Etiketleri ara...",
"remoteExitNodeNetworkingLabelsLoadError": "Etiketler yüklenemedi",
+4 -1
View File
@@ -1573,6 +1573,8 @@
"search": "搜索…",
"searchPlaceholder": "搜索...",
"emptySearchOptions": "未找到选项",
"ipFilterSearchPlaceholder": "Enter an IP address…",
"ipFilterEmptyMessage": "Enter an IP address to filter by",
"create": "创建",
"orgs": "组织",
"loginError": "发生意外错误。请重试。",
@@ -2596,6 +2598,7 @@
"createDomainType": "类型:",
"createDomainName": "名称:",
"createDomainValue": "值:",
"multiSelectFilterCount": "{count} selected",
"createDomainCnameRecords": "CNAME 记录",
"createDomainARecords": "A记录",
"createDomainRecordNumber": "记录 {number}",
@@ -2993,7 +2996,7 @@
"remoteExitNodeNetworkingSubnetsPlaceholder": "添加CIDR范围(例如10.0.0.0/8",
"remoteExitNodeNetworkingSubnetsLoadError": "无法加载子网",
"remoteExitNodeNetworkingLabelsTitle": "首选标签",
"remoteExitNodeNetworkingLabelsDescription": "拥有这些标签的站点将强制通过此远程出口节点连接。",
"remoteExitNodeNetworkingLabelsDescription": "Sites with these labels will prefer to connect through this remote exit node.",
"remoteExitNodeNetworkingLabelsButtonText": "选择标签……",
"remoteExitNodeNetworkingLabelsSearchPlaceholder": "搜索标签……",
"remoteExitNodeNetworkingLabelsLoadError": "无法加载标签",
+70 -26
View File
@@ -7569,6 +7569,72 @@
"node": ">=14.0.0"
}
},
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/core": {
"version": "1.10.0",
"dev": true,
"inBundle": true,
"license": "MIT",
"optional": true,
"dependencies": {
"@emnapi/wasi-threads": "1.2.1",
"tslib": "^2.4.0"
}
},
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/runtime": {
"version": "1.10.0",
"dev": true,
"inBundle": true,
"license": "MIT",
"optional": true,
"dependencies": {
"tslib": "^2.4.0"
}
},
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/wasi-threads": {
"version": "1.2.1",
"dev": true,
"inBundle": true,
"license": "MIT",
"optional": true,
"dependencies": {
"tslib": "^2.4.0"
}
},
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@napi-rs/wasm-runtime": {
"version": "1.1.4",
"dev": true,
"inBundle": true,
"license": "MIT",
"optional": true,
"dependencies": {
"@tybys/wasm-util": "^0.10.1"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/Brooooooklyn"
},
"peerDependencies": {
"@emnapi/core": "^1.7.1",
"@emnapi/runtime": "^1.7.1"
}
},
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@tybys/wasm-util": {
"version": "0.10.1",
"dev": true,
"inBundle": true,
"license": "MIT",
"optional": true,
"dependencies": {
"tslib": "^2.4.0"
}
},
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/tslib": {
"version": "2.8.1",
"dev": true,
"inBundle": true,
"license": "0BSD",
"optional": true
},
"node_modules/@tailwindcss/oxide-win32-arm64-msvc": {
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.3.0.tgz",
@@ -17131,36 +17197,14 @@
}
},
"node_modules/socket.io-adapter": {
"version": "2.5.6",
"resolved": "https://registry.npmjs.org/socket.io-adapter/-/socket.io-adapter-2.5.6.tgz",
"integrity": "sha512-DkkO/dz7MGln0dHn5bmN3pPy+JmywNICWrJqVWiVOyvXjWQFIv9c2h24JrQLLFJ2aQVQf/Cvl1vblnd4r2apLQ==",
"version": "2.5.8",
"resolved": "https://registry.npmjs.org/socket.io-adapter/-/socket.io-adapter-2.5.8.tgz",
"integrity": "sha512-6Oy52pbg+kvdCVvjcN+FnY7BvxZ7cIHNScbvztT/It5d0vbwoJoVZmF2gjJmnV0/4WlXRfG15zc45ySk9Ah8bw==",
"dev": true,
"license": "MIT",
"dependencies": {
"debug": "~4.4.1",
"ws": "~8.18.3"
}
},
"node_modules/socket.io-adapter/node_modules/ws": {
"version": "8.18.3",
"resolved": "https://registry.npmjs.org/ws/-/ws-8.18.3.tgz",
"integrity": "sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=10.0.0"
},
"peerDependencies": {
"bufferutil": "^4.0.1",
"utf-8-validate": ">=5.0.2"
},
"peerDependenciesMeta": {
"bufferutil": {
"optional": true
},
"utf-8-validate": {
"optional": true
}
"ws": "~8.21.0"
}
},
"node_modules/socket.io-parser": {
Binary file not shown.

After

Width:  |  Height:  |  Size: 790 KiB

+71 -49
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", {
+336 -267
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",
+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 -15
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,7 +55,7 @@ export async function validateAndConstructDomain(
};
}
if (!domainRes.orgDomains) {
if (!domainRes.orgDomains && !domainRes.domainNamespaces) {
return {
success: false,
error: `Organization does not have access to domain with ID ${domainId}`
@@ -83,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(
@@ -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(
+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(
@@ -88,7 +88,27 @@ export const queryAccessAuditLogsQuery = z.object({
.optional()
.default("0")
.transform(Number)
.pipe(z.int().nonnegative())
.pipe(z.int().nonnegative()),
ip: z
.preprocess((val) => {
if (val === undefined || val === null || val === "") {
return undefined;
}
if (Array.isArray(val)) {
return val;
}
// the array is returned as this
if (typeof val === "string") {
return val.split(",");
}
return undefined;
}, z.array(z.string()))
.optional()
.catch([])
.openapi({
type: "array",
description: "Filter by IP adresses"
})
});
export const queryAccessAuditLogsParams = z.object({
@@ -134,6 +154,9 @@ function getWhere(data: Q) {
data.type ? eq(accessAuditLog.type, data.type) : undefined,
data.action !== undefined
? eq(accessAuditLog.action, data.action)
: undefined,
data.ip && data.ip.length > 0
? inArray(accessAuditLog.ip, data.ip)
: undefined
);
}
+2 -4
View File
@@ -16,12 +16,10 @@ import {
handleRemoteExitNodePingMessage
} from "#private/routers/remoteExitNode";
import { MessageHandler } from "@server/routers/ws";
import {
handleConnectionLogMessage,
} from "#private/routers/newt";
import { handleConnectionLogMessage } from "#private/routers/newt";
export const messageHandlers: Record<string, MessageHandler> = {
"remoteExitNode/register": handleRemoteExitNodeRegisterMessage,
"remoteExitNode/ping": handleRemoteExitNodePingMessage,
"newt/access-log": handleConnectionLogMessage,
"newt/access-log": handleConnectionLogMessage
};
@@ -81,7 +81,27 @@ export const queryAccessAuditLogsQuery = z.strictObject({
.optional()
.default("0")
.transform(Number)
.pipe(z.int().nonnegative())
.pipe(z.int().nonnegative()),
ip: z
.preprocess((val) => {
if (val === undefined || val === null || val === "") {
return undefined;
}
if (Array.isArray(val)) {
return val;
}
// the array is returned as this
if (typeof val === "string") {
return val.split(",");
}
return undefined;
}, z.array(z.string()))
.optional()
.catch([])
.openapi({
type: "array",
description: "Filter by IP adresses"
})
});
export const queryRequestAuditLogsParams = z.object({
@@ -126,6 +146,9 @@ function getWhere(data: Q) {
data.path ? eq(requestAuditLog.path, data.path) : undefined,
data.action !== undefined
? eq(requestAuditLog.action, data.action)
: undefined,
data.ip && data.ip.length > 0
? inArray(requestAuditLog.ip, data.ip)
: undefined
);
}
+9 -4
View File
@@ -533,18 +533,23 @@ export async function startAuthentication(
// If email is provided, get security keys for that specific user
if (email) {
const [user] = await db
const matchingUsers = await db
.select()
.from(users)
.where(eq(users.email, email))
.limit(1);
.where(
and(
eq(users.email, email.toLowerCase()),
eq(users.type, UserType.Internal)
)
);
if (!user || user.type !== UserType.Internal) {
if (matchingUsers.length !== 1) {
return next(
createHttpError(HttpCode.BAD_REQUEST, "Invalid credentials")
);
}
const user = matchingUsers[0];
userId = user.userId;
const userSecurityKeys = await db
+49 -38
View File
@@ -42,54 +42,62 @@ export async function setServerAdmin(
const { email, password, setupToken } = parsedBody.data;
// Validate setup token
const [validToken] = await db
.select()
.from(setupTokens)
.where(
and(
eq(setupTokens.token, setupToken),
eq(setupTokens.used, false)
)
);
if (!validToken) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
"Invalid or expired setup token"
)
);
}
const [existing] = await db
.select()
.from(users)
.where(eq(users.serverAdmin, true));
if (existing) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
"Server admin already exists"
)
);
}
const passwordHash = await hashPassword(password);
const userId = generateId(15);
await db.transaction(async (trx) => {
// Mark the token as used
await trx
const consumed = await trx
.update(setupTokens)
.set({
used: true,
dateUsed: moment().toISOString()
})
.where(eq(setupTokens.tokenId, validToken.tokenId));
.where(
and(
eq(setupTokens.token, setupToken),
eq(setupTokens.used, false)
)
)
.returning({ tokenId: setupTokens.tokenId });
if (!consumed.length) {
throw createHttpError(
HttpCode.BAD_REQUEST,
"Invalid setup token"
);
}
const [existingAdmin] = await trx
.select({ userId: users.userId })
.from(users)
.where(eq(users.serverAdmin, true))
.limit(1);
if (existingAdmin) {
throw createHttpError(
HttpCode.BAD_REQUEST,
"Server admin already exists"
);
}
const [existingUser] = await trx
.select({ userId: users.userId })
.from(users)
.where(
and(
eq(users.email, email),
eq(users.type, UserType.Internal)
)
)
.limit(1);
if (existingUser) {
throw createHttpError(
HttpCode.BAD_REQUEST,
"A user with that email address already exists"
);
}
// Create the server admin user
await trx.insert(users).values({
userId: userId,
email: email,
@@ -111,6 +119,9 @@ export async function setServerAdmin(
status: HttpCode.OK
});
} catch (e) {
if (createHttpError.isHttpError(e)) {
return next(e);
}
logger.error(e);
return next(
createHttpError(
+1 -1
View File
@@ -48,7 +48,7 @@ export async function validateSetupToken(
return response<ValidateSetupTokenResponse>(res, {
data: {
valid: false,
message: "Invalid or expired setup token"
message: "Invalid setup token"
},
success: true,
error: false,
+46 -13
View File
@@ -12,6 +12,7 @@ import { DateTimeValue } from "@app/components/DateTimePicker";
import { ArrowUpRight, Key, User } from "lucide-react";
import Link from "next/link";
import { ColumnFilterButton } from "@app/components/ColumnFilterButton";
import { ColumnMultiFilterButton } from "@app/components/ColumnMultiFilterButton";
import SettingsSectionTitle from "@app/components/SettingsSectionTitle";
import { build } from "@server/build";
import { getSevenDaysAgo } from "@app/lib/getSevenDaysAgo";
@@ -26,6 +27,7 @@ import { tierMatrix } from "@server/lib/billing/tierMatrix";
import { logQueries } from "@app/lib/queries";
import { useQuery } from "@tanstack/react-query";
import type { QueryAccessAuditLogResponse } from "@server/routers/auditLogs/types";
import { countryCodeToFlagEmoji } from "@app/lib/countryCodeToFlagEmoji";
export default function GeneralPage() {
const router = useRouter();
@@ -45,12 +47,14 @@ export default function GeneralPage() {
resourceId?: string;
location?: string;
actor?: string;
ip?: string[];
}>({
action: searchParams.get("action") || undefined,
type: searchParams.get("type") || undefined,
resourceId: searchParams.get("resourceId") || undefined,
location: searchParams.get("location") || undefined,
actor: searchParams.get("actor") || undefined
actor: searchParams.get("actor") || undefined,
ip: searchParams.getAll("ip") || undefined
});
const [currentPage, setCurrentPage] = useState<number>(0);
@@ -176,7 +180,7 @@ export default function GeneralPage() {
const handleFilterChange = (
filterType: keyof typeof filters,
value: string | undefined
value: string | string[] | undefined
) => {
const newFilters = { ...filters, [filterType]: value };
setFilters(newFilters);
@@ -194,10 +198,13 @@ export default function GeneralPage() {
) => {
const params = new URLSearchParams(searchParams);
Object.entries(newFilters).forEach(([key, value]) => {
if (value) {
params.delete(key);
if (typeof value === "string") {
params.set(key, value);
} else {
params.delete(key);
} else if (typeof value !== "undefined" && "length" in value) {
for (const element of value) {
params.append(key, element);
}
}
});
router.replace(`?${params.toString()}`, { scroll: false });
@@ -205,6 +212,7 @@ export default function GeneralPage() {
const exportData = async () => {
try {
const { ip, ...restFilters } = filters;
const params: any = {
timeStart: dateRange.startDate?.date
? new Date(dateRange.startDate.date).toISOString()
@@ -212,13 +220,20 @@ export default function GeneralPage() {
timeEnd: dateRange.endDate?.date
? new Date(dateRange.endDate.date).toISOString()
: undefined,
...filters
...restFilters
};
const response = await api.get(`/org/${orgId}/logs/access/export`, {
responseType: "blob",
params
});
// axios serializes arrays as `ip[]=…`, which express's query
// parser does not read back as `ip`, so pass them in the URL
const sp = new URLSearchParams((ip ?? []).map((ip) => ["ip", ip]));
const response = await api.get(
`/org/${orgId}/logs/access/export?${sp.toString()}`,
{
responseType: "blob",
params
}
);
const url = window.URL.createObjectURL(new Blob([response.data]));
const link = document.createElement("a");
@@ -297,7 +312,24 @@ export default function GeneralPage() {
},
{
accessorKey: "ip",
header: () => <span className="px-2">{t("ip")}</span>,
header: () => (
<span className="px-2">
<ColumnMultiFilterButton
options={(filters.ip ?? []).map((ip) => ({
label: ip,
value: ip
}))}
label={t("ip")}
allowArbitraryValues
searchPlaceholder={t("ipFilterSearchPlaceholder")}
emptyMessage={t("ipFilterEmptyMessage")}
selectedValues={filters.ip ?? []}
onSelectedValuesChange={(value) =>
handleFilterChange("ip", value)
}
/>
</span>
),
cell: ({ row }) => {
return row.original.ip ? (
row.original.ip
@@ -315,7 +347,7 @@ export default function GeneralPage() {
options={filterAttributes.locations.map(
(location) => ({
value: location,
label: location
label: `${location} ${countryCodeToFlagEmoji(location)}`
})
)}
label={t("location")}
@@ -334,7 +366,8 @@ export default function GeneralPage() {
<span className="flex items-center gap-1">
{row.original.location ? (
<span className="text-muted-foreground text-xs">
{row.original.location}
{row.original.location}{" "}
{countryCodeToFlagEmoji(row.original.location)}
</span>
) : (
<span className="text-muted-foreground text-xs">
+40 -10
View File
@@ -23,6 +23,8 @@ import { useMemo, useState, useTransition } from "react";
import { useStoredPageSize } from "@app/hooks/useStoredPageSize";
import type { QueryRequestAuditLogResponse } from "@server/routers/auditLogs/types";
import { ColumnFilterButton } from "@app/components/ColumnFilterButton";
import { countryCodeToFlagEmoji } from "@app/lib/countryCodeToFlagEmoji";
import { ColumnMultiFilterButton } from "@app/components/ColumnMultiFilterButton";
export default function GeneralPage() {
const router = useRouter();
@@ -47,6 +49,7 @@ export default function GeneralPage() {
method?: string;
reason?: string;
path?: string;
ip?: string[];
}>({
action: searchParams.get("action") || undefined,
host: searchParams.get("host") || undefined,
@@ -55,7 +58,8 @@ export default function GeneralPage() {
actor: searchParams.get("actor") || undefined,
method: searchParams.get("method") || undefined,
reason: searchParams.get("reason") || undefined,
path: searchParams.get("path") || undefined
path: searchParams.get("path") || undefined,
ip: searchParams.getAll("ip") || undefined
});
const getDefaultDateRange = () => {
@@ -179,7 +183,7 @@ export default function GeneralPage() {
const handleFilterChange = (
filterType: keyof typeof filters,
value: string | undefined
value: string | string[] | undefined
) => {
const newFilters = { ...filters, [filterType]: value };
setFilters(newFilters);
@@ -197,10 +201,13 @@ export default function GeneralPage() {
) => {
const params = new URLSearchParams(searchParams);
Object.entries(newFilters).forEach(([key, value]) => {
if (value) {
params.delete(key);
if (typeof value === "string") {
params.set(key, value);
} else {
params.delete(key);
} else if (typeof value !== "undefined" && "length" in value) {
for (const element of value) {
params.append(key, element);
}
}
});
router.replace(`?${params.toString()}`, { scroll: false });
@@ -209,6 +216,7 @@ export default function GeneralPage() {
const exportData = async () => {
try {
// Prepare query params for export
const { ip, ...restFilters } = filters;
const params: any = {
timeStart: dateRange.startDate?.date
? new Date(dateRange.startDate.date).toISOString()
@@ -216,11 +224,15 @@ export default function GeneralPage() {
timeEnd: dateRange.endDate?.date
? new Date(dateRange.endDate.date).toISOString()
: undefined,
...filters
...restFilters
};
// axios serializes arrays as `ip[]=…`, which express's query
// parser does not read back as `ip`, so pass them in the URL
const sp = new URLSearchParams((ip ?? []).map((ip) => ["ip", ip]));
const response = await api.get(
`/org/${orgId}/logs/request/export`,
`/org/${orgId}/logs/request/export?${sp.toString()}`,
{
responseType: "blob",
params
@@ -351,7 +363,24 @@ export default function GeneralPage() {
},
{
accessorKey: "ip",
header: ({ column }) => <span className="px-2">{t("ip")}</span>,
header: ({ column }) => (
<span className="px-2">
<ColumnMultiFilterButton
options={(filters.ip ?? []).map((ip) => ({
label: ip,
value: ip
}))}
label={t("ip")}
allowArbitraryValues
searchPlaceholder={t("ipFilterSearchPlaceholder")}
emptyMessage={t("ipFilterEmptyMessage")}
selectedValues={filters.ip ?? []}
onSelectedValuesChange={(value) =>
handleFilterChange("ip", value)
}
/>
</span>
),
cell: ({ row }) => {
return row.original.ip ? (
row.original.ip
@@ -369,7 +398,7 @@ export default function GeneralPage() {
options={filterAttributes.locations.map(
(location) => ({
value: location,
label: location
label: `${location} ${countryCodeToFlagEmoji(location)}`
})
)}
selectedValue={filters.location}
@@ -389,7 +418,8 @@ export default function GeneralPage() {
<span className="flex items-center gap-1">
{row.original.location ? (
<span className="text-muted-foreground text-xs">
{row.original.location}
{row.original.location}{" "}
{countryCodeToFlagEmoji(row.original.location)}
</span>
) : (
<span className="text-muted-foreground text-xs">
@@ -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)) {
+5 -3
View File
@@ -21,7 +21,7 @@ import { useTranslations } from "next-intl";
interface FilterOption {
value: string;
label: string;
label: React.ReactNode;
}
interface ColumnFilterButtonProps {
@@ -32,6 +32,7 @@ interface ColumnFilterButtonProps {
emptyMessage?: string;
className?: string;
label: string;
allowArbitraryValues?: boolean;
}
export function ColumnFilterButton({
@@ -41,7 +42,8 @@ export function ColumnFilterButton({
searchPlaceholder = "Search...",
emptyMessage = "No options found",
className,
label
label,
allowArbitraryValues
}: ColumnFilterButtonProps) {
const [open, setOpen] = useState(false);
@@ -101,7 +103,7 @@ export function ColumnFilterButton({
{options.map((option) => (
<CommandItem
key={option.value}
value={option.label}
value={option.value}
onSelect={() => {
onValueChange(
selectedValue === option.value
+24 -4
View File
@@ -35,6 +35,7 @@ type ColumnMultiFilterButtonProps = {
emptyMessage?: string;
className?: string;
label: string;
allowArbitraryValues?: boolean;
};
export function ColumnMultiFilterButton({
@@ -44,11 +45,26 @@ export function ColumnMultiFilterButton({
searchPlaceholder = "Search...",
emptyMessage = "No options found",
className,
label
label,
allowArbitraryValues
}: ColumnMultiFilterButtonProps) {
const [open, setOpen] = useState(false);
const [searchQuery, setSearchQuery] = useState("");
const t = useTranslations();
const visibleOptions = useMemo<FilterOption[]>(() => {
const newOptions = [...options];
if (allowArbitraryValues && searchQuery.trim().length > 0) {
newOptions.push({
label: searchQuery,
value: searchQuery
});
}
return newOptions;
}, [options, allowArbitraryValues, searchQuery]);
const selectedSet = useMemo(
() => new Set(selectedValues),
[selectedValues]
@@ -64,7 +80,7 @@ export function ColumnMultiFilterButton({
selectedValues[0]
);
}
return t("accessUsersRoleFilterCount", {
return t("multiSelectFilterCount", {
count: selectedValues.length
});
}, [selectedValues, options, t]);
@@ -108,7 +124,11 @@ export function ColumnMultiFilterButton({
align="start"
>
<Command>
<CommandInput placeholder={searchPlaceholder} />
<CommandInput
placeholder={searchPlaceholder}
value={searchQuery}
onValueChange={setSearchQuery}
/>
<CommandList>
<CommandEmpty>{emptyMessage}</CommandEmpty>
<CommandGroup>
@@ -123,7 +143,7 @@ export function ColumnMultiFilterButton({
{t("accessFilterClear")}
</CommandItem>
)}
{options.map((option) => (
{visibleOptions.map((option) => (
<CommandItem
key={option.value}
value={option.label}
+30 -22
View File
@@ -1,3 +1,8 @@
import {
getAiBudgetScopeListPath,
type AiBudgetScope
} from "@app/lib/aiBudgetScope";
import type { AiProviderType } from "@app/lib/aiProviderDefaults";
import type { LauncherQueryFilters } from "@app/lib/launcherSearchParams";
import { buildLauncherSearchParams } from "@app/lib/launcherSearchParams";
import { build } from "@server/build";
@@ -5,15 +10,21 @@ import {
StatusHistoryResponse,
type BatchedStatusHistoryResponse
} from "@server/lib/statusHistory";
import type { ListAiBudgetsByScopeResponse } from "@server/routers/aiBudget/types";
import type {
ListAiModelsResponse,
ListAiProvidersResponse,
ListCatalogModelsResponse
} from "@server/routers/aiProvider/types";
import type { ListAlertRulesResponse } from "@server/routers/alertRule/types";
import type {
QueryRequestAnalyticsResponse,
QueryAiUsageFilterOptionsResponse,
QueryAiUsageOverviewResponse,
QueryAiUsageProvidersResponse,
QueryAiUsageResourcesResponse,
QueryAiUsageUsersRolesResponse,
QueryAiUsageVirtualApiKeysResponse
QueryAiUsageVirtualApiKeysResponse,
QueryRequestAnalyticsResponse
} from "@server/routers/auditLogs";
import type {
QueryAccessAuditLogResponse,
@@ -34,6 +45,7 @@ import type {
import type { GetDomainResponse } from "@server/routers/domain/getDomain";
import { ListHealthChecksResponse } from "@server/routers/healthChecks/types";
import type { ListOrgLabelsResponse } from "@server/routers/labels/types";
import type { ListLauncherAiModelsResponse } from "@server/routers/launcher/listLauncherAiModels";
import type {
LauncherResource,
ListLauncherGroupsResponse,
@@ -43,9 +55,8 @@ import type {
ListLauncherSitesResponse,
ListLauncherViewsResponse
} from "@server/routers/launcher/types";
import type { ListLauncherAiModelsResponse } from "@server/routers/launcher/listLauncherAiModels";
import type { ListMyVirtualApiKeysResponse } from "@server/routers/virtualApiKey/types";
import type { GetResourcePolicyResponse } from "@server/routers/policy";
import type { ListRemoteExitNodesResponse } from "@server/routers/remoteExitNode/types";
import type {
GetResourcePoliciesResponse,
GetResourceWhitelistResponse,
@@ -59,7 +70,6 @@ import type {
import type { GetResourceResponse } from "@server/routers/resource/getResource";
import type { GetResourceAuthInfoResponse } from "@server/routers/resource/getResourceAuthInfo";
import type { ListResourcePoliciesResponse } from "@server/routers/resource/types";
import type { ListRemoteExitNodesResponse } from "@server/routers/remoteExitNode/types";
import type { ListRolesResponse } from "@server/routers/role";
import type { ListSitesResponse } from "@server/routers/site";
import type {
@@ -71,18 +81,8 @@ import type {
} from "@server/routers/siteResource";
import type { GetSiteResourceResponse } from "@server/routers/siteResource/getSiteResource";
import type { ListTargetsResponse } from "@server/routers/target";
import type {
ListAiModelsResponse,
ListAiProvidersResponse,
ListCatalogModelsResponse
} from "@server/routers/aiProvider/types";
import type { AiProviderType } from "@app/lib/aiProviderDefaults";
import type { ListAiBudgetsByScopeResponse } from "@server/routers/aiBudget/types";
import {
getAiBudgetScopeListPath,
type AiBudgetScope
} from "@app/lib/aiBudgetScope";
import type { ListUsersResponse } from "@server/routers/user";
import type { ListMyVirtualApiKeysResponse } from "@server/routers/virtualApiKey/types";
import type ResponseT from "@server/types/Response";
import {
infiniteQueryOptions,
@@ -1000,7 +1000,8 @@ export const httpLogsFiltersSchema = z.object({
actor: z.string().optional().catch(undefined),
method: z.string().optional().catch(undefined),
reason: z.string().optional().catch(undefined),
path: z.string().optional().catch(undefined)
path: z.string().optional().catch(undefined),
ip: z.array(z.string()).optional().catch(undefined)
});
export type HttpLogFilters = z.output<typeof httpLogsFiltersSchema>;
@@ -1026,7 +1027,8 @@ export const accessLogsFiltersSchema = z.object({
action: z.string().optional().catch(undefined),
location: z.string().optional().catch(undefined),
actor: z.string().optional().catch(undefined),
type: z.string().optional().catch(undefined)
type: z.string().optional().catch(undefined),
ip: z.array(z.string()).optional().catch(undefined)
});
export type AccessLogFilters = z.output<typeof accessLogsFiltersSchema>;
@@ -1139,10 +1141,13 @@ export const logQueries = {
queryOptions({
queryKey: ["REQUEST_LOGS", orgId, "ALL", filters] as const,
queryFn: async ({ signal, meta }) => {
const { page, pageSize, ...rest } = filters;
const { page, pageSize, ip, ...rest } = filters;
const sp = new URLSearchParams(
(ip ?? []).map((ip) => ["ip", ip])
);
const res = await meta!.api.get<
AxiosResponse<QueryRequestAuditLogResponse>
>(`/org/${orgId}/logs/request`, {
>(`/org/${orgId}/logs/request?${sp.toString()}`, {
params: {
...rest,
limit: pageSize,
@@ -1164,10 +1169,13 @@ export const logQueries = {
queryOptions({
queryKey: ["ACCESS_LOGS", orgId, "ALL", filters] as const,
queryFn: async ({ signal, meta }) => {
const { page, pageSize, ...rest } = filters;
const { page, pageSize, ip, ...rest } = filters;
const sp = new URLSearchParams(
(ip ?? []).map((ip) => ["ip", ip])
);
const res = await meta!.api.get<
AxiosResponse<QueryAccessAuditLogResponse>
>(`/org/${orgId}/logs/access`, {
>(`/org/${orgId}/logs/access?${sp.toString()}`, {
params: {
...rest,
limit: pageSize,