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:
@@ -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);
|
||||
});
|
||||
Reference in New Issue
Block a user