Log both IPv4 and IPv6 Cloudflare egress IPs in WARP check (1.1.0)

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
2026-09-08 01:20:55 +02:00
parent 1a9dc1d676
commit 60c1c8770e
5 changed files with 72 additions and 46 deletions
+14
View File
@@ -3,6 +3,20 @@
All notable changes to this project are documented here, newest first. 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. 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 ## [1.0.0] - 2026-09-08
### Added ### Added
+4 -4
View File
@@ -26,11 +26,11 @@ generated inside a `node:22-bookworm-slim` container.
`mode warp`, connects and waits until `warp-cli status` says `Connected`. `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 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. 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` 3. `bot/src/index.js` logs into Discord and every `WARP_CHECK_INTERVAL` seconds (default 60) asks
seconds (default 60), logging a line like: 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. 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] WARP tunnel verified: ip=104.28.x.x warp=on colo=AMS
[entrypoint] starting bot: node src/index.js [entrypoint] starting bot: node src/index.js
[bot] logged in as ... in 1 guild(s) [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`) ## Configuration (`.env`)
+2 -2
View File
@@ -1,12 +1,12 @@
{ {
"name": "discordbot-cloudflarewarp", "name": "discordbot-cloudflarewarp",
"version": "1.0.0", "version": "1.1.0",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "discordbot-cloudflarewarp", "name": "discordbot-cloudflarewarp",
"version": "1.0.0", "version": "1.1.0",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"discord.js": "^14.16.3" "discord.js": "^14.16.3"
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "discordbot-cloudflarewarp", "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", "description": "Simple discord.js bot that runs behind a Cloudflare WARP tunnel and logs the Cloudflare IP it uses",
"main": "src/index.js", "main": "src/index.js",
"type": "module", "type": "module",
+44 -32
View File
@@ -14,7 +14,12 @@ const execFileAsync = promisify(execFile);
const TOKEN = process.env.DISCORD_TOKEN; const TOKEN = process.env.DISCORD_TOKEN;
const GUILD_ID = process.env.GUILD_ID || ""; const GUILD_ID = process.env.GUILD_ID || "";
const CHECK_INTERVAL_SEC = Number(process.env.WARP_CHECK_INTERVAL || 60); 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) { if (!TOKEN) {
console.error("[bot] DISCORD_TOKEN is not set. Exiting."); console.error("[bot] DISCORD_TOKEN is not set. Exiting.");
@@ -33,15 +38,7 @@ function log(scope, msg) {
// WARP status helpers // WARP status helpers
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
/** function parseTrace(text) {
* 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();
const data = {}; const data = {};
for (const line of text.trim().split("\n")) { for (const line of text.trim().split("\n")) {
const idx = line.indexOf("="); const idx = line.indexOf("=");
@@ -50,6 +47,22 @@ async function fetchTrace() {
return data; 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. */ /** Local view from the WARP daemon inside this container. */
async function warpCliStatus() { async function warpCliStatus() {
try { try {
@@ -67,25 +80,28 @@ async function warpCliStatus() {
let lastWarp = null; let lastWarp = null;
async function checkWarp(reason = "periodic") { async function checkWarp(reason = "periodic") {
const cliStatus = await warpCliStatus(); const [v4, v6, cliStatus] = await Promise.all([
try { traceFamily(TRACE_V4),
const t = await fetchTrace(); traceFamily(TRACE_V6),
const connected = t.warp === "on" || t.warp === "plus"; warpCliStatus(),
const summary = ]);
`ip=${t.ip} warp=${t.warp} colo=${t.colo} loc=${t.loc} ` +
`http=${t.http} tls=${t.tls} | warp-cli: ${cliStatus}`; // 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) { if (connected) {
log("WARP", `CONNECTED via Cloudflare (${reason}) ${summary}`); log("WARP", `CONNECTED via Cloudflare (${reason}) ${summary}`);
} else { } else {
log("WARP", `NOT CONNECTED (${reason}) ${summary}`); log("WARP", `NOT CONNECTED (${reason}) ${summary}`);
await tryReconnect(); await tryReconnect();
} }
lastWarp = { ...t, connected, checkedAt: ts(), cliStatus };
} catch (err) { lastWarp = { connected, v4, v6, colo, loc, cliStatus, checkedAt: ts() };
log("WARP", `check failed (${reason}): ${err.message} | warp-cli: ${cliStatus}`);
lastWarp = { connected: false, error: err.message, checkedAt: ts(), cliStatus };
await tryReconnect();
}
return lastWarp; return lastWarp;
} }
@@ -147,18 +163,14 @@ client.on(Events.InteractionCreate, async (interaction) => {
if (interaction.commandName === "warp") { if (interaction.commandName === "warp") {
await interaction.deferReply(); await interaction.deferReply();
const s = await checkWarp("slash-command"); const s = await checkWarp("slash-command");
if (s.connected) { const line = (label, r) =>
r.ok ? `${label}: \`${r.ip}\` (warp=${r.warp})` : `${label}: n/a (${r.error || `warp=${r.warp}`})`;
await interaction.editReply( await interaction.editReply(
`**Cloudflare WARP: connected**\n` + `**Cloudflare WARP: ${s.connected ? "connected" : "NOT connected"}**\n` +
`IP: \`${s.ip}\`\nwarp: \`${s.warp}\`\ncolo: \`${s.colo}\`\nlocation: \`${s.loc}\`\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}` `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}`
);
}
} }
}); });