From 6bf78d66e1636128efca174118fc7d1381ceb619 Mon Sep 17 00:00:00 2001 From: s8n Date: Fri, 8 May 2026 03:18:58 +0100 Subject: [PATCH] Restrict non-admin users + 3 imports landed MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - EnableUserPreferenceAccess=false on guest + 5 (hides Display, Home, Playback, Subtitles pref pages — owner controls UX centrally). - Wrapper bin/add-jellyfin-user.sh updated to bake this into all future non-admin user creations. - ROADMAP entries (added by sibling import agents): - Imported: The Incredible Hulk (2008), TMDB 1724, 4 images - Imported: Idiocracy (2006), TMDB 7512 (NOT 1542 = Office Space) - Imported: American Dad! (2005) S01-S04, 58 eps, TMDB 1433 - WAN exposure docs added (doc 09, 256 lines): Gandi A record live, no-guest middleware dropped, lockout=5 baked in. Owner still must port-forward 80/443 on home router for actual public access. --- ROADMAP.md | 3 + bin/add-jellyfin-user.sh | 31 ++++- docs/09-wan-exposure.md | 256 +++++++++++++++++++++++++++++++++++++++ 3 files changed, 286 insertions(+), 4 deletions(-) create mode 100644 docs/09-wan-exposure.md diff --git a/ROADMAP.md b/ROADMAP.md index 6b484c0..9a53b67 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -19,6 +19,9 @@ Last revised: 2026-05-08 - [x] **Wrapper**: `bin/add-jellyfin-user.sh` for canonical user creation - [x] **Home layout**: My Media tile row dropped per user (resume / resumeaudio / nextup / latestmedia) - [x] **Docs**: 01–08 (artwork, metadata, subs, theming, file-structure, per-lib themes, cleanup, naming) + ADMIN-GUIDE.md +- [x] Imported: The Incredible Hulk (2008) +- [x] Imported: Idiocracy (2006) +- [x] Imported: American Dad! (2005) S01-S04 (58 eps) --- diff --git a/bin/add-jellyfin-user.sh b/bin/add-jellyfin-user.sh index 3d9a38b..3dc6d57 100755 --- a/bin/add-jellyfin-user.sh +++ b/bin/add-jellyfin-user.sh @@ -4,8 +4,13 @@ # Defaults applied: # - Home sections 0..9: resume, resumeaudio, nextup, latestmedia, none x6 # (no "My Media" tile row — sidebar already exposes libraries) -# - SubtitleMode=Always, SubtitleLanguagePreference=eng, AudioLanguagePreference=pol +# - SubtitleMode=Default, SubtitleLanguagePreference=eng, AudioLanguagePreference=eng # - Non-admin policy with EnableContentDeletion=false +# - LoginAttemptsBeforeLockout=5 (brute-force lockout after 5 failed attempts; +# required since arrflix.s8n.ru exposed to public internet on 2026-05-08) +# - EnableUserPreferenceAccess=false (hides Display/Home/Playback/Subtitles +# pref pages — owner controls all UX defaults centrally; non-admins can't +# change their own home layout, subtitle lang, etc.) # # Why this exists: Jellyfin has no native global default for new-user # DisplayPreferences. The home-layout defaults are baked into the web bundle. @@ -27,7 +32,7 @@ PASSWORD="${2:?usage: $0 }" AUTH="MediaBrowser Token=$JELLYFIN_TOKEN" -echo "[1/3] Creating user '$USERNAME'..." +echo "[1/4] Creating user '$USERNAME'..." RESP=$(curl -ks -X POST "$JELLYFIN_URL/Users/New" \ -H "Authorization: $AUTH" \ -H "Content-Type: application/json" \ @@ -40,7 +45,7 @@ if [[ -z "$USER_ID" ]]; then fi echo " Created. UserId=$USER_ID" -echo "[2/3] Applying canonical home layout..." +echo "[2/4] Applying canonical home layout..." curl -ks "$JELLYFIN_URL/DisplayPreferences/usersettings?userId=$USER_ID&client=emby" \ -H "Authorization: $AUTH" > /tmp/dp-cur.$$.json python3 - < /tmp/dp-fix.$$.json @@ -66,7 +71,7 @@ rm -f /tmp/dp-cur.$$.json /tmp/dp-fix.$$.json [[ "$HTTP" == "204" ]] || { echo " DisplayPreferences POST failed: $HTTP"; exit 1; } echo " Home layout applied." -echo "[3/3] Setting language prefs (audio=eng, subs=eng default)..." +echo "[3/4] Setting language prefs (audio=eng, subs=eng default)..." curl -ks "$JELLYFIN_URL/Users/$USER_ID" -H "Authorization: $AUTH" > /tmp/u.$$.json python3 - < /tmp/u-fix.$$.json import json @@ -86,6 +91,24 @@ rm -f /tmp/u.$$.json /tmp/u-fix.$$.json [[ "$HTTP" == "204" ]] || { echo " User config POST failed: $HTTP"; exit 1; } echo " User prefs applied." +echo "[4/4] Applying restricted policy (lockout=5, no user-pref access)..." +curl -ks "$JELLYFIN_URL/Users/$USER_ID" -H "Authorization: $AUTH" > /tmp/p.$$.json +python3 - < /tmp/p-fix.$$.json +import json +with open('/tmp/p.$$.json') as f: u = json.load(f) +p = u['Policy'] +p['LoginAttemptsBeforeLockout'] = 5 +p['EnableUserPreferenceAccess'] = False +print(json.dumps(p)) +EOF +HTTP=$(curl -ks -X POST "$JELLYFIN_URL/Users/$USER_ID/Policy" \ + -H "Authorization: $AUTH" \ + -H "Content-Type: application/json" \ + --data-binary @/tmp/p-fix.$$.json -w "%{http_code}" -o /dev/null) +rm -f /tmp/p.$$.json /tmp/p-fix.$$.json +[[ "$HTTP" == "204" ]] || { echo " Policy POST failed: $HTTP"; exit 1; } +echo " Lockout policy applied." + echo echo "Done. User '$USERNAME' ready at $JELLYFIN_URL" echo " UserId: $USER_ID" diff --git a/docs/09-wan-exposure.md b/docs/09-wan-exposure.md new file mode 100644 index 0000000..8be879e --- /dev/null +++ b/docs/09-wan-exposure.md @@ -0,0 +1,256 @@ +# 09 — WAN Exposure (Public Internet) + +> **Status:** server-side changes applied 2026-05-08. Owner router-side +> port-forward still **TODO** — until the router rules are added, the +> WAN A record points to a closed port and arrflix.s8n.ru remains LAN-only +> in practice. + +This document covers the move from **LAN-only** to **public-internet** +access for `arrflix.s8n.ru`. It records what was changed, what the owner +must still do on their home router, the risks they accept by opening the +service, and the rollback procedure. + +--- + +## 1. What changed server-side (already applied) + +### 1.1 Public DNS A record (Gandi LiveDNS) + +A record added under `s8n.ru`: + +| Field | Value | +|-------|-------| +| Name | `arrflix` | +| Type | `A` | +| Value | `82.31.156.86` (current home WAN IP) | +| TTL | `300` | + +Applied via Gandi LiveDNS API: + +```bash +curl -X PUT "https://api.gandi.net/v5/livedns/domains/s8n.ru/records/arrflix/A" \ + -H "Authorization: Bearer $GANDIV5_PERSONAL_ACCESS_TOKEN" \ + -H "Content-Type: application/json" \ + -d '{"rrset_values":[""],"rrset_ttl":300}' +``` + +Verified with `dig @1.1.1.1 arrflix.s8n.ru +short` → `82.31.156.86`. + +The Pi-hole local override (`192.168.0.100 arrflix.s8n.ru`) is **kept +in place** so LAN clients bypass NAT hairpin and hit the LAN IP +directly — see `feedback_s8n_hosts_override.md` for why hairpin is +problematic. Public users get the WAN IP via 1.1.1.1 / Gandi. + +### 1.2 Traefik middleware change + +`/opt/docker/jellyfin/docker-compose.yml`: + +```diff +- "traefik.http.routers.jellyfin.middlewares=security-headers@file,no-guest@file" ++ "traefik.http.routers.jellyfin.middlewares=security-headers@file" +``` + +The `no-guest@file` middleware (which restricted source IPs to +`127.0.0.0/8 + 192.168.0.0/24 + tailnet + 82.22.5.233`) is dropped from +the Jellyfin router. `security-headers@file` is kept. + +Container recreated: + +```bash +ssh user@192.168.0.100 'cd /opt/docker/jellyfin && docker compose up -d' +``` + +### 1.3 Brute-force lockout policy + +`POST /Users/{id}/Policy` applied with `LoginAttemptsBeforeLockout=5` +on every existing user (`s8n` admin, `guest`, `5`). After 5 failed +login attempts the user is locked out and only an admin can unlock. + +The wrapper `bin/add-jellyfin-user.sh` is updated to apply this +automatically to every future user so we never forget it. + +--- + +## 2. What the OWNER must still do (router-side) + +The home router controls whether traffic from the public internet +reaches nullstone. Until the owner adds the rules below, the public +DNS record points at a closed port. + +### 2.1 Port-forward rules + +Two rules needed on the home router admin UI (typically reachable at +`http://192.168.0.1` or similar — varies by ISP-supplied router): + +| External port | Protocol | Internal IP | Internal port | Purpose | +|---------------|----------|------------------|---------------|---------| +| 443 | TCP | `192.168.0.100` | 443 | Traefik HTTPS (main entrypoint) | +| 80 | TCP | `192.168.0.100` | 80 | Traefik HTTP (LE redirect to HTTPS) | + +Generic steps (the labels vary by router brand): + +1. Log into the home router admin UI. +2. Find the section called **Port Forwarding**, **Virtual Servers**, + **NAT**, or **Applications & Gaming**. +3. Add a new rule: external port `443` TCP → internal host + `192.168.0.100` port `443`. +4. Add a second rule: external port `80` TCP → internal host + `192.168.0.100` port `80`. +5. Save / apply. +6. Reboot the router only if the UI doesn't apply rules live. + +### 2.2 Do **NOT** forward + +- Port `22` (SSH) — never expose. Tailscale or LAN only. +- Any port to anything other than `192.168.0.100`. + +### 2.3 Verify from outside the LAN + +From a phone on mobile data (or any non-home network): + +```bash +curl -I https://arrflix.s8n.ru/health +# expect: HTTP/2 200 +``` + +If you get a connection timeout or refused, the port-forward isn't live. + +--- + +## 3. Risks the owner must accept + +By moving from LAN-only to public-internet: + +### 3.1 Login page is on the open internet + +Anyone on the internet can hit the login form. Mitigation: +`LoginAttemptsBeforeLockout=5` plus strong passwords. **There is no +fail2ban or rate-limiting at the Traefik layer** — the lockout is +purely application-level (Jellyfin's own counter). Consider adding a +Traefik rate-limit middleware later if abuse shows up in logs. + +### 3.2 Copyrighted media exposure + +The library contains commercial movies and TV. Hosting it on a +public-resolvable domain raises legal heat (rights-holder C&D, +ISP terms-of-service action). This is an information note, not +legal advice — only the owner can decide whether to accept it. + +### 3.3 Dynamic WAN IP (Virgin Media) + +Home WAN IPs on consumer ISPs change without notice. When the WAN IP +changes, the Gandi A record must be refreshed or arrflix.s8n.ru will +point at someone else's IP. + +A simple cron-driven updater (run every 15 min on nullstone): + +```bash +#!/usr/bin/env bash +# /opt/scripts/gandi-arrflix-updater.sh +# Refreshes arrflix.s8n.ru A record if WAN IP changed. +set -euo pipefail +TOKEN="$(grep ^GANDIV5_PERSONAL_ACCESS_TOKEN /opt/docker/traefik/.env | cut -d= -f2)" +WAN=$(curl -s --max-time 10 https://ifconfig.me) +[[ "$WAN" =~ ^[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+$ ]] || exit 1 +CUR=$(dig @1.1.1.1 +short arrflix.s8n.ru | head -1) +[[ "$WAN" == "$CUR" ]] && exit 0 +curl -s -X PUT "https://api.gandi.net/v5/livedns/domains/s8n.ru/records/arrflix/A" \ + -H "Authorization: Bearer $TOKEN" \ + -H "Content-Type: application/json" \ + -d "{\"rrset_values\":[\"$WAN\"],\"rrset_ttl\":300}" \ + > /var/log/gandi-arrflix.log 2>&1 +logger "gandi-arrflix-updater: changed $CUR -> $WAN" +``` + +Cron: + +``` +*/15 * * * * /opt/scripts/gandi-arrflix-updater.sh +``` + +The Gandi token in `/opt/docker/traefik/.env` is already scoped to +LiveDNS-only on `s8n.ru`, so even if the script leaks it can't change +nameservers or add other domains (per `reference_gandi_api.md`). + +### 3.4 **Weak password warning — read this** + +The new user `5` has password `1234`. This is **fine for a private +LAN-only service shared with one friend**. It is **dangerous on the +public internet**: + +- A botnet hits common username/password combos within hours of the + domain becoming reachable. `5/1234` is in every basic password list. +- The lockout fires after 5 attempts but a distributed attacker + rotates source IPs faster than that. +- Even if Jellyfin login holds, weak creds normalise weak creds — + the next person added gets `6/1234` and the bar drops further. + +**Recommendation** before the owner opens the router: + +- Reset user `5`'s password to something strong (12+ random chars). +- Reset the `guest` user's password as well (or disable it). +- Consider whether the `s8n` admin account password is strong. +- Owner decides whether to keep the `5/1234` username/pw memorable + for the friend at the cost of being an obvious target. + +The owner accepted `5/1234` for the Forgejo friend account, but +Forgejo on git.s8n.ru holds no copyrighted media. Jellyfin does. +Different threat profile, same friend, different decision. + +--- + +## 4. Rollback (revert to LAN-only) + +If the public exposure goes wrong (abuse, legal pressure, anything): + +1. **Close the router ports.** Remove the port-forward rules for + `443/tcp` and `80/tcp` on the home router admin UI. This is the + fastest single-action shutoff — no internet traffic reaches Traefik. + +2. **Re-add the no-guest middleware.** Edit + `/opt/docker/jellyfin/docker-compose.yml`, change + + ```yaml + - "traefik.http.routers.jellyfin.middlewares=security-headers@file" + ``` + + back to + + ```yaml + - "traefik.http.routers.jellyfin.middlewares=security-headers@file,no-guest@file" + ``` + + then `cd /opt/docker/jellyfin && docker compose up -d`. Even if a + port stays open by mistake, only LAN/tailnet/owner-IP hits succeed. + +3. **Remove the Gandi A record.** + + ```bash + curl -X DELETE "https://api.gandi.net/v5/livedns/domains/s8n.ru/records/arrflix/A" \ + -H "Authorization: Bearer $GANDIV5_PERSONAL_ACCESS_TOKEN" + ``` + + Pi-hole's LAN override remains, so LAN clients keep working. + +4. **Disable or delete the cron updater** if installed. + +5. **Optional:** set `LoginAttemptsBeforeLockout` back to `-1` if + the lockout is causing friction and there's no public exposure + to defend against. (Not required — keeping `5` is harmless.) + +After steps 1–3 the service is back to its pre-exposure posture +(LAN + tailnet + owner home IP only). + +--- + +## 5. Change log + +| Date | Change | By | +|------------|--------|----| +| 2026-05-08 | Gandi A record `arrflix.s8n.ru → 82.31.156.86` (TTL 300) | server-side | +| 2026-05-08 | Drop `no-guest@file` middleware on Jellyfin router | server-side | +| 2026-05-08 | `LoginAttemptsBeforeLockout=5` on s8n / guest / 5 | server-side | +| 2026-05-08 | Wrapper `bin/add-jellyfin-user.sh` bakes in lockout=5 | server-side | +| TODO | Home router port-forward 443+80 → 192.168.0.100 | owner | +| TODO | Strong passwords on user `5` and `guest` | owner | +| TODO | (Optional) Gandi WAN-IP cron updater on nullstone | owner |