This commit is contained in:
Ludwig Lehnert
2026-07-31 14:50:54 +00:00
parent d6e264e655
commit b0fba5846f
25 changed files with 3452 additions and 7 deletions
+256
View File
@@ -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())
+119
View File
@@ -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()
+125 -2
View File
@@ -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
+343
View File
@@ -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 => ({"&":"&amp;","<":"&lt;",">":"&gt;","'":"&#39;",'"':"&quot;"}[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 = '<div class="loading">Wird geladen…</div>'; }
function pageHead(title, intro, extra = "") {
return `<header class="page-head"><div><h1>${esc(title)}</h1><p class="muted">${esc(intro)}</p></div>${extra}</header>`;
}
function empty(message) { return `<div class="empty">${esc(message)}</div>`; }
function badge(text, kind = "") { return `<span class="badge ${kind}">${esc(text)}</span>`; }
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) + `<section class="panel">${empty("Dienststatus prüfen und erneut versuchen.")}</section>`;
}
}
function eventRows(events) {
if (!events?.length) return '<tr><td colspan="7" class="empty">Keine passenden Ereignisse</td></tr>';
return events.map(event => `<tr>
<td class="timestamp">${esc(utcTime(event.timestamp))}</td>
<td>${esc(event.user)}</td><td>${esc(event.clientIp)}</td><td>${esc(event.share)}</td>
<td>${badge(actionLabel(event.action || event.operation))}<br><span class="muted">${esc(event.operation)}</span></td>
<td class="path">${esc(event.path || "—")}</td>
<td>${event.success ? badge("Erfolgreich") : badge(event.result || "Fehlgeschlagen", "error")}</td>
</tr>`).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.", `<span class="muted">Stand ${esc(utcTime(usage.scannedAt))}</span>`) + `
<section class="cards">
<article class="card"><span class="label">Daten</span><span class="value">${bytes(totals.dataBytes)}</span></article>
<article class="card"><span class="label">Private + FSLogix</span><span class="value">${bytes(Number(totals.privateBytes || 0) + Number(totals.fslogixBytes || 0))}</span></article>
<article class="card"><span class="label">Aktive Gruppen</span><span class="value">${esc(data.activeGroups)}</span></article>
<article class="card"><span class="label">Ereignisse · 48 Std.</span><span class="value">${esc(data.eventCount)}</span></article>
</section>
<section class="split">
<article class="panel"><div class="panel-head"><h2>Größte Datengruppen</h2><a href="/storage/data" data-nav>Alle anzeigen</a></div>${usageTable((usage.groups || []).slice(0, 7), "group")}</article>
<article class="panel"><div class="panel-head"><h2>Sicherung</h2><a href="/backup" data-nav>Details</a></div>
<div class="status-line">${badge(backupStateLabel(backup.state || (backup.enabled ? "waiting" : "disabled")), backup.state === "failed" ? "error" : "")}<span class="muted">${esc(backupMessage(backup))}</span></div>
<progress class="progress-large" max="100" value="${Number(backup.percent || 0)}"></progress>
<div class="numeric">${decimal(backup.percent)} % · ${bytes(backup.transferredBytes)} / ${bytes(backup.totalBytes)}</div>
</article>
</section>
<section class="panel"><div class="panel-head"><h2>Letzte Dateiaktivitäten</h2><a href="/activity" data-nav>Protokoll öffnen</a></div>
<div class="table-wrap"><table><thead><tr><th>Zeit (UTC)</th><th>Benutzer</th><th>Client</th><th>Freigabe</th><th>Aktion</th><th>Pfad</th><th>Ergebnis</th></tr></thead><tbody>${eventRows(data.recentEvents)}</tbody></table></div>
</section>`;
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 `<div class="table-wrap"><table><thead><tr><th>${type === "group" ? "Gruppenordner" : "Benutzer"}</th>${type === "user" ? "<th>Private</th><th>FSLogix</th>" : ""}<th>Belegung</th><th></th></tr></thead><tbody>${rows.map(row => {
const value = Number(type === "group" ? row.bytes : row.totalBytes);
return `<tr><td><strong>${esc(row.name)}</strong></td>${type === "user" ? `<td class="numeric">${bytes(row.privateBytes)}</td><td class="numeric">${bytes(row.fslogixBytes)}</td>` : ""}<td class="numeric">${bytes(value)}</td><td class="usage-bar"><progress max="${maximum}" value="${value}"></progress></td></tr>`;
}).join("")}</tbody></table></div>`;
}
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 = `<span class="kind">${esc(nodeTypeLabel(node.type))}</span> <strong>${esc(node.name)}</strong>${node.sam && node.sam !== node.name ? ` <span class="muted">${esc(node.sam)}</span>` : ""}${node.cycle ? ` ${badge("Zyklus", "warn")}` : ""}`;
return children ? `<details ${query ? "open" : ""}><summary>${title}</summary>${children}</details>` : `<div class="leaf">${title}</div>`;
}).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.", `<span class="muted">Verzeichnisstand ${esc(utcTime(data.fetchedAt))}</span>`) + `
${data.truncated ? `<p>${badge("Ergebnis gekürzt", "warn")} WEB_MAX_GROUP_NODES erhöhen, um alle Mitglieder anzuzeigen.</p>` : ""}
<section class="split">
<article class="panel"><div class="panel-head"><h2>Gruppenordner</h2><span>${groups.length}</span></div><input id="group-filter" type="search" placeholder="Gruppen oder Mitglieder filtern"><ul id="group-list" class="list"></ul></article>
<article class="panel"><div id="tree-panel"></div></article>
</section>`;
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 => `<li><button class="select-row ${group === selected ? "active" : ""}" data-guid="${esc(group.guid)}"><span><strong>${esc(group.folder)}</strong><br><span class="muted">${esc(group.sam)}</span></span><span>${group.userCount} Benutzer<br>${group.groupCount} Gruppen</span></button></li>`).join("") : empty("Keine Gruppe entspricht dem Filter.");
if (!selected) tree.innerHTML = empty("Gruppenordner auswählen.");
else tree.innerHTML = `<div class="panel-head"><div><h2>${esc(selected.folder)}</h2><span class="muted">${esc(selected.sam)} · ${selected.userCount} wirksame Benutzer</span></div>${selected.active ? badge("Aktiv") : badge("Archiviert", "warn")}</div><div class="tree">${treeNodes(selected.members, query) || empty("Keine direkten Mitglieder")}</div>`;
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.", `<span class="muted">Geprüft ${esc(utcTime(data.scannedAt))} in ${esc(data.scanSeconds || 0)} s</span>`) + `
<section class="panel"><div class="toolbar"><input id="storage-filter" type="search" placeholder="${type === "data" ? "Gruppenordner" : "Benutzer"} filtern"><select id="storage-sort"><option value="size-desc">Größte zuerst</option><option value="size-asc">Kleinste zuerst</option><option value="name">Name</option></select></div><div id="storage-table"></div></section>`;
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 = `<p class="muted">${rows.length} Einträge · ${bytes(sum(rows, field))} angezeigt</p>${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.") + `
<section class="panel">
<form id="activity-filter" class="filters">
<label>Von (UTC)<input name="from" type="date" value="${isoDay(yesterday)}" required></label>
<label>Bis (UTC)<input name="to" type="date" value="${isoDay(today)}" required></label>
<label>Benutzer<input name="user" list="users-list" placeholder="Alle Benutzer"></label>
<label>Freigabe<input name="share" list="shares-list" placeholder="Alle Freigaben"></label>
<label>Aktion<select name="action"><option value="">Alle Aktionen</option><option value="read">Lesen</option><option value="write">Schreiben</option><option value="list">Auflisten</option><option value="metadata">Metadaten</option><option value="session">Sitzung</option></select></label>
<label>Ergebnis<select name="result"><option value="">Alle Ergebnisse</option><option value="OK">Erfolgreich</option><option value="fail">Fehlgeschlagen</option></select></label>
<label>Operation<input name="operation" list="operations-list" placeholder="z. B. pread"></label>
<label class="wide">Pfad enthält<input name="path" placeholder="Ordner oder Dateiname"></label>
<button type="submit">Filter anwenden</button>
</form>
<datalist id="users-list"></datalist><datalist id="shares-list"></datalist><datalist id="operations-list"></datalist>
<p id="activity-summary" class="muted"></p>
<div class="table-wrap"><table><thead><tr><th>Zeit (UTC)</th><th>Benutzer</th><th>Client</th><th>Freigabe</th><th>Aktion</th><th>Pfad</th><th>Ergebnis</th></tr></thead><tbody id="activity-rows"></tbody></table></div>
<p><button id="load-more" hidden>Weitere laden</button></p>
</section>`;
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 => `<option value="${esc(value)}">`).join("");
}
};
form.addEventListener("submit", async event => { event.preventDefault(); cursor = 0; try { await load(false); } catch (error) { notice(error.message); } });
document.querySelector("#load-more").addEventListener("click", async () => { try { await load(true); } catch (error) { notice(error.message); } });
await load(false);
}
function backupMarkup(data) {
const stateName = data.state || (data.enabled ? "waiting" : "disabled");
const active = data.activeFiles || [];
return `
<section class="cards">
<article class="card"><span class="label">Status</span><span class="value">${esc(backupStateLabel(stateName))}</span></article>
<article class="card"><span class="label">Fortschritt</span><span class="value">${decimal(data.percent)} %</span></article>
<article class="card"><span class="label">Übertragen</span><span class="value">${bytes(data.transferredBytes)}</span></article>
<article class="card"><span class="label">Gestartet</span><span class="value">${data.startedAt ? esc(utcTime(data.startedAt)) : "—"}</span></article>
</section>
<section class="panel"><div class="panel-head"><h2>Aktueller Lauf</h2>${badge(backupStateLabel(stateName), stateName === "failed" ? "error" : "")}</div>
<p>${esc(backupMessage(data))}</p>
<progress class="progress-large" max="100" value="${Number(data.percent || 0)}"></progress>
<div class="status-line"><strong>${bytes(data.transferredBytes)} / ${bytes(data.totalBytes)}</strong><span class="muted">${esc(data.currentSource || "")}</span><span class="muted">${esc(data.snapshot || "")}</span></div>
${active.length ? `<h3>Dateien in Bearbeitung</h3><div class="table-wrap"><table><thead><tr><th>Datei</th><th>Fortschritt</th><th>Übertragen</th></tr></thead><tbody>${active.map(file => `<tr><td class="path">${esc(file.path)}</td><td><progress max="100" value="${Number(file.percent || 0)}"></progress></td><td class="numeric">${bytes(file.transferredBytes)} / ${bytes(file.totalBytes)}</td></tr>`).join("")}</tbody></table></div>` : ""}
</section>
<section class="panel"><div class="panel-head"><h2>Sicherungsprotokoll</h2><span class="muted">Letzte ${data.log?.length || 0} Zeilen</span></div><pre class="log">${esc((data.log || []).join("\n") || "Noch keine Ausgabe vorhanden.")}</pre></section>`;
}
async function renderBackup() {
content.innerHTML = pageHead("Sicherungen", "Live-Fortschritt und letzte Sicherungsausgabe. Auf dieser Seite können Sicherungen weder gestartet noch geändert werden.") + '<div id="backup-body"></div>';
const refresh = async () => {
try { document.querySelector("#backup-body").innerHTML = backupMarkup(await api("/api/backup")); }
catch (error) { notice(error.message); }
};
await refresh();
state.timer = window.setInterval(refresh, 2000);
}
async function renderSystem() {
const data = await api("/api/system");
const usage = data.usage || {};
content.innerHTML = pageHead("System", "Status von Diensten, Zertifikat, Protokollerfassung und Speicherprüfung.", `<span class="muted">Serverzeit ${esc(utcTime(data.serverTime))}</span>`) + `
<section class="panel"><div class="panel-head"><h2>Dienstprüfungen</h2><span>${esc(data.hostname)}</span></div><div class="check-list">
<div class="check"><span>Domänenvertrauen</span>${data.checks.domainTrust ? badge("In Ordnung") : badge("Fehlgeschlagen", "error")}</div>
<div class="check"><span>Samba-Konfiguration</span>${data.checks.sambaConfig ? badge("Gültig") : badge("Fehlgeschlagen", "error")}</div>
<div class="check"><span>Aktivitätsarchiv</span><strong>${data.audit.days} Tage · ${bytes(data.audit.bytes)}</strong></div>
<div class="check"><span>Speicherprüfung</span><strong>${usage.scannedAt ? esc(utcTime(usage.scannedAt)) : "Ausstehend"}</strong></div>
</div></section>
<section class="split">
<article class="panel"><h2>TLS-Zertifikat</h2><dl><dt>Allgemeiner Name</dt><dd>${esc(data.tls.subject?.commonName || "—")}</dd><dt>Aussteller</dt><dd>${esc(data.tls.issuer?.commonName || "—")}</dd><dt>Gültig bis</dt><dd>${esc(utcTime(data.tls.notAfter))}</dd><dt>Namen</dt><dd>${esc((data.tls.sans || []).map(value => value[1]).join(", ") || "—")}</dd></dl></article>
<article class="panel"><h2>Protokollaufbewahrung</h2><dl><dt>Ältester UTC-Tag</dt><dd>${esc(data.audit.oldest || "—")}</dd><dt>Neuester UTC-Tag</dt><dd>${esc(data.audit.newest || "—")}</dd><dt>Archivgröße</dt><dd>${bytes(data.audit.bytes)}</dd></dl><p class="muted">Abgeschlossene Tagesdateien werden automatisch komprimiert und aufbewahrt, bis ein Administrator sie entfernt.</p></article>
</section>`;
}
function bindInternalLinks() {
content.querySelectorAll("a[data-nav]").forEach(link => link.addEventListener("click", event => { event.preventDefault(); navigate(link.pathname); }));
}
document.querySelector("#login-form").addEventListener("submit", async event => {
event.preventDefault();
const form = event.currentTarget;
const button = form.querySelector("button");
const error = document.querySelector("#login-error");
error.hidden = true; button.disabled = true;
try {
const values = new FormData(form);
const session = await api("/api/login", {method: "POST", body: JSON.stringify({username: values.get("username"), password: values.get("password")})});
form.reset();
showApp(session);
} catch (reason) { error.textContent = reason.message; error.hidden = false; }
finally { button.disabled = false; }
});
document.querySelector("#logout").addEventListener("click", async () => { try { await api("/api/logout", {method: "POST", body: "{}"}); } finally { showLogin(); } });
document.querySelector("#menu-toggle").addEventListener("click", () => document.querySelector(".sidebar").classList.toggle("open"));
nav.addEventListener("click", event => { const link = event.target.closest("a"); if (link) { event.preventDefault(); navigate(link.pathname); } });
window.addEventListener("popstate", () => navigate(location.pathname, true));
api("/api/session").then(showApp).catch(showLogin);
+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64"><rect width="64" height="64" rx="12" fill="#205c46"/><path fill="#fff" d="M15 16h35v8H25v8h21v8H25v16H15zm31 28h8v8h-8z"/></svg>

After

Width:  |  Height:  |  Size: 189 B

+45
View File
@@ -0,0 +1,45 @@
<!doctype html>
<html lang="de">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="color-scheme" content="light">
<title>Dateiserver-Verwaltung</title>
<link rel="icon" href="/favicon.svg">
<link rel="stylesheet" href="/assets/styles.css">
</head>
<body>
<section id="login-view" class="login-view" hidden>
<main class="login-card">
<h1>Dateiserver-Verwaltung</h1>
<p>Anmeldung nur für Domänenadministratoren. Ohne Domänenangabe wird automatisch die konfigurierte NetBIOS-Domäne verwendet.</p>
<form id="login-form" class="stack">
<label>Benutzername<input name="username" autocomplete="username" required autofocus placeholder="benutzername"></label>
<label>Passwort<input name="password" type="password" autocomplete="current-password" required></label>
<p id="login-error" class="error" role="alert" hidden></p>
<button type="submit">Anmelden</button>
</form>
</main>
</section>
<div id="app-view" class="shell" hidden>
<aside class="sidebar">
<div class="brand">Dateiserver</div>
<nav id="navigation" aria-label="Hauptnavigation">
<a href="/overview" data-route="overview">Übersicht</a>
<a href="/shares" data-route="shares">Dateifreigaben</a>
<a href="/storage/data" data-route="storage-data">Datenbelegung</a>
<a href="/storage/users" data-route="storage-users">Benutzerbelegung</a>
<a href="/activity" data-route="activity">Aktivitätsprotokoll</a>
<a href="/backup" data-route="backup">Sicherungen</a>
<a href="/system" data-route="system">System</a>
</nav>
<div class="sidebar-footer"><span id="session-user"></span><button id="logout" class="link-button">Abmelden</button></div>
</aside>
<header class="mobile-header"><button id="menu-toggle" aria-label="Navigation ein- oder ausblenden">Menü</button><strong>Dateiserver</strong></header>
<main id="content" class="content" tabindex="-1"></main>
</div>
<div id="toast" class="toast" role="status" hidden></div>
<script src="/assets/app.js" defer></script>
</body>
</html>
+94
View File
@@ -0,0 +1,94 @@
:root {
font: 15px/1.4 Arial, Helvetica, sans-serif;
color: #111;
background: #fff;
}
* { box-sizing: border-box; }
[hidden] { display: none !important; }
body { margin: 0; min-height: 100vh; }
a { color: #0645ad; }
button, input, select { font: inherit; }
button { padding: .4rem .7rem; border: 1px solid #777; color: #111; background: #eee; cursor: pointer; }
button:disabled { color: #777; cursor: wait; }
input, select { width: 100%; padding: .4rem; border: 1px solid #999; background: #fff; }
button:focus, input:focus, select:focus, a:focus { outline: 2px solid #0645ad; outline-offset: 1px; }
label { display: grid; gap: .2rem; }
h1, h2, h3, p { margin-top: 0; }
h1 { margin-bottom: .25rem; font-size: 1.6rem; }
h2 { font-size: 1.15rem; }
h3 { font-size: 1rem; }
.muted { color: #666; }
.error { margin: 0; color: #900; }
.stack { display: grid; gap: .8rem; }
.login-view { padding: 2rem; }
.login-card { max-width: 28rem; margin: 4rem auto; }
.shell { min-height: 100vh; }
.sidebar { position: fixed; inset: 0 auto 0 0; display: flex; width: 220px; flex-direction: column; border-right: 1px solid #aaa; background: #eee; }
.brand { padding: 1rem; border-bottom: 1px solid #aaa; font-weight: bold; }
nav { display: grid; padding: .5rem; }
nav a { padding: .45rem .5rem; color: #111; text-decoration: none; }
nav a:hover { text-decoration: underline; }
nav a.active { font-weight: bold; background: #ddd; }
.sidebar-footer { display: grid; gap: .4rem; margin-top: auto; padding: 1rem; border-top: 1px solid #aaa; overflow-wrap: anywhere; }
.link-button { width: fit-content; padding: 0; border: 0; color: #0645ad; background: transparent; text-decoration: underline; }
.content { min-height: 100vh; margin-left: 220px; padding: 1.5rem 2rem 3rem; }
.mobile-header { display: none; }
.page-head { display: flex; align-items: flex-start; justify-content: space-between; gap: 1rem; margin-bottom: 1.2rem; border-bottom: 1px solid #aaa; padding-bottom: .8rem; }
.page-head p { margin: 0; }
.cards { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: .75rem; margin-bottom: 1rem; }
.card, .panel { border: 1px solid #aaa; background: #fff; }
.card { padding: .75rem; }
.card .value { display: block; margin-top: .2rem; font-size: 1.3rem; font-weight: bold; }
.card .label { color: #555; }
.panel { margin-bottom: 1rem; padding: .8rem; overflow: hidden; }
.panel-head { display: flex; align-items: center; justify-content: space-between; gap: 1rem; margin-bottom: .7rem; }
.panel-head h2 { margin: 0; }
.split { display: grid; grid-template-columns: minmax(260px, .7fr) minmax(380px, 1.3fr); gap: 1rem; }
.filters { display: grid; grid-template-columns: repeat(6, minmax(110px, 1fr)); gap: .6rem; margin-bottom: 1rem; align-items: end; }
.filters .wide { grid-column: span 2; }
.table-wrap { width: 100%; overflow-x: auto; }
table { width: 100%; border-collapse: collapse; font-size: .9rem; }
th { text-align: left; }
.timestamp { text-align: left; font-variant-numeric: tabular-nums; white-space: nowrap; }
th, td { padding: .5rem; border-bottom: 1px solid #ccc; vertical-align: top; }
.numeric { text-align: right; font-variant-numeric: tabular-nums; white-space: nowrap; }
.path { max-width: 460px; overflow-wrap: anywhere; font-family: monospace; }
.badge { display: inline-block; font-weight: bold; white-space: nowrap; }
.badge.error { color: #900; }
.badge.warn { color: #750; }
.toolbar { display: flex; flex-wrap: wrap; gap: .5rem; }
.toolbar input { max-width: 340px; }
.list { margin: 0; padding: 0; list-style: none; }
.select-row { width: 100%; display: grid; grid-template-columns: 1fr auto; gap: .5rem; border: 0; border-bottom: 1px solid #ccc; background: #fff; text-align: left; }
.select-row.active { font-weight: bold; background: #eee; }
.select-row span:last-child { color: #555; font-size: .85rem; }
.tree { max-height: 65vh; overflow: auto; }
.tree details { margin-left: 1rem; padding-left: .5rem; border-left: 1px solid #bbb; }
.tree > details { margin-left: 0; border-left: 0; }
.tree summary, .tree .leaf { padding: .2rem 0; }
.tree .leaf { margin-left: 1.5rem; }
.tree .kind { display: inline-block; min-width: 4.5rem; color: #555; }
.empty { padding: 1rem 0; color: #666; }
progress { width: 100%; }
.usage-bar { min-width: 160px; }
.progress-large { margin: .7rem 0; }
.status-line { display: flex; flex-wrap: wrap; align-items: center; gap: .6rem; }
.log { max-height: 370px; margin: 0; padding: .75rem; overflow: auto; border: 1px solid #aaa; background: #f5f5f5; font: .85rem/1.45 monospace; white-space: pre-wrap; overflow-wrap: anywhere; }
.check-list { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: .5rem; }
.check { display: flex; justify-content: space-between; padding: .5rem; border-bottom: 1px solid #ccc; }
.toast { position: fixed; right: 1rem; bottom: 1rem; max-width: 420px; padding: .7rem; border: 1px solid #777; background: #fff; }
.loading { padding: 2rem 0; color: #666; }
@media (max-width: 1000px) {
.cards { grid-template-columns: repeat(2, minmax(0, 1fr)); }
.filters { grid-template-columns: repeat(3, minmax(120px, 1fr)); }
.split { grid-template-columns: 1fr; }
}
@media (max-width: 700px) {
.sidebar { display: none; z-index: 5; }
.sidebar.open { display: flex; }
.mobile-header { display: flex; gap: 1rem; padding: .6rem 1rem; border-bottom: 1px solid #aaa; }
.content { margin-left: 0; padding: 1rem; }
.cards, .filters, .check-list { grid-template-columns: 1fr; }
.filters .wide { grid-column: span 1; }
.page-head { display: block; }
}
+916
View File
@@ -0,0 +1,916 @@
#!/usr/bin/env python3
"""Read-only HTTPS administration UI for the AD-integrated file server."""
import base64
import datetime as dt
import gzip
import hashlib
import hmac
import http.cookies
import json
import os
import pwd
import re
import secrets
import sqlite3
import ssl
import subprocess
import sys
import tempfile
import threading
import time
import urllib.parse
from collections import deque
from http import HTTPStatus
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from typing import Dict, Iterable, List, Optional, Tuple
try:
from app import reconcile_shares as directory
except ImportError: # Container execution uses /app as the import root.
import reconcile_shares as directory
STATE_ROOT = os.getenv("WEB_STATE_DIR", "/state/web")
AUDIT_ROOT = os.getenv("AUDIT_ARCHIVE_DIR", "/state/audit")
STATIC_ROOT = os.getenv("WEB_STATIC_DIR", "/app/web")
SHARE_DB = os.getenv("SHARE_DB_PATH", "/state/shares.db")
BACKUP_STATUS_FILE = os.getenv("BACKUP_STATUS_FILE", "/state/backup-status.json")
BACKUP_LOG_FILE = os.getenv("BACKUP_LOG_FILE", "/var/log/backup.log")
TLS_CERT_FILE = os.getenv("WEB_TLS_CERT_FILE", "/state/tls/web.crt")
TLS_KEY_FILE = os.getenv("WEB_TLS_KEY_FILE", "/state/tls/web.key")
USAGE_CACHE_FILE = os.path.join(STATE_ROOT, "usage.json")
JWT_COOKIE = "adfs_session"
JWT_ISSUER = "ad-file-server-web"
JWT_AUDIENCE = "domain-admins"
DATE_RE = re.compile(r"^\d{4}-\d{2}-\d{2}$")
SID_RE = re.compile(r"S-\d+(?:-\d+)+", re.IGNORECASE)
LOGIN_LIMIT: Dict[str, deque] = {}
LOGIN_LIMIT_LOCK = threading.Lock()
def log(message: str) -> None:
print(f"[web] {message}", flush=True)
def now_utc() -> dt.datetime:
return dt.datetime.now(dt.timezone.utc)
def env_int(name: str, default: int, minimum: int, maximum: int) -> int:
try:
return max(minimum, min(maximum, int(os.getenv(name, str(default)))))
except ValueError:
return default
def atomic_json(path: str, value: object) -> None:
os.makedirs(os.path.dirname(path), exist_ok=True)
temp = f"{path}.tmp"
with open(temp, "w", encoding="utf-8") as handle:
json.dump(value, handle, separators=(",", ":"), sort_keys=True)
handle.flush()
os.fsync(handle.fileno())
os.replace(temp, path)
def read_json(path: str, default):
try:
with open(path, encoding="utf-8") as handle:
return json.load(handle)
except (OSError, ValueError):
return default
def base64url(value: bytes) -> str:
return base64.urlsafe_b64encode(value).rstrip(b"=").decode("ascii")
def base64url_decode(value: str) -> bytes:
return base64.urlsafe_b64decode(value + "=" * (-len(value) % 4))
class TokenManager:
def __init__(self, secret: str, ttl_seconds: int):
if len(secret.encode("utf-8")) < 32:
raise RuntimeError("WEB_JWT_SECRET must contain at least 32 bytes")
self.secret = secret.encode("utf-8")
self.ttl_seconds = ttl_seconds
def issue(self, username: str) -> Tuple[str, int]:
issued = int(time.time())
expires = issued + self.ttl_seconds
header = {"alg": "HS256", "typ": "JWT"}
payload = {
"iss": JWT_ISSUER,
"aud": JWT_AUDIENCE,
"sub": username,
"role": "domain-admin",
"iat": issued,
"exp": expires,
"jti": secrets.token_urlsafe(16),
}
signing_input = ".".join(
[
base64url(json.dumps(header, separators=(",", ":")).encode()),
base64url(json.dumps(payload, separators=(",", ":")).encode()),
]
)
signature = hmac.new(self.secret, signing_input.encode(), hashlib.sha256).digest()
return f"{signing_input}.{base64url(signature)}", expires
def verify(self, token: str) -> Dict[str, object]:
try:
encoded_header, encoded_payload, encoded_signature = token.split(".")
signing_input = f"{encoded_header}.{encoded_payload}"
expected = hmac.new(
self.secret, signing_input.encode(), hashlib.sha256
).digest()
supplied = base64url_decode(encoded_signature)
if not hmac.compare_digest(expected, supplied):
raise ValueError("signature")
header = json.loads(base64url_decode(encoded_header))
payload = json.loads(base64url_decode(encoded_payload))
if header != {"alg": "HS256", "typ": "JWT"}:
raise ValueError("header")
if payload.get("iss") != JWT_ISSUER or payload.get("aud") != JWT_AUDIENCE:
raise ValueError("issuer")
if payload.get("role") != "domain-admin":
raise ValueError("role")
if int(payload.get("exp", 0)) <= int(time.time()):
raise ValueError("expired")
if int(payload.get("iat", 0)) > int(time.time()) + 60:
raise ValueError("issued")
return payload
except (TypeError, ValueError, KeyError, json.JSONDecodeError) as exc:
raise ValueError("Invalid or expired session") from exc
def normalize_username(username: str) -> str:
username = username.strip()
if not username or len(username) > 256 or any(char in username for char in "\r\n\0"):
raise ValueError("Invalid username")
if "\\" not in username and "@" not in username:
username = f"{os.environ['WORKGROUP']}\\{username}"
return username
def authenticate_domain_admin(username: str, password: str) -> Optional[str]:
if not password or len(password) > 4096 or any(char in password for char in "\r\n\0"):
return None
try:
qualified = normalize_username(username)
except ValueError:
return None
workgroup = os.environ["WORKGROUP"]
realm = os.environ["REALM"]
if "\\" in qualified:
domain_name, account = qualified.split("\\", 1)
if domain_name.casefold() != workgroup.casefold():
return None
else:
account, principal_realm = qualified.rsplit("@", 1)
if principal_realm.casefold() != realm.casefold():
return None
if not account:
return None
canonical_name = f"{workgroup}\\{account}"
principal = f"{account}@{realm}"
cache_fd = -1
cache_path = ""
try:
cache_fd, cache_path = tempfile.mkstemp(
prefix="web-auth-", dir=os.getenv("WEB_AUTH_CACHE_DIR", "/tmp")
)
os.close(cache_fd)
cache_fd = -1
command_env = os.environ.copy()
command_env["KRB5CCNAME"] = f"FILE:{cache_path}"
auth_result = subprocess.run(
["kinit", principal],
input=f"{password}\n",
capture_output=True,
text=True,
env=command_env,
timeout=15,
check=False,
)
except (OSError, subprocess.TimeoutExpired):
return None
finally:
if cache_fd >= 0:
os.close(cache_fd)
if cache_path:
try:
os.remove(cache_path)
except OSError:
pass
if auth_result.returncode != 0:
return None
try:
sid_result = subprocess.run(
["wbinfo", "--name-to-sid", canonical_name],
capture_output=True,
text=True,
timeout=15,
check=False,
)
user_sid_match = SID_RE.search(sid_result.stdout)
if sid_result.returncode != 0 or user_sid_match is None:
return None
group_result = subprocess.run(
["wbinfo", "--user-sids", user_sid_match.group(0)],
capture_output=True,
text=True,
timeout=15,
check=False,
)
except (OSError, subprocess.TimeoutExpired):
return None
if group_result.returncode != 0:
return None
admin_sid = os.environ["DOMAIN_ADMINS_SID"].casefold()
group_sids = {value.casefold() for value in SID_RE.findall(group_result.stdout)}
return canonical_name if admin_sid in group_sids else None
def login_allowed(remote: str) -> bool:
now = time.monotonic()
window = 300
limit = env_int("WEB_LOGIN_ATTEMPTS_PER_5_MIN", 10, 3, 100)
with LOGIN_LIMIT_LOCK:
attempts = LOGIN_LIMIT.setdefault(remote, deque())
while attempts and now - attempts[0] > window:
attempts.popleft()
return len(attempts) < limit
def record_login_failure(remote: str) -> None:
with LOGIN_LIMIT_LOCK:
LOGIN_LIMIT.setdefault(remote, deque()).append(time.monotonic())
def clear_login_failures(remote: str) -> None:
with LOGIN_LIMIT_LOCK:
LOGIN_LIMIT.pop(remote, None)
def path_size(path: str) -> int:
total = 0
stack = [path]
while stack:
current = stack.pop()
try:
with os.scandir(current) as entries:
for entry in entries:
try:
if entry.is_symlink():
continue
if entry.is_dir(follow_symlinks=False):
stack.append(entry.path)
elif entry.is_file(follow_symlinks=False):
total += entry.stat(follow_symlinks=False).st_size
except OSError:
continue
except OSError:
continue
return total
def fslogix_username(entry: os.DirEntry) -> str:
try:
owner = pwd.getpwuid(entry.stat(follow_symlinks=False).st_uid).pw_name
owner = owner.split("\\")[-1]
if owner.lower() not in {"root", "nobody"} and not owner.isdigit():
return owner
except (KeyError, OSError):
pass
name = re.sub(r"_S-1-\d+(?:-\d+)+$", "", entry.name, flags=re.IGNORECASE)
return name or entry.name
def scan_children(root: str) -> List[Dict[str, object]]:
rows = []
try:
entries = sorted(os.scandir(root), key=lambda item: item.name.casefold())
except OSError:
return rows
for entry in entries:
try:
if not entry.is_dir(follow_symlinks=False):
continue
rows.append({"name": entry.name, "bytes": path_size(entry.path)})
except OSError:
continue
return rows
class UsageScanner:
def __init__(self):
self.interval = env_int("WEB_USAGE_SCAN_INTERVAL_SECONDS", 900, 60, 86400)
self.lock = threading.Lock()
self.data = read_json(USAGE_CACHE_FILE, {})
self.stop = threading.Event()
def snapshot(self) -> Dict[str, object]:
with self.lock:
return json.loads(json.dumps(self.data))
def scan(self) -> Dict[str, object]:
started = now_utc()
groups = scan_children(os.getenv("GROUP_ROOT", "/data/groups/data"))
private = scan_children(os.getenv("PRIVATE_ROOT", "/data/private"))
fslogix_rows = scan_children(os.getenv("FSLOGIX_ROOT", "/data/fslogix"))
users: Dict[str, Dict[str, object]] = {}
for row in private:
key = str(row["name"]).casefold()
users[key] = {
"name": row["name"], "privateBytes": row["bytes"], "fslogixBytes": 0
}
fslogix_root = os.getenv("FSLOGIX_ROOT", "/data/fslogix")
try:
fs_entries = {entry.name: entry for entry in os.scandir(fslogix_root)}
except OSError:
fs_entries = {}
for row in fslogix_rows:
entry = fs_entries.get(str(row["name"]))
name = fslogix_username(entry) if entry else str(row["name"])
key = name.casefold()
user = users.setdefault(
key, {"name": name, "privateBytes": 0, "fslogixBytes": 0}
)
user["fslogixBytes"] = int(user["fslogixBytes"]) + int(row["bytes"])
user_rows = []
for user in users.values():
user["totalBytes"] = int(user["privateBytes"]) + int(user["fslogixBytes"])
user_rows.append(user)
user_rows.sort(key=lambda row: int(row["totalBytes"]), reverse=True)
groups.sort(key=lambda row: int(row["bytes"]), reverse=True)
value = {
"scannedAt": now_utc().isoformat(timespec="seconds"),
"scanSeconds": round((now_utc() - started).total_seconds(), 3),
"groups": groups,
"users": user_rows,
"totals": {
"dataBytes": sum(int(row["bytes"]) for row in groups),
"privateBytes": sum(int(row["privateBytes"]) for row in user_rows),
"fslogixBytes": sum(int(row["fslogixBytes"]) for row in user_rows),
},
}
atomic_json(USAGE_CACHE_FILE, value)
with self.lock:
self.data = value
return value
def run(self) -> None:
while not self.stop.is_set():
try:
self.scan()
except Exception as exc: # pylint: disable=broad-except
log(f"Usage scan failed: {exc}")
self.stop.wait(self.interval)
def display_name(entry) -> str:
return (
directory.ldap_first(entry, "displayName")
or directory.ldap_first(entry, "sAMAccountName")
or directory.ldap_first(entry, "cn")
or directory.entry_dn(entry).split(",", 1)[0].removeprefix("CN=")
or "Unbekannt"
)
class DirectoryCache:
ATTRS = [
"objectGUID", "distinguishedName", "sAMAccountName", "displayName", "cn",
"objectClass", "member",
]
def __init__(self):
self.ttl = env_int("WEB_DIRECTORY_CACHE_SECONDS", 300, 30, 3600)
self.max_nodes = env_int("WEB_MAX_GROUP_NODES", 10000, 100, 100000)
self.lock = threading.Lock()
self.cached_at = 0.0
self.value: Dict[str, object] = {"groups": [], "fetchedAt": None}
def get(self) -> Dict[str, object]:
with self.lock:
if time.monotonic() - self.cached_at < self.ttl:
return self.value
self.value = self.fetch()
self.cached_at = time.monotonic()
return self.value
def fetch(self) -> Dict[str, object]:
roots = directory.fetch_fileshare_groups()
entries: Dict[str, object] = {}
pending = deque()
for root in roots:
for dn in root.get("memberDns", []):
pending.append(str(dn))
requested = set()
while pending and len(entries) < self.max_nodes:
batch = []
while pending and len(batch) < 100:
dn = pending.popleft()
key = directory.normalize_dn(dn)
if not key or key in requested:
continue
requested.add(key)
batch.append(dn)
if not batch:
continue
for entry in directory.search_directory_entries(
directory.build_distinguished_name_filter(batch), self.ATTRS
):
key = directory.normalize_dn(directory.entry_dn(entry))
if not key:
continue
entries[key] = entry
classes = {value.lower() for value in directory.ldap_values(entry, "objectClass")}
if "group" in classes:
pending.extend(directory.ldap_values(entry, "member"))
folder_map = {}
try:
conn = sqlite3.connect(f"file:{SHARE_DB}?mode=ro", uri=True)
try:
folder_map = {
row[0]: {"folder": os.path.basename(row[1]), "active": bool(row[2])}
for row in conn.execute("SELECT objectGUID, path, isActive FROM shares")
}
finally:
conn.close()
except sqlite3.Error:
pass
def make_node(dn: str, ancestors: set) -> Dict[str, object]:
key = directory.normalize_dn(dn)
entry = entries.get(key)
if entry is None:
return {"id": dn, "name": dn, "sam": "", "type": "unknown", "members": []}
classes = {value.lower() for value in directory.ldap_values(entry, "objectClass")}
node_type = "group" if "group" in classes else "computer" if "computer" in classes else "user"
node = {
"id": directory.entry_dn(entry),
"name": display_name(entry),
"sam": directory.ldap_first(entry, "sAMAccountName") or "",
"type": node_type,
"members": [],
}
if key in ancestors:
node["cycle"] = True
return node
if node_type == "group":
next_ancestors = {*ancestors, key}
node["members"] = [
make_node(child, next_ancestors)
for child in directory.ldap_values(entry, "member")
]
return node
group_rows = []
for root in sorted(roots, key=lambda item: str(item["shareName"]).casefold()):
members = [make_node(str(dn), set()) for dn in root.get("memberDns", [])]
flat_users = set()
flat_groups = set()
def count_nodes(nodes):
for node in nodes:
target = flat_groups if node["type"] == "group" else flat_users if node["type"] == "user" else None
if target is not None:
target.add(str(node.get("sam") or node["id"]).casefold())
count_nodes(node.get("members", []))
count_nodes(members)
folder = folder_map.get(str(root["objectGUID"]), {})
group_rows.append(
{
"guid": root["objectGUID"],
"name": root["shareName"],
"sam": root["samAccountName"],
"folder": folder.get("folder", root["shareName"]),
"active": folder.get("active", True),
"userCount": len(flat_users),
"groupCount": len(flat_groups),
"members": members,
}
)
return {
"groups": group_rows,
"fetchedAt": now_utc().isoformat(timespec="seconds"),
"truncated": bool(pending),
}
def iter_audit_file(path: str) -> Iterable[Dict[str, object]]:
opener = gzip.open if path.endswith(".gz") else open
try:
with opener(path, "rt", encoding="utf-8", errors="replace") as handle:
for line in handle:
try:
value = json.loads(line)
if isinstance(value, dict):
yield value
except json.JSONDecodeError:
continue
except OSError:
return
def iter_audit_file_reverse(path: str) -> Iterable[Dict[str, object]]:
if path.endswith(".gz"):
yield from reversed(list(iter_audit_file(path)))
return
try:
with open(path, "rb") as handle:
position = handle.seek(0, os.SEEK_END)
remainder = b""
while position > 0:
size = min(1024 * 1024, position)
position -= size
handle.seek(position)
parts = (handle.read(size) + remainder).split(b"\n")
remainder = parts[0]
for line in reversed(parts[1:]):
if not line:
continue
try:
value = json.loads(line.decode("utf-8", errors="replace"))
if isinstance(value, dict):
yield value
except (json.JSONDecodeError, UnicodeDecodeError):
continue
if remainder:
try:
value = json.loads(remainder.decode("utf-8", errors="replace"))
if isinstance(value, dict):
yield value
except (json.JSONDecodeError, UnicodeDecodeError):
pass
except OSError:
return
def date_range(start: dt.date, end: dt.date):
day = start
while day <= end:
yield day.isoformat()
day += dt.timedelta(days=1)
def query_audit(params: Dict[str, List[str]]) -> Dict[str, object]:
today = now_utc().date()
default_start = today - dt.timedelta(days=1)
try:
start = dt.date.fromisoformat(params.get("from", [default_start.isoformat()])[0])
end = dt.date.fromisoformat(params.get("to", [today.isoformat()])[0])
except ValueError as exc:
raise ValueError("Datumsangaben müssen YYYY-MM-DD verwenden") from exc
max_days = env_int("AUDIT_QUERY_MAX_DAYS", 31, 1, 366)
if end < start or (end - start).days >= max_days:
raise ValueError(f"Der Datumsbereich darf höchstens {max_days} Tage umfassen")
limit = min(500, max(1, int(params.get("limit", ["100"])[0])))
offset = max(0, int(params.get("cursor", ["0"])[0]))
filters = {
key: params.get(key, [""])[0].casefold().strip()
for key in ("user", "share", "operation", "action", "path", "result")
}
page = []
matched = 0
facets = {"users": set(), "shares": set(), "operations": set(), "actions": set()}
for day in reversed(list(date_range(start, end))):
candidates = [os.path.join(AUDIT_ROOT, f"{day}.jsonl"), os.path.join(AUDIT_ROOT, f"{day}.jsonl.gz")]
for path in candidates:
if not os.path.isfile(path):
continue
for event in iter_audit_file_reverse(path):
facets["users"].add(str(event.get("user", "")))
facets["shares"].add(str(event.get("share", "")))
facets["operations"].add(str(event.get("operation", "")))
facets["actions"].add(str(event.get("action", "")))
failed_filter = filters["result"] == "fail"
if failed_filter and bool(event.get("success", False)):
continue
if (
filters["result"]
and not failed_filter
and filters["result"]
not in str(event.get("result", "")).casefold()
):
continue
if any(
value and value not in str(event.get(key, "")).casefold()
for key, value in filters.items()
if key != "result"
):
continue
if matched >= offset and len(page) < limit:
page.append(event)
matched += 1
page.sort(key=lambda event: str(event.get("timestamp", "")), reverse=True)
next_cursor = offset + limit if offset + limit < matched else None
return {
"events": page,
"nextCursor": next_cursor,
"matched": matched,
"facets": {key: sorted(value, key=str.casefold) for key, value in facets.items()},
}
def tail_lines(path: str, count: int) -> List[str]:
try:
with open(path, "rb") as handle:
handle.seek(0, os.SEEK_END)
position = handle.tell()
data = b""
while position > 0 and data.count(b"\n") <= count:
read_size = min(8192, position)
position -= read_size
handle.seek(position)
data = handle.read(read_size) + data
return data.decode("utf-8", errors="replace").splitlines()[-count:]
except OSError:
return []
def backup_payload() -> Dict[str, object]:
value = read_json(BACKUP_STATUS_FILE, {})
value["enabled"] = bool(os.getenv("BACKUP_DESTINATION", "").strip())
value["scheduledHour"] = env_int("BACKUP_START_HOUR", 2, 0, 23)
value["log"] = tail_lines(BACKUP_LOG_FILE, 100)
return value
def share_count() -> int:
try:
conn = sqlite3.connect(f"file:{SHARE_DB}?mode=ro", uri=True)
try:
return int(conn.execute("SELECT count(*) FROM shares WHERE isActive = 1").fetchone()[0])
finally:
conn.close()
except sqlite3.Error:
return 0
def audit_archive_summary() -> Dict[str, object]:
files = []
total_bytes = 0
try:
names = os.listdir(AUDIT_ROOT)
except OSError:
names = []
for name in names:
if re.match(r"^\d{4}-\d{2}-\d{2}\.jsonl(?:\.gz)?$", name):
path = os.path.join(AUDIT_ROOT, name)
try:
total_bytes += os.path.getsize(path)
files.append(name)
except OSError:
continue
return {"days": len(files), "bytes": total_bytes, "oldest": min(files)[:10] if files else None, "newest": max(files)[:10] if files else None}
def tls_summary() -> Dict[str, object]:
try:
value = ssl._ssl._test_decode_cert(TLS_CERT_FILE) # pylint: disable=protected-access
raw_expiry = value.get("notAfter")
expiry = None
if raw_expiry:
expiry = dt.datetime.fromtimestamp(
ssl.cert_time_to_seconds(raw_expiry), dt.timezone.utc
).isoformat(timespec="seconds")
return {"subject": dict(item[0] for item in value.get("subject", [])), "issuer": dict(item[0] for item in value.get("issuer", [])), "notAfter": expiry, "sans": value.get("subjectAltName", [])}
except (OSError, ValueError, ssl.SSLError):
return {}
class App:
def __init__(self):
secret = os.environ.get("WEB_JWT_SECRET", "")
self.tokens = TokenManager(secret, env_int("WEB_JWT_TTL_SECONDS", 28800, 300, 604800))
self.usage = UsageScanner()
self.directory = DirectoryCache()
threading.Thread(target=self.usage.run, name="usage-scanner", daemon=True).start()
def overview(self) -> Dict[str, object]:
usage = self.usage.snapshot()
recent = query_audit({"limit": ["12"]})
return {"usage": usage, "activeGroups": share_count(), "recentEvents": recent["events"], "eventCount": recent["matched"], "backup": backup_payload(), "audit": audit_archive_summary()}
def system(self) -> Dict[str, object]:
checks = {}
for name, command in {"domainTrust": ["wbinfo", "-t"], "sambaConfig": ["testparm", "-s"]}.items():
try:
result = subprocess.run(command, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, timeout=10, check=False)
checks[name] = result.returncode == 0
except (OSError, subprocess.TimeoutExpired):
checks[name] = False
return {"hostname": os.getenv("WEB_HOSTNAME", ""), "checks": checks, "tls": tls_summary(), "audit": audit_archive_summary(), "usage": self.usage.snapshot(), "serverTime": now_utc().isoformat(timespec="seconds")}
APP: Optional[App] = None
class Handler(BaseHTTPRequestHandler):
server_version = "ADFileServerUI/1"
def log_message(self, fmt: str, *args) -> None:
log(f"{self.client_address[0]} {fmt % args}")
def security_headers(self) -> None:
self.send_header("Strict-Transport-Security", "max-age=31536000; includeSubDomains")
self.send_header("Content-Security-Policy", "default-src 'self'; connect-src 'self'; img-src 'self' data:; style-src 'self'; script-src 'self'; base-uri 'none'; frame-ancestors 'none'; form-action 'self'")
self.send_header("X-Content-Type-Options", "nosniff")
self.send_header("Referrer-Policy", "no-referrer")
self.send_header("Permissions-Policy", "camera=(), microphone=(), geolocation=()")
self.send_header("Cache-Control", "no-store")
def send_json(self, value: object, status: int = 200, cookie: Optional[str] = None) -> None:
body = json.dumps(value, separators=(",", ":")).encode()
self.send_response(status)
self.security_headers()
self.send_header("Content-Type", "application/json; charset=utf-8")
self.send_header("Content-Length", str(len(body)))
if cookie:
self.send_header("Set-Cookie", cookie)
self.end_headers()
self.wfile.write(body)
def send_error_json(self, status: int, message: str) -> None:
self.send_json({"error": message}, status)
def token(self) -> Optional[str]:
authorization = self.headers.get("Authorization", "")
if authorization.startswith("Bearer "):
return authorization[7:].strip()
cookie = http.cookies.SimpleCookie(self.headers.get("Cookie", ""))
morsel = cookie.get(JWT_COOKIE)
return morsel.value if morsel else None
def user(self) -> Optional[Dict[str, object]]:
token = self.token()
if not token:
return None
try:
return APP.tokens.verify(token) if APP else None
except ValueError:
return None
def require_user(self) -> Optional[Dict[str, object]]:
value = self.user()
if value is None:
self.send_error_json(HTTPStatus.UNAUTHORIZED, "Anmeldung erforderlich")
return value
def read_json_body(self) -> Dict[str, object]:
try:
length = int(self.headers.get("Content-Length", "0"))
except ValueError as exc:
raise ValueError("Ungültige Anfragelänge") from exc
if length <= 0 or length > 16384:
raise ValueError("Ungültiger Anfrageinhalt")
try:
value = json.loads(self.rfile.read(length))
except json.JSONDecodeError as exc:
raise ValueError("Ungültiges JSON") from exc
if not isinstance(value, dict):
raise ValueError("Ein JSON-Objekt ist erforderlich")
return value
def do_POST(self) -> None: # pylint: disable=invalid-name
parsed = urllib.parse.urlparse(self.path)
if parsed.path == "/api/login":
remote = self.client_address[0]
if not login_allowed(remote):
self.send_error_json(HTTPStatus.TOO_MANY_REQUESTS, "Zu viele Anmeldeversuche; bitte später erneut versuchen")
return
try:
body = self.read_json_body()
except ValueError as exc:
self.send_error_json(HTTPStatus.BAD_REQUEST, str(exc))
return
username = authenticate_domain_admin(str(body.get("username", "")), str(body.get("password", "")))
if username is None:
record_login_failure(remote)
time.sleep(0.4)
self.send_error_json(HTTPStatus.UNAUTHORIZED, "Ungültige Zugangsdaten oder keine Mitgliedschaft bei den Domänenadministratoren")
return
clear_login_failures(remote)
token, expires = APP.tokens.issue(username)
max_age = max(0, expires - int(time.time()))
cookie = f"{JWT_COOKIE}={token}; Path=/; Max-Age={max_age}; HttpOnly; Secure; SameSite=Strict"
self.send_json({"user": username, "expiresAt": expires, "token": token, "tokenType": "Bearer"}, cookie=cookie)
return
if parsed.path == "/api/logout":
cookie = f"{JWT_COOKIE}=; Path=/; Max-Age=0; HttpOnly; Secure; SameSite=Strict"
self.send_json({"ok": True}, cookie=cookie)
return
self.send_error_json(HTTPStatus.NOT_FOUND, "Nicht gefunden")
def do_GET(self) -> None: # pylint: disable=invalid-name,too-many-return-statements
parsed = urllib.parse.urlparse(self.path)
path = parsed.path
if path == "/healthz":
self.send_json({"status": "ok"})
return
if path.startswith("/api/"):
user = self.require_user()
if user is None:
return
params = urllib.parse.parse_qs(parsed.query)
try:
if path == "/api/session":
self.send_json({"user": user["sub"], "expiresAt": user["exp"]})
elif path == "/api/overview":
self.send_json(APP.overview())
elif path == "/api/groups":
self.send_json(APP.directory.get())
elif path == "/api/storage":
self.send_json(APP.usage.snapshot())
elif path == "/api/activity":
self.send_json(query_audit(params))
elif path == "/api/backup":
self.send_json(backup_payload())
elif path == "/api/system":
self.send_json(APP.system())
else:
self.send_error_json(HTTPStatus.NOT_FOUND, "Nicht gefunden")
except (ValueError, OSError, RuntimeError) as exc:
log(f"Request {path} failed: {exc}")
self.send_error_json(HTTPStatus.BAD_REQUEST, str(exc))
return
self.serve_static(path)
def serve_static(self, path: str) -> None:
files = {
"/assets/app.js": ("app.js", "text/javascript; charset=utf-8"),
"/assets/styles.css": ("styles.css", "text/css; charset=utf-8"),
"/favicon.svg": ("favicon.svg", "image/svg+xml"),
}
if path in files:
filename, content_type = files[path]
else:
filename, content_type = "index.html", "text/html; charset=utf-8"
try:
with open(os.path.join(STATIC_ROOT, filename), "rb") as handle:
body = handle.read()
except OSError:
self.send_error_json(HTTPStatus.NOT_FOUND, "Statische Datei nicht gefunden")
return
self.send_response(HTTPStatus.OK)
self.security_headers()
self.send_header("Content-Type", content_type)
self.send_header("Content-Length", str(len(body)))
self.end_headers()
self.wfile.write(body)
class ReusableHTTPServer(ThreadingHTTPServer):
allow_reuse_address = True
daemon_threads = True
def serve_https() -> None:
address = os.getenv("WEB_BIND_ADDRESS", "0.0.0.0")
port = env_int("WEB_BIND_PORT", 8443, 1, 65535)
cert_mtime = -1.0
log(f"Serving https://{os.getenv('WEB_HOSTNAME', address)}:{port}")
while True:
context = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER)
context.minimum_version = ssl.TLSVersion.TLSv1_2
context.load_cert_chain(TLS_CERT_FILE, TLS_KEY_FILE)
server = ReusableHTTPServer((address, port), Handler)
server.timeout = 1
server.socket = context.wrap_socket(server.socket, server_side=True)
cert_mtime = os.path.getmtime(TLS_CERT_FILE)
try:
while os.path.getmtime(TLS_CERT_FILE) == cert_mtime:
server.handle_request()
finally:
server.server_close()
log("TLS certificate changed; reloading HTTPS listener")
def main() -> int:
global APP
os.makedirs(STATE_ROOT, mode=0o750, exist_ok=True)
if not os.path.isfile(TLS_CERT_FILE) or not os.path.isfile(TLS_KEY_FILE):
raise RuntimeError("TLS certificate or key is missing")
APP = App()
serve_https()
return 0
if __name__ == "__main__":
try:
sys.exit(main())
except KeyboardInterrupt:
sys.exit(0)
except Exception as exc: # pylint: disable=broad-except
log(f"ERROR: {exc}")
sys.exit(1)