Websocket Fixes & New Config Examples

This commit is contained in:
Caileb 2025-05-27 21:30:54 -05:00
parent 84225a66f9
commit 9bcdc532bb
10 changed files with 389 additions and 96 deletions

3
.gitignore vendored
View file

@ -38,3 +38,6 @@ data
# DB Folder # DB Folder
db db
# My Configs
*.toml

View file

@ -124,8 +124,8 @@ async function initConfig() {
}); });
// Extension handling // Extension handling
checkpointConfig.HTMLCheckpointIncludedExtensions = checkpointConfig.Extensions.IncludeOnly; checkpointConfig.HTMLCheckpointIncludedExtensions = checkpointConfig.Extensions?.IncludeOnly || [];
checkpointConfig.HTMLCheckpointExcludedExtensions = checkpointConfig.Extensions.Exclude; checkpointConfig.HTMLCheckpointExcludedExtensions = checkpointConfig.Extensions?.Exclude || [];
// Remove legacy arrays // Remove legacy arrays
checkpointConfig.HTMLCheckpointExclusions = []; checkpointConfig.HTMLCheckpointExclusions = [];

View file

@ -90,22 +90,9 @@ Path = "/api"
Hosts = ["gallery.caileb.com"] # Optional: only for specific hosts Hosts = ["gallery.caileb.com"] # Optional: only for specific hosts
[[Exclusion]] [[Exclusion]]
# Skip checkpoint for health checks # Allows Git pushes w/ ForgeJo
Path = "/health" Path = "/info/refs"
Hosts = ["git.caileb.com"]
[[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 # BYPASS KEYS

View file

@ -0,0 +1,123 @@
# =============================================================================
# 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
Path = "/api"
Hosts = ["api.example.com"] # Optional: only for specific hosts
[[Exclusion]]
# Allows Git operations
Path = "/info/refs"
Hosts = ["git.example.com"]
[[Exclusion]]
# Skip checkpoint for metrics endpoint
Path = "/metrics"
# [[Exclusion]]
# Example: Mobile app API with specific user agent
# Path = "/mobile-api"
# UserAgents = ["MyApp/", "Dart/"]
# -----------------------------------------------------------------------------
# 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.example.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

View file

@ -0,0 +1,89 @@
# =============================================================================
# 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 = false
# 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 = [
"XX", "YY", "ZZ" # Replace with actual country codes
]
# Continent codes to block
ContinentCodes = [] # Example: ["AF", "AS"]
# 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"
]
BlockPage = "/pages/ipfilter/datacenter.html"
# -----------------------------------------------------------------------------
# COUNTRY-SPECIFIC BLOCK PAGES
# -----------------------------------------------------------------------------
[CountryBlockPages]
# Custom block pages for specific countries
XX = "/pages/ipfilter/country-xx.html"
# -----------------------------------------------------------------------------
# CONTINENT-SPECIFIC BLOCK PAGES
# -----------------------------------------------------------------------------
[ContinentBlockPages]
# Custom block pages for specific continents
# AS = "pages/ipfilter/asia.html"
# AF = "pages/ipfilter/africa.html"

View file

@ -17,7 +17,7 @@ Enabled = true
# ----------------------------------------------------------------------------- # -----------------------------------------------------------------------------
[Timeouts] [Timeouts]
# WebSocket connection timeout in milliseconds # WebSocket connection timeout in milliseconds
WebSocketTimeoutMs = 5000 WebSocketTimeoutMs = 60000
# Upstream HTTP request timeout in milliseconds # Upstream HTTP request timeout in milliseconds
UpstreamTimeoutMs = 30000 UpstreamTimeoutMs = 30000

55
config/proxy.toml.example Normal file
View file

@ -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]]
# Media server
Host = "media.example.com"
Target = "http://192.168.1.100:8096"
[[Mapping]]
# Music streaming service
Host = "music.example.com"
Target = "http://192.168.1.100:4533"
[[Mapping]]
# Git repository
Host = "git.example.com"
Target = "http://192.168.1.100:3000"
# [[Mapping]]
# API service
# Host = "api.example.com"
# Target = "http://localhost:3001"
# [[Mapping]]
# Admin panel
# Host = "admin.example.com"
# Target = "http://localhost:3002"

31
config/stats.toml.example Normal file
View file

@ -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"

View file

@ -6,7 +6,6 @@ import { secureImportModule } from './utils/plugins.js';
import * as logs from './utils/logs.js'; import * as logs from './utils/logs.js';
import express from 'express'; import express from 'express';
import { createServer } from 'http'; import { createServer } from 'http';
import { WebSocketServer } from 'ws';
import { spawn } from 'child_process'; import { spawn } from 'child_process';
// Load environment variables from .env file // Load environment variables from .env file
@ -135,9 +134,6 @@ async function main() {
// Trust proxy headers (important for proper protocol detection) // Trust proxy headers (important for proper protocol detection)
app.set('trust proxy', true); app.set('trust proxy', true);
// Initialize WebSocket server
const wss = new WebSocketServer({ noServer: true });
try { try {
await secureImportModule('checkpoint.js'); await secureImportModule('checkpoint.js');
} catch (e) { } catch (e) {
@ -203,12 +199,30 @@ async function main() {
res.status(500).send(`Server Error: ${err.message}`); res.status(500).send(`Server Error: ${err.message}`);
}); });
// Handle WebSocket upgrades for http-proxy-middleware // Handle WebSocket upgrades using http-proxy-middleware instances
server.on('upgrade', (request, socket, head) => { server.on('upgrade', (req, socket, head) => {
// http-proxy-middleware handles WebSocket upgrades internally const hostname = req.headers.host?.split(':')[0];
// This is just a placeholder for any custom WebSocket handling if needed if (!hostname) {
wss.handleUpgrade(request, socket, head, (ws) => { logs.error('websocket', 'Upgrade request without host header, destroying socket.');
wss.emit('connection', ws, request); socket.destroy();
return;
}
logs.server(`WebSocket upgrade request for ${hostname}${req.url}`);
import('./plugins/proxy.js').then(proxyModule => {
const hpmInstance = proxyModule.getHpmInstance(hostname);
if (hpmInstance && typeof hpmInstance.upgrade === 'function') {
logs.server(`Attempting to upgrade WebSocket for ${hostname} using HPM instance.`);
hpmInstance.upgrade(req, socket, head);
} else {
logs.error('websocket', `No HPM instance or upgrade method found for ${hostname}${req.url}`);
socket.destroy();
}
}).catch(err => {
logs.error('websocket', `Error importing proxy module for upgrade: ${err.message}`);
socket.destroy();
}); });
}); });

View file

@ -2,16 +2,41 @@ import { registerPlugin, loadConfig } from '../index.js';
import * as logs from '../utils/logs.js'; import * as logs from '../utils/logs.js';
import { createProxyMiddleware } from 'http-proxy-middleware'; import { createProxyMiddleware } from 'http-proxy-middleware';
import express from 'express'; import express from 'express';
import { fileURLToPath } from 'url';
import { dirname } from 'path';
import { createRequire } from 'module';
// Setup require for ESM modules
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
const require = createRequire(import.meta.url);
// Monkey patch the ws module to prevent "write after end" errors
// Based on https://stackoverflow.com/questions/27769842/write-after-end-error-in-node-js-webserver/33591429
try {
const ws = require('ws');
const originalClose = ws.Sender.prototype.close;
// Override the close method to check if the socket is already closed
ws.Sender.prototype.close = function(code, data, mask, cb) {
if (this._socket && (this._socket.destroyed || !this._socket.writable)) {
logs.plugin('proxy', 'WebSocket close called on already closed socket - ignoring');
if (typeof cb === 'function') cb();
return;
}
return originalClose.call(this, code, data, mask, cb);
};
logs.plugin('proxy', 'Monkey patched ws module to prevent write after end errors');
} catch (err) {
logs.error('proxy', `Failed to monkey patch ws module: ${err.message}`);
}
const proxyConfig = {}; const proxyConfig = {};
await loadConfig('proxy', proxyConfig); await loadConfig('proxy', proxyConfig);
// Map configuration to internal structure
const enabled = proxyConfig.Core.Enabled; const enabled = proxyConfig.Core.Enabled;
const wsTimeout = proxyConfig.Timeouts.WebSocketTimeoutMs;
const upstreamTimeout = proxyConfig.Timeouts.UpstreamTimeoutMs; const upstreamTimeout = proxyConfig.Timeouts.UpstreamTimeoutMs;
// Build proxy mappings from array format
const proxyMappings = {}; const proxyMappings = {};
proxyConfig.Mapping.forEach(mapping => { proxyConfig.Mapping.forEach(mapping => {
proxyMappings[mapping.Host] = mapping.Target; proxyMappings[mapping.Host] = mapping.Target;
@ -19,94 +44,56 @@ proxyConfig.Mapping.forEach(mapping => {
logs.plugin('proxy', `Proxy mappings loaded: ${JSON.stringify(proxyMappings)}`); logs.plugin('proxy', `Proxy mappings loaded: ${JSON.stringify(proxyMappings)}`);
// Store for http-proxy-middleware instances
const hpmInstances = {};
function createProxyForHost(target) { function createProxyForHost(target) {
return createProxyMiddleware({ const proxyOptions = {
target, target,
changeOrigin: true, changeOrigin: true,
ws: true, // Enable WebSocket support ws: true,
logLevel: 'info',
timeout: upstreamTimeout, timeout: upstreamTimeout,
proxyTimeout: upstreamTimeout, onError: (err, req, res, _target) => {
onProxyReq: (proxyReq, req, res) => { const targetInfo = _target && _target.href ? _target.href : (typeof _target === 'string' ? _target : 'N/A');
// Remove undefined headers logs.error('proxy', `[HPM onError] Proxy error for ${req.method} ${req.url} to ${targetInfo}: ${err.message} (Code: ${err.code || 'N/A'})`);
const headersToRemove = ['x-forwarded-host', 'x-forwarded-proto', 'x-forwarded-for']; if (res && typeof res.writeHead === 'function') {
headersToRemove.forEach(header => { if (!res.headersSent) {
proxyReq.removeHeader(header); res.writeHead(502, { 'Content-Type': 'text/plain' });
}); res.end('Bad Gateway');
} else if (typeof res.destroy === 'function' && !res.destroyed) {
// Set proper forwarded headers res.destroy();
const forwarded = { }
for: req.ip || req.connection.remoteAddress, } else if (res && typeof res.end === 'function' && res.writable && !res.destroyed) {
host: req.get('host'), logs.plugin('proxy', `[HPM onError] Client WebSocket socket for ${req.url} attempting to end due to proxy error: ${err.message}.`);
proto: req.protocol res.end();
};
proxyReq.setHeader('X-Forwarded-For', forwarded.for);
proxyReq.setHeader('X-Forwarded-Host', forwarded.host);
proxyReq.setHeader('X-Forwarded-Proto', forwarded.proto);
// Log the proxied request
const startTime = Date.now();
res.on('finish', () => {
const latency = Date.now() - startTime;
logs.plugin('proxy', `Proxied request to: ${target}${req.url} (${res.statusCode}) (${latency}ms)`);
});
},
onProxyReqWs: (proxyReq, req, socket, options, head) => {
// Set WebSocket timeout
socket.setTimeout(wsTimeout);
logs.plugin('proxy', `WebSocket proxied to: ${target}${req.url}`);
},
onError: (err, req, res) => {
logs.error('proxy', `Proxy error: ${err.message}`);
if (!res.headersSent) {
res.status(502).send('Bad Gateway');
} }
}, },
// Handle SSE and streaming responses properly
onProxyRes: (proxyRes, req, res) => {
// For SSE responses, ensure proper headers
const contentType = proxyRes.headers['content-type'];
if (contentType && contentType.includes('text/event-stream')) {
res.setHeader('Cache-Control', 'no-cache, no-store, must-revalidate');
res.setHeader('X-Accel-Buffering', 'no');
// Remove compression for SSE
delete proxyRes.headers['content-encoding'];
// Force connection keep-alive
res.setHeader('Connection', 'keep-alive');
}
},
// Advanced options for better compatibility
followRedirects: false, followRedirects: false,
preserveHeaderKeyCase: true, preserveHeaderKeyCase: true,
autoRewrite: true, autoRewrite: true,
protocolRewrite: 'http', protocolRewrite: 'http',
cookieDomainRewrite: { cookieDomainRewrite: { "*": "" }
"*": "" // Remove domain restrictions from cookies };
}
}); return createProxyMiddleware(proxyOptions);
} }
function proxyMiddleware() { function proxyMiddleware() {
const router = express.Router(); const router = express.Router();
// Skip checkpoint endpoints
router.use('/api/challenge', (req, res, next) => next('route')); router.use('/api/challenge', (req, res, next) => next('route'));
router.use('/api/verify', (req, res, next) => next('route')); router.use('/api/verify', (req, res, next) => next('route'));
// Skip static assets (already handled by static middleware)
router.use('/webfont/', (req, res, next) => next('route')); router.use('/webfont/', (req, res, next) => next('route'));
router.use('/js/', (req, res, next) => next('route')); router.use('/js/', (req, res, next) => next('route'));
// Create a proxy instance for each host
const proxyInstances = {};
Object.entries(proxyMappings).forEach(([host, target]) => { Object.entries(proxyMappings).forEach(([host, target]) => {
proxyInstances[host] = createProxyForHost(target); hpmInstances[host] = createProxyForHost(target);
}); });
// Main proxy handler
router.use((req, res, next) => { router.use((req, res, next) => {
const hostname = req.hostname || req.headers.host?.split(':')[0]; const hostname = req.hostname || req.headers.host?.split(':')[0];
const proxyInstance = proxyInstances[hostname]; const proxyInstance = hpmInstances[hostname];
if (proxyInstance) { if (proxyInstance) {
proxyInstance(req, res, next); proxyInstance(req, res, next);
@ -118,6 +105,10 @@ function proxyMiddleware() {
return { middleware: router }; return { middleware: router };
} }
export function getHpmInstance(hostname) {
return hpmInstances[hostname];
}
if (enabled) { if (enabled) {
registerPlugin('proxy', proxyMiddleware()); registerPlugin('proxy', proxyMiddleware());
} else { } else {