From 4011f2fe0773a85053cacbfc05c57905627b54d4 Mon Sep 17 00:00:00 2001 From: lapp Date: Wed, 6 May 2026 15:35:16 -0700 Subject: [PATCH] Add deploy/upstream-forwarder --- deploy/upstream_forwarder/.env | 2 + deploy/upstream_forwarder/Dockerfile | 14 ++ deploy/upstream_forwarder/docker-compose.yml | 113 ++++++++++++++ deploy/upstream_forwarder/traefik.yml | 38 +++++ .../upstream_forwarder/upstream_forwarder.js | 143 ++++++++++++++++++ 5 files changed, 310 insertions(+) create mode 100644 deploy/upstream_forwarder/.env create mode 100644 deploy/upstream_forwarder/Dockerfile create mode 100644 deploy/upstream_forwarder/docker-compose.yml create mode 100644 deploy/upstream_forwarder/traefik.yml create mode 100644 deploy/upstream_forwarder/upstream_forwarder.js diff --git a/deploy/upstream_forwarder/.env b/deploy/upstream_forwarder/.env new file mode 100644 index 0000000..8c7a774 --- /dev/null +++ b/deploy/upstream_forwarder/.env @@ -0,0 +1,2 @@ +DOMAIN=example.com +LETSENCRYPT_EMAIL=user1234@example.com diff --git a/deploy/upstream_forwarder/Dockerfile b/deploy/upstream_forwarder/Dockerfile new file mode 100644 index 0000000..1015887 --- /dev/null +++ b/deploy/upstream_forwarder/Dockerfile @@ -0,0 +1,14 @@ +FROM node:18-alpine + +WORKDIR /app + +COPY upstream_forwarder.js . + +ENV NODE_ENV=production + +ENV PORT=8787 +ENV HOST=0.0.0.0 + +EXPOSE 8787 + +CMD ["node", "upstream_forwarder.js"] \ No newline at end of file diff --git a/deploy/upstream_forwarder/docker-compose.yml b/deploy/upstream_forwarder/docker-compose.yml new file mode 100644 index 0000000..00417d8 --- /dev/null +++ b/deploy/upstream_forwarder/docker-compose.yml @@ -0,0 +1,113 @@ +# docker-compose.yml + +name: "mhr-cfw-upstream-forwarder-cluster" + +services: + traefik: + image: traefik:v3.6 + container_name: traefik + restart: unless-stopped + security_opt: + - no-new-privileges:true + env_file: + - .env + volumes: + - "/var/run/docker.sock:/var/run/docker.sock:ro" + - "./traefik.yml:/traefik.yml:ro" + - "./data/letsencrypt/acme.json:/letsencrypt/acme.json" + networks: + - traefik-network + ports: + - 80:80 + - 443:443 + # - 8080:8080 + command: + - "--configFile=/traefik.yml" + labels: + - "traefik.enable=true" + - "traefik.http.routers.dashboard.rule=Host(`traefik.${DOMAIN}`)" + - "traefik.http.routers.dashboard.entrypoints=web,websecure" + - "traefik.http.routers.dashboard.service=api@internal" + - "traefik.http.routers.dashboard.tls=true" + - "traefik.http.routers.dashboard.tls.certresolver=letsencrypt" + - "traefik.docker.network=traefik-network" + portainer: + image: portainer/portainer-ce:lts + container_name: portainer + restart: unless-stopped + volumes: + - "/var/run/docker.sock:/var/run/docker.sock" + - "portainer-data:/data" + networks: + - traefik-network + labels: + - "traefik.enable=true" + - "traefik.http.routers.portainer.rule=Host(`portainer.${DOMAIN}`)" + - "traefik.http.routers.portainer.entrypoints=web,websecure" + - "traefik.http.routers.portainer.tls=true" + - "traefik.http.routers.portainer.tls.certresolver=letsencrypt" + - "traefik.http.services.portainer.loadbalancer.server.port=9000" + - "traefik.docker.network=traefik-network" + mhr-cfw-upstream-forwarder-node1: + image: mhr-cfw-upstream-forwarder-node1 + build: ./services/mhr-cfw-upstream-forwarder/. + container_name: mhr-cfw-upstream-forwarder-node1 + restart: unless-stopped + networks: + - traefik-network + environment: + AUTH_KEY: "YOUR_SECRET_KEY" # replace with your own secret key + PORT: 8787 + HOST: 0.0.0.0 + labels: + - "traefik.enable=true" + - "traefik.http.routers.mhr-cfw-upstream-forwarder-node1.rule=Host(`node1.${DOMAIN}`)" + - "traefik.http.routers.mhr-cfw-upstream-forwarder-node1.entrypoints=web,websecure" + - "traefik.http.routers.mhr-cfw-upstream-forwarder-node1.tls=true" + - "traefik.http.routers.mhr-cfw-upstream-forwarder-node1.tls.certresolver=letsencrypt" + - "traefik.http.services.mhr-cfw-upstream-forwarder-node1.loadbalancer.server.port=8787" + - "traefik.docker.network=traefik-network" + # Optional: basic healthcheck + healthcheck: + test: ["CMD", "wget", "-qO-", "http://127.0.0.1:8787/"] + interval: 30s + timeout: 5s + retries: 3 + start_period: 10s + mhr-cfw-upstream-forwarder-node2: + image: mhr-cfw-upstream-forwarder-node2 + build: ./services/mhr-cfw-upstream-forwarder/. + container_name: mhr-cfw-upstream-forwarder-node2 + restart: unless-stopped + networks: + - traefik-network + environment: + AUTH_KEY: "YOUR_SECRET_KEY" # replace with your own secret key + PORT: 8787 + HOST: 0.0.0.0 + labels: + - "traefik.enable=true" + - "traefik.http.routers.mhr-cfw-upstream-forwarder-node2.rule=Host(`node2.${DOMAIN}`)" + - "traefik.http.routers.mhr-cfw-upstream-forwarder-node2.entrypoints=web,websecure" + - "traefik.http.routers.mhr-cfw-upstream-forwarder-node2.tls=true" + - "traefik.http.routers.mhr-cfw-upstream-forwarder-node2.tls.certresolver=letsencrypt" + - "traefik.http.services.mhr-cfw-upstream-forwarder-node2.loadbalancer.server.port=8787" + - "traefik.docker.network=traefik-network" + # Optional: basic healthcheck + healthcheck: + test: ["CMD", "wget", "-qO-", "http://127.0.0.1:8787/"] + interval: 30s + timeout: 5s + retries: 3 + start_period: 10s + +volumes: + portainer-data: + name: portainer-data + external: false + +networks: + traefik-network: + name: traefik-network + driver: bridge + external: true diff --git a/deploy/upstream_forwarder/traefik.yml b/deploy/upstream_forwarder/traefik.yml new file mode 100644 index 0000000..79d30b9 --- /dev/null +++ b/deploy/upstream_forwarder/traefik.yml @@ -0,0 +1,38 @@ +# traefik.yml + +global: + checkNewVersion: true + sendAnonymousUsage: true + +log: + level: DEBUG + +api: + insecure: false + dashboard: true + +providers: + docker: + #watch: true + endpoint: "unix:///var/run/docker.sock" + exposedByDefault: false + network: traefik-network + +entryPoints: + web: + address: ":80" + # http: + # redirections: + # entryPoint: + # to: websecure + # scheme: https + websecure: + address: ":443" + +certificatesResolvers: + letsencrypt: + acme: + email: ${LETSENCRYPT_EMAIL} + storage: /letsencrypt/acme.json + httpChallenge: + entryPoint: web diff --git a/deploy/upstream_forwarder/upstream_forwarder.js b/deploy/upstream_forwarder/upstream_forwarder.js new file mode 100644 index 0000000..cde4fad --- /dev/null +++ b/deploy/upstream_forwarder/upstream_forwarder.js @@ -0,0 +1,143 @@ +// Upstream Forwarder — single-file Node 18+ HTTP server. +// +// Purpose: Provide a stable exit IP for the Cloudflare Worker relay so +// CAPTCHA tokens (Turnstile, reCAPTCHA, hCaptcha) bound to the solving +// IP survive verification on the target site. +// +// Run on a VPS with a stable public IP. Expose behind Caddy/nginx with +// TLS — the Worker rejects non-HTTPS forwarder URLs. +// +// Required env: +// AUTH_KEY — must match the Worker's UPSTREAM_AUTH_KEY (>= 32 chars) +// +// Optional env: +// PORT — listen port (default 8787) +// HOST — listen host (default 127.0.0.1, so Caddy/nginx fronts it) +// +// Wire protocol matches main/services/cloudflare-worker/worker.js: +// POST /fwd body: { u, m, h, b, ct, r } → { s, h, b } or { e } + +"use strict"; + +const http = require("http"); + +const AUTH_KEY = process.env.AUTH_KEY || ""; +const PORT = parseInt(process.env.PORT, 10) || 8787; +const HOST = process.env.HOST || "127.0.0.1"; + +if (!AUTH_KEY || AUTH_KEY.length < 32) { + console.error("FATAL: AUTH_KEY env var missing or shorter than 32 chars."); + process.exit(1); +} + +// Mirrors SKIP_HEADERS in main/script/Code.gs:6-9. +const SKIP_HEADERS = new Set([ + "host", + "connection", + "content-length", + "transfer-encoding", + "proxy-connection", + "proxy-authorization" +]); + +const STATUS_PAGE = + "Forwarder Active" + + '' + + '

Forwarder Active

' + + "

Upstream forwarder for the relay Worker.

" + + ""; + +const server = http.createServer(async (req, res) => { + try { + if (req.method === "GET" && (req.url === "/" || req.url === "")) { + res.writeHead(200, { "content-type": "text/html; charset=utf-8" }); + res.end(STATUS_PAGE); + return; + } + + if (req.method !== "POST" || req.url !== "/fwd") { + sendJson(res, 404, { e: "not found" }); + return; + } + + if (req.headers["x-upstream-auth"] !== AUTH_KEY) { + sendJson(res, 401, { e: "unauthorized" }); + return; + } + + const raw = await readBody(req); + let body; + try { + body = JSON.parse(raw); + } catch (_) { + sendJson(res, 400, { e: "invalid json" }); + return; + } + + if (!body.u || typeof body.u !== "string" || !/^https?:\/\//i.test(body.u)) { + sendJson(res, 400, { e: "bad url" }); + return; + } + + const headers = {}; + if (body.h && typeof body.h === "object") { + for (const [k, v] of Object.entries(body.h)) { + if (typeof v !== "string") continue; + if (SKIP_HEADERS.has(k.toLowerCase())) continue; + headers[k] = v; + } + } + headers["x-fwd-hop"] = "1"; + + const fetchOptions = { + method: (body.m || "GET").toUpperCase(), + headers, + redirect: body.r === false ? "manual" : "follow" + }; + + if (body.b) { + fetchOptions.body = Buffer.from(body.b, "base64"); + } + + let resp; + try { + resp = await fetch(body.u, fetchOptions); + } catch (err) { + sendJson(res, 502, { e: "fetch failed: " + String(err && err.message || err) }); + return; + } + + const buf = Buffer.from(await resp.arrayBuffer()); + const responseHeaders = {}; + resp.headers.forEach((v, k) => { + responseHeaders[k] = v; + }); + + sendJson(res, 200, { + s: resp.status, + h: responseHeaders, + b: buf.toString("base64") + }); + } catch (err) { + sendJson(res, 500, { e: String(err && err.message || err) }); + } +}); + +server.listen(PORT, HOST, () => { + console.log("upstream_forwarder listening on " + HOST + ":" + PORT); +}); + +function readBody(req) { + return new Promise((resolve, reject) => { + const chunks = []; + req.on("data", c => chunks.push(c)); + req.on("end", () => resolve(Buffer.concat(chunks).toString("utf8"))); + req.on("error", reject); + }); +} + +function sendJson(res, status, obj) { + const body = JSON.stringify(obj); + res.writeHead(status, { "content-type": "application/json" }); + res.end(body); +} \ No newline at end of file