diff --git a/src/netmover/config.py b/src/netmover/config.py index e0dbd78..5b43e0a 100644 --- a/src/netmover/config.py +++ b/src/netmover/config.py @@ -25,6 +25,12 @@ class Settings(BaseSettings): host: str = "127.0.0.1" port: int = Field(default=8080, ge=1, le=65535) + # Hinter einem Reverse Proxy mit TLS auf true setzen (Cookie nur per HTTPS). + cookie_secure: bool = False + # IPs, deren X-Forwarded-For/-Proto übernommen wird (Reverse Proxy). + forwarded_allow_ips: str = "127.0.0.1" + session_hours: int = Field(default=12, ge=1, le=24 * 30) + log_level: Literal["DEBUG", "INFO", "WARNING", "ERROR"] = "INFO" log_format: Literal["logfmt", "json"] = "logfmt" @@ -37,6 +43,10 @@ class Settings(BaseSettings): def db_path(self) -> Path: return self.data_dir / "netmover.db" + @property + def session_key_path(self) -> Path: + return self.data_dir / "session.key" + @property def secret_key_path(self) -> Path: return self.data_dir / "secret.key" diff --git a/src/netmover/mounting/manager.py b/src/netmover/mounting/manager.py index 50d4454..0fba6c3 100644 --- a/src/netmover/mounting/manager.py +++ b/src/netmover/mounting/manager.py @@ -572,7 +572,7 @@ class MountManager: self._notify("mount.error", m.name, msg) raise MountUnavailable(msg) - def usage(self, mount_id: int) -> tuple[int, int] | None: + def usage(self, mount_id: int, timeout: float | None = None) -> tuple[int, int] | None: """(gesamt, frei) in Bytes – nur bei eingehängten Kernel-Mounts.""" m, _ = self._load(mount_id) if not m.type.is_kernel_mount: @@ -581,7 +581,9 @@ class MountManager: if not self.backend.is_mounted(mp): return None try: - st = run_with_timeout(lambda: os.statvfs(mp), self.probe_timeout, f"statvfs:{m.name}") + st = run_with_timeout( + lambda: os.statvfs(mp), timeout or self.probe_timeout, f"statvfs:{m.name}" + ) except (OSError, OperationTimeout): return None return st.f_blocks * st.f_frsize, st.f_bavail * st.f_frsize diff --git a/src/netmover/web/__init__.py b/src/netmover/web/__init__.py new file mode 100644 index 0000000..b053307 --- /dev/null +++ b/src/netmover/web/__init__.py @@ -0,0 +1 @@ +"""Weboberfläche: FastAPI + Jinja2 + HTMX, serverseitig gerendert.""" diff --git a/src/netmover/web/app.py b/src/netmover/web/app.py new file mode 100644 index 0000000..0640b3e --- /dev/null +++ b/src/netmover/web/app.py @@ -0,0 +1,957 @@ +"""FastAPI-Anwendung: Middleware, Anmeldung, Seiten. + +Seiten werden serverseitig gerendert (Jinja2). HTMX nur dort, wo es echten +Nutzen bringt: Testergebnisse, Probelauf-Tabelle, aufklappbare Protokolle und +das automatische Aktualisieren der Statusseite. Alle Aktionen funktionieren +auch als normale Formulare (Post/Redirect/Get). +""" + +from __future__ import annotations + +import asyncio +import dataclasses +import json +import math +from collections.abc import AsyncIterator, Awaitable, Callable +from contextlib import asynccontextmanager +from dataclasses import dataclass +from datetime import UTC, datetime +from importlib.resources import files +from typing import Any + +from fastapi import APIRouter, Depends, FastAPI, Request +from fastapi.responses import HTMLResponse, JSONResponse, RedirectResponse, Response +from fastapi.staticfiles import StaticFiles +from fastapi.templating import Jinja2Templates +from sqlalchemy import func, or_, select +from starlette.concurrency import run_in_threadpool +from starlette.datastructures import UploadFile +from starlette.middleware.sessions import SessionMiddleware + +from netmover import __version__ +from netmover.admin import ( + AdminError, + MountInput, + RuleInput, + credential_for_test, + delete_mount, + mount_input_from_model, + parse_mount_form, + parse_rule_form, + rule_input_from_model, + save_mount, + save_rule, +) +from netmover.app_state import NetMover +from netmover.config import Settings, get_settings +from netmover.db import session_scope +from netmover.logging_setup import event, get_logger +from netmover.models import ( + CollisionPolicy, + CredentialKind, + Mount, + MountState, + MountType, + PatternType, + Rule, + RuleAction, + Run, + RunItem, + RunStatus, + utcnow, +) +from netmover.mounting.backend import MountError +from netmover.mounting.report import CheckReport +from netmover.notify import Notification +from netmover.settings_store import AppSettings, NotifyMode, load_settings, save_settings +from netmover.transfer import apply_imported_settings, export_config, import_config +from netmover.web import auth + +log = get_logger(__name__) + +PAGE_SIZE = 50 +STATE_LABELS = { + MountState.MOUNTED: "eingehängt", + MountState.UNMOUNTED: "ausgehängt", + MountState.DEGRADED: "gestört", + MountState.ERROR: "Fehler", +} +SFTP_STATE_LABELS = { + MountState.MOUNTED: "erreichbar", + MountState.UNMOUNTED: "nicht geprüft", + MountState.DEGRADED: "gestört", + MountState.ERROR: "nicht erreichbar", +} +RUN_LABELS = { + RunStatus.OK: "OK", + RunStatus.PARTIAL: "teilweise", + RunStatus.ERROR: "Fehler", + RunStatus.SKIPPED: "übersprungen", + RunStatus.RUNNING: "läuft", +} + + +# -- Jinja-Filter ----------------------------------------------------------------------- + + +def _ago(value: datetime | None, now: datetime | None = None) -> str: + if value is None: + return "–" + seconds = ((now or datetime.now(UTC)) - value).total_seconds() + future = seconds < 0 + seconds = abs(seconds) + if seconds < 60: + text = f"{int(seconds)} s" + elif seconds < 3600: + text = f"{int(seconds // 60)} min" + elif seconds < 86400: + text = f"{seconds / 3600:.1f} h".replace(".", ",") + else: + text = f"{int(seconds // 86400)} Tg." + return f"in {text}" if future else f"vor {text}" + + +def _localdt(value: datetime | None) -> str: + return value.astimezone().strftime("%d.%m.%Y %H:%M:%S") if value else "–" + + +def _bytes(value: int | None) -> str: + if value is None: + return "–" + units = ["B", "KB", "MB", "GB", "TB", "PB"] + n = float(value) + i = 0 + while n >= 1024 and i < len(units) - 1: + n /= 1024 + i += 1 + return f"{n:.1f} {units[i]}".replace(".", ",") if i else f"{int(n)} B" + + +def _duration(start: datetime | None, end: datetime | None) -> str: + if start is None or end is None: + return "–" + s = (end - start).total_seconds() + return f"{s:.1f} s".replace(".", ",") if s < 60 else f"{int(s // 60)} min {int(s % 60)} s" + + +TEMPLATES = Jinja2Templates(directory=str(files("netmover.web") / "templates")) +TEMPLATES.env.filters.update(ago=_ago, localdt=_localdt, bytes=_bytes) +TEMPLATES.env.globals.update( + version=__version__, + duration=_duration, + STATE_LABELS=STATE_LABELS, + SFTP_STATE_LABELS=SFTP_STATE_LABELS, + RUN_LABELS=RUN_LABELS, + MountType=MountType, + MountState=MountState, + RunStatus=RunStatus, + PatternType=PatternType, + RuleAction=RuleAction, + CollisionPolicy=CollisionPolicy, + CredentialKind=CredentialKind, + NotifyMode=NotifyMode, +) + + +def flash(request: Request, message: str, kind: str = "ok") -> None: + # Neu zuweisen statt anhängen: die Session erkennt nur Zuweisungen als Änderung. + request.session["flash"] = [*request.session.get("flash", []), [kind, message]] + + +def render(request: Request, name: str, status_code: int = 200, **ctx: Any) -> HTMLResponse: + flashes = request.session.pop("flash", []) if not name.startswith("partials/") else [] + return TEMPLATES.TemplateResponse( + request, + name, + { + "csrf": auth.csrf_token(request), + "flashes": flashes, + "logged_in": auth.is_logged_in(request), + "active": ctx.pop("active", ""), + **ctx, + }, + status_code=status_code, + ) + + +def redirect(url: str) -> RedirectResponse: + return RedirectResponse(url, status_code=303) + + +def nm(request: Request) -> NetMover: + app_state: NetMover = request.app.state.nm + return app_state + + +def _form_dict(form: Any) -> dict[str, str]: + return {k: v for k, v in form.items() if isinstance(v, str)} + + +# -- Schutz für alle geschützten Routen --------------------------------------------------- + + +async def guard(request: Request) -> None: + state = nm(request) + if not auth.password_is_set(state.session_factory): + raise auth.LoginRequired(setup=True) + if not auth.is_logged_in(request): + raise auth.LoginRequired() + await auth.check_csrf(request) + + +protected = APIRouter(dependencies=[Depends(guard)]) +public = APIRouter() + + +# -- Öffentlich: Health, Einrichtung, Anmeldung -------------------------------------------- + + +@public.get("/healthz") +async def healthz(request: Request) -> JSONResponse: + health = await run_in_threadpool(nm(request).health) + body = {"status": "ok" if health.ok else "error", "problems": health.problems} + return JSONResponse(body, status_code=200 if health.ok else 503) + + +@public.get("/setup", response_class=HTMLResponse) +async def setup_form(request: Request) -> Response: + if auth.password_is_set(nm(request).session_factory): + return redirect("/login") + return render(request, "setup.html") + + +@public.post("/setup", response_class=HTMLResponse) +async def setup_submit(request: Request) -> Response: + await auth.check_csrf(request) + state = nm(request) + if auth.password_is_set(state.session_factory): + return redirect("/login") + form = await request.form() + password = str(form.get("password") or "") + problem = auth.password_problem(password, str(form.get("confirm") or "")) + if not request.app.state.setup_code.matches(str(form.get("code") or "")): + problem = ( + "Einrichtungscode stimmt nicht – er steht im Log des Dienstes (journalctl -u netmover)." + ) + if problem: + return render(request, "setup.html", status_code=400, error=problem) + await run_in_threadpool(auth.set_password, state.session_factory, password) + auth.login_session(request) + event(log, "auth.setup_done") + flash(request, "Passwort gesetzt. Willkommen bei NetMover!") + return redirect("/") + + +@public.get("/login", response_class=HTMLResponse) +async def login_form(request: Request) -> Response: + if not auth.password_is_set(nm(request).session_factory): + return redirect("/setup") + return render(request, "login.html") + + +@public.post("/login", response_class=HTMLResponse) +async def login_submit(request: Request) -> Response: + await auth.check_csrf(request) + throttle: auth.LoginThrottle = request.app.state.throttle + client = request.client.host if request.client else "?" + wait = throttle.locked_for(client) + if wait: + return render( + request, + "login.html", + status_code=429, + error=f"Zu viele Fehlversuche – bitte {math.ceil(wait / 60)} min warten.", + ) + form = await request.form() + ok = await run_in_threadpool( + auth.verify_password, nm(request).session_factory, str(form.get("password") or "") + ) + if not ok: + throttle.failure(client) + event(log, "auth.login_failed", level=30, client=client) + return render(request, "login.html", status_code=401, error="Passwort falsch.") + throttle.success(client) + auth.login_session(request) + event(log, "auth.login", client=client) + target = request.query_params.get("next", "/") + return redirect(target if target.startswith("/") and not target.startswith("//") else "/") + + +@protected.post("/logout") +async def logout(request: Request) -> Response: + request.session.clear() + return redirect("/login") + + +# -- Status ----------------------------------------------------------------------------------- + + +@dataclass +class MountView: + m: Mount + usage: tuple[int, int] | None + + +@dataclass +class RuleView: + r: Rule + last_run: Run | None + next_run: datetime | None + running_since: datetime | None + stale: bool + target: str + + +def _rule_target(r: Rule) -> str: + src = f"{r.src_mount.name}:/{r.src_subpath}" if r.src_subpath else f"{r.src_mount.name}:/" + if r.dst_mount is None: + dst = r.dst_path + else: + dst = f"{r.dst_mount.name}:/{r.dst_path}" if r.dst_path else f"{r.dst_mount.name}:/" + return f"{src} → {dst}" + + +async def _status_context(request: Request) -> dict[str, Any]: + state = nm(request) + health = await run_in_threadpool(state.health) + with state.session_factory() as s: + mounts = s.scalars(select(Mount).order_by(Mount.name)).all() + rules = s.scalars(select(Rule).order_by(Rule.name)).all() + last_runs = { + run.rule_id: run + for run in s.scalars( + select(Run).where( + Run.id.in_( + select(func.max(Run.id)) + .where(Run.status != RunStatus.SKIPPED) + .group_by(Run.rule_id) + ) + ) + ) + } + rule_views = [ + RuleView( + r, + last_runs.get(r.id), + state.scheduler.next_run(r.id), + state.runner.running_since(r.id), + r.id in health.stale_rules, + _rule_target(r), + ) + for r in rules + ] + for m in mounts: + s.expunge(m) + + async def usage(m: Mount) -> tuple[int, int] | None: + if m.state is not MountState.MOUNTED or not m.type.is_kernel_mount: + return None + return await run_in_threadpool(state.mounts.usage, m.id, 2.0) + + usages = await asyncio.gather(*(usage(m) for m in mounts)) + return { + "health": health, + "mounts": [MountView(m, u) for m, u in zip(mounts, usages, strict=True)], + "rules": rule_views, + "now": utcnow(), + } + + +@protected.get("/", response_class=HTMLResponse) +async def status_page(request: Request) -> Response: + return render(request, "status.html", active="status", **await _status_context(request)) + + +@protected.get("/partials/status", response_class=HTMLResponse) +async def status_partial(request: Request) -> Response: + return render(request, "partials/status_body.html", **await _status_context(request)) + + +@protected.post("/rules/{rule_id}/run") +async def rule_run_now(request: Request, rule_id: int) -> Response: + state = nm(request) + task = asyncio.create_task(state.scheduler.run_now(rule_id)) + request.app.state.tasks.add(task) + task.add_done_callback(request.app.state.tasks.discard) + flash(request, "Lauf gestartet – das Ergebnis erscheint gleich im Protokoll.") + return redirect(request.headers.get("referer") or "/") + + +# -- Anbindungen ---------------------------------------------------------------------------- + + +@protected.get("/mounts", response_class=HTMLResponse) +async def mounts_page(request: Request) -> Response: + with nm(request).session_factory() as s: + mounts = s.scalars(select(Mount).order_by(Mount.name)).all() + reports = { + m.id: CheckReport(**_report_data(m.last_test_result)) + for m in mounts + if m.last_test_result + } + pubkeys = { + m.id: m.credential.public_key + for m in mounts + if m.credential is not None and m.credential.public_key + } + return render( + request, "mounts.html", active="mounts", mounts=mounts, reports=reports, pubkeys=pubkeys + ) + + +def _report_data(raw: str | None) -> dict[str, Any]: + from netmover.mounting.report import CheckStep + + data: dict[str, Any] = json.loads(raw or "{}") + data.pop("roles", None) + data.pop("ok", None) + data["steps"] = [CheckStep(**st) for st in data.get("steps", [])] + return data + + +def _mount_form( + request: Request, + data: MountInput, + errors: dict[str, str], + mount: Mount | None = None, + status_code: int = 200, +) -> Response: + pub = mount.credential.public_key if mount is not None and mount.credential else None + return render( + request, + "mount_form.html", + status_code=status_code, + active="mounts", + d=data, + errors=errors, + mount=mount, + public_key=pub, + has_secret=bool(mount is not None and mount.credential is not None), + ) + + +@protected.get("/mounts/new", response_class=HTMLResponse) +async def mount_new(request: Request) -> Response: + return _mount_form(request, MountInput(), {}) + + +@protected.get("/mounts/{mount_id}/edit", response_class=HTMLResponse) +async def mount_edit(request: Request, mount_id: int) -> Response: + with nm(request).session_factory() as s: + m = s.get(Mount, mount_id) + if m is None: + return redirect("/mounts") + return _mount_form(request, mount_input_from_model(m), {}, m) + + +@protected.post("/mounts/new", response_class=HTMLResponse) +@protected.post("/mounts/{mount_id}/edit", response_class=HTMLResponse) +async def mount_save(request: Request, mount_id: int | None = None) -> Response: + state = nm(request) + data, errors = parse_mount_form(_form_dict(await request.form())) + with session_scope(state.session_factory) as s: + existing = s.get(Mount, mount_id) if mount_id is not None else None + if not errors: + try: + m, errors = save_mount(s, state.secrets, state.mounts, data, mount_id) + except AdminError as exc: + errors = {"_": str(exc)} + m = None + else: + m = None + if errors or m is None: + s.rollback() + existing = s.get(Mount, mount_id) if mount_id is not None else None + return _mount_form(request, data, errors, existing, status_code=400) + saved_id, name = m.id, m.name + is_sftp_key = m.type is MountType.SFTP and data.auth is CredentialKind.SSH_KEY + flash(request, f"Anbindung „{name}“ gespeichert.") + if is_sftp_key: + flash( + request, + "Öffentlichen Schlüssel beim Zielserver in authorized_keys eintragen, dann „Testen“.", + "info", + ) + state.scheduler.sync() + return redirect(f"/mounts#mount-{saved_id}") + + +@protected.post("/mounts/test", response_class=HTMLResponse) +async def mount_test_form(request: Request) -> Response: + """„Testen“ im Formular – mit den (noch nicht gespeicherten) Eingaben.""" + state = nm(request) + form = _form_dict(await request.form()) + data, errors = parse_mount_form(form) + if errors: + return render(request, "partials/check_result.html", errors=errors, report=None) + existing_id = int(form["mount_id"]) if form.get("mount_id", "").isdigit() else None + with state.session_factory() as s: + existing = s.get(Mount, existing_id) if existing_id else None + cred = credential_for_test(data, existing, state.secrets) + cfg = data.config() + if data.type is MountType.SFTP and existing is not None: + cfg = dataclasses.replace(cfg, host_key=existing.host_key) + report = await run_in_threadpool(state.mounts.check, cfg, cred, None) + return render(request, "partials/check_result.html", report=report, errors={}) + + +@protected.post("/mounts/{mount_id}/test", response_class=HTMLResponse) +async def mount_test(request: Request, mount_id: int) -> Response: + state = nm(request) + with state.session_factory() as s: + m = s.get(Mount, mount_id) + if m is None: + return HTMLResponse("Anbindung existiert nicht.", status_code=404) + data = mount_input_from_model(m) + cred = credential_for_test(data, m, state.secrets) + from netmover.mounting.manager import MountConfig + + cfg = MountConfig.from_model(m) + report = await run_in_threadpool(state.mounts.check, cfg, cred, mount_id) + return render(request, "partials/check_result.html", report=report, errors={}) + + +async def _mount_action( + request: Request, mount_id: int, fn: Callable[[int], None], ok: str +) -> Response: + try: + await run_in_threadpool(fn, mount_id) + flash(request, ok) + except (MountError, LookupError) as exc: + flash(request, str(exc), "error") + return redirect(f"/mounts#mount-{mount_id}") + + +@protected.post("/mounts/{mount_id}/mount") +async def mount_mount(request: Request, mount_id: int) -> Response: + return await _mount_action(request, mount_id, nm(request).mounts.mount, "Eingehängt.") + + +@protected.post("/mounts/{mount_id}/unmount") +async def mount_unmount(request: Request, mount_id: int) -> Response: + return await _mount_action(request, mount_id, nm(request).mounts.unmount, "Ausgehängt.") + + +@protected.post("/mounts/{mount_id}/delete") +async def mount_delete(request: Request, mount_id: int) -> Response: + state = nm(request) + + def work() -> None: + with session_scope(state.session_factory) as s: + delete_mount(s, state.mounts, mount_id) + + try: + await run_in_threadpool(work) + flash(request, "Anbindung gelöscht.") + except (AdminError, MountError) as exc: + flash(request, str(exc), "error") + return redirect("/mounts") + + +@protected.post("/mounts/{mount_id}/reset-hostkey") +async def mount_reset_hostkey(request: Request, mount_id: int) -> Response: + with session_scope(nm(request).session_factory) as s: + m = s.get(Mount, mount_id) + if m is not None: + m.host_key = None + m.state = MountState.UNMOUNTED + flash( + request, + "Hostschlüssel zurückgesetzt – beim nächsten „Testen“ wird er neu festgehalten.", + "info", + ) + return redirect(f"/mounts#mount-{mount_id}") + + +# -- Regeln ----------------------------------------------------------------------------------- + + +def _rule_form( + request: Request, + d: RuleInput, + errors: dict[str, str], + rule_id: int | None, + status_code: int = 200, +) -> Response: + with nm(request).session_factory() as s: + mounts = s.scalars(select(Mount).order_by(Mount.name)).all() + for m in mounts: + s.expunge(m) + return render( + request, + "rule_form.html", + status_code=status_code, + active="rules", + d=d, + errors=errors, + rule_id=rule_id, + src_mounts=[m for m in mounts if m.type.is_kernel_mount], + src_options=[("", "– wählen –")] + + [(m.id, m.name) for m in mounts if m.type.is_kernel_mount], + dst_options=[("", "– wählen –")] + [(m.id, f"{m.name} ({m.type.value})") for m in mounts], + ) + + +@protected.get("/rules", response_class=HTMLResponse) +async def rules_page(request: Request) -> Response: + ctx = await _status_context(request) + return render(request, "rules.html", active="rules", rules=ctx["rules"], now=ctx["now"]) + + +@protected.get("/rules/new", response_class=HTMLResponse) +async def rule_new(request: Request) -> Response: + return _rule_form(request, RuleInput(), {}, None) + + +@protected.get("/rules/{rule_id}/edit", response_class=HTMLResponse) +async def rule_edit(request: Request, rule_id: int) -> Response: + with nm(request).session_factory() as s: + r = s.get(Rule, rule_id) + if r is None: + return redirect("/rules") + d = rule_input_from_model(r) + return _rule_form(request, d, {}, rule_id) + + +@protected.post("/rules/new", response_class=HTMLResponse) +@protected.post("/rules/{rule_id}/edit", response_class=HTMLResponse) +async def rule_save(request: Request, rule_id: int | None = None) -> Response: + state = nm(request) + form = _form_dict(await request.form()) + with session_scope(state.session_factory) as s: + d, errors = parse_rule_form(s, form) + r = None + if not errors: + try: + r, errors = save_rule(s, d, rule_id) + except AdminError as exc: + errors = {"_": str(exc)} + if errors or r is None: + s.rollback() + return _rule_form(request, d, errors, rule_id, status_code=400) + name = r.name + state.scheduler.sync() + flash(request, f"Regel „{name}“ gespeichert.") + return redirect("/rules") + + +@protected.post("/rules/{rule_id}/toggle") +async def rule_toggle(request: Request, rule_id: int) -> Response: + state = nm(request) + with session_scope(state.session_factory) as s: + r = s.get(Rule, rule_id) + if r is not None: + r.enabled = not r.enabled + flash(request, f"Regel „{r.name}“ {'aktiviert' if r.enabled else 'deaktiviert'}.") + state.scheduler.sync() + return redirect("/rules") + + +@protected.post("/rules/{rule_id}/delete") +async def rule_delete(request: Request, rule_id: int) -> Response: + state = nm(request) + with session_scope(state.session_factory) as s: + r = s.get(Rule, rule_id) + if r is not None: + s.delete(r) + flash(request, f"Regel „{r.name}“ gelöscht.") + state.scheduler.sync() + return redirect("/rules") + + +@protected.post("/rules/{rule_id}/dryrun", response_class=HTMLResponse) +async def rule_dryrun(request: Request, rule_id: int) -> Response: + state = nm(request) + run_id = await state.scheduler.run_now(rule_id, dry_run=True) + with state.session_factory() as s: + run = s.get(Run, run_id) if run_id else None + items = s.scalars(select(RunItem).where(RunItem.run_id == run_id)).all() if run_id else [] + return render(request, "partials/dryrun.html", run=run, items=items) + + +# -- Protokoll -------------------------------------------------------------------------------- + + +@protected.get("/log", response_class=HTMLResponse) +async def log_page(request: Request) -> Response: + q = request.query_params + rule_id = int(q["rule"]) if q.get("rule", "").isdigit() else None + status = q.get("status") or "" + active_only = q.get("all") != "1" + page = max(1, int(q["page"])) if q.get("page", "").isdigit() else 1 + with nm(request).session_factory() as s: + stmt = select(Run, Rule.name).join(Rule, Rule.id == Run.rule_id) + if rule_id: + stmt = stmt.where(Run.rule_id == rule_id) + if status in {st.value for st in RunStatus}: + stmt = stmt.where(Run.status == RunStatus(status)) + if active_only: + stmt = stmt.where( + or_(Run.seen > 0, Run.status != RunStatus.OK, Run.message.is_not(None)) + ) + total = s.scalar(select(func.count()).select_from(stmt.subquery())) or 0 + rows = s.execute( + stmt.order_by(Run.started_at.desc(), Run.id.desc()) + .offset((page - 1) * PAGE_SIZE) + .limit(PAGE_SIZE) + ).all() + rules = s.execute(select(Rule.id, Rule.name).order_by(Rule.name)).all() + return render( + request, + "log.html", + active="log", + rows=rows, + rules=rules, + rule_id=rule_id, + status=status, + active_only=active_only, + page=page, + pages=max(1, math.ceil(total / PAGE_SIZE)), + total=total, + ) + + +@protected.get("/log/{run_id}/items", response_class=HTMLResponse) +async def log_items(request: Request, run_id: int) -> Response: + with nm(request).session_factory() as s: + items = s.scalars( + select(RunItem).where(RunItem.run_id == run_id).order_by(RunItem.id) + ).all() + run = s.get(Run, run_id) + return render(request, "partials/run_items.html", items=items, run=run) + + +# -- Einstellungen ------------------------------------------------------------------------------ + + +@protected.get("/settings", response_class=HTMLResponse) +async def settings_page(request: Request) -> Response: + cfg = load_settings(nm(request).session_factory) + return render(request, "settings.html", active="settings", cfg=cfg, errors={}) + + +@protected.post("/settings", response_class=HTMLResponse) +async def settings_save(request: Request) -> Response: + state = nm(request) + form = _form_dict(await request.form()) + cfg = load_settings(state.session_factory) + errors: dict[str, str] = {} + try: + cfg.retention_days = int(form.get("retention_days") or 30) + if not 1 <= cfg.retention_days <= 3650: + errors["retention_days"] = "Zwischen 1 und 3650 Tagen." + cfg.notify_after = int(form.get("notify_after") or 3) + if cfg.notify_after < 1: + errors["notify_after"] = "Mindestens 1." + except ValueError: + errors["retention_days"] = "Bitte ganze Zahlen angeben." + try: + cfg.notify_mode = NotifyMode(form.get("notify_mode") or "off") + except ValueError: + errors["notify_mode"] = "Ungültig." + url = (form.get("webhook_url") or "").strip() + if url != auth_mask(cfg.webhook_url): # Maske unverändert = URL behalten + if url and not url.startswith(("http://", "https://")): + errors["webhook_url"] = "URL muss mit http:// oder https:// beginnen." + cfg.webhook_url = url + cfg.notify_recovery = form.get("notify_recovery") == "on" + cfg.webhook_template = form.get("webhook_template") or cfg.webhook_template + cfg.webhook_content_type = (form.get("webhook_content_type") or "application/json").strip() + if errors: + return render( + request, "settings.html", status_code=400, active="settings", cfg=cfg, errors=errors + ) + save_settings(state.session_factory, cfg) + flash(request, "Einstellungen gespeichert.") + return redirect("/settings") + + +def auth_mask(url: str) -> str: + """Webhook-URL nur gekürzt anzeigen – sie enthält oft ein Token.""" + if not url: + return "" + head, _, _rest = url.partition("://") + host = _rest.split("/", 1)[0] + return f"{head}://{host}/••••••" + + +TEMPLATES.env.globals["mask_url"] = auth_mask + + +@protected.post("/settings/webhook-test", response_class=HTMLResponse) +async def settings_webhook_test(request: Request) -> Response: + state = nm(request) + cfg = load_settings(state.session_factory) + if not cfg.webhook_url: + return HTMLResponse('
Keine Webhook-URL gespeichert.
') + from netmover.notify import http_sender + from netmover.notify import render as render_body + + body = render_body( + cfg.webhook_template, + cfg.webhook_content_type, + Notification("test", "NetMover: Testnachricht", "Webhook funktioniert."), + ) + try: + await run_in_threadpool(http_sender, cfg.webhook_url, cfg.webhook_content_type, body) + except Exception as exc: + return HTMLResponse(f'Fehlgeschlagen: {type(exc).__name__}
') + return HTMLResponse('Testnachricht gesendet.
') + + +@protected.post("/settings/password", response_class=HTMLResponse) +async def settings_password(request: Request) -> Response: + state = nm(request) + form = _form_dict(await request.form()) + if not await run_in_threadpool( + auth.verify_password, state.session_factory, form.get("current", "") + ): + flash(request, "Aktuelles Passwort falsch.", "error") + return redirect("/settings") + problem = auth.password_problem(form.get("password", ""), form.get("confirm", "")) + if problem: + flash(request, problem, "error") + return redirect("/settings") + await run_in_threadpool(auth.set_password, state.session_factory, form["password"]) + auth.login_session(request) + flash(request, "Passwort geändert.") + return redirect("/settings") + + +@protected.get("/settings/export") +async def settings_export(request: Request) -> Response: + state = nm(request) + with state.session_factory() as s: + data = export_config(s, load_settings(state.session_factory)) + stamp = datetime.now().strftime("%Y%m%d-%H%M") + return Response( + json.dumps(data, ensure_ascii=False, indent=2), + media_type="application/json", + headers={"Content-Disposition": f'attachment; filename="netmover-{stamp}.json"'}, + ) + + +@protected.post("/settings/import") +async def settings_import(request: Request) -> Response: + state = nm(request) + form = await request.form() + upload = form.get("file") + if not isinstance(upload, UploadFile): + flash(request, "Keine Datei ausgewählt.", "error") + return redirect("/settings") + raw = await upload.read(2 * 1024 * 1024 + 1) + if len(raw) > 2 * 1024 * 1024: + flash(request, "Datei zu groß.", "error") + return redirect("/settings") + try: + data = json.loads(raw) + except ValueError: + flash(request, "Keine gültige JSON-Datei.", "error") + return redirect("/settings") + with session_scope(state.session_factory) as s: + report = import_config(s, state.mounts, data) + if report.settings: + save_settings( + state.session_factory, + apply_imported_settings(load_settings(state.session_factory), report.settings), + ) + state.scheduler.sync() + if report.created: + flash(request, "Importiert: " + ", ".join(report.created)) + if report.skipped: + flash(request, "Übersprungen: " + ", ".join(report.skipped), "info") + for err in report.errors: + flash(request, err, "error") + if report.created: + flash( + request, + "Zugangsdaten bitte bei den Anbindungen neu eingeben; importierte Regeln " + "sind deaktiviert.", + "info", + ) + return redirect("/settings") + + +# -- Anwendung ---------------------------------------------------------------------------------- + + +SECURITY_HEADERS = { + "Content-Security-Policy": ( + "default-src 'self'; script-src 'self'; style-src 'self'; img-src 'self' data:; " + "frame-ancestors 'none'; form-action 'self'; base-uri 'none'" + ), + "X-Frame-Options": "DENY", + "X-Content-Type-Options": "nosniff", + "Referrer-Policy": "same-origin", +} + + +def create_app(settings: Settings | None = None, state: NetMover | None = None) -> FastAPI: + settings = settings or get_settings() + + @asynccontextmanager + async def lifespan(app: FastAPI) -> AsyncIterator[None]: + app_state = state or NetMover(settings) + app.state.nm = app_state + app.state.setup_code = auth.SetupCode() + app.state.throttle = auth.LoginThrottle() + app.state.tasks = set() + if not auth.password_is_set(app_state.session_factory): + event( + log, + "setup.required", + level=30, + code=app.state.setup_code.code, + hint="Diesen Code bei der Ersteinrichtung im Browser eingeben", + ) + app_state.start() + try: + yield + finally: + app_state.stop() + + app = FastAPI( + title="NetMover", + version=__version__, + lifespan=lifespan, + docs_url=None, + redoc_url=None, + openapi_url=None, + ) + + @app.middleware("http") + async def security_headers( + request: Request, call_next: Callable[[Request], Awaitable[Response]] + ) -> Response: + response = await call_next(request) + for k, v in SECURITY_HEADERS.items(): + response.headers.setdefault(k, v) + if request.url.path not in ("/healthz",) and not request.url.path.startswith("/static/"): + response.headers.setdefault("Cache-Control", "no-store") + return response + + app.add_middleware( + SessionMiddleware, + secret_key=auth.load_or_create_session_key(settings.session_key_path), + session_cookie="netmover_session", + max_age=settings.session_hours * 3600, + same_site="lax", + https_only=settings.cookie_secure, + ) + + @app.exception_handler(auth.LoginRequired) + async def login_required(request: Request, exc: auth.LoginRequired) -> Response: + target = "/setup" if exc.setup else f"/login?next={request.url.path}" + if request.headers.get("hx-request"): + return Response(status_code=204, headers={"HX-Redirect": target}) + return redirect(target) + + app.mount( + "/static", StaticFiles(directory=str(files("netmover.web") / "static")), name="static" + ) + app.include_router(public) + app.include_router(protected) + return app + + +__all__ = ["AppSettings", "create_app"] diff --git a/src/netmover/web/auth.py b/src/netmover/web/auth.py new file mode 100644 index 0000000..68f219b --- /dev/null +++ b/src/netmover/web/auth.py @@ -0,0 +1,163 @@ +"""Anmeldung, Einrichtung beim ersten Start, CSRF und Sperre bei Fehlversuchen. + +* Ein einziges Administratorkonto; Passwort-Hash (Argon2) in ``settings``. +* Erster Start: Ohne Passwort führt jede Seite zur Einrichtung. Dafür wird ein + einmaliger Einrichtungscode ins Log geschrieben – so kann nicht irgendwer im + LAN, der die Seite zuerst öffnet, das Passwort festlegen. +* Session-Cookie ``HttpOnly``, ``SameSite=Lax`` (Starlette-SessionMiddleware, + signiert). CSRF-Token pro Session, Pflicht bei jedem POST – als Formularfeld + ``csrf_token`` oder Header ``X-CSRF-Token`` (HTMX). +""" + +from __future__ import annotations + +import hmac +import secrets +import threading +import time +from collections import defaultdict, deque +from pathlib import Path + +from argon2 import PasswordHasher +from argon2.exceptions import InvalidHashError, VerificationError +from fastapi import HTTPException, Request +from sqlalchemy.orm import Session, sessionmaker + +from netmover.settings_store import get_value, set_value + +PASSWORD_KEY = "auth.admin_password_hash" # noqa: S105 – Schlüsselname +MIN_PASSWORD_LENGTH = 10 +SAFE_METHODS = frozenset({"GET", "HEAD", "OPTIONS"}) + +_hasher = PasswordHasher() + + +class LoginRequired(Exception): + def __init__(self, setup: bool = False) -> None: + self.setup = setup + + +def load_or_create_session_key(path: Path) -> str: + import os + + path.parent.mkdir(parents=True, exist_ok=True) + try: + fd = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600) + except FileExistsError: + return path.read_text().strip() + key = secrets.token_urlsafe(48) + with os.fdopen(fd, "w") as fh: + fh.write(key + "\n") + return key + + +def password_is_set(factory: sessionmaker[Session]) -> bool: + return bool(get_value(factory, PASSWORD_KEY)) + + +def set_password(factory: sessionmaker[Session], password: str) -> None: + set_value(factory, PASSWORD_KEY, _hasher.hash(password)) + + +def verify_password(factory: sessionmaker[Session], password: str) -> bool: + stored = get_value(factory, PASSWORD_KEY) + if not stored: + return False + try: + _hasher.verify(stored, password) + except (VerificationError, InvalidHashError): + return False + if _hasher.check_needs_rehash(stored): + set_value(factory, PASSWORD_KEY, _hasher.hash(password)) + return True + + +def password_problem(password: str, confirm: str) -> str | None: + if len(password) < MIN_PASSWORD_LENGTH: + return f"Mindestens {MIN_PASSWORD_LENGTH} Zeichen." + if password != confirm: + return "Die Passwörter stimmen nicht überein." + return None + + +# -- Session / CSRF ------------------------------------------------------------------- + + +def csrf_token(request: Request) -> str: + token = request.session.get("csrf") + if not isinstance(token, str): + token = secrets.token_urlsafe(32) + request.session["csrf"] = token + return token + + +def login_session(request: Request) -> None: + """Neue Session nach erfolgreicher Anmeldung (Schutz vor Session-Fixation).""" + request.session.clear() + request.session["user"] = "admin" + request.session["csrf"] = secrets.token_urlsafe(32) + request.session["since"] = int(time.time()) + + +def is_logged_in(request: Request) -> bool: + return request.session.get("user") == "admin" + + +async def check_csrf(request: Request) -> None: + if request.method in SAFE_METHODS: + return + expected = request.session.get("csrf") + sent = request.headers.get("x-csrf-token") + if sent is None: + form = await request.form() + value = form.get("csrf_token") + sent = value if isinstance(value, str) else None + if not expected or not sent or not hmac.compare_digest(str(expected), sent): + raise HTTPException(status_code=403, detail="CSRF-Token fehlt oder ist ungültig.") + + +# -- Sperre bei Fehlversuchen -------------------------------------------------------------- + + +class LoginThrottle: + """Nach ``limit`` Fehlversuchen innerhalb von ``window`` Sekunden wird die + Client-IP für ``lockout`` Sekunden gesperrt.""" + + def __init__(self, limit: int = 5, window: float = 900, lockout: float = 300) -> None: + self.limit = limit + self.window = window + self.lockout = lockout + self._fails: dict[str, deque[float]] = defaultdict(deque) + self._locked: dict[str, float] = {} + self._lock = threading.Lock() + + def locked_for(self, client: str) -> float: + with self._lock: + until = self._locked.get(client, 0) + return max(0.0, until - time.monotonic()) + + def failure(self, client: str) -> None: + now = time.monotonic() + with self._lock: + q = self._fails[client] + q.append(now) + while q and now - q[0] > self.window: + q.popleft() + if len(q) >= self.limit: + self._locked[client] = now + self.lockout + q.clear() + + def success(self, client: str) -> None: + with self._lock: + self._fails.pop(client, None) + self._locked.pop(client, None) + + +class SetupCode: + """Einmaliger Code für die Ersteinrichtung (steht im Log).""" + + def __init__(self) -> None: + self.code = "-".join(secrets.token_hex(2) for _ in range(3)) + + def matches(self, value: str) -> bool: + return hmac.compare_digest(self.code, value.strip().lower()) diff --git a/src/netmover/web/static/app.css b/src/netmover/web/static/app.css new file mode 100644 index 0000000..1489183 --- /dev/null +++ b/src/netmover/web/static/app.css @@ -0,0 +1,111 @@ +/* NetMover – handgeschrieben, ohne Build-Schritt. Hell/Dunkel nach Systemeinstellung. */ +:root { + --bg: #f6f7f9; --card: #fff; --fg: #1d2330; --muted: #5d6676; --line: #dfe3ea; + --accent: #2f6fde; --accent-fg: #fff; --ok: #1e8e3e; --warn: #b26a00; --err: #c62828; + --ok-bg: #e6f4ea; --warn-bg: #fff4e0; --err-bg: #fdecea; --info-bg: #e8f0fe; + --radius: 8px; --gap: 12px; + font: 15px/1.45 system-ui, -apple-system, "Segoe UI", Roboto, sans-serif; +} +@media (prefers-color-scheme: dark) { + :root { + --bg: #12151b; --card: #1b2028; --fg: #e6e9ef; --muted: #9aa3b2; --line: #2c333e; + --accent: #6b9bff; --accent-fg: #0b1020; --ok: #57c27a; --warn: #f0a63a; --err: #ff6b6b; + --ok-bg: #16301f; --warn-bg: #36280f; --err-bg: #3a1717; --info-bg: #182540; + } +} +* { box-sizing: border-box; } +body { margin: 0; background: var(--bg); color: var(--fg); } +a { color: var(--accent); } +code, pre, .mono { font-family: ui-monospace, "SFMono-Regular", Menlo, Consolas, monospace; font-size: .9em; } +pre { white-space: pre-wrap; word-break: break-word; margin: 0; } + +header.top { background: var(--card); border-bottom: 1px solid var(--line); } +header.top .inner { max-width: 1200px; margin: 0 auto; display: flex; align-items: center; + gap: var(--gap); padding: 8px 16px; flex-wrap: wrap; } +header.top .brand { font-weight: 700; text-decoration: none; color: var(--fg); margin-right: 8px; } +nav.main { display: flex; gap: 4px; flex-wrap: wrap; flex: 1; } +nav.main a { padding: 6px 10px; border-radius: var(--radius); text-decoration: none; color: var(--fg); } +nav.main a.active, nav.main a:hover { background: var(--bg); } +main { max-width: 1200px; margin: 0 auto; padding: 16px; } +h1 { font-size: 1.4rem; margin: 4px 0 16px; } +h2 { font-size: 1.1rem; margin: 24px 0 8px; } +.muted { color: var(--muted); } +.small { font-size: .85em; } + +.card { background: var(--card); border: 1px solid var(--line); border-radius: var(--radius); padding: 14px; } +.grid { display: grid; gap: var(--gap); grid-template-columns: repeat(auto-fill, minmax(260px, 1fr)); } +.row { display: flex; gap: 8px; align-items: center; flex-wrap: wrap; } +.spread { justify-content: space-between; } +.stack > * + * { margin-top: 8px; } + +.badge { display: inline-block; padding: 1px 8px; border-radius: 99px; font-size: .8em; font-weight: 600; + background: var(--bg); border: 1px solid var(--line); white-space: nowrap; } +.badge.ok { background: var(--ok-bg); color: var(--ok); border-color: transparent; } +.badge.warn { background: var(--warn-bg); color: var(--warn); border-color: transparent; } +.badge.err { background: var(--err-bg); color: var(--err); border-color: transparent; } + +.msg { padding: 8px 12px; border-radius: var(--radius); margin: 0 0 10px; } +.msg.ok { background: var(--ok-bg); } .msg.error { background: var(--err-bg); } +.msg.info { background: var(--info-bg); } .msg.warn { background: var(--warn-bg); } + + +table { width: 100%; border-collapse: collapse; background: var(--card); border: 1px solid var(--line); + border-radius: var(--radius); overflow: hidden; } +th, td { text-align: left; padding: 7px 10px; border-bottom: 1px solid var(--line); vertical-align: top; } +th { font-size: .8em; text-transform: uppercase; letter-spacing: .03em; color: var(--muted); background: var(--bg); } +tr:last-child td { border-bottom: 0; } +tr.warn td { background: var(--warn-bg); } +tr.err td { background: var(--err-bg); } +.table-wrap { overflow-x: auto; } +td.num, th.num { text-align: right; font-variant-numeric: tabular-nums; } + +button, .btn { font: inherit; padding: 6px 12px; border-radius: var(--radius); border: 1px solid var(--line); + background: var(--card); color: var(--fg); cursor: pointer; text-decoration: none; display: inline-block; } +button.primary, .btn.primary { background: var(--accent); color: var(--accent-fg); border-color: var(--accent); } +button.danger { color: var(--err); } +button.small, .btn.small { padding: 3px 9px; font-size: .85em; } +button:disabled { opacity: .5; } +form.inline { display: inline; margin: 0; } + +form.form { max-width: 760px; } +fieldset { border: 1px solid var(--line); border-radius: var(--radius); margin: 0 0 14px; padding: 10px 14px 14px; background: var(--card); } +legend { font-weight: 600; padding: 0 6px; } +.fields { display: grid; gap: 10px 14px; grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); } +.field { display: flex; flex-direction: column; gap: 3px; } +.field.wide { grid-column: 1 / -1; } +.field label { font-size: .85em; font-weight: 600; } +.field .hint { font-size: .8em; color: var(--muted); } +.field .error { font-size: .85em; color: var(--err); } +input[type=text], input[type=password], input[type=number], input[type=url], select, textarea { + font: inherit; padding: 6px 8px; border-radius: 6px; border: 1px solid var(--line); + background: var(--bg); color: var(--fg); width: 100%; } +textarea { min-height: 70px; } +.check { flex-direction: row; align-items: center; gap: 6px; } +.has-error input, .has-error select, .has-error textarea { border-color: var(--err); } + +.steps { list-style: none; padding: 0; margin: 0; } +.steps li { padding: 3px 0; } +.steps .ico { display: inline-block; width: 1.4em; font-weight: 700; } +.steps .ok { color: var(--ok); } .steps .fail { color: var(--err); } .steps .skip { color: var(--muted); } + +details > summary { cursor: pointer; } +.htmx-indicator { display: none; } +.htmx-request .htmx-indicator, .htmx-request.htmx-indicator { display: inline; } +.login { max-width: 380px; margin: 10vh auto; } +[hidden] { display: none !important; } + +@media (max-width: 640px) { + main { padding: 10px; } + th, td { padding: 6px; } + table.responsive thead { display: none; } + table.responsive tr { display: block; border-bottom: 1px solid var(--line); padding: 6px 0; } + table.responsive td { display: flex; justify-content: space-between; gap: 10px; border: 0; padding: 3px 8px; } + table.responsive td::before { content: attr(data-label); color: var(--muted); font-size: .85em; } + table.responsive td.num { text-align: left; } +} + +.text-err { color: var(--err); } .text-warn { color: var(--warn); } .text-ok { color: var(--ok); } +pre.text-err { margin-top: 6px; } +ul.tight { margin: 4px 0 0; padding-left: 20px; } +.foot { text-align: center; padding: 16px; } +meter.fill { width: 100%; height: 8px; margin: 6px 0 2px; display: block; } diff --git a/src/netmover/web/static/app.js b/src/netmover/web/static/app.js new file mode 100644 index 0000000..15daa15 --- /dev/null +++ b/src/netmover/web/static/app.js @@ -0,0 +1,45 @@ +// NetMover – kleine Helfer. Keine Inline-Skripte (CSP: script-src 'self'). +(function () { + "use strict"; + var meta = document.querySelector('meta[name="csrf-token"]'); + var csrf = meta ? meta.getAttribute("content") : ""; + + // CSRF-Token für alle HTMX-Anfragen + document.addEventListener("htmx:configRequest", function (e) { + e.detail.headers["X-CSRF-Token"] = csrf; + }); + + // Rückfrage vor gefährlichen Aktionen: