synfull 0.0.0-stage → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,1032 @@
1
+ from __future__ import annotations
2
+
3
+ import hashlib
4
+ import http.client
5
+ import hmac
6
+ import ipaddress
7
+ import json
8
+ import logging
9
+ import os
10
+ import threading
11
+ import re
12
+ import secrets
13
+ import shutil
14
+ import ssl
15
+ import socket
16
+ import subprocess
17
+ import sys
18
+ import time
19
+ from datetime import datetime, timezone
20
+ from pathlib import Path
21
+ from typing import Any
22
+ from urllib.error import HTTPError, URLError
23
+ from urllib.parse import urlparse
24
+ from urllib.request import HTTPRedirectHandler, Request, build_opener
25
+
26
+ from sqlalchemy import Column, Float, Integer, String, Text, func, inspect, select
27
+
28
+ from ..config import get_settings
29
+ from ..database import Base, SessionLocal, engine
30
+
31
+ logger = logging.getLogger(__name__)
32
+ COMMAND_TIMEOUT_SECONDS = 8
33
+ SESSION_COOKIE = "synfull_session"
34
+ SESSION_SECONDS = 12 * 60 * 60
35
+ SERVICE_NAME_PATTERN = re.compile(r"^[A-Za-z0-9_.@:-]{1,128}$")
36
+ _login_lock = threading.Lock()
37
+ _login_failures: dict[str, list[float]] = {}
38
+
39
+
40
+ class AdminAccount(Base):
41
+ __tablename__ = "admin_account"
42
+ id = Column(Integer, primary_key=True)
43
+ username = Column(String(80), nullable=False, unique=True)
44
+ password_hash = Column(Text, nullable=False)
45
+ password_salt = Column(String(64), nullable=False)
46
+ created_at = Column(Float, nullable=False, default=time.time)
47
+ role = Column(String(16), nullable=False, default="admin")
48
+ enabled = Column(Integer, nullable=False, default=1)
49
+ permissions_json = Column(Text, nullable=False, default='["*"]')
50
+ theme = Column(String(24), nullable=False, default="synfull")
51
+ must_change_password = Column(Integer, nullable=False, default=0)
52
+
53
+
54
+ class AdminSession(Base):
55
+ __tablename__ = "admin_session"
56
+ id = Column(Integer, primary_key=True, autoincrement=True)
57
+ account_id = Column(Integer, nullable=False, default=1, index=True)
58
+ token_hash = Column(String(64), nullable=False, unique=True, index=True)
59
+ expires_at = Column(Float, nullable=False, index=True)
60
+
61
+
62
+ class AuditEntry(Base):
63
+ __tablename__ = "audit_entry"
64
+ id = Column(Integer, primary_key=True, autoincrement=True)
65
+ timestamp = Column(Float, nullable=False, index=True, default=time.time)
66
+ username = Column(String(80), nullable=False)
67
+ action = Column(String(120), nullable=False)
68
+ target = Column(String(255), nullable=False, default="")
69
+ outcome = Column(String(24), nullable=False, default="success")
70
+ details = Column(Text, nullable=False, default="")
71
+
72
+
73
+ class ManagementConfig(Base):
74
+ __tablename__ = "management_config"
75
+ id = Column(Integer, primary_key=True, default=1)
76
+ service_allowlist_json = Column(Text, nullable=False, default="[]")
77
+ endpoint_targets_json = Column(Text, nullable=False, default="[]")
78
+ backup_targets_json = Column(Text, nullable=False, default="[]")
79
+ thresholds_json = Column(Text, nullable=False, default='{"cpu_percent":90,"memory_percent":90,"disk_percent":90}')
80
+
81
+
82
+ class MetricSample(Base):
83
+ __tablename__ = "metric_sample"
84
+ id = Column(Integer, primary_key=True, autoincrement=True)
85
+ timestamp = Column(Float, nullable=False, index=True)
86
+ cpu_percent = Column(Float, nullable=True)
87
+ memory_percent = Column(Float, nullable=True)
88
+ disk_percent = Column(Float, nullable=True)
89
+
90
+
91
+ class TargetCheck(Base):
92
+ __tablename__ = "target_check"
93
+ id = Column(Integer, primary_key=True, autoincrement=True)
94
+ target_id = Column(String(64), nullable=False, index=True)
95
+ checked_at = Column(Float, nullable=False, index=True)
96
+ status = Column(String(24), nullable=False)
97
+ response_code = Column(Integer, nullable=True)
98
+ response_ms = Column(Float, nullable=True)
99
+ tls_days_remaining = Column(Integer, nullable=True)
100
+ message = Column(Text, nullable=False, default="")
101
+
102
+
103
+ class MetricAlert(Base):
104
+ __tablename__ = "metric_alert"
105
+ id = Column(Integer, primary_key=True, autoincrement=True)
106
+ key = Column(String(48), nullable=False, unique=True)
107
+ active = Column(Integer, nullable=False, default=0)
108
+ message = Column(Text, nullable=False)
109
+ value = Column(Float, nullable=True)
110
+ threshold = Column(Float, nullable=True)
111
+ changed_at = Column(Float, nullable=False)
112
+
113
+
114
+ Base.metadata.create_all(bind=engine)
115
+
116
+
117
+ def _migrate_account_schema() -> None:
118
+ account_columns = {column["name"] for column in inspect(engine).get_columns("admin_account")}
119
+ session_columns = {column["name"] for column in inspect(engine).get_columns("admin_session")}
120
+ with engine.begin() as connection:
121
+ if "role" not in account_columns:
122
+ connection.exec_driver_sql("ALTER TABLE admin_account ADD COLUMN role VARCHAR(16) NOT NULL DEFAULT 'admin'")
123
+ if "enabled" not in account_columns:
124
+ connection.exec_driver_sql("ALTER TABLE admin_account ADD COLUMN enabled INTEGER NOT NULL DEFAULT 1")
125
+ if "permissions_json" not in account_columns:
126
+ connection.exec_driver_sql("ALTER TABLE admin_account ADD COLUMN permissions_json TEXT NOT NULL DEFAULT '[\"*\"]'")
127
+ if "theme" not in account_columns:
128
+ connection.exec_driver_sql("ALTER TABLE admin_account ADD COLUMN theme VARCHAR(24) NOT NULL DEFAULT 'synfull'")
129
+ if "must_change_password" not in account_columns:
130
+ connection.exec_driver_sql("ALTER TABLE admin_account ADD COLUMN must_change_password INTEGER NOT NULL DEFAULT 0")
131
+ if "account_id" not in session_columns:
132
+ connection.exec_driver_sql("ALTER TABLE admin_session ADD COLUMN account_id INTEGER NOT NULL DEFAULT 1")
133
+
134
+
135
+ _migrate_account_schema()
136
+
137
+ USER_PERMISSIONS = {
138
+ "view_dashboard": "View dashboard and live metrics",
139
+ "view_processes": "View process inventory",
140
+ "view_system_info": "View system information and settings summary",
141
+ "view_history": "View metric history and current alert state",
142
+ "view_services": "View host services",
143
+ "manage_services": "Start, stop, or restart allowlisted services",
144
+ "view_jobs": "View scheduled jobs",
145
+ "view_network": "View configured network checks",
146
+ "manage_network": "Add, edit, remove, and run network checks",
147
+ "view_storage": "View storage and backup health",
148
+ "manage_storage": "Change configured backup checks",
149
+ "view_security": "View security posture and service allowlist",
150
+ "manage_security": "Change the service action allowlist",
151
+ "view_audit": "View administrator activity",
152
+ "view_logs": "View host event records",
153
+ "view_alerts": "View Discord alert configuration",
154
+ "manage_alerts": "Change alert configuration and thresholds",
155
+ "view_docker": "View Docker containers and logs",
156
+ "manage_docker": "Start, stop, or restart Docker containers",
157
+ }
158
+
159
+
160
+ def _admin_exists() -> bool:
161
+ with SessionLocal() as session:
162
+ return session.scalar(select(AdminAccount.id).where(AdminAccount.role == "admin", AdminAccount.enabled == 1)) is not None
163
+
164
+
165
+ def ensure_default_admin() -> bool:
166
+ """Create the local-only npm distribution's forced-rotation bootstrap account."""
167
+ try:
168
+ with SessionLocal() as session:
169
+ if session.scalar(select(AdminAccount.id).limit(1)) is not None:
170
+ return False
171
+ salt = secrets.token_bytes(32)
172
+ session.add(AdminAccount(
173
+ username="admin", password_hash=_password_hash("admin", salt).hex(), password_salt=salt.hex(),
174
+ role="admin", enabled=1, permissions_json='["*"]', must_change_password=1,
175
+ ))
176
+ session.commit()
177
+ except Exception:
178
+ with SessionLocal() as session:
179
+ if session.scalar(select(AdminAccount.id).limit(1)) is not None:
180
+ return False
181
+ raise
182
+ _audit(username="admin", action="admin.bootstrap", details="Created local default account; password rotation required")
183
+ return True
184
+
185
+
186
+ def setup_token_path() -> Path:
187
+ return get_settings().state_dir / "initial-admin-token.txt"
188
+
189
+
190
+ def ensure_setup_token() -> None:
191
+ if _admin_exists():
192
+ return
193
+ path = setup_token_path()
194
+ try:
195
+ path.parent.mkdir(parents=True, exist_ok=True)
196
+ if not path.exists():
197
+ descriptor = os.open(path, os.O_CREAT | os.O_EXCL | os.O_WRONLY, 0o600)
198
+ with os.fdopen(descriptor, "w", encoding="utf-8") as token_file:
199
+ token_file.write(secrets.token_urlsafe(32))
200
+ except FileExistsError:
201
+ pass
202
+ except OSError:
203
+ logger.exception("Could not create the initial administrator setup token file")
204
+
205
+
206
+ def auth_status(session_token: str | None = None) -> dict[str, Any]:
207
+ if os.environ.get("SYNFULL_BOOTSTRAP_DEFAULT_ADMIN") == "1":
208
+ ensure_default_admin()
209
+ else:
210
+ ensure_setup_token()
211
+ setup_required = not _admin_exists()
212
+ username = get_session_username(session_token)
213
+ settings = get_settings()
214
+ instruction = ("Use SYNFULL_INITIAL_ADMIN_TOKEN from the server environment." if settings.initial_admin_token
215
+ else "Read initial-admin-token.txt from the Synfull state directory.")
216
+ user = current_user(username) if username else None
217
+ with SessionLocal() as session:
218
+ bootstrap = session.scalar(select(AdminAccount.must_change_password).where(AdminAccount.username == "admin", AdminAccount.enabled == 1))
219
+ return {"setup_required": setup_required, "authenticated": username is not None,
220
+ "username": username, "role": user["role"] if user else None,
221
+ "permissions": user["permissions"] if user else [], "theme": user["theme"] if user else "synfull",
222
+ "must_change_password": bool(user and user["must_change_password"]),
223
+ "default_login_available": bool(bootstrap),
224
+ "setup_instructions": instruction if setup_required else None}
225
+
226
+
227
+ def _password_hash(password: str, salt: bytes) -> bytes:
228
+ return hashlib.scrypt(password.encode("utf-8"), salt=salt, n=2**14, r=8, p=1, dklen=64)
229
+
230
+
231
+ def _audit(*, username: str, action: str, target: str = "", outcome: str = "success", details: str = "") -> None:
232
+ with SessionLocal() as session:
233
+ session.add(AuditEntry(username=username, action=action, target=target[:255], outcome=outcome,
234
+ details=details[:1000], timestamp=time.time()))
235
+ session.commit()
236
+
237
+
238
+ def create_admin(*, setup_token: str, username: str, password: str) -> str:
239
+ ensure_setup_token()
240
+ settings = get_settings()
241
+ configured_token = settings.initial_admin_token
242
+ token_path = setup_token_path()
243
+ try:
244
+ file_token = token_path.read_text(encoding="utf-8").strip()
245
+ except OSError:
246
+ file_token = ""
247
+ if _admin_exists():
248
+ raise ValueError("Administrator setup has already been completed")
249
+ valid_token = bool(setup_token) and (hmac.compare_digest(setup_token, configured_token) if configured_token else hmac.compare_digest(setup_token, file_token))
250
+ if not valid_token:
251
+ _audit(username="anonymous", action="admin.setup", outcome="denied", details="Invalid setup token")
252
+ raise ValueError("Invalid setup token. Read the token from the Synfull state directory or configure SYNFULL_INITIAL_ADMIN_TOKEN.")
253
+ normalized_username = username.strip()
254
+ if not re.fullmatch(r"[A-Za-z0-9_.@-]{1,80}", normalized_username):
255
+ raise ValueError("Username must be 1–80 letters, numbers, dots, underscores, @ signs, or hyphens")
256
+ if len(password) < 12 or len(password) > 256:
257
+ raise ValueError("Password must be between 12 and 256 characters")
258
+ salt = secrets.token_bytes(32)
259
+ account = AdminAccount(username=normalized_username,
260
+ password_hash=_password_hash(password, salt).hex(), password_salt=salt.hex(),
261
+ role="admin", enabled=1, permissions_json='["*"]')
262
+ try:
263
+ with SessionLocal() as session:
264
+ session.add(account)
265
+ session.commit()
266
+ except Exception as exc:
267
+ with SessionLocal() as session:
268
+ session.rollback()
269
+ if session.scalar(select(AdminAccount.id).where(AdminAccount.username == normalized_username)) is not None:
270
+ raise ValueError("Administrator setup has already been completed") from exc
271
+ raise
272
+ try:
273
+ token_path.unlink(missing_ok=True)
274
+ except OSError:
275
+ logger.warning("Administrator setup completed but setup token file could not be removed")
276
+ _audit(username=normalized_username, action="admin.setup", details="Initial administrator created")
277
+ return issue_session(normalized_username)
278
+
279
+
280
+ def login(*, username: str, password: str, client_ip: str = "unknown") -> str:
281
+ now = time.monotonic()
282
+ with _login_lock:
283
+ failures = [when for when in _login_failures.get(client_ip, []) if now - when < 900]
284
+ if len(failures) >= 10:
285
+ raise PermissionError("Too many unsuccessful sign-in attempts. Try again in 15 minutes.")
286
+ _login_failures[client_ip] = failures
287
+ with SessionLocal() as session:
288
+ account = session.scalar(select(AdminAccount).where(func.lower(AdminAccount.username) == username.strip().lower(), AdminAccount.enabled == 1))
289
+ valid = False
290
+ if account is not None:
291
+ try:
292
+ expected = bytes.fromhex(account.password_hash)
293
+ salt = bytes.fromhex(account.password_salt)
294
+ valid = account.username.casefold() == username.strip().casefold() and hmac.compare_digest(_password_hash(password, salt), expected)
295
+ except (ValueError, TypeError):
296
+ valid = False
297
+ if not valid:
298
+ with _login_lock:
299
+ _login_failures.setdefault(client_ip, []).append(now)
300
+ _audit(username="anonymous", action="admin.login", outcome="denied", details="Invalid credentials")
301
+ raise ValueError("Invalid username or password")
302
+ with _login_lock:
303
+ _login_failures.pop(client_ip, None)
304
+ _audit(username=account.username, action="admin.login", target=account.username)
305
+ return issue_session(account.username)
306
+
307
+
308
+ def issue_session(username: str) -> str:
309
+ token = secrets.token_urlsafe(32)
310
+ with SessionLocal() as session:
311
+ account = session.scalar(select(AdminAccount).where(AdminAccount.username == username))
312
+ if account is None or not account.enabled:
313
+ raise ValueError("Cannot create a session for a disabled or missing account")
314
+ session.add(AdminSession(account_id=account.id, token_hash=hashlib.sha256(token.encode()).hexdigest(), expires_at=time.time() + SESSION_SECONDS))
315
+ session.execute(AdminSession.__table__.delete().where(AdminSession.expires_at < time.time()))
316
+ session.commit()
317
+ return token
318
+
319
+
320
+ def get_session_username(token: str | None) -> str | None:
321
+ if not token:
322
+ return None
323
+ digest = hashlib.sha256(token.encode()).hexdigest()
324
+ with SessionLocal() as session:
325
+ row = session.execute(select(AdminAccount.username)
326
+ .join(AdminSession, AdminSession.account_id == AdminAccount.id)
327
+ .where(AdminSession.token_hash == digest, AdminSession.expires_at > time.time(),
328
+ AdminAccount.enabled == 1)).first()
329
+ return row[0] if row else None
330
+
331
+
332
+ def logout(token: str | None, username: str) -> None:
333
+ if token:
334
+ digest = hashlib.sha256(token.encode()).hexdigest()
335
+ with SessionLocal() as session:
336
+ session.execute(AdminSession.__table__.delete().where(AdminSession.token_hash == digest))
337
+ session.commit()
338
+ _audit(username=username, action="admin.logout")
339
+
340
+
341
+ def _permission_for_request(request) -> str | None:
342
+ path = request.url.path.removeprefix("/api/v1/")
343
+ method = request.method.upper()
344
+ if path == "system": return "view_dashboard"
345
+ if path == "processes": return "view_processes"
346
+ if path == "settings": return "view_system_info"
347
+ if path == "auth/preferences": return None
348
+ if path == "auth/preferences/theme": return None
349
+ if path == "integrations": return "view_docker"
350
+ if path == "capabilities": return "view_dashboard"
351
+ if path == "docker/alerts/discord": return "manage_alerts" if method in {"PUT", "POST"} else "view_alerts"
352
+ if path == "docker/alerts/discord/test": return "manage_alerts"
353
+ if path.startswith("docker/containers/"):
354
+ return "manage_docker" if method == "POST" else "view_docker"
355
+ if path.startswith("management/users"): return "view_security"
356
+ if path == "management/security": return "view_security"
357
+ if path == "management/services": return "view_services"
358
+ if path == "management/services/allowlist": return "manage_security"
359
+ if path.startswith("management/services/"): return "manage_services"
360
+ if path == "management/jobs": return "view_jobs"
361
+ if path == "management/endpoints": return "manage_network" if method == "PUT" else "view_network"
362
+ if path == "management/endpoints/check": return "manage_network"
363
+ if path in {"management/backups", "management/storage/smart"}:
364
+ return "manage_storage" if path.endswith("backups") and method == "PUT" else "view_storage"
365
+ if path == "management/metrics/history": return "view_history"
366
+ if path == "management/metrics/alerts": return "manage_alerts" if method == "PUT" else "view_history"
367
+ if path == "management/metrics/thresholds": return "manage_alerts"
368
+ if path == "management/audit": return "view_audit"
369
+ if path == "management/logs": return "view_logs"
370
+ return None
371
+
372
+
373
+ def require_admin(request) -> str:
374
+ from fastapi import HTTPException
375
+ username = get_session_username(request.cookies.get(SESSION_COOKIE))
376
+ if username is None:
377
+ raise HTTPException(status_code=401, detail="Sign in to access this resource")
378
+ required = _permission_for_request(request)
379
+ with SessionLocal() as session:
380
+ account = session.scalar(select(AdminAccount).where(AdminAccount.username == username))
381
+ if account is None or not account.enabled:
382
+ raise HTTPException(status_code=401, detail="Account is disabled")
383
+ if account.must_change_password and request.url.path.removeprefix("/api/v1/") != "auth/change-password":
384
+ raise HTTPException(status_code=403, detail="Change the default password before using Synfull")
385
+ if account.role == "admin":
386
+ return username
387
+ try:
388
+ permissions = json.loads(account.permissions_json)
389
+ except (TypeError, json.JSONDecodeError):
390
+ permissions = []
391
+ if required is None and request.url.path.removeprefix("/api/v1/") not in {"auth/preferences", "auth/preferences/theme"}:
392
+ raise HTTPException(status_code=403, detail="Your account does not have permission to access this resource")
393
+ if required is not None and required not in permissions:
394
+ raise HTTPException(status_code=403, detail="Your account does not have permission to access this resource")
395
+ return username
396
+
397
+
398
+ def user_accounts() -> list[dict[str, Any]]:
399
+ with SessionLocal() as session:
400
+ accounts = session.scalars(select(AdminAccount).order_by(AdminAccount.username)).all()
401
+ return [{"id": account.id, "username": account.username, "role": account.role,
402
+ "enabled": bool(account.enabled), "permissions": json.loads(account.permissions_json),
403
+ "theme": account.theme, "created_at": account.created_at} for account in accounts]
404
+
405
+
406
+ def create_user(*, username: str, password: str, role: str, permissions: list[str], theme: str = "synfull") -> dict[str, Any]:
407
+ normalized = username.strip()
408
+ if not re.fullmatch(r"[A-Za-z0-9_.@-]{1,80}", normalized):
409
+ raise ValueError("Username must be 1–80 letters, numbers, dots, underscores, @ signs, or hyphens")
410
+ if len(password) < 12 or len(password) > 256:
411
+ raise ValueError("Password must be between 12 and 256 characters")
412
+ if role not in {"admin", "guest"}:
413
+ raise ValueError("Role must be admin or guest")
414
+ if theme not in {"synfull", "monochrome", "hacker", "light", "oled"}:
415
+ raise ValueError("Choose a supported dashboard theme")
416
+ if role == "guest" and (not set(permissions) <= USER_PERMISSIONS.keys() or len(set(permissions)) != len(permissions)):
417
+ raise ValueError("Choose only supported unique permissions")
418
+ salt = secrets.token_bytes(32)
419
+ account = AdminAccount(username=normalized, password_hash=_password_hash(password, salt).hex(),
420
+ password_salt=salt.hex(), role=role, enabled=1, theme=theme,
421
+ permissions_json=json.dumps(["*"] if role == "admin" else sorted(set(permissions))))
422
+ with SessionLocal() as session:
423
+ session.add(account)
424
+ try:
425
+ session.commit()
426
+ except Exception as exc:
427
+ session.rollback()
428
+ raise ValueError("An account with that username already exists") from exc
429
+ session.refresh(account)
430
+ created = {"id": account.id, "username": account.username, "role": account.role,
431
+ "enabled": True, "permissions": json.loads(account.permissions_json),
432
+ "theme": account.theme, "created_at": account.created_at}
433
+ return created
434
+
435
+
436
+ def update_user(user_id: int, *, role: str, enabled: bool, permissions: list[str], password: str | None = None, theme: str | None = None) -> dict[str, Any]:
437
+ if role not in {"admin", "guest"}:
438
+ raise ValueError("Role must be admin or guest")
439
+ if role == "guest" and (not set(permissions) <= USER_PERMISSIONS.keys() or len(set(permissions)) != len(permissions)):
440
+ raise ValueError("Choose only supported unique permissions")
441
+ if password is not None and not 12 <= len(password) <= 256:
442
+ raise ValueError("Password must be between 12 and 256 characters")
443
+ if theme is not None and theme not in {"synfull", "monochrome", "hacker", "light", "oled"}:
444
+ raise ValueError("Choose a supported dashboard theme")
445
+ with SessionLocal() as session:
446
+ account = session.get(AdminAccount, user_id)
447
+ if account is None:
448
+ raise ValueError("Account not found")
449
+ if account.role == "admin" and (role != "admin" or not enabled):
450
+ remaining_admins = session.scalar(select(func.count()).select_from(AdminAccount).where(
451
+ AdminAccount.role == "admin", AdminAccount.enabled == 1, AdminAccount.id != user_id)) or 0
452
+ if remaining_admins == 0:
453
+ raise ValueError("At least one enabled administrator account must remain")
454
+ if account.role == "admin" and role == "admin" and enabled:
455
+ if password is None and permissions and permissions != ["*"]:
456
+ raise ValueError("Administrator permissions are full access and cannot be restricted")
457
+ account.role = role
458
+ account.enabled = int(enabled)
459
+ account.permissions_json = json.dumps(["*"] if role == "admin" else sorted(set(permissions)))
460
+ if theme is not None:
461
+ account.theme = theme
462
+ if password is not None:
463
+ salt = secrets.token_bytes(32)
464
+ account.password_salt = salt.hex()
465
+ account.password_hash = _password_hash(password, salt).hex()
466
+ account.must_change_password = 0
467
+ if not enabled:
468
+ session.execute(AdminSession.__table__.delete().where(AdminSession.account_id == user_id))
469
+ session.commit()
470
+ return {"id": account.id, "username": account.username, "role": account.role,
471
+ "enabled": bool(account.enabled), "permissions": json.loads(account.permissions_json),
472
+ "theme": account.theme, "created_at": account.created_at}
473
+
474
+
475
+ def current_user(username: str) -> dict[str, Any]:
476
+ with SessionLocal() as session:
477
+ account = session.scalar(select(AdminAccount).where(AdminAccount.username == username))
478
+ if account is None:
479
+ raise ValueError("Account not found")
480
+ return {"id": account.id, "username": account.username, "role": account.role,
481
+ "enabled": bool(account.enabled), "permissions": json.loads(account.permissions_json),
482
+ "theme": account.theme, "must_change_password": bool(account.must_change_password)}
483
+
484
+
485
+ def change_password(username: str, password: str) -> str:
486
+ if not 12 <= len(password) <= 256:
487
+ raise ValueError("Password must be between 12 and 256 characters")
488
+ with SessionLocal() as session:
489
+ account = session.scalar(select(AdminAccount).where(AdminAccount.username == username, AdminAccount.enabled == 1))
490
+ if account is None:
491
+ raise ValueError("Account not found or disabled")
492
+ if not account.must_change_password:
493
+ raise ValueError("This password-change flow is only available for the required first-login change")
494
+ salt = secrets.token_bytes(32)
495
+ account.password_salt = salt.hex()
496
+ account.password_hash = _password_hash(password, salt).hex()
497
+ account.must_change_password = 0
498
+ session.execute(AdminSession.__table__.delete().where(AdminSession.account_id == account.id))
499
+ session.commit()
500
+ _audit(username=username, action="admin.password.change", target=username, details="Password changed")
501
+ return issue_session(username)
502
+
503
+
504
+ def update_theme(username: str, theme: str) -> str:
505
+ if theme not in {"synfull", "monochrome", "hacker", "light", "oled"}:
506
+ raise ValueError("Choose a supported dashboard theme")
507
+ with SessionLocal() as session:
508
+ account = session.scalar(select(AdminAccount).where(AdminAccount.username == username))
509
+ if account is None:
510
+ raise ValueError("Account not found")
511
+ account.theme = theme
512
+ session.commit()
513
+ return theme
514
+
515
+
516
+ def audit_entries(*, limit: int = 200, offset: int = 0) -> list[dict[str, Any]]:
517
+ with SessionLocal() as session:
518
+ rows = session.scalars(select(AuditEntry).order_by(AuditEntry.timestamp.desc(), AuditEntry.id.desc()).offset(offset).limit(limit)).all()
519
+ return [{"id": row.id, "timestamp": row.timestamp, "username": row.username, "action": row.action,
520
+ "target": row.target, "outcome": row.outcome, "details": row.details} for row in rows]
521
+
522
+
523
+ def _config(session) -> ManagementConfig:
524
+ config = session.get(ManagementConfig, 1)
525
+ if config is None:
526
+ config = ManagementConfig(id=1)
527
+ session.add(config)
528
+ session.flush()
529
+ return config
530
+
531
+
532
+ def _json_list(raw: str) -> list[dict[str, Any]]:
533
+ try:
534
+ value = json.loads(raw)
535
+ return value if isinstance(value, list) else []
536
+ except (TypeError, json.JSONDecodeError):
537
+ return []
538
+
539
+
540
+ def management_settings() -> dict[str, Any]:
541
+ with SessionLocal() as session:
542
+ config = _config(session)
543
+ session.commit()
544
+ return {"service_allowlist": json.loads(config.service_allowlist_json),
545
+ "endpoint_targets": _json_list(config.endpoint_targets_json),
546
+ "backup_targets": _json_list(config.backup_targets_json),
547
+ "thresholds": json.loads(config.thresholds_json)}
548
+
549
+
550
+ def update_service_allowlist(names: list[str], *, username: str) -> list[str]:
551
+ normalized = sorted(set(name.strip() for name in names if name.strip()))
552
+ if len(normalized) > 250 or any(not SERVICE_NAME_PATTERN.fullmatch(name) for name in normalized):
553
+ raise ValueError("Service names must use only letters, numbers, dots, underscores, @ signs, colons, or hyphens")
554
+ with SessionLocal() as session:
555
+ config = _config(session)
556
+ config.service_allowlist_json = json.dumps(normalized)
557
+ session.commit()
558
+ _audit(username=username, action="security.service_allowlist.update", details=f"Configured {len(normalized)} service(s)")
559
+ return normalized
560
+
561
+
562
+ def list_services() -> list[dict[str, Any]]:
563
+ if sys.platform == "win32":
564
+ command = shutil.which("powershell.exe") or shutil.which("powershell")
565
+ if not command:
566
+ return []
567
+ script = "Get-Service | Select-Object Name,DisplayName,Status,StartType | ConvertTo-Json -Compress"
568
+ args = [command, "-NoProfile", "-NonInteractive", "-Command", script]
569
+ code, stdout, _ = _run(args)
570
+ if code:
571
+ return []
572
+ try:
573
+ rows = json.loads(stdout)
574
+ if isinstance(rows, dict):
575
+ rows = [rows]
576
+ return [{"name": row.get("Name", ""), "display_name": row.get("DisplayName", ""),
577
+ "status": str(row.get("Status", "unknown")).lower(), "startup_type": str(row.get("StartType", "unknown")).lower()}
578
+ for row in rows if row.get("Name")]
579
+ except (json.JSONDecodeError, AttributeError):
580
+ return []
581
+ systemctl = shutil.which("systemctl")
582
+ if not systemctl:
583
+ return []
584
+ code, stdout, _ = _run([systemctl, "list-units", "--type=service", "--all", "--no-legend", "--plain"])
585
+ services = []
586
+ if code:
587
+ return services
588
+ for line in stdout.splitlines():
589
+ parts = line.split(None, 4)
590
+ if len(parts) >= 4:
591
+ services.append({"name": parts[0], "display_name": parts[4] if len(parts) > 4 else parts[0],
592
+ "status": parts[3].lower(), "startup_type": "unknown"})
593
+ return services
594
+
595
+
596
+ def _run(args: list[str]) -> tuple[int, str, str]:
597
+ try:
598
+ result = subprocess.run(args, capture_output=True, text=True, timeout=COMMAND_TIMEOUT_SECONDS,
599
+ check=False, shell=False)
600
+ return result.returncode, result.stdout, result.stderr
601
+ except (OSError, subprocess.TimeoutExpired) as exc:
602
+ return 127, "", str(exc)
603
+
604
+
605
+ def service_action(name: str, action: str, *, username: str) -> dict[str, str]:
606
+ if action not in {"start", "stop", "restart"} or not SERVICE_NAME_PATTERN.fullmatch(name):
607
+ raise ValueError("Invalid service or action")
608
+ allowed = management_settings()["service_allowlist"]
609
+ if name not in allowed:
610
+ _audit(username=username, action=f"service.{action}", target=name, outcome="denied", details="Service is not allowlisted")
611
+ raise PermissionError("This service is not in the configured action allowlist")
612
+ if sys.platform == "win32":
613
+ executable = shutil.which("sc.exe") or shutil.which("sc")
614
+ if not executable:
615
+ raise RuntimeError("Windows Service Control is unavailable")
616
+ commands = [[executable, "stop", name], [executable, "start", name]] if action == "restart" else [[executable, action, name]]
617
+ else:
618
+ executable = shutil.which("systemctl")
619
+ if not executable:
620
+ raise RuntimeError("systemd is unavailable")
621
+ commands = [[executable, action, name]]
622
+ for command in commands:
623
+ code, _, stderr = _run(command)
624
+ if code:
625
+ _audit(username=username, action=f"service.{action}", target=name, outcome="failed", details=stderr[:500])
626
+ raise RuntimeError(stderr.strip() or f"Service {action} command failed")
627
+ _audit(username=username, action=f"service.{action}", target=name)
628
+ return {"name": name, "action": action, "status": "requested"}
629
+
630
+
631
+ def save_endpoint_targets(targets: list[dict[str, Any]], *, username: str) -> list[dict[str, Any]]:
632
+ if len(targets) > 100:
633
+ raise ValueError("Configure no more than 100 endpoint checks")
634
+ normalized = []
635
+ for target in targets:
636
+ name = str(target.get("name", "")).strip()[:100]
637
+ url = str(target.get("url", "")).strip()
638
+ parsed = urlparse(url)
639
+ if not name or parsed.scheme not in {"http", "https"} or not parsed.hostname or parsed.username or parsed.password or parsed.fragment:
640
+ raise ValueError("Each endpoint needs a name and an HTTP or HTTPS URL without embedded credentials or fragments")
641
+ if len(url) > 2048:
642
+ raise ValueError("Endpoint URL exceeds the 2048 character limit")
643
+ _validate_endpoint_host(parsed)
644
+ try:
645
+ port = parsed.port
646
+ except ValueError as exc:
647
+ raise ValueError("Endpoint URL has an invalid port") from exc
648
+ if port is not None and not 1 <= port <= 65535:
649
+ raise ValueError("Endpoint URL has an invalid port")
650
+ target_id = hashlib.sha256(f"{name.casefold()}|{url}".encode()).hexdigest()[:24]
651
+ normalized.append({"id": target_id, "name": name, "url": url})
652
+ with SessionLocal() as session:
653
+ config = _config(session)
654
+ config.endpoint_targets_json = json.dumps(normalized)
655
+ session.commit()
656
+ _audit(username=username, action="network.targets.update", details=f"Configured {len(normalized)} endpoint(s)")
657
+ return normalized
658
+
659
+
660
+ def save_backup_targets(targets: list[dict[str, Any]], *, username: str) -> list[dict[str, Any]]:
661
+ if len(targets) > 100:
662
+ raise ValueError("Configure no more than 100 backup paths")
663
+ normalized = []
664
+ for target in targets:
665
+ name = str(target.get("name", "")).strip()[:100]
666
+ raw_path = str(target.get("path", "")).strip()
667
+ if not name or not raw_path or len(raw_path) > 1024:
668
+ raise ValueError("Each backup check needs a name and a local path")
669
+ path = Path(raw_path).expanduser().resolve()
670
+ max_age_hours = int(target.get("max_age_hours", 48))
671
+ if not 1 <= max_age_hours <= 8760:
672
+ raise ValueError("Backup freshness limit must be between 1 and 8760 hours")
673
+ target_id = hashlib.sha256(f"{name.casefold()}|{path}".encode()).hexdigest()[:24]
674
+ normalized.append({"id": target_id, "name": name, "path": str(path), "max_age_hours": max_age_hours})
675
+ with SessionLocal() as session:
676
+ config = _config(session)
677
+ config.backup_targets_json = json.dumps(normalized)
678
+ session.commit()
679
+ _audit(username=username, action="storage.backup_targets.update", details=f"Configured {len(normalized)} backup path(s)")
680
+ return normalized
681
+
682
+
683
+ def save_thresholds(thresholds: dict[str, float], *, username: str) -> dict[str, float]:
684
+ keys = {"cpu_percent", "memory_percent", "disk_percent"}
685
+ if set(thresholds) != keys or any(not isinstance(v, (float, int)) or not 1 <= v <= 100 for v in thresholds.values()):
686
+ raise ValueError("Set CPU, memory, and disk thresholds between 1 and 100 percent")
687
+ normalized = {key: float(value) for key, value in thresholds.items()}
688
+ with SessionLocal() as session:
689
+ config = _config(session)
690
+ config.thresholds_json = json.dumps(normalized)
691
+ session.commit()
692
+ _audit(username=username, action="monitoring.thresholds.update", details="Metric thresholds updated")
693
+ return normalized
694
+
695
+
696
+ class _NoRedirect(HTTPRedirectHandler):
697
+ def redirect_request(self, req, fp, code, msg, headers, newurl):
698
+ return None
699
+
700
+
701
+ def _record_target_check(*, target_id: str, status: str, code: int | None, response_ms: float | None,
702
+ tls_days: int | None, message: str) -> dict[str, Any]:
703
+ checked_at = time.time()
704
+ with SessionLocal() as session:
705
+ session.add(TargetCheck(target_id=target_id, checked_at=checked_at, status=status, response_code=code,
706
+ response_ms=response_ms, tls_days_remaining=tls_days, message=message[:500]))
707
+ session.commit()
708
+ return {"checked_at": checked_at, "status": status, "response_code": code,
709
+ "response_ms": response_ms, "tls_days_remaining": tls_days, "message": message[:500]}
710
+
711
+
712
+ def _validate_endpoint_host(parsed) -> None:
713
+ if parsed.scheme not in {"http", "https"} or parsed.hostname is None:
714
+ raise ValueError("Endpoint URL must use HTTP or HTTPS and include a hostname")
715
+ if parsed.username or parsed.password or parsed.fragment:
716
+ raise ValueError("Endpoint URL cannot include embedded credentials or fragments")
717
+ try:
718
+ port = parsed.port or (443 if parsed.scheme == "https" else 80)
719
+ resolved_addresses = {item[4][0] for item in socket.getaddrinfo(parsed.hostname, port, type=socket.SOCK_STREAM)}
720
+ except (OSError, ValueError) as exc:
721
+ raise ValueError(f"Endpoint host could not be resolved: {exc}") from exc
722
+ if not resolved_addresses or any(not ipaddress.ip_address(address).is_global for address in resolved_addresses):
723
+ raise ValueError("Endpoint must resolve only to public addresses; private and local network targets are blocked")
724
+
725
+
726
+ def check_endpoint(target: dict[str, Any]) -> dict[str, Any]:
727
+ url = target["url"]
728
+ parsed = urlparse(url)
729
+ if len(url) > 2048:
730
+ raise ValueError("Endpoint URL exceeds the 2048 character limit")
731
+ _validate_endpoint_host(parsed)
732
+ started = time.monotonic()
733
+ tls_days = None
734
+ try:
735
+ if parsed.scheme == "https":
736
+ context = ssl.create_default_context()
737
+ address = next(address for address in socket.getaddrinfo(parsed.hostname, parsed.port or 443, type=socket.SOCK_STREAM)
738
+ if ipaddress.ip_address(address[4][0]).is_global)[4][0]
739
+ with socket.create_connection((address, parsed.port or 443), timeout=5) as raw:
740
+ with context.wrap_socket(raw, server_hostname=parsed.hostname) as secured:
741
+ if not ipaddress.ip_address(secured.getpeername()[0]).is_global:
742
+ raise ValueError("Endpoint resolved to a private or local address during the check")
743
+ cert = secured.getpeercert()
744
+ expiry = datetime.strptime(cert["notAfter"], "%b %d %H:%M:%S %Y %Z").replace(tzinfo=timezone.utc)
745
+ tls_days = (expiry - datetime.now(timezone.utc)).days
746
+ request = Request(url, headers={"User-Agent": "Synfull/0.1 endpoint-health-check"}, method="GET")
747
+ port = parsed.port or (443 if parsed.scheme == "https" else 80)
748
+ try:
749
+ resolved = socket.getaddrinfo(parsed.hostname, port, type=socket.SOCK_STREAM)
750
+ except OSError as exc:
751
+ raise ValueError(f"Endpoint host could not be resolved: {exc}") from exc
752
+ addresses = [address[4][0] for address in resolved]
753
+ if not addresses or any(not ipaddress.ip_address(address).is_global for address in addresses):
754
+ raise ValueError("Endpoint resolved to a private or local address during the check")
755
+ # Resolve on configuration and at each check to reject DNS changes to internal addresses.
756
+ try:
757
+ with build_opener(_NoRedirect).open(request, timeout=8) as response:
758
+ code = response.status
759
+ response.read(4096)
760
+ except HTTPError as response:
761
+ code = response.code
762
+ response.close()
763
+ elapsed = round((time.monotonic() - started) * 1000, 1)
764
+ status = "healthy" if 200 <= code < 400 and (tls_days is None or tls_days >= 0) else "unhealthy"
765
+ message = (f"Endpoint redirected with HTTP {code}." if 300 <= code < 400 else
766
+ "Endpoint responded successfully." if status == "healthy" else f"Endpoint returned HTTP {code}.")
767
+ if tls_days is not None and tls_days > 30:
768
+ message += f" TLS certificate expires in {tls_days} days."
769
+ if tls_days is not None and tls_days <= 30:
770
+ message = f"TLS certificate expires in {tls_days} days."
771
+ if tls_days < 0:
772
+ status = "unhealthy"
773
+ return {**target, **_record_target_check(target_id=target["id"], status=status, code=code,
774
+ response_ms=elapsed, tls_days=tls_days, message=message)}
775
+ except (OSError, URLError, TimeoutError, KeyError, ValueError, ssl.SSLError, http.client.HTTPException) as exc:
776
+ return {**target, **_record_target_check(target_id=target["id"], status="unhealthy", code=None,
777
+ response_ms=round((time.monotonic() - started) * 1000, 1),
778
+ tls_days=tls_days, message=f"Endpoint check failed: {exc}")}
779
+
780
+
781
+ def endpoint_results() -> list[dict[str, Any]]:
782
+ with SessionLocal() as session:
783
+ rows = session.execute(select(TargetCheck).order_by(TargetCheck.checked_at.desc())).all()
784
+ latest = {}
785
+ for (row,) in rows:
786
+ if row.target_id not in latest:
787
+ latest[row.target_id] = {"checked_at": row.checked_at, "status": row.status,
788
+ "response_code": row.response_code, "response_ms": row.response_ms,
789
+ "tls_days_remaining": row.tls_days_remaining, "message": row.message}
790
+ return [{**target, **latest.get(target["id"], {"status": "pending", "message": "Waiting for first check"})}
791
+ for target in management_settings()["endpoint_targets"]]
792
+
793
+
794
+ def smart_health() -> list[dict[str, str]]:
795
+ executable = shutil.which("smartctl")
796
+ if not executable:
797
+ return [{"device": "SMART diagnostics", "status": "unsupported", "message": "Install smartmontools and ensure smartctl is available to enable disk-health checks."}]
798
+ code, stdout, stderr = _run([executable, "--scan-open"])
799
+ if code and not stdout.strip():
800
+ return [{"device": "SMART diagnostics", "status": "unavailable", "message": stderr.strip() or "Unable to enumerate SMART-capable disks."}]
801
+ devices = []
802
+ for line in stdout.splitlines():
803
+ line = line.strip()
804
+ if line and not line.startswith("#"):
805
+ device = line.split()[0]
806
+ if device.startswith("/dev/") or (sys.platform == "win32" and device.lower().startswith("\\\\.\\physicaldrive")):
807
+ devices.append(device)
808
+ results = []
809
+ for device in devices[:32]:
810
+ code, output, error = _run([executable, "-H", device])
811
+ text = output + "\
812
+ " + error
813
+ passed = "PASSED" in text.upper() or "HEALTH STATUS: OK" in text.upper()
814
+ failed = "FAILED" in text.upper() or "HEALTH STATUS: BAD" in text.upper()
815
+ results.append({"device": device, "status": "healthy" if passed else "unhealthy" if failed or code & 0x7C else "unknown",
816
+ "message": next((line.strip() for line in text.splitlines() if "health" in line.lower() or "passed" in line.lower() or "failed" in line.lower()), text.strip()[:300] or "No SMART health result returned.")})
817
+ return results or [{"device": "SMART diagnostics", "status": "unsupported", "message": "No SMART-capable devices were detected."}]
818
+
819
+
820
+ def backup_results() -> list[dict[str, Any]]:
821
+ results = []
822
+ now = time.time()
823
+ for target in management_settings()["backup_targets"]:
824
+ path = Path(target["path"])
825
+ try:
826
+ if path.is_dir():
827
+ candidates = (entry for entry in path.iterdir() if entry.is_file())
828
+ newest = max(candidates, key=lambda entry: entry.stat().st_mtime, default=None)
829
+ elif path.is_file():
830
+ newest = path
831
+ else:
832
+ newest = None
833
+ mtime = newest.stat().st_mtime if newest else None
834
+ age_hours = round((now - mtime) / 3600, 1) if mtime is not None else None
835
+ status = "healthy" if age_hours is not None and age_hours <= target["max_age_hours"] else "stale"
836
+ message = f"Latest file is {age_hours} hours old." if age_hours is not None else "No backup file found at this path."
837
+ except OSError as exc:
838
+ mtime, age_hours, status, message = None, None, "unavailable", str(exc)
839
+ results.append({**target, "status": status, "latest_modified": mtime, "age_hours": age_hours, "message": message})
840
+ return results
841
+
842
+
843
+ def scheduled_jobs() -> list[dict[str, str]]:
844
+ jobs = []
845
+ if sys.platform == "win32":
846
+ executable = shutil.which("schtasks.exe") or shutil.which("schtasks")
847
+ if not executable:
848
+ return jobs
849
+ code, stdout, _ = _run([executable, "/Query", "/FO", "CSV", "/V"])
850
+ if code:
851
+ return jobs
852
+ try:
853
+ import csv
854
+ rows = csv.DictReader(stdout.splitlines())
855
+ for row in rows:
856
+ jobs.append({"name": row.get("TaskName", ""), "schedule": row.get("Schedule Type", ""),
857
+ "last_run": row.get("Last Run Time", ""), "next_run": row.get("Next Run Time", ""),
858
+ "last_result": row.get("Last Result", ""), "status": row.get("Status", "unknown")})
859
+ except (csv.Error, AttributeError):
860
+ return []
861
+ return jobs
862
+ systemctl = shutil.which("systemctl")
863
+ if systemctl:
864
+ code, stdout, _ = _run([systemctl, "list-timers", "--all", "--no-legend", "--no-pager"])
865
+ if code == 0:
866
+ for line in stdout.splitlines():
867
+ parts = line.split(None, 5)
868
+ if len(parts) >= 5:
869
+ timer_name = next((part for part in reversed(parts) if part.endswith((".timer", ".service"))), parts[-1])
870
+ jobs.append({"name": timer_name, "schedule": "systemd timer", "last_run": parts[0],
871
+ "next_run": parts[1], "last_result": "", "status": "scheduled"})
872
+ cron = shutil.which("crontab")
873
+ if cron:
874
+ code, stdout, _ = _run([cron, "-l"])
875
+ if code == 0:
876
+ for index, line in enumerate(stdout.splitlines(), start=1):
877
+ if line.strip() and not line.lstrip().startswith("#"):
878
+ jobs.append({"name": f"user crontab line {index}", "schedule": line[:500], "last_run": "",
879
+ "next_run": "", "last_result": "", "status": "configured"})
880
+ return jobs
881
+
882
+
883
+ def system_logs(*, query: str = "", limit: int = 200) -> list[dict[str, str]]:
884
+ limit = max(1, min(limit, 500))
885
+ query = query.casefold().strip()[:200]
886
+ if sys.platform == "win32":
887
+ powershell = shutil.which("powershell.exe") or shutil.which("powershell")
888
+ if not powershell:
889
+ return []
890
+ script = f"Get-WinEvent -LogName System -MaxEvents {limit} -ErrorAction SilentlyContinue | Select-Object TimeCreated,LevelDisplayName,ProviderName,Id,Message | ConvertTo-Json -Compress"
891
+ code, stdout, _ = _run([powershell, "-NoProfile", "-NonInteractive", "-Command", script])
892
+ if code:
893
+ return []
894
+ try:
895
+ rows = json.loads(stdout)
896
+ if isinstance(rows, dict):
897
+ rows = [rows]
898
+ values = [{"timestamp": str(row.get("TimeCreated", "")), "level": str(row.get("LevelDisplayName", "")),
899
+ "source": str(row.get("ProviderName", "")), "message": str(row.get("Message", ""))} for row in rows]
900
+ except (json.JSONDecodeError, AttributeError):
901
+ return []
902
+ else:
903
+ journalctl = shutil.which("journalctl")
904
+ if not journalctl:
905
+ return []
906
+ code, stdout, _ = _run([journalctl, "--no-pager", "-n", str(limit), "-o", "json"])
907
+ if code:
908
+ return []
909
+ if len(stdout) > 5_000_000:
910
+ logger.warning("Refusing to parse an oversized journal response")
911
+ return []
912
+ values = []
913
+ for line in stdout.splitlines():
914
+ try:
915
+ row = json.loads(line)
916
+ except json.JSONDecodeError:
917
+ continue
918
+ timestamp = row.get("__REALTIME_TIMESTAMP", "")
919
+ try:
920
+ timestamp = datetime.fromtimestamp(int(timestamp) / 1_000_000, timezone.utc).isoformat()
921
+ except (TypeError, ValueError, OSError):
922
+ timestamp = str(timestamp)
923
+ values.append({"timestamp": timestamp, "level": row.get("PRIORITY", ""),
924
+ "source": row.get("SYSLOG_IDENTIFIER", ""), "message": str(row.get("MESSAGE", ""))})
925
+ return [row for row in values if not query or query in " ".join(row.values()).casefold()]
926
+
927
+
928
+ def metric_history(*, hours: int = 24) -> list[dict[str, Any]]:
929
+ hours = max(1, min(hours, 720))
930
+ cutoff = time.time() - hours * 3600
931
+ with SessionLocal() as session:
932
+ rows = session.scalars(select(MetricSample).where(MetricSample.timestamp >= cutoff)
933
+ .order_by(MetricSample.timestamp.asc()).limit(10000)).all()
934
+ return [{"timestamp": row.timestamp, "cpu_percent": row.cpu_percent,
935
+ "memory_percent": row.memory_percent, "disk_percent": row.disk_percent} for row in rows]
936
+
937
+
938
+ def collect_monitoring_sample() -> None:
939
+ from ..collectors.system import collect_system_metrics
940
+ metrics = collect_system_metrics()
941
+ memory = metrics.memory_used / metrics.memory_total * 100 if metrics.memory_total else None
942
+ disk = sum(item["used"] for item in metrics.disks) / sum(item["total"] for item in metrics.disks) * 100 if metrics.disks and sum(item["total"] for item in metrics.disks) else None
943
+ thresholds = management_settings()["thresholds"]
944
+ values = {"cpu_percent": metrics.cpu_percent, "memory_percent": memory, "disk_percent": disk}
945
+ now = time.time()
946
+ notifications = []
947
+ retention = max(1, min(get_settings().metric_retention_days, 3650)) * 86400
948
+ with SessionLocal() as session:
949
+ session.add(MetricSample(timestamp=now, cpu_percent=metrics.cpu_percent, memory_percent=memory, disk_percent=disk))
950
+ session.execute(MetricSample.__table__.delete().where(MetricSample.timestamp < now - retention))
951
+ for key, value in values.items():
952
+ if value is None:
953
+ continue
954
+ active = value >= thresholds[key]
955
+ alert = session.scalar(select(MetricAlert).where(MetricAlert.key == key))
956
+ if alert is None:
957
+ session.add(MetricAlert(key=key, active=int(active), message=f"{key} is {value:.1f}% (threshold {thresholds[key]:.0f}%)",
958
+ value=value, threshold=thresholds[key], changed_at=now))
959
+ if active:
960
+ notifications.append((f"{key.replace('_', ' ').title()} threshold exceeded",
961
+ "\n".join((f"⚠️ **Metric:** {key.replace('_', ' ').title()}",
962
+ f"**Current:** {value:.1f}%",
963
+ f"**Threshold:** {thresholds[key]:.0f}%"))))
964
+ elif bool(alert.active) != active or alert.threshold != thresholds[key]:
965
+ was_active = bool(alert.active)
966
+ alert.active = int(active)
967
+ alert.value = value
968
+ alert.threshold = thresholds[key]
969
+ alert.message = f"{key} is {value:.1f}% (threshold {thresholds[key]:.0f}%)"
970
+ alert.changed_at = now
971
+ if active and not was_active:
972
+ notifications.append((f"{key.replace('_', ' ').title()} threshold exceeded",
973
+ "\n".join((f"⚠️ **Metric:** {key.replace('_', ' ').title()}",
974
+ f"**Current:** {value:.1f}%",
975
+ f"**Threshold:** {thresholds[key]:.0f}%"))))
976
+ else:
977
+ alert.value = value
978
+ alert.threshold = thresholds[key]
979
+ alert.message = f"{key} is {value:.1f}% (threshold {thresholds[key]:.0f}%)"
980
+ session.commit()
981
+ for title, message in notifications:
982
+ try:
983
+ from ..integrations.discord_alerts import public_config, send_discord_message
984
+ if public_config()["enabled"]:
985
+ send_discord_message(message, title=title)
986
+ except Exception as exc:
987
+ logger.debug("Metric alert notification unavailable: %s", exc)
988
+
989
+
990
+ def metric_alerts() -> list[dict[str, Any]]:
991
+ with SessionLocal() as session:
992
+ rows = session.scalars(select(MetricAlert).order_by(MetricAlert.key)).all()
993
+ return [{"key": row.key, "active": bool(row.active), "message": row.message, "value": row.value,
994
+ "threshold": row.threshold, "changed_at": row.changed_at} for row in rows]
995
+
996
+
997
+ def run_configured_checks() -> None:
998
+ settings = management_settings()
999
+ for target in settings["endpoint_targets"]:
1000
+ try:
1001
+ result = check_endpoint(target)
1002
+ except ValueError as exc:
1003
+ logger.warning("Rejected configured endpoint %s: %s", target.get("name", "unknown"), exc)
1004
+ continue
1005
+ if result["status"] == "unhealthy" or (result.get("tls_days_remaining") is not None and result["tls_days_remaining"] <= 30):
1006
+ try:
1007
+ from ..integrations.discord_alerts import public_config, send_discord_message
1008
+ if public_config()["enabled"]:
1009
+ send_discord_message(
1010
+ "\n".join((
1011
+ f"⚠️ **Endpoint:** {target['name']}",
1012
+ f"**URL:** {target['url']}",
1013
+ "**Status:** Unhealthy",
1014
+ f"**Details:** {result['message']}",
1015
+ )),
1016
+ title="Endpoint health alert",
1017
+ )
1018
+ except Exception as exc:
1019
+ logger.debug("Endpoint alert notification unavailable: %s", exc)
1020
+
1021
+
1022
+ def monitor_loop(stop_event) -> None:
1023
+ last_full_check = 0.0
1024
+ while not stop_event.is_set():
1025
+ try:
1026
+ collect_monitoring_sample()
1027
+ if time.monotonic() - last_full_check >= 60:
1028
+ run_configured_checks()
1029
+ last_full_check = time.monotonic()
1030
+ except Exception:
1031
+ logger.exception("Background management monitoring iteration failed")
1032
+ stop_event.wait(max(5.0, get_settings().collection_interval_seconds))