feat: audit/change log — owner-visible record of who changed what
Unit Tests / test (push) Successful in 12m47s

Add AuditManager (api/audit_manager.py): JSONL append-only log at
data/api/audit/audit.log with SHA-256 hash chain for tamper detection,
verify endpoint, size-based rotation, and automatic redaction of secret
fields before any entry is written. Supports structured query (actor,
action, date range) and CSV export.

Wire an @app.after_request hook in app.py that fires on every mutating
/api/* request: captures actor, role, remote IP, and maps the route +
method to a human-readable action via ROUTE_ACTION_MAP. Explicit audit
entries for password_change and password_reset are added in
auth_routes.py so those events record the actor without logging secret
values.

Expose an admin-only blueprint (api/routes/audit.py):
  GET /api/audit          — paginated query
  GET /api/audit/export   — CSV download
  GET /api/audit/verify   — hash-chain integrity check

Register AuditManager in managers.py and add api/audit to
config_manager.py critical_data_paths so it is included in backups and
restored with other persistent state.

Add Activity page (webui/src/pages/Activity.jsx, admin-only) reachable
from the nav in App.jsx. New auditAPI helper in api.js covers all three
endpoints.

Tests: test_audit_manager.py (unit: hash chain, redaction, rotation,
query, csv, verify) and test_audit_hook_routes.py (integration: hook
fires on mutating routes, skips safe methods, records actor/ip/action,
backup-inclusion assertion).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-06-10 20:19:38 -04:00
parent 13074f56cb
commit 8b50fb1036
12 changed files with 1246 additions and 2 deletions
+208
View File
@@ -47,6 +47,7 @@ from managers import (
service_registry, service_registry,
service_composer, service_composer,
account_manager, account_manager,
audit_manager,
firewall_manager, EventType, firewall_manager, EventType,
) )
# Re-exports: tests do `from app import CellManager` and `from app import _resolve_peer_dns` # Re-exports: tests do `from app import CellManager` and `from app import _resolve_peer_dns`
@@ -318,6 +319,210 @@ def log_request(response):
logger.info(f"{ctx.get('method')} {ctx.get('path')} {ctx.get('status')}") logger.info(f"{ctx.get('method')} {ctx.get('path')} {ctx.get('status')}")
return response return response
# ── Audit trail ─────────────────────────────────────────────────────────────
# Mutating endpoints that must NOT be audited: read-shaped POSTs (searches,
# exports, port checks, history clears) and namespaces handled elsewhere.
_NO_AUDIT_ENDPOINTS = frozenset({
# Read-shaped POSTs / diagnostics — not state changes worth auditing.
'services.search_logs',
'services.export_logs',
'services.rotate_logs',
'wireguard.check_wireguard_port',
'wireguard.test_wireguard_connectivity',
'wireguard.get_peer_config',
'wireguard.get_peer_status',
'wireguard.refresh_external_ip',
'network.test_network',
'routing.test_routing_connectivity',
'clear_health_history',
'peers.ip_update',
})
# Map (METHOD, endpoint) -> (action, target_type, target_id_view_arg).
# target_id_view_arg names a view_arg used as the target id, or None for a
# resource-level action. Endpoint is request.url_rule.endpoint
# ('<blueprint>.<func>' for blueprint routes, '<func>' for app routes).
ROUTE_ACTION_MAP = {
# config
('PUT', 'config.update_config'): ('config.update', 'config', None),
('POST', 'config.apply_pending_config'): ('config.apply', 'config', None),
('DELETE', 'config.cancel_pending_config'): ('config.cancel_pending', 'config', None),
('POST', 'config.import_config'): ('config.import', 'config', None),
('POST', 'config.create_config_backup'): ('backup.create', 'backup', None),
('POST', 'config.restore_config'): ('backup.restore', 'backup', 'backup_id'),
('POST', 'config.upload_backup'): ('backup.upload', 'backup', None),
('DELETE', 'config.delete_config_backup'): ('backup.delete', 'backup', 'backup_id'),
# ddns
('PUT', 'config.update_ddns_config'): ('ddns.update', 'ddns', None),
('POST', 'config.ddns_register'): ('ddns.register', 'ddns', None),
('POST', 'config.ddns_sync_records'): ('ddns.sync', 'ddns', None),
# peers
('POST', 'peers.add_peer'): ('peer.create', 'peer', None),
('PUT', 'peers.update_peer'): ('peer.update', 'peer', 'peer_name'),
('PUT', 'peers.set_peer_route_via'): ('peer.route_via', 'peer', 'peer_name'),
('DELETE', 'peers.remove_peer'): ('peer.delete', 'peer', 'peer_name'),
('POST', 'peers.register_peer'): ('peer.register', 'peer', None),
('DELETE', 'peers.unregister_peer'): ('peer.unregister', 'peer', 'peer_name'),
('PUT', 'peers.update_peer_ip_registry'): ('peer.update_ip', 'peer', 'peer_name'),
('POST', 'peers.clear_peer_reinstall'): ('peer.clear_reinstall', 'peer', 'peer_name'),
# wireguard
('POST', 'wireguard.generate_peer_keys'): ('wireguard.peer_keys', 'wireguard', None),
('POST', 'wireguard.add_wireguard_peer'): ('wireguard.peer_add', 'wireguard', None),
('DELETE', 'wireguard.remove_wireguard_peer'): ('wireguard.peer_remove', 'wireguard', None),
('PUT', 'wireguard.update_peer_ip'): ('wireguard.peer_ip', 'wireguard', None),
('POST', 'wireguard.setup_network'): ('wireguard.network_setup', 'wireguard', None),
('PUT', 'wireguard.set_wireguard_endpoint'): ('wireguard.endpoint', 'wireguard', None),
('POST', 'wireguard.apply_wireguard_enforcement'): ('wireguard.apply_enforcement', 'wireguard', None),
# services (catalog + bus)
('POST', 'services.restart_service_containers'): ('service.restart', 'service', 'service_id'),
('POST', 'services.reconfigure_service'): ('service.reconfigure', 'service', 'service_id'),
('POST', 'services.provision_service_account'): ('account.create', 'account', 'service_id'),
('DELETE', 'services.deprovision_service_account'): ('account.delete', 'account', 'service_id'),
('POST', 'services.start_service'): ('service.start', 'service', 'service_name'),
('POST', 'services.stop_service'): ('service.stop', 'service', 'service_name'),
('POST', 'services.restart_service'): ('service.restart', 'service', 'service_name'),
# service store
('POST', 'service_store.install_service'): ('service.install', 'service', 'service_id'),
('DELETE', 'service_store.remove_service'): ('service.remove', 'service', 'service_id'),
('POST', 'service_store.refresh_index'): ('service.store_refresh', 'service', None),
# built-in service accounts (email / calendar / files)
('POST', 'email.create_email_user'): ('account.create', 'account', None),
('DELETE', 'email.delete_email_user'): ('account.delete', 'account', 'username'),
('POST', 'calendar.create_calendar_user'): ('account.create', 'account', None),
('DELETE', 'calendar.delete_calendar_user'): ('account.delete', 'account', 'username'),
('POST', 'files.create_file_user'): ('account.create', 'account', None),
('DELETE', 'files.delete_file_user'): ('account.delete', 'account', 'username'),
# vault / certs / secrets / trust
('POST', 'vault.generate_certificate'): ('vault.cert_issue', 'certificate', None),
('DELETE', 'vault.revoke_certificate'): ('vault.cert_revoke', 'certificate', 'common_name'),
('POST', 'vault.store_secret'): ('vault.secret_store', 'secret', None),
('DELETE', 'vault.delete_secret'): ('vault.secret_delete', 'secret', 'name'),
('POST', 'vault.add_trusted_key'): ('vault.trust_key_add', 'trust', None),
('DELETE', 'vault.remove_trusted_key'): ('vault.trust_key_remove', 'trust', 'name'),
# caddy
('POST', 'caddy_cert_renew'): ('caddy.cert_renew', 'caddy', None),
('POST', 'caddy_upload_custom_cert'): ('caddy.custom_cert', 'caddy', None),
# connectivity
('POST', 'connectivity_upload_wireguard'): ('connection.exit_wireguard', 'connection', None),
('POST', 'connectivity_upload_openvpn'): ('connection.exit_openvpn', 'connection', None),
('POST', 'connectivity_configure_sshuttle'): ('connection.exit_sshuttle', 'connection', None),
('POST', 'connectivity_configure_proxy'): ('connection.exit_proxy', 'connection', None),
('PUT', 'connectivity_set_peer_exit'): ('connection.peer_exit_set', 'peer', 'peer_name'),
# egress
('PUT', 'egress_set_service_exit'): ('egress.service_exit_set', 'service', 'service_id'),
# cells
('POST', 'cells.add_cell_connection'): ('cell.create', 'cell', None),
('DELETE', 'cells.remove_cell_connection'): ('cell.delete', 'cell', 'cell_name'),
('PUT', 'cells.update_cell_permissions'): ('cell.permissions_set', 'cell', 'cell_name'),
('PUT', 'cells.set_exit_offer'): ('cell.exit_offer', 'cell', 'cell_name'),
# network / dns
('POST', 'network.add_dns_record'): ('network.dns_record_add', 'dns', None),
('DELETE', 'network.remove_dns_record'): ('network.dns_record_remove', 'dns', None),
# routing
('POST', 'routing.setup_routing'): ('network.routing_setup', 'routing', None),
('POST', 'routing.add_nat_rule'): ('network.nat_add', 'routing', None),
('DELETE', 'routing.remove_nat_rule'): ('network.nat_remove', 'routing', 'rule_id'),
('POST', 'routing.add_peer_route'): ('network.peer_route_add', 'routing', None),
('DELETE', 'routing.remove_peer_route'): ('network.peer_route_remove', 'routing', 'peer_name'),
('POST', 'routing.add_firewall_rule'): ('network.firewall_add', 'routing', None),
('DELETE', 'routing.remove_firewall_rule'): ('network.firewall_remove', 'routing', 'rule_id'),
('POST', 'routing.add_exit_node'): ('network.exit_node_add', 'routing', None),
('POST', 'routing.add_bridge_route'): ('network.bridge_add', 'routing', None),
('POST', 'routing.add_split_route'): ('network.split_add', 'routing', None),
# containers
('POST', 'containers.create_container'): ('container.create', 'container', None),
('DELETE', 'containers.remove_container'): ('container.remove', 'container', 'name'),
('POST', 'containers.restart_container'): ('container.restart', 'container', 'name'),
('POST', 'containers.start_container'): ('container.start', 'container', 'name'),
('POST', 'containers.stop_container'): ('container.stop', 'container', 'name'),
}
def _audit_actor_ip():
"""Derive (actor, role, ip) for the current request, mirroring is_local_request's
trust model: the last X-Forwarded-For entry (appended by Caddy) over remote_addr."""
actor = session.get('username', 'anonymous')
role = session.get('role', 'system')
ip = request.remote_addr or ''
xff = request.headers.get('X-Forwarded-For', '')
if xff:
last = xff.split(',')[-1].strip()
if last:
ip = last
return actor, role, ip
def _audit_map_action(method, endpoint, view_args, path):
"""Resolve (action, target_type, target_id) for a mutating request."""
spec = ROUTE_ACTION_MAP.get((method, endpoint))
view_args = view_args or {}
if spec:
action, target_type, id_arg = spec
target_id = str(view_args.get(id_arg, '')) if id_arg else ''
return action, target_type, target_id
# Unmapped: emit a generic action so nothing is invisible.
return f"{method.lower()}.{path}", 'unknown', ''
def _audit_summary(action):
"""Build a redacted summary for the current request.
For config.update only, list the changed config KEY NAMES (never values).
Request bodies are never recorded.
"""
if action != 'config.update':
return ''
try:
from audit_manager import AuditManager
body = request.get_json(silent=True)
if not isinstance(body, dict):
return ''
keys = []
for section, val in body.items():
if isinstance(val, dict):
keys.extend(f"{section}.{k}" for k in val.keys())
else:
keys.append(str(section))
return AuditManager.summarize_keys(keys)
except Exception:
return ''
@app.after_request
def audit_request(response):
"""Append an audit entry for mutating /api/* requests. Never raises."""
try:
method = request.method
if method not in ('POST', 'PUT', 'DELETE', 'PATCH'):
return response
path = request.path
if not path.startswith('/api/'):
return response
if (path.startswith('/api/auth/') or path.startswith('/api/setup/')
or path.startswith('/api/cells/peer-sync/')):
return response
rule = request.url_rule
endpoint = rule.endpoint if rule is not None else ''
if endpoint in _NO_AUDIT_ENDPOINTS:
return response
actor, role, ip = _audit_actor_ip()
action, target_type, target_id = _audit_map_action(
method, endpoint, request.view_args, path)
status = response.status_code
ctx = request_context.get({})
summary = _audit_summary(action)
audit_manager.record(
actor=actor, role=role, ip=ip, action=action,
target_type=target_type, target_id=target_id, summary=summary,
result='success' if status < 400 else 'failure',
status=status, method=method, path=path,
request_id=ctx.get('request_id', ''),
)
except Exception as e:
logger.warning(f"audit_request hook failed: {e}")
return response
@app.teardown_request @app.teardown_request
def clear_log_context(exc): def clear_log_context(exc):
request_context.set({}) request_context.set({})
@@ -564,6 +769,9 @@ app.register_blueprint(_config_bp)
from routes.service_store import store_bp from routes.service_store import store_bp
app.register_blueprint(store_bp) app.register_blueprint(store_bp)
from routes.audit import bp as _audit_bp
app.register_blueprint(_audit_bp)
# Re-export config helpers so existing test imports/patches keep working # Re-export config helpers so existing test imports/patches keep working
from routes.config import ( from routes.config import (
_set_pending_restart, _clear_pending_restart, _set_pending_restart, _clear_pending_restart,
+330
View File
@@ -0,0 +1,330 @@
#!/usr/bin/env python3
"""
Audit Manager for Personal Internet Cell.
Owner-visible, append-only audit trail of WHO (actor + role + ip) did WHAT
(action) to WHICH target, WHEN, with a redacted summary. Storage is a JSONL
file with a per-entry SHA-256 hash chain so tampering is detectable. Request
bodies and secret values are never written; summaries only ever list changed
config KEY NAMES, never their values.
"""
import os
import io
import re
import csv
import json
import hashlib
import logging
import threading
from datetime import datetime
from typing import Dict, List, Optional, Any
from base_service_manager import BaseServiceManager
logger = logging.getLogger(__name__)
def _utcnow_iso() -> str:
return datetime.utcnow().strftime('%Y-%m-%dT%H:%M:%SZ')
# Keys whose values must never be recorded — name-only in summaries.
_SECRET_KEY_RE = re.compile(r'(pass|secret|key|token|private|cred|otp|psk)', re.IGNORECASE)
# Final scrub of anything that looks like base64 key material / encoded blobs.
_BASE64_BLOCK_RE = re.compile(r'[A-Za-z0-9+/]{40,}={0,2}')
# bcrypt and age secret prefixes.
_SECRET_PREFIX_RE = re.compile(
r'(\$2[aby]\$[^\s]+|AGE-SECRET-KEY-[^\s]+|age1[^\s]+|-----BEGIN[^\n]+)'
)
_VALID_RESULTS = ('success', 'failure')
class AuditManager(BaseServiceManager):
"""Append-only, hash-chained audit trail."""
MAX_FILE_SIZE = 10 * 1024 * 1024 # 10 MB before rotation
BACKUP_COUNT = 10 # audit.log.1 .. audit.log.10
def __init__(self, data_dir: str = '/app/data', config_dir: str = '/app/config',
tamper_chain: bool = True):
super().__init__('audit', data_dir=data_dir, config_dir=config_dir)
self.tamper_chain = tamper_chain
self._lock = threading.RLock()
self._audit_dir = os.path.join(self.data_dir, 'api', 'audit')
self._audit_file = os.path.join(self._audit_dir, 'audit.log')
self._seq = 0
self._prev_hash = ''
self.safe_makedirs(self._audit_dir)
self._load_chain_state()
# ── chain bootstrap ─────────────────────────────────────────────────────
def _load_chain_state(self) -> None:
"""Recover seq + prev_hash from the last line of the live file."""
try:
if not os.path.exists(self._audit_file):
return
last = None
with open(self._audit_file, 'r', encoding='utf-8', errors='ignore') as f:
for line in f:
line = line.strip()
if line:
last = line
if last:
entry = json.loads(last)
self._seq = int(entry.get('seq', 0))
self._prev_hash = entry.get('hash', '') or ''
except Exception as e:
logger.warning(f"audit: could not load chain state: {e}")
# ── redaction ───────────────────────────────────────────────────────────
@staticmethod
def _scrub(text: str) -> str:
"""Strip anything resembling a secret value from a summary string."""
if not text:
return ''
text = _SECRET_PREFIX_RE.sub('[REDACTED]', text)
text = _BASE64_BLOCK_RE.sub('[REDACTED]', text)
return text
@classmethod
def _redact(cls, entry: Dict[str, Any]) -> Dict[str, Any]:
"""Enforce the redaction rules on a built entry before write.
- summary is scrubbed of base64/secret-prefixed blobs.
- any string field is scrubbed too (defence in depth).
Request bodies are never present — the caller passes only a summary.
"""
for field in ('summary', 'target_id', 'action', 'path'):
val = entry.get(field)
if isinstance(val, str):
entry[field] = cls._scrub(val)
return entry
@classmethod
def summarize_keys(cls, keys: List[str]) -> str:
"""Build a redacted summary listing changed config KEY NAMES only.
Secret-looking key names are kept (they are names, not values) but the
whole string is still scrubbed of any accidental value material.
"""
names = [str(k) for k in keys if k is not None]
return cls._scrub('changed: ' + ', '.join(names)) if names else 'no changes'
# ── hashing ─────────────────────────────────────────────────────────────
@staticmethod
def _canonical(entry: Dict[str, Any]) -> str:
return json.dumps(entry, sort_keys=True, separators=(',', ':'), ensure_ascii=False)
def _hash_entry(self, entry_without_hash: Dict[str, Any]) -> str:
return hashlib.sha256(self._canonical(entry_without_hash).encode('utf-8')).hexdigest()
# ── recording ───────────────────────────────────────────────────────────
def record(self, actor: str, role: str, ip: str, action: str,
target_type: str = '', target_id: str = '', summary: str = '',
result: str = 'success', status: int = 200, method: str = '',
path: str = '', request_id: str = '') -> Optional[Dict[str, Any]]:
"""Append one redacted, hash-chained JSON line. Never raises."""
try:
with self._lock:
self._maybe_rotate()
self._seq += 1
if result not in _VALID_RESULTS:
result = 'success' if int(status or 200) < 400 else 'failure'
entry: Dict[str, Any] = {
'ts': _utcnow_iso(),
'actor': actor or 'anonymous',
'role': role or 'system',
'ip': ip or '',
'action': action or '',
'target_type': target_type or '',
'target_id': target_id or '',
'summary': summary or '',
'result': result,
'status': int(status or 0),
'method': method or '',
'path': path or '',
'request_id': request_id or '',
'seq': self._seq,
'prev_hash': self._prev_hash if self.tamper_chain else '',
}
entry = self._redact(entry)
if self.tamper_chain:
entry['hash'] = self._hash_entry(entry)
else:
entry['hash'] = ''
self._append_line(json.dumps(entry, ensure_ascii=False))
self._prev_hash = entry['hash']
return entry
except Exception as e:
logger.warning(f"audit.record failed: {e}")
return None
def _append_line(self, line: str) -> None:
self.safe_makedirs(self._audit_dir)
fd = os.open(self._audit_file, os.O_WRONLY | os.O_CREAT | os.O_APPEND, 0o600)
try:
os.write(fd, (line + '\n').encode('utf-8'))
finally:
os.close(fd)
try:
os.chmod(self._audit_file, 0o600)
except OSError:
pass
# ── rotation ────────────────────────────────────────────────────────────
def _maybe_rotate(self) -> None:
try:
if not os.path.exists(self._audit_file):
return
if os.path.getsize(self._audit_file) < self.MAX_FILE_SIZE:
return
except OSError:
return
# audit.log.(N-1) -> audit.log.N, ... audit.log -> audit.log.1
for i in range(self.BACKUP_COUNT - 1, 0, -1):
src = f"{self._audit_file}.{i}"
dst = f"{self._audit_file}.{i + 1}"
if os.path.exists(src):
try:
os.replace(src, dst)
except OSError as e:
logger.warning(f"audit rotate {src}->{dst}: {e}")
try:
os.replace(self._audit_file, f"{self._audit_file}.1")
except OSError as e:
logger.warning(f"audit rotate live->.1: {e}")
def _segment_files(self) -> List[str]:
"""Live file first (newest), then rotated segments .1 .. .N (older)."""
files = []
if os.path.exists(self._audit_file):
files.append(self._audit_file)
for i in range(1, self.BACKUP_COUNT + 1):
seg = f"{self._audit_file}.{i}"
if os.path.exists(seg):
files.append(seg)
return files
# ── reading / filtering ─────────────────────────────────────────────────
@staticmethod
def _matches(entry: Dict[str, Any], filters: Dict[str, Any]) -> bool:
for field in ('actor', 'action', 'target_type', 'target_id', 'result'):
want = filters.get(field)
if want and str(entry.get(field, '')) != str(want):
return False
since = filters.get('since')
until = filters.get('until')
ts = entry.get('ts', '')
if since and ts < since:
return False
if until and ts > until:
return False
return True
def _read_all(self, filters: Dict[str, Any]) -> List[Dict[str, Any]]:
"""Return matching entries, newest-first across all segments."""
results: List[Dict[str, Any]] = []
with self._lock:
for seg in self._segment_files():
try:
with open(seg, 'r', encoding='utf-8', errors='ignore') as f:
lines = f.readlines()
except OSError:
continue
for line in reversed(lines):
line = line.strip()
if not line:
continue
try:
entry = json.loads(line)
except json.JSONDecodeError:
continue
if self._matches(entry, filters):
results.append(entry)
return results
def query(self, filters: Optional[Dict[str, Any]] = None,
limit: int = 100, offset: int = 0) -> Dict[str, Any]:
filters = filters or {}
try:
limit = max(1, min(int(limit), 1000))
except (TypeError, ValueError):
limit = 100
try:
offset = max(0, int(offset))
except (TypeError, ValueError):
offset = 0
entries = self._read_all(filters)
total = len(entries)
page = entries[offset:offset + limit]
next_offset = offset + limit if offset + limit < total else None
return {'entries': page, 'total': total, 'next_offset': next_offset}
def export_csv(self, filters: Optional[Dict[str, Any]] = None) -> str:
filters = filters or {}
entries = self._read_all(filters)
fields = ['ts', 'actor', 'role', 'ip', 'action', 'target_type',
'target_id', 'summary', 'result', 'status', 'method', 'path',
'request_id', 'seq']
buf = io.StringIO()
writer = csv.writer(buf)
writer.writerow(fields)
for e in entries:
writer.writerow([e.get(f, '') for f in fields])
return buf.getvalue()
# ── integrity ───────────────────────────────────────────────────────────
def verify_chain(self) -> Dict[str, Any]:
"""Walk all segments oldest-first; verify each entry's hash + link."""
if not self.tamper_chain:
return {'ok': True, 'broken_at_seq': None, 'disabled': True}
with self._lock:
segs = list(reversed(self._segment_files())) # oldest -> newest
prev_hash = ''
first = True # oldest available record: its predecessor may be pruned
for seg in segs:
try:
with open(seg, 'r', encoding='utf-8', errors='ignore') as f:
lines = f.readlines()
except OSError:
continue
for line in lines:
line = line.strip()
if not line:
continue
try:
entry = json.loads(line)
except json.JSONDecodeError:
return {'ok': False, 'broken_at_seq': None}
stored_hash = entry.get('hash', '')
# Don't fail the prev_hash link on the very first available
# record — older segments may have rotated off the end.
if not first and entry.get('prev_hash', '') != prev_hash:
return {'ok': False, 'broken_at_seq': entry.get('seq')}
recomputed = self._hash_entry({k: v for k, v in entry.items() if k != 'hash'})
if recomputed != stored_hash:
return {'ok': False, 'broken_at_seq': entry.get('seq')}
prev_hash = stored_hash
first = False
return {'ok': True, 'broken_at_seq': None}
# ── BaseServiceManager interface ────────────────────────────────────────
def get_status(self) -> Dict[str, Any]:
size = 0
try:
if os.path.exists(self._audit_file):
size = os.path.getsize(self._audit_file)
except OSError:
pass
return {
'running': True,
'tamper_chain': self.tamper_chain,
'seq': self._seq,
'file': self._audit_file,
'file_size': size,
}
def test_connectivity(self) -> Dict[str, Any]:
return {'success': True}
+32
View File
@@ -20,6 +20,30 @@ auth_manager = None # type: ignore
auth_bp = Blueprint('auth', __name__, url_prefix='/api/auth') auth_bp = Blueprint('auth', __name__, url_prefix='/api/auth')
def _audit(action, target_type, target_id, summary, result, status):
"""Record an explicit audit entry for auth actions the generic hook skips.
Never raises and never includes any password value.
"""
try:
from app import audit_manager
ip = request.remote_addr or ''
xff = request.headers.get('X-Forwarded-For', '')
if xff:
last = xff.split(',')[-1].strip()
if last:
ip = last
audit_manager.record(
actor=session.get('username', 'anonymous'),
role=session.get('role', 'system'),
ip=ip, action=action, target_type=target_type, target_id=target_id,
summary=summary, result=result, status=status,
method=request.method, path=request.path,
)
except Exception:
pass
def require_auth(role=None): def require_auth(role=None):
"""Decorator that enforces session authentication and an optional role.""" """Decorator that enforces session authentication and an optional role."""
def deco(fn): def deco(fn):
@@ -124,7 +148,11 @@ def change_password():
username = session.get('username') username = session.get('username')
ok = auth_manager.change_password(username, old_pw, new_pw) ok = auth_manager.change_password(username, old_pw, new_pw)
if not ok: if not ok:
_audit('user.password_change', 'user', username or '',
'password changed', 'failure', 400)
return jsonify({'error': 'Password change failed'}), 400 return jsonify({'error': 'Password change failed'}), 400
_audit('user.password_change', 'user', username or '',
'password changed', 'success', 200)
return jsonify({'ok': True}) return jsonify({'ok': True})
@@ -142,7 +170,11 @@ def admin_reset_password():
return jsonify({'error': 'new_password must be at least 10 characters'}), 400 return jsonify({'error': 'new_password must be at least 10 characters'}), 400
ok = auth_manager.set_password_admin(username, new_pw) ok = auth_manager.set_password_admin(username, new_pw)
if not ok: if not ok:
_audit('user.password_reset', 'user', username,
f'admin reset password for peer {username}', 'failure', 400)
return jsonify({'error': 'Reset failed (user not found?)'}), 400 return jsonify({'error': 'Reset failed (user not found?)'}), 400
_audit('user.password_reset', 'user', username,
f'admin reset password for peer {username}', 'success', 200)
return jsonify({'ok': True}) return jsonify({'ok': True})
+4 -1
View File
@@ -520,6 +520,8 @@ class ConfigManager:
'api/peer_service_credentials.json', 'api/peer_service_credentials.json',
'api/cell_links.json', 'api/cell_links.json',
'api/ddns_token', 'api/ddns_token',
# Append-only audit trail (who changed what) + rotated segments
'api/audit',
# WireGuard key material (server + peers) and live confs # WireGuard key material (server + peers) and live confs
'wireguard/keys', 'wireguard/keys',
'wireguard/wg_confs', 'wireguard/wg_confs',
@@ -688,8 +690,9 @@ class ConfigManager:
for rel in ('api/peers.json', 'api/peer_service_credentials.json'): for rel in ('api/peers.json', 'api/peer_service_credentials.json'):
self._restore_data_path(backup_path, rel) self._restore_data_path(backup_path, rel)
# (4) Cell-to-cell links / permissions # (4) Cell-to-cell links / permissions + audit trail
self._restore_data_path(backup_path, 'api/cell_links.json') self._restore_data_path(backup_path, 'api/cell_links.json')
self._restore_data_path(backup_path, 'api/audit')
# (5) Caddy issued certs/ACME, DNS Corefile + zones (generated files are # (5) Caddy issued certs/ACME, DNS Corefile + zones (generated files are
# reapplied below, but restoring them gives a correct starting point). # reapplied below, but restoring them gives a correct starting point).
+4 -1
View File
@@ -34,6 +34,7 @@ from connectivity_manager import ConnectivityManager
from service_registry import ServiceRegistry from service_registry import ServiceRegistry
from service_composer import ServiceComposer from service_composer import ServiceComposer
from account_manager import AccountManager from account_manager import AccountManager
from audit_manager import AuditManager
DATA_DIR = os.environ.get('DATA_DIR', '/app/data') DATA_DIR = os.environ.get('DATA_DIR', '/app/data')
CONFIG_DIR = os.environ.get('CONFIG_DIR', '/app/config') CONFIG_DIR = os.environ.get('CONFIG_DIR', '/app/config')
@@ -125,6 +126,8 @@ egress_manager = EgressManager(
) )
service_store_manager.egress_manager = egress_manager service_store_manager.egress_manager = egress_manager
audit_manager = AuditManager(data_dir=DATA_DIR, config_dir=CONFIG_DIR)
setup_manager = SetupManager(config_manager=config_manager, auth_manager=auth_manager, setup_manager = SetupManager(config_manager=config_manager, auth_manager=auth_manager,
network_manager=network_manager) network_manager=network_manager)
@@ -154,7 +157,7 @@ __all__ = [
'cell_link_manager', 'auth_manager', 'setup_manager', 'caddy_manager', 'cell_link_manager', 'auth_manager', 'setup_manager', 'caddy_manager',
'ddns_manager', 'service_store_manager', 'connectivity_manager', 'ddns_manager', 'service_store_manager', 'connectivity_manager',
'service_registry', 'service_composer', 'account_manager', 'service_registry', 'service_composer', 'account_manager',
'egress_manager', 'egress_manager', 'audit_manager',
'firewall_manager', 'EventType', 'firewall_manager', 'EventType',
'DATA_DIR', 'CONFIG_DIR', 'DATA_DIR', 'CONFIG_DIR',
] ]
+69
View File
@@ -0,0 +1,69 @@
"""Audit trail API (admin-only).
Not added to app._PEER_READABLE_PATHS, so enforce_auth blocks peer-role
sessions with 403. Routes are thin all logic lives in AuditManager.
"""
import logging
from flask import Blueprint, request, jsonify, Response
logger = logging.getLogger('picell')
bp = Blueprint('audit', __name__)
def _filters_from_args():
args = request.args
filters = {}
for field in ('actor', 'action', 'target_type', 'target_id', 'result', 'since', 'until'):
val = args.get(field)
if val:
filters[field] = val
return filters
@bp.route('/api/audit', methods=['GET'])
def list_audit():
try:
from app import audit_manager
try:
limit = int(request.args.get('limit', 100))
except (TypeError, ValueError):
limit = 100
try:
offset = int(request.args.get('offset', 0))
except (TypeError, ValueError):
offset = 0
result = audit_manager.query(_filters_from_args(), limit=limit, offset=offset)
return jsonify(result)
except Exception as e:
logger.error(f"list_audit: {e}")
return jsonify({'error': str(e)}), 500
@bp.route('/api/audit/export', methods=['GET'])
def export_audit():
try:
from app import audit_manager
fmt = request.args.get('format', 'csv')
if fmt != 'csv':
return jsonify({'error': 'only csv format is supported'}), 400
csv_text = audit_manager.export_csv(_filters_from_args())
return Response(
csv_text,
mimetype='text/csv',
headers={'Content-Disposition': 'attachment; filename="audit.csv"'},
)
except Exception as e:
logger.error(f"export_audit: {e}")
return jsonify({'error': str(e)}), 500
@bp.route('/api/audit/verify', methods=['GET'])
def verify_audit():
try:
from app import audit_manager
return jsonify(audit_manager.verify_chain())
except Exception as e:
logger.error(f"verify_audit: {e}")
return jsonify({'error': str(e)}), 500
+180
View File
@@ -0,0 +1,180 @@
#!/usr/bin/env python3
"""Tests for the audit after_request hook, auth-route audit calls, and audit API authz."""
import os
import sys
import json
from pathlib import Path
from unittest.mock import patch
import contextlib
import pytest
sys.path.insert(0, str(Path(__file__).parent.parent / 'api'))
from app import app
from auth_manager import AuthManager
from audit_manager import AuditManager
def _make_auth_manager(tmp_path):
data_dir = str(tmp_path / 'data')
config_dir = str(tmp_path / 'config')
os.makedirs(data_dir, exist_ok=True)
os.makedirs(config_dir, exist_ok=True)
mgr = AuthManager(data_dir=data_dir, config_dir=config_dir)
mgr.create_user('admin', 'AdminPass123!', 'admin')
mgr.create_user('alice', 'AlicePass123!', 'peer')
return mgr
def _login(client, username, password):
return client.post('/api/auth/login',
data=json.dumps({'username': username, 'password': password}),
content_type='application/json')
@contextlib.contextmanager
def _client(auth_mgr, audit_mgr, login_as=None):
app.config['TESTING'] = True
app.config['SECRET_KEY'] = 'test-secret'
with patch('app.auth_manager', auth_mgr), \
patch('app.audit_manager', audit_mgr):
import auth_routes
with patch.object(auth_routes, 'auth_manager', auth_mgr, create=True):
with app.test_client() as c:
if login_as == 'admin':
assert _login(c, 'admin', 'AdminPass123!').status_code == 200
elif login_as == 'peer':
assert _login(c, 'alice', 'AlicePass123!').status_code == 200
yield c
@pytest.fixture
def auth_mgr(tmp_path):
return _make_auth_manager(tmp_path)
@pytest.fixture
def audit_mgr(tmp_path):
return AuditManager(data_dir=str(tmp_path / 'auditdata'), config_dir=str(tmp_path / 'auditcfg'))
# ── after_request capture ─────────────────────────────────────────────────────
def test_post_peers_records_peer_create(auth_mgr, audit_mgr):
with _client(auth_mgr, audit_mgr, login_as='admin') as c:
with patch('app.peer_registry') as pr:
pr.add_peer.return_value = {'success': True, 'peer': {'name': 'bob'}}
c.post('/api/peers', json={'name': 'bob'})
res = audit_mgr.query({'action': 'peer.create'})
assert res['total'] >= 1
e = res['entries'][0]
assert e['target_type'] == 'peer'
assert e['method'] == 'POST'
assert e['actor'] == 'admin'
assert e['role'] == 'admin'
def test_4xx_records_failure(auth_mgr, audit_mgr):
with _client(auth_mgr, audit_mgr, login_as='admin') as c:
# missing body -> handler returns 400
c.post('/api/peers', json={})
res = audit_mgr.query({'action': 'peer.create'})
assert res['total'] >= 1
assert res['entries'][0]['result'] == 'failure'
def test_config_update_summary_lists_key_names_only(auth_mgr, audit_mgr):
# The summary is built from request-body key names regardless of the
# handler outcome, so we assert only on the recorded audit entry.
with _client(auth_mgr, audit_mgr, login_as='admin') as c:
c.put('/api/config', json={'email': {'smtp_password': 'hunter2supersecret', 'smtp_port': 25}})
res = audit_mgr.query({'action': 'config.update'})
assert res['total'] >= 1
summary = res['entries'][0]['summary']
assert 'smtp_port' in summary
assert 'smtp_password' in summary # key NAME is allowed
assert 'hunter2supersecret' not in summary # value never recorded
def test_unmapped_mutating_endpoint_gets_generic_action(auth_mgr, audit_mgr):
# email.send_email is NOT in ROUTE_ACTION_MAP — it must still be recorded
# via the generic "<method>.<path>" fallback so nothing is invisible.
with _client(auth_mgr, audit_mgr, login_as='admin') as c:
c.post('/api/email/send', json={})
entries = audit_mgr.query({})['entries']
match = [e for e in entries if e['path'] == '/api/email/send']
assert match, 'unmapped mutating endpoint was not audited'
assert match[0]['action'] == 'post./api/email/send'
assert match[0]['target_type'] == 'unknown'
# ── auth routes: never write password ─────────────────────────────────────────
def test_change_password_audited_without_value(auth_mgr, audit_mgr):
with _client(auth_mgr, audit_mgr, login_as='admin') as c:
c.post('/api/auth/change-password',
json={'old_password': 'AdminPass123!', 'new_password': 'BrandNewPass456!'})
res = audit_mgr.query({'action': 'user.password_change'})
assert res['total'] == 1
raw = json.dumps(res['entries'][0])
assert 'AdminPass123!' not in raw
assert 'BrandNewPass456!' not in raw
assert res['entries'][0]['summary'] == 'password changed'
def test_admin_reset_password_audited_without_value(auth_mgr, audit_mgr):
with _client(auth_mgr, audit_mgr, login_as='admin') as c:
c.post('/api/auth/admin/reset-password',
json={'username': 'alice', 'new_password': 'ResetPass789!'})
res = audit_mgr.query({'action': 'user.password_reset'})
assert res['total'] == 1
raw = json.dumps(res['entries'][0])
assert 'ResetPass789!' not in raw
assert 'alice' in res['entries'][0]['summary']
def test_auth_login_does_not_write_password(auth_mgr, audit_mgr):
with _client(auth_mgr, audit_mgr) as c:
_login(c, 'admin', 'AdminPass123!')
res = audit_mgr.query({})
for e in res['entries']:
assert 'AdminPass123!' not in json.dumps(e)
# ── audit API authz ───────────────────────────────────────────────────────────
def test_peer_forbidden_on_audit_list(auth_mgr, audit_mgr):
with _client(auth_mgr, audit_mgr, login_as='peer') as c:
r = c.get('/api/audit')
assert r.status_code == 403
def test_admin_allowed_on_audit_list(auth_mgr, audit_mgr):
audit_mgr.record('admin', 'admin', '', 'peer.create', 'peer', 'bob', '',
'success', 201, 'POST', '/api/peers', '')
with _client(auth_mgr, audit_mgr, login_as='admin') as c:
r = c.get('/api/audit')
assert r.status_code == 200
body = r.get_json()
assert body['total'] >= 1
assert 'entries' in body
def test_audit_verify_endpoint(auth_mgr, audit_mgr):
audit_mgr.record('admin', 'admin', '', 'x', '', '', '', 'success', 200, 'POST', '/api/x', '')
with _client(auth_mgr, audit_mgr, login_as='admin') as c:
r = c.get('/api/audit/verify')
assert r.status_code == 200
assert r.get_json()['ok'] is True
def test_audit_export_csv(auth_mgr, audit_mgr):
audit_mgr.record('admin', 'admin', '', 'peer.create', 'peer', 'bob', '',
'success', 201, 'POST', '/api/peers', '')
with _client(auth_mgr, audit_mgr, login_as='admin') as c:
r = c.get('/api/audit/export?format=csv')
assert r.status_code == 200
assert 'text/csv' in r.content_type
assert b'peer.create' in r.data
+198
View File
@@ -0,0 +1,198 @@
#!/usr/bin/env python3
"""Tests for AuditManager and the audit capture hook / routes."""
import os
import sys
import json
import threading
from pathlib import Path
from unittest.mock import patch
import pytest
sys.path.insert(0, str(Path(__file__).parent.parent / 'api'))
from audit_manager import AuditManager
# ── manager fixture ───────────────────────────────────────────────────────────
@pytest.fixture
def audit(tmp_path):
return AuditManager(data_dir=str(tmp_path / 'data'), config_dir=str(tmp_path / 'config'))
def _lines(audit):
with open(audit._audit_file, 'r', encoding='utf-8') as f:
return [l for l in f.read().splitlines() if l.strip()]
# ── record / schema ───────────────────────────────────────────────────────────
def test_record_writes_one_jsonl_line(audit):
entry = audit.record('admin', 'admin', '10.0.0.1', 'peer.create',
'peer', 'bob', 'created', 'success', 201, 'POST', '/api/peers', 'req-1')
lines = _lines(audit)
assert len(lines) == 1
parsed = json.loads(lines[0])
for field in ('ts', 'actor', 'role', 'ip', 'action', 'target_type', 'target_id',
'summary', 'result', 'status', 'method', 'path', 'request_id',
'seq', 'prev_hash', 'hash'):
assert field in parsed
assert parsed['actor'] == 'admin'
assert parsed['action'] == 'peer.create'
assert parsed['ts'].endswith('Z') # UTC ISO
def test_result_derived_from_status(audit):
e = audit.record('a', 'admin', '', 'x', '', '', '', 'bogus', 500, 'POST', '/api/x', '')
assert e['result'] == 'failure'
e2 = audit.record('a', 'admin', '', 'x', '', '', '', 'bogus', 200, 'POST', '/api/x', '')
assert e2['result'] == 'success'
# ── redaction ─────────────────────────────────────────────────────────────────
def test_summarize_keys_lists_names_only(audit):
summary = AuditManager.summarize_keys(['network.dns_port', 'email.smtp_password', 'wireguard.private_key'])
# KEY NAMES are present (they are names, not values)...
assert 'dns_port' in summary
assert 'smtp_password' in summary
# ...but no actual value material
assert 'changed:' in summary
def test_secret_values_never_appear(audit):
secret_b64 = 'A' * 60 + '=='
bcrypt = '$2b$12$abcdefghijklmnopqrstuv'
age = 'AGE-SECRET-KEY-1QQQQQQQQQQQQQQQQQQQQQQQQQQQQQ'
e = audit.record('admin', 'admin', '', 'config.update', 'config', '',
f'token={secret_b64} hash={bcrypt} key={age}', 'success', 200,
'PUT', '/api/config', '')
raw = _lines(audit)[0]
assert secret_b64 not in raw
assert bcrypt not in raw
assert age not in raw
assert 'REDACTED' in e['summary']
# ── append-only ───────────────────────────────────────────────────────────────
def test_append_only_prior_unchanged(audit):
audit.record('a', 'admin', '', 'one', '', '', 's1', 'success', 200, 'POST', '/api/a', '')
first = _lines(audit)[0]
audit.record('b', 'admin', '', 'two', '', '', 's2', 'success', 200, 'POST', '/api/b', '')
lines = _lines(audit)
assert len(lines) == 2
assert lines[0] == first # prior line byte-for-byte unchanged
assert json.loads(lines[1])['seq'] == 2
# ── hash chain ────────────────────────────────────────────────────────────────
def test_hash_chain_links(audit):
e1 = audit.record('a', 'admin', '', 'one', '', '', '', 'success', 200, 'POST', '/api/a', '')
e2 = audit.record('b', 'admin', '', 'two', '', '', '', 'success', 200, 'POST', '/api/b', '')
assert e1['prev_hash'] == ''
assert e2['prev_hash'] == e1['hash']
assert audit.verify_chain() == {'ok': True, 'broken_at_seq': None}
def test_tamper_detected(audit):
audit.record('a', 'admin', '', 'one', '', '', 'orig', 'success', 200, 'POST', '/api/a', '')
audit.record('b', 'admin', '', 'two', '', '', 'orig2', 'success', 200, 'POST', '/api/b', '')
lines = _lines(audit)
tampered = json.loads(lines[0])
tampered['summary'] = 'HACKED'
lines[0] = json.dumps(tampered)
with open(audit._audit_file, 'w', encoding='utf-8') as f:
f.write('\n'.join(lines) + '\n')
res = audit.verify_chain()
assert res['ok'] is False
assert res['broken_at_seq'] == 1
def test_chain_can_be_disabled(tmp_path):
a = AuditManager(data_dir=str(tmp_path / 'd'), config_dir=str(tmp_path / 'c'), tamper_chain=False)
e = a.record('a', 'admin', '', 'one', '', '', '', 'success', 200, 'POST', '/api/a', '')
assert e['hash'] == ''
assert a.verify_chain().get('disabled') is True
# ── rotation ──────────────────────────────────────────────────────────────────
def test_rotation_rolls_and_chain_continues(tmp_path):
a = AuditManager(data_dir=str(tmp_path / 'd'), config_dir=str(tmp_path / 'c'))
a.MAX_FILE_SIZE = 2048 # tiny so a few records trigger rotation
for i in range(60):
a.record('admin', 'admin', '', f'act{i}', 'thing', str(i),
'x' * 40, 'success', 200, 'POST', '/api/x', '')
assert os.path.exists(a._audit_file + '.1'), 'rotation did not occur'
# Chain spans live + rotated segments and stays intact across rotation.
assert a.verify_chain() == {'ok': True, 'broken_at_seq': None}
q = a.query({}, limit=1000)
seqs = [e['seq'] for e in q['entries']]
# Newest-first ordering preserved across segment boundaries.
assert seqs == sorted(seqs, reverse=True)
# The newest record (seq 60) is always retained; order is never lost.
assert seqs[0] == 60
# Retained seqs form a contiguous run ending at the newest (older entries
# beyond BACKUP_COUNT segments are pruned, as designed).
assert seqs == list(range(60, 60 - len(seqs), -1))
# ── concurrency ───────────────────────────────────────────────────────────────
def test_concurrent_records_intact(audit):
N = 50
def worker(i):
audit.record('admin', 'admin', '', f'act{i}', 'thing', str(i),
'', 'success', 200, 'POST', '/api/x', '')
threads = [threading.Thread(target=worker, args=(i,)) for i in range(N)]
for t in threads:
t.start()
for t in threads:
t.join()
lines = _lines(audit)
assert len(lines) == N
for l in lines:
json.loads(l) # every line is valid JSON
assert audit.verify_chain()['ok'] is True
# ── filters + pagination ──────────────────────────────────────────────────────
def test_filters_and_pagination(audit):
for i in range(10):
audit.record('admin' if i % 2 == 0 else 'alice', 'admin', '',
'peer.create' if i < 5 else 'peer.delete',
'peer', f'p{i}', '', 'success' if i != 3 else 'failure',
200, 'POST', '/api/peers', '')
res = audit.query({'actor': 'alice'})
assert all(e['actor'] == 'alice' for e in res['entries'])
res = audit.query({'action': 'peer.delete'})
assert res['total'] == 5
res = audit.query({'result': 'failure'})
assert res['total'] == 1
page = audit.query({}, limit=3, offset=0)
assert len(page['entries']) == 3
assert page['total'] == 10
assert page['next_offset'] == 3
def test_export_csv(audit):
audit.record('admin', 'admin', '1.2.3.4', 'peer.create', 'peer', 'bob',
'created', 'success', 201, 'POST', '/api/peers', 'r1')
csv = audit.export_csv({})
lines = csv.strip().splitlines()
assert lines[0].startswith('ts,actor,role,ip,action')
assert 'peer.create' in csv
assert 'bob' in csv
def test_write_failure_does_not_raise(audit):
with patch('os.open', side_effect=OSError('disk full')):
result = audit.record('a', 'admin', '', 'x', '', '', '', 'success', 200, 'POST', '/api/x', '')
assert result is None # swallowed, never raised
+2
View File
@@ -58,6 +58,7 @@ class _BackupBase(unittest.TestCase):
_write(d / 'api' / 'peer_service_credentials.json', '{}') _write(d / 'api' / 'peer_service_credentials.json', '{}')
_write(d / 'api' / 'cell_links.json', '{"link": 1}') _write(d / 'api' / 'cell_links.json', '{"link": 1}')
_write(d / 'api' / 'ddns_token', 'tok123') _write(d / 'api' / 'ddns_token', 'tok123')
_write(d / 'api' / 'audit' / 'audit.log', '{"seq": 1, "action": "peer.create"}')
_write(d / 'wireguard' / 'keys' / 'server_private.key', 'PRIV') _write(d / 'wireguard' / 'keys' / 'server_private.key', 'PRIV')
_write(d / 'wireguard' / 'wg_confs' / 'wg0.conf', '[Interface]') _write(d / 'wireguard' / 'wg_confs' / 'wg0.conf', '[Interface]')
_write(d / 'api' / 'wireguard' / 'keys' / 'private.key', 'P2') _write(d / 'api' / 'wireguard' / 'keys' / 'private.key', 'P2')
@@ -92,6 +93,7 @@ class TestBackupInclude(_BackupBase):
'data/api/peer_service_credentials.json', 'data/api/peer_service_credentials.json',
'data/api/cell_links.json', 'data/api/cell_links.json',
'data/api/ddns_token', 'data/api/ddns_token',
'data/api/audit/audit.log',
'data/wireguard/keys/server_private.key', 'data/wireguard/keys/server_private.key',
'data/wireguard/wg_confs/wg0.conf', 'data/wireguard/wg_confs/wg0.conf',
'data/api/wireguard/keys/private.key', 'data/api/wireguard/keys/private.key',
+4
View File
@@ -19,6 +19,7 @@ import {
RefreshCw, RefreshCw,
AlertTriangle, AlertTriangle,
User, User,
History,
} from 'lucide-react'; } from 'lucide-react';
import { healthAPI, cellAPI, servicesAPI } from './services/api'; import { healthAPI, cellAPI, servicesAPI } from './services/api';
import { ConfigProvider } from './contexts/ConfigContext'; import { ConfigProvider } from './contexts/ConfigContext';
@@ -45,6 +46,7 @@ import EmailPage from './pages/services/EmailPage';
import CalendarPage from './pages/services/CalendarPage'; import CalendarPage from './pages/services/CalendarPage';
import FilesPage from './pages/services/FilesPage'; import FilesPage from './pages/services/FilesPage';
import Connectivity from './pages/Connectivity'; import Connectivity from './pages/Connectivity';
import ActivityPage from './pages/Activity';
import Setup from './pages/Setup'; import Setup from './pages/Setup';
import SetupGuard from './components/SetupGuard'; import SetupGuard from './components/SetupGuard';
@@ -268,6 +270,7 @@ function AppCore() {
{ name: 'Cell Network', href: '/cell-network', icon: Link2 }, { name: 'Cell Network', href: '/cell-network', icon: Link2 },
{ name: 'Connectivity', href: '/connectivity', icon: Network }, { name: 'Connectivity', href: '/connectivity', icon: Network },
{ name: 'Logs', href: '/logs', icon: Activity }, { name: 'Logs', href: '/logs', icon: Activity },
{ name: 'Activity', href: '/activity', icon: History },
{ name: 'Settings', href: '/settings', icon: SettingsIcon }, { name: 'Settings', href: '/settings', icon: SettingsIcon },
{ name: 'Account', href: '/account', icon: User }, { name: 'Account', href: '/account', icon: User },
]; ];
@@ -382,6 +385,7 @@ function AppCore() {
<Route path="/cell-network" element={<PrivateRoute requireRole="admin"><CellNetwork /></PrivateRoute>} /> <Route path="/cell-network" element={<PrivateRoute requireRole="admin"><CellNetwork /></PrivateRoute>} />
<Route path="/connectivity" element={<PrivateRoute requireRole="admin"><Connectivity /></PrivateRoute>} /> <Route path="/connectivity" element={<PrivateRoute requireRole="admin"><Connectivity /></PrivateRoute>} />
<Route path="/logs" element={<PrivateRoute requireRole="admin"><Logs /></PrivateRoute>} /> <Route path="/logs" element={<PrivateRoute requireRole="admin"><Logs /></PrivateRoute>} />
<Route path="/activity" element={<PrivateRoute requireRole="admin"><ActivityPage /></PrivateRoute>} />
<Route path="/settings" element={<PrivateRoute requireRole="admin"><Settings /></PrivateRoute>} /> <Route path="/settings" element={<PrivateRoute requireRole="admin"><Settings /></PrivateRoute>} />
</Routes> </Routes>
</div> </div>
+209
View File
@@ -0,0 +1,209 @@
import { useState, useEffect, useCallback } from 'react';
import { History, RefreshCw, Download, ShieldCheck, ShieldAlert, Search } from 'lucide-react';
import { auditAPI } from '../services/api';
const RESULTS = ['', 'success', 'failure'];
const PAGE_SIZE = 100;
function relativeTime(ts) {
if (!ts) return '';
const then = Date.parse(ts.endsWith('Z') ? ts : ts + 'Z');
if (Number.isNaN(then)) return ts;
const secs = Math.max(0, Math.floor((Date.now() - then) / 1000));
if (secs < 60) return `${secs}s ago`;
if (secs < 3600) return `${Math.floor(secs / 60)}m ago`;
if (secs < 86400) return `${Math.floor(secs / 3600)}h ago`;
return `${Math.floor(secs / 86400)}d ago`;
}
function RoleBadge({ role }) {
const cls = role === 'admin'
? 'bg-purple-100 text-purple-700'
: role === 'peer' ? 'bg-blue-100 text-blue-700' : 'bg-gray-100 text-gray-600';
return <span className={`text-xs rounded px-1.5 py-0.5 ${cls}`}>{role || 'system'}</span>;
}
function ResultPill({ result }) {
const ok = result === 'success';
return (
<span className={`text-xs rounded-full px-2 py-0.5 font-medium ${
ok ? 'bg-green-100 text-green-700' : 'bg-red-100 text-red-700'
}`}>{result}</span>
);
}
export default function Activity() {
const [entries, setEntries] = useState([]);
const [total, setTotal] = useState(0);
const [offset, setOffset] = useState(0);
const [loading, setLoading] = useState(false);
const [verify, setVerify] = useState(null);
const [actor, setActor] = useState('');
const [action, setAction] = useState('');
const [targetType, setTargetType] = useState('');
const [result, setResult] = useState('');
const [since, setSince] = useState('');
const [until, setUntil] = useState('');
const [text, setText] = useState('');
const buildParams = useCallback((ofs) => {
const p = { limit: PAGE_SIZE, offset: ofs };
if (actor) p.actor = actor;
if (action) p.action = action;
if (targetType) p.target_type = targetType;
if (result) p.result = result;
if (since) p.since = since;
if (until) p.until = until;
return p;
}, [actor, action, targetType, result, since, until]);
const load = useCallback(async (ofs = 0) => {
setLoading(true);
try {
const res = await auditAPI.list(buildParams(ofs));
setEntries(res.data.entries || []);
setTotal(res.data.total || 0);
setOffset(ofs);
} catch (e) {
setEntries([]);
setTotal(0);
} finally {
setLoading(false);
}
}, [buildParams]);
useEffect(() => { load(0); }, []); // eslint-disable-line react-hooks/exhaustive-deps
const runVerify = async () => {
try { setVerify((await auditAPI.verify()).data); } catch { setVerify({ ok: false }); }
};
const exportCsv = async () => {
try {
const res = await auditAPI.exportCsv(buildParams(0));
const url = URL.createObjectURL(new Blob([res.data], { type: 'text/csv' }));
const a = document.createElement('a');
a.href = url;
a.download = 'audit.csv';
a.click();
URL.revokeObjectURL(url);
} catch { /* ignore */ }
};
const filtered = text
? entries.filter(e =>
JSON.stringify(e).toLowerCase().includes(text.toLowerCase()))
: entries;
return (
<div>
<div className="mb-6 flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold text-gray-900 flex items-center gap-2">
<History className="h-6 w-6" /> Activity
</h1>
<p className="mt-1 text-gray-600">Append-only audit trail who changed what, when.</p>
</div>
<div className="flex gap-2">
<button className="btn btn-secondary text-sm" onClick={runVerify}>
{verify == null ? <ShieldCheck className="h-4 w-4 mr-1 inline" />
: verify.ok ? <ShieldCheck className="h-4 w-4 mr-1 inline text-green-600" />
: <ShieldAlert className="h-4 w-4 mr-1 inline text-red-600" />}
Verify
</button>
<button className="btn btn-secondary text-sm" onClick={exportCsv}>
<Download className="h-4 w-4 mr-1 inline" />Export CSV
</button>
<button className="btn btn-secondary text-sm" onClick={() => load(offset)}>
<RefreshCw className={`h-4 w-4 inline ${loading ? 'animate-spin' : ''}`} />
</button>
</div>
</div>
{verify != null && (
<div className={`mb-4 rounded p-3 text-sm ${
verify.ok ? 'bg-green-50 text-green-700' : 'bg-red-50 text-red-700'
}`}>
{verify.ok
? 'Chain verified — no tampering detected.'
: `Chain broken${verify.broken_at_seq != null ? ` at seq ${verify.broken_at_seq}` : ''}.`}
</div>
)}
<div className="card mb-4">
<div className="flex flex-wrap gap-2 items-center">
<input className="border rounded px-2 py-1 text-sm" placeholder="Actor"
value={actor} onChange={e => setActor(e.target.value)} />
<input className="border rounded px-2 py-1 text-sm" placeholder="Action"
value={action} onChange={e => setAction(e.target.value)} />
<input className="border rounded px-2 py-1 text-sm" placeholder="Target type"
value={targetType} onChange={e => setTargetType(e.target.value)} />
<select className="border rounded px-2 py-1 text-sm" value={result}
onChange={e => setResult(e.target.value)}>
{RESULTS.map(r => <option key={r} value={r}>{r || 'any result'}</option>)}
</select>
<input type="date" className="border rounded px-2 py-1 text-sm" title="Since"
value={since.slice(0, 10)} onChange={e => setSince(e.target.value ? `${e.target.value}T00:00:00Z` : '')} />
<input type="date" className="border rounded px-2 py-1 text-sm" title="Until"
value={until.slice(0, 10)} onChange={e => setUntil(e.target.value ? `${e.target.value}T23:59:59Z` : '')} />
<button className="btn btn-primary text-sm" onClick={() => load(0)}>
<Search className="h-4 w-4 mr-1 inline" />Filter
</button>
<div className="flex-1 min-w-40">
<input className="border rounded px-2 py-1 text-sm w-full" placeholder="Free text (current page)"
value={text} onChange={e => setText(e.target.value)} />
</div>
</div>
</div>
<div className="card overflow-x-auto">
<table className="min-w-full text-sm">
<thead>
<tr className="bg-gray-100 text-left">
{['When', 'Who', 'Action', 'Target', 'Summary', 'Result'].map(h =>
<th key={h} className="px-3 py-2">{h}</th>)}
</tr>
</thead>
<tbody>
{filtered.map((e, i) => (
<tr key={e.seq ?? i} className="border-t align-top">
<td className="px-3 py-2 whitespace-nowrap">
<div>{relativeTime(e.ts)}</div>
<div className="text-xs text-gray-400 font-mono">{String(e.ts || '').replace('T', ' ').replace('Z', '')}</div>
</td>
<td className="px-3 py-2 whitespace-nowrap">
<div className="font-medium">{e.actor}</div>
<div className="mt-0.5"><RoleBadge role={e.role} /></div>
{e.ip && <div className="text-xs text-gray-400 font-mono mt-0.5">{e.ip}</div>}
</td>
<td className="px-3 py-2 font-mono text-xs">{e.action}</td>
<td className="px-3 py-2">
<div>{e.target_type}</div>
{e.target_id && <div className="text-xs text-gray-500 font-mono">{e.target_id}</div>}
</td>
<td className="px-3 py-2 text-gray-600 max-w-md break-words">{e.summary}</td>
<td className="px-3 py-2"><ResultPill result={e.result} /></td>
</tr>
))}
{!filtered.length && (
<tr><td colSpan={6} className="px-3 py-6 text-center text-gray-400">
{loading ? 'Loading…' : 'No activity recorded.'}
</td></tr>
)}
</tbody>
</table>
</div>
<div className="mt-3 flex items-center justify-between text-sm text-gray-500">
<span>{total} entries · showing {offset + 1}{Math.min(offset + PAGE_SIZE, total)}</span>
<div className="flex gap-2">
<button className="btn btn-secondary text-sm" disabled={offset === 0}
onClick={() => load(Math.max(0, offset - PAGE_SIZE))}>Previous</button>
<button className="btn btn-secondary text-sm" disabled={offset + PAGE_SIZE >= total}
onClick={() => load(offset + PAGE_SIZE)}>Next</button>
</div>
</div>
</div>
);
}
+6
View File
@@ -341,6 +341,12 @@ export const logsAPI = {
setVerbosity: (levels) => api.put('/api/logs/verbosity', levels), setVerbosity: (levels) => api.put('/api/logs/verbosity', levels),
}; };
export const auditAPI = {
list: (params) => api.get('/api/audit', { params }),
exportCsv: (params) => api.get('/api/audit/export', { params, responseType: 'blob' }),
verify: () => api.get('/api/audit/verify'),
};
// DDNS API // DDNS API
export const ddnsAPI = { export const ddnsAPI = {
checkName: (name) => api.get(`/api/ddns/check/${name}`), checkName: (name) => api.get(`/api/ddns/check/${name}`),