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,51 @@
|
|
|
1
|
+
from functools import lru_cache
|
|
2
|
+
import os
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
import sys
|
|
5
|
+
|
|
6
|
+
from pydantic import Field
|
|
7
|
+
from pydantic_settings import BaseSettings, SettingsConfigDict
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def default_state_dir() -> Path:
|
|
11
|
+
if sys.platform == "win32":
|
|
12
|
+
root = Path(os.environ.get("LOCALAPPDATA", Path.home() / "AppData" / "Local"))
|
|
13
|
+
else:
|
|
14
|
+
root = Path(os.environ.get("XDG_STATE_HOME", Path.home() / ".local" / "state"))
|
|
15
|
+
return root / "Synfull" if sys.platform == "win32" else root / "synfull"
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class Settings(BaseSettings):
|
|
19
|
+
model_config = SettingsConfigDict(env_prefix="SYNFULL_", env_file=Path(__file__).resolve().parents[2] / ".env", extra="ignore")
|
|
20
|
+
|
|
21
|
+
bind_host: str = "127.0.0.1"
|
|
22
|
+
bind_port: int = 8000
|
|
23
|
+
state_dir: Path = Field(default_factory=default_state_dir)
|
|
24
|
+
database_path: Path | None = None
|
|
25
|
+
log_dir: Path | None = None
|
|
26
|
+
collection_interval_seconds: float = 5.0
|
|
27
|
+
metric_retention_days: int = 30
|
|
28
|
+
development: bool = False
|
|
29
|
+
docker_endpoint: str | None = None
|
|
30
|
+
pm2_executable: str | None = None
|
|
31
|
+
pm2_owner: str | None = None
|
|
32
|
+
service_allowlist: str = ""
|
|
33
|
+
initial_admin_token: str | None = None
|
|
34
|
+
|
|
35
|
+
@property
|
|
36
|
+
def resolved_database_path(self) -> Path:
|
|
37
|
+
return self.database_path or self.state_dir / "synfull.sqlite3"
|
|
38
|
+
|
|
39
|
+
@property
|
|
40
|
+
def resolved_log_dir(self) -> Path:
|
|
41
|
+
return self.log_dir or self.state_dir / "logs"
|
|
42
|
+
|
|
43
|
+
def prepare_directories(self) -> None:
|
|
44
|
+
self.state_dir.mkdir(parents=True, exist_ok=True)
|
|
45
|
+
self.resolved_database_path.parent.mkdir(parents=True, exist_ok=True)
|
|
46
|
+
self.resolved_log_dir.mkdir(parents=True, exist_ok=True)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
@lru_cache
|
|
50
|
+
def get_settings() -> Settings:
|
|
51
|
+
return Settings()
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
from sqlalchemy import URL, create_engine
|
|
2
|
+
from sqlalchemy.orm import DeclarativeBase, sessionmaker
|
|
3
|
+
|
|
4
|
+
from .config import get_settings
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class Base(DeclarativeBase):
|
|
8
|
+
pass
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
settings = get_settings()
|
|
12
|
+
settings.prepare_directories()
|
|
13
|
+
engine = create_engine(URL.create("sqlite", database=str(settings.resolved_database_path)), connect_args={"check_same_thread": False})
|
|
14
|
+
SessionLocal = sessionmaker(bind=engine, autoflush=False, autocommit=False)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def get_db():
|
|
18
|
+
session = SessionLocal()
|
|
19
|
+
try:
|
|
20
|
+
yield session
|
|
21
|
+
finally:
|
|
22
|
+
session.close()
|
|
@@ -0,0 +1,239 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import logging
|
|
3
|
+
import threading
|
|
4
|
+
import time
|
|
5
|
+
from typing import Any
|
|
6
|
+
from urllib.error import HTTPError, URLError
|
|
7
|
+
from urllib.parse import urlparse
|
|
8
|
+
from urllib.request import Request, urlopen
|
|
9
|
+
|
|
10
|
+
from sqlalchemy import Boolean, Column, Integer, Text
|
|
11
|
+
|
|
12
|
+
from ..config import get_settings
|
|
13
|
+
from ..database import Base, SessionLocal, engine
|
|
14
|
+
|
|
15
|
+
logger = logging.getLogger(__name__)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class DiscordAlertConfig(Base):
|
|
19
|
+
__tablename__ = "discord_alert_config"
|
|
20
|
+
|
|
21
|
+
id = Column(Integer, primary_key=True, default=1)
|
|
22
|
+
webhook_url = Column(Text, nullable=False, default="")
|
|
23
|
+
enabled = Column(Boolean, nullable=False, default=False)
|
|
24
|
+
events_json = Column(Text, nullable=False, default='{"start":true,"stop":true,"crash":true,"restart":true}')
|
|
25
|
+
minimum_interval_seconds = Column(Integer, nullable=False, default=60)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
Base.metadata.create_all(bind=engine)
|
|
29
|
+
|
|
30
|
+
DEFAULT_EVENTS = {"start": True, "stop": True, "crash": True, "restart": True}
|
|
31
|
+
EMBED_BLUE = 0x3B82F6
|
|
32
|
+
WEBHOOK_USERNAME = "Synfull - AIO Monitoring Software"
|
|
33
|
+
WEBHOOK_AVATAR_URL = "https://api.iconify.design/lucide/activity.svg?color=%23ffffff"
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _config_from_db() -> DiscordAlertConfig:
|
|
37
|
+
with SessionLocal() as session:
|
|
38
|
+
config = session.get(DiscordAlertConfig, 1)
|
|
39
|
+
if config is None:
|
|
40
|
+
config = DiscordAlertConfig(id=1)
|
|
41
|
+
session.add(config)
|
|
42
|
+
session.commit()
|
|
43
|
+
session.refresh(config)
|
|
44
|
+
return config
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _parse_events(raw: str) -> dict[str, bool]:
|
|
48
|
+
try:
|
|
49
|
+
events = json.loads(raw)
|
|
50
|
+
except (TypeError, json.JSONDecodeError):
|
|
51
|
+
events = {}
|
|
52
|
+
return {key: bool(events.get(key, False)) for key in DEFAULT_EVENTS} if isinstance(events, dict) else DEFAULT_EVENTS.copy()
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def public_config() -> dict[str, Any]:
|
|
56
|
+
config = _config_from_db()
|
|
57
|
+
masked = "https://discord.com/api/webhooks/••••••••" if config.webhook_url else ""
|
|
58
|
+
return {"enabled": config.enabled, "configured": bool(config.webhook_url), "webhook_url_masked": masked,
|
|
59
|
+
"events": _parse_events(config.events_json), "minimum_interval_seconds": config.minimum_interval_seconds}
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def update_config(*, enabled: bool, webhook_url: str | None, events: dict[str, bool], minimum_interval_seconds: int) -> dict[str, Any]:
|
|
63
|
+
with SessionLocal() as session:
|
|
64
|
+
config = session.get(DiscordAlertConfig, 1)
|
|
65
|
+
if config is None:
|
|
66
|
+
config = DiscordAlertConfig(id=1)
|
|
67
|
+
session.add(config)
|
|
68
|
+
session.flush()
|
|
69
|
+
if webhook_url == "":
|
|
70
|
+
config.webhook_url = ""
|
|
71
|
+
elif webhook_url is not None:
|
|
72
|
+
normalized_url = webhook_url.strip()
|
|
73
|
+
parsed = urlparse(normalized_url)
|
|
74
|
+
if parsed.scheme != "https" or parsed.hostname != "discord.com" or not parsed.path.startswith("/api/webhooks/") or parsed.username or parsed.password:
|
|
75
|
+
raise ValueError("Enter a valid Discord HTTPS webhook URL")
|
|
76
|
+
config.webhook_url = normalized_url
|
|
77
|
+
if minimum_interval_seconds < 0 or minimum_interval_seconds > 3600:
|
|
78
|
+
raise ValueError("Minimum alert interval must be between 0 and 3600 seconds")
|
|
79
|
+
config.enabled = enabled
|
|
80
|
+
config.events_json = json.dumps({key: bool(events.get(key, False)) for key in DEFAULT_EVENTS})
|
|
81
|
+
config.minimum_interval_seconds = minimum_interval_seconds
|
|
82
|
+
session.commit()
|
|
83
|
+
return public_config()
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def send_discord_message(content: str, *, title: str = "Monitoring alert", timeout_seconds: float = 8.0) -> None:
|
|
87
|
+
config = _config_from_db()
|
|
88
|
+
if not config.webhook_url:
|
|
89
|
+
raise ValueError("Configure a Discord webhook URL first")
|
|
90
|
+
payload = {
|
|
91
|
+
"username": WEBHOOK_USERNAME,
|
|
92
|
+
"avatar_url": WEBHOOK_AVATAR_URL,
|
|
93
|
+
"allowed_mentions": {"parse": []},
|
|
94
|
+
"embeds": [{
|
|
95
|
+
"title": title[:256],
|
|
96
|
+
"description": content[:4096],
|
|
97
|
+
"color": EMBED_BLUE,
|
|
98
|
+
"footer": {"text": "Synfull · AIO Monitoring Software"},
|
|
99
|
+
}],
|
|
100
|
+
}
|
|
101
|
+
request = Request(config.webhook_url, data=json.dumps(payload).encode("utf-8"),
|
|
102
|
+
headers={"Content-Type": "application/json", "User-Agent": "Synfull/0.1"}, method="POST")
|
|
103
|
+
try:
|
|
104
|
+
with urlopen(request, timeout=timeout_seconds) as response:
|
|
105
|
+
if response.status not in (200, 204):
|
|
106
|
+
raise RuntimeError(f"Discord returned HTTP {response.status}")
|
|
107
|
+
except HTTPError as exc:
|
|
108
|
+
raise RuntimeError(f"Discord webhook returned HTTP {exc.code}") from None
|
|
109
|
+
except URLError:
|
|
110
|
+
raise RuntimeError("Could not reach Discord webhook. Check the connection and webhook URL.") from None
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def send_test_message() -> None:
|
|
114
|
+
send_discord_message(
|
|
115
|
+
"\n".join((
|
|
116
|
+
"✅ **Webhook connected successfully**",
|
|
117
|
+
"",
|
|
118
|
+
"Synfull can now deliver monitoring alerts to this channel.",
|
|
119
|
+
)),
|
|
120
|
+
title="Webhook test",
|
|
121
|
+
)
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
class DockerEventMonitor:
|
|
125
|
+
def __init__(self, client_factory=None, interval_seconds: float = 2.0):
|
|
126
|
+
self._client_factory = client_factory or self._default_client
|
|
127
|
+
self._interval_seconds = interval_seconds
|
|
128
|
+
self._stop_event = threading.Event()
|
|
129
|
+
self._thread: threading.Thread | None = None
|
|
130
|
+
self._client = None
|
|
131
|
+
self._last_sent: dict[tuple[str, str], float] = {}
|
|
132
|
+
self._previous_states: dict[str, tuple[str, str]] = {}
|
|
133
|
+
|
|
134
|
+
@staticmethod
|
|
135
|
+
def _default_client():
|
|
136
|
+
import docker # type: ignore[import-not-found]
|
|
137
|
+
settings = get_settings()
|
|
138
|
+
return docker.DockerClient(base_url=settings.docker_endpoint, timeout=5) if settings.docker_endpoint else docker.from_env(timeout=5)
|
|
139
|
+
|
|
140
|
+
def start(self) -> None:
|
|
141
|
+
if self._thread and self._thread.is_alive():
|
|
142
|
+
return
|
|
143
|
+
self._stop_event.clear()
|
|
144
|
+
self._thread = threading.Thread(target=self._run, name="synfull-docker-alerts", daemon=True)
|
|
145
|
+
self._thread.start()
|
|
146
|
+
|
|
147
|
+
def stop(self) -> None:
|
|
148
|
+
self._stop_event.set()
|
|
149
|
+
if self._client is not None:
|
|
150
|
+
try:
|
|
151
|
+
self._client.close()
|
|
152
|
+
except Exception:
|
|
153
|
+
pass
|
|
154
|
+
self._client = None
|
|
155
|
+
if self._thread:
|
|
156
|
+
self._thread.join(timeout=2)
|
|
157
|
+
|
|
158
|
+
def _run(self) -> None:
|
|
159
|
+
while not self._stop_event.is_set():
|
|
160
|
+
try:
|
|
161
|
+
with SessionLocal() as session:
|
|
162
|
+
config = session.get(DiscordAlertConfig, 1)
|
|
163
|
+
enabled = bool(config and config.enabled and config.webhook_url)
|
|
164
|
+
except Exception as exc:
|
|
165
|
+
logger.debug("Unable to read Docker alert settings: %s", exc)
|
|
166
|
+
self._stop_event.wait(self._interval_seconds)
|
|
167
|
+
continue
|
|
168
|
+
if not enabled:
|
|
169
|
+
self._stop_event.wait(self._interval_seconds)
|
|
170
|
+
continue
|
|
171
|
+
try:
|
|
172
|
+
client = self._client_factory()
|
|
173
|
+
self._client = client
|
|
174
|
+
try:
|
|
175
|
+
for event in client.events(decode=True, filters={"type": "container", "event": ["start", "stop", "die", "restart"]}):
|
|
176
|
+
if self._stop_event.is_set():
|
|
177
|
+
return
|
|
178
|
+
self.process_event(event)
|
|
179
|
+
finally:
|
|
180
|
+
if self._client is client:
|
|
181
|
+
self._client = None
|
|
182
|
+
close = getattr(client, "close", None)
|
|
183
|
+
if close:
|
|
184
|
+
close()
|
|
185
|
+
except Exception as exc:
|
|
186
|
+
if not self._stop_event.is_set():
|
|
187
|
+
logger.debug("Docker event monitor reconnecting after error: %s", exc)
|
|
188
|
+
self._stop_event.wait(self._interval_seconds)
|
|
189
|
+
|
|
190
|
+
def process_event(self, event: dict[str, Any]) -> bool:
|
|
191
|
+
config = _config_from_db()
|
|
192
|
+
if not config.enabled or not config.webhook_url:
|
|
193
|
+
return False
|
|
194
|
+
action = str(event.get("Action") or event.get("status") or "").lower()
|
|
195
|
+
actor = event.get("Actor") or {}
|
|
196
|
+
attributes = actor.get("Attributes") or {}
|
|
197
|
+
name = attributes.get("name") or event.get("id") or "unknown container"
|
|
198
|
+
container_id = str(event.get("id") or event.get("ID") or name)[:64]
|
|
199
|
+
if action == "die":
|
|
200
|
+
try:
|
|
201
|
+
exit_code = int(attributes.get("exitCode", "1"))
|
|
202
|
+
except (TypeError, ValueError):
|
|
203
|
+
exit_code = 1
|
|
204
|
+
event_key = "stop" if exit_code == 0 else "crash"
|
|
205
|
+
else:
|
|
206
|
+
event_key = action
|
|
207
|
+
events = _parse_events(config.events_json)
|
|
208
|
+
if event_key not in events or not events[event_key]:
|
|
209
|
+
return False
|
|
210
|
+
previous = self._previous_states.get(container_id)
|
|
211
|
+
if event_key == "crash" and previous and previous[0] == "stopped":
|
|
212
|
+
return False
|
|
213
|
+
if event_key == "start" and previous and previous[0] == "stopped" and events.get("restart"):
|
|
214
|
+
event_key = "restart"
|
|
215
|
+
self._previous_states[container_id] = ("stopped" if event_key in {"stop", "crash"} else "running", name)
|
|
216
|
+
now = time.monotonic()
|
|
217
|
+
throttle_key = (container_id, event_key)
|
|
218
|
+
if now - self._last_sent.get(throttle_key, 0) < config.minimum_interval_seconds:
|
|
219
|
+
return False
|
|
220
|
+
labels = {"start": "started", "stop": "stopped", "crash": "crashed unexpectedly", "restart": "restarted"}
|
|
221
|
+
titles = {"start": "Container started", "stop": "Container stopped", "crash": "Container crashed", "restart": "Container restarted"}
|
|
222
|
+
safe_name = str(name).replace("`", "ˋ")
|
|
223
|
+
try:
|
|
224
|
+
send_discord_message(
|
|
225
|
+
"\n".join((
|
|
226
|
+
f"🐳 **Container:** `{safe_name}`",
|
|
227
|
+
f"**ID:** `{container_id[:12]}`",
|
|
228
|
+
f"**Event:** {labels[event_key].capitalize()}.",
|
|
229
|
+
)),
|
|
230
|
+
title=titles[event_key],
|
|
231
|
+
)
|
|
232
|
+
self._last_sent[throttle_key] = now
|
|
233
|
+
return True
|
|
234
|
+
except Exception as exc:
|
|
235
|
+
logger.warning("Unable to send Docker %s alert for %s: %s", event_key, name, exc)
|
|
236
|
+
return False
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
monitor = DockerEventMonitor()
|
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import re
|
|
3
|
+
import shutil
|
|
4
|
+
import subprocess
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
from ..config import get_settings
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
COMMAND_TIMEOUT_SECONDS = 3
|
|
11
|
+
CONTAINER_ID_PATTERN = re.compile(r"^[a-fA-F0-9]{1,64}$")
|
|
12
|
+
CONTAINER_NAME_PATTERN = re.compile(r"^[a-zA-Z0-9][a-zA-Z0-9_.-]{0,127}$")
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def validate_container_identifier(container_id: str) -> str:
|
|
16
|
+
if not isinstance(container_id, str) or not (CONTAINER_ID_PATTERN.fullmatch(container_id) or CONTAINER_NAME_PATTERN.fullmatch(container_id)):
|
|
17
|
+
raise ValueError("Invalid Docker container identifier")
|
|
18
|
+
return container_id
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def docker_action(container_id: str, action: str) -> dict[str, Any]:
|
|
22
|
+
identifier = validate_container_identifier(container_id)
|
|
23
|
+
if action not in {"start", "stop", "restart"}:
|
|
24
|
+
raise ValueError("Unsupported Docker action")
|
|
25
|
+
try:
|
|
26
|
+
import docker # type: ignore[import-not-found]
|
|
27
|
+
except ImportError as exc:
|
|
28
|
+
raise RuntimeError("Docker SDK is not installed; container actions are disabled") from exc
|
|
29
|
+
settings = get_settings()
|
|
30
|
+
client = docker.DockerClient(base_url=settings.docker_endpoint, timeout=COMMAND_TIMEOUT_SECONDS) if settings.docker_endpoint else docker.from_env(timeout=COMMAND_TIMEOUT_SECONDS)
|
|
31
|
+
container = client.containers.get(identifier)
|
|
32
|
+
getattr(container, action)(timeout=10) if action in {"stop", "restart"} else getattr(container, action)()
|
|
33
|
+
container.reload()
|
|
34
|
+
return {"id": container.short_id, "name": container.name, "state": container.status, "action": action}
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def docker_logs(container_id: str, tail: int = 300) -> dict[str, Any]:
|
|
38
|
+
identifier = validate_container_identifier(container_id)
|
|
39
|
+
if not 1 <= tail <= 2000:
|
|
40
|
+
raise ValueError("Log tail must be between 1 and 2000 lines")
|
|
41
|
+
try:
|
|
42
|
+
import docker # type: ignore[import-not-found]
|
|
43
|
+
except ImportError as exc:
|
|
44
|
+
raise RuntimeError("Docker SDK is not installed; container logs are unavailable") from exc
|
|
45
|
+
settings = get_settings()
|
|
46
|
+
client = docker.DockerClient(base_url=settings.docker_endpoint, timeout=COMMAND_TIMEOUT_SECONDS) if settings.docker_endpoint else docker.from_env(timeout=COMMAND_TIMEOUT_SECONDS)
|
|
47
|
+
container = client.containers.get(identifier)
|
|
48
|
+
content = container.logs(tail=tail, timestamps=True, stdout=True, stderr=True)
|
|
49
|
+
if isinstance(content, bytes):
|
|
50
|
+
content = content.decode("utf-8", errors="replace")
|
|
51
|
+
return {"id": container.short_id, "name": container.name, "logs": content, "tail": tail}
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _format_size(value: int) -> str:
|
|
55
|
+
amount = float(value)
|
|
56
|
+
for unit in ("B", "KB", "MB", "GB", "TB"):
|
|
57
|
+
if amount < 1024 or unit == "TB":
|
|
58
|
+
return f"{amount:.1f} {unit}"
|
|
59
|
+
amount /= 1024
|
|
60
|
+
return f"{amount:.1f} TB"
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _run(arguments: list[str]) -> tuple[int, str, str]:
|
|
64
|
+
try:
|
|
65
|
+
result = subprocess.run(arguments, capture_output=True, text=True, timeout=COMMAND_TIMEOUT_SECONDS,
|
|
66
|
+
check=False, shell=False)
|
|
67
|
+
return result.returncode, result.stdout, result.stderr
|
|
68
|
+
except (OSError, subprocess.TimeoutExpired) as exc:
|
|
69
|
+
return 127, "", str(exc)
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def docker_status() -> dict[str, Any]:
|
|
73
|
+
settings = get_settings()
|
|
74
|
+
docker_bin = shutil.which("docker")
|
|
75
|
+
result: dict[str, Any] = {"installed": False, "available": False, "status": "unavailable",
|
|
76
|
+
"message": "Docker CLI is not installed.", "containers": []}
|
|
77
|
+
try:
|
|
78
|
+
import docker # type: ignore[import-not-found]
|
|
79
|
+
except ImportError:
|
|
80
|
+
docker_sdk = False
|
|
81
|
+
else:
|
|
82
|
+
docker_sdk = True
|
|
83
|
+
result["sdk_installed"] = docker_sdk
|
|
84
|
+
result["cli_installed"] = docker_bin is not None
|
|
85
|
+
result["installed"] = docker_sdk or docker_bin is not None
|
|
86
|
+
if docker_sdk:
|
|
87
|
+
try:
|
|
88
|
+
client = docker.DockerClient(base_url=settings.docker_endpoint, timeout=COMMAND_TIMEOUT_SECONDS) if settings.docker_endpoint else docker.from_env(timeout=COMMAND_TIMEOUT_SECONDS)
|
|
89
|
+
containers = client.containers.list(all=True)
|
|
90
|
+
normalized = []
|
|
91
|
+
for container in containers:
|
|
92
|
+
item = {"id": container.short_id, "name": container.name,
|
|
93
|
+
"image": str(container.image.tags[0] if container.image.tags else container.image.short_id),
|
|
94
|
+
"state": container.status, "cpu_percent": None, "memory_usage": None, "memory_percent": None}
|
|
95
|
+
if container.status == "running":
|
|
96
|
+
try:
|
|
97
|
+
stats = container.stats(stream=False)
|
|
98
|
+
cpu = stats.get("cpu_stats", {})
|
|
99
|
+
previous_cpu = stats.get("precpu_stats", {})
|
|
100
|
+
cpu_delta = cpu.get("cpu_usage", {}).get("total_usage", 0) - previous_cpu.get("cpu_usage", {}).get("total_usage", 0)
|
|
101
|
+
system_delta = cpu.get("system_cpu_usage", 0) - previous_cpu.get("system_cpu_usage", 0)
|
|
102
|
+
cpu_count = cpu.get("online_cpus") or len(cpu.get("cpu_usage", {}).get("percpu_usage", [])) or 1
|
|
103
|
+
if cpu_delta >= 0 and system_delta > 0:
|
|
104
|
+
item["cpu_percent"] = round((cpu_delta / system_delta) * cpu_count * 100, 1)
|
|
105
|
+
memory = stats.get("memory_stats", {})
|
|
106
|
+
usage = memory.get("usage")
|
|
107
|
+
cache = memory.get("stats", {}).get("inactive_file", memory.get("stats", {}).get("cache", 0))
|
|
108
|
+
limit = memory.get("limit")
|
|
109
|
+
if usage is not None:
|
|
110
|
+
item["memory_usage"] = _format_size(max(0, usage - cache))
|
|
111
|
+
if usage is not None and limit:
|
|
112
|
+
item["memory_percent"] = round(max(0, usage - cache) / limit * 100, 1)
|
|
113
|
+
except Exception:
|
|
114
|
+
pass
|
|
115
|
+
normalized.append(item)
|
|
116
|
+
result.update(available=True, status="available", message="Docker daemon is reachable.", containers=normalized)
|
|
117
|
+
result["running"] = sum(c["state"] == "running" for c in result["containers"])
|
|
118
|
+
return result
|
|
119
|
+
except Exception as exc:
|
|
120
|
+
result.update(status="unavailable", message=f"Docker daemon is not reachable: {exc}")
|
|
121
|
+
if docker_bin:
|
|
122
|
+
code, stdout, stderr = _run([docker_bin, "ps", "--all", "--format", "{{json .}}"])
|
|
123
|
+
if code == 0:
|
|
124
|
+
containers = []
|
|
125
|
+
for line in stdout.splitlines():
|
|
126
|
+
try:
|
|
127
|
+
row = json.loads(line)
|
|
128
|
+
except json.JSONDecodeError:
|
|
129
|
+
continue
|
|
130
|
+
containers.append({"id": row.get("ID", ""), "name": row.get("Names", ""),
|
|
131
|
+
"image": row.get("Image", ""), "state": row.get("State", "unknown")})
|
|
132
|
+
stats_code, stats_output, _ = _run([docker_bin, "stats", "--no-stream", "--format", "{{json .}}"])
|
|
133
|
+
if stats_code == 0:
|
|
134
|
+
stats_by_name = {}
|
|
135
|
+
for line in stats_output.splitlines():
|
|
136
|
+
try:
|
|
137
|
+
row = json.loads(line)
|
|
138
|
+
except json.JSONDecodeError:
|
|
139
|
+
continue
|
|
140
|
+
stats_by_name[row.get("Name", "")] = row
|
|
141
|
+
for container in containers:
|
|
142
|
+
stats = stats_by_name.get(container["name"])
|
|
143
|
+
if stats:
|
|
144
|
+
container.update(cpu_percent=stats.get("CPUPerc"), memory_usage=stats.get("MemUsage"),
|
|
145
|
+
memory_percent=stats.get("MemPerc"))
|
|
146
|
+
else:
|
|
147
|
+
container.update(cpu_percent=None, memory_usage=None, memory_percent=None)
|
|
148
|
+
else:
|
|
149
|
+
for container in containers:
|
|
150
|
+
container.update(cpu_percent=None, memory_usage=None, memory_percent=None)
|
|
151
|
+
result.update(available=True, status="available", message="Docker daemon is reachable.", containers=containers,
|
|
152
|
+
running=sum(c["state"] == "running" for c in containers))
|
|
153
|
+
else:
|
|
154
|
+
result.update(status="unavailable", message=(stderr.strip() or "Docker daemon is stopped or inaccessible."))
|
|
155
|
+
return result
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def pm2_status() -> dict[str, Any]:
|
|
159
|
+
settings = get_settings()
|
|
160
|
+
executable = settings.pm2_executable or shutil.which("pm2")
|
|
161
|
+
result: dict[str, Any] = {"installed": executable is not None, "available": False,
|
|
162
|
+
"status": "unavailable", "message": "PM2 is not installed.", "processes": []}
|
|
163
|
+
if executable is None:
|
|
164
|
+
return result
|
|
165
|
+
result["message"] = "PM2 is installed but its process list is unavailable."
|
|
166
|
+
code, stdout, stderr = _run([executable, "jlist"])
|
|
167
|
+
if code != 0:
|
|
168
|
+
result["message"] = stderr.strip() or "PM2 process list is inaccessible for the configured user."
|
|
169
|
+
return result
|
|
170
|
+
try:
|
|
171
|
+
rows = json.loads(stdout)
|
|
172
|
+
if not isinstance(rows, list):
|
|
173
|
+
raise ValueError("PM2 returned an unexpected process list")
|
|
174
|
+
except (json.JSONDecodeError, ValueError) as exc:
|
|
175
|
+
result["message"] = f"Could not parse PM2 process list: {exc}"
|
|
176
|
+
return result
|
|
177
|
+
processes = []
|
|
178
|
+
for row in rows:
|
|
179
|
+
env = row.get("pm2_env") or {}
|
|
180
|
+
monit = row.get("monit") or {}
|
|
181
|
+
processes.append({"id": row.get("pm_id"), "name": row.get("name"),
|
|
182
|
+
"status": env.get("status", "unknown"), "cpu_percent": monit.get("cpu"),
|
|
183
|
+
"memory_bytes": monit.get("memory"), "uptime_ms": env.get("pm_uptime")})
|
|
184
|
+
result.update(available=True, status="available", message="PM2 process list is available.", processes=processes,
|
|
185
|
+
running=sum(p["status"] == "online" for p in processes))
|
|
186
|
+
if settings.pm2_owner:
|
|
187
|
+
result["configured_owner"] = settings.pm2_owner
|
|
188
|
+
return result
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def integrations_status() -> dict[str, dict[str, Any]]:
|
|
192
|
+
return {"docker": docker_status(), "pm2": pm2_status()}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
from pathlib import Path
|
|
2
|
+
|
|
3
|
+
from contextlib import asynccontextmanager
|
|
4
|
+
import threading
|
|
5
|
+
|
|
6
|
+
from fastapi import FastAPI, HTTPException
|
|
7
|
+
from fastapi.responses import FileResponse
|
|
8
|
+
from fastapi.staticfiles import StaticFiles
|
|
9
|
+
|
|
10
|
+
from .api.routes import router
|
|
11
|
+
|
|
12
|
+
from .integrations.discord_alerts import monitor
|
|
13
|
+
from .services.management import monitor_loop
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@asynccontextmanager
|
|
17
|
+
async def lifespan(_app: FastAPI):
|
|
18
|
+
monitor.start()
|
|
19
|
+
stop_event = threading.Event()
|
|
20
|
+
management_thread = threading.Thread(target=monitor_loop, args=(stop_event,), name="synfull-monitoring", daemon=True)
|
|
21
|
+
management_thread.start()
|
|
22
|
+
try:
|
|
23
|
+
yield
|
|
24
|
+
finally:
|
|
25
|
+
stop_event.set()
|
|
26
|
+
management_thread.join(timeout=3)
|
|
27
|
+
monitor.stop()
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
app = FastAPI(title="Synfull", version="0.1.0", lifespan=lifespan)
|
|
31
|
+
app.include_router(router)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
@app.get("/health")
|
|
35
|
+
def health():
|
|
36
|
+
return {"status": "ok"}
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
_FRONTEND_DIST = Path(__file__).resolve().parents[2] / "frontend" / "dist"
|
|
40
|
+
if (_FRONTEND_DIST / "assets").is_dir():
|
|
41
|
+
app.mount("/assets", StaticFiles(directory=_FRONTEND_DIST / "assets"), name="assets")
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
@app.get("/{path:path}", include_in_schema=False)
|
|
45
|
+
def frontend(path: str):
|
|
46
|
+
if path.startswith("api/") or path in {"docs", "redoc", "openapi.json", "health"}:
|
|
47
|
+
raise HTTPException(status_code=404)
|
|
48
|
+
index = _FRONTEND_DIST / "index.html"
|
|
49
|
+
if not index.is_file():
|
|
50
|
+
raise HTTPException(status_code=404, detail="Frontend assets are not built")
|
|
51
|
+
from .services.management import auth_status
|
|
52
|
+
return FileResponse(index, headers={"Cache-Control": "no-store"} if auth_status()["setup_required"] else None)
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
from pydantic import BaseModel, Field, field_validator
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
DEFAULT_DOCKER_ALERT_EVENTS = {"start", "stop", "crash", "restart"}
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class DiscordAlertUpdate(BaseModel):
|
|
8
|
+
enabled: bool
|
|
9
|
+
events: dict[str, bool] = Field(default_factory=lambda: {"start": True, "stop": True, "crash": True, "restart": True})
|
|
10
|
+
minimum_interval_seconds: int = Field(default=60, ge=0, le=3600)
|
|
11
|
+
webhook_url: str | None = None
|
|
12
|
+
clear_webhook: bool = False
|
|
13
|
+
|
|
14
|
+
@field_validator("events")
|
|
15
|
+
@classmethod
|
|
16
|
+
def validate_event_keys(cls, value: dict[str, bool]) -> dict[str, bool]:
|
|
17
|
+
if set(value) != DEFAULT_DOCKER_ALERT_EVENTS:
|
|
18
|
+
raise ValueError("Choose a value for each supported Docker alert event")
|
|
19
|
+
return value
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
from pydantic import BaseModel, Field
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class AdminSetup(BaseModel):
|
|
5
|
+
setup_token: str = Field(min_length=16, max_length=256)
|
|
6
|
+
username: str = Field(min_length=1, max_length=80)
|
|
7
|
+
password: str = Field(min_length=12, max_length=256)
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class LoginPayload(BaseModel):
|
|
11
|
+
username: str = Field(min_length=1, max_length=80)
|
|
12
|
+
password: str = Field(min_length=1, max_length=256)
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class PasswordChange(BaseModel):
|
|
16
|
+
new_password: str = Field(min_length=12, max_length=256)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class ServiceAllowlistUpdate(BaseModel):
|
|
20
|
+
services: list[str] = Field(max_length=250)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class EndpointTarget(BaseModel):
|
|
24
|
+
name: str = Field(min_length=1, max_length=100)
|
|
25
|
+
url: str = Field(min_length=8, max_length=2048)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class EndpointTargetsUpdate(BaseModel):
|
|
29
|
+
targets: list[EndpointTarget] = Field(max_length=100)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class BackupTarget(BaseModel):
|
|
33
|
+
name: str = Field(min_length=1, max_length=100)
|
|
34
|
+
path: str = Field(min_length=1, max_length=1024)
|
|
35
|
+
max_age_hours: int = Field(default=48, ge=1, le=8760)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class BackupTargetsUpdate(BaseModel):
|
|
39
|
+
targets: list[BackupTarget] = Field(max_length=100)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class ThresholdUpdate(BaseModel):
|
|
43
|
+
thresholds: dict[str, float]
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class UserCreate(BaseModel):
|
|
47
|
+
username: str = Field(min_length=1, max_length=80)
|
|
48
|
+
password: str = Field(min_length=12, max_length=256)
|
|
49
|
+
role: str = Field(pattern="^(admin|guest)$")
|
|
50
|
+
permissions: list[str] = Field(default_factory=list, max_length=32)
|
|
51
|
+
theme: str = Field(default="synfull", pattern="^(synfull|monochrome|hacker|light|oled)$")
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
class UserUpdate(BaseModel):
|
|
55
|
+
role: str = Field(pattern="^(admin|guest)$")
|
|
56
|
+
enabled: bool = True
|
|
57
|
+
permissions: list[str] = Field(default_factory=list, max_length=32)
|
|
58
|
+
password: str | None = Field(default=None, min_length=12, max_length=256)
|
|
59
|
+
theme: str | None = Field(default=None, pattern="^(synfull|monochrome|hacker|light|oled)$")
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
class ThemeUpdate(BaseModel):
|
|
63
|
+
theme: str = Field(pattern="^(synfull|monochrome|hacker|light|oled)$")
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
from pydantic import BaseModel
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class SystemResponse(BaseModel):
|
|
5
|
+
platform: str
|
|
6
|
+
hostname: str
|
|
7
|
+
cpu_percent: float | None
|
|
8
|
+
per_cpu_percent: list[float] | None
|
|
9
|
+
memory_total: int | None
|
|
10
|
+
memory_used: int | None
|
|
11
|
+
memory_available: int | None
|
|
12
|
+
swap_total: int | None
|
|
13
|
+
swap_used: int | None
|
|
14
|
+
disks: list[dict]
|
|
15
|
+
networks: list[dict]
|
|
16
|
+
uptime_seconds: float | None
|
|
17
|
+
load_average: list[float] | None
|
|
18
|
+
temperatures: list[dict] | None
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class ProcessResponse(BaseModel):
|
|
22
|
+
pid: int
|
|
23
|
+
name: str | None
|
|
24
|
+
username: str | None
|
|
25
|
+
cpu_percent: float | None
|
|
26
|
+
memory_bytes: int | None
|
|
27
|
+
executable: str | None
|
|
28
|
+
command_line: list[str] | None
|
|
29
|
+
parent_pid: int | None
|
|
30
|
+
status: str | None
|