mirror of
https://github.com/fosrl/pangolin.git
synced 2026-08-24 05:00:19 +02:00
Compare commits
87 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 407ba567a0 | |||
| f28571629f | |||
| 5a575c916b | |||
| 9a7e534b10 | |||
| 42974d1739 | |||
| 780e8babe4 | |||
| 2c7b8006cf | |||
| 35066c1388 | |||
| 135a5d38af | |||
| 1b7c1ffa70 | |||
| 641f643d2d | |||
| b4ecfceb5e | |||
| 08a84d4bb1 | |||
| 4dbad7ab24 | |||
| 859c0c9477 | |||
| d294bf8534 | |||
| 3c8fea382f | |||
| b81bfcfcee | |||
| 56c415ca05 | |||
| 74fdcceace | |||
| 7dec8ba998 | |||
| c9dc6affe7 | |||
| 8fe45ba78c | |||
| 934886caea | |||
| fae258b145 | |||
| 9f224f655f | |||
| aea7df7dc2 | |||
| 3b675f7de1 | |||
| 8daf7c2872 | |||
| c394490473 | |||
| 3b6b78b3e1 | |||
| aa47f522ef | |||
| 8658198a93 | |||
| 4b770d1385 | |||
| cd4d7372a0 | |||
| dc8243cb51 | |||
| 7b1f8d98f3 | |||
| dd8bcbb3e3 | |||
| d1af7a153f | |||
| 13efa47db7 | |||
| 69bd61c308 | |||
| 7b7ff51289 | |||
| 772ac8af73 | |||
| 8ee520dbb5 | |||
| 8e5d9e94a9 | |||
| c9cb28af45 | |||
| a994f8ff07 | |||
| ea8eaf9736 | |||
| b78db3daef | |||
| 7cf3f8df92 | |||
| f2b5cff3f9 | |||
| 6de9ab8f05 | |||
| ad0e800d8d | |||
| 65470fb64b | |||
| f23142336b | |||
| 2da4987cd3 | |||
| 253ba554a2 | |||
| 95ce91d94b | |||
| a4548fd874 | |||
| eb03fb7060 | |||
| add9b8dfb0 | |||
| 2adb7b64cb | |||
| 84fef5f1d6 | |||
| def1e9c851 | |||
| 67b08ca61e | |||
| 614df75880 | |||
| 676cf37ee2 | |||
| 6b96e3dce6 | |||
| b67037e2ea | |||
| 5a5b77cf62 | |||
| d2793dfad7 | |||
| ff507f1275 | |||
| 6b04bcb383 | |||
| b2f1115ef8 | |||
| 567ef23ac4 | |||
| 6affebc666 | |||
| 889f78ddb8 | |||
| 9d3f96cf83 | |||
| e5d0673bbf | |||
| 0907c0346f | |||
| 6420a90d08 | |||
| 33fdc9a94f | |||
| c86026c941 | |||
| db014e3446 | |||
| feb8045643 | |||
| d485a09318 | |||
| 9cff5f66b1 |
@@ -0,0 +1,5 @@
|
|||||||
|
---
|
||||||
|
alwaysApply: true
|
||||||
|
---
|
||||||
|
|
||||||
|
When adding submit buttons, don't change the text of the button during the loading state. Text should stay static and you should use the loading prop on the button.
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
---
|
||||||
|
alwaysApply: true
|
||||||
|
---
|
||||||
|
|
||||||
|
When creating UI for popup dialogs or modals, use the Credenza componennt. This component is mobile responsive and works on desktop and wraps the dialog component and sheet into one.
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
---
|
||||||
|
alwaysApply: true
|
||||||
|
---
|
||||||
|
|
||||||
|
When writing TypeScript:
|
||||||
|
|
||||||
|
Prefer to use types instead of interfaces.
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
---
|
||||||
|
alwaysApply: true
|
||||||
|
---
|
||||||
|
|
||||||
|
When creating forms, use React form for validation and use Zod schemas.
|
||||||
+2
-2
@@ -200,8 +200,8 @@
|
|||||||
"shareErrorSelectResource": "Моля, изберете ресурс",
|
"shareErrorSelectResource": "Моля, изберете ресурс",
|
||||||
"proxyResourceTitle": "Управление на обществени ресурси",
|
"proxyResourceTitle": "Управление на обществени ресурси",
|
||||||
"proxyResourceDescription": "Създайте и управлявайте ресурси, които са общодостъпни чрез уеб браузър.",
|
"proxyResourceDescription": "Създайте и управлявайте ресурси, които са общодостъпни чрез уеб браузър.",
|
||||||
"proxyResourcesBannerTitle": "Публичен достъп чрез уеб.",
|
"publicResourcesBannerTitle": "Публичен достъп чрез уеб.",
|
||||||
"proxyResourcesBannerDescription": "Публичните ресурси са HTTPS или TCP/UDP проксита, достъпни за всеки в интернет чрез уеб браузър. За разлика от частните ресурси, те не изискват софтуер от страна на клиента и могат да включват издентити и контексто-осъзнати политики за достъп.",
|
"publicResourcesBannerDescription": "Публичните ресурси са HTTPS или TCP/UDP проксита, достъпни за всеки в интернет чрез уеб браузър. За разлика от частните ресурси, те не изискват софтуер от страна на клиента и могат да включват издентити и контексто-осъзнати политики за достъп.",
|
||||||
"clientResourceTitle": "Управление на частни ресурси",
|
"clientResourceTitle": "Управление на частни ресурси",
|
||||||
"clientResourceDescription": "Създайте и управлявайте ресурси, които са достъпни само чрез свързан клиент.",
|
"clientResourceDescription": "Създайте и управлявайте ресурси, които са достъпни само чрез свързан клиент.",
|
||||||
"privateResourcesBannerTitle": "Достъп до частни ресурси с нулево доверие.",
|
"privateResourcesBannerTitle": "Достъп до частни ресурси с нулево доверие.",
|
||||||
|
|||||||
+2
-2
@@ -200,8 +200,8 @@
|
|||||||
"shareErrorSelectResource": "Zvolte prosím zdroj",
|
"shareErrorSelectResource": "Zvolte prosím zdroj",
|
||||||
"proxyResourceTitle": "Spravovat veřejné zdroje",
|
"proxyResourceTitle": "Spravovat veřejné zdroje",
|
||||||
"proxyResourceDescription": "Vytváření a správa zdrojů, které jsou veřejně přístupné prostřednictvím webového prohlížeče",
|
"proxyResourceDescription": "Vytváření a správa zdrojů, které jsou veřejně přístupné prostřednictvím webového prohlížeče",
|
||||||
"proxyResourcesBannerTitle": "Veřejný přístup založený na webu",
|
"publicResourcesBannerTitle": "Veřejný přístup založený na webu",
|
||||||
"proxyResourcesBannerDescription": "Veřejné prostředky jsou HTTPS nebo TCP/UDP proxy, které jsou přístupné každému na internetu prostřednictvím webového prohlížeče. Na rozdíl od soukromých prostředků nevyžadují software na straně klienta a mohou zahrnovat politiky přístupu orientované na identitu a kontext.",
|
"publicResourcesBannerDescription": "Veřejné prostředky jsou HTTPS nebo TCP/UDP proxy, které jsou přístupné každému na internetu prostřednictvím webového prohlížeče. Na rozdíl od soukromých prostředků nevyžadují software na straně klienta a mohou zahrnovat politiky přístupu orientované na identitu a kontext.",
|
||||||
"clientResourceTitle": "Spravovat soukromé zdroje",
|
"clientResourceTitle": "Spravovat soukromé zdroje",
|
||||||
"clientResourceDescription": "Vytváření a správa zdrojů, které jsou přístupné pouze prostřednictvím připojeného klienta",
|
"clientResourceDescription": "Vytváření a správa zdrojů, které jsou přístupné pouze prostřednictvím připojeného klienta",
|
||||||
"privateResourcesBannerTitle": "Zero-Trust soukromý přístup",
|
"privateResourcesBannerTitle": "Zero-Trust soukromý přístup",
|
||||||
|
|||||||
+2
-2
@@ -200,8 +200,8 @@
|
|||||||
"shareErrorSelectResource": "Bitte wählen Sie eine Ressource",
|
"shareErrorSelectResource": "Bitte wählen Sie eine Ressource",
|
||||||
"proxyResourceTitle": "Öffentliche Ressourcen verwalten",
|
"proxyResourceTitle": "Öffentliche Ressourcen verwalten",
|
||||||
"proxyResourceDescription": "Erstelle und verwalte Ressourcen, die über einen Webbrowser öffentlich zugänglich sind",
|
"proxyResourceDescription": "Erstelle und verwalte Ressourcen, die über einen Webbrowser öffentlich zugänglich sind",
|
||||||
"proxyResourcesBannerTitle": "Web-basierter öffentlicher Zugang",
|
"publicResourcesBannerTitle": "Web-basierter öffentlicher Zugang",
|
||||||
"proxyResourcesBannerDescription": "Öffentliche Ressourcen sind HTTPS oder TCP/UDP-Proxys, die über einen Webbrowser für jeden zugänglich sind. Im Gegensatz zu privaten Ressourcen benötigen sie keine Client-seitige Software und können Identitäts- und kontextbezogene Zugriffsrichtlinien beinhalten.",
|
"publicResourcesBannerDescription": "Öffentliche Ressourcen sind HTTPS oder TCP/UDP-Proxys, die über einen Webbrowser für jeden zugänglich sind. Im Gegensatz zu privaten Ressourcen benötigen sie keine Client-seitige Software und können Identitäts- und kontextbezogene Zugriffsrichtlinien beinhalten.",
|
||||||
"clientResourceTitle": "Private Ressourcen verwalten",
|
"clientResourceTitle": "Private Ressourcen verwalten",
|
||||||
"clientResourceDescription": "Erstelle und verwalte Ressourcen, die nur über einen verbundenen Client zugänglich sind",
|
"clientResourceDescription": "Erstelle und verwalte Ressourcen, die nur über einen verbundenen Client zugänglich sind",
|
||||||
"privateResourcesBannerTitle": "Zero-Trust-Zugriff auf private Ressourcen",
|
"privateResourcesBannerTitle": "Zero-Trust-Zugriff auf private Ressourcen",
|
||||||
|
|||||||
+166
-38
@@ -101,6 +101,8 @@
|
|||||||
"sitesTableViewPrivateResources": "View Private Resources",
|
"sitesTableViewPrivateResources": "View Private Resources",
|
||||||
"siteInstallNewt": "Install Site",
|
"siteInstallNewt": "Install Site",
|
||||||
"siteInstallNewtDescription": "Install the site connector for your system",
|
"siteInstallNewtDescription": "Install the site connector for your system",
|
||||||
|
"siteInstallKubernetesDocsDescription": "For more and up to date Kubernetes installation information, see <docsLink>docs.pangolin.net/manage/sites/install-kubernetes</docsLink>.",
|
||||||
|
"siteInstallAdvantechDocsDescription": "For Advantech modem installation instructions, see <docsLink>docs.pangolin.net/manage/sites/install-advantech</docsLink>.",
|
||||||
"WgConfiguration": "WireGuard Configuration",
|
"WgConfiguration": "WireGuard Configuration",
|
||||||
"WgConfigurationDescription": "Use the following configuration to connect to the network",
|
"WgConfigurationDescription": "Use the following configuration to connect to the network",
|
||||||
"operatingSystem": "Operating System",
|
"operatingSystem": "Operating System",
|
||||||
@@ -148,16 +150,16 @@
|
|||||||
"siteCredentialsSaveDescription": "You will only be able to see this once. Make sure to copy it to a secure place.",
|
"siteCredentialsSaveDescription": "You will only be able to see this once. Make sure to copy it to a secure place.",
|
||||||
"siteInfo": "Site Information",
|
"siteInfo": "Site Information",
|
||||||
"status": "Status",
|
"status": "Status",
|
||||||
"shareTitle": "Manage Share Links",
|
"shareTitle": "Manage Shareable Links",
|
||||||
"shareDescription": "Create shareable links to grant temporary or permanent access to proxy resources",
|
"shareDescription": "Create shareable links to grant temporary or permanent access to proxy resources",
|
||||||
"shareSearch": "Search share links...",
|
"shareSearch": "Search shareable links...",
|
||||||
"shareCreate": "Create Share Link",
|
"shareCreate": "Create Shareable Link",
|
||||||
"shareErrorDelete": "Failed to delete link",
|
"shareErrorDelete": "Failed to delete link",
|
||||||
"shareErrorDeleteMessage": "An error occurred deleting link",
|
"shareErrorDeleteMessage": "An error occurred deleting link",
|
||||||
"shareDeleted": "Link deleted",
|
"shareDeleted": "Link deleted",
|
||||||
"shareDeletedDescription": "The link has been deleted",
|
"shareDeletedDescription": "The link has been deleted",
|
||||||
"shareDelete": "Delete Share Link",
|
"shareDelete": "Delete Shareable Link",
|
||||||
"shareDeleteConfirm": "Confirm Delete Share 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 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.",
|
||||||
@@ -177,6 +179,7 @@
|
|||||||
"shareCreateDescription": "Anyone with this link can access the resource",
|
"shareCreateDescription": "Anyone with this link can access the resource",
|
||||||
"shareTitleOptional": "Title (optional)",
|
"shareTitleOptional": "Title (optional)",
|
||||||
"sharePathOptional": "Path (optional)",
|
"sharePathOptional": "Path (optional)",
|
||||||
|
"sharePathDescription": "The link will redirect users to this path after authentication.",
|
||||||
"expireIn": "Expire In",
|
"expireIn": "Expire In",
|
||||||
"neverExpire": "Never expire",
|
"neverExpire": "Never expire",
|
||||||
"shareExpireDescription": "Expiration time is how long the link will be usable and provide access to the resource. After this time, the link will no longer work, and users who used this link will lose access to the resource.",
|
"shareExpireDescription": "Expiration time is how long the link will be usable and provide access to the resource. After this time, the link will no longer work, and users who used this link will lose access to the resource.",
|
||||||
@@ -200,8 +203,8 @@
|
|||||||
"shareErrorSelectResource": "Please select a resource",
|
"shareErrorSelectResource": "Please select a resource",
|
||||||
"proxyResourceTitle": "Manage Public Resources",
|
"proxyResourceTitle": "Manage Public Resources",
|
||||||
"proxyResourceDescription": "Create and manage resources that are publicly accessible through a web browser",
|
"proxyResourceDescription": "Create and manage resources that are publicly accessible through a web browser",
|
||||||
"proxyResourcesBannerTitle": "Web-based Public Access",
|
"publicResourcesBannerTitle": "Web-based Public Access",
|
||||||
"proxyResourcesBannerDescription": "Public resources are HTTPS proxies accessible to anyone on the internet through a web browser. Unlike private resources, they do not require client-side software and can include identity and context-aware access policies.",
|
"publicResourcesBannerDescription": "Public resources are HTTPS proxies accessible to anyone on the internet through a web browser. Unlike private resources, they do not require client-side software and can include identity and context-aware access policies.",
|
||||||
"clientResourceTitle": "Manage Private Resources",
|
"clientResourceTitle": "Manage Private Resources",
|
||||||
"clientResourceDescription": "Create and manage resources that are only accessible through a connected client",
|
"clientResourceDescription": "Create and manage resources that are only accessible through a connected client",
|
||||||
"privateResourcesBannerTitle": "Zero-Trust Private Access",
|
"privateResourcesBannerTitle": "Zero-Trust Private Access",
|
||||||
@@ -209,15 +212,18 @@
|
|||||||
"resourcesSearch": "Search resources...",
|
"resourcesSearch": "Search resources...",
|
||||||
"resourceAdd": "Add Resource",
|
"resourceAdd": "Add Resource",
|
||||||
"resourceErrorDelte": "Error deleting resource",
|
"resourceErrorDelte": "Error deleting resource",
|
||||||
"resourcePoliciesTitle": "Manage Resource Policies",
|
"resourcePoliciesBannerTitle": "Re-use Authentication and Access Rules",
|
||||||
"resourcePoliciesAttachedResourcesColumnTitle": "Attached resources",
|
"resourcePoliciesBannerDescription": "Shared resource policies let you define authentication methods and access rules once, then attach them to multiple public resources. When you update a policy, every linked resource inherits the change automatically.",
|
||||||
|
"resourcePoliciesTitle": "Manage Public Resource Policies",
|
||||||
|
"resourcePoliciesAttachedResourcesColumnTitle": "Resources",
|
||||||
"resourcePoliciesAttachedResources": "{count} resource(s)",
|
"resourcePoliciesAttachedResources": "{count} resource(s)",
|
||||||
|
"resourcePoliciesAttachedResourcesCount": "{count, plural, one {# resource} other {# resources}}",
|
||||||
"resourcePoliciesAttachedResourcesEmpty": "no resources",
|
"resourcePoliciesAttachedResourcesEmpty": "no resources",
|
||||||
"resourcePoliciesDescription": "Create and manage authentication policies to control access to your resources",
|
"resourcePoliciesDescription": "Create and manage authentication policies to control access to your public resources",
|
||||||
"resourcePoliciesSearch": "Search policies...",
|
"resourcePoliciesSearch": "Search policies...",
|
||||||
"resourcePoliciesAdd": "Add Policy",
|
"resourcePoliciesAdd": "Add Policy",
|
||||||
"resourcePoliciesDefaultBadgeText": "Default policy",
|
"resourcePoliciesDefaultBadgeText": "Default policy",
|
||||||
"resourcePoliciesCreate": "Create Resource Policy",
|
"resourcePoliciesCreate": "Create Public Resource Policy",
|
||||||
"resourcePoliciesCreateDescription": "Follow the steps below to create a new policy",
|
"resourcePoliciesCreateDescription": "Follow the steps below to create a new policy",
|
||||||
"resourcePolicyName": "Policy Name",
|
"resourcePolicyName": "Policy Name",
|
||||||
"resourcePolicyNameDescription": "Give this policy a name to identify it across your resources",
|
"resourcePolicyNameDescription": "Give this policy a name to identify it across your resources",
|
||||||
@@ -287,6 +293,8 @@
|
|||||||
"labelDelete": "Delete Label",
|
"labelDelete": "Delete Label",
|
||||||
"labelAdd": "Add Label",
|
"labelAdd": "Add Label",
|
||||||
"labelCreateSuccessMessage": "Label Created Successfully",
|
"labelCreateSuccessMessage": "Label Created Successfully",
|
||||||
|
"labelDuplicateError": "Duplicate Label",
|
||||||
|
"labelDuplicateErrorDescription": "A label with this name already exists.",
|
||||||
"labelEditSuccessMessage": "Label Modified Successfully",
|
"labelEditSuccessMessage": "Label Modified Successfully",
|
||||||
"labelNameField": "Label Name",
|
"labelNameField": "Label Name",
|
||||||
"labelColorField": "Label Color",
|
"labelColorField": "Label Color",
|
||||||
@@ -311,7 +319,7 @@
|
|||||||
"rules": "Rules",
|
"rules": "Rules",
|
||||||
"resourceSettingDescription": "Configure the settings on the resource",
|
"resourceSettingDescription": "Configure the settings on the resource",
|
||||||
"resourceSetting": "{resourceName} Settings",
|
"resourceSetting": "{resourceName} Settings",
|
||||||
"resourcePolicySettingDescription": "Configure the settings on the resource policy",
|
"resourcePolicySettingDescription": "Configure the settings on this public resource policy",
|
||||||
"resourcePolicySetting": "{policyName} Settings",
|
"resourcePolicySetting": "{policyName} Settings",
|
||||||
"alwaysAllow": "Bypass Auth",
|
"alwaysAllow": "Bypass Auth",
|
||||||
"alwaysDeny": "Block Access",
|
"alwaysDeny": "Block Access",
|
||||||
@@ -753,11 +761,11 @@
|
|||||||
"rulesErrorDuplicate": "Duplicate rule",
|
"rulesErrorDuplicate": "Duplicate rule",
|
||||||
"rulesErrorDuplicateDescription": "A rule with these settings already exists",
|
"rulesErrorDuplicateDescription": "A rule with these settings already exists",
|
||||||
"rulesErrorInvalidIpAddressRange": "Invalid CIDR",
|
"rulesErrorInvalidIpAddressRange": "Invalid CIDR",
|
||||||
"rulesErrorInvalidIpAddressRangeDescription": "Please enter a valid CIDR value",
|
"rulesErrorInvalidIpAddressRangeDescription": "Enter a valid CIDR range (e.g., 10.0.0.0/8).",
|
||||||
"rulesErrorInvalidUrl": "Invalid URL path",
|
"rulesErrorInvalidUrl": "Invalid path",
|
||||||
"rulesErrorInvalidUrlDescription": "Please enter a valid URL path value",
|
"rulesErrorInvalidUrlDescription": "Enter a valid URL path or pattern (e.g., /api/*).",
|
||||||
"rulesErrorInvalidIpAddress": "Invalid IP",
|
"rulesErrorInvalidIpAddress": "Invalid IP address",
|
||||||
"rulesErrorInvalidIpAddressDescription": "Please enter a valid IP address",
|
"rulesErrorInvalidIpAddressDescription": "Enter a valid IPv4 or IPv6 address.",
|
||||||
"rulesErrorUpdate": "Failed to update rules",
|
"rulesErrorUpdate": "Failed to update rules",
|
||||||
"rulesErrorUpdateDescription": "An error occurred while updating rules",
|
"rulesErrorUpdateDescription": "An error occurred while updating rules",
|
||||||
"rulesUpdated": "Enable Rules",
|
"rulesUpdated": "Enable Rules",
|
||||||
@@ -765,15 +773,24 @@
|
|||||||
"rulesMatchIpAddressRangeDescription": "Enter an address in CIDR format (e.g., 103.21.244.0/22)",
|
"rulesMatchIpAddressRangeDescription": "Enter an address in CIDR format (e.g., 103.21.244.0/22)",
|
||||||
"rulesMatchIpAddress": "Enter an IP address (e.g., 103.21.244.12)",
|
"rulesMatchIpAddress": "Enter an IP address (e.g., 103.21.244.12)",
|
||||||
"rulesMatchUrl": "Enter a URL path or pattern (e.g., /api/v1/todos or /api/v1/*)",
|
"rulesMatchUrl": "Enter a URL path or pattern (e.g., /api/v1/todos or /api/v1/*)",
|
||||||
"rulesErrorInvalidPriority": "Invalid Priority",
|
"rulesErrorInvalidPriority": "Invalid priority",
|
||||||
"rulesErrorInvalidPriorityDescription": "Please enter a valid priority",
|
"rulesErrorInvalidPriorityDescription": "Enter a whole number of 1 or higher.",
|
||||||
"rulesErrorDuplicatePriority": "Duplicate Priorities",
|
"rulesErrorDuplicatePriority": "Duplicate priorities",
|
||||||
"rulesErrorDuplicatePriorityDescription": "Please enter unique priorities",
|
"rulesErrorDuplicatePriorityDescription": "Each rule must have a unique priority number.",
|
||||||
|
"rulesErrorValidation": "Invalid rules",
|
||||||
|
"rulesErrorValidationRuleDescription": "Rule {ruleNumber}: {message}",
|
||||||
|
"rulesErrorInvalidMatchTypeDescription": "Select a valid match type (path, IP, CIDR, country, region, or ASN).",
|
||||||
|
"rulesErrorValueRequired": "Enter a value for this rule.",
|
||||||
|
"rulesErrorInvalidCountry": "Invalid country",
|
||||||
|
"rulesErrorInvalidCountryDescription": "Select a valid country.",
|
||||||
|
"rulesErrorInvalidAsn": "Invalid ASN",
|
||||||
|
"rulesErrorInvalidAsnDescription": "Enter a valid ASN (e.g., AS15169).",
|
||||||
"ruleUpdated": "Rules updated",
|
"ruleUpdated": "Rules updated",
|
||||||
"ruleUpdatedDescription": "Rules updated successfully",
|
"ruleUpdatedDescription": "Rules updated successfully",
|
||||||
"ruleErrorUpdate": "Operation failed",
|
"ruleErrorUpdate": "Operation failed",
|
||||||
"ruleErrorUpdateDescription": "An error occurred during the save operation",
|
"ruleErrorUpdateDescription": "An error occurred during the save operation",
|
||||||
"rulesPriority": "Priority",
|
"rulesPriority": "Priority",
|
||||||
|
"rulesReorderDragHandle": "Drag to reorder rule priority",
|
||||||
"rulesAction": "Action",
|
"rulesAction": "Action",
|
||||||
"rulesMatchType": "Match Type",
|
"rulesMatchType": "Match Type",
|
||||||
"value": "Value",
|
"value": "Value",
|
||||||
@@ -792,7 +809,7 @@
|
|||||||
"rulesResource": "Resource Rules Configuration",
|
"rulesResource": "Resource Rules Configuration",
|
||||||
"rulesResourceDescription": "Configure rules to control access to the resource",
|
"rulesResourceDescription": "Configure rules to control access to the resource",
|
||||||
"ruleSubmit": "Add Rule",
|
"ruleSubmit": "Add Rule",
|
||||||
"rulesNoOne": "No rules. Add a rule using the form.",
|
"rulesNoOne": "No rules yet.",
|
||||||
"rulesOrder": "Rules are evaluated by priority in ascending order.",
|
"rulesOrder": "Rules are evaluated by priority in ascending order.",
|
||||||
"rulesSubmit": "Save Rules",
|
"rulesSubmit": "Save Rules",
|
||||||
"policyErrorCreate": "Error creating policy",
|
"policyErrorCreate": "Error creating policy",
|
||||||
@@ -803,7 +820,48 @@
|
|||||||
"policyErrorUpdateMessageDescription": "An unexpected error occurred",
|
"policyErrorUpdateMessageDescription": "An unexpected error occurred",
|
||||||
"policyCreatedSuccess": "Resource policy succesfully created",
|
"policyCreatedSuccess": "Resource policy succesfully created",
|
||||||
"policyUpdatedSuccess": "Resource policy succesfully updated",
|
"policyUpdatedSuccess": "Resource policy succesfully updated",
|
||||||
"authMethodsSave": "Save auth methods",
|
"authMethodsSave": "Save Settings",
|
||||||
|
"policyAuthStackTitle": "Authentication",
|
||||||
|
"policyAuthStackDescription": "Control which authentication methods are required to access this resource",
|
||||||
|
"policyAuthOrLogicTitle": "Multiple authentication methods active",
|
||||||
|
"policyAuthOrLogicBanner": "Visitors may authenticate using any one of the active methods below. They do not need to complete all of them.",
|
||||||
|
"policyAuthMethodActive": "Active",
|
||||||
|
"policyAuthMethodOff": "Off",
|
||||||
|
"policyAuthSsoTitle": "Platform SSO",
|
||||||
|
"policyAuthSsoDescription": "Require sign-in through your organization's identity provider",
|
||||||
|
"policyAuthSsoSummary": "{idp} · {users} users, {roles} roles",
|
||||||
|
"policyAuthSsoDefaultIdp": "Default provider",
|
||||||
|
"policyAuthAddDefaultIdentityProvider": "Add Default Identity Provider",
|
||||||
|
"policyAuthOtherMethodsTitle": "Other Methods",
|
||||||
|
"policyAuthOtherMethodsDescription": "Optional methods visitors can use instead of or alongside platform SSO",
|
||||||
|
"policyAuthPasscodeTitle": "Passcode",
|
||||||
|
"policyAuthPasscodeDescription": "Require a shared alphanumeric passcode to access the resource",
|
||||||
|
"policyAuthPasscodeSummary": "Passcode set",
|
||||||
|
"policyAuthPincodeTitle": "PIN Code",
|
||||||
|
"policyAuthPincodeDescription": "A short numeric code required to access the resource",
|
||||||
|
"policyAuthPincodeSummary": "6-digit PIN set",
|
||||||
|
"policyAuthEmailTitle": "Email Whitelist",
|
||||||
|
"policyAuthEmailDescription": "Allow listed email addresses with one-time passwords",
|
||||||
|
"policyAuthEmailSummary": "{count} addresses allowed",
|
||||||
|
"policyAuthEmailOtpCallout": "Enabling email whitelist sends a one-time password to the visitor's email on login.",
|
||||||
|
"policyAuthHeaderAuthTitle": "Basic Header Auth",
|
||||||
|
"policyAuthHeaderAuthDescription": "Validate a custom HTTP header name and value on each request",
|
||||||
|
"policyAuthHeaderAuthSummary": "Header configured",
|
||||||
|
"policyAuthHeaderName": "Header name",
|
||||||
|
"policyAuthHeaderValue": "Expected value",
|
||||||
|
"policyAuthSetPasscode": "Set Passcode",
|
||||||
|
"policyAuthSetPincode": "Set PIN Code",
|
||||||
|
"policyAuthSetEmailWhitelist": "Set Email Whitelist",
|
||||||
|
"policyAuthSetHeaderAuth": "Set Basic Header Auth",
|
||||||
|
"policyAccessRulesTitle": "Access Rules",
|
||||||
|
"policyAccessRulesEnableDescription": "When enabled, rules are evaluated in descending order until one evaluates as true.",
|
||||||
|
"policyAccessRulesFirstMatch": "Rules are evaluated top to bottom. The first matching rule decides the outcome.",
|
||||||
|
"policyAccessRulesHowItWorks": "Rules match requests by path, IP address, location, or other criteria. Each rule applies an action: bypass authentication, block access, or pass to authentication. If no rule matches, traffic continues to authentication.",
|
||||||
|
"policyAccessRulesFallthroughOff": "When rules are disabled, all traffic passes through to authentication.",
|
||||||
|
"policyAccessRulesFallthroughOn": "When no rule matches, traffic passes through to authentication.",
|
||||||
|
"rulesPlaceholderCidr": "10.0.0.0/8",
|
||||||
|
"rulesPlaceholderPath": "/admin/*",
|
||||||
|
"rulesPlaceholderGeo": "RU, KP",
|
||||||
"rulesSave": "Save Rules",
|
"rulesSave": "Save Rules",
|
||||||
"resourceErrorCreate": "Error creating resource",
|
"resourceErrorCreate": "Error creating resource",
|
||||||
"resourceErrorCreateDescription": "An error occurred when creating the resource",
|
"resourceErrorCreateDescription": "An error occurred when creating the resource",
|
||||||
@@ -824,9 +882,9 @@
|
|||||||
"resourcesErrorUpdateDescription": "An error occurred while updating the resource",
|
"resourcesErrorUpdateDescription": "An error occurred while updating the resource",
|
||||||
"access": "Access",
|
"access": "Access",
|
||||||
"accessControl": "Access Control",
|
"accessControl": "Access Control",
|
||||||
"shareLink": "{resource} Share Link",
|
"shareLink": "{resource} Shareable Link",
|
||||||
"resourceSelect": "Select resource",
|
"resourceSelect": "Select resource",
|
||||||
"shareLinks": "Share Links",
|
"shareLinks": "Shareable Links",
|
||||||
"share": "Shareable Links",
|
"share": "Shareable Links",
|
||||||
"shareDescription2": "Create shareable links to resources. Links provide temporary or unlimited access to your resource. You can configure the expiration duration of the link when you create one.",
|
"shareDescription2": "Create shareable links to resources. Links provide temporary or unlimited access to your resource. You can configure the expiration duration of the link when you create one.",
|
||||||
"shareEasyCreate": "Easy to create and share",
|
"shareEasyCreate": "Easy to create and share",
|
||||||
@@ -916,10 +974,16 @@
|
|||||||
"resourceRoleDescription": "Admins can always access this resource.",
|
"resourceRoleDescription": "Admins can always access this resource.",
|
||||||
"resourcePolicySelectTitle": "Resource Access Policy",
|
"resourcePolicySelectTitle": "Resource Access Policy",
|
||||||
"resourcePolicySelectDescription": "Select the resource policy type for authentication",
|
"resourcePolicySelectDescription": "Select the resource policy type for authentication",
|
||||||
|
"resourcePolicyTypeLabel": "Policy type",
|
||||||
|
"resourcePolicyLabel": "Resource policy",
|
||||||
"resourcePolicyInline": "Inline Resource Policy",
|
"resourcePolicyInline": "Inline Resource Policy",
|
||||||
"resourcePolicyInlineDescription": "Access Policy scoped to only this resource",
|
"resourcePolicyInlineDescription": "Access Policy scoped to only this resource",
|
||||||
"resourcePolicyShared": "Shared Resource Policy",
|
"resourcePolicyShared": "Shared Resource Policy",
|
||||||
"resourcePolicySharedDescription": "This resource uses a shared policy. Policy-level settings (auth methods, email whitelist) are locked. You can add resource-specific rules, roles, and users below.",
|
"resourcePolicySharedDescription": "This resource uses a shared policy.",
|
||||||
|
"sharedPolicy": "Shared Policy",
|
||||||
|
"sharedPolicyNoneDescription": "This resource has its own policy.",
|
||||||
|
"resourceSharedPolicyAuthenticationNotice": "This resource is using a shared policy. Some authentication settings can be edited on this resource. To change the underlying policy, you must edit to <policyLink>{policyName}</policyLink>.",
|
||||||
|
"resourceSharedPolicyRulesNotice": "This resource is using a shared policy. Some access rules can be edited on this resource. To change the underlying policy, you must edit <policyLink>{policyName}</policyLink>.",
|
||||||
"resourceUsersRoles": "Access Controls",
|
"resourceUsersRoles": "Access Controls",
|
||||||
"resourceUsersRolesDescription": "Configure which users and roles can visit this resource",
|
"resourceUsersRolesDescription": "Configure which users and roles can visit this resource",
|
||||||
"resourceUsersRolesSubmit": "Save Access Controls",
|
"resourceUsersRolesSubmit": "Save Access Controls",
|
||||||
@@ -1220,11 +1284,14 @@
|
|||||||
"addLabels": "Add labels",
|
"addLabels": "Add labels",
|
||||||
"siteLabelsTab": "Labels",
|
"siteLabelsTab": "Labels",
|
||||||
"siteLabelsDescription": "Manage labels associated with this site.",
|
"siteLabelsDescription": "Manage labels associated with this site.",
|
||||||
"labelsNotFound": "Labels not found",
|
"labelsNotFound": "No labels found.",
|
||||||
|
"labelsEmptyCreateHint": "Start typing above to create a label.",
|
||||||
"labelSearch": "Search labels",
|
"labelSearch": "Search labels",
|
||||||
|
"labelSearchOrCreate": "Search or create a label",
|
||||||
"accessLabelFilterCount": "{count, plural, one {# label} other {# labels}}",
|
"accessLabelFilterCount": "{count, plural, one {# label} other {# labels}}",
|
||||||
"labelOverflowCount": "+{count, plural, one {# label} other {# labels}}",
|
"labelOverflowCount": "+{count, plural, one {# label} other {# labels}}",
|
||||||
"accessLabelFilterClear": "Clear label filters",
|
"accessLabelFilterClear": "Clear label filters",
|
||||||
|
"accessFilterClear": "Clear filters",
|
||||||
"selectColor": "Select color",
|
"selectColor": "Select color",
|
||||||
"createNewLabel": "Create new org label \"{label}\"",
|
"createNewLabel": "Create new org label \"{label}\"",
|
||||||
"inviteInvalidDescription": "The invite link is invalid.",
|
"inviteInvalidDescription": "The invite link is invalid.",
|
||||||
@@ -1461,8 +1528,8 @@
|
|||||||
"sidebarResources": "Resources",
|
"sidebarResources": "Resources",
|
||||||
"sidebarProxyResources": "Public",
|
"sidebarProxyResources": "Public",
|
||||||
"sidebarClientResources": "Private",
|
"sidebarClientResources": "Private",
|
||||||
"sidebarPolicies": "Policies",
|
"sidebarPolicies": "Shared Policies",
|
||||||
"sidebarResourcePolicies": "Resources",
|
"sidebarResourcePolicies": "Public Resources",
|
||||||
"sidebarAccessControl": "Access Control",
|
"sidebarAccessControl": "Access Control",
|
||||||
"sidebarLogsAndAnalytics": "Logs & Analytics",
|
"sidebarLogsAndAnalytics": "Logs & Analytics",
|
||||||
"sidebarTeam": "Team",
|
"sidebarTeam": "Team",
|
||||||
@@ -1470,7 +1537,7 @@
|
|||||||
"sidebarAdmin": "Admin",
|
"sidebarAdmin": "Admin",
|
||||||
"sidebarInvitations": "Invitations",
|
"sidebarInvitations": "Invitations",
|
||||||
"sidebarRoles": "Roles",
|
"sidebarRoles": "Roles",
|
||||||
"sidebarShareableLinks": "Links",
|
"sidebarShareableLinks": "Shareable Links",
|
||||||
"sidebarApiKeys": "API Keys",
|
"sidebarApiKeys": "API Keys",
|
||||||
"sidebarProvisioning": "Provisioning",
|
"sidebarProvisioning": "Provisioning",
|
||||||
"sidebarSettings": "Settings",
|
"sidebarSettings": "Settings",
|
||||||
@@ -1647,7 +1714,7 @@
|
|||||||
"standaloneHcFilterResourceIdFallback": "Resource {id}",
|
"standaloneHcFilterResourceIdFallback": "Resource {id}",
|
||||||
"blueprints": "Blueprints",
|
"blueprints": "Blueprints",
|
||||||
"blueprintsLog": "Blueprints Log",
|
"blueprintsLog": "Blueprints Log",
|
||||||
"blueprintsDescription": "View past blueprint applications and their results",
|
"blueprintsDescription": "View past blueprint applications and their results or apply a new blueprint",
|
||||||
"blueprintAdd": "Add Blueprint",
|
"blueprintAdd": "Add Blueprint",
|
||||||
"blueprintGoBack": "See all Blueprints",
|
"blueprintGoBack": "See all Blueprints",
|
||||||
"blueprintCreate": "Create Blueprint",
|
"blueprintCreate": "Create Blueprint",
|
||||||
@@ -2027,13 +2094,13 @@
|
|||||||
"healthCheckUnknown": "Unknown",
|
"healthCheckUnknown": "Unknown",
|
||||||
"healthCheck": "Health Check",
|
"healthCheck": "Health Check",
|
||||||
"configureHealthCheck": "Configure Health Check",
|
"configureHealthCheck": "Configure Health Check",
|
||||||
"configureHealthCheckDescription": "Set up health monitoring for {target}",
|
"configureHealthCheckDescription": "Set up monitoring for your resource to ensure it is always available",
|
||||||
"enableHealthChecks": "Enable Health Checks",
|
"enableHealthChecks": "Enable Health Checks",
|
||||||
"healthCheckDisabledStateDescription": "When disabled, the site will not perform health checks and the state will be considered unknown.",
|
"healthCheckDisabledStateDescription": "When disabled, the site will not perform health checks and the state will be considered unknown.",
|
||||||
"enableHealthChecksDescription": "Monitor the health of this target. You can monitor a different endpoint than the target if required.",
|
"enableHealthChecksDescription": "Monitor the health of this target. You can monitor a different endpoint than the target if required.",
|
||||||
"healthScheme": "Method",
|
"healthScheme": "Method",
|
||||||
"healthSelectScheme": "Select Method",
|
"healthSelectScheme": "Select Method",
|
||||||
"healthCheckPortInvalid": "Health check port must be between 1 and 65535",
|
"healthCheckPortInvalid": "Port must be between 1 and 65535",
|
||||||
"healthCheckPath": "Path",
|
"healthCheckPath": "Path",
|
||||||
"healthHostname": "IP / Host",
|
"healthHostname": "IP / Host",
|
||||||
"healthPort": "Port",
|
"healthPort": "Port",
|
||||||
@@ -2073,8 +2140,13 @@
|
|||||||
"sshDaemonDisclaimer": "Ensure your target host is properly configured to run the auth daemon before completing this setup, or provisioning will fail.",
|
"sshDaemonDisclaimer": "Ensure your target host is properly configured to run the auth daemon before completing this setup, or provisioning will fail.",
|
||||||
"sshDaemonPort": "Daemon Port",
|
"sshDaemonPort": "Daemon Port",
|
||||||
"sshServerDestination": "Server Destination",
|
"sshServerDestination": "Server Destination",
|
||||||
"sshServerDestinationDescription": "Configure the destination and port of the SSH server",
|
"sshServerDestinationDescription": "Configure the destination of the SSH server",
|
||||||
"destination": "Destination",
|
"destination": "Destination",
|
||||||
|
"destinationRequired": "Destination is required.",
|
||||||
|
"domainRequired": "Domain is required.",
|
||||||
|
"proxyPortRequired": "Port is required.",
|
||||||
|
"invalidPathConfiguration": "Invalid path configuration.",
|
||||||
|
"invalidRewritePathConfiguration": "Invalid rewrite path configuration.",
|
||||||
"bgTargetMultiSiteDisclaimer": "Selecting multiple sites enables resilient routing and failover for high availability.",
|
"bgTargetMultiSiteDisclaimer": "Selecting multiple sites enables resilient routing and failover for high availability.",
|
||||||
"roleAllowSsh": "Allow SSH",
|
"roleAllowSsh": "Allow SSH",
|
||||||
"roleAllowSshAllow": "Allow",
|
"roleAllowSshAllow": "Allow",
|
||||||
@@ -2089,10 +2161,25 @@
|
|||||||
"sshSudoModeCommandsDescription": "User can run only the specified commands with sudo.",
|
"sshSudoModeCommandsDescription": "User can run only the specified commands with sudo.",
|
||||||
"sshSudo": "Allow sudo",
|
"sshSudo": "Allow sudo",
|
||||||
"sshSudoCommands": "Sudo Commands",
|
"sshSudoCommands": "Sudo Commands",
|
||||||
"sshSudoCommandsDescription": "Comma separated list of commands the user is allowed to run with sudo. Absolute paths must be used.",
|
"sshSudoCommandsDescription": "List of commands the user is allowed to run with sudo, separated by commas, spaces, or new lines. Absolute paths must be used.",
|
||||||
"sshCreateHomeDir": "Create Home Directory",
|
"sshCreateHomeDir": "Create Home Directory",
|
||||||
"sshUnixGroups": "Unix Groups",
|
"sshUnixGroups": "Unix Groups",
|
||||||
"sshUnixGroupsDescription": "Comma separated Unix groups to add the user to on the target host.",
|
"sshUnixGroupsDescription": "Unix groups to add the user to on the target host, separated by commas, spaces, or new lines.",
|
||||||
|
"roleTextFieldPlaceholder": "Enter values, or drop a .txt or .csv file",
|
||||||
|
"roleTextImportTitle": "Import from File",
|
||||||
|
"roleTextImportDescription": "Importing {fileName} into {fieldLabel}.",
|
||||||
|
"roleTextImportSkipHeader": "Skip First Row (Header)",
|
||||||
|
"roleTextImportOverride": "Replace Existing",
|
||||||
|
"roleTextImportAppend": "Append to Existing",
|
||||||
|
"roleTextImportMode": "Import Mode",
|
||||||
|
"roleTextImportPreview": "Preview",
|
||||||
|
"roleTextImportItemCount": "{count, plural, =0 {No items to import} one {1 item to import} other {# items to import}}",
|
||||||
|
"roleTextImportTotalCount": "{existing} existing + {imported} imported = {total} total",
|
||||||
|
"roleTextImportConfirm": "Import",
|
||||||
|
"roleTextImportInvalidFile": "Unsupported file type",
|
||||||
|
"roleTextImportInvalidFileDescription": "Only .txt and .csv files are supported.",
|
||||||
|
"roleTextImportEmpty": "No items found in file",
|
||||||
|
"roleTextImportEmptyDescription": "The file does not contain any importable items.",
|
||||||
"retryAttempts": "Retry Attempts",
|
"retryAttempts": "Retry Attempts",
|
||||||
"expectedResponseCodes": "Expected Response Codes",
|
"expectedResponseCodes": "Expected Response Codes",
|
||||||
"expectedResponseCodesDescription": "HTTP status code that indicates healthy status. If left blank, 200-300 is considered healthy.",
|
"expectedResponseCodesDescription": "HTTP status code that indicates healthy status. If left blank, 200-300 is considered healthy.",
|
||||||
@@ -3035,7 +3122,7 @@
|
|||||||
"enterConfirmation": "Enter confirmation",
|
"enterConfirmation": "Enter confirmation",
|
||||||
"blueprintViewDetails": "Details",
|
"blueprintViewDetails": "Details",
|
||||||
"defaultIdentityProvider": "Default Identity Provider",
|
"defaultIdentityProvider": "Default Identity Provider",
|
||||||
"defaultIdentityProviderDescription": "When a default identity provider is selected, the user will be automatically redirected to the provider for authentication.",
|
"defaultIdentityProviderDescription": "The user will be automatically redirected to this identity provider for authentication.",
|
||||||
"editInternalResourceDialogNetworkSettings": "Network Settings",
|
"editInternalResourceDialogNetworkSettings": "Network Settings",
|
||||||
"editInternalResourceDialogAccessPolicy": "Access Policy",
|
"editInternalResourceDialogAccessPolicy": "Access Policy",
|
||||||
"editInternalResourceDialogAddRoles": "Add Roles",
|
"editInternalResourceDialogAddRoles": "Add Roles",
|
||||||
@@ -3076,6 +3163,7 @@
|
|||||||
"maintenanceModeType": "Maintenance Mode Type",
|
"maintenanceModeType": "Maintenance Mode Type",
|
||||||
"showMaintenancePage": "Show a maintenance page to visitors",
|
"showMaintenancePage": "Show a maintenance page to visitors",
|
||||||
"enableMaintenanceMode": "Enable Maintenance Mode",
|
"enableMaintenanceMode": "Enable Maintenance Mode",
|
||||||
|
"enableMaintenanceModeDescription": "When enabled, visitors will see a maintenance page instead of your resource.",
|
||||||
"automatic": "Automatic",
|
"automatic": "Automatic",
|
||||||
"automaticModeDescription": " Show maintenance page only when all backend targets are down or unhealthy. Your resource continues working normally as long as at least one target is healthy.",
|
"automaticModeDescription": " Show maintenance page only when all backend targets are down or unhealthy. Your resource continues working normally as long as at least one target is healthy.",
|
||||||
"forced": "Forced",
|
"forced": "Forced",
|
||||||
@@ -3455,5 +3543,45 @@
|
|||||||
"sshErrorNoTarget": "No target specified",
|
"sshErrorNoTarget": "No target specified",
|
||||||
"sshErrorWebSocket": "WebSocket connection failed",
|
"sshErrorWebSocket": "WebSocket connection failed",
|
||||||
"sshErrorAuthFailed": "Authentication failed",
|
"sshErrorAuthFailed": "Authentication failed",
|
||||||
"sshErrorConnectionClosed": "Connection closed before authentication completed"
|
"sshErrorConnectionClosed": "Connection closed before authentication completed",
|
||||||
|
"sitePangolinSshDescription": "Allow SSH access to resources on this site. This can be changed later.",
|
||||||
|
"browserGatewayNoResourceForDomain": "No resource found for this domain",
|
||||||
|
"browserGatewayNoTarget": "No target",
|
||||||
|
"browserGatewayConnect": "Connect",
|
||||||
|
"browserGatewayCtrlAltDel": "Ctrl+Alt+Del",
|
||||||
|
"sshErrorSignKeyFailed": "Failed to sign SSH key for PAM push authentication. Did you sign in as a user?",
|
||||||
|
"sshTerminalError": "Error: {error}",
|
||||||
|
"sshConnectionClosedCode": "Connection closed (code {code})",
|
||||||
|
"sshPrivateKeyPlaceholder": "-----BEGIN OPENSSH PRIVATE KEY-----",
|
||||||
|
"sshPrivateKeyRequired": "Private key is required",
|
||||||
|
"vncTitle": "VNC",
|
||||||
|
"vncSignInDescription": "Enter your VNC password to connect",
|
||||||
|
"vncPasswordOptional": "Password (optional)",
|
||||||
|
"vncNoResourceTarget": "No resource target is available",
|
||||||
|
"vncFailedToLoadNovnc": "Failed to load noVNC",
|
||||||
|
"vncAuthFailedStatus": "Status {status}",
|
||||||
|
"vncPasteClipboard": "Paste clipboard",
|
||||||
|
"rdpTitle": "RDP",
|
||||||
|
"rdpSignInTitle": "Sign in to Remote Desktop",
|
||||||
|
"rdpSignInDescription": "Enter Windows credentials to connect",
|
||||||
|
"rdpLoadingModule": "Loading module...",
|
||||||
|
"rdpFailedToLoadModule": "Failed to load RDP module",
|
||||||
|
"rdpNotReady": "Not ready",
|
||||||
|
"rdpModuleInitializing": "RDP module is still initializing",
|
||||||
|
"rdpDownloadingFiles": "Downloading {count} file(s) from remote…",
|
||||||
|
"rdpDownloadFailed": "Download failed: {fileName}",
|
||||||
|
"rdpUploaded": "Uploaded: {fileName}",
|
||||||
|
"rdpNoConnectionTarget": "No connection target available",
|
||||||
|
"rdpConnectionFailed": "Connection failed",
|
||||||
|
"rdpFit": "Fit",
|
||||||
|
"rdpFull": "Full",
|
||||||
|
"rdpReal": "Real",
|
||||||
|
"rdpMeta": "Meta",
|
||||||
|
"rdpUploadFiles": "Upload files",
|
||||||
|
"rdpFilesReadyToPaste": "Files ready to paste",
|
||||||
|
"rdpFilesReadyToPasteDescription": "{count} file(s) copied to remote clipboard — press Ctrl+V on the remote desktop to paste.",
|
||||||
|
"rdpUploadFailed": "Upload failed",
|
||||||
|
"rdpUnicodeKeyboardMode": "Unicode keyboard mode",
|
||||||
|
"sessionToolbarShow": "Show toolbar",
|
||||||
|
"sessionToolbarHide": "Hide toolbar"
|
||||||
}
|
}
|
||||||
|
|||||||
+2
-2
@@ -200,8 +200,8 @@
|
|||||||
"shareErrorSelectResource": "Por favor, seleccione un recurso",
|
"shareErrorSelectResource": "Por favor, seleccione un recurso",
|
||||||
"proxyResourceTitle": "Administrar recursos públicos",
|
"proxyResourceTitle": "Administrar recursos públicos",
|
||||||
"proxyResourceDescription": "Crear y administrar recursos que sean accesibles públicamente a través de un navegador web",
|
"proxyResourceDescription": "Crear y administrar recursos que sean accesibles públicamente a través de un navegador web",
|
||||||
"proxyResourcesBannerTitle": "Acceso público basado en web",
|
"publicResourcesBannerTitle": "Acceso público basado en web",
|
||||||
"proxyResourcesBannerDescription": "Los recursos públicos son proxies HTTPS o TCP/UDP accesibles a cualquiera en Internet a través de un navegador web. A diferencia de los recursos privados, no requieren software del lado del cliente e incluye políticas de acceso basadas en identidad y contexto.",
|
"publicResourcesBannerDescription": "Los recursos públicos son proxies HTTPS o TCP/UDP accesibles a cualquiera en Internet a través de un navegador web. A diferencia de los recursos privados, no requieren software del lado del cliente e incluye políticas de acceso basadas en identidad y contexto.",
|
||||||
"clientResourceTitle": "Administrar recursos privados",
|
"clientResourceTitle": "Administrar recursos privados",
|
||||||
"clientResourceDescription": "Crear y administrar recursos que sólo son accesibles a través de un cliente conectado",
|
"clientResourceDescription": "Crear y administrar recursos que sólo son accesibles a través de un cliente conectado",
|
||||||
"privateResourcesBannerTitle": "Acceso privado de confianza cero",
|
"privateResourcesBannerTitle": "Acceso privado de confianza cero",
|
||||||
|
|||||||
+2
-2
@@ -200,8 +200,8 @@
|
|||||||
"shareErrorSelectResource": "Veuillez sélectionner une ressource",
|
"shareErrorSelectResource": "Veuillez sélectionner une ressource",
|
||||||
"proxyResourceTitle": "Gérer les ressources publiques",
|
"proxyResourceTitle": "Gérer les ressources publiques",
|
||||||
"proxyResourceDescription": "Créer et gérer des ressources accessibles au public via un navigateur web",
|
"proxyResourceDescription": "Créer et gérer des ressources accessibles au public via un navigateur web",
|
||||||
"proxyResourcesBannerTitle": "Accès public basé sur le Web",
|
"publicResourcesBannerTitle": "Accès public basé sur le Web",
|
||||||
"proxyResourcesBannerDescription": "Les ressources publiques sont des proxys HTTPS ou TCP/UDP accessibles par tout le monde sur Internet via un navigateur Web. Contrairement aux ressources privées, elles n'exigent pas de logiciel côté client et peuvent inclure des politiques d'accès basées sur l'identité et le contexte.",
|
"publicResourcesBannerDescription": "Les ressources publiques sont des proxys HTTPS ou TCP/UDP accessibles par tout le monde sur Internet via un navigateur Web. Contrairement aux ressources privées, elles n'exigent pas de logiciel côté client et peuvent inclure des politiques d'accès basées sur l'identité et le contexte.",
|
||||||
"clientResourceTitle": "Gérer les ressources privées",
|
"clientResourceTitle": "Gérer les ressources privées",
|
||||||
"clientResourceDescription": "Créer et gérer des ressources qui ne sont accessibles que via un client connecté",
|
"clientResourceDescription": "Créer et gérer des ressources qui ne sont accessibles que via un client connecté",
|
||||||
"privateResourcesBannerTitle": "Accès privé sans confiance",
|
"privateResourcesBannerTitle": "Accès privé sans confiance",
|
||||||
|
|||||||
+2
-2
@@ -200,8 +200,8 @@
|
|||||||
"shareErrorSelectResource": "Seleziona una risorsa",
|
"shareErrorSelectResource": "Seleziona una risorsa",
|
||||||
"proxyResourceTitle": "Gestisci Risorse Pubbliche",
|
"proxyResourceTitle": "Gestisci Risorse Pubbliche",
|
||||||
"proxyResourceDescription": "Creare e gestire risorse pubbliche accessibili tramite un browser web",
|
"proxyResourceDescription": "Creare e gestire risorse pubbliche accessibili tramite un browser web",
|
||||||
"proxyResourcesBannerTitle": "Accesso Pubblico Basato sul Web",
|
"publicResourcesBannerTitle": "Accesso Pubblico Basato sul Web",
|
||||||
"proxyResourcesBannerDescription": "Le risorse pubbliche sono proxy HTTPS o TCP/UDP accessibili da chiunque tramite Internet da un browser web. A differenza delle risorse private non richiedono software lato client e possono includere politiche di accesso basate su identità e contesto.",
|
"publicResourcesBannerDescription": "Le risorse pubbliche sono proxy HTTPS o TCP/UDP accessibili da chiunque tramite Internet da un browser web. A differenza delle risorse private non richiedono software lato client e possono includere politiche di accesso basate su identità e contesto.",
|
||||||
"clientResourceTitle": "Gestisci Risorse Private",
|
"clientResourceTitle": "Gestisci Risorse Private",
|
||||||
"clientResourceDescription": "Crea e gestisci risorse accessibili solo tramite un client connesso",
|
"clientResourceDescription": "Crea e gestisci risorse accessibili solo tramite un client connesso",
|
||||||
"privateResourcesBannerTitle": "Accesso Privato Zero-Trust",
|
"privateResourcesBannerTitle": "Accesso Privato Zero-Trust",
|
||||||
|
|||||||
+2
-2
@@ -200,8 +200,8 @@
|
|||||||
"shareErrorSelectResource": "리소스를 선택하세요",
|
"shareErrorSelectResource": "리소스를 선택하세요",
|
||||||
"proxyResourceTitle": "공개 리소스 관리",
|
"proxyResourceTitle": "공개 리소스 관리",
|
||||||
"proxyResourceDescription": "웹 브라우저를 통해 공용으로 접근할 수 있는 리소스를 생성하고 관리하세요.",
|
"proxyResourceDescription": "웹 브라우저를 통해 공용으로 접근할 수 있는 리소스를 생성하고 관리하세요.",
|
||||||
"proxyResourcesBannerTitle": "웹 기반 공공 접근",
|
"publicResourcesBannerTitle": "웹 기반 공공 접근",
|
||||||
"proxyResourcesBannerDescription": "공공 자원은 누구나 웹 브라우저를 통해 접근 가능한 HTTPS 또는 TCP/UDP 프록시입니다. 개인 자원과 달리 클라이언트 측 소프트웨어가 필요하지 않으며, 아이덴티티 및 컨텍스트 인지 접근 정책을 포함할 수 있습니다.",
|
"publicResourcesBannerDescription": "공공 자원은 누구나 웹 브라우저를 통해 접근 가능한 HTTPS 또는 TCP/UDP 프록시입니다. 개인 자원과 달리 클라이언트 측 소프트웨어가 필요하지 않으며, 아이덴티티 및 컨텍스트 인지 접근 정책을 포함할 수 있습니다.",
|
||||||
"clientResourceTitle": "개인 리소스 관리",
|
"clientResourceTitle": "개인 리소스 관리",
|
||||||
"clientResourceDescription": "연결된 클라이언트를 통해서만 접근할 수 있는 리소스를 생성하고 관리하세요.",
|
"clientResourceDescription": "연결된 클라이언트를 통해서만 접근할 수 있는 리소스를 생성하고 관리하세요.",
|
||||||
"privateResourcesBannerTitle": "제로 트러스트 개인 접근",
|
"privateResourcesBannerTitle": "제로 트러스트 개인 접근",
|
||||||
|
|||||||
+2
-2
@@ -200,8 +200,8 @@
|
|||||||
"shareErrorSelectResource": "Vennligst velg en ressurs",
|
"shareErrorSelectResource": "Vennligst velg en ressurs",
|
||||||
"proxyResourceTitle": "Administrere offentlige ressurser",
|
"proxyResourceTitle": "Administrere offentlige ressurser",
|
||||||
"proxyResourceDescription": "Opprett og administrer ressurser som er offentlig tilgjengelige via en nettleser",
|
"proxyResourceDescription": "Opprett og administrer ressurser som er offentlig tilgjengelige via en nettleser",
|
||||||
"proxyResourcesBannerTitle": "Nettbasert offentlig tilgang",
|
"publicResourcesBannerTitle": "Nettbasert offentlig tilgang",
|
||||||
"proxyResourcesBannerDescription": "Offentlige ressurser er HTTPS- eller TCP/UDP-proxyer tilgjengelige for alle på internett via en nettleser. I motsetning til private ressurser, krever de ikke klient-basert programvare og kan inkludere identitets- og kontekstbevisste tilgangspolicyer.",
|
"publicResourcesBannerDescription": "Offentlige ressurser er HTTPS- eller TCP/UDP-proxyer tilgjengelige for alle på internett via en nettleser. I motsetning til private ressurser, krever de ikke klient-basert programvare og kan inkludere identitets- og kontekstbevisste tilgangspolicyer.",
|
||||||
"clientResourceTitle": "Administrer private ressurser",
|
"clientResourceTitle": "Administrer private ressurser",
|
||||||
"clientResourceDescription": "Opprette og administrere ressurser som bare er tilgjengelige via en tilkoblet klient",
|
"clientResourceDescription": "Opprette og administrere ressurser som bare er tilgjengelige via en tilkoblet klient",
|
||||||
"privateResourcesBannerTitle": "Zero-Trust privat tilgang",
|
"privateResourcesBannerTitle": "Zero-Trust privat tilgang",
|
||||||
|
|||||||
+2
-2
@@ -200,8 +200,8 @@
|
|||||||
"shareErrorSelectResource": "Selecteer een bron",
|
"shareErrorSelectResource": "Selecteer een bron",
|
||||||
"proxyResourceTitle": "Openbare bronnen beheren",
|
"proxyResourceTitle": "Openbare bronnen beheren",
|
||||||
"proxyResourceDescription": "Creëer en beheer bronnen die openbaar toegankelijk zijn via een webbrowser",
|
"proxyResourceDescription": "Creëer en beheer bronnen die openbaar toegankelijk zijn via een webbrowser",
|
||||||
"proxyResourcesBannerTitle": "Webgebaseerde openbare toegang",
|
"publicResourcesBannerTitle": "Webgebaseerde openbare toegang",
|
||||||
"proxyResourcesBannerDescription": "Openbare bronnen zijn HTTPS of TCP/UDP-proxies die toegankelijk zijn voor iedereen op het internet via een webbrowser. In tegenstelling tot priv��bronnen vereisen ze geen client-side software maar kunnen ze identiteits- en context-bewuste toegangsrichtlijnen bevatten.",
|
"publicResourcesBannerDescription": "Openbare bronnen zijn HTTPS of TCP/UDP-proxies die toegankelijk zijn voor iedereen op het internet via een webbrowser. In tegenstelling tot priv��bronnen vereisen ze geen client-side software maar kunnen ze identiteits- en context-bewuste toegangsrichtlijnen bevatten.",
|
||||||
"clientResourceTitle": "Privébronnen beheren",
|
"clientResourceTitle": "Privébronnen beheren",
|
||||||
"clientResourceDescription": "Creëer en beheer bronnen die alleen toegankelijk zijn via een verbonden client",
|
"clientResourceDescription": "Creëer en beheer bronnen die alleen toegankelijk zijn via een verbonden client",
|
||||||
"privateResourcesBannerTitle": "Zero-Trust Private Access",
|
"privateResourcesBannerTitle": "Zero-Trust Private Access",
|
||||||
|
|||||||
+2
-2
@@ -200,8 +200,8 @@
|
|||||||
"shareErrorSelectResource": "Wybierz zasób",
|
"shareErrorSelectResource": "Wybierz zasób",
|
||||||
"proxyResourceTitle": "Zarządzaj zasobami publicznymi",
|
"proxyResourceTitle": "Zarządzaj zasobami publicznymi",
|
||||||
"proxyResourceDescription": "Twórz i zarządzaj zasobami, które są publicznie dostępne w przeglądarce internetowej",
|
"proxyResourceDescription": "Twórz i zarządzaj zasobami, które są publicznie dostępne w przeglądarce internetowej",
|
||||||
"proxyResourcesBannerTitle": "Publiczny dostęp za pośrednictwem sieci Web",
|
"publicResourcesBannerTitle": "Publiczny dostęp za pośrednictwem sieci Web",
|
||||||
"proxyResourcesBannerDescription": "Zasoby publiczne to proxy HTTPS lub TCP/UDP dostępne dla każdego w internecie za pośrednictwem przeglądarki internetowej. W przeciwieństwie do zasobów prywatnych, nie wymagają oprogramowania po stronie klienta i mogą obejmować polityki dostępu świadome tożsamości i kontekstu.",
|
"publicResourcesBannerDescription": "Zasoby publiczne to proxy HTTPS lub TCP/UDP dostępne dla każdego w internecie za pośrednictwem przeglądarki internetowej. W przeciwieństwie do zasobów prywatnych, nie wymagają oprogramowania po stronie klienta i mogą obejmować polityki dostępu świadome tożsamości i kontekstu.",
|
||||||
"clientResourceTitle": "Zarządzaj zasobami prywatnymi",
|
"clientResourceTitle": "Zarządzaj zasobami prywatnymi",
|
||||||
"clientResourceDescription": "Twórz i zarządzaj zasobami, które są dostępne tylko za pośrednictwem połączonego klienta",
|
"clientResourceDescription": "Twórz i zarządzaj zasobami, które są dostępne tylko za pośrednictwem połączonego klienta",
|
||||||
"privateResourcesBannerTitle": "Zero zaufania do prywatnego dostępu",
|
"privateResourcesBannerTitle": "Zero zaufania do prywatnego dostępu",
|
||||||
|
|||||||
+2
-2
@@ -200,8 +200,8 @@
|
|||||||
"shareErrorSelectResource": "Por favor, selecione um recurso",
|
"shareErrorSelectResource": "Por favor, selecione um recurso",
|
||||||
"proxyResourceTitle": "Gerenciar Recursos Públicos",
|
"proxyResourceTitle": "Gerenciar Recursos Públicos",
|
||||||
"proxyResourceDescription": "Criar e gerenciar recursos que são acessíveis publicamente por meio de um navegador da web",
|
"proxyResourceDescription": "Criar e gerenciar recursos que são acessíveis publicamente por meio de um navegador da web",
|
||||||
"proxyResourcesBannerTitle": "Acesso Público via Web",
|
"publicResourcesBannerTitle": "Acesso Público via Web",
|
||||||
"proxyResourcesBannerDescription": "Os recursos públicos são proxies HTTPS ou TCP/UDP acessíveis a qualquer pessoa na internet por meio de um navegador web. Ao contrário dos recursos privados, eles não requerem software do lado do cliente e podem incluir políticas de acesso conscientes de identidade e contexto.",
|
"publicResourcesBannerDescription": "Os recursos públicos são proxies HTTPS ou TCP/UDP acessíveis a qualquer pessoa na internet por meio de um navegador web. Ao contrário dos recursos privados, eles não requerem software do lado do cliente e podem incluir políticas de acesso conscientes de identidade e contexto.",
|
||||||
"clientResourceTitle": "Gerenciar recursos privados",
|
"clientResourceTitle": "Gerenciar recursos privados",
|
||||||
"clientResourceDescription": "Criar e gerenciar recursos que só são acessíveis por meio de um cliente conectado",
|
"clientResourceDescription": "Criar e gerenciar recursos que só são acessíveis por meio de um cliente conectado",
|
||||||
"privateResourcesBannerTitle": "Acesso Privado com Confiança Zero",
|
"privateResourcesBannerTitle": "Acesso Privado com Confiança Zero",
|
||||||
|
|||||||
+2
-2
@@ -200,8 +200,8 @@
|
|||||||
"shareErrorSelectResource": "Пожалуйста, выберите ресурс",
|
"shareErrorSelectResource": "Пожалуйста, выберите ресурс",
|
||||||
"proxyResourceTitle": "Управление публичными ресурсами",
|
"proxyResourceTitle": "Управление публичными ресурсами",
|
||||||
"proxyResourceDescription": "Создание и управление ресурсами, которые доступны через веб-браузер",
|
"proxyResourceDescription": "Создание и управление ресурсами, которые доступны через веб-браузер",
|
||||||
"proxyResourcesBannerTitle": "Общедоступный доступ через веб",
|
"publicResourcesBannerTitle": "Общедоступный доступ через веб",
|
||||||
"proxyResourcesBannerDescription": "Общедоступные ресурсы - это прокси-по HTTPS или TCP/UDP, доступные любому пользователю в Интернете через веб-браузер. В отличие от частных ресурсов, они не требуют программного обеспечения на стороне клиента и могут включать политики доступа на основе идентификации и контекста.",
|
"publicResourcesBannerDescription": "Общедоступные ресурсы - это прокси-по HTTPS или TCP/UDP, доступные любому пользователю в Интернете через веб-браузер. В отличие от частных ресурсов, они не требуют программного обеспечения на стороне клиента и могут включать политики доступа на основе идентификации и контекста.",
|
||||||
"clientResourceTitle": "Управление приватными ресурсами",
|
"clientResourceTitle": "Управление приватными ресурсами",
|
||||||
"clientResourceDescription": "Создание и управление ресурсами, которые доступны только через подключенный клиент",
|
"clientResourceDescription": "Создание и управление ресурсами, которые доступны только через подключенный клиент",
|
||||||
"privateResourcesBannerTitle": "Частный доступ с нулевым доверием",
|
"privateResourcesBannerTitle": "Частный доступ с нулевым доверием",
|
||||||
|
|||||||
+2
-2
@@ -200,8 +200,8 @@
|
|||||||
"shareErrorSelectResource": "Lütfen bir kaynak seçin",
|
"shareErrorSelectResource": "Lütfen bir kaynak seçin",
|
||||||
"proxyResourceTitle": "Herkese Açık Kaynakları Yönet",
|
"proxyResourceTitle": "Herkese Açık Kaynakları Yönet",
|
||||||
"proxyResourceDescription": "Bir web tarayıcısı aracılığıyla kamuya açık kaynaklar oluşturun ve yönetin",
|
"proxyResourceDescription": "Bir web tarayıcısı aracılığıyla kamuya açık kaynaklar oluşturun ve yönetin",
|
||||||
"proxyResourcesBannerTitle": "Web Tabanlı Genel Erişim",
|
"publicResourcesBannerTitle": "Web Tabanlı Genel Erişim",
|
||||||
"proxyResourcesBannerDescription": "Genel kaynaklar, web tarayıcısı aracılığıyla herkesin internette erişebileceği HTTPS veya TCP/UDP proxy'leridir. Özel kaynakların aksine, istemci tarafı yazılıma ihtiyaç duymazlar ve kimlik ve bağlam farkındalığı erişim politikalarını içerebilirler.",
|
"publicResourcesBannerDescription": "Genel kaynaklar, web tarayıcısı aracılığıyla herkesin internette erişebileceği HTTPS veya TCP/UDP proxy'leridir. Özel kaynakların aksine, istemci tarafı yazılıma ihtiyaç duymazlar ve kimlik ve bağlam farkındalığı erişim politikalarını içerebilirler.",
|
||||||
"clientResourceTitle": "Özel Kaynakları Yönet",
|
"clientResourceTitle": "Özel Kaynakları Yönet",
|
||||||
"clientResourceDescription": "Sadece bağlı bir istemci aracılığıyla erişilebilen kaynakları oluşturun ve yönetin",
|
"clientResourceDescription": "Sadece bağlı bir istemci aracılığıyla erişilebilen kaynakları oluşturun ve yönetin",
|
||||||
"privateResourcesBannerTitle": "Sıfır Güven Özel Erişim",
|
"privateResourcesBannerTitle": "Sıfır Güven Özel Erişim",
|
||||||
|
|||||||
+2
-2
@@ -200,8 +200,8 @@
|
|||||||
"shareErrorSelectResource": "请选择一个资源",
|
"shareErrorSelectResource": "请选择一个资源",
|
||||||
"proxyResourceTitle": "管理公共资源",
|
"proxyResourceTitle": "管理公共资源",
|
||||||
"proxyResourceDescription": "创建和管理可通过 Web 浏览器公开访问的资源",
|
"proxyResourceDescription": "创建和管理可通过 Web 浏览器公开访问的资源",
|
||||||
"proxyResourcesBannerTitle": "基于Web的公共访问",
|
"publicResourcesBannerTitle": "基于Web的公共访问",
|
||||||
"proxyResourcesBannerDescription": "公共资源是可以通过网络浏览器在互联网上任何人访问的HTTPS或TCP/UDP代理。与私人资源不同,它们不需要客户端软件,并且可以包含身份和上下文感知访问策略。",
|
"publicResourcesBannerDescription": "公共资源是可以通过网络浏览器在互联网上任何人访问的HTTPS或TCP/UDP代理。与私人资源不同,它们不需要客户端软件,并且可以包含身份和上下文感知访问策略。",
|
||||||
"clientResourceTitle": "管理私有资源",
|
"clientResourceTitle": "管理私有资源",
|
||||||
"clientResourceDescription": "创建和管理只能通过连接客户端访问的资源",
|
"clientResourceDescription": "创建和管理只能通过连接客户端访问的资源",
|
||||||
"privateResourcesBannerTitle": "零信任的私人访问",
|
"privateResourcesBannerTitle": "零信任的私人访问",
|
||||||
|
|||||||
+2
-2
@@ -152,8 +152,8 @@
|
|||||||
"shareErrorSelectResource": "請選擇一個資源",
|
"shareErrorSelectResource": "請選擇一個資源",
|
||||||
"proxyResourceTitle": "管理公開資源",
|
"proxyResourceTitle": "管理公開資源",
|
||||||
"proxyResourceDescription": "建立和管理可透過網頁瀏覽器公開存取的資源",
|
"proxyResourceDescription": "建立和管理可透過網頁瀏覽器公開存取的資源",
|
||||||
"proxyResourcesBannerTitle": "基於網頁的公開存取",
|
"publicResourcesBannerTitle": "基於網頁的公開存取",
|
||||||
"proxyResourcesBannerDescription": "公開資源是任何人都可以透過網頁瀏覽器存取的 HTTPS 或 TCP/UDP 代理。與私有資源不同,它們不需要客戶端軟體,並且可以包含基於身份和情境感知的存取策略。",
|
"publicResourcesBannerDescription": "公開資源是任何人都可以透過網頁瀏覽器存取的 HTTPS 或 TCP/UDP 代理。與私有資源不同,它們不需要客戶端軟體,並且可以包含基於身份和情境感知的存取策略。",
|
||||||
"clientResourceTitle": "管理私有資源",
|
"clientResourceTitle": "管理私有資源",
|
||||||
"clientResourceDescription": "建立和管理只能透過已連接的客戶端存取的資源",
|
"clientResourceDescription": "建立和管理只能透過已連接的客戶端存取的資源",
|
||||||
"privateResourcesBannerTitle": "零信任私有存取",
|
"privateResourcesBannerTitle": "零信任私有存取",
|
||||||
|
|||||||
@@ -580,24 +580,6 @@ export const trialNotifications = pgTable("trialNotifications", {
|
|||||||
sentAt: bigint("sentAt", { mode: "number" }).notNull()
|
sentAt: bigint("sentAt", { mode: "number" }).notNull()
|
||||||
});
|
});
|
||||||
|
|
||||||
export const browserGatewayTarget = pgTable("browserGatewayTarget", {
|
|
||||||
browserGatewayTargetId: serial("browserGatewayTargetId").primaryKey(),
|
|
||||||
resourceId: integer("resourceId")
|
|
||||||
.references(() => resources.resourceId, {
|
|
||||||
onDelete: "cascade"
|
|
||||||
})
|
|
||||||
.notNull(),
|
|
||||||
siteId: integer("siteId")
|
|
||||||
.references(() => sites.siteId, {
|
|
||||||
onDelete: "cascade"
|
|
||||||
})
|
|
||||||
.notNull(),
|
|
||||||
authToken: varchar("authToken").notNull(),
|
|
||||||
type: varchar("type").notNull(), // "ssh", "rdp", "vnc"
|
|
||||||
destination: varchar("destination").notNull(),
|
|
||||||
destinationPort: integer("destinationPort").notNull()
|
|
||||||
});
|
|
||||||
|
|
||||||
export type Approval = InferSelectModel<typeof approvals>;
|
export type Approval = InferSelectModel<typeof approvals>;
|
||||||
export type Limit = InferSelectModel<typeof limits>;
|
export type Limit = InferSelectModel<typeof limits>;
|
||||||
export type Account = InferSelectModel<typeof account>;
|
export type Account = InferSelectModel<typeof account>;
|
||||||
@@ -645,6 +627,3 @@ export type AlertEmailRecipients = InferSelectModel<
|
|||||||
>;
|
>;
|
||||||
export type AlertWebhookActions = InferSelectModel<typeof alertWebhookActions>;
|
export type AlertWebhookActions = InferSelectModel<typeof alertWebhookActions>;
|
||||||
export type TrialNotification = InferSelectModel<typeof trialNotifications>;
|
export type TrialNotification = InferSelectModel<typeof trialNotifications>;
|
||||||
export type BrowserGatewayTarget = InferSelectModel<
|
|
||||||
typeof browserGatewayTarget
|
|
||||||
>;
|
|
||||||
|
|||||||
@@ -290,7 +290,12 @@ export const targets = pgTable("targets", {
|
|||||||
pathMatchType: text("pathMatchType"), // exact, prefix, regex
|
pathMatchType: text("pathMatchType"), // exact, prefix, regex
|
||||||
rewritePath: text("rewritePath"), // if set, rewrites the path to this value before sending to the target
|
rewritePath: text("rewritePath"), // if set, rewrites the path to this value before sending to the target
|
||||||
rewritePathType: text("rewritePathType"), // exact, prefix, regex, stripPrefix
|
rewritePathType: text("rewritePathType"), // exact, prefix, regex, stripPrefix
|
||||||
priority: integer("priority").notNull().default(100)
|
priority: integer("priority").notNull().default(100),
|
||||||
|
mode: varchar("mode")
|
||||||
|
.$type<"http" | "tcp" | "udp" | "ssh" | "rdp" | "vnc">()
|
||||||
|
.notNull()
|
||||||
|
.default("http"),
|
||||||
|
authToken: varchar("authToken")
|
||||||
});
|
});
|
||||||
|
|
||||||
export const targetHealthCheck = pgTable("targetHealthCheck", {
|
export const targetHealthCheck = pgTable("targetHealthCheck", {
|
||||||
@@ -886,7 +891,9 @@ export const resourcePolicyRules = pgTable("resourcePolicyRules", {
|
|||||||
enabled: boolean("enabled").notNull().default(true),
|
enabled: boolean("enabled").notNull().default(true),
|
||||||
priority: integer("priority").notNull(),
|
priority: integer("priority").notNull(),
|
||||||
action: varchar("action").$type<"ACCEPT" | "DROP" | "PASS">().notNull(),
|
action: varchar("action").$type<"ACCEPT" | "DROP" | "PASS">().notNull(),
|
||||||
match: varchar("match").$type<"CIDR" | "PATH" | "IP">().notNull(),
|
match: varchar("match")
|
||||||
|
.$type<"CIDR" | "PATH" | "IP" | "COUNTRY" | "ASN" | "REGION">()
|
||||||
|
.notNull(),
|
||||||
value: varchar("value").notNull()
|
value: varchar("value").notNull()
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -588,26 +588,6 @@ export const trialNotifications = sqliteTable("trialNotifications", {
|
|||||||
sentAt: integer("sentAt").notNull()
|
sentAt: integer("sentAt").notNull()
|
||||||
});
|
});
|
||||||
|
|
||||||
export const browserGatewayTarget = sqliteTable("browserGatewayTarget", {
|
|
||||||
browserGatewayTargetId: integer("browserGatewayTargetId").primaryKey({
|
|
||||||
autoIncrement: true
|
|
||||||
}),
|
|
||||||
resourceId: integer("resourceId")
|
|
||||||
.references(() => resources.resourceId, {
|
|
||||||
onDelete: "cascade"
|
|
||||||
})
|
|
||||||
.notNull(),
|
|
||||||
siteId: integer("siteId")
|
|
||||||
.references(() => sites.siteId, {
|
|
||||||
onDelete: "cascade"
|
|
||||||
})
|
|
||||||
.notNull(),
|
|
||||||
authToken: text("authToken").notNull(),
|
|
||||||
type: text("type").notNull(), // "ssh", "rdp", "vnc"
|
|
||||||
destination: text("destination").notNull(),
|
|
||||||
destinationPort: integer("destinationPort").notNull()
|
|
||||||
});
|
|
||||||
|
|
||||||
export type Approval = InferSelectModel<typeof approvals>;
|
export type Approval = InferSelectModel<typeof approvals>;
|
||||||
export type Limit = InferSelectModel<typeof limits>;
|
export type Limit = InferSelectModel<typeof limits>;
|
||||||
export type Account = InferSelectModel<typeof account>;
|
export type Account = InferSelectModel<typeof account>;
|
||||||
@@ -647,6 +627,3 @@ export type AlertEmailAction = InferSelectModel<typeof alertEmailActions>;
|
|||||||
export type AlertEmailRecipient = InferSelectModel<typeof alertEmailRecipients>;
|
export type AlertEmailRecipient = InferSelectModel<typeof alertEmailRecipients>;
|
||||||
export type AlertWebhookAction = InferSelectModel<typeof alertWebhookActions>;
|
export type AlertWebhookAction = InferSelectModel<typeof alertWebhookActions>;
|
||||||
export type TrialNotification = InferSelectModel<typeof trialNotifications>;
|
export type TrialNotification = InferSelectModel<typeof trialNotifications>;
|
||||||
export type BrowserGatewayTarget = InferSelectModel<
|
|
||||||
typeof browserGatewayTarget
|
|
||||||
>;
|
|
||||||
|
|||||||
@@ -322,7 +322,12 @@ export const targets = sqliteTable("targets", {
|
|||||||
pathMatchType: text("pathMatchType"), // exact, prefix, regex
|
pathMatchType: text("pathMatchType"), // exact, prefix, regex
|
||||||
rewritePath: text("rewritePath"), // if set, rewrites the path to this value before sending to the target
|
rewritePath: text("rewritePath"), // if set, rewrites the path to this value before sending to the target
|
||||||
rewritePathType: text("rewritePathType"), // exact, prefix, regex, stripPrefix
|
rewritePathType: text("rewritePathType"), // exact, prefix, regex, stripPrefix
|
||||||
priority: integer("priority").notNull().default(100)
|
priority: integer("priority").notNull().default(100),
|
||||||
|
mode: text("mode")
|
||||||
|
.$type<"http" | "tcp" | "udp" | "ssh" | "rdp" | "vnc">()
|
||||||
|
.notNull()
|
||||||
|
.default("http"),
|
||||||
|
authToken: text("authToken")
|
||||||
});
|
});
|
||||||
|
|
||||||
export const targetHealthCheck = sqliteTable("targetHealthCheck", {
|
export const targetHealthCheck = sqliteTable("targetHealthCheck", {
|
||||||
@@ -1248,7 +1253,9 @@ export const resourcePolicyRules = sqliteTable("resourcePolicyRules", {
|
|||||||
enabled: integer("enabled", { mode: "boolean" }).notNull().default(true),
|
enabled: integer("enabled", { mode: "boolean" }).notNull().default(true),
|
||||||
priority: integer("priority").notNull(),
|
priority: integer("priority").notNull(),
|
||||||
action: text("action").$type<"ACCEPT" | "DROP" | "PASS">().notNull(),
|
action: text("action").$type<"ACCEPT" | "DROP" | "PASS">().notNull(),
|
||||||
match: text("match").$type<"CIDR" | "PATH" | "IP">().notNull(),
|
match: text("match")
|
||||||
|
.$type<"CIDR" | "PATH" | "IP" | "COUNTRY" | "ASN" | "REGION">()
|
||||||
|
.notNull(),
|
||||||
value: text("value").notNull()
|
value: text("value").notNull()
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -10,16 +10,23 @@ import {
|
|||||||
clientSiteResources
|
clientSiteResources
|
||||||
} from "@server/db";
|
} from "@server/db";
|
||||||
import { Config, ConfigSchema } from "./types";
|
import { Config, ConfigSchema } from "./types";
|
||||||
import { ProxyResourcesResults, updateProxyResources } from "./proxyResources";
|
import {
|
||||||
|
PublicResourcesResults,
|
||||||
|
updatePublicResources
|
||||||
|
} from "./publicResources";
|
||||||
import { fromError } from "zod-validation-error";
|
import { fromError } from "zod-validation-error";
|
||||||
import logger from "@server/logger";
|
import logger from "@server/logger";
|
||||||
import { sites } from "@server/db";
|
import { sites } from "@server/db";
|
||||||
import { eq, and, isNotNull } from "drizzle-orm";
|
import { eq, and, isNotNull } from "drizzle-orm";
|
||||||
import { addTargets as addProxyTargets } from "@server/routers/newt/targets";
|
import {
|
||||||
|
addTargets as addProxyTargets,
|
||||||
|
sendBrowserGatewayTargets
|
||||||
|
} from "@server/routers/newt/targets";
|
||||||
import {
|
import {
|
||||||
ClientResourcesResults,
|
ClientResourcesResults,
|
||||||
updateClientResources
|
updatePrivateResources
|
||||||
} from "./clientResources";
|
} from "./privateResources";
|
||||||
|
import { updateResourcePolicies } from "./resourcePolicies";
|
||||||
import { BlueprintSource } from "@server/routers/blueprints/types";
|
import { BlueprintSource } from "@server/routers/blueprints/types";
|
||||||
import { stringify as stringifyYaml } from "yaml";
|
import { stringify as stringifyYaml } from "yaml";
|
||||||
import { generateName } from "@server/db/names";
|
import { generateName } from "@server/db/names";
|
||||||
@@ -53,16 +60,18 @@ export async function applyBlueprint({
|
|||||||
let error: any | null = null;
|
let error: any | null = null;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
let proxyResourcesResults: ProxyResourcesResults = [];
|
let proxyResourcesResults: PublicResourcesResults = [];
|
||||||
let clientResourcesResults: ClientResourcesResults = [];
|
let clientResourcesResults: ClientResourcesResults = [];
|
||||||
await db.transaction(async (trx) => {
|
await db.transaction(async (trx) => {
|
||||||
proxyResourcesResults = await updateProxyResources(
|
await updateResourcePolicies(orgId, config, trx);
|
||||||
|
|
||||||
|
proxyResourcesResults = await updatePublicResources(
|
||||||
orgId,
|
orgId,
|
||||||
config,
|
config,
|
||||||
trx,
|
trx,
|
||||||
siteId
|
siteId
|
||||||
);
|
);
|
||||||
clientResourcesResults = await updateClientResources(
|
clientResourcesResults = await updatePrivateResources(
|
||||||
orgId,
|
orgId,
|
||||||
config,
|
config,
|
||||||
trx,
|
trx,
|
||||||
@@ -101,13 +110,27 @@ export async function applyBlueprint({
|
|||||||
(hc) => hc.targetId === target.targetId
|
(hc) => hc.targetId === target.targetId
|
||||||
);
|
);
|
||||||
|
|
||||||
await addProxyTargets(
|
if (["http", "tcp", "udp"].includes(target.mode)) {
|
||||||
site.newt.newtId,
|
await addProxyTargets(
|
||||||
[target],
|
site.newt.newtId,
|
||||||
matchingHealthcheck ? [matchingHealthcheck] : [],
|
[target],
|
||||||
result.proxyResource.mode === "udp" ? "udp" : "tcp",
|
matchingHealthcheck
|
||||||
site.newt.version
|
? [matchingHealthcheck]
|
||||||
);
|
: [],
|
||||||
|
result.proxyResource.mode === "udp"
|
||||||
|
? "udp"
|
||||||
|
: "tcp",
|
||||||
|
site.newt.version
|
||||||
|
);
|
||||||
|
} else if (
|
||||||
|
["ssh", "rdp", "vnc"].includes(target.mode)
|
||||||
|
) {
|
||||||
|
await sendBrowserGatewayTargets(
|
||||||
|
site.newt.newtId,
|
||||||
|
[target],
|
||||||
|
site.newt.version
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+33
-3
@@ -23,6 +23,8 @@ import logger from "@server/logger";
|
|||||||
import { defaultRoleAllowedActions } from "@server/routers/role/createRole";
|
import { defaultRoleAllowedActions } from "@server/routers/role/createRole";
|
||||||
import { getNextAvailableAliasAddress } from "../ip";
|
import { getNextAvailableAliasAddress } from "../ip";
|
||||||
import { createCertificate } from "#dynamic/routers/certificates/createCertificate";
|
import { createCertificate } from "#dynamic/routers/certificates/createCertificate";
|
||||||
|
import { isLicensedOrSubscribed } from "#dynamic/lib/isLicencedOrSubscribed";
|
||||||
|
import { tierMatrix } from "../billing/tierMatrix";
|
||||||
|
|
||||||
async function getDomainForSiteResource(
|
async function getDomainForSiteResource(
|
||||||
siteResourceId: number | undefined,
|
siteResourceId: number | undefined,
|
||||||
@@ -103,7 +105,7 @@ export type ClientResourcesResults = {
|
|||||||
oldSites: { siteId: number }[];
|
oldSites: { siteId: number }[];
|
||||||
}[];
|
}[];
|
||||||
|
|
||||||
export async function updateClientResources(
|
export async function updatePrivateResources(
|
||||||
orgId: string,
|
orgId: string,
|
||||||
config: Config,
|
config: Config,
|
||||||
trx: Transaction,
|
trx: Transaction,
|
||||||
@@ -114,6 +116,30 @@ export async function updateClientResources(
|
|||||||
for (const [resourceNiceId, resourceData] of Object.entries(
|
for (const [resourceNiceId, resourceData] of Object.entries(
|
||||||
config["client-resources"]
|
config["client-resources"]
|
||||||
)) {
|
)) {
|
||||||
|
if (resourceData.mode === "http") {
|
||||||
|
const hasHttpFeature = await isLicensedOrSubscribed(
|
||||||
|
orgId,
|
||||||
|
tierMatrix.advancedPrivateResources
|
||||||
|
);
|
||||||
|
if (!hasHttpFeature) {
|
||||||
|
throw new Error(
|
||||||
|
"HTTP private resources are not included in your current plan. Please upgrade."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (resourceData.mode === "ssh") {
|
||||||
|
const hasSshFeature = await isLicensedOrSubscribed(
|
||||||
|
orgId,
|
||||||
|
tierMatrix.advancedPrivateResources
|
||||||
|
);
|
||||||
|
if (!hasSshFeature) {
|
||||||
|
throw new Error(
|
||||||
|
"SSH private resources are not included in your current plan. Please upgrade."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const [existingResource] = await trx
|
const [existingResource] = await trx
|
||||||
.select()
|
.select()
|
||||||
.from(siteResources)
|
.from(siteResources)
|
||||||
@@ -366,7 +392,9 @@ export async function updateClientResources(
|
|||||||
}))
|
}))
|
||||||
);
|
);
|
||||||
existingRoles.push(created);
|
existingRoles.push(created);
|
||||||
logger.info(`Auto-created role "${name}" in org ${orgId} from blueprint`);
|
logger.info(
|
||||||
|
`Auto-created role "${name}" in org ${orgId} from blueprint`
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const roleIds = existingRoles.map((role) => role.roleId);
|
const roleIds = existingRoles.map((role) => role.roleId);
|
||||||
@@ -510,7 +538,9 @@ export async function updateClientResources(
|
|||||||
}))
|
}))
|
||||||
);
|
);
|
||||||
existingRoles.push(created);
|
existingRoles.push(created);
|
||||||
logger.info(`Auto-created role "${name}" in org ${orgId} from blueprint`);
|
logger.info(
|
||||||
|
`Auto-created role "${name}" in org ${orgId} from blueprint`
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const roleIds = existingRoles.map((role) => role.roleId);
|
const roleIds = existingRoles.map((role) => role.roleId);
|
||||||
+121
-7
@@ -47,20 +47,24 @@ import { isLicensedOrSubscribed } from "#dynamic/lib/isLicencedOrSubscribed";
|
|||||||
import { fireHealthCheckUnknownAlert } from "@server/lib/alerts";
|
import { fireHealthCheckUnknownAlert } from "@server/lib/alerts";
|
||||||
import { tierMatrix } from "../billing/tierMatrix";
|
import { tierMatrix } from "../billing/tierMatrix";
|
||||||
import { defaultRoleAllowedActions } from "@server/routers/role/createRole";
|
import { defaultRoleAllowedActions } from "@server/routers/role/createRole";
|
||||||
|
import { build } from "@server/build";
|
||||||
|
import { encrypt } from "@server/lib/crypto";
|
||||||
|
import { generateId } from "@server/auth/sessions/app";
|
||||||
|
import serverConfig from "@server/lib/config";
|
||||||
|
|
||||||
export type ProxyResourcesResults = {
|
export type PublicResourcesResults = {
|
||||||
proxyResource: Resource;
|
proxyResource: Resource;
|
||||||
targetsToUpdate: Target[];
|
targetsToUpdate: Target[];
|
||||||
healthchecksToUpdate: TargetHealthCheck[];
|
healthchecksToUpdate: TargetHealthCheck[];
|
||||||
}[];
|
}[];
|
||||||
|
|
||||||
export async function updateProxyResources(
|
export async function updatePublicResources(
|
||||||
orgId: string,
|
orgId: string,
|
||||||
config: Config,
|
config: Config,
|
||||||
trx: Transaction,
|
trx: Transaction,
|
||||||
siteId?: number
|
siteId?: number
|
||||||
): Promise<ProxyResourcesResults> {
|
): Promise<PublicResourcesResults> {
|
||||||
const results: ProxyResourcesResults = [];
|
const results: PublicResourcesResults = [];
|
||||||
|
|
||||||
for (const [resourceNiceId, resourceData] of Object.entries(
|
for (const [resourceNiceId, resourceData] of Object.entries(
|
||||||
config["proxy-resources"]
|
config["proxy-resources"]
|
||||||
@@ -79,7 +83,7 @@ export async function updateProxyResources(
|
|||||||
if (targetSiteId) {
|
if (targetSiteId) {
|
||||||
// Look up site by niceId
|
// Look up site by niceId
|
||||||
[site] = await trx
|
[site] = await trx
|
||||||
.select({ siteId: sites.siteId })
|
.select({ siteId: sites.siteId, type: sites.type })
|
||||||
.from(sites)
|
.from(sites)
|
||||||
.where(
|
.where(
|
||||||
and(
|
and(
|
||||||
@@ -91,7 +95,7 @@ export async function updateProxyResources(
|
|||||||
} else if (siteId) {
|
} else if (siteId) {
|
||||||
// Use the provided siteId directly, but verify it belongs to the org
|
// Use the provided siteId directly, but verify it belongs to the org
|
||||||
[site] = await trx
|
[site] = await trx
|
||||||
.select({ siteId: sites.siteId })
|
.select({ siteId: sites.siteId, type: sites.type })
|
||||||
.from(sites)
|
.from(sites)
|
||||||
.where(
|
.where(
|
||||||
and(eq(sites.siteId, siteId), eq(sites.orgId, orgId))
|
and(eq(sites.siteId, siteId), eq(sites.orgId, orgId))
|
||||||
@@ -118,6 +122,15 @@ export async function updateProxyResources(
|
|||||||
internalPortToCreate = targetData["internal-port"];
|
internalPortToCreate = targetData["internal-port"];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let authToken: string | undefined;
|
||||||
|
if (site.type !== "local") {
|
||||||
|
const plainToken = generateId(48);
|
||||||
|
authToken = encrypt(
|
||||||
|
plainToken,
|
||||||
|
serverConfig.getRawConfig().server.secret!
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// Create target
|
// Create target
|
||||||
const [newTarget] = await trx
|
const [newTarget] = await trx
|
||||||
.insert(targets)
|
.insert(targets)
|
||||||
@@ -125,10 +138,12 @@ export async function updateProxyResources(
|
|||||||
resourceId: resourceId,
|
resourceId: resourceId,
|
||||||
siteId: site.siteId,
|
siteId: site.siteId,
|
||||||
ip: targetData.hostname,
|
ip: targetData.hostname,
|
||||||
|
mode: resourceData.mode as Target["mode"],
|
||||||
method: targetData.method,
|
method: targetData.method,
|
||||||
port: targetData.port,
|
port: targetData.port,
|
||||||
enabled: targetData.enabled,
|
enabled: targetData.enabled,
|
||||||
internalPort: internalPortToCreate,
|
internalPort: internalPortToCreate,
|
||||||
|
authToken: authToken,
|
||||||
path: targetData.path,
|
path: targetData.path,
|
||||||
pathMatchType: targetData["path-match"],
|
pathMatchType: targetData["path-match"],
|
||||||
rewritePath:
|
rewritePath:
|
||||||
@@ -222,17 +237,59 @@ export async function updateProxyResources(
|
|||||||
headers = JSON.stringify(resourceData.headers);
|
headers = JSON.stringify(resourceData.headers);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (["ssh", "rdp", "vnc"].includes(resourceData.mode || "")) {
|
||||||
|
const isLicensed = await isLicensedOrSubscribed(
|
||||||
|
orgId,
|
||||||
|
tierMatrix.advancedPublicResources
|
||||||
|
);
|
||||||
|
if (!isLicensed) {
|
||||||
|
throw new Error(
|
||||||
|
"Your current subscription does not support browser gateway resources. Please upgrade to access this feature."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (resourceData.policy) {
|
||||||
|
const isLicensed = await isLicensedOrSubscribed(
|
||||||
|
orgId,
|
||||||
|
tierMatrix.resourcePolicies
|
||||||
|
);
|
||||||
|
if (!isLicensed) {
|
||||||
|
throw new Error(
|
||||||
|
"Your current subscription does not support shared resource policies. Please upgrade to access this feature."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (existingResource) {
|
if (existingResource) {
|
||||||
let domain;
|
let domain;
|
||||||
if (
|
if (
|
||||||
["http", "ssh", "rdp", "vnc"].includes(resourceData.mode || "")
|
["http", "ssh", "rdp", "vnc"].includes(resourceData.mode || "")
|
||||||
) {
|
) {
|
||||||
|
if (resourceData["full-domain"]?.startsWith("*.")) {
|
||||||
|
const isLicensed = await isLicensedOrSubscribed(
|
||||||
|
orgId,
|
||||||
|
tierMatrix.wildcardSubdomain
|
||||||
|
);
|
||||||
|
if (!isLicensed) {
|
||||||
|
throw new Error(
|
||||||
|
"Wildcard subdomains are not supported on your current plan. Please upgrade to access this feature."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
domain = await getDomain(
|
domain = await getDomain(
|
||||||
existingResource.resourceId,
|
existingResource.resourceId,
|
||||||
resourceData["full-domain"]!,
|
resourceData["full-domain"]!,
|
||||||
orgId,
|
orgId,
|
||||||
trx
|
trx
|
||||||
);
|
);
|
||||||
|
|
||||||
|
await enforceDomainNamespacePaywall(
|
||||||
|
orgId,
|
||||||
|
domain.domainId,
|
||||||
|
trx
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// check if the only key in the resource is targets, if so, skip the update
|
// check if the only key in the resource is targets, if so, skip the update
|
||||||
@@ -522,6 +579,13 @@ export async function updateProxyResources(
|
|||||||
? (resourceData["proxy-protocol-version"] ??
|
? (resourceData["proxy-protocol-version"] ??
|
||||||
1)
|
1)
|
||||||
: 1,
|
: 1,
|
||||||
|
pamMode:
|
||||||
|
resourceData["auth-daemon"]?.pam ||
|
||||||
|
"passthrough",
|
||||||
|
authDaemonMode:
|
||||||
|
resourceData["auth-daemon"]?.mode || "native",
|
||||||
|
authDaemonPort:
|
||||||
|
resourceData["auth-daemon"]?.port || 22123,
|
||||||
resourcePolicyId: null,
|
resourcePolicyId: null,
|
||||||
defaultResourcePolicyId: inlinePolicyId
|
defaultResourcePolicyId: inlinePolicyId
|
||||||
})
|
})
|
||||||
@@ -664,7 +728,8 @@ export async function updateProxyResources(
|
|||||||
? "/"
|
? "/"
|
||||||
: undefined),
|
: undefined),
|
||||||
rewritePathType: targetData["rewrite-match"],
|
rewritePathType: targetData["rewrite-match"],
|
||||||
priority: targetData.priority
|
priority: targetData.priority,
|
||||||
|
mode: resourceData.mode
|
||||||
})
|
})
|
||||||
.where(eq(targets.targetId, existingTarget.targetId))
|
.where(eq(targets.targetId, existingTarget.targetId))
|
||||||
.returning();
|
.returning();
|
||||||
@@ -906,12 +971,30 @@ export async function updateProxyResources(
|
|||||||
if (
|
if (
|
||||||
["http", "ssh", "rdp", "vnc"].includes(resourceData.mode || "")
|
["http", "ssh", "rdp", "vnc"].includes(resourceData.mode || "")
|
||||||
) {
|
) {
|
||||||
|
if (resourceData["full-domain"]?.startsWith("*.")) {
|
||||||
|
const isLicensed = await isLicensedOrSubscribed(
|
||||||
|
orgId,
|
||||||
|
tierMatrix.wildcardSubdomain
|
||||||
|
);
|
||||||
|
if (!isLicensed) {
|
||||||
|
throw new Error(
|
||||||
|
"Wildcard subdomains are not supported on your current plan. Please upgrade to access this feature."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
domain = await getDomain(
|
domain = await getDomain(
|
||||||
undefined,
|
undefined,
|
||||||
resourceData["full-domain"]!,
|
resourceData["full-domain"]!,
|
||||||
orgId,
|
orgId,
|
||||||
trx
|
trx
|
||||||
);
|
);
|
||||||
|
|
||||||
|
await enforceDomainNamespacePaywall(
|
||||||
|
orgId,
|
||||||
|
domain.domainId,
|
||||||
|
trx
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const isLicensed = await isLicensedOrSubscribed(
|
const isLicensed = await isLicensedOrSubscribed(
|
||||||
@@ -1866,6 +1949,37 @@ function checkIfTargetChanged(
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function enforceDomainNamespacePaywall(
|
||||||
|
orgId: string,
|
||||||
|
domainId: string,
|
||||||
|
trx: Transaction
|
||||||
|
) {
|
||||||
|
if (build !== "saas") {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const hasDomainNamespaceAccess = await isLicensedOrSubscribed(
|
||||||
|
orgId,
|
||||||
|
tierMatrix.domainNamespaces
|
||||||
|
);
|
||||||
|
|
||||||
|
if (hasDomainNamespaceAccess) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const [namespaceDomain] = await trx
|
||||||
|
.select()
|
||||||
|
.from(domainNamespaces)
|
||||||
|
.where(eq(domainNamespaces.domainId, domainId))
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
if (namespaceDomain) {
|
||||||
|
throw new Error(
|
||||||
|
"Your current subscription does not support custom domain namespaces. Please upgrade to access this feature."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export async function getDomain(
|
export async function getDomain(
|
||||||
resourceId: number | undefined,
|
resourceId: number | undefined,
|
||||||
fullDomain: string,
|
fullDomain: string,
|
||||||
@@ -0,0 +1,653 @@
|
|||||||
|
import {
|
||||||
|
db,
|
||||||
|
idp,
|
||||||
|
idpOrg,
|
||||||
|
resourcePolicies,
|
||||||
|
resourcePolicyHeaderAuth,
|
||||||
|
resourcePolicyPassword,
|
||||||
|
resourcePolicyPincode,
|
||||||
|
resourcePolicyRules,
|
||||||
|
resourcePolicyWhiteList,
|
||||||
|
rolePolicies,
|
||||||
|
roles,
|
||||||
|
Transaction,
|
||||||
|
userOrgs,
|
||||||
|
userPolicies,
|
||||||
|
users
|
||||||
|
} from "@server/db";
|
||||||
|
import { eq, and, or } from "drizzle-orm";
|
||||||
|
import { Config, ResourcePolicyData } from "./types";
|
||||||
|
import logger from "@server/logger";
|
||||||
|
import { getUniqueResourcePolicyName } from "@server/db/names";
|
||||||
|
import { hashPassword } from "@server/auth/password";
|
||||||
|
import { isValidCIDR, isValidIP, isValidUrlGlobPattern } from "../validators";
|
||||||
|
import { isLicensedOrSubscribed } from "#dynamic/lib/isLicencedOrSubscribed";
|
||||||
|
import { tierMatrix } from "../billing/tierMatrix";
|
||||||
|
|
||||||
|
export type ResourcePoliciesResults = {
|
||||||
|
resourcePolicyId: number;
|
||||||
|
niceId: string;
|
||||||
|
}[];
|
||||||
|
|
||||||
|
export async function updateResourcePolicies(
|
||||||
|
orgId: string,
|
||||||
|
config: Config,
|
||||||
|
trx: Transaction
|
||||||
|
): Promise<ResourcePoliciesResults> {
|
||||||
|
const results: ResourcePoliciesResults = [];
|
||||||
|
|
||||||
|
for (const [policyNiceId, policyData] of Object.entries(
|
||||||
|
config["public-policies"]
|
||||||
|
)) {
|
||||||
|
const isLicensed = await isLicensedOrSubscribed(
|
||||||
|
orgId,
|
||||||
|
tierMatrix.resourcePolicies
|
||||||
|
);
|
||||||
|
if (!isLicensed) {
|
||||||
|
throw new Error(
|
||||||
|
"Your current subscription does not support shared resource policies. Please upgrade to access this feature."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate rules
|
||||||
|
for (const rule of policyData.rules) {
|
||||||
|
if (rule.match === "cidr" && !isValidCIDR(rule.value)) {
|
||||||
|
throw new Error(
|
||||||
|
`Invalid CIDR provided in resource policy '${policyNiceId}': ${rule.value}`
|
||||||
|
);
|
||||||
|
} else if (rule.match === "ip" && !isValidIP(rule.value)) {
|
||||||
|
throw new Error(
|
||||||
|
`Invalid IP provided in resource policy '${policyNiceId}': ${rule.value}`
|
||||||
|
);
|
||||||
|
} else if (
|
||||||
|
rule.match === "path" &&
|
||||||
|
!isValidUrlGlobPattern(rule.value)
|
||||||
|
) {
|
||||||
|
throw new Error(
|
||||||
|
`Invalid URL glob pattern provided in resource policy '${policyNiceId}': ${rule.value}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate auto-login-idp if provided
|
||||||
|
if (policyData["auto-login-idp"]) {
|
||||||
|
const [provider] = await trx
|
||||||
|
.select()
|
||||||
|
.from(idp)
|
||||||
|
.innerJoin(idpOrg, eq(idpOrg.idpId, idp.idpId))
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(idp.idpId, policyData["auto-login-idp"]),
|
||||||
|
eq(idpOrg.orgId, orgId)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
if (!provider) {
|
||||||
|
throw new Error(
|
||||||
|
`Identity provider not found for policy '${policyNiceId}' in this organization`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Look up the admin role
|
||||||
|
const [adminRole] = await trx
|
||||||
|
.select()
|
||||||
|
.from(roles)
|
||||||
|
.where(and(eq(roles.isAdmin, true), eq(roles.orgId, orgId)))
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
if (!adminRole) {
|
||||||
|
throw new Error("Admin role not found");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find existing policy by niceId and orgId
|
||||||
|
const [existingPolicy] = await trx
|
||||||
|
.select()
|
||||||
|
.from(resourcePolicies)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(resourcePolicies.niceId, policyNiceId),
|
||||||
|
eq(resourcePolicies.orgId, orgId)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
let resourcePolicyId: number;
|
||||||
|
|
||||||
|
if (existingPolicy) {
|
||||||
|
// Update the existing policy
|
||||||
|
await trx
|
||||||
|
.update(resourcePolicies)
|
||||||
|
.set({
|
||||||
|
name: policyData.name,
|
||||||
|
sso: policyData.sso ?? true,
|
||||||
|
idpId: policyData["auto-login-idp"] ?? null,
|
||||||
|
emailWhitelistEnabled:
|
||||||
|
policyData["email-whitelist-enabled"] ??
|
||||||
|
policyData["whitelist-users"].length > 0,
|
||||||
|
applyRules:
|
||||||
|
policyData["apply-rules"] || policyData.rules.length > 0
|
||||||
|
})
|
||||||
|
.where(
|
||||||
|
eq(
|
||||||
|
resourcePolicies.resourcePolicyId,
|
||||||
|
existingPolicy.resourcePolicyId
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
resourcePolicyId = existingPolicy.resourcePolicyId;
|
||||||
|
|
||||||
|
// Sync password
|
||||||
|
await trx
|
||||||
|
.delete(resourcePolicyPassword)
|
||||||
|
.where(
|
||||||
|
eq(
|
||||||
|
resourcePolicyPassword.resourcePolicyId,
|
||||||
|
resourcePolicyId
|
||||||
|
)
|
||||||
|
);
|
||||||
|
if (policyData.password) {
|
||||||
|
const passwordHash = await hashPassword(policyData.password);
|
||||||
|
await trx.insert(resourcePolicyPassword).values({
|
||||||
|
resourcePolicyId,
|
||||||
|
passwordHash
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sync pincode
|
||||||
|
await trx
|
||||||
|
.delete(resourcePolicyPincode)
|
||||||
|
.where(
|
||||||
|
eq(resourcePolicyPincode.resourcePolicyId, resourcePolicyId)
|
||||||
|
);
|
||||||
|
if (policyData.pincode) {
|
||||||
|
const pincodeHash = await hashPassword(policyData.pincode);
|
||||||
|
await trx.insert(resourcePolicyPincode).values({
|
||||||
|
resourcePolicyId,
|
||||||
|
pincodeHash,
|
||||||
|
digitLength: 6
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sync header auth
|
||||||
|
await trx
|
||||||
|
.delete(resourcePolicyHeaderAuth)
|
||||||
|
.where(
|
||||||
|
eq(
|
||||||
|
resourcePolicyHeaderAuth.resourcePolicyId,
|
||||||
|
resourcePolicyId
|
||||||
|
)
|
||||||
|
);
|
||||||
|
if (policyData["basic-auth"]) {
|
||||||
|
const basicAuth = policyData["basic-auth"];
|
||||||
|
const headerAuthHash = await hashPassword(
|
||||||
|
Buffer.from(
|
||||||
|
`${basicAuth.user}:${basicAuth.password}`
|
||||||
|
).toString("base64")
|
||||||
|
);
|
||||||
|
await trx.insert(resourcePolicyHeaderAuth).values({
|
||||||
|
resourcePolicyId,
|
||||||
|
headerAuthHash,
|
||||||
|
extendedCompatibility:
|
||||||
|
basicAuth["extended-compatibility"] ?? true
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sync SSO roles
|
||||||
|
await syncRolePolicies(
|
||||||
|
resourcePolicyId,
|
||||||
|
policyData["sso-roles"],
|
||||||
|
orgId,
|
||||||
|
adminRole.roleId,
|
||||||
|
trx
|
||||||
|
);
|
||||||
|
|
||||||
|
// Sync SSO users
|
||||||
|
await syncUserPolicies(
|
||||||
|
resourcePolicyId,
|
||||||
|
policyData["sso-users"],
|
||||||
|
orgId,
|
||||||
|
trx
|
||||||
|
);
|
||||||
|
|
||||||
|
// Sync whitelist users
|
||||||
|
await syncWhitelistPolicyUsers(
|
||||||
|
resourcePolicyId,
|
||||||
|
policyData["whitelist-users"],
|
||||||
|
trx
|
||||||
|
);
|
||||||
|
|
||||||
|
// Sync rules
|
||||||
|
await syncPolicyRules(resourcePolicyId, policyData.rules, trx);
|
||||||
|
|
||||||
|
logger.debug(
|
||||||
|
`Updated resource policy ${resourcePolicyId} (${policyNiceId})`
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
// Create a new policy
|
||||||
|
const [newPolicy] = await trx
|
||||||
|
.insert(resourcePolicies)
|
||||||
|
.values({
|
||||||
|
niceId: policyNiceId,
|
||||||
|
orgId,
|
||||||
|
name: policyData.name,
|
||||||
|
sso: policyData.sso ?? true,
|
||||||
|
idpId: policyData["auto-login-idp"] ?? null,
|
||||||
|
emailWhitelistEnabled:
|
||||||
|
policyData["email-whitelist-enabled"] ??
|
||||||
|
policyData["whitelist-users"].length > 0,
|
||||||
|
applyRules:
|
||||||
|
policyData["apply-rules"] ||
|
||||||
|
policyData.rules.length > 0,
|
||||||
|
scope: "global"
|
||||||
|
})
|
||||||
|
.returning();
|
||||||
|
|
||||||
|
resourcePolicyId = newPolicy.resourcePolicyId;
|
||||||
|
|
||||||
|
// Always add admin role
|
||||||
|
await trx.insert(rolePolicies).values({
|
||||||
|
roleId: adminRole.roleId,
|
||||||
|
resourcePolicyId
|
||||||
|
});
|
||||||
|
|
||||||
|
// Add SSO roles
|
||||||
|
await addRolePolicies(
|
||||||
|
resourcePolicyId,
|
||||||
|
policyData["sso-roles"],
|
||||||
|
orgId,
|
||||||
|
adminRole.roleId,
|
||||||
|
trx
|
||||||
|
);
|
||||||
|
|
||||||
|
// Add SSO users
|
||||||
|
await addUserPolicies(
|
||||||
|
resourcePolicyId,
|
||||||
|
policyData["sso-users"],
|
||||||
|
orgId,
|
||||||
|
trx
|
||||||
|
);
|
||||||
|
|
||||||
|
// Add password
|
||||||
|
if (policyData.password) {
|
||||||
|
const passwordHash = await hashPassword(policyData.password);
|
||||||
|
await trx.insert(resourcePolicyPassword).values({
|
||||||
|
resourcePolicyId,
|
||||||
|
passwordHash
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add pincode
|
||||||
|
if (policyData.pincode) {
|
||||||
|
const pincodeHash = await hashPassword(policyData.pincode);
|
||||||
|
await trx.insert(resourcePolicyPincode).values({
|
||||||
|
resourcePolicyId,
|
||||||
|
pincodeHash,
|
||||||
|
digitLength: 6
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add header auth
|
||||||
|
if (policyData["basic-auth"]) {
|
||||||
|
const basicAuth = policyData["basic-auth"];
|
||||||
|
const headerAuthHash = await hashPassword(
|
||||||
|
Buffer.from(
|
||||||
|
`${basicAuth.user}:${basicAuth.password}`
|
||||||
|
).toString("base64")
|
||||||
|
);
|
||||||
|
await trx.insert(resourcePolicyHeaderAuth).values({
|
||||||
|
resourcePolicyId,
|
||||||
|
headerAuthHash,
|
||||||
|
extendedCompatibility:
|
||||||
|
basicAuth["extended-compatibility"] ?? true
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add whitelist users
|
||||||
|
if (policyData["whitelist-users"].length > 0) {
|
||||||
|
await trx.insert(resourcePolicyWhiteList).values(
|
||||||
|
policyData["whitelist-users"].map((email) => ({
|
||||||
|
email,
|
||||||
|
resourcePolicyId
|
||||||
|
}))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add rules
|
||||||
|
if (policyData.rules.length > 0) {
|
||||||
|
await trx.insert(resourcePolicyRules).values(
|
||||||
|
policyData.rules.map((rule, index) => ({
|
||||||
|
resourcePolicyId,
|
||||||
|
action: getRuleAction(rule.action),
|
||||||
|
match: getRuleMatch(rule.match),
|
||||||
|
value: rule.value,
|
||||||
|
priority: rule.priority ?? index + 1,
|
||||||
|
enabled: rule.enabled ?? true
|
||||||
|
}))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.debug(
|
||||||
|
`Created resource policy ${resourcePolicyId} (${policyNiceId})`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
results.push({ resourcePolicyId, niceId: policyNiceId });
|
||||||
|
}
|
||||||
|
|
||||||
|
return results;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getRuleAction(input: string): "ACCEPT" | "DROP" | "PASS" {
|
||||||
|
if (input === "allow") return "ACCEPT";
|
||||||
|
if (input === "deny") return "DROP";
|
||||||
|
return "PASS";
|
||||||
|
}
|
||||||
|
|
||||||
|
function getRuleMatch(
|
||||||
|
input: string
|
||||||
|
): "CIDR" | "IP" | "PATH" | "COUNTRY" | "ASN" | "REGION" {
|
||||||
|
return input.toUpperCase() as
|
||||||
|
| "CIDR"
|
||||||
|
| "IP"
|
||||||
|
| "PATH"
|
||||||
|
| "COUNTRY"
|
||||||
|
| "ASN"
|
||||||
|
| "REGION";
|
||||||
|
}
|
||||||
|
|
||||||
|
async function syncRolePolicies(
|
||||||
|
policyId: number,
|
||||||
|
ssoRoles: string[],
|
||||||
|
orgId: string,
|
||||||
|
adminRoleId: number,
|
||||||
|
trx: Transaction
|
||||||
|
) {
|
||||||
|
const existingRolePolicies = await trx
|
||||||
|
.select()
|
||||||
|
.from(rolePolicies)
|
||||||
|
.where(eq(rolePolicies.resourcePolicyId, policyId));
|
||||||
|
|
||||||
|
for (const roleName of ssoRoles) {
|
||||||
|
const [role] = await trx
|
||||||
|
.select()
|
||||||
|
.from(roles)
|
||||||
|
.where(and(eq(roles.name, roleName), eq(roles.orgId, orgId)))
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
if (!role) {
|
||||||
|
logger.warn(
|
||||||
|
`Role '${roleName}' not found in org '${orgId}', skipping`
|
||||||
|
);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (role.isAdmin) {
|
||||||
|
continue; // admin role is always included, skip
|
||||||
|
}
|
||||||
|
|
||||||
|
const alreadyExists = existingRolePolicies.some(
|
||||||
|
(rp) => rp.roleId === role.roleId
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!alreadyExists) {
|
||||||
|
await trx.insert(rolePolicies).values({
|
||||||
|
roleId: role.roleId,
|
||||||
|
resourcePolicyId: policyId
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove roles no longer in the list (except admin)
|
||||||
|
for (const existingRolePolicy of existingRolePolicies) {
|
||||||
|
if (existingRolePolicy.roleId === adminRoleId) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const [role] = await trx
|
||||||
|
.select()
|
||||||
|
.from(roles)
|
||||||
|
.where(eq(roles.roleId, existingRolePolicy.roleId))
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
if (role?.isAdmin) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (role && !ssoRoles.includes(role.name)) {
|
||||||
|
await trx
|
||||||
|
.delete(rolePolicies)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(rolePolicies.resourcePolicyId, policyId),
|
||||||
|
eq(rolePolicies.roleId, existingRolePolicy.roleId)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function addRolePolicies(
|
||||||
|
policyId: number,
|
||||||
|
ssoRoles: string[],
|
||||||
|
orgId: string,
|
||||||
|
adminRoleId: number,
|
||||||
|
trx: Transaction
|
||||||
|
) {
|
||||||
|
for (const roleName of ssoRoles) {
|
||||||
|
const [role] = await trx
|
||||||
|
.select()
|
||||||
|
.from(roles)
|
||||||
|
.where(and(eq(roles.name, roleName), eq(roles.orgId, orgId)))
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
if (!role) {
|
||||||
|
logger.warn(
|
||||||
|
`Role '${roleName}' not found in org '${orgId}', skipping`
|
||||||
|
);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (role.isAdmin) {
|
||||||
|
continue; // admin already added
|
||||||
|
}
|
||||||
|
|
||||||
|
await trx.insert(rolePolicies).values({
|
||||||
|
roleId: role.roleId,
|
||||||
|
resourcePolicyId: policyId
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function syncUserPolicies(
|
||||||
|
policyId: number,
|
||||||
|
ssoUsers: string[],
|
||||||
|
orgId: string,
|
||||||
|
trx: Transaction
|
||||||
|
) {
|
||||||
|
const existingUserPolicies = await trx
|
||||||
|
.select()
|
||||||
|
.from(userPolicies)
|
||||||
|
.where(eq(userPolicies.resourcePolicyId, policyId));
|
||||||
|
|
||||||
|
for (const username of ssoUsers) {
|
||||||
|
const [user] = await trx
|
||||||
|
.select()
|
||||||
|
.from(users)
|
||||||
|
.innerJoin(userOrgs, eq(users.userId, userOrgs.userId))
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
or(eq(users.username, username), eq(users.email, username)),
|
||||||
|
eq(userOrgs.orgId, orgId)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
logger.warn(
|
||||||
|
`User '${username}' not found in org '${orgId}', skipping`
|
||||||
|
);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const alreadyExists = existingUserPolicies.some(
|
||||||
|
(up) => up.userId === user.user.userId
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!alreadyExists) {
|
||||||
|
await trx.insert(userPolicies).values({
|
||||||
|
userId: user.user.userId,
|
||||||
|
resourcePolicyId: policyId
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove users no longer in the list
|
||||||
|
for (const existingUserPolicy of existingUserPolicies) {
|
||||||
|
const [user] = await trx
|
||||||
|
.select()
|
||||||
|
.from(users)
|
||||||
|
.innerJoin(userOrgs, eq(users.userId, userOrgs.userId))
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(users.userId, existingUserPolicy.userId),
|
||||||
|
eq(userOrgs.orgId, orgId)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
if (
|
||||||
|
user &&
|
||||||
|
user.user.username &&
|
||||||
|
!ssoUsers.includes(user.user.username) &&
|
||||||
|
!ssoUsers.includes(user.user.email ?? "")
|
||||||
|
) {
|
||||||
|
await trx
|
||||||
|
.delete(userPolicies)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(userPolicies.resourcePolicyId, policyId),
|
||||||
|
eq(userPolicies.userId, existingUserPolicy.userId)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function addUserPolicies(
|
||||||
|
policyId: number,
|
||||||
|
ssoUsers: string[],
|
||||||
|
orgId: string,
|
||||||
|
trx: Transaction
|
||||||
|
) {
|
||||||
|
for (const username of ssoUsers) {
|
||||||
|
const [user] = await trx
|
||||||
|
.select()
|
||||||
|
.from(users)
|
||||||
|
.innerJoin(userOrgs, eq(users.userId, userOrgs.userId))
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
or(eq(users.username, username), eq(users.email, username)),
|
||||||
|
eq(userOrgs.orgId, orgId)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
logger.warn(
|
||||||
|
`User '${username}' not found in org '${orgId}', skipping`
|
||||||
|
);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
await trx.insert(userPolicies).values({
|
||||||
|
userId: user.user.userId,
|
||||||
|
resourcePolicyId: policyId
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function syncWhitelistPolicyUsers(
|
||||||
|
policyId: number,
|
||||||
|
whitelistUsers: string[],
|
||||||
|
trx: Transaction
|
||||||
|
) {
|
||||||
|
const existingWhitelist = await trx
|
||||||
|
.select()
|
||||||
|
.from(resourcePolicyWhiteList)
|
||||||
|
.where(eq(resourcePolicyWhiteList.resourcePolicyId, policyId));
|
||||||
|
|
||||||
|
for (const email of whitelistUsers) {
|
||||||
|
const alreadyExists = existingWhitelist.some((w) => w.email === email);
|
||||||
|
|
||||||
|
if (!alreadyExists) {
|
||||||
|
await trx.insert(resourcePolicyWhiteList).values({
|
||||||
|
email,
|
||||||
|
resourcePolicyId: policyId
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const existingEntry of existingWhitelist) {
|
||||||
|
if (!whitelistUsers.includes(existingEntry.email)) {
|
||||||
|
await trx
|
||||||
|
.delete(resourcePolicyWhiteList)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(resourcePolicyWhiteList.resourcePolicyId, policyId),
|
||||||
|
eq(resourcePolicyWhiteList.email, existingEntry.email)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function syncPolicyRules(
|
||||||
|
policyId: number,
|
||||||
|
rules: ResourcePolicyData["rules"],
|
||||||
|
trx: Transaction
|
||||||
|
) {
|
||||||
|
const existingRules = await trx
|
||||||
|
.select()
|
||||||
|
.from(resourcePolicyRules)
|
||||||
|
.where(eq(resourcePolicyRules.resourcePolicyId, policyId))
|
||||||
|
.orderBy(resourcePolicyRules.priority);
|
||||||
|
|
||||||
|
for (const [index, rule] of rules.entries()) {
|
||||||
|
const intendedPriority = rule.priority ?? index + 1;
|
||||||
|
const existingRule = existingRules[index];
|
||||||
|
|
||||||
|
if (existingRule) {
|
||||||
|
await trx
|
||||||
|
.update(resourcePolicyRules)
|
||||||
|
.set({
|
||||||
|
action: getRuleAction(rule.action),
|
||||||
|
match: getRuleMatch(rule.match),
|
||||||
|
value: rule.value,
|
||||||
|
priority: intendedPriority,
|
||||||
|
enabled: rule.enabled ?? true
|
||||||
|
})
|
||||||
|
.where(eq(resourcePolicyRules.ruleId, existingRule.ruleId));
|
||||||
|
} else {
|
||||||
|
await trx.insert(resourcePolicyRules).values({
|
||||||
|
resourcePolicyId: policyId,
|
||||||
|
action: getRuleAction(rule.action),
|
||||||
|
match: getRuleMatch(rule.match),
|
||||||
|
value: rule.value,
|
||||||
|
priority: intendedPriority,
|
||||||
|
enabled: rule.enabled ?? true
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove extra rules
|
||||||
|
if (existingRules.length > rules.length) {
|
||||||
|
const rulesToDelete = existingRules.slice(rules.length);
|
||||||
|
for (const rule of rulesToDelete) {
|
||||||
|
await trx
|
||||||
|
.delete(resourcePolicyRules)
|
||||||
|
.where(eq(resourcePolicyRules.ruleId, rule.ruleId));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -83,7 +83,8 @@ export const RuleSchema = z
|
|||||||
action: z.enum(["allow", "deny", "pass"]),
|
action: z.enum(["allow", "deny", "pass"]),
|
||||||
match: z.enum(["cidr", "path", "ip", "country", "asn", "region"]),
|
match: z.enum(["cidr", "path", "ip", "country", "asn", "region"]),
|
||||||
value: z.coerce.string(),
|
value: z.coerce.string(),
|
||||||
priority: z.int().optional()
|
priority: z.int().optional(),
|
||||||
|
enabled: z.boolean().optional().default(true)
|
||||||
})
|
})
|
||||||
.refine(
|
.refine(
|
||||||
(rule) => {
|
(rule) => {
|
||||||
@@ -267,8 +268,37 @@ export const PublicResourceSchema = z
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
// If protocol/mode is http, it must have a full-domain
|
const effectiveProtocol = resource.mode ?? resource.protocol;
|
||||||
if ((resource.mode ?? resource.protocol) === "http") {
|
if (effectiveProtocol !== "ssh") {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
const authDaemonMode = resource["auth-daemon"]?.mode;
|
||||||
|
if (authDaemonMode !== "native" && authDaemonMode !== "site") {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
resource.targets.filter((target) => target != null).length <= 1
|
||||||
|
);
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: ["targets"],
|
||||||
|
error: "When protocol is 'ssh' and auth-daemon mode is 'native' or 'site', only one target/site is allowed"
|
||||||
|
}
|
||||||
|
)
|
||||||
|
.refine(
|
||||||
|
(resource) => {
|
||||||
|
if (isTargetsOnlyResource(resource)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// If protocol/mode is http, ssh, rdp, or vnc, it must have a full-domain
|
||||||
|
const effectiveProtocol = resource.mode ?? resource.protocol;
|
||||||
|
if (
|
||||||
|
effectiveProtocol !== undefined &&
|
||||||
|
["http", "ssh", "rdp", "vnc"].includes(effectiveProtocol)
|
||||||
|
) {
|
||||||
return (
|
return (
|
||||||
resource["full-domain"] !== undefined &&
|
resource["full-domain"] !== undefined &&
|
||||||
resource["full-domain"].length > 0
|
resource["full-domain"].length > 0
|
||||||
@@ -278,7 +308,7 @@ export const PublicResourceSchema = z
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: ["full-domain"],
|
path: ["full-domain"],
|
||||||
error: "When protocol is 'http', a 'full-domain' must be provided"
|
error: "When protocol is 'http', 'ssh', 'rdp', or 'vnc', a 'full-domain' must be provided"
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
.refine(
|
.refine(
|
||||||
@@ -505,7 +535,90 @@ export const PrivateResourceSchema = z
|
|||||||
{
|
{
|
||||||
message: "Destination must be a valid CIDR notation for cidr mode"
|
message: "Destination must be a valid CIDR notation for cidr mode"
|
||||||
}
|
}
|
||||||
);
|
)
|
||||||
|
.refine(
|
||||||
|
(data) => {
|
||||||
|
if (data.mode !== "ssh") {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
const authDaemonMode = data["auth-daemon"]?.mode;
|
||||||
|
if (authDaemonMode !== "native" && authDaemonMode !== "site") {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
const uniqueSites = new Set<string>();
|
||||||
|
if (data.site) {
|
||||||
|
uniqueSites.add(data.site);
|
||||||
|
}
|
||||||
|
for (const site of data.sites) {
|
||||||
|
uniqueSites.add(site);
|
||||||
|
}
|
||||||
|
|
||||||
|
return uniqueSites.size <= 1;
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: ["sites"],
|
||||||
|
message:
|
||||||
|
"When mode is 'ssh' and auth-daemon mode is 'native' or 'site', only one site/target is allowed"
|
||||||
|
}
|
||||||
|
)
|
||||||
|
.transform((data) => {
|
||||||
|
if (
|
||||||
|
data.mode === "ssh" &&
|
||||||
|
data.destination !== undefined &&
|
||||||
|
data["destination-port"] === undefined
|
||||||
|
) {
|
||||||
|
data["destination-port"] = 22;
|
||||||
|
}
|
||||||
|
return data;
|
||||||
|
});
|
||||||
|
|
||||||
|
export const ResourcePolicyRuleSchema = RuleSchema;
|
||||||
|
|
||||||
|
export const ResourcePolicySchema = z.object({
|
||||||
|
name: z.string().min(1).max(255),
|
||||||
|
sso: z.boolean().optional().default(true),
|
||||||
|
"auto-login-idp": z.int().positive().optional().nullable(),
|
||||||
|
"sso-roles": z
|
||||||
|
.array(z.string())
|
||||||
|
.optional()
|
||||||
|
.default([])
|
||||||
|
.refine((roles) => !roles.includes("Admin"), {
|
||||||
|
error: "Admin role cannot be included in sso-roles"
|
||||||
|
}),
|
||||||
|
"sso-users": z.array(z.string()).optional().default([]),
|
||||||
|
password: z.string().min(4).max(100).optional().nullable(),
|
||||||
|
pincode: z
|
||||||
|
.string()
|
||||||
|
.regex(/^\d{6}$/)
|
||||||
|
.optional()
|
||||||
|
.nullable(),
|
||||||
|
"basic-auth": z
|
||||||
|
.object({
|
||||||
|
user: z.string().min(4).max(100),
|
||||||
|
password: z.string().min(4).max(100),
|
||||||
|
"extended-compatibility": z.boolean().default(true)
|
||||||
|
})
|
||||||
|
.optional()
|
||||||
|
.nullable(),
|
||||||
|
"email-whitelist-enabled": z.boolean().optional().default(false),
|
||||||
|
"whitelist-users": z
|
||||||
|
.array(
|
||||||
|
z.email().or(
|
||||||
|
z.string().regex(/^\*@[\w.-]+\.[a-zA-Z]{2,}$/, {
|
||||||
|
error: "Invalid email address. Wildcard (*) must be the entire local part."
|
||||||
|
})
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.max(50)
|
||||||
|
.transform((v) => v.map((e) => e.toLowerCase()))
|
||||||
|
.optional()
|
||||||
|
.default([]),
|
||||||
|
"apply-rules": z.boolean().optional().default(false),
|
||||||
|
rules: z.array(ResourcePolicyRuleSchema).optional().default([])
|
||||||
|
});
|
||||||
|
export type ResourcePolicyData = z.infer<typeof ResourcePolicySchema>;
|
||||||
|
|
||||||
// Schema for the entire configuration object
|
// Schema for the entire configuration object
|
||||||
export const ConfigSchema = z
|
export const ConfigSchema = z
|
||||||
@@ -526,6 +639,10 @@ export const ConfigSchema = z
|
|||||||
.record(z.string(), PrivateResourceSchema)
|
.record(z.string(), PrivateResourceSchema)
|
||||||
.optional()
|
.optional()
|
||||||
.prefault({}),
|
.prefault({}),
|
||||||
|
"public-policies": z
|
||||||
|
.record(z.string(), ResourcePolicySchema)
|
||||||
|
.optional()
|
||||||
|
.prefault({}),
|
||||||
sites: z.record(z.string(), SiteSchema).optional().prefault({})
|
sites: z.record(z.string(), SiteSchema).optional().prefault({})
|
||||||
})
|
})
|
||||||
.transform((data) => {
|
.transform((data) => {
|
||||||
@@ -556,6 +673,10 @@ export const ConfigSchema = z
|
|||||||
string,
|
string,
|
||||||
z.infer<typeof PrivateResourceSchema>
|
z.infer<typeof PrivateResourceSchema>
|
||||||
>;
|
>;
|
||||||
|
"public-policies": Record<
|
||||||
|
string,
|
||||||
|
z.infer<typeof ResourcePolicySchema>
|
||||||
|
>;
|
||||||
sites: Record<string, z.infer<typeof SiteSchema>>;
|
sites: Record<string, z.infer<typeof SiteSchema>>;
|
||||||
};
|
};
|
||||||
})
|
})
|
||||||
@@ -695,3 +816,4 @@ export type Site = z.infer<typeof SiteSchema>;
|
|||||||
export type Target = z.infer<typeof TargetSchema>;
|
export type Target = z.infer<typeof TargetSchema>;
|
||||||
export type Resource = z.infer<typeof PublicResourceSchema>;
|
export type Resource = z.infer<typeof PublicResourceSchema>;
|
||||||
export type Config = z.infer<typeof ConfigSchema>;
|
export type Config = z.infer<typeof ConfigSchema>;
|
||||||
|
export type BlueprintResourcePolicy = z.infer<typeof ResourcePolicySchema>;
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
import z from "zod";
|
import z from "zod";
|
||||||
import ipaddr from "ipaddr.js";
|
import ipaddr from "ipaddr.js";
|
||||||
|
import { COUNTRIES } from "@server/db/countries";
|
||||||
|
import { isValidRegionId } from "@server/db/regions";
|
||||||
|
|
||||||
export function isValidCIDR(cidr: string): boolean {
|
export function isValidCIDR(cidr: string): boolean {
|
||||||
return (
|
return (
|
||||||
@@ -67,6 +69,45 @@ export function isValidUrlGlobPattern(pattern: string): boolean {
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const RESOURCE_RULE_MATCH_TYPES = [
|
||||||
|
"CIDR",
|
||||||
|
"IP",
|
||||||
|
"PATH",
|
||||||
|
"COUNTRY",
|
||||||
|
"ASN",
|
||||||
|
"REGION"
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
export type ResourceRuleMatchType = (typeof RESOURCE_RULE_MATCH_TYPES)[number];
|
||||||
|
|
||||||
|
export function getResourceRuleValueValidationError(
|
||||||
|
match: ResourceRuleMatchType,
|
||||||
|
value: string
|
||||||
|
): string | null {
|
||||||
|
switch (match) {
|
||||||
|
case "CIDR":
|
||||||
|
return isValidCIDR(value) ? null : "Invalid CIDR provided";
|
||||||
|
case "IP":
|
||||||
|
return isValidIP(value) ? null : "Invalid IP provided";
|
||||||
|
case "PATH":
|
||||||
|
return isValidUrlGlobPattern(value)
|
||||||
|
? null
|
||||||
|
: "Invalid URL glob pattern provided";
|
||||||
|
case "REGION":
|
||||||
|
return isValidRegionId(value) ? null : "Invalid region ID provided";
|
||||||
|
case "COUNTRY":
|
||||||
|
return COUNTRIES.some((country) => country.code === value)
|
||||||
|
? null
|
||||||
|
: "Invalid country code provided";
|
||||||
|
case "ASN":
|
||||||
|
return /^AS\d+$/i.test(value.trim())
|
||||||
|
? null
|
||||||
|
: "Invalid ASN provided";
|
||||||
|
default:
|
||||||
|
return "Invalid rule match type provided";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export function isUrlValid(url: string | undefined) {
|
export function isUrlValid(url: string | undefined) {
|
||||||
if (!url) return true; // the link is optional in the schema so if it's empty it's valid
|
if (!url) return true; // the link is optional in the schema so if it's empty it's valid
|
||||||
var pattern = new RegExp(
|
var pattern = new RegExp(
|
||||||
|
|||||||
@@ -109,7 +109,11 @@ export const privateConfigSchema = z
|
|||||||
enable_redis: z.boolean().optional().default(false),
|
enable_redis: z.boolean().optional().default(false),
|
||||||
use_pangolin_dns: z.boolean().optional().default(false),
|
use_pangolin_dns: z.boolean().optional().default(false),
|
||||||
use_org_only_idp: z.boolean().optional(),
|
use_org_only_idp: z.boolean().optional(),
|
||||||
enable_acme_cert_sync: z.boolean().optional().default(true)
|
enable_acme_cert_sync: z.boolean().optional().default(true),
|
||||||
|
disable_private_http_placeholder: z
|
||||||
|
.boolean()
|
||||||
|
.optional()
|
||||||
|
.default(false)
|
||||||
})
|
})
|
||||||
.optional()
|
.optional()
|
||||||
.prefault({}),
|
.prefault({}),
|
||||||
|
|||||||
@@ -12,7 +12,6 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import {
|
import {
|
||||||
browserGatewayTarget,
|
|
||||||
certificates,
|
certificates,
|
||||||
db,
|
db,
|
||||||
domainNamespaces,
|
domainNamespaces,
|
||||||
@@ -172,8 +171,15 @@ export async function getTraefikConfig(
|
|||||||
),
|
),
|
||||||
inArray(sites.type, siteTypes),
|
inArray(sites.type, siteTypes),
|
||||||
allowRawResources
|
allowRawResources
|
||||||
? inArray(resources.mode, ["http", "udp", "tcp"]) // allow all three
|
? inArray(resources.mode, [
|
||||||
: eq(resources.mode, "http")
|
"http",
|
||||||
|
"udp",
|
||||||
|
"tcp",
|
||||||
|
"vnc",
|
||||||
|
"ssh",
|
||||||
|
"rdp"
|
||||||
|
]) // allow all three
|
||||||
|
: inArray(resources.mode, ["http", "vnc", "ssh", "rdp"])
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
.orderBy(desc(targets.priority), targets.targetId); // stable ordering
|
.orderBy(desc(targets.priority), targets.targetId); // stable ordering
|
||||||
@@ -181,7 +187,10 @@ export async function getTraefikConfig(
|
|||||||
// Group by resource and include targets with their unique site data
|
// Group by resource and include targets with their unique site data
|
||||||
const resourcesMap = new Map();
|
const resourcesMap = new Map();
|
||||||
|
|
||||||
resourcesWithTargetsAndSites.forEach((row) => {
|
for (const row of resourcesWithTargetsAndSites) {
|
||||||
|
if (!["http", "tcp", "udp"].includes(row.mode)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
const resourceId = row.resourceId;
|
const resourceId = row.resourceId;
|
||||||
const resourceName = sanitize(row.resourceName) || "";
|
const resourceName = sanitize(row.resourceName) || "";
|
||||||
const targetPath = encodePath(row.path); // Use encodePath to avoid collisions (e.g. "/a/b" vs "/a-b")
|
const targetPath = encodePath(row.path); // Use encodePath to avoid collisions (e.g. "/a/b" vs "/a-b")
|
||||||
@@ -191,7 +200,7 @@ export async function getTraefikConfig(
|
|||||||
const priority = row.priority ?? 100;
|
const priority = row.priority ?? 100;
|
||||||
|
|
||||||
if (filterOutNamespaceDomains && row.domainNamespaceId) {
|
if (filterOutNamespaceDomains && row.domainNamespaceId) {
|
||||||
return;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create a unique key combining resourceId, path config, and rewrite config
|
// Create a unique key combining resourceId, path config, and rewrite config
|
||||||
@@ -218,7 +227,7 @@ export async function getTraefikConfig(
|
|||||||
logger.debug(
|
logger.debug(
|
||||||
`Invalid path rewrite configuration for resource ${resourceId}: ${validation.error}`
|
`Invalid path rewrite configuration for resource ${resourceId}: ${validation.error}`
|
||||||
);
|
);
|
||||||
return;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
resourcesMap.set(mapKey, {
|
resourcesMap.set(mapKey, {
|
||||||
@@ -275,7 +284,7 @@ export async function getTraefikConfig(
|
|||||||
online: row.siteOnline
|
online: row.siteOnline
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
});
|
}
|
||||||
|
|
||||||
// Group browser gateway targets by resource
|
// Group browser gateway targets by resource
|
||||||
type BrowserGatewayResourceEntry = {
|
type BrowserGatewayResourceEntry = {
|
||||||
@@ -295,13 +304,12 @@ export async function getTraefikConfig(
|
|||||||
maintenanceMessage: string | null;
|
maintenanceMessage: string | null;
|
||||||
maintenanceEstimatedTime: string | null;
|
maintenanceEstimatedTime: string | null;
|
||||||
targets: {
|
targets: {
|
||||||
browserGatewayTargetId: number;
|
targetId: number;
|
||||||
bgType: string;
|
bgType: string;
|
||||||
siteId: number;
|
siteId: number;
|
||||||
siteType: string;
|
siteType: string;
|
||||||
siteOnline: boolean | null;
|
siteOnline: boolean | null;
|
||||||
subnet: string | null;
|
subnet: string | null;
|
||||||
siteExitNodeId: number | null;
|
|
||||||
}[];
|
}[];
|
||||||
};
|
};
|
||||||
const browserGatewayResourcesMap = new Map<
|
const browserGatewayResourcesMap = new Map<
|
||||||
@@ -310,66 +318,10 @@ export async function getTraefikConfig(
|
|||||||
>();
|
>();
|
||||||
|
|
||||||
if (allowBrowserGatewayResources) {
|
if (allowBrowserGatewayResources) {
|
||||||
// Query browser gateway targets for this exit node
|
for (const row of resourcesWithTargetsAndSites) {
|
||||||
const browserGatewayRows = await db
|
if (!["ssh", "vnc", "rdp"].includes(row.mode)) {
|
||||||
.select({
|
continue;
|
||||||
// Resource fields
|
}
|
||||||
resourceId: resources.resourceId,
|
|
||||||
resourceName: resources.name,
|
|
||||||
fullDomain: resources.fullDomain,
|
|
||||||
ssl: resources.ssl,
|
|
||||||
subdomain: resources.subdomain,
|
|
||||||
domainId: resources.domainId,
|
|
||||||
enabled: resources.enabled,
|
|
||||||
wildcard: resources.wildcard,
|
|
||||||
domainCertResolver: domains.certResolver,
|
|
||||||
preferWildcardCert: domains.preferWildcardCert,
|
|
||||||
domainNamespaceId: domainNamespaces.domainNamespaceId,
|
|
||||||
// Maintenance fields
|
|
||||||
maintenanceModeEnabled: resources.maintenanceModeEnabled,
|
|
||||||
maintenanceModeType: resources.maintenanceModeType,
|
|
||||||
maintenanceTitle: resources.maintenanceTitle,
|
|
||||||
maintenanceMessage: resources.maintenanceMessage,
|
|
||||||
maintenanceEstimatedTime: resources.maintenanceEstimatedTime,
|
|
||||||
// Browser gateway target fields
|
|
||||||
browserGatewayTargetId:
|
|
||||||
browserGatewayTarget.browserGatewayTargetId,
|
|
||||||
bgType: browserGatewayTarget.type,
|
|
||||||
// Site fields
|
|
||||||
siteId: sites.siteId,
|
|
||||||
siteType: sites.type,
|
|
||||||
siteOnline: sites.online,
|
|
||||||
subnet: sites.subnet,
|
|
||||||
siteExitNodeId: sites.exitNodeId
|
|
||||||
})
|
|
||||||
.from(browserGatewayTarget)
|
|
||||||
.innerJoin(sites, eq(sites.siteId, browserGatewayTarget.siteId))
|
|
||||||
.innerJoin(
|
|
||||||
resources,
|
|
||||||
eq(resources.resourceId, browserGatewayTarget.resourceId)
|
|
||||||
)
|
|
||||||
.leftJoin(domains, eq(domains.domainId, resources.domainId))
|
|
||||||
.leftJoin(
|
|
||||||
domainNamespaces,
|
|
||||||
eq(domainNamespaces.domainId, resources.domainId)
|
|
||||||
)
|
|
||||||
.where(
|
|
||||||
and(
|
|
||||||
eq(resources.enabled, true),
|
|
||||||
or(
|
|
||||||
eq(sites.exitNodeId, exitNodeId),
|
|
||||||
and(
|
|
||||||
isNull(sites.exitNodeId),
|
|
||||||
sql`(${siteTypes.includes("local") ? 1 : 0} = 1)`,
|
|
||||||
eq(sites.type, "local"),
|
|
||||||
sql`(${build != "saas" ? 1 : 0} = 1)`
|
|
||||||
)
|
|
||||||
),
|
|
||||||
inArray(sites.type, siteTypes)
|
|
||||||
)
|
|
||||||
);
|
|
||||||
|
|
||||||
for (const row of browserGatewayRows) {
|
|
||||||
if (filterOutNamespaceDomains && row.domainNamespaceId) {
|
if (filterOutNamespaceDomains && row.domainNamespaceId) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
@@ -394,13 +346,12 @@ export async function getTraefikConfig(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
browserGatewayResourcesMap.get(row.resourceId)!.targets.push({
|
browserGatewayResourcesMap.get(row.resourceId)!.targets.push({
|
||||||
browserGatewayTargetId: row.browserGatewayTargetId,
|
targetId: row.targetId,
|
||||||
bgType: row.bgType,
|
bgType: row.mode,
|
||||||
siteId: row.siteId,
|
siteId: row.siteId,
|
||||||
siteType: row.siteType,
|
siteType: row.siteType,
|
||||||
siteOnline: row.siteOnline,
|
siteOnline: row.siteOnline,
|
||||||
subnet: row.subnet,
|
subnet: row.subnet
|
||||||
siteExitNodeId: row.siteExitNodeId
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -410,7 +361,11 @@ export async function getTraefikConfig(
|
|||||||
fullDomain: string | null;
|
fullDomain: string | null;
|
||||||
mode: "http" | "host" | "cidr" | "ssh";
|
mode: "http" | "host" | "cidr" | "ssh";
|
||||||
}[] = [];
|
}[] = [];
|
||||||
if (build == "enterprise") {
|
if (
|
||||||
|
build == "enterprise" &&
|
||||||
|
!privateConfig.getRawPrivateConfig().flags
|
||||||
|
.disable_private_http_placeholder
|
||||||
|
) {
|
||||||
// we dont want to do this on the cloud
|
// we dont want to do this on the cloud
|
||||||
// Query siteResources in HTTP mode with SSL enabled and aliases - cert generation / HTTPS edge
|
// Query siteResources in HTTP mode with SSL enabled and aliases - cert generation / HTTPS edge
|
||||||
siteResourcesWithFullDomain = await db
|
siteResourcesWithFullDomain = await db
|
||||||
|
|||||||
@@ -1,187 +0,0 @@
|
|||||||
/*
|
|
||||||
* This file is part of a proprietary work.
|
|
||||||
*
|
|
||||||
* Copyright (c) 2025-2026 Fossorial, Inc.
|
|
||||||
* All rights reserved.
|
|
||||||
*
|
|
||||||
* This file is licensed under the Fossorial Commercial License.
|
|
||||||
* You may not use this file except in compliance with the License.
|
|
||||||
* Unauthorized use, copying, modification, or distribution is strictly prohibited.
|
|
||||||
*
|
|
||||||
* This file is not licensed under the AGPLv3.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { Request, Response, NextFunction } from "express";
|
|
||||||
import { z } from "zod";
|
|
||||||
import {
|
|
||||||
browserGatewayTarget,
|
|
||||||
BrowserGatewayTarget,
|
|
||||||
db,
|
|
||||||
newts,
|
|
||||||
resources,
|
|
||||||
sites
|
|
||||||
} from "@server/db";
|
|
||||||
import { eq, and } from "drizzle-orm";
|
|
||||||
import response from "@server/lib/response";
|
|
||||||
import HttpCode from "@server/types/HttpCode";
|
|
||||||
import createHttpError from "http-errors";
|
|
||||||
import logger from "@server/logger";
|
|
||||||
import { fromError } from "zod-validation-error";
|
|
||||||
import { OpenAPITags, registry } from "@server/openApi";
|
|
||||||
import { encrypt } from "@server/lib/crypto";
|
|
||||||
import config from "@server/lib/config";
|
|
||||||
import { sendBrowserGatewayTargets } from "@server/routers/newt/targets";
|
|
||||||
import { generateId } from "@server/auth/sessions/app";
|
|
||||||
|
|
||||||
const paramsSchema = z.strictObject({
|
|
||||||
orgId: z.string().nonempty(),
|
|
||||||
resourceId: z.string().transform(Number).pipe(z.number().int().positive())
|
|
||||||
});
|
|
||||||
|
|
||||||
const bodySchema = z.strictObject({
|
|
||||||
siteId: z.number().int().positive(),
|
|
||||||
type: z.enum(["ssh", "rdp", "vnc"]),
|
|
||||||
destination: z.string().nonempty(),
|
|
||||||
destinationPort: z.number().int().min(1).max(65535)
|
|
||||||
});
|
|
||||||
|
|
||||||
export type CreateBrowserGatewayTargetResponse = BrowserGatewayTarget;
|
|
||||||
|
|
||||||
registry.registerPath({
|
|
||||||
method: "put",
|
|
||||||
path: "/org/{orgId}/resource/{resourceId}/browser-gateway-target",
|
|
||||||
description: "Create a browser gateway target for a resource.",
|
|
||||||
tags: [OpenAPITags.Org],
|
|
||||||
request: {
|
|
||||||
params: paramsSchema,
|
|
||||||
body: {
|
|
||||||
content: {
|
|
||||||
"application/json": {
|
|
||||||
schema: bodySchema
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
responses: {}
|
|
||||||
});
|
|
||||||
|
|
||||||
export async function createBrowserGatewayTarget(
|
|
||||||
req: Request,
|
|
||||||
res: Response,
|
|
||||||
next: NextFunction
|
|
||||||
): Promise<any> {
|
|
||||||
try {
|
|
||||||
const parsedParams = paramsSchema.safeParse(req.params);
|
|
||||||
if (!parsedParams.success) {
|
|
||||||
return next(
|
|
||||||
createHttpError(
|
|
||||||
HttpCode.BAD_REQUEST,
|
|
||||||
fromError(parsedParams.error).toString()
|
|
||||||
)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const { orgId, resourceId } = parsedParams.data;
|
|
||||||
|
|
||||||
const parsedBody = bodySchema.safeParse(req.body);
|
|
||||||
if (!parsedBody.success) {
|
|
||||||
return next(
|
|
||||||
createHttpError(
|
|
||||||
HttpCode.BAD_REQUEST,
|
|
||||||
fromError(parsedBody.error).toString()
|
|
||||||
)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const { siteId, type, destination, destinationPort } = parsedBody.data;
|
|
||||||
|
|
||||||
const [resource] = await db
|
|
||||||
.select()
|
|
||||||
.from(resources)
|
|
||||||
.where(
|
|
||||||
and(
|
|
||||||
eq(resources.resourceId, resourceId),
|
|
||||||
eq(resources.orgId, orgId)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
.limit(1);
|
|
||||||
|
|
||||||
if (!resource) {
|
|
||||||
return next(
|
|
||||||
createHttpError(
|
|
||||||
HttpCode.NOT_FOUND,
|
|
||||||
`Resource with ID ${resourceId} not found in organization ${orgId}`
|
|
||||||
)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const [site] = await db
|
|
||||||
.select()
|
|
||||||
.from(sites)
|
|
||||||
.where(and(eq(sites.siteId, siteId), eq(sites.orgId, orgId)))
|
|
||||||
.limit(1);
|
|
||||||
|
|
||||||
if (!site) {
|
|
||||||
return next(
|
|
||||||
createHttpError(
|
|
||||||
HttpCode.NOT_FOUND,
|
|
||||||
`Site with ID ${siteId} not found in organization ${orgId}`
|
|
||||||
)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const plainToken = generateId(48);
|
|
||||||
const encryptedToken = encrypt(
|
|
||||||
plainToken,
|
|
||||||
config.getRawConfig().server.secret!
|
|
||||||
);
|
|
||||||
|
|
||||||
const [record] = await db
|
|
||||||
.insert(browserGatewayTarget)
|
|
||||||
.values({
|
|
||||||
resourceId,
|
|
||||||
siteId,
|
|
||||||
type,
|
|
||||||
destination,
|
|
||||||
destinationPort,
|
|
||||||
authToken: encryptedToken
|
|
||||||
})
|
|
||||||
.returning();
|
|
||||||
|
|
||||||
if (site.type === "newt") {
|
|
||||||
const [newt] = await db
|
|
||||||
.select()
|
|
||||||
.from(newts)
|
|
||||||
.where(eq(newts.siteId, siteId))
|
|
||||||
.limit(1);
|
|
||||||
|
|
||||||
if (newt) {
|
|
||||||
await sendBrowserGatewayTargets(
|
|
||||||
newt.newtId,
|
|
||||||
[record],
|
|
||||||
newt.version
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
logger.info(
|
|
||||||
`Created browser gateway target ${record.browserGatewayTargetId} for resource ${resourceId}`
|
|
||||||
);
|
|
||||||
|
|
||||||
return response<CreateBrowserGatewayTargetResponse>(res, {
|
|
||||||
data: record,
|
|
||||||
success: true,
|
|
||||||
error: false,
|
|
||||||
message: "Browser gateway target created successfully",
|
|
||||||
status: HttpCode.CREATED
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
logger.error(error);
|
|
||||||
return next(
|
|
||||||
createHttpError(
|
|
||||||
HttpCode.INTERNAL_SERVER_ERROR,
|
|
||||||
"Failed to create browser gateway target"
|
|
||||||
)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,130 +0,0 @@
|
|||||||
/*
|
|
||||||
* This file is part of a proprietary work.
|
|
||||||
*
|
|
||||||
* Copyright (c) 2025-2026 Fossorial, Inc.
|
|
||||||
* All rights reserved.
|
|
||||||
*
|
|
||||||
* This file is licensed under the Fossorial Commercial License.
|
|
||||||
* You may not use this file except in compliance with the License.
|
|
||||||
* Unauthorized use, copying, modification, or distribution is strictly prohibited.
|
|
||||||
*
|
|
||||||
* This file is not licensed under the AGPLv3.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { Request, Response, NextFunction } from "express";
|
|
||||||
import { z } from "zod";
|
|
||||||
import { browserGatewayTarget, db, newts, sites } from "@server/db";
|
|
||||||
import { eq, and } from "drizzle-orm";
|
|
||||||
import response from "@server/lib/response";
|
|
||||||
import HttpCode from "@server/types/HttpCode";
|
|
||||||
import createHttpError from "http-errors";
|
|
||||||
import logger from "@server/logger";
|
|
||||||
import { fromError } from "zod-validation-error";
|
|
||||||
import { OpenAPITags, registry } from "@server/openApi";
|
|
||||||
import { removeBrowserGatewayTarget } from "@server/routers/newt/targets";
|
|
||||||
|
|
||||||
const paramsSchema = z.strictObject({
|
|
||||||
orgId: z.string().nonempty(),
|
|
||||||
browserGatewayTargetId: z
|
|
||||||
.string()
|
|
||||||
.transform(Number)
|
|
||||||
.pipe(z.number().int().positive())
|
|
||||||
});
|
|
||||||
|
|
||||||
registry.registerPath({
|
|
||||||
method: "delete",
|
|
||||||
path: "/org/{orgId}/browser-gateway-target/{browserGatewayTargetId}",
|
|
||||||
description: "Delete a browser gateway target.",
|
|
||||||
tags: [OpenAPITags.Org],
|
|
||||||
request: {
|
|
||||||
params: paramsSchema
|
|
||||||
},
|
|
||||||
responses: {}
|
|
||||||
});
|
|
||||||
|
|
||||||
export async function deleteBrowserGatewayTarget(
|
|
||||||
req: Request,
|
|
||||||
res: Response,
|
|
||||||
next: NextFunction
|
|
||||||
): Promise<any> {
|
|
||||||
try {
|
|
||||||
const parsedParams = paramsSchema.safeParse(req.params);
|
|
||||||
if (!parsedParams.success) {
|
|
||||||
return next(
|
|
||||||
createHttpError(
|
|
||||||
HttpCode.BAD_REQUEST,
|
|
||||||
fromError(parsedParams.error).toString()
|
|
||||||
)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const { orgId, browserGatewayTargetId } = parsedParams.data;
|
|
||||||
|
|
||||||
const [existing] = await db
|
|
||||||
.select({ bgt: browserGatewayTarget, site: sites })
|
|
||||||
.from(browserGatewayTarget)
|
|
||||||
.innerJoin(sites, eq(sites.siteId, browserGatewayTarget.siteId))
|
|
||||||
.where(
|
|
||||||
and(
|
|
||||||
eq(
|
|
||||||
browserGatewayTarget.browserGatewayTargetId,
|
|
||||||
browserGatewayTargetId
|
|
||||||
),
|
|
||||||
eq(sites.orgId, orgId)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
.limit(1);
|
|
||||||
|
|
||||||
if (!existing) {
|
|
||||||
return next(
|
|
||||||
createHttpError(
|
|
||||||
HttpCode.NOT_FOUND,
|
|
||||||
`Browser gateway target with ID ${browserGatewayTargetId} not found`
|
|
||||||
)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
await db
|
|
||||||
.delete(browserGatewayTarget)
|
|
||||||
.where(
|
|
||||||
eq(
|
|
||||||
browserGatewayTarget.browserGatewayTargetId,
|
|
||||||
browserGatewayTargetId
|
|
||||||
)
|
|
||||||
);
|
|
||||||
|
|
||||||
if (existing.site.type === "newt") {
|
|
||||||
const [newt] = await db
|
|
||||||
.select()
|
|
||||||
.from(newts)
|
|
||||||
.where(eq(newts.siteId, existing.bgt.siteId))
|
|
||||||
.limit(1);
|
|
||||||
|
|
||||||
if (newt) {
|
|
||||||
await removeBrowserGatewayTarget(
|
|
||||||
newt.newtId,
|
|
||||||
browserGatewayTargetId,
|
|
||||||
newt.version
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
logger.info(`Deleted browser gateway target ${browserGatewayTargetId}`);
|
|
||||||
|
|
||||||
return response(res, {
|
|
||||||
data: null,
|
|
||||||
success: true,
|
|
||||||
error: false,
|
|
||||||
message: "Browser gateway target deleted successfully",
|
|
||||||
status: HttpCode.OK
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
logger.error(error);
|
|
||||||
return next(
|
|
||||||
createHttpError(
|
|
||||||
HttpCode.INTERNAL_SERVER_ERROR,
|
|
||||||
"Failed to delete browser gateway target"
|
|
||||||
)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,109 +0,0 @@
|
|||||||
/*
|
|
||||||
* This file is part of a proprietary work.
|
|
||||||
*
|
|
||||||
* Copyright (c) 2025-2026 Fossorial, Inc.
|
|
||||||
* All rights reserved.
|
|
||||||
*
|
|
||||||
* This file is licensed under the Fossorial Commercial License.
|
|
||||||
* You may not use this file except in compliance with the License.
|
|
||||||
* Unauthorized use, copying, modification, or distribution is strictly prohibited.
|
|
||||||
*
|
|
||||||
* This file is not licensed under the AGPLv3.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { Request, Response, NextFunction } from "express";
|
|
||||||
import { z } from "zod";
|
|
||||||
import {
|
|
||||||
browserGatewayTarget,
|
|
||||||
BrowserGatewayTarget,
|
|
||||||
db,
|
|
||||||
sites
|
|
||||||
} from "@server/db";
|
|
||||||
import { eq, and } from "drizzle-orm";
|
|
||||||
import response from "@server/lib/response";
|
|
||||||
import HttpCode from "@server/types/HttpCode";
|
|
||||||
import createHttpError from "http-errors";
|
|
||||||
import logger from "@server/logger";
|
|
||||||
import { fromError } from "zod-validation-error";
|
|
||||||
import { OpenAPITags, registry } from "@server/openApi";
|
|
||||||
|
|
||||||
const paramsSchema = z.strictObject({
|
|
||||||
orgId: z.string().nonempty(),
|
|
||||||
browserGatewayTargetId: z
|
|
||||||
.string()
|
|
||||||
.transform(Number)
|
|
||||||
.pipe(z.number().int().positive())
|
|
||||||
});
|
|
||||||
|
|
||||||
export type GetBrowserGatewayTargetResponse = BrowserGatewayTarget;
|
|
||||||
|
|
||||||
registry.registerPath({
|
|
||||||
method: "get",
|
|
||||||
path: "/org/{orgId}/browser-gateway-target/{browserGatewayTargetId}",
|
|
||||||
description: "Get a browser gateway target.",
|
|
||||||
tags: [OpenAPITags.Org],
|
|
||||||
request: {
|
|
||||||
params: paramsSchema
|
|
||||||
},
|
|
||||||
responses: {}
|
|
||||||
});
|
|
||||||
|
|
||||||
export async function getBrowserGatewayTarget(
|
|
||||||
req: Request,
|
|
||||||
res: Response,
|
|
||||||
next: NextFunction
|
|
||||||
): Promise<any> {
|
|
||||||
try {
|
|
||||||
const parsedParams = paramsSchema.safeParse(req.params);
|
|
||||||
if (!parsedParams.success) {
|
|
||||||
return next(
|
|
||||||
createHttpError(
|
|
||||||
HttpCode.BAD_REQUEST,
|
|
||||||
fromError(parsedParams.error).toString()
|
|
||||||
)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const { orgId, browserGatewayTargetId } = parsedParams.data;
|
|
||||||
|
|
||||||
const [result] = await db
|
|
||||||
.select({ bgt: browserGatewayTarget })
|
|
||||||
.from(browserGatewayTarget)
|
|
||||||
.innerJoin(sites, eq(sites.siteId, browserGatewayTarget.siteId))
|
|
||||||
.where(
|
|
||||||
and(
|
|
||||||
eq(
|
|
||||||
browserGatewayTarget.browserGatewayTargetId,
|
|
||||||
browserGatewayTargetId
|
|
||||||
),
|
|
||||||
eq(sites.orgId, orgId)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
.limit(1);
|
|
||||||
|
|
||||||
if (!result) {
|
|
||||||
return next(
|
|
||||||
createHttpError(
|
|
||||||
HttpCode.NOT_FOUND,
|
|
||||||
`Browser gateway target with ID ${browserGatewayTargetId} not found`
|
|
||||||
)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return response<GetBrowserGatewayTargetResponse>(res, {
|
|
||||||
data: result.bgt,
|
|
||||||
success: true,
|
|
||||||
error: false,
|
|
||||||
message: "Browser gateway target retrieved successfully",
|
|
||||||
status: HttpCode.OK
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
logger.error(error);
|
|
||||||
return next(
|
|
||||||
createHttpError(
|
|
||||||
HttpCode.INTERNAL_SERVER_ERROR,
|
|
||||||
"Failed to retrieve browser gateway target"
|
|
||||||
)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -13,9 +13,8 @@
|
|||||||
|
|
||||||
import { Request, Response, NextFunction } from "express";
|
import { Request, Response, NextFunction } from "express";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { browserGatewayTarget, db } from "@server/db";
|
import { db, resources, targets } from "@server/db";
|
||||||
import { resources, targets } from "@server/db";
|
import { eq, and, inArray } from "drizzle-orm";
|
||||||
import { 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";
|
||||||
@@ -51,31 +50,30 @@ export async function getBrowserTarget(
|
|||||||
|
|
||||||
logger.info(`Retrieving browser target for domain: ${fullDomain}`);
|
logger.info(`Retrieving browser target for domain: ${fullDomain}`);
|
||||||
|
|
||||||
const [browserTarget] = await db
|
const [row] = await db
|
||||||
.select({
|
.select({
|
||||||
destination: browserGatewayTarget.destination,
|
ip: targets.ip,
|
||||||
destinationPort: browserGatewayTarget.destinationPort,
|
port: targets.port,
|
||||||
authToken: browserGatewayTarget.authToken,
|
authToken: targets.authToken,
|
||||||
resourceId: resources.resourceId,
|
resourceId: resources.resourceId,
|
||||||
niceId: resources.niceId,
|
niceId: resources.niceId,
|
||||||
|
name: resources.name,
|
||||||
orgId: resources.orgId,
|
orgId: resources.orgId,
|
||||||
pamMode: resources.pamMode,
|
pamMode: resources.pamMode,
|
||||||
authDaemonMode: resources.authDaemonMode
|
authDaemonMode: resources.authDaemonMode
|
||||||
})
|
})
|
||||||
.from(browserGatewayTarget)
|
.from(targets)
|
||||||
.innerJoin(
|
.innerJoin(resources, eq(targets.resourceId, resources.resourceId))
|
||||||
resources,
|
.where(
|
||||||
eq(browserGatewayTarget.resourceId, resources.resourceId)
|
and(
|
||||||
|
eq(resources.fullDomain, fullDomain),
|
||||||
|
eq(targets.enabled, true),
|
||||||
|
inArray(targets.mode, ["ssh", "rdp", "vnc"])
|
||||||
|
)
|
||||||
)
|
)
|
||||||
.where(eq(resources.fullDomain, fullDomain))
|
|
||||||
.limit(1);
|
.limit(1);
|
||||||
|
|
||||||
const decryptedAuthToken = decrypt(
|
if (!row) {
|
||||||
browserTarget.authToken,
|
|
||||||
config.getRawConfig().server.secret!
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!browserTarget) {
|
|
||||||
return next(
|
return next(
|
||||||
createHttpError(
|
createHttpError(
|
||||||
HttpCode.NOT_FOUND,
|
HttpCode.NOT_FOUND,
|
||||||
@@ -84,16 +82,21 @@ export async function getBrowserTarget(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const decryptedAuthToken = row.authToken
|
||||||
|
? decrypt(row.authToken, config.getRawConfig().server.secret!)
|
||||||
|
: "";
|
||||||
|
|
||||||
return response<GetBrowserTargetResponse>(res, {
|
return response<GetBrowserTargetResponse>(res, {
|
||||||
data: {
|
data: {
|
||||||
ip: browserTarget.destination,
|
ip: row.ip,
|
||||||
port: browserTarget.destinationPort,
|
port: row.port,
|
||||||
authToken: decryptedAuthToken,
|
authToken: decryptedAuthToken,
|
||||||
pamMode: browserTarget.pamMode,
|
pamMode: row.pamMode,
|
||||||
authDaemonMode: browserTarget.authDaemonMode,
|
authDaemonMode: row.authDaemonMode,
|
||||||
orgId: browserTarget.orgId,
|
orgId: row.orgId,
|
||||||
resourceId: browserTarget.resourceId,
|
resourceId: row.resourceId,
|
||||||
niceId: browserTarget.niceId
|
niceId: row.niceId,
|
||||||
|
name: row.name ?? ""
|
||||||
},
|
},
|
||||||
success: true,
|
success: true,
|
||||||
error: false,
|
error: false,
|
||||||
|
|||||||
@@ -11,9 +11,4 @@
|
|||||||
* This file is not licensed under the AGPLv3.
|
* This file is not licensed under the AGPLv3.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
export * from "./createBrowserGatewayTarget";
|
|
||||||
export * from "./updateBrowserGatewayTarget";
|
|
||||||
export * from "./deleteBrowserGatewayTarget";
|
|
||||||
export * from "./getBrowserGatewayTarget";
|
|
||||||
export * from "./listBrowserGatewayTargets";
|
|
||||||
export * from "./getBrowserTarget";
|
export * from "./getBrowserTarget";
|
||||||
|
|||||||
@@ -1,159 +0,0 @@
|
|||||||
/*
|
|
||||||
* This file is part of a proprietary work.
|
|
||||||
*
|
|
||||||
* Copyright (c) 2025-2026 Fossorial, Inc.
|
|
||||||
* All rights reserved.
|
|
||||||
*
|
|
||||||
* This file is licensed under the Fossorial Commercial License.
|
|
||||||
* You may not use this file except in compliance with the License.
|
|
||||||
* Unauthorized use, copying, modification, or distribution is strictly prohibited.
|
|
||||||
*
|
|
||||||
* This file is not licensed under the AGPLv3.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { Request, Response, NextFunction } from "express";
|
|
||||||
import { z } from "zod";
|
|
||||||
import {
|
|
||||||
browserGatewayTarget,
|
|
||||||
BrowserGatewayTarget,
|
|
||||||
db,
|
|
||||||
resources,
|
|
||||||
sites
|
|
||||||
} from "@server/db";
|
|
||||||
import { eq, and } from "drizzle-orm";
|
|
||||||
import response from "@server/lib/response";
|
|
||||||
import HttpCode from "@server/types/HttpCode";
|
|
||||||
import createHttpError from "http-errors";
|
|
||||||
import logger from "@server/logger";
|
|
||||||
import { fromError } from "zod-validation-error";
|
|
||||||
import { OpenAPITags, registry } from "@server/openApi";
|
|
||||||
|
|
||||||
const paramsSchema = z.strictObject({
|
|
||||||
orgId: z.string().nonempty(),
|
|
||||||
resourceId: z.string().transform(Number).pipe(z.number().int().positive())
|
|
||||||
});
|
|
||||||
|
|
||||||
const querySchema = z.object({
|
|
||||||
limit: z
|
|
||||||
.string()
|
|
||||||
.optional()
|
|
||||||
.default("1000")
|
|
||||||
.transform(Number)
|
|
||||||
.pipe(z.number().int().positive()),
|
|
||||||
offset: z
|
|
||||||
.string()
|
|
||||||
.optional()
|
|
||||||
.default("0")
|
|
||||||
.transform(Number)
|
|
||||||
.pipe(z.number().int().nonnegative())
|
|
||||||
});
|
|
||||||
|
|
||||||
export type ListBrowserGatewayTargetsResponse = {
|
|
||||||
targets: BrowserGatewayTarget[];
|
|
||||||
total: number;
|
|
||||||
limit: number;
|
|
||||||
offset: number;
|
|
||||||
};
|
|
||||||
|
|
||||||
registry.registerPath({
|
|
||||||
method: "get",
|
|
||||||
path: "/org/{orgId}/resource/{resourceId}/browser-gateway-targets",
|
|
||||||
description: "List browser gateway targets for a resource.",
|
|
||||||
tags: [OpenAPITags.Org],
|
|
||||||
request: {
|
|
||||||
params: paramsSchema,
|
|
||||||
query: querySchema
|
|
||||||
},
|
|
||||||
responses: {}
|
|
||||||
});
|
|
||||||
|
|
||||||
export async function listBrowserGatewayTargets(
|
|
||||||
req: Request,
|
|
||||||
res: Response,
|
|
||||||
next: NextFunction
|
|
||||||
): Promise<any> {
|
|
||||||
try {
|
|
||||||
const parsedParams = paramsSchema.safeParse(req.params);
|
|
||||||
if (!parsedParams.success) {
|
|
||||||
return next(
|
|
||||||
createHttpError(
|
|
||||||
HttpCode.BAD_REQUEST,
|
|
||||||
fromError(parsedParams.error).toString()
|
|
||||||
)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const { orgId, resourceId } = parsedParams.data;
|
|
||||||
|
|
||||||
const parsedQuery = querySchema.safeParse(req.query);
|
|
||||||
if (!parsedQuery.success) {
|
|
||||||
return next(
|
|
||||||
createHttpError(
|
|
||||||
HttpCode.BAD_REQUEST,
|
|
||||||
fromError(parsedQuery.error).toString()
|
|
||||||
)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const { limit, offset } = parsedQuery.data;
|
|
||||||
|
|
||||||
const [resource] = await db
|
|
||||||
.select()
|
|
||||||
.from(resources)
|
|
||||||
.where(
|
|
||||||
and(
|
|
||||||
eq(resources.resourceId, resourceId),
|
|
||||||
eq(resources.orgId, orgId)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
.limit(1);
|
|
||||||
|
|
||||||
if (!resource) {
|
|
||||||
return next(
|
|
||||||
createHttpError(
|
|
||||||
HttpCode.NOT_FOUND,
|
|
||||||
`Resource with ID ${resourceId} not found in organization ${orgId}`
|
|
||||||
)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const rows = await db
|
|
||||||
.select({
|
|
||||||
browserGatewayTargetId:
|
|
||||||
browserGatewayTarget.browserGatewayTargetId,
|
|
||||||
resourceId: browserGatewayTarget.resourceId,
|
|
||||||
siteId: browserGatewayTarget.siteId,
|
|
||||||
authToken: browserGatewayTarget.authToken,
|
|
||||||
type: browserGatewayTarget.type,
|
|
||||||
destination: browserGatewayTarget.destination,
|
|
||||||
destinationPort: browserGatewayTarget.destinationPort,
|
|
||||||
siteName: sites.name
|
|
||||||
})
|
|
||||||
.from(browserGatewayTarget)
|
|
||||||
.leftJoin(sites, eq(sites.siteId, browserGatewayTarget.siteId))
|
|
||||||
.where(eq(browserGatewayTarget.resourceId, resourceId))
|
|
||||||
.limit(limit)
|
|
||||||
.offset(offset);
|
|
||||||
|
|
||||||
return response<ListBrowserGatewayTargetsResponse>(res, {
|
|
||||||
data: {
|
|
||||||
targets: rows as any,
|
|
||||||
total: rows.length,
|
|
||||||
limit,
|
|
||||||
offset
|
|
||||||
},
|
|
||||||
success: true,
|
|
||||||
error: false,
|
|
||||||
message: "Browser gateway targets retrieved successfully",
|
|
||||||
status: HttpCode.OK
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
logger.error(error);
|
|
||||||
return next(
|
|
||||||
createHttpError(
|
|
||||||
HttpCode.INTERNAL_SERVER_ERROR,
|
|
||||||
"Failed to list browser gateway targets"
|
|
||||||
)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,180 +0,0 @@
|
|||||||
/*
|
|
||||||
* This file is part of a proprietary work.
|
|
||||||
*
|
|
||||||
* Copyright (c) 2025-2026 Fossorial, Inc.
|
|
||||||
* All rights reserved.
|
|
||||||
*
|
|
||||||
* This file is licensed under the Fossorial Commercial License.
|
|
||||||
* You may not use this file except in compliance with the License.
|
|
||||||
* Unauthorized use, copying, modification, or distribution is strictly prohibited.
|
|
||||||
*
|
|
||||||
* This file is not licensed under the AGPLv3.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { Request, Response, NextFunction } from "express";
|
|
||||||
import { z } from "zod";
|
|
||||||
import {
|
|
||||||
browserGatewayTarget,
|
|
||||||
BrowserGatewayTarget,
|
|
||||||
db,
|
|
||||||
newts,
|
|
||||||
sites
|
|
||||||
} from "@server/db";
|
|
||||||
import { eq, and } from "drizzle-orm";
|
|
||||||
import response from "@server/lib/response";
|
|
||||||
import HttpCode from "@server/types/HttpCode";
|
|
||||||
import createHttpError from "http-errors";
|
|
||||||
import logger from "@server/logger";
|
|
||||||
import { fromError } from "zod-validation-error";
|
|
||||||
import { OpenAPITags, registry } from "@server/openApi";
|
|
||||||
import { sendBrowserGatewayTargets } from "@server/routers/newt/targets";
|
|
||||||
|
|
||||||
const paramsSchema = z.strictObject({
|
|
||||||
orgId: z.string().nonempty(),
|
|
||||||
browserGatewayTargetId: z
|
|
||||||
.string()
|
|
||||||
.transform(Number)
|
|
||||||
.pipe(z.number().int().positive())
|
|
||||||
});
|
|
||||||
|
|
||||||
const bodySchema = z.strictObject({
|
|
||||||
siteId: z.number().int().positive().optional(),
|
|
||||||
type: z.enum(["ssh", "rdp", "vnc"]).optional(),
|
|
||||||
destination: z.string().nonempty().optional(),
|
|
||||||
destinationPort: z.number().int().min(1).max(65535).optional()
|
|
||||||
});
|
|
||||||
|
|
||||||
export type UpdateBrowserGatewayTargetResponse = BrowserGatewayTarget;
|
|
||||||
|
|
||||||
registry.registerPath({
|
|
||||||
method: "post",
|
|
||||||
path: "/org/{orgId}/browser-gateway-target/{browserGatewayTargetId}",
|
|
||||||
description: "Update a browser gateway target.",
|
|
||||||
tags: [OpenAPITags.Org],
|
|
||||||
request: {
|
|
||||||
params: paramsSchema,
|
|
||||||
body: {
|
|
||||||
content: {
|
|
||||||
"application/json": {
|
|
||||||
schema: bodySchema
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
responses: {}
|
|
||||||
});
|
|
||||||
|
|
||||||
export async function updateBrowserGatewayTarget(
|
|
||||||
req: Request,
|
|
||||||
res: Response,
|
|
||||||
next: NextFunction
|
|
||||||
): Promise<any> {
|
|
||||||
try {
|
|
||||||
const parsedParams = paramsSchema.safeParse(req.params);
|
|
||||||
if (!parsedParams.success) {
|
|
||||||
return next(
|
|
||||||
createHttpError(
|
|
||||||
HttpCode.BAD_REQUEST,
|
|
||||||
fromError(parsedParams.error).toString()
|
|
||||||
)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const { orgId, browserGatewayTargetId } = parsedParams.data;
|
|
||||||
|
|
||||||
const parsedBody = bodySchema.safeParse(req.body);
|
|
||||||
if (!parsedBody.success) {
|
|
||||||
return next(
|
|
||||||
createHttpError(
|
|
||||||
HttpCode.BAD_REQUEST,
|
|
||||||
fromError(parsedBody.error).toString()
|
|
||||||
)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const { siteId, type, destination, destinationPort } = parsedBody.data;
|
|
||||||
|
|
||||||
const [existing] = await db
|
|
||||||
.select({ bgt: browserGatewayTarget, site: sites })
|
|
||||||
.from(browserGatewayTarget)
|
|
||||||
.innerJoin(sites, eq(sites.siteId, browserGatewayTarget.siteId))
|
|
||||||
.where(
|
|
||||||
and(
|
|
||||||
eq(
|
|
||||||
browserGatewayTarget.browserGatewayTargetId,
|
|
||||||
browserGatewayTargetId
|
|
||||||
),
|
|
||||||
eq(sites.orgId, orgId)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
.limit(1);
|
|
||||||
|
|
||||||
if (!existing) {
|
|
||||||
return next(
|
|
||||||
createHttpError(
|
|
||||||
HttpCode.NOT_FOUND,
|
|
||||||
`Browser gateway target with ID ${browserGatewayTargetId} not found`
|
|
||||||
)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const updateValues: Partial<BrowserGatewayTarget> = {};
|
|
||||||
if (siteId !== undefined) updateValues.siteId = siteId;
|
|
||||||
if (type !== undefined) updateValues.type = type;
|
|
||||||
if (destination !== undefined) updateValues.destination = destination;
|
|
||||||
if (destinationPort !== undefined)
|
|
||||||
updateValues.destinationPort = destinationPort;
|
|
||||||
|
|
||||||
const [updated] = await db
|
|
||||||
.update(browserGatewayTarget)
|
|
||||||
.set(updateValues)
|
|
||||||
.where(
|
|
||||||
eq(
|
|
||||||
browserGatewayTarget.browserGatewayTargetId,
|
|
||||||
browserGatewayTargetId
|
|
||||||
)
|
|
||||||
)
|
|
||||||
.returning();
|
|
||||||
|
|
||||||
const targetSiteId = siteId ?? existing.bgt.siteId;
|
|
||||||
const [site] = await db
|
|
||||||
.select()
|
|
||||||
.from(sites)
|
|
||||||
.where(eq(sites.siteId, targetSiteId))
|
|
||||||
.limit(1);
|
|
||||||
|
|
||||||
if (site && site.type === "newt") {
|
|
||||||
const [newt] = await db
|
|
||||||
.select()
|
|
||||||
.from(newts)
|
|
||||||
.where(eq(newts.siteId, targetSiteId))
|
|
||||||
.limit(1);
|
|
||||||
|
|
||||||
if (newt) {
|
|
||||||
await sendBrowserGatewayTargets(
|
|
||||||
newt.newtId,
|
|
||||||
[updated],
|
|
||||||
newt.version
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
logger.info(`Updated browser gateway target ${browserGatewayTargetId}`);
|
|
||||||
|
|
||||||
return response<UpdateBrowserGatewayTargetResponse>(res, {
|
|
||||||
data: updated,
|
|
||||||
success: true,
|
|
||||||
error: false,
|
|
||||||
message: "Browser gateway target updated successfully",
|
|
||||||
status: HttpCode.OK
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
logger.error(error);
|
|
||||||
return next(
|
|
||||||
createHttpError(
|
|
||||||
HttpCode.INTERNAL_SERVER_ERROR,
|
|
||||||
"Failed to update browser gateway target"
|
|
||||||
)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -31,7 +31,6 @@ import * as siteProvisioning from "#private/routers/siteProvisioning";
|
|||||||
import * as eventStreamingDestination from "#private/routers/eventStreamingDestination";
|
import * as eventStreamingDestination from "#private/routers/eventStreamingDestination";
|
||||||
import * as alertRule from "#private/routers/alertRule";
|
import * as alertRule from "#private/routers/alertRule";
|
||||||
import * as healthChecks from "#private/routers/healthChecks";
|
import * as healthChecks from "#private/routers/healthChecks";
|
||||||
import * as browserGatewayTarget from "#private/routers/browserGatewayTarget";
|
|
||||||
import * as labels from "#private/routers/labels";
|
import * as labels from "#private/routers/labels";
|
||||||
import * as client from "@server/routers/client";
|
import * as client from "@server/routers/client";
|
||||||
import * as resource from "#private/routers/resource";
|
import * as resource from "#private/routers/resource";
|
||||||
@@ -879,48 +878,3 @@ authenticated.post(
|
|||||||
verifyClientAccess,
|
verifyClientAccess,
|
||||||
client.rebuildClientAssociationsCacheRoute
|
client.rebuildClientAssociationsCacheRoute
|
||||||
);
|
);
|
||||||
|
|
||||||
authenticated.put(
|
|
||||||
"/org/:orgId/resource/:resourceId/browser-gateway-target",
|
|
||||||
verifyValidLicense,
|
|
||||||
verifyOrgAccess,
|
|
||||||
verifyLimits,
|
|
||||||
verifyUserHasAction(ActionsEnum.createBrowserGatewayTarget),
|
|
||||||
logActionAudit(ActionsEnum.createBrowserGatewayTarget),
|
|
||||||
browserGatewayTarget.createBrowserGatewayTarget
|
|
||||||
);
|
|
||||||
|
|
||||||
authenticated.get(
|
|
||||||
"/org/:orgId/resource/:resourceId/browser-gateway-targets",
|
|
||||||
verifyValidLicense,
|
|
||||||
verifyOrgAccess,
|
|
||||||
verifyUserHasAction(ActionsEnum.listBrowserGatewayTargets),
|
|
||||||
browserGatewayTarget.listBrowserGatewayTargets
|
|
||||||
);
|
|
||||||
|
|
||||||
authenticated.get(
|
|
||||||
"/org/:orgId/browser-gateway-target/:browserGatewayTargetId",
|
|
||||||
verifyValidLicense,
|
|
||||||
verifyOrgAccess,
|
|
||||||
verifyUserHasAction(ActionsEnum.getBrowserGatewayTarget),
|
|
||||||
browserGatewayTarget.getBrowserGatewayTarget
|
|
||||||
);
|
|
||||||
|
|
||||||
authenticated.post(
|
|
||||||
"/org/:orgId/browser-gateway-target/:browserGatewayTargetId",
|
|
||||||
verifyValidLicense,
|
|
||||||
verifyOrgAccess,
|
|
||||||
verifyLimits,
|
|
||||||
verifyUserHasAction(ActionsEnum.updateBrowserGatewayTarget),
|
|
||||||
logActionAudit(ActionsEnum.updateBrowserGatewayTarget),
|
|
||||||
browserGatewayTarget.updateBrowserGatewayTarget
|
|
||||||
);
|
|
||||||
|
|
||||||
authenticated.delete(
|
|
||||||
"/org/:orgId/browser-gateway-target/:browserGatewayTargetId",
|
|
||||||
verifyValidLicense,
|
|
||||||
verifyOrgAccess,
|
|
||||||
verifyUserHasAction(ActionsEnum.deleteBrowserGatewayTarget),
|
|
||||||
logActionAudit(ActionsEnum.deleteBrowserGatewayTarget),
|
|
||||||
browserGatewayTarget.deleteBrowserGatewayTarget
|
|
||||||
);
|
|
||||||
|
|||||||
@@ -79,7 +79,10 @@ import logger from "@server/logger";
|
|||||||
import { decrypt } from "@server/lib/crypto";
|
import { decrypt } from "@server/lib/crypto";
|
||||||
import config from "@server/lib/config";
|
import config from "@server/lib/config";
|
||||||
import { exchangeSession } from "@server/routers/badger";
|
import { exchangeSession } from "@server/routers/badger";
|
||||||
import { validateResourceSessionToken } from "@server/auth/sessions/resource";
|
import {
|
||||||
|
ResourceSessionValidationResult,
|
||||||
|
validateResourceSessionToken
|
||||||
|
} 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";
|
||||||
@@ -1754,11 +1757,34 @@ hybridRouter.post(
|
|||||||
resourceId
|
resourceId
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// this is for backward compatibility with nodes that did not have the policy id checking
|
||||||
|
const modifiedResult: ResourceSessionValidationResult = {
|
||||||
|
...result,
|
||||||
|
resourceSession: result.resourceSession
|
||||||
|
? {
|
||||||
|
...result.resourceSession,
|
||||||
|
// Prefer policy IDs, but keep legacy IDs populated for older nodes.
|
||||||
|
pincodeId:
|
||||||
|
result.resourceSession.policyPincodeId ??
|
||||||
|
result.resourceSession.pincodeId ??
|
||||||
|
null,
|
||||||
|
passwordId:
|
||||||
|
result.resourceSession.policyPasswordId ??
|
||||||
|
result.resourceSession.passwordId ??
|
||||||
|
null,
|
||||||
|
whitelistId:
|
||||||
|
result.resourceSession.policyWhitelistId ??
|
||||||
|
result.resourceSession.whitelistId ??
|
||||||
|
null
|
||||||
|
}
|
||||||
|
: null
|
||||||
|
};
|
||||||
|
|
||||||
return response(res, {
|
return response(res, {
|
||||||
data: result,
|
data: modifiedResult,
|
||||||
success: true,
|
success: true,
|
||||||
error: false,
|
error: false,
|
||||||
message: result.resourceSession
|
message: modifiedResult.resourceSession
|
||||||
? "Resource session token is valid"
|
? "Resource session token is valid"
|
||||||
: "Resource session token is invalid or expired",
|
: "Resource session token is invalid or expired",
|
||||||
status: HttpCode.OK
|
status: HttpCode.OK
|
||||||
|
|||||||
@@ -16,7 +16,6 @@ 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 browserGatewayTarget from "#private/routers/browserGatewayTarget";
|
|
||||||
|
|
||||||
import {
|
import {
|
||||||
verifyApiKeyHasAction,
|
verifyApiKeyHasAction,
|
||||||
@@ -216,43 +215,3 @@ authenticated.delete(
|
|||||||
logActionAudit(ActionsEnum.removeUserRole),
|
logActionAudit(ActionsEnum.removeUserRole),
|
||||||
user.removeUserRole
|
user.removeUserRole
|
||||||
);
|
);
|
||||||
|
|
||||||
authenticated.put(
|
|
||||||
"/org/:orgId/resource/:resourceId/browser-gateway-target",
|
|
||||||
verifyApiKeyOrgAccess,
|
|
||||||
verifyLimits,
|
|
||||||
verifyApiKeyHasAction(ActionsEnum.createBrowserGatewayTarget),
|
|
||||||
logActionAudit(ActionsEnum.createBrowserGatewayTarget),
|
|
||||||
browserGatewayTarget.createBrowserGatewayTarget
|
|
||||||
);
|
|
||||||
|
|
||||||
authenticated.get(
|
|
||||||
"/org/:orgId/resource/:resourceId/browser-gateway-targets",
|
|
||||||
verifyApiKeyOrgAccess,
|
|
||||||
verifyApiKeyHasAction(ActionsEnum.listBrowserGatewayTargets),
|
|
||||||
browserGatewayTarget.listBrowserGatewayTargets
|
|
||||||
);
|
|
||||||
|
|
||||||
authenticated.get(
|
|
||||||
"/org/:orgId/browser-gateway-target/:browserGatewayTargetId",
|
|
||||||
verifyApiKeyOrgAccess,
|
|
||||||
verifyApiKeyHasAction(ActionsEnum.getBrowserGatewayTarget),
|
|
||||||
browserGatewayTarget.getBrowserGatewayTarget
|
|
||||||
);
|
|
||||||
|
|
||||||
authenticated.post(
|
|
||||||
"/org/:orgId/browser-gateway-target/:browserGatewayTargetId",
|
|
||||||
verifyApiKeyOrgAccess,
|
|
||||||
verifyLimits,
|
|
||||||
verifyApiKeyHasAction(ActionsEnum.updateBrowserGatewayTarget),
|
|
||||||
logActionAudit(ActionsEnum.updateBrowserGatewayTarget),
|
|
||||||
browserGatewayTarget.updateBrowserGatewayTarget
|
|
||||||
);
|
|
||||||
|
|
||||||
authenticated.delete(
|
|
||||||
"/org/:orgId/browser-gateway-target/:browserGatewayTargetId",
|
|
||||||
verifyApiKeyOrgAccess,
|
|
||||||
verifyApiKeyHasAction(ActionsEnum.deleteBrowserGatewayTarget),
|
|
||||||
logActionAudit(ActionsEnum.deleteBrowserGatewayTarget),
|
|
||||||
browserGatewayTarget.deleteBrowserGatewayTarget
|
|
||||||
);
|
|
||||||
|
|||||||
@@ -17,9 +17,9 @@ import * as orgIdp from "#private/routers/orgIdp";
|
|||||||
import * as billing from "#private/routers/billing";
|
import * as billing from "#private/routers/billing";
|
||||||
import * as license from "#private/routers/license";
|
import * as license from "#private/routers/license";
|
||||||
import * as resource from "#private/routers/resource";
|
import * as resource from "#private/routers/resource";
|
||||||
import * as browserTarget from "#private/routers/browserGatewayTarget";
|
|
||||||
import * as ssh from "#private/routers/ssh";
|
import * as ssh from "#private/routers/ssh";
|
||||||
import * as ws from "@server/routers/ws";
|
import * as ws from "@server/routers/ws";
|
||||||
|
import * as browserTarget from "#private/routers/browserGatewayTarget";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
verifySessionUserMiddleware,
|
verifySessionUserMiddleware,
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ import response from "@server/lib/response";
|
|||||||
import logger from "@server/logger";
|
import logger from "@server/logger";
|
||||||
import type { CreateOrEditLabelResponse } from "@server/routers/labels/types";
|
import type { CreateOrEditLabelResponse } from "@server/routers/labels/types";
|
||||||
import HttpCode from "@server/types/HttpCode";
|
import HttpCode from "@server/types/HttpCode";
|
||||||
import { and, eq } from "drizzle-orm";
|
import { and, eq, sql } 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";
|
||||||
@@ -107,6 +107,26 @@ export async function createOrgLabel(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const [existingLabel] = await db
|
||||||
|
.select({ labelId: labels.labelId })
|
||||||
|
.from(labels)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(labels.orgId, orgId),
|
||||||
|
sql`LOWER(${labels.name}) = ${name.toLowerCase()}`
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
if (existingLabel) {
|
||||||
|
return next(
|
||||||
|
createHttpError(
|
||||||
|
HttpCode.CONFLICT,
|
||||||
|
"A label with this name already exists"
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
const label = await db.transaction(async (tx) => {
|
const label = await db.transaction(async (tx) => {
|
||||||
const [label] = await tx
|
const [label] = await tx
|
||||||
.insert(labels)
|
.insert(labels)
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ import response from "@server/lib/response";
|
|||||||
import logger from "@server/logger";
|
import logger from "@server/logger";
|
||||||
import type { CreateOrEditLabelResponse } from "@server/routers/labels/types";
|
import type { CreateOrEditLabelResponse } from "@server/routers/labels/types";
|
||||||
import HttpCode from "@server/types/HttpCode";
|
import HttpCode from "@server/types/HttpCode";
|
||||||
import { and, eq } from "drizzle-orm";
|
import { and, eq, ne, sql } 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";
|
||||||
@@ -74,6 +74,29 @@ export async function updateOrgLabel(
|
|||||||
|
|
||||||
const { name, color } = parsedBody.data;
|
const { name, color } = parsedBody.data;
|
||||||
|
|
||||||
|
if (name && name.toLowerCase() !== existing.name.toLowerCase()) {
|
||||||
|
const [duplicateLabel] = await db
|
||||||
|
.select({ labelId: labels.labelId })
|
||||||
|
.from(labels)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(labels.orgId, orgId),
|
||||||
|
ne(labels.labelId, labelId),
|
||||||
|
sql`LOWER(${labels.name}) = ${name.toLowerCase()}`
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
if (duplicateLabel) {
|
||||||
|
return next(
|
||||||
|
createHttpError(
|
||||||
|
HttpCode.CONFLICT,
|
||||||
|
"A label with this name already exists"
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const [label] = await db
|
const [label] = await db
|
||||||
.update(labels)
|
.update(labels)
|
||||||
.set({
|
.set({
|
||||||
|
|||||||
@@ -33,9 +33,8 @@ import {
|
|||||||
import { getUniqueResourcePolicyName } from "@server/db/names";
|
import { getUniqueResourcePolicyName } from "@server/db/names";
|
||||||
import response from "@server/lib/response";
|
import response from "@server/lib/response";
|
||||||
import {
|
import {
|
||||||
isValidCIDR,
|
getResourceRuleValueValidationError,
|
||||||
isValidIP,
|
RESOURCE_RULE_MATCH_TYPES
|
||||||
isValidUrlGlobPattern
|
|
||||||
} from "@server/lib/validators";
|
} from "@server/lib/validators";
|
||||||
import logger from "@server/logger";
|
import logger from "@server/logger";
|
||||||
import { OpenAPITags, registry } from "@server/openApi";
|
import { OpenAPITags, registry } from "@server/openApi";
|
||||||
@@ -56,9 +55,9 @@ const ruleSchema = z.strictObject({
|
|||||||
enum: ["ACCEPT", "DROP", "PASS"],
|
enum: ["ACCEPT", "DROP", "PASS"],
|
||||||
description: "rule action"
|
description: "rule action"
|
||||||
}),
|
}),
|
||||||
match: z.enum(["CIDR", "IP", "PATH"]).openapi({
|
match: z.enum(RESOURCE_RULE_MATCH_TYPES).openapi({
|
||||||
type: "string",
|
type: "string",
|
||||||
enum: ["CIDR", "IP", "PATH"],
|
enum: [...RESOURCE_RULE_MATCH_TYPES],
|
||||||
description: "rule match"
|
description: "rule match"
|
||||||
}),
|
}),
|
||||||
value: z.string().min(1),
|
value: z.string().min(1),
|
||||||
@@ -261,26 +260,13 @@ export async function createResourcePolicy(
|
|||||||
const niceId = await getUniqueResourcePolicyName(orgId);
|
const niceId = await getUniqueResourcePolicyName(orgId);
|
||||||
|
|
||||||
for (const rule of rules) {
|
for (const rule of rules) {
|
||||||
if (rule.match === "CIDR" && !isValidCIDR(rule.value)) {
|
const validationError = getResourceRuleValueValidationError(
|
||||||
|
rule.match,
|
||||||
|
rule.value
|
||||||
|
);
|
||||||
|
if (validationError) {
|
||||||
return next(
|
return next(
|
||||||
createHttpError(
|
createHttpError(HttpCode.BAD_REQUEST, validationError)
|
||||||
HttpCode.BAD_REQUEST,
|
|
||||||
"Invalid CIDR provided"
|
|
||||||
)
|
|
||||||
);
|
|
||||||
} else if (rule.match === "IP" && !isValidIP(rule.value)) {
|
|
||||||
return next(
|
|
||||||
createHttpError(HttpCode.BAD_REQUEST, "Invalid IP provided")
|
|
||||||
);
|
|
||||||
} else if (
|
|
||||||
rule.match === "PATH" &&
|
|
||||||
!isValidUrlGlobPattern(rule.value)
|
|
||||||
) {
|
|
||||||
return next(
|
|
||||||
createHttpError(
|
|
||||||
HttpCode.BAD_REQUEST,
|
|
||||||
"Invalid URL glob pattern provided"
|
|
||||||
)
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -216,6 +216,7 @@ export async function listResourcePolicies(
|
|||||||
: await db
|
: await db
|
||||||
.select({
|
.select({
|
||||||
resourceId: resources.resourceId,
|
resourceId: resources.resourceId,
|
||||||
|
niceId: resources.niceId,
|
||||||
name: resources.name,
|
name: resources.name,
|
||||||
fullDomain: resources.fullDomain,
|
fullDomain: resources.fullDomain,
|
||||||
resourcePolicyId: resources.resourcePolicyId
|
resourcePolicyId: resources.resourcePolicyId
|
||||||
|
|||||||
@@ -0,0 +1,202 @@
|
|||||||
|
/*
|
||||||
|
* This file is part of a proprietary work.
|
||||||
|
*
|
||||||
|
* Copyright (c) 2025-2026 Fossorial, Inc.
|
||||||
|
* All rights reserved.
|
||||||
|
*
|
||||||
|
* This file is licensed under the Fossorial Commercial License.
|
||||||
|
* You may not use this file except in compliance with the License.
|
||||||
|
* Unauthorized use, copying, modification, or distribution is strictly prohibited.
|
||||||
|
*
|
||||||
|
* This file is not licensed under the AGPLv3.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import axios from "axios";
|
||||||
|
import { db, exitNodes, newts, sites } from "@server/db";
|
||||||
|
import { eq } from "drizzle-orm";
|
||||||
|
import logger from "@server/logger";
|
||||||
|
import redisManager from "#private/lib/redis";
|
||||||
|
import { sendToClient } from "#private/routers/ws";
|
||||||
|
|
||||||
|
const INITIAL_DELAY_MS = 15 * 1000; // 15 seconds before first check
|
||||||
|
const CHECK_INTERVAL_MS = 10 * 1000; // Check every 10 seconds
|
||||||
|
const MAX_DURATION_MS = 5 * 60 * 1000; // Give up after 5 minutes
|
||||||
|
const REDIS_PENDING_SET = "exit-node-reconnect-pending";
|
||||||
|
const REDIS_HASH_PREFIX = "exit-node-reconnect:";
|
||||||
|
|
||||||
|
interface PendingReconnect {
|
||||||
|
startTime: number;
|
||||||
|
reachableAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// In-memory tracking for this node
|
||||||
|
const pendingReconnects = new Map<number, PendingReconnect>();
|
||||||
|
|
||||||
|
let schedulerInterval: NodeJS.Timeout | null = null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Schedules a reconnect check for newts connected to the given exit node.
|
||||||
|
* Called when an exit node transitions from offline to online.
|
||||||
|
*/
|
||||||
|
export async function scheduleExitNodeReconnect(
|
||||||
|
exitNodeId: number,
|
||||||
|
reachableAt: string
|
||||||
|
): Promise<void> {
|
||||||
|
logger.info(
|
||||||
|
`Scheduling newt reconnect for exit node ${exitNodeId} (reachableAt: ${reachableAt})`
|
||||||
|
);
|
||||||
|
|
||||||
|
const entry: PendingReconnect = {
|
||||||
|
startTime: Date.now(),
|
||||||
|
reachableAt
|
||||||
|
};
|
||||||
|
|
||||||
|
pendingReconnects.set(exitNodeId, entry);
|
||||||
|
|
||||||
|
// Store in Redis if available for cross-node coordination
|
||||||
|
if (redisManager.isRedisEnabled()) {
|
||||||
|
await redisManager.sadd(REDIS_PENDING_SET, exitNodeId.toString());
|
||||||
|
await redisManager.hset(
|
||||||
|
`${REDIS_HASH_PREFIX}${exitNodeId}`,
|
||||||
|
"startTime",
|
||||||
|
entry.startTime.toString()
|
||||||
|
);
|
||||||
|
await redisManager.hset(
|
||||||
|
`${REDIS_HASH_PREFIX}${exitNodeId}`,
|
||||||
|
"reachableAt",
|
||||||
|
reachableAt
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Starts the background interval that checks pending exit node reconnects.
|
||||||
|
*/
|
||||||
|
export function startExitNodeReconnectScheduler(): void {
|
||||||
|
if (schedulerInterval) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
schedulerInterval = setInterval(async () => {
|
||||||
|
try {
|
||||||
|
await processPendingReconnects();
|
||||||
|
} catch (error) {
|
||||||
|
logger.error("Error in exit node reconnect scheduler", { error });
|
||||||
|
}
|
||||||
|
}, CHECK_INTERVAL_MS);
|
||||||
|
|
||||||
|
logger.debug("Started exit node reconnect scheduler");
|
||||||
|
}
|
||||||
|
|
||||||
|
async function processPendingReconnects(): Promise<void> {
|
||||||
|
// Merge in-memory and Redis-tracked pending reconnects
|
||||||
|
const toProcess = new Map(pendingReconnects);
|
||||||
|
|
||||||
|
if (redisManager.isRedisEnabled()) {
|
||||||
|
const redisIds = await redisManager.smembers(REDIS_PENDING_SET);
|
||||||
|
for (const idStr of redisIds) {
|
||||||
|
const id = parseInt(idStr, 10);
|
||||||
|
if (!toProcess.has(id)) {
|
||||||
|
const startTimeStr = await redisManager.hget(
|
||||||
|
`${REDIS_HASH_PREFIX}${id}`,
|
||||||
|
"startTime"
|
||||||
|
);
|
||||||
|
const reachableAt = await redisManager.hget(
|
||||||
|
`${REDIS_HASH_PREFIX}${id}`,
|
||||||
|
"reachableAt"
|
||||||
|
);
|
||||||
|
if (startTimeStr && reachableAt) {
|
||||||
|
toProcess.set(id, {
|
||||||
|
startTime: parseInt(startTimeStr, 10),
|
||||||
|
reachableAt
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const now = Date.now();
|
||||||
|
|
||||||
|
for (const [exitNodeId, entry] of toProcess) {
|
||||||
|
const elapsed = now - entry.startTime;
|
||||||
|
|
||||||
|
// Give up after max duration
|
||||||
|
if (elapsed >= MAX_DURATION_MS) {
|
||||||
|
logger.warn(
|
||||||
|
`Exit node reconnect check timed out for exit node ${exitNodeId} after 5 minutes`
|
||||||
|
);
|
||||||
|
await removePending(exitNodeId);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Respect initial delay
|
||||||
|
if (elapsed < INITIAL_DELAY_MS) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if the exit node HTTP endpoint is reachable
|
||||||
|
const pingUrl = `${entry.reachableAt}/ping`;
|
||||||
|
try {
|
||||||
|
await axios.get(pingUrl, { timeout: 5000 });
|
||||||
|
} catch {
|
||||||
|
logger.debug(
|
||||||
|
`Exit node ${exitNodeId} not yet reachable at ${pingUrl}`
|
||||||
|
);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Node is reachable — send reconnect to all connected newts
|
||||||
|
logger.info(
|
||||||
|
`Exit node ${exitNodeId} is reachable. Sending newt/wg/reconnect to connected newts.`
|
||||||
|
);
|
||||||
|
|
||||||
|
await sendReconnectToNewts(exitNodeId);
|
||||||
|
await removePending(exitNodeId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function sendReconnectToNewts(exitNodeId: number): Promise<void> {
|
||||||
|
try {
|
||||||
|
const connectedNewts = await db
|
||||||
|
.select({ newtId: newts.newtId })
|
||||||
|
.from(newts)
|
||||||
|
.innerJoin(sites, eq(newts.siteId, sites.siteId))
|
||||||
|
.where(eq(sites.exitNodeId, exitNodeId));
|
||||||
|
|
||||||
|
if (connectedNewts.length === 0) {
|
||||||
|
logger.debug(
|
||||||
|
`No newts found for exit node ${exitNodeId}, nothing to reconnect`
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
`Sending newt/wg/reconnect to ${connectedNewts.length} newt(s) for exit node ${exitNodeId}`
|
||||||
|
);
|
||||||
|
|
||||||
|
const reconnectMessage = {
|
||||||
|
type: "newt/wg/reconnect",
|
||||||
|
data: {}
|
||||||
|
};
|
||||||
|
|
||||||
|
await Promise.allSettled(
|
||||||
|
connectedNewts.map(({ newtId }) =>
|
||||||
|
sendToClient(newtId, reconnectMessage)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(
|
||||||
|
`Failed to send reconnect messages for exit node ${exitNodeId}`,
|
||||||
|
{ error }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function removePending(exitNodeId: number): Promise<void> {
|
||||||
|
pendingReconnects.delete(exitNodeId);
|
||||||
|
|
||||||
|
if (redisManager.isRedisEnabled()) {
|
||||||
|
await redisManager.srem(REDIS_PENDING_SET, exitNodeId.toString());
|
||||||
|
await redisManager.del(`${REDIS_HASH_PREFIX}${exitNodeId}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -16,6 +16,7 @@ import { MessageHandler } from "@server/routers/ws";
|
|||||||
import { RemoteExitNode } from "@server/db";
|
import { RemoteExitNode } from "@server/db";
|
||||||
import { eq } from "drizzle-orm";
|
import { eq } from "drizzle-orm";
|
||||||
import logger from "@server/logger";
|
import logger from "@server/logger";
|
||||||
|
import { scheduleExitNodeReconnect } from "./exitNodeReconnectScheduler";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Handles ping messages from clients and responds with pong
|
* Handles ping messages from clients and responds with pong
|
||||||
@@ -37,6 +38,13 @@ export const handleRemoteExitNodePingMessage: MessageHandler = async (
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
// Fetch the current state before updating so we can detect the offline→online transition
|
||||||
|
const [currentExitNode] = await db
|
||||||
|
.select({ online: exitNodes.online, reachableAt: exitNodes.reachableAt })
|
||||||
|
.from(exitNodes)
|
||||||
|
.where(eq(exitNodes.exitNodeId, remoteExitNode.exitNodeId))
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
// Update the exit node's last ping timestamp
|
// Update the exit node's last ping timestamp
|
||||||
await db
|
await db
|
||||||
.update(exitNodes)
|
.update(exitNodes)
|
||||||
@@ -45,6 +53,16 @@ export const handleRemoteExitNodePingMessage: MessageHandler = async (
|
|||||||
online: true
|
online: true
|
||||||
})
|
})
|
||||||
.where(eq(exitNodes.exitNodeId, remoteExitNode.exitNodeId));
|
.where(eq(exitNodes.exitNodeId, remoteExitNode.exitNodeId));
|
||||||
|
|
||||||
|
// If the exit node was offline and is now coming online, schedule newt reconnects
|
||||||
|
if (currentExitNode && !currentExitNode.online && currentExitNode.reachableAt) {
|
||||||
|
scheduleExitNodeReconnect(
|
||||||
|
remoteExitNode.exitNodeId,
|
||||||
|
currentExitNode.reachableAt
|
||||||
|
).catch((error) => {
|
||||||
|
logger.error("Failed to schedule exit node reconnect", { error });
|
||||||
|
});
|
||||||
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error("Error handling ping message", { error });
|
logger.error("Error handling ping message", { error });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,3 +22,4 @@ export * from "./listRemoteExitNodes";
|
|||||||
export * from "./pickRemoteExitNodeDefaults";
|
export * from "./pickRemoteExitNodeDefaults";
|
||||||
export * from "./quickStartRemoteExitNode";
|
export * from "./quickStartRemoteExitNode";
|
||||||
export * from "./offlineChecker";
|
export * from "./offlineChecker";
|
||||||
|
export * from "./exitNodeReconnectScheduler";
|
||||||
|
|||||||
@@ -30,8 +30,7 @@ import {
|
|||||||
userOrgs,
|
userOrgs,
|
||||||
sites,
|
sites,
|
||||||
Resource,
|
Resource,
|
||||||
SiteResource,
|
SiteResource
|
||||||
browserGatewayTarget
|
|
||||||
} from "@server/db";
|
} from "@server/db";
|
||||||
import { logAccessAudit } from "#private/lib/logAccessAudit";
|
import { logAccessAudit } from "#private/lib/logAccessAudit";
|
||||||
import { isLicensedOrSubscribed } from "#private/lib/isLicencedOrSubscribed";
|
import { isLicensedOrSubscribed } from "#private/lib/isLicencedOrSubscribed";
|
||||||
@@ -291,16 +290,15 @@ export async function signSshKey(
|
|||||||
const publicResource = resource as Resource;
|
const publicResource = resource as Resource;
|
||||||
const targetRows = await db
|
const targetRows = await db
|
||||||
.select({
|
.select({
|
||||||
siteId: browserGatewayTarget.siteId,
|
siteId: targets.siteId,
|
||||||
ip: browserGatewayTarget.destination
|
ip: targets.ip
|
||||||
})
|
})
|
||||||
.from(browserGatewayTarget)
|
.from(targets)
|
||||||
.where(
|
.where(
|
||||||
and(
|
and(
|
||||||
eq(
|
eq(targets.resourceId, publicResource.resourceId),
|
||||||
browserGatewayTarget.resourceId,
|
eq(targets.enabled, true),
|
||||||
publicResource.resourceId
|
eq(targets.mode, "ssh")
|
||||||
)
|
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -14,7 +14,8 @@
|
|||||||
import {
|
import {
|
||||||
handleRemoteExitNodeRegisterMessage,
|
handleRemoteExitNodeRegisterMessage,
|
||||||
handleRemoteExitNodePingMessage,
|
handleRemoteExitNodePingMessage,
|
||||||
startRemoteExitNodeOfflineChecker
|
startRemoteExitNodeOfflineChecker,
|
||||||
|
startExitNodeReconnectScheduler
|
||||||
} from "#private/routers/remoteExitNode";
|
} from "#private/routers/remoteExitNode";
|
||||||
import { MessageHandler } from "@server/routers/ws";
|
import { MessageHandler } from "@server/routers/ws";
|
||||||
import { build } from "@server/build";
|
import { build } from "@server/build";
|
||||||
@@ -29,4 +30,5 @@ export const messageHandlers: Record<string, MessageHandler> = {
|
|||||||
|
|
||||||
if (build != "saas") {
|
if (build != "saas") {
|
||||||
startRemoteExitNodeOfflineChecker(); // this is to handle the offline check for remote exit nodes
|
startRemoteExitNodeOfflineChecker(); // this is to handle the offline check for remote exit nodes
|
||||||
|
startExitNodeReconnectScheduler(); // check pending exit node reconnects and notify newts
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,7 +20,8 @@ import {
|
|||||||
ResourcePolicyPincode,
|
ResourcePolicyPincode,
|
||||||
ResourcePolicyPassword,
|
ResourcePolicyPassword,
|
||||||
ResourcePolicyHeaderAuth,
|
ResourcePolicyHeaderAuth,
|
||||||
ResourceRule
|
ResourceRule,
|
||||||
|
ResourceSession
|
||||||
} 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";
|
||||||
@@ -536,7 +537,8 @@ export async function verifyResourceSession(
|
|||||||
|
|
||||||
if (resourceSessionToken) {
|
if (resourceSessionToken) {
|
||||||
const sessionCacheKey = `session:${resourceSessionToken}`;
|
const sessionCacheKey = `session:${resourceSessionToken}`;
|
||||||
let resourceSession: any = localCache.get(sessionCacheKey);
|
let resourceSession: ResourceSession | null | undefined =
|
||||||
|
localCache.get(sessionCacheKey);
|
||||||
|
|
||||||
if (!resourceSession) {
|
if (!resourceSession) {
|
||||||
const result = await validateResourceSessionToken(
|
const result = await validateResourceSessionToken(
|
||||||
@@ -671,7 +673,7 @@ export async function verifyResourceSession(
|
|||||||
orgId: resource.orgId,
|
orgId: resource.orgId,
|
||||||
location: ipCC,
|
location: ipCC,
|
||||||
apiKey: {
|
apiKey: {
|
||||||
name: resourceSession.accessTokenTitle,
|
name: null,
|
||||||
apiKeyId: resourceSession.accessTokenId
|
apiKeyId: resourceSession.accessTokenId
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -717,7 +719,7 @@ export async function verifyResourceSession(
|
|||||||
location: ipCC,
|
location: ipCC,
|
||||||
user: {
|
user: {
|
||||||
username: allowedUserData.username,
|
username: allowedUserData.username,
|
||||||
userId: resourceSession.userId
|
userId: allowedUserData.userId
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
parsedBody.data
|
parsedBody.data
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ export type GetBrowserTargetResponse = {
|
|||||||
orgId: string;
|
orgId: string;
|
||||||
resourceId: number;
|
resourceId: number;
|
||||||
niceId: string;
|
niceId: string;
|
||||||
|
name: string;
|
||||||
pamMode: "passthrough" | "push" | null;
|
pamMode: "passthrough" | "push" | null;
|
||||||
authDaemonMode: "site" | "remote" | "native" | null;
|
authDaemonMode: "site" | "remote" | "native" | null;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -666,6 +666,13 @@ authenticated.get(
|
|||||||
resource.getResourcePolicies
|
resource.getResourcePolicies
|
||||||
);
|
);
|
||||||
|
|
||||||
|
authenticated.get(
|
||||||
|
"/resource-policy/:resourcePolicyId",
|
||||||
|
verifyResourcePolicyAccess,
|
||||||
|
verifyUserHasAction(ActionsEnum.getResourcePolicy),
|
||||||
|
policy.getResourcePolicy
|
||||||
|
);
|
||||||
|
|
||||||
authenticated.put(
|
authenticated.put(
|
||||||
"/resource-policy/:resourcePolicyId",
|
"/resource-policy/:resourcePolicyId",
|
||||||
verifyResourcePolicyAccess,
|
verifyResourcePolicyAccess,
|
||||||
|
|||||||
@@ -332,17 +332,6 @@ export async function validateOidcCallback(
|
|||||||
.where(eq(idpOrg.idpId, existingIdp.idp.idpId))
|
.where(eq(idpOrg.idpId, existingIdp.idp.idpId))
|
||||||
.innerJoin(orgs, eq(orgs.orgId, idpOrg.orgId));
|
.innerJoin(orgs, eq(orgs.orgId, idpOrg.orgId));
|
||||||
allOrgs = idpOrgs.map((o) => o.orgs);
|
allOrgs = idpOrgs.map((o) => o.orgs);
|
||||||
|
|
||||||
for (const org of allOrgs) {
|
|
||||||
const subscribed = await isSubscribed(
|
|
||||||
org.orgId,
|
|
||||||
tierMatrix.autoProvisioning
|
|
||||||
);
|
|
||||||
if (!subscribed) {
|
|
||||||
// filter out the org
|
|
||||||
allOrgs = allOrgs.filter((o) => o.orgId !== org.orgId);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
allOrgs = await db.select().from(orgs);
|
allOrgs = await db.select().from(orgs);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,4 @@
|
|||||||
import {
|
import {
|
||||||
browserGatewayTarget,
|
|
||||||
BrowserGatewayTarget,
|
|
||||||
clients,
|
clients,
|
||||||
clientSiteResourcesAssociationsCache,
|
clientSiteResourcesAssociationsCache,
|
||||||
clientSitesAssociationsCache,
|
clientSitesAssociationsCache,
|
||||||
@@ -16,7 +14,7 @@ import {
|
|||||||
} from "@server/db";
|
} from "@server/db";
|
||||||
import logger from "@server/logger";
|
import logger from "@server/logger";
|
||||||
import { initPeerAddHandshake, updatePeer } from "../olm/peers";
|
import { initPeerAddHandshake, updatePeer } from "../olm/peers";
|
||||||
import { eq, and } from "drizzle-orm";
|
import { eq, and, inArray } from "drizzle-orm";
|
||||||
import config from "@server/lib/config";
|
import config from "@server/lib/config";
|
||||||
import { decrypt } from "@server/lib/crypto";
|
import { decrypt } from "@server/lib/crypto";
|
||||||
import {
|
import {
|
||||||
@@ -211,7 +209,13 @@ export async function buildTargetConfigurationForNewtClient(
|
|||||||
})
|
})
|
||||||
.from(targets)
|
.from(targets)
|
||||||
.innerJoin(resources, eq(targets.resourceId, resources.resourceId))
|
.innerJoin(resources, eq(targets.resourceId, resources.resourceId))
|
||||||
.where(and(eq(targets.siteId, siteId), eq(targets.enabled, true)));
|
.where(
|
||||||
|
and(
|
||||||
|
eq(targets.siteId, siteId),
|
||||||
|
eq(targets.enabled, true),
|
||||||
|
inArray(targets.mode, ["http", "udp", "tcp"])
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
const allHealthChecks = await db
|
const allHealthChecks = await db
|
||||||
.select({
|
.select({
|
||||||
@@ -236,10 +240,27 @@ export async function buildTargetConfigurationForNewtClient(
|
|||||||
.from(targetHealthCheck)
|
.from(targetHealthCheck)
|
||||||
.where(eq(targetHealthCheck.siteId, siteId));
|
.where(eq(targetHealthCheck.siteId, siteId));
|
||||||
|
|
||||||
|
// Get all enabled targets with their resource mode information
|
||||||
const allBrowserGatewayTargets = await db
|
const allBrowserGatewayTargets = await db
|
||||||
.select()
|
.select({
|
||||||
.from(browserGatewayTarget)
|
resourceId: targets.resourceId,
|
||||||
.where(eq(browserGatewayTarget.siteId, siteId));
|
targetId: targets.targetId,
|
||||||
|
ip: targets.ip,
|
||||||
|
method: targets.method,
|
||||||
|
port: targets.port,
|
||||||
|
enabled: targets.enabled,
|
||||||
|
mode: resources.mode,
|
||||||
|
authToken: targets.authToken
|
||||||
|
})
|
||||||
|
.from(targets)
|
||||||
|
.innerJoin(resources, eq(targets.resourceId, resources.resourceId))
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(targets.siteId, siteId),
|
||||||
|
eq(targets.enabled, true),
|
||||||
|
inArray(targets.mode, ["ssh", "rdp", "vnc"])
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
const { tcpTargets, udpTargets } = allTargets.reduce(
|
const { tcpTargets, udpTargets } = allTargets.reduce(
|
||||||
(acc, target) => {
|
(acc, target) => {
|
||||||
@@ -315,12 +336,15 @@ export async function buildTargetConfigurationForNewtClient(
|
|||||||
|
|
||||||
const serverSecret = config.getRawConfig().server.secret!;
|
const serverSecret = config.getRawConfig().server.secret!;
|
||||||
const browserGatewayTargets = allBrowserGatewayTargets.map((t) => {
|
const browserGatewayTargets = allBrowserGatewayTargets.map((t) => {
|
||||||
|
if (!t.ip || !t.port || !t.authToken) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
const decryptAuthToken = decrypt(t.authToken, serverSecret);
|
const decryptAuthToken = decrypt(t.authToken, serverSecret);
|
||||||
return {
|
return {
|
||||||
id: t.browserGatewayTargetId,
|
id: t.targetId,
|
||||||
type: t.type,
|
type: t.mode,
|
||||||
destination: t.destination,
|
destination: t.ip,
|
||||||
destinationPort: t.destinationPort,
|
destinationPort: t.port,
|
||||||
authToken: decryptAuthToken
|
authToken: decryptAuthToken
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { BrowserGatewayTarget, Target, TargetHealthCheck } from "@server/db";
|
import { Target, TargetHealthCheck } from "@server/db";
|
||||||
import { sendToClient } from "#dynamic/routers/ws";
|
import { sendToClient } from "#dynamic/routers/ws";
|
||||||
import logger from "@server/logger";
|
import logger from "@server/logger";
|
||||||
import { canCompress } from "@server/lib/clientVersionChecks";
|
import { canCompress } from "@server/lib/clientVersionChecks";
|
||||||
@@ -244,23 +244,27 @@ export async function removeTargets(
|
|||||||
|
|
||||||
export async function sendBrowserGatewayTargets(
|
export async function sendBrowserGatewayTargets(
|
||||||
newtId: string,
|
newtId: string,
|
||||||
targets: BrowserGatewayTarget[],
|
targets: Target[],
|
||||||
version?: string | null
|
version?: string | null
|
||||||
) {
|
) {
|
||||||
if (targets.length === 0) return;
|
if (targets.length === 0) return;
|
||||||
|
|
||||||
const payload = targets.map((t) => {
|
// filter out the ones without auth tokens
|
||||||
|
const filteredTargets = targets.filter((t) => t.authToken);
|
||||||
|
if (filteredTargets.length === 0) return;
|
||||||
|
|
||||||
|
const payload = filteredTargets.map((t) => {
|
||||||
const decryptAuthToken = decrypt(
|
const decryptAuthToken = decrypt(
|
||||||
t.authToken,
|
t.authToken!,
|
||||||
config.getRawConfig().server.secret!
|
config.getRawConfig().server.secret!
|
||||||
);
|
);
|
||||||
return {
|
return {
|
||||||
id: t.browserGatewayTargetId,
|
id: t.targetId,
|
||||||
resourceId: t.resourceId,
|
resourceId: t.resourceId,
|
||||||
siteId: t.siteId,
|
siteId: t.siteId,
|
||||||
type: t.type,
|
type: t.mode,
|
||||||
destination: t.destination,
|
destination: t.ip,
|
||||||
destinationPort: t.destinationPort,
|
destinationPort: t.port,
|
||||||
authToken: decryptAuthToken
|
authToken: decryptAuthToken
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ import {
|
|||||||
import { olms } from "@server/db";
|
import { olms } 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 { and, eq, inArray } from "drizzle-orm";
|
import { and, count, eq, inArray } 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";
|
||||||
@@ -24,6 +24,7 @@ import {
|
|||||||
EXPIRES
|
EXPIRES
|
||||||
} from "@server/auth/sessions/olm";
|
} from "@server/auth/sessions/olm";
|
||||||
import { getOrCreateCachedToken } from "#dynamic/lib/tokenCache";
|
import { getOrCreateCachedToken } from "#dynamic/lib/tokenCache";
|
||||||
|
import { listExitNodes } from "#dynamic/lib/exitNodes";
|
||||||
import { verifyPassword } from "@server/auth/password";
|
import { verifyPassword } from "@server/auth/password";
|
||||||
import logger from "@server/logger";
|
import logger from "@server/logger";
|
||||||
import config from "@server/lib/config";
|
import config from "@server/lib/config";
|
||||||
@@ -150,6 +151,7 @@ export async function getOlmToken(
|
|||||||
);
|
);
|
||||||
|
|
||||||
let clientIdToUse;
|
let clientIdToUse;
|
||||||
|
let orgIdToUse: string;
|
||||||
if (orgId) {
|
if (orgId) {
|
||||||
// we did provide the org
|
// we did provide the org
|
||||||
const [client] = await db
|
const [client] = await db
|
||||||
@@ -183,6 +185,7 @@ export async function getOlmToken(
|
|||||||
}
|
}
|
||||||
|
|
||||||
clientIdToUse = client.clientId;
|
clientIdToUse = client.clientId;
|
||||||
|
orgIdToUse = orgId;
|
||||||
} else {
|
} else {
|
||||||
if (!existingOlm.clientId) {
|
if (!existingOlm.clientId) {
|
||||||
return next(
|
return next(
|
||||||
@@ -209,6 +212,7 @@ export async function getOlmToken(
|
|||||||
}
|
}
|
||||||
|
|
||||||
clientIdToUse = client.clientId;
|
clientIdToUse = client.clientId;
|
||||||
|
orgIdToUse = client.orgId;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get all exit nodes from sites where the client has peers
|
// Get all exit nodes from sites where the client has peers
|
||||||
@@ -265,7 +269,7 @@ export async function getOlmToken(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const exitNodesHpData = allExitNodes.map((exitNode: ExitNode) => {
|
let exitNodesHpData = allExitNodes.map((exitNode: ExitNode) => {
|
||||||
return {
|
return {
|
||||||
publicKey: exitNode.publicKey,
|
publicKey: exitNode.publicKey,
|
||||||
relayPort: config.getRawConfig().gerbil.clients_start_port,
|
relayPort: config.getRawConfig().gerbil.clients_start_port,
|
||||||
@@ -274,6 +278,73 @@ export async function getOlmToken(
|
|||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// If no exit nodes were found for the client's sites, fall back to
|
||||||
|
// finding an available node in the same region (as newt does on ping).
|
||||||
|
if (exitNodesHpData.length === 0) {
|
||||||
|
logger.debug(
|
||||||
|
`No exit nodes found for olm ${olmId} client sites; falling back to region node selection`
|
||||||
|
);
|
||||||
|
const fallbackNodes = await listExitNodes(orgIdToUse!, true);
|
||||||
|
|
||||||
|
const weightedNodes = await Promise.all(
|
||||||
|
fallbackNodes.map(async (node) => {
|
||||||
|
let weight = 1;
|
||||||
|
const maxConnections = node.maxConnections;
|
||||||
|
if (
|
||||||
|
maxConnections !== null &&
|
||||||
|
maxConnections !== undefined
|
||||||
|
) {
|
||||||
|
const [currentConnections] = await db
|
||||||
|
.select({ count: count() })
|
||||||
|
.from(sites)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(sites.exitNodeId, node.exitNodeId),
|
||||||
|
eq(sites.online, true)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
if (currentConnections.count >= maxConnections) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
weight =
|
||||||
|
(maxConnections - currentConnections.count) /
|
||||||
|
maxConnections;
|
||||||
|
}
|
||||||
|
return { node, weight };
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
const availableNodes = weightedNodes
|
||||||
|
.filter(
|
||||||
|
(
|
||||||
|
n
|
||||||
|
): n is {
|
||||||
|
node: (typeof fallbackNodes)[0];
|
||||||
|
weight: number;
|
||||||
|
} => n !== null
|
||||||
|
)
|
||||||
|
.sort((a, b) => b.weight - a.weight);
|
||||||
|
|
||||||
|
if (availableNodes.length > 0) {
|
||||||
|
const best = availableNodes[0].node;
|
||||||
|
exitNodesHpData = [
|
||||||
|
{
|
||||||
|
publicKey: best.publicKey,
|
||||||
|
relayPort:
|
||||||
|
config.getRawConfig().gerbil.clients_start_port,
|
||||||
|
endpoint: best.endpoint,
|
||||||
|
siteIds: []
|
||||||
|
// it should still HP without the site ids but it will get stuck in the client
|
||||||
|
// if a site is removed or something because its not tied to a site which is okay for the session
|
||||||
|
}
|
||||||
|
];
|
||||||
|
} else {
|
||||||
|
logger.warn(
|
||||||
|
`No available fallback exit nodes found for olm ${olmId}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
logger.debug("Token created successfully");
|
logger.debug("Token created successfully");
|
||||||
|
|
||||||
return response<{
|
return response<{
|
||||||
|
|||||||
@@ -8,9 +8,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 {
|
import {
|
||||||
isValidCIDR,
|
getResourceRuleValueValidationError,
|
||||||
isValidIP,
|
RESOURCE_RULE_MATCH_TYPES
|
||||||
isValidUrlGlobPattern
|
|
||||||
} from "@server/lib/validators";
|
} from "@server/lib/validators";
|
||||||
import { OpenAPITags, registry } from "@server/openApi";
|
import { OpenAPITags, registry } from "@server/openApi";
|
||||||
|
|
||||||
@@ -20,9 +19,9 @@ const ruleSchema = z.strictObject({
|
|||||||
enum: ["ACCEPT", "DROP", "PASS"],
|
enum: ["ACCEPT", "DROP", "PASS"],
|
||||||
description: "rule action"
|
description: "rule action"
|
||||||
}),
|
}),
|
||||||
match: z.enum(["CIDR", "IP", "PATH"]).openapi({
|
match: z.enum(RESOURCE_RULE_MATCH_TYPES).openapi({
|
||||||
type: "string",
|
type: "string",
|
||||||
enum: ["CIDR", "IP", "PATH"],
|
enum: [...RESOURCE_RULE_MATCH_TYPES],
|
||||||
description: "rule match"
|
description: "rule match"
|
||||||
}),
|
}),
|
||||||
value: z.string().min(1),
|
value: z.string().min(1),
|
||||||
@@ -105,26 +104,13 @@ export async function setResourcePolicyRules(
|
|||||||
}
|
}
|
||||||
|
|
||||||
for (const rule of rules) {
|
for (const rule of rules) {
|
||||||
if (rule.match === "CIDR" && !isValidCIDR(rule.value)) {
|
const validationError = getResourceRuleValueValidationError(
|
||||||
|
rule.match,
|
||||||
|
rule.value
|
||||||
|
);
|
||||||
|
if (validationError) {
|
||||||
return next(
|
return next(
|
||||||
createHttpError(
|
createHttpError(HttpCode.BAD_REQUEST, validationError)
|
||||||
HttpCode.BAD_REQUEST,
|
|
||||||
"Invalid CIDR provided"
|
|
||||||
)
|
|
||||||
);
|
|
||||||
} else if (rule.match === "IP" && !isValidIP(rule.value)) {
|
|
||||||
return next(
|
|
||||||
createHttpError(HttpCode.BAD_REQUEST, "Invalid IP provided")
|
|
||||||
);
|
|
||||||
} else if (
|
|
||||||
rule.match === "PATH" &&
|
|
||||||
!isValidUrlGlobPattern(rule.value)
|
|
||||||
) {
|
|
||||||
return next(
|
|
||||||
createHttpError(
|
|
||||||
HttpCode.BAD_REQUEST,
|
|
||||||
"Invalid URL glob pattern provided"
|
|
||||||
)
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { Request, Response, NextFunction } from "express";
|
import { Request, Response, NextFunction } from "express";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { db, resources } from "@server/db";
|
import { db, resources } from "@server/db";
|
||||||
import { roleResources, roles } from "@server/db";
|
import { roleResources, roles, rolePolicies } 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";
|
||||||
@@ -131,31 +131,64 @@ export async function addRoleToResource(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if role already exists in resource
|
const isInlinePolicy =
|
||||||
const existingEntry = await db
|
resource.resourcePolicyId === null &&
|
||||||
.select()
|
resource.defaultResourcePolicyId !== null;
|
||||||
.from(roleResources)
|
|
||||||
.where(
|
|
||||||
and(
|
|
||||||
eq(roleResources.resourceId, resourceId),
|
|
||||||
eq(roleResources.roleId, roleId)
|
|
||||||
)
|
|
||||||
);
|
|
||||||
|
|
||||||
if (existingEntry.length > 0) {
|
if (isInlinePolicy) {
|
||||||
return next(
|
const policyId = resource.defaultResourcePolicyId!;
|
||||||
createHttpError(
|
|
||||||
HttpCode.CONFLICT,
|
// Check if role already exists in the inline policy
|
||||||
"Role already assigned to resource"
|
const existingEntry = await db
|
||||||
)
|
.select()
|
||||||
);
|
.from(rolePolicies)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(rolePolicies.resourcePolicyId, policyId),
|
||||||
|
eq(rolePolicies.roleId, roleId)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
if (existingEntry.length > 0) {
|
||||||
|
return next(
|
||||||
|
createHttpError(
|
||||||
|
HttpCode.CONFLICT,
|
||||||
|
"Role already assigned to resource"
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
await db.insert(rolePolicies).values({
|
||||||
|
roleId,
|
||||||
|
resourcePolicyId: policyId
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
// Check if role already exists in resource
|
||||||
|
const existingEntry = await db
|
||||||
|
.select()
|
||||||
|
.from(roleResources)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(roleResources.resourceId, resourceId),
|
||||||
|
eq(roleResources.roleId, roleId)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
if (existingEntry.length > 0) {
|
||||||
|
return next(
|
||||||
|
createHttpError(
|
||||||
|
HttpCode.CONFLICT,
|
||||||
|
"Role already assigned to resource"
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
await db.insert(roleResources).values({
|
||||||
|
roleId,
|
||||||
|
resourceId
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
await db.insert(roleResources).values({
|
|
||||||
roleId,
|
|
||||||
resourceId
|
|
||||||
});
|
|
||||||
|
|
||||||
return response(res, {
|
return response(res, {
|
||||||
data: {},
|
data: {},
|
||||||
success: true,
|
success: true,
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { Request, Response, NextFunction } from "express";
|
import { Request, Response, NextFunction } from "express";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { db, resources } from "@server/db";
|
import { db, resources } from "@server/db";
|
||||||
import { userResources } from "@server/db";
|
import { userResources, userPolicies } 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";
|
||||||
@@ -103,31 +103,64 @@ export async function addUserToResource(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if user already exists in resource
|
const isInlinePolicy =
|
||||||
const existingEntry = await db
|
resource.resourcePolicyId === null &&
|
||||||
.select()
|
resource.defaultResourcePolicyId !== null;
|
||||||
.from(userResources)
|
|
||||||
.where(
|
|
||||||
and(
|
|
||||||
eq(userResources.resourceId, resourceId),
|
|
||||||
eq(userResources.userId, userId)
|
|
||||||
)
|
|
||||||
);
|
|
||||||
|
|
||||||
if (existingEntry.length > 0) {
|
if (isInlinePolicy) {
|
||||||
return next(
|
const policyId = resource.defaultResourcePolicyId!;
|
||||||
createHttpError(
|
|
||||||
HttpCode.CONFLICT,
|
// Check if user already exists in the inline policy
|
||||||
"User already assigned to resource"
|
const existingEntry = await db
|
||||||
)
|
.select()
|
||||||
);
|
.from(userPolicies)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(userPolicies.resourcePolicyId, policyId),
|
||||||
|
eq(userPolicies.userId, userId)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
if (existingEntry.length > 0) {
|
||||||
|
return next(
|
||||||
|
createHttpError(
|
||||||
|
HttpCode.CONFLICT,
|
||||||
|
"User already assigned to resource"
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
await db.insert(userPolicies).values({
|
||||||
|
userId,
|
||||||
|
resourcePolicyId: policyId
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
// Check if user already exists in resource
|
||||||
|
const existingEntry = await db
|
||||||
|
.select()
|
||||||
|
.from(userResources)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(userResources.resourceId, resourceId),
|
||||||
|
eq(userResources.userId, userId)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
if (existingEntry.length > 0) {
|
||||||
|
return next(
|
||||||
|
createHttpError(
|
||||||
|
HttpCode.CONFLICT,
|
||||||
|
"User already assigned to resource"
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
await db.insert(userResources).values({
|
||||||
|
userId,
|
||||||
|
resourceId
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
await db.insert(userResources).values({
|
|
||||||
userId,
|
|
||||||
resourceId
|
|
||||||
});
|
|
||||||
|
|
||||||
return response(res, {
|
return response(res, {
|
||||||
data: {},
|
data: {},
|
||||||
success: true,
|
success: true,
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { Request, Response, NextFunction } from "express";
|
import { Request, Response, NextFunction } from "express";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { db } from "@server/db";
|
import { db } from "@server/db";
|
||||||
import { resourceRules, resources } from "@server/db";
|
import { resourceRules, resourcePolicyRules, resources } from "@server/db";
|
||||||
import { eq } from "drizzle-orm";
|
import { 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";
|
||||||
@@ -153,6 +153,34 @@ export async function createResourceRule(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Create the new resource rule
|
||||||
|
const isInlinePolicy =
|
||||||
|
resource.resourcePolicyId === null &&
|
||||||
|
resource.defaultResourcePolicyId !== null;
|
||||||
|
|
||||||
|
if (isInlinePolicy) {
|
||||||
|
const policyId = resource.defaultResourcePolicyId!;
|
||||||
|
const [newRule] = await db
|
||||||
|
.insert(resourcePolicyRules)
|
||||||
|
.values({
|
||||||
|
resourcePolicyId: policyId,
|
||||||
|
action,
|
||||||
|
match,
|
||||||
|
value,
|
||||||
|
priority,
|
||||||
|
enabled
|
||||||
|
})
|
||||||
|
.returning();
|
||||||
|
|
||||||
|
return response(res, {
|
||||||
|
data: newRule,
|
||||||
|
success: true,
|
||||||
|
error: false,
|
||||||
|
message: "Resource rule created successfully",
|
||||||
|
status: HttpCode.CREATED
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// Create the new resource rule
|
// Create the new resource rule
|
||||||
const [newRule] = await db
|
const [newRule] = await db
|
||||||
.insert(resourceRules)
|
.insert(resourceRules)
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { Request, Response, NextFunction } from "express";
|
import { Request, Response, NextFunction } from "express";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { db } from "@server/db";
|
import { db } from "@server/db";
|
||||||
import { resourceRules, resources } from "@server/db";
|
import { resourceRules, resourcePolicyRules, resources } from "@server/db";
|
||||||
import { eq } from "drizzle-orm";
|
import { 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";
|
||||||
@@ -59,6 +59,48 @@ export async function deleteResourceRule(
|
|||||||
|
|
||||||
const { ruleId } = parsedParams.data;
|
const { ruleId } = parsedParams.data;
|
||||||
|
|
||||||
|
// Look up resource to determine which table to use
|
||||||
|
const { resourceId } = parsedParams.data;
|
||||||
|
const [resource] = await db
|
||||||
|
.select()
|
||||||
|
.from(resources)
|
||||||
|
.where(eq(resources.resourceId, resourceId))
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
if (!resource) {
|
||||||
|
return next(
|
||||||
|
createHttpError(HttpCode.NOT_FOUND, "Resource not found")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const isInlinePolicy =
|
||||||
|
resource.resourcePolicyId === null &&
|
||||||
|
resource.defaultResourcePolicyId !== null;
|
||||||
|
|
||||||
|
if (isInlinePolicy) {
|
||||||
|
const [deletedRule] = await db
|
||||||
|
.delete(resourcePolicyRules)
|
||||||
|
.where(eq(resourcePolicyRules.ruleId, ruleId))
|
||||||
|
.returning();
|
||||||
|
|
||||||
|
if (!deletedRule) {
|
||||||
|
return next(
|
||||||
|
createHttpError(
|
||||||
|
HttpCode.NOT_FOUND,
|
||||||
|
`Resource rule with ID ${ruleId} not found`
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return response(res, {
|
||||||
|
data: null,
|
||||||
|
success: true,
|
||||||
|
error: false,
|
||||||
|
message: "Resource rule deleted successfully",
|
||||||
|
status: HttpCode.OK
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// Delete the rule and return the deleted record
|
// Delete the rule and return the deleted record
|
||||||
const [deletedRule] = await db
|
const [deletedRule] = await db
|
||||||
.delete(resourceRules)
|
.delete(resourceRules)
|
||||||
|
|||||||
@@ -225,7 +225,7 @@ export async function getResourceAuthInfo(
|
|||||||
wildcard: resource.wildcard ?? false,
|
wildcard: resource.wildcard ?? false,
|
||||||
fullDomain: resource.fullDomain,
|
fullDomain: resource.fullDomain,
|
||||||
whitelist: effectivePolicy?.emailWhitelistEnabled ?? false,
|
whitelist: effectivePolicy?.emailWhitelistEnabled ?? false,
|
||||||
skipToIdpId: resource.skipToIdpId,
|
skipToIdpId: effectivePolicy?.idpId ?? resource.skipToIdpId,
|
||||||
orgId: resource.orgId,
|
orgId: resource.orgId,
|
||||||
postAuthPath: resource.postAuthPath ?? null
|
postAuthPath: resource.postAuthPath ?? null
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,7 +1,11 @@
|
|||||||
import { Request, Response, NextFunction } from "express";
|
import { Request, Response, NextFunction } from "express";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { db } from "@server/db";
|
import { db } from "@server/db";
|
||||||
import { resourceWhitelist, users } from "@server/db"; // Assuming these are the correct tables
|
import {
|
||||||
|
resourceWhitelist,
|
||||||
|
resourcePolicyWhiteList,
|
||||||
|
resources
|
||||||
|
} from "@server/db";
|
||||||
import { eq } from "drizzle-orm";
|
import { 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";
|
||||||
@@ -23,6 +27,15 @@ async function queryWhitelist(resourceId: number) {
|
|||||||
.where(eq(resourceWhitelist.resourceId, resourceId));
|
.where(eq(resourceWhitelist.resourceId, resourceId));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function queryPolicyWhitelist(policyId: number) {
|
||||||
|
return await db
|
||||||
|
.select({
|
||||||
|
email: resourcePolicyWhiteList.email
|
||||||
|
})
|
||||||
|
.from(resourcePolicyWhiteList)
|
||||||
|
.where(eq(resourcePolicyWhiteList.resourcePolicyId, policyId));
|
||||||
|
}
|
||||||
|
|
||||||
export type GetResourceWhitelistResponse = {
|
export type GetResourceWhitelistResponse = {
|
||||||
whitelist: NonNullable<Awaited<ReturnType<typeof queryWhitelist>>>;
|
whitelist: NonNullable<Awaited<ReturnType<typeof queryWhitelist>>>;
|
||||||
};
|
};
|
||||||
@@ -71,7 +84,25 @@ export async function getResourceWhitelist(
|
|||||||
|
|
||||||
const { resourceId } = parsedParams.data;
|
const { resourceId } = parsedParams.data;
|
||||||
|
|
||||||
const whitelist = await queryWhitelist(resourceId);
|
const [resource] = await db
|
||||||
|
.select()
|
||||||
|
.from(resources)
|
||||||
|
.where(eq(resources.resourceId, resourceId))
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
if (!resource) {
|
||||||
|
return next(
|
||||||
|
createHttpError(HttpCode.NOT_FOUND, "Resource not found")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const isInlinePolicy =
|
||||||
|
resource.resourcePolicyId === null &&
|
||||||
|
resource.defaultResourcePolicyId !== null;
|
||||||
|
|
||||||
|
const whitelist = isInlinePolicy
|
||||||
|
? await queryPolicyWhitelist(resource.defaultResourcePolicyId!)
|
||||||
|
: await queryWhitelist(resourceId);
|
||||||
|
|
||||||
return response<GetResourceWhitelistResponse>(res, {
|
return response<GetResourceWhitelistResponse>(res, {
|
||||||
data: {
|
data: {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { Request, Response, NextFunction } from "express";
|
import { Request, Response, NextFunction } from "express";
|
||||||
import { db, DB_TYPE } from "@server/db";
|
import { db, DB_TYPE, type Label } from "@server/db";
|
||||||
import { and, eq, or, inArray, sql } from "drizzle-orm";
|
import { and, asc, eq, or, inArray, sql } from "drizzle-orm";
|
||||||
import {
|
import {
|
||||||
resources,
|
resources,
|
||||||
userResources,
|
userResources,
|
||||||
@@ -20,12 +20,17 @@ import {
|
|||||||
userSiteResources,
|
userSiteResources,
|
||||||
roleSiteResources,
|
roleSiteResources,
|
||||||
siteNetworks,
|
siteNetworks,
|
||||||
sites
|
sites,
|
||||||
|
labels,
|
||||||
|
resourceLabels,
|
||||||
|
siteResourceLabels
|
||||||
} from "@server/db";
|
} from "@server/db";
|
||||||
import createHttpError from "http-errors";
|
import createHttpError from "http-errors";
|
||||||
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 { getFirstString } from "@server/lib/requestParams";
|
import { getFirstString } from "@server/lib/requestParams";
|
||||||
|
import { isLicensedOrSubscribed } from "#dynamic/lib/isLicencedOrSubscribed";
|
||||||
|
import { tierMatrix } from "@server/lib/billing/tierMatrix";
|
||||||
|
|
||||||
export async function getUserResources(
|
export async function getUserResources(
|
||||||
req: Request,
|
req: Request,
|
||||||
@@ -353,6 +358,73 @@ export async function getUserResources(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const resourceIdList = resourcesData.map((r) => r.resourceId);
|
||||||
|
const siteResourceIdList = siteResourcesData.map(
|
||||||
|
(r) => r.siteResourceId
|
||||||
|
);
|
||||||
|
|
||||||
|
const isLabelFeatureEnabled = await isLicensedOrSubscribed(
|
||||||
|
orgId,
|
||||||
|
tierMatrix.labels
|
||||||
|
);
|
||||||
|
|
||||||
|
let labelsForResources: Array<{
|
||||||
|
labelId: number;
|
||||||
|
name: string;
|
||||||
|
color: string;
|
||||||
|
resourceId: number;
|
||||||
|
}> = [];
|
||||||
|
let labelsForSiteResources: Array<{
|
||||||
|
labelId: number;
|
||||||
|
name: string;
|
||||||
|
color: string;
|
||||||
|
siteResourceId: number;
|
||||||
|
}> = [];
|
||||||
|
|
||||||
|
if (isLabelFeatureEnabled) {
|
||||||
|
[labelsForResources, labelsForSiteResources] = await Promise.all([
|
||||||
|
resourceIdList.length === 0
|
||||||
|
? Promise.resolve([])
|
||||||
|
: db
|
||||||
|
.select({
|
||||||
|
labelId: labels.labelId,
|
||||||
|
name: labels.name,
|
||||||
|
color: labels.color,
|
||||||
|
resourceId: resourceLabels.resourceId
|
||||||
|
})
|
||||||
|
.from(labels)
|
||||||
|
.innerJoin(
|
||||||
|
resourceLabels,
|
||||||
|
eq(resourceLabels.labelId, labels.labelId)
|
||||||
|
)
|
||||||
|
.where(
|
||||||
|
inArray(resourceLabels.resourceId, resourceIdList)
|
||||||
|
)
|
||||||
|
.orderBy(asc(resourceLabels.resourceLabelId)),
|
||||||
|
siteResourceIdList.length === 0
|
||||||
|
? Promise.resolve([])
|
||||||
|
: db
|
||||||
|
.select({
|
||||||
|
labelId: labels.labelId,
|
||||||
|
name: labels.name,
|
||||||
|
color: labels.color,
|
||||||
|
siteResourceId: siteResourceLabels.siteResourceId
|
||||||
|
})
|
||||||
|
.from(labels)
|
||||||
|
.innerJoin(
|
||||||
|
siteResourceLabels,
|
||||||
|
eq(siteResourceLabels.labelId, labels.labelId)
|
||||||
|
)
|
||||||
|
.where(
|
||||||
|
inArray(
|
||||||
|
siteResourceLabels.siteResourceId,
|
||||||
|
siteResourceIdList
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.orderBy(asc(siteResourceLabels.siteResourceLabelId))
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
// Check for password, pincode, and whitelist protection for each resource
|
// Check for password, pincode, and whitelist protection for each resource
|
||||||
const resourcesWithAuth = await Promise.all(
|
const resourcesWithAuth = await Promise.all(
|
||||||
resourcesData.map(async (resource) => {
|
resourcesData.map(async (resource) => {
|
||||||
@@ -453,7 +525,10 @@ export async function getUserResources(
|
|||||||
sso: resource.sso,
|
sso: resource.sso,
|
||||||
password: hasPassword,
|
password: hasPassword,
|
||||||
pincode: hasPincode,
|
pincode: hasPincode,
|
||||||
whitelist: hasWhitelist
|
whitelist: hasWhitelist,
|
||||||
|
labels: labelsForResources.filter(
|
||||||
|
(l) => l.resourceId === resource.resourceId
|
||||||
|
)
|
||||||
};
|
};
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
@@ -479,7 +554,10 @@ export async function getUserResources(
|
|||||||
siteNiceIds: siteResource.siteNiceIds,
|
siteNiceIds: siteResource.siteNiceIds,
|
||||||
siteAddresses: siteResource.siteAddresses,
|
siteAddresses: siteResource.siteAddresses,
|
||||||
siteOnlines: siteResource.siteOnlines,
|
siteOnlines: siteResource.siteOnlines,
|
||||||
type: "site" as const
|
type: "site" as const,
|
||||||
|
labels: labelsForSiteResources.filter(
|
||||||
|
(l) => l.siteResourceId === siteResource.siteResourceId
|
||||||
|
)
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -514,6 +592,7 @@ export type GetUserResourcesResponse = {
|
|||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
protected: boolean;
|
protected: boolean;
|
||||||
mode: string;
|
mode: string;
|
||||||
|
labels?: Array<Pick<Label, "color" | "labelId" | "name">>;
|
||||||
}>;
|
}>;
|
||||||
siteResources: Array<{
|
siteResources: Array<{
|
||||||
siteResourceId: number;
|
siteResourceId: number;
|
||||||
@@ -535,6 +614,7 @@ export type GetUserResourcesResponse = {
|
|||||||
siteAddresses: (string | null)[];
|
siteAddresses: (string | null)[];
|
||||||
siteOnlines: boolean[];
|
siteOnlines: boolean[];
|
||||||
type: "site";
|
type: "site";
|
||||||
|
labels?: Array<Pick<Label, "color" | "labelId" | "name">>;
|
||||||
}>;
|
}>;
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { Request, Response, NextFunction } from "express";
|
import { Request, Response, NextFunction } from "express";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { db } from "@server/db";
|
import { db } from "@server/db";
|
||||||
import { roleResources, roles } from "@server/db";
|
import { roleResources, roles, rolePolicies, resources } from "@server/db";
|
||||||
import { eq } from "drizzle-orm";
|
import { 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";
|
||||||
@@ -27,6 +27,19 @@ async function query(resourceId: number) {
|
|||||||
.where(eq(roleResources.resourceId, resourceId));
|
.where(eq(roleResources.resourceId, resourceId));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function queryInlinePolicy(policyId: number) {
|
||||||
|
return await db
|
||||||
|
.select({
|
||||||
|
roleId: roles.roleId,
|
||||||
|
name: roles.name,
|
||||||
|
description: roles.description,
|
||||||
|
isAdmin: roles.isAdmin
|
||||||
|
})
|
||||||
|
.from(rolePolicies)
|
||||||
|
.innerJoin(roles, eq(rolePolicies.roleId, roles.roleId))
|
||||||
|
.where(eq(rolePolicies.resourcePolicyId, policyId));
|
||||||
|
}
|
||||||
|
|
||||||
export type ListResourceRolesResponse = {
|
export type ListResourceRolesResponse = {
|
||||||
roles: NonNullable<Awaited<ReturnType<typeof query>>>;
|
roles: NonNullable<Awaited<ReturnType<typeof query>>>;
|
||||||
};
|
};
|
||||||
@@ -75,7 +88,25 @@ export async function listResourceRoles(
|
|||||||
|
|
||||||
const { resourceId } = parsedParams.data;
|
const { resourceId } = parsedParams.data;
|
||||||
|
|
||||||
const resourceRolesList = await query(resourceId);
|
const [resource] = await db
|
||||||
|
.select()
|
||||||
|
.from(resources)
|
||||||
|
.where(eq(resources.resourceId, resourceId))
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
if (!resource) {
|
||||||
|
return next(
|
||||||
|
createHttpError(HttpCode.NOT_FOUND, "Resource not found")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const isInlinePolicy =
|
||||||
|
resource.resourcePolicyId === null &&
|
||||||
|
resource.defaultResourcePolicyId !== null;
|
||||||
|
|
||||||
|
const resourceRolesList = isInlinePolicy
|
||||||
|
? await queryInlinePolicy(resource.defaultResourcePolicyId!)
|
||||||
|
: await query(resourceId);
|
||||||
|
|
||||||
return response<ListResourceRolesResponse>(res, {
|
return response<ListResourceRolesResponse>(res, {
|
||||||
data: {
|
data: {
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { db } from "@server/db";
|
import { db } from "@server/db";
|
||||||
import { resourceRules, resources } from "@server/db";
|
import { resourceRules, resourcePolicyRules, resources } 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, sql } from "drizzle-orm";
|
import { eq, sql } from "drizzle-orm";
|
||||||
@@ -47,6 +47,21 @@ function queryResourceRules(resourceId: number) {
|
|||||||
return baseQuery;
|
return baseQuery;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function queryPolicyRules(policyId: number) {
|
||||||
|
return db
|
||||||
|
.select({
|
||||||
|
ruleId: resourcePolicyRules.ruleId,
|
||||||
|
resourceId: sql<number | null>`null`,
|
||||||
|
action: resourcePolicyRules.action,
|
||||||
|
match: resourcePolicyRules.match,
|
||||||
|
value: resourcePolicyRules.value,
|
||||||
|
priority: resourcePolicyRules.priority,
|
||||||
|
enabled: resourcePolicyRules.enabled
|
||||||
|
})
|
||||||
|
.from(resourcePolicyRules)
|
||||||
|
.where(eq(resourcePolicyRules.resourcePolicyId, policyId));
|
||||||
|
}
|
||||||
|
|
||||||
export type ListResourceRulesResponse = {
|
export type ListResourceRulesResponse = {
|
||||||
rules: Awaited<ReturnType<typeof queryResourceRules>>;
|
rules: Awaited<ReturnType<typeof queryResourceRules>>;
|
||||||
pagination: { total: number; limit: number; offset: number };
|
pagination: { total: number; limit: number; offset: number };
|
||||||
@@ -125,16 +140,34 @@ export async function listResourceRules(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const baseQuery = queryResourceRules(resourceId);
|
const isInlinePolicy =
|
||||||
|
resource.resourcePolicyId === null &&
|
||||||
|
resource.defaultResourcePolicyId !== null;
|
||||||
|
|
||||||
const countQuery = db
|
let rulesList: Awaited<ReturnType<typeof queryResourceRules>>;
|
||||||
.select({ count: sql<number>`cast(count(*) as integer)` })
|
let totalCount: number;
|
||||||
.from(resourceRules)
|
|
||||||
.where(eq(resourceRules.resourceId, resourceId));
|
|
||||||
|
|
||||||
let rulesList = await baseQuery.limit(limit).offset(offset);
|
if (isInlinePolicy) {
|
||||||
const totalCountResult = await countQuery;
|
const policyId = resource.defaultResourcePolicyId!;
|
||||||
const totalCount = totalCountResult[0].count;
|
const policyRules = await queryPolicyRules(policyId)
|
||||||
|
.limit(limit)
|
||||||
|
.offset(offset);
|
||||||
|
const countResult = await db
|
||||||
|
.select({ count: sql<number>`cast(count(*) as integer)` })
|
||||||
|
.from(resourcePolicyRules)
|
||||||
|
.where(eq(resourcePolicyRules.resourcePolicyId, policyId));
|
||||||
|
rulesList = policyRules as typeof rulesList;
|
||||||
|
totalCount = countResult[0].count;
|
||||||
|
} else {
|
||||||
|
const baseQuery = queryResourceRules(resourceId);
|
||||||
|
const countQuery = db
|
||||||
|
.select({ count: sql<number>`cast(count(*) as integer)` })
|
||||||
|
.from(resourceRules)
|
||||||
|
.where(eq(resourceRules.resourceId, resourceId));
|
||||||
|
rulesList = await baseQuery.limit(limit).offset(offset);
|
||||||
|
const totalCountResult = await countQuery;
|
||||||
|
totalCount = totalCountResult[0].count;
|
||||||
|
}
|
||||||
|
|
||||||
// sort rules list by the priority in ascending order
|
// sort rules list by the priority in ascending order
|
||||||
rulesList = rulesList.sort((a, b) => a.priority - b.priority);
|
rulesList = rulesList.sort((a, b) => a.priority - b.priority);
|
||||||
|
|||||||
@@ -1,9 +1,8 @@
|
|||||||
import {
|
import {
|
||||||
browserGatewayTarget,
|
alias,
|
||||||
db,
|
db,
|
||||||
labels,
|
labels,
|
||||||
resourceHeaderAuth,
|
resourceHeaderAuth,
|
||||||
resourceHeaderAuthExtendedCompatibility,
|
|
||||||
resourceLabels,
|
resourceLabels,
|
||||||
resourcePassword,
|
resourcePassword,
|
||||||
resourcePincode,
|
resourcePincode,
|
||||||
@@ -187,16 +186,98 @@ export type ResourceWithTargets = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
function queryResourcesBase() {
|
function queryResourcesBase() {
|
||||||
|
const sharedPolicy = alias(resourcePolicies, "sharedPolicy");
|
||||||
|
const defaultPolicy = alias(resourcePolicies, "defaultPolicy");
|
||||||
|
const sharedPolicyPincode = alias(
|
||||||
|
resourcePolicyPincode,
|
||||||
|
"sharedPolicyPincode"
|
||||||
|
);
|
||||||
|
const defaultPolicyPincode = alias(
|
||||||
|
resourcePolicyPincode,
|
||||||
|
"defaultPolicyPincode"
|
||||||
|
);
|
||||||
|
const sharedPolicyPassword = alias(
|
||||||
|
resourcePolicyPassword,
|
||||||
|
"sharedPolicyPassword"
|
||||||
|
);
|
||||||
|
const defaultPolicyPassword = alias(
|
||||||
|
resourcePolicyPassword,
|
||||||
|
"defaultPolicyPassword"
|
||||||
|
);
|
||||||
|
const sharedPolicyHeaderAuth = alias(
|
||||||
|
resourcePolicyHeaderAuth,
|
||||||
|
"sharedPolicyHeaderAuth"
|
||||||
|
);
|
||||||
|
const defaultPolicyHeaderAuth = alias(
|
||||||
|
resourcePolicyHeaderAuth,
|
||||||
|
"defaultPolicyHeaderAuth"
|
||||||
|
);
|
||||||
|
|
||||||
|
const effectivePasswordId = sql<number | null>`
|
||||||
|
COALESCE(
|
||||||
|
CASE
|
||||||
|
WHEN ${sharedPolicy.resourcePolicyId} IS NOT NULL THEN ${sharedPolicyPassword.passwordId}
|
||||||
|
ELSE ${defaultPolicyPassword.passwordId}
|
||||||
|
END,
|
||||||
|
${resourcePassword.passwordId}
|
||||||
|
)
|
||||||
|
`;
|
||||||
|
const effectivePincodeId = sql<number | null>`
|
||||||
|
COALESCE(
|
||||||
|
CASE
|
||||||
|
WHEN ${sharedPolicy.resourcePolicyId} IS NOT NULL THEN ${sharedPolicyPincode.pincodeId}
|
||||||
|
ELSE ${defaultPolicyPincode.pincodeId}
|
||||||
|
END,
|
||||||
|
${resourcePincode.pincodeId}
|
||||||
|
)
|
||||||
|
`;
|
||||||
|
const effectiveHeaderAuthId = sql<number | null>`
|
||||||
|
COALESCE(
|
||||||
|
CASE
|
||||||
|
WHEN ${sharedPolicy.resourcePolicyId} IS NOT NULL THEN ${sharedPolicyHeaderAuth.headerAuthId}
|
||||||
|
ELSE ${defaultPolicyHeaderAuth.headerAuthId}
|
||||||
|
END,
|
||||||
|
${resourceHeaderAuth.headerAuthId}
|
||||||
|
)
|
||||||
|
`;
|
||||||
|
const effectiveSso = sql<boolean>`
|
||||||
|
COALESCE(
|
||||||
|
CASE
|
||||||
|
WHEN ${sharedPolicy.resourcePolicyId} IS NOT NULL THEN ${sharedPolicy.sso}
|
||||||
|
ELSE ${defaultPolicy.sso}
|
||||||
|
END,
|
||||||
|
false
|
||||||
|
)
|
||||||
|
`;
|
||||||
|
const effectiveWhitelist = sql<boolean>`
|
||||||
|
COALESCE(
|
||||||
|
CASE
|
||||||
|
WHEN ${sharedPolicy.resourcePolicyId} IS NOT NULL THEN ${sharedPolicy.emailWhitelistEnabled}
|
||||||
|
ELSE ${defaultPolicy.emailWhitelistEnabled}
|
||||||
|
END,
|
||||||
|
false
|
||||||
|
)
|
||||||
|
`;
|
||||||
|
const effectiveHeaderAuthExtendedCompatibility = sql<boolean>`
|
||||||
|
COALESCE(
|
||||||
|
CASE
|
||||||
|
WHEN ${sharedPolicy.resourcePolicyId} IS NOT NULL THEN ${sharedPolicyHeaderAuth.extendedCompatibility}
|
||||||
|
ELSE ${defaultPolicyHeaderAuth.extendedCompatibility}
|
||||||
|
END,
|
||||||
|
false
|
||||||
|
)
|
||||||
|
`;
|
||||||
|
|
||||||
return db
|
return db
|
||||||
.select({
|
.select({
|
||||||
resourceId: resources.resourceId,
|
resourceId: resources.resourceId,
|
||||||
name: resources.name,
|
name: resources.name,
|
||||||
ssl: resources.ssl,
|
ssl: resources.ssl,
|
||||||
fullDomain: resources.fullDomain,
|
fullDomain: resources.fullDomain,
|
||||||
passwordId: resourcePolicyPassword.passwordId,
|
passwordId: effectivePasswordId,
|
||||||
sso: resourcePolicies.sso,
|
sso: effectiveSso,
|
||||||
pincodeId: resourcePolicyPincode.pincodeId,
|
pincodeId: effectivePincodeId,
|
||||||
whitelist: resourcePolicies.emailWhitelistEnabled,
|
whitelist: effectiveWhitelist,
|
||||||
proxyPort: resources.proxyPort,
|
proxyPort: resources.proxyPort,
|
||||||
enabled: resources.enabled,
|
enabled: resources.enabled,
|
||||||
domainId: resources.domainId,
|
domainId: resources.domainId,
|
||||||
@@ -204,44 +285,74 @@ function queryResourcesBase() {
|
|||||||
wildcard: resources.wildcard,
|
wildcard: resources.wildcard,
|
||||||
mode: resources.mode,
|
mode: resources.mode,
|
||||||
health: resources.health,
|
health: resources.health,
|
||||||
headerAuthId: resourcePolicyHeaderAuth.headerAuthId,
|
headerAuthId: effectiveHeaderAuthId,
|
||||||
headerAuthExtendedCompatibility:
|
headerAuthExtendedCompatibility:
|
||||||
resourcePolicyHeaderAuth.extendedCompatibility
|
effectiveHeaderAuthExtendedCompatibility
|
||||||
})
|
})
|
||||||
.from(resources)
|
.from(resources)
|
||||||
.leftJoin(
|
.leftJoin(
|
||||||
resourcePolicies,
|
resourcePincode,
|
||||||
or(
|
eq(resourcePincode.resourceId, resources.resourceId)
|
||||||
eq(
|
|
||||||
resourcePolicies.resourcePolicyId,
|
|
||||||
resources.resourcePolicyId
|
|
||||||
),
|
|
||||||
eq(
|
|
||||||
resourcePolicies.resourcePolicyId,
|
|
||||||
resources.defaultResourcePolicyId
|
|
||||||
)
|
|
||||||
)
|
|
||||||
)
|
)
|
||||||
|
|
||||||
.leftJoin(
|
.leftJoin(
|
||||||
resourcePolicyPassword,
|
resourcePassword,
|
||||||
|
eq(resourcePassword.resourceId, resources.resourceId)
|
||||||
|
)
|
||||||
|
.leftJoin(
|
||||||
|
resourceHeaderAuth,
|
||||||
|
eq(resourceHeaderAuth.resourceId, resources.resourceId)
|
||||||
|
)
|
||||||
|
.leftJoin(
|
||||||
|
sharedPolicy,
|
||||||
|
eq(sharedPolicy.resourcePolicyId, resources.resourcePolicyId)
|
||||||
|
)
|
||||||
|
.leftJoin(
|
||||||
|
sharedPolicyPincode,
|
||||||
eq(
|
eq(
|
||||||
resourcePolicyPassword.resourcePolicyId,
|
sharedPolicyPincode.resourcePolicyId,
|
||||||
resourcePolicies.resourcePolicyId
|
sharedPolicy.resourcePolicyId
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
.leftJoin(
|
.leftJoin(
|
||||||
resourcePolicyPincode,
|
sharedPolicyPassword,
|
||||||
eq(
|
eq(
|
||||||
resourcePolicyPincode.resourcePolicyId,
|
sharedPolicyPassword.resourcePolicyId,
|
||||||
resourcePolicies.resourcePolicyId
|
sharedPolicy.resourcePolicyId
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
.leftJoin(
|
.leftJoin(
|
||||||
resourcePolicyHeaderAuth,
|
sharedPolicyHeaderAuth,
|
||||||
eq(
|
eq(
|
||||||
resourcePolicyHeaderAuth.resourcePolicyId,
|
sharedPolicyHeaderAuth.resourcePolicyId,
|
||||||
resourcePolicies.resourcePolicyId
|
sharedPolicy.resourcePolicyId
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.leftJoin(
|
||||||
|
defaultPolicy,
|
||||||
|
eq(
|
||||||
|
defaultPolicy.resourcePolicyId,
|
||||||
|
resources.defaultResourcePolicyId
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.leftJoin(
|
||||||
|
defaultPolicyPincode,
|
||||||
|
eq(
|
||||||
|
defaultPolicyPincode.resourcePolicyId,
|
||||||
|
defaultPolicy.resourcePolicyId
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.leftJoin(
|
||||||
|
defaultPolicyPassword,
|
||||||
|
eq(
|
||||||
|
defaultPolicyPassword.resourcePolicyId,
|
||||||
|
defaultPolicy.resourcePolicyId
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.leftJoin(
|
||||||
|
defaultPolicyHeaderAuth,
|
||||||
|
eq(
|
||||||
|
defaultPolicyHeaderAuth.resourcePolicyId,
|
||||||
|
defaultPolicy.resourcePolicyId
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
.leftJoin(targets, eq(targets.resourceId, resources.resourceId))
|
.leftJoin(targets, eq(targets.resourceId, resources.resourceId))
|
||||||
@@ -251,10 +362,23 @@ function queryResourcesBase() {
|
|||||||
)
|
)
|
||||||
.groupBy(
|
.groupBy(
|
||||||
resources.resourceId,
|
resources.resourceId,
|
||||||
resourcePolicies.resourcePolicyId,
|
resourcePincode.pincodeId,
|
||||||
resourcePolicyPassword.passwordId,
|
resourcePassword.passwordId,
|
||||||
resourcePolicyPincode.pincodeId,
|
resourceHeaderAuth.headerAuthId,
|
||||||
resourcePolicyHeaderAuth.headerAuthId
|
sharedPolicy.resourcePolicyId,
|
||||||
|
sharedPolicy.sso,
|
||||||
|
sharedPolicy.emailWhitelistEnabled,
|
||||||
|
sharedPolicyPincode.pincodeId,
|
||||||
|
sharedPolicyPassword.passwordId,
|
||||||
|
sharedPolicyHeaderAuth.headerAuthId,
|
||||||
|
sharedPolicyHeaderAuth.extendedCompatibility,
|
||||||
|
defaultPolicy.resourcePolicyId,
|
||||||
|
defaultPolicy.sso,
|
||||||
|
defaultPolicy.emailWhitelistEnabled,
|
||||||
|
defaultPolicyPincode.pincodeId,
|
||||||
|
defaultPolicyPassword.passwordId,
|
||||||
|
defaultPolicyHeaderAuth.headerAuthId,
|
||||||
|
defaultPolicyHeaderAuth.extendedCompatibility
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -396,6 +520,80 @@ export async function listResources(
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (typeof authState !== "undefined") {
|
if (typeof authState !== "undefined") {
|
||||||
|
const sharedPolicy = alias(resourcePolicies, "sharedPolicy");
|
||||||
|
const defaultPolicy = alias(resourcePolicies, "defaultPolicy");
|
||||||
|
const sharedPolicyPincode = alias(
|
||||||
|
resourcePolicyPincode,
|
||||||
|
"sharedPolicyPincode"
|
||||||
|
);
|
||||||
|
const defaultPolicyPincode = alias(
|
||||||
|
resourcePolicyPincode,
|
||||||
|
"defaultPolicyPincode"
|
||||||
|
);
|
||||||
|
const sharedPolicyPassword = alias(
|
||||||
|
resourcePolicyPassword,
|
||||||
|
"sharedPolicyPassword"
|
||||||
|
);
|
||||||
|
const defaultPolicyPassword = alias(
|
||||||
|
resourcePolicyPassword,
|
||||||
|
"defaultPolicyPassword"
|
||||||
|
);
|
||||||
|
const sharedPolicyHeaderAuth = alias(
|
||||||
|
resourcePolicyHeaderAuth,
|
||||||
|
"sharedPolicyHeaderAuth"
|
||||||
|
);
|
||||||
|
const defaultPolicyHeaderAuth = alias(
|
||||||
|
resourcePolicyHeaderAuth,
|
||||||
|
"defaultPolicyHeaderAuth"
|
||||||
|
);
|
||||||
|
|
||||||
|
const effectiveSso = sql<boolean>`
|
||||||
|
COALESCE(
|
||||||
|
CASE
|
||||||
|
WHEN ${sharedPolicy.resourcePolicyId} IS NOT NULL THEN ${sharedPolicy.sso}
|
||||||
|
ELSE ${defaultPolicy.sso}
|
||||||
|
END,
|
||||||
|
false
|
||||||
|
)
|
||||||
|
`;
|
||||||
|
const effectiveWhitelist = sql<boolean>`
|
||||||
|
COALESCE(
|
||||||
|
CASE
|
||||||
|
WHEN ${sharedPolicy.resourcePolicyId} IS NOT NULL THEN ${sharedPolicy.emailWhitelistEnabled}
|
||||||
|
ELSE ${defaultPolicy.emailWhitelistEnabled}
|
||||||
|
END,
|
||||||
|
false
|
||||||
|
)
|
||||||
|
`;
|
||||||
|
const effectiveHeaderAuthId = sql<number | null>`
|
||||||
|
COALESCE(
|
||||||
|
CASE
|
||||||
|
WHEN ${sharedPolicy.resourcePolicyId} IS NOT NULL THEN ${sharedPolicyHeaderAuth.headerAuthId}
|
||||||
|
ELSE ${defaultPolicyHeaderAuth.headerAuthId}
|
||||||
|
END,
|
||||||
|
${resourceHeaderAuth.headerAuthId}
|
||||||
|
)
|
||||||
|
`;
|
||||||
|
const effectivePincodeId = sql<number | null>`
|
||||||
|
COALESCE(
|
||||||
|
CASE
|
||||||
|
WHEN ${sharedPolicy.resourcePolicyId} IS NOT NULL THEN ${sharedPolicyPincode.pincodeId}
|
||||||
|
ELSE ${defaultPolicyPincode.pincodeId}
|
||||||
|
END,
|
||||||
|
${resourcePincode.pincodeId}
|
||||||
|
)
|
||||||
|
`;
|
||||||
|
const effectivePasswordId = sql<number | null>`
|
||||||
|
COALESCE(
|
||||||
|
CASE
|
||||||
|
WHEN ${sharedPolicy.resourcePolicyId} IS NOT NULL THEN ${sharedPolicyPassword.passwordId}
|
||||||
|
ELSE ${defaultPolicyPassword.passwordId}
|
||||||
|
END,
|
||||||
|
${resourcePassword.passwordId}
|
||||||
|
)
|
||||||
|
`;
|
||||||
|
const browserGatewayModes = ["http", "ssh", "rdp", "vnc"];
|
||||||
|
|
||||||
switch (authState) {
|
switch (authState) {
|
||||||
case "none":
|
case "none":
|
||||||
conditions.push(
|
conditions.push(
|
||||||
@@ -404,22 +602,28 @@ export async function listResources(
|
|||||||
break;
|
break;
|
||||||
case "protected":
|
case "protected":
|
||||||
conditions.push(
|
conditions.push(
|
||||||
or(
|
and(
|
||||||
eq(resourcePolicies.sso, true),
|
inArray(resources.mode, browserGatewayModes),
|
||||||
eq(resourcePolicies.emailWhitelistEnabled, true),
|
or(
|
||||||
not(isNull(resourcePolicyHeaderAuth.headerAuthId)),
|
eq(effectiveSso, true),
|
||||||
not(isNull(resourcePolicyPincode.pincodeId)),
|
eq(effectiveWhitelist, true),
|
||||||
not(isNull(resourcePolicyPassword.passwordId))
|
not(isNull(effectiveHeaderAuthId)),
|
||||||
|
not(isNull(effectivePincodeId)),
|
||||||
|
not(isNull(effectivePasswordId))
|
||||||
|
)
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
break;
|
break;
|
||||||
case "not_protected":
|
case "not_protected":
|
||||||
conditions.push(
|
conditions.push(
|
||||||
not(eq(resourcePolicies.sso, true)),
|
and(
|
||||||
not(eq(resourcePolicies.emailWhitelistEnabled, true)),
|
inArray(resources.mode, browserGatewayModes),
|
||||||
isNull(resourcePolicyHeaderAuth.headerAuthId),
|
not(eq(effectiveSso, true)),
|
||||||
isNull(resourcePolicyPincode.pincodeId),
|
not(eq(effectiveWhitelist, true)),
|
||||||
isNull(resourcePolicyPassword.passwordId)
|
isNull(effectiveHeaderAuthId),
|
||||||
|
isNull(effectivePincodeId),
|
||||||
|
isNull(effectivePasswordId)
|
||||||
|
)
|
||||||
);
|
);
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
@@ -434,15 +638,8 @@ export async function listResources(
|
|||||||
.from(targets)
|
.from(targets)
|
||||||
.innerJoin(sites, eq(targets.siteId, sites.siteId))
|
.innerJoin(sites, eq(targets.siteId, sites.siteId))
|
||||||
.where(and(eq(sites.orgId, orgId), eq(sites.siteId, siteId)));
|
.where(and(eq(sites.orgId, orgId), eq(sites.siteId, siteId)));
|
||||||
const resourcesWithBrowserGateway = db
|
|
||||||
.select({ resourceId: browserGatewayTarget.resourceId })
|
|
||||||
.from(browserGatewayTarget)
|
|
||||||
.where(eq(browserGatewayTarget.siteId, siteId));
|
|
||||||
conditions.push(
|
conditions.push(
|
||||||
or(
|
or(inArray(resources.resourceId, resourcesWithSite))
|
||||||
inArray(resources.resourceId, resourcesWithSite),
|
|
||||||
inArray(resources.resourceId, resourcesWithBrowserGateway)
|
|
||||||
)
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -565,30 +762,6 @@ export async function listResources(
|
|||||||
)
|
)
|
||||||
.leftJoin(sites, eq(targets.siteId, sites.siteId));
|
.leftJoin(sites, eq(targets.siteId, sites.siteId));
|
||||||
|
|
||||||
const allBgTargetSites =
|
|
||||||
resourceIdList.length === 0
|
|
||||||
? []
|
|
||||||
: await db
|
|
||||||
.select({
|
|
||||||
resourceId: browserGatewayTarget.resourceId,
|
|
||||||
siteId: browserGatewayTarget.siteId,
|
|
||||||
siteName: sites.name,
|
|
||||||
siteNiceId: sites.niceId,
|
|
||||||
siteOnline: sites.online,
|
|
||||||
siteType: sites.type
|
|
||||||
})
|
|
||||||
.from(browserGatewayTarget)
|
|
||||||
.where(
|
|
||||||
inArray(
|
|
||||||
browserGatewayTarget.resourceId,
|
|
||||||
resourceIdList
|
|
||||||
)
|
|
||||||
)
|
|
||||||
.leftJoin(
|
|
||||||
sites,
|
|
||||||
eq(sites.siteId, browserGatewayTarget.siteId)
|
|
||||||
);
|
|
||||||
|
|
||||||
// avoids TS issues with reduce/never[]
|
// avoids TS issues with reduce/never[]
|
||||||
const map = new Map<number, ResourceWithTargets>();
|
const map = new Map<number, ResourceWithTargets>();
|
||||||
|
|
||||||
@@ -651,21 +824,6 @@ export async function listResources(
|
|||||||
online: isLocal ? undefined : Boolean(t.siteOnline)
|
online: isLocal ? undefined : Boolean(t.siteOnline)
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
const bgRaw = allBgTargetSites.filter(
|
|
||||||
(t) => t.resourceId === entry.resourceId
|
|
||||||
);
|
|
||||||
for (const t of bgRaw) {
|
|
||||||
if (typeof t.siteId !== "number" || siteById.has(t.siteId)) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
const isLocal = t.siteType === "local";
|
|
||||||
siteById.set(t.siteId, {
|
|
||||||
siteId: t.siteId,
|
|
||||||
siteName: t.siteName ?? "",
|
|
||||||
siteNiceId: t.siteNiceId ?? "",
|
|
||||||
online: isLocal ? undefined : Boolean(t.siteOnline)
|
|
||||||
});
|
|
||||||
}
|
|
||||||
entry.sites = Array.from(siteById.values());
|
entry.sites = Array.from(siteById.values());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { Request, Response, NextFunction } from "express";
|
import { Request, Response, NextFunction } from "express";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { db, resources } from "@server/db";
|
import { db, resources } from "@server/db";
|
||||||
import { roleResources, roles } from "@server/db";
|
import { roleResources, roles, rolePolicies } 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";
|
||||||
@@ -130,35 +130,71 @@ export async function removeRoleFromResource(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if role exists in resource
|
const isInlinePolicy =
|
||||||
const existingEntry = await db
|
resource.resourcePolicyId === null &&
|
||||||
.select()
|
resource.defaultResourcePolicyId !== null;
|
||||||
.from(roleResources)
|
|
||||||
.where(
|
|
||||||
and(
|
|
||||||
eq(roleResources.resourceId, resourceId),
|
|
||||||
eq(roleResources.roleId, roleId)
|
|
||||||
)
|
|
||||||
);
|
|
||||||
|
|
||||||
if (existingEntry.length === 0) {
|
if (isInlinePolicy) {
|
||||||
return next(
|
const policyId = resource.defaultResourcePolicyId!;
|
||||||
createHttpError(
|
|
||||||
HttpCode.NOT_FOUND,
|
const existingEntry = await db
|
||||||
"Role not found in resource"
|
.select()
|
||||||
)
|
.from(rolePolicies)
|
||||||
);
|
.where(
|
||||||
|
and(
|
||||||
|
eq(rolePolicies.resourcePolicyId, policyId),
|
||||||
|
eq(rolePolicies.roleId, roleId)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
if (existingEntry.length === 0) {
|
||||||
|
return next(
|
||||||
|
createHttpError(
|
||||||
|
HttpCode.NOT_FOUND,
|
||||||
|
"Role not found in resource"
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
await db
|
||||||
|
.delete(rolePolicies)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(rolePolicies.resourcePolicyId, policyId),
|
||||||
|
eq(rolePolicies.roleId, roleId)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
// Check if role exists in resource
|
||||||
|
const existingEntry = await db
|
||||||
|
.select()
|
||||||
|
.from(roleResources)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(roleResources.resourceId, resourceId),
|
||||||
|
eq(roleResources.roleId, roleId)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
if (existingEntry.length === 0) {
|
||||||
|
return next(
|
||||||
|
createHttpError(
|
||||||
|
HttpCode.NOT_FOUND,
|
||||||
|
"Role not found in resource"
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
await db
|
||||||
|
.delete(roleResources)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(roleResources.resourceId, resourceId),
|
||||||
|
eq(roleResources.roleId, roleId)
|
||||||
|
)
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
await db
|
|
||||||
.delete(roleResources)
|
|
||||||
.where(
|
|
||||||
and(
|
|
||||||
eq(roleResources.resourceId, resourceId),
|
|
||||||
eq(roleResources.roleId, roleId)
|
|
||||||
)
|
|
||||||
);
|
|
||||||
|
|
||||||
return response(res, {
|
return response(res, {
|
||||||
data: {},
|
data: {},
|
||||||
success: true,
|
success: true,
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { Request, Response, NextFunction } from "express";
|
import { Request, Response, NextFunction } from "express";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { db, resources } from "@server/db";
|
import { db, resources } from "@server/db";
|
||||||
import { userResources } from "@server/db";
|
import { userResources, userPolicies } 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";
|
||||||
@@ -103,35 +103,71 @@ export async function removeUserFromResource(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if user exists in resource
|
const isInlinePolicy =
|
||||||
const existingEntry = await db
|
resource.resourcePolicyId === null &&
|
||||||
.select()
|
resource.defaultResourcePolicyId !== null;
|
||||||
.from(userResources)
|
|
||||||
.where(
|
|
||||||
and(
|
|
||||||
eq(userResources.resourceId, resourceId),
|
|
||||||
eq(userResources.userId, userId)
|
|
||||||
)
|
|
||||||
);
|
|
||||||
|
|
||||||
if (existingEntry.length === 0) {
|
if (isInlinePolicy) {
|
||||||
return next(
|
const policyId = resource.defaultResourcePolicyId!;
|
||||||
createHttpError(
|
|
||||||
HttpCode.NOT_FOUND,
|
const existingEntry = await db
|
||||||
"User not found in resource"
|
.select()
|
||||||
)
|
.from(userPolicies)
|
||||||
);
|
.where(
|
||||||
|
and(
|
||||||
|
eq(userPolicies.resourcePolicyId, policyId),
|
||||||
|
eq(userPolicies.userId, userId)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
if (existingEntry.length === 0) {
|
||||||
|
return next(
|
||||||
|
createHttpError(
|
||||||
|
HttpCode.NOT_FOUND,
|
||||||
|
"User not found in resource"
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
await db
|
||||||
|
.delete(userPolicies)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(userPolicies.resourcePolicyId, policyId),
|
||||||
|
eq(userPolicies.userId, userId)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
// Check if user exists in resource
|
||||||
|
const existingEntry = await db
|
||||||
|
.select()
|
||||||
|
.from(userResources)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(userResources.resourceId, resourceId),
|
||||||
|
eq(userResources.userId, userId)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
if (existingEntry.length === 0) {
|
||||||
|
return next(
|
||||||
|
createHttpError(
|
||||||
|
HttpCode.NOT_FOUND,
|
||||||
|
"User not found in resource"
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
await db
|
||||||
|
.delete(userResources)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(userResources.resourceId, resourceId),
|
||||||
|
eq(userResources.userId, userId)
|
||||||
|
)
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
await db
|
|
||||||
.delete(userResources)
|
|
||||||
.where(
|
|
||||||
and(
|
|
||||||
eq(userResources.resourceId, resourceId),
|
|
||||||
eq(userResources.userId, userId)
|
|
||||||
)
|
|
||||||
);
|
|
||||||
|
|
||||||
return response(res, {
|
return response(res, {
|
||||||
data: {},
|
data: {},
|
||||||
success: true,
|
success: true,
|
||||||
|
|||||||
@@ -3,7 +3,9 @@ import { z } from "zod";
|
|||||||
import {
|
import {
|
||||||
db,
|
db,
|
||||||
resourceHeaderAuth,
|
resourceHeaderAuth,
|
||||||
resourceHeaderAuthExtendedCompatibility
|
resourceHeaderAuthExtendedCompatibility,
|
||||||
|
resourcePolicyHeaderAuth,
|
||||||
|
resources
|
||||||
} from "@server/db";
|
} from "@server/db";
|
||||||
import { eq } from "drizzle-orm";
|
import { eq } from "drizzle-orm";
|
||||||
import HttpCode from "@server/types/HttpCode";
|
import HttpCode from "@server/types/HttpCode";
|
||||||
@@ -89,36 +91,73 @@ export async function setResourceHeaderAuth(
|
|||||||
const { resourceId } = parsedParams.data;
|
const { resourceId } = parsedParams.data;
|
||||||
const { user, password, extendedCompatibility } = parsedBody.data;
|
const { user, password, extendedCompatibility } = 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 not found")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const isInlinePolicy =
|
||||||
|
resource.resourcePolicyId === null &&
|
||||||
|
resource.defaultResourcePolicyId !== null;
|
||||||
|
|
||||||
await db.transaction(async (trx) => {
|
await db.transaction(async (trx) => {
|
||||||
await trx
|
if (isInlinePolicy) {
|
||||||
.delete(resourceHeaderAuth)
|
const policyId = resource.defaultResourcePolicyId!;
|
||||||
.where(eq(resourceHeaderAuth.resourceId, resourceId));
|
await trx
|
||||||
await trx
|
.delete(resourcePolicyHeaderAuth)
|
||||||
.delete(resourceHeaderAuthExtendedCompatibility)
|
.where(
|
||||||
.where(
|
eq(resourcePolicyHeaderAuth.resourcePolicyId, policyId)
|
||||||
eq(
|
);
|
||||||
resourceHeaderAuthExtendedCompatibility.resourceId,
|
|
||||||
resourceId
|
|
||||||
)
|
|
||||||
);
|
|
||||||
|
|
||||||
if (user && password && extendedCompatibility !== null) {
|
if (user && password && extendedCompatibility !== null) {
|
||||||
const headerAuthHash = await hashPassword(
|
const headerAuthHash = await hashPassword(
|
||||||
Buffer.from(`${user}:${password}`).toString("base64")
|
Buffer.from(`${user}:${password}`).toString("base64")
|
||||||
);
|
);
|
||||||
|
|
||||||
await Promise.all([
|
await trx.insert(resourcePolicyHeaderAuth).values({
|
||||||
trx
|
resourcePolicyId: policyId,
|
||||||
.insert(resourceHeaderAuth)
|
headerAuthHash,
|
||||||
.values({ resourceId, headerAuthHash }),
|
extendedCompatibility: extendedCompatibility!
|
||||||
trx
|
});
|
||||||
.insert(resourceHeaderAuthExtendedCompatibility)
|
}
|
||||||
.values({
|
} else {
|
||||||
resourceId,
|
await trx
|
||||||
extendedCompatibilityIsActivated:
|
.delete(resourceHeaderAuth)
|
||||||
extendedCompatibility
|
.where(eq(resourceHeaderAuth.resourceId, resourceId));
|
||||||
})
|
await trx
|
||||||
]);
|
.delete(resourceHeaderAuthExtendedCompatibility)
|
||||||
|
.where(
|
||||||
|
eq(
|
||||||
|
resourceHeaderAuthExtendedCompatibility.resourceId,
|
||||||
|
resourceId
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
if (user && password && extendedCompatibility !== null) {
|
||||||
|
const headerAuthHash = await hashPassword(
|
||||||
|
Buffer.from(`${user}:${password}`).toString("base64")
|
||||||
|
);
|
||||||
|
|
||||||
|
await Promise.all([
|
||||||
|
trx
|
||||||
|
.insert(resourceHeaderAuth)
|
||||||
|
.values({ resourceId, headerAuthHash }),
|
||||||
|
trx
|
||||||
|
.insert(resourceHeaderAuthExtendedCompatibility)
|
||||||
|
.values({
|
||||||
|
resourceId,
|
||||||
|
extendedCompatibilityIsActivated:
|
||||||
|
extendedCompatibility
|
||||||
|
})
|
||||||
|
]);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,11 @@
|
|||||||
import { Request, Response, NextFunction } from "express";
|
import { Request, Response, NextFunction } from "express";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { db } from "@server/db";
|
import { db } from "@server/db";
|
||||||
import { resourcePassword } from "@server/db";
|
import {
|
||||||
|
resourcePassword,
|
||||||
|
resourcePolicyPassword,
|
||||||
|
resources
|
||||||
|
} from "@server/db";
|
||||||
import { eq } from "drizzle-orm";
|
import { eq } from "drizzle-orm";
|
||||||
import HttpCode from "@server/types/HttpCode";
|
import HttpCode from "@server/types/HttpCode";
|
||||||
import createHttpError from "http-errors";
|
import createHttpError from "http-errors";
|
||||||
@@ -85,17 +89,49 @@ export async function setResourcePassword(
|
|||||||
const { resourceId } = parsedParams.data;
|
const { resourceId } = parsedParams.data;
|
||||||
const { password } = parsedBody.data;
|
const { password } = 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 not found")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const isInlinePolicy =
|
||||||
|
resource.resourcePolicyId === null &&
|
||||||
|
resource.defaultResourcePolicyId !== null;
|
||||||
|
|
||||||
await db.transaction(async (trx) => {
|
await db.transaction(async (trx) => {
|
||||||
await trx
|
if (isInlinePolicy) {
|
||||||
.delete(resourcePassword)
|
const policyId = resource.defaultResourcePolicyId!;
|
||||||
.where(eq(resourcePassword.resourceId, resourceId));
|
|
||||||
|
|
||||||
if (password) {
|
|
||||||
const passwordHash = await hashPassword(password);
|
|
||||||
|
|
||||||
await trx
|
await trx
|
||||||
.insert(resourcePassword)
|
.delete(resourcePolicyPassword)
|
||||||
.values({ resourceId, passwordHash });
|
.where(
|
||||||
|
eq(resourcePolicyPassword.resourcePolicyId, policyId)
|
||||||
|
);
|
||||||
|
|
||||||
|
if (password) {
|
||||||
|
const passwordHash = await hashPassword(password);
|
||||||
|
await trx
|
||||||
|
.insert(resourcePolicyPassword)
|
||||||
|
.values({ resourcePolicyId: policyId, passwordHash });
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
await trx
|
||||||
|
.delete(resourcePassword)
|
||||||
|
.where(eq(resourcePassword.resourceId, resourceId));
|
||||||
|
|
||||||
|
if (password) {
|
||||||
|
const passwordHash = await hashPassword(password);
|
||||||
|
|
||||||
|
await trx
|
||||||
|
.insert(resourcePassword)
|
||||||
|
.values({ resourceId, passwordHash });
|
||||||
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { Request, Response, NextFunction } from "express";
|
import { Request, Response, NextFunction } from "express";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { db } from "@server/db";
|
import { db } from "@server/db";
|
||||||
import { resourcePincode } from "@server/db";
|
import { resourcePincode, resourcePolicyPincode, resources } from "@server/db";
|
||||||
import { eq } from "drizzle-orm";
|
import { eq } from "drizzle-orm";
|
||||||
import HttpCode from "@server/types/HttpCode";
|
import HttpCode from "@server/types/HttpCode";
|
||||||
import createHttpError from "http-errors";
|
import createHttpError from "http-errors";
|
||||||
@@ -89,17 +89,51 @@ export async function setResourcePincode(
|
|||||||
const { resourceId } = parsedParams.data;
|
const { resourceId } = parsedParams.data;
|
||||||
const { pincode } = parsedBody.data;
|
const { pincode } = 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 not found")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const isInlinePolicy =
|
||||||
|
resource.resourcePolicyId === null &&
|
||||||
|
resource.defaultResourcePolicyId !== null;
|
||||||
|
|
||||||
await db.transaction(async (trx) => {
|
await db.transaction(async (trx) => {
|
||||||
await trx
|
if (isInlinePolicy) {
|
||||||
.delete(resourcePincode)
|
const policyId = resource.defaultResourcePolicyId!;
|
||||||
.where(eq(resourcePincode.resourceId, resourceId));
|
|
||||||
|
|
||||||
if (pincode) {
|
|
||||||
const pincodeHash = await hashPassword(pincode);
|
|
||||||
|
|
||||||
await trx
|
await trx
|
||||||
.insert(resourcePincode)
|
.delete(resourcePolicyPincode)
|
||||||
.values({ resourceId, pincodeHash, digitLength: 6 });
|
.where(
|
||||||
|
eq(resourcePolicyPincode.resourcePolicyId, policyId)
|
||||||
|
);
|
||||||
|
|
||||||
|
if (pincode) {
|
||||||
|
const pincodeHash = await hashPassword(pincode);
|
||||||
|
await trx.insert(resourcePolicyPincode).values({
|
||||||
|
resourcePolicyId: policyId,
|
||||||
|
pincodeHash,
|
||||||
|
digitLength: 6
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
await trx
|
||||||
|
.delete(resourcePincode)
|
||||||
|
.where(eq(resourcePincode.resourceId, resourceId));
|
||||||
|
|
||||||
|
if (pincode) {
|
||||||
|
const pincodeHash = await hashPassword(pincode);
|
||||||
|
|
||||||
|
await trx
|
||||||
|
.insert(resourcePincode)
|
||||||
|
.values({ resourceId, pincodeHash, digitLength: 6 });
|
||||||
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { Request, Response, NextFunction } from "express";
|
import { Request, Response, NextFunction } from "express";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { db, resources } from "@server/db";
|
import { db, resources } from "@server/db";
|
||||||
import { apiKeys, roleResources, roles } from "@server/db";
|
import { apiKeys, roleResources, roles, rolePolicies } 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";
|
||||||
@@ -129,28 +129,61 @@ export async function setResourceRoles(
|
|||||||
);
|
);
|
||||||
const adminRoleIds = adminRoles.map((role) => role.roleId);
|
const adminRoleIds = adminRoles.map((role) => role.roleId);
|
||||||
|
|
||||||
|
const isInlinePolicy =
|
||||||
|
resource.resourcePolicyId === null &&
|
||||||
|
resource.defaultResourcePolicyId !== null;
|
||||||
|
|
||||||
await db.transaction(async (trx) => {
|
await db.transaction(async (trx) => {
|
||||||
if (adminRoleIds.length > 0) {
|
if (isInlinePolicy) {
|
||||||
await trx.delete(roleResources).where(
|
const policyId = resource.defaultResourcePolicyId!;
|
||||||
and(
|
|
||||||
eq(roleResources.resourceId, resourceId),
|
// For inline policy, preserve admin roles by only deleting non-admin entries
|
||||||
ne(roleResources.roleId, adminRoleIds[0]) // delete all but the admin role
|
if (adminRoleIds.length > 0) {
|
||||||
|
await trx
|
||||||
|
.delete(rolePolicies)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(rolePolicies.resourcePolicyId, policyId),
|
||||||
|
ne(rolePolicies.roleId, adminRoleIds[0])
|
||||||
|
)
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
await trx
|
||||||
|
.delete(rolePolicies)
|
||||||
|
.where(eq(rolePolicies.resourcePolicyId, policyId));
|
||||||
|
}
|
||||||
|
|
||||||
|
await Promise.all(
|
||||||
|
roleIds.map((roleId) =>
|
||||||
|
trx
|
||||||
|
.insert(rolePolicies)
|
||||||
|
.values({ roleId, resourcePolicyId: policyId })
|
||||||
|
.returning()
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
await trx
|
if (adminRoleIds.length > 0) {
|
||||||
.delete(roleResources)
|
await trx.delete(roleResources).where(
|
||||||
.where(eq(roleResources.resourceId, resourceId));
|
and(
|
||||||
}
|
eq(roleResources.resourceId, resourceId),
|
||||||
|
ne(roleResources.roleId, adminRoleIds[0]) // delete all but the admin role
|
||||||
|
)
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
await trx
|
||||||
|
.delete(roleResources)
|
||||||
|
.where(eq(roleResources.resourceId, resourceId));
|
||||||
|
}
|
||||||
|
|
||||||
const newRoleResources = await Promise.all(
|
await Promise.all(
|
||||||
roleIds.map((roleId) =>
|
roleIds.map((roleId) =>
|
||||||
trx
|
trx
|
||||||
.insert(roleResources)
|
.insert(roleResources)
|
||||||
.values({ roleId, resourceId })
|
.values({ roleId, resourceId })
|
||||||
.returning()
|
.returning()
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return response(res, {
|
return response(res, {
|
||||||
data: {},
|
data: {},
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { Request, Response, NextFunction } from "express";
|
import { Request, Response, NextFunction } from "express";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { db } from "@server/db";
|
import { db } from "@server/db";
|
||||||
import { userResources } from "@server/db";
|
import { userResources, userPolicies, resources } 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";
|
||||||
@@ -82,19 +82,51 @@ export async function setResourceUsers(
|
|||||||
|
|
||||||
const { resourceId } = parsedParams.data;
|
const { resourceId } = parsedParams.data;
|
||||||
|
|
||||||
await db.transaction(async (trx) => {
|
const [resource] = await db
|
||||||
await trx
|
.select()
|
||||||
.delete(userResources)
|
.from(resources)
|
||||||
.where(eq(userResources.resourceId, resourceId));
|
.where(eq(resources.resourceId, resourceId))
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
const newUserResources = await Promise.all(
|
if (!resource) {
|
||||||
userIds.map((userId) =>
|
return next(
|
||||||
trx
|
createHttpError(HttpCode.NOT_FOUND, "Resource not found")
|
||||||
.insert(userResources)
|
|
||||||
.values({ userId, resourceId })
|
|
||||||
.returning()
|
|
||||||
)
|
|
||||||
);
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const isInlinePolicy =
|
||||||
|
resource.resourcePolicyId === null &&
|
||||||
|
resource.defaultResourcePolicyId !== null;
|
||||||
|
|
||||||
|
await db.transaction(async (trx) => {
|
||||||
|
if (isInlinePolicy) {
|
||||||
|
const policyId = resource.defaultResourcePolicyId!;
|
||||||
|
await trx
|
||||||
|
.delete(userPolicies)
|
||||||
|
.where(eq(userPolicies.resourcePolicyId, policyId));
|
||||||
|
|
||||||
|
await Promise.all(
|
||||||
|
userIds.map((userId) =>
|
||||||
|
trx
|
||||||
|
.insert(userPolicies)
|
||||||
|
.values({ userId, resourcePolicyId: policyId })
|
||||||
|
.returning()
|
||||||
|
)
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
await trx
|
||||||
|
.delete(userResources)
|
||||||
|
.where(eq(userResources.resourceId, resourceId));
|
||||||
|
|
||||||
|
await Promise.all(
|
||||||
|
userIds.map((userId) =>
|
||||||
|
trx
|
||||||
|
.insert(userResources)
|
||||||
|
.values({ userId, resourceId })
|
||||||
|
.returning()
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return response(res, {
|
return response(res, {
|
||||||
data: {},
|
data: {},
|
||||||
|
|||||||
@@ -1,7 +1,12 @@
|
|||||||
import { Request, Response, NextFunction } from "express";
|
import { Request, Response, NextFunction } from "express";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { db } from "@server/db";
|
import { db } from "@server/db";
|
||||||
import { resources, resourceWhitelist } from "@server/db";
|
import {
|
||||||
|
resources,
|
||||||
|
resourceWhitelist,
|
||||||
|
resourcePolicies,
|
||||||
|
resourcePolicyWhiteList
|
||||||
|
} 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";
|
||||||
@@ -104,57 +109,135 @@ export async function setResourceWhitelist(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!resource.emailWhitelistEnabled) {
|
const isInlinePolicy =
|
||||||
return next(
|
resource.resourcePolicyId === null &&
|
||||||
createHttpError(
|
resource.defaultResourcePolicyId !== null;
|
||||||
HttpCode.BAD_REQUEST,
|
|
||||||
"Email whitelist is not enabled for this resource"
|
|
||||||
)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const whitelist = await db
|
if (isInlinePolicy) {
|
||||||
.select()
|
const policyId = resource.defaultResourcePolicyId!;
|
||||||
.from(resourceWhitelist)
|
|
||||||
.where(eq(resourceWhitelist.resourceId, resourceId));
|
|
||||||
|
|
||||||
await db.transaction(async (trx) => {
|
const [policy] = await db
|
||||||
// diff the emails
|
.select()
|
||||||
const existingEmails = whitelist.map((w) => w.email);
|
.from(resourcePolicies)
|
||||||
|
.where(eq(resourcePolicies.resourcePolicyId, policyId));
|
||||||
|
|
||||||
const emailsToAdd = emails.filter(
|
if (!policy) {
|
||||||
(e) => !existingEmails.includes(e)
|
return next(
|
||||||
);
|
createHttpError(
|
||||||
const emailsToRemove = existingEmails.filter(
|
HttpCode.NOT_FOUND,
|
||||||
(e) => !emails.includes(e)
|
"Resource policy not found"
|
||||||
);
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
for (const email of emailsToAdd) {
|
if (!policy.emailWhitelistEnabled) {
|
||||||
await trx.insert(resourceWhitelist).values({
|
return next(
|
||||||
email,
|
createHttpError(
|
||||||
resourceId
|
HttpCode.BAD_REQUEST,
|
||||||
|
"Email whitelist is not enabled for this resource"
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const existingPolicyWhitelist = await db
|
||||||
|
.select()
|
||||||
|
.from(resourcePolicyWhiteList)
|
||||||
|
.where(eq(resourcePolicyWhiteList.resourcePolicyId, policyId));
|
||||||
|
|
||||||
|
await db.transaction(async (trx) => {
|
||||||
|
const existingEmails = existingPolicyWhitelist.map(
|
||||||
|
(w) => w.email
|
||||||
|
);
|
||||||
|
|
||||||
|
const emailsToAdd = emails.filter(
|
||||||
|
(e) => !existingEmails.includes(e)
|
||||||
|
);
|
||||||
|
const emailsToRemove = existingEmails.filter(
|
||||||
|
(e) => !emails.includes(e)
|
||||||
|
);
|
||||||
|
|
||||||
|
for (const email of emailsToAdd) {
|
||||||
|
await trx.insert(resourcePolicyWhiteList).values({
|
||||||
|
email,
|
||||||
|
resourcePolicyId: policyId
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const email of emailsToRemove) {
|
||||||
|
await trx
|
||||||
|
.delete(resourcePolicyWhiteList)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(
|
||||||
|
resourcePolicyWhiteList.resourcePolicyId,
|
||||||
|
policyId
|
||||||
|
),
|
||||||
|
eq(resourcePolicyWhiteList.email, email)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return response(res, {
|
||||||
|
data: {},
|
||||||
|
success: true,
|
||||||
|
error: false,
|
||||||
|
message: "Whitelist set for resource successfully",
|
||||||
|
status: HttpCode.CREATED
|
||||||
});
|
});
|
||||||
}
|
|
||||||
|
|
||||||
for (const email of emailsToRemove) {
|
|
||||||
await trx
|
|
||||||
.delete(resourceWhitelist)
|
|
||||||
.where(
|
|
||||||
and(
|
|
||||||
eq(resourceWhitelist.resourceId, resourceId),
|
|
||||||
eq(resourceWhitelist.email, email)
|
|
||||||
)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return response(res, {
|
|
||||||
data: {},
|
|
||||||
success: true,
|
|
||||||
error: false,
|
|
||||||
message: "Whitelist set for resource successfully",
|
|
||||||
status: HttpCode.CREATED
|
|
||||||
});
|
});
|
||||||
});
|
} else {
|
||||||
|
if (!resource.emailWhitelistEnabled) {
|
||||||
|
return next(
|
||||||
|
createHttpError(
|
||||||
|
HttpCode.BAD_REQUEST,
|
||||||
|
"Email whitelist is not enabled for this resource"
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const whitelist = await db
|
||||||
|
.select()
|
||||||
|
.from(resourceWhitelist)
|
||||||
|
.where(eq(resourceWhitelist.resourceId, resourceId));
|
||||||
|
|
||||||
|
await db.transaction(async (trx) => {
|
||||||
|
// diff the emails
|
||||||
|
const existingEmails = whitelist.map((w) => w.email);
|
||||||
|
|
||||||
|
const emailsToAdd = emails.filter(
|
||||||
|
(e) => !existingEmails.includes(e)
|
||||||
|
);
|
||||||
|
const emailsToRemove = existingEmails.filter(
|
||||||
|
(e) => !emails.includes(e)
|
||||||
|
);
|
||||||
|
|
||||||
|
for (const email of emailsToAdd) {
|
||||||
|
await trx.insert(resourceWhitelist).values({
|
||||||
|
email,
|
||||||
|
resourceId
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const email of emailsToRemove) {
|
||||||
|
await trx
|
||||||
|
.delete(resourceWhitelist)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(resourceWhitelist.resourceId, resourceId),
|
||||||
|
eq(resourceWhitelist.email, email)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return response(res, {
|
||||||
|
data: {},
|
||||||
|
success: true,
|
||||||
|
error: false,
|
||||||
|
message: "Whitelist set for resource successfully",
|
||||||
|
status: HttpCode.CREATED
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error(error);
|
logger.error(error);
|
||||||
return next(
|
return next(
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ export type GetMaintenanceInfoResponse = {
|
|||||||
|
|
||||||
export type AttachedResource = Pick<
|
export type AttachedResource = Pick<
|
||||||
Resource,
|
Resource,
|
||||||
"resourceId" | "name" | "fullDomain"
|
"resourceId" | "niceId" | "name" | "fullDomain"
|
||||||
>;
|
>;
|
||||||
|
|
||||||
export type ResourcePolicyWithResources = Pick<
|
export type ResourcePolicyWithResources = Pick<
|
||||||
|
|||||||
@@ -549,6 +549,58 @@ async function updateHttpResource(
|
|||||||
updateData.maintenanceEstimatedTime = undefined;
|
updateData.maintenanceEstimatedTime = undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const isInlinePolicy =
|
||||||
|
resource.resourcePolicyId === null &&
|
||||||
|
resource.defaultResourcePolicyId !== null;
|
||||||
|
|
||||||
|
if (isInlinePolicy) {
|
||||||
|
const policyId = resource.defaultResourcePolicyId!;
|
||||||
|
const {
|
||||||
|
sso,
|
||||||
|
emailWhitelistEnabled,
|
||||||
|
applyRules,
|
||||||
|
skipToIdpId,
|
||||||
|
...resourceOnlyData
|
||||||
|
} = updateData;
|
||||||
|
|
||||||
|
const policyUpdate: Record<string, unknown> = {};
|
||||||
|
if (sso !== undefined) policyUpdate.sso = sso;
|
||||||
|
if (emailWhitelistEnabled !== undefined)
|
||||||
|
policyUpdate.emailWhitelistEnabled = emailWhitelistEnabled;
|
||||||
|
if (applyRules !== undefined) policyUpdate.applyRules = applyRules;
|
||||||
|
if (skipToIdpId !== undefined) policyUpdate.idpId = skipToIdpId;
|
||||||
|
|
||||||
|
if (Object.keys(policyUpdate).length > 0) {
|
||||||
|
await db
|
||||||
|
.update(resourcePolicies)
|
||||||
|
.set(policyUpdate)
|
||||||
|
.where(eq(resourcePolicies.resourcePolicyId, policyId));
|
||||||
|
}
|
||||||
|
|
||||||
|
const updatedResource = await db
|
||||||
|
.update(resources)
|
||||||
|
.set({ ...resourceOnlyData, headers })
|
||||||
|
.where(eq(resources.resourceId, resource.resourceId))
|
||||||
|
.returning();
|
||||||
|
|
||||||
|
if (updatedResource.length === 0) {
|
||||||
|
return next(
|
||||||
|
createHttpError(
|
||||||
|
HttpCode.NOT_FOUND,
|
||||||
|
`Resource with ID ${resource.resourceId} not found`
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return response(res, {
|
||||||
|
data: updatedResource[0],
|
||||||
|
success: true,
|
||||||
|
error: false,
|
||||||
|
message: "HTTP resource updated successfully",
|
||||||
|
status: HttpCode.OK
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
const updatedResource = await db
|
const updatedResource = await db
|
||||||
.update(resources)
|
.update(resources)
|
||||||
.set({ ...updateData, headers })
|
.set({ ...updateData, headers })
|
||||||
|
|||||||
@@ -93,10 +93,9 @@ export async function deleteSite(
|
|||||||
// Clean up all client associations and send peer/proxy removal
|
// Clean up all client associations and send peer/proxy removal
|
||||||
// messages in a single efficient pass before deleting the row.
|
// messages in a single efficient pass before deleting the row.
|
||||||
await cleanupSiteAssociations(site, trx);
|
await cleanupSiteAssociations(site, trx);
|
||||||
|
|
||||||
await trx.delete(sites).where(eq(sites.siteId, siteId));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
await trx.delete(sites).where(eq(sites.siteId, siteId));
|
||||||
await usageService.add(site.orgId, FeatureId.SITES, -1, trx);
|
await usageService.add(site.orgId, FeatureId.SITES, -1, trx);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -12,7 +12,6 @@ import {
|
|||||||
userSites,
|
userSites,
|
||||||
labels,
|
labels,
|
||||||
siteLabels,
|
siteLabels,
|
||||||
browserGatewayTarget,
|
|
||||||
type Label
|
type Label
|
||||||
} from "@server/db";
|
} from "@server/db";
|
||||||
import cache from "#dynamic/lib/cache";
|
import cache from "#dynamic/lib/cache";
|
||||||
@@ -241,10 +240,6 @@ function querySitesBase() {
|
|||||||
ON ${siteResources.networkId} = ${siteNetworks.networkId}
|
ON ${siteResources.networkId} = ${siteNetworks.networkId}
|
||||||
WHERE ${siteNetworks.siteId} = ${sites.siteId}
|
WHERE ${siteNetworks.siteId} = ${sites.siteId}
|
||||||
AND ${siteResources.orgId} = ${sites.orgId}
|
AND ${siteResources.orgId} = ${sites.orgId}
|
||||||
) + (
|
|
||||||
SELECT COUNT(DISTINCT ${browserGatewayTarget.resourceId})
|
|
||||||
FROM ${browserGatewayTarget}
|
|
||||||
WHERE ${browserGatewayTarget.siteId} = ${sites.siteId}
|
|
||||||
)`,
|
)`,
|
||||||
status: sites.status
|
status: sites.status
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -142,6 +142,7 @@ const createSiteResourceSchema = z
|
|||||||
data.destinationPort <= 65535)
|
data.destinationPort <= 65535)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
return true;
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
message:
|
message:
|
||||||
|
|||||||
@@ -24,6 +24,10 @@ import {
|
|||||||
fireHealthCheckUnhealthyAlert,
|
fireHealthCheckUnhealthyAlert,
|
||||||
fireHealthCheckUnknownAlert
|
fireHealthCheckUnknownAlert
|
||||||
} from "@server/lib/alerts";
|
} from "@server/lib/alerts";
|
||||||
|
import { encrypt } from "@server/lib/crypto";
|
||||||
|
import { generateId } from "@server/auth/sessions/app";
|
||||||
|
import config from "@server/lib/config";
|
||||||
|
import { sendBrowserGatewayTargets } from "@server/routers/newt/targets";
|
||||||
|
|
||||||
const createTargetParamsSchema = z.strictObject({
|
const createTargetParamsSchema = z.strictObject({
|
||||||
resourceId: z.coerce.number().int().positive()
|
resourceId: z.coerce.number().int().positive()
|
||||||
@@ -32,6 +36,7 @@ const createTargetParamsSchema = z.strictObject({
|
|||||||
const createTargetSchema = z.strictObject({
|
const createTargetSchema = z.strictObject({
|
||||||
siteId: z.int().positive(),
|
siteId: z.int().positive(),
|
||||||
ip: z.string().refine(isTargetValid),
|
ip: z.string().refine(isTargetValid),
|
||||||
|
mode: z.enum(["http", "tcp", "udp", "ssh", "rdp", "vnc"]).optional(),
|
||||||
method: z.string().optional().nullable(),
|
method: z.string().optional().nullable(),
|
||||||
port: z.int().min(1).max(65535),
|
port: z.int().min(1).max(65535),
|
||||||
enabled: z.boolean().default(true),
|
enabled: z.boolean().default(true),
|
||||||
@@ -161,6 +166,12 @@ export async function createTarget(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const plainToken = generateId(48);
|
||||||
|
const encryptedToken = encrypt(
|
||||||
|
plainToken,
|
||||||
|
config.getRawConfig().server.secret!
|
||||||
|
);
|
||||||
|
|
||||||
let newTarget: Target[] = [];
|
let newTarget: Target[] = [];
|
||||||
let targetIps: string[] = [];
|
let targetIps: string[] = [];
|
||||||
let healthCheck: TargetHealthCheck[] = [];
|
let healthCheck: TargetHealthCheck[] = [];
|
||||||
@@ -191,6 +202,9 @@ export async function createTarget(
|
|||||||
.values({
|
.values({
|
||||||
resourceId,
|
resourceId,
|
||||||
...targetData,
|
...targetData,
|
||||||
|
mode: (targetData.mode ??
|
||||||
|
resource.mode ??
|
||||||
|
"http") as Target["mode"],
|
||||||
priority: targetData.priority || 100
|
priority: targetData.priority || 100
|
||||||
})
|
})
|
||||||
.returning();
|
.returning();
|
||||||
@@ -226,6 +240,10 @@ export async function createTarget(
|
|||||||
resourceId,
|
resourceId,
|
||||||
siteId: site.siteId,
|
siteId: site.siteId,
|
||||||
ip: targetData.ip,
|
ip: targetData.ip,
|
||||||
|
mode: (targetData.mode ??
|
||||||
|
resource.mode ??
|
||||||
|
"http") as Target["mode"],
|
||||||
|
authToken: encryptedToken,
|
||||||
method: targetData.method,
|
method: targetData.method,
|
||||||
port: targetData.port,
|
port: targetData.port,
|
||||||
internalPort,
|
internalPort,
|
||||||
@@ -325,13 +343,21 @@ export async function createTarget(
|
|||||||
.where(eq(newts.siteId, site.siteId))
|
.where(eq(newts.siteId, site.siteId))
|
||||||
.limit(1);
|
.limit(1);
|
||||||
|
|
||||||
await addTargets(
|
if (["http", "tcp", "udp"].includes(newTarget[0].mode)) {
|
||||||
newt.newtId,
|
await addTargets(
|
||||||
newTarget,
|
newt.newtId,
|
||||||
healthCheck,
|
newTarget,
|
||||||
resource.mode === "udp" ? "udp" : "tcp",
|
healthCheck,
|
||||||
newt.version
|
resource.mode === "udp" ? "udp" : "tcp",
|
||||||
);
|
newt.version
|
||||||
|
);
|
||||||
|
} else if (["ssh", "rdp", "vnc"].includes(newTarget[0].mode)) {
|
||||||
|
await sendBrowserGatewayTargets(
|
||||||
|
newt.newtId,
|
||||||
|
newTarget,
|
||||||
|
newt.version
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import { fromError } from "zod-validation-error";
|
|||||||
import { removeTargets } from "../newt/targets";
|
import { removeTargets } from "../newt/targets";
|
||||||
import { OpenAPITags, registry } from "@server/openApi";
|
import { OpenAPITags, registry } from "@server/openApi";
|
||||||
import { targetHealthCheck } from "@server/db";
|
import { targetHealthCheck } from "@server/db";
|
||||||
|
import { removeBrowserGatewayTarget } from "@server/routers/newt/targets";
|
||||||
|
|
||||||
const deleteTargetSchema = z.strictObject({
|
const deleteTargetSchema = z.strictObject({
|
||||||
targetId: z.coerce.number().int().positive()
|
targetId: z.coerce.number().int().positive()
|
||||||
@@ -136,14 +137,22 @@ export async function deleteTarget(
|
|||||||
.where(eq(newts.siteId, site.siteId))
|
.where(eq(newts.siteId, site.siteId))
|
||||||
.limit(1);
|
.limit(1);
|
||||||
|
|
||||||
await removeTargets(
|
if (["http", "tcp", "udp"].includes(deletedTarget.mode)) {
|
||||||
newt.newtId,
|
await removeTargets(
|
||||||
// [deletedTarget],
|
newt.newtId,
|
||||||
[], // deleting the target from newt causes issues because we cant unbind the port. this needs to be fixed in newt before we can do this
|
// [deletedTarget],
|
||||||
[deletedHealthCheck],
|
[], // deleting the target from newt causes issues because we cant unbind the port. this needs to be fixed in newt before we can do this
|
||||||
resource.mode === "udp" ? "udp" : "tcp",
|
[deletedHealthCheck],
|
||||||
newt.version
|
resource.mode === "udp" ? "udp" : "tcp",
|
||||||
);
|
newt.version
|
||||||
|
);
|
||||||
|
} else if (["ssh", "rdp", "vnc"].includes(deletedTarget.mode)) {
|
||||||
|
await removeBrowserGatewayTarget(
|
||||||
|
newt.newtId,
|
||||||
|
deletedTarget.targetId,
|
||||||
|
newt.version
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -34,6 +34,7 @@ function queryTargets(resourceId: number) {
|
|||||||
.select({
|
.select({
|
||||||
targetId: targets.targetId,
|
targetId: targets.targetId,
|
||||||
ip: targets.ip,
|
ip: targets.ip,
|
||||||
|
mode: targets.mode,
|
||||||
method: targets.method,
|
method: targets.method,
|
||||||
port: targets.port,
|
port: targets.port,
|
||||||
enabled: targets.enabled,
|
enabled: targets.enabled,
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ import {
|
|||||||
import { pickPort } from "./helpers";
|
import { pickPort } from "./helpers";
|
||||||
import { isTargetValid } from "@server/lib/validators";
|
import { isTargetValid } from "@server/lib/validators";
|
||||||
import { OpenAPITags, registry } from "@server/openApi";
|
import { OpenAPITags, registry } from "@server/openApi";
|
||||||
|
import { sendBrowserGatewayTargets } from "@server/routers/newt/targets";
|
||||||
|
|
||||||
const updateTargetParamsSchema = z.strictObject({
|
const updateTargetParamsSchema = z.strictObject({
|
||||||
targetId: z.coerce.number().int().positive()
|
targetId: z.coerce.number().int().positive()
|
||||||
@@ -27,6 +28,10 @@ const updateTargetBodySchema = z
|
|||||||
.strictObject({
|
.strictObject({
|
||||||
siteId: z.int().positive(),
|
siteId: z.int().positive(),
|
||||||
ip: z.string().refine(isTargetValid),
|
ip: z.string().refine(isTargetValid),
|
||||||
|
mode: z
|
||||||
|
.enum(["http", "tcp", "udp", "ssh", "rdp", "vnc"])
|
||||||
|
.optional()
|
||||||
|
.nullable(),
|
||||||
method: z.string().min(1).max(10).optional().nullable(),
|
method: z.string().min(1).max(10).optional().nullable(),
|
||||||
port: z.int().min(1).max(65535).optional(),
|
port: z.int().min(1).max(65535).optional(),
|
||||||
enabled: z.boolean().optional(),
|
enabled: z.boolean().optional(),
|
||||||
@@ -184,6 +189,8 @@ export async function updateTarget(
|
|||||||
}
|
}
|
||||||
|
|
||||||
const pathMatchTypeRemoved = parsedBody.data.pathMatchType === null;
|
const pathMatchTypeRemoved = parsedBody.data.pathMatchType === null;
|
||||||
|
const nextMode =
|
||||||
|
parsedBody.data.mode === null ? undefined : parsedBody.data.mode;
|
||||||
|
|
||||||
let updatedTarget: any;
|
let updatedTarget: any;
|
||||||
let updatedHc: any;
|
let updatedHc: any;
|
||||||
@@ -193,6 +200,7 @@ export async function updateTarget(
|
|||||||
.set({
|
.set({
|
||||||
siteId: parsedBody.data.siteId,
|
siteId: parsedBody.data.siteId,
|
||||||
ip: parsedBody.data.ip,
|
ip: parsedBody.data.ip,
|
||||||
|
mode: nextMode,
|
||||||
method: parsedBody.data.method,
|
method: parsedBody.data.method,
|
||||||
port: parsedBody.data.port,
|
port: parsedBody.data.port,
|
||||||
internalPort,
|
internalPort,
|
||||||
@@ -343,13 +351,21 @@ export async function updateTarget(
|
|||||||
.where(eq(newts.siteId, site.siteId))
|
.where(eq(newts.siteId, site.siteId))
|
||||||
.limit(1);
|
.limit(1);
|
||||||
|
|
||||||
await addTargets(
|
if (["http", "tcp", "udp"].includes(updatedTarget.mode)) {
|
||||||
newt.newtId,
|
await addTargets(
|
||||||
[updatedTarget],
|
newt.newtId,
|
||||||
[updatedHc],
|
[updatedTarget],
|
||||||
resource.mode === "udp" ? "udp" : "tcp",
|
[updatedHc],
|
||||||
newt.version
|
resource.mode === "udp" ? "udp" : "tcp",
|
||||||
);
|
newt.version
|
||||||
|
);
|
||||||
|
} else if (["ssh", "rdp", "vnc"].includes(updatedTarget.mode)) {
|
||||||
|
await sendBrowserGatewayTargets(
|
||||||
|
newt.newtId,
|
||||||
|
[updatedTarget],
|
||||||
|
newt.version
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -39,18 +39,6 @@ export default async function migration() {
|
|||||||
try {
|
try {
|
||||||
await db.execute(sql`BEGIN`);
|
await db.execute(sql`BEGIN`);
|
||||||
|
|
||||||
await db.execute(sql`
|
|
||||||
CREATE TABLE "browserGatewayTarget" (
|
|
||||||
"browserGatewayTargetId" serial PRIMARY KEY NOT NULL,
|
|
||||||
"resourceId" integer NOT NULL,
|
|
||||||
"siteId" integer NOT NULL,
|
|
||||||
"authToken" varchar NOT NULL,
|
|
||||||
"type" varchar NOT NULL,
|
|
||||||
"destination" varchar NOT NULL,
|
|
||||||
"destinationPort" integer NOT NULL
|
|
||||||
);
|
|
||||||
`);
|
|
||||||
|
|
||||||
await db.execute(sql`
|
await db.execute(sql`
|
||||||
CREATE TABLE "clientLabels" (
|
CREATE TABLE "clientLabels" (
|
||||||
"clientLabelId" serial PRIMARY KEY NOT NULL,
|
"clientLabelId" serial PRIMARY KEY NOT NULL,
|
||||||
@@ -209,18 +197,17 @@ export default async function migration() {
|
|||||||
await db.execute(
|
await db.execute(
|
||||||
sql`ALTER TABLE "siteResources" ADD COLUMN "pamMode" varchar(32) DEFAULT 'passthrough';`
|
sql`ALTER TABLE "siteResources" ADD COLUMN "pamMode" varchar(32) DEFAULT 'passthrough';`
|
||||||
);
|
);
|
||||||
|
await db.execute(sql`
|
||||||
|
UPDATE "siteResources"
|
||||||
|
SET "pamMode" = 'push'
|
||||||
|
WHERE LOWER(COALESCE("mode", '')) = 'host';
|
||||||
|
`);
|
||||||
await db.execute(
|
await db.execute(
|
||||||
sql`ALTER TABLE "sites" ADD COLUMN "autoUpdateEnabled" boolean DEFAULT false NOT NULL;`
|
sql`ALTER TABLE "sites" ADD COLUMN "autoUpdateEnabled" boolean DEFAULT false NOT NULL;`
|
||||||
);
|
);
|
||||||
await db.execute(
|
await db.execute(
|
||||||
sql`ALTER TABLE "sites" ADD COLUMN "autoUpdateOverrideOrg" boolean DEFAULT false NOT NULL;`
|
sql`ALTER TABLE "sites" ADD COLUMN "autoUpdateOverrideOrg" boolean DEFAULT false NOT NULL;`
|
||||||
);
|
);
|
||||||
await db.execute(
|
|
||||||
sql`ALTER TABLE "browserGatewayTarget" ADD CONSTRAINT "browserGatewayTarget_resourceId_resources_resourceId_fk" FOREIGN KEY ("resourceId") REFERENCES "public"."resources"("resourceId") ON DELETE cascade ON UPDATE no action;`
|
|
||||||
);
|
|
||||||
await db.execute(
|
|
||||||
sql`ALTER TABLE "browserGatewayTarget" ADD CONSTRAINT "browserGatewayTarget_siteId_sites_siteId_fk" FOREIGN KEY ("siteId") REFERENCES "public"."sites"("siteId") ON DELETE cascade ON UPDATE no action;`
|
|
||||||
);
|
|
||||||
await db.execute(
|
await db.execute(
|
||||||
sql`ALTER TABLE "clientLabels" ADD CONSTRAINT "clientLabels_clientId_clients_clientId_fk" FOREIGN KEY ("clientId") REFERENCES "public"."clients"("clientId") ON DELETE cascade ON UPDATE no action;`
|
sql`ALTER TABLE "clientLabels" ADD CONSTRAINT "clientLabels_clientId_clients_clientId_fk" FOREIGN KEY ("clientId") REFERENCES "public"."clients"("clientId") ON DELETE cascade ON UPDATE no action;`
|
||||||
);
|
);
|
||||||
@@ -289,6 +276,16 @@ export default async function migration() {
|
|||||||
);
|
);
|
||||||
await db.execute(sql`ALTER TABLE "resources" DROP COLUMN "http";`);
|
await db.execute(sql`ALTER TABLE "resources" DROP COLUMN "http";`);
|
||||||
await db.execute(sql`ALTER TABLE "resources" DROP COLUMN "protocol";`);
|
await db.execute(sql`ALTER TABLE "resources" DROP COLUMN "protocol";`);
|
||||||
|
await db.execute(
|
||||||
|
sql`ALTER TABLE "targets" ADD "mode" text DEFAULT 'http' NOT NULL;`
|
||||||
|
);
|
||||||
|
await db.execute(sql`
|
||||||
|
UPDATE "targets"
|
||||||
|
SET "mode" = "resources"."mode"
|
||||||
|
FROM "resources"
|
||||||
|
WHERE "resources"."resourceId" = "targets"."resourceId";
|
||||||
|
`);
|
||||||
|
await db.execute(sql`ALTER TABLE "targets" ADD "authToken" text;`);
|
||||||
|
|
||||||
await db.execute(sql`COMMIT`);
|
await db.execute(sql`COMMIT`);
|
||||||
console.log("Migrated database");
|
console.log("Migrated database");
|
||||||
|
|||||||
@@ -40,22 +40,6 @@ export default async function migration() {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
db.transaction(() => {
|
db.transaction(() => {
|
||||||
db.prepare(
|
|
||||||
`
|
|
||||||
CREATE TABLE 'browserGatewayTarget' (
|
|
||||||
'browserGatewayTargetId' integer PRIMARY KEY AUTOINCREMENT NOT NULL,
|
|
||||||
'resourceId' integer NOT NULL,
|
|
||||||
'siteId' integer NOT NULL,
|
|
||||||
'authToken' text NOT NULL,
|
|
||||||
'type' text NOT NULL,
|
|
||||||
'destination' text NOT NULL,
|
|
||||||
'destinationPort' integer NOT NULL,
|
|
||||||
FOREIGN KEY ('resourceId') REFERENCES 'resources'('resourceId') ON UPDATE no action ON DELETE cascade,
|
|
||||||
FOREIGN KEY ('siteId') REFERENCES 'sites'('siteId') ON UPDATE no action ON DELETE cascade
|
|
||||||
);
|
|
||||||
`
|
|
||||||
).run();
|
|
||||||
|
|
||||||
db.prepare(
|
db.prepare(
|
||||||
`
|
`
|
||||||
CREATE TABLE 'clientLabels' (
|
CREATE TABLE 'clientLabels' (
|
||||||
@@ -263,6 +247,13 @@ export default async function migration() {
|
|||||||
ALTER TABLE 'siteResources' ADD COLUMN 'pamMode' text DEFAULT 'passthrough';
|
ALTER TABLE 'siteResources' ADD COLUMN 'pamMode' text DEFAULT 'passthrough';
|
||||||
`
|
`
|
||||||
).run();
|
).run();
|
||||||
|
db.prepare(
|
||||||
|
`
|
||||||
|
UPDATE 'siteResources'
|
||||||
|
SET "pamMode" = 'push'
|
||||||
|
WHERE LOWER(COALESCE("mode", '')) = 'host';
|
||||||
|
`
|
||||||
|
).run();
|
||||||
db.prepare(
|
db.prepare(
|
||||||
`
|
`
|
||||||
|
|
||||||
@@ -350,6 +341,25 @@ export default async function migration() {
|
|||||||
ALTER TABLE 'resourceSessions' ADD 'policyWhitelistId' integer REFERENCES resourcePolicyWhitelist(id);
|
ALTER TABLE 'resourceSessions' ADD 'policyWhitelistId' integer REFERENCES resourcePolicyWhitelist(id);
|
||||||
`
|
`
|
||||||
).run();
|
).run();
|
||||||
|
db.prepare(
|
||||||
|
`
|
||||||
|
ALTER TABLE 'targets' ADD 'mode' text DEFAULT 'http' NOT NULL;
|
||||||
|
`
|
||||||
|
).run();
|
||||||
|
db.prepare(
|
||||||
|
`
|
||||||
|
UPDATE 'targets'
|
||||||
|
SET 'mode' = (
|
||||||
|
SELECT 'mode' FROM 'resources'
|
||||||
|
WHERE 'resources'.'resourceId' = 'targets'.'resourceId'
|
||||||
|
);
|
||||||
|
`
|
||||||
|
).run();
|
||||||
|
db.prepare(
|
||||||
|
`
|
||||||
|
ALTER TABLE 'targets' ADD 'authToken' text;
|
||||||
|
`
|
||||||
|
).run();
|
||||||
})();
|
})();
|
||||||
|
|
||||||
const existingResources = db
|
const existingResources = db
|
||||||
|
|||||||
@@ -10,6 +10,8 @@ import { formatAxiosError } from "@app/lib/api";
|
|||||||
import { AxiosResponse } from "axios";
|
import { AxiosResponse } from "axios";
|
||||||
import {
|
import {
|
||||||
SettingsContainer,
|
SettingsContainer,
|
||||||
|
SettingsFormCell,
|
||||||
|
SettingsFormGrid,
|
||||||
SettingsSection,
|
SettingsSection,
|
||||||
SettingsSectionHeader,
|
SettingsSectionHeader,
|
||||||
SettingsSectionTitle,
|
SettingsSectionTitle,
|
||||||
@@ -1324,42 +1326,44 @@ export default function BillingPage() {
|
|||||||
</SettingsSectionDescription>
|
</SettingsSectionDescription>
|
||||||
</SettingsSectionHeader>
|
</SettingsSectionHeader>
|
||||||
<SettingsSectionBody>
|
<SettingsSectionBody>
|
||||||
<div className="w-full md:w-1/2">
|
<SettingsFormGrid>
|
||||||
<div className="flex flex-col md:flex-row items-start md:items-center justify-between gap-4 border rounded-lg p-4">
|
<SettingsFormCell span="half">
|
||||||
<div>
|
<div className="flex flex-col md:flex-row items-start md:items-center justify-between gap-4 border rounded-lg p-4">
|
||||||
<div className="text-sm text-muted-foreground mb-1">
|
<div>
|
||||||
{t("billingCurrentKeys") ||
|
<div className="text-sm text-muted-foreground mb-1">
|
||||||
"Current Keys"}
|
{t("billingCurrentKeys") ||
|
||||||
</div>
|
"Current Keys"}
|
||||||
<div className="flex items-baseline gap-2">
|
</div>
|
||||||
<span className="text-3xl font-semibold">
|
<div className="flex items-baseline gap-2">
|
||||||
{getLicenseKeyCount()}
|
<span className="text-3xl font-semibold">
|
||||||
</span>
|
{getLicenseKeyCount()}
|
||||||
<span className="text-lg">
|
</span>
|
||||||
{getLicenseKeyCount() === 1
|
<span className="text-lg">
|
||||||
? "key"
|
{getLicenseKeyCount() === 1
|
||||||
: "keys"}
|
? "key"
|
||||||
</span>
|
: "keys"}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onClick={handleModifySubscription}
|
||||||
|
disabled={isLoading}
|
||||||
|
loading={isLoading}
|
||||||
|
>
|
||||||
|
<CreditCard className="mr-2 h-4 w-4" />
|
||||||
|
{t("billingModifyCurrentPlan") ||
|
||||||
|
"Modify Current Plan"}
|
||||||
|
</Button>
|
||||||
|
<p className="text-sm text-muted-foreground mt-2">
|
||||||
|
{t(
|
||||||
|
"billingManageLicenseSubscriptionDescription"
|
||||||
|
) ||
|
||||||
|
"Manage your subscription for paid self-hosted license keys and download invoices."}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<Button
|
</SettingsFormCell>
|
||||||
variant="outline"
|
</SettingsFormGrid>
|
||||||
onClick={handleModifySubscription}
|
|
||||||
disabled={isLoading}
|
|
||||||
loading={isLoading}
|
|
||||||
>
|
|
||||||
<CreditCard className="mr-2 h-4 w-4" />
|
|
||||||
{t("billingModifyCurrentPlan") ||
|
|
||||||
"Modify Current Plan"}
|
|
||||||
</Button>
|
|
||||||
<p className="text-sm text-muted-foreground mt-2">
|
|
||||||
{t(
|
|
||||||
"billingManageLicenseSubscriptionDescription"
|
|
||||||
) ||
|
|
||||||
"Manage your subscription for paid self-hosted license keys and download invoices."}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</SettingsSectionBody>
|
</SettingsSectionBody>
|
||||||
</SettingsSection>
|
</SettingsSection>
|
||||||
)}
|
)}
|
||||||
|
|||||||
+7
@@ -0,0 +1,7 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { EditPolicyForm } from "@app/components/resource-policy/EditPolicyForm";
|
||||||
|
|
||||||
|
export default function EditPolicyAuthenticationPage() {
|
||||||
|
return <EditPolicyForm section="authentication" />;
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { EditPolicyForm } from "@app/components/resource-policy/EditPolicyForm";
|
||||||
|
|
||||||
|
export default function EditPolicyGeneralPage() {
|
||||||
|
return <EditPolicyForm section="general" />;
|
||||||
|
}
|
||||||
+33
-8
@@ -1,5 +1,5 @@
|
|||||||
import { EditPolicyForm } from "@app/components/resource-policy/EditPolicyForm";
|
|
||||||
import SettingsSectionTitle from "@app/components/SettingsSectionTitle";
|
import SettingsSectionTitle from "@app/components/SettingsSectionTitle";
|
||||||
|
import { HorizontalTabs } from "@app/components/HorizontalTabs";
|
||||||
import { Button } from "@app/components/ui/button";
|
import { Button } from "@app/components/ui/button";
|
||||||
import { internal } from "@app/lib/api";
|
import { internal } from "@app/lib/api";
|
||||||
import { authCookieHeader } from "@app/lib/api/cookies";
|
import { authCookieHeader } from "@app/lib/api/cookies";
|
||||||
@@ -9,12 +9,20 @@ import type { AxiosResponse } from "axios";
|
|||||||
import { getTranslations } from "next-intl/server";
|
import { getTranslations } from "next-intl/server";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { redirect } from "next/navigation";
|
import { redirect } from "next/navigation";
|
||||||
|
import type { Metadata } from "next";
|
||||||
|
|
||||||
export interface EditPolicyPageProps {
|
export const metadata: Metadata = {
|
||||||
|
title: "Resource Policy"
|
||||||
|
};
|
||||||
|
|
||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
|
type EditPolicyLayoutProps = {
|
||||||
|
children: React.ReactNode;
|
||||||
params: Promise<{ niceId: string; orgId: string }>;
|
params: Promise<{ niceId: string; orgId: string }>;
|
||||||
}
|
};
|
||||||
|
|
||||||
export default async function EditPolicyPage(props: EditPolicyPageProps) {
|
export default async function EditPolicyLayout(props: EditPolicyLayoutProps) {
|
||||||
const params = await props.params;
|
const params = await props.params;
|
||||||
const t = await getTranslations();
|
const t = await getTranslations();
|
||||||
|
|
||||||
@@ -28,13 +36,28 @@ export default async function EditPolicyPage(props: EditPolicyPageProps) {
|
|||||||
);
|
);
|
||||||
policyResponse = res.data.data;
|
policyResponse = res.data.data;
|
||||||
} catch {
|
} catch {
|
||||||
redirect(`/${params.orgId}/settings/policies/resource`);
|
redirect(`/${params.orgId}/settings/policies/resources/public`);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!policyResponse) {
|
if (!policyResponse) {
|
||||||
redirect(`/${params.orgId}/settings/policies/resource`);
|
redirect(`/${params.orgId}/settings/policies/resources/public`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const navItems = [
|
||||||
|
{
|
||||||
|
title: t("general"),
|
||||||
|
href: "/{orgId}/settings/policies/resources/public/{niceId}/general"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: t("authentication"),
|
||||||
|
href: "/{orgId}/settings/policies/resources/public/{niceId}/authentication"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: t("policyAccessRulesTitle"),
|
||||||
|
href: "/{orgId}/settings/policies/resources/public/{niceId}/rules"
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className="flex justify-between">
|
<div className="flex justify-between">
|
||||||
@@ -46,14 +69,16 @@ export default async function EditPolicyPage(props: EditPolicyPageProps) {
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
<Button asChild variant="outline">
|
<Button asChild variant="outline">
|
||||||
<Link href={`/${params.orgId}/settings/policies/resource`}>
|
<Link
|
||||||
|
href={`/${params.orgId}/settings/policies/resources/public`}
|
||||||
|
>
|
||||||
{t("resourcePoliciesSeeAll")}
|
{t("resourcePoliciesSeeAll")}
|
||||||
</Link>
|
</Link>
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<ResourcePolicyProvider policy={policyResponse}>
|
<ResourcePolicyProvider policy={policyResponse}>
|
||||||
<EditPolicyForm />
|
<HorizontalTabs items={navItems}>{props.children}</HorizontalTabs>
|
||||||
</ResourcePolicyProvider>
|
</ResourcePolicyProvider>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
import { redirect } from "next/navigation";
|
||||||
|
|
||||||
|
type EditPolicyPageProps = {
|
||||||
|
params: Promise<{ niceId: string; orgId: string }>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default async function EditPolicyPage(props: EditPolicyPageProps) {
|
||||||
|
const params = await props.params;
|
||||||
|
redirect(
|
||||||
|
`/${params.orgId}/settings/policies/resources/public/${params.niceId}/general`
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { EditPolicyForm } from "@app/components/resource-policy/EditPolicyForm";
|
||||||
|
|
||||||
|
export default function EditPolicyRulesPage() {
|
||||||
|
return <EditPolicyForm section="rules" />;
|
||||||
|
}
|
||||||
+3
-1
@@ -23,7 +23,9 @@ export default async function CreateResourcePolicyPage(
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
<Button asChild variant="outline">
|
<Button asChild variant="outline">
|
||||||
<Link href={`/${params.orgId}/settings/policies/resource`}>
|
<Link
|
||||||
|
href={`/${params.orgId}/settings/policies/resources/public`}
|
||||||
|
>
|
||||||
{t("resourcePoliciesSeeAll")}
|
{t("resourcePoliciesSeeAll")}
|
||||||
</Link>
|
</Link>
|
||||||
</Button>
|
</Button>
|
||||||
+3
@@ -1,3 +1,4 @@
|
|||||||
|
import ResourcePoliciesBanner from "@app/components/ResourcePoliciesBanner";
|
||||||
import { ResourcePoliciesTable } from "@app/components/ResourcePoliciesTable";
|
import { ResourcePoliciesTable } from "@app/components/ResourcePoliciesTable";
|
||||||
import SettingsSectionTitle from "@app/components/SettingsSectionTitle";
|
import SettingsSectionTitle from "@app/components/SettingsSectionTitle";
|
||||||
import { internal } from "@app/lib/api";
|
import { internal } from "@app/lib/api";
|
||||||
@@ -54,6 +55,8 @@ export default async function ResourcePoliciesPage(
|
|||||||
description={t("resourcePoliciesDescription")}
|
description={t("resourcePoliciesDescription")}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<ResourcePoliciesBanner />
|
||||||
|
|
||||||
<ResourcePoliciesTable
|
<ResourcePoliciesTable
|
||||||
policies={policies}
|
policies={policies}
|
||||||
orgId={params.orgId}
|
orgId={params.orgId}
|
||||||
@@ -9,6 +9,8 @@ import {
|
|||||||
} from "@app/components/InfoSection";
|
} from "@app/components/InfoSection";
|
||||||
import {
|
import {
|
||||||
SettingsContainer,
|
SettingsContainer,
|
||||||
|
SettingsFormCell,
|
||||||
|
SettingsFormGrid,
|
||||||
SettingsSection,
|
SettingsSection,
|
||||||
SettingsSectionBody,
|
SettingsSectionBody,
|
||||||
SettingsSectionDescription,
|
SettingsSectionDescription,
|
||||||
@@ -257,80 +259,87 @@ export default function Page() {
|
|||||||
e.preventDefault(); // block default enter refresh
|
e.preventDefault(); // block default enter refresh
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
className="space-y-4 grid gap-4 grid-cols-1 md:grid-cols-2 items-start"
|
|
||||||
id="create-client-form"
|
id="create-client-form"
|
||||||
>
|
>
|
||||||
<FormField
|
<SettingsFormGrid>
|
||||||
control={form.control}
|
<SettingsFormCell span="half">
|
||||||
name="name"
|
<FormField
|
||||||
render={({ field }) => (
|
control={form.control}
|
||||||
<FormItem>
|
name="name"
|
||||||
<FormLabel>
|
render={({ field }) => (
|
||||||
{t("name")}
|
<FormItem>
|
||||||
</FormLabel>
|
<FormLabel>
|
||||||
<FormControl>
|
{t("name")}
|
||||||
<Input
|
</FormLabel>
|
||||||
autoComplete="off"
|
<FormControl>
|
||||||
{...field}
|
<Input
|
||||||
/>
|
autoComplete="off"
|
||||||
</FormControl>
|
{...field}
|
||||||
<FormMessage />
|
/>
|
||||||
<FormDescription>
|
</FormControl>
|
||||||
{t(
|
<FormMessage />
|
||||||
"clientNameDescription"
|
<FormDescription>
|
||||||
)}
|
{t(
|
||||||
</FormDescription>
|
"clientNameDescription"
|
||||||
</FormItem>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
<div className="flex items-center justify-end md:col-start-2">
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
onClick={() =>
|
|
||||||
setShowAdvancedSettings(
|
|
||||||
!showAdvancedSettings
|
|
||||||
)
|
|
||||||
}
|
|
||||||
className="flex items-center gap-2"
|
|
||||||
>
|
|
||||||
{showAdvancedSettings ? (
|
|
||||||
<ChevronUp className="h-4 w-4" />
|
|
||||||
) : (
|
|
||||||
<ChevronDown className="h-4 w-4" />
|
|
||||||
)}
|
|
||||||
{t("advancedSettings")}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
{showAdvancedSettings && (
|
|
||||||
<FormField
|
|
||||||
control={form.control}
|
|
||||||
name="subnet"
|
|
||||||
render={({ field }) => (
|
|
||||||
<FormItem className="md:col-start-1 md:col-span-2">
|
|
||||||
<FormLabel>
|
|
||||||
{t("clientAddress")}
|
|
||||||
</FormLabel>
|
|
||||||
<FormControl>
|
|
||||||
<Input
|
|
||||||
autoComplete="off"
|
|
||||||
placeholder={t(
|
|
||||||
"subnetPlaceholder"
|
|
||||||
)}
|
)}
|
||||||
{...field}
|
</FormDescription>
|
||||||
/>
|
</FormItem>
|
||||||
</FormControl>
|
)}
|
||||||
<FormMessage />
|
/>
|
||||||
<FormDescription>
|
</SettingsFormCell>
|
||||||
{t(
|
<SettingsFormCell className="flex items-center justify-end md:col-span-2">
|
||||||
"addressDescription"
|
<Button
|
||||||
)}
|
type="button"
|
||||||
</FormDescription>
|
variant="ghost"
|
||||||
</FormItem>
|
size="sm"
|
||||||
)}
|
onClick={() =>
|
||||||
/>
|
setShowAdvancedSettings(
|
||||||
)}
|
!showAdvancedSettings
|
||||||
|
)
|
||||||
|
}
|
||||||
|
className="flex items-center gap-2"
|
||||||
|
>
|
||||||
|
{showAdvancedSettings ? (
|
||||||
|
<ChevronUp className="h-4 w-4" />
|
||||||
|
) : (
|
||||||
|
<ChevronDown className="h-4 w-4" />
|
||||||
|
)}
|
||||||
|
{t("advancedSettings")}
|
||||||
|
</Button>
|
||||||
|
</SettingsFormCell>
|
||||||
|
{showAdvancedSettings && (
|
||||||
|
<SettingsFormCell span="full">
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="subnet"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>
|
||||||
|
{t(
|
||||||
|
"clientAddress"
|
||||||
|
)}
|
||||||
|
</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input
|
||||||
|
autoComplete="off"
|
||||||
|
placeholder={t(
|
||||||
|
"subnetPlaceholder"
|
||||||
|
)}
|
||||||
|
{...field}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
<FormDescription>
|
||||||
|
{t(
|
||||||
|
"addressDescription"
|
||||||
|
)}
|
||||||
|
</FormDescription>
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</SettingsFormCell>
|
||||||
|
)}
|
||||||
|
</SettingsFormGrid>
|
||||||
</form>
|
</form>
|
||||||
</Form>
|
</Form>
|
||||||
</SettingsSectionBody>
|
</SettingsSectionBody>
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ import { ColumnDef } from "@tanstack/react-table";
|
|||||||
import { DateTimeValue } from "@app/components/DateTimePicker";
|
import { DateTimeValue } from "@app/components/DateTimePicker";
|
||||||
import { ArrowUpRight, Key, User } from "lucide-react";
|
import { ArrowUpRight, Key, User } from "lucide-react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { ColumnFilter } from "@app/components/ColumnFilter";
|
import { ColumnFilterButton } from "@app/components/ColumnFilterButton";
|
||||||
import SettingsSectionTitle from "@app/components/SettingsSectionTitle";
|
import SettingsSectionTitle from "@app/components/SettingsSectionTitle";
|
||||||
import { build } from "@server/build";
|
import { build } from "@server/build";
|
||||||
import { getSevenDaysAgo } from "@app/lib/getSevenDaysAgo";
|
import { getSevenDaysAgo } from "@app/lib/getSevenDaysAgo";
|
||||||
@@ -233,7 +233,7 @@ export default function GeneralPage() {
|
|||||||
{
|
{
|
||||||
accessorKey: "timestamp",
|
accessorKey: "timestamp",
|
||||||
header: () => {
|
header: () => {
|
||||||
return t("timestamp");
|
return <span className="px-2">{t("timestamp")}</span>;
|
||||||
},
|
},
|
||||||
cell: ({ row }) => {
|
cell: ({ row }) => {
|
||||||
return (
|
return (
|
||||||
@@ -249,19 +249,19 @@ export default function GeneralPage() {
|
|||||||
accessorKey: "action",
|
accessorKey: "action",
|
||||||
header: () => {
|
header: () => {
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2 px-2">
|
||||||
<span>{t("action")}</span>
|
<ColumnFilterButton
|
||||||
<ColumnFilter
|
|
||||||
options={[
|
options={[
|
||||||
{ value: "true", label: "Allowed" },
|
{ value: "true", label: "Allowed" },
|
||||||
{ value: "false", label: "Denied" }
|
{ value: "false", label: "Denied" }
|
||||||
]}
|
]}
|
||||||
|
label={t("action")}
|
||||||
selectedValue={filters.action}
|
selectedValue={filters.action}
|
||||||
onValueChange={(value) =>
|
onValueChange={(value) =>
|
||||||
handleFilterChange("action", value)
|
handleFilterChange("action", value)
|
||||||
}
|
}
|
||||||
searchPlaceholder="Search..."
|
searchPlaceholder={t("searchPlaceholder")}
|
||||||
emptyMessage="None found"
|
emptyMessage={t("emptySearchOptions")}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -276,27 +276,27 @@ export default function GeneralPage() {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
accessorKey: "ip",
|
accessorKey: "ip",
|
||||||
header: () => t("ip")
|
header: () => <span className="px-2">{t("ip")}</span>
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
accessorKey: "location",
|
accessorKey: "location",
|
||||||
header: () => {
|
header: () => {
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2 px-2">
|
||||||
<span>{t("location")}</span>
|
<ColumnFilterButton
|
||||||
<ColumnFilter
|
|
||||||
options={filterAttributes.locations.map(
|
options={filterAttributes.locations.map(
|
||||||
(location) => ({
|
(location) => ({
|
||||||
value: location,
|
value: location,
|
||||||
label: location
|
label: location
|
||||||
})
|
})
|
||||||
)}
|
)}
|
||||||
|
label={t("location")}
|
||||||
selectedValue={filters.location}
|
selectedValue={filters.location}
|
||||||
onValueChange={(value) =>
|
onValueChange={(value) =>
|
||||||
handleFilterChange("location", value)
|
handleFilterChange("location", value)
|
||||||
}
|
}
|
||||||
searchPlaceholder="Search..."
|
searchPlaceholder={t("searchPlaceholder")}
|
||||||
emptyMessage="None found"
|
emptyMessage={t("emptySearchOptions")}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -321,19 +321,19 @@ export default function GeneralPage() {
|
|||||||
accessorKey: "resourceName",
|
accessorKey: "resourceName",
|
||||||
header: () => {
|
header: () => {
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2 px-2">
|
||||||
<span>{t("resource")}</span>
|
<ColumnFilterButton
|
||||||
<ColumnFilter
|
|
||||||
options={filterAttributes.resources.map((res) => ({
|
options={filterAttributes.resources.map((res) => ({
|
||||||
value: res.id.toString(),
|
value: res.id.toString(),
|
||||||
label: res.name || "Unnamed Resource"
|
label: res.name || "Unnamed Resource"
|
||||||
}))}
|
}))}
|
||||||
|
label={t("resource")}
|
||||||
selectedValue={filters.resourceId}
|
selectedValue={filters.resourceId}
|
||||||
onValueChange={(value) =>
|
onValueChange={(value) =>
|
||||||
handleFilterChange("resourceId", value)
|
handleFilterChange("resourceId", value)
|
||||||
}
|
}
|
||||||
searchPlaceholder="Search..."
|
searchPlaceholder={t("searchPlaceholder")}
|
||||||
emptyMessage="None found"
|
emptyMessage={t("emptySearchOptions")}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -359,9 +359,8 @@ export default function GeneralPage() {
|
|||||||
accessorKey: "type",
|
accessorKey: "type",
|
||||||
header: () => {
|
header: () => {
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2 px-2">
|
||||||
<span>{t("type")}</span>
|
<ColumnFilterButton
|
||||||
<ColumnFilter
|
|
||||||
options={[
|
options={[
|
||||||
{ value: "password", label: "Password" },
|
{ value: "password", label: "Password" },
|
||||||
{ value: "pincode", label: "Pincode" },
|
{ value: "pincode", label: "Pincode" },
|
||||||
@@ -372,12 +371,13 @@ export default function GeneralPage() {
|
|||||||
},
|
},
|
||||||
{ value: "ssh", label: "SSH" }
|
{ value: "ssh", label: "SSH" }
|
||||||
]}
|
]}
|
||||||
|
label={t("type")}
|
||||||
selectedValue={filters.type}
|
selectedValue={filters.type}
|
||||||
onValueChange={(value) =>
|
onValueChange={(value) =>
|
||||||
handleFilterChange("type", value)
|
handleFilterChange("type", value)
|
||||||
}
|
}
|
||||||
searchPlaceholder="Search..."
|
searchPlaceholder={t("searchPlaceholder")}
|
||||||
emptyMessage="None found"
|
emptyMessage={t("emptySearchOptions")}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -395,19 +395,19 @@ export default function GeneralPage() {
|
|||||||
accessorKey: "actor",
|
accessorKey: "actor",
|
||||||
header: () => {
|
header: () => {
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2 px-2">
|
||||||
<span>{t("actor")}</span>
|
<ColumnFilterButton
|
||||||
<ColumnFilter
|
|
||||||
options={filterAttributes.actors.map((actor) => ({
|
options={filterAttributes.actors.map((actor) => ({
|
||||||
value: actor,
|
value: actor,
|
||||||
label: actor
|
label: actor
|
||||||
}))}
|
}))}
|
||||||
|
label={t("actor")}
|
||||||
selectedValue={filters.actor}
|
selectedValue={filters.actor}
|
||||||
onValueChange={(value) =>
|
onValueChange={(value) =>
|
||||||
handleFilterChange("actor", value)
|
handleFilterChange("actor", value)
|
||||||
}
|
}
|
||||||
searchPlaceholder="Search..."
|
searchPlaceholder={t("searchPlaceholder")}
|
||||||
emptyMessage="None found"
|
emptyMessage={t("emptySearchOptions")}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -433,7 +433,7 @@ export default function GeneralPage() {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
accessorKey: "actorId",
|
accessorKey: "actorId",
|
||||||
header: () => t("actorId"),
|
header: () => <span className="px-2">{t("actorId")}</span>,
|
||||||
cell: ({ row }) => (
|
cell: ({ row }) => (
|
||||||
<span className="flex items-center gap-1">
|
<span className="flex items-center gap-1">
|
||||||
{row.original.actorId || "-"}
|
{row.original.actorId || "-"}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
"use client";
|
"use client";
|
||||||
import { ColumnFilter } from "@app/components/ColumnFilter";
|
import { ColumnFilterButton } from "@app/components/ColumnFilterButton";
|
||||||
import { DateTimeValue } from "@app/components/DateTimePicker";
|
import { DateTimeValue } from "@app/components/DateTimePicker";
|
||||||
import { LogDataTable } from "@app/components/LogDataTable";
|
import { LogDataTable } from "@app/components/LogDataTable";
|
||||||
import { PaidFeaturesAlert } from "@app/components/PaidFeaturesAlert";
|
import { PaidFeaturesAlert } from "@app/components/PaidFeaturesAlert";
|
||||||
@@ -219,9 +219,7 @@ export default function GeneralPage() {
|
|||||||
const columns: ColumnDef<any>[] = [
|
const columns: ColumnDef<any>[] = [
|
||||||
{
|
{
|
||||||
accessorKey: "timestamp",
|
accessorKey: "timestamp",
|
||||||
header: () => {
|
header: () => <span className="px-2">{t("timestamp")}</span>,
|
||||||
return t("timestamp");
|
|
||||||
},
|
|
||||||
cell: ({ row }) => {
|
cell: ({ row }) => {
|
||||||
return (
|
return (
|
||||||
<div className="whitespace-nowrap">
|
<div className="whitespace-nowrap">
|
||||||
@@ -236,16 +234,16 @@ export default function GeneralPage() {
|
|||||||
accessorKey: "action",
|
accessorKey: "action",
|
||||||
header: () => {
|
header: () => {
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2 px-2">
|
||||||
<span>{t("action")}</span>
|
<ColumnFilterButton
|
||||||
<ColumnFilter
|
|
||||||
options={[]}
|
options={[]}
|
||||||
|
label={t("action")}
|
||||||
selectedValue={filters.action}
|
selectedValue={filters.action}
|
||||||
onValueChange={(value) =>
|
onValueChange={(value) =>
|
||||||
handleFilterChange("action", value)
|
handleFilterChange("action", value)
|
||||||
}
|
}
|
||||||
searchPlaceholder="Search..."
|
searchPlaceholder={t("searchPlaceholder")}
|
||||||
emptyMessage="None found"
|
emptyMessage={t("emptySearchOptions")}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -263,19 +261,19 @@ export default function GeneralPage() {
|
|||||||
accessorKey: "actor",
|
accessorKey: "actor",
|
||||||
header: () => {
|
header: () => {
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2 px-2">
|
||||||
<span>{t("actor")}</span>
|
<ColumnFilterButton
|
||||||
<ColumnFilter
|
|
||||||
options={filterAttributes.actors.map((actor) => ({
|
options={filterAttributes.actors.map((actor) => ({
|
||||||
value: actor,
|
value: actor,
|
||||||
label: actor
|
label: actor
|
||||||
}))}
|
}))}
|
||||||
|
label={t("actor")}
|
||||||
selectedValue={filters.actor}
|
selectedValue={filters.actor}
|
||||||
onValueChange={(value) =>
|
onValueChange={(value) =>
|
||||||
handleFilterChange("actor", value)
|
handleFilterChange("actor", value)
|
||||||
}
|
}
|
||||||
searchPlaceholder="Search..."
|
searchPlaceholder={t("searchPlaceholder")}
|
||||||
emptyMessage="None found"
|
emptyMessage={t("emptySearchOptions")}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -295,9 +293,7 @@ export default function GeneralPage() {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
accessorKey: "actorId",
|
accessorKey: "actorId",
|
||||||
header: () => {
|
header: () => <span className="px-2">{t("actorId")}</span>,
|
||||||
return t("actorId");
|
|
||||||
},
|
|
||||||
cell: ({ row }) => {
|
cell: ({ row }) => {
|
||||||
return (
|
return (
|
||||||
<span className="flex items-center gap-1">
|
<span className="flex items-center gap-1">
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user