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.
- package/LICENSE +21 -0
- package/README.md +58 -2
- package/backend/app/__init__.py +0 -0
- package/backend/app/api/__init__.py +0 -0
- package/backend/app/api/routes.py +380 -0
- package/backend/app/collectors/platform/__init__.py +13 -0
- package/backend/app/collectors/platform/base.py +26 -0
- package/backend/app/collectors/platform/linux.py +56 -0
- package/backend/app/collectors/platform/windows.py +54 -0
- package/backend/app/collectors/processes.py +7 -0
- package/backend/app/collectors/system.py +11 -0
- package/backend/app/config.py +51 -0
- package/backend/app/database.py +22 -0
- package/backend/app/integrations/discord_alerts.py +239 -0
- package/backend/app/integrations/status.py +192 -0
- package/backend/app/main.py +52 -0
- package/backend/app/schemas/alerts.py +19 -0
- package/backend/app/schemas/management.py +63 -0
- package/backend/app/schemas/system.py +30 -0
- package/backend/app/services/management.py +1032 -0
- package/backend/requirements-runtime.txt +6 -0
- package/frontend/dist/assets/index-B40UafPr.css +1 -0
- package/frontend/dist/assets/index-ERXSRoTY.js +249 -0
- package/frontend/dist/index.html +14 -0
- package/package.json +41 -4
- package/scripts/synfull.cjs +206 -0
|
@@ -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))
|