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:
@@ -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
|
||||||
|
|||||||
@@ -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`)
|
||||||
|
|||||||
Generated
+2
-2
@@ -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
@@ -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",
|
||||||
|
|||||||
+51
-39
@@ -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
|
||||||
if (connected) {
|
// Discord uses, so it is required; IPv6 is reported when available).
|
||||||
log("WARP", `CONNECTED via Cloudflare (${reason}) ${summary}`);
|
const connected = v4.ok;
|
||||||
} else {
|
const fmt = (r) => (r.ok ? `${r.ip} (warp=${r.warp})` : `n/a (${r.error || `warp=${r.warp}`})`);
|
||||||
log("WARP", `NOT CONNECTED (${reason}) ${summary}`);
|
const colo = v4.colo || v6.colo || "?";
|
||||||
await tryReconnect();
|
const loc = v4.loc || v6.loc || "?";
|
||||||
}
|
const summary = `ipv4=${fmt(v4)} ipv6=${fmt(v6)} colo=${colo} loc=${loc} | warp-cli: ${cliStatus}`;
|
||||||
lastWarp = { ...t, connected, checkedAt: ts(), cliStatus };
|
|
||||||
} catch (err) {
|
if (connected) {
|
||||||
log("WARP", `check failed (${reason}): ${err.message} | warp-cli: ${cliStatus}`);
|
log("WARP", `CONNECTED via Cloudflare (${reason}) ${summary}`);
|
||||||
lastWarp = { connected: false, error: err.message, checkedAt: ts(), cliStatus };
|
} else {
|
||||||
|
log("WARP", `NOT CONNECTED (${reason}) ${summary}`);
|
||||||
await tryReconnect();
|
await tryReconnect();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
lastWarp = { connected, v4, v6, colo, loc, cliStatus, checkedAt: ts() };
|
||||||
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) =>
|
||||||
await interaction.editReply(
|
r.ok ? `${label}: \`${r.ip}\` (warp=${r.warp})` : `${label}: n/a (${r.error || `warp=${r.warp}`})`;
|
||||||
`**Cloudflare WARP: connected**\n` +
|
await interaction.editReply(
|
||||||
`IP: \`${s.ip}\`\nwarp: \`${s.warp}\`\ncolo: \`${s.colo}\`\nlocation: \`${s.loc}\`\n` +
|
`**Cloudflare WARP: ${s.connected ? "connected" : "NOT connected"}**\n` +
|
||||||
`warp-cli: \`${s.cliStatus}\`\nchecked: ${s.checkedAt}`
|
`${line("IPv4", s.v4)}\n${line("IPv6", s.v6)}\n` +
|
||||||
);
|
`colo: \`${s.colo}\` location: \`${s.loc}\`\n` +
|
||||||
} else {
|
`warp-cli: \`${s.cliStatus}\`\nchecked: ${s.checkedAt}`
|
||||||
await interaction.editReply(
|
);
|
||||||
`**Cloudflare WARP: NOT connected**\n${s.error ? `error: \`${s.error}\`\n` : ""}` +
|
|
||||||
`warp-cli: \`${s.cliStatus}\`\nchecked: ${s.checkedAt}`
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user