Compare commits

..

43 Commits

Author SHA1 Message Date
Milo Schwartz c30fe5b574 Merge pull request #3457 from fosrl/dev
1.21.0
2026-07-17 17:17:35 -04:00
miloschwartz a0b9ea76a3 add badger to migration 2026-07-17 16:22:15 -04:00
miloschwartz eb3a3eac98 add 1.21 migrations 2026-07-17 15:58:13 -04:00
Owen 02d2e09709 Merge branch 'local-connection' into dev 2026-07-17 14:07:30 -04:00
miloschwartz 6e8283957c update remote node routes to support access token updates 2026-07-17 14:06:35 -04:00
miloschwartz bb9b94a983 add persistent session and users to access tokens 2026-07-17 13:39:09 -04:00
Owen 36f807ddbc Merge branch 'dev' into local-connection 2026-07-17 11:15:51 -04:00
miloschwartz cb31546c6b fix spelling in installer 2026-07-17 10:15:11 -04:00
miloschwartz 2d48adc9f9 use proxy for logout on org auth page session expire 2026-07-16 22:06:59 -04:00
Owen Schwartz 113b7a0b84 Merge pull request #3453 from fosrl/dev
1.20.0-s.3
2026-07-16 21:39:00 -04:00
Owen Schwartz ca279ee3e0 Merge pull request #3452 from fosrl/crowdin_dev
New Crowdin updates
2026-07-16 21:37:08 -04:00
Owen Schwartz a5808200f0 New translations en-us.json (Norwegian Bokmal)
[ci skip]
2026-07-16 21:34:54 -04:00
Owen Schwartz aa9de4d2ac New translations en-us.json (Chinese Simplified)
[ci skip]
2026-07-16 21:34:52 -04:00
Owen Schwartz 50821e972d New translations en-us.json (Turkish)
[ci skip]
2026-07-16 21:34:50 -04:00
Owen Schwartz e5b9dfee66 New translations en-us.json (Russian)
[ci skip]
2026-07-16 21:34:48 -04:00
Owen Schwartz 8e09070bc1 New translations en-us.json (Portuguese)
[ci skip]
2026-07-16 21:34:46 -04:00
Owen Schwartz 3d9c413bbb New translations en-us.json (Polish)
[ci skip]
2026-07-16 21:34:45 -04:00
Owen Schwartz 86670c1d60 New translations en-us.json (Dutch)
[ci skip]
2026-07-16 21:34:43 -04:00
Owen Schwartz 5f2ee77a73 New translations en-us.json (Korean)
[ci skip]
2026-07-16 21:34:41 -04:00
Owen Schwartz 714e3a8fb1 New translations en-us.json (Italian)
[ci skip]
2026-07-16 21:34:40 -04:00
Owen Schwartz 672555f051 New translations en-us.json (German)
[ci skip]
2026-07-16 21:34:38 -04:00
Owen Schwartz 7853f2b096 New translations en-us.json (Danish)
[ci skip]
2026-07-16 21:34:36 -04:00
Owen Schwartz 61a7dda29a New translations en-us.json (Czech)
[ci skip]
2026-07-16 21:34:35 -04:00
Owen Schwartz f9bcb25eaa New translations en-us.json (Bulgarian)
[ci skip]
2026-07-16 21:34:33 -04:00
Owen Schwartz 6df804ab32 New translations en-us.json (Spanish)
[ci skip]
2026-07-16 21:34:31 -04:00
Owen Schwartz 9e50735800 New translations en-us.json (French)
[ci skip]
2026-07-16 21:34:29 -04:00
miloschwartz a30119f5e8 encode email in url param 2026-07-16 21:28:36 -04:00
miloschwartz 3c9f0946d2 add site provisioning keys to integration api 2026-07-16 17:56:31 -04:00
Owen 21861a6dcd Merge branch 'local-connection' of github.com:fosrl/pangolin into local-connection 2026-07-16 17:25:58 -04:00
Owen 6135f2b727 Local endpoints from the get config 2026-07-16 17:25:51 -04:00
Owen 3a616cc804 Add handlers 2026-07-16 17:25:51 -04:00
Owen d5eb67a8fc Send localEndpoints on sites to olm 2026-07-16 17:25:51 -04:00
Owen 3ac7ef23ae Save the local endpoints 2026-07-16 17:25:51 -04:00
Owen 08c5ec2be7 Add new column 2026-07-16 17:25:51 -04:00
Owen a88b79e066 Add timezone offset to fix bad display 2026-07-16 16:09:35 -04:00
Owen a48ef77ee5 Merge branch 'dev' of github.com:fosrl/pangolin into dev 2026-07-16 15:49:52 -04:00
Owen 55f3807491 Enforce uniqueness of aliases in blueprints 2026-07-16 15:10:38 -04:00
Owen dbfb8e83e8 Local endpoints from the get config 2026-07-16 14:43:45 -04:00
Owen 515afc14a5 Add handlers 2026-07-16 11:23:53 -04:00
Owen 903e8c0fa1 Send localEndpoints on sites to olm 2026-07-16 10:47:24 -04:00
Owen 7f9c760380 Save the local endpoints 2026-07-16 09:59:11 -04:00
Owen 530a1a5350 Add new column 2026-07-16 09:59:07 -04:00
miloschwartz 3fb36c1434 add missing translation 2026-07-15 22:03:28 -04:00
69 changed files with 2019 additions and 338 deletions
+1 -1
View File
@@ -1,4 +1,4 @@
FROM node:26-alpine FROM node:24-alpine
WORKDIR /app WORKDIR /app
+1 -1
View File
@@ -76,7 +76,7 @@ var redisFlag *bool
func main() { func main() {
crowdsecFlag := flag.Bool("crowdsec", false, "Enable the CrowdSec installation prompt") crowdsecFlag := flag.Bool("crowdsec", false, "Enable the CrowdSec installation prompt")
redisFlag = flag.Bool("redis", false, "Install Redis as cacheing solution. Required for HA. Not required for the Enterprise version.") redisFlag = flag.Bool("redis", false, "Install Redis as caching solution. Required for HA. Not required for the Enterprise version.")
flag.Parse() flag.Parse()
// print a banner about prerequisites - opening port 80, 443, 51820, and 21820 on the VPS and firewall and pointing your domain to the VPS IP with a records. Docs are at http://localhost:3000/Getting%20Started/dns-networking // print a banner about prerequisites - opening port 80, 443, 51820, and 21820 on the VPS and firewall and pointing your domain to the VPS IP with a records. Docs are at http://localhost:3000/Getting%20Started/dns-networking
+6 -1
View File
@@ -1511,6 +1511,10 @@
"actionListInvitations": "Списък с покани", "actionListInvitations": "Списък с покани",
"actionExportLogs": "Експортиране на дневници", "actionExportLogs": "Експортиране на дневници",
"actionViewLogs": "Преглед на дневници", "actionViewLogs": "Преглед на дневници",
"actionCreateSiteProvisioningKey": "Създаване на ключ за предоставяне на сайт",
"actionListSiteProvisioningKeys": "Списък на ключовете за предоставяне на сайт",
"actionUpdateSiteProvisioningKey": "Актуализиране на ключ за предоставяне на сайт",
"actionDeleteSiteProvisioningKey": "Изтриване на ключ за предоставяне на сайт",
"noneSelected": "Нищо не е избрано", "noneSelected": "Нищо не е избрано",
"orgNotFound2": "Няма намерени организации.", "orgNotFound2": "Няма намерени организации.",
"search": "Търси…", "search": "Търси…",
@@ -3811,5 +3815,6 @@
"rdpUploadFailed": "Качването неуспешно", "rdpUploadFailed": "Качването неуспешно",
"rdpUnicodeKeyboardMode": "Режим на unicode клавиатура", "rdpUnicodeKeyboardMode": "Режим на unicode клавиатура",
"sessionToolbarShow": "Показване на лентата с инструменти", "sessionToolbarShow": "Показване на лентата с инструменти",
"sessionToolbarHide": "Скриване на лентата с инструменти" "sessionToolbarHide": "Скриване на лентата с инструменти",
"actionUpdateSiteApprovals": "Обновяване на одобренията на сайта"
} }
+6 -1
View File
@@ -1511,6 +1511,10 @@
"actionListInvitations": "Seznam pozvánek", "actionListInvitations": "Seznam pozvánek",
"actionExportLogs": "Exportovat protokoly", "actionExportLogs": "Exportovat protokoly",
"actionViewLogs": "Zobrazit logy", "actionViewLogs": "Zobrazit logy",
"actionCreateSiteProvisioningKey": "Vytvořit klíč pro zřízení webu",
"actionListSiteProvisioningKeys": "Seznam klíčů pro zřízení webu",
"actionUpdateSiteProvisioningKey": "Aktualizovat klíč pro zřízení webu",
"actionDeleteSiteProvisioningKey": "Smazat klíč pro zřízení webu",
"noneSelected": "Není vybráno", "noneSelected": "Není vybráno",
"orgNotFound2": "Nebyly nalezeny žádné organizace.", "orgNotFound2": "Nebyly nalezeny žádné organizace.",
"search": "Vyhledávání…", "search": "Vyhledávání…",
@@ -3811,5 +3815,6 @@
"rdpUploadFailed": "Nahrání selhalo", "rdpUploadFailed": "Nahrání selhalo",
"rdpUnicodeKeyboardMode": "Režim Unicode klávesnice", "rdpUnicodeKeyboardMode": "Režim Unicode klávesnice",
"sessionToolbarShow": "Zobrazit panel nástrojů", "sessionToolbarShow": "Zobrazit panel nástrojů",
"sessionToolbarHide": "Skrýt panel nástrojů" "sessionToolbarHide": "Skrýt panel nástrojů",
"actionUpdateSiteApprovals": "Aktualizovat schválení webu"
} }
+6 -1
View File
@@ -1511,6 +1511,10 @@
"actionListInvitations": "Vis invitationer", "actionListInvitations": "Vis invitationer",
"actionExportLogs": "Eksportér logs", "actionExportLogs": "Eksportér logs",
"actionViewLogs": "Vis logs", "actionViewLogs": "Vis logs",
"actionCreateSiteProvisioningKey": "Opret provisioneringsnøgle til site",
"actionListSiteProvisioningKeys": "Vis provisioneringsnøgler til site",
"actionUpdateSiteProvisioningKey": "Opdater provisioneringsnøgle til site",
"actionDeleteSiteProvisioningKey": "Slet provisioneringsnøgle til site",
"noneSelected": "Ingen valgt", "noneSelected": "Ingen valgt",
"orgNotFound2": "Ingen organisationer fundet.", "orgNotFound2": "Ingen organisationer fundet.",
"search": "Søg…", "search": "Søg…",
@@ -3811,5 +3815,6 @@
"rdpUploadFailed": "Uploaden mislykkedes", "rdpUploadFailed": "Uploaden mislykkedes",
"rdpUnicodeKeyboardMode": "Unicode tastaturtilstand", "rdpUnicodeKeyboardMode": "Unicode tastaturtilstand",
"sessionToolbarShow": "Vis værktøjslinje", "sessionToolbarShow": "Vis værktøjslinje",
"sessionToolbarHide": "Skjul værktøjslinje" "sessionToolbarHide": "Skjul værktøjslinje",
"actionUpdateSiteApprovals": "Opdater godkendelser på site"
} }
+6 -1
View File
@@ -1511,6 +1511,10 @@
"actionListInvitations": "Einladungen auflisten", "actionListInvitations": "Einladungen auflisten",
"actionExportLogs": "Logs exportieren", "actionExportLogs": "Logs exportieren",
"actionViewLogs": "Logs anzeigen", "actionViewLogs": "Logs anzeigen",
"actionCreateSiteProvisioningKey": "Bereitstellungsschlüssel für Standort erstellen",
"actionListSiteProvisioningKeys": "Liste der Bereitstellungsschlüssel für Standorte",
"actionUpdateSiteProvisioningKey": "Bereitstellungsschlüssel für Standort aktualisieren",
"actionDeleteSiteProvisioningKey": "Bereitstellungsschlüssel für Standort löschen",
"noneSelected": "Keine ausgewählt", "noneSelected": "Keine ausgewählt",
"orgNotFound2": "Keine Organisationen gefunden.", "orgNotFound2": "Keine Organisationen gefunden.",
"search": "Suche…", "search": "Suche…",
@@ -3811,5 +3815,6 @@
"rdpUploadFailed": "Upload fehlgeschlagen", "rdpUploadFailed": "Upload fehlgeschlagen",
"rdpUnicodeKeyboardMode": "Unicode-Tastaturmodus", "rdpUnicodeKeyboardMode": "Unicode-Tastaturmodus",
"sessionToolbarShow": "Werkzeugleiste zeigen", "sessionToolbarShow": "Werkzeugleiste zeigen",
"sessionToolbarHide": "Werkzeugleiste ausblenden" "sessionToolbarHide": "Werkzeugleiste ausblenden",
"actionUpdateSiteApprovals": "Standortgenehmigungen aktualisieren"
} }
+15 -4
View File
@@ -178,7 +178,7 @@
"shareDeleteConfirm": "Confirm Delete Shareable Link", "shareDeleteConfirm": "Confirm Delete Shareable Link",
"shareQuestionRemove": "Are you sure you want to delete this share link?", "shareQuestionRemove": "Are you sure you want to delete this share link?",
"shareMessageRemove": "Once deleted, the link will no longer work and anyone using it will lose access to the resource.", "shareMessageRemove": "Once deleted, the link will no longer work and anyone using it will lose access to the resource.",
"shareTokenDescription": "The access token can be passed in two ways: as a query parameter or in the request headers. These must be passed from the client on every request for authenticated access.", "shareTokenDescription": "The access token can be passed as a query parameter or in request headers. By default it must be sent on every request. If session persistence is enabled, the first request exchanges it for a session cookie.",
"accessToken": "Access Token", "accessToken": "Access Token",
"usageExamples": "Usage Examples", "usageExamples": "Usage Examples",
"tokenId": "Token ID", "tokenId": "Token ID",
@@ -196,8 +196,14 @@
"shareTitleOptional": "Title (optional)", "shareTitleOptional": "Title (optional)",
"sharePathOptional": "Path (optional)", "sharePathOptional": "Path (optional)",
"sharePathDescription": "The link will redirect users to this path after authentication.", "sharePathDescription": "The link will redirect users to this path after authentication.",
"shareAssociateUserOptional": "Associate User (optional)",
"shareAssociateUserDescription": "When set, requests using this link are attributed to the user in access logs and identity headers. The link is removed if the user leaves the organization.",
"userSelect": "Select user",
"usersNotFound": "No users found",
"expireIn": "Expire In", "expireIn": "Expire In",
"neverExpire": "Never expire", "neverExpire": "Never expire",
"sharePersistSession": "Persist session after first use",
"sharePersistSessionDescription": "When enabled, the first request with this token via a query param or header sets a session cookie so later requests do not need the token. Leave off for API clients that should send the token on every request.",
"shareExpireDescription": "Expiration time is how long the link will be usable and provide access to the resource. After this time, the link will no longer work, and users who used this link will lose access to the resource.", "shareExpireDescription": "Expiration time is how long the link will be usable and provide access to the resource. After this time, the link will no longer work, and users who used this link will lose access to the resource.",
"shareSeeOnce": "You will only be able to see this link once. Make sure to copy it.", "shareSeeOnce": "You will only be able to see this link once. Make sure to copy it.",
"shareAccessHint": "Anyone with this link can access the resource. Share it with care.", "shareAccessHint": "Anyone with this link can access the resource. Share it with care.",
@@ -1511,6 +1517,10 @@
"actionListInvitations": "List Invitations", "actionListInvitations": "List Invitations",
"actionExportLogs": "Export Logs", "actionExportLogs": "Export Logs",
"actionViewLogs": "View Logs", "actionViewLogs": "View Logs",
"actionCreateSiteProvisioningKey": "Create Site Provisioning Key",
"actionListSiteProvisioningKeys": "List Site Provisioning Keys",
"actionUpdateSiteProvisioningKey": "Update Site Provisioning Key",
"actionDeleteSiteProvisioningKey": "Delete Site Provisioning Key",
"noneSelected": "None selected", "noneSelected": "None selected",
"orgNotFound2": "No organizations found.", "orgNotFound2": "No organizations found.",
"search": "Search…", "search": "Search…",
@@ -3085,8 +3095,8 @@
"sourceAddress": "Source Address", "sourceAddress": "Source Address",
"destinationAddress": "Destination Address", "destinationAddress": "Destination Address",
"duration": "Duration", "duration": "Duration",
"licenseRequiredToUse": "An <enterpriseLicenseLink>Enterprise Edition</enterpriseLicenseLink> license or <pangolinCloudLink>Pangolin Cloud</pangolinCloudLink> is required to use this feature. <bookADemoLink>Book a free demo or POC trial to learn more</bookADemoLink>.", "licenseRequiredToUse": "An <enterpriseLicenseLink>Enterprise Edition</enterpriseLicenseLink> license or <pangolinCloudLink>Pangolin Cloud</pangolinCloudLink> is required to use this feature. <bookADemoLink>Book a free demo or POC trial to learn more.</bookADemoLink>",
"ossEnterpriseEditionRequired": "The <enterpriseEditionLink>Enterprise Edition</enterpriseEditionLink> is required to use this feature. This feature is also available in <pangolinCloudLink>Pangolin Cloud</pangolinCloudLink>. <bookADemoLink>Book a free demo or POC trial to learn more</bookADemoLink>.", "ossEnterpriseEditionRequired": "The <enterpriseEditionLink>Enterprise Edition</enterpriseEditionLink> is required to use this feature. This feature is also available in <pangolinCloudLink>Pangolin Cloud</pangolinCloudLink>. <bookADemoLink>Book a free demo or POC trial to learn more.</bookADemoLink>",
"certResolver": "Certificate Resolver", "certResolver": "Certificate Resolver",
"certResolverDescription": "Select the certificate resolver to use for this resource.", "certResolverDescription": "Select the certificate resolver to use for this resource.",
"selectCertResolver": "Select Certificate Resolver", "selectCertResolver": "Select Certificate Resolver",
@@ -3811,5 +3821,6 @@
"rdpUploadFailed": "Upload failed", "rdpUploadFailed": "Upload failed",
"rdpUnicodeKeyboardMode": "Unicode keyboard mode", "rdpUnicodeKeyboardMode": "Unicode keyboard mode",
"sessionToolbarShow": "Show toolbar", "sessionToolbarShow": "Show toolbar",
"sessionToolbarHide": "Hide toolbar" "sessionToolbarHide": "Hide toolbar",
"actionUpdateSiteApprovals": "Update Site Approvals"
} }
+6 -1
View File
@@ -1511,6 +1511,10 @@
"actionListInvitations": "Listar invitaciones", "actionListInvitations": "Listar invitaciones",
"actionExportLogs": "Exportar registros", "actionExportLogs": "Exportar registros",
"actionViewLogs": "Ver registros", "actionViewLogs": "Ver registros",
"actionCreateSiteProvisioningKey": "Crear clave de aprovisionamiento del sitio",
"actionListSiteProvisioningKeys": "Listado de claves de aprovisionamiento del sitio",
"actionUpdateSiteProvisioningKey": "Actualizar clave de aprovisionamiento del sitio",
"actionDeleteSiteProvisioningKey": "Eliminar clave de aprovisionamiento del sitio",
"noneSelected": "Ninguno seleccionado", "noneSelected": "Ninguno seleccionado",
"orgNotFound2": "No se encontraron organizaciones.", "orgNotFound2": "No se encontraron organizaciones.",
"search": "Buscar…", "search": "Buscar…",
@@ -3811,5 +3815,6 @@
"rdpUploadFailed": "Error de subida", "rdpUploadFailed": "Error de subida",
"rdpUnicodeKeyboardMode": "Modo teclado Unicode", "rdpUnicodeKeyboardMode": "Modo teclado Unicode",
"sessionToolbarShow": "Mostrar barra de herramientas", "sessionToolbarShow": "Mostrar barra de herramientas",
"sessionToolbarHide": "Ocultar barra de herramientas" "sessionToolbarHide": "Ocultar barra de herramientas",
"actionUpdateSiteApprovals": "Actualizar aprobaciones del sitio"
} }
+6 -1
View File
@@ -1511,6 +1511,10 @@
"actionListInvitations": "Lister les invitations", "actionListInvitations": "Lister les invitations",
"actionExportLogs": "Exporter les journaux", "actionExportLogs": "Exporter les journaux",
"actionViewLogs": "Voir les logs", "actionViewLogs": "Voir les logs",
"actionCreateSiteProvisioningKey": "Créer une clé de provisionnement de site",
"actionListSiteProvisioningKeys": "Lister les clés de provisionnement de site",
"actionUpdateSiteProvisioningKey": "Mettre à jour la clé de provisionnement de site",
"actionDeleteSiteProvisioningKey": "Supprimer la clé de provisionnement de site",
"noneSelected": "Aucune sélection", "noneSelected": "Aucune sélection",
"orgNotFound2": "Aucune organisation trouvée.", "orgNotFound2": "Aucune organisation trouvée.",
"search": "Rechercher…", "search": "Rechercher…",
@@ -3811,5 +3815,6 @@
"rdpUploadFailed": "Échec du téléchargement", "rdpUploadFailed": "Échec du téléchargement",
"rdpUnicodeKeyboardMode": "Mode clavier Unicode", "rdpUnicodeKeyboardMode": "Mode clavier Unicode",
"sessionToolbarShow": "Afficher la barre d'outils", "sessionToolbarShow": "Afficher la barre d'outils",
"sessionToolbarHide": "Masquer la barre d'outils" "sessionToolbarHide": "Masquer la barre d'outils",
"actionUpdateSiteApprovals": "Mettre à jour les approbations de site"
} }
+6 -1
View File
@@ -1511,6 +1511,10 @@
"actionListInvitations": "Elenco Inviti", "actionListInvitations": "Elenco Inviti",
"actionExportLogs": "Esporta Log", "actionExportLogs": "Esporta Log",
"actionViewLogs": "Visualizza Log", "actionViewLogs": "Visualizza Log",
"actionCreateSiteProvisioningKey": "Crea Chiave di Provisioning del Sito",
"actionListSiteProvisioningKeys": "Elenca Chiavi di Provisioning del Sito",
"actionUpdateSiteProvisioningKey": "Aggiorna Chiave di Provisioning del Sito",
"actionDeleteSiteProvisioningKey": "Elimina Chiave di Provisioning del Sito",
"noneSelected": "Nessuna selezione", "noneSelected": "Nessuna selezione",
"orgNotFound2": "Nessuna organizzazione trovata.", "orgNotFound2": "Nessuna organizzazione trovata.",
"search": "Cerca…", "search": "Cerca…",
@@ -3811,5 +3815,6 @@
"rdpUploadFailed": "Caricamento fallito", "rdpUploadFailed": "Caricamento fallito",
"rdpUnicodeKeyboardMode": "Modalità tastiera Unicode", "rdpUnicodeKeyboardMode": "Modalità tastiera Unicode",
"sessionToolbarShow": "Mostra barra degli strumenti", "sessionToolbarShow": "Mostra barra degli strumenti",
"sessionToolbarHide": "Nascondi barra degli strumenti" "sessionToolbarHide": "Nascondi barra degli strumenti",
"actionUpdateSiteApprovals": "Aggiorna Approvazioni del Sito"
} }
+6 -1
View File
@@ -1511,6 +1511,10 @@
"actionListInvitations": "초대 목록", "actionListInvitations": "초대 목록",
"actionExportLogs": "로그 내보내기", "actionExportLogs": "로그 내보내기",
"actionViewLogs": "로그 보기", "actionViewLogs": "로그 보기",
"actionCreateSiteProvisioningKey": "사이트 프로비저닝 키 생성",
"actionListSiteProvisioningKeys": "사이트 프로비저닝 키 목록",
"actionUpdateSiteProvisioningKey": "사이트 프로비저닝 키 업데이트",
"actionDeleteSiteProvisioningKey": "사이트 프로비저닝 키 삭제",
"noneSelected": "선택된 항목 없음", "noneSelected": "선택된 항목 없음",
"orgNotFound2": "조직이 없습니다.", "orgNotFound2": "조직이 없습니다.",
"search": "검색…", "search": "검색…",
@@ -3811,5 +3815,6 @@
"rdpUploadFailed": "업로드 실패", "rdpUploadFailed": "업로드 실패",
"rdpUnicodeKeyboardMode": "유니코드 키보드 모드", "rdpUnicodeKeyboardMode": "유니코드 키보드 모드",
"sessionToolbarShow": "툴바 보기", "sessionToolbarShow": "툴바 보기",
"sessionToolbarHide": "툴바 숨기기" "sessionToolbarHide": "툴바 숨기기",
"actionUpdateSiteApprovals": "사이트 승인 업데이트"
} }
+6 -1
View File
@@ -1511,6 +1511,10 @@
"actionListInvitations": "Liste invitasjoner", "actionListInvitations": "Liste invitasjoner",
"actionExportLogs": "Eksportlogger", "actionExportLogs": "Eksportlogger",
"actionViewLogs": "Vis logger", "actionViewLogs": "Vis logger",
"actionCreateSiteProvisioningKey": "Opprett Klargjøringsnøkkel for Sted",
"actionListSiteProvisioningKeys": "List Klargjøringsnøkler for Sted",
"actionUpdateSiteProvisioningKey": "Oppdater Klargjøringsnøkkel for Sted",
"actionDeleteSiteProvisioningKey": "Slett Klargjøringsnøkkel for Sted",
"noneSelected": "Ingen valgt", "noneSelected": "Ingen valgt",
"orgNotFound2": "Ingen organisasjoner funnet.", "orgNotFound2": "Ingen organisasjoner funnet.",
"search": "Søk…", "search": "Søk…",
@@ -3811,5 +3815,6 @@
"rdpUploadFailed": "Opplastningen mislyktes", "rdpUploadFailed": "Opplastningen mislyktes",
"rdpUnicodeKeyboardMode": "Unicode tastaturmodus", "rdpUnicodeKeyboardMode": "Unicode tastaturmodus",
"sessionToolbarShow": "Vis verktøylinje", "sessionToolbarShow": "Vis verktøylinje",
"sessionToolbarHide": "Skjul verktøylinje" "sessionToolbarHide": "Skjul verktøylinje",
"actionUpdateSiteApprovals": "Oppdater Stedsgodkjenninger"
} }
+6 -1
View File
@@ -1511,6 +1511,10 @@
"actionListInvitations": "Toon uitnodigingen", "actionListInvitations": "Toon uitnodigingen",
"actionExportLogs": "Logboeken exporteren", "actionExportLogs": "Logboeken exporteren",
"actionViewLogs": "Logboeken bekijken", "actionViewLogs": "Logboeken bekijken",
"actionCreateSiteProvisioningKey": "Een sitevoorzie-ningssleutel aanmaken",
"actionListSiteProvisioningKeys": "Sitevoorzie-ningssleutels weergeven",
"actionUpdateSiteProvisioningKey": "Sitevoorzie-ningssleutel bijwerken",
"actionDeleteSiteProvisioningKey": "Sitevoorzie-ningssleutel verwijderen",
"noneSelected": "Niet geselecteerd", "noneSelected": "Niet geselecteerd",
"orgNotFound2": "Geen organisaties gevonden.", "orgNotFound2": "Geen organisaties gevonden.",
"search": "Zoeken…", "search": "Zoeken…",
@@ -3811,5 +3815,6 @@
"rdpUploadFailed": "Upload mislukt", "rdpUploadFailed": "Upload mislukt",
"rdpUnicodeKeyboardMode": "Unicode toetsenbordmodus", "rdpUnicodeKeyboardMode": "Unicode toetsenbordmodus",
"sessionToolbarShow": "Toon werkbalk", "sessionToolbarShow": "Toon werkbalk",
"sessionToolbarHide": "Verberg werkbalk" "sessionToolbarHide": "Verberg werkbalk",
"actionUpdateSiteApprovals": "Sitegoedkeuringen bijwerken"
} }
+6 -1
View File
@@ -1511,6 +1511,10 @@
"actionListInvitations": "Lista zaproszeń", "actionListInvitations": "Lista zaproszeń",
"actionExportLogs": "Eksportuj dzienniki", "actionExportLogs": "Eksportuj dzienniki",
"actionViewLogs": "Zobacz dzienniki", "actionViewLogs": "Zobacz dzienniki",
"actionCreateSiteProvisioningKey": "Utwórz klucz konfiguracji strony",
"actionListSiteProvisioningKeys": "Lista kluczy konfiguracji strony",
"actionUpdateSiteProvisioningKey": "Zaktualizuj klucz konfiguracji strony",
"actionDeleteSiteProvisioningKey": "Usuń klucz konfiguracji strony",
"noneSelected": "Nie wybrano", "noneSelected": "Nie wybrano",
"orgNotFound2": "Nie znaleziono organizacji.", "orgNotFound2": "Nie znaleziono organizacji.",
"search": "Szukaj…", "search": "Szukaj…",
@@ -3811,5 +3815,6 @@
"rdpUploadFailed": "Niepowodzenie przesyłania", "rdpUploadFailed": "Niepowodzenie przesyłania",
"rdpUnicodeKeyboardMode": "Tryb klawiatury Unicode", "rdpUnicodeKeyboardMode": "Tryb klawiatury Unicode",
"sessionToolbarShow": "Pokaż pasek narzędzi", "sessionToolbarShow": "Pokaż pasek narzędzi",
"sessionToolbarHide": "Ukryj pasek narzędzi" "sessionToolbarHide": "Ukryj pasek narzędzi",
"actionUpdateSiteApprovals": "Zaktualizuj zgody na stronę"
} }
+6 -1
View File
@@ -1511,6 +1511,10 @@
"actionListInvitations": "Listar Convites", "actionListInvitations": "Listar Convites",
"actionExportLogs": "Exportar logs", "actionExportLogs": "Exportar logs",
"actionViewLogs": "Visualizar registros", "actionViewLogs": "Visualizar registros",
"actionCreateSiteProvisioningKey": "Criar Chave de Provisionamento do Site",
"actionListSiteProvisioningKeys": "Listar Chaves de Provisionamento do Site",
"actionUpdateSiteProvisioningKey": "Atualizar Chave de Provisionamento do Site",
"actionDeleteSiteProvisioningKey": "Excluir Chave de Provisionamento do Site",
"noneSelected": "Nenhum selecionado", "noneSelected": "Nenhum selecionado",
"orgNotFound2": "Nenhuma organização encontrada.", "orgNotFound2": "Nenhuma organização encontrada.",
"search": "Pesquisar…", "search": "Pesquisar…",
@@ -3811,5 +3815,6 @@
"rdpUploadFailed": "Falha no upload", "rdpUploadFailed": "Falha no upload",
"rdpUnicodeKeyboardMode": "Modo de teclado Unicode", "rdpUnicodeKeyboardMode": "Modo de teclado Unicode",
"sessionToolbarShow": "Mostrar barra de ferramentas", "sessionToolbarShow": "Mostrar barra de ferramentas",
"sessionToolbarHide": "Ocultar barra de ferramentas" "sessionToolbarHide": "Ocultar barra de ferramentas",
"actionUpdateSiteApprovals": "Atualizar Aprovações do Site"
} }
+6 -1
View File
@@ -1511,6 +1511,10 @@
"actionListInvitations": "Список приглашений", "actionListInvitations": "Список приглашений",
"actionExportLogs": "Экспорт журналов", "actionExportLogs": "Экспорт журналов",
"actionViewLogs": "Просмотр журналов", "actionViewLogs": "Просмотр журналов",
"actionCreateSiteProvisioningKey": "Создать ключ конфигурации сайта",
"actionListSiteProvisioningKeys": "Список ключей конфигурации сайтов",
"actionUpdateSiteProvisioningKey": "Обновить ключ конфигурации сайта",
"actionDeleteSiteProvisioningKey": "Удалить ключ конфигурации сайта",
"noneSelected": "Ничего не выбрано", "noneSelected": "Ничего не выбрано",
"orgNotFound2": "Организации не найдены.", "orgNotFound2": "Организации не найдены.",
"search": "Поиск…", "search": "Поиск…",
@@ -3811,5 +3815,6 @@
"rdpUploadFailed": "Ошибка загрузки", "rdpUploadFailed": "Ошибка загрузки",
"rdpUnicodeKeyboardMode": "Режим клавиатуры Unicode", "rdpUnicodeKeyboardMode": "Режим клавиатуры Unicode",
"sessionToolbarShow": "Показать панель инструментов", "sessionToolbarShow": "Показать панель инструментов",
"sessionToolbarHide": "Скрыть панель инструментов" "sessionToolbarHide": "Скрыть панель инструментов",
"actionUpdateSiteApprovals": "Обновить утверждения сайта"
} }
+6 -1
View File
@@ -1511,6 +1511,10 @@
"actionListInvitations": "Davetiyeleri Listele", "actionListInvitations": "Davetiyeleri Listele",
"actionExportLogs": "Kayıtları Dışa Aktar", "actionExportLogs": "Kayıtları Dışa Aktar",
"actionViewLogs": "Kayıtları Görüntüle", "actionViewLogs": "Kayıtları Görüntüle",
"actionCreateSiteProvisioningKey": "Site Sağlama Anahtarı Oluştur",
"actionListSiteProvisioningKeys": "Site Sağlama Anahtarlarını Listele",
"actionUpdateSiteProvisioningKey": "Site Sağlama Anahtarını Güncelle",
"actionDeleteSiteProvisioningKey": "Site Sağlama Anahtarını Sil",
"noneSelected": "Hiçbiri seçili değil", "noneSelected": "Hiçbiri seçili değil",
"orgNotFound2": "Hiçbir organizasyon bulunamadı.", "orgNotFound2": "Hiçbir organizasyon bulunamadı.",
"search": "Ara…", "search": "Ara…",
@@ -3811,5 +3815,6 @@
"rdpUploadFailed": "Yükleme başarısız", "rdpUploadFailed": "Yükleme başarısız",
"rdpUnicodeKeyboardMode": "Unicode klavye modu", "rdpUnicodeKeyboardMode": "Unicode klavye modu",
"sessionToolbarShow": "Araç çubuğunu göster", "sessionToolbarShow": "Araç çubuğunu göster",
"sessionToolbarHide": "Araç çubuğunu gizle" "sessionToolbarHide": "Araç çubuğunu gizle",
"actionUpdateSiteApprovals": "Site Onaylarını Güncelle"
} }
+6 -1
View File
@@ -1511,6 +1511,10 @@
"actionListInvitations": "邀请列表", "actionListInvitations": "邀请列表",
"actionExportLogs": "导出日志", "actionExportLogs": "导出日志",
"actionViewLogs": "查看日志", "actionViewLogs": "查看日志",
"actionCreateSiteProvisioningKey": "创建站点预配密钥",
"actionListSiteProvisioningKeys": "列出站点预配密钥",
"actionUpdateSiteProvisioningKey": "更新站点预配密钥",
"actionDeleteSiteProvisioningKey": "删除站点预配密钥",
"noneSelected": "未选择", "noneSelected": "未选择",
"orgNotFound2": "未找到组织。", "orgNotFound2": "未找到组织。",
"search": "搜索…", "search": "搜索…",
@@ -3811,5 +3815,6 @@
"rdpUploadFailed": "上传失败", "rdpUploadFailed": "上传失败",
"rdpUnicodeKeyboardMode": "Unicode 键盘模式", "rdpUnicodeKeyboardMode": "Unicode 键盘模式",
"sessionToolbarShow": "显示工具栏", "sessionToolbarShow": "显示工具栏",
"sessionToolbarHide": "隐藏工具栏" "sessionToolbarHide": "隐藏工具栏",
"actionUpdateSiteApprovals": "更新站点审批"
} }
+5
View File
@@ -107,6 +107,7 @@ export const sites = pgTable(
lastPing: integer("lastPing"), lastPing: integer("lastPing"),
address: varchar("address"), address: varchar("address"),
endpoint: varchar("endpoint"), endpoint: varchar("endpoint"),
localEndpoints: varchar("localEndpoints"), // JSON encoded list of string ips on the local machine to try to connect to
publicKey: varchar("publicKey"), publicKey: varchar("publicKey"),
lastHolePunch: bigint("lastHolePunch", { mode: "number" }), lastHolePunch: bigint("lastHolePunch", { mode: "number" }),
listenPort: integer("listenPort"), listenPort: integer("listenPort"),
@@ -905,12 +906,16 @@ export const resourceAccessToken = pgTable("resourceAccessToken", {
resourceId: integer("resourceId") resourceId: integer("resourceId")
.notNull() .notNull()
.references(() => resources.resourceId, { onDelete: "cascade" }), .references(() => resources.resourceId, { onDelete: "cascade" }),
userId: varchar("userId").references(() => users.userId, {
onDelete: "cascade"
}),
path: varchar("path"), path: varchar("path"),
tokenHash: varchar("tokenHash").notNull(), tokenHash: varchar("tokenHash").notNull(),
sessionLength: bigint("sessionLength", { mode: "number" }).notNull(), sessionLength: bigint("sessionLength", { mode: "number" }).notNull(),
expiresAt: bigint("expiresAt", { mode: "number" }), expiresAt: bigint("expiresAt", { mode: "number" }),
title: varchar("title"), title: varchar("title"),
description: varchar("description"), description: varchar("description"),
persistSession: boolean("persistSession").notNull().default(false),
createdAt: bigint("createdAt", { mode: "number" }).notNull() createdAt: bigint("createdAt", { mode: "number" }).notNull()
}); });
+7
View File
@@ -118,6 +118,7 @@ export const sites = sqliteTable("sites", {
// exit node stuff that is how to connect to the site when it has a wg server // 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 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 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 publicKey: text("publicKey"), // TODO: Fix typo in publicKey
lastHolePunch: integer("lastHolePunch"), lastHolePunch: integer("lastHolePunch"),
listenPort: integer("listenPort"), listenPort: integer("listenPort"),
@@ -1125,12 +1126,18 @@ export const resourceAccessToken = sqliteTable("resourceAccessToken", {
resourceId: integer("resourceId") resourceId: integer("resourceId")
.notNull() .notNull()
.references(() => resources.resourceId, { onDelete: "cascade" }), .references(() => resources.resourceId, { onDelete: "cascade" }),
userId: text("userId").references(() => users.userId, {
onDelete: "cascade"
}),
path: text("path"), path: text("path"),
tokenHash: text("tokenHash").notNull(), tokenHash: text("tokenHash").notNull(),
sessionLength: integer("sessionLength").notNull(), sessionLength: integer("sessionLength").notNull(),
expiresAt: integer("expiresAt"), expiresAt: integer("expiresAt"),
title: text("title"), title: text("title"),
description: text("description"), description: text("description"),
persistSession: integer("persistSession", { mode: "boolean" })
.notNull()
.default(false),
createdAt: integer("createdAt").notNull() createdAt: integer("createdAt").notNull()
}); });
+46
View File
@@ -239,6 +239,31 @@ export async function updatePrivateResources(
); );
} }
if (resourceData.alias) {
const [aliasConflict] = await trx
.select({
siteResourceId: siteResources.siteResourceId
})
.from(siteResources)
.where(
and(
eq(siteResources.orgId, orgId),
eq(siteResources.alias, resourceData.alias),
ne(
siteResources.siteResourceId,
existingResource.siteResourceId
)
)
)
.limit(1);
if (aliasConflict) {
throw new Error(
`Alias ${resourceData.alias} already in use by another site resource in org ${orgId}`
);
}
}
// Update existing resource // Update existing resource
const [updatedResource] = await trx const [updatedResource] = await trx
.update(siteResources) .update(siteResources)
@@ -480,6 +505,27 @@ export async function updatePrivateResources(
); );
} }
if (resourceData.alias) {
const [aliasConflict] = await trx
.select({
siteResourceId: siteResources.siteResourceId
})
.from(siteResources)
.where(
and(
eq(siteResources.orgId, orgId),
eq(siteResources.alias, resourceData.alias)
)
)
.limit(1);
if (aliasConflict) {
throw new Error(
`Alias ${resourceData.alias} already in use by another site resource in org ${orgId}`
);
}
}
const [network] = await trx const [network] = await trx
.insert(networks) .insert(networks)
.values({ .values({
+1 -1
View File
@@ -2,7 +2,7 @@ import path from "path";
import { fileURLToPath } from "url"; import { fileURLToPath } from "url";
// This is a placeholder value replaced by the build process // This is a placeholder value replaced by the build process
export const APP_VERSION = "1.20.0"; export const APP_VERSION = "1.21.0";
export const __FILENAME = fileURLToPath(import.meta.url); export const __FILENAME = fileURLToPath(import.meta.url);
export const __DIRNAME = path.dirname(__FILENAME); export const __DIRNAME = path.dirname(__FILENAME);
+49 -18
View File
@@ -8,26 +8,42 @@ const STATUS_HISTORY_CACHE_TTL = 60; // seconds
function statusHistoryCacheKey( function statusHistoryCacheKey(
entityType: string, entityType: string,
entityId: number, entityId: number,
days: number days: number,
tzOffsetMinutes: number
): string { ): string {
return `statusHistory:${entityType}:${entityId}:${days}`; return `statusHistory:${entityType}:${entityId}:${days}:${tzOffsetMinutes}`;
}
// Returns the epoch seconds of the most recent local-calendar-day midnight,
// where "local" is defined by tzOffsetMinutes (minutes to ADD to UTC to get
// local time, e.g. Australia/Sydney standard time is 600). Defaults to 0
// (UTC) so callers that don't pass a timezone keep the original behavior.
function localMidnightSec(tzOffsetMinutes: number): number {
const localNow = new Date(Date.now() + tzOffsetMinutes * 60_000);
localNow.setUTCHours(0, 0, 0, 0);
return Math.floor(localNow.getTime() / 1000) - tzOffsetMinutes * 60;
} }
export async function getCachedStatusHistory( export async function getCachedStatusHistory(
entityType: string, entityType: string,
entityId: number, entityId: number,
days: number days: number,
tzOffsetMinutes: number = 0
): Promise<StatusHistoryResponse> { ): Promise<StatusHistoryResponse> {
const cacheKey = statusHistoryCacheKey(entityType, entityId, days); const cacheKey = statusHistoryCacheKey(
entityType,
entityId,
days,
tzOffsetMinutes
);
const cached = await cache.get<StatusHistoryResponse>(cacheKey); const cached = await cache.get<StatusHistoryResponse>(cacheKey);
if (cached !== undefined) { if (cached !== undefined) {
return cached; return cached;
} }
// Anchor to UTC midnight so the query window aligns with stable calendar days // Anchor to local midnight (UTC when tzOffsetMinutes is 0) so the query
const utcToday = new Date(); // window aligns with stable calendar days for the requesting client
utcToday.setUTCHours(0, 0, 0, 0); const todayMidnightSec = localMidnightSec(tzOffsetMinutes);
const todayMidnightSec = Math.floor(utcToday.getTime() / 1000);
const startSec = todayMidnightSec - days * 86400; const startSec = todayMidnightSec - days * 86400;
const events = await logsDb const events = await logsDb
@@ -63,7 +79,8 @@ export async function getCachedStatusHistory(
const { buckets, totalDowntime } = computeBuckets( const { buckets, totalDowntime } = computeBuckets(
events, events,
days, days,
priorStatus priorStatus,
tzOffsetMinutes
); );
const totalWindow = days * 86400; const totalWindow = days * 86400;
const overallUptime = const overallUptime =
@@ -99,11 +116,19 @@ export const statusHistoryQuerySchema = z
days: z days: z
.string() .string()
.optional() .optional()
.transform((v) => (v ? parseInt(v, 10) : 90)) .transform((v) => (v ? parseInt(v, 10) : 90)),
// Minutes to add to UTC to get the requesting client's local time
// (e.g. Australia/Sydney standard time is 600). Optional and
// defaults to 0 (UTC) so older clients keep the prior behavior.
tzOffsetMinutes: z
.string()
.optional()
.transform((v) => (v ? parseInt(v, 10) : 0))
}) })
.pipe( .pipe(
z.object({ z.object({
days: z.number().int().min(1).max(365) days: z.number().int().min(1).max(365),
tzOffsetMinutes: z.number().int().min(-720).max(840)
}) })
); );
@@ -133,15 +158,15 @@ export function computeBuckets(
id: number; id: number;
}[], }[],
days: number, days: number,
priorStatus: string | null = null priorStatus: string | null = null,
tzOffsetMinutes: number = 0
): { buckets: StatusHistoryDayBucket[]; totalDowntime: number } { ): { buckets: StatusHistoryDayBucket[]; totalDowntime: number } {
const nowSec = Math.floor(Date.now() / 1000); const nowSec = Math.floor(Date.now() / 1000);
// Anchor bucket boundaries to UTC midnight so dates are stable calendar days // Anchor bucket boundaries to local midnight (UTC when tzOffsetMinutes is
// and don't drift as the cache expires and is recomputed // 0) so dates are stable calendar days for the requesting client and
const utcToday = new Date(); // don't drift as the cache expires and is recomputed
utcToday.setUTCHours(0, 0, 0, 0); const todayMidnightSec = localMidnightSec(tzOffsetMinutes);
const todayMidnightSec = Math.floor(utcToday.getTime() / 1000);
const buckets: StatusHistoryDayBucket[] = []; const buckets: StatusHistoryDayBucket[] = [];
let totalDowntime = 0; let totalDowntime = 0;
@@ -237,7 +262,13 @@ export function computeBuckets(
) )
: 100; : 100;
const dateStr = new Date(dayStartSec * 1000).toISOString().slice(0, 10); // Shift by the client's offset before formatting so the label reflects
// their local calendar date rather than the UTC date of dayStartSec
const dateStr = new Date(
(dayStartSec + tzOffsetMinutes * 60) * 1000
)
.toISOString()
.slice(0, 10);
const hasAnyData = currentStatus !== null || dayEvents.length > 0; const hasAnyData = currentStatus !== null || dayEvents.length > 0;
+10
View File
@@ -2,6 +2,7 @@ import {
db, db,
Org, Org,
orgs, orgs,
resourceAccessToken,
resources, resources,
siteResources, siteResources,
sites, sites,
@@ -83,6 +84,15 @@ export async function removeUserFromOrg(
.delete(userOrgs) .delete(userOrgs)
.where(and(eq(userOrgs.userId, userId), eq(userOrgs.orgId, org.orgId))); .where(and(eq(userOrgs.userId, userId), eq(userOrgs.orgId, org.orgId)));
await trx
.delete(resourceAccessToken)
.where(
and(
eq(resourceAccessToken.userId, userId),
eq(resourceAccessToken.orgId, org.orgId)
)
);
await trx.delete(userResources).where( await trx.delete(userResources).where(
and( and(
eq(userResources.userId, userId), eq(userResources.userId, userId),
+1
View File
@@ -17,3 +17,4 @@ export * from "./verifyApiKeySiteResourceAccess";
export * from "./verifyApiKeyIdpAccess"; export * from "./verifyApiKeyIdpAccess";
export * from "./verifyApiKeyDomainAccess"; export * from "./verifyApiKeyDomainAccess";
export * from "./verifyApiKeyResourcePolicyAccess"; export * from "./verifyApiKeyResourcePolicyAccess";
export * from "./verifyApiKeySiteProvisioningKeyAccess";
@@ -0,0 +1,111 @@
import { Request, Response, NextFunction } from "express";
import {
db,
siteProvisioningKeys,
siteProvisioningKeyOrg,
apiKeyOrg
} from "@server/db";
import { and, eq } from "drizzle-orm";
import createHttpError from "http-errors";
import HttpCode from "@server/types/HttpCode";
import { getFirstString } from "@server/lib/requestParams";
export async function verifyApiKeySiteProvisioningKeyAccess(
req: Request,
res: Response,
next: NextFunction
) {
try {
const apiKey = req.apiKey;
const siteProvisioningKeyId =
getFirstString(req.params.siteProvisioningKeyId) ||
getFirstString(req.body.siteProvisioningKeyId) ||
getFirstString(req.query.siteProvisioningKeyId);
const orgId = getFirstString(req.params.orgId);
if (!apiKey) {
return next(
createHttpError(HttpCode.UNAUTHORIZED, "Key not authenticated")
);
}
if (!orgId) {
return next(
createHttpError(HttpCode.BAD_REQUEST, "Invalid organization ID")
);
}
if (!siteProvisioningKeyId) {
return next(
createHttpError(HttpCode.BAD_REQUEST, "Invalid key ID")
);
}
if (apiKey.isRoot) {
// Root keys can access any site provisioning key in any org
return next();
}
const [row] = await db
.select()
.from(siteProvisioningKeys)
.innerJoin(
siteProvisioningKeyOrg,
and(
eq(
siteProvisioningKeys.siteProvisioningKeyId,
siteProvisioningKeyOrg.siteProvisioningKeyId
),
eq(siteProvisioningKeyOrg.orgId, orgId)
)
)
.where(
eq(
siteProvisioningKeys.siteProvisioningKeyId,
siteProvisioningKeyId
)
)
.limit(1);
if (!row?.siteProvisioningKeys || !row.siteProvisioningKeyOrg) {
return next(
createHttpError(
HttpCode.NOT_FOUND,
`Site provisioning key with ID ${siteProvisioningKeyId} not found for organization ${orgId}`
)
);
}
if (!req.apiKeyOrg) {
const apiKeyOrgRes = await db
.select()
.from(apiKeyOrg)
.where(
and(
eq(apiKeyOrg.apiKeyId, apiKey.apiKeyId),
eq(apiKeyOrg.orgId, row.siteProvisioningKeyOrg.orgId)
)
)
.limit(1);
req.apiKeyOrg = apiKeyOrgRes[0];
}
if (!req.apiKeyOrg) {
return next(
createHttpError(
HttpCode.FORBIDDEN,
"Key does not have access to this organization"
)
);
}
return next();
} catch (error) {
return next(
createHttpError(
HttpCode.INTERNAL_SERVER_ERROR,
"Error verifying site provisioning key access"
)
);
}
}
+1
View File
@@ -18,6 +18,7 @@ export enum OpenAPITags {
OrgIdp = "Identity Provider (Organization Only)", OrgIdp = "Identity Provider (Organization Only)",
Client = "Client", Client = "Client",
ApiKey = "API Key", ApiKey = "API Key",
SiteProvisioningKey = "Site Provisioning Key",
Domain = "Domain", Domain = "Domain",
Blueprint = "Blueprint", Blueprint = "Blueprint",
Ssh = "SSH", Ssh = "SSH",
@@ -55,9 +55,14 @@ export async function getHealthCheckStatusHistory(
const entityType = "health_check"; const entityType = "health_check";
const entityId = parsedParams.data.healthCheckId; const entityId = parsedParams.data.healthCheckId;
const { days } = parsedQuery.data; const { days, tzOffsetMinutes } = parsedQuery.data;
const data = await getCachedStatusHistory(entityType, entityId, days); const data = await getCachedStatusHistory(
entityType,
entityId,
days,
tzOffsetMinutes
);
return response<StatusHistoryResponse>(res, { return response<StatusHistoryResponse>(res, {
data, data,
+317 -2
View File
@@ -58,7 +58,8 @@ import {
resourceRules, resourceRules,
resourcePolicyRules, resourcePolicyRules,
userOrgRoles, userOrgRoles,
roles roles,
resourceAccessToken
} from "@server/db"; } from "@server/db";
import { eq, and, inArray, isNotNull, isNull, ne, or, sql } from "drizzle-orm"; import { eq, and, inArray, isNotNull, isNull, ne, or, sql } from "drizzle-orm";
import { alias } from "@server/db"; import { alias } from "@server/db";
@@ -81,11 +82,16 @@ import config from "@server/lib/config";
import { exchangeSession } from "@server/routers/badger"; import { exchangeSession } from "@server/routers/badger";
import { import {
ResourceSessionValidationResult, ResourceSessionValidationResult,
createResourceSession,
serializeResourceSessionCookie,
validateResourceSessionToken validateResourceSessionToken
} from "@server/auth/sessions/resource"; } from "@server/auth/sessions/resource";
import { checkExitNodeOrg, resolveExitNodes } from "#private/lib/exitNodes"; import { checkExitNodeOrg, resolveExitNodes } from "#private/lib/exitNodes";
import { maxmindLookup } from "@server/db/maxmind"; import { maxmindLookup } from "@server/db/maxmind";
import { verifyResourceAccessToken } from "@server/auth/verifyResourceAccessToken"; import { verifyResourceAccessToken } from "@server/auth/verifyResourceAccessToken";
import { generateSessionToken } from "@server/auth/sessions/app";
import { logAccessAudit } from "#private/lib/logAccessAudit";
import { getUserOrgRoles } from "@server/lib/userOrgRoles";
import semver from "semver"; import semver from "semver";
import { maxmindAsnLookup } from "@server/db/maxmindAsn"; import { maxmindAsnLookup } from "@server/db/maxmindAsn";
import { checkOrgAccessPolicy } from "@server/lib/checkOrgAccessPolicy"; import { checkOrgAccessPolicy } from "@server/lib/checkOrgAccessPolicy";
@@ -178,6 +184,73 @@ const validateResourceAccessTokenBodySchema = z.strictObject({
accessToken: z.string() accessToken: z.string()
}); });
const createAccessTokenSessionParamsSchema = z.strictObject({
resourceId: z.coerce.number().int().positive()
});
const createAccessTokenSessionBodySchema = z.strictObject({
accessTokenId: z.string().min(1)
});
const getAccessTokenParamsSchema = z.strictObject({
accessTokenId: z.string().min(1)
});
const logAccessAuditBodySchema = z.strictObject({
action: z.boolean(),
type: z.string(),
orgId: z.string(),
resourceId: z.number().optional(),
siteResourceId: z.number().optional(),
user: z
.object({
username: z.string(),
userId: z.string()
})
.optional(),
apiKey: z
.object({
name: z.string().nullable(),
apiKeyId: z.string()
})
.optional(),
metadata: z.any().optional(),
userAgent: z.string().optional(),
requestIp: z.string().optional()
});
type AccessTokenUserData = {
userId: string;
username: string;
email: string | null;
name: string | null;
role: string | null;
};
async function resolveAccessTokenUserData(
userId: string,
orgId: string
): Promise<AccessTokenUserData | undefined> {
const [user] = await db
.select()
.from(users)
.where(eq(users.userId, userId))
.limit(1);
if (!user) {
return undefined;
}
const userOrgRoles = await getUserOrgRoles(user.userId, orgId);
return {
userId: user.userId,
username: user.username,
email: user.email,
name: user.name,
role: userOrgRoles.map((r) => r.roleName).join(", ") || null
};
}
// Certificates by domains query validation // Certificates by domains query validation
const getCertificatesByDomainsQuerySchema = z.strictObject({ const getCertificatesByDomainsQuerySchema = z.strictObject({
// Accept domains as string or array (domains or domains[]) // Accept domains as string or array (domains or domains[])
@@ -1829,8 +1902,23 @@ hybridRouter.post(
resourceId resourceId
}); });
let userData: AccessTokenUserData | undefined;
if (
result.valid &&
result.tokenItem?.userId &&
result.tokenItem.orgId
) {
userData = await resolveAccessTokenUserData(
result.tokenItem.userId,
result.tokenItem.orgId
);
}
return response(res, { return response(res, {
data: result, data: {
...result,
userData
},
success: true, success: true,
error: false, error: false,
message: result.valid message: result.valid
@@ -1850,6 +1938,233 @@ hybridRouter.post(
} }
); );
// Create a resource session from a valid access token (for remote nodes)
hybridRouter.post(
"/resource/:resourceId/session/create-access-token",
async (req: Request, res: Response, next: NextFunction) => {
try {
const parsedParams = createAccessTokenSessionParamsSchema.safeParse(
req.params
);
if (!parsedParams.success) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
fromError(parsedParams.error).toString()
)
);
}
const parsedBody = createAccessTokenSessionBodySchema.safeParse(
req.body
);
if (!parsedBody.success) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
fromError(parsedBody.error).toString()
)
);
}
const { resourceId } = parsedParams.data;
const { accessTokenId } = parsedBody.data;
const [tokenItem] = await db
.select()
.from(resourceAccessToken)
.where(eq(resourceAccessToken.accessTokenId, accessTokenId))
.limit(1);
if (!tokenItem || tokenItem.resourceId !== resourceId) {
return next(
createHttpError(
HttpCode.NOT_FOUND,
"Access token not found"
)
);
}
if (!tokenItem.persistSession) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
"Access token does not allow session persistence"
)
);
}
const [resource] = await db
.select()
.from(resources)
.where(eq(resources.resourceId, resourceId))
.limit(1);
if (!resource || !resource.fullDomain) {
return next(
createHttpError(HttpCode.NOT_FOUND, "Resource not found")
);
}
const token = generateSessionToken();
const sess = await createResourceSession({
resourceId: resource.resourceId,
token,
accessTokenId: tokenItem.accessTokenId,
sessionLength: tokenItem.sessionLength,
expiresAt: tokenItem.expiresAt,
doNotExtend: tokenItem.expiresAt ? true : false
});
const cookieName = config.getRawConfig().server.session_cookie_name;
const cookie = serializeResourceSessionCookie(
cookieName,
resource.fullDomain,
token,
!resource.ssl,
new Date(sess.expiresAt)
);
return response(res, {
data: { cookie },
success: true,
error: false,
message: "Access token session created successfully",
status: HttpCode.OK
});
} catch (error) {
logger.error(error);
return next(
createHttpError(
HttpCode.INTERNAL_SERVER_ERROR,
"Failed to create access token session"
)
);
}
}
);
// Resolve access token metadata for remote nodes (cookie session path)
hybridRouter.get(
"/resource/access-token/:accessTokenId",
async (req: Request, res: Response, next: NextFunction) => {
try {
const parsedParams = getAccessTokenParamsSchema.safeParse(
req.params
);
if (!parsedParams.success) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
fromError(parsedParams.error).toString()
)
);
}
const { accessTokenId } = parsedParams.data;
const [tokenItem] = await db
.select()
.from(resourceAccessToken)
.where(eq(resourceAccessToken.accessTokenId, accessTokenId))
.limit(1);
if (!tokenItem) {
return next(
createHttpError(
HttpCode.NOT_FOUND,
"Access token not found"
)
);
}
let userData: AccessTokenUserData | undefined;
if (tokenItem.userId) {
userData = await resolveAccessTokenUserData(
tokenItem.userId,
tokenItem.orgId
);
}
return response(res, {
data: { tokenItem, userData },
success: true,
error: false,
message: "Access token retrieved successfully",
status: HttpCode.OK
});
} catch (error) {
logger.error(error);
return next(
createHttpError(
HttpCode.INTERNAL_SERVER_ERROR,
"Failed to get access token"
)
);
}
}
);
// Access audit log from remote nodes
hybridRouter.post(
"/logs/access",
async (req: Request, res: Response, next: NextFunction) => {
try {
const parsedBody = logAccessAuditBodySchema.safeParse(req.body);
if (!parsedBody.success) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
fromError(parsedBody.error).toString()
)
);
}
const remoteExitNode = req.remoteExitNode;
if (!remoteExitNode || !remoteExitNode.exitNodeId) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
"Remote exit node not found"
)
);
}
if (
await checkExitNodeOrg(
remoteExitNode.exitNodeId,
parsedBody.data.orgId
)
) {
return next(
createHttpError(
HttpCode.FORBIDDEN,
"Exit node not allowed for this organization"
)
);
}
await logAccessAudit(parsedBody.data);
return response(res, {
data: null,
success: true,
error: false,
message: "Access audit log saved successfully",
status: HttpCode.OK
});
} catch (error) {
logger.error(error);
return next(
createHttpError(
HttpCode.INTERNAL_SERVER_ERROR,
"Failed to save access audit log"
)
);
}
}
);
const geoIpLookupParamsSchema = z.object({ const geoIpLookupParamsSchema = z.object({
ip: z.union([z.ipv4(), z.ipv6()]) ip: z.union([z.ipv4(), z.ipv6()])
}); });
+44
View File
@@ -16,6 +16,7 @@ import * as org from "#private/routers/org";
import * as logs from "#private/routers/auditLogs"; import * as logs from "#private/routers/auditLogs";
import * as alertEvents from "#private/routers/alertEvents"; import * as alertEvents from "#private/routers/alertEvents";
import * as certificates from "#private/routers/certificates"; import * as certificates from "#private/routers/certificates";
import * as siteProvisioning from "#private/routers/siteProvisioning";
import { import {
verifyApiKeyHasAction, verifyApiKeyHasAction,
@@ -24,6 +25,7 @@ import {
verifyApiKeyIdpAccess, verifyApiKeyIdpAccess,
verifyApiKeyRoleAccess, verifyApiKeyRoleAccess,
verifyApiKeyUserAccess, verifyApiKeyUserAccess,
verifyApiKeySiteProvisioningKeyAccess,
verifyLimits verifyLimits
} from "@server/middlewares"; } from "@server/middlewares";
import * as user from "#private/routers/user"; import * as user from "#private/routers/user";
@@ -215,3 +217,45 @@ authenticated.delete(
logActionAudit(ActionsEnum.removeUserRole), logActionAudit(ActionsEnum.removeUserRole),
user.removeUserRole user.removeUserRole
); );
authenticated.put(
"/org/:orgId/site-provisioning-key",
verifyValidLicense,
verifyValidSubscription(tierMatrix.siteProvisioningKeys),
verifyApiKeyOrgAccess,
verifyLimits,
verifyApiKeyHasAction(ActionsEnum.createSiteProvisioningKey),
logActionAudit(ActionsEnum.createSiteProvisioningKey),
siteProvisioning.createSiteProvisioningKey
);
authenticated.get(
"/org/:orgId/site-provisioning-keys",
verifyValidLicense,
verifyValidSubscription(tierMatrix.siteProvisioningKeys),
verifyApiKeyOrgAccess,
verifyApiKeyHasAction(ActionsEnum.listSiteProvisioningKeys),
siteProvisioning.listSiteProvisioningKeys
);
authenticated.delete(
"/org/:orgId/site-provisioning-key/:siteProvisioningKeyId",
verifyValidLicense,
verifyValidSubscription(tierMatrix.siteProvisioningKeys),
verifyApiKeyOrgAccess,
verifyApiKeySiteProvisioningKeyAccess,
verifyApiKeyHasAction(ActionsEnum.deleteSiteProvisioningKey),
logActionAudit(ActionsEnum.deleteSiteProvisioningKey),
siteProvisioning.deleteSiteProvisioningKey
);
authenticated.patch(
"/org/:orgId/site-provisioning-key/:siteProvisioningKeyId",
verifyValidLicense,
verifyValidSubscription(tierMatrix.siteProvisioningKeys),
verifyApiKeyOrgAccess,
verifyApiKeySiteProvisioningKeyAccess,
verifyApiKeyHasAction(ActionsEnum.updateSiteProvisioningKey),
logActionAudit(ActionsEnum.updateSiteProvisioningKey),
siteProvisioning.updateSiteProvisioningKey
);
@@ -26,6 +26,8 @@ import {
import logger from "@server/logger"; import logger from "@server/logger";
import { hashPassword } from "@server/auth/password"; import { hashPassword } from "@server/auth/password";
import type { CreateSiteProvisioningKeyResponse } from "@server/routers/siteProvisioning/types"; import type { CreateSiteProvisioningKeyResponse } from "@server/routers/siteProvisioning/types";
import { createApiResponseSchema } from "@server/lib/openapi/createApiResponseSchema";
import { OpenAPITags, registry } from "@server/openApi";
const paramsSchema = z.object({ const paramsSchema = z.object({
orgId: z.string().nonempty() orgId: z.string().nonempty()
@@ -34,10 +36,17 @@ const paramsSchema = z.object({
const bodySchema = z const bodySchema = z
.strictObject({ .strictObject({
name: z.string().min(1).max(255), name: z.string().min(1).max(255),
maxBatchSize: z.union([ maxBatchSize: z
z.null(), .union([
z.coerce.number().int().positive().max(1_000_000) z.null(),
]), z.coerce.number().int().positive().max(1_000_000)
])
.openapi({
type: "number",
default: null,
description:
"Maximum number of sites that can be provisioned in a single batch. If null, there is no limit."
}),
validUntil: z.string().max(255).optional(), validUntil: z.string().max(255).optional(),
approveNewSites: z.boolean().optional().default(true) approveNewSites: z.boolean().optional().default(true)
}) })
@@ -57,6 +66,49 @@ const bodySchema = z
export type CreateSiteProvisioningKeyBody = z.infer<typeof bodySchema>; export type CreateSiteProvisioningKeyBody = z.infer<typeof bodySchema>;
const CreateSiteProvisioningKeyResponseDataSchema = z.object({
siteProvisioningKeyId: z.string(),
orgId: z.string(),
name: z.string(),
siteProvisioningKey: z.string(),
lastChars: z.string(),
createdAt: z.string(),
lastUsed: z.string().nullable(),
maxBatchSize: z.number().nullable(),
numUsed: z.number(),
validUntil: z.string().nullable(),
approveNewSites: z.boolean()
});
registry.registerPath({
method: "put",
path: "/org/{orgId}/site-provisioning-key",
description: "Create a new site provisioning key for the organization.",
tags: [OpenAPITags.SiteProvisioningKey],
request: {
params: paramsSchema,
body: {
content: {
"application/json": {
schema: bodySchema
}
}
}
},
responses: {
201: {
description: "Successful response",
content: {
"application/json": {
schema: createApiResponseSchema(
CreateSiteProvisioningKeyResponseDataSchema
)
}
}
}
}
});
export async function createSiteProvisioningKey( export async function createSiteProvisioningKey(
req: Request, req: Request,
res: Response, res: Response,
@@ -13,23 +13,46 @@
import { Request, Response, NextFunction } from "express"; import { Request, Response, NextFunction } from "express";
import { z } from "zod"; import { z } from "zod";
import { import { db, siteProvisioningKeyOrg, siteProvisioningKeys } from "@server/db";
db,
siteProvisioningKeyOrg,
siteProvisioningKeys
} from "@server/db";
import { and, eq } from "drizzle-orm"; import { and, eq } from "drizzle-orm";
import response from "@server/lib/response"; import response from "@server/lib/response";
import HttpCode from "@server/types/HttpCode"; import HttpCode from "@server/types/HttpCode";
import createHttpError from "http-errors"; import createHttpError from "http-errors";
import logger from "@server/logger"; import logger from "@server/logger";
import { fromError } from "zod-validation-error"; import { fromError } from "zod-validation-error";
import { OpenAPITags, registry } from "@server/openApi";
const paramsSchema = z.object({ const paramsSchema = z.object({
siteProvisioningKeyId: z.string().nonempty(), siteProvisioningKeyId: z.string().nonempty(),
orgId: z.string().nonempty() orgId: z.string().nonempty()
}); });
registry.registerPath({
method: "delete",
path: "/org/{orgId}/site-provisioning-key/{siteProvisioningKeyId}",
description: "Delete a site provisioning key.",
tags: [OpenAPITags.SiteProvisioningKey],
request: {
params: paramsSchema
},
responses: {
200: {
description: "Successful response",
content: {
"application/json": {
schema: z.object({
data: z.record(z.string(), z.any()).nullable(),
success: z.boolean(),
error: z.boolean(),
message: z.string(),
status: z.number()
})
}
}
}
}
});
export async function deleteSiteProvisioningKey( export async function deleteSiteProvisioningKey(
req: Request, req: Request,
res: Response, res: Response,
@@ -11,11 +11,7 @@
* This file is not licensed under the AGPLv3. * This file is not licensed under the AGPLv3.
*/ */
import { import { db, siteProvisioningKeyOrg, siteProvisioningKeys } from "@server/db";
db,
siteProvisioningKeyOrg,
siteProvisioningKeys
} from "@server/db";
import logger from "@server/logger"; import logger from "@server/logger";
import HttpCode from "@server/types/HttpCode"; import HttpCode from "@server/types/HttpCode";
import response from "@server/lib/response"; import response from "@server/lib/response";
@@ -25,6 +21,8 @@ import { z } from "zod";
import { fromError } from "zod-validation-error"; import { fromError } from "zod-validation-error";
import { eq } from "drizzle-orm"; import { eq } from "drizzle-orm";
import type { ListSiteProvisioningKeysResponse } from "@server/routers/siteProvisioning/types"; import type { ListSiteProvisioningKeysResponse } from "@server/routers/siteProvisioning/types";
import { createApiResponseSchema } from "@server/lib/openapi/createApiResponseSchema";
import { OpenAPITags, registry } from "@server/openApi";
const paramsSchema = z.object({ const paramsSchema = z.object({
orgId: z.string().nonempty() orgId: z.string().nonempty()
@@ -45,11 +43,55 @@ const querySchema = z.object({
.pipe(z.int().nonnegative()) .pipe(z.int().nonnegative())
}); });
const ListSiteProvisioningKeysResponseDataSchema = z.object({
siteProvisioningKeys: z.array(
z.object({
siteProvisioningKeyId: z.string(),
orgId: z.string(),
lastChars: z.string(),
createdAt: z.string(),
name: z.string(),
lastUsed: z.string().nullable(),
maxBatchSize: z.number().nullable(),
numUsed: z.number(),
validUntil: z.string().nullable(),
approveNewSites: z.boolean()
})
),
pagination: z.object({
total: z.number(),
limit: z.number(),
offset: z.number()
})
});
registry.registerPath({
method: "get",
path: "/org/{orgId}/site-provisioning-keys",
description: "List all site provisioning keys for an organization.",
tags: [OpenAPITags.SiteProvisioningKey],
request: {
params: paramsSchema,
query: querySchema
},
responses: {
200: {
description: "Successful response",
content: {
"application/json": {
schema: createApiResponseSchema(
ListSiteProvisioningKeysResponseDataSchema
)
}
}
}
}
});
function querySiteProvisioningKeys(orgId: string) { function querySiteProvisioningKeys(orgId: string) {
return db return db
.select({ .select({
siteProvisioningKeyId: siteProvisioningKeyId: siteProvisioningKeys.siteProvisioningKeyId,
siteProvisioningKeys.siteProvisioningKeyId,
orgId: siteProvisioningKeyOrg.orgId, orgId: siteProvisioningKeyOrg.orgId,
lastChars: siteProvisioningKeys.lastChars, lastChars: siteProvisioningKeys.lastChars,
createdAt: siteProvisioningKeys.createdAt, createdAt: siteProvisioningKeys.createdAt,
@@ -13,11 +13,7 @@
import { Request, Response, NextFunction } from "express"; import { Request, Response, NextFunction } from "express";
import { z } from "zod"; import { z } from "zod";
import { import { db, siteProvisioningKeyOrg, siteProvisioningKeys } from "@server/db";
db,
siteProvisioningKeyOrg,
siteProvisioningKeys
} from "@server/db";
import { and, eq } from "drizzle-orm"; import { and, eq } from "drizzle-orm";
import response from "@server/lib/response"; import response from "@server/lib/response";
import HttpCode from "@server/types/HttpCode"; import HttpCode from "@server/types/HttpCode";
@@ -25,6 +21,8 @@ import createHttpError from "http-errors";
import logger from "@server/logger"; import logger from "@server/logger";
import { fromError } from "zod-validation-error"; import { fromError } from "zod-validation-error";
import type { UpdateSiteProvisioningKeyResponse } from "@server/routers/siteProvisioning/types"; import type { UpdateSiteProvisioningKeyResponse } from "@server/routers/siteProvisioning/types";
import { createApiResponseSchema } from "@server/lib/openapi/createApiResponseSchema";
import { OpenAPITags, registry } from "@server/openApi";
const paramsSchema = z.object({ const paramsSchema = z.object({
siteProvisioningKeyId: z.string().nonempty(), siteProvisioningKeyId: z.string().nonempty(),
@@ -38,7 +36,13 @@ const bodySchema = z
z.null(), z.null(),
z.coerce.number().int().positive().max(1_000_000) z.coerce.number().int().positive().max(1_000_000)
]) ])
.optional(), .optional()
.openapi({
type: "number",
default: null,
description:
"Maximum number of sites that can be provisioned in a single batch. If null, there is no limit."
}),
validUntil: z.string().max(255).optional(), validUntil: z.string().max(255).optional(),
approveNewSites: z.boolean().optional() approveNewSites: z.boolean().optional()
}) })
@@ -50,7 +54,8 @@ const bodySchema = z
) { ) {
ctx.addIssue({ ctx.addIssue({
code: "custom", code: "custom",
message: "Provide maxBatchSize and/or validUntil and/or approveNewSites", message:
"Provide maxBatchSize and/or validUntil and/or approveNewSites",
path: ["maxBatchSize"] path: ["maxBatchSize"]
}); });
} }
@@ -69,6 +74,48 @@ const bodySchema = z
export type UpdateSiteProvisioningKeyBody = z.infer<typeof bodySchema>; export type UpdateSiteProvisioningKeyBody = z.infer<typeof bodySchema>;
const UpdateSiteProvisioningKeyResponseDataSchema = z.object({
siteProvisioningKeyId: z.string(),
orgId: z.string(),
name: z.string(),
lastChars: z.string(),
createdAt: z.string(),
lastUsed: z.string().nullable(),
maxBatchSize: z.number().nullable(),
numUsed: z.number(),
validUntil: z.string().nullable(),
approveNewSites: z.boolean()
});
registry.registerPath({
method: "patch",
path: "/org/{orgId}/site-provisioning-key/{siteProvisioningKeyId}",
description: "Update a site provisioning key.",
tags: [OpenAPITags.SiteProvisioningKey],
request: {
params: paramsSchema,
body: {
content: {
"application/json": {
schema: bodySchema
}
}
}
},
responses: {
200: {
description: "Successful response",
content: {
"application/json": {
schema: createApiResponseSchema(
UpdateSiteProvisioningKeyResponseDataSchema
)
}
}
}
}
});
export async function updateSiteProvisioningKey( export async function updateSiteProvisioningKey(
req: Request, req: Request,
res: Response, res: Response,
@@ -1,4 +1,3 @@
import { hash } from "@node-rs/argon2";
import { import {
generateId, generateId,
generateIdFromEntropySize, generateIdFromEntropySize,
@@ -8,18 +7,18 @@ import { db } from "@server/db";
import { import {
ResourceAccessToken, ResourceAccessToken,
resourceAccessToken, resourceAccessToken,
resources resources,
userOrgs
} from "@server/db"; } from "@server/db";
import HttpCode from "@server/types/HttpCode"; import HttpCode from "@server/types/HttpCode";
import response from "@server/lib/response"; import response from "@server/lib/response";
import { eq } from "drizzle-orm"; import { and, eq } from "drizzle-orm";
import { NextFunction, Request, Response } from "express"; import { NextFunction, Request, Response } from "express";
import createHttpError from "http-errors"; import createHttpError from "http-errors";
import { z } from "zod"; import { z } from "zod";
import { fromError } from "zod-validation-error"; import { fromError } from "zod-validation-error";
import logger from "@server/logger"; import logger from "@server/logger";
import { createDate, TimeSpan } from "oslo"; import { createDate, TimeSpan } from "oslo";
import { hashPassword } from "@server/auth/password";
import { encodeHexLowerCase } from "@oslojs/encoding"; import { encodeHexLowerCase } from "@oslojs/encoding";
import { sha256 } from "@oslojs/crypto/sha2"; import { sha256 } from "@oslojs/crypto/sha2";
import { OpenAPITags, registry } from "@server/openApi"; import { OpenAPITags, registry } from "@server/openApi";
@@ -28,7 +27,9 @@ export const generateAccessTokenBodySchema = z.strictObject({
validForSeconds: z.int().positive().optional(), // seconds validForSeconds: z.int().positive().optional(), // seconds
title: z.string().optional(), title: z.string().optional(),
path: z.string().optional(), path: z.string().optional(),
description: z.string().optional() description: z.string().optional(),
persistSession: z.boolean().optional().default(false),
userId: z.string().optional()
}); });
export const generateAccssTokenParamsSchema = z.strictObject({ export const generateAccssTokenParamsSchema = z.strictObject({
@@ -101,7 +102,14 @@ export async function generateAccessToken(
} }
const { resourceId } = parsedParams.data; const { resourceId } = parsedParams.data;
const { validForSeconds, title, path, description } = parsedBody.data; const {
validForSeconds,
title,
path,
description,
persistSession,
userId
} = parsedBody.data;
const [resource] = await db const [resource] = await db
.select() .select()
@@ -112,6 +120,28 @@ export async function generateAccessToken(
return next(createHttpError(HttpCode.NOT_FOUND, "Resource not found")); return next(createHttpError(HttpCode.NOT_FOUND, "Resource not found"));
} }
if (userId) {
const [membership] = await db
.select()
.from(userOrgs)
.where(
and(
eq(userOrgs.userId, userId),
eq(userOrgs.orgId, resource.orgId)
)
)
.limit(1);
if (!membership) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
"User is not a member of this organization"
)
);
}
}
try { try {
const sessionLength = validForSeconds const sessionLength = validForSeconds
? validForSeconds * 1000 ? validForSeconds * 1000
@@ -133,23 +163,27 @@ export async function generateAccessToken(
accessTokenId: id, accessTokenId: id,
orgId: resource.orgId, orgId: resource.orgId,
resourceId, resourceId,
userId: userId || null,
tokenHash, tokenHash,
expiresAt: expiresAt || null, expiresAt: expiresAt || null,
sessionLength: sessionLength, sessionLength: sessionLength,
title: title || null, title: title || null,
path: path || null, path: path || null,
description: description || null, description: description || null,
persistSession,
createdAt: new Date().getTime() createdAt: new Date().getTime()
}) })
.returning({ .returning({
accessTokenId: resourceAccessToken.accessTokenId, accessTokenId: resourceAccessToken.accessTokenId,
orgId: resourceAccessToken.orgId, orgId: resourceAccessToken.orgId,
resourceId: resourceAccessToken.resourceId, resourceId: resourceAccessToken.resourceId,
userId: resourceAccessToken.userId,
expiresAt: resourceAccessToken.expiresAt, expiresAt: resourceAccessToken.expiresAt,
sessionLength: resourceAccessToken.sessionLength, sessionLength: resourceAccessToken.sessionLength,
title: resourceAccessToken.title, title: resourceAccessToken.title,
path: resourceAccessToken.path, path: resourceAccessToken.path,
description: resourceAccessToken.description, description: resourceAccessToken.description,
persistSession: resourceAccessToken.persistSession,
createdAt: resourceAccessToken.createdAt createdAt: resourceAccessToken.createdAt
}) })
.execute(); .execute();
+10 -2
View File
@@ -6,12 +6,13 @@ import {
userResources, userResources,
roleResources, roleResources,
resourceAccessToken, resourceAccessToken,
sites sites,
users
} from "@server/db"; } from "@server/db";
import response from "@server/lib/response"; import response from "@server/lib/response";
import HttpCode from "@server/types/HttpCode"; import HttpCode from "@server/types/HttpCode";
import createHttpError from "http-errors"; import createHttpError from "http-errors";
import { sql, eq, or, inArray, and, count, isNull, lt, gt } from "drizzle-orm"; import { sql, eq, or, inArray, and, count, isNull, gt } from "drizzle-orm";
import logger from "@server/logger"; import logger from "@server/logger";
import stoi from "@server/lib/stoi"; import stoi from "@server/lib/stoi";
import { fromZodError } from "zod-validation-error"; import { fromZodError } from "zod-validation-error";
@@ -55,11 +56,16 @@ function queryAccessTokens(
accessTokenId: resourceAccessToken.accessTokenId, accessTokenId: resourceAccessToken.accessTokenId,
orgId: resourceAccessToken.orgId, orgId: resourceAccessToken.orgId,
resourceId: resourceAccessToken.resourceId, resourceId: resourceAccessToken.resourceId,
userId: resourceAccessToken.userId,
userName: users.name,
username: users.username,
userEmail: users.email,
sessionLength: resourceAccessToken.sessionLength, sessionLength: resourceAccessToken.sessionLength,
expiresAt: resourceAccessToken.expiresAt, expiresAt: resourceAccessToken.expiresAt,
tokenHash: resourceAccessToken.tokenHash, tokenHash: resourceAccessToken.tokenHash,
title: resourceAccessToken.title, title: resourceAccessToken.title,
description: resourceAccessToken.description, description: resourceAccessToken.description,
persistSession: resourceAccessToken.persistSession,
createdAt: resourceAccessToken.createdAt, createdAt: resourceAccessToken.createdAt,
resourceName: resources.name, resourceName: resources.name,
resourceNiceId: resources.niceId, resourceNiceId: resources.niceId,
@@ -75,6 +81,7 @@ function queryAccessTokens(
eq(resourceAccessToken.resourceId, resources.resourceId) eq(resourceAccessToken.resourceId, resources.resourceId)
) )
.leftJoin(sites, eq(resources.resourceId, sites.siteId)) .leftJoin(sites, eq(resources.resourceId, sites.siteId))
.leftJoin(users, eq(resourceAccessToken.userId, users.userId))
.where( .where(
and( and(
inArray( inArray(
@@ -97,6 +104,7 @@ function queryAccessTokens(
eq(resourceAccessToken.resourceId, resources.resourceId) eq(resourceAccessToken.resourceId, resources.resourceId)
) )
.leftJoin(sites, eq(resources.resourceId, sites.siteId)) .leftJoin(sites, eq(resources.resourceId, sites.siteId))
.leftJoin(users, eq(resourceAccessToken.userId, users.userId))
.where( .where(
and( and(
inArray( inArray(
+15 -10
View File
@@ -16,18 +16,26 @@ export async function logout(
next: NextFunction next: NextFunction
): Promise<any> { ): Promise<any> {
const { user, session } = await verifySession(req); const { user, session } = await verifySession(req);
const isSecure = req.protocol === "https";
// Always clear the session cookie so logout is idempotent, even when
// the session is already missing or invalid
res.setHeader("Set-Cookie", createBlankSessionTokenCookie(isSecure));
if (!user || !session) { if (!user || !session) {
if (config.getRawConfig().app.log_failed_attempts) { if (config.getRawConfig().app.log_failed_attempts) {
logger.info( logger.info(
`Log out failed because missing or invalid session. IP: ${req.ip}.` `Log out with missing or invalid session. IP: ${req.ip}.`
); );
} }
return next(
createHttpError( return response<null>(res, {
HttpCode.BAD_REQUEST, data: null,
"You must be logged in to sign out" success: true,
) error: false,
); message: "Logged out successfully",
status: HttpCode.OK
});
} }
try { try {
@@ -37,9 +45,6 @@ export async function logout(
logger.error("Failed to invalidate session", error); logger.error("Failed to invalidate session", error);
} }
const isSecure = req.protocol === "https";
res.setHeader("Set-Cookie", createBlankSessionTokenCookie(isSecure));
return response<null>(res, { return response<null>(res, {
data: null, data: null,
success: true, success: true,
+1 -1
View File
@@ -99,7 +99,7 @@ export async function requestPasswordReset(
}); });
}); });
const url = `${config.getRawConfig().app.dashboard_url}/auth/reset-password?email=${email}&token=${token}`; const url = `${config.getRawConfig().app.dashboard_url}/auth/reset-password?email=${encodeURIComponent(email)}&token=${token}`;
if (!config.getRawConfig().email) { if (!config.getRawConfig().email) {
logger.info( logger.info(
+273 -40
View File
@@ -1,4 +1,9 @@
import { validateResourceSessionToken } from "@server/auth/sessions/resource"; import {
createResourceSession,
serializeResourceSessionCookie,
validateResourceSessionToken
} from "@server/auth/sessions/resource";
import { generateSessionToken } from "@server/auth/sessions/app";
import { verifyResourceAccessToken } from "@server/auth/verifyResourceAccessToken"; import { verifyResourceAccessToken } from "@server/auth/verifyResourceAccessToken";
import { import {
getResourceByDomain, getResourceByDomain,
@@ -13,6 +18,7 @@ import {
LoginPage, LoginPage,
Org, Org,
Resource, Resource,
ResourceAccessToken,
ResourceHeaderAuth, ResourceHeaderAuth,
ResourceHeaderAuthExtendedCompatibility, ResourceHeaderAuthExtendedCompatibility,
ResourcePassword, ResourcePassword,
@@ -21,7 +27,10 @@ import {
ResourcePolicyPassword, ResourcePolicyPassword,
ResourcePolicyHeaderAuth, ResourcePolicyHeaderAuth,
ResourceRule, ResourceRule,
ResourceSession ResourceSession,
db,
resourceAccessToken,
users
} from "@server/db"; } from "@server/db";
import config from "@server/lib/config"; import config from "@server/lib/config";
import { isIpInCidr, stripPortFromHost } from "@server/lib/ip"; import { isIpInCidr, stripPortFromHost } from "@server/lib/ip";
@@ -41,11 +50,13 @@ import {
enforceResourceSessionLength enforceResourceSessionLength
} from "#dynamic/lib/checkOrgAccessPolicy"; } from "#dynamic/lib/checkOrgAccessPolicy";
import { logRequestAudit } from "./logRequestAudit"; import { logRequestAudit } from "./logRequestAudit";
import { logAccessAudit } from "#dynamic/lib/logAccessAudit";
import { REGIONS } from "@server/db/regions"; import { REGIONS } from "@server/db/regions";
import { localCache } from "#dynamic/lib/cache"; import { localCache } from "#dynamic/lib/cache";
import { APP_VERSION } from "@server/lib/consts"; import { APP_VERSION } from "@server/lib/consts";
import { isSubscribed } from "#dynamic/lib/isSubscribed"; import { isSubscribed } from "#dynamic/lib/isSubscribed";
import { tierMatrix } from "@server/lib/billing/tierMatrix"; import { tierMatrix } from "@server/lib/billing/tierMatrix";
import { eq } from "drizzle-orm";
const verifyResourceSessionSchema = z.object({ const verifyResourceSessionSchema = z.object({
sessions: z.record(z.string(), z.string()).optional(), sessions: z.record(z.string(), z.string()).optional(),
@@ -350,22 +361,15 @@ export async function verifyResourceSession(
} }
if (valid && tokenItem) { if (valid && tokenItem) {
logRequestAudit( return await allowAccessToken(
{ res,
action: true, resource,
reason: 102, // valid access token tokenItem,
resourceId: resource.resourceId, sessions,
orgId: resource.orgId, dontStripSession,
location: ipCC, parsedBody.data,
apiKey: { ipCC
name: tokenItem.title,
apiKeyId: tokenItem.accessTokenId
}
},
parsedBody.data
); );
return allowed(res, undefined, dontStripSession);
} }
} }
@@ -401,22 +405,15 @@ export async function verifyResourceSession(
} }
if (valid && tokenItem) { if (valid && tokenItem) {
logRequestAudit( return await allowAccessToken(
{ res,
action: true, resource,
reason: 102, // valid access token tokenItem,
resourceId: resource.resourceId, sessions,
orgId: resource.orgId, dontStripSession,
location: ipCC, parsedBody.data,
apiKey: { ipCC
name: tokenItem.title,
apiKeyId: tokenItem.accessTokenId
}
},
parsedBody.data
); );
return allowed(res, undefined, dontStripSession);
} }
} }
@@ -666,22 +663,37 @@ export async function verifyResourceSession(
"Resource allowed because access token session is valid" "Resource allowed because access token session is valid"
); );
logRequestAudit( const [tokenItem] = await db
.select()
.from(resourceAccessToken)
.where(
eq(
resourceAccessToken.accessTokenId,
resourceSession.accessTokenId
)
)
.limit(1);
const userData = tokenItem
? await getAccessTokenUserData(
tokenItem,
resource.orgId
)
: undefined;
logAccessTokenRequestAudit(
{ {
action: true,
reason: 102, // valid access token
resourceId: resource.resourceId, resourceId: resource.resourceId,
orgId: resource.orgId, orgId: resource.orgId,
location: ipCC, location: ipCC,
apiKey: { accessTokenId: resourceSession.accessTokenId,
name: null, tokenTitle: tokenItem?.title ?? null,
apiKeyId: resourceSession.accessTokenId userData
}
}, },
parsedBody.data parsedBody.data
); );
return allowed(res, undefined, dontStripSession); return allowed(res, userData, dontStripSession);
} }
if (resourceSession.userSessionId && sso) { if (resourceSession.userSessionId && sso) {
@@ -892,6 +904,227 @@ function allowed(
return response<VerifyUserResponse>(res, data); return response<VerifyUserResponse>(res, data);
} }
async function allowAccessToken(
res: Response,
resource: Resource,
tokenItem: ResourceAccessToken,
sessions: Record<string, string> | undefined,
dontStripSession: boolean | undefined,
auditBody: VerifyResourceSessionSchema,
location?: string
) {
const userData = await getAccessTokenUserData(tokenItem, resource.orgId);
logAccessTokenRequestAudit(
{
resourceId: resource.resourceId,
orgId: resource.orgId,
location,
accessTokenId: tokenItem.accessTokenId,
tokenTitle: tokenItem.title,
userData
},
auditBody
);
if (!tokenItem.persistSession) {
logAccessTokenAccessAudit(tokenItem, resource, userData, auditBody);
return allowed(res, userData, dontStripSession);
}
const resourceSessionToken = extractResourceSessionToken(
sessions ?? {},
resource.ssl
);
if (resourceSessionToken) {
const sessionCacheKey = `session:${resourceSessionToken}`;
let resourceSession: ResourceSession | null | undefined =
localCache.get(sessionCacheKey);
if (!resourceSession) {
const result = await validateResourceSessionToken(
resourceSessionToken,
resource.resourceId
);
resourceSession = result?.resourceSession;
localCache.set(sessionCacheKey, resourceSession, 5);
}
if (
resourceSession &&
!resourceSession.isRequestToken &&
resourceSession.accessTokenId === tokenItem.accessTokenId
) {
logger.debug(
"Resource allowed because existing access token session is valid"
);
return allowed(res, userData, dontStripSession);
}
}
logAccessTokenAccessAudit(tokenItem, resource, userData, auditBody);
return await createAccessTokenSession(res, resource, tokenItem, userData);
}
async function createAccessTokenSession(
res: Response,
resource: Resource,
tokenItem: ResourceAccessToken,
userData?: BasicUserData
) {
const token = generateSessionToken();
const sess = await createResourceSession({
resourceId: resource.resourceId,
token,
accessTokenId: tokenItem.accessTokenId,
sessionLength: tokenItem.sessionLength,
expiresAt: tokenItem.expiresAt,
doNotExtend: tokenItem.expiresAt ? true : false
});
const cookieName = config.getRawConfig().server.session_cookie_name;
const cookie = serializeResourceSessionCookie(
cookieName,
resource.fullDomain!,
token,
!resource.ssl,
new Date(sess.expiresAt)
);
res.appendHeader("Set-Cookie", cookie);
logger.debug("Access token is valid, creating new session");
return allowed(res, userData);
}
async function getAccessTokenUserData(
tokenItem: ResourceAccessToken,
orgId: string
): Promise<BasicUserData | undefined> {
if (!tokenItem.userId) {
return undefined;
}
const cacheKey = `accessTokenUser:${tokenItem.userId}:${orgId}`;
const cached = localCache.get(cacheKey) as BasicUserData | null | undefined;
if (cached !== undefined) {
return cached ?? undefined;
}
const [user] = await db
.select()
.from(users)
.where(eq(users.userId, tokenItem.userId))
.limit(1);
if (!user) {
localCache.set(cacheKey, null, 5);
return undefined;
}
const userOrgRoles = await getUserOrgRoles(user.userId, orgId);
const userData: BasicUserData = {
userId: user.userId,
username: user.username,
email: user.email,
name: user.name,
role: userOrgRoles.map((r) => r.roleName).join(", ") || null
};
localCache.set(cacheKey, userData, 12);
return userData;
}
function logAccessTokenRequestAudit(
data: {
resourceId: number;
orgId: string;
location?: string;
accessTokenId: string;
tokenTitle: string | null;
userData?: BasicUserData;
},
body: VerifyResourceSessionSchema
) {
if (data.userData) {
logRequestAudit(
{
action: true,
reason: 102, // valid access token
resourceId: data.resourceId,
orgId: data.orgId,
location: data.location,
user: {
username: data.userData.username,
userId: data.userData.userId
},
metadata: {
accessTokenId: data.accessTokenId,
accessTokenTitle: data.tokenTitle
}
},
body
);
return;
}
logRequestAudit(
{
action: true,
reason: 102, // valid access token
resourceId: data.resourceId,
orgId: data.orgId,
location: data.location,
apiKey: {
name: data.tokenTitle,
apiKeyId: data.accessTokenId
}
},
body
);
}
function logAccessTokenAccessAudit(
tokenItem: ResourceAccessToken,
resource: Resource,
userData: BasicUserData | undefined,
body: VerifyResourceSessionSchema
) {
const userAgent =
body.headers?.["user-agent"] || body.headers?.["User-Agent"];
if (userData) {
logAccessAudit({
orgId: resource.orgId,
resourceId: resource.resourceId,
action: true,
type: "accessToken",
user: {
username: userData.username,
userId: userData.userId
},
metadata: {
accessTokenId: tokenItem.accessTokenId,
accessTokenTitle: tokenItem.title
},
userAgent,
requestIp: body.requestIp
});
return;
}
logAccessAudit({
orgId: resource.orgId,
resourceId: resource.resourceId,
action: true,
type: "accessToken",
apiKey: {
name: tokenItem.title,
apiKeyId: tokenItem.accessTokenId
},
userAgent,
requestIp: body.requestIp
});
}
async function headerAuthChallenged( async function headerAuthChallenged(
res: Response, res: Response,
redirectPath?: string, redirectPath?: string,
-10
View File
@@ -809,16 +809,6 @@ authenticated.post(
accessToken.generateAccessToken accessToken.generateAccessToken
); );
authenticated.post(
`/resource/:resourceId/session-token`,
verifyApiKeyResourceAccess,
verifyApiKeyUserAccess,
verifyLimits,
verifyApiKeyHasAction(ActionsEnum.createResourceSessionToken),
logActionAudit(ActionsEnum.createResourceSessionToken),
resource.createResourceSessionToken
);
authenticated.delete( authenticated.delete(
`/access-token/:accessTokenId`, `/access-token/:accessTokenId`,
verifyApiKeyAccessTokenAccess, verifyApiKeyAccessTokenAccess,
@@ -29,7 +29,7 @@ export const handleNewtGetConfigMessage: MessageHandler = async (context) => {
return; return;
} }
const { publicKey, port, chainId } = message.data; const { publicKey, port, localEndpoints, chainId } = message.data;
const siteId = newt.siteId; const siteId = newt.siteId;
// Get the current site data // Get the current site data
@@ -69,7 +69,10 @@ export const handleNewtGetConfigMessage: MessageHandler = async (context) => {
.update(sites) .update(sites)
.set({ .set({
publicKey, publicKey,
listenPort: port listenPort: port,
localEndpoints: localEndpoints
? JSON.stringify(localEndpoints)
: null
}) })
.where(eq(sites.siteId, siteId)) .where(eq(sites.siteId, siteId))
.returning(); .returning();
+4
View File
@@ -30,6 +30,7 @@ export async function buildSiteConfigurationForOlmClient(
siteId: number; siteId: number;
name?: string; name?: string;
endpoint?: string; endpoint?: string;
localEndpoints?: string[];
publicKey?: string; publicKey?: string;
serverIP?: string | null; serverIP?: string | null;
serverPort?: number | null; serverPort?: number | null;
@@ -206,6 +207,9 @@ export async function buildSiteConfigurationForOlmClient(
name: site.name, name: site.name,
// relayEndpoint: relayEndpoint, // this can be undefined now if not relayed // lets not do this for now because it would conflict with the hole punch testing // relayEndpoint: relayEndpoint, // this can be undefined now if not relayed // lets not do this for now because it would conflict with the hole punch testing
endpoint: site.endpoint, endpoint: site.endpoint,
localEndpoints: site.localEndpoints
? JSON.parse(site.localEndpoints)
: undefined,
publicKey: site.publicKey, publicKey: site.publicKey,
serverIP: site.address, serverIP: site.address,
serverPort: site.listenPort, serverPort: site.listenPort,
@@ -0,0 +1,74 @@
import { db, sites } from "@server/db";
import { MessageHandler } from "@server/routers/ws";
import { clients, Olm } from "@server/db";
import { and, eq } from "drizzle-orm";
import { updatePeer as newtUpdatePeer } from "../newt/peers";
import logger from "@server/logger";
export const handleOlmLocalMessage: MessageHandler = async (context) => {
const { message, client: c, sendToClient } = context;
const olm = c as Olm;
logger.info("Handling local olm message!");
if (!olm) {
logger.warn("Olm not found");
return;
}
if (!olm.clientId) {
logger.warn("Olm has no client!");
return;
}
const clientId = olm.clientId;
const [client] = await db
.select()
.from(clients)
.where(eq(clients.clientId, clientId))
.limit(1);
if (!client) {
logger.warn("Client not found");
return;
}
// make sure we hand endpoints for both the site and the client and the lastHolePunch is not too old
if (!client.pubKey) {
logger.warn("Client has no endpoint or listen port");
return;
}
const { siteId, chainId } = message.data;
// Get the site
const [site] = await db
.select()
.from(sites)
.where(eq(sites.siteId, siteId))
.limit(1);
if (!site || !site.exitNodeId) {
logger.warn("Site not found or has no exit node");
return;
}
// update the peer on the newt
await newtUpdatePeer(siteId, client.pubKey, {
endpoint: "" // this removes the endpoint so the newt knows to accept local
});
// Just ack the message, we don't keep sending it
return {
message: {
type: "olm/wg/peer/local",
data: {
siteId: siteId,
chainId
}
},
broadcast: false,
excludeSender: false
};
};
+2 -2
View File
@@ -79,9 +79,9 @@ export const handleOlmRelayMessage: MessageHandler = async (context) => {
) )
); );
// update the peer on the exit node // update the peer on the newt
await newtUpdatePeer(siteId, client.pubKey, { await newtUpdatePeer(siteId, client.pubKey, {
endpoint: "" // this removes the endpoint so the exit node knows to relay endpoint: "" // this removes the endpoint so the newt knows to relay
}); });
return { return {
@@ -3,24 +3,15 @@ import {
db, db,
networks, networks,
siteNetworks, siteNetworks,
siteResources, siteResources
} from "@server/db"; } from "@server/db";
import { MessageHandler } from "@server/routers/ws"; import { MessageHandler } from "@server/routers/ws";
import { import { clients, clientSitesAssociationsCache, Olm, sites } from "@server/db";
clients,
clientSitesAssociationsCache,
Olm,
sites
} from "@server/db";
import { and, eq, inArray, isNotNull, isNull } from "drizzle-orm"; import { and, eq, inArray, isNotNull, isNull } from "drizzle-orm";
import logger from "@server/logger"; import logger from "@server/logger";
import { import { generateAliasConfig } from "@server/lib/ip";
generateAliasConfig,
} from "@server/lib/ip";
import { generateRemoteSubnets } from "@server/lib/ip"; import { generateRemoteSubnets } from "@server/lib/ip";
import { import { addPeer as newtAddPeer } from "@server/routers/newt/peers";
addPeer as newtAddPeer,
} from "@server/routers/newt/peers";
export const handleOlmServerPeerAddMessage: MessageHandler = async ( export const handleOlmServerPeerAddMessage: MessageHandler = async (
context context
@@ -135,10 +126,7 @@ export const handleOlmServerPeerAddMessage: MessageHandler = async (
clientSiteResourcesAssociationsCache.siteResourceId clientSiteResourcesAssociationsCache.siteResourceId
) )
) )
.innerJoin( .innerJoin(networks, eq(siteResources.networkId, networks.networkId))
networks,
eq(siteResources.networkId, networks.networkId)
)
.innerJoin( .innerJoin(
siteNetworks, siteNetworks,
and( and(
@@ -147,10 +135,7 @@ export const handleOlmServerPeerAddMessage: MessageHandler = async (
) )
) )
.where( .where(
eq( eq(clientSiteResourcesAssociationsCache.clientId, client.clientId)
clientSiteResourcesAssociationsCache.clientId,
client.clientId
)
); );
// Return connect message with all site configurations // Return connect message with all site configurations
@@ -161,6 +146,9 @@ export const handleOlmServerPeerAddMessage: MessageHandler = async (
siteId: site.siteId, siteId: site.siteId,
name: site.name, name: site.name,
endpoint: site.endpoint, endpoint: site.endpoint,
localEndpoints: site.localEndpoints
? JSON.parse(site.localEndpoints)
: undefined,
publicKey: site.publicKey, publicKey: site.publicKey,
serverIP: site.address, serverIP: site.address,
serverPort: site.listenPort, serverPort: site.listenPort,
@@ -170,7 +158,7 @@ export const handleOlmServerPeerAddMessage: MessageHandler = async (
aliases: generateAliasConfig( aliases: generateAliasConfig(
allSiteResources.map(({ siteResources }) => siteResources) allSiteResources.map(({ siteResources }) => siteResources)
), ),
chainId: chainId, chainId: chainId
} }
}, },
broadcast: false, broadcast: false,
@@ -0,0 +1,95 @@
import { db, exitNodes, sites } from "@server/db";
import { MessageHandler } from "@server/routers/ws";
import { clients, clientSitesAssociationsCache, Olm } from "@server/db";
import { and, eq } from "drizzle-orm";
import { updatePeer as newtUpdatePeer } from "../newt/peers";
import logger from "@server/logger";
export const handleOlmUnLocalMessage: MessageHandler = async (context) => {
const { message, client: c, sendToClient } = context;
const olm = c as Olm;
logger.info("Handling unlocal olm message!");
if (!olm) {
logger.warn("Olm not found");
return;
}
if (!olm.clientId) {
logger.warn("Olm has no client!");
return;
}
const clientId = olm.clientId;
const [client] = await db
.select()
.from(clients)
.where(eq(clients.clientId, clientId))
.limit(1);
if (!client) {
logger.warn("Client not found");
return;
}
// make sure we hand endpoints for both the site and the client and the lastHolePunch is not too old
if (!client.pubKey) {
logger.warn("Client has no endpoint or listen port");
return;
}
const { siteId, chainId } = message.data;
// Get the site
const [site] = await db
.select()
.from(sites)
.where(eq(sites.siteId, siteId))
.limit(1);
if (!site) {
logger.warn("Site not found or has no exit node");
return;
}
const [clientSiteAssociation] = await db
.select()
.from(clientSitesAssociationsCache)
.where(
and(
eq(clientSitesAssociationsCache.clientId, olm.clientId),
eq(clientSitesAssociationsCache.siteId, siteId)
)
);
if (!clientSiteAssociation) {
logger.warn("Client-Site association not found");
return;
}
if (!clientSiteAssociation.endpoint) {
logger.warn("Client-Site association has no endpoint, cannot unrelay");
return;
}
// update the peer on the newt
await newtUpdatePeer(siteId, client.pubKey, {
endpoint: clientSiteAssociation.isRelayed
? ""
: clientSiteAssociation.endpoint // this is the endpoint of the client to connect directly to the newt
});
return {
message: {
type: "olm/wg/peer/unlocal",
data: {
siteId: siteId,
chainId
}
},
broadcast: false,
excludeSender: false
};
};
@@ -77,9 +77,9 @@ export const handleOlmUnRelayMessage: MessageHandler = async (context) => {
return; return;
} }
// update the peer on the exit node // update the peer on the newt
await newtUpdatePeer(siteId, client.pubKey, { await newtUpdatePeer(siteId, client.pubKey, {
endpoint: clientSiteAssociation.endpoint // this is the endpoint of the client to connect directly to the exit node endpoint: clientSiteAssociation.endpoint // this is the endpoint of the client to connect directly to the newt
}); });
return { return {
+2
View File
@@ -13,3 +13,5 @@ export * from "./recoverOlmWithFingerprint";
export * from "./handleOlmDisconnectingMessage"; export * from "./handleOlmDisconnectingMessage";
export * from "./handleOlmServerInitAddPeerHandshake"; export * from "./handleOlmServerInitAddPeerHandshake";
export * from "./offlineChecker"; export * from "./offlineChecker";
export * from "./handleOlmUnLocalMessage";
export * from "./handleOlmLocalMessage";
+2
View File
@@ -18,6 +18,7 @@ export async function addPeer(
serverPort: number | null; serverPort: number | null;
remoteSubnets: string[] | null; // optional, comma-separated list of subnets that this site can access remoteSubnets: string[] | null; // optional, comma-separated list of subnets that this site can access
aliases: Alias[]; aliases: Alias[];
localEndpoints?: string[]; // optional, list of local endpoints for the peer
}, },
olmId?: string, olmId?: string,
version?: string | null version?: string | null
@@ -44,6 +45,7 @@ export async function addPeer(
name: peer.name, name: peer.name,
publicKey: peer.publicKey, publicKey: peer.publicKey,
endpoint: peer.endpoint, endpoint: peer.endpoint,
localEndpoints: peer.localEndpoints,
relayEndpoint: peer.relayEndpoint, relayEndpoint: peer.relayEndpoint,
serverIP: peer.serverIP, serverIP: peer.serverIP,
serverPort: peer.serverPort, serverPort: peer.serverPort,
+32 -1
View File
@@ -1,6 +1,6 @@
import { generateSessionToken } from "@server/auth/sessions/app"; import { generateSessionToken } from "@server/auth/sessions/app";
import { db } from "@server/db"; import { db } from "@server/db";
import { Resource, resources } from "@server/db"; import { Resource, resources, users } from "@server/db";
import HttpCode from "@server/types/HttpCode"; import HttpCode from "@server/types/HttpCode";
import response from "@server/lib/response"; import response from "@server/lib/response";
import { eq } from "drizzle-orm"; import { eq } from "drizzle-orm";
@@ -156,11 +156,42 @@ export async function authWithAccessToken(
doNotExtend: true doNotExtend: true
}); });
let accessAuditUser: { username: string; userId: string } | undefined;
if (tokenItem.userId) {
const [associatedUser] = await db
.select({
userId: users.userId,
username: users.username
})
.from(users)
.where(eq(users.userId, tokenItem.userId))
.limit(1);
if (associatedUser) {
accessAuditUser = {
userId: associatedUser.userId,
username: associatedUser.username
};
}
}
logAccessAudit({ logAccessAudit({
orgId: resource.orgId, orgId: resource.orgId,
resourceId: resource.resourceId, resourceId: resource.resourceId,
action: true, action: true,
type: "accessToken", type: "accessToken",
apiKey: accessAuditUser
? undefined
: {
name: tokenItem.title,
apiKeyId: tokenItem.accessTokenId
},
user: accessAuditUser,
metadata: accessAuditUser
? {
accessTokenId: tokenItem.accessTokenId,
accessTokenTitle: tokenItem.title
}
: undefined,
userAgent: req.headers["user-agent"], userAgent: req.headers["user-agent"],
requestIp: req.ip requestIp: req.ip
}); });
@@ -1,133 +0,0 @@
import { Request, Response, NextFunction } from "express";
import { z } from "zod";
import { db } from "@server/db";
import { resources, users, userOrgs } from "@server/db";
import { eq, and } from "drizzle-orm";
import { createResourceSession } from "@server/auth/sessions/resource";
import HttpCode from "@server/types/HttpCode";
import createHttpError from "http-errors";
import { fromError } from "zod-validation-error";
import logger from "@server/logger";
import { createSession, generateSessionToken } from "@server/auth/sessions/app";
import { response } from "@server/lib/response";
const createResourceSessionTokenParams = z.strictObject({
resourceId: z.coerce.number().int().positive()
});
const createResourceSessionTokenBody = z.strictObject({
userId: z.string().nonempty(),
idpId: z.coerce.number().int().positive().optional()
});
export type CreateResourceSessionTokenResponse = {
requestToken: string;
};
export async function createResourceSessionToken(
req: Request,
res: Response,
next: NextFunction
): Promise<any> {
try {
const parsedParams = createResourceSessionTokenParams.safeParse(
req.params
);
if (!parsedParams.success) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
fromError(parsedParams.error).toString()
)
);
}
const parsedBody = createResourceSessionTokenBody.safeParse(req.body);
if (!parsedBody.success) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
fromError(parsedBody.error).toString()
)
);
}
const { resourceId } = parsedParams.data;
const { userId, idpId } = parsedBody.data;
const [resource] = await db
.select()
.from(resources)
.where(eq(resources.resourceId, resourceId))
.limit(1);
if (!resource) {
return next(
createHttpError(
HttpCode.NOT_FOUND,
`Resource with ID ${resourceId} not found`
)
);
}
const candidates = await db
.select({ userId: users.userId })
.from(userOrgs)
.innerJoin(users, eq(userOrgs.userId, users.userId))
.where(
and(
eq(users.userId, userId),
eq(userOrgs.orgId, resource.orgId)
)
);
if (candidates.length === 0) {
return next(
createHttpError(
HttpCode.NOT_FOUND,
`User not found in the organization that owns this resource`
)
);
}
if (candidates.length > 1) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
"Multiple users match this username (external users from different identity providers). Specify idpId to disambiguate."
)
);
}
const targetUserId = candidates[0].userId;
const appSessionToken = generateSessionToken();
const appSession = await createSession(appSessionToken, targetUserId);
const requestToken = generateSessionToken();
await createResourceSession({
resourceId,
token: requestToken,
userSessionId: appSession.sessionId,
isRequestToken: true,
expiresAt: Date.now() + 1000 * 30, // 30 seconds
sessionLength: 1000 * 30,
doNotExtend: true
});
logger.debug("Resource session token created successfully");
return response<CreateResourceSessionTokenResponse>(res, {
data: { requestToken },
success: true,
error: false,
message: "Resource session token created successfully",
status: HttpCode.OK
});
} catch (error) {
logger.error(error);
return next(
createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred")
);
}
}
+7 -2
View File
@@ -42,9 +42,14 @@ export async function getResourceStatusHistory(
const entityType = "resource"; const entityType = "resource";
const entityId = parsedParams.data.resourceId; const entityId = parsedParams.data.resourceId;
const { days } = parsedQuery.data; const { days, tzOffsetMinutes } = parsedQuery.data;
const data = await getCachedStatusHistory(entityType, entityId, days); const data = await getCachedStatusHistory(
entityType,
entityId,
days,
tzOffsetMinutes
);
return response<StatusHistoryResponse>(res, { return response<StatusHistoryResponse>(res, {
data, data,
-1
View File
@@ -17,7 +17,6 @@ export * from "./getResourceWhitelist";
export * from "./authWithWhitelist"; export * from "./authWithWhitelist";
export * from "./authWithAccessToken"; export * from "./authWithAccessToken";
export * from "./getExchangeToken"; export * from "./getExchangeToken";
export * from "./createResourceSessionToken";
export * from "./createResourceRule"; export * from "./createResourceRule";
export * from "./deleteResourceRule"; export * from "./deleteResourceRule";
export * from "./listResourceRules"; export * from "./listResourceRules";
+7 -2
View File
@@ -42,9 +42,14 @@ export async function getSiteStatusHistory(
const entityType = "site"; const entityType = "site";
const entityId = parsedParams.data.siteId; const entityId = parsedParams.data.siteId;
const { days } = parsedQuery.data; const { days, tzOffsetMinutes } = parsedQuery.data;
const data = await getCachedStatusHistory(entityType, entityId, days); const data = await getCachedStatusHistory(
entityType,
entityId,
days,
tzOffsetMinutes
);
return response<StatusHistoryResponse>(res, { return response<StatusHistoryResponse>(res, {
data, data,
@@ -94,7 +94,7 @@ export async function adminGeneratePasswordResetCode(
}); });
}); });
const url = `${config.getRawConfig().app.dashboard_url}/auth/reset-password?email=${existingUser[0].email}&token=${token}`; const url = `${config.getRawConfig().app.dashboard_url}/auth/reset-password?email=${encodeURIComponent(existingUser[0].email!)}&token=${token}`;
logger.info( logger.info(
`Admin generated password reset code for user ${existingUser[0].email} (${userId})` `Admin generated password reset code for user ${existingUser[0].email} (${userId})`
+2 -2
View File
@@ -287,7 +287,7 @@ export async function inviteUser(
) )
); );
const inviteLink = `${config.getRawConfig().app.dashboard_url}/invite?token=${inviteId}-${token}&email=${email}`; const inviteLink = `${config.getRawConfig().app.dashboard_url}/invite?token=${inviteId}-${token}&email=${encodeURIComponent(email)}`;
if (doEmail) { if (doEmail) {
await sendEmail( await sendEmail(
@@ -341,7 +341,7 @@ export async function inviteUser(
.values(uniqueRoleIds.map((roleId) => ({ inviteId, roleId }))); .values(uniqueRoleIds.map((roleId) => ({ inviteId, roleId })));
}); });
const inviteLink = `${config.getRawConfig().app.dashboard_url}/invite?token=${inviteId}-${token}&email=${email}`; const inviteLink = `${config.getRawConfig().app.dashboard_url}/invite?token=${inviteId}-${token}&email=${encodeURIComponent(email)}`;
if (doEmail) { if (doEmail) {
await sendEmail( await sendEmail(
+5 -1
View File
@@ -20,7 +20,9 @@ import {
handleOlmServerPeerAddMessage, handleOlmServerPeerAddMessage,
handleOlmUnRelayMessage, handleOlmUnRelayMessage,
handleOlmDisconnectingMessage, handleOlmDisconnectingMessage,
handleOlmServerInitAddPeerHandshake handleOlmServerInitAddPeerHandshake,
handleOlmLocalMessage,
handleOlmUnLocalMessage
} from "../olm"; } from "../olm";
import { handleHealthcheckStatusMessage } from "../target"; import { handleHealthcheckStatusMessage } from "../target";
import { handleRoundTripMessage } from "./handleRoundTripMessage"; import { handleRoundTripMessage } from "./handleRoundTripMessage";
@@ -32,6 +34,8 @@ export const messageHandlers: Record<string, MessageHandler> = {
"olm/wg/register": handleOlmRegisterMessage, "olm/wg/register": handleOlmRegisterMessage,
"olm/wg/relay": handleOlmRelayMessage, "olm/wg/relay": handleOlmRelayMessage,
"olm/wg/unrelay": handleOlmUnRelayMessage, "olm/wg/unrelay": handleOlmUnRelayMessage,
"olm/wg/local": handleOlmLocalMessage,
"olm/wg/unlocal": handleOlmUnLocalMessage,
"olm/ping": handleOlmPingMessage, "olm/ping": handleOlmPingMessage,
"olm/disconnecting": handleOlmDisconnectingMessage, "olm/disconnecting": handleOlmDisconnectingMessage,
"newt/disconnecting": handleNewtDisconnectingMessage, "newt/disconnecting": handleNewtDisconnectingMessage,
+3 -1
View File
@@ -27,6 +27,7 @@ import m18 from "./scriptsPg/1.18.3";
import m19 from "./scriptsPg/1.18.4"; import m19 from "./scriptsPg/1.18.4";
import m20 from "./scriptsPg/1.19.0"; import m20 from "./scriptsPg/1.19.0";
import m21 from "./scriptsPg/1.20.0"; import m21 from "./scriptsPg/1.20.0";
import m22 from "./scriptsPg/1.21.0";
// THIS CANNOT IMPORT ANYTHING FROM THE SERVER // THIS CANNOT IMPORT ANYTHING FROM THE SERVER
// EXCEPT FOR THE DATABASE AND THE SCHEMA // EXCEPT FOR THE DATABASE AND THE SCHEMA
@@ -53,7 +54,8 @@ const migrations = [
{ version: "1.18.3", run: m18 }, { version: "1.18.3", run: m18 },
{ version: "1.18.4", run: m19 }, { version: "1.18.4", run: m19 },
{ version: "1.19.0", run: m20 }, { version: "1.19.0", run: m20 },
{ version: "1.20.0", run: m21 } { version: "1.20.0", run: m21 },
{ version: "1.21.0", run: m22 }
// Add new migrations here as they are created // Add new migrations here as they are created
] as { ] as {
version: string; version: string;
+3 -1
View File
@@ -46,6 +46,7 @@ import m40 from "./scriptsSqlite/1.18.4";
import m41 from "./scriptsSqlite/1.19.0"; import m41 from "./scriptsSqlite/1.19.0";
import m42 from "./scriptsSqlite/1.19.1"; import m42 from "./scriptsSqlite/1.19.1";
import m43 from "./scriptsSqlite/1.20.0"; import m43 from "./scriptsSqlite/1.20.0";
import m44 from "./scriptsSqlite/1.21.0";
// THIS CANNOT IMPORT ANYTHING FROM THE SERVER // THIS CANNOT IMPORT ANYTHING FROM THE SERVER
// EXCEPT FOR THE DATABASE AND THE SCHEMA // EXCEPT FOR THE DATABASE AND THE SCHEMA
@@ -89,7 +90,8 @@ const migrations = [
{ version: "1.18.4", run: m40 }, { version: "1.18.4", run: m40 },
{ version: "1.19.0", run: m41 }, { version: "1.19.0", run: m41 },
{ version: "1.19.1", run: m42 }, { version: "1.19.1", run: m42 },
{ version: "1.20.0", run: m43 } { version: "1.20.0", run: m43 },
{ version: "1.21.0", run: m44 }
// Add new migrations here as they are created // Add new migrations here as they are created
] as const; ] as const;
+95
View File
@@ -0,0 +1,95 @@
import { db } from "@server/db/pg/driver";
import { APP_PATH } from "@server/lib/consts";
import { sql } from "drizzle-orm";
import fs from "fs";
import yaml from "js-yaml";
import path from "path";
import z from "zod";
import { fromZodError } from "zod-validation-error";
const version = "1.21.0";
export default async function migration() {
console.log(`Running setup script ${version}...`);
try {
await db.execute(sql`BEGIN`);
await db.execute(sql`
ALTER TABLE "resourceAccessToken" ADD COLUMN "userId" varchar;
`);
await db.execute(sql`
ALTER TABLE "resourceAccessToken" ADD COLUMN "persistSession" boolean DEFAULT false NOT NULL;
`);
await db.execute(sql`
ALTER TABLE "resources" ADD COLUMN "status" varchar DEFAULT 'approved';
`);
await db.execute(sql`
ALTER TABLE "siteResources" ADD COLUMN "status" varchar DEFAULT 'approved';
`);
await db.execute(sql`
ALTER TABLE "sites" ADD COLUMN "localEndpoints" varchar;
`);
await db.execute(sql`
ALTER TABLE "resourceAccessToken" ADD CONSTRAINT "resourceAccessToken_userId_user_id_fk" FOREIGN KEY ("userId") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;
`);
await db.execute(sql`COMMIT`);
console.log("Migrated database");
} catch (e) {
await db.execute(sql`ROLLBACK`);
console.log("Unable to migrate database");
console.log(e);
throw e;
}
try {
const traefikPath = path.join(
APP_PATH,
"traefik",
"traefik_config.yml"
);
const schema = z.object({
experimental: z.object({
plugins: z.object({
badger: z.object({
moduleName: z.string(),
version: z.string()
})
})
})
});
const traefikFileContents = fs.readFileSync(traefikPath, "utf8");
const traefikConfig = yaml.load(traefikFileContents) as any;
const parsedConfig = schema.safeParse(traefikConfig);
if (!parsedConfig.success) {
throw new Error(fromZodError(parsedConfig.error).toString());
}
traefikConfig.experimental.plugins.badger.version = "v1.5.0";
const updatedTraefikYaml = yaml.dump(traefikConfig);
fs.writeFileSync(traefikPath, updatedTraefikYaml, "utf8");
console.log(
"Updated the version of Badger in your Traefik configuration to v1.5.0"
);
} catch (e) {
console.log(
"We were unable to update the version of Badger in your Traefik configuration. Please update it manually. Check the release notes for this version for more information."
);
console.error(e);
}
console.log(`${version} migration complete`);
}
+104
View File
@@ -0,0 +1,104 @@
import { APP_PATH } from "@server/lib/consts";
import Database from "better-sqlite3";
import fs from "fs";
import yaml from "js-yaml";
import path from "path";
import z from "zod";
import { fromZodError } from "zod-validation-error";
const version = "1.21.0";
export default async function migration() {
console.log(`Running setup script ${version}...`);
const location = path.join(APP_PATH, "db", "db.sqlite");
const db = new Database(location);
try {
db.pragma("foreign_keys = OFF");
db.transaction(() => {
db.prepare(
`
ALTER TABLE 'resourceAccessToken' ADD 'userId' text REFERENCES user(id);
`
).run();
db.prepare(
`
ALTER TABLE 'resourceAccessToken' ADD 'persistSession' integer DEFAULT false NOT NULL;
`
).run();
db.prepare(
`
ALTER TABLE 'resources' ADD 'status' text DEFAULT 'approved';
`
).run();
db.prepare(
`
ALTER TABLE 'siteResources' ADD 'status' text DEFAULT 'approved';
`
).run();
db.prepare(
`
ALTER TABLE 'sites' ADD 'localEndpoints' text;
`
).run();
})();
db.pragma("foreign_keys = ON");
console.log("Migrated database");
} catch (e) {
console.log("Failed to migrate db:", e);
throw e;
}
try {
const traefikPath = path.join(
APP_PATH,
"traefik",
"traefik_config.yml"
);
const schema = z.object({
experimental: z.object({
plugins: z.object({
badger: z.object({
moduleName: z.string(),
version: z.string()
})
})
})
});
const traefikFileContents = fs.readFileSync(traefikPath, "utf8");
const traefikConfig = yaml.load(traefikFileContents) as any;
const parsedConfig = schema.safeParse(traefikConfig);
if (!parsedConfig.success) {
throw new Error(fromZodError(parsedConfig.error).toString());
}
traefikConfig.experimental.plugins.badger.version = "v1.5.0";
const updatedTraefikYaml = yaml.dump(traefikConfig);
fs.writeFileSync(traefikPath, updatedTraefikYaml, "utf8");
console.log(
"Updated the version of Badger in your Traefik configuration to v1.5.0"
);
} catch (e) {
console.log(
"We were unable to update the version of Badger in your Traefik configuration. Please update it manually. Check the release notes for this version for more information."
);
console.error(e);
}
console.log(`${version} migration complete`);
}
+33
View File
@@ -248,6 +248,39 @@ export async function loginProxy(
return await makeApiRequest<LoginResponse>(url, "POST", request); return await makeApiRequest<LoginResponse>(url, "POST", request);
} }
export async function logoutProxy(): Promise<ResponseT<null>> {
const env = pullEnv();
const serverPort = process.env.SERVER_EXTERNAL_PORT;
const url = `http://localhost:${serverPort}/api/v1/auth/logout`;
const result = await makeApiRequest<null>(url, "POST");
try {
const headersList = await reqHeaders();
const host = headersList.get("host")?.split(":")[0];
const allCookies = await cookies();
const clearOptions = {
httpOnly: true,
secure: true,
sameSite: "lax" as const,
path: "/",
maxAge: 0
};
// Clear both host-only and domain-scoped variants.
allCookies.set(env.server.sessionCookieName, "", clearOptions);
if (host) {
allCookies.set(env.server.sessionCookieName, "", {
...clearOptions,
domain: host
});
}
} catch (cookieError) {
console.error("Failed to clear session cookie:", cookieError);
}
return result;
}
export async function securityKeyStartProxy( export async function securityKeyStartProxy(
request: SecurityKeyStartRequest, request: SecurityKeyStartRequest,
forceLogin?: boolean forceLogin?: boolean
+99 -4
View File
@@ -52,7 +52,6 @@ import { ChevronsUpDown } from "lucide-react";
import { Checkbox } from "@app/components/ui/checkbox"; import { Checkbox } from "@app/components/ui/checkbox";
import { GenerateAccessTokenResponse } from "@server/routers/accessToken"; import { GenerateAccessTokenResponse } from "@server/routers/accessToken";
import { constructShareLink } from "@app/lib/shareLinks"; import { constructShareLink } from "@app/lib/shareLinks";
import { ShareLinkRow } from "@app/components/ShareLinksTable";
import { QRCodeCanvas, QRCodeSVG } from "qrcode.react"; import { QRCodeCanvas, QRCodeSVG } from "qrcode.react";
import { import {
Collapsible, Collapsible,
@@ -63,11 +62,26 @@ import AccessTokenSection from "@app/components/AccessTokenUsage";
import { useTranslations } from "next-intl"; import { useTranslations } from "next-intl";
import { toUnicode } from "punycode"; import { toUnicode } from "punycode";
import { ResourceSelector, type SelectedResource } from "./resource-selector"; import { ResourceSelector, type SelectedResource } from "./resource-selector";
import { UserSelector, type SelectedUser } from "@app/components/user-selector";
type CreatedShareLink = {
accessTokenId: string;
resourceId: number;
resourceName: string;
resourceNiceId: string;
title: string | null;
createdAt: number;
expiresAt: number | null;
userId?: string | null;
userName?: string | null;
username?: string | null;
userEmail?: string | null;
};
type FormProps = { type FormProps = {
open: boolean; open: boolean;
setOpen: (open: boolean) => void; setOpen: (open: boolean) => void;
onCreated?: (result: ShareLinkRow) => void; onCreated?: (result: CreatedShareLink) => void;
}; };
export default function CreateShareLinkForm({ export default function CreateShareLinkForm({
@@ -85,6 +99,8 @@ export default function CreateShareLinkForm({
const [accessToken, setAccessToken] = useState<string | null>(null); const [accessToken, setAccessToken] = useState<string | null>(null);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [neverExpire, setNeverExpire] = useState(false); const [neverExpire, setNeverExpire] = useState(false);
const [persistSession, setPersistSession] = useState(false);
const [selectedUser, setSelectedUser] = useState<SelectedUser | null>(null);
const [isOpen, setIsOpen] = useState(false); const [isOpen, setIsOpen] = useState(false);
const t = useTranslations(); const t = useTranslations();
@@ -175,7 +191,9 @@ export default function CreateShareLinkForm({
values.resourceName || values.resourceName ||
"Resource" + values.resourceId "Resource" + values.resourceId
}), }),
path: values.path path: values.path,
persistSession,
userId: selectedUser?.id
} }
) )
.catch((e) => { .catch((e) => {
@@ -205,7 +223,11 @@ export default function CreateShareLinkForm({
resourceNiceId: selectedResource ? selectedResource.niceId : "", resourceNiceId: selectedResource ? selectedResource.niceId : "",
title: token.title, title: token.title,
createdAt: token.createdAt, createdAt: token.createdAt,
expiresAt: token.expiresAt expiresAt: token.expiresAt,
userId: token.userId,
userName: selectedUser?.text ?? null,
username: null,
userEmail: null
}); });
} }
@@ -220,6 +242,9 @@ export default function CreateShareLinkForm({
setOpen(val); setOpen(val);
setLink(null); setLink(null);
setLoading(false); setLoading(false);
setNeverExpire(false);
setPersistSession(false);
setSelectedUser(null);
form.reset(); form.reset();
}} }}
> >
@@ -344,6 +369,48 @@ export default function CreateShareLinkForm({
)} )}
/> />
<div className="space-y-2">
<FormLabel>
{t(
"shareAssociateUserOptional"
)}
</FormLabel>
<Popover>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
className={cn(
"w-full justify-between",
!selectedUser &&
"text-muted-foreground"
)}
>
{selectedUser?.text
? selectedUser.text
: t("userSelect")}
<CaretSortIcon className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="p-0 w-[var(--radix-popover-trigger-width)]">
<UserSelector
orgId={org.org.orgId}
selectedUser={
selectedUser
}
onSelectUser={
setSelectedUser
}
/>
</PopoverContent>
</Popover>
<p className="text-sm text-muted-foreground">
{t(
"shareAssociateUserDescription"
)}
</p>
</div>
<div className="space-y-4"> <div className="space-y-4">
<div className="space-y-2"> <div className="space-y-2">
<FormLabel> <FormLabel>
@@ -437,6 +504,34 @@ export default function CreateShareLinkForm({
</label> </label>
</div> </div>
<div className="flex items-start space-x-2">
<Checkbox
id="persist-session"
checked={persistSession}
onCheckedChange={(val) =>
setPersistSession(
val as boolean
)
}
className="mt-0.5"
/>
<div className="space-y-1">
<label
htmlFor="persist-session"
className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
>
{t(
"sharePersistSession"
)}
</label>
<p className="text-sm text-muted-foreground">
{t(
"sharePersistSessionDescription"
)}
</p>
</div>
</div>
<p className="text-sm text-muted-foreground"> <p className="text-sm text-muted-foreground">
{t("shareExpireDescription")} {t("shareExpireDescription")}
</p> </p>
+20 -8
View File
@@ -93,14 +93,20 @@ export default function InviteStatusCard({
setType(type); setType(type);
if (!user && type === "user_does_not_exist") { if (!user && type === "user_does_not_exist") {
const inviteRedirect = encodeURIComponent(
`/invite?token=${tokenParam}`
);
const redirectUrl = email const redirectUrl = email
? `/auth/signup?redirect=/invite?token=${tokenParam}&email=${email}` ? `/auth/signup?redirect=${inviteRedirect}&email=${encodeURIComponent(email)}`
: `/auth/signup?redirect=/invite?token=${tokenParam}`; : `/auth/signup?redirect=${inviteRedirect}`;
router.push(redirectUrl); router.push(redirectUrl);
} else if (!user && type === "not_logged_in") { } else if (!user && type === "not_logged_in") {
const inviteRedirect = encodeURIComponent(
`/invite?token=${tokenParam}`
);
const redirectUrl = email const redirectUrl = email
? `/auth/login?redirect=/invite?token=${tokenParam}&user=${email}` ? `/auth/login?redirect=${inviteRedirect}&user=${encodeURIComponent(email)}`
: `/auth/login?redirect=/invite?token=${tokenParam}`; : `/auth/login?redirect=${inviteRedirect}`;
router.push(redirectUrl); router.push(redirectUrl);
} else { } else {
setLoading(false); setLoading(false);
@@ -112,17 +118,23 @@ export default function InviteStatusCard({
async function goToLogin() { async function goToLogin() {
await api.post("/auth/logout", {}); await api.post("/auth/logout", {});
const inviteRedirect = encodeURIComponent(
`/invite?token=${tokenParam}`
);
const redirectUrl = email const redirectUrl = email
? `/auth/login?redirect=/invite?token=${tokenParam}&user=${email}` ? `/auth/login?redirect=${inviteRedirect}&user=${encodeURIComponent(email)}`
: `/auth/login?redirect=/invite?token=${tokenParam}`; : `/auth/login?redirect=${inviteRedirect}`;
router.push(redirectUrl); router.push(redirectUrl);
} }
async function goToSignup() { async function goToSignup() {
await api.post("/auth/logout", {}); await api.post("/auth/logout", {});
const inviteRedirect = encodeURIComponent(
`/invite?token=${tokenParam}`
);
const redirectUrl = email const redirectUrl = email
? `/auth/signup?redirect=/invite?token=${tokenParam}&email=${email}` ? `/auth/signup?redirect=${inviteRedirect}&email=${encodeURIComponent(email)}`
: `/auth/signup?redirect=/invite?token=${tokenParam}`; : `/auth/signup?redirect=${inviteRedirect}`;
router.push(redirectUrl); router.push(redirectUrl);
} }
+15 -12
View File
@@ -12,8 +12,8 @@ import { Shield, ArrowRight } from "lucide-react";
import Link from "next/link"; import Link from "next/link";
import { useTranslations } from "next-intl"; import { useTranslations } from "next-intl";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { createApiClient } from "@app/lib/api"; import { useState } from "react";
import { useEnvContext } from "@app/hooks/useEnvContext"; import { logoutProxy } from "@app/actions/server";
type OrgPolicyRequiredProps = { type OrgPolicyRequiredProps = {
orgId: string; orgId: string;
@@ -40,21 +40,23 @@ export default function OrgPolicyRequired({
}: OrgPolicyRequiredProps) { }: OrgPolicyRequiredProps) {
const t = useTranslations(); const t = useTranslations();
const router = useRouter(); const router = useRouter();
const [loading, setLoading] = useState(false);
const api = createApiClient(useEnvContext());
const sessionExpired = const sessionExpired =
policies?.maxSessionLength && policies?.maxSessionLength &&
policies.maxSessionLength.compliant === false; policies.maxSessionLength.compliant === false;
function reauthenticate() { async function reauthenticate() {
api.post("/auth/logout") setLoading(true);
.catch(() => {}) try {
.then(() => { await logoutProxy();
const destination = redirectAfterAuth ?? `/${orgId}`; } catch (error) {
router.push(destination); console.error("Error during logout:", error);
router.refresh(); } finally {
}); const destination = redirectAfterAuth ?? `/${orgId}`;
router.push(destination);
router.refresh();
}
} }
if (sessionExpired) { if (sessionExpired) {
@@ -76,6 +78,7 @@ export default function OrgPolicyRequired({
<Button <Button
className="w-full" className="w-full"
onClick={reauthenticate} onClick={reauthenticate}
loading={loading}
> >
{t("reauthenticate")} {t("reauthenticate")}
<ArrowRight className="ml-2 h-4 w-4" /> <ArrowRight className="ml-2 h-4 w-4" />
+7
View File
@@ -143,6 +143,13 @@ function getActionsCategories(root: boolean) {
Logs: { Logs: {
[t("actionExportLogs")]: "exportLogs", [t("actionExportLogs")]: "exportLogs",
[t("actionViewLogs")]: "viewLogs" [t("actionViewLogs")]: "viewLogs"
},
"Site Provisioning Key": {
[t("actionCreateSiteProvisioningKey")]: "createSiteProvisioningKey",
[t("actionListSiteProvisioningKeys")]: "listSiteProvisioningKeys",
[t("actionUpdateSiteProvisioningKey")]: "updateSiteProvisioningKey",
[t("actionDeleteSiteProvisioningKey")]: "deleteSiteProvisioningKey"
} }
}; };
+40
View File
@@ -35,6 +35,7 @@ import moment from "moment";
import CreateShareLinkForm from "@app/components/CreateShareLinkForm"; import CreateShareLinkForm from "@app/components/CreateShareLinkForm";
import { constructShareLink } from "@app/lib/shareLinks"; import { constructShareLink } from "@app/lib/shareLinks";
import { useTranslations } from "next-intl"; import { useTranslations } from "next-intl";
import { getUserDisplayName } from "@app/lib/getUserDisplayName";
export type ShareLinkRow = { export type ShareLinkRow = {
accessTokenId: string; accessTokenId: string;
@@ -44,6 +45,10 @@ export type ShareLinkRow = {
title: string | null; title: string | null;
createdAt: number; createdAt: number;
expiresAt: number | null; expiresAt: number | null;
userId?: string | null;
userName?: string | null;
username?: string | null;
userEmail?: string | null;
}; };
type ShareLinksTableProps = { type ShareLinksTableProps = {
@@ -155,6 +160,41 @@ export default function ShareLinksTable({
); );
} }
}, },
{
accessorKey: "userId",
friendlyName: t("user"),
header: ({ column }) => {
return (
<Button
variant="ghost"
onClick={() =>
column.toggleSorting(column.getIsSorted() === "asc")
}
>
{t("user")}
<ArrowUpDown className="ml-2 h-4 w-4" />
</Button>
);
},
cell: ({ row }) => {
const r = row.original;
if (!r.userId) {
return <span>-</span>;
}
return (
<Link href={`/${orgId}/settings/access/users/${r.userId}`}>
<Button variant="outline" size="sm">
{getUserDisplayName({
email: r.userEmail,
name: r.userName,
username: r.username
})}
<ArrowUpRight className="ml-2 h-3 w-3" />
</Button>
</Link>
);
}
},
// { // {
// accessorKey: "domain", // accessorKey: "domain",
// header: "Link", // header: "Link",
+106
View File
@@ -0,0 +1,106 @@
import { orgQueries } from "@app/lib/queries";
import { getUserDisplayName } from "@app/lib/getUserDisplayName";
import { useQuery } from "@tanstack/react-query";
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList
} from "./ui/command";
import { useMemo, useState } from "react";
import { useTranslations } from "next-intl";
import { CheckIcon } from "lucide-react";
import { cn } from "@app/lib/cn";
import { useDebounce } from "use-debounce";
import type { SelectedUser } from "./users-selector";
export type { SelectedUser };
export type UserSelectorProps = {
orgId: string;
selectedUser?: SelectedUser | null;
onSelectUser: (user: SelectedUser | null) => void;
allowClear?: boolean;
};
export function UserSelector({
orgId,
selectedUser,
onSelectUser,
allowClear = true
}: UserSelectorProps) {
const t = useTranslations();
const [userSearchQuery, setUserSearchQuery] = useState("");
const [debouncedValue] = useDebounce(userSearchQuery, 150);
const { data: users = [] } = useQuery(
orgQueries.users({ orgId, perPage: 10, query: debouncedValue })
);
const usersShown = useMemo(() => {
const allUsers: Array<SelectedUser> = users.map((u) => ({
id: u.id,
text: getUserDisplayName(u)
}));
if (
debouncedValue.trim().length === 0 &&
selectedUser &&
!allUsers.find((user) => user.id === selectedUser.id)
) {
allUsers.unshift(selectedUser);
}
return allUsers;
}, [users, selectedUser, debouncedValue]);
return (
<Command shouldFilter={false}>
<CommandInput
placeholder={t("userSearch")}
value={userSearchQuery}
onValueChange={setUserSearchQuery}
/>
<CommandList>
<CommandEmpty>{t("usersNotFound")}</CommandEmpty>
<CommandGroup>
{allowClear && (
<CommandItem
value="__none__"
onSelect={() => {
onSelectUser(null);
}}
>
<CheckIcon
className={cn(
"mr-2 h-4 w-4",
!selectedUser ? "opacity-100" : "opacity-0"
)}
/>
{t("none")}
</CommandItem>
)}
{usersShown.map((user) => (
<CommandItem
value={`${user.text}:${user.id}`}
key={user.id}
onSelect={() => {
onSelectUser(user);
}}
>
<CheckIcon
className={cn(
"mr-2 h-4 w-4",
user.id === selectedUser?.id
? "opacity-100"
: "opacity-0"
)}
/>
{user.text}
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
);
}
+12 -5
View File
@@ -650,9 +650,13 @@ export const orgQueries = {
queryOptions({ queryOptions({
queryKey: ["SITE_STATUS_HISTORY", siteId, days] as const, queryKey: ["SITE_STATUS_HISTORY", siteId, days] as const,
queryFn: async ({ signal, meta }) => { queryFn: async ({ signal, meta }) => {
const tzOffsetMinutes = -new Date().getTimezoneOffset();
const res = await meta!.api.get< const res = await meta!.api.get<
AxiosResponse<StatusHistoryResponse> AxiosResponse<StatusHistoryResponse>
>(`/site/${siteId}/status-history?days=${days}`, { signal }); >(
`/site/${siteId}/status-history?days=${days}&tzOffsetMinutes=${tzOffsetMinutes}`,
{ signal }
);
return res.data.data; return res.data.data;
} }
}), }),
@@ -667,11 +671,13 @@ export const orgQueries = {
queryOptions({ queryOptions({
queryKey: ["RESOURCE_STATUS_HISTORY", resourceId, days] as const, queryKey: ["RESOURCE_STATUS_HISTORY", resourceId, days] as const,
queryFn: async ({ signal, meta }) => { queryFn: async ({ signal, meta }) => {
const tzOffsetMinutes = -new Date().getTimezoneOffset();
const res = await meta!.api.get< const res = await meta!.api.get<
AxiosResponse<StatusHistoryResponse> AxiosResponse<StatusHistoryResponse>
>(`/resource/${resourceId}/status-history?days=${days}`, { >(
signal `/resource/${resourceId}/status-history?days=${days}&tzOffsetMinutes=${tzOffsetMinutes}`,
}); { signal }
);
return res.data.data; return res.data.data;
} }
}), }),
@@ -693,10 +699,11 @@ export const orgQueries = {
days days
] as const, ] as const,
queryFn: async ({ signal, meta }) => { queryFn: async ({ signal, meta }) => {
const tzOffsetMinutes = -new Date().getTimezoneOffset();
const res = await meta!.api.get< const res = await meta!.api.get<
AxiosResponse<StatusHistoryResponse> AxiosResponse<StatusHistoryResponse>
>( >(
`/org/${orgId}/health-check/${healthCheckId}/status-history?days=${days}`, `/org/${orgId}/health-check/${healthCheckId}/status-history?days=${days}&tzOffsetMinutes=${tzOffsetMinutes}`,
{ signal } { signal }
); );
return res.data.data; return res.data.data;