Compare commits
15 Commits
claude/med
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3e938efe3e | ||
|
|
90c7567e16 | ||
|
|
348a86495b | ||
|
|
de0de68ba5 | ||
|
|
bddb5de803 | ||
|
|
693ab9f217 | ||
|
|
e1de6a0794 | ||
|
|
e4c696d142 | ||
|
|
7cc5fcb514 | ||
|
|
dbaa7b0d8b | ||
|
|
950e60b0de | ||
|
|
d22fb6ae10 | ||
|
|
03c0d72272 | ||
|
|
8d7a66c431 | ||
|
|
6812758af0 |
111
README.md
111
README.md
@@ -30,22 +30,30 @@ cp .env.example .env
|
|||||||
|
|
||||||
Edit `.env` with your actual values (see [Environment Variables](#environment-variables) below).
|
Edit `.env` with your actual values (see [Environment Variables](#environment-variables) below).
|
||||||
|
|
||||||
### 2. Verify your configuration
|
### 2. Set up a virtual environment
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
python3 -m venv .venv
|
||||||
|
source .venv/bin/activate
|
||||||
pip install -r requirements.txt
|
pip install -r requirements.txt
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. Verify your configuration
|
||||||
|
|
||||||
|
```bash
|
||||||
python -m app --check
|
python -m app --check
|
||||||
```
|
```
|
||||||
|
|
||||||
This validates that all required environment variables are set, the Matrix homeserver is reachable, the bot token is valid, the bot has joined the announcements room, and the database path is writable. Fix any failing checks before starting the server.
|
This validates that all required environment variables are set, the Matrix homeserver is reachable, the bot token is valid, the bot has joined the announcements room, and the database path is writable. Fix any failing checks before starting the server.
|
||||||
|
|
||||||
### 3. Run locally
|
### 4. Run locally
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
source .venv/bin/activate
|
||||||
uvicorn app.main:app --host 0.0.0.0 --port 8000
|
uvicorn app.main:app --host 0.0.0.0 --port 8000
|
||||||
```
|
```
|
||||||
|
|
||||||
### 4. Run with Docker
|
### 5. Run with Docker
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
docker build -t melora .
|
docker build -t melora .
|
||||||
@@ -253,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:
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import asyncio
|
import asyncio
|
||||||
import logging
|
import logging
|
||||||
|
|
||||||
from nio import AsyncClient, RoomSendResponse
|
from nio import AsyncClient, PresenceSetError, RoomSendResponse
|
||||||
|
|
||||||
from app.config import config
|
from app.config import config
|
||||||
|
|
||||||
@@ -38,23 +38,28 @@ async def close_client() -> None:
|
|||||||
pass
|
pass
|
||||||
_presence_task = None
|
_presence_task = None
|
||||||
if _client is not None:
|
if _client is not None:
|
||||||
|
try:
|
||||||
await _set_presence("offline")
|
await _set_presence("offline")
|
||||||
|
except Exception:
|
||||||
|
log.warning("Failed to set offline presence during shutdown", exc_info=True)
|
||||||
await _client.close()
|
await _client.close()
|
||||||
_client = None
|
_client = None
|
||||||
|
|
||||||
|
|
||||||
async def _set_presence(state: str) -> None:
|
async def _set_presence(state: str) -> None:
|
||||||
client = await get_client()
|
client = await get_client()
|
||||||
try:
|
resp = await client.set_presence(state)
|
||||||
await client.set_presence(state)
|
if isinstance(resp, PresenceSetError):
|
||||||
except Exception:
|
raise RuntimeError(f"Presence update to '{state}' failed: {resp.message}")
|
||||||
log.debug("Presence update failed", exc_info=True)
|
|
||||||
|
|
||||||
|
|
||||||
async def _presence_loop() -> None:
|
async def _presence_loop() -> None:
|
||||||
while True:
|
while True:
|
||||||
await _set_presence("online")
|
|
||||||
await asyncio.sleep(PRESENCE_INTERVAL_SECONDS)
|
await asyncio.sleep(PRESENCE_INTERVAL_SECONDS)
|
||||||
|
try:
|
||||||
|
await _set_presence("online")
|
||||||
|
except Exception:
|
||||||
|
log.warning("Presence heartbeat failed", exc_info=True)
|
||||||
|
|
||||||
|
|
||||||
async def start_presence() -> None:
|
async def start_presence() -> None:
|
||||||
|
|||||||
Reference in New Issue
Block a user