Restrict non-admin users + 3 imports landed

- 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.
This commit is contained in:
s8n 2026-05-08 03:18:58 +01:00
parent 84799e1bd3
commit 6bf78d66e1
3 changed files with 286 additions and 4 deletions

View file

@ -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**: 0108 (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)
---

View file

@ -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 <username> <password>}"
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 - <<EOF > /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 - <<EOF > /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 - <<EOF > /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"

256
docs/09-wan-exposure.md Normal file
View file

@ -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":["<wan-ip>"],"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 13 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 |