diff --git a/install/config/traefik/dynamic_config.yml b/install/config/traefik/dynamic_config.yml index 8fcf8e55..f795016b 100644 --- a/install/config/traefik/dynamic_config.yml +++ b/install/config/traefik/dynamic_config.yml @@ -51,3 +51,12 @@ http: loadBalancer: servers: - url: "http://pangolin:3000" # API/WebSocket server + +tcp: + serversTransports: + pp-transport-v1: + proxyProtocol: + version: 1 + pp-transport-v2: + proxyProtocol: + version: 2 \ No newline at end of file diff --git a/install/main.go b/install/main.go index 72ffbac0..a1b7d901 100644 --- a/install/main.go +++ b/install/main.go @@ -378,7 +378,7 @@ func collectUserInput(reader *bufio.Reader) Config { fmt.Println("\n=== Advanced Configuration ===") config.EnableIPv6 = readBool(reader, "Is your server IPv6 capable?", true) - config.EnableGeoblocking = readBool(reader, "Do you want to download the MaxMind GeoLite2 database for geoblocking functionality?", false) + config.EnableGeoblocking = readBool(reader, "Do you want to download the MaxMind GeoLite2 database for geoblocking functionality?", true) if config.DashboardDomain == "" { fmt.Println("Error: Dashboard Domain name is required") diff --git a/messages/en-US.json b/messages/en-US.json index 7374de9e..2767a25c 100644 --- a/messages/en-US.json +++ b/messages/en-US.json @@ -1958,5 +1958,87 @@ "cannotbeUndone": "This can not be undone.", "toConfirm": "to confirm", "deleteClientQuestion": "Are you sure you want to remove the client from the site and organization?", - "clientMessageRemove": "Once removed, the client will no longer be able to connect to the site." + "clientMessageRemove": "Once removed, the client will no longer be able to connect to the site.", + "sidebarLogs": "Logs", + "request": "Request", + "logs": "Logs", + "logsSettingsDescription": "Monitor logs collected from this orginization", + "searchLogs": "Search logs...", + "action": "Action", + "actor": "Actor", + "timestamp": "Timestamp", + "accessLogs": "Access Logs", + "exportCsv": "Export CSV", + "actorId": "Actor ID", + "allowedByRule": "Allowed by Rule", + "allowedNoAuth": "Allowed No Auth", + "validAccessToken": "Valid Access Token", + "validHeaderAuth": "Valid header auth", + "validPincode": "Valid Pincode", + "validPassword": "Valid Password", + "validEmail": "Valid email", + "validSSO": "Valid SSO", + "resourceBlocked": "Resource Blocked", + "droppedByRule": "Dropped by Rule", + "noSessions": "No Sessions", + "temporaryRequestToken": "Temporary Request Token", + "noMoreAuthMethods": "No Valid Auth", + "ip": "IP", + "reason": "Reason", + "requestLogs": "Request Logs", + "host": "Host", + "location": "Location", + "actionLogs": "Action Logs", + "sidebarLogsRequest": "Request Logs", + "sidebarLogsAccess": "Access Logs", + "sidebarLogsAction": "Action Logs", + "logRetention": "Log Retention", + "logRetentionDescription": "Manage how long different types of logs are retained for this organization or disable them", + "requestLogsDescription": "View detailed request logs for resources in this organization", + "logRetentionRequestLabel": "Request Log Retention", + "logRetentionRequestDescription": "How long to retain request logs", + "logRetentionAccessLabel": "Access Log Retention", + "logRetentionAccessDescription": "How long to retain access logs", + "logRetentionActionLabel": "Action Log Retention", + "logRetentionActionDescription": "How long to retain action logs", + "logRetentionDisabled": "Disabled", + "logRetention3Days": "3 days", + "logRetention7Days": "7 days", + "logRetention14Days": "14 days", + "logRetention30Days": "30 days", + "logRetention90Days": "90 days", + "logRetentionForever": "Forever", + "actionLogsDescription": "View a history of actions performed in this organization", + "accessLogsDescription": "View access auth requests for resources in this organization", + "licenseRequiredToUse": "An Enterprise license is required to use this feature.", + "certResolver": "Certificate Resolver", + "certResolverDescription": "Select the certificate resolver to use for this resource.", + "selectCertResolver": "Select Certificate Resolver", + "enterCustomResolver": "Enter Custom Resolver", + "preferWildcardCert": "Prefer Wildcard Certificate", + "unverified": "Unverified", + "domainSetting": "Domain Settings", + "domainSettingDescription": "Configure settings for your domain", + "preferWildcardCertDescription": "Attempt to generate a wildcard certificate (require a properly configured certificate resolver).", + "recordName": "Record Name", + "auto": "Auto", + "TTL": "TTL", + "howToAddRecords": "How to Add Records", + "dnsRecord": "DNS Records", + "required": "Required", + "domainSettingsUpdated": "Domain settings updated successfully", + "orgOrDomainIdMissing": "Organization or Domain ID is missing", + "loadingDNSRecords": "Loading DNS records...", + "olmUpdateAvailableInfo": "An updated version of Olm is available. Please update to the latest version for the best experience.", + "client": "Client", + "proxyProtocol": "Proxy Protocol Settings", + "proxyProtocolDescription": "Configure Proxy Protocol to preserve client IP addresses for TCP/UDP services.", + "enableProxyProtocol": "Enable Proxy Protocol", + "proxyProtocolInfo": "Preserve client IP addresses for TCP/UDP backends", + "proxyProtocolVersion": "Proxy Protocol Version", + "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.", + "warning": "Warning", + "proxyProtocolWarning": "Your backend application must be configured to accept Proxy Protocol connections. If your backend doesn't support Proxy Protocol, enabling this will break all connections. Make sure to configure your backend to trust Proxy Protocol headers from Traefik." } diff --git a/package-lock.json b/package-lock.json index f03f5a87..3457a122 100644 --- a/package-lock.json +++ b/package-lock.json @@ -52,6 +52,7 @@ "cookies": "^0.9.1", "cors": "2.8.5", "crypto-js": "^4.2.0", + "date-fns": "4.1.0", "drizzle-orm": "0.44.6", "eslint": "9.37.0", "eslint-config-next": "15.5.6", @@ -81,6 +82,7 @@ "posthog-node": "^5.9.5", "qrcode.react": "4.2.0", "react": "19.2.0", + "react-day-picker": "9.11.1", "react-dom": "19.2.0", "react-easy-sort": "^1.8.0", "react-hook-form": "7.65.0", @@ -1628,6 +1630,7 @@ "integrity": "sha512-vMqyb7XCDMPvJFFOaT9kxtiRh42GwlZEg1/uIgtZshS5a/8OaduUfCi7kynKgc3Tw/6Uo2D+db9qBttghhmxwQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@ampproject/remapping": "^2.2.0", "@babel/code-frame": "^7.26.2", @@ -1979,6 +1982,12 @@ "kuler": "^2.0.0" } }, + "node_modules/@date-fns/tz": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/@date-fns/tz/-/tz-1.4.1.tgz", + "integrity": "sha512-P5LUNhtbj6YfI3iJjw5EL9eUAG6OitD0W3fWQcpQjDRc/QIsL0tRNuO1PcDvPccWL1fSTXXdE1ds+l95DV/OFA==", + "license": "MIT" + }, "node_modules/@dotenvx/dotenvx": { "version": "1.51.0", "resolved": "https://registry.npmjs.org/@dotenvx/dotenvx/-/dotenvx-1.51.0.tgz", @@ -4015,6 +4024,7 @@ "integrity": "sha512-2I0gnIVPtfnMw9ee9h1dJG7tp81+8Ob3OJb3Mv37rx5L40/b0i7djjCVvGOVqc9AEIQyvyu1i6ypKdFw8R8gQw==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": "^14.21.3 || >=16" }, @@ -6844,6 +6854,7 @@ "integrity": "sha512-JuRQ9KXLEjaUNjTWpzuR231Z2WpIwczOkBEIvbHNCzQefFIT0L8IqE6NV6ULLyC1SI/i234JnDoMkfg+RjQj2g==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "csstype": "^3.0.2" } @@ -7049,6 +7060,7 @@ "integrity": "sha512-V8AVnmPIICiWpGfm6GLzCR/W5FXLchHop40W4nXBmdlEceh16rCN8O8LNWm5bh5XUX91fh7KpA+W0TgMKmgTpQ==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=0.10.0" } @@ -7059,6 +7071,7 @@ "integrity": "sha512-4GV5sHFG0e/0AD4X+ySy6UJd3jVl1iNsNHdpad0qhABJ11twS3TTBnseqsKurKcsNqCEFeGL3uLpVChpIO3QfQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "scheduler": "^0.25.0" }, @@ -8490,6 +8503,7 @@ "integrity": "sha512-fnQmj8lELIj7BSrZQAdBMHEHX8OZLYIHXqAKT1O7tDfLxaINzf00PMjw22r3N/xXh0w/sGHlO6SVaCQ2mj78lg==", "devOptional": true, "license": "MIT", + "peer": true, "dependencies": { "@types/node": "*" } @@ -8576,6 +8590,7 @@ "integrity": "sha512-wGA0NX93b19/dZC1J18tKWVIYWyyF2ZjT9vin/NRu0qzzvfVzWjs04iq2rQ3H65vCTQYlRqs3YHfY7zjdV+9Kw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@types/body-parser": "*", "@types/express-serve-static-core": "^5.0.0", @@ -8669,6 +8684,7 @@ "integrity": "sha512-alv65KGRadQVfVcG69MuB4IzdYVpRwMG/mq8KWOaoOdyY617P5ivaDiMCGOFDWD2sAn5Q0mR3mRtUOgm99hL9Q==", "devOptional": true, "license": "MIT", + "peer": true, "dependencies": { "undici-types": "~7.14.0" } @@ -8697,6 +8713,7 @@ "integrity": "sha512-LF7lF6zWEKxuT3/OR8wAZGzkg4ENGXFNyiV/JeOt9z5B+0ZVwbql9McqX5c/WStFq1GaGso7H1AzP/qSzmlCKQ==", "devOptional": true, "license": "MIT", + "peer": true, "dependencies": { "@types/node": "*", "pg-protocol": "*", @@ -8730,6 +8747,7 @@ "integrity": "sha512-6mDvHUFSjyT2B2yeNx2nUgMxh9LtOWvkhIU3uePn2I2oyNymUAX1NIsdgviM4CH+JSrp2D2hsMvJOkxY+0wNRA==", "devOptional": true, "license": "MIT", + "peer": true, "dependencies": { "csstype": "^3.0.2" } @@ -8740,6 +8758,7 @@ "integrity": "sha512-9KQPoO6mZCi7jcIStSnlOWn2nEF3mNmyr3rIAsGnAbQKYbRLyqmeSc39EVgtxXVia+LMT8j3knZLAZAh+xLmrw==", "devOptional": true, "license": "MIT", + "peer": true, "peerDependencies": { "@types/react": "^19.2.0" } @@ -8883,6 +8902,7 @@ "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.46.1.tgz", "integrity": "sha512-6JSSaBZmsKvEkbRUkf7Zj7dru/8ZCrJxAqArcLaVMee5907JdtEbKGsZ7zNiIm/UAkpGUkaSMZEXShnN2D1HZA==", "license": "MIT", + "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.46.1", "@typescript-eslint/types": "8.46.1", @@ -9556,6 +9576,7 @@ "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "license": "MIT", + "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -10086,6 +10107,7 @@ "integrity": "sha512-mXpa5jnIKKHeoGzBrUJrc65cXFKcILGZpU3FXR0pradUEm9MA7UZz02qfEejaMcm9iXrSOCenwwYMJ/tZ1y5Ig==", "hasInstallScript": true, "license": "MIT", + "peer": true, "dependencies": { "bindings": "^1.5.0", "prebuild-install": "^7.1.1" @@ -10199,6 +10221,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "baseline-browser-mapping": "^2.8.9", "caniuse-lite": "^1.0.30001746", @@ -10936,6 +10959,22 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/date-fns": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/date-fns/-/date-fns-4.1.0.tgz", + "integrity": "sha512-Ukq0owbQXxa/U3EGtsdVBkR1w7KOQ5gIBqdH2hkvknzZPYvBxb/aa6E8L7tmjFtkwZBu3UXBbjIgPo/Ez4xaNg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/kossnocorp" + } + }, + "node_modules/date-fns-jalali": { + "version": "4.1.0-0", + "resolved": "https://registry.npmjs.org/date-fns-jalali/-/date-fns-jalali-4.1.0-0.tgz", + "integrity": "sha512-hTIP/z+t+qKwBDcmmsnmjWTduxCg+5KfdqWQvb2X/8C9+knYY6epN/pfxdDuyVlSVeFz0sM5eEfwIUQ70U4ckg==", + "license": "MIT" + }, "node_modules/debounce": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/debounce/-/debounce-2.2.0.tgz", @@ -11839,6 +11878,7 @@ "dev": true, "hasInstallScript": true, "license": "MIT", + "peer": true, "bin": { "esbuild": "bin/esbuild" }, @@ -11935,6 +11975,7 @@ "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.37.0.tgz", "integrity": "sha512-XyLmROnACWqSxiGYArdef1fItQd47weqB7iwtfr9JHwRrqIXZdcFMvvEcL9xHCmL0SNsOvF0c42lWyM1U5dgig==", "license": "MIT", + "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", @@ -12102,6 +12143,7 @@ "resolved": "https://registry.npmjs.org/eslint-plugin-import/-/eslint-plugin-import-2.32.0.tgz", "integrity": "sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA==", "license": "MIT", + "peer": true, "dependencies": { "@rtsao/scc": "^1.1.0", "array-includes": "^3.1.9", @@ -12391,6 +12433,7 @@ "resolved": "https://registry.npmjs.org/express/-/express-5.1.0.tgz", "integrity": "sha512-DT9ck5YIRU+8GYzzU5kT3eHGA5iL+1Zd0EutOmTE9Dtk+Tvuzd23VBU+ec7HPNSTxXYO55gPV/hq4pSBJDjFpA==", "license": "MIT", + "peer": true, "dependencies": { "accepts": "^2.0.0", "body-parser": "^2.2.0", @@ -17514,6 +17557,7 @@ "version": "4.0.3", "inBundle": true, "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, @@ -18492,6 +18536,7 @@ "resolved": "https://registry.npmjs.org/pg/-/pg-8.16.3.tgz", "integrity": "sha512-enxc1h0jA/aq5oSDMvqyW3q89ra6XIIDZgCX9vkMrnz5DFTw/Ny3Li2lFQ+pt3L6MCgm/5o2o8HW9hiJji+xvw==", "license": "MIT", + "peer": true, "dependencies": { "pg-connection-string": "^2.9.1", "pg-pool": "^3.10.1", @@ -18668,6 +18713,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", @@ -19119,15 +19165,38 @@ "resolved": "https://registry.npmjs.org/react/-/react-19.2.0.tgz", "integrity": "sha512-tmbWg6W31tQLeB5cdIBOicJDJRR2KzXsV7uSK9iNfLWQ5bIZfxuPEHp7M8wiHyHnn0DD1i7w3Zmin0FtkrwoCQ==", "license": "MIT", + "peer": true, "engines": { "node": ">=0.10.0" } }, + "node_modules/react-day-picker": { + "version": "9.11.1", + "resolved": "https://registry.npmjs.org/react-day-picker/-/react-day-picker-9.11.1.tgz", + "integrity": "sha512-l3ub6o8NlchqIjPKrRFUCkTUEq6KwemQlfv3XZzzwpUeGwmDJ+0u0Upmt38hJyd7D/vn2dQoOoLV/qAp0o3uUw==", + "license": "MIT", + "dependencies": { + "@date-fns/tz": "^1.4.1", + "date-fns": "^4.1.0", + "date-fns-jalali": "^4.1.0-0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "individual", + "url": "https://github.com/sponsors/gpbl" + }, + "peerDependencies": { + "react": ">=16.8.0" + } + }, "node_modules/react-dom": { "version": "19.2.0", "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.0.tgz", "integrity": "sha512-UlbRu4cAiGaIewkPyiRGJk0imDN2T3JjieT6spoL2UeSf5od4n5LB/mQ4ejmxhCFT1tYe8IvaFulzynWovsEFQ==", "license": "MIT", + "peer": true, "dependencies": { "scheduler": "^0.27.0" }, @@ -19419,6 +19488,7 @@ "resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.65.0.tgz", "integrity": "sha512-xtOzDz063WcXvGWaHgLNrNzlsdFgtUWcb32E6WFaGTd7kPZG3EeDusjdZfUsPwKCKVXy1ZlntifaHZ4l8pAsmw==", "license": "MIT", + "peer": true, "engines": { "node": ">=18.0.0" }, @@ -19903,6 +19973,7 @@ "integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", @@ -21068,7 +21139,8 @@ "version": "4.1.14", "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.1.14.tgz", "integrity": "sha512-b7pCxjGO98LnxVkKjaZSDeNuljC4ueKUddjENJOADtubtdo8llTaJy7HwBMeLNSSo2N5QIAgklslK1+Ir8r6CA==", - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/tapable": { "version": "2.3.0", @@ -21625,6 +21697,7 @@ "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "license": "Apache-2.0", + "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -22130,6 +22203,7 @@ "resolved": "https://registry.npmjs.org/winston/-/winston-3.18.3.tgz", "integrity": "sha512-NoBZauFNNWENgsnC9YpgyYwOVrl2m58PpQ8lNHjV3kosGs7KJ7Npk9pCUE+WJlawVSe8mykWDKWFSVfs3QO9ww==", "license": "MIT", + "peer": true, "dependencies": { "@colors/colors": "^1.6.0", "@dabh/diagnostics": "^2.0.8", @@ -22437,6 +22511,7 @@ "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", "license": "MIT", + "peer": true, "funding": { "url": "https://github.com/sponsors/colinhacks" } diff --git a/package.json b/package.json index 44985a2f..ce222a85 100644 --- a/package.json +++ b/package.json @@ -75,6 +75,7 @@ "cookies": "^0.9.1", "cors": "2.8.5", "crypto-js": "^4.2.0", + "date-fns": "4.1.0", "drizzle-orm": "0.44.6", "eslint": "9.37.0", "eslint-config-next": "15.5.6", @@ -104,6 +105,7 @@ "posthog-node": "^5.9.5", "qrcode.react": "4.2.0", "react": "19.2.0", + "react-day-picker": "9.11.1", "react-dom": "19.2.0", "react-easy-sort": "^1.8.0", "react-hook-form": "7.65.0", diff --git a/server/auth/actions.ts b/server/auth/actions.ts index e48bc502..34278dfb 100644 --- a/server/auth/actions.ts +++ b/server/auth/actions.ts @@ -81,6 +81,9 @@ export enum ActionsEnum { listClients = "listClients", getClient = "getClient", listOrgDomains = "listOrgDomains", + getDomain = "getDomain", + updateOrgDomain = "updateOrgDomain", + getDNSRecords = "getDNSRecords", createNewt = "createNewt", createIdp = "createIdp", updateIdp = "updateIdp", @@ -116,7 +119,9 @@ export enum ActionsEnum { updateLoginPage = "updateLoginPage", getLoginPage = "getLoginPage", deleteLoginPage = "deleteLoginPage", - applyBlueprint = "applyBlueprint" + applyBlueprint = "applyBlueprint", + viewLogs = "viewLogs", + exportLogs = "exportLogs" } export async function checkUserActionPermission( diff --git a/server/db/pg/schema/privateSchema.ts b/server/db/pg/schema/privateSchema.ts index 67fb28ec..266a8646 100644 --- a/server/db/pg/schema/privateSchema.ts +++ b/server/db/pg/schema/privateSchema.ts @@ -6,7 +6,8 @@ import { integer, bigint, real, - text + text, + index } from "drizzle-orm/pg-core"; import { InferSelectModel } from "drizzle-orm"; import { domains, orgs, targets, users, exitNodes, sessions } from "./schema"; @@ -213,6 +214,43 @@ export const sessionTransferToken = pgTable("sessionTransferToken", { expiresAt: bigint("expiresAt", { mode: "number" }).notNull() }); +export const actionAuditLog = pgTable("actionAuditLog", { + id: serial("id").primaryKey(), + timestamp: bigint("timestamp", { mode: "number" }).notNull(), // this is EPOCH time in seconds + orgId: varchar("orgId") + .notNull() + .references(() => orgs.orgId, { onDelete: "cascade" }), + actorType: varchar("actorType", { length: 50 }).notNull(), + actor: varchar("actor", { length: 255 }).notNull(), + actorId: varchar("actorId", { length: 255 }).notNull(), + action: varchar("action", { length: 100 }).notNull(), + metadata: text("metadata") +}, (table) => ([ + index("idx_actionAuditLog_timestamp").on(table.timestamp), + index("idx_actionAuditLog_org_timestamp").on(table.orgId, table.timestamp) +])); + +export const accessAuditLog = pgTable("accessAuditLog", { + id: serial("id").primaryKey(), + timestamp: bigint("timestamp", { mode: "number" }).notNull(), // this is EPOCH time in seconds + orgId: varchar("orgId") + .notNull() + .references(() => orgs.orgId, { onDelete: "cascade" }), + actorType: varchar("actorType", { length: 50 }), + actor: varchar("actor", { length: 255 }), + actorId: varchar("actorId", { length: 255 }), + resourceId: integer("resourceId"), + ip: varchar("ip", { length: 45 }), + type: varchar("type", { length: 100 }).notNull(), + action: boolean("action").notNull(), + location: text("location"), + userAgent: text("userAgent"), + metadata: text("metadata") +}, (table) => ([ + index("idx_identityAuditLog_timestamp").on(table.timestamp), + index("idx_identityAuditLog_org_timestamp").on(table.orgId, table.timestamp) +])); + export type Limit = InferSelectModel; export type Account = InferSelectModel; export type Certificate = InferSelectModel; @@ -230,3 +268,5 @@ export type RemoteExitNodeSession = InferSelectModel< >; export type ExitNodeOrg = InferSelectModel; export type LoginPage = InferSelectModel; +export type ActionAuditLog = InferSelectModel; +export type AccessAuditLog = InferSelectModel; \ No newline at end of file diff --git a/server/db/pg/schema/schema.ts b/server/db/pg/schema/schema.ts index f6450d07..713ecef3 100644 --- a/server/db/pg/schema/schema.ts +++ b/server/db/pg/schema/schema.ts @@ -6,7 +6,8 @@ import { integer, bigint, real, - text + text, + index } from "drizzle-orm/pg-core"; import { InferSelectModel } from "drizzle-orm"; import { randomUUID } from "crypto"; @@ -18,7 +19,22 @@ export const domains = pgTable("domains", { type: varchar("type"), // "ns", "cname", "wildcard" verified: boolean("verified").notNull().default(false), failed: boolean("failed").notNull().default(false), - tries: integer("tries").notNull().default(0) + tries: integer("tries").notNull().default(0), + certResolver: varchar("certResolver"), + customCertResolver: varchar("customCertResolver"), + preferWildcardCert: boolean("preferWildcardCert") +}); + + +export const dnsRecords = pgTable("dnsRecords", { + id: varchar("id").primaryKey(), + domainId: varchar("domainId") + .notNull() + .references(() => domains.domainId, { onDelete: "cascade" }), + recordType: varchar("recordType").notNull(), // "NS" | "CNAME" | "A" | "TXT" + baseDomain: varchar("baseDomain"), + value: varchar("value").notNull(), + verified: boolean("verified").notNull().default(false), }); export const orgs = pgTable("orgs", { @@ -28,7 +44,16 @@ export const orgs = pgTable("orgs", { createdAt: text("createdAt"), requireTwoFactor: boolean("requireTwoFactor"), maxSessionLengthHours: integer("maxSessionLengthHours"), - passwordExpiryDays: integer("passwordExpiryDays") + passwordExpiryDays: integer("passwordExpiryDays"), + settingsLogRetentionDaysRequest: integer("settingsLogRetentionDaysRequest") // where 0 = dont keep logs and -1 = keep forever + .notNull() + .default(7), + settingsLogRetentionDaysAccess: integer("settingsLogRetentionDaysAccess") + .notNull() + .default(0), + settingsLogRetentionDaysAction: integer("settingsLogRetentionDaysAction") + .notNull() + .default(0) }); export const orgDomains = pgTable("orgDomains", { @@ -102,9 +127,11 @@ export const resources = pgTable("resources", { setHostHeader: varchar("setHostHeader"), enableProxy: boolean("enableProxy").default(true), skipToIdpId: integer("skipToIdpId").references(() => idp.idpId, { - onDelete: "cascade" + onDelete: "set null" }), - headers: text("headers") // comma-separated list of headers to add to the request + headers: text("headers"), // comma-separated list of headers to add to the request + proxyProtocol: boolean("proxyProtocol").notNull().default(false), + proxyProtocolVersion: integer("proxyProtocolVersion").default(1) }); export const targets = pgTable("targets", { @@ -676,6 +703,42 @@ export const setupTokens = pgTable("setupTokens", { dateUsed: varchar("dateUsed") }); +export const requestAuditLog = pgTable( + "requestAuditLog", + { + id: serial("id").primaryKey(), + timestamp: integer("timestamp").notNull(), // this is EPOCH time in seconds + orgId: text("orgId").references(() => orgs.orgId, { + onDelete: "cascade" + }), + action: boolean("action").notNull(), + reason: integer("reason").notNull(), + actorType: text("actorType"), + actor: text("actor"), + actorId: text("actorId"), + resourceId: integer("resourceId"), + ip: text("ip"), + location: text("location"), + userAgent: text("userAgent"), + metadata: text("metadata"), + headers: text("headers"), // JSON blob + query: text("query"), // JSON blob + originalRequestURL: text("originalRequestURL"), + scheme: text("scheme"), + host: text("host"), + path: text("path"), + method: text("method"), + tls: boolean("tls") + }, + (table) => [ + index("idx_requestAuditLog_timestamp").on(table.timestamp), + index("idx_requestAuditLog_org_timestamp").on( + table.orgId, + table.timestamp + ) + ] +); + export type Org = InferSelectModel; export type User = InferSelectModel; export type Site = InferSelectModel; @@ -727,3 +790,7 @@ export type SetupToken = InferSelectModel; export type HostMeta = InferSelectModel; export type TargetHealthCheck = InferSelectModel; export type IdpOidcConfig = InferSelectModel; +export type LicenseKey = InferSelectModel; +export type SecurityKey = InferSelectModel; +export type WebauthnChallenge = InferSelectModel; +export type RequestAuditLog = InferSelectModel; diff --git a/server/db/sqlite/schema/privateSchema.ts b/server/db/sqlite/schema/privateSchema.ts index 557ebfd6..89d11310 100644 --- a/server/db/sqlite/schema/privateSchema.ts +++ b/server/db/sqlite/schema/privateSchema.ts @@ -2,10 +2,12 @@ import { sqliteTable, integer, text, - real + real, + index } from "drizzle-orm/sqlite-core"; import { InferSelectModel } from "drizzle-orm"; import { domains, orgs, targets, users, exitNodes, sessions } from "./schema"; +import { metadata } from "@app/app/[orgId]/settings/layout"; export const certificates = sqliteTable("certificates", { certId: integer("certId").primaryKey({ autoIncrement: true }), @@ -207,6 +209,43 @@ export const sessionTransferToken = sqliteTable("sessionTransferToken", { expiresAt: integer("expiresAt").notNull() }); +export const actionAuditLog = sqliteTable("actionAuditLog", { + id: integer("id").primaryKey({ autoIncrement: true }), + timestamp: integer("timestamp").notNull(), // this is EPOCH time in seconds + orgId: text("orgId") + .notNull() + .references(() => orgs.orgId, { onDelete: "cascade" }), + actorType: text("actorType").notNull(), + actor: text("actor").notNull(), + actorId: text("actorId").notNull(), + action: text("action").notNull(), + metadata: text("metadata") +}, (table) => ([ + index("idx_actionAuditLog_timestamp").on(table.timestamp), + index("idx_actionAuditLog_org_timestamp").on(table.orgId, table.timestamp) +])); + +export const accessAuditLog = sqliteTable("accessAuditLog", { + id: integer("id").primaryKey({ autoIncrement: true }), + timestamp: integer("timestamp").notNull(), // this is EPOCH time in seconds + orgId: text("orgId") + .notNull() + .references(() => orgs.orgId, { onDelete: "cascade" }), + actorType: text("actorType"), + actor: text("actor"), + actorId: text("actorId"), + resourceId: integer("resourceId"), + ip: text("ip"), + location: text("location"), + type: text("type").notNull(), + action: integer("action", { mode: "boolean" }).notNull(), + userAgent: text("userAgent"), + metadata: text("metadata") +}, (table) => ([ + index("idx_identityAuditLog_timestamp").on(table.timestamp), + index("idx_identityAuditLog_org_timestamp").on(table.orgId, table.timestamp) +])); + export type Limit = InferSelectModel; export type Account = InferSelectModel; export type Certificate = InferSelectModel; @@ -224,3 +263,5 @@ export type RemoteExitNodeSession = InferSelectModel< >; export type ExitNodeOrg = InferSelectModel; export type LoginPage = InferSelectModel; +export type ActionAuditLog = InferSelectModel; +export type AccessAuditLog = InferSelectModel; \ No newline at end of file diff --git a/server/db/sqlite/schema/schema.ts b/server/db/sqlite/schema/schema.ts index 40d22bdf..01d50b05 100644 --- a/server/db/sqlite/schema/schema.ts +++ b/server/db/sqlite/schema/schema.ts @@ -1,6 +1,7 @@ import { randomUUID } from "crypto"; import { InferSelectModel } from "drizzle-orm"; -import { sqliteTable, text, integer } from "drizzle-orm/sqlite-core"; +import { sqliteTable, text, integer, index } from "drizzle-orm/sqlite-core"; +import { boolean } from "yargs"; export const domains = sqliteTable("domains", { domainId: text("domainId").primaryKey(), @@ -11,9 +12,24 @@ export const domains = sqliteTable("domains", { type: text("type"), // "ns", "cname", "wildcard" verified: integer("verified", { mode: "boolean" }).notNull().default(false), failed: integer("failed", { mode: "boolean" }).notNull().default(false), - tries: integer("tries").notNull().default(0) + tries: integer("tries").notNull().default(0), + certResolver: text("certResolver"), + preferWildcardCert: integer("preferWildcardCert", { mode: "boolean" }) }); +export const dnsRecords = sqliteTable("dnsRecords", { + id: text("id").primaryKey(), + domainId: text("domainId") + .notNull() + .references(() => domains.domainId, { onDelete: "cascade" }), + + recordType: text("recordType").notNull(), // "NS" | "CNAME" | "A" | "TXT" + baseDomain: text("baseDomain"), + value: text("value").notNull(), + verified: integer("verified", { mode: "boolean" }).notNull().default(false), +}); + + export const orgs = sqliteTable("orgs", { orgId: text("orgId").primaryKey(), name: text("name").notNull(), @@ -21,7 +37,16 @@ export const orgs = sqliteTable("orgs", { createdAt: text("createdAt"), requireTwoFactor: integer("requireTwoFactor", { mode: "boolean" }), maxSessionLengthHours: integer("maxSessionLengthHours"), // hours - passwordExpiryDays: integer("passwordExpiryDays") // days + passwordExpiryDays: integer("passwordExpiryDays"), // days + settingsLogRetentionDaysRequest: integer("settingsLogRetentionDaysRequest") // where 0 = dont keep logs and -1 = keep forever + .notNull() + .default(7), + settingsLogRetentionDaysAccess: integer("settingsLogRetentionDaysAccess") + .notNull() + .default(0), + settingsLogRetentionDaysAction: integer("settingsLogRetentionDaysAction") + .notNull() + .default(0) }); export const userDomains = sqliteTable("userDomains", { @@ -114,9 +139,12 @@ export const resources = sqliteTable("resources", { setHostHeader: text("setHostHeader"), enableProxy: integer("enableProxy", { mode: "boolean" }).default(true), skipToIdpId: integer("skipToIdpId").references(() => idp.idpId, { - onDelete: "cascade" + onDelete: "set null" }), - headers: text("headers") // comma-separated list of headers to add to the request + headers: text("headers"), // comma-separated list of headers to add to the request + proxyProtocol: integer("proxyProtocol", { mode: "boolean" }).notNull().default(false), + proxyProtocolVersion: integer("proxyProtocolVersion").default(1) + }); export const targets = sqliteTable("targets", { @@ -721,6 +749,42 @@ export const idpOrg = sqliteTable("idpOrg", { orgMapping: text("orgMapping") }); +export const requestAuditLog = sqliteTable( + "requestAuditLog", + { + id: integer("id").primaryKey({ autoIncrement: true }), + timestamp: integer("timestamp").notNull(), // this is EPOCH time in seconds + orgId: text("orgId").references(() => orgs.orgId, { + onDelete: "cascade" + }), + action: integer("action", { mode: "boolean" }).notNull(), + reason: integer("reason").notNull(), + actorType: text("actorType"), + actor: text("actor"), + actorId: text("actorId"), + resourceId: integer("resourceId"), + ip: text("ip"), + location: text("location"), + userAgent: text("userAgent"), + metadata: text("metadata"), + headers: text("headers"), // JSON blob + query: text("query"), // JSON blob + originalRequestURL: text("originalRequestURL"), + scheme: text("scheme"), + host: text("host"), + path: text("path"), + method: text("method"), + tls: integer("tls", { mode: "boolean" }) + }, + (table) => [ + index("idx_requestAuditLog_timestamp").on(table.timestamp), + index("idx_requestAuditLog_org_timestamp").on( + table.orgId, + table.timestamp + ) + ] +); + export type Org = InferSelectModel; export type User = InferSelectModel; export type Site = InferSelectModel; @@ -757,6 +821,7 @@ export type ResourceWhitelist = InferSelectModel; export type VersionMigration = InferSelectModel; export type ResourceRule = InferSelectModel; export type Domain = InferSelectModel; +export type DnsRecord = InferSelectModel; export type Client = InferSelectModel; export type ClientSite = InferSelectModel; export type RoleClient = InferSelectModel; @@ -772,3 +837,7 @@ export type SetupToken = InferSelectModel; export type HostMeta = InferSelectModel; export type TargetHealthCheck = InferSelectModel; export type IdpOidcConfig = InferSelectModel; +export type LicenseKey = InferSelectModel; +export type SecurityKey = InferSelectModel; +export type WebauthnChallenge = InferSelectModel; +export type RequestAuditLog = InferSelectModel; diff --git a/server/index.ts b/server/index.ts index 8b4b3728..daa4b7d3 100644 --- a/server/index.ts +++ b/server/index.ts @@ -5,6 +5,7 @@ import { runSetupFunctions } from "./setup"; import { createApiServer } from "./apiServer"; import { createNextServer } from "./nextServer"; import { createInternalServer } from "./internalServer"; +import { createIntegrationApiServer } from "./integrationApiServer"; import { ApiKey, ApiKeyOrg, @@ -13,13 +14,14 @@ import { User, UserOrg } from "@server/db"; -import { createIntegrationApiServer } from "./integrationApiServer"; import config from "@server/lib/config"; import { setHostMeta } from "@server/lib/hostMeta"; -import { initTelemetryClient } from "./lib/telemetry.js"; -import { TraefikConfigManager } from "./lib/traefik/TraefikConfigManager.js"; +import { initTelemetryClient } from "@server/lib/telemetry"; +import { TraefikConfigManager } from "@server/lib/traefik/TraefikConfigManager"; import { initCleanup } from "#dynamic/cleanup"; import license from "#dynamic/license/license"; +import { initLogCleanupInterval } from "@server/lib/cleanupLogs"; +import { fetchServerIp } from "@server/lib/serverIpService"; async function startServers() { await setHostMeta(); @@ -31,14 +33,17 @@ async function startServers() { await runSetupFunctions(); + await fetchServerIp(); + initTelemetryClient(); + initLogCleanupInterval(); + // Start all servers const apiServer = createApiServer(); const internalServer = createInternalServer(); - let nextServer; - nextServer = await createNextServer(); + const nextServer = await createNextServer(); if (config.getRawConfig().traefik.file_mode) { const monitor = new TraefikConfigManager(); await monitor.start(); diff --git a/server/lib/billing/getOrgTierData.ts b/server/lib/billing/getOrgTierData.ts index 24664790..75f12559 100644 --- a/server/lib/billing/getOrgTierData.ts +++ b/server/lib/billing/getOrgTierData.ts @@ -1,8 +1,8 @@ export async function getOrgTierData( orgId: string ): Promise<{ tier: string | null; active: boolean }> { - let tier = null; - let active = false; + const tier = null; + const active = false; return { tier, active }; } diff --git a/server/lib/billing/usageService.ts b/server/lib/billing/usageService.ts index 999d5b63..8e6f5e9c 100644 --- a/server/lib/billing/usageService.ts +++ b/server/lib/billing/usageService.ts @@ -1,5 +1,4 @@ import { eq, sql, and } from "drizzle-orm"; -import NodeCache from "node-cache"; import { v4 as uuidv4 } from "uuid"; import { PutObjectCommand } from "@aws-sdk/client-s3"; import * as fs from "fs/promises"; @@ -20,6 +19,7 @@ import logger from "@server/logger"; import { sendToClient } from "#dynamic/routers/ws"; import { build } from "@server/build"; import { s3Client } from "@server/lib/s3"; +import cache from "@server/lib/cache"; interface StripeEvent { identifier?: string; @@ -43,7 +43,6 @@ export function noop() { } export class UsageService { - private cache: NodeCache; private bucketName: string | undefined; private currentEventFile: string | null = null; private currentFileStartTime: number = 0; @@ -51,7 +50,6 @@ export class UsageService { private uploadingFiles: Set = new Set(); constructor() { - this.cache = new NodeCache({ stdTTL: 300 }); // 5 minute TTL if (noop()) { return; } @@ -399,7 +397,7 @@ export class UsageService { featureId: FeatureId ): Promise { const cacheKey = `customer_${orgId}_${featureId}`; - const cached = this.cache.get(cacheKey); + const cached = cache.get(cacheKey); if (cached) { return cached; @@ -422,7 +420,7 @@ export class UsageService { const customerId = customer.customerId; // Cache the result - this.cache.set(cacheKey, customerId); + cache.set(cacheKey, customerId, 300); // 5 minute TTL return customerId; } catch (error) { @@ -700,10 +698,6 @@ export class UsageService { await this.uploadFileToS3(); } - public clearCache(): void { - this.cache.flushAll(); - } - /** * Scan the events directory for files older than 1 minute and upload them if not empty. */ diff --git a/server/lib/blueprints/proxyResources.ts b/server/lib/blueprints/proxyResources.ts index a31cfb9d..37b69761 100644 --- a/server/lib/blueprints/proxyResources.ts +++ b/server/lib/blueprints/proxyResources.ts @@ -527,7 +527,7 @@ export async function updateProxyResources( if ( existingRule.action !== getRuleAction(rule.action) || existingRule.match !== rule.match.toUpperCase() || - existingRule.value !== rule.value + existingRule.value !== rule.value.toUpperCase() ) { validateRule(rule); await trx @@ -535,7 +535,7 @@ export async function updateProxyResources( .set({ action: getRuleAction(rule.action), match: rule.match.toUpperCase(), - value: rule.value + value: rule.value.toUpperCase(), }) .where( eq(resourceRules.ruleId, existingRule.ruleId) @@ -547,7 +547,7 @@ export async function updateProxyResources( resourceId: existingResource.resourceId, action: getRuleAction(rule.action), match: rule.match.toUpperCase(), - value: rule.value, + value: rule.value.toUpperCase(), priority: index + 1 // start priorities at 1 }); } @@ -705,7 +705,7 @@ export async function updateProxyResources( resourceId: newResource.resourceId, action: getRuleAction(rule.action), match: rule.match.toUpperCase(), - value: rule.value, + value: rule.value.toUpperCase(), priority: index + 1 // start priorities at 1 }); } diff --git a/server/lib/blueprints/types.ts b/server/lib/blueprints/types.ts index 02f83f9d..de5c8a70 100644 --- a/server/lib/blueprints/types.ts +++ b/server/lib/blueprints/types.ts @@ -275,24 +275,26 @@ export const ConfigSchema = z } ) .refine( - // Enforce proxy-port uniqueness within proxy-resources + // Enforce proxy-port uniqueness within proxy-resources per protocol (config) => { - const proxyPortMap = new Map(); + const protocolPortMap = new Map(); Object.entries(config["proxy-resources"]).forEach( ([resourceKey, resource]) => { const proxyPort = resource["proxy-port"]; - if (proxyPort !== undefined) { - if (!proxyPortMap.has(proxyPort)) { - proxyPortMap.set(proxyPort, []); + const protocol = resource.protocol; + if (proxyPort !== undefined && protocol !== undefined) { + const key = `${protocol}:${proxyPort}`; + if (!protocolPortMap.has(key)) { + protocolPortMap.set(key, []); } - proxyPortMap.get(proxyPort)!.push(resourceKey); + protocolPortMap.get(key)!.push(resourceKey); } } ); // Find duplicates - const duplicates = Array.from(proxyPortMap.entries()).filter( + const duplicates = Array.from(protocolPortMap.entries()).filter( ([_, resourceKeys]) => resourceKeys.length > 1 ); @@ -300,25 +302,29 @@ export const ConfigSchema = z }, (config) => { // Extract duplicates for error message - const proxyPortMap = new Map(); + const protocolPortMap = new Map(); Object.entries(config["proxy-resources"]).forEach( ([resourceKey, resource]) => { const proxyPort = resource["proxy-port"]; - if (proxyPort !== undefined) { - if (!proxyPortMap.has(proxyPort)) { - proxyPortMap.set(proxyPort, []); + const protocol = resource.protocol; + if (proxyPort !== undefined && protocol !== undefined) { + const key = `${protocol}:${proxyPort}`; + if (!protocolPortMap.has(key)) { + protocolPortMap.set(key, []); } - proxyPortMap.get(proxyPort)!.push(resourceKey); + protocolPortMap.get(key)!.push(resourceKey); } } ); - const duplicates = Array.from(proxyPortMap.entries()) + const duplicates = Array.from(protocolPortMap.entries()) .filter(([_, resourceKeys]) => resourceKeys.length > 1) .map( - ([proxyPort, resourceKeys]) => - `port ${proxyPort} used by proxy-resources: ${resourceKeys.join(", ")}` + ([protocolPort, resourceKeys]) => { + const [protocol, port] = protocolPort.split(':'); + return `${protocol.toUpperCase()} port ${port} used by proxy-resources: ${resourceKeys.join(", ")}`; + } ) .join("; "); diff --git a/server/lib/cache.ts b/server/lib/cache.ts new file mode 100644 index 00000000..efa7d201 --- /dev/null +++ b/server/lib/cache.ts @@ -0,0 +1,5 @@ +import NodeCache from "node-cache"; + +export const cache = new NodeCache({ stdTTL: 3600, checkperiod: 120 }); + +export default cache; \ No newline at end of file diff --git a/server/lib/cleanupLogs.ts b/server/lib/cleanupLogs.ts new file mode 100644 index 00000000..7cc96ff9 --- /dev/null +++ b/server/lib/cleanupLogs.ts @@ -0,0 +1,62 @@ +import { db, orgs } from "@server/db"; +import { cleanUpOldLogs as cleanUpOldAccessLogs } from "@server/private/lib/logAccessAudit"; +import { cleanUpOldLogs as cleanUpOldActionLogs } from "@server/private/middlewares/logActionAudit"; +import { cleanUpOldLogs as cleanUpOldRequestLogs } from "@server/routers/badger/logRequestAudit"; +import { gt, or } from "drizzle-orm"; + +export function initLogCleanupInterval() { + return setInterval( + async () => { + const orgsToClean = await db + .select({ + orgId: orgs.orgId, + settingsLogRetentionDaysAction: + orgs.settingsLogRetentionDaysAction, + settingsLogRetentionDaysAccess: + orgs.settingsLogRetentionDaysAccess, + settingsLogRetentionDaysRequest: + orgs.settingsLogRetentionDaysRequest + }) + .from(orgs) + .where( + or( + gt(orgs.settingsLogRetentionDaysAction, 0), + gt(orgs.settingsLogRetentionDaysAccess, 0), + gt(orgs.settingsLogRetentionDaysRequest, 0) + ) + ); + + for (const org of orgsToClean) { + const { + orgId, + settingsLogRetentionDaysAction, + settingsLogRetentionDaysAccess, + settingsLogRetentionDaysRequest + } = org; + + if (settingsLogRetentionDaysAction > 0) { + await cleanUpOldActionLogs( + orgId, + settingsLogRetentionDaysRequest + ); + } + + if (settingsLogRetentionDaysAccess > 0) { + await cleanUpOldAccessLogs( + orgId, + settingsLogRetentionDaysRequest + ); + } + + if (settingsLogRetentionDaysRequest > 0) { + await cleanUpOldRequestLogs( + orgId, + settingsLogRetentionDaysRequest + ); + } + } + }, + // 3 * 60 * 60 * 1000 + 60 * 1000 // for testing + ); // every 3 hours +} diff --git a/server/lib/geoip.ts b/server/lib/geoip.ts index ac739fa3..5bc29ef9 100644 --- a/server/lib/geoip.ts +++ b/server/lib/geoip.ts @@ -6,7 +6,7 @@ export async function getCountryCodeForIp( ): Promise { try { if (!maxmindLookup) { - logger.warn( + logger.debug( "MaxMind DB path not configured, cannot perform GeoIP lookup" ); return; diff --git a/server/lib/serverIpService.ts b/server/lib/serverIpService.ts new file mode 100644 index 00000000..fb25c1c2 --- /dev/null +++ b/server/lib/serverIpService.ts @@ -0,0 +1,28 @@ +import axios from "axios"; + +let serverIp: string | null = null; + +const services = [ + "https://ifconfig.io/ip", + "https://api.ipify.org", + "https://checkip.amazonaws.com" +]; + +export async function fetchServerIp() { + for (const url of services) { + try { + const response = await axios.get(url, { timeout: 5000 }); + serverIp = response.data.trim(); + console.log("Detected public IP:", serverIp); + return; + } catch (err: any) { + console.warn(`Failed to fetch server IP from ${url}: ${err.message || err.code}`); + } + } + + console.error("All attempts to fetch server IP failed."); +} + +export function getServerIp() { + return serverIp; +} diff --git a/server/lib/traefik/TraefikConfigManager.ts b/server/lib/traefik/TraefikConfigManager.ts index ec4e25f4..56648559 100644 --- a/server/lib/traefik/TraefikConfigManager.ts +++ b/server/lib/traefik/TraefikConfigManager.ts @@ -309,10 +309,7 @@ export class TraefikConfigManager { this.lastActiveDomains = new Set(domains); } - if ( - process.env.USE_PANGOLIN_DNS === "true" && - build != "oss" - ) { + if (process.env.USE_PANGOLIN_DNS === "true" && build != "oss") { // Scan current local certificate state this.lastLocalCertificateState = await this.scanLocalCertificateState(); @@ -450,7 +447,8 @@ export class TraefikConfigManager { currentExitNode, config.getRawConfig().traefik.site_types, build == "oss", // filter out the namespace domains in open source - build != "oss" // generate the login pages on the cloud and hybrid + build != "oss", // generate the login pages on the cloud and hybrid, + build == "saas" ? false : config.getRawConfig().traefik.allow_raw_resources // dont allow raw resources on saas otherwise use config ); const domains = new Set(); @@ -502,6 +500,25 @@ export class TraefikConfigManager { }; } + // tcp: + // serversTransports: + // pp-transport-v1: + // proxyProtocol: + // version: 1 + // pp-transport-v2: + // proxyProtocol: + // version: 2 + + if (build != "saas") { + // add the serversTransports section if not present + if (traefikConfig.tcp && !traefikConfig.tcp.serversTransports) { + traefikConfig.tcp.serversTransports = { + "pp-transport-v1": { proxyProtocol: { version: 1 } }, + "pp-transport-v2": { proxyProtocol: { version: 2 } } + }; + } + } + return { domains, traefikConfig }; } catch (error) { // pull data out of the axios error to log diff --git a/server/lib/traefik/getTraefikConfig.ts b/server/lib/traefik/getTraefikConfig.ts index 5916d026..4352173b 100644 --- a/server/lib/traefik/getTraefikConfig.ts +++ b/server/lib/traefik/getTraefikConfig.ts @@ -1,4 +1,4 @@ -import { db, targetHealthCheck } from "@server/db"; +import { db, targetHealthCheck, domains } from "@server/db"; import { and, eq, @@ -23,7 +23,8 @@ export async function getTraefikConfig( exitNodeId: number, siteTypes: string[], filterOutNamespaceDomains = false, - generateLoginPageRouters = false + generateLoginPageRouters = false, + allowRawResources = true ): Promise { // Define extended target type with site information type TargetWithSite = Target & { @@ -56,6 +57,8 @@ export async function getTraefikConfig( setHostHeader: resources.setHostHeader, enableProxy: resources.enableProxy, headers: resources.headers, + proxyProtocol: resources.proxyProtocol, + proxyProtocolVersion: resources.proxyProtocolVersion, // Target fields targetId: targets.targetId, targetEnabled: targets.enabled, @@ -75,11 +78,14 @@ export async function getTraefikConfig( siteType: sites.type, siteOnline: sites.online, subnet: sites.subnet, - exitNodeId: sites.exitNodeId + exitNodeId: sites.exitNodeId, + // Domain cert resolver fields + domainCertResolver: domains.certResolver }) .from(sites) .innerJoin(targets, eq(targets.siteId, sites.siteId)) .innerJoin(resources, eq(resources.resourceId, targets.resourceId)) + .leftJoin(domains, eq(domains.domainId, resources.domainId)) .leftJoin( targetHealthCheck, eq(targetHealthCheck.targetId, targets.targetId) @@ -101,7 +107,7 @@ export async function getTraefikConfig( isNull(targetHealthCheck.hcHealth) // Include targets with no health check record ), inArray(sites.type, siteTypes), - config.getRawConfig().traefik.allow_raw_resources + allowRawResources ? isNotNull(resources.http) // ignore the http check if allow_raw_resources is true : eq(resources.http, true) ) @@ -164,11 +170,15 @@ export async function getTraefikConfig( enableProxy: row.enableProxy, targets: [], headers: row.headers, + proxyProtocol: row.proxyProtocol, + proxyProtocolVersion: row.proxyProtocolVersion ?? 1, path: row.path, // the targets will all have the same path pathMatchType: row.pathMatchType, // the targets will all have the same pathMatchType rewritePath: row.rewritePath, rewritePathType: row.rewritePathType, - priority: priority // may be null, we fallback later + priority: priority, + // Store domain cert resolver fields + domainCertResolver: row.domainCertResolver }); } @@ -247,30 +257,45 @@ export async function getTraefikConfig( wildCard = resource.fullDomain; } - const configDomain = config.getDomain(resource.domainId); + const globalDefaultResolver = + config.getRawConfig().traefik.cert_resolver; + const globalDefaultPreferWildcard = + config.getRawConfig().traefik.prefer_wildcard_cert; - let certResolver: string, preferWildcardCert: boolean; - if (!configDomain) { - certResolver = config.getRawConfig().traefik.cert_resolver; - preferWildcardCert = - config.getRawConfig().traefik.prefer_wildcard_cert; - } else { - certResolver = configDomain.cert_resolver; - preferWildcardCert = configDomain.prefer_wildcard_cert; - } + const domainCertResolver = resource.domainCertResolver; + const preferWildcardCert = resource.preferWildcardCert; - const tls = { - certResolver: certResolver, - ...(preferWildcardCert - ? { - domains: [ - { - main: wildCard - } - ] - } - : {}) - }; + let resolverName: string | undefined; + let preferWildcard: boolean | undefined; + // Handle both letsencrypt & custom cases + if (domainCertResolver) { + resolverName = domainCertResolver.trim(); + } else { + resolverName = globalDefaultResolver; + } + + if ( + preferWildcardCert !== undefined && + preferWildcardCert !== null + ) { + preferWildcard = preferWildcardCert; + } else { + preferWildcard = globalDefaultPreferWildcard; + } + + const tls = { + certResolver: resolverName, + ...(preferWildcard + ? { + domains: [ + { + main: wildCard + } + ] + } + : {}) + }; + const additionalMiddlewares = config.getRawConfig().traefik.additional_middlewares || []; @@ -509,14 +534,14 @@ export async function getTraefikConfig( })(), ...(resource.stickySession ? { - sticky: { - cookie: { - name: "p_sticky", // TODO: make this configurable via config.yml like other cookies - secure: resource.ssl, - httpOnly: true - } - } - } + sticky: { + cookie: { + name: "p_sticky", // TODO: make this configurable via config.yml like other cookies + secure: resource.ssl, + httpOnly: true + } + } + } : {}) } }; @@ -615,15 +640,20 @@ export async function getTraefikConfig( } }); })(), + ...(resource.proxyProtocol && protocol == "tcp" + ? { + serversTransport: `pp-transport-v${resource.proxyProtocolVersion || 1}` + } + : {}), ...(resource.stickySession ? { - sticky: { - ipStrategy: { - depth: 0, - sourcePort: true - } - } - } + sticky: { + ipStrategy: { + depth: 0, + sourcePort: true + } + } + } : {}) } }; diff --git a/server/middlewares/index.ts b/server/middlewares/index.ts index 629cafe9..66a92809 100644 --- a/server/middlewares/index.ts +++ b/server/middlewares/index.ts @@ -27,3 +27,4 @@ export * from "./verifyDomainAccess"; export * from "./verifyClientsEnabled"; export * from "./verifyUserIsOrgOwner"; export * from "./verifySiteResourceAccess"; +export * from "./logActionAudit"; \ No newline at end of file diff --git a/server/middlewares/logActionAudit.ts b/server/middlewares/logActionAudit.ts new file mode 100644 index 00000000..a1521883 --- /dev/null +++ b/server/middlewares/logActionAudit.ts @@ -0,0 +1,12 @@ +import { ActionsEnum } from "@server/auth/actions"; +import { Request, Response, NextFunction } from "express"; + +export function logActionAudit(action: ActionsEnum) { + return async function ( + req: Request, + res: Response, + next: NextFunction + ): Promise { + next(); + }; +} diff --git a/server/private/lib/certificates.ts b/server/private/lib/certificates.ts index 2ca967be..ec4b73ee 100644 --- a/server/private/lib/certificates.ts +++ b/server/private/lib/certificates.ts @@ -16,8 +16,8 @@ import { certificates, db } from "@server/db"; import { and, eq, isNotNull, or, inArray, sql } from "drizzle-orm"; import { decryptData } from "@server/lib/encryption"; import * as fs from "fs"; -import NodeCache from "node-cache"; import logger from "@server/logger"; +import cache from "@server/lib/cache"; let encryptionKeyPath = ""; let encryptionKeyHex = ""; @@ -51,9 +51,6 @@ export type CertificateResult = { updatedAt?: number | null; }; -// --- In-Memory Cache Implementation --- -const certificateCache = new NodeCache({ stdTTL: 180 }); // Cache for 3 minutes (180 seconds) - export async function getValidCertificatesForDomains( domains: Set, useCache: boolean = true @@ -67,7 +64,8 @@ export async function getValidCertificatesForDomains( // 1. Check cache first if enabled if (useCache) { for (const domain of domains) { - const cachedCert = certificateCache.get(domain); + const cacheKey = `cert:${domain}`; + const cachedCert = cache.get(cacheKey); if (cachedCert) { finalResults.push(cachedCert); // Valid cache hit } else { @@ -180,7 +178,8 @@ export async function getValidCertificatesForDomains( // Add to cache for future requests, using the *requested domain* as the key if (useCache) { - certificateCache.set(domain, resultCert); + const cacheKey = `cert:${domain}`; + cache.set(cacheKey, resultCert, 180); } } } diff --git a/server/private/lib/logAccessAudit.ts b/server/private/lib/logAccessAudit.ts new file mode 100644 index 00000000..cc56d94c --- /dev/null +++ b/server/private/lib/logAccessAudit.ts @@ -0,0 +1,157 @@ +import { accessAuditLog, db, orgs } from "@server/db"; +import { getCountryCodeForIp } from "@server/lib/geoip"; +import logger from "@server/logger"; +import { and, eq, lt } from "drizzle-orm"; +import cache from "@server/lib/cache"; + +async function getAccessDays(orgId: string): Promise { + // check cache first + const cached = cache.get(`org_${orgId}_accessDays`); + if (cached !== undefined) { + return cached; + } + + const [org] = await db + .select({ + settingsLogRetentionDaysAction: orgs.settingsLogRetentionDaysAction + }) + .from(orgs) + .where(eq(orgs.orgId, orgId)) + .limit(1); + + if (!org) { + return 0; + } + + // store the result in cache + cache.set( + `org_${orgId}_accessDays`, + org.settingsLogRetentionDaysAction, + 300 + ); + + return org.settingsLogRetentionDaysAction; +} + +export async function cleanUpOldLogs(orgId: string, retentionDays: number) { + const now = Math.floor(Date.now() / 1000); + + const cutoffTimestamp = now - retentionDays * 24 * 60 * 60; + + try { + const deleteResult = await db + .delete(accessAuditLog) + .where( + and( + lt(accessAuditLog.timestamp, cutoffTimestamp), + eq(accessAuditLog.orgId, orgId) + ) + ); + + logger.info( + `Cleaned up ${deleteResult.changes} access audit logs older than ${retentionDays} days` + ); + } catch (error) { + logger.error("Error cleaning up old action audit logs:", error); + } +} + +export async function logAccessAudit(data: { + action: boolean; + type: string; + orgId: string; + resourceId?: number; + user?: { username: string; userId: string }; + apiKey?: { name: string | null; apiKeyId: string }; + metadata?: any; + userAgent?: string; + requestIp?: string; +}) { + try { + const retentionDays = await getAccessDays(data.orgId); + if (retentionDays === 0) { + // do not log + return; + } + + let actorType: string | undefined; + let actor: string | undefined; + let actorId: string | undefined; + + const user = data.user; + if (user) { + actorType = "user"; + actor = user.username; + actorId = user.userId; + } + const apiKey = data.apiKey; + if (apiKey) { + actorType = "apiKey"; + actor = apiKey.name || apiKey.apiKeyId; + actorId = apiKey.apiKeyId; + } + + // if (!actorType || !actor || !actorId) { + // logger.warn("logRequestAudit: Incomplete actor information"); + // return; + // } + + const timestamp = Math.floor(Date.now() / 1000); + + let metadata = null; + if (metadata) { + metadata = JSON.stringify(metadata); + } + + const clientIp = data.requestIp + ? (() => { + if ( + data.requestIp.startsWith("[") && + data.requestIp.includes("]") + ) { + // if brackets are found, extract the IPv6 address from between the brackets + const ipv6Match = data.requestIp.match(/\[(.*?)\]/); + if (ipv6Match) { + return ipv6Match[1]; + } + } + return data.requestIp; + })() + : undefined; + + const countryCode = data.requestIp + ? await getCountryCodeFromIp(data.requestIp) + : undefined; + + await db.insert(accessAuditLog).values({ + timestamp: timestamp, + orgId: data.orgId, + actorType, + actor, + actorId, + action: data.action, + type: data.type, + metadata, + resourceId: data.resourceId, + userAgent: data.userAgent, + ip: clientIp, + location: countryCode + }); + } catch (error) { + logger.error(error); + } +} + +async function getCountryCodeFromIp(ip: string): Promise { + const geoIpCacheKey = `geoip_access:${ip}`; + + let cachedCountryCode: string | undefined = cache.get(geoIpCacheKey); + + if (!cachedCountryCode) { + cachedCountryCode = await getCountryCodeForIp(ip); // do it locally + // Cache for longer since IP geolocation doesn't change frequently + cache.set(geoIpCacheKey, cachedCountryCode, 300); // 5 minutes + } + + return cachedCountryCode; +} diff --git a/server/private/lib/traefik/getTraefikConfig.ts b/server/private/lib/traefik/getTraefikConfig.ts index 881e4632..bbbdbcfd 100644 --- a/server/private/lib/traefik/getTraefikConfig.ts +++ b/server/private/lib/traefik/getTraefikConfig.ts @@ -15,6 +15,7 @@ import { certificates, db, domainNamespaces, + domains, exitNodes, loginPage, targetHealthCheck @@ -50,7 +51,8 @@ export async function getTraefikConfig( exitNodeId: number, siteTypes: string[], filterOutNamespaceDomains = false, - generateLoginPageRouters = false + generateLoginPageRouters = false, + allowRawResources = true ): Promise { // Define extended target type with site information type TargetWithSite = Target & { @@ -104,11 +106,16 @@ export async function getTraefikConfig( subnet: sites.subnet, exitNodeId: sites.exitNodeId, // Namespace - domainNamespaceId: domainNamespaces.domainNamespaceId + domainNamespaceId: domainNamespaces.domainNamespaceId, + // Certificate + certificateStatus: certificates.status, + domainCertResolver: domains.certResolver, }) .from(sites) .innerJoin(targets, eq(targets.siteId, sites.siteId)) .innerJoin(resources, eq(resources.resourceId, targets.resourceId)) + .leftJoin(certificates, eq(certificates.domainId, resources.domainId)) + .leftJoin(domains, eq(domains.domainId, resources.domainId)) .leftJoin( targetHealthCheck, eq(targetHealthCheck.targetId, targets.targetId) @@ -135,7 +142,7 @@ export async function getTraefikConfig( isNull(targetHealthCheck.hcHealth) // Include targets with no health check record ), inArray(sites.type, siteTypes), - config.getRawConfig().traefik.allow_raw_resources + allowRawResources ? isNotNull(resources.http) // ignore the http check if allow_raw_resources is true : eq(resources.http, true) ) @@ -206,7 +213,8 @@ export async function getTraefikConfig( pathMatchType: row.pathMatchType, // the targets will all have the same pathMatchType rewritePath: row.rewritePath, rewritePathType: row.rewritePathType, - priority: priority // may be null, we fallback later + priority: priority, // may be null, we fallback later + domainCertResolver: row.domainCertResolver, }); } @@ -294,6 +302,20 @@ export async function getTraefikConfig( config_output.http.services = {}; } + const domainParts = fullDomain.split("."); + let wildCard; + if (domainParts.length <= 2) { + wildCard = `*.${domainParts.join(".")}`; + } else { + wildCard = `*.${domainParts.slice(1).join(".")}`; + } + + if (!resource.subdomain) { + wildCard = resource.fullDomain; + } + + const configDomain = config.getDomain(resource.domainId); + let tls = {}; if (!privateConfig.getRawPrivateConfig().flags.use_pangolin_dns) { const domainParts = fullDomain.split("."); @@ -324,13 +346,13 @@ export async function getTraefikConfig( certResolver: certResolver, ...(preferWildcardCert ? { - domains: [ - { - main: wildCard - } - ] - } - : {}) + domains: [ + { + main: wildCard, + }, + ], + } + : {}), }; } else { // find a cert that matches the full domain, if not continue @@ -582,14 +604,14 @@ export async function getTraefikConfig( })(), ...(resource.stickySession ? { - sticky: { - cookie: { - name: "p_sticky", // TODO: make this configurable via config.yml like other cookies - secure: resource.ssl, - httpOnly: true - } - } - } + sticky: { + cookie: { + name: "p_sticky", // TODO: make this configurable via config.yml like other cookies + secure: resource.ssl, + httpOnly: true + } + } + } : {}) } }; @@ -688,15 +710,20 @@ export async function getTraefikConfig( } }); })(), + ...(resource.proxyProtocol && protocol == "tcp" // proxy protocol only works for tcp + ? { + serversTransport: `pp-transport-v${resource.proxyProtocolVersion || 1}` + } + : {}), ...(resource.stickySession ? { - sticky: { - ipStrategy: { - depth: 0, - sourcePort: true - } - } - } + sticky: { + ipStrategy: { + depth: 0, + sourcePort: true + } + } + } : {}) } }; @@ -744,10 +771,9 @@ export async function getTraefikConfig( loadBalancer: { servers: [ { - url: `http://${ - config.getRawConfig().server + url: `http://${config.getRawConfig().server .internal_hostname - }:${config.getRawConfig().server.next_port}` + }:${config.getRawConfig().server.next_port}` } ] } @@ -763,7 +789,7 @@ export async function getTraefikConfig( continue; } - let tls = {}; + const tls = {}; if ( !privateConfig.getRawPrivateConfig().flags.use_pangolin_dns ) { diff --git a/server/private/middlewares/index.ts b/server/private/middlewares/index.ts index c92b0d3d..bb4d9c05 100644 --- a/server/private/middlewares/index.ts +++ b/server/private/middlewares/index.ts @@ -15,4 +15,5 @@ export * from "./verifyCertificateAccess"; export * from "./verifyRemoteExitNodeAccess"; export * from "./verifyIdpAccess"; export * from "./verifyLoginPageAccess"; -export * from "../../lib/corsWithLoginPage"; \ No newline at end of file +export * from "./logActionAudit"; +export * from "./verifySubscription"; \ No newline at end of file diff --git a/server/private/middlewares/logActionAudit.ts b/server/private/middlewares/logActionAudit.ts new file mode 100644 index 00000000..3dd2f084 --- /dev/null +++ b/server/private/middlewares/logActionAudit.ts @@ -0,0 +1,145 @@ +/* + * This file is part of a proprietary work. + * + * Copyright (c) 2025 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 { ActionsEnum } from "@server/auth/actions"; +import { actionAuditLog, db, orgs } from "@server/db"; +import logger from "@server/logger"; +import HttpCode from "@server/types/HttpCode"; +import { Request, Response, NextFunction } from "express"; +import createHttpError from "http-errors"; +import { and, eq, lt } from "drizzle-orm"; +import cache from "@server/lib/cache"; + +async function getActionDays(orgId: string): Promise { + // check cache first + const cached = cache.get(`org_${orgId}_actionDays`); + if (cached !== undefined) { + return cached; + } + + const [org] = await db + .select({ + settingsLogRetentionDaysAction: orgs.settingsLogRetentionDaysAction + }) + .from(orgs) + .where(eq(orgs.orgId, orgId)) + .limit(1); + + if (!org) { + return 0; + } + + // store the result in cache + cache.set(`org_${orgId}_actionDays`, org.settingsLogRetentionDaysAction, 300); + + return org.settingsLogRetentionDaysAction; +} + +export async function cleanUpOldLogs(orgId: string, retentionDays: number) { + const now = Math.floor(Date.now() / 1000); + + const cutoffTimestamp = now - retentionDays * 24 * 60 * 60; + + try { + const deleteResult = await db + .delete(actionAuditLog) + .where( + and( + lt(actionAuditLog.timestamp, cutoffTimestamp), + eq(actionAuditLog.orgId, orgId) + ) + ); + + logger.info( + `Cleaned up ${deleteResult.changes} action audit logs older than ${retentionDays} days` + ); + } catch (error) { + logger.error("Error cleaning up old action audit logs:", error); + } +} + +export function logActionAudit(action: ActionsEnum) { + return async function ( + req: Request, + res: Response, + next: NextFunction + ): Promise { + try { + let orgId; + let actorType; + let actor; + let actorId; + + const user = req.user; + if (user) { + const userOrg = req.userOrg; + orgId = userOrg?.orgId; + actorType = "user"; + actor = user.username; + actorId = user.userId; + } + const apiKey = req.apiKey; + if (apiKey) { + const apiKeyOrg = req.apiKeyOrg; + orgId = apiKeyOrg?.orgId; + actorType = "apiKey"; + actor = apiKey.name; + actorId = apiKey.apiKeyId; + } + + if (!orgId) { + logger.warn("logActionAudit: No organization context found"); + return next(); + } + + if (!actorType || !actor || !actorId) { + logger.warn("logActionAudit: Incomplete actor information"); + return next(); + } + + const retentionDays = await getActionDays(orgId); + if (retentionDays === 0) { + // do not log + return next(); + } + + const timestamp = Math.floor(Date.now() / 1000); + + let metadata = null; + if (req.params) { + metadata = JSON.stringify(req.params); + } + + await db.insert(actionAuditLog).values({ + timestamp, + orgId, + actorType, + actor, + actorId, + action, + metadata + }); + + return next(); + } catch (error) { + logger.error(error); + return next( + createHttpError( + HttpCode.INTERNAL_SERVER_ERROR, + "Error verifying logging action" + ) + ); + } + }; +} + diff --git a/server/private/middlewares/verifySubscription.ts b/server/private/middlewares/verifySubscription.ts new file mode 100644 index 00000000..5249c026 --- /dev/null +++ b/server/private/middlewares/verifySubscription.ts @@ -0,0 +1,50 @@ +/* + * This file is part of a proprietary work. + * + * Copyright (c) 2025 Fossorial, Inc. + * All rights reserved. + * + * This file is licensed under the Fossorial Commercial License. + * You may not use this file except in compliance with the License. + * Unauthorized use, copying, modification, or distribution is strictly prohibited. + * + * This file is not licensed under the AGPLv3. + */ + +import { Request, Response, NextFunction } from "express"; +import createHttpError from "http-errors"; +import HttpCode from "@server/types/HttpCode"; +import { build } from "@server/build"; +import { getOrgTierData } from "#private/lib/billing"; + +export async function verifyValidSubscription( + req: Request, + res: Response, + next: NextFunction +) { + try { + if (build != "saas") { + return next(); + } + + const tier = await getOrgTierData(req.params.orgId); + + if (!tier.active) { + return next( + createHttpError( + HttpCode.FORBIDDEN, + "Organization does not have an active subscription" + ) + ); + } + + return next(); + } catch (e) { + return next( + createHttpError( + HttpCode.INTERNAL_SERVER_ERROR, + "Error verifying subscription" + ) + ); + } +} diff --git a/server/private/routers/auditLogs/exportAccessAuditLog.ts b/server/private/routers/auditLogs/exportAccessAuditLog.ts new file mode 100644 index 00000000..89aef6cb --- /dev/null +++ b/server/private/routers/auditLogs/exportAccessAuditLog.ts @@ -0,0 +1,81 @@ +/* + * This file is part of a proprietary work. + * + * Copyright (c) 2025 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 { registry } from "@server/openApi"; +import { NextFunction } from "express"; +import { Request, Response } from "express"; +import { OpenAPITags } from "@server/openApi"; +import createHttpError from "http-errors"; +import HttpCode from "@server/types/HttpCode"; +import { fromError } from "zod-validation-error"; +import logger from "@server/logger"; +import { queryAccessAuditLogsParams, queryAccessAuditLogsQuery, queryAccess } from "./queryAccessAuditLog"; +import { generateCSV } from "@server/routers/auditLogs/generateCSV"; + +registry.registerPath({ + method: "get", + path: "/org/{orgId}/logs/access/export", + description: "Export the access audit log for an organization as CSV", + tags: [OpenAPITags.Org], + request: { + query: queryAccessAuditLogsQuery, + params: queryAccessAuditLogsParams + }, + responses: {} +}); + +export async function exportAccessAuditLogs( + req: Request, + res: Response, + next: NextFunction +): Promise { + try { + const parsedQuery = queryAccessAuditLogsQuery.safeParse(req.query); + if (!parsedQuery.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedQuery.error) + ) + ); + } + + const parsedParams = queryAccessAuditLogsParams.safeParse(req.params); + if (!parsedParams.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedParams.error) + ) + ); + } + + const data = { ...parsedQuery.data, ...parsedParams.data }; + + const baseQuery = queryAccess(data); + + const log = await baseQuery.limit(data.limit).offset(data.offset); + + const csvData = generateCSV(log); + + res.setHeader('Content-Type', 'text/csv'); + res.setHeader('Content-Disposition', `attachment; filename="access-audit-logs-${data.orgId}-${Date.now()}.csv"`); + + return res.send(csvData); + } catch (error) { + logger.error(error); + return next( + createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred") + ); + } +} \ No newline at end of file diff --git a/server/private/routers/auditLogs/exportActionAuditLog.ts b/server/private/routers/auditLogs/exportActionAuditLog.ts new file mode 100644 index 00000000..12c9ff8b --- /dev/null +++ b/server/private/routers/auditLogs/exportActionAuditLog.ts @@ -0,0 +1,81 @@ +/* + * This file is part of a proprietary work. + * + * Copyright (c) 2025 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 { registry } from "@server/openApi"; +import { NextFunction } from "express"; +import { Request, Response } from "express"; +import { OpenAPITags } from "@server/openApi"; +import createHttpError from "http-errors"; +import HttpCode from "@server/types/HttpCode"; +import { fromError } from "zod-validation-error"; +import logger from "@server/logger"; +import { queryActionAuditLogsParams, queryActionAuditLogsQuery, queryAction } from "./queryActionAuditLog"; +import { generateCSV } from "@server/routers/auditLogs/generateCSV"; + +registry.registerPath({ + method: "get", + path: "/org/{orgId}/logs/action/export", + description: "Export the action audit log for an organization as CSV", + tags: [OpenAPITags.Org], + request: { + query: queryActionAuditLogsQuery, + params: queryActionAuditLogsParams + }, + responses: {} +}); + +export async function exportActionAuditLogs( + req: Request, + res: Response, + next: NextFunction +): Promise { + try { + const parsedQuery = queryActionAuditLogsQuery.safeParse(req.query); + if (!parsedQuery.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedQuery.error) + ) + ); + } + + const parsedParams = queryActionAuditLogsParams.safeParse(req.params); + if (!parsedParams.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedParams.error) + ) + ); + } + + const data = { ...parsedQuery.data, ...parsedParams.data }; + + const baseQuery = queryAction(data); + + const log = await baseQuery.limit(data.limit).offset(data.offset); + + const csvData = generateCSV(log); + + res.setHeader('Content-Type', 'text/csv'); + res.setHeader('Content-Disposition', `attachment; filename="action-audit-logs-${data.orgId}-${Date.now()}.csv"`); + + return res.send(csvData); + } catch (error) { + logger.error(error); + return next( + createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred") + ); + } +} \ No newline at end of file diff --git a/server/private/routers/auditLogs/index.ts b/server/private/routers/auditLogs/index.ts new file mode 100644 index 00000000..ac623c4c --- /dev/null +++ b/server/private/routers/auditLogs/index.ts @@ -0,0 +1,17 @@ +/* + * This file is part of a proprietary work. + * + * Copyright (c) 2025 Fossorial, Inc. + * All rights reserved. + * + * This file is licensed under the Fossorial Commercial License. + * You may not use this file except in compliance with the License. + * Unauthorized use, copying, modification, or distribution is strictly prohibited. + * + * This file is not licensed under the AGPLv3. + */ + +export * from "./queryActionAuditLog"; +export * from "./exportActionAuditLog"; +export * from "./queryAccessAuditLog"; +export * from "./exportAccessAuditLog"; \ No newline at end of file diff --git a/server/private/routers/auditLogs/queryAccessAuditLog.ts b/server/private/routers/auditLogs/queryAccessAuditLog.ts new file mode 100644 index 00000000..d6c9dea6 --- /dev/null +++ b/server/private/routers/auditLogs/queryAccessAuditLog.ts @@ -0,0 +1,258 @@ +/* + * This file is part of a proprietary work. + * + * Copyright (c) 2025 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 { accessAuditLog, db, resources } from "@server/db"; +import { registry } from "@server/openApi"; +import { NextFunction } from "express"; +import { Request, Response } from "express"; +import { eq, gt, lt, and, count } from "drizzle-orm"; +import { OpenAPITags } from "@server/openApi"; +import { z } from "zod"; +import createHttpError from "http-errors"; +import HttpCode from "@server/types/HttpCode"; +import { fromError } from "zod-validation-error"; +import { QueryAccessAuditLogResponse } from "@server/routers/auditLogs/types"; +import response from "@server/lib/response"; +import logger from "@server/logger"; + +export const queryAccessAuditLogsQuery = z.object({ + // iso string just validate its a parseable date + timeStart: z + .string() + .refine((val) => !isNaN(Date.parse(val)), { + message: "timeStart must be a valid ISO date string" + }) + .transform((val) => Math.floor(new Date(val).getTime() / 1000)), + timeEnd: z + .string() + .refine((val) => !isNaN(Date.parse(val)), { + message: "timeEnd must be a valid ISO date string" + }) + .transform((val) => Math.floor(new Date(val).getTime() / 1000)) + .optional() + .default(new Date().toISOString()), + action: z + .union([z.boolean(), z.string()]) + .transform((val) => (typeof val === "string" ? val === "true" : val)) + .optional(), + actorType: z.string().optional(), + actorId: z.string().optional(), + resourceId: z + .string() + .optional() + .transform(Number) + .pipe(z.number().int().positive()) + .optional(), + actor: z.string().optional(), + type: z.string().optional(), + location: z.string().optional(), + limit: z + .string() + .optional() + .default("1000") + .transform(Number) + .pipe(z.number().int().positive()), + offset: z + .string() + .optional() + .default("0") + .transform(Number) + .pipe(z.number().int().nonnegative()) +}); + +export const queryAccessAuditLogsParams = z.object({ + orgId: z.string() +}); + +export const queryAccessAuditLogsCombined = queryAccessAuditLogsQuery.merge( + queryAccessAuditLogsParams +); +type Q = z.infer; + +function getWhere(data: Q) { + return and( + gt(accessAuditLog.timestamp, data.timeStart), + lt(accessAuditLog.timestamp, data.timeEnd), + eq(accessAuditLog.orgId, data.orgId), + data.resourceId + ? eq(accessAuditLog.resourceId, data.resourceId) + : undefined, + data.actor ? eq(accessAuditLog.actor, data.actor) : undefined, + data.actorType + ? eq(accessAuditLog.actorType, data.actorType) + : undefined, + data.actorId ? eq(accessAuditLog.actorId, data.actorId) : undefined, + data.location ? eq(accessAuditLog.location, data.location) : undefined, + data.type ? eq(accessAuditLog.type, data.type) : undefined, + data.action !== undefined + ? eq(accessAuditLog.action, data.action) + : undefined + ); +} + +export function queryAccess(data: Q) { + return db + .select({ + orgId: accessAuditLog.orgId, + action: accessAuditLog.action, + actorType: accessAuditLog.actorType, + actorId: accessAuditLog.actorId, + resourceId: accessAuditLog.resourceId, + resourceName: resources.name, + resourceNiceId: resources.niceId, + ip: accessAuditLog.ip, + location: accessAuditLog.location, + userAgent: accessAuditLog.userAgent, + metadata: accessAuditLog.metadata, + type: accessAuditLog.type, + timestamp: accessAuditLog.timestamp, + actor: accessAuditLog.actor + }) + .from(accessAuditLog) + .leftJoin( + resources, + eq(accessAuditLog.resourceId, resources.resourceId) + ) + .where(getWhere(data)) + .orderBy(accessAuditLog.timestamp); +} + +export function countAccessQuery(data: Q) { + const countQuery = db + .select({ count: count() }) + .from(accessAuditLog) + .where(getWhere(data)); + return countQuery; +} + +async function queryUniqueFilterAttributes( + timeStart: number, + timeEnd: number, + orgId: string +) { + const baseConditions = and( + gt(accessAuditLog.timestamp, timeStart), + lt(accessAuditLog.timestamp, timeEnd), + eq(accessAuditLog.orgId, orgId) + ); + + // Get unique actors + const uniqueActors = await db + .selectDistinct({ + actor: accessAuditLog.actor + }) + .from(accessAuditLog) + .where(baseConditions); + + // Get unique locations + const uniqueLocations = await db + .selectDistinct({ + locations: accessAuditLog.location + }) + .from(accessAuditLog) + .where(baseConditions); + + // Get unique resources with names + const uniqueResources = await db + .selectDistinct({ + id: accessAuditLog.resourceId, + name: resources.name + }) + .from(accessAuditLog) + .leftJoin( + resources, + eq(accessAuditLog.resourceId, resources.resourceId) + ) + .where(baseConditions); + + return { + actors: uniqueActors.map(row => row.actor).filter((actor): actor is string => actor !== null), + resources: uniqueResources.filter((row): row is { id: number; name: string | null } => row.id !== null), + locations: uniqueLocations.map(row => row.locations).filter((location): location is string => location !== null) + }; +} + +registry.registerPath({ + method: "get", + path: "/org/{orgId}/logs/access", + description: "Query the access audit log for an organization", + tags: [OpenAPITags.Org], + request: { + query: queryAccessAuditLogsQuery, + params: queryAccessAuditLogsParams + }, + responses: {} +}); + +export async function queryAccessAuditLogs( + req: Request, + res: Response, + next: NextFunction +): Promise { + try { + const parsedQuery = queryAccessAuditLogsQuery.safeParse(req.query); + if (!parsedQuery.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedQuery.error) + ) + ); + } + const parsedParams = queryAccessAuditLogsParams.safeParse(req.params); + if (!parsedParams.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedParams.error) + ) + ); + } + + const data = { ...parsedQuery.data, ...parsedParams.data }; + + const baseQuery = queryAccess(data); + + const log = await baseQuery.limit(data.limit).offset(data.offset); + + const totalCountResult = await countAccessQuery(data); + const totalCount = totalCountResult[0].count; + + const filterAttributes = await queryUniqueFilterAttributes( + data.timeStart, + data.timeEnd, + data.orgId + ); + + return response(res, { + data: { + log: log, + pagination: { + total: totalCount, + limit: data.limit, + offset: data.offset + }, + filterAttributes + }, + success: true, + error: false, + message: "Access audit logs retrieved successfully", + status: HttpCode.OK + }); + } catch (error) { + logger.error(error); + return next( + createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred") + ); + } +} diff --git a/server/private/routers/auditLogs/queryActionAuditLog.ts b/server/private/routers/auditLogs/queryActionAuditLog.ts new file mode 100644 index 00000000..f9dcbbf5 --- /dev/null +++ b/server/private/routers/auditLogs/queryActionAuditLog.ts @@ -0,0 +1,211 @@ +/* + * This file is part of a proprietary work. + * + * Copyright (c) 2025 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 { actionAuditLog, db } from "@server/db"; +import { registry } from "@server/openApi"; +import { NextFunction } from "express"; +import { Request, Response } from "express"; +import { eq, gt, lt, and, count } from "drizzle-orm"; +import { OpenAPITags } from "@server/openApi"; +import { z } from "zod"; +import createHttpError from "http-errors"; +import HttpCode from "@server/types/HttpCode"; +import { fromError } from "zod-validation-error"; +import { QueryActionAuditLogResponse } from "@server/routers/auditLogs/types"; +import response from "@server/lib/response"; +import logger from "@server/logger"; + +export const queryActionAuditLogsQuery = z.object({ + // iso string just validate its a parseable date + timeStart: z + .string() + .refine((val) => !isNaN(Date.parse(val)), { + message: "timeStart must be a valid ISO date string" + }) + .transform((val) => Math.floor(new Date(val).getTime() / 1000)), + timeEnd: z + .string() + .refine((val) => !isNaN(Date.parse(val)), { + message: "timeEnd must be a valid ISO date string" + }) + .transform((val) => Math.floor(new Date(val).getTime() / 1000)) + .optional() + .default(new Date().toISOString()), + action: z.string().optional(), + actorType: z.string().optional(), + actorId: z.string().optional(), + actor: z.string().optional(), + limit: z + .string() + .optional() + .default("1000") + .transform(Number) + .pipe(z.number().int().positive()), + offset: z + .string() + .optional() + .default("0") + .transform(Number) + .pipe(z.number().int().nonnegative()) +}); + +export const queryActionAuditLogsParams = z.object({ + orgId: z.string() +}); + +export const queryActionAuditLogsCombined = + queryActionAuditLogsQuery.merge(queryActionAuditLogsParams); +type Q = z.infer; + +function getWhere(data: Q) { + return and( + gt(actionAuditLog.timestamp, data.timeStart), + lt(actionAuditLog.timestamp, data.timeEnd), + eq(actionAuditLog.orgId, data.orgId), + data.actor ? eq(actionAuditLog.actor, data.actor) : undefined, + data.actorType ? eq(actionAuditLog.actorType, data.actorType) : undefined, + data.actorId ? eq(actionAuditLog.actorId, data.actorId) : undefined, + data.action ? eq(actionAuditLog.action, data.action) : undefined + ); +} + +export function queryAction(data: Q) { + return db + .select({ + orgId: actionAuditLog.orgId, + action: actionAuditLog.action, + actorType: actionAuditLog.actorType, + metadata: actionAuditLog.metadata, + actorId: actionAuditLog.actorId, + timestamp: actionAuditLog.timestamp, + actor: actionAuditLog.actor + }) + .from(actionAuditLog) + .where(getWhere(data)) + .orderBy(actionAuditLog.timestamp); +} + +export function countActionQuery(data: Q) { + const countQuery = db + .select({ count: count() }) + .from(actionAuditLog) + .where(getWhere(data)); + return countQuery; +} + +async function queryUniqueFilterAttributes( + timeStart: number, + timeEnd: number, + orgId: string +) { + const baseConditions = and( + gt(actionAuditLog.timestamp, timeStart), + lt(actionAuditLog.timestamp, timeEnd), + eq(actionAuditLog.orgId, orgId) + ); + + // Get unique actors + const uniqueActors = await db + .selectDistinct({ + actor: actionAuditLog.actor + }) + .from(actionAuditLog) + .where(baseConditions); + + const uniqueActions = await db + .selectDistinct({ + action: actionAuditLog.action + }) + .from(actionAuditLog) + .where(baseConditions); + + return { + actors: uniqueActors.map(row => row.actor).filter((actor): actor is string => actor !== null), + actions: uniqueActions.map(row => row.action).filter((action): action is string => action !== null), + }; +} + +registry.registerPath({ + method: "get", + path: "/org/{orgId}/logs/action", + description: "Query the action audit log for an organization", + tags: [OpenAPITags.Org], + request: { + query: queryActionAuditLogsQuery, + params: queryActionAuditLogsParams + }, + responses: {} +}); + +export async function queryActionAuditLogs( + req: Request, + res: Response, + next: NextFunction +): Promise { + try { + const parsedQuery = queryActionAuditLogsQuery.safeParse(req.query); + if (!parsedQuery.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedQuery.error) + ) + ); + } + const parsedParams = queryActionAuditLogsParams.safeParse(req.params); + if (!parsedParams.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedParams.error) + ) + ); + } + + const data = { ...parsedQuery.data, ...parsedParams.data }; + + const baseQuery = queryAction(data); + + const log = await baseQuery.limit(data.limit).offset(data.offset); + + const totalCountResult = await countActionQuery(data); + const totalCount = totalCountResult[0].count; + + const filterAttributes = await queryUniqueFilterAttributes( + data.timeStart, + data.timeEnd, + data.orgId + ); + + return response(res, { + data: { + log: log, + pagination: { + total: totalCount, + limit: data.limit, + offset: data.offset + }, + filterAttributes + }, + success: true, + error: false, + message: "Action audit logs retrieved successfully", + status: HttpCode.OK + }); + } catch (error) { + logger.error(error); + return next( + createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred") + ); + } +} diff --git a/server/private/routers/external.ts b/server/private/routers/external.ts index 74cd6b0c..b569e98f 100644 --- a/server/private/routers/external.ts +++ b/server/private/routers/external.ts @@ -21,20 +21,21 @@ import * as domain from "#private/routers/domain"; import * as auth from "#private/routers/auth"; import * as license from "#private/routers/license"; import * as generateLicense from "./generatedLicense"; +import * as logs from "#private/routers/auditLogs"; -import { Router } from "express"; import { verifyOrgAccess, verifyUserHasAction, - verifyUserIsOrgOwner, verifyUserIsServerAdmin } from "@server/middlewares"; import { ActionsEnum } from "@server/auth/actions"; import { + logActionAudit, verifyCertificateAccess, verifyIdpAccess, verifyLoginPageAccess, - verifyRemoteExitNodeAccess + verifyRemoteExitNodeAccess, + verifyValidSubscription } from "#private/middlewares"; import rateLimit, { ipKeyGenerator } from "express-rate-limit"; import createHttpError from "http-errors"; @@ -72,7 +73,8 @@ authenticated.put( verifyValidLicense, verifyOrgAccess, verifyUserHasAction(ActionsEnum.createIdp), - orgIdp.createOrgOidcIdp + logActionAudit(ActionsEnum.createIdp), + orgIdp.createOrgOidcIdp, ); authenticated.post( @@ -81,7 +83,8 @@ authenticated.post( verifyOrgAccess, verifyIdpAccess, verifyUserHasAction(ActionsEnum.updateIdp), - orgIdp.updateOrgOidcIdp + logActionAudit(ActionsEnum.updateIdp), + orgIdp.updateOrgOidcIdp, ); authenticated.delete( @@ -90,7 +93,8 @@ authenticated.delete( verifyOrgAccess, verifyIdpAccess, verifyUserHasAction(ActionsEnum.deleteIdp), - orgIdp.deleteOrgIdp + logActionAudit(ActionsEnum.deleteIdp), + orgIdp.deleteOrgIdp, ); authenticated.get( @@ -127,7 +131,8 @@ authenticated.post( verifyOrgAccess, verifyCertificateAccess, verifyUserHasAction(ActionsEnum.restartCertificate), - certificates.restartCertificate + logActionAudit(ActionsEnum.restartCertificate), + certificates.restartCertificate, ); if (build === "saas") { @@ -152,14 +157,16 @@ if (build === "saas") { "/org/:orgId/billing/create-checkout-session", verifyOrgAccess, verifyUserHasAction(ActionsEnum.billing), - billing.createCheckoutSession + logActionAudit(ActionsEnum.billing), + billing.createCheckoutSession, ); authenticated.post( "/org/:orgId/billing/create-portal-session", verifyOrgAccess, verifyUserHasAction(ActionsEnum.billing), - billing.createPortalSession + logActionAudit(ActionsEnum.billing), + billing.createPortalSession, ); authenticated.get( @@ -206,7 +213,8 @@ authenticated.put( verifyValidLicense, verifyOrgAccess, verifyUserHasAction(ActionsEnum.createRemoteExitNode), - remoteExitNode.createRemoteExitNode + logActionAudit(ActionsEnum.createRemoteExitNode), + remoteExitNode.createRemoteExitNode, ); authenticated.get( @@ -240,7 +248,8 @@ authenticated.delete( verifyOrgAccess, verifyRemoteExitNodeAccess, verifyUserHasAction(ActionsEnum.deleteRemoteExitNode), - remoteExitNode.deleteRemoteExitNode + logActionAudit(ActionsEnum.deleteRemoteExitNode), + remoteExitNode.deleteRemoteExitNode, ); authenticated.put( @@ -248,7 +257,8 @@ authenticated.put( verifyValidLicense, verifyOrgAccess, verifyUserHasAction(ActionsEnum.createLoginPage), - loginPage.createLoginPage + logActionAudit(ActionsEnum.createLoginPage), + loginPage.createLoginPage, ); authenticated.post( @@ -257,7 +267,8 @@ authenticated.post( verifyOrgAccess, verifyLoginPageAccess, verifyUserHasAction(ActionsEnum.updateLoginPage), - loginPage.updateLoginPage + logActionAudit(ActionsEnum.updateLoginPage), + loginPage.updateLoginPage, ); authenticated.delete( @@ -266,7 +277,8 @@ authenticated.delete( verifyOrgAccess, verifyLoginPageAccess, verifyUserHasAction(ActionsEnum.deleteLoginPage), - loginPage.deleteLoginPage + logActionAudit(ActionsEnum.deleteLoginPage), + loginPage.deleteLoginPage, ); authenticated.get( @@ -334,3 +346,41 @@ authenticated.post( verifyUserIsServerAdmin, license.recheckStatus ); + +authenticated.get( + "/org/:orgId/logs/action", + verifyValidLicense, + verifyValidSubscription, + verifyOrgAccess, + verifyUserHasAction(ActionsEnum.exportLogs), + logs.queryActionAuditLogs +); + +authenticated.get( + "/org/:orgId/logs/action/export", + verifyValidLicense, + verifyValidSubscription, + verifyOrgAccess, + verifyUserHasAction(ActionsEnum.exportLogs), + logActionAudit(ActionsEnum.exportLogs), + logs.exportActionAuditLogs +); + +authenticated.get( + "/org/:orgId/logs/access", + verifyValidLicense, + verifyValidSubscription, + verifyOrgAccess, + verifyUserHasAction(ActionsEnum.exportLogs), + logs.queryAccessAuditLogs +); + +authenticated.get( + "/org/:orgId/logs/access/export", + verifyValidLicense, + verifyValidSubscription, + verifyOrgAccess, + verifyUserHasAction(ActionsEnum.exportLogs), + logActionAudit(ActionsEnum.exportLogs), + logs.exportAccessAuditLogs +); \ No newline at end of file diff --git a/server/private/routers/hybrid.ts b/server/private/routers/hybrid.ts index 9da80e87..1fc5d512 100644 --- a/server/private/routers/hybrid.ts +++ b/server/private/routers/hybrid.ts @@ -35,7 +35,9 @@ import { loginPageOrg, LoginPage, resourceHeaderAuth, - ResourceHeaderAuth + ResourceHeaderAuth, + orgs, + requestAuditLog } from "@server/db"; import { resources, @@ -270,7 +272,8 @@ hybridRouter.get( remoteExitNode.exitNodeId, ["newt", "local", "wireguard"], // Allow them to use all the site types true, // But don't allow domain namespace resources - false // Dont include login pages + false, // Dont include login pages, + true // allow raw resources ); return response(res, { @@ -1583,3 +1586,193 @@ hybridRouter.post( } } ); + +hybridRouter.get( + "/org/:orgId/get-retention-days", + async (req: Request, res: Response, next: NextFunction) => { + try { + const parsedParams = getOrgLoginPageParamsSchema.safeParse( + req.params + ); + if (!parsedParams.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedParams.error).toString() + ) + ); + } + + const { orgId } = parsedParams.data; + + const remoteExitNode = req.remoteExitNode; + + if (!remoteExitNode || !remoteExitNode.exitNodeId) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + "Remote exit node not found" + ) + ); + } + + if (await checkExitNodeOrg(remoteExitNode.exitNodeId, orgId)) { + // If the exit node is not allowed for the org, return an error + return next( + createHttpError( + HttpCode.FORBIDDEN, + "Exit node not allowed for this organization" + ) + ); + } + + const [org] = await db + .select({ + settingsLogRetentionDaysRequest: + orgs.settingsLogRetentionDaysRequest + }) + .from(orgs) + .where(eq(orgs.orgId, orgId)) + .limit(1); + + return response(res, { + data: { + settingsLogRetentionDaysRequest: + org.settingsLogRetentionDaysRequest + }, + success: true, + error: false, + message: "Log retention days retrieved successfully", + status: HttpCode.OK + }); + } catch (error) { + logger.error(error); + return next( + createHttpError( + HttpCode.INTERNAL_SERVER_ERROR, + "An error occurred..." + ) + ); + } + } +); + +const batchLogsSchema = z.object({ + logs: z.array( + z.object({ + timestamp: z.number(), + orgId: z.string().optional(), + actorType: z.string().optional(), + actor: z.string().optional(), + actorId: z.string().optional(), + metadata: z.string().nullable(), + action: z.boolean(), + resourceId: z.number().optional(), + reason: z.number(), + location: z.string().optional(), + originalRequestURL: z.string(), + scheme: z.string(), + host: z.string(), + path: z.string(), + method: z.string(), + ip: z.string().optional(), + tls: z.boolean() + }) + ) +}); + +hybridRouter.post( + "/org/:orgId/logs/batch", + async (req: Request, res: Response, next: NextFunction) => { + try { + const parsedParams = getOrgLoginPageParamsSchema.safeParse( + req.params + ); + if (!parsedParams.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedParams.error).toString() + ) + ); + } + + const { orgId } = parsedParams.data; + + const parsedBody = batchLogsSchema.safeParse(req.body); + if (!parsedBody.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedBody.error).toString() + ) + ); + } + + const { logs } = parsedBody.data; + + const remoteExitNode = req.remoteExitNode; + + if (!remoteExitNode || !remoteExitNode.exitNodeId) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + "Remote exit node not found" + ) + ); + } + + if (await checkExitNodeOrg(remoteExitNode.exitNodeId, orgId)) { + // If the exit node is not allowed for the org, return an error + return next( + createHttpError( + HttpCode.FORBIDDEN, + "Exit node not allowed for this organization" + ) + ); + } + + // Batch insert all logs in a single query + const logEntries = logs.map((logEntry) => ({ + timestamp: logEntry.timestamp, + orgId: logEntry.orgId, + actorType: logEntry.actorType, + actor: logEntry.actor, + actorId: logEntry.actorId, + metadata: logEntry.metadata, + action: logEntry.action, + resourceId: logEntry.resourceId, + reason: logEntry.reason, + location: logEntry.location, + // userAgent: data.userAgent, // TODO: add this + // headers: data.body.headers, + // query: data.body.query, + originalRequestURL: logEntry.originalRequestURL, + scheme: logEntry.scheme, + host: logEntry.host, + path: logEntry.path, + method: logEntry.method, + ip: logEntry.ip, + tls: logEntry.tls + })); + + await db.insert(requestAuditLog).values(logEntries); + + return response(res, { + data: null, + success: true, + error: false, + message: "Logs saved successfully", + status: HttpCode.OK + }); + } catch (error) { + logger.error(error); + return next( + createHttpError( + HttpCode.INTERNAL_SERVER_ERROR, + "An error occurred..." + ) + ); + } + } +); diff --git a/server/private/routers/integration.ts b/server/private/routers/integration.ts index d767424a..21c74624 100644 --- a/server/private/routers/integration.ts +++ b/server/private/routers/integration.ts @@ -23,6 +23,7 @@ import { import { ActionsEnum } from "@server/auth/actions"; import { unauthenticated as ua, authenticated as a } from "@server/routers/integration"; +import { logActionAudit } from "#private/middlewares"; export const unauthenticated = ua; export const authenticated = a; @@ -31,12 +32,14 @@ authenticated.post( `/org/:orgId/send-usage-notification`, verifyApiKeyIsRoot, // We are the only ones who can use root key so its fine verifyApiKeyHasAction(ActionsEnum.sendUsageNotification), - org.sendUsageNotification + logActionAudit(ActionsEnum.sendUsageNotification), + org.sendUsageNotification, ); authenticated.delete( "/idp/:idpId", verifyApiKeyIsRoot, verifyApiKeyHasAction(ActionsEnum.deleteIdp), - orgIdp.deleteOrgIdp + logActionAudit(ActionsEnum.deleteIdp), + orgIdp.deleteOrgIdp, ); \ No newline at end of file diff --git a/server/routers/auditLogs/exportRequstAuditLog.ts b/server/routers/auditLogs/exportRequstAuditLog.ts new file mode 100644 index 00000000..89df2d3f --- /dev/null +++ b/server/routers/auditLogs/exportRequstAuditLog.ts @@ -0,0 +1,68 @@ +import { registry } from "@server/openApi"; +import { NextFunction } from "express"; +import { Request, Response } from "express"; +import { OpenAPITags } from "@server/openApi"; +import createHttpError from "http-errors"; +import HttpCode from "@server/types/HttpCode"; +import { fromError } from "zod-validation-error"; +import logger from "@server/logger"; +import { queryAccessAuditLogsQuery, queryRequestAuditLogsParams, queryRequest } from "./queryRequstAuditLog"; +import { generateCSV } from "./generateCSV"; + +registry.registerPath({ + method: "get", + path: "/org/{orgId}/logs/request", + description: "Query the request audit log for an organization", + tags: [OpenAPITags.Org], + request: { + query: queryAccessAuditLogsQuery, + params: queryRequestAuditLogsParams + }, + responses: {} +}); + +export async function exportRequestAuditLogs( + req: Request, + res: Response, + next: NextFunction +): Promise { + try { + const parsedQuery = queryAccessAuditLogsQuery.safeParse(req.query); + if (!parsedQuery.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedQuery.error) + ) + ); + } + + const parsedParams = queryRequestAuditLogsParams.safeParse(req.params); + if (!parsedParams.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedParams.error) + ) + ); + } + + const data = { ...parsedQuery.data, ...parsedParams.data }; + + const baseQuery = queryRequest(data); + + const log = await baseQuery.limit(data.limit).offset(data.offset); + + const csvData = generateCSV(log); + + res.setHeader('Content-Type', 'text/csv'); + res.setHeader('Content-Disposition', `attachment; filename="request-audit-logs-${data.orgId}-${Date.now()}.csv"`); + + return res.send(csvData); + } catch (error) { + logger.error(error); + return next( + createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred") + ); + } +} diff --git a/server/routers/auditLogs/generateCSV.ts b/server/routers/auditLogs/generateCSV.ts new file mode 100644 index 00000000..8a067069 --- /dev/null +++ b/server/routers/auditLogs/generateCSV.ts @@ -0,0 +1,16 @@ +export function generateCSV(data: any[]): string { + if (data.length === 0) { + return "orgId,action,actorType,timestamp,actor\n"; + } + + const headers = Object.keys(data[0]).join(","); + const rows = data.map(row => + Object.values(row).map(value => + typeof value === 'string' && value.includes(',') + ? `"${value.replace(/"/g, '""')}"` + : value + ).join(",") + ); + + return [headers, ...rows].join("\n"); +} \ No newline at end of file diff --git a/server/routers/auditLogs/index.ts b/server/routers/auditLogs/index.ts new file mode 100644 index 00000000..4823831d --- /dev/null +++ b/server/routers/auditLogs/index.ts @@ -0,0 +1,2 @@ +export * from "./queryRequstAuditLog"; +export * from "./exportRequstAuditLog"; \ No newline at end of file diff --git a/server/routers/auditLogs/queryRequstAuditLog.ts b/server/routers/auditLogs/queryRequstAuditLog.ts new file mode 100644 index 00000000..d41b14b6 --- /dev/null +++ b/server/routers/auditLogs/queryRequstAuditLog.ts @@ -0,0 +1,276 @@ +import { db, requestAuditLog, resources } from "@server/db"; +import { registry } from "@server/openApi"; +import { NextFunction } from "express"; +import { Request, Response } from "express"; +import { eq, gt, lt, and, count } from "drizzle-orm"; +import { OpenAPITags } from "@server/openApi"; +import { z } from "zod"; +import createHttpError from "http-errors"; +import HttpCode from "@server/types/HttpCode"; +import { fromError } from "zod-validation-error"; +import { QueryRequestAuditLogResponse } from "@server/routers/auditLogs/types"; +import response from "@server/lib/response"; +import logger from "@server/logger"; + +export const queryAccessAuditLogsQuery = z.object({ + // iso string just validate its a parseable date + timeStart: z + .string() + .refine((val) => !isNaN(Date.parse(val)), { + message: "timeStart must be a valid ISO date string" + }) + .transform((val) => Math.floor(new Date(val).getTime() / 1000)), + timeEnd: z + .string() + .refine((val) => !isNaN(Date.parse(val)), { + message: "timeEnd must be a valid ISO date string" + }) + .transform((val) => Math.floor(new Date(val).getTime() / 1000)) + .optional() + .default(new Date().toISOString()), + action: z + .union([z.boolean(), z.string()]) + .transform((val) => (typeof val === "string" ? val === "true" : val)) + .optional(), + method: z.enum(["GET", "POST", "PUT", "DELETE", "PATCH"]).optional(), + reason: z + .string() + .optional() + .transform(Number) + .pipe(z.number().int().positive()) + .optional(), + resourceId: z + .string() + .optional() + .transform(Number) + .pipe(z.number().int().positive()) + .optional(), + actor: z.string().optional(), + location: z.string().optional(), + host: z.string().optional(), + path: z.string().optional(), + limit: z + .string() + .optional() + .default("1000") + .transform(Number) + .pipe(z.number().int().positive()), + offset: z + .string() + .optional() + .default("0") + .transform(Number) + .pipe(z.number().int().nonnegative()) +}); + +export const queryRequestAuditLogsParams = z.object({ + orgId: z.string() +}); + +export const queryRequestAuditLogsCombined = + queryAccessAuditLogsQuery.merge(queryRequestAuditLogsParams); +type Q = z.infer; + +function getWhere(data: Q) { + return and( + gt(requestAuditLog.timestamp, data.timeStart), + lt(requestAuditLog.timestamp, data.timeEnd), + eq(requestAuditLog.orgId, data.orgId), + data.resourceId + ? eq(requestAuditLog.resourceId, data.resourceId) + : undefined, + data.actor ? eq(requestAuditLog.actor, data.actor) : undefined, + data.method ? eq(requestAuditLog.method, data.method) : undefined, + data.reason ? eq(requestAuditLog.reason, data.reason) : undefined, + data.host ? eq(requestAuditLog.host, data.host) : undefined, + data.location ? eq(requestAuditLog.location, data.location) : undefined, + data.path ? eq(requestAuditLog.path, data.path) : undefined, + data.action !== undefined + ? eq(requestAuditLog.action, data.action) + : undefined + ); +} + +export function queryRequest(data: Q) { + return db + .select({ + timestamp: requestAuditLog.timestamp, + orgId: requestAuditLog.orgId, + action: requestAuditLog.action, + reason: requestAuditLog.reason, + actorType: requestAuditLog.actorType, + actor: requestAuditLog.actor, + actorId: requestAuditLog.actorId, + resourceId: requestAuditLog.resourceId, + ip: requestAuditLog.ip, + location: requestAuditLog.location, + userAgent: requestAuditLog.userAgent, + metadata: requestAuditLog.metadata, + headers: requestAuditLog.headers, + query: requestAuditLog.query, + originalRequestURL: requestAuditLog.originalRequestURL, + scheme: requestAuditLog.scheme, + host: requestAuditLog.host, + path: requestAuditLog.path, + method: requestAuditLog.method, + tls: requestAuditLog.tls, + resourceName: resources.name, + resourceNiceId: resources.niceId + }) + .from(requestAuditLog) + .leftJoin( + resources, + eq(requestAuditLog.resourceId, resources.resourceId) + ) // TODO: Is this efficient? + .where(getWhere(data)) + .orderBy(requestAuditLog.timestamp); +} + +export function countRequestQuery(data: Q) { + const countQuery = db + .select({ count: count() }) + .from(requestAuditLog) + .where(getWhere(data)); + return countQuery; +} + +registry.registerPath({ + method: "get", + path: "/org/{orgId}/logs/request", + description: "Query the request audit log for an organization", + tags: [OpenAPITags.Org], + request: { + query: queryAccessAuditLogsQuery, + params: queryRequestAuditLogsParams + }, + responses: {} +}); + +async function queryUniqueFilterAttributes( + timeStart: number, + timeEnd: number, + orgId: string +) { + const baseConditions = and( + gt(requestAuditLog.timestamp, timeStart), + lt(requestAuditLog.timestamp, timeEnd), + eq(requestAuditLog.orgId, orgId) + ); + + // Get unique actors + const uniqueActors = await db + .selectDistinct({ + actor: requestAuditLog.actor + }) + .from(requestAuditLog) + .where(baseConditions); + + // Get unique locations + const uniqueLocations = await db + .selectDistinct({ + locations: requestAuditLog.location + }) + .from(requestAuditLog) + .where(baseConditions); + + // Get unique actors + const uniqueHosts = await db + .selectDistinct({ + hosts: requestAuditLog.host + }) + .from(requestAuditLog) + .where(baseConditions); + + // Get unique actors + const uniquePaths = await db + .selectDistinct({ + paths: requestAuditLog.path + }) + .from(requestAuditLog) + .where(baseConditions); + + // Get unique resources with names + const uniqueResources = await db + .selectDistinct({ + id: requestAuditLog.resourceId, + name: resources.name + }) + .from(requestAuditLog) + .leftJoin( + resources, + eq(requestAuditLog.resourceId, resources.resourceId) + ) + .where(baseConditions); + + return { + actors: uniqueActors.map(row => row.actor).filter((actor): actor is string => actor !== null), + resources: uniqueResources.filter((row): row is { id: number; name: string | null } => row.id !== null), + locations: uniqueLocations.map(row => row.locations).filter((location): location is string => location !== null), + hosts: uniqueHosts.map(row => row.hosts).filter((host): host is string => host !== null), + paths: uniquePaths.map(row => row.paths).filter((path): path is string => path !== null) + }; +} + +export async function queryRequestAuditLogs( + req: Request, + res: Response, + next: NextFunction +): Promise { + try { + const parsedQuery = queryAccessAuditLogsQuery.safeParse(req.query); + if (!parsedQuery.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedQuery.error) + ) + ); + } + + const parsedParams = queryRequestAuditLogsParams.safeParse(req.params); + if (!parsedParams.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedParams.error) + ) + ); + } + + const data = { ...parsedQuery.data, ...parsedParams.data }; + + const baseQuery = queryRequest(data); + + const log = await baseQuery.limit(data.limit).offset(data.offset); + + const totalCountResult = await countRequestQuery(data); + const totalCount = totalCountResult[0].count; + + const filterAttributes = await queryUniqueFilterAttributes( + data.timeStart, + data.timeEnd, + data.orgId + ); + + return response(res, { + data: { + log: log, + pagination: { + total: totalCount, + limit: data.limit, + offset: data.offset + }, + filterAttributes + }, + success: true, + error: false, + message: "Action audit logs retrieved successfully", + status: HttpCode.OK + }); + } catch (error) { + logger.error(error); + return next( + createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred") + ); + } +} diff --git a/server/routers/auditLogs/types.ts b/server/routers/auditLogs/types.ts new file mode 100644 index 00000000..81cef733 --- /dev/null +++ b/server/routers/auditLogs/types.ts @@ -0,0 +1,93 @@ +export type QueryActionAuditLogResponse = { + log: { + orgId: string; + action: string; + actorType: string; + actorId: string; + metadata: string | null; + timestamp: number; + actor: string; + }[]; + pagination: { + total: number; + limit: number; + offset: number; + }; + filterAttributes: { + actors: string[]; + }; +}; + +export type QueryRequestAuditLogResponse = { + log: { + timestamp: number; + action: boolean; + reason: number; + orgId: string | null; + actorType: string | null; + actor: string | null; + actorId: string | null; + resourceId: number | null; + resourceNiceId: string | null; + resourceName: string | null; + ip: string | null; + location: string | null; + userAgent: string | null; + metadata: string | null; + headers: string | null; + query: string | null; + originalRequestURL: string | null; + scheme: string | null; + host: string | null; + path: string | null; + method: string | null; + tls: boolean | null; + }[]; + pagination: { + total: number; + limit: number; + offset: number; + }; + filterAttributes: { + actors: string[]; + resources: { + id: number; + name: string | null; + }[]; + locations: string[]; + hosts: string[]; + paths: string[]; + }; +}; + +export type QueryAccessAuditLogResponse = { + log: { + orgId: string; + action: boolean; + actorType: string | null; + actorId: string | null; + resourceId: number | null; + resourceName: string | null; + resourceNiceId: string | null; + ip: string | null; + location: string | null; + userAgent: string | null; + metadata: string | null; + type: string; + timestamp: number; + actor: string | null; + }[]; + pagination: { + total: number; + limit: number; + offset: number; + }; + filterAttributes: { + actors: string[]; + resources: { + id: number; + name: string | null; + }[]; + locations: string[]; + }; +}; \ No newline at end of file diff --git a/server/routers/auth/login.ts b/server/routers/auth/login.ts index 8dad5a42..a3fe1b54 100644 --- a/server/routers/auth/login.ts +++ b/server/routers/auth/login.ts @@ -3,7 +3,7 @@ import { generateSessionToken, serializeSessionCookie } from "@server/auth/sessions/app"; -import { db } from "@server/db"; +import { db, resources } from "@server/db"; import { users, securityKeys } from "@server/db"; import HttpCode from "@server/types/HttpCode"; import response from "@server/lib/response"; @@ -18,12 +18,14 @@ import logger from "@server/logger"; import { verifyPassword } from "@server/auth/password"; import { verifySession } from "@server/auth/sessions/verifySession"; import { UserType } from "@server/types/UserTypes"; +import { logAccessAudit } from "@server/private/lib/logAccessAudit"; export const loginBodySchema = z .object({ email: z.string().toLowerCase().email(), password: z.string(), - code: z.string().optional() + code: z.string().optional(), + resourceGuid: z.string().optional() }) .strict(); @@ -52,7 +54,7 @@ export async function login( ); } - const { email, password, code } = parsedBody.data; + const { email, password, code, resourceGuid } = parsedBody.data; try { const { session: existingSession } = await verifySession(req); @@ -66,6 +68,28 @@ export async function login( }); } + let resourceId: number | null = null; + let orgId: string | null = null; + if (resourceGuid) { + const [resource] = await db + .select() + .from(resources) + .where(eq(resources.resourceGuid, resourceGuid)) + .limit(1); + + if (!resource) { + return next( + createHttpError( + HttpCode.NOT_FOUND, + `Resource with GUID ${resourceGuid} not found` + ) + ); + } + + resourceId = resource.resourceId; + orgId = resource.orgId; + } + const existingUserRes = await db .select() .from(users) @@ -78,6 +102,18 @@ export async function login( `Username or password incorrect. Email: ${email}. IP: ${req.ip}.` ); } + + if (resourceId && orgId) { + logAccessAudit({ + orgId: orgId, + resourceId: resourceId, + action: false, + type: "login", + userAgent: req.headers["user-agent"], + requestIp: req.ip + }); + } + return next( createHttpError( HttpCode.UNAUTHORIZED, @@ -98,6 +134,18 @@ export async function login( `Username or password incorrect. Email: ${email}. IP: ${req.ip}.` ); } + + if (resourceId && orgId) { + logAccessAudit({ + orgId: orgId, + resourceId: resourceId, + action: false, + type: "login", + userAgent: req.headers["user-agent"], + requestIp: req.ip + }); + } + return next( createHttpError( HttpCode.UNAUTHORIZED, @@ -158,6 +206,18 @@ export async function login( `Two-factor code incorrect. Email: ${email}. IP: ${req.ip}.` ); } + + if (resourceId && orgId) { + logAccessAudit({ + orgId: orgId, + resourceId: resourceId, + action: false, + type: "login", + userAgent: req.headers["user-agent"], + requestIp: req.ip + }); + } + return next( createHttpError( HttpCode.UNAUTHORIZED, diff --git a/server/routers/badger/logRequestAudit.ts b/server/routers/badger/logRequestAudit.ts new file mode 100644 index 00000000..b0754aed --- /dev/null +++ b/server/routers/badger/logRequestAudit.ts @@ -0,0 +1,191 @@ +import { db, orgs, requestAuditLog } from "@server/db"; +import logger from "@server/logger"; +import { and, eq, lt } from "drizzle-orm"; +import cache from "@server/lib/cache"; + +/** + +Reasons: +100 - Allowed by Rule +101 - Allowed No Auth +102 - Valid Access Token +103 - Valid header auth +104 - Valid Pincode +105 - Valid Password +106 - Valid email +107 - Valid SSO + +201 - Resource Not Found +202 - Resource Blocked +203 - Dropped by Rule +204 - No Sessions +205 - Temporary Request Token +299 - No More Auth Methods + + */ + +async function getRetentionDays(orgId: string): Promise { + // check cache first + const cached = cache.get(`org_${orgId}_retentionDays`); + if (cached !== undefined) { + return cached; + } + + const [org] = await db + .select({ + settingsLogRetentionDaysRequest: + orgs.settingsLogRetentionDaysRequest + }) + .from(orgs) + .where(eq(orgs.orgId, orgId)) + .limit(1); + + if (!org) { + return 0; + } + + // store the result in cache + cache.set( + `org_${orgId}_retentionDays`, + org.settingsLogRetentionDaysRequest, + 300 + ); + + return org.settingsLogRetentionDaysRequest; +} + +export async function cleanUpOldLogs(orgId: string, retentionDays: number) { + const now = Math.floor(Date.now() / 1000); + + const cutoffTimestamp = now - retentionDays * 24 * 60 * 60; + + try { + const deleteResult = await db + .delete(requestAuditLog) + .where( + and( + lt(requestAuditLog.timestamp, cutoffTimestamp), + eq(requestAuditLog.orgId, orgId) + ) + ); + + logger.info( + `Cleaned up ${deleteResult.changes} request audit logs older than ${retentionDays} days` + ); + } catch (error) { + logger.error("Error cleaning up old request audit logs:", error); + } +} + +export async function logRequestAudit( + data: { + action: boolean; + reason: number; + resourceId?: number; + orgId?: string; + location?: string; + user?: { username: string; userId: string }; + apiKey?: { name: string | null; apiKeyId: string }; + metadata?: any; + // userAgent?: string; + }, + body: { + path: string; + originalRequestURL: string; + scheme: string; + host: string; + method: string; + tls: boolean; + sessions?: Record; + headers?: Record; + query?: Record; + requestIp?: string; + } +) { + try { + if (data.orgId) { + const retentionDays = await getRetentionDays(data.orgId); + if (retentionDays === 0) { + // do not log + return; + } + } + + let actorType: string | undefined; + let actor: string | undefined; + let actorId: string | undefined; + + const user = data.user; + if (user) { + actorType = "user"; + actor = user.username; + actorId = user.userId; + } + const apiKey = data.apiKey; + if (apiKey) { + actorType = "apiKey"; + actor = apiKey.name || apiKey.apiKeyId; + actorId = apiKey.apiKeyId; + } + + // if (!actorType || !actor || !actorId) { + // logger.warn("logRequestAudit: Incomplete actor information"); + // return; + // } + + const timestamp = Math.floor(Date.now() / 1000); + + let metadata = null; + if (metadata) { + metadata = JSON.stringify(metadata); + } + + const clientIp = body.requestIp + ? (() => { + if ( + body.requestIp.startsWith("[") && + body.requestIp.includes("]") + ) { + // if brackets are found, extract the IPv6 address from between the brackets + const ipv6Match = body.requestIp.match(/\[(.*?)\]/); + if (ipv6Match) { + return ipv6Match[1]; + } + } + + // ivp4 + // split at last colon + const lastColonIndex = body.requestIp.lastIndexOf(":"); + if (lastColonIndex !== -1) { + return body.requestIp.substring(0, lastColonIndex); + } + return body.requestIp; + })() + : undefined; + + await db.insert(requestAuditLog).values({ + timestamp, + orgId: data.orgId, + actorType, + actor, + actorId, + metadata, + action: data.action, + resourceId: data.resourceId, + reason: data.reason, + location: data.location, + // userAgent: data.userAgent, // TODO: add this + // headers: data.body.headers, + // query: data.body.query, + originalRequestURL: body.originalRequestURL, + scheme: body.scheme, + host: body.host, + path: body.path, + method: body.method, + ip: clientIp, + tls: body.tls + }); + } catch (error) { + logger.error(error); + } +} diff --git a/server/routers/badger/verifySession.ts b/server/routers/badger/verifySession.ts index 2f88cc46..085ad6b6 100644 --- a/server/routers/badger/verifySession.ts +++ b/server/routers/badger/verifySession.ts @@ -1,9 +1,4 @@ -import { generateSessionToken } from "@server/auth/sessions/app"; -import { - createResourceSession, - serializeResourceSessionCookie, - validateResourceSessionToken -} from "@server/auth/sessions/resource"; +import { validateResourceSessionToken } from "@server/auth/sessions/resource"; import { verifyResourceAccessToken } from "@server/auth/verifyResourceAccessToken"; import { getResourceByDomain, @@ -18,7 +13,6 @@ import { LoginPage, Org, Resource, - ResourceAccessToken, ResourceHeaderAuth, ResourcePassword, ResourcePincode, @@ -32,7 +26,6 @@ import logger from "@server/logger"; import HttpCode from "@server/types/HttpCode"; import { NextFunction, Request, Response } from "express"; import createHttpError from "http-errors"; -import NodeCache from "node-cache"; import { z } from "zod"; import { fromError } from "zod-validation-error"; import { getCountryCodeForIp } from "@server/lib/geoip"; @@ -43,11 +36,8 @@ import { checkOrgAccessPolicy, enforceResourceSessionLength } from "#dynamic/lib/checkOrgAccessPolicy"; - -// We'll see if this speeds anything up -const cache = new NodeCache({ - stdTTL: 5 // seconds -}); +import { logRequestAudit } from "./logRequestAudit"; +import cache from "@server/lib/cache"; const verifyResourceSessionSchema = z.object({ sessions: z.record(z.string()).optional(), @@ -133,6 +123,10 @@ export async function verifyResourceSession( logger.debug("Client IP:", { clientIp }); + const ipCC = clientIp + ? await getCountryCodeFromIp(clientIp) + : undefined; + let cleanHost = host; // if the host ends with :port, strip it if (cleanHost.match(/:[0-9]{1,5}$/)) { @@ -156,17 +150,43 @@ export async function verifyResourceSession( if (!result) { logger.debug(`Resource not found ${cleanHost}`); + + // TODO: we cant log this for now because we dont know the org + // eventually it would be cool to show this for the server admin + + // logRequestAudit( + // { + // action: false, + // reason: 201, //resource not found + // location: ipCC + // }, + // parsedBody.data + // ); + return notAllowed(res); } resourceData = result; - cache.set(resourceCacheKey, resourceData); + cache.set(resourceCacheKey, resourceData, 5); } const { resource, pincode, password, headerAuth } = resourceData; if (!resource) { logger.debug(`Resource not found ${cleanHost}`); + + // TODO: we cant log this for now because we dont know the org + // eventually it would be cool to show this for the server admin + + // logRequestAudit( + // { + // action: false, + // reason: 201, //resource not found + // location: ipCC + // }, + // parsedBody.data + // ); + return notAllowed(res); } @@ -174,6 +194,18 @@ export async function verifyResourceSession( if (blockAccess) { logger.debug("Resource blocked", host); + + logRequestAudit( + { + action: false, + reason: 202, //resource blocked + resourceId: resource.resourceId, + orgId: resource.orgId, + location: ipCC + }, + parsedBody.data + ); + return notAllowed(res); } @@ -182,14 +214,40 @@ export async function verifyResourceSession( const action = await checkRules( resource.resourceId, clientIp, - path + path, + ipCC ); if (action == "ACCEPT") { logger.debug("Resource allowed by rule"); + + logRequestAudit( + { + action: true, + reason: 100, // allowed by rule + resourceId: resource.resourceId, + orgId: resource.orgId, + location: ipCC + }, + parsedBody.data + ); + return allowed(res); } else if (action == "DROP") { logger.debug("Resource denied by rule"); + + // TODO: add rules type + logRequestAudit( + { + action: false, + reason: 203, // dropped by rules + resourceId: resource.resourceId, + orgId: resource.orgId, + location: ipCC + }, + parsedBody.data + ); + return notAllowed(res); } else if (action == "PASS") { logger.debug( @@ -210,6 +268,18 @@ export async function verifyResourceSession( !headerAuth ) { logger.debug("Resource allowed because no auth"); + + logRequestAudit( + { + action: true, + reason: 101, // allowed no auth + resourceId: resource.resourceId, + orgId: resource.orgId, + location: ipCC + }, + parsedBody.data + ); + return allowed(res); } @@ -261,6 +331,21 @@ export async function verifyResourceSession( } if (valid && tokenItem) { + logRequestAudit( + { + action: true, + reason: 102, // valid access token + resourceId: resource.resourceId, + orgId: resource.orgId, + location: ipCC, + apiKey: { + name: tokenItem.title, + apiKeyId: tokenItem.accessTokenId + } + }, + parsedBody.data + ); + return allowed(res); } } @@ -297,6 +382,21 @@ export async function verifyResourceSession( } if (valid && tokenItem) { + logRequestAudit( + { + action: true, + reason: 102, // valid access token + resourceId: resource.resourceId, + orgId: resource.orgId, + location: ipCC, + apiKey: { + name: tokenItem.title, + apiKeyId: tokenItem.accessTokenId + } + }, + parsedBody.data + ); + return allowed(res); } } @@ -308,6 +408,18 @@ export async function verifyResourceSession( logger.debug( "Resource allowed because header auth is valid (cached)" ); + + logRequestAudit( + { + action: true, + reason: 103, // valid header auth + resourceId: resource.resourceId, + orgId: resource.orgId, + location: ipCC + }, + parsedBody.data + ); + return allowed(res); } else if ( await verifyPassword( @@ -315,8 +427,20 @@ export async function verifyResourceSession( headerAuth.headerAuthHash ) ) { - cache.set(clientHeaderAuthKey, clientHeaderAuth); + cache.set(clientHeaderAuthKey, clientHeaderAuth, 5); logger.debug("Resource allowed because header auth is valid"); + + logRequestAudit( + { + action: true, + reason: 103, // valid header auth + resourceId: resource.resourceId, + orgId: resource.orgId, + location: ipCC + }, + parsedBody.data + ); + return allowed(res); } @@ -327,6 +451,17 @@ export async function verifyResourceSession( !password && !resource.emailWhitelistEnabled ) { + logRequestAudit( + { + action: false, + reason: 299, // no more auth methods + resourceId: resource.resourceId, + orgId: resource.orgId, + location: ipCC + }, + parsedBody.data + ); + return notAllowed(res); } } else if (headerAuth) { @@ -337,6 +472,17 @@ export async function verifyResourceSession( !password && !resource.emailWhitelistEnabled ) { + logRequestAudit( + { + action: false, + reason: 299, // no more auth methods + resourceId: resource.resourceId, + orgId: resource.orgId, + location: ipCC + }, + parsedBody.data + ); + return notAllowed(res); } } @@ -349,6 +495,18 @@ export async function verifyResourceSession( }. IP: ${clientIp}.` ); } + + logRequestAudit( + { + action: false, + reason: 204, // no sessions + resourceId: resource.resourceId, + orgId: resource.orgId, + location: ipCC + }, + parsedBody.data + ); + return notAllowed(res); } @@ -368,7 +526,7 @@ export async function verifyResourceSession( ); resourceSession = result?.resourceSession; - cache.set(sessionCacheKey, resourceSession); + cache.set(sessionCacheKey, resourceSession, 5); } if (resourceSession?.isRequestToken) { @@ -382,6 +540,18 @@ export async function verifyResourceSession( }. IP: ${clientIp}.` ); } + + logRequestAudit( + { + action: false, + reason: 205, // temporary request token + resourceId: resource.resourceId, + orgId: resource.orgId, + location: ipCC + }, + parsedBody.data + ); + return notAllowed(res); } @@ -404,6 +574,18 @@ export async function verifyResourceSession( logger.debug( "Resource allowed because pincode session is valid" ); + + logRequestAudit( + { + action: true, + reason: 104, // valid pincode + resourceId: resource.resourceId, + orgId: resource.orgId, + location: ipCC + }, + parsedBody.data + ); + return allowed(res); } @@ -411,6 +593,18 @@ export async function verifyResourceSession( logger.debug( "Resource allowed because password session is valid" ); + + logRequestAudit( + { + action: true, + reason: 105, // valid password + resourceId: resource.resourceId, + orgId: resource.orgId, + location: ipCC + }, + parsedBody.data + ); + return allowed(res); } @@ -421,6 +615,18 @@ export async function verifyResourceSession( logger.debug( "Resource allowed because whitelist session is valid" ); + + logRequestAudit( + { + action: true, + reason: 106, // valid email + resourceId: resource.resourceId, + orgId: resource.orgId, + location: ipCC + }, + parsedBody.data + ); + return allowed(res); } @@ -428,6 +634,22 @@ export async function verifyResourceSession( logger.debug( "Resource allowed because access token session is valid" ); + + logRequestAudit( + { + action: true, + reason: 102, // valid access token + resourceId: resource.resourceId, + orgId: resource.orgId, + location: ipCC, + apiKey: { + name: resourceSession.accessTokenTitle, + apiKeyId: resourceSession.accessTokenId + } + }, + parsedBody.data + ); + return allowed(res); } @@ -446,7 +668,7 @@ export async function verifyResourceSession( resourceData.org ); - cache.set(userAccessCacheKey, allowedUserData); + cache.set(userAccessCacheKey, allowedUserData, 5); } if ( @@ -456,6 +678,22 @@ export async function verifyResourceSession( logger.debug( "Resource allowed because user session is valid" ); + + logRequestAudit( + { + action: true, + reason: 107, // valid sso + resourceId: resource.resourceId, + orgId: resource.orgId, + location: ipCC, + user: { + username: allowedUserData.username, + userId: resourceSession.userId + } + }, + parsedBody.data + ); + return allowed(res, allowedUserData); } } @@ -474,6 +712,17 @@ export async function verifyResourceSession( logger.debug(`Redirecting to login at ${redirectPath}`); + logRequestAudit( + { + action: false, + reason: 299, // no more auth methods + resourceId: resource.resourceId, + orgId: resource.orgId, + location: ipCC + }, + parsedBody.data + ); + return notAllowed(res, redirectPath, resource.orgId); } catch (e) { console.error(e); @@ -657,7 +906,8 @@ async function isUserAllowedToAccessResource( async function checkRules( resourceId: number, clientIp: string | undefined, - path: string | undefined + path: string | undefined, + ipCC?: string ): Promise<"ACCEPT" | "DROP" | "PASS" | undefined> { const ruleCacheKey = `rules:${resourceId}`; @@ -665,7 +915,7 @@ async function checkRules( if (!rules) { rules = await getResourceRules(resourceId); - cache.set(ruleCacheKey, rules); + cache.set(ruleCacheKey, rules, 5); } if (rules.length === 0) { @@ -697,7 +947,7 @@ async function checkRules( return rule.action as any; } else if ( clientIp && - rule.match == "GEOIP" && + rule.match == "COUNTRY" && (await isIpInGeoIP(clientIp, rule.value)) ) { return rule.action as any; @@ -826,11 +1076,20 @@ export function isPathAllowed(pattern: string, path: string): boolean { return result; } -async function isIpInGeoIP(ip: string, countryCode: string): Promise { - if (countryCode == "ALL") { +async function isIpInGeoIP( + ipCountryCode: string, + checkCountryCode: string +): Promise { + if (checkCountryCode == "ALL") { return true; } + logger.debug(`IP ${ipCountryCode} is in country: ${checkCountryCode}`); + + return ipCountryCode?.toUpperCase() === checkCountryCode.toUpperCase(); +} + +async function getCountryCodeFromIp(ip: string): Promise { const geoIpCacheKey = `geoip:${ip}`; let cachedCountryCode: string | undefined = cache.get(geoIpCacheKey); @@ -841,9 +1100,7 @@ async function isIpInGeoIP(ip: string, countryCode: string): Promise { cache.set(geoIpCacheKey, cachedCountryCode, 300); // 5 minutes } - logger.debug(`IP ${ip} is in country: ${cachedCountryCode}`); - - return cachedCountryCode?.toUpperCase() === countryCode.toUpperCase(); + return cachedCountryCode; } function extractBasicAuth( diff --git a/server/routers/client/listClients.ts b/server/routers/client/listClients.ts index ff03b2e0..209b54b4 100644 --- a/server/routers/client/listClients.ts +++ b/server/routers/client/listClients.ts @@ -1,4 +1,4 @@ -import { db } from "@server/db"; +import { db, olms } from "@server/db"; import { clients, orgs, @@ -16,6 +16,67 @@ import createHttpError from "http-errors"; import { z } from "zod"; import { fromError } from "zod-validation-error"; import { OpenAPITags, registry } from "@server/openApi"; +import NodeCache from "node-cache"; +import semver from "semver"; + +const olmVersionCache = new NodeCache({ stdTTL: 3600 }); + +async function getLatestOlmVersion(): Promise { + try { + const cachedVersion = olmVersionCache.get("latestOlmVersion"); + if (cachedVersion) { + return cachedVersion; + } + + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), 1500); + + const response = await fetch( + "https://api.github.com/repos/fosrl/olm/tags", + { + signal: controller.signal + } + ); + + clearTimeout(timeoutId); + + if (!response.ok) { + logger.warn( + `Failed to fetch latest Olm version from GitHub: ${response.status} ${response.statusText}` + ); + return null; + } + + const tags = await response.json(); + if (!Array.isArray(tags) || tags.length === 0) { + logger.warn("No tags found for Olm repository"); + return null; + } + + const latestVersion = tags[0].name; + + olmVersionCache.set("latestOlmVersion", latestVersion); + + return latestVersion; + } catch (error: any) { + if (error.name === "AbortError") { + logger.warn( + "Request to fetch latest Olm version timed out (1.5s)" + ); + } else if (error.cause?.code === "UND_ERR_CONNECT_TIMEOUT") { + logger.warn( + "Connection timeout while fetching latest Olm version" + ); + } else { + logger.warn( + "Error fetching latest Olm version:", + error.message || error + ); + } + return null; + } +} + const listClientsParamsSchema = z .object({ @@ -50,10 +111,12 @@ function queryClients(orgId: string, accessibleClientIds: number[]) { megabytesOut: clients.megabytesOut, orgName: orgs.name, type: clients.type, - online: clients.online + online: clients.online, + olmVersion: olms.version }) .from(clients) .leftJoin(orgs, eq(clients.orgId, orgs.orgId)) + .leftJoin(olms, eq(clients.clientId, olms.clientId)) .where( and( inArray(clients.clientId, accessibleClientIds), @@ -77,12 +140,20 @@ async function getSiteAssociations(clientIds: number[]) { .where(inArray(clientSites.clientId, clientIds)); } +type OlmWithUpdateAvailable = Awaited>[0] & { + olmUpdateAvailable?: boolean; +}; + + export type ListClientsResponse = { - clients: Array>[0] & { sites: Array<{ - siteId: number; - siteName: string | null; - siteNiceId: string | null; - }> }>; + clients: Array>[0] & { + sites: Array<{ + siteId: number; + siteName: string | null; + siteNiceId: string | null; + }> + olmUpdateAvailable?: boolean; + }>; pagination: { total: number; limit: number; offset: number }; }; @@ -206,6 +277,43 @@ export async function listClients( sites: sitesByClient[client.clientId] || [] })); + const latestOlVersionPromise = getLatestOlmVersion(); + + const olmsWithUpdates: OlmWithUpdateAvailable[] = clientsWithSites.map( + (client) => { + const OlmWithUpdate: OlmWithUpdateAvailable = { ...client }; + // Initially set to false, will be updated if version check succeeds + OlmWithUpdate.olmUpdateAvailable = false; + return OlmWithUpdate; + } + ); + + // Try to get the latest version, but don't block if it fails + try { + const latestOlVersion = await latestOlVersionPromise; + + if (latestOlVersion) { + olmsWithUpdates.forEach((client) => { + try { + client.olmUpdateAvailable = semver.lt( + client.olmVersion ? client.olmVersion : "", + latestOlVersion + ); + } catch (error) { + client.olmUpdateAvailable = false; + } + + }); + } + } catch (error) { + // Log the error but don't let it block the response + logger.warn( + "Failed to check for OLM updates, continuing without update info:", + error + ); + } + + return response(res, { data: { clients: clientsWithSites, diff --git a/server/routers/domain/createOrgDomain.ts b/server/routers/domain/createOrgDomain.ts index d0e8a72b..d40a0cb8 100644 --- a/server/routers/domain/createOrgDomain.ts +++ b/server/routers/domain/createOrgDomain.ts @@ -1,6 +1,6 @@ import { Request, Response, NextFunction } from "express"; import { z } from "zod"; -import { db, Domain, domains, OrgDomains, orgDomains } from "@server/db"; +import { db, Domain, domains, OrgDomains, orgDomains, dnsRecords } from "@server/db"; import response from "@server/lib/response"; import HttpCode from "@server/types/HttpCode"; import createHttpError from "http-errors"; @@ -24,16 +24,21 @@ const paramsSchema = z const bodySchema = z .object({ type: z.enum(["ns", "cname", "wildcard"]), - baseDomain: subdomainSchema + baseDomain: subdomainSchema, + certResolver: z.string().optional().nullable(), + preferWildcardCert: z.boolean().optional().nullable() // optional, only for wildcard }) .strict(); + export type CreateDomainResponse = { domainId: string; nsRecords?: string[]; cnameRecords?: { baseDomain: string; value: string }[]; aRecords?: { baseDomain: string; value: string }[]; txtRecords?: { baseDomain: string; value: string }[]; + certResolver?: string | null; + preferWildcardCert?: boolean | null; }; // Helper to check if a domain is a subdomain or equal to another domain @@ -71,7 +76,7 @@ export async function createOrgDomain( } const { orgId } = parsedParams.data; - const { type, baseDomain } = parsedBody.data; + const { type, baseDomain, certResolver, preferWildcardCert } = parsedBody.data; if (build == "oss") { if (type !== "wildcard") { @@ -254,7 +259,9 @@ export async function createOrgDomain( domainId, baseDomain, type, - verified: type === "wildcard" ? true : false + verified: type === "wildcard" ? true : false, + certResolver: certResolver || null, + preferWildcardCert: preferWildcardCert || false }) .returning(); @@ -269,9 +276,24 @@ export async function createOrgDomain( }) .returning(); + // Prepare DNS records to insert + const recordsToInsert = []; + // TODO: This needs to be cross region and not hardcoded if (type === "ns") { nsRecords = config.getRawConfig().dns.nameservers as string[]; + + // Save NS records to database + for (const nsValue of nsRecords) { + recordsToInsert.push({ + id: generateId(15), + domainId, + recordType: "NS", + baseDomain: baseDomain, + value: nsValue, + verified: false + }); + } } else if (type === "cname") { cnameRecords = [ { @@ -283,6 +305,18 @@ export async function createOrgDomain( baseDomain: `_acme-challenge.${baseDomain}` } ]; + + // Save CNAME records to database + for (const cnameRecord of cnameRecords) { + recordsToInsert.push({ + id: generateId(15), + domainId, + recordType: "CNAME", + baseDomain: cnameRecord.baseDomain, + value: cnameRecord.value, + verified: false + }); + } } else if (type === "wildcard") { aRecords = [ { @@ -294,6 +328,23 @@ export async function createOrgDomain( baseDomain: `${baseDomain}` } ]; + + // Save A records to database + for (const aRecord of aRecords) { + recordsToInsert.push({ + id: generateId(15), + domainId, + recordType: "A", + baseDomain: aRecord.baseDomain, + value: aRecord.value, + verified: true + }); + } + } + + // Insert all DNS records in batch + if (recordsToInsert.length > 0) { + await trx.insert(dnsRecords).values(recordsToInsert); } numOrgDomains = await trx @@ -325,7 +376,9 @@ export async function createOrgDomain( cnameRecords, txtRecords, nsRecords, - aRecords + aRecords, + certResolver: returned.certResolver, + preferWildcardCert: returned.preferWildcardCert }, success: true, error: false, diff --git a/server/routers/domain/getDNSRecords.ts b/server/routers/domain/getDNSRecords.ts new file mode 100644 index 00000000..c705b4fa --- /dev/null +++ b/server/routers/domain/getDNSRecords.ts @@ -0,0 +1,97 @@ +import { Request, Response, NextFunction } from "express"; +import { z } from "zod"; +import { db, dnsRecords } from "@server/db"; +import { eq } from "drizzle-orm"; +import response from "@server/lib/response"; +import HttpCode from "@server/types/HttpCode"; +import createHttpError from "http-errors"; +import logger from "@server/logger"; +import { fromError } from "zod-validation-error"; +import { OpenAPITags, registry } from "@server/openApi"; +import { getServerIp } from "@server/lib/serverIpService"; // your in-memory IP module + +const getDNSRecordsSchema = z + .object({ + domainId: z.string(), + orgId: z.string() + }) + .strict(); + +async function query(domainId: string) { + const records = await db + .select() + .from(dnsRecords) + .where(eq(dnsRecords.domainId, domainId)); + + return records; +} + +export type GetDNSRecordsResponse = Awaited>; + +registry.registerPath({ + method: "get", + path: "/org/{orgId}/domain/{domainId}/dns-records", + description: "Get all DNS records for a domain by domainId.", + tags: [OpenAPITags.Domain], + request: { + params: z.object({ + domainId: z.string(), + orgId: z.string() + }) + }, + responses: {} +}); + +export async function getDNSRecords( + req: Request, + res: Response, + next: NextFunction +): Promise { + try { + const parsedParams = getDNSRecordsSchema.safeParse(req.params); + if (!parsedParams.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedParams.error).toString() + ) + ); + } + + const { domainId } = parsedParams.data; + + const records = await query(domainId); + + if (!records || records.length === 0) { + return next( + createHttpError( + HttpCode.NOT_FOUND, + "No DNS records found for this domain" + ) + ); + } + + const serverIp = getServerIp(); + + // Override value for type A or wildcard records + const updatedRecords = records.map(record => { + if ((record.recordType === "A" || record.baseDomain === "*") && serverIp) { + return { ...record, value: serverIp }; + } + return record; + }); + + return response(res, { + data: updatedRecords, + success: true, + error: false, + message: "DNS records retrieved successfully", + status: HttpCode.OK + }); + } catch (error) { + logger.error(error); + return next( + createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred") + ); + } +} \ No newline at end of file diff --git a/server/routers/domain/getDomain.ts b/server/routers/domain/getDomain.ts new file mode 100644 index 00000000..77bd18ae --- /dev/null +++ b/server/routers/domain/getDomain.ts @@ -0,0 +1,86 @@ +import { Request, Response, NextFunction } from "express"; +import { z } from "zod"; +import { db, domains } from "@server/db"; +import { eq, and } from "drizzle-orm"; +import response from "@server/lib/response"; +import HttpCode from "@server/types/HttpCode"; +import createHttpError from "http-errors"; +import logger from "@server/logger"; +import { fromError } from "zod-validation-error"; +import { OpenAPITags, registry } from "@server/openApi"; +import { domain } from "zod/v4/core/regexes"; + +const getDomainSchema = z + .object({ + domainId: z + .string() + .optional(), + orgId: z.string().optional() + }) + .strict(); + +async function query(domainId?: string, orgId?: string) { + if (domainId) { + const [res] = await db + .select() + .from(domains) + .where(eq(domains.domainId, domainId)) + .limit(1); + return res; + } +} + +export type GetDomainResponse = NonNullable>>; + +registry.registerPath({ + method: "get", + path: "/org/{orgId}/domain/{domainId}", + description: "Get a domain by domainId.", + tags: [OpenAPITags.Domain], + request: { + params: z.object({ + domainId: z.string(), + orgId: z.string() + }) + }, + responses: {} +}); + +export async function getDomain( + req: Request, + res: Response, + next: NextFunction +): Promise { + try { + const parsedParams = getDomainSchema.safeParse(req.params); + if (!parsedParams.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedParams.error).toString() + ) + ); + } + + const { orgId, domainId } = parsedParams.data; + + const domain = await query(domainId, orgId); + + if (!domain) { + return next(createHttpError(HttpCode.NOT_FOUND, "Domain not found")); + } + + return response(res, { + data: domain, + success: true, + error: false, + message: "Domain retrieved successfully", + status: HttpCode.OK + }); + } catch (error) { + logger.error(error); + return next( + createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred") + ); + } +} diff --git a/server/routers/domain/index.ts b/server/routers/domain/index.ts index c0cafafe..e7e0b555 100644 --- a/server/routers/domain/index.ts +++ b/server/routers/domain/index.ts @@ -1,4 +1,7 @@ export * from "./listDomains"; export * from "./createOrgDomain"; export * from "./deleteOrgDomain"; -export * from "./restartOrgDomain"; \ No newline at end of file +export * from "./restartOrgDomain"; +export * from "./getDomain"; +export * from "./getDNSRecords"; +export * from "./updateDomain"; \ No newline at end of file diff --git a/server/routers/domain/listDomains.ts b/server/routers/domain/listDomains.ts index fe51cde6..55ea99cb 100644 --- a/server/routers/domain/listDomains.ts +++ b/server/routers/domain/listDomains.ts @@ -42,7 +42,9 @@ async function queryDomains(orgId: string, limit: number, offset: number) { type: domains.type, failed: domains.failed, tries: domains.tries, - configManaged: domains.configManaged + configManaged: domains.configManaged, + certResolver: domains.certResolver, + preferWildcardCert: domains.preferWildcardCert }) .from(orgDomains) .where(eq(orgDomains.orgId, orgId)) diff --git a/server/routers/domain/updateDomain.ts b/server/routers/domain/updateDomain.ts new file mode 100644 index 00000000..c684466e --- /dev/null +++ b/server/routers/domain/updateDomain.ts @@ -0,0 +1,161 @@ +import { Request, Response, NextFunction } from "express"; +import { z } from "zod"; +import { db, domains, orgDomains } from "@server/db"; +import response from "@server/lib/response"; +import HttpCode from "@server/types/HttpCode"; +import createHttpError from "http-errors"; +import logger from "@server/logger"; +import { fromError } from "zod-validation-error"; +import { eq, and } from "drizzle-orm"; +import { OpenAPITags, registry } from "@server/openApi"; + +const paramsSchema = z + .object({ + orgId: z.string(), + domainId: z.string() + }) + .strict(); + +const bodySchema = z + .object({ + certResolver: z.string().optional().nullable(), + preferWildcardCert: z.boolean().optional().nullable() + }) + .strict(); + +export type UpdateDomainResponse = { + domainId: string; + certResolver: string | null; + preferWildcardCert: boolean | null; +}; + + +registry.registerPath({ + method: "patch", + path: "/org/{orgId}/domain/{domainId}", + description: "Update a domain by domainId.", + tags: [OpenAPITags.Domain], + request: { + params: z.object({ + domainId: z.string(), + orgId: z.string() + }) + }, + responses: {} +}); + +export async function updateOrgDomain( + req: Request, + res: Response, + next: NextFunction +): Promise { + try { + const parsedParams = paramsSchema.safeParse(req.params); + if (!parsedParams.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedParams.error).toString() + ) + ); + } + + const parsedBody = bodySchema.safeParse(req.body); + if (!parsedBody.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedBody.error).toString() + ) + ); + } + + const { orgId, domainId } = parsedParams.data; + const { certResolver, preferWildcardCert } = parsedBody.data; + + const [orgDomain] = await db + .select() + .from(orgDomains) + .where( + and( + eq(orgDomains.orgId, orgId), + eq(orgDomains.domainId, domainId) + ) + ); + + if (!orgDomain) { + return next( + createHttpError( + HttpCode.NOT_FOUND, + "Domain not found or does not belong to this organization" + ) + ); + } + + + const [existingDomain] = await db + .select() + .from(domains) + .where(eq(domains.domainId, domainId)); + + if (!existingDomain) { + return next( + createHttpError(HttpCode.NOT_FOUND, "Domain not found") + ); + } + + if (existingDomain.type !== "wildcard") { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + "Domain settings can only be updated for wildcard domains" + ) + ); + } + + const updateData: Partial<{ + certResolver: string | null; + preferWildcardCert: boolean; + }> = {}; + + if (certResolver !== undefined) { + updateData.certResolver = certResolver; + } + + if (preferWildcardCert !== undefined && preferWildcardCert !== null) { + updateData.preferWildcardCert = preferWildcardCert; + } + + const [updatedDomain] = await db + .update(domains) + .set(updateData) + .where(eq(domains.domainId, domainId)) + .returning(); + + if (!updatedDomain) { + return next( + createHttpError( + HttpCode.INTERNAL_SERVER_ERROR, + "Failed to update domain" + ) + ); + } + + return response(res, { + data: { + domainId: updatedDomain.domainId, + certResolver: updatedDomain.certResolver, + preferWildcardCert: updatedDomain.preferWildcardCert + }, + success: true, + error: false, + message: "Domain updated successfully", + status: HttpCode.OK + }); + } catch (error) { + logger.error(error); + return next( + createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred") + ); + } +} \ No newline at end of file diff --git a/server/routers/external.ts b/server/routers/external.ts index 74ad3b31..d2258652 100644 --- a/server/routers/external.ts +++ b/server/routers/external.ts @@ -14,6 +14,7 @@ import * as supporterKey from "./supporterKey"; import * as accessToken from "./accessToken"; import * as idp from "./idp"; import * as apiKeys from "./apiKeys"; +import * as logs from "./auditLogs"; import HttpCode from "@server/types/HttpCode"; import { verifyAccessTokenAccess, @@ -44,6 +45,8 @@ import rateLimit, { ipKeyGenerator } from "express-rate-limit"; import createHttpError from "http-errors"; import { build } from "@server/build"; import { createStore } from "#dynamic/lib/rateLimitStore"; +import { logActionAudit } from "#dynamic/middlewares"; +import { log } from "console"; // Root routes export const unauthenticated = Router(); @@ -75,7 +78,8 @@ authenticated.post( "/org/:orgId", verifyOrgAccess, verifyUserHasAction(ActionsEnum.updateOrg), - org.updateOrg + logActionAudit(ActionsEnum.updateOrg), + org.updateOrg, ); if (build !== "saas") { @@ -84,7 +88,8 @@ if (build !== "saas") { verifyOrgAccess, verifyUserIsOrgOwner, verifyUserHasAction(ActionsEnum.deleteOrg), - org.deleteOrg + logActionAudit(ActionsEnum.deleteOrg), + org.deleteOrg, ); } @@ -92,6 +97,7 @@ authenticated.put( "/org/:orgId/site", verifyOrgAccess, verifyUserHasAction(ActionsEnum.createSite), + logActionAudit(ActionsEnum.createSite), site.createSite ); authenticated.get( @@ -149,7 +155,8 @@ authenticated.put( verifyClientsEnabled, verifyOrgAccess, verifyUserHasAction(ActionsEnum.createClient), - client.createClient + logActionAudit(ActionsEnum.createClient), + client.createClient, ); authenticated.delete( @@ -157,7 +164,8 @@ authenticated.delete( verifyClientsEnabled, verifyClientAccess, verifyUserHasAction(ActionsEnum.deleteClient), - client.deleteClient + logActionAudit(ActionsEnum.deleteClient), + client.deleteClient, ); authenticated.post( @@ -165,7 +173,8 @@ authenticated.post( verifyClientsEnabled, verifyClientAccess, // this will check if the user has access to the client verifyUserHasAction(ActionsEnum.updateClient), // this will check if the user has permission to update the client - client.updateClient + logActionAudit(ActionsEnum.updateClient), + client.updateClient, ); // authenticated.get( @@ -178,15 +187,18 @@ authenticated.post( "/site/:siteId", verifySiteAccess, verifyUserHasAction(ActionsEnum.updateSite), - site.updateSite + logActionAudit(ActionsEnum.updateSite), + site.updateSite, ); authenticated.delete( "/site/:siteId", verifySiteAccess, verifyUserHasAction(ActionsEnum.deleteSite), - site.deleteSite + logActionAudit(ActionsEnum.deleteSite), + site.deleteSite, ); +// TODO: BREAK OUT THESE ACTIONS SO THEY ARE NOT ALL "getSite" authenticated.get( "/site/:siteId/docker/status", verifySiteAccess, @@ -203,13 +215,13 @@ authenticated.post( "/site/:siteId/docker/check", verifySiteAccess, verifyUserHasAction(ActionsEnum.getSite), - site.checkDockerSocket + site.checkDockerSocket, ); authenticated.post( "/site/:siteId/docker/trigger", verifySiteAccess, verifyUserHasAction(ActionsEnum.getSite), - site.triggerFetchContainers + site.triggerFetchContainers, ); authenticated.get( "/site/:siteId/docker/containers", @@ -224,7 +236,8 @@ authenticated.put( verifyOrgAccess, verifySiteAccess, verifyUserHasAction(ActionsEnum.createSiteResource), - siteResource.createSiteResource + logActionAudit(ActionsEnum.createSiteResource), + siteResource.createSiteResource, ); authenticated.get( @@ -257,7 +270,8 @@ authenticated.post( verifySiteAccess, verifySiteResourceAccess, verifyUserHasAction(ActionsEnum.updateSiteResource), - siteResource.updateSiteResource + logActionAudit(ActionsEnum.updateSiteResource), + siteResource.updateSiteResource, ); authenticated.delete( @@ -266,14 +280,16 @@ authenticated.delete( verifySiteAccess, verifySiteResourceAccess, verifyUserHasAction(ActionsEnum.deleteSiteResource), - siteResource.deleteSiteResource + logActionAudit(ActionsEnum.deleteSiteResource), + siteResource.deleteSiteResource, ); authenticated.put( "/org/:orgId/resource", verifyOrgAccess, verifyUserHasAction(ActionsEnum.createResource), - resource.createResource + logActionAudit(ActionsEnum.createResource), + resource.createResource, ); authenticated.get( @@ -302,6 +318,27 @@ authenticated.get( domain.listDomains ); +authenticated.get( + "/org/:orgId/domain/:domainId", + verifyOrgAccess, + verifyUserHasAction(ActionsEnum.getDomain), + domain.getDomain +); + +authenticated.patch( + "/org/:orgId/domain/:domainId", + verifyOrgAccess, + verifyUserHasAction(ActionsEnum.updateOrgDomain), + domain.updateOrgDomain +); + +authenticated.get( + "/org/:orgId/domain/:domainId/dns-records", + verifyOrgAccess, + verifyUserHasAction(ActionsEnum.getDNSRecords), + domain.getDNSRecords +); + authenticated.get( "/org/:orgId/invitations", verifyOrgAccess, @@ -313,15 +350,18 @@ authenticated.delete( "/org/:orgId/invitations/:inviteId", verifyOrgAccess, verifyUserHasAction(ActionsEnum.removeInvitation), - user.removeInvitation + logActionAudit(ActionsEnum.removeInvitation), + user.removeInvitation, ); authenticated.post( "/org/:orgId/create-invite", verifyOrgAccess, verifyUserHasAction(ActionsEnum.inviteUser), - user.inviteUser + logActionAudit(ActionsEnum.inviteUser), + user.inviteUser, ); // maybe make this /invite/create instead + unauthenticated.post("/invite/accept", user.acceptInvite); // this is supposed to be unauthenticated authenticated.get( @@ -354,20 +394,23 @@ authenticated.post( "/resource/:resourceId", verifyResourceAccess, verifyUserHasAction(ActionsEnum.updateResource), - resource.updateResource + logActionAudit(ActionsEnum.updateResource), + resource.updateResource, ); authenticated.delete( "/resource/:resourceId", verifyResourceAccess, verifyUserHasAction(ActionsEnum.deleteResource), - resource.deleteResource + logActionAudit(ActionsEnum.deleteResource), + resource.deleteResource, ); authenticated.put( "/resource/:resourceId/target", verifyResourceAccess, verifyUserHasAction(ActionsEnum.createTarget), - target.createTarget + logActionAudit(ActionsEnum.createTarget), + target.createTarget, ); authenticated.get( "/resource/:resourceId/targets", @@ -380,7 +423,8 @@ authenticated.put( "/resource/:resourceId/rule", verifyResourceAccess, verifyUserHasAction(ActionsEnum.createResourceRule), - resource.createResourceRule + logActionAudit(ActionsEnum.createResourceRule), + resource.createResourceRule, ); authenticated.get( "/resource/:resourceId/rules", @@ -392,13 +436,15 @@ authenticated.post( "/resource/:resourceId/rule/:ruleId", verifyResourceAccess, verifyUserHasAction(ActionsEnum.updateResourceRule), - resource.updateResourceRule + logActionAudit(ActionsEnum.updateResourceRule), + resource.updateResourceRule, ); authenticated.delete( "/resource/:resourceId/rule/:ruleId", verifyResourceAccess, verifyUserHasAction(ActionsEnum.deleteResourceRule), - resource.deleteResourceRule + logActionAudit(ActionsEnum.deleteResourceRule), + resource.deleteResourceRule, ); authenticated.get( @@ -411,20 +457,23 @@ authenticated.post( "/target/:targetId", verifyTargetAccess, verifyUserHasAction(ActionsEnum.updateTarget), - target.updateTarget + logActionAudit(ActionsEnum.updateTarget), + target.updateTarget, ); authenticated.delete( "/target/:targetId", verifyTargetAccess, verifyUserHasAction(ActionsEnum.deleteTarget), - target.deleteTarget + logActionAudit(ActionsEnum.deleteTarget), + target.deleteTarget, ); authenticated.put( "/org/:orgId/role", verifyOrgAccess, verifyUserHasAction(ActionsEnum.createRole), - role.createRole + logActionAudit(ActionsEnum.createRole), + role.createRole, ); authenticated.get( "/org/:orgId/roles", @@ -449,14 +498,16 @@ authenticated.delete( "/role/:roleId", verifyRoleAccess, verifyUserHasAction(ActionsEnum.deleteRole), - role.deleteRole + logActionAudit(ActionsEnum.deleteRole), + role.deleteRole, ); authenticated.post( "/role/:roleId/add/:userId", verifyRoleAccess, verifyUserAccess, verifyUserHasAction(ActionsEnum.addUserRole), - user.addUserRole + logActionAudit(ActionsEnum.addUserRole), + user.addUserRole, ); authenticated.post( @@ -464,7 +515,8 @@ authenticated.post( verifyResourceAccess, verifyRoleAccess, verifyUserHasAction(ActionsEnum.setResourceRoles), - resource.setResourceRoles + logActionAudit(ActionsEnum.setResourceRoles), + resource.setResourceRoles, ); authenticated.post( @@ -472,35 +524,40 @@ authenticated.post( verifyResourceAccess, verifySetResourceUsers, verifyUserHasAction(ActionsEnum.setResourceUsers), - resource.setResourceUsers + logActionAudit(ActionsEnum.setResourceUsers), + resource.setResourceUsers, ); authenticated.post( `/resource/:resourceId/password`, verifyResourceAccess, verifyUserHasAction(ActionsEnum.setResourcePassword), - resource.setResourcePassword + logActionAudit(ActionsEnum.setResourcePassword), + resource.setResourcePassword, ); authenticated.post( `/resource/:resourceId/pincode`, verifyResourceAccess, verifyUserHasAction(ActionsEnum.setResourcePincode), - resource.setResourcePincode + logActionAudit(ActionsEnum.setResourcePincode), + resource.setResourcePincode, ); authenticated.post( `/resource/:resourceId/header-auth`, verifyResourceAccess, verifyUserHasAction(ActionsEnum.setResourceHeaderAuth), - resource.setResourceHeaderAuth + logActionAudit(ActionsEnum.setResourceHeaderAuth), + resource.setResourceHeaderAuth, ); authenticated.post( `/resource/:resourceId/whitelist`, verifyResourceAccess, verifyUserHasAction(ActionsEnum.setResourceWhitelist), - resource.setResourceWhitelist + logActionAudit(ActionsEnum.setResourceWhitelist), + resource.setResourceWhitelist, ); authenticated.get( @@ -514,14 +571,16 @@ authenticated.post( `/resource/:resourceId/access-token`, verifyResourceAccess, verifyUserHasAction(ActionsEnum.generateAccessToken), - accessToken.generateAccessToken + logActionAudit(ActionsEnum.generateAccessToken), + accessToken.generateAccessToken, ); authenticated.delete( `/access-token/:accessTokenId`, verifyAccessTokenAccess, verifyUserHasAction(ActionsEnum.deleteAcessToken), - accessToken.deleteAccessToken + logActionAudit(ActionsEnum.deleteAcessToken), + accessToken.deleteAccessToken, ); authenticated.get( @@ -594,7 +653,8 @@ authenticated.put( "/org/:orgId/user", verifyOrgAccess, verifyUserHasAction(ActionsEnum.createOrgUser), - user.createOrgUser + logActionAudit(ActionsEnum.createOrgUser), + user.createOrgUser, ); authenticated.post( @@ -602,7 +662,8 @@ authenticated.post( verifyOrgAccess, verifyUserAccess, verifyUserHasAction(ActionsEnum.updateOrgUser), - user.updateOrgUser + logActionAudit(ActionsEnum.updateOrgUser), + user.updateOrgUser, ); authenticated.get("/org/:orgId/user/:userId", verifyOrgAccess, user.getOrgUser); @@ -625,7 +686,8 @@ authenticated.delete( verifyOrgAccess, verifyUserAccess, verifyUserHasAction(ActionsEnum.removeUser), - user.removeUserOrg + logActionAudit(ActionsEnum.removeUser), + user.removeUserOrg, ); // authenticated.put( @@ -755,7 +817,8 @@ authenticated.post( verifyOrgAccess, verifyApiKeyAccess, verifyUserHasAction(ActionsEnum.setApiKeyActions), - apiKeys.setApiKeyActions + logActionAudit(ActionsEnum.setApiKeyActions), + apiKeys.setApiKeyActions, ); authenticated.get( @@ -770,7 +833,8 @@ authenticated.put( `/org/:orgId/api-key`, verifyOrgAccess, verifyUserHasAction(ActionsEnum.createApiKey), - apiKeys.createOrgApiKey + logActionAudit(ActionsEnum.createApiKey), + apiKeys.createOrgApiKey, ); authenticated.delete( @@ -778,7 +842,8 @@ authenticated.delete( verifyOrgAccess, verifyApiKeyAccess, verifyUserHasAction(ActionsEnum.deleteApiKey), - apiKeys.deleteOrgApiKey + logActionAudit(ActionsEnum.deleteApiKey), + apiKeys.deleteOrgApiKey, ); authenticated.get( @@ -793,7 +858,8 @@ authenticated.put( `/org/:orgId/domain`, verifyOrgAccess, verifyUserHasAction(ActionsEnum.createOrgDomain), - domain.createOrgDomain + logActionAudit(ActionsEnum.createOrgDomain), + domain.createOrgDomain, ); authenticated.post( @@ -801,7 +867,8 @@ authenticated.post( verifyOrgAccess, verifyDomainAccess, verifyUserHasAction(ActionsEnum.restartOrgDomain), - domain.restartOrgDomain + logActionAudit(ActionsEnum.restartOrgDomain), + domain.restartOrgDomain, ); authenticated.delete( @@ -809,7 +876,23 @@ authenticated.delete( verifyOrgAccess, verifyDomainAccess, verifyUserHasAction(ActionsEnum.deleteOrgDomain), - domain.deleteAccountDomain + logActionAudit(ActionsEnum.deleteOrgDomain), + domain.deleteAccountDomain, +); + +authenticated.get( + "/org/:orgId/logs/request", + verifyOrgAccess, + verifyUserHasAction(ActionsEnum.viewLogs), + logs.queryRequestAuditLogs +); + +authenticated.get( + "/org/:orgId/logs/request/export", + verifyOrgAccess, + verifyUserHasAction(ActionsEnum.exportLogs), + logActionAudit(ActionsEnum.exportLogs), + logs.exportRequestAuditLogs ); // Auth routes @@ -1132,4 +1215,4 @@ authRouter.delete( store: createStore() }), auth.deleteSecurityKey -); +); \ No newline at end of file diff --git a/server/routers/integration.ts b/server/routers/integration.ts index 03b72ddd..9cd2d3ca 100644 --- a/server/routers/integration.ts +++ b/server/routers/integration.ts @@ -29,7 +29,7 @@ import { import HttpCode from "@server/types/HttpCode"; import { Router } from "express"; import { ActionsEnum } from "@server/auth/actions"; -import { build } from "@server/build"; +import { logActionAudit } from "#dynamic/middlewares"; export const unauthenticated = Router(); @@ -51,7 +51,8 @@ authenticated.put( "/org", verifyApiKeyIsRoot, verifyApiKeyHasAction(ActionsEnum.createOrg), - org.createOrg + logActionAudit(ActionsEnum.createOrg), + org.createOrg, ); authenticated.get( @@ -72,21 +73,24 @@ authenticated.post( "/org/:orgId", verifyApiKeyOrgAccess, verifyApiKeyHasAction(ActionsEnum.updateOrg), - org.updateOrg + logActionAudit(ActionsEnum.updateOrg), + org.updateOrg, ); authenticated.delete( "/org/:orgId", verifyApiKeyIsRoot, verifyApiKeyHasAction(ActionsEnum.deleteOrg), - org.deleteOrg + logActionAudit(ActionsEnum.deleteOrg), + org.deleteOrg, ); authenticated.put( "/org/:orgId/site", verifyApiKeyOrgAccess, verifyApiKeyHasAction(ActionsEnum.createSite), - site.createSite + logActionAudit(ActionsEnum.createSite), + site.createSite, ); authenticated.get( @@ -121,14 +125,16 @@ authenticated.post( "/site/:siteId", verifyApiKeySiteAccess, verifyApiKeyHasAction(ActionsEnum.updateSite), - site.updateSite + logActionAudit(ActionsEnum.updateSite), + site.updateSite, ); authenticated.delete( "/site/:siteId", verifyApiKeySiteAccess, verifyApiKeyHasAction(ActionsEnum.deleteSite), - site.deleteSite + logActionAudit(ActionsEnum.deleteSite), + site.deleteSite, ); authenticated.get( @@ -142,7 +148,8 @@ authenticated.put( verifyApiKeyOrgAccess, verifyApiKeySiteAccess, verifyApiKeyHasAction(ActionsEnum.createSiteResource), - siteResource.createSiteResource + logActionAudit(ActionsEnum.createSiteResource), + siteResource.createSiteResource, ); authenticated.get( @@ -175,7 +182,8 @@ authenticated.post( verifyApiKeySiteAccess, verifyApiKeySiteResourceAccess, verifyApiKeyHasAction(ActionsEnum.updateSiteResource), - siteResource.updateSiteResource + logActionAudit(ActionsEnum.updateSiteResource), + siteResource.updateSiteResource, ); authenticated.delete( @@ -184,21 +192,24 @@ authenticated.delete( verifyApiKeySiteAccess, verifyApiKeySiteResourceAccess, verifyApiKeyHasAction(ActionsEnum.deleteSiteResource), - siteResource.deleteSiteResource + logActionAudit(ActionsEnum.deleteSiteResource), + siteResource.deleteSiteResource, ); authenticated.put( "/org/:orgId/resource", verifyApiKeyOrgAccess, verifyApiKeyHasAction(ActionsEnum.createResource), - resource.createResource + logActionAudit(ActionsEnum.createResource), + resource.createResource, ); authenticated.put( "/org/:orgId/site/:siteId/resource", verifyApiKeyOrgAccess, verifyApiKeyHasAction(ActionsEnum.createResource), - resource.createResource + logActionAudit(ActionsEnum.createResource), + resource.createResource, ); authenticated.get( @@ -233,7 +244,8 @@ authenticated.post( "/org/:orgId/create-invite", verifyApiKeyOrgAccess, verifyApiKeyHasAction(ActionsEnum.inviteUser), - user.inviteUser + logActionAudit(ActionsEnum.inviteUser), + user.inviteUser, ); authenticated.get( @@ -261,21 +273,24 @@ authenticated.post( "/resource/:resourceId", verifyApiKeyResourceAccess, verifyApiKeyHasAction(ActionsEnum.updateResource), - resource.updateResource + logActionAudit(ActionsEnum.updateResource), + resource.updateResource, ); authenticated.delete( "/resource/:resourceId", verifyApiKeyResourceAccess, verifyApiKeyHasAction(ActionsEnum.deleteResource), - resource.deleteResource + logActionAudit(ActionsEnum.deleteResource), + resource.deleteResource, ); authenticated.put( "/resource/:resourceId/target", verifyApiKeyResourceAccess, verifyApiKeyHasAction(ActionsEnum.createTarget), - target.createTarget + logActionAudit(ActionsEnum.createTarget), + target.createTarget, ); authenticated.get( @@ -289,7 +304,8 @@ authenticated.put( "/resource/:resourceId/rule", verifyApiKeyResourceAccess, verifyApiKeyHasAction(ActionsEnum.createResourceRule), - resource.createResourceRule + logActionAudit(ActionsEnum.createResourceRule), + resource.createResourceRule, ); authenticated.get( @@ -303,14 +319,16 @@ authenticated.post( "/resource/:resourceId/rule/:ruleId", verifyApiKeyResourceAccess, verifyApiKeyHasAction(ActionsEnum.updateResourceRule), - resource.updateResourceRule + logActionAudit(ActionsEnum.updateResourceRule), + resource.updateResourceRule, ); authenticated.delete( "/resource/:resourceId/rule/:ruleId", verifyApiKeyResourceAccess, verifyApiKeyHasAction(ActionsEnum.deleteResourceRule), - resource.deleteResourceRule + logActionAudit(ActionsEnum.deleteResourceRule), + resource.deleteResourceRule, ); authenticated.get( @@ -324,21 +342,24 @@ authenticated.post( "/target/:targetId", verifyApiKeyTargetAccess, verifyApiKeyHasAction(ActionsEnum.updateTarget), - target.updateTarget + logActionAudit(ActionsEnum.updateTarget), + target.updateTarget, ); authenticated.delete( "/target/:targetId", verifyApiKeyTargetAccess, verifyApiKeyHasAction(ActionsEnum.deleteTarget), - target.deleteTarget + logActionAudit(ActionsEnum.deleteTarget), + target.deleteTarget, ); authenticated.put( "/org/:orgId/role", verifyApiKeyOrgAccess, verifyApiKeyHasAction(ActionsEnum.createRole), - role.createRole + logActionAudit(ActionsEnum.createRole), + role.createRole, ); authenticated.get( @@ -352,7 +373,8 @@ authenticated.delete( "/role/:roleId", verifyApiKeyRoleAccess, verifyApiKeyHasAction(ActionsEnum.deleteRole), - role.deleteRole + logActionAudit(ActionsEnum.deleteRole), + role.deleteRole, ); authenticated.get( @@ -367,7 +389,8 @@ authenticated.post( verifyApiKeyRoleAccess, verifyApiKeyUserAccess, verifyApiKeyHasAction(ActionsEnum.addUserRole), - user.addUserRole + logActionAudit(ActionsEnum.addUserRole), + user.addUserRole, ); authenticated.post( @@ -375,7 +398,8 @@ authenticated.post( verifyApiKeyResourceAccess, verifyApiKeyRoleAccess, verifyApiKeyHasAction(ActionsEnum.setResourceRoles), - resource.setResourceRoles + logActionAudit(ActionsEnum.setResourceRoles), + resource.setResourceRoles, ); authenticated.post( @@ -383,35 +407,40 @@ authenticated.post( verifyApiKeyResourceAccess, verifyApiKeySetResourceUsers, verifyApiKeyHasAction(ActionsEnum.setResourceUsers), - resource.setResourceUsers + logActionAudit(ActionsEnum.setResourceUsers), + resource.setResourceUsers, ); authenticated.post( `/resource/:resourceId/password`, verifyApiKeyResourceAccess, verifyApiKeyHasAction(ActionsEnum.setResourcePassword), - resource.setResourcePassword + logActionAudit(ActionsEnum.setResourcePassword), + resource.setResourcePassword, ); authenticated.post( `/resource/:resourceId/pincode`, verifyApiKeyResourceAccess, verifyApiKeyHasAction(ActionsEnum.setResourcePincode), - resource.setResourcePincode + logActionAudit(ActionsEnum.setResourcePincode), + resource.setResourcePincode, ); authenticated.post( `/resource/:resourceId/header-auth`, verifyApiKeyResourceAccess, verifyApiKeyHasAction(ActionsEnum.setResourceHeaderAuth), - resource.setResourceHeaderAuth + logActionAudit(ActionsEnum.setResourceHeaderAuth), + resource.setResourceHeaderAuth, ); authenticated.post( `/resource/:resourceId/whitelist`, verifyApiKeyResourceAccess, verifyApiKeyHasAction(ActionsEnum.setResourceWhitelist), - resource.setResourceWhitelist + logActionAudit(ActionsEnum.setResourceWhitelist), + resource.setResourceWhitelist, ); authenticated.post( @@ -439,14 +468,16 @@ authenticated.post( `/resource/:resourceId/access-token`, verifyApiKeyResourceAccess, verifyApiKeyHasAction(ActionsEnum.generateAccessToken), - accessToken.generateAccessToken + logActionAudit(ActionsEnum.generateAccessToken), + accessToken.generateAccessToken, ); authenticated.delete( `/access-token/:accessTokenId`, verifyApiKeyAccessTokenAccess, verifyApiKeyHasAction(ActionsEnum.deleteAcessToken), - accessToken.deleteAccessToken + logActionAudit(ActionsEnum.deleteAcessToken), + accessToken.deleteAccessToken, ); authenticated.get( @@ -474,7 +505,8 @@ authenticated.post( "/user/:userId/2fa", verifyApiKeyIsRoot, verifyApiKeyHasAction(ActionsEnum.updateUser), - user.updateUser2FA + logActionAudit(ActionsEnum.updateUser), + user.updateUser2FA, ); authenticated.get( @@ -495,7 +527,8 @@ authenticated.put( "/org/:orgId/user", verifyApiKeyOrgAccess, verifyApiKeyHasAction(ActionsEnum.createOrgUser), - user.createOrgUser + logActionAudit(ActionsEnum.createOrgUser), + user.createOrgUser, ); authenticated.post( @@ -503,7 +536,8 @@ authenticated.post( verifyApiKeyOrgAccess, verifyApiKeyUserAccess, verifyApiKeyHasAction(ActionsEnum.updateOrgUser), - user.updateOrgUser + logActionAudit(ActionsEnum.updateOrgUser), + user.updateOrgUser, ); authenticated.delete( @@ -511,7 +545,8 @@ authenticated.delete( verifyApiKeyOrgAccess, verifyApiKeyUserAccess, verifyApiKeyHasAction(ActionsEnum.removeUser), - user.removeUserOrg + logActionAudit(ActionsEnum.removeUser), + user.removeUserOrg, ); // authenticated.put( @@ -531,7 +566,8 @@ authenticated.post( `/org/:orgId/api-key/:apiKeyId/actions`, verifyApiKeyIsRoot, verifyApiKeyHasAction(ActionsEnum.setApiKeyActions), - apiKeys.setApiKeyActions + logActionAudit(ActionsEnum.setApiKeyActions), + apiKeys.setApiKeyActions, ); authenticated.get( @@ -545,28 +581,32 @@ authenticated.put( `/org/:orgId/api-key`, verifyApiKeyIsRoot, verifyApiKeyHasAction(ActionsEnum.createApiKey), - apiKeys.createOrgApiKey + logActionAudit(ActionsEnum.createApiKey), + apiKeys.createOrgApiKey, ); authenticated.delete( `/org/:orgId/api-key/:apiKeyId`, verifyApiKeyIsRoot, verifyApiKeyHasAction(ActionsEnum.deleteApiKey), - apiKeys.deleteApiKey + logActionAudit(ActionsEnum.deleteApiKey), + apiKeys.deleteApiKey, ); authenticated.put( "/idp/oidc", verifyApiKeyIsRoot, verifyApiKeyHasAction(ActionsEnum.createIdp), - idp.createOidcIdp + logActionAudit(ActionsEnum.createIdp), + idp.createOidcIdp, ); authenticated.post( "/idp/:idpId/oidc", verifyApiKeyIsRoot, verifyApiKeyHasAction(ActionsEnum.updateIdp), - idp.updateOidcIdp + logActionAudit(ActionsEnum.updateIdp), + idp.updateOidcIdp, ); authenticated.get( @@ -587,21 +627,24 @@ authenticated.put( "/idp/:idpId/org/:orgId", verifyApiKeyIsRoot, verifyApiKeyHasAction(ActionsEnum.createIdpOrg), - idp.createIdpOrgPolicy + logActionAudit(ActionsEnum.createIdpOrg), + idp.createIdpOrgPolicy, ); authenticated.post( "/idp/:idpId/org/:orgId", verifyApiKeyIsRoot, verifyApiKeyHasAction(ActionsEnum.updateIdpOrg), - idp.updateIdpOrgPolicy + logActionAudit(ActionsEnum.updateIdpOrg), + idp.updateIdpOrgPolicy, ); authenticated.delete( "/idp/:idpId/org/:orgId", verifyApiKeyIsRoot, verifyApiKeyHasAction(ActionsEnum.deleteIdpOrg), - idp.deleteIdpOrgPolicy + logActionAudit(ActionsEnum.deleteIdpOrg), + idp.deleteIdpOrgPolicy, ); authenticated.get( @@ -640,7 +683,8 @@ authenticated.put( verifyClientsEnabled, verifyApiKeyOrgAccess, verifyApiKeyHasAction(ActionsEnum.createClient), - client.createClient + logActionAudit(ActionsEnum.createClient), + client.createClient, ); authenticated.delete( @@ -648,7 +692,8 @@ authenticated.delete( verifyClientsEnabled, verifyApiKeyClientAccess, verifyApiKeyHasAction(ActionsEnum.deleteClient), - client.deleteClient + logActionAudit(ActionsEnum.deleteClient), + client.deleteClient, ); authenticated.post( @@ -656,12 +701,14 @@ authenticated.post( verifyClientsEnabled, verifyApiKeyClientAccess, verifyApiKeyHasAction(ActionsEnum.updateClient), - client.updateClient + logActionAudit(ActionsEnum.updateClient), + client.updateClient, ); authenticated.put( "/org/:orgId/blueprint", verifyApiKeyOrgAccess, verifyApiKeyHasAction(ActionsEnum.applyBlueprint), - org.applyBlueprint + logActionAudit(ActionsEnum.applyBlueprint), + org.applyBlueprint, ); diff --git a/server/routers/newt/dockerSocket.ts b/server/routers/newt/dockerSocket.ts index 3847d9a4..41f36d3a 100644 --- a/server/routers/newt/dockerSocket.ts +++ b/server/routers/newt/dockerSocket.ts @@ -1,10 +1,5 @@ -import NodeCache from "node-cache"; import { sendToClient } from "#dynamic/routers/ws"; -export const dockerSocketCache = new NodeCache({ - stdTTL: 3600 // seconds -}); - export function fetchContainers(newtId: string) { const payload = { type: `newt/socket/fetch`, diff --git a/server/routers/newt/handleSocketMessages.ts b/server/routers/newt/handleSocketMessages.ts index 0491393f..09a473b9 100644 --- a/server/routers/newt/handleSocketMessages.ts +++ b/server/routers/newt/handleSocketMessages.ts @@ -1,8 +1,8 @@ import { MessageHandler } from "@server/routers/ws"; import logger from "@server/logger"; -import { dockerSocketCache } from "./dockerSocket"; import { Newt } from "@server/db"; import { applyNewtDockerBlueprint } from "@server/lib/blueprints/applyNewtDockerBlueprint"; +import cache from "@server/lib/cache"; export const handleDockerStatusMessage: MessageHandler = async (context) => { const { message, client, sendToClient } = context; @@ -24,8 +24,8 @@ export const handleDockerStatusMessage: MessageHandler = async (context) => { if (available) { logger.info(`Newt ${newt.newtId} has Docker socket access`); - dockerSocketCache.set(`${newt.newtId}:socketPath`, socketPath, 0); - dockerSocketCache.set(`${newt.newtId}:isAvailable`, available, 0); + cache.set(`${newt.newtId}:socketPath`, socketPath, 0); + cache.set(`${newt.newtId}:isAvailable`, available, 0); } else { logger.warn(`Newt ${newt.newtId} does not have Docker socket access`); } @@ -54,7 +54,7 @@ export const handleDockerContainersMessage: MessageHandler = async ( ); if (containers && containers.length > 0) { - dockerSocketCache.set(`${newt.newtId}:dockerContainers`, containers, 0); + cache.set(`${newt.newtId}:dockerContainers`, containers, 0); } else { logger.warn(`Newt ${newt.newtId} does not have Docker containers`); } diff --git a/server/routers/org/getOrg.ts b/server/routers/org/getOrg.ts index 35c1a5f7..2497f9a6 100644 --- a/server/routers/org/getOrg.ts +++ b/server/routers/org/getOrg.ts @@ -49,13 +49,13 @@ export async function getOrg( const { orgId } = parsedParams.data; - const org = await db + const [org] = await db .select() .from(orgs) .where(eq(orgs.orgId, orgId)) .limit(1); - if (org.length === 0) { + if (!org) { return next( createHttpError( HttpCode.NOT_FOUND, @@ -66,7 +66,7 @@ export async function getOrg( return response(res, { data: { - org: org[0] + org }, success: true, error: false, diff --git a/server/routers/org/updateOrg.ts b/server/routers/org/updateOrg.ts index ea7e3eeb..a2690bcd 100644 --- a/server/routers/org/updateOrg.ts +++ b/server/routers/org/updateOrg.ts @@ -26,7 +26,19 @@ const updateOrgBodySchema = z name: z.string().min(1).max(255).optional(), requireTwoFactor: z.boolean().optional(), maxSessionLengthHours: z.number().nullable().optional(), - passwordExpiryDays: z.number().nullable().optional() + passwordExpiryDays: z.number().nullable().optional(), + settingsLogRetentionDaysRequest: z + .number() + .min(build === "saas" ? 0 : -1) + .optional(), + settingsLogRetentionDaysAccess: z + .number() + .min(build === "saas" ? 0 : -1) + .optional(), + settingsLogRetentionDaysAction: z + .number() + .min(build === "saas" ? 0 : -1) + .optional() }) .strict() .refine((data) => Object.keys(data).length > 0, { @@ -86,13 +98,32 @@ export async function updateOrg( parsedBody.data.passwordExpiryDays = undefined; } + if ( + !isLicensed && + parsedBody.data.settingsLogRetentionDaysRequest && + parsedBody.data.settingsLogRetentionDaysRequest > 30 + ) { + return next( + createHttpError( + HttpCode.FORBIDDEN, + "You are not allowed to set log retention days greater than 30 because you are not subscribed to the Standard tier" + ) + ); + } + const updatedOrg = await db .update(orgs) .set({ name: parsedBody.data.name, requireTwoFactor: parsedBody.data.requireTwoFactor, maxSessionLengthHours: parsedBody.data.maxSessionLengthHours, - passwordExpiryDays: parsedBody.data.passwordExpiryDays + passwordExpiryDays: parsedBody.data.passwordExpiryDays, + settingsLogRetentionDaysRequest: + parsedBody.data.settingsLogRetentionDaysRequest, + settingsLogRetentionDaysAccess: + parsedBody.data.settingsLogRetentionDaysAccess, + settingsLogRetentionDaysAction: + parsedBody.data.settingsLogRetentionDaysAction }) .where(eq(orgs.orgId, orgId)) .returning(); diff --git a/server/routers/resource/authWithAccessToken.ts b/server/routers/resource/authWithAccessToken.ts index 2d7fdf93..04317a73 100644 --- a/server/routers/resource/authWithAccessToken.ts +++ b/server/routers/resource/authWithAccessToken.ts @@ -10,11 +10,10 @@ import { z } from "zod"; import { fromError } from "zod-validation-error"; import { createResourceSession } from "@server/auth/sessions/resource"; import logger from "@server/logger"; -import { - verifyResourceAccessToken -} from "@server/auth/verifyResourceAccessToken"; +import { verifyResourceAccessToken } from "@server/auth/verifyResourceAccessToken"; import config from "@server/lib/config"; import stoi from "@server/lib/stoi"; +import { logAccessAudit } from "@server/private/lib/logAccessAudit"; const authWithAccessTokenBodySchema = z .object({ @@ -131,6 +130,16 @@ export async function authWithAccessToken( `Resource access token invalid. Resource ID: ${resource.resourceId}. IP: ${req.ip}.` ); } + + logAccessAudit({ + orgId: resource.orgId, + resourceId: resource.resourceId, + action: false, + type: "accessToken", + userAgent: req.headers["user-agent"], + requestIp: req.ip + }); + return next( createHttpError( HttpCode.UNAUTHORIZED, @@ -150,6 +159,15 @@ export async function authWithAccessToken( doNotExtend: true }); + logAccessAudit({ + orgId: resource.orgId, + resourceId: resource.resourceId, + action: true, + type: "accessToken", + userAgent: req.headers["user-agent"], + requestIp: req.ip + }); + return response(res, { data: { session: token, diff --git a/server/routers/resource/authWithPassword.ts b/server/routers/resource/authWithPassword.ts index 652c4e86..318c88d8 100644 --- a/server/routers/resource/authWithPassword.ts +++ b/server/routers/resource/authWithPassword.ts @@ -13,6 +13,7 @@ import { createResourceSession } from "@server/auth/sessions/resource"; import logger from "@server/logger"; import { verifyPassword } from "@server/auth/password"; import config from "@server/lib/config"; +import { logAccessAudit } from "@server/private/lib/logAccessAudit"; export const authWithPasswordBodySchema = z .object({ @@ -113,6 +114,16 @@ export async function authWithPassword( `Resource password incorrect. Resource ID: ${resource.resourceId}. IP: ${req.ip}.` ); } + + logAccessAudit({ + orgId: org.orgId, + resourceId: resource.resourceId, + action: false, + type: "password", + userAgent: req.headers["user-agent"], + requestIp: req.ip + }); + return next( createHttpError(HttpCode.UNAUTHORIZED, "Incorrect password") ); @@ -129,6 +140,15 @@ export async function authWithPassword( doNotExtend: true }); + logAccessAudit({ + orgId: org.orgId, + resourceId: resource.resourceId, + action: true, + type: "password", + userAgent: req.headers["user-agent"], + requestIp: req.ip + }); + return response(res, { data: { session: token diff --git a/server/routers/resource/authWithPincode.ts b/server/routers/resource/authWithPincode.ts index d8733c18..2508b84c 100644 --- a/server/routers/resource/authWithPincode.ts +++ b/server/routers/resource/authWithPincode.ts @@ -12,6 +12,7 @@ import { createResourceSession } from "@server/auth/sessions/resource"; import logger from "@server/logger"; import { verifyPassword } from "@server/auth/password"; import config from "@server/lib/config"; +import { logAccessAudit } from "@server/private/lib/logAccessAudit"; export const authWithPincodeBodySchema = z .object({ @@ -112,6 +113,16 @@ export async function authWithPincode( `Resource pin code incorrect. Resource ID: ${resource.resourceId}. IP: ${req.ip}.` ); } + + logAccessAudit({ + orgId: org.orgId, + resourceId: resource.resourceId, + action: false, + type: "pincode", + userAgent: req.headers["user-agent"], + requestIp: req.ip + }); + return next( createHttpError(HttpCode.UNAUTHORIZED, "Incorrect PIN") ); @@ -128,6 +139,15 @@ export async function authWithPincode( doNotExtend: true }); + logAccessAudit({ + orgId: org.orgId, + resourceId: resource.resourceId, + action: true, + type: "pincode", + userAgent: req.headers["user-agent"], + requestIp: req.ip + }); + return response(res, { data: { session: token diff --git a/server/routers/resource/authWithWhitelist.ts b/server/routers/resource/authWithWhitelist.ts index 07662f7f..37410b7b 100644 --- a/server/routers/resource/authWithWhitelist.ts +++ b/server/routers/resource/authWithWhitelist.ts @@ -1,11 +1,6 @@ import { generateSessionToken } from "@server/auth/sessions/app"; import { db } from "@server/db"; -import { - orgs, - resourceOtp, - resources, - resourceWhitelist -} from "@server/db"; +import { orgs, resourceOtp, resources, resourceWhitelist } from "@server/db"; import HttpCode from "@server/types/HttpCode"; import response from "@server/lib/response"; import { eq, and } from "drizzle-orm"; @@ -17,13 +12,11 @@ import { createResourceSession } from "@server/auth/sessions/resource"; import { isValidOtp, sendResourceOtpEmail } from "@server/auth/resourceOtp"; import logger from "@server/logger"; import config from "@server/lib/config"; +import { logAccessAudit } from "@server/private/lib/logAccessAudit"; const authWithWhitelistBodySchema = z .object({ - email: z - .string() - .toLowerCase() - .email(), + email: z.string().toLowerCase().email(), otp: z.string().optional() }) .strict(); @@ -126,6 +119,19 @@ export async function authWithWhitelist( `Email is not whitelisted. Email: ${email}. IP: ${req.ip}.` ); } + + if (org && resource) { + logAccessAudit({ + orgId: org.orgId, + resourceId: resource.resourceId, + action: false, + type: "whitelistedEmail", + metadata: { email }, + userAgent: req.headers["user-agent"], + requestIp: req.ip + }); + } + return next( createHttpError( HttpCode.UNAUTHORIZED, @@ -219,6 +225,16 @@ export async function authWithWhitelist( doNotExtend: true }); + logAccessAudit({ + orgId: org.orgId, + resourceId: resource.resourceId, + action: true, + metadata: { email }, + type: "whitelistedEmail", + userAgent: req.headers["user-agent"], + requestIp: req.ip + }); + return response(res, { data: { session: token diff --git a/server/routers/resource/createResourceRule.ts b/server/routers/resource/createResourceRule.ts index 7cb83d8b..1a5c07c2 100644 --- a/server/routers/resource/createResourceRule.ts +++ b/server/routers/resource/createResourceRule.ts @@ -18,7 +18,7 @@ import { OpenAPITags, registry } from "@server/openApi"; const createResourceRuleSchema = z .object({ action: z.enum(["ACCEPT", "DROP", "PASS"]), - match: z.enum(["CIDR", "IP", "PATH", "GEOIP"]), + match: z.enum(["CIDR", "IP", "PATH", "COUNTRY"]), value: z.string().min(1), priority: z.number().int(), enabled: z.boolean().optional() diff --git a/server/routers/resource/getExchangeToken.ts b/server/routers/resource/getExchangeToken.ts index b4a1a591..5e5f4980 100644 --- a/server/routers/resource/getExchangeToken.ts +++ b/server/routers/resource/getExchangeToken.ts @@ -14,6 +14,7 @@ import { encodeHexLowerCase } from "@oslojs/encoding"; import { sha256 } from "@oslojs/crypto/sha2"; import { response } from "@server/lib/response"; import { checkOrgAccessPolicy } from "#dynamic/lib/checkOrgAccessPolicy"; +import { logAccessAudit } from "#private/lib/logAccessAudit"; const getExchangeTokenParams = z .object({ @@ -46,13 +47,13 @@ export async function getExchangeToken( const { resourceId } = parsedParams.data; - const resource = await db + const [resource] = await db .select() .from(resources) .where(eq(resources.resourceId, resourceId)) .limit(1); - if (resource.length === 0) { + if (!resource) { return next( createHttpError( HttpCode.NOT_FOUND, @@ -105,6 +106,21 @@ export async function getExchangeToken( doNotExtend: true }); + if (req.user) { + logAccessAudit({ + orgId: resource.orgId, + resourceId: resourceId, + user: { + username: req.user.username, + userId: req.user.userId + }, + action: true, + type: "login", + userAgent: req.headers["user-agent"], + requestIp: req.ip + }); + } + logger.debug("Request token created successfully"); return response(res, { diff --git a/server/routers/resource/updateResource.ts b/server/routers/resource/updateResource.ts index a9c3b5de..13c5220d 100644 --- a/server/routers/resource/updateResource.ts +++ b/server/routers/resource/updateResource.ts @@ -99,8 +99,9 @@ const updateRawResourceBodySchema = z name: z.string().min(1).max(255).optional(), proxyPort: z.number().int().min(1).max(65535).optional(), stickySession: z.boolean().optional(), - enabled: z.boolean().optional() - // enableProxy: z.boolean().optional() // always true now + enabled: z.boolean().optional(), + proxyProtocol: z.boolean().optional(), + proxyProtocolVersion: z.number().int().min(1).optional() }) .strict() .refine((data) => Object.keys(data).length > 0, { diff --git a/server/routers/resource/updateResourceRule.ts b/server/routers/resource/updateResourceRule.ts index 06061da9..8df70c0f 100644 --- a/server/routers/resource/updateResourceRule.ts +++ b/server/routers/resource/updateResourceRule.ts @@ -30,7 +30,7 @@ const updateResourceRuleParamsSchema = z const updateResourceRuleSchema = z .object({ action: z.enum(["ACCEPT", "DROP", "PASS"]).optional(), - match: z.enum(["CIDR", "IP", "PATH", "GEOIP"]).optional(), + match: z.enum(["CIDR", "IP", "PATH", "COUNTRY"]).optional(), value: z.string().min(1).optional(), priority: z.number().int(), enabled: z.boolean().optional() diff --git a/server/routers/site/listSites.ts b/server/routers/site/listSites.ts index 694556f7..cddf8c4b 100644 --- a/server/routers/site/listSites.ts +++ b/server/routers/site/listSites.ts @@ -9,14 +9,12 @@ import createHttpError from "http-errors"; import { z } from "zod"; import { fromError } from "zod-validation-error"; import { OpenAPITags, registry } from "@server/openApi"; -import NodeCache from "node-cache"; import semver from "semver"; - -const newtVersionCache = new NodeCache({ stdTTL: 3600 }); // 1 hours in seconds +import cache from "@server/lib/cache"; async function getLatestNewtVersion(): Promise { try { - const cachedVersion = newtVersionCache.get("latestNewtVersion"); + const cachedVersion = cache.get("latestNewtVersion"); if (cachedVersion) { return cachedVersion; } @@ -48,7 +46,7 @@ async function getLatestNewtVersion(): Promise { const latestVersion = tags[0].name; - newtVersionCache.set("latestNewtVersion", latestVersion); + cache.set("latestNewtVersion", latestVersion); return latestVersion; } catch (error: any) { diff --git a/server/routers/site/socketIntegration.ts b/server/routers/site/socketIntegration.ts index 7b5160cb..3a52dcd2 100644 --- a/server/routers/site/socketIntegration.ts +++ b/server/routers/site/socketIntegration.ts @@ -12,9 +12,9 @@ import stoi from "@server/lib/stoi"; import { sendToClient } from "#dynamic/routers/ws"; import { fetchContainers, - dockerSocketCache, dockerSocket } from "../newt/dockerSocket"; +import cache from "@server/lib/cache"; export interface ContainerNetwork { networkId: string; @@ -157,7 +157,7 @@ async function triggerFetch(siteId: number) { // clear the cache for this Newt ID so that the site has to keep asking for the containers // this is to ensure that the site always gets the latest data - dockerSocketCache.del(`${newt.newtId}:dockerContainers`); + cache.del(`${newt.newtId}:dockerContainers`); return { siteId, newtId: newt.newtId }; } @@ -165,7 +165,7 @@ async function triggerFetch(siteId: number) { async function queryContainers(siteId: number) { const { newt } = await getSiteAndNewt(siteId); - const result = dockerSocketCache.get( + const result = cache.get( `${newt.newtId}:dockerContainers` ) as Container[]; if (!result) { @@ -182,7 +182,7 @@ async function isDockerAvailable(siteId: number): Promise { const { newt } = await getSiteAndNewt(siteId); const key = `${newt.newtId}:isAvailable`; - const isAvailable = dockerSocketCache.get(key); + const isAvailable = cache.get(key); return !!isAvailable; } @@ -196,8 +196,8 @@ async function getDockerStatus( const mappedKeys = keys.map((x) => `${newt.newtId}:${x}`); const result = { - isAvailable: dockerSocketCache.get(mappedKeys[0]) as boolean, - socketPath: dockerSocketCache.get(mappedKeys[1]) as string | undefined + isAvailable: cache.get(mappedKeys[0]) as boolean, + socketPath: cache.get(mappedKeys[1]) as string | undefined }; return result; diff --git a/server/routers/traefik/traefikConfigProvider.ts b/server/routers/traefik/traefikConfigProvider.ts index 6c9404e9..9b12ed8a 100644 --- a/server/routers/traefik/traefikConfigProvider.ts +++ b/server/routers/traefik/traefikConfigProvider.ts @@ -21,7 +21,8 @@ export async function traefikConfigProvider( currentExitNodeId, config.getRawConfig().traefik.site_types, build == "oss", // filter out the namespace domains in open source - build != "oss" // generate the login pages on the cloud and hybrid + build != "oss", // generate the login pages on the cloud and and enterprise, + config.getRawConfig().traefik.allow_raw_resources ); if (traefikConfig?.http?.middlewares) { diff --git a/server/routers/user/inviteUser.ts b/server/routers/user/inviteUser.ts index 56098bea..f35fa785 100644 --- a/server/routers/user/inviteUser.ts +++ b/server/routers/user/inviteUser.ts @@ -1,4 +1,3 @@ -import NodeCache from "node-cache"; import { Request, Response, NextFunction } from "express"; import { z } from "zod"; import { db } from "@server/db"; @@ -20,8 +19,7 @@ import { UserType } from "@server/types/UserTypes"; import { usageService } from "@server/lib/billing/usageService"; import { FeatureId } from "@server/lib/billing"; import { build } from "@server/build"; - -const regenerateTracker = new NodeCache({ stdTTL: 3600, checkperiod: 600 }); +import cache from "@server/lib/cache"; const inviteUserParamsSchema = z .object({ @@ -182,7 +180,7 @@ export async function inviteUser( } if (existingInvite.length) { - const attempts = regenerateTracker.get(email) || 0; + const attempts = cache.get(email) || 0; if (attempts >= 3) { return next( createHttpError( @@ -192,7 +190,7 @@ export async function inviteUser( ); } - regenerateTracker.set(email, attempts + 1); + cache.set(email, attempts + 1); const inviteId = existingInvite[0].inviteId; // Retrieve the original inviteId const token = generateRandomString( diff --git a/server/setup/copyInConfig.ts b/server/setup/copyInConfig.ts index b8c00192..e003d089 100644 --- a/server/setup/copyInConfig.ts +++ b/server/setup/copyInConfig.ts @@ -37,7 +37,9 @@ async function copyInDomains() { const configDomains = Object.entries(rawDomains).map( ([key, value]) => ({ domainId: key, - baseDomain: value.base_domain.toLowerCase() + baseDomain: value.base_domain.toLowerCase(), + certResolver: value.cert_resolver || null, + preferWildcardCert: value.prefer_wildcard_cert || null, }) ); @@ -59,11 +61,11 @@ async function copyInDomains() { } } - for (const { domainId, baseDomain } of configDomains) { + for (const { domainId, baseDomain, certResolver, preferWildcardCert } of configDomains) { if (existingDomainKeys.has(domainId)) { await trx .update(domains) - .set({ baseDomain, verified: true, type: "wildcard" }) + .set({ baseDomain, verified: true, type: "wildcard", certResolver, preferWildcardCert }) .where(eq(domains.domainId, domainId)) .execute(); } else { @@ -74,7 +76,9 @@ async function copyInDomains() { baseDomain, configManaged: true, type: "wildcard", - verified: true + verified: true, + certResolver, + preferWildcardCert }) .execute(); } diff --git a/server/setup/migrationsPg.ts b/server/setup/migrationsPg.ts index c8e632e0..b6d20512 100644 --- a/server/setup/migrationsPg.ts +++ b/server/setup/migrationsPg.ts @@ -13,6 +13,7 @@ import m5 from "./scriptsPg/1.10.0"; import m6 from "./scriptsPg/1.10.2"; import m7 from "./scriptsPg/1.11.0"; import m8 from "./scriptsPg/1.11.1"; +import m9 from "./scriptsPg/1.11.2"; // THIS CANNOT IMPORT ANYTHING FROM THE SERVER // EXCEPT FOR THE DATABASE AND THE SCHEMA @@ -26,7 +27,8 @@ const migrations = [ { version: "1.10.0", run: m5 }, { version: "1.10.2", run: m6 }, { version: "1.11.0", run: m7 }, - { version: "1.11.1", run: m8 } + { version: "1.11.1", run: m8 }, + { version: "1.11.2", run: m9 } // Add new migrations here as they are created ] as { version: string; diff --git a/server/setup/migrationsSqlite.ts b/server/setup/migrationsSqlite.ts index e65d7436..d60db7a0 100644 --- a/server/setup/migrationsSqlite.ts +++ b/server/setup/migrationsSqlite.ts @@ -31,6 +31,7 @@ import m26 from "./scriptsSqlite/1.10.1"; import m27 from "./scriptsSqlite/1.10.2"; import m28 from "./scriptsSqlite/1.11.0"; import m29 from "./scriptsSqlite/1.11.1"; +import m30 from "./scriptsSqlite/1.11.2"; // THIS CANNOT IMPORT ANYTHING FROM THE SERVER // EXCEPT FOR THE DATABASE AND THE SCHEMA @@ -60,7 +61,8 @@ const migrations = [ { version: "1.10.1", run: m26 }, { version: "1.10.2", run: m27 }, { version: "1.11.0", run: m28 }, - { version: "1.11.1", run: m29 } + { version: "1.11.1", run: m29 }, + { version: "1.11.2", run: m30 } // Add new migrations here as they are created ] as const; diff --git a/server/setup/scriptsPg/1.11.2.ts b/server/setup/scriptsPg/1.11.2.ts new file mode 100644 index 00000000..6f61e727 --- /dev/null +++ b/server/setup/scriptsPg/1.11.2.ts @@ -0,0 +1,24 @@ +import { db } from "@server/db/pg/driver"; +import { sql } from "drizzle-orm"; + +const version = "1.11.2"; + +export default async function migration() { + console.log(`Running setup script ${version}...`); + + try { + await db.execute(sql`BEGIN`); + + await db.execute(sql`UPDATE "resourceRules" SET "match" = "COUNTRY" WHERE "match" = "GEOIP"`); + + await db.execute(sql`COMMIT`); + console.log(`Updated resource rules match value from GEOIP to COUNTRY`); + } catch (e) { + await db.execute(sql`ROLLBACK`); + console.log("Unable to update resource rules match value"); + console.log(e); + throw e; + } + + console.log(`${version} migration complete`); +} diff --git a/server/setup/scriptsSqlite/1.11.2.ts b/server/setup/scriptsSqlite/1.11.2.ts new file mode 100644 index 00000000..dfc1b7ae --- /dev/null +++ b/server/setup/scriptsSqlite/1.11.2.ts @@ -0,0 +1,18 @@ +import { APP_PATH } from "@server/lib/consts"; +import Database from "better-sqlite3"; +import path from "path"; + +const version = "1.11.2"; + +export default async function migration() { + console.log(`Running setup script ${version}...`); + + const location = path.join(APP_PATH, "db", "db.sqlite"); + const db = new Database(location); + + db.transaction(() => { + db.prepare(`UPDATE resourceRules SET match = "COUNTRY" WHERE match = "GEOIP"`).run(); + })(); + + console.log(`${version} migration complete`); +} diff --git a/src/actions/server.ts b/src/actions/server.ts index b9dc6e55..b75c3ed7 100644 --- a/src/actions/server.ts +++ b/src/actions/server.ts @@ -80,10 +80,12 @@ async function makeApiRequest( const headersList = await reqHeaders(); const host = headersList.get("host"); + const xForwardedFor = headersList.get("x-forwarded-for"); const headers: Record = { "Content-Type": "application/json", "X-CSRF-Token": "x-csrf-protection", + ...(xForwardedFor ? { "X-Forwarded-For": xForwardedFor } : {}), ...(cookieHeader && { Cookie: cookieHeader }), ...additionalHeaders }; @@ -202,6 +204,7 @@ export type LoginRequest = { email: string; password: string; code?: string; + resourceGuid?: string; }; export type LoginResponse = { diff --git a/src/app/[orgId]/settings/clients/page.tsx b/src/app/[orgId]/settings/clients/page.tsx index 0813ad3c..fd73b736 100644 --- a/src/app/[orgId]/settings/clients/page.tsx +++ b/src/app/[orgId]/settings/clients/page.tsx @@ -44,7 +44,9 @@ export default async function ClientsPage(props: ClientsPageProps) { mbIn: formatSize(client.megabytesIn || 0), mbOut: formatSize(client.megabytesOut || 0), orgId: params.orgId, - online: client.online + online: client.online, + olmVersion: client.olmVersion || undefined, + olmUpdateAvailable: client.olmUpdateAvailable || false, }; }); diff --git a/src/app/[orgId]/settings/domains/[domainId]/layout.tsx b/src/app/[orgId]/settings/domains/[domainId]/layout.tsx new file mode 100644 index 00000000..d33d666a --- /dev/null +++ b/src/app/[orgId]/settings/domains/[domainId]/layout.tsx @@ -0,0 +1,32 @@ +import { redirect } from "next/navigation"; +import { authCookieHeader } from "@app/lib/api/cookies"; +import { internal } from "@app/lib/api"; +import { GetDomainResponse } from "@server/routers/domain/getDomain"; +import { AxiosResponse } from "axios"; +import DomainProvider from "@app/providers/DomainProvider"; + +interface SettingsLayoutProps { + children: React.ReactNode; + params: Promise<{ domainId: string; orgId: string }>; +} + +export default async function SettingsLayout({ children, params }: SettingsLayoutProps) { + const { domainId, orgId } = await params; + let domain = null; + + try { + const res = await internal.get>( + `/org/${orgId}/domain/${domainId}`, + await authCookieHeader() + ); + domain = res.data.data; + } catch { + redirect(`/${orgId}/settings/domains`); + } + + return ( + + {children} + + ); +} \ No newline at end of file diff --git a/src/app/[orgId]/settings/domains/[domainId]/page.tsx b/src/app/[orgId]/settings/domains/[domainId]/page.tsx new file mode 100644 index 00000000..c7e137f6 --- /dev/null +++ b/src/app/[orgId]/settings/domains/[domainId]/page.tsx @@ -0,0 +1,104 @@ +"use client"; +import { useState } from "react"; +import { createApiClient, formatAxiosError } from "@app/lib/api"; +import { useEnvContext } from "@app/hooks/useEnvContext"; +import { toast } from "@app/hooks/useToast"; +import { useRouter } from "next/navigation"; +import { RefreshCw } from "lucide-react"; +import { Button } from "@app/components/ui/button"; +import SettingsSectionTitle from "@app/components/SettingsSectionTitle"; +import DomainInfoCard from "@app/components/DomainInfoCard"; +import { useDomain } from "@app/contexts/domainContext"; +import { useTranslations } from "next-intl"; + +export default function DomainSettingsPage() { + const { domain, orgId } = useDomain(); + const router = useRouter(); + const api = createApiClient(useEnvContext()); + const [isRefreshing, setIsRefreshing] = useState(false); + const [restartingDomains, setRestartingDomains] = useState>(new Set()); + const t = useTranslations(); + + const refreshData = async () => { + setIsRefreshing(true); + try { + await new Promise((resolve) => setTimeout(resolve, 200)); + router.refresh(); + } catch { + toast({ + title: t("error"), + description: t("refreshError"), + variant: "destructive", + }); + } finally { + setIsRefreshing(false); + } + }; + + const restartDomain = async (domainId: string) => { + setRestartingDomains((prev) => new Set(prev).add(domainId)); + try { + await api.post(`/org/${orgId}/domain/${domainId}/restart`); + toast({ + title: t("success"), + description: t("domainRestartedDescription", { + fallback: "Domain verification restarted successfully", + }), + }); + refreshData(); + } catch (e) { + toast({ + title: t("error"), + description: formatAxiosError(e), + variant: "destructive", + }); + } finally { + setRestartingDomains((prev) => { + const newSet = new Set(prev); + newSet.delete(domainId); + return newSet; + }); + } + }; + + if (!domain) { + return null; + } + + const isRestarting = restartingDomains.has(domain.domainId); + + return ( + <> +
+ + +
+
+ +
+ + ); +} \ No newline at end of file diff --git a/src/app/[orgId]/settings/domains/page.tsx b/src/app/[orgId]/settings/domains/page.tsx index cb587d92..2c667b3a 100644 --- a/src/app/[orgId]/settings/domains/page.tsx +++ b/src/app/[orgId]/settings/domains/page.tsx @@ -60,7 +60,7 @@ export default async function DomainsPage(props: Props) { title={t("domains")} description={t("domainsDescription")} /> - + ); diff --git a/src/app/[orgId]/settings/general/page.tsx b/src/app/[orgId]/settings/general/page.tsx index b9f44141..8f2e6820 100644 --- a/src/app/[orgId]/settings/general/page.tsx +++ b/src/app/[orgId]/settings/general/page.tsx @@ -50,9 +50,17 @@ import { useUserContext } from "@app/hooks/useUserContext"; import { useTranslations } from "next-intl"; import { build } from "@server/build"; import { SwitchInput } from "@app/components/SwitchInput"; +import { SecurityFeaturesAlert } from "@app/components/SecurityFeaturesAlert"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger +} from "@app/components/ui/dropdown-menu"; +import { ChevronDown } from "lucide-react"; import { useLicenseStatusContext } from "@app/hooks/useLicenseStatusContext"; import { useSubscriptionStatusContext } from "@app/hooks/useSubscriptionStatusContext"; -import { SecurityFeaturesAlert } from "@app/components/SecurityFeaturesAlert"; +import { Alert, AlertDescription } from "@app/components/ui/alert"; // Session length options in hours const SESSION_LENGTH_OPTIONS = [ @@ -87,11 +95,24 @@ const GeneralFormSchema = z.object({ subnet: z.string().optional(), requireTwoFactor: z.boolean().optional(), maxSessionLengthHours: z.number().nullable().optional(), - passwordExpiryDays: z.number().nullable().optional() + passwordExpiryDays: z.number().nullable().optional(), + settingsLogRetentionDaysRequest: z.number(), + settingsLogRetentionDaysAccess: z.number(), + settingsLogRetentionDaysAction: z.number() }); type GeneralFormValues = z.infer; +const LOG_RETENTION_OPTIONS = [ + { label: "logRetentionDisabled", value: 0 }, + { label: "logRetention3Days", value: 3 }, + { label: "logRetention7Days", value: 7 }, + { label: "logRetention14Days", value: 14 }, + { label: "logRetention30Days", value: 30 }, + { label: "logRetention90Days", value: 90 }, + ...(build != "saas" ? [{ label: "logRetentionForever", value: -1 }] : []) +]; + export default function GeneralPage() { const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false); const { orgUser } = userOrgUserContext(); @@ -102,19 +123,20 @@ export default function GeneralPage() { const t = useTranslations(); const { env } = useEnvContext(); const { licenseStatus, isUnlocked } = useLicenseStatusContext(); - const subscriptionStatus = useSubscriptionStatusContext(); + const subscription = useSubscriptionStatusContext(); // Check if security features are disabled due to licensing/subscription const isSecurityFeatureDisabled = () => { const isEnterpriseNotLicensed = build === "enterprise" && !isUnlocked(); const isSaasNotSubscribed = - build === "saas" && !subscriptionStatus?.isSubscribed(); + build === "saas" && !subscription?.isSubscribed(); return isEnterpriseNotLicensed || isSaasNotSubscribed; }; const [loadingDelete, setLoadingDelete] = useState(false); const [loadingSave, setLoadingSave] = useState(false); - const [isSecurityPolicyConfirmOpen, setIsSecurityPolicyConfirmOpen] = useState(false); + const [isSecurityPolicyConfirmOpen, setIsSecurityPolicyConfirmOpen] = + useState(false); const authPageSettingsRef = useRef(null); const form = useForm({ @@ -124,7 +146,13 @@ export default function GeneralPage() { subnet: org?.org.subnet || "", // Add default value for subnet requireTwoFactor: org?.org.requireTwoFactor || false, maxSessionLengthHours: org?.org.maxSessionLengthHours || null, - passwordExpiryDays: org?.org.passwordExpiryDays || null + passwordExpiryDays: org?.org.passwordExpiryDays || null, + settingsLogRetentionDaysRequest: + org.org.settingsLogRetentionDaysRequest ?? 15, + settingsLogRetentionDaysAccess: + org.org.settingsLogRetentionDaysAccess ?? 15, + settingsLogRetentionDaysAction: + org.org.settingsLogRetentionDaysAction ?? 15 }, mode: "onChange" }); @@ -140,9 +168,12 @@ export default function GeneralPage() { const hasSecurityPolicyChanged = () => { const currentValues = form.getValues(); return ( - currentValues.requireTwoFactor !== initialSecurityValues.requireTwoFactor || - currentValues.maxSessionLengthHours !== initialSecurityValues.maxSessionLengthHours || - currentValues.passwordExpiryDays !== initialSecurityValues.passwordExpiryDays + currentValues.requireTwoFactor !== + initialSecurityValues.requireTwoFactor || + currentValues.maxSessionLengthHours !== + initialSecurityValues.maxSessionLengthHours || + currentValues.passwordExpiryDays !== + initialSecurityValues.passwordExpiryDays ); }; @@ -212,7 +243,13 @@ export default function GeneralPage() { try { const reqData = { - name: data.name + name: data.name, + settingsLogRetentionDaysRequest: + data.settingsLogRetentionDaysRequest, + settingsLogRetentionDaysAccess: + data.settingsLogRetentionDaysAccess, + settingsLogRetentionDaysAction: + data.settingsLogRetentionDaysAction } as any; if (build !== "oss") { reqData.requireTwoFactor = data.requireTwoFactor || false; @@ -247,6 +284,11 @@ export default function GeneralPage() { } } + const getLabelForValue = (value: number) => { + const option = LOG_RETENTION_OPTIONS.find((opt) => opt.value === value); + return option ? t(option.label) : `${value} days`; + }; + return ( - - - - {t("orgGeneralSettings")} - - - {t("orgGeneralSettingsDescription")} - - - - -
- + + + + + + + {t("orgGeneralSettings")} + + + {t("orgGeneralSettingsDescription")} + + + + )} - - - - -
+
+
+
+ + + + + {t("logRetention")} + + + {t("logRetentionDescription")} + + + + + ( + + + {t("logRetentionRequestLabel")} + + + + + + + + {LOG_RETENTION_OPTIONS.filter( + (option) => { + if ( + build == + "saas" && + !subscription?.subscribed && + option.value > + 30 + ) { + return false; + } + return true; + } + ).map((option) => ( + + field.onChange( + option.value + ) + } + > + {t( + option.label + )} + + ))} + + + + + {t( + "logRetentionRequestDescription" + )} + + + + )} + /> + + {build != "oss" && ( + <> + {build == "saas" && + !subscription?.subscribed ? ( + + + {t( + "subscriptionRequiredToUse" + )} + + + ) : null} + + {build == "enterprise" && + !isUnlocked() ? ( + + + {t("licenseRequiredToUse")} + + + ) : null} + + ( + + + {t( + "logRetentionAccessLabel" + )} + + + + + + + + {LOG_RETENTION_OPTIONS.map( + ( + option + ) => ( + + field.onChange( + option.value + ) + } + > + {t( + option.label + )} + + ) + )} + + + + + {t( + "logRetentionAccessDescription" + )} + + + + )} + /> + ( + + + {t( + "logRetentionActionLabel" + )} + + + + + + + + {LOG_RETENTION_OPTIONS.map( + ( + option + ) => ( + + field.onChange( + option.value + ) + } + > + {t( + option.label + )} + + ) + )} + + + + + {t( + "logRetentionActionDescription" + )} + + + + )} + /> + + )} + + + + + + + {build === "saas" && } {/* Security Settings Section */} @@ -471,7 +759,9 @@ export default function GeneralPage() { : option.value.toString() } > - {t(option.labelKey)} + {t( + option.labelKey + )} ) )} @@ -550,7 +840,9 @@ export default function GeneralPage() { : option.value.toString() } > - {t(option.labelKey)} + {t( + option.labelKey + )} ) )} diff --git a/src/app/[orgId]/settings/logs/access/page.tsx b/src/app/[orgId]/settings/logs/access/page.tsx new file mode 100644 index 00000000..56071976 --- /dev/null +++ b/src/app/[orgId]/settings/logs/access/page.tsx @@ -0,0 +1,662 @@ +"use client"; +import { Button } from "@app/components/ui/button"; +import { toast } from "@app/hooks/useToast"; +import { useState, useRef, useEffect } from "react"; +import { createApiClient } from "@app/lib/api"; +import { useEnvContext } from "@app/hooks/useEnvContext"; +import { useParams, useRouter, useSearchParams } from "next/navigation"; +import { useTranslations } from "next-intl"; +import { + getStoredPageSize, + LogDataTable, + setStoredPageSize +} from "@app/components/LogDataTable"; +import { ColumnDef } from "@tanstack/react-table"; +import { DateTimeValue } from "@app/components/DateTimePicker"; +import { ArrowUpRight, Key, User } from "lucide-react"; +import Link from "next/link"; +import { ColumnFilter } from "@app/components/ColumnFilter"; +import SettingsSectionTitle from "@app/components/SettingsSectionTitle"; +import { useSubscriptionStatusContext } from "@app/hooks/useSubscriptionStatusContext"; +import { useLicenseStatusContext } from "@app/hooks/useLicenseStatusContext"; +import { build } from "@server/build"; +import { Alert, AlertDescription } from "@app/components/ui/alert"; + +export default function GeneralPage() { + const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false); + const router = useRouter(); + const searchParams = useSearchParams(); + const api = createApiClient(useEnvContext()); + const t = useTranslations(); + const { env } = useEnvContext(); + const { orgId } = useParams(); + const subscription = useSubscriptionStatusContext(); + const { isUnlocked } = useLicenseStatusContext(); + + const [rows, setRows] = useState([]); + const [isRefreshing, setIsRefreshing] = useState(false); + const [isExporting, setIsExporting] = useState(false); + const [filterAttributes, setFilterAttributes] = useState<{ + actors: string[]; + resources: { + id: number; + name: string | null; + }[]; + locations: string[]; + }>({ + actors: [], + resources: [], + locations: [] + }); + + // Filter states - unified object for all filters + const [filters, setFilters] = useState<{ + action?: string; + type?: string; + resourceId?: string; + location?: string; + actor?: string; + }>({ + action: searchParams.get("action") || undefined, + type: searchParams.get("type") || undefined, + resourceId: searchParams.get("resourceId") || undefined, + location: searchParams.get("location") || undefined, + actor: searchParams.get("actor") || undefined + }); + + // Pagination state + const [totalCount, setTotalCount] = useState(0); + const [currentPage, setCurrentPage] = useState(0); + const [isLoading, setIsLoading] = useState(false); + + // Initialize page size from storage or default + const [pageSize, setPageSize] = useState(() => { + return getStoredPageSize("access-audit-logs", 20); + }); + + // Set default date range to last 24 hours + const getDefaultDateRange = () => { + // if the time is in the url params, use that instead + const startParam = searchParams.get("start"); + const endParam = searchParams.get("end"); + if (startParam && endParam) { + return { + startDate: { + date: new Date(startParam) + }, + endDate: { + date: new Date(endParam) + } + }; + } + + const now = new Date(); + const yesterday = new Date(now.getTime() - 24 * 60 * 60 * 1000); + + return { + startDate: { + date: yesterday + }, + endDate: { + date: now + } + }; + }; + + const [dateRange, setDateRange] = useState<{ + startDate: DateTimeValue; + endDate: DateTimeValue; + }>(getDefaultDateRange()); + + // Trigger search with default values on component mount + useEffect(() => { + const defaultRange = getDefaultDateRange(); + queryDateTime( + defaultRange.startDate, + defaultRange.endDate, + 0, + pageSize + ); + }, [orgId]); // Re-run if orgId changes + + const handleDateRangeChange = ( + startDate: DateTimeValue, + endDate: DateTimeValue + ) => { + setDateRange({ startDate, endDate }); + setCurrentPage(0); // Reset to first page when filtering + // put the search params in the url for the time + updateUrlParamsForAllFilters({ + start: startDate.date?.toISOString() || "", + end: endDate.date?.toISOString() || "" + }); + + queryDateTime(startDate, endDate, 0, pageSize); + }; + + // Handle page changes + const handlePageChange = (newPage: number) => { + setCurrentPage(newPage); + queryDateTime( + dateRange.startDate, + dateRange.endDate, + newPage, + pageSize + ); + }; + + // Handle page size changes + const handlePageSizeChange = (newPageSize: number) => { + setPageSize(newPageSize); + setStoredPageSize(newPageSize, "access-audit-logs"); + setCurrentPage(0); // Reset to first page when changing page size + queryDateTime(dateRange.startDate, dateRange.endDate, 0, newPageSize); + }; + + // Handle filter changes generically + const handleFilterChange = ( + filterType: keyof typeof filters, + value: string | undefined + ) => { + // Create new filters object with updated value + const newFilters = { + ...filters, + [filterType]: value + }; + + setFilters(newFilters); + setCurrentPage(0); // Reset to first page when filtering + + // Update URL params + updateUrlParamsForAllFilters(newFilters); + + // Trigger new query with updated filters (pass directly to avoid async state issues) + queryDateTime( + dateRange.startDate, + dateRange.endDate, + 0, + pageSize, + newFilters + ); + }; + + const updateUrlParamsForAllFilters = ( + newFilters: + | typeof filters + | { + start: string; + end: string; + } + ) => { + const params = new URLSearchParams(searchParams); + Object.entries(newFilters).forEach(([key, value]) => { + if (value) { + params.set(key, value); + } else { + params.delete(key); + } + }); + router.replace(`?${params.toString()}`, { scroll: false }); + }; + + const queryDateTime = async ( + startDate: DateTimeValue, + endDate: DateTimeValue, + page: number = currentPage, + size: number = pageSize, + filtersParam?: { + action?: string; + type?: string; + resourceId?: string; + location?: string; + actor?: string; + } + ) => { + console.log("Date range changed:", { startDate, endDate, page, size }); + if ( + (build == "saas" && !subscription?.subscribed) || + (build == "enterprise" && !isUnlocked()) + ) { + console.log( + "Access denied: subscription inactive or license locked" + ); + return; + } + + setIsLoading(true); + + try { + // Use the provided filters or fall back to current state + const activeFilters = filtersParam || filters; + + // Convert the date/time values to API parameters + const params: any = { + limit: size, + offset: page * size, + ...activeFilters + }; + + if (startDate?.date) { + const startDateTime = new Date(startDate.date); + if (startDate.time) { + const [hours, minutes, seconds] = startDate.time + .split(":") + .map(Number); + startDateTime.setHours(hours, minutes, seconds || 0); + } + params.timeStart = startDateTime.toISOString(); + } + + if (endDate?.date) { + const endDateTime = new Date(endDate.date); + if (endDate.time) { + const [hours, minutes, seconds] = endDate.time + .split(":") + .map(Number); + endDateTime.setHours(hours, minutes, seconds || 0); + } else { + // If no time is specified, set to NOW + const now = new Date(); + endDateTime.setHours( + now.getHours(), + now.getMinutes(), + now.getSeconds(), + now.getMilliseconds() + ); + } + params.timeEnd = endDateTime.toISOString(); + } + + const res = await api.get(`/org/${orgId}/logs/access`, { params }); + if (res.status === 200) { + setRows(res.data.data.log || []); + setTotalCount(res.data.data.pagination?.total || 0); + setFilterAttributes(res.data.data.filterAttributes); + console.log("Fetched logs:", res.data); + } + } catch (error) { + toast({ + title: t("error"), + description: t("Failed to filter logs"), + variant: "destructive" + }); + } finally { + setIsLoading(false); + } + }; + + const refreshData = async () => { + console.log("Data refreshed"); + setIsRefreshing(true); + try { + // Refresh data with current date range and pagination + await queryDateTime( + dateRange.startDate, + dateRange.endDate, + currentPage, + pageSize + ); + } catch (error) { + toast({ + title: t("error"), + description: t("refreshError"), + variant: "destructive" + }); + } finally { + setIsRefreshing(false); + } + }; + + const exportData = async () => { + try { + setIsExporting(true); + + // Prepare query params for export + const params: any = { + timeStart: dateRange.startDate?.date + ? new Date(dateRange.startDate.date).toISOString() + : undefined, + timeEnd: dateRange.endDate?.date + ? new Date(dateRange.endDate.date).toISOString() + : undefined, + ...filters + }; + + const response = await api.get(`/org/${orgId}/logs/access/export`, { + responseType: "blob", + params + }); + + // Create a URL for the blob and trigger a download + const url = window.URL.createObjectURL(new Blob([response.data])); + const link = document.createElement("a"); + link.href = url; + const epoch = Math.floor(Date.now() / 1000); + link.setAttribute( + "download", + `access-audit-logs-${orgId}-${epoch}.csv` + ); + document.body.appendChild(link); + link.click(); + link.parentNode?.removeChild(link); + setIsExporting(false); + } catch (error) { + toast({ + title: t("error"), + description: t("exportError"), + variant: "destructive" + }); + } + }; + + const columns: ColumnDef[] = [ + { + accessorKey: "timestamp", + header: ({ column }) => { + return t("timestamp"); + }, + cell: ({ row }) => { + return ( +
+ {new Date( + row.original.timestamp * 1000 + ).toLocaleString()} +
+ ); + } + }, + { + accessorKey: "action", + header: ({ column }) => { + return ( +
+ {t("action")} + + handleFilterChange("action", value) + } + // placeholder="" + searchPlaceholder="Search..." + emptyMessage="None found" + /> +
+ ); + }, + cell: ({ row }) => { + return ( + + {row.original.action ? <>Allowed : <>Denied} + + ); + } + }, + { + accessorKey: "ip", + header: ({ column }) => { + return t("ip"); + } + }, + { + accessorKey: "location", + header: ({ column }) => { + return ( +
+ {t("location")} + ({ + value: location, + label: location + }) + )} + selectedValue={filters.location} + onValueChange={(value) => + handleFilterChange("location", value) + } + // placeholder="" + searchPlaceholder="Search..." + emptyMessage="None found" + /> +
+ ); + }, + cell: ({ row }) => { + return ( + + {row.original.location ? ( + + {row.original.location} + + ) : ( + + - + + )} + + ); + } + }, + { + accessorKey: "resourceName", + header: ({ column }) => { + return ( +
+ {t("resource")} + ({ + value: res.id.toString(), + label: res.name || "Unnamed Resource" + }))} + selectedValue={filters.resourceId} + onValueChange={(value) => + handleFilterChange("resourceId", value) + } + // placeholder="" + searchPlaceholder="Search..." + emptyMessage="None found" + /> +
+ ); + }, + cell: ({ row }) => { + return ( + + + + ); + } + }, + { + accessorKey: "type", + header: ({ column }) => { + return ( +
+ {t("type")} + + handleFilterChange("type", value) + } + // placeholder="" + searchPlaceholder="Search..." + emptyMessage="None found" + /> +
+ ); + }, + cell: ({ row }) => { + // should be capitalized first letter + return ( + + {row.original.type.charAt(0).toUpperCase() + + row.original.type.slice(1) || "-"} + + ); + } + }, + { + accessorKey: "actor", + header: ({ column }) => { + return ( +
+ {t("actor")} + ({ + value: actor, + label: actor + }))} + selectedValue={filters.actor} + onValueChange={(value) => + handleFilterChange("actor", value) + } + // placeholder="" + searchPlaceholder="Search..." + emptyMessage="None found" + /> +
+ ); + }, + cell: ({ row }) => { + return ( + + {row.original.actor ? ( + <> + {row.original.actorType == "user" ? ( + + ) : ( + + )} + {row.original.actor} + + ) : ( + <>- + )} + + ); + } + }, + { + accessorKey: "actorId", + header: ({ column }) => { + return t("actorId"); + }, + cell: ({ row }) => { + return ( + + {row.original.actorId || "-"} + + ); + } + } + ]; + + const renderExpandedRow = (row: any) => { + return ( +
+
+ {row.userAgent != "node" && ( +
+ User Agent: +

+ {row.userAgent || "N/A"} +

+
+ )} +
+ Metadata: +
+                            {row.metadata
+                                ? JSON.stringify(
+                                      JSON.parse(row.metadata),
+                                      null,
+                                      2
+                                  )
+                                : "N/A"}
+                        
+
+
+
+ ); + }; + + return ( + <> + + + {build == "saas" && !subscription?.subscribed ? ( + + + {t("subscriptionRequiredToUse")} + + + ) : null} + + {build == "enterprise" && !isUnlocked() ? ( + + + {t("licenseRequiredToUse")} + + + ) : null} + + + + ); +} diff --git a/src/app/[orgId]/settings/logs/action/page.tsx b/src/app/[orgId]/settings/logs/action/page.tsx new file mode 100644 index 00000000..b9845afa --- /dev/null +++ b/src/app/[orgId]/settings/logs/action/page.tsx @@ -0,0 +1,515 @@ +"use client"; +import { Button } from "@app/components/ui/button"; +import { toast } from "@app/hooks/useToast"; +import { useState, useRef, useEffect } from "react"; +import { createApiClient } from "@app/lib/api"; +import { useEnvContext } from "@app/hooks/useEnvContext"; +import { useParams, useRouter, useSearchParams } from "next/navigation"; +import { useTranslations } from "next-intl"; +import { + getStoredPageSize, + LogDataTable, + setStoredPageSize +} from "@app/components/LogDataTable"; +import { ColumnDef } from "@tanstack/react-table"; +import { DateTimeValue } from "@app/components/DateTimePicker"; +import { Key, User } from "lucide-react"; +import { ColumnFilter } from "@app/components/ColumnFilter"; +import SettingsSectionTitle from "@app/components/SettingsSectionTitle"; +import { useSubscriptionStatusContext } from "@app/hooks/useSubscriptionStatusContext"; +import { useLicenseStatusContext } from "@app/hooks/useLicenseStatusContext"; +import { build } from "@server/build"; +import { Alert, AlertDescription } from "@app/components/ui/alert"; + +export default function GeneralPage() { + const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false); + const router = useRouter(); + const api = createApiClient(useEnvContext()); + const t = useTranslations(); + const { env } = useEnvContext(); + const { orgId } = useParams(); + const searchParams = useSearchParams(); + const subscription = useSubscriptionStatusContext(); + const { isUnlocked } = useLicenseStatusContext(); + + const [rows, setRows] = useState([]); + const [isRefreshing, setIsRefreshing] = useState(false); + const [isExporting, setIsExporting] = useState(false); + const [filterAttributes, setFilterAttributes] = useState<{ + actors: string[]; + actions: string[]; + }>({ + actors: [], + actions: [] + }); + + // Filter states - unified object for all filters + const [filters, setFilters] = useState<{ + action?: string; + actor?: string; + }>({ + action: searchParams.get("action") || undefined, + actor: searchParams.get("actor") || undefined + }); + + // Pagination state + const [totalCount, setTotalCount] = useState(0); + const [currentPage, setCurrentPage] = useState(0); + const [isLoading, setIsLoading] = useState(false); + + // Initialize page size from storage or default + const [pageSize, setPageSize] = useState(() => { + return getStoredPageSize("action-audit-logs", 20); + }); + + // Set default date range to last 24 hours + const getDefaultDateRange = () => { + // if the time is in the url params, use that instead + const startParam = searchParams.get("start"); + const endParam = searchParams.get("end"); + if (startParam && endParam) { + return { + startDate: { + date: new Date(startParam) + }, + endDate: { + date: new Date(endParam) + } + }; + } + + const now = new Date(); + const yesterday = new Date(now.getTime() - 24 * 60 * 60 * 1000); + + return { + startDate: { + date: yesterday + }, + endDate: { + date: now + } + }; + }; + + const [dateRange, setDateRange] = useState<{ + startDate: DateTimeValue; + endDate: DateTimeValue; + }>(getDefaultDateRange()); + + // Trigger search with default values on component mount + useEffect(() => { + const defaultRange = getDefaultDateRange(); + queryDateTime( + defaultRange.startDate, + defaultRange.endDate, + 0, + pageSize + ); + }, [orgId]); // Re-run if orgId changes + + const handleDateRangeChange = ( + startDate: DateTimeValue, + endDate: DateTimeValue + ) => { + setDateRange({ startDate, endDate }); + setCurrentPage(0); // Reset to first page when filtering + // put the search params in the url for the time + updateUrlParamsForAllFilters({ + start: startDate.date?.toISOString() || "", + end: endDate.date?.toISOString() || "" + }); + + queryDateTime(startDate, endDate, 0, pageSize); + }; + + // Handle page changes + const handlePageChange = (newPage: number) => { + setCurrentPage(newPage); + queryDateTime( + dateRange.startDate, + dateRange.endDate, + newPage, + pageSize + ); + }; + + // Handle page size changes + const handlePageSizeChange = (newPageSize: number) => { + setPageSize(newPageSize); + setStoredPageSize(newPageSize, "action-audit-logs"); + setCurrentPage(0); // Reset to first page when changing page size + queryDateTime(dateRange.startDate, dateRange.endDate, 0, newPageSize); + }; + + // Handle filter changes generically + const handleFilterChange = ( + filterType: keyof typeof filters, + value: string | undefined + ) => { + // Create new filters object with updated value + const newFilters = { + ...filters, + [filterType]: value + }; + + setFilters(newFilters); + setCurrentPage(0); // Reset to first page when filtering + + // Update URL params + updateUrlParamsForAllFilters(newFilters); + + // Trigger new query with updated filters (pass directly to avoid async state issues) + queryDateTime( + dateRange.startDate, + dateRange.endDate, + 0, + pageSize, + newFilters + ); + }; + + const updateUrlParamsForAllFilters = ( + newFilters: + | typeof filters + | { + start: string; + end: string; + } + ) => { + const params = new URLSearchParams(searchParams); + Object.entries(newFilters).forEach(([key, value]) => { + if (value) { + params.set(key, value); + } else { + params.delete(key); + } + }); + router.replace(`?${params.toString()}`, { scroll: false }); + }; + + const queryDateTime = async ( + startDate: DateTimeValue, + endDate: DateTimeValue, + page: number = currentPage, + size: number = pageSize, + filtersParam?: { + action?: string; + actor?: string; + } + ) => { + console.log("Date range changed:", { startDate, endDate, page, size }); + if ( + (build == "saas" && !subscription?.subscribed) || + (build == "enterprise" && !isUnlocked()) + ) { + console.log( + "Access denied: subscription inactive or license locked" + ); + return; + } + setIsLoading(true); + + try { + // Use the provided filters or fall back to current state + const activeFilters = filtersParam || filters; + + // Convert the date/time values to API parameters + const params: any = { + limit: size, + offset: page * size, + ...activeFilters + }; + + if (startDate?.date) { + const startDateTime = new Date(startDate.date); + if (startDate.time) { + const [hours, minutes, seconds] = startDate.time + .split(":") + .map(Number); + startDateTime.setHours(hours, minutes, seconds || 0); + } + params.timeStart = startDateTime.toISOString(); + } + + if (endDate?.date) { + const endDateTime = new Date(endDate.date); + if (endDate.time) { + const [hours, minutes, seconds] = endDate.time + .split(":") + .map(Number); + endDateTime.setHours(hours, minutes, seconds || 0); + } else { + // If no time is specified, set to NOW + const now = new Date(); + endDateTime.setHours( + now.getHours(), + now.getMinutes(), + now.getSeconds(), + now.getMilliseconds() + ); + } + params.timeEnd = endDateTime.toISOString(); + } + + const res = await api.get(`/org/${orgId}/logs/action`, { params }); + if (res.status === 200) { + setRows(res.data.data.log || []); + setTotalCount(res.data.data.pagination?.total || 0); + setFilterAttributes(res.data.data.filterAttributes); + console.log("Fetched logs:", res.data); + } + } catch (error) { + toast({ + title: t("error"), + description: t("Failed to filter logs"), + variant: "destructive" + }); + } finally { + setIsLoading(false); + } + }; + + const refreshData = async () => { + console.log("Data refreshed"); + setIsRefreshing(true); + try { + // Refresh data with current date range and pagination + await queryDateTime( + dateRange.startDate, + dateRange.endDate, + currentPage, + pageSize + ); + } catch (error) { + toast({ + title: t("error"), + description: t("refreshError"), + variant: "destructive" + }); + } finally { + setIsRefreshing(false); + } + }; + + const exportData = async () => { + try { + setIsExporting(true); + + // Prepare query params for export + const params: any = { + timeStart: dateRange.startDate?.date + ? new Date(dateRange.startDate.date).toISOString() + : undefined, + timeEnd: dateRange.endDate?.date + ? new Date(dateRange.endDate.date).toISOString() + : undefined, + ...filters + }; + + const response = await api.get(`/org/${orgId}/logs/action/export`, { + responseType: "blob", + params + }); + + // Create a URL for the blob and trigger a download + const url = window.URL.createObjectURL(new Blob([response.data])); + const link = document.createElement("a"); + link.href = url; + const epoch = Math.floor(Date.now() / 1000); + link.setAttribute( + "download", + `action-audit-logs-${orgId}-${epoch}.csv` + ); + document.body.appendChild(link); + link.click(); + link.parentNode?.removeChild(link); + setIsExporting(false); + } catch (error) { + toast({ + title: t("error"), + description: t("exportError"), + variant: "destructive" + }); + } + }; + + const columns: ColumnDef[] = [ + { + accessorKey: "timestamp", + header: ({ column }) => { + return t("timestamp"); + }, + cell: ({ row }) => { + return ( +
+ {new Date( + row.original.timestamp * 1000 + ).toLocaleString()} +
+ ); + } + }, + { + accessorKey: "action", + header: ({ column }) => { + return ( +
+ {t("action")} + ({ + label: + action.charAt(0).toUpperCase() + + action.slice(1), + value: action + }))} + selectedValue={filters.action} + onValueChange={(value) => + handleFilterChange("action", value) + } + // placeholder="" + searchPlaceholder="Search..." + emptyMessage="None found" + /> +
+ ); + }, + cell: ({ row }) => { + return ( + + {row.original.action.charAt(0).toUpperCase() + + row.original.action.slice(1)} + + ); + } + }, + { + accessorKey: "actor", + header: ({ column }) => { + return ( +
+ {t("actor")} + ({ + value: actor, + label: actor + }))} + selectedValue={filters.actor} + onValueChange={(value) => + handleFilterChange("actor", value) + } + // placeholder="" + searchPlaceholder="Search..." + emptyMessage="None found" + /> +
+ ); + }, + cell: ({ row }) => { + return ( + + {row.original.actorType == "user" ? ( + + ) : ( + + )} + {row.original.actor} + + ); + } + }, + { + accessorKey: "actorId", + header: ({ column }) => { + return t("actorId"); + }, + cell: ({ row }) => { + return ( + + {row.original.actorId} + + ); + } + } + ]; + + const renderExpandedRow = (row: any) => { + return ( +
+
+
+ Metadata: +
+                            {row.metadata
+                                ? JSON.stringify(
+                                      JSON.parse(row.metadata),
+                                      null,
+                                      2
+                                  )
+                                : "N/A"}
+                        
+
+
+
+ ); + }; + + return ( + <> + + + {build == "saas" && !subscription?.subscribed ? ( + + + {t("subscriptionRequiredToUse")} + + + ) : null} + + {build == "enterprise" && !isUnlocked() ? ( + + + {t("licenseRequiredToUse")} + + + ) : null} + + + + ); +} diff --git a/src/app/[orgId]/settings/logs/layout.tsx b/src/app/[orgId]/settings/logs/layout.tsx new file mode 100644 index 00000000..96958403 --- /dev/null +++ b/src/app/[orgId]/settings/logs/layout.tsx @@ -0,0 +1,22 @@ +import { verifySession } from "@app/lib/auth/verifySession"; +import { redirect } from "next/navigation"; +import { cache } from "react"; + +type GeneralSettingsProps = { + children: React.ReactNode; + params: Promise<{ orgId: string }>; +}; + +export default async function GeneralSettingsPage({ + children, + params +}: GeneralSettingsProps) { + const getUser = cache(verifySession); + const user = await getUser(); + + if (!user) { + redirect(`/`); + } + + return children; +} diff --git a/src/app/[orgId]/settings/logs/page.tsx b/src/app/[orgId]/settings/logs/page.tsx new file mode 100644 index 00000000..45b5a7de --- /dev/null +++ b/src/app/[orgId]/settings/logs/page.tsx @@ -0,0 +1,54 @@ +"use client"; +import ConfirmDeleteDialog from "@app/components/ConfirmDeleteDialog"; +import AuthPageSettings, { + AuthPageSettingsRef +} from "@app/components/private/AuthPageSettings"; + +import { Button } from "@app/components/ui/button"; +import { useOrgContext } from "@app/hooks/useOrgContext"; +import { userOrgUserContext } from "@app/hooks/useOrgUserContext"; +import { toast } from "@app/hooks/useToast"; +import { useState, useRef } from "react"; +import { + Form, + FormControl, + FormDescription, + FormField, + FormItem, + FormLabel, + FormMessage +} from "@/components/ui/form"; +import { Input } from "@/components/ui/input"; + +import { z } from "zod"; +import { useForm } from "react-hook-form"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { createApiClient } from "@app/lib/api"; +import { useEnvContext } from "@app/hooks/useEnvContext"; +import { formatAxiosError } from "@app/lib/api"; +import { AxiosResponse } from "axios"; +import { DeleteOrgResponse, ListUserOrgsResponse } from "@server/routers/org"; +import { useRouter } from "next/navigation"; +import { + SettingsContainer, + SettingsSection, + SettingsSectionHeader, + SettingsSectionTitle, + SettingsSectionDescription, + SettingsSectionBody, + SettingsSectionForm, + SettingsSectionFooter +} from "@app/components/Settings"; +import { useUserContext } from "@app/hooks/useUserContext"; +import { useTranslations } from "next-intl"; +import { build } from "@server/build"; + +export default function GeneralPage() { + const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false); + const router = useRouter(); + const api = createApiClient(useEnvContext()); + const t = useTranslations(); + const { env } = useEnvContext(); + + return

dfas

; +} diff --git a/src/app/[orgId]/settings/logs/request/page.tsx b/src/app/[orgId]/settings/logs/request/page.tsx new file mode 100644 index 00000000..7aeef772 --- /dev/null +++ b/src/app/[orgId]/settings/logs/request/page.tsx @@ -0,0 +1,796 @@ +"use client"; +import { Button } from "@app/components/ui/button"; +import { toast } from "@app/hooks/useToast"; +import { useState, useRef, useEffect } from "react"; +import { createApiClient } from "@app/lib/api"; +import { useEnvContext } from "@app/hooks/useEnvContext"; +import { useParams, useRouter, useSearchParams } from "next/navigation"; +import { useTranslations } from "next-intl"; +import { getStoredPageSize, LogDataTable, setStoredPageSize } from "@app/components/LogDataTable"; +import { ColumnDef } from "@tanstack/react-table"; +import { DateTimeValue } from "@app/components/DateTimePicker"; +import { Key, RouteOff, User, Lock, Unlock, ArrowUpRight } from "lucide-react"; +import Link from "next/link"; +import { ColumnFilter } from "@app/components/ColumnFilter"; +import SettingsSectionTitle from "@app/components/SettingsSectionTitle"; + +export default function GeneralPage() { + const router = useRouter(); + const api = createApiClient(useEnvContext()); + const t = useTranslations(); + const { env } = useEnvContext(); + const { orgId } = useParams(); + const searchParams = useSearchParams(); + + const [rows, setRows] = useState([]); + const [isRefreshing, setIsRefreshing] = useState(false); + const [isExporting, setIsExporting] = useState(false); + + // Pagination state + const [totalCount, setTotalCount] = useState(0); + const [currentPage, setCurrentPage] = useState(0); + const [isLoading, setIsLoading] = useState(false); + + // Initialize page size from storage or default + const [pageSize, setPageSize] = useState(() => { + return getStoredPageSize("request-audit-logs", 20); + }); + + const [filterAttributes, setFilterAttributes] = useState<{ + actors: string[]; + resources: { + id: number; + name: string | null; + }[]; + locations: string[]; + hosts: string[]; + paths: string[]; + }>({ + actors: [], + resources: [], + locations: [], + hosts: [], + paths: [] + }); + + // Filter states - unified object for all filters + const [filters, setFilters] = useState<{ + action?: string; + resourceId?: string; + host?: string; + location?: string; + actor?: string; + method?: string; + reason?: string; + path?: string; + }>({ + action: searchParams.get("action") || undefined, + host: searchParams.get("host") || undefined, + resourceId: searchParams.get("resourceId") || undefined, + location: searchParams.get("location") || undefined, + actor: searchParams.get("actor") || undefined, + method: searchParams.get("method") || undefined, + reason: searchParams.get("reason") || undefined, + path: searchParams.get("path") || undefined + }); + + // Set default date range to last 24 hours + const getDefaultDateRange = () => { + // if the time is in the url params, use that instead + const startParam = searchParams.get("start"); + const endParam = searchParams.get("end"); + if (startParam && endParam) { + return { + startDate: { + date: new Date(startParam) + }, + endDate: { + date: new Date(endParam) + } + }; + } + + const now = new Date(); + const yesterday = new Date(now.getTime() - 24 * 60 * 60 * 1000); + + return { + startDate: { + date: yesterday + }, + endDate: { + date: now + } + }; + }; + + const [dateRange, setDateRange] = useState<{ + startDate: DateTimeValue; + endDate: DateTimeValue; + }>(getDefaultDateRange()); + + // Trigger search with default values on component mount + useEffect(() => { + const defaultRange = getDefaultDateRange(); + queryDateTime( + defaultRange.startDate, + defaultRange.endDate, + 0, + pageSize + ); + }, [orgId]); // Re-run if orgId changes + + const handleDateRangeChange = ( + startDate: DateTimeValue, + endDate: DateTimeValue + ) => { + setDateRange({ startDate, endDate }); + setCurrentPage(0); // Reset to first page when filtering + // put the search params in the url for the time + updateUrlParamsForAllFilters({ + start: startDate.date?.toISOString() || "", + end: endDate.date?.toISOString() || "" + }); + + queryDateTime(startDate, endDate, 0, pageSize); + }; + + // Handle page changes + const handlePageChange = (newPage: number) => { + setCurrentPage(newPage); + queryDateTime( + dateRange.startDate, + dateRange.endDate, + newPage, + pageSize + ); + }; + + // Handle page size changes + const handlePageSizeChange = (newPageSize: number) => { + setPageSize(newPageSize); + setStoredPageSize(newPageSize, "request-audit-logs"); + setCurrentPage(0); // Reset to first page when changing page size + queryDateTime(dateRange.startDate, dateRange.endDate, 0, newPageSize); + }; + + // Handle filter changes generically + const handleFilterChange = ( + filterType: keyof typeof filters, + value: string | undefined + ) => { + console.log(`${filterType} filter changed:`, value); + + // Create new filters object with updated value + const newFilters = { + ...filters, + [filterType]: value + }; + + setFilters(newFilters); + setCurrentPage(0); // Reset to first page when filtering + + // Update URL params + updateUrlParamsForAllFilters(newFilters); + + // Trigger new query with updated filters (pass directly to avoid async state issues) + queryDateTime( + dateRange.startDate, + dateRange.endDate, + 0, + pageSize, + newFilters + ); + }; + + const updateUrlParamsForAllFilters = ( + newFilters: + | typeof filters + | { + start: string; + end: string; + } + ) => { + const params = new URLSearchParams(searchParams); + Object.entries(newFilters).forEach(([key, value]) => { + if (value) { + params.set(key, value); + } else { + params.delete(key); + } + }); + router.replace(`?${params.toString()}`, { scroll: false }); + }; + + const queryDateTime = async ( + startDate: DateTimeValue, + endDate: DateTimeValue, + page: number = currentPage, + size: number = pageSize, + filtersParam?: { + action?: string; + type?: string; + } + ) => { + console.log("Date range changed:", { startDate, endDate, page, size }); + setIsLoading(true); + + try { + // Use the provided filters or fall back to current state + const activeFilters = filtersParam || filters; + + // Convert the date/time values to API parameters + const params: any = { + limit: size, + offset: page * size, + ...activeFilters + }; + + if (startDate?.date) { + const startDateTime = new Date(startDate.date); + if (startDate.time) { + const [hours, minutes, seconds] = startDate.time + .split(":") + .map(Number); + startDateTime.setHours(hours, minutes, seconds || 0); + } + params.timeStart = startDateTime.toISOString(); + } + + if (endDate?.date) { + const endDateTime = new Date(endDate.date); + if (endDate.time) { + const [hours, minutes, seconds] = endDate.time + .split(":") + .map(Number); + endDateTime.setHours(hours, minutes, seconds || 0); + } else { + // If no time is specified, set to NOW + const now = new Date(); + endDateTime.setHours( + now.getHours(), + now.getMinutes(), + now.getSeconds(), + now.getMilliseconds() + ); + } + params.timeEnd = endDateTime.toISOString(); + } + + const res = await api.get(`/org/${orgId}/logs/request`, { params }); + if (res.status === 200) { + setRows(res.data.data.log || []); + setTotalCount(res.data.data.pagination?.total || 0); + setFilterAttributes(res.data.data.filterAttributes); + console.log("Fetched logs:", res.data); + } + } catch (error) { + toast({ + title: t("error"), + description: t("Failed to filter logs"), + variant: "destructive" + }); + } finally { + setIsLoading(false); + } + }; + + const refreshData = async () => { + console.log("Data refreshed"); + setIsRefreshing(true); + try { + // Refresh data with current date range and pagination + await queryDateTime( + dateRange.startDate, + dateRange.endDate, + currentPage, + pageSize + ); + } catch (error) { + toast({ + title: t("error"), + description: t("refreshError"), + variant: "destructive" + }); + } finally { + setIsRefreshing(false); + } + }; + + const exportData = async () => { + try { + setIsExporting(true); + + // Prepare query params for export + const params: any = { + timeStart: dateRange.startDate?.date + ? new Date(dateRange.startDate.date).toISOString() + : undefined, + timeEnd: dateRange.endDate?.date + ? new Date(dateRange.endDate.date).toISOString() + : undefined, + ...filters + }; + + const response = await api.get( + `/org/${orgId}/logs/request/export`, + { + responseType: "blob", + params + } + ); + + // Create a URL for the blob and trigger a download + const url = window.URL.createObjectURL(new Blob([response.data])); + const link = document.createElement("a"); + link.href = url; + const epoch = Math.floor(Date.now() / 1000); + link.setAttribute( + "download", + `request-audit-logs-${orgId}-${epoch}.csv` + ); + document.body.appendChild(link); + link.click(); + link.parentNode?.removeChild(link); + setIsExporting(false); + } catch (error) { + toast({ + title: t("error"), + description: t("exportError"), + variant: "destructive" + }); + } + }; + + // 100 - Allowed by Rule + // 101 - Allowed No Auth + // 102 - Valid Access Token + // 103 - Valid header auth + // 104 - Valid Pincode + // 105 - Valid Password + // 106 - Valid email + // 107 - Valid SSO + + // 201 - Resource Not Found + // 202 - Resource Blocked + // 203 - Dropped by Rule + // 204 - No Sessions + // 205 - Temporary Request Token + // 299 - No More Auth Methods + + const reasonMap: any = { + 100: t("allowedByRule"), + 101: t("allowedNoAuth"), + 102: t("validAccessToken"), + 103: t("validHeaderAuth"), + 104: t("validPincode"), + 105: t("validPassword"), + 106: t("validEmail"), + 107: t("validSSO"), + 201: t("resourceNotFound"), + 202: t("resourceBlocked"), + 203: t("droppedByRule"), + 204: t("noSessions"), + 205: t("temporaryRequestToken"), + 299: t("noMoreAuthMethods") + }; + + // resourceId: integer("resourceId"), + // userAgent: text("userAgent"), + // metadata: text("details"), + // headers: text("headers"), // JSON blob + // query: text("query"), // JSON blob + // originalRequestURL: text("originalRequestURL"), + // scheme: text("scheme"), + + const columns: ColumnDef[] = [ + { + accessorKey: "timestamp", + header: ({ column }) => { + return t("timestamp"); + }, + cell: ({ row }) => { + return ( +
+ {new Date( + row.original.timestamp * 1000 + ).toLocaleString()} +
+ ); + } + }, + { + accessorKey: "action", + header: ({ column }) => { + return ( +
+ {t("action")} + + handleFilterChange("action", value) + } + // placeholder="" + searchPlaceholder="Search..." + emptyMessage="None found" + /> +
+ ); + }, + cell: ({ row }) => { + return ( + + {row.original.action ? <>Allowed : <>Denied} + + ); + } + }, + { + accessorKey: "ip", + header: ({ column }) => { + return t("ip"); + } + }, + { + accessorKey: "location", + header: ({ column }) => { + return ( +
+ {t("location")} + ({ + value: location, + label: location + }) + )} + selectedValue={filters.location} + onValueChange={(value) => + handleFilterChange("location", value) + } + // placeholder="" + searchPlaceholder="Search..." + emptyMessage="None found" + /> +
+ ); + }, + cell: ({ row }) => { + return ( + + {row.original.location ? ( + + {row.original.location} + + ) : ( + + - + + )} + + ); + } + }, + { + accessorKey: "resourceName", + header: ({ column }) => { + return ( +
+ {t("resource")} + ({ + value: res.id.toString(), + label: res.name || "Unnamed Resource" + }))} + selectedValue={filters.resourceId} + onValueChange={(value) => + handleFilterChange("resourceId", value) + } + // placeholder="" + searchPlaceholder="Search..." + emptyMessage="None found" + /> +
+ ); + }, + cell: ({ row }) => { + return ( + e.stopPropagation()} + > + + + ); + } + }, + { + accessorKey: "host", + header: ({ column }) => { + return ( +
+ {t("host")} + ({ + value: host, + label: host + }))} + selectedValue={filters.host} + onValueChange={(value) => + handleFilterChange("host", value) + } + // placeholder="" + searchPlaceholder="Search..." + emptyMessage="None found" + /> +
+ ); + }, + cell: ({ row }) => { + return ( + + {row.original.tls ? ( + + ) : ( + + )} + {row.original.host} + + ); + } + }, + { + accessorKey: "path", + header: ({ column }) => { + return ( +
+ {t("path")} + ({ + value: path, + label: path + }))} + selectedValue={filters.path} + onValueChange={(value) => + handleFilterChange("path", value) + } + // placeholder="" + searchPlaceholder="Search..." + emptyMessage="None found" + /> +
+ ); + } + }, + + // { + // accessorKey: "scheme", + // header: ({ column }) => { + // return t("scheme"); + // }, + // }, + { + accessorKey: "method", + header: ({ column }) => { + return ( +
+ {t("method")} + + handleFilterChange("method", value) + } + // placeholder="" + searchPlaceholder="Search..." + emptyMessage="None found" + /> +
+ ); + } + }, + { + accessorKey: "reason", + header: ({ column }) => { + return ( +
+ {t("reason")} + + handleFilterChange("reason", value) + } + // placeholder="" + searchPlaceholder="Search..." + emptyMessage="None found" + /> +
+ ); + }, + cell: ({ row }) => { + return ( + + {reasonMap[row.original.reason]} + + ); + } + }, + { + accessorKey: "actor", + header: ({ column }) => { + return ( +
+ {t("actor")} + ({ + value: actor, + label: actor + }))} + selectedValue={filters.actor} + onValueChange={(value) => + handleFilterChange("actor", value) + } + // placeholder="" + searchPlaceholder="Search..." + emptyMessage="None found" + /> +
+ ); + }, + cell: ({ row }) => { + return ( + + {row.original.actor ? ( + <> + {row.original.actorType == "user" ? ( + + ) : ( + + )} + {row.original.actor} + + ) : ( + <>- + )} + + ); + } + } + ]; + + const renderExpandedRow = (row: any) => { + return ( +
+
+
+ User Agent: +

+ {row.userAgent || "N/A"} +

+
+
+ Original URL: +

+ {row.originalRequestURL || "N/A"} +

+
+
+ Scheme: +

+ {row.scheme || "N/A"} +

+
+
+ Metadata: +
+                            {row.metadata
+                                ? JSON.stringify(
+                                      JSON.parse(row.metadata),
+                                      null,
+                                      2
+                                  )
+                                : "N/A"}
+                        
+
+ {row.headers && ( +
+ Headers: +
+                                {JSON.stringify(
+                                    JSON.parse(row.headers),
+                                    null,
+                                    2
+                                )}
+                            
+
+ )} + {row.query && ( +
+ Query Parameters: +
+                                {JSON.stringify(JSON.parse(row.query), null, 2)}
+                            
+
+ )} +
+
+ ); + }; + + return ( + <> + + + + + ); +} \ No newline at end of file diff --git a/src/app/[orgId]/settings/resources/[niceId]/proxy/page.tsx b/src/app/[orgId]/settings/resources/[niceId]/proxy/page.tsx index 9588e0c8..5ef2ccd5 100644 --- a/src/app/[orgId]/settings/resources/[niceId]/proxy/page.tsx +++ b/src/app/[orgId]/settings/resources/[niceId]/proxy/page.tsx @@ -77,7 +77,8 @@ import { MoveRight, ArrowUp, Info, - ArrowDown + ArrowDown, + AlertTriangle } from "lucide-react"; import { ContainersSelector } from "@app/components/ContainersSelector"; import { useTranslations } from "next-intl"; @@ -115,6 +116,7 @@ import { TooltipProvider, TooltipTrigger } from "@app/components/ui/tooltip"; +import { Alert, AlertDescription } from "@app/components/ui/alert"; const addTargetSchema = z .object({ @@ -288,7 +290,9 @@ export default function ReverseProxyTargets(props: { ), headers: z .array(z.object({ name: z.string(), value: z.string() })) - .nullable() + .nullable(), + proxyProtocol: z.boolean().optional(), + proxyProtocolVersion: z.number().int().min(1).max(2).optional() }); const tlsSettingsSchema = z.object({ @@ -325,7 +329,9 @@ export default function ReverseProxyTargets(props: { resolver: zodResolver(proxySettingsSchema), defaultValues: { setHostHeader: resource.setHostHeader || "", - headers: resource.headers + headers: resource.headers, + proxyProtocol: resource.proxyProtocol || false, + proxyProtocolVersion: resource.proxyProtocolVersion || 1 } }); @@ -549,11 +555,11 @@ export default function ReverseProxyTargets(props: { prev.map((t) => t.targetId === target.targetId ? { - ...t, - targetId: response.data.data.targetId, - new: false, - updated: false - } + ...t, + targetId: response.data.data.targetId, + new: false, + updated: false + } : t ) ); @@ -673,11 +679,11 @@ export default function ReverseProxyTargets(props: { targets.map((target) => target.targetId === targetId ? { - ...target, - ...data, - updated: true, - siteType: site ? site.type : target.siteType - } + ...target, + ...data, + updated: true, + siteType: site ? site.type : target.siteType + } : target ) ); @@ -688,10 +694,10 @@ export default function ReverseProxyTargets(props: { targets.map((target) => target.targetId === targetId ? { - ...target, - ...config, - updated: true - } + ...target, + ...config, + updated: true + } : target ) ); @@ -800,6 +806,22 @@ export default function ReverseProxyTargets(props: { setHostHeader: proxyData.setHostHeader || null, headers: proxyData.headers || null }); + } else { + // For TCP/UDP resources, save proxy protocol settings + const proxyData = proxySettingsForm.getValues(); + + const payload = { + proxyProtocol: proxyData.proxyProtocol || false, + proxyProtocolVersion: proxyData.proxyProtocolVersion || 1 + }; + + await api.post(`/resource/${resource.resourceId}`, payload); + + updateResource({ + ...resource, + proxyProtocol: proxyData.proxyProtocol || false, + proxyProtocolVersion: proxyData.proxyProtocolVersion || 1 + }); } toast({ @@ -1064,7 +1086,7 @@ export default function ReverseProxyTargets(props: { className={cn( "w-[180px] justify-between text-sm border-r pr-4 rounded-none h-8 hover:bg-transparent", !row.original.siteId && - "text-muted-foreground" + "text-muted-foreground" )} > @@ -1404,12 +1426,12 @@ export default function ReverseProxyTargets(props: { {header.isPlaceholder ? null : flexRender( - header - .column - .columnDef - .header, - header.getContext() - )} + header + .column + .columnDef + .header, + header.getContext() + )} ) )} @@ -1675,6 +1697,102 @@ export default function ReverseProxyTargets(props: {
)} + {!resource.http && resource.protocol && ( + + + + {t("proxyProtocol")} + + + {t("proxyProtocolDescription")} + + + + +
+ + ( + + + { + field.onChange(val); + }} + /> + + + )} + /> + + {proxySettingsForm.watch("proxyProtocol") && ( + <> + ( + + {t("proxyProtocolVersion")} + + + + + {t("versionDescription")} + + + )} + /> + + + + + {t("warning")}: {t("proxyProtocolWarning")} + + + + )} + + +
+
+
+ )} +
+ ); + }, + cell: ({ row }) => { + const originalRow = row.original; + + return ( +
+ +
+ Olm + {originalRow.olmVersion && ( + + v{originalRow.olmVersion} + + )} +
+
+ {originalRow.olmUpdateAvailable && ( + + )} +
+ ); + } + }, { accessorKey: "subnet", header: ({ column }) => { @@ -282,7 +325,7 @@ export default function ClientsTable({ clients, orgId }: ClientTableProps) { {t("deleteClientQuestion")}

- {t("clientMessageRemove")} + {t("clientMessageRemove")}

} diff --git a/src/components/ColumnFilter.tsx b/src/components/ColumnFilter.tsx new file mode 100644 index 00000000..eee91ecc --- /dev/null +++ b/src/components/ColumnFilter.tsx @@ -0,0 +1,104 @@ +import { useState } from "react"; +import { Button } from "@app/components/ui/button"; +import { Popover, PopoverContent, PopoverTrigger } from "@app/components/ui/popover"; +import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from "@app/components/ui/command"; +import { CheckIcon, ChevronDownIcon, Filter } from "lucide-react"; +import { cn } from "@app/lib/cn"; + +interface FilterOption { + value: string; + label: string; +} + +interface ColumnFilterProps { + options: FilterOption[]; + selectedValue?: string; + onValueChange: (value: string | undefined) => void; + placeholder?: string; + searchPlaceholder?: string; + emptyMessage?: string; + className?: string; +} + +export function ColumnFilter({ + options, + selectedValue, + onValueChange, + placeholder, + searchPlaceholder = "Search...", + emptyMessage = "No options found", + className +}: ColumnFilterProps) { + const [open, setOpen] = useState(false); + + const selectedOption = options.find(option => option.value === selectedValue); + + return ( + + + + + + + + + {emptyMessage} + + {/* Clear filter option */} + {selectedValue && ( + { + onValueChange(undefined); + setOpen(false); + }} + className="text-muted-foreground" + > + Clear filter + + )} + {options.map((option) => ( + { + onValueChange( + selectedValue === option.value ? undefined : option.value + ); + setOpen(false); + }} + > + + {option.label} + + ))} + + + + + + ); +} \ No newline at end of file diff --git a/src/components/CreateDomainForm.tsx b/src/components/CreateDomainForm.tsx index 258aee49..7d614376 100644 --- a/src/components/CreateDomainForm.tsx +++ b/src/components/CreateDomainForm.tsx @@ -11,6 +11,7 @@ import { FormDescription } from "@app/components/ui/form"; import { Input } from "@app/components/ui/input"; +import { Checkbox, CheckboxWithLabel } from "@app/components/ui/checkbox"; import { useToast } from "@app/hooks/useToast"; import { zodResolver } from "@hookform/resolvers/zod"; import { useState, useMemo } from "react"; @@ -45,6 +46,8 @@ import { import { useOrgContext } from "@app/hooks/useOrgContext"; import { build } from "@server/build"; import { toASCII, toUnicode } from 'punycode'; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "./ui/select"; +import { useRouter } from "next/navigation"; // Helper functions for Unicode domain handling @@ -96,7 +99,9 @@ const formSchema = z.object({ .min(1, "Domain is required") .refine((val) => isValidDomainFormat(val), "Invalid domain format") .transform((val) => toPunycode(val)), - type: z.enum(["ns", "cname", "wildcard"]) + type: z.enum(["ns", "cname", "wildcard"]), + certResolver: z.string().nullable().optional(), + preferWildcardCert: z.boolean().optional() }); type FormValues = z.infer; @@ -107,6 +112,12 @@ type CreateDomainFormProps = { onCreated?: (domain: CreateDomainResponse) => void; }; +// Example cert resolver options (replace with real API/fetch if needed) +const certResolverOptions = [ + { id: "default", title: "Default" }, + { id: "custom", title: "Custom Resolver" } +]; + export default function CreateDomainForm({ open, setOpen, @@ -120,20 +131,32 @@ export default function CreateDomainForm({ const { toast } = useToast(); const { org } = useOrgContext(); const { env } = useEnvContext(); + const router = useRouter(); const form = useForm({ resolver: zodResolver(formSchema), defaultValues: { baseDomain: "", - type: build == "oss" || !env.flags.usePangolinDns ? "wildcard" : "ns" + type: build == "oss" || !env.flags.usePangolinDns ? "wildcard" : "ns", + certResolver: null, + preferWildcardCert: false } }); - function reset() { + const baseDomain = form.watch("baseDomain"); + const domainType = form.watch("type"); + + const punycodePreview = useMemo(() => { + if (!baseDomain) return ""; + const punycode = toPunycode(baseDomain); + return punycode !== baseDomain.toLowerCase() ? punycode : ""; + }, [baseDomain]); + + const reset = () => { form.reset(); setLoading(false); setCreatedDomain(null); - } + }; async function onSubmit(values: FormValues) { setLoading(true); @@ -149,6 +172,7 @@ export default function CreateDomainForm({ description: t("domainCreatedDescription") }); onCreated?.(domainData); + router.push(`/${org.org.orgId}/settings/domains/${domainData.domainId}`); } catch (e) { toast({ title: t("error"), @@ -158,17 +182,9 @@ export default function CreateDomainForm({ } finally { setLoading(false); } - } - - const baseDomain = form.watch("baseDomain"); - const domainInputValue = form.watch("baseDomain") || ""; - - const punycodePreview = useMemo(() => { - if (!domainInputValue) return ""; - const punycode = toPunycode(domainInputValue); - return punycode !== domainInputValue.toLowerCase() ? punycode : ""; - }, [domainInputValue]); + }; + // Domain type options let domainOptions: any = []; if (build != "oss" && env.flags.usePangolinDns) { domainOptions = [ @@ -209,7 +225,6 @@ export default function CreateDomainForm({ - {!createdDomain ? (
)} /> + {domainType === "wildcard" && ( + <> + ( + + {t("certResolver")} + + + + + + )} + /> + {form.watch("certResolver") !== null && + form.watch("certResolver") !== "default" && ( + ( + + + field.onChange(e.target.value)} + /> + + + + )} + /> + )} + + {form.watch("certResolver") !== null && + form.watch("certResolver") !== "default" && ( + ( + + + + + {/*
+ + {t("preferWildcardCert")} + +
*/} +
+ )} + /> + )} + + )} - ) : ( -
- - - - {t("createDomainAddDnsRecords")} - - - {t("createDomainAddDnsRecordsDescription")} - - -
- {createdDomain.nsRecords && - createdDomain.nsRecords.length > 0 && ( -
-

- {t("createDomainNsRecords")} -

- - - - {t("createDomainRecord")} - - -
-
- - {t( - "createDomainType" - )} - - - NS - -
-
- - {t( - "createDomainName" - )} - -
- - {fromPunycode(baseDomain)} - - {fromPunycode(baseDomain) !== baseDomain && ( - - ({baseDomain}) - - )} -
-
- - {t( - "createDomainValue" - )} - - {createdDomain.nsRecords.map( - ( - nsRecord, - index - ) => ( -
- -
- ) - )} -
-
-
-
-
- )} - - {createdDomain.cnameRecords && - createdDomain.cnameRecords.length > 0 && ( -
-

- {t("createDomainCnameRecords")} -

- - {createdDomain.cnameRecords.map( - (cnameRecord, index) => ( - - - {t( - "createDomainRecordNumber", - { - number: - index + - 1 - } - )} - - -
-
- - {t( - "createDomainType" - )} - - - CNAME - -
-
- - {t( - "createDomainName" - )} - -
- - {fromPunycode(cnameRecord.baseDomain)} - - {fromPunycode(cnameRecord.baseDomain) !== cnameRecord.baseDomain && ( - - ({cnameRecord.baseDomain}) - - )} -
-
-
- - {t( - "createDomainValue" - )} - - -
-
-
-
- ) - )} -
-
- )} - - {createdDomain.aRecords && - createdDomain.aRecords.length > 0 && ( -
-

- {t("createDomainARecords")} -

- - {createdDomain.aRecords.map( - (aRecord, index) => ( - - - {t( - "createDomainRecordNumber", - { - number: - index + - 1 - } - )} - - -
-
- - {t( - "createDomainType" - )} - - - A - -
-
- - {t( - "createDomainName" - )} - -
- - {fromPunycode(aRecord.baseDomain)} - - {fromPunycode(aRecord.baseDomain) !== aRecord.baseDomain && ( - - ({aRecord.baseDomain}) - - )} -
-
-
- - {t( - "createDomainValue" - )} - - - { - aRecord.value - } - -
-
-
-
- ) - )} -
-
- )} - {createdDomain.txtRecords && - createdDomain.txtRecords.length > 0 && ( -
-

- {t("createDomainTxtRecords")} -

- - {createdDomain.txtRecords.map( - (txtRecord, index) => ( - - - {t( - "createDomainRecordNumber", - { - number: - index + - 1 - } - )} - - -
-
- - {t( - "createDomainType" - )} - - - TXT - -
-
- - {t( - "createDomainName" - )} - -
- - {fromPunycode(txtRecord.baseDomain)} - - {fromPunycode(txtRecord.baseDomain) !== txtRecord.baseDomain && ( - - ({txtRecord.baseDomain}) - - )} -
-
-
- - {t( - "createDomainValue" - )} - - -
-
-
-
- ) - )} -
-
- )} -
- - {build != "oss" && env.flags.usePangolinDns && ( - - - - {t("createDomainSaveTheseRecords")} - - - {t( - "createDomainSaveTheseRecordsDescription" - )} - - - )} - - - - - {t("createDomainDnsPropagation")} - - - {t("createDomainDnsPropagationDescription")} - - -
- )}
diff --git a/src/components/DNSRecordTable.tsx b/src/components/DNSRecordTable.tsx new file mode 100644 index 00000000..8d8e4024 --- /dev/null +++ b/src/components/DNSRecordTable.tsx @@ -0,0 +1,130 @@ +"use client"; + +import { ColumnDef } from "@tanstack/react-table"; +import { useTranslations } from "next-intl"; +import { Badge } from "@app/components/ui/badge"; +import { DNSRecordsDataTable } from "./DNSRecordsDataTable"; + +export type DNSRecordRow = { + id: string; + domainId: string; + recordType: string; // "NS" | "CNAME" | "A" | "TXT" + baseDomain: string | null; + value: string; + verified?: boolean; +}; + +type Props = { + records: DNSRecordRow[]; + domainId: string; + isRefreshing?: boolean; +}; + +export default function DNSRecordsTable({ records, domainId, isRefreshing }: Props) { + const t = useTranslations(); + + const columns: ColumnDef[] = [ + { + accessorKey: "baseDomain", + header: ({ column }) => { + return ( +
+ {t("recordName", { fallback: "Record name" })} +
+ ); + }, + cell: ({ row }) => { + const baseDomain = row.original.baseDomain; + return ( +
+ {baseDomain || "-"} +
+ ); + } + }, + { + accessorKey: "recordType", + header: ({ column }) => { + return ( +
+ {t("type")} +
+ ); + }, + cell: ({ row }) => { + const type = row.original.recordType; + return ( +
+ {type} +
+ ); + } + }, + { + accessorKey: "ttl", + header: ({ column }) => { + return ( +
+ {t("TTL")} +
+ ); + }, + cell: ({ row }) => { + return ( +
+ {t("auto")} +
+ ); + } + }, + { + accessorKey: "value", + header: () => { + return
{t("value")}
; + }, + cell: ({ row }) => { + const value = row.original.value; + return ( +
+ {value} +
+ ); + } + }, + { + accessorKey: "verified", + header: ({ column }) => { + return ( +
+ {t("status")} +
+ ); + }, + cell: ({ row }) => { + const verified = row.original.verified; + return ( + verified ? ( + {t("verified")} + ) : ( + + {t("pending", { fallback: "Pending" })} + + ) + ); + } + } + ]; + + + return ( + + ); +} \ No newline at end of file diff --git a/src/components/DNSRecordsDataTable.tsx b/src/components/DNSRecordsDataTable.tsx new file mode 100644 index 00000000..5d179f30 --- /dev/null +++ b/src/components/DNSRecordsDataTable.tsx @@ -0,0 +1,177 @@ +"use client"; + +import { + ColumnDef, + flexRender, + getCoreRowModel, + useReactTable, + getPaginationRowModel, + getSortedRowModel, + getFilteredRowModel +} from "@tanstack/react-table"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow +} from "@/components/ui/table"; +import { Button } from "@app/components/ui/button"; +import { useMemo, useState } from "react"; +import { ExternalLink, Plus, RefreshCw } from "lucide-react"; +import { + Card, + CardContent, + CardHeader, + CardTitle +} from "@app/components/ui/card"; +import { Tabs, TabsList, TabsTrigger } from "@app/components/ui/tabs"; +import { useTranslations } from "next-intl"; +import { Badge } from "./ui/badge"; + + +type TabFilter = { + id: string; + label: string; + filterFn: (row: any) => boolean; +}; + +type DNSRecordsDataTableProps = { + columns: ColumnDef[]; + data: TData[]; + title?: string; + addButtonText?: string; + onAdd?: () => void; + onRefresh?: () => void; + isRefreshing?: boolean; + searchPlaceholder?: string; + searchColumn?: string; + defaultSort?: { + id: string; + desc: boolean; + }; + tabs?: TabFilter[]; + defaultTab?: string; + persistPageSize?: boolean | string; + defaultPageSize?: number; +}; + +export function DNSRecordsDataTable({ + columns, + data, + title, + addButtonText, + onAdd, + onRefresh, + isRefreshing, + defaultSort, + tabs, + defaultTab, + +}: DNSRecordsDataTableProps) { + const t = useTranslations(); + + const [activeTab, setActiveTab] = useState( + defaultTab || tabs?.[0]?.id || "" + ); + + // Apply tab filter to data + const filteredData = useMemo(() => { + if (!tabs || activeTab === "") { + return data; + } + + const activeTabFilter = tabs.find((tab) => tab.id === activeTab); + if (!activeTabFilter) { + return data; + } + + return data.filter(activeTabFilter.filterFn); + }, [data, tabs, activeTab]); + + const table = useReactTable({ + data: filteredData, + columns, + getCoreRowModel: getCoreRowModel(), + getPaginationRowModel: getPaginationRowModel(), + + getSortedRowModel: getSortedRowModel(), + getFilteredRowModel: getFilteredRowModel(), + }); + + + + + return ( +
+ + +
+
+

{t("dnsRecord")}

+ {t("required")} +
+ +
+
+ + + + {table.getHeaderGroups().map((headerGroup) => ( + + {headerGroup.headers.map((header) => ( + + {header.isPlaceholder + ? null + : flexRender( + header.column.columnDef + .header, + header.getContext() + )} + + ))} + + ))} + + + {table.getRowModel().rows?.length ? ( + table.getRowModel().rows.map((row) => ( + + {row.getVisibleCells().map((cell) => ( + + {flexRender( + cell.column.columnDef.cell, + cell.getContext() + )} + + ))} + + )) + ) : ( + + + {t("noResults")} + + + )} + +
+
+
+
+ ); +} diff --git a/src/components/DataTablePagination.tsx b/src/components/DataTablePagination.tsx index af0a0fe6..70d64f0c 100644 --- a/src/components/DataTablePagination.tsx +++ b/src/components/DataTablePagination.tsx @@ -19,11 +19,21 @@ import { useTranslations } from "next-intl"; interface DataTablePaginationProps { table: Table; onPageSizeChange?: (pageSize: number) => void; + onPageChange?: (pageIndex: number) => void; + totalCount?: number; + isServerPagination?: boolean; + isLoading?: boolean; + disabled?: boolean; } export function DataTablePagination({ table, - onPageSizeChange + onPageSizeChange, + onPageChange, + totalCount, + isServerPagination = false, + isLoading = false, + disabled = false }: DataTablePaginationProps) { const t = useTranslations(); @@ -37,14 +47,60 @@ export function DataTablePagination({ } }; + const handlePageNavigation = (action: 'first' | 'previous' | 'next' | 'last') => { + if (isServerPagination && onPageChange) { + const currentPage = table.getState().pagination.pageIndex; + const pageCount = table.getPageCount(); + + let newPage: number; + switch (action) { + case 'first': + newPage = 0; + break; + case 'previous': + newPage = Math.max(0, currentPage - 1); + break; + case 'next': + newPage = Math.min(pageCount - 1, currentPage + 1); + break; + case 'last': + newPage = pageCount - 1; + break; + default: + return; + } + + if (newPage !== currentPage) { + onPageChange(newPage); + } + } else { + // Use table's built-in navigation for client-side pagination + switch (action) { + case 'first': + table.setPageIndex(0); + break; + case 'previous': + table.previousPage(); + break; + case 'next': + table.nextPage(); + break; + case 'last': + table.setPageIndex(table.getPageCount() - 1); + break; + } + } + }; + return (
+
+
+ + ) : ( + { + handleDateChange(date); + setOpen(false); + }} + /> + )} + + + + + + ); +} + +export interface DateRangePickerProps { + startLabel?: string; + endLabel?: string; + startValue?: DateTimeValue; + endValue?: DateTimeValue; + onStartChange?: (value: DateTimeValue) => void; + onEndChange?: (value: DateTimeValue) => void; + onRangeChange?: (start: DateTimeValue, end: DateTimeValue) => void; + className?: string; + disabled?: boolean; + showTime?: boolean; +} + +export function DateRangePicker({ +// startLabel = "From", +// endLabel = "To", + startValue, + endValue, + onStartChange, + onEndChange, + onRangeChange, + className, + disabled = false, + showTime = true, +}: DateRangePickerProps) { + const handleStartChange = (value: DateTimeValue) => { + onStartChange?.(value); + if (onRangeChange && endValue) { + onRangeChange(value, endValue); + } + }; + + const handleEndChange = (value: DateTimeValue) => { + onEndChange?.(value); + if (onRangeChange && startValue) { + onRangeChange(startValue, value); + } + }; + + return ( +
+ + +
+ ); +} \ No newline at end of file diff --git a/src/components/DomainInfoCard.tsx b/src/components/DomainInfoCard.tsx new file mode 100644 index 00000000..15fe5ce0 --- /dev/null +++ b/src/components/DomainInfoCard.tsx @@ -0,0 +1,397 @@ +"use client"; + +import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"; +import { + InfoSection, + InfoSectionContent, + InfoSections, + InfoSectionTitle +} from "@app/components/InfoSection"; +import { useTranslations } from "next-intl"; +import { useEnvContext } from "@app/hooks/useEnvContext"; +import { useDomainContext } from "@app/hooks/useDomainContext"; +import { SettingsContainer, SettingsSection, SettingsSectionBody, SettingsSectionDescription, SettingsSectionFooter, SettingsSectionForm, SettingsSectionHeader, SettingsSectionTitle } from "./Settings"; +import { Button } from "./ui/button"; +import { + Form, + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage, + FormDescription +} from "@app/components/ui/form"; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "./ui/select"; +import { Input } from "./ui/input"; +import { useForm } from "react-hook-form"; +import z from "zod"; +import { toASCII } from "punycode"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { build } from "@server/build"; +import { Switch } from "./ui/switch"; +import { useEffect, useState } from "react"; +import DNSRecordsTable, { DNSRecordRow } from "./DNSRecordTable"; +import { createApiClient } from "@app/lib/api"; +import { useToast } from "@app/hooks/useToast"; +import { formatAxiosError } from "@app/lib/api"; +import { Badge } from "./ui/badge"; + +type DomainInfoCardProps = { + orgId?: string; + domainId?: string; +}; + +// Helper functions for Unicode domain handling +function toPunycode(domain: string): string { + try { + const parts = toASCII(domain); + return parts; + } catch (error) { + return domain.toLowerCase(); + } +} + + +function isValidDomainFormat(domain: string): boolean { + const unicodeRegex = /^(?!:\/\/)([^\s.]+\.)*[^\s.]+$/; + + if (!unicodeRegex.test(domain)) { + return false; + } + + const parts = domain.split('.'); + for (const part of parts) { + if (part.length === 0 || part.startsWith('-') || part.endsWith('-')) { + return false; + } + if (part.length > 63) { + return false; + } + } + + if (domain.length > 253) { + return false; + } + + return true; +} + +const formSchema = z.object({ + baseDomain: z + .string() + .min(1, "Domain is required") + .refine((val) => isValidDomainFormat(val), "Invalid domain format") + .transform((val) => toPunycode(val)), + type: z.enum(["ns", "cname", "wildcard"]), + certResolver: z.string().nullable().optional(), + preferWildcardCert: z.boolean().optional() +}); + +type FormValues = z.infer; + +const certResolverOptions = [ + { id: "default", title: "Default" }, + { id: "custom", title: "Custom Resolver" } +]; + + +export default function DomainInfoCard({ orgId, domainId }: DomainInfoCardProps) { + const { domain, updateDomain } = useDomainContext(); + const t = useTranslations(); + const { env } = useEnvContext(); + const api = createApiClient(useEnvContext()); + const { toast } = useToast(); + + const [dnsRecords, setDnsRecords] = useState([]); + const [loadingRecords, setLoadingRecords] = useState(true); + const [isRefreshing, setIsRefreshing] = useState(false); + const [saveLoading, setSaveLoading] = useState(false); + + const form = useForm({ + resolver: zodResolver(formSchema), + defaultValues: { + baseDomain: "", + type: build == "oss" || !env.flags.usePangolinDns ? "wildcard" : "ns", + certResolver: domain.certResolver ?? "", + preferWildcardCert: false + } + }); + + useEffect(() => { + if (domain.domainId) { + const certResolverValue = domain.certResolver && domain.certResolver.trim() !== "" + ? domain.certResolver + : null; + + form.reset({ + baseDomain: domain.baseDomain || "", + type: (domain.type as "ns" | "cname" | "wildcard") || "wildcard", + certResolver: certResolverValue, + preferWildcardCert: domain.preferWildcardCert || false + }); + } + }, [domain]); + + const fetchDNSRecords = async (showRefreshing = false) => { + if (showRefreshing) { + setIsRefreshing(true); + } else { + setLoadingRecords(true); + } + + try { + const response = await api.get<{ data: DNSRecordRow[] }>( + `/org/${orgId}/domain/${domainId}/dns-records` + ); + setDnsRecords(response.data.data); + } catch (error) { + // Only show error if records exist (not a 404) + const err = error as any; + if (err?.response?.status !== 404) { + toast({ + title: t("error"), + description: formatAxiosError(error), + variant: "destructive" + }); + } + } finally { + setLoadingRecords(false); + setIsRefreshing(false); + } + }; + + useEffect(() => { + if (domain.domainId) { + fetchDNSRecords(); + } + }, [domain.domainId]); + + const onSubmit = async (values: FormValues) => { + if (!orgId || !domainId) { + toast({ + title: t("error"), + description: t("orgOrDomainIdMissing", { fallback: "Organization or Domain ID is missing" }), + variant: "destructive" + }); + return; + } + + setSaveLoading(true); + + try { + const response = await api.patch( + `/org/${orgId}/domain/${domainId}`, + { + certResolver: values.certResolver, + preferWildcardCert: values.preferWildcardCert + } + ); + + updateDomain({ + ...domain, + certResolver: values.certResolver || null, + preferWildcardCert: values.preferWildcardCert || false + }); + + toast({ + title: t("success"), + description: t("domainSettingsUpdated", { fallback: "Domain settings updated successfully" }), + variant: "default" + }); + } catch (error) { + toast({ + title: t("error"), + description: formatAxiosError(error), + variant: "destructive" + }); + } finally { + setSaveLoading(false); + } + }; + + const getTypeDisplay = (type: string) => { + switch (type) { + case "ns": + return t("selectDomainTypeNsName"); + case "cname": + return t("selectDomainTypeCnameName"); + case "wildcard": + return t("selectDomainTypeWildcardName"); + default: + return type; + } + }; + + + + return ( + <> + + + + + + {t("type")} + + + + {getTypeDisplay(domain.type ? domain.type : "")} + + + + + + {t("status")} + + + {domain.verified ? ( + {t("verified")} + ) : ( + + {t("pending", { fallback: "Pending" })} + + )} + + + + + + + {loadingRecords ? ( +
+ {t("loadingDNSRecords", { fallback: "Loading DNS Records..." })} +
+ ) : ( + + ) + } + + {/* Domain Settings - Only show for wildcard domains */} + {domain.type === "wildcard" && ( + + + + + {t("domainSetting")} + + + + + +
+ + <> + ( + + {t("certResolver")} + + + + + + )} + /> + {form.watch("certResolver") !== null && + form.watch("certResolver") !== "default" && ( + ( + + + field.onChange(e.target.value)} + /> + + + + )} + /> + )} + + {form.watch("certResolver") !== null && + form.watch("certResolver") !== "default" && ( + ( + + +
+ + {t("preferWildcardCert")} +
+
+ + + {t("preferWildcardCertDescription")} + + +
+ )} + /> + )} + + + +
+
+ + + + +
+
+ )} + + ); +} \ No newline at end of file diff --git a/src/components/DomainsTable.tsx b/src/components/DomainsTable.tsx index ca8d2a7c..51aa951a 100644 --- a/src/components/DomainsTable.tsx +++ b/src/components/DomainsTable.tsx @@ -3,7 +3,7 @@ import { ColumnDef } from "@tanstack/react-table"; import { DomainsDataTable } from "@app/components/DomainsDataTable"; import { Button } from "@app/components/ui/button"; -import { ArrowUpDown } from "lucide-react"; +import { ArrowRight, ArrowUpDown, MoreHorizontal } from "lucide-react"; import { useState } from "react"; import ConfirmDeleteDialog from "@app/components/ConfirmDeleteDialog"; import { formatAxiosError } from "@app/lib/api"; @@ -15,6 +15,8 @@ import { useTranslations } from "next-intl"; import CreateDomainForm from "@app/components/CreateDomainForm"; import { useToast } from "@app/hooks/useToast"; import { useOrgContext } from "@app/hooks/useOrgContext"; +import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "./ui/dropdown-menu"; +import Link from "next/link"; export type DomainRow = { domainId: string; @@ -24,13 +26,16 @@ export type DomainRow = { failed: boolean; tries: number; configManaged: boolean; + certResolver: string; + preferWildcardCert: boolean; }; type Props = { domains: DomainRow[]; + orgId: string; }; -export default function DomainsTable({ domains }: Props) { +export default function DomainsTable({ domains, orgId }: Props) { const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false); const [isCreateModalOpen, setIsCreateModalOpen] = useState(false); const [selectedDomain, setSelectedDomain] = useState( @@ -205,12 +210,51 @@ export default function DomainsTable({ domains }: Props) { > {isRestarting ? t("restarting", { - fallback: "Restarting..." - }) + fallback: "Restarting..." + }) : t("restart", { fallback: "Restart" })} )} - + + + + + {t("viewSettings")} + + + { + setSelectedDomain(domain); + setIsDeleteModalOpen(true); + }} + > + + {t("delete")} + + + + + + + + + + {/* + */} ); } diff --git a/src/components/HorizontalTabs.tsx b/src/components/HorizontalTabs.tsx index b529dbba..5b0fdbab 100644 --- a/src/components/HorizontalTabs.tsx +++ b/src/components/HorizontalTabs.tsx @@ -4,10 +4,10 @@ import React from "react"; import Link from "next/link"; import { useParams, usePathname } from "next/navigation"; import { cn } from "@app/lib/cn"; -import { buttonVariants } from "@/components/ui/button"; import { Badge } from "@app/components/ui/badge"; import { useLicenseStatusContext } from "@app/hooks/useLicenseStatusContext"; import { useTranslations } from "next-intl"; +import { useSubscriptionStatusContext } from "@app/hooks/useSubscriptionStatusContext"; export type HorizontalTabs = Array<{ title: string; @@ -30,6 +30,7 @@ export function HorizontalTabs({ const pathname = usePathname(); const params = useParams(); const { licenseStatus, isUnlocked } = useLicenseStatusContext(); + const subscription = useSubscriptionStatusContext(); const t = useTranslations(); function hydrateHref(href: string) { diff --git a/src/components/LogDataTable.tsx b/src/components/LogDataTable.tsx new file mode 100644 index 00000000..58f7623e --- /dev/null +++ b/src/components/LogDataTable.tsx @@ -0,0 +1,529 @@ +"use client"; + +import { + ColumnDef, + flexRender, + getCoreRowModel, + useReactTable, + getPaginationRowModel, + SortingState, + getSortedRowModel, + ColumnFiltersState, + getFilteredRowModel +} from "@tanstack/react-table"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow +} from "@/components/ui/table"; +import { Button } from "@app/components/ui/button"; +import { useEffect, useMemo, useState } from "react"; +import { Input } from "@app/components/ui/input"; +import { DataTablePagination } from "@app/components/DataTablePagination"; +import { + Plus, + Search, + RefreshCw, + Filter, + X, + Download, + ChevronRight, + ChevronDown +} from "lucide-react"; +import { + Card, + CardContent, + CardHeader, + CardTitle +} from "@app/components/ui/card"; +import { Tabs, TabsList, TabsTrigger } from "@app/components/ui/tabs"; +import { useTranslations } from "next-intl"; +import { DateRangePicker, DateTimeValue } from "@app/components/DateTimePicker"; + +const STORAGE_KEYS = { + PAGE_SIZE: "datatable-page-size", + getTablePageSize: (tableId?: string) => + tableId ? `${tableId}-size` : STORAGE_KEYS.PAGE_SIZE +}; + +export const getStoredPageSize = (tableId?: string, defaultSize = 20): number => { + if (typeof window === "undefined") return defaultSize; + + try { + const key = STORAGE_KEYS.getTablePageSize(tableId); + const stored = localStorage.getItem(key); + if (stored) { + const parsed = parseInt(stored, 10); + // Validate that it's a reasonable page size + if (parsed > 0 && parsed <= 1000) { + return parsed; + } + } + } catch (error) { + console.warn("Failed to read page size from localStorage:", error); + } + return defaultSize; +}; + +export const setStoredPageSize = (pageSize: number, tableId?: string): void => { + if (typeof window === "undefined") return; + + try { + const key = STORAGE_KEYS.getTablePageSize(tableId); + localStorage.setItem(key, pageSize.toString()); + } catch (error) { + console.warn("Failed to save page size to localStorage:", error); + } +}; + +type TabFilter = { + id: string; + label: string; + filterFn: (row: any) => boolean; +}; + +type DataTableProps = { + columns: ColumnDef[]; + data: TData[]; + title?: string; + addButtonText?: string; + onRefresh?: () => void; + onExport?: () => void; + isExporting?: boolean; + isRefreshing?: boolean; + searchPlaceholder?: string; + searchColumn?: string; + defaultSort?: { + id: string; + desc: boolean; + }; + tabs?: TabFilter[]; + defaultTab?: string; + disabled?: boolean; + onDateRangeChange?: ( + startDate: DateTimeValue, + endDate: DateTimeValue + ) => void; + dateRange?: { + start: DateTimeValue; + end: DateTimeValue; + }; + // Server-side pagination props + totalCount?: number; + pageSize: number; + currentPage?: number; + onPageChange?: (page: number) => void; + onPageSizeChange?: (pageSize: number) => void; + isLoading?: boolean; + // Row expansion props + expandable?: boolean; + renderExpandedRow?: (row: TData) => React.ReactNode; +}; + +export function LogDataTable({ + columns, + data, + title, + onRefresh, + isRefreshing, + onExport, + isExporting, + // searchPlaceholder = "Search...", + // searchColumn = "name", + defaultSort, + tabs, + defaultTab, + onDateRangeChange, + pageSize, + dateRange, + totalCount, + currentPage = 0, + onPageChange, + onPageSizeChange: onPageSizeChangeProp, + isLoading = false, + expandable = false, + disabled=false, + renderExpandedRow +}: DataTableProps) { + const t = useTranslations(); + + const [sorting, setSorting] = useState( + defaultSort ? [defaultSort] : [] + ); + const [columnFilters, setColumnFilters] = useState([]); + const [globalFilter, setGlobalFilter] = useState([]); + const [activeTab, setActiveTab] = useState( + defaultTab || tabs?.[0]?.id || "" + ); + + const [startDate, setStartDate] = useState( + dateRange?.start || {} + ); + const [endDate, setEndDate] = useState(dateRange?.end || {}); + const [expandedRows, setExpandedRows] = useState>(new Set()); + + // Sync internal date state with external dateRange prop + useEffect(() => { + if (dateRange?.start) { + setStartDate(dateRange.start); + } + if (dateRange?.end) { + setEndDate(dateRange.end); + } + }, [dateRange?.start, dateRange?.end]); + + // Apply tab filter to data + const filteredData = useMemo(() => { + // If disabled, return empty array to prevent data loading + if (disabled) { + return []; + } + + if (!tabs || activeTab === "") { + return data; + } + + const activeTabFilter = tabs.find((tab) => tab.id === activeTab); + if (!activeTabFilter) { + return data; + } + + return data.filter(activeTabFilter.filterFn); + }, [data, tabs, activeTab, disabled]); + + // Toggle row expansion + const toggleRowExpansion = (rowId: string) => { + setExpandedRows((prev) => { + const newSet = new Set(prev); + if (newSet.has(rowId)) { + newSet.delete(rowId); + } else { + newSet.add(rowId); + } + return newSet; + }); + }; + + // Determine if using server-side pagination + const isServerPagination = totalCount !== undefined; + + // Create columns with expansion column if expandable + const enhancedColumns = useMemo(() => { + if (!expandable) { + return columns; + } + + const expansionColumn: ColumnDef = { + id: "expand", + header: () => null, + cell: ({ row }) => { + const isExpanded = expandedRows.has(row.id); + return ( + + ); + }, + size: 40 + }; + + return [expansionColumn, ...columns]; + }, [columns, expandable, expandedRows, toggleRowExpansion, disabled]); + + const table = useReactTable({ + data: filteredData, + columns: enhancedColumns, + getCoreRowModel: getCoreRowModel(), + // Only use client-side pagination if totalCount is not provided + ...(isServerPagination + ? {} + : { getPaginationRowModel: getPaginationRowModel() }), + onSortingChange: setSorting, + getSortedRowModel: getSortedRowModel(), + onColumnFiltersChange: setColumnFilters, + getFilteredRowModel: getFilteredRowModel(), + onGlobalFilterChange: setGlobalFilter, + // Configure pagination state + ...(isServerPagination + ? { + manualPagination: true, + pageCount: totalCount ? Math.ceil(totalCount / pageSize) : 0 + } + : {}), + initialState: { + pagination: { + pageSize: pageSize, + pageIndex: currentPage + } + }, + state: { + sorting, + columnFilters, + globalFilter, + pagination: { + pageSize: pageSize, + pageIndex: currentPage + } + } + }); + + // useEffect(() => { + // const currentPageSize = table.getState().pagination.pageSize; + // if (currentPageSize !== pageSize) { + // table.setPageSize(pageSize); + + // // Persist to localStorage if enabled + // if (persistPageSize) { + // setStoredPageSize(pageSize, tableId); + // } + // } + // }, [pageSize, table, persistPageSize, tableId]); + + // Update table page index when currentPage prop changes (server pagination) + useEffect(() => { + if (isServerPagination) { + const currentPageIndex = table.getState().pagination.pageIndex; + if (currentPageIndex !== currentPage) { + table.setPageIndex(currentPage); + } + } + }, [currentPage, table, isServerPagination]); + + const handleTabChange = (value: string) => { + if (disabled) return; + + setActiveTab(value); + // Reset to first page when changing tabs + table.setPageIndex(0); + }; + + // Enhanced pagination component that updates our local state + const handlePageSizeChange = (newPageSize: number) => { + if (disabled) return; + + // setPageSize(newPageSize); + table.setPageSize(newPageSize); + + // Persist immediately when changed + // if (persistPageSize) { + // setStoredPageSize(newPageSize, tableId); + // } + + // For server pagination, notify parent component + if (isServerPagination && onPageSizeChangeProp) { + onPageSizeChangeProp(newPageSize); + } + }; + + // Handle page changes for server pagination + const handlePageChange = (newPageIndex: number) => { + if (disabled) return; + + if (isServerPagination && onPageChange) { + onPageChange(newPageIndex); + } + }; + + const handleDateRangeChange = ( + start: DateTimeValue, + end: DateTimeValue + ) => { + if (disabled) return; + + setStartDate(start); + setEndDate(end); + onDateRangeChange?.(start, end); + }; + + return ( +
+ + +
+ {/*
+ + table.setGlobalFilter( + String(e.target.value) + ) + } + className="w-full pl-8 m-0" + /> + +
*/} + +
+
+ {onRefresh && ( + + )} + {onExport && ( + + )} +
+
+ + + + {table.getHeaderGroups().map((headerGroup) => ( + + {headerGroup.headers.map((header) => ( + + {header.isPlaceholder + ? null + : flexRender( + header.column.columnDef + .header, + header.getContext() + )} + + ))} + + ))} + + + {table.getRowModel().rows?.length ? ( + table.getRowModel().rows.map((row) => { + const isExpanded = + expandable && expandedRows.has(row.id); + return [ + + expandable && !disabled + ? toggleRowExpansion( + row.id + ) + : undefined + } + className="text-xs" // made smaller + > + {row + .getVisibleCells() + .map((cell) => { + const originalRow = + row.original as any; + const actionValue = + originalRow?.action; + let className = ""; + + if ( + typeof actionValue === + "boolean" + ) { + className = + actionValue + ? "bg-green-100 dark:bg-green-900/50" + : "bg-red-100 dark:bg-red-900/50"; + } + + return ( + + {flexRender( + cell.column + .columnDef + .cell, + cell.getContext() + )} + + ); + })} + , + isExpanded && + renderExpandedRow && ( + + + {renderExpandedRow( + row.original + )} + + + ) + ].filter(Boolean); + }).flat() + ) : ( + + + No results found. + + + )} + +
+
+ +
+
+
+
+ ); +} diff --git a/src/components/LoginForm.tsx b/src/components/LoginForm.tsx index c55ad871..8149989e 100644 --- a/src/components/LoginForm.tsx +++ b/src/components/LoginForm.tsx @@ -22,7 +22,7 @@ import { CardTitle } from "@app/components/ui/card"; import { Alert, AlertDescription } from "@app/components/ui/alert"; -import { useRouter } from "next/navigation"; +import { useParams, useRouter } from "next/navigation"; import { LockIcon, FingerprintIcon } from "lucide-react"; import { createApiClient } from "@app/lib/api"; import { @@ -74,6 +74,8 @@ export default function LoginForm({ const { env } = useEnvContext(); const api = createApiClient({ env }); + const { resourceGuid } = useParams(); + const [error, setError] = useState(null); const [loading, setLoading] = useState(false); const hasIdp = idps && idps.length > 0; @@ -235,7 +237,8 @@ export default function LoginForm({ const response = await loginProxy({ email, password, - code + code, + resourceGuid: resourceGuid as string }); try { diff --git a/src/components/ui/calendar.tsx b/src/components/ui/calendar.tsx new file mode 100644 index 00000000..f27bab31 --- /dev/null +++ b/src/components/ui/calendar.tsx @@ -0,0 +1,213 @@ +"use client"; + +import * as React from "react"; +import { + ChevronDownIcon, + ChevronLeftIcon, + ChevronRightIcon, +} from "lucide-react"; +import { DayButton, DayPicker, getDefaultClassNames } from "react-day-picker"; + +import { Button, buttonVariants } from "@/components/ui/button"; +import { cn } from "@app/lib/cn"; + +function Calendar({ + className, + classNames, + showOutsideDays = true, + captionLayout = "label", + buttonVariant = "ghost", + formatters, + components, + ...props +}: React.ComponentProps & { + buttonVariant?: React.ComponentProps["variant"] +}) { + const defaultClassNames = getDefaultClassNames(); + + return ( + svg]:rotate-180`, + String.raw`rtl:**:[.rdp-button\_previous>svg]:rotate-180`, + className + )} + captionLayout={captionLayout} + formatters={{ + formatMonthDropdown: (date) => + date.toLocaleString("default", { month: "short" }), + ...formatters, + }} + classNames={{ + root: cn("w-fit", defaultClassNames.root), + months: cn( + "relative flex flex-col gap-4 md:flex-row", + defaultClassNames.months + ), + month: cn("flex w-full flex-col gap-4", defaultClassNames.month), + nav: cn( + "absolute inset-x-0 top-0 flex w-full items-center justify-between gap-1", + defaultClassNames.nav + ), + button_previous: cn( + buttonVariants({ variant: buttonVariant }), + "h-[--cell-size] w-[--cell-size] select-none p-0 aria-disabled:opacity-50", + defaultClassNames.button_previous + ), + button_next: cn( + buttonVariants({ variant: buttonVariant }), + "h-[--cell-size] w-[--cell-size] select-none p-0 aria-disabled:opacity-50", + defaultClassNames.button_next + ), + month_caption: cn( + "flex h-[--cell-size] w-full items-center justify-center px-[--cell-size]", + defaultClassNames.month_caption + ), + dropdowns: cn( + "flex h-[--cell-size] w-full items-center justify-center gap-1.5 text-sm font-medium", + defaultClassNames.dropdowns + ), + dropdown_root: cn( + "has-focus:border-ring border-input shadow-xs has-focus:ring-ring/50 has-focus:ring-[3px] relative rounded-md border", + defaultClassNames.dropdown_root + ), + dropdown: cn( + "bg-popover absolute inset-0 opacity-0", + defaultClassNames.dropdown + ), + caption_label: cn( + "select-none font-medium", + captionLayout === "label" + ? "text-sm" + : "[&>svg]:text-muted-foreground flex h-8 items-center gap-1 rounded-md pl-2 pr-1 text-sm [&>svg]:size-3.5", + defaultClassNames.caption_label + ), + table: "w-full border-collapse", + weekdays: cn("flex", defaultClassNames.weekdays), + weekday: cn( + "text-muted-foreground flex-1 select-none rounded-md text-[0.8rem] font-normal", + defaultClassNames.weekday + ), + week: cn("mt-2 flex w-full", defaultClassNames.week), + week_number_header: cn( + "w-[--cell-size] select-none", + defaultClassNames.week_number_header + ), + week_number: cn( + "text-muted-foreground select-none text-[0.8rem]", + defaultClassNames.week_number + ), + day: cn( + "group/day relative aspect-square h-full w-full select-none p-0 text-center [&:first-child[data-selected=true]_button]:rounded-l-md [&:last-child[data-selected=true]_button]:rounded-r-md", + defaultClassNames.day + ), + range_start: cn( + "bg-accent rounded-l-md", + defaultClassNames.range_start + ), + range_middle: cn("rounded-none", defaultClassNames.range_middle), + range_end: cn("bg-accent rounded-r-md", defaultClassNames.range_end), + today: cn( + "bg-accent text-accent-foreground rounded-md data-[selected=true]:rounded-none", + defaultClassNames.today + ), + outside: cn( + "text-muted-foreground aria-selected:text-muted-foreground", + defaultClassNames.outside + ), + disabled: cn( + "text-muted-foreground opacity-50", + defaultClassNames.disabled + ), + hidden: cn("invisible", defaultClassNames.hidden), + ...classNames, + }} + components={{ + Root: ({ className, rootRef, ...props }) => { + return ( +
+ ); + }, + Chevron: ({ className, orientation, ...props }) => { + if (orientation === "left") { + return ( + + ); + } + + if (orientation === "right") { + return ( + + ); + } + + return ( + + ); + }, + DayButton: CalendarDayButton, + WeekNumber: ({ children, ...props }) => { + return ( + +
+ {children} +
+ + ); + }, + ...components, + }} + {...props} + /> + ); +} + +function CalendarDayButton({ + className, + day, + modifiers, + ...props +}: React.ComponentProps) { + const defaultClassNames = getDefaultClassNames(); + + const ref = React.useRef(null); + React.useEffect(() => { + if (modifiers.focused) ref.current?.focus(); + }, [modifiers.focused]); + + return ( +