204 lines
7.0 KiB
Python
204 lines
7.0 KiB
Python
from __future__ import annotations
|
|
|
|
import os
|
|
import pwd
|
|
import shutil
|
|
import socket
|
|
import subprocess
|
|
import threading
|
|
import time
|
|
from pathlib import Path
|
|
from typing import Any
|
|
|
|
|
|
class RedisSupervisor:
|
|
"""Run the bounded Redis ingestion buffer inside the IDS container."""
|
|
|
|
def __init__(
|
|
self,
|
|
enabled: bool,
|
|
data_dir: str,
|
|
port: int = 6379,
|
|
maxmemory_mb: int = 128,
|
|
snapshot_seconds: int = 0,
|
|
aof: bool = False,
|
|
) -> None:
|
|
self.enabled = bool(enabled)
|
|
self.data_dir = data_dir
|
|
self.port = int(port)
|
|
# Redis is an ingestion buffer. A hard ceiling prevents host OOMK if the
|
|
# SQLite archive worker stalls; noeviction makes overload explicit instead
|
|
# of silently discarding arbitrary history.
|
|
self.maxmemory_mb = max(0, int(maxmemory_mb))
|
|
self.snapshot_seconds = max(0, int(snapshot_seconds))
|
|
self.aof = bool(aof)
|
|
self.executable = shutil.which("redis-server")
|
|
self._lock = threading.RLock()
|
|
self._stop = threading.Event()
|
|
self._proc: subprocess.Popen | None = None
|
|
self._thread = threading.Thread(target=self._run, name="redis-supervisor", daemon=True)
|
|
self._restarts = 0
|
|
self._last_error = ""
|
|
|
|
def start(self, *, wait_ready_seconds: float = 12.0) -> bool:
|
|
if not self.enabled:
|
|
self._last_error = "managed Redis disabled"
|
|
return False
|
|
if not self.executable:
|
|
self._last_error = "redis-server executable not found"
|
|
return False
|
|
try:
|
|
self._prepare_data_dir()
|
|
except OSError as exc:
|
|
self._last_error = f"cannot prepare Redis data directory: {exc}"
|
|
return False
|
|
self._spawn()
|
|
if not self.wait_ready(wait_ready_seconds):
|
|
return False
|
|
if not self._thread.is_alive():
|
|
self._thread.start()
|
|
return True
|
|
|
|
def wait_ready(self, timeout: float = 12.0) -> bool:
|
|
deadline = time.monotonic() + max(0.2, float(timeout))
|
|
while time.monotonic() < deadline and not self._stop.is_set():
|
|
with self._lock:
|
|
proc = self._proc
|
|
if proc is None:
|
|
self._last_error = self._last_error or "redis-server did not start"
|
|
return False
|
|
code = proc.poll()
|
|
if code is not None:
|
|
self._last_error = f"redis-server exited with code {code}"
|
|
return False
|
|
if self._ping():
|
|
self._last_error = ""
|
|
return True
|
|
time.sleep(0.1)
|
|
self._last_error = self._last_error or f"Redis did not become ready on 127.0.0.1:{self.port}"
|
|
return False
|
|
|
|
def stop(self) -> None:
|
|
self._stop.set()
|
|
with self._lock:
|
|
proc = self._proc
|
|
if proc is not None and proc.poll() is None:
|
|
proc.terminate()
|
|
try:
|
|
proc.wait(timeout=4)
|
|
except subprocess.TimeoutExpired:
|
|
proc.kill()
|
|
proc.wait(timeout=2)
|
|
if self._thread.is_alive():
|
|
self._thread.join(timeout=2)
|
|
|
|
def status(self) -> dict[str, Any]:
|
|
with self._lock:
|
|
proc = self._proc
|
|
running = bool(proc is not None and proc.poll() is None)
|
|
pid = proc.pid if running else None
|
|
return {
|
|
"managed": self.enabled,
|
|
"available": bool(self.executable),
|
|
"running": running,
|
|
"ready": running and self._ping(),
|
|
"pid": pid,
|
|
"port": self.port,
|
|
"restarts": self._restarts,
|
|
"data_dir": self.data_dir,
|
|
"maxmemory_mb": self.maxmemory_mb,
|
|
"snapshot_seconds": self.snapshot_seconds,
|
|
"aof": self.aof,
|
|
"persistence": (
|
|
"AOF everysec + RDB" if self.aof and self.snapshot_seconds > 0
|
|
else "AOF everysec" if self.aof
|
|
else "RDB" if self.snapshot_seconds > 0
|
|
else "disabled (SQLite archive is durable)"
|
|
),
|
|
"last_error": self._last_error,
|
|
}
|
|
|
|
def _run(self) -> None:
|
|
while not self._stop.wait(2):
|
|
with self._lock:
|
|
proc = self._proc
|
|
if proc is not None and proc.poll() is None:
|
|
continue
|
|
if self._stop.is_set():
|
|
return
|
|
self._restarts += 1
|
|
self._spawn()
|
|
# A restart is only considered successful once Redis accepts PING.
|
|
self.wait_ready(8.0)
|
|
|
|
def _prepare_data_dir(self) -> None:
|
|
Path(self.data_dir).mkdir(parents=True, exist_ok=True)
|
|
try:
|
|
user = pwd.getpwnam("redis")
|
|
except KeyError:
|
|
return
|
|
for root, dirs, files in os.walk(self.data_dir):
|
|
os.chown(root, user.pw_uid, user.pw_gid)
|
|
for name in dirs:
|
|
os.chown(os.path.join(root, name), user.pw_uid, user.pw_gid)
|
|
for name in files:
|
|
os.chown(os.path.join(root, name), user.pw_uid, user.pw_gid)
|
|
|
|
def _spawn(self) -> None:
|
|
if not self.executable:
|
|
return
|
|
cmd = [
|
|
self.executable,
|
|
"--bind", "127.0.0.1",
|
|
"--protected-mode", "yes",
|
|
"--port", str(self.port),
|
|
]
|
|
if self.snapshot_seconds > 0:
|
|
cmd.extend(["--save", str(self.snapshot_seconds), "100"])
|
|
else:
|
|
cmd.extend(["--save", ""])
|
|
cmd.extend([
|
|
"--appendonly", "yes" if self.aof else "no",
|
|
"--appendfsync", "everysec",
|
|
"--aof-use-rdb-preamble", "yes",
|
|
"--dir", self.data_dir,
|
|
"--dbfilename", "traffic.rdb",
|
|
"--maxmemory-policy", "noeviction",
|
|
"--loglevel", "warning",
|
|
])
|
|
if self.maxmemory_mb > 0:
|
|
cmd.extend(["--maxmemory", f"{self.maxmemory_mb}mb"])
|
|
else:
|
|
cmd.extend(["--maxmemory", "0"])
|
|
kwargs: dict[str, Any] = {
|
|
"stdin": subprocess.DEVNULL,
|
|
"stdout": subprocess.DEVNULL,
|
|
"stderr": subprocess.DEVNULL,
|
|
"start_new_session": True,
|
|
}
|
|
try:
|
|
user = pwd.getpwnam("redis")
|
|
if os.geteuid() == 0:
|
|
kwargs["user"] = user.pw_uid
|
|
kwargs["group"] = user.pw_gid
|
|
except KeyError:
|
|
pass
|
|
try:
|
|
proc = subprocess.Popen(cmd, **kwargs)
|
|
with self._lock:
|
|
self._proc = proc
|
|
self._last_error = ""
|
|
except OSError as exc:
|
|
self._last_error = str(exc)
|
|
with self._lock:
|
|
self._proc = None
|
|
|
|
def _ping(self) -> bool:
|
|
try:
|
|
with socket.create_connection(("127.0.0.1", self.port), timeout=0.3) as sock:
|
|
sock.settimeout(0.3)
|
|
sock.sendall(b"*1\r\n$4\r\nPING\r\n")
|
|
return sock.recv(64).startswith(b"+PONG")
|
|
except OSError:
|
|
return False
|