webui
This commit is contained in:
@@ -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())
|
||||
@@ -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
@@ -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
@@ -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 = '<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);
|
||||
@@ -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 |
@@ -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>
|
||||
@@ -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
@@ -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)
|
||||
Reference in New Issue
Block a user