higher level logging; sqlite db for logs
This commit is contained in:
+1
-1
@@ -48,6 +48,6 @@ ACME_HTTP_PORT=80
|
|||||||
# BACKUP_PROGRESS=auto
|
# BACKUP_PROGRESS=auto
|
||||||
# BACKUP_PROGRESS_INTERVAL_SECONDS=10
|
# BACKUP_PROGRESS_INTERVAL_SECONDS=10
|
||||||
# BACKUP_STATUS_FILE=/state/backup-status.json
|
# BACKUP_STATUS_FILE=/state/backup-status.json
|
||||||
# AUDIT_COMPRESS_AFTER_HOURS=24
|
# STATE_DB_PATH=/state/shares.db
|
||||||
# AUDIT_QUERY_MAX_DAYS=31
|
# AUDIT_QUERY_MAX_DAYS=31
|
||||||
# AUDIT_SKIP_USER_SUFFIXES=_svc,_ServiceAcc
|
# AUDIT_SKIP_USER_SUFFIXES=_svc,_ServiceAcc
|
||||||
|
|||||||
@@ -29,6 +29,8 @@ 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_policy.py /app/audit_policy.py
|
COPY app/audit_policy.py /app/audit_policy.py
|
||||||
|
COPY app/state_db.py /app/state_db.py
|
||||||
|
COPY app/audit_store.py /app/audit_store.py
|
||||||
COPY app/audit_collector.py /app/audit_collector.py
|
COPY app/audit_collector.py /app/audit_collector.py
|
||||||
COPY app/web_ui.py /app/web_ui.py
|
COPY app/web_ui.py /app/web_ui.py
|
||||||
COPY app/web /app/web
|
COPY app/web /app/web
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ This repository provides a production-oriented Samba file server container that
|
|||||||
- `\\server\FSLogix` -> `/data/fslogix`
|
- `\\server\FSLogix` -> `/data/fslogix`
|
||||||
- FS_* groups are projected as folders inside the Data share (`/data/groups/data/<groupName>`).
|
- FS_* groups are projected as folders inside the Data share (`/data/groups/data/<groupName>`).
|
||||||
- Data folder ACLs expand nested AD group membership recursively and detect group cycles.
|
- Data folder ACLs expand nested AD group membership recursively and detect group cycles.
|
||||||
- Group records are persisted in SQLite at `/state/shares.db`.
|
- Group records, normalized activity events, collector offsets, and web caches share one SQLite database at `/state/shares.db`.
|
||||||
- Group folders are name-based while active and moved to archive on deactivation:
|
- Group folders are name-based while active and moved to archive on deactivation:
|
||||||
- active: `/data/groups/data/<groupName>`
|
- active: `/data/groups/data/<groupName>`
|
||||||
- inactive/deleted groups: `/data/groups/archive/<groupName>`
|
- inactive/deleted groups: `/data/groups/archive/<groupName>`
|
||||||
@@ -23,7 +23,7 @@ This repository provides a production-oriented Samba file server container that
|
|||||||
- `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.
|
||||||
- Samba `full_audit` is restricted to successful and failed reads, writes, renames/moves, and deletions.
|
- Samba `full_audit` is restricted to successful and failed reads, writes, renames/moves, and deletions.
|
||||||
- A collector normalizes those four actions and persists them in daily NDJSON files under `/state/audit`; closed files are gzip-compressed and are never automatically deleted.
|
- A collector normalizes those four actions and persists them in indexed SQLite tables; activity is never automatically deleted.
|
||||||
- A read-only HTTPS web console provides group membership trees, storage usage, searchable activity, live backup progress, and system health.
|
- 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.
|
- 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.
|
- HTTPS certificates are requested from a configured local Smallstep CA and renewed automatically. Pre-issued certificate files are also supported.
|
||||||
@@ -44,23 +44,20 @@ The reconciliation script (`/app/reconcile_shares.py`) enforces these rules:
|
|||||||
3. Group removed or no longer matching `FS_*` -> set `isActive=0` and move folder to `/data/groups/archive/...`.
|
3. Group removed or no longer matching `FS_*` -> set `isActive=0` and move folder to `/data/groups/archive/...`.
|
||||||
4. Previously inactive group returns -> set `isActive=1`, move back into `/data/groups/data/...`.
|
4. Previously inactive group returns -> set `isActive=1`, move back into `/data/groups/data/...`.
|
||||||
|
|
||||||
## SQLite State Database
|
## Shared SQLite State Database
|
||||||
|
|
||||||
Database path: `/state/shares.db`
|
The default database path is `/state/shares.db`; `STATE_DB_PATH` can override it. `SHARE_DB_PATH` remains a backward-compatible fallback.
|
||||||
|
|
||||||
Table schema:
|
The database contains:
|
||||||
|
|
||||||
```sql
|
- `shares`: AD group-to-folder lifecycle and ACL reconciliation state;
|
||||||
CREATE TABLE shares (
|
- `audit_events`: normalized read, write, move, and delete events;
|
||||||
objectGUID TEXT PRIMARY KEY,
|
- `audit_sources`: Samba log inode/offset checkpoints;
|
||||||
samAccountName TEXT NOT NULL,
|
- `web_cache`: cached storage scan results.
|
||||||
shareName TEXT NOT NULL,
|
|
||||||
path TEXT NOT NULL,
|
Activity lookup uses UTC epoch seconds, keyset pagination, and multi-column indexes for time, user, share, action, and result. WAL mode allows the collector, reconciler, scanner, and read-only web requests to operate concurrently.
|
||||||
createdAt TIMESTAMP NOT NULL,
|
|
||||||
lastSeenAt TIMESTAMP NOT NULL,
|
Standard SQLite does not provide transparent general-purpose compression, so this project deliberately does not depend on a non-core compression VFS. Structured columns avoid repeated JSON field names and make indexed queries much cheaper; the state volume still needs capacity for retained activity.
|
||||||
isActive INTEGER NOT NULL
|
|
||||||
);
|
|
||||||
```
|
|
||||||
|
|
||||||
## AD Requirements
|
## AD Requirements
|
||||||
|
|
||||||
@@ -100,6 +97,8 @@ Kerberos requires close time alignment.
|
|||||||
- `app/reconcile_shares.py`
|
- `app/reconcile_shares.py`
|
||||||
- `app/backup_to_destination.py`
|
- `app/backup_to_destination.py`
|
||||||
- `app/audit_collector.py`
|
- `app/audit_collector.py`
|
||||||
|
- `app/audit_store.py`
|
||||||
|
- `app/state_db.py`
|
||||||
- `app/web_ui.py`
|
- `app/web_ui.py`
|
||||||
- `app/web/`
|
- `app/web/`
|
||||||
- `etc/samba/smb.conf`
|
- `etc/samba/smb.conf`
|
||||||
@@ -123,7 +122,7 @@ The launcher builds the current application and starts an isolated, run-scoped n
|
|||||||
- the actual file-server image, joined to the dummy domain;
|
- the actual file-server image, joined to the dummy domain;
|
||||||
- a continuous SMB client that exercises reads, writes, renames/moves, and deletions, including activity from an excluded dummy service account.
|
- a continuous SMB client that exercises reads, writes, renames/moves, and deletions, including activity from an excluded dummy service account.
|
||||||
|
|
||||||
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:
|
The preview starts with group, Private, and FSLogix data. The client keeps current 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
|
```text
|
||||||
URL: https://files.localhost:8443
|
URL: https://files.localhost:8443
|
||||||
@@ -175,7 +174,7 @@ The E2E suite verifies:
|
|||||||
- SMB allow/deny behavior and real file operations;
|
- SMB allow/deny behavior and real file operations;
|
||||||
- Data, Private, and FSLogix usage aggregation;
|
- Data, Private, and FSLogix usage aggregation;
|
||||||
- high-level `full_audit` ingestion for all four actions, service-account exclusion, filters, facets, and pagination;
|
- high-level `full_audit` ingestion for all four actions, service-account exclusion, filters, facets, and pagination;
|
||||||
- compression and querying of a closed daily audit log;
|
- shared SQLite schema, integrity, indexes, legacy-log removal, and ordered read deduplication;
|
||||||
- real rsync transfer progress, completed backup status, log output, and remote snapshot marker;
|
- real rsync transfer progress, completed backup status, log output, and remote snapshot marker;
|
||||||
- overview and system-health aggregation.
|
- overview and system-health aggregation.
|
||||||
|
|
||||||
@@ -284,11 +283,11 @@ The console is intentionally operational and plain:
|
|||||||
|
|
||||||
- **Overview**: current capacity totals, active group count, recent activity, and backup state.
|
- **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.
|
- **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.
|
- **Data usage**: cached recursive size of every top-level `/Data` group folder.
|
||||||
- **Storage / Users**: per-user `/Private + /FSLogix` totals with component sizes.
|
- **User usage**: per-user `/Private + /FSLogix` totals with component sizes.
|
||||||
- **Activity logs**: dynamic date, user, share, action, operation, result, and path filters with pagination.
|
- **Activity**: dynamic date, user, share, action, result, and path filters with pagination.
|
||||||
- **Backups**: read-only live progress, active transfer rows, snapshot name, and recent backup output.
|
- **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.
|
- **System**: domain trust, Samba configuration, TLS certificate, scanner, and activity database 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.
|
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.
|
||||||
|
|
||||||
@@ -372,24 +371,25 @@ Useful optional settings:
|
|||||||
| `WEB_USAGE_SCAN_INTERVAL_SECONDS` | `900` | Recursive storage scan interval |
|
| `WEB_USAGE_SCAN_INTERVAL_SECONDS` | `900` | Recursive storage scan interval |
|
||||||
| `WEB_DIRECTORY_CACHE_SECONDS` | `300` | AD membership tree cache time |
|
| `WEB_DIRECTORY_CACHE_SECONDS` | `300` | AD membership tree cache time |
|
||||||
| `WEB_MAX_GROUP_NODES` | `10000` | Membership expansion safety limit |
|
| `WEB_MAX_GROUP_NODES` | `10000` | Membership expansion safety limit |
|
||||||
| `AUDIT_COMPRESS_AFTER_HOURS` | `24` | Minimum idle age before compressing a closed day |
|
| `STATE_DB_PATH` | `/state/shares.db` | Shared SQLite database for shares, activity, collector offsets, and caches |
|
||||||
| `AUDIT_QUERY_MAX_DAYS` | `31` | Largest activity query window |
|
| `AUDIT_QUERY_MAX_DAYS` | `31` | Largest activity query window |
|
||||||
| `AUDIT_SKIP_USER_SUFFIXES` | `_svc,_ServiceAcc` | Case-insensitive account suffixes excluded from collection and queries; set empty to disable |
|
| `AUDIT_SKIP_USER_SUFFIXES` | `_svc,_ServiceAcc` | Case-insensitive account suffixes excluded from collection and queries; set empty to disable |
|
||||||
|
|
||||||
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.
|
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
|
## Activity Database
|
||||||
|
|
||||||
Samba emits only the selected high-level `full_audit` operations, and the collector makes them durable:
|
Samba emits only the selected high-level `full_audit` operations, and the collector stores them durably:
|
||||||
|
|
||||||
- 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;
|
- it tails every `/var/log/samba/log.*` source and stores inode/byte offsets transactionally in `audit_sources`, so source progress and inserted events commit together;
|
||||||
- each event records timestamp, user, client address/name, share, result, path, and one of the actions `read`, `write`, `move`, or `delete`;
|
- each event records a UTC timestamp, user, client address/name, share, result, path, and one of `read`, `write`, `move`, or `delete`;
|
||||||
- directory listings, sessions, metadata access, file-open/create noise, and all other VFS operations are discarded; users ending in a configured `AUDIT_SKIP_USER_SUFFIXES` value are also discarded;
|
- directory listings, sessions, metadata access, file-open/create noise, and all other VFS operations are discarded; users ending in a configured `AUDIT_SKIP_USER_SUFFIXES` value are also discarded;
|
||||||
- records are appended to `/state/audit/YYYY-MM-DD.jsonl`;
|
- immediately consecutive identical reads within the same UTC second are collapsed into one event, including across collector polling cycles; a different event interrupts the sequence and preserves later reads;
|
||||||
- a closed, idle daily file becomes `.jsonl.gz`;
|
- activity queries run directly against indexed SQLite columns and use a stable time/id cursor;
|
||||||
- no audit retention deletion is performed, so capacity planning for the `state_data` volume is the operator's responsibility.
|
- no activity retention deletion is performed.
|
||||||
|
|
||||||
|
Collection starts even when the web UI is disabled. On the first collector start after this migration, recognized legacy daily `.jsonl`/`.jsonl.gz` files and the old collector state file under `/state/audit` are deleted without import. Existing raw Samba log content is then processed using the current action, suffix, and deduplication policy.
|
||||||
|
|
||||||
Collection starts even when the web UI is disabled. Existing Samba log content is imported when the collector first starts, but the same operation and user-suffix policy is applied during import. Audit data that Samba rotated away before this version was deployed cannot be recovered.
|
|
||||||
|
|
||||||
## Backups
|
## Backups
|
||||||
|
|
||||||
@@ -400,7 +400,7 @@ Collection starts even when the web UI is disabled. Existing Samba log content i
|
|||||||
- `/data/private` -> `data/private`
|
- `/data/private` -> `data/private`
|
||||||
- `/data/groups` -> `data/groups`
|
- `/data/groups` -> `data/groups`
|
||||||
- `/data/fslogix` -> `data/fslogix`
|
- `/data/fslogix` -> `data/fslogix`
|
||||||
- `/state` -> `state`
|
- `/state` -> `state` (the live WAL database is replaced by a consistent SQLite online snapshot)
|
||||||
- `/var/lib/samba/private` -> `samba/private`
|
- `/var/lib/samba/private` -> `samba/private`
|
||||||
- Retention policy env vars (defaults):
|
- Retention policy env vars (defaults):
|
||||||
- `BACKUP_RETENTION_YEARLY=1`
|
- `BACKUP_RETENTION_YEARLY=1`
|
||||||
@@ -408,7 +408,7 @@ Collection starts even when the web UI is disabled. Existing Samba log content i
|
|||||||
- `BACKUP_RETENTION_WEEKLY=2`
|
- `BACKUP_RETENTION_WEEKLY=2`
|
||||||
- `BACKUP_RETENTION_DAILY=3`
|
- `BACKUP_RETENTION_DAILY=3`
|
||||||
- The backup script writes directly to `BACKUP_LOG_FILE` (default: `/var/log/backup.log`). Cron does not redirect backup output into the logfile.
|
- The backup script writes directly to `BACKUP_LOG_FILE` (default: `/var/log/backup.log`). Cron does not redirect backup output into the logfile.
|
||||||
- Before uploading, the backup script measures all source files so it can report total upload progress.
|
- Before uploading, the backup script creates a temporary, integrity-checked SQLite online snapshot of the shared state database, then 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.
|
- 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.
|
||||||
@@ -446,11 +446,11 @@ Collection starts even when the web UI is disabled. Existing Samba log content i
|
|||||||
docker compose logs -f samba
|
docker compose logs -f samba
|
||||||
docker compose logs -f samba | grep -E '\\[web\\]|\\[audit\\]'
|
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 'PRAGMA quick_check;'
|
||||||
|
docker compose exec samba sqlite3 /state/shares.db 'SELECT action, count(*) FROM audit_events GROUP BY action;'
|
||||||
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
|
docker compose exec samba python3 -m json.tool /state/backup-status.json
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
+34
-100
@@ -1,30 +1,26 @@
|
|||||||
#!/usr/bin/env python3
|
#!/usr/bin/env python3
|
||||||
"""Persist Samba full_audit records as immutable daily NDJSON archives."""
|
"""Persist selected Samba full_audit records in indexed SQLite tables."""
|
||||||
|
|
||||||
import datetime as dt
|
import datetime as dt
|
||||||
import glob
|
import glob
|
||||||
import gzip
|
|
||||||
import json
|
|
||||||
import os
|
import os
|
||||||
import re
|
import re
|
||||||
import signal
|
import signal
|
||||||
import sys
|
import sys
|
||||||
import time
|
import time
|
||||||
from typing import Dict, Iterable, Optional
|
from typing import Dict, Optional
|
||||||
|
|
||||||
try:
|
try:
|
||||||
from .audit_policy import action_for, skip_user
|
from .audit_policy import action_for, skip_user
|
||||||
|
from .audit_store import AuditStore, drop_legacy_audit_files
|
||||||
except ImportError:
|
except ImportError:
|
||||||
from audit_policy import action_for, skip_user
|
from audit_policy import action_for, skip_user
|
||||||
|
from audit_store import AuditStore, drop_legacy_audit_files
|
||||||
|
|
||||||
|
|
||||||
SAMBA_LOG_GLOB = os.getenv("AUDIT_SOURCE_GLOB", "/var/log/samba/log.*")
|
SAMBA_LOG_GLOB = os.getenv("AUDIT_SOURCE_GLOB", "/var/log/samba/log.*")
|
||||||
ARCHIVE_DIR = os.getenv("AUDIT_ARCHIVE_DIR", "/state/audit")
|
LEGACY_AUDIT_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")))
|
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_MARKER_RE = re.compile(r"smbd_audit:\s*(.*)$")
|
||||||
AUDIT_PAYLOAD_RE = re.compile(
|
AUDIT_PAYLOAD_RE = re.compile(
|
||||||
r"^\s*\d{4}/\d{2}/\d{2}\s+\d{2}:\d{2}:\d{2}(?:\.\d+)?\|"
|
r"^\s*\d{4}/\d{2}/\d{2}\s+\d{2}:\d{2}:\d{2}(?:\.\d+)?\|"
|
||||||
@@ -43,26 +39,6 @@ def utc_now() -> dt.datetime:
|
|||||||
return dt.datetime.now(dt.timezone.utc)
|
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:
|
def parse_samba_timestamp(raw_line: str, fallback: dt.datetime) -> str:
|
||||||
match = SAMBA_LOG_TIME_RE.match(raw_line)
|
match = SAMBA_LOG_TIME_RE.match(raw_line)
|
||||||
if not match:
|
if not match:
|
||||||
@@ -108,29 +84,6 @@ def parse_audit_line(raw_line: str, source: str) -> Optional[Dict[str, object]]:
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
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]):
|
def read_new_events(path: str, entry: Dict[str, object]):
|
||||||
stat = os.stat(path)
|
stat = os.stat(path)
|
||||||
inode = int(stat.st_ino)
|
inode = int(stat.st_ino)
|
||||||
@@ -157,60 +110,40 @@ def read_new_events(path: str, entry: Dict[str, object]):
|
|||||||
return events, {"inode": inode, "offset": new_offset}
|
return events, {"inode": inode, "offset": new_offset}
|
||||||
|
|
||||||
|
|
||||||
def compress_old_archives() -> None:
|
def collect_once(store: AuditStore) -> int:
|
||||||
cutoff = utc_now() - dt.timedelta(hours=COMPRESS_AFTER_HOURS)
|
state = store.source_entries()
|
||||||
today = utc_now().date().isoformat()
|
all_events = []
|
||||||
for path in glob.glob(os.path.join(ARCHIVE_DIR, "????-??-??.jsonl")):
|
source_updates = {}
|
||||||
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()
|
seen = set()
|
||||||
initial_by_inode = {
|
initial_by_inode = {
|
||||||
int(entry.get("inode", -1)): entry
|
int(entry.get("inode", -1)): entry
|
||||||
for entry in state.values()
|
for entry in state.values()
|
||||||
if isinstance(entry, dict) and int(entry.get("inode", -1)) >= 0
|
if int(entry.get("inode", -1)) >= 0
|
||||||
}
|
}
|
||||||
for path in sorted(glob.glob(SAMBA_LOG_GLOB)):
|
candidates = []
|
||||||
|
for path in glob.glob(SAMBA_LOG_GLOB):
|
||||||
if not os.path.isfile(path):
|
if not os.path.isfile(path):
|
||||||
continue
|
continue
|
||||||
|
try:
|
||||||
|
stat = os.stat(path)
|
||||||
|
except OSError as exc:
|
||||||
|
log(f"Unable to inspect {path}: {exc}")
|
||||||
|
continue
|
||||||
|
known_inode = int(stat.st_ino) in initial_by_inode
|
||||||
|
candidates.append((not known_inode, stat.st_mtime_ns, path, int(stat.st_ino)))
|
||||||
|
|
||||||
|
for _is_new, _mtime_ns, path, current_inode in sorted(candidates):
|
||||||
seen.add(path)
|
seen.add(path)
|
||||||
try:
|
try:
|
||||||
path_entry = state.get(path, {})
|
path_entry = state.get(path, {})
|
||||||
current_inode = os.stat(path).st_ino
|
|
||||||
if int(path_entry.get("inode", -1)) != current_inode:
|
if int(path_entry.get("inode", -1)) != current_inode:
|
||||||
path_entry = initial_by_inode.get(current_inode, {})
|
path_entry = initial_by_inode.get(current_inode, {})
|
||||||
events, new_entry = read_new_events(path, path_entry)
|
events, new_entry = read_new_events(path, path_entry)
|
||||||
if events:
|
all_events.extend(events)
|
||||||
total += archive_events(events)
|
source_updates[path] = new_entry
|
||||||
state[path] = new_entry
|
|
||||||
except OSError as exc:
|
except OSError as exc:
|
||||||
log(f"Unable to read {path}: {exc}")
|
log(f"Unable to read {path}: {exc}")
|
||||||
for stale_path in list(state):
|
return store.append_batch(all_events, source_updates, seen)
|
||||||
if stale_path not in seen:
|
|
||||||
state.pop(stale_path, None)
|
|
||||||
atomic_json(STATE_FILE, state)
|
|
||||||
return total
|
|
||||||
|
|
||||||
|
|
||||||
def stop(_signum, _frame) -> None:
|
def stop(_signum, _frame) -> None:
|
||||||
@@ -219,23 +152,24 @@ def stop(_signum, _frame) -> None:
|
|||||||
|
|
||||||
|
|
||||||
def main() -> int:
|
def main() -> int:
|
||||||
os.makedirs(ARCHIVE_DIR, mode=0o750, exist_ok=True)
|
|
||||||
signal.signal(signal.SIGTERM, stop)
|
signal.signal(signal.SIGTERM, stop)
|
||||||
signal.signal(signal.SIGINT, stop)
|
signal.signal(signal.SIGINT, stop)
|
||||||
state = load_state()
|
store = AuditStore()
|
||||||
last_compress = 0.0
|
removed = drop_legacy_audit_files(LEGACY_AUDIT_DIR)
|
||||||
log(f"Watching {SAMBA_LOG_GLOB}")
|
if removed:
|
||||||
|
log(f"Dropped {removed} legacy audit archive file(s)")
|
||||||
|
log(f"Watching {SAMBA_LOG_GLOB}; storing events in SQLite")
|
||||||
|
try:
|
||||||
while not STOP:
|
while not STOP:
|
||||||
try:
|
try:
|
||||||
count = collect_once(state)
|
count = collect_once(store)
|
||||||
if count:
|
if count:
|
||||||
log(f"Archived {count} event(s)")
|
log(f"Stored {count} event(s)")
|
||||||
if time.monotonic() - last_compress >= 3600:
|
|
||||||
compress_old_archives()
|
|
||||||
last_compress = time.monotonic()
|
|
||||||
except Exception as exc: # pylint: disable=broad-except
|
except Exception as exc: # pylint: disable=broad-except
|
||||||
log(f"Collector cycle failed: {exc}")
|
log(f"Collector cycle failed: {exc}")
|
||||||
time.sleep(POLL_SECONDS)
|
time.sleep(POLL_SECONDS)
|
||||||
|
finally:
|
||||||
|
store.close()
|
||||||
return 0
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
+34
-1
@@ -1,8 +1,9 @@
|
|||||||
#!/usr/bin/env python3
|
#!/usr/bin/env python3
|
||||||
"""Shared policy for the small set of user-facing audit events."""
|
"""Shared policy for the small set of user-facing audit events."""
|
||||||
|
|
||||||
|
import datetime as dt
|
||||||
import os
|
import os
|
||||||
from typing import Optional, Tuple
|
from typing import Mapping, Optional, Tuple
|
||||||
|
|
||||||
|
|
||||||
AUDIT_ACTIONS = frozenset({"read", "write", "move", "delete"})
|
AUDIT_ACTIONS = frozenset({"read", "write", "move", "delete"})
|
||||||
@@ -51,3 +52,35 @@ def account_name(user: str) -> str:
|
|||||||
def skip_user(user: str) -> bool:
|
def skip_user(user: str) -> bool:
|
||||||
account = account_name(user).casefold()
|
account = account_name(user).casefold()
|
||||||
return any(account.endswith(suffix) for suffix in skipped_user_suffixes())
|
return any(account.endswith(suffix) for suffix in skipped_user_suffixes())
|
||||||
|
|
||||||
|
|
||||||
|
ReadEventKey = Tuple[str, ...]
|
||||||
|
|
||||||
|
|
||||||
|
def read_deduplication_key(event: Mapping[str, object]) -> Optional[ReadEventKey]:
|
||||||
|
if str(event.get("action", "")).casefold() != "read":
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
timestamp = dt.datetime.fromisoformat(
|
||||||
|
str(event.get("timestamp", "")).replace("Z", "+00:00")
|
||||||
|
)
|
||||||
|
if timestamp.tzinfo is None:
|
||||||
|
timestamp = timestamp.replace(tzinfo=dt.timezone.utc)
|
||||||
|
second = (
|
||||||
|
timestamp.astimezone(dt.timezone.utc)
|
||||||
|
.replace(microsecond=0)
|
||||||
|
.isoformat()
|
||||||
|
)
|
||||||
|
except ValueError:
|
||||||
|
second = str(event.get("timestamp", ""))
|
||||||
|
|
||||||
|
success = bool(event.get("success", False))
|
||||||
|
return (
|
||||||
|
second,
|
||||||
|
str(event.get("user", "")),
|
||||||
|
str(event.get("clientIp", "")),
|
||||||
|
str(event.get("share", "")),
|
||||||
|
str(event.get("path", "")),
|
||||||
|
"success" if success else "failure",
|
||||||
|
"" if success else str(event.get("result", "")),
|
||||||
|
)
|
||||||
|
|||||||
@@ -0,0 +1,369 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Indexed SQLite storage for normalized Samba audit events."""
|
||||||
|
|
||||||
|
import datetime as dt
|
||||||
|
import glob
|
||||||
|
import os
|
||||||
|
import sqlite3
|
||||||
|
from typing import Dict, Iterable, List, Optional, Set, Tuple
|
||||||
|
|
||||||
|
try:
|
||||||
|
from .audit_policy import (
|
||||||
|
account_name,
|
||||||
|
read_deduplication_key,
|
||||||
|
skipped_user_suffixes,
|
||||||
|
)
|
||||||
|
from .state_db import connect_state_db
|
||||||
|
except ImportError:
|
||||||
|
from audit_policy import account_name, read_deduplication_key, skipped_user_suffixes
|
||||||
|
from state_db import connect_state_db
|
||||||
|
|
||||||
|
|
||||||
|
AUDIT_SCHEMA = """
|
||||||
|
CREATE TABLE IF NOT EXISTS audit_events (
|
||||||
|
id INTEGER PRIMARY KEY,
|
||||||
|
occurred_at TEXT NOT NULL,
|
||||||
|
occurred_second INTEGER NOT NULL,
|
||||||
|
ingested_at TEXT NOT NULL,
|
||||||
|
user TEXT NOT NULL,
|
||||||
|
account TEXT NOT NULL,
|
||||||
|
client_ip TEXT NOT NULL,
|
||||||
|
client TEXT NOT NULL,
|
||||||
|
share TEXT NOT NULL,
|
||||||
|
action TEXT NOT NULL CHECK (action IN ('read', 'write', 'move', 'delete')),
|
||||||
|
result TEXT NOT NULL,
|
||||||
|
success INTEGER NOT NULL CHECK (success IN (0, 1)),
|
||||||
|
path TEXT NOT NULL,
|
||||||
|
source TEXT NOT NULL
|
||||||
|
);
|
||||||
|
CREATE TABLE IF NOT EXISTS audit_sources (
|
||||||
|
path TEXT PRIMARY KEY,
|
||||||
|
inode INTEGER NOT NULL,
|
||||||
|
offset INTEGER NOT NULL
|
||||||
|
) WITHOUT ROWID;
|
||||||
|
CREATE INDEX IF NOT EXISTS audit_events_time
|
||||||
|
ON audit_events (occurred_second DESC, id DESC);
|
||||||
|
CREATE INDEX IF NOT EXISTS audit_events_action_time
|
||||||
|
ON audit_events (action, occurred_second DESC, id DESC);
|
||||||
|
CREATE INDEX IF NOT EXISTS audit_events_success_time
|
||||||
|
ON audit_events (success, occurred_second DESC, id DESC);
|
||||||
|
CREATE INDEX IF NOT EXISTS audit_events_user_time
|
||||||
|
ON audit_events (user COLLATE NOCASE, occurred_second DESC, id DESC);
|
||||||
|
CREATE INDEX IF NOT EXISTS audit_events_account_time
|
||||||
|
ON audit_events (account, occurred_second DESC, id DESC);
|
||||||
|
CREATE INDEX IF NOT EXISTS audit_events_share_time
|
||||||
|
ON audit_events (share COLLATE NOCASE, occurred_second DESC, id DESC);
|
||||||
|
CREATE INDEX IF NOT EXISTS audit_events_result_time
|
||||||
|
ON audit_events (result COLLATE NOCASE, occurred_second DESC, id DESC);
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
def ensure_audit_schema(conn: sqlite3.Connection) -> None:
|
||||||
|
conn.executescript(AUDIT_SCHEMA)
|
||||||
|
conn.commit()
|
||||||
|
|
||||||
|
|
||||||
|
def parse_event_second(timestamp: object) -> int:
|
||||||
|
parsed = dt.datetime.fromisoformat(str(timestamp).replace("Z", "+00:00"))
|
||||||
|
if parsed.tzinfo is None:
|
||||||
|
parsed = parsed.replace(tzinfo=dt.timezone.utc)
|
||||||
|
return int(parsed.astimezone(dt.timezone.utc).timestamp())
|
||||||
|
|
||||||
|
|
||||||
|
def row_to_event(row: sqlite3.Row) -> Dict[str, object]:
|
||||||
|
action = str(row["action"])
|
||||||
|
return {
|
||||||
|
"id": int(row["id"]),
|
||||||
|
"timestamp": str(row["occurred_at"]),
|
||||||
|
"ingestedAt": str(row["ingested_at"]),
|
||||||
|
"user": str(row["user"]),
|
||||||
|
"clientIp": str(row["client_ip"]),
|
||||||
|
"client": str(row["client"]),
|
||||||
|
"share": str(row["share"]),
|
||||||
|
"operation": action,
|
||||||
|
"action": action,
|
||||||
|
"result": str(row["result"]),
|
||||||
|
"success": bool(row["success"]),
|
||||||
|
"path": str(row["path"]),
|
||||||
|
"source": str(row["source"]),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def last_event(conn: sqlite3.Connection) -> Optional[Dict[str, object]]:
|
||||||
|
row = conn.execute(
|
||||||
|
"""
|
||||||
|
SELECT id, occurred_at, ingested_at, user, client_ip, client, share,
|
||||||
|
action, result, success, path, source
|
||||||
|
FROM audit_events
|
||||||
|
ORDER BY id DESC
|
||||||
|
LIMIT 1
|
||||||
|
"""
|
||||||
|
).fetchone()
|
||||||
|
return row_to_event(row) if row is not None else None
|
||||||
|
|
||||||
|
|
||||||
|
class AuditStore:
|
||||||
|
def __init__(self, database_path: Optional[str] = None):
|
||||||
|
self.conn = connect_state_db(database_path)
|
||||||
|
ensure_audit_schema(self.conn)
|
||||||
|
|
||||||
|
def close(self) -> None:
|
||||||
|
self.conn.close()
|
||||||
|
|
||||||
|
def source_entries(self) -> Dict[str, Dict[str, object]]:
|
||||||
|
return {
|
||||||
|
str(row["path"]): {
|
||||||
|
"inode": int(row["inode"]),
|
||||||
|
"offset": int(row["offset"]),
|
||||||
|
}
|
||||||
|
for row in self.conn.execute(
|
||||||
|
"SELECT path, inode, offset FROM audit_sources"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
def append_batch(
|
||||||
|
self,
|
||||||
|
events: Iterable[Dict[str, object]],
|
||||||
|
source_updates: Dict[str, Dict[str, object]],
|
||||||
|
seen_paths: Set[str],
|
||||||
|
) -> int:
|
||||||
|
inserted = 0
|
||||||
|
self.conn.execute("BEGIN IMMEDIATE")
|
||||||
|
try:
|
||||||
|
previous_key = read_deduplication_key(last_event(self.conn) or {})
|
||||||
|
for event in events:
|
||||||
|
current_key = read_deduplication_key(event)
|
||||||
|
if current_key is not None and current_key == previous_key:
|
||||||
|
continue
|
||||||
|
self.conn.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO audit_events (
|
||||||
|
occurred_at, occurred_second, ingested_at, user, account,
|
||||||
|
client_ip, client, share, action, result, success, path,
|
||||||
|
source
|
||||||
|
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
|
""",
|
||||||
|
(
|
||||||
|
str(event["timestamp"]),
|
||||||
|
parse_event_second(event["timestamp"]),
|
||||||
|
str(event["ingestedAt"]),
|
||||||
|
str(event["user"]),
|
||||||
|
account_name(str(event["user"])).casefold(),
|
||||||
|
str(event["clientIp"]),
|
||||||
|
str(event["client"]),
|
||||||
|
str(event["share"]),
|
||||||
|
str(event["action"]),
|
||||||
|
str(event["result"]),
|
||||||
|
int(bool(event["success"])),
|
||||||
|
str(event["path"]),
|
||||||
|
str(event["source"]),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
inserted += 1
|
||||||
|
previous_key = current_key
|
||||||
|
|
||||||
|
for path, entry in source_updates.items():
|
||||||
|
self.conn.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO audit_sources (path, inode, offset)
|
||||||
|
VALUES (?, ?, ?)
|
||||||
|
ON CONFLICT(path) DO UPDATE SET
|
||||||
|
inode = excluded.inode,
|
||||||
|
offset = excluded.offset
|
||||||
|
""",
|
||||||
|
(path, int(entry["inode"]), int(entry["offset"])),
|
||||||
|
)
|
||||||
|
for row in self.conn.execute("SELECT path FROM audit_sources").fetchall():
|
||||||
|
path = str(row["path"])
|
||||||
|
if path not in seen_paths:
|
||||||
|
self.conn.execute("DELETE FROM audit_sources WHERE path = ?", (path,))
|
||||||
|
self.conn.commit()
|
||||||
|
except Exception:
|
||||||
|
self.conn.rollback()
|
||||||
|
raise
|
||||||
|
return inserted
|
||||||
|
|
||||||
|
|
||||||
|
def escape_like(value: str) -> str:
|
||||||
|
return value.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_")
|
||||||
|
|
||||||
|
|
||||||
|
def date_seconds(day: dt.date) -> int:
|
||||||
|
return int(
|
||||||
|
dt.datetime.combine(day, dt.time.min, tzinfo=dt.timezone.utc).timestamp()
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def activity_conditions(
|
||||||
|
start: dt.date,
|
||||||
|
end: dt.date,
|
||||||
|
params: Dict[str, List[str]],
|
||||||
|
*,
|
||||||
|
include_filters: bool,
|
||||||
|
) -> Tuple[List[str], List[object]]:
|
||||||
|
conditions = ["occurred_second >= ?", "occurred_second < ?"]
|
||||||
|
values: List[object] = [
|
||||||
|
date_seconds(start),
|
||||||
|
date_seconds(end + dt.timedelta(days=1)),
|
||||||
|
]
|
||||||
|
for suffix in skipped_user_suffixes():
|
||||||
|
conditions.append("lower(account) NOT LIKE ? ESCAPE '\\'")
|
||||||
|
values.append(f"%{escape_like(suffix)}")
|
||||||
|
|
||||||
|
if not include_filters:
|
||||||
|
return conditions, values
|
||||||
|
|
||||||
|
filters = {
|
||||||
|
key: params.get(key, [""])[0].casefold().strip()
|
||||||
|
for key in ("user", "share", "operation", "action", "path", "result")
|
||||||
|
}
|
||||||
|
user = filters["user"]
|
||||||
|
if user:
|
||||||
|
if "\\" in user or "@" in user:
|
||||||
|
conditions.append("user = ? COLLATE NOCASE")
|
||||||
|
else:
|
||||||
|
conditions.append("account = ?")
|
||||||
|
values.append(user)
|
||||||
|
share = filters["share"]
|
||||||
|
if share:
|
||||||
|
conditions.append("share = ? COLLATE NOCASE")
|
||||||
|
values.append(share)
|
||||||
|
path = filters["path"]
|
||||||
|
if path:
|
||||||
|
conditions.append("lower(path) LIKE ? ESCAPE '\\'")
|
||||||
|
values.append(f"%{escape_like(path)}%")
|
||||||
|
action = filters["action"] or filters["operation"]
|
||||||
|
if action:
|
||||||
|
conditions.append("action = ?")
|
||||||
|
values.append(action)
|
||||||
|
result = filters["result"]
|
||||||
|
if result == "fail":
|
||||||
|
conditions.append("success = 0")
|
||||||
|
elif result:
|
||||||
|
conditions.append("result = ? COLLATE NOCASE")
|
||||||
|
values.append(result)
|
||||||
|
return conditions, values
|
||||||
|
|
||||||
|
|
||||||
|
def query_activity(
|
||||||
|
conn: sqlite3.Connection,
|
||||||
|
start: dt.date,
|
||||||
|
end: dt.date,
|
||||||
|
params: Dict[str, List[str]],
|
||||||
|
) -> Dict[str, object]:
|
||||||
|
limit = min(500, max(1, int(params.get("limit", ["100"])[0])))
|
||||||
|
conditions, values = activity_conditions(start, end, params, include_filters=True)
|
||||||
|
where_sql = " AND ".join(conditions)
|
||||||
|
matched = int(
|
||||||
|
conn.execute(
|
||||||
|
f"SELECT count(*) FROM audit_events WHERE {where_sql}",
|
||||||
|
values,
|
||||||
|
).fetchone()[0]
|
||||||
|
)
|
||||||
|
|
||||||
|
cursor = params.get("cursor", [""])[0].strip()
|
||||||
|
page_conditions = list(conditions)
|
||||||
|
page_values = list(values)
|
||||||
|
if cursor:
|
||||||
|
try:
|
||||||
|
cursor_second, cursor_id = (int(part) for part in cursor.split(":", 1))
|
||||||
|
except (TypeError, ValueError) as exc:
|
||||||
|
raise ValueError("Ungültiger Seitenzeiger") from exc
|
||||||
|
page_conditions.append(
|
||||||
|
"(occurred_second < ? OR (occurred_second = ? AND id < ?))"
|
||||||
|
)
|
||||||
|
page_values.extend((cursor_second, cursor_second, cursor_id))
|
||||||
|
|
||||||
|
rows = conn.execute(
|
||||||
|
f"""
|
||||||
|
SELECT id, occurred_at, occurred_second, ingested_at, user, client_ip,
|
||||||
|
client, share, action, result, success, path, source
|
||||||
|
FROM audit_events
|
||||||
|
WHERE {" AND ".join(page_conditions)}
|
||||||
|
ORDER BY occurred_second DESC, id DESC
|
||||||
|
LIMIT ?
|
||||||
|
""",
|
||||||
|
(*page_values, limit + 1),
|
||||||
|
).fetchall()
|
||||||
|
has_more = len(rows) > limit
|
||||||
|
rows = rows[:limit]
|
||||||
|
events = [row_to_event(row) for row in rows]
|
||||||
|
next_cursor = None
|
||||||
|
if has_more and rows:
|
||||||
|
next_cursor = f"{int(rows[-1]['occurred_second'])}:{int(rows[-1]['id'])}"
|
||||||
|
|
||||||
|
facet_conditions, facet_values = activity_conditions(
|
||||||
|
start, end, params, include_filters=False
|
||||||
|
)
|
||||||
|
facet_where = " AND ".join(facet_conditions)
|
||||||
|
|
||||||
|
def distinct(column: str) -> List[str]:
|
||||||
|
return [
|
||||||
|
str(row[0])
|
||||||
|
for row in conn.execute(
|
||||||
|
f"""
|
||||||
|
SELECT DISTINCT {column}
|
||||||
|
FROM audit_events
|
||||||
|
WHERE {facet_where} AND {column} <> ''
|
||||||
|
ORDER BY {column} COLLATE NOCASE
|
||||||
|
""",
|
||||||
|
facet_values,
|
||||||
|
)
|
||||||
|
]
|
||||||
|
|
||||||
|
actions = distinct("action")
|
||||||
|
return {
|
||||||
|
"events": events,
|
||||||
|
"nextCursor": next_cursor,
|
||||||
|
"matched": matched,
|
||||||
|
"facets": {
|
||||||
|
"users": distinct("user"),
|
||||||
|
"shares": distinct("share"),
|
||||||
|
"operations": actions,
|
||||||
|
"actions": actions,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def audit_summary(conn: sqlite3.Connection, database_path: str) -> Dict[str, object]:
|
||||||
|
row = conn.execute(
|
||||||
|
"""
|
||||||
|
SELECT count(DISTINCT substr(occurred_at, 1, 10)),
|
||||||
|
min(substr(occurred_at, 1, 10)),
|
||||||
|
max(substr(occurred_at, 1, 10))
|
||||||
|
FROM audit_events
|
||||||
|
"""
|
||||||
|
).fetchone()
|
||||||
|
database_bytes = 0
|
||||||
|
for suffix in ("", "-wal"):
|
||||||
|
try:
|
||||||
|
database_bytes += os.path.getsize(f"{database_path}{suffix}")
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
return {
|
||||||
|
"days": int(row[0] or 0),
|
||||||
|
"bytes": database_bytes,
|
||||||
|
"oldest": row[1],
|
||||||
|
"newest": row[2],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def drop_legacy_audit_files(directory: str) -> int:
|
||||||
|
removed = 0
|
||||||
|
patterns = (
|
||||||
|
"????-??-??.jsonl",
|
||||||
|
"????-??-??.jsonl.gz",
|
||||||
|
"collector-state.json",
|
||||||
|
"collector-state.json.tmp",
|
||||||
|
)
|
||||||
|
for pattern in patterns:
|
||||||
|
for path in glob.glob(os.path.join(directory, pattern)):
|
||||||
|
if not os.path.isfile(path):
|
||||||
|
continue
|
||||||
|
os.remove(path)
|
||||||
|
removed += 1
|
||||||
|
try:
|
||||||
|
os.rmdir(directory)
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
return removed
|
||||||
@@ -5,6 +5,8 @@ import fcntl
|
|||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
import re
|
import re
|
||||||
|
import shutil
|
||||||
|
import sqlite3
|
||||||
import subprocess
|
import subprocess
|
||||||
import sys
|
import sys
|
||||||
import tempfile
|
import tempfile
|
||||||
@@ -14,7 +16,12 @@ from typing import Callable, Dict, List, Optional, Set, TextIO, Tuple
|
|||||||
from urllib.parse import SplitResult, unquote, urlsplit
|
from urllib.parse import SplitResult, unquote, urlsplit
|
||||||
|
|
||||||
|
|
||||||
LOCK_PATH = "/state/backup.lock"
|
STATE_ROOT = os.getenv("STATE_ROOT", "/state")
|
||||||
|
STATE_DB_PATH = os.getenv(
|
||||||
|
"STATE_DB_PATH",
|
||||||
|
os.getenv("SHARE_DB_PATH", os.path.join(STATE_ROOT, "shares.db")),
|
||||||
|
)
|
||||||
|
LOCK_PATH = os.path.join(STATE_ROOT, "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_BACKUP_STATUS_FILE = "/state/backup-status.json"
|
||||||
DEFAULT_PROGRESS_MODE = "auto"
|
DEFAULT_PROGRESS_MODE = "auto"
|
||||||
@@ -43,7 +50,7 @@ BACKUP_SOURCES: List[Tuple[str, str]] = [
|
|||||||
("/data/private", "data/private"),
|
("/data/private", "data/private"),
|
||||||
("/data/groups", "data/groups"),
|
("/data/groups", "data/groups"),
|
||||||
("/data/fslogix", "data/fslogix"),
|
("/data/fslogix", "data/fslogix"),
|
||||||
("/state", "state"),
|
(STATE_ROOT, "state"),
|
||||||
("/var/lib/samba/private", "samba/private"),
|
("/var/lib/samba/private", "samba/private"),
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -917,11 +924,75 @@ def redact_destination(raw_url: str) -> str:
|
|||||||
return f"{parts.scheme}://{netloc}{parts.path}"
|
return f"{parts.scheme}://{netloc}{parts.path}"
|
||||||
|
|
||||||
|
|
||||||
def available_sources() -> List[Tuple[str, str]]:
|
def prepare_state_snapshot(
|
||||||
|
state_root: str = STATE_ROOT,
|
||||||
|
database_path: str = STATE_DB_PATH,
|
||||||
|
) -> Tuple[str, str]:
|
||||||
|
"""Copy state and replace the live WAL database with an online snapshot."""
|
||||||
|
state_root = os.path.abspath(state_root)
|
||||||
|
database_path = os.path.abspath(database_path)
|
||||||
|
database_relative = os.path.relpath(database_path, state_root)
|
||||||
|
if database_relative == ".." or database_relative.startswith(f"..{os.sep}"):
|
||||||
|
raise RuntimeError("STATE_DB_PATH must be located below STATE_ROOT")
|
||||||
|
|
||||||
|
temporary_root = tempfile.mkdtemp(prefix="backup-state-")
|
||||||
|
staged_state = os.path.join(temporary_root, "state")
|
||||||
|
excluded = {
|
||||||
|
database_relative,
|
||||||
|
f"{database_relative}-wal",
|
||||||
|
f"{database_relative}-shm",
|
||||||
|
}
|
||||||
|
|
||||||
|
def ignore_live_database(directory: str, names: List[str]) -> Set[str]:
|
||||||
|
relative_directory = os.path.relpath(directory, state_root)
|
||||||
|
return {
|
||||||
|
name
|
||||||
|
for name in names
|
||||||
|
if os.path.normpath(os.path.join(relative_directory, name)) in excluded
|
||||||
|
}
|
||||||
|
|
||||||
|
try:
|
||||||
|
shutil.copytree(
|
||||||
|
state_root,
|
||||||
|
staged_state,
|
||||||
|
symlinks=True,
|
||||||
|
ignore=ignore_live_database,
|
||||||
|
)
|
||||||
|
if os.path.isfile(database_path):
|
||||||
|
staged_database = os.path.join(staged_state, database_relative)
|
||||||
|
os.makedirs(os.path.dirname(staged_database), exist_ok=True)
|
||||||
|
source = sqlite3.connect(
|
||||||
|
f"file:{database_path}?mode=ro",
|
||||||
|
uri=True,
|
||||||
|
timeout=30,
|
||||||
|
)
|
||||||
|
destination = sqlite3.connect(staged_database, timeout=30)
|
||||||
|
try:
|
||||||
|
source.execute("PRAGMA busy_timeout = 30000")
|
||||||
|
source.backup(destination)
|
||||||
|
if destination.execute("PRAGMA quick_check").fetchone()[0] != "ok":
|
||||||
|
raise RuntimeError("SQLite state snapshot failed its integrity check")
|
||||||
|
finally:
|
||||||
|
destination.close()
|
||||||
|
source.close()
|
||||||
|
return temporary_root, staged_state
|
||||||
|
except Exception:
|
||||||
|
shutil.rmtree(temporary_root, ignore_errors=True)
|
||||||
|
raise
|
||||||
|
|
||||||
|
|
||||||
|
def available_sources(
|
||||||
|
state_snapshot: Optional[str] = None,
|
||||||
|
) -> List[Tuple[str, str]]:
|
||||||
sources: List[Tuple[str, str]] = []
|
sources: List[Tuple[str, str]] = []
|
||||||
for source_path, destination_path in BACKUP_SOURCES:
|
for source_path, destination_path in BACKUP_SOURCES:
|
||||||
if os.path.isdir(source_path):
|
effective_source = (
|
||||||
sources.append((source_path, destination_path))
|
state_snapshot
|
||||||
|
if destination_path == "state" and state_snapshot is not None
|
||||||
|
else source_path
|
||||||
|
)
|
||||||
|
if os.path.isdir(effective_source):
|
||||||
|
sources.append((effective_source, destination_path))
|
||||||
else:
|
else:
|
||||||
log(f"Skipping missing source: {source_path}")
|
log(f"Skipping missing source: {source_path}")
|
||||||
return sources
|
return sources
|
||||||
@@ -1455,11 +1526,6 @@ def run_backup() -> int:
|
|||||||
return 0
|
return 0
|
||||||
|
|
||||||
policy = parse_retention_policy()
|
policy = parse_retention_policy()
|
||||||
sources = available_sources()
|
|
||||||
if not sources:
|
|
||||||
log("No backup sources are available, skipping backup")
|
|
||||||
return 0
|
|
||||||
|
|
||||||
progress_interval_seconds = parse_int_env(
|
progress_interval_seconds = parse_int_env(
|
||||||
"BACKUP_PROGRESS_INTERVAL_SECONDS",
|
"BACKUP_PROGRESS_INTERVAL_SECONDS",
|
||||||
DEFAULT_PROGRESS_INTERVAL_SECONDS,
|
DEFAULT_PROGRESS_INTERVAL_SECONDS,
|
||||||
@@ -1474,8 +1540,19 @@ def run_backup() -> int:
|
|||||||
os.getenv("BACKUP_STATUS_FILE", DEFAULT_BACKUP_STATUS_FILE).strip()
|
os.getenv("BACKUP_STATUS_FILE", DEFAULT_BACKUP_STATUS_FILE).strip()
|
||||||
)
|
)
|
||||||
BACKUP_STATUS.begin(redact_destination(destination.raw_url))
|
BACKUP_STATUS.begin(redact_destination(destination.raw_url))
|
||||||
backend = build_backend(destination)
|
backend = None
|
||||||
|
state_snapshot_root = None
|
||||||
try:
|
try:
|
||||||
|
state_snapshot = None
|
||||||
|
if os.path.isdir(STATE_ROOT):
|
||||||
|
BACKUP_STATUS.write(message="Creating consistent state database snapshot")
|
||||||
|
state_snapshot_root, state_snapshot = prepare_state_snapshot()
|
||||||
|
sources = available_sources(state_snapshot)
|
||||||
|
if not sources:
|
||||||
|
log("No backup sources are available, skipping backup")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
backend = build_backend(destination)
|
||||||
log(f"Starting backup to {redact_destination(destination.raw_url)}")
|
log(f"Starting backup to {redact_destination(destination.raw_url)}")
|
||||||
|
|
||||||
existing = set(backend.list_snapshots())
|
existing = set(backend.list_snapshots())
|
||||||
@@ -1494,7 +1571,7 @@ def run_backup() -> int:
|
|||||||
)
|
)
|
||||||
|
|
||||||
for source_path, destination_path in sources:
|
for source_path, destination_path in sources:
|
||||||
log(f"Syncing {source_path}")
|
log(f"Syncing {destination_path}")
|
||||||
backend.sync_source(
|
backend.sync_source(
|
||||||
snapshot_name,
|
snapshot_name,
|
||||||
source_path,
|
source_path,
|
||||||
@@ -1527,7 +1604,10 @@ def run_backup() -> int:
|
|||||||
)
|
)
|
||||||
return 0
|
return 0
|
||||||
finally:
|
finally:
|
||||||
|
if backend is not None:
|
||||||
backend.close()
|
backend.close()
|
||||||
|
if state_snapshot_root is not None:
|
||||||
|
shutil.rmtree(state_snapshot_root, ignore_errors=True)
|
||||||
|
|
||||||
|
|
||||||
def with_lock() -> int:
|
def with_lock() -> int:
|
||||||
|
|||||||
+1
-1
@@ -584,7 +584,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 /state/audit /state/web /var/log/samba
|
mkdir -p /data/private /data/fslogix /data/groups/data /data/groups/archive /state /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
|
||||||
|
|||||||
@@ -15,8 +15,13 @@ import uuid
|
|||||||
from dataclasses import dataclass, field
|
from dataclasses import dataclass, field
|
||||||
from typing import Callable, Dict, Iterable, List, Optional, Set, Tuple
|
from typing import Callable, Dict, Iterable, List, Optional, Set, Tuple
|
||||||
|
|
||||||
|
try:
|
||||||
|
from .state_db import STATE_DB_PATH, connect_state_db
|
||||||
|
except ImportError:
|
||||||
|
from state_db import STATE_DB_PATH, connect_state_db
|
||||||
|
|
||||||
DB_PATH = "/state/shares.db"
|
|
||||||
|
DB_PATH = STATE_DB_PATH
|
||||||
LOCK_PATH = "/state/reconcile.lock"
|
LOCK_PATH = "/state/reconcile.lock"
|
||||||
GROUP_ROOT = "/data/groups/data"
|
GROUP_ROOT = "/data/groups/data"
|
||||||
GROUP_ARCHIVE_ROOT = "/data/groups/archive"
|
GROUP_ARCHIVE_ROOT = "/data/groups/archive"
|
||||||
@@ -720,9 +725,7 @@ def ensure_share_db_schema(conn: sqlite3.Connection) -> None:
|
|||||||
|
|
||||||
|
|
||||||
def open_db() -> sqlite3.Connection:
|
def open_db() -> sqlite3.Connection:
|
||||||
os.makedirs(os.path.dirname(DB_PATH), exist_ok=True)
|
conn = connect_state_db(DB_PATH)
|
||||||
conn = sqlite3.connect(DB_PATH)
|
|
||||||
conn.row_factory = sqlite3.Row
|
|
||||||
conn.execute(
|
conn.execute(
|
||||||
"""
|
"""
|
||||||
CREATE TABLE IF NOT EXISTS shares (
|
CREATE TABLE IF NOT EXISTS shares (
|
||||||
|
|||||||
@@ -0,0 +1,69 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Connections to the shared persistent SQLite state database."""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import sqlite3
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
|
||||||
|
STATE_DB_PATH = os.getenv(
|
||||||
|
"STATE_DB_PATH",
|
||||||
|
os.getenv("SHARE_DB_PATH", "/state/shares.db"),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def connect_state_db(
|
||||||
|
path: Optional[str] = None,
|
||||||
|
*,
|
||||||
|
read_only: bool = False,
|
||||||
|
) -> sqlite3.Connection:
|
||||||
|
database_path = path or STATE_DB_PATH
|
||||||
|
if read_only:
|
||||||
|
conn = sqlite3.connect(
|
||||||
|
f"file:{database_path}?mode=ro",
|
||||||
|
uri=True,
|
||||||
|
timeout=30,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
directory = os.path.dirname(database_path)
|
||||||
|
if directory:
|
||||||
|
os.makedirs(directory, exist_ok=True)
|
||||||
|
conn = sqlite3.connect(database_path, timeout=30)
|
||||||
|
conn.row_factory = sqlite3.Row
|
||||||
|
conn.execute("PRAGMA busy_timeout = 30000")
|
||||||
|
conn.execute("PRAGMA foreign_keys = ON")
|
||||||
|
if not read_only:
|
||||||
|
conn.execute("PRAGMA journal_mode = WAL")
|
||||||
|
conn.execute("PRAGMA synchronous = NORMAL")
|
||||||
|
return conn
|
||||||
|
|
||||||
|
|
||||||
|
def ensure_web_cache_schema(conn: sqlite3.Connection) -> None:
|
||||||
|
conn.execute(
|
||||||
|
"""
|
||||||
|
CREATE TABLE IF NOT EXISTS web_cache (
|
||||||
|
key TEXT PRIMARY KEY,
|
||||||
|
value TEXT NOT NULL,
|
||||||
|
updated_at TEXT NOT NULL
|
||||||
|
)
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
|
||||||
|
|
||||||
|
def drop_legacy_web_cache(directory: Optional[str] = None) -> int:
|
||||||
|
legacy_directory = directory or os.getenv("WEB_STATE_DIR", "/state/web")
|
||||||
|
removed = 0
|
||||||
|
for name in ("usage.json", "usage.json.tmp"):
|
||||||
|
path = os.path.join(legacy_directory, name)
|
||||||
|
try:
|
||||||
|
os.remove(path)
|
||||||
|
removed += 1
|
||||||
|
except FileNotFoundError:
|
||||||
|
pass
|
||||||
|
try:
|
||||||
|
os.rmdir(legacy_directory)
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
return removed
|
||||||
|
|
||||||
+2
-2
@@ -306,12 +306,12 @@ async function renderSystem() {
|
|||||||
<section class="panel"><div class="panel-head"><h2>Dienstprüfungen</h2><span>${esc(data.hostname)}</span></div><div class="check-list">
|
<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>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>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>Aktivitätsdatenbank</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 class="check"><span>Speicherprüfung</span><strong>${usage.scannedAt ? esc(utcTime(usage.scannedAt)) : "Ausstehend"}</strong></div>
|
||||||
</div></section>
|
</div></section>
|
||||||
<section class="split">
|
<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>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>
|
<article class="panel"><h2>Protokollspeicherung</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>Datenbankgröße</dt><dd>${bytes(data.audit.bytes)}</dd></dl><p class="muted">Ereignisse werden dauerhaft und indexiert in der gemeinsamen SQLite-Datenbank gespeichert.</p></article>
|
||||||
</section>`;
|
</section>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+86
-146
@@ -3,7 +3,6 @@
|
|||||||
|
|
||||||
import base64
|
import base64
|
||||||
import datetime as dt
|
import datetime as dt
|
||||||
import gzip
|
|
||||||
import hashlib
|
import hashlib
|
||||||
import hmac
|
import hmac
|
||||||
import http.cookies
|
import http.cookies
|
||||||
@@ -23,7 +22,7 @@ import urllib.parse
|
|||||||
from collections import deque
|
from collections import deque
|
||||||
from http import HTTPStatus
|
from http import HTTPStatus
|
||||||
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||||
from typing import Dict, Iterable, List, Optional, Tuple
|
from typing import Dict, List, Optional, Tuple
|
||||||
|
|
||||||
try:
|
try:
|
||||||
from app import reconcile_shares as directory
|
from app import reconcile_shares as directory
|
||||||
@@ -31,20 +30,37 @@ except ImportError: # Container execution uses /app as the import root.
|
|||||||
import reconcile_shares as directory
|
import reconcile_shares as directory
|
||||||
|
|
||||||
try:
|
try:
|
||||||
from app.audit_policy import AUDIT_ACTIONS, action_for, skip_user
|
from app.audit_store import (
|
||||||
|
audit_summary as sqlite_audit_summary,
|
||||||
|
ensure_audit_schema,
|
||||||
|
query_activity,
|
||||||
|
)
|
||||||
|
from app.state_db import (
|
||||||
|
STATE_DB_PATH,
|
||||||
|
connect_state_db,
|
||||||
|
drop_legacy_web_cache,
|
||||||
|
ensure_web_cache_schema,
|
||||||
|
)
|
||||||
except ImportError: # Container execution uses /app as the import root.
|
except ImportError: # Container execution uses /app as the import root.
|
||||||
from audit_policy import AUDIT_ACTIONS, action_for, skip_user
|
from audit_store import (
|
||||||
|
audit_summary as sqlite_audit_summary,
|
||||||
|
ensure_audit_schema,
|
||||||
|
query_activity,
|
||||||
|
)
|
||||||
|
from state_db import (
|
||||||
|
STATE_DB_PATH,
|
||||||
|
connect_state_db,
|
||||||
|
drop_legacy_web_cache,
|
||||||
|
ensure_web_cache_schema,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
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")
|
STATIC_ROOT = os.getenv("WEB_STATIC_DIR", "/app/web")
|
||||||
SHARE_DB = os.getenv("SHARE_DB_PATH", "/state/shares.db")
|
STATE_DB = STATE_DB_PATH
|
||||||
BACKUP_STATUS_FILE = os.getenv("BACKUP_STATUS_FILE", "/state/backup-status.json")
|
BACKUP_STATUS_FILE = os.getenv("BACKUP_STATUS_FILE", "/state/backup-status.json")
|
||||||
BACKUP_LOG_FILE = os.getenv("BACKUP_LOG_FILE", "/var/log/backup.log")
|
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_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")
|
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_COOKIE = "adfs_session"
|
||||||
JWT_ISSUER = "ad-file-server-web"
|
JWT_ISSUER = "ad-file-server-web"
|
||||||
JWT_AUDIENCE = "domain-admins"
|
JWT_AUDIENCE = "domain-admins"
|
||||||
@@ -69,16 +85,6 @@ def env_int(name: str, default: int, minimum: int, maximum: int) -> int:
|
|||||||
return default
|
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):
|
def read_json(path: str, default):
|
||||||
try:
|
try:
|
||||||
with open(path, encoding="utf-8") as handle:
|
with open(path, encoding="utf-8") as handle:
|
||||||
@@ -316,11 +322,49 @@ def scan_children(root: str) -> List[Dict[str, object]]:
|
|||||||
return rows
|
return rows
|
||||||
|
|
||||||
|
|
||||||
|
def load_usage_cache() -> Dict[str, object]:
|
||||||
|
try:
|
||||||
|
conn = connect_state_db(STATE_DB)
|
||||||
|
try:
|
||||||
|
ensure_web_cache_schema(conn)
|
||||||
|
row = conn.execute(
|
||||||
|
"SELECT value FROM web_cache WHERE key = 'usage'"
|
||||||
|
).fetchone()
|
||||||
|
value = json.loads(str(row[0])) if row is not None else {}
|
||||||
|
return value if isinstance(value, dict) else {}
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
except (sqlite3.Error, ValueError):
|
||||||
|
return {}
|
||||||
|
|
||||||
|
|
||||||
|
def store_usage_cache(value: Dict[str, object]) -> None:
|
||||||
|
conn = connect_state_db(STATE_DB)
|
||||||
|
try:
|
||||||
|
ensure_web_cache_schema(conn)
|
||||||
|
with conn:
|
||||||
|
conn.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO web_cache (key, value, updated_at)
|
||||||
|
VALUES ('usage', ?, ?)
|
||||||
|
ON CONFLICT(key) DO UPDATE SET
|
||||||
|
value = excluded.value,
|
||||||
|
updated_at = excluded.updated_at
|
||||||
|
""",
|
||||||
|
(
|
||||||
|
json.dumps(value, separators=(",", ":"), sort_keys=True),
|
||||||
|
now_utc().isoformat(timespec="seconds"),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
class UsageScanner:
|
class UsageScanner:
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
self.interval = env_int("WEB_USAGE_SCAN_INTERVAL_SECONDS", 900, 60, 86400)
|
self.interval = env_int("WEB_USAGE_SCAN_INTERVAL_SECONDS", 900, 60, 86400)
|
||||||
self.lock = threading.Lock()
|
self.lock = threading.Lock()
|
||||||
self.data = read_json(USAGE_CACHE_FILE, {})
|
self.data = load_usage_cache()
|
||||||
self.stop = threading.Event()
|
self.stop = threading.Event()
|
||||||
|
|
||||||
def snapshot(self) -> Dict[str, object]:
|
def snapshot(self) -> Dict[str, object]:
|
||||||
@@ -368,7 +412,7 @@ class UsageScanner:
|
|||||||
"fslogixBytes": sum(int(row["fslogixBytes"]) for row in user_rows),
|
"fslogixBytes": sum(int(row["fslogixBytes"]) for row in user_rows),
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
atomic_json(USAGE_CACHE_FILE, value)
|
store_usage_cache(value)
|
||||||
with self.lock:
|
with self.lock:
|
||||||
self.data = value
|
self.data = value
|
||||||
return value
|
return value
|
||||||
@@ -445,7 +489,7 @@ class DirectoryCache:
|
|||||||
|
|
||||||
folder_map = {}
|
folder_map = {}
|
||||||
try:
|
try:
|
||||||
conn = sqlite3.connect(f"file:{SHARE_DB}?mode=ro", uri=True)
|
conn = connect_state_db(STATE_DB, read_only=True)
|
||||||
try:
|
try:
|
||||||
folder_map = {
|
folder_map = {
|
||||||
row[0]: {"folder": os.path.basename(row[1]), "active": bool(row[2])}
|
row[0]: {"folder": os.path.basename(row[1]), "active": bool(row[2])}
|
||||||
@@ -515,60 +559,6 @@ class DirectoryCache:
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
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]:
|
def query_audit(params: Dict[str, List[str]]) -> Dict[str, object]:
|
||||||
@@ -582,62 +572,11 @@ def query_audit(params: Dict[str, List[str]]) -> Dict[str, object]:
|
|||||||
max_days = env_int("AUDIT_QUERY_MAX_DAYS", 31, 1, 366)
|
max_days = env_int("AUDIT_QUERY_MAX_DAYS", 31, 1, 366)
|
||||||
if end < start or (end - start).days >= max_days:
|
if end < start or (end - start).days >= max_days:
|
||||||
raise ValueError(f"Der Datumsbereich darf höchstens {max_days} Tage umfassen")
|
raise ValueError(f"Der Datumsbereich darf höchstens {max_days} Tage umfassen")
|
||||||
limit = min(500, max(1, int(params.get("limit", ["100"])[0])))
|
conn = connect_state_db(STATE_DB, read_only=True)
|
||||||
offset = max(0, int(params.get("cursor", ["0"])[0]))
|
try:
|
||||||
filters = {
|
return query_activity(conn, start, end, params)
|
||||||
key: params.get(key, [""])[0].casefold().strip()
|
finally:
|
||||||
for key in ("user", "share", "operation", "action", "path", "result")
|
conn.close()
|
||||||
}
|
|
||||||
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):
|
|
||||||
user = str(event.get("user", ""))
|
|
||||||
operation = str(event.get("operation", ""))
|
|
||||||
action = action_for(operation)
|
|
||||||
if action is None and not operation:
|
|
||||||
stored_action = str(event.get("action", "")).casefold()
|
|
||||||
action = stored_action if stored_action in AUDIT_ACTIONS else None
|
|
||||||
if action is None or skip_user(user):
|
|
||||||
continue
|
|
||||||
if event.get("action") != action:
|
|
||||||
event = {**event, "action": action}
|
|
||||||
facets["users"].add(user)
|
|
||||||
facets["shares"].add(str(event.get("share", "")))
|
|
||||||
facets["operations"].add(operation)
|
|
||||||
facets["actions"].add(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]:
|
def tail_lines(path: str, count: int) -> List[str]:
|
||||||
@@ -666,7 +605,7 @@ def backup_payload() -> Dict[str, object]:
|
|||||||
|
|
||||||
def share_count() -> int:
|
def share_count() -> int:
|
||||||
try:
|
try:
|
||||||
conn = sqlite3.connect(f"file:{SHARE_DB}?mode=ro", uri=True)
|
conn = connect_state_db(STATE_DB, read_only=True)
|
||||||
try:
|
try:
|
||||||
return int(conn.execute("SELECT count(*) FROM shares WHERE isActive = 1").fetchone()[0])
|
return int(conn.execute("SELECT count(*) FROM shares WHERE isActive = 1").fetchone()[0])
|
||||||
finally:
|
finally:
|
||||||
@@ -676,21 +615,14 @@ def share_count() -> int:
|
|||||||
|
|
||||||
|
|
||||||
def audit_archive_summary() -> Dict[str, object]:
|
def audit_archive_summary() -> Dict[str, object]:
|
||||||
files = []
|
|
||||||
total_bytes = 0
|
|
||||||
try:
|
try:
|
||||||
names = os.listdir(AUDIT_ROOT)
|
conn = connect_state_db(STATE_DB, read_only=True)
|
||||||
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:
|
try:
|
||||||
total_bytes += os.path.getsize(path)
|
return sqlite_audit_summary(conn, STATE_DB)
|
||||||
files.append(name)
|
finally:
|
||||||
except OSError:
|
conn.close()
|
||||||
continue
|
except sqlite3.Error:
|
||||||
return {"days": len(files), "bytes": total_bytes, "oldest": min(files)[:10] if files else None, "newest": max(files)[:10] if files else None}
|
return {"days": 0, "bytes": 0, "oldest": None, "newest": None}
|
||||||
|
|
||||||
|
|
||||||
def tls_summary() -> Dict[str, object]:
|
def tls_summary() -> Dict[str, object]:
|
||||||
@@ -711,7 +643,16 @@ class App:
|
|||||||
def __init__(self):
|
def __init__(self):
|
||||||
secret = os.environ.get("WEB_JWT_SECRET", "")
|
secret = os.environ.get("WEB_JWT_SECRET", "")
|
||||||
self.tokens = TokenManager(secret, env_int("WEB_JWT_TTL_SECONDS", 28800, 300, 604800))
|
self.tokens = TokenManager(secret, env_int("WEB_JWT_TTL_SECONDS", 28800, 300, 604800))
|
||||||
|
removed = drop_legacy_web_cache()
|
||||||
|
if removed:
|
||||||
|
log(f"Dropped {removed} legacy web cache file(s)")
|
||||||
self.usage = UsageScanner()
|
self.usage = UsageScanner()
|
||||||
|
conn = connect_state_db(STATE_DB)
|
||||||
|
try:
|
||||||
|
ensure_audit_schema(conn)
|
||||||
|
ensure_web_cache_schema(conn)
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
self.directory = DirectoryCache()
|
self.directory = DirectoryCache()
|
||||||
threading.Thread(target=self.usage.run, name="usage-scanner", daemon=True).start()
|
threading.Thread(target=self.usage.run, name="usage-scanner", daemon=True).start()
|
||||||
|
|
||||||
@@ -916,7 +857,6 @@ def serve_https() -> None:
|
|||||||
|
|
||||||
def main() -> int:
|
def main() -> int:
|
||||||
global APP
|
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):
|
if not os.path.isfile(TLS_CERT_FILE) or not os.path.isfile(TLS_KEY_FILE):
|
||||||
raise RuntimeError("TLS certificate or key is missing")
|
raise RuntimeError("TLS certificate or key is missing")
|
||||||
APP = App()
|
APP = App()
|
||||||
|
|||||||
+57
-19
@@ -2,7 +2,6 @@
|
|||||||
"""End-to-end checks for the disposable preview domain and file server."""
|
"""End-to-end checks for the disposable preview domain and file server."""
|
||||||
|
|
||||||
import base64
|
import base64
|
||||||
import datetime as dt
|
|
||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
import socket
|
import socket
|
||||||
@@ -302,25 +301,64 @@ def main() -> int:
|
|||||||
check(len(one_event.get("events", [])) == 1, "activity limit was ignored")
|
check(len(one_event.get("events", [])) == 1, "activity limit was ignored")
|
||||||
check(one_event.get("nextCursor") is not None, "activity pagination cursor missing")
|
check(one_event.get("nextCursor") is not None, "activity pagination cursor missing")
|
||||||
|
|
||||||
announce("closed daily log compression and querying gzip history")
|
announce("shared SQLite state, indexes, integrity, and ordered read deduplication")
|
||||||
old_day = (dt.datetime.now(dt.timezone.utc).date() - dt.timedelta(days=2)).isoformat()
|
integrity = engine_run(
|
||||||
eventually(
|
"exec", FILES_CONTAINER, "sqlite3", "/state/shares.db", "PRAGMA quick_check;"
|
||||||
"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(
|
check(integrity.stdout.strip() == "ok", "shared SQLite database failed quick_check")
|
||||||
query_path(
|
tables = set(
|
||||||
"/api/activity",
|
engine_run(
|
||||||
{"from": old_day, "to": old_day, "user": "archived-user", "limit": "10"},
|
"exec",
|
||||||
),
|
FILES_CONTAINER,
|
||||||
token=token,
|
"sqlite3",
|
||||||
|
"/state/shares.db",
|
||||||
|
"SELECT name FROM sqlite_schema WHERE type='table' ORDER BY name;",
|
||||||
|
).stdout.splitlines()
|
||||||
)
|
)
|
||||||
check(archived.status == 200 and archived.json().get("matched") == 1, "compressed audit history is not searchable")
|
check(
|
||||||
|
{"shares", "audit_events", "audit_sources", "web_cache"}.issubset(tables),
|
||||||
|
f"shared SQLite tables are incomplete: {sorted(tables)}",
|
||||||
|
)
|
||||||
|
indexes = set(
|
||||||
|
engine_run(
|
||||||
|
"exec",
|
||||||
|
FILES_CONTAINER,
|
||||||
|
"sqlite3",
|
||||||
|
"/state/shares.db",
|
||||||
|
"SELECT name FROM sqlite_schema WHERE type='index' AND name LIKE 'audit_events_%';",
|
||||||
|
).stdout.splitlines()
|
||||||
|
)
|
||||||
|
check(
|
||||||
|
{"audit_events_time", "audit_events_user_time", "audit_events_action_time"}.issubset(indexes),
|
||||||
|
f"audit indexes are incomplete: {sorted(indexes)}",
|
||||||
|
)
|
||||||
|
duplicate_reads = engine_run(
|
||||||
|
"exec",
|
||||||
|
FILES_CONTAINER,
|
||||||
|
"sqlite3",
|
||||||
|
"/state/shares.db",
|
||||||
|
"""SELECT count(*) FROM (
|
||||||
|
SELECT action, occurred_second, user, client_ip, share, path, success, result,
|
||||||
|
lag(action) OVER (ORDER BY id) AS previous_action,
|
||||||
|
lag(occurred_second) OVER (ORDER BY id) AS previous_second,
|
||||||
|
lag(user) OVER (ORDER BY id) AS previous_user,
|
||||||
|
lag(client_ip) OVER (ORDER BY id) AS previous_client_ip,
|
||||||
|
lag(share) OVER (ORDER BY id) AS previous_share,
|
||||||
|
lag(path) OVER (ORDER BY id) AS previous_path,
|
||||||
|
lag(success) OVER (ORDER BY id) AS previous_success,
|
||||||
|
lag(result) OVER (ORDER BY id) AS previous_result
|
||||||
|
FROM audit_events
|
||||||
|
) WHERE action='read' AND previous_action='read'
|
||||||
|
AND occurred_second=previous_second AND user=previous_user
|
||||||
|
AND client_ip=previous_client_ip AND share=previous_share
|
||||||
|
AND path=previous_path AND success=previous_success
|
||||||
|
AND (success=1 OR result=previous_result);""",
|
||||||
|
)
|
||||||
|
check(duplicate_reads.stdout.strip() == "0", "uninterrupted duplicate reads remain")
|
||||||
|
legacy_archive = engine_run(
|
||||||
|
"exec", FILES_CONTAINER, "test", "!", "-e", "/state/audit", check_result=False
|
||||||
|
)
|
||||||
|
check(legacy_archive.returncode == 0, "legacy JSONL audit archive still exists")
|
||||||
|
|
||||||
announce("real rsync backup, status API, log tail, and remote completion marker")
|
announce("real rsync backup, status API, log tail, and remote completion marker")
|
||||||
backup = eventually(
|
backup = eventually(
|
||||||
@@ -350,7 +388,7 @@ def main() -> int:
|
|||||||
check(system_payload.get("checks", {}).get("domainTrust") is True, "system reports broken AD trust")
|
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("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("tls", {}).get("sans"), "system TLS summary is empty")
|
||||||
check(system_payload.get("audit", {}).get("days", 0) >= 2, "system audit archive summary is incomplete")
|
check(system_payload.get("audit", {}).get("days", 0) >= 1, "system activity database summary is incomplete")
|
||||||
|
|
||||||
logout = http("/api/logout", method="POST", value={}, token=token)
|
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")
|
check(logout.status == 200 and "Max-Age=0" in logout.headers.get("Set-Cookie", ""), "logout did not clear session cookie")
|
||||||
|
|||||||
@@ -35,7 +35,7 @@ fi
|
|||||||
|
|
||||||
printf 'Generated by the preview SMB client.\n' > /tmp/live-note.txt
|
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 alice Data 'cd Finance; mkdir Reports; cd Reports; put /tmp/live-note.txt live-note.txt; ls' >/dev/null
|
||||||
smb alice Data 'cd Finance; cd Reports; put /tmp/live-note.txt audit-source.txt; get audit-source.txt /tmp/audit-readback.txt; rename audit-source.txt audit-moved.txt; del audit-moved.txt' >/dev/null
|
smb alice Data 'cd Finance; cd Reports; put /tmp/live-note.txt audit-source.txt; get audit-source.txt /tmp/audit-readback-1.txt; get audit-source.txt /tmp/audit-readback-2.txt; get audit-source.txt /tmp/audit-readback-3.txt; rename audit-source.txt audit-moved.txt; del audit-moved.txt' >/dev/null
|
||||||
smb carol Data 'cd Engineering; mkdir Designs; cd Designs; put /tmp/live-note.txt architecture.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
|
smb alice Private 'cd alice; put /tmp/live-note.txt notes.txt; ls' >/dev/null
|
||||||
smb report_svc Data 'cd Finance; cd Reports; put /tmp/live-note.txt ignored-service-event.txt; get ignored-service-event.txt /tmp/ignored-service-readback.txt; del ignored-service-event.txt' >/dev/null
|
smb report_svc Data 'cd Finance; cd Reports; put /tmp/live-note.txt ignored-service-event.txt; get ignored-service-event.txt /tmp/ignored-service-readback.txt; del ignored-service-event.txt' >/dev/null
|
||||||
|
|||||||
+2
-7
@@ -13,8 +13,7 @@ mkdir -p \
|
|||||||
/data/private/dave \
|
/data/private/dave \
|
||||||
/data/private/eve \
|
/data/private/eve \
|
||||||
/data/fslogix/alice_S-1-5-21-111-222-333-1101 \
|
/data/fslogix/alice_S-1-5-21-111-222-333-1101 \
|
||||||
/data/fslogix/carol_S-1-5-21-111-222-333-1103 \
|
/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 '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 '# Architecture\n\nPreview design notes.\n' > /data/groups/data/Engineering/Designs/architecture.md
|
||||||
@@ -29,9 +28,5 @@ dd if=/dev/zero of=/data/groups/data/Finance/Reports/history.bin bs=1M count="$s
|
|||||||
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/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
|
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'
|
printf '[preview-seed] Seeded group, private, and FSLogix data.\n'
|
||||||
|
|||||||
+1
-2
@@ -279,7 +279,7 @@ if ! wait_for_exec "$dc_container" 150 test -f /run/preview-ready; then
|
|||||||
fi
|
fi
|
||||||
domain_sid=$("$engine" exec "$dc_container" cat /run/domain-sid)
|
domain_sid=$("$engine" exec "$dc_container" cat /run/domain-sid)
|
||||||
|
|
||||||
printf 'seeding preview files and an old audit archive\n'
|
printf 'seeding preview files\n'
|
||||||
"$engine" run --rm \
|
"$engine" run --rm \
|
||||||
--network "$network_name" \
|
--network "$network_name" \
|
||||||
-e "DEV_SEED_MB=${DEV_SEED_MB:-8}" \
|
-e "DEV_SEED_MB=${DEV_SEED_MB:-8}" \
|
||||||
@@ -325,7 +325,6 @@ printf 'starting actual file server and HTTPS web UI\n'
|
|||||||
-e "WEB_USAGE_SCAN_INTERVAL_SECONDS=60" \
|
-e "WEB_USAGE_SCAN_INTERVAL_SECONDS=60" \
|
||||||
-e "WEB_DIRECTORY_CACHE_SECONDS=30" \
|
-e "WEB_DIRECTORY_CACHE_SECONDS=30" \
|
||||||
-e "AUDIT_POLL_SECONDS=0.25" \
|
-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_DESTINATION=rsync://${dev_backup_user}:${dev_backup_password}@backup.${dev_dns_domain}/backups/fileserver" \
|
||||||
-e "BACKUP_PROGRESS=never" \
|
-e "BACKUP_PROGRESS=never" \
|
||||||
-e "BACKUP_PROGRESS_INTERVAL_SECONDS=1" \
|
-e "BACKUP_PROGRESS_INTERVAL_SECONDS=1" \
|
||||||
|
|||||||
@@ -411,7 +411,7 @@ ACME_HTTP_PORT=${acme_http_port}
|
|||||||
# WEB_JWT_TTL_SECONDS=28800
|
# WEB_JWT_TTL_SECONDS=28800
|
||||||
# WEB_USAGE_SCAN_INTERVAL_SECONDS=900
|
# WEB_USAGE_SCAN_INTERVAL_SECONDS=900
|
||||||
# WEB_DIRECTORY_CACHE_SECONDS=300
|
# WEB_DIRECTORY_CACHE_SECONDS=300
|
||||||
# AUDIT_COMPRESS_AFTER_HOURS=24
|
# STATE_DB_PATH=/state/shares.db
|
||||||
# AUDIT_QUERY_MAX_DAYS=31
|
# AUDIT_QUERY_MAX_DAYS=31
|
||||||
# AUDIT_SKIP_USER_SUFFIXES=_svc,_ServiceAcc
|
# AUDIT_SKIP_USER_SUFFIXES=_svc,_ServiceAcc
|
||||||
# ACME_RENEW_CHECK_SECONDS=900
|
# ACME_RENEW_CHECK_SECONDS=900
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
import io
|
import io
|
||||||
import os
|
import os
|
||||||
|
import shutil
|
||||||
|
import sqlite3
|
||||||
import tempfile
|
import tempfile
|
||||||
import unittest
|
import unittest
|
||||||
from unittest import mock
|
from unittest import mock
|
||||||
@@ -186,6 +188,56 @@ class ProgressParsingTests(unittest.TestCase):
|
|||||||
self.assertEqual(total, 8)
|
self.assertEqual(total, 8)
|
||||||
|
|
||||||
|
|
||||||
|
class StateSnapshotTests(unittest.TestCase):
|
||||||
|
def test_online_snapshot_includes_wal_commits_and_other_state(self):
|
||||||
|
with tempfile.TemporaryDirectory() as tmpdir:
|
||||||
|
state_root = os.path.join(tmpdir, "state")
|
||||||
|
os.mkdir(state_root)
|
||||||
|
database = os.path.join(state_root, "shares.db")
|
||||||
|
with open(os.path.join(state_root, "backup-status.json"), "w", encoding="utf-8") as handle:
|
||||||
|
handle.write("status")
|
||||||
|
|
||||||
|
writer = sqlite3.connect(database)
|
||||||
|
writer.execute("PRAGMA journal_mode = WAL")
|
||||||
|
writer.execute("CREATE TABLE events (id INTEGER PRIMARY KEY, value TEXT)")
|
||||||
|
writer.execute("INSERT INTO events (value) VALUES ('committed-in-wal')")
|
||||||
|
writer.commit()
|
||||||
|
snapshot_root = None
|
||||||
|
try:
|
||||||
|
snapshot_root, staged_state = backup.prepare_state_snapshot(
|
||||||
|
state_root,
|
||||||
|
database,
|
||||||
|
)
|
||||||
|
staged_database = os.path.join(staged_state, "shares.db")
|
||||||
|
reader = sqlite3.connect(staged_database)
|
||||||
|
try:
|
||||||
|
self.assertEqual(
|
||||||
|
reader.execute("SELECT value FROM events").fetchone()[0],
|
||||||
|
"committed-in-wal",
|
||||||
|
)
|
||||||
|
self.assertEqual(reader.execute("PRAGMA quick_check").fetchone()[0], "ok")
|
||||||
|
finally:
|
||||||
|
reader.close()
|
||||||
|
with open(os.path.join(staged_state, "backup-status.json"), encoding="utf-8") as handle:
|
||||||
|
self.assertEqual(handle.read(), "status")
|
||||||
|
self.assertFalse(os.path.exists(f"{staged_database}-wal"))
|
||||||
|
self.assertFalse(os.path.exists(f"{staged_database}-shm"))
|
||||||
|
finally:
|
||||||
|
writer.close()
|
||||||
|
if snapshot_root is not None:
|
||||||
|
shutil.rmtree(snapshot_root, ignore_errors=True)
|
||||||
|
|
||||||
|
def test_state_database_must_be_inside_state_root(self):
|
||||||
|
with tempfile.TemporaryDirectory() as tmpdir:
|
||||||
|
state_root = os.path.join(tmpdir, "state")
|
||||||
|
os.mkdir(state_root)
|
||||||
|
database = os.path.join(tmpdir, "outside.db")
|
||||||
|
sqlite3.connect(database).close()
|
||||||
|
|
||||||
|
with self.assertRaisesRegex(RuntimeError, "below STATE_ROOT"):
|
||||||
|
backup.prepare_state_snapshot(state_root, database)
|
||||||
|
|
||||||
|
|
||||||
class BackendProgressCommandTests(unittest.TestCase):
|
class BackendProgressCommandTests(unittest.TestCase):
|
||||||
def test_rclone_sync_uses_progress_flags(self):
|
def test_rclone_sync_uses_progress_flags(self):
|
||||||
destination = backup.parse_destination("sftp://user@example.com/backups")
|
destination = backup.parse_destination("sftp://user@example.com/backups")
|
||||||
|
|||||||
+230
-29
@@ -1,13 +1,14 @@
|
|||||||
import datetime as dt
|
import datetime as dt
|
||||||
import gzip
|
|
||||||
import json
|
|
||||||
import os
|
import os
|
||||||
|
import sqlite3
|
||||||
import tempfile
|
import tempfile
|
||||||
import unittest
|
import unittest
|
||||||
from unittest import mock
|
from unittest import mock
|
||||||
|
|
||||||
from app import audit_collector
|
from app import audit_collector
|
||||||
|
from app import audit_store
|
||||||
from app import web_ui
|
from app import web_ui
|
||||||
|
from app import state_db
|
||||||
|
|
||||||
|
|
||||||
class TokenManagerTests(unittest.TestCase):
|
class TokenManagerTests(unittest.TestCase):
|
||||||
@@ -132,9 +133,8 @@ class AuditParsingTests(unittest.TestCase):
|
|||||||
|
|
||||||
def test_tracks_rotated_file_by_inode_without_reingesting_it(self):
|
def test_tracks_rotated_file_by_inode_without_reingesting_it(self):
|
||||||
with tempfile.TemporaryDirectory() as tmpdir:
|
with tempfile.TemporaryDirectory() as tmpdir:
|
||||||
archive = os.path.join(tmpdir, "audit")
|
|
||||||
os.mkdir(archive)
|
|
||||||
active = os.path.join(tmpdir, "log.pc01")
|
active = os.path.join(tmpdir, "log.pc01")
|
||||||
|
database = os.path.join(tmpdir, "state.db")
|
||||||
line = (
|
line = (
|
||||||
"[2026/07/31 12:34:56.000000, 1] smbd_audit: "
|
"[2026/07/31 12:34:56.000000, 1] smbd_audit: "
|
||||||
"x|alice|192.0.2.5|PC01|Data|pread|OK|a.txt\n"
|
"x|alice|192.0.2.5|PC01|Data|pread|OK|a.txt\n"
|
||||||
@@ -142,61 +142,252 @@ class AuditParsingTests(unittest.TestCase):
|
|||||||
with open(active, "w", encoding="utf-8") as handle:
|
with open(active, "w", encoding="utf-8") as handle:
|
||||||
handle.write(line)
|
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")):
|
store = audit_store.AuditStore(database)
|
||||||
state = {}
|
try:
|
||||||
self.assertEqual(audit_collector.collect_once(state), 1)
|
with mock.patch.object(
|
||||||
|
audit_collector,
|
||||||
|
"SAMBA_LOG_GLOB",
|
||||||
|
os.path.join(tmpdir, "log.*"),
|
||||||
|
):
|
||||||
|
self.assertEqual(audit_collector.collect_once(store), 1)
|
||||||
rotated = f"{active}.old"
|
rotated = f"{active}.old"
|
||||||
os.rename(active, rotated)
|
os.rename(active, rotated)
|
||||||
with open(active, "w", encoding="utf-8") as handle:
|
with open(active, "w", encoding="utf-8") as handle:
|
||||||
handle.write(line.replace("a.txt", "b.txt"))
|
handle.write(line.replace("a.txt", "b.txt"))
|
||||||
self.assertEqual(audit_collector.collect_once(state), 1)
|
self.assertEqual(audit_collector.collect_once(store), 1)
|
||||||
|
paths = [
|
||||||
|
row[0]
|
||||||
|
for row in store.conn.execute(
|
||||||
|
"SELECT path FROM audit_events ORDER BY id"
|
||||||
|
)
|
||||||
|
]
|
||||||
|
self.assertEqual(paths, ["a.txt", "b.txt"])
|
||||||
|
finally:
|
||||||
|
store.close()
|
||||||
|
|
||||||
|
def test_processes_known_rotated_inode_before_new_active_file(self):
|
||||||
|
with tempfile.TemporaryDirectory() as tmpdir:
|
||||||
|
active = os.path.join(tmpdir, "log.pc01")
|
||||||
|
database = os.path.join(tmpdir, "state.db")
|
||||||
|
read = (
|
||||||
|
"[2026/07/31 12:34:56.000000, 1] smbd_audit: "
|
||||||
|
"x|alice|192.0.2.5|PC01|Data|pread|OK|a.txt\n"
|
||||||
|
)
|
||||||
|
write = read.replace("pread|OK|a.txt", "pwrite|OK|changed.txt")
|
||||||
|
with open(active, "w", encoding="utf-8") as handle:
|
||||||
|
handle.write(read)
|
||||||
|
|
||||||
|
store = audit_store.AuditStore(database)
|
||||||
|
try:
|
||||||
|
with mock.patch.object(
|
||||||
|
audit_collector,
|
||||||
|
"SAMBA_LOG_GLOB",
|
||||||
|
os.path.join(tmpdir, "log.*"),
|
||||||
|
):
|
||||||
|
self.assertEqual(audit_collector.collect_once(store), 1)
|
||||||
|
with open(active, "a", encoding="utf-8") as handle:
|
||||||
|
handle.write(read)
|
||||||
|
os.rename(active, f"{active}.old")
|
||||||
|
with open(active, "w", encoding="utf-8") as handle:
|
||||||
|
handle.write(write)
|
||||||
|
self.assertEqual(audit_collector.collect_once(store), 1)
|
||||||
|
|
||||||
|
rows = store.conn.execute(
|
||||||
|
"SELECT action, path FROM audit_events ORDER BY id"
|
||||||
|
).fetchall()
|
||||||
|
self.assertEqual(
|
||||||
|
[(row["action"], row["path"]) for row in rows],
|
||||||
|
[("read", "a.txt"), ("write", "changed.txt")],
|
||||||
|
)
|
||||||
|
finally:
|
||||||
|
store.close()
|
||||||
|
|
||||||
|
def test_deduplicates_only_uninterrupted_identical_reads_in_one_second(self):
|
||||||
|
with tempfile.TemporaryDirectory() as tmpdir:
|
||||||
|
active = os.path.join(tmpdir, "log.pc01")
|
||||||
|
database = os.path.join(tmpdir, "state.db")
|
||||||
|
|
||||||
|
def line(operation, path, second="12:34:56"):
|
||||||
|
return (
|
||||||
|
f"[2026/07/31 {second}.000000, 1] smbd_audit: "
|
||||||
|
f"x|alice|192.0.2.5|PC01|Data|{operation}|OK|{path}\n"
|
||||||
|
)
|
||||||
|
|
||||||
|
with open(active, "w", encoding="utf-8") as handle:
|
||||||
|
handle.writelines(
|
||||||
|
[
|
||||||
|
line("pread", "a.txt"),
|
||||||
|
line("pread", "a.txt"),
|
||||||
|
line("pread", "b.txt"),
|
||||||
|
line("pread", "a.txt"),
|
||||||
|
line("pread", "a.txt"),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
store = audit_store.AuditStore(database)
|
||||||
|
try:
|
||||||
|
with mock.patch.object(
|
||||||
|
audit_collector,
|
||||||
|
"SAMBA_LOG_GLOB",
|
||||||
|
os.path.join(tmpdir, "log.*"),
|
||||||
|
):
|
||||||
|
self.assertEqual(audit_collector.collect_once(store), 3)
|
||||||
|
with open(active, "a", encoding="utf-8") as handle:
|
||||||
|
handle.write(line("pread", "a.txt"))
|
||||||
|
self.assertEqual(audit_collector.collect_once(store), 0)
|
||||||
|
with open(active, "a", encoding="utf-8") as handle:
|
||||||
|
handle.write(line("pwrite", "changed.txt"))
|
||||||
|
handle.write(line("pread", "a.txt"))
|
||||||
|
self.assertEqual(audit_collector.collect_once(store), 2)
|
||||||
|
with open(active, "a", encoding="utf-8") as handle:
|
||||||
|
handle.write(line("pread", "a.txt", "12:34:57"))
|
||||||
|
self.assertEqual(audit_collector.collect_once(store), 1)
|
||||||
|
|
||||||
|
rows = store.conn.execute(
|
||||||
|
"SELECT action, path, occurred_at FROM audit_events ORDER BY id"
|
||||||
|
).fetchall()
|
||||||
|
self.assertEqual(
|
||||||
|
[(row["action"], row["path"]) for row in rows],
|
||||||
|
[
|
||||||
|
("read", "a.txt"),
|
||||||
|
("read", "b.txt"),
|
||||||
|
("read", "a.txt"),
|
||||||
|
("write", "changed.txt"),
|
||||||
|
("read", "a.txt"),
|
||||||
|
("read", "a.txt"),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
self.assertTrue(rows[-1]["occurred_at"].endswith("12:34:57+00:00"))
|
||||||
|
finally:
|
||||||
|
store.close()
|
||||||
|
|
||||||
|
|
||||||
class AuditQueryTests(unittest.TestCase):
|
class AuditQueryTests(unittest.TestCase):
|
||||||
def make_event(self, timestamp, user, success=True):
|
def make_event(self, timestamp, user, success=True):
|
||||||
return {
|
return {
|
||||||
"timestamp": timestamp,
|
"timestamp": timestamp,
|
||||||
|
"ingestedAt": timestamp,
|
||||||
"user": user,
|
"user": user,
|
||||||
"clientIp": "192.0.2.5",
|
"clientIp": "192.0.2.5",
|
||||||
|
"client": "PC01",
|
||||||
"share": "Data",
|
"share": "Data",
|
||||||
"operation": "pread" if success else "unlinkat",
|
"operation": "pread" if success else "unlinkat",
|
||||||
"action": "read" if success else "delete",
|
"action": "read" if success else "delete",
|
||||||
"path": "folder/file.txt",
|
"path": "folder/file.txt",
|
||||||
"result": "OK" if success else "NT_STATUS_ACCESS_DENIED",
|
"result": "OK" if success else "NT_STATUS_ACCESS_DENIED",
|
||||||
"success": success,
|
"success": success,
|
||||||
|
"source": "log.pc01",
|
||||||
}
|
}
|
||||||
|
|
||||||
def test_queries_plain_and_compressed_days_with_filters_and_cursor(self):
|
def test_queries_indexed_events_with_filters_and_keyset_cursor(self):
|
||||||
with tempfile.TemporaryDirectory() as tmpdir:
|
with tempfile.TemporaryDirectory() as tmpdir:
|
||||||
today = dt.datetime.now(dt.timezone.utc).date()
|
today = dt.datetime.now(dt.timezone.utc).date()
|
||||||
yesterday = today - dt.timedelta(days=1)
|
yesterday = today - dt.timedelta(days=1)
|
||||||
current = os.path.join(tmpdir, f"{today.isoformat()}.jsonl")
|
database = os.path.join(tmpdir, "state.db")
|
||||||
old = os.path.join(tmpdir, f"{yesterday.isoformat()}.jsonl.gz")
|
store = audit_store.AuditStore(database)
|
||||||
with open(current, "w", encoding="utf-8") as handle:
|
events = [
|
||||||
for index in range(3):
|
self.make_event(f"{today}T12:00:0{index}+00:00", "alice")
|
||||||
handle.write(json.dumps(self.make_event(f"{today}T12:00:0{index}+00:00", "alice")) + "\n")
|
for index in range(3)
|
||||||
handle.write(json.dumps(self.make_event(f"{today}T12:00:04+00:00", "bob", False)) + "\n")
|
]
|
||||||
ignored = self.make_event(f"{today}T12:00:05+00:00", "metadata-user")
|
events.extend(
|
||||||
ignored.update({"operation": "create_file", "action": "write"})
|
[
|
||||||
handle.write(json.dumps(ignored) + "\n")
|
self.make_event(f"{today}T12:00:04+00:00", "bob", False),
|
||||||
handle.write(json.dumps(self.make_event(f"{today}T12:00:06+00:00", "robot_svc")) + "\n")
|
self.make_event(f"{today}T12:00:06+00:00", "robot_svc"),
|
||||||
with gzip.open(old, "wt", encoding="utf-8") as handle:
|
self.make_event(f"{yesterday}T12:00:00+00:00", "alice"),
|
||||||
handle.write(json.dumps(self.make_event(f"{yesterday}T12:00:00+00:00", "alice")) + "\n")
|
]
|
||||||
|
)
|
||||||
|
store.append_batch(events, {}, set())
|
||||||
|
store.close()
|
||||||
|
|
||||||
with mock.patch.object(web_ui, "AUDIT_ROOT", tmpdir):
|
with mock.patch.object(web_ui, "STATE_DB", database):
|
||||||
first = web_ui.query_audit({"from": [yesterday.isoformat()], "to": [today.isoformat()], "user": ["alice"], "limit": ["2"]})
|
first = web_ui.query_audit(
|
||||||
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"])]})
|
"from": [yesterday.isoformat()],
|
||||||
visible = web_ui.query_audit({"from": [today.isoformat()], "to": [today.isoformat()], "limit": ["100"]})
|
"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"])],
|
||||||
|
}
|
||||||
|
)
|
||||||
|
visible = web_ui.query_audit(
|
||||||
|
{
|
||||||
|
"from": [today.isoformat()],
|
||||||
|
"to": [today.isoformat()],
|
||||||
|
"limit": ["100"],
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
self.assertEqual(first["matched"], 4)
|
self.assertEqual(first["matched"], 4)
|
||||||
self.assertEqual(len(first["events"]), 2)
|
self.assertEqual(len(first["events"]), 2)
|
||||||
self.assertEqual(len(second["events"]), 2)
|
self.assertEqual(len(second["events"]), 2)
|
||||||
self.assertEqual(failed["events"][0]["user"], "bob")
|
self.assertEqual(failed["events"][0]["user"], "bob")
|
||||||
self.assertNotIn("metadata-user", visible["facets"]["users"])
|
|
||||||
self.assertNotIn("robot_svc", visible["facets"]["users"])
|
self.assertNotIn("robot_svc", visible["facets"]["users"])
|
||||||
self.assertEqual(set(visible["facets"]["actions"]), {"read", "delete"})
|
self.assertEqual(set(visible["facets"]["actions"]), {"read", "delete"})
|
||||||
|
|
||||||
|
def test_schema_has_filter_and_time_indexes(self):
|
||||||
|
with tempfile.TemporaryDirectory() as tmpdir:
|
||||||
|
store = audit_store.AuditStore(os.path.join(tmpdir, "state.db"))
|
||||||
|
try:
|
||||||
|
indexes = {
|
||||||
|
row[0]
|
||||||
|
for row in store.conn.execute(
|
||||||
|
"SELECT name FROM sqlite_schema WHERE type = 'index'"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
finally:
|
||||||
|
store.close()
|
||||||
|
self.assertTrue(
|
||||||
|
{
|
||||||
|
"audit_events_time",
|
||||||
|
"audit_events_action_time",
|
||||||
|
"audit_events_success_time",
|
||||||
|
"audit_events_user_time",
|
||||||
|
"audit_events_account_time",
|
||||||
|
"audit_events_share_time",
|
||||||
|
"audit_events_result_time",
|
||||||
|
}.issubset(indexes)
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_drops_only_known_legacy_audit_files(self):
|
||||||
|
with tempfile.TemporaryDirectory() as tmpdir:
|
||||||
|
for name in (
|
||||||
|
"2026-07-30.jsonl",
|
||||||
|
"2026-07-29.jsonl.gz",
|
||||||
|
"collector-state.json",
|
||||||
|
"keep.txt",
|
||||||
|
):
|
||||||
|
with open(os.path.join(tmpdir, name), "w", encoding="utf-8"):
|
||||||
|
pass
|
||||||
|
|
||||||
|
self.assertEqual(audit_store.drop_legacy_audit_files(tmpdir), 3)
|
||||||
|
self.assertEqual(os.listdir(tmpdir), ["keep.txt"])
|
||||||
|
|
||||||
|
|
||||||
|
class StateDatabaseMigrationTests(unittest.TestCase):
|
||||||
|
def test_drops_only_known_recomputable_web_cache_files(self):
|
||||||
|
with tempfile.TemporaryDirectory() as tmpdir:
|
||||||
|
for name in ("usage.json", "usage.json.tmp", "keep.txt"):
|
||||||
|
with open(os.path.join(tmpdir, name), "w", encoding="utf-8"):
|
||||||
|
pass
|
||||||
|
|
||||||
|
self.assertEqual(state_db.drop_legacy_web_cache(tmpdir), 2)
|
||||||
|
self.assertEqual(os.listdir(tmpdir), ["keep.txt"])
|
||||||
|
|
||||||
|
|
||||||
class WebPresentationTests(unittest.TestCase):
|
class WebPresentationTests(unittest.TestCase):
|
||||||
def asset(self, name):
|
def asset(self, name):
|
||||||
@@ -283,16 +474,26 @@ class UsageScannerTests(unittest.TestCase):
|
|||||||
handle.write(b"b" * 3)
|
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:
|
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)
|
handle.write(b"c" * 5)
|
||||||
cache = os.path.join(tmpdir, "usage.json")
|
database = os.path.join(tmpdir, "state.db")
|
||||||
|
|
||||||
env = {"GROUP_ROOT": group_root, "PRIVATE_ROOT": private_root, "FSLOGIX_ROOT": fslogix_root}
|
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"):
|
with mock.patch.dict(os.environ, env), mock.patch.object(web_ui, "STATE_DB", database), mock.patch.object(web_ui, "fslogix_username", return_value="alice"):
|
||||||
value = web_ui.UsageScanner().scan()
|
value = web_ui.UsageScanner().scan()
|
||||||
|
|
||||||
self.assertEqual(value["totals"]["dataBytes"], 7)
|
self.assertEqual(value["totals"]["dataBytes"], 7)
|
||||||
self.assertEqual(value["users"][0]["privateBytes"], 3)
|
self.assertEqual(value["users"][0]["privateBytes"], 3)
|
||||||
self.assertEqual(value["users"][0]["fslogixBytes"], 5)
|
self.assertEqual(value["users"][0]["fslogixBytes"], 5)
|
||||||
self.assertEqual(value["users"][0]["totalBytes"], 8)
|
self.assertEqual(value["users"][0]["totalBytes"], 8)
|
||||||
|
conn = sqlite3.connect(database)
|
||||||
|
try:
|
||||||
|
self.assertEqual(
|
||||||
|
conn.execute(
|
||||||
|
"SELECT count(*) FROM web_cache WHERE key = 'usage'"
|
||||||
|
).fetchone()[0],
|
||||||
|
1,
|
||||||
|
)
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
|
|||||||
Reference in New Issue
Block a user