From 8c295d183bdc16fce19bc2b814b1b5a664708da0 Mon Sep 17 00:00:00 2001 From: prosolis <5590409+prosolis@users.noreply.github.com> Date: Sun, 24 May 2026 20:29:13 -0700 Subject: [PATCH] Session 5: Dockerfile, config example, README rewrite MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Multi-stage alpine Dockerfile builds with -tags goolm so libolm isn't needed at runtime. Annotated config.example.yaml documents every field and shows ${ENV_VAR} usage for secrets. README is rewritten for the Go bot — Python-era web-portal docs are gone. --- Dockerfile | 21 ++++ README.md | 268 +++++++------------------------------------- SESSION_PLAN.md | 2 +- config.example.yaml | 42 +++++++ 4 files changed, 105 insertions(+), 228 deletions(-) create mode 100644 Dockerfile create mode 100644 config.example.yaml diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..67ca357 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,21 @@ +# syntax=docker/dockerfile:1 + +FROM golang:1.25-alpine AS build +RUN apk add --no-cache build-base +WORKDIR /src +COPY go.mod go.sum ./ +RUN go mod download +COPY . . +# goolm = pure-Go olm; sqlite needs cgo for the device/crypto stores. +ENV CGO_ENABLED=1 +RUN go build -tags goolm -trimpath -ldflags="-s -w" -o /out/bellhop ./ + +FROM alpine:3.21 +RUN apk add --no-cache ca-certificates tzdata +RUN addgroup -S bellhop && adduser -S -G bellhop bellhop +WORKDIR /app +COPY --from=build /out/bellhop /usr/local/bin/bellhop +USER bellhop +VOLUME ["/app/data"] +ENTRYPOINT ["/usr/local/bin/bellhop"] +CMD ["-config", "/app/config.yaml"] diff --git a/README.md b/README.md index 9d8242a..9caa9d2 100644 --- a/README.md +++ b/README.md @@ -1,267 +1,81 @@ # Bellhop -A Matrix-authenticated web portal for submitting media requests to Radarr, Sonarr, and Lidarr. Users sign in with their Matrix homeserver credentials, search for movies, TV shows, or music, and submit requests — all through a clean single-page interface. Every request is logged to a Matrix room for auditing. +A Matrix bot that adds movies, TV, and music to Radarr/Sonarr/Lidarr from chat. Invite the bot to a room, allowlist the room ID, and any member can type `!movie dune` to add the top search hit. -## Architecture +## Command UX ``` -Browser ──► FastAPI app ──► Matrix homeserver (authentication) - ──► Radarr / Sonarr / Lidarr (search + add) - ──► Matrix room (audit log) - ──► SQLite (session storage) +!movie — add the top Radarr hit +!tv — add the top Sonarr hit +!music — add the top Lidarr hit +!help — show the command list ``` -All *arr communication happens server-side. API keys and service URLs are never exposed to the browser. +The bot replies in a thread under the request so a busy room stays readable. No numbered picker, no reaction selector — the top search result is what gets added. If you want something other than the top hit, narrow the query. -## Requirements +## Authorization -- Python 3.12+ -- A Matrix homeserver (Synapse, Dendrite, Conduit, etc.) -- At least one of: Radarr, Sonarr, or Lidarr accessible over HTTPS -- (Optional) A Matrix bot account for audit logging +Any member of an allowlisted room may issue commands. The bot auto-joins on invite, but the room ID must appear under `matrix.allowed_rooms` in the config before commands are honored. -## Quick Start +## Configuration -### 1. Clone and configure +Copy `config.example.yaml` to `config.yaml` and edit. The loader expands `${ENV_VAR}` references at load time, so secrets can come from the environment. -```bash -git clone https://github.com/prosolis/Bellhop.git -cd Bellhop -cp .env.example .env -``` +Required: -Edit `.env` with your actual values (see [Environment Variables](#environment-variables) below). +- `matrix.homeserver`, `matrix.user_id`, `matrix.password` +- `matrix.allowed_rooms` (at least one room ID) +- At least one of `services.radarr` / `services.sonarr` / `services.lidarr` -### 2. Run locally +Per-service: `url`, `api_key`, `quality_profile_id`, `root_folder`. Lidarr also needs `metadata_profile_id`. -```bash -pip install -r requirements.txt -uvicorn app.main:app --host 0.0.0.0 --port 8000 -``` +Omit a service block to disable its command — `!movie` with no `radarr` block replies "Radarr is not configured". -Open `http://localhost:8000` in your browser. - -### 3. Run with Docker - -```bash -docker build -t bellhop . -docker run -d \ - --name bellhop \ - --env-file .env \ - -p 8000:8000 \ - -v bellhop-data:/app \ - bellhop -``` - -The SQLite database file is created at the path specified by `DATABASE_PATH` (default: `bellhop.db` in the working directory). Mount a volume if you want persistence across container recreations. - -## Environment Variables - -Create a `.env` file in the project root (or pass variables via Docker `--env-file`). See `.env.example` for a template. - -### Required - -| Variable | Description | -|---|---| -| `MATRIX_HOMESERVER_URL` | Base URL of your Matrix homeserver (e.g. `https://matrix.example.com`) | - -### *arr Services - -Configure one or more. If a service's URL or API key is left empty, that media type will return a "not configured" error when used. - -| Variable | Default | Description | -|---|---|---| -| `RADARR_URL` | _(empty)_ | Radarr instance URL (e.g. `https://radarr.example.com`) | -| `RADARR_API_KEY` | _(empty)_ | Radarr API key (Settings > General in Radarr) | -| `RADARR_QUALITY_PROFILE_ID` | `1` | Quality profile ID to assign to new movies | -| `RADARR_ROOT_FOLDER` | `/movies` | Root folder path for movie storage | -| `SONARR_URL` | _(empty)_ | Sonarr instance URL | -| `SONARR_API_KEY` | _(empty)_ | Sonarr API key | -| `SONARR_QUALITY_PROFILE_ID` | `1` | Quality profile ID for new series | -| `SONARR_ROOT_FOLDER` | `/tv` | Root folder path for TV storage | -| `LIDARR_URL` | _(empty)_ | Lidarr instance URL | -| `LIDARR_API_KEY` | _(empty)_ | Lidarr API key | -| `LIDARR_QUALITY_PROFILE_ID` | `1` | Quality profile ID for new artists | -| `LIDARR_ROOT_FOLDER` | `/music` | Root folder path for music storage | - -**Finding quality profile IDs:** Open your *arr instance, go to Settings > Profiles. The ID is visible in the URL when you click a profile, or query the API directly: +**Finding quality profile IDs:** ```bash curl -H "X-Api-Key: YOUR_KEY" https://radarr.example.com/api/v3/qualityprofile ``` -### Audit Bot (optional) +## Running -| Variable | Default | Description | -|---|---|---| -| `MATRIX_AUDIT_ROOM_ID` | _(empty)_ | Room ID for audit messages (e.g. `!abc123:example.com`) | -| `MATRIX_BOT_USER_ID` | _(empty)_ | Bot's Matrix user ID (e.g. `@bellhop-bot:example.com`) | -| `MATRIX_BOT_ACCESS_TOKEN` | _(empty)_ | Pre-authenticated access token for the bot | - -If any of these are left empty, audit logging is silently disabled. The room must be **unencrypted** and the bot must already be joined to it. - -**Getting a bot access token:** +### Local ```bash -curl -X POST https://matrix.example.com/_matrix/client/v3/login \ - -H "Content-Type: application/json" \ - -d '{"type":"m.login.password","identifier":{"type":"m.id.user","user":"@bellhop-bot:example.com"},"password":"bot-password"}' +go build -tags goolm -o bellhop ./ +./bellhop -config config.yaml ``` -Copy the `access_token` from the response. +The `goolm` tag uses the pure-Go olm implementation so libolm isn't needed. -### Other +### Docker -| Variable | Default | Description | -|---|---|---| -| `SESSION_SECRET_KEY` | _(auto-generated)_ | Secret for signing session cookies. Auto-generated at startup if not set. A new key is generated on every restart, which invalidates all existing sessions. | -| `DATABASE_PATH` | `bellhop.db` | Path to the SQLite database file | - -## API Reference - -### Authentication - -| Method | Path | Description | -|---|---|---| -| `POST` | `/auth/login` | Authenticate with Matrix credentials. Rate-limited to 5 requests/minute per IP. | -| `POST` | `/auth/logout` | Destroy the current session. | -| `GET` | `/auth/me` | Return the current user's Matrix ID, or 401 if not authenticated. | - -**Login request body:** - -```json -{ - "username": "@user:example.com", - "password": "your-password" -} +```bash +docker build -t bellhop . +docker run -d \ + --name bellhop \ + -v "$PWD/config.yaml:/app/config.yaml:ro" \ + -v bellhop-data:/app/data \ + bellhop ``` -The username can be a full Matrix ID (`@user:example.com`) or a localpart (`user`) — the homeserver resolves it. +The `data` volume holds the Matrix device file and the E2EE crypto store. Losing it forces a re-login and re-verification on next start. -**Login response (200):** +## E2EE notes -```json -{ - "user_id": "@user:example.com" -} -``` +The bot bootstraps cross-signing on first run and persists Olm/Megolm sessions in `data/crypto.db`. If you rotate the bot's password or wipe `data/`, the bot logs in as a new device — existing rooms will need to re-share keys, which mautrix handles automatically on the next message. -A `bellhop_session` cookie is set automatically. - -### Search - -| Method | Path | Description | -|---|---|---| -| `GET` | `/search/movie?term=...` | Search Radarr for movies | -| `GET` | `/search/tv?term=...` | Search Sonarr for TV shows | -| `GET` | `/search/music?term=...` | Search Lidarr for artists | - -All search endpoints require an active session (cookie). Results are capped at 25 items. Response fields are sanitized — only safe metadata (title, year, poster URL, IDs) is returned. - -### Request - -| Method | Path | Description | -|---|---|---| -| `POST` | `/request/movie` | Add a movie to Radarr | -| `POST` | `/request/tv` | Add a series to Sonarr | -| `POST` | `/request/music` | Add an artist to Lidarr | - -**Movie request body:** - -```json -{ - "title": "Movie Title", - "tmdbId": 12345, - "year": 2024 -} -``` - -**TV request body:** - -```json -{ - "title": "Show Title", - "tvdbId": 67890, - "year": 2024 -} -``` - -**Music request body:** - -```json -{ - "artistName": "Artist Name", - "foreignArtistId": "mbid-uuid-here" -} -``` - -All items are added as monitored with "search on add" enabled. Quality profile and root folder are set from the corresponding environment variables. - -**Success response (200):** - -```json -{ - "ok": true, - "message": "Movie added successfully" -} -``` - -### Frontend - -| Method | Path | Description | -|---|---|---| -| `GET` | `/` | Serves the single-page Alpine.js frontend | - -## Project Structure +## Project layout ``` -Bellhop/ -├── app/ -│ ├── __init__.py -│ ├── main.py # FastAPI app, lifespan, rate limiter, route mounting -│ ├── config.py # Environment variable loading -│ ├── database.py # Async SQLite session CRUD -│ ├── auth.py # /auth/* routes, session cookie management -│ ├── arr.py # /search/* and /request/* routes, *arr API proxying -│ ├── audit.py # Fire-and-forget Matrix room messaging -│ ├── static/ # Static assets (served at /static) -│ └── templates/ -│ └── index.html # Alpine.js single-page frontend -├── Dockerfile -├── requirements.txt -├── .env.example -└── README.md +main.go +internal/ + config/ — YAML loader with ${ENV_VAR} expansion + matrix/ — mautrix client: login, device persistence, E2EE, sync loop + arr/ — Radarr/Sonarr/Lidarr HTTP clients + bot/ — command parser + dispatch + threaded replies ``` -## Security - -- **Session cookies** are set with `httponly`, `samesite=strict`, and `secure` flags. The `secure` flag means cookies are only sent over HTTPS — use a reverse proxy with TLS in production. -- **Login rate limiting** — 5 attempts per minute per IP address via slowapi. -- **Token validation** — every protected route verifies the Matrix access token against the homeserver's `/_matrix/client/v3/account/whoami` endpoint. If the token has been revoked, the session is deleted immediately. If the homeserver is unreachable, the local session is trusted as a fallback. -- **No credential leakage** — *arr API keys, URLs, and internal IDs are never included in any response to the browser. Search results are mapped to a safe subset of fields before returning. -- **Sessions expire** after 7 days (cookie `max_age`). - -### Production Recommendations - -- Run behind a reverse proxy (nginx, Caddy, Traefik) with TLS termination so the `secure` cookie flag works. -- If you want sessions to persist across restarts, set `SESSION_SECRET_KEY` explicitly. Otherwise, all users are logged out on restart. -- Restrict network access to your *arr instances — only the Bellhop container needs to reach them. -- Use a dedicated Matrix bot account for audit logging rather than a personal account. - -## How It Works - -1. **User signs in** — the frontend POSTs Matrix credentials to `/auth/login`. The backend authenticates against the Matrix homeserver's Client-Server API (`m.login.password`), stores the resulting access token in SQLite, and returns a session cookie. - -2. **User searches** — the frontend sends a search query to `/search/{type}`. The backend proxies the request to the appropriate *arr instance, strips internal fields, and returns sanitized results with poster URLs. - -3. **User requests** — clicking "Request" on a result POSTs it to `/request/{type}`. The backend sends the add command to the *arr API with preconfigured quality profile and root folder. On success, an audit message is fired asynchronously to the configured Matrix room. - -4. **Audit trail** — every successful request posts a message like `[REQUEST] @user:example.com → [Movie] "Title" (2024)` to the Matrix audit room. This is fire-and-forget — failures are logged but never block the user's request. - -## Lidarr Notes - -Lidarr uses MusicBrainz IDs (`foreignArtistId`) rather than TMDB/TVDB IDs. The lookup response includes this field and it is passed through directly to the add call. No independent MBID resolution is needed. - ## License See repository for license details. diff --git a/SESSION_PLAN.md b/SESSION_PLAN.md index e62da7c..5dfe3b8 100644 --- a/SESSION_PLAN.md +++ b/SESSION_PLAN.md @@ -42,7 +42,7 @@ Rewriting Bellhop from a Python FastAPI web portal into a Go-based Matrix comman - "Service not configured" reply if the corresponding *arr block is absent - `main.go`: load config, init matrix, wire handler, SIGINT/SIGTERM shutdown -- [ ] **Session 5 — Ship polish** +- [x] **Session 5 — Ship polish** - Multi-stage Dockerfile (`golang:1.25-alpine` → `alpine:3.21`, build with `-tags goolm`) - `config.example.yaml` - Rewrite `README.md`: command UX, install, config reference, Docker diff --git a/config.example.yaml b/config.example.yaml new file mode 100644 index 0000000..65f60d2 --- /dev/null +++ b/config.example.yaml @@ -0,0 +1,42 @@ +# Bellhop config. Values like ${ENV_VAR} are expanded from the environment +# at load time so secrets can stay out of this file. + +matrix: + homeserver: https://matrix.example.com + user_id: "@bellhop:example.com" + password: ${BELLHOP_MATRIX_PASSWORD} + + # Optional. Defaults shown. + display_name: Bellhop + data_dir: ./data # device.json + crypto.db live here + pickle_key: ${BELLHOP_PICKLE_KEY} # encrypts the crypto store; pick something stable + command_prefix: "!" + + # Rooms the bot will respond to commands in. Messages anywhere else are + # ignored. The bot auto-joins on invite, but joining alone does not grant + # command access — the room ID must appear here. + allowed_rooms: + - "!room-id-one:example.com" + - "!room-id-two:example.com" + +# Configure any subset. Omit a block to disable that command: +# leaving out `radarr` makes `!movie` reply "Radarr is not configured". +services: + radarr: + url: https://radarr.example.com + api_key: ${RADARR_API_KEY} + quality_profile_id: 1 + root_folder: /movies + + sonarr: + url: https://sonarr.example.com + api_key: ${SONARR_API_KEY} + quality_profile_id: 1 + root_folder: /tv + + lidarr: + url: https://lidarr.example.com + api_key: ${LIDARR_API_KEY} + quality_profile_id: 1 + metadata_profile_id: 1 # required for Lidarr only + root_folder: /music