From c0e37812448d2beb027caa578bd62b10457bee86 Mon Sep 17 00:00:00 2001 From: Caileb Date: Mon, 26 May 2025 22:25:42 -0500 Subject: [PATCH] Initial commit: Upload Checkpoint project --- .cursor/rules/coding-practices.mdc | 16 + .cursor/rules/comments.mdc | 5 + .cursor/rules/conversation-style.mdc | 13 + .cursor/rules/css-animations.mdc | 17 + .cursor/rules/js-ts-naming.mdc | 16 + .gitignore | 40 + .prettierrc | 7 + bun.lock | 158 ++ checkpoint.js | 961 +++++++++++++ config/checkpoint.toml | 143 ++ config/ipfilter.toml | 92 ++ config/proxy.toml | 55 + config/stats.toml | 31 + index.js | 197 +++ package-lock.json | 728 ++++++++++ package.json | 26 + pages/interstitial/js/c.js | 518 +++++++ pages/interstitial/page.html | 742 ++++++++++ .../webfont/Poppins-Regular.woff2 | Bin 0 -> 49048 bytes .../webfont/Poppins-SemiBold.woff2 | Bin 0 -> 48508 bytes pages/ipfilter/datacenter.html | 1 + pages/ipfilter/default.html | 1 + pages/ipfilter/india.html | 1 + pages/stats/stats.html | 1274 +++++++++++++++++ plugins/ipfilter.js | 470 ++++++ plugins/proxy.js | 303 ++++ plugins/stats.js | 132 ++ utils/logs.js | 41 + utils/network.js | 13 + utils/plugins.js | 28 + utils/proof.js | 72 + utils/time.js | 20 + 32 files changed, 6121 insertions(+) create mode 100644 .cursor/rules/coding-practices.mdc create mode 100644 .cursor/rules/comments.mdc create mode 100644 .cursor/rules/conversation-style.mdc create mode 100644 .cursor/rules/css-animations.mdc create mode 100644 .cursor/rules/js-ts-naming.mdc create mode 100644 .gitignore create mode 100644 .prettierrc create mode 100644 bun.lock create mode 100644 checkpoint.js create mode 100644 config/checkpoint.toml create mode 100644 config/ipfilter.toml create mode 100644 config/proxy.toml create mode 100644 config/stats.toml create mode 100644 index.js create mode 100644 package-lock.json create mode 100644 package.json create mode 100644 pages/interstitial/js/c.js create mode 100644 pages/interstitial/page.html create mode 100644 pages/interstitial/webfont/Poppins-Regular.woff2 create mode 100644 pages/interstitial/webfont/Poppins-SemiBold.woff2 create mode 100644 pages/ipfilter/datacenter.html create mode 100644 pages/ipfilter/default.html create mode 100644 pages/ipfilter/india.html create mode 100644 pages/stats/stats.html create mode 100644 plugins/ipfilter.js create mode 100644 plugins/proxy.js create mode 100644 plugins/stats.js create mode 100644 utils/logs.js create mode 100644 utils/network.js create mode 100644 utils/plugins.js create mode 100644 utils/proof.js create mode 100644 utils/time.js diff --git a/.cursor/rules/coding-practices.mdc b/.cursor/rules/coding-practices.mdc new file mode 100644 index 0000000..2c7a985 --- /dev/null +++ b/.cursor/rules/coding-practices.mdc @@ -0,0 +1,16 @@ +--- +description: +globs: +alwaysApply: true +--- +# Code Quality Standards + +- Follow established industry best practices for each programming language +- Maintain consistent code style throughout the project +- Write self-documenting code with descriptive variable and function names +- Keep functions small and focused on a single responsibility +- Add appropriate error handling where necessary +- Include comments only for complex logic that requires additional context +- Write DRY (Don't Repeat Yourself) code that minimizes duplication +- Consider performance implications of implemented solutions + diff --git a/.cursor/rules/comments.mdc b/.cursor/rules/comments.mdc new file mode 100644 index 0000000..b93c988 --- /dev/null +++ b/.cursor/rules/comments.mdc @@ -0,0 +1,5 @@ +--- +description: +globs: +alwaysApply: false +--- diff --git a/.cursor/rules/conversation-style.mdc b/.cursor/rules/conversation-style.mdc new file mode 100644 index 0000000..35bbbb3 --- /dev/null +++ b/.cursor/rules/conversation-style.mdc @@ -0,0 +1,13 @@ +--- +description: +globs: +alwaysApply: true +--- +# Conversation Style Guidelines + +- Maintain a concise, direct communication style in all responses +- Focus on delivering information efficiently without unnecessary filler words +- Prioritize clarity and brevity while keeping responses informative +- Avoid repetitive phrasing or excessive explanations unless specifically requested +- Use straightforward language that conveys the point clearly and precisely + diff --git a/.cursor/rules/css-animations.mdc b/.cursor/rules/css-animations.mdc new file mode 100644 index 0000000..d5e74c3 --- /dev/null +++ b/.cursor/rules/css-animations.mdc @@ -0,0 +1,17 @@ +--- +description: +globs: *.css,*.html +alwaysApply: false +--- +# CSS Animation Excellence Standards + +- Prioritize CSS-based animations over JavaScript for better performance +- Target buttery-smooth 60fps animations with clean transitions +- Use hardware-accelerated properties (transform, opacity) whenever possible +- Implement appropriate easing functions that feel natural and responsive +- Ensure animations are subtle and enhance user experience without being distracting +- Design animations that meet Apple-level quality standards for smoothness and polish +- Consider reduced-motion preferences for accessibility +- Optimize animation performance by minimizing repaints and reflows +- Test animations across different device capabilities to ensure consistent experience + diff --git a/.cursor/rules/js-ts-naming.mdc b/.cursor/rules/js-ts-naming.mdc new file mode 100644 index 0000000..e602eff --- /dev/null +++ b/.cursor/rules/js-ts-naming.mdc @@ -0,0 +1,16 @@ +--- +description: +globs: *.ts,*.js +alwaysApply: false +--- +# JavaScript/TypeScript Naming Conventions + +- Use camelCase for all variable names (`userProfile`, `itemCount`, `fetchData`) +- Use camelCase for function and method names (`calculateTotal`, `getUserInfo`) +- Use SCREAMING_SNAKE_CASE for constants (`API_KEY`, `MAX_ATTEMPTS`, `DEFAULT_TIMEOUT`) +- Use PascalCase for class and component names (`UserProfile`, `PaymentForm`) +- Use descriptive names that clearly indicate the purpose or content +- Avoid single-letter variable names except in small loop contexts +- Prefix boolean variables with "is", "has", or "should" for clarity +- Maintain consistent naming patterns throughout the codebase + diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..78bc649 --- /dev/null +++ b/.gitignore @@ -0,0 +1,40 @@ +# dependencies (bun install) +node_modules + +# output +out +dist +*.tgz + +# code coverage +coverage +*.lcov + +# logs +logs +_.log +report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json + +# dotenv environment variable files +.env +.env.development.local +.env.test.local +.env.production.local +.env.local + +# caches +.eslintcache +.cache +*.tsbuildinfo + +# IntelliJ based IDEs +.idea + +# Finder (MacOS) folder config +.DS_Store + +# Data Folder +data + +# DB Folder +db \ No newline at end of file diff --git a/.prettierrc b/.prettierrc new file mode 100644 index 0000000..e78a706 --- /dev/null +++ b/.prettierrc @@ -0,0 +1,7 @@ +{ + "singleQuote": true, + "trailingComma": "all", + "printWidth": 100, + "tabWidth": 2, + "semi": true +} diff --git a/bun.lock b/bun.lock new file mode 100644 index 0000000..00d40d6 --- /dev/null +++ b/bun.lock @@ -0,0 +1,158 @@ +{ + "lockfileVersion": 1, + "workspaces": { + "": { + "name": "bunbun", + "dependencies": { + "@iarna/toml": "^2.2.5", + "cookie": "^1.0.2", + "dotenv": "^16.5.0", + "express-http-proxy": "^2.1.1", + "http-proxy": "^1.18.1", + "level": "^10.0.0", + "level-ttl": "^3.1.1", + "maxmind": "^4.3.25", + "string-dsa": "^2.1.0", + "tar": "^7.4.3", + "tar-stream": "^3.1.7", + "toml": "^3.0.0", + }, + "devDependencies": { + "prettier": "^2.8.8", + }, + }, + }, + "packages": { + "@iarna/toml": ["@iarna/toml@2.2.5", "", {}, "sha512-trnsAYxU3xnS1gPHPyU961coFyLkh4gAD/0zQ5mymY4yOZ+CYvsPqUbOFSw0aDM4y0tV7tiFxL/1XfXPNC6IPg=="], + + "@isaacs/fs-minipass": ["@isaacs/fs-minipass@4.0.1", "", { "dependencies": { "minipass": "^7.0.4" } }, "sha512-wgm9Ehl2jpeqP3zw/7mo3kRHFp5MEDhqAdwy1fTGkHAwnkGOVsgpvQhL8B5n1qlb01jV3n/bI0ZfZp5lWA1k4w=="], + + "abstract-level": ["abstract-level@3.1.0", "", { "dependencies": { "buffer": "^6.0.3", "is-buffer": "^2.0.5", "level-supports": "^6.2.0", "level-transcoder": "^1.0.1", "maybe-combine-errors": "^1.0.0", "module-error": "^1.0.1" } }, "sha512-j2e+TsAxy7Ri+0h7dJqwasymgt0zHBWX4+nMk3XatyuqgHfdstBJ9wsMfbiGwE1O+QovRyPcVAqcViMYdyPaaw=="], + + "after": ["after@0.8.2", "", {}, "sha512-QbJ0NTQ/I9DI3uSJA4cbexiwQeRAfjPScqIbSjUDd9TOrcg6pTkdgziesOqxBMBzit8vFCTwrP27t13vFOORRA=="], + + "b4a": ["b4a@1.6.7", "", {}, "sha512-OnAYlL5b7LEkALw87fUVafQw5rVR9RjwGd4KUwNQ6DrrNmaVaUCgLipfVlzrPQ4tWOR9P0IXGNOx50jYCCdSJg=="], + + "bare-events": ["bare-events@2.5.4", "", {}, "sha512-+gFfDkR8pj4/TrWCGUGWmJIkBwuxPS5F+a5yWjOHQt2hHvNZd5YLzadjmDUtFmMM4y429bnKLa8bYBMHcYdnQA=="], + + "base64-js": ["base64-js@1.5.1", "", {}, "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA=="], + + "browser-level": ["browser-level@3.0.0", "", { "dependencies": { "abstract-level": "^3.1.0" } }, "sha512-kGXtLh29jMwqKaskz5xeDLtCtN1KBz/DbQSqmvH7QdJiyGRC7RAM8PPg6gvUiNMa+wVnaxS9eSmEtP/f5ajOVw=="], + + "buffer": ["buffer@6.0.3", "", { "dependencies": { "base64-js": "^1.3.1", "ieee754": "^1.2.1" } }, "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA=="], + + "bytes": ["bytes@3.1.2", "", {}, "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg=="], + + "chownr": ["chownr@3.0.0", "", {}, "sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g=="], + + "classic-level": ["classic-level@3.0.0", "", { "dependencies": { "abstract-level": "^3.1.0", "module-error": "^1.0.1", "napi-macros": "^2.2.2", "node-gyp-build": "^4.3.0" } }, "sha512-yGy8j8LjPbN0Bh3+ygmyYvrmskVita92pD/zCoalfcC9XxZj6iDtZTAnz+ot7GG8p9KLTG+MZ84tSA4AhkgVZQ=="], + + "cookie": ["cookie@1.0.2", "", {}, "sha512-9Kr/j4O16ISv8zBBhJoi4bXOYNTkFLOqSL3UDB0njXxCXNezjeyVrJyGOWtgfs/q2km1gwBcfH8q1yEGoMYunA=="], + + "core-util-is": ["core-util-is@1.0.3", "", {}, "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ=="], + + "debug": ["debug@3.2.7", "", { "dependencies": { "ms": "^2.1.1" } }, "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ=="], + + "depd": ["depd@2.0.0", "", {}, "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw=="], + + "dotenv": ["dotenv@16.5.0", "", {}, "sha512-m/C+AwOAr9/W1UOIZUo232ejMNnJAJtYQjUbHoNTBNTJSvqzzDh7vnrei3o3r3m9blf6ZoDkvcw0VmozNRFJxg=="], + + "es6-promise": ["es6-promise@4.2.8", "", {}, "sha512-HJDGx5daxeIvxdBxvG2cb9g4tEvwIk3i8+nhX0yGrYmZUzbkdg8QbDevheDB8gd0//uPj4c1EQua8Q+MViT0/w=="], + + "eventemitter3": ["eventemitter3@4.0.7", "", {}, "sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw=="], + + "express-http-proxy": ["express-http-proxy@2.1.1", "", { "dependencies": { "debug": "^3.0.1", "es6-promise": "^4.1.1", "raw-body": "^2.3.0" } }, "sha512-4aRQRqDQU7qNPV5av0/hLcyc0guB9UP71nCYrQEYml7YphTo8tmWf3nDZWdTJMMjFikyz9xKXaURor7Chygdwg=="], + + "fast-fifo": ["fast-fifo@1.3.2", "", {}, "sha512-/d9sfos4yxzpwkDkuN7k2SqFKtYNmCTzgfEpz82x34IM9/zc8KGxQoXg1liNC/izpRM/MBdt44Nmx41ZWqk+FQ=="], + + "follow-redirects": ["follow-redirects@1.15.9", "", {}, "sha512-gew4GsXizNgdoRyqmyfMHyAmXsZDk6mHkSxZFCzW9gwlbtOW44CDtYavM+y+72qD/Vq2l550kMF52DT8fOLJqQ=="], + + "http-errors": ["http-errors@2.0.0", "", { "dependencies": { "depd": "2.0.0", "inherits": "2.0.4", "setprototypeof": "1.2.0", "statuses": "2.0.1", "toidentifier": "1.0.1" } }, "sha512-FtwrG/euBzaEjYeRqOgly7G0qviiXoJWnvEH2Z1plBdXgbyjv34pHTSb9zoeHMyDy33+DWy5Wt9Wo+TURtOYSQ=="], + + "http-proxy": ["http-proxy@1.18.1", "", { "dependencies": { "eventemitter3": "^4.0.0", "follow-redirects": "^1.0.0", "requires-port": "^1.0.0" } }, "sha512-7mz/721AbnJwIVbnaSv1Cz3Am0ZLT/UBwkC92VlxhXv/k/BBQfM2fXElQNC27BVGr0uwUpplYPQM9LnaBMR5NQ=="], + + "iconv-lite": ["iconv-lite@0.4.24", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3" } }, "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA=="], + + "ieee754": ["ieee754@1.2.1", "", {}, "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA=="], + + "inherits": ["inherits@2.0.4", "", {}, "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ=="], + + "is-buffer": ["is-buffer@2.0.5", "", {}, "sha512-i2R6zNFDwgEHJyQUtJEk0XFi1i0dPFn/oqjK3/vPCcDeJvW5NQ83V8QbicfF1SupOaB0h8ntgBC2YiE7dfyctQ=="], + + "isarray": ["isarray@1.0.0", "", {}, "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ=="], + + "level": ["level@10.0.0", "", { "dependencies": { "abstract-level": "^3.1.0", "browser-level": "^3.0.0", "classic-level": "^3.0.0" } }, "sha512-aZJvdfRr/f0VBbSRF5C81FHON47ZsC2TkGxbBezXpGGXAUEL/s6+GP73nnhAYRSCIqUNsmJjfeOF4lzRDKbUig=="], + + "level-supports": ["level-supports@6.2.0", "", {}, "sha512-QNxVXP0IRnBmMsJIh+sb2kwNCYcKciQZJEt+L1hPCHrKNELllXhvrlClVHXBYZVT+a7aTSM6StgNXdAldoab3w=="], + + "level-transcoder": ["level-transcoder@1.0.1", "", { "dependencies": { "buffer": "^6.0.3", "module-error": "^1.0.1" } }, "sha512-t7bFwFtsQeD8cl8NIoQ2iwxA0CL/9IFw7/9gAjOonH0PWTTiRfY7Hq+Ejbsxh86tXobDQ6IOiddjNYIfOBs06w=="], + + "level-ttl": ["level-ttl@3.1.1", "", { "dependencies": { "after": ">=0.8.1 <0.9.0-0", "list-stream": ">=1.0.0 <1.1.0-0", "lock": "~0.1.2", "xtend": ">=4.0.0 <4.1.0-0" } }, "sha512-OeiHOD2IPkmdLqqU4feVJL7mnZX/Q03WEClrQi5t9558alkajVaecCgwJQZVVL/zFR9q74n5pWN1eozifa1Ghw=="], + + "list-stream": ["list-stream@1.0.1", "", { "dependencies": { "readable-stream": "~2.0.5", "xtend": "~4.0.1" } }, "sha512-XheYsTtN+/nay6Co4N9NlTjQzo1ohknNlDJfxTeH0tvvssxBINUXwmjqPtj8+7rYMBwTRb3kO8C8d6ogeRwD1A=="], + + "lock": ["lock@0.1.4", "", {}, "sha512-IcEe2R+NA7WgM622ppgmJFCFZl20f2owsA1YiJg7qpvO0wdOgOuZdfhQMxCYXdESVX+QIF/eikE4hB5ZPM2ipA=="], + + "maxmind": ["maxmind@4.3.25", "", { "dependencies": { "mmdb-lib": "2.2.0", "tiny-lru": "11.2.11" } }, "sha512-u7L6LrbXZUtpdoovTVHo/l4/EoWUT2eHfCKWDMNNTsW9BaLa7h0jCHjqVx5ZeS5aWorLGZSsZwqxcpoollBw1g=="], + + "maybe-combine-errors": ["maybe-combine-errors@1.0.0", "", {}, "sha512-eefp6IduNPT6fVdwPp+1NgD0PML1NU5P6j1Mj5nz1nidX8/sWY7119WL8vTAHgqfsY74TzW0w1XPgdYEKkGZ5A=="], + + "minipass": ["minipass@7.1.2", "", {}, "sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw=="], + + "minizlib": ["minizlib@3.0.2", "", { "dependencies": { "minipass": "^7.1.2" } }, "sha512-oG62iEk+CYt5Xj2YqI5Xi9xWUeZhDI8jjQmC5oThVH5JGCTgIjr7ciJDzC7MBzYd//WvR1OTmP5Q38Q8ShQtVA=="], + + "mkdirp": ["mkdirp@3.0.1", "", { "bin": { "mkdirp": "dist/cjs/src/bin.js" } }, "sha512-+NsyUUAZDmo6YVHzL/stxSu3t9YS1iljliy3BSDrXJ/dkn1KYdmtZODGGjLcc9XLgVVpH4KshHB8XmZgMhaBXg=="], + + "mmdb-lib": ["mmdb-lib@2.2.0", "", {}, "sha512-V6DDh3v8tfZFWbeH6fsL5uBIlWL7SvRgGDaAZWFC5kjQ2xP5dl/mLpWwJQ1Ho6ZbEKVp/351QF1JXYTAmeZ/zA=="], + + "module-error": ["module-error@1.0.2", "", {}, "sha512-0yuvsqSCv8LbaOKhnsQ/T5JhyFlCYLPXK3U2sgV10zoKQwzs/MyfuQUOZQ1V/6OCOJsK/TRgNVrPuPDqtdMFtA=="], + + "ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="], + + "napi-macros": ["napi-macros@2.2.2", "", {}, "sha512-hmEVtAGYzVQpCKdbQea4skABsdXW4RUh5t5mJ2zzqowJS2OyXZTU1KhDVFhx+NlWZ4ap9mqR9TcDO3LTTttd+g=="], + + "node-gyp-build": ["node-gyp-build@4.8.4", "", { "bin": { "node-gyp-build": "bin.js", "node-gyp-build-optional": "optional.js", "node-gyp-build-test": "build-test.js" } }, "sha512-LA4ZjwlnUblHVgq0oBF3Jl/6h/Nvs5fzBLwdEF4nuxnFdsfajde4WfxtJr3CaiH+F6ewcIB/q4jQ4UzPyid+CQ=="], + + "prettier": ["prettier@2.8.8", "", { "bin": { "prettier": "bin-prettier.js" } }, "sha512-tdN8qQGvNjw4CHbY+XXk0JgCXn9QiF21a55rBe5LJAU+kDyC4WQn4+awm2Xfk2lQMk5fKup9XgzTZtGkjBdP9Q=="], + + "process-nextick-args": ["process-nextick-args@1.0.7", "", {}, "sha512-yN0WQmuCX63LP/TMvAg31nvT6m4vDqJEiiv2CAZqWOGNWutc9DfDk1NPYYmKUFmaVM2UwDowH4u5AHWYP/jxKw=="], + + "raw-body": ["raw-body@2.5.2", "", { "dependencies": { "bytes": "3.1.2", "http-errors": "2.0.0", "iconv-lite": "0.4.24", "unpipe": "1.0.0" } }, "sha512-8zGqypfENjCIqGhgXToC8aB2r7YrBX+AQAfIPs/Mlk+BtPTztOvTS01NRW/3Eh60J+a48lt8qsCzirQ6loCVfA=="], + + "readable-stream": ["readable-stream@2.0.6", "", { "dependencies": { "core-util-is": "~1.0.0", "inherits": "~2.0.1", "isarray": "~1.0.0", "process-nextick-args": "~1.0.6", "string_decoder": "~0.10.x", "util-deprecate": "~1.0.1" } }, "sha512-TXcFfb63BQe1+ySzsHZI/5v1aJPCShfqvWJ64ayNImXMsN1Cd0YGk/wm8KB7/OeessgPc9QvS9Zou8QTkFzsLw=="], + + "requires-port": ["requires-port@1.0.0", "", {}, "sha512-KigOCHcocU3XODJxsu8i/j8T9tzT4adHiecwORRQ0ZZFcp7ahwXuRU1m+yuO90C5ZUyGeGfocHDI14M3L3yDAQ=="], + + "safer-buffer": ["safer-buffer@2.1.2", "", {}, "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg=="], + + "setprototypeof": ["setprototypeof@1.2.0", "", {}, "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw=="], + + "statuses": ["statuses@2.0.1", "", {}, "sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ=="], + + "streamx": ["streamx@2.22.0", "", { "dependencies": { "fast-fifo": "^1.3.2", "text-decoder": "^1.1.0" }, "optionalDependencies": { "bare-events": "^2.2.0" } }, "sha512-sLh1evHOzBy/iWRiR6d1zRcLao4gGZr3C1kzNz4fopCOKJb6xD9ub8Mpi9Mr1R6id5o43S+d93fI48UC5uM9aw=="], + + "string-dsa": ["string-dsa@2.1.0", "", {}, "sha512-ht+H83VtdA0JXmZsRfhQYzUSwqK3T7STPqiD/u3bIvYUHLEw8zzZyvP9WI3l9uKbK/2IpU+ZdshAe5BoRil3wA=="], + + "string_decoder": ["string_decoder@0.10.31", "", {}, "sha512-ev2QzSzWPYmy9GuqfIVildA4OdcGLeFZQrq5ys6RtiuF+RQQiZWr8TZNyAcuVXyQRYfEO+MsoB/1BuQVhOJuoQ=="], + + "tar": ["tar@7.4.3", "", { "dependencies": { "@isaacs/fs-minipass": "^4.0.0", "chownr": "^3.0.0", "minipass": "^7.1.2", "minizlib": "^3.0.1", "mkdirp": "^3.0.1", "yallist": "^5.0.0" } }, "sha512-5S7Va8hKfV7W5U6g3aYxXmlPoZVAwUMy9AOKyF2fVuZa2UD3qZjg578OrLRt8PcNN1PleVaL/5/yYATNL0ICUw=="], + + "tar-stream": ["tar-stream@3.1.7", "", { "dependencies": { "b4a": "^1.6.4", "fast-fifo": "^1.2.0", "streamx": "^2.15.0" } }, "sha512-qJj60CXt7IU1Ffyc3NJMjh6EkuCFej46zUqJ4J7pqYlThyd9bO0XBTmcOIhSzZJVWfsLks0+nle/j538YAW9RQ=="], + + "text-decoder": ["text-decoder@1.2.3", "", { "dependencies": { "b4a": "^1.6.4" } }, "sha512-3/o9z3X0X0fTupwsYvR03pJ/DjWuqqrfwBgTQzdWDiQSm9KitAyz/9WqsT2JQW7KV2m+bC2ol/zqpW37NHxLaA=="], + + "tiny-lru": ["tiny-lru@11.2.11", "", {}, "sha512-27BIW0dIWTYYoWNnqSmoNMKe5WIbkXsc0xaCQHd3/3xT2XMuMJrzHdrO9QBFR14emBz1Bu0dOAs2sCBBrvgPQA=="], + + "toidentifier": ["toidentifier@1.0.1", "", {}, "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA=="], + + "toml": ["toml@3.0.0", "", {}, "sha512-y/mWCZinnvxjTKYhJ+pYxwD0mRLVvOtdS2Awbgxln6iEnt4rk0yBxeSBHkGJcPucRiG0e55mwWp+g/05rsrd6w=="], + + "unpipe": ["unpipe@1.0.0", "", {}, "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ=="], + + "util-deprecate": ["util-deprecate@1.0.2", "", {}, "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw=="], + + "xtend": ["xtend@4.0.2", "", {}, "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ=="], + + "yallist": ["yallist@5.0.0", "", {}, "sha512-YgvUTfwqyc7UXVMrB+SImsVYSmTS8X/tSrtdNZMImM+n7+QTriRXyXim0mBrTXNeqzVF0KWGgHPeiyViFFrNDw=="], + } +} diff --git a/checkpoint.js b/checkpoint.js new file mode 100644 index 0000000..d1d4a3f --- /dev/null +++ b/checkpoint.js @@ -0,0 +1,961 @@ +import { registerPlugin, loadConfig, rootDir } from './index.js'; +import crypto from 'crypto'; +import path from 'path'; +import fs from 'fs'; +import { promises as fsPromises } from 'fs'; +import { dirname, join } from 'path'; +import { fileURLToPath } from 'url'; +import { Level } from 'level'; +import cookie from 'cookie'; +import { parseDuration } from './utils/time.js'; +import { getRealIP } from './utils/network.js'; +import ttl from 'level-ttl'; +import { Readable } from 'stream'; +import { + challengeStore, + generateRequestID as proofGenerateRequestID, + getChallengeParams, + deleteChallenge, + verifyPoW, + verifyPoS, +} from './utils/proof.js'; +// Import recordEvent dynamically to avoid circular dependency issues +let recordEvent; +let statsLoadPromise = import('./plugins/stats.js') + .then((stats) => { + recordEvent = stats.recordEvent; + }) + .catch((err) => { + console.error('Failed to import stats module:', err); + recordEvent = null; + }); + +function sanitizePath(inputPath) { + let pathOnly = inputPath.replace(/[\x00-\x1F\x7F]/g, ''); + + pathOnly = pathOnly.replace(/[<>;"'`|]/g, ''); + + const parts = pathOnly.split('/').filter((seg) => seg && seg !== '.' && seg !== '..'); + + return '/' + parts.map((seg) => encodeURIComponent(seg)).join('/'); +} + +const checkpointConfig = {}; +let hmacSecret = null; +const usedNonces = new Map(); +const ipRateLimit = new Map(); + +const tokenCache = new Map(); + +let db; + +const tokenExpirations = new Map(); + +let interstitialTemplate = null; + +const __dirname = dirname(fileURLToPath(import.meta.url)); + +function simpleTemplate(str) { + return function (data) { + return str.replace(/\{\{\s*([^{}]+?)\s*\}\}/g, (_, key) => { + let value = data; + + for (const part of key.trim().split('.')) { + value = value?.[part]; + if (value == null) break; + } + return value != null ? String(value) : ''; + }); + }; +} + +async function initConfig() { + await loadConfig('checkpoint', checkpointConfig); + + // Handle new nested configuration structure + // Map nested structure to flat structure for internal use + checkpointConfig.Enabled = checkpointConfig.Core.Enabled; + checkpointConfig.CookieName = checkpointConfig.Core.CookieName; + checkpointConfig.CookieDomain = checkpointConfig.Core.CookieDomain; + checkpointConfig.SanitizeURLs = checkpointConfig.Core.SanitizeURLs; + + // Proof of Work settings + checkpointConfig.Difficulty = checkpointConfig.ProofOfWork.Difficulty; + checkpointConfig.SaltLength = checkpointConfig.ProofOfWork.SaltLength; + checkpointConfig.ChallengeExpiration = parseDuration( + checkpointConfig.ProofOfWork.ChallengeExpiration, + ); + checkpointConfig.MaxAttemptsPerHour = checkpointConfig.ProofOfWork.MaxAttemptsPerHour; + + // Proof of Space-Time settings + checkpointConfig.CheckPoSTimes = checkpointConfig.ProofOfSpaceTime.Enabled; + checkpointConfig.PoSTimeConsistencyRatio = checkpointConfig.ProofOfSpaceTime.ConsistencyRatio; + + // Token settings + checkpointConfig.TokenExpiration = parseDuration(checkpointConfig.Token.Expiration); + checkpointConfig.MaxNonceAge = parseDuration(checkpointConfig.Token.MaxNonceAge); + + // Storage settings + checkpointConfig.SecretConfigPath = checkpointConfig.Storage.SecretPath; + checkpointConfig.TokenStoreDBPath = checkpointConfig.Storage.TokenDBPath; + checkpointConfig.InterstitialPaths = checkpointConfig.Storage.InterstitialTemplates; + + // Process exclusions + checkpointConfig.ExclusionRules = checkpointConfig.Exclusion || []; + + // Process bypass keys + checkpointConfig.BypassQueryKeys = []; + checkpointConfig.BypassHeaderKeys = []; + checkpointConfig.BypassKeys.forEach((key) => { + if (key.Type === 'query') { + checkpointConfig.BypassQueryKeys.push({ + Key: key.Key, + Value: key.Value, + Domains: key.Hosts || [], + }); + } else if (key.Type === 'header') { + checkpointConfig.BypassHeaderKeys.push({ + Name: key.Key, + Value: key.Value, + Domains: key.Hosts || [], + }); + } + }); + + // Extension handling + checkpointConfig.HTMLCheckpointIncludedExtensions = checkpointConfig.Extensions.IncludeOnly; + checkpointConfig.HTMLCheckpointExcludedExtensions = checkpointConfig.Extensions.Exclude; + + // Remove legacy arrays + checkpointConfig.HTMLCheckpointExclusions = []; + checkpointConfig.UserAgentValidationExclusions = []; + checkpointConfig.UserAgentRequiredPrefixes = {}; + checkpointConfig.ReverseProxyMappings = {}; +} + +function addReadStreamSupport(dbInstance) { + if (!dbInstance.createReadStream) { + dbInstance.createReadStream = (opts) => + Readable.from( + (async function* () { + for await (const [key, value] of dbInstance.iterator(opts)) { + yield { key, value }; + } + })(), + ); + } + return dbInstance; +} + +function initTokenStore() { + try { + const storePath = join(rootDir, checkpointConfig.TokenStoreDBPath || 'db/tokenstore'); + fs.mkdirSync(storePath, { recursive: true }); + + let rawDB = new Level(storePath, { valueEncoding: 'json' }); + + addReadStreamSupport(rawDB); + db = ttl(rawDB, { defaultTTL: checkpointConfig.TokenExpiration }); + + addReadStreamSupport(db); + console.log('Token store initialized with TTL'); + } catch (err) { + console.error('Failed to initialize token store:', err); + } +} + +function getFullClientIP(request) { + const ip = getRealIP(request) || ''; + const h = crypto.createHash('sha256').update(ip).digest(); + return h.slice(0, 8).toString('hex'); +} + +function hashUserAgent(ua) { + if (!ua) return ''; + const h = crypto.createHash('sha256').update(ua).digest(); + return h.slice(0, 8).toString('hex'); +} + +function extractBrowserFingerprint(request) { + const headers = [ + 'sec-ch-ua', + 'sec-ch-ua-platform', + 'sec-ch-ua-mobile', + 'sec-ch-ua-platform-version', + 'sec-ch-ua-arch', + 'sec-ch-ua-model', + ]; + const parts = headers.map((h) => request.headers.get(h)).filter(Boolean); + if (!parts.length) return ''; + const buf = Buffer.from(parts.join('|')); + const h = crypto.createHash('sha256').update(buf).digest(); + return h.slice(0, 12).toString('hex'); +} + +async function getInterstitialTemplate() { + if (!interstitialTemplate) { + for (const p of checkpointConfig.InterstitialPaths) { + try { + let templatePath = join(__dirname, p); + if (fs.existsSync(templatePath)) { + const raw = await fsPromises.readFile(templatePath, 'utf8'); + interstitialTemplate = simpleTemplate(raw); + break; + } + + templatePath = join(rootDir, p); + if (fs.existsSync(templatePath)) { + const raw = await fsPromises.readFile(templatePath, 'utf8'); + interstitialTemplate = simpleTemplate(raw); + break; + } + } catch (e) { + console.warn(`Failed to load interstitial template from path ${p}:`, e); + } + } + + if (!interstitialTemplate) { + // Create a minimal fallback template + console.warn('Could not find interstitial HTML template, using minimal fallback'); + interstitialTemplate = simpleTemplate(` + + + + Security Verification + + + + +

Security Verification Required

+

Please wait while we verify your request...

+
+
+ + + + `); + } + } + return interstitialTemplate; +} + +// Helper function for safe stats recording +function safeRecordEvent(metric, data) { + // If recordEvent is not yet loaded, try to wait for it + if (!recordEvent && statsLoadPromise) { + statsLoadPromise.then(() => { + if (recordEvent) { + try { + recordEvent(metric, data); + } catch (err) { + console.error(`Failed to record ${metric} event:`, err); + } + } + }); + return; + } + + if (typeof recordEvent === 'function') { + try { + recordEvent(metric, data); + } catch (err) { + console.error(`Failed to record ${metric} event:`, err); + } + } +} + +async function serveInterstitial(request) { + const ip = getRealIP(request); + const requestPath = new URL(request.url).pathname; + safeRecordEvent('checkpoint.sent', { ip, path: requestPath }); + let tpl; + try { + tpl = await getInterstitialTemplate(); + } catch (err) { + console.error('Interstitial template error:', err); + return new Response('Security verification required.', { + status: 200, + headers: { 'Content-Type': 'text/plain' }, + }); + } + + const requestID = proofGenerateRequestID(request, checkpointConfig); + const url = new URL(request.url); + const host = request.headers.get('host') || url.hostname; + const targetPath = url.pathname; + const fullURL = request.url; + + const html = tpl({ + TargetPath: targetPath, + RequestID: requestID, + Host: host, + FullURL: fullURL, + }); + + return new Response(html, { + status: 200, + headers: { 'Content-Type': 'text/html; charset=utf-8' }, + }); +} + +async function handleGetCheckpointChallenge(request) { + const url = new URL(request.url); + const requestID = url.searchParams.get('id'); + if (!requestID) { + return new Response(JSON.stringify({ error: 'Missing request ID' }), { + status: 400, + headers: { 'Content-Type': 'application/json' }, + }); + } + + const ip = getRealIP(request); + const attempts = (ipRateLimit.get(ip) || 0) + 1; + ipRateLimit.set(ip, attempts); + + if (attempts > checkpointConfig.MaxAttemptsPerHour) { + return new Response( + JSON.stringify({ error: 'Too many challenge requests. Try again later.' }), + { + status: 429, + headers: { 'Content-Type': 'application/json' }, + }, + ); + } + + const params = getChallengeParams(requestID); + if (!params) { + return new Response(JSON.stringify({ error: 'Challenge not found or expired' }), { + status: 404, + headers: { 'Content-Type': 'application/json' }, + }); + } + + if (ip !== params.ClientIP) { + return new Response(JSON.stringify({ error: 'IP address mismatch for challenge' }), { + status: 403, + headers: { 'Content-Type': 'application/json' }, + }); + } + + const payload = { + a: params.Challenge, + b: params.Salt, + c: params.Difficulty, + d: params.PoSSeed, + }; + return new Response(JSON.stringify(payload), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }); +} + +function calculateTokenHash(token) { + const data = `${token.Nonce}:${token.Entropy}:${token.Created.getTime()}`; + return crypto.createHash('sha256').update(data).digest('hex'); +} + +function computeTokenSignature(token) { + const copy = { ...token, Signature: '' }; + const serialized = JSON.stringify(copy); + return crypto.createHmac('sha256', hmacSecret).update(serialized).digest('hex'); +} + +function verifyTokenSignature(token) { + if (!token.Signature) return false; + const expected = computeTokenSignature(token); + try { + return crypto.timingSafeEqual( + Buffer.from(token.Signature, 'hex'), + Buffer.from(expected, 'hex'), + ); + } catch (e) { + return false; + } +} + +async function issueToken(request, token) { + const tokenHash = calculateTokenHash(token); + const storedData = { + ClientIPHash: token.ClientIP, + UserAgentHash: token.UserAgent, + BrowserHint: token.BrowserHint, + LastVerified: new Date(token.LastVerified).toISOString(), + ExpiresAt: new Date(token.ExpiresAt).toISOString(), + }; + + try { + await addToken(tokenHash, storedData); + } catch (err) { + console.error('Failed to store token:', err); + } + + token.Signature = computeTokenSignature(token); + + const tokenStr = Buffer.from(JSON.stringify(token)).toString('base64'); + + const url = new URL(request.url); + const cookieDomain = checkpointConfig.CookieDomain || ''; + const sameSite = cookieDomain ? 'Lax' : 'Strict'; + const secure = url.protocol === 'https:'; + const expires = new Date(token.ExpiresAt).toUTCString(); + + const domainPart = cookieDomain ? `; Domain=${cookieDomain}` : ''; + const securePart = secure ? '; Secure' : ''; + const cookieStr = + `${checkpointConfig.CookieName}=${tokenStr}; Path=/` + + `${domainPart}; Expires=${expires}; HttpOnly; SameSite=${sameSite}${securePart}`; + return new Response(JSON.stringify({ token: tokenStr, expires_at: token.ExpiresAt }), { + status: 200, + headers: { + 'Content-Type': 'application/json', + 'Set-Cookie': cookieStr, + }, + }); +} + +async function handleVerifyCheckpoint(request) { + let body; + try { + body = await request.json(); + } catch (e) { + safeRecordEvent('checkpoint.failure', { reason: 'invalid_json', ip: getRealIP(request) }); + return new Response(JSON.stringify({ error: 'Invalid JSON' }), { + status: 400, + headers: { 'Content-Type': 'application/json' }, + }); + } + + const ip = getRealIP(request); + const params = getChallengeParams(body.request_id); + + if (!params) { + safeRecordEvent('checkpoint.failure', { reason: 'invalid_or_expired_request', ip }); + return new Response(JSON.stringify({ error: 'Invalid or expired request ID' }), { + status: 400, + headers: { 'Content-Type': 'application/json' }, + }); + } + + if (ip !== params.ClientIP) { + safeRecordEvent('checkpoint.failure', { reason: 'ip_mismatch', ip }); + return new Response(JSON.stringify({ error: 'IP address mismatch' }), { + status: 403, + headers: { 'Content-Type': 'application/json' }, + }); + } + + const challenge = params.Challenge; + const salt = params.Salt; + + if (!body.g || !verifyPoW(challenge, salt, body.g, params.Difficulty)) { + safeRecordEvent('checkpoint.failure', { reason: 'invalid_pow', ip }); + return new Response(JSON.stringify({ error: 'Invalid proof-of-work solution' }), { + status: 400, + headers: { 'Content-Type': 'application/json' }, + }); + } + + const nonceKey = body.g + challenge; + usedNonces.set(nonceKey, Date.now()); + + if (body.h?.length === 3 && body.i?.length === 3) { + try { + verifyPoS(body.h, body.i, checkpointConfig); + } catch (e) { + safeRecordEvent('checkpoint.failure', { reason: 'invalid_pos', ip }); + return new Response(JSON.stringify({ error: e.message }), { + status: 400, + headers: { 'Content-Type': 'application/json' }, + }); + } + } + + deleteChallenge(body.request_id); + safeRecordEvent('checkpoint.success', { ip }); + const now = new Date(); + const expiresAt = new Date(now.getTime() + checkpointConfig.TokenExpiration); + + const token = { + Nonce: body.g, + ExpiresAt: expiresAt, + ClientIP: getFullClientIP(request), + UserAgent: hashUserAgent(request.headers.get('user-agent')), + BrowserHint: extractBrowserFingerprint(request), + Entropy: crypto.randomBytes(8).toString('hex'), + Created: now, + LastVerified: now, + TokenFormat: 2, + }; + + token.Signature = computeTokenSignature(token); + const tokenStr = Buffer.from(JSON.stringify(token)).toString('base64'); + + const tokenKey = crypto.createHash('sha256').update(tokenStr).digest('hex'); + try { + await db.put(tokenKey, true); + tokenCache.set(tokenKey, true); + + tokenExpirations.set(tokenKey, new Date(token.ExpiresAt).getTime()); + console.log(`checkpoint: token stored in DB and cache key=${tokenKey}`); + } catch (e) { + console.error('checkpoint: failed to store token in DB:', e); + } + + return new Response(JSON.stringify({ token: tokenStr, expires_at: token.ExpiresAt }), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }); +} + +function generateUpdatedCookie(token, secure) { + token.Signature = computeTokenSignature(token); + const tokenStr = Buffer.from(JSON.stringify(token)).toString('base64'); + const cookieDomain = checkpointConfig.CookieDomain || ''; + const sameSite = cookieDomain ? 'Lax' : 'Strict'; + const expires = new Date(token.ExpiresAt).toUTCString(); + + const domainPart = cookieDomain ? `; Domain=${cookieDomain}` : ''; + const securePart = secure ? '; Secure' : ''; + const cookieStr = + `${checkpointConfig.CookieName}=${tokenStr}; Path=/` + + `${domainPart}; Expires=${expires}; HttpOnly; SameSite=${sameSite}${securePart}`; + return cookieStr; +} + +async function validateToken(tokenStr, request) { + if (!tokenStr) return false; + let token; + try { + token = JSON.parse(Buffer.from(tokenStr, 'base64').toString()); + } catch { + console.log('checkpoint: invalid token format'); + return false; + } + + if (Date.now() > new Date(token.ExpiresAt).getTime()) { + console.log('checkpoint: token expired'); + return false; + } + + if (!verifyTokenSignature(token)) { + console.log('checkpoint: invalid token signature'); + return false; + } + + const tokenKey = crypto.createHash('sha256').update(tokenStr).digest('hex'); + + if (tokenCache.has(tokenKey)) return true; + + try { + await db.get(tokenKey); + tokenCache.set(tokenKey, true); + + tokenExpirations.set(tokenKey, new Date(token.ExpiresAt).getTime()); + return true; + } catch { + console.log('checkpoint: token not found in DB'); + return false; + } +} + +async function createProxyResponse(targetURL, request) { + const url = new URL(request.url); + const targetUrl = new URL(url.pathname + url.search, targetURL); + + const headers = Object.fromEntries(request.headers.entries()); + delete headers.host; + + try { + const method = request.method; + const options = { + method, + headers, + redirect: 'manual', + }; + + if (method !== 'GET' && method !== 'HEAD') { + options.body = await request.blob(); + } + + const response = await fetch(targetUrl.toString(), options); + + const responseHeaders = new Headers(response.headers); + const hopByHopHeaders = [ + 'connection', + 'keep-alive', + 'proxy-authenticate', + 'proxy-authorization', + 'te', + 'trailer', + 'transfer-encoding', + 'upgrade', + ]; + + hopByHopHeaders.forEach((h) => responseHeaders.delete(h)); + + return new Response(response.body, { + status: response.status, + statusText: response.statusText, + headers: responseHeaders, + }); + } catch (err) { + console.error('Proxy error:', err); + return new Response('Bad Gateway', { status: 502 }); + } +} + +async function handleTokenRedirect(request) { + const url = new URL(request.url); + const tokenStr = url.searchParams.get('token'); + if (!tokenStr) return undefined; + + let token; + try { + token = JSON.parse(Buffer.from(tokenStr, 'base64').toString()); + + if (Date.now() > new Date(token.ExpiresAt).getTime()) { + console.log('checkpoint: token in URL parameter expired'); + return undefined; + } + + if (!verifyTokenSignature(token)) { + console.log('checkpoint: invalid token signature in URL parameter'); + return undefined; + } + + const tokenKey = crypto.createHash('sha256').update(tokenStr).digest('hex'); + try { + await db.get(tokenKey); + } catch { + console.log('checkpoint: token in URL parameter not found in DB'); + return undefined; + } + } catch (e) { + console.log('checkpoint: invalid token format in URL parameter', e); + return undefined; + } + + const expires = new Date(token.ExpiresAt).toUTCString(); + const cookieDomain = checkpointConfig.CookieDomain || ''; + const sameSite = cookieDomain ? 'Lax' : 'Strict'; + const securePart = url.protocol === 'https:' ? '; Secure' : ''; + const domainPart = cookieDomain ? `; Domain=${cookieDomain}` : ''; + const cookieStr = + `${checkpointConfig.CookieName}=${tokenStr}; Path=/` + + `${domainPart}; Expires=${expires}; HttpOnly; SameSite=${sameSite}${securePart}`; + + url.searchParams.delete('token'); + const cleanUrl = url.pathname + (url.search || ''); + return new Response(null, { + status: 302, + headers: { + 'Set-Cookie': cookieStr, + Location: cleanUrl, + }, + }); +} + +function CheckpointMiddleware() { + return async (request) => { + // Check if checkpoint is enabled + if (checkpointConfig.Enabled === false) { + return undefined; + } + + const urlObj = new URL(request.url); + const host = request.headers.get('host')?.split(':')[0]; + const userAgent = request.headers.get('user-agent') || ''; + + // 1) Bypass via query keys + for (const { Key, Value, Domains } of checkpointConfig.BypassQueryKeys) { + if (urlObj.searchParams.get(Key) === Value) { + if (!Array.isArray(Domains) || Domains.length === 0 || Domains.includes(host)) { + return undefined; + } + } + } + + // 2) Bypass via header keys + for (const { Name, Value, Domains } of checkpointConfig.BypassHeaderKeys) { + const headerVal = request.headers.get(Name); + if (headerVal === Value) { + if (!Array.isArray(Domains) || Domains.length === 0 || Domains.includes(host)) { + return undefined; + } + } + } + + // Handle token redirect for URL-token login + const tokenResponse = await handleTokenRedirect(request); + if (tokenResponse) return tokenResponse; + + // Setup request context + const url = new URL(request.url); + let path = url.pathname; + if (checkpointConfig.SanitizeURLs) { + path = sanitizePath(path); + } + const method = request.method; + + // Always allow challenge & verify endpoints + if (method === 'GET' && path === '/api/challenge') { + return handleGetCheckpointChallenge(request); + } + if (method === 'POST' && path === '/api/verify') { + return handleVerifyCheckpoint(request); + } + + // Check new exclusion rules + if (checkpointConfig.ExclusionRules && checkpointConfig.ExclusionRules.length > 0) { + for (const rule of checkpointConfig.ExclusionRules) { + // Check if path matches + if (!rule.Path || !path.startsWith(rule.Path)) { + continue; + } + + // Check if host matches (if specified) + if (rule.Hosts && rule.Hosts.length > 0 && !rule.Hosts.includes(host)) { + continue; + } + + // Check if user agent matches (if specified) + if (rule.UserAgents && rule.UserAgents.length > 0) { + const matchesUA = rule.UserAgents.some((ua) => userAgent.includes(ua)); + if (!matchesUA) { + continue; + } + } + + // All conditions match - exclude this request + return undefined; + } + } + + // Check file extensions + const ext = path.includes('.') ? path.slice(path.lastIndexOf('.')) : ''; + + // First check excluded extensions + if (ext && checkpointConfig.HTMLCheckpointExcludedExtensions.includes(ext)) { + return undefined; + } + + // Then check if we should only include specific extensions + if (checkpointConfig.HTMLCheckpointIncludedExtensions.length > 0) { + // If extension list is specified and current extension is not in it, skip + if (!checkpointConfig.HTMLCheckpointIncludedExtensions.includes(ext)) { + return undefined; + } + } + + // Validate session token + const cookies = cookie.parse(request.headers.get('cookie') || ''); + const tokenCookie = cookies[checkpointConfig.CookieName]; + const validation = await validateToken(tokenCookie, request); + if (validation) { + // Active session: bypass checkpoint + return undefined; + } + + // Log new checkpoint flow + console.log(`checkpoint: incoming ${method} ${request.url}`); + console.log(`checkpoint: tokenCookie=${tokenCookie}`); + console.log(`checkpoint: validateToken => ${validation}`); + + // Serve interstitial challenge + return serveInterstitial(request); + }; +} + +async function addToken(tokenHash, data) { + if (!db) return; + try { + const ttlMs = checkpointConfig.TokenExpiration; + + await db.put(tokenHash, data); + + tokenExpirations.set(tokenHash, Date.now() + ttlMs); + } catch (err) { + console.error('Error adding token:', err); + } +} + +async function updateTokenVerification(tokenHash) { + if (!db) return; + try { + const data = await db.get(tokenHash); + data.LastVerified = new Date().toISOString(); + await db.put(tokenHash, data); + } catch (err) { + console.error('Error updating token verification:', err); + } +} + +async function lookupTokenData(tokenHash) { + if (!db) return { data: null, found: false }; + try { + const expireTime = tokenExpirations.get(tokenHash); + if (!expireTime || expireTime <= Date.now()) { + if (expireTime) { + tokenExpirations.delete(tokenHash); + try { + await db.del(tokenHash); + } catch (e) {} + } + return { data: null, found: false }; + } + + const data = await db.get(tokenHash); + return { data, found: true }; + } catch (err) { + if (err.code === 'LEVEL_NOT_FOUND') return { data: null, found: false }; + console.error('Error looking up token:', err); + throw err; + } +} + +async function closeTokenStore() { + if (db) await db.close(); +} + +function startCleanupTimer() { + // Cleanup expired data hourly + setInterval(() => { + cleanupExpiredData(); + }, 3600000); + // Cleanup expired challenges at the challenge expiration interval + const challengeInterval = checkpointConfig.ChallengeExpiration || 60000; + setInterval(() => { + cleanupExpiredChallenges(); + }, challengeInterval); +} + +function cleanupExpiredData() { + const now = Date.now(); + let count = 0; + + try { + for (const [nonce, ts] of usedNonces.entries()) { + if (now - ts > checkpointConfig.MaxNonceAge) { + usedNonces.delete(nonce); + count++; + } + } + if (count) console.log(`Checkpoint: cleaned up ${count} expired nonces.`); + } catch (err) { + console.error('Error cleaning up nonces:', err); + } + + // Clean up expired tokens from cache + let tokenCacheCount = 0; + try { + for (const [tokenKey, _] of tokenCache.entries()) { + const expireTime = tokenExpirations.get(tokenKey); + if (!expireTime || expireTime <= now) { + tokenCache.delete(tokenKey); + tokenExpirations.delete(tokenKey); + tokenCacheCount++; + } + } + if (tokenCacheCount) + console.log(`Checkpoint: cleaned up ${tokenCacheCount} expired tokens from cache.`); + } catch (err) { + console.error('Error cleaning up token cache:', err); + } + + try { + ipRateLimit.clear(); + console.log('Checkpoint: IP rate limits reset.'); + } catch (err) { + console.error('Error resetting IP rate limits:', err); + } +} + +function cleanupExpiredChallenges() { + const now = Date.now(); + let count = 0; + for (const [id, params] of challengeStore.entries()) { + if (params.ExpiresAt && params.ExpiresAt < now) { + // Record failure for expired challenges that were never completed + safeRecordEvent('checkpoint.failure', { + reason: 'challenge_expired', + ip: params.ClientIP, + challenge_id: id.substring(0, 8), // Include partial ID for debugging + age_ms: now - params.CreatedAt, // How long the challenge existed + expiry_ms: checkpointConfig.ChallengeExpiration, // Configured expiry time + }); + challengeStore.delete(id); + count++; + } + } + if (count) console.log(`Checkpoint: cleaned up ${count} expired challenges.`); +} + +async function initSecret() { + try { + if (!checkpointConfig.SecretConfigPath) { + checkpointConfig.SecretConfigPath = join(rootDir, 'data', 'checkpoint_secret.json'); + } + + const secretPath = checkpointConfig.SecretConfigPath; + const exists = fs.existsSync(secretPath); + + if (exists) { + const loaded = loadSecretFromFile(); + if (loaded) { + hmacSecret = loaded; + console.log(`Loaded existing HMAC secret from ${secretPath}`); + return; + } + } + + hmacSecret = crypto.randomBytes(32); + fs.mkdirSync(path.dirname(secretPath), { recursive: true }); + + const secretCfg = { + hmac_secret: hmacSecret.toString('base64'), + created_at: new Date().toISOString(), + updated_at: new Date().toISOString(), + }; + + fs.writeFileSync(secretPath, JSON.stringify(secretCfg), { mode: 0o600 }); + console.log(`Created and saved new HMAC secret to ${secretPath}`); + } catch (err) { + console.error('Error initializing secret:', err); + + hmacSecret = crypto.randomBytes(32); + } +} + +function loadSecretFromFile() { + try { + const data = fs.readFileSync(checkpointConfig.SecretConfigPath, 'utf8'); + const cfg = JSON.parse(data); + const buf = Buffer.from(cfg.hmac_secret, 'base64'); + if (buf.length < 16) return null; + + cfg.updated_at = new Date().toISOString(); + fs.writeFileSync(checkpointConfig.SecretConfigPath, JSON.stringify(cfg), { mode: 0o600 }); + return buf; + } catch (e) { + console.warn('Could not load HMAC secret from file:', e); + return null; + } +} + +(async function initialize() { + await initConfig(); + await initSecret(); + initTokenStore(); + startCleanupTimer(); + + // Only register plugin if enabled + if (checkpointConfig.Enabled !== false) { + registerPlugin('checkpoint', CheckpointMiddleware()); + } else { + console.log('Checkpoint plugin disabled via configuration'); + } +})(); + +export { checkpointConfig, addToken, updateTokenVerification, lookupTokenData, closeTokenStore }; diff --git a/config/checkpoint.toml b/config/checkpoint.toml new file mode 100644 index 0000000..a01cec6 --- /dev/null +++ b/config/checkpoint.toml @@ -0,0 +1,143 @@ +# ============================================================================= +# CHECKPOINT SECURITY CONFIGURATION +# ============================================================================= +# This configuration controls the checkpoint security middleware that protects +# your services with proof-of-work challenges and token-based authentication. +# ============================================================================= + +# ----------------------------------------------------------------------------- +# CORE SETTINGS +# ----------------------------------------------------------------------------- +[Core] +# Enable or disable the checkpoint system entirely +Enabled = true + +# Cookie name for storing checkpoint tokens +CookieName = "checkpoint_token" + +# Cookie domain (empty = host-only cookie for localhost) +# Set to ".yourdomain.com" for all subdomains +CookieDomain = "" + +# Enable URL path sanitization to prevent path traversal attacks +SanitizeURLs = true + +# ----------------------------------------------------------------------------- +# PROOF OF WORK SETTINGS +# ----------------------------------------------------------------------------- +[ProofOfWork] +# Number of leading zeros required in the SHA-256 hash +Difficulty = 4 + +# Random salt length in bytes +SaltLength = 16 + +# Time allowed to solve a challenge before it expires +ChallengeExpiration = "3m" + +# Maximum attempts per IP address per hour +MaxAttemptsPerHour = 10 + +# ----------------------------------------------------------------------------- +# PROOF OF SPACE-TIME SETTINGS (Optional additional verification) +# ----------------------------------------------------------------------------- +[ProofOfSpaceTime] +# Enable consistency checks for PoS-Time verification +Enabled = true + +# Maximum allowed ratio between slowest and fastest PoS runs +ConsistencyRatio = 1.35 + +# ----------------------------------------------------------------------------- +# TOKEN SETTINGS +# ----------------------------------------------------------------------------- +[Token] +# How long tokens remain valid +Expiration = "24h" + +# Maximum age for used nonces before cleanup +MaxNonceAge = "24h" + +# ----------------------------------------------------------------------------- +# STORAGE PATHS +# ----------------------------------------------------------------------------- +[Storage] +# HMAC secret storage location +SecretPath = "./data/checkpoint_secret.json" + +# Token database directory +TokenDBPath = "./db/tokenstore" + +# Interstitial page templates (in order of preference) +InterstitialTemplates = [ + "/pages/interstitial/page.html", + "/pages/ipfilter/default.html" +] + +# ----------------------------------------------------------------------------- +# EXCLUSION RULES +# ----------------------------------------------------------------------------- +# Define which requests should bypass the checkpoint system. +# Each rule can specify: +# - Path (required): URL path or prefix to match +# - Hosts (optional): Specific hostnames this rule applies to +# - UserAgents (optional): User-Agent patterns to match +# ----------------------------------------------------------------------------- + +[[Exclusion]] +# Skip checkpoint for all API endpoints (required for Immich and similar apps) +Path = "/api" +Hosts = ["gallery.caileb.com"] # Optional: only for specific hosts + +[[Exclusion]] +# Skip checkpoint for health checks +Path = "/health" + +[[Exclusion]] +# Skip checkpoint for metrics endpoint +Path = "/metrics" + +# [[Exclusion]] +# Example: Mobile app API with specific user agent +# Path = "/mobile-api" +# UserAgents = ["MyApp/", "Dart/"] + +# [[Exclusion]] +# Example: Host-specific exclusion +# Path = "/admin" +# Hosts = ["admin.internal.com"] + +# ----------------------------------------------------------------------------- +# BYPASS KEYS +# ----------------------------------------------------------------------------- +# Special keys that can bypass the checkpoint when provided + +[[BypassKeys]] +# Query parameter bypass +Type = "query" +Key = "bypass_key" +Value = "your-secret-key-here" +Hosts = ["music.caileb.com"] # Optional: restrict to specific hosts + +[[BypassKeys]] +# Header bypass +Type = "header" +Key = "X-Bypass-Token" +Value = "another-secret-key" +# Hosts = [] # If empty or omitted, applies to all hosts + +# ----------------------------------------------------------------------------- +# FILE EXTENSION HANDLING +# ----------------------------------------------------------------------------- +[Extensions] +# Only apply checkpoint to these file extensions (for HTML content) +# Empty = check all paths +IncludeOnly = [".html", ".htm", ".shtml", ""] + +# Never apply checkpoint to these file extensions +# This takes precedence over IncludeOnly +Exclude = [ + ".js", ".css", ".png", ".jpg", ".jpeg", ".gif", ".svg", + ".ico", ".woff", ".woff2", ".ttf", ".eot", ".map", + ".json", ".xml", ".txt", ".webp", ".avif" +] \ No newline at end of file diff --git a/config/ipfilter.toml b/config/ipfilter.toml new file mode 100644 index 0000000..4c7f86f --- /dev/null +++ b/config/ipfilter.toml @@ -0,0 +1,92 @@ +# ============================================================================= +# IP FILTER CONFIGURATION +# ============================================================================= +# This configuration controls the IP filtering middleware that blocks requests +# based on geographic location (country/continent) and network (ASN) information. +# ============================================================================= + +# ----------------------------------------------------------------------------- +# CORE SETTINGS +# ----------------------------------------------------------------------------- +[Core] +# Enable or disable the IP filter entirely +Enabled = true + +# MaxMind account ID for downloading GeoIP databases +# Can also be set via MAXMIND_ACCOUNT_ID environment variable or .env file +AccountID = "" + +# MaxMind license key for downloading GeoIP databases +# Can also be set via MAXMIND_LICENSE_KEY environment variable or .env file +LicenseKey = "" + +# How often to check for database updates (in hours) +DBUpdateIntervalHours = 12 + +# ----------------------------------------------------------------------------- +# CACHING SETTINGS +# ----------------------------------------------------------------------------- +[Cache] +# TTL for cached IP block decisions (in seconds) +# 0 = cache indefinitely until server restart +IPBlockCacheTTLSec = 300 + +# Maximum number of cached IP decisions +# 0 = unlimited +IPBlockCacheMaxEntries = 10000 + +# ----------------------------------------------------------------------------- +# BLOCKING RULES +# ----------------------------------------------------------------------------- +[Blocking] +# ISO country codes to block (2-letter codes) +CountryCodes = [ + "IN", "BH", "AE", "OM", "QA", "KW", "SA", "YE", "IR", "IQ", + "LB", "PS", "CY", "TR", "AZ", "AM", "TM", "UZ", "KZ", "KG", + "TJ", "KE", "ET", "SO", "SD", "SS", "KP", "UA", "IL" +] + +# Continent codes to block +ContinentCodes = ["AF", "SA", "AS", "AN"] + +# Default block page when no specific page is configured +DefaultBlockPage = "/pages/ipfilter/default.html" + +# ----------------------------------------------------------------------------- +# ASN BLOCKING +# ----------------------------------------------------------------------------- +# Block by Autonomous System Number (ASN) +# Group ASNs by category for different block pages + +# [ASN.Example] +# Numbers = [12345, 67890] +# BlockPage = "pages/ipfilter/example.html" + +# ----------------------------------------------------------------------------- +# ASN NAME BLOCKING +# ----------------------------------------------------------------------------- +# Block by ASN organization name patterns + +[ASNNames.DataCenter] +# Block data center and cloud providers +Patterns = [ + "Cloudflare", "GOOGLE-CLOUD-PLATFORM", "Microsoft", "Amazon", "AWS", + "Digitalocean", "OVH", "HUAWEI CLOUDS", "HWCLOUDS", "M247", + "Datacamp", "Datapacket", "Amanah", "Hern Labs" +] +BlockPage = "/pages/ipfilter/datacenter.html" + +# ----------------------------------------------------------------------------- +# COUNTRY-SPECIFIC BLOCK PAGES +# ----------------------------------------------------------------------------- +[CountryBlockPages] +# Custom block pages for specific countries +IN = "/pages/ipfilter/india.html" + +# ----------------------------------------------------------------------------- +# CONTINENT-SPECIFIC BLOCK PAGES +# ----------------------------------------------------------------------------- +[ContinentBlockPages] +# Custom block pages for specific continents +# AS = "pages/ipfilter/asia.html" +# AF = "pages/ipfilter/africa.html" \ No newline at end of file diff --git a/config/proxy.toml b/config/proxy.toml new file mode 100644 index 0000000..b009c94 --- /dev/null +++ b/config/proxy.toml @@ -0,0 +1,55 @@ +# ============================================================================= +# PROXY CONFIGURATION +# ============================================================================= +# This configuration controls the reverse proxy middleware that forwards +# requests to backend services based on hostname mappings. +# ============================================================================= + +# ----------------------------------------------------------------------------- +# CORE SETTINGS +# ----------------------------------------------------------------------------- +[Core] +# Enable or disable the proxy middleware +Enabled = true + +# ----------------------------------------------------------------------------- +# TIMEOUT SETTINGS +# ----------------------------------------------------------------------------- +[Timeouts] +# WebSocket connection timeout in milliseconds +WebSocketTimeoutMs = 5000 + +# Upstream HTTP request timeout in milliseconds +UpstreamTimeoutMs = 30000 + +# ----------------------------------------------------------------------------- +# PROXY MAPPINGS +# ----------------------------------------------------------------------------- +# Map hostnames to backend service URLs +# Format: "hostname" = "backend_url" +# ----------------------------------------------------------------------------- + +[[Mapping]] +# Immich +Host = "gallery.caileb.com" +Target = "http://192.168.0.2:2283" + +[[Mapping]] +# Navidrome +Host = "music.caileb.com" +Target = "http://192.168.0.2:4533" + +[[Mapping]] +# ForgeJo +Host = "git.caileb.com" +Target = "http://192.168.0.2:3053" + +# [[Mapping]] +# Example: API service +# Host = "api.example.com" +# Target = "http://localhost:3001" + +# [[Mapping]] +# Example: Admin panel +# Host = "admin.example.com" +# Target = "http://localhost:3002" \ No newline at end of file diff --git a/config/stats.toml b/config/stats.toml new file mode 100644 index 0000000..3819696 --- /dev/null +++ b/config/stats.toml @@ -0,0 +1,31 @@ +# ============================================================================= +# STATS CONFIGURATION +# ============================================================================= +# This configuration controls the statistics collection and visualization +# middleware that tracks events and provides a web UI for viewing metrics. +# ============================================================================= + +# ----------------------------------------------------------------------------- +# CORE SETTINGS +# ----------------------------------------------------------------------------- +[Core] +# Enable or disable the stats plugin +Enabled = true + +# ----------------------------------------------------------------------------- +# STORAGE SETTINGS +# ----------------------------------------------------------------------------- +[Storage] +# TTL for stats entries +# Format: "30d", "24h", "1h", etc. +StatsTTL = "30d" + +# ----------------------------------------------------------------------------- +# WEB UI SETTINGS +# ----------------------------------------------------------------------------- +[WebUI] +# Path for stats UI +StatsUIPath = "/stats" + +# Path for stats API +StatsAPIPath = "/stats/api" \ No newline at end of file diff --git a/index.js b/index.js new file mode 100644 index 0000000..d210683 --- /dev/null +++ b/index.js @@ -0,0 +1,197 @@ +import { mkdir, readFile } from 'fs/promises'; +import { writeFileSync, readFileSync, unlinkSync, existsSync } from 'fs'; +import { join, dirname } from 'path'; +import { fileURLToPath } from 'url'; +import { secureImportModule } from './utils/plugins.js'; +import * as logs from './utils/logs.js'; + +// Load environment variables from .env file +import dotenv from 'dotenv'; +dotenv.config(); + +// Stop daemon: if run with -k, kill the running process and exit. +if (process.argv.includes('-k')) { + const pidFile = join(dirname(fileURLToPath(import.meta.url)), 'checkpoint.pid'); + if (existsSync(pidFile)) { + const pid = parseInt(readFileSync(pidFile, 'utf8'), 10); + try { + process.kill(pid); + unlinkSync(pidFile); + console.log(`Stopped daemon (pid ${pid})`); + } catch (err) { + console.error(`Failed to stop pid ${pid}: ${err}`); + } + } else { + console.error(`No pid file found at ${pidFile}`); + } + process.exit(0); +} + +// Daemonize: if run with -d, kill any existing daemon, then re-spawn detached, write pid file, and exit parent. +if (process.argv.includes('-d')) { + const pidFile = join(dirname(fileURLToPath(import.meta.url)), 'checkpoint.pid'); + // If already running, stop the old daemon + if (existsSync(pidFile)) { + const oldPid = parseInt(readFileSync(pidFile, 'utf8'), 10); + try { + process.kill(oldPid); + console.log(`Stopped old daemon (pid ${oldPid})`); + } catch (e) { + console.error(`Failed to stop old daemon (pid ${oldPid}): ${e}`); + } + try { + unlinkSync(pidFile); + } catch {} + } + // Spawn new background process + const args = process.argv.slice(1).filter((arg) => arg !== '-d'); + const cp = Bun.spawn({ + cmd: [process.argv[0], ...args], + detached: true, + stdio: ['ignore', 'ignore', 'ignore'], + }); + cp.unref(); + writeFileSync(pidFile, cp.pid.toString(), 'utf8'); + console.log(`Daemonized (pid ${cp.pid}), pid stored in ${pidFile}`); + process.exit(0); +} + +const pluginRegistry = []; +export function registerPlugin(pluginName, handler) { + pluginRegistry.push({ name: pluginName, handler }); +} +/** + * Return the array of middleware handlers in registration order. + */ +export function loadPlugins() { + return pluginRegistry.map((item) => item.handler); +} +/** + * Return the names of all registered plugins. + */ +export function getRegisteredPluginNames() { + return pluginRegistry.map((item) => item.name); +} +/** + * Freeze plugin registry to prevent further registration and log the final set. + */ +export function freezePlugins() { + Object.freeze(pluginRegistry); + pluginRegistry.forEach((item) => Object.freeze(item)); + logs.msg('Plugin registration frozen'); +} + +// Determine root directory for config loading +const __dirname = dirname(fileURLToPath(import.meta.url)); +export const rootDir = __dirname; + +export async function loadConfig(name, target) { + const configPath = join(rootDir, 'config', `${name}.toml`); + const txt = await readFile(configPath, 'utf8'); + const { default: toml } = await import('@iarna/toml'); + Object.assign(target, toml.parse(txt)); + logs.config(name, 'loaded'); +} + +async function initDataDirectories() { + logs.section('INIT'); + const directories = [join(rootDir, 'data'), join(rootDir, 'db'), join(rootDir, 'config')]; + for (const dirPath of directories) { + try { + await mkdir(dirPath, { recursive: true }); + } catch {} + } + logs.init('Data directories are now in place'); +} + +function staticFileMiddleware() { + return async (request) => { + const url = new URL(request.url); + const pathname = url.pathname; + if (pathname.startsWith('/webfont/') || pathname.startsWith('/js/')) { + const filePath = join(rootDir, 'pages/interstitial', pathname.slice(1)); + try { + return new Response(Bun.file(filePath), { + headers: { 'Cache-Control': 'public, max-age=604800' }, + }); + } catch { + return new Response('Not Found', { status: 404 }); + } + } + return undefined; + }; +} + +async function main() { + await initDataDirectories(); + + logs.section('CONFIG'); + logs.config('checkpoint', 'loaded'); + logs.config('ipfilter', 'loaded'); + logs.config('proxy', 'loaded'); + logs.config('stats', 'loaded'); + + logs.section('OPERATIONS'); + let wsHandler; + try { + await secureImportModule('checkpoint.js'); + } catch (e) { + logs.error('checkpoint', `Failed to load checkpoint plugin: ${e}`); + } + try { + await secureImportModule('plugins/ipfilter.js'); + } catch (e) { + logs.error('ipfilter', `Failed to load IP filter plugin: ${e}`); + } + try { + await secureImportModule('plugins/proxy.js'); + const mod = await import('./plugins/proxy.js'); + wsHandler = mod.proxyWebSocketHandler; + } catch (e) { + logs.error('proxy', `Failed to load proxy plugin: ${e}`); + } + try { + await secureImportModule('plugins/stats.js'); + } catch (e) { + logs.error('stats', `Failed to load stats plugin: ${e}`); + } + + registerPlugin('static', staticFileMiddleware()); + + logs.section('PLUGINS'); + // Ensure ipfilter runs first by moving it to front of the registry + const ipIndex = pluginRegistry.findIndex((item) => item.name === 'ipfilter'); + if (ipIndex > 0) { + const [ipEntry] = pluginRegistry.splice(ipIndex, 1); + pluginRegistry.unshift(ipEntry); + } + pluginRegistry.forEach((item) => logs.msg(item.name)); + logs.section('SYSTEM'); + freezePlugins(); + + logs.section('SERVER'); + const portNumber = Number(process.env.PORT || 3000); + const middlewareHandlers = loadPlugins(); + logs.server(`🚀 Server is up and running on port ${portNumber}...`); + logs.section('REQ LOGS'); + Bun.serve({ + port: portNumber, + async fetch(request, server) { + for (const handler of middlewareHandlers) { + try { + const resp = await handler(request, server); + if (resp instanceof Response) return resp; + } catch (err) { + logs.error('server', `Handler error: ${err}`); + } + } + return new Response('Not Found', { status: 404 }); + }, + websocket: wsHandler, + error(err) { + return new Response(`Server Error: ${err.message}`, { status: 500 }); + }, + }); +} + +main(); diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..880800e --- /dev/null +++ b/package-lock.json @@ -0,0 +1,728 @@ +{ + "name": "checkpoint", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "checkpoint", + "dependencies": { + "@iarna/toml": "^2.2.5", + "cookie": "^1.0.2", + "dotenv": "^16.5.0", + "express-http-proxy": "^2.1.1", + "http-proxy": "^1.18.1", + "level": "^10.0.0", + "level-ttl": "^3.1.1", + "maxmind": "^4.3.25", + "string-dsa": "^2.1.0", + "tar": "^7.4.3", + "tar-stream": "^3.1.7", + "toml": "^3.0.0" + }, + "devDependencies": { + "prettier": "^2.8.8" + } + }, + "node_modules/@iarna/toml": { + "version": "2.2.5", + "resolved": "https://registry.npmjs.org/@iarna/toml/-/toml-2.2.5.tgz", + "integrity": "sha512-trnsAYxU3xnS1gPHPyU961coFyLkh4gAD/0zQ5mymY4yOZ+CYvsPqUbOFSw0aDM4y0tV7tiFxL/1XfXPNC6IPg==", + "license": "ISC" + }, + "node_modules/@isaacs/fs-minipass": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/@isaacs/fs-minipass/-/fs-minipass-4.0.1.tgz", + "integrity": "sha512-wgm9Ehl2jpeqP3zw/7mo3kRHFp5MEDhqAdwy1fTGkHAwnkGOVsgpvQhL8B5n1qlb01jV3n/bI0ZfZp5lWA1k4w==", + "license": "ISC", + "dependencies": { + "minipass": "^7.0.4" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/abstract-level": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/abstract-level/-/abstract-level-3.1.0.tgz", + "integrity": "sha512-j2e+TsAxy7Ri+0h7dJqwasymgt0zHBWX4+nMk3XatyuqgHfdstBJ9wsMfbiGwE1O+QovRyPcVAqcViMYdyPaaw==", + "license": "MIT", + "dependencies": { + "buffer": "^6.0.3", + "is-buffer": "^2.0.5", + "level-supports": "^6.2.0", + "level-transcoder": "^1.0.1", + "maybe-combine-errors": "^1.0.0", + "module-error": "^1.0.1" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/after": { + "version": "0.8.2", + "resolved": "https://registry.npmjs.org/after/-/after-0.8.2.tgz", + "integrity": "sha512-QbJ0NTQ/I9DI3uSJA4cbexiwQeRAfjPScqIbSjUDd9TOrcg6pTkdgziesOqxBMBzit8vFCTwrP27t13vFOORRA==", + "license": "MIT" + }, + "node_modules/b4a": { + "version": "1.6.7", + "resolved": "https://registry.npmjs.org/b4a/-/b4a-1.6.7.tgz", + "integrity": "sha512-OnAYlL5b7LEkALw87fUVafQw5rVR9RjwGd4KUwNQ6DrrNmaVaUCgLipfVlzrPQ4tWOR9P0IXGNOx50jYCCdSJg==", + "license": "Apache-2.0" + }, + "node_modules/bare-events": { + "version": "2.5.4", + "resolved": "https://registry.npmjs.org/bare-events/-/bare-events-2.5.4.tgz", + "integrity": "sha512-+gFfDkR8pj4/TrWCGUGWmJIkBwuxPS5F+a5yWjOHQt2hHvNZd5YLzadjmDUtFmMM4y429bnKLa8bYBMHcYdnQA==", + "license": "Apache-2.0", + "optional": true + }, + "node_modules/base64-js": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", + "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/browser-level": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/browser-level/-/browser-level-3.0.0.tgz", + "integrity": "sha512-kGXtLh29jMwqKaskz5xeDLtCtN1KBz/DbQSqmvH7QdJiyGRC7RAM8PPg6gvUiNMa+wVnaxS9eSmEtP/f5ajOVw==", + "license": "MIT", + "dependencies": { + "abstract-level": "^3.1.0" + } + }, + "node_modules/buffer": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-6.0.3.tgz", + "integrity": "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "base64-js": "^1.3.1", + "ieee754": "^1.2.1" + } + }, + "node_modules/bytes": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", + "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/chownr": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/chownr/-/chownr-3.0.0.tgz", + "integrity": "sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g==", + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=18" + } + }, + "node_modules/classic-level": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/classic-level/-/classic-level-3.0.0.tgz", + "integrity": "sha512-yGy8j8LjPbN0Bh3+ygmyYvrmskVita92pD/zCoalfcC9XxZj6iDtZTAnz+ot7GG8p9KLTG+MZ84tSA4AhkgVZQ==", + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "abstract-level": "^3.1.0", + "module-error": "^1.0.1", + "napi-macros": "^2.2.2", + "node-gyp-build": "^4.3.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/cookie": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-1.0.2.tgz", + "integrity": "sha512-9Kr/j4O16ISv8zBBhJoi4bXOYNTkFLOqSL3UDB0njXxCXNezjeyVrJyGOWtgfs/q2km1gwBcfH8q1yEGoMYunA==", + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/core-util-is": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz", + "integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==", + "license": "MIT" + }, + "node_modules/debug": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/debug/-/debug-3.2.7.tgz", + "integrity": "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.1" + } + }, + "node_modules/depd": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", + "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/dotenv": { + "version": "16.5.0", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.5.0.tgz", + "integrity": "sha512-m/C+AwOAr9/W1UOIZUo232ejMNnJAJtYQjUbHoNTBNTJSvqzzDh7vnrei3o3r3m9blf6ZoDkvcw0VmozNRFJxg==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://dotenvx.com" + } + }, + "node_modules/es6-promise": { + "version": "4.2.8", + "resolved": "https://registry.npmjs.org/es6-promise/-/es6-promise-4.2.8.tgz", + "integrity": "sha512-HJDGx5daxeIvxdBxvG2cb9g4tEvwIk3i8+nhX0yGrYmZUzbkdg8QbDevheDB8gd0//uPj4c1EQua8Q+MViT0/w==", + "license": "MIT" + }, + "node_modules/eventemitter3": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-4.0.7.tgz", + "integrity": "sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw==", + "license": "MIT" + }, + "node_modules/express-http-proxy": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/express-http-proxy/-/express-http-proxy-2.1.1.tgz", + "integrity": "sha512-4aRQRqDQU7qNPV5av0/hLcyc0guB9UP71nCYrQEYml7YphTo8tmWf3nDZWdTJMMjFikyz9xKXaURor7Chygdwg==", + "license": "MIT", + "dependencies": { + "debug": "^3.0.1", + "es6-promise": "^4.1.1", + "raw-body": "^2.3.0" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/fast-fifo": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/fast-fifo/-/fast-fifo-1.3.2.tgz", + "integrity": "sha512-/d9sfos4yxzpwkDkuN7k2SqFKtYNmCTzgfEpz82x34IM9/zc8KGxQoXg1liNC/izpRM/MBdt44Nmx41ZWqk+FQ==", + "license": "MIT" + }, + "node_modules/follow-redirects": { + "version": "1.15.9", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.9.tgz", + "integrity": "sha512-gew4GsXizNgdoRyqmyfMHyAmXsZDk6mHkSxZFCzW9gwlbtOW44CDtYavM+y+72qD/Vq2l550kMF52DT8fOLJqQ==", + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/RubenVerborgh" + } + ], + "license": "MIT", + "engines": { + "node": ">=4.0" + }, + "peerDependenciesMeta": { + "debug": { + "optional": true + } + } + }, + "node_modules/http-errors": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.0.tgz", + "integrity": "sha512-FtwrG/euBzaEjYeRqOgly7G0qviiXoJWnvEH2Z1plBdXgbyjv34pHTSb9zoeHMyDy33+DWy5Wt9Wo+TURtOYSQ==", + "license": "MIT", + "dependencies": { + "depd": "2.0.0", + "inherits": "2.0.4", + "setprototypeof": "1.2.0", + "statuses": "2.0.1", + "toidentifier": "1.0.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/http-proxy": { + "version": "1.18.1", + "resolved": "https://registry.npmjs.org/http-proxy/-/http-proxy-1.18.1.tgz", + "integrity": "sha512-7mz/721AbnJwIVbnaSv1Cz3Am0ZLT/UBwkC92VlxhXv/k/BBQfM2fXElQNC27BVGr0uwUpplYPQM9LnaBMR5NQ==", + "license": "MIT", + "dependencies": { + "eventemitter3": "^4.0.0", + "follow-redirects": "^1.0.0", + "requires-port": "^1.0.0" + }, + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/iconv-lite": { + "version": "0.4.24", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz", + "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/ieee754": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", + "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "BSD-3-Clause" + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "license": "ISC" + }, + "node_modules/is-buffer": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/is-buffer/-/is-buffer-2.0.5.tgz", + "integrity": "sha512-i2R6zNFDwgEHJyQUtJEk0XFi1i0dPFn/oqjK3/vPCcDeJvW5NQ83V8QbicfF1SupOaB0h8ntgBC2YiE7dfyctQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/isarray": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", + "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==", + "license": "MIT" + }, + "node_modules/level": { + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/level/-/level-10.0.0.tgz", + "integrity": "sha512-aZJvdfRr/f0VBbSRF5C81FHON47ZsC2TkGxbBezXpGGXAUEL/s6+GP73nnhAYRSCIqUNsmJjfeOF4lzRDKbUig==", + "license": "MIT", + "dependencies": { + "abstract-level": "^3.1.0", + "browser-level": "^3.0.0", + "classic-level": "^3.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/level" + } + }, + "node_modules/level-supports": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/level-supports/-/level-supports-6.2.0.tgz", + "integrity": "sha512-QNxVXP0IRnBmMsJIh+sb2kwNCYcKciQZJEt+L1hPCHrKNELllXhvrlClVHXBYZVT+a7aTSM6StgNXdAldoab3w==", + "license": "MIT", + "engines": { + "node": ">=16" + } + }, + "node_modules/level-transcoder": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/level-transcoder/-/level-transcoder-1.0.1.tgz", + "integrity": "sha512-t7bFwFtsQeD8cl8NIoQ2iwxA0CL/9IFw7/9gAjOonH0PWTTiRfY7Hq+Ejbsxh86tXobDQ6IOiddjNYIfOBs06w==", + "license": "MIT", + "dependencies": { + "buffer": "^6.0.3", + "module-error": "^1.0.1" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/level-ttl": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/level-ttl/-/level-ttl-3.1.1.tgz", + "integrity": "sha512-OeiHOD2IPkmdLqqU4feVJL7mnZX/Q03WEClrQi5t9558alkajVaecCgwJQZVVL/zFR9q74n5pWN1eozifa1Ghw==", + "license": "MIT", + "dependencies": { + "after": ">=0.8.1 <0.9.0-0", + "list-stream": ">=1.0.0 <1.1.0-0", + "lock": "~0.1.2", + "xtend": ">=4.0.0 <4.1.0-0" + } + }, + "node_modules/list-stream": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/list-stream/-/list-stream-1.0.1.tgz", + "integrity": "sha512-XheYsTtN+/nay6Co4N9NlTjQzo1ohknNlDJfxTeH0tvvssxBINUXwmjqPtj8+7rYMBwTRb3kO8C8d6ogeRwD1A==", + "license": "MIT +no-false-attribs", + "dependencies": { + "readable-stream": "~2.0.5", + "xtend": "~4.0.1" + } + }, + "node_modules/lock": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/lock/-/lock-0.1.4.tgz", + "integrity": "sha512-IcEe2R+NA7WgM622ppgmJFCFZl20f2owsA1YiJg7qpvO0wdOgOuZdfhQMxCYXdESVX+QIF/eikE4hB5ZPM2ipA==", + "license": "MIT" + }, + "node_modules/maxmind": { + "version": "4.3.25", + "resolved": "https://registry.npmjs.org/maxmind/-/maxmind-4.3.25.tgz", + "integrity": "sha512-u7L6LrbXZUtpdoovTVHo/l4/EoWUT2eHfCKWDMNNTsW9BaLa7h0jCHjqVx5ZeS5aWorLGZSsZwqxcpoollBw1g==", + "license": "MIT", + "dependencies": { + "mmdb-lib": "2.2.0", + "tiny-lru": "11.2.11" + }, + "engines": { + "node": ">=12", + "npm": ">=6" + } + }, + "node_modules/maybe-combine-errors": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/maybe-combine-errors/-/maybe-combine-errors-1.0.0.tgz", + "integrity": "sha512-eefp6IduNPT6fVdwPp+1NgD0PML1NU5P6j1Mj5nz1nidX8/sWY7119WL8vTAHgqfsY74TzW0w1XPgdYEKkGZ5A==", + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/minipass": { + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.2.tgz", + "integrity": "sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==", + "license": "ISC", + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/minizlib": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/minizlib/-/minizlib-3.0.2.tgz", + "integrity": "sha512-oG62iEk+CYt5Xj2YqI5Xi9xWUeZhDI8jjQmC5oThVH5JGCTgIjr7ciJDzC7MBzYd//WvR1OTmP5Q38Q8ShQtVA==", + "license": "MIT", + "dependencies": { + "minipass": "^7.1.2" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/mkdirp": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-3.0.1.tgz", + "integrity": "sha512-+NsyUUAZDmo6YVHzL/stxSu3t9YS1iljliy3BSDrXJ/dkn1KYdmtZODGGjLcc9XLgVVpH4KshHB8XmZgMhaBXg==", + "license": "MIT", + "bin": { + "mkdirp": "dist/cjs/src/bin.js" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/mmdb-lib": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/mmdb-lib/-/mmdb-lib-2.2.0.tgz", + "integrity": "sha512-V6DDh3v8tfZFWbeH6fsL5uBIlWL7SvRgGDaAZWFC5kjQ2xP5dl/mLpWwJQ1Ho6ZbEKVp/351QF1JXYTAmeZ/zA==", + "license": "MIT", + "engines": { + "node": ">=10", + "npm": ">=6" + } + }, + "node_modules/module-error": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/module-error/-/module-error-1.0.2.tgz", + "integrity": "sha512-0yuvsqSCv8LbaOKhnsQ/T5JhyFlCYLPXK3U2sgV10zoKQwzs/MyfuQUOZQ1V/6OCOJsK/TRgNVrPuPDqtdMFtA==", + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/napi-macros": { + "version": "2.2.2", + "resolved": "https://registry.npmjs.org/napi-macros/-/napi-macros-2.2.2.tgz", + "integrity": "sha512-hmEVtAGYzVQpCKdbQea4skABsdXW4RUh5t5mJ2zzqowJS2OyXZTU1KhDVFhx+NlWZ4ap9mqR9TcDO3LTTttd+g==", + "license": "MIT" + }, + "node_modules/node-gyp-build": { + "version": "4.8.4", + "resolved": "https://registry.npmjs.org/node-gyp-build/-/node-gyp-build-4.8.4.tgz", + "integrity": "sha512-LA4ZjwlnUblHVgq0oBF3Jl/6h/Nvs5fzBLwdEF4nuxnFdsfajde4WfxtJr3CaiH+F6ewcIB/q4jQ4UzPyid+CQ==", + "license": "MIT", + "bin": { + "node-gyp-build": "bin.js", + "node-gyp-build-optional": "optional.js", + "node-gyp-build-test": "build-test.js" + } + }, + "node_modules/prettier": { + "version": "2.8.8", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-2.8.8.tgz", + "integrity": "sha512-tdN8qQGvNjw4CHbY+XXk0JgCXn9QiF21a55rBe5LJAU+kDyC4WQn4+awm2Xfk2lQMk5fKup9XgzTZtGkjBdP9Q==", + "dev": true, + "license": "MIT", + "bin": { + "prettier": "bin-prettier.js" + }, + "engines": { + "node": ">=10.13.0" + }, + "funding": { + "url": "https://github.com/prettier/prettier?sponsor=1" + } + }, + "node_modules/process-nextick-args": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-1.0.7.tgz", + "integrity": "sha512-yN0WQmuCX63LP/TMvAg31nvT6m4vDqJEiiv2CAZqWOGNWutc9DfDk1NPYYmKUFmaVM2UwDowH4u5AHWYP/jxKw==", + "license": "MIT" + }, + "node_modules/raw-body": { + "version": "2.5.2", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.2.tgz", + "integrity": "sha512-8zGqypfENjCIqGhgXToC8aB2r7YrBX+AQAfIPs/Mlk+BtPTztOvTS01NRW/3Eh60J+a48lt8qsCzirQ6loCVfA==", + "license": "MIT", + "dependencies": { + "bytes": "3.1.2", + "http-errors": "2.0.0", + "iconv-lite": "0.4.24", + "unpipe": "1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/readable-stream": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.0.6.tgz", + "integrity": "sha512-TXcFfb63BQe1+ySzsHZI/5v1aJPCShfqvWJ64ayNImXMsN1Cd0YGk/wm8KB7/OeessgPc9QvS9Zou8QTkFzsLw==", + "license": "MIT", + "dependencies": { + "core-util-is": "~1.0.0", + "inherits": "~2.0.1", + "isarray": "~1.0.0", + "process-nextick-args": "~1.0.6", + "string_decoder": "~0.10.x", + "util-deprecate": "~1.0.1" + } + }, + "node_modules/requires-port": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/requires-port/-/requires-port-1.0.0.tgz", + "integrity": "sha512-KigOCHcocU3XODJxsu8i/j8T9tzT4adHiecwORRQ0ZZFcp7ahwXuRU1m+yuO90C5ZUyGeGfocHDI14M3L3yDAQ==", + "license": "MIT" + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "license": "MIT" + }, + "node_modules/setprototypeof": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", + "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", + "license": "ISC" + }, + "node_modules/statuses": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.1.tgz", + "integrity": "sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/streamx": { + "version": "2.22.0", + "resolved": "https://registry.npmjs.org/streamx/-/streamx-2.22.0.tgz", + "integrity": "sha512-sLh1evHOzBy/iWRiR6d1zRcLao4gGZr3C1kzNz4fopCOKJb6xD9ub8Mpi9Mr1R6id5o43S+d93fI48UC5uM9aw==", + "license": "MIT", + "dependencies": { + "fast-fifo": "^1.3.2", + "text-decoder": "^1.1.0" + }, + "optionalDependencies": { + "bare-events": "^2.2.0" + } + }, + "node_modules/string_decoder": { + "version": "0.10.31", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-0.10.31.tgz", + "integrity": "sha512-ev2QzSzWPYmy9GuqfIVildA4OdcGLeFZQrq5ys6RtiuF+RQQiZWr8TZNyAcuVXyQRYfEO+MsoB/1BuQVhOJuoQ==", + "license": "MIT" + }, + "node_modules/string-dsa": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/string-dsa/-/string-dsa-2.1.0.tgz", + "integrity": "sha512-ht+H83VtdA0JXmZsRfhQYzUSwqK3T7STPqiD/u3bIvYUHLEw8zzZyvP9WI3l9uKbK/2IpU+ZdshAe5BoRil3wA==", + "license": "ISC" + }, + "node_modules/tar": { + "version": "7.4.3", + "resolved": "https://registry.npmjs.org/tar/-/tar-7.4.3.tgz", + "integrity": "sha512-5S7Va8hKfV7W5U6g3aYxXmlPoZVAwUMy9AOKyF2fVuZa2UD3qZjg578OrLRt8PcNN1PleVaL/5/yYATNL0ICUw==", + "license": "ISC", + "dependencies": { + "@isaacs/fs-minipass": "^4.0.0", + "chownr": "^3.0.0", + "minipass": "^7.1.2", + "minizlib": "^3.0.1", + "mkdirp": "^3.0.1", + "yallist": "^5.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/tar-stream": { + "version": "3.1.7", + "resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-3.1.7.tgz", + "integrity": "sha512-qJj60CXt7IU1Ffyc3NJMjh6EkuCFej46zUqJ4J7pqYlThyd9bO0XBTmcOIhSzZJVWfsLks0+nle/j538YAW9RQ==", + "license": "MIT", + "dependencies": { + "b4a": "^1.6.4", + "fast-fifo": "^1.2.0", + "streamx": "^2.15.0" + } + }, + "node_modules/text-decoder": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/text-decoder/-/text-decoder-1.2.3.tgz", + "integrity": "sha512-3/o9z3X0X0fTupwsYvR03pJ/DjWuqqrfwBgTQzdWDiQSm9KitAyz/9WqsT2JQW7KV2m+bC2ol/zqpW37NHxLaA==", + "license": "Apache-2.0", + "dependencies": { + "b4a": "^1.6.4" + } + }, + "node_modules/tiny-lru": { + "version": "11.2.11", + "resolved": "https://registry.npmjs.org/tiny-lru/-/tiny-lru-11.2.11.tgz", + "integrity": "sha512-27BIW0dIWTYYoWNnqSmoNMKe5WIbkXsc0xaCQHd3/3xT2XMuMJrzHdrO9QBFR14emBz1Bu0dOAs2sCBBrvgPQA==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=12" + } + }, + "node_modules/toidentifier": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", + "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", + "license": "MIT", + "engines": { + "node": ">=0.6" + } + }, + "node_modules/toml": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/toml/-/toml-3.0.0.tgz", + "integrity": "sha512-y/mWCZinnvxjTKYhJ+pYxwD0mRLVvOtdS2Awbgxln6iEnt4rk0yBxeSBHkGJcPucRiG0e55mwWp+g/05rsrd6w==", + "license": "MIT" + }, + "node_modules/unpipe": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", + "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "license": "MIT" + }, + "node_modules/xtend": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz", + "integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==", + "license": "MIT", + "engines": { + "node": ">=0.4" + } + }, + "node_modules/yallist": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-5.0.0.tgz", + "integrity": "sha512-YgvUTfwqyc7UXVMrB+SImsVYSmTS8X/tSrtdNZMImM+n7+QTriRXyXim0mBrTXNeqzVF0KWGgHPeiyViFFrNDw==", + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=18" + } + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..4b29319 --- /dev/null +++ b/package.json @@ -0,0 +1,26 @@ +{ + "name": "checkpoint", + "module": "index.js", + "private": true, + "type": "module", + "scripts": { + "start": "bun index.js" + }, + "devDependencies": { + "prettier": "^2.8.8" + }, + "dependencies": { + "@iarna/toml": "^2.2.5", + "cookie": "^1.0.2", + "dotenv": "^16.5.0", + "express-http-proxy": "^2.1.1", + "http-proxy": "^1.18.1", + "level": "^10.0.0", + "level-ttl": "^3.1.1", + "maxmind": "^4.3.25", + "string-dsa": "^2.1.0", + "tar": "^7.4.3", + "tar-stream": "^3.1.7", + "toml": "^3.0.0" + } +} diff --git a/pages/interstitial/js/c.js b/pages/interstitial/js/c.js new file mode 100644 index 0000000..539a806 --- /dev/null +++ b/pages/interstitial/js/c.js @@ -0,0 +1,518 @@ +// Modal state variables +let isModalOpen = false; +let pendingAction = null; // Can be 'success' or 'error' +let storedErrorMessage = ''; +let storedRedirectUrl = ''; +// let redirectToken = ''; // This was defined but not used, removing for now. +const REDIRECT_DELAY = 1488; // Moved for wider accessibility + +function workerFunction() { + self.onmessage = function (e) { + const { type, data } = e.data; + + if (type === 'pow') { + const { challenge, salt, startNonce, endNonce, target, batchId } = data; + let count = 0; + let solution = null; + + processNextNonce(startNonce); + + function processNextNonce(nonce) { + const input = String(challenge) + String(salt) + nonce.toString(); + const msgBuffer = new TextEncoder().encode(input); + + crypto.subtle + .digest('SHA-256', msgBuffer) + .then((hashBuffer) => { + const hashArray = Array.from(new Uint8Array(hashBuffer)); + const result = hashArray.map((b) => b.toString(16).padStart(2, '0')).join(''); + + count++; + + if (result.startsWith(target)) { + solution = { nonce: nonce.toString(), found: true }; + self.postMessage({ + type: 'pow_result', + solution: solution, + count: count, + batchId: batchId, + }); + return; + } + + if (count % 1000 === 0) { + self.postMessage({ + type: 'progress', + count: count, + batchId: batchId, + }); + } + + if (nonce < endNonce && !solution) { + setTimeout(() => processNextNonce(nonce + 1), 0); + } else if (!solution) { + self.postMessage({ + type: 'pow_result', + solution: null, + count: count, + batchId: batchId, + }); + } + }) + .catch((err) => { + self.postMessage({ + type: 'error', + error: 'Crypto API error: ' + err.message, + }); + }); + } + } else { + self.postMessage({ type: 'error', error: 'Unknown message type: ' + type }); + } + }; +} +const workerCode = '(' + workerFunction.toString() + ')()'; + +function posWorkerFunction() { + self.onmessage = async function (e) { + const { type, seedHex, isDecoy } = e.data; + if (type === 'pos') { + const minMB = 48, + maxMB = 160; + let seedInt = parseInt(seedHex.slice(0, 8), 16); + if (isNaN(seedInt)) seedInt = Math.floor(Math.random() * (maxMB - minMB + 1)); + const CHUNK_MB = isDecoy + ? minMB + ((seedInt * 3 + 17) % (maxMB - minMB + 1)) + : minMB + (seedInt % (maxMB - minMB + 1)); + const CHUNK_SIZE = CHUNK_MB * 1024 * 1024; + const chunkCount = 4 + (seedInt % 5); + const chunkSize = Math.floor(CHUNK_SIZE / chunkCount); + const FILL_STEP_4K = 4096, + FILL_STEP_1K = 1024; + const FILL_STEP_SWITCH = 35 * 1024 * 1024; + const runs = 3; + + const mainBuf = new ArrayBuffer(CHUNK_SIZE); + const view = new Uint8Array(mainBuf); + const pressureBuf = new ArrayBuffer(16 * 1024 * 1024); + const pressureView = new Uint8Array(pressureBuf); + + const hashes = []; + const times = []; + for (let r = 0; r < runs; r++) { + const prng = seededPRNG(seedHex + r.toString(16)); + + const order = Array.from({ length: chunkCount }, (_, i) => i); + for (let i = order.length - 1; i > 0; i--) { + const j = prng() % (i + 1); + [order[i], order[j]] = [order[j], order[i]]; + } + + const t0 = performance.now(); + for (let c = 0; c < chunkCount; c++) { + const idx = order[c]; + const start = idx * chunkSize; + const end = idx === chunkCount - 1 ? CHUNK_SIZE : start + chunkSize; + const step = start < FILL_STEP_SWITCH ? FILL_STEP_4K : FILL_STEP_1K; + for (let i = start; i < end; i += step) view[i] = prng() & 0xff; + } + const hashBuf = await crypto.subtle.digest('SHA-256', view); + const t2 = performance.now(); + hashes.push( + Array.from(new Uint8Array(hashBuf)) + .map((b) => b.toString(16).padStart(2, '0')) + .join(''), + ); + times.push(Math.round(t2 - t0)); + + for (let i = 0; i < pressureView.length; i += 4096) pressureView[i] = prng() & 0xff; + } + + self.postMessage({ type: 'pos_result', hashes, times }); + } + }; + function seededPRNG(seedHex) { + const s = []; + for (let i = 0; i < 4; i++) s[i] = parseInt(seedHex.substr(i * 8, 8), 16) >>> 0; + function rotl(x, k) { + return ((x << k) | (x >>> (32 - k))) >>> 0; + } + return function () { + const t = s[1] << 9; + let r = (s[0] * 5) >>> 0; + r = (rotl(r, 7) * 9) >>> 0; + const tmp = s[0] ^ s[2]; + s[2] ^= s[1]; + s[1] ^= s[3]; + s[0] ^= s[1]; + s[3] ^= tmp; + s[2] ^= t; + s[3] = rotl(s[3], 11); + return r >>> 0; + }; + } +} +const posWorkerCode = '(' + posWorkerFunction.toString() + ')()'; + +document.addEventListener('DOMContentLoaded', function () { + setTimeout(initVerification, 650); + + // Modal elements + const infoButton = document.getElementById('infoBtn'); + const infoModal = document.getElementById('infoModal'); + const modalCloseButton = document.getElementById('modalCloseBtn'); + + if (infoButton) { + infoButton.addEventListener('click', () => { + if (isModalOpen) { + closeModal(); + } else { + isModalOpen = true; + if (infoModal) infoModal.classList.add('active'); + } + }); + } + + if (modalCloseButton) { + modalCloseButton.addEventListener('click', closeModal); + } + + // Close modal if overlay is clicked + if (infoModal) { + infoModal.addEventListener('click', (event) => { + if (event.target === infoModal) { + closeModal(); + } + }); + } + + function closeModal() { + if (infoModal) infoModal.classList.remove('active'); + isModalOpen = false; + if (pendingAction === 'success') { + triggerStoredSuccess(); + } else if (pendingAction === 'error') { + triggerStoredError(storedErrorMessage); + } + pendingAction = null; // Reset after handling + } + + // Moved triggerStoredSuccess and triggerStoredError to this scope + function triggerStoredSuccess() { + document.querySelector('.container').classList.add('success'); + const statusEl = document.getElementById('status'); + if (statusEl) statusEl.textContent = 'Redirecting'; // Ensure status is updated + if (storedRedirectUrl) { + setTimeout(() => { + window.location.href = storedRedirectUrl; + }, REDIRECT_DELAY); // Ensure REDIRECT_DELAY is accessible or define it here + } + } + + function triggerStoredError(message) { + const container = document.querySelector('.container'); + const statusEl = document.getElementById('status'); + const spinnerContainer = document.querySelector('.spinner-container'); + + container.classList.add('error'); + container.classList.remove('success'); + + if (statusEl) { + statusEl.style.display = 'inline-block'; + statusEl.textContent = 'Error'; + statusEl.classList.add('error'); + statusEl.classList.remove('success'); + } + + let errorDetails = document.getElementById('error-details'); + if (!errorDetails && spinnerContainer) { + errorDetails = document.createElement('div'); + errorDetails.id = 'error-details'; + errorDetails.className = 'error-details'; + spinnerContainer.appendChild(errorDetails); + } + if (errorDetails) { + // errorDetails.textContent = message; // Display the specific error message + errorDetails.style.display = 'block'; + } + // Ensure any running workers are stopped on error + // This might need to be called from within Verifier's scope or Verifier needs a public method + // For now, if terminateWorkers is global or accessible, it would be called. + // However, terminateWorkers is defined within Verifier. This needs careful handling. + // Let's assume for now the original placement inside Verifier handles termination on actual error progression. + // The primary goal here is to show the UI error state. + } + + function initVerification() { + const dataEl = document.getElementById('verification-data'); + const targetPath = dataEl.getAttribute('data-target'); + const requestID = dataEl.getAttribute('data-request-id'); + + startVerification(); + + async function startVerification() { + try { + const challengeResponse = await fetch( + '/api/challenge?id=' + encodeURIComponent(requestID), + { method: 'GET', headers: { Accept: 'application/json' }, credentials: 'include' }, + ); + + if (!challengeResponse.ok) { + throw new Error('Failed to get challenge parameters'); + } + + const challengeData = await challengeResponse.json(); + + const verifier = new Verifier(challengeData, targetPath, requestID); + verifier.start(); + } catch (error) { + showError('Verification setup failed: ' + error.message); + } + } + + function createWorker() { + const blob = new Blob([workerCode], { type: 'text/javascript' }); + return new Worker(URL.createObjectURL(blob)); + } + + function createPosWorker() { + const blob = new Blob([posWorkerCode], { type: 'text/javascript' }); + return new Worker(URL.createObjectURL(blob)); + } + + function showError(message) { + const container = document.querySelector('.container'); + const statusEl = document.getElementById('status'); + const spinnerContainer = document.querySelector('.spinner-container'); + + storedErrorMessage = message; // Store for later + + if (isModalOpen) { + pendingAction = 'error'; + if (statusEl) { + statusEl.textContent = 'Error'; // Generic message while modal is open + statusEl.style.display = 'inline-block'; // Make sure status is visible + statusEl.classList.remove('success'); // Ensure no success styling + statusEl.classList.add('error'); // Add error styling for text color + } + // Do not show full error details or animations yet + return; + } + + // Proceed with normal error display if modal is not open + container.classList.add('error'); + container.classList.remove('success'); + + if (statusEl) { + statusEl.style.display = 'inline-block'; + statusEl.textContent = ''; + statusEl.classList.add('error'); + statusEl.classList.remove('success'); + } + + let errorDetails = document.getElementById('error-details'); + if (!errorDetails && spinnerContainer) { + errorDetails = document.createElement('div'); + errorDetails.id = 'error-details'; + errorDetails.className = 'error-details'; + spinnerContainer.appendChild(errorDetails); + } + + if (errorDetails) { + // errorDetails.textContent = message; // This was causing issues, let animations handle it or remove. + errorDetails.style.display = 'none'; // Keep this hidden, rely on class-based animation + } + } + + function showSuccess() { + const statusEl = document.getElementById('status'); + if (statusEl) statusEl.textContent = 'Redirecting'; + + if (isModalOpen) { + pendingAction = 'success'; + // Do not add 'success' class or start animations yet + return; + } + document.querySelector('.container').classList.add('success'); + // The redirect will be handled by submitSolution after a delay + } + + function Verifier(params, targetPath, requestID) { + const workers = []; + const activeBatches = {}; + let powSolution = null; + let isRunning = false; + + const cpuCount = navigator.hardwareConcurrency || 4; + const workerCount = Math.max(1, Math.floor(cpuCount * 0.8)); + + // const REDIRECT_DELAY = 1488; // Defined globally now + + this.start = function () { + setTimeout(findProofOfWork, 100); + }; + + async function findProofOfWork() { + try { + isRunning = true; + + const challenge = params.a; + const salt = params.b; + const target = '0'.repeat(params.c); + + for (let i = 0; i < workerCount; i++) { + const worker = createWorker(); + + worker.onmessage = (e) => handleWorkerMessage(e.data); + worker.onerror = (error) => {}; + workers.push(worker); + } + + const totalRange = Number.MAX_SAFE_INTEGER; + const rangePerWorker = Math.floor(totalRange / workerCount); + + for (let i = 0; i < workers.length; i++) { + const startNonce = i * rangePerWorker; + const endNonce = i === workers.length - 1 ? totalRange : (i + 1) * rangePerWorker - 1; + + const workerId = `pow-worker-${i}`; + + activeBatches[workerId] = { + workerId: i, + startNonce, + endNonce, + }; + + workers[i].postMessage({ + type: 'pow', + data: { + challenge: challenge, + salt: salt, + startNonce, + endNonce, + target, + batchId: workerId, + }, + }); + } + } catch (error) { + terminateWorkers(); + showError(error.message); + } + } + + function handleWorkerMessage(data) { + if (!isRunning) return; + + if (data.type === 'pow_result') { + if (activeBatches[data.batchId]) { + delete activeBatches[data.batchId]; + + if (data.solution && data.solution.found) { + if (!powSolution) { + powSolution = data.solution; + proofOfWorkFound(powSolution); + } + } + } + } else if (data.type === 'error') { + showError('Compatibility error: ' + data.error); + terminateWorkers(); + } + } + + async function proofOfWorkFound(solution) { + isRunning = false; + terminateWorkers(); + try { + const posResult = await new Promise((res) => { + const w = createPosWorker(); + w.onmessage = (e) => { + if (e.data.type === 'pos_result') { + res(e.data); + w.terminate(); + } + }; + w.postMessage({ type: 'pos', seedHex: params.d, isDecoy: false }); + }); + + await submitSolution({ + requestID, + g: solution.nonce, + h: posResult.hashes, + i: posResult.times, + }); + } catch (error) { + showError(error.message); + } + } + + function terminateWorkers() { + workers.forEach((worker) => worker.terminate()); + } + + async function submitSolution(solutionData) { + try { + const response = await fetch('/api/verify', { + method: 'POST', + credentials: 'include', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + request_id: solutionData.requestID, + g: solutionData.g, + h: solutionData.h, + i: solutionData.i, + }), + }); + + if (!response.ok) { + let errorMsg = `Verification failed: ${response.statusText}`; + try { + const errorData = await response.json(); + if (errorData && errorData.error) { + errorMsg += ` - ${errorData.error}`; + } else { + const text = await response.text(); + errorMsg += ` - Response: ${text}`; + } + } catch (parseError) {} + showError(errorMsg); + return; + } + + let result; + try { + result = await response.json(); + } catch (e) { + showError('Invalid verification response'); + return; + } + const token = result.token; + if (!token) { + // Use existing showError for immediate display if modal not involved + // or to set up pending error if modal is open. + showError('Verification did not return a token'); + return; + } + + // Store for potential delayed redirect + const sep = targetPath.includes('?') ? '&' : '?'; + storedRedirectUrl = `${targetPath}${sep}token=${encodeURIComponent(token)}`; + + showSuccess(); // This will now respect isModalOpen + + if (!isModalOpen) { + setTimeout(() => { + window.location.href = storedRedirectUrl; + }, REDIRECT_DELAY); + } + } catch (error) { + // Use existing showError + showError('Verification failed. Please refresh the page.'); + } + } + } + } +}); diff --git a/pages/interstitial/page.html b/pages/interstitial/page.html new file mode 100644 index 0000000..b2aac9c --- /dev/null +++ b/pages/interstitial/page.html @@ -0,0 +1,742 @@ + + + + + +Security Checkpoint + + + + + +
+
+

Security Checkpoint

+

Verifying your browser to protect from automated abuse. This may take a few seconds...

+
+
+
Redirecting
+
+
+
+ +
+ \ No newline at end of file diff --git a/pages/interstitial/webfont/Poppins-Regular.woff2 b/pages/interstitial/webfont/Poppins-Regular.woff2 new file mode 100644 index 0000000000000000000000000000000000000000..4aae28cf15ebf4e02bf9d9007a5f9c12be78a949 GIT binary patch literal 49048 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"bI<&cle5@u_qzx(6Sz8d< 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 literal 0 HcmV?d00001 diff --git a/pages/interstitial/webfont/Poppins-SemiBold.woff2 b/pages/interstitial/webfont/Poppins-SemiBold.woff2 new file mode 100644 index 0000000000000000000000000000000000000000..990f6f717e03110875cd602f5cc43e53ad834037 GIT binary patch literal 48508 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 M%EgQ_$t(*~0Os+R_5c6? literal 0 HcmV?d00001 diff --git a/pages/ipfilter/datacenter.html b/pages/ipfilter/datacenter.html new file mode 100644 index 0000000..b64d56f --- /dev/null +++ b/pages/ipfilter/datacenter.html @@ -0,0 +1 @@ +Blocked (Datacenter) diff --git a/pages/ipfilter/default.html b/pages/ipfilter/default.html new file mode 100644 index 0000000..cb9f9a6 --- /dev/null +++ b/pages/ipfilter/default.html @@ -0,0 +1 @@ +Blocked (Default) diff --git a/pages/ipfilter/india.html b/pages/ipfilter/india.html new file mode 100644 index 0000000..493f6c6 --- /dev/null +++ b/pages/ipfilter/india.html @@ -0,0 +1 @@ +Blocked (India) diff --git a/pages/stats/stats.html b/pages/stats/stats.html new file mode 100644 index 0000000..92bd330 --- /dev/null +++ b/pages/stats/stats.html @@ -0,0 +1,1274 @@ + + + + + + Checkpoint Stats + + + + + +

+ + + + diff --git a/plugins/ipfilter.js b/plugins/ipfilter.js new file mode 100644 index 0000000..3bd7d1d --- /dev/null +++ b/plugins/ipfilter.js @@ -0,0 +1,470 @@ +import { registerPlugin, loadConfig, rootDir } from '../index.js'; +import fs from 'fs'; +import { dirname, join } from 'path'; +import { fileURLToPath } from 'url'; +import maxmind from 'maxmind'; +import { AhoCorasick } from 'string-dsa'; +import { getRealIP } from '../utils/network.js'; +import { createGunzip } from 'zlib'; +import tarStream from 'tar-stream'; +import { Buffer } from 'buffer'; +import * as logs from '../utils/logs.js'; +import { recordEvent } from './stats.js'; + +const cfg = {}; +await loadConfig('ipfilter', cfg); + +// Map configuration to internal structure +const enabled = cfg.Core.Enabled; +const accountId = cfg.Core.AccountID || process.env.MAXMIND_ACCOUNT_ID; +const licenseKey = cfg.Core.LicenseKey || process.env.MAXMIND_LICENSE_KEY; +const dbUpdateInterval = cfg.Core.DBUpdateIntervalHours; + +const ipBlockCacheTTL = cfg.Cache.IPBlockCacheTTLSec * 1000; +const ipBlockCacheMaxEntries = cfg.Cache.IPBlockCacheMaxEntries; + +const blockedCountryCodes = new Set(cfg.Blocking.CountryCodes); +const blockedContinentCodes = new Set(cfg.Blocking.ContinentCodes); +const defaultBlockPage = cfg.Blocking.DefaultBlockPage; + +// Process ASN blocks +const blockedASNs = {}; +const asnGroupBlockPages = {}; +for (const [group, config] of Object.entries(cfg.ASN || {})) { + blockedASNs[group] = config.Numbers || []; + asnGroupBlockPages[group] = config.BlockPage; +} + +// Process ASN name blocks +const blockedASNNames = {}; +for (const [group, config] of Object.entries(cfg.ASNNames || {})) { + blockedASNNames[group] = config.Patterns || []; + if (config.BlockPage) { + asnGroupBlockPages[group] = config.BlockPage; + } +} + +const countryBlockPages = cfg.CountryBlockPages || {}; +const continentBlockPages = cfg.ContinentBlockPages || {}; + +const ipBlockCache = new Map(); + +const blockPageCache = new Map(); +async function loadBlockPage(filePath) { + if (!blockPageCache.has(filePath)) { + try { + const txt = await fs.promises.readFile(filePath, 'utf8'); + blockPageCache.set(filePath, txt); + } catch { + blockPageCache.set(filePath, null); + } + } + return blockPageCache.get(filePath); +} + +const __dirname = dirname(fileURLToPath(import.meta.url)); + +const geoIPCountryDBPath = join(rootDir, 'data/GeoLite2-Country.mmdb'); +const geoIPASNDBPath = join(rootDir, 'data/GeoLite2-ASN.mmdb'); +const updateTimestampPath = join(rootDir, 'data/ipfilter_update.json'); + +let geoipCountryReader, geoipASNReader; + +let isReloading = false; +let reloadLock = Promise.resolve(); + +async function getLastUpdateTimestamp() { + try { + if (fs.existsSync(updateTimestampPath)) { + const data = await fs.promises.readFile(updateTimestampPath, 'utf8'); + const json = JSON.parse(data); + return json.lastUpdated || 0; + } + } catch (err) { + logs.warn('ipfilter', `Failed to read last update timestamp: ${err}`); + } + return 0; +} + +async function saveUpdateTimestamp() { + try { + const timestamp = Date.now(); + await fs.promises.writeFile( + updateTimestampPath, + JSON.stringify({ lastUpdated: timestamp }), + 'utf8', + ); + return timestamp; + } catch (err) { + logs.error('ipfilter', `Failed to save update timestamp: ${err}`); + return Date.now(); + } +} + +// Ensure the update timestamp file exists on first run +if (!fs.existsSync(updateTimestampPath)) { + try { + await saveUpdateTimestamp(); + } catch (err) { + logs.error('ipfilter', `Failed to initialize update timestamp file: ${err}`); + } +} + +// Download GeoIP databases if missing +async function downloadGeoIPDatabases() { + if (!licenseKey || !accountId) { + logs.warn( + 'ipfilter', + 'No MaxMind credentials found; skipping GeoIP database download. Please set MAXMIND_ACCOUNT_ID and MAXMIND_LICENSE_KEY environment variables or add AccountID and LicenseKey to config/ipfilter.toml', + ); + return; + } + const editions = [ + { id: 'GeoLite2-Country', filePath: geoIPCountryDBPath }, + { id: 'GeoLite2-ASN', filePath: geoIPASNDBPath }, + ]; + for (const { id, filePath } of editions) { + if (!fs.existsSync(filePath)) { + logs.plugin('ipfilter', `Downloading ${id} database...`); + const url = `https://download.maxmind.com/app/geoip_download?edition_id=${id}&license_key=${licenseKey}&suffix=tar.gz`; + const res = await fetch(url); + if (!res.ok) { + logs.error( + 'ipfilter', + `Failed to download ${id} database: ${res.status} ${res.statusText}`, + ); + continue; + } + const tempTar = join(rootDir, 'data', `${id}.tar.gz`); + // write response body into a .tar.gz file + const arrayBuf = await res.arrayBuffer(); + await fs.promises.writeFile(tempTar, Buffer.from(arrayBuf)); + // extract .mmdb files from the downloaded tar.gz + const extract = tarStream.extract(); + extract.on('entry', (header, stream, next) => { + if (header.name.endsWith('.mmdb')) { + const filename = header.name.split('/').pop(); + const outPath = join(rootDir, 'data', filename); + const ws = fs.createWriteStream(outPath); + stream + .pipe(ws) + .on('finish', next) + .on('error', (err) => { + logs.error('ipfilter', `Extraction error: ${err}`); + next(); + }); + } else { + stream.resume(); + next(); + } + }); + await new Promise((resolve, reject) => { + fs.createReadStream(tempTar) + .pipe(createGunzip()) + .pipe(extract) + .on('finish', resolve) + .on('error', reject); + }); + await fs.promises.unlink(tempTar); + logs.plugin('ipfilter', `${id} database downloaded and extracted.`); + } + } +} + +await downloadGeoIPDatabases(); + +async function loadGeoDatabases() { + if (isReloading) { + await reloadLock; + return true; + } + + isReloading = true; + let lockResolve; + reloadLock = new Promise((resolve) => { + lockResolve = resolve; + }); + + try { + const countryStats = fs.statSync(geoIPCountryDBPath); + const asnStats = fs.statSync(geoIPASNDBPath); + + if (countryStats.size > 1024 && asnStats.size > 1024) { + logs.plugin('ipfilter', 'Initializing GeoIP databases from disk...'); + const newCountryReader = await maxmind.open(geoIPCountryDBPath); + const newASNReader = await maxmind.open(geoIPASNDBPath); + + try { + const testIP = '8.8.8.8'; + const countryTest = newCountryReader.get(testIP); + const asnTest = newASNReader.get(testIP); + + if (!countryTest || !asnTest) { + throw new Error('Database validation failed: test lookups returned empty results'); + } + } catch (validationErr) { + logs.error('ipfilter', `GeoIP database validation failed: ${validationErr}`); + + try { + await newCountryReader.close(); + } catch (e) {} + try { + await newASNReader.close(); + } catch (e) {} + throw new Error('Database validation failed'); + } + + const oldCountryReader = geoipCountryReader; + const oldASNReader = geoipASNReader; + + geoipCountryReader = newCountryReader; + geoipASNReader = newASNReader; + if (oldCountryReader || oldASNReader) { + logs.plugin('ipfilter', 'GeoIP databases reloaded and active'); + } else { + logs.plugin('ipfilter', 'GeoIP databases loaded and active'); + } + + ipBlockCache.clear(); + + await saveUpdateTimestamp(); + + if (oldCountryReader || oldASNReader) { + setTimeout(async () => { + if (oldCountryReader) { + try { + await oldCountryReader.close(); + } catch (e) {} + } + if (oldASNReader) { + try { + await oldASNReader.close(); + } catch (e) {} + } + logs.plugin('ipfilter', 'Old GeoIP database instances closed successfully'); + }, 5000); + } + + return true; + } else { + logs.warn( + 'ipfilter', + 'GeoIP database files are empty or too small. IP filtering will be disabled.', + ); + return false; + } + } catch (err) { + logs.error('ipfilter', `Failed to load GeoIP databases: ${err}`); + return false; + } finally { + isReloading = false; + lockResolve(); + } +} + +async function checkAndUpdateDatabases() { + if (isReloading) return false; + + const lastUpdate = await getLastUpdateTimestamp(); + const now = Date.now(); + const hoursSinceUpdate = (now - lastUpdate) / (1000 * 60 * 60); + + if (hoursSinceUpdate >= dbUpdateInterval) { + logs.plugin( + 'ipfilter', + `GeoIP databases last updated ${hoursSinceUpdate.toFixed(1)} hours ago, reloading...`, + ); + return await loadGeoDatabases(); + } + + return false; +} + +function startPeriodicDatabaseUpdates() { + // Calculate interval in milliseconds + const intervalMs = dbUpdateInterval * 60 * 60 * 1000; + + // Schedule periodic updates + setInterval(async () => { + try { + await checkAndUpdateDatabases(); + } catch (err) { + logs.error('ipfilter', `Failed during periodic database update: ${err}`); + } + }, intervalMs); + + logs.plugin('ipfilter', `Scheduled GeoIP database updates every ${dbUpdateInterval} hours`); +} + +await loadGeoDatabases(); + +startPeriodicDatabaseUpdates(); + +const asnNameMatchers = new Map(); +for (const [group, names] of Object.entries(blockedASNNames)) { + asnNameMatchers.set(group, new AhoCorasick(names)); +} + +function cacheAndReturn(ip, blocked, blockType, blockValue, customPage, asnOrgName) { + const expiresAt = Date.now() + ipBlockCacheTTL; + ipBlockCache.set(ip, { blocked, blockType, blockValue, customPage, asnOrgName, expiresAt }); + // Enforce maximum cache size + if (ipBlockCacheMaxEntries > 0 && ipBlockCache.size > ipBlockCacheMaxEntries) { + // Remove the oldest entry (first key in insertion order) + const oldestKey = ipBlockCache.keys().next().value; + ipBlockCache.delete(oldestKey); + } + return [blocked, blockType, blockValue, customPage, asnOrgName]; +} + +function isBlockedIPExtended(ip) { + const now = Date.now(); + const entry = ipBlockCache.get(ip); + if (entry) { + if (entry.expiresAt > now) { + // Refresh recency by re-inserting entry + ipBlockCache.delete(ip); + ipBlockCache.set(ip, entry); + return [entry.blocked, entry.blockType, entry.blockValue, entry.customPage, entry.asnOrgName]; + } else { + // Entry expired, remove it + ipBlockCache.delete(ip); + } + } + + const countryReader = geoipCountryReader; + const asnReader = geoipASNReader; + + if (!countryReader || !asnReader) { + return [false, '', '', '', '']; + } + + let countryInfo; + try { + countryInfo = countryReader.get(ip); + } catch (e) {} + if (countryInfo?.country && blockedCountryCodes.has(countryInfo.country.iso_code)) { + const page = countryBlockPages[countryInfo.country.iso_code] || defaultBlockPage; + return cacheAndReturn(ip, true, 'country', countryInfo.country.iso_code, page, ''); + } + + if (countryInfo?.continent && blockedContinentCodes.has(countryInfo.continent.code)) { + const page = continentBlockPages[countryInfo.continent.code] || defaultBlockPage; + return cacheAndReturn(ip, true, 'continent', countryInfo.continent.code, page, ''); + } + + let asnInfo; + try { + asnInfo = asnReader.get(ip); + } catch (e) {} + if (asnInfo?.autonomous_system_number) { + const asn = asnInfo.autonomous_system_number; + const orgName = asnInfo.autonomous_system_organization || ''; + + for (const [group, arr] of Object.entries(blockedASNs)) { + if (arr.includes(asn)) { + const page = asnGroupBlockPages[group] || defaultBlockPage; + return cacheAndReturn(ip, true, 'asn', group, page, orgName); + } + } + + for (const [group, matcher] of asnNameMatchers.entries()) { + const matches = matcher.find(orgName); + if (matches.length) { + const page = asnGroupBlockPages[group] || defaultBlockPage; + return cacheAndReturn(ip, true, 'asn', group, page, orgName); + } + } + } + + return cacheAndReturn(ip, false, '', '', '', ''); +} + +function IPBlockMiddleware() { + return async (request, server) => { + const clientIP = getRealIP(request, server); + logs.plugin('ipfilter', `Incoming request from IP: ${clientIP}`); + const [blocked, blockType, blockValue, customPage, asnOrgName] = isBlockedIPExtended(clientIP); + + if (blocked) { + recordEvent('ipfilter.block', { + type: blockType, + value: blockValue, + asn_org: asnOrgName, + ip: clientIP, // Include the IP address for stats + }); + const url = new URL(request.url); + + if (url.pathname.startsWith('/api')) { + return new Response( + JSON.stringify({ + error: 'Access denied from your location or network.', + reason: 'geoip', + type: blockType, + value: blockValue, + asn_org: asnOrgName, + }), + { + status: 403, + headers: { 'Content-Type': 'application/json' }, + }, + ); + } + + // Normalize page paths by stripping leading slash + const cleanCustomPage = customPage.replace(/^\/+/, ''); + const cleanDefaultPage = defaultBlockPage.replace(/^\/+/, ''); + + let html = ''; + logs.plugin( + 'ipfilter', + `Block pages: custom="${cleanCustomPage}", default="${cleanDefaultPage}"`, + ); + logs.plugin('ipfilter', 'Searching for block page in the following locations:'); + const paths = [ + // allow absolute paths relative to project root first + join(rootDir, cleanCustomPage), + ]; + // Fallback to default block page if custom page isn't found + if (customPage !== defaultBlockPage) { + paths.push( + // check default page at root directory + join(rootDir, cleanDefaultPage), + ); + } + + for (const p of paths) { + logs.plugin('ipfilter', `Trying block page at: ${p}`); + const content = await loadBlockPage(p); + logs.plugin('ipfilter', `Load result for ${p}: ${content ? 'FOUND' : 'NOT FOUND'}`); + if (content) { + html = content; + break; + } + } + + if (html) { + const output = html.replace('{{.ASNName}}', asnOrgName || 'Blocked Network'); + return new Response(output, { + status: 403, + headers: { 'Content-Type': 'text/html; charset=utf-8' }, + }); + } else { + return new Response('Access denied from your location or network.', { + status: 403, + headers: { 'Content-Type': 'text/plain' }, + }); + } + } + + return undefined; + }; +} + +if (enabled) { + registerPlugin('ipfilter', IPBlockMiddleware()); +} else { + logs.plugin('ipfilter', 'IP filter plugin disabled via config'); +} + +export { checkAndUpdateDatabases, loadGeoDatabases }; diff --git a/plugins/proxy.js b/plugins/proxy.js new file mode 100644 index 0000000..5fda5bf --- /dev/null +++ b/plugins/proxy.js @@ -0,0 +1,303 @@ +import { registerPlugin, loadConfig } from '../index.js'; +import * as logs from '../utils/logs.js'; + +const proxyConfig = {}; +await loadConfig('proxy', proxyConfig); + +// Map configuration to internal structure +const enabled = proxyConfig.Core.Enabled; +const wsTimeout = proxyConfig.Timeouts.WebSocketTimeoutMs; +const upstreamTimeout = proxyConfig.Timeouts.UpstreamTimeoutMs; + +// Build proxy mappings from array format +const proxyMappings = {}; +proxyConfig.Mapping.forEach((mapping) => { + proxyMappings[mapping.Host] = mapping.Target; +}); + +logs.plugin('proxy', `Proxy mappings loaded: ${JSON.stringify(proxyMappings)}`); + +const HOP_BY_HOP_HEADERS = [ + 'connection', + 'keep-alive', + 'proxy-authenticate', + 'proxy-authorization', + 'te', + 'trailer', + 'transfer-encoding', + 'upgrade', +]; + +// Connect to upstream WebSocket with handshake timeout +async function connectUpstreamWebSocket(url, headers) { + return new Promise((resolve, reject) => { + const ws = new WebSocket(url, { headers }); + const timer = setTimeout(() => { + ws.close(); + reject(new Error('timeout')); + }, wsTimeout); + ws.onopen = () => { + clearTimeout(timer); + resolve(ws); + }; + ws.onerror = (err) => { + clearTimeout(timer); + reject(err); + }; + ws.onclose = () => { + clearTimeout(timer); + reject(new Error('closed')); + }; + }); +} + +async function createProxyResponse(targetURL, request) { + try { + const url = new URL(request.url); + const targetPathAndQuery = url.pathname + url.search; + const fullTargetURL = new URL(targetPathAndQuery, targetURL).toString(); + + const startTime = Date.now(); + + const outgoingHeaders = new Headers(request.headers); + outgoingHeaders.delete('host'); + + // Set proper host header for the target + const targetHost = new URL(targetURL).host; + outgoingHeaders.set('host', targetHost); + + // Forward the original host as X-Forwarded-Host for applications that need it + outgoingHeaders.set('x-forwarded-host', request.headers.get('host')); + outgoingHeaders.set('x-forwarded-proto', url.protocol.replace(':', '')); + + // Preserve important headers for authentication + // Don't delete content-length or transfer-encoding here, handle them properly below + const options = { + method: request.method, + headers: outgoingHeaders, + // Follow redirects automatically for GET; forward redirects for non-GET + // Absolute requirement: DONT REMOVE + redirect: request.method === 'GET' ? 'follow' : 'manual', + credentials: 'include', + }; + + const isChunked = request.headers.get('transfer-encoding')?.toLowerCase() === 'chunked'; + + // Define methods that can legitimately have request bodies + const methodsWithBody = new Set(['POST', 'PUT', 'PATCH', 'DELETE']); + + if (methodsWithBody.has(request.method) && request.body) { + if (isChunked) { + logs.plugin('proxy', `De-chunking request body for ${request.method} ${request.url}`); + try { + const bodyBuffer = await request.arrayBuffer(); + options.body = bodyBuffer; + outgoingHeaders.set('content-length', String(bodyBuffer.byteLength)); + outgoingHeaders.delete('transfer-encoding'); + } catch (bufferError) { + logs.error('proxy', `Error buffering chunked request body: ${bufferError}`); + return new Response('Error processing chunked request body', { status: 500 }); + } + } else { + // For non-chunked bodies, preserve the body stream + options.body = request.body; + // Keep the original content-length if it exists + if (request.headers.has('content-length')) { + outgoingHeaders.set('content-length', request.headers.get('content-length')); + } + } + } + + // Add a timeout controller for the upstream fetch + const controller = new AbortController(); + const timeoutId = setTimeout(() => { + logs.warn( + 'proxy', + `Upstream request to ${fullTargetURL} timed out after ${upstreamTimeout}ms`, + ); + controller.abort(); + }, upstreamTimeout); + + let response; + try { + response = await fetch(fullTargetURL, { + ...options, + signal: controller.signal, + verbose: true, + }); + } catch (fetchErr) { + clearTimeout(timeoutId); + if (fetchErr.name === 'AbortError') { + logs.error('proxy', `Upstream fetch aborted for ${fullTargetURL} (likely due to timeout)`); + return new Response('Gateway Timeout', { status: 504 }); + } + throw fetchErr; + } + clearTimeout(timeoutId); + + const latency = Date.now() - startTime; + + logs.plugin( + 'proxy', + `Proxied request to: ${fullTargetURL} (${response.status} ${response.statusText}) (${latency}ms)`, + ); + + const responseHeaders = new Headers(response.headers); + + // Remove hop-by-hop headers + HOP_BY_HOP_HEADERS.forEach((h) => responseHeaders.delete(h)); + + // Remove content-encoding and content-length headers + // This is necessary because Bun/fetch automatically decompresses the response body + // but leaves the content-encoding header, causing the browser to try to decompress already decompressed content + responseHeaders.delete('content-encoding'); + responseHeaders.delete('content-length'); + + // Add proxy information + responseHeaders.set('X-Proxy-Latency', `${latency}ms`); + + // Handle Set-Cookie headers - rewrite domain if needed + const setCookieHeaders = response.headers.getSetCookie ? response.headers.getSetCookie() : []; + if (setCookieHeaders.length > 0) { + responseHeaders.delete('set-cookie'); + + setCookieHeaders.forEach((cookieStr) => { + // Parse and potentially rewrite the cookie domain + let modifiedCookie = cookieStr; + + // Remove domain restrictions that might prevent the cookie from working + modifiedCookie = modifiedCookie.replace(/;\s*domain=[^;]*/gi, ''); + + // If the cookie has SameSite=None, ensure it also has Secure + if (modifiedCookie.match(/samesite\s*=\s*none/i) && !modifiedCookie.match(/secure/i)) { + modifiedCookie += '; Secure'; + } + + // For local development, you might need to adjust SameSite + if (url.protocol === 'http:' && modifiedCookie.match(/samesite\s*=\s*none/i)) { + modifiedCookie = modifiedCookie.replace(/;\s*samesite=[^;]*/gi, '; SameSite=Lax'); + modifiedCookie = modifiedCookie.replace(/;\s*secure/gi, ''); + } + + responseHeaders.append('set-cookie', modifiedCookie); + }); + } + + return new Response(response.body, { + status: response.status, + statusText: response.statusText, + headers: responseHeaders, + }); + } catch (err) { + logs.error( + 'proxy', + `Proxy error processing ${request.method} ${request.url}: ${err.message}${ + err.cause ? ' - Cause: ' + err.cause : '' + }`, + ); + let causeDetails = ''; + if (err.cause) { + causeDetails = typeof err.cause === 'object' ? JSON.stringify(err.cause) : String(err.cause); + } + logs.error( + 'proxy', + `Full error details: ${err.stack}${err.cause ? '\nCause: ' + causeDetails : ''}`, + ); + return new Response('Bad Gateway', { status: 502 }); + } +} + +function proxyMiddleware() { + return async (request, server) => { + const url = new URL(request.url); + const path = url.pathname; + + // Skip checkpoint endpoints + if (path.startsWith('/api/challenge') || path.startsWith('/api/verify')) return undefined; + + // Skip static assets + if (path.startsWith('/webfont/') || path.startsWith('/js/')) return undefined; + + // Get the hostname from the request + const hostname = request.headers.get('host')?.split(':')[0]; + const target = proxyMappings[hostname]; + if (!target) return undefined; + + // Handle WebSocket upgrade requests + const upgradeHeader = request.headers.get('upgrade')?.toLowerCase(); + if (upgradeHeader === 'websocket') { + const targetUrl = new URL(url.pathname + url.search, target); + targetUrl.protocol = targetUrl.protocol.replace(/^http/, 'ws'); + + // Forward important headers for WebSocket + const wsHeaders = new Headers(); + if (request.headers.has('cookie')) wsHeaders.set('Cookie', request.headers.get('cookie')); + if (request.headers.has('authorization')) + wsHeaders.set('Authorization', request.headers.get('authorization')); + if (request.headers.has('origin')) wsHeaders.set('Origin', request.headers.get('origin')); + if (request.headers.has('sec-websocket-protocol')) + wsHeaders.set('Sec-WebSocket-Protocol', request.headers.get('sec-websocket-protocol')); + if (request.headers.has('sec-websocket-extensions')) + wsHeaders.set('Sec-WebSocket-Extensions', request.headers.get('sec-websocket-extensions')); + + let upstream; + try { + // Convert Headers object to a plain object for the WebSocket constructor + const plainWsHeaders = {}; + for (const [key, value] of wsHeaders) { + plainWsHeaders[key] = value; + } + upstream = await connectUpstreamWebSocket(targetUrl.toString(), plainWsHeaders); + } catch (err) { + logs.error('proxy', `Upstream WebSocket connection failed: ${err}`); + return new Response('Bad Gateway', { status: 502 }); + } + // Upgrade incoming client connection and attach upstream socket + const ok = server.upgrade(request, { data: { upstream } }); + if (!ok) { + logs.error('proxy', 'WebSocket upgrade failed'); + upstream.close(); + return new Response('Bad Gateway', { status: 502 }); + } + logs.plugin('proxy', `WebSocket proxied to: ${targetUrl.toString()}`); + return; + } + + return createProxyResponse(target, request); + }; +} + +// WebSocket handlers for proxying messages between client and upstream +export const proxyWebSocketHandler = { + open(ws) { + const upstream = ws.data.upstream; + upstream.onopen = () => logs.plugin('proxy', 'Upstream WebSocket connected'); + // Forward messages from target to client + upstream.onmessage = (event) => ws.send(event.data); + upstream.onerror = (err) => { + logs.error('proxy', `Upstream WebSocket error: ${err}`); + ws.close(1011, 'Upstream error'); + }; + upstream.onclose = ({ code, reason }) => ws.close(code, reason); + }, + message(ws, message) { + const upstream = ws.data.upstream; + // Forward messages from client to target + upstream.send(message); + }, + close(ws, code, reason) { + const upstream = ws.data.upstream; + upstream.close(code, reason); + }, + error(ws, err) { + logs.error('proxy', `WebSocket proxy error: ${err}`); + const upstream = ws.data.upstream; + upstream.close(); + }, +}; + +if (enabled) { + registerPlugin('proxy', proxyMiddleware()); +} else { + logs.plugin('proxy', 'Proxy plugin disabled via config'); +} diff --git a/plugins/stats.js b/plugins/stats.js new file mode 100644 index 0000000..5a5f29c --- /dev/null +++ b/plugins/stats.js @@ -0,0 +1,132 @@ +import { registerPlugin, rootDir, loadConfig } from '../index.js'; +import { Level } from 'level'; +import ttl from 'level-ttl'; +import fs from 'fs/promises'; +import path from 'path'; +import { fileURLToPath } from 'url'; +import { Readable } from 'stream'; +import cookie from 'cookie'; +import { getRealIP } from '../utils/network.js'; +import { parseDuration } from '../utils/time.js'; + +// Load stats configuration +const statsConfig = {}; +await loadConfig('stats', statsConfig); + +// Map configuration to internal structure +const enabled = statsConfig.Core.Enabled; +const statsTTL = parseDuration(statsConfig.Storage.StatsTTL); +const statsUIPath = statsConfig.WebUI.StatsUIPath; +const statsAPIPath = statsConfig.WebUI.StatsAPIPath; + +// Determine __dirname for ES modules +const __dirname = path.dirname(fileURLToPath(import.meta.url)); + +/** + * Adds createReadStream support to LevelDB instances using async iterator. + */ +function addReadStreamSupport(dbInstance) { + if (!dbInstance.createReadStream) { + dbInstance.createReadStream = (opts) => + Readable.from( + (async function* () { + for await (const [key, value] of dbInstance.iterator(opts)) { + yield { key, value }; + } + })(), + ); + } + return dbInstance; +} + +// Initialize LevelDB for stats under db/stats with TTL and stream support +const statsDBPath = path.join(rootDir, 'db', 'stats'); +await fs.mkdir(statsDBPath, { recursive: true }); +let rawStatsDB = new Level(statsDBPath, { valueEncoding: 'json' }); +rawStatsDB = addReadStreamSupport(rawStatsDB); +const statsDB = ttl(rawStatsDB, { defaultTTL: statsTTL }); +addReadStreamSupport(statsDB); + +/** + * Record a stat event with a metric name and optional data. + * @param {string} metric + * @param {object} data + */ +function recordEvent(metric, data = {}) { + // Skip if statsDB is not initialized + if (typeof statsDB === 'undefined' || !statsDB || typeof statsDB.put !== 'function') { + console.warn(`stats: cannot record "${metric}", statsDB not available`); + return; + } + const timestamp = Date.now(); + // key includes metric and timestamp and a random suffix to avoid collisions + const key = `${metric}:${timestamp}:${Math.random().toString(36).slice(2, 8)}`; + try { + // Use callback form to avoid promise chaining + statsDB.put(key, { timestamp, metric, ...data }, (err) => { + if (err) console.error('stats: failed to record event', err); + }); + } catch (err) { + console.error('stats: failed to record event', err); + } +} + +// Handler for serving the stats HTML UI +async function handleStatsPage(request) { + const url = new URL(request.url); + if (url.pathname !== statsUIPath) return undefined; + try { + const html = await fs.readFile(path.join(__dirname, 'stats.html'), 'utf8'); + return new Response(html, { + status: 200, + headers: { 'Content-Type': 'text/html; charset=utf-8' }, + }); + } catch (e) { + return new Response('Stats UI not found', { status: 404 }); + } +} + +// Handler for stats API +async function handleStatsAPI(request) { + const url = new URL(request.url); + if (url.pathname !== statsAPIPath) return undefined; + const metric = url.searchParams.get('metric'); + const start = parseInt(url.searchParams.get('start') || '0', 10); + const end = parseInt(url.searchParams.get('end') || `${Date.now()}`, 10); + const result = []; + // Iterate over keys for this metric in the time range + for await (const [key, value] of statsDB.iterator({ + gte: `${metric}:${start}`, + lte: `${metric}:${end}\uffff`, + })) { + result.push(value); + } + return new Response(JSON.stringify(result), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }); +} + +// Middleware for stats plugin +function StatsMiddleware() { + return async (request) => { + // Always serve stats UI and API first, bypassing auth + const pageResp = await handleStatsPage(request); + if (pageResp) return pageResp; + const apiResp = await handleStatsAPI(request); + if (apiResp) return apiResp; + + // For any other routes, do not handle + return undefined; + }; +} + +// Register the stats plugin +if (enabled) { + registerPlugin('stats', StatsMiddleware()); +} else { + console.log('Stats plugin disabled via config'); +} + +// Export recordEvent for other plugins to use +export { recordEvent }; diff --git a/utils/logs.js b/utils/logs.js new file mode 100644 index 0000000..038a902 --- /dev/null +++ b/utils/logs.js @@ -0,0 +1,41 @@ +const seenConfigs = new Set(); + +export function init(msg) { + console.log(msg); +} + +export function plugin(_name, msg) { + console.log(msg); +} + +export function config(name, msg) { + if (!seenConfigs.has(name)) { + console.log(`Config ${msg} for ${name}`); + seenConfigs.add(name); + } +} + +export function db(msg) { + console.log(msg); +} + +export function server(msg) { + console.log(msg); +} + +export function section(title) { + console.log(`\n=== ${title.toUpperCase()} ===`); +} + +export function warn(_category, msg) { + console.warn(`WARNING: ${msg}`); +} + +export function error(_category, msg) { + console.error(`ERROR: ${msg}`); +} + +// General message function for bullet items +export function msg(msg) { + console.log(msg); +} diff --git a/utils/network.js b/utils/network.js new file mode 100644 index 0000000..100eb13 --- /dev/null +++ b/utils/network.js @@ -0,0 +1,13 @@ +export function getRealIP(request, server) { + let ip = request.headers.get('x-forwarded-for') || request.headers.get('x-real-ip'); + if (ip?.includes(',')) ip = ip.split(',')[0].trim(); + if (!ip && server) { + ip = server.remoteAddress; + } + if (!ip) { + const url = new URL(request.url); + ip = url.hostname; + } + if (ip?.startsWith('::ffff:')) ip = ip.slice(7); + return ip; +} diff --git a/utils/plugins.js b/utils/plugins.js new file mode 100644 index 0000000..b2fd961 --- /dev/null +++ b/utils/plugins.js @@ -0,0 +1,28 @@ +import { resolve, extname, sep, isAbsolute } from 'path'; +import { pathToFileURL } from 'url'; +import { rootDir } from '../index.js'; + +/** + * Securely import a JavaScript module from within the application root. + * Prevents path traversal and disallows non-.js extensions. + * + * @param {string} relPath - The relative path to the module from the application root. + * @returns {Promise} The imported module. + */ +export async function secureImportModule(relPath) { + if (isAbsolute(relPath)) { + throw new Error('Absolute paths are not allowed for module imports'); + } + if (relPath.includes('..')) { + throw new Error('Relative paths containing .. are not allowed for module imports'); + } + if (extname(relPath) !== '.js') { + throw new Error(`Only .js files can be imported: ${relPath}`); + } + const absPath = resolve(rootDir, relPath); + if (!absPath.startsWith(rootDir + sep)) { + throw new Error(`Module path outside of application root: ${relPath}`); + } + const url = pathToFileURL(absPath).href; + return import(url); +} diff --git a/utils/proof.js b/utils/proof.js new file mode 100644 index 0000000..2058e91 --- /dev/null +++ b/utils/proof.js @@ -0,0 +1,72 @@ +import crypto from 'crypto'; +import { getRealIP } from './network.js'; + +export function generateChallenge(checkpointConfig) { + const challenge = crypto.randomBytes(16).toString('hex'); + const salt = crypto.randomBytes(checkpointConfig.SaltLength).toString('hex'); + return { challenge, salt }; +} + +export function calculateHash(input) { + return crypto.createHash('sha256').update(input).digest('hex'); +} + +export function verifyPoW(challenge, salt, nonce, difficulty) { + const hash = calculateHash(challenge + salt + nonce); + return hash.startsWith('0'.repeat(difficulty)); +} + +export function checkPoSTimes(times, enableCheck, ratio) { + if (!Array.isArray(times) || times.length !== 3) { + throw new Error('Invalid PoS run times length'); + } + const minT = Math.min(...times); + const maxT = Math.max(...times); + if (enableCheck && maxT > minT * ratio) { + throw new Error(`PoS run times inconsistent (ratio ${maxT / minT} > ${ratio})`); + } +} + +export const challengeStore = new Map(); + +export function generateRequestID(request, checkpointConfig) { + const { challenge, salt } = generateChallenge(checkpointConfig); + const posSeed = crypto.randomBytes(32).toString('hex'); + const requestID = crypto.randomBytes(16).toString('hex'); + const params = { + Challenge: challenge, + Salt: salt, + Difficulty: checkpointConfig.Difficulty, + ExpiresAt: Date.now() + checkpointConfig.ChallengeExpiration, + CreatedAt: Date.now(), + ClientIP: getRealIP(request), + PoSSeed: posSeed, + }; + challengeStore.set(requestID, params); + return requestID; +} + +export function getChallengeParams(requestID) { + return challengeStore.get(requestID); +} + +export function deleteChallenge(requestID) { + challengeStore.delete(requestID); +} + +export function verifyPoS(hashes, times, checkpointConfig) { + if (!Array.isArray(hashes) || hashes.length !== 3) { + throw new Error('Invalid PoS hashes length'); + } + if (!Array.isArray(times) || times.length !== 3) { + throw new Error('Invalid PoS run times length'); + } + if (hashes[0] !== hashes[1] || hashes[1] !== hashes[2]) { + throw new Error('PoS hashes do not match'); + } + if (hashes[0].length !== 64) { + throw new Error('Invalid PoS hash length'); + } + + checkPoSTimes(times, checkpointConfig.CheckPoSTimes, checkpointConfig.PoSTimeConsistencyRatio); +} diff --git a/utils/time.js b/utils/time.js new file mode 100644 index 0000000..d10e569 --- /dev/null +++ b/utils/time.js @@ -0,0 +1,20 @@ +export function parseDuration(str) { + if (!str) return 0; + const m = /^([0-9]+)(ms|s|m|h|d)$/.exec(str); + if (!m) return 0; + const val = parseInt(m[1], 10); + switch (m[2]) { + case 'ms': + return val; + case 's': + return val * 1000; + case 'm': + return val * 60 * 1000; + case 'h': + return val * 60 * 60 * 1000; + case 'd': + return val * 24 * 60 * 60 * 1000; + default: + return 0; + } +}