feat: SIGHUP hot reload for headless config updates

Add signal handler that calls rehash() on SIGHUP, logging results
instead of sending to a client. Useful for systemd and container
environments where no IRC client is attached. Update docs with
channel key config, hot reload section, and roadmap checkoffs.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
user
2026-02-21 19:03:35 +01:00
parent c11bd5555a
commit 2ab5f95476
6 changed files with 91 additions and 9 deletions

View File

@@ -33,8 +33,8 @@
## v0.3.0 ## v0.3.0
- [x] Client-side TLS (accept TLS from clients) - [x] Client-side TLS (accept TLS from clients)
- [ ] Channel key support (JOIN #channel key) - [x] Channel key support (JOIN #channel key)
- [ ] Hot config reload (SIGHUP) - [x] Hot config reload (SIGHUP)
- [ ] Systemd service file - [ ] Systemd service file
## v0.4.0 ## v0.4.0

View File

@@ -26,6 +26,7 @@
## Next ## Next
- [x] P2: Client-side TLS support - [x] P2: Client-side TLS support
- [ ] P2: Channel key support - [x] P2: Channel key support
- [x] P2: Hot config reload (SIGHUP + REHASH refactor)
- [ ] P3: Systemd service file - [ ] P3: Systemd service file
- [ ] P3: Containerfile for podman deployment - [ ] P3: Containerfile for podman deployment

View File

@@ -2,8 +2,6 @@
## Features ## Features
- [ ] Channel key support (JOIN #channel key)
- [ ] Hot config reload on SIGHUP
- [ ] Web status dashboard - [ ] Web status dashboard
- [ ] DCC passthrough - [ ] DCC passthrough
- [ ] Per-client backlog tracking (multi-user) - [ ] Per-client backlog tracking (multi-user)

View File

@@ -74,8 +74,9 @@ PASS <password> # authenticate (all networks)
/msg *bouncer REHASH # reload config file /msg *bouncer REHASH # reload config file
/msg *bouncer ADDNETWORK name host=h port=N tls=yes nick=n channels=#a,#b /msg *bouncer ADDNETWORK name host=h port=N tls=yes nick=n channels=#a,#b
/msg *bouncer DELNETWORK name # remove network /msg *bouncer DELNETWORK name # remove network
/msg *bouncer AUTOJOIN net +#chan # add to autojoin /msg *bouncer AUTOJOIN net +#chan # add to autojoin
/msg *bouncer AUTOJOIN net -#chan # remove from autojoin /msg *bouncer AUTOJOIN net +#chan key # add with channel key
/msg *bouncer AUTOJOIN net -#chan # remove from autojoin
``` ```
### NickServ ### NickServ
@@ -174,6 +175,13 @@ notify_proxy = false # use SOCKS5 for notifications
Only fires when no clients are attached. Only fires when no clients are attached.
## Hot Reload
```bash
kill -HUP $(pidof bouncer) # reload config via signal
/msg *bouncer REHASH # reload config via command
```
## Config Skeleton ## Config Skeleton
```toml ```toml
@@ -201,6 +209,7 @@ host / port
[networks.<name>] # repeatable [networks.<name>] # repeatable
host / port / tls host / port / tls
nick / channels / autojoin nick / channels / autojoin
channel_keys # keys for +k channels
password # optional, IRC server PASS password # optional, IRC server PASS
``` ```

View File

@@ -364,6 +364,7 @@ port = 6697 # server port (default: 6697 if tls, 6667 otherwise)
tls = true # use TLS for server connection tls = true # use TLS for server connection
nick = "mynick" # desired IRC nick (set after probation) nick = "mynick" # desired IRC nick (set after probation)
channels = ["#test"] # channels to join (after probation) channels = ["#test"] # channels to join (after probation)
channel_keys = { "#secret" = "hunter2" } # keys for +k channels (optional)
autojoin = true # auto-join channels on ready (default: true) autojoin = true # auto-join channels on ready (default: true)
password = "" # IRC server password (optional, for PASS command) password = "" # IRC server password (optional, for PASS command)
``` ```
@@ -488,10 +489,11 @@ Responses arrive as NOTICE messages from `*bouncer`.
| `REHASH` | Reload config file, add/remove/reconnect networks | | `REHASH` | Reload config file, add/remove/reconnect networks |
| `ADDNETWORK <name> key=val ...` | Create a network at runtime | | `ADDNETWORK <name> key=val ...` | Create a network at runtime |
| `DELNETWORK <name>` | Stop and remove a network | | `DELNETWORK <name>` | Stop and remove a network |
| `AUTOJOIN <network> +/-#channel` | Add or remove channel from autojoin list | | `AUTOJOIN <network> +#channel [key]` | Add channel (with optional key for +k channels) |
| `AUTOJOIN <network> -#channel` | Remove channel from autojoin list |
**ADDNETWORK keys:** `host` (required), `port`, `tls` (yes/no), `nick`, **ADDNETWORK keys:** `host` (required), `port`, `tls` (yes/no), `nick`,
`channels` (comma-separated), `password`. `channels` (comma-separated), `channel_keys` (`#chan=key,...`), `password`.
### NickServ ### NickServ
@@ -536,6 +538,7 @@ Responses arrive as NOTICE messages from `*bouncer`.
/msg *bouncer ADDNETWORK oftc host=irc.oftc.net port=6697 tls=yes channels=#test /msg *bouncer ADDNETWORK oftc host=irc.oftc.net port=6697 tls=yes channels=#test
/msg *bouncer DELNETWORK oftc /msg *bouncer DELNETWORK oftc
/msg *bouncer AUTOJOIN libera +#newchannel /msg *bouncer AUTOJOIN libera +#newchannel
/msg *bouncer AUTOJOIN libera +#secret hunter2
/msg *bouncer AUTOJOIN libera -#oldchannel /msg *bouncer AUTOJOIN libera -#oldchannel
/msg *bouncer IDENTIFY libera /msg *bouncer IDENTIFY libera
/msg *bouncer REGISTER libera /msg *bouncer REGISTER libera
@@ -625,6 +628,62 @@ farm_interval = 3600 # seconds between attempts per network
farm_max_accounts = 10 # stop farming when this many verified accounts exist farm_max_accounts = 10 # stop farming when this many verified accounts exist
``` ```
## Channel Keys
Channels with mode `+k` require a key to join. Configure keys in TOML:
```toml
[networks.libera]
channels = ["#secret", "#public"]
channel_keys = { "#secret" = "hunter2" }
```
Keys are used automatically during autojoin and KICK rejoin. To add a keyed
channel at runtime:
```
/msg *bouncer AUTOJOIN libera +#secret hunter2
```
Removing a channel also clears its key:
```
/msg *bouncer AUTOJOIN libera -#secret
```
## Hot Reload
The bouncer reloads its config file on `SIGHUP` or via the `REHASH` command.
Both use the same logic: re-read TOML, diff networks (add/remove/reconnect),
and update mutable fields (channels, channel_keys, nick, password).
### SIGHUP
```bash
kill -HUP $(pidof bouncer)
```
Results are logged (no client connection needed). Useful for headless
operation (systemd, containers).
### REHASH command
```
/msg *bouncer REHASH
```
Results are sent back as NOTICE messages.
### What changes on reload
| Field | Effect |
|-------|--------|
| Network host/port/tls/proxy | Network reconnected |
| channels, channel_keys, nick, password | Updated in-place |
| notify_url, notify_cooldown, etc. | Notifier recreated |
| farm_enabled, farm_interval, etc. | Farm started/stopped |
| bind, port, password, client_tls | Warning logged (restart required) |
## Stopping ## Stopping
Press `Ctrl+C` or send `SIGTERM`. The bouncer shuts down gracefully, closing Press `Ctrl+C` or send `SIGTERM`. The bouncer shuts down gracefully, closing

View File

@@ -90,6 +90,21 @@ async def _run(config_path: Path, verbose: bool) -> None:
for sig in (signal.SIGINT, signal.SIGTERM): for sig in (signal.SIGINT, signal.SIGTERM):
loop.add_signal_handler(sig, _signal_handler) loop.add_signal_handler(sig, _signal_handler)
# Hot reload on SIGHUP
async def _sighup_rehash() -> None:
try:
lines = await commands.rehash(router, config_path)
for line in lines:
log.info("REHASH: %s", line)
except Exception:
log.exception("SIGHUP rehash failed")
def _sighup_handler() -> None:
log.info("SIGHUP received, reloading config...")
asyncio.create_task(_sighup_rehash())
loop.add_signal_handler(signal.SIGHUP, _sighup_handler)
await stop_event.wait() await stop_event.wait()
server.close() server.close()