9 Commits

Author SHA1 Message Date
prosolis
3e938efe3e Merge pull request #7 from prosolis/claude/fix-sonarr-webhook-error-CM6zH
Add webhook security guide to README (TLS + IP allowlisting)
2026-02-28 15:46:09 -08:00
Claude
90c7567e16 Add webhook security guide to README (TLS + IP allowlisting)
Document how to put Melora behind a Caddy reverse proxy for
automatic TLS and how to restrict access by source IP, both at
the proxy layer and with iptables.

https://claude.ai/code/session_0188gygwJLdYhxbKvEz2jFjr
2026-02-28 17:12:07 +00:00
prosolis
348a86495b Merge pull request #6 from prosolis/claude/fix-sonarr-webhook-error-CM6zH
Fix same quality field crash in radarr and lidarr formatters
2026-02-28 02:11:44 -08:00
Claude
de0de68ba5 Fix same quality field crash in radarr and lidarr formatters
Apply the same isinstance guard from the sonarr fix to radarr
(movieFile.quality) and lidarr (trackFiles[].quality) so a plain
string value no longer triggers an AttributeError.

https://claude.ai/code/session_0188gygwJLdYhxbKvEz2jFjr
2026-02-28 10:10:53 +00:00
prosolis
bddb5de803 Merge pull request #5 from prosolis/claude/fix-sonarr-webhook-error-CM6zH
Fix sonarr formatter crash when episodeFile.quality is a string
2026-02-28 01:55:12 -08:00
Claude
693ab9f217 Fix sonarr formatter crash when episodeFile.quality is a string
Sonarr can send quality as a plain string instead of the nested
{"quality": {"name": ...}} object. Guard with an isinstance check
and fall back to using the string value directly.

https://claude.ai/code/session_0188gygwJLdYhxbKvEz2jFjr
2026-02-28 09:54:09 +00:00
prosolis
e1de6a0794 Merge pull request #4 from prosolis/claude/fix-bot-online-issue-dWyHe
Claude/fix bot online issue d wy he
2026-02-28 01:38:28 -08:00
prosolis
950e60b0de Merge pull request #3 from prosolis/claude/fix-bot-online-issue-dWyHe
Check set_presence return value for errors
2026-02-28 01:18:45 -08:00
prosolis
03c0d72272 Merge pull request #2 from prosolis/claude/fix-bot-online-issue-dWyHe
Fix bot not going online: stop silently swallowing presence errors
2026-02-28 01:13:20 -08:00
2 changed files with 112 additions and 17 deletions

View File

@@ -261,6 +261,103 @@ Melora/
- Parsing and Matrix posting errors are logged but don't crash the service
- Missing thread roots on startup halt with a clear error
## Securing Webhooks
By default, webhook traffic (including the `X-Arr-Webhook-Secret` header) is sent over plain HTTP. Anyone who can observe network traffic between your \*arr instances and Melora can read the secret. The two recommended mitigations are a TLS reverse proxy and IP allowlisting — ideally both.
### 1. TLS via Caddy reverse proxy
[Caddy](https://caddyserver.com/) is the simplest option because it provisions and renews Let's Encrypt certificates automatically. Any reverse proxy that terminates TLS will work (nginx, Traefik, etc.) — Caddy just requires the least configuration.
#### Install Caddy
```bash
# Debian / Ubuntu
sudo apt install -y caddy
# Or with Docker
docker pull caddy:latest
```
#### Configure
Create (or edit) `/etc/caddy/Caddyfile`:
```
melora.example.com {
reverse_proxy localhost:8000
}
```
Replace `melora.example.com` with your actual domain. Caddy will automatically obtain a TLS certificate and redirect HTTP to HTTPS.
```bash
sudo systemctl restart caddy
```
#### Update your \*arr webhook URLs
In each \*arr instance, change the webhook URL from:
```
http://melora-host:8000/webhook/radarr
```
to:
```
https://melora.example.com/webhook/radarr
```
The `X-Arr-Webhook-Secret` header is now encrypted in transit.
### 2. IP allowlisting
Restrict the webhook endpoints so only your \*arr server(s) can reach them. This works regardless of whether you use TLS, and is a good defense-in-depth layer.
#### Option A: At the reverse proxy (Caddy)
Add a `remote_ip` matcher to your Caddyfile:
```
melora.example.com {
@allowed remote_ip 192.168.1.50 192.168.1.51
handle @allowed {
reverse_proxy localhost:8000
}
respond 403
}
```
Replace the IPs with the actual addresses of your Radarr/Sonarr/Lidarr hosts. If everything runs on the same machine, use `127.0.0.1`.
#### Option B: With a firewall (iptables / nftables)
If you aren't using a reverse proxy, restrict at the OS level:
```bash
# Allow only 192.168.1.50 to reach Melora on port 8000
sudo iptables -A INPUT -p tcp --dport 8000 -s 192.168.1.50 -j ACCEPT
sudo iptables -A INPUT -p tcp --dport 8000 -j DROP
```
To persist across reboots:
```bash
sudo apt install -y iptables-persistent
sudo netfilter-persistent save
```
### Recommended setup
| Layer | What it does |
|-------|-------------|
| **Caddy (TLS)** | Encrypts all traffic, including the webhook secret header |
| **IP allowlist** | Ensures only your \*arr hosts can reach the endpoint at all |
| **Webhook secret** | Authenticates requests in case the IP filter is misconfigured |
All three layers together give defense in depth — any single layer failing still leaves the other two in place.
## License
See repository for license details.

View File

@@ -15,12 +15,11 @@ def format_radarr(payload: dict) -> tuple[str, str]:
year = movie.get("year", "")
is_upgrade = payload.get("isUpgrade", False)
quality = (
payload.get("movieFile", {})
.get("quality", {})
.get("quality", {})
.get("name", "Unknown")
)
quality_field = payload.get("movieFile", {}).get("quality", {})
if isinstance(quality_field, dict):
quality = quality_field.get("quality", {}).get("name", "Unknown")
else:
quality = str(quality_field) if quality_field else "Unknown"
if is_upgrade:
lines = [
@@ -52,12 +51,11 @@ def format_sonarr(payload: dict) -> tuple[str, str]:
episode = ep.get("episodeNumber", 0)
ep_title = ep.get("title", "")
quality = (
payload.get("episodeFile", {})
.get("quality", {})
.get("quality", {})
.get("name", "Unknown")
)
quality_field = payload.get("episodeFile", {}).get("quality", {})
if isinstance(quality_field, dict):
quality = quality_field.get("quality", {}).get("name", "Unknown")
else:
quality = str(quality_field) if quality_field else "Unknown"
header = f"\U0001f4fa **{series_title}** \u2014 S{season:02d}E{episode:02d}"
if ep_title:
@@ -91,11 +89,11 @@ def format_lidarr(payload: dict) -> tuple[str, str]:
track_files = payload.get("trackFiles", [{}])
tf = track_files[0] if track_files else {}
quality = (
tf.get("quality", {})
.get("quality", {})
.get("name", "Unknown")
)
quality_field = tf.get("quality", {})
if isinstance(quality_field, dict):
quality = quality_field.get("quality", {}).get("name", "Unknown")
else:
quality = str(quality_field) if quality_field else "Unknown"
header = f"\U0001f3b5 **{artist_name}**"
if album_title: