diff --git a/CHANGELOG.md b/CHANGELOG.md index 7332a1e..a3721d1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,20 @@ All notable changes to this project are documented here, newest first. Every change is committed and pushed so the history can be followed in git as well. +## [1.1.0] - 2026-09-08 + +### Changed +- WARP check now queries Cloudflare over IPv4 and IPv6 separately (by edge IP literal) and logs both + egress IPs: `ipv4=104.28.x.x (warp=on) ipv6=2a09:bac1:... (warp=on) colo=AMS loc=NL`. +- `/warp` slash command shows the IPv4 and IPv6 Cloudflare IPs on separate lines. +- "Connected" now means the IPv4 path (the one Discord uses) reports `warp=on`; IPv6 is informational. + +### Verified on the host (192.168.5.67) +- Host public IP reports `warp=off`; inside the container both families report `warp=on`, colo `AMS`. +- The Node process's Discord gateway socket originates from `172.16.0.2` on the `CloudflareWARP` + interface, so bot traffic really goes through the tunnel. +- Docker health check reports `healthy`; `/ping` and `/warp` are registered in the test guild. + ## [1.0.0] - 2026-09-08 ### Added diff --git a/README.md b/README.md index cfb164f..1cc742f 100644 --- a/README.md +++ b/README.md @@ -26,11 +26,11 @@ generated inside a `node:22-bookworm-slim` container. `mode warp`, connects and waits until `warp-cli status` says `Connected`. 2. It then calls `https://www.cloudflare.com/cdn-cgi/trace` and **refuses to start the bot** unless Cloudflare reports `warp=on` (or `warp=plus`). The egress IP and colo are logged. -3. `bot/src/index.js` logs into Discord and repeats the same trace check every `WARP_CHECK_INTERVAL` - seconds (default 60), logging a line like: +3. `bot/src/index.js` logs into Discord and every `WARP_CHECK_INTERVAL` seconds (default 60) asks + Cloudflare's edge over IPv4 (`1.1.1.1`) and IPv6 (`2606:4700:4700::1111`) what it sees, logging a line like: ``` - [2026-09-08T00:20:15.123Z] [WARP] CONNECTED via Cloudflare (periodic) ip=104.28.x.x warp=on colo=AMS loc=NL http=http/2 tls=TLSv1.3 | warp-cli: Status update: Connected + [2026-09-08T00:20:15.123Z] [WARP] CONNECTED via Cloudflare (periodic) ipv4=104.28.x.x (warp=on) ipv6=2a09:bac1:xxxx::x:x (warp=on) colo=AMS loc=NL | warp-cli: Status update: Connected Network: healthy ``` If the tunnel drops it logs `NOT CONNECTED` and runs `warp-cli connect` to recover. @@ -89,7 +89,7 @@ You should see the entrypoint report the tunnel, then the bot log in: [entrypoint] WARP tunnel verified: ip=104.28.x.x warp=on colo=AMS [entrypoint] starting bot: node src/index.js [bot] logged in as ... in 1 guild(s) -[WARP] CONNECTED via Cloudflare (startup) ip=104.28.x.x warp=on colo=AMS ... +[WARP] CONNECTED via Cloudflare (startup) ipv4=104.28.x.x (warp=on) ipv6=2a09:bac1:xxxx::x:x (warp=on) colo=AMS loc=NL ... ``` ## Configuration (`.env`) diff --git a/bot/package-lock.json b/bot/package-lock.json index b6e8f2f..66e3c4b 100644 --- a/bot/package-lock.json +++ b/bot/package-lock.json @@ -1,12 +1,12 @@ { "name": "discordbot-cloudflarewarp", - "version": "1.0.0", + "version": "1.1.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "discordbot-cloudflarewarp", - "version": "1.0.0", + "version": "1.1.0", "license": "MIT", "dependencies": { "discord.js": "^14.16.3" diff --git a/bot/package.json b/bot/package.json index 598a3c1..4461b91 100644 --- a/bot/package.json +++ b/bot/package.json @@ -1,6 +1,6 @@ { "name": "discordbot-cloudflarewarp", - "version": "1.0.0", + "version": "1.1.0", "description": "Simple discord.js bot that runs behind a Cloudflare WARP tunnel and logs the Cloudflare IP it uses", "main": "src/index.js", "type": "module", diff --git a/bot/src/index.js b/bot/src/index.js index 2e1c07f..f330060 100644 --- a/bot/src/index.js +++ b/bot/src/index.js @@ -14,7 +14,12 @@ const execFileAsync = promisify(execFile); const TOKEN = process.env.DISCORD_TOKEN; const GUILD_ID = process.env.GUILD_ID || ""; const CHECK_INTERVAL_SEC = Number(process.env.WARP_CHECK_INTERVAL || 60); -const TRACE_URL = "https://www.cloudflare.com/cdn-cgi/trace"; + +// Cloudflare's trace endpoint reports the IP it sees us as and whether the +// request arrived over WARP. Hitting the edge by IP literal lets us force the +// address family so both the IPv4 and IPv6 egress IPs can be shown. +const TRACE_V4 = "https://1.1.1.1/cdn-cgi/trace"; +const TRACE_V6 = "https://[2606:4700:4700::1111]/cdn-cgi/trace"; if (!TOKEN) { console.error("[bot] DISCORD_TOKEN is not set. Exiting."); @@ -33,15 +38,7 @@ function log(scope, msg) { // WARP status helpers // --------------------------------------------------------------------------- -/** - * Ask Cloudflare's trace endpoint what it sees. This is the authoritative - * check: `warp=on` (or `warp=plus`) only appears when the request reached - * Cloudflare through the WARP tunnel. `ip=` is the public egress IP. - */ -async function fetchTrace() { - const res = await fetch(TRACE_URL, { signal: AbortSignal.timeout(10_000) }); - if (!res.ok) throw new Error(`trace HTTP ${res.status}`); - const text = await res.text(); +function parseTrace(text) { const data = {}; for (const line of text.trim().split("\n")) { const idx = line.indexOf("="); @@ -50,6 +47,22 @@ async function fetchTrace() { return data; } +async function fetchTrace(url) { + const res = await fetch(url, { signal: AbortSignal.timeout(10_000) }); + if (!res.ok) throw new Error(`trace HTTP ${res.status}`); + return parseTrace(await res.text()); +} + +/** Returns { ok, ip, warp, colo, loc, error } for one address family. */ +async function traceFamily(url) { + try { + const t = await fetchTrace(url); + return { ok: t.warp === "on" || t.warp === "plus", ...t }; + } catch (err) { + return { ok: false, error: err.message }; + } +} + /** Local view from the WARP daemon inside this container. */ async function warpCliStatus() { try { @@ -67,25 +80,28 @@ async function warpCliStatus() { let lastWarp = null; async function checkWarp(reason = "periodic") { - const cliStatus = await warpCliStatus(); - try { - const t = await fetchTrace(); - const connected = t.warp === "on" || t.warp === "plus"; - const summary = - `ip=${t.ip} warp=${t.warp} colo=${t.colo} loc=${t.loc} ` + - `http=${t.http} tls=${t.tls} | warp-cli: ${cliStatus}`; - if (connected) { - log("WARP", `CONNECTED via Cloudflare (${reason}) ${summary}`); - } else { - log("WARP", `NOT CONNECTED (${reason}) ${summary}`); - await tryReconnect(); - } - lastWarp = { ...t, connected, checkedAt: ts(), cliStatus }; - } catch (err) { - log("WARP", `check failed (${reason}): ${err.message} | warp-cli: ${cliStatus}`); - lastWarp = { connected: false, error: err.message, checkedAt: ts(), cliStatus }; + const [v4, v6, cliStatus] = await Promise.all([ + traceFamily(TRACE_V4), + traceFamily(TRACE_V6), + warpCliStatus(), + ]); + + // Connected = Cloudflare sees WARP on at least one family (IPv4 is the one + // Discord uses, so it is required; IPv6 is reported when available). + const connected = v4.ok; + const fmt = (r) => (r.ok ? `${r.ip} (warp=${r.warp})` : `n/a (${r.error || `warp=${r.warp}`})`); + const colo = v4.colo || v6.colo || "?"; + const loc = v4.loc || v6.loc || "?"; + const summary = `ipv4=${fmt(v4)} ipv6=${fmt(v6)} colo=${colo} loc=${loc} | warp-cli: ${cliStatus}`; + + if (connected) { + log("WARP", `CONNECTED via Cloudflare (${reason}) ${summary}`); + } else { + log("WARP", `NOT CONNECTED (${reason}) ${summary}`); await tryReconnect(); } + + lastWarp = { connected, v4, v6, colo, loc, cliStatus, checkedAt: ts() }; return lastWarp; } @@ -147,18 +163,14 @@ client.on(Events.InteractionCreate, async (interaction) => { if (interaction.commandName === "warp") { await interaction.deferReply(); const s = await checkWarp("slash-command"); - if (s.connected) { - await interaction.editReply( - `**Cloudflare WARP: connected**\n` + - `IP: \`${s.ip}\`\nwarp: \`${s.warp}\`\ncolo: \`${s.colo}\`\nlocation: \`${s.loc}\`\n` + - `warp-cli: \`${s.cliStatus}\`\nchecked: ${s.checkedAt}` - ); - } else { - await interaction.editReply( - `**Cloudflare WARP: NOT connected**\n${s.error ? `error: \`${s.error}\`\n` : ""}` + - `warp-cli: \`${s.cliStatus}\`\nchecked: ${s.checkedAt}` - ); - } + const line = (label, r) => + r.ok ? `${label}: \`${r.ip}\` (warp=${r.warp})` : `${label}: n/a (${r.error || `warp=${r.warp}`})`; + await interaction.editReply( + `**Cloudflare WARP: ${s.connected ? "connected" : "NOT connected"}**\n` + + `${line("IPv4", s.v4)}\n${line("IPv6", s.v6)}\n` + + `colo: \`${s.colo}\` location: \`${s.loc}\`\n` + + `warp-cli: \`${s.cliStatus}\`\nchecked: ${s.checkedAt}` + ); } });