webui
This commit is contained in:
@@ -0,0 +1,6 @@
|
|||||||
|
.git
|
||||||
|
.env
|
||||||
|
**/__pycache__
|
||||||
|
**/*.py[cod]
|
||||||
|
*.orig
|
||||||
|
*.rej
|
||||||
+23
-1
@@ -11,6 +11,25 @@ FSLOGIX_GROUP_SID=S-1-5-21-1111111111-2222222222-3333333333-513
|
|||||||
# AD_DNS_IP_AUTO=1
|
# AD_DNS_IP_AUTO=1
|
||||||
# SAMBA_HOSTNAME=adsambafsrv
|
# SAMBA_HOSTNAME=adsambafsrv
|
||||||
# NETBIOS_NAME=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_URI=ldaps://example.com
|
||||||
# LDAP_BASE_DN=DC=example,DC=com
|
# LDAP_BASE_DN=DC=example,DC=com
|
||||||
# PRIVATE_SKIP_USERS=svc_backup,svc_sql
|
# 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=smb://DOMAIN%5Cuser:pass@backup.example.com/Backups/samba
|
||||||
# BACKUP_DESTINATION=davfs://user:pass@webdav.example.com/remote.php/dav/files/backup
|
# BACKUP_DESTINATION=davfs://user:pass@webdav.example.com/remote.php/dav/files/backup
|
||||||
# BACKUP_DESTINATION=sftp://user:pass@sftp.example.com/exports/samba
|
# 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_DAILY=3
|
||||||
# BACKUP_RETENTION_WEEKLY=2
|
# BACKUP_RETENTION_WEEKLY=2
|
||||||
# BACKUP_RETENTION_MONTHLY=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_LOG_FILE=/var/log/backup.log
|
||||||
# BACKUP_PROGRESS=auto
|
# BACKUP_PROGRESS=auto
|
||||||
# BACKUP_PROGRESS_INTERVAL_SECONDS=10
|
# BACKUP_PROGRESS_INTERVAL_SECONDS=10
|
||||||
|
# BACKUP_STATUS_FILE=/state/backup-status.json
|
||||||
|
# AUDIT_COMPRESS_AFTER_HOURS=24
|
||||||
|
# AUDIT_QUERY_MAX_DAYS=31
|
||||||
|
|||||||
+8
-1
@@ -1,6 +1,8 @@
|
|||||||
|
FROM smallstep/step-cli:0.30.2 AS step-cli
|
||||||
|
|
||||||
FROM debian:12-slim
|
FROM debian:12-slim
|
||||||
|
|
||||||
ENV DEBIAN_FRONTEND=noninteractive
|
ENV DEBIAN_FRONTEND=noninteractive TZ=Etc/UTC
|
||||||
|
|
||||||
RUN apt-get update \
|
RUN apt-get update \
|
||||||
&& apt-get install -y --no-install-recommends \
|
&& apt-get install -y --no-install-recommends \
|
||||||
@@ -22,13 +24,18 @@ RUN apt-get update \
|
|||||||
&& rm -rf /var/lib/apt/lists/*
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
RUN mkdir -p /app /data/private /data/fslogix /data/groups/data /data/groups/archive /state
|
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/reconcile_shares.py /app/reconcile_shares.py
|
||||||
COPY app/backup_to_destination.py /app/backup_to_destination.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 app/init.sh /app/init.sh
|
||||||
COPY etc/samba/smb.conf /app/smb.conf.template
|
COPY etc/samba/smb.conf /app/smb.conf.template
|
||||||
|
|
||||||
RUN chmod +x /app/init.sh /app/reconcile_shares.py /app/backup_to_destination.py \
|
RUN chmod +x /app/init.sh /app/reconcile_shares.py /app/backup_to_destination.py \
|
||||||
|
/app/audit_collector.py /app/web_ui.py \
|
||||||
&& true
|
&& true
|
||||||
|
|
||||||
ENTRYPOINT ["/usr/bin/tini", "--"]
|
ENTRYPOINT ["/usr/bin/tini", "--"]
|
||||||
|
|||||||
@@ -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.
|
- 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`).
|
- `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.
|
- 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.
|
- Optional remote backups run when `BACKUP_DESTINATION` is configured.
|
||||||
- Private home creation skips well-known/service accounts by default (including `krbtgt`, `msol_*`, `FileShare_ServiceAcc`).
|
- Private home creation skips well-known/service accounts by default (including `krbtgt`, `msol_*`, `FileShare_ServiceAcc`).
|
||||||
- Reconciliation is executed:
|
- Reconciliation is executed:
|
||||||
- once on startup
|
- once on startup
|
||||||
- every 5 minutes via cron
|
- every 5 minutes via cron
|
||||||
- Backup is executed:
|
- 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
|
## Data Folder Lifecycle
|
||||||
|
|
||||||
@@ -95,7 +99,86 @@ Kerberos requires close time alignment.
|
|||||||
- `app/init.sh`
|
- `app/init.sh`
|
||||||
- `app/reconcile_shares.py`
|
- `app/reconcile_shares.py`
|
||||||
- `app/backup_to_destination.py`
|
- `app/backup_to_destination.py`
|
||||||
|
- `app/audit_collector.py`
|
||||||
|
- `app/web_ui.py`
|
||||||
|
- `app/web/`
|
||||||
- `etc/samba/smb.conf`
|
- `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
|
## Setup
|
||||||
|
|
||||||
@@ -124,6 +207,14 @@ Kerberos requires close time alignment.
|
|||||||
- optional `BACKUP_LOG_FILE` (default `/var/log/backup.log`)
|
- optional `BACKUP_LOG_FILE` (default `/var/log/backup.log`)
|
||||||
- optional `BACKUP_PROGRESS` (`auto`, `always`, or `never`; default `auto`)
|
- optional `BACKUP_PROGRESS` (`auto`, `always`, or `never`; default `auto`)
|
||||||
- optional `BACKUP_PROGRESS_INTERVAL_SECONDS` (default `10`)
|
- 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:
|
Optional:
|
||||||
- `SAMBA_HOSTNAME` (defaults to `adsambafsrv`)
|
- `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.
|
- 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.
|
- 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
|
||||||
|
|
||||||
- Backups are enabled only if `BACKUP_DESTINATION` is non-empty.
|
- Backups are enabled only if `BACKUP_DESTINATION` is non-empty.
|
||||||
- Each run creates a timestamped snapshot under `snapshots/YYYYMMDDTHHMMSSZ` at the destination.
|
- 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:
|
- Sources synced to destination on each run:
|
||||||
- `/data/private` -> `data/private`
|
- `/data/private` -> `data/private`
|
||||||
- `/data/groups` -> `data/groups`
|
- `/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.
|
- 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.
|
- 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.
|
- `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.
|
- Rclone-backed destinations cap concurrent file transfers at 12. Rsync remains single-streamed by rsync itself.
|
||||||
- Retention logic:
|
- Retention logic:
|
||||||
- daily: newest N snapshots
|
- daily: newest N snapshots
|
||||||
@@ -237,11 +408,14 @@ Kerberos requires close time alignment.
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
docker compose logs -f samba
|
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 python3 /app/reconcile_shares.py
|
||||||
docker compose exec samba sqlite3 /state/shares.db 'SELECT * FROM shares;'
|
docker compose exec samba sqlite3 /state/shares.db 'SELECT * FROM shares;'
|
||||||
docker compose exec samba testparm -s
|
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/samba/log.*'
|
||||||
docker compose exec samba sh -lc 'tail -n 200 /var/log/backup.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
|
## Troubleshooting
|
||||||
|
|||||||
@@ -0,0 +1,256 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Persist Samba full_audit records as immutable daily NDJSON archives."""
|
||||||
|
|
||||||
|
import datetime as dt
|
||||||
|
import glob
|
||||||
|
import gzip
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import signal
|
||||||
|
import sys
|
||||||
|
import time
|
||||||
|
from typing import Dict, Iterable, Optional
|
||||||
|
|
||||||
|
|
||||||
|
SAMBA_LOG_GLOB = os.getenv("AUDIT_SOURCE_GLOB", "/var/log/samba/log.*")
|
||||||
|
ARCHIVE_DIR = os.getenv("AUDIT_ARCHIVE_DIR", "/state/audit")
|
||||||
|
STATE_FILE = os.path.join(ARCHIVE_DIR, "collector-state.json")
|
||||||
|
POLL_SECONDS = max(0.2, float(os.getenv("AUDIT_POLL_SECONDS", "1")))
|
||||||
|
COMPRESS_AFTER_HOURS = max(
|
||||||
|
1, int(os.getenv("AUDIT_COMPRESS_AFTER_HOURS", "24"))
|
||||||
|
)
|
||||||
|
AUDIT_MARKER_RE = re.compile(r"smbd_audit:\s*(.*)$")
|
||||||
|
AUDIT_PAYLOAD_RE = re.compile(
|
||||||
|
r"^\s*\d{4}/\d{2}/\d{2}\s+\d{2}:\d{2}:\d{2}(?:\.\d+)?\|"
|
||||||
|
)
|
||||||
|
SAMBA_LOG_TIME_RE = re.compile(
|
||||||
|
r"^\s*\[?(\d{4}/\d{2}/\d{2}\s+\d{2}:\d{2}:\d{2}(?:\.\d+)?)"
|
||||||
|
)
|
||||||
|
STOP = False
|
||||||
|
|
||||||
|
|
||||||
|
def log(message: str) -> None:
|
||||||
|
print(f"[audit] {message}", flush=True)
|
||||||
|
|
||||||
|
|
||||||
|
def utc_now() -> dt.datetime:
|
||||||
|
return dt.datetime.now(dt.timezone.utc)
|
||||||
|
|
||||||
|
|
||||||
|
def atomic_json(path: str, value: object) -> None:
|
||||||
|
temp_path = f"{path}.tmp"
|
||||||
|
with open(temp_path, "w", encoding="utf-8") as handle:
|
||||||
|
json.dump(value, handle, separators=(",", ":"), sort_keys=True)
|
||||||
|
handle.flush()
|
||||||
|
os.fsync(handle.fileno())
|
||||||
|
os.replace(temp_path, path)
|
||||||
|
|
||||||
|
|
||||||
|
def load_state() -> Dict[str, Dict[str, object]]:
|
||||||
|
try:
|
||||||
|
with open(STATE_FILE, encoding="utf-8") as handle:
|
||||||
|
value = json.load(handle)
|
||||||
|
if isinstance(value, dict):
|
||||||
|
return value
|
||||||
|
except (OSError, ValueError):
|
||||||
|
pass
|
||||||
|
return {}
|
||||||
|
|
||||||
|
|
||||||
|
def parse_samba_timestamp(raw_line: str, fallback: dt.datetime) -> str:
|
||||||
|
match = SAMBA_LOG_TIME_RE.match(raw_line)
|
||||||
|
if not match:
|
||||||
|
return fallback.isoformat(timespec="milliseconds")
|
||||||
|
try:
|
||||||
|
parsed = dt.datetime.strptime(match.group(1).split(".")[0], "%Y/%m/%d %H:%M:%S")
|
||||||
|
return parsed.replace(tzinfo=dt.timezone.utc).isoformat(timespec="seconds")
|
||||||
|
except ValueError:
|
||||||
|
return fallback.isoformat(timespec="milliseconds")
|
||||||
|
|
||||||
|
|
||||||
|
def action_for(operation: str) -> str:
|
||||||
|
operation = operation.lower()
|
||||||
|
if operation in {
|
||||||
|
"read", "pread", "pread_recv", "pread_send", "recvfile", "sendfile",
|
||||||
|
"offload_read_recv", "offload_read_send",
|
||||||
|
}:
|
||||||
|
return "read"
|
||||||
|
if operation in {
|
||||||
|
"write", "pwrite", "pwrite_recv", "pwrite_send", "ftruncate",
|
||||||
|
"fallocate", "create_file", "mkdirat", "mknodat", "renameat",
|
||||||
|
"unlinkat", "symlinkat", "linkat", "offload_write_recv",
|
||||||
|
"offload_write_send", "fsetxattr", "removexattr", "fremovexattr",
|
||||||
|
"mkdir", "rmdir", "rename", "unlink",
|
||||||
|
}:
|
||||||
|
return "write"
|
||||||
|
if operation in {"opendir", "fdopendir", "readdir", "freaddir_attr", "closedir"}:
|
||||||
|
return "list"
|
||||||
|
if operation in {"connect", "disconnect"}:
|
||||||
|
return "session"
|
||||||
|
return "metadata"
|
||||||
|
|
||||||
|
|
||||||
|
def parse_audit_line(raw_line: str, source: str) -> Optional[Dict[str, object]]:
|
||||||
|
match = AUDIT_MARKER_RE.search(raw_line)
|
||||||
|
if match:
|
||||||
|
payload = match.group(1)
|
||||||
|
elif AUDIT_PAYLOAD_RE.match(raw_line):
|
||||||
|
payload = raw_line.strip()
|
||||||
|
else:
|
||||||
|
return None
|
||||||
|
fields = payload.rstrip("\r\n").split("|")
|
||||||
|
if len(fields) < 8:
|
||||||
|
return None
|
||||||
|
observed_at = utc_now()
|
||||||
|
operation = fields[5].strip()
|
||||||
|
result = fields[6].strip()
|
||||||
|
return {
|
||||||
|
"timestamp": parse_samba_timestamp(raw_line, observed_at),
|
||||||
|
"ingestedAt": observed_at.isoformat(timespec="milliseconds"),
|
||||||
|
"user": fields[1].strip(),
|
||||||
|
"clientIp": fields[2].strip(),
|
||||||
|
"client": fields[3].strip(),
|
||||||
|
"share": fields[4].strip(),
|
||||||
|
"operation": operation,
|
||||||
|
"action": action_for(operation),
|
||||||
|
"result": result,
|
||||||
|
"success": result.upper() == "OK",
|
||||||
|
"path": "|".join(fields[7:]).strip(),
|
||||||
|
"source": os.path.basename(source),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def archive_events(events: Iterable[Dict[str, object]]) -> int:
|
||||||
|
handles: Dict[str, object] = {}
|
||||||
|
count = 0
|
||||||
|
try:
|
||||||
|
for event in events:
|
||||||
|
day = str(event["ingestedAt"])[:10]
|
||||||
|
path = os.path.join(ARCHIVE_DIR, f"{day}.jsonl")
|
||||||
|
handle = handles.get(path)
|
||||||
|
if handle is None:
|
||||||
|
handle = open(path, "a", encoding="utf-8")
|
||||||
|
handles[path] = handle
|
||||||
|
handle.write(json.dumps(event, separators=(",", ":"), sort_keys=True))
|
||||||
|
handle.write("\n")
|
||||||
|
count += 1
|
||||||
|
for handle in handles.values():
|
||||||
|
handle.flush()
|
||||||
|
os.fsync(handle.fileno())
|
||||||
|
finally:
|
||||||
|
for handle in handles.values():
|
||||||
|
handle.close()
|
||||||
|
return count
|
||||||
|
|
||||||
|
|
||||||
|
def read_new_events(path: str, entry: Dict[str, object]):
|
||||||
|
stat = os.stat(path)
|
||||||
|
inode = int(stat.st_ino)
|
||||||
|
previous_inode = int(entry.get("inode", -1))
|
||||||
|
offset = int(entry.get("offset", 0))
|
||||||
|
if previous_inode != inode or stat.st_size < offset:
|
||||||
|
offset = 0
|
||||||
|
|
||||||
|
events = []
|
||||||
|
with open(path, "r", encoding="utf-8", errors="replace") as handle:
|
||||||
|
handle.seek(offset)
|
||||||
|
while True:
|
||||||
|
line_start = handle.tell()
|
||||||
|
line = handle.readline()
|
||||||
|
if not line:
|
||||||
|
break
|
||||||
|
if not line.endswith("\n"):
|
||||||
|
handle.seek(line_start)
|
||||||
|
break
|
||||||
|
event = parse_audit_line(line, path)
|
||||||
|
if event is not None:
|
||||||
|
events.append(event)
|
||||||
|
new_offset = handle.tell()
|
||||||
|
return events, {"inode": inode, "offset": new_offset}
|
||||||
|
|
||||||
|
|
||||||
|
def compress_old_archives() -> None:
|
||||||
|
cutoff = utc_now() - dt.timedelta(hours=COMPRESS_AFTER_HOURS)
|
||||||
|
today = utc_now().date().isoformat()
|
||||||
|
for path in glob.glob(os.path.join(ARCHIVE_DIR, "????-??-??.jsonl")):
|
||||||
|
day = os.path.basename(path)[:10]
|
||||||
|
if day == today:
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
modified = dt.datetime.fromtimestamp(os.path.getmtime(path), dt.timezone.utc)
|
||||||
|
if modified > cutoff:
|
||||||
|
continue
|
||||||
|
target = f"{path}.gz"
|
||||||
|
temp_target = f"{target}.tmp"
|
||||||
|
with open(path, "rb") as source, gzip.open(temp_target, "wb", compresslevel=6) as output:
|
||||||
|
while True:
|
||||||
|
chunk = source.read(1024 * 1024)
|
||||||
|
if not chunk:
|
||||||
|
break
|
||||||
|
output.write(chunk)
|
||||||
|
os.replace(temp_target, target)
|
||||||
|
os.remove(path)
|
||||||
|
log(f"Compressed {os.path.basename(path)}")
|
||||||
|
except OSError as exc:
|
||||||
|
log(f"Unable to compress {path}: {exc}")
|
||||||
|
|
||||||
|
|
||||||
|
def collect_once(state: Dict[str, Dict[str, object]]) -> int:
|
||||||
|
total = 0
|
||||||
|
seen = set()
|
||||||
|
initial_by_inode = {
|
||||||
|
int(entry.get("inode", -1)): entry
|
||||||
|
for entry in state.values()
|
||||||
|
if isinstance(entry, dict) and int(entry.get("inode", -1)) >= 0
|
||||||
|
}
|
||||||
|
for path in sorted(glob.glob(SAMBA_LOG_GLOB)):
|
||||||
|
if not os.path.isfile(path):
|
||||||
|
continue
|
||||||
|
seen.add(path)
|
||||||
|
try:
|
||||||
|
path_entry = state.get(path, {})
|
||||||
|
current_inode = os.stat(path).st_ino
|
||||||
|
if int(path_entry.get("inode", -1)) != current_inode:
|
||||||
|
path_entry = initial_by_inode.get(current_inode, {})
|
||||||
|
events, new_entry = read_new_events(path, path_entry)
|
||||||
|
if events:
|
||||||
|
total += archive_events(events)
|
||||||
|
state[path] = new_entry
|
||||||
|
except OSError as exc:
|
||||||
|
log(f"Unable to read {path}: {exc}")
|
||||||
|
for stale_path in list(state):
|
||||||
|
if stale_path not in seen:
|
||||||
|
state.pop(stale_path, None)
|
||||||
|
atomic_json(STATE_FILE, state)
|
||||||
|
return total
|
||||||
|
|
||||||
|
|
||||||
|
def stop(_signum, _frame) -> None:
|
||||||
|
global STOP
|
||||||
|
STOP = True
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
os.makedirs(ARCHIVE_DIR, mode=0o750, exist_ok=True)
|
||||||
|
signal.signal(signal.SIGTERM, stop)
|
||||||
|
signal.signal(signal.SIGINT, stop)
|
||||||
|
state = load_state()
|
||||||
|
last_compress = 0.0
|
||||||
|
log(f"Watching {SAMBA_LOG_GLOB}")
|
||||||
|
while not STOP:
|
||||||
|
try:
|
||||||
|
count = collect_once(state)
|
||||||
|
if count:
|
||||||
|
log(f"Archived {count} event(s)")
|
||||||
|
if time.monotonic() - last_compress >= 3600:
|
||||||
|
compress_old_archives()
|
||||||
|
last_compress = time.monotonic()
|
||||||
|
except Exception as exc: # pylint: disable=broad-except
|
||||||
|
log(f"Collector cycle failed: {exc}")
|
||||||
|
time.sleep(POLL_SECONDS)
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit(main())
|
||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
import datetime as dt
|
import datetime as dt
|
||||||
import fcntl
|
import fcntl
|
||||||
|
import json
|
||||||
import os
|
import os
|
||||||
import re
|
import re
|
||||||
import subprocess
|
import subprocess
|
||||||
@@ -15,6 +16,7 @@ from urllib.parse import SplitResult, unquote, urlsplit
|
|||||||
|
|
||||||
LOCK_PATH = "/state/backup.lock"
|
LOCK_PATH = "/state/backup.lock"
|
||||||
DEFAULT_BACKUP_LOG_FILE = "/var/log/backup.log"
|
DEFAULT_BACKUP_LOG_FILE = "/var/log/backup.log"
|
||||||
|
DEFAULT_BACKUP_STATUS_FILE = "/state/backup-status.json"
|
||||||
DEFAULT_PROGRESS_MODE = "auto"
|
DEFAULT_PROGRESS_MODE = "auto"
|
||||||
DEFAULT_PROGRESS_INTERVAL_SECONDS = 10
|
DEFAULT_PROGRESS_INTERVAL_SECONDS = 10
|
||||||
PROGRESS_BAR_WIDTH = 28
|
PROGRESS_BAR_WIDTH = 28
|
||||||
@@ -152,6 +154,102 @@ class BackupLogger:
|
|||||||
LOGGER = 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:
|
def configure_logging() -> None:
|
||||||
LOGGER.configure(os.getenv("BACKUP_LOG_FILE", DEFAULT_BACKUP_LOG_FILE).strip())
|
LOGGER.configure(os.getenv("BACKUP_LOG_FILE", DEFAULT_BACKUP_LOG_FILE).strip())
|
||||||
|
|
||||||
@@ -541,12 +639,16 @@ class SyncProgressReporter:
|
|||||||
if progress.percent >= 100.0:
|
if progress.percent >= 100.0:
|
||||||
self._complete_file(progress)
|
self._complete_file(progress)
|
||||||
self._sync_total_progress()
|
self._sync_total_progress()
|
||||||
|
if BACKUP_STATUS is not None:
|
||||||
|
BACKUP_STATUS.progress(self)
|
||||||
|
|
||||||
def finish(self, *, success: bool) -> None:
|
def finish(self, *, success: bool) -> None:
|
||||||
if success:
|
if success:
|
||||||
self.overall_progress.complete_source()
|
self.overall_progress.complete_source()
|
||||||
self._render_dashboard()
|
self._render_dashboard()
|
||||||
self._log_total_progress(self.now(), force=True)
|
self._log_total_progress(self.now(), force=True)
|
||||||
|
if BACKUP_STATUS is not None:
|
||||||
|
BACKUP_STATUS.progress(self)
|
||||||
self._clear_dashboard()
|
self._clear_dashboard()
|
||||||
|
|
||||||
def _known_file_size(self, file_path: Optional[str]) -> Optional[int]:
|
def _known_file_size(self, file_path: Optional[str]) -> Optional[int]:
|
||||||
@@ -1230,6 +1332,7 @@ class RsyncBackend:
|
|||||||
"rsync",
|
"rsync",
|
||||||
"-a",
|
"-a",
|
||||||
"--delete",
|
"--delete",
|
||||||
|
"--mkpath",
|
||||||
"--progress",
|
"--progress",
|
||||||
"--outbuf=L",
|
"--outbuf=L",
|
||||||
f"{source_path}/",
|
f"{source_path}/",
|
||||||
@@ -1345,6 +1448,7 @@ def parse_snapshot_inventory(snapshot_names: List[str]) -> List[Snapshot]:
|
|||||||
|
|
||||||
|
|
||||||
def run_backup() -> int:
|
def run_backup() -> int:
|
||||||
|
global BACKUP_STATUS
|
||||||
destination_url = os.getenv("BACKUP_DESTINATION", "").strip()
|
destination_url = os.getenv("BACKUP_DESTINATION", "").strip()
|
||||||
if not destination_url:
|
if not destination_url:
|
||||||
log("BACKUP_DESTINATION is unset, skipping backup")
|
log("BACKUP_DESTINATION is unset, skipping backup")
|
||||||
@@ -1366,6 +1470,10 @@ def run_backup() -> int:
|
|||||||
interactive_progress = should_show_progress_bar(progress_mode)
|
interactive_progress = should_show_progress_bar(progress_mode)
|
||||||
|
|
||||||
destination = parse_destination(destination_url)
|
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)
|
backend = build_backend(destination)
|
||||||
try:
|
try:
|
||||||
log(f"Starting backup to {redact_destination(destination.raw_url)}")
|
log(f"Starting backup to {redact_destination(destination.raw_url)}")
|
||||||
@@ -1373,11 +1481,17 @@ def run_backup() -> int:
|
|||||||
existing = set(backend.list_snapshots())
|
existing = set(backend.list_snapshots())
|
||||||
snapshot_name = choose_snapshot_name(existing)
|
snapshot_name = choose_snapshot_name(existing)
|
||||||
log(f"Creating snapshot {snapshot_name}")
|
log(f"Creating snapshot {snapshot_name}")
|
||||||
|
BACKUP_STATUS.write(snapshot=snapshot_name, message="Measuring backup payload")
|
||||||
|
|
||||||
log("Measuring backup payload size")
|
log("Measuring backup payload size")
|
||||||
source_sizes, total_bytes = measure_backup_payload(sources)
|
source_sizes, total_bytes = measure_backup_payload(sources)
|
||||||
log(f"Backup payload size: {format_bytes(total_bytes)}")
|
log(f"Backup payload size: {format_bytes(total_bytes)}")
|
||||||
overall_progress = OverallProgress(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:
|
for source_path, destination_path in sources:
|
||||||
log(f"Syncing {source_path}")
|
log(f"Syncing {source_path}")
|
||||||
@@ -1408,6 +1522,9 @@ def run_backup() -> int:
|
|||||||
log(
|
log(
|
||||||
f"Backup completed (snapshots total={len(snapshots)}, retained={len(retained)}, pruned={deleted_count})"
|
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
|
return 0
|
||||||
finally:
|
finally:
|
||||||
backend.close()
|
backend.close()
|
||||||
@@ -1442,6 +1559,8 @@ def main() -> int:
|
|||||||
return with_lock()
|
return with_lock()
|
||||||
except Exception as exc: # pylint: disable=broad-except
|
except Exception as exc: # pylint: disable=broad-except
|
||||||
log(f"ERROR: {exc}")
|
log(f"ERROR: {exc}")
|
||||||
|
if BACKUP_STATUS is not None:
|
||||||
|
BACKUP_STATUS.fail(str(exc))
|
||||||
return 1
|
return 1
|
||||||
finally:
|
finally:
|
||||||
close_logging()
|
close_logging()
|
||||||
|
|||||||
+125
-2
@@ -223,6 +223,9 @@ write_runtime_env_file() {
|
|||||||
if [[ -n "${BACKUP_PROGRESS_INTERVAL_SECONDS:-}" ]]; then
|
if [[ -n "${BACKUP_PROGRESS_INTERVAL_SECONDS:-}" ]]; then
|
||||||
printf 'export BACKUP_PROGRESS_INTERVAL_SECONDS=%q\n' "$BACKUP_PROGRESS_INTERVAL_SECONDS"
|
printf 'export BACKUP_PROGRESS_INTERVAL_SECONDS=%q\n' "$BACKUP_PROGRESS_INTERVAL_SECONDS"
|
||||||
fi
|
fi
|
||||||
|
if [[ -n "${BACKUP_STATUS_FILE:-}" ]]; then
|
||||||
|
printf 'export BACKUP_STATUS_FILE=%q\n' "$BACKUP_STATUS_FILE"
|
||||||
|
fi
|
||||||
if [[ -n "${JOIN_USER:-}" ]]; then
|
if [[ -n "${JOIN_USER:-}" ]]; then
|
||||||
printf 'export JOIN_USER=%q\n' "$JOIN_USER"
|
printf 'export JOIN_USER=%q\n' "$JOIN_USER"
|
||||||
fi
|
fi
|
||||||
@@ -288,6 +291,124 @@ wait_for_winbind() {
|
|||||||
return 0
|
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() {
|
install_cron_job() {
|
||||||
cat > /etc/cron.d/reconcile-shares <<'EOF'
|
cat > /etc/cron.d/reconcile-shares <<'EOF'
|
||||||
SHELL=/bin/bash
|
SHELL=/bin/bash
|
||||||
@@ -323,7 +444,7 @@ if [[ -n "${JOIN_PASSWORD:-}" ]]; then
|
|||||||
export JOIN_PASSWORD
|
export JOIN_PASSWORD
|
||||||
fi
|
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
|
touch /var/log/reconcile.log /var/log/backup.log
|
||||||
|
|
||||||
append_winbind_to_nss
|
append_winbind_to_nss
|
||||||
@@ -348,8 +469,10 @@ write_runtime_env_file
|
|||||||
log 'Running startup reconciliation'
|
log 'Running startup reconciliation'
|
||||||
python3 /app/reconcile_shares.py
|
python3 /app/reconcile_shares.py
|
||||||
|
|
||||||
|
start_observability_services
|
||||||
|
|
||||||
if [[ -n "${BACKUP_DESTINATION:-}" ]]; then
|
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
|
else
|
||||||
log 'BACKUP_DESTINATION is unset; scheduled backup disabled'
|
log 'BACKUP_DESTINATION is unset; scheduled backup disabled'
|
||||||
fi
|
fi
|
||||||
|
|||||||
+343
@@ -0,0 +1,343 @@
|
|||||||
|
"use strict";
|
||||||
|
|
||||||
|
const state = { session: null, timer: null, groups: null, storage: null, activity: null };
|
||||||
|
const loginView = document.querySelector("#login-view");
|
||||||
|
const appView = document.querySelector("#app-view");
|
||||||
|
const content = document.querySelector("#content");
|
||||||
|
const nav = document.querySelector("#navigation");
|
||||||
|
const toast = document.querySelector("#toast");
|
||||||
|
|
||||||
|
const esc = value => String(value ?? "").replace(/[&<>'"]/g, char => ({"&":"&","<":"<",">":">","'":"'",'"':"""}[char]));
|
||||||
|
const bytes = value => {
|
||||||
|
let number = Number(value || 0);
|
||||||
|
const units = ["B", "kB", "MB", "GB", "TB", "PB"];
|
||||||
|
let unit = 0;
|
||||||
|
while (Math.abs(number) >= 1024 && unit < units.length - 1) { number /= 1024; unit += 1; }
|
||||||
|
const digits = number >= 100 || unit === 0 ? 0 : 1;
|
||||||
|
return `${number.toLocaleString("de-DE", {minimumFractionDigits: digits, maximumFractionDigits: digits})} ${units[unit]}`;
|
||||||
|
};
|
||||||
|
const decimal = (value, digits = 1) => Number(value || 0).toLocaleString("de-DE", {minimumFractionDigits: digits, maximumFractionDigits: digits});
|
||||||
|
const utcTime = value => {
|
||||||
|
if (!value) return "—";
|
||||||
|
const date = new Date(value);
|
||||||
|
if (Number.isNaN(date.getTime())) return "—";
|
||||||
|
const pad = number => String(number).padStart(2, "0");
|
||||||
|
return `${pad(date.getUTCDate())}.${pad(date.getUTCMonth() + 1)}.${date.getUTCFullYear()} ${pad(date.getUTCHours())}:${pad(date.getUTCMinutes())}:${pad(date.getUTCSeconds())} UTC`;
|
||||||
|
};
|
||||||
|
const actionLabel = value => ({read: "Lesen", write: "Schreiben", list: "Auflisten", metadata: "Metadaten", session: "Sitzung"}[value] || value || "—");
|
||||||
|
const nodeTypeLabel = value => ({group: "Gruppe", user: "Benutzer", computer: "Computer", unknown: "Unbekannt"}[value] || value);
|
||||||
|
const backupStateLabel = value => ({starting: "Startet", running: "Läuft", completed: "Abgeschlossen", failed: "Fehlgeschlagen", waiting: "Wartet", disabled: "Deaktiviert"}[value] || value || "Unbekannt");
|
||||||
|
function backupMessage(data) {
|
||||||
|
const message = String(data.message || "");
|
||||||
|
if (!message) return data.enabled ? `Täglich um ${String(data.scheduledHour).padStart(2, "0")}:00 UTC geplant.` : "Sicherungen sind nicht eingerichtet.";
|
||||||
|
if (message === "Starting backup") return "Sicherung wird gestartet.";
|
||||||
|
if (message === "Measuring backup payload") return "Sicherungsumfang wird ermittelt.";
|
||||||
|
if (message.startsWith("Backup payload: ")) return `Sicherungsumfang: ${message.slice(16)}`;
|
||||||
|
if (message.startsWith("Syncing ")) return `${message.slice(8)} wird synchronisiert.`;
|
||||||
|
const completed = message.match(/^Backup completed; (\d+) snapshot\(s\) retained$/);
|
||||||
|
if (completed) return `Sicherung abgeschlossen; ${completed[1]} Sicherungsstände werden aufbewahrt.`;
|
||||||
|
if (data.state === "failed") return "Sicherung fehlgeschlagen. Einzelheiten stehen im Sicherungsprotokoll.";
|
||||||
|
if (data.state === "completed") return "Sicherung abgeschlossen.";
|
||||||
|
if (data.state === "running") return "Sicherung läuft.";
|
||||||
|
if (data.state === "starting") return "Sicherung wird gestartet.";
|
||||||
|
return "Kein Laufstatus verfügbar.";
|
||||||
|
}
|
||||||
|
const isoDay = date => date.toISOString().slice(0, 10);
|
||||||
|
const sum = (rows, field) => (rows || []).reduce((total, row) => total + Number(row[field] || 0), 0);
|
||||||
|
|
||||||
|
async function api(path, options = {}) {
|
||||||
|
const response = await fetch(path, {
|
||||||
|
...options,
|
||||||
|
headers: {"Content-Type": "application/json", ...(options.headers || {})},
|
||||||
|
credentials: "same-origin",
|
||||||
|
});
|
||||||
|
let body = {};
|
||||||
|
try { body = await response.json(); } catch (_) { /* no body */ }
|
||||||
|
if (response.status === 401 && path !== "/api/login") {
|
||||||
|
showLogin();
|
||||||
|
throw new Error("Die Sitzung ist abgelaufen. Bitte erneut anmelden.");
|
||||||
|
}
|
||||||
|
if (!response.ok) throw new Error(body.error || `Anfrage fehlgeschlagen (${response.status})`);
|
||||||
|
return body;
|
||||||
|
}
|
||||||
|
|
||||||
|
function notice(message) {
|
||||||
|
toast.textContent = message;
|
||||||
|
toast.hidden = false;
|
||||||
|
window.setTimeout(() => { toast.hidden = true; }, 4000);
|
||||||
|
}
|
||||||
|
|
||||||
|
function showLogin() {
|
||||||
|
clearInterval(state.timer);
|
||||||
|
state.session = null;
|
||||||
|
appView.hidden = true;
|
||||||
|
loginView.hidden = false;
|
||||||
|
document.querySelector("#login-form input[name=username]").focus();
|
||||||
|
}
|
||||||
|
|
||||||
|
function showApp(session) {
|
||||||
|
state.session = {user: session.user, expiresAt: session.expiresAt};
|
||||||
|
document.querySelector("#session-user").textContent = session.user;
|
||||||
|
loginView.hidden = true;
|
||||||
|
appView.hidden = false;
|
||||||
|
navigate(location.pathname === "/" ? "/overview" : location.pathname, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
function setLoading() { content.innerHTML = '<div class="loading">Wird geladen…</div>'; }
|
||||||
|
function pageHead(title, intro, extra = "") {
|
||||||
|
return `<header class="page-head"><div><h1>${esc(title)}</h1><p class="muted">${esc(intro)}</p></div>${extra}</header>`;
|
||||||
|
}
|
||||||
|
function empty(message) { return `<div class="empty">${esc(message)}</div>`; }
|
||||||
|
function badge(text, kind = "") { return `<span class="badge ${kind}">${esc(text)}</span>`; }
|
||||||
|
|
||||||
|
function routeFor(path) {
|
||||||
|
if (path.startsWith("/storage/data")) return "storage-data";
|
||||||
|
if (path.startsWith("/storage/users")) return "storage-users";
|
||||||
|
if (path.startsWith("/shares")) return "shares";
|
||||||
|
if (path.startsWith("/activity")) return "activity";
|
||||||
|
if (path.startsWith("/backup")) return "backup";
|
||||||
|
if (path.startsWith("/system")) return "system";
|
||||||
|
return "overview";
|
||||||
|
}
|
||||||
|
|
||||||
|
async function navigate(path, replace = false) {
|
||||||
|
clearInterval(state.timer);
|
||||||
|
state.timer = null;
|
||||||
|
const route = routeFor(path);
|
||||||
|
if (replace) history.replaceState({}, "", path); else history.pushState({}, "", path);
|
||||||
|
nav.querySelectorAll("a").forEach(link => link.classList.toggle("active", link.dataset.route === route));
|
||||||
|
document.querySelector(".sidebar").classList.remove("open");
|
||||||
|
setLoading();
|
||||||
|
try {
|
||||||
|
if (route === "overview") await renderOverview();
|
||||||
|
if (route === "shares") await renderShares();
|
||||||
|
if (route === "storage-data") await renderStorage("data");
|
||||||
|
if (route === "storage-users") await renderStorage("users");
|
||||||
|
if (route === "activity") await renderActivity();
|
||||||
|
if (route === "backup") await renderBackup();
|
||||||
|
if (route === "system") await renderSystem();
|
||||||
|
content.focus();
|
||||||
|
} catch (error) {
|
||||||
|
content.innerHTML = pageHead("Seite konnte nicht geladen werden", error.message) + `<section class="panel">${empty("Dienststatus prüfen und erneut versuchen.")}</section>`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function eventRows(events) {
|
||||||
|
if (!events?.length) return '<tr><td colspan="7" class="empty">Keine passenden Ereignisse</td></tr>';
|
||||||
|
return events.map(event => `<tr>
|
||||||
|
<td class="timestamp">${esc(utcTime(event.timestamp))}</td>
|
||||||
|
<td>${esc(event.user)}</td><td>${esc(event.clientIp)}</td><td>${esc(event.share)}</td>
|
||||||
|
<td>${badge(actionLabel(event.action || event.operation))}<br><span class="muted">${esc(event.operation)}</span></td>
|
||||||
|
<td class="path">${esc(event.path || "—")}</td>
|
||||||
|
<td>${event.success ? badge("Erfolgreich") : badge(event.result || "Fehlgeschlagen", "error")}</td>
|
||||||
|
</tr>`).join("");
|
||||||
|
}
|
||||||
|
|
||||||
|
async function renderOverview() {
|
||||||
|
const data = await api("/api/overview");
|
||||||
|
const usage = data.usage || {};
|
||||||
|
const totals = usage.totals || {};
|
||||||
|
const backup = data.backup || {};
|
||||||
|
content.innerHTML = pageHead("Übersicht", "Speicherbelegung, Aktivität und Sicherungsstatus.", `<span class="muted">Stand ${esc(utcTime(usage.scannedAt))}</span>`) + `
|
||||||
|
<section class="cards">
|
||||||
|
<article class="card"><span class="label">Daten</span><span class="value">${bytes(totals.dataBytes)}</span></article>
|
||||||
|
<article class="card"><span class="label">Private + FSLogix</span><span class="value">${bytes(Number(totals.privateBytes || 0) + Number(totals.fslogixBytes || 0))}</span></article>
|
||||||
|
<article class="card"><span class="label">Aktive Gruppen</span><span class="value">${esc(data.activeGroups)}</span></article>
|
||||||
|
<article class="card"><span class="label">Ereignisse · 48 Std.</span><span class="value">${esc(data.eventCount)}</span></article>
|
||||||
|
</section>
|
||||||
|
<section class="split">
|
||||||
|
<article class="panel"><div class="panel-head"><h2>Größte Datengruppen</h2><a href="/storage/data" data-nav>Alle anzeigen</a></div>${usageTable((usage.groups || []).slice(0, 7), "group")}</article>
|
||||||
|
<article class="panel"><div class="panel-head"><h2>Sicherung</h2><a href="/backup" data-nav>Details</a></div>
|
||||||
|
<div class="status-line">${badge(backupStateLabel(backup.state || (backup.enabled ? "waiting" : "disabled")), backup.state === "failed" ? "error" : "")}<span class="muted">${esc(backupMessage(backup))}</span></div>
|
||||||
|
<progress class="progress-large" max="100" value="${Number(backup.percent || 0)}"></progress>
|
||||||
|
<div class="numeric">${decimal(backup.percent)} % · ${bytes(backup.transferredBytes)} / ${bytes(backup.totalBytes)}</div>
|
||||||
|
</article>
|
||||||
|
</section>
|
||||||
|
<section class="panel"><div class="panel-head"><h2>Letzte Dateiaktivitäten</h2><a href="/activity" data-nav>Protokoll öffnen</a></div>
|
||||||
|
<div class="table-wrap"><table><thead><tr><th>Zeit (UTC)</th><th>Benutzer</th><th>Client</th><th>Freigabe</th><th>Aktion</th><th>Pfad</th><th>Ergebnis</th></tr></thead><tbody>${eventRows(data.recentEvents)}</tbody></table></div>
|
||||||
|
</section>`;
|
||||||
|
bindInternalLinks();
|
||||||
|
}
|
||||||
|
|
||||||
|
function usageTable(rows, type) {
|
||||||
|
if (!rows.length) return empty("Die erste Speicherprüfung ist noch nicht abgeschlossen.");
|
||||||
|
const maximum = Math.max(...rows.map(row => Number(type === "group" ? row.bytes : row.totalBytes)), 1);
|
||||||
|
return `<div class="table-wrap"><table><thead><tr><th>${type === "group" ? "Gruppenordner" : "Benutzer"}</th>${type === "user" ? "<th>Private</th><th>FSLogix</th>" : ""}<th>Belegung</th><th></th></tr></thead><tbody>${rows.map(row => {
|
||||||
|
const value = Number(type === "group" ? row.bytes : row.totalBytes);
|
||||||
|
return `<tr><td><strong>${esc(row.name)}</strong></td>${type === "user" ? `<td class="numeric">${bytes(row.privateBytes)}</td><td class="numeric">${bytes(row.fslogixBytes)}</td>` : ""}<td class="numeric">${bytes(value)}</td><td class="usage-bar"><progress max="${maximum}" value="${value}"></progress></td></tr>`;
|
||||||
|
}).join("")}</tbody></table></div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function nodeMatches(node, query) {
|
||||||
|
if (!query) return true;
|
||||||
|
if (`${node.name} ${node.sam} ${node.type} ${nodeTypeLabel(node.type)}`.toLowerCase().includes(query)) return true;
|
||||||
|
return (node.members || []).some(child => nodeMatches(child, query));
|
||||||
|
}
|
||||||
|
|
||||||
|
function treeNodes(nodes, query = "") {
|
||||||
|
return nodes.filter(node => nodeMatches(node, query)).map(node => {
|
||||||
|
const children = treeNodes(node.members || [], query);
|
||||||
|
const title = `<span class="kind">${esc(nodeTypeLabel(node.type))}</span> <strong>${esc(node.name)}</strong>${node.sam && node.sam !== node.name ? ` <span class="muted">${esc(node.sam)}</span>` : ""}${node.cycle ? ` ${badge("Zyklus", "warn")}` : ""}`;
|
||||||
|
return children ? `<details ${query ? "open" : ""}><summary>${title}</summary>${children}</details>` : `<div class="leaf">${title}</div>`;
|
||||||
|
}).join("");
|
||||||
|
}
|
||||||
|
|
||||||
|
async function renderShares() {
|
||||||
|
const data = await api("/api/groups");
|
||||||
|
state.groups = data;
|
||||||
|
const groups = data.groups || [];
|
||||||
|
content.innerHTML = pageHead("Dateifreigaben", "FS_*-Ordnergruppen und ihre wirksamen verschachtelten Mitgliedschaften.", `<span class="muted">Verzeichnisstand ${esc(utcTime(data.fetchedAt))}</span>`) + `
|
||||||
|
${data.truncated ? `<p>${badge("Ergebnis gekürzt", "warn")} WEB_MAX_GROUP_NODES erhöhen, um alle Mitglieder anzuzeigen.</p>` : ""}
|
||||||
|
<section class="split">
|
||||||
|
<article class="panel"><div class="panel-head"><h2>Gruppenordner</h2><span>${groups.length}</span></div><input id="group-filter" type="search" placeholder="Gruppen oder Mitglieder filtern"><ul id="group-list" class="list"></ul></article>
|
||||||
|
<article class="panel"><div id="tree-panel"></div></article>
|
||||||
|
</section>`;
|
||||||
|
const list = document.querySelector("#group-list");
|
||||||
|
const tree = document.querySelector("#tree-panel");
|
||||||
|
let selected = groups[0] || null;
|
||||||
|
let query = "";
|
||||||
|
const draw = () => {
|
||||||
|
const visible = groups.filter(group => `${group.name} ${group.sam} ${group.folder}`.toLowerCase().includes(query) || group.members.some(node => nodeMatches(node, query)));
|
||||||
|
if (selected && !visible.includes(selected)) selected = visible[0] || null;
|
||||||
|
list.innerHTML = visible.length ? visible.map(group => `<li><button class="select-row ${group === selected ? "active" : ""}" data-guid="${esc(group.guid)}"><span><strong>${esc(group.folder)}</strong><br><span class="muted">${esc(group.sam)}</span></span><span>${group.userCount} Benutzer<br>${group.groupCount} Gruppen</span></button></li>`).join("") : empty("Keine Gruppe entspricht dem Filter.");
|
||||||
|
if (!selected) tree.innerHTML = empty("Gruppenordner auswählen.");
|
||||||
|
else tree.innerHTML = `<div class="panel-head"><div><h2>${esc(selected.folder)}</h2><span class="muted">${esc(selected.sam)} · ${selected.userCount} wirksame Benutzer</span></div>${selected.active ? badge("Aktiv") : badge("Archiviert", "warn")}</div><div class="tree">${treeNodes(selected.members, query) || empty("Keine direkten Mitglieder")}</div>`;
|
||||||
|
list.querySelectorAll("button[data-guid]").forEach(button => button.addEventListener("click", () => { selected = groups.find(group => group.guid === button.dataset.guid); draw(); }));
|
||||||
|
};
|
||||||
|
document.querySelector("#group-filter").addEventListener("input", event => { query = event.target.value.trim().toLowerCase(); draw(); });
|
||||||
|
draw();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function renderStorage(type) {
|
||||||
|
const data = await api("/api/storage");
|
||||||
|
state.storage = data;
|
||||||
|
let query = "";
|
||||||
|
let sort = "size-desc";
|
||||||
|
content.innerHTML = pageHead(type === "data" ? "Datenbelegung" : "Benutzerbelegung", type === "data" ? "Belegter Speicher je aktivem Gruppenordner unter /Data." : "Gemeinsame Belegung von /Private und /FSLogix je Benutzer.", `<span class="muted">Geprüft ${esc(utcTime(data.scannedAt))} in ${esc(data.scanSeconds || 0)} s</span>`) + `
|
||||||
|
<section class="panel"><div class="toolbar"><input id="storage-filter" type="search" placeholder="${type === "data" ? "Gruppenordner" : "Benutzer"} filtern"><select id="storage-sort"><option value="size-desc">Größte zuerst</option><option value="size-asc">Kleinste zuerst</option><option value="name">Name</option></select></div><div id="storage-table"></div></section>`;
|
||||||
|
const draw = () => {
|
||||||
|
const source = [...(type === "data" ? data.groups || [] : data.users || [])];
|
||||||
|
let rows = source.filter(row => row.name.toLowerCase().includes(query));
|
||||||
|
const field = type === "data" ? "bytes" : "totalBytes";
|
||||||
|
rows.sort((a, b) => sort === "name" ? a.name.localeCompare(b.name) : sort === "size-asc" ? Number(a[field]) - Number(b[field]) : Number(b[field]) - Number(a[field]));
|
||||||
|
document.querySelector("#storage-table").innerHTML = `<p class="muted">${rows.length} Einträge · ${bytes(sum(rows, field))} angezeigt</p>${usageTable(rows, type === "data" ? "group" : "user")}`;
|
||||||
|
};
|
||||||
|
document.querySelector("#storage-filter").addEventListener("input", event => { query = event.target.value.trim().toLowerCase(); draw(); });
|
||||||
|
document.querySelector("#storage-sort").addEventListener("change", event => { sort = event.target.value; draw(); });
|
||||||
|
draw();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function renderActivity() {
|
||||||
|
const today = new Date();
|
||||||
|
const yesterday = new Date(Date.now() - 86400000);
|
||||||
|
content.innerHTML = pageHead("Aktivitätsprotokoll", "Aufgezeichnete Samba-Vorgänge nach Datum, Identität, Freigabe, Aktion, Ergebnis oder Pfad durchsuchen.") + `
|
||||||
|
<section class="panel">
|
||||||
|
<form id="activity-filter" class="filters">
|
||||||
|
<label>Von (UTC)<input name="from" type="date" value="${isoDay(yesterday)}" required></label>
|
||||||
|
<label>Bis (UTC)<input name="to" type="date" value="${isoDay(today)}" required></label>
|
||||||
|
<label>Benutzer<input name="user" list="users-list" placeholder="Alle Benutzer"></label>
|
||||||
|
<label>Freigabe<input name="share" list="shares-list" placeholder="Alle Freigaben"></label>
|
||||||
|
<label>Aktion<select name="action"><option value="">Alle Aktionen</option><option value="read">Lesen</option><option value="write">Schreiben</option><option value="list">Auflisten</option><option value="metadata">Metadaten</option><option value="session">Sitzung</option></select></label>
|
||||||
|
<label>Ergebnis<select name="result"><option value="">Alle Ergebnisse</option><option value="OK">Erfolgreich</option><option value="fail">Fehlgeschlagen</option></select></label>
|
||||||
|
<label>Operation<input name="operation" list="operations-list" placeholder="z. B. pread"></label>
|
||||||
|
<label class="wide">Pfad enthält<input name="path" placeholder="Ordner oder Dateiname"></label>
|
||||||
|
<button type="submit">Filter anwenden</button>
|
||||||
|
</form>
|
||||||
|
<datalist id="users-list"></datalist><datalist id="shares-list"></datalist><datalist id="operations-list"></datalist>
|
||||||
|
<p id="activity-summary" class="muted"></p>
|
||||||
|
<div class="table-wrap"><table><thead><tr><th>Zeit (UTC)</th><th>Benutzer</th><th>Client</th><th>Freigabe</th><th>Aktion</th><th>Pfad</th><th>Ergebnis</th></tr></thead><tbody id="activity-rows"></tbody></table></div>
|
||||||
|
<p><button id="load-more" hidden>Weitere laden</button></p>
|
||||||
|
</section>`;
|
||||||
|
const form = document.querySelector("#activity-filter");
|
||||||
|
let cursor = 0;
|
||||||
|
const load = async append => {
|
||||||
|
const params = new URLSearchParams(new FormData(form));
|
||||||
|
params.set("limit", "100");
|
||||||
|
if (cursor) params.set("cursor", String(cursor));
|
||||||
|
const result = await api(`/api/activity?${params}`);
|
||||||
|
state.activity = result;
|
||||||
|
const rows = document.querySelector("#activity-rows");
|
||||||
|
if (append) rows.insertAdjacentHTML("beforeend", eventRows(result.events)); else rows.innerHTML = eventRows(result.events);
|
||||||
|
document.querySelector("#activity-summary").textContent = `${result.matched.toLocaleString("de-DE")} passende Ereignisse`;
|
||||||
|
const more = document.querySelector("#load-more");
|
||||||
|
cursor = result.nextCursor || 0;
|
||||||
|
more.hidden = !result.nextCursor;
|
||||||
|
for (const [id, values] of [["users-list", result.facets.users], ["shares-list", result.facets.shares], ["operations-list", result.facets.operations]]) {
|
||||||
|
document.querySelector(`#${id}`).innerHTML = values.filter(Boolean).map(value => `<option value="${esc(value)}">`).join("");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
form.addEventListener("submit", async event => { event.preventDefault(); cursor = 0; try { await load(false); } catch (error) { notice(error.message); } });
|
||||||
|
document.querySelector("#load-more").addEventListener("click", async () => { try { await load(true); } catch (error) { notice(error.message); } });
|
||||||
|
await load(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
function backupMarkup(data) {
|
||||||
|
const stateName = data.state || (data.enabled ? "waiting" : "disabled");
|
||||||
|
const active = data.activeFiles || [];
|
||||||
|
return `
|
||||||
|
<section class="cards">
|
||||||
|
<article class="card"><span class="label">Status</span><span class="value">${esc(backupStateLabel(stateName))}</span></article>
|
||||||
|
<article class="card"><span class="label">Fortschritt</span><span class="value">${decimal(data.percent)} %</span></article>
|
||||||
|
<article class="card"><span class="label">Übertragen</span><span class="value">${bytes(data.transferredBytes)}</span></article>
|
||||||
|
<article class="card"><span class="label">Gestartet</span><span class="value">${data.startedAt ? esc(utcTime(data.startedAt)) : "—"}</span></article>
|
||||||
|
</section>
|
||||||
|
<section class="panel"><div class="panel-head"><h2>Aktueller Lauf</h2>${badge(backupStateLabel(stateName), stateName === "failed" ? "error" : "")}</div>
|
||||||
|
<p>${esc(backupMessage(data))}</p>
|
||||||
|
<progress class="progress-large" max="100" value="${Number(data.percent || 0)}"></progress>
|
||||||
|
<div class="status-line"><strong>${bytes(data.transferredBytes)} / ${bytes(data.totalBytes)}</strong><span class="muted">${esc(data.currentSource || "")}</span><span class="muted">${esc(data.snapshot || "")}</span></div>
|
||||||
|
${active.length ? `<h3>Dateien in Bearbeitung</h3><div class="table-wrap"><table><thead><tr><th>Datei</th><th>Fortschritt</th><th>Übertragen</th></tr></thead><tbody>${active.map(file => `<tr><td class="path">${esc(file.path)}</td><td><progress max="100" value="${Number(file.percent || 0)}"></progress></td><td class="numeric">${bytes(file.transferredBytes)} / ${bytes(file.totalBytes)}</td></tr>`).join("")}</tbody></table></div>` : ""}
|
||||||
|
</section>
|
||||||
|
<section class="panel"><div class="panel-head"><h2>Sicherungsprotokoll</h2><span class="muted">Letzte ${data.log?.length || 0} Zeilen</span></div><pre class="log">${esc((data.log || []).join("\n") || "Noch keine Ausgabe vorhanden.")}</pre></section>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function renderBackup() {
|
||||||
|
content.innerHTML = pageHead("Sicherungen", "Live-Fortschritt und letzte Sicherungsausgabe. Auf dieser Seite können Sicherungen weder gestartet noch geändert werden.") + '<div id="backup-body"></div>';
|
||||||
|
const refresh = async () => {
|
||||||
|
try { document.querySelector("#backup-body").innerHTML = backupMarkup(await api("/api/backup")); }
|
||||||
|
catch (error) { notice(error.message); }
|
||||||
|
};
|
||||||
|
await refresh();
|
||||||
|
state.timer = window.setInterval(refresh, 2000);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function renderSystem() {
|
||||||
|
const data = await api("/api/system");
|
||||||
|
const usage = data.usage || {};
|
||||||
|
content.innerHTML = pageHead("System", "Status von Diensten, Zertifikat, Protokollerfassung und Speicherprüfung.", `<span class="muted">Serverzeit ${esc(utcTime(data.serverTime))}</span>`) + `
|
||||||
|
<section class="panel"><div class="panel-head"><h2>Dienstprüfungen</h2><span>${esc(data.hostname)}</span></div><div class="check-list">
|
||||||
|
<div class="check"><span>Domänenvertrauen</span>${data.checks.domainTrust ? badge("In Ordnung") : badge("Fehlgeschlagen", "error")}</div>
|
||||||
|
<div class="check"><span>Samba-Konfiguration</span>${data.checks.sambaConfig ? badge("Gültig") : badge("Fehlgeschlagen", "error")}</div>
|
||||||
|
<div class="check"><span>Aktivitätsarchiv</span><strong>${data.audit.days} Tage · ${bytes(data.audit.bytes)}</strong></div>
|
||||||
|
<div class="check"><span>Speicherprüfung</span><strong>${usage.scannedAt ? esc(utcTime(usage.scannedAt)) : "Ausstehend"}</strong></div>
|
||||||
|
</div></section>
|
||||||
|
<section class="split">
|
||||||
|
<article class="panel"><h2>TLS-Zertifikat</h2><dl><dt>Allgemeiner Name</dt><dd>${esc(data.tls.subject?.commonName || "—")}</dd><dt>Aussteller</dt><dd>${esc(data.tls.issuer?.commonName || "—")}</dd><dt>Gültig bis</dt><dd>${esc(utcTime(data.tls.notAfter))}</dd><dt>Namen</dt><dd>${esc((data.tls.sans || []).map(value => value[1]).join(", ") || "—")}</dd></dl></article>
|
||||||
|
<article class="panel"><h2>Protokollaufbewahrung</h2><dl><dt>Ältester UTC-Tag</dt><dd>${esc(data.audit.oldest || "—")}</dd><dt>Neuester UTC-Tag</dt><dd>${esc(data.audit.newest || "—")}</dd><dt>Archivgröße</dt><dd>${bytes(data.audit.bytes)}</dd></dl><p class="muted">Abgeschlossene Tagesdateien werden automatisch komprimiert und aufbewahrt, bis ein Administrator sie entfernt.</p></article>
|
||||||
|
</section>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function bindInternalLinks() {
|
||||||
|
content.querySelectorAll("a[data-nav]").forEach(link => link.addEventListener("click", event => { event.preventDefault(); navigate(link.pathname); }));
|
||||||
|
}
|
||||||
|
|
||||||
|
document.querySelector("#login-form").addEventListener("submit", async event => {
|
||||||
|
event.preventDefault();
|
||||||
|
const form = event.currentTarget;
|
||||||
|
const button = form.querySelector("button");
|
||||||
|
const error = document.querySelector("#login-error");
|
||||||
|
error.hidden = true; button.disabled = true;
|
||||||
|
try {
|
||||||
|
const values = new FormData(form);
|
||||||
|
const session = await api("/api/login", {method: "POST", body: JSON.stringify({username: values.get("username"), password: values.get("password")})});
|
||||||
|
form.reset();
|
||||||
|
showApp(session);
|
||||||
|
} catch (reason) { error.textContent = reason.message; error.hidden = false; }
|
||||||
|
finally { button.disabled = false; }
|
||||||
|
});
|
||||||
|
|
||||||
|
document.querySelector("#logout").addEventListener("click", async () => { try { await api("/api/logout", {method: "POST", body: "{}"}); } finally { showLogin(); } });
|
||||||
|
document.querySelector("#menu-toggle").addEventListener("click", () => document.querySelector(".sidebar").classList.toggle("open"));
|
||||||
|
nav.addEventListener("click", event => { const link = event.target.closest("a"); if (link) { event.preventDefault(); navigate(link.pathname); } });
|
||||||
|
window.addEventListener("popstate", () => navigate(location.pathname, true));
|
||||||
|
|
||||||
|
api("/api/session").then(showApp).catch(showLogin);
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64"><rect width="64" height="64" rx="12" fill="#205c46"/><path fill="#fff" d="M15 16h35v8H25v8h21v8H25v16H15zm31 28h8v8h-8z"/></svg>
|
||||||
|
After Width: | Height: | Size: 189 B |
@@ -0,0 +1,45 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="de">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<meta name="color-scheme" content="light">
|
||||||
|
<title>Dateiserver-Verwaltung</title>
|
||||||
|
<link rel="icon" href="/favicon.svg">
|
||||||
|
<link rel="stylesheet" href="/assets/styles.css">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<section id="login-view" class="login-view" hidden>
|
||||||
|
<main class="login-card">
|
||||||
|
<h1>Dateiserver-Verwaltung</h1>
|
||||||
|
<p>Anmeldung nur für Domänenadministratoren. Ohne Domänenangabe wird automatisch die konfigurierte NetBIOS-Domäne verwendet.</p>
|
||||||
|
<form id="login-form" class="stack">
|
||||||
|
<label>Benutzername<input name="username" autocomplete="username" required autofocus placeholder="benutzername"></label>
|
||||||
|
<label>Passwort<input name="password" type="password" autocomplete="current-password" required></label>
|
||||||
|
<p id="login-error" class="error" role="alert" hidden></p>
|
||||||
|
<button type="submit">Anmelden</button>
|
||||||
|
</form>
|
||||||
|
</main>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<div id="app-view" class="shell" hidden>
|
||||||
|
<aside class="sidebar">
|
||||||
|
<div class="brand">Dateiserver</div>
|
||||||
|
<nav id="navigation" aria-label="Hauptnavigation">
|
||||||
|
<a href="/overview" data-route="overview">Übersicht</a>
|
||||||
|
<a href="/shares" data-route="shares">Dateifreigaben</a>
|
||||||
|
<a href="/storage/data" data-route="storage-data">Datenbelegung</a>
|
||||||
|
<a href="/storage/users" data-route="storage-users">Benutzerbelegung</a>
|
||||||
|
<a href="/activity" data-route="activity">Aktivitätsprotokoll</a>
|
||||||
|
<a href="/backup" data-route="backup">Sicherungen</a>
|
||||||
|
<a href="/system" data-route="system">System</a>
|
||||||
|
</nav>
|
||||||
|
<div class="sidebar-footer"><span id="session-user"></span><button id="logout" class="link-button">Abmelden</button></div>
|
||||||
|
</aside>
|
||||||
|
<header class="mobile-header"><button id="menu-toggle" aria-label="Navigation ein- oder ausblenden">Menü</button><strong>Dateiserver</strong></header>
|
||||||
|
<main id="content" class="content" tabindex="-1"></main>
|
||||||
|
</div>
|
||||||
|
<div id="toast" class="toast" role="status" hidden></div>
|
||||||
|
<script src="/assets/app.js" defer></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,94 @@
|
|||||||
|
:root {
|
||||||
|
font: 15px/1.4 Arial, Helvetica, sans-serif;
|
||||||
|
color: #111;
|
||||||
|
background: #fff;
|
||||||
|
}
|
||||||
|
* { box-sizing: border-box; }
|
||||||
|
[hidden] { display: none !important; }
|
||||||
|
body { margin: 0; min-height: 100vh; }
|
||||||
|
a { color: #0645ad; }
|
||||||
|
button, input, select { font: inherit; }
|
||||||
|
button { padding: .4rem .7rem; border: 1px solid #777; color: #111; background: #eee; cursor: pointer; }
|
||||||
|
button:disabled { color: #777; cursor: wait; }
|
||||||
|
input, select { width: 100%; padding: .4rem; border: 1px solid #999; background: #fff; }
|
||||||
|
button:focus, input:focus, select:focus, a:focus { outline: 2px solid #0645ad; outline-offset: 1px; }
|
||||||
|
label { display: grid; gap: .2rem; }
|
||||||
|
h1, h2, h3, p { margin-top: 0; }
|
||||||
|
h1 { margin-bottom: .25rem; font-size: 1.6rem; }
|
||||||
|
h2 { font-size: 1.15rem; }
|
||||||
|
h3 { font-size: 1rem; }
|
||||||
|
.muted { color: #666; }
|
||||||
|
.error { margin: 0; color: #900; }
|
||||||
|
.stack { display: grid; gap: .8rem; }
|
||||||
|
.login-view { padding: 2rem; }
|
||||||
|
.login-card { max-width: 28rem; margin: 4rem auto; }
|
||||||
|
.shell { min-height: 100vh; }
|
||||||
|
.sidebar { position: fixed; inset: 0 auto 0 0; display: flex; width: 220px; flex-direction: column; border-right: 1px solid #aaa; background: #eee; }
|
||||||
|
.brand { padding: 1rem; border-bottom: 1px solid #aaa; font-weight: bold; }
|
||||||
|
nav { display: grid; padding: .5rem; }
|
||||||
|
nav a { padding: .45rem .5rem; color: #111; text-decoration: none; }
|
||||||
|
nav a:hover { text-decoration: underline; }
|
||||||
|
nav a.active { font-weight: bold; background: #ddd; }
|
||||||
|
.sidebar-footer { display: grid; gap: .4rem; margin-top: auto; padding: 1rem; border-top: 1px solid #aaa; overflow-wrap: anywhere; }
|
||||||
|
.link-button { width: fit-content; padding: 0; border: 0; color: #0645ad; background: transparent; text-decoration: underline; }
|
||||||
|
.content { min-height: 100vh; margin-left: 220px; padding: 1.5rem 2rem 3rem; }
|
||||||
|
.mobile-header { display: none; }
|
||||||
|
.page-head { display: flex; align-items: flex-start; justify-content: space-between; gap: 1rem; margin-bottom: 1.2rem; border-bottom: 1px solid #aaa; padding-bottom: .8rem; }
|
||||||
|
.page-head p { margin: 0; }
|
||||||
|
.cards { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: .75rem; margin-bottom: 1rem; }
|
||||||
|
.card, .panel { border: 1px solid #aaa; background: #fff; }
|
||||||
|
.card { padding: .75rem; }
|
||||||
|
.card .value { display: block; margin-top: .2rem; font-size: 1.3rem; font-weight: bold; }
|
||||||
|
.card .label { color: #555; }
|
||||||
|
.panel { margin-bottom: 1rem; padding: .8rem; overflow: hidden; }
|
||||||
|
.panel-head { display: flex; align-items: center; justify-content: space-between; gap: 1rem; margin-bottom: .7rem; }
|
||||||
|
.panel-head h2 { margin: 0; }
|
||||||
|
.split { display: grid; grid-template-columns: minmax(260px, .7fr) minmax(380px, 1.3fr); gap: 1rem; }
|
||||||
|
.filters { display: grid; grid-template-columns: repeat(6, minmax(110px, 1fr)); gap: .6rem; margin-bottom: 1rem; align-items: end; }
|
||||||
|
.filters .wide { grid-column: span 2; }
|
||||||
|
.table-wrap { width: 100%; overflow-x: auto; }
|
||||||
|
table { width: 100%; border-collapse: collapse; font-size: .9rem; }
|
||||||
|
th { text-align: left; }
|
||||||
|
.timestamp { text-align: left; font-variant-numeric: tabular-nums; white-space: nowrap; }
|
||||||
|
th, td { padding: .5rem; border-bottom: 1px solid #ccc; vertical-align: top; }
|
||||||
|
.numeric { text-align: right; font-variant-numeric: tabular-nums; white-space: nowrap; }
|
||||||
|
.path { max-width: 460px; overflow-wrap: anywhere; font-family: monospace; }
|
||||||
|
.badge { display: inline-block; font-weight: bold; white-space: nowrap; }
|
||||||
|
.badge.error { color: #900; }
|
||||||
|
.badge.warn { color: #750; }
|
||||||
|
.toolbar { display: flex; flex-wrap: wrap; gap: .5rem; }
|
||||||
|
.toolbar input { max-width: 340px; }
|
||||||
|
.list { margin: 0; padding: 0; list-style: none; }
|
||||||
|
.select-row { width: 100%; display: grid; grid-template-columns: 1fr auto; gap: .5rem; border: 0; border-bottom: 1px solid #ccc; background: #fff; text-align: left; }
|
||||||
|
.select-row.active { font-weight: bold; background: #eee; }
|
||||||
|
.select-row span:last-child { color: #555; font-size: .85rem; }
|
||||||
|
.tree { max-height: 65vh; overflow: auto; }
|
||||||
|
.tree details { margin-left: 1rem; padding-left: .5rem; border-left: 1px solid #bbb; }
|
||||||
|
.tree > details { margin-left: 0; border-left: 0; }
|
||||||
|
.tree summary, .tree .leaf { padding: .2rem 0; }
|
||||||
|
.tree .leaf { margin-left: 1.5rem; }
|
||||||
|
.tree .kind { display: inline-block; min-width: 4.5rem; color: #555; }
|
||||||
|
.empty { padding: 1rem 0; color: #666; }
|
||||||
|
progress { width: 100%; }
|
||||||
|
.usage-bar { min-width: 160px; }
|
||||||
|
.progress-large { margin: .7rem 0; }
|
||||||
|
.status-line { display: flex; flex-wrap: wrap; align-items: center; gap: .6rem; }
|
||||||
|
.log { max-height: 370px; margin: 0; padding: .75rem; overflow: auto; border: 1px solid #aaa; background: #f5f5f5; font: .85rem/1.45 monospace; white-space: pre-wrap; overflow-wrap: anywhere; }
|
||||||
|
.check-list { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: .5rem; }
|
||||||
|
.check { display: flex; justify-content: space-between; padding: .5rem; border-bottom: 1px solid #ccc; }
|
||||||
|
.toast { position: fixed; right: 1rem; bottom: 1rem; max-width: 420px; padding: .7rem; border: 1px solid #777; background: #fff; }
|
||||||
|
.loading { padding: 2rem 0; color: #666; }
|
||||||
|
@media (max-width: 1000px) {
|
||||||
|
.cards { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
||||||
|
.filters { grid-template-columns: repeat(3, minmax(120px, 1fr)); }
|
||||||
|
.split { grid-template-columns: 1fr; }
|
||||||
|
}
|
||||||
|
@media (max-width: 700px) {
|
||||||
|
.sidebar { display: none; z-index: 5; }
|
||||||
|
.sidebar.open { display: flex; }
|
||||||
|
.mobile-header { display: flex; gap: 1rem; padding: .6rem 1rem; border-bottom: 1px solid #aaa; }
|
||||||
|
.content { margin-left: 0; padding: 1rem; }
|
||||||
|
.cards, .filters, .check-list { grid-template-columns: 1fr; }
|
||||||
|
.filters .wide { grid-column: span 1; }
|
||||||
|
.page-head { display: block; }
|
||||||
|
}
|
||||||
+916
@@ -0,0 +1,916 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Read-only HTTPS administration UI for the AD-integrated file server."""
|
||||||
|
|
||||||
|
import base64
|
||||||
|
import datetime as dt
|
||||||
|
import gzip
|
||||||
|
import hashlib
|
||||||
|
import hmac
|
||||||
|
import http.cookies
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import pwd
|
||||||
|
import re
|
||||||
|
import secrets
|
||||||
|
import sqlite3
|
||||||
|
import ssl
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
import tempfile
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
|
import urllib.parse
|
||||||
|
from collections import deque
|
||||||
|
from http import HTTPStatus
|
||||||
|
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||||
|
from typing import Dict, Iterable, List, Optional, Tuple
|
||||||
|
|
||||||
|
try:
|
||||||
|
from app import reconcile_shares as directory
|
||||||
|
except ImportError: # Container execution uses /app as the import root.
|
||||||
|
import reconcile_shares as directory
|
||||||
|
|
||||||
|
|
||||||
|
STATE_ROOT = os.getenv("WEB_STATE_DIR", "/state/web")
|
||||||
|
AUDIT_ROOT = os.getenv("AUDIT_ARCHIVE_DIR", "/state/audit")
|
||||||
|
STATIC_ROOT = os.getenv("WEB_STATIC_DIR", "/app/web")
|
||||||
|
SHARE_DB = os.getenv("SHARE_DB_PATH", "/state/shares.db")
|
||||||
|
BACKUP_STATUS_FILE = os.getenv("BACKUP_STATUS_FILE", "/state/backup-status.json")
|
||||||
|
BACKUP_LOG_FILE = os.getenv("BACKUP_LOG_FILE", "/var/log/backup.log")
|
||||||
|
TLS_CERT_FILE = os.getenv("WEB_TLS_CERT_FILE", "/state/tls/web.crt")
|
||||||
|
TLS_KEY_FILE = os.getenv("WEB_TLS_KEY_FILE", "/state/tls/web.key")
|
||||||
|
USAGE_CACHE_FILE = os.path.join(STATE_ROOT, "usage.json")
|
||||||
|
JWT_COOKIE = "adfs_session"
|
||||||
|
JWT_ISSUER = "ad-file-server-web"
|
||||||
|
JWT_AUDIENCE = "domain-admins"
|
||||||
|
DATE_RE = re.compile(r"^\d{4}-\d{2}-\d{2}$")
|
||||||
|
SID_RE = re.compile(r"S-\d+(?:-\d+)+", re.IGNORECASE)
|
||||||
|
LOGIN_LIMIT: Dict[str, deque] = {}
|
||||||
|
LOGIN_LIMIT_LOCK = threading.Lock()
|
||||||
|
|
||||||
|
|
||||||
|
def log(message: str) -> None:
|
||||||
|
print(f"[web] {message}", flush=True)
|
||||||
|
|
||||||
|
|
||||||
|
def now_utc() -> dt.datetime:
|
||||||
|
return dt.datetime.now(dt.timezone.utc)
|
||||||
|
|
||||||
|
|
||||||
|
def env_int(name: str, default: int, minimum: int, maximum: int) -> int:
|
||||||
|
try:
|
||||||
|
return max(minimum, min(maximum, int(os.getenv(name, str(default)))))
|
||||||
|
except ValueError:
|
||||||
|
return default
|
||||||
|
|
||||||
|
|
||||||
|
def atomic_json(path: str, value: object) -> None:
|
||||||
|
os.makedirs(os.path.dirname(path), exist_ok=True)
|
||||||
|
temp = f"{path}.tmp"
|
||||||
|
with open(temp, "w", encoding="utf-8") as handle:
|
||||||
|
json.dump(value, handle, separators=(",", ":"), sort_keys=True)
|
||||||
|
handle.flush()
|
||||||
|
os.fsync(handle.fileno())
|
||||||
|
os.replace(temp, path)
|
||||||
|
|
||||||
|
|
||||||
|
def read_json(path: str, default):
|
||||||
|
try:
|
||||||
|
with open(path, encoding="utf-8") as handle:
|
||||||
|
return json.load(handle)
|
||||||
|
except (OSError, ValueError):
|
||||||
|
return default
|
||||||
|
|
||||||
|
|
||||||
|
def base64url(value: bytes) -> str:
|
||||||
|
return base64.urlsafe_b64encode(value).rstrip(b"=").decode("ascii")
|
||||||
|
|
||||||
|
|
||||||
|
def base64url_decode(value: str) -> bytes:
|
||||||
|
return base64.urlsafe_b64decode(value + "=" * (-len(value) % 4))
|
||||||
|
|
||||||
|
|
||||||
|
class TokenManager:
|
||||||
|
def __init__(self, secret: str, ttl_seconds: int):
|
||||||
|
if len(secret.encode("utf-8")) < 32:
|
||||||
|
raise RuntimeError("WEB_JWT_SECRET must contain at least 32 bytes")
|
||||||
|
self.secret = secret.encode("utf-8")
|
||||||
|
self.ttl_seconds = ttl_seconds
|
||||||
|
|
||||||
|
def issue(self, username: str) -> Tuple[str, int]:
|
||||||
|
issued = int(time.time())
|
||||||
|
expires = issued + self.ttl_seconds
|
||||||
|
header = {"alg": "HS256", "typ": "JWT"}
|
||||||
|
payload = {
|
||||||
|
"iss": JWT_ISSUER,
|
||||||
|
"aud": JWT_AUDIENCE,
|
||||||
|
"sub": username,
|
||||||
|
"role": "domain-admin",
|
||||||
|
"iat": issued,
|
||||||
|
"exp": expires,
|
||||||
|
"jti": secrets.token_urlsafe(16),
|
||||||
|
}
|
||||||
|
signing_input = ".".join(
|
||||||
|
[
|
||||||
|
base64url(json.dumps(header, separators=(",", ":")).encode()),
|
||||||
|
base64url(json.dumps(payload, separators=(",", ":")).encode()),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
signature = hmac.new(self.secret, signing_input.encode(), hashlib.sha256).digest()
|
||||||
|
return f"{signing_input}.{base64url(signature)}", expires
|
||||||
|
|
||||||
|
def verify(self, token: str) -> Dict[str, object]:
|
||||||
|
try:
|
||||||
|
encoded_header, encoded_payload, encoded_signature = token.split(".")
|
||||||
|
signing_input = f"{encoded_header}.{encoded_payload}"
|
||||||
|
expected = hmac.new(
|
||||||
|
self.secret, signing_input.encode(), hashlib.sha256
|
||||||
|
).digest()
|
||||||
|
supplied = base64url_decode(encoded_signature)
|
||||||
|
if not hmac.compare_digest(expected, supplied):
|
||||||
|
raise ValueError("signature")
|
||||||
|
header = json.loads(base64url_decode(encoded_header))
|
||||||
|
payload = json.loads(base64url_decode(encoded_payload))
|
||||||
|
if header != {"alg": "HS256", "typ": "JWT"}:
|
||||||
|
raise ValueError("header")
|
||||||
|
if payload.get("iss") != JWT_ISSUER or payload.get("aud") != JWT_AUDIENCE:
|
||||||
|
raise ValueError("issuer")
|
||||||
|
if payload.get("role") != "domain-admin":
|
||||||
|
raise ValueError("role")
|
||||||
|
if int(payload.get("exp", 0)) <= int(time.time()):
|
||||||
|
raise ValueError("expired")
|
||||||
|
if int(payload.get("iat", 0)) > int(time.time()) + 60:
|
||||||
|
raise ValueError("issued")
|
||||||
|
return payload
|
||||||
|
except (TypeError, ValueError, KeyError, json.JSONDecodeError) as exc:
|
||||||
|
raise ValueError("Invalid or expired session") from exc
|
||||||
|
|
||||||
|
|
||||||
|
def normalize_username(username: str) -> str:
|
||||||
|
username = username.strip()
|
||||||
|
if not username or len(username) > 256 or any(char in username for char in "\r\n\0"):
|
||||||
|
raise ValueError("Invalid username")
|
||||||
|
if "\\" not in username and "@" not in username:
|
||||||
|
username = f"{os.environ['WORKGROUP']}\\{username}"
|
||||||
|
return username
|
||||||
|
|
||||||
|
|
||||||
|
def authenticate_domain_admin(username: str, password: str) -> Optional[str]:
|
||||||
|
if not password or len(password) > 4096 or any(char in password for char in "\r\n\0"):
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
qualified = normalize_username(username)
|
||||||
|
except ValueError:
|
||||||
|
return None
|
||||||
|
|
||||||
|
workgroup = os.environ["WORKGROUP"]
|
||||||
|
realm = os.environ["REALM"]
|
||||||
|
if "\\" in qualified:
|
||||||
|
domain_name, account = qualified.split("\\", 1)
|
||||||
|
if domain_name.casefold() != workgroup.casefold():
|
||||||
|
return None
|
||||||
|
else:
|
||||||
|
account, principal_realm = qualified.rsplit("@", 1)
|
||||||
|
if principal_realm.casefold() != realm.casefold():
|
||||||
|
return None
|
||||||
|
if not account:
|
||||||
|
return None
|
||||||
|
canonical_name = f"{workgroup}\\{account}"
|
||||||
|
principal = f"{account}@{realm}"
|
||||||
|
|
||||||
|
cache_fd = -1
|
||||||
|
cache_path = ""
|
||||||
|
try:
|
||||||
|
cache_fd, cache_path = tempfile.mkstemp(
|
||||||
|
prefix="web-auth-", dir=os.getenv("WEB_AUTH_CACHE_DIR", "/tmp")
|
||||||
|
)
|
||||||
|
os.close(cache_fd)
|
||||||
|
cache_fd = -1
|
||||||
|
command_env = os.environ.copy()
|
||||||
|
command_env["KRB5CCNAME"] = f"FILE:{cache_path}"
|
||||||
|
auth_result = subprocess.run(
|
||||||
|
["kinit", principal],
|
||||||
|
input=f"{password}\n",
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
env=command_env,
|
||||||
|
timeout=15,
|
||||||
|
check=False,
|
||||||
|
)
|
||||||
|
except (OSError, subprocess.TimeoutExpired):
|
||||||
|
return None
|
||||||
|
finally:
|
||||||
|
if cache_fd >= 0:
|
||||||
|
os.close(cache_fd)
|
||||||
|
if cache_path:
|
||||||
|
try:
|
||||||
|
os.remove(cache_path)
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
if auth_result.returncode != 0:
|
||||||
|
return None
|
||||||
|
|
||||||
|
try:
|
||||||
|
sid_result = subprocess.run(
|
||||||
|
["wbinfo", "--name-to-sid", canonical_name],
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
timeout=15,
|
||||||
|
check=False,
|
||||||
|
)
|
||||||
|
user_sid_match = SID_RE.search(sid_result.stdout)
|
||||||
|
if sid_result.returncode != 0 or user_sid_match is None:
|
||||||
|
return None
|
||||||
|
group_result = subprocess.run(
|
||||||
|
["wbinfo", "--user-sids", user_sid_match.group(0)],
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
timeout=15,
|
||||||
|
check=False,
|
||||||
|
)
|
||||||
|
except (OSError, subprocess.TimeoutExpired):
|
||||||
|
return None
|
||||||
|
if group_result.returncode != 0:
|
||||||
|
return None
|
||||||
|
admin_sid = os.environ["DOMAIN_ADMINS_SID"].casefold()
|
||||||
|
group_sids = {value.casefold() for value in SID_RE.findall(group_result.stdout)}
|
||||||
|
return canonical_name if admin_sid in group_sids else None
|
||||||
|
|
||||||
|
|
||||||
|
def login_allowed(remote: str) -> bool:
|
||||||
|
now = time.monotonic()
|
||||||
|
window = 300
|
||||||
|
limit = env_int("WEB_LOGIN_ATTEMPTS_PER_5_MIN", 10, 3, 100)
|
||||||
|
with LOGIN_LIMIT_LOCK:
|
||||||
|
attempts = LOGIN_LIMIT.setdefault(remote, deque())
|
||||||
|
while attempts and now - attempts[0] > window:
|
||||||
|
attempts.popleft()
|
||||||
|
return len(attempts) < limit
|
||||||
|
|
||||||
|
|
||||||
|
def record_login_failure(remote: str) -> None:
|
||||||
|
with LOGIN_LIMIT_LOCK:
|
||||||
|
LOGIN_LIMIT.setdefault(remote, deque()).append(time.monotonic())
|
||||||
|
|
||||||
|
|
||||||
|
def clear_login_failures(remote: str) -> None:
|
||||||
|
with LOGIN_LIMIT_LOCK:
|
||||||
|
LOGIN_LIMIT.pop(remote, None)
|
||||||
|
|
||||||
|
|
||||||
|
def path_size(path: str) -> int:
|
||||||
|
total = 0
|
||||||
|
stack = [path]
|
||||||
|
while stack:
|
||||||
|
current = stack.pop()
|
||||||
|
try:
|
||||||
|
with os.scandir(current) as entries:
|
||||||
|
for entry in entries:
|
||||||
|
try:
|
||||||
|
if entry.is_symlink():
|
||||||
|
continue
|
||||||
|
if entry.is_dir(follow_symlinks=False):
|
||||||
|
stack.append(entry.path)
|
||||||
|
elif entry.is_file(follow_symlinks=False):
|
||||||
|
total += entry.stat(follow_symlinks=False).st_size
|
||||||
|
except OSError:
|
||||||
|
continue
|
||||||
|
except OSError:
|
||||||
|
continue
|
||||||
|
return total
|
||||||
|
|
||||||
|
|
||||||
|
def fslogix_username(entry: os.DirEntry) -> str:
|
||||||
|
try:
|
||||||
|
owner = pwd.getpwuid(entry.stat(follow_symlinks=False).st_uid).pw_name
|
||||||
|
owner = owner.split("\\")[-1]
|
||||||
|
if owner.lower() not in {"root", "nobody"} and not owner.isdigit():
|
||||||
|
return owner
|
||||||
|
except (KeyError, OSError):
|
||||||
|
pass
|
||||||
|
name = re.sub(r"_S-1-\d+(?:-\d+)+$", "", entry.name, flags=re.IGNORECASE)
|
||||||
|
return name or entry.name
|
||||||
|
|
||||||
|
|
||||||
|
def scan_children(root: str) -> List[Dict[str, object]]:
|
||||||
|
rows = []
|
||||||
|
try:
|
||||||
|
entries = sorted(os.scandir(root), key=lambda item: item.name.casefold())
|
||||||
|
except OSError:
|
||||||
|
return rows
|
||||||
|
for entry in entries:
|
||||||
|
try:
|
||||||
|
if not entry.is_dir(follow_symlinks=False):
|
||||||
|
continue
|
||||||
|
rows.append({"name": entry.name, "bytes": path_size(entry.path)})
|
||||||
|
except OSError:
|
||||||
|
continue
|
||||||
|
return rows
|
||||||
|
|
||||||
|
|
||||||
|
class UsageScanner:
|
||||||
|
def __init__(self):
|
||||||
|
self.interval = env_int("WEB_USAGE_SCAN_INTERVAL_SECONDS", 900, 60, 86400)
|
||||||
|
self.lock = threading.Lock()
|
||||||
|
self.data = read_json(USAGE_CACHE_FILE, {})
|
||||||
|
self.stop = threading.Event()
|
||||||
|
|
||||||
|
def snapshot(self) -> Dict[str, object]:
|
||||||
|
with self.lock:
|
||||||
|
return json.loads(json.dumps(self.data))
|
||||||
|
|
||||||
|
def scan(self) -> Dict[str, object]:
|
||||||
|
started = now_utc()
|
||||||
|
groups = scan_children(os.getenv("GROUP_ROOT", "/data/groups/data"))
|
||||||
|
private = scan_children(os.getenv("PRIVATE_ROOT", "/data/private"))
|
||||||
|
fslogix_rows = scan_children(os.getenv("FSLOGIX_ROOT", "/data/fslogix"))
|
||||||
|
users: Dict[str, Dict[str, object]] = {}
|
||||||
|
for row in private:
|
||||||
|
key = str(row["name"]).casefold()
|
||||||
|
users[key] = {
|
||||||
|
"name": row["name"], "privateBytes": row["bytes"], "fslogixBytes": 0
|
||||||
|
}
|
||||||
|
fslogix_root = os.getenv("FSLOGIX_ROOT", "/data/fslogix")
|
||||||
|
try:
|
||||||
|
fs_entries = {entry.name: entry for entry in os.scandir(fslogix_root)}
|
||||||
|
except OSError:
|
||||||
|
fs_entries = {}
|
||||||
|
for row in fslogix_rows:
|
||||||
|
entry = fs_entries.get(str(row["name"]))
|
||||||
|
name = fslogix_username(entry) if entry else str(row["name"])
|
||||||
|
key = name.casefold()
|
||||||
|
user = users.setdefault(
|
||||||
|
key, {"name": name, "privateBytes": 0, "fslogixBytes": 0}
|
||||||
|
)
|
||||||
|
user["fslogixBytes"] = int(user["fslogixBytes"]) + int(row["bytes"])
|
||||||
|
user_rows = []
|
||||||
|
for user in users.values():
|
||||||
|
user["totalBytes"] = int(user["privateBytes"]) + int(user["fslogixBytes"])
|
||||||
|
user_rows.append(user)
|
||||||
|
user_rows.sort(key=lambda row: int(row["totalBytes"]), reverse=True)
|
||||||
|
groups.sort(key=lambda row: int(row["bytes"]), reverse=True)
|
||||||
|
value = {
|
||||||
|
"scannedAt": now_utc().isoformat(timespec="seconds"),
|
||||||
|
"scanSeconds": round((now_utc() - started).total_seconds(), 3),
|
||||||
|
"groups": groups,
|
||||||
|
"users": user_rows,
|
||||||
|
"totals": {
|
||||||
|
"dataBytes": sum(int(row["bytes"]) for row in groups),
|
||||||
|
"privateBytes": sum(int(row["privateBytes"]) for row in user_rows),
|
||||||
|
"fslogixBytes": sum(int(row["fslogixBytes"]) for row in user_rows),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
atomic_json(USAGE_CACHE_FILE, value)
|
||||||
|
with self.lock:
|
||||||
|
self.data = value
|
||||||
|
return value
|
||||||
|
|
||||||
|
def run(self) -> None:
|
||||||
|
while not self.stop.is_set():
|
||||||
|
try:
|
||||||
|
self.scan()
|
||||||
|
except Exception as exc: # pylint: disable=broad-except
|
||||||
|
log(f"Usage scan failed: {exc}")
|
||||||
|
self.stop.wait(self.interval)
|
||||||
|
|
||||||
|
|
||||||
|
def display_name(entry) -> str:
|
||||||
|
return (
|
||||||
|
directory.ldap_first(entry, "displayName")
|
||||||
|
or directory.ldap_first(entry, "sAMAccountName")
|
||||||
|
or directory.ldap_first(entry, "cn")
|
||||||
|
or directory.entry_dn(entry).split(",", 1)[0].removeprefix("CN=")
|
||||||
|
or "Unbekannt"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class DirectoryCache:
|
||||||
|
ATTRS = [
|
||||||
|
"objectGUID", "distinguishedName", "sAMAccountName", "displayName", "cn",
|
||||||
|
"objectClass", "member",
|
||||||
|
]
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
self.ttl = env_int("WEB_DIRECTORY_CACHE_SECONDS", 300, 30, 3600)
|
||||||
|
self.max_nodes = env_int("WEB_MAX_GROUP_NODES", 10000, 100, 100000)
|
||||||
|
self.lock = threading.Lock()
|
||||||
|
self.cached_at = 0.0
|
||||||
|
self.value: Dict[str, object] = {"groups": [], "fetchedAt": None}
|
||||||
|
|
||||||
|
def get(self) -> Dict[str, object]:
|
||||||
|
with self.lock:
|
||||||
|
if time.monotonic() - self.cached_at < self.ttl:
|
||||||
|
return self.value
|
||||||
|
self.value = self.fetch()
|
||||||
|
self.cached_at = time.monotonic()
|
||||||
|
return self.value
|
||||||
|
|
||||||
|
def fetch(self) -> Dict[str, object]:
|
||||||
|
roots = directory.fetch_fileshare_groups()
|
||||||
|
entries: Dict[str, object] = {}
|
||||||
|
pending = deque()
|
||||||
|
for root in roots:
|
||||||
|
for dn in root.get("memberDns", []):
|
||||||
|
pending.append(str(dn))
|
||||||
|
requested = set()
|
||||||
|
while pending and len(entries) < self.max_nodes:
|
||||||
|
batch = []
|
||||||
|
while pending and len(batch) < 100:
|
||||||
|
dn = pending.popleft()
|
||||||
|
key = directory.normalize_dn(dn)
|
||||||
|
if not key or key in requested:
|
||||||
|
continue
|
||||||
|
requested.add(key)
|
||||||
|
batch.append(dn)
|
||||||
|
if not batch:
|
||||||
|
continue
|
||||||
|
for entry in directory.search_directory_entries(
|
||||||
|
directory.build_distinguished_name_filter(batch), self.ATTRS
|
||||||
|
):
|
||||||
|
key = directory.normalize_dn(directory.entry_dn(entry))
|
||||||
|
if not key:
|
||||||
|
continue
|
||||||
|
entries[key] = entry
|
||||||
|
classes = {value.lower() for value in directory.ldap_values(entry, "objectClass")}
|
||||||
|
if "group" in classes:
|
||||||
|
pending.extend(directory.ldap_values(entry, "member"))
|
||||||
|
|
||||||
|
folder_map = {}
|
||||||
|
try:
|
||||||
|
conn = sqlite3.connect(f"file:{SHARE_DB}?mode=ro", uri=True)
|
||||||
|
try:
|
||||||
|
folder_map = {
|
||||||
|
row[0]: {"folder": os.path.basename(row[1]), "active": bool(row[2])}
|
||||||
|
for row in conn.execute("SELECT objectGUID, path, isActive FROM shares")
|
||||||
|
}
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
except sqlite3.Error:
|
||||||
|
pass
|
||||||
|
|
||||||
|
def make_node(dn: str, ancestors: set) -> Dict[str, object]:
|
||||||
|
key = directory.normalize_dn(dn)
|
||||||
|
entry = entries.get(key)
|
||||||
|
if entry is None:
|
||||||
|
return {"id": dn, "name": dn, "sam": "", "type": "unknown", "members": []}
|
||||||
|
classes = {value.lower() for value in directory.ldap_values(entry, "objectClass")}
|
||||||
|
node_type = "group" if "group" in classes else "computer" if "computer" in classes else "user"
|
||||||
|
node = {
|
||||||
|
"id": directory.entry_dn(entry),
|
||||||
|
"name": display_name(entry),
|
||||||
|
"sam": directory.ldap_first(entry, "sAMAccountName") or "",
|
||||||
|
"type": node_type,
|
||||||
|
"members": [],
|
||||||
|
}
|
||||||
|
if key in ancestors:
|
||||||
|
node["cycle"] = True
|
||||||
|
return node
|
||||||
|
if node_type == "group":
|
||||||
|
next_ancestors = {*ancestors, key}
|
||||||
|
node["members"] = [
|
||||||
|
make_node(child, next_ancestors)
|
||||||
|
for child in directory.ldap_values(entry, "member")
|
||||||
|
]
|
||||||
|
return node
|
||||||
|
|
||||||
|
group_rows = []
|
||||||
|
for root in sorted(roots, key=lambda item: str(item["shareName"]).casefold()):
|
||||||
|
members = [make_node(str(dn), set()) for dn in root.get("memberDns", [])]
|
||||||
|
flat_users = set()
|
||||||
|
flat_groups = set()
|
||||||
|
|
||||||
|
def count_nodes(nodes):
|
||||||
|
for node in nodes:
|
||||||
|
target = flat_groups if node["type"] == "group" else flat_users if node["type"] == "user" else None
|
||||||
|
if target is not None:
|
||||||
|
target.add(str(node.get("sam") or node["id"]).casefold())
|
||||||
|
count_nodes(node.get("members", []))
|
||||||
|
|
||||||
|
count_nodes(members)
|
||||||
|
folder = folder_map.get(str(root["objectGUID"]), {})
|
||||||
|
group_rows.append(
|
||||||
|
{
|
||||||
|
"guid": root["objectGUID"],
|
||||||
|
"name": root["shareName"],
|
||||||
|
"sam": root["samAccountName"],
|
||||||
|
"folder": folder.get("folder", root["shareName"]),
|
||||||
|
"active": folder.get("active", True),
|
||||||
|
"userCount": len(flat_users),
|
||||||
|
"groupCount": len(flat_groups),
|
||||||
|
"members": members,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
"groups": group_rows,
|
||||||
|
"fetchedAt": now_utc().isoformat(timespec="seconds"),
|
||||||
|
"truncated": bool(pending),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def iter_audit_file(path: str) -> Iterable[Dict[str, object]]:
|
||||||
|
opener = gzip.open if path.endswith(".gz") else open
|
||||||
|
try:
|
||||||
|
with opener(path, "rt", encoding="utf-8", errors="replace") as handle:
|
||||||
|
for line in handle:
|
||||||
|
try:
|
||||||
|
value = json.loads(line)
|
||||||
|
if isinstance(value, dict):
|
||||||
|
yield value
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
continue
|
||||||
|
except OSError:
|
||||||
|
return
|
||||||
|
|
||||||
|
|
||||||
|
def iter_audit_file_reverse(path: str) -> Iterable[Dict[str, object]]:
|
||||||
|
if path.endswith(".gz"):
|
||||||
|
yield from reversed(list(iter_audit_file(path)))
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
with open(path, "rb") as handle:
|
||||||
|
position = handle.seek(0, os.SEEK_END)
|
||||||
|
remainder = b""
|
||||||
|
while position > 0:
|
||||||
|
size = min(1024 * 1024, position)
|
||||||
|
position -= size
|
||||||
|
handle.seek(position)
|
||||||
|
parts = (handle.read(size) + remainder).split(b"\n")
|
||||||
|
remainder = parts[0]
|
||||||
|
for line in reversed(parts[1:]):
|
||||||
|
if not line:
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
value = json.loads(line.decode("utf-8", errors="replace"))
|
||||||
|
if isinstance(value, dict):
|
||||||
|
yield value
|
||||||
|
except (json.JSONDecodeError, UnicodeDecodeError):
|
||||||
|
continue
|
||||||
|
if remainder:
|
||||||
|
try:
|
||||||
|
value = json.loads(remainder.decode("utf-8", errors="replace"))
|
||||||
|
if isinstance(value, dict):
|
||||||
|
yield value
|
||||||
|
except (json.JSONDecodeError, UnicodeDecodeError):
|
||||||
|
pass
|
||||||
|
except OSError:
|
||||||
|
return
|
||||||
|
|
||||||
|
|
||||||
|
def date_range(start: dt.date, end: dt.date):
|
||||||
|
day = start
|
||||||
|
while day <= end:
|
||||||
|
yield day.isoformat()
|
||||||
|
day += dt.timedelta(days=1)
|
||||||
|
|
||||||
|
|
||||||
|
def query_audit(params: Dict[str, List[str]]) -> Dict[str, object]:
|
||||||
|
today = now_utc().date()
|
||||||
|
default_start = today - dt.timedelta(days=1)
|
||||||
|
try:
|
||||||
|
start = dt.date.fromisoformat(params.get("from", [default_start.isoformat()])[0])
|
||||||
|
end = dt.date.fromisoformat(params.get("to", [today.isoformat()])[0])
|
||||||
|
except ValueError as exc:
|
||||||
|
raise ValueError("Datumsangaben müssen YYYY-MM-DD verwenden") from exc
|
||||||
|
max_days = env_int("AUDIT_QUERY_MAX_DAYS", 31, 1, 366)
|
||||||
|
if end < start or (end - start).days >= max_days:
|
||||||
|
raise ValueError(f"Der Datumsbereich darf höchstens {max_days} Tage umfassen")
|
||||||
|
limit = min(500, max(1, int(params.get("limit", ["100"])[0])))
|
||||||
|
offset = max(0, int(params.get("cursor", ["0"])[0]))
|
||||||
|
filters = {
|
||||||
|
key: params.get(key, [""])[0].casefold().strip()
|
||||||
|
for key in ("user", "share", "operation", "action", "path", "result")
|
||||||
|
}
|
||||||
|
page = []
|
||||||
|
matched = 0
|
||||||
|
facets = {"users": set(), "shares": set(), "operations": set(), "actions": set()}
|
||||||
|
for day in reversed(list(date_range(start, end))):
|
||||||
|
candidates = [os.path.join(AUDIT_ROOT, f"{day}.jsonl"), os.path.join(AUDIT_ROOT, f"{day}.jsonl.gz")]
|
||||||
|
for path in candidates:
|
||||||
|
if not os.path.isfile(path):
|
||||||
|
continue
|
||||||
|
for event in iter_audit_file_reverse(path):
|
||||||
|
facets["users"].add(str(event.get("user", "")))
|
||||||
|
facets["shares"].add(str(event.get("share", "")))
|
||||||
|
facets["operations"].add(str(event.get("operation", "")))
|
||||||
|
facets["actions"].add(str(event.get("action", "")))
|
||||||
|
failed_filter = filters["result"] == "fail"
|
||||||
|
if failed_filter and bool(event.get("success", False)):
|
||||||
|
continue
|
||||||
|
if (
|
||||||
|
filters["result"]
|
||||||
|
and not failed_filter
|
||||||
|
and filters["result"]
|
||||||
|
not in str(event.get("result", "")).casefold()
|
||||||
|
):
|
||||||
|
continue
|
||||||
|
if any(
|
||||||
|
value and value not in str(event.get(key, "")).casefold()
|
||||||
|
for key, value in filters.items()
|
||||||
|
if key != "result"
|
||||||
|
):
|
||||||
|
continue
|
||||||
|
if matched >= offset and len(page) < limit:
|
||||||
|
page.append(event)
|
||||||
|
matched += 1
|
||||||
|
page.sort(key=lambda event: str(event.get("timestamp", "")), reverse=True)
|
||||||
|
next_cursor = offset + limit if offset + limit < matched else None
|
||||||
|
return {
|
||||||
|
"events": page,
|
||||||
|
"nextCursor": next_cursor,
|
||||||
|
"matched": matched,
|
||||||
|
"facets": {key: sorted(value, key=str.casefold) for key, value in facets.items()},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def tail_lines(path: str, count: int) -> List[str]:
|
||||||
|
try:
|
||||||
|
with open(path, "rb") as handle:
|
||||||
|
handle.seek(0, os.SEEK_END)
|
||||||
|
position = handle.tell()
|
||||||
|
data = b""
|
||||||
|
while position > 0 and data.count(b"\n") <= count:
|
||||||
|
read_size = min(8192, position)
|
||||||
|
position -= read_size
|
||||||
|
handle.seek(position)
|
||||||
|
data = handle.read(read_size) + data
|
||||||
|
return data.decode("utf-8", errors="replace").splitlines()[-count:]
|
||||||
|
except OSError:
|
||||||
|
return []
|
||||||
|
|
||||||
|
|
||||||
|
def backup_payload() -> Dict[str, object]:
|
||||||
|
value = read_json(BACKUP_STATUS_FILE, {})
|
||||||
|
value["enabled"] = bool(os.getenv("BACKUP_DESTINATION", "").strip())
|
||||||
|
value["scheduledHour"] = env_int("BACKUP_START_HOUR", 2, 0, 23)
|
||||||
|
value["log"] = tail_lines(BACKUP_LOG_FILE, 100)
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
def share_count() -> int:
|
||||||
|
try:
|
||||||
|
conn = sqlite3.connect(f"file:{SHARE_DB}?mode=ro", uri=True)
|
||||||
|
try:
|
||||||
|
return int(conn.execute("SELECT count(*) FROM shares WHERE isActive = 1").fetchone()[0])
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
except sqlite3.Error:
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
def audit_archive_summary() -> Dict[str, object]:
|
||||||
|
files = []
|
||||||
|
total_bytes = 0
|
||||||
|
try:
|
||||||
|
names = os.listdir(AUDIT_ROOT)
|
||||||
|
except OSError:
|
||||||
|
names = []
|
||||||
|
for name in names:
|
||||||
|
if re.match(r"^\d{4}-\d{2}-\d{2}\.jsonl(?:\.gz)?$", name):
|
||||||
|
path = os.path.join(AUDIT_ROOT, name)
|
||||||
|
try:
|
||||||
|
total_bytes += os.path.getsize(path)
|
||||||
|
files.append(name)
|
||||||
|
except OSError:
|
||||||
|
continue
|
||||||
|
return {"days": len(files), "bytes": total_bytes, "oldest": min(files)[:10] if files else None, "newest": max(files)[:10] if files else None}
|
||||||
|
|
||||||
|
|
||||||
|
def tls_summary() -> Dict[str, object]:
|
||||||
|
try:
|
||||||
|
value = ssl._ssl._test_decode_cert(TLS_CERT_FILE) # pylint: disable=protected-access
|
||||||
|
raw_expiry = value.get("notAfter")
|
||||||
|
expiry = None
|
||||||
|
if raw_expiry:
|
||||||
|
expiry = dt.datetime.fromtimestamp(
|
||||||
|
ssl.cert_time_to_seconds(raw_expiry), dt.timezone.utc
|
||||||
|
).isoformat(timespec="seconds")
|
||||||
|
return {"subject": dict(item[0] for item in value.get("subject", [])), "issuer": dict(item[0] for item in value.get("issuer", [])), "notAfter": expiry, "sans": value.get("subjectAltName", [])}
|
||||||
|
except (OSError, ValueError, ssl.SSLError):
|
||||||
|
return {}
|
||||||
|
|
||||||
|
|
||||||
|
class App:
|
||||||
|
def __init__(self):
|
||||||
|
secret = os.environ.get("WEB_JWT_SECRET", "")
|
||||||
|
self.tokens = TokenManager(secret, env_int("WEB_JWT_TTL_SECONDS", 28800, 300, 604800))
|
||||||
|
self.usage = UsageScanner()
|
||||||
|
self.directory = DirectoryCache()
|
||||||
|
threading.Thread(target=self.usage.run, name="usage-scanner", daemon=True).start()
|
||||||
|
|
||||||
|
def overview(self) -> Dict[str, object]:
|
||||||
|
usage = self.usage.snapshot()
|
||||||
|
recent = query_audit({"limit": ["12"]})
|
||||||
|
return {"usage": usage, "activeGroups": share_count(), "recentEvents": recent["events"], "eventCount": recent["matched"], "backup": backup_payload(), "audit": audit_archive_summary()}
|
||||||
|
|
||||||
|
def system(self) -> Dict[str, object]:
|
||||||
|
checks = {}
|
||||||
|
for name, command in {"domainTrust": ["wbinfo", "-t"], "sambaConfig": ["testparm", "-s"]}.items():
|
||||||
|
try:
|
||||||
|
result = subprocess.run(command, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, timeout=10, check=False)
|
||||||
|
checks[name] = result.returncode == 0
|
||||||
|
except (OSError, subprocess.TimeoutExpired):
|
||||||
|
checks[name] = False
|
||||||
|
return {"hostname": os.getenv("WEB_HOSTNAME", ""), "checks": checks, "tls": tls_summary(), "audit": audit_archive_summary(), "usage": self.usage.snapshot(), "serverTime": now_utc().isoformat(timespec="seconds")}
|
||||||
|
|
||||||
|
|
||||||
|
APP: Optional[App] = None
|
||||||
|
|
||||||
|
|
||||||
|
class Handler(BaseHTTPRequestHandler):
|
||||||
|
server_version = "ADFileServerUI/1"
|
||||||
|
|
||||||
|
def log_message(self, fmt: str, *args) -> None:
|
||||||
|
log(f"{self.client_address[0]} {fmt % args}")
|
||||||
|
|
||||||
|
def security_headers(self) -> None:
|
||||||
|
self.send_header("Strict-Transport-Security", "max-age=31536000; includeSubDomains")
|
||||||
|
self.send_header("Content-Security-Policy", "default-src 'self'; connect-src 'self'; img-src 'self' data:; style-src 'self'; script-src 'self'; base-uri 'none'; frame-ancestors 'none'; form-action 'self'")
|
||||||
|
self.send_header("X-Content-Type-Options", "nosniff")
|
||||||
|
self.send_header("Referrer-Policy", "no-referrer")
|
||||||
|
self.send_header("Permissions-Policy", "camera=(), microphone=(), geolocation=()")
|
||||||
|
self.send_header("Cache-Control", "no-store")
|
||||||
|
|
||||||
|
def send_json(self, value: object, status: int = 200, cookie: Optional[str] = None) -> None:
|
||||||
|
body = json.dumps(value, separators=(",", ":")).encode()
|
||||||
|
self.send_response(status)
|
||||||
|
self.security_headers()
|
||||||
|
self.send_header("Content-Type", "application/json; charset=utf-8")
|
||||||
|
self.send_header("Content-Length", str(len(body)))
|
||||||
|
if cookie:
|
||||||
|
self.send_header("Set-Cookie", cookie)
|
||||||
|
self.end_headers()
|
||||||
|
self.wfile.write(body)
|
||||||
|
|
||||||
|
def send_error_json(self, status: int, message: str) -> None:
|
||||||
|
self.send_json({"error": message}, status)
|
||||||
|
|
||||||
|
def token(self) -> Optional[str]:
|
||||||
|
authorization = self.headers.get("Authorization", "")
|
||||||
|
if authorization.startswith("Bearer "):
|
||||||
|
return authorization[7:].strip()
|
||||||
|
cookie = http.cookies.SimpleCookie(self.headers.get("Cookie", ""))
|
||||||
|
morsel = cookie.get(JWT_COOKIE)
|
||||||
|
return morsel.value if morsel else None
|
||||||
|
|
||||||
|
def user(self) -> Optional[Dict[str, object]]:
|
||||||
|
token = self.token()
|
||||||
|
if not token:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
return APP.tokens.verify(token) if APP else None
|
||||||
|
except ValueError:
|
||||||
|
return None
|
||||||
|
|
||||||
|
def require_user(self) -> Optional[Dict[str, object]]:
|
||||||
|
value = self.user()
|
||||||
|
if value is None:
|
||||||
|
self.send_error_json(HTTPStatus.UNAUTHORIZED, "Anmeldung erforderlich")
|
||||||
|
return value
|
||||||
|
|
||||||
|
def read_json_body(self) -> Dict[str, object]:
|
||||||
|
try:
|
||||||
|
length = int(self.headers.get("Content-Length", "0"))
|
||||||
|
except ValueError as exc:
|
||||||
|
raise ValueError("Ungültige Anfragelänge") from exc
|
||||||
|
if length <= 0 or length > 16384:
|
||||||
|
raise ValueError("Ungültiger Anfrageinhalt")
|
||||||
|
try:
|
||||||
|
value = json.loads(self.rfile.read(length))
|
||||||
|
except json.JSONDecodeError as exc:
|
||||||
|
raise ValueError("Ungültiges JSON") from exc
|
||||||
|
if not isinstance(value, dict):
|
||||||
|
raise ValueError("Ein JSON-Objekt ist erforderlich")
|
||||||
|
return value
|
||||||
|
|
||||||
|
def do_POST(self) -> None: # pylint: disable=invalid-name
|
||||||
|
parsed = urllib.parse.urlparse(self.path)
|
||||||
|
if parsed.path == "/api/login":
|
||||||
|
remote = self.client_address[0]
|
||||||
|
if not login_allowed(remote):
|
||||||
|
self.send_error_json(HTTPStatus.TOO_MANY_REQUESTS, "Zu viele Anmeldeversuche; bitte später erneut versuchen")
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
body = self.read_json_body()
|
||||||
|
except ValueError as exc:
|
||||||
|
self.send_error_json(HTTPStatus.BAD_REQUEST, str(exc))
|
||||||
|
return
|
||||||
|
username = authenticate_domain_admin(str(body.get("username", "")), str(body.get("password", "")))
|
||||||
|
if username is None:
|
||||||
|
record_login_failure(remote)
|
||||||
|
time.sleep(0.4)
|
||||||
|
self.send_error_json(HTTPStatus.UNAUTHORIZED, "Ungültige Zugangsdaten oder keine Mitgliedschaft bei den Domänenadministratoren")
|
||||||
|
return
|
||||||
|
clear_login_failures(remote)
|
||||||
|
token, expires = APP.tokens.issue(username)
|
||||||
|
max_age = max(0, expires - int(time.time()))
|
||||||
|
cookie = f"{JWT_COOKIE}={token}; Path=/; Max-Age={max_age}; HttpOnly; Secure; SameSite=Strict"
|
||||||
|
self.send_json({"user": username, "expiresAt": expires, "token": token, "tokenType": "Bearer"}, cookie=cookie)
|
||||||
|
return
|
||||||
|
if parsed.path == "/api/logout":
|
||||||
|
cookie = f"{JWT_COOKIE}=; Path=/; Max-Age=0; HttpOnly; Secure; SameSite=Strict"
|
||||||
|
self.send_json({"ok": True}, cookie=cookie)
|
||||||
|
return
|
||||||
|
self.send_error_json(HTTPStatus.NOT_FOUND, "Nicht gefunden")
|
||||||
|
|
||||||
|
def do_GET(self) -> None: # pylint: disable=invalid-name,too-many-return-statements
|
||||||
|
parsed = urllib.parse.urlparse(self.path)
|
||||||
|
path = parsed.path
|
||||||
|
if path == "/healthz":
|
||||||
|
self.send_json({"status": "ok"})
|
||||||
|
return
|
||||||
|
if path.startswith("/api/"):
|
||||||
|
user = self.require_user()
|
||||||
|
if user is None:
|
||||||
|
return
|
||||||
|
params = urllib.parse.parse_qs(parsed.query)
|
||||||
|
try:
|
||||||
|
if path == "/api/session":
|
||||||
|
self.send_json({"user": user["sub"], "expiresAt": user["exp"]})
|
||||||
|
elif path == "/api/overview":
|
||||||
|
self.send_json(APP.overview())
|
||||||
|
elif path == "/api/groups":
|
||||||
|
self.send_json(APP.directory.get())
|
||||||
|
elif path == "/api/storage":
|
||||||
|
self.send_json(APP.usage.snapshot())
|
||||||
|
elif path == "/api/activity":
|
||||||
|
self.send_json(query_audit(params))
|
||||||
|
elif path == "/api/backup":
|
||||||
|
self.send_json(backup_payload())
|
||||||
|
elif path == "/api/system":
|
||||||
|
self.send_json(APP.system())
|
||||||
|
else:
|
||||||
|
self.send_error_json(HTTPStatus.NOT_FOUND, "Nicht gefunden")
|
||||||
|
except (ValueError, OSError, RuntimeError) as exc:
|
||||||
|
log(f"Request {path} failed: {exc}")
|
||||||
|
self.send_error_json(HTTPStatus.BAD_REQUEST, str(exc))
|
||||||
|
return
|
||||||
|
self.serve_static(path)
|
||||||
|
|
||||||
|
def serve_static(self, path: str) -> None:
|
||||||
|
files = {
|
||||||
|
"/assets/app.js": ("app.js", "text/javascript; charset=utf-8"),
|
||||||
|
"/assets/styles.css": ("styles.css", "text/css; charset=utf-8"),
|
||||||
|
"/favicon.svg": ("favicon.svg", "image/svg+xml"),
|
||||||
|
}
|
||||||
|
if path in files:
|
||||||
|
filename, content_type = files[path]
|
||||||
|
else:
|
||||||
|
filename, content_type = "index.html", "text/html; charset=utf-8"
|
||||||
|
try:
|
||||||
|
with open(os.path.join(STATIC_ROOT, filename), "rb") as handle:
|
||||||
|
body = handle.read()
|
||||||
|
except OSError:
|
||||||
|
self.send_error_json(HTTPStatus.NOT_FOUND, "Statische Datei nicht gefunden")
|
||||||
|
return
|
||||||
|
self.send_response(HTTPStatus.OK)
|
||||||
|
self.security_headers()
|
||||||
|
self.send_header("Content-Type", content_type)
|
||||||
|
self.send_header("Content-Length", str(len(body)))
|
||||||
|
self.end_headers()
|
||||||
|
self.wfile.write(body)
|
||||||
|
|
||||||
|
|
||||||
|
class ReusableHTTPServer(ThreadingHTTPServer):
|
||||||
|
allow_reuse_address = True
|
||||||
|
daemon_threads = True
|
||||||
|
|
||||||
|
|
||||||
|
def serve_https() -> None:
|
||||||
|
address = os.getenv("WEB_BIND_ADDRESS", "0.0.0.0")
|
||||||
|
port = env_int("WEB_BIND_PORT", 8443, 1, 65535)
|
||||||
|
cert_mtime = -1.0
|
||||||
|
log(f"Serving https://{os.getenv('WEB_HOSTNAME', address)}:{port}")
|
||||||
|
while True:
|
||||||
|
context = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER)
|
||||||
|
context.minimum_version = ssl.TLSVersion.TLSv1_2
|
||||||
|
context.load_cert_chain(TLS_CERT_FILE, TLS_KEY_FILE)
|
||||||
|
server = ReusableHTTPServer((address, port), Handler)
|
||||||
|
server.timeout = 1
|
||||||
|
server.socket = context.wrap_socket(server.socket, server_side=True)
|
||||||
|
cert_mtime = os.path.getmtime(TLS_CERT_FILE)
|
||||||
|
try:
|
||||||
|
while os.path.getmtime(TLS_CERT_FILE) == cert_mtime:
|
||||||
|
server.handle_request()
|
||||||
|
finally:
|
||||||
|
server.server_close()
|
||||||
|
log("TLS certificate changed; reloading HTTPS listener")
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
global APP
|
||||||
|
os.makedirs(STATE_ROOT, mode=0o750, exist_ok=True)
|
||||||
|
if not os.path.isfile(TLS_CERT_FILE) or not os.path.isfile(TLS_KEY_FILE):
|
||||||
|
raise RuntimeError("TLS certificate or key is missing")
|
||||||
|
APP = App()
|
||||||
|
serve_https()
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
try:
|
||||||
|
sys.exit(main())
|
||||||
|
except KeyboardInterrupt:
|
||||||
|
sys.exit(0)
|
||||||
|
except Exception as exc: # pylint: disable=broad-except
|
||||||
|
log(f"ERROR: {exc}")
|
||||||
|
sys.exit(1)
|
||||||
@@ -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"]
|
||||||
Executable
+141
@@ -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"
|
||||||
Executable
+32
@@ -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
|
||||||
@@ -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
@@ -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)
|
||||||
Executable
+53
@@ -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
|
||||||
Executable
+37
@@ -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'
|
||||||
@@ -8,10 +8,12 @@ services:
|
|||||||
env_file:
|
env_file:
|
||||||
- .env
|
- .env
|
||||||
environment:
|
environment:
|
||||||
|
TZ: Etc/UTC
|
||||||
NETBIOS_NAME: ${NETBIOS_NAME:-ADSAMBAFSRV}
|
NETBIOS_NAME: ${NETBIOS_NAME:-ADSAMBAFSRV}
|
||||||
ports:
|
ports:
|
||||||
- "445:445"
|
- "445:445"
|
||||||
- "139:139"
|
- "139:139"
|
||||||
|
- "${WEB_HTTPS_PORT:-443}:${WEB_BIND_PORT:-8443}"
|
||||||
volumes:
|
volumes:
|
||||||
- private_data:/data/private
|
- private_data:/data/private
|
||||||
- fslogix_data:/data/fslogix
|
- fslogix_data:/data/fslogix
|
||||||
|
|||||||
Executable
+418
@@ -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
|
||||||
Executable
+12
@@ -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
|
||||||
@@ -135,6 +135,14 @@ write_env_file() {
|
|||||||
local ad_dns_name=""
|
local ad_dns_name=""
|
||||||
local ad_dns_ip_auto="0"
|
local ad_dns_ip_auto="0"
|
||||||
local service_password=""
|
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 service_account_sam=""
|
||||||
local fslogix_group_prompt=""
|
local fslogix_group_prompt=""
|
||||||
local samba_hostname_input=""
|
local samba_hostname_input=""
|
||||||
@@ -204,6 +212,22 @@ write_env_file() {
|
|||||||
read -r -p "BACKUP_RETENTION_YEARLY [1]: " backup_retention_yearly
|
read -r -p "BACKUP_RETENTION_YEARLY [1]: " backup_retention_yearly
|
||||||
backup_retention_yearly="${backup_retention_yearly:-1}"
|
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")"
|
service_account_sam="$(sanitize_sam_account_name "$SERVICE_ACCOUNT_NAME")"
|
||||||
if [[ "$service_account_sam" != "$SERVICE_ACCOUNT_NAME" ]]; then
|
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"
|
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}
|
BACKUP_RETENTION_YEARLY=${backup_retention_yearly}
|
||||||
SAMBA_HOSTNAME=${samba_hostname}
|
SAMBA_HOSTNAME=${samba_hostname}
|
||||||
NETBIOS_NAME=${netbios_name}
|
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
|
EOF
|
||||||
|
|
||||||
cp "$BOOTSTRAP_ENV_FILE" "$ENV_FILE"
|
cp "$BOOTSTRAP_ENV_FILE" "$ENV_FILE"
|
||||||
@@ -311,6 +344,15 @@ BACKUP_RETENTION_MONTHLY=${backup_retention_monthly}
|
|||||||
BACKUP_RETENTION_YEARLY=${backup_retention_yearly}
|
BACKUP_RETENTION_YEARLY=${backup_retention_yearly}
|
||||||
SAMBA_HOSTNAME=${samba_hostname}
|
SAMBA_HOSTNAME=${samba_hostname}
|
||||||
NETBIOS_NAME=${netbios_name}
|
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:
|
# Optional overrides:
|
||||||
# LDAP_URI=ldaps://${domain}
|
# LDAP_URI=ldaps://${domain}
|
||||||
# LDAP_BASE_DN=DC=example,DC=com
|
# LDAP_BASE_DN=DC=example,DC=com
|
||||||
@@ -328,6 +370,14 @@ NETBIOS_NAME=${netbios_name}
|
|||||||
# BACKUP_LOG_FILE=/var/log/backup.log
|
# BACKUP_LOG_FILE=/var/log/backup.log
|
||||||
# BACKUP_PROGRESS=auto
|
# BACKUP_PROGRESS=auto
|
||||||
# BACKUP_PROGRESS_INTERVAL_SECONDS=10
|
# 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
|
EOF
|
||||||
|
|
||||||
chmod 600 "$ENV_FILE"
|
chmod 600 "$ENV_FILE"
|
||||||
|
|||||||
@@ -233,6 +233,7 @@ class BackendProgressCommandTests(unittest.TestCase):
|
|||||||
)
|
)
|
||||||
|
|
||||||
command = run_streaming.call_args.args[0]
|
command = run_streaming.call_args.args[0]
|
||||||
|
self.assertIn("--mkpath", command)
|
||||||
self.assertIn("--progress", command)
|
self.assertIn("--progress", command)
|
||||||
self.assertIn("--outbuf=L", command)
|
self.assertIn("--outbuf=L", command)
|
||||||
self.assertEqual(
|
self.assertEqual(
|
||||||
|
|||||||
@@ -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()
|
||||||
Reference in New Issue
Block a user