Compare commits

...

73 Commits

Author SHA1 Message Date
miloschwartz d082de3d88 command palette tweaks 2026-07-07 21:55:15 -04:00
Milo Schwartz 1e7863ce4f Merge pull request #3342 from Fredkiss3/feat/geoip-country-is-not-rule
feat: add country `is not` rule in resource policies
2026-07-07 21:18:43 -04:00
Milo Schwartz a942871f3d Merge pull request #3263 from Fredkiss3/feat/command-bar
feat: Command Palette for searching accross the dashboard
2026-07-07 21:17:39 -04:00
Milo Schwartz da1205d38e Merge pull request #3407 from fosrl/fix/non-semver-version-error
fix: invalid version causing crashes in the frontend
2026-07-07 21:09:05 -04:00
Fred KISSIE 92775a51ca 🐛 fix invalid semver version causing crashes in frontend 2026-07-08 01:44:28 +02:00
Fred KISSIE ea60aa701e 🐛 fix sitetable page crashing because of invalid version 2026-07-08 01:39:52 +02:00
Fred KISSIE b1f7ee02b0 🐛 fix imports 2026-07-08 00:58:03 +02:00
Fred KISSIE affa3bbb6a Merge branch 'dev' into feat/command-bar 2026-07-08 00:33:28 +02:00
miloschwartz dbb1e7b274 dont show non sso resoruces 2026-07-07 18:10:12 -04:00
miloschwartz b2d1a66279 bump version 2026-07-07 17:08:49 -04:00
miloschwartz 202466792c fix search icon alignment 2026-07-07 16:34:37 -04:00
miloschwartz 4dcf684868 add 1.20.0 migration 2026-07-07 16:04:41 -04:00
miloschwartz 2b8f50af6f add missing api key perms to permissions selector 2026-07-07 15:23:41 -04:00
Owen a8c255ef71 Fix type issue 2026-07-07 15:21:23 -04:00
miloschwartz d9608bd408 improve role required feedback on user 2026-07-07 15:17:29 -04:00
Owen be193731c1 Reorder labels columns 2026-07-07 15:15:00 -04:00
Owen 0c3edc7560 Further remove labels 2026-07-07 15:12:14 -04:00
miloschwartz 813a3f07dc Merge branch 'dev' into private-resource-page 2026-07-07 15:07:35 -04:00
Owen 417438c209 Remove feature flags from labels 2026-07-07 15:04:41 -04:00
miloschwartz beaa5735ce move cert to info box and add network access to create wizard 2026-07-07 14:51:15 -04:00
Owen 83de8576bf Add ? to support undefined 2026-07-07 10:59:01 -04:00
Owen e9d424eb80 Move jit mode to a config param
Ref #3262
2026-07-07 10:41:12 -04:00
Owen 8cceed91cf Make feilds optional 2026-07-07 09:02:25 -04:00
Owen 633159fb77 Email whitelist should respect policies 2026-07-07 08:37:20 -04:00
miloschwartz 4c8bd4db0a includen on sso resources in launcher 2026-07-06 22:00:35 -04:00
miloschwartz d742300e82 add mising tooltips to collapsed sidebar 2026-07-06 21:52:22 -04:00
miloschwartz a521db91a2 migrate private resources to page 2026-07-06 21:48:29 -04:00
Owen d4549f1c33 log error 2026-07-06 14:44:30 -04:00
Owen 71da22328c Quiet logs 2026-07-06 14:44:30 -04:00
miloschwartz 0b5b732a48 Merge branch 'resource-launcher-compact' into dev 2026-07-06 14:19:58 -04:00
miloschwartz a82bae8f93 add resource info to side panel 2026-07-06 12:45:07 -04:00
Owen e59176149b Disable jit
Fixes #3262
2026-07-06 11:01:40 -04:00
Owen 2c3151da9b Fix #3374 2026-07-03 17:36:06 -04:00
Owen f60b8795ad Fix #3383 2026-07-03 17:26:49 -04:00
Owen 4054976388 Fix #3395 2026-07-03 17:15:48 -04:00
Fred KISSIE 11c30b8b27 Merge branch 'dev' into feat/geoip-country-is-not-rule 2026-07-03 22:50:56 +02:00
Owen 390c822bb4 Fix issue with overlapping site resources not sending 2026-07-03 16:13:50 -04:00
Owen 2bfc1901a6 Dont check the subnet because we dont use it 2026-07-03 14:38:01 -04:00
Owen 5da186b528 Quiet warning messages 2026-07-03 11:58:00 -04:00
Owen 600a96c13b Update rate limit to 10 2026-07-03 11:52:59 -04:00
Owen e4e0da3723 Add not exists check 2026-07-03 11:24:53 -04:00
miloschwartz 4087d7fb6b remove text from refresh button 2026-07-03 11:18:06 -04:00
miloschwartz 9edb86fd73 add invalidate launcher cache on refresh 2026-07-03 10:30:41 -04:00
Owen 440ebfe08e Clarify error messages 2026-07-03 10:26:13 -04:00
Owen b399d2a291 Add some retry and database confict mitigation 2026-07-03 10:23:32 -04:00
miloschwartz 811119a9a6 reduce filter dropdown page size 2026-07-03 10:13:58 -04:00
miloschwartz b53f80d317 use column for default view 2026-07-03 09:59:49 -04:00
Owen 1b1fba60f1 Make sure to retry rebuilds 2026-07-03 09:02:08 -04:00
miloschwartz a89509c8bd add more caching 2026-07-02 23:32:16 -04:00
miloschwartz f0546eb622 add save default view 2026-07-02 22:27:51 -04:00
miloschwartz 12f9ac94fd allow grouping when filter applied 2026-07-02 22:07:30 -04:00
miloschwartz 1717a99cee add compact mode for large orgs 2026-07-02 21:53:52 -04:00
Owen b93d26f09f Fix reading from replicas 2026-07-02 21:46:53 -04:00
Owen fc54ad49b5 Fix trial showing 2026-07-02 21:46:44 -04:00
Owen f87e136f6b Unique subnets for exit nodes 2026-07-02 20:54:59 -04:00
Fred KISSIE 8d29602929 🏷️ fix types 2026-06-29 18:06:03 +02:00
Fred KISSIE 6c2a8bf6de 💄 Show the selected country in policy access rules country is not rule match 2026-06-27 02:12:09 +02:00
Fred KISSIE 697be01411 Merge branch 'dev' into feat/geoip-country-is-not-rule 2026-06-27 02:05:02 +02:00
Fred KISSIE 1bd6f240cc ♻️ add Country is not rule to verifySession 2026-06-26 23:18:46 +02:00
Fred KISSIE 278375f7be 🚧 More country is not rule 2026-06-25 21:26:13 +02:00
Fred KISSIE 65c383520b 💄 adjust match type column width 2026-06-25 20:49:55 +02:00
Fred KISSIE e54bd25516 🚧 add country is not rule 2026-06-25 20:28:57 +02:00
Fred KISSIE 8f0e17774f 💄 Update command bar 2026-06-22 21:22:55 +02:00
Fred KISSIE 2ab5540085 ♻️ add command palette trigger on the header 2026-06-19 18:08:36 +02:00
Fred KISSIE 4e7328a1cc ♻️ refactor private resource page 2026-06-16 22:29:03 +02:00
Fred KISSIE 6380079239 Command palette with search & actions 2026-06-16 22:28:31 +02:00
Fred KISSIE b6aba13d3a ♻️ rename query, add query 2026-06-16 22:28:04 +02:00
Fred KISSIE c0a9db92ef 💄 update command bar UI 2026-06-16 19:28:22 +02:00
Fred KISSIE 3bc5ab2136 🚧 wip 2026-06-13 00:48:32 +02:00
Fred KISSIE ce77268c82 🚧 wip: block top position of command palette 2026-06-13 00:47:36 +02:00
Fred KISSIE abc0a41d9e ♻️ reorganize command bar items 2026-06-13 00:27:39 +02:00
Fred KISSIE a68b57067c 🚧 wip: Use command bar nav items 2026-06-12 23:51:05 +02:00
Fred KISSIE 444d293a29 🚧 WIP: copy command bar from existing PR 2026-06-11 23:46:11 +02:00
171 changed files with 10786 additions and 4431 deletions
+114 -7
View File
@@ -266,6 +266,8 @@
"resourceRawDescriptionCloud": "Proxy requests over raw TCP/UDP using a port number. Requires sites to connect to a remote node.", "resourceRawDescriptionCloud": "Proxy requests over raw TCP/UDP using a port number. Requires sites to connect to a remote node.",
"resourceCreate": "Create Resource", "resourceCreate": "Create Resource",
"resourceCreateDescription": "Follow the steps below to create a new resource", "resourceCreateDescription": "Follow the steps below to create a new resource",
"resourcePublicCreate": "Create Public Resource",
"resourcePublicCreateDescription": "Follow the steps below to create a new public resource that is accessible through a web browser",
"resourceCreateGeneralDescription": "Configure the basic resource settings including the name and the type", "resourceCreateGeneralDescription": "Configure the basic resource settings including the name and the type",
"resourceSeeAll": "See All Resources", "resourceSeeAll": "See All Resources",
"resourceCreateGeneral": "General", "resourceCreateGeneral": "General",
@@ -613,7 +615,8 @@
"idpNameInternal": "Internal", "idpNameInternal": "Internal",
"emailInvalid": "Invalid email address", "emailInvalid": "Invalid email address",
"inviteValidityDuration": "Please select a duration", "inviteValidityDuration": "Please select a duration",
"accessRoleSelectPlease": "Please select a role", "accessRoleSelectPlease": "A user must belong to at least one role.",
"accessRoleRequired": "Role required",
"removeOwnAdminRoleConfirmTitle": "Remove your administrator access?", "removeOwnAdminRoleConfirmTitle": "Remove your administrator access?",
"removeOwnAdminRoleConfirmDescription": "You will no longer have administrator permissions in this organization after saving. Another administrator can restore access if needed.", "removeOwnAdminRoleConfirmDescription": "You will no longer have administrator permissions in this organization after saving. Another administrator can restore access if needed.",
"removeOwnAdminRoleConfirmButton": "Remove My Administrator Access", "removeOwnAdminRoleConfirmButton": "Remove My Administrator Access",
@@ -864,8 +867,8 @@
"policyAuthHeaderAuthTitle": "Basic Header Auth", "policyAuthHeaderAuthTitle": "Basic Header Auth",
"policyAuthHeaderAuthDescription": "Validate a custom HTTP header name and value on each request", "policyAuthHeaderAuthDescription": "Validate a custom HTTP header name and value on each request",
"policyAuthHeaderAuthSummary": "Header configured", "policyAuthHeaderAuthSummary": "Header configured",
"policyAuthHeaderName": "Header name", "policyAuthHeaderName": "Username",
"policyAuthHeaderValue": "Expected value", "policyAuthHeaderValue": "Password",
"policyAuthSetPasscode": "Set Passcode", "policyAuthSetPasscode": "Set Passcode",
"policyAuthSetPincode": "Set PIN Code", "policyAuthSetPincode": "Set PIN Code",
"policyAuthSetEmailWhitelist": "Set Email Whitelist", "policyAuthSetEmailWhitelist": "Set Email Whitelist",
@@ -1431,6 +1434,15 @@
"actionSetResourcePincode": "Set Resource Pincode", "actionSetResourcePincode": "Set Resource Pincode",
"actionSetResourceEmailWhitelist": "Set Resource Email Whitelist", "actionSetResourceEmailWhitelist": "Set Resource Email Whitelist",
"actionGetResourceEmailWhitelist": "Get Resource Email Whitelist", "actionGetResourceEmailWhitelist": "Get Resource Email Whitelist",
"actionGetResourcePolicy": "Get Resource Policy",
"actionUpdateResourcePolicy": "Update Resource Policy",
"actionSetResourcePolicyUsers": "Set Resource Policy Users",
"actionSetResourcePolicyRoles": "Set Resource Policy Roles",
"actionSetResourcePolicyPassword": "Set Resource Policy Password",
"actionSetResourcePolicyPincode": "Set Resource Policy Pincode",
"actionSetResourcePolicyHeaderAuth": "Set Resource Policy Header Authentication",
"actionSetResourcePolicyWhitelist": "Set Resource Policy Email Whitelist",
"actionSetResourcePolicyRules": "Set Resource Policy Rules",
"actionCreateTarget": "Create Target", "actionCreateTarget": "Create Target",
"actionDeleteTarget": "Delete Target", "actionDeleteTarget": "Delete Target",
"actionGetTarget": "Get Target", "actionGetTarget": "Get Target",
@@ -1507,6 +1519,30 @@
"navbar": "Navigation Menu", "navbar": "Navigation Menu",
"navbarDescription": "Main navigation menu for the application", "navbarDescription": "Main navigation menu for the application",
"navbarDocsLink": "Documentation", "navbarDocsLink": "Documentation",
"commandPaletteTitle": "Command Palette",
"commandPaletteDescription": "Search for pages, organizations, resources, and actions",
"commandPaletteSearchPlaceholder": "Search pages, resources, actions...",
"commandPaletteNoResults": "No results found.",
"commandPaletteSearching": "Searching...",
"commandPaletteNavigation": "Navigation",
"commandPaletteOrganizations": "Organizations",
"commandPaletteSites": "Sites",
"commandPaletteResources": "Resources",
"commandPaletteUsers": "Users",
"commandPaletteClients": "Machine Clients",
"commandPaletteActions": "Actions",
"commandPaletteCreateSite": "Create Site",
"commandPaletteCreateProxyResource": "Create Public Resource",
"commandPaletteCreatePrivateResource": "Create Private Resource",
"commandPaletteCreateUser": "Create User",
"commandPaletteCreateApiKey": "Create API Key",
"commandPaletteCreateMachineClient": "Create Machine Client",
"commandPaletteCreateAlertRule": "Create Alert Rule",
"commandPaletteCreateIdentityProvider": "Create Identity Provider",
"commandPaletteToggleTheme": "Toggle Theme",
"commandPaletteChooseOrganization": "Choose Organization",
"commandPaletteShortcutMac": "⌘K",
"commandPaletteShortcutWindows": "Ctrl K",
"otpErrorEnable": "Unable to enable 2FA", "otpErrorEnable": "Unable to enable 2FA",
"otpErrorEnableDescription": "An error occurred while enabling 2FA", "otpErrorEnableDescription": "An error occurred while enabling 2FA",
"otpSetupCheckCode": "Please enter a 6-digit code", "otpSetupCheckCode": "Please enter a 6-digit code",
@@ -1584,6 +1620,45 @@
"sidebarManagement": "Management", "sidebarManagement": "Management",
"sidebarBillingAndLicenses": "Billing & Licenses", "sidebarBillingAndLicenses": "Billing & Licenses",
"sidebarLogsAnalytics": "Analytics", "sidebarLogsAnalytics": "Analytics",
"commandSites": "Sites",
"commandActionModeInfo": "Type \">\" To Open Action Mode",
"commandResources": "Resources",
"commandProxyResources": "Public Resources",
"commandClientResources": "Private Resources",
"commandClients": "Clients",
"commandUserDevices": "User Devices",
"commandMachineClients": "Machine Clients",
"commandDomains": "Domains",
"commandRemoteExitNodes": "Remote Nodes",
"commandTeam": "Team",
"commandUsers": "Users",
"commandRoles": "Roles",
"commandInvitations": "Invitations",
"commandPolicies": "Shared Policies",
"commandResourcePolicies": "Public Resources Policies",
"commandIdentityProviders": "Identity Providers",
"commandApprovals": "Approval Requests",
"commandShareableLinks": "Shareable Links",
"commandOrganization": "Organization",
"commandLogsAndAnalytics": "Logs & Analytics",
"commandLogsAnalytics": "Analytics",
"commandLogsRequest": "HTTP Request Logs",
"commandLogsAccess": "Authentication Logs",
"commandLogsAction": "Admin Action Logs",
"commandLogsConnection": "Network Logs",
"commandLogsStreaming": "Event Streaming",
"commandManagement": "Management",
"commandAlerting": "Alerting",
"commandProvisioning": "Provisioning",
"commandBluePrints": "Blueprints",
"commandApiKeys": "API Keys",
"commandBillingAndLicenses": "Billing & Licenses",
"commandBilling": "Billing",
"commandEnterpriseLicenses": "Licenses",
"commandSettings": "Settings",
"commandLauncher": "Launcher",
"commandResourceLauncher": "Resource Launcher",
"commandSearchResults": "Search Results",
"alertingTitle": "Alerting", "alertingTitle": "Alerting",
"alertingDescription": "Define sources, triggers, and actions for notifications", "alertingDescription": "Define sources, triggers, and actions for notifications",
"alertingRules": "Alert rules", "alertingRules": "Alert rules",
@@ -2317,6 +2392,12 @@
"createInternalResourceDialogClose": "Close", "createInternalResourceDialogClose": "Close",
"createInternalResourceDialogCreateClientResource": "Create Private Resource", "createInternalResourceDialogCreateClientResource": "Create Private Resource",
"createInternalResourceDialogCreateClientResourceDescription": "Create a new resource that will only be accessible to clients connected to the organization", "createInternalResourceDialogCreateClientResourceDescription": "Create a new resource that will only be accessible to clients connected to the organization",
"privateResourceCreatePageSeeAll": "See All Private Resources",
"privateResourceAllowIcmpPing": "Allow ICMP Ping",
"privateResourceNetworkAccess": "Network Access",
"privateResourceNetworkAccessDescription": "Control TCP/UDP port access and whether ICMP ping is allowed for this resource.",
"hostSettings": "Host Settings",
"cidrSettings": "CIDR Settings",
"createInternalResourceDialogResourceProperties": "Resource Properties", "createInternalResourceDialogResourceProperties": "Resource Properties",
"createInternalResourceDialogName": "Name", "createInternalResourceDialogName": "Name",
"createInternalResourceDialogSite": "Site", "createInternalResourceDialogSite": "Site",
@@ -2465,6 +2546,7 @@
"noRemoteExitNodesAvailableDescription": "No nodes are available for this organization. Create a node first to use local sites.", "noRemoteExitNodesAvailableDescription": "No nodes are available for this organization. Create a node first to use local sites.",
"exitNode": "Exit Node", "exitNode": "Exit Node",
"country": "Country", "country": "Country",
"countryIsNot": "Country Is Not",
"rulesMatchCountry": "Currently based on source IP", "rulesMatchCountry": "Currently based on source IP",
"region": "Region", "region": "Region",
"selectRegion": "Select region", "selectRegion": "Select region",
@@ -3183,7 +3265,7 @@
"editInternalResourceDialogAddUsers": "Add Users", "editInternalResourceDialogAddUsers": "Add Users",
"editInternalResourceDialogAddClients": "Add Clients", "editInternalResourceDialogAddClients": "Add Clients",
"editInternalResourceDialogDestinationLabel": "Destination", "editInternalResourceDialogDestinationLabel": "Destination",
"editInternalResourceDialogDestinationDescription": "Choose where this resource runs and how clients reach it. Selecting multiple sites will create a high availability resource that can be accessed from any of the selected sites.", "editInternalResourceDialogDestinationDescription": "Choose where this resource runs and how clients reach it",
"internalResourceFormMultiSiteRoutingHelp": "Selecting multiple sites enables resilient routing and failover for high availability.", "internalResourceFormMultiSiteRoutingHelp": "Selecting multiple sites enables resilient routing and failover for high availability.",
"internalResourceFormMultiSiteRoutingHelpLearnMore": "Learn more", "internalResourceFormMultiSiteRoutingHelpLearnMore": "Learn more",
"editInternalResourceDialogPortRestrictionsDescription": "Restrict access to specific TCP/UDP ports or allow/block all ports.", "editInternalResourceDialogPortRestrictionsDescription": "Restrict access to specific TCP/UDP ports or allow/block all ports.",
@@ -3577,18 +3659,24 @@
"memberPortalResourceDisabled": "Resource Disabled", "memberPortalResourceDisabled": "Resource Disabled",
"memberPortalShowingResources": "Showing {start}-{end} of {total} resources", "memberPortalShowingResources": "Showing {start}-{end} of {total} resources",
"resourceLauncherTitle": "Resource Launcher", "resourceLauncherTitle": "Resource Launcher",
"resourceLauncherDescription": "View resource details and launch them from one place", "resourceSidebarLauncherTitle": "Launcher",
"resourceLauncherSearchPlaceholder": "Search all sites...", "resourceLauncherDescription": "View all available resources and launch them from one central hub",
"resourceLauncherSearchPlaceholder": "Search your resources...",
"resourceLauncherDefaultView": "Default", "resourceLauncherDefaultView": "Default",
"resourceLauncherSaveView": "Save View", "resourceLauncherSaveView": "Save View",
"resourceLauncherSaveToCurrentView": "Save to Current View", "resourceLauncherSaveToCurrentView": "Save to Current View",
"resourceLauncherSaveDefaultPersonal": "Save for Me",
"resourceLauncherResetView": "Reset View", "resourceLauncherResetView": "Reset View",
"resourceLauncherResetSystemDefault": "Reset to System Default",
"resourceLauncherSystemDefaultRestored": "System default restored",
"resourceLauncherSystemDefaultRestoredDescription": "The default view has been reset to the original settings.",
"resourceLauncherSaveAsNewView": "Save as New View", "resourceLauncherSaveAsNewView": "Save as New View",
"resourceLauncherSaveAsNewViewDescription": "Give this view a name to save your current filters and layout.", "resourceLauncherSaveAsNewViewDescription": "Give this view a name to save your current filters and layout.",
"resourceLauncherSaveForEveryone": "Save for Everyone", "resourceLauncherSaveForEveryone": "Save for Everyone",
"resourceLauncherSaveForEveryoneDescription": "Share this view with all organization members. When unchecked, the view is only visible to you.", "resourceLauncherSaveForEveryoneDescription": "Share this view with all organization members. When unchecked, the view is only visible to you.",
"resourceLauncherMakePersonal": "Make Personal", "resourceLauncherMakePersonal": "Make Personal",
"resourceLauncherFilter": "Filter", "resourceLauncherFilter": "Filter",
"resourceLauncherFilterWithCount": "Filter, {count} applied",
"resourceLauncherSort": "Sort", "resourceLauncherSort": "Sort",
"resourceLauncherSortAscending": "Sort ascending", "resourceLauncherSortAscending": "Sort ascending",
"resourceLauncherSortDescending": "Sort descending", "resourceLauncherSortDescending": "Sort descending",
@@ -3596,6 +3684,7 @@
"resourceLauncherGroupBy": "Group By", "resourceLauncherGroupBy": "Group By",
"resourceLauncherGroupBySite": "Site", "resourceLauncherGroupBySite": "Site",
"resourceLauncherGroupByLabel": "Label", "resourceLauncherGroupByLabel": "Label",
"resourceLauncherGroupByNone": "None",
"resourceLauncherLayout": "Layout", "resourceLauncherLayout": "Layout",
"resourceLauncherLayoutGrid": "Grid", "resourceLauncherLayoutGrid": "Grid",
"resourceLauncherLayoutList": "List", "resourceLauncherLayoutList": "List",
@@ -3603,8 +3692,20 @@
"resourceLauncherShowSiteTags": "Show Site Tags", "resourceLauncherShowSiteTags": "Show Site Tags",
"resourceLauncherShowRecents": "Show Recents", "resourceLauncherShowRecents": "Show Recents",
"resourceLauncherDeleteView": "Delete View", "resourceLauncherDeleteView": "Delete View",
"resourceLauncherDeleteViewTitle": "Delete View",
"resourceLauncherDeleteViewQuestion": "Are you sure you want to delete this launcher view?",
"resourceLauncherDeleteViewConfirm": "Delete View",
"resourceLauncherViewAsAdmin": "View as Admin", "resourceLauncherViewAsAdmin": "View as Admin",
"resourceLauncherResourceDetailsDescription": "View details for this resource.", "resourceLauncherResourceDetailsDescription": "Connection information and status for this resource.",
"resourceLauncherResourceDetails": "Resource Details",
"resourceLauncherAuthMethodsDescription": "Authentication methods enabled for this resource.",
"resourceLauncherPrivateClientRequired": "Connect with a client on your device to access this resource privately.",
"resourceLauncherPrivateClientRequiredTitle": "Client Connection Required",
"resourceLauncherDownloadClient": "Download client",
"resourceLauncherFailedToLoadDetails": "Could not load resource details. You may no longer have access to this resource.",
"resourceLauncherNoPortRestrictions": "No port restrictions",
"resourceLauncherTcp": "TCP",
"resourceLauncherUdp": "UDP",
"resourceLauncherUnlabeled": "Unlabeled", "resourceLauncherUnlabeled": "Unlabeled",
"resourceLauncherNoSite": "No Site", "resourceLauncherNoSite": "No Site",
"resourceLauncherNoResourcesInGroup": "No resources in this group", "resourceLauncherNoResourcesInGroup": "No resources in this group",
@@ -3613,6 +3714,12 @@
"resourceLauncherEmptyStateNoResultsTitle": "No Resources Found", "resourceLauncherEmptyStateNoResultsTitle": "No Resources Found",
"resourceLauncherEmptyStateNoResultsDescription": "No resources match your current search or filters. Try adjusting them to find what you are looking for.", "resourceLauncherEmptyStateNoResultsDescription": "No resources match your current search or filters. Try adjusting them to find what you are looking for.",
"resourceLauncherEmptyStateNoResultsWithQuery": "No resources match \"{query}\". Try adjusting your search or clearing filters to see all resources.", "resourceLauncherEmptyStateNoResultsWithQuery": "No resources match \"{query}\". Try adjusting your search or clearing filters to see all resources.",
"resourceLauncherSearchFirstTitle": "Search or Filter to Browse",
"resourceLauncherSearchFirstDescription": "You have access to many resources. Use search or filter by site or label to find what you need.",
"resourceLauncherSiteGroupingDisabled": "Site grouping is unavailable at this scale. Filter by site to group a smaller set.",
"resourceLauncherLabelGroupingDisabled": "Label grouping is unavailable at this scale.",
"resourceLauncherCompactModeHint": "Showing a simplified list for faster browsing. Use search or filters to narrow results.",
"resourceLauncherCompactGroupingHint": "Apply site or label filters to enable grouping.",
"resourceLauncherCopiedToClipboard": "Copied to clipboard", "resourceLauncherCopiedToClipboard": "Copied to clipboard",
"resourceLauncherCopiedAccessDescription": "Resource access has been copied to your clipboard.", "resourceLauncherCopiedAccessDescription": "Resource access has been copied to your clipboard.",
"resourceLauncherViewNamePlaceholder": "View name", "resourceLauncherViewNamePlaceholder": "View name",
+23
View File
@@ -1,3 +1,4 @@
import config from "@server/lib/config";
import { Pool, PoolConfig } from "pg"; import { Pool, PoolConfig } from "pg";
export function createPoolConfig( export function createPoolConfig(
@@ -39,6 +40,28 @@ export function attachPoolErrorHandlers(pool: Pool, label: string): void {
`Failed to set statement_timeout on ${label} client: ${err.message}` `Failed to set statement_timeout on ${label} client: ${err.message}`
); );
}); });
// Disable JIT compilation for this connection. Our hot-path queries
// (e.g. resource-by-domain lookups) join many tables but only ever
// return a handful of rows. When planner row estimates drift (e.g.
// due to autovacuum lag under write-heavy load), Postgres decides
// these plans are expensive enough to JIT-compile, which can add
// multiple seconds of pure compilation overhead per query and
// saturate the connection pool. JIT never pays off for these
// short-lived OLTP queries, so it's disabled outright rather than
// relying on statistics staying fresh.
//
// Set via a runtime SET command rather than the `options: "-c
// jit=off"` startup parameter: connections in SaaS mode go through
// a pooler (e.g. PgBouncer) that rejects arbitrary startup packet
// options with a protocol_violation (08P01) error.
if (config.getRawConfig().postgres?.pool.jit_mode == false) {
client.query("SET jit = off").catch((err: Error) => {
console.warn(
`Failed to set jit=off on ${label} client: ${err.message}`
);
});
}
}); });
} }
+22 -2
View File
@@ -232,6 +232,7 @@ export const launcherViews = pgTable("launcherViews", {
}), }),
name: varchar("name").notNull(), name: varchar("name").notNull(),
config: text("config").notNull(), config: text("config").notNull(),
isDefault: boolean("isDefault").notNull().default(false),
createdAt: varchar("createdAt").notNull(), createdAt: varchar("createdAt").notNull(),
updatedAt: varchar("updatedAt").notNull() updatedAt: varchar("updatedAt").notNull()
}); });
@@ -998,7 +999,17 @@ export const resourceRules = pgTable("resourceRules", {
enabled: boolean("enabled").notNull().default(true), enabled: boolean("enabled").notNull().default(true),
priority: integer("priority").notNull(), priority: integer("priority").notNull(),
action: varchar("action").notNull(), // ACCEPT, DROP, PASS action: varchar("action").notNull(), // ACCEPT, DROP, PASS
match: varchar("match").notNull(), // CIDR, PATH, IP match: varchar("match")
.$type<
| "CIDR"
| "PATH"
| "IP"
| "COUNTRY"
| "COUNTRY_IS_NOT"
| "ASN"
| "REGION"
>()
.notNull(), // CIDR, PATH, IP
value: varchar("value").notNull() value: varchar("value").notNull()
}); });
@@ -1013,7 +1024,15 @@ export const resourcePolicyRules = pgTable("resourcePolicyRules", {
priority: integer("priority").notNull(), priority: integer("priority").notNull(),
action: varchar("action").$type<"ACCEPT" | "DROP" | "PASS">().notNull(), action: varchar("action").$type<"ACCEPT" | "DROP" | "PASS">().notNull(),
match: varchar("match") match: varchar("match")
.$type<"CIDR" | "PATH" | "IP" | "COUNTRY" | "ASN" | "REGION">() .$type<
| "CIDR"
| "PATH"
| "IP"
| "COUNTRY"
| "COUNTRY_IS_NOT"
| "ASN"
| "REGION"
>()
.notNull(), .notNull(),
value: varchar("value").notNull() value: varchar("value").notNull()
}); });
@@ -1593,3 +1612,4 @@ export type LauncherView = InferSelectModel<typeof launcherViews>;
export type ResourcePolicy = InferSelectModel<typeof resourcePolicies>; export type ResourcePolicy = InferSelectModel<typeof resourcePolicies>;
export type RolePolicy = InferSelectModel<typeof rolePolicies>; export type RolePolicy = InferSelectModel<typeof rolePolicies>;
export type UserPolicy = InferSelectModel<typeof userPolicies>; export type UserPolicy = InferSelectModel<typeof userPolicies>;
export type ResourcePolicyRule = InferSelectModel<typeof resourcePolicyRules>;
+23 -2
View File
@@ -233,6 +233,9 @@ export const launcherViews = sqliteTable("launcherViews", {
}), }),
name: text("name").notNull(), name: text("name").notNull(),
config: text("config").notNull(), config: text("config").notNull(),
isDefault: integer("isDefault", { mode: "boolean" })
.notNull()
.default(false),
createdAt: text("createdAt").notNull(), createdAt: text("createdAt").notNull(),
updatedAt: text("updatedAt").notNull() updatedAt: text("updatedAt").notNull()
}); });
@@ -1221,7 +1224,17 @@ export const resourceRules = sqliteTable("resourceRules", {
enabled: integer("enabled", { mode: "boolean" }).notNull().default(true), enabled: integer("enabled", { mode: "boolean" }).notNull().default(true),
priority: integer("priority").notNull(), priority: integer("priority").notNull(),
action: text("action").notNull(), // ACCEPT, DROP, PASS action: text("action").notNull(), // ACCEPT, DROP, PASS
match: text("match").notNull(), // CIDR, PATH, IP match: text("match")
.$type<
| "CIDR"
| "PATH"
| "IP"
| "COUNTRY"
| "COUNTRY_IS_NOT"
| "ASN"
| "REGION"
>()
.notNull(), // CIDR, PATH, IP
value: text("value").notNull() value: text("value").notNull()
}); });
@@ -1268,7 +1281,15 @@ export const resourcePolicyRules = sqliteTable("resourcePolicyRules", {
priority: integer("priority").notNull(), priority: integer("priority").notNull(),
action: text("action").$type<"ACCEPT" | "DROP" | "PASS">().notNull(), action: text("action").$type<"ACCEPT" | "DROP" | "PASS">().notNull(),
match: text("match") match: text("match")
.$type<"CIDR" | "PATH" | "IP" | "COUNTRY" | "ASN" | "REGION">() .$type<
| "CIDR"
| "PATH"
| "IP"
| "COUNTRY"
| "COUNTRY_IS_NOT"
| "ASN"
| "REGION"
>()
.notNull(), .notNull(),
value: text("value").notNull() value: text("value").notNull()
}); });
-2
View File
@@ -23,7 +23,6 @@ export enum TierFeature {
StandaloneHealthChecks = "standaloneHealthChecks", StandaloneHealthChecks = "standaloneHealthChecks",
AlertingRules = "alertingRules", AlertingRules = "alertingRules",
WildcardSubdomain = "wildcardSubdomain", WildcardSubdomain = "wildcardSubdomain",
Labels = "labels",
NewtAutoUpdate = "newtAutoUpdate", NewtAutoUpdate = "newtAutoUpdate",
ResourcePolicies = "resourcePolicies", ResourcePolicies = "resourcePolicies",
AdvancedPublicResources = "advancedPublicResources", AdvancedPublicResources = "advancedPublicResources",
@@ -31,7 +30,6 @@ export enum TierFeature {
} }
export const tierMatrix: Record<TierFeature, Tier[]> = { export const tierMatrix: Record<TierFeature, Tier[]> = {
[TierFeature.Labels]: ["tier1", "tier2", "tier3", "enterprise"],
[TierFeature.OrgOidc]: ["tier1", "tier2", "tier3", "enterprise"], [TierFeature.OrgOidc]: ["tier1", "tier2", "tier3", "enterprise"],
[TierFeature.LoginPageDomain]: ["tier1", "tier2", "tier3", "enterprise"], [TierFeature.LoginPageDomain]: ["tier1", "tier2", "tier3", "enterprise"],
[TierFeature.DeviceApprovals]: ["tier1", "tier3", "enterprise"], [TierFeature.DeviceApprovals]: ["tier1", "tier3", "enterprise"],
@@ -116,7 +116,7 @@ export async function applyNewtDockerBlueprint(
source: "NEWT" source: "NEWT"
}); });
} catch (error) { } catch (error) {
logger.error(`Failed to update database from config: ${error}`); logger.debug(`Failed to update database from config: ${error}`);
await sendToClient(newtId, { await sendToClient(newtId, {
type: "newt/blueprint/results", type: "newt/blueprint/results",
data: { data: {
+32 -34
View File
@@ -1,56 +1,54 @@
import { isLicensedOrSubscribed } from "#dynamic/lib/isLicencedOrSubscribed";
import { createCertificate } from "#dynamic/routers/certificates/createCertificate";
import { hashPassword } from "@server/auth/password";
import { generateId } from "@server/auth/sessions/app";
import { build } from "@server/build";
import { import {
domains,
domainNamespaces, domainNamespaces,
domains,
orgDomains, orgDomains,
Resource, Resource,
resourceHeaderAuth, resourceHeaderAuth,
resourceHeaderAuthExtendedCompatibility, resourceHeaderAuthExtendedCompatibility,
resourcePassword,
resourcePincode, resourcePincode,
resourcePolicies,
resourcePolicyHeaderAuth,
resourcePolicyPassword,
resourcePolicyPincode,
resourcePolicyRules,
resourcePolicyWhiteList,
resourceRules, resourceRules,
resources,
resourceWhitelist, resourceWhitelist,
roleActions, roleActions,
rolePolicies,
roleResources, roleResources,
roles, roles,
sites,
Target, Target,
TargetHealthCheck, TargetHealthCheck,
targetHealthCheck, targetHealthCheck,
targets,
Transaction, Transaction,
userOrgs, userOrgs,
userPolicies,
userResources, userResources,
users, users,
resourcePolicies, type ResourceRule
resourcePolicyPassword,
resourcePolicyPincode,
resourcePolicyHeaderAuth,
resourcePolicyRules,
resourcePolicyWhiteList,
rolePolicies,
userPolicies
} from "@server/db"; } from "@server/db";
import { resources, targets, sites } from "@server/db";
import { eq, and, asc, or, ne, count, isNotNull } from "drizzle-orm";
import {
Config,
ConfigSchema,
isTargetsOnlyResource,
TargetData
} from "./types";
import logger from "@server/logger";
import { createCertificate } from "#dynamic/routers/certificates/createCertificate";
import { pickPort } from "@server/routers/target/helpers";
import { resourcePassword } from "@server/db";
import { getUniqueResourcePolicyName } from "@server/db/names"; import { getUniqueResourcePolicyName } from "@server/db/names";
import { hashPassword } from "@server/auth/password";
import { isValidCIDR, isValidIP, isValidUrlGlobPattern } from "../validators";
import { isValidRegionId } from "@server/db/regions"; import { isValidRegionId } from "@server/db/regions";
import { isLicensedOrSubscribed } from "#dynamic/lib/isLicencedOrSubscribed";
import { fireHealthCheckUnknownAlert } from "@server/lib/alerts"; 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"; import serverConfig from "@server/lib/config";
import { encrypt } from "@server/lib/crypto";
import logger from "@server/logger";
import { defaultRoleAllowedActions } from "@server/routers/role/createRole";
import { pickPort } from "@server/routers/target/helpers";
import { and, asc, eq, isNotNull, ne, or } from "drizzle-orm";
import { tierMatrix } from "../billing/tierMatrix";
import { isValidCIDR, isValidIP, isValidUrlGlobPattern } from "../validators";
import { Config, isTargetsOnlyResource, TargetData } from "./types";
import HttpCode from "@server/types/HttpCode"; import HttpCode from "@server/types/HttpCode";
import createHttpError from "http-errors"; import createHttpError from "http-errors";
import next from "next"; import next from "next";
@@ -909,7 +907,7 @@ export async function updatePublicResources(
.update(resourceRules) .update(resourceRules)
.set({ .set({
action: getRuleAction(rule.action), action: getRuleAction(rule.action),
match: rule.match.toUpperCase(), match: rule.match.toUpperCase() as ResourceRule["match"],
value: getRuleValue( value: getRuleValue(
rule.match.toUpperCase(), rule.match.toUpperCase(),
rule.value rule.value
@@ -928,7 +926,7 @@ export async function updatePublicResources(
await trx.insert(resourceRules).values({ await trx.insert(resourceRules).values({
resourceId: existingResource.resourceId, resourceId: existingResource.resourceId,
action: getRuleAction(rule.action), action: getRuleAction(rule.action),
match: rule.match.toUpperCase(), match: rule.match.toUpperCase() as ResourceRule["match"],
value: getRuleValue( value: getRuleValue(
rule.match.toUpperCase(), rule.match.toUpperCase(),
rule.value rule.value
@@ -1011,7 +1009,7 @@ export async function updatePublicResources(
} else { } else {
// create a brand new resource // create a brand new resource
if (build == "saas") { if (build === "saas") {
const usage = await usageService.getUsage( const usage = await usageService.getUsage(
orgId, orgId,
LimitId.PUBLIC_RESOURCES LimitId.PUBLIC_RESOURCES
@@ -1292,7 +1290,7 @@ export async function updatePublicResources(
await trx.insert(resourceRules).values({ await trx.insert(resourceRules).values({
resourceId: newResource.resourceId, resourceId: newResource.resourceId,
action: getRuleAction(rule.action), action: getRuleAction(rule.action),
match: rule.match.toUpperCase(), match: rule.match.toUpperCase() as ResourceRule["match"],
value: getRuleValue( value: getRuleValue(
rule.match.toUpperCase(), rule.match.toUpperCase(),
rule.value rule.value
@@ -1355,7 +1353,7 @@ function getRuleAction(input: string) {
function getRuleValue(match: string, value: string) { function getRuleValue(match: string, value: string) {
// if the match is a country, uppercase the value // if the match is a country, uppercase the value
if (match == "COUNTRY") { if (match === "COUNTRY" || match === "COUNTRY_IS_NOT") {
return value.toUpperCase(); return value.toUpperCase();
} }
return value; return value;
+2 -1
View File
@@ -347,12 +347,13 @@ function getRuleAction(input: string): "ACCEPT" | "DROP" | "PASS" {
function getRuleMatch( function getRuleMatch(
input: string input: string
): "CIDR" | "IP" | "PATH" | "COUNTRY" | "ASN" | "REGION" { ): "CIDR" | "IP" | "PATH" | "COUNTRY" | "COUNTRY_IS_NOT" | "ASN" | "REGION" {
return input.toUpperCase() as return input.toUpperCase() as
| "CIDR" | "CIDR"
| "IP" | "IP"
| "PATH" | "PATH"
| "COUNTRY" | "COUNTRY"
| "COUNTRY_IS_NOT"
| "ASN" | "ASN"
| "REGION"; | "REGION";
} }
+1 -1
View File
@@ -2,7 +2,7 @@ import path from "path";
import { fileURLToPath } from "url"; import { fileURLToPath } from "url";
// This is a placeholder value replaced by the build process // This is a placeholder value replaced by the build process
export const APP_VERSION = "1.19.0"; export const APP_VERSION = "1.20.0";
export const __FILENAME = fileURLToPath(import.meta.url); export const __FILENAME = fileURLToPath(import.meta.url);
export const __DIRNAME = path.dirname(__FILENAME); export const __DIRNAME = path.dirname(__FILENAME);
+83
View File
@@ -0,0 +1,83 @@
import logger from "@server/logger";
const MAX_RETRIES = 5;
const BASE_DELAY_MS = 50;
/**
* Detect transient errors that are safe to retry (connection drops, deadlocks,
* serialization failures). PostgreSQL deadlocks (40P01) are always safe to
* retry: the database guarantees exactly one winner per deadlock pair, so the
* loser just needs to try again.
*/
export function isTransientError(error: any): boolean {
if (!error) return false;
const message = (error.message || "").toLowerCase();
const causeMessage = (error.cause?.message || "").toLowerCase();
const code = error.code || error.cause?.code || "";
// Connection timeout / terminated
if (
message.includes("connection timeout") ||
message.includes("connection terminated") ||
message.includes("timeout exceeded when trying to connect") ||
causeMessage.includes("connection terminated unexpectedly") ||
causeMessage.includes("connection timeout")
) {
return true;
}
// PostgreSQL deadlock detected - always safe to retry (one winner guaranteed)
if (code === "40P01" || message.includes("deadlock")) {
return true;
}
// PostgreSQL serialization failure
if (code === "40001") {
return true;
}
// ECONNRESET, ECONNREFUSED, EPIPE, ETIMEDOUT
if (
code === "ECONNRESET" ||
code === "ECONNREFUSED" ||
code === "EPIPE" ||
code === "ETIMEDOUT"
) {
return true;
}
return false;
}
/**
* Simple retry wrapper with exponential backoff for transient errors
* (deadlocks, connection timeouts, unexpected disconnects).
*/
export async function withRetry<T>(
operation: () => Promise<T>,
context: string,
maxRetries: number = MAX_RETRIES,
baseDelayMs: number = BASE_DELAY_MS
): Promise<T> {
let attempt = 0;
while (true) {
try {
return await operation();
} catch (error: any) {
if (isTransientError(error) && attempt < maxRetries) {
attempt++;
const baseDelay = Math.pow(2, attempt - 1) * baseDelayMs;
const jitter = Math.random() * baseDelay;
const delay = baseDelay + jitter;
logger.warn(
`Transient DB error in ${context}, retrying attempt ${attempt}/${maxRetries} after ${delay.toFixed(0)}ms`,
{ code: error?.code ?? error?.cause?.code }
);
await new Promise((resolve) => setTimeout(resolve, delay));
continue;
}
throw error;
}
}
}
+29 -4
View File
@@ -1,10 +1,31 @@
import { db, exitNodes } from "@server/db"; import { db, exitNodes, Transaction } from "@server/db";
import config from "@server/lib/config"; import config from "@server/lib/config";
import { findNextAvailableCidr } from "@server/lib/ip"; import { findNextAvailableCidr } from "@server/lib/ip";
import { lockManager } from "#dynamic/lib/lock";
export async function getNextAvailableSubnet(): Promise<string> { /**
* Reserves the next available exit node subnet.
*
* Exit node subnets must never overlap with one another - regardless of
* which org(s) they belong to - since HA exit nodes can end up routing for
* the same org. This acquires a lock that the caller MUST release (via the
* returned `release`) only after the chosen address has been durably
* persisted (e.g. after the enclosing transaction commits), otherwise
* concurrent callers can race and pick the same subnet.
*/
export async function getNextAvailableSubnet(
trx: Transaction | typeof db = db
): Promise<{ value: string; release: () => Promise<void> }> {
const lockKey = "exit-node-subnet-allocation";
const acquired = await lockManager.acquireLockWithRetry(lockKey, 6000);
if (!acquired) {
throw new Error(`Failed to acquire lock: ${lockKey}`);
}
const release = () => lockManager.releaseLock(lockKey, acquired);
try {
// Get all existing subnets from routes table // Get all existing subnets from routes table
const existingAddresses = await db const existingAddresses = await trx
.select({ .select({
address: exitNodes.address address: exitNodes.address
}) })
@@ -26,5 +47,9 @@ export async function getNextAvailableSubnet(): Promise<string> {
".1" + ".1" +
"/" + "/" +
subnet.split("/")[1]; subnet.split("/")[1];
return subnet; return { value: subnet, release };
} catch (e) {
await release();
throw e;
}
} }
+2 -1
View File
@@ -184,7 +184,8 @@ export const configSchema = z
.number() .number()
.positive() .positive()
.optional() .optional()
.default(5000) .default(5000),
jit_mode: z.boolean().default(true)
}) })
.optional() .optional()
.prefault({}) .prefault({})
+91 -17
View File
@@ -45,6 +45,7 @@ import {
} from "@server/routers/client/targets"; } from "@server/routers/client/targets";
import { lockManager } from "#dynamic/lib/lock"; import { lockManager } from "#dynamic/lib/lock";
import { rebuildQueue } from "#dynamic/lib/rebuildQueue"; import { rebuildQueue } from "#dynamic/lib/rebuildQueue";
import { withRetry, isTransientError } from "@server/lib/dbRetry";
import { import {
checkOrgRebuildRateLimit, checkOrgRebuildRateLimit,
decrementOrgRebuildCount, decrementOrgRebuildCount,
@@ -285,10 +286,20 @@ export async function rebuildClientAssociationsFromSiteResource(
) { ) {
await incrementOrgRebuildCount(siteResource.orgId); await incrementOrgRebuildCount(siteResource.orgId);
try { try {
return await lockManager.withLock( // The whole locked rebuild is idempotent (it diffs full expected vs.
// actual state each time), so on a transient DB error it's safe to
// retry the entire thing rather than just the failed query.
return await withRetry(
() =>
lockManager.withLock(
`rebuild-client-associations:site-resource:${siteResource.siteResourceId}`, `rebuild-client-associations:site-resource:${siteResource.siteResourceId}`,
() => rebuildClientAssociationsFromSiteResourceImpl(siteResource), () =>
rebuildClientAssociationsFromSiteResourceImpl(
siteResource
),
REBUILD_ASSOCIATIONS_LOCK_TTL_MS REBUILD_ASSOCIATIONS_LOCK_TTL_MS
),
`rebuildClientAssociationsFromSiteResource:${siteResource.siteResourceId}`
); );
} catch (err: any) { } catch (err: any) {
if ( if (
@@ -304,6 +315,17 @@ export async function rebuildClientAssociationsFromSiteResource(
}); });
return { mergedAllClients: [] }; return { mergedAllClients: [] };
} }
if (isTransientError(err)) {
logger.warn(
`rebuildClientAssociations: transient DB error rebuilding site resource ${siteResource.siteResourceId} persisted after retries, queuing for deferred processing:`,
err
);
await rebuildQueue.enqueue({
type: "site-resource",
id: siteResource.siteResourceId
});
return { mergedAllClients: [] };
}
throw err; throw err;
} finally { } finally {
await decrementOrgRebuildCount(siteResource.orgId); await decrementOrgRebuildCount(siteResource.orgId);
@@ -463,6 +485,7 @@ async function rebuildClientAssociationsFromSiteResourceImpl(
await trx await trx
.insert(clientSiteResourcesAssociationsCache) .insert(clientSiteResourcesAssociationsCache)
.values(clientSiteResourcesToInsert) .values(clientSiteResourcesToInsert)
.onConflictDoNothing()
.returning(); .returning();
logger.debug( logger.debug(
`rebuildClientAssociations: [rebuildClientAssociationsFromSiteResource] siteResourceId=${siteResource.siteResourceId} inserted clientSiteResource associations` `rebuildClientAssociations: [rebuildClientAssociationsFromSiteResource] siteResourceId=${siteResource.siteResourceId} inserted clientSiteResource associations`
@@ -510,6 +533,7 @@ async function rebuildClientAssociationsFromSiteResourceImpl(
for (const site of sitesToProcess) { for (const site of sitesToProcess) {
const siteId = site.siteId; const siteId = site.siteId;
try {
logger.debug( logger.debug(
`rebuildClientAssociations: [rebuildClientAssociationsFromSiteResource] processing siteId=${siteId} for siteResourceId=${siteResource.siteResourceId}` `rebuildClientAssociations: [rebuildClientAssociationsFromSiteResource] processing siteId=${siteId} for siteResourceId=${siteResource.siteResourceId}`
); );
@@ -539,11 +563,14 @@ async function rebuildClientAssociationsFromSiteResourceImpl(
subnet: clients.subnet subnet: clients.subnet
}) })
.from(clients) .from(clients)
.where(inArray(clients.clientId, existingClientSiteIds)) .where(
inArray(clients.clientId, existingClientSiteIds)
)
: []; : [];
const otherResourceClientIds = const otherResourceClientIds =
clientsFromOtherResourcesBySite.get(siteId) ?? new Set<number>(); clientsFromOtherResourcesBySite.get(siteId) ??
new Set<number>();
logger.debug( logger.debug(
`rebuildClientAssociations: [rebuildClientAssociationsFromSiteResource] siteId=${siteId} otherResourceClientIds=[${[...otherResourceClientIds].join(", ")}] mergedAllClientIds=[${mergedAllClientIds.join(", ")}]` `rebuildClientAssociations: [rebuildClientAssociationsFromSiteResource] siteId=${siteId} otherResourceClientIds=[${[...otherResourceClientIds].join(", ")}] mergedAllClientIds=[${mergedAllClientIds.join(", ")}]`
@@ -555,10 +582,17 @@ async function rebuildClientAssociationsFromSiteResourceImpl(
? mergedAllClientIds ? mergedAllClientIds
: []; : [];
// Note: we deliberately do NOT exclude clients covered by another
// site resource here (unlike clientSitesToRemove below). Doing so
// previously caused a permanent gap: if resource A saw resource B's
// cache row and skipped adding (assuming B would maintain it), and
// B's own rebuild made the same assumption about A, the site-level
// row could end up never inserted by anyone even though both
// resources' client associations were otherwise correct.
// onConflictDoNothing makes a redundant insert harmless, so there's
// no correctness reason to skip here.
const clientSitesToAdd = expectedClientIdsForSite.filter( const clientSitesToAdd = expectedClientIdsForSite.filter(
(clientId) => (clientId) => !existingClientSiteIds.includes(clientId)
!existingClientSiteIds.includes(clientId) &&
!otherResourceClientIds.has(clientId) // dont add if already connected via another site resource
); );
const clientSitesToInsert = clientSitesToAdd.map((clientId) => ({ const clientSitesToInsert = clientSitesToAdd.map((clientId) => ({
@@ -577,6 +611,7 @@ async function rebuildClientAssociationsFromSiteResourceImpl(
await trx await trx
.insert(clientSitesAssociationsCache) .insert(clientSitesAssociationsCache)
.values(clientSitesToInsert) .values(clientSitesToInsert)
.onConflictDoNothing()
.returning(); .returning();
logger.debug( logger.debug(
`rebuildClientAssociations: [rebuildClientAssociationsFromSiteResource] siteId=${siteId} inserted clientSite associations` `rebuildClientAssociations: [rebuildClientAssociationsFromSiteResource] siteId=${siteId} inserted clientSite associations`
@@ -625,6 +660,21 @@ async function rebuildClientAssociationsFromSiteResourceImpl(
clientSitesToRemove, clientSitesToRemove,
trx trx
); );
} catch (err) {
// Don't let a failure on one site abort processing of every
// other site queued after it in this run. Since we're not
// re-throwing, the outer wrapper's retry/requeue logic never
// sees this failure, so explicitly queue this resource for a
// follow-up pass to reconcile whatever this site didn't get to.
logger.error(
`rebuildClientAssociations: [rebuildClientAssociationsFromSiteResource] siteId=${siteId} failed while processing site for siteResourceId=${siteResource.siteResourceId}, continuing with remaining sites and queuing a follow-up pass:`,
err
);
await rebuildQueue.enqueue({
type: "site-resource",
id: siteResource.siteResourceId
});
}
} }
// Handle subnet proxy target updates for the resource associations // Handle subnet proxy target updates for the resource associations
@@ -657,7 +707,7 @@ async function handleMessagesForSiteClients(
trx: Transaction | typeof db = db trx: Transaction | typeof db = db
): Promise<void> { ): Promise<void> {
if (!site.exitNodeId) { if (!site.exitNodeId) {
logger.warn( logger.debug(
`Exit node ID not on site ${site.siteId} so there is no reason to update clients because it must be offline` `Exit node ID not on site ${site.siteId} so there is no reason to update clients because it must be offline`
); );
return; return;
@@ -671,14 +721,14 @@ async function handleMessagesForSiteClients(
.limit(1); .limit(1);
if (!exitNode) { if (!exitNode) {
logger.warn( logger.debug(
`Exit node not found for site ${site.siteId} so there is no reason to update clients because it must be offline` `Exit node not found for site ${site.siteId} so there is no reason to update clients because it must be offline`
); );
return; return;
} }
if (!site.publicKey) { if (!site.publicKey) {
logger.warn( logger.debug(
`Site publicKey not set for site ${site.siteId} so cannot add peers to clients` `Site publicKey not set for site ${site.siteId} so cannot add peers to clients`
); );
return; return;
@@ -692,7 +742,7 @@ async function handleMessagesForSiteClients(
.where(eq(newts.siteId, siteId)) .where(eq(newts.siteId, siteId))
.limit(1); .limit(1);
if (!newt) { if (!newt) {
logger.warn( logger.debug(
`Newt not found for site ${siteId} so cannot add peers to clients` `Newt not found for site ${siteId} so cannot add peers to clients`
); );
return; return;
@@ -917,7 +967,7 @@ export async function updateClientSiteDestinations(
for (const site of sitesData) { for (const site of sitesData) {
if (!site.sites.subnet) { if (!site.sites.subnet) {
logger.warn(`Site ${site.sites.siteId} has no subnet, skipping`); logger.debug(`Site ${site.sites.siteId} has no subnet, skipping`);
continue; continue;
} }
@@ -1656,10 +1706,17 @@ export async function rebuildClientAssociationsFromClient(
await incrementOrgRebuildCount(client.orgId); await incrementOrgRebuildCount(client.orgId);
try { try {
const trx = primaryDb; const trx = primaryDb;
return await lockManager.withLock( // The whole locked rebuild is idempotent (it diffs full expected vs.
// actual state each time), so on a transient DB error it's safe to
// retry the entire thing rather than just the failed query.
return await withRetry(
() =>
lockManager.withLock(
`rebuild-client-associations:client:${client.clientId}`, `rebuild-client-associations:client:${client.clientId}`,
() => rebuildClientAssociationsFromClientImpl(client, trx), () => rebuildClientAssociationsFromClientImpl(client, trx),
REBUILD_ASSOCIATIONS_LOCK_TTL_MS REBUILD_ASSOCIATIONS_LOCK_TTL_MS
),
`rebuildClientAssociationsFromClient:${client.clientId}`
); );
} catch (err: any) { } catch (err: any) {
if ( if (
@@ -1675,6 +1732,17 @@ export async function rebuildClientAssociationsFromClient(
}); });
return; return;
} }
if (isTransientError(err)) {
logger.warn(
`rebuildClientAssociations: transient DB error rebuilding client ${client.clientId} persisted after retries, queuing for deferred processing:`,
err
);
await rebuildQueue.enqueue({
type: "client",
id: client.clientId
});
return;
}
throw err; throw err;
} finally { } finally {
await decrementOrgRebuildCount(client.orgId); await decrementOrgRebuildCount(client.orgId);
@@ -1826,12 +1894,15 @@ async function rebuildClientAssociationsFromClientImpl(
// Insert new associations // Insert new associations
if (resourcesToAdd.length > 0) { if (resourcesToAdd.length > 0) {
await trx.insert(clientSiteResourcesAssociationsCache).values( await trx
.insert(clientSiteResourcesAssociationsCache)
.values(
resourcesToAdd.map((siteResourceId) => ({ resourcesToAdd.map((siteResourceId) => ({
clientId: client.clientId, clientId: client.clientId,
siteResourceId siteResourceId
})) }))
); )
.onConflictDoNothing();
} }
// Remove old associations // Remove old associations
@@ -1869,12 +1940,15 @@ async function rebuildClientAssociationsFromClientImpl(
// Insert new site associations // Insert new site associations
if (sitesToAdd.length > 0) { if (sitesToAdd.length > 0) {
await trx.insert(clientSitesAssociationsCache).values( await trx
.insert(clientSitesAssociationsCache)
.values(
sitesToAdd.map((siteId) => ({ sitesToAdd.map((siteId) => ({
clientId: client.clientId, clientId: client.clientId,
siteId siteId
})) }))
); )
.onConflictDoNothing();
} }
// Remove old site associations // Remove old site associations
+27
View File
@@ -1,10 +1,14 @@
import logger from "@server/logger"; import logger from "@server/logger";
import { isTransientError } from "@server/lib/dbRetry";
export type RebuildJobType = "site-resource" | "client"; export type RebuildJobType = "site-resource" | "client";
export interface RebuildJob { export interface RebuildJob {
type: RebuildJobType; type: RebuildJobType;
id: number; id: number;
// Number of times this job has already been re-queued after a transient
// failure. Absent/0 means it has not failed yet.
attempt?: number;
} }
export interface RebuildJobHandlers { export interface RebuildJobHandlers {
@@ -24,6 +28,10 @@ export interface RebuildQueueManager {
// retried shortly after against fresh DB state. // retried shortly after against fresh DB state.
const POLL_INTERVAL_MS = 500; const POLL_INTERVAL_MS = 500;
const BATCH_SIZE = 5; const BATCH_SIZE = 5;
// A job that fails with a transient DB error gets re-queued with backoff
// instead of being dropped, up to this many times.
const MAX_JOB_ATTEMPTS = 5;
const JOB_RETRY_BASE_DELAY_MS = 1000;
function dedupeKey(job: RebuildJob): string { function dedupeKey(job: RebuildJob): string {
return `${job.type}:${job.id}`; return `${job.type}:${job.id}`;
@@ -106,12 +114,31 @@ class InMemoryRebuildQueue implements RebuildQueueManager {
`Rebuild queue: completed ${job.type}:${job.id}` `Rebuild queue: completed ${job.type}:${job.id}`
); );
} catch (err) { } catch (err) {
const attempt = (job.attempt ?? 0) + 1;
if (isTransientError(err) && attempt <= MAX_JOB_ATTEMPTS) {
const delay =
JOB_RETRY_BASE_DELAY_MS * Math.pow(2, attempt - 1);
logger.warn(
`Rebuild queue: job ${job.type}:${job.id} hit a transient error (attempt ${attempt}/${MAX_JOB_ATTEMPTS}), re-queuing in ${delay}ms:`,
err
);
setTimeout(() => {
this.enqueue({ ...job, attempt }).catch(
(enqueueErr) =>
logger.error(
`Rebuild queue: failed to re-queue ${job.type}:${job.id} after transient error:`,
enqueueErr
)
);
}, delay);
} else {
logger.error( logger.error(
`Rebuild queue: job ${job.type}:${job.id} threw an error:`, `Rebuild queue: job ${job.type}:${job.id} threw an error:`,
err err
); );
} }
} }
}
} finally { } finally {
this.processing = false; this.processing = false;
} }
+2
View File
@@ -74,6 +74,7 @@ export const RESOURCE_RULE_MATCH_TYPES = [
"IP", "IP",
"PATH", "PATH",
"COUNTRY", "COUNTRY",
"COUNTRY_IS_NOT",
"ASN", "ASN",
"REGION" "REGION"
] as const; ] as const;
@@ -96,6 +97,7 @@ export function getResourceRuleValueValidationError(
case "REGION": case "REGION":
return isValidRegionId(value) ? null : "Invalid region ID provided"; return isValidRegionId(value) ? null : "Invalid region ID provided";
case "COUNTRY": case "COUNTRY":
case "COUNTRY_IS_NOT":
return COUNTRIES.some((country) => country.code === value) return COUNTRIES.some((country) => country.code === value)
? null ? null
: "Invalid country code provided"; : "Invalid country code provided";
+1 -1
View File
@@ -14,7 +14,7 @@
import { redis } from "#private/lib/redis"; import { redis } from "#private/lib/redis";
import logger from "@server/logger"; import logger from "@server/logger";
export const ORG_REBUILD_CONCURRENCY_LIMIT = 5; export const ORG_REBUILD_CONCURRENCY_LIMIT = 10;
// Safety-net TTL: slightly longer than the rebuild lock TTL (120 s). If a // Safety-net TTL: slightly longer than the rebuild lock TTL (120 s). If a
// server process dies while holding a rebuild, this ensures the counter key // server process dies while holding a rebuild, this ensures the counter key
+32
View File
@@ -14,12 +14,16 @@
import { redis } from "#private/lib/redis"; import { redis } from "#private/lib/redis";
import { lockManager } from "#private/lib/lock"; import { lockManager } from "#private/lib/lock";
import logger from "@server/logger"; import logger from "@server/logger";
import { isTransientError } from "@server/lib/dbRetry";
export type RebuildJobType = "site-resource" | "client"; export type RebuildJobType = "site-resource" | "client";
export interface RebuildJob { export interface RebuildJob {
type: RebuildJobType; type: RebuildJobType;
id: number; id: number;
// Number of times this job has already been re-queued after a transient
// failure. Absent/0 means it has not failed yet.
attempt?: number;
} }
export interface RebuildJobHandlers { export interface RebuildJobHandlers {
@@ -43,6 +47,11 @@ const PROCESSOR_LOCK_TTL_MS = 120000 * BATCH_SIZE + 30000; // ~630 s
const POLL_INTERVAL_MS = 500; const POLL_INTERVAL_MS = 500;
// A job that fails with a transient DB error gets re-queued with backoff
// instead of being dropped, up to this many times.
const MAX_JOB_ATTEMPTS = 5;
const JOB_RETRY_BASE_DELAY_MS = 1000;
class RedisRebuildQueue { class RedisRebuildQueue {
private processingStarted = false; private processingStarted = false;
@@ -180,12 +189,35 @@ class RedisRebuildQueue {
`Rebuild queue: completed ${job.type}:${job.id}` `Rebuild queue: completed ${job.type}:${job.id}`
); );
} catch (err) { } catch (err) {
const attempt = (job.attempt ?? 0) + 1;
if (
isTransientError(err) &&
attempt <= MAX_JOB_ATTEMPTS
) {
const delay =
JOB_RETRY_BASE_DELAY_MS *
Math.pow(2, attempt - 1);
logger.warn(
`Rebuild queue: job ${job.type}:${job.id} hit a transient error (attempt ${attempt}/${MAX_JOB_ATTEMPTS}), re-queuing in ${delay}ms:`,
err
);
setTimeout(() => {
this.enqueue({ ...job, attempt }).catch(
(enqueueErr) =>
logger.error(
`Rebuild queue: failed to re-queue ${job.type}:${job.id} after transient error:`,
enqueueErr
)
);
}, delay);
} else {
logger.error( logger.error(
`Rebuild queue: job ${job.type}:${job.id} threw an error:`, `Rebuild queue: job ${job.type}:${job.id} threw an error:`,
err err
); );
} }
} }
}
}, },
PROCESSOR_LOCK_TTL_MS PROCESSOR_LOCK_TTL_MS
); );
+41 -18
View File
@@ -894,6 +894,19 @@ class RegionalRedisManager {
return opts; return opts;
} }
// The regional Redis StatefulSet's "redis" service pins to pod redis-0
// (primary). The replica (redis-1) is only reachable through the
// per-pod headless service: <svc>.<namespace>.svc.cluster.local ->
// redis-1.redis-headless.<namespace>.svc.cluster.local. Returns null
// if the configured host doesn't match that pattern (e.g. local dev),
// in which case callers should fall back to the primary for reads.
private getReplicaHost(primaryHost: string): string | null {
const match = primaryHost.match(/^redis\.([^.]+)\.svc\.cluster\.local$/);
if (!match) return null;
const namespace = match[1];
return `redis-1.redis-headless.${namespace}.svc.cluster.local`;
}
private initializeClients(): void { private initializeClients(): void {
const cfg = this.getConfig(); const cfg = this.getConfig();
const baseOpts = { const baseOpts = {
@@ -907,35 +920,42 @@ class RegionalRedisManager {
try { try {
this.writeClient = new Redis(baseOpts); this.writeClient = new Redis(baseOpts);
// redis-1 (replica) handles reads; fall back to primary if not resolvable
this.readClient = new Redis({
...baseOpts,
host: cfg.host!.replace(/^(.*?)(\.\S+)$/, (_, h, rest) => {
// Derive replica hostname from the headless service pattern:
// redis.redis.svc.cluster.local -> redis-1.redis-headless.redis.svc.cluster.local
// If it doesn't look like a k8s service, just use the same host
return h + rest;
})
});
// For simplicity use same host for both; callers can always read from primary const replicaHost = this.getReplicaHost(cfg.host!);
// The real replica routing is handled by the StatefulSet headless service this.readClient = replicaHost
this.readClient = this.writeClient; ? new Redis({ ...baseOpts, host: replicaHost })
: this.writeClient;
this.writeClient.on("ready", () => { this.writeClient.on("ready", () => {
logger.info("Regional Redis client ready"); logger.info("Regional Redis write client ready");
this.isHealthy = true; this.isHealthy = true;
}); });
this.writeClient.on("error", (err) => { this.writeClient.on("error", (err) => {
logger.error("Regional Redis client error:", err); logger.error("Regional Redis write client error:", err);
this.isHealthy = false; this.isHealthy = false;
}); });
this.writeClient.on("reconnecting", () => { this.writeClient.on("reconnecting", () => {
logger.info("Regional Redis client reconnecting..."); logger.info("Regional Redis write client reconnecting...");
this.isHealthy = false; this.isHealthy = false;
}); });
logger.info("Regional Redis client initialized"); if (this.readClient !== this.writeClient) {
this.readClient.on("ready", () => {
logger.info("Regional Redis read client ready");
});
this.readClient.on("error", (err) => {
logger.error("Regional Redis read client error:", err);
});
this.readClient.on("reconnecting", () => {
logger.info("Regional Redis read client reconnecting...");
});
}
logger.info(
replicaHost
? `Regional Redis client initialized (reads routed to replica ${replicaHost})`
: "Regional Redis client initialized (no replica resolvable, reads routed to primary)"
);
} catch (error) { } catch (error) {
logger.error("Failed to initialize regional Redis client:", error); logger.error("Failed to initialize regional Redis client:", error);
this.isEnabled = false; this.isEnabled = false;
@@ -1041,11 +1061,14 @@ class RegionalRedisManager {
public async disconnect(): Promise<void> { public async disconnect(): Promise<void> {
try { try {
if (this.readClient && this.readClient !== this.writeClient) {
await this.readClient.quit();
}
this.readClient = null;
if (this.writeClient) { if (this.writeClient) {
await this.writeClient.quit(); await this.writeClient.quit();
this.writeClient = null; this.writeClient = null;
} }
this.readClient = null;
logger.info("Regional Redis client disconnected"); logger.info("Regional Redis client disconnected");
} catch (error) { } catch (error) {
logger.error("Error disconnecting regional Redis client:", error); logger.error("Error disconnecting regional Redis client:", error);
@@ -17,6 +17,7 @@ import HttpCode from "@server/types/HttpCode";
import { build } from "@server/build"; import { build } from "@server/build";
import { getOrgTierData } from "#private/lib/billing"; import { getOrgTierData } from "#private/lib/billing";
import { Tier } from "@server/types/Tiers"; import { Tier } from "@server/types/Tiers";
import logger from "@server/logger";
export function verifyValidSubscription(tiers: Tier[]) { export function verifyValidSubscription(tiers: Tier[]) {
return async function ( return async function (
@@ -30,9 +31,9 @@ export function verifyValidSubscription(tiers: Tier[]) {
} }
const orgId = const orgId =
req.params.orgId || req.params?.orgId ||
req.body.orgId || req.body?.orgId ||
req.query.orgId || req.query?.orgId ||
req.userOrgId; req.userOrgId;
if (!orgId) { if (!orgId) {
@@ -65,6 +66,7 @@ export function verifyValidSubscription(tiers: Tier[]) {
return next(); return next();
} catch (e) { } catch (e) {
logger.error(e);
return next( return next(
createHttpError( createHttpError(
HttpCode.INTERNAL_SERVER_ERROR, HttpCode.INTERNAL_SERVER_ERROR,
-54
View File
@@ -31,7 +31,6 @@ import * as siteProvisioning from "#private/routers/siteProvisioning";
import * as eventStreamingDestination from "#private/routers/eventStreamingDestination"; import * as eventStreamingDestination from "#private/routers/eventStreamingDestination";
import * as alertRule from "#private/routers/alertRule"; import * as alertRule from "#private/routers/alertRule";
import * as healthChecks from "#private/routers/healthChecks"; import * as healthChecks from "#private/routers/healthChecks";
import * as labels from "#private/routers/labels";
import * as client from "@server/routers/client"; import * as client from "@server/routers/client";
import * as resource from "#private/routers/resource"; import * as resource from "#private/routers/resource";
import * as policy from "#private/routers/policy"; import * as policy from "#private/routers/policy";
@@ -810,59 +809,6 @@ authenticated.get(
alertRule.getAlertRule alertRule.getAlertRule
); );
authenticated.get(
"/org/:orgId/labels",
verifyValidLicense,
verifyOrgAccess,
verifyValidSubscription(tierMatrix.labels),
verifyUserHasAction(ActionsEnum.listOrgLabels),
labels.listOrgLabels
);
authenticated.post(
"/org/:orgId/labels",
verifyValidLicense,
verifyOrgAccess,
verifyValidSubscription(tierMatrix.labels),
verifyUserHasAction(ActionsEnum.createOrgLabel),
labels.createOrgLabel
);
authenticated.patch(
"/org/:orgId/label/:labelId",
verifyValidLicense,
verifyOrgAccess,
verifyValidSubscription(tierMatrix.labels),
verifyUserHasAction(ActionsEnum.updateOrgLabel),
labels.updateOrgLabel
);
authenticated.delete(
"/org/:orgId/label/:labelId",
verifyValidLicense,
verifyOrgAccess,
verifyUserHasAction(ActionsEnum.deleteOrgLabel),
labels.deleteOrgLabel
);
authenticated.put(
"/org/:orgId/label/:labelId/attach",
verifyValidLicense,
verifyOrgAccess,
verifyValidSubscription(tierMatrix.labels),
verifyUserHasAction(ActionsEnum.attachLabelToItem),
labels.attachLabelToItem
);
authenticated.put(
"/org/:orgId/label/:labelId/detach",
verifyValidLicense,
verifyOrgAccess,
verifyValidSubscription(tierMatrix.labels),
verifyUserHasAction(ActionsEnum.detachLabelFromItem),
labels.detachLabelFromItem
);
authenticated.get( authenticated.get(
"/org/:orgId/health-checks", "/org/:orgId/health-checks",
verifyValidLicense, verifyValidLicense,
@@ -29,7 +29,8 @@ export async function createExitNode(
.where(eq(exitNodes.publicKey, publicKey)); .where(eq(exitNodes.publicKey, publicKey));
let exitNode: ExitNode; let exitNode: ExitNode;
if (!exitNodeQuery) { if (!exitNodeQuery) {
const address = await getNextAvailableSubnet(); const { value: address, release } = await getNextAvailableSubnet();
try {
// TODO: eventually we will want to get the next available port so that we can multiple exit nodes // TODO: eventually we will want to get the next available port so that we can multiple exit nodes
// const listenPort = await getNextAvailablePort(); // const listenPort = await getNextAvailablePort();
const listenPort = config.getRawConfig().gerbil.start_port; const listenPort = config.getRawConfig().gerbil.start_port;
@@ -60,6 +61,9 @@ export async function createExitNode(
logger.info( logger.info(
`Created new exit node ${exitNode.name} with address ${exitNode.address} and port ${exitNode.listenPort}` `Created new exit node ${exitNode.name} with address ${exitNode.address} and port ${exitNode.listenPort}`
); );
} finally {
await release();
}
} else { } else {
// update the reachable at // update the reachable at
[exitNode] = await db [exitNode] = await db
+1
View File
@@ -1695,6 +1695,7 @@ hybridRouter.get(
) { ) {
for (const rule of rules) { for (const rule of rules) {
if (rule.match == "COUNTRY") { if (rule.match == "COUNTRY") {
// @ts-expect-error this is for backward compatibility
rule.match = "GEOIP"; rule.match = "GEOIP";
} }
} }
-19
View File
@@ -1,19 +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.
*/
export * from "./listOrgLabels";
export * from "./createOrgLabel";
export * from "./updateOrgLabel";
export * from "./attachLabelToItem";
export * from "./detachLabelFromItem";
export * from "./deleteOrgLabel";
@@ -114,8 +114,6 @@ export async function createRemoteExitNode(
} }
const secretHash = await hashPassword(secret); const secretHash = await hashPassword(secret);
// const address = await getNextAvailableSubnet();
const address = "100.89.140.1/24"; // FOR NOW LETS HARDCODE THESE ADDRESSES
const [existingRemoteExitNode] = await db const [existingRemoteExitNode] = await db
.select() .select()
@@ -191,13 +189,27 @@ export async function createRemoteExitNode(
); );
} }
// If this remote exit node isn't already backing an exit node in
// another org, we're about to create a brand new one. Reserve a
// subnet for it up front so the allocation lock is held across the
// whole insert - this guarantees exit node subnets never overlap,
// even under concurrent creation, which matters for HA setups.
let releaseSubnetLock: (() => Promise<void>) | null = null;
let newExitNodeAddress: string | null = null;
if (!existingExitNode) {
const { value, release } = await getNextAvailableSubnet();
newExitNodeAddress = value;
releaseSubnetLock = release;
}
try {
await db.transaction(async (trx) => { await db.transaction(async (trx) => {
if (!existingExitNode) { if (!existingExitNode) {
const [res] = await trx const [res] = await trx
.insert(exitNodes) .insert(exitNodes)
.values({ .values({
name: remoteExitNodeId, name: remoteExitNodeId,
address, address: newExitNodeAddress!,
endpoint: "", endpoint: "",
publicKey: "", publicKey: "",
listenPort: 0, listenPort: 0,
@@ -274,6 +286,9 @@ export async function createRemoteExitNode(
} }
} }
}); });
} finally {
await releaseSubnetLock?.();
}
const token = generateSessionToken(); const token = generateSessionToken();
await createRemoteExitNodeSession(token, remoteExitNodeId); await createRemoteExitNodeSession(token, remoteExitNodeId);
+8 -2
View File
@@ -1054,7 +1054,10 @@ async function checkRules(
isPathAllowed(rule.value, path) isPathAllowed(rule.value, path)
) { ) {
return rule.action as any; return rule.action as any;
} else if (clientIp && rule.match == "COUNTRY") { } else if (
clientIp &&
(rule.match === "COUNTRY" || rule.match === "COUNTRY_IS_NOT")
) {
// COUNTRY=ALL should not affect local/private/CGNAT addresses. // COUNTRY=ALL should not affect local/private/CGNAT addresses.
if ( if (
rule.value.toUpperCase() === "ALL" && rule.value.toUpperCase() === "ALL" &&
@@ -1063,7 +1066,10 @@ async function checkRules(
continue; continue;
} }
if (await isIpInGeoIP(ipCC, rule.value)) { const inCountry = await isIpInGeoIP(ipCC, rule.value);
const matched = rule.match === "COUNTRY" ? inCountry : !inCountry;
if (matched) {
return rule.action as any; return rule.action as any;
} }
} else if (clientIp && rule.match == "ASN") { } else if (clientIp && rule.match == "ASN") {
@@ -99,7 +99,7 @@ export async function applyJSONBlueprint(
source: "API" source: "API"
}); });
} catch (error) { } catch (error) {
logger.error(`Failed to update database from config: ${error}`); logger.debug(`Failed to update database from config: ${error}`);
return next( return next(
createHttpError( createHttpError(
HttpCode.BAD_REQUEST, HttpCode.BAD_REQUEST,
+4 -14
View File
@@ -304,11 +304,6 @@ export async function listClients(
(client) => client.clientId (client) => client.clientId
); );
const isLabelFeatureEnabled = await isLicensedOrSubscribed(
orgId,
tierMatrix.labels
);
// Get client count with filter // Get client count with filter
const conditions = [ const conditions = [
and( and(
@@ -341,7 +336,7 @@ export async function listClients(
conditions.push(or(...filterAggregates)); conditions.push(or(...filterAggregates));
} }
if (isLabelFeatureEnabled && labelFilter && labelFilter.length > 0) { if (labelFilter && labelFilter.length > 0) {
conditions.push( conditions.push(
inArray( inArray(
clients.clientId, clients.clientId,
@@ -361,11 +356,7 @@ export async function listClients(
const q = "%" + query.toLowerCase() + "%"; const q = "%" + query.toLowerCase() + "%";
const queryList = [ const queryList = [
like(sql`LOWER(${clients.name})`, q), like(sql`LOWER(${clients.name})`, q),
like(sql`LOWER(${clients.niceId})`, q) like(sql`LOWER(${clients.niceId})`, q),
];
if (isLabelFeatureEnabled) {
queryList.push(
inArray( inArray(
clients.clientId, clients.clientId,
db db
@@ -377,8 +368,7 @@ export async function listClients(
) )
.where(like(sql`LOWER(${labels.name})`, q)) .where(like(sql`LOWER(${labels.name})`, q))
) )
); ];
}
conditions.push(or(...queryList)); conditions.push(or(...queryList));
} }
@@ -414,7 +404,7 @@ export async function listClients(
clientId: number; clientId: number;
}> = []; }> = [];
if (isLabelFeatureEnabled && clientIds.length > 0) { if (clientIds.length > 0) {
labelsForClients = await db labelsForClients = await db
.select({ .select({
labelId: labels.labelId, labelId: labels.labelId,
+75
View File
@@ -54,6 +54,7 @@ import { build } from "@server/build";
import { createStore } from "#dynamic/lib/rateLimitStore"; import { createStore } from "#dynamic/lib/rateLimitStore";
import { logActionAudit } from "#dynamic/middlewares"; import { logActionAudit } from "#dynamic/middlewares";
import { checkRoundTripMessage } from "./ws"; import { checkRoundTripMessage } from "./ws";
import * as labels from "@server/routers/labels";
// Root routes // Root routes
export const unauthenticated = Router(); export const unauthenticated = Router();
@@ -327,6 +328,14 @@ authenticated.get(
siteResource.listAllSiteResourcesByOrg siteResource.listAllSiteResourcesByOrg
); );
authenticated.get(
"/org/:orgId/site-resource/:siteResourceId",
verifyOrgAccess,
verifySiteResourceAccess,
verifyUserHasAction(ActionsEnum.getSiteResource),
siteResource.getSiteResource
);
authenticated.get( authenticated.get(
"/site-resource/:siteResourceId", "/site-resource/:siteResourceId",
verifySiteResourceAccess, verifySiteResourceAccess,
@@ -470,6 +479,12 @@ authenticated.get(
launcher.listLauncherGroups launcher.listLauncherGroups
); );
authenticated.get(
"/org/:orgId/launcher/scale",
verifyOrgAccess,
launcher.listLauncherScale
);
authenticated.get( authenticated.get(
"/org/:orgId/launcher/resources", "/org/:orgId/launcher/resources",
verifyOrgAccess, verifyOrgAccess,
@@ -494,6 +509,12 @@ authenticated.get(
launcher.listLauncherViews launcher.listLauncherViews
); );
authenticated.post(
"/org/:orgId/launcher/invalidate-cache",
verifyOrgAccess,
launcher.invalidateLauncherCache
);
authenticated.post( authenticated.post(
"/org/:orgId/launcher/views", "/org/:orgId/launcher/views",
verifyOrgAccess, verifyOrgAccess,
@@ -506,6 +527,18 @@ authenticated.put(
launcher.updateLauncherView launcher.updateLauncherView
); );
authenticated.put(
"/org/:orgId/launcher/default-view",
verifyOrgAccess,
launcher.upsertLauncherDefaultView
);
authenticated.delete(
"/org/:orgId/launcher/default-view",
verifyOrgAccess,
launcher.deleteLauncherDefaultView
);
authenticated.delete( authenticated.delete(
"/org/:orgId/launcher/views/:viewId", "/org/:orgId/launcher/views/:viewId",
verifyOrgAccess, verifyOrgAccess,
@@ -1303,6 +1336,48 @@ authenticated.get(
authenticated.get("/ws/round-trip-message/:messageId", checkRoundTripMessage); authenticated.get("/ws/round-trip-message/:messageId", checkRoundTripMessage);
authenticated.get(
"/org/:orgId/labels",
verifyOrgAccess,
verifyUserHasAction(ActionsEnum.listOrgLabels),
labels.listOrgLabels
);
authenticated.post(
"/org/:orgId/labels",
verifyOrgAccess,
verifyUserHasAction(ActionsEnum.createOrgLabel),
labels.createOrgLabel
);
authenticated.patch(
"/org/:orgId/label/:labelId",
verifyOrgAccess,
verifyUserHasAction(ActionsEnum.updateOrgLabel),
labels.updateOrgLabel
);
authenticated.delete(
"/org/:orgId/label/:labelId",
verifyOrgAccess,
verifyUserHasAction(ActionsEnum.deleteOrgLabel),
labels.deleteOrgLabel
);
authenticated.put(
"/org/:orgId/label/:labelId/attach",
verifyOrgAccess,
verifyUserHasAction(ActionsEnum.attachLabelToItem),
labels.attachLabelToItem
);
authenticated.put(
"/org/:orgId/label/:labelId/detach",
verifyOrgAccess,
verifyUserHasAction(ActionsEnum.detachLabelFromItem),
labels.detachLabelFromItem
);
// Auth routes // Auth routes
export const authRouter = Router(); export const authRouter = Router();
unauthenticated.use("/auth", authRouter); unauthenticated.use("/auth", authRouter);
+5 -1
View File
@@ -13,7 +13,8 @@ export async function createExitNode(
const [exitNodeQuery] = await db.select().from(exitNodes).limit(1); const [exitNodeQuery] = await db.select().from(exitNodes).limit(1);
let exitNode: ExitNode; let exitNode: ExitNode;
if (!exitNodeQuery) { if (!exitNodeQuery) {
const address = await getNextAvailableSubnet(); const { value: address, release } = await getNextAvailableSubnet();
try {
// TODO: eventually we will want to get the next available port so that we can multiple exit nodes // TODO: eventually we will want to get the next available port so that we can multiple exit nodes
// const listenPort = await getNextAvailablePort(); // const listenPort = await getNextAvailablePort();
const listenPort = config.getRawConfig().gerbil.start_port; const listenPort = config.getRawConfig().gerbil.start_port;
@@ -44,6 +45,9 @@ export async function createExitNode(
logger.info( logger.info(
`Created new exit node ${exitNode.name} with address ${exitNode.address} and port ${exitNode.listenPort}` `Created new exit node ${exitNode.name} with address ${exitNode.address} and port ${exitNode.listenPort}`
); );
} finally {
await release();
}
} else { } else {
// update the existing exit node // update the existing exit node
[exitNode] = await db [exitNode] = await db
-6
View File
@@ -895,12 +895,6 @@ authenticated.delete(
user.removeUserOrg user.removeUserOrg
); );
// authenticated.put(
// "/newt",
// verifyApiKeyHasAction(ActionsEnum.createNewt),
// newt.createNewt
// );
authenticated.get( authenticated.get(
`/org/:orgId/api-keys`, `/org/:orgId/api-keys`,
verifyApiKeyIsRoot, verifyApiKeyIsRoot,
@@ -1,16 +1,3 @@
/*
* 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 { import {
clients, clients,
clientLabels, clientLabels,
@@ -1,15 +1,3 @@
/*
* 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 { import {
db, db,
labels, labels,
@@ -1,15 +1,3 @@
/*
* 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 { db, labels } from "@server/db"; import { db, labels } from "@server/db";
import response from "@server/lib/response"; import response from "@server/lib/response";
import logger from "@server/logger"; import logger from "@server/logger";
@@ -1,16 +1,3 @@
/*
* 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 { import {
clients, clients,
clientLabels, clientLabels,
+6
View File
@@ -0,0 +1,6 @@
export * from "./listOrgLabels";
export * from "./createOrgLabel";
export * from "./updateOrgLabel";
export * from "./attachLabelToItem";
export * from "./detachLabelFromItem";
export * from "./deleteOrgLabel";
@@ -1,16 +1,3 @@
/*
* 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 { db, labels } from "@server/db"; import { db, labels } from "@server/db";
import response from "@server/lib/response"; import response from "@server/lib/response";
import logger from "@server/logger"; import logger from "@server/logger";
@@ -1,16 +1,3 @@
/*
* 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 { db, labels } from "@server/db"; import { db, labels } from "@server/db";
import response from "@server/lib/response"; import response from "@server/lib/response";
import logger from "@server/logger"; import logger from "@server/logger";
@@ -79,7 +79,8 @@ export async function createLauncherView(
), ),
createdAt: created.createdAt, createdAt: created.createdAt,
updatedAt: created.updatedAt, updatedAt: created.updatedAt,
isOrgWide: created.userId == null isOrgWide: created.userId == null,
isDefault: created.isDefault
}, },
success: true, success: true,
error: false, error: false,
@@ -0,0 +1,104 @@
import { response } from "@server/lib/response";
import HttpCode from "@server/types/HttpCode";
import { NextFunction, Request, Response } from "express";
import createHttpError from "http-errors";
import { fromZodError } from "zod-validation-error";
import { z } from "zod";
import { ActionsEnum, checkUserActionPermission } from "@server/auth/actions";
import {
deleteAllDefaultViewOverrides,
deleteDefaultViewOverride
} from "./launcherDefaultView";
const deleteLauncherDefaultViewBodySchema = z.strictObject({
orgWide: z.boolean().optional().default(false),
all: z.boolean().optional().default(false)
});
export async function deleteLauncherDefaultView(
req: Request,
res: Response,
next: NextFunction
): Promise<any> {
try {
const orgId = req.userOrgId;
const userId = req.user!.userId;
if (!orgId) {
return next(
createHttpError(HttpCode.BAD_REQUEST, "Invalid organization ID")
);
}
const parsed = deleteLauncherDefaultViewBodySchema.safeParse(req.body);
if (!parsed.success) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
fromZodError(parsed.error)
)
);
}
if (parsed.data.all) {
const canManageOrgWide = await checkUserActionPermission(
ActionsEnum.createOrgWideLauncherView,
req
);
if (!canManageOrgWide) {
return next(
createHttpError(
HttpCode.FORBIDDEN,
"User does not have permission perform this action"
)
);
}
await deleteAllDefaultViewOverrides(orgId, userId);
} else if (parsed.data.orgWide) {
const canManageOrgWide = await checkUserActionPermission(
ActionsEnum.createOrgWideLauncherView,
req
);
if (!canManageOrgWide) {
return next(
createHttpError(
HttpCode.FORBIDDEN,
"User does not have permission perform this action"
)
);
}
await deleteDefaultViewOverride({
orgId,
userId,
orgWide: true
});
} else {
await deleteDefaultViewOverride({
orgId,
userId,
orgWide: false
});
}
return response(res, {
data: null,
success: true,
error: false,
message: "Launcher default view reset successfully",
status: HttpCode.OK
});
} catch (error) {
if (createHttpError.isHttpError(error)) {
return next(error);
}
console.error("Error resetting launcher default view:", error);
return next(
createHttpError(
HttpCode.INTERNAL_SERVER_ERROR,
"Internal server error"
)
);
}
}
@@ -46,6 +46,15 @@ export async function deleteLauncherView(
); );
} }
if (existing.isDefault) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
"The default view cannot be deleted from here"
)
);
}
const isPersonalView = existing.userId === userId; const isPersonalView = existing.userId === userId;
const isOrgWideView = existing.userId == null; const isOrgWideView = existing.userId == null;
const canManageOrgWide = await checkUserActionPermission( const canManageOrgWide = await checkUserActionPermission(
+4
View File
@@ -1,5 +1,6 @@
export * from "./types"; export * from "./types";
export { listLauncherGroups } from "./listLauncherGroups"; export { listLauncherGroups } from "./listLauncherGroups";
export { listLauncherScale } from "./listLauncherScale";
export { listLauncherResources } from "./listLauncherResources"; export { listLauncherResources } from "./listLauncherResources";
export { listLauncherSites } from "./listLauncherSites"; export { listLauncherSites } from "./listLauncherSites";
export { listLauncherLabels } from "./listLauncherLabels"; export { listLauncherLabels } from "./listLauncherLabels";
@@ -7,3 +8,6 @@ export { listLauncherViews } from "./listLauncherViews";
export { createLauncherView } from "./createLauncherView"; export { createLauncherView } from "./createLauncherView";
export { updateLauncherView } from "./updateLauncherView"; export { updateLauncherView } from "./updateLauncherView";
export { deleteLauncherView } from "./deleteLauncherView"; export { deleteLauncherView } from "./deleteLauncherView";
export { upsertLauncherDefaultView } from "./upsertLauncherDefaultView";
export { deleteLauncherDefaultView } from "./deleteLauncherDefaultView";
export { invalidateLauncherCache } from "./invalidateLauncherCache";
@@ -0,0 +1,65 @@
import { regionalCache as cache } from "#dynamic/lib/cache";
import { response } from "@server/lib/response";
import HttpCode from "@server/types/HttpCode";
import { NextFunction, Request, Response } from "express";
import createHttpError from "http-errors";
async function invalidateLauncherCacheForUser(
orgId: string,
userId: string
): Promise<void> {
const prefixes = [
`launcherAccessibleIds:${orgId}:${userId}:`,
`launcher:groups:${orgId}:${userId}:`,
`launcher:results:${orgId}:${userId}:`,
`launcher:scale:counts:${orgId}:${userId}:`
];
const keys = (
await Promise.all(
prefixes.map((prefix) => cache.keysWithPrefix(prefix))
)
).flat();
if (keys.length > 0) {
await cache.del(keys);
}
}
export async function invalidateLauncherCache(
req: Request,
res: Response,
next: NextFunction
): Promise<any> {
try {
const orgId = req.userOrgId;
const userId = req.user!.userId;
if (!orgId) {
return next(
createHttpError(HttpCode.BAD_REQUEST, "Invalid organization ID")
);
}
await invalidateLauncherCacheForUser(orgId, userId);
return response(res, {
data: null,
success: true,
error: false,
message: "Launcher cache invalidated successfully",
status: HttpCode.OK
});
} catch (error) {
if (createHttpError.isHttpError(error)) {
return next(error);
}
console.error("Error invalidating launcher cache:", error);
return next(
createHttpError(
HttpCode.INTERNAL_SERVER_ERROR,
"Internal server error"
)
);
}
}
@@ -0,0 +1,137 @@
import { db, launcherViews } from "@server/db";
import { and, eq, isNull } from "drizzle-orm";
import moment from "moment";
import {
launcherViewConfigSchema,
type LauncherDefaultViewOverrides,
type LauncherViewConfig,
type LauncherViewRecord
} from "./types";
export function mapViewRow(
row: typeof launcherViews.$inferSelect
): LauncherViewRecord {
return {
viewId: row.viewId,
orgId: row.orgId,
userId: row.userId,
name: row.name,
config: launcherViewConfigSchema.parse(JSON.parse(row.config)),
createdAt: row.createdAt,
updatedAt: row.updatedAt,
isOrgWide: row.userId == null,
isDefault: row.isDefault
};
}
export function extractDefaultViewOverrides(
rows: Array<typeof launcherViews.$inferSelect>
): LauncherDefaultViewOverrides {
const overrideRows = rows.filter((row) => row.isDefault);
const personalRow = overrideRows.find((row) => row.userId !== null);
const orgWideRow = overrideRows.find((row) => row.userId === null);
return {
personal: personalRow ? mapViewRow(personalRow) : null,
orgWide: orgWideRow ? mapViewRow(orgWideRow) : null
};
}
export function listVisibleLauncherViews(
rows: Array<typeof launcherViews.$inferSelect>
): LauncherViewRecord[] {
return rows.filter((row) => !row.isDefault).map(mapViewRow);
}
export async function findDefaultViewOverride(
orgId: string,
orgWide: boolean,
userId: string
) {
const [existing] = await db
.select()
.from(launcherViews)
.where(
and(
eq(launcherViews.orgId, orgId),
eq(launcherViews.isDefault, true),
orgWide
? isNull(launcherViews.userId)
: eq(launcherViews.userId, userId)
)
)
.limit(1);
return existing ?? null;
}
export async function upsertDefaultViewOverride({
orgId,
userId,
orgWide,
config
}: {
orgId: string;
userId: string;
orgWide: boolean;
config: LauncherViewConfig;
}) {
const now = moment().toISOString();
const existing = await findDefaultViewOverride(orgId, orgWide, userId);
if (existing) {
const [updated] = await db
.update(launcherViews)
.set({
config: JSON.stringify(config),
updatedAt: now
})
.where(eq(launcherViews.viewId, existing.viewId))
.returning();
return mapViewRow(updated);
}
const [created] = await db
.insert(launcherViews)
.values({
orgId,
userId: orgWide ? null : userId,
name: "",
isDefault: true,
config: JSON.stringify(config),
createdAt: now,
updatedAt: now
})
.returning();
return mapViewRow(created);
}
export async function deleteDefaultViewOverride({
orgId,
userId,
orgWide
}: {
orgId: string;
userId: string;
orgWide: boolean;
}) {
const existing = await findDefaultViewOverride(orgId, orgWide, userId);
if (!existing) {
return;
}
await db
.delete(launcherViews)
.where(eq(launcherViews.viewId, existing.viewId));
}
export async function deleteAllDefaultViewOverrides(
orgId: string,
userId: string
) {
await deleteDefaultViewOverride({ orgId, userId, orgWide: false });
await deleteDefaultViewOverride({ orgId, userId, orgWide: true });
}
+260 -89
View File
@@ -1,3 +1,4 @@
import { createHash } from "node:crypto";
import { db } from "@server/db"; import { db } from "@server/db";
import { import {
exitNodes, exitNodes,
@@ -31,13 +32,15 @@ import {
isNull, isNull,
like, like,
or, or,
sql sql,
type SQL
} from "drizzle-orm"; } from "drizzle-orm";
import { import {
formatPublicResourceAccess, formatPublicResourceAccess,
formatSiteResourceAccess formatSiteResourceAccess
} from "./formatLauncherAccess"; } from "./formatLauncherAccess";
import { import {
LAUNCHER_FLAT_GROUP_KEY,
LAUNCHER_NO_SITE_GROUP_KEY, LAUNCHER_NO_SITE_GROUP_KEY,
LAUNCHER_UNLABELED_GROUP_KEY, LAUNCHER_UNLABELED_GROUP_KEY,
type LauncherFilterListQuery, type LauncherFilterListQuery,
@@ -59,6 +62,68 @@ export type AccessibleIds = {
}; };
const LAUNCHER_ACCESSIBLE_IDS_TTL_SEC = 60; const LAUNCHER_ACCESSIBLE_IDS_TTL_SEC = 60;
const LAUNCHER_RESOURCES_RESULT_TTL_SEC = 60;
const LAUNCHER_GROUPS_RESULT_TTL_SEC = 60;
type LauncherResourcesCacheEntry = {
items: LauncherResource[];
total: number;
};
type LauncherGroupsCacheEntry = {
groups: LauncherGroup[];
total: number;
};
function launcherListQueryHash(
userRoleIds: number[],
query: LauncherListQuery,
extra?: Record<string, string>
) {
const payload = JSON.stringify({
roles: [...userRoleIds].sort((a, b) => a - b),
query: query.query,
groupBy: query.groupBy,
siteIds: query.siteIds ?? "",
labelIds: query.labelIds ?? "",
sort_by: query.sort_by,
order: query.order,
...extra
});
return createHash("sha256").update(payload).digest("hex").slice(0, 16);
}
function launcherResourcesQueryHash(
userRoleIds: number[],
query: LauncherListQuery & { groupKey: string }
) {
return launcherListQueryHash(userRoleIds, query, {
groupKey: query.groupKey
});
}
function launcherGroupsQueryHash(
userRoleIds: number[],
query: LauncherListQuery
) {
return launcherListQueryHash(userRoleIds, query);
}
function launcherResourcesCacheKey(
orgId: string,
userId: string,
queryHash: string
) {
return `launcher:results:${orgId}:${userId}:${queryHash}`;
}
function launcherGroupsCacheKey(
orgId: string,
userId: string,
queryHash: string
) {
return `launcher:groups:${orgId}:${userId}:${queryHash}`;
}
function launcherAccessibleIdsCacheKey( function launcherAccessibleIdsCacheKey(
orgId: string, orgId: string,
@@ -194,10 +259,22 @@ function searchPattern(query: string) {
return `%${query.trim()}%`; return `%${query.trim()}%`;
} }
function buildSearchConditionForPublic( function combineOrConditions(
query: string, ...conditions: (SQL | undefined)[]
labelsFeatureEnabled: boolean ): SQL | undefined {
) { const parts = conditions.filter(
(condition): condition is SQL => !!condition
);
if (parts.length === 0) {
return undefined;
}
if (parts.length === 1) {
return parts[0];
}
return or(...parts);
}
function buildSearchConditionForPublic(query: string) {
if (!query.trim()) { if (!query.trim()) {
return undefined; return undefined;
} }
@@ -205,32 +282,31 @@ function buildSearchConditionForPublic(
const queryList = [ const queryList = [
like(sql`LOWER(${resources.name})`, pattern), like(sql`LOWER(${resources.name})`, pattern),
like(sql`LOWER(${resources.fullDomain})`, pattern), like(sql`LOWER(${resources.fullDomain})`, pattern),
like(sql`LOWER(cast(${resources.proxyPort} as text))`, pattern) like(sql`LOWER(cast(${resources.proxyPort} as text))`, pattern),
]; inArray(
resources.resourceId,
if (labelsFeatureEnabled) { db
queryList.push( .select({ id: resources.resourceId })
.from(resources)
.leftJoin(targets, eq(targets.resourceId, resources.resourceId))
.leftJoin(sites, eq(targets.siteId, sites.siteId))
.leftJoin(exitNodes, eq(sites.exitNodeId, exitNodes.exitNodeId))
.where(like(sql`LOWER(${exitNodes.endpoint})`, pattern))
),
inArray( inArray(
resources.resourceId, resources.resourceId,
db db
.select({ id: resourceLabels.resourceId }) .select({ id: resourceLabels.resourceId })
.from(resourceLabels) .from(resourceLabels)
.innerJoin( .innerJoin(labels, eq(labels.labelId, resourceLabels.labelId))
labels,
eq(labels.labelId, resourceLabels.labelId)
)
.where(like(sql`LOWER(${labels.name})`, pattern)) .where(like(sql`LOWER(${labels.name})`, pattern))
) )
); ];
}
return or(...queryList); return or(...queryList);
} }
function buildSearchConditionForSiteResource( function buildSearchConditionForSiteResource(query: string) {
query: string,
labelsFeatureEnabled: boolean
) {
if (!query.trim()) { if (!query.trim()) {
return undefined; return undefined;
} }
@@ -238,13 +314,14 @@ function buildSearchConditionForSiteResource(
const queryList = [ const queryList = [
like(sql`LOWER(${siteResources.name})`, pattern), like(sql`LOWER(${siteResources.name})`, pattern),
like(sql`LOWER(${siteResources.destination})`, pattern), like(sql`LOWER(${siteResources.destination})`, pattern),
like(
sql`LOWER(cast(${siteResources.destinationPort} as text))`,
pattern
),
like(sql`LOWER(${siteResources.scheme})`, pattern),
like(sql`LOWER(${siteResources.alias})`, pattern), like(sql`LOWER(${siteResources.alias})`, pattern),
like(sql`LOWER(${siteResources.fullDomain})`, pattern), like(sql`LOWER(${siteResources.fullDomain})`, pattern),
like(sql`LOWER(${siteResources.aliasAddress})`, pattern) like(sql`LOWER(${siteResources.aliasAddress})`, pattern),
];
if (labelsFeatureEnabled) {
queryList.push(
inArray( inArray(
siteResources.siteResourceId, siteResources.siteResourceId,
db db
@@ -256,14 +333,80 @@ function buildSearchConditionForSiteResource(
) )
.where(like(sql`LOWER(${labels.name})`, pattern)) .where(like(sql`LOWER(${labels.name})`, pattern))
) )
); ];
}
return or(...queryList); return or(...queryList);
} }
async function labelsEnabled(orgId: string): Promise<boolean> { async function filterPublicResourceIdsByTextSearch(
return isLicensedOrSubscribed(orgId, tierMatrix.labels); orgId: string,
resourceIds: number[],
query: string
): Promise<number[]> {
if (!query.trim() || resourceIds.length === 0) {
return resourceIds;
}
const textMatch = combineOrConditions(
buildSearchConditionForPublic(query),
buildSiteNameSearchCondition(query)
);
if (!textMatch) {
return resourceIds;
}
const rows = await db
.selectDistinct({ resourceId: resources.resourceId })
.from(resources)
.leftJoin(targets, eq(targets.resourceId, resources.resourceId))
.leftJoin(sites, eq(targets.siteId, sites.siteId))
.where(
and(
inArray(resources.resourceId, resourceIds),
eq(resources.orgId, orgId),
eq(resources.enabled, true),
textMatch
)
);
return rows.map((row) => row.resourceId);
}
async function filterSiteResourceIdsByTextSearch(
orgId: string,
siteResourceIds: number[],
query: string
): Promise<number[]> {
if (!query.trim() || siteResourceIds.length === 0) {
return siteResourceIds;
}
const textMatch = combineOrConditions(
buildSearchConditionForSiteResource(query),
buildSiteNameSearchCondition(query)
);
if (!textMatch) {
return siteResourceIds;
}
const rows = await db
.selectDistinct({ siteResourceId: siteResources.siteResourceId })
.from(siteResources)
.leftJoin(
siteNetworks,
eq(siteResources.networkId, siteNetworks.networkId)
)
.leftJoin(sites, eq(siteNetworks.siteId, sites.siteId))
.where(
and(
inArray(siteResources.siteResourceId, siteResourceIds),
eq(siteResources.orgId, orgId),
eq(siteResources.enabled, true),
textMatch
)
);
return rows.map((row) => row.siteResourceId);
} }
async function fetchLabelsForResources( async function fetchLabelsForResources(
@@ -277,10 +420,6 @@ async function fetchLabelsForResources(
const byResourceId = new Map<number, LauncherLabel[]>(); const byResourceId = new Map<number, LauncherLabel[]>();
const bySiteResourceId = new Map<number, LauncherLabel[]>(); const bySiteResourceId = new Map<number, LauncherLabel[]>();
if (!(await labelsEnabled(orgId))) {
return { byResourceId, bySiteResourceId };
}
const [resourceLabelRows, siteResourceLabelRows] = await Promise.all([ const [resourceLabelRows, siteResourceLabelRows] = await Promise.all([
resourceIds.length === 0 resourceIds.length === 0
? Promise.resolve([]) ? Promise.resolve([])
@@ -356,15 +495,8 @@ async function listSiteGroups(
): Promise<{ groups: LauncherGroup[]; total: number }> { ): Promise<{ groups: LauncherGroup[]; total: number }> {
const siteFilterIds = parseIdListParam(query.siteIds); const siteFilterIds = parseIdListParam(query.siteIds);
const labelFilterIds = parseIdListParam(query.labelIds); const labelFilterIds = parseIdListParam(query.labelIds);
const labelsFeatureEnabled = await labelsEnabled(orgId); const searchPublic = buildSearchConditionForPublic(query.query);
const searchPublic = buildSearchConditionForPublic( const searchSite = buildSearchConditionForSiteResource(query.query);
query.query,
labelsFeatureEnabled
);
const searchSite = buildSearchConditionForSiteResource(
query.query,
labelsFeatureEnabled
);
const siteCountMap = new Map<number, SiteGroupRow>(); const siteCountMap = new Map<number, SiteGroupRow>();
if (accessible.resourceIds.length > 0) { if (accessible.resourceIds.length > 0) {
@@ -588,9 +720,8 @@ async function listSiteGroups(
}); });
const total = groups.length; const total = groups.length;
const offset = (query.page - 1) * query.pageSize;
return { return {
groups: groups.slice(offset, offset + query.pageSize), groups,
total total
}; };
} }
@@ -608,10 +739,6 @@ async function listLabelGroups(
>(); >();
let unlabeledCount = 0; let unlabeledCount = 0;
if (!(await labelsEnabled(orgId))) {
return { groups: [], total: 0 };
}
const matchesLabelFilters = (labelId: number) => const matchesLabelFilters = (labelId: number) =>
labelFilterIds.length === 0 || labelFilterIds.includes(labelId); labelFilterIds.length === 0 || labelFilterIds.includes(labelId);
@@ -621,7 +748,7 @@ async function listLabelGroups(
eq(resources.orgId, orgId), eq(resources.orgId, orgId),
eq(resources.enabled, true) eq(resources.enabled, true)
]; ];
const searchPublic = buildSearchConditionForPublic(query.query, true); const searchPublic = buildSearchConditionForPublic(query.query);
if (searchPublic) { if (searchPublic) {
publicConditions.push(searchPublic); publicConditions.push(searchPublic);
} }
@@ -685,10 +812,7 @@ async function listLabelGroups(
eq(siteResources.orgId, orgId), eq(siteResources.orgId, orgId),
eq(siteResources.enabled, true) eq(siteResources.enabled, true)
]; ];
const searchSite = buildSearchConditionForSiteResource( const searchSite = buildSearchConditionForSiteResource(query.query);
query.query,
true
);
if (searchSite) { if (searchSite) {
siteConditions.push(searchSite); siteConditions.push(searchSite);
} }
@@ -788,26 +912,53 @@ async function listLabelGroups(
}); });
const total = groups.length; const total = groups.length;
const offset = (query.page - 1) * query.pageSize;
return { return {
groups: groups.slice(offset, offset + query.pageSize), groups,
total total
}; };
} }
async function listLauncherGroupsForUserUncached(
orgId: string,
userId: string,
userRoleIds: number[],
query: LauncherListQuery
): Promise<LauncherGroupsCacheEntry> {
const accessible = await resolveAccessibleIds(orgId, userId, userRoleIds);
if (query.groupBy === "label") {
return listLabelGroups(orgId, accessible, query);
}
return listSiteGroups(orgId, accessible, query);
}
export async function listLauncherGroupsForUser( export async function listLauncherGroupsForUser(
orgId: string, orgId: string,
userId: string, userId: string,
userRoleIds: number[], userRoleIds: number[],
query: LauncherListQuery query: LauncherListQuery
): Promise<{ groups: LauncherGroup[]; total: number }> { ): Promise<{ groups: LauncherGroup[]; total: number }> {
const accessible = await resolveAccessibleIds(orgId, userId, userRoleIds); const queryHash = launcherGroupsQueryHash(userRoleIds, query);
const cacheKey = launcherGroupsCacheKey(orgId, userId, queryHash);
const cached = await cache.get<LauncherGroupsCacheEntry>(cacheKey);
if (query.groupBy === "label") { let result = cached;
return listLabelGroups(orgId, accessible, query); if (!result) {
result = await listLauncherGroupsForUserUncached(
orgId,
userId,
userRoleIds,
query
);
await cache.set(cacheKey, result, LAUNCHER_GROUPS_RESULT_TTL_SEC);
} }
return listSiteGroups(orgId, accessible, query); const offset = (query.page - 1) * query.pageSize;
return {
groups: result.groups.slice(offset, offset + query.pageSize),
total: result.total
};
} }
async function mapPublicResources( async function mapPublicResources(
@@ -1018,26 +1169,6 @@ function filterResourcesByLabel(
); );
} }
function filterResourcesBySearch(
items: LauncherResource[],
query: string
): LauncherResource[] {
if (!query.trim()) {
return items;
}
const pattern = query.trim().toLowerCase();
return items.filter(
(item) =>
item.name.toLowerCase().includes(pattern) ||
item.accessDisplay.toLowerCase().includes(pattern) ||
item.accessCopyValue.toLowerCase().includes(pattern) ||
item.labels.some((label) =>
label.name.toLowerCase().includes(pattern)
) ||
item.site?.name.toLowerCase().includes(pattern)
);
}
function sortLauncherResources( function sortLauncherResources(
items: LauncherResource[], items: LauncherResource[],
order: "asc" | "desc" order: "asc" | "desc"
@@ -1050,12 +1181,12 @@ function sortLauncherResources(
}); });
} }
export async function listLauncherResourcesForUser( async function listLauncherResourcesForUserUncached(
orgId: string, orgId: string,
userId: string, userId: string,
userRoleIds: number[], userRoleIds: number[],
query: LauncherListQuery & { groupKey: string } query: LauncherListQuery & { groupKey: string }
): Promise<{ resources: LauncherResource[]; total: number }> { ): Promise<LauncherResourcesCacheEntry> {
const accessible = await resolveAccessibleIds(orgId, userId, userRoleIds); const accessible = await resolveAccessibleIds(orgId, userId, userRoleIds);
const siteFilterIds = parseIdListParam(query.siteIds); const siteFilterIds = parseIdListParam(query.siteIds);
@@ -1128,6 +1259,23 @@ export async function listLauncherResourcesForUser(
} }
} }
if (query.query.trim()) {
if (filteredResourceIds.length > 0) {
filteredResourceIds = await filterPublicResourceIdsByTextSearch(
orgId,
filteredResourceIds,
query.query
);
}
if (filteredSiteResourceIds.length > 0) {
filteredSiteResourceIds = await filterSiteResourceIdsByTextSearch(
orgId,
filteredSiteResourceIds,
query.query
);
}
}
const labelMaps = await fetchLabelsForResources( const labelMaps = await fetchLabelsForResources(
orgId, orgId,
filteredResourceIds, filteredResourceIds,
@@ -1159,21 +1307,48 @@ export async function listLauncherResourcesForUser(
]); ]);
let items = [...publicItems, ...siteItems]; let items = [...publicItems, ...siteItems];
items = filterResourcesBySearch(items, query.query);
if (query.groupKey !== LAUNCHER_FLAT_GROUP_KEY) {
if (query.groupBy === "label") { if (query.groupBy === "label") {
items = filterResourcesByLabel(items, query.groupKey); items = filterResourcesByLabel(items, query.groupKey);
} else if (query.groupBy === "site") { } else if (query.groupBy === "site") {
items = filterResourcesBySite(items, query.groupKey); items = filterResourcesBySite(items, query.groupKey);
} }
}
items = sortLauncherResources(items, query.order); items = sortLauncherResources(items, query.order);
const total = items.length; return {
items,
total: items.length
};
}
export async function listLauncherResourcesForUser(
orgId: string,
userId: string,
userRoleIds: number[],
query: LauncherListQuery & { groupKey: string }
): Promise<{ resources: LauncherResource[]; total: number }> {
const queryHash = launcherResourcesQueryHash(userRoleIds, query);
const cacheKey = launcherResourcesCacheKey(orgId, userId, queryHash);
const cached = await cache.get<LauncherResourcesCacheEntry>(cacheKey);
let result = cached;
if (!result) {
result = await listLauncherResourcesForUserUncached(
orgId,
userId,
userRoleIds,
query
);
await cache.set(cacheKey, result, LAUNCHER_RESOURCES_RESULT_TTL_SEC);
}
const offset = (query.page - 1) * query.pageSize; const offset = (query.page - 1) * query.pageSize;
return { return {
resources: items.slice(offset, offset + query.pageSize), resources: result.items.slice(offset, offset + query.pageSize),
total total: result.total
}; };
} }
@@ -1296,10 +1471,6 @@ async function collectAccessibleLabels(
): Promise<Map<number, LauncherLabel>> { ): Promise<Map<number, LauncherLabel>> {
const labelMap = new Map<number, LauncherLabel>(); const labelMap = new Map<number, LauncherLabel>();
if (!(await labelsEnabled(orgId))) {
return labelMap;
}
if (accessible.resourceIds.length > 0) { if (accessible.resourceIds.length > 0) {
const publicConditions = [ const publicConditions = [
inArray(resources.resourceId, accessible.resourceIds), inArray(resources.resourceId, accessible.resourceIds),
+133
View File
@@ -0,0 +1,133 @@
import { regionalCache as cache } from "#dynamic/lib/cache";
import {
listLauncherGroupsForUser,
resolveAccessibleIds
} from "./launcherResourceAccess";
import {
parseIdListParam,
type LauncherScaleInfo,
type LauncherScaleQuery
} from "./types";
export const LAUNCHER_FULL_MAX_RESOURCES = 2000;
export const LAUNCHER_FULL_MAX_SITE_GROUPS = 200;
export const LAUNCHER_FULL_MAX_LABEL_GROUPS = 100;
export const LAUNCHER_FILTERED_SITE_GROUPING_MAX = 20;
const LAUNCHER_SCALE_COUNTS_TTL_SEC = 60;
type LauncherScaleCountsCacheEntry = {
resourceCount: number;
siteGroupCount: number;
labelGroupCount: number;
mode: LauncherScaleInfo["mode"];
};
function launcherScaleCountsCacheKey(
orgId: string,
userId: string,
roleIds: number[]
) {
const rolesKey = [...roleIds].sort((a, b) => a - b).join(",");
return `launcher:scale:counts:${orgId}:${userId}:${rolesKey}`;
}
function buildScaleCapabilities(
counts: LauncherScaleCountsCacheEntry,
query: LauncherScaleQuery
): LauncherScaleInfo["capabilities"] {
const siteFilterIds = parseIdListParam(query.siteIds);
const labelFilterIds = parseIdListParam(query.labelIds);
return {
allowSiteGrouping:
counts.siteGroupCount <= LAUNCHER_FULL_MAX_SITE_GROUPS ||
(siteFilterIds.length > 0 &&
siteFilterIds.length <= LAUNCHER_FILTERED_SITE_GROUPING_MAX),
allowLabelGrouping:
counts.labelGroupCount <= LAUNCHER_FULL_MAX_LABEL_GROUPS ||
(labelFilterIds.length > 0 &&
labelFilterIds.length <= LAUNCHER_FILTERED_SITE_GROUPING_MAX),
requireSearchOrFilter:
counts.mode === "compact" &&
counts.resourceCount > LAUNCHER_FULL_MAX_RESOURCES
};
}
async function getLauncherScaleCountsForUser(
orgId: string,
userId: string,
userRoleIds: number[]
): Promise<LauncherScaleCountsCacheEntry> {
const cacheKey = launcherScaleCountsCacheKey(orgId, userId, userRoleIds);
const cached = await cache.get<LauncherScaleCountsCacheEntry>(cacheKey);
if (cached) {
return cached;
}
const accessible = await resolveAccessibleIds(orgId, userId, userRoleIds);
const resourceCount =
accessible.resourceIds.length + accessible.siteResourceIds.length;
const baselineQuery = {
query: "",
groupBy: "site" as const,
siteIds: undefined,
labelIds: undefined,
sort_by: "name" as const,
order: "asc" as const,
page: 1,
pageSize: 1
};
const [{ total: siteGroupCount }, { total: labelGroupCount }] =
await Promise.all([
listLauncherGroupsForUser(
orgId,
userId,
userRoleIds,
baselineQuery
),
listLauncherGroupsForUser(orgId, userId, userRoleIds, {
...baselineQuery,
groupBy: "label"
})
]);
const mode =
resourceCount <= LAUNCHER_FULL_MAX_RESOURCES &&
siteGroupCount <= LAUNCHER_FULL_MAX_SITE_GROUPS
? "full"
: "compact";
const result: LauncherScaleCountsCacheEntry = {
resourceCount,
siteGroupCount,
labelGroupCount,
mode
};
await cache.set(cacheKey, result, LAUNCHER_SCALE_COUNTS_TTL_SEC);
return result;
}
export async function getLauncherScaleForUser(
orgId: string,
userId: string,
userRoleIds: number[],
query: LauncherScaleQuery
): Promise<LauncherScaleInfo> {
const counts = await getLauncherScaleCountsForUser(
orgId,
userId,
userRoleIds
);
return {
mode: counts.mode,
resourceCount: counts.resourceCount,
siteGroupCount: counts.siteGroupCount,
labelGroupCount: counts.labelGroupCount,
capabilities: buildScaleCapabilities(counts, query)
};
}
@@ -0,0 +1,60 @@
import { response } from "@server/lib/response";
import HttpCode from "@server/types/HttpCode";
import { NextFunction, Request, Response } from "express";
import createHttpError from "http-errors";
import { fromZodError } from "zod-validation-error";
import { getLauncherScaleForUser } from "./launcherScale";
import { launcherScaleQuerySchema } from "./types";
export async function listLauncherScale(
req: Request,
res: Response,
next: NextFunction
): Promise<any> {
try {
const orgId = req.userOrgId;
const userId = req.user!.userId;
if (!orgId) {
return next(
createHttpError(HttpCode.BAD_REQUEST, "Invalid organization ID")
);
}
const parsed = launcherScaleQuerySchema.safeParse(req.query);
if (!parsed.success) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
fromZodError(parsed.error)
)
);
}
const scale = await getLauncherScaleForUser(
orgId,
userId,
req.userOrgRoleIds ?? [],
parsed.data
);
return response(res, {
data: { scale },
success: true,
error: false,
message: "Launcher scale retrieved successfully",
status: HttpCode.OK
});
} catch (error) {
if (createHttpError.isHttpError(error)) {
return next(error);
}
console.error("Error listing launcher scale:", error);
return next(
createHttpError(
HttpCode.INTERNAL_SERVER_ERROR,
"Internal server error"
)
);
}
}
+6 -17
View File
@@ -4,22 +4,10 @@ import HttpCode from "@server/types/HttpCode";
import { and, eq, isNull, or } from "drizzle-orm"; import { and, eq, isNull, or } from "drizzle-orm";
import { NextFunction, Request, Response } from "express"; import { NextFunction, Request, Response } from "express";
import createHttpError from "http-errors"; import createHttpError from "http-errors";
import { launcherViewConfigSchema, type LauncherViewRecord } from "./types"; import {
extractDefaultViewOverrides,
function mapViewRow( listVisibleLauncherViews
row: typeof launcherViews.$inferSelect } from "./launcherDefaultView";
): LauncherViewRecord {
return {
viewId: row.viewId,
orgId: row.orgId,
userId: row.userId,
name: row.name,
config: launcherViewConfigSchema.parse(JSON.parse(row.config)),
createdAt: row.createdAt,
updatedAt: row.updatedAt,
isOrgWide: row.userId == null
};
}
export async function listLauncherViews( export async function listLauncherViews(
req: Request, req: Request,
@@ -51,7 +39,8 @@ export async function listLauncherViews(
return response(res, { return response(res, {
data: { data: {
views: rows.map(mapViewRow) views: listVisibleLauncherViews(rows),
defaultViewOverrides: extractDefaultViewOverrides(rows)
}, },
success: true, success: true,
error: false, error: false,
+55 -4
View File
@@ -2,9 +2,10 @@ import { z } from "zod";
export const LAUNCHER_UNLABELED_GROUP_KEY = "unlabeled"; export const LAUNCHER_UNLABELED_GROUP_KEY = "unlabeled";
export const LAUNCHER_NO_SITE_GROUP_KEY = "no-site"; export const LAUNCHER_NO_SITE_GROUP_KEY = "no-site";
export const LAUNCHER_FLAT_GROUP_KEY = "__all__";
export const launcherViewConfigSchema = z.object({ export const launcherViewConfigSchema = z.object({
groupBy: z.enum(["site", "label"]).default("site"), groupBy: z.enum(["site", "label", "none"]).default("site"),
layout: z.enum(["grid", "list"]).default("grid"), layout: z.enum(["grid", "list"]).default("grid"),
sortBy: z.literal("name").default("name"), sortBy: z.literal("name").default("name"),
order: z.enum(["asc", "desc"]).default("asc"), order: z.enum(["asc", "desc"]).default("asc"),
@@ -88,10 +89,31 @@ export type LauncherViewRecord = {
createdAt: string; createdAt: string;
updatedAt: string; updatedAt: string;
isOrgWide: boolean; isOrgWide: boolean;
isDefault: boolean;
}; };
export type LauncherDefaultViewOverrides = {
personal: LauncherViewRecord | null;
orgWide: LauncherViewRecord | null;
};
export function getEffectiveDefaultLauncherConfig(
overrides: LauncherDefaultViewOverrides
): LauncherViewConfig {
if (overrides.personal) {
return overrides.personal.config;
}
if (overrides.orgWide) {
return overrides.orgWide.config;
}
return defaultLauncherViewConfig;
}
export type ListLauncherViewsResponse = { export type ListLauncherViewsResponse = {
views: LauncherViewRecord[]; views: LauncherViewRecord[];
defaultViewOverrides: LauncherDefaultViewOverrides;
}; };
export const launcherFilterListQuerySchema = z.strictObject({ export const launcherFilterListQuerySchema = z.strictObject({
@@ -100,8 +122,8 @@ export const launcherFilterListQuerySchema = z.strictObject({
.int() .int()
.positive() .positive()
.optional() .optional()
.catch(500) .catch(20)
.default(500), .default(20),
page: z.coerce.number().int().min(1).optional().catch(1).default(1), page: z.coerce.number().int().min(1).optional().catch(1).default(1),
query: z.string().optional().default("") query: z.string().optional().default("")
}); });
@@ -138,7 +160,7 @@ export const launcherListQuerySchema = z.strictObject({
.default(20), .default(20),
page: z.coerce.number().int().min(1).optional().catch(1).default(1), page: z.coerce.number().int().min(1).optional().catch(1).default(1),
query: z.string().optional().default(""), query: z.string().optional().default(""),
groupBy: z.enum(["site", "label"]).optional().default("site"), groupBy: z.enum(["site", "label", "none"]).optional().default("site"),
groupKey: z.string().optional(), groupKey: z.string().optional(),
siteIds: z.string().optional(), siteIds: z.string().optional(),
labelIds: z.string().optional(), labelIds: z.string().optional(),
@@ -163,3 +185,32 @@ export const DEFAULT_LAUNCHER_VIEW_ID = "default" as const;
export type LauncherViewSelection = export type LauncherViewSelection =
| { type: "default" } | { type: "default" }
| { type: "saved"; viewId: number }; | { type: "saved"; viewId: number };
export type LauncherScaleCapabilities = {
allowSiteGrouping: boolean;
allowLabelGrouping: boolean;
requireSearchOrFilter: boolean;
};
export type LauncherScaleInfo = {
mode: "full" | "compact";
resourceCount: number;
siteGroupCount: number;
labelGroupCount: number;
capabilities: LauncherScaleCapabilities;
};
export type ListLauncherScaleResponse = {
scale: LauncherScaleInfo;
};
export const launcherScaleQuerySchema = z.strictObject({
query: z.string().optional().default(""),
groupBy: z.enum(["site", "label", "none"]).optional().default("site"),
siteIds: z.string().optional(),
labelIds: z.string().optional(),
sort_by: z.literal("name").optional().default("name"),
order: z.enum(["asc", "desc"]).optional().default("asc")
});
export type LauncherScaleQuery = z.infer<typeof launcherScaleQuerySchema>;
+11 -1
View File
@@ -66,6 +66,15 @@ export async function updateLauncherView(
); );
} }
if (existing.isDefault) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
"Use the default view save endpoint for this view"
)
);
}
const isPersonalView = existing.userId === userId; const isPersonalView = existing.userId === userId;
const isOrgWideView = existing.userId == null; const isOrgWideView = existing.userId == null;
const canManageOrgWide = await checkUserActionPermission( const canManageOrgWide = await checkUserActionPermission(
@@ -135,7 +144,8 @@ export async function updateLauncherView(
), ),
createdAt: updated.createdAt, createdAt: updated.createdAt,
updatedAt: updated.updatedAt, updatedAt: updated.updatedAt,
isOrgWide: updated.userId == null isOrgWide: updated.userId == null,
isDefault: updated.isDefault
}, },
success: true, success: true,
error: false, error: false,
@@ -0,0 +1,82 @@
import { response } from "@server/lib/response";
import HttpCode from "@server/types/HttpCode";
import { NextFunction, Request, Response } from "express";
import createHttpError from "http-errors";
import { fromZodError } from "zod-validation-error";
import { z } from "zod";
import { ActionsEnum, checkUserActionPermission } from "@server/auth/actions";
import { upsertDefaultViewOverride } from "./launcherDefaultView";
import { launcherViewConfigSchema } from "./types";
const upsertLauncherDefaultViewBodySchema = z.strictObject({
config: launcherViewConfigSchema,
orgWide: z.boolean().optional().default(false)
});
export async function upsertLauncherDefaultView(
req: Request,
res: Response,
next: NextFunction
): Promise<any> {
try {
const orgId = req.userOrgId;
const userId = req.user!.userId;
if (!orgId) {
return next(
createHttpError(HttpCode.BAD_REQUEST, "Invalid organization ID")
);
}
const parsed = upsertLauncherDefaultViewBodySchema.safeParse(req.body);
if (!parsed.success) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
fromZodError(parsed.error)
)
);
}
if (parsed.data.orgWide) {
const canManageOrgWide = await checkUserActionPermission(
ActionsEnum.createOrgWideLauncherView,
req
);
if (!canManageOrgWide) {
return next(
createHttpError(
HttpCode.FORBIDDEN,
"User does not have permission perform this action"
)
);
}
}
const view = await upsertDefaultViewOverride({
orgId,
userId,
orgWide: parsed.data.orgWide,
config: parsed.data.config
});
return response(res, {
data: view,
success: true,
error: false,
message: "Launcher default view saved successfully",
status: HttpCode.OK
});
} catch (error) {
if (createHttpError.isHttpError(error)) {
return next(error);
}
console.error("Error saving launcher default view:", error);
return next(
createHttpError(
HttpCode.INTERNAL_SERVER_ERROR,
"Internal server error"
)
);
}
}
+2 -2
View File
@@ -52,13 +52,13 @@ export async function buildClientConfigurationForNewtClient(
clientsRes clientsRes
.filter((client) => { .filter((client) => {
if (!client.clients.pubKey) { if (!client.clients.pubKey) {
logger.warn( logger.debug(
`Client ${client.clients.clientId} has no public key, skipping` `Client ${client.clients.clientId} has no public key, skipping`
); );
return false; return false;
} }
if (!client.clients.subnet) { if (!client.clients.subnet) {
logger.warn( logger.debug(
`Client ${client.clients.clientId} has no subnet, skipping` `Client ${client.clients.clientId} has no subnet, skipping`
); );
return false; return false;
@@ -50,7 +50,7 @@ export const handleApplyBlueprintMessage: MessageHandler = async (context) => {
source: "NEWT" source: "NEWT"
}); });
} catch (error) { } catch (error) {
logger.error(`Failed to update database from config: ${error}`); logger.debug(`Failed to update database from config: ${error}`);
return { return {
message: { message: {
type: "newt/blueprint/results", type: "newt/blueprint/results",
@@ -19,7 +19,7 @@ export const handleNewtDisconnectingMessage: MessageHandler = async (
} }
if (!newt.siteId) { if (!newt.siteId) {
logger.warn("Newt has no client ID!"); logger.warn("Newt has no site ID!");
return; return;
} }
@@ -34,6 +34,12 @@ export const handleNewtDisconnectingMessage: MessageHandler = async (
.where(eq(sites.siteId, newt.siteId!)) .where(eq(sites.siteId, newt.siteId!))
.returning(); .returning();
if (!site) {
throw new Error(
`Could not find site ${newt.siteId} to update disconnection from disconnect message`
);
}
await fireSiteOfflineAlert( await fireSiteOfflineAlert(
site.orgId, site.orgId,
site.siteId, site.siteId,
@@ -43,6 +49,6 @@ export const handleNewtDisconnectingMessage: MessageHandler = async (
); );
}); });
} catch (error) { } catch (error) {
logger.error("Error handling disconnecting message", { error }); logger.error("Error handling site disconnecting message", error);
} }
}; };
+2 -81
View File
@@ -3,6 +3,7 @@ import { sites, clients, olms } from "@server/db";
import { and, eq, inArray } from "drizzle-orm"; import { and, eq, inArray } from "drizzle-orm";
import logger from "@server/logger"; import logger from "@server/logger";
import { fireSiteOnlineAlert } from "@server/lib/alerts"; import { fireSiteOnlineAlert } from "@server/lib/alerts";
import { withRetry } from "@server/lib/dbRetry";
/** /**
* Ping Accumulator * Ping Accumulator
@@ -22,8 +23,6 @@ import { fireSiteOnlineAlert } from "@server/lib/alerts";
*/ */
const FLUSH_INTERVAL_MS = 10_000; // Flush every 10 seconds const FLUSH_INTERVAL_MS = 10_000; // Flush every 10 seconds
const MAX_RETRIES = 5;
const BASE_DELAY_MS = 50;
// ── Site (newt) pings ────────────────────────────────────────────────── // ── Site (newt) pings ──────────────────────────────────────────────────
// Map of siteId -> latest ping timestamp (unix seconds) // Map of siteId -> latest ping timestamp (unix seconds)
@@ -266,85 +265,7 @@ export async function flushPingsToDb(): Promise<void> {
} }
// ── Retry / Error Helpers ────────────────────────────────────────────── // ── Retry / Error Helpers ──────────────────────────────────────────────
// See @server/lib/dbRetry for the shared withRetry/isTransientError helpers.
/**
* Simple retry wrapper with exponential backoff for transient errors
* (deadlocks, connection timeouts, unexpected disconnects).
*
* PostgreSQL deadlocks (40P01) are always safe to retry: the database
* guarantees exactly one winner per deadlock pair, so the loser just needs
* to try again. MAX_RETRIES is intentionally higher than typical connection
* retry budgets to give deadlock victims enough chances to succeed.
*/
async function withRetry<T>(
operation: () => Promise<T>,
context: string
): Promise<T> {
let attempt = 0;
while (true) {
try {
return await operation();
} catch (error: any) {
if (isTransientError(error) && attempt < MAX_RETRIES) {
attempt++;
const baseDelay = Math.pow(2, attempt - 1) * BASE_DELAY_MS;
const jitter = Math.random() * baseDelay;
const delay = baseDelay + jitter;
logger.warn(
`Transient DB error in ${context}, retrying attempt ${attempt}/${MAX_RETRIES} after ${delay.toFixed(0)}ms`,
{ code: error?.code ?? error?.cause?.code }
);
await new Promise((resolve) => setTimeout(resolve, delay));
continue;
}
throw error;
}
}
}
/**
* Detect transient errors that are safe to retry.
*/
function isTransientError(error: any): boolean {
if (!error) return false;
const message = (error.message || "").toLowerCase();
const causeMessage = (error.cause?.message || "").toLowerCase();
const code = error.code || error.cause?.code || "";
// Connection timeout / terminated
if (
message.includes("connection timeout") ||
message.includes("connection terminated") ||
message.includes("timeout exceeded when trying to connect") ||
causeMessage.includes("connection terminated unexpectedly") ||
causeMessage.includes("connection timeout")
) {
return true;
}
// PostgreSQL deadlock detected - always safe to retry (one winner guaranteed)
if (code === "40P01" || message.includes("deadlock")) {
return true;
}
// PostgreSQL serialization failure
if (code === "40001") {
return true;
}
// ECONNRESET, ECONNREFUSED, EPIPE, ETIMEDOUT
if (
code === "ECONNRESET" ||
code === "ECONNREFUSED" ||
code === "EPIPE" ||
code === "ETIMEDOUT"
) {
return true;
}
return false;
}
// ── Lifecycle ────────────────────────────────────────────────────────── // ── Lifecycle ──────────────────────────────────────────────────────────
+1 -1
View File
@@ -161,7 +161,7 @@ export async function buildSiteConfigurationForOlmClient(
} }
if (!site.subnet) { if (!site.subnet) {
logger.warn(`Site ${site.siteId} has no subnet, skipping`); logger.debug(`Site ${site.siteId} has no subnet, skipping`);
continue; continue;
} }
@@ -6,7 +6,9 @@ import logger from "@server/logger";
/** /**
* Handles disconnecting messages from clients to show disconnected in the ui * Handles disconnecting messages from clients to show disconnected in the ui
*/ */
export const handleOlmDisconnectingMessage: MessageHandler = async (context) => { export const handleOlmDisconnectingMessage: MessageHandler = async (
context
) => {
const { message, client: c, sendToClient } = context; const { message, client: c, sendToClient } = context;
const olm = c as Olm; const olm = c as Olm;
@@ -29,6 +31,6 @@ export const handleOlmDisconnectingMessage: MessageHandler = async (context) =>
}) })
.where(eq(clients.clientId, olm.clientId)); .where(eq(clients.clientId, olm.clientId));
} catch (error) { } catch (error) {
logger.error("Error handling disconnecting message", { error }); logger.error("Error handling client disconnecting message", error);
} }
}; };
@@ -1,7 +1,12 @@
import { Request, Response, NextFunction } from "express"; import { Request, Response, NextFunction } from "express";
import { z } from "zod"; import { z } from "zod";
import { db } from "@server/db"; import { db } from "@server/db";
import { resources, resourceWhitelist } from "@server/db"; import {
resources,
resourceWhitelist,
resourcePolicies,
resourcePolicyWhiteList
} from "@server/db";
import response from "@server/lib/response"; import response from "@server/lib/response";
import HttpCode from "@server/types/HttpCode"; import HttpCode from "@server/types/HttpCode";
import createHttpError from "http-errors"; import createHttpError from "http-errors";
@@ -103,6 +108,64 @@ export async function addEmailToResourceWhitelist(
); );
} }
// A shared policy takes precedence over the resource's inline
// (default) policy, which takes precedence over the resource's own
// direct whitelist fields. This mirrors the precedence used at
// request time in authWithWhitelist.ts / getResourceAuthInfo.ts.
const policyId =
resource.resourcePolicyId ?? resource.defaultResourcePolicyId;
if (policyId !== null) {
const [policy] = await db
.select()
.from(resourcePolicies)
.where(eq(resourcePolicies.resourcePolicyId, policyId));
if (!policy) {
return next(
createHttpError(
HttpCode.NOT_FOUND,
"Resource policy not found"
)
);
}
if (!policy.emailWhitelistEnabled) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
"Email whitelist is not enabled for this resource"
)
);
}
const existingEntry = await db
.select()
.from(resourcePolicyWhiteList)
.where(
and(
eq(
resourcePolicyWhiteList.resourcePolicyId,
policyId
),
eq(resourcePolicyWhiteList.email, email)
)
);
if (existingEntry.length > 0) {
return next(
createHttpError(
HttpCode.CONFLICT,
"Email already exists in whitelist"
)
);
}
await db.insert(resourcePolicyWhiteList).values({
email,
resourcePolicyId: policyId
});
} else {
if (!resource.emailWhitelistEnabled) { if (!resource.emailWhitelistEnabled) {
return next( return next(
createHttpError( createHttpError(
@@ -136,6 +199,7 @@ export async function addEmailToResourceWhitelist(
email, email,
resourceId resourceId
}); });
}
return response(res, { return response(res, {
data: {}, data: {},
@@ -96,12 +96,16 @@ export async function getResourceWhitelist(
); );
} }
const isInlinePolicy = // A shared policy takes precedence over the resource's inline
resource.resourcePolicyId === null && // (default) policy, which takes precedence over the resource's own
resource.defaultResourcePolicyId !== null; // direct whitelist fields. This mirrors the precedence used at
// request time in authWithWhitelist.ts / getResourceAuthInfo.ts.
const policyId =
resource.resourcePolicyId ?? resource.defaultResourcePolicyId;
const whitelist = isInlinePolicy const whitelist =
? await queryPolicyWhitelist(resource.defaultResourcePolicyId!) policyId !== null
? await queryPolicyWhitelist(policyId)
: await queryWhitelist(resourceId); : await queryWhitelist(resourceId);
return response<GetResourceWhitelistResponse>(res, { return response<GetResourceWhitelistResponse>(res, {
+1 -10
View File
@@ -363,11 +363,6 @@ export async function getUserResources(
(r) => r.siteResourceId (r) => r.siteResourceId
); );
const isLabelFeatureEnabled = await isLicensedOrSubscribed(
orgId,
tierMatrix.labels
);
let labelsForResources: Array<{ let labelsForResources: Array<{
labelId: number; labelId: number;
name: string; name: string;
@@ -381,7 +376,6 @@ export async function getUserResources(
siteResourceId: number; siteResourceId: number;
}> = []; }> = [];
if (isLabelFeatureEnabled) {
[labelsForResources, labelsForSiteResources] = await Promise.all([ [labelsForResources, labelsForSiteResources] = await Promise.all([
resourceIdList.length === 0 resourceIdList.length === 0
? Promise.resolve([]) ? Promise.resolve([])
@@ -397,9 +391,7 @@ export async function getUserResources(
resourceLabels, resourceLabels,
eq(resourceLabels.labelId, labels.labelId) eq(resourceLabels.labelId, labels.labelId)
) )
.where( .where(inArray(resourceLabels.resourceId, resourceIdList))
inArray(resourceLabels.resourceId, resourceIdList)
)
.orderBy(asc(resourceLabels.resourceLabelId)), .orderBy(asc(resourceLabels.resourceLabelId)),
siteResourceIdList.length === 0 siteResourceIdList.length === 0
? Promise.resolve([]) ? Promise.resolve([])
@@ -423,7 +415,6 @@ export async function getUserResources(
) )
.orderBy(asc(siteResourceLabels.siteResourceLabelId)) .orderBy(asc(siteResourceLabels.siteResourceLabelId))
]); ]);
}
// Check for password, pincode, and whitelist protection for each resource // Check for password, pincode, and whitelist protection for each resource
const resourcesWithAuth = await Promise.all( const resourcesWithAuth = await Promise.all(
+8 -22
View File
@@ -484,11 +484,6 @@ export async function listResources(
); );
} }
const isLabelFeatureEnabled = await isLicensedOrSubscribed(
orgId,
tierMatrix.labels
);
let accessibleResources: Array<{ resourceId: number }>; let accessibleResources: Array<{ resourceId: number }>;
if (req.user) { if (req.user) {
accessibleResources = await db accessibleResources = await db
@@ -616,8 +611,8 @@ export async function listResources(
and( and(
inArray(resources.mode, browserGatewayModes), inArray(resources.mode, browserGatewayModes),
or( or(
eq(effectiveSso, true), effectiveSso,
eq(effectiveWhitelist, true), effectiveWhitelist,
not(isNull(effectiveHeaderAuthId)), not(isNull(effectiveHeaderAuthId)),
not(isNull(effectivePincodeId)), not(isNull(effectivePincodeId)),
not(isNull(effectivePasswordId)) not(isNull(effectivePasswordId))
@@ -629,8 +624,8 @@ export async function listResources(
conditions.push( conditions.push(
and( and(
inArray(resources.mode, browserGatewayModes), inArray(resources.mode, browserGatewayModes),
not(eq(effectiveSso, true)), not(effectiveSso),
not(eq(effectiveWhitelist, true)), not(effectiveWhitelist),
isNull(effectiveHeaderAuthId), isNull(effectiveHeaderAuthId),
isNull(effectivePincodeId), isNull(effectivePincodeId),
isNull(effectivePasswordId) isNull(effectivePasswordId)
@@ -676,7 +671,7 @@ export async function listResources(
); );
} }
if (isLabelFeatureEnabled && labelFilter && labelFilter.length > 0) { if (labelFilter && labelFilter.length > 0) {
conditions.push( conditions.push(
inArray( inArray(
resources.resourceId, resources.resourceId,
@@ -697,11 +692,7 @@ export async function listResources(
const queryList = [ const queryList = [
like(sql`LOWER(${resources.name})`, q), like(sql`LOWER(${resources.name})`, q),
like(sql`LOWER(${resources.niceId})`, q), like(sql`LOWER(${resources.niceId})`, q),
like(sql`LOWER(${resources.fullDomain})`, q) like(sql`LOWER(${resources.fullDomain})`, q),
];
if (isLabelFeatureEnabled) {
queryList.push(
inArray( inArray(
resources.resourceId, resources.resourceId,
db db
@@ -713,8 +704,7 @@ export async function listResources(
) )
.where(like(sql`LOWER(${labels.name})`, q)) .where(like(sql`LOWER(${labels.name})`, q))
) )
); ];
}
conditions.push(or(...queryList)); conditions.push(or(...queryList));
} }
@@ -747,7 +737,6 @@ export async function listResources(
resourceId: number; resourceId: number;
}> = []; }> = [];
if (isLabelFeatureEnabled) {
labelsForResources = labelsForResources =
resourceIdList.length === 0 resourceIdList.length === 0
? [] ? []
@@ -763,11 +752,8 @@ export async function listResources(
resourceLabels, resourceLabels,
eq(resourceLabels.labelId, labels.labelId) eq(resourceLabels.labelId, labels.labelId)
) )
.where( .where(inArray(resourceLabels.resourceId, resourceIdList))
inArray(resourceLabels.resourceId, resourceIdList)
)
.orderBy(asc(resourceLabels.resourceLabelId)); .orderBy(asc(resourceLabels.resourceLabelId));
}
const allResourceTargets = const allResourceTargets =
resourceIdList.length === 0 resourceIdList.length === 0
@@ -248,11 +248,6 @@ export async function listUserResourceAliases(
}); });
} }
const isLabelFeatureEnabled = await isLicensedOrSubscribed(
orgId,
tierMatrix.labels
);
const whereConditions = [ const whereConditions = [
eq(siteResources.orgId, orgId), eq(siteResources.orgId, orgId),
eq(siteResources.enabled, true), eq(siteResources.enabled, true),
@@ -262,7 +257,7 @@ export async function listUserResourceAliases(
inArray(siteResources.siteResourceId, accessibleSiteResourceIds) inArray(siteResources.siteResourceId, accessibleSiteResourceIds)
]; ];
if (isLabelFeatureEnabled && labelFilter && labelFilter.length > 0) { if (labelFilter && labelFilter.length > 0) {
whereConditions.push( whereConditions.push(
inArray( inArray(
siteResources.siteResourceId, siteResources.siteResourceId,
@@ -310,7 +305,7 @@ export async function listUserResourceAliases(
siteResourceId: number; siteResourceId: number;
}> = []; }> = [];
if (isLabelFeatureEnabled && siteResourceIdList.length > 0) { if (siteResourceIdList.length > 0) {
labelsForSiteResources = await db labelsForSiteResources = await db
.select({ .select({
name: labels.name, name: labels.name,
@@ -1,7 +1,12 @@
import { Request, Response, NextFunction } from "express"; import { Request, Response, NextFunction } from "express";
import { z } from "zod"; import { z } from "zod";
import { db } from "@server/db"; import { db } from "@server/db";
import { resources, resourceWhitelist } from "@server/db"; import {
resources,
resourceWhitelist,
resourcePolicies,
resourcePolicyWhiteList
} from "@server/db";
import response from "@server/lib/response"; import response from "@server/lib/response";
import HttpCode from "@server/types/HttpCode"; import HttpCode from "@server/types/HttpCode";
import createHttpError from "http-errors"; import createHttpError from "http-errors";
@@ -102,6 +107,71 @@ export async function removeEmailFromResourceWhitelist(
); );
} }
// A shared policy takes precedence over the resource's inline
// (default) policy, which takes precedence over the resource's own
// direct whitelist fields. This mirrors the precedence used at
// request time in authWithWhitelist.ts / getResourceAuthInfo.ts.
const policyId =
resource.resourcePolicyId ?? resource.defaultResourcePolicyId;
if (policyId !== null) {
const [policy] = await db
.select()
.from(resourcePolicies)
.where(eq(resourcePolicies.resourcePolicyId, policyId));
if (!policy) {
return next(
createHttpError(
HttpCode.NOT_FOUND,
"Resource policy not found"
)
);
}
if (!policy.emailWhitelistEnabled) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
"Email whitelist is not enabled for this resource"
)
);
}
const existingEntry = await db
.select()
.from(resourcePolicyWhiteList)
.where(
and(
eq(
resourcePolicyWhiteList.resourcePolicyId,
policyId
),
eq(resourcePolicyWhiteList.email, email)
)
);
if (existingEntry.length === 0) {
return next(
createHttpError(
HttpCode.NOT_FOUND,
"Email not found in whitelist"
)
);
}
await db
.delete(resourcePolicyWhiteList)
.where(
and(
eq(
resourcePolicyWhiteList.resourcePolicyId,
policyId
),
eq(resourcePolicyWhiteList.email, email)
)
);
} else {
if (!resource.emailWhitelistEnabled) { if (!resource.emailWhitelistEnabled) {
return next( return next(
createHttpError( createHttpError(
@@ -139,6 +209,7 @@ export async function removeEmailFromResourceWhitelist(
eq(resourceWhitelist.email, email) eq(resourceWhitelist.email, email)
) )
); );
}
return response(res, { return response(res, {
data: {}, data: {},
@@ -109,13 +109,14 @@ export async function setResourceWhitelist(
); );
} }
const isInlinePolicy = // A shared policy takes precedence over the resource's inline
resource.resourcePolicyId === null && // (default) policy, which takes precedence over the resource's own
resource.defaultResourcePolicyId !== null; // direct whitelist fields. This mirrors the precedence used at
// request time in authWithWhitelist.ts / getResourceAuthInfo.ts.
if (isInlinePolicy) { const policyId =
const policyId = resource.defaultResourcePolicyId!; resource.resourcePolicyId ?? resource.defaultResourcePolicyId;
if (policyId !== null) {
const [policy] = await db const [policy] = await db
.select() .select()
.from(resourcePolicies) .from(resourcePolicies)
@@ -27,6 +27,7 @@ const resourceRuleMatchSchema = z.enum([
"IP", "IP",
"PATH", "PATH",
"COUNTRY", "COUNTRY",
"COUNTRY_IS_NOT",
"ASN", "ASN",
"REGION" "REGION"
]); ]);
@@ -144,6 +145,7 @@ export async function updateResourceRule(
| "IP" | "IP"
| "PATH" | "PATH"
| "COUNTRY" | "COUNTRY"
| "COUNTRY_IS_NOT"
| "ASN" | "ASN"
| "REGION"; | "REGION";
+5 -1
View File
@@ -268,7 +268,11 @@ export async function createSite(
let newSite: Site | undefined; let newSite: Site | undefined;
try { try {
if (subnet && exitNodeId) { if (type === "wireguard" && subnet && exitNodeId) {
// Only wireguard sites actually persist the provided subnet/exitNodeId.
// Newt sites have their subnet/exit node chosen (under a lock) when the
// newt connects, so validating them here is both unnecessary and racy,
// since pickSiteDefaults does not lock the subnet it suggests.
//make sure the subnet is in the range of the exit node if provided //make sure the subnet is in the range of the exit node if provided
const [exitNode] = await db const [exitNode] = await db
.select() .select()
+4 -15
View File
@@ -235,11 +235,6 @@ export async function listSites(
); );
} }
const isLabelFeatureEnabled = await isLicensedOrSubscribed(
orgId,
tierMatrix.labels
);
const { const {
pageSize, pageSize,
page, page,
@@ -291,7 +286,7 @@ export async function listSites(
conditions.push(eq(sites.status, status)); conditions.push(eq(sites.status, status));
} }
if (isLabelFeatureEnabled && labelFilter && labelFilter.length > 0) { if (labelFilter && labelFilter.length > 0) {
conditions.push( conditions.push(
inArray( inArray(
sites.siteId, sites.siteId,
@@ -311,11 +306,7 @@ export async function listSites(
const q = "%" + query.toLowerCase() + "%"; const q = "%" + query.toLowerCase() + "%";
const queryList = [ const queryList = [
like(sql`LOWER(${sites.name})`, q), like(sql`LOWER(${sites.name})`, q),
like(sql`LOWER(${sites.niceId})`, q) like(sql`LOWER(${sites.niceId})`, q),
];
if (isLabelFeatureEnabled) {
queryList.push(
inArray( inArray(
sites.siteId, sites.siteId,
db db
@@ -327,8 +318,8 @@ export async function listSites(
) )
.where(like(sql`LOWER(${labels.name})`, q)) .where(like(sql`LOWER(${labels.name})`, q))
) )
); ];
}
conditions.push(or(...queryList)!); conditions.push(or(...queryList)!);
} }
@@ -366,7 +357,6 @@ export async function listSites(
siteId: number; siteId: number;
}> = []; }> = [];
if (isLabelFeatureEnabled) {
labelsForSites = labelsForSites =
siteIds.length === 0 siteIds.length === 0
? [] ? []
@@ -384,7 +374,6 @@ export async function listSites(
) )
.where(inArray(siteLabels.siteId, siteIds)) .where(inArray(siteLabels.siteId, siteIds))
.orderBy(asc(siteLabels.siteLabelId)); .orderBy(asc(siteLabels.siteLabelId));
}
const sitesWithUpdates: SiteWithUpdateAvailable[] = rows.map((site) => { const sitesWithUpdates: SiteWithUpdateAvailable[] = rows.map((site) => {
const siteWithUpdate: SiteWithUpdateAvailable = { ...site }; const siteWithUpdate: SiteWithUpdateAvailable = { ...site };
@@ -55,7 +55,7 @@ const createSiteResourceSchema = z
siteIds: z.array(z.int()).optional(), siteIds: z.array(z.int()).optional(),
siteId: z.number().int().positive().optional(), // DEPRECATED: for backward compatibility, we will convert this to siteIds array if provided siteId: z.number().int().positive().optional(), // DEPRECATED: for backward compatibility, we will convert this to siteIds array if provided
destinationPort: z.int().positive().optional(), destinationPort: z.int().positive().optional(),
destination: z.string().min(1).optional(), destination: z.string().min(1).nullish(),
enabled: z.boolean().default(true), enabled: z.boolean().default(true),
alias: z alias: z
.string() .string()
@@ -161,7 +161,8 @@ const createSiteResourceSchema = z
return true; return true;
} }
return ( return (
data.destination !== undefined && data.destination.trim() !== "" data.destination !== undefined &&
data.destination?.trim() !== ""
); );
}, },
{ {
@@ -286,11 +286,6 @@ export async function listAllSiteResourcesByOrg(
labels: labelFilter labels: labelFilter
} = parsedQuery.data; } = parsedQuery.data;
const isLabelFeatureEnabled = await isLicensedOrSubscribed(
orgId,
tierMatrix.labels
);
const conditions = [and(eq(siteResources.orgId, orgId))]; const conditions = [and(eq(siteResources.orgId, orgId))];
if (siteId != null) { if (siteId != null) {
@@ -320,7 +315,7 @@ export async function listAllSiteResourcesByOrg(
conditions.push(eq(siteResources.mode, mode)); conditions.push(eq(siteResources.mode, mode));
} }
if (isLabelFeatureEnabled && labelFilter && labelFilter.length > 0) { if (labelFilter && labelFilter.length > 0) {
conditions.push( conditions.push(
inArray( inArray(
siteResources.siteResourceId, siteResources.siteResourceId,
@@ -344,11 +339,7 @@ export async function listAllSiteResourcesByOrg(
like(sql`LOWER(${siteResources.destination})`, q), like(sql`LOWER(${siteResources.destination})`, q),
like(sql`LOWER(${siteResources.alias})`, q), like(sql`LOWER(${siteResources.alias})`, q),
like(sql`LOWER(${siteResources.aliasAddress})`, q), like(sql`LOWER(${siteResources.aliasAddress})`, q),
like(sql`LOWER(${sites.name})`, q) like(sql`LOWER(${sites.name})`, q),
];
if (isLabelFeatureEnabled) {
queryList.push(
inArray( inArray(
siteResources.siteResourceId, siteResources.siteResourceId,
db db
@@ -360,8 +351,7 @@ export async function listAllSiteResourcesByOrg(
) )
.where(like(sql`LOWER(${labels.name})`, q)) .where(like(sql`LOWER(${labels.name})`, q))
) )
); ];
}
conditions.push(or(...queryList)); conditions.push(or(...queryList));
} }
@@ -403,7 +393,7 @@ export async function listAllSiteResourcesByOrg(
siteResourceId: number; siteResourceId: number;
}> = []; }> = [];
if (isLabelFeatureEnabled && siteResourceIdList.length > 0) { if (siteResourceIdList.length > 0) {
labelsForSiteResources = await db labelsForSiteResources = await db
.select({ .select({
labelId: labels.labelId, labelId: labels.labelId,
@@ -54,7 +54,7 @@ const updateSiteResourceSchema = z
ssl: z.boolean().optional(), ssl: z.boolean().optional(),
scheme: z.enum(["http", "https"]).nullish(), scheme: z.enum(["http", "https"]).nullish(),
destinationPort: z.int().positive().nullish(), destinationPort: z.int().positive().nullish(),
destination: z.string().min(1).optional(), destination: z.string().min(1).nullish(),
enabled: z.boolean().optional(), enabled: z.boolean().optional(),
alias: z alias: z
.string() .string()
@@ -68,9 +68,9 @@ const updateSiteResourceSchema = z
"Fully qualified domain name with optional wildcards, e.g., example.internal, *.example.internal, or host-0?.example.internal", "Fully qualified domain name with optional wildcards, e.g., example.internal, *.example.internal, or host-0?.example.internal",
example: "service.example.internal" example: "service.example.internal"
}), }),
userIds: z.array(z.string()), userIds: z.array(z.string()).optional(),
roleIds: z.array(z.int()), roleIds: z.array(z.int()).optional(),
clientIds: z.array(z.int()), clientIds: z.array(z.int()).optional(),
tcpPortRangeString: portRangeStringSchema, tcpPortRangeString: portRangeStringSchema,
udpPortRangeString: portRangeStringSchema, udpPortRangeString: portRangeStringSchema,
disableIcmp: z.boolean().optional(), disableIcmp: z.boolean().optional(),
@@ -157,7 +157,8 @@ const updateSiteResourceSchema = z
return true; return true;
} }
return ( return (
data.destination !== undefined && data.destination.trim() !== "" data.destination !== undefined &&
data.destination?.trim() !== ""
); );
}, },
{ {
@@ -167,6 +168,10 @@ const updateSiteResourceSchema = z
) )
.refine( .refine(
(data) => { (data) => {
// if neither is provided, the existing site associations are left unchanged
if (data.siteIds === undefined && data.siteId === undefined) {
return true;
}
return ( return (
(data.siteIds !== undefined && data.siteIds.length > 0) || (data.siteIds !== undefined && data.siteIds.length > 0) ||
data.siteId !== undefined data.siteId !== undefined
@@ -263,7 +268,7 @@ export async function updateSiteResource(
const { siteResourceId } = parsedParams.data; const { siteResourceId } = parsedParams.data;
const { const {
name, name,
siteIds: siteIdsInput = [], // because it can change siteIds: siteIdsInput,
siteId, siteId,
niceId, niceId,
mode, mode,
@@ -287,10 +292,15 @@ export async function updateSiteResource(
} = parsedBody.data; } = parsedBody.data;
// Backward compatibility: merge deprecated siteId into siteIds array // Backward compatibility: merge deprecated siteId into siteIds array
const siteIds = [...siteIdsInput]; const siteIdsProvided =
siteIdsInput !== undefined || siteId !== undefined;
let siteIds: number[] | undefined;
if (siteIdsProvided) {
siteIds = [...(siteIdsInput ?? [])];
if (siteId !== undefined && !siteIds.includes(siteId)) { if (siteId !== undefined && !siteIds.includes(siteId)) {
siteIds.push(siteId); siteIds.push(siteId);
} }
}
// Check if site resource exists // Check if site resource exists
const [existingSiteResource] = await db const [existingSiteResource] = await db
@@ -356,6 +366,7 @@ export async function updateSiteResource(
} }
// Verify the site exists and belongs to the org // Verify the site exists and belongs to the org
if (siteIds !== undefined) {
const sitesToAssign = await db const sitesToAssign = await db
.select() .select()
.from(sites) .from(sites)
@@ -371,6 +382,7 @@ export async function updateSiteResource(
createHttpError(HttpCode.NOT_FOUND, "Some site not found") createHttpError(HttpCode.NOT_FOUND, "Some site not found")
); );
} }
}
// Only check if destination is an IP address // Only check if destination is an IP address
const isIp = z const isIp = z
@@ -463,7 +475,8 @@ export async function updateSiteResource(
} }
let updatedSiteResource: SiteResource | undefined; let updatedSiteResource: SiteResource | undefined;
let updatedSiteIds: number[] = []; // defaults to the existing sites; only overwritten below if siteIds/siteId was provided
let updatedSiteIds: number[] = [...existingSiteIds];
await db.transaction(async (trx) => { await db.transaction(async (trx) => {
// Update the site resource // Update the site resource
const sshPamSet = const sshPamSet =
@@ -522,13 +535,18 @@ export async function updateSiteResource(
//////////////////// update the associations //////////////////// //////////////////// update the associations ////////////////////
if (siteIds !== undefined) {
// delete the site - site resources associations // delete the site - site resources associations
await trx await trx
.delete(siteNetworks) .delete(siteNetworks)
.where( .where(
eq(siteNetworks.networkId, updatedSiteResource.networkId!) eq(
siteNetworks.networkId,
updatedSiteResource.networkId!
)
); );
updatedSiteIds = [];
for (const siteId of siteIds) { for (const siteId of siteIds) {
await trx.insert(siteNetworks).values({ await trx.insert(siteNetworks).values({
siteId: siteId, siteId: siteId,
@@ -536,10 +554,14 @@ export async function updateSiteResource(
}); });
updatedSiteIds.push(siteId); updatedSiteIds.push(siteId);
} }
}
if (clientIds !== undefined) {
await trx await trx
.delete(clientSiteResources) .delete(clientSiteResources)
.where(eq(clientSiteResources.siteResourceId, siteResourceId)); .where(
eq(clientSiteResources.siteResourceId, siteResourceId)
);
if (clientIds.length > 0) { if (clientIds.length > 0) {
await trx.insert(clientSiteResources).values( await trx.insert(clientSiteResources).values(
@@ -549,10 +571,14 @@ export async function updateSiteResource(
})) }))
); );
} }
}
if (userIds !== undefined) {
await trx await trx
.delete(userSiteResources) .delete(userSiteResources)
.where(eq(userSiteResources.siteResourceId, siteResourceId)); .where(
eq(userSiteResources.siteResourceId, siteResourceId)
);
if (userIds.length > 0) { if (userIds.length > 0) {
await trx.insert(userSiteResources).values( await trx.insert(userSiteResources).values(
@@ -562,7 +588,9 @@ export async function updateSiteResource(
})) }))
); );
} }
}
if (roleIds !== undefined) {
// Get all admin role IDs for this org to exclude from deletion // Get all admin role IDs for this org to exclude from deletion
const adminRoles = await trx const adminRoles = await trx
.select() .select()
@@ -578,7 +606,10 @@ export async function updateSiteResource(
if (adminRoleIds.length > 0) { if (adminRoleIds.length > 0) {
await trx.delete(roleSiteResources).where( await trx.delete(roleSiteResources).where(
and( and(
eq(roleSiteResources.siteResourceId, siteResourceId), eq(
roleSiteResources.siteResourceId,
siteResourceId
),
ne(roleSiteResources.roleId, adminRoleIds[0]) // delete all but the admin role ne(roleSiteResources.roleId, adminRoleIds[0]) // delete all but the admin role
) )
); );
@@ -598,6 +629,7 @@ export async function updateSiteResource(
})) }))
); );
} }
}
logger.info(`Updated site resource ${siteResourceId}`); logger.info(`Updated site resource ${siteResourceId}`);
}); });
+3 -1
View File
@@ -26,6 +26,7 @@ import m17 from "./scriptsPg/1.18.0";
import m18 from "./scriptsPg/1.18.3"; import m18 from "./scriptsPg/1.18.3";
import m19 from "./scriptsPg/1.18.4"; import m19 from "./scriptsPg/1.18.4";
import m20 from "./scriptsPg/1.19.0"; import m20 from "./scriptsPg/1.19.0";
import m21 from "./scriptsPg/1.20.0";
// THIS CANNOT IMPORT ANYTHING FROM THE SERVER // THIS CANNOT IMPORT ANYTHING FROM THE SERVER
// EXCEPT FOR THE DATABASE AND THE SCHEMA // EXCEPT FOR THE DATABASE AND THE SCHEMA
@@ -51,7 +52,8 @@ const migrations = [
{ version: "1.18.0", run: m17 }, { version: "1.18.0", run: m17 },
{ version: "1.18.3", run: m18 }, { version: "1.18.3", run: m18 },
{ version: "1.18.4", run: m19 }, { version: "1.18.4", run: m19 },
{ version: "1.19.0", run: m20 } { version: "1.19.0", run: m20 },
{ version: "1.20.0", run: m21 }
// Add new migrations here as they are created // Add new migrations here as they are created
] as { ] as {
version: string; version: string;
+2 -2
View File
@@ -45,7 +45,7 @@ import m39 from "./scriptsSqlite/1.18.3";
import m40 from "./scriptsSqlite/1.18.4"; import m40 from "./scriptsSqlite/1.18.4";
import m41 from "./scriptsSqlite/1.19.0"; import m41 from "./scriptsSqlite/1.19.0";
import m42 from "./scriptsSqlite/1.19.1"; import m42 from "./scriptsSqlite/1.19.1";
import m43 from "./scriptsSqlite/1.19.5"; import m43 from "./scriptsSqlite/1.20.0";
// THIS CANNOT IMPORT ANYTHING FROM THE SERVER // THIS CANNOT IMPORT ANYTHING FROM THE SERVER
// EXCEPT FOR THE DATABASE AND THE SCHEMA // EXCEPT FOR THE DATABASE AND THE SCHEMA
@@ -89,7 +89,7 @@ const migrations = [
{ version: "1.18.4", run: m40 }, { version: "1.18.4", run: m40 },
{ version: "1.19.0", run: m41 }, { version: "1.19.0", run: m41 },
{ version: "1.19.1", run: m42 }, { version: "1.19.1", run: m42 },
{ version: "1.19.5", run: m43 } { version: "1.20.0", run: m43 }
// Add new migrations here as they are created // Add new migrations here as they are created
] as const; ] as const;
+104
View File
@@ -0,0 +1,104 @@
import { db } from "@server/db/pg/driver";
import { sql } from "drizzle-orm";
const version = "1.20.0";
export default async function migration() {
console.log(`Running setup script ${version}...`);
try {
await db.execute(sql`BEGIN`);
await db.execute(sql`
CREATE TABLE "remoteExitNodePreferenceLabels" (
"remoteExitNodePreferenceLabelId" serial PRIMARY KEY NOT NULL,
"remoteExitNodeId" varchar NOT NULL,
"labelId" integer NOT NULL,
CONSTRAINT "remote_exit_node_preference_label_uniq" UNIQUE("remoteExitNodeId","labelId")
);
`);
await db.execute(sql`
CREATE TABLE "remoteExitNodeResources" (
"remoteExitNodeResourceId" serial PRIMARY KEY NOT NULL,
"remoteExitNodeId" varchar NOT NULL,
"destination" varchar NOT NULL
);
`);
await db.execute(sql`
CREATE TABLE "launcherViews" (
"viewId" serial PRIMARY KEY NOT NULL,
"orgId" varchar NOT NULL,
"userId" varchar,
"name" varchar NOT NULL,
"config" text NOT NULL,
"isDefault" boolean DEFAULT false NOT NULL,
"createdAt" varchar NOT NULL,
"updatedAt" varchar NOT NULL
);
`);
await db.execute(
sql`ALTER TABLE "domains" ADD COLUMN "lastCheckedAt" integer;`
);
await db.execute(sql`
ALTER TABLE "remoteExitNodePreferenceLabels" ADD CONSTRAINT "remoteExitNodePreferenceLabels_remoteExitNodeId_remoteExitNode_id_fk" FOREIGN KEY ("remoteExitNodeId") REFERENCES "public"."remoteExitNode"("id") ON DELETE cascade ON UPDATE no action;
`);
await db.execute(sql`
ALTER TABLE "remoteExitNodePreferenceLabels" ADD CONSTRAINT "remoteExitNodePreferenceLabels_labelId_labels_labelId_fk" FOREIGN KEY ("labelId") REFERENCES "public"."labels"("labelId") ON DELETE cascade ON UPDATE no action;
`);
await db.execute(sql`
ALTER TABLE "remoteExitNodeResources" ADD CONSTRAINT "remoteExitNodeResources_remoteExitNodeId_remoteExitNode_id_fk" FOREIGN KEY ("remoteExitNodeId") REFERENCES "public"."remoteExitNode"("id") ON DELETE cascade ON UPDATE no action;
`);
await db.execute(sql`
ALTER TABLE "launcherViews" ADD CONSTRAINT "launcherViews_orgId_orgs_orgId_fk" FOREIGN KEY ("orgId") REFERENCES "public"."orgs"("orgId") ON DELETE cascade ON UPDATE no action;
`);
await db.execute(sql`
ALTER TABLE "launcherViews" ADD CONSTRAINT "launcherViews_userId_user_id_fk" FOREIGN KEY ("userId") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;
`);
await db.execute(sql`
CREATE INDEX "idx_clients_orgid_niceid" ON "clients" USING btree ("orgId","niceId");
`);
await db.execute(sql`
CREATE INDEX "idx_networks_orgid" ON "networks" USING btree ("orgId");
`);
await db.execute(sql`
CREATE INDEX "idx_resourcepolicies_orgid_niceid" ON "resourcePolicies" USING btree ("orgId","niceId");
`);
await db.execute(sql`
CREATE INDEX "idx_resources_niceid" ON "resources" USING btree ("niceId");
`);
await db.execute(sql`
CREATE INDEX "idx_resources_orgid_niceid" ON "resources" USING btree ("orgId","niceId");
`);
await db.execute(sql`
CREATE INDEX "idx_siteresources_orgid_niceid" ON "siteResources" USING btree ("orgId","niceId");
`);
await db.execute(sql`
CREATE INDEX "idx_sites_orgid_niceid" ON "sites" USING btree ("orgId","niceId");
`);
await db.execute(sql`COMMIT`);
console.log("Migrated database");
} catch (e) {
await db.execute(sql`ROLLBACK`);
console.log("Unable to migrate database");
console.log(e);
throw e;
}
console.log(`${version} migration complete`);
}
@@ -2,7 +2,7 @@ import { APP_PATH } from "@server/lib/consts";
import Database from "better-sqlite3"; import Database from "better-sqlite3";
import path from "path"; import path from "path";
const version = "1.19.5"; const version = "1.20.0";
export default async function migration() { export default async function migration() {
console.log(`Running setup script ${version}...`); console.log(`Running setup script ${version}...`);
@@ -92,6 +92,64 @@ export default async function migration() {
db.prepare( db.prepare(
`ALTER TABLE 'resourceSessions_new' RENAME TO 'resourceSessions';` `ALTER TABLE 'resourceSessions_new' RENAME TO 'resourceSessions';`
).run(); ).run();
db.prepare(
`
CREATE TABLE 'remoteExitNodePreferenceLabels' (
'remoteExitNodePreferenceLabelId' integer PRIMARY KEY AUTOINCREMENT NOT NULL,
'remoteExitNodeId' text NOT NULL,
'labelId' integer NOT NULL,
FOREIGN KEY ('remoteExitNodeId') REFERENCES 'remoteExitNode'('id') ON UPDATE no action ON DELETE cascade,
FOREIGN KEY ('labelId') REFERENCES 'labels'('labelId') ON UPDATE no action ON DELETE cascade
);
`
).run();
db.prepare(
`
CREATE UNIQUE INDEX 'remote_exit_node_preference_label_uniq' ON 'remoteExitNodePreferenceLabels' ('remoteExitNodeId','labelId');
`
).run();
db.prepare(
`
CREATE TABLE 'remoteExitNodeResources' (
'remoteExitNodeResourceId' integer PRIMARY KEY AUTOINCREMENT NOT NULL,
'remoteExitNodeId' text NOT NULL,
'destination' text NOT NULL,
FOREIGN KEY ('remoteExitNodeId') REFERENCES 'remoteExitNode'('id') ON UPDATE no action ON DELETE cascade
);
`
).run();
db.prepare(
`
CREATE TABLE 'launcherViews' (
'viewId' integer PRIMARY KEY AUTOINCREMENT NOT NULL,
'orgId' text NOT NULL,
'userId' text,
'name' text NOT NULL,
'config' text NOT NULL,
'isDefault' integer DEFAULT false NOT NULL,
'createdAt' text NOT NULL,
'updatedAt' text NOT NULL,
FOREIGN KEY ('orgId') REFERENCES 'orgs'('orgId') ON UPDATE no action ON DELETE cascade,
FOREIGN KEY ('userId') REFERENCES 'user'('id') ON UPDATE no action ON DELETE cascade
);
`
).run();
db.prepare(
`
ALTER TABLE 'domains' ADD 'customCertResolver' text;
`
).run();
db.prepare(
`
ALTER TABLE 'domains' ADD 'lastCheckedAt' integer;
`
).run();
})(); })();
db.pragma("foreign_keys = ON"); db.pragma("foreign_keys = ON");
+9
View File
@@ -1,9 +1,11 @@
import { Layout } from "@app/components/Layout"; import { Layout } from "@app/components/Layout";
import ResourceLauncher from "@app/components/resource-launcher/ResourceLauncher"; import ResourceLauncher from "@app/components/resource-launcher/ResourceLauncher";
import { commandBarNavSections } from "@app/app/navigation";
import { internal } from "@app/lib/api"; import { internal } from "@app/lib/api";
import { authCookieHeader } from "@app/lib/api/cookies"; import { authCookieHeader } from "@app/lib/api/cookies";
import { fetchLauncherPageData } from "@app/lib/launcherServerData"; import { fetchLauncherPageData } from "@app/lib/launcherServerData";
import { verifySession } from "@app/lib/auth/verifySession"; import { verifySession } from "@app/lib/auth/verifySession";
import { pullEnv } from "@app/lib/pullEnv";
import UserProvider from "@app/providers/UserProvider"; import UserProvider from "@app/providers/UserProvider";
import { ListUserOrgsResponse } from "@server/routers/org"; import { ListUserOrgsResponse } from "@server/routers/org";
import { GetOrgOverviewResponse } from "@server/routers/org/getOrgOverview"; import { GetOrgOverviewResponse } from "@server/routers/org/getOrgOverview";
@@ -57,6 +59,8 @@ export default async function OrgPage(props: OrgPageProps) {
} catch (e) {} } catch (e) {}
const isAdminOrOwner = Boolean(overview?.isAdmin || overview?.isOwner); const isAdminOrOwner = Boolean(overview?.isAdmin || overview?.isOwner);
const env = pullEnv();
const primaryOrg = orgs.find((o) => o.orgId === orgId)?.isPrimaryOrg;
const searchParams = new URLSearchParams(await props.searchParams); const searchParams = new URLSearchParams(await props.searchParams);
const launcherData = overview const launcherData = overview
@@ -73,6 +77,9 @@ export default async function OrgPage(props: OrgPageProps) {
orgId={orgId} orgId={orgId}
orgs={orgs} orgs={orgs}
navItems={[]} navItems={[]}
commandNavItems={commandBarNavSections(env, {
isPrimaryOrg: primaryOrg
})}
showSidebar={false} showSidebar={false}
launcherMode launcherMode
showViewAsAdmin={isAdminOrOwner} showViewAsAdmin={isAdminOrOwner}
@@ -82,9 +89,11 @@ export default async function OrgPage(props: OrgPageProps) {
orgId={orgId} orgId={orgId}
isAdmin={isAdminOrOwner} isAdmin={isAdminOrOwner}
views={launcherData.views} views={launcherData.views}
defaultViewOverrides={launcherData.defaultViewOverrides}
activeViewId={launcherData.activeViewId} activeViewId={launcherData.activeViewId}
config={launcherData.config} config={launcherData.config}
savedConfig={launcherData.savedConfig} savedConfig={launcherData.savedConfig}
scale={launcherData.scale}
groups={launcherData.groups} groups={launcherData.groups}
groupsPagination={launcherData.groupsPagination} groupsPagination={launcherData.groupsPagination}
/> />
@@ -290,7 +290,13 @@ export default function BillingPage() {
setHasSubscription( setHasSubscription(
tierSub.subscription.status === "active" tierSub.subscription.status === "active"
); );
setIsTrial(tierSub.subscription.expiresAt != null); // expiresAt is only meaningful while the trial hasn't
// actually run out yet; a stale row with a past
// expiresAt should no longer be treated as a live trial
const expiresAt = tierSub.subscription.expiresAt;
setIsTrial(
expiresAt != null && expiresAt * 1000 > Date.now()
);
} }
// Find license subscription // Find license subscription
@@ -111,7 +111,7 @@ export default function AccessControlsPage() {
if (values.roles.length === 0) { if (values.roles.length === 0) {
toast({ toast({
variant: "destructive", variant: "destructive",
title: t("accessRoleErrorAdd"), title: t("accessRoleRequired"),
description: t("accessRoleSelectPlease") description: t("accessRoleSelectPlease")
}); });
return; return;
@@ -173,15 +173,13 @@ export default function AccessControlsPage() {
if (values.roles.length === 0) { if (values.roles.length === 0) {
toast({ toast({
variant: "destructive", variant: "destructive",
title: t("accessRoleErrorAdd"), title: t("accessRoleRequired"),
description: t("accessRoleSelectPlease") description: t("accessRoleSelectPlease")
}); });
return; return;
} }
const willHaveAdminRole = values.roles.some( const willHaveAdminRole = values.roles.some((r) => r.isAdmin === true);
(r) => r.isAdmin === true
);
const isRemovingOwnAdmin = const isRemovingOwnAdmin =
sessionUser.userId === user.userId && sessionUser.userId === user.userId &&
@@ -226,7 +224,9 @@ export default function AccessControlsPage() {
<SettingsSectionForm> <SettingsSectionForm>
<Form {...form}> <Form {...form}>
<form <form
onSubmit={(e) => void handleAccessControlsSubmit(e)} onSubmit={(e) =>
void handleAccessControlsSubmit(e)
}
className="space-y-4" className="space-y-4"
id="access-controls-form" id="access-controls-form"
> >
@@ -195,9 +195,9 @@ export default function GeneralPage() {
if (agent in latestPlatformVersions) { if (agent in latestPlatformVersions) {
const agentVersion = latestPlatformVersions[agent]; const agentVersion = latestPlatformVersions[agent];
updateAvailable = semver.lt( updateAvailable = Boolean(
client.olmVersion, semver.valid(client.olmVersion) &&
agentVersion.latestVersion semver.lt(client.olmVersion, agentVersion.latestVersion)
); );
} }
} }
@@ -3,9 +3,7 @@ import { authCookieHeader } from "@app/lib/api/cookies";
import { ListOrgLabelsResponse } from "@server/routers/labels/types"; import { ListOrgLabelsResponse } from "@server/routers/labels/types";
import { AxiosResponse } from "axios"; import { AxiosResponse } from "axios";
import OrgLabelsTable from "@app/components/OrgLabelsTable"; import OrgLabelsTable from "@app/components/OrgLabelsTable";
import { PaidFeaturesAlert } from "@app/components/PaidFeaturesAlert";
import SettingsSectionTitle from "@app/components/SettingsSectionTitle"; import SettingsSectionTitle from "@app/components/SettingsSectionTitle";
import { tierMatrix } from "@server/lib/billing/tierMatrix";
import type { Metadata } from "next"; import type { Metadata } from "next";
import { getTranslations } from "next-intl/server"; import { getTranslations } from "next-intl/server";
@@ -51,8 +49,6 @@ export default async function LabelsPage({ params, searchParams }: Props) {
description={t("orgLabelsDescription")} description={t("orgLabelsDescription")}
/> />
<PaidFeaturesAlert tiers={tierMatrix.labels} />
<OrgLabelsTable <OrgLabelsTable
labels={labels} labels={labels}
orgId={orgId} orgId={orgId}
+4 -1
View File
@@ -12,7 +12,7 @@ import UserProvider from "@app/providers/UserProvider";
import { Layout } from "@app/components/Layout"; import { Layout } from "@app/components/Layout";
import { getTranslations } from "next-intl/server"; import { getTranslations } from "next-intl/server";
import { pullEnv } from "@app/lib/pullEnv"; import { pullEnv } from "@app/lib/pullEnv";
import { orgNavSections } from "@app/app/navigation"; import { commandBarNavSections, orgNavSections } from "@app/app/navigation";
import { getCachedOrgUser } from "@app/lib/api/getCachedOrgUser"; import { getCachedOrgUser } from "@app/lib/api/getCachedOrgUser";
export const dynamic = "force-dynamic"; export const dynamic = "force-dynamic";
@@ -82,6 +82,9 @@ export default async function SettingsLayout(props: SettingsLayoutProps) {
navItems={orgNavSections(env, { navItems={orgNavSections(env, {
isPrimaryOrg: primaryOrg isPrimaryOrg: primaryOrg
})} })}
commandNavItems={commandBarNavSections(env, {
isPrimaryOrg: primaryOrg
})}
> >
{children} {children}
</Layout> </Layout>
@@ -15,6 +15,7 @@ import { ColumnFilterButton } from "@app/components/ColumnFilterButton";
import SettingsSectionTitle from "@app/components/SettingsSectionTitle"; import SettingsSectionTitle from "@app/components/SettingsSectionTitle";
import { build } from "@server/build"; import { build } from "@server/build";
import { getSevenDaysAgo } from "@app/lib/getSevenDaysAgo"; import { getSevenDaysAgo } from "@app/lib/getSevenDaysAgo";
import { getPrivateResourceSettingsHref } from "@app/lib/launcherResourceAdminHref";
import axios from "axios"; import axios from "axios";
import { useStoredPageSize } from "@app/hooks/useStoredPageSize"; import { useStoredPageSize } from "@app/hooks/useStoredPageSize";
import { PaidFeaturesAlert } from "@app/components/PaidFeaturesAlert"; import { PaidFeaturesAlert } from "@app/components/PaidFeaturesAlert";
@@ -343,7 +344,10 @@ export default function GeneralPage() {
<Link <Link
href={ href={
row.original.siteResourceId != null row.original.siteResourceId != null
? `/${row.original.orgId}/settings/resources/private?query=${row.original.resourceNiceId}` ? getPrivateResourceSettingsHref(
row.original.orgId,
row.original.resourceNiceId
)
: `/${row.original.orgId}/settings/resources/public/${row.original.resourceNiceId}` : `/${row.original.orgId}/settings/resources/public/${row.original.resourceNiceId}`
} }
> >
@@ -11,6 +11,7 @@ import { useStoredPageSize } from "@app/hooks/useStoredPageSize";
import { toast } from "@app/hooks/useToast"; import { toast } from "@app/hooks/useToast";
import { createApiClient } from "@app/lib/api"; import { createApiClient } from "@app/lib/api";
import { getSevenDaysAgo } from "@app/lib/getSevenDaysAgo"; import { getSevenDaysAgo } from "@app/lib/getSevenDaysAgo";
import { getPrivateResourceSettingsHref } from "@app/lib/launcherResourceAdminHref";
import { logQueries } from "@app/lib/queries"; import { logQueries } from "@app/lib/queries";
import { build } from "@server/build"; import { build } from "@server/build";
import { tierMatrix } from "@server/lib/billing/tierMatrix"; import { tierMatrix } from "@server/lib/billing/tierMatrix";
@@ -323,7 +324,10 @@ export default function ConnectionLogsPage() {
if (row.original.resourceName && row.original.resourceNiceId) { if (row.original.resourceName && row.original.resourceNiceId) {
return ( return (
<Link <Link
href={`/${row.original.orgId}/settings/resources/private/?query=${row.original.resourceNiceId}`} href={getPrivateResourceSettingsHref(
row.original.orgId,
row.original.resourceNiceId
)}
> >
<Button variant="outline" size="sm"> <Button variant="outline" size="sm">
{row.original.resourceName} {row.original.resourceName}
@@ -9,6 +9,7 @@ import { toast } from "@app/hooks/useToast";
import { createApiClient } from "@app/lib/api"; import { createApiClient } from "@app/lib/api";
import { useTranslations } from "next-intl"; import { useTranslations } from "next-intl";
import { getSevenDaysAgo } from "@app/lib/getSevenDaysAgo"; import { getSevenDaysAgo } from "@app/lib/getSevenDaysAgo";
import { getPrivateResourceSettingsHref } from "@app/lib/launcherResourceAdminHref";
import { logQueries } from "@app/lib/queries"; import { logQueries } from "@app/lib/queries";
import { ColumnDef } from "@tanstack/react-table"; import { ColumnDef } from "@tanstack/react-table";
import { useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
@@ -395,7 +396,10 @@ export default function GeneralPage() {
<Link <Link
href={ href={
row.original.reason == 108 // for now the client will only have reason 108 so we know where to go row.original.reason == 108 // for now the client will only have reason 108 so we know where to go
? `/${row.original.orgId}/settings/resources/private?query=${row.original.resourceNiceId}` ? getPrivateResourceSettingsHref(
row.original.orgId,
row.original.resourceNiceId
)
: `/${row.original.orgId}/settings/resources/public/${row.original.resourceNiceId}` : `/${row.original.orgId}/settings/resources/public/${row.original.resourceNiceId}`
} }
onClick={(e) => e.stopPropagation()} onClick={(e) => e.stopPropagation()}
@@ -0,0 +1,112 @@
"use client";
import { MachinesSelector } from "@app/components/machines-selector";
import { RolesSelector } from "@app/components/roles-selector";
import { UsersSelector } from "@app/components/users-selector";
import { SettingsFormCell, SettingsFormGrid } from "@app/components/Settings";
import type { Tag } from "@app/components/tags/tag-input";
import {
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage
} from "@app/components/ui/form";
import type { PrivateResourceClient } from "@app/lib/privateResourceForm";
import { useTranslations } from "next-intl";
import type { Control } from "react-hook-form";
type AccessFormValues = {
roles?: Tag[];
users?: Tag[];
clients?: PrivateResourceClient[];
};
type PrivateResourceAccessFieldsProps = {
control: Control<AccessFormValues>;
orgId: string;
loading?: boolean;
hasMachineClients?: boolean;
};
export function PrivateResourceAccessFields({
control,
orgId,
loading = false,
hasMachineClients = false
}: PrivateResourceAccessFieldsProps) {
const t = useTranslations();
if (loading) {
return (
<div className="text-sm text-muted-foreground">{t("loading")}</div>
);
}
return (
<SettingsFormGrid>
<SettingsFormCell span="full">
<FormField
control={control}
name="roles"
render={({ field }) => (
<FormItem className="flex flex-col items-start">
<FormLabel>{t("roles")}</FormLabel>
<FormControl>
<RolesSelector
selectedRoles={field.value ?? []}
orgId={orgId}
restrictAdminRole
onSelectRoles={(newRoles) => {
field.onChange(newRoles);
}}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</SettingsFormCell>
<SettingsFormCell span="full">
<FormField
control={control}
name="users"
render={({ field }) => (
<FormItem className="flex flex-col items-start">
<FormLabel>{t("users")}</FormLabel>
<UsersSelector
selectedUsers={field.value ?? []}
orgId={orgId}
onSelectUsers={(newUsers) => {
field.onChange(newUsers);
}}
/>
<FormMessage />
</FormItem>
)}
/>
</SettingsFormCell>
{hasMachineClients && (
<SettingsFormCell span="full">
<FormField
control={control}
name="clients"
render={({ field }) => (
<FormItem className="flex flex-col items-start">
<FormLabel>{t("machineClients")}</FormLabel>
<MachinesSelector
selectedMachines={field.value ?? []}
orgId={orgId}
onSelectMachines={(machines) => {
field.onChange(machines);
}}
/>
<FormMessage />
</FormItem>
)}
/>
</SettingsFormCell>
)}
</SettingsFormGrid>
);
}
@@ -0,0 +1,175 @@
"use client";
import { SettingsFormCell, SettingsFormGrid } from "@app/components/Settings";
import {
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage
} from "@app/components/ui/form";
import { Input } from "@app/components/ui/input";
import { useTranslations } from "next-intl";
import type { Control, UseFormWatch } from "react-hook-form";
type PrivateResourceAliasFieldProps = {
control: Control<any>;
watch: UseFormWatch<any>;
labelPrefix?: "create" | "edit";
disabled?: boolean;
};
export function PrivateResourceAliasField({
control,
watch,
labelPrefix = "edit",
disabled = false
}: PrivateResourceAliasFieldProps) {
const t = useTranslations();
const aliasLabelKey =
labelPrefix === "create"
? "createInternalResourceDialogAlias"
: "editInternalResourceDialogAlias";
const aliasDescriptionKey =
labelPrefix === "create"
? "createInternalResourceDialogAliasDescription"
: "editInternalResourceDialogAliasDescription";
const aliasValue = watch("alias");
const aliasEndsWithLocal =
typeof aliasValue === "string" &&
aliasValue.trim().toLowerCase().endsWith(".local");
return (
<FormField
control={control}
name="alias"
render={({ field }) => (
<FormItem>
<FormLabel>{t(aliasLabelKey)}</FormLabel>
<FormControl>
<Input
{...field}
className="w-full"
value={field.value ?? ""}
disabled={disabled}
/>
</FormControl>
{aliasEndsWithLocal && (
<p className="text-xs text-amber-700/80 mt-1">
{t("internalResourceAliasLocalWarning")}
</p>
)}
<FormMessage />
<FormDescription>{t(aliasDescriptionKey)}</FormDescription>
</FormItem>
)}
/>
);
}
type PrivateResourceHostDestinationFieldsProps = {
control: Control<any>;
watch: UseFormWatch<any>;
labelPrefix?: "create" | "edit";
hideAlias?: boolean;
};
export function PrivateResourceHostDestinationFields({
control,
watch,
labelPrefix = "edit",
hideAlias = false
}: PrivateResourceHostDestinationFieldsProps) {
const t = useTranslations();
const destinationLabelKey =
labelPrefix === "create"
? "createInternalResourceDialogDestination"
: "editInternalResourceDialogDestination";
const destinationField = (
<FormField
control={control}
name="destination"
render={({ field }) => (
<FormItem>
<FormLabel>{t(destinationLabelKey)}</FormLabel>
<FormControl>
<Input
{...field}
className="w-full"
value={field.value ?? ""}
onChange={(e) =>
field.onChange(
e.target.value === ""
? null
: e.target.value
)
}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
);
if (hideAlias) {
return destinationField;
}
return (
<SettingsFormGrid>
<SettingsFormCell span="half">{destinationField}</SettingsFormCell>
<SettingsFormCell span="half">
<PrivateResourceAliasField
control={control}
watch={watch}
labelPrefix={labelPrefix}
/>
</SettingsFormCell>
</SettingsFormGrid>
);
}
export function PrivateResourceCidrDestinationField({
control,
labelPrefix = "edit"
}: {
control: Control<any>;
labelPrefix?: "create" | "edit";
}) {
const t = useTranslations();
const destinationLabelKey =
labelPrefix === "create"
? "createInternalResourceDialogDestination"
: "editInternalResourceDialogDestination";
return (
<FormField
control={control}
name="destination"
render={({ field }) => (
<FormItem>
<FormLabel>{t(destinationLabelKey)}</FormLabel>
<FormControl>
<Input
{...field}
className="w-full"
value={field.value ?? ""}
onChange={(e) =>
field.onChange(
e.target.value === ""
? null
: e.target.value
)
}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
);
}
@@ -0,0 +1,297 @@
"use client";
import DomainPicker from "@app/components/DomainPicker";
import { PaidFeaturesAlert } from "@app/components/PaidFeaturesAlert";
import {
SettingsFormCell,
SettingsFormGrid,
SettingsSubsectionDescription,
SettingsSubsectionHeader,
SettingsSubsectionTitle
} from "@app/components/Settings";
import { SwitchInput } from "@app/components/SwitchInput";
import {
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage
} from "@app/components/ui/form";
import { Input } from "@app/components/ui/input";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue
} from "@app/components/ui/select";
import { tierMatrix } from "@server/lib/billing/tierMatrix";
import { useTranslations } from "next-intl";
import type { Control, UseFormSetValue, UseFormWatch } from "react-hook-form";
type PrivateResourceHttpFieldsProps = {
control: Control<any>;
setValue: UseFormSetValue<any>;
orgId: string;
watch: UseFormWatch<any>;
disabled?: boolean;
siteResourceId?: number;
labelPrefix?: "create" | "edit";
hideDomainPicker?: boolean;
hidePaidFeaturesAlert?: boolean;
};
export function PrivateResourceHttpFields({
control,
setValue,
orgId,
watch,
disabled = false,
siteResourceId,
labelPrefix = "edit",
hideDomainPicker = false,
hidePaidFeaturesAlert = false
}: PrivateResourceHttpFieldsProps) {
const t = useTranslations();
const schemeLabelKey =
labelPrefix === "create"
? "createInternalResourceDialogScheme"
: "editInternalResourceDialogScheme";
const destinationLabelKey =
labelPrefix === "create"
? "createInternalResourceDialogDestination"
: "editInternalResourceDialogDestination";
const destinationPortLabelKey =
labelPrefix === "create"
? "createInternalResourceDialogModePort"
: "editInternalResourceDialogModePort";
const httpConfigurationTitleKey =
labelPrefix === "create"
? "createInternalResourceDialogHttpConfiguration"
: "editInternalResourceDialogHttpConfiguration";
const httpConfigurationDescriptionKey =
labelPrefix === "create"
? "createInternalResourceDialogHttpConfigurationDescription"
: "editInternalResourceDialogHttpConfigurationDescription";
const enableSslLabelKey =
labelPrefix === "create"
? "createInternalResourceDialogEnableSsl"
: "editInternalResourceDialogEnableSsl";
const enableSslDescriptionKey =
labelPrefix === "create"
? "createInternalResourceDialogEnableSslDescription"
: "editInternalResourceDialogEnableSslDescription";
const httpConfigSubdomain = watch("httpConfigSubdomain");
const httpConfigDomainId = watch("httpConfigDomainId");
const httpConfigFullDomain = watch("httpConfigFullDomain");
return (
<SettingsFormGrid>
{!hidePaidFeaturesAlert && (
<SettingsFormCell span="full">
<PaidFeaturesAlert
tiers={tierMatrix.advancedPrivateResources}
/>
</SettingsFormCell>
)}
<SettingsFormCell span="quarter">
<FormField
control={control}
name="scheme"
render={({ field }) => (
<FormItem>
<FormLabel>{t(schemeLabelKey)}</FormLabel>
<Select
onValueChange={field.onChange}
value={field.value ?? "http"}
disabled={disabled}
>
<FormControl>
<SelectTrigger className="w-full">
<SelectValue />
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem value="http">http</SelectItem>
<SelectItem value="https">https</SelectItem>
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
</SettingsFormCell>
<SettingsFormCell span="half">
<FormField
control={control}
name="destination"
render={({ field }) => (
<FormItem>
<FormLabel>{t(destinationLabelKey)}</FormLabel>
<FormControl>
<Input
{...field}
className="w-full"
value={field.value ?? ""}
disabled={disabled}
onChange={(e) =>
field.onChange(
e.target.value === ""
? null
: e.target.value
)
}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</SettingsFormCell>
<SettingsFormCell span="quarter">
<FormField
control={control}
name="destinationPort"
render={({ field }) => (
<FormItem>
<FormLabel>{t(destinationPortLabelKey)}</FormLabel>
<FormControl>
<Input
className="w-full"
type="number"
min={1}
max={65535}
value={field.value ?? ""}
disabled={disabled}
onChange={(e) => {
const raw = e.target.value;
if (raw === "") {
field.onChange(null);
return;
}
const n = Number(raw);
field.onChange(
Number.isFinite(n) ? n : null
);
}}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</SettingsFormCell>
{!hideDomainPicker && (
<>
<SettingsFormCell span="full">
<SettingsSubsectionHeader>
<SettingsSubsectionTitle>
{t(httpConfigurationTitleKey)}
</SettingsSubsectionTitle>
<SettingsSubsectionDescription>
{t(httpConfigurationDescriptionKey)}
</SettingsSubsectionDescription>
</SettingsSubsectionHeader>
</SettingsFormCell>
<SettingsFormCell span="full">
<div
className={
disabled
? "pointer-events-none opacity-50"
: undefined
}
>
<DomainPicker
key={
siteResourceId
? `http-domain-${siteResourceId}`
: "http-domain-create"
}
orgId={orgId}
cols={2}
hideFreeDomain
defaultSubdomain={
httpConfigSubdomain ?? undefined
}
defaultDomainId={
httpConfigDomainId ?? undefined
}
defaultFullDomain={
httpConfigFullDomain ?? undefined
}
onDomainChange={(res) => {
if (res === null) {
setValue("httpConfigSubdomain", null);
setValue("httpConfigDomainId", null);
setValue("httpConfigFullDomain", null);
return;
}
setValue(
"httpConfigSubdomain",
res.subdomain ?? null
);
setValue(
"httpConfigDomainId",
res.domainId
);
setValue(
"httpConfigFullDomain",
res.fullDomain
);
}}
/>
</div>
</SettingsFormCell>
<SettingsFormCell span="half">
<FormField
control={control}
name="ssl"
render={({ field }) => (
<FormItem>
<FormControl>
<SwitchInput
id="private-resource-ssl"
label={t(enableSslLabelKey)}
description={t(
enableSslDescriptionKey
)}
checked={!!field.value}
onCheckedChange={field.onChange}
disabled={disabled}
/>
</FormControl>
</FormItem>
)}
/>
</SettingsFormCell>
</>
)}
{hideDomainPicker && (
<SettingsFormCell span="half">
<FormField
control={control}
name="ssl"
render={({ field }) => (
<FormItem>
<FormControl>
<SwitchInput
id="private-resource-ssl"
label={t(enableSslLabelKey)}
description={t(enableSslDescriptionKey)}
checked={!!field.value}
onCheckedChange={field.onChange}
disabled={disabled}
/>
</FormControl>
</FormItem>
)}
/>
</SettingsFormCell>
)}
</SettingsFormGrid>
);
}
@@ -0,0 +1,24 @@
"use client";
import { ExternalLink } from "lucide-react";
import { useTranslations } from "next-intl";
export function PrivateResourceMultiSiteRoutingHelp() {
const t = useTranslations();
return (
<p className="text-sm text-muted-foreground mt-2">
{t("internalResourceFormMultiSiteRoutingHelp")}{" "}
<a
href="https://docs.pangolin.net/manage/resources/private/multi-site-routing"
target="_blank"
rel="noopener noreferrer"
className="text-primary hover:underline inline-flex items-center gap-1"
>
{t("internalResourceFormMultiSiteRoutingHelpLearnMore")}
<ExternalLink className="size-3.5 shrink-0" />
</a>
.
</p>
);
}
@@ -0,0 +1,300 @@
"use client";
import {
SettingsFormCell,
SettingsFormGrid,
SettingsSubsectionDescription,
SettingsSubsectionHeader,
SettingsSubsectionTitle
} from "@app/components/Settings";
import { SwitchInput } from "@app/components/SwitchInput";
import {
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage
} from "@app/components/ui/form";
import { Input } from "@app/components/ui/input";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue
} from "@app/components/ui/select";
import {
getPortModeFromString,
getPortStringFromMode,
type PortMode
} from "@app/lib/privateResourceForm";
import { useTranslations } from "next-intl";
import { useEffect, useState, type ReactNode } from "react";
import type { Control, UseFormSetValue } from "react-hook-form";
type PrivateResourceNetworkAccessFieldsProps = {
control: Control<any>;
setValue: UseFormSetValue<any>;
showPortRanges?: boolean;
initialTcp?: string | null;
initialUdp?: string | null;
disabled?: boolean;
icmpId?: string;
embedInParentGrid?: boolean;
};
export function PrivateResourceAllowIcmpField({
control,
id = "private-resource-allow-icmp",
disabled = false
}: {
control: Control<any>;
id?: string;
disabled?: boolean;
}) {
const t = useTranslations();
return (
<FormField
control={control}
name="disableIcmp"
render={({ field }) => (
<FormItem>
<FormControl>
<SwitchInput
id={id}
label={t("privateResourceAllowIcmpPing")}
checked={!field.value}
onCheckedChange={(checked) =>
field.onChange(!checked)
}
disabled={disabled}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
);
}
function PrivateResourceNetworkAccessHeader() {
const t = useTranslations();
return (
<SettingsFormCell span="full">
<SettingsSubsectionHeader>
<SettingsSubsectionTitle>
{t("privateResourceNetworkAccess")}
</SettingsSubsectionTitle>
<SettingsSubsectionDescription>
{t("privateResourceNetworkAccessDescription")}
</SettingsSubsectionDescription>
</SettingsSubsectionHeader>
</SettingsFormCell>
);
}
export function PrivateResourceNetworkAccessFields({
control,
setValue,
showPortRanges = true,
initialTcp,
initialUdp,
disabled = false,
icmpId = "private-resource-allow-icmp",
embedInParentGrid = false
}: PrivateResourceNetworkAccessFieldsProps) {
const t = useTranslations();
const [tcpPortMode, setTcpPortMode] = useState<PortMode>(() =>
getPortModeFromString(initialTcp)
);
const [udpPortMode, setUdpPortMode] = useState<PortMode>(() =>
getPortModeFromString(initialUdp)
);
const [tcpCustomPorts, setTcpCustomPorts] = useState(() =>
initialTcp && initialTcp !== "*" ? initialTcp : ""
);
const [udpCustomPorts, setUdpCustomPorts] = useState(() =>
initialUdp && initialUdp !== "*" ? initialUdp : ""
);
useEffect(() => {
if (!showPortRanges) return;
setValue(
"tcpPortRangeString",
getPortStringFromMode(tcpPortMode, tcpCustomPorts)
);
}, [showPortRanges, tcpPortMode, tcpCustomPorts, setValue]);
useEffect(() => {
if (!showPortRanges) return;
setValue(
"udpPortRangeString",
getPortStringFromMode(udpPortMode, udpCustomPorts)
);
}, [showPortRanges, udpPortMode, udpCustomPorts, setValue]);
const content: ReactNode = (
<>
<PrivateResourceNetworkAccessHeader />
{showPortRanges ? (
<>
<SettingsFormCell span="full">
<FormField
control={control}
name="tcpPortRangeString"
render={() => (
<FormItem>
<FormLabel>
{t("editInternalResourceDialogTcp")}
</FormLabel>
<div className="flex items-center gap-2">
<Select
value={tcpPortMode}
onValueChange={(v: PortMode) =>
setTcpPortMode(v)
}
>
<FormControl>
<SelectTrigger className="w-[110px]">
<SelectValue />
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem value="all">
{t("allPorts")}
</SelectItem>
<SelectItem value="blocked">
{t("blocked")}
</SelectItem>
<SelectItem value="custom">
{t("custom")}
</SelectItem>
</SelectContent>
</Select>
{tcpPortMode === "custom" ? (
<FormControl>
<Input
className="flex-1"
placeholder="80,443,8000-9000"
value={tcpCustomPorts}
onChange={(e) =>
setTcpCustomPorts(
e.target.value
)
}
/>
</FormControl>
) : (
<Input
className="flex-1"
disabled
placeholder={
tcpPortMode === "all"
? t("allPortsAllowed")
: t("allPortsBlocked")
}
/>
)}
</div>
<FormMessage />
</FormItem>
)}
/>
</SettingsFormCell>
<SettingsFormCell span="full">
<FormField
control={control}
name="udpPortRangeString"
render={() => (
<FormItem>
<FormLabel>
{t("editInternalResourceDialogUdp")}
</FormLabel>
<div className="flex items-center gap-2">
<Select
value={udpPortMode}
onValueChange={(v: PortMode) =>
setUdpPortMode(v)
}
>
<FormControl>
<SelectTrigger className="w-[110px]">
<SelectValue />
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem value="all">
{t("allPorts")}
</SelectItem>
<SelectItem value="blocked">
{t("blocked")}
</SelectItem>
<SelectItem value="custom">
{t("custom")}
</SelectItem>
</SelectContent>
</Select>
{udpPortMode === "custom" ? (
<FormControl>
<Input
className="flex-1"
placeholder="53,123,500-600"
value={udpCustomPorts}
onChange={(e) =>
setUdpCustomPorts(
e.target.value
)
}
/>
</FormControl>
) : (
<Input
className="flex-1"
disabled
placeholder={
udpPortMode === "all"
? t("allPortsAllowed")
: t("allPortsBlocked")
}
/>
)}
</div>
<FormMessage />
</FormItem>
)}
/>
</SettingsFormCell>
</>
) : null}
<SettingsFormCell span="full">
<PrivateResourceAllowIcmpField
control={control}
id={icmpId}
disabled={disabled}
/>
</SettingsFormCell>
</>
);
if (embedInParentGrid) {
return content;
}
return <SettingsFormGrid>{content}</SettingsFormGrid>;
}
export function PrivateResourcePortRanges(
props: Omit<
PrivateResourceNetworkAccessFieldsProps,
"showPortRanges" | "embedInParentGrid"
>
) {
return <PrivateResourceNetworkAccessFields showPortRanges {...props} />;
}
@@ -0,0 +1,133 @@
"use client";
import {
MultiSitesSelector,
formatMultiSitesSelectorLabel
} from "@app/components/multi-site-selector";
import { SitesSelector } from "@app/components/site-selector";
import type { Selectedsite } from "@app/components/site-selector";
import { Button } from "@app/components/ui/button";
import {
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage
} from "@app/components/ui/form";
import { cn } from "@app/lib/cn";
import {
Popover,
PopoverContent,
PopoverTrigger
} from "@app/components/ui/popover";
import { ChevronsUpDown } from "lucide-react";
import { useTranslations } from "next-intl";
import type { Control, FieldPath, FieldValues } from "react-hook-form";
import { PrivateResourceMultiSiteRoutingHelp } from "./PrivateResourceMultiSiteRoutingHelp";
type PrivateResourceSitesFieldProps<T extends FieldValues> = {
control: Control<T>;
orgId: string;
selectedSites: Selectedsite[];
onSelectedSitesChange: (sites: Selectedsite[]) => void;
siteIdsFieldName?: FieldPath<T>;
singleSite?: boolean;
};
export function PrivateResourceSitesField<T extends FieldValues>({
control,
orgId,
selectedSites,
onSelectedSitesChange,
siteIdsFieldName = "siteIds" as FieldPath<T>,
singleSite = false
}: PrivateResourceSitesFieldProps<T>) {
const t = useTranslations();
return (
<FormField
control={control}
name={siteIdsFieldName}
render={({ field }) => (
<FormItem className="flex flex-col">
<FormLabel>{t("sites")}</FormLabel>
{singleSite ? (
<Popover>
<PopoverTrigger asChild>
<FormControl>
<Button
variant="outline"
role="combobox"
className={cn(
"w-full justify-between",
selectedSites.length === 0 &&
"text-muted-foreground"
)}
>
<span className="truncate text-left">
{selectedSites[0]?.name ??
t("selectSite")}
</span>
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</FormControl>
</PopoverTrigger>
<PopoverContent className="w-full p-0">
<SitesSelector
orgId={orgId}
selectedSite={selectedSites[0] ?? null}
filterTypes={["newt"]}
onSelectSite={(site) => {
onSelectedSitesChange([site]);
field.onChange([site.siteId]);
}}
/>
</PopoverContent>
</Popover>
) : (
<Popover>
<PopoverTrigger asChild>
<FormControl>
<Button
variant="outline"
role="combobox"
className={cn(
"w-full justify-between",
selectedSites.length === 0 &&
"text-muted-foreground"
)}
>
<span className="truncate text-left">
{formatMultiSitesSelectorLabel(
selectedSites,
t
)}
</span>
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</FormControl>
</PopoverTrigger>
<PopoverContent className="w-full p-0">
<MultiSitesSelector
orgId={orgId}
selectedSites={selectedSites}
filterTypes={["newt"]}
onSelectionChange={(sites) => {
onSelectedSitesChange(sites);
field.onChange(
sites.map((s) => s.siteId)
);
}}
/>
</PopoverContent>
</Popover>
)}
<FormMessage />
{!singleSite && selectedSites.length > 1 ? (
<PrivateResourceMultiSiteRoutingHelp />
) : null}
</FormItem>
)}
/>
);
}
@@ -0,0 +1,333 @@
"use client";
import {
SettingsFormCell,
SettingsFormGrid,
SettingsSubsectionDescription,
SettingsSubsectionHeader,
SettingsSubsectionTitle
} from "@app/components/Settings";
import { PaidFeaturesAlert } from "@app/components/PaidFeaturesAlert";
import { SshServerSettingsFields } from "@app/components/SshServerSettingsFields";
import { PrivateResourceAliasField } from "./PrivateResourceDestinationFields";
import { PrivateResourceSitesField } from "./PrivateResourceSitesField";
import { getSshUseMultiSiteTargetForm } from "./privateResourceUtils";
import { inferSshPamMode } from "@app/lib/privateResourceForm";
import {
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage
} from "@app/components/ui/form";
import { Input } from "@app/components/ui/input";
import { tierMatrix } from "@server/lib/billing/tierMatrix";
import { useTranslations } from "next-intl";
import { useState, type ReactNode } from "react";
import type { Control, UseFormSetValue, UseFormWatch } from "react-hook-form";
import type { Selectedsite } from "@app/components/site-selector";
type PrivateResourceSshFieldsProps = {
control: Control<any>;
setValue: UseFormSetValue<any>;
watch: UseFormWatch<any>;
orgId?: string;
disabled?: boolean;
selectedSites: Selectedsite[];
onSelectedSitesChange: (sites: Selectedsite[]) => void;
labelPrefix?: "create" | "edit";
showSshSettings?: boolean;
layout?: "default" | "wizard";
showPaidFeaturesAlert?: boolean;
hideAlias?: boolean;
embedInParentGrid?: boolean;
isNativeSsh?: boolean;
};
export function PrivateResourceSshFields({
control,
setValue,
watch,
orgId,
disabled = false,
selectedSites,
onSelectedSitesChange,
labelPrefix = "edit",
showSshSettings = true,
layout = "default",
showPaidFeaturesAlert = true,
hideAlias = false,
embedInParentGrid = false,
isNativeSsh: isNativeSshProp
}: PrivateResourceSshFieldsProps) {
const t = useTranslations();
const destinationLabelKey =
labelPrefix === "create"
? "createInternalResourceDialogDestination"
: "editInternalResourceDialogDestination";
const destinationPortLabelKey =
labelPrefix === "create"
? "createInternalResourceDialogModePort"
: "editInternalResourceDialogModePort";
const authDaemonMode = watch("authDaemonMode") ?? "site";
const pamMode = inferSshPamMode(authDaemonMode, watch("pamMode"));
const standardDaemonLocation =
watch("standardDaemonLocation") ??
(authDaemonMode === "remote" ? "remote" : "site");
const formAuthDaemonPort = watch("authDaemonPort");
const [authDaemonPortInput, setAuthDaemonPortInput] = useState(() =>
formAuthDaemonPort != null ? String(formAuthDaemonPort) : "22123"
);
const isEditLayout = layout === "default";
const [sshServerMode, setSshServerMode] = useState<"standard" | "native">(
() => (authDaemonMode === "native" ? "native" : "standard")
);
const isNative =
isNativeSshProp ??
(isEditLayout
? authDaemonMode === "native"
: sshServerMode === "native");
const useMultiSiteTargetForm = getSshUseMultiSiteTargetForm(
isNative,
authDaemonMode,
pamMode
);
function trimSitesToFirst() {
if (selectedSites.length <= 1) return;
const first = selectedSites.slice(0, 1);
onSelectedSitesChange(first);
setValue(
"siteIds",
first.map((s: Selectedsite) => s.siteId),
{ shouldValidate: true }
);
}
function handlePamModeChange(value: "passthrough" | "push") {
if (disabled) return;
setValue("pamMode", value, { shouldValidate: true });
if (value === "passthrough") {
setValue("authDaemonPort", null, { shouldValidate: true });
setAuthDaemonPortInput("22123");
return;
}
if (standardDaemonLocation !== "remote" && selectedSites.length > 1) {
trimSitesToFirst();
}
}
function handleDaemonLocationChange(value: "site" | "remote") {
if (disabled) return;
setValue("standardDaemonLocation", value, { shouldValidate: true });
setValue("authDaemonMode", value, { shouldValidate: true });
if (value === "site") {
setValue("authDaemonPort", null, { shouldValidate: true });
setAuthDaemonPortInput("22123");
trimSitesToFirst();
}
}
function handleAuthDaemonPortChange(value: string) {
if (disabled) return;
setAuthDaemonPortInput(value);
const trimmed = value.trim();
setValue("authDaemonPort", trimmed ? Number(trimmed) : null, {
shouldValidate: true
});
}
function handleServerModeChange(mode: "standard" | "native") {
if (disabled) return;
setSshServerMode(mode);
if (mode === "native") {
setValue("authDaemonMode", "native", { shouldValidate: true });
setValue("authDaemonPort", null, { shouldValidate: true });
setValue("destination", null, { shouldValidate: true });
setValue("destinationPort", null, { shouldValidate: true });
setAuthDaemonPortInput("22123");
trimSitesToFirst();
return;
}
setValue("authDaemonMode", standardDaemonLocation, {
shouldValidate: true
});
setValue("destinationPort", 22, { shouldValidate: true });
}
const aliasField = hideAlias ? null : (
<PrivateResourceAliasField
control={control}
watch={watch}
labelPrefix={labelPrefix}
disabled={disabled}
/>
);
const standardSshTargetRow =
orgId && !isNative ? (
<div className="grid grid-cols-3 gap-4 items-start">
<PrivateResourceSitesField
control={control}
orgId={orgId}
selectedSites={selectedSites}
onSelectedSitesChange={onSelectedSitesChange}
singleSite={!useMultiSiteTargetForm}
/>
<FormField
control={control}
name="destination"
render={({ field }) => (
<FormItem>
<FormLabel>{t(destinationLabelKey)}</FormLabel>
<FormControl>
<Input
{...field}
className="w-full"
value={field.value ?? ""}
disabled={disabled}
onChange={(e) =>
field.onChange(
e.target.value === ""
? null
: e.target.value
)
}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={control}
name="destinationPort"
render={({ field }) => (
<FormItem>
<FormLabel>{t(destinationPortLabelKey)}</FormLabel>
<FormControl>
<Input
className="w-full"
type="number"
min={1}
max={65535}
value={field.value ?? ""}
disabled={disabled}
onChange={(e) => {
const raw = e.target.value;
if (raw === "") {
field.onChange(null);
return;
}
const n = Number(raw);
field.onChange(
Number.isFinite(n) ? n : null
);
}}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
) : null;
const sshSettingsFields = showSshSettings ? (
<SshServerSettingsFields
idPrefix={
layout === "wizard"
? "private-ssh-create"
: "private-ssh-fields"
}
pamMode={pamMode}
standardDaemonLocation={standardDaemonLocation}
authDaemonPort={authDaemonPortInput}
onPamModeChange={handlePamModeChange}
onStandardDaemonLocationChange={handleDaemonLocationChange}
onAuthDaemonPortChange={handleAuthDaemonPortChange}
sshServerMode={sshServerMode}
serverModeDisplay={layout === "wizard" ? "select" : "badge"}
onServerModeChange={handleServerModeChange}
/>
) : null;
const destinationSection = (
<>
<SettingsFormCell span="full">
<SettingsSubsectionHeader>
<SettingsSubsectionTitle>
{t("sshServerDestination")}
</SettingsSubsectionTitle>
<SettingsSubsectionDescription>
{t("sshServerDestinationDescription")}
</SettingsSubsectionDescription>
</SettingsSubsectionHeader>
</SettingsFormCell>
{isNative && orgId ? (
<>
<SettingsFormCell span="half">
<PrivateResourceSitesField
control={control}
orgId={orgId}
selectedSites={selectedSites}
onSelectedSitesChange={onSelectedSitesChange}
singleSite
/>
</SettingsFormCell>
<SettingsFormCell span="half">
<PrivateResourceAliasField
control={control}
watch={watch}
labelPrefix={labelPrefix}
disabled={disabled}
/>
</SettingsFormCell>
</>
) : null}
{!isNative && orgId ? (
<SettingsFormCell span="full">
{standardSshTargetRow}
</SettingsFormCell>
) : null}
{!isNative && !hideAlias ? (
<SettingsFormCell span="half">{aliasField}</SettingsFormCell>
) : null}
</>
);
const content: ReactNode = (
<>
{showPaidFeaturesAlert && layout === "default" && (
<SettingsFormCell span="full">
<PaidFeaturesAlert
tiers={tierMatrix.advancedPrivateResources}
/>
</SettingsFormCell>
)}
{sshSettingsFields}
{destinationSection}
</>
);
if (embedInParentGrid) {
return content;
}
return <SettingsFormGrid>{content}</SettingsFormGrid>;
}
@@ -0,0 +1,170 @@
"use client";
import {
SettingsContainer,
SettingsSection,
SettingsSectionBody,
SettingsSectionDescription,
SettingsSectionFooter,
SettingsSectionForm,
SettingsSectionHeader,
SettingsSectionTitle
} from "@app/components/Settings";
import { Button } from "@app/components/ui/button";
import { Form } from "@app/components/ui/form";
import { useEnvContext } from "@app/hooks/useEnvContext";
import { useSiteResourceContext } from "@app/hooks/useSiteResourceContext";
import { toast } from "@app/hooks/useToast";
import { createApiClient, formatAxiosError } from "@app/lib/api";
import {
accessTagsToIds,
buildUpdateSiteResourcePayload,
createAccessFormSchema,
mergeFormValuesWithResource
} from "@app/lib/privateResourceForm";
import { resourceQueries, orgQueries } from "@app/lib/queries";
import { useAccessFormDefaults } from "@app/providers/SiteResourceProvider";
import { zodResolver } from "@hookform/resolvers/zod";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { useTranslations } from "next-intl";
import { useActionState, useEffect } from "react";
import { useForm } from "react-hook-form";
import { PrivateResourceAccessFields } from "../../PrivateResourceAccessFields";
export default function PrivateResourceAccessPage() {
const t = useTranslations();
const { env } = useEnvContext();
const api = createApiClient({ env });
const queryClient = useQueryClient();
const { siteResource, setAccess } = useSiteResourceContext();
const { loading, roles, users, clients, hasMachineClients } =
useAccessFormDefaults(siteResource.orgId, siteResource.id);
const machineClientsQuery = useQuery(
orgQueries.machineClients({
orgId: siteResource.orgId,
perPage: 1
})
);
const hasMachineClientsResolved =
(machineClientsQuery.data ?? []).filter((c) => !c.userId).length > 0;
const form = useForm({
resolver: zodResolver(createAccessFormSchema()),
defaultValues: {
roles: [] as typeof roles,
users: [] as typeof users,
clients: [] as typeof clients
}
});
useEffect(() => {
if (!loading) {
form.reset({ roles, users, clients });
}
}, [loading, roles, users, clients, form]);
const [, formAction, saveLoading] = useActionState(async () => {
const isValid = await form.trigger();
if (!isValid) return;
const data = form.getValues();
const access = accessTagsToIds({
roles: data.roles,
users: data.users,
clients: data.clients
});
const merged = mergeFormValuesWithResource(siteResource, {});
const payload = buildUpdateSiteResourcePayload(merged, access);
try {
await api.post(`/site-resource/${siteResource.id}`, payload);
setAccess(access);
await Promise.all([
queryClient.invalidateQueries(
resourceQueries.siteResourceRoles({
siteResourceId: siteResource.id
})
),
queryClient.invalidateQueries(
resourceQueries.siteResourceUsers({
siteResourceId: siteResource.id
})
),
queryClient.invalidateQueries(
resourceQueries.siteResourceClients({
siteResourceId: siteResource.id
})
)
]);
toast({
title: t("editInternalResourceDialogSuccess"),
description: t(
"editInternalResourceDialogInternalResourceUpdatedSuccessfully"
)
});
} catch (error) {
toast({
title: t("editInternalResourceDialogError"),
description: formatAxiosError(
error,
t(
"editInternalResourceDialogFailedToUpdateInternalResource"
)
),
variant: "destructive"
});
}
}, null);
return (
<SettingsContainer>
<SettingsSection>
<SettingsSectionHeader>
<SettingsSectionTitle>
{t("authentication")}
</SettingsSectionTitle>
<SettingsSectionDescription>
{t(
"editInternalResourceDialogAccessControlDescription"
)}
</SettingsSectionDescription>
</SettingsSectionHeader>
<SettingsSectionBody>
<SettingsSectionForm variant="half">
<Form {...form}>
<form
action={formAction}
id="private-resource-access-form"
>
<PrivateResourceAccessFields
control={form.control}
orgId={siteResource.orgId}
loading={loading}
hasMachineClients={
hasMachineClients ||
hasMachineClientsResolved
}
/>
</form>
</Form>
</SettingsSectionForm>
</SettingsSectionBody>
<SettingsSectionFooter>
<Button
type="submit"
form="private-resource-access-form"
loading={saveLoading}
>
{t("saveSettings")}
</Button>
</SettingsSectionFooter>
</SettingsSection>
</SettingsContainer>
);
}
@@ -0,0 +1,138 @@
"use client";
import {
SettingsContainer,
SettingsFormCell,
SettingsFormGrid,
SettingsSection,
SettingsSectionBody,
SettingsSectionDescription,
SettingsSectionFooter,
SettingsSectionForm,
SettingsSectionHeader,
SettingsSectionTitle
} from "@app/components/Settings";
import { Button } from "@app/components/ui/button";
import { Form } from "@app/components/ui/form";
import { createCidrFormSchema } from "@app/lib/privateResourceForm";
import { zodResolver } from "@hookform/resolvers/zod";
import { useTranslations } from "next-intl";
import { useActionState, useMemo, useState } from "react";
import { useForm } from "react-hook-form";
import { z } from "zod";
import { PrivateResourceSitesField } from "../../PrivateResourceSitesField";
import { PrivateResourceCidrDestinationField } from "../../PrivateResourceDestinationFields";
import { PrivateResourcePortRanges } from "../../PrivateResourcePortRanges";
import { buildSelectedSitesForResource } from "../../privateResourceUtils";
import { asAnyControl, asAnySetValue } from "../../formControlUtils";
import { useSaveSiteResource } from "../../useSaveSiteResource";
export default function PrivateResourceCidrPage() {
const t = useTranslations();
const { save, siteResource } = useSaveSiteResource();
const [selectedSites, setSelectedSites] = useState(() =>
buildSelectedSitesForResource(siteResource)
);
const formSchema = useMemo(() => createCidrFormSchema(t), [t]);
type FormValues = z.infer<typeof formSchema>;
const form = useForm<FormValues>({
resolver: zodResolver(formSchema),
defaultValues: {
siteIds: siteResource.siteIds,
mode: "cidr",
destination: siteResource.destination ?? "",
tcpPortRangeString: siteResource.tcpPortRangeString ?? "*",
udpPortRangeString: siteResource.udpPortRangeString ?? "*",
disableIcmp: siteResource.disableIcmp ?? false
}
});
const [, formAction, saveLoading] = useActionState(async () => {
const isValid = await form.trigger();
if (!isValid) return;
const data = form.getValues();
await save({
siteIds: data.siteIds,
mode: "cidr",
destination: data.destination,
tcpPortRangeString: data.tcpPortRangeString,
udpPortRangeString: data.udpPortRangeString,
disableIcmp: data.disableIcmp
});
}, null);
return (
<SettingsContainer>
<SettingsSection>
<SettingsSectionHeader>
<SettingsSectionTitle>
{t("cidrSettings")}
</SettingsSectionTitle>
<SettingsSectionDescription>
{t(
"editInternalResourceDialogDestinationCidrDescription"
)}
</SettingsSectionDescription>
</SettingsSectionHeader>
<SettingsSectionBody>
<SettingsSectionForm variant="half">
<Form {...form}>
<form
action={formAction}
id="private-resource-cidr-form"
>
<SettingsFormGrid>
<SettingsFormCell span="half">
<PrivateResourceSitesField
control={form.control}
orgId={siteResource.orgId}
selectedSites={selectedSites}
onSelectedSitesChange={
setSelectedSites
}
/>
</SettingsFormCell>
<SettingsFormCell span="half">
<PrivateResourceCidrDestinationField
control={asAnyControl(form.control)}
/>
</SettingsFormCell>
<SettingsFormCell span="full">
<PrivateResourcePortRanges
control={asAnyControl(form.control)}
setValue={asAnySetValue(
form.setValue
)}
initialTcp={
siteResource.tcpPortRangeString
}
initialUdp={
siteResource.udpPortRangeString
}
/>
</SettingsFormCell>
</SettingsFormGrid>
</form>
</Form>
</SettingsSectionForm>
</SettingsSectionBody>
<SettingsSectionFooter>
<Button
type="submit"
form="private-resource-cidr-form"
loading={saveLoading}
>
{t("saveSettings")}
</Button>
</SettingsSectionFooter>
</SettingsSection>
</SettingsContainer>
);
}
@@ -0,0 +1,133 @@
"use client";
import {
SettingsContainer,
SettingsFormCell,
SettingsFormGrid,
SettingsSection,
SettingsSectionBody,
SettingsSectionDescription,
SettingsSectionFooter,
SettingsSectionForm,
SettingsSectionHeader,
SettingsSectionTitle
} from "@app/components/Settings";
import { Button } from "@app/components/ui/button";
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage
} from "@app/components/ui/form";
import { Input } from "@app/components/ui/input";
import { createGeneralFormSchema } from "@app/lib/privateResourceForm";
import { zodResolver } from "@hookform/resolvers/zod";
import { useTranslations } from "next-intl";
import { useActionState, useMemo } from "react";
import { useForm } from "react-hook-form";
import { z } from "zod";
import { useSaveSiteResource } from "../../useSaveSiteResource";
export default function PrivateResourceGeneralPage() {
const t = useTranslations();
const { save, siteResource } = useSaveSiteResource();
const formSchema = useMemo(() => createGeneralFormSchema(t), [t]);
type FormValues = z.infer<typeof formSchema>;
const form = useForm<FormValues>({
resolver: zodResolver(formSchema),
defaultValues: {
name: siteResource.name,
niceId: siteResource.niceId
}
});
const [, formAction, saveLoading] = useActionState(async () => {
const isValid = await form.trigger();
if (!isValid) return;
const data = form.getValues();
await save({
name: data.name,
niceId: data.niceId
});
}, null);
return (
<SettingsContainer>
<SettingsSection>
<SettingsSectionHeader>
<SettingsSectionTitle>
{t("resourceGeneral")}
</SettingsSectionTitle>
<SettingsSectionDescription>
{t("resourceGeneralDescription")}
</SettingsSectionDescription>
</SettingsSectionHeader>
<SettingsSectionBody>
<SettingsSectionForm variant="half">
<Form {...form}>
<form
action={formAction}
id="private-resource-general-form"
>
<SettingsFormGrid>
<SettingsFormCell span="half">
<FormField
control={form.control}
name="name"
render={({ field }) => (
<FormItem>
<FormLabel>
{t(
"editInternalResourceDialogName"
)}
</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</SettingsFormCell>
<SettingsFormCell span="half">
<FormField
control={form.control}
name="niceId"
render={({ field }) => (
<FormItem>
<FormLabel>
{t("identifier")}
</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</SettingsFormCell>
</SettingsFormGrid>
</form>
</Form>
</SettingsSectionForm>
</SettingsSectionBody>
<SettingsSectionFooter>
<Button
type="submit"
form="private-resource-general-form"
loading={saveLoading}
>
{t("saveSettings")}
</Button>
</SettingsSectionFooter>
</SettingsSection>
</SettingsContainer>
);
}
@@ -0,0 +1,147 @@
"use client";
import {
SettingsContainer,
SettingsFormCell,
SettingsFormGrid,
SettingsSection,
SettingsSectionBody,
SettingsSectionDescription,
SettingsSectionFooter,
SettingsSectionForm,
SettingsSectionHeader,
SettingsSectionTitle
} from "@app/components/Settings";
import { Button } from "@app/components/ui/button";
import { Form } from "@app/components/ui/form";
import { createHostFormSchema } from "@app/lib/privateResourceForm";
import { zodResolver } from "@hookform/resolvers/zod";
import { useTranslations } from "next-intl";
import { useActionState, useMemo, useState } from "react";
import { useForm } from "react-hook-form";
import { z } from "zod";
import { PrivateResourceSitesField } from "../../PrivateResourceSitesField";
import { PrivateResourceHostDestinationFields } from "../../PrivateResourceDestinationFields";
import { PrivateResourcePortRanges } from "../../PrivateResourcePortRanges";
import { buildSelectedSitesForResource } from "../../privateResourceUtils";
import {
asAnyControl,
asAnySetValue,
asAnyWatch
} from "../../formControlUtils";
import { useSaveSiteResource } from "../../useSaveSiteResource";
export default function PrivateResourceHostPage() {
const t = useTranslations();
const { save, siteResource } = useSaveSiteResource();
const [selectedSites, setSelectedSites] = useState(() =>
buildSelectedSitesForResource(siteResource)
);
const formSchema = useMemo(() => createHostFormSchema(t), [t]);
type FormValues = z.infer<typeof formSchema>;
const form = useForm<FormValues>({
resolver: zodResolver(formSchema),
defaultValues: {
siteIds: siteResource.siteIds,
mode: "host",
destination: siteResource.destination ?? "",
alias: siteResource.alias ?? null,
tcpPortRangeString: siteResource.tcpPortRangeString ?? "*",
udpPortRangeString: siteResource.udpPortRangeString ?? "*",
disableIcmp: siteResource.disableIcmp ?? false,
authDaemonMode: siteResource.authDaemonMode ?? "site",
authDaemonPort: siteResource.authDaemonPort ?? null
}
});
const [, formAction, saveLoading] = useActionState(async () => {
const isValid = await form.trigger();
if (!isValid) return;
const data = form.getValues();
await save({
siteIds: data.siteIds,
mode: "host",
destination: data.destination,
alias: data.alias,
tcpPortRangeString: data.tcpPortRangeString,
udpPortRangeString: data.udpPortRangeString,
disableIcmp: data.disableIcmp,
authDaemonMode: data.authDaemonMode,
authDaemonPort: data.authDaemonPort
});
}, null);
return (
<SettingsContainer>
<SettingsSection>
<SettingsSectionHeader>
<SettingsSectionTitle>
{t("hostSettings")}
</SettingsSectionTitle>
<SettingsSectionDescription>
{t("editInternalResourceDialogDestinationDescription")}
</SettingsSectionDescription>
</SettingsSectionHeader>
<SettingsSectionBody>
<SettingsSectionForm variant="half">
<Form {...form}>
<form
action={formAction}
id="private-resource-host-form"
>
<SettingsFormGrid>
<SettingsFormCell span="half">
<PrivateResourceSitesField
control={form.control}
orgId={siteResource.orgId}
selectedSites={selectedSites}
onSelectedSitesChange={
setSelectedSites
}
/>
</SettingsFormCell>
<SettingsFormCell span="full">
<PrivateResourceHostDestinationFields
control={asAnyControl(form.control)}
watch={asAnyWatch(form.watch)}
/>
</SettingsFormCell>
<SettingsFormCell span="full">
<PrivateResourcePortRanges
control={asAnyControl(form.control)}
setValue={asAnySetValue(
form.setValue
)}
initialTcp={
siteResource.tcpPortRangeString
}
initialUdp={
siteResource.udpPortRangeString
}
/>
</SettingsFormCell>
</SettingsFormGrid>
</form>
</Form>
</SettingsSectionForm>
</SettingsSectionBody>
<SettingsSectionFooter>
<Button
type="submit"
form="private-resource-host-form"
loading={saveLoading}
>
{t("saveSettings")}
</Button>
</SettingsSectionFooter>
</SettingsSection>
</SettingsContainer>
);
}
@@ -0,0 +1,146 @@
"use client";
import {
SettingsContainer,
SettingsFormCell,
SettingsFormGrid,
SettingsSection,
SettingsSectionBody,
SettingsSectionDescription,
SettingsSectionFooter,
SettingsSectionForm,
SettingsSectionHeader,
SettingsSectionTitle
} from "@app/components/Settings";
import { Button } from "@app/components/ui/button";
import { Form } from "@app/components/ui/form";
import { usePaidStatus } from "@app/hooks/usePaidStatus";
import { createHttpFormSchema } from "@app/lib/privateResourceForm";
import { zodResolver } from "@hookform/resolvers/zod";
import { tierMatrix } from "@server/lib/billing/tierMatrix";
import { useTranslations } from "next-intl";
import { useActionState, useMemo, useState } from "react";
import { useForm } from "react-hook-form";
import { z } from "zod";
import { PrivateResourceSitesField } from "../../PrivateResourceSitesField";
import { PrivateResourceHttpFields } from "../../PrivateResourceHttpFields";
import { buildSelectedSitesForResource } from "../../privateResourceUtils";
import {
asAnyControl,
asAnySetValue,
asAnyWatch
} from "../../formControlUtils";
import { useSaveSiteResource } from "../../useSaveSiteResource";
export default function PrivateResourceHttpPage() {
const t = useTranslations();
const { save, siteResource } = useSaveSiteResource();
const { isPaidUser } = usePaidStatus();
const httpSectionDisabled = !isPaidUser(
tierMatrix.advancedPrivateResources
);
const [selectedSites, setSelectedSites] = useState(() =>
buildSelectedSitesForResource(siteResource)
);
const formSchema = useMemo(() => createHttpFormSchema(t), [t]);
type FormValues = z.infer<typeof formSchema>;
const form = useForm<FormValues>({
resolver: zodResolver(formSchema),
defaultValues: {
siteIds: siteResource.siteIds,
mode: "http",
destination: siteResource.destination ?? "",
destinationPort: siteResource.destinationPort ?? null,
scheme: siteResource.scheme ?? "http",
ssl: siteResource.ssl ?? false,
httpConfigSubdomain: siteResource.subdomain ?? null,
httpConfigDomainId: siteResource.domainId ?? null,
httpConfigFullDomain: siteResource.fullDomain ?? null
}
});
const [, formAction, saveLoading] = useActionState(async () => {
const isValid = await form.trigger();
if (!isValid) return;
const data = form.getValues();
await save({
siteIds: data.siteIds,
mode: "http",
destination: data.destination,
destinationPort: data.destinationPort,
scheme: data.scheme,
ssl: data.ssl,
httpConfigSubdomain: data.httpConfigSubdomain,
httpConfigDomainId: data.httpConfigDomainId,
httpConfigFullDomain: data.httpConfigFullDomain
});
}, null);
return (
<SettingsContainer>
<SettingsSection>
<SettingsSectionHeader>
<SettingsSectionTitle>
{t("httpSettings")}
</SettingsSectionTitle>
<SettingsSectionDescription>
{t(
"editInternalResourceDialogHttpConfigurationDescription"
)}
</SettingsSectionDescription>
</SettingsSectionHeader>
<SettingsSectionBody>
<SettingsSectionForm variant="half">
<Form {...form}>
<form
action={formAction}
id="private-resource-http-form"
>
<SettingsFormGrid>
<SettingsFormCell span="half">
<PrivateResourceSitesField
control={form.control}
orgId={siteResource.orgId}
selectedSites={selectedSites}
onSelectedSitesChange={
setSelectedSites
}
/>
</SettingsFormCell>
<SettingsFormCell span="full">
<PrivateResourceHttpFields
control={asAnyControl(form.control)}
setValue={asAnySetValue(
form.setValue
)}
orgId={siteResource.orgId}
watch={asAnyWatch(form.watch)}
disabled={httpSectionDisabled}
siteResourceId={siteResource.id}
/>
</SettingsFormCell>
</SettingsFormGrid>
</form>
</Form>
</SettingsSectionForm>
</SettingsSectionBody>
<SettingsSectionFooter>
<Button
type="submit"
form="private-resource-http-form"
loading={saveLoading}
disabled={httpSectionDisabled}
>
{t("saveSettings")}
</Button>
</SettingsSectionFooter>
</SettingsSection>
</SettingsContainer>
);
}
@@ -0,0 +1,93 @@
import { HorizontalTabs } from "@app/components/HorizontalTabs";
import SettingsSectionTitle from "@app/components/SettingsSectionTitle";
import { fetchSiteResourceByNiceId } from "@app/lib/fetchSiteResourceByNiceId";
import { getCachedOrg } from "@app/lib/api/getCachedOrg";
import OrgProvider from "@app/providers/OrgProvider";
import SiteResourceProvider from "@app/providers/SiteResourceProvider";
import SiteResourceInfoBox from "@app/components/SiteResourceInfoBox";
import type { Metadata } from "next";
import { getTranslations } from "next-intl/server";
import { redirect } from "next/navigation";
export const metadata: Metadata = {
title: "Private Resource"
};
export const dynamic = "force-dynamic";
type PrivateResourceLayoutProps = {
children: React.ReactNode;
params: Promise<{ niceId: string; orgId: string }>;
};
export default async function PrivateResourceLayout(
props: PrivateResourceLayoutProps
) {
const params = await props.params;
const t = await getTranslations();
const { children } = props;
const siteResource = await fetchSiteResourceByNiceId(
params.orgId,
params.niceId
);
if (!siteResource) {
redirect(`/${params.orgId}/settings/resources/private`);
}
let org = null;
try {
const res = await getCachedOrg(params.orgId);
org = res.data.data;
} catch {
redirect(`/${params.orgId}/settings/resources/private`);
}
if (!org) {
redirect(`/${params.orgId}/settings/resources/private`);
}
const modeSettingsKey = `${siteResource.mode}Settings` as
| "hostSettings"
| "cidrSettings"
| "httpSettings"
| "sshSettings";
const navItems = [
{
title: t("general"),
href: `/{orgId}/settings/resources/private/{niceId}/general`
},
{
title: t(modeSettingsKey),
href: `/{orgId}/settings/resources/private/{niceId}/${siteResource.mode}`
},
{
title: t("authentication"),
href: `/{orgId}/settings/resources/private/{niceId}/access`
}
];
return (
<>
<SettingsSectionTitle
title={t("resourceSetting", {
resourceName: siteResource.name
})}
description={t("resourceSettingDescription")}
/>
<OrgProvider org={org}>
<SiteResourceProvider siteResource={siteResource}>
<div className="space-y-6">
<SiteResourceInfoBox />
<HorizontalTabs items={navItems}>
{children}
</HorizontalTabs>
</div>
</SiteResourceProvider>
</OrgProvider>
</>
);
}
@@ -0,0 +1,15 @@
import type { Metadata } from "next";
import { redirect } from "next/navigation";
export const metadata: Metadata = {
title: "Private Resource"
};
export default async function PrivateResourcePage(props: {
params: Promise<{ niceId: string; orgId: string }>;
}) {
const params = await props.params;
redirect(
`/${params.orgId}/settings/resources/private/${params.niceId}/general`
);
}
@@ -0,0 +1,229 @@
"use client";
import {
SettingsContainer,
SettingsSection,
SettingsSectionBody,
SettingsSectionDescription,
SettingsSectionFooter,
SettingsSectionForm,
SettingsSectionHeader,
SettingsSectionTitle,
SettingsFormGrid
} from "@app/components/Settings";
import { SshServerSettingsFields } from "@app/components/SshServerSettingsFields";
import { PaidFeaturesAlert } from "@app/components/PaidFeaturesAlert";
import { Button } from "@app/components/ui/button";
import { Form } from "@app/components/ui/form";
import { usePaidStatus } from "@app/hooks/usePaidStatus";
import {
createSshFormSchema,
inferSshPamMode
} from "@app/lib/privateResourceForm";
import { zodResolver } from "@hookform/resolvers/zod";
import { tierMatrix } from "@server/lib/billing/tierMatrix";
import { useTranslations } from "next-intl";
import { useActionState, useMemo, useState } from "react";
import { useForm } from "react-hook-form";
import { z } from "zod";
import { PrivateResourceSshFields } from "../../PrivateResourceSshFields";
import { buildSelectedSitesForResource } from "../../privateResourceUtils";
import {
asAnyControl,
asAnySetValue,
asAnyWatch
} from "../../formControlUtils";
import { useSaveSiteResource } from "../../useSaveSiteResource";
import type { Selectedsite } from "@app/components/site-selector";
export default function PrivateResourceSshPage() {
const t = useTranslations();
const { save, siteResource } = useSaveSiteResource();
const { isPaidUser } = usePaidStatus();
const sshSectionDisabled = !isPaidUser(tierMatrix.advancedPrivateResources);
const isNative = siteResource.authDaemonMode === "native";
const [sshServerMode] = useState<"standard" | "native">(
isNative ? "native" : "standard"
);
const [selectedSites, setSelectedSites] = useState(() =>
buildSelectedSitesForResource(siteResource)
);
const formSchema = useMemo(
() => createSshFormSchema(t, { isNative }),
[t, isNative]
);
type FormValues = z.infer<typeof formSchema>;
const form = useForm<FormValues>({
resolver: zodResolver(formSchema),
defaultValues: {
siteIds: siteResource.siteIds,
mode: "ssh",
destination: siteResource.destination ?? "",
alias: siteResource.alias ?? null,
destinationPort: siteResource.destinationPort ?? null,
pamMode: inferSshPamMode(
siteResource.authDaemonMode,
siteResource.pamMode
),
standardDaemonLocation: isNative
? "site"
: siteResource.authDaemonMode === "remote"
? "remote"
: "site",
authDaemonPort: siteResource.authDaemonPort
? String(siteResource.authDaemonPort)
: "22123"
}
});
const pamMode = form.watch("pamMode");
const standardDaemonLocation = form.watch("standardDaemonLocation");
const authDaemonPort = form.watch("authDaemonPort");
function trimSitesToFirst() {
if (selectedSites.length <= 1) return;
const first = selectedSites.slice(0, 1);
setSelectedSites(first);
form.setValue(
"siteIds",
first.map((s: Selectedsite) => s.siteId),
{ shouldValidate: true }
);
}
function handlePamModeChange(value: "passthrough" | "push") {
form.setValue("pamMode", value, { shouldValidate: true });
if (value === "push") {
if (
standardDaemonLocation !== "remote" &&
selectedSites.length > 1
) {
trimSitesToFirst();
}
return;
}
form.setValue("authDaemonPort", "22123", { shouldValidate: true });
}
function handleDaemonLocationChange(value: "site" | "remote") {
form.setValue("standardDaemonLocation", value, {
shouldValidate: true
});
if (value === "site") {
form.setValue("authDaemonPort", "22123", { shouldValidate: true });
trimSitesToFirst();
}
}
const [, formAction, saveLoading] = useActionState(async () => {
const isValid = await form.trigger();
if (!isValid) return;
const data = form.getValues();
const effectiveAuthDaemonMode = isNative
? "native"
: data.standardDaemonLocation;
const effectiveAuthDaemonPort =
!isNative &&
data.pamMode === "push" &&
data.standardDaemonLocation === "remote"
? Number(data.authDaemonPort)
: null;
await save({
siteIds: data.siteIds,
mode: "ssh",
destination: isNative ? null : data.destination,
alias: data.alias,
destinationPort: isNative ? null : data.destinationPort,
authDaemonMode: effectiveAuthDaemonMode,
authDaemonPort: effectiveAuthDaemonPort,
pamMode: data.pamMode
});
}, null);
return (
<SettingsContainer>
<PaidFeaturesAlert tiers={tierMatrix.advancedPrivateResources} />
<SettingsSection>
<SettingsSectionHeader>
<SettingsSectionTitle>
{t("sshSettings")}
</SettingsSectionTitle>
<SettingsSectionDescription>
{t("editInternalResourceDialogDestinationDescription")}
</SettingsSectionDescription>
</SettingsSectionHeader>
<fieldset
disabled={sshSectionDisabled}
className={
sshSectionDisabled
? "opacity-50 pointer-events-none"
: ""
}
>
<Form {...form}>
<SettingsSectionBody>
<SettingsSectionForm variant="half">
<SettingsFormGrid>
<SshServerSettingsFields
idPrefix="private-ssh-edit"
pamMode={pamMode}
standardDaemonLocation={
standardDaemonLocation
}
authDaemonPort={authDaemonPort}
onPamModeChange={handlePamModeChange}
onStandardDaemonLocationChange={
handleDaemonLocationChange
}
onAuthDaemonPortChange={(value) =>
form.setValue(
"authDaemonPort",
value,
{ shouldValidate: true }
)
}
authDaemonPortError={
form.formState.errors.authDaemonPort
?.message
}
sshServerMode={sshServerMode}
serverModeDisplay="badge"
/>
<PrivateResourceSshFields
control={asAnyControl(form.control)}
setValue={asAnySetValue(form.setValue)}
watch={asAnyWatch(form.watch)}
orgId={siteResource.orgId}
selectedSites={selectedSites}
onSelectedSitesChange={setSelectedSites}
showSshSettings={false}
embedInParentGrid
showPaidFeaturesAlert={false}
isNativeSsh={isNative}
/>
</SettingsFormGrid>
</SettingsSectionForm>
</SettingsSectionBody>
<SettingsSectionFooter>
<form action={formAction}>
<Button type="submit" loading={saveLoading}>
{t("saveSettings")}
</Button>
</form>
</SettingsSectionFooter>
</Form>
</fieldset>
</SettingsSection>
</SettingsContainer>
);
}
@@ -0,0 +1,638 @@
"use client";
import {
SettingsFormCell,
SettingsFormGrid,
SettingsSection,
SettingsSectionBody,
SettingsSectionDescription,
SettingsSectionForm,
SettingsSectionHeader,
SettingsSectionTitle
} from "@app/components/Settings";
import HeaderTitle from "@app/components/SettingsSectionTitle";
import {
OptionSelect,
type OptionSelectOption
} from "@app/components/OptionSelect";
import DomainPicker from "@app/components/DomainPicker";
import { PaidFeaturesAlert } from "@app/components/PaidFeaturesAlert";
import { Button } from "@app/components/ui/button";
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage
} from "@app/components/ui/form";
import { Input } from "@app/components/ui/input";
import type { Selectedsite } from "@app/components/site-selector";
import { useEnvContext } from "@app/hooks/useEnvContext";
import { usePaidStatus } from "@app/hooks/usePaidStatus";
import { toast } from "@app/hooks/useToast";
import { createApiClient, formatAxiosError } from "@app/lib/api";
import {
buildCreateSiteResourcePayload,
createCreateFormSchema,
type PrivateResourceMode
} from "@app/lib/privateResourceForm";
import { zodResolver } from "@hookform/resolvers/zod";
import { tierMatrix } from "@server/lib/billing/tierMatrix";
import type { SiteResource } from "@server/db";
import { GetSiteResponse } from "@server/routers/site/getSite";
import type ResponseT from "@server/types/Response";
import { AxiosResponse } from "axios";
import { useTranslations } from "next-intl";
import Link from "next/link";
import { useParams, useRouter, useSearchParams } from "next/navigation";
import { useEffect, useMemo, useState, useTransition } from "react";
import { useForm } from "react-hook-form";
import { z } from "zod";
import { PrivateResourceSitesField } from "../PrivateResourceSitesField";
import { PrivateResourceHttpFields } from "../PrivateResourceHttpFields";
import { PrivateResourceSshFields } from "../PrivateResourceSshFields";
import { PrivateResourcePortRanges } from "../PrivateResourcePortRanges";
import {
PrivateResourceAliasField,
PrivateResourceCidrDestinationField,
PrivateResourceHostDestinationFields
} from "../PrivateResourceDestinationFields";
import { asAnyControl, asAnySetValue, asAnyWatch } from "../formControlUtils";
export default function CreatePrivateResourcePage() {
const params = useParams();
const searchParams = useSearchParams();
const router = useRouter();
const t = useTranslations();
const { env } = useEnvContext();
const api = createApiClient({ env });
const orgId = params.orgId as string;
const disableEnterpriseFeatures = env.flags.disableEnterpriseFeatures;
const { isPaidUser } = usePaidStatus();
const httpSectionDisabled = !isPaidUser(
tierMatrix.advancedPrivateResources
);
const sshSectionDisabled = !isPaidUser(tierMatrix.advancedPrivateResources);
const [isSubmitting, startTransition] = useTransition();
const siteIdParam = searchParams.get("siteId");
const siteIdNumber =
siteIdParam && Number.isInteger(Number(siteIdParam))
? Number(siteIdParam)
: null;
const [selectedSites, setSelectedSites] = useState<Selectedsite[]>([]);
const formSchema = useMemo(() => createCreateFormSchema(t), [t]);
type FormValues = z.infer<typeof formSchema>;
const form = useForm<FormValues>({
resolver: zodResolver(formSchema),
defaultValues: {
name: "",
siteIds: [],
mode: "host",
destination: "",
alias: null,
destinationPort: null,
scheme: "http",
ssl: true,
httpConfigSubdomain: null,
httpConfigDomainId: null,
httpConfigFullDomain: null,
authDaemonMode: "native",
standardDaemonLocation: "site",
authDaemonPort: null,
pamMode: "passthrough",
tcpPortRangeString: "*",
udpPortRangeString: "*",
disableIcmp: false
}
});
useEffect(() => {
if (!siteIdNumber) return;
void api
.get<ResponseT<GetSiteResponse>>(`/site/${siteIdNumber}`)
.then((res) => {
const site = res.data.data;
if (!site || site.orgId !== orgId) return;
const selected: Selectedsite = {
siteId: site.siteId,
name: site.name,
type: site.type as Selectedsite["type"]
};
setSelectedSites([selected]);
form.setValue("siteIds", [site.siteId]);
})
.catch(() => {});
}, [api, form, orgId, siteIdNumber]);
const mode = form.watch("mode");
const authDaemonMode = form.watch("authDaemonMode");
const isNativeSsh = mode === "ssh" && authDaemonMode === "native";
const modeOptions: OptionSelectOption<PrivateResourceMode>[] = [
{ value: "host", label: t("createInternalResourceDialogModeHost") },
{ value: "cidr", label: t("createInternalResourceDialogModeCidr") },
...(!disableEnterpriseFeatures
? [
{
value: "http" as const,
label: t("createInternalResourceDialogModeHttp")
},
{
value: "ssh" as const,
label: t("createInternalResourceDialogModeSsh")
}
]
: [])
];
const submitDisabled =
isSubmitting ||
(mode === "http" && httpSectionDisabled) ||
(mode === "ssh" && sshSectionDisabled);
function onSubmit(values: FormValues) {
startTransition(async () => {
try {
const res = await api.put<
AxiosResponse<ResponseT<SiteResource>>
>(
`/org/${orgId}/site-resource`,
buildCreateSiteResourcePayload({
...values,
destination:
values.destination?.trim() &&
values.destination.trim().length > 0
? values.destination.trim()
: null
})
);
toast({
title: t("createInternalResourceDialogSuccess"),
description: t(
"createInternalResourceDialogInternalResourceCreatedSuccessfully"
)
});
const created = (res.data as unknown as ResponseT<SiteResource>)
.data;
if (!created) {
throw new Error("Failed to create private resource");
}
router.push(
`/${orgId}/settings/resources/private/${created.niceId}/${created.mode}`
);
} catch (error) {
toast({
title: t("createInternalResourceDialogError"),
description: formatAxiosError(
error,
t(
"createInternalResourceDialogFailedToCreateInternalResource"
)
),
variant: "destructive"
});
}
});
}
return (
<>
<div className="flex items-start justify-between gap-4">
<HeaderTitle
title={t(
"createInternalResourceDialogCreateClientResource"
)}
description={t(
"createInternalResourceDialogCreateClientResourceDescription"
)}
/>
<Button variant="outline" asChild>
<Link href={`/${orgId}/settings/resources/private`}>
{t("privateResourceCreatePageSeeAll")}
</Link>
</Button>
</div>
<Form {...form}>
<form
id="create-private-resource-form"
onSubmit={form.handleSubmit(onSubmit)}
className="space-y-6"
>
{/* General */}
<SettingsSection>
<SettingsSectionHeader>
<SettingsSectionTitle>
{t("resourceCreateGeneral")}
</SettingsSectionTitle>
<SettingsSectionDescription>
{t("resourceCreateGeneralDescription")}
</SettingsSectionDescription>
</SettingsSectionHeader>
<SettingsSectionBody>
<SettingsSectionForm variant="half">
<SettingsFormGrid>
<SettingsFormCell span="half">
<FormField
control={form.control}
name="name"
render={({ field }) => (
<FormItem>
<FormLabel>
{t("name")}
</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormMessage />
<FormDescription>
{t(
"resourceNameDescription"
)}
</FormDescription>
</FormItem>
)}
/>
</SettingsFormCell>
<SettingsFormCell span="full">
<FormField
control={form.control}
name="mode"
render={({ field }) => (
<FormItem>
<FormLabel>
{t("type")}
</FormLabel>
<OptionSelect<PrivateResourceMode>
options={modeOptions}
value={field.value}
onChange={(newMode) => {
field.onChange(
newMode
);
if (
newMode ===
"ssh"
) {
form.setValue(
"authDaemonMode",
"native"
);
form.setValue(
"standardDaemonLocation",
"site"
);
form.setValue(
"destination",
null
);
form.setValue(
"destinationPort",
null
);
} else if (
newMode ===
"http"
) {
form.setValue(
"destinationPort",
443
);
} else {
form.setValue(
"destinationPort",
null
);
}
}}
cols={4}
/>
<FormMessage />
</FormItem>
)}
/>
</SettingsFormCell>
{mode === "http" && (
<SettingsFormCell span="full">
<FormItem>
<DomainPicker
orgId={orgId}
cols={2}
hideFreeDomain
onDomainChange={(res) => {
if (!res) {
form.setValue(
"httpConfigSubdomain",
null
);
form.setValue(
"httpConfigDomainId",
null
);
form.setValue(
"httpConfigFullDomain",
null
);
return;
}
form.setValue(
"httpConfigSubdomain",
res.subdomain ??
null
);
form.setValue(
"httpConfigDomainId",
res.domainId
);
form.setValue(
"httpConfigFullDomain",
res.fullDomain
);
}}
/>
<FormMessage />
<FormDescription>
{t(
"resourceDomainDescription"
)}
</FormDescription>
</FormItem>
</SettingsFormCell>
)}
{(mode === "host" ||
(mode === "ssh" && !isNativeSsh)) && (
<SettingsFormCell span="half">
<PrivateResourceAliasField
control={asAnyControl(
form.control
)}
watch={asAnyWatch(form.watch)}
labelPrefix="create"
disabled={
mode === "ssh" &&
sshSectionDisabled
}
/>
</SettingsFormCell>
)}
</SettingsFormGrid>
</SettingsSectionForm>
</SettingsSectionBody>
</SettingsSection>
{/* Host destination */}
{mode === "host" && (
<SettingsSection>
<SettingsSectionHeader>
<SettingsSectionTitle>
{t("hostSettings")}
</SettingsSectionTitle>
<SettingsSectionDescription>
{t(
"editInternalResourceDialogDestinationDescription"
)}
</SettingsSectionDescription>
</SettingsSectionHeader>
<SettingsSectionBody>
<SettingsSectionForm variant="half">
<SettingsFormGrid>
<SettingsFormCell span="half">
<PrivateResourceSitesField
control={form.control}
orgId={orgId}
selectedSites={selectedSites}
onSelectedSitesChange={
setSelectedSites
}
/>
</SettingsFormCell>
<SettingsFormCell span="half">
<PrivateResourceHostDestinationFields
control={asAnyControl(
form.control
)}
watch={asAnyWatch(form.watch)}
labelPrefix="create"
hideAlias
/>
</SettingsFormCell>
<SettingsFormCell span="full">
<PrivateResourcePortRanges
control={asAnyControl(
form.control
)}
setValue={asAnySetValue(
form.setValue
)}
/>
</SettingsFormCell>
</SettingsFormGrid>
</SettingsSectionForm>
</SettingsSectionBody>
</SettingsSection>
)}
{/* CIDR destination */}
{mode === "cidr" && (
<SettingsSection>
<SettingsSectionHeader>
<SettingsSectionTitle>
{t("cidrSettings")}
</SettingsSectionTitle>
<SettingsSectionDescription>
{t(
"editInternalResourceDialogDestinationCidrDescription"
)}
</SettingsSectionDescription>
</SettingsSectionHeader>
<SettingsSectionBody>
<SettingsSectionForm variant="half">
<SettingsFormGrid>
<SettingsFormCell span="half">
<PrivateResourceSitesField
control={form.control}
orgId={orgId}
selectedSites={selectedSites}
onSelectedSitesChange={
setSelectedSites
}
/>
</SettingsFormCell>
<SettingsFormCell span="half">
<PrivateResourceCidrDestinationField
control={asAnyControl(
form.control
)}
labelPrefix="create"
/>
</SettingsFormCell>
<SettingsFormCell span="full">
<PrivateResourcePortRanges
control={asAnyControl(
form.control
)}
setValue={asAnySetValue(
form.setValue
)}
/>
</SettingsFormCell>
</SettingsFormGrid>
</SettingsSectionForm>
</SettingsSectionBody>
</SettingsSection>
)}
{/* HTTP configuration */}
{mode === "http" && (
<SettingsSection>
<PaidFeaturesAlert
tiers={tierMatrix.advancedPrivateResources}
/>
<SettingsSectionHeader>
<SettingsSectionTitle>
{t("httpSettings")}
</SettingsSectionTitle>
<SettingsSectionDescription>
{t(
"editInternalResourceDialogHttpConfigurationDescription"
)}
</SettingsSectionDescription>
</SettingsSectionHeader>
<fieldset
disabled={httpSectionDisabled}
className={
httpSectionDisabled
? "opacity-50 pointer-events-none"
: ""
}
>
<SettingsSectionBody>
<SettingsSectionForm variant="half">
<SettingsFormGrid>
<SettingsFormCell span="half">
<PrivateResourceSitesField
control={form.control}
orgId={orgId}
selectedSites={
selectedSites
}
onSelectedSitesChange={
setSelectedSites
}
/>
</SettingsFormCell>
<SettingsFormCell span="full">
<PrivateResourceHttpFields
control={asAnyControl(
form.control
)}
setValue={asAnySetValue(
form.setValue
)}
orgId={orgId}
watch={asAnyWatch(
form.watch
)}
disabled={
httpSectionDisabled
}
labelPrefix="create"
hideDomainPicker
hidePaidFeaturesAlert
/>
</SettingsFormCell>
</SettingsFormGrid>
</SettingsSectionForm>
</SettingsSectionBody>
</fieldset>
</SettingsSection>
)}
{/* SSH server */}
{mode === "ssh" && (
<SettingsSection>
<PaidFeaturesAlert
tiers={tierMatrix.advancedPrivateResources}
/>
<SettingsSectionHeader>
<SettingsSectionTitle>
{t("sshServer")}
</SettingsSectionTitle>
<SettingsSectionDescription>
{t("sshServerDescription")}
</SettingsSectionDescription>
</SettingsSectionHeader>
<fieldset
disabled={sshSectionDisabled}
className={
sshSectionDisabled
? "opacity-50 pointer-events-none"
: ""
}
>
<SettingsSectionBody>
<SettingsSectionForm variant="half">
<PrivateResourceSshFields
control={asAnyControl(form.control)}
setValue={asAnySetValue(
form.setValue
)}
watch={asAnyWatch(form.watch)}
orgId={orgId}
disabled={sshSectionDisabled}
selectedSites={selectedSites}
onSelectedSitesChange={
setSelectedSites
}
labelPrefix="create"
showSshSettings={true}
layout="wizard"
showPaidFeaturesAlert={false}
hideAlias
/>
</SettingsSectionForm>
</SettingsSectionBody>
</fieldset>
</SettingsSection>
)}
<div className="flex justify-end space-x-2 mt-8">
<Button
type="button"
variant="outline"
onClick={() =>
router.push(
`/${orgId}/settings/resources/private`
)
}
disabled={isSubmitting}
>
{t("createInternalResourceDialogCancel")}
</Button>
<Button
type="submit"
form="create-private-resource-form"
disabled={submitDisabled}
loading={isSubmitting}
>
{t("createInternalResourceDialogCreateResource")}
</Button>
</div>
</form>
</Form>
</>
);
}
@@ -0,0 +1,24 @@
import type {
Control,
FieldValues,
UseFormSetValue,
UseFormWatch
} from "react-hook-form";
export function asAnyControl<T extends FieldValues>(
control: Control<T>
): Control<any> {
return control as Control<any>;
}
export function asAnySetValue<T extends FieldValues>(
setValue: UseFormSetValue<T>
): UseFormSetValue<any> {
return setValue as UseFormSetValue<any>;
}
export function asAnyWatch<T extends FieldValues>(
watch: UseFormWatch<T>
): UseFormWatch<any> {
return watch as UseFormWatch<any>;
}
@@ -1,18 +1,15 @@
import PrivateResourcesBanner from "@app/components/PrivateResourcesBanner";
import type { InternalResourceRow } from "@app/components/PrivateResourcesTable"; import type { InternalResourceRow } from "@app/components/PrivateResourcesTable";
import PrivateResourcesTable from "@app/components/PrivateResourcesTable"; import PrivateResourcesTable from "@app/components/PrivateResourcesTable";
import SettingsSectionTitle from "@app/components/SettingsSectionTitle"; import SettingsSectionTitle from "@app/components/SettingsSectionTitle";
import PrivateResourcesBanner from "@app/components/PrivateResourcesBanner";
import { internal } from "@app/lib/api"; import { internal } from "@app/lib/api";
import { authCookieHeader } from "@app/lib/api/cookies"; import { authCookieHeader } from "@app/lib/api/cookies";
import { getCachedOrg } from "@app/lib/api/getCachedOrg"; import { getCachedOrg } from "@app/lib/api/getCachedOrg";
import OrgProvider from "@app/providers/OrgProvider"; import OrgProvider from "@app/providers/OrgProvider";
import type { ListResourcesResponse } from "@server/routers/resource";
import { GetSiteResponse } from "@server/routers/site/getSite";
import type { ListAllSiteResourcesByOrgResponse } from "@server/routers/siteResource"; import type { ListAllSiteResourcesByOrgResponse } from "@server/routers/siteResource";
import type ResponseT from "@server/types/Response";
import type { AxiosResponse } from "axios"; import type { AxiosResponse } from "axios";
import { getTranslations } from "next-intl/server";
import type { Metadata } from "next"; import type { Metadata } from "next";
import { getTranslations } from "next-intl/server";
import { redirect } from "next/navigation"; import { redirect } from "next/navigation";
export const metadata: Metadata = { export const metadata: Metadata = {
@@ -24,13 +21,6 @@ export interface ClientResourcesPageProps {
searchParams: Promise<Record<string, string>>; searchParams: Promise<Record<string, string>>;
} }
function parsePositiveInt(s: string | undefined): number | undefined {
if (!s) return undefined;
const n = Number(s);
if (!Number.isInteger(n) || n <= 0) return undefined;
return n;
}
export default async function ClientResourcesPage( export default async function ClientResourcesPage(
props: ClientResourcesPageProps props: ClientResourcesPageProps
) { ) {
@@ -39,7 +29,7 @@ export default async function ClientResourcesPage(
const searchParams = new URLSearchParams(await props.searchParams); const searchParams = new URLSearchParams(await props.searchParams);
let siteResources: ListAllSiteResourcesByOrgResponse["siteResources"] = []; let siteResources: ListAllSiteResourcesByOrgResponse["siteResources"] = [];
let pagination: ListResourcesResponse["pagination"] = { let pagination: ListAllSiteResourcesByOrgResponse["pagination"] = {
total: 0, total: 0,
page: 1, page: 1,
pageSize: 20 pageSize: 20
@@ -56,34 +46,6 @@ export default async function ClientResourcesPage(
pagination = responseData.pagination; pagination = responseData.pagination;
} catch (e) {} } catch (e) {}
const siteIdParam = parsePositiveInt(
searchParams.get("siteId") ?? undefined
);
let initialFilterSite: {
siteId: number;
name: string;
type: string;
} | null = null;
if (siteIdParam) {
try {
const siteRes = await internal.get(
`/site/${siteIdParam}`,
await authCookieHeader()
);
const s = (siteRes.data as ResponseT<GetSiteResponse>).data;
if (s && s.orgId === params.orgId) {
initialFilterSite = {
siteId: s.siteId,
name: s.name,
type: s.type
};
}
} catch {
// leave null
}
}
let org = null; let org = null;
try { try {
const res = await getCachedOrg(params.orgId); const res = await getCachedOrg(params.orgId);
@@ -122,6 +84,7 @@ export default async function ClientResourcesPage(
aliasAddress: siteResource.aliasAddress || null, aliasAddress: siteResource.aliasAddress || null,
siteNiceIds: siteResource.siteNiceIds, siteNiceIds: siteResource.siteNiceIds,
niceId: siteResource.niceId, niceId: siteResource.niceId,
enabled: siteResource.enabled,
tcpPortRangeString: siteResource.tcpPortRangeString || null, tcpPortRangeString: siteResource.tcpPortRangeString || null,
udpPortRangeString: siteResource.udpPortRangeString || null, udpPortRangeString: siteResource.udpPortRangeString || null,
disableIcmp: siteResource.disableIcmp || false, disableIcmp: siteResource.disableIcmp || false,
@@ -153,7 +116,6 @@ export default async function ClientResourcesPage(
pageIndex: pagination.page - 1, pageIndex: pagination.page - 1,
pageSize: pagination.pageSize pageSize: pagination.pageSize
}} }}
initialFilterSite={initialFilterSite}
/> />
</OrgProvider> </OrgProvider>
</> </>

Some files were not shown because too many files have changed in this diff Show More