diff --git a/.changeset/self-hosted-cloudflare.md b/.changeset/self-hosted-cloudflare.md new file mode 100644 index 0000000000..87b0f6f771 --- /dev/null +++ b/.changeset/self-hosted-cloudflare.md @@ -0,0 +1,17 @@ +--- +"@prosopo/scripts": patch +--- + +Document and support self-hosting on Cloudflare's free plan, for anyone without a server that has a public IP. + +A Worker on the free `workers.dev` subdomain serves the widget and forwards API calls through a Workers VPC binding to a +Cloudflare Tunnel, so the provider can run on any machine with Docker: no domain, no open ports, no certificates. + +- `docker/cloudflare/worker.ts` and `wrangler.jsonc`: the Worker. It also serves a demo page at `/` that shows whether + the provider is reachable, renders the CAPTCHA and displays the response token. It sets `X-Forwarded-For` to the + visitor's address, because without it the provider sees every visitor as the tunnel and per-IP limits apply to all of + them together. +- `docker-compose.self-hosted.cloudflare.yml`: adds `cloudflared` and leaves Caddy out, since Cloudflare terminates TLS. +- `generateSelfHostedEnv.ts --cloudflare`: writes a `COMPOSE_FILE` that loads both compose files and a + `CLOUDFLARE_TUNNEL_TOKEN` to fill in. +- `SELF_HOSTING.md`: a "No server? Run it on Cloudflare's free plan" section. diff --git a/SELF_HOSTING.md b/SELF_HOSTING.md index d102567ba4..5f9152d463 100644 --- a/SELF_HOSTING.md +++ b/SELF_HOSTING.md @@ -120,6 +120,84 @@ The CAPTCHA type comes from the site key record, not the page. The bundle is an ES module split into chunks, so `type="module"` is required and the whole `dist/bundle/` directory must be hosted together. +## No server? Run it on Cloudflare's free plan + +Steps 2 and 4 assume a server with a public IP and ports 80 and 443 open. If you don't have one, any machine that can +run Docker will do — a spare PC, a home server — with Cloudflare in front of it. You need a free Cloudflare account and +nothing else: no domain, no public IP, no open ports, no certificates. + +One Worker on your free `workers.dev` subdomain serves the widget and forwards API calls to your provider through a +Cloudflare Tunnel. The tunnel connects outbound from your machine, so nothing is exposed on your network. Cloudflare +handles HTTPS, so Caddy is not used. + +Do step 1 up to `pnpm run build:all`, but don't generate the environment file yet — you'll generate it below with +the Worker's hostname. Then, from the repository root: + +```bash +npx wrangler@4.151.3 login +npx wrangler@4.151.3 tunnel create procaptcha # note the ID it prints +``` + +Open the tunnel in the Cloudflare dashboard (**Zero Trust → Networks → Tunnels → procaptcha**). Its install command +contains `--token `; copy the token. You don't need to add a public hostname. + +Your Worker will be at `procaptcha..workers.dev`, where `` is shown in the dashboard under +**Workers & Pages**. Generate the environment file for that hostname, with `--cloudflare`: + +```bash +pnpm exec tsx ./dev/scripts/src/scripts/generateSelfHostedEnv.ts \ + --host procaptcha..workers.dev --cloudflare +``` + +Paste the token into `CLOUDFLARE_TUNNEL_TOKEN` in `.env`, then start the stack. `--cloudflare` has already set +`COMPOSE_FILE` to add [`docker-compose.self-hosted.cloudflare.yml`](docker-compose.self-hosted.cloudflare.yml), which +runs `cloudflared` and leaves Caddy out: + +```bash +docker compose up -d +``` + +Let Workers reach the provider through the tunnel, and put the service ID it prints into +[`docker/cloudflare/wrangler.jsonc`](docker/cloudflare/wrangler.jsonc) in place of `REPLACE_WITH_YOUR_VPC_SERVICE_ID`: + +```bash +npx wrangler@4.151.3 vpc service create procaptcha-provider \ + --type http --tunnel-id --hostname provider --http-port 9229 +``` + +Register your site key as in [step 3](#3-register-a-site-key), adding the Worker's hostname to `--domains` so its demo +page works: `--domains example.com procaptcha..workers.dev`. + +Build the widget against the Worker and deploy it: + +```bash +NODE_ENV=production \ +PROSOPO_PROVIDER_LIST=https://procaptcha..workers.dev \ +pnpm --filter @prosopo/procaptcha-bundle run bundle + +npx wrangler@4.151.3 deploy --config docker/cloudflare/wrangler.jsonc +``` + +Open `https://procaptcha..workers.dev/?sitekey=`. The demo page shows whether the provider is +reachable, renders the CAPTCHA, and shows the response token once it is solved. On your own site, load the widget from +the Worker: + +```html + +
+``` + +Server-side verification is unchanged; `PROSOPO_PROVIDER_LIST` in your `.env` already points at the Worker. + +Worth knowing before you rely on it: + +- Workers VPC, which lets the Worker reach the tunnel, is in open beta. +- The free Workers plan has a daily request allowance. Each solve is a handful of requests, so check the current limit + against your traffic. +- The Worker passes each visitor's address to the provider in `X-Forwarded-For`. Without that, the provider would see + every visitor as the tunnel, and per-IP rate limits and rules would apply to all of them together. +- The provider only runs while your machine does. + ## Bring your own detector The steps above serve a fixed challenge to everyone. To score visitors yourself, load a detector of your own and register diff --git a/dev/scripts/src/scripts/generateSelfHostedEnv.ts b/dev/scripts/src/scripts/generateSelfHostedEnv.ts index 3ee61afed4..1354b5e27d 100644 --- a/dev/scripts/src/scripts/generateSelfHostedEnv.ts +++ b/dev/scripts/src/scripts/generateSelfHostedEnv.ts @@ -27,6 +27,7 @@ interface Args { host: string; output: string; force: boolean; + cloudflare: boolean; } const usage = ` @@ -38,6 +39,9 @@ Options: --host Hostname clients will reach this provider on (required) --output Where to write (default: .env) --force Overwrite the output file if it already exists + --cloudflare Run behind a Cloudflare Tunnel instead of Caddy. Adds the + cloudflared service and a CLOUDFLARE_TUNNEL_TOKEN to fill in. + --host is then the Worker's workers.dev hostname. `; const parseArgs = (argv: string[]): Args => { @@ -65,6 +69,7 @@ const parseArgs = (argv: string[]): Args => { host, output: flagValue("--output") ?? ".env", force: argv.includes("--force"), + cloudflare: argv.includes("--cloudflare"), }; }; @@ -76,7 +81,22 @@ const parseArgs = (argv: string[]): Args => { */ const generatePassword = (): string => randomBytes(24).toString("base64url"); -const buildEnvFile = async (host: string): Promise => { +const composeFileSection = (cloudflare: boolean): string => + cloudflare + ? `# Lets every docker compose command find both compose files without -f. +# The second runs cloudflared and leaves Caddy out. +COMPOSE_FILE=docker-compose.self-hosted.yml:docker-compose.self-hosted.cloudflare.yml + +# The token from your tunnel's page in the Cloudflare dashboard. +CLOUDFLARE_TUNNEL_TOKEN=` + : `# Lets every docker compose command find docker-compose.self-hosted.yml +# without -f. +COMPOSE_FILE=docker-compose.self-hosted.yml`; + +const buildEnvFile = async ( + host: string, + cloudflare: boolean, +): Promise => { const providerImageVersion = readProviderImageVersion(); const [providerMnemonic, providerAddress] = await generateMnemonic(); const [siteKeyMnemonic, siteKeyAddress] = await generateMnemonic(); @@ -95,9 +115,7 @@ NODE_ENV=production PROSOPO_DEFAULT_ENVIRONMENT=production PROSOPO_LOG_LEVEL=info -# Lets every docker compose command find docker-compose.self-hosted.yml -# without -f. -COMPOSE_FILE=docker-compose.self-hosted.yml +${composeFileSection(cloudflare)} # The provider release this checkout documents. Change it when you pull a # newer checkout, so the image and the instructions you follow stay in step. @@ -181,7 +199,7 @@ const main = async (): Promise => { ); } - const contents = await buildEnvFile(args.host); + const contents = await buildEnvFile(args.host, args.cloudflare); // 0o600 because this file holds two mnemonics and two passwords. fs.writeFileSync(outputPath, contents, { mode: 0o600 }); diff --git a/docker-compose.self-hosted.cloudflare.yml b/docker-compose.self-hosted.cloudflare.yml new file mode 100644 index 0000000000..18e23fbb95 --- /dev/null +++ b/docker-compose.self-hosted.cloudflare.yml @@ -0,0 +1,28 @@ +# Runs the self-hosted provider behind a Cloudflare Tunnel, for hosts with no +# public address. Cloudflare terminates TLS, so Caddy is not started. +# +# Used on top of docker-compose.self-hosted.yml. generateSelfHostedEnv.ts +# --cloudflare writes a COMPOSE_FILE that loads both. +# +# Guide: "Self-hosting on Cloudflare" in SELF_HOSTING.md. + +services: + caddy: + # Only started with --profile caddy, which a tunnelled node never needs. + profiles: ['caddy'] + + cloudflared: + image: cloudflare/cloudflared:2026.10.3 + restart: unless-stopped + depends_on: + - provider + command: tunnel --no-autoupdate run + environment: + # Passed in the environment rather than on the command line so it does + # not show in the process list. + TUNNEL_TOKEN: ${CLOUDFLARE_TUNNEL_TOKEN:?paste your tunnel token into CLOUDFLARE_TUNNEL_TOKEN in .env} + logging: + driver: 'json-file' + options: + max-size: '100m' + max-file: '1' diff --git a/docker/cloudflare/worker.ts b/docker/cloudflare/worker.ts new file mode 100644 index 0000000000..d290718fa1 --- /dev/null +++ b/docker/cloudflare/worker.ts @@ -0,0 +1,169 @@ +// Copyright 2021-2026 Prosopo (UK) Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +// Fronts a self-hosted provider that has no public address of its own. The +// widget is served as static assets, and provider API calls are forwarded +// through a Workers VPC binding to the Cloudflare Tunnel the provider sits +// behind. + +interface Binding { + fetch(input: RequestInfo | URL, init?: RequestInit): Promise; +} + +interface Env { + ASSETS: Binding; + PROVIDER: Binding; + SITE_KEY: string; +} + +const isProviderPath = (pathname: string): boolean => + pathname.startsWith("/v1/") || pathname === "/healthz"; + +// The provider reads the client address from the last X-Forwarded-For entry. +// Without this it would see every visitor as the tunnel, and per-IP rate +// limits and access rules would apply to all of them at once. +const forwardToProvider = ( + request: Request, + url: URL, + env: Env, +): Promise => { + const headers = new Headers(request.headers); + const visitorIp = request.headers.get("CF-Connecting-IP"); + if (visitorIp) headers.set("X-Forwarded-For", visitorIp); + headers.set("X-Forwarded-Proto", "https"); + return env.PROVIDER.fetch(`http://provider${url.pathname}${url.search}`, { + method: request.method, + headers, + body: request.body, + redirect: "manual", + }); +}; + +const escapeHtml = (value: string): string => + value.replace( + /[&<>"']/g, + (char) => + ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[ + char + ] ?? char, + ); + +const SITE_KEY_PATTERN = /^[1-9A-HJ-NP-Za-km-z]{47,48}$/; + +const demoPage = (siteKey: string, host: string): string => ` + + + + +Procaptcha · self-hosted + + + + +
+

Procaptcha, self-hosted

+

This page, the widget and the provider API are all served by this Worker. Challenges are answered by your own provider through a Cloudflare Tunnel.

+
+
Provider
https://${escapeHtml(host)}
+
Status
checking…
+
Site key
${siteKey ? `${escapeHtml(siteKey)}` : 'none — add ?sitekey= to the URL, or set SITE_KEY in wrangler.jsonc'}
+
+ ${siteKey ? `
` : ""} +
Response token
+
Solve the challenge above to get a token.
+ +
+ + +`; + +export default { + async fetch(request: Request, env: Env): Promise { + const url = new URL(request.url); + if (isProviderPath(url.pathname)) { + return forwardToProvider(request, url, env); + } + if (url.pathname === "/") { + const requested = url.searchParams.get("sitekey") ?? env.SITE_KEY; + const siteKey = SITE_KEY_PATTERN.test(requested) ? requested : ""; + return new Response(demoPage(siteKey, url.host), { + headers: { "content-type": "text/html; charset=utf-8" }, + }); + } + return env.ASSETS.fetch(request); + }, +}; diff --git a/docker/cloudflare/wrangler.jsonc b/docker/cloudflare/wrangler.jsonc new file mode 100644 index 0000000000..be8f5d7049 --- /dev/null +++ b/docker/cloudflare/wrangler.jsonc @@ -0,0 +1,24 @@ +// Deploys the Worker that fronts a self-hosted provider on Cloudflare's free +// plan. See "Self-hosting on Cloudflare" in SELF_HOSTING.md. +{ + "name": "procaptcha", + "main": "worker.ts", + "compatibility_date": "2026-10-01", + "assets": { + // The widget, built with PROSOPO_PROVIDER_LIST pointing at this Worker. + "directory": "../../packages/procaptcha-bundle/dist/bundle", + "binding": "ASSETS", + "run_worker_first": ["/v1/*", "/healthz"] + }, + "vpc_services": [ + { + "binding": "PROVIDER", + // The id printed by `wrangler vpc service create`. + "service_id": "REPLACE_WITH_YOUR_VPC_SERVICE_ID" + } + ], + "vars": { + // Shown on the demo page at /. Leave empty to pass ?sitekey= instead. + "SITE_KEY": "" + } +}