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
+6
View File
@@ -0,0 +1,6 @@
.git
.gitignore
.env
*.md
node_modules
scripts
+11
View File
@@ -0,0 +1,11 @@
# Copy this file to .env and fill in your values. .env is git-ignored.
# Discord bot token (Discord Developer Portal -> Bot -> Token)
DISCORD_TOKEN=your-bot-token-here
# How often (seconds) the bot re-checks the WARP tunnel and logs the Cloudflare IP
WARP_CHECK_INTERVAL=60
# Optional: register slash commands only in this guild for instant availability.
# Leave empty to register them globally (can take up to an hour to appear).
GUILD_ID=
+7
View File
@@ -0,0 +1,7 @@
* text=auto eol=lf
*.sh text eol=lf
*.js text eol=lf
*.json text eol=lf
*.yml text eol=lf
*.md text eol=lf
Dockerfile text eol=lf
+14
View File
@@ -0,0 +1,14 @@
# Secrets
.env
*.env
!.env.example
# Node
node_modules/
npm-debug.log*
# OS / editor
.DS_Store
Thumbs.db
.vscode/
.idea/
+23
View File
@@ -0,0 +1,23 @@
# Changelog
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.0.0] - 2026-09-08
### Added
- discord.js 14 bot (`bot/src/index.js`) with `/ping` and `/warp` slash commands.
- Periodic Cloudflare WARP check that logs the egress IP, `warp=on` flag, colo and `warp-cli status`,
with automatic `warp-cli connect` when the tunnel is down.
- `docker/Dockerfile`: `node:22-bookworm-slim` with the Cloudflare WARP Debian 12 client
(`2026.7.1377.0`) installed from the official download URL; `npm ci` runs inside the build.
- `docker/entrypoint.sh`: starts dbus and `warp-svc`, registers, sets `mode warp`, connects, and only
starts the bot once Cloudflare's trace endpoint reports `warp=on`.
- `docker-compose.yml`: one self-contained service per bot (own WARP tunnel, own registration volume),
`NET_ADMIN`, `/dev/net/tun`, required sysctls, log rotation and a `warp=on` health check.
- `scripts/deploy.sh`: ships the committed tree to the Docker host over SSH and restarts the stack.
- `package-lock.json` generated inside a `node:22-bookworm-slim` container (no Node on the workstation).
- README, `.env.example`, `.gitignore`, `.dockerignore`, `.gitattributes` (LF line endings).
### Removed
- Placeholder `test` file from the initial commit.
+134
View File
@@ -0,0 +1,134 @@
# DiscordBot-CloudflareWarp
A small [discord.js](https://discord.js.org/) bot that runs **entirely inside Docker** and sends all of its
traffic through a **Cloudflare WARP** (Cloudflare One / Zero Trust client) tunnel. Every container runs its
own WARP client, so every bot gets its own VPN tunnel and its own Cloudflare egress IP. The bot proves it is
still on Cloudflare by logging the egress IP and `warp=on` flag on startup, on a timer and on demand.
Nothing runs on the host except Docker: `npm install` happens in the image build, and the lockfile was
generated inside a `node:22-bookworm-slim` container.
## How it works
```
+-------------------------------------------------------------+
| container: discordbot-warp |
| |
| dbus -> warp-svc (Cloudflare WARP daemon, TUN device) |
| ^ |
| entrypoint.sh: register -> mode warp -> connect -> verify |
| | |
| node src/index.js ---- all traffic ----> WARP tunnel ---> Cloudflare ---> Discord
+-------------------------------------------------------------+
```
1. `docker/entrypoint.sh` starts `dbus` and `warp-svc`, creates (or reuses) a WARP registration, sets
`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:
```
[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
```
If the tunnel drops it logs `NOT CONNECTED` and runs `warp-cli connect` to recover.
4. A Docker `HEALTHCHECK` performs the same `warp=on` check, so `docker ps` shows `unhealthy` if the
container ever falls off Cloudflare.
### Slash commands
| Command | What it does |
|---------|-------------------------------------------------------------------------------|
| `/ping` | Replies with round-trip and gateway latency. |
| `/warp` | Runs a live tunnel check and replies with the Cloudflare IP, colo and status. |
## Repository layout
```
bot/
package.json, package-lock.json Node project (discord.js 14)
src/index.js the bot
docker/
Dockerfile node:22-bookworm-slim + cloudflare-warp .deb
entrypoint.sh boots WARP, verifies, then execs the bot
docker-compose.yml one service per bot, each with its own WARP tunnel
scripts/deploy.sh ships the committed tree to the Docker host and restarts the stack
.env.example configuration template
CHANGELOG.md every change, newest first
```
## Requirements
* A Docker host (tested on Debian 12 with Docker 20.10 and Compose v5) with `/dev/net/tun` available.
* Containers need `NET_ADMIN` and the TUN device. Both are set in `docker-compose.yml`.
* A Discord bot token from the [Developer Portal](https://discord.com/developers/applications).
The Cloudflare WARP client is downloaded during the image build from
`https://downloads.cloudflareclient.com/v1/download/bookworm-intel/version/<WARP_VERSION>`
(`WARP_VERSION` defaults to `2026.7.1377.0`, the Debian 12 build).
## Quick start (on the Docker host)
```bash
git clone https://gitea.ikbengino.nl/ginoblij/DiscordBot-CloudflareWarp.git
cd DiscordBot-CloudflareWarp
cp .env.example .env # put your DISCORD_TOKEN in .env
docker compose up -d --build
docker compose logs -f
```
You should see the entrypoint report the tunnel, then the bot log in:
```
[entrypoint] warp-svc is up
[entrypoint] no registration found, creating a new one
[entrypoint] mode set to warp
[entrypoint] warp-cli status: Status update: Connected
[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 ...
```
## Configuration (`.env`)
| Variable | Default | Meaning |
|-----------------------|---------|----------------------------------------------------------------------|
| `DISCORD_TOKEN` | | Bot token. Required. |
| `WARP_CHECK_INTERVAL` | `60` | Seconds between tunnel checks logged by the bot. |
| `GUILD_ID` | | Register slash commands in one guild (instant) instead of globally. |
| `WARP_MODE` | `warp` | `warp-cli mode` to use (`warp`, `doh`, `warp+doh`, ...). |
| `WARP_CONNECT_TIMEOUT`| `60` | Seconds the entrypoint waits for `Connected` before giving up. |
## Running more than one bot (one VPN per container)
Each service in `docker-compose.yml` is an independent container with its own `warp-svc`, its own
registration volume and therefore its own tunnel and egress IP. To add a bot:
1. Create `.env.bot2` with the second token.
2. Uncomment the `bot2` service and the `warp-data-bot2` volume in `docker-compose.yml`.
3. `docker compose up -d --build`.
## Deploying from this repo
`scripts/deploy.sh` sends the **committed** tree (`git archive HEAD`) to the host over SSH, rebuilds the
image there and restarts the stack. The `.env` on the host is never touched or overwritten.
```bash
scripts/deploy.sh # defaults to root@192.168.5.67:/opt/discordbot-cloudflarewarp
scripts/deploy.sh user@host /some/dir
```
## Checking the tunnel by hand
```bash
docker compose exec bot warp-cli --accept-tos status
docker compose exec bot curl -s https://www.cloudflare.com/cdn-cgi/trace
docker compose logs -f | grep WARP
```
## Change history
See [CHANGELOG.md](CHANGELOG.md). Every change is committed and pushed with a changelog entry.
+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);
});
+46
View File
@@ -0,0 +1,46 @@
# Every service defined here is a fully self-contained bot: it runs its own
# Cloudflare WARP client inside the container, so each bot gets its own VPN
# tunnel and its own Cloudflare egress IP. To run a second bot, copy the
# `bot` service block, give it a different name, env file and volume.
x-warp-bot: &warp-bot
build:
context: .
dockerfile: docker/Dockerfile
args:
WARP_VERSION: "2026.7.1377.0"
image: discordbot-cloudflarewarp:latest
restart: unless-stopped
# WARP needs to create a TUN device and manage routes inside the container.
cap_add:
- NET_ADMIN
devices:
- /dev/net/tun:/dev/net/tun
sysctls:
net.ipv6.conf.all.disable_ipv6: 0
net.ipv4.conf.all.src_valid_mark: 1
logging:
driver: json-file
options:
max-size: "10m"
max-file: "3"
services:
bot:
<<: *warp-bot
container_name: discordbot-warp
env_file: .env
volumes:
- warp-data-bot:/var/lib/cloudflare-warp
# Example second bot with its own WARP tunnel and its own token:
# bot2:
# <<: *warp-bot
# container_name: discordbot-warp-2
# env_file: .env.bot2
# volumes:
# - warp-data-bot2:/var/lib/cloudflare-warp
volumes:
warp-data-bot:
# warp-data-bot2:
+45
View File
@@ -0,0 +1,45 @@
# ---------------------------------------------------------------------------
# Stage 1: install Node dependencies (npm runs only inside Docker)
# ---------------------------------------------------------------------------
FROM node:22-bookworm-slim AS deps
WORKDIR /app
COPY bot/package.json bot/package-lock.json ./
RUN npm ci --omit=dev
# ---------------------------------------------------------------------------
# Stage 2: runtime image = Node 22 on Debian 12 (bookworm) + Cloudflare WARP
# ---------------------------------------------------------------------------
FROM node:22-bookworm-slim
ARG WARP_VERSION=2026.7.1377.0
ARG WARP_DEB_URL=https://downloads.cloudflareclient.com/v1/download/bookworm-intel/version/${WARP_VERSION}
ENV DEBIAN_FRONTEND=noninteractive \
NODE_ENV=production
# dbus + iproute2 are required by warp-svc; curl for health checks.
RUN apt-get update \
&& apt-get install -y --no-install-recommends \
ca-certificates curl gnupg dbus iproute2 procps \
&& curl -fsSL "${WARP_DEB_URL}" -o /tmp/cloudflare-warp.deb \
&& apt-get install -y --no-install-recommends /tmp/cloudflare-warp.deb \
&& rm -f /tmp/cloudflare-warp.deb \
&& apt-get clean \
&& rm -rf /var/lib/apt/lists/* \
&& warp-cli --version
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY bot/package.json ./package.json
COPY bot/src ./src
COPY docker/entrypoint.sh /usr/local/bin/entrypoint.sh
RUN chmod +x /usr/local/bin/entrypoint.sh
# WARP registration/state lives here; mount a volume per container to keep it.
VOLUME ["/var/lib/cloudflare-warp"]
HEALTHCHECK --interval=60s --timeout=15s --start-period=90s --retries=3 \
CMD curl -fsS --max-time 10 https://www.cloudflare.com/cdn-cgi/trace | grep -Eq '^warp=(on|plus)$' || exit 1
ENTRYPOINT ["/usr/local/bin/entrypoint.sh"]
CMD ["node", "src/index.js"]
+77
View File
@@ -0,0 +1,77 @@
#!/bin/bash
# Boots Cloudflare WARP inside this container, verifies the tunnel is up and
# that Cloudflare sees us as a WARP client, then hands over to the bot.
set -euo pipefail
log() { echo "[$(date -u +%Y-%m-%dT%H:%M:%SZ)] [entrypoint] $*"; }
WARP_MODE="${WARP_MODE:-warp}"
WARP_CONNECT_TIMEOUT="${WARP_CONNECT_TIMEOUT:-60}"
TRACE_URL="https://www.cloudflare.com/cdn-cgi/trace"
cleanup() {
log "shutting down"
warp-cli --accept-tos disconnect >/dev/null 2>&1 || true
kill "${WARP_SVC_PID:-}" 2>/dev/null || true
}
trap cleanup EXIT
# --- 1. system dbus (warp-svc talks to warp-cli over it) --------------------
mkdir -p /run/dbus
rm -f /run/dbus/pid
if ! pgrep -x dbus-daemon >/dev/null; then
dbus-daemon --system --fork
log "dbus started"
fi
# --- 2. WARP daemon ---------------------------------------------------------
log "starting warp-svc ($(warp-cli --version | head -n1))"
warp-svc >/var/log/warp-svc.log 2>&1 &
WARP_SVC_PID=$!
for i in $(seq 1 30); do
if warp-cli --accept-tos status >/dev/null 2>&1; then break; fi
if ! kill -0 "$WARP_SVC_PID" 2>/dev/null; then
log "warp-svc exited early:"; cat /var/log/warp-svc.log; exit 1
fi
sleep 1
done
log "warp-svc is up"
# --- 3. registration (persisted in /var/lib/cloudflare-warp) ---------------
if warp-cli --accept-tos registration show >/dev/null 2>&1; then
log "existing WARP registration found, reusing it"
else
log "no registration found, creating a new one"
warp-cli --accept-tos registration new
fi
warp-cli --accept-tos mode "$WARP_MODE" >/dev/null
log "mode set to $WARP_MODE"
# --- 4. connect and wait ----------------------------------------------------
warp-cli --accept-tos connect >/dev/null
for i in $(seq 1 "$WARP_CONNECT_TIMEOUT"); do
if warp-cli --accept-tos status 2>/dev/null | grep -q "Connected"; then break; fi
sleep 1
done
STATUS="$(warp-cli --accept-tos status 2>/dev/null | tr -s '[:space:]' ' ')"
log "warp-cli status: $STATUS"
# --- 5. prove it: Cloudflare must report warp=on and give us the egress IP --
TRACE="$(curl -fsS --max-time 15 "$TRACE_URL" || true)"
WARP_FLAG="$(echo "$TRACE" | sed -n 's/^warp=//p')"
EGRESS_IP="$(echo "$TRACE" | sed -n 's/^ip=//p')"
COLO="$(echo "$TRACE" | sed -n 's/^colo=//p')"
if [[ "$WARP_FLAG" == "on" || "$WARP_FLAG" == "plus" ]]; then
log "WARP tunnel verified: ip=$EGRESS_IP warp=$WARP_FLAG colo=$COLO"
else
log "ERROR: Cloudflare does not see this container as a WARP client (warp='${WARP_FLAG:-none}', ip='${EGRESS_IP:-none}')"
log "warp-svc log tail:"; tail -n 30 /var/log/warp-svc.log || true
exit 1
fi
# --- 6. run the bot ---------------------------------------------------------
log "starting bot: $*"
exec "$@"
+28
View File
@@ -0,0 +1,28 @@
#!/usr/bin/env bash
# Deploys the committed tree (git archive HEAD) to the Docker host and
# rebuilds/restarts the stack there. Run from the repo root:
# scripts/deploy.sh [user@host] [remote-dir]
set -euo pipefail
HOST="${1:-root@192.168.5.67}"
REMOTE_DIR="${2:-/opt/discordbot-cloudflarewarp}"
cd "$(dirname "$0")/.."
echo ">> shipping $(git rev-parse --short HEAD) to $HOST:$REMOTE_DIR"
ssh "$HOST" "mkdir -p '$REMOTE_DIR'"
git archive --format=tar HEAD | ssh "$HOST" "tar -x -C '$REMOTE_DIR'"
ssh "$HOST" bash -s <<REMOTE
set -euo pipefail
cd '$REMOTE_DIR'
if [ ! -f .env ]; then
echo "!! $REMOTE_DIR/.env is missing on the host. Create it from .env.example first." >&2
exit 1
fi
docker compose build --pull
docker compose up -d --remove-orphans
docker compose ps
REMOTE
echo ">> done. follow logs with: ssh $HOST 'cd $REMOTE_DIR && docker compose logs -f'"
-1
View File
@@ -1 +0,0 @@
test