Compare commits
6 Commits
claude/fix
...
claude/fix
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
90c7567e16 | ||
|
|
de0de68ba5 | ||
|
|
693ab9f217 | ||
|
|
e1de6a0794 | ||
|
|
950e60b0de | ||
|
|
03c0d72272 |
97
README.md
97
README.md
@@ -261,6 +261,103 @@ Melora/
|
|||||||
- Parsing and Matrix posting errors are logged but don't crash the service
|
- Parsing and Matrix posting errors are logged but don't crash the service
|
||||||
- Missing thread roots on startup halt with a clear error
|
- 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
|
## License
|
||||||
|
|
||||||
See repository for license details.
|
See repository for license details.
|
||||||
|
|||||||
@@ -15,12 +15,11 @@ def format_radarr(payload: dict) -> tuple[str, str]:
|
|||||||
year = movie.get("year", "")
|
year = movie.get("year", "")
|
||||||
is_upgrade = payload.get("isUpgrade", False)
|
is_upgrade = payload.get("isUpgrade", False)
|
||||||
|
|
||||||
quality = (
|
quality_field = payload.get("movieFile", {}).get("quality", {})
|
||||||
payload.get("movieFile", {})
|
if isinstance(quality_field, dict):
|
||||||
.get("quality", {})
|
quality = quality_field.get("quality", {}).get("name", "Unknown")
|
||||||
.get("quality", {})
|
else:
|
||||||
.get("name", "Unknown")
|
quality = str(quality_field) if quality_field else "Unknown"
|
||||||
)
|
|
||||||
|
|
||||||
if is_upgrade:
|
if is_upgrade:
|
||||||
lines = [
|
lines = [
|
||||||
@@ -52,12 +51,11 @@ def format_sonarr(payload: dict) -> tuple[str, str]:
|
|||||||
episode = ep.get("episodeNumber", 0)
|
episode = ep.get("episodeNumber", 0)
|
||||||
ep_title = ep.get("title", "")
|
ep_title = ep.get("title", "")
|
||||||
|
|
||||||
quality = (
|
quality_field = payload.get("episodeFile", {}).get("quality", {})
|
||||||
payload.get("episodeFile", {})
|
if isinstance(quality_field, dict):
|
||||||
.get("quality", {})
|
quality = quality_field.get("quality", {}).get("name", "Unknown")
|
||||||
.get("quality", {})
|
else:
|
||||||
.get("name", "Unknown")
|
quality = str(quality_field) if quality_field else "Unknown"
|
||||||
)
|
|
||||||
|
|
||||||
header = f"\U0001f4fa **{series_title}** \u2014 S{season:02d}E{episode:02d}"
|
header = f"\U0001f4fa **{series_title}** \u2014 S{season:02d}E{episode:02d}"
|
||||||
if ep_title:
|
if ep_title:
|
||||||
@@ -91,11 +89,11 @@ def format_lidarr(payload: dict) -> tuple[str, str]:
|
|||||||
|
|
||||||
track_files = payload.get("trackFiles", [{}])
|
track_files = payload.get("trackFiles", [{}])
|
||||||
tf = track_files[0] if track_files else {}
|
tf = track_files[0] if track_files else {}
|
||||||
quality = (
|
quality_field = tf.get("quality", {})
|
||||||
tf.get("quality", {})
|
if isinstance(quality_field, dict):
|
||||||
.get("quality", {})
|
quality = quality_field.get("quality", {}).get("name", "Unknown")
|
||||||
.get("name", "Unknown")
|
else:
|
||||||
)
|
quality = str(quality_field) if quality_field else "Unknown"
|
||||||
|
|
||||||
header = f"\U0001f3b5 **{artist_name}**"
|
header = f"\U0001f3b5 **{artist_name}**"
|
||||||
if album_title:
|
if album_title:
|
||||||
|
|||||||
Reference in New Issue
Block a user