diff --git a/.cursor/rules/Button-loading-state.mdc b/.cursor/rules/Button-loading-state.mdc new file mode 100644 index 000000000..351380ddd --- /dev/null +++ b/.cursor/rules/Button-loading-state.mdc @@ -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. diff --git a/.cursor/rules/Components.mdc b/.cursor/rules/Components.mdc new file mode 100644 index 000000000..671eb9b10 --- /dev/null +++ b/.cursor/rules/Components.mdc @@ -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. diff --git a/.cursor/rules/TypeScript-rules.mdc b/.cursor/rules/TypeScript-rules.mdc new file mode 100644 index 000000000..0b0a4ba28 --- /dev/null +++ b/.cursor/rules/TypeScript-rules.mdc @@ -0,0 +1,7 @@ +--- +alwaysApply: true +--- + +When writing TypeScript: + +Prefer to use types instead of interfaces. diff --git a/.cursor/rules/Use-React-form-and-Zod-schemas.mdc b/.cursor/rules/Use-React-form-and-Zod-schemas.mdc new file mode 100644 index 000000000..1dc5c33aa --- /dev/null +++ b/.cursor/rules/Use-React-form-and-Zod-schemas.mdc @@ -0,0 +1,5 @@ +--- +alwaysApply: true +--- + +When creating forms, use React form for validation and use Zod schemas. diff --git a/cli/commands/setServerAdmin.ts b/cli/commands/setServerAdmin.ts index 341b70bc1..0cab8cc9f 100644 --- a/cli/commands/setServerAdmin.ts +++ b/cli/commands/setServerAdmin.ts @@ -4,19 +4,26 @@ import { eq } from "drizzle-orm"; type SetServerAdminArgs = { email: string; + remove: boolean; }; export const setServerAdmin: CommandModule<{}, SetServerAdminArgs> = { command: "set-server-admin", - describe: "Mark any user as a server admin by email address", + describe: "Add or remove server admin by email address", builder: (yargs) => { - return yargs.option("email", { - type: "string", - demandOption: true, - describe: "User email address" - }); + return yargs + .option("email", { + type: "string", + demandOption: true, + describe: "User email address" + }) + .option("remove", { + type: "boolean", + default: false, + describe: "Remove server admin status from the user" + }); }, - handler: async (argv: { email: string }) => { + handler: async (argv: SetServerAdminArgs) => { try { const email = argv.email.trim().toLowerCase(); @@ -31,6 +38,33 @@ export const setServerAdmin: CommandModule<{}, SetServerAdminArgs> = { process.exit(1); } + if (argv.remove) { + if (!user.serverAdmin) { + console.log(`User '${email}' is not a server admin`); + process.exit(0); + } + + const serverAdmins = await db + .select() + .from(users) + .where(eq(users.serverAdmin, true)); + + if (serverAdmins.length <= 1) { + console.error( + "Cannot remove server admin: at least one server admin must exist" + ); + process.exit(1); + } + + await db + .update(users) + .set({ serverAdmin: false }) + .where(eq(users.userId, user.userId)); + + console.log(`Server admin status removed from user '${email}'`); + process.exit(0); + } + if (user.serverAdmin) { console.log(`User '${email}' is already a server admin`); process.exit(0); diff --git a/messages/bg-BG.json b/messages/bg-BG.json index 3417a0d3f..91291d2e3 100644 --- a/messages/bg-BG.json +++ b/messages/bg-BG.json @@ -200,8 +200,8 @@ "shareErrorSelectResource": "Моля, изберете ресурс", "proxyResourceTitle": "Управление на обществени ресурси", "proxyResourceDescription": "Създайте и управлявайте ресурси, които са общодостъпни чрез уеб браузър.", - "proxyResourcesBannerTitle": "Публичен достъп чрез уеб.", - "proxyResourcesBannerDescription": "Публичните ресурси са HTTPS или TCP/UDP проксита, достъпни за всеки в интернет чрез уеб браузър. За разлика от частните ресурси, те не изискват софтуер от страна на клиента и могат да включват издентити и контексто-осъзнати политики за достъп.", + "publicResourcesBannerTitle": "Публичен достъп чрез уеб.", + "publicResourcesBannerDescription": "Публичните ресурси са HTTPS или TCP/UDP проксита, достъпни за всеки в интернет чрез уеб браузър. За разлика от частните ресурси, те не изискват софтуер от страна на клиента и могат да включват издентити и контексто-осъзнати политики за достъп.", "clientResourceTitle": "Управление на частни ресурси", "clientResourceDescription": "Създайте и управлявайте ресурси, които са достъпни само чрез свързан клиент.", "privateResourcesBannerTitle": "Достъп до частни ресурси с нулево доверие.", diff --git a/messages/cs-CZ.json b/messages/cs-CZ.json index 1ab19dc13..891bc58a4 100644 --- a/messages/cs-CZ.json +++ b/messages/cs-CZ.json @@ -200,8 +200,8 @@ "shareErrorSelectResource": "Zvolte prosím zdroj", "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", - "proxyResourcesBannerTitle": "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.", + "publicResourcesBannerTitle": "Veřejný přístup založený na webu", + "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", "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", diff --git a/messages/de-DE.json b/messages/de-DE.json index 50b9c0bda..e4afce362 100644 --- a/messages/de-DE.json +++ b/messages/de-DE.json @@ -200,8 +200,8 @@ "shareErrorSelectResource": "Bitte wählen Sie eine Ressource", "proxyResourceTitle": "Öffentliche Ressourcen verwalten", "proxyResourceDescription": "Erstelle und verwalte Ressourcen, die über einen Webbrowser öffentlich zugänglich sind", - "proxyResourcesBannerTitle": "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.", + "publicResourcesBannerTitle": "Web-basierter öffentlicher Zugang", + "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", "clientResourceDescription": "Erstelle und verwalte Ressourcen, die nur über einen verbundenen Client zugänglich sind", "privateResourcesBannerTitle": "Zero-Trust-Zugriff auf private Ressourcen", diff --git a/messages/en-US.json b/messages/en-US.json index 0745f861a..48ad74542 100644 --- a/messages/en-US.json +++ b/messages/en-US.json @@ -101,6 +101,8 @@ "sitesTableViewPrivateResources": "View Private Resources", "siteInstallNewt": "Install Site", "siteInstallNewtDescription": "Install the site connector for your system", + "siteInstallKubernetesDocsDescription": "For more and up to date Kubernetes installation information, see docs.pangolin.net/manage/sites/install-kubernetes.", + "siteInstallAdvantechDocsDescription": "For Advantech modem installation instructions, see docs.pangolin.net/manage/sites/install-advantech.", "WgConfiguration": "WireGuard Configuration", "WgConfigurationDescription": "Use the following configuration to connect to the network", "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.", "siteInfo": "Site Information", "status": "Status", - "shareTitle": "Manage Share Links", + "shareTitle": "Manage Shareable Links", "shareDescription": "Create shareable links to grant temporary or permanent access to proxy resources", - "shareSearch": "Search share links...", - "shareCreate": "Create Share Link", + "shareSearch": "Search shareable links...", + "shareCreate": "Create Shareable Link", "shareErrorDelete": "Failed to delete link", "shareErrorDeleteMessage": "An error occurred deleting link", "shareDeleted": "Link deleted", "shareDeletedDescription": "The link has been deleted", - "shareDelete": "Delete Share Link", - "shareDeleteConfirm": "Confirm Delete Share Link", + "shareDelete": "Delete Shareable Link", + "shareDeleteConfirm": "Confirm Delete Shareable 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.", "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", "shareTitleOptional": "Title (optional)", "sharePathOptional": "Path (optional)", + "sharePathDescription": "The link will redirect users to this path after authentication.", "expireIn": "Expire In", "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.", @@ -200,8 +203,8 @@ "shareErrorSelectResource": "Please select a resource", "proxyResourceTitle": "Manage Public Resources", "proxyResourceDescription": "Create and manage resources that are publicly accessible through a web browser", - "proxyResourcesBannerTitle": "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.", + "publicResourcesBannerTitle": "Web-based Public Access", + "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", "clientResourceDescription": "Create and manage resources that are only accessible through a connected client", "privateResourcesBannerTitle": "Zero-Trust Private Access", @@ -209,15 +212,18 @@ "resourcesSearch": "Search resources...", "resourceAdd": "Add Resource", "resourceErrorDelte": "Error deleting resource", - "resourcePoliciesTitle": "Manage Resource Policies", - "resourcePoliciesAttachedResourcesColumnTitle": "Attached resources", + "resourcePoliciesBannerTitle": "Re-use Authentication and Access Rules", + "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)", + "resourcePoliciesAttachedResourcesCount": "{count, plural, one {# resource} other {# 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...", "resourcePoliciesAdd": "Add Policy", "resourcePoliciesDefaultBadgeText": "Default policy", - "resourcePoliciesCreate": "Create Resource Policy", + "resourcePoliciesCreate": "Create Public Resource Policy", "resourcePoliciesCreateDescription": "Follow the steps below to create a new policy", "resourcePolicyName": "Policy Name", "resourcePolicyNameDescription": "Give this policy a name to identify it across your resources", @@ -274,7 +280,7 @@ "back": "Back", "cancel": "Cancel", "resourceConfig": "Configuration Snippets", - "resourceConfigDescription": "Copy and paste these configuration snippets to set up the TCP/UDP resource", + "resourceConfigDescription": "Copy and paste these configuration snippets to set up the TCP/UDP resource.", "resourceAddEntrypoints": "Traefik: Add Entrypoints", "resourceExposePorts": "Gerbil: Expose Ports in Docker Compose", "resourceLearnRaw": "Learn how to configure TCP/UDP resources", @@ -287,6 +293,8 @@ "labelDelete": "Delete Label", "labelAdd": "Add Label", "labelCreateSuccessMessage": "Label Created Successfully", + "labelDuplicateError": "Duplicate Label", + "labelDuplicateErrorDescription": "A label with this name already exists.", "labelEditSuccessMessage": "Label Modified Successfully", "labelNameField": "Label Name", "labelColorField": "Label Color", @@ -311,7 +319,7 @@ "rules": "Rules", "resourceSettingDescription": "Configure the settings on the resource", "resourceSetting": "{resourceName} Settings", - "resourcePolicySettingDescription": "Configure the settings on the resource policy", + "resourcePolicySettingDescription": "Configure the settings on this public resource policy", "resourcePolicySetting": "{policyName} Settings", "alwaysAllow": "Bypass Auth", "alwaysDeny": "Block Access", @@ -719,7 +727,7 @@ "targetSubmit": "Add Target", "targetNoOne": "This resource doesn't have any targets. Add a target to configure where to send requests to the backend.", "targetNoOneDescription": "Adding more than one target above will enable load balancing.", - "targetsSubmit": "Save Targets", + "targetsSubmit": "Save Settings", "addTarget": "Add Target", "proxyMultiSiteRoundRobinNodeHelp": "Round robin routing will not work between sites that are not connected to the same node, but failover will work.", "targetErrorInvalidIp": "Invalid IP address", @@ -753,11 +761,11 @@ "rulesErrorDuplicate": "Duplicate rule", "rulesErrorDuplicateDescription": "A rule with these settings already exists", "rulesErrorInvalidIpAddressRange": "Invalid CIDR", - "rulesErrorInvalidIpAddressRangeDescription": "Please enter a valid CIDR value", - "rulesErrorInvalidUrl": "Invalid URL path", - "rulesErrorInvalidUrlDescription": "Please enter a valid URL path value", - "rulesErrorInvalidIpAddress": "Invalid IP", - "rulesErrorInvalidIpAddressDescription": "Please enter a valid IP address", + "rulesErrorInvalidIpAddressRangeDescription": "Enter a valid CIDR range (e.g., 10.0.0.0/8).", + "rulesErrorInvalidUrl": "Invalid path", + "rulesErrorInvalidUrlDescription": "Enter a valid URL path or pattern (e.g., /api/*).", + "rulesErrorInvalidIpAddress": "Invalid IP address", + "rulesErrorInvalidIpAddressDescription": "Enter a valid IPv4 or IPv6 address.", "rulesErrorUpdate": "Failed to update rules", "rulesErrorUpdateDescription": "An error occurred while updating rules", "rulesUpdated": "Enable Rules", @@ -765,15 +773,24 @@ "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)", "rulesMatchUrl": "Enter a URL path or pattern (e.g., /api/v1/todos or /api/v1/*)", - "rulesErrorInvalidPriority": "Invalid Priority", - "rulesErrorInvalidPriorityDescription": "Please enter a valid priority", - "rulesErrorDuplicatePriority": "Duplicate Priorities", - "rulesErrorDuplicatePriorityDescription": "Please enter unique priorities", + "rulesErrorInvalidPriority": "Invalid priority", + "rulesErrorInvalidPriorityDescription": "Enter a whole number of 1 or higher.", + "rulesErrorDuplicatePriority": "Duplicate 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", "ruleUpdatedDescription": "Rules updated successfully", "ruleErrorUpdate": "Operation failed", "ruleErrorUpdateDescription": "An error occurred during the save operation", "rulesPriority": "Priority", + "rulesReorderDragHandle": "Drag to reorder rule priority", "rulesAction": "Action", "rulesMatchType": "Match Type", "value": "Value", @@ -792,7 +809,7 @@ "rulesResource": "Resource Rules Configuration", "rulesResourceDescription": "Configure rules to control access to the resource", "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.", "rulesSubmit": "Save Rules", "policyErrorCreate": "Error creating policy", @@ -803,7 +820,48 @@ "policyErrorUpdateMessageDescription": "An unexpected error occurred", "policyCreatedSuccess": "Resource policy succesfully created", "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", "resourceErrorCreate": "Error creating resource", "resourceErrorCreateDescription": "An error occurred when creating the resource", @@ -824,9 +882,9 @@ "resourcesErrorUpdateDescription": "An error occurred while updating the resource", "access": "Access", "accessControl": "Access Control", - "shareLink": "{resource} Share Link", + "shareLink": "{resource} Shareable Link", "resourceSelect": "Select resource", - "shareLinks": "Share Links", + "shareLinks": "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.", "shareEasyCreate": "Easy to create and share", @@ -916,10 +974,18 @@ "resourceRoleDescription": "Admins can always access this resource.", "resourcePolicySelectTitle": "Resource Access Policy", "resourcePolicySelectDescription": "Select the resource policy type for authentication", + "resourcePolicyTypeLabel": "Policy type", + "resourcePolicyLabel": "Resource policy", "resourcePolicyInline": "Inline Resource Policy", "resourcePolicyInlineDescription": "Access Policy scoped to only this resource", "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.", + "resourceSharedPolicyOwnDescription": "This resource has its own authentication and access rules controls.", + "resourceSharedPolicyInheritedDescription": "This resource inherits authentication and access rules controls from {policyName}.", + "resourceSharedPolicyAuthenticationNotice": "This resource is using a shared policy. Some authentication settings can be edited on this resource to add to the policy. To change the underlying policy, you must edit to {policyName}.", + "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 {policyName}.", "resourceUsersRoles": "Access Controls", "resourceUsersRolesDescription": "Configure which users and roles can visit this resource", "resourceUsersRolesSubmit": "Save Access Controls", @@ -944,7 +1010,14 @@ "resourceVisibilityTitle": "Visibility", "resourceVisibilityTitleDescription": "Completely enable or disable resource visibility", "resourceGeneral": "General Settings", - "resourceGeneralDescription": "Configure the general settings for this resource", + "resourceGeneralDescription": "Configure name, address, and access policy for this resource.", + "resourceGeneralDetailsSubsection": "Resource Details", + "resourceGeneralDetailsSubsectionDescription": "Set the display name, identifier, and publicly accessible domain for this resource.", + "resourceGeneralDetailsSubsectionPortDescription": "Set the display name, identifier, and public port for this resource.", + "resourceGeneralPublicAddressSubsection": "Public Address", + "resourceGeneralPublicAddressSubsectionDescription": "Configure how users reach this resource.", + "resourceGeneralAuthenticationAccessSubsection": "Authentication & Access", + "resourceGeneralAuthenticationAccessSubsectionDescription": "Choose whether this resource uses its own policy or inherits from a shared policy.", "resourceEnable": "Enable Resource", "resourceTransfer": "Transfer Resource", "resourceTransferDescription": "Transfer this resource to a different site", @@ -1220,11 +1293,14 @@ "addLabels": "Add labels", "siteLabelsTab": "Labels", "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", + "labelSearchOrCreate": "Search or create a label", "accessLabelFilterCount": "{count, plural, one {# label} other {# labels}}", "labelOverflowCount": "+{count, plural, one {# label} other {# labels}}", "accessLabelFilterClear": "Clear label filters", + "accessFilterClear": "Clear filters", "selectColor": "Select color", "createNewLabel": "Create new org label \"{label}\"", "inviteInvalidDescription": "The invite link is invalid.", @@ -1461,8 +1537,8 @@ "sidebarResources": "Resources", "sidebarProxyResources": "Public", "sidebarClientResources": "Private", - "sidebarPolicies": "Policies", - "sidebarResourcePolicies": "Resources", + "sidebarPolicies": "Shared Policies", + "sidebarResourcePolicies": "Public Resources", "sidebarAccessControl": "Access Control", "sidebarLogsAndAnalytics": "Logs & Analytics", "sidebarTeam": "Team", @@ -1470,7 +1546,7 @@ "sidebarAdmin": "Admin", "sidebarInvitations": "Invitations", "sidebarRoles": "Roles", - "sidebarShareableLinks": "Links", + "sidebarShareableLinks": "Shareable Links", "sidebarApiKeys": "API Keys", "sidebarProvisioning": "Provisioning", "sidebarSettings": "Settings", @@ -1647,7 +1723,7 @@ "standaloneHcFilterResourceIdFallback": "Resource {id}", "blueprints": "Blueprints", "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", "blueprintGoBack": "See all Blueprints", "blueprintCreate": "Create Blueprint", @@ -1667,10 +1743,10 @@ "enableDockerSocket": "Enable Docker Blueprint", "enableDockerSocketDescription": "Enable Docker Socket label scraping for blueprint labels. Socket path must be provided to the site connector. Read about how this works in the documentation.", "newtAutoUpdate": "Enable Site Auto-Update", - "newtAutoUpdateDescription": "When enabled, site connectors will automatically update to the latest version when a new release is available.", + "newtAutoUpdateDescription": "When enabled, site connectors will automatically download the latest version and restart themselves. This can be overridden on a per-site basis.", "siteAutoUpdate": "Site Auto-Update", "siteAutoUpdateLabel": "Enable Auto-Update", - "siteAutoUpdateDescription": "Control whether this site's connector automatically downloads the latest version.", + "siteAutoUpdateDescription": "When enabled, this site's connector will automatically download the latest version and restart itself.", "siteAutoUpdateOrgDefault": "Organization default: {state}", "siteAutoUpdateOverriding": "Overriding organization setting", "siteAutoUpdateResetToOrg": "Reset to Organization Default", @@ -1768,9 +1844,9 @@ "accountSetupSuccess": "Account setup completed! Welcome to Pangolin!", "documentation": "Documentation", "saveAllSettings": "Save All Settings", - "saveResourceTargets": "Save Targets", - "saveResourceHttp": "Save Proxy Settings", - "saveProxyProtocol": "Save Proxy protocol settings", + "saveResourceTargets": "Save Settings", + "saveResourceHttp": "Save Settings", + "saveProxyProtocol": "Save Settings", "settingsUpdated": "Settings updated", "settingsUpdatedDescription": "Settings updated successfully", "settingsErrorUpdate": "Failed to update settings", @@ -2027,13 +2103,13 @@ "healthCheckUnknown": "Unknown", "healthCheck": "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", "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.", "healthScheme": "Method", "healthSelectScheme": "Select Method", - "healthCheckPortInvalid": "Health check port must be between 1 and 65535", + "healthCheckPortInvalid": "Port must be between 1 and 65535", "healthCheckPath": "Path", "healthHostname": "IP / Host", "healthPort": "Port", @@ -2073,8 +2149,13 @@ "sshDaemonDisclaimer": "Ensure your target host is properly configured to run the auth daemon before completing this setup, or provisioning will fail.", "sshDaemonPort": "Daemon Port", "sshServerDestination": "Server Destination", - "sshServerDestinationDescription": "Configure the destination and port of the SSH server", + "sshServerDestinationDescription": "Configure the destination of the SSH server", "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.", "roleAllowSsh": "Allow SSH", "roleAllowSshAllow": "Allow", @@ -2089,10 +2170,25 @@ "sshSudoModeCommandsDescription": "User can run only the specified commands with sudo.", "sshSudo": "Allow sudo", "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", "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", "expectedResponseCodes": "Expected Response Codes", "expectedResponseCodesDescription": "HTTP status code that indicates healthy status. If left blank, 200-300 is considered healthy.", @@ -2876,9 +2972,10 @@ "enableProxyProtocol": "Enable Proxy Protocol", "proxyProtocolInfo": "Preserve client IP addresses for TCP backends", "proxyProtocolVersion": "Proxy Protocol Version", - "version1": " Version 1 (Recommended)", + "version1": "Version 1 (Recommended)", "version2": "Version 2", - "versionDescription": "Version 1 is text-based and widely supported. Version 2 is binary and more efficient but less compatible. Make sure servers transport is added to dynamic config.", + "version1Description": "Text-based and widely supported. Make sure servers transport is added to dynamic config.", + "version2Description": "Binary and more efficient but less compatible. Make sure servers transport is added to dynamic config.", "warning": "Warning", "proxyProtocolWarning": "The backend application must be configured to accept Proxy Protocol connections. If your backend doesn't support Proxy Protocol, enabling this will break all connections so only enable this if you know what you're doing. Make sure to configure your backend to trust Proxy Protocol headers from Traefik.", "restarting": "Restarting...", @@ -3035,7 +3132,7 @@ "enterConfirmation": "Enter confirmation", "blueprintViewDetails": "Details", "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", "editInternalResourceDialogAccessPolicy": "Access Policy", "editInternalResourceDialogAddRoles": "Add Roles", @@ -3076,6 +3173,7 @@ "maintenanceModeType": "Maintenance Mode Type", "showMaintenancePage": "Show a maintenance page to visitors", "enableMaintenanceMode": "Enable Maintenance Mode", + "enableMaintenanceModeDescription": "When enabled, visitors will see a maintenance page instead of your resource.", "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.", "forced": "Forced", @@ -3083,6 +3181,8 @@ "warning:": "Warning:", "forcedeModeWarning": "All traffic will be directed to the maintenance page. Your backend resources will not receive any requests.", "pageTitle": "Page Title", + "maintenancePageContentSubsection": "Page Content", + "maintenancePageContentSubsectionDescription": "Customize the content displayed on the maintenance page", "pageTitleDescription": "The main heading displayed on the maintenance page", "maintenancePageMessage": "Maintenance Message", "maintenancePageMessagePlaceholder": "We'll be back soon! Our site is currently undergoing scheduled maintenance.", @@ -3442,18 +3542,58 @@ "sshConnecting": "Connecting…", "sshInitializing": "Initializing…", "sshSignInTitle": "Sign in to SSH", - "sshSignInDescription": "Enter your SSH credentials", + "sshSignInDescription": "Enter your SSH credentials to connect", "sshPasswordTab": "Password", "sshPrivateKeyTab": "Private Key", "sshPrivateKeyField": "Private Key", "sshPrivateKeyDisclaimer": "Your private key is not stored or visible to Pangolin. Alternatively, you can use short-lived certificates for seamless authentication using your existing Pangolin identity.", "sshLearnMore": "Learn more", "sshPrivateKeyFile": "Private Key File", - "sshAuthenticate": "Authenticate", + "sshAuthenticate": "Connect", "sshTerminate": "Terminate", "sshPoweredBy": "Powered by", "sshErrorNoTarget": "No target specified", "sshErrorWebSocket": "WebSocket connection 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" } diff --git a/messages/es-ES.json b/messages/es-ES.json index 7b41a83be..a43ccbc4c 100644 --- a/messages/es-ES.json +++ b/messages/es-ES.json @@ -200,8 +200,8 @@ "shareErrorSelectResource": "Por favor, seleccione un recurso", "proxyResourceTitle": "Administrar recursos públicos", "proxyResourceDescription": "Crear y administrar recursos que sean accesibles públicamente a través de un navegador web", - "proxyResourcesBannerTitle": "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.", + "publicResourcesBannerTitle": "Acceso público basado en web", + "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", "clientResourceDescription": "Crear y administrar recursos que sólo son accesibles a través de un cliente conectado", "privateResourcesBannerTitle": "Acceso privado de confianza cero", diff --git a/messages/fr-FR.json b/messages/fr-FR.json index 54a0581f2..98ebf6e7c 100644 --- a/messages/fr-FR.json +++ b/messages/fr-FR.json @@ -200,8 +200,8 @@ "shareErrorSelectResource": "Veuillez sélectionner une ressource", "proxyResourceTitle": "Gérer les ressources publiques", "proxyResourceDescription": "Créer et gérer des ressources accessibles au public via un navigateur web", - "proxyResourcesBannerTitle": "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.", + "publicResourcesBannerTitle": "Accès public basé sur le Web", + "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", "clientResourceDescription": "Créer et gérer des ressources qui ne sont accessibles que via un client connecté", "privateResourcesBannerTitle": "Accès privé sans confiance", diff --git a/messages/it-IT.json b/messages/it-IT.json index 33694cb2c..78018b288 100644 --- a/messages/it-IT.json +++ b/messages/it-IT.json @@ -200,8 +200,8 @@ "shareErrorSelectResource": "Seleziona una risorsa", "proxyResourceTitle": "Gestisci Risorse Pubbliche", "proxyResourceDescription": "Creare e gestire risorse pubbliche accessibili tramite un browser web", - "proxyResourcesBannerTitle": "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.", + "publicResourcesBannerTitle": "Accesso Pubblico Basato sul Web", + "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", "clientResourceDescription": "Crea e gestisci risorse accessibili solo tramite un client connesso", "privateResourcesBannerTitle": "Accesso Privato Zero-Trust", diff --git a/messages/ko-KR.json b/messages/ko-KR.json index 1ee09e4bb..43213d093 100644 --- a/messages/ko-KR.json +++ b/messages/ko-KR.json @@ -200,8 +200,8 @@ "shareErrorSelectResource": "리소스를 선택하세요", "proxyResourceTitle": "공개 리소스 관리", "proxyResourceDescription": "웹 브라우저를 통해 공용으로 접근할 수 있는 리소스를 생성하고 관리하세요.", - "proxyResourcesBannerTitle": "웹 기반 공공 접근", - "proxyResourcesBannerDescription": "공공 자원은 누구나 웹 브라우저를 통해 접근 가능한 HTTPS 또는 TCP/UDP 프록시입니다. 개인 자원과 달리 클라이언트 측 소프트웨어가 필요하지 않으며, 아이덴티티 및 컨텍스트 인지 접근 정책을 포함할 수 있습니다.", + "publicResourcesBannerTitle": "웹 기반 공공 접근", + "publicResourcesBannerDescription": "공공 자원은 누구나 웹 브라우저를 통해 접근 가능한 HTTPS 또는 TCP/UDP 프록시입니다. 개인 자원과 달리 클라이언트 측 소프트웨어가 필요하지 않으며, 아이덴티티 및 컨텍스트 인지 접근 정책을 포함할 수 있습니다.", "clientResourceTitle": "개인 리소스 관리", "clientResourceDescription": "연결된 클라이언트를 통해서만 접근할 수 있는 리소스를 생성하고 관리하세요.", "privateResourcesBannerTitle": "제로 트러스트 개인 접근", diff --git a/messages/nb-NO.json b/messages/nb-NO.json index ccfd483b4..22ad13c05 100644 --- a/messages/nb-NO.json +++ b/messages/nb-NO.json @@ -200,8 +200,8 @@ "shareErrorSelectResource": "Vennligst velg en ressurs", "proxyResourceTitle": "Administrere offentlige ressurser", "proxyResourceDescription": "Opprett og administrer ressurser som er offentlig tilgjengelige via en nettleser", - "proxyResourcesBannerTitle": "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.", + "publicResourcesBannerTitle": "Nettbasert offentlig tilgang", + "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", "clientResourceDescription": "Opprette og administrere ressurser som bare er tilgjengelige via en tilkoblet klient", "privateResourcesBannerTitle": "Zero-Trust privat tilgang", diff --git a/messages/nl-NL.json b/messages/nl-NL.json index 9f170c853..5c36b2504 100644 --- a/messages/nl-NL.json +++ b/messages/nl-NL.json @@ -200,8 +200,8 @@ "shareErrorSelectResource": "Selecteer een bron", "proxyResourceTitle": "Openbare bronnen beheren", "proxyResourceDescription": "Creëer en beheer bronnen die openbaar toegankelijk zijn via een webbrowser", - "proxyResourcesBannerTitle": "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.", + "publicResourcesBannerTitle": "Webgebaseerde openbare toegang", + "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", "clientResourceDescription": "Creëer en beheer bronnen die alleen toegankelijk zijn via een verbonden client", "privateResourcesBannerTitle": "Zero-Trust Private Access", diff --git a/messages/pl-PL.json b/messages/pl-PL.json index b90cb8f8e..e793145b3 100644 --- a/messages/pl-PL.json +++ b/messages/pl-PL.json @@ -200,8 +200,8 @@ "shareErrorSelectResource": "Wybierz zasób", "proxyResourceTitle": "Zarządzaj zasobami publicznymi", "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", - "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.", + "publicResourcesBannerTitle": "Publiczny dostęp za pośrednictwem sieci Web", + "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", "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", diff --git a/messages/pt-PT.json b/messages/pt-PT.json index 9cade51f2..97c88ff3f 100644 --- a/messages/pt-PT.json +++ b/messages/pt-PT.json @@ -200,8 +200,8 @@ "shareErrorSelectResource": "Por favor, selecione um recurso", "proxyResourceTitle": "Gerenciar Recursos Públicos", "proxyResourceDescription": "Criar e gerenciar recursos que são acessíveis publicamente por meio de um navegador da web", - "proxyResourcesBannerTitle": "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.", + "publicResourcesBannerTitle": "Acesso Público via Web", + "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", "clientResourceDescription": "Criar e gerenciar recursos que só são acessíveis por meio de um cliente conectado", "privateResourcesBannerTitle": "Acesso Privado com Confiança Zero", diff --git a/messages/ru-RU.json b/messages/ru-RU.json index 6b2e8c2a1..702525a2f 100644 --- a/messages/ru-RU.json +++ b/messages/ru-RU.json @@ -200,8 +200,8 @@ "shareErrorSelectResource": "Пожалуйста, выберите ресурс", "proxyResourceTitle": "Управление публичными ресурсами", "proxyResourceDescription": "Создание и управление ресурсами, которые доступны через веб-браузер", - "proxyResourcesBannerTitle": "Общедоступный доступ через веб", - "proxyResourcesBannerDescription": "Общедоступные ресурсы - это прокси-по HTTPS или TCP/UDP, доступные любому пользователю в Интернете через веб-браузер. В отличие от частных ресурсов, они не требуют программного обеспечения на стороне клиента и могут включать политики доступа на основе идентификации и контекста.", + "publicResourcesBannerTitle": "Общедоступный доступ через веб", + "publicResourcesBannerDescription": "Общедоступные ресурсы - это прокси-по HTTPS или TCP/UDP, доступные любому пользователю в Интернете через веб-браузер. В отличие от частных ресурсов, они не требуют программного обеспечения на стороне клиента и могут включать политики доступа на основе идентификации и контекста.", "clientResourceTitle": "Управление приватными ресурсами", "clientResourceDescription": "Создание и управление ресурсами, которые доступны только через подключенный клиент", "privateResourcesBannerTitle": "Частный доступ с нулевым доверием", diff --git a/messages/tr-TR.json b/messages/tr-TR.json index 1eb3adb03..236adf4d4 100644 --- a/messages/tr-TR.json +++ b/messages/tr-TR.json @@ -200,8 +200,8 @@ "shareErrorSelectResource": "Lütfen bir kaynak seçin", "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", - "proxyResourcesBannerTitle": "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.", + "publicResourcesBannerTitle": "Web Tabanlı Genel Erişim", + "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", "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", diff --git a/messages/zh-CN.json b/messages/zh-CN.json index 637653c2a..cabee95be 100644 --- a/messages/zh-CN.json +++ b/messages/zh-CN.json @@ -200,8 +200,8 @@ "shareErrorSelectResource": "请选择一个资源", "proxyResourceTitle": "管理公共资源", "proxyResourceDescription": "创建和管理可通过 Web 浏览器公开访问的资源", - "proxyResourcesBannerTitle": "基于Web的公共访问", - "proxyResourcesBannerDescription": "公共资源是可以通过网络浏览器在互联网上任何人访问的HTTPS或TCP/UDP代理。与私人资源不同,它们不需要客户端软件,并且可以包含身份和上下文感知访问策略。", + "publicResourcesBannerTitle": "基于Web的公共访问", + "publicResourcesBannerDescription": "公共资源是可以通过网络浏览器在互联网上任何人访问的HTTPS或TCP/UDP代理。与私人资源不同,它们不需要客户端软件,并且可以包含身份和上下文感知访问策略。", "clientResourceTitle": "管理私有资源", "clientResourceDescription": "创建和管理只能通过连接客户端访问的资源", "privateResourcesBannerTitle": "零信任的私人访问", diff --git a/messages/zh-TW.json b/messages/zh-TW.json index 532962593..6eb103d3a 100644 --- a/messages/zh-TW.json +++ b/messages/zh-TW.json @@ -152,8 +152,8 @@ "shareErrorSelectResource": "請選擇一個資源", "proxyResourceTitle": "管理公開資源", "proxyResourceDescription": "建立和管理可透過網頁瀏覽器公開存取的資源", - "proxyResourcesBannerTitle": "基於網頁的公開存取", - "proxyResourcesBannerDescription": "公開資源是任何人都可以透過網頁瀏覽器存取的 HTTPS 或 TCP/UDP 代理。與私有資源不同,它們不需要客戶端軟體,並且可以包含基於身份和情境感知的存取策略。", + "publicResourcesBannerTitle": "基於網頁的公開存取", + "publicResourcesBannerDescription": "公開資源是任何人都可以透過網頁瀏覽器存取的 HTTPS 或 TCP/UDP 代理。與私有資源不同,它們不需要客戶端軟體,並且可以包含基於身份和情境感知的存取策略。", "clientResourceTitle": "管理私有資源", "clientResourceDescription": "建立和管理只能透過已連接的客戶端存取的資源", "privateResourcesBannerTitle": "零信任私有存取", diff --git a/server/db/pg/driver.ts b/server/db/pg/driver.ts index 9f6901dda..9e07a6234 100644 --- a/server/db/pg/driver.ts +++ b/server/db/pg/driver.ts @@ -87,7 +87,7 @@ function createDb() { export const db = createDb(); export default db; -export const primaryDb = db.$primary as typeof db; // is this typeof a problem - techincally they are different types +export const primaryDb = db.$primary as typeof db; // is this typeof a problem - technically they are different types export type Transaction = Parameters< Parameters<(typeof db)["transaction"]>[0] >[0]; diff --git a/server/db/pg/logsDriver.ts b/server/db/pg/logsDriver.ts index 146b8fb2f..2c34136de 100644 --- a/server/db/pg/logsDriver.ts +++ b/server/db/pg/logsDriver.ts @@ -2,7 +2,7 @@ import { drizzle as DrizzlePostgres } from "drizzle-orm/node-postgres"; import { readConfigFile } from "@server/lib/readConfigFile"; import { withReplicas } from "drizzle-orm/pg-core"; import { build } from "@server/build"; -import { db as mainDb, primaryDb as mainPrimaryDb } from "./driver"; +import { db as mainDb } from "./driver"; import { createPool } from "./poolConfig"; function createLogsDb() { @@ -63,8 +63,7 @@ function createLogsDb() { }) ); } else { - const maxReplicaConnections = - poolConfig?.max_replica_connections || 20; + const maxReplicaConnections = poolConfig?.max_replica_connections || 20; for (const conn of replicaConnections) { const replicaPool = createPool( conn.connection_string, @@ -91,4 +90,4 @@ function createLogsDb() { export const logsDb = createLogsDb(); export default logsDb; -export const primaryLogsDb = logsDb.$primary; \ No newline at end of file +export const primaryLogsDb = logsDb.$primary; diff --git a/server/db/pg/poolConfig.ts b/server/db/pg/poolConfig.ts index f753121c1..b893c2159 100644 --- a/server/db/pg/poolConfig.ts +++ b/server/db/pg/poolConfig.ts @@ -1,5 +1,4 @@ import { Pool, PoolConfig } from "pg"; -import logger from "@server/logger"; export function createPoolConfig( connectionString: string, @@ -27,7 +26,7 @@ export function attachPoolErrorHandlers(pool: Pool, label: string): void { pool.on("error", (err) => { // This catches errors on idle clients in the pool. Without this // handler an unexpected disconnect would crash the process. - logger.error( + console.error( `Unexpected error on idle ${label} database client: ${err.message}` ); }); @@ -36,7 +35,7 @@ export function attachPoolErrorHandlers(pool: Pool, label: string): void { // Set a statement timeout on every new connection so a single slow // query can't block the pool forever client.query("SET statement_timeout = '30s'").catch((err: Error) => { - logger.warn( + console.warn( `Failed to set statement_timeout on ${label} client: ${err.message}` ); }); @@ -60,4 +59,4 @@ export function createPool( ); attachPoolErrorHandlers(pool, label); return pool; -} \ No newline at end of file +} diff --git a/server/db/pg/schema/privateSchema.ts b/server/db/pg/schema/privateSchema.ts index 5040808a9..229fc9ff0 100644 --- a/server/db/pg/schema/privateSchema.ts +++ b/server/db/pg/schema/privateSchema.ts @@ -580,24 +580,6 @@ export const trialNotifications = pgTable("trialNotifications", { 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; export type Limit = InferSelectModel; export type Account = InferSelectModel; @@ -645,6 +627,3 @@ export type AlertEmailRecipients = InferSelectModel< >; export type AlertWebhookActions = InferSelectModel; export type TrialNotification = InferSelectModel; -export type BrowserGatewayTarget = InferSelectModel< - typeof browserGatewayTarget ->; diff --git a/server/db/pg/schema/schema.ts b/server/db/pg/schema/schema.ts index 6ca067ade..025bdf923 100644 --- a/server/db/pg/schema/schema.ts +++ b/server/db/pg/schema/schema.ts @@ -147,12 +147,10 @@ export const resources = pgTable("resources", { }), ssl: boolean("ssl").notNull().default(false), blockAccess: boolean("blockAccess").notNull().default(false), - sso: boolean("sso").notNull().default(true), proxyPort: integer("proxyPort"), - emailWhitelistEnabled: boolean("emailWhitelistEnabled") - .notNull() - .default(false), - applyRules: boolean("applyRules").notNull().default(false), + sso: boolean("sso"), + emailWhitelistEnabled: boolean("emailWhitelistEnabled"), + applyRules: boolean("applyRules"), enabled: boolean("enabled").notNull().default(true), stickySession: boolean("stickySession").notNull().default(false), tlsServerName: varchar("tlsServerName"), @@ -290,7 +288,12 @@ export const targets = pgTable("targets", { pathMatchType: text("pathMatchType"), // exact, prefix, regex rewritePath: text("rewritePath"), // if set, rewrites the path to this value before sending to the target rewritePathType: text("rewritePathType"), // exact, prefix, regex, stripPrefix - priority: integer("priority").notNull().default(100) + 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", { @@ -886,7 +889,9 @@ export const resourcePolicyRules = pgTable("resourcePolicyRules", { enabled: boolean("enabled").notNull().default(true), priority: integer("priority").notNull(), action: varchar("action").$type<"ACCEPT" | "DROP" | "PASS">().notNull(), - match: varchar("match").$type<"CIDR" | "PATH" | "IP">().notNull(), + match: varchar("match") + .$type<"CIDR" | "PATH" | "IP" | "COUNTRY" | "ASN" | "REGION">() + .notNull(), value: varchar("value").notNull() }); diff --git a/server/db/queries/verifySessionQueries.ts b/server/db/queries/verifySessionQueries.ts index 302671b0d..0a22e8df3 100644 --- a/server/db/queries/verifySessionQueries.ts +++ b/server/db/queries/verifySessionQueries.ts @@ -45,9 +45,9 @@ export type ResourceWithAuth = { password: ResourcePassword | ResourcePolicyPassword | null; headerAuth: ResourceHeaderAuth | ResourcePolicyHeaderAuth | null; headerAuthExtendedCompatibility: ResourceHeaderAuthExtendedCompatibility | null; - applyRules: boolean; - sso: boolean; - emailWhitelistEnabled: boolean; + applyRules: boolean | null; + sso: boolean | null; + emailWhitelistEnabled: boolean | null; org: Org; }; diff --git a/server/db/sqlite/schema/privateSchema.ts b/server/db/sqlite/schema/privateSchema.ts index b235d26d5..ae7360780 100644 --- a/server/db/sqlite/schema/privateSchema.ts +++ b/server/db/sqlite/schema/privateSchema.ts @@ -588,26 +588,6 @@ export const trialNotifications = sqliteTable("trialNotifications", { 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; export type Limit = InferSelectModel; export type Account = InferSelectModel; @@ -647,6 +627,3 @@ export type AlertEmailAction = InferSelectModel; export type AlertEmailRecipient = InferSelectModel; export type AlertWebhookAction = InferSelectModel; export type TrialNotification = InferSelectModel; -export type BrowserGatewayTarget = InferSelectModel< - typeof browserGatewayTarget ->; diff --git a/server/db/sqlite/schema/schema.ts b/server/db/sqlite/schema/schema.ts index 4291df6b0..0c4a143f5 100644 --- a/server/db/sqlite/schema/schema.ts +++ b/server/db/sqlite/schema/schema.ts @@ -165,14 +165,12 @@ export const resources = sqliteTable("resources", { blockAccess: integer("blockAccess", { mode: "boolean" }) .notNull() .default(false), - sso: integer("sso", { mode: "boolean" }).notNull().default(true), proxyPort: integer("proxyPort"), - emailWhitelistEnabled: integer("emailWhitelistEnabled", { mode: "boolean" }) - .notNull() - .default(false), - applyRules: integer("applyRules", { mode: "boolean" }) - .notNull() - .default(false), + sso: integer("sso", { mode: "boolean" }), + emailWhitelistEnabled: integer("emailWhitelistEnabled", { + mode: "boolean" + }), + applyRules: integer("applyRules", { mode: "boolean" }), enabled: integer("enabled", { mode: "boolean" }).notNull().default(true), stickySession: integer("stickySession", { mode: "boolean" }) .notNull() @@ -322,7 +320,12 @@ export const targets = sqliteTable("targets", { pathMatchType: text("pathMatchType"), // exact, prefix, regex rewritePath: text("rewritePath"), // if set, rewrites the path to this value before sending to the target rewritePathType: text("rewritePathType"), // exact, prefix, regex, stripPrefix - priority: integer("priority").notNull().default(100) + 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", { @@ -1248,7 +1251,9 @@ export const resourcePolicyRules = sqliteTable("resourcePolicyRules", { enabled: integer("enabled", { mode: "boolean" }).notNull().default(true), priority: integer("priority").notNull(), action: text("action").$type<"ACCEPT" | "DROP" | "PASS">().notNull(), - match: text("match").$type<"CIDR" | "PATH" | "IP">().notNull(), + match: text("match") + .$type<"CIDR" | "PATH" | "IP" | "COUNTRY" | "ASN" | "REGION">() + .notNull(), value: text("value").notNull() }); diff --git a/server/integrationApiServer.ts b/server/integrationApiServer.ts index 5c6d50a85..104e02d5c 100644 --- a/server/integrationApiServer.ts +++ b/server/integrationApiServer.ts @@ -157,7 +157,9 @@ function getOpenApiDocumentation() { content: { "application/json": { schema: z.object({ - data: z.unknown().nullable(), + data: z + .record(z.string(), z.any()) + .nullable(), success: z.boolean(), error: z.boolean(), message: z.string(), diff --git a/server/lib/billing/tierMatrix.ts b/server/lib/billing/tierMatrix.ts index 45434aac4..f0e6dc95a 100644 --- a/server/lib/billing/tierMatrix.ts +++ b/server/lib/billing/tierMatrix.ts @@ -31,7 +31,7 @@ export enum TierFeature { } export const tierMatrix: Record = { - [TierFeature.Labels]: ["tier2", "tier3", "enterprise"], + [TierFeature.Labels]: ["tier1", "tier2", "tier3", "enterprise"], [TierFeature.OrgOidc]: ["tier1", "tier2", "tier3", "enterprise"], [TierFeature.LoginPageDomain]: ["tier1", "tier2", "tier3", "enterprise"], [TierFeature.DeviceApprovals]: ["tier1", "tier3", "enterprise"], @@ -71,16 +71,6 @@ export const tierMatrix: Record = { [TierFeature.WildcardSubdomain]: ["tier1", "tier2", "tier3", "enterprise"], [TierFeature.NewtAutoUpdate]: ["tier1", "tier2", "tier3", "enterprise"], [TierFeature.ResourcePolicies]: ["tier3", "enterprise"], - [TierFeature.AdvancedPublicResources]: [ - "tier1", - "tier2", - "tier3", - "enterprise" - ], - [TierFeature.AdvancedPrivateResources]: [ - "tier1", - "tier2", - "tier3", - "enterprise" - ] + [TierFeature.AdvancedPublicResources]: ["tier3", "enterprise"], + [TierFeature.AdvancedPrivateResources]: ["tier3", "enterprise"] }; diff --git a/server/lib/blueprints/applyBlueprint.ts b/server/lib/blueprints/applyBlueprint.ts index 12d18f653..f2bb9b0c8 100644 --- a/server/lib/blueprints/applyBlueprint.ts +++ b/server/lib/blueprints/applyBlueprint.ts @@ -10,16 +10,23 @@ import { clientSiteResources } from "@server/db"; import { Config, ConfigSchema } from "./types"; -import { ProxyResourcesResults, updateProxyResources } from "./proxyResources"; +import { + PublicResourcesResults, + updatePublicResources +} from "./publicResources"; import { fromError } from "zod-validation-error"; import logger from "@server/logger"; import { sites } from "@server/db"; 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 { ClientResourcesResults, - updateClientResources -} from "./clientResources"; + updatePrivateResources +} from "./privateResources"; +import { updateResourcePolicies } from "./resourcePolicies"; import { BlueprintSource } from "@server/routers/blueprints/types"; import { stringify as stringifyYaml } from "yaml"; import { generateName } from "@server/db/names"; @@ -53,16 +60,18 @@ export async function applyBlueprint({ let error: any | null = null; try { - let proxyResourcesResults: ProxyResourcesResults = []; + let proxyResourcesResults: PublicResourcesResults = []; let clientResourcesResults: ClientResourcesResults = []; await db.transaction(async (trx) => { - proxyResourcesResults = await updateProxyResources( + await updateResourcePolicies(orgId, config, trx); + + proxyResourcesResults = await updatePublicResources( orgId, config, trx, siteId ); - clientResourcesResults = await updateClientResources( + clientResourcesResults = await updatePrivateResources( orgId, config, trx, @@ -101,13 +110,27 @@ export async function applyBlueprint({ (hc) => hc.targetId === target.targetId ); - await addProxyTargets( - site.newt.newtId, - [target], - matchingHealthcheck ? [matchingHealthcheck] : [], - result.proxyResource.mode === "udp" ? "udp" : "tcp", - site.newt.version - ); + if (["http", "tcp", "udp"].includes(target.mode)) { + await addProxyTargets( + site.newt.newtId, + [target], + matchingHealthcheck + ? [matchingHealthcheck] + : [], + result.proxyResource.mode === "udp" + ? "udp" + : "tcp", + site.newt.version + ); + } else if ( + ["ssh", "rdp", "vnc"].includes(target.mode) + ) { + await sendBrowserGatewayTargets( + site.newt.newtId, + [target], + site.newt.version + ); + } } } } diff --git a/server/lib/blueprints/clientResources.ts b/server/lib/blueprints/privateResources.ts similarity index 94% rename from server/lib/blueprints/clientResources.ts rename to server/lib/blueprints/privateResources.ts index 44cd9956b..3e6a784e0 100644 --- a/server/lib/blueprints/clientResources.ts +++ b/server/lib/blueprints/privateResources.ts @@ -23,6 +23,8 @@ import logger from "@server/logger"; import { defaultRoleAllowedActions } from "@server/routers/role/createRole"; import { getNextAvailableAliasAddress } from "../ip"; import { createCertificate } from "#dynamic/routers/certificates/createCertificate"; +import { isLicensedOrSubscribed } from "#dynamic/lib/isLicencedOrSubscribed"; +import { tierMatrix } from "../billing/tierMatrix"; async function getDomainForSiteResource( siteResourceId: number | undefined, @@ -103,7 +105,7 @@ export type ClientResourcesResults = { oldSites: { siteId: number }[]; }[]; -export async function updateClientResources( +export async function updatePrivateResources( orgId: string, config: Config, trx: Transaction, @@ -114,6 +116,30 @@ export async function updateClientResources( for (const [resourceNiceId, resourceData] of Object.entries( 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 .select() .from(siteResources) @@ -366,7 +392,9 @@ export async function updateClientResources( })) ); 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); @@ -510,7 +538,9 @@ export async function updateClientResources( })) ); 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); diff --git a/server/lib/blueprints/proxyResources.ts b/server/lib/blueprints/publicResources.ts similarity index 94% rename from server/lib/blueprints/proxyResources.ts rename to server/lib/blueprints/publicResources.ts index 28bc1c90d..b60970310 100644 --- a/server/lib/blueprints/proxyResources.ts +++ b/server/lib/blueprints/publicResources.ts @@ -47,20 +47,24 @@ import { isLicensedOrSubscribed } from "#dynamic/lib/isLicencedOrSubscribed"; import { fireHealthCheckUnknownAlert } from "@server/lib/alerts"; import { tierMatrix } from "../billing/tierMatrix"; 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; targetsToUpdate: Target[]; healthchecksToUpdate: TargetHealthCheck[]; }[]; -export async function updateProxyResources( +export async function updatePublicResources( orgId: string, config: Config, trx: Transaction, siteId?: number -): Promise { - const results: ProxyResourcesResults = []; +): Promise { + const results: PublicResourcesResults = []; for (const [resourceNiceId, resourceData] of Object.entries( config["proxy-resources"] @@ -79,7 +83,7 @@ export async function updateProxyResources( if (targetSiteId) { // Look up site by niceId [site] = await trx - .select({ siteId: sites.siteId }) + .select({ siteId: sites.siteId, type: sites.type }) .from(sites) .where( and( @@ -91,7 +95,7 @@ export async function updateProxyResources( } else if (siteId) { // Use the provided siteId directly, but verify it belongs to the org [site] = await trx - .select({ siteId: sites.siteId }) + .select({ siteId: sites.siteId, type: sites.type }) .from(sites) .where( and(eq(sites.siteId, siteId), eq(sites.orgId, orgId)) @@ -118,6 +122,15 @@ export async function updateProxyResources( 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 const [newTarget] = await trx .insert(targets) @@ -125,10 +138,12 @@ export async function updateProxyResources( resourceId: resourceId, siteId: site.siteId, ip: targetData.hostname, + mode: resourceData.mode as Target["mode"], method: targetData.method, port: targetData.port, enabled: targetData.enabled, internalPort: internalPortToCreate, + authToken: authToken, path: targetData.path, pathMatchType: targetData["path-match"], rewritePath: @@ -222,17 +237,59 @@ export async function updateProxyResources( 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) { let domain; if ( ["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( existingResource.resourceId, resourceData["full-domain"]!, orgId, trx ); + + await enforceDomainNamespacePaywall( + orgId, + domain.domainId, + trx + ); } // 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"] ?? 1) : 1, + pamMode: + resourceData["auth-daemon"]?.pam || + "passthrough", + authDaemonMode: + resourceData["auth-daemon"]?.mode || "native", + authDaemonPort: + resourceData["auth-daemon"]?.port || 22123, resourcePolicyId: null, defaultResourcePolicyId: inlinePolicyId }) @@ -664,7 +728,8 @@ export async function updateProxyResources( ? "/" : undefined), rewritePathType: targetData["rewrite-match"], - priority: targetData.priority + priority: targetData.priority, + mode: resourceData.mode }) .where(eq(targets.targetId, existingTarget.targetId)) .returning(); @@ -906,12 +971,30 @@ export async function updateProxyResources( if ( ["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( undefined, resourceData["full-domain"]!, orgId, trx ); + + await enforceDomainNamespacePaywall( + orgId, + domain.domainId, + trx + ); } const isLicensed = await isLicensedOrSubscribed( @@ -1384,17 +1467,6 @@ async function syncWhitelistUsers( .where(eq(resourceWhitelist.resourceId, resourceId)); for (const email of whitelistUsers) { - const [user] = await trx - .select() - .from(users) - .innerJoin(userOrgs, eq(users.userId, userOrgs.userId)) - .where(and(eq(users.email, email), eq(userOrgs.orgId, orgId))) - .limit(1); - - if (!user) { - throw new Error(`User not found: ${email} in org ${orgId}`); - } - const existingWhitelistEntry = existingWhitelist.find( (w) => w.email === email ); @@ -1866,6 +1938,37 @@ function checkIfTargetChanged( 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( resourceId: number | undefined, fullDomain: string, diff --git a/server/lib/blueprints/resourcePolicies.ts b/server/lib/blueprints/resourcePolicies.ts new file mode 100644 index 000000000..f8d8d1269 --- /dev/null +++ b/server/lib/blueprints/resourcePolicies.ts @@ -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 { + 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)); + } + } +} diff --git a/server/lib/blueprints/types.ts b/server/lib/blueprints/types.ts index 454d83aa9..5495a2d8e 100644 --- a/server/lib/blueprints/types.ts +++ b/server/lib/blueprints/types.ts @@ -1,8 +1,23 @@ import { z } from "zod"; +import { existsSync } from "node:fs"; import { portRangeStringSchema } from "@server/lib/ip"; import { MaintenanceSchema } from "#dynamic/lib/blueprints/MaintenanceSchema"; import { isValidRegionId } from "@server/db/regions"; import { wildcardSubdomainSchema } from "@server/lib/schemas"; +import config from "@server/lib/config"; + +const maxmindDbPath = config.getRawConfig().server.maxmind_db_path; +const maxmindAsnPath = config.getRawConfig().server.maxmind_asn_path; + +const hasMaxmindCountryDb = + typeof maxmindDbPath === "string" && + maxmindDbPath.length > 0 && + existsSync(maxmindDbPath); + +const hasMaxmindAsnDb = + typeof maxmindAsnPath === "string" && + maxmindAsnPath.length > 0 && + existsSync(maxmindAsnPath); export const SiteSchema = z.object({ name: z.string().min(1).max(100), @@ -83,7 +98,8 @@ export const RuleSchema = z action: z.enum(["allow", "deny", "pass"]), match: z.enum(["cidr", "path", "ip", "country", "asn", "region"]), value: z.coerce.string(), - priority: z.int().optional() + priority: z.int().optional(), + enabled: z.boolean().optional().default(true) }) .refine( (rule) => { @@ -116,6 +132,9 @@ export const RuleSchema = z .refine( (rule) => { if (rule.match === "country") { + if (!hasMaxmindCountryDb) { + return false; + } // Check if it's a valid 2-letter country code or "ALL" return /^[A-Z]{2}$/.test(rule.value) || rule.value === "ALL"; } @@ -124,12 +143,15 @@ export const RuleSchema = z { path: ["value"], message: - "Value must be a 2-letter country code or 'ALL' when match is 'country'" + "Country rules require a valid existing server.maxmind_db_path and value must be a 2-letter country code or 'ALL'" } ) .refine( (rule) => { if (rule.match === "asn") { + if (!hasMaxmindCountryDb || !hasMaxmindAsnDb) { + return false; + } // Check if it's either AS format or "ALL" const asNumberPattern = /^AS\d+$/i; return asNumberPattern.test(rule.value) || rule.value === "ALL"; @@ -139,7 +161,7 @@ export const RuleSchema = z { path: ["value"], message: - "Value must be 'AS' format or 'ALL' when match is 'asn'" + "ASN rules require valid existing server.maxmind_db_path and server.maxmind_asn_path, and value must be 'AS' format or 'ALL'" } ) .refine( @@ -267,8 +289,37 @@ export const PublicResourceSchema = z return true; } - // If protocol/mode is http, it must have a full-domain - if ((resource.mode ?? resource.protocol) === "http") { + const effectiveProtocol = resource.mode ?? resource.protocol; + 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 ( resource["full-domain"] !== undefined && resource["full-domain"].length > 0 @@ -278,7 +329,7 @@ export const PublicResourceSchema = z }, { 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( @@ -505,7 +556,90 @@ export const PrivateResourceSchema = z { 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(); + 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; // Schema for the entire configuration object export const ConfigSchema = z @@ -526,6 +660,10 @@ export const ConfigSchema = z .record(z.string(), PrivateResourceSchema) .optional() .prefault({}), + "public-policies": z + .record(z.string(), ResourcePolicySchema) + .optional() + .prefault({}), sites: z.record(z.string(), SiteSchema).optional().prefault({}) }) .transform((data) => { @@ -556,6 +694,10 @@ export const ConfigSchema = z string, z.infer >; + "public-policies": Record< + string, + z.infer + >; sites: Record>; }; }) @@ -695,3 +837,4 @@ export type Site = z.infer; export type Target = z.infer; export type Resource = z.infer; export type Config = z.infer; +export type BlueprintResourcePolicy = z.infer; diff --git a/server/lib/validators.ts b/server/lib/validators.ts index b1efe8b38..c179d3c91 100644 --- a/server/lib/validators.ts +++ b/server/lib/validators.ts @@ -1,5 +1,7 @@ import z from "zod"; import ipaddr from "ipaddr.js"; +import { COUNTRIES } from "@server/db/countries"; +import { isValidRegionId } from "@server/db/regions"; export function isValidCIDR(cidr: string): boolean { return ( @@ -67,6 +69,45 @@ export function isValidUrlGlobPattern(pattern: string): boolean { 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) { if (!url) return true; // the link is optional in the schema so if it's empty it's valid var pattern = new RegExp( diff --git a/server/middlewares/verifyResourceAccess.ts b/server/middlewares/verifyResourceAccess.ts index ba49f02e3..f790a481a 100644 --- a/server/middlewares/verifyResourceAccess.ts +++ b/server/middlewares/verifyResourceAccess.ts @@ -1,11 +1,15 @@ import { Request, Response, NextFunction } from "express"; import { db, Resource } from "@server/db"; -import { resources, userOrgs, userResources, roleResources } from "@server/db"; -import { and, eq, inArray } from "drizzle-orm"; +import { resources, userOrgs } from "@server/db"; +import { and, eq } from "drizzle-orm"; import createHttpError from "http-errors"; import HttpCode from "@server/types/HttpCode"; import { checkOrgAccessPolicy } from "#dynamic/lib/checkOrgAccessPolicy"; import { getUserOrgRoleIds } from "@server/lib/userOrgRoles"; +import { + getRoleResourceAccess, + getUserResourceAccess +} from "@server/db/queries/verifySessionQueries"; export async function verifyResourceAccess( req: Request, @@ -116,37 +120,22 @@ export async function verifyResourceAccess( const roleResourceAccess = (req.userOrgRoleIds?.length ?? 0) > 0 - ? await db - .select() - .from(roleResources) - .where( - and( - eq(roleResources.resourceId, resource.resourceId), - inArray( - roleResources.roleId, - req.userOrgRoleIds! - ) - ) - ) - .limit(1) - : []; + ? await getRoleResourceAccess( + resource.resourceId, + req.userOrgRoleIds! + ) + : null; - if (roleResourceAccess.length > 0) { + if (roleResourceAccess) { return next(); } - const userResourceAccess = await db - .select() - .from(userResources) - .where( - and( - eq(userResources.userId, userId), - eq(userResources.resourceId, resource.resourceId) - ) - ) - .limit(1); + const userResourceAccess = await getUserResourceAccess( + userId, + resource.resourceId + ); - if (userResourceAccess.length > 0) { + if (userResourceAccess) { return next(); } diff --git a/server/private/lib/readConfigFile.ts b/server/private/lib/readConfigFile.ts index 087143007..565a0151a 100644 --- a/server/private/lib/readConfigFile.ts +++ b/server/private/lib/readConfigFile.ts @@ -109,7 +109,11 @@ export const privateConfigSchema = z enable_redis: z.boolean().optional().default(false), use_pangolin_dns: z.boolean().optional().default(false), 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() .prefault({}), diff --git a/server/private/lib/traefik/getTraefikConfig.ts b/server/private/lib/traefik/getTraefikConfig.ts index a46033196..e81715d3b 100644 --- a/server/private/lib/traefik/getTraefikConfig.ts +++ b/server/private/lib/traefik/getTraefikConfig.ts @@ -12,7 +12,6 @@ */ import { - browserGatewayTarget, certificates, db, domainNamespaces, @@ -172,8 +171,15 @@ export async function getTraefikConfig( ), inArray(sites.type, siteTypes), allowRawResources - ? inArray(resources.mode, ["http", "udp", "tcp"]) // allow all three - : eq(resources.mode, "http") + ? inArray(resources.mode, [ + "http", + "udp", + "tcp", + "vnc", + "ssh", + "rdp" + ]) // allow all three + : inArray(resources.mode, ["http", "vnc", "ssh", "rdp"]) ) ) .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 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 resourceName = sanitize(row.resourceName) || ""; 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; if (filterOutNamespaceDomains && row.domainNamespaceId) { - return; + continue; } // Create a unique key combining resourceId, path config, and rewrite config @@ -218,7 +227,7 @@ export async function getTraefikConfig( logger.debug( `Invalid path rewrite configuration for resource ${resourceId}: ${validation.error}` ); - return; + continue; } resourcesMap.set(mapKey, { @@ -275,7 +284,7 @@ export async function getTraefikConfig( online: row.siteOnline } }); - }); + } // Group browser gateway targets by resource type BrowserGatewayResourceEntry = { @@ -295,13 +304,12 @@ export async function getTraefikConfig( maintenanceMessage: string | null; maintenanceEstimatedTime: string | null; targets: { - browserGatewayTargetId: number; + targetId: number; bgType: string; siteId: number; siteType: string; siteOnline: boolean | null; subnet: string | null; - siteExitNodeId: number | null; }[]; }; const browserGatewayResourcesMap = new Map< @@ -310,66 +318,10 @@ export async function getTraefikConfig( >(); if (allowBrowserGatewayResources) { - // Query browser gateway targets for this exit node - const browserGatewayRows = await db - .select({ - // Resource fields - resourceId: resources.resourceId, - resourceName: resources.name, - fullDomain: resources.fullDomain, - ssl: resources.ssl, - subdomain: resources.subdomain, - domainId: resources.domainId, - enabled: resources.enabled, - wildcard: resources.wildcard, - domainCertResolver: domains.certResolver, - preferWildcardCert: domains.preferWildcardCert, - domainNamespaceId: domainNamespaces.domainNamespaceId, - // 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) { + for (const row of resourcesWithTargetsAndSites) { + if (!["ssh", "vnc", "rdp"].includes(row.mode)) { + continue; + } if (filterOutNamespaceDomains && row.domainNamespaceId) { continue; } @@ -394,13 +346,12 @@ export async function getTraefikConfig( }); } browserGatewayResourcesMap.get(row.resourceId)!.targets.push({ - browserGatewayTargetId: row.browserGatewayTargetId, - bgType: row.bgType, + targetId: row.targetId, + bgType: row.mode, siteId: row.siteId, siteType: row.siteType, siteOnline: row.siteOnline, - subnet: row.subnet, - siteExitNodeId: row.siteExitNodeId + subnet: row.subnet }); } } @@ -410,7 +361,11 @@ export async function getTraefikConfig( fullDomain: string | null; 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 // Query siteResources in HTTP mode with SSL enabled and aliases - cert generation / HTTPS edge siteResourcesWithFullDomain = await db diff --git a/server/private/routers/alertRule/createAlertRule.ts b/server/private/routers/alertRule/createAlertRule.ts index f4a11ad3d..7a46c84ff 100644 --- a/server/private/routers/alertRule/createAlertRule.ts +++ b/server/private/routers/alertRule/createAlertRule.ts @@ -208,7 +208,7 @@ registry.registerPath({ content: { "application/json": { schema: z.object({ - data: z.unknown().nullable(), + data: z.record(z.string(), z.any()).nullable(), success: z.boolean(), error: z.boolean(), message: z.string(), diff --git a/server/private/routers/alertRule/deleteAlertRule.ts b/server/private/routers/alertRule/deleteAlertRule.ts index b475bb6c3..0439a6622 100644 --- a/server/private/routers/alertRule/deleteAlertRule.ts +++ b/server/private/routers/alertRule/deleteAlertRule.ts @@ -44,7 +44,7 @@ registry.registerPath({ content: { "application/json": { schema: z.object({ - data: z.unknown().nullable(), + data: z.record(z.string(), z.any()).nullable(), success: z.boolean(), error: z.boolean(), message: z.string(), @@ -112,4 +112,4 @@ export async function deleteAlertRule( createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred") ); } -} \ No newline at end of file +} diff --git a/server/private/routers/alertRule/getAlertRule.ts b/server/private/routers/alertRule/getAlertRule.ts index dde9093fb..fcbd44f11 100644 --- a/server/private/routers/alertRule/getAlertRule.ts +++ b/server/private/routers/alertRule/getAlertRule.ts @@ -32,7 +32,10 @@ import { OpenAPITags, registry } from "@server/openApi"; import { and, eq } from "drizzle-orm"; import { decrypt } from "@server/lib/crypto"; import config from "@server/lib/config"; -import { GetAlertRuleResponse, WebhookAlertConfig } from "@server/routers/alertRule/types"; +import { + GetAlertRuleResponse, + WebhookAlertConfig +} from "@server/routers/alertRule/types"; const paramsSchema = z .object({ @@ -55,7 +58,7 @@ registry.registerPath({ content: { "application/json": { schema: z.object({ - data: z.unknown().nullable(), + data: z.record(z.string(), z.any()).nullable(), success: z.boolean(), error: z.boolean(), message: z.string(), diff --git a/server/private/routers/alertRule/listAlertRules.ts b/server/private/routers/alertRule/listAlertRules.ts index 3931da44c..0fc720d66 100644 --- a/server/private/routers/alertRule/listAlertRules.ts +++ b/server/private/routers/alertRule/listAlertRules.ts @@ -101,7 +101,7 @@ registry.registerPath({ content: { "application/json": { schema: z.object({ - data: z.unknown().nullable(), + data: z.record(z.string(), z.any()).nullable(), success: z.boolean(), error: z.boolean(), message: z.string(), diff --git a/server/private/routers/auditLogs/exportAccessAuditLog.ts b/server/private/routers/auditLogs/exportAccessAuditLog.ts index b83673b33..5c6240b2e 100644 --- a/server/private/routers/auditLogs/exportAccessAuditLog.ts +++ b/server/private/routers/auditLogs/exportAccessAuditLog.ts @@ -44,7 +44,7 @@ registry.registerPath({ content: { "application/json": { schema: z.object({ - data: z.unknown().nullable(), + data: z.record(z.string(), z.any()).nullable(), success: z.boolean(), error: z.boolean(), message: z.string(), diff --git a/server/private/routers/auditLogs/exportActionAuditLog.ts b/server/private/routers/auditLogs/exportActionAuditLog.ts index 0d707c41e..112f03fb4 100644 --- a/server/private/routers/auditLogs/exportActionAuditLog.ts +++ b/server/private/routers/auditLogs/exportActionAuditLog.ts @@ -44,7 +44,7 @@ registry.registerPath({ content: { "application/json": { schema: z.object({ - data: z.unknown().nullable(), + data: z.record(z.string(), z.any()).nullable(), success: z.boolean(), error: z.boolean(), message: z.string(), diff --git a/server/private/routers/auditLogs/exportConnectionAuditLog.ts b/server/private/routers/auditLogs/exportConnectionAuditLog.ts index 1115d23ad..a20d4052a 100644 --- a/server/private/routers/auditLogs/exportConnectionAuditLog.ts +++ b/server/private/routers/auditLogs/exportConnectionAuditLog.ts @@ -44,7 +44,7 @@ registry.registerPath({ content: { "application/json": { schema: z.object({ - data: z.unknown().nullable(), + data: z.record(z.string(), z.any()).nullable(), success: z.boolean(), error: z.boolean(), message: z.string(), @@ -72,7 +72,9 @@ export async function exportConnectionAuditLogs( ); } - const parsedParams = queryConnectionAuditLogsParams.safeParse(req.params); + const parsedParams = queryConnectionAuditLogsParams.safeParse( + req.params + ); if (!parsedParams.success) { return next( createHttpError( @@ -112,4 +114,4 @@ export async function exportConnectionAuditLogs( createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred") ); } -} \ No newline at end of file +} diff --git a/server/private/routers/auditLogs/queryAccessAuditLog.ts b/server/private/routers/auditLogs/queryAccessAuditLog.ts index 570621216..0feca4154 100644 --- a/server/private/routers/auditLogs/queryAccessAuditLog.ts +++ b/server/private/routers/auditLogs/queryAccessAuditLog.ts @@ -11,7 +11,14 @@ * This file is not licensed under the AGPLv3. */ -import { accessAuditLog, logsDb, resources, siteResources, db, primaryDb } from "@server/db"; +import { + accessAuditLog, + logsDb, + resources, + siteResources, + db, + primaryDb +} from "@server/db"; import { registry } from "@server/openApi"; import { NextFunction } from "express"; import { Request, Response } from "express"; @@ -150,21 +157,30 @@ export function queryAccess(data: Q) { .orderBy(desc(accessAuditLog.timestamp), desc(accessAuditLog.id)); } -async function enrichWithResourceDetails(logs: Awaited>) { +async function enrichWithResourceDetails( + logs: Awaited> +) { const resourceIds = logs - .map(log => log.resourceId) + .map((log) => log.resourceId) .filter((id): id is number => id !== null && id !== undefined); const siteResourceIds = logs - .filter(log => log.resourceId == null && log.siteResourceId != null) - .map(log => log.siteResourceId) + .filter((log) => log.resourceId == null && log.siteResourceId != null) + .map((log) => log.siteResourceId) .filter((id): id is number => id !== null && id !== undefined); if (resourceIds.length === 0 && siteResourceIds.length === 0) { - return logs.map(log => ({ ...log, resourceName: null, resourceNiceId: null })); + return logs.map((log) => ({ + ...log, + resourceName: null, + resourceNiceId: null + })); } - const resourceMap = new Map(); + const resourceMap = new Map< + number, + { name: string | null; niceId: string | null } + >(); if (resourceIds.length > 0) { const resourceDetails = await primaryDb @@ -181,7 +197,10 @@ async function enrichWithResourceDetails(logs: Awaited(); + const siteResourceMap = new Map< + number, + { name: string | null; niceId: string | null } + >(); if (siteResourceIds.length > 0) { const siteResourceDetails = await primaryDb @@ -194,12 +213,15 @@ async function enrichWithResourceDetails(logs: Awaited { + return logs.map((log) => { if (log.resourceId != null) { const details = resourceMap.get(log.resourceId); return { @@ -273,11 +295,11 @@ async function queryUniqueFilterAttributes( // Fetch resource names from main database for the unique resource IDs const resourceIds = uniqueResources - .map(row => row.id) + .map((row) => row.id) .filter((id): id is number => id !== null); const siteResourceIds = uniqueSiteResources - .map(row => row.id) + .map((row) => row.id) .filter((id): id is number => id !== null); let resourcesWithNames: Array<{ id: number; name: string | null }> = []; @@ -293,7 +315,7 @@ async function queryUniqueFilterAttributes( resourcesWithNames = [ ...resourcesWithNames, - ...resourceDetails.map(r => ({ + ...resourceDetails.map((r) => ({ id: r.resourceId, name: r.name })) @@ -311,7 +333,7 @@ async function queryUniqueFilterAttributes( resourcesWithNames = [ ...resourcesWithNames, - ...siteResourceDetails.map(r => ({ + ...siteResourceDetails.map((r) => ({ id: r.siteResourceId, name: r.name })) @@ -344,7 +366,7 @@ registry.registerPath({ content: { "application/json": { schema: z.object({ - data: z.unknown().nullable(), + data: z.record(z.string(), z.any()).nullable(), success: z.boolean(), error: z.boolean(), message: z.string(), diff --git a/server/private/routers/auditLogs/queryActionAuditLog.ts b/server/private/routers/auditLogs/queryActionAuditLog.ts index 56f2f0201..271929875 100644 --- a/server/private/routers/auditLogs/queryActionAuditLog.ts +++ b/server/private/routers/auditLogs/queryActionAuditLog.ts @@ -171,7 +171,7 @@ registry.registerPath({ content: { "application/json": { schema: z.object({ - data: z.unknown().nullable(), + data: z.record(z.string(), z.any()).nullable(), success: z.boolean(), error: z.boolean(), message: z.string(), diff --git a/server/private/routers/auditLogs/queryConnectionAuditLog.ts b/server/private/routers/auditLogs/queryConnectionAuditLog.ts index e6dd2f6a5..e214d0c66 100644 --- a/server/private/routers/auditLogs/queryConnectionAuditLog.ts +++ b/server/private/routers/auditLogs/queryConnectionAuditLog.ts @@ -459,7 +459,7 @@ registry.registerPath({ content: { "application/json": { schema: z.object({ - data: z.unknown().nullable(), + data: z.record(z.string(), z.any()).nullable(), success: z.boolean(), error: z.boolean(), message: z.string(), diff --git a/server/private/routers/auth/transferSession.ts b/server/private/routers/auth/transferSession.ts index 78673e8a0..d26664dfd 100644 --- a/server/private/routers/auth/transferSession.ts +++ b/server/private/routers/auth/transferSession.ts @@ -17,8 +17,7 @@ import createHttpError from "http-errors"; import { z } from "zod"; import { fromError } from "zod-validation-error"; import logger from "@server/logger"; -import { sessions, sessionTransferToken } from "@server/db"; -import { db } from "@server/db"; +import { db, safeRead, sessions, sessionTransferToken } from "@server/db"; import { eq } from "drizzle-orm"; import { response } from "@server/lib/response"; import { encodeHexLowerCase } from "@oslojs/encoding"; @@ -57,15 +56,19 @@ export async function transferSession( sha256(new TextEncoder().encode(token)) ); - const [existing] = await db - .select() - .from(sessionTransferToken) - .where(eq(sessionTransferToken.token, tokenRaw)) - .innerJoin( - sessions, - eq(sessions.sessionId, sessionTransferToken.sessionId) - ) - .limit(1); + const result = await safeRead((db) => + db + .select() + .from(sessionTransferToken) + .where(eq(sessionTransferToken.token, tokenRaw)) + .innerJoin( + sessions, + eq(sessions.sessionId, sessionTransferToken.sessionId) + ) + .limit(1) + ); + + const [existing] = result; if (!existing) { return next( diff --git a/server/private/routers/billing/getOrgUsage.ts b/server/private/routers/billing/getOrgUsage.ts index ad2102df5..718559654 100644 --- a/server/private/routers/billing/getOrgUsage.ts +++ b/server/private/routers/billing/getOrgUsage.ts @@ -45,7 +45,7 @@ const getOrgSchema = z.strictObject({ // content: { // "application/json": { // schema: z.object({ -// data: z.unknown().nullable(), +// data: z.record(z.string(), z.any()).nullable(), // success: z.boolean(), // error: z.boolean(), // message: z.string(), diff --git a/server/private/routers/browserGatewayTarget/createBrowserGatewayTarget.ts b/server/private/routers/browserGatewayTarget/createBrowserGatewayTarget.ts deleted file mode 100644 index b26a1a8b6..000000000 --- a/server/private/routers/browserGatewayTarget/createBrowserGatewayTarget.ts +++ /dev/null @@ -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 { - 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(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" - ) - ); - } -} diff --git a/server/private/routers/browserGatewayTarget/deleteBrowserGatewayTarget.ts b/server/private/routers/browserGatewayTarget/deleteBrowserGatewayTarget.ts deleted file mode 100644 index 850944b29..000000000 --- a/server/private/routers/browserGatewayTarget/deleteBrowserGatewayTarget.ts +++ /dev/null @@ -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 { - 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" - ) - ); - } -} diff --git a/server/private/routers/browserGatewayTarget/getBrowserGatewayTarget.ts b/server/private/routers/browserGatewayTarget/getBrowserGatewayTarget.ts deleted file mode 100644 index 0ac7a8ce9..000000000 --- a/server/private/routers/browserGatewayTarget/getBrowserGatewayTarget.ts +++ /dev/null @@ -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 { - 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(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" - ) - ); - } -} diff --git a/server/private/routers/browserGatewayTarget/getBrowserTarget.ts b/server/private/routers/browserGatewayTarget/getBrowserTarget.ts index 7feda01e5..b8e32d836 100644 --- a/server/private/routers/browserGatewayTarget/getBrowserTarget.ts +++ b/server/private/routers/browserGatewayTarget/getBrowserTarget.ts @@ -13,9 +13,8 @@ import { Request, Response, NextFunction } from "express"; import { z } from "zod"; -import { browserGatewayTarget, db } from "@server/db"; -import { resources, targets } from "@server/db"; -import { eq } from "drizzle-orm"; +import { db, resources, targets } from "@server/db"; +import { eq, and, inArray } from "drizzle-orm"; import response from "@server/lib/response"; import HttpCode from "@server/types/HttpCode"; import createHttpError from "http-errors"; @@ -51,31 +50,30 @@ export async function getBrowserTarget( logger.info(`Retrieving browser target for domain: ${fullDomain}`); - const [browserTarget] = await db + const [row] = await db .select({ - destination: browserGatewayTarget.destination, - destinationPort: browserGatewayTarget.destinationPort, - authToken: browserGatewayTarget.authToken, + ip: targets.ip, + port: targets.port, + authToken: targets.authToken, resourceId: resources.resourceId, niceId: resources.niceId, + name: resources.name, orgId: resources.orgId, pamMode: resources.pamMode, authDaemonMode: resources.authDaemonMode }) - .from(browserGatewayTarget) - .innerJoin( - resources, - eq(browserGatewayTarget.resourceId, resources.resourceId) + .from(targets) + .innerJoin(resources, eq(targets.resourceId, resources.resourceId)) + .where( + and( + eq(resources.fullDomain, fullDomain), + eq(targets.enabled, true), + inArray(targets.mode, ["ssh", "rdp", "vnc"]) + ) ) - .where(eq(resources.fullDomain, fullDomain)) .limit(1); - const decryptedAuthToken = decrypt( - browserTarget.authToken, - config.getRawConfig().server.secret! - ); - - if (!browserTarget) { + if (!row) { return next( createHttpError( HttpCode.NOT_FOUND, @@ -84,16 +82,21 @@ export async function getBrowserTarget( ); } + const decryptedAuthToken = row.authToken + ? decrypt(row.authToken, config.getRawConfig().server.secret!) + : ""; + return response(res, { data: { - ip: browserTarget.destination, - port: browserTarget.destinationPort, + ip: row.ip, + port: row.port, authToken: decryptedAuthToken, - pamMode: browserTarget.pamMode, - authDaemonMode: browserTarget.authDaemonMode, - orgId: browserTarget.orgId, - resourceId: browserTarget.resourceId, - niceId: browserTarget.niceId + pamMode: row.pamMode, + authDaemonMode: row.authDaemonMode, + orgId: row.orgId, + resourceId: row.resourceId, + niceId: row.niceId, + name: row.name ?? "" }, success: true, error: false, diff --git a/server/private/routers/browserGatewayTarget/index.ts b/server/private/routers/browserGatewayTarget/index.ts index c9cd15dff..3c1b3d6f9 100644 --- a/server/private/routers/browserGatewayTarget/index.ts +++ b/server/private/routers/browserGatewayTarget/index.ts @@ -11,9 +11,4 @@ * 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"; diff --git a/server/private/routers/browserGatewayTarget/listBrowserGatewayTargets.ts b/server/private/routers/browserGatewayTarget/listBrowserGatewayTargets.ts deleted file mode 100644 index 5b3d1e5d0..000000000 --- a/server/private/routers/browserGatewayTarget/listBrowserGatewayTargets.ts +++ /dev/null @@ -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 { - 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(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" - ) - ); - } -} diff --git a/server/private/routers/browserGatewayTarget/updateBrowserGatewayTarget.ts b/server/private/routers/browserGatewayTarget/updateBrowserGatewayTarget.ts deleted file mode 100644 index 825407dc3..000000000 --- a/server/private/routers/browserGatewayTarget/updateBrowserGatewayTarget.ts +++ /dev/null @@ -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 { - 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 = {}; - 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(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" - ) - ); - } -} diff --git a/server/private/routers/certificates/getCertificate.ts b/server/private/routers/certificates/getCertificate.ts index c365d3d7b..60a6de59f 100644 --- a/server/private/routers/certificates/getCertificate.ts +++ b/server/private/routers/certificates/getCertificate.ts @@ -121,7 +121,7 @@ registry.registerPath({ content: { "application/json": { schema: z.object({ - data: z.unknown().nullable(), + data: z.record(z.string(), z.any()).nullable(), success: z.boolean(), error: z.boolean(), message: z.string(), diff --git a/server/private/routers/certificates/restartCertificate.ts b/server/private/routers/certificates/restartCertificate.ts index 50c747f7b..9c3cbb8cc 100644 --- a/server/private/routers/certificates/restartCertificate.ts +++ b/server/private/routers/certificates/restartCertificate.ts @@ -46,7 +46,7 @@ registry.registerPath({ content: { "application/json": { schema: z.object({ - data: z.unknown().nullable(), + data: z.record(z.string(), z.any()).nullable(), success: z.boolean(), error: z.boolean(), message: z.string(), diff --git a/server/private/routers/domain/checkDomainNamespaceAvailability.ts b/server/private/routers/domain/checkDomainNamespaceAvailability.ts index c5320c1f6..b2bfb8923 100644 --- a/server/private/routers/domain/checkDomainNamespaceAvailability.ts +++ b/server/private/routers/domain/checkDomainNamespaceAvailability.ts @@ -29,7 +29,7 @@ import { tierMatrix } from "@server/lib/billing/tierMatrix"; const paramsSchema = z.strictObject({}); const querySchema = z.strictObject({ - subdomain: z.string(), + subdomain: z.string() // orgId: build === "saas" ? z.string() : z.string().optional() // Required for saas, optional otherwise }); @@ -48,7 +48,7 @@ registry.registerPath({ content: { "application/json": { schema: z.object({ - data: z.unknown().nullable(), + data: z.record(z.string(), z.any()).nullable(), success: z.boolean(), error: z.boolean(), message: z.string(), diff --git a/server/private/routers/eventStreamingDestination/deleteEventStreamingDestination.ts b/server/private/routers/eventStreamingDestination/deleteEventStreamingDestination.ts index 13b54ef17..5aa4bf314 100644 --- a/server/private/routers/eventStreamingDestination/deleteEventStreamingDestination.ts +++ b/server/private/routers/eventStreamingDestination/deleteEventStreamingDestination.ts @@ -33,7 +33,8 @@ const paramsSchema = z registry.registerPath({ method: "delete", path: "/org/{orgId}/event-streaming-destination/{destinationId}", - description: "Delete an event streaming destination for a specific organization.", + description: + "Delete an event streaming destination for a specific organization.", tags: [OpenAPITags.Org], request: { params: paramsSchema @@ -44,7 +45,7 @@ registry.registerPath({ content: { "application/json": { schema: z.object({ - data: z.unknown().nullable(), + data: z.record(z.string(), z.any()).nullable(), success: z.boolean(), error: z.boolean(), message: z.string(), @@ -115,4 +116,4 @@ export async function deleteEventStreamingDestination( createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred") ); } -} \ No newline at end of file +} diff --git a/server/private/routers/external.ts b/server/private/routers/external.ts index 0598a1514..881ba2277 100644 --- a/server/private/routers/external.ts +++ b/server/private/routers/external.ts @@ -31,7 +31,6 @@ import * as siteProvisioning from "#private/routers/siteProvisioning"; import * as eventStreamingDestination from "#private/routers/eventStreamingDestination"; import * as alertRule from "#private/routers/alertRule"; import * as healthChecks from "#private/routers/healthChecks"; -import * as browserGatewayTarget from "#private/routers/browserGatewayTarget"; import * as labels from "#private/routers/labels"; import * as client from "@server/routers/client"; import * as resource from "#private/routers/resource"; @@ -879,48 +878,3 @@ authenticated.post( verifyClientAccess, 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 -); diff --git a/server/private/routers/healthChecks/deleteHealthCheck.ts b/server/private/routers/healthChecks/deleteHealthCheck.ts index b5d054783..90bb41e50 100644 --- a/server/private/routers/healthChecks/deleteHealthCheck.ts +++ b/server/private/routers/healthChecks/deleteHealthCheck.ts @@ -47,7 +47,7 @@ registry.registerPath({ content: { "application/json": { schema: z.object({ - data: z.unknown().nullable(), + data: z.record(z.string(), z.any()).nullable(), success: z.boolean(), error: z.boolean(), message: z.string(), diff --git a/server/private/routers/healthChecks/listHealthChecks.ts b/server/private/routers/healthChecks/listHealthChecks.ts index c0198d96e..0d18b881e 100644 --- a/server/private/routers/healthChecks/listHealthChecks.ts +++ b/server/private/routers/healthChecks/listHealthChecks.ts @@ -74,7 +74,7 @@ registry.registerPath({ content: { "application/json": { schema: z.object({ - data: z.unknown().nullable(), + data: z.record(z.string(), z.any()).nullable(), success: z.boolean(), error: z.boolean(), message: z.string(), diff --git a/server/private/routers/hybrid.ts b/server/private/routers/hybrid.ts index 5d35ca2a7..c6be3e7d1 100644 --- a/server/private/routers/hybrid.ts +++ b/server/private/routers/hybrid.ts @@ -79,7 +79,10 @@ import logger from "@server/logger"; import { decrypt } from "@server/lib/crypto"; import config from "@server/lib/config"; 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 { maxmindLookup } from "@server/db/maxmind"; import { verifyResourceAccessToken } from "@server/auth/verifyResourceAccessToken"; @@ -216,9 +219,9 @@ export type ResourceWithAuth = { password: ResourcePassword | ResourcePolicyPassword | null; headerAuth: ResourceHeaderAuth | ResourcePolicyHeaderAuth | null; headerAuthExtendedCompatibility: ResourceHeaderAuthExtendedCompatibility | null; - applyRules: boolean; - sso: boolean; - emailWhitelistEnabled: boolean; + applyRules: boolean | null; + sso: boolean | null; + emailWhitelistEnabled: boolean | null; org: Org; }; @@ -1754,11 +1757,34 @@ hybridRouter.post( 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, { - data: result, + data: modifiedResult, success: true, error: false, - message: result.resourceSession + message: modifiedResult.resourceSession ? "Resource session token is valid" : "Resource session token is invalid or expired", status: HttpCode.OK diff --git a/server/private/routers/integration.ts b/server/private/routers/integration.ts index 542c806f4..820a843f0 100644 --- a/server/private/routers/integration.ts +++ b/server/private/routers/integration.ts @@ -16,7 +16,6 @@ import * as org from "#private/routers/org"; import * as logs from "#private/routers/auditLogs"; import * as alertEvents from "#private/routers/alertEvents"; import * as certificates from "#private/routers/certificates"; -import * as browserGatewayTarget from "#private/routers/browserGatewayTarget"; import { verifyApiKeyHasAction, @@ -216,43 +215,3 @@ authenticated.delete( logActionAudit(ActionsEnum.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 -); diff --git a/server/private/routers/internal.ts b/server/private/routers/internal.ts index f78acb48e..c45fe36b9 100644 --- a/server/private/routers/internal.ts +++ b/server/private/routers/internal.ts @@ -17,9 +17,9 @@ import * as orgIdp from "#private/routers/orgIdp"; import * as billing from "#private/routers/billing"; import * as license from "#private/routers/license"; import * as resource from "#private/routers/resource"; -import * as browserTarget from "#private/routers/browserGatewayTarget"; import * as ssh from "#private/routers/ssh"; import * as ws from "@server/routers/ws"; +import * as browserTarget from "#private/routers/browserGatewayTarget"; import { verifySessionUserMiddleware, diff --git a/server/private/routers/labels/createOrgLabel.ts b/server/private/routers/labels/createOrgLabel.ts index 074a96207..c856eecf4 100644 --- a/server/private/routers/labels/createOrgLabel.ts +++ b/server/private/routers/labels/createOrgLabel.ts @@ -22,7 +22,7 @@ import response from "@server/lib/response"; import logger from "@server/logger"; import type { CreateOrEditLabelResponse } from "@server/routers/labels/types"; 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 createHttpError from "http-errors"; 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 tx .insert(labels) diff --git a/server/private/routers/labels/updateOrgLabel.ts b/server/private/routers/labels/updateOrgLabel.ts index eb5f5177a..134a01f02 100644 --- a/server/private/routers/labels/updateOrgLabel.ts +++ b/server/private/routers/labels/updateOrgLabel.ts @@ -16,7 +16,7 @@ import response from "@server/lib/response"; import logger from "@server/logger"; import type { CreateOrEditLabelResponse } from "@server/routers/labels/types"; 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 createHttpError from "http-errors"; import { z } from "zod"; @@ -74,6 +74,29 @@ export async function updateOrgLabel( 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 .update(labels) .set({ diff --git a/server/private/routers/orgIdp/createOrgOidcIdp.ts b/server/private/routers/orgIdp/createOrgOidcIdp.ts index c829f0c84..8b214b895 100644 --- a/server/private/routers/orgIdp/createOrgOidcIdp.ts +++ b/server/private/routers/orgIdp/createOrgOidcIdp.ts @@ -69,7 +69,7 @@ registry.registerPath({ content: { "application/json": { schema: z.object({ - data: z.unknown().nullable(), + data: z.record(z.string(), z.any()).nullable(), success: z.boolean(), error: z.boolean(), message: z.string(), @@ -127,7 +127,8 @@ export async function createOrgOidcIdp( let { autoProvision } = parsedBody.data; - if (build == "saas") { // this is not paywalled with a ee license because this whole endpoint is restricted + if (build == "saas") { + // this is not paywalled with a ee license because this whole endpoint is restricted const subscribed = await isSubscribed( orgId, tierMatrix.deviceApprovals diff --git a/server/private/routers/orgIdp/deleteOrgIdp.ts b/server/private/routers/orgIdp/deleteOrgIdp.ts index 2007f180e..f7cfd82bf 100644 --- a/server/private/routers/orgIdp/deleteOrgIdp.ts +++ b/server/private/routers/orgIdp/deleteOrgIdp.ts @@ -44,7 +44,7 @@ registry.registerPath({ content: { "application/json": { schema: z.object({ - data: z.unknown().nullable(), + data: z.record(z.string(), z.any()).nullable(), success: z.boolean(), error: z.boolean(), message: z.string(), diff --git a/server/private/routers/orgIdp/getOrgIdp.ts b/server/private/routers/orgIdp/getOrgIdp.ts index a2f29a57f..e22929d46 100644 --- a/server/private/routers/orgIdp/getOrgIdp.ts +++ b/server/private/routers/orgIdp/getOrgIdp.ts @@ -62,7 +62,7 @@ registry.registerPath({ content: { "application/json": { schema: z.object({ - data: z.unknown().nullable(), + data: z.record(z.string(), z.any()).nullable(), success: z.boolean(), error: z.boolean(), message: z.string(), diff --git a/server/private/routers/orgIdp/listOrgIdps.ts b/server/private/routers/orgIdp/listOrgIdps.ts index 672fbc054..66a2da2fe 100644 --- a/server/private/routers/orgIdp/listOrgIdps.ts +++ b/server/private/routers/orgIdp/listOrgIdps.ts @@ -78,7 +78,7 @@ registry.registerPath({ content: { "application/json": { schema: z.object({ - data: z.unknown().nullable(), + data: z.record(z.string(), z.any()).nullable(), success: z.boolean(), error: z.boolean(), message: z.string(), diff --git a/server/private/routers/policy/createResourcePolicy.ts b/server/private/routers/policy/createResourcePolicy.ts index 2b4678331..9f02b912c 100644 --- a/server/private/routers/policy/createResourcePolicy.ts +++ b/server/private/routers/policy/createResourcePolicy.ts @@ -33,9 +33,8 @@ import { import { getUniqueResourcePolicyName } from "@server/db/names"; import response from "@server/lib/response"; import { - isValidCIDR, - isValidIP, - isValidUrlGlobPattern + getResourceRuleValueValidationError, + RESOURCE_RULE_MATCH_TYPES } from "@server/lib/validators"; import logger from "@server/logger"; import { OpenAPITags, registry } from "@server/openApi"; @@ -56,9 +55,9 @@ const ruleSchema = z.strictObject({ enum: ["ACCEPT", "DROP", "PASS"], description: "rule action" }), - match: z.enum(["CIDR", "IP", "PATH"]).openapi({ + match: z.enum(RESOURCE_RULE_MATCH_TYPES).openapi({ type: "string", - enum: ["CIDR", "IP", "PATH"], + enum: [...RESOURCE_RULE_MATCH_TYPES], description: "rule match" }), value: z.string().min(1), @@ -261,26 +260,13 @@ export async function createResourcePolicy( const niceId = await getUniqueResourcePolicyName(orgId); for (const rule of rules) { - if (rule.match === "CIDR" && !isValidCIDR(rule.value)) { + const validationError = getResourceRuleValueValidationError( + rule.match, + rule.value + ); + if (validationError) { return next( - createHttpError( - 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" - ) + createHttpError(HttpCode.BAD_REQUEST, validationError) ); } } diff --git a/server/private/routers/policy/listResourcePolicies.ts b/server/private/routers/policy/listResourcePolicies.ts index beb1b68c3..a5a52d9c8 100644 --- a/server/private/routers/policy/listResourcePolicies.ts +++ b/server/private/routers/policy/listResourcePolicies.ts @@ -216,6 +216,7 @@ export async function listResourcePolicies( : await db .select({ resourceId: resources.resourceId, + niceId: resources.niceId, name: resources.name, fullDomain: resources.fullDomain, resourcePolicyId: resources.resourcePolicyId diff --git a/server/private/routers/remoteExitNode/exitNodeReconnectScheduler.ts b/server/private/routers/remoteExitNode/exitNodeReconnectScheduler.ts new file mode 100644 index 000000000..0d871583f --- /dev/null +++ b/server/private/routers/remoteExitNode/exitNodeReconnectScheduler.ts @@ -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(); + +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 { + 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 { + // 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 { + 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 { + pendingReconnects.delete(exitNodeId); + + if (redisManager.isRedisEnabled()) { + await redisManager.srem(REDIS_PENDING_SET, exitNodeId.toString()); + await redisManager.del(`${REDIS_HASH_PREFIX}${exitNodeId}`); + } +} diff --git a/server/private/routers/remoteExitNode/handleRemoteExitNodePingMessage.ts b/server/private/routers/remoteExitNode/handleRemoteExitNodePingMessage.ts index c2c710e11..10bf36d7c 100644 --- a/server/private/routers/remoteExitNode/handleRemoteExitNodePingMessage.ts +++ b/server/private/routers/remoteExitNode/handleRemoteExitNodePingMessage.ts @@ -16,6 +16,7 @@ import { MessageHandler } from "@server/routers/ws"; import { RemoteExitNode } from "@server/db"; import { eq } from "drizzle-orm"; import logger from "@server/logger"; +import { scheduleExitNodeReconnect } from "./exitNodeReconnectScheduler"; /** * Handles ping messages from clients and responds with pong @@ -37,6 +38,13 @@ export const handleRemoteExitNodePingMessage: MessageHandler = async ( } 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 await db .update(exitNodes) @@ -45,6 +53,16 @@ export const handleRemoteExitNodePingMessage: MessageHandler = async ( online: true }) .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) { logger.error("Error handling ping message", { error }); } diff --git a/server/private/routers/remoteExitNode/index.ts b/server/private/routers/remoteExitNode/index.ts index 730f6b693..953ccba88 100644 --- a/server/private/routers/remoteExitNode/index.ts +++ b/server/private/routers/remoteExitNode/index.ts @@ -22,3 +22,4 @@ export * from "./listRemoteExitNodes"; export * from "./pickRemoteExitNodeDefaults"; export * from "./quickStartRemoteExitNode"; export * from "./offlineChecker"; +export * from "./exitNodeReconnectScheduler"; diff --git a/server/private/routers/ssh/signSshKey.ts b/server/private/routers/ssh/signSshKey.ts index dac4ae62a..bcf8beab7 100644 --- a/server/private/routers/ssh/signSshKey.ts +++ b/server/private/routers/ssh/signSshKey.ts @@ -30,8 +30,7 @@ import { userOrgs, sites, Resource, - SiteResource, - browserGatewayTarget + SiteResource } from "@server/db"; import { logAccessAudit } from "#private/lib/logAccessAudit"; import { isLicensedOrSubscribed } from "#private/lib/isLicencedOrSubscribed"; @@ -291,16 +290,15 @@ export async function signSshKey( const publicResource = resource as Resource; const targetRows = await db .select({ - siteId: browserGatewayTarget.siteId, - ip: browserGatewayTarget.destination + siteId: targets.siteId, + ip: targets.ip }) - .from(browserGatewayTarget) + .from(targets) .where( and( - eq( - browserGatewayTarget.resourceId, - publicResource.resourceId - ) + eq(targets.resourceId, publicResource.resourceId), + eq(targets.enabled, true), + eq(targets.mode, "ssh") ) ); diff --git a/server/private/routers/user/addUserRole.ts b/server/private/routers/user/addUserRole.ts index a59993a5d..c59a3d0f7 100644 --- a/server/private/routers/user/addUserRole.ts +++ b/server/private/routers/user/addUserRole.ts @@ -44,7 +44,7 @@ registry.registerPath({ content: { "application/json": { schema: z.object({ - data: z.unknown().nullable(), + data: z.record(z.string(), z.any()).nullable(), success: z.boolean(), error: z.boolean(), message: z.string(), diff --git a/server/private/routers/user/removeUserRole.ts b/server/private/routers/user/removeUserRole.ts index de099be6d..b96670815 100644 --- a/server/private/routers/user/removeUserRole.ts +++ b/server/private/routers/user/removeUserRole.ts @@ -45,7 +45,7 @@ registry.registerPath({ content: { "application/json": { schema: z.object({ - data: z.unknown().nullable(), + data: z.record(z.string(), z.any()).nullable(), success: z.boolean(), error: z.boolean(), message: z.string(), diff --git a/server/private/routers/ws/messageHandlers.ts b/server/private/routers/ws/messageHandlers.ts index b2553871e..d91726393 100644 --- a/server/private/routers/ws/messageHandlers.ts +++ b/server/private/routers/ws/messageHandlers.ts @@ -14,7 +14,8 @@ import { handleRemoteExitNodeRegisterMessage, handleRemoteExitNodePingMessage, - startRemoteExitNodeOfflineChecker + startRemoteExitNodeOfflineChecker, + startExitNodeReconnectScheduler } from "#private/routers/remoteExitNode"; import { MessageHandler } from "@server/routers/ws"; import { build } from "@server/build"; @@ -29,4 +30,5 @@ export const messageHandlers: Record = { if (build != "saas") { startRemoteExitNodeOfflineChecker(); // this is to handle the offline check for remote exit nodes + startExitNodeReconnectScheduler(); // check pending exit node reconnects and notify newts } diff --git a/server/routers/accessToken/deleteAccessToken.ts b/server/routers/accessToken/deleteAccessToken.ts index 405d4e68e..82344630c 100644 --- a/server/routers/accessToken/deleteAccessToken.ts +++ b/server/routers/accessToken/deleteAccessToken.ts @@ -28,7 +28,7 @@ registry.registerPath({ content: { "application/json": { schema: z.object({ - data: z.unknown().nullable(), + data: z.record(z.string(), z.any()).nullable(), success: z.boolean(), error: z.boolean(), message: z.string(), diff --git a/server/routers/accessToken/generateAccessToken.ts b/server/routers/accessToken/generateAccessToken.ts index fd03bc06d..a06068c01 100644 --- a/server/routers/accessToken/generateAccessToken.ts +++ b/server/routers/accessToken/generateAccessToken.ts @@ -61,7 +61,7 @@ registry.registerPath({ content: { "application/json": { schema: z.object({ - data: z.unknown().nullable(), + data: z.record(z.string(), z.any()).nullable(), success: z.boolean(), error: z.boolean(), message: z.string(), diff --git a/server/routers/accessToken/listAccessTokens.ts b/server/routers/accessToken/listAccessTokens.ts index 88ff07b27..0339cc2c4 100644 --- a/server/routers/accessToken/listAccessTokens.ts +++ b/server/routers/accessToken/listAccessTokens.ts @@ -135,7 +135,7 @@ registry.registerPath({ content: { "application/json": { schema: z.object({ - data: z.unknown().nullable(), + data: z.record(z.string(), z.any()).nullable(), success: z.boolean(), error: z.boolean(), message: z.string(), @@ -164,7 +164,7 @@ registry.registerPath({ content: { "application/json": { schema: z.object({ - data: z.unknown().nullable(), + data: z.record(z.string(), z.any()).nullable(), success: z.boolean(), error: z.boolean(), message: z.string(), diff --git a/server/routers/apiKeys/deleteApiKey.ts b/server/routers/apiKeys/deleteApiKey.ts index 07d00d727..895ebf560 100644 --- a/server/routers/apiKeys/deleteApiKey.ts +++ b/server/routers/apiKeys/deleteApiKey.ts @@ -28,7 +28,7 @@ registry.registerPath({ content: { "application/json": { schema: z.object({ - data: z.unknown().nullable(), + data: z.record(z.string(), z.any()).nullable(), success: z.boolean(), error: z.boolean(), message: z.string(), diff --git a/server/routers/apiKeys/setApiKeyActions.ts b/server/routers/apiKeys/setApiKeyActions.ts index e31e8b8b9..62c06ba73 100644 --- a/server/routers/apiKeys/setApiKeyActions.ts +++ b/server/routers/apiKeys/setApiKeyActions.ts @@ -42,7 +42,7 @@ registry.registerPath({ content: { "application/json": { schema: z.object({ - data: z.unknown().nullable(), + data: z.record(z.string(), z.any()).nullable(), success: z.boolean(), error: z.boolean(), message: z.string(), diff --git a/server/routers/auditLogs/exportRequestAuditLog.ts b/server/routers/auditLogs/exportRequestAuditLog.ts index 1b4eede00..156f85113 100644 --- a/server/routers/auditLogs/exportRequestAuditLog.ts +++ b/server/routers/auditLogs/exportRequestAuditLog.ts @@ -35,7 +35,7 @@ registry.registerPath({ content: { "application/json": { schema: z.object({ - data: z.unknown().nullable(), + data: z.record(z.string(), z.any()).nullable(), success: z.boolean(), error: z.boolean(), message: z.string(), diff --git a/server/routers/auditLogs/queryRequestAnalytics.ts b/server/routers/auditLogs/queryRequestAnalytics.ts index 62b0bd75e..7f8aaf047 100644 --- a/server/routers/auditLogs/queryRequestAnalytics.ts +++ b/server/routers/auditLogs/queryRequestAnalytics.ts @@ -162,7 +162,7 @@ registry.registerPath({ content: { "application/json": { schema: z.object({ - data: z.unknown().nullable(), + data: z.record(z.string(), z.any()).nullable(), success: z.boolean(), error: z.boolean(), message: z.string(), diff --git a/server/routers/auditLogs/queryRequestAuditLog.ts b/server/routers/auditLogs/queryRequestAuditLog.ts index 14bbb3c4d..f14c28cf1 100644 --- a/server/routers/auditLogs/queryRequestAuditLog.ts +++ b/server/routers/auditLogs/queryRequestAuditLog.ts @@ -1,4 +1,11 @@ -import { logsDb, requestAuditLog, resources, siteResources, db, primaryDb } from "@server/db"; +import { + logsDb, + requestAuditLog, + resources, + siteResources, + db, + primaryDb +} from "@server/db"; import { registry } from "@server/openApi"; import { NextFunction } from "express"; import { Request, Response } from "express"; @@ -127,16 +134,16 @@ export function queryRequest(data: Q) { return logsDb .select({ id: requestAuditLog.id, - timestamp: requestAuditLog.timestamp, - orgId: requestAuditLog.orgId, - action: requestAuditLog.action, - reason: requestAuditLog.reason, - actorType: requestAuditLog.actorType, - actor: requestAuditLog.actor, - actorId: requestAuditLog.actorId, - resourceId: requestAuditLog.resourceId, - siteResourceId: requestAuditLog.siteResourceId, - ip: requestAuditLog.ip, + timestamp: requestAuditLog.timestamp, + orgId: requestAuditLog.orgId, + action: requestAuditLog.action, + reason: requestAuditLog.reason, + actorType: requestAuditLog.actorType, + actor: requestAuditLog.actor, + actorId: requestAuditLog.actorId, + resourceId: requestAuditLog.resourceId, + siteResourceId: requestAuditLog.siteResourceId, + ip: requestAuditLog.ip, location: requestAuditLog.location, userAgent: requestAuditLog.userAgent, metadata: requestAuditLog.metadata, @@ -154,21 +161,30 @@ export function queryRequest(data: Q) { .orderBy(desc(requestAuditLog.timestamp)); } -async function enrichWithResourceDetails(logs: Awaited>) { +async function enrichWithResourceDetails( + logs: Awaited> +) { const resourceIds = logs - .map(log => log.resourceId) + .map((log) => log.resourceId) .filter((id): id is number => id !== null && id !== undefined); const siteResourceIds = logs - .filter(log => log.resourceId == null && log.siteResourceId != null) - .map(log => log.siteResourceId) + .filter((log) => log.resourceId == null && log.siteResourceId != null) + .map((log) => log.siteResourceId) .filter((id): id is number => id !== null && id !== undefined); if (resourceIds.length === 0 && siteResourceIds.length === 0) { - return logs.map(log => ({ ...log, resourceName: null, resourceNiceId: null })); + return logs.map((log) => ({ + ...log, + resourceName: null, + resourceNiceId: null + })); } - const resourceMap = new Map(); + const resourceMap = new Map< + number, + { name: string | null; niceId: string | null } + >(); if (resourceIds.length > 0) { const resourceDetails = await primaryDb @@ -185,7 +201,10 @@ async function enrichWithResourceDetails(logs: Awaited(); + const siteResourceMap = new Map< + number, + { name: string | null; niceId: string | null } + >(); if (siteResourceIds.length > 0) { const siteResourceDetails = await primaryDb @@ -198,12 +217,15 @@ async function enrichWithResourceDetails(logs: Awaited { + return logs.map((log) => { if (log.resourceId != null) { const details = resourceMap.get(log.resourceId); return { @@ -247,7 +269,7 @@ registry.registerPath({ content: { "application/json": { schema: z.object({ - data: z.unknown().nullable(), + data: z.record(z.string(), z.any()).nullable(), success: z.boolean(), error: z.boolean(), message: z.string(), @@ -333,11 +355,11 @@ async function queryUniqueFilterAttributes( // Fetch resource names from main database for the unique resource IDs const resourceIds = uniqueResources - .map(row => row.id) + .map((row) => row.id) .filter((id): id is number => id !== null); const siteResourceIds = uniqueSiteResources - .map(row => row.id) + .map((row) => row.id) .filter((id): id is number => id !== null); let resourcesWithNames: Array<{ id: number; name: string | null }> = []; @@ -353,7 +375,7 @@ async function queryUniqueFilterAttributes( resourcesWithNames = [ ...resourcesWithNames, - ...resourceDetails.map(r => ({ + ...resourceDetails.map((r) => ({ id: r.resourceId, name: r.name })) @@ -371,7 +393,7 @@ async function queryUniqueFilterAttributes( resourcesWithNames = [ ...resourcesWithNames, - ...siteResourceDetails.map(r => ({ + ...siteResourceDetails.map((r) => ({ id: r.siteResourceId, name: r.name })) diff --git a/server/routers/auth/lookupUser.ts b/server/routers/auth/lookupUser.ts index d086cf4e1..dc32d5bb6 100644 --- a/server/routers/auth/lookupUser.ts +++ b/server/routers/auth/lookupUser.ts @@ -1,14 +1,7 @@ import { Request, Response, NextFunction } from "express"; import { z } from "zod"; import { db } from "@server/db"; -import { - users, - userOrgs, - orgs, - idpOrg, - idp, - idpOidcConfig -} from "@server/db"; +import { users, userOrgs, orgs, idpOrg, idp, idpOidcConfig } from "@server/db"; import { eq, or, sql, and, isNotNull, inArray } from "drizzle-orm"; import response from "@server/lib/response"; import HttpCode from "@server/types/HttpCode"; @@ -57,7 +50,7 @@ export type LookupUserResponse = { // content: { // "application/json": { // schema: z.object({ -// data: z.unknown().nullable(), +// data: z.record(z.string(), z.any()).nullable(), // success: z.boolean(), // error: z.boolean(), // message: z.string(), @@ -169,46 +162,54 @@ export async function lookupUser( ); // Deduplicate orgs (user might have multiple memberships in same org) - const uniqueOrgs = new Map(); + const uniqueOrgs = new Map< + string, + (typeof userOrgMemberships)[0] + >(); for (const membership of userOrgMemberships) { if (!uniqueOrgs.has(membership.orgId)) { uniqueOrgs.set(membership.orgId, membership); } } - const orgsData = Array.from(uniqueOrgs.values()).map((membership) => { - // Get IdPs for this org where the user (with the exact identifier) is authenticated via that IdP - // Only show IdPs where the user's idpId matches - // Internal users don't have an idpId, so they won't see any IdPs - const orgIdpsList = orgIdps - .filter((idp) => { - if (idp.orgId !== membership.orgId) { + const orgsData = Array.from(uniqueOrgs.values()).map( + (membership) => { + // Get IdPs for this org where the user (with the exact identifier) is authenticated via that IdP + // Only show IdPs where the user's idpId matches + // Internal users don't have an idpId, so they won't see any IdPs + const orgIdpsList = orgIdps + .filter((idp) => { + if (idp.orgId !== membership.orgId) { + return false; + } + // Only show IdPs where the user (with exact identifier) is authenticated via that IdP + // This means user.idpId must match idp.idpId + if ( + user.idpId !== null && + user.idpId === idp.idpId + ) { + return true; + } return false; - } - // Only show IdPs where the user (with exact identifier) is authenticated via that IdP - // This means user.idpId must match idp.idpId - if (user.idpId !== null && user.idpId === idp.idpId) { - return true; - } - return false; - }) - .map((idp) => ({ - idpId: idp.idpId, - name: idp.idpName, - variant: idp.variant - })); + }) + .map((idp) => ({ + idpId: idp.idpId, + name: idp.idpName, + variant: idp.variant + })); - // Check if user has internal auth for this org - // User has internal auth if they have an internal account type - const orgHasInternalAuth = hasInternalAuth; + // Check if user has internal auth for this org + // User has internal auth if they have an internal account type + const orgHasInternalAuth = hasInternalAuth; - return { - orgId: membership.orgId, - orgName: membership.orgName, - idps: orgIdpsList, - hasInternalAuth: orgHasInternalAuth - }; - }); + return { + orgId: membership.orgId, + orgName: membership.orgName, + idps: orgIdpsList, + hasInternalAuth: orgHasInternalAuth + }; + } + ); accounts.push({ userId: user.userId, diff --git a/server/routers/badger/verifySession.ts b/server/routers/badger/verifySession.ts index 1735ed558..677fa281d 100644 --- a/server/routers/badger/verifySession.ts +++ b/server/routers/badger/verifySession.ts @@ -20,7 +20,8 @@ import { ResourcePolicyPincode, ResourcePolicyPassword, ResourcePolicyHeaderAuth, - ResourceRule + ResourceRule, + ResourceSession } from "@server/db"; import config from "@server/lib/config"; import { isIpInCidr, stripPortFromHost } from "@server/lib/ip"; @@ -144,9 +145,9 @@ export async function verifyResourceSession( | ResourcePolicyHeaderAuth | null; headerAuthExtendedCompatibility: ResourceHeaderAuthExtendedCompatibility | null; - applyRules: boolean; - sso: boolean; - emailWhitelistEnabled: boolean; + applyRules: boolean | null; + sso: boolean | null; + emailWhitelistEnabled: boolean | null; org: Org; } | undefined = localCache.get(resourceCacheKey); @@ -536,7 +537,8 @@ export async function verifyResourceSession( if (resourceSessionToken) { const sessionCacheKey = `session:${resourceSessionToken}`; - let resourceSession: any = localCache.get(sessionCacheKey); + let resourceSession: ResourceSession | null | undefined = + localCache.get(sessionCacheKey); if (!resourceSession) { const result = await validateResourceSessionToken( @@ -671,7 +673,7 @@ export async function verifyResourceSession( orgId: resource.orgId, location: ipCC, apiKey: { - name: resourceSession.accessTokenTitle, + name: null, apiKeyId: resourceSession.accessTokenId } }, @@ -717,7 +719,7 @@ export async function verifyResourceSession( location: ipCC, user: { username: allowedUserData.username, - userId: resourceSession.userId + userId: allowedUserData.userId } }, parsedBody.data diff --git a/server/routers/blueprints/applyJSONBlueprint.ts b/server/routers/blueprints/applyJSONBlueprint.ts index cd7026a9a..8ad41e9e4 100644 --- a/server/routers/blueprints/applyJSONBlueprint.ts +++ b/server/routers/blueprints/applyJSONBlueprint.ts @@ -37,7 +37,7 @@ registry.registerPath({ content: { "application/json": { schema: z.object({ - data: z.unknown().nullable(), + data: z.record(z.string(), z.any()).nullable(), success: z.boolean(), error: z.boolean(), message: z.string(), diff --git a/server/routers/blueprints/applyYAMLBlueprint.ts b/server/routers/blueprints/applyYAMLBlueprint.ts index 5ddebeb67..9809e6971 100644 --- a/server/routers/blueprints/applyYAMLBlueprint.ts +++ b/server/routers/blueprints/applyYAMLBlueprint.ts @@ -60,7 +60,7 @@ registry.registerPath({ content: { "application/json": { schema: z.object({ - data: z.unknown().nullable(), + data: z.record(z.string(), z.any()).nullable(), success: z.boolean(), error: z.boolean(), message: z.string(), diff --git a/server/routers/blueprints/getBlueprint.ts b/server/routers/blueprints/getBlueprint.ts index 4bad8ee3f..9f56c04d5 100644 --- a/server/routers/blueprints/getBlueprint.ts +++ b/server/routers/blueprints/getBlueprint.ts @@ -62,7 +62,7 @@ registry.registerPath({ content: { "application/json": { schema: z.object({ - data: z.unknown().nullable(), + data: z.record(z.string(), z.any()).nullable(), success: z.boolean(), error: z.boolean(), message: z.string(), diff --git a/server/routers/blueprints/listBlueprints.ts b/server/routers/blueprints/listBlueprints.ts index f94bd9978..ab9ac604e 100644 --- a/server/routers/blueprints/listBlueprints.ts +++ b/server/routers/blueprints/listBlueprints.ts @@ -80,7 +80,7 @@ registry.registerPath({ content: { "application/json": { schema: z.object({ - data: z.unknown().nullable(), + data: z.record(z.string(), z.any()).nullable(), success: z.boolean(), error: z.boolean(), message: z.string(), diff --git a/server/routers/browserGatewayTarget/types.ts b/server/routers/browserGatewayTarget/types.ts index e644c952a..df6302391 100644 --- a/server/routers/browserGatewayTarget/types.ts +++ b/server/routers/browserGatewayTarget/types.ts @@ -5,6 +5,7 @@ export type GetBrowserTargetResponse = { orgId: string; resourceId: number; niceId: string; + name: string; pamMode: "passthrough" | "push" | null; authDaemonMode: "site" | "remote" | "native" | null; }; diff --git a/server/routers/client/archiveClient.ts b/server/routers/client/archiveClient.ts index 25ea06f7d..9d6254b8f 100644 --- a/server/routers/client/archiveClient.ts +++ b/server/routers/client/archiveClient.ts @@ -28,7 +28,7 @@ registry.registerPath({ content: { "application/json": { schema: z.object({ - data: z.unknown().nullable(), + data: z.record(z.string(), z.any()).nullable(), success: z.boolean(), error: z.boolean(), message: z.string(), diff --git a/server/routers/client/blockClient.ts b/server/routers/client/blockClient.ts index 1a7cb48c4..659cd864f 100644 --- a/server/routers/client/blockClient.ts +++ b/server/routers/client/blockClient.ts @@ -30,7 +30,7 @@ registry.registerPath({ content: { "application/json": { schema: z.object({ - data: z.unknown().nullable(), + data: z.record(z.string(), z.any()).nullable(), success: z.boolean(), error: z.boolean(), message: z.string(), @@ -94,7 +94,11 @@ export async function blockClient( // Send terminate signal if there's an associated OLM and it's connected if (client.olmId && client.online) { - await sendTerminateClient(client.clientId, OlmErrorCodes.TERMINATED_BLOCKED, client.olmId); + await sendTerminateClient( + client.clientId, + OlmErrorCodes.TERMINATED_BLOCKED, + client.olmId + ); } }); diff --git a/server/routers/client/createClient.ts b/server/routers/client/createClient.ts index ddf8f3dbf..ecda098c5 100644 --- a/server/routers/client/createClient.ts +++ b/server/routers/client/createClient.ts @@ -65,7 +65,7 @@ registry.registerPath({ content: { "application/json": { schema: z.object({ - data: z.unknown().nullable(), + data: z.record(z.string(), z.any()).nullable(), success: z.boolean(), error: z.boolean(), message: z.string(), diff --git a/server/routers/client/createUserClient.ts b/server/routers/client/createUserClient.ts index c51c4fe6a..09bec218a 100644 --- a/server/routers/client/createUserClient.ts +++ b/server/routers/client/createUserClient.ts @@ -66,7 +66,7 @@ registry.registerPath({ content: { "application/json": { schema: z.object({ - data: z.unknown().nullable(), + data: z.record(z.string(), z.any()).nullable(), success: z.boolean(), error: z.boolean(), message: z.string(), diff --git a/server/routers/client/deleteClient.ts b/server/routers/client/deleteClient.ts index 21d6f1c9f..24ab9917a 100644 --- a/server/routers/client/deleteClient.ts +++ b/server/routers/client/deleteClient.ts @@ -31,7 +31,7 @@ registry.registerPath({ content: { "application/json": { schema: z.object({ - data: z.unknown().nullable(), + data: z.record(z.string(), z.any()).nullable(), success: z.boolean(), error: z.boolean(), message: z.string(), diff --git a/server/routers/client/getClient.ts b/server/routers/client/getClient.ts index 936d62b7d..21b49cf7a 100644 --- a/server/routers/client/getClient.ts +++ b/server/routers/client/getClient.ts @@ -259,7 +259,7 @@ registry.registerPath({ content: { "application/json": { schema: z.object({ - data: z.unknown().nullable(), + data: z.record(z.string(), z.any()).nullable(), success: z.boolean(), error: z.boolean(), message: z.string(), @@ -287,7 +287,7 @@ registry.registerPath({ content: { "application/json": { schema: z.object({ - data: z.unknown().nullable(), + data: z.record(z.string(), z.any()).nullable(), success: z.boolean(), error: z.boolean(), message: z.string(), @@ -340,18 +340,18 @@ export async function getClient( // Build fingerprint data if available const fingerprintData = client.currentFingerprint ? { - username: client.currentFingerprint.username || null, - hostname: client.currentFingerprint.hostname || null, - platform: client.currentFingerprint.platform || null, - osVersion: client.currentFingerprint.osVersion || null, - kernelVersion: - client.currentFingerprint.kernelVersion || null, - arch: client.currentFingerprint.arch || null, - deviceModel: client.currentFingerprint.deviceModel || null, - serialNumber: client.currentFingerprint.serialNumber || null, - firstSeen: client.currentFingerprint.firstSeen || null, - lastSeen: client.currentFingerprint.lastSeen || null - } + username: client.currentFingerprint.username || null, + hostname: client.currentFingerprint.hostname || null, + platform: client.currentFingerprint.platform || null, + osVersion: client.currentFingerprint.osVersion || null, + kernelVersion: + client.currentFingerprint.kernelVersion || null, + arch: client.currentFingerprint.arch || null, + deviceModel: client.currentFingerprint.deviceModel || null, + serialNumber: client.currentFingerprint.serialNumber || null, + firstSeen: client.currentFingerprint.firstSeen || null, + lastSeen: client.currentFingerprint.lastSeen || null + } : null; // Build posture data if available (platform-specific) diff --git a/server/routers/client/listClients.ts b/server/routers/client/listClients.ts index 1fffdcba9..9178c27a5 100644 --- a/server/routers/client/listClients.ts +++ b/server/routers/client/listClients.ts @@ -218,7 +218,7 @@ registry.registerPath({ content: { "application/json": { schema: z.object({ - data: z.unknown().nullable(), + data: z.record(z.string(), z.any()).nullable(), success: z.boolean(), error: z.boolean(), message: z.string(), diff --git a/server/routers/client/listUserDevices.ts b/server/routers/client/listUserDevices.ts index 82b52577e..5a864f93b 100644 --- a/server/routers/client/listUserDevices.ts +++ b/server/routers/client/listUserDevices.ts @@ -219,7 +219,7 @@ registry.registerPath({ content: { "application/json": { schema: z.object({ - data: z.unknown().nullable(), + data: z.record(z.string(), z.any()).nullable(), success: z.boolean(), error: z.boolean(), message: z.string(), diff --git a/server/routers/client/targets.ts b/server/routers/client/targets.ts index b2d49db4c..c208acd88 100644 --- a/server/routers/client/targets.ts +++ b/server/routers/client/targets.ts @@ -13,7 +13,7 @@ import semver from "semver"; const NEWT_V2_TARGETS_VERSION = ">=1.10.3"; -export async function convertTargetsIfNessicary( +export async function convertTargetsIfNecessary( newtId: string, targets: SubnetProxyTarget[] | SubnetProxyTargetV2[] ) { @@ -47,7 +47,7 @@ export async function addTargets( targets: SubnetProxyTarget[] | SubnetProxyTargetV2[], version?: string | null ) { - targets = await convertTargetsIfNessicary(newtId, targets); + targets = await convertTargetsIfNecessary(newtId, targets); await sendToClient( newtId, @@ -64,7 +64,7 @@ export async function removeTargets( targets: SubnetProxyTarget[] | SubnetProxyTargetV2[], version?: string | null ) { - targets = await convertTargetsIfNessicary(newtId, targets); + targets = await convertTargetsIfNecessary(newtId, targets); await sendToClient( newtId, diff --git a/server/routers/client/unarchiveClient.ts b/server/routers/client/unarchiveClient.ts index e8b3aef5a..4ca2503ca 100644 --- a/server/routers/client/unarchiveClient.ts +++ b/server/routers/client/unarchiveClient.ts @@ -28,7 +28,7 @@ registry.registerPath({ content: { "application/json": { schema: z.object({ - data: z.unknown().nullable(), + data: z.record(z.string(), z.any()).nullable(), success: z.boolean(), error: z.boolean(), message: z.string(), diff --git a/server/routers/client/unblockClient.ts b/server/routers/client/unblockClient.ts index fa02394a5..f0c1cff1d 100644 --- a/server/routers/client/unblockClient.ts +++ b/server/routers/client/unblockClient.ts @@ -28,7 +28,7 @@ registry.registerPath({ content: { "application/json": { schema: z.object({ - data: z.unknown().nullable(), + data: z.record(z.string(), z.any()).nullable(), success: z.boolean(), error: z.boolean(), message: z.string(), diff --git a/server/routers/client/updateClient.ts b/server/routers/client/updateClient.ts index f8794960c..2c1eccd48 100644 --- a/server/routers/client/updateClient.ts +++ b/server/routers/client/updateClient.ts @@ -42,7 +42,7 @@ registry.registerPath({ content: { "application/json": { schema: z.object({ - data: z.unknown().nullable(), + data: z.record(z.string(), z.any()).nullable(), success: z.boolean(), error: z.boolean(), message: z.string(), diff --git a/server/routers/domain/getDNSRecords.ts b/server/routers/domain/getDNSRecords.ts index 8a557c6cf..a064d5c9f 100644 --- a/server/routers/domain/getDNSRecords.ts +++ b/server/routers/domain/getDNSRecords.ts @@ -43,7 +43,7 @@ registry.registerPath({ content: { "application/json": { schema: z.object({ - data: z.unknown().nullable(), + data: z.record(z.string(), z.any()).nullable(), success: z.boolean(), error: z.boolean(), message: z.string(), diff --git a/server/routers/domain/getDomain.ts b/server/routers/domain/getDomain.ts index 3c484505b..9c5d2bd88 100644 --- a/server/routers/domain/getDomain.ts +++ b/server/routers/domain/getDomain.ts @@ -44,7 +44,7 @@ registry.registerPath({ content: { "application/json": { schema: z.object({ - data: z.unknown().nullable(), + data: z.record(z.string(), z.any()).nullable(), success: z.boolean(), error: z.boolean(), message: z.string(), diff --git a/server/routers/external.ts b/server/routers/external.ts index db0db594a..960c00249 100644 --- a/server/routers/external.ts +++ b/server/routers/external.ts @@ -666,6 +666,13 @@ authenticated.get( resource.getResourcePolicies ); +authenticated.get( + "/resource-policy/:resourcePolicyId", + verifyResourcePolicyAccess, + verifyUserHasAction(ActionsEnum.getResourcePolicy), + policy.getResourcePolicy +); + authenticated.put( "/resource-policy/:resourcePolicyId", verifyResourcePolicyAccess, diff --git a/server/routers/idp/deleteIdp.ts b/server/routers/idp/deleteIdp.ts index e6d330263..e2447a42c 100644 --- a/server/routers/idp/deleteIdp.ts +++ b/server/routers/idp/deleteIdp.ts @@ -31,7 +31,7 @@ registry.registerPath({ content: { "application/json": { schema: z.object({ - data: z.unknown().nullable(), + data: z.record(z.string(), z.any()).nullable(), success: z.boolean(), error: z.boolean(), message: z.string(), diff --git a/server/routers/idp/deleteIdpOrgPolicy.ts b/server/routers/idp/deleteIdpOrgPolicy.ts index 716de4a9d..27842404b 100644 --- a/server/routers/idp/deleteIdpOrgPolicy.ts +++ b/server/routers/idp/deleteIdpOrgPolicy.ts @@ -29,7 +29,7 @@ registry.registerPath({ content: { "application/json": { schema: z.object({ - data: z.unknown().nullable(), + data: z.record(z.string(), z.any()).nullable(), success: z.boolean(), error: z.boolean(), message: z.string(), diff --git a/server/routers/idp/getIdp.ts b/server/routers/idp/getIdp.ts index 23f7990d5..10aa71c8b 100644 --- a/server/routers/idp/getIdp.ts +++ b/server/routers/idp/getIdp.ts @@ -44,7 +44,7 @@ registry.registerPath({ content: { "application/json": { schema: z.object({ - data: z.unknown().nullable(), + data: z.record(z.string(), z.any()).nullable(), success: z.boolean(), error: z.boolean(), message: z.string(), diff --git a/server/routers/idp/validateOidcCallback.ts b/server/routers/idp/validateOidcCallback.ts index b5415c52d..8188f46da 100644 --- a/server/routers/idp/validateOidcCallback.ts +++ b/server/routers/idp/validateOidcCallback.ts @@ -332,17 +332,6 @@ export async function validateOidcCallback( .where(eq(idpOrg.idpId, existingIdp.idp.idpId)) .innerJoin(orgs, eq(orgs.orgId, idpOrg.orgId)); 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 { allOrgs = await db.select().from(orgs); } diff --git a/server/routers/newt/buildConfiguration.ts b/server/routers/newt/buildConfiguration.ts index 135920d6f..154ab38cb 100644 --- a/server/routers/newt/buildConfiguration.ts +++ b/server/routers/newt/buildConfiguration.ts @@ -1,6 +1,4 @@ import { - browserGatewayTarget, - BrowserGatewayTarget, clients, clientSiteResourcesAssociationsCache, clientSitesAssociationsCache, @@ -16,7 +14,7 @@ import { } from "@server/db"; import logger from "@server/logger"; 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 { decrypt } from "@server/lib/crypto"; import { @@ -154,34 +152,64 @@ export async function buildClientConfigurationForNewtClient( const targetsToSend: SubnetProxyTargetV2[] = []; - for (const resource of allSiteResources) { - // Get clients associated with this specific resource - const resourceClients = await db - .select({ - clientId: clients.clientId, - pubKey: clients.pubKey, - subnet: clients.subnet - }) - .from(clients) - .innerJoin( - clientSiteResourcesAssociationsCache, - eq( - clients.clientId, - clientSiteResourcesAssociationsCache.clientId - ) - ) - .where( - eq( - clientSiteResourcesAssociationsCache.siteResourceId, - resource.siteResourceId - ) - ); + if (allSiteResources.length === 0) { + return { + peers: validPeers, + targets: targetsToSend + }; + } - const resourceTargets = await generateSubnetProxyTargetV2( - resource, - resourceClients + // Batch fetch all client associations for every site resource in one query + // to avoid an N+1 lookup that would issue thousands of queries when a site + // has many resources. + const siteResourceIds = allSiteResources.map((r) => r.siteResourceId); + + const resourceClientRows = await db + .select({ + siteResourceId: clientSiteResourcesAssociationsCache.siteResourceId, + clientId: clients.clientId, + pubKey: clients.pubKey, + subnet: clients.subnet + }) + .from(clients) + .innerJoin( + clientSiteResourcesAssociationsCache, + eq(clients.clientId, clientSiteResourcesAssociationsCache.clientId) + ) + .where( + inArray( + clientSiteResourcesAssociationsCache.siteResourceId, + siteResourceIds + ) ); + const clientsByResourceId = new Map< + number, + { clientId: number; pubKey: string | null; subnet: string | null }[] + >(); + for (const row of resourceClientRows) { + let list = clientsByResourceId.get(row.siteResourceId); + if (!list) { + list = []; + clientsByResourceId.set(row.siteResourceId, list); + } + list.push({ + clientId: row.clientId, + pubKey: row.pubKey, + subnet: row.subnet + }); + } + + const resourceTargetsArr = await Promise.all( + allSiteResources.map((resource) => + generateSubnetProxyTargetV2( + resource, + clientsByResourceId.get(resource.siteResourceId) ?? [] + ) + ) + ); + + for (const resourceTargets of resourceTargetsArr) { if (resourceTargets) { targetsToSend.push(...resourceTargets); } @@ -211,7 +239,13 @@ export async function buildTargetConfigurationForNewtClient( }) .from(targets) .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 .select({ @@ -236,10 +270,27 @@ export async function buildTargetConfigurationForNewtClient( .from(targetHealthCheck) .where(eq(targetHealthCheck.siteId, siteId)); + // Get all enabled targets with their resource mode information const allBrowserGatewayTargets = await db - .select() - .from(browserGatewayTarget) - .where(eq(browserGatewayTarget.siteId, siteId)); + .select({ + resourceId: targets.resourceId, + 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( (acc, target) => { @@ -315,12 +366,15 @@ export async function buildTargetConfigurationForNewtClient( const serverSecret = config.getRawConfig().server.secret!; const browserGatewayTargets = allBrowserGatewayTargets.map((t) => { + if (!t.ip || !t.port || !t.authToken) { + return null; + } const decryptAuthToken = decrypt(t.authToken, serverSecret); return { - id: t.browserGatewayTargetId, - type: t.type, - destination: t.destination, - destinationPort: t.destinationPort, + id: t.targetId, + type: t.mode, + destination: t.ip, + destinationPort: t.port, authToken: decryptAuthToken }; }); diff --git a/server/routers/newt/getNewtVersion.ts b/server/routers/newt/getNewtVersion.ts index 0dd0eff18..8a76bc3d2 100644 --- a/server/routers/newt/getNewtVersion.ts +++ b/server/routers/newt/getNewtVersion.ts @@ -56,13 +56,18 @@ async function getLatestReleaseInfo(): Promise { return staleReleaseInfo; } - // Drop drafts, pre-releases, and anything with "rc" in the tag name. + const oneDayAgo = new Date(Date.now() - 24 * 60 * 60 * 1000); + + // Drop drafts, pre-releases, anything with "rc" in the tag name, + // and releases published less than 1 day ago. releases = releases.filter( (r: any) => !r.draft && !r.prerelease && !r.tag_name.includes("rc") && - !r.tag_name.includes("v") + !r.tag_name.includes("v") && + r.published_at && + new Date(r.published_at) <= oneDayAgo ); // Sort descending by semver to find the true latest stable release. diff --git a/server/routers/newt/handleNewtGetConfigMessage.ts b/server/routers/newt/handleNewtGetConfigMessage.ts index 787151a5a..d78fa6f71 100644 --- a/server/routers/newt/handleNewtGetConfigMessage.ts +++ b/server/routers/newt/handleNewtGetConfigMessage.ts @@ -6,7 +6,7 @@ import { db, ExitNode, exitNodes, Newt, sites } from "@server/db"; import { eq } from "drizzle-orm"; import { sendToExitNode } from "#dynamic/lib/exitNodes"; import { buildClientConfigurationForNewtClient } from "./buildConfiguration"; -import { convertTargetsIfNessicary } from "../client/targets"; +import { convertTargetsIfNecessary } from "../client/targets"; import { canCompress } from "@server/lib/clientVersionChecks"; import config from "@server/lib/config"; @@ -113,7 +113,7 @@ export const handleNewtGetConfigMessage: MessageHandler = async (context) => { exitNode ); - const targetsToSend = await convertTargetsIfNessicary(newt.newtId, targets); // for backward compatibility with old newt versions that don't support the new target format + const targetsToSend = await convertTargetsIfNecessary(newt.newtId, targets); // for backward compatibility with old newt versions that don't support the new target format return { message: { diff --git a/server/routers/newt/targets.ts b/server/routers/newt/targets.ts index 6d8212b12..44aa34637 100644 --- a/server/routers/newt/targets.ts +++ b/server/routers/newt/targets.ts @@ -1,4 +1,4 @@ -import { BrowserGatewayTarget, Target, TargetHealthCheck } from "@server/db"; +import { Target, TargetHealthCheck } from "@server/db"; import { sendToClient } from "#dynamic/routers/ws"; import logger from "@server/logger"; import { canCompress } from "@server/lib/clientVersionChecks"; @@ -244,23 +244,27 @@ export async function removeTargets( export async function sendBrowserGatewayTargets( newtId: string, - targets: BrowserGatewayTarget[], + targets: Target[], version?: string | null ) { 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( - t.authToken, + t.authToken!, config.getRawConfig().server.secret! ); return { - id: t.browserGatewayTargetId, + id: t.targetId, resourceId: t.resourceId, siteId: t.siteId, - type: t.type, - destination: t.destination, - destinationPort: t.destinationPort, + type: t.mode, + destination: t.ip, + destinationPort: t.port, authToken: decryptAuthToken }; }); diff --git a/server/routers/olm/createUserOlm.ts b/server/routers/olm/createUserOlm.ts index 486546ada..714fb4b35 100644 --- a/server/routers/olm/createUserOlm.ts +++ b/server/routers/olm/createUserOlm.ts @@ -49,7 +49,7 @@ export type CreateOlmResponse = { // content: { // "application/json": { // schema: z.object({ -// data: z.unknown().nullable(), +// data: z.record(z.string(), z.any()).nullable(), // success: z.boolean(), // error: z.boolean(), // message: z.string(), diff --git a/server/routers/olm/deleteUserOlm.ts b/server/routers/olm/deleteUserOlm.ts index df9328dd3..861a413d8 100644 --- a/server/routers/olm/deleteUserOlm.ts +++ b/server/routers/olm/deleteUserOlm.ts @@ -34,7 +34,7 @@ const paramsSchema = z // content: { // "application/json": { // schema: z.object({ -// data: z.unknown().nullable(), +// data: z.record(z.string(), z.any()).nullable(), // success: z.boolean(), // error: z.boolean(), // message: z.string(), diff --git a/server/routers/olm/getOlmToken.ts b/server/routers/olm/getOlmToken.ts index 51cdc8b4e..f7fdb81a8 100644 --- a/server/routers/olm/getOlmToken.ts +++ b/server/routers/olm/getOlmToken.ts @@ -13,7 +13,7 @@ import { import { olms } from "@server/db"; import HttpCode from "@server/types/HttpCode"; 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 createHttpError from "http-errors"; import { z } from "zod"; @@ -24,6 +24,7 @@ import { EXPIRES } from "@server/auth/sessions/olm"; import { getOrCreateCachedToken } from "#dynamic/lib/tokenCache"; +import { listExitNodes } from "#dynamic/lib/exitNodes"; import { verifyPassword } from "@server/auth/password"; import logger from "@server/logger"; import config from "@server/lib/config"; @@ -150,6 +151,7 @@ export async function getOlmToken( ); let clientIdToUse; + let orgIdToUse: string; if (orgId) { // we did provide the org const [client] = await db @@ -183,6 +185,7 @@ export async function getOlmToken( } clientIdToUse = client.clientId; + orgIdToUse = orgId; } else { if (!existingOlm.clientId) { return next( @@ -209,6 +212,7 @@ export async function getOlmToken( } clientIdToUse = client.clientId; + orgIdToUse = client.orgId; } // 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 { publicKey: exitNode.publicKey, 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"); return response<{ diff --git a/server/routers/olm/getUserOlm.ts b/server/routers/olm/getUserOlm.ts index 534ae3bb7..b3f09a2b2 100644 --- a/server/routers/olm/getUserOlm.ts +++ b/server/routers/olm/getUserOlm.ts @@ -36,7 +36,7 @@ const querySchema = z.object({ // content: { // "application/json": { // schema: z.object({ -// data: z.unknown().nullable(), +// data: z.record(z.string(), z.any()).nullable(), // success: z.boolean(), // error: z.boolean(), // message: z.string(), diff --git a/server/routers/olm/listUserOlms.ts b/server/routers/olm/listUserOlms.ts index d6ac26819..b2db262e6 100644 --- a/server/routers/olm/listUserOlms.ts +++ b/server/routers/olm/listUserOlms.ts @@ -47,7 +47,7 @@ const paramsSchema = z // content: { // "application/json": { // schema: z.object({ -// data: z.unknown().nullable(), +// data: z.record(z.string(), z.any()).nullable(), // success: z.boolean(), // error: z.boolean(), // message: z.string(), diff --git a/server/routers/org/checkOrgUserAccess.ts b/server/routers/org/checkOrgUserAccess.ts index c40c85aaa..78afee701 100644 --- a/server/routers/org/checkOrgUserAccess.ts +++ b/server/routers/org/checkOrgUserAccess.ts @@ -49,10 +49,7 @@ async function queryUser(orgId: string, userId: string) { .from(userOrgRoles) .leftJoin(roles, eq(userOrgRoles.roleId, roles.roleId)) .where( - and( - eq(userOrgRoles.userId, userId), - eq(userOrgRoles.orgId, orgId) - ) + and(eq(userOrgRoles.userId, userId), eq(userOrgRoles.orgId, orgId)) ); const isAdmin = roleRows.some((r) => r.isAdmin); @@ -89,7 +86,7 @@ registry.registerPath({ content: { "application/json": { schema: z.object({ - data: z.unknown().nullable(), + data: z.record(z.string(), z.any()).nullable(), success: z.boolean(), error: z.boolean(), message: z.string(), diff --git a/server/routers/org/createOrg.ts b/server/routers/org/createOrg.ts index a195e3a33..7b2b1f87a 100644 --- a/server/routers/org/createOrg.ts +++ b/server/routers/org/createOrg.ts @@ -80,7 +80,7 @@ registry.registerPath({ content: { "application/json": { schema: z.object({ - data: z.unknown().nullable(), + data: z.record(z.string(), z.any()).nullable(), success: z.boolean(), error: z.boolean(), message: z.string(), diff --git a/server/routers/org/deleteOrg.ts b/server/routers/org/deleteOrg.ts index 5beffb536..a81b23504 100644 --- a/server/routers/org/deleteOrg.ts +++ b/server/routers/org/deleteOrg.ts @@ -30,7 +30,7 @@ registry.registerPath({ content: { "application/json": { schema: z.object({ - data: z.unknown().nullable(), + data: z.record(z.string(), z.any()).nullable(), success: z.boolean(), error: z.boolean(), message: z.string(), diff --git a/server/routers/org/listUserOrgs.ts b/server/routers/org/listUserOrgs.ts index 465dd5801..c48f2fa91 100644 --- a/server/routers/org/listUserOrgs.ts +++ b/server/routers/org/listUserOrgs.ts @@ -43,7 +43,7 @@ const listOrgsSchema = z.object({ // content: { // "application/json": { // schema: z.object({ -// data: z.unknown().nullable(), +// data: z.record(z.string(), z.any()).nullable(), // success: z.boolean(), // error: z.boolean(), // message: z.string(), diff --git a/server/routers/org/resetOrgBandwidth.ts b/server/routers/org/resetOrgBandwidth.ts index 41ad761e3..0e605dba1 100644 --- a/server/routers/org/resetOrgBandwidth.ts +++ b/server/routers/org/resetOrgBandwidth.ts @@ -27,7 +27,7 @@ registry.registerPath({ content: { "application/json": { schema: z.object({ - data: z.unknown().nullable(), + data: z.record(z.string(), z.any()).nullable(), success: z.boolean(), error: z.boolean(), message: z.string(), diff --git a/server/routers/org/updateOrg.ts b/server/routers/org/updateOrg.ts index 2f20d3760..f98bdec27 100644 --- a/server/routers/org/updateOrg.ts +++ b/server/routers/org/updateOrg.ts @@ -68,7 +68,7 @@ registry.registerPath({ content: { "application/json": { schema: z.object({ - data: z.unknown().nullable(), + data: z.record(z.string(), z.any()).nullable(), success: z.boolean(), error: z.boolean(), message: z.string(), diff --git a/server/routers/policy/setResourcePolicyRules.ts b/server/routers/policy/setResourcePolicyRules.ts index 533e01c0e..f15c1e51a 100644 --- a/server/routers/policy/setResourcePolicyRules.ts +++ b/server/routers/policy/setResourcePolicyRules.ts @@ -8,9 +8,8 @@ import createHttpError from "http-errors"; import logger from "@server/logger"; import { fromError } from "zod-validation-error"; import { - isValidCIDR, - isValidIP, - isValidUrlGlobPattern + getResourceRuleValueValidationError, + RESOURCE_RULE_MATCH_TYPES } from "@server/lib/validators"; import { OpenAPITags, registry } from "@server/openApi"; @@ -20,9 +19,9 @@ const ruleSchema = z.strictObject({ enum: ["ACCEPT", "DROP", "PASS"], description: "rule action" }), - match: z.enum(["CIDR", "IP", "PATH"]).openapi({ + match: z.enum(RESOURCE_RULE_MATCH_TYPES).openapi({ type: "string", - enum: ["CIDR", "IP", "PATH"], + enum: [...RESOURCE_RULE_MATCH_TYPES], description: "rule match" }), value: z.string().min(1), @@ -105,26 +104,13 @@ export async function setResourcePolicyRules( } for (const rule of rules) { - if (rule.match === "CIDR" && !isValidCIDR(rule.value)) { + const validationError = getResourceRuleValueValidationError( + rule.match, + rule.value + ); + if (validationError) { return next( - createHttpError( - 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" - ) + createHttpError(HttpCode.BAD_REQUEST, validationError) ); } } diff --git a/server/routers/resource/addEmailToResourceWhitelist.ts b/server/routers/resource/addEmailToResourceWhitelist.ts index 1ad10000f..506c2caa9 100644 --- a/server/routers/resource/addEmailToResourceWhitelist.ts +++ b/server/routers/resource/addEmailToResourceWhitelist.ts @@ -46,7 +46,7 @@ registry.registerPath({ content: { "application/json": { schema: z.object({ - data: z.unknown().nullable(), + data: z.record(z.string(), z.any()).nullable(), success: z.boolean(), error: z.boolean(), message: z.string(), diff --git a/server/routers/resource/addRoleToResource.ts b/server/routers/resource/addRoleToResource.ts index 8192f779c..a1d493631 100644 --- a/server/routers/resource/addRoleToResource.ts +++ b/server/routers/resource/addRoleToResource.ts @@ -1,7 +1,7 @@ import { Request, Response, NextFunction } from "express"; import { z } from "zod"; 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 HttpCode from "@server/types/HttpCode"; import createHttpError from "http-errors"; @@ -28,7 +28,8 @@ const addRoleToResourceParamsSchema = z registry.registerPath({ method: "post", path: "/resource/{resourceId}/roles/add", - description: "Add a single role to a resource.", + description: + "Add a single role to a resource. When the resource has an inline policy defined (no shared resource policy assigned), the role is added to the inline policy instead of directly to the resource.", tags: [OpenAPITags.PublicResource, OpenAPITags.Role], request: { params: addRoleToResourceParamsSchema, @@ -46,7 +47,7 @@ registry.registerPath({ content: { "application/json": { schema: z.object({ - data: z.unknown().nullable(), + data: z.record(z.string(), z.any()).nullable(), success: z.boolean(), error: z.boolean(), message: z.string(), @@ -131,31 +132,64 @@ export async function addRoleToResource( ); } - // Check if role already exists in resource - const existingEntry = await db - .select() - .from(roleResources) - .where( - and( - eq(roleResources.resourceId, resourceId), - eq(roleResources.roleId, roleId) - ) - ); + const isInlinePolicy = + resource.resourcePolicyId === null && + resource.defaultResourcePolicyId !== null; - if (existingEntry.length > 0) { - return next( - createHttpError( - HttpCode.CONFLICT, - "Role already assigned to resource" - ) - ); + if (isInlinePolicy) { + const policyId = resource.defaultResourcePolicyId!; + + // Check if role already exists in the inline policy + 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, { data: {}, success: true, diff --git a/server/routers/resource/addUserToResource.ts b/server/routers/resource/addUserToResource.ts index 3a75b0043..ebe43fccd 100644 --- a/server/routers/resource/addUserToResource.ts +++ b/server/routers/resource/addUserToResource.ts @@ -1,7 +1,7 @@ import { Request, Response, NextFunction } from "express"; import { z } from "zod"; import { db, resources } from "@server/db"; -import { userResources } from "@server/db"; +import { userResources, userPolicies } from "@server/db"; import response from "@server/lib/response"; import HttpCode from "@server/types/HttpCode"; import createHttpError from "http-errors"; @@ -28,7 +28,8 @@ const addUserToResourceParamsSchema = z registry.registerPath({ method: "post", path: "/resource/{resourceId}/users/add", - description: "Add a single user to a resource.", + description: + "Add a single user to a resource. When the resource has an inline policy defined (no shared resource policy assigned), the user is added to the inline policy instead of directly to the resource.", tags: [OpenAPITags.PublicResource, OpenAPITags.User], request: { params: addUserToResourceParamsSchema, @@ -46,7 +47,7 @@ registry.registerPath({ content: { "application/json": { schema: z.object({ - data: z.unknown().nullable(), + data: z.record(z.string(), z.any()).nullable(), success: z.boolean(), error: z.boolean(), message: z.string(), @@ -103,31 +104,64 @@ export async function addUserToResource( ); } - // Check if user already exists in resource - const existingEntry = await db - .select() - .from(userResources) - .where( - and( - eq(userResources.resourceId, resourceId), - eq(userResources.userId, userId) - ) - ); + const isInlinePolicy = + resource.resourcePolicyId === null && + resource.defaultResourcePolicyId !== null; - if (existingEntry.length > 0) { - return next( - createHttpError( - HttpCode.CONFLICT, - "User already assigned to resource" - ) - ); + if (isInlinePolicy) { + const policyId = resource.defaultResourcePolicyId!; + + // Check if user already exists in the inline policy + 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, { data: {}, success: true, diff --git a/server/routers/resource/createResource.ts b/server/routers/resource/createResource.ts index 0bb90e7b8..b9547bfbf 100644 --- a/server/routers/resource/createResource.ts +++ b/server/routers/resource/createResource.ts @@ -168,7 +168,7 @@ registry.registerPath({ content: { "application/json": { schema: z.object({ - data: z.unknown().nullable(), + data: z.record(z.string(), z.any()).nullable(), success: z.boolean(), error: z.boolean(), message: z.string(), diff --git a/server/routers/resource/createResourceRule.ts b/server/routers/resource/createResourceRule.ts index f55eb4112..9d2261338 100644 --- a/server/routers/resource/createResourceRule.ts +++ b/server/routers/resource/createResourceRule.ts @@ -1,7 +1,7 @@ import { Request, Response, NextFunction } from "express"; import { z } from "zod"; import { db } from "@server/db"; -import { resourceRules, resources } from "@server/db"; +import { resourceRules, resourcePolicyRules, resources } from "@server/db"; import { eq } from "drizzle-orm"; import response from "@server/lib/response"; import HttpCode from "@server/types/HttpCode"; @@ -49,7 +49,7 @@ registry.registerPath({ content: { "application/json": { schema: z.object({ - data: z.unknown().nullable(), + data: z.record(z.string(), z.any()).nullable(), success: z.boolean(), error: z.boolean(), message: z.string(), @@ -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 const [newRule] = await db .insert(resourceRules) diff --git a/server/routers/resource/deleteResource.ts b/server/routers/resource/deleteResource.ts index fa8bd96bf..a959611ec 100644 --- a/server/routers/resource/deleteResource.ts +++ b/server/routers/resource/deleteResource.ts @@ -37,7 +37,7 @@ registry.registerPath({ content: { "application/json": { schema: z.object({ - data: z.unknown().nullable(), + data: z.record(z.string(), z.any()).nullable(), success: z.boolean(), error: z.boolean(), message: z.string(), diff --git a/server/routers/resource/deleteResourceRule.ts b/server/routers/resource/deleteResourceRule.ts index ef40ecaab..c619a693e 100644 --- a/server/routers/resource/deleteResourceRule.ts +++ b/server/routers/resource/deleteResourceRule.ts @@ -1,7 +1,7 @@ import { Request, Response, NextFunction } from "express"; import { z } from "zod"; import { db } from "@server/db"; -import { resourceRules, resources } from "@server/db"; +import { resourceRules, resourcePolicyRules, resources } from "@server/db"; import { eq } from "drizzle-orm"; import response from "@server/lib/response"; import HttpCode from "@server/types/HttpCode"; @@ -29,7 +29,7 @@ registry.registerPath({ content: { "application/json": { schema: z.object({ - data: z.unknown().nullable(), + data: z.record(z.string(), z.any()).nullable(), success: z.boolean(), error: z.boolean(), message: z.string(), @@ -59,6 +59,48 @@ export async function deleteResourceRule( 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 const [deletedRule] = await db .delete(resourceRules) diff --git a/server/routers/resource/getResource.ts b/server/routers/resource/getResource.ts index 3a650578c..708351db1 100644 --- a/server/routers/resource/getResource.ts +++ b/server/routers/resource/getResource.ts @@ -1,4 +1,4 @@ -import { db, resources } from "@server/db"; +import { db, resourcePolicies, resources } from "@server/db"; import response from "@server/lib/response"; import stoi from "@server/lib/stoi"; import logger from "@server/logger"; @@ -41,6 +41,15 @@ async function query(resourceId?: number, niceId?: string, orgId?: string) { } } +async function queryInlinePolicy(resourcePolicyId: number) { + const [res] = await db + .select() + .from(resourcePolicies) + .where(eq(resourcePolicies.resourcePolicyId, resourcePolicyId)) + .limit(1); + return res; +} + export type GetResourceResponse = Omit< NonNullable>>, "headers" @@ -66,7 +75,7 @@ registry.registerPath({ content: { "application/json": { schema: z.object({ - data: z.unknown().nullable(), + data: z.record(z.string(), z.any()).nullable(), success: z.boolean(), error: z.boolean(), message: z.string(), @@ -94,7 +103,7 @@ registry.registerPath({ content: { "application/json": { schema: z.object({ - data: z.unknown().nullable(), + data: z.record(z.string(), z.any()).nullable(), success: z.boolean(), error: z.boolean(), message: z.string(), @@ -132,12 +141,31 @@ export async function getResource( ); } + const isInlinePolicy = + resource.resourcePolicyId === null && + resource.defaultResourcePolicyId !== null; + + let returnData = resource; + if (isInlinePolicy) { + // get the policy + const policy = await queryInlinePolicy( + resource.defaultResourcePolicyId! + ); + returnData = { + ...returnData, + sso: policy?.sso || null, + emailWhitelistEnabled: policy?.emailWhitelistEnabled || null, + applyRules: policy?.applyRules || null, + skipToIdpId: policy?.idpId || null + }; + } + return response(res, { data: { - ...resource, - headers: resource.headers - ? JSON.parse(resource.headers) - : resource.headers + ...returnData, + headers: returnData.headers + ? JSON.parse(returnData.headers) + : returnData.headers }, success: true, error: false, diff --git a/server/routers/resource/getResourceAuthInfo.ts b/server/routers/resource/getResourceAuthInfo.ts index 4957781f5..f1328833d 100644 --- a/server/routers/resource/getResourceAuthInfo.ts +++ b/server/routers/resource/getResourceAuthInfo.ts @@ -225,7 +225,7 @@ export async function getResourceAuthInfo( wildcard: resource.wildcard ?? false, fullDomain: resource.fullDomain, whitelist: effectivePolicy?.emailWhitelistEnabled ?? false, - skipToIdpId: resource.skipToIdpId, + skipToIdpId: effectivePolicy?.idpId ?? resource.skipToIdpId, orgId: resource.orgId, postAuthPath: resource.postAuthPath ?? null }, diff --git a/server/routers/resource/getResourceWhitelist.ts b/server/routers/resource/getResourceWhitelist.ts index bb6105b0b..2d882d49e 100644 --- a/server/routers/resource/getResourceWhitelist.ts +++ b/server/routers/resource/getResourceWhitelist.ts @@ -1,7 +1,11 @@ import { Request, Response, NextFunction } from "express"; import { z } from "zod"; 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 response from "@server/lib/response"; import HttpCode from "@server/types/HttpCode"; @@ -23,6 +27,15 @@ async function queryWhitelist(resourceId: number) { .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 = { whitelist: NonNullable>>; }; @@ -41,7 +54,7 @@ registry.registerPath({ content: { "application/json": { schema: z.object({ - data: z.unknown().nullable(), + data: z.record(z.string(), z.any()).nullable(), success: z.boolean(), error: z.boolean(), message: z.string(), @@ -71,7 +84,25 @@ export async function getResourceWhitelist( 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(res, { data: { diff --git a/server/routers/resource/getUserResources.ts b/server/routers/resource/getUserResources.ts index 197699a68..c82dcac48 100644 --- a/server/routers/resource/getUserResources.ts +++ b/server/routers/resource/getUserResources.ts @@ -1,6 +1,6 @@ import { Request, Response, NextFunction } from "express"; -import { db, DB_TYPE } from "@server/db"; -import { and, eq, or, inArray, sql } from "drizzle-orm"; +import { db, DB_TYPE, type Label } from "@server/db"; +import { and, asc, eq, or, inArray, sql } from "drizzle-orm"; import { resources, userResources, @@ -20,12 +20,17 @@ import { userSiteResources, roleSiteResources, siteNetworks, - sites + sites, + labels, + resourceLabels, + siteResourceLabels } from "@server/db"; import createHttpError from "http-errors"; import HttpCode from "@server/types/HttpCode"; import { response } from "@server/lib/response"; import { getFirstString } from "@server/lib/requestParams"; +import { isLicensedOrSubscribed } from "#dynamic/lib/isLicencedOrSubscribed"; +import { tierMatrix } from "@server/lib/billing/tierMatrix"; export async function getUserResources( req: Request, @@ -198,9 +203,9 @@ export async function getUserResources( fullDomain: string | null; ssl: boolean; enabled: boolean; - sso: boolean; + sso: boolean | null; mode: string; - emailWhitelistEnabled: boolean; + emailWhitelistEnabled: boolean | null; policyEmailWhitelistEnabled: boolean | null; }> = []; if (uniqueResourceIds.length > 0) { @@ -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 const resourcesWithAuth = await Promise.all( resourcesData.map(async (resource) => { @@ -453,7 +525,10 @@ export async function getUserResources( sso: resource.sso, password: hasPassword, 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, siteAddresses: siteResource.siteAddresses, 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; protected: boolean; mode: string; + labels?: Array>; }>; siteResources: Array<{ siteResourceId: number; @@ -535,6 +614,7 @@ export type GetUserResourcesResponse = { siteAddresses: (string | null)[]; siteOnlines: boolean[]; type: "site"; + labels?: Array>; }>; }; }; diff --git a/server/routers/resource/listAllResourceNames.ts b/server/routers/resource/listAllResourceNames.ts index 7c4f18b35..e38a3fd69 100644 --- a/server/routers/resource/listAllResourceNames.ts +++ b/server/routers/resource/listAllResourceNames.ts @@ -45,7 +45,7 @@ registry.registerPath({ content: { "application/json": { schema: z.object({ - data: z.unknown().nullable(), + data: z.record(z.string(), z.any()).nullable(), success: z.boolean(), error: z.boolean(), message: z.string(), diff --git a/server/routers/resource/listResourceRoles.ts b/server/routers/resource/listResourceRoles.ts index ffff8c602..ea4d07f64 100644 --- a/server/routers/resource/listResourceRoles.ts +++ b/server/routers/resource/listResourceRoles.ts @@ -1,7 +1,7 @@ import { Request, Response, NextFunction } from "express"; import { z } from "zod"; 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 response from "@server/lib/response"; import HttpCode from "@server/types/HttpCode"; @@ -27,6 +27,19 @@ async function query(resourceId: number) { .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 = { roles: NonNullable>>; }; @@ -45,7 +58,7 @@ registry.registerPath({ content: { "application/json": { schema: z.object({ - data: z.unknown().nullable(), + data: z.record(z.string(), z.any()).nullable(), success: z.boolean(), error: z.boolean(), message: z.string(), @@ -75,7 +88,25 @@ export async function listResourceRoles( 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(res, { data: { diff --git a/server/routers/resource/listResourceRules.ts b/server/routers/resource/listResourceRules.ts index b1b2581ed..6b9df688a 100644 --- a/server/routers/resource/listResourceRules.ts +++ b/server/routers/resource/listResourceRules.ts @@ -1,5 +1,5 @@ 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 response from "@server/lib/response"; import { eq, sql } from "drizzle-orm"; @@ -47,6 +47,21 @@ function queryResourceRules(resourceId: number) { return baseQuery; } +function queryPolicyRules(policyId: number) { + return db + .select({ + ruleId: resourcePolicyRules.ruleId, + resourceId: sql`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 = { rules: Awaited>; pagination: { total: number; limit: number; offset: number }; @@ -67,7 +82,7 @@ registry.registerPath({ content: { "application/json": { schema: z.object({ - data: z.unknown().nullable(), + data: z.record(z.string(), z.any()).nullable(), success: z.boolean(), error: z.boolean(), message: z.string(), @@ -125,16 +140,34 @@ export async function listResourceRules( ); } - const baseQuery = queryResourceRules(resourceId); + const isInlinePolicy = + resource.resourcePolicyId === null && + resource.defaultResourcePolicyId !== null; - const countQuery = db - .select({ count: sql`cast(count(*) as integer)` }) - .from(resourceRules) - .where(eq(resourceRules.resourceId, resourceId)); + let rulesList: Awaited>; + let totalCount: number; - let rulesList = await baseQuery.limit(limit).offset(offset); - const totalCountResult = await countQuery; - const totalCount = totalCountResult[0].count; + if (isInlinePolicy) { + const policyId = resource.defaultResourcePolicyId!; + const policyRules = await queryPolicyRules(policyId) + .limit(limit) + .offset(offset); + const countResult = await db + .select({ count: sql`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`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 rulesList = rulesList.sort((a, b) => a.priority - b.priority); diff --git a/server/routers/resource/listResourceUsers.ts b/server/routers/resource/listResourceUsers.ts index 292edf3cd..c99dbf0ad 100644 --- a/server/routers/resource/listResourceUsers.ts +++ b/server/routers/resource/listResourceUsers.ts @@ -48,7 +48,7 @@ registry.registerPath({ content: { "application/json": { schema: z.object({ - data: z.unknown().nullable(), + data: z.record(z.string(), z.any()).nullable(), success: z.boolean(), error: z.boolean(), message: z.string(), diff --git a/server/routers/resource/listResources.ts b/server/routers/resource/listResources.ts index 57d7a40d0..684c48159 100644 --- a/server/routers/resource/listResources.ts +++ b/server/routers/resource/listResources.ts @@ -1,9 +1,8 @@ import { - browserGatewayTarget, + alias, db, labels, resourceHeaderAuth, - resourceHeaderAuthExtendedCompatibility, resourceLabels, resourcePassword, resourcePincode, @@ -124,6 +123,16 @@ const listResourcesSchema = z.object({ description: "Filter resources based on health status of their targets. `healthy` means all targets are healthy. `degraded` means at least one target is unhealthy, but not all are unhealthy. `offline` means all targets are unhealthy. `unknown` means all targets have unknown health status." }), + protocol: z + .enum(["http", "https", "tcp", "udp", "ssh", "rdp", "vnc"]) + .optional() + .catch(undefined) + .openapi({ + type: "string", + enum: ["http", "https", "tcp", "udp", "ssh", "rdp", "vnc"], + description: + "Filter resources by protocol. `http` and `https` match HTTP resources without and with SSL respectively." + }), siteId: z.coerce.number().int().positive().optional().openapi({ type: "integer", description: @@ -187,16 +196,98 @@ export type ResourceWithTargets = { }; 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` + COALESCE( + CASE + WHEN ${sharedPolicy.resourcePolicyId} IS NOT NULL THEN ${sharedPolicyPassword.passwordId} + ELSE ${defaultPolicyPassword.passwordId} + END, + ${resourcePassword.passwordId} + ) + `; + const effectivePincodeId = sql` + COALESCE( + CASE + WHEN ${sharedPolicy.resourcePolicyId} IS NOT NULL THEN ${sharedPolicyPincode.pincodeId} + ELSE ${defaultPolicyPincode.pincodeId} + END, + ${resourcePincode.pincodeId} + ) + `; + const effectiveHeaderAuthId = sql` + COALESCE( + CASE + WHEN ${sharedPolicy.resourcePolicyId} IS NOT NULL THEN ${sharedPolicyHeaderAuth.headerAuthId} + ELSE ${defaultPolicyHeaderAuth.headerAuthId} + END, + ${resourceHeaderAuth.headerAuthId} + ) + `; + const effectiveSso = sql` + COALESCE( + CASE + WHEN ${sharedPolicy.resourcePolicyId} IS NOT NULL THEN ${sharedPolicy.sso} + ELSE ${defaultPolicy.sso} + END, + false + ) + `; + const effectiveWhitelist = sql` + COALESCE( + CASE + WHEN ${sharedPolicy.resourcePolicyId} IS NOT NULL THEN ${sharedPolicy.emailWhitelistEnabled} + ELSE ${defaultPolicy.emailWhitelistEnabled} + END, + false + ) + `; + const effectiveHeaderAuthExtendedCompatibility = sql` + COALESCE( + CASE + WHEN ${sharedPolicy.resourcePolicyId} IS NOT NULL THEN ${sharedPolicyHeaderAuth.extendedCompatibility} + ELSE ${defaultPolicyHeaderAuth.extendedCompatibility} + END, + false + ) + `; + return db .select({ resourceId: resources.resourceId, name: resources.name, ssl: resources.ssl, fullDomain: resources.fullDomain, - passwordId: resourcePolicyPassword.passwordId, - sso: resourcePolicies.sso, - pincodeId: resourcePolicyPincode.pincodeId, - whitelist: resourcePolicies.emailWhitelistEnabled, + passwordId: effectivePasswordId, + sso: effectiveSso, + pincodeId: effectivePincodeId, + whitelist: effectiveWhitelist, proxyPort: resources.proxyPort, enabled: resources.enabled, domainId: resources.domainId, @@ -204,44 +295,74 @@ function queryResourcesBase() { wildcard: resources.wildcard, mode: resources.mode, health: resources.health, - headerAuthId: resourcePolicyHeaderAuth.headerAuthId, + headerAuthId: effectiveHeaderAuthId, headerAuthExtendedCompatibility: - resourcePolicyHeaderAuth.extendedCompatibility + effectiveHeaderAuthExtendedCompatibility }) .from(resources) .leftJoin( - resourcePolicies, - or( - eq( - resourcePolicies.resourcePolicyId, - resources.resourcePolicyId - ), - eq( - resourcePolicies.resourcePolicyId, - resources.defaultResourcePolicyId - ) - ) + resourcePincode, + eq(resourcePincode.resourceId, resources.resourceId) ) - .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( - resourcePolicyPassword.resourcePolicyId, - resourcePolicies.resourcePolicyId + sharedPolicyPincode.resourcePolicyId, + sharedPolicy.resourcePolicyId ) ) .leftJoin( - resourcePolicyPincode, + sharedPolicyPassword, eq( - resourcePolicyPincode.resourcePolicyId, - resourcePolicies.resourcePolicyId + sharedPolicyPassword.resourcePolicyId, + sharedPolicy.resourcePolicyId ) ) .leftJoin( - resourcePolicyHeaderAuth, + sharedPolicyHeaderAuth, eq( - resourcePolicyHeaderAuth.resourcePolicyId, - resourcePolicies.resourcePolicyId + sharedPolicyHeaderAuth.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)) @@ -251,10 +372,23 @@ function queryResourcesBase() { ) .groupBy( resources.resourceId, - resourcePolicies.resourcePolicyId, - resourcePolicyPassword.passwordId, - resourcePolicyPincode.pincodeId, - resourcePolicyHeaderAuth.headerAuthId + resourcePincode.pincodeId, + resourcePassword.passwordId, + resourceHeaderAuth.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 ); } @@ -279,7 +413,7 @@ registry.registerPath({ content: { "application/json": { schema: z.object({ - data: z.unknown().nullable(), + data: z.record(z.string(), z.any()).nullable(), success: z.boolean(), error: z.boolean(), message: z.string(), @@ -313,6 +447,7 @@ export async function listResources( enabled, query, healthStatus, + protocol, sort_by, order, siteId, @@ -396,6 +531,80 @@ export async function listResources( } 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` + COALESCE( + CASE + WHEN ${sharedPolicy.resourcePolicyId} IS NOT NULL THEN ${sharedPolicy.sso} + ELSE ${defaultPolicy.sso} + END, + false + ) + `; + const effectiveWhitelist = sql` + COALESCE( + CASE + WHEN ${sharedPolicy.resourcePolicyId} IS NOT NULL THEN ${sharedPolicy.emailWhitelistEnabled} + ELSE ${defaultPolicy.emailWhitelistEnabled} + END, + false + ) + `; + const effectiveHeaderAuthId = sql` + COALESCE( + CASE + WHEN ${sharedPolicy.resourcePolicyId} IS NOT NULL THEN ${sharedPolicyHeaderAuth.headerAuthId} + ELSE ${defaultPolicyHeaderAuth.headerAuthId} + END, + ${resourceHeaderAuth.headerAuthId} + ) + `; + const effectivePincodeId = sql` + COALESCE( + CASE + WHEN ${sharedPolicy.resourcePolicyId} IS NOT NULL THEN ${sharedPolicyPincode.pincodeId} + ELSE ${defaultPolicyPincode.pincodeId} + END, + ${resourcePincode.pincodeId} + ) + `; + const effectivePasswordId = sql` + 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) { case "none": conditions.push( @@ -404,22 +613,28 @@ export async function listResources( break; case "protected": conditions.push( - or( - eq(resourcePolicies.sso, true), - eq(resourcePolicies.emailWhitelistEnabled, true), - not(isNull(resourcePolicyHeaderAuth.headerAuthId)), - not(isNull(resourcePolicyPincode.pincodeId)), - not(isNull(resourcePolicyPassword.passwordId)) + and( + inArray(resources.mode, browserGatewayModes), + or( + eq(effectiveSso, true), + eq(effectiveWhitelist, true), + not(isNull(effectiveHeaderAuthId)), + not(isNull(effectivePincodeId)), + not(isNull(effectivePasswordId)) + ) ) ); break; case "not_protected": conditions.push( - not(eq(resourcePolicies.sso, true)), - not(eq(resourcePolicies.emailWhitelistEnabled, true)), - isNull(resourcePolicyHeaderAuth.headerAuthId), - isNull(resourcePolicyPincode.pincodeId), - isNull(resourcePolicyPassword.passwordId) + and( + inArray(resources.mode, browserGatewayModes), + not(eq(effectiveSso, true)), + not(eq(effectiveWhitelist, true)), + isNull(effectiveHeaderAuthId), + isNull(effectivePincodeId), + isNull(effectivePasswordId) + ) ); break; } @@ -428,21 +643,36 @@ export async function listResources( if (typeof healthStatus !== "undefined") { conditions.push(eq(resources.health, healthStatus)); } + + if (typeof protocol !== "undefined") { + switch (protocol) { + case "http": + conditions.push( + and( + eq(resources.mode, "http"), + eq(resources.ssl, false) + ) + ); + break; + case "https": + conditions.push( + and(eq(resources.mode, "http"), eq(resources.ssl, true)) + ); + break; + default: + conditions.push(eq(resources.mode, protocol)); + break; + } + } + if (siteId != null) { const resourcesWithSite = db .select({ resourceId: targets.resourceId }) .from(targets) .innerJoin(sites, eq(targets.siteId, sites.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( - or( - inArray(resources.resourceId, resourcesWithSite), - inArray(resources.resourceId, resourcesWithBrowserGateway) - ) + or(inArray(resources.resourceId, resourcesWithSite)) ); } @@ -565,30 +795,6 @@ export async function listResources( ) .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[] const map = new Map(); @@ -651,21 +857,6 @@ export async function listResources( 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()); } diff --git a/server/routers/resource/listUserResourceAliases.ts b/server/routers/resource/listUserResourceAliases.ts index ae71c3708..1199a6cf2 100644 --- a/server/routers/resource/listUserResourceAliases.ts +++ b/server/routers/resource/listUserResourceAliases.ts @@ -82,7 +82,7 @@ export type ListUserResourceAliasesResponse = PaginatedResponse<{ // content: { // "application/json": { // schema: z.object({ -// data: z.unknown().nullable(), +// data: z.record(z.string(), z.any()).nullable(), // success: z.boolean(), // error: z.boolean(), // message: z.string(), diff --git a/server/routers/resource/removeEmailFromResourceWhitelist.ts b/server/routers/resource/removeEmailFromResourceWhitelist.ts index 4e7dc8904..615e62438 100644 --- a/server/routers/resource/removeEmailFromResourceWhitelist.ts +++ b/server/routers/resource/removeEmailFromResourceWhitelist.ts @@ -46,7 +46,7 @@ registry.registerPath({ content: { "application/json": { schema: z.object({ - data: z.unknown().nullable(), + data: z.record(z.string(), z.any()).nullable(), success: z.boolean(), error: z.boolean(), message: z.string(), diff --git a/server/routers/resource/removeRoleFromResource.ts b/server/routers/resource/removeRoleFromResource.ts index 66da1d377..0c4d811e2 100644 --- a/server/routers/resource/removeRoleFromResource.ts +++ b/server/routers/resource/removeRoleFromResource.ts @@ -1,7 +1,7 @@ import { Request, Response, NextFunction } from "express"; import { z } from "zod"; 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 HttpCode from "@server/types/HttpCode"; import createHttpError from "http-errors"; @@ -46,7 +46,7 @@ registry.registerPath({ content: { "application/json": { schema: z.object({ - data: z.unknown().nullable(), + data: z.record(z.string(), z.any()).nullable(), success: z.boolean(), error: z.boolean(), message: z.string(), @@ -130,35 +130,71 @@ export async function removeRoleFromResource( ); } - // Check if role exists in resource - const existingEntry = await db - .select() - .from(roleResources) - .where( - and( - eq(roleResources.resourceId, resourceId), - eq(roleResources.roleId, roleId) - ) - ); + const isInlinePolicy = + resource.resourcePolicyId === null && + resource.defaultResourcePolicyId !== null; - if (existingEntry.length === 0) { - return next( - createHttpError( - HttpCode.NOT_FOUND, - "Role not found in resource" - ) - ); + if (isInlinePolicy) { + const policyId = resource.defaultResourcePolicyId!; + + 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.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, { data: {}, success: true, diff --git a/server/routers/resource/removeUserFromResource.ts b/server/routers/resource/removeUserFromResource.ts index 17f2380a2..43a22c904 100644 --- a/server/routers/resource/removeUserFromResource.ts +++ b/server/routers/resource/removeUserFromResource.ts @@ -1,7 +1,7 @@ import { Request, Response, NextFunction } from "express"; import { z } from "zod"; import { db, resources } from "@server/db"; -import { userResources } from "@server/db"; +import { userResources, userPolicies } from "@server/db"; import response from "@server/lib/response"; import HttpCode from "@server/types/HttpCode"; import createHttpError from "http-errors"; @@ -46,7 +46,7 @@ registry.registerPath({ content: { "application/json": { schema: z.object({ - data: z.unknown().nullable(), + data: z.record(z.string(), z.any()).nullable(), success: z.boolean(), error: z.boolean(), message: z.string(), @@ -103,35 +103,71 @@ export async function removeUserFromResource( ); } - // Check if user exists in resource - const existingEntry = await db - .select() - .from(userResources) - .where( - and( - eq(userResources.resourceId, resourceId), - eq(userResources.userId, userId) - ) - ); + const isInlinePolicy = + resource.resourcePolicyId === null && + resource.defaultResourcePolicyId !== null; - if (existingEntry.length === 0) { - return next( - createHttpError( - HttpCode.NOT_FOUND, - "User not found in resource" - ) - ); + if (isInlinePolicy) { + const policyId = resource.defaultResourcePolicyId!; + + 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.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, { data: {}, success: true, diff --git a/server/routers/resource/setResourceHeaderAuth.ts b/server/routers/resource/setResourceHeaderAuth.ts index cec9ad96a..e19d47c3b 100644 --- a/server/routers/resource/setResourceHeaderAuth.ts +++ b/server/routers/resource/setResourceHeaderAuth.ts @@ -3,7 +3,9 @@ import { z } from "zod"; import { db, resourceHeaderAuth, - resourceHeaderAuthExtendedCompatibility + resourceHeaderAuthExtendedCompatibility, + resourcePolicyHeaderAuth, + resources } from "@server/db"; import { eq } from "drizzle-orm"; import HttpCode from "@server/types/HttpCode"; @@ -46,7 +48,7 @@ registry.registerPath({ content: { "application/json": { schema: z.object({ - data: z.unknown().nullable(), + data: z.record(z.string(), z.any()).nullable(), success: z.boolean(), error: z.boolean(), message: z.string(), @@ -89,36 +91,73 @@ export async function setResourceHeaderAuth( const { resourceId } = parsedParams.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 trx - .delete(resourceHeaderAuth) - .where(eq(resourceHeaderAuth.resourceId, resourceId)); - await trx - .delete(resourceHeaderAuthExtendedCompatibility) - .where( - eq( - resourceHeaderAuthExtendedCompatibility.resourceId, - resourceId - ) - ); + if (isInlinePolicy) { + const policyId = resource.defaultResourcePolicyId!; + await trx + .delete(resourcePolicyHeaderAuth) + .where( + eq(resourcePolicyHeaderAuth.resourcePolicyId, policyId) + ); - if (user && password && extendedCompatibility !== null) { - const headerAuthHash = await hashPassword( - Buffer.from(`${user}:${password}`).toString("base64") - ); + 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 - }) - ]); + await trx.insert(resourcePolicyHeaderAuth).values({ + resourcePolicyId: policyId, + headerAuthHash, + extendedCompatibility: extendedCompatibility! + }); + } + } else { + await trx + .delete(resourceHeaderAuth) + .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 + }) + ]); + } } }); diff --git a/server/routers/resource/setResourcePassword.ts b/server/routers/resource/setResourcePassword.ts index 0f89bccd4..b52ebfe2c 100644 --- a/server/routers/resource/setResourcePassword.ts +++ b/server/routers/resource/setResourcePassword.ts @@ -1,7 +1,11 @@ import { Request, Response, NextFunction } from "express"; import { z } from "zod"; import { db } from "@server/db"; -import { resourcePassword } from "@server/db"; +import { + resourcePassword, + resourcePolicyPassword, + resources +} from "@server/db"; import { eq } from "drizzle-orm"; import HttpCode from "@server/types/HttpCode"; import createHttpError from "http-errors"; @@ -42,7 +46,7 @@ registry.registerPath({ content: { "application/json": { schema: z.object({ - data: z.unknown().nullable(), + data: z.record(z.string(), z.any()).nullable(), success: z.boolean(), error: z.boolean(), message: z.string(), @@ -85,17 +89,49 @@ export async function setResourcePassword( const { resourceId } = parsedParams.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 trx - .delete(resourcePassword) - .where(eq(resourcePassword.resourceId, resourceId)); - - if (password) { - const passwordHash = await hashPassword(password); - + if (isInlinePolicy) { + const policyId = resource.defaultResourcePolicyId!; await trx - .insert(resourcePassword) - .values({ resourceId, passwordHash }); + .delete(resourcePolicyPassword) + .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 }); + } } }); diff --git a/server/routers/resource/setResourcePincode.ts b/server/routers/resource/setResourcePincode.ts index 9135529fb..577b11dbf 100644 --- a/server/routers/resource/setResourcePincode.ts +++ b/server/routers/resource/setResourcePincode.ts @@ -1,7 +1,7 @@ import { Request, Response, NextFunction } from "express"; import { z } from "zod"; import { db } from "@server/db"; -import { resourcePincode } from "@server/db"; +import { resourcePincode, resourcePolicyPincode, resources } from "@server/db"; import { eq } from "drizzle-orm"; import HttpCode from "@server/types/HttpCode"; import createHttpError from "http-errors"; @@ -46,7 +46,7 @@ registry.registerPath({ content: { "application/json": { schema: z.object({ - data: z.unknown().nullable(), + data: z.record(z.string(), z.any()).nullable(), success: z.boolean(), error: z.boolean(), message: z.string(), @@ -89,17 +89,51 @@ export async function setResourcePincode( const { resourceId } = parsedParams.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 trx - .delete(resourcePincode) - .where(eq(resourcePincode.resourceId, resourceId)); - - if (pincode) { - const pincodeHash = await hashPassword(pincode); - + if (isInlinePolicy) { + const policyId = resource.defaultResourcePolicyId!; await trx - .insert(resourcePincode) - .values({ resourceId, pincodeHash, digitLength: 6 }); + .delete(resourcePolicyPincode) + .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 }); + } } }); diff --git a/server/routers/resource/setResourceRoles.ts b/server/routers/resource/setResourceRoles.ts index 0015e373a..681e00650 100644 --- a/server/routers/resource/setResourceRoles.ts +++ b/server/routers/resource/setResourceRoles.ts @@ -1,7 +1,7 @@ import { Request, Response, NextFunction } from "express"; import { z } from "zod"; 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 HttpCode from "@server/types/HttpCode"; import createHttpError from "http-errors"; @@ -22,7 +22,7 @@ registry.registerPath({ method: "post", path: "/resource/{resourceId}/roles", description: - "Set roles for a resource. This will replace all existing roles.", + "Set roles for a resource. This will replace all existing roles. When the resource has an inline policy defined (no shared resource policy assigned), roles are set on the inline policy instead of directly on the resource.", tags: [OpenAPITags.PublicResource, OpenAPITags.Role], request: { params: setResourceRolesParamsSchema, @@ -40,7 +40,7 @@ registry.registerPath({ content: { "application/json": { schema: z.object({ - data: z.unknown().nullable(), + data: z.record(z.string(), z.any()).nullable(), success: z.boolean(), error: z.boolean(), message: z.string(), @@ -129,28 +129,61 @@ export async function setResourceRoles( ); const adminRoleIds = adminRoles.map((role) => role.roleId); + const isInlinePolicy = + resource.resourcePolicyId === null && + resource.defaultResourcePolicyId !== null; + await db.transaction(async (trx) => { - if (adminRoleIds.length > 0) { - await trx.delete(roleResources).where( - and( - eq(roleResources.resourceId, resourceId), - ne(roleResources.roleId, adminRoleIds[0]) // delete all but the admin role + if (isInlinePolicy) { + const policyId = resource.defaultResourcePolicyId!; + + // For inline policy, preserve admin roles by only deleting non-admin entries + 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 { - await trx - .delete(roleResources) - .where(eq(roleResources.resourceId, resourceId)); - } + if (adminRoleIds.length > 0) { + await trx.delete(roleResources).where( + 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( - roleIds.map((roleId) => - trx - .insert(roleResources) - .values({ roleId, resourceId }) - .returning() - ) - ); + await Promise.all( + roleIds.map((roleId) => + trx + .insert(roleResources) + .values({ roleId, resourceId }) + .returning() + ) + ); + } return response(res, { data: {}, diff --git a/server/routers/resource/setResourceUsers.ts b/server/routers/resource/setResourceUsers.ts index 4c2b7457a..d292ccba2 100644 --- a/server/routers/resource/setResourceUsers.ts +++ b/server/routers/resource/setResourceUsers.ts @@ -1,7 +1,7 @@ import { Request, Response, NextFunction } from "express"; import { z } from "zod"; import { db } from "@server/db"; -import { userResources } from "@server/db"; +import { userResources, userPolicies, resources } from "@server/db"; import response from "@server/lib/response"; import HttpCode from "@server/types/HttpCode"; import createHttpError from "http-errors"; @@ -22,7 +22,7 @@ registry.registerPath({ method: "post", path: "/resource/{resourceId}/users", description: - "Set users for a resource. This will replace all existing users.", + "Set users for a resource. This will replace all existing users. When the resource has an inline policy defined (no shared resource policy assigned), users are set on the inline policy instead of directly on the resource.", tags: [OpenAPITags.PublicResource, OpenAPITags.User], request: { params: setUserResourcesParamsSchema, @@ -40,7 +40,7 @@ registry.registerPath({ content: { "application/json": { schema: z.object({ - data: z.unknown().nullable(), + data: z.record(z.string(), z.any()).nullable(), success: z.boolean(), error: z.boolean(), message: z.string(), @@ -82,19 +82,51 @@ export async function setResourceUsers( const { resourceId } = parsedParams.data; - await db.transaction(async (trx) => { - await trx - .delete(userResources) - .where(eq(userResources.resourceId, resourceId)); + const [resource] = await db + .select() + .from(resources) + .where(eq(resources.resourceId, resourceId)) + .limit(1); - const newUserResources = await Promise.all( - userIds.map((userId) => - trx - .insert(userResources) - .values({ userId, resourceId }) - .returning() - ) + if (!resource) { + return next( + createHttpError(HttpCode.NOT_FOUND, "Resource not found") ); + } + + 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, { data: {}, diff --git a/server/routers/resource/setResourceWhitelist.ts b/server/routers/resource/setResourceWhitelist.ts index ff6c9fd02..697ae4541 100644 --- a/server/routers/resource/setResourceWhitelist.ts +++ b/server/routers/resource/setResourceWhitelist.ts @@ -1,7 +1,12 @@ import { Request, Response, NextFunction } from "express"; import { z } from "zod"; 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 HttpCode from "@server/types/HttpCode"; import createHttpError from "http-errors"; @@ -49,7 +54,7 @@ registry.registerPath({ content: { "application/json": { schema: z.object({ - data: z.unknown().nullable(), + data: z.record(z.string(), z.any()).nullable(), success: z.boolean(), error: z.boolean(), message: z.string(), @@ -104,57 +109,135 @@ export async function setResourceWhitelist( ); } - if (!resource.emailWhitelistEnabled) { - return next( - createHttpError( - HttpCode.BAD_REQUEST, - "Email whitelist is not enabled for this resource" - ) - ); - } + const isInlinePolicy = + resource.resourcePolicyId === null && + resource.defaultResourcePolicyId !== null; - const whitelist = await db - .select() - .from(resourceWhitelist) - .where(eq(resourceWhitelist.resourceId, resourceId)); + if (isInlinePolicy) { + const policyId = resource.defaultResourcePolicyId!; - await db.transaction(async (trx) => { - // diff the emails - const existingEmails = whitelist.map((w) => w.email); + const [policy] = await db + .select() + .from(resourcePolicies) + .where(eq(resourcePolicies.resourcePolicyId, policyId)); - const emailsToAdd = emails.filter( - (e) => !existingEmails.includes(e) - ); - const emailsToRemove = existingEmails.filter( - (e) => !emails.includes(e) - ); + if (!policy) { + return next( + createHttpError( + HttpCode.NOT_FOUND, + "Resource policy not found" + ) + ); + } - for (const email of emailsToAdd) { - await trx.insert(resourceWhitelist).values({ - email, - resourceId + if (!policy.emailWhitelistEnabled) { + return next( + createHttpError( + 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) { logger.error(error); return next( diff --git a/server/routers/resource/types.ts b/server/routers/resource/types.ts index eee70bd35..edfe49b00 100644 --- a/server/routers/resource/types.ts +++ b/server/routers/resource/types.ts @@ -14,7 +14,7 @@ export type GetMaintenanceInfoResponse = { export type AttachedResource = Pick< Resource, - "resourceId" | "name" | "fullDomain" + "resourceId" | "niceId" | "name" | "fullDomain" >; export type ResourcePolicyWithResources = Pick< diff --git a/server/routers/resource/updateResource.ts b/server/routers/resource/updateResource.ts index 9647bb68e..77603fe9f 100644 --- a/server/routers/resource/updateResource.ts +++ b/server/routers/resource/updateResource.ts @@ -9,7 +9,11 @@ import { resourcePassword, resourcePincode, resourceRules, - resourceWhitelist + resourceWhitelist, + roleResources, + roles, + Transaction, + userResources } from "@server/db"; import { domains, @@ -62,16 +66,38 @@ const updateHttpResourceBodySchema = z .optional(), subdomain: z.string().nullable().optional(), ssl: z.boolean().optional(), - sso: z.boolean().optional(), + sso: z + .boolean() + .optional() + .describe( + "When no shared resource policy is assigned (resourcePolicyId is null), updates the resource's inline policy. When a shared policy is assigned, this value overrides the shared policy for this resource." + ), blockAccess: z.boolean().optional(), - emailWhitelistEnabled: z.boolean().optional(), - applyRules: z.boolean().optional(), + emailWhitelistEnabled: z + .boolean() + .optional() + .describe( + "When no shared resource policy is assigned (resourcePolicyId is null), updates the resource's inline policy. When a shared policy is assigned, this value overrides the shared policy for this resource." + ), + applyRules: z + .boolean() + .optional() + .describe( + "When no shared resource policy is assigned (resourcePolicyId is null), updates the resource's inline policy. When a shared policy is assigned, this value overrides the shared policy for this resource." + ), domainId: z.string().optional(), enabled: z.boolean().optional(), stickySession: z.boolean().optional(), tlsServerName: z.string().nullable().optional(), setHostHeader: z.string().nullable().optional(), - skipToIdpId: z.int().positive().nullable().optional(), + skipToIdpId: z + .int() + .positive() + .nullable() + .optional() + .describe( + "When no shared resource policy is assigned (resourcePolicyId is null), updates the resource's inline policy. When a shared policy is assigned, this value overrides the shared policy for this resource." + ), headers: z .array(z.strictObject({ name: z.string(), value: z.string() })) .nullable() @@ -87,7 +113,13 @@ const updateHttpResourceBodySchema = z pamMode: z.enum(["passthrough", "push"]).optional(), authDaemonMode: z.enum(["site", "remote", "native"]).optional(), authDaemonPort: z.int().min(1).max(65535).nullable().optional(), - resourcePolicyId: z.number().nullable().optional() + resourcePolicyId: z + .number() + .nullable() + .optional() + .describe( + "ID of the resource policy to apply to this resource. Set to null to remove the resource policy and fall back to the inline policy settings." + ) }) .refine((data) => Object.keys(data).length > 0, { error: "At least one field must be provided for update" @@ -207,7 +239,8 @@ const updateRawResourceBodySchema = z registry.registerPath({ method: "post", path: "/resource/{resourceId}", - description: "Update a resource.", + description: + "Update a resource. Policy fields (sso, mfa, pincode, password, whitelist) update the inline policy when no shared resource policy is assigned; when a shared policy is assigned those fields override the shared policy for this resource only.", tags: [OpenAPITags.PublicResource], request: { params: updateResourceParamsSchema, @@ -227,7 +260,7 @@ registry.registerPath({ content: { "application/json": { schema: z.object({ - data: z.unknown().nullable(), + data: z.record(z.string(), z.any()).nullable(), success: z.boolean(), error: z.boolean(), message: z.string(), @@ -310,6 +343,61 @@ export async function updateResource( } } +async function clearResourceSpecificSettings( + resourceId: number, + orgId: string, + trx: Transaction | typeof db +) { + const adminRole = await db + .select() + .from(roles) + .where(and(eq(roles.isAdmin, true), eq(roles.orgId, orgId))) + .limit(1); + + if (adminRole.length === 0) { + throw new Error(`Admin role not found for org ${orgId}`); + } + // remove the resource specific pincode, password, header auth, rules, nad whitelist entries so that the resource will fall back to the policy settings + await Promise.all([ + trx + .delete(resourcePassword) + .where(eq(resourcePassword.resourceId, resourceId)), + trx + .delete(resourcePincode) + .where(eq(resourcePincode.resourceId, resourceId)), + trx + .delete(resourceHeaderAuth) + .where(eq(resourceHeaderAuth.resourceId, resourceId)), + trx + .delete(resourceHeaderAuthExtendedCompatibility) + .where( + eq( + resourceHeaderAuthExtendedCompatibility.resourceId, + resourceId + ) + ), + trx + .delete(resourceWhitelist) + .where(eq(resourceWhitelist.resourceId, resourceId)), + trx + .delete(resourceRules) + .where(eq(resourceRules.resourceId, resourceId)), + // delete the roles and the users as well + trx + .delete(userResources) + .where(eq(userResources.resourceId, resourceId)), + // except the admin role + trx + .delete(roleResources) + .where( + and( + eq(roleResources.resourceId, resourceId), + ne(roleResources.roleId, adminRole[0].roleId) + ) + ) + ]); +} + async function updateHttpResource( route: { req: Request; @@ -372,6 +460,15 @@ async function updateHttpResource( } } + // catch when the resource policy changes or gets cleared + if (resource.resourcePolicyId != updateData.resourcePolicyId) { + await clearResourceSpecificSettings( + resource.resourceId, + resource.orgId, + db + ); + } + if (updateData.niceId) { const [existingResource] = await db .select() @@ -549,6 +646,66 @@ async function updateHttpResource( updateData.maintenanceEstimatedTime = undefined; } + const isInlinePolicy = + resource.resourcePolicyId === null && + resource.defaultResourcePolicyId !== null; + + if (isInlinePolicy) { + const policyId = resource.defaultResourcePolicyId!; + const { + sso, + emailWhitelistEnabled, + applyRules, + skipToIdpId, + ...resourceOnlyDataRest + } = updateData; + + const resourceOnlyData = { + ...resourceOnlyDataRest, + sso: null, // reset these because they are controlled by the inline policy + emailWhitelistEnabled: null, + applyRules: null, + skipToIdpId: null + }; + + const policyUpdate: Record = {}; + 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 .update(resources) .set({ ...updateData, headers }) @@ -607,81 +764,6 @@ async function updateRawResource( .limit(1); await db.transaction(async (trx) => { - if (updateData.resourcePolicyId != null) { - const [existingPolicy] = await trx - .select() - .from(resourcePolicies) - .where( - eq( - resourcePolicies.resourcePolicyId, - updateData.resourcePolicyId - ) - ) - .limit(1); - - if (!existingPolicy) { - return next( - createHttpError( - HttpCode.NOT_FOUND, - `Resource policy with ID ${updateData.resourcePolicyId} not found` - ) - ); - } - } else { - // we are in an inline policy and we need to clear out the old tables - await Promise.all([ - trx - .delete(resourcePassword) - .where( - eq( - resourcePassword.resourceId, - existingResource.resourceId - ) - ), - trx - .delete(resourcePincode) - .where( - eq( - resourcePincode.resourceId, - existingResource.resourceId - ) - ), - trx - .delete(resourceHeaderAuth) - .where( - eq( - resourceHeaderAuth.resourceId, - existingResource.resourceId - ) - ), - trx - .delete(resourceHeaderAuthExtendedCompatibility) - .where( - eq( - resourceHeaderAuthExtendedCompatibility.resourceId, - existingResource.resourceId - ) - ), - trx - .delete(resourceWhitelist) - .where( - eq( - resourceWhitelist.resourceId, - existingResource.resourceId - ) - ), - - trx - .delete(resourceRules) - .where( - eq( - resourceRules.resourceId, - existingResource.resourceId - ) - ) - ]); - } - if (updateData.niceId) { const [existingResourceConflict] = await trx .select() @@ -706,9 +788,24 @@ async function updateRawResource( } } + await clearResourceSpecificSettings( + resource.resourceId, + resource.orgId, + trx + ); // none of these are supported on raw resources + + // we should make sure sso, emailWhitelistEnabled, and applyRules are null because this is a raw resource + const realUpdateData = { + ...updateData, + sso: null, + emailWhitelistEnabled: null, + applyRules: null, + skipToIdpId: null + }; + [updatedResource] = await trx .update(resources) - .set(updateData) + .set(realUpdateData) .where(eq(resources.resourceId, resource.resourceId)) .returning(); }); diff --git a/server/routers/resource/updateResourceRule.ts b/server/routers/resource/updateResourceRule.ts index 49a57ba86..cc2a6fc03 100644 --- a/server/routers/resource/updateResourceRule.ts +++ b/server/routers/resource/updateResourceRule.ts @@ -58,7 +58,7 @@ registry.registerPath({ content: { "application/json": { schema: z.object({ - data: z.unknown().nullable(), + data: z.record(z.string(), z.any()).nullable(), success: z.boolean(), error: z.boolean(), message: z.string(), diff --git a/server/routers/role/createRole.ts b/server/routers/role/createRole.ts index e193c5018..5ad6cd435 100644 --- a/server/routers/role/createRole.ts +++ b/server/routers/role/createRole.ts @@ -62,7 +62,7 @@ registry.registerPath({ content: { "application/json": { schema: z.object({ - data: z.unknown().nullable(), + data: z.record(z.string(), z.any()).nullable(), success: z.boolean(), error: z.boolean(), message: z.string(), diff --git a/server/routers/role/deleteRole.ts b/server/routers/role/deleteRole.ts index d3b56b37d..a79d11269 100644 --- a/server/routers/role/deleteRole.ts +++ b/server/routers/role/deleteRole.ts @@ -39,7 +39,7 @@ registry.registerPath({ content: { "application/json": { schema: z.object({ - data: z.unknown().nullable(), + data: z.record(z.string(), z.any()).nullable(), success: z.boolean(), error: z.boolean(), message: z.string(), diff --git a/server/routers/role/getRole.ts b/server/routers/role/getRole.ts index c90471551..8313a19c7 100644 --- a/server/routers/role/getRole.ts +++ b/server/routers/role/getRole.ts @@ -28,7 +28,7 @@ registry.registerPath({ content: { "application/json": { schema: z.object({ - data: z.unknown().nullable(), + data: z.record(z.string(), z.any()).nullable(), success: z.boolean(), error: z.boolean(), message: z.string(), diff --git a/server/routers/role/listRoles.ts b/server/routers/role/listRoles.ts index ace5e1fc1..248db5063 100644 --- a/server/routers/role/listRoles.ts +++ b/server/routers/role/listRoles.ts @@ -104,7 +104,7 @@ registry.registerPath({ content: { "application/json": { schema: z.object({ - data: z.unknown().nullable(), + data: z.record(z.string(), z.any()).nullable(), success: z.boolean(), error: z.boolean(), message: z.string(), diff --git a/server/routers/role/updateRole.ts b/server/routers/role/updateRole.ts index eb3239419..aa01899db 100644 --- a/server/routers/role/updateRole.ts +++ b/server/routers/role/updateRole.ts @@ -59,7 +59,7 @@ registry.registerPath({ content: { "application/json": { schema: z.object({ - data: z.unknown().nullable(), + data: z.record(z.string(), z.any()).nullable(), success: z.boolean(), error: z.boolean(), message: z.string(), diff --git a/server/routers/site/createSite.ts b/server/routers/site/createSite.ts index bba487978..cc67f7b27 100644 --- a/server/routers/site/createSite.ts +++ b/server/routers/site/createSite.ts @@ -84,7 +84,7 @@ registry.registerPath({ content: { "application/json": { schema: z.object({ - data: z.unknown().nullable(), + data: z.record(z.string(), z.any()).nullable(), success: z.boolean(), error: z.boolean(), message: z.string(), diff --git a/server/routers/site/deleteSite.ts b/server/routers/site/deleteSite.ts index 47efba910..077376211 100644 --- a/server/routers/site/deleteSite.ts +++ b/server/routers/site/deleteSite.ts @@ -33,7 +33,7 @@ registry.registerPath({ content: { "application/json": { schema: z.object({ - data: z.unknown().nullable(), + data: z.record(z.string(), z.any()).nullable(), success: z.boolean(), error: z.boolean(), message: z.string(), @@ -93,10 +93,9 @@ export async function deleteSite( // Clean up all client associations and send peer/proxy removal // messages in a single efficient pass before deleting the row. 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); }); diff --git a/server/routers/site/getSite.ts b/server/routers/site/getSite.ts index 020fbd36f..a671a47f9 100644 --- a/server/routers/site/getSite.ts +++ b/server/routers/site/getSite.ts @@ -67,7 +67,7 @@ registry.registerPath({ content: { "application/json": { schema: z.object({ - data: z.unknown().nullable(), + data: z.record(z.string(), z.any()).nullable(), success: z.boolean(), error: z.boolean(), message: z.string(), @@ -95,7 +95,7 @@ registry.registerPath({ content: { "application/json": { schema: z.object({ - data: z.unknown().nullable(), + data: z.record(z.string(), z.any()).nullable(), success: z.boolean(), error: z.boolean(), message: z.string(), diff --git a/server/routers/site/listSites.ts b/server/routers/site/listSites.ts index c217da489..37fe8630d 100644 --- a/server/routers/site/listSites.ts +++ b/server/routers/site/listSites.ts @@ -12,7 +12,6 @@ import { userSites, labels, siteLabels, - browserGatewayTarget, type Label } from "@server/db"; import cache from "#dynamic/lib/cache"; @@ -241,10 +240,6 @@ function querySitesBase() { ON ${siteResources.networkId} = ${siteNetworks.networkId} WHERE ${siteNetworks.siteId} = ${sites.siteId} AND ${siteResources.orgId} = ${sites.orgId} - ) + ( - SELECT COUNT(DISTINCT ${browserGatewayTarget.resourceId}) - FROM ${browserGatewayTarget} - WHERE ${browserGatewayTarget.siteId} = ${sites.siteId} )`, status: sites.status }) @@ -285,7 +280,7 @@ registry.registerPath({ content: { "application/json": { schema: z.object({ - data: z.unknown().nullable(), + data: z.record(z.string(), z.any()).nullable(), success: z.boolean(), error: z.boolean(), message: z.string(), diff --git a/server/routers/site/updateSite.ts b/server/routers/site/updateSite.ts index 7201d2898..c6851a3c3 100644 --- a/server/routers/site/updateSite.ts +++ b/server/routers/site/updateSite.ts @@ -50,7 +50,7 @@ registry.registerPath({ content: { "application/json": { schema: z.object({ - data: z.unknown().nullable(), + data: z.record(z.string(), z.any()).nullable(), success: z.boolean(), error: z.boolean(), message: z.string(), diff --git a/server/routers/siteResource/addClientToSiteResource.ts b/server/routers/siteResource/addClientToSiteResource.ts index 03145f672..c43b755b2 100644 --- a/server/routers/siteResource/addClientToSiteResource.ts +++ b/server/routers/siteResource/addClientToSiteResource.ts @@ -47,7 +47,7 @@ registry.registerPath({ content: { "application/json": { schema: z.object({ - data: z.unknown().nullable(), + data: z.record(z.string(), z.any()).nullable(), success: z.boolean(), error: z.boolean(), message: z.string(), diff --git a/server/routers/siteResource/addRoleToSiteResource.ts b/server/routers/siteResource/addRoleToSiteResource.ts index 8f8bf27fb..a7153b3e3 100644 --- a/server/routers/siteResource/addRoleToSiteResource.ts +++ b/server/routers/siteResource/addRoleToSiteResource.ts @@ -47,7 +47,7 @@ registry.registerPath({ content: { "application/json": { schema: z.object({ - data: z.unknown().nullable(), + data: z.record(z.string(), z.any()).nullable(), success: z.boolean(), error: z.boolean(), message: z.string(), diff --git a/server/routers/siteResource/addUserToSiteResource.ts b/server/routers/siteResource/addUserToSiteResource.ts index bc6e6bcd9..6300502af 100644 --- a/server/routers/siteResource/addUserToSiteResource.ts +++ b/server/routers/siteResource/addUserToSiteResource.ts @@ -47,7 +47,7 @@ registry.registerPath({ content: { "application/json": { schema: z.object({ - data: z.unknown().nullable(), + data: z.record(z.string(), z.any()).nullable(), success: z.boolean(), error: z.boolean(), message: z.string(), diff --git a/server/routers/siteResource/batchAddClientToSiteResources.ts b/server/routers/siteResource/batchAddClientToSiteResources.ts index aad23b0d4..c8a8c90a6 100644 --- a/server/routers/siteResource/batchAddClientToSiteResources.ts +++ b/server/routers/siteResource/batchAddClientToSiteResources.ts @@ -52,7 +52,7 @@ registry.registerPath({ content: { "application/json": { schema: z.object({ - data: z.unknown().nullable(), + data: z.record(z.string(), z.any()).nullable(), success: z.boolean(), error: z.boolean(), message: z.string(), diff --git a/server/routers/siteResource/createSiteResource.ts b/server/routers/siteResource/createSiteResource.ts index 3f38cc7e1..0648c45cc 100644 --- a/server/routers/siteResource/createSiteResource.ts +++ b/server/routers/siteResource/createSiteResource.ts @@ -142,6 +142,7 @@ const createSiteResourceSchema = z data.destinationPort <= 65535) ); } + return true; }, { message: @@ -218,7 +219,7 @@ registry.registerPath({ content: { "application/json": { schema: z.object({ - data: z.unknown().nullable(), + data: z.record(z.string(), z.any()).nullable(), success: z.boolean(), error: z.boolean(), message: z.string(), diff --git a/server/routers/siteResource/deleteSiteResource.ts b/server/routers/siteResource/deleteSiteResource.ts index 82f80b875..8ff23405c 100644 --- a/server/routers/siteResource/deleteSiteResource.ts +++ b/server/routers/siteResource/deleteSiteResource.ts @@ -33,7 +33,7 @@ registry.registerPath({ content: { "application/json": { schema: z.object({ - data: z.unknown().nullable(), + data: z.record(z.string(), z.any()).nullable(), success: z.boolean(), error: z.boolean(), message: z.string(), diff --git a/server/routers/siteResource/getSiteResource.ts b/server/routers/siteResource/getSiteResource.ts index d923cb843..f25723d4b 100644 --- a/server/routers/siteResource/getSiteResource.ts +++ b/server/routers/siteResource/getSiteResource.ts @@ -21,11 +21,7 @@ const getSiteResourceParamsSchema = z.strictObject({ orgId: z.string() }); -async function query( - siteResourceId?: number, - niceId?: string, - orgId?: string -) { +async function query(siteResourceId?: number, niceId?: string, orgId?: string) { if (siteResourceId && orgId) { const [siteResource] = await db .select() @@ -75,7 +71,7 @@ registry.registerPath({ content: { "application/json": { schema: z.object({ - data: z.unknown().nullable(), + data: z.record(z.string(), z.any()).nullable(), success: z.boolean(), error: z.boolean(), message: z.string(), @@ -104,7 +100,7 @@ registry.registerPath({ content: { "application/json": { schema: z.object({ - data: z.unknown().nullable(), + data: z.record(z.string(), z.any()).nullable(), success: z.boolean(), error: z.boolean(), message: z.string(), diff --git a/server/routers/siteResource/listAllSiteResourcesByOrg.ts b/server/routers/siteResource/listAllSiteResourcesByOrg.ts index 732f2a229..5c20bc5a7 100644 --- a/server/routers/siteResource/listAllSiteResourcesByOrg.ts +++ b/server/routers/siteResource/listAllSiteResourcesByOrg.ts @@ -232,7 +232,7 @@ registry.registerPath({ content: { "application/json": { schema: z.object({ - data: z.unknown().nullable(), + data: z.record(z.string(), z.any()).nullable(), success: z.boolean(), error: z.boolean(), message: z.string(), diff --git a/server/routers/siteResource/listSiteResourceClients.ts b/server/routers/siteResource/listSiteResourceClients.ts index c95874b21..5ae6f6f8d 100644 --- a/server/routers/siteResource/listSiteResourceClients.ts +++ b/server/routers/siteResource/listSiteResourceClients.ts @@ -49,7 +49,7 @@ registry.registerPath({ content: { "application/json": { schema: z.object({ - data: z.unknown().nullable(), + data: z.record(z.string(), z.any()).nullable(), success: z.boolean(), error: z.boolean(), message: z.string(), diff --git a/server/routers/siteResource/listSiteResourceRoles.ts b/server/routers/siteResource/listSiteResourceRoles.ts index 6a451b192..d430c8c43 100644 --- a/server/routers/siteResource/listSiteResourceRoles.ts +++ b/server/routers/siteResource/listSiteResourceRoles.ts @@ -50,7 +50,7 @@ registry.registerPath({ content: { "application/json": { schema: z.object({ - data: z.unknown().nullable(), + data: z.record(z.string(), z.any()).nullable(), success: z.boolean(), error: z.boolean(), message: z.string(), diff --git a/server/routers/siteResource/listSiteResourceUsers.ts b/server/routers/siteResource/listSiteResourceUsers.ts index d6846b9eb..d13a58d3b 100644 --- a/server/routers/siteResource/listSiteResourceUsers.ts +++ b/server/routers/siteResource/listSiteResourceUsers.ts @@ -53,7 +53,7 @@ registry.registerPath({ content: { "application/json": { schema: z.object({ - data: z.unknown().nullable(), + data: z.record(z.string(), z.any()).nullable(), success: z.boolean(), error: z.boolean(), message: z.string(), diff --git a/server/routers/siteResource/listSiteResources.ts b/server/routers/siteResource/listSiteResources.ts index 1cebb281f..311009dfa 100644 --- a/server/routers/siteResource/listSiteResources.ts +++ b/server/routers/siteResource/listSiteResources.ts @@ -69,7 +69,7 @@ registry.registerPath({ content: { "application/json": { schema: z.object({ - data: z.unknown().nullable(), + data: z.record(z.string(), z.any()).nullable(), success: z.boolean(), error: z.boolean(), message: z.string(), diff --git a/server/routers/siteResource/removeClientFromSiteResource.ts b/server/routers/siteResource/removeClientFromSiteResource.ts index 906f07902..35944ca15 100644 --- a/server/routers/siteResource/removeClientFromSiteResource.ts +++ b/server/routers/siteResource/removeClientFromSiteResource.ts @@ -47,7 +47,7 @@ registry.registerPath({ content: { "application/json": { schema: z.object({ - data: z.unknown().nullable(), + data: z.record(z.string(), z.any()).nullable(), success: z.boolean(), error: z.boolean(), message: z.string(), diff --git a/server/routers/siteResource/removeRoleFromSiteResource.ts b/server/routers/siteResource/removeRoleFromSiteResource.ts index 00aba1114..2759a57e7 100644 --- a/server/routers/siteResource/removeRoleFromSiteResource.ts +++ b/server/routers/siteResource/removeRoleFromSiteResource.ts @@ -47,7 +47,7 @@ registry.registerPath({ content: { "application/json": { schema: z.object({ - data: z.unknown().nullable(), + data: z.record(z.string(), z.any()).nullable(), success: z.boolean(), error: z.boolean(), message: z.string(), diff --git a/server/routers/siteResource/removeUserFromSiteResource.ts b/server/routers/siteResource/removeUserFromSiteResource.ts index 9c25ddcf2..473db41b5 100644 --- a/server/routers/siteResource/removeUserFromSiteResource.ts +++ b/server/routers/siteResource/removeUserFromSiteResource.ts @@ -47,7 +47,7 @@ registry.registerPath({ content: { "application/json": { schema: z.object({ - data: z.unknown().nullable(), + data: z.record(z.string(), z.any()).nullable(), success: z.boolean(), error: z.boolean(), message: z.string(), diff --git a/server/routers/siteResource/setSiteResourceClients.ts b/server/routers/siteResource/setSiteResourceClients.ts index cde78d052..0f88f363f 100644 --- a/server/routers/siteResource/setSiteResourceClients.ts +++ b/server/routers/siteResource/setSiteResourceClients.ts @@ -47,7 +47,7 @@ registry.registerPath({ content: { "application/json": { schema: z.object({ - data: z.unknown().nullable(), + data: z.record(z.string(), z.any()).nullable(), success: z.boolean(), error: z.boolean(), message: z.string(), diff --git a/server/routers/siteResource/setSiteResourceRoles.ts b/server/routers/siteResource/setSiteResourceRoles.ts index 3e83efea2..e9878a320 100644 --- a/server/routers/siteResource/setSiteResourceRoles.ts +++ b/server/routers/siteResource/setSiteResourceRoles.ts @@ -48,7 +48,7 @@ registry.registerPath({ content: { "application/json": { schema: z.object({ - data: z.unknown().nullable(), + data: z.record(z.string(), z.any()).nullable(), success: z.boolean(), error: z.boolean(), message: z.string(), diff --git a/server/routers/siteResource/setSiteResourceUsers.ts b/server/routers/siteResource/setSiteResourceUsers.ts index 37e3152b7..4fa6f2218 100644 --- a/server/routers/siteResource/setSiteResourceUsers.ts +++ b/server/routers/siteResource/setSiteResourceUsers.ts @@ -48,7 +48,7 @@ registry.registerPath({ content: { "application/json": { schema: z.object({ - data: z.unknown().nullable(), + data: z.record(z.string(), z.any()).nullable(), success: z.boolean(), error: z.boolean(), message: z.string(), diff --git a/server/routers/siteResource/updateSiteResource.ts b/server/routers/siteResource/updateSiteResource.ts index d503a2b5c..db4d4445b 100644 --- a/server/routers/siteResource/updateSiteResource.ts +++ b/server/routers/siteResource/updateSiteResource.ts @@ -226,7 +226,7 @@ registry.registerPath({ content: { "application/json": { schema: z.object({ - data: z.unknown().nullable(), + data: z.record(z.string(), z.any()).nullable(), success: z.boolean(), error: z.boolean(), message: z.string(), diff --git a/server/routers/target/createTarget.ts b/server/routers/target/createTarget.ts index 53488e2b7..2b3f472e8 100644 --- a/server/routers/target/createTarget.ts +++ b/server/routers/target/createTarget.ts @@ -24,6 +24,10 @@ import { fireHealthCheckUnhealthyAlert, fireHealthCheckUnknownAlert } 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({ resourceId: z.coerce.number().int().positive() @@ -32,6 +36,7 @@ const createTargetParamsSchema = z.strictObject({ const createTargetSchema = z.strictObject({ siteId: z.int().positive(), ip: z.string().refine(isTargetValid), + mode: z.enum(["http", "tcp", "udp", "ssh", "rdp", "vnc"]).optional(), method: z.string().optional().nullable(), port: z.int().min(1).max(65535), enabled: z.boolean().default(true), @@ -87,7 +92,7 @@ registry.registerPath({ content: { "application/json": { schema: z.object({ - data: z.unknown().nullable(), + data: z.record(z.string(), z.any()).nullable(), success: z.boolean(), error: z.boolean(), message: z.string(), @@ -161,6 +166,12 @@ export async function createTarget( ); } + const plainToken = generateId(48); + const encryptedToken = encrypt( + plainToken, + config.getRawConfig().server.secret! + ); + let newTarget: Target[] = []; let targetIps: string[] = []; let healthCheck: TargetHealthCheck[] = []; @@ -191,6 +202,9 @@ export async function createTarget( .values({ resourceId, ...targetData, + mode: (targetData.mode ?? + resource.mode ?? + "http") as Target["mode"], priority: targetData.priority || 100 }) .returning(); @@ -226,6 +240,10 @@ export async function createTarget( resourceId, siteId: site.siteId, ip: targetData.ip, + mode: (targetData.mode ?? + resource.mode ?? + "http") as Target["mode"], + authToken: encryptedToken, method: targetData.method, port: targetData.port, internalPort, @@ -325,13 +343,21 @@ export async function createTarget( .where(eq(newts.siteId, site.siteId)) .limit(1); - await addTargets( - newt.newtId, - newTarget, - healthCheck, - resource.mode === "udp" ? "udp" : "tcp", - newt.version - ); + if (["http", "tcp", "udp"].includes(newTarget[0].mode)) { + await addTargets( + newt.newtId, + newTarget, + healthCheck, + resource.mode === "udp" ? "udp" : "tcp", + newt.version + ); + } else if (["ssh", "rdp", "vnc"].includes(newTarget[0].mode)) { + await sendBrowserGatewayTargets( + newt.newtId, + newTarget, + newt.version + ); + } } } diff --git a/server/routers/target/deleteTarget.ts b/server/routers/target/deleteTarget.ts index 61d748f8c..a959e10eb 100644 --- a/server/routers/target/deleteTarget.ts +++ b/server/routers/target/deleteTarget.ts @@ -11,6 +11,7 @@ import { fromError } from "zod-validation-error"; import { removeTargets } from "../newt/targets"; import { OpenAPITags, registry } from "@server/openApi"; import { targetHealthCheck } from "@server/db"; +import { removeBrowserGatewayTarget } from "@server/routers/newt/targets"; const deleteTargetSchema = z.strictObject({ targetId: z.coerce.number().int().positive() @@ -30,7 +31,7 @@ registry.registerPath({ content: { "application/json": { schema: z.object({ - data: z.unknown().nullable(), + data: z.record(z.string(), z.any()).nullable(), success: z.boolean(), error: z.boolean(), message: z.string(), @@ -136,14 +137,22 @@ export async function deleteTarget( .where(eq(newts.siteId, site.siteId)) .limit(1); - await removeTargets( - newt.newtId, - // [deletedTarget], - [], // 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 - [deletedHealthCheck], - resource.mode === "udp" ? "udp" : "tcp", - newt.version - ); + if (["http", "tcp", "udp"].includes(deletedTarget.mode)) { + await removeTargets( + newt.newtId, + // [deletedTarget], + [], // 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 + [deletedHealthCheck], + resource.mode === "udp" ? "udp" : "tcp", + newt.version + ); + } else if (["ssh", "rdp", "vnc"].includes(deletedTarget.mode)) { + await removeBrowserGatewayTarget( + newt.newtId, + deletedTarget.targetId, + newt.version + ); + } } } diff --git a/server/routers/target/getTarget.ts b/server/routers/target/getTarget.ts index 37fa8b7f0..24fd6b18f 100644 --- a/server/routers/target/getTarget.ts +++ b/server/routers/target/getTarget.ts @@ -33,7 +33,7 @@ registry.registerPath({ content: { "application/json": { schema: z.object({ - data: z.unknown().nullable(), + data: z.record(z.string(), z.any()).nullable(), success: z.boolean(), error: z.boolean(), message: z.string(), diff --git a/server/routers/target/listTargets.ts b/server/routers/target/listTargets.ts index 47e9cdea5..b097b1f6e 100644 --- a/server/routers/target/listTargets.ts +++ b/server/routers/target/listTargets.ts @@ -34,6 +34,7 @@ function queryTargets(resourceId: number) { .select({ targetId: targets.targetId, ip: targets.ip, + mode: targets.mode, method: targets.method, port: targets.port, enabled: targets.enabled, @@ -102,7 +103,7 @@ registry.registerPath({ content: { "application/json": { schema: z.object({ - data: z.unknown().nullable(), + data: z.record(z.string(), z.any()).nullable(), success: z.boolean(), error: z.boolean(), message: z.string(), diff --git a/server/routers/target/updateTarget.ts b/server/routers/target/updateTarget.ts index 4b667d086..1bed7b982 100644 --- a/server/routers/target/updateTarget.ts +++ b/server/routers/target/updateTarget.ts @@ -18,6 +18,7 @@ import { import { pickPort } from "./helpers"; import { isTargetValid } from "@server/lib/validators"; import { OpenAPITags, registry } from "@server/openApi"; +import { sendBrowserGatewayTargets } from "@server/routers/newt/targets"; const updateTargetParamsSchema = z.strictObject({ targetId: z.coerce.number().int().positive() @@ -27,6 +28,10 @@ const updateTargetBodySchema = z .strictObject({ siteId: z.int().positive(), 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(), port: z.int().min(1).max(65535).optional(), enabled: z.boolean().optional(), @@ -86,7 +91,7 @@ registry.registerPath({ content: { "application/json": { schema: z.object({ - data: z.unknown().nullable(), + data: z.record(z.string(), z.any()).nullable(), success: z.boolean(), error: z.boolean(), message: z.string(), @@ -184,6 +189,8 @@ export async function updateTarget( } const pathMatchTypeRemoved = parsedBody.data.pathMatchType === null; + const nextMode = + parsedBody.data.mode === null ? undefined : parsedBody.data.mode; let updatedTarget: any; let updatedHc: any; @@ -193,6 +200,7 @@ export async function updateTarget( .set({ siteId: parsedBody.data.siteId, ip: parsedBody.data.ip, + mode: nextMode, method: parsedBody.data.method, port: parsedBody.data.port, internalPort, @@ -343,13 +351,21 @@ export async function updateTarget( .where(eq(newts.siteId, site.siteId)) .limit(1); - await addTargets( - newt.newtId, - [updatedTarget], - [updatedHc], - resource.mode === "udp" ? "udp" : "tcp", - newt.version - ); + if (["http", "tcp", "udp"].includes(updatedTarget.mode)) { + await addTargets( + newt.newtId, + [updatedTarget], + [updatedHc], + resource.mode === "udp" ? "udp" : "tcp", + newt.version + ); + } else if (["ssh", "rdp", "vnc"].includes(updatedTarget.mode)) { + await sendBrowserGatewayTargets( + newt.newtId, + [updatedTarget], + newt.version + ); + } } } diff --git a/server/routers/user/addUserRoleLegacy.ts b/server/routers/user/addUserRoleLegacy.ts index 201f7a7e4..bef69387a 100644 --- a/server/routers/user/addUserRoleLegacy.ts +++ b/server/routers/user/addUserRoleLegacy.ts @@ -33,7 +33,7 @@ registry.registerPath({ content: { "application/json": { schema: z.object({ - data: z.unknown().nullable(), + data: z.record(z.string(), z.any()).nullable(), success: z.boolean(), error: z.boolean(), message: z.string(), diff --git a/server/routers/user/adminGetUser.ts b/server/routers/user/adminGetUser.ts index 1bc674339..6d5c5e664 100644 --- a/server/routers/user/adminGetUser.ts +++ b/server/routers/user/adminGetUser.ts @@ -27,7 +27,7 @@ registry.registerPath({ content: { "application/json": { schema: z.object({ - data: z.unknown().nullable(), + data: z.record(z.string(), z.any()).nullable(), success: z.boolean(), error: z.boolean(), message: z.string(), diff --git a/server/routers/user/adminListUsers.ts b/server/routers/user/adminListUsers.ts index 8200b6f0f..f3c08f25b 100644 --- a/server/routers/user/adminListUsers.ts +++ b/server/routers/user/adminListUsers.ts @@ -143,7 +143,7 @@ registry.registerPath({ content: { "application/json": { schema: z.object({ - data: z.unknown().nullable(), + data: z.record(z.string(), z.any()).nullable(), success: z.boolean(), error: z.boolean(), message: z.string(), diff --git a/server/routers/user/getOrgUser.ts b/server/routers/user/getOrgUser.ts index d39b0e57e..34732b8d8 100644 --- a/server/routers/user/getOrgUser.ts +++ b/server/routers/user/getOrgUser.ts @@ -87,7 +87,7 @@ registry.registerPath({ content: { "application/json": { schema: z.object({ - data: z.unknown().nullable(), + data: z.record(z.string(), z.any()).nullable(), success: z.boolean(), error: z.boolean(), message: z.string(), diff --git a/server/routers/user/getOrgUserByUsername.ts b/server/routers/user/getOrgUserByUsername.ts index af5ad3feb..97e312b0e 100644 --- a/server/routers/user/getOrgUserByUsername.ts +++ b/server/routers/user/getOrgUserByUsername.ts @@ -46,7 +46,7 @@ registry.registerPath({ content: { "application/json": { schema: z.object({ - data: z.unknown().nullable(), + data: z.record(z.string(), z.any()).nullable(), success: z.boolean(), error: z.boolean(), message: z.string(), diff --git a/server/routers/user/listInvitations.ts b/server/routers/user/listInvitations.ts index df8663d1a..ae207c26d 100644 --- a/server/routers/user/listInvitations.ts +++ b/server/routers/user/listInvitations.ts @@ -66,7 +66,10 @@ async function queryInvitations( .from(userInviteRoles) .innerJoin(roles, eq(userInviteRoles.roleId, roles.roleId)) .where( - and(eq(roles.orgId, orgId), inArray(userInviteRoles.inviteId, inviteIds)) + and( + eq(roles.orgId, orgId), + inArray(userInviteRoles.inviteId, inviteIds) + ) ); const rolesByInvite = new Map< @@ -107,7 +110,7 @@ registry.registerPath({ content: { "application/json": { schema: z.object({ - data: z.unknown().nullable(), + data: z.record(z.string(), z.any()).nullable(), success: z.boolean(), error: z.boolean(), message: z.string(), diff --git a/server/routers/user/listUsers.ts b/server/routers/user/listUsers.ts index 32e717b46..c74f48468 100644 --- a/server/routers/user/listUsers.ts +++ b/server/routers/user/listUsers.ts @@ -1,18 +1,21 @@ import { Request, Response, NextFunction } from "express"; import { z } from "zod"; import { db, idpOidcConfig } from "@server/db"; -import { - idp, - idpOrg, - roles, - userOrgRoles, - userOrgs, - users -} from "@server/db"; +import { idp, idpOrg, roles, userOrgRoles, userOrgs, users } from "@server/db"; import response from "@server/lib/response"; import HttpCode from "@server/types/HttpCode"; import createHttpError from "http-errors"; -import { and, asc, desc, eq, exists, inArray, like, or, sql } from "drizzle-orm"; +import { + and, + asc, + desc, + eq, + exists, + inArray, + like, + or, + sql +} from "drizzle-orm"; import logger from "@server/logger"; import { fromZodError } from "zod-validation-error"; import { OpenAPITags, registry } from "@server/openApi"; @@ -70,18 +73,23 @@ const listUsersSchema = z.strictObject({ description: "Sort order" }), idp_id: z - .preprocess((val) => { - if (val === undefined || val === null || val === "") { + .preprocess( + (val) => { + if (val === undefined || val === null || val === "") { + return undefined; + } + if (val === "internal") { + return "internal"; + } + if (typeof val === "string" && /^\d+$/.test(val)) { + return parseInt(val, 10); + } return undefined; - } - if (val === "internal") { - return "internal"; - } - if (typeof val === "string" && /^\d+$/.test(val)) { - return parseInt(val, 10); - } - return undefined; - }, z.union([z.literal("internal"), z.number().int().positive()]).optional()) + }, + z + .union([z.literal("internal"), z.number().int().positive()]) + .optional() + ) .openapi({ description: 'Filter by identity provider id, or "internal" for internal users' @@ -156,7 +164,7 @@ registry.registerPath({ content: { "application/json": { schema: z.object({ - data: z.unknown().nullable(), + data: z.record(z.string(), z.any()).nullable(), success: z.boolean(), error: z.boolean(), message: z.string(), @@ -203,9 +211,7 @@ export async function listUsers( const idpOk = await db .select({ one: sql`1` }) .from(idpOrg) - .where( - and(eq(idpOrg.orgId, orgId), eq(idpOrg.idpId, idp_id)) - ) + .where(and(eq(idpOrg.orgId, orgId), eq(idpOrg.idpId, idp_id))) .limit(1); if (idpOk.length === 0) { return next( diff --git a/server/routers/user/removeInvitation.ts b/server/routers/user/removeInvitation.ts index c8b897f71..660a900c7 100644 --- a/server/routers/user/removeInvitation.ts +++ b/server/routers/user/removeInvitation.ts @@ -29,7 +29,7 @@ registry.registerPath({ content: { "application/json": { schema: z.object({ - data: z.unknown().nullable(), + data: z.record(z.string(), z.any()).nullable(), success: z.boolean(), error: z.boolean(), message: z.string(), diff --git a/server/routers/user/removeUserOrg.ts b/server/routers/user/removeUserOrg.ts index 982aa495d..58fc85b69 100644 --- a/server/routers/user/removeUserOrg.ts +++ b/server/routers/user/removeUserOrg.ts @@ -44,7 +44,7 @@ registry.registerPath({ content: { "application/json": { schema: z.object({ - data: z.unknown().nullable(), + data: z.record(z.string(), z.any()).nullable(), success: z.boolean(), error: z.boolean(), message: z.string(), diff --git a/server/routers/user/updateOrgUser.ts b/server/routers/user/updateOrgUser.ts index dd3ba4a9d..17fc6a659 100644 --- a/server/routers/user/updateOrgUser.ts +++ b/server/routers/user/updateOrgUser.ts @@ -43,7 +43,7 @@ registry.registerPath({ content: { "application/json": { schema: z.object({ - data: z.unknown().nullable(), + data: z.record(z.string(), z.any()).nullable(), success: z.boolean(), error: z.boolean(), message: z.string(), diff --git a/server/routers/ws/checkRoundTripMessage.ts b/server/routers/ws/checkRoundTripMessage.ts index 121f5affe..79f08d2a0 100644 --- a/server/routers/ws/checkRoundTripMessage.ts +++ b/server/routers/ws/checkRoundTripMessage.ts @@ -33,7 +33,7 @@ const checkRoundTripMessageParamsSchema = z // content: { // "application/json": { // schema: z.object({ -// data: z.unknown().nullable(), +// data: z.record(z.string(), z.any()).nullable(), // success: z.boolean(), // error: z.boolean(), // message: z.string(), @@ -84,7 +84,7 @@ export async function checkRoundTripMessage( complete: message.complete, sentAt: message.sentAt, receivedAt: message.receivedAt, - error: message.error, + error: message.error }, success: true, error: false, diff --git a/server/setup/scriptsPg/1.19.0.ts b/server/setup/scriptsPg/1.19.0.ts index f8685e80b..8d65d7807 100644 --- a/server/setup/scriptsPg/1.19.0.ts +++ b/server/setup/scriptsPg/1.19.0.ts @@ -39,18 +39,6 @@ export default async function migration() { try { 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` CREATE TABLE "clientLabels" ( "clientLabelId" serial PRIMARY KEY NOT NULL, @@ -209,18 +197,17 @@ export default async function migration() { await db.execute( 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( sql`ALTER TABLE "sites" ADD COLUMN "autoUpdateEnabled" boolean DEFAULT false NOT NULL;` ); await db.execute( 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( 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,53 @@ export default async function migration() { ); 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 "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` + ALTER TABLE "resourceSessions" ADD COLUMN "policyPasswordId" integer; + `); + await db.execute(sql` + ALTER TABLE "resourceSessions" ADD COLUMN "policyPincodeId" integer; + `); + await db.execute(sql` + ALTER TABLE "resourceSessions" ADD COLUMN "policyWhitelistId" integer; + `); + await db.execute(sql` + ALTER TABLE "resourceSessions" ADD CONSTRAINT "resourceSessions_policyPasswordId_resourcePolicyPassword_passwordId_fk" FOREIGN KEY ("policyPasswordId") REFERENCES "public"."resourcePolicyPassword"("passwordId") ON DELETE cascade ON UPDATE no action; + `); + await db.execute(sql` + ALTER TABLE "resourceSessions" ADD CONSTRAINT "resourceSessions_policyPincodeId_resourcePolicyPincode_pincodeId_fk" FOREIGN KEY ("policyPincodeId") REFERENCES "public"."resourcePolicyPincode"("pincodeId") ON DELETE cascade ON UPDATE no action; + `); + await db.execute(sql` + ALTER TABLE "resourceSessions" ADD CONSTRAINT "resourceSessions_policyWhitelistId_resourcePolicyWhitelist_id_fk" FOREIGN KEY ("policyWhitelistId") REFERENCES "public"."resourcePolicyWhitelist"("id") ON DELETE cascade ON UPDATE no action; + `); + // remove not null/default from sso, applyRules, and emailWhitelistEnabled in preparation for resource policies + await db.execute( + sql`ALTER TABLE "resources" ALTER COLUMN "sso" DROP NOT NULL;` + ); + await db.execute( + sql`ALTER TABLE "resources" ALTER COLUMN "sso" DROP DEFAULT;` + ); + await db.execute( + sql`ALTER TABLE "resources" ALTER COLUMN "applyRules" DROP NOT NULL;` + ); + await db.execute( + sql`ALTER TABLE "resources" ALTER COLUMN "applyRules" DROP DEFAULT;` + ); + await db.execute( + sql`ALTER TABLE "resources" ALTER COLUMN "emailWhitelistEnabled" DROP NOT NULL;` + ); + await db.execute( + sql`ALTER TABLE "resources" ALTER COLUMN "emailWhitelistEnabled" DROP DEFAULT;` + ); await db.execute(sql`COMMIT`); console.log("Migrated database"); @@ -583,26 +617,16 @@ export default async function migration() { DELETE FROM "resourceWhitelist" WHERE "resourceId" = ${resource.resourceId} `); - await db.execute(sql` - ALTER TABLE "resourceSessions" ADD COLUMN "policyPasswordId" integer; - `); - await db.execute(sql` - ALTER TABLE "resourceSessions" ADD COLUMN "policyPincodeId" integer; - `); - await db.execute(sql` - ALTER TABLE "resourceSessions" ADD COLUMN "policyWhitelistId" integer; - `); - await db.execute(sql` - ALTER TABLE "resourceSessions" ADD CONSTRAINT "resourceSessions_policyPasswordId_resourcePolicyPassword_passwordId_fk" FOREIGN KEY ("policyPasswordId") REFERENCES "public"."resourcePolicyPassword"("passwordId") ON DELETE cascade ON UPDATE no action; - `); - await db.execute(sql` - ALTER TABLE "resourceSessions" ADD CONSTRAINT "resourceSessions_policyPincodeId_resourcePolicyPincode_pincodeId_fk" FOREIGN KEY ("policyPincodeId") REFERENCES "public"."resourcePolicyPincode"("pincodeId") ON DELETE cascade ON UPDATE no action; - `); - await db.execute(sql` - ALTER TABLE "resourceSessions" ADD CONSTRAINT "resourceSessions_policyWhitelistId_resourcePolicyWhitelist_id_fk" FOREIGN KEY ("policyWhitelistId") REFERENCES "public"."resourcePolicyWhitelist"("id") ON DELETE cascade ON UPDATE no action; - `); } + // clear the sso, applyRules, and emailWhitelistEnabled columns on all resources since that information is now in the resource policies + await db.execute(sql` + UPDATE "resources" + SET "sso" = null, + "applyRules" = null, + "emailWhitelistEnabled" = null + `); + await db.execute(sql`COMMIT`); console.log( `Migrated inline resource policies for ${existingResources.length} resource(s)` diff --git a/server/setup/scriptsSqlite/1.19.0.ts b/server/setup/scriptsSqlite/1.19.0.ts index 9ea84261b..809340d94 100644 --- a/server/setup/scriptsSqlite/1.19.0.ts +++ b/server/setup/scriptsSqlite/1.19.0.ts @@ -40,22 +40,6 @@ export default async function migration() { try { 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( ` CREATE TABLE 'clientLabels' ( @@ -263,6 +247,13 @@ export default async function migration() { ALTER TABLE 'siteResources' ADD COLUMN 'pamMode' text DEFAULT 'passthrough'; ` ).run(); + db.prepare( + ` + UPDATE 'siteResources' + SET "pamMode" = 'push' + WHERE LOWER(COALESCE("mode", '')) = 'host'; + ` + ).run(); db.prepare( ` @@ -350,6 +341,25 @@ export default async function migration() { ALTER TABLE 'resourceSessions' ADD 'policyWhitelistId' integer REFERENCES resourcePolicyWhitelist(id); ` ).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 @@ -670,6 +680,25 @@ export default async function migration() { deleteResourceRules.run(resource.resourceId); deleteResourceWhitelist.run(resource.resourceId); } + // remove not null/default from sso, applyRules, and emailWhitelistEnabled in preparation for resource policies + db.prepare(`ALTER TABLE 'resources' DROP COLUMN 'sso';`).run(); + db.prepare( + `ALTER TABLE 'resources' ADD COLUMN 'sso' integer;` + ).run(); + + db.prepare( + `ALTER TABLE 'resources' DROP COLUMN 'applyRules';` + ).run(); + db.prepare( + `ALTER TABLE 'resources' ADD COLUMN 'applyRules' integer;` + ).run(); + + db.prepare( + `ALTER TABLE 'resources' DROP COLUMN 'emailWhitelistEnabled';` + ).run(); + db.prepare( + `ALTER TABLE 'resources' ADD COLUMN 'emailWhitelistEnabled' integer;` + ).run(); }); migrateInlinePolicies(); diff --git a/src/app/[orgId]/layout.tsx b/src/app/[orgId]/layout.tsx index fe0077427..f85cbd03e 100644 --- a/src/app/[orgId]/layout.tsx +++ b/src/app/[orgId]/layout.tsx @@ -21,7 +21,6 @@ import { Layout } from "@app/components/Layout"; import ApplyInternalRedirect from "@app/components/ApplyInternalRedirect"; import SubscriptionViolation from "@app/components/SubscriptionViolation"; - export default async function OrgLayout(props: { children: React.ReactNode; params: Promise<{ orgId: string }>; @@ -42,6 +41,26 @@ export default async function OrgLayout(props: { redirect(`/`); } + let orgs: ListUserOrgsResponse["orgs"] = []; + try { + const getOrgs = cache(async () => + internal.get>( + `/user/${user.userId}/orgs`, + await authCookieHeader() + ) + ); + const res = await getOrgs(); + if (res && res.data.data.orgs) { + orgs = res.data.data.orgs; + } + } catch (e) {} + + const primaryOrg = orgs.find((org) => org.isPrimaryOrg); + const canViewPrimaryBilling = Boolean(primaryOrg?.isOwner); + const primaryOrgBillingHref = primaryOrg + ? `/${primaryOrg.orgId}/settings/billing` + : null; + let accessRes: CheckOrgUserAccessResponse | null = null; try { const checkOrgAccess = cache(() => @@ -58,19 +77,6 @@ export default async function OrgLayout(props: { if (!accessRes?.allowed) { // For non-admin users, show the member resources portal - let orgs: ListUserOrgsResponse["orgs"] = []; - try { - const getOrgs = cache(async () => - internal.get>( - `/user/${user.userId}/orgs`, - await authCookieHeader() - ) - ); - const res = await getOrgs(); - if (res && res.data.data.orgs) { - orgs = res.data.data.orgs; - } - } catch (e) {} return ( @@ -110,7 +116,12 @@ export default async function OrgLayout(props: { > {props.children} - {build === "saas" && } + {build === "saas" && ( + + )} diff --git a/src/app/[orgId]/settings/(private)/billing/page.tsx b/src/app/[orgId]/settings/(private)/billing/page.tsx index f77ae8589..e4abdb561 100644 --- a/src/app/[orgId]/settings/(private)/billing/page.tsx +++ b/src/app/[orgId]/settings/(private)/billing/page.tsx @@ -10,12 +10,15 @@ import { formatAxiosError } from "@app/lib/api"; import { AxiosResponse } from "axios"; import { SettingsContainer, + SettingsFormCell, + SettingsFormGrid, SettingsSection, SettingsSectionHeader, SettingsSectionTitle, SettingsSectionDescription, SettingsSectionBody, - SettingsSectionFooter + SettingsSectionFooter, + SettingsSectionForm } from "@app/components/Settings"; import { InfoSection, @@ -1324,42 +1327,46 @@ export default function BillingPage() { -
-
-
-
- {t("billingCurrentKeys") || - "Current Keys"} + + + +
+
+
+ {t("billingCurrentKeys") || + "Current Keys"} +
+
+ + {getLicenseKeyCount()} + + + {getLicenseKeyCount() === 1 + ? "key" + : "keys"} + +
+
+ +

+ {t( + "billingManageLicenseSubscriptionDescription" + ) || + "Manage your subscription for paid self-hosted license keys and download invoices."} +

-
- - {getLicenseKeyCount()} - - - {getLicenseKeyCount() === 1 - ? "key" - : "keys"} - -
-
- -

- {t( - "billingManageLicenseSubscriptionDescription" - ) || - "Manage your subscription for paid self-hosted license keys and download invoices."} -

-
-
+ + + )} diff --git a/src/app/[orgId]/settings/(private)/policies/resources/public/[niceId]/authentication/page.tsx b/src/app/[orgId]/settings/(private)/policies/resources/public/[niceId]/authentication/page.tsx new file mode 100644 index 000000000..ff9ebd4cf --- /dev/null +++ b/src/app/[orgId]/settings/(private)/policies/resources/public/[niceId]/authentication/page.tsx @@ -0,0 +1,7 @@ +"use client"; + +import { EditPolicyForm } from "@app/components/resource-policy/EditPolicyForm"; + +export default function EditPolicyAuthenticationPage() { + return ; +} diff --git a/src/app/[orgId]/settings/(private)/policies/resources/public/[niceId]/general/page.tsx b/src/app/[orgId]/settings/(private)/policies/resources/public/[niceId]/general/page.tsx new file mode 100644 index 000000000..a0e80b9f7 --- /dev/null +++ b/src/app/[orgId]/settings/(private)/policies/resources/public/[niceId]/general/page.tsx @@ -0,0 +1,7 @@ +"use client"; + +import { EditPolicyForm } from "@app/components/resource-policy/EditPolicyForm"; + +export default function EditPolicyGeneralPage() { + return ; +} diff --git a/src/app/[orgId]/settings/(private)/policies/resource/[niceId]/page.tsx b/src/app/[orgId]/settings/(private)/policies/resources/public/[niceId]/layout.tsx similarity index 59% rename from src/app/[orgId]/settings/(private)/policies/resource/[niceId]/page.tsx rename to src/app/[orgId]/settings/(private)/policies/resources/public/[niceId]/layout.tsx index 5519506b9..7c8d3d9bc 100644 --- a/src/app/[orgId]/settings/(private)/policies/resource/[niceId]/page.tsx +++ b/src/app/[orgId]/settings/(private)/policies/resources/public/[niceId]/layout.tsx @@ -1,5 +1,5 @@ -import { EditPolicyForm } from "@app/components/resource-policy/EditPolicyForm"; import SettingsSectionTitle from "@app/components/SettingsSectionTitle"; +import { HorizontalTabs } from "@app/components/HorizontalTabs"; import { Button } from "@app/components/ui/button"; import { internal } from "@app/lib/api"; import { authCookieHeader } from "@app/lib/api/cookies"; @@ -9,12 +9,20 @@ import type { AxiosResponse } from "axios"; import { getTranslations } from "next-intl/server"; import Link from "next/link"; 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 }>; -} +}; -export default async function EditPolicyPage(props: EditPolicyPageProps) { +export default async function EditPolicyLayout(props: EditPolicyLayoutProps) { const params = await props.params; const t = await getTranslations(); @@ -28,13 +36,28 @@ export default async function EditPolicyPage(props: EditPolicyPageProps) { ); policyResponse = res.data.data; } catch { - redirect(`/${params.orgId}/settings/policies/resource`); + redirect(`/${params.orgId}/settings/policies/resources/public`); } 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 ( <>
@@ -46,14 +69,16 @@ export default async function EditPolicyPage(props: EditPolicyPageProps) { />
- + {props.children} ); diff --git a/src/app/[orgId]/settings/(private)/policies/resources/public/[niceId]/page.tsx b/src/app/[orgId]/settings/(private)/policies/resources/public/[niceId]/page.tsx new file mode 100644 index 000000000..9cc180715 --- /dev/null +++ b/src/app/[orgId]/settings/(private)/policies/resources/public/[niceId]/page.tsx @@ -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` + ); +} diff --git a/src/app/[orgId]/settings/(private)/policies/resources/public/[niceId]/rules/page.tsx b/src/app/[orgId]/settings/(private)/policies/resources/public/[niceId]/rules/page.tsx new file mode 100644 index 000000000..a33fce94e --- /dev/null +++ b/src/app/[orgId]/settings/(private)/policies/resources/public/[niceId]/rules/page.tsx @@ -0,0 +1,7 @@ +"use client"; + +import { EditPolicyForm } from "@app/components/resource-policy/EditPolicyForm"; + +export default function EditPolicyRulesPage() { + return ; +} diff --git a/src/app/[orgId]/settings/(private)/policies/resource/create/page.tsx b/src/app/[orgId]/settings/(private)/policies/resources/public/create/page.tsx similarity index 88% rename from src/app/[orgId]/settings/(private)/policies/resource/create/page.tsx rename to src/app/[orgId]/settings/(private)/policies/resources/public/create/page.tsx index edf67fbef..4afa1110d 100644 --- a/src/app/[orgId]/settings/(private)/policies/resource/create/page.tsx +++ b/src/app/[orgId]/settings/(private)/policies/resources/public/create/page.tsx @@ -23,7 +23,9 @@ export default async function CreateResourcePolicyPage( /> diff --git a/src/app/[orgId]/settings/(private)/policies/resource/page.tsx b/src/app/[orgId]/settings/(private)/policies/resources/public/page.tsx similarity index 95% rename from src/app/[orgId]/settings/(private)/policies/resource/page.tsx rename to src/app/[orgId]/settings/(private)/policies/resources/public/page.tsx index a51bbef3a..8b12b75b2 100644 --- a/src/app/[orgId]/settings/(private)/policies/resource/page.tsx +++ b/src/app/[orgId]/settings/(private)/policies/resources/public/page.tsx @@ -1,3 +1,4 @@ +import ResourcePoliciesBanner from "@app/components/ResourcePoliciesBanner"; import { ResourcePoliciesTable } from "@app/components/ResourcePoliciesTable"; import SettingsSectionTitle from "@app/components/SettingsSectionTitle"; import { internal } from "@app/lib/api"; @@ -54,6 +55,8 @@ export default async function ResourcePoliciesPage( description={t("resourcePoliciesDescription")} /> + + -
- { - if (e.key === "Enter") { - 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" - > - ( - - - {t("name")} - - - - - - - {t( - "clientNameDescription" - )} - - - )} - /> -
- -
- {showAdvancedSettings && ( - ( - - - {t("clientAddress")} - - - - - - - {t( - "addressDescription" + }} + id="create-client-form" + > + + + ( + + + {t("name")} + + + + + + + {t( + "clientNameDescription" + )} + + + )} + /> + + + + + {showAdvancedSettings && ( + + ( + + + {t( + "clientAddress" + )} + + + + + + + {t( + "addressDescription" + )} + + )} - - + /> + )} - /> - )} - - + + + +
diff --git a/src/app/[orgId]/settings/general/page.tsx b/src/app/[orgId]/settings/general/page.tsx index bef9b0cd7..0ebfae651 100644 --- a/src/app/[orgId]/settings/general/page.tsx +++ b/src/app/[orgId]/settings/general/page.tsx @@ -42,6 +42,7 @@ import { SwitchInput } from "@app/components/SwitchInput"; import { usePaidStatus } from "@app/hooks/usePaidStatus"; import { tierMatrix, TierFeature } from "@server/lib/billing/tierMatrix"; import { PaidFeaturesAlert } from "@app/components/PaidFeaturesAlert"; +import { ExternalLink } from "lucide-react"; // Schema for general organization settings const GeneralFormSchema = z.object({ @@ -279,7 +280,16 @@ function GeneralSectionForm({ org }: SectionFormProps) { /> - {t("newtAutoUpdateDescription")} + {t("newtAutoUpdateDescription")}{" "} + + {t("learnMore")} + + diff --git a/src/app/[orgId]/settings/general/security/page.tsx b/src/app/[orgId]/settings/general/security/page.tsx index e7d0d85c8..51afa6077 100644 --- a/src/app/[orgId]/settings/general/security/page.tsx +++ b/src/app/[orgId]/settings/general/security/page.tsx @@ -224,23 +224,39 @@ function LogRetentionSectionForm({ org }: SectionFormProps) { {LOG_RETENTION_OPTIONS.filter( (option) => { - if (build != "saas") { + if ( + build != "saas" + ) { return true; } let maxDays: number; - if (!subscriptionTier) { + if ( + !subscriptionTier + ) { // No tier maxDays = 3; - } else if (subscriptionTier == "enterprise") { + } else if ( + subscriptionTier == + "enterprise" + ) { // Enterprise - no limit return true; - } else if (subscriptionTier == "tier3") { + } else if ( + subscriptionTier == + "tier3" + ) { maxDays = 90; - } else if (subscriptionTier == "tier2") { + } else if ( + subscriptionTier == + "tier2" + ) { maxDays = 30; - } else if (subscriptionTier == "tier1") { + } else if ( + subscriptionTier == + "tier1" + ) { maxDays = 7; } else { // Default to most restrictive @@ -249,7 +265,12 @@ function LogRetentionSectionForm({ org }: SectionFormProps) { // Filter out options that exceed the max // Special values: -1 (forever) and 9001 (end of year) should be filtered - if (option.value < 0 || option.value > maxDays) { + if ( + option.value < + 0 || + option.value > + maxDays + ) { return false; } @@ -322,24 +343,43 @@ function LogRetentionSectionForm({ org }: SectionFormProps) { {LOG_RETENTION_OPTIONS.filter( - (option) => { - if (build != "saas") { + ( + option + ) => { + if ( + build != + "saas" + ) { return true; } let maxDays: number; - if (!subscriptionTier) { + if ( + !subscriptionTier + ) { // No tier maxDays = 3; - } else if (subscriptionTier == "enterprise") { + } else if ( + subscriptionTier == + "enterprise" + ) { // Enterprise - no limit return true; - } else if (subscriptionTier == "tier3") { + } else if ( + subscriptionTier == + "tier3" + ) { maxDays = 90; - } else if (subscriptionTier == "tier2") { + } else if ( + subscriptionTier == + "tier2" + ) { maxDays = 30; - } else if (subscriptionTier == "tier1") { + } else if ( + subscriptionTier == + "tier1" + ) { maxDays = 7; } else { // Default to most restrictive @@ -348,7 +388,12 @@ function LogRetentionSectionForm({ org }: SectionFormProps) { // Filter out options that exceed the max // Special values: -1 (forever) and 9001 (end of year) should be filtered - if (option.value < 0 || option.value > maxDays) { + if ( + option.value < + 0 || + option.value > + maxDays + ) { return false; } @@ -423,24 +468,43 @@ function LogRetentionSectionForm({ org }: SectionFormProps) { {LOG_RETENTION_OPTIONS.filter( - (option) => { - if (build != "saas") { + ( + option + ) => { + if ( + build != + "saas" + ) { return true; } let maxDays: number; - if (!subscriptionTier) { + if ( + !subscriptionTier + ) { // No tier maxDays = 3; - } else if (subscriptionTier == "enterprise") { + } else if ( + subscriptionTier == + "enterprise" + ) { // Enterprise - no limit return true; - } else if (subscriptionTier == "tier3") { + } else if ( + subscriptionTier == + "tier3" + ) { maxDays = 90; - } else if (subscriptionTier == "tier2") { + } else if ( + subscriptionTier == + "tier2" + ) { maxDays = 30; - } else if (subscriptionTier == "tier1") { + } else if ( + subscriptionTier == + "tier1" + ) { maxDays = 7; } else { // Default to most restrictive @@ -449,7 +513,12 @@ function LogRetentionSectionForm({ org }: SectionFormProps) { // Filter out options that exceed the max // Special values: -1 (forever) and 9001 (end of year) should be filtered - if (option.value < 0 || option.value > maxDays) { + if ( + option.value < + 0 || + option.value > + maxDays + ) { return false; } @@ -524,24 +593,43 @@ function LogRetentionSectionForm({ org }: SectionFormProps) { {LOG_RETENTION_OPTIONS.filter( - (option) => { - if (build != "saas") { + ( + option + ) => { + if ( + build != + "saas" + ) { return true; } let maxDays: number; - if (!subscriptionTier) { + if ( + !subscriptionTier + ) { // No tier maxDays = 3; - } else if (subscriptionTier == "enterprise") { + } else if ( + subscriptionTier == + "enterprise" + ) { // Enterprise - no limit return true; - } else if (subscriptionTier == "tier3") { + } else if ( + subscriptionTier == + "tier3" + ) { maxDays = 90; - } else if (subscriptionTier == "tier2") { + } else if ( + subscriptionTier == + "tier2" + ) { maxDays = 30; - } else if (subscriptionTier == "tier1") { + } else if ( + subscriptionTier == + "tier1" + ) { maxDays = 7; } else { // Default to most restrictive @@ -550,7 +638,12 @@ function LogRetentionSectionForm({ org }: SectionFormProps) { // Filter out options that exceed the max // Special values: -1 (forever) and 9001 (end of year) should be filtered - if (option.value < 0 || option.value > maxDays) { + if ( + option.value < + 0 || + option.value > + maxDays + ) { return false; } diff --git a/src/app/[orgId]/settings/logs/access/page.tsx b/src/app/[orgId]/settings/logs/access/page.tsx index a6cb87fc8..fc0660ebb 100644 --- a/src/app/[orgId]/settings/logs/access/page.tsx +++ b/src/app/[orgId]/settings/logs/access/page.tsx @@ -11,7 +11,7 @@ import { ColumnDef } from "@tanstack/react-table"; import { DateTimeValue } from "@app/components/DateTimePicker"; import { ArrowUpRight, Key, User } from "lucide-react"; import Link from "next/link"; -import { ColumnFilter } from "@app/components/ColumnFilter"; +import { ColumnFilterButton } from "@app/components/ColumnFilterButton"; import SettingsSectionTitle from "@app/components/SettingsSectionTitle"; import { build } from "@server/build"; import { getSevenDaysAgo } from "@app/lib/getSevenDaysAgo"; @@ -233,7 +233,7 @@ export default function GeneralPage() { { accessorKey: "timestamp", header: () => { - return t("timestamp"); + return {t("timestamp")}; }, cell: ({ row }) => { return ( @@ -249,19 +249,19 @@ export default function GeneralPage() { accessorKey: "action", header: () => { return ( -
- {t("action")} - + handleFilterChange("action", value) } - searchPlaceholder="Search..." - emptyMessage="None found" + searchPlaceholder={t("searchPlaceholder")} + emptyMessage={t("emptySearchOptions")} />
); @@ -276,27 +276,27 @@ export default function GeneralPage() { }, { accessorKey: "ip", - header: () => t("ip") + header: () => {t("ip")} }, { accessorKey: "location", header: () => { return ( -
- {t("location")} - + ({ value: location, label: location }) )} + label={t("location")} selectedValue={filters.location} onValueChange={(value) => handleFilterChange("location", value) } - searchPlaceholder="Search..." - emptyMessage="None found" + searchPlaceholder={t("searchPlaceholder")} + emptyMessage={t("emptySearchOptions")} />
); @@ -321,19 +321,19 @@ export default function GeneralPage() { accessorKey: "resourceName", header: () => { return ( -
- {t("resource")} - + ({ value: res.id.toString(), label: res.name || "Unnamed Resource" }))} + label={t("resource")} selectedValue={filters.resourceId} onValueChange={(value) => handleFilterChange("resourceId", value) } - searchPlaceholder="Search..." - emptyMessage="None found" + searchPlaceholder={t("searchPlaceholder")} + emptyMessage={t("emptySearchOptions")} />
); @@ -359,9 +359,8 @@ export default function GeneralPage() { accessorKey: "type", header: () => { return ( -
- {t("type")} - + handleFilterChange("type", value) } - searchPlaceholder="Search..." - emptyMessage="None found" + searchPlaceholder={t("searchPlaceholder")} + emptyMessage={t("emptySearchOptions")} />
); @@ -395,19 +395,19 @@ export default function GeneralPage() { accessorKey: "actor", header: () => { return ( -
- {t("actor")} - + ({ value: actor, label: actor }))} + label={t("actor")} selectedValue={filters.actor} onValueChange={(value) => handleFilterChange("actor", value) } - searchPlaceholder="Search..." - emptyMessage="None found" + searchPlaceholder={t("searchPlaceholder")} + emptyMessage={t("emptySearchOptions")} />
); @@ -433,7 +433,7 @@ export default function GeneralPage() { }, { accessorKey: "actorId", - header: () => t("actorId"), + header: () => {t("actorId")}, cell: ({ row }) => ( {row.original.actorId || "-"} diff --git a/src/app/[orgId]/settings/logs/action/page.tsx b/src/app/[orgId]/settings/logs/action/page.tsx index 7ccce8877..3418030f5 100644 --- a/src/app/[orgId]/settings/logs/action/page.tsx +++ b/src/app/[orgId]/settings/logs/action/page.tsx @@ -1,5 +1,5 @@ "use client"; -import { ColumnFilter } from "@app/components/ColumnFilter"; +import { ColumnFilterButton } from "@app/components/ColumnFilterButton"; import { DateTimeValue } from "@app/components/DateTimePicker"; import { LogDataTable } from "@app/components/LogDataTable"; import { PaidFeaturesAlert } from "@app/components/PaidFeaturesAlert"; @@ -219,9 +219,7 @@ export default function GeneralPage() { const columns: ColumnDef[] = [ { accessorKey: "timestamp", - header: () => { - return t("timestamp"); - }, + header: () => {t("timestamp")}, cell: ({ row }) => { return (
@@ -236,16 +234,16 @@ export default function GeneralPage() { accessorKey: "action", header: () => { return ( -
- {t("action")} - + handleFilterChange("action", value) } - searchPlaceholder="Search..." - emptyMessage="None found" + searchPlaceholder={t("searchPlaceholder")} + emptyMessage={t("emptySearchOptions")} />
); @@ -263,19 +261,19 @@ export default function GeneralPage() { accessorKey: "actor", header: () => { return ( -
- {t("actor")} - + ({ value: actor, label: actor }))} + label={t("actor")} selectedValue={filters.actor} onValueChange={(value) => handleFilterChange("actor", value) } - searchPlaceholder="Search..." - emptyMessage="None found" + searchPlaceholder={t("searchPlaceholder")} + emptyMessage={t("emptySearchOptions")} />
); @@ -295,9 +293,7 @@ export default function GeneralPage() { }, { accessorKey: "actorId", - header: () => { - return t("actorId"); - }, + header: () => {t("actorId")}, cell: ({ row }) => { return ( diff --git a/src/app/[orgId]/settings/logs/connection/page.tsx b/src/app/[orgId]/settings/logs/connection/page.tsx index be408edb1..528ba9a37 100644 --- a/src/app/[orgId]/settings/logs/connection/page.tsx +++ b/src/app/[orgId]/settings/logs/connection/page.tsx @@ -1,6 +1,6 @@ "use client"; import { Button } from "@app/components/ui/button"; -import { ColumnFilter } from "@app/components/ColumnFilter"; +import { ColumnFilterButton } from "@app/components/ColumnFilterButton"; import { DateTimeValue } from "@app/components/DateTimePicker"; import { LogDataTable } from "@app/components/LogDataTable"; import { PaidFeaturesAlert } from "@app/components/PaidFeaturesAlert"; @@ -256,9 +256,7 @@ export default function ConnectionLogsPage() { const columns: ColumnDef[] = [ { accessorKey: "startedAt", - header: () => { - return t("timestamp"); - }, + header: () => {t("timestamp")}, cell: ({ row }) => { return (
@@ -273,21 +271,21 @@ export default function ConnectionLogsPage() { accessorKey: "protocol", header: () => { return ( -
- {t("protocol")} - + ({ label: protocol.toUpperCase(), value: protocol }) )} + label={t("protocol")} selectedValue={filters.protocol} onValueChange={(value) => handleFilterChange("protocol", value) } - searchPlaceholder="Search..." - emptyMessage="None found" + searchPlaceholder={t("searchPlaceholder")} + emptyMessage={t("emptySearchOptions")} />
); @@ -304,19 +302,19 @@ export default function ConnectionLogsPage() { accessorKey: "resourceName", header: () => { return ( -
- {t("resource")} - + ({ value: res.id.toString(), label: res.name || "Unnamed Resource" }))} + label={t("resource")} selectedValue={filters.siteResourceId} onValueChange={(value) => handleFilterChange("siteResourceId", value) } - searchPlaceholder="Search..." - emptyMessage="None found" + searchPlaceholder={t("searchPlaceholder")} + emptyMessage={t("emptySearchOptions")} />
); @@ -345,19 +343,19 @@ export default function ConnectionLogsPage() { accessorKey: "clientName", header: () => { return ( -
- {t("client")} - + ({ value: c.id.toString(), label: c.name }))} + label={t("client")} selectedValue={filters.clientId} onValueChange={(value) => handleFilterChange("clientId", value) } - searchPlaceholder="Search..." - emptyMessage="None found" + searchPlaceholder={t("searchPlaceholder")} + emptyMessage={t("emptySearchOptions")} />
); @@ -388,19 +386,19 @@ export default function ConnectionLogsPage() { accessorKey: "userEmail", header: () => { return ( -
- {t("user")} - + ({ value: u.id, label: u.email || u.id }))} + label={t("user")} selectedValue={filters.userId} onValueChange={(value) => handleFilterChange("userId", value) } - searchPlaceholder="Search..." - emptyMessage="None found" + searchPlaceholder={t("searchPlaceholder")} + emptyMessage={t("emptySearchOptions")} />
); @@ -419,9 +417,7 @@ export default function ConnectionLogsPage() { }, { accessorKey: "sourceAddr", - header: () => { - return t("sourceAddress"); - }, + header: () => {t("sourceAddress")}, cell: ({ row }) => { return ( @@ -434,19 +430,19 @@ export default function ConnectionLogsPage() { accessorKey: "destAddr", header: () => { return ( -
- {t("destinationAddress")} - + ({ value: addr, label: addr }))} + label={t("destinationAddress")} selectedValue={filters.destAddr} onValueChange={(value) => handleFilterChange("destAddr", value) } - searchPlaceholder="Search..." - emptyMessage="None found" + searchPlaceholder={t("searchPlaceholder")} + emptyMessage={t("emptySearchOptions")} />
); @@ -461,9 +457,7 @@ export default function ConnectionLogsPage() { }, { accessorKey: "duration", - header: () => { - return t("duration"); - }, + header: () => {t("duration")}, cell: ({ row }) => { return ( diff --git a/src/app/[orgId]/settings/logs/request/page.tsx b/src/app/[orgId]/settings/logs/request/page.tsx index ae11da78a..e1249f9c7 100644 --- a/src/app/[orgId]/settings/logs/request/page.tsx +++ b/src/app/[orgId]/settings/logs/request/page.tsx @@ -20,6 +20,7 @@ import { useMemo, useState, useTransition } from "react"; import { useStoredPageSize } from "@app/hooks/useStoredPageSize"; import { build } from "@server/build"; import type { QueryRequestAuditLogResponse } from "@server/routers/auditLogs/types"; +import { ColumnFilterButton } from "@app/components/ColumnFilterButton"; export default function GeneralPage() { const router = useRouter(); @@ -284,9 +285,9 @@ export default function GeneralPage() { const columns: ColumnDef[] = [ { accessorKey: "timestamp", - header: ({ column }) => { - return t("timestamp"); - }, + header: ({ column }) => ( + {t("timestamp")} + ), cell: ({ row }) => { return (
@@ -299,22 +300,21 @@ export default function GeneralPage() { }, { accessorKey: "action", - header: ({ column }) => { + header: () => { return ( -
- {t("action")} - + handleFilterChange("action", value) } - // placeholder="" - searchPlaceholder="Search..." - emptyMessage="None found" + searchPlaceholder={t("searchPlaceholder")} + emptyMessage={t("emptySearchOptions")} />
); @@ -329,17 +329,14 @@ export default function GeneralPage() { }, { accessorKey: "ip", - header: ({ column }) => { - return t("ip"); - } + header: ({ column }) => {t("ip")} }, { accessorKey: "location", header: ({ column }) => { return ( -
- {t("location")} - + ({ value: location, @@ -351,8 +348,9 @@ export default function GeneralPage() { handleFilterChange("location", value) } // placeholder="" - searchPlaceholder="Search..." - emptyMessage="None found" + label={t("location")} + searchPlaceholder={t("searchPlaceholder")} + emptyMessage={t("emptySearchOptions")} />
); @@ -377,9 +375,8 @@ export default function GeneralPage() { accessorKey: "resourceName", header: ({ column }) => { return ( -
- {t("resource")} - + ({ value: res.id.toString(), label: res.name || "Unnamed Resource" @@ -388,9 +385,9 @@ export default function GeneralPage() { onValueChange={(value) => handleFilterChange("resourceId", value) } - // placeholder="" - searchPlaceholder="Search..." - emptyMessage="None found" + label={t("resource")} + searchPlaceholder={t("searchPlaceholder")} + emptyMessage={t("emptySearchOptions")} />
); @@ -417,9 +414,8 @@ export default function GeneralPage() { accessorKey: "host", header: ({ column }) => { return ( -
- {t("host")} - + ({ value: host, label: host @@ -428,9 +424,9 @@ export default function GeneralPage() { onValueChange={(value) => handleFilterChange("host", value) } - // placeholder="" - searchPlaceholder="Search..." - emptyMessage="None found" + label={t("host")} + searchPlaceholder={t("searchPlaceholder")} + emptyMessage={t("emptySearchOptions")} />
); @@ -452,9 +448,8 @@ export default function GeneralPage() { accessorKey: "path", header: ({ column }) => { return ( -
- {t("path")} - + ({ value: path, label: path @@ -463,9 +458,9 @@ export default function GeneralPage() { onValueChange={(value) => handleFilterChange("path", value) } - // placeholder="" - searchPlaceholder="Search..." - emptyMessage="None found" + label={t("path")} + searchPlaceholder={t("searchPlaceholder")} + emptyMessage={t("emptySearchOptions")} />
); @@ -482,9 +477,8 @@ export default function GeneralPage() { accessorKey: "method", header: ({ column }) => { return ( -
- {t("method")} - + handleFilterChange("method", value) } - // placeholder="" - searchPlaceholder="Search..." - emptyMessage="None found" + label={t("method")} + searchPlaceholder={t("searchPlaceholder")} + emptyMessage={t("emptySearchOptions")} />
); @@ -510,9 +504,8 @@ export default function GeneralPage() { accessorKey: "reason", header: ({ column }) => { return ( -
- {t("reason")} - + handleFilterChange("reason", value) } - // placeholder="" - searchPlaceholder="Search..." - emptyMessage="None found" + label={t("reason")} + searchPlaceholder={t("searchPlaceholder")} + emptyMessage={t("emptySearchOptions")} />
); @@ -556,9 +549,8 @@ export default function GeneralPage() { accessorKey: "actor", header: ({ column }) => { return ( -
- {t("actor")} - + ({ value: actor, label: actor @@ -567,9 +559,9 @@ export default function GeneralPage() { onValueChange={(value) => handleFilterChange("actor", value) } - // placeholder="" - searchPlaceholder="Search..." - emptyMessage="None found" + label={t("actor")} + searchPlaceholder={t("searchPlaceholder")} + emptyMessage={t("emptySearchOptions")} />
); diff --git a/src/app/[orgId]/settings/resources/private/page.tsx b/src/app/[orgId]/settings/resources/private/page.tsx index f90bc91c7..23ff86296 100644 --- a/src/app/[orgId]/settings/resources/private/page.tsx +++ b/src/app/[orgId]/settings/resources/private/page.tsx @@ -108,10 +108,7 @@ export default async function ClientResourcesPage( siteNiceId: siteResource.siteNiceIds[idx], online: siteResource.siteOnlines[idx] })), - mode: - siteResource.pamMode && siteResource.mode === "host" - ? "ssh" - : siteResource.mode, + mode: siteResource.mode, scheme: siteResource.scheme, ssl: siteResource.ssl, siteNames: siteResource.siteNames, diff --git a/src/app/[orgId]/settings/resources/public/ProxyResourceTargetsForm.tsx b/src/app/[orgId]/settings/resources/public/ProxyResourceTargetsForm.tsx index 5863a50a8..b8042b2f3 100644 --- a/src/app/[orgId]/settings/resources/public/ProxyResourceTargetsForm.tsx +++ b/src/app/[orgId]/settings/resources/public/ProxyResourceTargetsForm.tsx @@ -10,7 +10,10 @@ import { PathRewriteDisplay, PathRewriteModal } from "@app/components/PathMatchRenameModal"; -import { ResourceTargetAddressItem } from "@app/components/resource-target-address-item"; +import { + ResourceTargetAddressItem, + ResourceTargetSiteItem +} from "@app/components/resource-target-address-item"; import { SettingsSection, SettingsSectionBody, @@ -18,6 +21,7 @@ import { SettingsSectionHeader, SettingsSectionTitle } from "@app/components/Settings"; +import { DataTableEmptyState } from "@app/components/ui/data-table-empty-state"; import { Table, TableBody, @@ -65,6 +69,7 @@ import { useMemo, useState } from "react"; +import { maxSize } from "zod"; export type LocalTarget = Omit< ArrayElement & { @@ -138,11 +143,6 @@ export function ProxyResourceTargetsForm({ const [selectedTargetForHealthCheck, setSelectedTargetForHealthCheck] = useState(null); - const [bgDestination, setBgDestination] = useState(""); - const [bgDestinationPort, setBgDestinationPort] = useState(""); - const [bgSiteId, setBgSiteId] = useState(null); - const [bgTargetId, setBgTargetId] = useState(null); - const initializeDockerForSite = async (siteId: number) => { if (dockerStates.has(siteId)) { return; @@ -207,42 +207,6 @@ export function ProxyResourceTargetsForm({ }) ); - // Browser-gateway targets (edit mode only) - const { data: bgTargetsResponse } = useQuery({ - queryKey: ["browserGatewayTargets", resource?.resourceId, orgId], - queryFn: async () => { - const res = await api.get( - `/org/${orgId}/resource/${resource!.resourceId}/browser-gateway-targets` - ); - return res.data.data as { - targets: Array<{ - browserGatewayTargetId: number; - resourceId: number; - siteId: number; - type: string; - destination: string; - destinationPort: number; - }>; - }; - }, - enabled: !!resource - }); - - useEffect(() => { - if (!bgTargetsResponse?.targets?.length) return; - const bgt = bgTargetsResponse.targets[0]; - setBgDestination(bgt.destination); - setBgDestinationPort(String(bgt.destinationPort)); - setBgSiteId(bgt.siteId); - setBgTargetId(bgt.browserGatewayTargetId); - }, [bgTargetsResponse]); - - useEffect(() => { - if (sites.length > 0 && bgSiteId === null) { - setBgSiteId(sites[0].siteId); - } - }, [sites, bgSiteId]); - const updateTarget = useCallback( (targetId: number, data: Partial) => { setTargets((prevTargets) => { @@ -269,7 +233,7 @@ export function ProxyResourceTargetsForm({ const priorityColumn: ColumnDef = { id: "priority", header: () => ( -
+
{t("priority")} @@ -285,7 +249,6 @@ export function ProxyResourceTargetsForm({ ), cell: ({ row }) => { return ( -
-
); }, size: 120, @@ -334,19 +296,15 @@ export function ProxyResourceTargetsForm({ {row.original.siteType === "newt" ? (
+ {getStatusText(status)} ) : ( - @@ -441,13 +399,12 @@ export function ProxyResourceTargetsForm({ maxSize: 200 }; - const addressColumn: ColumnDef = { - accessorKey: "address", - header: () => {t("address")}, + const siteColumn: ColumnDef = { + accessorKey: "site", + header: () => {t("site")}, cell: ({ row }) => { return ( - ); }, - size: 400, - minSize: 350, - maxSize: 500 + size: 220, + minSize: 180, + maxSize: 280 + }; + + const addressColumn: ColumnDef = { + accessorKey: "address", + header: () => {t("address")}, + cell: ({ row }) => { + return ( + + ); + }, + size: 350, + minSize: 300, + maxSize: 450 }; const rewritePathColumn: ColumnDef = { @@ -535,7 +509,7 @@ export function ProxyResourceTargetsForm({ accessorKey: "enabled", header: () => {t("enabled")}, cell: ({ row }) => ( -
+
@@ -554,9 +528,8 @@ export function ProxyResourceTargetsForm({ const actionsColumn: ColumnDef = { id: "actions", - header: () => {t("actions")}, cell: ({ row }) => ( -
+
+ ); + + const hasTargets = targets.length > 0; + async function saveTargets() { if (!resource) return; @@ -808,131 +794,104 @@ export function ProxyResourceTargetsForm({ - {targets.length > 0 ? ( - <> -
- - - {table - .getHeaderGroups() - .map((headerGroup) => ( - - {headerGroup.headers.map( - (header) => { - const isActionsColumn = - header.column - .id === - "actions"; - return ( - - {header.isPlaceholder - ? null - : flexRender( - header - .column - .columnDef - .header, - header.getContext() - )} - - ); - } - )} - - ))} - - - {table.getRowModel().rows?.length ? ( - table - .getRowModel() - .rows.map((row) => ( - - {row - .getVisibleCells() - .map((cell) => { - const isActionsColumn = - cell.column - .id === - "actions"; - return ( - - {flexRender( - cell - .column - .columnDef - .cell, - cell.getContext() - )} - - ); - })} - - )) - ) : ( - - +
+ + {table.getHeaderGroups().map((headerGroup) => ( + + {headerGroup.headers.map((header) => { + const isActionsColumn = + header.column.id === "actions"; + const isSiteColumn = + header.column.id === "site"; + return ( + - {t("targetNoOne")} - - - )} - -
-
-
-
-
+ {hasTargets && ( +
+
+ {addTargetButton} +
+ +
- - ) : ( -
-

- {t("targetNoOne")} -

-
)} {build === "saas" && diff --git a/src/app/[orgId]/settings/resources/public/[niceId]/authentication/page.tsx b/src/app/[orgId]/settings/resources/public/[niceId]/authentication/page.tsx index ba55ce833..29c2f4825 100644 --- a/src/app/[orgId]/settings/resources/public/[niceId]/authentication/page.tsx +++ b/src/app/[orgId]/settings/resources/public/[niceId]/authentication/page.tsx @@ -1,350 +1,7 @@ "use client"; -import ActionBanner from "@app/components/ActionBanner"; -import { EditPolicyForm } from "@app/components/resource-policy/EditPolicyForm"; -import { - SettingsContainer, - SettingsSection, - SettingsSectionBody, - SettingsSectionDescription, - SettingsSectionFooter, - SettingsSectionHeader, - SettingsSectionTitle -} from "@app/components/Settings"; -import { - StrategySelect, - type StrategyOption -} from "@app/components/StrategySelect"; -import { Button } from "@app/components/ui/button"; -import { - Command, - CommandEmpty, - CommandGroup, - CommandInput, - CommandItem, - CommandList -} from "@app/components/ui/command"; -import { - Popover, - PopoverContent, - PopoverTrigger -} from "@app/components/ui/popover"; -import { useEnvContext } from "@app/hooks/useEnvContext"; -import { useOrgContext } from "@app/hooks/useOrgContext"; -import { usePaidStatus } from "@app/hooks/usePaidStatus"; -import { useResourceContext } from "@app/hooks/useResourceContext"; -import { toast } from "@app/hooks/useToast"; -import { createApiClient, formatAxiosError } from "@app/lib/api"; -import { cn } from "@app/lib/cn"; -import { orgQueries, resourceQueries } from "@app/lib/queries"; -import { ResourcePolicyProvider } from "@app/providers/ResourcePolicyProvider"; -import { zodResolver } from "@hookform/resolvers/zod"; -import { CaretSortIcon } from "@radix-ui/react-icons"; -import { build } from "@server/build"; -import { tierMatrix, TierFeature } from "@server/lib/billing/tierMatrix"; -import { useQuery, useQueryClient } from "@tanstack/react-query"; -import SetResourcePasswordForm from "@app/components/SetResourcePasswordForm"; -import { Binary, Bot, InfoIcon, Key } from "lucide-react"; -import { ArrowRightIcon, CheckIcon, ShieldAlertIcon } from "lucide-react"; -import { useTranslations } from "next-intl"; -import Link from "next/link"; -import { useRouter } from "next/navigation"; -import { useEffect, useState, useTransition } from "react"; -import { useForm, useWatch } from "react-hook-form"; -import { z } from "zod"; - -const resourceTypeSchema = z - .object({ - type: z.literal("inline") - }) - .or( - z.object({ - type: z.literal("shared"), - resourcePolicyId: z.number() - }) - ); - -type ResourcePolicyType = StrategyOption<"inline" | "shared">; +import { ResourcePolicyEditForm } from "@app/components/resource-policy/ResourcePolicyEditForm"; export default function ResourceAuthenticationPage() { - const { org } = useOrgContext(); - const { resource, updateResource } = useResourceContext(); - const queryClient = useQueryClient(); - - const { env } = useEnvContext(); - const { isPaidUser } = usePaidStatus(); - - const api = createApiClient({ env }); - const router = useRouter(); - const t = useTranslations(); - - const { data: policies, isLoading: isLoadingPolicies } = useQuery( - resourceQueries.policies({ - resourceId: resource.resourceId - }) - ); - - const form = useForm({ - resolver: zodResolver(resourceTypeSchema), - defaultValues: { - type: - build !== "oss" && resource.resourcePolicyId - ? "shared" - : "inline" - } - }); - - const selectedResourceType = useWatch({ - control: form.control, - name: "type" - }); - - const [resourcePolicysearchQuery, setResourcePolicySearchQuery] = - useState(""); - - const { data: policiesList = [] } = useQuery({ - ...orgQueries.policies({ - orgId: org.org.orgId, - name: resourcePolicysearchQuery - }), - enabled: selectedResourceType === "shared" - }); - - const [selectedPolicy, setSelectedPolicy] = useState<{ - name: string; - id: number; - } | null>(null); - - const resourcePolicyTypes: Array = [ - { - id: "inline", - title: t("resourcePolicyInline"), - description: t("resourcePolicyInlineDescription") - }, - { - id: "shared", - title: t("resourcePolicyShared"), - description: t("resourcePolicySharedDescription") - } - ]; - - useEffect(() => { - if (!isLoadingPolicies && policies?.sharedPolicy) { - setSelectedPolicy({ - id: policies?.sharedPolicy.resourcePolicyId, - name: policies?.sharedPolicy.name - }); - } - }, [isLoadingPolicies, policies?.sharedPolicy]); - - const [isUpdatingResource, startTransition] = useTransition(); - - async function handleSaveResourcePolicyType() { - try { - if (selectedResourceType === "inline") { - await api.post(`/resource/${resource.resourceId}`, { - resourcePolicyId: null - }); - } else { - if (!selectedPolicy) { - toast({ - title: t("error"), - description: t("resourcePolicySelectError"), - variant: "destructive" - }); - return; - } - await api.post(`/resource/${resource.resourceId}`, { - resourcePolicyId: selectedPolicy.id - }); - } - router.refresh(); - toast({ - title: t("resourceUpdated"), - description: t("resourceUpdatedDescription") - }); - } catch (e) { - toast({ - title: t("error"), - description: formatAxiosError(e), - variant: "destructive" - }); - } finally { - await queryClient.invalidateQueries( - resourceQueries.policies({ - resourceId: resource.resourceId - }) - ); - } - } - - const pageLoading = isLoadingPolicies || !policies; - - if (pageLoading) { - return <>; - } - - console.log({ - shared: policies.sharedPolicy - }); - - return ( - <> - - {build !== "oss" && - isPaidUser(tierMatrix[TierFeature.ResourcePolicies]) && ( - - - - {t("resourcePolicySelectTitle")} - - - {t("resourcePolicySelectDescription")} - - - - { - form.setValue("type", value); - }} - cols={2} - /> - {selectedResourceType === "shared" && ( - - - - - - - - - - {t( - "resourcePolicyNotFound" - )} - - - {policiesList.map( - (policy) => ( - - setSelectedPolicy( - { - id: policy.resourcePolicyId, - name: policy.name - } - ) - } - > - - { - policy.name - } - - ) - )} - - - - - - )} - - - - - - )} - - {selectedResourceType === "inline" ? ( - - - - ) : ( - policies.sharedPolicy && ( - - - } - description={t( - "resourcePolicySharedDescription" - )} - actions={ - - } - /> - - - ) - )} - - - ); + return ; } diff --git a/src/app/[orgId]/settings/resources/public/[niceId]/general/page.tsx b/src/app/[orgId]/settings/resources/public/[niceId]/general/page.tsx index f53afd056..574dc27cb 100644 --- a/src/app/[orgId]/settings/resources/public/[niceId]/general/page.tsx +++ b/src/app/[orgId]/settings/resources/public/[niceId]/general/page.tsx @@ -11,7 +11,6 @@ import { FormMessage } from "@/components/ui/form"; import { Input } from "@/components/ui/input"; -import { Textarea } from "@/components/ui/textarea"; import { useResourceContext } from "@app/hooks/useResourceContext"; import DomainPicker from "@app/components/DomainPicker"; import { @@ -20,425 +19,76 @@ import { SettingsSectionBody, SettingsSectionDescription, SettingsSectionFooter, + SettingsFormCell, + SettingsFormGrid, SettingsSectionForm, SettingsSectionHeader, - SettingsSectionTitle + SettingsSectionTitle, + SettingsSubsectionDescription, + SettingsSubsectionHeader, + SettingsSubsectionTitle } from "@app/components/Settings"; import { SwitchInput } from "@app/components/SwitchInput"; -import { Label } from "@app/components/ui/label"; import { useEnvContext } from "@app/hooks/useEnvContext"; import { toast } from "@app/hooks/useToast"; import { createApiClient, formatAxiosError } from "@app/lib/api"; import { finalizeSubdomainSanitize } from "@app/lib/subdomain-utils"; -import { UpdateResourceResponse } from "@server/routers/resource"; +import { + GetResourceAuthInfoResponse, + UpdateResourceResponse +} from "@server/routers/resource"; import { AxiosResponse } from "axios"; -import { AlertCircle } from "lucide-react"; import { useTranslations } from "next-intl"; import { useParams, useRouter } from "next/navigation"; import { toASCII, toUnicode } from "punycode"; -import { useActionState, useMemo, useState } from "react"; +import { useActionState, useEffect, useMemo, useState } from "react"; import { useForm } from "react-hook-form"; import { zodResolver } from "@hookform/resolvers/zod"; import z from "zod"; -import { Alert, AlertDescription } from "@app/components/ui/alert"; -import { RadioGroup, RadioGroupItem } from "@app/components/ui/radio-group"; -import { - Tooltip, - TooltipProvider, - TooltipTrigger -} from "@app/components/ui/tooltip"; -import { PaidFeaturesAlert } from "@app/components/PaidFeaturesAlert"; -import { GetResourceResponse } from "@server/routers/resource/getResource"; -import type { ResourceContextType } from "@app/contexts/resourceContext"; +import { SharedPolicySelect } from "@app/components/shared-policy-selector"; +import { useOrgContext } from "@app/hooks/useOrgContext"; +import { orgQueries } from "@app/lib/queries"; +import { useQuery } from "@tanstack/react-query"; +import Link from "next/link"; +import { build } from "@server/build"; +import { TierFeature } from "@server/lib/billing/tierMatrix"; import { usePaidStatus } from "@app/hooks/usePaidStatus"; import { tierMatrix } from "@server/lib/billing/tierMatrix"; import UptimeAlertSection from "@app/components/UptimeAlertSection"; -type MaintenanceSectionFormProps = { - resource: GetResourceResponse; - updateResource: ResourceContextType["updateResource"]; -}; - -function MaintenanceSectionForm({ - resource, - updateResource -}: MaintenanceSectionFormProps) { - const { env } = useEnvContext(); - const t = useTranslations(); - const api = createApiClient({ env }); - const { isPaidUser } = usePaidStatus(); - - const MaintenanceFormSchema = z.object({ - maintenanceModeEnabled: z.boolean().optional(), - maintenanceModeType: z.enum(["forced", "automatic"]).optional(), - maintenanceTitle: z.string().max(255).optional(), - maintenanceMessage: z.string().max(2000).optional(), - maintenanceEstimatedTime: z.string().max(100).optional() - }); - - const maintenanceForm = useForm({ - resolver: zodResolver(MaintenanceFormSchema), - defaultValues: { - maintenanceModeEnabled: resource.maintenanceModeEnabled || false, - maintenanceModeType: resource.maintenanceModeType || "automatic", - maintenanceTitle: - resource.maintenanceTitle || "We'll be back soon!", - maintenanceMessage: - resource.maintenanceMessage || - "We are currently performing scheduled maintenance. Please check back soon.", - maintenanceEstimatedTime: resource.maintenanceEstimatedTime || "" - }, - mode: "onChange" - }); - - const isMaintenanceEnabled = maintenanceForm.watch( - "maintenanceModeEnabled" - ); - const maintenanceModeType = maintenanceForm.watch("maintenanceModeType"); - - const [, maintenanceFormAction, maintenanceSaveLoading] = useActionState( - onMaintenanceSubmit, - null - ); - - async function onMaintenanceSubmit() { - const isValid = await maintenanceForm.trigger(); - if (!isValid) return; - - const data = maintenanceForm.getValues(); - - const res = await api - .post>( - `resource/${resource?.resourceId}`, - { - maintenanceModeEnabled: data.maintenanceModeEnabled, - maintenanceModeType: data.maintenanceModeType, - maintenanceTitle: data.maintenanceTitle || null, - maintenanceMessage: data.maintenanceMessage || null, - maintenanceEstimatedTime: - data.maintenanceEstimatedTime || null - } - ) - .catch((e) => { - toast({ - variant: "destructive", - title: t("resourceErrorUpdate"), - description: formatAxiosError( - e, - t("resourceErrorUpdateDescription") - ) - }); - }); - - if (res && res.status === 200) { - updateResource({ - maintenanceModeEnabled: data.maintenanceModeEnabled, - maintenanceModeType: data.maintenanceModeType, - maintenanceTitle: data.maintenanceTitle || null, - maintenanceMessage: data.maintenanceMessage || null, - maintenanceEstimatedTime: data.maintenanceEstimatedTime || null - }); - - toast({ - title: t("resourceUpdated"), - description: t("resourceUpdatedDescription") - }); - } - } - - if (!["http", "ssh", "rdp", "vnc"].includes(resource.mode)) { - return null; - } - - return ( - - - - {t("maintenanceMode")} - - - {t("maintenanceModeDescription")} - - - - - - -
- - { - const isDisabled = - !isPaidUser(tierMatrix.maintencePage) || - !["http", "ssh", "rdp", "vnc"].includes( - resource.mode - ); - - return ( - -
- - - - -
- { - if ( - !isDisabled - ) { - maintenanceForm.setValue( - "maintenanceModeEnabled", - val - ); - } - }} - /> -
-
-
-
-
-
- -
- ); - }} - /> - - {isMaintenanceEnabled && ( -
- ( - - - {t("maintenanceModeType")} - - - - - - - -
- - - {t( - "automatic" - )} - {" "} - ( - {t( - "recommended" - )} - ) - - - {t( - "automaticModeDescription" - )} - -
-
- - - - -
- - - {t( - "forced" - )} - - - - {t( - "forcedModeDescription" - )} - -
-
-
-
- -
- )} - /> - - {maintenanceModeType === "forced" && ( - - - - {t("forcedeModeWarning")} - - - )} - - ( - - - {t("pageTitle")} - - - - - - {t("pageTitleDescription")} - - - - )} - /> - - ( - - - {t( - "maintenancePageMessage" - )} - - -