From b0fba5846f71602719a657003d5a0f2f9fccc63c Mon Sep 17 00:00:00 2001 From: Ludwig Lehnert Date: Fri, 31 Jul 2026 14:50:54 +0000 Subject: [PATCH] webui --- .dockerignore | 6 + .env.example | 24 +- Dockerfile | 9 +- README.md | 180 +++++- app/audit_collector.py | 256 ++++++++ app/backup_to_destination.py | 119 ++++ app/init.sh | 127 +++- app/web/app.js | 343 +++++++++++ app/web/favicon.svg | 1 + app/web/index.html | 45 ++ app/web/styles.css | 94 +++ app/web_ui.py | 916 ++++++++++++++++++++++++++++ dev/ad-dc.Dockerfile | 28 + dev/ad-entrypoint.sh | 141 +++++ dev/backup-entrypoint.sh | 32 + dev/backup.Dockerfile | 13 + dev/e2e.py | 328 ++++++++++ dev/preview-client.sh | 53 ++ dev/seed-files.sh | 37 ++ docker-compose.yml | 2 + scripts/dev | 418 +++++++++++++ scripts/test-e2e | 12 + setup | 50 ++ tests/test_backup_to_destination.py | 1 + tests/test_web_ui.py | 224 +++++++ 25 files changed, 3452 insertions(+), 7 deletions(-) create mode 100644 .dockerignore create mode 100644 app/audit_collector.py create mode 100644 app/web/app.js create mode 100644 app/web/favicon.svg create mode 100644 app/web/index.html create mode 100644 app/web/styles.css create mode 100644 app/web_ui.py create mode 100644 dev/ad-dc.Dockerfile create mode 100755 dev/ad-entrypoint.sh create mode 100755 dev/backup-entrypoint.sh create mode 100644 dev/backup.Dockerfile create mode 100755 dev/e2e.py create mode 100755 dev/preview-client.sh create mode 100755 dev/seed-files.sh create mode 100755 scripts/dev create mode 100755 scripts/test-e2e create mode 100644 tests/test_web_ui.py diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..a156c5b --- /dev/null +++ b/.dockerignore @@ -0,0 +1,6 @@ +.git +.env +**/__pycache__ +**/*.py[cod] +*.orig +*.rej diff --git a/.env.example b/.env.example index 6d6553e..75c817a 100644 --- a/.env.example +++ b/.env.example @@ -11,6 +11,25 @@ FSLOGIX_GROUP_SID=S-1-5-21-1111111111-2222222222-3333333333-513 # AD_DNS_IP_AUTO=1 # SAMBA_HOSTNAME=adsambafsrv # NETBIOS_NAME=ADSAMBAFSRV +WEB_ENABLED=true +WEB_HOSTNAME=files.example.com +WEB_HTTPS_PORT=443 +WEB_JWT_SECRET=ReplaceWithAtLeast32RandomBytes +WEB_TLS_MODE=step +STEP_CA_URL=https://ca.example.com:9000 +STEP_CA_FINGERPRINT=ReplaceWithStepRootFingerprint +STEP_CA_PROVISIONER=fileserver +STEP_CA_PROVISIONER_PASSWORD=ReplaceWithProvisionerPassword +# STEP_CA_TOKEN=single-use-bootstrap-token +# STEP_CA_PROVISIONER_PASSWORD_FILE=/run/secrets/step-ca-provisioner-password +# STEP_CA_REBOOTSTRAP=false +# WEB_TLS_CERT_FILE=/state/tls/web.crt +# WEB_TLS_KEY_FILE=/state/tls/web.key +# WEB_JWT_TTL_SECONDS=28800 +# WEB_USAGE_SCAN_INTERVAL_SECONDS=900 +# WEB_DIRECTORY_CACHE_SECONDS=300 +# WEB_MAX_GROUP_NODES=10000 +# WEB_LOGIN_ATTEMPTS_PER_5_MIN=10 # LDAP_URI=ldaps://example.com # LDAP_BASE_DN=DC=example,DC=com # PRIVATE_SKIP_USERS=svc_backup,svc_sql @@ -19,7 +38,7 @@ FSLOGIX_GROUP_SID=S-1-5-21-1111111111-2222222222-3333333333-513 # BACKUP_DESTINATION=smb://DOMAIN%5Cuser:pass@backup.example.com/Backups/samba # BACKUP_DESTINATION=davfs://user:pass@webdav.example.com/remote.php/dav/files/backup # BACKUP_DESTINATION=sftp://user:pass@sftp.example.com/exports/samba -# BACKUP_START_HOUR=2 +# BACKUP_START_HOUR=2 # 0-23, UTC # BACKUP_RETENTION_DAILY=3 # BACKUP_RETENTION_WEEKLY=2 # BACKUP_RETENTION_MONTHLY=2 @@ -27,3 +46,6 @@ FSLOGIX_GROUP_SID=S-1-5-21-1111111111-2222222222-3333333333-513 # BACKUP_LOG_FILE=/var/log/backup.log # BACKUP_PROGRESS=auto # BACKUP_PROGRESS_INTERVAL_SECONDS=10 +# BACKUP_STATUS_FILE=/state/backup-status.json +# AUDIT_COMPRESS_AFTER_HOURS=24 +# AUDIT_QUERY_MAX_DAYS=31 diff --git a/Dockerfile b/Dockerfile index 1ec8ce0..39dac2e 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,6 +1,8 @@ +FROM smallstep/step-cli:0.30.2 AS step-cli + FROM debian:12-slim -ENV DEBIAN_FRONTEND=noninteractive +ENV DEBIAN_FRONTEND=noninteractive TZ=Etc/UTC RUN apt-get update \ && apt-get install -y --no-install-recommends \ @@ -22,13 +24,18 @@ RUN apt-get update \ && rm -rf /var/lib/apt/lists/* RUN mkdir -p /app /data/private /data/fslogix /data/groups/data /data/groups/archive /state +COPY --from=step-cli /usr/local/bin/step /usr/local/bin/step COPY app/reconcile_shares.py /app/reconcile_shares.py COPY app/backup_to_destination.py /app/backup_to_destination.py +COPY app/audit_collector.py /app/audit_collector.py +COPY app/web_ui.py /app/web_ui.py +COPY app/web /app/web COPY app/init.sh /app/init.sh COPY etc/samba/smb.conf /app/smb.conf.template RUN chmod +x /app/init.sh /app/reconcile_shares.py /app/backup_to_destination.py \ + /app/audit_collector.py /app/web_ui.py \ && true ENTRYPOINT ["/usr/bin/tini", "--"] diff --git a/README.md b/README.md index 7dda23f..d602847 100644 --- a/README.md +++ b/README.md @@ -22,14 +22,18 @@ This repository provides a production-oriented Samba file server container that - Setup prompts for well-known authorization groups by SID (`DOMAIN_USERS_SID`, `DOMAIN_ADMINS_SID`) to avoid localized group names. - `FSLOGIX_GROUP_SID` controls who can access the default FSLogix share (defaults to `DOMAIN_USERS_SID`). - Startup resolves those SIDs to NSS group names via winbind, then uses those resolved groups in Samba `valid users` rules. -- Share operations are audited with Samba `full_audit` (connect, list, read, write, create, delete, rename) and written to Samba log files. +- Share operations are audited with Samba `full_audit` (connect, list, read, write, create, delete, rename). +- A collector persists every `full_audit` record in daily NDJSON files under `/state/audit`; closed files are gzip-compressed and are never automatically deleted. +- A read-only HTTPS web console provides group membership trees, storage usage, searchable activity, live backup progress, and system health. +- Web sign-in validates the submitted username/password with Kerberos, permits only users whose winbind group SID set contains `DOMAIN_ADMINS_SID`, and issues an expiring JWT in a Secure, HttpOnly, SameSite=Strict cookie. The browser does not use NTLM/SPNEGO or Kerberos negotiation. +- HTTPS certificates are requested from a configured local Smallstep CA and renewed automatically. Pre-issued certificate files are also supported. - Optional remote backups run when `BACKUP_DESTINATION` is configured. - Private home creation skips well-known/service accounts by default (including `krbtgt`, `msol_*`, `FileShare_ServiceAcc`). - Reconciliation is executed: - once on startup - every 5 minutes via cron - Backup is executed: - - daily at `BACKUP_START_HOUR` (default: `2`, i.e. 02:00) + - daily at `BACKUP_START_HOUR` in UTC (default: `2`, i.e. 02:00 UTC) ## Data Folder Lifecycle @@ -95,7 +99,86 @@ Kerberos requires close time alignment. - `app/init.sh` - `app/reconcile_shares.py` - `app/backup_to_destination.py` +- `app/audit_collector.py` +- `app/web_ui.py` +- `app/web/` - `etc/samba/smb.conf` +- `dev/` (disposable AD DC, backup target, SMB client, seed data, and E2E assertions) +- `scripts/dev` +- `scripts/test-e2e` + +## Local Preview + +Run a complete disposable environment with Docker or Podman: + +```bash +./scripts/dev +``` + +The launcher builds the current application and starts an isolated, run-scoped network containing: + +- a real Samba AD DC for `DEV.TEST`, seeded with users, nested groups, and three `FS_*` groups; +- a real Smallstep CA that issues the web UI certificate for `localhost`; +- an authenticated rsync daemon used by the normal backup implementation; +- the actual file-server image, joined to the dummy domain; +- a continuous SMB client that reads, writes, and lists files as several domain users. + +The preview starts with group, Private, FSLogix, and historical audit data. The client keeps current-day audit activity moving, while a real backup runs immediately and repeats in the background. Open the URL and use the credentials printed by the launcher. Defaults are: + +```text +URL: https://localhost:8443 +Username: DEV\previewadmin +Password: PreviewAdmin123! +``` + +The generated CA is intentionally disposable. `scripts/dev` prints the temporary root certificate path so it can be trusted only for the duration of that run. Ctrl-C stops and removes all run-scoped containers, volumes, the network, and the temporary root. A watchdog performs the same cleanup if the parent script is killed. + +The dummy DC alone receives `SYS_ADMIN`, which Samba needs to write Windows ACL xattrs while provisioning `SYSVOL`; the application container receives no extra capability. + +Useful overrides: + +| Variable | Default | Purpose | +| --- | --- | --- | +| `DEV_HTTPS_PORT` | `8443` | HTTPS port bound to host loopback | +| `DEV_SKIP_BUILD` | `0` | Set to `1` to reuse already-built local images | +| `DEV_STEP_CA_IMAGE` | `docker.io/smallstep/step-ca:latest` | CA image; pin a tag or digest for reproducible CI | +| `DEV_SERVER_IMAGE` | `ad-ds-simple-file-server-dev:latest` | Local file-server image name | +| `DEV_AD_IMAGE` | `ad-ds-simple-file-server-ad-dev:latest` | Local AD/client image name | +| `DEV_BACKUP_IMAGE` | `ad-ds-simple-file-server-backup-dev:latest` | Local rsync target image name | +| `DEV_REALM` | `DEV.TEST` | Dummy Kerberos realm | +| `DEV_WORKGROUP` | `DEV` | Dummy NetBIOS domain | +| `DEV_DNS_DOMAIN` | `dev.test` | Dummy AD DNS zone | +| `DEV_BASE_DN` | `DC=dev,DC=test` | Dummy directory base DN | +| `DEV_ADMIN_USER` | `previewadmin` | Seeded Domain Admin login | +| `DEV_ADMIN_PASSWORD` | `PreviewAdmin123!` | Seeded Domain Admin password | +| `DEV_USER_PASSWORD` | `PreviewUser123!` | Shared password for seeded non-admin users | +| `DEV_SEED_MB` | `8` | MiB per large seed file | +| `DEV_ACTIVITY_INTERVAL_SECONDS` | `4` | Delay between SMB activity batches | +| `DEV_BACKUP_INTERVAL_SECONDS` | `120` | Delay between completed preview backups | + +`DEV_REALM`, `DEV_DNS_DOMAIN`, and `DEV_BASE_DN` describe the same domain and must be changed together. + +## End-to-End Tests + +Run the same disposable stack headlessly with extensive assertions: + +```bash +./scripts/test-e2e +``` + +The E2E suite verifies: + +- CA-issued TLS, hostname validation, HSTS, and CSP; +- anonymous rejection, bad credentials, valid non-admin rejection, real Domain Admin login, JWT claims, bearer use, cookie flags, tamper rejection, and logout; +- domain trust, group-to-folder mapping, nested and transitive group trees; +- SMB allow/deny behavior and real file operations; +- Data, Private, and FSLogix usage aggregation; +- live `full_audit` ingestion, filters, facets, and pagination; +- compression and querying of a closed daily audit log; +- real rsync transfer progress, completed backup status, log output, and remote snapshot marker; +- overview and system-health aggregation. + +The runner returns non-zero on the first failed assertion, prints bounded logs from every run-scoped service, and always removes its containers, volumes, network, and temporary CA root. Set `DEV_SKIP_BUILD=1` for a fast rerun against existing local images. ## Setup @@ -124,6 +207,14 @@ Kerberos requires close time alignment. - optional `BACKUP_LOG_FILE` (default `/var/log/backup.log`) - optional `BACKUP_PROGRESS` (`auto`, `always`, or `never`; default `auto`) - optional `BACKUP_PROGRESS_INTERVAL_SECONDS` (default `10`) + - `WEB_HOSTNAME` (the DNS name in the HTTPS certificate) + - optional `WEB_HTTPS_PORT` (host port, default `443`) + - `STEP_CA_URL` + - `STEP_CA_FINGERPRINT` + - `STEP_CA_PROVISIONER` + - `STEP_CA_PROVISIONER_PASSWORD` + + Setup generates a random `WEB_JWT_SECRET`. A one-time `STEP_CA_TOKEN` or a provisioner password file can be configured manually instead of keeping a provisioner password in `.env`. Optional: - `SAMBA_HOSTNAME` (defaults to `adsambafsrv`) @@ -185,11 +276,90 @@ Kerberos requires close time alignment. - Semantics intentionally differ from `Data`: only the share root is reconciled (`03770` + ACL defaults), while user-created profile container folders/files are not recursively normalized. - Samba masks are profile-container oriented (`create mask = 0600`, `directory mask = 0700`) so profile payload stays user-private by default. +## Read-only Web Console + +Open `https:///` after setup. Only members of the group identified by `DOMAIN_ADMINS_SID` can sign in. The form accepts `DOMAIN\username`, `username@realm`, or an unqualified username (which is qualified with `WORKGROUP`). + +The console is intentionally operational and plain: + +- **Overview**: current capacity totals, active group count, recent activity, and backup state. +- **File shares**: one selectable tree per active `FS_*` group/folder with recursively expanded user and nested-group membership, cycle markers, and a live filter. +- **Storage / Data groups**: cached recursive size of every top-level `/Data` group folder. +- **Storage / Users**: per-user `/Private + /FSLogix` totals with component sizes. +- **Activity logs**: dynamic date, user, share, action, operation, result, and path filters with pagination. +- **Backups**: read-only live progress, active transfer rows, snapshot name, and recent backup output. +- **System**: domain trust, Samba configuration, TLS certificate, scanner, and audit archive health. + +The web API has no mutation endpoint other than session login/logout. Files, groups, ACLs, backup schedules, and retention cannot be changed from the console. + +### Authentication and sessions + +- Credentials are submitted only over HTTPS. The password is passed to `kinit` through stdin, is never placed in a process argument, and the temporary Kerberos credential cache is immediately removed. +- After Kerberos succeeds, the service resolves the account SID and its complete group SID set through winbind. Login succeeds only when that set contains `DOMAIN_ADMINS_SID`. +- Authentication is form-based; the browser never performs NTLM, SPNEGO, or Kerberos negotiation. +- JWTs use HMAC-SHA256, default to eight hours, and are accepted from the protected cookie or an `Authorization: Bearer` header. +- Login attempts are rate-limited per client address. +- Responses set HSTS, a restrictive Content Security Policy, clickjacking protection, MIME sniffing protection, and no-store caching. + +### TLS with a local Smallstep CA + +Default enrollment uses `WEB_TLS_MODE=step`: + +```env +WEB_ENABLED=true +WEB_HOSTNAME=files.example.com +WEB_HTTPS_PORT=443 +WEB_JWT_SECRET= +WEB_TLS_MODE=step +STEP_CA_URL=https://ca.example.com:9000 +STEP_CA_FINGERPRINT= +STEP_CA_PROVISIONER=fileserver +STEP_CA_PROVISIONER_PASSWORD= +``` + +At first startup the container bootstraps the CA root, requests a certificate for `WEB_HOSTNAME`, and stores its certificate, key, and Step client state on the persistent `state_data` volume. The `step ca renew --daemon` process renews the certificate; the HTTPS listener notices the certificate file change and reloads it. + +For secret-file based enrollment, set `STEP_CA_PROVISIONER_PASSWORD_FILE` to a mounted file. A single-use `STEP_CA_TOKEN` is also supported. To use externally managed files instead: + +```env +WEB_TLS_MODE=files +WEB_TLS_CERT_FILE=/state/tls/web.crt +WEB_TLS_KEY_FILE=/state/tls/web.key +``` + +Smallstep trust configuration is reused from the state volume on normal restarts, so a temporary CA outage does not stop Samba or an already-certificate-equipped web service. Set `STEP_CA_REBOOTSTRAP=true` only when intentionally replacing the configured CA trust. + +Useful optional settings: + +| Variable | Default | Purpose | +| --- | ---: | --- | +| `WEB_JWT_TTL_SECONDS` | `28800` | Session lifetime, 5 minutes to 7 days | +| `WEB_LOGIN_ATTEMPTS_PER_5_MIN` | `10` | Per-address login attempt limit | +| `WEB_USAGE_SCAN_INTERVAL_SECONDS` | `900` | Recursive storage scan interval | +| `WEB_DIRECTORY_CACHE_SECONDS` | `300` | AD membership tree cache time | +| `WEB_MAX_GROUP_NODES` | `10000` | Membership expansion safety limit | +| `AUDIT_COMPRESS_AFTER_HOURS` | `24` | Minimum idle age before compressing a closed day | +| `AUDIT_QUERY_MAX_DAYS` | `31` | Largest activity query window | + +If `WEB_ENABLED` is absent on an upgraded installation and no TLS settings/certificate exist, the web service stays disabled while Samba and audit collection continue. Set `WEB_ENABLED=true` after adding TLS configuration. + +## Audit Archive + +Samba already produced `full_audit` messages before this web UI was added. The collector now makes them durable: + +- it tails every `/var/log/samba/log.*` source, remembers inode and byte offsets in `/state/audit/collector-state.json`, and follows Samba rotation without duplicating a rotated file; +- each event records timestamp, user, client address/name, share, VFS operation, read/write/list/session category, result, and path; +- records are appended to `/state/audit/YYYY-MM-DD.jsonl`; +- a closed, idle daily file becomes `.jsonl.gz`; +- no audit retention deletion is performed, so capacity planning for the `state_data` volume is the operator's responsibility. + +Collection starts even when the web UI is disabled. Existing Samba log content is imported when the collector first starts, but audit data that Samba rotated away before this version was deployed cannot be recovered. + ## Backups - Backups are enabled only if `BACKUP_DESTINATION` is non-empty. - Each run creates a timestamped snapshot under `snapshots/YYYYMMDDTHHMMSSZ` at the destination. -- Backup job is scheduled daily at `BACKUP_START_HOUR` in container local time. +- Backup job is scheduled daily at `BACKUP_START_HOUR` in UTC. The container and Compose service force `TZ=Etc/UTC`. - Sources synced to destination on each run: - `/data/private` -> `data/private` - `/data/groups` -> `data/groups` @@ -205,6 +375,7 @@ Kerberos requires close time alignment. - Before uploading, the backup script measures all source files so it can report total upload progress. - Upload progress is logged per file every `BACKUP_PROGRESS_INTERVAL_SECONDS` seconds and again when a file reaches 100%, including percentage and transferred/remaining bytes with auto-scaled units. - `BACKUP_PROGRESS=auto` shows an interactive multi-line progress view only for TTY/manual runs. Current file uploads are shown as separate rows, capped at 12 rows, with the total progress row at the bottom. Use `always` to force it or `never` to suppress the bar. File and total progress are still logged. +- Every run atomically updates `BACKUP_STATUS_FILE` (default `/state/backup-status.json`) with its state, current source, active files, byte totals, percentage, snapshot, and final result for the live web view. - Rclone-backed destinations cap concurrent file transfers at 12. Rsync remains single-streamed by rsync itself. - Retention logic: - daily: newest N snapshots @@ -237,11 +408,14 @@ Kerberos requires close time alignment. ```bash docker compose logs -f samba +docker compose logs -f samba | grep -E '\\[web\\]|\\[audit\\]' docker compose exec samba python3 /app/reconcile_shares.py docker compose exec samba sqlite3 /state/shares.db 'SELECT * FROM shares;' docker compose exec samba testparm -s docker compose exec samba sh -lc 'tail -n 200 /var/log/samba/log.*' docker compose exec samba sh -lc 'tail -n 200 /var/log/backup.log' +docker compose exec samba sh -lc 'ls -lh /state/audit' +docker compose exec samba python3 -m json.tool /state/backup-status.json ``` ## Troubleshooting diff --git a/app/audit_collector.py b/app/audit_collector.py new file mode 100644 index 0000000..db57238 --- /dev/null +++ b/app/audit_collector.py @@ -0,0 +1,256 @@ +#!/usr/bin/env python3 +"""Persist Samba full_audit records as immutable daily NDJSON archives.""" + +import datetime as dt +import glob +import gzip +import json +import os +import re +import signal +import sys +import time +from typing import Dict, Iterable, Optional + + +SAMBA_LOG_GLOB = os.getenv("AUDIT_SOURCE_GLOB", "/var/log/samba/log.*") +ARCHIVE_DIR = os.getenv("AUDIT_ARCHIVE_DIR", "/state/audit") +STATE_FILE = os.path.join(ARCHIVE_DIR, "collector-state.json") +POLL_SECONDS = max(0.2, float(os.getenv("AUDIT_POLL_SECONDS", "1"))) +COMPRESS_AFTER_HOURS = max( + 1, int(os.getenv("AUDIT_COMPRESS_AFTER_HOURS", "24")) +) +AUDIT_MARKER_RE = re.compile(r"smbd_audit:\s*(.*)$") +AUDIT_PAYLOAD_RE = re.compile( + r"^\s*\d{4}/\d{2}/\d{2}\s+\d{2}:\d{2}:\d{2}(?:\.\d+)?\|" +) +SAMBA_LOG_TIME_RE = re.compile( + r"^\s*\[?(\d{4}/\d{2}/\d{2}\s+\d{2}:\d{2}:\d{2}(?:\.\d+)?)" +) +STOP = False + + +def log(message: str) -> None: + print(f"[audit] {message}", flush=True) + + +def utc_now() -> dt.datetime: + return dt.datetime.now(dt.timezone.utc) + + +def atomic_json(path: str, value: object) -> None: + temp_path = f"{path}.tmp" + with open(temp_path, "w", encoding="utf-8") as handle: + json.dump(value, handle, separators=(",", ":"), sort_keys=True) + handle.flush() + os.fsync(handle.fileno()) + os.replace(temp_path, path) + + +def load_state() -> Dict[str, Dict[str, object]]: + try: + with open(STATE_FILE, encoding="utf-8") as handle: + value = json.load(handle) + if isinstance(value, dict): + return value + except (OSError, ValueError): + pass + return {} + + +def parse_samba_timestamp(raw_line: str, fallback: dt.datetime) -> str: + match = SAMBA_LOG_TIME_RE.match(raw_line) + if not match: + return fallback.isoformat(timespec="milliseconds") + try: + parsed = dt.datetime.strptime(match.group(1).split(".")[0], "%Y/%m/%d %H:%M:%S") + return parsed.replace(tzinfo=dt.timezone.utc).isoformat(timespec="seconds") + except ValueError: + return fallback.isoformat(timespec="milliseconds") + + +def action_for(operation: str) -> str: + operation = operation.lower() + if operation in { + "read", "pread", "pread_recv", "pread_send", "recvfile", "sendfile", + "offload_read_recv", "offload_read_send", + }: + return "read" + if operation in { + "write", "pwrite", "pwrite_recv", "pwrite_send", "ftruncate", + "fallocate", "create_file", "mkdirat", "mknodat", "renameat", + "unlinkat", "symlinkat", "linkat", "offload_write_recv", + "offload_write_send", "fsetxattr", "removexattr", "fremovexattr", + "mkdir", "rmdir", "rename", "unlink", + }: + return "write" + if operation in {"opendir", "fdopendir", "readdir", "freaddir_attr", "closedir"}: + return "list" + if operation in {"connect", "disconnect"}: + return "session" + return "metadata" + + +def parse_audit_line(raw_line: str, source: str) -> Optional[Dict[str, object]]: + match = AUDIT_MARKER_RE.search(raw_line) + if match: + payload = match.group(1) + elif AUDIT_PAYLOAD_RE.match(raw_line): + payload = raw_line.strip() + else: + return None + fields = payload.rstrip("\r\n").split("|") + if len(fields) < 8: + return None + observed_at = utc_now() + operation = fields[5].strip() + result = fields[6].strip() + return { + "timestamp": parse_samba_timestamp(raw_line, observed_at), + "ingestedAt": observed_at.isoformat(timespec="milliseconds"), + "user": fields[1].strip(), + "clientIp": fields[2].strip(), + "client": fields[3].strip(), + "share": fields[4].strip(), + "operation": operation, + "action": action_for(operation), + "result": result, + "success": result.upper() == "OK", + "path": "|".join(fields[7:]).strip(), + "source": os.path.basename(source), + } + + +def archive_events(events: Iterable[Dict[str, object]]) -> int: + handles: Dict[str, object] = {} + count = 0 + try: + for event in events: + day = str(event["ingestedAt"])[:10] + path = os.path.join(ARCHIVE_DIR, f"{day}.jsonl") + handle = handles.get(path) + if handle is None: + handle = open(path, "a", encoding="utf-8") + handles[path] = handle + handle.write(json.dumps(event, separators=(",", ":"), sort_keys=True)) + handle.write("\n") + count += 1 + for handle in handles.values(): + handle.flush() + os.fsync(handle.fileno()) + finally: + for handle in handles.values(): + handle.close() + return count + + +def read_new_events(path: str, entry: Dict[str, object]): + stat = os.stat(path) + inode = int(stat.st_ino) + previous_inode = int(entry.get("inode", -1)) + offset = int(entry.get("offset", 0)) + if previous_inode != inode or stat.st_size < offset: + offset = 0 + + events = [] + with open(path, "r", encoding="utf-8", errors="replace") as handle: + handle.seek(offset) + while True: + line_start = handle.tell() + line = handle.readline() + if not line: + break + if not line.endswith("\n"): + handle.seek(line_start) + break + event = parse_audit_line(line, path) + if event is not None: + events.append(event) + new_offset = handle.tell() + return events, {"inode": inode, "offset": new_offset} + + +def compress_old_archives() -> None: + cutoff = utc_now() - dt.timedelta(hours=COMPRESS_AFTER_HOURS) + today = utc_now().date().isoformat() + for path in glob.glob(os.path.join(ARCHIVE_DIR, "????-??-??.jsonl")): + day = os.path.basename(path)[:10] + if day == today: + continue + try: + modified = dt.datetime.fromtimestamp(os.path.getmtime(path), dt.timezone.utc) + if modified > cutoff: + continue + target = f"{path}.gz" + temp_target = f"{target}.tmp" + with open(path, "rb") as source, gzip.open(temp_target, "wb", compresslevel=6) as output: + while True: + chunk = source.read(1024 * 1024) + if not chunk: + break + output.write(chunk) + os.replace(temp_target, target) + os.remove(path) + log(f"Compressed {os.path.basename(path)}") + except OSError as exc: + log(f"Unable to compress {path}: {exc}") + + +def collect_once(state: Dict[str, Dict[str, object]]) -> int: + total = 0 + seen = set() + initial_by_inode = { + int(entry.get("inode", -1)): entry + for entry in state.values() + if isinstance(entry, dict) and int(entry.get("inode", -1)) >= 0 + } + for path in sorted(glob.glob(SAMBA_LOG_GLOB)): + if not os.path.isfile(path): + continue + seen.add(path) + try: + path_entry = state.get(path, {}) + current_inode = os.stat(path).st_ino + if int(path_entry.get("inode", -1)) != current_inode: + path_entry = initial_by_inode.get(current_inode, {}) + events, new_entry = read_new_events(path, path_entry) + if events: + total += archive_events(events) + state[path] = new_entry + except OSError as exc: + log(f"Unable to read {path}: {exc}") + for stale_path in list(state): + if stale_path not in seen: + state.pop(stale_path, None) + atomic_json(STATE_FILE, state) + return total + + +def stop(_signum, _frame) -> None: + global STOP + STOP = True + + +def main() -> int: + os.makedirs(ARCHIVE_DIR, mode=0o750, exist_ok=True) + signal.signal(signal.SIGTERM, stop) + signal.signal(signal.SIGINT, stop) + state = load_state() + last_compress = 0.0 + log(f"Watching {SAMBA_LOG_GLOB}") + while not STOP: + try: + count = collect_once(state) + if count: + log(f"Archived {count} event(s)") + if time.monotonic() - last_compress >= 3600: + compress_old_archives() + last_compress = time.monotonic() + except Exception as exc: # pylint: disable=broad-except + log(f"Collector cycle failed: {exc}") + time.sleep(POLL_SECONDS) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/app/backup_to_destination.py b/app/backup_to_destination.py index 1184dd7..ffb4331 100644 --- a/app/backup_to_destination.py +++ b/app/backup_to_destination.py @@ -2,6 +2,7 @@ import datetime as dt import fcntl +import json import os import re import subprocess @@ -15,6 +16,7 @@ from urllib.parse import SplitResult, unquote, urlsplit LOCK_PATH = "/state/backup.lock" DEFAULT_BACKUP_LOG_FILE = "/var/log/backup.log" +DEFAULT_BACKUP_STATUS_FILE = "/state/backup-status.json" DEFAULT_PROGRESS_MODE = "auto" DEFAULT_PROGRESS_INTERVAL_SECONDS = 10 PROGRESS_BAR_WIDTH = 28 @@ -152,6 +154,102 @@ class BackupLogger: LOGGER = BackupLogger() +class BackupStatus: + """Atomic machine-readable status consumed by the read-only web UI.""" + + def __init__(self, path: str): + self.path = path + self.value: Dict[str, object] = { + "enabled": True, + "state": "starting", + "percent": 0.0, + "transferredBytes": 0, + "totalBytes": 0, + "activeFiles": [], + } + + def write(self, **changes: object) -> None: + self.value.update(changes) + self.value["updatedAt"] = dt.datetime.now(dt.timezone.utc).isoformat( + timespec="seconds" + ) + try: + status_dir = os.path.dirname(self.path) + if status_dir: + os.makedirs(status_dir, exist_ok=True) + temp_path = f"{self.path}.tmp" + with open(temp_path, "w", encoding="utf-8") as handle: + json.dump(self.value, handle, separators=(",", ":"), sort_keys=True) + handle.flush() + os.fsync(handle.fileno()) + os.replace(temp_path, self.path) + except OSError as exc: + print( + f"[backup] WARNING: unable to update status file {self.path}: {exc}", + file=sys.stderr, + flush=True, + ) + + def begin(self, destination: str) -> None: + self.write( + state="starting", + startedAt=dt.datetime.now(dt.timezone.utc).isoformat(timespec="seconds"), + finishedAt=None, + destination=destination, + snapshot=None, + currentSource=None, + percent=0.0, + transferredBytes=0, + totalBytes=0, + activeFiles=[], + message="Starting backup", + ) + + def progress(self, reporter: "SyncProgressReporter") -> None: + active = [] + for entry in reporter._visible_active_files(): # pylint: disable=protected-access + active.append( + { + "path": entry.file_path, + "percent": entry.percent, + "transferredBytes": entry.transferred_bytes, + "totalBytes": entry.total_bytes, + "detail": entry.detail, + } + ) + self.write( + state="running", + currentSource=reporter.source_path, + percent=round(reporter.overall_progress.percent, 2), + transferredBytes=reporter.overall_progress.transferred_bytes, + totalBytes=reporter.overall_progress.total_bytes, + activeFiles=active, + message=f"Syncing {reporter.source_path}", + ) + + def complete(self, message: str) -> None: + self.write( + state="completed", + finishedAt=dt.datetime.now(dt.timezone.utc).isoformat(timespec="seconds"), + percent=100.0, + transferredBytes=self.value.get("totalBytes", 0), + activeFiles=[], + currentSource=None, + message=message, + ) + + def fail(self, message: str) -> None: + self.write( + state="failed", + finishedAt=dt.datetime.now(dt.timezone.utc).isoformat(timespec="seconds"), + activeFiles=[], + message=message, + ) + + +BACKUP_STATUS: Optional[BackupStatus] = None + + def configure_logging() -> None: LOGGER.configure(os.getenv("BACKUP_LOG_FILE", DEFAULT_BACKUP_LOG_FILE).strip()) @@ -541,12 +639,16 @@ class SyncProgressReporter: if progress.percent >= 100.0: self._complete_file(progress) self._sync_total_progress() + if BACKUP_STATUS is not None: + BACKUP_STATUS.progress(self) def finish(self, *, success: bool) -> None: if success: self.overall_progress.complete_source() self._render_dashboard() self._log_total_progress(self.now(), force=True) + if BACKUP_STATUS is not None: + BACKUP_STATUS.progress(self) self._clear_dashboard() def _known_file_size(self, file_path: Optional[str]) -> Optional[int]: @@ -1230,6 +1332,7 @@ class RsyncBackend: "rsync", "-a", "--delete", + "--mkpath", "--progress", "--outbuf=L", f"{source_path}/", @@ -1345,6 +1448,7 @@ def parse_snapshot_inventory(snapshot_names: List[str]) -> List[Snapshot]: def run_backup() -> int: + global BACKUP_STATUS destination_url = os.getenv("BACKUP_DESTINATION", "").strip() if not destination_url: log("BACKUP_DESTINATION is unset, skipping backup") @@ -1366,6 +1470,10 @@ def run_backup() -> int: interactive_progress = should_show_progress_bar(progress_mode) destination = parse_destination(destination_url) + BACKUP_STATUS = BackupStatus( + os.getenv("BACKUP_STATUS_FILE", DEFAULT_BACKUP_STATUS_FILE).strip() + ) + BACKUP_STATUS.begin(redact_destination(destination.raw_url)) backend = build_backend(destination) try: log(f"Starting backup to {redact_destination(destination.raw_url)}") @@ -1373,11 +1481,17 @@ def run_backup() -> int: existing = set(backend.list_snapshots()) snapshot_name = choose_snapshot_name(existing) log(f"Creating snapshot {snapshot_name}") + BACKUP_STATUS.write(snapshot=snapshot_name, message="Measuring backup payload") log("Measuring backup payload size") source_sizes, total_bytes = measure_backup_payload(sources) log(f"Backup payload size: {format_bytes(total_bytes)}") overall_progress = OverallProgress(total_bytes) + BACKUP_STATUS.write( + state="running", + totalBytes=total_bytes, + message=f"Backup payload: {format_bytes(total_bytes)}", + ) for source_path, destination_path in sources: log(f"Syncing {source_path}") @@ -1408,6 +1522,9 @@ def run_backup() -> int: log( f"Backup completed (snapshots total={len(snapshots)}, retained={len(retained)}, pruned={deleted_count})" ) + BACKUP_STATUS.complete( + f"Backup completed; {len(retained)} snapshot(s) retained" + ) return 0 finally: backend.close() @@ -1442,6 +1559,8 @@ def main() -> int: return with_lock() except Exception as exc: # pylint: disable=broad-except log(f"ERROR: {exc}") + if BACKUP_STATUS is not None: + BACKUP_STATUS.fail(str(exc)) return 1 finally: close_logging() diff --git a/app/init.sh b/app/init.sh index af86725..390d3fb 100755 --- a/app/init.sh +++ b/app/init.sh @@ -223,6 +223,9 @@ write_runtime_env_file() { if [[ -n "${BACKUP_PROGRESS_INTERVAL_SECONDS:-}" ]]; then printf 'export BACKUP_PROGRESS_INTERVAL_SECONDS=%q\n' "$BACKUP_PROGRESS_INTERVAL_SECONDS" fi + if [[ -n "${BACKUP_STATUS_FILE:-}" ]]; then + printf 'export BACKUP_STATUS_FILE=%q\n' "$BACKUP_STATUS_FILE" + fi if [[ -n "${JOIN_USER:-}" ]]; then printf 'export JOIN_USER=%q\n' "$JOIN_USER" fi @@ -288,6 +291,124 @@ wait_for_winbind() { return 0 } +env_is_true() { + case "${1,,}" in + 1|true|yes|on) return 0 ;; + *) return 1 ;; + esac +} + +web_should_start() { + if [[ -n "${WEB_ENABLED:-}" ]]; then + env_is_true "$WEB_ENABLED" + return + fi + if [[ -n "${STEP_CA_URL:-}" ]] || \ + [[ -s "${WEB_TLS_CERT_FILE:-/state/tls/web.crt}" ]]; then + return 0 + fi + log 'WEB_ENABLED is unset and no TLS configuration exists; web UI disabled for upgrade compatibility.' + return 1 +} + +ensure_web_jwt_secret() { + local secret_file="/state/web/jwt-secret" + if [[ -n "${WEB_JWT_SECRET:-}" ]]; then + export WEB_JWT_SECRET + return + fi + + mkdir -p /state/web + if [[ ! -s "$secret_file" ]]; then + umask 077 + python3 -c 'import secrets; print(secrets.token_urlsafe(48))' > "$secret_file" + fi + WEB_JWT_SECRET="$(<"$secret_file")" + export WEB_JWT_SECRET + log 'WEB_JWT_SECRET was not provided; using a persistent generated secret.' +} + +configure_web_tls() { + local tls_mode="${WEB_TLS_MODE:-step}" + local tls_dir="" + local provisioner_password_file="" + + export WEB_HOSTNAME="${WEB_HOSTNAME:-${AD_DNS_NAME}}" + export WEB_BIND_PORT="${WEB_BIND_PORT:-8443}" + export WEB_TLS_CERT_FILE="${WEB_TLS_CERT_FILE:-/state/tls/web.crt}" + export WEB_TLS_KEY_FILE="${WEB_TLS_KEY_FILE:-/state/tls/web.key}" + tls_dir="$(dirname "$WEB_TLS_CERT_FILE")" + mkdir -p "$tls_dir" "$(dirname "$WEB_TLS_KEY_FILE")" + + if [[ "$tls_mode" == "files" ]]; then + if [[ ! -s "$WEB_TLS_CERT_FILE" || ! -s "$WEB_TLS_KEY_FILE" ]]; then + printf '[init] ERROR: WEB_TLS_MODE=files requires %s and %s\n' "$WEB_TLS_CERT_FILE" "$WEB_TLS_KEY_FILE" >&2 + return 1 + fi + return + fi + if [[ "$tls_mode" != "step" ]]; then + printf '[init] ERROR: WEB_TLS_MODE must be step or files\n' >&2 + return 1 + fi + + export STEPPATH="${STEP_PATH:-/state/step}" + if [[ ! -s "$STEPPATH/config/defaults.json" ]] || \ + env_is_true "${STEP_CA_REBOOTSTRAP:-false}"; then + require_env STEP_CA_URL + require_env STEP_CA_FINGERPRINT + log "Bootstrapping Smallstep trust for ${STEP_CA_URL}" + step ca bootstrap --force --ca-url "$STEP_CA_URL" --fingerprint "$STEP_CA_FINGERPRINT" + fi + + if [[ ! -s "$WEB_TLS_CERT_FILE" || ! -s "$WEB_TLS_KEY_FILE" ]]; then + log "Requesting HTTPS certificate for ${WEB_HOSTNAME}" + if [[ -n "${STEP_CA_TOKEN:-}" ]]; then + step ca certificate "$WEB_HOSTNAME" "$WEB_TLS_CERT_FILE" "$WEB_TLS_KEY_FILE" --token "$STEP_CA_TOKEN" --force + else + require_env STEP_CA_PROVISIONER + if [[ -n "${STEP_CA_PROVISIONER_PASSWORD_FILE:-}" ]]; then + provisioner_password_file="$STEP_CA_PROVISIONER_PASSWORD_FILE" + elif [[ -n "${STEP_CA_PROVISIONER_PASSWORD:-}" ]]; then + provisioner_password_file="/run/step-provisioner-password" + umask 077 + printf '%s\n' "$STEP_CA_PROVISIONER_PASSWORD" > "$provisioner_password_file" + else + printf '[init] ERROR: STEP_CA_TOKEN, STEP_CA_PROVISIONER_PASSWORD_FILE, or STEP_CA_PROVISIONER_PASSWORD is required for initial TLS setup\n' >&2 + return 1 + fi + step ca certificate "$WEB_HOSTNAME" "$WEB_TLS_CERT_FILE" "$WEB_TLS_KEY_FILE" \ + --provisioner "$STEP_CA_PROVISIONER" \ + --provisioner-password-file "$provisioner_password_file" \ + --force + if [[ "$provisioner_password_file" == "/run/step-provisioner-password" ]]; then + rm -f "$provisioner_password_file" + fi + fi + fi + chmod 0600 "$WEB_TLS_KEY_FILE" + chmod 0644 "$WEB_TLS_CERT_FILE" +} + +start_observability_services() { + log 'Starting Samba audit collector' + python3 /app/audit_collector.py & + + if web_should_start; then + ensure_web_jwt_secret + configure_web_tls + unset STEP_CA_PROVISIONER_PASSWORD STEP_CA_TOKEN + if [[ "${WEB_TLS_MODE:-step}" == "step" ]] && env_is_true "${WEB_TLS_AUTORENEW:-true}"; then + log 'Starting Smallstep certificate renewal daemon' + step ca renew --daemon --force "$WEB_TLS_CERT_FILE" "$WEB_TLS_KEY_FILE" & + fi + log "Starting read-only web UI for https://${WEB_HOSTNAME}" + python3 /app/web_ui.py & + else + log 'WEB_ENABLED is false; web UI disabled' + fi +} + install_cron_job() { cat > /etc/cron.d/reconcile-shares <<'EOF' SHELL=/bin/bash @@ -323,7 +444,7 @@ if [[ -n "${JOIN_PASSWORD:-}" ]]; then export JOIN_PASSWORD fi -mkdir -p /data/private /data/fslogix /data/groups/data /data/groups/archive /state /var/log/samba +mkdir -p /data/private /data/fslogix /data/groups/data /data/groups/archive /state /state/audit /state/web /var/log/samba touch /var/log/reconcile.log /var/log/backup.log append_winbind_to_nss @@ -348,8 +469,10 @@ write_runtime_env_file log 'Running startup reconciliation' python3 /app/reconcile_shares.py +start_observability_services + if [[ -n "${BACKUP_DESTINATION:-}" ]]; then - log "Backups enabled: daily at ${BACKUP_START_HOUR}:00 (container local time)." + log "Backups enabled: daily at ${BACKUP_START_HOUR}:00 UTC." else log 'BACKUP_DESTINATION is unset; scheduled backup disabled' fi diff --git a/app/web/app.js b/app/web/app.js new file mode 100644 index 0000000..cb556ac --- /dev/null +++ b/app/web/app.js @@ -0,0 +1,343 @@ +"use strict"; + +const state = { session: null, timer: null, groups: null, storage: null, activity: null }; +const loginView = document.querySelector("#login-view"); +const appView = document.querySelector("#app-view"); +const content = document.querySelector("#content"); +const nav = document.querySelector("#navigation"); +const toast = document.querySelector("#toast"); + +const esc = value => String(value ?? "").replace(/[&<>'"]/g, char => ({"&":"&","<":"<",">":">","'":"'",'"':"""}[char])); +const bytes = value => { + let number = Number(value || 0); + const units = ["B", "kB", "MB", "GB", "TB", "PB"]; + let unit = 0; + while (Math.abs(number) >= 1024 && unit < units.length - 1) { number /= 1024; unit += 1; } + const digits = number >= 100 || unit === 0 ? 0 : 1; + return `${number.toLocaleString("de-DE", {minimumFractionDigits: digits, maximumFractionDigits: digits})} ${units[unit]}`; +}; +const decimal = (value, digits = 1) => Number(value || 0).toLocaleString("de-DE", {minimumFractionDigits: digits, maximumFractionDigits: digits}); +const utcTime = value => { + if (!value) return "—"; + const date = new Date(value); + if (Number.isNaN(date.getTime())) return "—"; + const pad = number => String(number).padStart(2, "0"); + return `${pad(date.getUTCDate())}.${pad(date.getUTCMonth() + 1)}.${date.getUTCFullYear()} ${pad(date.getUTCHours())}:${pad(date.getUTCMinutes())}:${pad(date.getUTCSeconds())} UTC`; +}; +const actionLabel = value => ({read: "Lesen", write: "Schreiben", list: "Auflisten", metadata: "Metadaten", session: "Sitzung"}[value] || value || "—"); +const nodeTypeLabel = value => ({group: "Gruppe", user: "Benutzer", computer: "Computer", unknown: "Unbekannt"}[value] || value); +const backupStateLabel = value => ({starting: "Startet", running: "Läuft", completed: "Abgeschlossen", failed: "Fehlgeschlagen", waiting: "Wartet", disabled: "Deaktiviert"}[value] || value || "Unbekannt"); +function backupMessage(data) { + const message = String(data.message || ""); + if (!message) return data.enabled ? `Täglich um ${String(data.scheduledHour).padStart(2, "0")}:00 UTC geplant.` : "Sicherungen sind nicht eingerichtet."; + if (message === "Starting backup") return "Sicherung wird gestartet."; + if (message === "Measuring backup payload") return "Sicherungsumfang wird ermittelt."; + if (message.startsWith("Backup payload: ")) return `Sicherungsumfang: ${message.slice(16)}`; + if (message.startsWith("Syncing ")) return `${message.slice(8)} wird synchronisiert.`; + const completed = message.match(/^Backup completed; (\d+) snapshot\(s\) retained$/); + if (completed) return `Sicherung abgeschlossen; ${completed[1]} Sicherungsstände werden aufbewahrt.`; + if (data.state === "failed") return "Sicherung fehlgeschlagen. Einzelheiten stehen im Sicherungsprotokoll."; + if (data.state === "completed") return "Sicherung abgeschlossen."; + if (data.state === "running") return "Sicherung läuft."; + if (data.state === "starting") return "Sicherung wird gestartet."; + return "Kein Laufstatus verfügbar."; +} +const isoDay = date => date.toISOString().slice(0, 10); +const sum = (rows, field) => (rows || []).reduce((total, row) => total + Number(row[field] || 0), 0); + +async function api(path, options = {}) { + const response = await fetch(path, { + ...options, + headers: {"Content-Type": "application/json", ...(options.headers || {})}, + credentials: "same-origin", + }); + let body = {}; + try { body = await response.json(); } catch (_) { /* no body */ } + if (response.status === 401 && path !== "/api/login") { + showLogin(); + throw new Error("Die Sitzung ist abgelaufen. Bitte erneut anmelden."); + } + if (!response.ok) throw new Error(body.error || `Anfrage fehlgeschlagen (${response.status})`); + return body; +} + +function notice(message) { + toast.textContent = message; + toast.hidden = false; + window.setTimeout(() => { toast.hidden = true; }, 4000); +} + +function showLogin() { + clearInterval(state.timer); + state.session = null; + appView.hidden = true; + loginView.hidden = false; + document.querySelector("#login-form input[name=username]").focus(); +} + +function showApp(session) { + state.session = {user: session.user, expiresAt: session.expiresAt}; + document.querySelector("#session-user").textContent = session.user; + loginView.hidden = true; + appView.hidden = false; + navigate(location.pathname === "/" ? "/overview" : location.pathname, true); +} + +function setLoading() { content.innerHTML = '
Wird geladen…
'; } +function pageHead(title, intro, extra = "") { + return `

${esc(title)}

${esc(intro)}

${extra}
`; +} +function empty(message) { return `
${esc(message)}
`; } +function badge(text, kind = "") { return `${esc(text)}`; } + +function routeFor(path) { + if (path.startsWith("/storage/data")) return "storage-data"; + if (path.startsWith("/storage/users")) return "storage-users"; + if (path.startsWith("/shares")) return "shares"; + if (path.startsWith("/activity")) return "activity"; + if (path.startsWith("/backup")) return "backup"; + if (path.startsWith("/system")) return "system"; + return "overview"; +} + +async function navigate(path, replace = false) { + clearInterval(state.timer); + state.timer = null; + const route = routeFor(path); + if (replace) history.replaceState({}, "", path); else history.pushState({}, "", path); + nav.querySelectorAll("a").forEach(link => link.classList.toggle("active", link.dataset.route === route)); + document.querySelector(".sidebar").classList.remove("open"); + setLoading(); + try { + if (route === "overview") await renderOverview(); + if (route === "shares") await renderShares(); + if (route === "storage-data") await renderStorage("data"); + if (route === "storage-users") await renderStorage("users"); + if (route === "activity") await renderActivity(); + if (route === "backup") await renderBackup(); + if (route === "system") await renderSystem(); + content.focus(); + } catch (error) { + content.innerHTML = pageHead("Seite konnte nicht geladen werden", error.message) + `
${empty("Dienststatus prüfen und erneut versuchen.")}
`; + } +} + +function eventRows(events) { + if (!events?.length) return 'Keine passenden Ereignisse'; + return events.map(event => ` + ${esc(utcTime(event.timestamp))} + ${esc(event.user)}${esc(event.clientIp)}${esc(event.share)} + ${badge(actionLabel(event.action || event.operation))}
${esc(event.operation)} + ${esc(event.path || "—")} + ${event.success ? badge("Erfolgreich") : badge(event.result || "Fehlgeschlagen", "error")} + `).join(""); +} + +async function renderOverview() { + const data = await api("/api/overview"); + const usage = data.usage || {}; + const totals = usage.totals || {}; + const backup = data.backup || {}; + content.innerHTML = pageHead("Übersicht", "Speicherbelegung, Aktivität und Sicherungsstatus.", `Stand ${esc(utcTime(usage.scannedAt))}`) + ` +
+
Daten${bytes(totals.dataBytes)}
+
Private + FSLogix${bytes(Number(totals.privateBytes || 0) + Number(totals.fslogixBytes || 0))}
+
Aktive Gruppen${esc(data.activeGroups)}
+
Ereignisse · 48 Std.${esc(data.eventCount)}
+
+
+

Größte Datengruppen

Alle anzeigen
${usageTable((usage.groups || []).slice(0, 7), "group")}
+

Sicherung

Details
+
${badge(backupStateLabel(backup.state || (backup.enabled ? "waiting" : "disabled")), backup.state === "failed" ? "error" : "")}${esc(backupMessage(backup))}
+ +
${decimal(backup.percent)} % · ${bytes(backup.transferredBytes)} / ${bytes(backup.totalBytes)}
+
+
+

Letzte Dateiaktivitäten

Protokoll öffnen
+
${eventRows(data.recentEvents)}
Zeit (UTC)BenutzerClientFreigabeAktionPfadErgebnis
+
`; + bindInternalLinks(); +} + +function usageTable(rows, type) { + if (!rows.length) return empty("Die erste Speicherprüfung ist noch nicht abgeschlossen."); + const maximum = Math.max(...rows.map(row => Number(type === "group" ? row.bytes : row.totalBytes)), 1); + return `
${type === "user" ? "" : ""}${rows.map(row => { + const value = Number(type === "group" ? row.bytes : row.totalBytes); + return `${type === "user" ? `` : ""}`; + }).join("")}
${type === "group" ? "Gruppenordner" : "Benutzer"}PrivateFSLogixBelegung
${esc(row.name)}${bytes(row.privateBytes)}${bytes(row.fslogixBytes)}${bytes(value)}
`; +} + +function nodeMatches(node, query) { + if (!query) return true; + if (`${node.name} ${node.sam} ${node.type} ${nodeTypeLabel(node.type)}`.toLowerCase().includes(query)) return true; + return (node.members || []).some(child => nodeMatches(child, query)); +} + +function treeNodes(nodes, query = "") { + return nodes.filter(node => nodeMatches(node, query)).map(node => { + const children = treeNodes(node.members || [], query); + const title = `${esc(nodeTypeLabel(node.type))} ${esc(node.name)}${node.sam && node.sam !== node.name ? ` ${esc(node.sam)}` : ""}${node.cycle ? ` ${badge("Zyklus", "warn")}` : ""}`; + return children ? `
${title}${children}
` : `
${title}
`; + }).join(""); +} + +async function renderShares() { + const data = await api("/api/groups"); + state.groups = data; + const groups = data.groups || []; + content.innerHTML = pageHead("Dateifreigaben", "FS_*-Ordnergruppen und ihre wirksamen verschachtelten Mitgliedschaften.", `Verzeichnisstand ${esc(utcTime(data.fetchedAt))}`) + ` + ${data.truncated ? `

${badge("Ergebnis gekürzt", "warn")} WEB_MAX_GROUP_NODES erhöhen, um alle Mitglieder anzuzeigen.

` : ""} +
+

Gruppenordner

${groups.length}
    +
    +
    `; + const list = document.querySelector("#group-list"); + const tree = document.querySelector("#tree-panel"); + let selected = groups[0] || null; + let query = ""; + const draw = () => { + const visible = groups.filter(group => `${group.name} ${group.sam} ${group.folder}`.toLowerCase().includes(query) || group.members.some(node => nodeMatches(node, query))); + if (selected && !visible.includes(selected)) selected = visible[0] || null; + list.innerHTML = visible.length ? visible.map(group => `
  • `).join("") : empty("Keine Gruppe entspricht dem Filter."); + if (!selected) tree.innerHTML = empty("Gruppenordner auswählen."); + else tree.innerHTML = `

    ${esc(selected.folder)}

    ${esc(selected.sam)} · ${selected.userCount} wirksame Benutzer
    ${selected.active ? badge("Aktiv") : badge("Archiviert", "warn")}
    ${treeNodes(selected.members, query) || empty("Keine direkten Mitglieder")}
    `; + list.querySelectorAll("button[data-guid]").forEach(button => button.addEventListener("click", () => { selected = groups.find(group => group.guid === button.dataset.guid); draw(); })); + }; + document.querySelector("#group-filter").addEventListener("input", event => { query = event.target.value.trim().toLowerCase(); draw(); }); + draw(); +} + +async function renderStorage(type) { + const data = await api("/api/storage"); + state.storage = data; + let query = ""; + let sort = "size-desc"; + content.innerHTML = pageHead(type === "data" ? "Datenbelegung" : "Benutzerbelegung", type === "data" ? "Belegter Speicher je aktivem Gruppenordner unter /Data." : "Gemeinsame Belegung von /Private und /FSLogix je Benutzer.", `Geprüft ${esc(utcTime(data.scannedAt))} in ${esc(data.scanSeconds || 0)} s`) + ` +
    `; + const draw = () => { + const source = [...(type === "data" ? data.groups || [] : data.users || [])]; + let rows = source.filter(row => row.name.toLowerCase().includes(query)); + const field = type === "data" ? "bytes" : "totalBytes"; + rows.sort((a, b) => sort === "name" ? a.name.localeCompare(b.name) : sort === "size-asc" ? Number(a[field]) - Number(b[field]) : Number(b[field]) - Number(a[field])); + document.querySelector("#storage-table").innerHTML = `

    ${rows.length} Einträge · ${bytes(sum(rows, field))} angezeigt

    ${usageTable(rows, type === "data" ? "group" : "user")}`; + }; + document.querySelector("#storage-filter").addEventListener("input", event => { query = event.target.value.trim().toLowerCase(); draw(); }); + document.querySelector("#storage-sort").addEventListener("change", event => { sort = event.target.value; draw(); }); + draw(); +} + +async function renderActivity() { + const today = new Date(); + const yesterday = new Date(Date.now() - 86400000); + content.innerHTML = pageHead("Aktivitätsprotokoll", "Aufgezeichnete Samba-Vorgänge nach Datum, Identität, Freigabe, Aktion, Ergebnis oder Pfad durchsuchen.") + ` +
    +
    + + + + + + + + + +
    + +

    +
    Zeit (UTC)BenutzerClientFreigabeAktionPfadErgebnis
    +

    +
    `; + const form = document.querySelector("#activity-filter"); + let cursor = 0; + const load = async append => { + const params = new URLSearchParams(new FormData(form)); + params.set("limit", "100"); + if (cursor) params.set("cursor", String(cursor)); + const result = await api(`/api/activity?${params}`); + state.activity = result; + const rows = document.querySelector("#activity-rows"); + if (append) rows.insertAdjacentHTML("beforeend", eventRows(result.events)); else rows.innerHTML = eventRows(result.events); + document.querySelector("#activity-summary").textContent = `${result.matched.toLocaleString("de-DE")} passende Ereignisse`; + const more = document.querySelector("#load-more"); + cursor = result.nextCursor || 0; + more.hidden = !result.nextCursor; + for (const [id, values] of [["users-list", result.facets.users], ["shares-list", result.facets.shares], ["operations-list", result.facets.operations]]) { + document.querySelector(`#${id}`).innerHTML = values.filter(Boolean).map(value => `