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
+6
View File
@@ -0,0 +1,6 @@
.git
.env
**/__pycache__
**/*.py[cod]
*.orig
*.rej
+23 -1
View File
@@ -11,6 +11,25 @@ FSLOGIX_GROUP_SID=S-1-5-21-1111111111-2222222222-3333333333-513
# AD_DNS_IP_AUTO=1
# SAMBA_HOSTNAME=adsambafsrv
# NETBIOS_NAME=ADSAMBAFSRV
WEB_ENABLED=true
WEB_HOSTNAME=files.example.com
WEB_HTTPS_PORT=443
WEB_JWT_SECRET=ReplaceWithAtLeast32RandomBytes
WEB_TLS_MODE=step
STEP_CA_URL=https://ca.example.com:9000
STEP_CA_FINGERPRINT=ReplaceWithStepRootFingerprint
STEP_CA_PROVISIONER=fileserver
STEP_CA_PROVISIONER_PASSWORD=ReplaceWithProvisionerPassword
# STEP_CA_TOKEN=single-use-bootstrap-token
# STEP_CA_PROVISIONER_PASSWORD_FILE=/run/secrets/step-ca-provisioner-password
# STEP_CA_REBOOTSTRAP=false
# WEB_TLS_CERT_FILE=/state/tls/web.crt
# WEB_TLS_KEY_FILE=/state/tls/web.key
# WEB_JWT_TTL_SECONDS=28800
# WEB_USAGE_SCAN_INTERVAL_SECONDS=900
# WEB_DIRECTORY_CACHE_SECONDS=300
# WEB_MAX_GROUP_NODES=10000
# WEB_LOGIN_ATTEMPTS_PER_5_MIN=10
# LDAP_URI=ldaps://example.com
# LDAP_BASE_DN=DC=example,DC=com
# PRIVATE_SKIP_USERS=svc_backup,svc_sql
@@ -19,7 +38,7 @@ FSLOGIX_GROUP_SID=S-1-5-21-1111111111-2222222222-3333333333-513
# BACKUP_DESTINATION=smb://DOMAIN%5Cuser:pass@backup.example.com/Backups/samba
# BACKUP_DESTINATION=davfs://user:pass@webdav.example.com/remote.php/dav/files/backup
# BACKUP_DESTINATION=sftp://user:pass@sftp.example.com/exports/samba
# BACKUP_START_HOUR=2
# BACKUP_START_HOUR=2 # 0-23, UTC
# BACKUP_RETENTION_DAILY=3
# BACKUP_RETENTION_WEEKLY=2
# BACKUP_RETENTION_MONTHLY=2
@@ -27,3 +46,6 @@ FSLOGIX_GROUP_SID=S-1-5-21-1111111111-2222222222-3333333333-513
# BACKUP_LOG_FILE=/var/log/backup.log
# BACKUP_PROGRESS=auto
# BACKUP_PROGRESS_INTERVAL_SECONDS=10
# BACKUP_STATUS_FILE=/state/backup-status.json
# AUDIT_COMPRESS_AFTER_HOURS=24
# AUDIT_QUERY_MAX_DAYS=31
+8 -1
View File
@@ -1,6 +1,8 @@
FROM smallstep/step-cli:0.30.2 AS step-cli
FROM debian:12-slim
ENV DEBIAN_FRONTEND=noninteractive
ENV DEBIAN_FRONTEND=noninteractive TZ=Etc/UTC
RUN apt-get update \
&& apt-get install -y --no-install-recommends \
@@ -22,13 +24,18 @@ RUN apt-get update \
&& rm -rf /var/lib/apt/lists/*
RUN mkdir -p /app /data/private /data/fslogix /data/groups/data /data/groups/archive /state
COPY --from=step-cli /usr/local/bin/step /usr/local/bin/step
COPY app/reconcile_shares.py /app/reconcile_shares.py
COPY app/backup_to_destination.py /app/backup_to_destination.py
COPY app/audit_collector.py /app/audit_collector.py
COPY app/web_ui.py /app/web_ui.py
COPY app/web /app/web
COPY app/init.sh /app/init.sh
COPY etc/samba/smb.conf /app/smb.conf.template
RUN chmod +x /app/init.sh /app/reconcile_shares.py /app/backup_to_destination.py \
/app/audit_collector.py /app/web_ui.py \
&& true
ENTRYPOINT ["/usr/bin/tini", "--"]
+177 -3
View File
@@ -22,14 +22,18 @@ This repository provides a production-oriented Samba file server container that
- Setup prompts for well-known authorization groups by SID (`DOMAIN_USERS_SID`, `DOMAIN_ADMINS_SID`) to avoid localized group names.
- `FSLOGIX_GROUP_SID` controls who can access the default FSLogix share (defaults to `DOMAIN_USERS_SID`).
- Startup resolves those SIDs to NSS group names via winbind, then uses those resolved groups in Samba `valid users` rules.
- Share operations are audited with Samba `full_audit` (connect, list, read, write, create, delete, rename) and written to Samba log files.
- Share operations are audited with Samba `full_audit` (connect, list, read, write, create, delete, rename).
- A collector persists every `full_audit` record in daily NDJSON files under `/state/audit`; closed files are gzip-compressed and are never automatically deleted.
- A read-only HTTPS web console provides group membership trees, storage usage, searchable activity, live backup progress, and system health.
- Web sign-in validates the submitted username/password with Kerberos, permits only users whose winbind group SID set contains `DOMAIN_ADMINS_SID`, and issues an expiring JWT in a Secure, HttpOnly, SameSite=Strict cookie. The browser does not use NTLM/SPNEGO or Kerberos negotiation.
- HTTPS certificates are requested from a configured local Smallstep CA and renewed automatically. Pre-issued certificate files are also supported.
- Optional remote backups run when `BACKUP_DESTINATION` is configured.
- Private home creation skips well-known/service accounts by default (including `krbtgt`, `msol_*`, `FileShare_ServiceAcc`).
- Reconciliation is executed:
- once on startup
- every 5 minutes via cron
- Backup is executed:
- daily at `BACKUP_START_HOUR` (default: `2`, i.e. 02:00)
- daily at `BACKUP_START_HOUR` in UTC (default: `2`, i.e. 02:00 UTC)
## Data Folder Lifecycle
@@ -95,7 +99,86 @@ Kerberos requires close time alignment.
- `app/init.sh`
- `app/reconcile_shares.py`
- `app/backup_to_destination.py`
- `app/audit_collector.py`
- `app/web_ui.py`
- `app/web/`
- `etc/samba/smb.conf`
- `dev/` (disposable AD DC, backup target, SMB client, seed data, and E2E assertions)
- `scripts/dev`
- `scripts/test-e2e`
## Local Preview
Run a complete disposable environment with Docker or Podman:
```bash
./scripts/dev
```
The launcher builds the current application and starts an isolated, run-scoped network containing:
- a real Samba AD DC for `DEV.TEST`, seeded with users, nested groups, and three `FS_*` groups;
- a real Smallstep CA that issues the web UI certificate for `localhost`;
- an authenticated rsync daemon used by the normal backup implementation;
- the actual file-server image, joined to the dummy domain;
- a continuous SMB client that reads, writes, and lists files as several domain users.
The preview starts with group, Private, FSLogix, and historical audit data. The client keeps current-day audit activity moving, while a real backup runs immediately and repeats in the background. Open the URL and use the credentials printed by the launcher. Defaults are:
```text
URL: https://localhost:8443
Username: DEV\previewadmin
Password: PreviewAdmin123!
```
The generated CA is intentionally disposable. `scripts/dev` prints the temporary root certificate path so it can be trusted only for the duration of that run. Ctrl-C stops and removes all run-scoped containers, volumes, the network, and the temporary root. A watchdog performs the same cleanup if the parent script is killed.
The dummy DC alone receives `SYS_ADMIN`, which Samba needs to write Windows ACL xattrs while provisioning `SYSVOL`; the application container receives no extra capability.
Useful overrides:
| Variable | Default | Purpose |
| --- | --- | --- |
| `DEV_HTTPS_PORT` | `8443` | HTTPS port bound to host loopback |
| `DEV_SKIP_BUILD` | `0` | Set to `1` to reuse already-built local images |
| `DEV_STEP_CA_IMAGE` | `docker.io/smallstep/step-ca:latest` | CA image; pin a tag or digest for reproducible CI |
| `DEV_SERVER_IMAGE` | `ad-ds-simple-file-server-dev:latest` | Local file-server image name |
| `DEV_AD_IMAGE` | `ad-ds-simple-file-server-ad-dev:latest` | Local AD/client image name |
| `DEV_BACKUP_IMAGE` | `ad-ds-simple-file-server-backup-dev:latest` | Local rsync target image name |
| `DEV_REALM` | `DEV.TEST` | Dummy Kerberos realm |
| `DEV_WORKGROUP` | `DEV` | Dummy NetBIOS domain |
| `DEV_DNS_DOMAIN` | `dev.test` | Dummy AD DNS zone |
| `DEV_BASE_DN` | `DC=dev,DC=test` | Dummy directory base DN |
| `DEV_ADMIN_USER` | `previewadmin` | Seeded Domain Admin login |
| `DEV_ADMIN_PASSWORD` | `PreviewAdmin123!` | Seeded Domain Admin password |
| `DEV_USER_PASSWORD` | `PreviewUser123!` | Shared password for seeded non-admin users |
| `DEV_SEED_MB` | `8` | MiB per large seed file |
| `DEV_ACTIVITY_INTERVAL_SECONDS` | `4` | Delay between SMB activity batches |
| `DEV_BACKUP_INTERVAL_SECONDS` | `120` | Delay between completed preview backups |
`DEV_REALM`, `DEV_DNS_DOMAIN`, and `DEV_BASE_DN` describe the same domain and must be changed together.
## End-to-End Tests
Run the same disposable stack headlessly with extensive assertions:
```bash
./scripts/test-e2e
```
The E2E suite verifies:
- CA-issued TLS, hostname validation, HSTS, and CSP;
- anonymous rejection, bad credentials, valid non-admin rejection, real Domain Admin login, JWT claims, bearer use, cookie flags, tamper rejection, and logout;
- domain trust, group-to-folder mapping, nested and transitive group trees;
- SMB allow/deny behavior and real file operations;
- Data, Private, and FSLogix usage aggregation;
- live `full_audit` ingestion, filters, facets, and pagination;
- compression and querying of a closed daily audit log;
- real rsync transfer progress, completed backup status, log output, and remote snapshot marker;
- overview and system-health aggregation.
The runner returns non-zero on the first failed assertion, prints bounded logs from every run-scoped service, and always removes its containers, volumes, network, and temporary CA root. Set `DEV_SKIP_BUILD=1` for a fast rerun against existing local images.
## Setup
@@ -124,6 +207,14 @@ Kerberos requires close time alignment.
- optional `BACKUP_LOG_FILE` (default `/var/log/backup.log`)
- optional `BACKUP_PROGRESS` (`auto`, `always`, or `never`; default `auto`)
- optional `BACKUP_PROGRESS_INTERVAL_SECONDS` (default `10`)
- `WEB_HOSTNAME` (the DNS name in the HTTPS certificate)
- optional `WEB_HTTPS_PORT` (host port, default `443`)
- `STEP_CA_URL`
- `STEP_CA_FINGERPRINT`
- `STEP_CA_PROVISIONER`
- `STEP_CA_PROVISIONER_PASSWORD`
Setup generates a random `WEB_JWT_SECRET`. A one-time `STEP_CA_TOKEN` or a provisioner password file can be configured manually instead of keeping a provisioner password in `.env`.
Optional:
- `SAMBA_HOSTNAME` (defaults to `adsambafsrv`)
@@ -185,11 +276,90 @@ Kerberos requires close time alignment.
- Semantics intentionally differ from `Data`: only the share root is reconciled (`03770` + ACL defaults), while user-created profile container folders/files are not recursively normalized.
- Samba masks are profile-container oriented (`create mask = 0600`, `directory mask = 0700`) so profile payload stays user-private by default.
## Read-only Web Console
Open `https://<WEB_HOSTNAME>/` after setup. Only members of the group identified by `DOMAIN_ADMINS_SID` can sign in. The form accepts `DOMAIN\username`, `username@realm`, or an unqualified username (which is qualified with `WORKGROUP`).
The console is intentionally operational and plain:
- **Overview**: current capacity totals, active group count, recent activity, and backup state.
- **File shares**: one selectable tree per active `FS_*` group/folder with recursively expanded user and nested-group membership, cycle markers, and a live filter.
- **Storage / Data groups**: cached recursive size of every top-level `/Data` group folder.
- **Storage / Users**: per-user `/Private + /FSLogix` totals with component sizes.
- **Activity logs**: dynamic date, user, share, action, operation, result, and path filters with pagination.
- **Backups**: read-only live progress, active transfer rows, snapshot name, and recent backup output.
- **System**: domain trust, Samba configuration, TLS certificate, scanner, and audit archive health.
The web API has no mutation endpoint other than session login/logout. Files, groups, ACLs, backup schedules, and retention cannot be changed from the console.
### Authentication and sessions
- Credentials are submitted only over HTTPS. The password is passed to `kinit` through stdin, is never placed in a process argument, and the temporary Kerberos credential cache is immediately removed.
- After Kerberos succeeds, the service resolves the account SID and its complete group SID set through winbind. Login succeeds only when that set contains `DOMAIN_ADMINS_SID`.
- Authentication is form-based; the browser never performs NTLM, SPNEGO, or Kerberos negotiation.
- JWTs use HMAC-SHA256, default to eight hours, and are accepted from the protected cookie or an `Authorization: Bearer` header.
- Login attempts are rate-limited per client address.
- Responses set HSTS, a restrictive Content Security Policy, clickjacking protection, MIME sniffing protection, and no-store caching.
### TLS with a local Smallstep CA
Default enrollment uses `WEB_TLS_MODE=step`:
```env
WEB_ENABLED=true
WEB_HOSTNAME=files.example.com
WEB_HTTPS_PORT=443
WEB_JWT_SECRET=<at-least-32-random-bytes>
WEB_TLS_MODE=step
STEP_CA_URL=https://ca.example.com:9000
STEP_CA_FINGERPRINT=<root-certificate-fingerprint>
STEP_CA_PROVISIONER=fileserver
STEP_CA_PROVISIONER_PASSWORD=<provisioner-password>
```
At first startup the container bootstraps the CA root, requests a certificate for `WEB_HOSTNAME`, and stores its certificate, key, and Step client state on the persistent `state_data` volume. The `step ca renew --daemon` process renews the certificate; the HTTPS listener notices the certificate file change and reloads it.
For secret-file based enrollment, set `STEP_CA_PROVISIONER_PASSWORD_FILE` to a mounted file. A single-use `STEP_CA_TOKEN` is also supported. To use externally managed files instead:
```env
WEB_TLS_MODE=files
WEB_TLS_CERT_FILE=/state/tls/web.crt
WEB_TLS_KEY_FILE=/state/tls/web.key
```
Smallstep trust configuration is reused from the state volume on normal restarts, so a temporary CA outage does not stop Samba or an already-certificate-equipped web service. Set `STEP_CA_REBOOTSTRAP=true` only when intentionally replacing the configured CA trust.
Useful optional settings:
| Variable | Default | Purpose |
| --- | ---: | --- |
| `WEB_JWT_TTL_SECONDS` | `28800` | Session lifetime, 5 minutes to 7 days |
| `WEB_LOGIN_ATTEMPTS_PER_5_MIN` | `10` | Per-address login attempt limit |
| `WEB_USAGE_SCAN_INTERVAL_SECONDS` | `900` | Recursive storage scan interval |
| `WEB_DIRECTORY_CACHE_SECONDS` | `300` | AD membership tree cache time |
| `WEB_MAX_GROUP_NODES` | `10000` | Membership expansion safety limit |
| `AUDIT_COMPRESS_AFTER_HOURS` | `24` | Minimum idle age before compressing a closed day |
| `AUDIT_QUERY_MAX_DAYS` | `31` | Largest activity query window |
If `WEB_ENABLED` is absent on an upgraded installation and no TLS settings/certificate exist, the web service stays disabled while Samba and audit collection continue. Set `WEB_ENABLED=true` after adding TLS configuration.
## Audit Archive
Samba already produced `full_audit` messages before this web UI was added. The collector now makes them durable:
- it tails every `/var/log/samba/log.*` source, remembers inode and byte offsets in `/state/audit/collector-state.json`, and follows Samba rotation without duplicating a rotated file;
- each event records timestamp, user, client address/name, share, VFS operation, read/write/list/session category, result, and path;
- records are appended to `/state/audit/YYYY-MM-DD.jsonl`;
- a closed, idle daily file becomes `.jsonl.gz`;
- no audit retention deletion is performed, so capacity planning for the `state_data` volume is the operator's responsibility.
Collection starts even when the web UI is disabled. Existing Samba log content is imported when the collector first starts, but audit data that Samba rotated away before this version was deployed cannot be recovered.
## Backups
- Backups are enabled only if `BACKUP_DESTINATION` is non-empty.
- Each run creates a timestamped snapshot under `snapshots/YYYYMMDDTHHMMSSZ` at the destination.
- Backup job is scheduled daily at `BACKUP_START_HOUR` in container local time.
- Backup job is scheduled daily at `BACKUP_START_HOUR` in UTC. The container and Compose service force `TZ=Etc/UTC`.
- Sources synced to destination on each run:
- `/data/private` -> `data/private`
- `/data/groups` -> `data/groups`
@@ -205,6 +375,7 @@ Kerberos requires close time alignment.
- Before uploading, the backup script measures all source files so it can report total upload progress.
- Upload progress is logged per file every `BACKUP_PROGRESS_INTERVAL_SECONDS` seconds and again when a file reaches 100%, including percentage and transferred/remaining bytes with auto-scaled units.
- `BACKUP_PROGRESS=auto` shows an interactive multi-line progress view only for TTY/manual runs. Current file uploads are shown as separate rows, capped at 12 rows, with the total progress row at the bottom. Use `always` to force it or `never` to suppress the bar. File and total progress are still logged.
- Every run atomically updates `BACKUP_STATUS_FILE` (default `/state/backup-status.json`) with its state, current source, active files, byte totals, percentage, snapshot, and final result for the live web view.
- Rclone-backed destinations cap concurrent file transfers at 12. Rsync remains single-streamed by rsync itself.
- Retention logic:
- daily: newest N snapshots
@@ -237,11 +408,14 @@ Kerberos requires close time alignment.
```bash
docker compose logs -f samba
docker compose logs -f samba | grep -E '\\[web\\]|\\[audit\\]'
docker compose exec samba python3 /app/reconcile_shares.py
docker compose exec samba sqlite3 /state/shares.db 'SELECT * FROM shares;'
docker compose exec samba testparm -s
docker compose exec samba sh -lc 'tail -n 200 /var/log/samba/log.*'
docker compose exec samba sh -lc 'tail -n 200 /var/log/backup.log'
docker compose exec samba sh -lc 'ls -lh /state/audit'
docker compose exec samba python3 -m json.tool /state/backup-status.json
```
## Troubleshooting
+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)
+28
View File
@@ -0,0 +1,28 @@
FROM debian:12-slim
ENV DEBIAN_FRONTEND=noninteractive
RUN apt-get update \
&& apt-get install -y --no-install-recommends \
ca-certificates \
dnsutils \
krb5-user \
ldb-tools \
samba \
samba-ad-dc \
samba-ad-provision \
smbclient \
tini \
&& rm -rf /var/lib/apt/lists/*
COPY dev/ad-entrypoint.sh /usr/local/bin/preview-ad-entrypoint
COPY dev/preview-client.sh /usr/local/bin/preview-client
COPY dev/seed-files.sh /usr/local/bin/preview-seed-files
RUN chmod +x \
/usr/local/bin/preview-ad-entrypoint \
/usr/local/bin/preview-client \
/usr/local/bin/preview-seed-files
ENTRYPOINT ["/usr/bin/tini", "--"]
CMD ["/usr/local/bin/preview-ad-entrypoint"]
+141
View File
@@ -0,0 +1,141 @@
#!/usr/bin/env bash
set -Eeuo pipefail
log() {
printf '[preview-ad] %s\n' "$*"
}
require_env() {
local name=$1
if [[ -z ${!name:-} ]]; then
printf '[preview-ad] ERROR: missing %s\n' "$name" >&2
exit 1
fi
}
for name in AD_REALM AD_DOMAIN AD_DNS_DOMAIN AD_BASE_DN AD_ADMIN_PASSWORD \
AD_USER_PASSWORD AD_WEB_ADMIN_USER AD_WEB_ADMIN_PASSWORD \
DEV_CA_IP DEV_BACKUP_IP DEV_FILESERVER_IP; do
require_env "$name"
done
if [[ ! -s /var/lib/samba/private/sam.ldb ]]; then
log "Provisioning disposable ${AD_REALM} domain"
rm -f /etc/samba/smb.conf
samba-tool domain provision \
--server-role=dc \
--use-rfc2307 \
--dns-backend=SAMBA_INTERNAL \
--realm="$AD_REALM" \
--domain="$AD_DOMAIN" \
--adminpass="$AD_ADMIN_PASSWORD"
fi
ln -sf /var/lib/samba/private/krb5.conf /etc/krb5.conf
log 'Starting Samba AD DC'
samba -i --no-process-group &
samba_pid=$!
stop_samba() {
kill "$samba_pid" >/dev/null 2>&1 || true
wait "$samba_pid" 2>/dev/null || true
}
trap stop_samba EXIT INT TERM HUP
ready=0
for _ in $(seq 1 90); do
if samba-tool domain info 127.0.0.1 >/dev/null 2>&1; then
ready=1
break
fi
sleep 1
done
if [[ $ready != 1 ]]; then
printf '[preview-ad] ERROR: domain controller did not become ready\n' >&2
exit 1
fi
ensure_user() {
local user=$1
local password=$2
local given=$3
local surname=$4
if ! samba-tool user show "$user" >/dev/null 2>&1; then
samba-tool user create "$user" "$password" \
--given-name="$given" --surname="$surname" >/dev/null
fi
}
ensure_group() {
local group=$1
if ! samba-tool group show "$group" >/dev/null 2>&1; then
samba-tool group add "$group" >/dev/null
fi
}
add_members() {
local group=$1
local members=$2
samba-tool group addmembers "$group" "$members" >/dev/null 2>&1 || true
}
set_display_name() {
local group=$1
local display_name=$2
ldbmodify -H /var/lib/samba/private/sam.ldb >/dev/null <<EOF
dn: CN=${group},CN=Users,${AD_BASE_DN}
changetype: modify
replace: displayName
displayName: ${display_name}
EOF
}
log 'Seeding users and nested file-share groups'
ensure_user alice "$AD_USER_PASSWORD" Alice Adams
ensure_user bob "$AD_USER_PASSWORD" Bob Brown
ensure_user carol "$AD_USER_PASSWORD" Carol Clark
ensure_user dave "$AD_USER_PASSWORD" Dave Davis
ensure_user eve "$AD_USER_PASSWORD" Eve Evans
ensure_user "$AD_WEB_ADMIN_USER" "$AD_WEB_ADMIN_PASSWORD" Preview Administrator
for group in Finance_Analysts Engineering_Leads FS_Finance FS_Engineering FS_Projects; do
ensure_group "$group"
done
set_display_name Finance_Analysts 'Finance Analysts'
set_display_name Engineering_Leads 'Engineering Leads'
set_display_name FS_Finance Finance
set_display_name FS_Engineering Engineering
set_display_name FS_Projects Projects
add_members Finance_Analysts alice
add_members FS_Finance 'Finance_Analysts,bob'
add_members Engineering_Leads carol
add_members FS_Engineering 'Engineering_Leads,dave'
add_members FS_Projects 'FS_Finance,FS_Engineering,eve'
add_members 'Domain Admins' "$AD_WEB_ADMIN_USER"
add_dns_record() {
local name=$1
local address=$2
samba-tool dns add 127.0.0.1 "$AD_DNS_DOMAIN" "$name" A "$address" \
-U "Administrator%${AD_ADMIN_PASSWORD}" >/dev/null 2>&1 || true
}
add_dns_record ca "$DEV_CA_IP"
add_dns_record backup "$DEV_BACKUP_IP"
add_dns_record files "$DEV_FILESERVER_IP"
domain_sid=$(ldbsearch -H /var/lib/samba/private/sam.ldb \
-b "$AD_BASE_DN" -s base objectSid 2>/dev/null \
| sed -n 's/^objectSid: //p' | head -n1)
if [[ -z $domain_sid ]]; then
printf '[preview-ad] ERROR: unable to determine domain SID\n' >&2
exit 1
fi
printf '%s\n' "$domain_sid" > /run/domain-sid
touch /run/preview-ready
log "Ready: ${AD_DOMAIN} (${domain_sid})"
wait "$samba_pid"
+32
View File
@@ -0,0 +1,32 @@
#!/usr/bin/env bash
set -Eeuo pipefail
: "${BACKUP_USERNAME:?missing BACKUP_USERNAME}"
: "${BACKUP_PASSWORD:?missing BACKUP_PASSWORD}"
backup_base_dir=${BACKUP_BASE_DIR:-fileserver}
mkdir -p "/backup/${backup_base_dir}/snapshots"
cat > /etc/rsyncd.conf <<EOF
uid = root
gid = root
use chroot = no
max connections = 8
pid file = /run/rsyncd.pid
lock file = /run/rsyncd.lock
log file = /dev/stdout
timeout = 300
[backups]
path = /backup
read only = false
list = true
auth users = ${BACKUP_USERNAME}
secrets file = /etc/rsyncd.secrets
EOF
printf '%s:%s\n' "$BACKUP_USERNAME" "$BACKUP_PASSWORD" > /etc/rsyncd.secrets
chmod 0600 /etc/rsyncd.secrets
printf '[preview-backup] rsync://0.0.0.0/backups ready\n'
exec rsync --daemon --no-detach --config=/etc/rsyncd.conf
+13
View File
@@ -0,0 +1,13 @@
FROM debian:12-slim
ENV DEBIAN_FRONTEND=noninteractive
RUN apt-get update \
&& apt-get install -y --no-install-recommends rsync tini \
&& rm -rf /var/lib/apt/lists/* \
&& mkdir -p /backup
COPY dev/backup-entrypoint.sh /usr/local/bin/preview-backup-entrypoint
RUN chmod +x /usr/local/bin/preview-backup-entrypoint
ENTRYPOINT ["/usr/bin/tini", "--", "/usr/local/bin/preview-backup-entrypoint"]
Executable
+328
View File
@@ -0,0 +1,328 @@
#!/usr/bin/env python3
"""End-to-end checks for the disposable preview domain and file server."""
import base64
import datetime as dt
import json
import os
import socket
import ssl
import subprocess
import sys
import time
import urllib.error
import urllib.parse
import urllib.request
from dataclasses import dataclass
from typing import Callable, Dict, Optional
ENGINE = os.environ["PREVIEW_ENGINE"]
FILES_CONTAINER = os.environ["PREVIEW_FILES_CONTAINER"]
CLIENT_CONTAINER = os.environ["PREVIEW_CLIENT_CONTAINER"]
BACKUP_CONTAINER = os.environ["PREVIEW_BACKUP_CONTAINER"]
CA_ROOT = os.environ["PREVIEW_CA_ROOT"]
HTTPS_PORT = int(os.environ["PREVIEW_HTTPS_PORT"])
REALM = os.environ["PREVIEW_REALM"]
WORKGROUP = os.environ["PREVIEW_WORKGROUP"]
DNS_DOMAIN = os.environ["PREVIEW_DNS_DOMAIN"]
DOMAIN_SID = os.environ["PREVIEW_DOMAIN_SID"]
ADMIN_USER = os.environ["PREVIEW_ADMIN_USER"]
ADMIN_PASSWORD = os.environ["PREVIEW_ADMIN_PASSWORD"]
USER_PASSWORD = os.environ["PREVIEW_USER_PASSWORD"]
BASE_URL = f"https://localhost:{HTTPS_PORT}"
TLS_CONTEXT = ssl.create_default_context(cafile=CA_ROOT)
@dataclass
class Response:
status: int
headers: object
body: bytes
def json(self):
return json.loads(self.body.decode("utf-8"))
def fail(message: str) -> None:
raise AssertionError(message)
def check(condition: bool, message: str) -> None:
if not condition:
fail(message)
def announce(message: str) -> None:
print(f"[e2e] {message}", flush=True)
def engine_run(*args: str, check_result: bool = True) -> subprocess.CompletedProcess:
result = subprocess.run(
[ENGINE, *args], capture_output=True, text=True, check=False
)
if check_result and result.returncode != 0:
output = result.stderr.strip() or result.stdout.strip()
fail(f"container command failed ({' '.join(args)}): {output}")
return result
def http(
path: str,
*,
method: str = "GET",
value: Optional[Dict[str, object]] = None,
token: str = "",
) -> Response:
body = None
headers = {"Accept": "application/json"}
if value is not None:
body = json.dumps(value).encode("utf-8")
headers["Content-Type"] = "application/json"
if token:
headers["Authorization"] = f"Bearer {token}"
request = urllib.request.Request(
f"{BASE_URL}{path}", data=body, headers=headers, method=method
)
try:
with urllib.request.urlopen(
request, context=TLS_CONTEXT, timeout=30
) as response:
return Response(response.status, response.headers, response.read())
except urllib.error.HTTPError as exc:
return Response(exc.code, exc.headers, exc.read())
def eventually(
description: str,
callback: Callable[[], object],
predicate: Callable[[object], bool],
timeout: float = 90,
interval: float = 1,
):
deadline = time.monotonic() + timeout
last_value = None
last_error: Optional[Exception] = None
while time.monotonic() < deadline:
try:
last_value = callback()
if predicate(last_value):
return last_value
except Exception as exc: # pylint: disable=broad-except
last_error = exc
time.sleep(interval)
detail = f"; last value={last_value!r}"
if last_error is not None:
detail += f"; last error={last_error}"
fail(f"timed out waiting for {description}{detail}")
def decode_jwt_payload(token: str) -> Dict[str, object]:
parts = token.split(".")
check(len(parts) == 3, "login did not return a compact JWT")
padding = "=" * (-len(parts[1]) % 4)
return json.loads(base64.urlsafe_b64decode(parts[1] + padding))
def flatten_members(nodes):
values = []
for node in nodes:
values.append((node.get("type"), node.get("sam"), node.get("name")))
values.extend(flatten_members(node.get("members", [])))
return values
def query_path(path: str, params: Dict[str, str]) -> str:
return f"{path}?{urllib.parse.urlencode(params)}"
def main() -> int:
announce("TLS chain, hostname, public health, and browser security headers")
health = http("/healthz")
check(health.status == 200 and health.json() == {"status": "ok"}, "health check failed")
index = http("/")
check(index.status == 200 and b'Dateiserver-Verwaltung' in index.body, "German web shell was not served")
check(b'<html lang="de">' in index.body, "web shell language is not German")
styles = http("/assets/styles.css")
check(b"[hidden]" in styles.body and b"display: none !important" in styles.body, "hidden views can be made visible by author CSS")
script = http("/assets/app.js")
check(b"getUTCHours()" in script.body and b" UTC`" in script.body, "UI does not format timestamps explicitly in UTC")
check(b"localTime" not in script.body and b"eyebrow" not in script.body, "obsolete local-time or decorative UI code remains")
check(b"brand-mark" not in index.body, "decorative brand mark remains")
check("max-age=" in index.headers.get("Strict-Transport-Security", ""), "HSTS missing")
check("default-src 'self'" in index.headers.get("Content-Security-Policy", ""), "CSP missing")
with socket.create_connection(("localhost", HTTPS_PORT), timeout=10) as raw:
with TLS_CONTEXT.wrap_socket(raw, server_hostname="localhost") as secured:
certificate = secured.getpeercert()
sans = {value for kind, value in certificate.get("subjectAltName", ()) if kind == "DNS"}
check("localhost" in sans, "issued certificate does not cover localhost")
check(certificate.get("issuer") != certificate.get("subject"), "web certificate is self-signed instead of CA-issued")
announce("JWT boundary, real Kerberos credentials, and Domain Admin authorization")
unauthenticated = http("/api/session")
check(unauthenticated.status == 401, "protected API accepted an anonymous request")
non_admin = http(
"/api/login",
method="POST",
value={"username": "alice", "password": USER_PASSWORD},
)
check(non_admin.status == 401, "valid non-admin domain user was allowed into the UI")
wrong_password = http(
"/api/login",
method="POST",
value={"username": ADMIN_USER, "password": "wrong-password"},
)
check(wrong_password.status == 401, "invalid admin password was accepted")
login = http(
"/api/login",
method="POST",
value={"username": ADMIN_USER, "password": ADMIN_PASSWORD},
)
check(login.status == 200, f"Domain Admin login failed: {login.body!r}")
login_payload = login.json()
token = str(login_payload.get("token", ""))
claims = decode_jwt_payload(token)
check(claims.get("iss") == "ad-file-server-web", "JWT issuer is wrong")
check(claims.get("aud") == "domain-admins", "JWT audience is wrong")
check(claims.get("role") == "domain-admin", "JWT role is wrong")
check(claims.get("sub") == f"{WORKGROUP}\\{ADMIN_USER}", "JWT subject is wrong")
cookie = login.headers.get("Set-Cookie", "")
for attribute in ("HttpOnly", "Secure", "SameSite=Strict"):
check(attribute in cookie, f"session cookie is missing {attribute}")
session = http("/api/session", token=token)
check(session.status == 200 and session.json().get("user") == claims["sub"], "Bearer JWT was not accepted")
check(http("/api/session", token=f"{token}corrupt").status == 401, "corrupt JWT was accepted")
readonly = http("/api/groups", method="POST", value={}, token=token)
check(readonly.status == 404, "a mutation-like API method was accepted")
announce("AD trust, nested group tree, folders, and domain membership")
engine_run("exec", FILES_CONTAINER, "wbinfo", "-t")
admin_identity = engine_run(
"exec", FILES_CONTAINER, "wbinfo", "--name-to-sid", f"{WORKGROUP}\\{ADMIN_USER}"
)
admin_sid = admin_identity.stdout.split()[0]
admin_sids = engine_run(
"exec", FILES_CONTAINER, "wbinfo", "--user-sids", admin_sid
)
check(f"{DOMAIN_SID}-512" in admin_sids.stdout, "preview admin SID set lacks Domain Admins")
groups_response = http("/api/groups", token=token)
check(groups_response.status == 200, f"group endpoint failed: {groups_response.body!r}")
groups_payload = groups_response.json()
groups = {row["name"]: row for row in groups_payload.get("groups", [])}
check({"Finance", "Engineering", "Projects"}.issubset(groups), f"seed groups missing: {sorted(groups)}")
check(groups["Finance"]["folder"] == "Finance", "Finance group/folder mapping is wrong")
finance_nodes = flatten_members(groups["Finance"].get("members", []))
check(any(kind == "group" and sam == "Finance_Analysts" for kind, sam, _ in finance_nodes), "nested Finance Analysts group missing")
check({"alice", "bob"}.issubset({sam for kind, sam, _ in finance_nodes if kind == "user"}), "Finance users missing")
project_nodes = flatten_members(groups["Projects"].get("members", []))
check({"alice", "bob", "carol", "dave", "eve"}.issubset({sam for kind, sam, _ in project_nodes if kind == "user"}), "transitive Project membership is incomplete")
announce("SMB authorization and real share reads/writes")
alice_access = engine_run(
"exec", CLIENT_CONTAINER, "smbclient", f"//files.{DNS_DOMAIN}/Data", "-m", "SMB3",
"-U", f"{WORKGROUP}\\alice%{USER_PASSWORD}", "-c", "cd Finance; ls",
check_result=False,
)
check(alice_access.returncode == 0, f"Alice cannot access Finance: {alice_access.stderr}")
dave_denied = engine_run(
"exec", CLIENT_CONTAINER, "smbclient", f"//files.{DNS_DOMAIN}/Data", "-m", "SMB3",
"-U", f"{WORKGROUP}\\dave%{USER_PASSWORD}", "-c", "cd Finance; ls",
check_result=False,
)
check(dave_denied.returncode != 0, "unrelated user Dave can access Finance")
admin_access = engine_run(
"exec", CLIENT_CONTAINER, "smbclient", f"//files.{DNS_DOMAIN}/Data", "-m", "SMB3",
"-U", f"{WORKGROUP}\\{ADMIN_USER}%{ADMIN_PASSWORD}", "-c", "cd Finance; ls",
check_result=False,
)
check(admin_access.returncode == 0, "Domain Admin cannot inspect Finance")
announce("group, Private, and FSLogix size accounting")
storage = http("/api/storage", token=token)
check(storage.status == 200, f"storage endpoint failed: {storage.body!r}")
storage_payload = storage.json()
storage_groups = {row["name"]: int(row["bytes"]) for row in storage_payload.get("groups", [])}
check(storage_groups.get("Finance", 0) >= 1024 * 1024, "Finance usage was not scanned")
check(storage_groups.get("Engineering", 0) >= 1024 * 1024, "Engineering usage was not scanned")
users = {row["name"].casefold(): row for row in storage_payload.get("users", [])}
check(int(users.get("alice", {}).get("privateBytes", 0)) > 0, "Alice Private usage missing")
check(int(users.get("alice", {}).get("fslogixBytes", 0)) > 0, "Alice FSLogix usage missing")
check(int(storage_payload.get("totals", {}).get("dataBytes", 0)) > 0, "Data total is empty")
announce("live Samba audit ingestion, filters, facets, and pagination")
activity = eventually(
"live alice audit records",
lambda: http(query_path("/api/activity", {"user": "alice", "limit": "5"}), token=token),
lambda response: response.status == 200 and response.json().get("matched", 0) >= 2,
timeout=60,
)
activity_payload = activity.json()
check(all("alice" in str(event.get("user", "")).casefold() for event in activity_payload["events"]), "activity user filter leaked unrelated events")
check("Data" in activity_payload.get("facets", {}).get("shares", []), "activity share facets missing Data")
one_event = http(query_path("/api/activity", {"limit": "1"}), token=token).json()
check(len(one_event.get("events", [])) == 1, "activity limit was ignored")
check(one_event.get("nextCursor") is not None, "activity pagination cursor missing")
announce("closed daily log compression and querying gzip history")
old_day = (dt.datetime.now(dt.timezone.utc).date() - dt.timedelta(days=2)).isoformat()
eventually(
"historical audit gzip",
lambda: engine_run(
"exec", FILES_CONTAINER, "test", "-f", f"/state/audit/{old_day}.jsonl.gz",
check_result=False,
).returncode,
lambda returncode: returncode == 0,
timeout=30,
)
archived = http(
query_path(
"/api/activity",
{"from": old_day, "to": old_day, "user": "archived-user", "limit": "10"},
),
token=token,
)
check(archived.status == 200 and archived.json().get("matched") == 1, "compressed audit history is not searchable")
announce("real rsync backup, status API, log tail, and remote completion marker")
backup = eventually(
"completed backup",
lambda: http("/api/backup", token=token),
lambda response: response.status == 200 and response.json().get("state") in {"completed", "failed"},
timeout=240,
interval=2,
)
backup_payload = backup.json()
check(backup_payload.get("state") == "completed", f"backup failed: {backup_payload}")
check(float(backup_payload.get("percent", 0)) == 100.0, "completed backup is not at 100%")
check(any("completed" in line.casefold() for line in backup_payload.get("log", [])), "backup completion absent from log tail")
marker = engine_run(
"exec", BACKUP_CONTAINER, "sh", "-ec",
"find /backup/fileserver/snapshots -name .backup_complete -type f | grep -q .",
check_result=False,
)
check(marker.returncode == 0, "backup target has no completed snapshot marker")
announce("overview and system health aggregation")
overview = http("/api/overview", token=token)
check(overview.status == 200 and overview.json().get("activeGroups") == 3, "overview group count is wrong")
system = http("/api/system", token=token)
check(system.status == 200, f"system endpoint failed: {system.body!r}")
system_payload = system.json()
check(system_payload.get("checks", {}).get("domainTrust") is True, "system reports broken AD trust")
check(system_payload.get("checks", {}).get("sambaConfig") is True, "system reports invalid Samba config")
check(system_payload.get("tls", {}).get("sans"), "system TLS summary is empty")
check(system_payload.get("audit", {}).get("days", 0) >= 2, "system audit archive summary is incomplete")
logout = http("/api/logout", method="POST", value={}, token=token)
check(logout.status == 200 and "Max-Age=0" in logout.headers.get("Set-Cookie", ""), "logout did not clear session cookie")
announce("PASS: all end-to-end assertions succeeded")
return 0
if __name__ == "__main__":
try:
sys.exit(main())
except Exception as exc: # pylint: disable=broad-except
print(f"[e2e] FAIL: {exc}", file=sys.stderr, flush=True)
sys.exit(1)
+53
View File
@@ -0,0 +1,53 @@
#!/usr/bin/env bash
set -Eeuo pipefail
: "${AD_DOMAIN:?missing AD_DOMAIN}"
: "${AD_USER_PASSWORD:?missing AD_USER_PASSWORD}"
: "${FILESERVER_HOST:?missing FILESERVER_HOST}"
interval=${DEV_ACTIVITY_INTERVAL_SECONDS:-4}
log() {
printf '[preview-client] %s\n' "$*"
}
smb() {
local user=$1
local share=$2
local commands=$3
smbclient "//${FILESERVER_HOST}/${share}" \
-m SMB3 -U "${AD_DOMAIN}\\${user}%${AD_USER_PASSWORD}" \
-c "$commands"
}
ready=0
for _ in $(seq 1 120); do
if smb alice Data 'ls' >/dev/null 2>&1; then
ready=1
break
fi
sleep 1
done
if [[ $ready != 1 ]]; then
printf '[preview-client] ERROR: SMB server did not become ready\n' >&2
exit 1
fi
printf 'Generated by the preview SMB client.\n' > /tmp/live-note.txt
smb alice Data 'cd Finance; mkdir Reports; cd Reports; put /tmp/live-note.txt live-note.txt; ls' >/dev/null
smb carol Data 'cd Engineering; mkdir Designs; cd Designs; put /tmp/live-note.txt architecture.txt; ls' >/dev/null
smb alice Private 'cd alice; put /tmp/live-note.txt notes.txt; ls' >/dev/null
touch /run/preview-client-ready
log 'Initial SMB reads and writes complete; generating live activity'
counter=0
while true; do
counter=$((counter + 1))
printf 'Preview activity event %d at %s\n' "$counter" "$(date -u +%FT%TZ)" > /tmp/live-note.txt
smb alice Data 'cd Finance; cd Reports; put /tmp/live-note.txt live-note.txt; get live-note.txt /tmp/readback.txt; ls' >/dev/null 2>&1 || true
smb bob Data 'cd Finance; ls' >/dev/null 2>&1 || true
smb carol Data 'cd Engineering; cd Designs; get architecture.txt /tmp/architecture.txt; ls' >/dev/null 2>&1 || true
smb eve Data 'cd Projects; ls' >/dev/null 2>&1 || true
smb alice Private 'cd alice; ls; get notes.txt /tmp/private-note.txt' >/dev/null 2>&1 || true
sleep "$interval"
done
+37
View File
@@ -0,0 +1,37 @@
#!/usr/bin/env bash
set -Eeuo pipefail
seed_mb=${DEV_SEED_MB:-8}
mkdir -p \
/data/groups/data/Finance/Reports \
/data/groups/data/Engineering/Designs \
/data/groups/data/Projects/Planning \
/data/private/alice \
/data/private/bob \
/data/private/carol \
/data/private/dave \
/data/private/eve \
/data/fslogix/alice_S-1-5-21-111-222-333-1101 \
/data/fslogix/carol_S-1-5-21-111-222-333-1103 \
/state/audit
printf 'Quarter,Forecast\nQ1,120000\nQ2,135000\n' > /data/groups/data/Finance/Reports/forecast.csv
printf '# Architecture\n\nPreview design notes.\n' > /data/groups/data/Engineering/Designs/architecture.md
printf 'Milestone,Owner\nDiscovery,Eve\nDelivery,Carol\n' > /data/groups/data/Projects/Planning/roadmap.csv
printf 'Alice private preview data.\n' > /data/private/alice/readme.txt
printf 'Bob private preview data.\n' > /data/private/bob/readme.txt
printf 'Carol private preview data.\n' > /data/private/carol/readme.txt
printf 'Dummy FSLogix profile for Alice.\n' > /data/fslogix/alice_S-1-5-21-111-222-333-1101/profile.vhdx
printf 'Dummy FSLogix profile for Carol.\n' > /data/fslogix/carol_S-1-5-21-111-222-333-1103/profile.vhdx
dd if=/dev/zero of=/data/groups/data/Finance/Reports/history.bin bs=1M count="$seed_mb" status=none
dd if=/dev/zero of=/data/groups/data/Engineering/Designs/models.bin bs=1M count="$seed_mb" status=none
dd if=/dev/zero of=/data/fslogix/alice_S-1-5-21-111-222-333-1101/profile-data.bin bs=1M count="$seed_mb" status=none
old_day=$(date -u -d '2 days ago' +%F)
printf '%s\n' "{\"action\":\"read\",\"client\":\"archived-client\",\"clientIp\":\"192.0.2.50\",\"ingestedAt\":\"${old_day}T12:00:00+00:00\",\"operation\":\"read\",\"path\":\"Finance/Reports/archive.csv\",\"result\":\"OK\",\"share\":\"Data\",\"source\":\"log.archived-client\",\"success\":true,\"timestamp\":\"${old_day}T12:00:00+00:00\",\"user\":\"archived-user\"}" \
> "/state/audit/${old_day}.jsonl"
touch -d '2 days ago' "/state/audit/${old_day}.jsonl"
printf '[preview-seed] Seeded group, private, FSLogix, and historical audit data.\n'
+2
View File
@@ -8,10 +8,12 @@ services:
env_file:
- .env
environment:
TZ: Etc/UTC
NETBIOS_NAME: ${NETBIOS_NAME:-ADSAMBAFSRV}
ports:
- "445:445"
- "139:139"
- "${WEB_HTTPS_PORT:-443}:${WEB_BIND_PORT:-8443}"
volumes:
- private_data:/data/private
- fslogix_data:/data/fslogix
Executable
+418
View File
@@ -0,0 +1,418 @@
#!/usr/bin/env bash
set -Eeuo pipefail
die() {
printf 'error: %s\n' "$*" >&2
exit 1
}
script_path=${BASH_SOURCE[0]}
case "$script_path" in
*/*) script_dir=${script_path%/*} ;;
*) script_dir=. ;;
esac
cd "$script_dir/.."
repo_root=$PWD
if command -v podman >/dev/null 2>&1; then
engine=podman
elif command -v docker >/dev/null 2>&1; then
engine=docker
else
die 'podman or docker required'
fi
run_e2e=${DEV_RUN_E2E:-0}
case "${1:-}" in
--e2e) run_e2e=1 ;;
'') ;;
*) die "unknown argument: $1" ;;
esac
dev_realm=${DEV_REALM:-DEV.TEST}
dev_workgroup=${DEV_WORKGROUP:-DEV}
dev_dns_domain=${DEV_DNS_DOMAIN:-dev.test}
dev_base_dn=${DEV_BASE_DN:-DC=dev,DC=test}
dev_dc_password=${DEV_DC_PASSWORD:-PreviewDc123!}
dev_user_password=${DEV_USER_PASSWORD:-PreviewUser123!}
dev_admin_user=${DEV_ADMIN_USER:-previewadmin}
dev_admin_password=${DEV_ADMIN_PASSWORD:-PreviewAdmin123!}
dev_backup_user=${DEV_BACKUP_USER:-preview}
dev_backup_password=${DEV_BACKUP_PASSWORD:-PreviewBackup123!}
dev_ca_password=${DEV_CA_PASSWORD:-PreviewCa123!}
dev_ca_provisioner=${DEV_CA_PROVISIONER:-preview}
dev_https_port=${DEV_HTTPS_PORT:-8443}
dev_activity_interval=${DEV_ACTIVITY_INTERVAL_SECONDS:-4}
dev_backup_interval=${DEV_BACKUP_INTERVAL_SECONDS:-120}
server_image=${DEV_SERVER_IMAGE:-ad-ds-simple-file-server-dev:latest}
ad_image=${DEV_AD_IMAGE:-ad-ds-simple-file-server-ad-dev:latest}
backup_image=${DEV_BACKUP_IMAGE:-ad-ds-simple-file-server-backup-dev:latest}
step_ca_image=${DEV_STEP_CA_IMAGE:-docker.io/smallstep/step-ca:latest}
run_id="ad-file-server-dev-$$-$RANDOM"
network_name="$run_id-net"
dc_container="$run_id-dc"
ca_container="$run_id-ca"
backup_container="$run_id-backup"
files_container="$run_id-files"
client_container="$run_id-client"
ca_volume="$run_id-ca-state"
backup_volume="$run_id-backup-data"
private_volume="$run_id-private-data"
fslogix_volume="$run_id-fslogix-data"
groups_volume="$run_id-group-data"
state_volume="$run_id-state-data"
samba_volume="$run_id-samba-lib"
containers=(
"$client_container"
"$files_container"
"$dc_container"
"$backup_container"
"$ca_container"
)
volumes=(
"$ca_volume"
"$backup_volume"
"$private_volume"
"$fslogix_volume"
"$groups_volume"
"$state_volume"
"$samba_volume"
)
network_created=0
watchdog_pid=
log_pid=
backup_driver_pid=
ca_root_file=
stop_resources() {
local container
local volume
for container in "${containers[@]}"; do
"$engine" stop -t 3 "$container" >/dev/null 2>&1 || true
done
for container in "${containers[@]}"; do
"$engine" rm -f "$container" >/dev/null 2>&1 || true
done
if [[ $network_created == 1 ]]; then
"$engine" network rm "$network_name" >/dev/null 2>&1 || true
fi
for volume in "${volumes[@]}"; do
"$engine" volume rm "$volume" >/dev/null 2>&1 || true
done
}
start_watchdog() {
local parent_pid=$$
(
while kill -0 "$parent_pid" >/dev/null 2>&1; do
sleep 1
done
stop_resources
) &
watchdog_pid=$!
}
cleanup() {
local status=$?
trap - EXIT INT TERM HUP QUIT
if [[ -n ${backup_driver_pid:-} ]]; then
kill "$backup_driver_pid" >/dev/null 2>&1 || true
wait "$backup_driver_pid" 2>/dev/null || true
fi
if [[ -n ${log_pid:-} ]]; then
kill "$log_pid" >/dev/null 2>&1 || true
wait "$log_pid" 2>/dev/null || true
fi
if [[ -n ${watchdog_pid:-} ]]; then
kill "$watchdog_pid" >/dev/null 2>&1 || true
wait "$watchdog_pid" 2>/dev/null || true
fi
stop_resources
if [[ -n ${ca_root_file:-} && -f $ca_root_file ]]; then
rm -f -- "$ca_root_file"
fi
exit "$status"
}
trap cleanup EXIT
trap 'exit 130' INT
trap 'exit 143' TERM
trap 'exit 129' HUP
trap 'exit 131' QUIT
show_logs() {
local container
for container in "$dc_container" "$ca_container" "$backup_container" "$files_container" "$client_container"; do
if "$engine" inspect "$container" >/dev/null 2>&1; then
printf '\n===== %s =====\n' "$container" >&2
"$engine" logs --tail 160 "$container" >&2 || true
fi
done
}
wait_for_exec() {
local container=$1
local seconds=$2
shift 2
local elapsed=0
while (( elapsed < seconds )); do
if "$engine" exec "$container" "$@" >/dev/null 2>&1; then
return 0
fi
if [[ $("$engine" inspect -f '{{.State.Running}}' "$container" 2>/dev/null || true) != true ]]; then
return 1
fi
sleep 1
elapsed=$((elapsed + 1))
done
return 1
}
create_network() {
local attempt
local second
local third
for attempt in $(seq 0 31); do
second=$((20 + ((RANDOM + attempt) % 10)))
third=$(((RANDOM + $$ + attempt) % 240 + 8))
subnet="172.${second}.${third}.0/24"
if "$engine" network create --subnet "$subnet" "$network_name" >/dev/null 2>&1; then
network_created=1
subnet_prefix="172.${second}.${third}."
return 0
fi
done
return 1
}
start_watchdog
printf 'engine: %s\n' "$engine"
if [[ ${DEV_SKIP_BUILD:-0} != 1 ]]; then
printf 'building file server image: %s\n' "$server_image"
"$engine" build -t "$server_image" -f "$repo_root/Dockerfile" "$repo_root"
printf 'building AD/client image: %s\n' "$ad_image"
"$engine" build -t "$ad_image" -f "$repo_root/dev/ad-dc.Dockerfile" "$repo_root"
printf 'building backup image: %s\n' "$backup_image"
"$engine" build -t "$backup_image" -f "$repo_root/dev/backup.Dockerfile" "$repo_root"
fi
printf 'creating isolated network: %s\n' "$network_name"
create_network || die 'unable to allocate an isolated container subnet'
dc_ip="${subnet_prefix}10"
ca_ip="${subnet_prefix}20"
files_ip="${subnet_prefix}30"
backup_ip="${subnet_prefix}40"
for volume in "${volumes[@]}"; do
"$engine" volume create "$volume" >/dev/null
done
printf 'starting local CA: %s\n' "$step_ca_image"
"$engine" run -d \
--name "$ca_container" \
--hostname "ca.${dev_dns_domain}" \
--network "$network_name" \
--ip "$ca_ip" \
-e "DOCKER_STEPCA_INIT_NAME=AD file server preview CA" \
-e "DOCKER_STEPCA_INIT_DNS_NAMES=ca.${dev_dns_domain},localhost,127.0.0.1" \
-e "DOCKER_STEPCA_INIT_PROVISIONER_NAME=${dev_ca_provisioner}" \
-e "DOCKER_STEPCA_INIT_PASSWORD=${dev_ca_password}" \
-v "$ca_volume:/home/step" \
"$step_ca_image" >/dev/null
if ! wait_for_exec "$ca_container" 120 step ca health \
--ca-url=https://localhost:9000 \
--root=/home/step/certs/root_ca.crt; then
show_logs
die 'local CA did not become healthy'
fi
ca_fingerprint=$("$engine" exec "$ca_container" step certificate fingerprint \
/home/step/certs/root_ca.crt)
preview_tmp_root=${TMPDIR:-/tmp}
ca_root_file="${preview_tmp_root%/}/${run_id}-root-ca.crt"
"$engine" exec "$ca_container" cat /home/step/certs/root_ca.crt > "$ca_root_file"
printf 'starting disposable rsync backup target\n'
"$engine" run -d \
--name "$backup_container" \
--hostname "backup.${dev_dns_domain}" \
--network "$network_name" \
--ip "$backup_ip" \
-e "BACKUP_USERNAME=${dev_backup_user}" \
-e "BACKUP_PASSWORD=${dev_backup_password}" \
-v "$backup_volume:/backup" \
"$backup_image" >/dev/null
printf 'starting disposable Samba AD DC: %s\n' "$dev_realm"
"$engine" run -d \
--name "$dc_container" \
--hostname "dc.${dev_dns_domain}" \
--cap-add SYS_ADMIN \
--network "$network_name" \
--ip "$dc_ip" \
-e "AD_REALM=${dev_realm}" \
-e "AD_DOMAIN=${dev_workgroup}" \
-e "AD_DNS_DOMAIN=${dev_dns_domain}" \
-e "AD_BASE_DN=${dev_base_dn}" \
-e "AD_ADMIN_PASSWORD=${dev_dc_password}" \
-e "AD_USER_PASSWORD=${dev_user_password}" \
-e "AD_WEB_ADMIN_USER=${dev_admin_user}" \
-e "AD_WEB_ADMIN_PASSWORD=${dev_admin_password}" \
-e "DEV_CA_IP=${ca_ip}" \
-e "DEV_BACKUP_IP=${backup_ip}" \
-e "DEV_FILESERVER_IP=${files_ip}" \
"$ad_image" >/dev/null
if ! wait_for_exec "$dc_container" 150 test -f /run/preview-ready; then
show_logs
die 'dummy AD domain did not become ready'
fi
domain_sid=$("$engine" exec "$dc_container" cat /run/domain-sid)
printf 'seeding preview files and an old audit archive\n'
"$engine" run --rm \
--network "$network_name" \
-e "DEV_SEED_MB=${DEV_SEED_MB:-8}" \
-v "$private_volume:/data/private" \
-v "$fslogix_volume:/data/fslogix" \
-v "$groups_volume:/data/groups" \
-v "$state_volume:/state" \
--entrypoint /usr/local/bin/preview-seed-files \
"$ad_image"
printf 'starting actual file server and HTTPS web UI\n'
"$engine" run -d \
--name "$files_container" \
--hostname files \
--network "$network_name" \
--ip "$files_ip" \
--dns "$dc_ip" \
--dns-search "$dev_dns_domain" \
-p "127.0.0.1:${dev_https_port}:8443" \
-e "REALM=${dev_realm}" \
-e "WORKGROUP=${dev_workgroup}" \
-e "DOMAIN=dc.${dev_dns_domain}" \
-e "LDAP_URI=ldap://dc.${dev_dns_domain}" \
-e "LDAP_BASE_DN=${dev_base_dn}" \
-e "JOIN_USER=Administrator" \
-e "JOIN_PASSWORD=${dev_dc_password}" \
-e "DOMAIN_USERS_SID=${domain_sid}-513" \
-e "DOMAIN_ADMINS_SID=${domain_sid}-512" \
-e "FSLOGIX_GROUP_SID=${domain_sid}-513" \
-e "SAMBA_HOSTNAME=files" \
-e "NETBIOS_NAME=FILES" \
-e "AD_DNS_NAME=files.${dev_dns_domain}" \
-e "WEB_ENABLED=true" \
-e "WEB_HOSTNAME=localhost" \
-e "WEB_BIND_PORT=8443" \
-e "WEB_JWT_SECRET=preview-only-jwt-secret-with-at-least-32-bytes" \
-e "WEB_TLS_MODE=step" \
-e "STEP_CA_URL=https://ca.${dev_dns_domain}:9000" \
-e "STEP_CA_FINGERPRINT=${ca_fingerprint}" \
-e "STEP_CA_PROVISIONER=${dev_ca_provisioner}" \
-e "STEP_CA_PROVISIONER_PASSWORD=${dev_ca_password}" \
-e "WEB_USAGE_SCAN_INTERVAL_SECONDS=60" \
-e "WEB_DIRECTORY_CACHE_SECONDS=30" \
-e "AUDIT_POLL_SECONDS=0.25" \
-e "AUDIT_COMPRESS_AFTER_HOURS=1" \
-e "BACKUP_DESTINATION=rsync://${dev_backup_user}:${dev_backup_password}@backup.${dev_dns_domain}/backups/fileserver" \
-e "BACKUP_PROGRESS=never" \
-e "BACKUP_PROGRESS_INTERVAL_SECONDS=1" \
-e "BACKUP_RETENTION_DAILY=3" \
-e "BACKUP_RETENTION_WEEKLY=2" \
-e "BACKUP_RETENTION_MONTHLY=1" \
-e "BACKUP_RETENTION_YEARLY=1" \
-v "$private_volume:/data/private" \
-v "$fslogix_volume:/data/fslogix" \
-v "$groups_volume:/data/groups" \
-v "$state_volume:/state" \
-v "$samba_volume:/var/lib/samba" \
"$server_image" >/dev/null
if ! wait_for_exec "$files_container" 240 python3 -c \
'import ssl,urllib.request; print(urllib.request.urlopen("https://127.0.0.1:8443/healthz", context=ssl._create_unverified_context(), timeout=3).status)'; then
show_logs
die 'file server web UI did not become healthy'
fi
printf 'starting continuous authenticated SMB activity client\n'
"$engine" run -d \
--name "$client_container" \
--hostname preview-client \
--network "$network_name" \
--dns "$dc_ip" \
--dns-search "$dev_dns_domain" \
--add-host "files.${dev_dns_domain}:${files_ip}" \
-e "AD_DOMAIN=${dev_workgroup}" \
-e "AD_USER_PASSWORD=${dev_user_password}" \
-e "FILESERVER_HOST=files.${dev_dns_domain}" \
-e "DEV_ACTIVITY_INTERVAL_SECONDS=${dev_activity_interval}" \
"$ad_image" /usr/local/bin/preview-client >/dev/null
if ! wait_for_exec "$client_container" 120 test -f /run/preview-client-ready; then
show_logs
die 'preview SMB client did not complete its initial activity'
fi
run_backup_loop() {
local elapsed
while "$engine" inspect "$files_container" >/dev/null 2>&1; do
"$engine" exec "$files_container" /bin/bash -lc \
'source /app/runtime.env && exec /usr/bin/python3 /app/backup_to_destination.py' || true
elapsed=0
while (( elapsed < dev_backup_interval )); do
"$engine" inspect "$files_container" >/dev/null 2>&1 || return 0
sleep 1
elapsed=$((elapsed + 1))
done
done
}
run_backup_loop &
backup_driver_pid=$!
if [[ $run_e2e == 1 ]]; then
printf 'running end-to-end assertions\n'
if ! PREVIEW_ENGINE="$engine" \
PREVIEW_FILES_CONTAINER="$files_container" \
PREVIEW_CLIENT_CONTAINER="$client_container" \
PREVIEW_BACKUP_CONTAINER="$backup_container" \
PREVIEW_CA_ROOT="$ca_root_file" \
PREVIEW_HTTPS_PORT="$dev_https_port" \
PREVIEW_REALM="$dev_realm" \
PREVIEW_WORKGROUP="$dev_workgroup" \
PREVIEW_DNS_DOMAIN="$dev_dns_domain" \
PREVIEW_DOMAIN_SID="$domain_sid" \
PREVIEW_ADMIN_USER="$dev_admin_user" \
PREVIEW_ADMIN_PASSWORD="$dev_admin_password" \
PREVIEW_USER_PASSWORD="$dev_user_password" \
python3 "$repo_root/dev/e2e.py"; then
show_logs
exit 1
fi
exit 0
fi
printf '\nPreview ready\n'
printf ' URL: https://localhost:%s\n' "$dev_https_port"
printf ' Username: %s\\%s\n' "$dev_workgroup" "$dev_admin_user"
printf ' Password: %s\n' "$dev_admin_password"
printf ' CA root: %s\n' "$ca_root_file"
printf '\nThe CA is disposable, so import the displayed root only as temporary trust.\n'
printf 'SMB activity and backups repeat in the background. Press Ctrl-C to remove everything.\n\n'
"$engine" logs -f "$files_container" &
log_pid=$!
file_status=$("$engine" wait "$files_container" 2>/dev/null || printf '1')
case "$file_status" in
''|*[!0-9]*) exit 1 ;;
*) exit "$file_status" ;;
esac
+12
View File
@@ -0,0 +1,12 @@
#!/usr/bin/env bash
set -Eeuo pipefail
script_path=${BASH_SOURCE[0]}
case "$script_path" in
*/*) script_dir=${script_path%/*} ;;
*) script_dir=. ;;
esac
cd "$script_dir/.."
export DEV_RUN_E2E=1
exec ./scripts/dev --e2e
+50
View File
@@ -135,6 +135,14 @@ write_env_file() {
local ad_dns_name=""
local ad_dns_ip_auto="0"
local service_password=""
local web_hostname=""
local web_https_port="443"
local web_jwt_secret=""
local step_ca_url=""
local step_ca_fingerprint=""
local step_ca_provisioner=""
local step_ca_provisioner_password=""
local web_hostname_input=""
local service_account_sam=""
local fslogix_group_prompt=""
local samba_hostname_input=""
@@ -204,6 +212,22 @@ write_env_file() {
read -r -p "BACKUP_RETENTION_YEARLY [1]: " backup_retention_yearly
backup_retention_yearly="${backup_retention_yearly:-1}"
web_hostname="$ad_dns_name"
read -r -p "WEB_HOSTNAME [${web_hostname}]: " web_hostname_input
web_hostname="${web_hostname_input:-$web_hostname}"
read -r -p "WEB_HTTPS_PORT [443]: " web_https_port
web_https_port="${web_https_port:-443}"
prompt_value step_ca_url "STEP_CA_URL (e.g. https://ca.example.com:9000)"
prompt_value step_ca_fingerprint "STEP_CA_FINGERPRINT"
prompt_value step_ca_provisioner "STEP_CA_PROVISIONER (JWK provisioner name)"
prompt_value step_ca_provisioner_password "STEP_CA_PROVISIONER_PASSWORD" true
web_jwt_secret="$(python3 - <<'PY'
import secrets
print(secrets.token_urlsafe(48))
PY
)"
service_account_sam="$(sanitize_sam_account_name "$SERVICE_ACCOUNT_NAME")"
if [[ "$service_account_sam" != "$SERVICE_ACCOUNT_NAME" ]]; then
printf "Using sAMAccountName '%s' (AD limit is 20 chars; requested '%s').\n" "$service_account_sam" "$SERVICE_ACCOUNT_NAME"
@@ -251,6 +275,15 @@ BACKUP_RETENTION_MONTHLY=${backup_retention_monthly}
BACKUP_RETENTION_YEARLY=${backup_retention_yearly}
SAMBA_HOSTNAME=${samba_hostname}
NETBIOS_NAME=${netbios_name}
WEB_ENABLED=true
WEB_HOSTNAME=${web_hostname}
WEB_HTTPS_PORT=${web_https_port}
WEB_JWT_SECRET=${web_jwt_secret}
WEB_TLS_MODE=step
STEP_CA_URL=${step_ca_url}
STEP_CA_FINGERPRINT=${step_ca_fingerprint}
STEP_CA_PROVISIONER=${step_ca_provisioner}
STEP_CA_PROVISIONER_PASSWORD=${step_ca_provisioner_password}
EOF
cp "$BOOTSTRAP_ENV_FILE" "$ENV_FILE"
@@ -311,6 +344,15 @@ BACKUP_RETENTION_MONTHLY=${backup_retention_monthly}
BACKUP_RETENTION_YEARLY=${backup_retention_yearly}
SAMBA_HOSTNAME=${samba_hostname}
NETBIOS_NAME=${netbios_name}
WEB_ENABLED=true
WEB_HOSTNAME=${web_hostname}
WEB_HTTPS_PORT=${web_https_port}
WEB_JWT_SECRET=${web_jwt_secret}
WEB_TLS_MODE=step
STEP_CA_URL=${step_ca_url}
STEP_CA_FINGERPRINT=${step_ca_fingerprint}
STEP_CA_PROVISIONER=${step_ca_provisioner}
STEP_CA_PROVISIONER_PASSWORD=${step_ca_provisioner_password}
# Optional overrides:
# LDAP_URI=ldaps://${domain}
# LDAP_BASE_DN=DC=example,DC=com
@@ -328,6 +370,14 @@ NETBIOS_NAME=${netbios_name}
# BACKUP_LOG_FILE=/var/log/backup.log
# BACKUP_PROGRESS=auto
# BACKUP_PROGRESS_INTERVAL_SECONDS=10
# WEB_JWT_TTL_SECONDS=28800
# WEB_USAGE_SCAN_INTERVAL_SECONDS=900
# WEB_DIRECTORY_CACHE_SECONDS=300
# AUDIT_COMPRESS_AFTER_HOURS=24
# AUDIT_QUERY_MAX_DAYS=31
# STEP_CA_PROVISIONER_PASSWORD_FILE=/run/secrets/step-ca-provisioner-password
# WEB_TLS_CERT_FILE=/state/tls/web.crt
# WEB_TLS_KEY_FILE=/state/tls/web.key
EOF
chmod 600 "$ENV_FILE"
+1
View File
@@ -233,6 +233,7 @@ class BackendProgressCommandTests(unittest.TestCase):
)
command = run_streaming.call_args.args[0]
self.assertIn("--mkpath", command)
self.assertIn("--progress", command)
self.assertIn("--outbuf=L", command)
self.assertEqual(
+224
View File
@@ -0,0 +1,224 @@
import datetime as dt
import gzip
import json
import os
import tempfile
import unittest
from unittest import mock
from app import audit_collector
from app import web_ui
class TokenManagerTests(unittest.TestCase):
def test_issues_and_verifies_short_lived_admin_jwt(self):
manager = web_ui.TokenManager("s" * 48, 600)
token, expires = manager.issue("EXAMPLE\\alice")
payload = manager.verify(token)
self.assertEqual(payload["sub"], "EXAMPLE\\alice")
self.assertEqual(payload["role"], "domain-admin")
self.assertEqual(payload["exp"], expires)
def test_rejects_tampered_jwt(self):
manager = web_ui.TokenManager("s" * 48, 600)
token, _ = manager.issue("EXAMPLE\\alice")
with self.assertRaisesRegex(ValueError, "Invalid or expired"):
manager.verify(f"{token[:-1]}x")
def test_requires_a_long_secret(self):
with self.assertRaisesRegex(RuntimeError, "32 bytes"):
web_ui.TokenManager("short", 600)
class DomainAuthenticationTests(unittest.TestCase):
@mock.patch.dict(os.environ, {"WORKGROUP": "EXAMPLE"})
def test_bare_username_defaults_to_configured_netbios_domain(self):
self.assertEqual(web_ui.normalize_username("alice"), "EXAMPLE\\alice")
@mock.patch.dict(os.environ, {"WORKGROUP": "EXAMPLE"})
def test_qualified_username_remains_supported(self):
self.assertEqual(web_ui.normalize_username("EXAMPLE\\alice"), "EXAMPLE\\alice")
@mock.patch.dict(
os.environ,
{
"WORKGROUP": "EXAMPLE",
"REALM": "EXAMPLE.COM",
"DOMAIN_ADMINS_SID": "S-1-5-21-1-2-3-512",
},
)
@mock.patch("app.web_ui.subprocess.run")
def test_password_uses_kerberos_stdin_and_checks_admin_sid(self, run):
run.side_effect = [
mock.Mock(returncode=0, stdout=""),
mock.Mock(returncode=0, stdout="S-1-5-21-1-2-3-1100 SID_USER (1)\n"),
mock.Mock(returncode=0, stdout="S-1-5-21-1-2-3-1100\nS-1-5-21-1-2-3-512\n"),
]
result = web_ui.authenticate_domain_admin("alice", "p@ss word")
self.assertEqual(result, "EXAMPLE\\alice")
self.assertEqual(run.call_args_list[0].args[0], ["kinit", "alice@EXAMPLE.COM"])
self.assertNotIn("p@ss word", run.call_args_list[0].args[0])
class AuditParsingTests(unittest.TestCase):
def test_parses_full_audit_record(self):
line = (
"[2026/07/31 12:34:56.123456, 1] smbd_audit: "
"2026/07/31 12:34:56|alice|192.0.2.5|PC01|Data|pread|OK|Finance/report.xlsx\n"
)
event = audit_collector.parse_audit_line(line, "/var/log/samba/log.pc01")
self.assertEqual(event["user"], "alice")
self.assertEqual(event["action"], "read")
self.assertEqual(event["path"], "Finance/report.xlsx")
self.assertTrue(event["success"])
def test_parses_samba_two_line_payload_record(self):
line = (
" 2026/07/31 12:34:56|DEV\\alice|192.0.2.5|PC01|"
"Private|pread_send|ok|/data/private/alice/notes.txt\n"
)
event = audit_collector.parse_audit_line(line, "/var/log/samba/log.pc01")
self.assertEqual(event["timestamp"], "2026-07-31T12:34:56+00:00")
self.assertEqual(event["user"], "DEV\\alice")
self.assertEqual(event["action"], "read")
self.assertEqual(event["path"], "/data/private/alice/notes.txt")
self.assertTrue(event["success"])
def test_tracks_rotated_file_by_inode_without_reingesting_it(self):
with tempfile.TemporaryDirectory() as tmpdir:
archive = os.path.join(tmpdir, "audit")
os.mkdir(archive)
active = os.path.join(tmpdir, "log.pc01")
line = (
"[2026/07/31 12:34:56.000000, 1] smbd_audit: "
"x|alice|192.0.2.5|PC01|Data|pread|OK|a.txt\n"
)
with open(active, "w", encoding="utf-8") as handle:
handle.write(line)
with mock.patch.object(audit_collector, "SAMBA_LOG_GLOB", os.path.join(tmpdir, "log.*")), mock.patch.object(audit_collector, "ARCHIVE_DIR", archive), mock.patch.object(audit_collector, "STATE_FILE", os.path.join(archive, "state.json")):
state = {}
self.assertEqual(audit_collector.collect_once(state), 1)
rotated = f"{active}.old"
os.rename(active, rotated)
with open(active, "w", encoding="utf-8") as handle:
handle.write(line.replace("a.txt", "b.txt"))
self.assertEqual(audit_collector.collect_once(state), 1)
class AuditQueryTests(unittest.TestCase):
def make_event(self, timestamp, user, success=True):
return {
"timestamp": timestamp,
"user": user,
"clientIp": "192.0.2.5",
"share": "Data",
"operation": "pread" if success else "openat",
"action": "read" if success else "metadata",
"path": "folder/file.txt",
"result": "OK" if success else "NT_STATUS_ACCESS_DENIED",
"success": success,
}
def test_queries_plain_and_compressed_days_with_filters_and_cursor(self):
with tempfile.TemporaryDirectory() as tmpdir:
today = dt.datetime.now(dt.timezone.utc).date()
yesterday = today - dt.timedelta(days=1)
current = os.path.join(tmpdir, f"{today.isoformat()}.jsonl")
old = os.path.join(tmpdir, f"{yesterday.isoformat()}.jsonl.gz")
with open(current, "w", encoding="utf-8") as handle:
for index in range(3):
handle.write(json.dumps(self.make_event(f"{today}T12:00:0{index}+00:00", "alice")) + "\n")
handle.write(json.dumps(self.make_event(f"{today}T12:00:04+00:00", "bob", False)) + "\n")
with gzip.open(old, "wt", encoding="utf-8") as handle:
handle.write(json.dumps(self.make_event(f"{yesterday}T12:00:00+00:00", "alice")) + "\n")
with mock.patch.object(web_ui, "AUDIT_ROOT", tmpdir):
first = web_ui.query_audit({"from": [yesterday.isoformat()], "to": [today.isoformat()], "user": ["alice"], "limit": ["2"]})
failed = web_ui.query_audit({"from": [today.isoformat()], "to": [today.isoformat()], "result": ["fail"]})
second = web_ui.query_audit({"from": [yesterday.isoformat()], "to": [today.isoformat()], "user": ["alice"], "limit": ["2"], "cursor": [str(first["nextCursor"])]})
self.assertEqual(first["matched"], 4)
self.assertEqual(len(first["events"]), 2)
self.assertEqual(len(second["events"]), 2)
self.assertEqual(failed["events"][0]["user"], "bob")
class WebPresentationTests(unittest.TestCase):
def asset(self, name):
path = os.path.join(os.path.dirname(__file__), "..", "app", "web", name)
with open(path, encoding="utf-8") as handle:
return handle.read()
def test_login_and_application_views_obey_hidden_attribute(self):
html = self.asset("index.html")
css = self.asset("styles.css")
self.assertIn('id="login-view" class="login-view" hidden', html)
self.assertIn('id="app-view" class="shell" hidden', html)
self.assertIn("[hidden] { display: none !important; }", css)
def test_ui_is_german_plain_utc_and_left_aligns_time(self):
html = self.asset("index.html")
script = self.asset("app.js")
css = self.asset("styles.css")
self.assertIn('<html lang="de">', html)
self.assertIn("Benutzername", html)
self.assertNotIn("nav-group", html)
self.assertIn('>Datenbelegung</a>', html)
self.assertIn('>Benutzerbelegung</a>', html)
self.assertNotIn("brand-mark", html)
self.assertNotIn("eyebrow", html + script)
self.assertIn("getUTCHours()", script)
self.assertIn(" UTC`", script)
self.assertNotIn("localTime", script)
self.assertIn('class="timestamp"', script)
self.assertIn(".timestamp { text-align: left;", css)
class TlsSummaryTests(unittest.TestCase):
@mock.patch("app.web_ui.ssl._ssl._test_decode_cert")
def test_certificate_expiry_is_returned_as_utc_iso_timestamp(self, decode):
decode.return_value = {"notAfter": "Jul 31 12:34:56 2027 GMT"}
self.assertEqual(web_ui.tls_summary()["notAfter"], "2027-07-31T12:34:56+00:00")
class UsageScannerTests(unittest.TestCase):
def test_aggregates_private_and_fslogix_by_user(self):
with tempfile.TemporaryDirectory() as tmpdir:
group_root = os.path.join(tmpdir, "data")
private_root = os.path.join(tmpdir, "private")
fslogix_root = os.path.join(tmpdir, "fslogix")
os.makedirs(os.path.join(group_root, "Finance"))
os.makedirs(os.path.join(private_root, "alice"))
os.makedirs(os.path.join(fslogix_root, "alice_S-1-5-21-1-2-3-1001"))
with open(os.path.join(group_root, "Finance", "a"), "wb") as handle:
handle.write(b"a" * 7)
with open(os.path.join(private_root, "alice", "b"), "wb") as handle:
handle.write(b"b" * 3)
with open(os.path.join(fslogix_root, "alice_S-1-5-21-1-2-3-1001", "c"), "wb") as handle:
handle.write(b"c" * 5)
cache = os.path.join(tmpdir, "usage.json")
env = {"GROUP_ROOT": group_root, "PRIVATE_ROOT": private_root, "FSLOGIX_ROOT": fslogix_root}
with mock.patch.dict(os.environ, env), mock.patch.object(web_ui, "USAGE_CACHE_FILE", cache), mock.patch.object(web_ui, "fslogix_username", return_value="alice"):
value = web_ui.UsageScanner().scan()
self.assertEqual(value["totals"]["dataBytes"], 7)
self.assertEqual(value["users"][0]["privateBytes"], 3)
self.assertEqual(value["users"][0]["fslogixBytes"], 5)
self.assertEqual(value["users"][0]["totalBytes"], 8)
if __name__ == "__main__":
unittest.main()