diff --git a/.env b/.env.example similarity index 82% rename from .env rename to .env.example index ede07b7..ef22406 100644 --- a/.env +++ b/.env.example @@ -53,3 +53,9 @@ BASIC_AUTH_PASS=CHANGE_ME # ===== Polling ===== POLL_SECONDS=5 + +# ===== SSH host key verification (optional) ===== +# Path to known_hosts file inside container. If present, strict host key +# checking is used. If absent, all host keys are accepted (less secure). +# Generate with: ssh-keyscan -p 22 192.168.1.1 > known_hosts +# SSH_KNOWN_HOSTS=/ssh/known_hosts diff --git a/README.md b/README.md index 7f293a0..5fc72db 100644 --- a/README.md +++ b/README.md @@ -11,7 +11,7 @@ Web GUI to: ## Files - `docker-compose.yml` – stack -- `.env.example` – copy to `.env` and fill values +- `.env.example` – copy to `.env` and fill in your values (**never commit `.env`!**) - `jd-webgui/app.py` – FastAPI web app - `jd-webgui/Dockerfile` – includes ffprobe @@ -40,6 +40,16 @@ docker compose up -d --build - If `MYJD_DEVICE` is empty, the WebGUI will automatically pick the first available device. - Ensure the SSH user can write to `/jellyfin/Filme` (and series dir if used). +## Security +- **Never commit `.env`** – it contains passwords and API keys. Only `.env.example` is tracked. +- **SSH host key verification**: For secure SFTP transfers, provide a `known_hosts` file: + ```bash + ssh-keyscan -p 22 192.168.1.1 > known_hosts + ``` + Mount it in `docker-compose.yml` and set `SSH_KNOWN_HOSTS=/ssh/known_hosts`. + Without it, any host key is accepted (MITM risk on untrusted networks). +- **Basic Auth** protects the WebGUI but transmits credentials in cleartext over HTTP. Use a reverse proxy with HTTPS (e.g. Traefik, Caddy) in production. + ## Troubleshooting - Device not found: list devices ```bash diff --git a/jd-webgui/app.py b/jd-webgui/app.py index 4c04770..5de0991 100644 --- a/jd-webgui/app.py +++ b/jd-webgui/app.py @@ -3,6 +3,7 @@ from __future__ import annotations import base64 import hashlib +import html as html_mod import json import os import re @@ -63,6 +64,16 @@ URL_RE = re.compile(r"^https?://", re.I) NO_PROXY_OPENER = urllib.request.build_opener(urllib.request.ProxyHandler({})) +def esc(s: str) -> str: + """HTML-escape a string to prevent XSS.""" + return html_mod.escape(str(s), quote=True) + +def mask_secret(value: str, visible: int = 4) -> str: + """Mask a secret string, showing only the last `visible` characters.""" + if len(value) <= visible: + return "***" + return "***" + value[-visible:] + VIDEO_EXTS = { ".mkv", ".mp4", ".m4v", ".avi", ".mov", ".wmv", ".flv", ".webm", ".ts", ".m2ts", ".mts", ".mpg", ".mpeg", ".vob", ".ogv", @@ -219,6 +230,29 @@ def is_video_file(path: str) -> bool: return False return ext in VIDEO_EXTS +DEMO_PATTERNS = {"big_buck_bunny", "bigbuckbunny", "big buck bunny", "bbb_sunflower"} + +def is_demo_link(name: str) -> bool: + """Detect JDownloader demo/fallback videos (e.g. Big Buck Bunny).""" + lower = name.lower().replace("-", "_").replace(".", " ") + return any(pat in lower for pat in DEMO_PATTERNS) + +def check_url_reachable(url: str) -> Optional[str]: + """Try a HEAD request to verify the URL is reachable. Returns error string or None.""" + try: + req = urllib.request.Request(url, method="HEAD") + req.add_header("User-Agent", "Mozilla/5.0") + with urllib.request.urlopen(req, timeout=10) as resp: + if resp.status >= 400: + return f"URL antwortet mit HTTP {resp.status}" + except urllib.error.HTTPError as e: + return f"URL nicht erreichbar: HTTP {e.code}" + except urllib.error.URLError as e: + return f"URL nicht erreichbar: {e.reason}" + except Exception as e: + return f"URL-Check fehlgeschlagen: {e}" + return None + def md5_file(path: str) -> str: h = hashlib.md5() with open(path, "rb") as f: @@ -262,10 +296,17 @@ def ffprobe_ok(path: str) -> bool: # ============================================================ # SSH/SFTP # ============================================================ +SSH_KNOWN_HOSTS = os.environ.get("SSH_KNOWN_HOSTS", "/ssh/known_hosts") + def ssh_connect() -> paramiko.SSHClient: ssh = paramiko.SSHClient() - ssh.set_missing_host_key_policy(paramiko.AutoAddPolicy()) - log_connection(f"SSH connect {JELLYFIN_USER}@{JELLYFIN_HOST}:{JELLYFIN_PORT}") + if os.path.isfile(SSH_KNOWN_HOSTS): + ssh.load_host_keys(SSH_KNOWN_HOSTS) + ssh.set_missing_host_key_policy(paramiko.RejectPolicy()) + log_connection(f"SSH connect {JELLYFIN_USER}@{JELLYFIN_HOST}:{JELLYFIN_PORT} (known_hosts verified)") + else: + ssh.set_missing_host_key_policy(paramiko.AutoAddPolicy()) + log_connection(f"SSH connect {JELLYFIN_USER}@{JELLYFIN_HOST}:{JELLYFIN_PORT} (WARNING: no known_hosts, accepting any host key)") ssh.connect( hostname=JELLYFIN_HOST, port=JELLYFIN_PORT, @@ -310,9 +351,19 @@ def remote_md5sum(ssh: paramiko.SSHClient, remote_path: str) -> str: # ============================================================ # TMDB & naming # ============================================================ +def _sanitize_url_for_log(url: str) -> str: + """Remove sensitive query params (api_key) from URLs before logging.""" + parsed = urllib.parse.urlparse(url) + params = urllib.parse.parse_qs(parsed.query, keep_blank_values=True) + for key in ("api_key", "apikey", "token"): + if key in params: + params[key] = ["***"] + safe_query = urllib.parse.urlencode(params, doseq=True) + return urllib.parse.urlunparse(parsed._replace(query=safe_query)) + def _http_get_json(url: str, headers: Optional[Dict[str, str]] = None) -> Any: req = urllib.request.Request(url, headers=headers or {}) - log_connection(f"HTTP GET {url} (no-proxy)") + log_connection(f"HTTP GET {_sanitize_url_for_log(url)} (no-proxy)") with NO_PROXY_OPENER.open(req, timeout=20) as r: return json.loads(r.read().decode("utf-8", "replace")) @@ -908,17 +959,18 @@ def render_job_rows() -> str: cancel_html = "" if j.status not in {"finished", "failed", "canceled"}: cancel_html = ( - f"
" ) + status_class = "error" if j.status == "failed" else ("success" if j.status == "finished" else "") rows += ( f"{j.id}{esc(j.id)}{error}
" if error else "" + err_html = f"{esc(error)}
" if error else "" auth_note = "aktiv" if _auth_enabled() else "aus" return f""" @@ -991,6 +1043,10 @@ def render_page(error: str = "") -> str: {rows} + +