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,6 @@
|
|||||||
|
.git
|
||||||
|
.gitignore
|
||||||
|
.env
|
||||||
|
*.md
|
||||||
|
node_modules
|
||||||
|
scripts
|
||||||
@@ -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=
|
||||||
@@ -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
@@ -0,0 +1,14 @@
|
|||||||
|
# Secrets
|
||||||
|
.env
|
||||||
|
*.env
|
||||||
|
!.env.example
|
||||||
|
|
||||||
|
# Node
|
||||||
|
node_modules/
|
||||||
|
npm-debug.log*
|
||||||
|
|
||||||
|
# OS / editor
|
||||||
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
|
.vscode/
|
||||||
|
.idea/
|
||||||
@@ -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.
|
||||||
@@ -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.
|
||||||
Generated
+315
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
@@ -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:
|
||||||
@@ -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"]
|
||||||
@@ -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 "$@"
|
||||||
@@ -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'"
|
||||||
Reference in New Issue
Block a user