diff --git a/.github/workflows/docker-publish.yml b/.github/workflows/docker-publish.yml new file mode 100644 index 0000000..fc106b0 --- /dev/null +++ b/.github/workflows/docker-publish.yml @@ -0,0 +1,56 @@ +name: Build & Publish Docker Image + +on: + push: + branches: [main] + tags: ["v*"] + +env: + REGISTRY: ghcr.io + IMAGE_NAME: ${{ github.repository }} + +jobs: + build-and-push: + runs-on: ubuntu-latest + permissions: + contents: read + packages: write + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Set up QEMU + uses: docker/setup-qemu-action@v3 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Log in to GHCR + uses: docker/login-action@v3 + with: + registry: ${{ env.REGISTRY }} + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Extract metadata + id: meta + uses: docker/metadata-action@v5 + with: + images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} + tags: | + type=raw,value=latest,enable={{is_default_branch}} + type=sha,prefix= + type=semver,pattern={{version}} + type=semver,pattern={{major}}.{{minor}} + + - name: Build and push + uses: docker/build-push-action@v6 + with: + context: . + platforms: linux/amd64,linux/arm64 + push: true + tags: ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} + cache-from: type=gha + cache-to: type=gha,mode=max diff --git a/.gitignore b/.gitignore index 8c556d3..f7e49a8 100644 --- a/.gitignore +++ b/.gitignore @@ -4,3 +4,9 @@ dist/ .wrangler/ *.log + +# Runtime data — never commit +secret.key +*.db +__pycache__/ +venv/ diff --git a/Dockerfile b/Dockerfile index 1fdbb85..0885a05 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,5 +1,11 @@ FROM python:3.12-slim +LABEL maintainer="Inch-high" +LABEL org.opencontainers.image.title="PlexGeo" +LABEL org.opencontainers.image.description="Stream intelligence dashboard for Plex — live map, session logs, anomaly alerts" +LABEL org.opencontainers.image.source="https://github.com/Inch-high/plexgeo" +LABEL org.opencontainers.image.url="https://github.com/Inch-high/plexgeo" + WORKDIR /app RUN apt-get update && apt-get install -y --no-install-recommends \ @@ -15,4 +21,7 @@ ENV DB_PATH=/data/plexgeo.db EXPOSE 7842 +HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \ + CMD python -c "import urllib.request; urllib.request.urlopen('http://localhost:7842/health')" || exit 1 + CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "7842"] diff --git a/README.md b/README.md index 530d173..281b8e2 100644 --- a/README.md +++ b/README.md @@ -18,19 +18,27 @@ Self-hosted Docker app that monitors your Plex server, logs every user stream by ### 1. Get your Plex token -Open Plex Web → any media item → `···` → Get Info → View XML. +Open Plex Web → any media item → `···` → Get Info → View XML. The URL will contain `X-Plex-Token=XXXX` — copy that value. Or follow: https://support.plex.tv/articles/204059436-finding-an-authentication-token-x-plex-token/ -### 2. Configure +### 2. Generate an encryption key + +```bash +python -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())" +``` + +Save the output — you'll need it for the `ENCRYPTION_KEY` variable. + +### 3. Configure ```bash cp .env.example .env -# Edit .env with your PLEX_URL and PLEX_TOKEN +# Edit .env with your PLEX_URL, PLEX_TOKEN, and ENCRYPTION_KEY ``` -### 3. Run +### 4. Run ```bash docker compose up -d @@ -46,11 +54,40 @@ Dashboard will be at **http://localhost:7842** |---|---|---| | `PLEX_URL` | — | URL of your Plex server, e.g. `http://192.168.1.50:32400` | | `PLEX_TOKEN` | — | Your Plex authentication token | +| `ENCRYPTION_KEY` | — | Fernet key for encrypting sensitive settings at rest (see above) | | `PORT` | `7842` | Host port for the dashboard | | `POLL_INTERVAL` | `30` | Seconds between Plex polls | | `OUTLIER_THRESHOLD` | `0.10` | A country must account for <10% of sessions to be flagged | | `OUTLIER_MIN_SESSIONS` | `5` | Minimum sessions before outlier detection kicks in | +> If `ENCRYPTION_KEY` is not set, a temporary key is generated in memory. Encrypted settings (e.g. your Plex token stored via the UI) will be lost on container restart. + +--- + +## Docker Image + +Pre-built images are published to GHCR on every push to `main`: + +```bash +docker pull ghcr.io/inch-high/plexgeo:latest +``` + +Available for `linux/amd64` and `linux/arm64`. + +--- + +## Unraid + +1. In the Unraid Docker tab, click **Add Container** +2. Set **Repository** to `ghcr.io/inch-high/plexgeo:latest` +3. Add a **Port** mapping: host `7842` → container `7842` +4. Add a **Path** mapping: host `/mnt/user/appdata/plexgeo` → container `/data` +5. Add **Variables**: + - `PLEX_URL` = your Plex server URL + - `PLEX_TOKEN` = your Plex token + - `ENCRYPTION_KEY` = your generated Fernet key +6. Click **Apply** + --- ## Outlier Detection Logic @@ -71,6 +108,7 @@ For each new session: - All data stored locally in a Docker volume (`plexgeo_data`) - IP addresses are sent to [ip-api.com](https://ip-api.com) for geolocation, then cached in SQLite (subsequent lookups don't re-query) - Local/private IPs (192.168.x.x etc.) are detected and never sent externally — labelled "Local Network" +- Sensitive settings (Plex token) are encrypted at rest using Fernet symmetric encryption - No external telemetry --- @@ -85,6 +123,8 @@ plexgeo/ ├── .env.example └── app/ ├── main.py # FastAPI app + scheduler + ├── config.py # Settings management + encryption + ├── crypto.py # Fernet encryption (key from ENCRYPTION_KEY env) ├── database.py # SQLite schema + queries ├── plex_poller.py # Plex session polling ├── geo.py # ip-api.com geolocation + cache diff --git a/app/crypto.py b/app/crypto.py index 199ac2f..c701d62 100644 --- a/app/crypto.py +++ b/app/crypto.py @@ -1,38 +1,40 @@ """ Fernet symmetric encryption for sensitive settings. -The encryption key is generated once and stored at KEY_PATH (default /data/secret.key). -In dev, KEY_PATH follows DB_PATH so the key lives alongside dev.db. +The encryption key is read from the ENCRYPTION_KEY environment variable. +If not set, a transient key is generated in memory (will not survive restarts, +meaning encrypted settings will become unreadable after a container restart). """ import os -import stat import logging -from pathlib import Path from cryptography.fernet import Fernet logger = logging.getLogger(__name__) -# Derive key path from DB_PATH so they always live together -_db_path = os.environ.get("DB_PATH", "/data/plexgeo.db") -KEY_PATH = os.environ.get("KEY_PATH", str(Path(_db_path).parent / "secret.key")) +_key: bytes | None = None -def _load_or_create_key() -> bytes: - path = Path(KEY_PATH) - if path.exists(): - return path.read_bytes().strip() +def _get_key() -> bytes: + global _key + if _key is not None: + return _key - key = Fernet.generate_key() - path.parent.mkdir(parents=True, exist_ok=True) - path.write_bytes(key) - # Restrict to owner read/write only - os.chmod(path, stat.S_IRUSR | stat.S_IWUSR) - logger.info(f"Generated new encryption key at {KEY_PATH}") - return key + env_key = os.environ.get("ENCRYPTION_KEY", "").strip() + if env_key: + _key = env_key.encode() + else: + _key = Fernet.generate_key() + logger.warning( + "ENCRYPTION_KEY not set — using an ephemeral key. " + "Encrypted settings will be LOST on restart. " + "Generate a permanent key with: " + 'python -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())"' + ) + return _key def _fernet() -> Fernet: - return Fernet(_load_or_create_key()) + return Fernet(_get_key()) def encrypt(plaintext: str) -> str: diff --git a/dev.db b/dev.db deleted file mode 100644 index a4a6d57..0000000 Binary files a/dev.db and /dev/null differ diff --git a/docker-compose.yml b/docker-compose.yml index 4f8aa66..3ef4c94 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,5 +1,6 @@ services: plexgeo: + image: ghcr.io/inch-high/plexgeo:latest build: . container_name: plexgeo restart: unless-stopped @@ -11,6 +12,8 @@ services: - POLL_INTERVAL=${POLL_INTERVAL:-30} - OUTLIER_THRESHOLD=${OUTLIER_THRESHOLD:-0.10} - OUTLIER_MIN_SESSIONS=${OUTLIER_MIN_SESSIONS:-5} + # Generate with: python -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())" + - ENCRYPTION_KEY=${ENCRYPTION_KEY} volumes: - plexgeo_data:/data