This cog makes and removes short URLs with the Caddy admin API. Caddy does all the redirects. The cog does not need a database or a web app.
- Caddy serves the short URL domain with a
maphandler. The map handler changes a request path to a redirect target. - The cog sends requests to the Caddy admin API. The requests change the
mappingsarray in the map handler. - The cog also keeps its own copy of each short URL in the RedBot config. The cog puts these copies back into Caddy at start-up.
- Caddy saves the running config to
autosave.json. The--resumeoption loads this file after a restart.
Discord Caddy admin API Caddy (go.example.com)
│ │ │
├─ [p]shorten ─────────► write /config/.../mappings│
│ ├── add redirect rule ─────►
│ ◄──── 200 OK ──────────────┤
│◄── go.example.com/abc ─┤ │
│ │ │
│ A user opens go.example.com/abc ──────────────►
│ │ ─── 302 → target ────┤
⚠️ The Caddy admin API has no authentication. Any client that reaches the port controls Caddy fully. It can replace the full config. Therefore, isolate the admin API. Give access only to RedBot. Do not put it on a network that other apps use. Do not map it to a host port.
Step 1. Make a private admin network. Only Caddy and RedBot use this network.
docker network create --subnet 172.30.0.0/24 caddy-adminStep 2. Attach Caddy to the network with a fixed IP. Keep the data. Turn on
--resume. Edit the caddy service in docker-compose.yml.
services:
caddy:
command: caddy run --config /etc/caddy/Caddyfile --adapter caddyfile --resume
volumes:
- /opt/caddy_data:/data # certificates
- /opt/caddy_config:/config # autosave.json (necessary for --resume)
# ...your Caddyfile and other mounts...
networks:
your-proxy-net: # the network that serves your apps
caddy-admin:
ipv4_address: 172.30.0.2
networks:
your-proxy-net:
external: true
caddy-admin:
external: trueKeep both volumes. The /data volume holds the certificates. The /config
volume holds autosave.json. Without the /config volume, --resume has no
file to load after you rebuild the container.
Step 3. Bind the admin API to the fixed IP. Add this global block to the
Caddyfile.
{
admin 172.30.0.2:2019
}
Use the private IP. Do not use :2019 or 0.0.0.0. These addresses listen on
all interfaces, which includes the network that your apps use.
Step 4. Add the shortener block to the Caddyfile.
go.example.com {
map {path} {dst} {
default not_found
}
@found expression `{dst} != "not_found"`
redir @found {dst} 302
respond "Unknown short URL" 404
}
Put the map directive first in the block. Reload Caddy to load the new block.
Use caddy reload --config /etc/caddy/Caddyfile --address 172.30.0.2:2019, or
restart the container.
Attach RedBot to the caddy-admin network. Keep the other networks.
services:
redbot:
networks:
your-existing-net:
caddy-admin:
networks:
caddy-admin:
external: trueSet the admin URL to the IP, not the caddy hostname. When two containers share
more than one network, DNS can give the wrong interface. Use
http://172.30.0.2:2019.
-
Copy the
caddyshortener/folder to~/Docker/redbot/. -
Add these lines to
docker/Dockerfile.RUN mkdir -p /cogs/caddyshortener COPY caddyshortener/ /cogs/caddyshortener/
-
Rebuild RedBot.
docker compose -f docker/docker-compose.yml up -d --build redbot
-
Register the cog path in Discord. Do this one time.
[p]addpath /cogs -
Load the cog in Discord.
[p]load caddyshortener
Run these commands one time. Only the owner can run them.
[p]caddyset adminurl http://172.30.0.2:2019 # your caddy-admin IP
[p]caddyset domain go.example.com # must match the Caddyfile block
[p]caddyset allowedrole add @Admins # who can manage URLs
[p]caddyset channel add #links # optional: limit to channels
Test the cog.
[p]shorten https://example.com/some/long/path test
The cog replies with https://go.example.com/test → https://example.com/some/long/path.
Use [p]caddyset to see the settings. Use [p]shortenhelp to see the cheat
sheet. The cog has no default domain. The commands do not work until you set the
domain with [p]caddyset domain.
| Command | Description |
|---|---|
[p]caddyset |
Show the current settings. |
[p]caddyset adminurl <url> |
Set the Caddy admin API URL. |
[p]caddyset domain <domain> |
Set the short URL domain. There is no default. |
[p]caddyset allowedrole add <role> |
Add a role to the allowlist. The cog stores the role ID. |
[p]caddyset allowedrole remove <role> |
Remove a role from the allowlist. |
[p]caddyset channel add|remove <#channel> |
Limit the user commands to specific channels. |
[p]caddyset channel clear |
Remove all channel limits. |
[p]caddyset resync |
Send the stored short URLs to Caddy again. Use this after a Caddyfile reload. |
[p]caddyset import |
Copy the short URLs that are already in Caddy into the store. |
[p]caddyset export |
Download all stored short URLs as a JSON file. |
[p]caddyset restore |
Load short URLs from an attached JSON file. |
[p]caddyset purge |
Remove all short URLs. The cog asks you to confirm. |
| Command | Description |
|---|---|
[p]shorten <url> [shortcode] |
Make a short URL. The cog makes a random 6-character code if you give none. |
[p]unshorten <shortcode> |
Remove one short URL. |
[p]shorturls |
Show all short URLs. |
[p]shortenbulk |
Make many short URLs. Put one <url> [shortcode] on each line. |
[p]unshortenbulk <code...> |
Remove more than one short URL in one command. |
[p]shortenhelp |
Show the cheat sheet. |
- No DMs. A cog check stops every command in direct messages. This includes
caddysetandshortenhelp. It applies to all users, and to the owner. - Roles control access. The commands
shorten,unshorten, andshorturlsneed an allowed role. Set the roles with[p]caddyset allowedrole. If you set no roles, only the bot owner can use the commands. - Channel limit. Use
[p]caddyset channelto limit the user commands to specific channels. - Role match by ID. The allowlist stores role IDs, not names. A user with Manage Roles can copy a role name, so names never give access.
- Owner-only config. Only the owner can use the
caddysetgroup. This includes the admin URL, so the internal address does not reach other users. - Isolated admin API. The Caddy admin API has no authentication. Bind it to
the private
caddy-adminnetwork only. See Prerequisites. - Placeholder guard. The cog rejects URLs that contain
{,}, or$. A target such ashttps://evil.com/{env.SECRET}cannot put a Caddy placeholder into a redirect. - Input checks. A URL must start with
http://orhttps://. A shortcode can have up to 20 characters: letters, numbers,-, and_. - No mentions. Every reply turns off mentions. A crafted URL, shortcode, or role name cannot ping a user.
The cog cannot find the map handler. Check these items.
- The go block is in the running config, not only in the Caddyfile. With
--resume, an oldautosave.jsoncan hide a new Caddyfile. Reload Caddy, or delete/config/caddy/autosave.json, to load the block. - The admin API is reachable. Make sure
[p]caddyset adminurluses the correctcaddy-adminIP. - The domain in the config is the same as the domain in the Caddyfile. Set it
with
[p]caddyset domain. - The
mapdirective is first in the go block. The cog checks for amaphandler and writes a warning to the log if it does not find one.
The cog keeps its own copy of each short URL in the RedBot config. The cog sends them to Caddy again when the bot starts. Therefore a Caddy restart alone does not lose data after the bot reconnects.
Two cases still need action.
- You reloaded the Caddyfile (
caddy reload). This empties the live map. Run[p]caddyset resyncto send the stored URLs again. A bot restart does the same automatically. - A short URL is not in the store. This can happen for URLs from before this
feature, or for URLs that you added directly with the admin API. Run
[p]caddyset importone time to copy the live URLs into the store.
Keep --resume and the /config volume so that Caddy also keeps its state
between restarts. The cog no longer needs them to prevent data loss.
-
Caddy does not run, or the admin API is not reachable.
-
Test the connection from RedBot. Change the IP to your
caddy-adminbind.docker exec redbot python3 -c "import urllib.request; print(urllib.request.urlopen('http://172.30.0.2:2019/config/').read()[:80])"
-
If the test fails, attach both Caddy and RedBot to the
caddy-adminnetwork, and bind the admin API to that IP.
Set a new domain. This clears the stored path and finds it again.
[p]caddyset domain go.example.com
caddyshortener/
├── __init__.py # setup() entry point
├── caddyshortener.py # cog class, commands, and Caddy API logic
├── info.json # RedBot cog metadata
└── README.md # this file
This section shows how the cog changes short URLs in Caddy.
Caddy changes the go block to JSON. The JSON looks like this (made simpler):
{
"match": [{"host": ["go.example.com"]}],
"handle": [{
"handler": "subroute",
"routes": [
{
"handle": [{
"handler": "map",
"source": "{http.request.uri.path}",
"destinations": ["{dst}"],
"defaults": ["not_found"],
"mappings": []
}]
}
]
}]
}The mappings array is the important part. It starts empty. Caddy can leave the
mappings key out while the map has no entries. Each entry that the cog adds
becomes one short URL. The cog manages this one array.
The go block can go into any Caddy server (srv0, srv1, and so on) and at any
index in the routes of that server. The server names are not stable. Therefore
the cog reads every server. The cog checks that a map handler is at the path
before it saves the path.
async def _discover_mappings_path(self):
# 1. GET /config/ returns the full running config.
status, config = await self._caddy_request("GET", "/config/")
# 2. Read every http server. The names are not stable.
servers = config["apps"]["http"]["servers"]
for srv_name, srv in servers.items():
for idx, route in enumerate(srv.get("routes", [])):
if domain in route["match"][0]["host"]:
# The map handler is at handle/0/routes/0/handle/0.
handler_path = (f"/config/apps/http/servers/{srv_name}"
f"/routes/{idx}/handle/0/routes/0/handle/0")
# 3. Confirm the handler is a map before you save the path.
_, handler = await self._caddy_request("GET", handler_path)
if handler.get("handler") != "map":
return None # writes a warning to the log
await self.config.mappings_path.set(handler_path + "/mappings")
return handler_path + "/mappings"The cog checks the handler object, not the mappings array. An empty map has no
mappings key, so a check of the array would fail. The cog saves the path in the
RedBot config. The next command uses the saved path.
Every command reads the map handler first. The cog reads the handler object, and
takes the mappings value. If the key is absent, the cog uses an empty list.
status, handler = await self._caddy_request("GET", handler_path)
mappings = handler.get("mappings", [])The cog writes the whole array back. The Caddy admin API uses two verbs for this.
PUT makes a new array. PATCH replaces an array that exists. Therefore the cog
uses PUT the first time, and PATCH after that.
# Create the array the first time (empty map, or after a reload):
PUT /config/.../mappings [ {"input": "/test", "outputs": ["https://..."]} ]
# Replace the array after it exists:
PATCH /config/.../mappings [ ...the full new list... ]
[p]shorten reads the array, adds one entry, and writes the array.
[p]unshorten reads the array, removes one entry, and writes the array. The cog
holds a lock during read and write, so two commands cannot damage the array.
The cog stores each short URL in the RedBot config as {shortcode: url}. This
store is the source of truth.
- At start-up, the cog sends any missing URLs to Caddy.
[p]caddyset resyncdoes the same on demand, after a Caddyfile reload.[p]caddyset exportand[p]caddyset restoremove the store to and from a JSON file.
Note - the sync only adds. The sync matches a short URL by its shortcode, not by its target URL. It sends only the shortcodes that Caddy does not have. It does not change or remove a shortcode that is already in Caddy. Therefore, if a shortcode in Caddy points to a different URL than the store, the sync does not correct it. This can happen only if you edit the map directly with the admin API. The commands
[p]shortenand[p]unshortenkeep the store and Caddy the same. To force Caddy to match the store for one shortcode, remove it and add it again, or run[p]unshortenthen[p]shorten.
Caddy also saves its running config to autosave.json. The --resume option
loads this file after a restart. If the file is absent, Caddy reads the Caddyfile.
A user opens go.example.com/abc. The map handler reads the path /abc. The map
sets {dst} to the target URL. The @found matcher is true, so Caddy sends a
302 redirect to the target. If the path has no entry, {dst} is not_found,
and Caddy sends a 404.
| Design | Good | Bad |
|---|---|---|
| Separate app and DB (YOURLS, and others) | Rich reports and a UI. | One more service to run and back up. |
| File based (write the Caddyfile, reload) | No API. | Needs file access between containers, and is fragile. |
| Caddy admin API (this cog) | No extra service. Live changes. | No click reports. |