Initial Discord bot in Docker behind a per-container Cloudflare WARP tunnel

- discord.js 14 bot with /ping and /warp, periodic WARP egress-IP logging
- Dockerfile: node:22-bookworm-slim + cloudflare-warp 2026.7.1377.0 (.deb)
- entrypoint boots dbus/warp-svc, registers, connects, verifies warp=on
- docker-compose with NET_ADMIN, /dev/net/tun, sysctls, healthcheck
- deploy script, README, CHANGELOG, lockfile generated inside Docker

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
2026-09-08 01:16:32 +02:00
parent bcf7b67ef7
commit 1a9dc1d676
14 changed files with 904 additions and 1 deletions
+315
View File
@@ -0,0 +1,315 @@
{
"name": "discordbot-cloudflarewarp",
"version": "1.0.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "discordbot-cloudflarewarp",
"version": "1.0.0",
"license": "MIT",
"dependencies": {
"discord.js": "^14.16.3"
},
"engines": {
"node": ">=22"
}
},
"node_modules/@discordjs/builders": {
"version": "1.14.1",
"resolved": "https://registry.npmjs.org/@discordjs/builders/-/builders-1.14.1.tgz",
"integrity": "sha512-gSKkhXLqs96TCzk66VZuHHl8z2bQMJFGwrXC0f33ngK+FLNau4hU1PYny3DNJfNdSH+gVMzE85/d5FQ2BpcNwQ==",
"license": "Apache-2.0",
"dependencies": {
"@discordjs/formatters": "^0.6.2",
"@discordjs/util": "^1.2.0",
"@sapphire/shapeshift": "^4.0.0",
"discord-api-types": "^0.38.40",
"fast-deep-equal": "^3.1.3",
"ts-mixer": "^6.0.4",
"tslib": "^2.6.3"
},
"engines": {
"node": ">=16.11.0"
},
"funding": {
"url": "https://github.com/discordjs/discord.js?sponsor"
}
},
"node_modules/@discordjs/collection": {
"version": "1.5.3",
"resolved": "https://registry.npmjs.org/@discordjs/collection/-/collection-1.5.3.tgz",
"integrity": "sha512-SVb428OMd3WO1paV3rm6tSjM4wC+Kecaa1EUGX7vc6/fddvw/6lg90z4QtCqm21zvVe92vMMDt9+DkIvjXImQQ==",
"license": "Apache-2.0",
"engines": {
"node": ">=16.11.0"
}
},
"node_modules/@discordjs/formatters": {
"version": "0.6.2",
"resolved": "https://registry.npmjs.org/@discordjs/formatters/-/formatters-0.6.2.tgz",
"integrity": "sha512-y4UPwWhH6vChKRkGdMB4odasUbHOUwy7KL+OVwF86PvT6QVOwElx+TiI1/6kcmcEe+g5YRXJFiXSXUdabqZOvQ==",
"license": "Apache-2.0",
"dependencies": {
"discord-api-types": "^0.38.33"
},
"engines": {
"node": ">=16.11.0"
},
"funding": {
"url": "https://github.com/discordjs/discord.js?sponsor"
}
},
"node_modules/@discordjs/rest": {
"version": "2.6.3",
"resolved": "https://registry.npmjs.org/@discordjs/rest/-/rest-2.6.3.tgz",
"integrity": "sha512-wvOylxNYJkwKjctS/Mn5GP1w9r3/rzyH+ThD1JlAca6zEdlHs8QWBBUQJpU5Q+W6DoIj/Ljh1IPlZs7hTU+UAg==",
"license": "Apache-2.0",
"dependencies": {
"@discordjs/collection": "^2.1.1",
"@discordjs/util": "^1.2.0",
"@sapphire/async-queue": "^1.5.3",
"@sapphire/snowflake": "^3.5.5",
"@vladfrangu/async_event_emitter": "^2.4.6",
"discord-api-types": "^0.38.50",
"magic-bytes.js": "^1.13.0",
"tslib": "^2.6.3",
"undici": "^6.27.0"
},
"engines": {
"node": ">=18"
},
"funding": {
"url": "https://github.com/discordjs/discord.js?sponsor"
}
},
"node_modules/@discordjs/rest/node_modules/@discordjs/collection": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/@discordjs/collection/-/collection-2.1.1.tgz",
"integrity": "sha512-LiSusze9Tc7qF03sLCujF5iZp7K+vRNEDBZ86FT9aQAv3vxMLihUvKvpsCWiQ2DJq1tVckopKm1rxomgNUc9hg==",
"license": "Apache-2.0",
"engines": {
"node": ">=18"
},
"funding": {
"url": "https://github.com/discordjs/discord.js?sponsor"
}
},
"node_modules/@discordjs/util": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/@discordjs/util/-/util-1.2.0.tgz",
"integrity": "sha512-3LKP7F2+atl9vJFhaBjn4nOaSWahZ/yWjOvA4e5pnXkt2qyXRCHLxoBQy81GFtLGCq7K9lPm9R517M1U+/90Qg==",
"license": "Apache-2.0",
"dependencies": {
"discord-api-types": "^0.38.33"
},
"engines": {
"node": ">=18"
},
"funding": {
"url": "https://github.com/discordjs/discord.js?sponsor"
}
},
"node_modules/@discordjs/ws": {
"version": "1.2.3",
"resolved": "https://registry.npmjs.org/@discordjs/ws/-/ws-1.2.3.tgz",
"integrity": "sha512-wPlQDxEmlDg5IxhJPuxXr3Vy9AjYq5xCvFWGJyD7w7Np8ZGu+Mc+97LCoEc/+AYCo2IDpKioiH0/c/mj5ZR9Uw==",
"license": "Apache-2.0",
"dependencies": {
"@discordjs/collection": "^2.1.0",
"@discordjs/rest": "^2.5.1",
"@discordjs/util": "^1.1.0",
"@sapphire/async-queue": "^1.5.2",
"@types/ws": "^8.5.10",
"@vladfrangu/async_event_emitter": "^2.2.4",
"discord-api-types": "^0.38.1",
"tslib": "^2.6.2",
"ws": "^8.17.0"
},
"engines": {
"node": ">=16.11.0"
},
"funding": {
"url": "https://github.com/discordjs/discord.js?sponsor"
}
},
"node_modules/@discordjs/ws/node_modules/@discordjs/collection": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/@discordjs/collection/-/collection-2.1.1.tgz",
"integrity": "sha512-LiSusze9Tc7qF03sLCujF5iZp7K+vRNEDBZ86FT9aQAv3vxMLihUvKvpsCWiQ2DJq1tVckopKm1rxomgNUc9hg==",
"license": "Apache-2.0",
"engines": {
"node": ">=18"
},
"funding": {
"url": "https://github.com/discordjs/discord.js?sponsor"
}
},
"node_modules/@sapphire/async-queue": {
"version": "1.5.5",
"resolved": "https://registry.npmjs.org/@sapphire/async-queue/-/async-queue-1.5.5.tgz",
"integrity": "sha512-cvGzxbba6sav2zZkH8GPf2oGk9yYoD5qrNWdu9fRehifgnFZJMV+nuy2nON2roRO4yQQ+v7MK/Pktl/HgfsUXg==",
"license": "MIT",
"engines": {
"node": ">=v14.0.0",
"npm": ">=7.0.0"
}
},
"node_modules/@sapphire/shapeshift": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/@sapphire/shapeshift/-/shapeshift-4.0.0.tgz",
"integrity": "sha512-d9dUmWVA7MMiKobL3VpLF8P2aeanRTu6ypG2OIaEv/ZHH/SUQ2iHOVyi5wAPjQ+HmnMuL0whK9ez8I/raWbtIg==",
"license": "MIT",
"dependencies": {
"fast-deep-equal": "^3.1.3",
"lodash": "^4.17.21"
},
"engines": {
"node": ">=v16"
}
},
"node_modules/@sapphire/snowflake": {
"version": "3.5.5",
"resolved": "https://registry.npmjs.org/@sapphire/snowflake/-/snowflake-3.5.5.tgz",
"integrity": "sha512-xzvBr1Q1c4lCe7i6sRnrofxeO1QTP/LKQ6A6qy0iB4x5yfiSfARMEQEghojzTNALDTcv8En04qYNIco9/K9eZQ==",
"license": "MIT",
"engines": {
"node": ">=v14.0.0",
"npm": ">=7.0.0"
}
},
"node_modules/@types/node": {
"version": "26.5.0",
"resolved": "https://registry.npmjs.org/@types/node/-/node-26.5.0.tgz",
"integrity": "sha512-dVSGpriSoCgz8WnDNTuSSuSv1PC/ALXihO4ulRZt7Md8k9mlbdin3lGOcDE8SnWOgf513ByWlXd7BK4azmyg/A==",
"license": "MIT",
"dependencies": {
"undici-types": "~8.9.0"
}
},
"node_modules/@types/ws": {
"version": "8.18.1",
"resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz",
"integrity": "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==",
"license": "MIT",
"dependencies": {
"@types/node": "*"
}
},
"node_modules/@vladfrangu/async_event_emitter": {
"version": "2.4.7",
"resolved": "https://registry.npmjs.org/@vladfrangu/async_event_emitter/-/async_event_emitter-2.4.7.tgz",
"integrity": "sha512-Xfe6rpCTxSxfbswi/W/Pz7zp1WWSNn4A0eW4mLkQUewCrXXtMj31lCg+iQyTkh/CkusZSq9eDflu7tjEDXUY6g==",
"license": "MIT",
"engines": {
"node": ">=v14.0.0",
"npm": ">=7.0.0"
}
},
"node_modules/discord-api-types": {
"version": "0.38.55",
"resolved": "https://registry.npmjs.org/discord-api-types/-/discord-api-types-0.38.55.tgz",
"integrity": "sha512-ytuaRTzdnHUCXJ6KjL9MrItQX0xKncBKEeYI1Bst4+ud47eejH3cG6gaesYakjpPcUhh68XYS2YwGq3mmujFsA==",
"license": "MIT"
},
"node_modules/discord.js": {
"version": "14.27.0",
"resolved": "https://registry.npmjs.org/discord.js/-/discord.js-14.27.0.tgz",
"integrity": "sha512-qHbFlFG2N7y3LjPySYsL6A1+BnX6bkTVgo842EX0CqVPk/KTMwZkojPHEXKsQUpWZNyz5BISNHK1cPpQw0+m4A==",
"license": "Apache-2.0",
"dependencies": {
"@discordjs/builders": "^1.14.1",
"@discordjs/collection": "1.5.3",
"@discordjs/formatters": "^0.6.2",
"@discordjs/rest": "^2.6.2",
"@discordjs/util": "^1.2.0",
"@discordjs/ws": "^1.2.3",
"@sapphire/snowflake": "3.5.5",
"discord-api-types": "^0.38.49",
"fast-deep-equal": "3.1.3",
"lodash.snakecase": "4.1.1",
"magic-bytes.js": "^1.13.0",
"tslib": "^2.6.3",
"undici": "^6.27.0"
},
"engines": {
"node": ">=18"
},
"funding": {
"url": "https://github.com/discordjs/discord.js?sponsor"
}
},
"node_modules/fast-deep-equal": {
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
"integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==",
"license": "MIT"
},
"node_modules/lodash": {
"version": "4.18.1",
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz",
"integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==",
"license": "MIT"
},
"node_modules/lodash.snakecase": {
"version": "4.1.1",
"resolved": "https://registry.npmjs.org/lodash.snakecase/-/lodash.snakecase-4.1.1.tgz",
"integrity": "sha512-QZ1d4xoBHYUeuouhEq3lk3Uq7ldgyFXGBhg04+oRLnIz8o9T65Eh+8YdroUwn846zchkA9yDsDl5CVVaV2nqYw==",
"license": "MIT"
},
"node_modules/magic-bytes.js": {
"version": "1.13.1",
"resolved": "https://registry.npmjs.org/magic-bytes.js/-/magic-bytes.js-1.13.1.tgz",
"integrity": "sha512-x5sn4UX2k5gCWlcfmoFwG4TPie8+dctESyqOBdhB5p6MsgWXdBKGmt9nXPObj/JI50TTL928lc5Yt1WntMn1bw==",
"license": "MIT"
},
"node_modules/ts-mixer": {
"version": "6.0.4",
"resolved": "https://registry.npmjs.org/ts-mixer/-/ts-mixer-6.0.4.tgz",
"integrity": "sha512-ufKpbmrugz5Aou4wcr5Wc1UUFWOLhq+Fm6qa6P0w0K5Qw2yhaUoiWszhCVuNQyNwrlGiscHOmqYoAox1PtvgjA==",
"license": "MIT"
},
"node_modules/tslib": {
"version": "2.8.1",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
"license": "0BSD"
},
"node_modules/undici": {
"version": "6.28.1",
"resolved": "https://registry.npmjs.org/undici/-/undici-6.28.1.tgz",
"integrity": "sha512-zWpdTVD54H48CIybL0rWQ3ukpb9d23wM7eH5RtfdmeP70cWHNjtfo7P4vZX+5CoDcO53J4Pu5uXp7lNfjc6DRA==",
"license": "MIT",
"engines": {
"node": ">=18.17"
}
},
"node_modules/undici-types": {
"version": "8.9.0",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-8.9.0.tgz",
"integrity": "sha512-KTDyRTYX8sWmKXAikPHHSyc63CRPETMctyjKFupcC6OBLXT3xsN0e9aF7m+mIXutFWpUXuedtowG7iLOzp0kQg==",
"license": "MIT"
},
"node_modules/ws": {
"version": "8.21.3",
"resolved": "https://registry.npmjs.org/ws/-/ws-8.21.3.tgz",
"integrity": "sha512-201TZ/kPWxoPr/OKWjquZR1SWKXcvxdH+e1xrx89b3YbmzLMFCLfnaG1HFIgWzJOEWZ7MvpK++odZufgYR50Rw==",
"license": "MIT",
"engines": {
"node": ">=10.0.0"
},
"peerDependencies": {
"bufferutil": "^4.0.1",
"utf-8-validate": ">=5.0.2"
},
"peerDependenciesMeta": {
"bufferutil": {
"optional": true
},
"utf-8-validate": {
"optional": true
}
}
}
}
}
+18
View File
@@ -0,0 +1,18 @@
{
"name": "discordbot-cloudflarewarp",
"version": "1.0.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",
"private": true,
"engines": {
"node": ">=22"
},
"scripts": {
"start": "node src/index.js"
},
"dependencies": {
"discord.js": "^14.16.3"
},
"license": "MIT"
}
+180
View File
@@ -0,0 +1,180 @@
import { execFile } from "node:child_process";
import { promisify } from "node:util";
import {
Client,
Events,
GatewayIntentBits,
REST,
Routes,
SlashCommandBuilder,
} from "discord.js";
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";
if (!TOKEN) {
console.error("[bot] DISCORD_TOKEN is not set. Exiting.");
process.exit(1);
}
function ts() {
return new Date().toISOString();
}
function log(scope, msg) {
console.log(`[${ts()}] [${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();
const data = {};
for (const line of text.trim().split("\n")) {
const idx = line.indexOf("=");
if (idx > 0) data[line.slice(0, idx)] = line.slice(idx + 1);
}
return data;
}
/** Local view from the WARP daemon inside this container. */
async function warpCliStatus() {
try {
const { stdout } = await execFileAsync(
"warp-cli",
["--accept-tos", "status"],
{ timeout: 10_000 }
);
return stdout.trim().replace(/\s+/g, " ");
} catch (err) {
return `unavailable (${err.message})`;
}
}
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 };
await tryReconnect();
}
return lastWarp;
}
async function tryReconnect() {
try {
log("WARP", "attempting warp-cli connect");
await execFileAsync("warp-cli", ["--accept-tos", "connect"], { timeout: 15_000 });
} catch (err) {
log("WARP", `reconnect attempt failed: ${err.message}`);
}
}
// ---------------------------------------------------------------------------
// Discord client
// ---------------------------------------------------------------------------
const commands = [
new SlashCommandBuilder().setName("ping").setDescription("Replies with pong and latency"),
new SlashCommandBuilder()
.setName("warp")
.setDescription("Show the Cloudflare WARP tunnel status and egress IP of this bot"),
].map((c) => c.toJSON());
async function registerCommands(appId) {
const rest = new REST({ version: "10" }).setToken(TOKEN);
if (GUILD_ID) {
await rest.put(Routes.applicationGuildCommands(appId, GUILD_ID), { body: commands });
log("bot", `registered ${commands.length} guild slash commands in ${GUILD_ID}`);
} else {
await rest.put(Routes.applicationCommands(appId), { body: commands });
log("bot", `registered ${commands.length} global slash commands`);
}
}
const client = new Client({ intents: [GatewayIntentBits.Guilds] });
client.once(Events.ClientReady, async (c) => {
log("bot", `logged in as ${c.user.tag} (id ${c.user.id}) in ${c.guilds.cache.size} guild(s)`);
try {
await registerCommands(c.user.id);
} catch (err) {
log("bot", `failed to register slash commands: ${err.message}`);
}
await checkWarp("startup");
setInterval(() => checkWarp("periodic"), CHECK_INTERVAL_SEC * 1000);
log("bot", `WARP check scheduled every ${CHECK_INTERVAL_SEC}s`);
});
client.on(Events.InteractionCreate, async (interaction) => {
if (!interaction.isChatInputCommand()) return;
if (interaction.commandName === "ping") {
const sent = await interaction.reply({ content: "Pong!", fetchReply: true });
const rtt = sent.createdTimestamp - interaction.createdTimestamp;
await interaction.editReply(`Pong! Round-trip ${rtt}ms, gateway ${Math.round(client.ws.ping)}ms`);
return;
}
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}`
);
}
}
});
client.on(Events.Error, (err) => log("bot", `client error: ${err.message}`));
client.on(Events.Warn, (msg) => log("bot", `warn: ${msg}`));
for (const sig of ["SIGINT", "SIGTERM"]) {
process.on(sig, () => {
log("bot", `${sig} received, shutting down`);
client.destroy();
process.exit(0);
});
}
log("bot", "starting, connecting to Discord gateway through WARP tunnel");
client.login(TOKEN).catch((err) => {
log("bot", `login failed: ${err.message}`);
process.exit(1);
});