#!/usr/bin/env python3 """Simple NAS - Flask Web GUI v2.0""" import hashlib import hmac import ipaddress import json import os import random import secrets import subprocess import re import shutil import time import socket import threading from datetime import timedelta from flask import Flask, jsonify, request, render_template, session, redirect, url_for from werkzeug.security import generate_password_hash, check_password_hash import safepath import sharing_store import share_web import accesslog import zipstream import clamav import sharesandbox import share_worker import fileicons import wopi import remote_backup import services import trim from ratelimit import LIMITER, AUTHFAIL_IP, AUTHFAIL_LINK, LOCK_BASE, LOCK_CAP, Limiter # ── Admin-Login: Brute-Force-Schutz ───────────────────────────────────────── # Eigener Limiter, damit Admin-Sperren nie mit denen der Freigabe-Seite # vermischt werden (die laeuft ggf. abgeschottet in einem eigenen Prozess). ADMIN_LIMITER = Limiter() ADMIN_FAIL_IP = (10, 15 * 60) # Fehlversuche je Besucher-IP ADMIN_FAIL_USER = (20, 15 * 60) # je Benutzername, ueber alle IPs (verteilte Angriffe) INGRESS_IP = "172.30.32.2" # Supervisor: HA-Ingress kommt immer von hier DEFAULT_TRUSTED_PROXIES = ["127.0.0.1", "::1", "172.30.32.0/23"] app = Flask(__name__) MAX_UPLOAD_BYTES = 4 * 1024 * 1024 * 1024 # 4 GB per request; bodies are buffered to TMPDIR first app.config['MAX_CONTENT_LENGTH'] = MAX_UPLOAD_BYTES DATA_DIR = "/data" CONFIG_BACKUP_DIR = "/config/.simplenas" CONFIG_AUTO_DIR = f"{CONFIG_BACKUP_DIR}/auto" # auto-backup (reinstall-safe, always overwritten) CONFIG_BACKUPS_DIR = f"{CONFIG_BACKUP_DIR}/backups" # manual snapshots MAX_MANUAL_BACKUPS = 10 SHARES_FILE = f"{DATA_DIR}/shares.json" USERS_FILE = f"{DATA_DIR}/users.json" MOUNTS_FILE = f"{DATA_DIR}/mounts.json" GROUPS_FILE = f"{DATA_DIR}/groups.json" FSTYPE_MEMORY_FILE = f"{DATA_DIR}/fstype_memory.json" ADMIN_AUTH_FILE = f"{DATA_DIR}/admin_auth.json" WORKGROUP = os.environ.get("WORKGROUP", "WORKGROUP") NAS_NAME = os.environ.get("NAS_NAME", "SimpleNAS") SMB_PORT = os.environ.get("SMB_PORT", "445") # ─────────────────────────── admin auth ───────────────────────── _admin_auth = {"enabled": False} def _session_key(): """Persistent Flask secret, so logins survive an add-on restart.""" os.makedirs(DATA_DIR, exist_ok=True) auth = load_json(ADMIN_AUTH_FILE, {}) if not auth.get("secret_key"): auth["secret_key"] = secrets.token_hex(32) save_json(ADMIN_AUTH_FILE, auth) return auth["secret_key"], auth def _auth_epoch(password_hash): """Fingerprint of the current password. Stored in the session so that changing the password in the add-on options logs everyone out.""" return hashlib.sha256((password_hash or "").encode()).hexdigest()[:16] def _setup_admin_auth(): """Read admin auth settings from env vars (set by HA from config.yaml) and store hashed password.""" global _admin_auth # Cookie hardening. Note: SESSION_COOKIE_SECURE stays off on purpose - HA # Ingress talks plain HTTP to the add-on, and a Secure cookie would never # be sent back, locking the user out of the panel. app.config.update( SESSION_COOKIE_HTTPONLY=True, SESSION_COOKIE_SAMESITE="Lax", PERMANENT_SESSION_LIFETIME=timedelta(hours=12), ) enabled = os.environ.get("ADMIN_PASSWORD_ENABLED", "false").lower() in ("true", "1", "yes") if not enabled: # Still needs a key: the CSRF token lives in the session either way. app.secret_key, _ = _session_key() _admin_auth = {"enabled": False} return username = os.environ.get("ADMIN_USERNAME", "admin").strip() or "admin" password = os.environ.get("ADMIN_PASSWORD", "").strip() key, auth = _session_key() app.secret_key = key if not password: # Protection was switched on but no password was given. Previously the # add-on silently disabled the protection here and served everything to # the LAN. Now it locks itself down until a password is configured. _admin_auth = {"enabled": True, "setup_required": True, "username": username} print("[AUTH] Passwortschutz ist aktiv, aber kein Passwort gesetzt - " "Oberflaeche gesperrt bis eines konfiguriert ist.", flush=True) return auth["enabled"] = True auth["username"] = username auth["password_hash"] = generate_password_hash(password) save_json(ADMIN_AUTH_FILE, auth) _admin_auth = auth def _base(): """Return the HA Ingress base path (e.g. /api/hassio_ingress/TOKEN) or '' for direct access.""" return request.headers.get("X-Ingress-Path", "").rstrip("/") def _trusted_nets(): nets = [] for n in _opt("share_trusted_proxies", DEFAULT_TRUSTED_PROXIES) or []: try: nets.append(ipaddress.ip_network(str(n), strict=False)) except ValueError: pass return nets def _from_trusted_proxy(): try: ip = ipaddress.ip_address(request.remote_addr or "") except ValueError: return False return any(ip in n for n in _trusted_nets()) def _via_ingress(): """Request came through the HA Ingress (user already logged in to HA). The address cannot be forged from the LAN: TCP needs the reply.""" return (request.remote_addr or "") == INGRESS_IP def _client_ip(): """The real visitor address. Proxy headers count only when the request really came from a trusted proxy (share_trusted_proxies) - otherwise anyone could dodge a lockout with a made-up X-Forwarded-For. Behind a Cloudflare tunnel (cloudflared -> Nginx Proxy Manager -> here) X-Forwarded-For only names the tunnel; the visitor is in CF-Connecting-IP.""" if _from_trusted_proxy(): cf = request.headers.get("CF-Connecting-IP", "").strip() if cf: return cf xff = request.headers.get("X-Forwarded-For", "") if xff: return xff.split(",")[-1].strip() or request.remote_addr or "?" return request.remote_addr or "?" def _admin_lock_keys(username): keys = [(f"ip:{_client_ip()}", ADMIN_FAIL_IP)] if username: keys.append((f"user:{username.lower()}", ADMIN_FAIL_USER)) return keys def _admin_lock_remaining(keys): return max([ADMIN_LIMITER.ban_remaining(k) for k, _ in keys if ADMIN_LIMITER.banned(k)] or [0]) def _admin_login_failed(keys): for key, (limit, window) in keys: ADMIN_LIMITER.record(key) if ADMIN_LIMITER.count(key, window) >= limit: dur = ADMIN_LIMITER.lock(key, LOCK_BASE, LOCK_CAP) print(f"[AUTH LOCK] Admin-Login gesperrt fuer {key} ({dur // 60} min)", flush=True) SAFE_METHODS = ("GET", "HEAD", "OPTIONS") def csrf_token(): """Per-session token. Issued on the first request, then reused.""" tok = session.get("csrf") if not tok: tok = secrets.token_urlsafe(32) session["csrf"] = tok return tok @app.before_request def check_auth(): # 0. Optional: admin UI only through the HA Ingress. Direct LAN access # (http://HA-IP:8100) and any reverse proxy / Cloudflare get 403. if _opt("admin_ingress_only", False) and not _via_ingress(): ip = _client_ip() if ADMIN_LIMITER.hit(f"denylog:{ip}", 1, 300)[0]: print(f"[AUTH] Direktzugriff abgewiesen (admin_ingress_only): ip={ip}", flush=True) return ("Nur ueber Home Assistant erreichbar / Only available through Home Assistant.", 403, {"Content-Type": "text/plain; charset=utf-8"}) # 1. Locked down: protection is on but no password is configured. if _admin_auth.get("setup_required"): if request.path.startswith("/api/"): return jsonify({"error": "Kein Admin-Passwort gesetzt. Bitte in der " "Addon-Konfiguration eines eintragen."}), 503 if request.endpoint != "setup_required_page": return redirect(_base() + "/setup-required") return if not _admin_auth.get("enabled"): csrf_token() return _csrf_check() if request.endpoint in ("login", "logout"): return if not session.get("authenticated"): if request.path.startswith("/api/"): return jsonify({"error": "Unauthorized"}), 401 return redirect(_base() + "/login") # 2. The password may have changed since this session was created. if session.get("auth_epoch") != _auth_epoch(_admin_auth.get("password_hash", "")): session.clear() if request.path.startswith("/api/"): return jsonify({"error": "Sitzung abgelaufen"}), 401 return redirect(_base() + "/login") csrf_token() return _csrf_check() def _csrf_check(): """Reject state-changing API calls without a matching token. SameSite=Lax already blocks cross-site form posts; this additionally covers same-site sources such as another add-on on the same host. """ if request.method in SAFE_METHODS or not request.path.startswith("/api/"): return sent = request.headers.get("X-CSRF-Token", "") if not sent or not hmac.compare_digest(sent, session.get("csrf", "")): print(f"[CSRF] abgewiesen: {request.method} {request.path} ip={_client_ip()}", flush=True) return jsonify({"error": "Sitzung ungültig, bitte Seite neu laden"}), 403 @app.route("/setup-required") def setup_required_page(): return render_template("setup_required.html", username=_admin_auth.get("username", "admin")), 503 @app.route("/login", methods=["GET", "POST"]) def login(): error = None if request.method == "POST": username = request.form.get("username", "").strip() password = request.form.get("password", "").strip() # Ingress users are already logged in to HA and cannot be an outside # attacker, so they are never locked out - the way back in if an # attack has locked the account on the direct/Cloudflare route. keys = [] if _via_ingress() else _admin_lock_keys(username) wait = _admin_lock_remaining(keys) if wait: mins = max(1, (wait + 59) // 60) print(f"[AUTH LOCK] Login abgewiesen (gesperrt, noch {mins} min): " f"user='{username}' ip={_client_ip()}", flush=True) resp = app.make_response((render_template( "login.html", error=f"Zu viele Fehlversuche - bitte in {mins} Min. erneut versuchen " f"(ueber Home Assistant geht es sofort) / Too many failed attempts - " f"try again in {mins} min (or open it through Home Assistant)"), 429)) resp.headers["Retry-After"] = str(wait) return resp if (username == _admin_auth.get("username") and check_password_hash(_admin_auth.get("password_hash", ""), password)): if keys: ADMIN_LIMITER.clear(key=keys[0][0]) # own IP counter only session.clear() # fresh session id after login session.permanent = True session["authenticated"] = True session["user"] = username session["auth_epoch"] = _auth_epoch(_admin_auth.get("password_hash", "")) csrf_token() print(f"[AUTH] Login successful: user='{username}' ip={_client_ip()}", flush=True) return redirect(_base() + "/") print(f"[AUTH FAIL] Login failed: user='{username}' ip={_client_ip()}", flush=True) _admin_login_failed(keys) time.sleep(random.uniform(0.3, 0.7)) # slows scripts, not people error = "Ungültige Anmeldedaten / Invalid credentials" return render_template("login.html", error=error) @app.route("/logout") def logout(): print(f"[AUTH] Logout: ip={_client_ip()}", flush=True) session.clear() return redirect(_base() + "/login") # ─────────────────────────── helpers ──────────────────────────── def load_json(path, default): try: with open(path) as f: return json.load(f) except Exception: return default _backup_lock = False def _copy_data_to(dest_dir): """Copy all settings from /data to dest_dir.""" import time os.makedirs(dest_dir, exist_ok=True) with open(os.path.join(dest_dir, "meta.json"), "w") as f: json.dump({"timestamp": time.time()}, f) for fname in ("shares.json", "users.json", "groups.json", "mounts.json", "backups.json", "admin_auth.json", "file_access.json", "share_links.json", "share_accounts.json", "share_auth.json", "crowdsec_setup.json", "recycle.json"): src = os.path.join(DATA_DIR, fname) if os.path.exists(src): shutil.copy2(src, os.path.join(dest_dir, fname)) # External backup jobs: without them (and the archive password in them) # a reinstall would leave the restic archive unreadable. rb_src = os.path.join(DATA_DIR, "remote_backup", "jobs.json") if os.path.exists(rb_src): os.makedirs(os.path.join(dest_dir, "remote_backup"), mode=0o700, exist_ok=True) shutil.copy2(rb_src, os.path.join(dest_dir, "remote_backup", "jobs.json")) samba_src = os.path.join(DATA_DIR, "samba") if os.path.isdir(samba_src): samba_dst = os.path.join(dest_dir, "samba") os.makedirs(samba_dst, exist_ok=True) for f in os.listdir(samba_src): shutil.copy2(os.path.join(samba_src, f), os.path.join(samba_dst, f)) def _restore_from(src_dir): """Restore settings from src_dir to /data.""" for fname in ("shares.json", "users.json", "groups.json", "mounts.json", "backups.json", "admin_auth.json", "file_access.json", "share_links.json", "share_accounts.json", "share_auth.json", "crowdsec_setup.json", "recycle.json"): src = os.path.join(src_dir, fname) if os.path.exists(src): shutil.copy2(src, os.path.join(DATA_DIR, fname)) rb_src = os.path.join(src_dir, "remote_backup", "jobs.json") if os.path.exists(rb_src): os.makedirs(os.path.join(DATA_DIR, "remote_backup"), mode=0o700, exist_ok=True) shutil.copy2(rb_src, os.path.join(DATA_DIR, "remote_backup", "jobs.json")) samba_src = os.path.join(src_dir, "samba") if os.path.isdir(samba_src): samba_dst = os.path.join(DATA_DIR, "samba") os.makedirs(samba_dst, exist_ok=True) for f in os.listdir(samba_src): shutil.copy2(os.path.join(samba_src, f), os.path.join(samba_dst, f)) def _mirroring_enabled(): """The auto-backup writes into the add-on's /config mapping. Off unless we really run from the add-on's data directory - a test run or a one-off start on a workstation must not scribble into whatever /config is there.""" return os.path.abspath(DATA_DIR) == os.path.abspath("/data") def _auto_backup(): """Sync /data to auto-backup slot (reinstall-safe, always overwritten).""" global _backup_lock if _backup_lock: return _backup_lock = True try: _copy_data_to(CONFIG_AUTO_DIR) except Exception as e: print(f"[SETTINGS-BACKUP] Auto-backup error: {e}", flush=True) finally: _backup_lock = False def save_json(path, data, mirror=True): with open(path, "w") as f: json.dump(data, f, indent=2) # Auto-sync to reinstall-safe location after every settings change. # mirror=False for things written per request (download counters). if mirror and _mirroring_enabled() and \ os.path.dirname(os.path.abspath(path)) == os.path.abspath(DATA_DIR): _auto_backup() sharing_store.init(DATA_DIR, load_json, save_json) # Run setup at import time (covers both __main__ and WSGI server invocations) _setup_admin_auth() def backup_samba_passwords(): """Copy Samba password database to /data/ for persistence across restarts.""" import glob os.makedirs("/data/samba", exist_ok=True) for src in ["/var/lib/samba/private/passdb.tdb", "/var/lib/samba/private/secrets.tdb", "/etc/samba/smbpasswd"]: if os.path.exists(src): dst = f"/data/samba/{os.path.basename(src)}" try: shutil.copy2(src, dst) except Exception: pass def reload_samba(): """Regenerate smb.conf and signal smbd to reload. Restart if needed.""" subprocess.run(["python3", "/app/generate_smb_conf.py", WORKGROUP, NAS_NAME, SMB_PORT], check=False) # Try smbcontrol first try: rc = subprocess.run(["smbcontrol", "smbd", "reload-config"], capture_output=True, timeout=5).returncode except Exception: rc = 1 if rc != 0: # smbcontrol failed - try HUP signal rc2 = subprocess.run(["pkill", "-HUP", "smbd"], capture_output=True).returncode if rc2 != 0: # smbd not running at all - restart it subprocess.Popen(["smbd", "--foreground", "--no-process-group"], stdout=open("/var/log/samba/smbd.log", "a"), stderr=subprocess.STDOUT) subprocess.Popen(["nmbd", "--foreground", "--no-process-group"], stdout=open("/var/log/samba/nmbd.log", "a"), stderr=subprocess.STDOUT) publish_timemachine() if _WEBDAV["app"] is not None: _WEBDAV["app"].refresh() # Laufwerk ein-/ausgehaengt, Freigabe geaendert if _FTP["shares"] is not None: _FTP["shares"].refresh() # ── WebDAV (Issue #12) ── # Die Freigaben ueber HTTP(S), mit WsgiDAV auf eigenem Port; Anmeldung mit Samba-Benutzern (webdav.py). _WEBDAV = {"app": None, "server": None, "port": None, "error": None, "passwords": None} def _webdav_log(event, **fields): print(f"[WEBDAV] {event} " + " ".join(f"{k}={v}" for k, v in fields.items() if v and k != "ua"), flush=True) accesslog.log(event, **fields) def start_webdav(): port = int(_opt("webdav_port", 8102) or 8102) _WEBDAV["port"] = port try: import webdav except ImportError as e: _WEBDAV["error"] = f"WsgiDAV fehlt: {e}" print(f"[WEBDAV] {_WEBDAV['error']}", flush=True) return False passwords = webdav.SambaPasswords() dav = webdav.ReloadingApp( SHARES_FILE, lambda: load_json(SHARES_FILE, []), passwords=passwords, load_groups=lambda: load_json(GROUPS_FILE, []), recycle_on=lambda: recycle_settings()["enabled"], recycle_dir=RECYCLE_DIR, trusted_nets=lambda: webdav.trusted_networks(_opt("share_trusted_proxies", DEFAULT_TRUSTED_PROXIES)), log=_webdav_log) try: server = webdav.serve(dav, port) except OSError as e: _WEBDAV["error"] = f"Port {port} ist belegt ({e}) - webdav_port aendern" print(f"[WEBDAV] FEHLER: {_WEBDAV['error']}", flush=True) return False _WEBDAV.update(app=dav, server=server, passwords=passwords, error=None) print(f"[WEBDAV] laeuft auf Port {port}", flush=True) return True def _webdav_passwords_changed(username=None): for svc in (_WEBDAV, _FTP): if svc["passwords"] is not None: svc["passwords"].invalidate() if username: # neues Passwort vom Admin: eine Sperre des Benutzernamens ist damit hinfaellig LIMITER.clear(key=f"authfail:user:{username.lower()}") @app.route("/api/settings/webdav", methods=["GET"]) def api_get_webdav(): dav = _WEBDAV["app"] # Die Sperren von WebDAV liegen im Limiter dieses Prozesses; die Freigabe-Seite laeuft meist # abgeschottet mit eigenem, darum zeigt der Teilen-Tab sie nicht snap = LIMITER.snapshot(AUTHFAIL_IP[1]) if _WEBDAV["server"] is not None else {"locks": [], "counters": []} return jsonify({"enabled": bool(_opt("webdav_enabled", False)), "running": _WEBDAV["server"] is not None, "port": _WEBDAV["port"] or int(_opt("webdav_port", 8102) or 8102), "error": _WEBDAV["error"], "shares": dav.names() if dav is not None else [], "locks": snap["locks"], "authfail": snap["counters"], "authfail_limit": AUTHFAIL_IP[0], "authfail_window_min": AUTHFAIL_IP[1] // 60}) @app.route("/api/settings/webdav/unlock", methods=["POST"]) def api_webdav_unlock(): """Sperre nach Fehlversuchen aufheben (IP-Adresse oder Benutzername), ohne auf ihr Ende zu warten.""" key = str((request.get_json(silent=True) or {}).get("key", "")).strip() if not key.startswith("authfail:"): return jsonify({"error": "Unbekannte Sperre"}), 400 LIMITER.clear(key=key) print(f"[WEBDAV] Sperre aufgehoben: {key}", flush=True) return jsonify({"ok": True, "key": key}) # ── FTP ── # Fuer Geraete, die nur FTP koennen (IP-Kameras, Scanner): die Freigaben mit pyftpdlib auf eigenem Port, # Anmeldung und Rechte wie bei WebDAV (ftp.py). Unverschluesselt - nur im Heimnetz. _FTP = {"server": None, "shares": None, "port": None, "error": None, "passwords": None} def _ftp_log(event, **fields): print(f"[FTP] {event} " + " ".join(f"{k}={v}" for k, v in fields.items() if v), flush=True) accesslog.log(event, **fields) def start_ftp(): port = int(_opt("ftp_port", 21) or 21) _FTP["port"] = port try: import ftp import webdav except ImportError as e: _FTP["error"] = f"pyftpdlib fehlt: {e}" print(f"[FTP] {_FTP['error']}", flush=True) return False passwords = webdav.SambaPasswords() shares = ftp.ShareSet(SHARES_FILE, lambda: load_json(SHARES_FILE, []), lambda: load_json(GROUPS_FILE, [])) authorizer = ftp.SambaAuthorizer(shares, passwords, log=_ftp_log, recycle_on=lambda: recycle_settings()["enabled"], recycle_dir=RECYCLE_DIR) passive = ftp.parse_passive_ports(_opt("ftp_passive_ports", ftp.DEFAULT_PASSIVE)) try: server = ftp.serve(ftp.make_handler(authorizer, passive), port) except OSError as e: _FTP["error"] = f"Port {port} ist belegt ({e}) - ftp_port aendern oder das andere FTP-Add-on stoppen" print(f"[FTP] FEHLER: {_FTP['error']}", flush=True) return False _FTP.update(server=server, shares=shares, passwords=passwords, error=None) print(f"[FTP] laeuft auf Port {port} (passiv {passive[0]}-{passive[-1]})", flush=True) return True @app.route("/api/settings/ftp", methods=["GET"]) def api_get_ftp(): # dieselbe Sperrliste wie WebDAV (gleicher Limiter, gleicher Prozess) running = _FTP["server"] is not None snap = LIMITER.snapshot(AUTHFAIL_IP[1]) if running else {"locks": [], "counters": []} return jsonify({"enabled": bool(_opt("ftp_enabled", False)), "running": running, "port": _FTP["port"] or int(_opt("ftp_port", 21) or 21), "error": _FTP["error"], "passive": str(_opt("ftp_passive_ports", "30000-30010") or "30000-30010"), "shares": _FTP["shares"].names() if _FTP["shares"] is not None else [], "locks": snap["locks"], "authfail": snap["counters"], "authfail_limit": AUTHFAIL_IP[0], "authfail_window_min": AUTHFAIL_IP[1] // 60}) # ── Time Machine (Issue #11) ── # macOS findet Backup-Ziele über den Bonjour-Dienst _adisk._tcp: je Freigabe ein Eintrag dkN mit # adVF=0x82 (Time-Machine-Ziel). Ohne ihn ginge es nur über "Mit Server verbinden" und tmutil. _ADISK = {"proc": None, "txt": None} def timemachine_txt(shares): names = [s.get("name") for s in shares if s.get("timemachine") and s.get("writable", True) and s.get("name") and services.offers(s, "smb")] if not names: return [] return ["sys=waMa=0,adVF=0x100"] + [f"dk{i}=adVN={name},adVF=0x82" for i, name in enumerate(names)] def timemachine_size(value): """Maximale Größe in GB, 0 = unbegrenzt. Ungültiges wird 0.""" try: return max(0, min(int(value or 0), 1_000_000)) except (TypeError, ValueError): return 0 def publish_timemachine(): """Bonjour-Eintrag für Time Machine an die aktuellen Freigaben anpassen (avahi-publish läuft weiter).""" txt = timemachine_txt(load_json(SHARES_FILE, [])) proc = _ADISK["proc"] running = proc is not None and proc.poll() is None if txt == _ADISK["txt"] and (running or not txt): return if running: proc.terminate() try: proc.wait(timeout=5) except subprocess.TimeoutExpired: proc.kill() _ADISK.update(proc=None, txt=txt) if not txt: return try: _ADISK["proc"] = subprocess.Popen(["avahi-publish", "-s", NAS_NAME, "_adisk._tcp", "9", *txt], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) print(f"[TM] Bonjour: Time Machine fuer {len(txt) - 1} Freigabe(n) angekuendigt", flush=True) except OSError as e: print(f"[TM] avahi-publish nicht startbar: {e}", flush=True) def get_by_id_path(dev_name): """Return the most readable stable /dev/disk/by-id/ symlink for dev_name (e.g. 'sdb1'). Returns the full by-id path string, or None if not available.""" real_dev = os.path.realpath(f"/dev/{dev_name}") by_id_dir = "/dev/disk/by-id" if not os.path.isdir(by_id_dir): return None candidates = [] try: for link in os.listdir(by_id_dir): link_path = os.path.join(by_id_dir, link) try: if os.path.realpath(link_path) == real_dev: candidates.append(link) except Exception: pass except Exception: return None if not candidates: return None # Prefer human-readable prefixes; avoid wwn- / dm- / md- for prefix in ("usb-", "ata-", "nvme-", "mmc-", "scsi-"): for c in sorted(candidates): if c.startswith(prefix): return os.path.join(by_id_dir, c) return os.path.join(by_id_dir, sorted(candidates)[0]) def get_system_devices(): """Return a set of device names (e.g. 'sda', 'sda1', 'mmcblk0p1') that back critical system mount points so the UI can warn before touching them.""" critical = {'/', '/boot', '/boot/efi', '/usr', '/var', '/homeassistant', '/mnt/data'} system_devs = set() try: with open("/proc/mounts") as f: for line in f: parts = line.split() if len(parts) < 2: continue dev_raw, mp = parts[0], parts[1] if mp not in critical: continue real = os.path.realpath(dev_raw) name = os.path.basename(real) system_devs.add(name) # Also flag the parent disk (strip partition suffix p?N) parent = re.sub(r'p?\d+$', '', name) if parent != name: system_devs.add(parent) except Exception: pass return system_devs def get_disk_usage(path): try: usage = shutil.disk_usage(path) return { "total": usage.total, "used": usage.used, "free": usage.free, "percent": round(usage.used / usage.total * 100, 1) if usage.total else 0, } except Exception: return None # ─────────────────────────── drives API ───────────────────────── def update_fstype_memory(updates): """Persist freshly-known FS types so unmounted-but-known drives keep their tag across restarts. Updates only — never deletes entries.""" if not updates: return try: mem = load_json(FSTYPE_MEMORY_FILE, {}) or {} except Exception: mem = {} changed = False for name, fs in updates.items(): if name and fs and fs != "auto" and mem.get(name) != fs: mem[name] = fs changed = True if changed: try: save_json(FSTYPE_MEMORY_FILE, mem) except Exception: pass def get_proc_mounts_fstypes(): """Return {device_basename: fstype} for everything currently mounted. Uses /proc/mounts where the kernel always knows the resolved FS type. Resolves symlinks (e.g. /dev/disk/by-id/...) to /dev/sdXN.""" out = {} try: with open("/proc/mounts") as f: for line in f: parts = line.split() if len(parts) < 3: continue dev_raw, _, fs = parts[0], parts[1], parts[2] if not dev_raw.startswith("/dev/"): continue try: real = os.path.realpath(dev_raw) except Exception: real = dev_raw out[os.path.basename(real)] = fs except Exception: pass return out def probe_fstype(dev_path): """Detect filesystem type when lsblk's FSTYPE column is empty. Tries blkid → blkid -p → file -sL. Note: HA-OS often blocks raw block reads with EPERM, so all of these may return nothing for unmounted USB devices — that's why we also fall back to /proc/mounts elsewhere.""" if not dev_path or not os.path.exists(dev_path): return None for cmd in (["blkid", "-o", "value", "-s", "TYPE", dev_path], ["blkid", "-p", "-o", "value", "-s", "TYPE", dev_path]): try: r = subprocess.run(cmd, capture_output=True, text=True, timeout=3) t = (r.stdout or "").strip().splitlines()[0:1] if t and t[0]: return t[0] except Exception: pass try: r = subprocess.run(["file", "-sL", dev_path], capture_output=True, text=True, timeout=3) s = (r.stdout or "").lower() for needle, fs in (("ext4", "ext4"), ("ext3", "ext3"), ("ext2", "ext2"), ("ntfs", "ntfs"), ("exfat", "exfat"), ("fat (", "vfat"), ("dos/mbr", "vfat"), ("btrfs", "btrfs"), ("xfs", "xfs")): if needle in s: return fs except Exception: pass return None def list_block_devices(): try: result = subprocess.run( ["lsblk", "-J", "-o", "NAME,SIZE,TYPE,FSTYPE,LABEL,MOUNTPOINT,UUID,MODEL,VENDOR,HOTPLUG,RM"], capture_output=True, text=True, check=True) return json.loads(result.stdout).get("blockdevices", []) except Exception: return [] def flatten_devices(devices, parent=None, mounted_fs=None, mem_fs=None): if mounted_fs is None: mounted_fs = get_proc_mounts_fstypes() # Seed the persistent memory with whatever the kernel currently # reports — so once a drive has been mounted, the tag keeps # showing after unmount and across restarts. update_fstype_memory(mounted_fs) if mem_fs is None: # Also pick up fstypes saved in mounts.json (backwards-compat) and # the new fstype_memory.json mem_fs = {} try: for m in load_json(MOUNTS_FILE, []): resolved = m.get("resolved_fstype") or m.get("fstype") dev_real = os.path.basename(os.path.realpath(m.get("device", ""))) if resolved and resolved != "auto" and dev_real: mem_fs[dev_real] = resolved except Exception: pass try: mem_fs.update(load_json(FSTYPE_MEMORY_FILE, {}) or {}) except Exception: pass result = [] for dev in devices: name = dev.get("name") # FS type sources, in order: lsblk → /proc/mounts → memory file/mounts.json → probe fstype = dev.get("fstype") if not fstype: fstype = mounted_fs.get(name) if not fstype: fstype = mem_fs.get(name) if not fstype and dev.get("type") == "part": fstype = probe_fstype(f"/dev/{name}") d = { "name": name, "path": f"/dev/{name}", "by_id": get_by_id_path(name), "size": dev.get("size", "?"), "type": dev.get("type"), "fstype": fstype, "label": dev.get("label"), "mountpoint": dev.get("mountpoint"), "uuid": dev.get("uuid"), "model": dev.get("model") or (parent.get("model") if parent else None), "vendor": dev.get("vendor") or (parent.get("vendor") if parent else None), "removable": dev.get("rm") == "1" or dev.get("hotplug") == "1", "parent": parent.get("name") if parent else None, } result.append(d) for child in dev.get("children", []): result.extend(flatten_devices([child], dev, mounted_fs, mem_fs)) return result def _has_medium(name): """Return True if the disk has an actual medium inserted. Reads /sys/block//size — 0 means empty card-reader slot, USB hub port without device, etc.""" try: with open(f"/sys/block/{name}/size") as f: return int(f.read().strip()) > 0 except Exception: return True # if we can't tell, don't hide it @app.route("/api/drives") def api_drives(): system_devs = get_system_devices() devices = flatten_devices(list_block_devices()) drives = [d for d in devices if d["type"] in ("disk", "part")] # Hide phantom disks (card-reader slots without medium etc.) drives = [d for d in drives if d["type"] != "disk" or _has_medium(d["name"])] for d in drives: d["system_device"] = d["name"] in system_devs return jsonify(drives) def _run_smartctl(dev_path): """Run smartctl: auto-detection first, then plain SAT. Vendor modes (usbjmicron/usbprolific/usbsunplus) are deliberately not tried: on a non-matching bridge (e.g. ASMedia) their vendor commands can wedge the bridge and trigger UAS resets. smartctl auto-detects the common bridges as SAT anyway. Returns the parsed JSON dict (smartctl --json always emits valid JSON even on error, with smartctl.exit_status describing the issue).""" attempts = [ ["smartctl", "--json", "-a", dev_path], ["smartctl", "--json", "-a", "-d", "sat", dev_path], ] last = None for cmd in attempts: try: r = subprocess.run(cmd, capture_output=True, text=True, timeout=30) data = json.loads(r.stdout) if r.stdout else {} last = data # exit_status is a bit mask: bit 0 = command line error, # bit 1 = device open failed / not responding. All higher bits # (SMART threshold exceeded, error log entries ...) still mean # the device answered, so the data is valid. status = data.get("smartctl", {}).get("exit_status", -1) if isinstance(status, int) and status >= 0 and (status & 0x03) == 0: data["_smartctl_cmd"] = " ".join(cmd) return data except subprocess.TimeoutExpired: # The drive does not answer - do not hammer it with more commands return {"error": "smartctl timeout", "_smartctl_cmd": " ".join(cmd)} except (json.JSONDecodeError, FileNotFoundError) as e: last = {"error": str(e)} if isinstance(last, dict): last["_smartctl_cmd"] = "(all attempts failed)" return last or {"error": "smartctl failed"} # One smartctl at a time, results cached per disk (SMART only on request) _SMART_LOCK = threading.Lock() _SMART_CACHE = {} # disk name -> (timestamp, summary) _SMART_CACHE_TTL = 1800 # seconds def _disk_busy(disk): """True if the block device has requests in flight right now.""" try: with open(f"/sys/block/{disk}/inflight") as f: return sum(int(x) for x in f.read().split()) > 0 except (OSError, ValueError): return False def _smart_summary(data): """Boil the full smartctl JSON down to the fields we want to show.""" s = { "device": data.get("device", {}).get("name"), "model": data.get("model_name") or data.get("scsi_model_name"), "serial": data.get("serial_number"), "firmware": data.get("firmware_version"), "capacity_bytes": data.get("user_capacity", {}).get("bytes"), "rotation_rate": data.get("rotation_rate"), # 0 = SSD "smart_supported": data.get("smart_support", {}).get("available", False), "smart_enabled": data.get("smart_support", {}).get("enabled", False), "passed": data.get("smart_status", {}).get("passed"), "temperature_c": (data.get("temperature") or {}).get("current"), "power_on_hours": (data.get("power_on_time") or {}).get("hours"), "power_cycles": data.get("power_cycle_count"), "attributes": [], "errors": None, "raw": data, # full JSON for debugging } # ATA attribute table: pull a curated set if present keep = {1: "Read Error Rate", 5: "Reallocated Sectors", 9: "Power-On Hours", 12: "Power Cycle Count", 187: "Reported Uncorrectable", 188: "Command Timeout", 190: "Airflow Temperature", 194: "Temperature", 196: "Reallocation Events", 197: "Pending Sectors", 198: "Offline Uncorrectable", 199: "UDMA CRC Errors", 231: "SSD Life Left", 241: "Total LBAs Written"} for a in (data.get("ata_smart_attributes") or {}).get("table", []) or []: aid = a.get("id") if aid in keep: s["attributes"].append({ "id": aid, "name": keep[aid], "value": a.get("value"), "worst": a.get("worst"), "thresh": a.get("thresh"), "raw": (a.get("raw") or {}).get("value"), "raw_str":(a.get("raw") or {}).get("string"), "failing":a.get("when_failed") not in (None, "", "-"), }) # NVMe-style health log (if present) nvme = data.get("nvme_smart_health_information_log") if nvme: s["nvme"] = { "critical_warning": nvme.get("critical_warning"), "percentage_used": nvme.get("percentage_used"), "available_spare": nvme.get("available_spare"), "available_spare_thresh": nvme.get("available_spare_threshold"), "media_errors": nvme.get("media_errors"), "unsafe_shutdowns": nvme.get("unsafe_shutdowns"), "data_units_read": nvme.get("data_units_read"), "data_units_written": nvme.get("data_units_written"), } # error counts (ATA) errlog = data.get("ata_smart_error_log") or {} if errlog: s["errors"] = (errlog.get("summary") or {}).get("count") return s @app.route("/api/drives//smart") def api_drive_smart(name): # Whitelist: only allow real device names from our list devices = flatten_devices(list_block_devices()) if not any(d["name"] == name for d in devices): return jsonify({"error": "unknown device"}), 404 # SMART on partitions makes no sense — use the parent disk parent = name if name and name[-1:].isdigit(): # strip trailing digits (sdb1 -> sdb, nvme0n1p1 -> nvme0n1) import re parent = re.sub(r"(p?\d+)$", "", name) if parent.startswith(("zram", "loop", "ram")): return jsonify({"error": "kein SMART für virtuelle Geräte"}), 400 hit = _SMART_CACHE.get(parent) if hit and time.time() - hit[0] < _SMART_CACHE_TTL and not request.args.get("force"): return jsonify(hit[1]) # ATA pass-through while the bridge is busy writing is what wedges # USB bridges - wait until the disk is idle. if _disk_busy(parent): return jsonify({"error": "Platte arbeitet gerade – SMART später erneut abfragen"}), 409 if not _SMART_LOCK.acquire(blocking=False): return jsonify({"error": "SMART-Abfrage läuft bereits"}), 429 try: dev_path = f"/dev/{parent}" raw = _run_smartctl(dev_path) finally: _SMART_LOCK.release() summary = _smart_summary(raw) summary["queried_device"] = dev_path if "error" not in raw: _SMART_CACHE[parent] = (time.time(), summary) return jsonify(summary) MOUNT_FIFO = "/tmp/mount_cmd" MOUNT_RESULT = "/tmp/mount_result" def _helper_call(action, arg1="", arg2="", arg3="", timeout=20): import time deadline_fifo = time.time() + 10 while not os.path.exists(MOUNT_FIFO): if time.time() > deadline_fifo: return 1, "Mount-Helper FIFO nicht gefunden" time.sleep(0.2) try: os.remove(MOUNT_RESULT) except FileNotFoundError: pass cmd_line = f"{action}|{arg1}|{arg2}|{arg3}\n" # Retry a few times on ENXIO — the helper script briefly has no reader # on the FIFO between iterations (race window when it re-opens). last_err = None for attempt in range(20): # ~2s total try: fd = os.open(MOUNT_FIFO, os.O_WRONLY | os.O_NONBLOCK) os.write(fd, cmd_line.encode()) os.close(fd) last_err = None break except OSError as e: last_err = e if e.errno == 6: # ENXIO: no reader yet time.sleep(0.1) continue return 1, f"FIFO-Fehler: {e}" if last_err is not None: return 1, f"FIFO-Fehler: {last_err}" deadline = time.time() + timeout while time.time() < deadline: if os.path.exists(MOUNT_RESULT): try: with open(MOUNT_RESULT) as f: line = f.read().strip() rc_str, _, out = line.partition("|") return int(rc_str), out except Exception: pass time.sleep(0.1) return 1, "Timeout" def _trim_prepare(mountpoint): """Before fstrim: enable TRIM on USB adapters that support it (option trim_usb_unmap). Returns extra fields for the result: unmap_possible tells the web interface that the option would probably help on this drive, unmap_unknown that the adapter hides whether it can (usb-storage) and the option may help. """ disk = _trim_targets().get(mountpoint) if not disk: return {} if bool(_opt("trim_usb_unmap", False)): msg = trim.enable_unmap(disk) if msg: print(f"[TRIM] {msg}", flush=True) return {} if trim.discard_supported(disk): return {} info = trim.unmap_info(disk) # usb-storage bridges show no VPD pages, so support is unknown until asked unknown = info["usb"] and info["mode"] == "full" and info["vpd"] == "none" return {"unmap_possible": trim.possible(info), "unmap_unknown": unknown} TRIMMER = trim.Trimmer(f"{DATA_DIR}/trim_results.json", prepare=_trim_prepare) TRIM_CHECK_INTERVAL = 3600 # seconds def _writable_mounts(): """Mountpoints that are mounted read-write in this container.""" found = set() try: with open("/proc/mounts") as f: for line in f: parts = line.split() if len(parts) >= 4 and "rw" in parts[3].split(","): found.add(parts[1].replace("\\040", " ")) except OSError: pass return found def _trim_targets(): """Mounted, writable partitions outside the system disk: {mountpoint: disk}.""" system_devs = get_system_devices() writable = _writable_mounts() targets = {} for d in flatten_devices(list_block_devices()): mountpoint = d.get("mountpoint") if mountpoint and d.get("name") not in system_devs and mountpoint in writable: targets[mountpoint] = d.get("parent") or d.get("name") return targets @app.route("/api/drives/trim", methods=["GET"]) def api_trim_status(): return jsonify(TRIMMER.status()) @app.route("/api/drives/trim", methods=["POST"]) def api_trim_start(): body = request.get_json(force=True) or {} mountpoint = str(body.get("mountpoint", "")).strip() if mountpoint not in _trim_targets(): return jsonify({"error": "not a mounted, writable drive"}), 400 if not TRIMMER.start(mountpoint): return jsonify({"error": "TRIM is already running on this drive"}), 409 return jsonify({"started": True}), 202 def start_trim_schedule(): """Option trim_interval_days: trim drives that report TRIM support.""" try: days = int(_opt("trim_interval_days", 0)) except (TypeError, ValueError): days = 0 if days <= 0: return def run(): time.sleep(300) # let drives mount and start-up work finish while True: try: for mountpoint, disk in _trim_targets().items(): usable = trim.discard_supported(disk) or ( bool(_opt("trim_usb_unmap", False)) and trim.unmap_possible(disk, ask_device=trim.scsi_vpd)) if usable and trim.is_due(TRIMMER.last_run(mountpoint), days, time.time()): TRIMMER.execute(mountpoint) except Exception as e: print(f"[TRIM] scheduled run failed: {e}", flush=True) time.sleep(TRIM_CHECK_INTERVAL) threading.Thread(target=run, daemon=True, name="trim-schedule").start() print(f"[TRIM] Scheduled every {days} days for drives that support TRIM", flush=True) @app.route("/api/drives/mount", methods=["POST"]) def api_mount(): body = request.get_json(force=True) device = body.get("device", "").strip() mountpoint = body.get("mountpoint", "").strip() fstype = body.get("fstype", "auto") if not device or not mountpoint: return jsonify({"error": "device and mountpoint required"}), 400 # Resolve to stable by-id path — prevents /dev/sdX reordering across reboots dev_name = os.path.basename(os.path.realpath(device)) by_id = get_by_id_path(dev_name) stable_dev = by_id if by_id else device if by_id: print(f"[MOUNT] Resolved {device} → {by_id}", flush=True) else: print(f"[MOUNT] No by-id path found for {device}, using raw device path", flush=True) # If mountpoint doesn't start with /, treat as name under /media if not mountpoint.startswith("/"): safe_name = re.sub(r"[^a-zA-Z0-9_\-]", "_", mountpoint) mountpoint = f"/media/{safe_name}" # A mount point may only ever be under /media or /mnt - this runs as root # and an arbitrary target would shadow any directory on the host. mountpoint = _safe(mountpoint, roots=["/media", "/mnt"]) _helper_call("MKDIR", mountpoint) os.makedirs(mountpoint, exist_ok=True) rc, out = _helper_call("MOUNT", stable_dev, mountpoint, fstype) if rc != 0: body, status = mount_error(out) return jsonify(body), status # Resolve the actual FS type the kernel is using — for the drive list tag resolved_fs = fstype if fstype == "auto": try: with open("/proc/mounts") as f: for ln in f: p = ln.split() if len(p) >= 3 and p[1] == mountpoint: resolved_fs = p[2] break except Exception: pass # /proc/mounts reports ntfs-3g (FUSE) mounts as "fuseblk" — normalize to a # meaningful tag and a value that mount_fs understands on restore. if resolved_fs == "fuseblk": resolved_fs = "ntfs" # Persist the resolved type so the tag stays visible after unmount if resolved_fs and resolved_fs != "auto": update_fstype_memory({os.path.basename(os.path.realpath(stable_dev)): resolved_fs}) mounts = load_json(MOUNTS_FILE, []) mounts = [m for m in mounts if m.get("mountpoint") != mountpoint] mounts.append({ "device": stable_dev, "dev_path": device, "mountpoint": mountpoint, "fstype": fstype, "resolved_fstype": resolved_fs, }) save_json(MOUNTS_FILE, mounts) reload_samba() return jsonify({"ok": True, "mountpoint": mountpoint, "stable_device": stable_dev}) UNKNOWN_FS = re.compile(r"unknown filesystem type '([^']+)'") def mount_error(out): """Fehlermeldung von mount fürs Frontend. Kennt der Kernel das Dateisystem nicht (XFS, Issue #16), hilft kein zweiter Versuch: Home Assistant OS bringt es nicht mit, ein Add-on kann es nicht nachladen.""" unknown = UNKNOWN_FS.search(out or "") if unknown: fs = unknown.group(1) return {"error": f"Home Assistant OS kann {fs}-Laufwerke nicht einbinden: Das Dateisystem fehlt im Kernel. " f"Daten an einem anderen Rechner sichern und das Laufwerk mit ext4 formatieren.", "code": "fs_unsupported", "fstype": fs}, 422 return {"error": out or "Mount fehlgeschlagen"}, 500 @app.route("/api/drives/unmount", methods=["POST"]) def api_unmount(): body = request.get_json(force=True) mountpoint = body.get("mountpoint", "").strip() device = body.get("device", "").strip() if not mountpoint and not device: return jsonify({"error": "mountpoint or device required"}), 400 target = mountpoint or device force = bool(body.get("force", False)) mounts = load_json(MOUNTS_FILE, []) # 1) Tell smbd to close all shares whose path lies under the mountpoint, # so it releases its file handles before we unmount. try: shares = load_json(SHARES_FILE, []) affected = [s.get("name") for s in shares if s.get("path", "").rstrip("/").startswith(mountpoint.rstrip("/")) and s.get("name")] for sname in affected: subprocess.run(["smbcontrol", "all", "close-share", sname], capture_output=True, timeout=5) if affected: print(f"[UNMOUNT] Closed Samba handles for: {', '.join(affected)}", flush=True) except Exception as e: print(f"[UNMOUNT] close-share failed: {e}", flush=True) def _umount_with_fallback(path): """Try plain umount, fall back to lazy umount on force or busy.""" rc, out = _helper_call("UMOUNT", path) if rc == 0: return 0, out, False is_busy = "busy" in (out or "").lower() or "target is busy" in (out or "").lower() if force and is_busy: rc2, out2 = _helper_call("UMOUNT_LAZY", path) return rc2, out2, True return rc, out, False # 2) Unmount rc, out, lazy = _umount_with_fallback(target) if rc != 0: # Collect diagnostic info on what's holding it _, who = _helper_call("FUSER", target, timeout=5) return jsonify({ "error": out or "Aushängen fehlgeschlagen", "busy": "busy" in (out or "").lower(), "holders": who, }), 500 mounts = [m for m in mounts if m.get("mountpoint") != mountpoint and m.get("device") != device] save_json(MOUNTS_FILE, mounts) # Regenerate smb.conf — shares on this mount point become unavailable reload_samba() return jsonify({"ok": True}) # ─────────────────────────── disk / partition API ─────────────────── _DEVNAME_RE = re.compile(r'^[a-zA-Z0-9_]+$') def _safe_disk_path(name): """Return /dev/ if name is a plain block device name and the device exists, otherwise None. Blocks path-traversal / shell tricks.""" if not name or not _DEVNAME_RE.match(name): return None path = f"/dev/{name}" try: st = os.stat(path) except OSError: return None import stat as _stat if not _stat.S_ISBLK(st.st_mode): return None return path def _is_system_disk(name): sys_devs = get_system_devices() # name might be a partition (sdc1) or whole disk (sdc) — strip partition suffix too base = re.sub(r'p?\d+$', '', name) return name in sys_devs or base in sys_devs def _parse_parted(text): """Parse `parted -m -s unit MiB print free` output.""" lines = [l for l in text.strip().split('\n') if l] if not lines or not lines[0].startswith('BYT'): return None if len(lines) < 2: return None di = lines[1].rstrip(';').split(':') if len(di) < 6: return None def to_mib(s): try: return float(s.replace('MiB', '').replace('MB', '').replace('GB', '')) except: return 0.0 disk = { 'path': di[0], 'size_mib': to_mib(di[1]), 'table': di[5] if len(di) > 5 else '', 'model': di[6] if len(di) > 6 else '', } parts = [] for line in lines[2:]: f = line.rstrip(';').split(':') if len(f) < 4: continue start, end, size = to_mib(f[1]), to_mib(f[2]), to_mib(f[3]) is_free = (len(f) >= 5 and 'free' in f[4].lower()) if is_free: parts.append({ 'free': True, 'start_mib': start, 'end_mib': end, 'size_mib': size, }) else: try: num = int(f[0]) except: num = 0 parts.append({ 'free': False, 'num': num, 'start_mib': start, 'end_mib': end, 'size_mib': size, 'fstype': f[4] if len(f) > 4 else '', 'label': f[5] if len(f) > 5 else '', 'flags': f[6] if len(f) > 6 else '', }) return {'disk': disk, 'partitions': parts} def _mounted_paths(): """Return {device_path: mountpoint} from /proc/mounts. Resolves realpath so /dev/disk/by-id/... matches /dev/sdb2. `in _mounted_paths()` still works (dict membership).""" out = {} try: with open('/proc/mounts') as f: for line in f: p = line.split() if len(p) >= 2 and p[0].startswith('/dev/'): out.setdefault(p[0], p[1]) try: real = os.path.realpath(p[0]) out.setdefault(real, p[1]) except Exception: pass except Exception: pass return out @app.route("/api/disk//partitions", methods=["GET"]) def api_disk_partitions(name): path = _safe_disk_path(name) if not path: return jsonify({"error": f"invalid disk: {name}"}), 400 if not _has_medium(name): return jsonify({"error": f"no medium in /dev/{name} (empty card-reader / hub port)"}), 410 rc, out = _helper_call("PARTLIST", path, timeout=15) # decode the \n encoding from helper out = (out or "").replace('\\n', '\n') parsed = _parse_parted(out) if rc == 0 else None if not parsed: # Disk might have no partition table at all — return blank layout try: sz_bytes = int(open(f"/sys/class/block/{name}/size").read().strip()) * 512 except: sz_bytes = 0 return jsonify({ "disk": {"path": path, "name": name, "size_mib": sz_bytes / 1024 / 1024, "table": "", "model": ""}, "partitions": [], "system_device": _is_system_disk(name), "raw_error": out if rc != 0 else None, }) parsed["disk"]["name"] = name parsed["system_device"] = _is_system_disk(name) mounted = _mounted_paths() for p in parsed["partitions"]: if not p["free"]: p["device"] = f"{path}{p['num']}" if not name[-1].isdigit() else f"{path}p{p['num']}" p["mountpoint"] = mounted.get(p["device"]) p["mounted"] = p["mountpoint"] is not None return jsonify(parsed) @app.route("/api/disk//init", methods=["POST"]) def api_disk_init(name): path = _safe_disk_path(name) if not path: return jsonify({"error": f"invalid disk: {name}"}), 400 if _is_system_disk(name): return jsonify({"error": "Refusing to touch a system disk"}), 403 body = request.get_json(force=True) or {} table = body.get("table", "gpt") if table not in ("gpt", "msdos"): return jsonify({"error": "table must be 'gpt' or 'msdos'"}), 400 if body.get("confirm") != name: return jsonify({"error": "confirmation does not match"}), 400 # Pre-check: parted refuses mklabel ("Partition(s) on /dev/sdX are being # used.") if ANY partition of the disk is currently mounted. List them # explicitly so the user knows exactly what to unmount. mounted = _mounted_paths() busy = [] prefix_p = f"{path}p" prefix_n = path for dev, mp in mounted.items(): if dev.startswith(prefix_p) and dev[len(prefix_p):].isdigit(): busy.append((dev, mp)) elif dev.startswith(prefix_n) and dev[len(prefix_n):].isdigit(): busy.append((dev, mp)) if busy: listing = ", ".join(f"{d} → {m}" for d, m in busy) return jsonify({"error": f"Unmount first: {listing}"}), 409 rc, out = _helper_call("PARTMKLABEL", path, table, timeout=30) if rc != 0: return jsonify({"error": out or "mklabel failed"}), 500 return jsonify({"ok": True}) @app.route("/api/disk//partition", methods=["POST"]) def api_disk_partition_add(name): path = _safe_disk_path(name) if not path: return jsonify({"error": f"invalid disk: {name}"}), 400 if _is_system_disk(name): return jsonify({"error": "Refusing to touch a system disk"}), 403 body = request.get_json(force=True) or {} if body.get("confirm") != name: return jsonify({"error": "confirmation does not match"}), 400 try: start_mib = float(body.get("start_mib")) except (TypeError, ValueError): return jsonify({"error": "start_mib required (number)"}), 400 # Align start: parted refuses to place a partition in the GPT header # region (first ~1 MiB) and also wants MiB-aligned starts for performance. # Round UP to the next whole MiB, with a minimum of 1 MiB. import math start_mib = max(1.0, math.ceil(start_mib)) end = body.get("end_mib", "100%") if isinstance(end, str) and end.strip().endswith("%"): end_arg = end.strip() else: try: # Round end DOWN to whole MiB so we never overflow the free region. end_mib = math.floor(float(end)) if end_mib <= start_mib: return jsonify({"error": f"end_mib ({end_mib}) must be greater than start_mib ({start_mib})"}), 400 end_arg = f"{end_mib}MiB" except (TypeError, ValueError): return jsonify({"error": "end_mib invalid"}), 400 rc, out = _helper_call("PARTADD", path, f"{int(start_mib)}", end_arg, timeout=60) if rc != 0: return jsonify({"error": out or "mkpart failed"}), 500 return jsonify({"ok": True}) @app.route("/api/disk//partition/", methods=["DELETE"]) def api_disk_partition_rm(name, num): path = _safe_disk_path(name) if not path: return jsonify({"error": f"invalid disk: {name}"}), 400 if _is_system_disk(name): return jsonify({"error": "Refusing to touch a system disk"}), 403 body = request.get_json(silent=True) or {} part_name = f"{name}{num}" if not name[-1].isdigit() else f"{name}p{num}" if body.get("confirm") != part_name: return jsonify({"error": "confirmation does not match"}), 400 part_path = f"{path}{num}" if not name[-1].isdigit() else f"{path}p{num}" if part_path in _mounted_paths(): return jsonify({"error": "Partition is mounted — unmount first"}), 409 rc, out = _helper_call("PARTRM", path, str(num), timeout=30) if rc != 0: return jsonify({"error": out or "rm failed"}), 500 return jsonify({"ok": True}) @app.route("/api/disk//partition//format", methods=["POST"]) def api_partition_format(name, num): path = _safe_disk_path(name) if not path: return jsonify({"error": f"invalid disk: {name}"}), 400 if _is_system_disk(name): return jsonify({"error": "Refusing to touch a system disk"}), 403 body = request.get_json(force=True) or {} part_name = f"{name}{num}" if not name[-1].isdigit() else f"{name}p{num}" if body.get("confirm") != part_name: return jsonify({"error": "confirmation does not match"}), 400 fstype = (body.get("fstype") or "").strip().lower() if fstype not in ("ext4", "ext3", "ext2", "exfat", "vfat", "fat32", "ntfs"): return jsonify({"error": f"unsupported fstype: {fstype}"}), 400 label = (body.get("label") or "").strip() # Cap label length per FS limits label_limits = {"vfat": 11, "fat32": 11, "exfat": 15, "ext2": 16, "ext3": 16, "ext4": 16, "ntfs": 32} label = label[:label_limits.get(fstype, 16)] part_path = f"{path}{num}" if not name[-1].isdigit() else f"{path}p{num}" if part_path in _mounted_paths(): return jsonify({"error": "Partition is mounted — unmount first"}), 409 # mkfs.ntfs on a multi-TB drive can take 15+ min; ext4 quick, exfat fast. rc, out = _helper_call("MKFS", part_path, fstype, label, timeout=1800) if rc != 0: return jsonify({"error": out or "mkfs failed"}), 500 # Refresh known FS-type memory so the drive list shows the new type try: dev_name = os.path.basename(part_path) memory = load_json(FSTYPE_MEMORY_FILE, {}) or {} memory[dev_name] = "ntfs" if fstype == "ntfs" else fstype save_json(FSTYPE_MEMORY_FILE, memory) except Exception: pass return jsonify({"ok": True}) # ─────────────────────────── shares API ───────────────────────── def ensure_share_dir(path): """Create the share directory and apply Samba-friendly permissions. exFAT / FAT32 / NTFS do not support POSIX permissions, so os.chmod() raises PermissionError (EPERM) or OSError (ENOTSUP/EINVAL) on them. That is harmless — those filesystems grant access to everyone anyway — so we only warn and continue instead of failing share creation. (Issue #8) """ try: os.makedirs(path, mode=0o2775, exist_ok=True) except FileExistsError: pass except OSError as e: # Directory may already exist on a non-POSIX FS, or the parent is # read-only. Re-raise only if the path is genuinely not there. if not os.path.isdir(path): raise print(f"ensure_share_dir: makedirs({path}) warning: {e}", flush=True) try: os.chmod(path, 0o2775) except OSError as e: # exFAT/FAT/NTFS have no Unix permission model — skip silently-ish print(f"ensure_share_dir: chmod not supported on {path} " f"(likely exFAT/FAT/NTFS): {e}", flush=True) @app.route("/api/shares", methods=["GET"]) def api_get_shares(): shares = load_json(SHARES_FILE, []) for s in shares: s["services"] = services.share_services(s) usage = get_disk_usage(s.get("path", "/")) if usage: s["usage"] = usage return jsonify(shares) @app.route("/api/shares", methods=["POST"]) def api_create_share(): body = request.get_json(force=True) name = body.get("name", "").strip() path = body.get("path", "").strip().rstrip("/") if not name or not path: return jsonify({"error": "name and path required"}), 400 name = re.sub(r"[^a-zA-Z0-9_\-]", "_", name) path = _safe(path) # Create directory with Samba-friendly permissions (tolerates exFAT/FAT) ensure_share_dir(path) shares = load_json(SHARES_FILE, []) if any(s["name"] == name for s in shares): return jsonify({"error": "Share name already exists"}), 409 share = { "name": name, "path": path, "comment": body.get("comment", ""), "writable": body.get("writable", True), "public": body.get("public", False), "users": body.get("users", []), "groups": body.get("groups", []), "timemachine": bool(body.get("timemachine", False)), "timemachine_max_gb": timemachine_size(body.get("timemachine_max_gb")), "services": services.clean(body.get("services")), } shares.append(share) save_json(SHARES_FILE, shares) reload_samba() return jsonify(share), 201 @app.route("/api/shares/", methods=["PUT"]) def api_update_share(name): body = request.get_json(force=True) # Strip trailing slashes from path if "path" in body: body["path"] = _safe(body["path"].strip().rstrip("/")) shares = load_json(SHARES_FILE, []) for s in shares: if s["name"] == name: for key in ("comment", "writable", "public", "users", "groups", "path"): if key in body: s[key] = body[key] if "timemachine" in body: s["timemachine"] = bool(body["timemachine"]) if "timemachine_max_gb" in body: s["timemachine_max_gb"] = timemachine_size(body["timemachine_max_gb"]) if "services" in body: s["services"] = services.clean(body["services"]) # Ensure share directory exists with correct permissions p = s.get("path", "") if p: ensure_share_dir(p) save_json(SHARES_FILE, shares) reload_samba() return jsonify(s) return jsonify({"error": "Not found"}), 404 @app.route("/api/shares/", methods=["DELETE"]) def api_delete_share(name): shares = load_json(SHARES_FILE, []) shares = [s for s in shares if s["name"] != name] save_json(SHARES_FILE, shares) reload_samba() n = sharing_store.disable_links_for_share(name) if n: print(f"[SHARE] {n} Link(s) deaktiviert, Freigabe {name} geloescht", flush=True) return jsonify({"ok": True, "links_disabled": n}) # ─────────────────────────── users API ────────────────────────── @app.route("/api/users", methods=["GET"]) def api_get_users(): users = load_json(USERS_FILE, []) groups = load_json(GROUPS_FILE, []) # Enrich users with their group memberships for u in users: u["groups"] = [g["name"] for g in groups if u["username"] in g.get("members", [])] return jsonify(users) @app.route("/api/users", methods=["POST"]) def api_create_user(): body = request.get_json(force=True) username = re.sub(r"[^a-zA-Z0-9_\-]", "", body.get("username", "").strip()) password = body.get("password", "").strip() if not username or not password: return jsonify({"error": "username and password required"}), 400 subprocess.run(["adduser", "-D", "-H", "-s", "/sbin/nologin", username], capture_output=True) proc = subprocess.run(["smbpasswd", "-a", "-s", username], input=f"{password}\n{password}\n", text=True, capture_output=True) if proc.returncode != 0: return jsonify({"error": proc.stderr or "Failed to set Samba password"}), 500 users = load_json(USERS_FILE, []) users = [u for u in users if u["username"] != username] users.append({"username": username}) save_json(USERS_FILE, users) backup_samba_passwords() _webdav_passwords_changed(username) return jsonify({"username": username}), 201 @app.route("/api/users/", methods=["PUT"]) def api_update_user(username): body = request.get_json(force=True) password = body.get("password", "").strip() if not password: return jsonify({"error": "password required"}), 400 # Ensure system user exists (may be lost after container restart) subprocess.run(["adduser", "-D", "-H", "-s", "/sbin/nologin", username], capture_output=True) # Use -a flag to add samba entry if it doesn't exist yet proc = subprocess.run(["smbpasswd", "-a", "-s", username], input=f"{password}\n{password}\n", text=True, capture_output=True) if proc.returncode != 0: return jsonify({"error": proc.stderr or "Failed"}), 500 backup_samba_passwords() _webdav_passwords_changed(username) return jsonify({"ok": True}) @app.route("/api/users/", methods=["DELETE"]) def api_delete_user(username): subprocess.run(["smbpasswd", "-x", username], capture_output=True) _webdav_passwords_changed(username) subprocess.run(["deluser", username], capture_output=True) users = load_json(USERS_FILE, []) users = [u for u in users if u["username"] != username] save_json(USERS_FILE, users) # Also remove from groups groups = load_json(GROUPS_FILE, []) for g in groups: g["members"] = [m for m in g.get("members", []) if m != username] save_json(GROUPS_FILE, groups) backup_samba_passwords() return jsonify({"ok": True}) # ─────────────────────────── groups API ───────────────────────── @app.route("/api/groups", methods=["GET"]) def api_get_groups(): return jsonify(load_json(GROUPS_FILE, [])) @app.route("/api/groups", methods=["POST"]) def api_create_group(): body = request.get_json(force=True) name = re.sub(r"[^a-zA-Z0-9_\-]", "", body.get("name", "").strip()) if not name: return jsonify({"error": "group name required"}), 400 groups = load_json(GROUPS_FILE, []) if any(g["name"] == name for g in groups): return jsonify({"error": "Group already exists"}), 409 # Create system group subprocess.run(["addgroup", name], capture_output=True) group = {"name": name, "members": body.get("members", [])} # Add members to system group for m in group["members"]: subprocess.run(["addgroup", m, name], capture_output=True) groups.append(group) save_json(GROUPS_FILE, groups) return jsonify(group), 201 @app.route("/api/groups/", methods=["PUT"]) def api_update_group(name): body = request.get_json(force=True) groups = load_json(GROUPS_FILE, []) for g in groups: if g["name"] == name: old_members = set(g.get("members", [])) new_members = set(body.get("members", [])) # Remove members no longer in group for m in old_members - new_members: subprocess.run(["delgroup", m, name], capture_output=True) # Add new members for m in new_members - old_members: subprocess.run(["addgroup", m, name], capture_output=True) g["members"] = list(new_members) save_json(GROUPS_FILE, groups) return jsonify(g) return jsonify({"error": "Not found"}), 404 @app.route("/api/groups/", methods=["DELETE"]) def api_delete_group(name): groups = load_json(GROUPS_FILE, []) groups = [g for g in groups if g["name"] != name] save_json(GROUPS_FILE, groups) subprocess.run(["delgroup", name], capture_output=True) return jsonify({"ok": True}) # ─────────────────────────── status API ───────────────────────── def _protection_mode_active(): """Detect Home Assistant 'Protection mode' / 'Gesicherter Modus'. When protection is enabled, the supervisor strips privileged capabilities (notably CAP_SYS_ADMIN, bit 21) even though config.yaml requests them. Without CAP_SYS_ADMIN we cannot mount drives, so we surface a clear warning. """ try: with open("/proc/self/status", "r") as fh: for line in fh: if line.startswith("CapEff:"): cap_eff = int(line.split()[1], 16) # CAP_SYS_ADMIN = 21 return not (cap_eff & (1 << 21)) except Exception: pass return False @app.route("/api/status") def api_status(): import psutil smbd_running = any(p.name() == "smbd" for p in psutil.process_iter(["name"])) nmbd_running = any(p.name() == "nmbd" for p in psutil.process_iter(["name"])) mem = psutil.virtual_memory() cpu = psutil.cpu_percent(interval=0.1) disk = shutil.disk_usage("/") return jsonify({ "smbd": smbd_running, "nmbd": nmbd_running, "cpu_percent": cpu, "memory": {"total": mem.total, "used": mem.used, "percent": mem.percent}, "disk": {"total": disk.total, "used": disk.used, "free": disk.free, "percent": round(disk.used / disk.total * 100, 1)}, "protection_mode": _protection_mode_active(), "csrf": csrf_token(), "admin_password_enabled": bool(_admin_auth.get("enabled")), "user": session.get("user", ""), }) @app.route("/api/samba/restart", methods=["POST"]) def api_restart_samba(): import time import psutil try: # Open files (e.g. a running HA backup written via the share) would be # cut off by the restart - ask the user first unless forced. if not request.args.get("force"): r = subprocess.run(["smbstatus", "-L"], capture_output=True, text=True, timeout=10) if r.returncode == 0 and "No locked files" not in r.stdout: return jsonify({"error": "open_files", "detail": "Dateien sind geöffnet (z. B. laufendes Backup)"}), 409 # Regenerate smb.conf reload_samba() # Stop gracefully so clients get a clean disconnect; SIGKILL only # for processes that are still there after 10 s. subprocess.run(["pkill", "-TERM", "smbd"], check=False) subprocess.run(["pkill", "-TERM", "nmbd"], check=False) deadline = time.time() + 10 while time.time() < deadline and any( (p.info["name"] or "") in ("smbd", "nmbd") for p in psutil.process_iter(["name"])): time.sleep(0.5) subprocess.run(["pkill", "-KILL", "smbd"], check=False) subprocess.run(["pkill", "-KILL", "nmbd"], check=False) time.sleep(1) # Start fresh - use simple flags, redirect output to log subprocess.Popen(["smbd", "--foreground", "--no-process-group"], stdout=open("/var/log/samba/smbd.log", "a"), stderr=subprocess.STDOUT) subprocess.Popen(["nmbd", "--foreground", "--no-process-group"], stdout=open("/var/log/samba/nmbd.log", "a"), stderr=subprocess.STDOUT) time.sleep(2) # Verify smbd_ok = any("smbd" in (p.name() or "") for p in psutil.process_iter(["name"])) nmbd_ok = any("nmbd" in (p.name() or "") for p in psutil.process_iter(["name"])) if not smbd_ok or not nmbd_ok: return jsonify({"error": f"smbd={'OK' if smbd_ok else 'FEHLT'}, nmbd={'OK' if nmbd_ok else 'FEHLT'}"}), 500 return jsonify({"ok": True}) except Exception as e: return jsonify({"error": str(e)}), 500 # ─────────────────────────── settings backup API ──────────────── @app.route("/api/settings/backup-status") def api_settings_backup_status(): """Auto-backup status (used for reinstall-safe indicator).""" meta_file = os.path.join(CONFIG_AUTO_DIR, "meta.json") if os.path.exists(meta_file): meta = load_json(meta_file, {}) return jsonify({"available": True, "timestamp": meta.get("timestamp")}) return jsonify({"available": False}) @app.route("/api/settings/backups", methods=["GET"]) def api_settings_backups_list(): """List all manual backup snapshots.""" result = [] if os.path.isdir(CONFIG_BACKUPS_DIR): for name in sorted(os.listdir(CONFIG_BACKUPS_DIR), reverse=True): d = os.path.join(CONFIG_BACKUPS_DIR, name) meta_file = os.path.join(d, "meta.json") if os.path.isdir(d) and os.path.exists(meta_file): meta = load_json(meta_file, {}) result.append({"id": name, "timestamp": meta.get("timestamp"), "label": name}) return jsonify(result) @app.route("/api/settings/backup", methods=["POST"]) def api_settings_backup(): """Create a new manual backup snapshot.""" import datetime name = datetime.datetime.now().strftime("%Y-%m-%d_%H-%M-%S") dest = os.path.join(CONFIG_BACKUPS_DIR, name) try: _copy_data_to(dest) # Keep only MAX_MANUAL_BACKUPS newest entries = sorted(os.listdir(CONFIG_BACKUPS_DIR)) for old in entries[:-MAX_MANUAL_BACKUPS]: shutil.rmtree(os.path.join(CONFIG_BACKUPS_DIR, old), ignore_errors=True) meta = load_json(os.path.join(dest, "meta.json"), {}) print(f"[SETTINGS-BACKUP] Manual snapshot created: {name}", flush=True) return jsonify({"ok": True, "id": name, "timestamp": meta.get("timestamp")}) except Exception as e: return jsonify({"error": str(e)}), 500 @app.route("/api/settings/backups/", methods=["DELETE"]) def api_settings_backup_delete(backup_id): """Delete a specific manual backup snapshot.""" # Security: only allow simple timestamp-format IDs, no path traversal import re if not re.match(r'^\d{4}-\d{2}-\d{2}_\d{2}-\d{2}-\d{2}$', backup_id): return jsonify({"error": "Ungültige Backup-ID"}), 400 dest = os.path.join(CONFIG_BACKUPS_DIR, backup_id) if not os.path.isdir(dest): return jsonify({"error": "Backup nicht gefunden"}), 404 shutil.rmtree(dest) print(f"[SETTINGS-BACKUP] Snapshot deleted: {backup_id}", flush=True) return jsonify({"ok": True}) @app.route("/api/settings/backups//restore", methods=["POST"]) def api_settings_backup_restore(backup_id): """Restore settings from a specific manual backup snapshot.""" import re if not re.match(r'^\d{4}-\d{2}-\d{2}_\d{2}-\d{2}-\d{2}$', backup_id): return jsonify({"error": "Ungültige Backup-ID"}), 400 src = os.path.join(CONFIG_BACKUPS_DIR, backup_id) if not os.path.isdir(src): return jsonify({"error": "Backup nicht gefunden"}), 404 try: _restore_from(src) reload_samba() print(f"[SETTINGS-BACKUP] Restored from snapshot: {backup_id}", flush=True) return jsonify({"ok": True}) except Exception as e: return jsonify({"error": str(e)}), 500 @app.route("/api/settings/restore", methods=["POST"]) def api_settings_restore(): """Restore from auto-backup slot.""" if not os.path.exists(os.path.join(CONFIG_AUTO_DIR, "meta.json")): return jsonify({"error": "Kein Auto-Backup vorhanden"}), 404 try: _restore_from(CONFIG_AUTO_DIR) reload_samba() print("[SETTINGS-BACKUP] Auto-backup restored", flush=True) return jsonify({"ok": True}) except Exception as e: return jsonify({"error": str(e)}), 500 # ─────────────────────────── options + path confinement ───────── _OPTIONS = None def _opt(key, default=None): """Read an add-on option from /data/options.json. The Supervisor writes the resolved options there; that is the same file bashio reads. Going straight to it avoids exporting list options through shell variables in run.sh, which is the kind of plumbing that only breaks on the device. """ global _OPTIONS if _OPTIONS is None: try: with open(f"{DATA_DIR}/options.json") as f: _OPTIONS = json.load(f) except (OSError, ValueError): _OPTIONS = {} val = _OPTIONS.get(key, default) return default if val is None else val DEFAULT_FILE_ROOTS = ["/media", "/mnt", "/share", "/config", "/addon_configs"] FILE_ACCESS_FILE = f"{DATA_DIR}/file_access.json" def file_access(): """Current file-access settings. Whatever the Settings tab last saved wins over the add-on option, because the add-on cannot write its own options.json. """ saved = load_json(FILE_ACCESS_FILE, {}) if isinstance(saved, dict) and saved.get("roots"): roots = saved["roots"] unlock = bool(saved.get("unlock_system")) else: roots = _opt("file_allowed_roots", None) unlock = False if not isinstance(roots, list) or not roots: roots = DEFAULT_FILE_ROOTS roots = [str(r).strip() for r in roots if str(r).strip()] return {"roots": roots or list(DEFAULT_FILE_ROOTS), "unlock_system": unlock} def file_roots(): """Directories the file API may touch. Everything else is refused.""" return file_access()["roots"] def _deny_list(): """/data and the kernel pseudo-filesystems always stay shut. The rest is only blocked until the admin unlocks it in the Settings tab.""" if file_access()["unlock_system"]: return safepath.HARD_DENY return safepath.DENY_ROOTS def _safe(path, roots=None): """Confine a client-supplied path. Raises safepath.PathError, which the error handler below turns into a generic 403.""" return safepath.resolve_in_roots( roots if roots is not None else file_roots(), path, deny=_deny_list()) @app.route("/api/settings/file-access", methods=["GET"]) def api_get_file_access(): acc = file_access() return jsonify({ "roots": acc["roots"], "unlock_system": acc["unlock_system"], "full_access": any(safepath.real(r) == safepath.real("/") for r in acc["roots"]), "defaults": DEFAULT_FILE_ROOTS, "always_blocked": list(safepath.HARD_DENY), "unlockable": list(safepath.SOFT_DENY), }) @app.route("/api/settings/file-access", methods=["POST"]) def api_set_file_access(): body = request.get_json(force=True) or {} if body.get("full_access"): roots, unlock = ["/"], True elif body.get("reset"): roots, unlock = list(DEFAULT_FILE_ROOTS), False else: raw = body.get("roots", None) if raw is None: acc = file_access() roots = acc["roots"] else: if not isinstance(raw, list): return jsonify({"error": "roots muss eine Liste sein"}), 400 roots = [] for r in raw: r = str(r).strip().rstrip("/") or "/" if not os.path.isabs(r): return jsonify({"error": f"„{r}“ ist kein absoluter Pfad"}), 400 if "\x00" in r: return jsonify({"error": "Ungültiger Pfad"}), 400 if r not in roots: roots.append(r) if not roots: return jsonify({"error": "Mindestens ein Ordner wird gebraucht"}), 400 unlock = bool(body.get("unlock_system", file_access()["unlock_system"])) save_json(FILE_ACCESS_FILE, {"roots": roots, "unlock_system": unlock}) print(f"[FILE-ACCESS] geaendert: roots={roots} unlock_system={unlock} ip={_client_ip()}", flush=True) return jsonify({"ok": True, "roots": roots, "unlock_system": unlock}) # ─────────────────────────── network recycle bin ──────────────── # # Samba's vfs_recycle moves files deleted over SMB into /.recycle # instead of removing them (generate_smb_conf.py switches it on). Clients # cannot trash files on a network share themselves - Nautilus and Windows # Explorer delete them for good - so this is the only safety net there is. RECYCLE_FILE = f"{DATA_DIR}/recycle.json" RECYCLE_DIR = ".recycle" RECYCLE_DEFAULT_DAYS = 30 RECYCLE_PURGE_INTERVAL = 6 * 3600 def recycle_settings(): saved = load_json(RECYCLE_FILE, {}) if not isinstance(saved, dict): saved = {} try: days = min(365, max(1, int(saved.get("days", RECYCLE_DEFAULT_DAYS)))) except (TypeError, ValueError): days = RECYCLE_DEFAULT_DAYS # since: when Simple NAS first ran with the bin. A .recycle left by an # earlier Samba setup predates it and is never purged automatically. since = saved.get("since") return {"enabled": bool(saved.get("enabled", True)), "days": days, "since": since if isinstance(since, (int, float)) else None} def _save_recycle(enabled, days): since = recycle_settings()["since"] save_json(RECYCLE_FILE, {"enabled": enabled, "days": days, "since": since or time.time()}) def _recycle_root(share_path): """The share's bin, or None if there is none or it is not a plain directory.""" root = os.path.join(share_path, RECYCLE_DIR) if os.path.islink(root) or not os.path.isdir(root): return None return root def recycle_purge(share_path, older_than_days=None, now=None, since=None): """Delete what has been in the bin longer than older_than_days; None empties it. Age is the ctime: the move into the bin sets it, and unlike the access time a later read (backup, virus scan) cannot push it back. With since, anything that was already in the bin before that moment stays. Returns (files, bytes) removed.""" root = _recycle_root(share_path) if root is None: return 0, 0 cutoff = None if older_than_days is None else (now or time.time()) - older_than_days * 86400 files = size = 0 for folder, dirs, names in os.walk(root, topdown=False): # os.walk lists symlinks to directories under dirs; they go like files links = [d for d in dirs if os.path.islink(os.path.join(folder, d))] for name in names + links: path = os.path.join(folder, name) try: st = os.lstat(path) if cutoff is None or (st.st_ctime < cutoff and (since is None or st.st_ctime >= since)): os.unlink(path) files += 1 size += st.st_size except OSError as e: print(f"[RECYCLE] {path}: {e}", flush=True) for name in dirs: try: os.rmdir(os.path.join(folder, name)) # only succeeds once it is empty except OSError: pass return files, size def recycle_usage(share_path, max_seconds=5): """Files and bytes in the share's bin; truncated when the walk ran out of time.""" root = _recycle_root(share_path) files = size = 0 if root is not None: deadline = time.time() + max_seconds for folder, _dirs, names in os.walk(root): if time.time() > deadline: return {"files": files, "bytes": size, "truncated": True} for name in names: try: size += os.lstat(os.path.join(folder, name)).st_size files += 1 except OSError: pass return {"files": files, "bytes": size, "truncated": False} def _recycle_purge_all(): settings = recycle_settings() days = settings["days"] for share in load_json(SHARES_FILE, []): path = share.get("path") if not path: continue files, size = recycle_purge(path, days, since=settings["since"] or time.time()) if files: print(f"[RECYCLE] {share.get('name')}: {files} Dateien ({size} Bytes) " f"aelter als {days} Tage geloescht", flush=True) def start_recycle_purge(): """Empty old entries every few hours - also while the bin is switched off, so what is already in it still goes away on schedule.""" if recycle_settings()["since"] is None: cur = recycle_settings() _save_recycle(cur["enabled"], cur["days"]) # remembers since = now def run(): time.sleep(120) # slow USB drives mount after the web UI starts while True: try: _recycle_purge_all() except Exception as e: print(f"[RECYCLE] Aufraeumen fehlgeschlagen: {e}", flush=True) time.sleep(RECYCLE_PURGE_INTERVAL) threading.Thread(target=run, daemon=True, name="recycle-purge").start() @app.route("/api/settings/recycle", methods=["GET"]) def api_get_recycle(): shares = [] for s in load_json(SHARES_FILE, []): path = s.get("path", "") shares.append({"name": s.get("name"), "path": path, "writable": s.get("writable", True), "timemachine": bool(s.get("timemachine")), **recycle_usage(path)}) return jsonify({**recycle_settings(), "dir": RECYCLE_DIR, "shares": shares}) @app.route("/api/settings/recycle", methods=["POST"]) def api_set_recycle(): body = request.get_json(force=True) or {} cur = recycle_settings() enabled = bool(body.get("enabled", cur["enabled"])) try: days = min(365, max(1, int(body.get("days", cur["days"])))) except (TypeError, ValueError): return jsonify({"error": "Tage: bitte eine Zahl von 1 bis 365"}), 400 _save_recycle(enabled, days) reload_samba() print(f"[RECYCLE] geaendert: enabled={enabled} days={days} ip={_client_ip()}", flush=True) return api_get_recycle() @app.route("/api/settings/recycle/empty", methods=["POST"]) def api_empty_recycle(): name = (request.get_json(force=True) or {}).get("name") share = next((s for s in load_json(SHARES_FILE, []) if s.get("name") == name), None) if share is None: return jsonify({"error": "Not found"}), 404 files, size = recycle_purge(share.get("path", "")) print(f"[RECYCLE] {name} geleert: {files} Dateien ({size} Bytes) ip={_client_ip()}", flush=True) return jsonify({"ok": True, "files": files, "bytes": size}) @app.errorhandler(safepath.PathError) def _handle_path_error(e): # The reason goes to the add-on log only - the client gets one flat answer # so the API cannot be used to probe which paths exist. print(f"[SAFEPATH] abgewiesen: {e}", flush=True) return jsonify({"error": "Pfad nicht erlaubt"}), 403 def _protected_paths(): """Paths that must never be deleted: the roots themselves, every mount point and every configured share directory.""" out = {safepath.real(r) for r in file_roots()} out.update(safepath.real(m.get("mountpoint", "")) for m in load_json(MOUNTS_FILE, []) if m.get("mountpoint")) out.update(safepath.real(s.get("path", "")) for s in load_json(SHARES_FILE, []) if s.get("path")) return out def _slash_is_root(): """True when "/" itself is allowed (the "all files" switch). The synthetic roots listing makes no sense then - it would contain one entry, "/", pointing back at itself, and the file manager could never get in.""" return any(safepath.real(r) == safepath.real("/") for r in file_roots()) def _roots_listing(): """Synthetic listing shown instead of the container root.""" entries = [] for r in file_roots(): try: real_r = safepath.real(r) except Exception: continue if not os.path.isdir(real_r): continue entries.append({ "name": r.strip("/") or r, "path": real_r, "is_dir": True, "size": None, "size_truncated": False, "modified": os.path.getmtime(real_r) if os.path.exists(real_r) else 0, "readable": os.access(real_r, os.R_OK), "is_symlink": False, }) return entries @app.route("/api/roots") def api_roots(): """The allowed roots, so the UI can offer them instead of hard-coded paths.""" return jsonify({"roots": [e["path"] for e in _roots_listing()]}) # ─────────────────────────── sharing (admin side) ─────────────── DEFAULT_SHARE_ROOTS = ["/media", "/mnt", "/share"] def share_roots(): roots = _opt("share_allowed_roots", None) if not isinstance(roots, list) or not roots: roots = DEFAULT_SHARE_ROOTS return [str(r).strip() for r in roots if str(r).strip()] def public_link_url(token): """Absolute URL for a link. The add-on cannot know its public hostname, so share_public_url must be configured; otherwise fall back to the address the browser used for this request plus the share port.""" base = (_opt("share_public_url", "") or "").strip().rstrip("/") fallback = not base if fallback: host = request.host.split(":")[0] if request else "localhost" base = f"http://{host}:{int(_opt('share_port', 8101))}" return f"{base}/s/{token}", fallback def _link_out(link): out = dict(link) out["url"], out["url_is_fallback"] = public_link_url(link["token"]) out["live"] = sharing_store.link_is_live(link) out.pop("password_hash", None) out["has_password"] = bool(link.get("password_hash")) out["locked"] = _link_locked(link["id"]) return out @app.errorhandler(sharing_store.ShareError) def _handle_share_error(e): return jsonify({"error": str(e)}), 400 @app.route("/api/sharing/status") def api_sharing_status(): links = sharing_store.list_links() _, fallback = public_link_url("x") clam = _clamd_state() snap = _lock_snapshot() return jsonify({ "sharing_enabled": bool(_opt("sharing_enabled", False)), "public_site_running": _share_running(), "sandboxed": _share_sandboxed(), "share_port": int(_opt("share_port", 8101)), "share_bind": _opt("share_bind", "0.0.0.0"), "public_url": (_opt("share_public_url", "") or "").strip(), "url_is_fallback": fallback, "admin_password_enabled": bool(_admin_auth.get("enabled")), "links": len(links), "links_live": sum(1 for l in links if sharing_store.link_is_live(l)), "accounts": len(sharing_store.list_accounts()), "allowed_roots": share_roots(), "clamav_enabled": bool(_opt("share_clamav_enabled", False)), "clamav_target": f"{_opt('share_clamav_host', '127.0.0.1')}:{_opt('share_clamav_port', 3310)}", "clamav_on_error": _opt("share_clamav_on_error", "reject"), "clamav_reachable": clam["ok"], "clamav_message": clam["message"], "authfail_limit": AUTHFAIL_IP[0], "authfail_window_min": AUTHFAIL_IP[1] // 60, "locks": snap["locks"], "authfail": snap["counters"], "collabora_enabled": bool(_opt("collabora_enabled", False)), "collabora_url": (_opt("collabora_url", "") or "").strip(), "links_editable": sum(1 for l in links if l.get("allow_edit")), }) CROWDSEC_DIR = "/config/.storage/crowdsec/config" CROWDSEC_FILES = { "parsers/s01-parse/simplenas-share.yaml": "simplenas-share-parser.yaml", "scenarios/simplenas-share.yaml": "simplenas-share-scenarios.yaml", "acquis.d/simplenas-share.yaml": "simplenas-share-acquis.yaml", } CROWDSEC_SETUP_FILE = f"{DATA_DIR}/crowdsec_setup.json" # Both add-ons map the Home Assistant config directory, so a file under # /config is the one both can reach. /share is mapped here but NOT into the # CrowdSec add-on, which is why the first default left CrowdSec tailing a # file it could never see ("No matching files for pattern"). DEFAULT_EXPORT_PATH = "/config/.simplenas/share_access.log" LEGACY_EXPORT_PATHS = ("/share/simplenas/share_access.log",) def _crowdsec_src(name): return os.path.join(os.path.dirname(os.path.abspath(__file__)), "crowdsec", name) def _share_export_path(): """Where the second copy of the access log goes. The add-on option wins; otherwise the path the CrowdSec setup button chose (the add-on cannot write its own options.json, so that choice lives in /data).""" export = (_opt("share_log_export_path", "") or "").strip() if export: return export saved = load_json(CROWDSEC_SETUP_FILE, {}) path = str(saved.get("export_path") or "").strip() if isinstance(saved, dict) else "" return DEFAULT_EXPORT_PATH if path in LEGACY_EXPORT_PATHS else path def _crowdsec_can_read(path): """Can the CrowdSec add-on open this file? Both add-ons map the Home Assistant config directory, so /config is the one place each of them reaches. /share is mapped here but not there - a log kept there makes CrowdSec start with "No matching files for pattern" and no scenario ever fires. """ return bool(path) and path.startswith("/config/") def _crowdsec_log_path(export): """The file the acquisition should name: the configured export when CrowdSec can read it, otherwise the default next to it.""" return export if _crowdsec_can_read(export) else DEFAULT_EXPORT_PATH def _crowdsec_installed(): return os.path.exists(os.path.join(CROWDSEC_DIR, "acquis.d", "simplenas-share.yaml")) def _apply_export_paths(export, announce=False): """Open the configured export and, when CrowdSec is set up, make sure the file its acquisition names is written too. Returns the CrowdSec path.""" max_mb = _opt("share_log_max_mb", 5) if export: accesslog.set_export(export, max_mb, slot="main") if announce: print(f"[SHARE] Zugriffsprotokoll wird zusaetzlich nach {export} geschrieben", flush=True) cs_path = _crowdsec_log_path(export) if _crowdsec_installed() or not export: accesslog.set_export(cs_path, max_mb, slot="crowdsec") if announce and cs_path != export: print(f"[SHARE] CrowdSec liest das Protokoll unter {cs_path} " f"(share_log_export_path zeigt auf einen Ordner, den CrowdSec nicht sieht)", flush=True) return cs_path def _write_crowdsec_acquis(export): """Write the acquisition file pointing at `export`. Returns True on change.""" dest = os.path.join(CROWDSEC_DIR, "acquis.d", "simplenas-share.yaml") with open(_crowdsec_src("simplenas-share-acquis.yaml"), encoding="utf-8") as f: # Replace the path the template lists, whatever it is - keying on # DEFAULT_EXPORT_PATH would silently write the template's own path # through unchanged the moment the two drift apart. content = re.sub(r"(?m)^(\s*-\s+)\S+$", lambda m: m.group(1) + export, f.read(), count=1) if os.path.exists(dest): with open(dest, encoding="utf-8") as f: if f.read() == content: return False os.makedirs(os.path.dirname(dest), exist_ok=True) with open(dest, "w", encoding="utf-8") as f: f.write(content) return True def _sync_crowdsec_acquis(export): """Keep an already installed acquisition file on the current export path. Only touches a file this add-on wrote itself, and only when the path it names is out of date - otherwise CrowdSec keeps tailing a file nobody writes any more, and the scenarios never fire. """ dest = os.path.join(CROWDSEC_DIR, "acquis.d", "simplenas-share.yaml") if not export or not os.path.exists(dest): return False try: with open(dest, encoding="utf-8") as f: if export in f.read(): return False _write_crowdsec_acquis(export) except OSError as e: print(f"[SHARE] CrowdSec-Acquisition nicht aktualisierbar: {e}", flush=True) return False print(f"[SHARE] CrowdSec-Acquisition auf {export} umgestellt - CrowdSec-Addon neu starten", flush=True) return True _CLAMD_PROBE = {"ts": 0.0, "ok": None, "message": ""} def _clamd_state(max_age=60): """Is clamd actually answering? Cached, because the status card polls. With share_clamav_on_error=reject an unreachable clamd silently turns every upload into a rejection, so this belongs in the status, not only behind the Test button. """ if not _opt("share_clamav_enabled", False): return {"ok": None, "message": ""} now = time.time() if now - _CLAMD_PROBE["ts"] > max_age or _CLAMD_PROBE["ok"] is None: host = str(_opt("share_clamav_host", "127.0.0.1") or "127.0.0.1") port = int(_opt("share_clamav_port", 3310) or 3310) ok = False try: ok = bool(clamav.ping(host, port, timeout=3)) except Exception: ok = False _CLAMD_PROBE.update(ts=now, ok=ok, message="" if ok else f"clamd auf {host}:{port} antwortet nicht auf PING") return {"ok": _CLAMD_PROBE["ok"], "message": _CLAMD_PROBE["message"]} @app.route("/api/sharing/crowdsec/status") def api_sharing_crowdsec_status(): present = os.path.isdir(CROWDSEC_DIR) installed = {rel: os.path.exists(os.path.join(CROWDSEC_DIR, rel)) for rel in CROWDSEC_FILES} export = _share_export_path() cs_path = _crowdsec_log_path(export) option = (_opt("share_log_export_path", "") or "").strip() return jsonify({ "crowdsec_config_found": present, "config_dir": CROWDSEC_DIR, "installed": installed, "all_installed": present and all(installed.values()), "export_path": export, "crowdsec_path": cs_path, "default_export_path": DEFAULT_EXPORT_PATH, "export_active": _crowdsec_export_active(cs_path), "option_path_blind": bool(option) and not _crowdsec_can_read(option), }) @app.route("/api/sharing/crowdsec/install", methods=["POST"]) def api_sharing_crowdsec_install(): """Copy parser, scenarios and acquisition into the CrowdSec add-on's config. Both add-ons see /config, so this is a plain file copy. CrowdSec picks the files up after a restart of its add-on. Without share_log_export_path the export starts right here at the default path - no option, no restart.""" if not os.path.isdir(CROWDSEC_DIR): return jsonify({"error": f"CrowdSec-Konfiguration nicht gefunden ({CROWDSEC_DIR}). Ist das CrowdSec-Addon installiert?"}), 404 export = _share_export_path() or DEFAULT_EXPORT_PATH if not (_opt("share_log_export_path", "") or "").strip(): save_json(CROWDSEC_SETUP_FILE, {"export_path": export}) cs_path = _crowdsec_log_path(export) max_mb = _opt("share_log_max_mb", 5) if _share_sandboxed(): # The worker writes the log; this process never emits a line, so a # log handler here would feed nothing. Point the shipper at the file. _start_log_shipper(os.path.join(DATA_DIR, sharesandbox.LOG_FILE), cs_path, max_mb) else: if export and not accesslog.set_export(export, max_mb, slot="main"): return jsonify({"error": f"Protokoll-Export nach {export} nicht moeglich (Ordner nicht beschreibbar?)."}), 500 if not accesslog.set_export(cs_path, max_mb, slot="crowdsec"): return jsonify({"error": f"Protokoll-Export nach {cs_path} nicht moeglich (Ordner nicht beschreibbar?)."}), 500 export = cs_path written = [] for rel, src in CROWDSEC_FILES.items(): if src.endswith("acquis.yaml"): _write_crowdsec_acquis(export) written.append(rel) continue dest = os.path.join(CROWDSEC_DIR, rel) os.makedirs(os.path.dirname(dest), exist_ok=True) with open(_crowdsec_src(src), encoding="utf-8") as f: content = f.read() with open(dest, "w", encoding="utf-8") as f: f.write(content) written.append(rel) print(f"[SHARE] CrowdSec-Dateien installiert: {', '.join(written)}", flush=True) return jsonify({"ok": True, "written": written, "restart_needed": "CrowdSec", "export_path": export, "export_active": _crowdsec_export_active(export)}) @app.route("/api/sharing/clamav/test", methods=["POST"]) def api_sharing_clamav_test(): """PING, then an EICAR round trip - the difference between configured and working.""" host = str(_opt("share_clamav_host", "127.0.0.1") or "127.0.0.1") port = int(_opt("share_clamav_port", 3310) or 3310) ok, msg = clamav.self_test(host, port) print(f"[SHARE] ClamAV-Test {host}:{port}: {'OK' if ok else 'FEHLER'} - {msg}", flush=True) return jsonify({"ok": ok, "message": msg, "enabled": bool(_opt("share_clamav_enabled", False))}) _COLLABORA_DISCOVERY = wopi.Discovery() @app.route("/api/sharing/collabora/test", methods=["POST"]) def api_sharing_collabora_test(): """Fetch discovery the way the share site does: reachable, how many file types, proof key present. The worker keeps its own cache.""" url = (_opt("collabora_url", "") or "").strip() if not url: return jsonify({"ok": False, "message": "collabora_url ist nicht gesetzt", "enabled": bool(_opt("collabora_enabled", False))}) d = _COLLABORA_DISCOVERY.get(url, _opt("collabora_internal_url", ""), bool(_opt("collabora_verify_tls", True)), force=True) if not d: msg = f"Discovery nicht erreichbar: {_COLLABORA_DISCOVERY.error}" ok = False else: exts = sorted(d["actions"]) editable = sum(1 for a in d["actions"].values() if "edit" in a) msg = (f"{len(exts)} Dateitypen, {editable} bearbeitbar" + ("" if d.get("proof") else " - WARNUNG: kein Proof-Key, Signaturpruefung nicht moeglich")) ok = True print(f"[SHARE] Collabora-Test {url}: {'OK' if ok else 'FEHLER'} - {msg}", flush=True) return jsonify({"ok": ok, "message": msg, "enabled": bool(_opt("collabora_enabled", False))}) @app.route("/api/sharing/links", methods=["GET"]) def api_sharing_links(): return jsonify([_link_out(l) for l in sharing_store.list_links()]) @app.route("/api/sharing/links", methods=["POST"]) def api_sharing_create_link(): body = request.get_json(force=True) or {} link = sharing_store.create_link(body, created_by=session.get("user", "admin"), shares=load_json(SHARES_FILE, []), allowed_roots=share_roots()) print(f"[SHARE] Link angelegt: {link['name']} ({link['id']}) root={link['root']} by={session.get('user','admin')}", flush=True) return jsonify(_link_out(link)), 201 @app.route("/api/sharing/links/", methods=["PUT"]) def api_sharing_update_link(link_id): body = request.get_json(force=True) or {} link = sharing_store.update_link(link_id, body, shares=load_json(SHARES_FILE, []), allowed_roots=share_roots()) return jsonify(_link_out(link)) @app.route("/api/sharing/links/", methods=["DELETE"]) def api_sharing_delete_link(link_id): sharing_store.delete_link(link_id) print(f"[SHARE] Link geloescht: {link_id}", flush=True) return jsonify({"ok": True}) @app.route("/api/sharing/links//rotate", methods=["POST"]) def api_sharing_rotate(link_id): link = sharing_store.rotate_token(link_id) print(f"[SHARE] Token erneuert: {link_id}", flush=True) return jsonify(_link_out(link)) @app.route("/api/sharing/links//reset-counters", methods=["POST"]) def api_sharing_reset_counters(link_id): if not sharing_store.get_link(link_id): return jsonify({"error": "Link nicht gefunden"}), 404 sharing_store.reset_counters(link_id) return jsonify(_link_out(sharing_store.get_link(link_id))) @app.route("/api/sharing/links//unlock", methods=["POST"]) def api_sharing_unlock(link_id): if not sharing_store.get_link(link_id): return jsonify({"error": "Link nicht gefunden"}), 404 _do_unlock(f"authfail:link:{link_id}") return jsonify(_link_out(sharing_store.get_link(link_id))) @app.route("/api/sharing/locks/unlock", methods=["POST"]) def api_sharing_locks_unlock(): """Lift one lockout shown in the Sharing card (own IP after testing, say).""" key = str((request.get_json(silent=True) or {}).get("key", "")).strip() if not key or not key.startswith(("authfail:", "ban:", "scan:")): return jsonify({"error": "Unbekannte Sperre"}), 400 _do_unlock(key) print(f"[SHARE] Sperre aufgehoben: {key}", flush=True) return jsonify({"ok": True, "key": key}) @app.route("/api/sharing/log", methods=["GET"]) def api_sharing_log(): limit = max(1, min(int(request.args.get("limit", 200)), 1000)) return jsonify(accesslog.tail(limit, request.args.get("event") or None, request.args.get("link_id") or None)) @app.route("/api/sharing/log", methods=["DELETE"]) def api_sharing_log_clear(): accesslog.clear() return jsonify({"ok": True}) @app.route("/api/sharing/accounts", methods=["GET"]) def api_sharing_accounts(): return jsonify(sharing_store.list_accounts()) @app.route("/api/sharing/accounts", methods=["POST"]) def api_sharing_create_account(): body = request.get_json(force=True) or {} acc = sharing_store.create_account(body.get("username", ""), body.get("password", ""), body.get("display_name", ""), created_by=session.get("user", "admin")) print(f"[SHARE] Konto angelegt: {acc['username']}", flush=True) return jsonify(acc), 201 @app.route("/api/sharing/accounts/", methods=["PUT"]) def api_sharing_update_account(username): body = request.get_json(force=True) or {} return jsonify(sharing_store.update_account(username, body)) @app.route("/api/sharing/accounts/", methods=["DELETE"]) def api_sharing_delete_account(username): sharing_store.delete_account(username) print(f"[SHARE] Konto geloescht: {username}", flush=True) return jsonify({"ok": True}) # ─────────────────────────── browse API ───────────────────────── @app.route("/api/browse") def api_browse(): path = request.args.get("path", "/").strip() if path in ("", "/") and not _slash_is_root(): # The container root is not browsable - offer the allowed roots instead roots = _roots_listing() return jsonify({"path": "/", "parent": None, "is_root": True, "breadcrumb": [{"name": "/", "path": "/"}], "entries": [{"name": e["name"], "path": e["path"], "readable": e["readable"], "is_symlink": False} for e in roots]}) path = _safe(_remap_path(os.path.abspath(path))) # The folder picker can be handed a file path (typed in, or a stale # entry); listdir() on it raised NotADirectoryError and a 500. if os.path.exists(path) and not os.path.isdir(path): return jsonify({"error": "Kein Ordner: " + path}), 400 entries = [] try: for name in sorted(os.listdir(path)): full = os.path.join(path, name) try: if not os.path.isdir(full): continue # folgt Symlinks automatisch → korrekt entries.append({ "name": name, "path": full, "readable": os.access(full, os.R_OK), "is_symlink": os.path.islink(full), }) except Exception: # Symlink-Fallback: Eintrag trotzdem anzeigen entries.append({ "name": name, "path": full, "readable": False, "is_symlink": True, }) except PermissionError: # Fallback: use ls via subprocess (runs as root) try: result = subprocess.run( ["ls", "-1a", path], # -a statt Glob, damit Symlinks auftauchen capture_output=True, text=True, timeout=5) if result.returncode == 0: for name in result.stdout.strip().split("\n"): name = name.strip() if not name or name in (".", ".."): continue full = os.path.join(path, name) if os.path.isdir(full): # folgt Symlinks → Symlink-Dirs werden inkludiert entries.append({"name": name, "path": full, "readable": True, "is_symlink": os.path.islink(full)}) except Exception: return jsonify({"error": "Kein Zugriff auf " + path}), 403 except FileNotFoundError: return jsonify({"error": "Pfad nicht gefunden"}), 404 return jsonify({"path": path, "parent": _parent_of(path), "breadcrumb": _breadcrumb(path), "entries": entries}) @app.route("/api/mkdir", methods=["POST"]) def api_mkdir(): body = request.get_json(force=True) path = body.get("path", "").strip() if not path: return jsonify({"error": "path required"}), 400 path = _safe(path) try: os.makedirs(path, exist_ok=True) return jsonify({"ok": True, "path": path}) except Exception as e: return jsonify({"error": str(e)}), 500 # ─────────────────────────── file manager API ─────────────────── def _remap_path(path): """HA only exposes addon_configs at /addon_configs/ inside the container. /homeassistant/addons_config/... and /config/addons_config/... are not accessible, so redirect to the real mount point.""" for prefix in ("/homeassistant/addons_config/", "/config/addons_config/"): if path.startswith(prefix): remapped = "/addon_configs/" + path[len(prefix):] print(f"[REMAP] {path} -> {remapped} (exists={os.path.isdir(remapped)})", flush=True) return remapped if path == prefix.rstrip("/"): print(f"[REMAP] {path} -> /addon_configs (exists={os.path.isdir('/addon_configs')})", flush=True) return "/addon_configs" return path def _breadcrumb(path): """Crumbs from the allowed root down to path - never above it, so the UI cannot offer a click into the container root.""" real_path = safepath.real(path) base = None for r in file_roots(): real_r = safepath.real(r) if safepath.is_within(real_r, real_path): base = real_r break parts, cur = [], real_path while True: parts.insert(0, {"name": os.path.basename(cur) or cur, "path": cur}) if base is not None and cur == base: break parent = os.path.dirname(cur) if parent == cur: break cur = parent if not parts or parts[0]["path"] != "/": parts.insert(0, {"name": "/", "path": "/"}) return parts def _parent_of(path): """Parent directory, or '/' (the roots listing) when path is a root.""" real_path = safepath.real(path) if real_path == safepath.real("/"): return None for r in file_roots(): if real_path == safepath.real(r): return None if _slash_is_root() else "/" parent = os.path.dirname(real_path) return parent if parent != real_path else None def _recursive_dir_size(path, max_seconds=8): """Sum file sizes recursively under `path`. Returns (bytes, truncated). truncated=True when the walk was aborted due to the time budget.""" total = 0 deadline = time.time() + max_seconds try: for root, dirs, files in os.walk(path, followlinks=False): if time.time() > deadline: return total, True for f in files: try: total += os.lstat(os.path.join(root, f)).st_size except OSError: pass except Exception: pass return total, False @app.route("/api/files") def api_files(): """List files and directories (unlike /browse which is dirs only). ?dir_size=1 recursively computes the size of each directory entry (capped per directory by _recursive_dir_size). Off by default because it can be slow on large trees / slow USB media.""" path = request.args.get("path", "/").strip() want_dir_size = request.args.get("dir_size", "0") in ("1", "true", "yes") if path in ("", "/") and not _slash_is_root(): return jsonify({"path": "/", "parent": None, "is_root": True, "breadcrumb": [{"name": "/", "path": "/"}], "entries": _roots_listing()}) path = _safe(_remap_path(os.path.abspath(path or "/"))) entries = [] try: for name in sorted(os.listdir(path)): full = os.path.join(path, name) try: is_link = os.path.islink(full) size_truncated = False try: st = os.stat(full) # folgt Symlink is_dir = os.path.isdir(full) if is_dir: if want_dir_size: size, size_truncated = _recursive_dir_size(full) else: size = None else: size = st.st_size mtime = st.st_mtime except OSError: # Symlink-Ziel nicht auflösbar (z.B. bind-mount außerhalb Container) # → als Verzeichnis behandeln wenn es ein Symlink ist is_dir = is_link size = None try: mtime = os.lstat(full).st_mtime except Exception: mtime = 0 entries.append({ "name": name, "path": full, "is_dir": is_dir, "size": size, "size_truncated": size_truncated, "modified": mtime, "readable": os.access(full, os.R_OK), "is_symlink": os.path.islink(full), }) except Exception: # Letzter Fallback: Eintrag trotzdem anzeigen statt überspringen entries.append({ "name": name, "path": full, "is_dir": True, "size": None, "modified": 0, "readable": False, "is_symlink": False, }) except PermissionError: return jsonify({"error": "Kein Zugriff"}), 403 except FileNotFoundError: return jsonify({"error": "Pfad nicht gefunden"}), 404 return jsonify({"path": path, "parent": _parent_of(path), "breadcrumb": _breadcrumb(path), "entries": entries}) @app.route("/api/files/delete", methods=["POST"]) def api_files_delete(): body = request.get_json(force=True) path = _safe(body.get("path", "").strip()) if path in _protected_paths(): return jsonify({"error": "Dieser Pfad kann nicht gelöscht werden"}), 400 try: if os.path.isdir(path): shutil.rmtree(path) else: os.remove(path) return jsonify({"ok": True}) except Exception as e: return jsonify({"error": str(e)}), 500 @app.route("/api/files/rename", methods=["POST"]) def api_files_rename(): body = request.get_json(force=True) path = body.get("path", "").strip() new_name = body.get("new_name", "").strip() if not path or not new_name: return jsonify({"error": "path and new_name required"}), 400 path = _safe(path) if path in _protected_paths(): return jsonify({"error": "Dieser Pfad kann nicht umbenannt werden"}), 400 if new_name in (".", "..") or "/" in new_name or "\\" in new_name or "\x00" in new_name: return jsonify({"error": "Ungültiger Name"}), 400 new_path = _safe(os.path.join(os.path.dirname(path), new_name)) try: os.rename(path, new_path) return jsonify({"ok": True, "new_path": new_path}) except Exception as e: return jsonify({"error": str(e)}), 500 def _file_action_dest(src, dst): """Where a copy or move lands. An existing folder means "into it", as with cp and mv: the folder picker in the dialog hands over the target folder itself, and copying a folder there used to fail with "File exists".""" if os.path.isdir(dst): return os.path.join(dst, os.path.basename(src)) return dst def _file_action_refused(src, dst, verb): """Error response when a copy or move must not run, else None. Nothing is overwritten - same rule as for uploads.""" if os.path.lexists(dst): return jsonify({"error": f"„{os.path.basename(dst)}“ existiert bereits in {os.path.dirname(dst)}"}), 409 if os.path.isdir(src) and safepath.is_within(src, dst): return jsonify({"error": f"Ein Ordner kann nicht in sich selbst {verb} werden"}), 400 return None @app.route("/api/files/copy", methods=["POST"]) def api_files_copy(): body = request.get_json(force=True) src = body.get("src", "").strip() dst = body.get("dst", "").strip() if not src or not dst: return jsonify({"error": "src and dst required"}), 400 src, dst = _safe(src), _safe(dst) dst = _file_action_dest(src, dst) refused = _file_action_refused(src, dst, "kopiert") if refused: return refused try: if os.path.isdir(src): shutil.copytree(src, dst) else: os.makedirs(os.path.dirname(dst), exist_ok=True) # O_EXCL: a file that appeared since the check is not overwritten with open(src, "rb") as fin, os.fdopen(safepath.open_new_file(dst), "wb") as fout: shutil.copyfileobj(fin, fout) shutil.copystat(src, dst, follow_symlinks=False) return jsonify({"ok": True}) except Exception as e: return jsonify({"error": str(e)}), 500 @app.route("/api/files/move", methods=["POST"]) def api_files_move(): body = request.get_json(force=True) src = body.get("src", "").strip() dst = body.get("dst", "").strip() if not src or not dst: return jsonify({"error": "src and dst required"}), 400 src, dst = _safe(src), _safe(dst) if src in _protected_paths(): return jsonify({"error": "Dieser Pfad kann nicht verschoben werden"}), 400 dst = _file_action_dest(src, dst) refused = _file_action_refused(src, dst, "verschoben") if refused: return refused try: os.makedirs(os.path.dirname(dst), exist_ok=True) shutil.move(src, dst) return jsonify({"ok": True}) except Exception as e: return jsonify({"error": str(e)}), 500 @app.route("/api/files/upload", methods=["POST"]) def api_files_upload(): target_dir = _safe(request.form.get("path", "/media")) if "file" not in request.files: return jsonify({"error": "no file"}), 400 f = request.files["file"] if not f.filename: return jsonify({"error": "no filename"}), 400 # The browser controls this name; strip any directory part before use. leaf = os.path.basename(f.filename.replace("\\", "/")).strip() if not leaf or leaf in (".", "..") or "\x00" in leaf: return jsonify({"error": "Ungültiger Dateiname"}), 400 os.makedirs(target_dir, exist_ok=True) dest = safepath.resolve_new(target_dir, "", leaf) if os.path.exists(dest): return jsonify({"error": f"„{leaf}“ existiert bereits"}), 409 f.save(dest) return jsonify({"ok": True, "path": dest}) # ── Upload in pieces ────────────────────────────────────────────────── # One request per file failed silently for large files: behind Cloudflare # (free plan) a request body above 100 MB is refused before it reaches Home # Assistant, so the add-on never saw it and the page showed nothing. The UI # now sends each file as start -> chunk ... -> (done with the last chunk), # every piece far below that limit, and shows progress in between. # The part file lives in the target folder, so finishing is a rename on the # same filesystem. State is in memory: a restart drops unfinished uploads # (the browser then reports "unknown upload"); their .part files are # removed by the sweep only while the add-on keeps running. UPLOAD_CHUNK_BYTES = 8 * 1024 * 1024 UPLOAD_STALE_S = 6 * 3600 _UPLOADS = {} _UPLOADS_LOCK = threading.Lock() def _upload_drop(uid): st = _UPLOADS.pop(uid, None) if st: try: os.unlink(st["part"]) except OSError: pass def _upload_sweep(now): for uid in [u for u, st in _UPLOADS.items() if now - st["ts"] > UPLOAD_STALE_S]: _upload_drop(uid) def _upload_finish(uid, st): """Move the finished part file into place without overwriting anything.""" dest = st["dest"] try: try: os.link(st["part"], dest) # fails if dest exists, never follows it os.unlink(st["part"]) except FileExistsError: raise except OSError: # exFAT/FAT USB drives have no hard links if os.path.lexists(dest): raise FileExistsError(dest) os.rename(st["part"], dest) except FileExistsError: with _UPLOADS_LOCK: _upload_drop(uid) return jsonify({"error": f"„{os.path.basename(dest)}“ existiert bereits"}), 409 with _UPLOADS_LOCK: _UPLOADS.pop(uid, None) return jsonify({"ok": True, "done": True, "path": dest}) @app.route("/api/files/upload/start", methods=["POST"]) def api_files_upload_start(): body = request.get_json(force=True, silent=True) or {} target_dir = _safe(body.get("path") or "/media") leaf = os.path.basename(str(body.get("name") or "").replace("\\", "/")).strip() if not leaf or leaf in (".", "..") or "\x00" in leaf: return jsonify({"error": "Ungültiger Dateiname"}), 400 try: size = int(body.get("size")) except (TypeError, ValueError): return jsonify({"error": "Dateigröße fehlt"}), 400 if size < 0: return jsonify({"error": "Dateigröße fehlt"}), 400 if size > MAX_UPLOAD_BYTES: return jsonify({"error": f"Datei zu groß (höchstens {MAX_UPLOAD_BYTES // (1024 ** 3)} GB)"}), 413 os.makedirs(target_dir, exist_ok=True) dest = safepath.resolve_new(target_dir, "", leaf) if os.path.lexists(dest): return jsonify({"error": f"„{leaf}“ existiert bereits"}), 409 uid = secrets.token_hex(16) part = os.path.join(os.path.dirname(dest), f".upload-{uid}.part") os.close(safepath.open_new_file(part)) st = {"part": part, "dest": dest, "size": size, "written": 0, "ts": time.time(), "lock": threading.Lock()} with _UPLOADS_LOCK: _upload_sweep(st["ts"]) _UPLOADS[uid] = st if size == 0: return _upload_finish(uid, st) return jsonify({"ok": True, "id": uid, "chunk": UPLOAD_CHUNK_BYTES}) @app.route("/api/files/upload/chunk", methods=["POST"]) def api_files_upload_chunk(): uid = request.args.get("id", "") try: offset = int(request.args.get("offset", "")) except ValueError: return jsonify({"error": "offset fehlt"}), 400 with _UPLOADS_LOCK: st = _UPLOADS.get(uid) if not st: return jsonify({"error": "Upload unbekannt oder abgelaufen, bitte neu starten"}), 404 if not st["lock"].acquire(blocking=False): return jsonify({"error": "Stück wird noch geschrieben", "written": st["written"]}), 409 try: if offset != st["written"]: # A retried piece that already landed: tell the browser where to go on return jsonify({"error": "Versatz passt nicht", "written": st["written"]}), 409 data = request.get_data(cache=False) if len(data) > UPLOAD_CHUNK_BYTES or st["written"] + len(data) > st["size"]: return jsonify({"error": "Stück zu groß"}), 400 if not data: return jsonify({"error": "Leeres Stück"}), 400 with open(st["part"], "ab") as out: out.write(data) st["written"] += len(data) st["ts"] = time.time() if st["written"] < st["size"]: return jsonify({"ok": True, "written": st["written"]}) finally: st["lock"].release() return _upload_finish(uid, st) @app.route("/api/files/upload/abort", methods=["POST"]) def api_files_upload_abort(): uid = (request.get_json(force=True, silent=True) or {}).get("id", "") with _UPLOADS_LOCK: _upload_drop(uid) return jsonify({"ok": True}) @app.route("/api/files/download") def api_files_download(): from flask import send_file path = _safe(request.args.get("path", "").strip()) if not os.path.isfile(path): return jsonify({"error": "Datei nicht gefunden"}), 404 return send_file(path, as_attachment=True) @app.route("/api/files/zip") def api_files_zip(): """Stream a folder as a ZIP - same generator as the public site.""" folder = _safe(request.args.get("path", "").strip()) if not os.path.isdir(folder): return jsonify({"error": "Ordner nicht gefunden"}), 404 limit_bytes = int(_opt("share_zip_max_gb", 5) or 5) * 1024 ** 3 try: total, count = zipstream.preflight(folder, limit_bytes, int(_opt("share_zip_max_files", 10000) or 10000)) except zipstream.TooBig as e: return jsonify({"error": f"Ordner zu groß für ZIP (Grenze {e.limit_bytes // 1024**3} GB / {e.limit_files} Dateien)"}), 413 name = (os.path.basename(folder.rstrip("/")) or "archive") + ".zip" resp = app.response_class(zipstream.stream(folder, total), mimetype="application/zip") resp.headers["Content-Disposition"] = share_web.content_disposition(name) resp.headers["X-Accel-Buffering"] = "no" return resp @app.route("/api/files/view") def api_files_view(): """Serve a file inline (for browser-native preview of PDFs, images, videos, audio, text). Falls back to download for unknown MIME types.""" from flask import send_file import mimetypes path = _safe(request.args.get("path", "").strip()) if not os.path.isfile(path): return jsonify({"error": "Datei nicht gefunden"}), 404 mime, _ = mimetypes.guess_type(path) # send_file in modern Flask supports Range requests via conditional=True # which is the default — videos/audio can seek without full download return send_file(path, mimetype=mime, as_attachment=False, conditional=True) @app.route("/api/files/content") def api_files_content(): path = _safe(request.args.get("path", "").strip()) if not os.path.isfile(path): return jsonify({"error": "Datei nicht gefunden"}), 404 try: with open(path, "r", encoding="utf-8", errors="replace") as f: content = f.read() return jsonify({"ok": True, "content": content}) except Exception as e: return jsonify({"error": str(e)}), 500 @app.route("/api/files/write", methods=["POST"]) def api_files_write(): body = request.get_json(force=True) path = _safe(body.get("path", "").strip()) content = body.get("content", "") try: with open(path, "w", encoding="utf-8") as f: f.write(content) return jsonify({"ok": True}) except Exception as e: return jsonify({"error": str(e)}), 500 # ─────────────────────────── backup API ───────────────────────── BACKUPS_FILE = f"{DATA_DIR}/backups.json" _backup_status = {} # runtime status: {job_id: {running, progress, last_run, last_error}} @app.route("/api/backups", methods=["GET"]) def api_get_backups(): jobs = load_json(BACKUPS_FILE, []) for j in jobs: st = _backup_status.get(j["id"], {}) j["running"] = st.get("running", False) j["progress"] = st.get("progress", "") j["last_run"] = st.get("last_run", j.get("last_run", "")) j["last_error"] = st.get("last_error", j.get("last_error", "")) return jsonify(jobs) @app.route("/api/backups", methods=["POST"]) def api_create_backup(): body = request.get_json(force=True) name = body.get("name", "").strip() src = body.get("src", "").strip() dst = body.get("dst", "").strip() if not name or not src or not dst: return jsonify({"error": "name, src and dst required"}), 400 src, dst = _safe(src), _safe(dst) import uuid job = { "id": str(uuid.uuid4())[:8], "name": name, "src": src, "dst": dst, "schedule": body.get("schedule", "manual"), "keep": body.get("keep", 3), "last_run": "", "last_error": "", } jobs = load_json(BACKUPS_FILE, []) jobs.append(job) save_json(BACKUPS_FILE, jobs) return jsonify(job), 201 @app.route("/api/backups/", methods=["PUT"]) def api_update_backup(job_id): body = request.get_json(force=True) jobs = load_json(BACKUPS_FILE, []) for j in jobs: if j["id"] == job_id: for k in ("src", "dst"): if k in body: body[k] = _safe(str(body[k]).strip()) for k in ("name", "src", "dst", "schedule", "keep"): if k in body: j[k] = body[k] save_json(BACKUPS_FILE, jobs) return jsonify(j) return jsonify({"error": "Not found"}), 404 @app.route("/api/backups/", methods=["DELETE"]) def api_delete_backup(job_id): jobs = load_json(BACKUPS_FILE, []) jobs = [j for j in jobs if j["id"] != job_id] save_json(BACKUPS_FILE, jobs) return jsonify({"ok": True}) @app.route("/api/backups//run", methods=["POST"]) def api_run_backup(job_id): jobs = load_json(BACKUPS_FILE, []) job = next((j for j in jobs if j["id"] == job_id), None) if not job: return jsonify({"error": "Not found"}), 404 if _backup_status.get(job_id, {}).get("running"): return jsonify({"error": "Backup läuft bereits"}), 409 import threading def run_backup(j): import time, datetime jid = j["id"] _backup_status[jid] = {"running": True, "progress": "Starte...", "last_error": ""} try: # Re-check at run time: the job file could have been edited by hand, # and this starts rsync as root. src = _safe(j["src"].rstrip("/")) dst_base = _safe(j["dst"].rstrip("/")) ts = datetime.datetime.now().strftime("%Y%m%d_%H%M%S") dst_dir = os.path.join(dst_base, f"{j['name']}_{ts}") _backup_status[jid]["progress"] = f"Kopiere {src} → {dst_dir}" # Use rsync for better performance and progress. Deleted files # waiting in a share's recycle bin are not worth a backup. proc = subprocess.run( ["rsync", "-a", "--info=progress2", f"--exclude={RECYCLE_DIR}/", src + "/", dst_dir + "/"], capture_output=True, text=True, timeout=3600, preexec_fn=remote_backup._background_priority) # like the external backups: HA first if proc.returncode != 0: # Fallback to shutil if rsync not available if "not found" in (proc.stderr or ""): shutil.copytree(src, dst_dir, ignore=shutil.ignore_patterns(RECYCLE_DIR)) else: raise Exception(proc.stderr or f"rsync failed (rc={proc.returncode})") # Cleanup old backups (keep N newest) keep = j.get("keep", 3) prefix = j["name"] + "_" parent = j["dst"].rstrip("/") if os.path.isdir(parent): existing = sorted([d for d in os.listdir(parent) if d.startswith(prefix) and os.path.isdir(os.path.join(parent, d))], reverse=True) for old in existing[keep:]: _backup_status[jid]["progress"] = f"Lösche altes Backup: {old}" shutil.rmtree(os.path.join(parent, old), ignore_errors=True) _backup_status[jid] = {"running": False, "progress": "", "last_run": ts, "last_error": ""} # Persist last_run jobs2 = load_json(BACKUPS_FILE, []) for j2 in jobs2: if j2["id"] == jid: j2["last_run"] = ts j2["last_error"] = "" save_json(BACKUPS_FILE, jobs2) except Exception as e: _backup_status[jid] = {"running": False, "progress": "", "last_run": "", "last_error": str(e)} jobs2 = load_json(BACKUPS_FILE, []) for j2 in jobs2: if j2["id"] == jid: j2["last_error"] = str(e) save_json(BACKUPS_FILE, jobs2) threading.Thread(target=run_backup, args=(job,), daemon=True).start() return jsonify({"ok": True, "message": "Backup gestartet"}) # ─────────────────────────── external backups (restic / rclone) ─ remote_backup.init( DATA_DIR, lambda p: _safe(p), on_change=lambda: _auto_backup() if _mirroring_enabled() else None) app.register_blueprint(remote_backup.bp, url_prefix="/api/remote-backups") # ─────────────────────────── frontend ─────────────────────────── @app.route("/") def index(): return render_template("index.html", admin_enabled=_admin_auth.get("enabled", False), csrf=csrf_token(), file_icons={"svg": fileicons.svgs(), "ext": fileicons.ext_kinds()}) def _install_safe_getfqdn(): """Keep the reverse DNS lookup during bind from killing the addon. http.server calls socket.getfqdn() while binding. With host_network the addon uses the router's DNS; a PTR record that is not valid UTF-8 then raises UnicodeDecodeError and the addon never starts. """ real_getfqdn = socket.getfqdn def safe_getfqdn(name=""): try: return real_getfqdn(name) except (UnicodeDecodeError, UnicodeError, OSError): return name or "localhost" socket.getfqdn = safe_getfqdn def _sharing_should_start(): """The public site needs sharing_enabled AND a working admin password. run.sh checks the same thing; two independent checks for the one condition that must never fail.""" if not _opt("sharing_enabled", False): return False if _admin_auth.get("setup_required") or not _admin_auth.get("enabled") or not _admin_auth.get("password_hash"): print("[SHARE] sharing_enabled, aber kein Admin-Passwort - oeffentliche Seite bleibt aus", flush=True) return False return True # The public share site runs either as a locked-down separate process # (share_sandbox=auto|on, the default) or, when that is unavailable, as a # thread in this process (the pre-3.8 behaviour). _SHARE tracks which. _SHARE = {"sandboxed": False, "proc": None} _SNAP_CACHE = {"ts": 0.0, "snap": None} def _share_sandboxed(): p = _SHARE["proc"] return bool(_SHARE["sandboxed"] and p is not None and p.poll() is None) def _share_running(): return True if _share_sandboxed() else bool(share_web.RUNNING) def _lock_snapshot(): """Locks/counters for the admin card. From the worker's snapshot file when sandboxed, straight from the in-process limiter otherwise. Cached ~1 s so rendering a link list does not read the file per row.""" if not _share_sandboxed(): snap = LIMITER.snapshot(AUTHFAIL_IP[1]) snap["link_locked"] = None # None = ask the limiter per link return snap now = time.time() if _SNAP_CACHE["snap"] is None or now - _SNAP_CACHE["ts"] > 1: _SNAP_CACHE["snap"] = sharesandbox.read_snapshot(DATA_DIR) or { "locks": [], "counters": [], "link_locked": []} _SNAP_CACHE["ts"] = now return _SNAP_CACHE["snap"] def _link_locked(link_id): snap = _lock_snapshot() ll = snap.get("link_locked") if ll is None: return LIMITER.locked(f"authfail:link:{link_id}", *AUTHFAIL_LINK) return link_id in ll def _do_unlock(key): if _share_sandboxed(): sharesandbox.queue_unlock(DATA_DIR, key) else: LIMITER.clear(key=key) _LOG_SHIP = {"src": None, "dst": None, "cap": 5 * 1024 * 1024, "thread": None} def _start_log_shipper(src, dst, max_mb): """Sandbox only: the worker writes src; ship new lines to dst (the path CrowdSec reads under /config, which the worker cannot see). Rotates dst so it cannot grow without bound. Idempotent: one thread per process. A later call (the CrowdSec button) only retargets it, so clicking the button never starts a second copier.""" import threading _LOG_SHIP.update(src=src, dst=dst, cap=max(1, int(max_mb)) * 1024 * 1024) t = _LOG_SHIP["thread"] if t is not None and t.is_alive(): return def run(): pos, ino, made = None, None, None while True: src_now, dst_now, cap = _LOG_SHIP["src"], _LOG_SHIP["dst"], _LOG_SHIP["cap"] try: if src_now and dst_now: if made != dst_now: os.makedirs(os.path.dirname(dst_now), exist_ok=True) made = dst_now st = os.stat(src_now) if pos is None or ino != st.st_ino or st.st_size < pos: pos, ino = st.st_size, st.st_ino # start at the end, ship only new lines elif st.st_size > pos: with open(src_now, "r", encoding="utf-8", errors="replace") as f: f.seek(pos) data = f.read() pos = f.tell() if os.path.exists(dst_now) and os.path.getsize(dst_now) > cap: try: os.replace(dst_now, dst_now + ".1") except OSError: pass with open(dst_now, "a", encoding="utf-8") as g: g.write(data) except OSError: pass time.sleep(2) t = threading.Thread(target=run, daemon=True, name="share-logship") _LOG_SHIP["thread"] = t t.start() def _crowdsec_export_active(path): """Is the file CrowdSec reads being fed? In-process the access log writes it directly; sandboxed the worker writes /data and the shipper copies. Checking only the first made the Sharing tab report "not active" after every restart of a sandboxed site, although the shipper was running.""" if not path: return False if accesslog.writes_to(path): return True t = _LOG_SHIP["thread"] return (_share_sandboxed() and _LOG_SHIP["dst"] == path and t is not None and t.is_alive()) def _launch_sandbox(host, port, export): """Start the worker in a mount namespace, dropped to nobody. Returns the Popen once it answers /healthz, or None (caller falls back).""" import shutil import urllib.request if not (shutil.which("unshare") and shutil.which("setpriv")): print("[SHARE] unshare/setpriv nicht vorhanden - keine Sandbox", flush=True) return None os.makedirs(os.path.join(DATA_DIR, "tmp"), exist_ok=True) # every file the jail binds must already exist, or the bind mount fails seeds = {"share_links.json": "[]", "share_accounts.json": "[]", "share_auth.json": "{}", "shares.json": "[]", "share_counters.json": "{}", sharesandbox.SNAPSHOT_FILE: "{}", sharesandbox.UNLOCK_FILE: "[]"} for name, default in seeds.items(): p = os.path.join(DATA_DIR, name) if not os.path.exists(p): with open(p, "w", encoding="utf-8") as h: h.write(default) for name in (sharesandbox.LOG_FILE,): p = os.path.join(DATA_DIR, name) if not os.path.exists(p): open(p, "a").close() sharesandbox.write_options(DATA_DIR, sharesandbox.curate_options( _opt, {"share_bind": host, "share_port": port})) env = dict(os.environ, SHARE_DATA_DIR="/data", TMPDIR="/data/tmp") cmd = ["unshare", "--mount", "--propagation", "private", "--", "/bin/sh", "/app/share_jail.sh"] try: proc = subprocess.Popen(cmd, env=env) except OSError as e: print(f"[SHARE] Sandbox-Start fehlgeschlagen: {e}", flush=True) return None url = f"http://127.0.0.1:{port}/healthz" for _ in range(50): # up to ~10 s if proc.poll() is not None: print(f"[SHARE] Sandbox-Prozess sofort beendet (Code {proc.returncode})", flush=True) return None try: with urllib.request.urlopen(url, timeout=1) as r: if r.status == 200: return proc except Exception: pass time.sleep(0.2) print("[SHARE] Sandbox antwortet nicht auf /healthz - Abbruch, Rueckfall", flush=True) try: proc.terminate() except Exception: pass return None def start_share_site(): """Bring up the public share site. Returns True once it is serving.""" host = str(_opt("share_bind", "0.0.0.0") or "0.0.0.0") port = int(_opt("share_port", 8101) or 8101) import socket as _s probe = _s.socket(_s.AF_INET, _s.SOCK_STREAM) probe.setsockopt(_s.SOL_SOCKET, _s.SO_REUSEADDR, 1) try: probe.bind((host, port)) except OSError as e: print(f"[SHARE] FEHLER: Port {port} ist bereits belegt ({e}). Laeuft ein anderes Addon auf " f"diesem Port (z. B. Nextcloud)? share_port aendern oder das andere Addon stoppen.", flush=True) return False finally: probe.close() if host == "127.0.0.1": print("[SHARE] Hinweis: share_bind=127.0.0.1 - nur ein Reverse Proxy, der selbst im Host-Netz " "laeuft, erreicht die Seite. Der Nginx Proxy Manager als Addon kann das NICHT; " "dann share_bind=0.0.0.0 und im Proxy die IP des Hosts eintragen.", flush=True) export = _share_export_path() cs_needed = bool(export or _crowdsec_installed()) cs_path = _crowdsec_log_path(export) if cs_needed else None max_mb = _opt("share_log_max_mb", 5) mode = str(_opt("share_sandbox", "auto") or "auto").strip().lower() if mode not in ("auto", "on", "off"): mode = "auto" if mode != "off": proc = _launch_sandbox(host, port, export) if proc is not None: _SHARE.update(sandboxed=True, proc=proc) main_log = os.path.join(DATA_DIR, sharesandbox.LOG_FILE) accesslog.attach_read(main_log) # admin reads the worker's log if cs_path: _sync_crowdsec_acquis(cs_path) # parent owns /config if cs_path != main_log: _start_log_shipper(main_log, cs_path, max_mb) print(f"[SHARE] Protokoll wird nach {cs_path} weitergereicht (CrowdSec)", flush=True) return True if mode == "on": print("[SHARE] FEHLER: share_sandbox=on, aber die Sandbox startet nicht. Seite bleibt aus.", flush=True) return False print("[SHARE] Sandbox nicht verfuegbar - Rueckfall auf den Hauptprozess", flush=True) # ── in-process fallback (pre-3.8 behaviour) ───────────────────────────── if cs_needed: accesslog.init(os.path.join(DATA_DIR, sharesandbox.LOG_FILE), max_mb) cs2 = _apply_export_paths(export, announce=True) _sync_crowdsec_acquis(cs2) try: srv = share_worker.serve_share(_opt, lambda: load_json(SHARES_FILE, []), share_roots, DATA_DIR, blocking=False, sandboxed=False) except Exception as e: print(f"[SHARE] Start der oeffentlichen Seite fehlgeschlagen: {e}", flush=True) return False import threading threading.Thread(target=srv.run, daemon=True, name="share-http").start() _SHARE.update(sandboxed=False, proc=None) return True def listen_ports(web_port, ingress_port): """web_port für den Direktzugriff, dazu der Port, den Home Assistant für Ingress vergibt (ingress_port: 0 in config.yaml; ein fester 8100 kollidierte mit Dnsmasq-DHCP, Issue #10).""" ports = [web_port] if ingress_port and ingress_port != web_port: ports.append(ingress_port) return ports def _env_port(name): value = str(os.environ.get(name, "")).strip() return int(value) if value.isdigit() else 0 def serve(wsgi_app, port): """Run the admin UI on waitress, a production WSGI server. Flask's built-in server is single-purpose development tooling; it is what ran here until 3.2.2. waitress is pure Python (no wheel needed on Alpine) and threaded. WEB_SERVER=werkzeug is an escape hatch for one release in case ingress misbehaves on a device I could not test on. """ if os.environ.get("WEB_SERVER", "").lower() == "werkzeug": print("[WEB] WEB_SERVER=werkzeug - Entwicklungsserver", flush=True) wsgi_app.run(host="0.0.0.0", port=port, debug=False) return try: from waitress import create_server except ImportError: print("[WEB] waitress fehlt - Rueckfall auf den Entwicklungsserver", flush=True) wsgi_app.run(host="0.0.0.0", port=port, debug=False) return server, ports = make_server(wsgi_app, port, _env_port("INGRESS_PORT"), create_server) print(f"[WEB] waitress auf Port {', '.join(map(str, ports))}", flush=True) server.run() def make_server(wsgi_app, port, ingress, create_server, host="0.0.0.0"): """waitress-Server auf web_port und Ingress-Port. Ist web_port belegt (anderes Add-on im Host-Netz), lauscht er nur auf dem Ingress-Port: "Öffnen" in Home Assistant geht dann weiter.""" options = dict( threads=8, ident=None, # no "Server: waitress" header channel_timeout=300, # slow USB media, long listings max_request_body_size=MAX_UPLOAD_BYTES + 8 * 1024 * 1024, asyncore_use_poll=True, ) ports = listen_ports(port, ingress) try: return create_server(wsgi_app, listen=" ".join(f"{host}:{p}" for p in ports), **options), ports except OSError as e: if len(ports) < 2: raise print(f"[WEB] Port {port} nicht verfuegbar ({e}) - nur ueber Home Assistant (Ingress-Port {ingress})", flush=True) return create_server(wsgi_app, listen=f"{host}:{ingress}", **options), [ingress] if __name__ == "__main__": _install_safe_getfqdn() os.makedirs(DATA_DIR, exist_ok=True) for fpath, default in [(SHARES_FILE, []), (USERS_FILE, []), (MOUNTS_FILE, []), (GROUPS_FILE, []), (BACKUPS_FILE, [])]: if not os.path.exists(fpath): save_json(fpath, default) _setup_admin_auth() if _sharing_should_start(): try: start_share_site() except SystemExit: raise except Exception as e: print(f"[SHARE] Start der oeffentlichen Seite fehlgeschlagen: {e}", flush=True) remote_backup.start() start_recycle_purge() start_trim_schedule() publish_timemachine() if _opt("webdav_enabled", False): start_webdav() if _opt("ftp_enabled", False): start_ftp() port = int(os.environ.get("WEB_PORT", 8100)) serve(app, port)