From 45a09ae9a61826ede98c9ed4eeb97bbff46ad84d Mon Sep 17 00:00:00 2001 From: Inch-high Date: Wed, 1 Apr 2026 20:40:29 +0100 Subject: [PATCH] feat: move encryption key to env var, add GHCR publishing and Unraid support Move Fernet encryption key from filesystem (secret.key) to ENCRYPTION_KEY environment variable for better security separation. If unset, an ephemeral key is generated with a warning. - Add Docker healthcheck, OCI labels, and multi-arch GHCR CI workflow - Add Unraid setup instructions to README - Remove dev.db and secret.key from tracking, update .gitignore Co-Authored-By: Claude Opus 4.6 (1M context) --- .github/workflows/docker-publish.yml | 56 +++++++++++++++++++++++++++ .gitignore | 6 +++ Dockerfile | 9 +++++ README.md | 48 +++++++++++++++++++++-- app/crypto.py | 40 ++++++++++--------- dev.db | Bin 57344 -> 0 bytes docker-compose.yml | 3 ++ 7 files changed, 139 insertions(+), 23 deletions(-) create mode 100644 .github/workflows/docker-publish.yml delete mode 100644 dev.db 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 a4a6d57e6839f4c02c1cdc8cdf2556daed27d63d..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 57344 zcmeI*Pi))P9S3kyvSiEu7^4VOBQVA;8*I(7tbcYC4?*kLa_U5q9a-M&UIkswgdygMJ{+>w6^2Vd0ViB>S8~d^)X1Qw|&vS2xBFAyd^mmm0vT2Y$=wnalJ@0wk z>)|q&Jov@P;D_8u-&1b*<6(K|%b{NlO%6U8_-gRS(Z5805{>sgjr_gW;+PKs2tc5> zzUnX1D6HMd}jG&ehdYVWYZOTJK(PX|qx(l&jKdh_B6w*^y{`X@chztxk^2 zcT}44CE2$0_H&7`NNM(Hv@z5l|Nc0C5@|;$^&?4EiD8+NT-($1LzUEbN!^__+#y~q zFBi7N9--n|S#-mvyd@;I2cz-qIB!2+jcpP`llO_c%uuf?SeU1HHY@S|w`2YB@p1m= z-)i>^w#Swsk1KO+_R15=^5hUW_4|~OiL?K{D5uOS&M+K(jBa{B)MKU zh-o_EzJApmT+?mMGLEI1UZ?%(e++g&FATO#TUKeg?eZ@w*`%#ibWL)lm{6s#QlPzY zsjxx!xORX!rPLDvYhe)9x1Cjff$rU1mbY7-ZLID+%)ev2_oM!H`uOR?b|<0RpeEOv zgxwGLItrT`)K$D2@8SgF+k7vw^)8B4tkX2s-gezis%$Bi?erQt(d1YR2+_6O)0p58 z+D&!!z)edpwBh{xvjgYlCFU)q*`pX82nL+NK#TUrU!UMlMotq4>uId(ws#FSv%f2W zuvdKHP8wk^OnJ9aq8Xw2kk)sW4_TaKTC(9LXf`X_ySB4nUtMRj6MKFA@w>F9SofNB z%9rR^#j1*&Q&nf*bVfGsdU;Ao$l+-G_5}ZKcU>%vvWoh8SHwbnwXm6q^-wfEJaCFYl=acFA`TO@aHF|BMEmLlLMpwG$tLG-=QEl4=~QYV zmGZxy&Mjo}$@JWOE}L>+KS}YlrQC>rglLii_oS-LS+T5>1SrIC2*twVG52!` z?(zRKPWVE2_RU^Fv>pNwfB*y_009U<00Izz00bZa0gpg5JjUm|8v)_#L8hMAOHafKmY;|fB*y_009U< z00I!`A>ds9$MJs;9L$CQ1Rwwb2tWV=5P$##AOHafToC~r|6h>}iPeAr1Rwwb2tWV= z5P$##AOHafoFm|T{y#cuaKayj$f(m0k(Q*FqqNdk&Jxe