diff --git a/.github/ISSUE_TEMPLATE/1.bug_report.yml b/.github/ISSUE_TEMPLATE/1.bug_report.yml index c945608..1b8084a 100644 --- a/.github/ISSUE_TEMPLATE/1.bug_report.yml +++ b/.github/ISSUE_TEMPLATE/1.bug_report.yml @@ -34,6 +34,14 @@ body: validations: required: true + - type: textarea + attributes: + label: AI Disclosure + description: | + If you used AI to help write this issue, please disclose it here. This is important for transparency and helps maintain the integrity of the issue tracking process. + validations: + required: true + - type: textarea attributes: label: Expected Behavior diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md index aeee133..6e13337 100644 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -4,6 +4,10 @@ perpetual license to use, modify, and redistribute these contributions under any choose, including both the AGPLv3 and the Fossorial Commercial license terms. I represent that I have the right to grant this license for all contributed content. +## AI Disclosure + +> Please disclose how AI was used in this pull request. The use of AI does not preclude this from being merged but is an important factor in how we review your request. + ## Description diff --git a/.github/workflows/linting.yml b/.github/workflows/linting.yml index 2315a9c..4ac0ca8 100644 --- a/.github/workflows/linting.yml +++ b/.github/workflows/linting.yml @@ -1,17 +1,18 @@ name: ESLint -on: - pull_request: - paths: - - '**/*.js' - - '**/*.jsx' - - '**/*.ts' - - '**/*.tsx' - - '.eslintrc*' - - 'package.json' - - 'yarn.lock' - - 'pnpm-lock.yaml' - - 'package-lock.json' +# disable until eslint is updated +# on: +# pull_request: +# paths: +# - '**/*.js' +# - '**/*.jsx' +# - '**/*.ts' +# - '**/*.tsx' +# - '.eslintrc*' +# - 'package.json' +# - 'yarn.lock' +# - 'pnpm-lock.yaml' +# - 'package-lock.json' jobs: Linter: diff --git a/install/main.go b/install/main.go index 2646c35..5885720 100644 --- a/install/main.go +++ b/install/main.go @@ -3,6 +3,7 @@ package main import ( "bufio" "embed" + "flag" "fmt" "io" "io/fs" @@ -50,6 +51,9 @@ const ( func main() { + panelURL := flag.String("pangolin-endpoint", DefaultPanelURL, "Base URL of the Pangolin server to fetch node credentials from. Only needed if you are not using Pangolin Cloud (app.pangolin.net).") + flag.Parse() + // print a banner about prerequisites - opening port 80, 443, 51820, and 21820 on the VPS and firewall and pointing your domain to the VPS IP with a records. Docs are at http://localhost:3000/Getting%20Started/dns-networking fmt.Println("Welcome to the Pangolin Remote Node installer!") @@ -85,7 +89,7 @@ func main() { // If the secret and id are not generated then generate them if config.HybridId == "" || config.HybridSecret == "" { // fmt.Println("Requesting hybrid credentials from cloud...") - credentials, err := requestHybridCredentials() + credentials, err := requestHybridCredentials(*panelURL) if err != nil { fmt.Printf("Error requesting hybrid credentials: %v\n", err) fmt.Println("Please obtain credentials manually from the dashboard and run the installer again.") @@ -96,7 +100,7 @@ func main() { fmt.Printf("Your managed credentials have been obtained successfully.\n") fmt.Printf(" ID: %s\n", config.HybridId) fmt.Printf(" Secret: %s\n", config.HybridSecret) - fmt.Print("\nTake these to the Pangolin dashboard https://app.pangolin.net to adopt your node.\n\n") + fmt.Printf("\nTake these to the Pangolin dashboard %s to adopt your node.\n\n", *panelURL) readBool(reader, "Have you adopted your node?", true) } diff --git a/install/quickStart.go b/install/quickStart.go index 11ed355..5533351 100644 --- a/install/quickStart.go +++ b/install/quickStart.go @@ -7,13 +7,15 @@ import ( "fmt" "io" "net/http" + "strings" "time" ) const ( FRONTEND_SECRET_KEY = "af4e4785-7e09-11f0-b93a-74563c4e2a7e" - // CLOUD_API_URL = "https://pangolin.fossorial.io/api/v1/remote-exit-node/quick-start" - CLOUD_API_URL = "https://app.pangolin.net/api/v1/remote-exit-node/quick-start" + + // DefaultPanelURL is Pangolin Cloud, used unless the --pangolin-endpoint flag points elsewhere. + DefaultPanelURL = "https://app.pangolin.net" ) // HybridCredentials represents the response from the cloud API @@ -42,9 +44,10 @@ func generateValidationToken() string { return base64.StdEncoding.EncodeToString(obfuscated) } -// requestHybridCredentials makes an HTTP POST request to the cloud API -// to get hybrid credentials (ID and secret) -func requestHybridCredentials() (*HybridCredentials, error) { +// requestHybridCredentials makes an HTTP POST request to the panel API +// to get hybrid credentials (ID and secret). panelURL is the base URL of +// the Pangolin panel (Pangolin Cloud by default, or a self-hosted instance). +func requestHybridCredentials(panelURL string) (*HybridCredentials, error) { // Generate validation token token := generateValidationToken() @@ -60,7 +63,8 @@ func requestHybridCredentials() (*HybridCredentials, error) { } // Create HTTP request - req, err := http.NewRequest("POST", CLOUD_API_URL, bytes.NewBuffer(jsonData)) + apiURL := strings.TrimRight(panelURL, "/") + "/api/v1/remote-exit-node/quick-start" + req, err := http.NewRequest("POST", apiURL, bytes.NewBuffer(jsonData)) if err != nil { return nil, fmt.Errorf("failed to create HTTP request: %v", err) } diff --git a/package-lock.json b/package-lock.json index 45e51aa..570bf24 100644 --- a/package-lock.json +++ b/package-lock.json @@ -21,9 +21,8 @@ "helmet": "8.3.0", "http-errors": "2.0.1", "js-yaml": "5.4.3", - "node-cache": "5.1.2", + "lru-cache": "11.5.2", "pg": "8.23.1", - "react": "19.3.0", "semver": "7.8.5", "winston": "3.19.0", "winston-daily-rotate-file": "5.0.0", @@ -44,8 +43,7 @@ "esbuild": "0.28.2", "esbuild-node-externals": "2.0.0", "tsx": "4.23.15", - "typescript": "7.0.2", - "typescript-eslint": "8.71.1" + "typescript": "7.0.2" } }, "node_modules/@babel/code-frame": { @@ -142,6 +140,15 @@ "node": ">=6.9.0" } }, + "node_modules/@babel/helper-compilation-targets/node_modules/lru-cache": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", + "license": "ISC", + "dependencies": { + "yallist": "^3.0.2" + } + }, "node_modules/@babel/helper-compilation-targets/node_modules/semver": { "version": "6.3.1", "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", @@ -2826,15 +2833,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/clone": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/clone/-/clone-2.1.2.tgz", - "integrity": "sha512-3Pe/CF1Nn94hyhIYpjtiLhdCoEoz0DqQ+988E9gmeEdQZlojxnOb74wctFyuwWQHzqyf9X7C7MG8juUpqBJT8w==", - "license": "MIT", - "engines": { - "node": ">=0.8" - } - }, "node_modules/color": { "version": "5.0.3", "resolved": "https://registry.npmjs.org/color/-/color-5.0.3.tgz", @@ -5395,12 +5393,12 @@ } }, "node_modules/lru-cache": { - "version": "5.1.1", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", - "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", - "license": "ISC", - "dependencies": { - "yallist": "^3.0.2" + "version": "11.5.2", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.2.tgz", + "integrity": "sha512-4pfM1Ff0x50o0tQwb5ucw/RzNyD0/YJME6IVcStalZuMWxdt3sR3huStTtxz4PUmvZfRguvDejasvQ2kifR11g==", + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" } }, "node_modules/math-intrinsics": { @@ -5560,18 +5558,6 @@ "url": "https://opencollective.com/express" } }, - "node_modules/node-cache": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/node-cache/-/node-cache-5.1.2.tgz", - "integrity": "sha512-t1QzWwnk4sjLWaQAS8CHgOJ+RAfmHpxFWmc36IWTiWHQfs0w5JDMBS1b1ZxQteo0vVVuWJvIUKHDkkeK7vIGCg==", - "license": "MIT", - "dependencies": { - "clone": "2.x" - }, - "engines": { - "node": ">= 8.0.0" - } - }, "node_modules/node-exports-info": { "version": "1.6.2", "resolved": "https://registry.npmjs.org/node-exports-info/-/node-exports-info-1.6.2.tgz", @@ -6162,15 +6148,6 @@ "node": ">= 0.10" } }, - "node_modules/react": { - "version": "19.3.0", - "resolved": "https://registry.npmjs.org/react/-/react-19.3.0.tgz", - "integrity": "sha512-E8LUcbtBWt20bbl2YoHfx4ZDBdxVTfOKtCZn9cDSJ4l6/nuoApcpIBcj47t2wZoVX8g2ZHuMHbiShgCR1T5Sog==", - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/react-is": { "version": "16.13.1", "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", diff --git a/package.json b/package.json index ca12817..237b52a 100644 --- a/package.json +++ b/package.json @@ -27,10 +27,9 @@ "express-rate-limit": "8.7.1", "helmet": "8.3.0", "http-errors": "2.0.1", + "lru-cache": "11.5.2", "js-yaml": "5.4.3", - "node-cache": "5.1.2", "pg": "8.23.1", - "react": "19.3.0", "semver": "7.8.5", "winston": "3.19.0", "winston-daily-rotate-file": "5.0.0", @@ -51,7 +50,6 @@ "esbuild": "0.28.2", "esbuild-node-externals": "2.0.0", "tsx": "4.23.15", - "typescript": "7.0.2", - "typescript-eslint": "8.71.1" + "typescript": "7.0.2" } } diff --git a/server/auth/sessions/resource.ts b/server/auth/sessions/resource.ts index 0da0992..5c72279 100644 --- a/server/auth/sessions/resource.ts +++ b/server/auth/sessions/resource.ts @@ -78,4 +78,6 @@ export async function createAccessTokenResourceSession( export type ResourceSessionValidationResult = { resourceSession: ResourceSession | null; + // Email of the whitelist entry the session was issued for, if any + whitelistEmail?: string | null; }; diff --git a/server/lib/consts.ts b/server/lib/consts.ts index 97b9c42..aa1e3a1 100644 --- a/server/lib/consts.ts +++ b/server/lib/consts.ts @@ -2,7 +2,7 @@ import path from "path"; import { fileURLToPath } from "url"; // This is a placeholder value replaced by the build process -export const APP_VERSION = "1.1.2"; +export const APP_VERSION = "1.6.0"; export const __FILENAME = fileURLToPath(import.meta.url); export const __DIRNAME = path.dirname(__FILENAME); diff --git a/server/lib/createLocalCache.ts b/server/lib/createLocalCache.ts new file mode 100644 index 0000000..f37fc48 --- /dev/null +++ b/server/lib/createLocalCache.ts @@ -0,0 +1,79 @@ +import { LRUCache } from "lru-cache"; + +const DEFAULT_MAX_KEYS = 10000; +const DEFAULT_TTL_MS = 3600 * 1000; + +export type LocalCache = { + get(key: string): T | undefined; + set(key: string, value: unknown, ttlSeconds?: number): boolean; + del(key: string | string[]): number; + has(key: string): boolean; + keys(): string[]; + flushAll(): void; + getStats(): { keys: number }; + getTtl(key: string): number | undefined; +}; + +export function createLocalCache(max = DEFAULT_MAX_KEYS): LocalCache { + const lru = new LRUCache({ + max, + ttl: DEFAULT_TTL_MS, + updateAgeOnGet: false + }); + + return { + get(key: string): T | undefined { + return lru.get(key) as T | undefined; + }, + + set(key: string, value: unknown, ttlSeconds?: number): boolean { + const stored = value as {}; + if (ttlSeconds === undefined) { + lru.set(key, stored); + } else if (ttlSeconds === 0) { + lru.set(key, stored, { ttl: 0 }); + } else { + lru.set(key, stored, { ttl: ttlSeconds * 1000 }); + } + return true; + }, + + del(key: string | string[]): number { + const keys = Array.isArray(key) ? key : [key]; + let deleted = 0; + for (const k of keys) { + if (lru.delete(k)) { + deleted++; + } + } + return deleted; + }, + + has(key: string): boolean { + return lru.has(key); + }, + + keys(): string[] { + return [...lru.keys()]; + }, + + flushAll(): void { + lru.clear(); + }, + + getStats(): { keys: number } { + return { keys: lru.size }; + }, + + getTtl(key: string): number | undefined { + if (!lru.has(key)) { + return undefined; + } + const remaining = lru.getRemainingTTL(key); + if (!Number.isFinite(remaining)) { + return 0; + } + return Date.now() + remaining; + } + }; +} diff --git a/server/lib/pathMatch.ts b/server/lib/pathMatch.ts new file mode 100644 index 0000000..900073d --- /dev/null +++ b/server/lib/pathMatch.ts @@ -0,0 +1,114 @@ +// Matches a single segment against a glob where `*` matches any run of +// characters and `?` matches exactly one. Greedy two-pointer matching that +// only ever backtracks to the most recent `*`, so it runs in +// O(pattern * text) worst case. Compiling to a `.*` regex instead +// backtracks catastrophically on patterns like `*a*a*a*a*b`. +function matchSegmentGlob(pattern: string, text: string): boolean { + let p = 0; + let t = 0; + let starP = -1; + let starT = 0; + + while (t < text.length) { + if (p < pattern.length && pattern[p] === "*") { + starP = p++; + starT = t; + } else if ( + p < pattern.length && + (pattern[p] === "?" || pattern[p] === text[t]) + ) { + p++; + t++; + } else if (starP !== -1) { + // let the last `*` swallow one more character and retry + p = starP + 1; + t = ++starT; + } else { + return false; + } + } + + while (p < pattern.length && pattern[p] === "*") { + p++; + } + return p === pattern.length; +} + +// Decodes percent-encoding (so an encoded slash like `%2F` is treated as a +// real path separator, matching what most backends will do) and then +// resolves `.` / `..` segments, so a request like `/public%2F..%2Fadmin/` +// or `/public/../admin/` is matched as `/admin/`, not as a literal segment +// or a wildcard-swallowed sequence under `/public/*`. +// +// Applied to both the request path and the rule pattern: the pattern +// validator only accepts spaces / non-ASCII in percent-encoded form, so a +// rule like `/my%20docs/*` must be compared against the decoded segment +// `my docs`, not the literal text `my%20docs`. +function decodeAndResolvePath(p: string): string[] { + const rawParts = p.split("/").filter(Boolean); + + const resolved: string[] = []; + for (const rawPart of rawParts) { + let part: string; + try { + part = decodeURIComponent(rawPart); + } catch { + part = rawPart; + } + + // an encoded slash can turn one raw segment into several real ones + for (const segment of part.split("/").filter(Boolean)) { + if (segment === ".") { + continue; + } else if (segment === "..") { + resolved.pop(); + } else { + resolved.push(segment); + } + } + } + + return resolved; +} + +export function isPathAllowed(pattern: string, path: string): boolean { + const patternParts = decodeAndResolvePath(pattern); + const pathParts = decodeAndResolvePath(path); + + // Iterative dynamic-programming matcher. The previous recursive + // implementation forked on every bare `*`, which backtracked + // exponentially and let an unauthenticated path rule pin the event loop. + // `dp[i][j]` is true when patternParts[i..] matches pathParts[j..]. + const m = patternParts.length; + const n = pathParts.length; + const dp: boolean[][] = Array.from({ length: m + 1 }, () => + new Array(n + 1).fill(false) + ); + + dp[m][n] = true; + for (let j = 0; j < n; j++) { + dp[m][j] = false; + } + for (let i = m - 1; i >= 0; i--) { + dp[i][n] = patternParts.slice(i).every((p) => p === "*"); + } + + for (let i = m - 1; i >= 0; i--) { + const currentPatternPart = patternParts[i]; + for (let j = n - 1; j >= 0; j--) { + if (currentPatternPart === "*") { + // match zero segments, or consume one path segment + dp[i][j] = dp[i + 1][j] || dp[i][j + 1]; + } else if (currentPatternPart.includes("*")) { + dp[i][j] = + dp[i + 1][j + 1] && + matchSegmentGlob(currentPatternPart, pathParts[j]); + } else { + dp[i][j] = + currentPatternPart === pathParts[j] && dp[i + 1][j + 1]; + } + } + } + + return dp[0][0]; +} diff --git a/server/lib/readConfigFile.ts b/server/lib/readConfigFile.ts index 00ccb69..8d5b6de 100644 --- a/server/lib/readConfigFile.ts +++ b/server/lib/readConfigFile.ts @@ -48,7 +48,6 @@ export const configSchema = z.object({ .optional() .default("/var/dynamic/router_config.yml") }) - .optional() .prefault({}), gerbil: z.object({ base_endpoint: z.string(), diff --git a/server/lib/traefik/TraefikConfigManager.ts b/server/lib/traefik/TraefikConfigManager.ts index d0fdc3c..fad6ae0 100644 --- a/server/lib/traefik/TraefikConfigManager.ts +++ b/server/lib/traefik/TraefikConfigManager.ts @@ -1,5 +1,6 @@ import * as fs from "fs"; import * as path from "path"; +import { createHash } from "crypto"; import config from "@server/lib/config"; import logger from "@server/logger"; import * as yaml from "js-yaml"; @@ -8,6 +9,10 @@ import { tokenManager } from "../tokenManager"; import { getValidCertificatesForDomainsHybrid } from "@server/lib/certificates"; import { sendToExitNode } from "@server/lib/exitNodes"; +// Flat files in the certificates dir; the per-domain directory scan and +// cleanup only look at directories, so these don't collide with them. +const MTLS_CA_FILE_PREFIX = "mtls-ca-"; + export class TraefikConfigManager { private intervalId: NodeJS.Timeout | null = null; private isRunning = false; @@ -15,6 +20,7 @@ export class TraefikConfigManager { private timeoutId: NodeJS.Timeout | null = null; private lastCertificateFetch: Date | null = null; private lastKnownDomains = new Set(); + private pendingDeletion = new Map(); // domain -> cycles remaining before delete private lastLocalCertificateState = new Map< string, { @@ -288,7 +294,7 @@ export class TraefikConfigManager { localState.expiresAt - nowInSeconds; const daysUntilExpiry = secondsUntilExpiry / (60 * 60 * 24); - if (daysUntilExpiry < 45) { + if (daysUntilExpiry < 15) { logger.info( `Fetching certificates due to upcoming expiry for ${domain} (${Math.round(daysUntilExpiry)} days remaining)` ); @@ -312,7 +318,7 @@ export class TraefikConfigManager { state.expiresAt - nowInSeconds; const daysUntilExpiry = secondsUntilExpiry / (60 * 60 * 24); - if (daysUntilExpiry < 45) { + if (daysUntilExpiry < 15) { logger.info( `Fetching certificates due to upcoming expiry for wildcard cert ${certDomain} (${Math.round(daysUntilExpiry)} days remaining)` ); @@ -472,8 +478,18 @@ export class TraefikConfigManager { // wait 1 second for traefik to pick up the new certificates await new Promise((resolve) => setTimeout(resolve, 500)); + // Swap inline mTLS CA PEMs for files in the certificates dir + const mtlsCaFiles = this.writeMtlsCaFiles(traefikConfig); + // Write traefik config as YAML to a second dynamic config file if changed - await this.writeTraefikDynamicConfig(traefikConfig); + const dynamicConfigWritten = + await this.writeTraefikDynamicConfig(traefikConfig); + + // Only remove stale CA files once the config on disk no longer + // references them + if (dynamicConfigWritten) { + this.cleanupUnusedMtlsCaFiles(mtlsCaFiles); + } // Send domains to SNI proxy await sendToExitNode({ @@ -620,7 +636,9 @@ export class TraefikConfigManager { /** * Write traefik config as YAML to a second dynamic config file if changed */ - private async writeTraefikDynamicConfig(traefikConfig: any): Promise { + private async writeTraefikDynamicConfig( + traefikConfig: any + ): Promise { const traefikDynamicConfigPath = config.getRawConfig().traefik.dynamic_router_config_path; let shouldWrite = false; @@ -644,18 +662,111 @@ export class TraefikConfigManager { } if (shouldWrite) { try { - fs.writeFileSync( + this.atomicWriteFileSync( traefikDynamicConfigPath, - yaml.dump(traefikConfig, { noRefs: true }), - "utf8" + yaml.dump(traefikConfig, { noRefs: true }) ); logger.info("Traefik dynamic config updated"); } catch (err) { logger.error("Failed to write traefik dynamic config:", err); + return false; } } + return true; + } + /** + * Replace inline PEM entries in tls.options[*].clientAuth.caFiles with + * paths to files in the certificates directory. Files are named by a + * hash of their content so a changed CA gets a new path, which changes + * the dynamic config and makes Traefik reload it. Returns the set of + * file paths the config now references. + */ + private writeMtlsCaFiles(traefikConfig: any): Set { + const referenced = new Set(); + const tlsOptions = traefikConfig?.tls?.options; + if (!tlsOptions) { + return referenced; + } + + const certsPath = config.getRawConfig().traefik.certificates_path; + + for (const [optionsName, options] of Object.entries( + tlsOptions + )) { + const caFiles = options?.clientAuth?.caFiles; + if (!Array.isArray(caFiles)) continue; + + const paths: string[] = []; + for (const pem of caFiles) { + if (typeof pem !== "string" || !pem.includes("-----BEGIN")) { + // Already a file path (or not something we can handle) + paths.push(pem); + continue; + } + + const hash = createHash("sha256") + .update(pem) + .digest("hex") + .slice(0, 32); + const caPath = path.join( + certsPath, + `${MTLS_CA_FILE_PREFIX}${hash}.pem` + ); + try { + if (!fs.existsSync(caPath)) { + this.atomicWriteFileSync(caPath, pem, 0o644); + logger.info( + `Wrote mTLS CA file for TLS options ${optionsName}` + ); + } + paths.push(caPath); + referenced.add(caPath); + } catch (err) { + logger.error( + `Failed to write mTLS CA file for TLS options ${optionsName}, falling back to inline PEM:`, + err + ); + paths.push(pem); + } + } + + options.clientAuth.caFiles = paths; + } + + return referenced; } + /** + * Remove mTLS CA files from the certificates directory that the current + * config no longer references + */ + private cleanupUnusedMtlsCaFiles(referenced: Set): void { + const certsPath = config.getRawConfig().traefik.certificates_path; + try { + if (!fs.existsSync(certsPath)) return; + + for (const dirent of fs.readdirSync(certsPath, { + withFileTypes: true + })) { + if ( + !dirent.isFile() || + !dirent.name.startsWith(MTLS_CA_FILE_PREFIX) || + !dirent.name.endsWith(".pem") + ) { + continue; + } + const filePath = path.join(certsPath, dirent.name); + if (!referenced.has(filePath)) { + fs.rmSync(filePath, { force: true }); + logger.info(`Removed unused mTLS CA file ${dirent.name}`); + } + } + } catch (error) { + logger.error("Error cleaning up mTLS CA files:", error); + } + } + + /** * Update dynamic config from existing local certificates without fetching from remote */ @@ -749,7 +860,7 @@ export class TraefikConfigManager { // Only write the config if it has changed const newConfigYaml = yaml.dump(dynamicConfig, { noRefs: true }); if (newConfigYaml !== originalConfigYaml) { - fs.writeFileSync(dynamicConfigPath, newConfigYaml, "utf8"); + this.atomicWriteFileSync(dynamicConfigPath, newConfigYaml); logger.info("Dynamic cert config updated from local certificates"); } } @@ -822,26 +933,23 @@ export class TraefikConfigManager { `Processing certificate for domain: ${cert.domain}` ); - fs.writeFileSync(certPath, cert.certFile, "utf8"); - fs.writeFileSync(keyPath, cert.keyFile, "utf8"); - - // Set appropriate permissions (readable by owner only for key file) - fs.chmodSync(certPath, 0o644); - fs.chmodSync(keyPath, 0o600); + // Write atomically (temp file + rename) so Traefik's + // file watcher never observes a partially written + // cert/key and fails with "failed to find any PEM data". + this.atomicWriteFileSync(certPath, cert.certFile, 0o644); + this.atomicWriteFileSync(keyPath, cert.keyFile, 0o600); // Write/update .last_update file with current timestamp - fs.writeFileSync( + this.atomicWriteFileSync( lastUpdatePath, - new Date().toISOString(), - "utf8" + new Date().toISOString() ); // Check if this is a wildcard certificate and store it const wildcardPath = path.join(domainDir, ".wildcard"); - fs.writeFileSync( + this.atomicWriteFileSync( wildcardPath, - cert.wildcard ? "true" : "false", - "utf8" + cert.wildcard ? "true" : "false" ); logger.info( @@ -855,10 +963,9 @@ export class TraefikConfigManager { // even if the cert content didn't change if (cert.expiresAt) { const expiresAtPath = path.join(domainDir, ".expires_at"); - fs.writeFileSync( + this.atomicWriteFileSync( expiresAtPath, - cert.expiresAt.toString(), - "utf8" + cert.expiresAt.toString() ); } @@ -894,7 +1001,7 @@ export class TraefikConfigManager { // Only write the config if it has changed const newConfigYaml = yaml.dump(dynamicConfig, { noRefs: true }); if (newConfigYaml !== originalConfigYaml) { - fs.writeFileSync(dynamicConfigPath, newConfigYaml, "utf8"); + this.atomicWriteFileSync(dynamicConfigPath, newConfigYaml); logger.info("Dynamic cert config updated"); } } @@ -1003,43 +1110,71 @@ export class TraefikConfigManager { const dirName = dirent.name; // Only delete if NO current domain is exactly the same or ends with `.${dirName}` - const shouldDelete = !Array.from(currentActiveDomains).some( + const isUnused = !Array.from(currentActiveDomains).some( (domain) => domain === dirName || domain.endsWith(`.${dirName}`) ); - if (shouldDelete) { - const domainDir = path.join(certsPath, dirName); + if (!isUnused) { + // Domain is still active - remove from pending deletion if it was queued + if (this.pendingDeletion.has(dirName)) { + logger.info( + `Certificate ${dirName} is active again, cancelling pending deletion` + ); + this.pendingDeletion.delete(dirName); + } + continue; + } + + // Domain is unused - add to pending deletion or decrement its counter + if (!this.pendingDeletion.has(dirName)) { + const graceCycles = 3; logger.info( - `Cleaning up unused certificate directory: ${dirName}` + `Certificate ${dirName} is no longer in use. Will delete after ${graceCycles} more cycles.` ); - fs.rmSync(domainDir, { recursive: true, force: true }); - - // Remove from local state tracking - this.lastLocalCertificateState.delete(dirName); - - // Remove from dynamic config - const certFilePath = path.join(domainDir, "cert.pem"); - const keyFilePath = path.join(domainDir, "key.pem"); - const before = dynamicConfig.tls.certificates.length; - dynamicConfig.tls.certificates = - dynamicConfig.tls.certificates.filter( - (entry: any) => - entry.certFile !== certFilePath && - entry.keyFile !== keyFilePath + this.pendingDeletion.set(dirName, graceCycles); + } else { + const remaining = this.pendingDeletion.get(dirName)! - 1; + if (remaining > 0) { + logger.info( + `Certificate ${dirName} pending deletion: ${remaining} cycle(s) remaining` + ); + this.pendingDeletion.set(dirName, remaining); + } else { + // Grace period expired - actually delete now + this.pendingDeletion.delete(dirName); + + const domainDir = path.join(certsPath, dirName); + logger.info( + `Cleaning up unused certificate directory: ${dirName}` ); - if (dynamicConfig.tls.certificates.length !== before) { - configChanged = true; + fs.rmSync(domainDir, { recursive: true, force: true }); + + // Remove from local state tracking + this.lastLocalCertificateState.delete(dirName); + + // Remove from dynamic config + const certFilePath = path.join(domainDir, "cert.pem"); + const keyFilePath = path.join(domainDir, "key.pem"); + const before = dynamicConfig.tls.certificates.length; + dynamicConfig.tls.certificates = + dynamicConfig.tls.certificates.filter( + (entry: any) => + entry.certFile !== certFilePath && + entry.keyFile !== keyFilePath + ); + if (dynamicConfig.tls.certificates.length !== before) { + configChanged = true; + } } } } if (configChanged) { try { - fs.writeFileSync( + this.atomicWriteFileSync( dynamicConfigPath, - yaml.dump(dynamicConfig, { noRefs: true }), - "utf8" + yaml.dump(dynamicConfig, { noRefs: true }) ); logger.info("Dynamic config updated after cleanup"); } catch (err) { @@ -1066,6 +1201,36 @@ export class TraefikConfigManager { } } + /** + * Write a file atomically by writing to a temp file in the same + * directory and renaming it into place. This avoids Traefik (which + * watches these files/directories) picking up a partially written + * file and failing to parse it (e.g. "failed to find any PEM data"). + */ + private atomicWriteFileSync( + filePath: string, + data: string, + mode?: number + ): void { + const dir = path.dirname(filePath); + const tmpPath = path.join( + dir, + `.${path.basename(filePath)}.tmp-${process.pid}-${Date.now()}-${Math.random().toString(36).slice(2)}` + ); + try { + fs.writeFileSync(tmpPath, data, "utf8"); + if (mode !== undefined) { + fs.chmodSync(tmpPath, mode); + } + fs.renameSync(tmpPath, filePath); + } catch (error) { + try { + fs.rmSync(tmpPath, { force: true }); + } catch {} + throw error; + } + } + /** * Check if file exists */ diff --git a/server/lib/traefik/middleware.ts b/server/lib/traefik/middleware.ts new file mode 100644 index 0000000..519048e --- /dev/null +++ b/server/lib/traefik/middleware.ts @@ -0,0 +1,75 @@ +/** + * Apply a path rewrite to a request path in-process, producing the same + * result the replacePathRegex / stripPrefix middlewares built above would. + * Used where Pangolin issues the redirect itself (badger) instead of + * handing it to Traefik. + */ +export function rewriteRequestPath( + requestPath: string, + path: string | null, + pathMatchType: string | null, + rewritePath: string | null, + rewritePathType: string | null +): string { + if (!rewritePathType) { + return requestPath; + } + + let target = rewritePath ?? ""; + if ( + rewritePathType !== "regex" && + target !== "" && + !target.startsWith("/") + ) { + target = `/${target}`; + } + + // Nothing was matched against, so there is nothing to strip or replace; + // an exact rewrite is the only one that still means something. + if (!path || !pathMatchType) { + return rewritePathType === "exact" ? target || "/" : requestPath; + } + + let matched = path; + if (pathMatchType !== "regex" && !matched.startsWith("/")) { + matched = `/${matched}`; + } + + const matchRegex = + pathMatchType === "regex" + ? matched + : pathMatchType === "prefix" + ? `^${escapeRegex(matched)}(.*)` + : `^${escapeRegex(matched)}$`; + + switch (rewritePathType) { + case "exact": + return requestPath.replace( + new RegExp(`^${escapeRegex(matched)}$`), + target + ); + case "prefix": + return requestPath.replace( + new RegExp(matchRegex), + pathMatchType === "prefix" ? `${target}$1` : target + ); + case "regex": + return requestPath.replace(new RegExp(matchRegex), target); + case "stripPrefix": { + if (pathMatchType === "prefix") { + const stripped = requestPath.startsWith(matched) + ? requestPath.slice(matched.length) + : requestPath; + const prefix = target && target !== "/" ? target : ""; + return `${prefix}${stripped}` || "/"; + } + return requestPath.replace(new RegExp(matchRegex), target || "/"); + } + default: + return requestPath; + } +} + +function escapeRegex(string: string): string { + return string.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); +} diff --git a/server/lib/traefik/rule.ts b/server/lib/traefik/rule.ts new file mode 100644 index 0000000..2d40180 --- /dev/null +++ b/server/lib/traefik/rule.ts @@ -0,0 +1,41 @@ +/** + * Server-side equivalent of the clause appendPathMatch emits, so badger can + * tell whether a request would have matched a given path config. Mirrors + * Traefik v3 semantics: Path is exact, PathPrefix is segment-aware + * (`/products` matches `/products/shoes` but not `/productsforsale`), and + * PathRegexp is an unanchored regex test. + */ +export function matchesPath( + requestPath: string, + path: string | null | undefined, + pathMatchType: string | null | undefined +): boolean { + if (!path || !pathMatchType) return true; + + if (pathMatchType === "regex") { + try { + return new RegExp(path).test(requestPath); + } catch { + return false; + } + } + + let p = path; + if (!p.startsWith("/")) { + p = `/${p}`; + } + + if (pathMatchType === "exact") { + return requestPath === p; + } else if (pathMatchType === "prefix") { + if (!requestPath.startsWith(p)) { + return false; + } + if (p.endsWith("/")) { + return true; + } + const rest = requestPath.slice(p.length); + return rest === "" || rest.startsWith("/"); + } + return true; +} diff --git a/server/lib/types.ts b/server/lib/types.ts index 13900db..92f5455 100644 --- a/server/lib/types.ts +++ b/server/lib/types.ts @@ -22,6 +22,9 @@ export type Resource = { enableProxy: boolean | null; skipToIdpId: number | null; headers: string | null; + // Optional so payloads from older managed versions still parse + mode?: "rdp" | "ssh" | "http" | "vnc" | "inference" | "tcp" | "udp"; + forwardIdentityHeaders?: boolean; }; export type ResourcePincode = { diff --git a/server/routers/badger/logRequestAudit.ts b/server/routers/badger/logRequestAudit.ts index bccb3b2..a6ed9ba 100644 --- a/server/routers/badger/logRequestAudit.ts +++ b/server/routers/badger/logRequestAudit.ts @@ -15,6 +15,7 @@ Reasons: 105 - Valid Password 106 - Valid email 107 - Valid SSO +110 - Allowed Redirect 201 - Resource Not Found 202 - Resource Blocked diff --git a/server/routers/badger/verifySession.ts b/server/routers/badger/verifySession.ts index 9112dbd..b06a605 100644 --- a/server/routers/badger/verifySession.ts +++ b/server/routers/badger/verifySession.ts @@ -3,6 +3,8 @@ import { createAccessTokenResourceSession } from "@server/auth/sessions/resource"; import { + type RedirectByHost, + getRedirectsByHost, getResourceByDomain, getResourceRules, getRoleName, @@ -13,6 +15,8 @@ import { getUserSessionWithUser, checkOrgAccessPolicy } from "@server/routers/ws/verifySessionQueries"; +import { matchesPath } from "@server/lib/traefik/rule"; +import { rewriteRequestPath } from "@server/lib/traefik/middleware"; import config from "@server/lib/config"; import { isIpInCidr, stripPortFromHost } from "@server/lib/ip"; import { response } from "@server/lib/response"; @@ -20,7 +24,7 @@ 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 { createLocalCache } from "@server/lib/createLocalCache"; import { z } from "zod"; import { fromError } from "zod-validation-error"; import { remoteGetCountryCodeForIp } from "@server/lib/geoip"; @@ -47,13 +51,11 @@ import { remoteGetASNForIp } from "@server/lib/asn"; import { APP_VERSION } from "@server/lib/consts"; import { enforceResourceSessionLength } from "@server/lib/checkOrgAccessPolicy"; import { REGIONS } from "@server/db/regions"; +import { isPathAllowed } from "@server/lib/pathMatch"; const APP_VERSION_PREPEND = `remote-${APP_VERSION}`; -// We'll see if this speeds anything up -const localCache = new NodeCache({ - stdTTL: 5 // seconds -}); +const localCache = createLocalCache(); const verifyResourceSessionSchema = z.object({ sessions: z.record(z.string(), z.string()).optional(), @@ -74,6 +76,7 @@ export type VerifyResourceSessionSchema = z.infer< >; type BasicUserData = { + dontStripSession?: boolean; userId: string; username: string; email: string | null; @@ -81,12 +84,23 @@ type BasicUserData = { role: string | null; }; +// Some auth methods (e.g. email whitelist) only know the remote email and +// have no associated user record to attach userId/username/name/role to. +type EmailOnlyUserData = { + dontStripSession?: boolean; + email: string; +}; + export type VerifyUserResponse = { valid: boolean; headerAuthChallenged?: boolean; redirectUrl?: string; + // Set alongside redirectUrl when the redirect is a configured Redirect + // rather than a login bounce, so badger can answer 307 instead of 302. + redirectPermanent?: boolean; userData?: BasicUserData; pangolinVersion?: string; + dontStripSession?: boolean; }; export async function verifyResourceSession( @@ -116,13 +130,14 @@ export async function verifyResourceSession( path, headers, query, + method, badgerVersion } = parsedBody.data; // Extract HTTP Basic Auth credentials if present const clientHeaderAuth = extractBasicAuth(headers); - const clientUserAgent = headers?.["user-agent"] || headers?.["User-Agent"]; + const clientUserAgent = getClientHeader(headers, "user-agent"); const clientIsBrowser = isBrowserUserAgent(clientUserAgent); const clientIp = requestIp @@ -144,6 +159,30 @@ export async function verifyResourceSession( cleanHost = cleanHost.slice(0, -1 * matched.length); } + // Redirects always win: they are routed ahead of resources in + // Traefik and never require auth, even when attached to a resource, + // so let a matching one through to the redirect middleware before + // any resource lookup. + const redirect = await findRedirect(cleanHost, path); + if (redirect) { + const redirectUrl = buildRedirectUrl(redirect, parsedBody.data); + logger.debug( + `Redirecting ${cleanHost}${path} to ${redirectUrl} (redirect ${redirect.redirectId})` + ); + + logRequestAudit( + { + action: true, + reason: 110, // redirected + orgId: redirect.orgId, + location: ipCC + }, + parsedBody.data + ); + + return redirected(res, redirectUrl, redirect.permanent); + } + const resourceCacheKey = `resource:${cleanHost}`; let resourceData: | { @@ -213,7 +252,10 @@ export async function verifyResourceSession( return notAllowed(res); } - const { blockAccess } = resource; + const { blockAccess, mode } = resource; + const dontStripSession = ["ssh", "rdp", "vnc", "inference"].includes( + mode ?? "" + ); if (blockAccess) { logger.debug("Resource blocked", host); @@ -239,7 +281,8 @@ export async function verifyResourceSession( clientIp, path, ipCC, - ipAsn + ipAsn, + method ); if (action === "ACCEPT") { @@ -256,7 +299,7 @@ export async function verifyResourceSession( parsedBody.data ); - return allowed(res); + return allowed(res, undefined, dontStripSession); } else if (action === "DROP") { logger.debug("Resource denied by rule"); @@ -304,7 +347,7 @@ export async function verifyResourceSession( parsedBody.data ); - return allowed(res); + return allowed(res, undefined, dontStripSession); } // Only offer a browser redirect to clients that can actually follow one and log in @@ -366,6 +409,7 @@ export async function verifyResourceSession( resource, tokenItem, sessions, + dontStripSession, parsedBody.data, ipCC, userData @@ -413,6 +457,7 @@ export async function verifyResourceSession( resource, tokenItem, sessions, + dontStripSession, parsedBody.data, ipCC, userData @@ -439,7 +484,7 @@ export async function verifyResourceSession( parsedBody.data ); - return allowed(res); + return allowed(res, undefined, dontStripSession); } else if ( await verifyPassword( clientHeaderAuth, @@ -460,7 +505,7 @@ export async function verifyResourceSession( parsedBody.data ); - return allowed(res); + return allowed(res, undefined, dontStripSession); } if ( @@ -548,7 +593,16 @@ export async function verifyResourceSession( ); resourceSession = result?.resourceSession; - localCache.set(sessionCacheKey, resourceSession, 5); + if (resourceSession) { + localCache.set(sessionCacheKey, resourceSession, 5); + // Outlives the session entry so a cached session always + // finds its email + localCache.set( + `whitelistEmail:${resourceSession.whitelistId}:${resourceSession.policyWhitelistId}`, + result.whitelistEmail ?? null, + 12 + ); + } } if (resourceSession?.isRequestToken) { @@ -612,7 +666,7 @@ export async function verifyResourceSession( parsedBody.data ); - return allowed(res); + return allowed(res, undefined, dontStripSession); } if ( @@ -635,7 +689,7 @@ export async function verifyResourceSession( parsedBody.data ); - return allowed(res); + return allowed(res, undefined, dontStripSession); } if ( @@ -647,6 +701,11 @@ export async function verifyResourceSession( "Resource allowed because whitelist session is valid" ); + const whitelistEmail: string | null | undefined = + localCache.get( + `whitelistEmail:${resourceSession.whitelistId}:${resourceSession.policyWhitelistId}` + ); + logRequestAudit( { action: true, @@ -658,33 +717,53 @@ export async function verifyResourceSession( parsedBody.data ); - return allowed(res); + return allowed( + res, + forwardedUserData( + resource, + whitelistEmail ? { email: whitelistEmail } : undefined + ), + dontStripSession + ); } if (resourceSession.accessTokenId) { - logger.debug( - "Resource allowed because access token session is valid" - ); - const tokenData = await getResourceAccessToken( resourceSession.accessTokenId ); const tokenItem = tokenData?.tokenItem; const userData = tokenData?.userData; - logAccessTokenRequestAudit( - { - resourceId: resource.resourceId, - orgId: resource.orgId, - location: ipCC, - accessTokenId: resourceSession.accessTokenId, - tokenTitle: tokenItem?.title ?? null, - userData - }, - parsedBody.data - ); + if ( + tokenItem && + tokenItem.resourceId === resource.resourceId + ) { + logger.debug( + "Resource allowed because access token session is valid" + ); + + logAccessTokenRequestAudit( + { + resourceId: resource.resourceId, + orgId: resource.orgId, + location: ipCC, + accessTokenId: resourceSession.accessTokenId, + tokenTitle: tokenItem.title ?? null, + userData + }, + parsedBody.data + ); - return allowed(res, userData); + return allowed( + res, + forwardedUserData(resource, userData), + dontStripSession + ); + } + + logger.debug( + "Access token session does not belong to this resource" + ); } if (resourceSession.userSessionId && sso) { @@ -702,7 +781,8 @@ export async function verifyResourceSession( resourceData.org ); - localCache.set(userAccessCacheKey, allowedUserData, 5); + // this is query intensive so let it cache a little longer + localCache.set(userAccessCacheKey, allowedUserData, 12); } if ( @@ -728,17 +808,27 @@ export async function verifyResourceSession( parsedBody.data ); - return allowed(res, allowedUserData); + return allowed( + res, + forwardedUserData(resource, { + ...allowedUserData, + dontStripSession + }), + dontStripSession + ); } } } } - // If headerAuthExtendedCompatibility is activated but no clientHeaderAuth provided, force client to challenge + // If headerAuthExtendedCompatibility is activated but no clientHeaderAuth provided, force client to challenge. + // Skip the challenge when SSO is also enabled so browsers get the SSO redirect instead of a native Basic + // Auth dialog; clients that proactively send Authorization: Basic are still accepted above. if ( headerAuthExtendedCompatibility && headerAuthExtendedCompatibility.extendedCompatibilityIsActivated && - !clientHeaderAuth + !clientHeaderAuth && + !sso ) { return headerAuthChallenged(res, redirectPath, resource.orgId); } @@ -821,6 +911,71 @@ function extractResourceSessionToken( return latest.token; } +async function findRedirect( + host: string, + path: string +): Promise { + const cacheKey = `redirects:${host}`; + let candidates: RedirectByHost[] | undefined = localCache.get(cacheKey); + if (!candidates) { + candidates = await getRedirectsByHost(host); + localCache.set(cacheKey, candidates, 5); + } + + // Candidates come back highest priority first, matching the order + // Traefik evaluates the routers in. + return ( + candidates.find((r) => + matchesPath(path, r.matchPath, r.pathMatchType) + ) ?? null + ); +} + +/** + * Destination for a configured redirect: the scheme and host come from the + * redirect's destination, the request's query is kept and the path is run + * through the redirect's rewrite rules (if any). + */ +function buildRedirectUrl( + redirect: RedirectByHost, + request: VerifyResourceSessionSchema +): string { + const newPath = rewriteRequestPath( + request.path, + redirect.matchPath, + redirect.pathMatchType, + redirect.rewritePath, + redirect.rewritePathType + ); + + let search = ""; + try { + search = new URL(request.originalRequestURL).search; + } catch { + // originalRequestURL is validated as a URL, so this is only defensive + } + + return `${redirect.destinationHost}${newPath}${search}`; +} + +// Like a notAllowed login bounce, but the destination is the configured +// redirect target rather than the auth page. +function redirected(res: Response, redirectUrl: string, permanent: boolean) { + const data = { + data: { + valid: true, + redirectUrl, + redirectPermanent: permanent, + pangolinVersion: APP_VERSION_PREPEND + }, + success: true, + error: false, + message: "Redirected", + status: HttpCode.OK + }; + return response(res, data); +} + export type LoginPage = { loginPageId: number; subdomain: string | null; @@ -871,16 +1026,35 @@ async function notAllowed( return response(res, data); } -function allowed(res: Response, userData?: BasicUserData) { +// Badger only sets the Remote-* identity headers for fields present in the +// response, so omitting the user data is enough to keep them off the upstream +// request. Some backends (e.g. embedded device web servers) reject requests +// carrying headers they don't recognize. +function forwardedUserData( + resource: Resource, + userData: T | undefined +): T | undefined { + // Treat a missing value (older managed versions) as enabled + return resource.forwardIdentityHeaders === false ? undefined : userData; +} + +function allowed( + res: Response, + userData?: BasicUserData | EmailOnlyUserData, + dontStripSession?: boolean +) { + const baseData = + userData !== undefined && userData !== null + ? { + valid: true, + ...userData, + pangolinVersion: APP_VERSION_PREPEND + } + : { valid: true, pangolinVersion: APP_VERSION_PREPEND }; const data = { - data: - userData !== undefined && userData !== null - ? { - valid: true, - ...userData, - pangolinVersion: APP_VERSION_PREPEND - } - : { valid: true, pangolinVersion: APP_VERSION_PREPEND }, + data: dontStripSession + ? { ...baseData, dontStripSession: true } + : baseData, success: true, error: false, message: "Access allowed", @@ -894,6 +1068,7 @@ async function allowAccessToken( resource: Resource, tokenItem: ResourceAccessToken, sessions: Record | undefined, + dontStripSession: boolean | undefined, auditBody: VerifyResourceSessionSchema, location?: string, userData?: AccessTokenUserData @@ -912,7 +1087,11 @@ async function allowAccessToken( if (!tokenItem.persistSession) { logAccessTokenAccessAudit(tokenItem, resource, userData, auditBody); - return allowed(res, userData); + return allowed( + res, + forwardedUserData(resource, userData), + dontStripSession + ); } const resourceSessionToken = extractResourceSessionToken( @@ -931,7 +1110,9 @@ async function allowAccessToken( resource.resourceId ); resourceSession = result?.resourceSession; - localCache.set(sessionCacheKey, resourceSession, 5); + if (resourceSession) { + localCache.set(sessionCacheKey, resourceSession, 5); + } } if ( @@ -942,7 +1123,11 @@ async function allowAccessToken( logger.debug( "Resource allowed because existing access token session is valid" ); - return allowed(res, userData); + return allowed( + res, + forwardedUserData(resource, userData), + dontStripSession + ); } } @@ -970,7 +1155,7 @@ async function createAccessTokenSession( ); } - return allowed(res, userData); + return allowed(res, forwardedUserData(resource, userData)); } function logAccessTokenRequestAudit( @@ -1205,7 +1390,8 @@ async function checkRules( clientIp: string | undefined, path: string | undefined, ipCC?: string, - ipAsn?: number + ipAsn?: number, + method?: string ): Promise<"ACCEPT" | "DROP" | "PASS" | undefined> { const ruleCacheKey = `rules:${resourceId}`; @@ -1277,180 +1463,27 @@ async function checkRules( (await isIpInRegion(ipCC, rule.value)) ) { return rule.action as any; + } else if ( + method && + rule.match === "METHOD" && + isMethodAllowed(rule.value, method) + ) { + return rule.action as any; } } return; } -// Decodes percent-encoding (so an encoded slash like `%2F` is treated as a -// real path separator, matching what most backends will do) and then -// resolves `.` / `..` segments, so a request like `/public%2F..%2Fadmin/` -// or `/public/../admin/` is matched as `/admin/`, not as a literal segment -// or a wildcard-swallowed sequence under `/public/*`. -function decodeAndResolvePath(p: string): string[] { - const rawParts = p.split("/").filter(Boolean); - - const resolved: string[] = []; - for (const rawPart of rawParts) { - let part: string; - try { - part = decodeURIComponent(rawPart); - } catch { - part = rawPart; - } - - // an encoded slash can turn one raw segment into several real ones - for (const segment of part.split("/").filter(Boolean)) { - if (segment === ".") { - continue; - } else if (segment === "..") { - resolved.pop(); - } else { - resolved.push(segment); - } - } - } - - return resolved; +// rule.value holds a comma-separated list of HTTP methods, e.g. "POST,PUT". +function isMethodAllowed(ruleValue: string, method: string): boolean { + const requestMethod = method.toUpperCase(); + return ruleValue + .split(",") + .some((ruleMethod) => ruleMethod.trim().toUpperCase() === requestMethod); } -export function isPathAllowed(pattern: string, path: string): boolean { - logger.debug(`\nMatching path "${path}" against pattern "${pattern}"`); - - // Normalize and split paths into segments - const patternParts = pattern.split("/").filter(Boolean); - const pathParts = decodeAndResolvePath(path); - - logger.debug(`Normalized pattern parts: [${patternParts.join(", ")}]`); - logger.debug(`Normalized path parts: [${pathParts.join(", ")}]`); - - // Maximum recursion depth to prevent stack overflow and memory issues - const MAX_RECURSION_DEPTH = 100; - - // Recursive function to try different wildcard matches - function matchSegments( - patternIndex: number, - pathIndex: number, - depth: number = 0 - ): boolean { - // Check recursion depth limit - if (depth > MAX_RECURSION_DEPTH) { - logger.warn( - `Path matching exceeded maximum recursion depth (${MAX_RECURSION_DEPTH}) for pattern "${pattern}" and path "${path}"` - ); - return false; - } - - const indent = " ".repeat(depth); // Indent based on recursion depth - const currentPatternPart = patternParts[patternIndex]; - const currentPathPart = pathParts[pathIndex]; - - logger.debug( - `${indent}Checking patternIndex=${patternIndex} (${currentPatternPart || "END"}) vs pathIndex=${pathIndex} (${currentPathPart || "END"}) [depth=${depth}]` - ); - - // If we've consumed all pattern parts, we should have consumed all path parts - if (patternIndex >= patternParts.length) { - const result = pathIndex >= pathParts.length; - logger.debug( - `${indent}Reached end of pattern, remaining path: ${pathParts.slice(pathIndex).join("/")} -> ${result}` - ); - return result; - } - - // If we've consumed all path parts but still have pattern parts - if (pathIndex >= pathParts.length) { - // The only way this can match is if all remaining pattern parts are wildcards - const remainingPattern = patternParts.slice(patternIndex); - const result = remainingPattern.every((p) => p === "*"); - logger.debug( - `${indent}Reached end of path, remaining pattern: ${remainingPattern.join("/")} -> ${result}` - ); - return result; - } - - // For full segment wildcards, try consuming different numbers of path segments - if (currentPatternPart === "*") { - logger.debug( - `${indent}Found wildcard at pattern index ${patternIndex}` - ); - - // Try consuming 0 segments (skip the wildcard) - logger.debug( - `${indent}Trying to skip wildcard (consume 0 segments)` - ); - if (matchSegments(patternIndex + 1, pathIndex, depth + 1)) { - logger.debug( - `${indent}Successfully matched by skipping wildcard` - ); - return true; - } - - // Try consuming current segment and recursively try rest - logger.debug( - `${indent}Trying to consume segment "${currentPathPart}" for wildcard` - ); - if (matchSegments(patternIndex, pathIndex + 1, depth + 1)) { - logger.debug( - `${indent}Successfully matched by consuming segment for wildcard` - ); - return true; - } - - logger.debug(`${indent}Failed to match wildcard`); - return false; - } - - // Check for in-segment wildcard (e.g., "prefix*" or "prefix*suffix") - if (currentPatternPart.includes("*")) { - logger.debug( - `${indent}Found in-segment wildcard in "${currentPatternPart}"` - ); - - // Convert the pattern segment to a regex pattern - const regexPattern = currentPatternPart - .replace(/\*/g, ".*") // Replace * with .* for regex wildcard - .replace(/\?/g, "."); // Replace ? with . for single character wildcard if needed - - const regex = new RegExp(`^${regexPattern}$`); - - if (regex.test(currentPathPart)) { - logger.debug( - `${indent}Segment with wildcard matches: "${currentPatternPart}" matches "${currentPathPart}"` - ); - return matchSegments( - patternIndex + 1, - pathIndex + 1, - depth + 1 - ); - } - - logger.debug( - `${indent}Segment with wildcard mismatch: "${currentPatternPart}" doesn't match "${currentPathPart}"` - ); - return false; - } - - // For regular segments, they must match exactly - if (currentPatternPart !== currentPathPart) { - logger.debug( - `${indent}Segment mismatch: "${currentPatternPart}" != "${currentPathPart}"` - ); - return false; - } - - logger.debug( - `${indent}Segments match: "${currentPatternPart}" = "${currentPathPart}"` - ); - // Move to next segments in both pattern and path - return matchSegments(patternIndex + 1, pathIndex + 1, depth + 1); - } - - const result = matchSegments(0, 0, 0); - logger.debug(`Final result: ${result}`); - return result; -} +export { isPathAllowed }; async function isIpInGeoIP( ipCountryCode: string | undefined, @@ -1601,14 +1634,29 @@ const NON_BROWSER_USER_AGENT_PATTERNS = [ /wget/, /python-requests/, /python-urllib/, + /python-httpx/, + /httpx/, + /httpcore/, + /aiohttp/, + /urllib3/, + /openai\//, + /anthropic/, /go-http-client/, /okhttp/, /axios/, /node-fetch/, + /undici/, /postmanruntime/, /insomnia/, /libwww-perl/, /java\//, + /\bjava\b/, + /jakarta/, + /jersey/, + /netty/, + /jetty/, + /eclipse/, + /dbeaver/, /ruby/, /php/, /bot/, @@ -1633,6 +1681,22 @@ function isBrowserUserAgent(userAgent: string | undefined): boolean { return !NON_BROWSER_USER_AGENT_PATTERNS.some((pattern) => pattern.test(ua)); } +function getClientHeader( + headers: Record | undefined, + name: string +): string | undefined { + if (!headers) { + return undefined; + } + const lower = name.toLowerCase(); + for (const [key, value] of Object.entries(headers)) { + if (key.toLowerCase() === lower) { + return value; + } + } + return undefined; +} + function extractBasicAuth( headers: Record | undefined ): string | undefined { diff --git a/server/routers/ws/verifySessionQueries.ts b/server/routers/ws/verifySessionQueries.ts index eaa7022..4b8ccf7 100644 --- a/server/routers/ws/verifySessionQueries.ts +++ b/server/routers/ws/verifySessionQueries.ts @@ -30,6 +30,18 @@ export type UserSessionWithUser = { user: any; }; +export type RedirectByHost = { + redirectId: number; + orgId: string; + matchPath: string | null; + pathMatchType: string; + destinationHost: string; + rewritePath: string | null; + rewritePathType: string | null; + permanent: boolean; + priority: number | null; +}; + /** * Get resource by domain with pincode and password information */ @@ -59,6 +71,36 @@ export async function getResourceByDomain( } } +/** + * Enabled redirects listening on the given host, highest priority first. + * Host matching (including wildcard labels) happens on the central server. + */ +export async function getRedirectsByHost( + host: string +): Promise { + try { + const response = await axios.get( + `${config.getRawConfig().managed?.endpoint}/api/v1/hybrid/redirects/host/${encodeURIComponent(host)}`, + await tokenManager.getAuthHeader() + ); + return response.data.data ?? []; + } catch (error) { + if (axios.isAxiosError(error)) { + logger.error("Error fetching redirects in verify session:", { + message: error.message, + code: error.code, + status: error.response?.status, + statusText: error.response?.statusText, + url: error.config?.url, + method: error.config?.method + }); + } else { + logger.error("Error fetching redirects in verify session:", error); + } + return []; + } +} + /** * Get user session with user information */ diff --git a/tsconfig.json b/tsconfig.json index 9756e89..a91af1d 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -7,24 +7,17 @@ "noEmit": true, "esModuleInterop": true, "module": "esnext", - "moduleResolution": "node", "resolveJsonModule": true, "isolatedModules": true, "jsx": "preserve", "incremental": true, - "baseUrl": "src", "paths": { - "@server/*": ["../server/*"], - "@test/*": ["../test/*"], - "@app/*": ["*"], - "@cli/*": ["../cli/*"], + "@server/*": ["./server/*"], + "@test/*": ["./test/*"], + "@app/*": ["./src/*"], + "@cli/*": ["./cli/*"], "@/*": ["./*"] }, - "plugins": [ - { - "name": "next" - } - ], "target": "ES2024" }, "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],