diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..d5c62ce --- /dev/null +++ b/.dockerignore @@ -0,0 +1,6 @@ +.git +.gitignore +.env +*.md +node_modules +scripts diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..d25eb08 --- /dev/null +++ b/.env.example @@ -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= diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..7266979 --- /dev/null +++ b/.gitattributes @@ -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 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..d18d90c --- /dev/null +++ b/.gitignore @@ -0,0 +1,14 @@ +# Secrets +.env +*.env +!.env.example + +# Node +node_modules/ +npm-debug.log* + +# OS / editor +.DS_Store +Thumbs.db +.vscode/ +.idea/ diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..7332a1e --- /dev/null +++ b/CHANGELOG.md @@ -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. diff --git a/README.md b/README.md new file mode 100644 index 0000000..cfb164f --- /dev/null +++ b/README.md @@ -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` 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. diff --git a/bot/package-lock.json b/bot/package-lock.json new file mode 100644 index 0000000..b6e8f2f --- /dev/null +++ b/bot/package-lock.json @@ -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 + } + } + } + } +} diff --git a/bot/package.json b/bot/package.json new file mode 100644 index 0000000..598a3c1 --- /dev/null +++ b/bot/package.json @@ -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" +} diff --git a/bot/src/index.js b/bot/src/index.js new file mode 100644 index 0000000..2e1c07f --- /dev/null +++ b/bot/src/index.js @@ -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); +}); diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..ac1e25d --- /dev/null +++ b/docker-compose.yml @@ -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: diff --git a/docker/Dockerfile b/docker/Dockerfile new file mode 100644 index 0000000..54917e0 --- /dev/null +++ b/docker/Dockerfile @@ -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"] diff --git a/docker/entrypoint.sh b/docker/entrypoint.sh new file mode 100644 index 0000000..023b17a --- /dev/null +++ b/docker/entrypoint.sh @@ -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 "$@" diff --git a/scripts/deploy.sh b/scripts/deploy.sh new file mode 100644 index 0000000..b4a9b2e --- /dev/null +++ b/scripts/deploy.sh @@ -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 <&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'" diff --git a/test b/test deleted file mode 100644 index 30d74d2..0000000 --- a/test +++ /dev/null @@ -1 +0,0 @@ -test \ No newline at end of file