Add deploy/upstream-forwarder

This commit is contained in:
lapp
2026-05-06 15:35:16 -07:00
parent e5f7b35580
commit 4011f2fe07
5 changed files with 310 additions and 0 deletions
+2
View File
@@ -0,0 +1,2 @@
DOMAIN=example.com
LETSENCRYPT_EMAIL=user1234@example.com
+14
View File
@@ -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"]
@@ -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
+38
View File
@@ -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
@@ -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 =
"<!DOCTYPE html><html><head><title>Forwarder Active</title></head>" +
'<body style="font-family:sans-serif;max-width:600px;margin:40px auto">' +
'<h1>Forwarder <span style="color:#16a34a;font-weight:700">Active</span></h1>' +
"<p>Upstream forwarder for the relay Worker.</p>" +
"</body></html>";
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);
}