mirror of
https://github.com/fosrl/pangolin.git
synced 2026-06-12 10:29:51 +00:00
Compare commits
102 Commits
exit-node-
...
1.19.0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6e271028f3 | ||
|
|
820f66e58f | ||
|
|
b0fdc10e06 | ||
|
|
b82b41ed26 | ||
|
|
3e977ba00d | ||
|
|
a724b07846 | ||
|
|
5f0bc71bcd | ||
|
|
aea7827c1a | ||
|
|
d865c4c55b | ||
|
|
5baf0c3c09 | ||
|
|
cfe33eb974 | ||
|
|
71273e1b1c | ||
|
|
02f6e2a8c3 | ||
|
|
3cc244a1d3 | ||
|
|
1d9c4dd9e2 | ||
|
|
b9dd0c8e43 | ||
|
|
cd052976eb | ||
|
|
cc498f0e33 | ||
|
|
1a942937e6 | ||
|
|
d81d1a6b7f | ||
|
|
f64d04e827 | ||
|
|
540aee3fe2 | ||
|
|
10542d7282 | ||
|
|
b1d52ad1a3 | ||
|
|
ce2fbef805 | ||
|
|
e312b31e02 | ||
|
|
bc156c715d | ||
|
|
9a4c1f23c6 | ||
|
|
6921447fab | ||
|
|
d47449b082 | ||
|
|
665806dfe8 | ||
|
|
e248571268 | ||
|
|
fcf03854ff | ||
|
|
dd1fba4e45 | ||
|
|
a1ab8d8f35 | ||
|
|
c789e967db | ||
|
|
d870b9ff49 | ||
|
|
9c09019ddb | ||
|
|
9d88683fc5 | ||
|
|
dd2c9f2a02 | ||
|
|
bdb38db5bc | ||
|
|
96a54fc9cc | ||
|
|
3a485f74f1 | ||
|
|
92b0340324 | ||
|
|
9257ac01c7 | ||
|
|
4d1d0d9fcb | ||
|
|
f186e7e99e | ||
|
|
1aa6e3511f | ||
|
|
fb6f5b3953 | ||
|
|
c85a7f6ac5 | ||
|
|
dd54be523f | ||
|
|
d57f064d4c | ||
|
|
34799b7de2 | ||
|
|
20a66bba6f | ||
|
|
cdb43d9658 | ||
|
|
6581ccafa3 | ||
|
|
a3a45b4239 | ||
|
|
d6634b6e8a | ||
|
|
1089cfbacc | ||
|
|
1907a3c93b | ||
|
|
407ba567a0 | ||
|
|
f28571629f | ||
|
|
5a575c916b | ||
|
|
9a7e534b10 | ||
|
|
42974d1739 | ||
|
|
780e8babe4 | ||
|
|
2c7b8006cf | ||
|
|
35066c1388 | ||
|
|
135a5d38af | ||
|
|
1b7c1ffa70 | ||
|
|
641f643d2d | ||
|
|
b4ecfceb5e | ||
|
|
08a84d4bb1 | ||
|
|
4dbad7ab24 | ||
|
|
859c0c9477 | ||
|
|
d294bf8534 | ||
|
|
3c8fea382f | ||
|
|
b81bfcfcee | ||
|
|
56c415ca05 | ||
|
|
74fdcceace | ||
|
|
7dec8ba998 | ||
|
|
c9dc6affe7 | ||
|
|
8fe45ba78c | ||
|
|
934886caea | ||
|
|
fae258b145 | ||
|
|
9f224f655f | ||
|
|
aea7df7dc2 | ||
|
|
3b675f7de1 | ||
|
|
aa47f522ef | ||
|
|
a994f8ff07 | ||
|
|
95ce91d94b | ||
|
|
a4548fd874 | ||
|
|
eb03fb7060 | ||
|
|
7fa1180d10 | ||
|
|
33fdc9a94f | ||
|
|
8b50f1fb65 | ||
|
|
c86026c941 | ||
|
|
db014e3446 | ||
|
|
feb8045643 | ||
|
|
d485a09318 | ||
|
|
9cff5f66b1 | ||
|
|
527d4cc777 |
5
.cursor/rules/Components.mdc
Normal file
5
.cursor/rules/Components.mdc
Normal file
@@ -0,0 +1,5 @@
|
||||
---
|
||||
alwaysApply: true
|
||||
---
|
||||
|
||||
When creating UI for popup dialogs or modals, use the Credenza componennt. This component is mobile responsive and works on desktop and wraps the dialog component and sheet into one.
|
||||
@@ -34,4 +34,5 @@ build.ts
|
||||
tsconfig.json
|
||||
Dockerfile*
|
||||
drizzle.config.ts
|
||||
allowedDevOrigins.json
|
||||
allowedDevOrigins.json
|
||||
scratch/
|
||||
|
||||
@@ -4,19 +4,26 @@ import { eq } from "drizzle-orm";
|
||||
|
||||
type SetServerAdminArgs = {
|
||||
email: string;
|
||||
remove: boolean;
|
||||
};
|
||||
|
||||
export const setServerAdmin: CommandModule<{}, SetServerAdminArgs> = {
|
||||
command: "set-server-admin",
|
||||
describe: "Mark any user as a server admin by email address",
|
||||
describe: "Add or remove server admin by email address",
|
||||
builder: (yargs) => {
|
||||
return yargs.option("email", {
|
||||
type: "string",
|
||||
demandOption: true,
|
||||
describe: "User email address"
|
||||
});
|
||||
return yargs
|
||||
.option("email", {
|
||||
type: "string",
|
||||
demandOption: true,
|
||||
describe: "User email address"
|
||||
})
|
||||
.option("remove", {
|
||||
type: "boolean",
|
||||
default: false,
|
||||
describe: "Remove server admin status from the user"
|
||||
});
|
||||
},
|
||||
handler: async (argv: { email: string }) => {
|
||||
handler: async (argv: SetServerAdminArgs) => {
|
||||
try {
|
||||
const email = argv.email.trim().toLowerCase();
|
||||
|
||||
@@ -31,6 +38,33 @@ export const setServerAdmin: CommandModule<{}, SetServerAdminArgs> = {
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
if (argv.remove) {
|
||||
if (!user.serverAdmin) {
|
||||
console.log(`User '${email}' is not a server admin`);
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
const serverAdmins = await db
|
||||
.select()
|
||||
.from(users)
|
||||
.where(eq(users.serverAdmin, true));
|
||||
|
||||
if (serverAdmins.length <= 1) {
|
||||
console.error(
|
||||
"Cannot remove server admin: at least one server admin must exist"
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
await db
|
||||
.update(users)
|
||||
.set({ serverAdmin: false })
|
||||
.where(eq(users.userId, user.userId));
|
||||
|
||||
console.log(`Server admin status removed from user '${email}'`);
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
if (user.serverAdmin) {
|
||||
console.log(`User '${email}' is already a server admin`);
|
||||
process.exit(0);
|
||||
|
||||
@@ -150,16 +150,16 @@
|
||||
"siteCredentialsSaveDescription": "You will only be able to see this once. Make sure to copy it to a secure place.",
|
||||
"siteInfo": "Site Information",
|
||||
"status": "Status",
|
||||
"shareTitle": "Manage Share Links",
|
||||
"shareTitle": "Manage Shareable Links",
|
||||
"shareDescription": "Create shareable links to grant temporary or permanent access to proxy resources",
|
||||
"shareSearch": "Search share links...",
|
||||
"shareCreate": "Create Share Link",
|
||||
"shareSearch": "Search shareable links...",
|
||||
"shareCreate": "Create Shareable Link",
|
||||
"shareErrorDelete": "Failed to delete link",
|
||||
"shareErrorDeleteMessage": "An error occurred deleting link",
|
||||
"shareDeleted": "Link deleted",
|
||||
"shareDeletedDescription": "The link has been deleted",
|
||||
"shareDelete": "Delete Share Link",
|
||||
"shareDeleteConfirm": "Confirm Delete Share Link",
|
||||
"shareDelete": "Delete Shareable Link",
|
||||
"shareDeleteConfirm": "Confirm Delete Shareable Link",
|
||||
"shareQuestionRemove": "Are you sure you want to delete this share link?",
|
||||
"shareMessageRemove": "Once deleted, the link will no longer work and anyone using it will lose access to the resource.",
|
||||
"shareTokenDescription": "The access token can be passed in two ways: as a query parameter or in the request headers. These must be passed from the client on every request for authenticated access.",
|
||||
@@ -179,6 +179,7 @@
|
||||
"shareCreateDescription": "Anyone with this link can access the resource",
|
||||
"shareTitleOptional": "Title (optional)",
|
||||
"sharePathOptional": "Path (optional)",
|
||||
"sharePathDescription": "The link will redirect users to this path after authentication.",
|
||||
"expireIn": "Expire In",
|
||||
"neverExpire": "Never expire",
|
||||
"shareExpireDescription": "Expiration time is how long the link will be usable and provide access to the resource. After this time, the link will no longer work, and users who used this link will lose access to the resource.",
|
||||
@@ -211,6 +212,9 @@
|
||||
"resourcesSearch": "Search resources...",
|
||||
"resourceAdd": "Add Resource",
|
||||
"resourceErrorDelte": "Error deleting resource",
|
||||
"resourcePoliciesBannerTitle": "Re-use Authentication and Access Rules",
|
||||
"resourcePoliciesBannerDescription": "Shared resource policies let you define authentication methods and access rules once, then attach them to multiple public resources. When you update a policy, every linked resource inherits the change automatically.",
|
||||
"resourcePoliciesBannerButtonText": "Learn More",
|
||||
"resourcePoliciesTitle": "Manage Public Resource Policies",
|
||||
"resourcePoliciesAttachedResourcesColumnTitle": "Resources",
|
||||
"resourcePoliciesAttachedResources": "{count} resource(s)",
|
||||
@@ -277,7 +281,7 @@
|
||||
"back": "Back",
|
||||
"cancel": "Cancel",
|
||||
"resourceConfig": "Configuration Snippets",
|
||||
"resourceConfigDescription": "Copy and paste these configuration snippets to set up the TCP/UDP resource",
|
||||
"resourceConfigDescription": "Copy and paste these configuration snippets to set up the TCP/UDP resource.",
|
||||
"resourceAddEntrypoints": "Traefik: Add Entrypoints",
|
||||
"resourceExposePorts": "Gerbil: Expose Ports in Docker Compose",
|
||||
"resourceLearnRaw": "Learn how to configure TCP/UDP resources",
|
||||
@@ -290,6 +294,8 @@
|
||||
"labelDelete": "Delete Label",
|
||||
"labelAdd": "Add Label",
|
||||
"labelCreateSuccessMessage": "Label Created Successfully",
|
||||
"labelDuplicateError": "Duplicate Label",
|
||||
"labelDuplicateErrorDescription": "A label with this name already exists.",
|
||||
"labelEditSuccessMessage": "Label Modified Successfully",
|
||||
"labelNameField": "Label Name",
|
||||
"labelColorField": "Label Color",
|
||||
@@ -722,7 +728,7 @@
|
||||
"targetSubmit": "Add Target",
|
||||
"targetNoOne": "This resource doesn't have any targets. Add a target to configure where to send requests to the backend.",
|
||||
"targetNoOneDescription": "Adding more than one target above will enable load balancing.",
|
||||
"targetsSubmit": "Save Targets",
|
||||
"targetsSubmit": "Save Settings",
|
||||
"addTarget": "Add Target",
|
||||
"proxyMultiSiteRoundRobinNodeHelp": "Round robin routing will not work between sites that are not connected to the same node, but failover will work.",
|
||||
"targetErrorInvalidIp": "Invalid IP address",
|
||||
@@ -774,6 +780,7 @@
|
||||
"rulesErrorDuplicatePriorityDescription": "Each rule must have a unique priority number.",
|
||||
"rulesErrorValidation": "Invalid rules",
|
||||
"rulesErrorValidationRuleDescription": "Rule {ruleNumber}: {message}",
|
||||
"rulesErrorInvalidMatchTypeDescription": "Select a valid match type (path, IP, CIDR, country, region, or ASN).",
|
||||
"rulesErrorValueRequired": "Enter a value for this rule.",
|
||||
"rulesErrorInvalidCountry": "Invalid country",
|
||||
"rulesErrorInvalidCountryDescription": "Select a valid country.",
|
||||
@@ -843,6 +850,10 @@
|
||||
"policyAuthHeaderAuthSummary": "Header configured",
|
||||
"policyAuthHeaderName": "Header name",
|
||||
"policyAuthHeaderValue": "Expected value",
|
||||
"policyAuthSetPasscode": "Set Passcode",
|
||||
"policyAuthSetPincode": "Set PIN Code",
|
||||
"policyAuthSetEmailWhitelist": "Set Email Whitelist",
|
||||
"policyAuthSetHeaderAuth": "Set Basic Header Auth",
|
||||
"policyAccessRulesTitle": "Access Rules",
|
||||
"policyAccessRulesEnableDescription": "When enabled, rules are evaluated in descending order until one evaluates as true.",
|
||||
"policyAccessRulesFirstMatch": "Rules are evaluated top to bottom. The first matching rule decides the outcome.",
|
||||
@@ -872,9 +883,9 @@
|
||||
"resourcesErrorUpdateDescription": "An error occurred while updating the resource",
|
||||
"access": "Access",
|
||||
"accessControl": "Access Control",
|
||||
"shareLink": "{resource} Share Link",
|
||||
"shareLink": "{resource} Shareable Link",
|
||||
"resourceSelect": "Select resource",
|
||||
"shareLinks": "Share Links",
|
||||
"shareLinks": "Shareable Links",
|
||||
"share": "Shareable Links",
|
||||
"shareDescription2": "Create shareable links to resources. Links provide temporary or unlimited access to your resource. You can configure the expiration duration of the link when you create one.",
|
||||
"shareEasyCreate": "Easy to create and share",
|
||||
@@ -964,10 +975,18 @@
|
||||
"resourceRoleDescription": "Admins can always access this resource.",
|
||||
"resourcePolicySelectTitle": "Resource Access Policy",
|
||||
"resourcePolicySelectDescription": "Select the resource policy type for authentication",
|
||||
"resourcePolicyTypeLabel": "Policy type",
|
||||
"resourcePolicyLabel": "Resource policy",
|
||||
"resourcePolicyInline": "Inline Resource Policy",
|
||||
"resourcePolicyInlineDescription": "Access Policy scoped to only this resource",
|
||||
"resourcePolicyShared": "Shared Resource Policy",
|
||||
"resourcePolicySharedDescription": "This resource uses a shared policy. Policy-level settings (auth methods, email whitelist) are locked. You can add resource-specific rules, roles, and users below.",
|
||||
"resourcePolicySharedDescription": "This resource uses a shared policy.",
|
||||
"sharedPolicy": "Shared Policy",
|
||||
"sharedPolicyNoneDescription": "This resource has its own policy.",
|
||||
"resourceSharedPolicyOwnDescription": "This resource has its own authentication and access rules controls.",
|
||||
"resourceSharedPolicyInheritedDescription": "This resource inherits from <policyLink>{policyName}</policyLink>.",
|
||||
"resourceSharedPolicyAuthenticationNotice": "This resource is using a shared policy. Some authentication settings can be edited on this resource to add to the policy. To change the underlying policy, you must edit to <policyLink>{policyName}</policyLink>.",
|
||||
"resourceSharedPolicyRulesNotice": "This resource is using a shared policy. Some access rules can be edited on this resource. To change the underlying policy, you must edit <policyLink>{policyName}</policyLink>.",
|
||||
"resourceUsersRoles": "Access Controls",
|
||||
"resourceUsersRolesDescription": "Configure which users and roles can visit this resource",
|
||||
"resourceUsersRolesSubmit": "Save Access Controls",
|
||||
@@ -992,7 +1011,14 @@
|
||||
"resourceVisibilityTitle": "Visibility",
|
||||
"resourceVisibilityTitleDescription": "Completely enable or disable resource visibility",
|
||||
"resourceGeneral": "General Settings",
|
||||
"resourceGeneralDescription": "Configure the general settings for this resource",
|
||||
"resourceGeneralDescription": "Configure name, address, and access policy for this resource.",
|
||||
"resourceGeneralDetailsSubsection": "Resource Details",
|
||||
"resourceGeneralDetailsSubsectionDescription": "Set the display name, identifier, and publicly accessible domain for this resource.",
|
||||
"resourceGeneralDetailsSubsectionPortDescription": "Set the display name, identifier, and public port for this resource.",
|
||||
"resourceGeneralPublicAddressSubsection": "Public Address",
|
||||
"resourceGeneralPublicAddressSubsectionDescription": "Configure how users reach this resource.",
|
||||
"resourceGeneralAuthenticationAccessSubsection": "Authentication & Access",
|
||||
"resourceGeneralAuthenticationAccessSubsectionDescription": "Choose whether this resource uses its own policy or inherits from a shared policy.",
|
||||
"resourceEnable": "Enable Resource",
|
||||
"resourceTransfer": "Transfer Resource",
|
||||
"resourceTransferDescription": "Transfer this resource to a different site",
|
||||
@@ -1275,6 +1301,7 @@
|
||||
"accessLabelFilterCount": "{count, plural, one {# label} other {# labels}}",
|
||||
"labelOverflowCount": "+{count, plural, one {# label} other {# labels}}",
|
||||
"accessLabelFilterClear": "Clear label filters",
|
||||
"accessFilterClear": "Clear filters",
|
||||
"selectColor": "Select color",
|
||||
"createNewLabel": "Create new org label \"{label}\"",
|
||||
"inviteInvalidDescription": "The invite link is invalid.",
|
||||
@@ -1511,7 +1538,7 @@
|
||||
"sidebarResources": "Resources",
|
||||
"sidebarProxyResources": "Public",
|
||||
"sidebarClientResources": "Private",
|
||||
"sidebarPolicies": "Policies",
|
||||
"sidebarPolicies": "Shared Policies",
|
||||
"sidebarResourcePolicies": "Public Resources",
|
||||
"sidebarAccessControl": "Access Control",
|
||||
"sidebarLogsAndAnalytics": "Logs & Analytics",
|
||||
@@ -1520,7 +1547,7 @@
|
||||
"sidebarAdmin": "Admin",
|
||||
"sidebarInvitations": "Invitations",
|
||||
"sidebarRoles": "Roles",
|
||||
"sidebarShareableLinks": "Share Links",
|
||||
"sidebarShareableLinks": "Shareable Links",
|
||||
"sidebarApiKeys": "API Keys",
|
||||
"sidebarProvisioning": "Provisioning",
|
||||
"sidebarSettings": "Settings",
|
||||
@@ -1717,10 +1744,10 @@
|
||||
"enableDockerSocket": "Enable Docker Blueprint",
|
||||
"enableDockerSocketDescription": "Enable Docker Socket label scraping for blueprint labels. Socket path must be provided to the site connector. Read about how this works in <docsLink>the documentation</docsLink>.",
|
||||
"newtAutoUpdate": "Enable Site Auto-Update",
|
||||
"newtAutoUpdateDescription": "When enabled, site connectors will automatically update to the latest version when a new release is available.",
|
||||
"newtAutoUpdateDescription": "When enabled, site connectors will automatically download the latest version and restart themselves. This can be overridden on a per-site basis.",
|
||||
"siteAutoUpdate": "Site Auto-Update",
|
||||
"siteAutoUpdateLabel": "Enable Auto-Update",
|
||||
"siteAutoUpdateDescription": "Control whether this site's connector automatically downloads the latest version.",
|
||||
"siteAutoUpdateDescription": "When enabled, this site's connector will automatically download the latest version and restart itself.",
|
||||
"siteAutoUpdateOrgDefault": "Organization default: {state}",
|
||||
"siteAutoUpdateOverriding": "Overriding organization setting",
|
||||
"siteAutoUpdateResetToOrg": "Reset to Organization Default",
|
||||
@@ -1818,9 +1845,9 @@
|
||||
"accountSetupSuccess": "Account setup completed! Welcome to Pangolin!",
|
||||
"documentation": "Documentation",
|
||||
"saveAllSettings": "Save All Settings",
|
||||
"saveResourceTargets": "Save Targets",
|
||||
"saveResourceHttp": "Save Proxy Settings",
|
||||
"saveProxyProtocol": "Save Proxy protocol settings",
|
||||
"saveResourceTargets": "Save Settings",
|
||||
"saveResourceHttp": "Save Settings",
|
||||
"saveProxyProtocol": "Save Settings",
|
||||
"settingsUpdated": "Settings updated",
|
||||
"settingsUpdatedDescription": "Settings updated successfully",
|
||||
"settingsErrorUpdate": "Failed to update settings",
|
||||
@@ -2144,10 +2171,25 @@
|
||||
"sshSudoModeCommandsDescription": "User can run only the specified commands with sudo.",
|
||||
"sshSudo": "Allow sudo",
|
||||
"sshSudoCommands": "Sudo Commands",
|
||||
"sshSudoCommandsDescription": "Comma separated list of commands the user is allowed to run with sudo. Absolute paths must be used.",
|
||||
"sshSudoCommandsDescription": "List of commands the user is allowed to run with sudo, separated by commas, spaces, or new lines. Absolute paths must be used.",
|
||||
"sshCreateHomeDir": "Create Home Directory",
|
||||
"sshUnixGroups": "Unix Groups",
|
||||
"sshUnixGroupsDescription": "Comma separated Unix groups to add the user to on the target host.",
|
||||
"sshUnixGroupsDescription": "Unix groups to add the user to on the target host, separated by commas, spaces, or new lines.",
|
||||
"roleTextFieldPlaceholder": "Enter values, or drop a .txt or .csv file",
|
||||
"roleTextImportTitle": "Import from File",
|
||||
"roleTextImportDescription": "Importing {fileName} into {fieldLabel}.",
|
||||
"roleTextImportSkipHeader": "Skip First Row (Header)",
|
||||
"roleTextImportOverride": "Replace Existing",
|
||||
"roleTextImportAppend": "Append to Existing",
|
||||
"roleTextImportMode": "Import Mode",
|
||||
"roleTextImportPreview": "Preview",
|
||||
"roleTextImportItemCount": "{count, plural, =0 {No items to import} one {1 item to import} other {# items to import}}",
|
||||
"roleTextImportTotalCount": "{existing} existing + {imported} imported = {total} total",
|
||||
"roleTextImportConfirm": "Import",
|
||||
"roleTextImportInvalidFile": "Unsupported file type",
|
||||
"roleTextImportInvalidFileDescription": "Only .txt and .csv files are supported.",
|
||||
"roleTextImportEmpty": "No items found in file",
|
||||
"roleTextImportEmptyDescription": "The file does not contain any importable items.",
|
||||
"retryAttempts": "Retry Attempts",
|
||||
"expectedResponseCodes": "Expected Response Codes",
|
||||
"expectedResponseCodesDescription": "HTTP status code that indicates healthy status. If left blank, 200-300 is considered healthy.",
|
||||
@@ -2931,9 +2973,10 @@
|
||||
"enableProxyProtocol": "Enable Proxy Protocol",
|
||||
"proxyProtocolInfo": "Preserve client IP addresses for TCP backends",
|
||||
"proxyProtocolVersion": "Proxy Protocol Version",
|
||||
"version1": " Version 1 (Recommended)",
|
||||
"version1": "Version 1 (Recommended)",
|
||||
"version2": "Version 2",
|
||||
"versionDescription": "Version 1 is text-based and widely supported. Version 2 is binary and more efficient but less compatible. Make sure servers transport is added to dynamic config.",
|
||||
"version1Description": "Text-based and widely supported. Make sure servers transport is added to dynamic config.",
|
||||
"version2Description": "Binary and more efficient but less compatible. Make sure servers transport is added to dynamic config.",
|
||||
"warning": "Warning",
|
||||
"proxyProtocolWarning": "The backend application must be configured to accept Proxy Protocol connections. If your backend doesn't support Proxy Protocol, enabling this will break all connections so only enable this if you know what you're doing. Make sure to configure your backend to trust Proxy Protocol headers from Traefik.",
|
||||
"restarting": "Restarting...",
|
||||
@@ -3131,6 +3174,7 @@
|
||||
"maintenanceModeType": "Maintenance Mode Type",
|
||||
"showMaintenancePage": "Show a maintenance page to visitors",
|
||||
"enableMaintenanceMode": "Enable Maintenance Mode",
|
||||
"enableMaintenanceModeDescription": "When enabled, visitors will see a maintenance page instead of your resource.",
|
||||
"automatic": "Automatic",
|
||||
"automaticModeDescription": " Show maintenance page only when all backend targets are down or unhealthy. Your resource continues working normally as long as at least one target is healthy.",
|
||||
"forced": "Forced",
|
||||
@@ -3138,6 +3182,8 @@
|
||||
"warning:": "Warning:",
|
||||
"forcedeModeWarning": "All traffic will be directed to the maintenance page. Your backend resources will not receive any requests.",
|
||||
"pageTitle": "Page Title",
|
||||
"maintenancePageContentSubsection": "Page Content",
|
||||
"maintenancePageContentSubsectionDescription": "Customize the content displayed on the maintenance page",
|
||||
"pageTitleDescription": "The main heading displayed on the maintenance page",
|
||||
"maintenancePageMessage": "Maintenance Message",
|
||||
"maintenancePageMessagePlaceholder": "We'll be back soon! Our site is currently undergoing scheduled maintenance.",
|
||||
@@ -3497,14 +3543,14 @@
|
||||
"sshConnecting": "Connecting…",
|
||||
"sshInitializing": "Initializing…",
|
||||
"sshSignInTitle": "Sign in to SSH",
|
||||
"sshSignInDescription": "Enter your SSH credentials",
|
||||
"sshSignInDescription": "Enter your SSH credentials to connect",
|
||||
"sshPasswordTab": "Password",
|
||||
"sshPrivateKeyTab": "Private Key",
|
||||
"sshPrivateKeyField": "Private Key",
|
||||
"sshPrivateKeyDisclaimer": "Your private key is not stored or visible to Pangolin. Alternatively, you can use short-lived certificates for seamless authentication using your existing Pangolin identity.",
|
||||
"sshLearnMore": "Learn more",
|
||||
"sshPrivateKeyFile": "Private Key File",
|
||||
"sshAuthenticate": "Authenticate",
|
||||
"sshAuthenticate": "Connect",
|
||||
"sshTerminate": "Terminate",
|
||||
"sshPoweredBy": "Powered by",
|
||||
"sshErrorNoTarget": "No target specified",
|
||||
@@ -3548,5 +3594,7 @@
|
||||
"rdpFilesReadyToPaste": "Files ready to paste",
|
||||
"rdpFilesReadyToPasteDescription": "{count} file(s) copied to remote clipboard — press Ctrl+V on the remote desktop to paste.",
|
||||
"rdpUploadFailed": "Upload failed",
|
||||
"rdpUnicodeKeyboardMode": "Unicode keyboard mode"
|
||||
"rdpUnicodeKeyboardMode": "Unicode keyboard mode",
|
||||
"sessionToolbarShow": "Show toolbar",
|
||||
"sessionToolbarHide": "Hide toolbar"
|
||||
}
|
||||
|
||||
@@ -87,7 +87,7 @@ function createDb() {
|
||||
|
||||
export const db = createDb();
|
||||
export default db;
|
||||
export const primaryDb = db.$primary as typeof db; // is this typeof a problem - techincally they are different types
|
||||
export const primaryDb = db.$primary as typeof db; // is this typeof a problem - technically they are different types
|
||||
export type Transaction = Parameters<
|
||||
Parameters<(typeof db)["transaction"]>[0]
|
||||
>[0];
|
||||
|
||||
@@ -2,7 +2,7 @@ import { drizzle as DrizzlePostgres } from "drizzle-orm/node-postgres";
|
||||
import { readConfigFile } from "@server/lib/readConfigFile";
|
||||
import { withReplicas } from "drizzle-orm/pg-core";
|
||||
import { build } from "@server/build";
|
||||
import { db as mainDb, primaryDb as mainPrimaryDb } from "./driver";
|
||||
import { db as mainDb } from "./driver";
|
||||
import { createPool } from "./poolConfig";
|
||||
|
||||
function createLogsDb() {
|
||||
@@ -63,8 +63,7 @@ function createLogsDb() {
|
||||
})
|
||||
);
|
||||
} else {
|
||||
const maxReplicaConnections =
|
||||
poolConfig?.max_replica_connections || 20;
|
||||
const maxReplicaConnections = poolConfig?.max_replica_connections || 20;
|
||||
for (const conn of replicaConnections) {
|
||||
const replicaPool = createPool(
|
||||
conn.connection_string,
|
||||
@@ -91,4 +90,4 @@ function createLogsDb() {
|
||||
|
||||
export const logsDb = createLogsDb();
|
||||
export default logsDb;
|
||||
export const primaryLogsDb = logsDb.$primary;
|
||||
export const primaryLogsDb = logsDb.$primary;
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { Pool, PoolConfig } from "pg";
|
||||
import logger from "@server/logger";
|
||||
|
||||
export function createPoolConfig(
|
||||
connectionString: string,
|
||||
@@ -27,7 +26,7 @@ export function attachPoolErrorHandlers(pool: Pool, label: string): void {
|
||||
pool.on("error", (err) => {
|
||||
// This catches errors on idle clients in the pool. Without this
|
||||
// handler an unexpected disconnect would crash the process.
|
||||
logger.error(
|
||||
console.error(
|
||||
`Unexpected error on idle ${label} database client: ${err.message}`
|
||||
);
|
||||
});
|
||||
@@ -36,7 +35,7 @@ export function attachPoolErrorHandlers(pool: Pool, label: string): void {
|
||||
// Set a statement timeout on every new connection so a single slow
|
||||
// query can't block the pool forever
|
||||
client.query("SET statement_timeout = '30s'").catch((err: Error) => {
|
||||
logger.warn(
|
||||
console.warn(
|
||||
`Failed to set statement_timeout on ${label} client: ${err.message}`
|
||||
);
|
||||
});
|
||||
@@ -60,4 +59,4 @@ export function createPool(
|
||||
);
|
||||
attachPoolErrorHandlers(pool, label);
|
||||
return pool;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -147,12 +147,10 @@ export const resources = pgTable("resources", {
|
||||
}),
|
||||
ssl: boolean("ssl").notNull().default(false),
|
||||
blockAccess: boolean("blockAccess").notNull().default(false),
|
||||
sso: boolean("sso").notNull().default(true),
|
||||
proxyPort: integer("proxyPort"),
|
||||
emailWhitelistEnabled: boolean("emailWhitelistEnabled")
|
||||
.notNull()
|
||||
.default(false),
|
||||
applyRules: boolean("applyRules").notNull().default(false),
|
||||
sso: boolean("sso"),
|
||||
emailWhitelistEnabled: boolean("emailWhitelistEnabled"),
|
||||
applyRules: boolean("applyRules"),
|
||||
enabled: boolean("enabled").notNull().default(true),
|
||||
stickySession: boolean("stickySession").notNull().default(false),
|
||||
tlsServerName: varchar("tlsServerName"),
|
||||
|
||||
@@ -45,9 +45,9 @@ export type ResourceWithAuth = {
|
||||
password: ResourcePassword | ResourcePolicyPassword | null;
|
||||
headerAuth: ResourceHeaderAuth | ResourcePolicyHeaderAuth | null;
|
||||
headerAuthExtendedCompatibility: ResourceHeaderAuthExtendedCompatibility | null;
|
||||
applyRules: boolean;
|
||||
sso: boolean;
|
||||
emailWhitelistEnabled: boolean;
|
||||
applyRules: boolean | null;
|
||||
sso: boolean | null;
|
||||
emailWhitelistEnabled: boolean | null;
|
||||
org: Org;
|
||||
};
|
||||
|
||||
|
||||
@@ -165,14 +165,12 @@ export const resources = sqliteTable("resources", {
|
||||
blockAccess: integer("blockAccess", { mode: "boolean" })
|
||||
.notNull()
|
||||
.default(false),
|
||||
sso: integer("sso", { mode: "boolean" }).notNull().default(true),
|
||||
proxyPort: integer("proxyPort"),
|
||||
emailWhitelistEnabled: integer("emailWhitelistEnabled", { mode: "boolean" })
|
||||
.notNull()
|
||||
.default(false),
|
||||
applyRules: integer("applyRules", { mode: "boolean" })
|
||||
.notNull()
|
||||
.default(false),
|
||||
sso: integer("sso", { mode: "boolean" }),
|
||||
emailWhitelistEnabled: integer("emailWhitelistEnabled", {
|
||||
mode: "boolean"
|
||||
}),
|
||||
applyRules: integer("applyRules", { mode: "boolean" }),
|
||||
enabled: integer("enabled", { mode: "boolean" }).notNull().default(true),
|
||||
stickySession: integer("stickySession", { mode: "boolean" })
|
||||
.notNull()
|
||||
|
||||
@@ -157,7 +157,9 @@ function getOpenApiDocumentation() {
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: z.object({
|
||||
data: z.unknown().nullable(),
|
||||
data: z
|
||||
.record(z.string(), z.any())
|
||||
.nullable(),
|
||||
success: z.boolean(),
|
||||
error: z.boolean(),
|
||||
message: z.string(),
|
||||
|
||||
@@ -31,7 +31,7 @@ export enum TierFeature {
|
||||
}
|
||||
|
||||
export const tierMatrix: Record<TierFeature, Tier[]> = {
|
||||
[TierFeature.Labels]: ["tier2", "tier3", "enterprise"],
|
||||
[TierFeature.Labels]: ["tier1", "tier2", "tier3", "enterprise"],
|
||||
[TierFeature.OrgOidc]: ["tier1", "tier2", "tier3", "enterprise"],
|
||||
[TierFeature.LoginPageDomain]: ["tier1", "tier2", "tier3", "enterprise"],
|
||||
[TierFeature.DeviceApprovals]: ["tier1", "tier3", "enterprise"],
|
||||
@@ -71,16 +71,6 @@ export const tierMatrix: Record<TierFeature, Tier[]> = {
|
||||
[TierFeature.WildcardSubdomain]: ["tier1", "tier2", "tier3", "enterprise"],
|
||||
[TierFeature.NewtAutoUpdate]: ["tier1", "tier2", "tier3", "enterprise"],
|
||||
[TierFeature.ResourcePolicies]: ["tier3", "enterprise"],
|
||||
[TierFeature.AdvancedPublicResources]: [
|
||||
"tier1",
|
||||
"tier2",
|
||||
"tier3",
|
||||
"enterprise"
|
||||
],
|
||||
[TierFeature.AdvancedPrivateResources]: [
|
||||
"tier1",
|
||||
"tier2",
|
||||
"tier3",
|
||||
"enterprise"
|
||||
]
|
||||
[TierFeature.AdvancedPublicResources]: ["tier3", "enterprise"],
|
||||
[TierFeature.AdvancedPrivateResources]: ["tier3", "enterprise"]
|
||||
};
|
||||
|
||||
@@ -415,7 +415,11 @@ export async function updatePrivateResources(
|
||||
} else {
|
||||
let aliasAddress: string | null = null;
|
||||
let releaseAliasLock: (() => Promise<void>) | null = null;
|
||||
if (resourceData.mode === "host" || resourceData.mode === "http") {
|
||||
if (
|
||||
resourceData.mode === "host" ||
|
||||
resourceData.mode === "http" ||
|
||||
resourceData.mode === "ssh"
|
||||
) {
|
||||
const { value, release } = await getNextAvailableAliasAddress(
|
||||
orgId,
|
||||
trx
|
||||
|
||||
@@ -1467,17 +1467,6 @@ async function syncWhitelistUsers(
|
||||
.where(eq(resourceWhitelist.resourceId, resourceId));
|
||||
|
||||
for (const email of whitelistUsers) {
|
||||
const [user] = await trx
|
||||
.select()
|
||||
.from(users)
|
||||
.innerJoin(userOrgs, eq(users.userId, userOrgs.userId))
|
||||
.where(and(eq(users.email, email), eq(userOrgs.orgId, orgId)))
|
||||
.limit(1);
|
||||
|
||||
if (!user) {
|
||||
throw new Error(`User not found: ${email} in org ${orgId}`);
|
||||
}
|
||||
|
||||
const existingWhitelistEntry = existingWhitelist.find(
|
||||
(w) => w.email === email
|
||||
);
|
||||
|
||||
@@ -1,8 +1,23 @@
|
||||
import { z } from "zod";
|
||||
import { existsSync } from "node:fs";
|
||||
import { portRangeStringSchema } from "@server/lib/ip";
|
||||
import { MaintenanceSchema } from "#dynamic/lib/blueprints/MaintenanceSchema";
|
||||
import { isValidRegionId } from "@server/db/regions";
|
||||
import { wildcardSubdomainSchema } from "@server/lib/schemas";
|
||||
import config from "@server/lib/config";
|
||||
|
||||
const maxmindDbPath = config.getRawConfig().server.maxmind_db_path;
|
||||
const maxmindAsnPath = config.getRawConfig().server.maxmind_asn_path;
|
||||
|
||||
const hasMaxmindCountryDb =
|
||||
typeof maxmindDbPath === "string" &&
|
||||
maxmindDbPath.length > 0 &&
|
||||
existsSync(maxmindDbPath);
|
||||
|
||||
const hasMaxmindAsnDb =
|
||||
typeof maxmindAsnPath === "string" &&
|
||||
maxmindAsnPath.length > 0 &&
|
||||
existsSync(maxmindAsnPath);
|
||||
|
||||
export const SiteSchema = z.object({
|
||||
name: z.string().min(1).max(100),
|
||||
@@ -117,6 +132,9 @@ export const RuleSchema = z
|
||||
.refine(
|
||||
(rule) => {
|
||||
if (rule.match === "country") {
|
||||
if (!hasMaxmindCountryDb) {
|
||||
return false;
|
||||
}
|
||||
// Check if it's a valid 2-letter country code or "ALL"
|
||||
return /^[A-Z]{2}$/.test(rule.value) || rule.value === "ALL";
|
||||
}
|
||||
@@ -125,12 +143,15 @@ export const RuleSchema = z
|
||||
{
|
||||
path: ["value"],
|
||||
message:
|
||||
"Value must be a 2-letter country code or 'ALL' when match is 'country'"
|
||||
"Country rules require a valid existing server.maxmind_db_path and value must be a 2-letter country code or 'ALL'"
|
||||
}
|
||||
)
|
||||
.refine(
|
||||
(rule) => {
|
||||
if (rule.match === "asn") {
|
||||
if (!hasMaxmindCountryDb || !hasMaxmindAsnDb) {
|
||||
return false;
|
||||
}
|
||||
// Check if it's either AS<number> format or "ALL"
|
||||
const asNumberPattern = /^AS\d+$/i;
|
||||
return asNumberPattern.test(rule.value) || rule.value === "ALL";
|
||||
@@ -140,7 +161,7 @@ export const RuleSchema = z
|
||||
{
|
||||
path: ["value"],
|
||||
message:
|
||||
"Value must be 'AS<number>' format or 'ALL' when match is 'asn'"
|
||||
"ASN rules require valid existing server.maxmind_db_path and server.maxmind_asn_path, and value must be 'AS<number>' format or 'ALL'"
|
||||
}
|
||||
)
|
||||
.refine(
|
||||
|
||||
@@ -504,7 +504,7 @@ export function generateRemoteSubnets(
|
||||
const parseResult = cidrSchema.safeParse(sr.destination);
|
||||
return parseResult.success;
|
||||
}
|
||||
if (sr.mode === "host") {
|
||||
if (sr.mode === "host" || sr.mode === "ssh") {
|
||||
// check if its a valid IP using zod
|
||||
const ipSchema = z.union([z.ipv4(), z.ipv6()]);
|
||||
const parseResult = ipSchema.safeParse(sr.destination);
|
||||
@@ -514,7 +514,7 @@ export function generateRemoteSubnets(
|
||||
})
|
||||
.map((sr) => {
|
||||
if (sr.mode === "cidr") return sr.destination;
|
||||
if (sr.mode === "host") {
|
||||
if (sr.mode === "host" || sr.mode === "ssh") {
|
||||
return `${sr.destination}/32`;
|
||||
}
|
||||
return ""; // This should never be reached due to filtering, but satisfies TypeScript
|
||||
@@ -531,7 +531,7 @@ export function generateAliasConfig(allSiteResources: SiteResource[]): Alias[] {
|
||||
.filter(
|
||||
(sr) =>
|
||||
sr.aliasAddress &&
|
||||
((sr.alias && sr.mode == "host") ||
|
||||
((sr.alias && (sr.mode == "host" || sr.mode == "ssh")) ||
|
||||
(sr.fullDomain && sr.mode == "http"))
|
||||
)
|
||||
.map((sr) => ({
|
||||
@@ -577,6 +577,10 @@ export function generateSubnetProxyTargets(
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!siteResource.destination) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const clientPrefix = `${clientSite.subnet.split("/")[0]}/32`;
|
||||
const portRange = [
|
||||
...parsePortRangeString(siteResource.tcpPortRangeString, "tcp"),
|
||||
@@ -584,7 +588,7 @@ export function generateSubnetProxyTargets(
|
||||
];
|
||||
const disableIcmp = siteResource.disableIcmp ?? false;
|
||||
|
||||
if (siteResource.mode == "host") {
|
||||
if (siteResource.mode == "host" || siteResource.mode == "ssh") {
|
||||
let destination = siteResource.destination;
|
||||
// check if this is a valid ip
|
||||
const ipSchema = z.union([z.ipv4(), z.ipv6()]);
|
||||
@@ -665,6 +669,11 @@ export async function generateSubnetProxyTargetV2(
|
||||
return;
|
||||
}
|
||||
|
||||
if (!siteResource.destination) {
|
||||
// ssh can have no destination
|
||||
return;
|
||||
}
|
||||
|
||||
const targets: SubnetProxyTargetV2[] = [];
|
||||
|
||||
const portRange = [
|
||||
@@ -673,7 +682,7 @@ export async function generateSubnetProxyTargetV2(
|
||||
];
|
||||
const disableIcmp = siteResource.disableIcmp ?? false;
|
||||
|
||||
if (siteResource.mode == "host") {
|
||||
if (siteResource.mode == "host" || siteResource.mode == "ssh") {
|
||||
let destination = siteResource.destination;
|
||||
// check if this is a valid ip
|
||||
const ipSchema = z.union([z.ipv4(), z.ipv6()]);
|
||||
|
||||
@@ -181,6 +181,7 @@ class TelemetryClient {
|
||||
let numPrivResourceHosts = 0;
|
||||
let numPrivResourceCidr = 0;
|
||||
let numPrivResourceHttp = 0;
|
||||
let numPrivResourceSsh = 0;
|
||||
for (const res of allPrivateResources) {
|
||||
if (res.mode === "host") {
|
||||
numPrivResourceHosts += 1;
|
||||
@@ -188,6 +189,8 @@ class TelemetryClient {
|
||||
numPrivResourceCidr += 1;
|
||||
} else if (res.mode === "http") {
|
||||
numPrivResourceHttp += 1;
|
||||
} else if (res.mode === "ssh") {
|
||||
numPrivResourceSsh += 1;
|
||||
}
|
||||
|
||||
if (res.alias) {
|
||||
@@ -207,6 +210,7 @@ class TelemetryClient {
|
||||
numPrivateResourceHosts: numPrivResourceHosts,
|
||||
numPrivateResourceCidr: numPrivResourceCidr,
|
||||
numPrivateResourceHttp: numPrivResourceHttp,
|
||||
numPrivateResourceSsh: numPrivResourceSsh,
|
||||
numAlertRules: numAlertRules.count,
|
||||
numUserDevices: userDevicesCount.count,
|
||||
numMachineClients: machineClients.count,
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import z from "zod";
|
||||
import ipaddr from "ipaddr.js";
|
||||
import { COUNTRIES } from "@server/db/countries";
|
||||
import { isValidRegionId } from "@server/db/regions";
|
||||
|
||||
export function isValidCIDR(cidr: string): boolean {
|
||||
return (
|
||||
@@ -67,6 +69,45 @@ export function isValidUrlGlobPattern(pattern: string): boolean {
|
||||
return true;
|
||||
}
|
||||
|
||||
export const RESOURCE_RULE_MATCH_TYPES = [
|
||||
"CIDR",
|
||||
"IP",
|
||||
"PATH",
|
||||
"COUNTRY",
|
||||
"ASN",
|
||||
"REGION"
|
||||
] as const;
|
||||
|
||||
export type ResourceRuleMatchType = (typeof RESOURCE_RULE_MATCH_TYPES)[number];
|
||||
|
||||
export function getResourceRuleValueValidationError(
|
||||
match: ResourceRuleMatchType,
|
||||
value: string
|
||||
): string | null {
|
||||
switch (match) {
|
||||
case "CIDR":
|
||||
return isValidCIDR(value) ? null : "Invalid CIDR provided";
|
||||
case "IP":
|
||||
return isValidIP(value) ? null : "Invalid IP provided";
|
||||
case "PATH":
|
||||
return isValidUrlGlobPattern(value)
|
||||
? null
|
||||
: "Invalid URL glob pattern provided";
|
||||
case "REGION":
|
||||
return isValidRegionId(value) ? null : "Invalid region ID provided";
|
||||
case "COUNTRY":
|
||||
return COUNTRIES.some((country) => country.code === value)
|
||||
? null
|
||||
: "Invalid country code provided";
|
||||
case "ASN":
|
||||
return /^AS\d+$/i.test(value.trim())
|
||||
? null
|
||||
: "Invalid ASN provided";
|
||||
default:
|
||||
return "Invalid rule match type provided";
|
||||
}
|
||||
}
|
||||
|
||||
export function isUrlValid(url: string | undefined) {
|
||||
if (!url) return true; // the link is optional in the schema so if it's empty it's valid
|
||||
var pattern = new RegExp(
|
||||
|
||||
@@ -1,11 +1,15 @@
|
||||
import { Request, Response, NextFunction } from "express";
|
||||
import { db, Resource } from "@server/db";
|
||||
import { resources, userOrgs, userResources, roleResources } from "@server/db";
|
||||
import { and, eq, inArray } from "drizzle-orm";
|
||||
import { resources, userOrgs } from "@server/db";
|
||||
import { and, eq } from "drizzle-orm";
|
||||
import createHttpError from "http-errors";
|
||||
import HttpCode from "@server/types/HttpCode";
|
||||
import { checkOrgAccessPolicy } from "#dynamic/lib/checkOrgAccessPolicy";
|
||||
import { getUserOrgRoleIds } from "@server/lib/userOrgRoles";
|
||||
import {
|
||||
getRoleResourceAccess,
|
||||
getUserResourceAccess
|
||||
} from "@server/db/queries/verifySessionQueries";
|
||||
|
||||
export async function verifyResourceAccess(
|
||||
req: Request,
|
||||
@@ -116,37 +120,22 @@ export async function verifyResourceAccess(
|
||||
|
||||
const roleResourceAccess =
|
||||
(req.userOrgRoleIds?.length ?? 0) > 0
|
||||
? await db
|
||||
.select()
|
||||
.from(roleResources)
|
||||
.where(
|
||||
and(
|
||||
eq(roleResources.resourceId, resource.resourceId),
|
||||
inArray(
|
||||
roleResources.roleId,
|
||||
req.userOrgRoleIds!
|
||||
)
|
||||
)
|
||||
)
|
||||
.limit(1)
|
||||
: [];
|
||||
? await getRoleResourceAccess(
|
||||
resource.resourceId,
|
||||
req.userOrgRoleIds!
|
||||
)
|
||||
: null;
|
||||
|
||||
if (roleResourceAccess.length > 0) {
|
||||
if (roleResourceAccess) {
|
||||
return next();
|
||||
}
|
||||
|
||||
const userResourceAccess = await db
|
||||
.select()
|
||||
.from(userResources)
|
||||
.where(
|
||||
and(
|
||||
eq(userResources.userId, userId),
|
||||
eq(userResources.resourceId, resource.resourceId)
|
||||
)
|
||||
)
|
||||
.limit(1);
|
||||
const userResourceAccess = await getUserResourceAccess(
|
||||
userId,
|
||||
resource.resourceId
|
||||
);
|
||||
|
||||
if (userResourceAccess.length > 0) {
|
||||
if (userResourceAccess) {
|
||||
return next();
|
||||
}
|
||||
|
||||
|
||||
@@ -208,7 +208,7 @@ registry.registerPath({
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: z.object({
|
||||
data: z.unknown().nullable(),
|
||||
data: z.record(z.string(), z.any()).nullable(),
|
||||
success: z.boolean(),
|
||||
error: z.boolean(),
|
||||
message: z.string(),
|
||||
|
||||
@@ -44,7 +44,7 @@ registry.registerPath({
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: z.object({
|
||||
data: z.unknown().nullable(),
|
||||
data: z.record(z.string(), z.any()).nullable(),
|
||||
success: z.boolean(),
|
||||
error: z.boolean(),
|
||||
message: z.string(),
|
||||
@@ -112,4 +112,4 @@ export async function deleteAlertRule(
|
||||
createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred")
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -32,7 +32,10 @@ import { OpenAPITags, registry } from "@server/openApi";
|
||||
import { and, eq } from "drizzle-orm";
|
||||
import { decrypt } from "@server/lib/crypto";
|
||||
import config from "@server/lib/config";
|
||||
import { GetAlertRuleResponse, WebhookAlertConfig } from "@server/routers/alertRule/types";
|
||||
import {
|
||||
GetAlertRuleResponse,
|
||||
WebhookAlertConfig
|
||||
} from "@server/routers/alertRule/types";
|
||||
|
||||
const paramsSchema = z
|
||||
.object({
|
||||
@@ -55,7 +58,7 @@ registry.registerPath({
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: z.object({
|
||||
data: z.unknown().nullable(),
|
||||
data: z.record(z.string(), z.any()).nullable(),
|
||||
success: z.boolean(),
|
||||
error: z.boolean(),
|
||||
message: z.string(),
|
||||
|
||||
@@ -101,7 +101,7 @@ registry.registerPath({
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: z.object({
|
||||
data: z.unknown().nullable(),
|
||||
data: z.record(z.string(), z.any()).nullable(),
|
||||
success: z.boolean(),
|
||||
error: z.boolean(),
|
||||
message: z.string(),
|
||||
|
||||
@@ -44,7 +44,7 @@ registry.registerPath({
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: z.object({
|
||||
data: z.unknown().nullable(),
|
||||
data: z.record(z.string(), z.any()).nullable(),
|
||||
success: z.boolean(),
|
||||
error: z.boolean(),
|
||||
message: z.string(),
|
||||
|
||||
@@ -44,7 +44,7 @@ registry.registerPath({
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: z.object({
|
||||
data: z.unknown().nullable(),
|
||||
data: z.record(z.string(), z.any()).nullable(),
|
||||
success: z.boolean(),
|
||||
error: z.boolean(),
|
||||
message: z.string(),
|
||||
|
||||
@@ -44,7 +44,7 @@ registry.registerPath({
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: z.object({
|
||||
data: z.unknown().nullable(),
|
||||
data: z.record(z.string(), z.any()).nullable(),
|
||||
success: z.boolean(),
|
||||
error: z.boolean(),
|
||||
message: z.string(),
|
||||
@@ -72,7 +72,9 @@ export async function exportConnectionAuditLogs(
|
||||
);
|
||||
}
|
||||
|
||||
const parsedParams = queryConnectionAuditLogsParams.safeParse(req.params);
|
||||
const parsedParams = queryConnectionAuditLogsParams.safeParse(
|
||||
req.params
|
||||
);
|
||||
if (!parsedParams.success) {
|
||||
return next(
|
||||
createHttpError(
|
||||
@@ -112,4 +114,4 @@ export async function exportConnectionAuditLogs(
|
||||
createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred")
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,7 +11,14 @@
|
||||
* This file is not licensed under the AGPLv3.
|
||||
*/
|
||||
|
||||
import { accessAuditLog, logsDb, resources, siteResources, db, primaryDb } from "@server/db";
|
||||
import {
|
||||
accessAuditLog,
|
||||
logsDb,
|
||||
resources,
|
||||
siteResources,
|
||||
db,
|
||||
primaryDb
|
||||
} from "@server/db";
|
||||
import { registry } from "@server/openApi";
|
||||
import { NextFunction } from "express";
|
||||
import { Request, Response } from "express";
|
||||
@@ -150,21 +157,30 @@ export function queryAccess(data: Q) {
|
||||
.orderBy(desc(accessAuditLog.timestamp), desc(accessAuditLog.id));
|
||||
}
|
||||
|
||||
async function enrichWithResourceDetails(logs: Awaited<ReturnType<typeof queryAccess>>) {
|
||||
async function enrichWithResourceDetails(
|
||||
logs: Awaited<ReturnType<typeof queryAccess>>
|
||||
) {
|
||||
const resourceIds = logs
|
||||
.map(log => log.resourceId)
|
||||
.map((log) => log.resourceId)
|
||||
.filter((id): id is number => id !== null && id !== undefined);
|
||||
|
||||
const siteResourceIds = logs
|
||||
.filter(log => log.resourceId == null && log.siteResourceId != null)
|
||||
.map(log => log.siteResourceId)
|
||||
.filter((log) => log.resourceId == null && log.siteResourceId != null)
|
||||
.map((log) => log.siteResourceId)
|
||||
.filter((id): id is number => id !== null && id !== undefined);
|
||||
|
||||
if (resourceIds.length === 0 && siteResourceIds.length === 0) {
|
||||
return logs.map(log => ({ ...log, resourceName: null, resourceNiceId: null }));
|
||||
return logs.map((log) => ({
|
||||
...log,
|
||||
resourceName: null,
|
||||
resourceNiceId: null
|
||||
}));
|
||||
}
|
||||
|
||||
const resourceMap = new Map<number, { name: string | null; niceId: string | null }>();
|
||||
const resourceMap = new Map<
|
||||
number,
|
||||
{ name: string | null; niceId: string | null }
|
||||
>();
|
||||
|
||||
if (resourceIds.length > 0) {
|
||||
const resourceDetails = await primaryDb
|
||||
@@ -181,7 +197,10 @@ async function enrichWithResourceDetails(logs: Awaited<ReturnType<typeof queryAc
|
||||
}
|
||||
}
|
||||
|
||||
const siteResourceMap = new Map<number, { name: string | null; niceId: string | null }>();
|
||||
const siteResourceMap = new Map<
|
||||
number,
|
||||
{ name: string | null; niceId: string | null }
|
||||
>();
|
||||
|
||||
if (siteResourceIds.length > 0) {
|
||||
const siteResourceDetails = await primaryDb
|
||||
@@ -194,12 +213,15 @@ async function enrichWithResourceDetails(logs: Awaited<ReturnType<typeof queryAc
|
||||
.where(inArray(siteResources.siteResourceId, siteResourceIds));
|
||||
|
||||
for (const r of siteResourceDetails) {
|
||||
siteResourceMap.set(r.siteResourceId, { name: r.name, niceId: r.niceId });
|
||||
siteResourceMap.set(r.siteResourceId, {
|
||||
name: r.name,
|
||||
niceId: r.niceId
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Enrich logs with resource details
|
||||
return logs.map(log => {
|
||||
return logs.map((log) => {
|
||||
if (log.resourceId != null) {
|
||||
const details = resourceMap.get(log.resourceId);
|
||||
return {
|
||||
@@ -273,11 +295,11 @@ async function queryUniqueFilterAttributes(
|
||||
|
||||
// Fetch resource names from main database for the unique resource IDs
|
||||
const resourceIds = uniqueResources
|
||||
.map(row => row.id)
|
||||
.map((row) => row.id)
|
||||
.filter((id): id is number => id !== null);
|
||||
|
||||
const siteResourceIds = uniqueSiteResources
|
||||
.map(row => row.id)
|
||||
.map((row) => row.id)
|
||||
.filter((id): id is number => id !== null);
|
||||
|
||||
let resourcesWithNames: Array<{ id: number; name: string | null }> = [];
|
||||
@@ -293,7 +315,7 @@ async function queryUniqueFilterAttributes(
|
||||
|
||||
resourcesWithNames = [
|
||||
...resourcesWithNames,
|
||||
...resourceDetails.map(r => ({
|
||||
...resourceDetails.map((r) => ({
|
||||
id: r.resourceId,
|
||||
name: r.name
|
||||
}))
|
||||
@@ -311,7 +333,7 @@ async function queryUniqueFilterAttributes(
|
||||
|
||||
resourcesWithNames = [
|
||||
...resourcesWithNames,
|
||||
...siteResourceDetails.map(r => ({
|
||||
...siteResourceDetails.map((r) => ({
|
||||
id: r.siteResourceId,
|
||||
name: r.name
|
||||
}))
|
||||
@@ -344,7 +366,7 @@ registry.registerPath({
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: z.object({
|
||||
data: z.unknown().nullable(),
|
||||
data: z.record(z.string(), z.any()).nullable(),
|
||||
success: z.boolean(),
|
||||
error: z.boolean(),
|
||||
message: z.string(),
|
||||
|
||||
@@ -171,7 +171,7 @@ registry.registerPath({
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: z.object({
|
||||
data: z.unknown().nullable(),
|
||||
data: z.record(z.string(), z.any()).nullable(),
|
||||
success: z.boolean(),
|
||||
error: z.boolean(),
|
||||
message: z.string(),
|
||||
|
||||
@@ -459,7 +459,7 @@ registry.registerPath({
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: z.object({
|
||||
data: z.unknown().nullable(),
|
||||
data: z.record(z.string(), z.any()).nullable(),
|
||||
success: z.boolean(),
|
||||
error: z.boolean(),
|
||||
message: z.string(),
|
||||
|
||||
@@ -17,8 +17,7 @@ import createHttpError from "http-errors";
|
||||
import { z } from "zod";
|
||||
import { fromError } from "zod-validation-error";
|
||||
import logger from "@server/logger";
|
||||
import { sessions, sessionTransferToken } from "@server/db";
|
||||
import { db } from "@server/db";
|
||||
import { db, safeRead, sessions, sessionTransferToken } from "@server/db";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { response } from "@server/lib/response";
|
||||
import { encodeHexLowerCase } from "@oslojs/encoding";
|
||||
@@ -57,15 +56,19 @@ export async function transferSession(
|
||||
sha256(new TextEncoder().encode(token))
|
||||
);
|
||||
|
||||
const [existing] = await db
|
||||
.select()
|
||||
.from(sessionTransferToken)
|
||||
.where(eq(sessionTransferToken.token, tokenRaw))
|
||||
.innerJoin(
|
||||
sessions,
|
||||
eq(sessions.sessionId, sessionTransferToken.sessionId)
|
||||
)
|
||||
.limit(1);
|
||||
const result = await safeRead((db) =>
|
||||
db
|
||||
.select()
|
||||
.from(sessionTransferToken)
|
||||
.where(eq(sessionTransferToken.token, tokenRaw))
|
||||
.innerJoin(
|
||||
sessions,
|
||||
eq(sessions.sessionId, sessionTransferToken.sessionId)
|
||||
)
|
||||
.limit(1)
|
||||
);
|
||||
|
||||
const [existing] = result;
|
||||
|
||||
if (!existing) {
|
||||
return next(
|
||||
|
||||
@@ -45,7 +45,7 @@ const getOrgSchema = z.strictObject({
|
||||
// content: {
|
||||
// "application/json": {
|
||||
// schema: z.object({
|
||||
// data: z.unknown().nullable(),
|
||||
// data: z.record(z.string(), z.any()).nullable(),
|
||||
// success: z.boolean(),
|
||||
// error: z.boolean(),
|
||||
// message: z.string(),
|
||||
|
||||
@@ -121,7 +121,7 @@ registry.registerPath({
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: z.object({
|
||||
data: z.unknown().nullable(),
|
||||
data: z.record(z.string(), z.any()).nullable(),
|
||||
success: z.boolean(),
|
||||
error: z.boolean(),
|
||||
message: z.string(),
|
||||
|
||||
@@ -46,7 +46,7 @@ registry.registerPath({
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: z.object({
|
||||
data: z.unknown().nullable(),
|
||||
data: z.record(z.string(), z.any()).nullable(),
|
||||
success: z.boolean(),
|
||||
error: z.boolean(),
|
||||
message: z.string(),
|
||||
|
||||
@@ -29,7 +29,7 @@ import { tierMatrix } from "@server/lib/billing/tierMatrix";
|
||||
const paramsSchema = z.strictObject({});
|
||||
|
||||
const querySchema = z.strictObject({
|
||||
subdomain: z.string(),
|
||||
subdomain: z.string()
|
||||
// orgId: build === "saas" ? z.string() : z.string().optional() // Required for saas, optional otherwise
|
||||
});
|
||||
|
||||
@@ -48,7 +48,7 @@ registry.registerPath({
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: z.object({
|
||||
data: z.unknown().nullable(),
|
||||
data: z.record(z.string(), z.any()).nullable(),
|
||||
success: z.boolean(),
|
||||
error: z.boolean(),
|
||||
message: z.string(),
|
||||
|
||||
@@ -33,7 +33,8 @@ const paramsSchema = z
|
||||
registry.registerPath({
|
||||
method: "delete",
|
||||
path: "/org/{orgId}/event-streaming-destination/{destinationId}",
|
||||
description: "Delete an event streaming destination for a specific organization.",
|
||||
description:
|
||||
"Delete an event streaming destination for a specific organization.",
|
||||
tags: [OpenAPITags.Org],
|
||||
request: {
|
||||
params: paramsSchema
|
||||
@@ -44,7 +45,7 @@ registry.registerPath({
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: z.object({
|
||||
data: z.unknown().nullable(),
|
||||
data: z.record(z.string(), z.any()).nullable(),
|
||||
success: z.boolean(),
|
||||
error: z.boolean(),
|
||||
message: z.string(),
|
||||
@@ -115,4 +116,4 @@ export async function deleteEventStreamingDestination(
|
||||
createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred")
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -47,7 +47,7 @@ registry.registerPath({
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: z.object({
|
||||
data: z.unknown().nullable(),
|
||||
data: z.record(z.string(), z.any()).nullable(),
|
||||
success: z.boolean(),
|
||||
error: z.boolean(),
|
||||
message: z.string(),
|
||||
|
||||
@@ -74,7 +74,7 @@ registry.registerPath({
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: z.object({
|
||||
data: z.unknown().nullable(),
|
||||
data: z.record(z.string(), z.any()).nullable(),
|
||||
success: z.boolean(),
|
||||
error: z.boolean(),
|
||||
message: z.string(),
|
||||
|
||||
@@ -79,7 +79,10 @@ import logger from "@server/logger";
|
||||
import { decrypt } from "@server/lib/crypto";
|
||||
import config from "@server/lib/config";
|
||||
import { exchangeSession } from "@server/routers/badger";
|
||||
import { validateResourceSessionToken } from "@server/auth/sessions/resource";
|
||||
import {
|
||||
ResourceSessionValidationResult,
|
||||
validateResourceSessionToken
|
||||
} from "@server/auth/sessions/resource";
|
||||
import { checkExitNodeOrg, resolveExitNodes } from "#private/lib/exitNodes";
|
||||
import { maxmindLookup } from "@server/db/maxmind";
|
||||
import { verifyResourceAccessToken } from "@server/auth/verifyResourceAccessToken";
|
||||
@@ -216,9 +219,9 @@ export type ResourceWithAuth = {
|
||||
password: ResourcePassword | ResourcePolicyPassword | null;
|
||||
headerAuth: ResourceHeaderAuth | ResourcePolicyHeaderAuth | null;
|
||||
headerAuthExtendedCompatibility: ResourceHeaderAuthExtendedCompatibility | null;
|
||||
applyRules: boolean;
|
||||
sso: boolean;
|
||||
emailWhitelistEnabled: boolean;
|
||||
applyRules: boolean | null;
|
||||
sso: boolean | null;
|
||||
emailWhitelistEnabled: boolean | null;
|
||||
org: Org;
|
||||
};
|
||||
|
||||
@@ -1754,11 +1757,34 @@ hybridRouter.post(
|
||||
resourceId
|
||||
);
|
||||
|
||||
// this is for backward compatibility with nodes that did not have the policy id checking
|
||||
const modifiedResult: ResourceSessionValidationResult = {
|
||||
...result,
|
||||
resourceSession: result.resourceSession
|
||||
? {
|
||||
...result.resourceSession,
|
||||
// Prefer policy IDs, but keep legacy IDs populated for older nodes.
|
||||
pincodeId:
|
||||
result.resourceSession.policyPincodeId ??
|
||||
result.resourceSession.pincodeId ??
|
||||
null,
|
||||
passwordId:
|
||||
result.resourceSession.policyPasswordId ??
|
||||
result.resourceSession.passwordId ??
|
||||
null,
|
||||
whitelistId:
|
||||
result.resourceSession.policyWhitelistId ??
|
||||
result.resourceSession.whitelistId ??
|
||||
null
|
||||
}
|
||||
: null
|
||||
};
|
||||
|
||||
return response(res, {
|
||||
data: result,
|
||||
data: modifiedResult,
|
||||
success: true,
|
||||
error: false,
|
||||
message: result.resourceSession
|
||||
message: modifiedResult.resourceSession
|
||||
? "Resource session token is valid"
|
||||
: "Resource session token is invalid or expired",
|
||||
status: HttpCode.OK
|
||||
|
||||
@@ -22,7 +22,7 @@ import response from "@server/lib/response";
|
||||
import logger from "@server/logger";
|
||||
import type { CreateOrEditLabelResponse } from "@server/routers/labels/types";
|
||||
import HttpCode from "@server/types/HttpCode";
|
||||
import { and, eq } from "drizzle-orm";
|
||||
import { and, eq, sql } from "drizzle-orm";
|
||||
import { NextFunction, Request, Response } from "express";
|
||||
import createHttpError from "http-errors";
|
||||
import { z } from "zod";
|
||||
@@ -107,6 +107,26 @@ export async function createOrgLabel(
|
||||
}
|
||||
}
|
||||
|
||||
const [existingLabel] = await db
|
||||
.select({ labelId: labels.labelId })
|
||||
.from(labels)
|
||||
.where(
|
||||
and(
|
||||
eq(labels.orgId, orgId),
|
||||
sql`LOWER(${labels.name}) = ${name.toLowerCase()}`
|
||||
)
|
||||
)
|
||||
.limit(1);
|
||||
|
||||
if (existingLabel) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.CONFLICT,
|
||||
"A label with this name already exists"
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
const label = await db.transaction(async (tx) => {
|
||||
const [label] = await tx
|
||||
.insert(labels)
|
||||
|
||||
@@ -16,7 +16,7 @@ import response from "@server/lib/response";
|
||||
import logger from "@server/logger";
|
||||
import type { CreateOrEditLabelResponse } from "@server/routers/labels/types";
|
||||
import HttpCode from "@server/types/HttpCode";
|
||||
import { and, eq } from "drizzle-orm";
|
||||
import { and, eq, ne, sql } from "drizzle-orm";
|
||||
import { NextFunction, Request, Response } from "express";
|
||||
import createHttpError from "http-errors";
|
||||
import { z } from "zod";
|
||||
@@ -74,6 +74,29 @@ export async function updateOrgLabel(
|
||||
|
||||
const { name, color } = parsedBody.data;
|
||||
|
||||
if (name && name.toLowerCase() !== existing.name.toLowerCase()) {
|
||||
const [duplicateLabel] = await db
|
||||
.select({ labelId: labels.labelId })
|
||||
.from(labels)
|
||||
.where(
|
||||
and(
|
||||
eq(labels.orgId, orgId),
|
||||
ne(labels.labelId, labelId),
|
||||
sql`LOWER(${labels.name}) = ${name.toLowerCase()}`
|
||||
)
|
||||
)
|
||||
.limit(1);
|
||||
|
||||
if (duplicateLabel) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.CONFLICT,
|
||||
"A label with this name already exists"
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const [label] = await db
|
||||
.update(labels)
|
||||
.set({
|
||||
|
||||
@@ -69,7 +69,7 @@ registry.registerPath({
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: z.object({
|
||||
data: z.unknown().nullable(),
|
||||
data: z.record(z.string(), z.any()).nullable(),
|
||||
success: z.boolean(),
|
||||
error: z.boolean(),
|
||||
message: z.string(),
|
||||
@@ -127,7 +127,8 @@ export async function createOrgOidcIdp(
|
||||
|
||||
let { autoProvision } = parsedBody.data;
|
||||
|
||||
if (build == "saas") { // this is not paywalled with a ee license because this whole endpoint is restricted
|
||||
if (build == "saas") {
|
||||
// this is not paywalled with a ee license because this whole endpoint is restricted
|
||||
const subscribed = await isSubscribed(
|
||||
orgId,
|
||||
tierMatrix.deviceApprovals
|
||||
|
||||
@@ -44,7 +44,7 @@ registry.registerPath({
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: z.object({
|
||||
data: z.unknown().nullable(),
|
||||
data: z.record(z.string(), z.any()).nullable(),
|
||||
success: z.boolean(),
|
||||
error: z.boolean(),
|
||||
message: z.string(),
|
||||
|
||||
@@ -62,7 +62,7 @@ registry.registerPath({
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: z.object({
|
||||
data: z.unknown().nullable(),
|
||||
data: z.record(z.string(), z.any()).nullable(),
|
||||
success: z.boolean(),
|
||||
error: z.boolean(),
|
||||
message: z.string(),
|
||||
|
||||
@@ -78,7 +78,7 @@ registry.registerPath({
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: z.object({
|
||||
data: z.unknown().nullable(),
|
||||
data: z.record(z.string(), z.any()).nullable(),
|
||||
success: z.boolean(),
|
||||
error: z.boolean(),
|
||||
message: z.string(),
|
||||
|
||||
@@ -33,9 +33,8 @@ import {
|
||||
import { getUniqueResourcePolicyName } from "@server/db/names";
|
||||
import response from "@server/lib/response";
|
||||
import {
|
||||
isValidCIDR,
|
||||
isValidIP,
|
||||
isValidUrlGlobPattern
|
||||
getResourceRuleValueValidationError,
|
||||
RESOURCE_RULE_MATCH_TYPES
|
||||
} from "@server/lib/validators";
|
||||
import logger from "@server/logger";
|
||||
import { OpenAPITags, registry } from "@server/openApi";
|
||||
@@ -56,9 +55,9 @@ const ruleSchema = z.strictObject({
|
||||
enum: ["ACCEPT", "DROP", "PASS"],
|
||||
description: "rule action"
|
||||
}),
|
||||
match: z.enum(["CIDR", "IP", "PATH"]).openapi({
|
||||
match: z.enum(RESOURCE_RULE_MATCH_TYPES).openapi({
|
||||
type: "string",
|
||||
enum: ["CIDR", "IP", "PATH"],
|
||||
enum: [...RESOURCE_RULE_MATCH_TYPES],
|
||||
description: "rule match"
|
||||
}),
|
||||
value: z.string().min(1),
|
||||
@@ -261,26 +260,13 @@ export async function createResourcePolicy(
|
||||
const niceId = await getUniqueResourcePolicyName(orgId);
|
||||
|
||||
for (const rule of rules) {
|
||||
if (rule.match === "CIDR" && !isValidCIDR(rule.value)) {
|
||||
const validationError = getResourceRuleValueValidationError(
|
||||
rule.match,
|
||||
rule.value
|
||||
);
|
||||
if (validationError) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.BAD_REQUEST,
|
||||
"Invalid CIDR provided"
|
||||
)
|
||||
);
|
||||
} else if (rule.match === "IP" && !isValidIP(rule.value)) {
|
||||
return next(
|
||||
createHttpError(HttpCode.BAD_REQUEST, "Invalid IP provided")
|
||||
);
|
||||
} else if (
|
||||
rule.match === "PATH" &&
|
||||
!isValidUrlGlobPattern(rule.value)
|
||||
) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.BAD_REQUEST,
|
||||
"Invalid URL glob pattern provided"
|
||||
)
|
||||
createHttpError(HttpCode.BAD_REQUEST, validationError)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,202 @@
|
||||
/*
|
||||
* This file is part of a proprietary work.
|
||||
*
|
||||
* Copyright (c) 2025-2026 Fossorial, Inc.
|
||||
* All rights reserved.
|
||||
*
|
||||
* This file is licensed under the Fossorial Commercial License.
|
||||
* You may not use this file except in compliance with the License.
|
||||
* Unauthorized use, copying, modification, or distribution is strictly prohibited.
|
||||
*
|
||||
* This file is not licensed under the AGPLv3.
|
||||
*/
|
||||
|
||||
import axios from "axios";
|
||||
import { db, exitNodes, newts, sites } from "@server/db";
|
||||
import { eq } from "drizzle-orm";
|
||||
import logger from "@server/logger";
|
||||
import redisManager from "#private/lib/redis";
|
||||
import { sendToClient } from "#private/routers/ws";
|
||||
|
||||
const INITIAL_DELAY_MS = 15 * 1000; // 15 seconds before first check
|
||||
const CHECK_INTERVAL_MS = 10 * 1000; // Check every 10 seconds
|
||||
const MAX_DURATION_MS = 5 * 60 * 1000; // Give up after 5 minutes
|
||||
const REDIS_PENDING_SET = "exit-node-reconnect-pending";
|
||||
const REDIS_HASH_PREFIX = "exit-node-reconnect:";
|
||||
|
||||
interface PendingReconnect {
|
||||
startTime: number;
|
||||
reachableAt: string;
|
||||
}
|
||||
|
||||
// In-memory tracking for this node
|
||||
const pendingReconnects = new Map<number, PendingReconnect>();
|
||||
|
||||
let schedulerInterval: NodeJS.Timeout | null = null;
|
||||
|
||||
/**
|
||||
* Schedules a reconnect check for newts connected to the given exit node.
|
||||
* Called when an exit node transitions from offline to online.
|
||||
*/
|
||||
export async function scheduleExitNodeReconnect(
|
||||
exitNodeId: number,
|
||||
reachableAt: string
|
||||
): Promise<void> {
|
||||
logger.info(
|
||||
`Scheduling newt reconnect for exit node ${exitNodeId} (reachableAt: ${reachableAt})`
|
||||
);
|
||||
|
||||
const entry: PendingReconnect = {
|
||||
startTime: Date.now(),
|
||||
reachableAt
|
||||
};
|
||||
|
||||
pendingReconnects.set(exitNodeId, entry);
|
||||
|
||||
// Store in Redis if available for cross-node coordination
|
||||
if (redisManager.isRedisEnabled()) {
|
||||
await redisManager.sadd(REDIS_PENDING_SET, exitNodeId.toString());
|
||||
await redisManager.hset(
|
||||
`${REDIS_HASH_PREFIX}${exitNodeId}`,
|
||||
"startTime",
|
||||
entry.startTime.toString()
|
||||
);
|
||||
await redisManager.hset(
|
||||
`${REDIS_HASH_PREFIX}${exitNodeId}`,
|
||||
"reachableAt",
|
||||
reachableAt
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Starts the background interval that checks pending exit node reconnects.
|
||||
*/
|
||||
export function startExitNodeReconnectScheduler(): void {
|
||||
if (schedulerInterval) {
|
||||
return;
|
||||
}
|
||||
|
||||
schedulerInterval = setInterval(async () => {
|
||||
try {
|
||||
await processPendingReconnects();
|
||||
} catch (error) {
|
||||
logger.error("Error in exit node reconnect scheduler", { error });
|
||||
}
|
||||
}, CHECK_INTERVAL_MS);
|
||||
|
||||
logger.debug("Started exit node reconnect scheduler");
|
||||
}
|
||||
|
||||
async function processPendingReconnects(): Promise<void> {
|
||||
// Merge in-memory and Redis-tracked pending reconnects
|
||||
const toProcess = new Map(pendingReconnects);
|
||||
|
||||
if (redisManager.isRedisEnabled()) {
|
||||
const redisIds = await redisManager.smembers(REDIS_PENDING_SET);
|
||||
for (const idStr of redisIds) {
|
||||
const id = parseInt(idStr, 10);
|
||||
if (!toProcess.has(id)) {
|
||||
const startTimeStr = await redisManager.hget(
|
||||
`${REDIS_HASH_PREFIX}${id}`,
|
||||
"startTime"
|
||||
);
|
||||
const reachableAt = await redisManager.hget(
|
||||
`${REDIS_HASH_PREFIX}${id}`,
|
||||
"reachableAt"
|
||||
);
|
||||
if (startTimeStr && reachableAt) {
|
||||
toProcess.set(id, {
|
||||
startTime: parseInt(startTimeStr, 10),
|
||||
reachableAt
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const now = Date.now();
|
||||
|
||||
for (const [exitNodeId, entry] of toProcess) {
|
||||
const elapsed = now - entry.startTime;
|
||||
|
||||
// Give up after max duration
|
||||
if (elapsed >= MAX_DURATION_MS) {
|
||||
logger.warn(
|
||||
`Exit node reconnect check timed out for exit node ${exitNodeId} after 5 minutes`
|
||||
);
|
||||
await removePending(exitNodeId);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Respect initial delay
|
||||
if (elapsed < INITIAL_DELAY_MS) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Check if the exit node HTTP endpoint is reachable
|
||||
const pingUrl = `${entry.reachableAt}/ping`;
|
||||
try {
|
||||
await axios.get(pingUrl, { timeout: 5000 });
|
||||
} catch {
|
||||
logger.debug(
|
||||
`Exit node ${exitNodeId} not yet reachable at ${pingUrl}`
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Node is reachable — send reconnect to all connected newts
|
||||
logger.info(
|
||||
`Exit node ${exitNodeId} is reachable. Sending newt/wg/reconnect to connected newts.`
|
||||
);
|
||||
|
||||
await sendReconnectToNewts(exitNodeId);
|
||||
await removePending(exitNodeId);
|
||||
}
|
||||
}
|
||||
|
||||
async function sendReconnectToNewts(exitNodeId: number): Promise<void> {
|
||||
try {
|
||||
const connectedNewts = await db
|
||||
.select({ newtId: newts.newtId })
|
||||
.from(newts)
|
||||
.innerJoin(sites, eq(newts.siteId, sites.siteId))
|
||||
.where(eq(sites.exitNodeId, exitNodeId));
|
||||
|
||||
if (connectedNewts.length === 0) {
|
||||
logger.debug(
|
||||
`No newts found for exit node ${exitNodeId}, nothing to reconnect`
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
logger.info(
|
||||
`Sending newt/wg/reconnect to ${connectedNewts.length} newt(s) for exit node ${exitNodeId}`
|
||||
);
|
||||
|
||||
const reconnectMessage = {
|
||||
type: "newt/wg/reconnect",
|
||||
data: {}
|
||||
};
|
||||
|
||||
await Promise.allSettled(
|
||||
connectedNewts.map(({ newtId }) =>
|
||||
sendToClient(newtId, reconnectMessage)
|
||||
)
|
||||
);
|
||||
} catch (error) {
|
||||
logger.error(
|
||||
`Failed to send reconnect messages for exit node ${exitNodeId}`,
|
||||
{ error }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async function removePending(exitNodeId: number): Promise<void> {
|
||||
pendingReconnects.delete(exitNodeId);
|
||||
|
||||
if (redisManager.isRedisEnabled()) {
|
||||
await redisManager.srem(REDIS_PENDING_SET, exitNodeId.toString());
|
||||
await redisManager.del(`${REDIS_HASH_PREFIX}${exitNodeId}`);
|
||||
}
|
||||
}
|
||||
@@ -16,6 +16,7 @@ import { MessageHandler } from "@server/routers/ws";
|
||||
import { RemoteExitNode } from "@server/db";
|
||||
import { eq } from "drizzle-orm";
|
||||
import logger from "@server/logger";
|
||||
import { scheduleExitNodeReconnect } from "./exitNodeReconnectScheduler";
|
||||
|
||||
/**
|
||||
* Handles ping messages from clients and responds with pong
|
||||
@@ -37,6 +38,13 @@ export const handleRemoteExitNodePingMessage: MessageHandler = async (
|
||||
}
|
||||
|
||||
try {
|
||||
// Fetch the current state before updating so we can detect the offline→online transition
|
||||
const [currentExitNode] = await db
|
||||
.select({ online: exitNodes.online, reachableAt: exitNodes.reachableAt })
|
||||
.from(exitNodes)
|
||||
.where(eq(exitNodes.exitNodeId, remoteExitNode.exitNodeId))
|
||||
.limit(1);
|
||||
|
||||
// Update the exit node's last ping timestamp
|
||||
await db
|
||||
.update(exitNodes)
|
||||
@@ -45,6 +53,16 @@ export const handleRemoteExitNodePingMessage: MessageHandler = async (
|
||||
online: true
|
||||
})
|
||||
.where(eq(exitNodes.exitNodeId, remoteExitNode.exitNodeId));
|
||||
|
||||
// If the exit node was offline and is now coming online, schedule newt reconnects
|
||||
if (currentExitNode && !currentExitNode.online && currentExitNode.reachableAt) {
|
||||
scheduleExitNodeReconnect(
|
||||
remoteExitNode.exitNodeId,
|
||||
currentExitNode.reachableAt
|
||||
).catch((error) => {
|
||||
logger.error("Failed to schedule exit node reconnect", { error });
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error("Error handling ping message", { error });
|
||||
}
|
||||
|
||||
@@ -22,3 +22,4 @@ export * from "./listRemoteExitNodes";
|
||||
export * from "./pickRemoteExitNodeDefaults";
|
||||
export * from "./quickStartRemoteExitNode";
|
||||
export * from "./offlineChecker";
|
||||
export * from "./exitNodeReconnectScheduler";
|
||||
|
||||
@@ -44,7 +44,7 @@ registry.registerPath({
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: z.object({
|
||||
data: z.unknown().nullable(),
|
||||
data: z.record(z.string(), z.any()).nullable(),
|
||||
success: z.boolean(),
|
||||
error: z.boolean(),
|
||||
message: z.string(),
|
||||
|
||||
@@ -45,7 +45,7 @@ registry.registerPath({
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: z.object({
|
||||
data: z.unknown().nullable(),
|
||||
data: z.record(z.string(), z.any()).nullable(),
|
||||
success: z.boolean(),
|
||||
error: z.boolean(),
|
||||
message: z.string(),
|
||||
|
||||
@@ -14,7 +14,8 @@
|
||||
import {
|
||||
handleRemoteExitNodeRegisterMessage,
|
||||
handleRemoteExitNodePingMessage,
|
||||
startRemoteExitNodeOfflineChecker
|
||||
startRemoteExitNodeOfflineChecker,
|
||||
startExitNodeReconnectScheduler
|
||||
} from "#private/routers/remoteExitNode";
|
||||
import { MessageHandler } from "@server/routers/ws";
|
||||
import { build } from "@server/build";
|
||||
@@ -29,4 +30,5 @@ export const messageHandlers: Record<string, MessageHandler> = {
|
||||
|
||||
if (build != "saas") {
|
||||
startRemoteExitNodeOfflineChecker(); // this is to handle the offline check for remote exit nodes
|
||||
startExitNodeReconnectScheduler(); // check pending exit node reconnects and notify newts
|
||||
}
|
||||
|
||||
@@ -28,7 +28,7 @@ registry.registerPath({
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: z.object({
|
||||
data: z.unknown().nullable(),
|
||||
data: z.record(z.string(), z.any()).nullable(),
|
||||
success: z.boolean(),
|
||||
error: z.boolean(),
|
||||
message: z.string(),
|
||||
|
||||
@@ -61,7 +61,7 @@ registry.registerPath({
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: z.object({
|
||||
data: z.unknown().nullable(),
|
||||
data: z.record(z.string(), z.any()).nullable(),
|
||||
success: z.boolean(),
|
||||
error: z.boolean(),
|
||||
message: z.string(),
|
||||
|
||||
@@ -135,7 +135,7 @@ registry.registerPath({
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: z.object({
|
||||
data: z.unknown().nullable(),
|
||||
data: z.record(z.string(), z.any()).nullable(),
|
||||
success: z.boolean(),
|
||||
error: z.boolean(),
|
||||
message: z.string(),
|
||||
@@ -164,7 +164,7 @@ registry.registerPath({
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: z.object({
|
||||
data: z.unknown().nullable(),
|
||||
data: z.record(z.string(), z.any()).nullable(),
|
||||
success: z.boolean(),
|
||||
error: z.boolean(),
|
||||
message: z.string(),
|
||||
|
||||
@@ -28,7 +28,7 @@ registry.registerPath({
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: z.object({
|
||||
data: z.unknown().nullable(),
|
||||
data: z.record(z.string(), z.any()).nullable(),
|
||||
success: z.boolean(),
|
||||
error: z.boolean(),
|
||||
message: z.string(),
|
||||
|
||||
@@ -42,7 +42,7 @@ registry.registerPath({
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: z.object({
|
||||
data: z.unknown().nullable(),
|
||||
data: z.record(z.string(), z.any()).nullable(),
|
||||
success: z.boolean(),
|
||||
error: z.boolean(),
|
||||
message: z.string(),
|
||||
|
||||
@@ -35,7 +35,7 @@ registry.registerPath({
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: z.object({
|
||||
data: z.unknown().nullable(),
|
||||
data: z.record(z.string(), z.any()).nullable(),
|
||||
success: z.boolean(),
|
||||
error: z.boolean(),
|
||||
message: z.string(),
|
||||
|
||||
@@ -162,7 +162,7 @@ registry.registerPath({
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: z.object({
|
||||
data: z.unknown().nullable(),
|
||||
data: z.record(z.string(), z.any()).nullable(),
|
||||
success: z.boolean(),
|
||||
error: z.boolean(),
|
||||
message: z.string(),
|
||||
|
||||
@@ -1,4 +1,11 @@
|
||||
import { logsDb, requestAuditLog, resources, siteResources, db, primaryDb } from "@server/db";
|
||||
import {
|
||||
logsDb,
|
||||
requestAuditLog,
|
||||
resources,
|
||||
siteResources,
|
||||
db,
|
||||
primaryDb
|
||||
} from "@server/db";
|
||||
import { registry } from "@server/openApi";
|
||||
import { NextFunction } from "express";
|
||||
import { Request, Response } from "express";
|
||||
@@ -127,16 +134,16 @@ export function queryRequest(data: Q) {
|
||||
return logsDb
|
||||
.select({
|
||||
id: requestAuditLog.id,
|
||||
timestamp: requestAuditLog.timestamp,
|
||||
orgId: requestAuditLog.orgId,
|
||||
action: requestAuditLog.action,
|
||||
reason: requestAuditLog.reason,
|
||||
actorType: requestAuditLog.actorType,
|
||||
actor: requestAuditLog.actor,
|
||||
actorId: requestAuditLog.actorId,
|
||||
resourceId: requestAuditLog.resourceId,
|
||||
siteResourceId: requestAuditLog.siteResourceId,
|
||||
ip: requestAuditLog.ip,
|
||||
timestamp: requestAuditLog.timestamp,
|
||||
orgId: requestAuditLog.orgId,
|
||||
action: requestAuditLog.action,
|
||||
reason: requestAuditLog.reason,
|
||||
actorType: requestAuditLog.actorType,
|
||||
actor: requestAuditLog.actor,
|
||||
actorId: requestAuditLog.actorId,
|
||||
resourceId: requestAuditLog.resourceId,
|
||||
siteResourceId: requestAuditLog.siteResourceId,
|
||||
ip: requestAuditLog.ip,
|
||||
location: requestAuditLog.location,
|
||||
userAgent: requestAuditLog.userAgent,
|
||||
metadata: requestAuditLog.metadata,
|
||||
@@ -154,21 +161,30 @@ export function queryRequest(data: Q) {
|
||||
.orderBy(desc(requestAuditLog.timestamp));
|
||||
}
|
||||
|
||||
async function enrichWithResourceDetails(logs: Awaited<ReturnType<typeof queryRequest>>) {
|
||||
async function enrichWithResourceDetails(
|
||||
logs: Awaited<ReturnType<typeof queryRequest>>
|
||||
) {
|
||||
const resourceIds = logs
|
||||
.map(log => log.resourceId)
|
||||
.map((log) => log.resourceId)
|
||||
.filter((id): id is number => id !== null && id !== undefined);
|
||||
|
||||
const siteResourceIds = logs
|
||||
.filter(log => log.resourceId == null && log.siteResourceId != null)
|
||||
.map(log => log.siteResourceId)
|
||||
.filter((log) => log.resourceId == null && log.siteResourceId != null)
|
||||
.map((log) => log.siteResourceId)
|
||||
.filter((id): id is number => id !== null && id !== undefined);
|
||||
|
||||
if (resourceIds.length === 0 && siteResourceIds.length === 0) {
|
||||
return logs.map(log => ({ ...log, resourceName: null, resourceNiceId: null }));
|
||||
return logs.map((log) => ({
|
||||
...log,
|
||||
resourceName: null,
|
||||
resourceNiceId: null
|
||||
}));
|
||||
}
|
||||
|
||||
const resourceMap = new Map<number, { name: string | null; niceId: string | null }>();
|
||||
const resourceMap = new Map<
|
||||
number,
|
||||
{ name: string | null; niceId: string | null }
|
||||
>();
|
||||
|
||||
if (resourceIds.length > 0) {
|
||||
const resourceDetails = await primaryDb
|
||||
@@ -185,7 +201,10 @@ async function enrichWithResourceDetails(logs: Awaited<ReturnType<typeof queryRe
|
||||
}
|
||||
}
|
||||
|
||||
const siteResourceMap = new Map<number, { name: string | null; niceId: string | null }>();
|
||||
const siteResourceMap = new Map<
|
||||
number,
|
||||
{ name: string | null; niceId: string | null }
|
||||
>();
|
||||
|
||||
if (siteResourceIds.length > 0) {
|
||||
const siteResourceDetails = await primaryDb
|
||||
@@ -198,12 +217,15 @@ async function enrichWithResourceDetails(logs: Awaited<ReturnType<typeof queryRe
|
||||
.where(inArray(siteResources.siteResourceId, siteResourceIds));
|
||||
|
||||
for (const r of siteResourceDetails) {
|
||||
siteResourceMap.set(r.siteResourceId, { name: r.name, niceId: r.niceId });
|
||||
siteResourceMap.set(r.siteResourceId, {
|
||||
name: r.name,
|
||||
niceId: r.niceId
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Enrich logs with resource details
|
||||
return logs.map(log => {
|
||||
return logs.map((log) => {
|
||||
if (log.resourceId != null) {
|
||||
const details = resourceMap.get(log.resourceId);
|
||||
return {
|
||||
@@ -247,7 +269,7 @@ registry.registerPath({
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: z.object({
|
||||
data: z.unknown().nullable(),
|
||||
data: z.record(z.string(), z.any()).nullable(),
|
||||
success: z.boolean(),
|
||||
error: z.boolean(),
|
||||
message: z.string(),
|
||||
@@ -333,11 +355,11 @@ async function queryUniqueFilterAttributes(
|
||||
|
||||
// Fetch resource names from main database for the unique resource IDs
|
||||
const resourceIds = uniqueResources
|
||||
.map(row => row.id)
|
||||
.map((row) => row.id)
|
||||
.filter((id): id is number => id !== null);
|
||||
|
||||
const siteResourceIds = uniqueSiteResources
|
||||
.map(row => row.id)
|
||||
.map((row) => row.id)
|
||||
.filter((id): id is number => id !== null);
|
||||
|
||||
let resourcesWithNames: Array<{ id: number; name: string | null }> = [];
|
||||
@@ -353,7 +375,7 @@ async function queryUniqueFilterAttributes(
|
||||
|
||||
resourcesWithNames = [
|
||||
...resourcesWithNames,
|
||||
...resourceDetails.map(r => ({
|
||||
...resourceDetails.map((r) => ({
|
||||
id: r.resourceId,
|
||||
name: r.name
|
||||
}))
|
||||
@@ -371,7 +393,7 @@ async function queryUniqueFilterAttributes(
|
||||
|
||||
resourcesWithNames = [
|
||||
...resourcesWithNames,
|
||||
...siteResourceDetails.map(r => ({
|
||||
...siteResourceDetails.map((r) => ({
|
||||
id: r.siteResourceId,
|
||||
name: r.name
|
||||
}))
|
||||
|
||||
@@ -1,14 +1,7 @@
|
||||
import { Request, Response, NextFunction } from "express";
|
||||
import { z } from "zod";
|
||||
import { db } from "@server/db";
|
||||
import {
|
||||
users,
|
||||
userOrgs,
|
||||
orgs,
|
||||
idpOrg,
|
||||
idp,
|
||||
idpOidcConfig
|
||||
} from "@server/db";
|
||||
import { users, userOrgs, orgs, idpOrg, idp, idpOidcConfig } from "@server/db";
|
||||
import { eq, or, sql, and, isNotNull, inArray } from "drizzle-orm";
|
||||
import response from "@server/lib/response";
|
||||
import HttpCode from "@server/types/HttpCode";
|
||||
@@ -57,7 +50,7 @@ export type LookupUserResponse = {
|
||||
// content: {
|
||||
// "application/json": {
|
||||
// schema: z.object({
|
||||
// data: z.unknown().nullable(),
|
||||
// data: z.record(z.string(), z.any()).nullable(),
|
||||
// success: z.boolean(),
|
||||
// error: z.boolean(),
|
||||
// message: z.string(),
|
||||
@@ -169,46 +162,54 @@ export async function lookupUser(
|
||||
);
|
||||
|
||||
// Deduplicate orgs (user might have multiple memberships in same org)
|
||||
const uniqueOrgs = new Map<string, typeof userOrgMemberships[0]>();
|
||||
const uniqueOrgs = new Map<
|
||||
string,
|
||||
(typeof userOrgMemberships)[0]
|
||||
>();
|
||||
for (const membership of userOrgMemberships) {
|
||||
if (!uniqueOrgs.has(membership.orgId)) {
|
||||
uniqueOrgs.set(membership.orgId, membership);
|
||||
}
|
||||
}
|
||||
|
||||
const orgsData = Array.from(uniqueOrgs.values()).map((membership) => {
|
||||
// Get IdPs for this org where the user (with the exact identifier) is authenticated via that IdP
|
||||
// Only show IdPs where the user's idpId matches
|
||||
// Internal users don't have an idpId, so they won't see any IdPs
|
||||
const orgIdpsList = orgIdps
|
||||
.filter((idp) => {
|
||||
if (idp.orgId !== membership.orgId) {
|
||||
const orgsData = Array.from(uniqueOrgs.values()).map(
|
||||
(membership) => {
|
||||
// Get IdPs for this org where the user (with the exact identifier) is authenticated via that IdP
|
||||
// Only show IdPs where the user's idpId matches
|
||||
// Internal users don't have an idpId, so they won't see any IdPs
|
||||
const orgIdpsList = orgIdps
|
||||
.filter((idp) => {
|
||||
if (idp.orgId !== membership.orgId) {
|
||||
return false;
|
||||
}
|
||||
// Only show IdPs where the user (with exact identifier) is authenticated via that IdP
|
||||
// This means user.idpId must match idp.idpId
|
||||
if (
|
||||
user.idpId !== null &&
|
||||
user.idpId === idp.idpId
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
// Only show IdPs where the user (with exact identifier) is authenticated via that IdP
|
||||
// This means user.idpId must match idp.idpId
|
||||
if (user.idpId !== null && user.idpId === idp.idpId) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
})
|
||||
.map((idp) => ({
|
||||
idpId: idp.idpId,
|
||||
name: idp.idpName,
|
||||
variant: idp.variant
|
||||
}));
|
||||
})
|
||||
.map((idp) => ({
|
||||
idpId: idp.idpId,
|
||||
name: idp.idpName,
|
||||
variant: idp.variant
|
||||
}));
|
||||
|
||||
// Check if user has internal auth for this org
|
||||
// User has internal auth if they have an internal account type
|
||||
const orgHasInternalAuth = hasInternalAuth;
|
||||
// Check if user has internal auth for this org
|
||||
// User has internal auth if they have an internal account type
|
||||
const orgHasInternalAuth = hasInternalAuth;
|
||||
|
||||
return {
|
||||
orgId: membership.orgId,
|
||||
orgName: membership.orgName,
|
||||
idps: orgIdpsList,
|
||||
hasInternalAuth: orgHasInternalAuth
|
||||
};
|
||||
});
|
||||
return {
|
||||
orgId: membership.orgId,
|
||||
orgName: membership.orgName,
|
||||
idps: orgIdpsList,
|
||||
hasInternalAuth: orgHasInternalAuth
|
||||
};
|
||||
}
|
||||
);
|
||||
|
||||
accounts.push({
|
||||
userId: user.userId,
|
||||
|
||||
@@ -20,7 +20,8 @@ import {
|
||||
ResourcePolicyPincode,
|
||||
ResourcePolicyPassword,
|
||||
ResourcePolicyHeaderAuth,
|
||||
ResourceRule
|
||||
ResourceRule,
|
||||
ResourceSession
|
||||
} from "@server/db";
|
||||
import config from "@server/lib/config";
|
||||
import { isIpInCidr, stripPortFromHost } from "@server/lib/ip";
|
||||
@@ -144,9 +145,9 @@ export async function verifyResourceSession(
|
||||
| ResourcePolicyHeaderAuth
|
||||
| null;
|
||||
headerAuthExtendedCompatibility: ResourceHeaderAuthExtendedCompatibility | null;
|
||||
applyRules: boolean;
|
||||
sso: boolean;
|
||||
emailWhitelistEnabled: boolean;
|
||||
applyRules: boolean | null;
|
||||
sso: boolean | null;
|
||||
emailWhitelistEnabled: boolean | null;
|
||||
org: Org;
|
||||
}
|
||||
| undefined = localCache.get(resourceCacheKey);
|
||||
@@ -536,7 +537,8 @@ export async function verifyResourceSession(
|
||||
|
||||
if (resourceSessionToken) {
|
||||
const sessionCacheKey = `session:${resourceSessionToken}`;
|
||||
let resourceSession: any = localCache.get(sessionCacheKey);
|
||||
let resourceSession: ResourceSession | null | undefined =
|
||||
localCache.get(sessionCacheKey);
|
||||
|
||||
if (!resourceSession) {
|
||||
const result = await validateResourceSessionToken(
|
||||
@@ -671,7 +673,7 @@ export async function verifyResourceSession(
|
||||
orgId: resource.orgId,
|
||||
location: ipCC,
|
||||
apiKey: {
|
||||
name: resourceSession.accessTokenTitle,
|
||||
name: null,
|
||||
apiKeyId: resourceSession.accessTokenId
|
||||
}
|
||||
},
|
||||
@@ -717,7 +719,7 @@ export async function verifyResourceSession(
|
||||
location: ipCC,
|
||||
user: {
|
||||
username: allowedUserData.username,
|
||||
userId: resourceSession.userId
|
||||
userId: allowedUserData.userId
|
||||
}
|
||||
},
|
||||
parsedBody.data
|
||||
|
||||
@@ -37,7 +37,7 @@ registry.registerPath({
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: z.object({
|
||||
data: z.unknown().nullable(),
|
||||
data: z.record(z.string(), z.any()).nullable(),
|
||||
success: z.boolean(),
|
||||
error: z.boolean(),
|
||||
message: z.string(),
|
||||
|
||||
@@ -60,7 +60,7 @@ registry.registerPath({
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: z.object({
|
||||
data: z.unknown().nullable(),
|
||||
data: z.record(z.string(), z.any()).nullable(),
|
||||
success: z.boolean(),
|
||||
error: z.boolean(),
|
||||
message: z.string(),
|
||||
|
||||
@@ -62,7 +62,7 @@ registry.registerPath({
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: z.object({
|
||||
data: z.unknown().nullable(),
|
||||
data: z.record(z.string(), z.any()).nullable(),
|
||||
success: z.boolean(),
|
||||
error: z.boolean(),
|
||||
message: z.string(),
|
||||
|
||||
@@ -80,7 +80,7 @@ registry.registerPath({
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: z.object({
|
||||
data: z.unknown().nullable(),
|
||||
data: z.record(z.string(), z.any()).nullable(),
|
||||
success: z.boolean(),
|
||||
error: z.boolean(),
|
||||
message: z.string(),
|
||||
|
||||
@@ -28,7 +28,7 @@ registry.registerPath({
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: z.object({
|
||||
data: z.unknown().nullable(),
|
||||
data: z.record(z.string(), z.any()).nullable(),
|
||||
success: z.boolean(),
|
||||
error: z.boolean(),
|
||||
message: z.string(),
|
||||
|
||||
@@ -30,7 +30,7 @@ registry.registerPath({
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: z.object({
|
||||
data: z.unknown().nullable(),
|
||||
data: z.record(z.string(), z.any()).nullable(),
|
||||
success: z.boolean(),
|
||||
error: z.boolean(),
|
||||
message: z.string(),
|
||||
@@ -94,7 +94,11 @@ export async function blockClient(
|
||||
|
||||
// Send terminate signal if there's an associated OLM and it's connected
|
||||
if (client.olmId && client.online) {
|
||||
await sendTerminateClient(client.clientId, OlmErrorCodes.TERMINATED_BLOCKED, client.olmId);
|
||||
await sendTerminateClient(
|
||||
client.clientId,
|
||||
OlmErrorCodes.TERMINATED_BLOCKED,
|
||||
client.olmId
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -65,7 +65,7 @@ registry.registerPath({
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: z.object({
|
||||
data: z.unknown().nullable(),
|
||||
data: z.record(z.string(), z.any()).nullable(),
|
||||
success: z.boolean(),
|
||||
error: z.boolean(),
|
||||
message: z.string(),
|
||||
|
||||
@@ -66,7 +66,7 @@ registry.registerPath({
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: z.object({
|
||||
data: z.unknown().nullable(),
|
||||
data: z.record(z.string(), z.any()).nullable(),
|
||||
success: z.boolean(),
|
||||
error: z.boolean(),
|
||||
message: z.string(),
|
||||
|
||||
@@ -31,7 +31,7 @@ registry.registerPath({
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: z.object({
|
||||
data: z.unknown().nullable(),
|
||||
data: z.record(z.string(), z.any()).nullable(),
|
||||
success: z.boolean(),
|
||||
error: z.boolean(),
|
||||
message: z.string(),
|
||||
|
||||
@@ -259,7 +259,7 @@ registry.registerPath({
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: z.object({
|
||||
data: z.unknown().nullable(),
|
||||
data: z.record(z.string(), z.any()).nullable(),
|
||||
success: z.boolean(),
|
||||
error: z.boolean(),
|
||||
message: z.string(),
|
||||
@@ -287,7 +287,7 @@ registry.registerPath({
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: z.object({
|
||||
data: z.unknown().nullable(),
|
||||
data: z.record(z.string(), z.any()).nullable(),
|
||||
success: z.boolean(),
|
||||
error: z.boolean(),
|
||||
message: z.string(),
|
||||
@@ -340,18 +340,18 @@ export async function getClient(
|
||||
// Build fingerprint data if available
|
||||
const fingerprintData = client.currentFingerprint
|
||||
? {
|
||||
username: client.currentFingerprint.username || null,
|
||||
hostname: client.currentFingerprint.hostname || null,
|
||||
platform: client.currentFingerprint.platform || null,
|
||||
osVersion: client.currentFingerprint.osVersion || null,
|
||||
kernelVersion:
|
||||
client.currentFingerprint.kernelVersion || null,
|
||||
arch: client.currentFingerprint.arch || null,
|
||||
deviceModel: client.currentFingerprint.deviceModel || null,
|
||||
serialNumber: client.currentFingerprint.serialNumber || null,
|
||||
firstSeen: client.currentFingerprint.firstSeen || null,
|
||||
lastSeen: client.currentFingerprint.lastSeen || null
|
||||
}
|
||||
username: client.currentFingerprint.username || null,
|
||||
hostname: client.currentFingerprint.hostname || null,
|
||||
platform: client.currentFingerprint.platform || null,
|
||||
osVersion: client.currentFingerprint.osVersion || null,
|
||||
kernelVersion:
|
||||
client.currentFingerprint.kernelVersion || null,
|
||||
arch: client.currentFingerprint.arch || null,
|
||||
deviceModel: client.currentFingerprint.deviceModel || null,
|
||||
serialNumber: client.currentFingerprint.serialNumber || null,
|
||||
firstSeen: client.currentFingerprint.firstSeen || null,
|
||||
lastSeen: client.currentFingerprint.lastSeen || null
|
||||
}
|
||||
: null;
|
||||
|
||||
// Build posture data if available (platform-specific)
|
||||
|
||||
@@ -218,7 +218,7 @@ registry.registerPath({
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: z.object({
|
||||
data: z.unknown().nullable(),
|
||||
data: z.record(z.string(), z.any()).nullable(),
|
||||
success: z.boolean(),
|
||||
error: z.boolean(),
|
||||
message: z.string(),
|
||||
|
||||
@@ -219,7 +219,7 @@ registry.registerPath({
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: z.object({
|
||||
data: z.unknown().nullable(),
|
||||
data: z.record(z.string(), z.any()).nullable(),
|
||||
success: z.boolean(),
|
||||
error: z.boolean(),
|
||||
message: z.string(),
|
||||
|
||||
@@ -13,7 +13,7 @@ import semver from "semver";
|
||||
|
||||
const NEWT_V2_TARGETS_VERSION = ">=1.10.3";
|
||||
|
||||
export async function convertTargetsIfNessicary(
|
||||
export async function convertTargetsIfNecessary(
|
||||
newtId: string,
|
||||
targets: SubnetProxyTarget[] | SubnetProxyTargetV2[]
|
||||
) {
|
||||
@@ -47,7 +47,7 @@ export async function addTargets(
|
||||
targets: SubnetProxyTarget[] | SubnetProxyTargetV2[],
|
||||
version?: string | null
|
||||
) {
|
||||
targets = await convertTargetsIfNessicary(newtId, targets);
|
||||
targets = await convertTargetsIfNecessary(newtId, targets);
|
||||
|
||||
await sendToClient(
|
||||
newtId,
|
||||
@@ -64,7 +64,7 @@ export async function removeTargets(
|
||||
targets: SubnetProxyTarget[] | SubnetProxyTargetV2[],
|
||||
version?: string | null
|
||||
) {
|
||||
targets = await convertTargetsIfNessicary(newtId, targets);
|
||||
targets = await convertTargetsIfNecessary(newtId, targets);
|
||||
|
||||
await sendToClient(
|
||||
newtId,
|
||||
|
||||
@@ -28,7 +28,7 @@ registry.registerPath({
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: z.object({
|
||||
data: z.unknown().nullable(),
|
||||
data: z.record(z.string(), z.any()).nullable(),
|
||||
success: z.boolean(),
|
||||
error: z.boolean(),
|
||||
message: z.string(),
|
||||
|
||||
@@ -28,7 +28,7 @@ registry.registerPath({
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: z.object({
|
||||
data: z.unknown().nullable(),
|
||||
data: z.record(z.string(), z.any()).nullable(),
|
||||
success: z.boolean(),
|
||||
error: z.boolean(),
|
||||
message: z.string(),
|
||||
|
||||
@@ -42,7 +42,7 @@ registry.registerPath({
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: z.object({
|
||||
data: z.unknown().nullable(),
|
||||
data: z.record(z.string(), z.any()).nullable(),
|
||||
success: z.boolean(),
|
||||
error: z.boolean(),
|
||||
message: z.string(),
|
||||
|
||||
@@ -43,7 +43,7 @@ registry.registerPath({
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: z.object({
|
||||
data: z.unknown().nullable(),
|
||||
data: z.record(z.string(), z.any()).nullable(),
|
||||
success: z.boolean(),
|
||||
error: z.boolean(),
|
||||
message: z.string(),
|
||||
|
||||
@@ -44,7 +44,7 @@ registry.registerPath({
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: z.object({
|
||||
data: z.unknown().nullable(),
|
||||
data: z.record(z.string(), z.any()).nullable(),
|
||||
success: z.boolean(),
|
||||
error: z.boolean(),
|
||||
message: z.string(),
|
||||
|
||||
@@ -666,6 +666,13 @@ authenticated.get(
|
||||
resource.getResourcePolicies
|
||||
);
|
||||
|
||||
authenticated.get(
|
||||
"/resource-policy/:resourcePolicyId",
|
||||
verifyResourcePolicyAccess,
|
||||
verifyUserHasAction(ActionsEnum.getResourcePolicy),
|
||||
policy.getResourcePolicy
|
||||
);
|
||||
|
||||
authenticated.put(
|
||||
"/resource-policy/:resourcePolicyId",
|
||||
verifyResourcePolicyAccess,
|
||||
|
||||
@@ -31,7 +31,7 @@ registry.registerPath({
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: z.object({
|
||||
data: z.unknown().nullable(),
|
||||
data: z.record(z.string(), z.any()).nullable(),
|
||||
success: z.boolean(),
|
||||
error: z.boolean(),
|
||||
message: z.string(),
|
||||
|
||||
@@ -29,7 +29,7 @@ registry.registerPath({
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: z.object({
|
||||
data: z.unknown().nullable(),
|
||||
data: z.record(z.string(), z.any()).nullable(),
|
||||
success: z.boolean(),
|
||||
error: z.boolean(),
|
||||
message: z.string(),
|
||||
|
||||
@@ -44,7 +44,7 @@ registry.registerPath({
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: z.object({
|
||||
data: z.unknown().nullable(),
|
||||
data: z.record(z.string(), z.any()).nullable(),
|
||||
success: z.boolean(),
|
||||
error: z.boolean(),
|
||||
message: z.string(),
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Request, Response, NextFunction } from "express";
|
||||
import { z } from "zod";
|
||||
import { db, Org } from "@server/db";
|
||||
import { db, Org, primaryDb } from "@server/db";
|
||||
import response from "@server/lib/response";
|
||||
import HttpCode from "@server/types/HttpCode";
|
||||
import createHttpError from "http-errors";
|
||||
@@ -635,9 +635,7 @@ export async function validateOidcCallback(
|
||||
}
|
||||
});
|
||||
|
||||
db.transaction(async (trx) => {
|
||||
await calculateUserClientsForOrgs(userId!, trx);
|
||||
}).catch((err) => {
|
||||
calculateUserClientsForOrgs(userId!, primaryDb).catch((err) => {
|
||||
logger.error(
|
||||
"Error calculating user clients after syncing orgs and roles for OIDC user",
|
||||
{ error: err }
|
||||
|
||||
@@ -152,34 +152,64 @@ export async function buildClientConfigurationForNewtClient(
|
||||
|
||||
const targetsToSend: SubnetProxyTargetV2[] = [];
|
||||
|
||||
for (const resource of allSiteResources) {
|
||||
// Get clients associated with this specific resource
|
||||
const resourceClients = await db
|
||||
.select({
|
||||
clientId: clients.clientId,
|
||||
pubKey: clients.pubKey,
|
||||
subnet: clients.subnet
|
||||
})
|
||||
.from(clients)
|
||||
.innerJoin(
|
||||
clientSiteResourcesAssociationsCache,
|
||||
eq(
|
||||
clients.clientId,
|
||||
clientSiteResourcesAssociationsCache.clientId
|
||||
)
|
||||
)
|
||||
.where(
|
||||
eq(
|
||||
clientSiteResourcesAssociationsCache.siteResourceId,
|
||||
resource.siteResourceId
|
||||
)
|
||||
);
|
||||
if (allSiteResources.length === 0) {
|
||||
return {
|
||||
peers: validPeers,
|
||||
targets: targetsToSend
|
||||
};
|
||||
}
|
||||
|
||||
const resourceTargets = await generateSubnetProxyTargetV2(
|
||||
resource,
|
||||
resourceClients
|
||||
// Batch fetch all client associations for every site resource in one query
|
||||
// to avoid an N+1 lookup that would issue thousands of queries when a site
|
||||
// has many resources.
|
||||
const siteResourceIds = allSiteResources.map((r) => r.siteResourceId);
|
||||
|
||||
const resourceClientRows = await db
|
||||
.select({
|
||||
siteResourceId: clientSiteResourcesAssociationsCache.siteResourceId,
|
||||
clientId: clients.clientId,
|
||||
pubKey: clients.pubKey,
|
||||
subnet: clients.subnet
|
||||
})
|
||||
.from(clients)
|
||||
.innerJoin(
|
||||
clientSiteResourcesAssociationsCache,
|
||||
eq(clients.clientId, clientSiteResourcesAssociationsCache.clientId)
|
||||
)
|
||||
.where(
|
||||
inArray(
|
||||
clientSiteResourcesAssociationsCache.siteResourceId,
|
||||
siteResourceIds
|
||||
)
|
||||
);
|
||||
|
||||
const clientsByResourceId = new Map<
|
||||
number,
|
||||
{ clientId: number; pubKey: string | null; subnet: string | null }[]
|
||||
>();
|
||||
for (const row of resourceClientRows) {
|
||||
let list = clientsByResourceId.get(row.siteResourceId);
|
||||
if (!list) {
|
||||
list = [];
|
||||
clientsByResourceId.set(row.siteResourceId, list);
|
||||
}
|
||||
list.push({
|
||||
clientId: row.clientId,
|
||||
pubKey: row.pubKey,
|
||||
subnet: row.subnet
|
||||
});
|
||||
}
|
||||
|
||||
const resourceTargetsArr = await Promise.all(
|
||||
allSiteResources.map((resource) =>
|
||||
generateSubnetProxyTargetV2(
|
||||
resource,
|
||||
clientsByResourceId.get(resource.siteResourceId) ?? []
|
||||
)
|
||||
)
|
||||
);
|
||||
|
||||
for (const resourceTargets of resourceTargetsArr) {
|
||||
if (resourceTargets) {
|
||||
targetsToSend.push(...resourceTargets);
|
||||
}
|
||||
|
||||
@@ -56,13 +56,18 @@ async function getLatestReleaseInfo(): Promise<ReleaseInfo | null> {
|
||||
return staleReleaseInfo;
|
||||
}
|
||||
|
||||
// Drop drafts, pre-releases, and anything with "rc" in the tag name.
|
||||
const oneDayAgo = new Date(Date.now() - 24 * 60 * 60 * 1000);
|
||||
|
||||
// Drop drafts, pre-releases, anything with "rc" in the tag name,
|
||||
// and releases published less than 1 day ago.
|
||||
releases = releases.filter(
|
||||
(r: any) =>
|
||||
!r.draft &&
|
||||
!r.prerelease &&
|
||||
!r.tag_name.includes("rc") &&
|
||||
!r.tag_name.includes("v")
|
||||
!r.tag_name.includes("v") &&
|
||||
r.published_at &&
|
||||
new Date(r.published_at) <= oneDayAgo
|
||||
);
|
||||
|
||||
// Sort descending by semver to find the true latest stable release.
|
||||
|
||||
@@ -6,7 +6,7 @@ import { db, ExitNode, exitNodes, Newt, sites } from "@server/db";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { sendToExitNode } from "#dynamic/lib/exitNodes";
|
||||
import { buildClientConfigurationForNewtClient } from "./buildConfiguration";
|
||||
import { convertTargetsIfNessicary } from "../client/targets";
|
||||
import { convertTargetsIfNecessary } from "../client/targets";
|
||||
import { canCompress } from "@server/lib/clientVersionChecks";
|
||||
import config from "@server/lib/config";
|
||||
|
||||
@@ -113,7 +113,7 @@ export const handleNewtGetConfigMessage: MessageHandler = async (context) => {
|
||||
exitNode
|
||||
);
|
||||
|
||||
const targetsToSend = await convertTargetsIfNessicary(newt.newtId, targets); // for backward compatibility with old newt versions that don't support the new target format
|
||||
const targetsToSend = await convertTargetsIfNecessary(newt.newtId, targets); // for backward compatibility with old newt versions that don't support the new target format
|
||||
|
||||
return {
|
||||
message: {
|
||||
|
||||
@@ -49,7 +49,7 @@ export type CreateOlmResponse = {
|
||||
// content: {
|
||||
// "application/json": {
|
||||
// schema: z.object({
|
||||
// data: z.unknown().nullable(),
|
||||
// data: z.record(z.string(), z.any()).nullable(),
|
||||
// success: z.boolean(),
|
||||
// error: z.boolean(),
|
||||
// message: z.string(),
|
||||
|
||||
@@ -34,7 +34,7 @@ const paramsSchema = z
|
||||
// content: {
|
||||
// "application/json": {
|
||||
// schema: z.object({
|
||||
// data: z.unknown().nullable(),
|
||||
// data: z.record(z.string(), z.any()).nullable(),
|
||||
// success: z.boolean(),
|
||||
// error: z.boolean(),
|
||||
// message: z.string(),
|
||||
|
||||
@@ -13,7 +13,7 @@ import {
|
||||
import { olms } from "@server/db";
|
||||
import HttpCode from "@server/types/HttpCode";
|
||||
import response from "@server/lib/response";
|
||||
import { and, eq, inArray } from "drizzle-orm";
|
||||
import { and, count, eq, inArray } from "drizzle-orm";
|
||||
import { NextFunction, Request, Response } from "express";
|
||||
import createHttpError from "http-errors";
|
||||
import { z } from "zod";
|
||||
@@ -24,6 +24,7 @@ import {
|
||||
EXPIRES
|
||||
} from "@server/auth/sessions/olm";
|
||||
import { getOrCreateCachedToken } from "#dynamic/lib/tokenCache";
|
||||
import { listExitNodes } from "#dynamic/lib/exitNodes";
|
||||
import { verifyPassword } from "@server/auth/password";
|
||||
import logger from "@server/logger";
|
||||
import config from "@server/lib/config";
|
||||
@@ -150,6 +151,7 @@ export async function getOlmToken(
|
||||
);
|
||||
|
||||
let clientIdToUse;
|
||||
let orgIdToUse: string;
|
||||
if (orgId) {
|
||||
// we did provide the org
|
||||
const [client] = await db
|
||||
@@ -183,6 +185,7 @@ export async function getOlmToken(
|
||||
}
|
||||
|
||||
clientIdToUse = client.clientId;
|
||||
orgIdToUse = orgId;
|
||||
} else {
|
||||
if (!existingOlm.clientId) {
|
||||
return next(
|
||||
@@ -209,6 +212,7 @@ export async function getOlmToken(
|
||||
}
|
||||
|
||||
clientIdToUse = client.clientId;
|
||||
orgIdToUse = client.orgId;
|
||||
}
|
||||
|
||||
// Get all exit nodes from sites where the client has peers
|
||||
@@ -265,7 +269,7 @@ export async function getOlmToken(
|
||||
}
|
||||
}
|
||||
|
||||
const exitNodesHpData = allExitNodes.map((exitNode: ExitNode) => {
|
||||
let exitNodesHpData = allExitNodes.map((exitNode: ExitNode) => {
|
||||
return {
|
||||
publicKey: exitNode.publicKey,
|
||||
relayPort: config.getRawConfig().gerbil.clients_start_port,
|
||||
@@ -274,6 +278,73 @@ export async function getOlmToken(
|
||||
};
|
||||
});
|
||||
|
||||
// If no exit nodes were found for the client's sites, fall back to
|
||||
// finding an available node in the same region (as newt does on ping).
|
||||
if (exitNodesHpData.length === 0) {
|
||||
logger.debug(
|
||||
`No exit nodes found for olm ${olmId} client sites; falling back to region node selection`
|
||||
);
|
||||
const fallbackNodes = await listExitNodes(orgIdToUse!, true);
|
||||
|
||||
const weightedNodes = await Promise.all(
|
||||
fallbackNodes.map(async (node) => {
|
||||
let weight = 1;
|
||||
const maxConnections = node.maxConnections;
|
||||
if (
|
||||
maxConnections !== null &&
|
||||
maxConnections !== undefined
|
||||
) {
|
||||
const [currentConnections] = await db
|
||||
.select({ count: count() })
|
||||
.from(sites)
|
||||
.where(
|
||||
and(
|
||||
eq(sites.exitNodeId, node.exitNodeId),
|
||||
eq(sites.online, true)
|
||||
)
|
||||
);
|
||||
if (currentConnections.count >= maxConnections) {
|
||||
return null;
|
||||
}
|
||||
weight =
|
||||
(maxConnections - currentConnections.count) /
|
||||
maxConnections;
|
||||
}
|
||||
return { node, weight };
|
||||
})
|
||||
);
|
||||
|
||||
const availableNodes = weightedNodes
|
||||
.filter(
|
||||
(
|
||||
n
|
||||
): n is {
|
||||
node: (typeof fallbackNodes)[0];
|
||||
weight: number;
|
||||
} => n !== null
|
||||
)
|
||||
.sort((a, b) => b.weight - a.weight);
|
||||
|
||||
if (availableNodes.length > 0) {
|
||||
const best = availableNodes[0].node;
|
||||
exitNodesHpData = [
|
||||
{
|
||||
publicKey: best.publicKey,
|
||||
relayPort:
|
||||
config.getRawConfig().gerbil.clients_start_port,
|
||||
endpoint: best.endpoint,
|
||||
siteIds: []
|
||||
// it should still HP without the site ids but it will get stuck in the client
|
||||
// if a site is removed or something because its not tied to a site which is okay for the session
|
||||
}
|
||||
];
|
||||
} else {
|
||||
logger.warn(
|
||||
`No available fallback exit nodes found for olm ${olmId}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
logger.debug("Token created successfully");
|
||||
|
||||
return response<{
|
||||
|
||||
@@ -36,7 +36,7 @@ const querySchema = z.object({
|
||||
// content: {
|
||||
// "application/json": {
|
||||
// schema: z.object({
|
||||
// data: z.unknown().nullable(),
|
||||
// data: z.record(z.string(), z.any()).nullable(),
|
||||
// success: z.boolean(),
|
||||
// error: z.boolean(),
|
||||
// message: z.string(),
|
||||
|
||||
@@ -47,7 +47,7 @@ const paramsSchema = z
|
||||
// content: {
|
||||
// "application/json": {
|
||||
// schema: z.object({
|
||||
// data: z.unknown().nullable(),
|
||||
// data: z.record(z.string(), z.any()).nullable(),
|
||||
// success: z.boolean(),
|
||||
// error: z.boolean(),
|
||||
// message: z.string(),
|
||||
|
||||
@@ -49,10 +49,7 @@ async function queryUser(orgId: string, userId: string) {
|
||||
.from(userOrgRoles)
|
||||
.leftJoin(roles, eq(userOrgRoles.roleId, roles.roleId))
|
||||
.where(
|
||||
and(
|
||||
eq(userOrgRoles.userId, userId),
|
||||
eq(userOrgRoles.orgId, orgId)
|
||||
)
|
||||
and(eq(userOrgRoles.userId, userId), eq(userOrgRoles.orgId, orgId))
|
||||
);
|
||||
|
||||
const isAdmin = roleRows.some((r) => r.isAdmin);
|
||||
@@ -89,7 +86,7 @@ registry.registerPath({
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: z.object({
|
||||
data: z.unknown().nullable(),
|
||||
data: z.record(z.string(), z.any()).nullable(),
|
||||
success: z.boolean(),
|
||||
error: z.boolean(),
|
||||
message: z.string(),
|
||||
|
||||
@@ -80,7 +80,7 @@ registry.registerPath({
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: z.object({
|
||||
data: z.unknown().nullable(),
|
||||
data: z.record(z.string(), z.any()).nullable(),
|
||||
success: z.boolean(),
|
||||
error: z.boolean(),
|
||||
message: z.string(),
|
||||
|
||||
@@ -30,7 +30,7 @@ registry.registerPath({
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: z.object({
|
||||
data: z.unknown().nullable(),
|
||||
data: z.record(z.string(), z.any()).nullable(),
|
||||
success: z.boolean(),
|
||||
error: z.boolean(),
|
||||
message: z.string(),
|
||||
|
||||
@@ -43,7 +43,7 @@ const listOrgsSchema = z.object({
|
||||
// content: {
|
||||
// "application/json": {
|
||||
// schema: z.object({
|
||||
// data: z.unknown().nullable(),
|
||||
// data: z.record(z.string(), z.any()).nullable(),
|
||||
// success: z.boolean(),
|
||||
// error: z.boolean(),
|
||||
// message: z.string(),
|
||||
|
||||
@@ -27,7 +27,7 @@ registry.registerPath({
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: z.object({
|
||||
data: z.unknown().nullable(),
|
||||
data: z.record(z.string(), z.any()).nullable(),
|
||||
success: z.boolean(),
|
||||
error: z.boolean(),
|
||||
message: z.string(),
|
||||
|
||||
@@ -68,7 +68,7 @@ registry.registerPath({
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: z.object({
|
||||
data: z.unknown().nullable(),
|
||||
data: z.record(z.string(), z.any()).nullable(),
|
||||
success: z.boolean(),
|
||||
error: z.boolean(),
|
||||
message: z.string(),
|
||||
|
||||
@@ -8,9 +8,8 @@ import createHttpError from "http-errors";
|
||||
import logger from "@server/logger";
|
||||
import { fromError } from "zod-validation-error";
|
||||
import {
|
||||
isValidCIDR,
|
||||
isValidIP,
|
||||
isValidUrlGlobPattern
|
||||
getResourceRuleValueValidationError,
|
||||
RESOURCE_RULE_MATCH_TYPES
|
||||
} from "@server/lib/validators";
|
||||
import { OpenAPITags, registry } from "@server/openApi";
|
||||
|
||||
@@ -20,9 +19,9 @@ const ruleSchema = z.strictObject({
|
||||
enum: ["ACCEPT", "DROP", "PASS"],
|
||||
description: "rule action"
|
||||
}),
|
||||
match: z.enum(["CIDR", "IP", "PATH"]).openapi({
|
||||
match: z.enum(RESOURCE_RULE_MATCH_TYPES).openapi({
|
||||
type: "string",
|
||||
enum: ["CIDR", "IP", "PATH"],
|
||||
enum: [...RESOURCE_RULE_MATCH_TYPES],
|
||||
description: "rule match"
|
||||
}),
|
||||
value: z.string().min(1),
|
||||
@@ -105,26 +104,13 @@ export async function setResourcePolicyRules(
|
||||
}
|
||||
|
||||
for (const rule of rules) {
|
||||
if (rule.match === "CIDR" && !isValidCIDR(rule.value)) {
|
||||
const validationError = getResourceRuleValueValidationError(
|
||||
rule.match,
|
||||
rule.value
|
||||
);
|
||||
if (validationError) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.BAD_REQUEST,
|
||||
"Invalid CIDR provided"
|
||||
)
|
||||
);
|
||||
} else if (rule.match === "IP" && !isValidIP(rule.value)) {
|
||||
return next(
|
||||
createHttpError(HttpCode.BAD_REQUEST, "Invalid IP provided")
|
||||
);
|
||||
} else if (
|
||||
rule.match === "PATH" &&
|
||||
!isValidUrlGlobPattern(rule.value)
|
||||
) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.BAD_REQUEST,
|
||||
"Invalid URL glob pattern provided"
|
||||
)
|
||||
createHttpError(HttpCode.BAD_REQUEST, validationError)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -46,7 +46,7 @@ registry.registerPath({
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: z.object({
|
||||
data: z.unknown().nullable(),
|
||||
data: z.record(z.string(), z.any()).nullable(),
|
||||
success: z.boolean(),
|
||||
error: z.boolean(),
|
||||
message: z.string(),
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user