From 5c27ccda6a9d43114e1d526a5396448ed0b46bd9 Mon Sep 17 00:00:00 2001 From: 01rabbit Date: Tue, 11 Nov 2025 16:30:52 +0900 Subject: [PATCH 1/2] Persist workspace changes: EWMA scoring, display decay writer, EPD refresh-on-mode, renderer and status_collector updates, demo injector --- azazel_pi/core/display/epd_daemon.py | 82 ++++++++- azazel_pi/core/display/renderer.py | 93 +++++++--- azazel_pi/core/display/status_collector.py | 202 ++++++++++++++++++++- azazel_pi/core/state_machine.py | 46 ++++- azazel_pi/utils/network_utils.py | 77 +++++--- azctl/cli.py | 156 +++++++++++++++- azctl/daemon.py | 82 +++++++++ configs/monitoring/notify.yaml | 77 ++------ configs/monitoring/notify.yaml.bak | 68 +++++++ configs/monitoring/notify_demo.yaml | 15 ++ docs/DEMO.md | 126 +++++++++++++ runtime/demo_eve.json | 25 +++ scripts/demo_injector.py | 107 +++++++++++ scripts/demo_start.sh | 56 ++++++ scripts/demo_stop.sh | 36 ++++ scripts/eve_replay.py | 107 +++++++++++ scripts/install_demo_notify.sh | 24 +++ scripts/restore_notify.sh | 13 ++ 18 files changed, 1248 insertions(+), 144 deletions(-) create mode 100644 configs/monitoring/notify.yaml.bak create mode 100644 configs/monitoring/notify_demo.yaml create mode 100644 docs/DEMO.md create mode 100644 runtime/demo_eve.json create mode 100644 scripts/demo_injector.py create mode 100755 scripts/demo_start.sh create mode 100755 scripts/demo_stop.sh create mode 100644 scripts/eve_replay.py create mode 100644 scripts/install_demo_notify.sh create mode 100644 scripts/restore_notify.sh diff --git a/azazel_pi/core/display/epd_daemon.py b/azazel_pi/core/display/epd_daemon.py index e57d0f8..d27d394 100755 --- a/azazel_pi/core/display/epd_daemon.py +++ b/azazel_pi/core/display/epd_daemon.py @@ -17,6 +17,9 @@ from azazel_pi.core.display import EPaperRenderer, StatusCollector from azazel_pi.core.state_machine import StateMachine +from azazel_pi.core.config import AzazelConfig +from collections import deque +from pathlib import Path class EPaperDaemon: @@ -79,17 +82,49 @@ def __init__( ) self.logger = logging.getLogger(__name__) - # Initialize state machine (optional, for mode/score tracking). + # Initialize state machine (optional, for mode/score tracking). + # Try to build a StateMachine when either an explicit state_machine_path + # is provided or a system config exists at /etc/azazel/azazel.yaml. self.state_machine = None - if state_machine_path and Path(state_machine_path).exists(): - try: - # Import the build_machine function from azctl - from azctl.cli import build_machine + try: + config_path = None + if state_machine_path and Path(state_machine_path).exists(): + config_path = Path(state_machine_path) + else: + # Fall back to system config if present + system_cfg = Path(os.getenv('AZAZEL_CONFIG_PATH', '/etc/azazel/azazel.yaml')) + if system_cfg.exists(): + config_path = system_cfg + + if config_path is not None: + try: + from azctl.cli import build_machine - self.state_machine = build_machine() - self.logger.info(f"Loaded state machine from {state_machine_path}") - except Exception as e: - self.logger.warning(f"Could not load state machine: {e}") + self.state_machine = build_machine() + # Load config and apply optional scoring tuning (ewma_tau/window_size) + try: + cfg = AzazelConfig.from_file(str(config_path)) + scoring = cfg.get('scoring', {}) or {} + if 'ewma_tau' in scoring: + try: + self.state_machine.ewma_tau = float(scoring.get('ewma_tau')) + except Exception: + pass + if 'window_size' in scoring: + try: + self.state_machine.window_size = int(scoring.get('window_size')) + self.state_machine._score_window = deque(maxlen=max(self.state_machine.window_size, 1)) + except Exception: + pass + except Exception: + # Non-fatal: continue even if config can't be parsed + pass + self.logger.info(f"Loaded state machine (config: {config_path})") + except Exception as e: + self.logger.warning(f"Could not load state machine: {e}") + except Exception: + # Defensive: keep running even if state machine init fails + self.state_machine = None # Initialize status collector (allow explicit wan_state_path for testing). # Some installed copies of StatusCollector may not accept the @@ -125,6 +160,11 @@ def __init__( # happens perform a short clear + force a full refresh to avoid # ghosting/artifacts from partial updates. self._last_interface: str | None = None + # Track last seen security mode so mode transitions can trigger + # a full refresh on the E-Paper display (reduce ghosting/artifacts + # after a mode switch). Initialized to None so the first update + # won't be considered a transition. + self._last_mode: str | None = None # Set up signal handlers signal.signal(signal.SIGINT, self._signal_handler) @@ -180,6 +220,7 @@ def run(self) -> int: try: current_wan_state = getattr(status.network, "wan_state", None) current_interface = getattr(status.network, "interface", None) + current_mode = getattr(status.security, "mode", None) if self._last_wan_state != current_wan_state and current_wan_state == "reconfiguring": self.logger.info("WAN reconfiguration detected: clearing display before update") try: @@ -205,6 +246,24 @@ def run(self) -> int: except Exception as e: self.logger.debug(f"Display clear on interface change failed: {e}") force_full_refresh = True + # If the security mode itself changed since the last + # update, perform a clear + full refresh so the new + # mode is displayed cleanly on the EPD. We avoid + # treating the initial run as a transition. + try: + if ( + self._last_mode is not None + and current_mode is not None + and current_mode != self._last_mode + ): + self.logger.info(f"Mode transition detected: {self._last_mode} -> {current_mode}; forcing full refresh") + try: + self.renderer.clear() + except Exception as e: + self.logger.debug(f"Display clear on mode change failed: {e}") + force_full_refresh = True + except Exception: + pass except Exception: # Conservative: if any error occurs, don't prevent normal update pass @@ -266,6 +325,11 @@ def run(self) -> int: self._last_interface = getattr(status.network, "interface", None) except Exception: pass + # Remember last seen security mode for transition detection + try: + self._last_mode = getattr(status.security, "mode", None) + except Exception: + pass except Exception: pass # Wait for next update (with early exit on shutdown) diff --git a/azazel_pi/core/display/renderer.py b/azazel_pi/core/display/renderer.py index ada1af1..042756a 100644 --- a/azazel_pi/core/display/renderer.py +++ b/azazel_pi/core/display/renderer.py @@ -234,6 +234,55 @@ def render_status(self, status: SystemStatus) -> Image.Image: draw.text((score_x, y + 2), score_text, font=header_font, fill=0) y += 22 + # Draw a tiny sparkline for recent scores (if available). + # Use font-independent rectangle bars so the E-Paper hardware + # doesn't depend on availability of block glyphs. This method + # renders reliably on real devices. + try: + sh = getattr(status.security, "score_history", []) or [] + if sh: + vals = list(sh[-12:]) + n = len(vals) + # Sparkline drawing area (left margin 4, right margin 4) + area_x = 4 + area_w = max(8, self.width - 8) + area_y = y + area_h = 10 + + # Determine mapping: if constant values, map absolute 0-100 + # so a constant high score appears high; otherwise normalize + mn = min(vals) + mx = max(vals) + + bars_total_w = area_w + bar_w = max(1, bars_total_w // n) + gap = max(1, bar_w // 6) + + for i, v in enumerate(vals): + try: + fv = float(v) + except Exception: + fv = 0.0 + + if mx - mn < 1e-6: + # absolute mapping 0..100 + frac = max(0.0, min(1.0, fv / 100.0)) + else: + frac = (fv - mn) / (mx - mn) + + bar_h = int(frac * area_h) + x0 = area_x + i * bar_w + gap // 2 + x1 = x0 + bar_w - gap + y0 = area_y + (area_h - bar_h) + y1 = area_y + area_h + # Draw filled rectangle for the bar (black) + draw.rectangle([(x0, y0), (x1, y1)], fill=0) + + # Move cursor down after drawing sparkline area + y += area_h + 4 + except Exception: + pass + # Separator line draw.line([(0, y), (self.width, y)], fill=0, width=1) y += 4 @@ -268,41 +317,33 @@ def render_status(self, status: SystemStatus) -> Image.Image: alert_line = f"Alerts: {status.security.recent_alerts}/{status.security.total_alerts} (5m/total)" alert_line = self._fit_text(draw, alert_line, body_font, self.width - 8) draw.text((4, y), alert_line, font=body_font, fill=0) - y += 16 + y += 14 - # Service status - suri_status = "✓" if status.security.suricata_active else "✗" - canary_status = "✓" if status.security.opencanary_active else "✗" - svc_line = f"Svc: Suri{suri_status} Canary{canary_status}" - draw.text((4, y), svc_line, font=body_font, fill=0) - y += 16 + # Service status: show ON/OFF explicitly. Also display the current + # local time at the right edge of this same row so the bottom area + # doesn't need a separate uptime footer (uptime removed per request). + suri_txt = "ON" if status.security.suricata_active else "OFF" + canary_txt = "ON" if status.security.opencanary_active else "OFF" + # Short EPD form requested by user: 'Serv: Suri:ON, Canary:OFF' + svc_line = f"Serv: Suri:{suri_txt}, Canary:{canary_txt}" - # Uptime and timestamp - uptime_hours = status.uptime_seconds // 3600 - uptime_mins = (status.uptime_seconds % 3600) // 60 - # Ensure displayed time uses the local timezone. StatusCollector - # provides a timezone-aware timestamp (UTC); convert to local time - # so the E-Paper shows human-local time instead of UTC. + # Time string (local) try: local_ts = status.timestamp.astimezone() time_str = local_ts.strftime("%H:%M:%S") except Exception: - # Fallback to naive formatting if anything goes wrong time_str = status.timestamp.strftime("%H:%M:%S") - footer = f"Up {uptime_hours}h{uptime_mins}m | {time_str}" - footer = self._fit_text(draw, footer, body_font, self.width - 8) - # Reserve footer area to avoid content overlap: compute footer bbox - # and ensure we don't draw other content into this region. + + # Draw service text at left, time at right + draw.text((4, y), svc_line, font=body_font, fill=0) try: - fbbox = draw.textbbox((0, 0), footer, font=body_font) - footer_height = fbbox[3] - fbbox[1] + tbbox = draw.textbbox((0, 0), time_str, font=body_font) + tw = tbbox[2] - tbbox[0] except Exception: - footer_height = 12 - footer_y = self.height - footer_height - 2 - # If current y has already reached footer area, shift it up slightly - if y >= footer_y: - y = max(2, footer_y - 16) - draw.text((4, footer_y), footer, font=body_font, fill=0) + tw = 40 + time_x = max(4, self.width - tw - 4) + draw.text((time_x, y), time_str, font=body_font, fill=0) + y += 16 # Prevent any accidental drawing beyond footer by returning image # (all content should be complete at this point). diff --git a/azazel_pi/core/display/status_collector.py b/azazel_pi/core/display/status_collector.py index 74bd76e..d7ad939 100644 --- a/azazel_pi/core/display/status_collector.py +++ b/azazel_pi/core/display/status_collector.py @@ -3,13 +3,14 @@ import json import subprocess -from dataclasses import dataclass +from dataclasses import dataclass, field from datetime import datetime, timezone import os from pathlib import Path from typing import Any, Dict, Optional, Iterable from ..state_machine import StateMachine +import math from ...utils.wan_state import ( InterfaceSnapshot, WANState, @@ -41,6 +42,9 @@ class SecurityStatus: recent_alerts: int suricata_active: bool opencanary_active: bool + # New: recent score history (most recent last) and last decision summary + score_history: list[float] = field(default_factory=list) + last_decision: Optional[dict] = None @dataclass @@ -265,10 +269,81 @@ def _get_security_status(self) -> SecurityStatus: # Get mode and score from state machine if available if self.state_machine: mode = self.state_machine.current_state.name - if len(self.state_machine._score_window) > 0: - score_average = sum(self.state_machine._score_window) / len( - self.state_machine._score_window - ) + # Prefer EWMA score if state_machine exposes it via get_current_score() + try: + metrics = self.state_machine.get_current_score() + score_average = float(metrics.get("ewma", 0.0)) + # use history from state machine if provided + score_history = list(metrics.get("history", []))[-24:] + except Exception: + # Fallback to legacy window average + if len(self.state_machine._score_window) > 0: + score_average = sum(self.state_machine._score_window) / len( + self.state_machine._score_window + ) + + # Build a recent score history (copy, limit to last 24 entries) + score_history = [] + try: + if self.state_machine and hasattr(self.state_machine, "_score_window"): + score_history = list(self.state_machine._score_window)[-24:] + except Exception: + score_history = [] + + # If we still have no score_history (either because the in-memory + # StateMachine hasn't collected values yet or because we're running + # in a short-lived process), attempt to build recent history from + # the authoritative decisions.log. We do this regardless of whether + # a state_machine exists so the TUI/EPD can show a trend even when + # the in-process machine hasn't been populated. + if not score_history: + try: + # Probe the same candidate paths as _read_last_decision_if_any() + candidates = [ + Path("/var/log/azazel/decisions.log"), + Path("/etc/azazel/decisions.log"), + Path("/var/lib/azazel/decisions.log"), + Path("decisions.log"), + ] + for p in candidates: + if not p.exists(): + continue + with p.open("r") as fh: + lines = [l.strip() for l in fh.readlines() if l.strip()] + if not lines: + continue + # Parse last up to 24 JSON lines and extract the most + # representative numeric field (prefer 'average', then + # 'score', then 'severity'). This builds a sparkline + # even when the StateMachine hasn't produced a local + # _score_window yet. + recent = [] + for ln in lines[-24:]: + try: + obj = json.loads(ln) + except Exception: + continue + val = None + if isinstance(obj.get("average"), (int, float)): + val = obj.get("average") + elif isinstance(obj.get("score"), (int, float)): + val = obj.get("score") + else: + val = obj.get("severity") + if val is None: + continue + try: + recent.append(float(val)) + except Exception: + continue + if recent: + score_history = recent + score_average = sum(score_history) / len(score_history) + # We found a candidate file and built history; stop searching + break + except Exception: + # Best-effort fallback; leave score_history empty on error + pass # Count alerts from events log total_alerts, recent_alerts = self._count_alerts() @@ -277,6 +352,80 @@ def _get_security_status(self) -> SecurityStatus: suricata_active = self._is_service_active("suricata") opencanary_active = self._is_service_active("opencanary") + # Read last decision from typical decision log locations + last_decision = self._read_last_decision_if_any() + + # If there's a recent last_decision available from the daemon, prefer + # its authoritative mode/average values for display. This keeps the + # TUI/EPD consistent with the running daemon even when the local + # in-process StateMachine isn't receiving events. + try: + if isinstance(last_decision, dict): + # Use mode if present + last_mode = last_decision.get("mode") + if last_mode: + mode = last_mode + # Use reported average if present (decisions.log contains "average") + last_avg = last_decision.get("average") + if isinstance(last_avg, (int, float)): + score_average = float(last_avg) + # If decisions.log contains multiple recent entries we may also + # build a small history for sparkline purposes. Prefer any + # 'history' field if present, else leave existing score_history. + if isinstance(last_decision.get("history"), list): + score_history = list(last_decision.get("history"))[-24:] + except Exception: + # Best-effort: ignore errors and keep prior values + pass + + # If the authoritative decisions.log hasn't been updated recently + # we want the displayed score to gradually fall to reflect a + # decaying threat level even when no new events are being written. + # This is a display-only decay (we don't modify the decisions.log). + try: + # Find the most likely decisions.log file and inspect mtime + candidates = [ + Path("/var/log/azazel/decisions.log"), + Path("/etc/azazel/decisions.log"), + Path("/var/lib/azazel/decisions.log"), + Path("decisions.log"), + ] + found_path = None + for p in candidates: + if p.exists(): + found_path = p + break + if found_path is not None: + now_ts = datetime.now(timezone.utc).timestamp() + try: + age = now_ts - float(found_path.stat().st_mtime) + except Exception: + age = 0.0 + # Configure decay timescale (seconds). Can be tuned via env var. + try: + decay_tau = float(os.getenv("AZAZEL_DISPLAY_DECAY_TAU", "120")) + except Exception: + decay_tau = 120.0 + # Apply exponential decay for display if age is non-zero and + # we have a positive baseline score to decay from. + if age > 0.5 and score_average > 0.0 and decay_tau > 0.0: + try: + decayed = float(score_average) * math.exp(-age / decay_tau) + # Ensure we don't go below zero due to numeric noise + decayed = max(0.0, decayed) + score_average = decayed + # Add decayed value to history so sparkline shows decline + if score_history: + score_history = (score_history[-23:] + [decayed]) + else: + score_history = [decayed] + except Exception: + # If decay calculation fails, ignore and keep existing + pass + except Exception: + # Non-fatal: keep previous values + pass + return SecurityStatus( mode=mode, score_average=score_average, @@ -284,8 +433,51 @@ def _get_security_status(self) -> SecurityStatus: recent_alerts=recent_alerts, suricata_active=suricata_active, opencanary_active=opencanary_active, + score_history=score_history, + last_decision=last_decision, ) + def _read_last_decision_if_any(self) -> Optional[dict]: + """Attempt to read the last JSON line from a decisions.log file. + + Probes a set of candidate paths and returns the decoded JSON of the + last non-empty line if available. + """ + # Prefer the system-installed decision log before any relative + # file that may exist in the current working directory. + candidates = [ + Path("/var/log/azazel/decisions.log"), + Path("/etc/azazel/decisions.log"), + Path("/var/lib/azazel/decisions.log"), + Path("decisions.log"), + ] + for p in candidates: + try: + if not p.exists(): + continue + with p.open("rb") as fh: + fh.seek(0, os.SEEK_END) + size = fh.tell() + if size == 0: + continue + block = 4096 + data = b"" + while size > 0 and b"\n" not in data: + delta = min(block, size) + size -= delta + fh.seek(size) + data = fh.read(delta) + data + last = data.splitlines()[-1] if data else b"" + if not last: + continue + try: + return json.loads(last.decode("utf-8", errors="ignore")) + except Exception: + continue + except Exception: + continue + return None + def _count_alerts(self, recent_window_seconds: int = 300) -> tuple[int, int]: """Count total and recent alerts from events log. diff --git a/azazel_pi/core/state_machine.py b/azazel_pi/core/state_machine.py index 89e19c9..e4fc518 100644 --- a/azazel_pi/core/state_machine.py +++ b/azazel_pi/core/state_machine.py @@ -3,6 +3,7 @@ import time from collections import deque +import math from dataclasses import dataclass, field from pathlib import Path from typing import Any, Callable, Deque, Dict, List, Optional @@ -47,6 +48,8 @@ class StateMachine: transitions: List[Transition] = field(default_factory=list) config_path: str | Path | None = None window_size: int = 5 + # EWMA time constant in seconds (used for decay / smoothing) + ewma_tau: float = 60.0 clock: Callable[[], float] = field(default=time.monotonic, repr=False) current_state: State = field(init=False) @@ -57,6 +60,9 @@ def __post_init__(self) -> None: self.add_transition(transition) self._config_cache: Dict[str, Any] | None = None self._score_window: Deque[int] = deque(maxlen=max(self.window_size, 1)) + # Exponential moving average state + self._ewma: float = 0.0 + self._last_ewma_ts: float = float(self.clock()) self._unlock_until: Dict[str, float] = {} self._user_mode_until: float = 0.0 # Timer for user intervention modes @@ -214,9 +220,30 @@ def get_actions_preset(self) -> Dict[str, Any]: # ------------------------------------------------------------------ def evaluate_window(self, severity: int) -> Dict[str, Any]: """Append a severity score and compute moving average decisions.""" - + # Keep raw recent scores for display/backwards compatibility self._score_window.append(max(int(severity), 0)) - average = sum(self._score_window) / len(self._score_window) + + # Update EWMA using elapsed time to allow natural decay when events are sparse + now = self.clock() + try: + dt = max(0.0, now - self._last_ewma_ts) + except Exception: + dt = 0.0 + tau = float(self.ewma_tau) if getattr(self, "ewma_tau", None) else 60.0 + if tau <= 0 or dt <= 0: + alpha = 1.0 + else: + alpha = 1.0 - math.exp(-dt / tau) + + # Initialize EWMA on first sample + if not hasattr(self, "_ewma") or self._ewma is None or len(self._score_window) == 1: + self._ewma = float(max(int(severity), 0)) + else: + self._ewma = alpha * float(max(int(severity), 0)) + (1.0 - alpha) * float(self._ewma) + self._last_ewma_ts = now + + # Expose the EWMA as the 'average' used for decisions + average = float(self._ewma) thresholds = self.get_thresholds() # 閾値判定: t0_normal=20の場合、score<20がnormal, 20<=score<50がportal @@ -231,6 +258,21 @@ def evaluate_window(self, severity: int) -> Dict[str, Any]: return {"average": average, "desired_mode": desired_mode} + def get_current_score(self) -> Dict[str, Any]: + """Return current score metrics (EWMA + window avg/history) for display. + + Returns: + Dict with keys: 'ewma', 'window_avg', 'history' (list newest-last) + """ + window_avg = 0.0 + if len(self._score_window) > 0: + window_avg = sum(self._score_window) / len(self._score_window) + return { + "ewma": float(getattr(self, "_ewma", 0.0)), + "window_avg": float(window_avg), + "history": list(self._score_window), + } + def apply_score(self, severity: int) -> Dict[str, Any]: """Evaluate the score window and transition to the appropriate mode.""" diff --git a/azazel_pi/utils/network_utils.py b/azazel_pi/utils/network_utils.py index fadd61a..2c443e2 100644 --- a/azazel_pi/utils/network_utils.py +++ b/azazel_pi/utils/network_utils.py @@ -127,58 +127,75 @@ def get_wlan_link_info(interface: str = "wlan1") -> Dict[str, Any]: try: # インターフェース存在確認 - result = subprocess.run( - ["ip", "link", "show", interface], - capture_output=True, text=True, timeout=5 - ) + result = subprocess.run(["ip", "link", "show", interface], capture_output=True, text=True, timeout=5) if result.returncode != 0: info["status"] = "not_found" + # ensure compatibility keys exist + info.setdefault("ip4", None) + info.setdefault("signal_dbm", None) return info - - # IPアドレス取得 - result = subprocess.run( - ["ip", "addr", "show", interface], - capture_output=True, text=True, timeout=5 - ) + + # IPv4 アドレス取得(第一の inet 行を使用) + result = subprocess.run(["ip", "-4", "addr", "show", interface], capture_output=True, text=True, timeout=5) if result.returncode == 0: - for line in result.stdout.split('\n'): - if "inet " in line and "scope global" in line: - info["ip_address"] = line.split()[1].split('/')[0] - break - + for line in result.stdout.splitlines(): + if "inet " in line: + parts = line.strip().split() + if len(parts) >= 2: + info["ip_address"] = parts[1].split("/")[0] + break + # 接続情報取得(iw経由) - result = subprocess.run( - ["iw", "dev", interface, "link"], - capture_output=True, text=True, timeout=5 - ) + result = subprocess.run(["iw", "dev", interface, "link"], capture_output=True, text=True, timeout=5) if result.returncode == 0: - if "Connected to" in result.stdout: + out = result.stdout or "" + if "Connected to" in out: info["connected"] = True - for line in result.stdout.split('\n'): - if "SSID:" in line: - info["ssid"] = line.split("SSID: ")[1].strip() - elif "freq:" in line: + for line in out.splitlines(): + line = line.strip() + if line.startswith("SSID:"): + info["ssid"] = line.split("SSID:", 1)[1].strip() + elif line.startswith("freq:"): try: - info["frequency"] = int(line.split("freq: ")[1].split()[0]) + info["frequency"] = int(line.split("freq:", 1)[1].strip().split()[0]) except (ValueError, IndexError): pass elif "signal:" in line: + # typical: 'signal: -45.00 dBm' try: - info["signal"] = float(line.split("signal: ")[1].split()[0]) + info["signal"] = float(line.split("signal:", 1)[1].strip().split()[0]) + except (ValueError, IndexError): + pass + elif "signal_dbm" in line: + try: + # fallback parsing for alternate formats + info["signal"] = float(line.split("signal_dbm", 1)[1].strip().split()[0]) except (ValueError, IndexError): pass else: info["connected"] = False - + info["status"] = "connected" if info["connected"] else "disconnected" - + except Exception as e: logger.error(f"WLAN link info check failed for {interface}: {e}") info["status"] = "error" - - return info + # Backwards-compatible aliases expected by CLI/menu code + if info.get("ip_address"): + info["ip4"] = info.get("ip_address") + else: + info.setdefault("ip4", None) + + if info.get("signal") is not None: + try: + info["signal_dbm"] = int(round(info.get("signal"))) + except Exception: + info["signal_dbm"] = None + else: + info.setdefault("signal_dbm", None) + return info def get_active_profile() -> Optional[str]: """ 現在アクティブなネットワークプロファイル取得 diff --git a/azctl/cli.py b/azctl/cli.py index c0a12bd..08a32b8 100644 --- a/azctl/cli.py +++ b/azctl/cli.py @@ -20,6 +20,7 @@ get_wlan_ap_status, get_wlan_link_info, get_active_profile, get_network_interfaces_stats, format_bytes ) +from collections import deque from azctl.daemon import AzazelDaemon import yaml @@ -421,21 +422,54 @@ def _clear_terminal() -> None: ] decision_paths = [p for p in decisions_paths if p is not None] - collector = StatusCollector() + # Try to build a local StateMachine (and apply scoring tuning from + # /etc/azazel/azazel.yaml) so the TUI can display EWMA-based score when + # possible. Fall back to no state_machine when anything fails. + try: + state_machine = None + system_cfg = Path(os.getenv('AZAZEL_CONFIG_PATH', '/etc/azazel/azazel.yaml')) + if system_cfg.exists(): + try: + from azctl.cli import build_machine + + state_machine = build_machine() + try: + cfg = AzazelConfig.from_file(str(system_cfg)) + scoring = cfg.get('scoring', {}) or {} + if 'ewma_tau' in scoring: + try: + state_machine.ewma_tau = float(scoring.get('ewma_tau')) + except Exception: + pass + if 'window_size' in scoring: + try: + state_machine.window_size = int(scoring.get('window_size')) + state_machine._score_window = deque(maxlen=max(state_machine.window_size, 1)) + except Exception: + pass + except Exception: + pass + except Exception: + # If build_machine import or construction failed, continue without a state machine + pass + else: + state_machine = None + collector = StatusCollector(state_machine=state_machine) + except Exception: + collector = StatusCollector() def render(): - last = _read_last_decision(decision_paths) - defensive_mode = last.get("mode") if isinstance(last, dict) else None + status = collector.collect() + defensive_mode = getattr(status.security, "mode", None) mode_label, color = _mode_style(defensive_mode) - status = collector.collect() wlan0 = get_wlan_ap_status(lan_if) wlan1 = get_wlan_link_info(wan_if) # Header now = datetime.now().strftime("%Y-%m-%d %H:%M:%S") header = Text.assemble( - (" Azazel Pi ", "bold white on blue"), + (" AZ-01X Azazel Pi ", "bold white on blue"), (" "), (f"{now}", "dim"), ) @@ -444,8 +478,50 @@ def render(): t_left = Table.grid(padding=(0, 1)) t_left.add_row("Mode", Text(mode_label, style=f"bold {color}")) t_left.add_row("Score(avg)", f"{status.security.score_average:.1f}") + # Sparkline from recent scores (if available) + def make_sparkline(vals: list[float]) -> str: + if not vals: + return "" + blocks = ["▁","▂","▃","▄","▅","▆","▇","█"] + mn = min(vals) + mx = max(vals) + # If all values equal, map their absolute magnitude to 0-100 + # scale so that a constant high score (e.g. 100.0) displays as + # the highest block rather than always the lowest one. + if mx - mn < 1e-6: + try: + v = float(vals[-1]) + idx = int(max(0, min(1.0, v / 100.0)) * (len(blocks) - 1)) + return blocks[idx] * len(vals) + except Exception: + return blocks[0] * len(vals) + out = [] + for v in vals: + idx = int((v - mn) / (mx - mn) * (len(blocks) - 1)) + out.append(blocks[idx]) + return "".join(out) + + spark = make_sparkline(status.security.score_history[-12:]) if getattr(status.security, "score_history", None) else "" + if spark: + t_left.add_row("Trend", spark) t_left.add_row("Alerts(recent)", f"{status.security.total_alerts} ({status.security.recent_alerts})") - t_left.add_row("Services", f"Suricata: {'ON' if status.security.suricata_active else 'off'}, Canary: {'ON' if status.security.opencanary_active else 'off'}") + # Last decision summary + if getattr(status.security, "last_decision", None): + ld = status.security.last_decision + # Prefer short fields if present + reason = ld.get("reason") or ld.get("source") or ld.get("signature") or ld.get("note") or None + score = ld.get("score") or ld.get("severity") or None + summary = f"{reason}" if reason else "(decision)" + if score is not None: + summary += f" (+{score})" + t_left.add_row("Last", summary) + + # Services row (ON/OFF) + t_left.add_row( + "Services", + f"Suricata: {'ON' if status.security.suricata_active else 'OFF'}, OpenCanary: {'ON' if status.security.opencanary_active else 'OFF'}", + ) + left_panel = Panel(t_left, title="Security", border_style=color) # Network panel @@ -471,7 +547,10 @@ def render(): return 0 _clear_terminal() - with Live(render(), refresh_per_second=max(1, int(1/interval)) if interval < 1 else int(1/interval) if interval >= 1 else 1, screen=False) as live: + # Compute refresh rate safely: use fractional refresh_per_second (1/interval) + # Live expects a positive number; avoid int-casting which can become 0 for interval>=1. + refresh_per_second = 1.0 / interval if interval > 0 else 1.0 + with Live(render(), refresh_per_second=refresh_per_second, screen=False) as live: try: while True: live.update(render()) @@ -602,6 +681,25 @@ def safe_path(path_str: Optional[str], fallback: Optional[str] = None) -> Option parser.error("--config is required (either use 'events --config' or legacy '--config')") machine = build_machine() + # Allow config to suggest faster scoring parameters for demo/testing + try: + if config_obj: + scoring_cfg = config_obj.get("scoring", {}) or {} + if "ewma_tau" in scoring_cfg: + try: + machine.ewma_tau = float(scoring_cfg.get("ewma_tau")) + except Exception: + pass + if "window_size" in scoring_cfg: + try: + machine.window_size = int(scoring_cfg.get("window_size")) + # resize internal deque to match new window size + machine._score_window = deque(maxlen=max(machine.window_size, 1)) + except Exception: + pass + except Exception: + # Non-fatal: if config parsing fails here, continue with defaults + pass daemon = AzazelDaemon(machine=machine, scorer=ScoreEvaluator()) daemon.process_events(load_events(config_path)) return 0 @@ -646,6 +744,28 @@ def cmd_serve(config: Optional[str], decisions: Optional[str], suricata_eve: str # Prepare machine and daemon machine = build_machine() + # If a config path was provided, try to apply any scoring tuning (ewma_tau/window_size) + try: + if config: + try: + config_obj = AzazelConfig.from_file(config) + scoring_cfg = config_obj.get('scoring', {}) or {} + if 'ewma_tau' in scoring_cfg: + try: + machine.ewma_tau = float(scoring_cfg.get('ewma_tau')) + except Exception: + pass + if 'window_size' in scoring_cfg: + try: + machine.window_size = int(scoring_cfg.get('window_size')) + machine._score_window = deque(maxlen=max(machine.window_size, 1)) + except Exception: + pass + except Exception: + # if config can't be read here, continue with defaults + pass + except Exception: + pass decisions_path = Path(decisions) if decisions else Path(notice._get_nested({}, "paths.decisions", "/var/log/azazel/decisions.log")) daemon = AzazelDaemon(machine=machine, scorer=ScoreEvaluator(), decisions_log=decisions_path) @@ -655,6 +775,23 @@ def cmd_serve(config: Optional[str], decisions: Optional[str], suricata_eve: str except Exception: pass + # Start background decay writer if available on the daemon. Use the + # AZAZEL_DISPLAY_DECAY_TAU environment variable (seconds) to control + # the timescale. Also allow configuring the check interval. + try: + decay_tau = float(os.getenv("AZAZEL_DISPLAY_DECAY_TAU", "120")) + except Exception: + decay_tau = 120.0 + try: + check_interval = float(os.getenv("AZAZEL_DISPLAY_DECAY_CHECK_INTERVAL", "5")) + except Exception: + check_interval = 5.0 + try: + # start_decay_writer is best-effort (may not exist on older daemons) + daemon.start_decay_writer(decay_tau=decay_tau, check_interval=check_interval) + except Exception: + pass + # Write an initial entry describing current (default) mode so status shows something try: daemon.process_event(Event(name="startup", severity=0)) @@ -704,6 +841,11 @@ def sigint_handler(sig, frame): t_reader.join(timeout=2) t_consumer.join(timeout=2) + # Stop decay writer thread cleanly if available + try: + daemon.stop_decay_writer() + except Exception: + pass return 0 diff --git a/azctl/daemon.py b/azctl/daemon.py index 7fe66ae..5a8295b 100644 --- a/azctl/daemon.py +++ b/azctl/daemon.py @@ -3,6 +3,9 @@ import json from dataclasses import dataclass, field +import threading +import time +import math from pathlib import Path from typing import Iterable, List @@ -67,3 +70,82 @@ def _append_decisions(self, entries: List[dict]) -> None: for entry in entries: handle.write(json.dumps(entry, sort_keys=True)) handle.write("\n") + + # Record last written entry and timestamp for decay logic + try: + self._last_entry = dict(entry) + self._last_written_ts = time.time() + except Exception: + pass + + def start_decay_writer(self, decay_tau: float = 120.0, check_interval: float = 5.0) -> None: + """Start a background thread that appends decayed display entries to decisions.log. + + This writes synthetic 'decay' entries when no new events are being + written for some time so that downstream display consumers see a + gradually decreasing score. + """ + # If already running, no-op + if getattr(self, '_decay_thread', None) and getattr(self._decay_thread, 'is_alive', lambda: False)(): + return + self._decay_stop = threading.Event() + + def _worker(): + while not (self._decay_stop and self._decay_stop.is_set()): + try: + now = time.time() + last_ts = float(getattr(self, '_last_written_ts', 0.0) or 0.0) + if last_ts <= 0.0 or getattr(self, '_last_entry', None) is None: + # Nothing written yet; wait + time.sleep(check_interval) + continue + + age = max(0.0, now - last_ts) + # If no new writes for at least check_interval, compute decayed average + if age >= check_interval: + try: + base_avg = float(self._last_entry.get("average", 0.0)) + except Exception: + base_avg = 0.0 + # Exponential decay + try: + decayed = base_avg * math.exp(-age / float(decay_tau or 1.0)) + except Exception: + decayed = base_avg + # Only append if decayed value is meaningfully different + if abs(decayed - base_avg) > 1e-3: + new_entry = dict(self._last_entry) + new_entry["event"] = "decay" + new_entry["score"] = decayed + new_entry["average"] = decayed + new_entry["classification"] = "decay" + # Timestamp in ISO UTC + try: + from datetime import datetime, timezone + + new_entry["timestamp"] = datetime.now(timezone.utc).isoformat() + except Exception: + pass + # Persist the synthetic decay entry + try: + self._append_decisions([new_entry]) + except Exception: + pass + time.sleep(check_interval) + except Exception: + # Avoid thread death on unexpected errors + time.sleep(check_interval) + + t = threading.Thread(target=_worker, daemon=True) + self._decay_thread = t + t.start() + + def stop_decay_writer(self) -> None: + """Stop the background decay writer thread if running.""" + try: + if getattr(self, '_decay_stop', None): + self._decay_stop.set() + if getattr(self, '_decay_thread', None): + self._decay_thread.join(timeout=2.0) + except Exception: + pass diff --git a/configs/monitoring/notify.yaml b/configs/monitoring/notify.yaml index c235fdb..89c5e2a 100644 --- a/configs/monitoring/notify.yaml +++ b/configs/monitoring/notify.yaml @@ -1,68 +1,15 @@ -# Azazel notification and monitoring settings -# Updated for production use with minimal Mattermost configuration - -# 日本時間(UTC+9) -tz: "+09:00" - -# Mattermost 通知設定 -# 【運用方法】 -# 1. Mattermostで通知専用botユーザーを作成 -# 2. そのbotユーザーでWebhookを生成 -# 3. 通知先チャンネルは、Webhook作成時に指定 -# 4. 個別通知が必要なユーザーをnotify_usersに列挙 +# Demo notification configuration for Azazel-Pi +# WARNING: Replace `webhook_url` with your test webhook before enabling in a real environment. mattermost: - enabled: true # 通知有効フラグ - webhook_url: "http://172.16.0.254:8065/hooks/w5togts4gprcjdgstjkmhotszh" - notify_users: # 通知対象ユーザー(@mention) - - "admin" # 管理者ユーザー - - "security.team" # セキュリティチーム - # 必要に応じてユーザーを追加 - # - "oncall.engineer" # 待機エンジニア - # 注意: channel, username, icon_emojiはbotユーザーの設定を使用するため不要 - -# ログファイルパス -paths: - events: "/opt/azazel/logs/events.json" - opencanary: "/opt/azazel/logs/opencanary.log" - suricata_eve: "/var/log/suricata/eve.json" - decisions: "/var/log/azazel/decisions.log" - -# イベント抑止設定 + enabled: true + webhook_url: "https://example.test/hooks/YOUR_TEST_WEBHOOK" + channel: "azazel-demo" + username: "Azazel-Demo-Bot" + icon_emoji: ":shield:" +# suppress / cooldown settings suppress: - # 選択肢: "signature", "signature_ip", "signature_ip_user", "signature_ip_user_session" key_mode: "signature_ip_user" - cooldown_seconds: 60 # 同一イベントの抑止時間 - summary_interval_mins: 5 # サマリー通知間隔 - -# OpenCanary設定 -opencanary: - ip: "172.16.10.10" - ports: - - 22 # SSH - - 80 # HTTP - - 5432 # PostgreSQL - -# ネットワーク制御設定 -network: - # 遅延制御対象インタフェース(例: wlan1)。ランタイムでの検出を優先する場合は - # 環境変数 AZAZEL_WAN_IF を設定してください(例: export AZAZEL_WAN_IF=wlan1)。 - interface: "wlan1" # 遅延制御対象インタフェース - inactivity_minutes: 2 # 無活動判定時間 - delay: - base_ms: 500 # 基本遅延時間 - jitter_ms: 100 # 揺らぎ - -# AI脅威評価設定 -ai: - enabled: true # AI評価機能の有効/無効 - ollama_url: "http://127.0.0.1:11434/api/generate" - model: "phi3:mini" # 使用するLLMモデル - timeout: 30 # API呼び出しタイムアウト(秒) - max_payload_chars: 400 # ペイロード最大文字数 - fallback_on_error: true # エラー時のフォールバック有効 - model_alternatives: # 代替モデル候補 - - "qwen2.5:3b" - - "tinyllama" - policy_scripts: # AI判定によるポリシー適用スクリプト - delay: "/home/azazel/Azazel-Pi/scripts/ai_policy_delay.sh" - block: "/home/azazel/Azazel-Pi/scripts/ai_policy_block.sh" + cooldown_seconds: 10 + summary_interval_mins: 1 +paths: + decisions: "./decisions.log" diff --git a/configs/monitoring/notify.yaml.bak b/configs/monitoring/notify.yaml.bak new file mode 100644 index 0000000..c235fdb --- /dev/null +++ b/configs/monitoring/notify.yaml.bak @@ -0,0 +1,68 @@ +# Azazel notification and monitoring settings +# Updated for production use with minimal Mattermost configuration + +# 日本時間(UTC+9) +tz: "+09:00" + +# Mattermost 通知設定 +# 【運用方法】 +# 1. Mattermostで通知専用botユーザーを作成 +# 2. そのbotユーザーでWebhookを生成 +# 3. 通知先チャンネルは、Webhook作成時に指定 +# 4. 個別通知が必要なユーザーをnotify_usersに列挙 +mattermost: + enabled: true # 通知有効フラグ + webhook_url: "http://172.16.0.254:8065/hooks/w5togts4gprcjdgstjkmhotszh" + notify_users: # 通知対象ユーザー(@mention) + - "admin" # 管理者ユーザー + - "security.team" # セキュリティチーム + # 必要に応じてユーザーを追加 + # - "oncall.engineer" # 待機エンジニア + # 注意: channel, username, icon_emojiはbotユーザーの設定を使用するため不要 + +# ログファイルパス +paths: + events: "/opt/azazel/logs/events.json" + opencanary: "/opt/azazel/logs/opencanary.log" + suricata_eve: "/var/log/suricata/eve.json" + decisions: "/var/log/azazel/decisions.log" + +# イベント抑止設定 +suppress: + # 選択肢: "signature", "signature_ip", "signature_ip_user", "signature_ip_user_session" + key_mode: "signature_ip_user" + cooldown_seconds: 60 # 同一イベントの抑止時間 + summary_interval_mins: 5 # サマリー通知間隔 + +# OpenCanary設定 +opencanary: + ip: "172.16.10.10" + ports: + - 22 # SSH + - 80 # HTTP + - 5432 # PostgreSQL + +# ネットワーク制御設定 +network: + # 遅延制御対象インタフェース(例: wlan1)。ランタイムでの検出を優先する場合は + # 環境変数 AZAZEL_WAN_IF を設定してください(例: export AZAZEL_WAN_IF=wlan1)。 + interface: "wlan1" # 遅延制御対象インタフェース + inactivity_minutes: 2 # 無活動判定時間 + delay: + base_ms: 500 # 基本遅延時間 + jitter_ms: 100 # 揺らぎ + +# AI脅威評価設定 +ai: + enabled: true # AI評価機能の有効/無効 + ollama_url: "http://127.0.0.1:11434/api/generate" + model: "phi3:mini" # 使用するLLMモデル + timeout: 30 # API呼び出しタイムアウト(秒) + max_payload_chars: 400 # ペイロード最大文字数 + fallback_on_error: true # エラー時のフォールバック有効 + model_alternatives: # 代替モデル候補 + - "qwen2.5:3b" + - "tinyllama" + policy_scripts: # AI判定によるポリシー適用スクリプト + delay: "/home/azazel/Azazel-Pi/scripts/ai_policy_delay.sh" + block: "/home/azazel/Azazel-Pi/scripts/ai_policy_block.sh" diff --git a/configs/monitoring/notify_demo.yaml b/configs/monitoring/notify_demo.yaml new file mode 100644 index 0000000..89c5e2a --- /dev/null +++ b/configs/monitoring/notify_demo.yaml @@ -0,0 +1,15 @@ +# Demo notification configuration for Azazel-Pi +# WARNING: Replace `webhook_url` with your test webhook before enabling in a real environment. +mattermost: + enabled: true + webhook_url: "https://example.test/hooks/YOUR_TEST_WEBHOOK" + channel: "azazel-demo" + username: "Azazel-Demo-Bot" + icon_emoji: ":shield:" +# suppress / cooldown settings +suppress: + key_mode: "signature_ip_user" + cooldown_seconds: 10 + summary_interval_mins: 1 +paths: + decisions: "./decisions.log" diff --git a/docs/DEMO.md b/docs/DEMO.md new file mode 100644 index 0000000..283dc65 --- /dev/null +++ b/docs/DEMO.md @@ -0,0 +1,126 @@ +## Azazel-Pi E2E デモ: Suricata EVE リプレイで検出→評価→遷移→通知を再現する + +このドキュメントは E2E デモ用に特化しています。目的は以下のとおりです。 + +- Suricata EVE イベントをリプレイして、監視パイプラインが検出→AI 評価→モード遷移→enforcer/通知 を通して動く様子を示す。 +- デモで使う通知は `configs/monitoring/notify_demo.yaml`(テスト webhook)を使い、本番構成に影響を与えない。 +- 最小の操作で自動的に `scan → brute → exploit → ddos` のシーケンスが発生し、最終的に lockdown 相当のモードに到達できることを確認する。 + +前提と注意 +- この手順はローカルのデモ環境を想定します。実際の enforcer (nft/tc) は root 権限で実行されるとネットワークに影響を与えます。デモでは root を使わないか、ネットワークに影響しないテスト環境で実行してください。 +-- いくつかのコマンドは azctl の CLI 引数に依存します。実際のサブコマンドは次のオプションをサポートします(`python3 -m azctl.cli --help` を実行して詳細を確認してください)。 + + - `serve` の主なオプション: + - `--config CONFIG` : 初期化用の設定 YAML + - `--decisions-log PATH` : decisions.log 出力先(任意) + - `--suricata-eve PATH` : Suricata eve.json のパス(デフォルトは設定から読み取る) + - `--lan-if IF` / `--wan-if IF`: インターフェース指定 + + - `menu` の主なオプション: + - `--decisions-log PATH` : decisions.log を指定して表示させる(任意) + - `--lan-if IF` / `--wan-if IF` : インターフェース指定 + + 上の例では `--suricata-eve` と `--decisions-log` を使っていますが、環境に合わせて `--help` を参照のうえ適宜置き換えてください。 + +必要なファイル(このリポジトリで追加済み) +- `scripts/eve_replay.py` — EVE JSON を指定ファイルに周期的に追記してリプレイするスクリプト +- `configs/monitoring/notify_demo.yaml` — デモ用の Mattermost/webhook 設定(安全なテスト先を設定してください) +- `scripts/install_demo_notify.sh` — 既存の `configs/monitoring/notify.yaml` をバックアップしてデモ用設定をインストールする補助スクリプト +- `scripts/restore_notify.sh` — 既存の notify 設定を復元するスクリプト(デモ後に実行) + +準備手順 +1) 必要 Python ライブラリをインストール + +```bash +pip3 install --user rich requests +``` + +2) デモ用の notify 設定をインストール(安全のためバックアップされます) + +```bash +bash scripts/install_demo_notify.sh +# 成功すると configs/monitoring/notify.yaml が demo 設定に置き換わります +``` + +3) EVE リプレイ先ファイルの準備(デフォルトの場所) + +```bash +mkdir -p runtime +: > runtime/demo_eve.json # 空ファイルを作る +``` + +E2E デモ手順(実行順) +以下は一例の最小オペレーションです。別のターミナルでそれぞれ実行してください。 + +1. 監視デーモンを起動(Suricata EVE を監視させる) + + 想定コマンド(CLI がこれらのオプションを受け取る場合): + +```bash +# 例: decisions.log をカレントに、Suricata EVE を runtime/demo_eve.json に設定 +python3 -m azctl.cli serve --suricata-eve runtime/demo_eve.json --decisions-log ./decisions.log +``` + + 注: `azctl.cli serve` のオプションが異なる場合は、`main_suricata.py` を直接起動して `runtime/demo_eve.json` を監視するようにしてください。目的は監視プロセスが `runtime/demo_eve.json` の追記を読み、評価→enforce→notify を実行することです。 + +2. TUI(状態表示)を起動 + +```bash +python3 -m azctl.cli menu --decisions-log ./decisions.log +``` + + TUI は decisions.log / 状態を表示します。これで観客に現在モードや決定の遷移を見せられます。 + +3. EVE リプレイを開始(攻撃シーケンスの注入) + +```bash +python3 scripts/eve_replay.py --file runtime/demo_eve.json --interval 5 --loop +``` + + - `--interval 5` はイベント間隔(秒)です。`--loop` を付けるとシーケンスを繰り返します。 + - このスクリプトは段階的に検出シグネチャ(scan → brute → exploit → ddos)に見立てた EVE JSON を追加します。 + +4. 監視の動作観察 + +- TUI にスコア/モード遷移が表示されることを確認します。 +- decisions.log を別ターミナルで tail して、該当の決定(JSON ライン)が追記されることを確認します: + +```bash +tail -f ./decisions.log +``` + +- 通知: `configs/monitoring/notify_demo.yaml` に設定したテスト webhook(例: RequestBin)にポストが届くことを確認します。 + +期待される流れ(デフォルトデモ) +- EVE シーケンスにより最初は軽度のスキャン検出が入り、AI 評価が一定閾値を越えると警告モードに移行。 +- 攻撃シーケンスが進むとスコアが上がり、最終的に lockdown 相当のモードが適用され、enforcer が適切なアクションを実行(実環境では nft/tc を適用)します。 + + + +安全とロールバック +- デモ後は必ず notify 設定を復元してください: + +```bash +bash scripts/restore_notify.sh +``` + +- decisions.log、runtime/demo_eve.json、runtime/demo_mode.json などのデモ生成ファイルは削除して構いません。 +- 実際に enforcer を動かす(nft/tc を適用する)場合は root 権限が必要です。本番ネットワークに影響を与えないテスト環境で行ってください。 + +トラブルシュート +- 何も起きない場合: + - 監視プロセスが runtime/demo_eve.json を見ていることを確認。パスやオプションが異なる可能性あり。 + - `decisions.log` が別の場所に出力されていないか確認(`azctl.cli` のオプションや `azctl/daemon.py` の設定を確認)。 + - Ollama を使う評価を見せたい場合はローカル Ollama サービスが稼働していること(デフォルト: http://127.0.0.1:11434)を確認。 + +期待検証チェックリスト(すぐ使える) +- [ ] `scripts/install_demo_notify.sh` を実行してデモ通知を有効にした +- [ ] `python3 -m azctl.cli serve --suricata-eve runtime/demo_eve.json --decisions-log ./decisions.log` を起動した +- [ ] `python3 -m azctl.cli menu --decisions-log ./decisions.log` を起動した +- [ ] `python3 scripts/eve_replay.py --file runtime/demo_eve.json --interval 5 --loop` を起動した +- [ ] TUI と decisions.log に検出・スコア・モード遷移が表示された +- [ ] notify_demo の webhook に通知が届いた + +補足(実行環境に応じた微調整) +- `azctl.cli` の `serve`/`menu` の引数名は実装により異なることがあります。もし上記コマンドが通らない場合は `python3 -m azctl.cli --help` でオプションを確認し、`--suricata-eve` や `--decisions-log` に相当するオプションを指定してください。 + diff --git a/runtime/demo_eve.json b/runtime/demo_eve.json new file mode 100644 index 0000000..8c52fd9 --- /dev/null +++ b/runtime/demo_eve.json @@ -0,0 +1,25 @@ +{"event_type":"alert","timestamp":"2025-11-10T20:40:11.077768","src_ip":"10.0.0.5","dest_ip":"172.16.0.10","proto":"ICMP","dest_port":null,"alert":{"signature":"ET SCAN Potential scan detected (NMAP)","severity":30}} +{"event_type":"alert","timestamp":"2025-11-10T20:40:12.078096","src_ip":"198.51.100.23","dest_ip":"172.16.0.10","proto":"TCP","dest_port":22,"alert":{"signature":"ET BRUTEFORCE SSH Brute force attempt","severity":40}} +{"event_type":"alert","timestamp":"2025-11-10T20:40:13.078446","src_ip":"198.51.100.23","dest_ip":"172.16.0.10","proto":"TCP","dest_port":80,"alert":{"signature":"ET EXPLOIT Possible buffer overflow","severity":80}} +{"event_type":"alert","timestamp":"2025-11-10T20:40:14.078769","src_ip":"203.0.113.9","dest_ip":"172.16.0.10","proto":"UDP","dest_port":0,"alert":{"signature":"ET DOS Possible DDoS amplification","severity":60}} +{"event_type":"alert","timestamp":"2025-11-10T20:40:15.079103","src_ip":"10.0.0.5","dest_ip":"172.16.0.10","proto":"ICMP","dest_port":null,"alert":{"signature":"ET SCAN Potential scan detected (NMAP)","severity":30}} +{"event_type":"alert","timestamp":"2025-11-10T20:40:16.079430","src_ip":"198.51.100.23","dest_ip":"172.16.0.10","proto":"TCP","dest_port":22,"alert":{"signature":"ET BRUTEFORCE SSH Brute force attempt","severity":40}} +{"event_type":"alert","timestamp":"2025-11-10T20:40:17.079751","src_ip":"198.51.100.23","dest_ip":"172.16.0.10","proto":"TCP","dest_port":80,"alert":{"signature":"ET EXPLOIT Possible buffer overflow","severity":80}} +{"event_type":"alert","timestamp":"2025-11-10T20:40:18.080047","src_ip":"203.0.113.9","dest_ip":"172.16.0.10","proto":"UDP","dest_port":0,"alert":{"signature":"ET DOS Possible DDoS amplification","severity":60}} +{"event_type":"alert","timestamp":"2025-11-10T20:40:19.080444","src_ip":"10.0.0.5","dest_ip":"172.16.0.10","proto":"ICMP","dest_port":null,"alert":{"signature":"ET SCAN Potential scan detected (NMAP)","severity":30}} +{"event_type":"alert","timestamp":"2025-11-10T20:40:20.080806","src_ip":"198.51.100.23","dest_ip":"172.16.0.10","proto":"TCP","dest_port":22,"alert":{"signature":"ET BRUTEFORCE SSH Brute force attempt","severity":40}} +{"event_type":"alert","timestamp":"2025-11-10T20:40:21.081181","src_ip":"198.51.100.23","dest_ip":"172.16.0.10","proto":"TCP","dest_port":80,"alert":{"signature":"ET EXPLOIT Possible buffer overflow","severity":80}} +{"event_type":"alert","timestamp":"2025-11-10T20:40:22.081730","src_ip":"203.0.113.9","dest_ip":"172.16.0.10","proto":"UDP","dest_port":0,"alert":{"signature":"ET DOS Possible DDoS amplification","severity":60}} +{"event_type":"alert","timestamp":"2025-11-10T20:40:23.274659","src_ip":"10.0.0.5","dest_ip":"172.16.0.10","proto":"ICMP","dest_port":null,"alert":{"signature":"ET SCAN Potential scan detected (NMAP)","severity":30}} +{"event_type":"alert","timestamp":"2025-11-10T20:40:24.275063","src_ip":"198.51.100.23","dest_ip":"172.16.0.10","proto":"TCP","dest_port":22,"alert":{"signature":"ET BRUTEFORCE SSH Brute force attempt","severity":40}} +{"event_type":"alert","timestamp":"2025-11-10T20:40:25.275415","src_ip":"198.51.100.23","dest_ip":"172.16.0.10","proto":"TCP","dest_port":80,"alert":{"signature":"ET EXPLOIT Possible buffer overflow","severity":80}} +{"event_type":"alert","timestamp":"2025-11-10T20:40:26.275783","src_ip":"203.0.113.9","dest_ip":"172.16.0.10","proto":"UDP","dest_port":0,"alert":{"signature":"ET DOS Possible DDoS amplification","severity":60}} +{"event_type":"alert","timestamp":"2025-11-10T20:40:27.276093","src_ip":"10.0.0.5","dest_ip":"172.16.0.10","proto":"ICMP","dest_port":null,"alert":{"signature":"ET SCAN Potential scan detected (NMAP)","severity":30}} +{"event_type":"alert","timestamp":"2025-11-10T20:40:28.276461","src_ip":"198.51.100.23","dest_ip":"172.16.0.10","proto":"TCP","dest_port":22,"alert":{"signature":"ET BRUTEFORCE SSH Brute force attempt","severity":40}} +{"event_type":"alert","timestamp":"2025-11-10T20:40:29.276877","src_ip":"198.51.100.23","dest_ip":"172.16.0.10","proto":"TCP","dest_port":80,"alert":{"signature":"ET EXPLOIT Possible buffer overflow","severity":80}} +{"event_type":"alert","timestamp":"2025-11-10T20:40:30.277244","src_ip":"203.0.113.9","dest_ip":"172.16.0.10","proto":"UDP","dest_port":0,"alert":{"signature":"ET DOS Possible DDoS amplification","severity":60}} +{"event_type":"alert","timestamp":"2025-11-10T20:40:31.277590","src_ip":"10.0.0.5","dest_ip":"172.16.0.10","proto":"ICMP","dest_port":null,"alert":{"signature":"ET SCAN Potential scan detected (NMAP)","severity":30}} +{"event_type": "alert", "timestamp": "2025-11-10T20:46:44.281191", "src_ip": "198.51.100.23", "dest_ip": "172.16.0.10", "proto": "TCP", "dest_port": 22, "alert": {"signature": "ET BRUTEFORCE SSH Brute force attempt", "severity": 3}} +{"event_type": "alert", "timestamp": "2025-11-10T20:46:49.281480", "src_ip": "198.51.100.23", "dest_ip": "172.16.0.10", "proto": "TCP", "dest_port": 80, "alert": {"signature": "ET EXPLOIT Possible buffer overflow", "severity": 1}} +{"event_type": "alert", "timestamp": "2025-11-10T20:46:54.281771", "src_ip": "203.0.113.9", "dest_ip": "172.16.0.10", "proto": "UDP", "dest_port": 0, "alert": {"signature": "ET DOS Possible DDoS amplification", "severity": 1}} +{"event_type": "alert", "timestamp": "2025-11-10T20:46:59.282399", "src_ip": "10.0.0.5", "dest_ip": "172.16.0.10", "proto": "ICMP", "dest_port": null, "alert": {"signature": "ET SCAN Potential scan detected (NMAP)", "severity": 4}} diff --git a/scripts/demo_injector.py b/scripts/demo_injector.py new file mode 100644 index 0000000..281eaa0 --- /dev/null +++ b/scripts/demo_injector.py @@ -0,0 +1,107 @@ +#!/usr/bin/env python3 +"""Demo event injector for Azazel-Pi + +Append crafted Suricata-like alert JSON lines to the configured eve.json +to drive scoring for demos. Run with sudo to write to /var/log/suricata/eve.json. + +Usage examples: + sudo python3 scripts/demo_injector.py --count 150 --interval 0.1 --severity 80 + sudo python3 scripts/demo_injector.py --count 50 --burst --severity 100 +""" +from __future__ import annotations + +import argparse +import json +import time +from datetime import datetime, timezone +from pathlib import Path +import random + + +TEMPLATE_ALERTS = [ + { + "proto": "TCP", + "dest_port": 22, + "signature": "ET BRUTEFORCE SSH Brute force attempt", + }, + { + "proto": "TCP", + "dest_port": 80, + "signature": "ET EXPLOIT Possible buffer overflow", + }, + { + "proto": "UDP", + "dest_port": 0, + "signature": "ET DOS Possible DDoS amplification", + }, + { + "proto": "ICMP", + "dest_port": None, + "signature": "ET SCAN Potential scan detected (NMAP)", + }, +] + + +def make_event(severity: int) -> str: + now = datetime.now(timezone.utc).isoformat() + t = random.choice(TEMPLATE_ALERTS) + ev = { + "event_type": "alert", + "timestamp": now, + "src_ip": f"198.51.100.{random.randint(2,250)}", + "dest_ip": "172.16.0.10", + "proto": t["proto"], + "dest_port": t["dest_port"], + "alert": {"signature": t["signature"], "severity": int(severity)}, + } + # Return single-line JSON + return json.dumps(ev, separators=(',', ':')) + + +def append_event(path: Path, line: str) -> None: + with path.open('a') as fh: + fh.write(line + '\n') + + +def main() -> int: + p = argparse.ArgumentParser(description="Inject demo Suricata EVE events to drive scoring") + p.add_argument('--path', type=Path, default=Path('/var/log/suricata/eve.json'), help='Path to eve.json') + p.add_argument('--count', type=int, default=100, help='Number of events to inject') + p.add_argument('--interval', type=float, default=0.2, help='Seconds between events (ignored with --burst)') + p.add_argument('--severity', type=int, default=80, help='Severity to set on injected alerts') + p.add_argument('--burst', action='store_true', help='Inject as fast as possible with no sleep') + p.add_argument('--prefix', type=str, default='', help='Optional prefix text to include in signature') + args = p.parse_args() + + target = args.path + if not target.parent.exists(): + print(f"Path {target} does not exist and parent {target.parent} missing") + return 2 + + print(f"Injecting {args.count} events to {target} (severity={args.severity}, interval={'burst' if args.burst else args.interval}s)") + + for i in range(args.count): + try: + line = make_event(args.severity) + if args.prefix: + # cheap way to add prefix into signature + obj = json.loads(line) + obj['alert']['signature'] = f"{args.prefix} {obj['alert']['signature']}" + line = json.dumps(obj, separators=(',', ':')) + append_event(target, line) + except PermissionError: + print(f"Permission denied writing to {target}. Run with sudo.") + return 3 + except Exception as e: + print(f"Write failed: {e}") + return 4 + + if not args.burst: + time.sleep(max(0.0, args.interval)) + + print("Done") + return 0 + + +if __name__ == '__main__': + raise SystemExit(main()) diff --git a/scripts/demo_start.sh b/scripts/demo_start.sh new file mode 100755 index 0000000..6c5e759 --- /dev/null +++ b/scripts/demo_start.sh @@ -0,0 +1,56 @@ +#!/usr/bin/env bash +set -euo pipefail +# Start a demo tmux session that runs: +# - azctl serve (monitor) +# - eve_replay (inject events) +# - azctl status --tui --watch (live view) + +REPO_ROOT="$(cd "$(dirname "$0")/.." && pwd)" +SESSION="azazel-demo" +EVE_FILE="$REPO_ROOT/runtime/demo_eve.json" +DECISIONS_LOG="$REPO_ROOT/decisions.log" +LOG_DIR="$REPO_ROOT/runtime/logs" + +mkdir -p "$LOG_DIR" + +CMD_SERVE=(python3 -m azctl.cli serve --suricata-eve "$EVE_FILE" --decisions-log "$DECISIONS_LOG") +CMD_EVE=(python3 "$REPO_ROOT/scripts/eve_replay.py" --file "$EVE_FILE" --interval 5 --loop) +CMD_TUI=(python3 -m azctl.cli status --tui --watch) + +which tmux >/dev/null 2>&1 && USE_TMUX=true || USE_TMUX=false + +if $USE_TMUX; then + # Create or reuse session + if tmux has-session -t "$SESSION" 2>/dev/null; then + echo "Session $SESSION already exists. Attaching..." + tmux attach -t "$SESSION" + exit 0 + fi + + echo "Creating tmux session: $SESSION" + tmux new-session -d -s "$SESSION" -c "$REPO_ROOT" + # Pane 0: serve + tmux send-keys -t "$SESSION:0.0" "${CMD_SERVE[*]} 2> $LOG_DIR/serve.log" C-m + # Split pane for eve_replay + tmux split-window -v -t "$SESSION:0.0" -c "$REPO_ROOT" + tmux send-keys -t "$SESSION:0.1" "${CMD_EVE[*]} 2> $LOG_DIR/eve_replay.log" C-m + # Split the lower pane horizontally for TUI + tmux split-window -h -t "$SESSION:0.1" -c "$REPO_ROOT" + tmux send-keys -t "$SESSION:0.2" "${CMD_TUI[*]} 2> $LOG_DIR/tui.log" C-m + tmux select-layout -t "$SESSION" tiled + + echo "Started demo in tmux session '$SESSION'. Attach with: tmux attach -t $SESSION" + echo "Logs: $LOG_DIR" +else + echo "tmux not found; falling back to background processes (nohup)." + nohup "${CMD_SERVE[@]}" > "$LOG_DIR/serve.log" 2>&1 & + echo $! > "$REPO_ROOT/runtime/serve.pid" + nohup "${CMD_EVE[@]}" > "$LOG_DIR/eve_replay.log" 2>&1 & + echo $! > "$REPO_ROOT/runtime/eve_replay.pid" + nohup "${CMD_TUI[@]}" > "$LOG_DIR/tui.log" 2>&1 & + echo $! > "$REPO_ROOT/runtime/tui.pid" + + echo "Started demo processes in background. Logs: $LOG_DIR" +fi + +exit 0 diff --git a/scripts/demo_stop.sh b/scripts/demo_stop.sh new file mode 100755 index 0000000..d1ec0fa --- /dev/null +++ b/scripts/demo_stop.sh @@ -0,0 +1,36 @@ +#!/usr/bin/env bash +set -euo pipefail + +REPO_ROOT="$(cd "$(dirname "$0")/.." && pwd)" +SESSION="azazel-demo" +LOG_DIR="$REPO_ROOT/runtime/logs" + +which tmux >/dev/null 2>&1 && USE_TMUX=true || USE_TMUX=false + +if $USE_TMUX; then + if tmux has-session -t "$SESSION" 2>/dev/null; then + echo "Killing tmux session $SESSION" + tmux kill-session -t "$SESSION" + else + echo "No tmux session named $SESSION found." + fi +else + echo "tmux not available; attempting to stop background demo processes by PID files." + for f in serve eve_replay tui; do + pidfile="$REPO_ROOT/runtime/${f}.pid" + if [ -f "$pidfile" ]; then + pid=$(cat "$pidfile" 2>/dev/null || echo "") + if [ -n "$pid" ]; then + if kill -0 "$pid" 2>/dev/null; then + echo "Killing $f (pid=$pid)" + kill "$pid" || true + sleep 0.2 + fi + fi + rm -f "$pidfile" + fi + done +fi + +echo "Demo stopped. Logs are in: $LOG_DIR" +exit 0 diff --git a/scripts/eve_replay.py b/scripts/eve_replay.py new file mode 100644 index 0000000..a55dc54 --- /dev/null +++ b/scripts/eve_replay.py @@ -0,0 +1,107 @@ +#!/usr/bin/env python3 +"""EVE Replay for demo (Tool A alternative) + +Writes synthetic Suricata EVE JSON alert lines to a demo file (default: runtime/demo_eve.json) +at a configurable interval. Designed to escalate threat level over a sequence so that the +monitoring pipeline (azctl serve or main_suricata) will detect and enact mode transitions. + +Usage: + python3 scripts/eve_replay.py --file runtime/demo_eve.json --interval 5 + +This script is safe to run locally; it only appends JSON lines to the chosen file. +Run the monitoring daemon with: + python3 -m azctl.cli serve --suricata-eve runtime/demo_eve.json --decisions-log ./decisions.log + +Then start this script to inject alerts. +""" +from __future__ import annotations + +import time +import json +import argparse +from datetime import datetime +from pathlib import Path + +SAMPLE_ALERTS = [ + # low -> reconnaissance + { + "event_type": "alert", + "timestamp": "{ts}", + "src_ip": "10.0.0.5", + "dest_ip": "172.16.0.10", + "proto": "ICMP", + "dest_port": None, + "alert": {"signature": "ET SCAN Potential scan detected (NMAP)", "severity": 4} + }, + # medium -> brute/scan + { + "event_type": "alert", + "timestamp": "{ts}", + "src_ip": "198.51.100.23", + "dest_ip": "172.16.0.10", + "proto": "TCP", + "dest_port": 22, + "alert": {"signature": "ET BRUTEFORCE SSH Brute force attempt", "severity": 3} + }, + # higher -> exploit + { + "event_type": "alert", + "timestamp": "{ts}", + "src_ip": "198.51.100.23", + "dest_ip": "172.16.0.10", + "proto": "TCP", + "dest_port": 80, + "alert": {"signature": "ET EXPLOIT Possible buffer overflow", "severity": 1} + }, + # final aggressive -> ddos/flood + { + "event_type": "alert", + "timestamp": "{ts}", + "src_ip": "203.0.113.9", + "dest_ip": "172.16.0.10", + "proto": "UDP", + "dest_port": 0, + "alert": {"signature": "ET DOS Possible DDoS amplification", "severity": 1} + } +] + + +def append_line(path: Path, obj: dict): + path.parent.mkdir(parents=True, exist_ok=True) + with path.open("a", encoding="utf-8") as fh: + fh.write(json.dumps(obj, ensure_ascii=False)) + fh.write("\n") + + +def main(): + parser = argparse.ArgumentParser(description="EVE replay generator for Azazel demo") + parser.add_argument("--file", default="runtime/demo_eve.json", help="Path to EVE file to append to") + parser.add_argument("--interval", type=float, default=5.0, help="Seconds between alert injections") + parser.add_argument("--loop", action="store_true", help="Loop the sequence until stopped") + args = parser.parse_args() + + eve_path = Path(args.file) + seq = SAMPLE_ALERTS + + print(f"EVE replay: writing to {eve_path} every {args.interval}s (loop={args.loop})") + + try: + while True: + for item in seq: + data = dict(item) + ts = datetime.now().isoformat() + data["timestamp"] = ts + # normalize None dest_port to null + if data.get("dest_port") is None: + data["dest_port"] = None + append_line(eve_path, data) + print(f"Appended alert: {data['alert']['signature']} @ {ts}") + time.sleep(args.interval) + if not args.loop: + break + except KeyboardInterrupt: + print("EVE replay interrupted by user") + + +if __name__ == "__main__": + main() diff --git a/scripts/install_demo_notify.sh b/scripts/install_demo_notify.sh new file mode 100644 index 0000000..807167c --- /dev/null +++ b/scripts/install_demo_notify.sh @@ -0,0 +1,24 @@ +#!/usr/bin/env bash +# Install demo notify config (back up existing configs/monitoring/notify.yaml -> notify.yaml.bak) +set -euo pipefail +REPO_ROOT="$(cd "$(dirname "$0")/.." && pwd)" +DEMO_CFG="$REPO_ROOT/configs/monitoring/notify_demo.yaml" +TARGET_CFG="$REPO_ROOT/configs/monitoring/notify.yaml" +BACKUP="$TARGET_CFG.bak" + +if [ ! -f "$DEMO_CFG" ]; then + echo "Demo config not found: $DEMO_CFG" + exit 1 +fi + +if [ -f "$TARGET_CFG" ] && [ ! -f "$BACKUP" ]; then + echo "Backing up existing notify.yaml -> notify.yaml.bak" + cp "$TARGET_CFG" "$BACKUP" +fi + +cp "$DEMO_CFG" "$TARGET_CFG" +chmod 644 "$TARGET_CFG" + +echo "Demo notify.yaml installed to $TARGET_CFG" + +echo "Remember: edit $TARGET_CFG and set 'webhook_url' to your TEST webhook before running the demo." diff --git a/scripts/restore_notify.sh b/scripts/restore_notify.sh new file mode 100644 index 0000000..78d3490 --- /dev/null +++ b/scripts/restore_notify.sh @@ -0,0 +1,13 @@ +#!/usr/bin/env bash +# Restore original notify.yaml if backup exists +set -euo pipefail +REPO_ROOT="$(cd "$(dirname "$0")/.." && pwd)" +TARGET_CFG="$REPO_ROOT/configs/monitoring/notify.yaml" +BACKUP="$TARGET_CFG.bak" + +if [ -f "$BACKUP" ]; then + mv -f "$BACKUP" "$TARGET_CFG" + echo "Restored original notify.yaml from backup" +else + echo "No backup found. Nothing to restore." +fi From ecb707aa115ff0f51c11df68282cab1cac0b62e4 Mon Sep 17 00:00:00 2001 From: 01rabbit Date: Wed, 12 Nov 2025 22:07:34 +0900 Subject: [PATCH 2/2] chore: centralize subprocess execution to cmd_runner.run; replace subprocess.run in azctl/menu and scripts; fix network_utils/delay_action; keep test subprocess invocation unchanged --- README.md | 21 + README_ja.md | 37 ++ azazel_pi/core/async_ai.py | 226 +++++++ azazel_pi/core/display/status_collector.py | 47 +- azazel_pi/core/enforcer/_nft_restore.py | 14 + azazel_pi/core/enforcer/traffic_control.py | 740 ++++++++++++++++----- azazel_pi/core/hybrid_threat_evaluator.py | 74 ++- azazel_pi/core/ingest/suricata_tail.py | 5 + azazel_pi/core/mock_llm.py | 23 +- azazel_pi/core/network/wan_manager.py | 64 +- azazel_pi/core/notify.py | 85 +++ azazel_pi/core/offline_ai_evaluator.py | 21 +- azazel_pi/core/state_machine.py | 29 +- azazel_pi/monitor/run_all.py | 11 +- azazel_pi/utils/cmd_runner.py | 40 ++ azazel_pi/utils/delay_action.py | 67 +- azazel_pi/utils/mattermost.py | 6 +- azazel_pi/utils/network_utils.py | 53 +- azctl/daemon.py | 148 ++++- azctl/menu/core.py | 3 +- azctl/menu/defense.py | 11 +- azctl/menu/emergency.py | 67 +- azctl/menu/monitoring.py | 9 +- azctl/menu/services.py | 15 +- azctl/menu/system.py | 7 +- azctl/menu/wifi.py | 54 +- bin/azazel-qos-apply.sh | 50 +- bin/azazel-traffic-init.sh | 128 +++- configs/monitoring/notify.yaml | 15 - configs/network/azazel.yaml | 3 +- configs/notify.yaml | 79 +++ docs/DEMO.md | 4 +- docs/ja/E2E_RUN.md | 48 ++ docs/ja/INSTALLATION.md | 2 +- runtime/E2E_README.md | 28 + runtime/demo_eve.json | 125 +++- runtime/serve.pid | 1 + scripts/ai_policy_delay.sh | 23 +- scripts/install_demo_notify.sh | 4 +- scripts/restore_notify.sh | 2 +- scripts/tc_reset.sh | 10 +- scripts/test_ai_integration.py | 5 +- systemd/azctl-unified.service | 10 +- tests/helpers/run_wan_state_test.py | 3 +- tests/test_async_ai_persistence.py | 50 ++ tests/test_traffic_control.py | 80 +++ tests/utils/fake_subprocess.py | 76 +++ tests/utils/test_fake_subprocess_usage.py | 47 ++ 48 files changed, 2118 insertions(+), 552 deletions(-) create mode 100644 azazel_pi/core/async_ai.py create mode 100644 azazel_pi/core/enforcer/_nft_restore.py create mode 100644 azazel_pi/core/notify.py create mode 100644 azazel_pi/utils/cmd_runner.py delete mode 100644 configs/monitoring/notify.yaml create mode 100644 configs/notify.yaml create mode 100644 docs/ja/E2E_RUN.md create mode 100644 runtime/E2E_README.md create mode 100644 runtime/serve.pid create mode 100644 tests/test_async_ai_persistence.py create mode 100644 tests/test_traffic_control.py create mode 100644 tests/utils/fake_subprocess.py create mode 100644 tests/utils/test_fake_subprocess_usage.py diff --git a/README.md b/README.md index 7d4cf03..7f0f3f3 100644 --- a/README.md +++ b/README.md @@ -497,6 +497,27 @@ Modern cyber attacks are increasingly fast and automated, making traditional hon The core philosophy recognizes that in asymmetric cyber warfare, defenders often cannot prevent initial compromise but can control the attacker's subsequent actions. By implementing strategic delays and misdirection, Azazel creates opportunities for detection, analysis, and response. +### Developer notes and helper API + +The `TrafficControlEngine` exposes two helpers to make testing and development easier: + +- `TrafficControlEngine.set_subprocess_runner(runner_callable)` + - Inject a custom subprocess runner in tests to simulate `tc`/`nft` outputs without running system commands. + - The runner should accept `(cmd, **kwargs)` and return an object with attributes `returncode`, `stdout`, and `stderr` (a `subprocess.CompletedProcess` is ideal). + - Example usage in tests: + + ```py + from azazel_pi.core.enforcer.traffic_control import get_traffic_control_engine, make_completed_process + + engine = get_traffic_control_engine() + engine.set_subprocess_runner(lambda cmd, **kw: make_completed_process(cmd, 0, stdout='ok')) + ``` + +- `make_completed_process(cmd, returncode=0, stdout='', stderr='')` + - Convenience factory (available at module level in `traffic_control.py`) to produce CompletedProcess-like objects for tests. + +These APIs make it simple to unit-test enforcer behavior without requiring root or modifying the host network stack. + ## What's New ### Enhanced AI Integration (v3) - November 2024 diff --git a/README_ja.md b/README_ja.md index 5032734..66dc76c 100644 --- a/README_ja.md +++ b/README_ja.md @@ -1,3 +1,40 @@ +### 開発者向けメモとテスト用ヘルパー API + +`TrafficControlEngine` はテストや開発を容易にするためのヘルパーを公開しています。特にネットワーク周りの `tc` / `nft` コマンドを実行する箇所をモックし、root 権限や実際のネットワークスタックに依存せずに単体テストできるようになっています。 +- `TrafficControlEngine.set_subprocess_runner(runner_callable)` + - テストでカスタムのサブプロセスランナーを注入して、`tc`/`nft` の出力を模擬できます。 + - `runner_callable` は `(cmd, **kwargs)` を受け取り、`returncode` / `stdout` / `stderr` 属性を持つオブジェクト(`subprocess.CompletedProcess` が最適)を返すようにしてください。 + - 例(テスト内での利用): + + ```py + from azazel_pi.core.enforcer.traffic_control import TrafficControlEngine, make_completed_process + from tests.utils.fake_subprocess import FakeSubprocess + + fake = FakeSubprocess() + fake.when("tc qdisc show").then_stdout("") + fake.when("nft -a add rule").then_stdout("added rule handle 123") + + # クラス属性に注入してインスタンス化すると __init__ 時のセットアップ呼び出しもモックされます + TrafficControlEngine._subprocess_runner = fake + engine = TrafficControlEngine(config_path="runtime/test_azazel.yaml") + try: + ok = engine.apply_dnat_redirect("10.0.0.5", dest_port=2222) + assert ok + rules = engine.get_active_rules() + assert "10.0.0.5" in rules + finally: + # テスト後にクラス属性をクリーンアップ + try: + delattr(TrafficControlEngine, "_subprocess_runner") + except Exception: + pass + ``` + +- `make_completed_process(cmd, returncode=0, stdout='', stderr='')` + - テスト用の `subprocess.CompletedProcess` 相当を簡単に作るためのファクトリ関数です。 + - モジュール (`azazel_pi.core.enforcer.traffic_control`) のトップレベルに公開されています。 + +これらの API により、root 権限や実行環境に依存しないユニットテストが容易になります。テスト用の軽量ユーティリティ `tests/utils/fake_subprocess.py` も用意しており、単純なコマンド substring マッチングで期待する stdout/stderr を返すことができます。 # AZ-01X Azazel-Pi - The Cyber Scapegoat Gateway [English](README.md) | 日本語 diff --git a/azazel_pi/core/async_ai.py b/azazel_pi/core/async_ai.py new file mode 100644 index 0000000..bcc2c8c --- /dev/null +++ b/azazel_pi/core/async_ai.py @@ -0,0 +1,226 @@ +#!/usr/bin/env python3 +""" +Asynchronous Deep AI evaluation worker. + +This module provides a simple background queue to run costly Ollama +evaluations asynchronously. Call `enqueue(alert_data, context)` to +schedule a deep analysis. Results are logged and optionally sent via +Mattermost notifications when configured. +""" +from __future__ import annotations + +import logging +import threading +import random +import time +from queue import Queue +from typing import Any, Dict, Optional +from pathlib import Path + +# lazy resolver for AI evaluator to avoid importing heavy deps at module import time +get_ai_evaluator = None +from . import notify_config +try: + from .notify import MattermostNotifier +except Exception: + MattermostNotifier = None + +logger = logging.getLogger(__name__) + +_queue: "Queue[Dict[str, Any]]" = Queue() +_started = False + +# Sampling/rate defaults +_DEFAULT_SAMPLE_RATE = float(notify_config.get("ai", {}).get("deep_sample_rate", 1.0)) +_DEFAULT_MAX_PER_MIN = int(notify_config.get("ai", {}).get("deep_max_per_min", 60) or 60) + +# Simple rate limiter state (use time.time()) +_tokens = _DEFAULT_MAX_PER_MIN +_last_refill = time.time() +_rate_lock = threading.Lock() + + +def _allow_enqueue() -> bool: + """Decide whether to allow enqueue based on sampling and a simple token bucket. + + Thread-safe and uses wall-clock minute-based refill. Returns True when + the item is allowed to be enqueued. + """ + # Probabilistic sampling + try: + if random.random() > _DEFAULT_SAMPLE_RATE: + return False + except Exception: + # if random fails for some reason, default to allow + pass + + global _tokens, _last_refill + with _rate_lock: + try: + current = int(time.time()) + # refill once per minute + if current - int(_last_refill) >= 60: + _tokens = _DEFAULT_MAX_PER_MIN + _last_refill = current + if _tokens <= 0: + return False + _tokens -= 1 + return True + except Exception: + return True + + +def _worker() -> None: + logger.info("Async AI worker started") + while True: + item = _queue.get() + if item is None: + break + alert = item.get("alert") or {} + context = item.get("context") or {} + try: + # resolve evaluator lazily; tests may monkeypatch `get_ai_evaluator` in this module + evaluator = None + try: + if callable(get_ai_evaluator): + evaluator = get_ai_evaluator() + else: + # lazy import (avoid importing requests during test collection) + from .ai_evaluator import get_ai_evaluator as _g + globals()['get_ai_evaluator'] = _g + evaluator = _g() + except Exception: + logger.warning("No AI evaluator available for deep analysis") + continue + + sig_safe = str(alert.get('signature') or '') + src_safe = str(alert.get('src_ip') or '') + logger.info(f"Running deep AI analysis for {src_safe} {sig_safe}") + # evaluator may be flaky; retry a small number of times with backoff + max_eval_retries = int(notify_config.get("ai", {}).get("deep_eval_retries", 2) or 2) + eval_attempt = 0 + result = None + while eval_attempt <= max_eval_retries: + try: + result = evaluator.evaluate_threat(alert) + break + except Exception: + eval_attempt += 1 + wait = 0.5 * (2 ** (eval_attempt - 1)) + logger.exception(f"Deep eval attempt {eval_attempt} failed, retrying in {wait}s") + time.sleep(wait) + if result is None: + logger.error("Deep AI evaluation failed after retries") + continue + logger.info(f"Deep AI result: {result}") + + # Persist deep result to decisions.log if provided in context + decisions_path = context.get("decisions_log") + if decisions_path: + p = Path(decisions_path) + p.parent.mkdir(parents=True, exist_ok=True) + entry = { + "event": alert.get("signature", "deep_ai"), + "score": result.get("score") or ((result.get("risk", 1) - 1) * 25), + "classification": result.get("category"), + "timestamp": alert.get("timestamp"), + "deep_ai": result, + "note": "deep_followup", + } + import json + + # write with retries to tolerate transient FS/permission issues + max_persist_retries = int(notify_config.get("ai", {}).get("deep_persist_retries", 3) or 3) + attempt = 0 + while attempt <= max_persist_retries: + try: + with p.open("a", encoding="utf-8") as fh: + fh.write(json.dumps(entry, sort_keys=True, ensure_ascii=False)) + fh.write("\n") + fh.flush() + break + except Exception: + attempt += 1 + wait = 0.25 * (2 ** (attempt - 1)) + logger.exception(f"Failed to persist deep AI result (attempt {attempt}), retrying in {wait}s") + time.sleep(wait) + else: + logger.error("Giving up persisting deep AI result after retries") + + # Send a follow-up Mattermost notification if possible (with retries) + if MattermostNotifier is not None: + notifier = None + try: + notifier = MattermostNotifier() + except Exception: + logger.exception("Failed to construct MattermostNotifier") + if notifier is not None: + text = ( + f"🔎 Deep AI analysis result for {alert.get('src_ip')} - " + f"risk={result.get('risk')} category={result.get('category')}\n{result.get('reason')}" + ) + payload = { + "timestamp": alert.get("timestamp"), + "signature": "🔎 Deep AI Analysis", + "severity": 3, + "src_ip": alert.get("src_ip"), + "details": text, + } + sig_short = (str(alert.get('signature') or '') )[:50] + key = f"deep:{str(alert.get('src_ip') or '')}:{sig_short}" + max_notify_retries = int(notify_config.get("notify", {}).get("notify_retries", 2) or 2) + ntry = 0 + while ntry <= max_notify_retries: + try: + notifier.notify(payload, key=key) + break + except Exception: + ntry += 1 + wait = 0.5 * (2 ** (ntry - 1)) + logger.exception(f"Deep notify attempt {ntry} failed, retrying in {wait}s") + time.sleep(wait) + else: + logger.error("Giving up deep notify after retries") + + except Exception: + logger.exception("Async AI worker encountered an error during evaluation") + finally: + try: + _queue.task_done() + except Exception: + pass + + +def start() -> None: + global _started + if _started: + return + t = threading.Thread(target=_worker, daemon=True, name="azazel-async-ai") + t.start() + _started = True + + +def enqueue(alert: Dict[str, Any], context: Optional[Dict[str, Any]] = None) -> None: + """Schedule a deep AI evaluation for the given alert. + + This returns immediately; evaluation runs in background. + """ + start() + ctx = context or {} + # sampling / rate limiting + try: + allow = _allow_enqueue() + except Exception: + allow = True + if not allow: + logger.info("Async AI enqueue skipped by sampling/rate limit") + return + _queue.put({"alert": alert, "context": ctx}) + + +def shutdown() -> None: + """Gracefully stop the worker (for tests). + + Note: This will block until the worker thread sees the sentinel. + """ + _queue.put(None) diff --git a/azazel_pi/core/display/status_collector.py b/azazel_pi/core/display/status_collector.py index d7ad939..9693bfa 100644 --- a/azazel_pi/core/display/status_collector.py +++ b/azazel_pi/core/display/status_collector.py @@ -3,6 +3,7 @@ import json import subprocess +from azazel_pi.utils.cmd_runner import run as run_cmd from dataclasses import dataclass, field from datetime import datetime, timezone import os @@ -91,13 +92,7 @@ def collect(self) -> SystemStatus: def _get_hostname(self) -> str: """Get system hostname.""" try: - result = subprocess.run( - ["hostname"], - capture_output=True, - text=True, - timeout=1, - check=False, - ) + result = run_cmd(["hostname"], capture_output=True, text=True, timeout=1, check=False) return result.stdout.strip() or "azazel-pi" except Exception: return "azazel-pi" @@ -141,27 +136,15 @@ def _get_network_status(self, interface: Optional[str] = None) -> NetworkStatus: # Check if interface is up try: - result = subprocess.run( - ["ip", "link", "show", active_iface], - capture_output=True, - text=True, - timeout=1, - check=False, - ) - status.is_up = "state UP" in result.stdout + result = run_cmd(["ip", "link", "show", active_iface], capture_output=True, text=True, timeout=1, check=False) + status.is_up = "state UP" in (result.stdout or "") except Exception: pass # Get IP address try: - result = subprocess.run( - ["ip", "-4", "addr", "show", active_iface], - capture_output=True, - text=True, - timeout=1, - check=False, - ) - for line in result.stdout.splitlines(): + result = run_cmd(["ip", "-4", "addr", "show", active_iface], capture_output=True, text=True, timeout=1, check=False) + for line in (result.stdout or "").splitlines(): if "inet " in line: parts = line.strip().split() if len(parts) >= 2: @@ -188,13 +171,7 @@ def _get_network_status(self, interface: Optional[str] = None) -> NetworkStatus: def _get_default_route_interface(self) -> Optional[str]: """Return the interface used for the default route (best-effort).""" try: - result = subprocess.run( - ["ip", "route", "get", "1.1.1.1"], - capture_output=True, - text=True, - timeout=1, - check=False, - ) + result = run_cmd(["ip", "route", "get", "1.1.1.1"], capture_output=True, text=True, timeout=1, check=False) except Exception: return None @@ -517,14 +494,8 @@ def _count_alerts(self, recent_window_seconds: int = 300) -> tuple[int, int]: def _is_service_active(self, service_name: str) -> bool: """Check if a systemd service is active.""" try: - result = subprocess.run( - ["systemctl", "is-active", f"{service_name}.service"], - capture_output=True, - text=True, - timeout=2, - check=False, - ) - return result.stdout.strip() == "active" + result = run_cmd(["systemctl", "is-active", f"{service_name}.service"], capture_output=True, text=True, timeout=2, check=False) + return (result.stdout or "").strip() == "active" except Exception: return False diff --git a/azazel_pi/core/enforcer/_nft_restore.py b/azazel_pi/core/enforcer/_nft_restore.py new file mode 100644 index 0000000..2b5a60c --- /dev/null +++ b/azazel_pi/core/enforcer/_nft_restore.py @@ -0,0 +1,14 @@ +#!/usr/bin/env python3 +"""Helper utilities for restoring persisted nft handle entries into engine.""" +from typing import Dict +import json +from pathlib import Path + +def load_persisted(path: Path) -> Dict[str, Dict]: + try: + if not path.exists(): + return {} + with path.open('r') as fh: + return json.load(fh) + except Exception: + return {} diff --git a/azazel_pi/core/enforcer/traffic_control.py b/azazel_pi/core/enforcer/traffic_control.py index 3e23011..6445647 100644 --- a/azazel_pi/core/enforcer/traffic_control.py +++ b/azazel_pi/core/enforcer/traffic_control.py @@ -11,7 +11,9 @@ from dataclasses import dataclass from pathlib import Path from typing import Dict, List, Optional, Tuple +import json import yaml +import threading # 統合システムでは actions モジュールは使用しない(直接tc/nftコマンド実行) from ...utils.delay_action import ( @@ -58,7 +60,34 @@ def __init__(self, config_path: Optional[str] = None): # Respect AZAZEL_WAN_IF environment override first, then WAN manager helper self.interface = os.environ.get("AZAZEL_WAN_IF") or get_active_wan_interface() self.active_rules: Dict[str, List[TrafficControlRule]] = {} + # lock protecting active_rules and related operations + self._rules_lock = threading.Lock() self._ensure_tc_setup() + + # Restore any persisted nft handles into in-memory active_rules mapping so + # deletions by handle will work across restarts. + try: + self._restore_persisted_nft_handles() + except Exception: + logger.exception('Failed restoring persisted nft handles at startup') + # Validate persisted entries and prune stale ones + try: + self._validate_and_clean_persisted_handles() + except Exception: + logger.exception('Failed validating persisted nft handles at startup') + + # Start background cleanup thread (uses config.rules.cleanup_interval_seconds) + try: + conf = self._load_config() + rules_cfg = conf.get("rules", {}) if isinstance(conf, dict) else {} + self._cleanup_interval = int(rules_cfg.get("cleanup_interval_seconds", 60) or 60) + self._max_rule_age = int(rules_cfg.get("max_age_seconds", 3600) or 3600) + except Exception: + self._cleanup_interval = 60 + self._max_rule_age = 3600 + + self._cleanup_thread = threading.Thread(target=self._cleanup_loop, daemon=True, name="azazel-tc-cleanup") + self._cleanup_thread.start() def _load_config(self) -> Dict: """設定ファイルを読み込み""" @@ -68,44 +97,235 @@ def _load_config(self) -> Dict: except Exception as e: logger.error(f"Config load failed: {e}") return {} + + # --- safe subprocess result accessors --- + def _safe_stdout(self, proc) -> str: + """Return stdout as str, tolerating Mock objects or None.""" + try: + return str(getattr(proc, 'stdout', '') or '') + except Exception: + try: + # Some mocks may be simple callables/objects; coerce to str + return str(proc) + except Exception: + return "" + + def _safe_stderr(self, proc) -> str: + """Return stderr as str, tolerating Mock objects or None.""" + try: + return str(getattr(proc, 'stderr', '') or '') + except Exception: + try: + return str(proc) + except Exception: + return "" + + def _run_cmd(self, cmd, capture_output=True, text=True, timeout=None, check=False): + """Centralized subprocess runner to make testing/mocking easier. + + - Uses an injectable runner if tests set `self._subprocess_runner` (callable). + - Normalizes unexpected/mocked returns to CompletedProcess-like objects. + """ + runner = getattr(self, '_subprocess_runner', subprocess.run) + try: + return runner(cmd, capture_output=capture_output, text=text, timeout=timeout, check=check) + except TypeError: + # Some test doubles may be simple callables that accept only (cmd,) positional. + try: + res = runner(cmd) + return res + except Exception: + # Fall through to creating a safe CompletedProcess + return subprocess.CompletedProcess(cmd, returncode=1, stdout="", stderr="") + except Exception as e: + # Ensure we always return a CompletedProcess-like object to callers + try: + return subprocess.CompletedProcess(cmd, returncode=1, stdout="", stderr=str(e)) + except Exception: + # Last resort + class _Dummy: + returncode = 1 + stdout = "" + stderr = "" + return _Dummy() + + def set_subprocess_runner(self, runner_callable): + """Set a custom subprocess runner. + + Tests can inject a lightweight runner (callable) that accepts the same + parameters as subprocess.run or at least (cmd,) and return an object + with attributes: returncode, stdout, stderr. + + Example: + engine.set_subprocess_runner(lambda cmd, **kw: make_completed_process(cmd, 0, stdout='ok')) + """ + setattr(self, '_subprocess_runner', runner_callable) + + + # --- nft handle persistence helpers --- + def _nft_handles_path(self) -> Path: + # persistent mapping of target_ip -> nft rule metadata + return Path('/var/lib/azazel') / 'nft_handles.json' + + def _load_persisted_nft_handles(self) -> Dict[str, Dict]: + path = self._nft_handles_path() + try: + if not path.exists(): + return {} + with path.open('r') as fh: + return json.load(fh) + except Exception: + logger.exception('Failed loading persisted nft handles') + return {} + + def _validate_and_clean_persisted_handles(self) -> None: + """Verify persisted nft handles actually exist in nft and remove stale entries.""" + try: + data = self._load_persisted_nft_handles() + if not data: + return + # Build a mapping of existing handles by family/table + existing = {} + for ip, meta in list(data.items()): + fam = meta.get('family') + tab = meta.get('table') + handle = meta.get('handle') + if not (fam and tab and handle): + # invalid entry, remove + del data[ip] + continue + try: + res = self._run_cmd(["nft", "-a", "list", "table", fam, tab], capture_output=True, text=True, timeout=10) + if res.returncode != 0 or (str(handle) not in (self._safe_stdout(res) or "")): + # handle not present anymore + del data[ip] + continue + except Exception: + # on error, conservatively keep entry + continue + + # Save cleaned data back + self._save_persisted_nft_handles(data) + except Exception: + logger.exception('Error validating persisted nft handles') + + def _save_persisted_nft_handles(self, data: Dict[str, Dict]) -> None: + path = self._nft_handles_path() + try: + path.parent.mkdir(parents=True, exist_ok=True) + tmp = path.with_suffix('.tmp') + with tmp.open('w') as fh: + json.dump(data, fh) + tmp.replace(path) + except Exception: + logger.exception('Failed saving persisted nft handles') + + def _persist_nft_handle_entry(self, target_ip: str, family: str, table: str, handle: str, action: str, dest_port: Optional[int] = None) -> None: + try: + data = self._load_persisted_nft_handles() + data[target_ip] = { + 'family': family, + 'table': table, + 'handle': str(handle), + 'action': action, + 'dest_port': dest_port + } + self._save_persisted_nft_handles(data) + except Exception: + logger.exception('Failed persisting nft handle entry') + + def _remove_persisted_nft_handle(self, target_ip: str) -> None: + try: + data = self._load_persisted_nft_handles() + if target_ip in data: + del data[target_ip] + self._save_persisted_nft_handles(data) + except Exception: + logger.exception('Failed removing persisted nft handle entry') + + def _restore_persisted_nft_handles(self) -> None: + """Load persisted nft handles and populate active_rules so deletions work after restart.""" + try: + data = self._load_persisted_nft_handles() + if not data: + return + with self._rules_lock: + for ip, meta in data.items(): + action = meta.get('action') + family = meta.get('family') + table = meta.get('table') + handle = meta.get('handle') + dest_port = meta.get('dest_port') + if action == 'redirect': + rule = TrafficControlRule( + target_ip=ip, + action_type='redirect', + parameters={ + 'nft_family': family, + 'nft_table': table, + 'nft_handle': handle, + 'dest_port': dest_port + } + ) + self.active_rules.setdefault(ip, []).append(rule) + elif action == 'block': + rule = TrafficControlRule( + target_ip=ip, + action_type='block', + parameters={ + 'nft_family': family, + 'nft_table': table, + 'nft_handle': handle + } + ) + self.active_rules.setdefault(ip, []).append(rule) + except Exception: + logger.exception('Failed restoring persisted nft handles') def _ensure_tc_setup(self): """tc qdisc/class構造を初期化""" try: - # 既存のqdisc削除 - subprocess.run([ - "tc", "qdisc", "del", "dev", self.interface, "root" - ], capture_output=True, timeout=10) - - # HTB qdisc作成 - subprocess.run([ - "tc", "qdisc", "add", "dev", self.interface, "root", - "handle", "1:", "htb", "default", "30" - ], check=True, timeout=10) + # 既存のqdiscがあるか確認して、なければ作成する(冪等化) + qdisc_show = self._run_cmd([ + "tc", "qdisc", "show", "dev", self.interface + ], capture_output=True, text=True, timeout=10) + + if "htb 1:" not in self._safe_stdout(qdisc_show): + # HTB qdisc作成(replace を優先して冪等化) + res = self._run_cmd([ + "tc", "qdisc", "replace", "dev", self.interface, "root", + "handle", "1:", "htb", "default", "30" + ], capture_output=True, text=True, timeout=10) + if res.returncode != 0: + # replace が失敗した場合は add を試す代わりに存在チェックとログ出力に留める + if "File exists" in self._safe_stderr(res): + logger.debug("HTB qdisc already exists according to tc output") + else: + logger.warning(f"tc qdisc replace failed (continuing): {self._safe_stderr(res)}") # ルートクラス作成 config = self._load_config() uplink = config.get("profiles", {}).get("lte", {}).get("uplink_kbps", 5000) - subprocess.run([ - "tc", "class", "add", "dev", self.interface, "parent", "1:", - "classid", "1:1", "htb", "rate", f"{uplink}kbit" - ], check=True, timeout=10) - - # デフォルトクラス - subprocess.run([ - "tc", "class", "add", "dev", self.interface, "parent", "1:1", - "classid", "1:30", "htb", "rate", f"{uplink//2}kbit", - "ceil", f"{uplink}kbit" - ], check=True, timeout=10) - - # suspectクラス(低優先度) + # ルート/デフォルト/疑わしいクラスを作成(replace を使い冪等化) + def _ensure_class(classid: str, args: List[str]): + try: + # replace を優先して実行し、存在していれば上書きすることで File exists を避ける + cmd = ["tc", "class", "replace", "dev", self.interface] + args + res = self._run_cmd(cmd, capture_output=True, text=True, timeout=10) + if res.returncode != 0: + # replace failed: log and continue. Avoid unconditional `add` to prevent RTNETLINK File exists races. + if "File exists" in self._safe_stderr(res): + logger.debug(f"TC class {classid} already exists according to tc output") + else: + logger.warning(f"tc class replace failed for {classid}: {self._safe_stderr(res)}") + except Exception as e: + logger.exception(f"Failed ensuring class {classid}: {e}") + + _ensure_class("1:1", ["parent", "1:", "classid", "1:1", "htb", "rate", f"{uplink}kbit"]) + _ensure_class("1:30", ["parent", "1:1", "classid", "1:30", "htb", "rate", f"{uplink//2}kbit", "ceil", f"{uplink}kbit"]) suspect_rate = uplink // 10 # 10% - subprocess.run([ - "tc", "class", "add", "dev", self.interface, "parent", "1:1", - "classid", "1:40", "htb", "rate", f"{suspect_rate}kbit", - "ceil", f"{suspect_rate * 2}kbit", "prio", "4" - ], check=True, timeout=10) + _ensure_class("1:40", ["parent", "1:1", "classid", "1:40", "htb", "rate", f"{suspect_rate}kbit", "ceil", f"{suspect_rate * 2}kbit", "prio", "4"]) logger.info(f"TC setup completed for {self.interface}") @@ -122,35 +342,63 @@ def apply_delay(self, target_ip: str, delay_ms: int) -> bool: # netem遅延qdisc作成 classid = "1:41" # 遅延専用クラス - # 遅延クラス作成 - subprocess.run([ - "tc", "class", "add", "dev", self.interface, "parent", "1:1", - "classid", classid, "htb", "rate", "64kbit", "ceil", "128kbit" - ], check=True, timeout=10) - - # netem遅延qdisc追加 - subprocess.run([ - "tc", "qdisc", "add", "dev", self.interface, "parent", classid, - "handle", "41:", "netem", "delay", f"{delay_ms}ms" - ], check=True, timeout=10) - - # フィルタ作成(IPベース) - subprocess.run([ - "tc", "filter", "add", "dev", self.interface, "protocol", "ip", - "parent", "1:", "prio", "1", "u32", "match", "ip", "src", target_ip, - "flowid", classid - ], check=True, timeout=10) - - # ルール記録 + # 遅延クラス作成(replace で作成/更新、存在すればスキップ) + cp = self._run_cmd([ + "tc", "class", "show", "dev", self.interface, "classid", classid + ], capture_output=True, text=True, timeout=5) + if not (cp.returncode == 0 and classid in self._safe_stdout(cp)): + res = self._run_cmd([ + "tc", "class", "replace", "dev", self.interface, "parent", "1:1", + "classid", classid, "htb", "rate", "64kbit", "ceil", "128kbit" + ], capture_output=True, text=True, timeout=10) + if res.returncode != 0: + if "File exists" in self._safe_stderr(res): + logger.debug(f"TC class {classid} appears to already exist") + else: + logger.warning(f"tc class replace failed for {classid}: {self._safe_stderr(res)}") + + # netem遅延qdisc追加(replace を使い冪等化) + qdisc_show = self._run_cmd(["tc", "qdisc", "show", "dev", self.interface], capture_output=True, text=True, timeout=5) + if f"parent {classid}" not in self._safe_stdout(qdisc_show) or "netem" not in self._safe_stdout(qdisc_show): + res = self._run_cmd([ + "tc", "qdisc", "replace", "dev", self.interface, "parent", classid, + "handle", "41:", "netem", "delay", f"{delay_ms}ms" + ], capture_output=True, text=True, timeout=10) + if res.returncode != 0: + if "File exists" in self._safe_stderr(res): + logger.debug("netem qdisc already exists for class") + else: + logger.warning(f"tc qdisc replace failed for netem on {classid}: {self._safe_stderr(res)}") + + # フィルタ作成(IPベース) — 既存フィルタの存在チェック + filter_list = self._run_cmd([ + "tc", "filter", "show", "dev", self.interface, "parent", "1:" + ], capture_output=True, text=True, timeout=5) + if target_ip in self._safe_stdout(filter_list): + logger.info(f"TC filter for {target_ip} already exists, skip") + else: + # try replace first, then fallback to add + res = self._run_cmd([ + "tc", "filter", "replace", "dev", self.interface, "protocol", "ip", + "parent", "1:", "prio", "1", "u32", "match", "ip", "src", target_ip, + "flowid", classid + ], capture_output=True, text=True, timeout=10) + if res.returncode != 0: + if "File exists" in self._safe_stderr(res): + logger.debug("TC filter appears to already exist for target") + else: + logger.warning(f"tc filter replace failed for {target_ip}: {self._safe_stderr(res)}") + + # ルール記録(ロック) rule = TrafficControlRule( target_ip=target_ip, - action_type="delay", + action_type="delay", parameters={"delay_ms": delay_ms, "classid": classid, "prio": 1} ) - - if target_ip not in self.active_rules: - self.active_rules[target_ip] = [] - self.active_rules[target_ip].append(rule) + with self._rules_lock: + if target_ip not in self.active_rules: + self.active_rules[target_ip] = [] + self.active_rules[target_ip].append(rule) logger.info(f"Delay {delay_ms}ms applied to {target_ip}") return True @@ -168,30 +416,40 @@ def apply_shaping(self, target_ip: str, rate_kbps: int) -> bool: return True classid = "1:42" # シェーピング専用クラス - # シェーピングクラス作成 - subprocess.run([ - "tc", "class", "add", "dev", self.interface, "parent", "1:1", + # シェーピングクラス作成(replace を優先、add をフォールバック) + res = self._run_cmd([ + "tc", "class", "replace", "dev", self.interface, "parent", "1:1", "classid", classid, "htb", "rate", f"{rate_kbps}kbit", "ceil", f"{rate_kbps}kbit" - ], check=True, timeout=10) - - # フィルタ作成 - subprocess.run([ - "tc", "filter", "add", "dev", self.interface, "protocol", "ip", + ], capture_output=True, text=True, timeout=10) + if res.returncode != 0: + if "File exists" in self._safe_stderr(res): + logger.debug(f"TC class {classid} already exists for shaping") + else: + logger.warning(f"tc class replace failed for shaping {classid}: {self._safe_stderr(res)}") + + # フィルタ作成 (replace -> add) + resf = self._run_cmd([ + "tc", "filter", "replace", "dev", self.interface, "protocol", "ip", "parent", "1:", "prio", "2", "u32", "match", "ip", "src", target_ip, "flowid", classid - ], check=True, timeout=10) + ], capture_output=True, text=True, timeout=10) + if resf.returncode != 0: + if "File exists" in self._safe_stderr(resf): + logger.debug("TC filter already exists for shaping") + else: + logger.warning(f"tc filter replace failed for shaping {target_ip}: {self._safe_stderr(resf)}") - # ルール記録 + # ルール記録(ロック) rule = TrafficControlRule( target_ip=target_ip, action_type="shape", parameters={"rate_kbps": rate_kbps, "classid": classid, "prio": 2} ) - - if target_ip not in self.active_rules: - self.active_rules[target_ip] = [] - self.active_rules[target_ip].append(rule) + with self._rules_lock: + if target_ip not in self.active_rules: + self.active_rules[target_ip] = [] + self.active_rules[target_ip].append(rule) logger.info(f"Shaping {rate_kbps}kbps applied to {target_ip}") return True @@ -210,9 +468,40 @@ def apply_dnat_redirect(self, target_ip: str, dest_port: Optional[int] = None) - canary_ip = load_opencanary_ip() # nftablesテーブル確保 - if not ensure_nft_table_and_chain(): + # prefer a dedicated inet azazel NAT-capable table to avoid touching ip nat managed by iptables-nft + def _ensure_nat_table(): + # prefer inet azazel (create/prerouting nat chain) via helper + try: + if ensure_nft_table_and_chain(): + return ("inet", "azazel") + except Exception: + logger.debug("ensure_nft_table_and_chain failed or not available") + + # fall back to ip nat only if inet azazel is not available + try: + cp = self._run_cmd(["nft", "list", "table", "ip", "nat"], capture_output=True, text=True, timeout=5) + if cp.returncode == 0: + return ("ip", "nat") + except Exception: + pass + + # try to create ip nat if nothing else + try: + self._run_cmd(["nft", "add", "table", "ip", "nat"], capture_output=True, timeout=5) + self._run_cmd(["nft", "add", "chain", "ip", "nat", "prerouting", "{ type nat hook prerouting priority -100; }"], capture_output=True, timeout=5) + cp2 = self._run_cmd(["nft", "list", "table", "ip", "nat"], capture_output=True, text=True, timeout=5) + if cp2.returncode == 0: + return ("ip", "nat") + except Exception: + pass + + return (None, None) + + family, table = _ensure_nat_table() + if not family: + logger.error("No suitable nftables table available for DNAT") return False - + # DNATルール構築 if dest_port: rule_match = f"ip saddr {target_ip} tcp dport {dest_port}" @@ -220,32 +509,62 @@ def apply_dnat_redirect(self, target_ip: str, dest_port: Optional[int] = None) - else: rule_match = f"ip saddr {target_ip}" rule_action = f"dnat to {canary_ip}" - - # nftables DNAT ルール追加 + + # nftables DNAT ルール追加(既存チェックを行う) + list_cmd = ["nft", "list", "table", family, table] + list_res = self._run_cmd(list_cmd, capture_output=True, text=True, timeout=10) + if list_res.returncode == 0 and rule_match in self._safe_stdout(list_res) and "dnat to" in self._safe_stdout(list_res): + logger.info(f"DNAT rule already present for {target_ip}, skip") + # 記録は行う(既存ルールのhandleは不明なため保存せず) + rule = TrafficControlRule( + target_ip=target_ip, + action_type="redirect", + parameters={"canary_ip": canary_ip, "dest_port": dest_port, "nft_family": family, "nft_table": table} + ) + with self._rules_lock: + if target_ip not in self.active_rules: + self.active_rules[target_ip] = [] + self.active_rules[target_ip].append(rule) + return True + + # add rule and request handle (-a) so we can persist it for later deletion cmd = [ - "nft", "add", "rule", "inet", "azazel", "prerouting", + "nft", "-a", "add", "rule", family, table, "prerouting", rule_match, rule_action ] - - result = subprocess.run(cmd, capture_output=True, text=True, timeout=15) - + + result = self._run_cmd(cmd, capture_output=True, text=True, timeout=15) + if result.returncode == 0: - # ルール記録 + # try to parse handle from stdout/stderr + out = self._safe_stdout(result) + "\n" + self._safe_stderr(result) + import re + m = re.search(r"handle\s+(\d+)", out) + handle = m.group(1) if m else None + + # ルール記録(ハンドル・テーブル情報を含める) rule = TrafficControlRule( target_ip=target_ip, action_type="redirect", - parameters={"canary_ip": canary_ip, "dest_port": dest_port} + parameters={"canary_ip": canary_ip, "dest_port": dest_port, "nft_family": family, "nft_table": table, "nft_handle": handle} ) - - if target_ip not in self.active_rules: - self.active_rules[target_ip] = [] - self.active_rules[target_ip].append(rule) - - logger.info(f"DNAT redirect: {target_ip} -> {canary_ip}" + - (f":{dest_port}" if dest_port else "")) + with self._rules_lock: + if target_ip not in self.active_rules: + self.active_rules[target_ip] = [] + self.active_rules[target_ip].append(rule) + + # persist handle for cross-reboot deletion if available + if handle: + try: + self._persist_nft_handle_entry(target_ip, family, table, handle, 'redirect', dest_port) + except Exception: + logger.exception('Failed to persist nft handle for redirect') + + logger.info(f"DNAT redirect: {target_ip} -> {canary_ip}" + (f":{dest_port}" if dest_port else "") + (f" (handle {handle})" if handle else "")) return True else: - logger.error(f"nft DNAT failed: {result.stderr}") + logger.error(f"nft DNAT failed: {self._safe_stderr(result)}\n{self._safe_stdout(result)}") + # Operation not supported の可能性などはここでログに残し失敗扱い return False except Exception as e: @@ -256,13 +575,25 @@ def apply_suspect_classification(self, target_ip: str) -> bool: """攻撃者IPをsuspectクラスに分類(低優先度・低帯域)""" try: classid = "1:40" # suspect クラス(既にsetupで作成済み) - # フィルタ作成(全トラフィックをsuspectクラスに) - subprocess.run([ - "tc", "filter", "add", "dev", self.interface, "protocol", "ip", - "parent", "1:", "prio", "4", "u32", "match", "ip", "src", target_ip, - "flowid", classid - ], check=True, timeout=10) + # 既存フィルタの存在チェック + filter_list = self._run_cmd([ + "tc", "filter", "show", "dev", self.interface, "parent", "1:" + ], capture_output=True, text=True, timeout=5) + if target_ip in self._safe_stdout(filter_list): + logger.info(f"Suspect TC filter for {target_ip} already exists, skip") + else: + # try replace first, then fallback to add (handle File exists gracefully) + res = self._run_cmd([ + "tc", "filter", "replace", "dev", self.interface, "protocol", "ip", + "parent", "1:", "prio", "4", "u32", "match", "ip", "src", target_ip, + "flowid", classid + ], capture_output=True, text=True, timeout=10) + if res.returncode != 0: + if "File exists" in self._safe_stderr(res): + logger.debug("Suspect TC filter already exists according to tc output") + else: + logger.warning(f"tc filter replace failed for suspect class on {target_ip}: {self._safe_stderr(res)}") # ルール記録 rule = TrafficControlRule( @@ -270,10 +601,10 @@ def apply_suspect_classification(self, target_ip: str) -> bool: action_type="suspect_qos", parameters={"classid": classid, "priority": 4} ) - - if target_ip not in self.active_rules: - self.active_rules[target_ip] = [] - self.active_rules[target_ip].append(rule) + with self._rules_lock: + if target_ip not in self.active_rules: + self.active_rules[target_ip] = [] + self.active_rules[target_ip].append(rule) logger.info(f"Suspect classification applied to {target_ip} (low priority/bandwidth)") return True @@ -289,36 +620,51 @@ def apply_block(self, target_ip: str) -> bool: """ try: # nftables drop ルール追加 - handle_check = subprocess.run( + handle_check = self._run_cmd( ["nft", "-a", "list", "chain", "inet", "azazel", "prerouting"], capture_output=True, text=True, timeout=10 ) # 既にブロックルールが存在する場合はスキップ(冪等性) - if f"ip saddr {target_ip} drop" in handle_check.stdout: + if f"ip saddr {target_ip} drop" in self._safe_stdout(handle_check): logger.info(f"Block rule already exists for {target_ip}") return True - # dropルールを追加(最優先プライオリティ) - subprocess.run([ - "nft", "add", "rule", "inet", "azazel", "prerouting", + # dropルールを追加(-a でハンドル要求) + res = self._run_cmd([ + "nft", "-a", "add", "rule", "inet", "azazel", "prerouting", "ip", "saddr", target_ip, "drop" - ], check=True, timeout=10) - - logger.info(f"Exception block applied: {target_ip} (nft drop)") - - # アクティブルール記録 - if target_ip not in self.active_rules: - self.active_rules[target_ip] = [] - - self.active_rules[target_ip].append( - TrafficRule( - action_type="block", - target_ip=target_ip, - parameters={"method": "nft_drop"} - ) + ], capture_output=True, text=True, timeout=10) + if res.returncode != 0: + logger.error(f"nft drop add failed: {self._safe_stderr(res)} {self._safe_stdout(res)}") + return False + + # try to parse handle + out = self._safe_stdout(res) + "\n" + self._safe_stderr(res) + import re + m = re.search(r"handle\s+(\d+)", out) + handle = m.group(1) if m else None + + logger.info(f"Exception block applied: {target_ip} (nft drop)" + (f" (handle {handle})" if handle else "")) + + # アクティブルール記録(ロックして追加) + rule = TrafficControlRule( + target_ip=target_ip, + action_type="block", + parameters={"method": "nft_drop", "nft_handle": handle, "nft_family": "inet", "nft_table": "azazel"} ) - + with self._rules_lock: + if target_ip not in self.active_rules: + self.active_rules[target_ip] = [] + self.active_rules[target_ip].append(rule) + + # persist handle if available + if handle: + try: + self._persist_nft_handle_entry(target_ip, 'inet', 'azazel', handle, 'block') + except Exception: + logger.exception('Failed persisting nft handle for block') + return True except subprocess.CalledProcessError as e: @@ -374,53 +720,71 @@ def apply_combined_action(self, target_ip: str, mode: str) -> bool: def remove_rules_for_ip(self, target_ip: str) -> bool: """指定IPの全制御ルールを削除""" - if target_ip not in self.active_rules: - logger.info(f"No active rules for {target_ip}") - return True - + # スレッドセーフに active_rules をスナップして削除 + with self._rules_lock: + if target_ip not in self.active_rules: + logger.info(f"No active rules for {target_ip}") + return True + rules_to_remove = list(self.active_rules[target_ip]) + del self.active_rules[target_ip] + success = True - - for rule in self.active_rules[target_ip]: + for rule in rules_to_remove: try: if rule.action_type in ["delay", "shape"]: # tcルール削除(個別クラス) classid = rule.parameters.get("classid") - prio = str(rule.parameters.get("prio", 1 if rule.action_type=="delay" else 2)) + prio = str(rule.parameters.get("prio", 1 if rule.action_type == "delay" else 2)) if classid and classid not in ["1:40"]: # suspectクラスではない場合のみ削除 # フィルタ削除 - subprocess.run([ - "tc", "filter", "del", "dev", self.interface, + self._run_cmd([ + "tc", "filter", "del", "dev", self.interface, "protocol", "ip", "parent", "1:", "prio", prio ], capture_output=True, timeout=10) - + # クラス削除 - subprocess.run([ - "tc", "class", "del", "dev", self.interface, + self._run_cmd([ + "tc", "class", "del", "dev", self.interface, "classid", classid ], capture_output=True, timeout=10) - + elif rule.action_type == "suspect_qos": # suspectクラスフィルタ削除 - subprocess.run([ + self._run_cmd([ "tc", "filter", "del", "dev", self.interface, "protocol", "ip", "parent", "1:", "prio", "4" ], capture_output=True, timeout=10) - + elif rule.action_type == "redirect": # nftables DNAT削除 - self._remove_nft_dnat_rule(target_ip, rule.parameters.get("dest_port")) - + # If we have stored the handle/family/table use it for precise deletion + fam = rule.parameters.get("nft_family") + tab = rule.parameters.get("nft_table") + handle = rule.parameters.get("nft_handle") + if fam and tab and handle: + try: + self._run_cmd([ + "nft", "delete", "rule", fam, tab, "prerouting", "handle", str(handle) + ], capture_output=True, timeout=10) + logger.info(f"Deleted nft rule for {target_ip} by handle {handle}") + # remove persisted handle record + try: + self._remove_persisted_nft_handle(target_ip) + except Exception: + logger.exception('Failed removing persisted nft handle after deletion') + except Exception: + logger.exception(f"Failed deleting nft rule handle {handle} for {target_ip}") + else: + self._remove_nft_dnat_rule(target_ip, rule.parameters.get("dest_port")) + elif rule.action_type == "block": # nftables drop ルール削除 self._remove_nft_drop_rule(target_ip) - + except Exception as e: logger.error(f"Failed to remove rule {rule.action_type} for {target_ip}: {e}") success = False - - # アクティブルールリストから削除 - del self.active_rules[target_ip] - + logger.info(f"All rules removed for {target_ip}") return success @@ -433,26 +797,32 @@ def _remove_nft_dnat_rule(self, target_ip: str, dest_port: Optional[int] = None) search_pattern = f"ip saddr {target_ip}" # ルール一覧取得 - result = subprocess.run([ - "nft", "-a", "list", "table", "inet", "azazel" - ], capture_output=True, text=True, timeout=10) - - if result.returncode != 0: - return False - - # 該当ルールのハンドルを探す - for line in result.stdout.split('\n'): - if search_pattern in line and "handle" in line: - handle = line.split("handle")[-1].strip() - if handle.isdigit(): - # ルール削除 - delete_cmd = [ - "nft", "delete", "rule", "inet", "azazel", "prerouting", - "handle", handle - ] - subprocess.run(delete_cmd, capture_output=True, timeout=10) - return True - + # Try both ip nat and inet azazel tables + candidates = [("ip", "nat"), ("inet", "azazel")] + import re + for family, table in candidates: + try: + result = self._run_cmd(["nft", "-a", "list", "table", family, table], capture_output=True, text=True, timeout=10) + except Exception: + continue + if result.returncode != 0: + continue + + for line in self._safe_stdout(result).split('\n'): + if search_pattern in line and "handle" in line: + part = line.split("handle")[-1].strip() + part = part.strip().strip(',;') + m = re.search(r"(\d+)", part) + if m: + handle = m.group(1) + try: + delete_cmd = ["nft", "delete", "rule", family, table, "prerouting", "handle", handle] + self._run_cmd(delete_cmd, capture_output=True, timeout=10) + logger.info(f"Deleted nft rule handle {handle} from {family} {table}") + except Exception: + logger.exception(f"Failed deleting nft rule handle {handle}") + return True + return False except Exception as e: @@ -465,7 +835,7 @@ def _remove_nft_drop_rule(self, target_ip: str) -> bool: search_pattern = f"ip saddr {target_ip} drop" # ルール一覧取得 - result = subprocess.run([ + result = self._run_cmd([ "nft", "-a", "list", "chain", "inet", "azazel", "prerouting" ], capture_output=True, text=True, timeout=10) @@ -474,7 +844,7 @@ def _remove_nft_drop_rule(self, target_ip: str) -> bool: return False # 該当ルールのハンドルを探す - for line in result.stdout.split('\n'): + for line in self._safe_stdout(result).split('\n'): if search_pattern in line and "handle" in line: handle = line.split("handle")[-1].strip() if handle.isdigit(): @@ -483,8 +853,13 @@ def _remove_nft_drop_rule(self, target_ip: str) -> bool: "nft", "delete", "rule", "inet", "azazel", "prerouting", "handle", handle ] - subprocess.run(delete_cmd, check=True, timeout=10) + self._run_cmd(delete_cmd, check=True, timeout=10) logger.info(f"Removed nft drop rule for {target_ip} (handle {handle})") + # remove persisted record if any + try: + self._remove_persisted_nft_handle(target_ip) + except Exception: + logger.exception('Failed removing persisted nft handle after drop deletion') return True logger.info(f"No drop rule found for {target_ip}") @@ -514,7 +889,23 @@ def cleanup_expired_rules(self, max_age_seconds: int = 3600) -> int: def get_active_rules(self) -> Dict[str, List[TrafficControlRule]]: """現在アクティブなルール一覧を取得""" - return self.active_rules.copy() + with self._rules_lock: + return {k: list(v) for k, v in self.active_rules.items()} + + def _cleanup_loop(self) -> None: + """Background loop to periodically cleanup expired rules.""" + while True: + try: + time.sleep(self._cleanup_interval) + try: + cleaned = self.cleanup_expired_rules(max_age_seconds=self._max_rule_age) + if cleaned: + logger.info(f"Periodic cleanup removed {cleaned} rule sets") + except Exception: + logger.exception("Error during periodic cleanup") + except Exception: + # Protect thread from dying on unexpected errors + logger.exception("Cleanup loop encountered an error; continuing") def get_stats(self) -> Dict[str, any]: """統計情報を取得""" @@ -562,3 +953,30 @@ def get_traffic_control_engine() -> TrafficControlEngine: print("✗ Failed to remove rules") else: print("✗ Failed to apply shield mode") + + +def make_completed_process(cmd, returncode=0, stdout="", stderr=""): + """Convenience factory that returns a subprocess.CompletedProcess-like object + suitable for injecting into tests. Uses subprocess.CompletedProcess under the hood. + + Args: + cmd: the command list or string (kept in the CompletedProcess.args) + returncode: integer exit code + stdout: string to present as stdout + stderr: string to present as stderr + + Returns: + subprocess.CompletedProcess instance + """ + try: + return subprocess.CompletedProcess(cmd, returncode, stdout=stdout, stderr=stderr) + except Exception: + # Fallback to a tiny dummy object if CompletedProcess construction fails + class _Dummy: + def __init__(self, args, rc, out, err): + self.args = args + self.returncode = rc + self.stdout = out + self.stderr = err + + return _Dummy(cmd, returncode, stdout, stderr) diff --git a/azazel_pi/core/hybrid_threat_evaluator.py b/azazel_pi/core/hybrid_threat_evaluator.py index 82c3292..5a2ba40 100644 --- a/azazel_pi/core/hybrid_threat_evaluator.py +++ b/azazel_pi/core/hybrid_threat_evaluator.py @@ -8,6 +8,7 @@ import logging from typing import Dict, Any, Tuple, Optional from azazel_pi.core.offline_ai_evaluator import evaluate_with_offline_ai +from .async_ai import enqueue as enqueue_deep_eval logger = logging.getLogger(__name__) @@ -71,9 +72,9 @@ def __init__(self, ollama_config: Optional[Dict[str, Any]] = None): def evaluate_threat_hybrid(self, alert_data: Dict[str, Any]) -> Dict[str, Any]: """ハイブリッド脅威評価 - Legacy + Mock LLM + Ollama(未知の脅威用)""" - - signature = alert_data.get("signature", "") - + # Defensive: ensure signature is a string + signature = str(alert_data.get("signature", "") or "") + # 1. Mock LLM評価を取得(高速・軽量) try: mock_result = evaluate_with_offline_ai(alert_data) @@ -104,10 +105,23 @@ def evaluate_threat_hybrid(self, alert_data: Dict[str, Any]) -> Dict[str, Any]: ollama_result = ollama_evaluator.evaluate_threat(alert_data) if ollama_result.get("ai_used", False): self.logger.info(f"Ollama評価成功: risk={ollama_result['risk']}, category={ollama_result['category']}") - + + # If legacy heuristic already labels this as benign, respect that override + if self._is_benign_traffic(signature, alert_data): + return { + "risk": 1, + "reason": "正常トラフィックとして判定", + "category": "benign", + "score": min(self._calculate_legacy_score(alert_data, signature), 15), + "ai_used": True, + "model": "hybrid_legacy+mock_llm", + "confidence": 0.9, + "evaluation_method": "benign_override" + } + # Ollamaの評価を優先(未知の脅威に強い) ollama_score = (ollama_result["risk"] - 1) * 25 # 1-5 → 0-100 - + # Mock LLMとOllamaの統合(Ollama優先度高め: 70%) mock_score = (mock_risk - 1) * 25 integrated_score = int(ollama_score * 0.7 + mock_score * 0.3) @@ -208,6 +222,8 @@ def _finalize_evaluation(self, score: int, category: str, reason: str, def _calculate_legacy_score(self, alert_data: Dict[str, Any], signature: str) -> int: """従来のルールベーススコア計算""" + # Defensive: ensure signature is a string + signature = str(signature or "") base_score = 0 # 1. Suricata severity基準スコア @@ -252,6 +268,8 @@ def _calculate_legacy_score(self, alert_data: Dict[str, Any], signature: str) -> def _is_benign_traffic(self, signature: str, alert_data: Dict[str, Any]) -> bool: """正常トラフィック判定 (Legacy優先)""" + # Defensive: ensure signature is a string + signature = str(signature or "") sig_lower = signature.lower() # 明示的な正常パターン @@ -320,5 +338,47 @@ def get_hybrid_evaluator(config: Optional[Dict[str, Any]] = None) -> HybridThrea def evaluate_with_hybrid_system(alert_data: Dict[str, Any], config: Optional[Dict[str, Any]] = None) -> Dict[str, Any]: """ハイブリッド脅威評価の実行""" - evaluator = get_hybrid_evaluator(config) - return evaluator.evaluate_threat_hybrid(alert_data) \ No newline at end of file + # The hybrid evaluator object still exists for legacy synchronous use, + # but here we prioritize a fast Mock-LLM (offline) evaluation and + # schedule expensive Ollama-based deep analysis asynchronously when + # mock confidence is low or category is unknown. + try: + # Fast offline/mock evaluation + mock_result = evaluate_with_offline_ai(alert_data) + + # If the mock LLM marks low confidence or unknown category, enqueue deep eval + confidence = float(mock_result.get("confidence", 0.0) or 0.0) + category = (mock_result.get("category") or "").lower() + unknown_categories = {"unknown", "benign"} + # Threshold under which we want deeper analysis (tunable) + DEEP_CONF_THRESHOLD = 0.7 + + if confidence < DEEP_CONF_THRESHOLD or category in unknown_categories: + try: + # Provide decisions log path to async worker so deep result can be persisted + try: + from azazel_pi.core import notify_config as _nc + decisions_path = _nc._get_nested(_nc._CFG, "paths.decisions", None) or _nc._DEFAULTS["paths"]["decisions"] + except Exception: + decisions_path = None + + enqueue_deep_eval(alert_data, context={"decisions_log": decisions_path}) + mock_result["deferred"] = True + except Exception: + # If enqueue fails, mark as not deferred but continue + mock_result["deferred"] = False + else: + mock_result["deferred"] = False + + # If the offline/mock result does not provide a 0-100 'score' field, + # fall back to the richer HybridThreatEvaluator path to produce a + # fully-normalized evaluation result used elsewhere in the code/tests. + if "score" not in mock_result: + evaluator = get_hybrid_evaluator(config) + return evaluator.evaluate_threat_hybrid(alert_data) + + return mock_result + except Exception: + # Fallback to the original hybrid evaluator when offline fails + evaluator = get_hybrid_evaluator(config) + return evaluator.evaluate_threat_hybrid(alert_data) \ No newline at end of file diff --git a/azazel_pi/core/ingest/suricata_tail.py b/azazel_pi/core/ingest/suricata_tail.py index 6b73adc..f27063b 100644 --- a/azazel_pi/core/ingest/suricata_tail.py +++ b/azazel_pi/core/ingest/suricata_tail.py @@ -92,9 +92,14 @@ def stream(self) -> Generator[Event, None, None]: record = json.loads(line) event = FilteredEvent.from_eve_record(record) if event: + # Yield enriched Event including network/source metadata yield Event( name=event.event_type, severity=event.severity, + src_ip=event.src_ip, + dest_ip=event.dest_ip, + signature=event.signature, + details=event.details, ) except json.JSONDecodeError: continue diff --git a/azazel_pi/core/mock_llm.py b/azazel_pi/core/mock_llm.py index b94fd4c..bc779db 100644 --- a/azazel_pi/core/mock_llm.py +++ b/azazel_pi/core/mock_llm.py @@ -63,7 +63,9 @@ def _load_response_templates(self) -> Dict[str, List[str]]: def generate_response(self, prompt: str) -> str: """Generate LLM-like response for threat evaluation""" - + # Defensive: ensure prompt is a string to avoid NoneType errors + prompt = str(prompt or "") + # Extract key information from prompt risk_level = self._analyze_prompt_for_risk(prompt) category = self._analyze_prompt_for_category(prompt) @@ -91,7 +93,8 @@ def generate_response(self, prompt: str) -> str: def _analyze_prompt_for_risk(self, prompt: str) -> int: """Analyze prompt to determine risk level""" - prompt_lower = prompt.lower() + # Defensive: coerce to string and lowercase once + prompt_lower = str(prompt or "").lower() # 乱数の非決定性を抑えるため、プロンプト内容から安定シードを生成 seed = int(hashlib.md5(prompt_lower.encode("utf-8")).hexdigest(), 16) & 0x7FFFFFFF rng = random.Random(seed) @@ -125,7 +128,8 @@ def _analyze_prompt_for_risk(self, prompt: str) -> int: def _analyze_prompt_for_category(self, prompt: str) -> str: """Analyze prompt to determine threat category with priority-based matching""" - prompt_lower = prompt.lower() + # Defensive: coerce to string and lowercase once + prompt_lower = str(prompt or "").lower() # 優先度順でカテゴリパターンを定義(具体的なものから先に判定) category_patterns = [ @@ -156,15 +160,18 @@ def _generate_reason(self, category: str, prompt: str) -> str: """Generate explanation reason based on category""" if category in self.response_templates: base_reason = random.choice(self.response_templates[category]) - + + # Defensive: coerce prompt to string and lowercase for checks + prompt_lower = str(prompt or "").lower() + # Add some context from the prompt - if "ssh" in prompt.lower(): + if "ssh" in prompt_lower: base_reason += " SSHサービスが標的。" - elif "http" in prompt.lower(): + elif "http" in prompt_lower: base_reason += " Webサービスへの攻撃。" - elif "database" in prompt.lower() or "sql" in prompt.lower(): + elif "database" in prompt_lower or "sql" in prompt_lower: base_reason += " データベースが標的。" - + return base_reason else: return "総合的な脅威分析に基づく評価。継続的な監視が推奨される。" diff --git a/azazel_pi/core/network/wan_manager.py b/azazel_pi/core/network/wan_manager.py index cf2d0df..d27e8b7 100644 --- a/azazel_pi/core/network/wan_manager.py +++ b/azazel_pi/core/network/wan_manager.py @@ -10,6 +10,7 @@ from typing import Dict, Iterable, List, Optional, Sequence, Tuple import yaml +from azazel_pi.utils.cmd_runner import run as run_cmd from azazel_pi.utils.wan_state import ( InterfaceSnapshot, @@ -224,29 +225,17 @@ def _probe_interface(self, iface: str) -> ProbeResult: ip_addr: Optional[str] = None try: - res = subprocess.run( - ["ip", "link", "show", iface], - capture_output=True, - text=True, - timeout=2, - check=False, - ) + res = run_cmd(["ip", "link", "show", iface], capture_output=True, text=True, timeout=2, check=False) exists = res.returncode == 0 if exists: - link_up = "state UP" in res.stdout or "state UNKNOWN" in res.stdout + link_up = "state UP" in (res.stdout or "") or "state UNKNOWN" in (res.stdout or "") except Exception as exc: LOG.debug("ip link show %s failed: %s", iface, exc) if exists: try: - res = subprocess.run( - ["ip", "-4", "addr", "show", iface], - capture_output=True, - text=True, - timeout=2, - check=False, - ) - for line in res.stdout.splitlines(): + res = run_cmd(["ip", "-4", "addr", "show", iface], capture_output=True, text=True, timeout=2, check=False) + for line in (res.stdout or "").splitlines(): if "inet " in line: parts = line.strip().split() if len(parts) >= 2: @@ -287,14 +276,8 @@ def _determine_speed(self, iface: str) -> Optional[int]: # ethtool fallback (mostly for wired NICs) try: - res = subprocess.run( - ["ethtool", iface], - capture_output=True, - text=True, - timeout=2, - check=False, - ) - for line in res.stdout.splitlines(): + res = run_cmd(["ethtool", iface], capture_output=True, text=True, timeout=2, check=False) + for line in (res.stdout or "").splitlines(): if "Speed:" in line and "Mb/s" in line: tokens = "".join(ch for ch in line if ch.isdigit()) if tokens: @@ -306,14 +289,8 @@ def _determine_speed(self, iface: str) -> Optional[int]: # Wi-Fi bitrate via `iw` try: - res = subprocess.run( - ["iw", "dev", iface, "link"], - capture_output=True, - text=True, - timeout=2, - check=False, - ) - for line in res.stdout.splitlines(): + res = run_cmd(["iw", "dev", iface, "link"], capture_output=True, text=True, timeout=2, check=False) + for line in (res.stdout or "").splitlines(): if "tx bitrate" in line.lower(): parts = line.split() for idx, token in enumerate(parts): @@ -393,22 +370,15 @@ def _ensure_traffic_control(self, iface: str) -> None: env = os.environ.copy() env["WAN_IF_OVERRIDE"] = iface try: - subprocess.run( - [str(self.traffic_init_script)], - cwd=str(self.repo_root), - env=env, - check=True, - text=True, - ) + run_cmd([str(self.traffic_init_script)], cwd=str(self.repo_root), env=env, check=True, text=True) LOG.info("Re-applied traffic control on %s", iface) except subprocess.CalledProcessError as exc: LOG.error("Traffic control initialization failed: %s", exc) def _reapply_nat(self, iface: str) -> None: try: - subprocess.run(["iptables", "-t", "nat", "-F"], check=True) - subprocess.run( - [ + run_cmd(["iptables", "-t", "nat", "-F"], check=True) + run_cmd([ "iptables", "-t", "nat", @@ -420,9 +390,7 @@ def _reapply_nat(self, iface: str) -> None: iface, "-j", "MASQUERADE", - ], - check=True, - ) + ], check=True) LOG.info("NAT POSTROUTING updated for %s", iface) except FileNotFoundError: LOG.warning("iptables not available; skipping NAT reapply") @@ -432,11 +400,7 @@ def _reapply_nat(self, iface: str) -> None: def _restart_services(self) -> None: for svc in self.services_to_restart: try: - subprocess.run( - ["systemctl", "try-restart", svc], - check=False, - timeout=30, - ) + run_cmd(["systemctl", "try-restart", svc], check=False, timeout=30) LOG.info("Triggered restart for %s", svc) except FileNotFoundError: LOG.warning("systemctl not found while restarting %s", svc) diff --git a/azazel_pi/core/notify.py b/azazel_pi/core/notify.py new file mode 100644 index 0000000..f1c4897 --- /dev/null +++ b/azazel_pi/core/notify.py @@ -0,0 +1,85 @@ +"""Mattermost notification helper with simple suppression logic. + +This module reads configuration from `azazel_pi.core.notify_config` and only +sends notifications when a Mattermost webhook is configured. + +Suppression is implemented with an in-memory last-sent map keyed by a +user-provided key (e.g. "signature:src_ip") and cooldown seconds from +config. +""" +from __future__ import annotations + +import json +import time +import urllib.request +import urllib.error +from typing import Any, Dict, Optional + +from . import notify_config + + +class MattermostNotifier: + def __init__(self) -> None: + cfg = notify_config.get("mattermost", {}) or {} + self.webhook = cfg.get("webhook_url") or notify_config.MATTERMOST_WEBHOOK_URL + self.channel = cfg.get("channel") or "azazel-alerts" + self.username = cfg.get("username") or "Azazel-Bot" + self.icon = cfg.get("icon_emoji") or ":shield:" + + # Suppression settings + suppress = notify_config.get("suppress", {}) or {} + self.cooldown_seconds = int(suppress.get("cooldown_seconds", 60) or 60) + + self.enabled = bool(self.webhook) + self._last_sent: Dict[str, float] = {} + + def _should_send(self, key: str) -> bool: + if not key: + return True + now = time.time() + last = self._last_sent.get(key) + if not last or (now - last) > self.cooldown_seconds: + self._last_sent[key] = now + return True + return False + + def notify(self, payload: Dict[str, Any], key: Optional[str] = None) -> bool: + """Send a notification payload (dict) to Mattermost via webhook. + + payload: dictionary with summary data. This will be rendered into a + simple text message. key: suppression key; if provided, repeated sends + within cooldown_seconds will be suppressed. + """ + if not self.enabled: + return False + + key = key or "" + if not self._should_send(key): + return False + + try: + text_lines = [f"*{payload.get('event','') }* — score {payload.get('score','')}"] + if payload.get("src_ip"): + text_lines.append(f"Source: {payload.get('src_ip')}") + if payload.get("mode"): + text_lines.append(f"Mode: {payload.get('mode')}") + if payload.get("actions"): + text_lines.append(f"Actions: {payload.get('actions')}") + + message = "\n".join(text_lines) + + body = { + "channel": self.channel, + "username": self.username, + "icon_emoji": self.icon, + "text": message, + } + + data = json.dumps(body).encode("utf-8") + req = urllib.request.Request(self.webhook, data=data, headers={"Content-Type": "application/json"}) + with urllib.request.urlopen(req, timeout=8) as resp: + return 200 <= resp.getcode() < 300 + except urllib.error.HTTPError: + return False + except Exception: + return False diff --git a/azazel_pi/core/offline_ai_evaluator.py b/azazel_pi/core/offline_ai_evaluator.py index 6851635..8686a38 100644 --- a/azazel_pi/core/offline_ai_evaluator.py +++ b/azazel_pi/core/offline_ai_evaluator.py @@ -131,16 +131,17 @@ def evaluate_threat(self, alert_data: Dict[str, Any]) -> Dict[str, Any]: """Enhanced threat evaluation with ML-inspired scoring""" # Handle both direct signature and nested alert structure - signature = "" - if "signature" in alert_data: - signature = alert_data["signature"].lower() - elif "alert" in alert_data and "signature" in alert_data["alert"]: - signature = alert_data["alert"]["signature"].lower() - - src_ip = alert_data.get("src_ip", "") - payload = alert_data.get("payload_printable", "").lower() - dest_port = alert_data.get("dest_port", 0) - proto = alert_data.get("proto", "").lower() + sig_val = None + if isinstance(alert_data, dict): + sig_val = alert_data.get("signature") + if sig_val is None and isinstance(alert_data.get("alert"), dict): + sig_val = alert_data.get("alert", {}).get("signature") + signature = str(sig_val or "").lower() + + src_ip = str(alert_data.get("src_ip", "") or "") + payload = str(alert_data.get("payload_printable", "") or "").lower() + dest_port = int(alert_data.get("dest_port") or 0) + proto = str(alert_data.get("proto", "") or "").lower() logger.debug(f"Evaluating signature: '{signature}'") diff --git a/azazel_pi/core/state_machine.py b/azazel_pi/core/state_machine.py index e4fc518..7b4d703 100644 --- a/azazel_pi/core/state_machine.py +++ b/azazel_pi/core/state_machine.py @@ -24,10 +24,18 @@ class State: @dataclass(frozen=True) class Event: - """An external event that may trigger a transition.""" + """An external event that may trigger a transition. + + Extended to carry optional network/meta information so downstream + consumers (scorer, daemon, enforcer) can act on IP/signature data. + """ name: str severity: int = 0 + src_ip: str | None = None + dest_ip: str | None = None + signature: str | None = None + details: dict | None = None @dataclass @@ -350,8 +358,23 @@ def _target_for_shield(self, now: float) -> str: def _target_for_normal(self, now: float) -> str: """Target state when desired mode is normal - handles step-down from higher modes.""" - # Normal mode can be reached from any mode when score is low enough - # No unlock delays apply when going to normal + # When score indicates 'normal', we normally step down to normal. + # However, if we're currently in a higher mode that has an unlock + # hold (e.g. lockdown -> shield unlock window), remain in the + # higher mode until its unlock timer expires. + if self.current_state.name == "lockdown": + unlock_at = self._unlock_until.get("shield", 0.0) + if now < unlock_at: + return "lockdown" + # unlock window expired: step down to shield first + return "shield" + if self.current_state.name == "shield": + unlock_at = self._unlock_until.get("portal", 0.0) + if now < unlock_at: + return "shield" + # unlock window expired: step down to portal + return "portal" + # otherwise allow normal return "normal" def _target_for_portal(self, now: float) -> str: diff --git a/azazel_pi/monitor/run_all.py b/azazel_pi/monitor/run_all.py index 8f5165f..6487213 100644 --- a/azazel_pi/monitor/run_all.py +++ b/azazel_pi/monitor/run_all.py @@ -4,6 +4,7 @@ import time import logging import subprocess +from azazel_pi.utils.cmd_runner import run as run_cmd from datetime import datetime, timedelta from ..core import notify_config as notice @@ -66,17 +67,17 @@ def reset_network_config(): except Exception as e: logging.error(f"Integrated system cleanup failed: {e}") # フォールバック: 従来のtc直接実行 - result = subprocess.run(["tc", "qdisc", "show", "dev", wan_iface], capture_output=True, text=True) + result = run_cmd(["tc", "qdisc", "show", "dev", wan_iface], capture_output=True, text=True) if "prio" in result.stdout or "netem" in result.stdout: - subprocess.run(["tc", "qdisc", "del", "dev", wan_iface, "root"], check=False) + run_cmd(["tc", "qdisc", "del", "dev", wan_iface, "root"], check=False) logging.info("Fallback: tc qdisc deleted directly") # ② NATテーブルの全ルールを一旦削除 - subprocess.run(["iptables", "-t", "nat", "-F"], check=False) + run_cmd(["iptables", "-t", "nat", "-F"], check=False) # ③ 内部LAN(172.16.0.0/24)からWAN出口(wlan1)へのMASQUERADEを再設定 - subprocess.run(["iptables", "-t", "nat", "-A", "POSTROUTING", - "-s", "172.16.0.0/24", "-o", wan_iface, "-j", "MASQUERADE"], check=True) + run_cmd(["iptables", "-t", "nat", "-A", "POSTROUTING", + "-o", wan_iface, "-j", "MASQUERADE"], check=False) logging.info("Internal LAN to WAN routing re-established.") logging.info("Network reset completed via integrated system.") diff --git a/azazel_pi/utils/cmd_runner.py b/azazel_pi/utils/cmd_runner.py new file mode 100644 index 0000000..28be436 --- /dev/null +++ b/azazel_pi/utils/cmd_runner.py @@ -0,0 +1,40 @@ +"""Central, test-injectable subprocess runner used across the project. + +This module exposes: +- run(cmd, **kwargs): proxy to the current runner (defaults to subprocess.run) +- set_runner(runner): set a custom runner for tests (callable with same signature) +- reset_runner(): restore default + +The runner should accept the same parameters as subprocess.run and return +an object with attributes: returncode, stdout, stderr when capture_output/text +are used. Tests can inject a lightweight callable (e.g., FakeSubprocess). +""" +from __future__ import annotations +import subprocess +from typing import Callable + +# Default runner is subprocess.run +_runner: Callable = subprocess.run + + +def run(cmd, **kwargs): + """Run command via the currently configured runner. + + Accepts the same args as subprocess.run and returns whatever the runner returns. + """ + return _runner(cmd, **kwargs) + + +def set_runner(runner: Callable): + """Set custom runner for tests. + + runner: callable(cmd, **kwargs) -> CompletedProcess-like + """ + global _runner + _runner = runner + + +def reset_runner(): + """Reset runner to subprocess.run.""" + global _runner + _runner = subprocess.run diff --git a/azazel_pi/utils/delay_action.py b/azazel_pi/utils/delay_action.py index 07b566e..b998838 100644 --- a/azazel_pi/utils/delay_action.py +++ b/azazel_pi/utils/delay_action.py @@ -59,14 +59,15 @@ def load_opencanary_ip() -> str: return OPENCANARY_IP +from azazel_pi.utils.cmd_runner import run as run_cmd + + def check_nft_table_exists(table_name: str = "azazel") -> bool: """nftablesテーブルが存在するかチェック""" try: - import subprocess - result = subprocess.run( - ["nft", "list", "table", "inet", table_name], - capture_output=True, text=True, timeout=10 - ) + result = run_cmd([ + "nft", "list", "table", "inet", table_name + ], capture_output=True, text=True, timeout=10) return result.returncode == 0 except Exception as e: logger.error(f"Failed to check nft table: {e}") @@ -76,19 +77,15 @@ def check_nft_table_exists(table_name: str = "azazel") -> bool: def ensure_nft_table_and_chain(): """必要なnftablesテーブルとチェーンを作成""" try: - import subprocess # テーブル作成(既存の場合は無視) - subprocess.run( - ["nft", "add", "table", "inet", "azazel"], - capture_output=True, timeout=10 - ) - + run_cmd(["nft", "add", "table", "inet", "azazel"], capture_output=True, timeout=10) + # DNATチェーン作成 - subprocess.run([ + run_cmd([ "nft", "add", "chain", "inet", "azazel", "prerouting", "{ type nat hook prerouting priority -100; }" ], capture_output=True, timeout=10) - + logger.info("nftables table and chain ensured") return True @@ -144,7 +141,6 @@ def _legacy_divert_to_opencanary(src_ip: str, dest_port: Optional[int] = None) - return False try: - import subprocess # DNATルールを構築 if dest_port: @@ -161,9 +157,9 @@ def _legacy_divert_to_opencanary(src_ip: str, dest_port: Optional[int] = None) - "nft", "add", "rule", "inet", "azazel", "prerouting", rule_match, rule_action ] - - result = subprocess.run(cmd, capture_output=True, text=True, timeout=15) - + + result = run_cmd(cmd, capture_output=True, text=True, timeout=15) + if result.returncode == 0: logger.info(f"[Legacy] DNAT rule added: {src_ip} -> {canary_ip}" + (f":{dest_port}" if dest_port else "")) @@ -171,13 +167,12 @@ def _legacy_divert_to_opencanary(src_ip: str, dest_port: Optional[int] = None) - else: logger.error(f"nft rule add failed: {result.stderr}") return False - - except subprocess.TimeoutExpired: - logger.error("nft command timeout") - return False + except Exception as e: - logger.error(f"Failed to add DNAT rule: {e}") + # run_cmd may raise various exceptions; treat as failure + logger.error(f"nft command failed or timed out: {e}") return False + def remove_divert_rule(src_ip: str, dest_port: Optional[int] = None) -> bool: @@ -213,25 +208,21 @@ def _legacy_remove_divert_rule(src_ip: str, dest_port: Optional[int] = None) -> logger.warning("⚠️ _legacy_remove_divert_rule は非推奨です。統合システムの修復を推奨します。") try: - import subprocess - # 該当するルールのハンドルを検索して削除 if dest_port: search_pattern = f"ip saddr {src_ip} tcp dport {dest_port}" else: search_pattern = f"ip saddr {src_ip}" - + # ルール一覧取得 - result = subprocess.run([ - "nft", "-a", "list", "table", "inet", "azazel" - ], capture_output=True, text=True, timeout=10) - + result = run_cmd(["nft", "-a", "list", "table", "inet", "azazel"], capture_output=True, text=True, timeout=10) + if result.returncode != 0: logger.warning("Failed to list nft rules") return False - + # 該当ルールのハンドルを探す - for line in result.stdout.split('\n'): + for line in (result.stdout or "").split('\n'): if search_pattern in line and "handle" in line: # ハンドル番号を抽出 handle = line.split("handle")[-1].strip() @@ -241,15 +232,15 @@ def _legacy_remove_divert_rule(src_ip: str, dest_port: Optional[int] = None) -> "nft", "delete", "rule", "inet", "azazel", "prerouting", "handle", handle ] - delete_result = subprocess.run(delete_cmd, capture_output=True, timeout=10) - + delete_result = run_cmd(delete_cmd, capture_output=True, timeout=10) + if delete_result.returncode == 0: logger.info(f"[Legacy] DNAT rule removed: {src_ip}") return True - + logger.warning(f"No matching DNAT rule found for {src_ip}") return False - + except Exception as e: logger.error(f"Failed to remove DNAT rule: {e}") return False @@ -286,11 +277,7 @@ def _legacy_list_active_diversions() -> list: logger.warning("⚠️ _legacy_list_active_diversions は非推奨です。統合システムの修復を推奨します。") try: - import subprocess - - result = subprocess.run([ - "nft", "list", "table", "inet", "azazel" - ], capture_output=True, text=True, timeout=10) + result = run_cmd(["nft", "list", "table", "inet", "azazel"], capture_output=True, text=True, timeout=10) if result.returncode != 0: return [] diff --git a/azazel_pi/utils/mattermost.py b/azazel_pi/utils/mattermost.py index e835bc8..8b27f7e 100644 --- a/azazel_pi/utils/mattermost.py +++ b/azazel_pi/utils/mattermost.py @@ -20,10 +20,10 @@ def _load_notify_config() -> Dict[str, Any]: """通知設定を読み込む""" + repo_root = Path(__file__).resolve().parents[2] config_paths = [ Path("/etc/azazel/notify.yaml"), - Path(__file__).parent.parent.parent / "configs" / "monitoring" / "notify.yaml", - Path(__file__).parent.parent / "configs" / "notify.yaml", + repo_root / "configs" / "notify.yaml", ] for config_path in config_paths: @@ -280,4 +280,4 @@ def test_mattermost_connection() -> bool: } success = send_alert_to_mattermost("Test", test_alert) - print(f"アラート送信テスト: {'成功' if success else '失敗'}") \ No newline at end of file + print(f"アラート送信テスト: {'成功' if success else '失敗'}") diff --git a/azazel_pi/utils/network_utils.py b/azazel_pi/utils/network_utils.py index 2c443e2..ce545e9 100644 --- a/azazel_pi/utils/network_utils.py +++ b/azazel_pi/utils/network_utils.py @@ -13,6 +13,7 @@ from pathlib import Path from typing import Dict, Optional, Any import yaml +from azazel_pi.utils.cmd_runner import run as run_cmd # ログ設定 logging.basicConfig(level=logging.INFO) @@ -41,32 +42,23 @@ def get_wlan_ap_status(interface: str = "wlan0") -> Dict[str, Any]: try: # インターフェース存在確認 - result = subprocess.run( - ["ip", "link", "show", interface], - capture_output=True, text=True, timeout=5 - ) + result = run_cmd(["ip", "link", "show", interface], capture_output=True, text=True, timeout=5) if result.returncode != 0: status["status"] = "not_found" return status - + # IPアドレス取得 - result = subprocess.run( - ["ip", "addr", "show", interface], - capture_output=True, text=True, timeout=5 - ) + result = run_cmd(["ip", "addr", "show", interface], capture_output=True, text=True, timeout=5) if result.returncode == 0: - for line in result.stdout.split('\n'): + for line in (result.stdout or "").split('\n'): if "inet " in line and "scope global" in line: status["ip_address"] = line.split()[1].split('/')[0] break - + # AP情報取得 - result = subprocess.run( - ["iw", "dev", interface, "info"], - capture_output=True, text=True, timeout=5 - ) + result = run_cmd(["iw", "dev", interface, "info"], capture_output=True, text=True, timeout=5) if result.returncode == 0: - for line in result.stdout.split('\n'): + for line in (result.stdout or "").split('\n'): if "type AP" in line: status["is_ap"] = True elif "type managed" in line: @@ -76,35 +68,30 @@ def get_wlan_ap_status(interface: str = "wlan0") -> Dict[str, Any]: status["channel"] = int(line.split()[1]) except (ValueError, IndexError): pass - + # SSID取得(hostapd経由) if status["is_ap"]: try: - result = subprocess.run( - ["hostapd_cli", "-i", interface, "status"], - capture_output=True, text=True, timeout=5 - ) + result = run_cmd(["hostapd_cli", "-i", interface, "status"], capture_output=True, text=True, timeout=5) if result.returncode == 0: - for line in result.stdout.split('\n'): + for line in (result.stdout or "").split('\n'): if line.startswith("ssid="): status["ssid"] = line.split('=', 1)[1] elif line.startswith("num_sta="): try: status["stations"] = int(line.split('=')[1]) - except ValueError: + except Exception: pass except Exception: pass - + status["status"] = "active" if status["is_ap"] is not None else "inactive" - + except Exception as e: logger.error(f"WLAN AP status check failed for {interface}: {e}") - status["status"] = "error" return status - def get_wlan_link_info(interface: str = "wlan1") -> Dict[str, Any]: """ WLAN STAインターフェースのリンク情報取得 @@ -127,7 +114,7 @@ def get_wlan_link_info(interface: str = "wlan1") -> Dict[str, Any]: try: # インターフェース存在確認 - result = subprocess.run(["ip", "link", "show", interface], capture_output=True, text=True, timeout=5) + result = run_cmd(["ip", "link", "show", interface], capture_output=True, text=True, timeout=5) if result.returncode != 0: info["status"] = "not_found" # ensure compatibility keys exist @@ -136,8 +123,8 @@ def get_wlan_link_info(interface: str = "wlan1") -> Dict[str, Any]: return info # IPv4 アドレス取得(第一の inet 行を使用) - result = subprocess.run(["ip", "-4", "addr", "show", interface], capture_output=True, text=True, timeout=5) - if result.returncode == 0: + result = run_cmd(["ip", "-4", "addr", "show", interface], capture_output=True, text=True, timeout=5) + if result.returncode == 0 and result.stdout: for line in result.stdout.splitlines(): if "inet " in line: parts = line.strip().split() @@ -146,7 +133,7 @@ def get_wlan_link_info(interface: str = "wlan1") -> Dict[str, Any]: break # 接続情報取得(iw経由) - result = subprocess.run(["iw", "dev", interface, "link"], capture_output=True, text=True, timeout=5) + result = run_cmd(["iw", "dev", interface, "link"], capture_output=True, text=True, timeout=5) if result.returncode == 0: out = result.stdout or "" if "Connected to" in out: @@ -196,6 +183,7 @@ def get_wlan_link_info(interface: str = "wlan1") -> Dict[str, Any]: info.setdefault("signal_dbm", None) return info + def get_active_profile() -> Optional[str]: """ 現在アクティブなネットワークプロファイル取得 @@ -289,8 +277,7 @@ def get_comprehensive_network_status() -> Dict[str, Any]: "wlan1_sta": get_wlan_link_info("wlan1"), "active_profile": get_active_profile(), "interface_stats": get_network_interfaces_stats(), - "timestamp": subprocess.run(["date", "+%Y-%m-%d %H:%M:%S"], - capture_output=True, text=True).stdout.strip() + "timestamp": run_cmd(["date", "+%Y-%m-%d %H:%M:%S"], capture_output=True, text=True).stdout.strip() } diff --git a/azctl/daemon.py b/azctl/daemon.py index 5a8295b..139eabe 100644 --- a/azctl/daemon.py +++ b/azctl/daemon.py @@ -11,6 +11,12 @@ from azazel_pi.core.scorer import ScoreEvaluator from azazel_pi.core.state_machine import Event, StateMachine +from azazel_pi.core.enforcer.traffic_control import get_traffic_control_engine +from azazel_pi.core.hybrid_threat_evaluator import evaluate_with_hybrid_system +try: + from azazel_pi.core.notify import MattermostNotifier +except Exception: + MattermostNotifier = None @dataclass @@ -19,34 +25,56 @@ class AzazelDaemon: scorer: ScoreEvaluator # Default decisions log path aligned with CLI expectations decisions_log: Path = field(default_factory=lambda: Path("/var/log/azazel/decisions.log")) + # Optional integrations + traffic_engine: object | None = field(default_factory=lambda: get_traffic_control_engine()) + notifier: object | None = field(default_factory=lambda: MattermostNotifier() if MattermostNotifier is not None else None) def process_events(self, events: Iterable[Event]) -> None: entries: List[dict] = [] for event in events: - score = self.scorer.evaluate([event]) - classification = self.scorer.classify(score) - evaluation = self.machine.apply_score(score) - actions = self.machine.get_actions_preset() - entries.append( - { - "event": event.name, - "score": score, - "classification": classification, - "average": evaluation["average"], - "desired_mode": evaluation["desired_mode"], - "target_mode": evaluation["target_mode"], - "mode": evaluation["applied_mode"], - "actions": actions, - } - ) - - # Persist each entry immediately so long-running consumers have up-to-date state - self._append_decisions([entries[-1]]) + # Reuse single-event processing for consistent side-effects + self.process_event(event) def process_event(self, event: Event) -> None: - """Process a single Event and append a decision entry immediately.""" - score = self.scorer.evaluate([event]) - classification = self.scorer.classify(score) + """Process a single Event and append a decision entry immediately. + + Prefer hybrid AI evaluation (Mock-LLM first, Ollama for low-confidence/unknown). + Fall back to ScoreEvaluator if AI evaluation is not available or fails. + """ + # Build alert dict from Event for AI evaluators + alert_data = { + "timestamp": getattr(event, "timestamp", None), + "signature": getattr(event, "signature", getattr(event, "name", "")), + "severity": getattr(event, "severity", None), + "src_ip": getattr(event, "src_ip", None), + "dest_ip": getattr(event, "dest_ip", None), + "proto": getattr(event, "proto", None), + "dest_port": getattr(event, "dest_port", None), + "details": getattr(event, "details", None), + # Provide decisions log path so async deep eval can persist results + "decisions_log": str(self.decisions_log) if getattr(self, 'decisions_log', None) is not None else None, + } + + # Try hybrid AI evaluation + try: + ai_result = evaluate_with_hybrid_system(alert_data) + # hybrid returns 'score' in 0-100 or 'risk' in 1-5; normalize to 0-100 + if isinstance(ai_result.get("score"), (int, float)): + score = int(ai_result.get("score")) + else: + # convert risk 1-5 -> 0-100 + risk = int(ai_result.get("risk", 1)) + score = (risk - 1) * 25 + classification = ai_result.get("category", "ai") + except Exception: + # AI failed: fallback to legacy scorer + try: + score = self.scorer.evaluate([event]) + classification = self.scorer.classify(score) + except Exception: + score = 0 + classification = "unknown" + evaluation = self.machine.apply_score(score) actions = self.machine.get_actions_preset() entry = { @@ -61,6 +89,48 @@ def process_event(self, event: Event) -> None: } self._append_decisions([entry]) + # If we have a source IP, attempt to apply traffic control based on the applied mode + try: + target_ip = getattr(event, "src_ip", None) + applied_mode = evaluation.get("applied_mode") or self.machine.current_state.name + if target_ip and self.traffic_engine: + # Apply or remove rules according to mode + if applied_mode and applied_mode != "normal": + try: + self.traffic_engine.apply_combined_action(target_ip, applied_mode) + except Exception as e: + # Log but continue + try: + import logging + + logging.getLogger(__name__).error(f"Traffic apply failed: {e}") + except Exception: + pass + else: + try: + self.traffic_engine.remove_rules_for_ip(target_ip) + except Exception: + pass + + # Send a notification about the event and applied action + if self.notifier: + try: + summary = { + "event": event.name, + "src_ip": getattr(event, "src_ip", None), + "mode": applied_mode, + "score": score, + "actions": actions, + } + # Use signature+ip as suppression key when possible + key = f"{getattr(event, 'signature', '')}:{getattr(event, 'src_ip', '')}" + self.notifier.notify(summary, key=key) + except Exception: + pass + except Exception: + # Keep daemon robust even if integrations fail + pass + # ------------------------------------------------------------------ # Persistence helpers # ------------------------------------------------------------------ @@ -149,3 +219,37 @@ def stop_decay_writer(self) -> None: self._decay_thread.join(timeout=2.0) except Exception: pass + + def start_periodic_cleanup(self, interval_seconds: int = 60, max_age_seconds: int = 3600) -> None: + """Start a background thread that periodically cleans expired traffic rules. + + This keeps the TrafficControlEngine's active rules trimmed during demos + without requiring an external scheduler. + """ + if not getattr(self, '_cleanup_thread', None): + self._cleanup_stop = threading.Event() + + def _worker(): + while not (self._cleanup_stop and self._cleanup_stop.is_set()): + try: + if self.traffic_engine: + try: + self.traffic_engine.cleanup_expired_rules(max_age_seconds) + except Exception: + pass + time.sleep(interval_seconds) + except Exception: + time.sleep(interval_seconds) + + t = threading.Thread(target=_worker, daemon=True) + self._cleanup_thread = t + t.start() + + def stop_periodic_cleanup(self) -> None: + try: + if getattr(self, '_cleanup_stop', None): + self._cleanup_stop.set() + if getattr(self, '_cleanup_thread', None): + self._cleanup_thread.join(timeout=2.0) + except Exception: + pass diff --git a/azctl/menu/core.py b/azctl/menu/core.py index e021df8..db94685 100644 --- a/azctl/menu/core.py +++ b/azctl/menu/core.py @@ -7,6 +7,7 @@ """ import subprocess +from azazel_pi.utils.cmd_runner import run as run_cmd import sys import time from dataclasses import dataclass @@ -452,7 +453,7 @@ def _get_current_status(self) -> Dict[str, Any]: services_active = 0 for service in services: try: - result = subprocess.run( + result = run_cmd( ["systemctl", "is-active", service], capture_output=True, text=True, timeout=5 ) diff --git a/azctl/menu/defense.py b/azctl/menu/defense.py index 69fd65f..24da5d2 100644 --- a/azctl/menu/defense.py +++ b/azctl/menu/defense.py @@ -7,6 +7,7 @@ """ import subprocess +from azazel_pi.utils.cmd_runner import run as run_cmd from pathlib import Path from typing import Optional, Dict, Any import os @@ -214,10 +215,8 @@ def _view_status(self) -> None: # Add services status if available try: import subprocess - suricata_status = subprocess.run(['systemctl', 'is-active', 'suricata'], - capture_output=True, text=True).stdout.strip() - canary_status = subprocess.run(['systemctl', 'is-active', 'opencanary'], - capture_output=True, text=True).stdout.strip() + suricata_status = run_cmd(['systemctl', 'is-active', 'suricata'], capture_output=True, text=True).stdout.strip() + canary_status = run_cmd(['systemctl', 'is-active', 'opencanary'], capture_output=True, text=True).stdout.strip() services_info = f"Suricata: {'✅' if suricata_status == 'active' else '❌'} | Canary: {'✅' if canary_status == 'active' else '❌'}" except: @@ -351,7 +350,7 @@ def _user_override_mode(self, mode: str, description: str) -> None: try: # Process events with azctl - result = subprocess.run( + result = run_cmd( ["python3", "-m", "azctl", "events", "--config", temp_config], capture_output=True, text=True, @@ -424,7 +423,7 @@ def _switch_mode(self, mode: str, description: str) -> None: try: # Process events with azctl - result = subprocess.run( + result = run_cmd( ["python3", "-m", "azctl", "events", "--config", temp_config_path], capture_output=True, text=True, diff --git a/azctl/menu/emergency.py b/azctl/menu/emergency.py index 7dc0bb5..d3eff3f 100644 --- a/azctl/menu/emergency.py +++ b/azctl/menu/emergency.py @@ -6,6 +6,7 @@ """ import subprocess +from azazel_pi.utils.cmd_runner import run as run_cmd import os from datetime import datetime from pathlib import Path @@ -70,7 +71,7 @@ def _emergency_lockdown(self) -> None: try: # Step 1: Switch to lockdown mode self.console.print("[blue]1. Switching to lockdown mode...[/blue]") - result = subprocess.run( + result = run_cmd( ["python3", "-m", "azctl", "events", "--mode", "lockdown"], capture_output=True, text=True, timeout=30 ) @@ -82,27 +83,27 @@ def _emergency_lockdown(self) -> None: # Step 2: Apply emergency firewall rules self.console.print("[blue]2. Applying emergency firewall rules...[/blue]") try: - subprocess.run(["sudo", "nft", "flush", "ruleset"], timeout=10) - subprocess.run([ + run_cmd(["sudo", "nft", "flush", "ruleset"], timeout=10) + run_cmd([ "sudo", "nft", "add", "table", "inet", "emergency" ], timeout=5) - subprocess.run([ + run_cmd([ "sudo", "nft", "add", "chain", "inet", "emergency", "input", "{", "type", "filter", "hook", "input", "priority", "0", ";", "policy", "drop", ";", "}" ], timeout=5) - subprocess.run([ + run_cmd([ "sudo", "nft", "add", "chain", "inet", "emergency", "forward", "{", "type", "filter", "hook", "forward", "priority", "0", ";", "policy", "drop", ";", "}" ], timeout=5) - subprocess.run([ + run_cmd([ "sudo", "nft", "add", "chain", "inet", "emergency", "output", "{", "type", "filter", "hook", "output", "priority", "0", ";", "policy", "drop", ";", "}" ], timeout=5) # Allow loopback - subprocess.run([ + run_cmd([ "sudo", "nft", "add", "rule", "inet", "emergency", "input", "iif", "lo", "accept" ], timeout=5) - subprocess.run([ + run_cmd([ "sudo", "nft", "add", "rule", "inet", "emergency", "output", "oif", "lo", "accept" ], timeout=5) @@ -113,7 +114,7 @@ def _emergency_lockdown(self) -> None: # Step 3: Disconnect wireless self.console.print("[blue]3. Disconnecting wireless connections...[/blue]") try: - subprocess.run(["sudo", "wpa_cli", "-i", self.wan_if, "disconnect"], timeout=10) + run_cmd(["sudo", "wpa_cli", "-i", self.wan_if, "disconnect"], timeout=10) self.console.print("[green]✓ Wireless connections disconnected[/green]") except Exception as e: self.console.print(f"[red]✗ Wireless disconnect failed: {e}[/red]") @@ -123,7 +124,7 @@ def _emergency_lockdown(self) -> None: services_to_stop = ["vector", "opencanary"] for service in services_to_stop: try: - subprocess.run(["sudo", "systemctl", "stop", f"{service}.service"], timeout=15) + run_cmd(["sudo", "systemctl", "stop", f"{service}.service"], timeout=15) self.console.print(f"[green]✓ {service} stopped[/green]") except Exception: self.console.print(f"[yellow]! {service} stop failed[/yellow]") @@ -159,13 +160,13 @@ def _reset_network(self) -> None: # Reset wpa_supplicant configuration self.console.print("[blue]1. Resetting Wi-Fi configuration...[/blue]") try: - subprocess.run(["sudo", "systemctl", "stop", "wpa_supplicant"], timeout=10) + run_cmd(["sudo", "systemctl", "stop", "wpa_supplicant"], timeout=10) # Backup and reset wpa_supplicant.conf - subprocess.run([ - "sudo", "cp", "/etc/wpa_supplicant/wpa_supplicant.conf", - f"/etc/wpa_supplicant/wpa_supplicant.conf.backup.{datetime.now().strftime('%Y%m%d_%H%M%S')}" - ], timeout=5) + run_cmd([ + "sudo", "cp", "/etc/wpa_supplicant/wpa_supplicant.conf", + f"/etc/wpa_supplicant/wpa_supplicant.conf.backup.{datetime.now().strftime('%Y%m%d_%H%M%S')}" + ], timeout=5) # Create minimal wpa_supplicant.conf minimal_config = """ctrl_interface=DIR=/var/run/wpa_supplicant GROUP=netdev @@ -175,12 +176,12 @@ def _reset_network(self) -> None: with open("/tmp/wpa_supplicant_reset.conf", "w") as f: f.write(minimal_config) - subprocess.run([ + run_cmd([ "sudo", "cp", "/tmp/wpa_supplicant_reset.conf", "/etc/wpa_supplicant/wpa_supplicant.conf" ], timeout=5) - subprocess.run(["sudo", "systemctl", "start", "wpa_supplicant"], timeout=10) + run_cmd(["sudo", "systemctl", "start", "wpa_supplicant"], timeout=10) self.console.print("[green]✓ Wi-Fi configuration reset[/green]") except Exception as e: @@ -189,10 +190,10 @@ def _reset_network(self) -> None: # Reset network interfaces self.console.print("[blue]2. Resetting network interfaces...[/blue]") try: - subprocess.run(["sudo", "ip", "link", "set", self.wan_if, "down"], timeout=5) - subprocess.run(["sudo", "ip", "link", "set", self.wan_if, "up"], timeout=5) - subprocess.run(["sudo", "ip", "link", "set", self.lan_if, "down"], timeout=5) - subprocess.run(["sudo", "ip", "link", "set", self.lan_if, "up"], timeout=5) + run_cmd(["sudo", "ip", "link", "set", self.wan_if, "down"], timeout=5) + run_cmd(["sudo", "ip", "link", "set", self.wan_if, "up"], timeout=5) + run_cmd(["sudo", "ip", "link", "set", self.lan_if, "down"], timeout=5) + run_cmd(["sudo", "ip", "link", "set", self.lan_if, "up"], timeout=5) self.console.print("[green]✓ Network interfaces reset[/green]") except Exception as e: self.console.print(f"[red]✗ Interface reset failed: {e}[/red]") @@ -200,12 +201,12 @@ def _reset_network(self) -> None: # Restart network services self.console.print("[blue]3. Restarting network services...[/blue]") services = ["dhcpcd", "hostapd"] - for service in services: - try: - subprocess.run(["sudo", "systemctl", "restart", service], timeout=15) - self.console.print(f"[green]✓ {service} restarted[/green]") - except Exception: - self.console.print(f"[yellow]! {service} restart failed[/yellow]") + for service in services: + try: + run_cmd(["sudo", "systemctl", "restart", service], timeout=15) + self.console.print(f"[green]✓ {service} restarted[/green]") + except Exception: + self.console.print(f"[yellow]! {service} restart failed[/yellow]") self.console.print("\n[bold green]Network configuration reset completed[/bold green]") @@ -235,13 +236,13 @@ def _system_report(self) -> None: report.write("SYSTEM INFORMATION\n") report.write("-" * 20 + "\n") try: - result = subprocess.run(["uname", "-a"], capture_output=True, text=True, timeout=5) + result = run_cmd(["uname", "-a"], capture_output=True, text=True, timeout=5) report.write(f"Kernel: {result.stdout.strip()}\n") except Exception: report.write("Kernel: Unable to determine\n") try: - result = subprocess.run(["uptime"], capture_output=True, text=True, timeout=5) + result = run_cmd(["uptime"], capture_output=True, text=True, timeout=5) report.write(f"Uptime: {result.stdout.strip()}\n") except Exception: report.write("Uptime: Unable to determine\n") @@ -294,7 +295,7 @@ def _system_report(self) -> None: services = ["azctl", "azctl-serve", "suricata", "opencanary", "vector"] for service in services: try: - result = subprocess.run( + result = run_cmd( ["systemctl", "is-active", f"{service}.service"], capture_output=True, text=True, timeout=5 ) @@ -309,14 +310,14 @@ def _system_report(self) -> None: report.write("SYSTEM RESOURCES\n") report.write("-" * 17 + "\n") try: - result = subprocess.run(["free", "-h"], capture_output=True, text=True, timeout=5) + result = run_cmd(["free", "-h"], capture_output=True, text=True, timeout=5) report.write("Memory Usage:\n") report.write(result.stdout) except Exception: report.write("Memory Usage: Unable to determine\n") try: - result = subprocess.run(["df", "-h", "/"], capture_output=True, text=True, timeout=5) + result = run_cmd(["df", "-h", "/"], capture_output=True, text=True, timeout=5) report.write("\nDisk Usage:\n") report.write(result.stdout) except Exception: @@ -328,7 +329,7 @@ def _system_report(self) -> None: report.write("RECENT SYSTEM LOGS\n") report.write("-" * 19 + "\n") try: - result = subprocess.run( + result = run_cmd( ["journalctl", "-n", "20", "--no-pager"], capture_output=True, text=True, timeout=10 ) diff --git a/azctl/menu/monitoring.py b/azctl/menu/monitoring.py index 9361cf2..a3912a5 100644 --- a/azctl/menu/monitoring.py +++ b/azctl/menu/monitoring.py @@ -6,6 +6,7 @@ """ import subprocess +from azazel_pi.utils.cmd_runner import run as run_cmd from typing import Optional from rich.console import Console @@ -44,7 +45,7 @@ def _live_decision_log(self) -> None: self.console.print() try: - result = subprocess.run( + result = run_cmd( ["tail", "-f", "/var/log/azazel/decisions.log"], timeout=30 ) @@ -64,7 +65,7 @@ def _suricata_alerts(self) -> None: self.console.print(Text("─" * len("Recent Suricata Alerts"), style="dim")) try: - result = subprocess.run( + result = run_cmd( ["journalctl", "-u", "suricata", "-n", "50", "--no-pager"], capture_output=True, text=True, timeout=10 ) @@ -96,7 +97,7 @@ def _system_logs(self) -> None: self.console.print(Text("─" * len("Recent System Messages"), style="dim")) try: - result = subprocess.run( + result = run_cmd( ["journalctl", "-n", "30", "--no-pager"], capture_output=True, text=True, timeout=10 ) @@ -138,7 +139,7 @@ def _security_logs(self) -> None: for cmd, label in commands: self.console.print(f"\n[bold cyan]{label} Events:[/bold cyan]") try: - result = subprocess.run(cmd, capture_output=True, text=True, timeout=5) + result = run_cmd(cmd, capture_output=True, text=True, timeout=5) if result.returncode == 0 and result.stdout.strip(): for line in result.stdout.split('\n')[-10:]: if line.strip(): diff --git a/azctl/menu/services.py b/azctl/menu/services.py index 8a75faa..221d190 100644 --- a/azctl/menu/services.py +++ b/azctl/menu/services.py @@ -7,6 +7,7 @@ """ import subprocess +from azazel_pi.utils.cmd_runner import run as run_cmd from typing import List, Tuple from rich.console import Console @@ -77,7 +78,7 @@ def _get_service_info(self, service_name: str) -> Tuple[str, str, str]: """Get service status information.""" try: # Get service status - status_result = subprocess.run( + status_result = run_cmd( ["systemctl", "is-active", service_name], capture_output=True, text=True, timeout=5 ) @@ -86,7 +87,7 @@ def _get_service_info(self, service_name: str) -> Tuple[str, str, str]: status = "🟢 ACTIVE" # Get when service started - since_result = subprocess.run( + since_result = run_cmd( ["systemctl", "show", service_name, "--property=ActiveEnterTimestamp", "--value"], capture_output=True, text=True, timeout=5 ) @@ -126,7 +127,7 @@ def _manage_service(self, service_name: str, display_name: str) -> None: # Get current status try: - status_result = subprocess.run( + status_result = run_cmd( ["systemctl", "is-active", service_name], capture_output=True, text=True, timeout=5 ) @@ -188,7 +189,7 @@ def _control_service(self, service_name: str, action: str, display_name: str) -> self.console.print(f"[blue]{action.title()}ing {display_name}...[/blue]") try: - result = subprocess.run( + result = run_cmd( ["sudo", "systemctl", action, service_name], capture_output=True, text=True, timeout=30 ) @@ -210,7 +211,7 @@ def _show_service_logs(self, service_name: str, display_name: str) -> None: self.console.print(Text("─" * len(f"{display_name} Recent Logs"), style="dim")) try: - result = subprocess.run( + result = run_cmd( ["journalctl", "-u", service_name, "-n", "50", "--no-pager"], capture_output=True, text=True, timeout=15 ) @@ -245,7 +246,7 @@ def _show_service_details(self, service_name: str, display_name: str) -> None: self.console.print(Text("─" * len(f"{display_name} Service Details"), style="dim")) try: - result = subprocess.run( + result = run_cmd( ["systemctl", "status", service_name, "--no-pager", "-l"], capture_output=True, text=True, timeout=10 ) @@ -292,7 +293,7 @@ def _restart_all_services(self) -> None: for service in services: self.console.print(f"[blue]Restarting {service}...[/blue]") try: - result = subprocess.run( + result = run_cmd( ["sudo", "systemctl", "restart", service], capture_output=True, text=True, timeout=30 ) diff --git a/azctl/menu/system.py b/azctl/menu/system.py index 9e7a20b..9c8cc89 100644 --- a/azctl/menu/system.py +++ b/azctl/menu/system.py @@ -6,6 +6,7 @@ """ import subprocess +from azazel_pi.utils.cmd_runner import run as run_cmd from typing import Optional, Any from rich.console import Console @@ -127,7 +128,7 @@ def _system_resources(self) -> None: # Disk Information self.console.print("[bold cyan]Disk Usage:[/bold cyan]") try: - result = subprocess.run(['df', '-h', '/'], capture_output=True, text=True, timeout=5) + result = run_cmd(['df', '-h', '/'], capture_output=True, text=True, timeout=5) if result.returncode == 0: lines = result.stdout.strip().split('\n') if len(lines) >= 2: @@ -271,8 +272,8 @@ def _process_list(self) -> None: self.console.print(Text("─" * len("Running Processes"), style="dim")) try: - result = subprocess.run( - ['ps', 'aux', '--sort=-pcpu'], + result = run_cmd( + ['ps', 'aux', '--sort=-pcpu'], capture_output=True, text=True, timeout=10 ) diff --git a/azctl/menu/wifi.py b/azctl/menu/wifi.py index 2e906ff..f5a4217 100644 --- a/azctl/menu/wifi.py +++ b/azctl/menu/wifi.py @@ -8,6 +8,7 @@ import re import subprocess +from azazel_pi.utils.cmd_runner import run as run_cmd import time from typing import List, Dict, Optional, Tuple, Any @@ -91,7 +92,7 @@ def _check_wifi_tools(self) -> bool: missing_tools = [] for tool in required_tools: - result = subprocess.run(['which', tool], capture_output=True, text=True) + result = run_cmd(['which', tool], capture_output=True, text=True) if result.returncode != 0: missing_tools.append(tool) @@ -105,7 +106,7 @@ def _check_wifi_tools(self) -> bool: def _get_wifi_interfaces(self) -> List[str]: """Get list of available Wi-Fi interfaces.""" try: - result = subprocess.run(['iw', 'dev'], capture_output=True, text=True, timeout=5) + result = run_cmd(['iw', 'dev'], capture_output=True, text=True, timeout=5) if result.returncode != 0: return [] @@ -155,7 +156,7 @@ def _wifi_scan_and_connect(self, interface: str) -> None: scan_task = progress.add_task("Scanning networks...", total=100) # Perform scan - result = subprocess.run( + result = run_cmd( ['sudo', 'iw', 'dev', interface, 'scan'], capture_output=True, text=True, timeout=15 ) @@ -371,7 +372,7 @@ def _connect_to_wifi_network(self, interface: str, network: Dict[str, Any]) -> N if network_id is None: # Create new network - result = subprocess.run( + result = run_cmd( ['sudo', 'wpa_cli', '-i', interface, 'add_network'], capture_output=True, text=True, timeout=10 ) @@ -384,23 +385,23 @@ def _connect_to_wifi_network(self, interface: str, network: Dict[str, Any]) -> N created_new = True # Configure network - subprocess.run(['sudo', 'wpa_cli', '-i', interface, 'set_network', network_id, 'ssid', f'"{ssid}"'], + run_cmd(['sudo', 'wpa_cli', '-i', interface, 'set_network', network_id, 'ssid', f'"{ssid}"'], capture_output=True, timeout=10) if is_open: - subprocess.run(['sudo', 'wpa_cli', '-i', interface, 'set_network', network_id, 'key_mgmt', 'NONE'], + run_cmd(['sudo', 'wpa_cli', '-i', interface, 'set_network', network_id, 'key_mgmt', 'NONE'], capture_output=True, timeout=10) else: - subprocess.run(['sudo', 'wpa_cli', '-i', interface, 'set_network', network_id, 'psk', f'"{passphrase}"'], + run_cmd(['sudo', 'wpa_cli', '-i', interface, 'set_network', network_id, 'psk', f'"{passphrase}"'], capture_output=True, timeout=10) # Attempt connection with Progress() as progress: connect_task = progress.add_task("Connecting...", total=100) - subprocess.run(['sudo', 'wpa_cli', '-i', interface, 'enable_network', network_id], + run_cmd(['sudo', 'wpa_cli', '-i', interface, 'enable_network', network_id], capture_output=True, timeout=10) - subprocess.run(['sudo', 'wpa_cli', '-i', interface, 'select_network', network_id], + run_cmd(['sudo', 'wpa_cli', '-i', interface, 'select_network', network_id], capture_output=True, timeout=10) # Wait for connection with timeout @@ -408,7 +409,7 @@ def _connect_to_wifi_network(self, interface: str, network: Dict[str, Any]) -> N for i in range(20): # 10 seconds progress.update(connect_task, completed=(i + 1) * 5) - result = subprocess.run(['wpa_cli', '-i', interface, 'status'], + result = run_cmd(['wpa_cli', '-i', interface, 'status'], capture_output=True, text=True, timeout=5) if result.returncode == 0: @@ -423,9 +424,9 @@ def _connect_to_wifi_network(self, interface: str, network: Dict[str, Any]) -> N if connected: # Get IP address - subprocess.run(['sudo', 'dhcpcd', '-n', interface], capture_output=True, timeout=10) + run_cmd(['sudo', 'dhcpcd', '-n', interface], capture_output=True, timeout=10) # Save configuration - subprocess.run(['sudo', 'wpa_cli', '-i', interface, 'save_config'], capture_output=True, timeout=10) + run_cmd(['sudo', 'wpa_cli', '-i', interface, 'save_config'], capture_output=True, timeout=10) self.console.print(f"[green]✓ Successfully connected to {ssid}[/green]") @@ -437,14 +438,11 @@ def _connect_to_wifi_network(self, interface: str, network: Dict[str, Any]) -> N # Rollback if created_new and network_id: - subprocess.run(['sudo', 'wpa_cli', '-i', interface, 'remove_network', network_id], - capture_output=True, timeout=10) + run_cmd(['sudo', 'wpa_cli', '-i', interface, 'remove_network', network_id], capture_output=True, timeout=10) if current_id: - subprocess.run(['sudo', 'wpa_cli', '-i', interface, 'select_network', current_id], - capture_output=True, timeout=10) - subprocess.run(['sudo', 'wpa_cli', '-i', interface, 'reassociate'], - capture_output=True, timeout=10) + run_cmd(['sudo', 'wpa_cli', '-i', interface, 'select_network', current_id], capture_output=True, timeout=10) + run_cmd(['sudo', 'wpa_cli', '-i', interface, 'reassociate'], capture_output=True, timeout=10) except Exception as e: self.console.print(f"[red]Connection error: {e}[/red]") @@ -454,7 +452,7 @@ def _connect_to_wifi_network(self, interface: str, network: Dict[str, Any]) -> N def _find_wifi_network_id(self, ssid: str, interface: str) -> Optional[str]: """Find network ID for given SSID.""" try: - result = subprocess.run(['wpa_cli', '-i', interface, 'list_networks'], + result = run_cmd(['wpa_cli', '-i', interface, 'list_networks'], capture_output=True, text=True, timeout=5) if result.returncode != 0: return None @@ -471,7 +469,7 @@ def _find_wifi_network_id(self, ssid: str, interface: str) -> Optional[str]: def _get_current_wifi_connection(self, interface: str) -> Tuple[Optional[str], Optional[str], Optional[str]]: """Get current Wi-Fi connection info.""" try: - result = subprocess.run(['wpa_cli', '-i', interface, 'status'], + result = run_cmd(['wpa_cli', '-i', interface, 'status'], capture_output=True, text=True, timeout=5) if result.returncode != 0: return None, None, None @@ -498,7 +496,7 @@ def _wifi_show_current_connection(self, interface: str) -> None: try: # Get wpa_cli status - result = subprocess.run(['wpa_cli', '-i', interface, 'status'], + result = run_cmd(['wpa_cli', '-i', interface, 'status'], capture_output=True, text=True, timeout=5) if result.returncode != 0: @@ -514,7 +512,7 @@ def _wifi_show_current_connection(self, interface: str) -> None: status_info[key] = value # Get IP information - ip_result = subprocess.run(['ip', '-4', 'addr', 'show', interface], + ip_result = run_cmd(['ip', '-4', 'addr', 'show', interface], capture_output=True, text=True, timeout=5) ip_addr = "Not assigned" @@ -560,7 +558,7 @@ def _wifi_disconnect(self, interface: str) -> None: return try: - result = subprocess.run(['sudo', 'wpa_cli', '-i', interface, 'disconnect'], + result = run_cmd(['sudo', 'wpa_cli', '-i', interface, 'disconnect'], capture_output=True, text=True, timeout=10) if result.returncode == 0: @@ -578,7 +576,7 @@ def _wifi_saved_networks(self, interface: str) -> None: self._print_section_header(f"Saved Wi-Fi Networks - {interface}") try: - result = subprocess.run(['wpa_cli', '-i', interface, 'list_networks'], + result = run_cmd(['wpa_cli', '-i', interface, 'list_networks'], capture_output=True, text=True, timeout=5) if result.returncode != 0: @@ -640,18 +638,16 @@ def _wifi_saved_networks(self, interface: str) -> None: # Delete network net_id = choice[1:] if Confirm.ask(f"Delete network ID {net_id}?", default=False): - result = subprocess.run(['sudo', 'wpa_cli', '-i', interface, 'remove_network', net_id], - capture_output=True, text=True, timeout=10) + result = run_cmd(['sudo', 'wpa_cli', '-i', interface, 'remove_network', net_id], capture_output=True, text=True, timeout=10) if result.returncode == 0: - subprocess.run(['sudo', 'wpa_cli', '-i', interface, 'save_config'], - capture_output=True, timeout=10) + run_cmd(['sudo', 'wpa_cli', '-i', interface, 'save_config'], capture_output=True, timeout=10) self.console.print(f"[green]✓ Network {net_id} deleted[/green]") else: self.console.print(f"[red]✗ Failed to delete network {net_id}[/red]") elif choice.isdigit(): # Enable/disable network net_id = choice - result = subprocess.run(['sudo', 'wpa_cli', '-i', interface, 'enable_network', net_id], + result = run_cmd(['sudo', 'wpa_cli', '-i', interface, 'enable_network', net_id], capture_output=True, text=True, timeout=10) if result.returncode == 0: self.console.print(f"[green]✓ Network {net_id} enabled[/green]") diff --git a/bin/azazel-qos-apply.sh b/bin/azazel-qos-apply.sh index 9baea15..0df4745 100755 --- a/bin/azazel-qos-apply.sh +++ b/bin/azazel-qos-apply.sh @@ -7,14 +7,55 @@ run() { if [[ "$DRY_RUN" == "1" ]]; then echo "+ $*" else - eval "$@" + local args=("$@") + local argc=${#args[@]} + if (( argc >= 2 )); then + local guard_idx=$((argc - 2)) + local fallback_idx=$((argc - 1)) + if [[ "${args[$guard_idx]}" == "||" ]]; then + local fallback="${args[$fallback_idx]}" + unset "args[$fallback_idx]" + unset "args[$guard_idx]" + args=("${args[@]}") + if "${args[@]}"; then + return 0 + fi + eval "$fallback" + return $? + fi + fi + "${args[@]}" fi } +run_nft_block() { + local snippet="$1" + if [[ "$DRY_RUN" == "1" ]]; then + printf "+ nft -f - <<'EOF'\n%s\nEOF\n" "$snippet" + return 0 + fi + nft -f - <<<"$snippet" +} + CSV="${1:-configs/network/privileged.csv}" CFG="${CFG:-configs/network/azazel.yaml}" MODE="${MODE:-verify}" +ensure_nft_primitives() { + if ! nft list table inet azazel >/dev/null 2>&1; then + run nft add table inet azazel + fi + if ! nft list chain inet azazel prerouting >/dev/null 2>&1; then + run_nft_block "add chain inet azazel prerouting { type filter hook prerouting priority mangle; }" + fi + if ! nft list set inet azazel v4ipmac >/dev/null 2>&1; then + run_nft_block "add set inet azazel v4ipmac { type ipv4_addr . ether_addr; }" + fi + if ! nft list set inet azazel v4priv >/dev/null 2>&1; then + run_nft_block "add set inet azazel v4priv { type ipv4_addr; flags interval; }" + fi +} + if [[ "$DRY_RUN" == "1" ]]; then for cmd in nft ip; do command -v "$cmd" >/dev/null 2>&1 || { echo "missing command: $cmd" >&2; exit 1; } @@ -45,7 +86,8 @@ else fi fi -# Prepare sets +# Prepare nftables table/sets so that flushing never fails +ensure_nft_primitives run nft flush set inet azazel v4ipmac '||' true run nft flush set inet azazel v4priv '||' true @@ -55,8 +97,8 @@ for line in "${LINES[@]}"; do IFS=',' read -r IP MAC NOTE <<<"$line" IP=$(echo "$IP" | xargs); MAC=$(echo "$MAC" | xargs) [[ -n "$IP" && -n "$MAC" ]] || continue - run nft add element inet azazel v4ipmac "{ $IP . $MAC : $MARK_PREMIUM }" - run nft add element inet azazel v4priv "{ $IP }" + run_nft_block "add element inet azazel v4ipmac { $IP . $MAC }" + run_nft_block "add element inet azazel v4priv { $IP }" done # Rebuild prerouting rules diff --git a/bin/azazel-traffic-init.sh b/bin/azazel-traffic-init.sh index 0256e98..6cfe0b7 100755 --- a/bin/azazel-traffic-init.sh +++ b/bin/azazel-traffic-init.sh @@ -7,12 +7,88 @@ run() { if [[ "$DRY_RUN" == "1" ]]; then echo "+ $*" else - eval "$@" + local args=("$@") + local argc=${#args[@]} + if (( argc >= 2 )); then + local guard_idx=$((argc - 2)) + local fallback_idx=$((argc - 1)) + if [[ "${args[$guard_idx]}" == "||" ]]; then + local fallback="${args[$fallback_idx]}" + unset "args[$fallback_idx]" + unset "args[$guard_idx]" + args=("${args[@]}") + if "${args[@]}"; then + return 0 + fi + eval "$fallback" + return $? + fi + fi + "${args[@]}" + fi +} + +run_nft_block() { + local snippet="$1" + if [[ "$DRY_RUN" == "1" ]]; then + printf "+ nft -f - <<'EOF'\n%s\nEOF\n" "$snippet" + return 0 fi + nft -f - <<<"$snippet" } CFG="${1:-configs/network/azazel.yaml}" +# Flag indicating the target interface fundamentally cannot host a root qdisc. +SKIP_TC_SETUP=0 + +ensure_htb_root_qdisc() { + if run tc qdisc replace dev "$WAN_IF" root handle 1: htb default 30; then + return 0 + fi + + local existing_qdisc + existing_qdisc=$(tc qdisc show dev "$WAN_IF" root 2>/dev/null | head -n1 || true) + echo "tc replace failed on ${WAN_IF} (existing: ${existing_qdisc:-unknown}), retrying with delete/add" >&2 + + run tc qdisc del dev "$WAN_IF" root '||' true + if [ -z "${existing_qdisc}" ] || echo "${existing_qdisc}" | grep -qi "noqueue"; then + # no existing root qdisc, try add + if run tc qdisc add dev "$WAN_IF" root handle 1: htb default 30; then + return 0 + else + echo "failed to create qdisc on ${WAN_IF}" >&2 + fi + else + echo "tc replace failed on ${WAN_IF} (existing: ${existing_qdisc:-unknown}), skipping add to avoid RTNETLINK conflicts" >&2 + fi + + existing_qdisc=$(tc qdisc show dev "$WAN_IF" root 2>/dev/null | head -n1 || true) + if [[ "$existing_qdisc" == *"noqueue"* ]]; then + echo "Interface ${WAN_IF} reports noqueue root qdisc; skipping tc class configuration" >&2 + SKIP_TC_SETUP=1 + return 0 + fi + + echo "Failed to program HTB root qdisc on ${WAN_IF} (existing: ${existing_qdisc:-unknown})" >&2 + return 1 +} + +ensure_nft_primitives() { + if ! nft list table inet azazel >/dev/null 2>&1; then + run nft add table inet azazel + fi + if ! nft list chain inet azazel prerouting >/dev/null 2>&1; then + run_nft_block "add chain inet azazel prerouting { type filter hook prerouting priority mangle; }" + fi + if ! nft list set inet azazel v4ipmac >/dev/null 2>&1; then + run_nft_block "add set inet azazel v4ipmac { type ipv4_addr . ether_addr; }" + fi + if ! nft list set inet azazel v4priv >/dev/null 2>&1; then + run_nft_block "add set inet azazel v4priv { type ipv4_addr; flags interval; }" + fi +} + # Validate dependencies for cmd in tc nft; do command -v "$cmd" >/dev/null 2>&1 || { echo "missing command: $cmd" >&2; exit 1; } @@ -73,33 +149,37 @@ fi [[ -n "$WAN_IF" && "$WAN_IF" != "null" ]] || { echo "wan_iface missing in $CFG and no fallback available" >&2; exit 1; } -# HTB root qdisc (idempotent replace) -run tc qdisc replace dev "$WAN_IF" root handle 1: htb default 30 - -# Create classes and filters mapping fwmark -> classid -for CLASS in premium standard best_effort restricted; do - MARK=$(yq -r ".mark_map.${CLASS}" "$CFG" 2>/dev/null || echo "0x10") - RATE=$(yq -r ".classes.${CLASS}.rate_kbps" "$CFG" 2>/dev/null || echo "10000")kbit - CEIL=$(yq -r ".classes.${CLASS}.ceil_kbps" "$CFG" 2>/dev/null || echo "10000")kbit - case "$CLASS" in - premium) CID=10 ;; - standard) CID=20 ;; - best_effort) CID=30 ;; - restricted) CID=40 ;; - esac - run tc class replace dev "$WAN_IF" parent 1: classid 1:${CID} htb rate "$RATE" ceil "$CEIL" - # IPv4/IPv6 fwmark filters - run tc filter replace dev "$WAN_IF" parent 1: protocol ip handle "$MARK" fw flowid 1:${CID} - run tc filter replace dev "$WAN_IF" parent 1: protocol ipv6 handle "$MARK" fw flowid 1:${CID} -done +# HTB root qdisc (idempotent with fallback) +ensure_htb_root_qdisc + +if [[ "$SKIP_TC_SETUP" != "1" ]]; then + # Create classes and filters mapping fwmark -> classid + for CLASS in premium standard best_effort restricted; do + MARK=$(yq -r ".mark_map.${CLASS}" "$CFG" 2>/dev/null || echo "0x10") + RATE=$(yq -r ".classes.${CLASS}.rate_kbps" "$CFG" 2>/dev/null || echo "10000")kbit + CEIL=$(yq -r ".classes.${CLASS}.ceil_kbps" "$CFG" 2>/dev/null || echo "10000")kbit + case "$CLASS" in + premium) CID=10 ;; + standard) CID=20 ;; + best_effort) CID=30 ;; + restricted) CID=40 ;; + esac + run tc class replace dev "$WAN_IF" parent 1: classid 1:${CID} htb rate "$RATE" ceil "$CEIL" + # IPv4/IPv6 fwmark filters + run tc filter replace dev "$WAN_IF" parent 1: protocol ip handle "$MARK" fw flowid 1:${CID} + run tc filter replace dev "$WAN_IF" parent 1: protocol ipv6 handle "$MARK" fw flowid 1:${CID} + done +else + echo "Skipping tc class/filter creation because ${WAN_IF} cannot host HTB root qdisc" >&2 +fi # nftables table and sets -run nft list table inet azazel >/dev/null 2>&1 || run nft add table inet azazel +ensure_nft_primitives run nft delete chain inet azazel prerouting 2>/dev/null || true -run nft add chain inet azazel prerouting '{ type filter hook prerouting priority mangle; }' +run_nft_block "add chain inet azazel prerouting { type filter hook prerouting priority mangle; }" run nft delete set inet azazel v4ipmac 2>/dev/null || true -run nft add set inet azazel v4ipmac '{ type ipv4_addr . ether_addr : mark; }' +run_nft_block "add set inet azazel v4ipmac { type ipv4_addr . ether_addr; }" run nft delete set inet azazel v4priv 2>/dev/null || true -run nft add set inet azazel v4priv '{ type ipv4_addr; flags interval; }' +run_nft_block "add set inet azazel v4priv { type ipv4_addr; flags interval; }" echo "initialized" diff --git a/configs/monitoring/notify.yaml b/configs/monitoring/notify.yaml deleted file mode 100644 index 89c5e2a..0000000 --- a/configs/monitoring/notify.yaml +++ /dev/null @@ -1,15 +0,0 @@ -# Demo notification configuration for Azazel-Pi -# WARNING: Replace `webhook_url` with your test webhook before enabling in a real environment. -mattermost: - enabled: true - webhook_url: "https://example.test/hooks/YOUR_TEST_WEBHOOK" - channel: "azazel-demo" - username: "Azazel-Demo-Bot" - icon_emoji: ":shield:" -# suppress / cooldown settings -suppress: - key_mode: "signature_ip_user" - cooldown_seconds: 10 - summary_interval_mins: 1 -paths: - decisions: "./decisions.log" diff --git a/configs/network/azazel.yaml b/configs/network/azazel.yaml index d19f6d4..82166ea 100644 --- a/configs/network/azazel.yaml +++ b/configs/network/azazel.yaml @@ -34,7 +34,8 @@ thresholds: t0_normal: 20 t1_shield: 50 t2_lockdown: 80 - unlock_wait_secs: { shield: 600, portal: 1800 } + # Shortened unlock windows for verification testing. Revert after test. + unlock_wait_secs: { shield: 30, portal: 90 } user_mode_timeout_mins: 3.0 notify: { level: warn } storage: { log_dir: "/var/log/azazel", retain_days: 14 } diff --git a/configs/notify.yaml b/configs/notify.yaml new file mode 100644 index 0000000..6f3a885 --- /dev/null +++ b/configs/notify.yaml @@ -0,0 +1,79 @@ +# Azazel notification and monitoring settings +# Updated for production use with minimal Mattermost configuration + +# 日本時間(UTC+9) +tz: "+09:00" + +# Mattermost 通知設定 +# 【運用方法】 +# 1. Mattermostで通知専用botユーザーを作成 +# 2. そのbotユーザーでWebhookを生成 +# 3. 通知先チャンネルは、Webhook作成時に指定 +# 4. 個別通知が必要なユーザーをnotify_usersに列挙 +mattermost: + enabled: true # 通知有効フラグ + webhook_url: "http://172.16.0.254:8065/hooks/nk1ze8whj3838rrnoe8qnby6ww" + notify_users: # 通知対象ユーザー(@mention) + - "admin" # 管理者ユーザー + - "security.team" # セキュリティチーム + # 必要に応じてユーザーを追加 + # - "oncall.engineer" # 待機エンジニア + # 注意: channel, username, icon_emojiはbotユーザーの設定を使用するため不要 + +# ログファイルパス +paths: + events: "/opt/azazel/logs/events.json" + opencanary: "/opt/azazel/logs/opencanary.log" + suricata_eve: "/var/log/suricata/eve.json" + decisions: "/var/log/azazel/decisions.log" + +# イベント抑止設定 +suppress: + # 選択肢: "signature", "signature_ip", "signature_ip_user", "signature_ip_user_session" + key_mode: "signature_ip_user" + cooldown_seconds: 60 # 同一イベントの抑止時間 + summary_interval_mins: 5 # サマリー通知間隔 + +# OpenCanary設定 +opencanary: + ip: "172.16.10.10" + ports: + - 22 # SSH + - 80 # HTTP + - 5432 # PostgreSQL + +# ネットワーク制御設定 +network: + # 遅延制御対象インタフェース(例: wlan1)。ランタイムでの検出を優先する場合は + # 環境変数 AZAZEL_WAN_IF を設定してください(例: export AZAZEL_WAN_IF=wlan1)。 + interface: "wlan1" # 遅延制御対象インタフェース + inactivity_minutes: 2 # 無活動判定時間 + delay: + base_ms: 500 # 基本遅延時間 + jitter_ms: 100 # 揺らぎ + +# AI脅威評価設定 +ai: + enabled: true # AI評価機能の有効/無効 + ollama_url: "http://127.0.0.1:11434/api/generate" + model: "phi3:mini" # 使用するLLMモデル + timeout: 30 # API呼び出しタイムアウト(秒) + max_payload_chars: 400 # ペイロード最大文字数 + fallback_on_error: true # エラー時のフォールバック有効 + model_alternatives: # 代替モデル候補 + - "qwen2.5:3b" + - "tinyllama" + policy_scripts: # AI判定によるポリシー適用スクリプト + delay: "/home/azazel/Azazel-Pi/scripts/ai_policy_delay.sh" + block: "/home/azazel/Azazel-Pi/scripts/ai_policy_block.sh" + +# Deep evaluation sampling and retry tuning (チューニング済み値) +deep_eval: + deep_sample_rate: 0.5 # 0.0-1.0 の確率で deep-eval をサンプリング(本番では 0.5 推奨) + deep_max_per_min: 10 # 1分あたりの最大 deep-eval 件数(負荷制御) + deep_eval_retries: 2 # deep-eval 呼び出しのリトライ回数 + deep_persist_retries: 3 # decisions.log への永続化リトライ回数 + +# 通知周りの挙動調整 +notify: + notify_retries: 2 diff --git a/docs/DEMO.md b/docs/DEMO.md index 283dc65..701d1be 100644 --- a/docs/DEMO.md +++ b/docs/DEMO.md @@ -25,7 +25,7 @@ 必要なファイル(このリポジトリで追加済み) - `scripts/eve_replay.py` — EVE JSON を指定ファイルに周期的に追記してリプレイするスクリプト - `configs/monitoring/notify_demo.yaml` — デモ用の Mattermost/webhook 設定(安全なテスト先を設定してください) -- `scripts/install_demo_notify.sh` — 既存の `configs/monitoring/notify.yaml` をバックアップしてデモ用設定をインストールする補助スクリプト +- `scripts/install_demo_notify.sh` — 既存の `configs/notify.yaml` をバックアップしてデモ用設定をインストールする補助スクリプト - `scripts/restore_notify.sh` — 既存の notify 設定を復元するスクリプト(デモ後に実行) 準備手順 @@ -39,7 +39,7 @@ pip3 install --user rich requests ```bash bash scripts/install_demo_notify.sh -# 成功すると configs/monitoring/notify.yaml が demo 設定に置き換わります +# 成功すると configs/notify.yaml が demo 設定に置き換わります ``` 3) EVE リプレイ先ファイルの準備(デフォルトの場所) diff --git a/docs/ja/E2E_RUN.md b/docs/ja/E2E_RUN.md new file mode 100644 index 0000000..19eebee --- /dev/null +++ b/docs/ja/E2E_RUN.md @@ -0,0 +1,48 @@ +# 実機 E2E 実行手順(概要) + +このドキュメントは、管理者が許可した実機環境で Azazel-Pi の E2E を安全に実行するための簡潔な手順を示します。 + +前提条件: +- 実行ユーザーが sudo 権限を持っていること +- テストに使う IP が影響範囲の少ない、許可済みのアドレスであること(例: 10.0.0.250) + +手順: + +1. スナップショット取得(実行前) + +```bash +sudo nft list ruleset > runtime/nft_snapshot.before.txt +sudo tc qdisc show dev > runtime/tc_snapshot.before.txt +``` + +2. テストイベント注入(AzazelDaemon を利用) + +- 既存の `runtime/e2e_run.py` スクリプトを利用して、AzazelDaemon.process_event() を呼び出します。 +- PYTHONPATH が必要な場合は `sudo env PYTHONPATH=$(pwd) python3 runtime/e2e_run.py` のように実行します。 + +3. decisions ログの確認 + +```bash +# 実行結果を確認 +cat runtime/e2e_decisions.log +``` + +4. クリーンアップ + +```bash +sudo env PYTHONPATH=$(pwd) python3 runtime/e2e_cleanup.py +``` + +5. スナップショット取得(実行後)と差分確認 + +```bash +sudo nft list ruleset > runtime/nft_snapshot.after.txt +sudo tc qdisc show dev > runtime/tc_snapshot.after.txt +# 差分 +diff -u runtime/nft_snapshot.before.txt runtime/nft_snapshot.after.txt > runtime/nft_diff.txt || true +diff -u runtime/tc_snapshot.before.txt runtime/tc_snapshot.after.txt > runtime/tc_diff.txt || true +``` + +注意: +- `tc`/`nft` のコマンドはシステムに依存します。特に `tc` の場合はインターフェース名の扱いに注意してください。 +- 実行前にバックアップを取り、必要であれば手動でのリストア手順を用意してください。 diff --git a/docs/ja/INSTALLATION.md b/docs/ja/INSTALLATION.md index 861a707..bf9e6bb 100644 --- a/docs/ja/INSTALLATION.md +++ b/docs/ja/INSTALLATION.md @@ -519,7 +519,7 @@ sudo systemctl restart azctl-unified.service 2. **内部ネットワーク設定**: wlan0インターフェースで172.16.0.254の内部APが動作することを確認 3. **Mattermost設定**: http://172.16.0.254:8065 でWebhookとチャンネル設定を完了 4. **防御モードのテスト**: 手動でモード遷移をトリガーして動作を確認 -5. **通知の設定**: configs/monitoring/notify.yaml でWebhook URLを設定 +5. **通知の設定**: configs/notify.yaml でWebhook URLを設定 6. **パフォーマンス監視**: 内蔵ツールを使用してシステムヘルスを追跡 7. **メンテナンス計画**: 更新とバックアップのスケジュールを確立 diff --git a/runtime/E2E_README.md b/runtime/E2E_README.md new file mode 100644 index 0000000..20f3a8a --- /dev/null +++ b/runtime/E2E_README.md @@ -0,0 +1,28 @@ +Azazel-Pi: E2E 実行アーティファクト + +このディレクトリに保存される典型的なアーティファクトと実行メモ: + +- e2e_decisions.log + - 実機実行で出力された decisions.log の抜粋(JSON 行) +- nft_snapshot.before.txt, nft_snapshot.after.txt, nft_snapshot.cleanup.txt + - `nft list ruleset` の実行結果(実行前/実行後/クリーンアップ後) +- tc_snapshot.before.txt, tc_snapshot.after.txt, tc_snapshot.cleanup.txt + - `tc qdisc show dev ` の実行結果(実行前/実行後/クリーンアップ後) +- nft_diff.txt, tc_diff.correct.txt + - before/after の差分(`diff -u` 出力) + +安全な手順(要 root): + +1. 実行前スナップショットを取得 + - sudo nft list ruleset > runtime/nft_snapshot.before.txt + - sudo tc qdisc show dev > runtime/tc_snapshot.before.txt +2. テスト用イベントを AzazelDaemon に注入して `process_event()` を呼ぶ +3. decisions.log(runtime/e2e_decisions.log)を確認 +4. cleanup: ルールを削除して復旧を確認 + - engine.remove_rules_for_ip() を呼ぶ +5. 実行後スナップショットを取得して差分を確認 + +注意事項: +- `nft` / `tc` による変更は即時にネットワークに影響します。必ず実行環境の許可を得てください。 +- 実行前にインターフェース名(例: wlan1, eth0)を正しく指定してください。 +- 本 README は実行ログを含みません。実際に行った操作は runtime/ 以下のファイルで確認できます。 diff --git a/runtime/demo_eve.json b/runtime/demo_eve.json index 8c52fd9..9f304a0 100644 --- a/runtime/demo_eve.json +++ b/runtime/demo_eve.json @@ -1,25 +1,100 @@ -{"event_type":"alert","timestamp":"2025-11-10T20:40:11.077768","src_ip":"10.0.0.5","dest_ip":"172.16.0.10","proto":"ICMP","dest_port":null,"alert":{"signature":"ET SCAN Potential scan detected (NMAP)","severity":30}} -{"event_type":"alert","timestamp":"2025-11-10T20:40:12.078096","src_ip":"198.51.100.23","dest_ip":"172.16.0.10","proto":"TCP","dest_port":22,"alert":{"signature":"ET BRUTEFORCE SSH Brute force attempt","severity":40}} -{"event_type":"alert","timestamp":"2025-11-10T20:40:13.078446","src_ip":"198.51.100.23","dest_ip":"172.16.0.10","proto":"TCP","dest_port":80,"alert":{"signature":"ET EXPLOIT Possible buffer overflow","severity":80}} -{"event_type":"alert","timestamp":"2025-11-10T20:40:14.078769","src_ip":"203.0.113.9","dest_ip":"172.16.0.10","proto":"UDP","dest_port":0,"alert":{"signature":"ET DOS Possible DDoS amplification","severity":60}} -{"event_type":"alert","timestamp":"2025-11-10T20:40:15.079103","src_ip":"10.0.0.5","dest_ip":"172.16.0.10","proto":"ICMP","dest_port":null,"alert":{"signature":"ET SCAN Potential scan detected (NMAP)","severity":30}} -{"event_type":"alert","timestamp":"2025-11-10T20:40:16.079430","src_ip":"198.51.100.23","dest_ip":"172.16.0.10","proto":"TCP","dest_port":22,"alert":{"signature":"ET BRUTEFORCE SSH Brute force attempt","severity":40}} -{"event_type":"alert","timestamp":"2025-11-10T20:40:17.079751","src_ip":"198.51.100.23","dest_ip":"172.16.0.10","proto":"TCP","dest_port":80,"alert":{"signature":"ET EXPLOIT Possible buffer overflow","severity":80}} -{"event_type":"alert","timestamp":"2025-11-10T20:40:18.080047","src_ip":"203.0.113.9","dest_ip":"172.16.0.10","proto":"UDP","dest_port":0,"alert":{"signature":"ET DOS Possible DDoS amplification","severity":60}} -{"event_type":"alert","timestamp":"2025-11-10T20:40:19.080444","src_ip":"10.0.0.5","dest_ip":"172.16.0.10","proto":"ICMP","dest_port":null,"alert":{"signature":"ET SCAN Potential scan detected (NMAP)","severity":30}} -{"event_type":"alert","timestamp":"2025-11-10T20:40:20.080806","src_ip":"198.51.100.23","dest_ip":"172.16.0.10","proto":"TCP","dest_port":22,"alert":{"signature":"ET BRUTEFORCE SSH Brute force attempt","severity":40}} -{"event_type":"alert","timestamp":"2025-11-10T20:40:21.081181","src_ip":"198.51.100.23","dest_ip":"172.16.0.10","proto":"TCP","dest_port":80,"alert":{"signature":"ET EXPLOIT Possible buffer overflow","severity":80}} -{"event_type":"alert","timestamp":"2025-11-10T20:40:22.081730","src_ip":"203.0.113.9","dest_ip":"172.16.0.10","proto":"UDP","dest_port":0,"alert":{"signature":"ET DOS Possible DDoS amplification","severity":60}} -{"event_type":"alert","timestamp":"2025-11-10T20:40:23.274659","src_ip":"10.0.0.5","dest_ip":"172.16.0.10","proto":"ICMP","dest_port":null,"alert":{"signature":"ET SCAN Potential scan detected (NMAP)","severity":30}} -{"event_type":"alert","timestamp":"2025-11-10T20:40:24.275063","src_ip":"198.51.100.23","dest_ip":"172.16.0.10","proto":"TCP","dest_port":22,"alert":{"signature":"ET BRUTEFORCE SSH Brute force attempt","severity":40}} -{"event_type":"alert","timestamp":"2025-11-10T20:40:25.275415","src_ip":"198.51.100.23","dest_ip":"172.16.0.10","proto":"TCP","dest_port":80,"alert":{"signature":"ET EXPLOIT Possible buffer overflow","severity":80}} -{"event_type":"alert","timestamp":"2025-11-10T20:40:26.275783","src_ip":"203.0.113.9","dest_ip":"172.16.0.10","proto":"UDP","dest_port":0,"alert":{"signature":"ET DOS Possible DDoS amplification","severity":60}} -{"event_type":"alert","timestamp":"2025-11-10T20:40:27.276093","src_ip":"10.0.0.5","dest_ip":"172.16.0.10","proto":"ICMP","dest_port":null,"alert":{"signature":"ET SCAN Potential scan detected (NMAP)","severity":30}} -{"event_type":"alert","timestamp":"2025-11-10T20:40:28.276461","src_ip":"198.51.100.23","dest_ip":"172.16.0.10","proto":"TCP","dest_port":22,"alert":{"signature":"ET BRUTEFORCE SSH Brute force attempt","severity":40}} -{"event_type":"alert","timestamp":"2025-11-10T20:40:29.276877","src_ip":"198.51.100.23","dest_ip":"172.16.0.10","proto":"TCP","dest_port":80,"alert":{"signature":"ET EXPLOIT Possible buffer overflow","severity":80}} -{"event_type":"alert","timestamp":"2025-11-10T20:40:30.277244","src_ip":"203.0.113.9","dest_ip":"172.16.0.10","proto":"UDP","dest_port":0,"alert":{"signature":"ET DOS Possible DDoS amplification","severity":60}} -{"event_type":"alert","timestamp":"2025-11-10T20:40:31.277590","src_ip":"10.0.0.5","dest_ip":"172.16.0.10","proto":"ICMP","dest_port":null,"alert":{"signature":"ET SCAN Potential scan detected (NMAP)","severity":30}} -{"event_type": "alert", "timestamp": "2025-11-10T20:46:44.281191", "src_ip": "198.51.100.23", "dest_ip": "172.16.0.10", "proto": "TCP", "dest_port": 22, "alert": {"signature": "ET BRUTEFORCE SSH Brute force attempt", "severity": 3}} -{"event_type": "alert", "timestamp": "2025-11-10T20:46:49.281480", "src_ip": "198.51.100.23", "dest_ip": "172.16.0.10", "proto": "TCP", "dest_port": 80, "alert": {"signature": "ET EXPLOIT Possible buffer overflow", "severity": 1}} -{"event_type": "alert", "timestamp": "2025-11-10T20:46:54.281771", "src_ip": "203.0.113.9", "dest_ip": "172.16.0.10", "proto": "UDP", "dest_port": 0, "alert": {"signature": "ET DOS Possible DDoS amplification", "severity": 1}} -{"event_type": "alert", "timestamp": "2025-11-10T20:46:59.282399", "src_ip": "10.0.0.5", "dest_ip": "172.16.0.10", "proto": "ICMP", "dest_port": null, "alert": {"signature": "ET SCAN Potential scan detected (NMAP)", "severity": 4}} +{"event_type":"alert","timestamp":"2025-11-11T12:52:02.086867+00:00","src_ip":"198.51.100.55","dest_ip":"172.16.0.10","proto":"TCP","dest_port":80,"alert":{"signature":"ET EXPLOIT Possible buffer overflow","severity":80}} +{"event_type":"alert","timestamp":"2025-11-11T12:52:02.187201+00:00","src_ip":"198.51.100.10","dest_ip":"172.16.0.10","proto":"UDP","dest_port":0,"alert":{"signature":"ET DOS Possible DDoS amplification","severity":80}} +{"event_type":"alert","timestamp":"2025-11-11T12:52:02.287475+00:00","src_ip":"198.51.100.11","dest_ip":"172.16.0.10","proto":"TCP","dest_port":80,"alert":{"signature":"ET EXPLOIT Possible buffer overflow","severity":80}} +{"event_type":"alert","timestamp":"2025-11-11T12:52:02.387703+00:00","src_ip":"198.51.100.33","dest_ip":"172.16.0.10","proto":"ICMP","dest_port":null,"alert":{"signature":"ET SCAN Potential scan detected (NMAP)","severity":80}} +{"event_type":"alert","timestamp":"2025-11-11T12:52:02.487929+00:00","src_ip":"198.51.100.74","dest_ip":"172.16.0.10","proto":"UDP","dest_port":0,"alert":{"signature":"ET DOS Possible DDoS amplification","severity":80}} +{"event_type":"alert","timestamp":"2025-11-11T12:52:02.588161+00:00","src_ip":"198.51.100.195","dest_ip":"172.16.0.10","proto":"ICMP","dest_port":null,"alert":{"signature":"ET SCAN Potential scan detected (NMAP)","severity":80}} +{"event_type":"alert","timestamp":"2025-11-11T12:52:02.688402+00:00","src_ip":"198.51.100.55","dest_ip":"172.16.0.10","proto":"ICMP","dest_port":null,"alert":{"signature":"ET SCAN Potential scan detected (NMAP)","severity":80}} +{"event_type":"alert","timestamp":"2025-11-11T12:52:02.788631+00:00","src_ip":"198.51.100.102","dest_ip":"172.16.0.10","proto":"TCP","dest_port":80,"alert":{"signature":"ET EXPLOIT Possible buffer overflow","severity":80}} +{"event_type":"alert","timestamp":"2025-11-11T12:52:02.888839+00:00","src_ip":"198.51.100.211","dest_ip":"172.16.0.10","proto":"TCP","dest_port":80,"alert":{"signature":"ET EXPLOIT Possible buffer overflow","severity":80}} +{"event_type":"alert","timestamp":"2025-11-11T12:52:02.989077+00:00","src_ip":"198.51.100.19","dest_ip":"172.16.0.10","proto":"ICMP","dest_port":null,"alert":{"signature":"ET SCAN Potential scan detected (NMAP)","severity":80}} +{"event_type":"alert","timestamp":"2025-11-11T12:52:03.089328+00:00","src_ip":"198.51.100.137","dest_ip":"172.16.0.10","proto":"TCP","dest_port":80,"alert":{"signature":"ET EXPLOIT Possible buffer overflow","severity":80}} +{"event_type":"alert","timestamp":"2025-11-11T12:52:03.189559+00:00","src_ip":"198.51.100.60","dest_ip":"172.16.0.10","proto":"TCP","dest_port":80,"alert":{"signature":"ET EXPLOIT Possible buffer overflow","severity":80}} +{"event_type":"alert","timestamp":"2025-11-11T12:52:03.289802+00:00","src_ip":"198.51.100.25","dest_ip":"172.16.0.10","proto":"TCP","dest_port":22,"alert":{"signature":"ET BRUTEFORCE SSH Brute force attempt","severity":80}} +{"event_type":"alert","timestamp":"2025-11-11T12:52:03.390022+00:00","src_ip":"198.51.100.36","dest_ip":"172.16.0.10","proto":"TCP","dest_port":22,"alert":{"signature":"ET BRUTEFORCE SSH Brute force attempt","severity":80}} +{"event_type":"alert","timestamp":"2025-11-11T12:52:03.490243+00:00","src_ip":"198.51.100.209","dest_ip":"172.16.0.10","proto":"ICMP","dest_port":null,"alert":{"signature":"ET SCAN Potential scan detected (NMAP)","severity":80}} +{"event_type":"alert","timestamp":"2025-11-11T12:52:03.590464+00:00","src_ip":"198.51.100.43","dest_ip":"172.16.0.10","proto":"UDP","dest_port":0,"alert":{"signature":"ET DOS Possible DDoS amplification","severity":80}} +{"event_type":"alert","timestamp":"2025-11-11T12:52:03.690675+00:00","src_ip":"198.51.100.142","dest_ip":"172.16.0.10","proto":"TCP","dest_port":80,"alert":{"signature":"ET EXPLOIT Possible buffer overflow","severity":80}} +{"event_type":"alert","timestamp":"2025-11-11T12:52:03.790884+00:00","src_ip":"198.51.100.96","dest_ip":"172.16.0.10","proto":"TCP","dest_port":80,"alert":{"signature":"ET EXPLOIT Possible buffer overflow","severity":80}} +{"event_type":"alert","timestamp":"2025-11-11T12:52:03.891098+00:00","src_ip":"198.51.100.239","dest_ip":"172.16.0.10","proto":"TCP","dest_port":80,"alert":{"signature":"ET EXPLOIT Possible buffer overflow","severity":80}} +{"event_type":"alert","timestamp":"2025-11-11T12:52:03.991293+00:00","src_ip":"198.51.100.100","dest_ip":"172.16.0.10","proto":"TCP","dest_port":80,"alert":{"signature":"ET EXPLOIT Possible buffer overflow","severity":80}} +{"event_type":"alert","timestamp":"2025-11-11T12:52:04.091590+00:00","src_ip":"198.51.100.40","dest_ip":"172.16.0.10","proto":"TCP","dest_port":22,"alert":{"signature":"ET BRUTEFORCE SSH Brute force attempt","severity":80}} +{"event_type":"alert","timestamp":"2025-11-11T12:52:04.191846+00:00","src_ip":"198.51.100.148","dest_ip":"172.16.0.10","proto":"ICMP","dest_port":null,"alert":{"signature":"ET SCAN Potential scan detected (NMAP)","severity":80}} +{"event_type":"alert","timestamp":"2025-11-11T12:52:04.292144+00:00","src_ip":"198.51.100.202","dest_ip":"172.16.0.10","proto":"UDP","dest_port":0,"alert":{"signature":"ET DOS Possible DDoS amplification","severity":80}} +{"event_type":"alert","timestamp":"2025-11-11T12:52:04.392402+00:00","src_ip":"198.51.100.62","dest_ip":"172.16.0.10","proto":"TCP","dest_port":22,"alert":{"signature":"ET BRUTEFORCE SSH Brute force attempt","severity":80}} +{"event_type":"alert","timestamp":"2025-11-11T12:52:04.492661+00:00","src_ip":"198.51.100.226","dest_ip":"172.16.0.10","proto":"TCP","dest_port":22,"alert":{"signature":"ET BRUTEFORCE SSH Brute force attempt","severity":80}} +{"event_type":"alert","timestamp":"2025-11-11T12:52:04.592954+00:00","src_ip":"198.51.100.211","dest_ip":"172.16.0.10","proto":"TCP","dest_port":22,"alert":{"signature":"ET BRUTEFORCE SSH Brute force attempt","severity":80}} +{"event_type":"alert","timestamp":"2025-11-11T12:52:04.693228+00:00","src_ip":"198.51.100.230","dest_ip":"172.16.0.10","proto":"UDP","dest_port":0,"alert":{"signature":"ET DOS Possible DDoS amplification","severity":80}} +{"event_type":"alert","timestamp":"2025-11-11T12:52:04.793502+00:00","src_ip":"198.51.100.71","dest_ip":"172.16.0.10","proto":"ICMP","dest_port":null,"alert":{"signature":"ET SCAN Potential scan detected (NMAP)","severity":80}} +{"event_type":"alert","timestamp":"2025-11-11T12:52:04.893744+00:00","src_ip":"198.51.100.4","dest_ip":"172.16.0.10","proto":"TCP","dest_port":22,"alert":{"signature":"ET BRUTEFORCE SSH Brute force attempt","severity":80}} +{"event_type":"alert","timestamp":"2025-11-11T12:52:04.993957+00:00","src_ip":"198.51.100.127","dest_ip":"172.16.0.10","proto":"TCP","dest_port":22,"alert":{"signature":"ET BRUTEFORCE SSH Brute force attempt","severity":80}} +{"event_type":"alert","timestamp":"2025-11-11T12:52:05.094255+00:00","src_ip":"198.51.100.216","dest_ip":"172.16.0.10","proto":"ICMP","dest_port":null,"alert":{"signature":"ET SCAN Potential scan detected (NMAP)","severity":80}} +{"event_type":"alert","timestamp":"2025-11-11T12:52:05.194504+00:00","src_ip":"198.51.100.161","dest_ip":"172.16.0.10","proto":"TCP","dest_port":80,"alert":{"signature":"ET EXPLOIT Possible buffer overflow","severity":80}} +{"event_type":"alert","timestamp":"2025-11-11T12:52:05.294752+00:00","src_ip":"198.51.100.193","dest_ip":"172.16.0.10","proto":"TCP","dest_port":80,"alert":{"signature":"ET EXPLOIT Possible buffer overflow","severity":80}} +{"event_type":"alert","timestamp":"2025-11-11T12:52:05.394969+00:00","src_ip":"198.51.100.77","dest_ip":"172.16.0.10","proto":"ICMP","dest_port":null,"alert":{"signature":"ET SCAN Potential scan detected (NMAP)","severity":80}} +{"event_type":"alert","timestamp":"2025-11-11T12:52:05.495197+00:00","src_ip":"198.51.100.91","dest_ip":"172.16.0.10","proto":"TCP","dest_port":80,"alert":{"signature":"ET EXPLOIT Possible buffer overflow","severity":80}} +{"event_type":"alert","timestamp":"2025-11-11T12:52:05.595447+00:00","src_ip":"198.51.100.206","dest_ip":"172.16.0.10","proto":"UDP","dest_port":0,"alert":{"signature":"ET DOS Possible DDoS amplification","severity":80}} +{"event_type":"alert","timestamp":"2025-11-11T12:52:05.695686+00:00","src_ip":"198.51.100.138","dest_ip":"172.16.0.10","proto":"TCP","dest_port":22,"alert":{"signature":"ET BRUTEFORCE SSH Brute force attempt","severity":80}} +{"event_type":"alert","timestamp":"2025-11-11T12:52:05.795932+00:00","src_ip":"198.51.100.223","dest_ip":"172.16.0.10","proto":"UDP","dest_port":0,"alert":{"signature":"ET DOS Possible DDoS amplification","severity":80}} +{"event_type":"alert","timestamp":"2025-11-11T12:52:05.896145+00:00","src_ip":"198.51.100.11","dest_ip":"172.16.0.10","proto":"TCP","dest_port":22,"alert":{"signature":"ET BRUTEFORCE SSH Brute force attempt","severity":80}} +{"event_type":"alert","timestamp":"2025-11-11T12:52:05.996349+00:00","src_ip":"198.51.100.22","dest_ip":"172.16.0.10","proto":"TCP","dest_port":22,"alert":{"signature":"ET BRUTEFORCE SSH Brute force attempt","severity":80}} +{"event_type":"alert","timestamp":"2025-11-11T12:52:06.096596+00:00","src_ip":"198.51.100.21","dest_ip":"172.16.0.10","proto":"ICMP","dest_port":null,"alert":{"signature":"ET SCAN Potential scan detected (NMAP)","severity":80}} +{"event_type":"alert","timestamp":"2025-11-11T12:52:06.196858+00:00","src_ip":"198.51.100.174","dest_ip":"172.16.0.10","proto":"TCP","dest_port":22,"alert":{"signature":"ET BRUTEFORCE SSH Brute force attempt","severity":80}} +{"event_type":"alert","timestamp":"2025-11-11T12:52:06.297085+00:00","src_ip":"198.51.100.7","dest_ip":"172.16.0.10","proto":"TCP","dest_port":22,"alert":{"signature":"ET BRUTEFORCE SSH Brute force attempt","severity":80}} +{"event_type":"alert","timestamp":"2025-11-11T12:52:06.397302+00:00","src_ip":"198.51.100.102","dest_ip":"172.16.0.10","proto":"UDP","dest_port":0,"alert":{"signature":"ET DOS Possible DDoS amplification","severity":80}} +{"event_type":"alert","timestamp":"2025-11-11T12:52:06.497485+00:00","src_ip":"198.51.100.194","dest_ip":"172.16.0.10","proto":"ICMP","dest_port":null,"alert":{"signature":"ET SCAN Potential scan detected (NMAP)","severity":80}} +{"event_type":"alert","timestamp":"2025-11-11T12:52:06.597736+00:00","src_ip":"198.51.100.250","dest_ip":"172.16.0.10","proto":"UDP","dest_port":0,"alert":{"signature":"ET DOS Possible DDoS amplification","severity":80}} +{"event_type":"alert","timestamp":"2025-11-11T12:52:06.697974+00:00","src_ip":"198.51.100.38","dest_ip":"172.16.0.10","proto":"TCP","dest_port":80,"alert":{"signature":"ET EXPLOIT Possible buffer overflow","severity":80}} +{"event_type":"alert","timestamp":"2025-11-11T12:52:06.798197+00:00","src_ip":"198.51.100.112","dest_ip":"172.16.0.10","proto":"TCP","dest_port":80,"alert":{"signature":"ET EXPLOIT Possible buffer overflow","severity":80}} +{"event_type":"alert","timestamp":"2025-11-11T12:52:06.898421+00:00","src_ip":"198.51.100.158","dest_ip":"172.16.0.10","proto":"UDP","dest_port":0,"alert":{"signature":"ET DOS Possible DDoS amplification","severity":80}} +{"event_type":"alert","timestamp":"2025-11-11T12:52:06.998657+00:00","src_ip":"198.51.100.143","dest_ip":"172.16.0.10","proto":"TCP","dest_port":80,"alert":{"signature":"ET EXPLOIT Possible buffer overflow","severity":80}} +{"event_type":"alert","timestamp":"2025-11-11T12:52:16.256341+00:00","src_ip":"198.51.100.13","dest_ip":"172.16.0.10","proto":"UDP","dest_port":0,"alert":{"signature":"ET DOS Possible DDoS amplification","severity":80}} +{"event_type":"alert","timestamp":"2025-11-11T12:52:16.356564+00:00","src_ip":"198.51.100.138","dest_ip":"172.16.0.10","proto":"UDP","dest_port":0,"alert":{"signature":"ET DOS Possible DDoS amplification","severity":80}} +{"event_type":"alert","timestamp":"2025-11-11T12:52:16.456886+00:00","src_ip":"198.51.100.214","dest_ip":"172.16.0.10","proto":"ICMP","dest_port":null,"alert":{"signature":"ET SCAN Potential scan detected (NMAP)","severity":80}} +{"event_type":"alert","timestamp":"2025-11-11T12:52:16.557092+00:00","src_ip":"198.51.100.48","dest_ip":"172.16.0.10","proto":"TCP","dest_port":80,"alert":{"signature":"ET EXPLOIT Possible buffer overflow","severity":80}} +{"event_type":"alert","timestamp":"2025-11-11T12:52:16.657288+00:00","src_ip":"198.51.100.9","dest_ip":"172.16.0.10","proto":"TCP","dest_port":80,"alert":{"signature":"ET EXPLOIT Possible buffer overflow","severity":80}} +{"event_type":"alert","timestamp":"2025-11-11T12:52:16.757507+00:00","src_ip":"198.51.100.217","dest_ip":"172.16.0.10","proto":"UDP","dest_port":0,"alert":{"signature":"ET DOS Possible DDoS amplification","severity":80}} +{"event_type":"alert","timestamp":"2025-11-11T12:52:16.857741+00:00","src_ip":"198.51.100.199","dest_ip":"172.16.0.10","proto":"TCP","dest_port":80,"alert":{"signature":"ET EXPLOIT Possible buffer overflow","severity":80}} +{"event_type":"alert","timestamp":"2025-11-11T12:52:16.957963+00:00","src_ip":"198.51.100.189","dest_ip":"172.16.0.10","proto":"ICMP","dest_port":null,"alert":{"signature":"ET SCAN Potential scan detected (NMAP)","severity":80}} +{"event_type":"alert","timestamp":"2025-11-11T12:52:17.058298+00:00","src_ip":"198.51.100.59","dest_ip":"172.16.0.10","proto":"TCP","dest_port":22,"alert":{"signature":"ET BRUTEFORCE SSH Brute force attempt","severity":80}} +{"event_type":"alert","timestamp":"2025-11-11T12:52:17.158620+00:00","src_ip":"198.51.100.203","dest_ip":"172.16.0.10","proto":"TCP","dest_port":22,"alert":{"signature":"ET BRUTEFORCE SSH Brute force attempt","severity":80}} +{"event_type":"alert","timestamp":"2025-11-11T12:52:17.258865+00:00","src_ip":"198.51.100.39","dest_ip":"172.16.0.10","proto":"ICMP","dest_port":null,"alert":{"signature":"ET SCAN Potential scan detected (NMAP)","severity":80}} +{"event_type":"alert","timestamp":"2025-11-11T12:52:17.359099+00:00","src_ip":"198.51.100.246","dest_ip":"172.16.0.10","proto":"ICMP","dest_port":null,"alert":{"signature":"ET SCAN Potential scan detected (NMAP)","severity":80}} +{"event_type":"alert","timestamp":"2025-11-11T12:52:17.459332+00:00","src_ip":"198.51.100.119","dest_ip":"172.16.0.10","proto":"TCP","dest_port":22,"alert":{"signature":"ET BRUTEFORCE SSH Brute force attempt","severity":80}} +{"event_type":"alert","timestamp":"2025-11-11T12:52:17.559620+00:00","src_ip":"198.51.100.156","dest_ip":"172.16.0.10","proto":"ICMP","dest_port":null,"alert":{"signature":"ET SCAN Potential scan detected (NMAP)","severity":80}} +{"event_type":"alert","timestamp":"2025-11-11T12:52:17.659890+00:00","src_ip":"198.51.100.136","dest_ip":"172.16.0.10","proto":"TCP","dest_port":80,"alert":{"signature":"ET EXPLOIT Possible buffer overflow","severity":80}} +{"event_type":"alert","timestamp":"2025-11-11T12:52:17.760131+00:00","src_ip":"198.51.100.25","dest_ip":"172.16.0.10","proto":"ICMP","dest_port":null,"alert":{"signature":"ET SCAN Potential scan detected (NMAP)","severity":80}} +{"event_type":"alert","timestamp":"2025-11-11T12:52:17.861804+00:00","src_ip":"198.51.100.104","dest_ip":"172.16.0.10","proto":"TCP","dest_port":80,"alert":{"signature":"ET EXPLOIT Possible buffer overflow","severity":80}} +{"event_type":"alert","timestamp":"2025-11-11T12:52:17.962043+00:00","src_ip":"198.51.100.228","dest_ip":"172.16.0.10","proto":"UDP","dest_port":0,"alert":{"signature":"ET DOS Possible DDoS amplification","severity":80}} +{"event_type":"alert","timestamp":"2025-11-11T12:52:18.062263+00:00","src_ip":"198.51.100.216","dest_ip":"172.16.0.10","proto":"UDP","dest_port":0,"alert":{"signature":"ET DOS Possible DDoS amplification","severity":80}} +{"event_type":"alert","timestamp":"2025-11-11T12:52:18.162520+00:00","src_ip":"198.51.100.6","dest_ip":"172.16.0.10","proto":"TCP","dest_port":80,"alert":{"signature":"ET EXPLOIT Possible buffer overflow","severity":80}} +{"event_type":"alert","timestamp":"2025-11-11T12:52:18.262761+00:00","src_ip":"198.51.100.41","dest_ip":"172.16.0.10","proto":"TCP","dest_port":22,"alert":{"signature":"ET BRUTEFORCE SSH Brute force attempt","severity":80}} +{"event_type":"alert","timestamp":"2025-11-11T12:52:18.362998+00:00","src_ip":"198.51.100.26","dest_ip":"172.16.0.10","proto":"ICMP","dest_port":null,"alert":{"signature":"ET SCAN Potential scan detected (NMAP)","severity":80}} +{"event_type":"alert","timestamp":"2025-11-11T12:52:18.463391+00:00","src_ip":"198.51.100.46","dest_ip":"172.16.0.10","proto":"UDP","dest_port":0,"alert":{"signature":"ET DOS Possible DDoS amplification","severity":80}} +{"event_type":"alert","timestamp":"2025-11-11T12:52:18.563630+00:00","src_ip":"198.51.100.86","dest_ip":"172.16.0.10","proto":"ICMP","dest_port":null,"alert":{"signature":"ET SCAN Potential scan detected (NMAP)","severity":80}} +{"event_type":"alert","timestamp":"2025-11-11T12:52:18.664881+00:00","src_ip":"198.51.100.4","dest_ip":"172.16.0.10","proto":"TCP","dest_port":22,"alert":{"signature":"ET BRUTEFORCE SSH Brute force attempt","severity":80}} +{"event_type":"alert","timestamp":"2025-11-11T12:52:18.765133+00:00","src_ip":"198.51.100.167","dest_ip":"172.16.0.10","proto":"TCP","dest_port":80,"alert":{"signature":"ET EXPLOIT Possible buffer overflow","severity":80}} +{"event_type":"alert","timestamp":"2025-11-11T12:52:18.865396+00:00","src_ip":"198.51.100.181","dest_ip":"172.16.0.10","proto":"UDP","dest_port":0,"alert":{"signature":"ET DOS Possible DDoS amplification","severity":80}} +{"event_type":"alert","timestamp":"2025-11-11T12:52:18.966324+00:00","src_ip":"198.51.100.138","dest_ip":"172.16.0.10","proto":"UDP","dest_port":0,"alert":{"signature":"ET DOS Possible DDoS amplification","severity":80}} +{"event_type":"alert","timestamp":"2025-11-11T12:52:19.066563+00:00","src_ip":"198.51.100.250","dest_ip":"172.16.0.10","proto":"TCP","dest_port":22,"alert":{"signature":"ET BRUTEFORCE SSH Brute force attempt","severity":80}} +{"event_type":"alert","timestamp":"2025-11-11T12:52:19.166846+00:00","src_ip":"198.51.100.186","dest_ip":"172.16.0.10","proto":"UDP","dest_port":0,"alert":{"signature":"ET DOS Possible DDoS amplification","severity":80}} +{"event_type":"alert","timestamp":"2025-11-11T12:52:19.267221+00:00","src_ip":"198.51.100.154","dest_ip":"172.16.0.10","proto":"UDP","dest_port":0,"alert":{"signature":"ET DOS Possible DDoS amplification","severity":80}} +{"event_type":"alert","timestamp":"2025-11-11T12:52:19.367490+00:00","src_ip":"198.51.100.138","dest_ip":"172.16.0.10","proto":"UDP","dest_port":0,"alert":{"signature":"ET DOS Possible DDoS amplification","severity":80}} +{"event_type":"alert","timestamp":"2025-11-11T12:52:19.467763+00:00","src_ip":"198.51.100.201","dest_ip":"172.16.0.10","proto":"TCP","dest_port":80,"alert":{"signature":"ET EXPLOIT Possible buffer overflow","severity":80}} +{"event_type":"alert","timestamp":"2025-11-11T12:52:19.568005+00:00","src_ip":"198.51.100.155","dest_ip":"172.16.0.10","proto":"UDP","dest_port":0,"alert":{"signature":"ET DOS Possible DDoS amplification","severity":80}} +{"event_type":"alert","timestamp":"2025-11-11T12:52:19.668287+00:00","src_ip":"198.51.100.244","dest_ip":"172.16.0.10","proto":"TCP","dest_port":80,"alert":{"signature":"ET EXPLOIT Possible buffer overflow","severity":80}} +{"event_type":"alert","timestamp":"2025-11-11T12:52:19.768547+00:00","src_ip":"198.51.100.86","dest_ip":"172.16.0.10","proto":"TCP","dest_port":80,"alert":{"signature":"ET EXPLOIT Possible buffer overflow","severity":80}} +{"event_type":"alert","timestamp":"2025-11-11T12:52:19.868820+00:00","src_ip":"198.51.100.150","dest_ip":"172.16.0.10","proto":"TCP","dest_port":22,"alert":{"signature":"ET BRUTEFORCE SSH Brute force attempt","severity":80}} +{"event_type":"alert","timestamp":"2025-11-11T12:52:19.969205+00:00","src_ip":"198.51.100.137","dest_ip":"172.16.0.10","proto":"TCP","dest_port":80,"alert":{"signature":"ET EXPLOIT Possible buffer overflow","severity":80}} +{"event_type":"alert","timestamp":"2025-11-11T12:52:20.069507+00:00","src_ip":"198.51.100.238","dest_ip":"172.16.0.10","proto":"ICMP","dest_port":null,"alert":{"signature":"ET SCAN Potential scan detected (NMAP)","severity":80}} +{"event_type":"alert","timestamp":"2025-11-11T12:52:20.169767+00:00","src_ip":"198.51.100.35","dest_ip":"172.16.0.10","proto":"TCP","dest_port":80,"alert":{"signature":"ET EXPLOIT Possible buffer overflow","severity":80}} +{"event_type":"alert","timestamp":"2025-11-11T12:52:20.270032+00:00","src_ip":"198.51.100.62","dest_ip":"172.16.0.10","proto":"TCP","dest_port":22,"alert":{"signature":"ET BRUTEFORCE SSH Brute force attempt","severity":80}} +{"event_type":"alert","timestamp":"2025-11-11T12:52:20.375574+00:00","src_ip":"198.51.100.164","dest_ip":"172.16.0.10","proto":"ICMP","dest_port":null,"alert":{"signature":"ET SCAN Potential scan detected (NMAP)","severity":80}} +{"event_type":"alert","timestamp":"2025-11-11T12:52:20.475976+00:00","src_ip":"198.51.100.75","dest_ip":"172.16.0.10","proto":"TCP","dest_port":22,"alert":{"signature":"ET BRUTEFORCE SSH Brute force attempt","severity":80}} +{"event_type":"alert","timestamp":"2025-11-11T12:52:20.576243+00:00","src_ip":"198.51.100.29","dest_ip":"172.16.0.10","proto":"ICMP","dest_port":null,"alert":{"signature":"ET SCAN Potential scan detected (NMAP)","severity":80}} +{"event_type":"alert","timestamp":"2025-11-11T12:52:20.676576+00:00","src_ip":"198.51.100.82","dest_ip":"172.16.0.10","proto":"UDP","dest_port":0,"alert":{"signature":"ET DOS Possible DDoS amplification","severity":80}} +{"event_type":"alert","timestamp":"2025-11-11T12:52:20.776890+00:00","src_ip":"198.51.100.168","dest_ip":"172.16.0.10","proto":"TCP","dest_port":80,"alert":{"signature":"ET EXPLOIT Possible buffer overflow","severity":80}} +{"event_type":"alert","timestamp":"2025-11-11T12:52:20.877151+00:00","src_ip":"198.51.100.244","dest_ip":"172.16.0.10","proto":"UDP","dest_port":0,"alert":{"signature":"ET DOS Possible DDoS amplification","severity":80}} +{"event_type":"alert","timestamp":"2025-11-11T12:52:20.977426+00:00","src_ip":"198.51.100.77","dest_ip":"172.16.0.10","proto":"TCP","dest_port":80,"alert":{"signature":"ET EXPLOIT Possible buffer overflow","severity":80}} +{"event_type":"alert","timestamp":"2025-11-11T12:52:21.077699+00:00","src_ip":"198.51.100.121","dest_ip":"172.16.0.10","proto":"TCP","dest_port":22,"alert":{"signature":"ET BRUTEFORCE SSH Brute force attempt","severity":80}} +{"event_type":"alert","timestamp":"2025-11-11T12:52:21.177966+00:00","src_ip":"198.51.100.178","dest_ip":"172.16.0.10","proto":"ICMP","dest_port":null,"alert":{"signature":"ET SCAN Potential scan detected (NMAP)","severity":80}} diff --git a/runtime/serve.pid b/runtime/serve.pid new file mode 100644 index 0000000..302b0e0 --- /dev/null +++ b/runtime/serve.pid @@ -0,0 +1 @@ +100734 diff --git a/scripts/ai_policy_delay.sh b/scripts/ai_policy_delay.sh index bb60f82..3723fc2 100755 --- a/scripts/ai_policy_delay.sh +++ b/scripts/ai_policy_delay.sh @@ -38,20 +38,31 @@ fi tc filter del dev "$INTERFACE" protocol ip parent 1:0 prio 3 \ u32 match ip src "$SRC_IP" 2>/dev/null || true -# Ensure basic qdisc structure exists +# Ensure basic qdisc structure exists (replace preferred) tc qdisc replace dev "$INTERFACE" root handle 1: prio 2>/dev/null || { log "Creating base qdisc on $INTERFACE" - tc qdisc add dev "$INTERFACE" root handle 1: prio + existing_qdisc=$(tc qdisc show dev "$INTERFACE" root 2>/dev/null | head -n1 || true) + if [ -z "${existing_qdisc}" ] || echo "${existing_qdisc}" | grep -qi "noqueue"; then + tc qdisc add dev "$INTERFACE" root handle 1: prio + else + echo "tc qdisc replace failed and interface has existing qdisc; skipping add to avoid RTNETLINK conflicts" >&2 + fi } -# Create delay qdisc if not exists +# Create delay qdisc (replace preferred) tc qdisc replace dev "$INTERFACE" parent 1:3 handle 30: netem delay "$DELAY" 2>/dev/null || { log "Creating delay qdisc with $DELAY" - tc qdisc add dev "$INTERFACE" parent 1:3 handle 30: netem delay "$DELAY" + # Only add if the specific parent/class doesn't already have a qdisc + parent_qdisc=$(tc qdisc show dev "$INTERFACE" | grep "parent 1:3" || true) + if [ -z "${parent_qdisc}" ]; then + tc qdisc add dev "$INTERFACE" parent 1:3 handle 30: netem delay "$DELAY" + else + echo "tc qdisc replace failed for parent 1:3 but parent qdisc exists; skipping add to avoid conflicts" >&2 + fi } -# Apply filter to target specific source IP -tc filter add dev "$INTERFACE" protocol ip parent 1:0 prio 3 \ +# Apply filter to target specific source IP (use replace to avoid File exists races) +tc filter replace dev "$INTERFACE" protocol ip parent 1:0 prio 3 \ u32 match ip src "$SRC_IP" flowid 1:3 # Verify the rule was applied diff --git a/scripts/install_demo_notify.sh b/scripts/install_demo_notify.sh index 807167c..2f666ea 100644 --- a/scripts/install_demo_notify.sh +++ b/scripts/install_demo_notify.sh @@ -1,9 +1,9 @@ #!/usr/bin/env bash -# Install demo notify config (back up existing configs/monitoring/notify.yaml -> notify.yaml.bak) +# Install demo notify config (back up existing configs/notify.yaml -> notify.yaml.bak) set -euo pipefail REPO_ROOT="$(cd "$(dirname "$0")/.." && pwd)" DEMO_CFG="$REPO_ROOT/configs/monitoring/notify_demo.yaml" -TARGET_CFG="$REPO_ROOT/configs/monitoring/notify.yaml" +TARGET_CFG="$REPO_ROOT/configs/notify.yaml" BACKUP="$TARGET_CFG.bak" if [ ! -f "$DEMO_CFG" ]; then diff --git a/scripts/restore_notify.sh b/scripts/restore_notify.sh index 78d3490..bf94352 100644 --- a/scripts/restore_notify.sh +++ b/scripts/restore_notify.sh @@ -2,7 +2,7 @@ # Restore original notify.yaml if backup exists set -euo pipefail REPO_ROOT="$(cd "$(dirname "$0")/.." && pwd)" -TARGET_CFG="$REPO_ROOT/configs/monitoring/notify.yaml" +TARGET_CFG="$REPO_ROOT/configs/notify.yaml" BACKUP="$TARGET_CFG.bak" if [ -f "$BACKUP" ]; then diff --git a/scripts/tc_reset.sh b/scripts/tc_reset.sh index ea2decd..c8ff9c4 100644 --- a/scripts/tc_reset.sh +++ b/scripts/tc_reset.sh @@ -5,4 +5,12 @@ set -euo pipefail IFACE=${1:-${AZAZEL_WAN_IF:-wlan1}} tc qdisc del dev "$IFACE" root 2>/dev/null || true -tc qdisc add dev "$IFACE" root handle 1: htb default 30 +# Use replace to be idempotent; if replace is unsupported, fall back to add +tc qdisc replace dev "$IFACE" root handle 1: htb default 30 2>/dev/null || { + existing_qdisc=$(tc qdisc show dev "$IFACE" root 2>/dev/null | head -n1 || true) + if [ -z "${existing_qdisc}" ] || echo "${existing_qdisc}" | grep -qi "noqueue"; then + tc qdisc add dev "$IFACE" root handle 1: htb default 30 + else + echo "tc qdisc replace failed and interface has existing qdisc; skipping add to avoid RTNETLINK conflicts" >&2 + fi +} diff --git a/scripts/test_ai_integration.py b/scripts/test_ai_integration.py index bb483ba..5367007 100644 --- a/scripts/test_ai_integration.py +++ b/scripts/test_ai_integration.py @@ -98,9 +98,10 @@ def test_ollama_connection(): try: import subprocess + from azazel_pi.utils.cmd_runner import run as run_cmd # Test if container is running - result = subprocess.run( + result = run_cmd( ["docker", "ps", "--filter", "name=azazel_ollama", "--format", "{{.Names}}"], capture_output=True, text=True, timeout=10 ) @@ -109,7 +110,7 @@ def test_ollama_connection(): logger.info("✓ Ollama container is running") # Test ollama list command - list_result = subprocess.run( + list_result = run_cmd( ["docker", "exec", "azazel_ollama", "ollama", "list"], capture_output=True, text=True, timeout=30 ) diff --git a/systemd/azctl-unified.service b/systemd/azctl-unified.service index eec88ab..c52d4b3 100644 --- a/systemd/azctl-unified.service +++ b/systemd/azctl-unified.service @@ -10,16 +10,18 @@ Type=simple WorkingDirectory=/home/azazel/Azazel-Pi Environment=PYTHONPATH=/home/azazel/Azazel-Pi -# Ensure required directories exist (as root before switching to azazel user) +# Ensure required directories exist (created as root). +# This service runs as root to allow nft/tc operations; adjust permissions as needed. ExecStartPre=+/bin/mkdir -p /var/log/azazel -ExecStartPre=+/bin/chown azazel:azazel /var/log/azazel # Unified startup command combining serve mode with config file support ExecStart=/usr/bin/env python3 -u -m azctl.cli serve --config /etc/azazel/azazel.yaml --suricata-eve /var/log/suricata/eve.json --decisions-log /var/log/azazel/decisions.log # Service management -User=azazel -Group=azazel +# Run as root to allow privileged network operations (nft/tc). Be aware of +# the security implications: consider using a restricted capability drop or +# an additional wrapper if you want to limit privileges later. +# (User/Group intentionally omitted to run as root) Restart=on-failure RestartSec=5 KillMode=mixed diff --git a/tests/helpers/run_wan_state_test.py b/tests/helpers/run_wan_state_test.py index 56039c9..4c1df13 100644 --- a/tests/helpers/run_wan_state_test.py +++ b/tests/helpers/run_wan_state_test.py @@ -12,6 +12,7 @@ import json import subprocess +from azazel_pi.utils.cmd_runner import run as run_cmd import sys from pathlib import Path @@ -48,7 +49,7 @@ def main(argv: list[str]) -> int: ] print("Running:", " ".join(cmd)) - res = subprocess.run(cmd) + res = run_cmd(cmd) return res.returncode diff --git a/tests/test_async_ai_persistence.py b/tests/test_async_ai_persistence.py new file mode 100644 index 0000000..3848e12 --- /dev/null +++ b/tests/test_async_ai_persistence.py @@ -0,0 +1,50 @@ +import time +import json +from pathlib import Path + +import azazel_pi.core.async_ai as async_ai +from azazel_pi.core import notify_config + + +def test_async_ai_persists_deep_result(tmp_path): + # prepare decisions path + decisions = tmp_path / "decisions_test.log" + + # stub evaluator + class DummyEvaluator: + def evaluate_threat(self, alert): + return {"risk": 3, "category": "malware", "reason": "dummy", "score": 75} + + # monkeypatch evaluator in module + async_ai.get_ai_evaluator = lambda: DummyEvaluator() + + # ensure sampling always allows + notify_config._CFG.setdefault('ai', {}) + notify_config._CFG['ai']['deep_sample_rate'] = 1.0 + notify_config._CFG['ai']['deep_max_per_min'] = 10 + notify_config._CFG['ai']['deep_eval_retries'] = 1 + notify_config._CFG['ai']['deep_persist_retries'] = 1 + + # enqueue a test alert + alert = {"src_ip": "10.0.0.5", "signature": "TEST ALERT", "timestamp": time.time()} + async_ai.enqueue(alert, context={"decisions_log": str(decisions)}) + + # wait for worker to persist + timeout = 5.0 + start = time.time() + while time.time() - start < timeout: + if decisions.exists(): + break + time.sleep(0.1) + + # signal shutdown and allow worker finish + async_ai.shutdown() + time.sleep(0.1) + + assert decisions.exists(), "decisions log was not created" + with decisions.open("r", encoding="utf-8") as fh: + lines = [ln.strip() for ln in fh.readlines() if ln.strip()] + assert len(lines) >= 1 + data = json.loads(lines[-1]) + assert data.get('note') == 'deep_followup' + assert data.get('deep_ai', {}).get('category') == 'malware' diff --git a/tests/test_traffic_control.py b/tests/test_traffic_control.py new file mode 100644 index 0000000..5db637f --- /dev/null +++ b/tests/test_traffic_control.py @@ -0,0 +1,80 @@ +import json +import tempfile +from pathlib import Path +import subprocess +import types +import pytest + +from azazel_pi.core.enforcer.traffic_control import TrafficControlEngine, TrafficControlRule + + +class DummyCompleted: + def __init__(self, returncode=0, stdout="", stderr=""): + self.returncode = returncode + self.stdout = stdout + self.stderr = stderr + + +def test_restore_persisted_nft_handles(tmp_path, monkeypatch): + tmp_file = tmp_path / "nft_handles.json" + data = { + "198.51.100.1": { + "family": "inet", + "table": "azazel", + "handle": "123", + "action": "redirect", + "dest_port": None + }, + "198.51.100.2": { + "family": "inet", + "table": "azazel", + "handle": "124", + "action": "block" + } + } + tmp_file.write_text(json.dumps(data)) + + # Monkeypatch the path method to point to our temp file + monkeypatch.setattr(TrafficControlEngine, '_nft_handles_path', lambda self: tmp_file) + + # Monkeypatch subprocess.run to avoid calling real tc/nft + def fake_run(cmd, capture_output=False, text=False, timeout=None, check=False): + # Simulate list outputs for tc qdisc show + joined = ' '.join(cmd) + if 'tc qdisc show' in joined: + return DummyCompleted(0, stdout="") + return DummyCompleted(0, stdout="") + + monkeypatch.setattr(subprocess, 'run', fake_run) + + engine = TrafficControlEngine(config_path=str(tmp_path / 'nope.yaml')) + rules = engine.get_active_rules() + + assert "198.51.100.1" in rules + assert any(r.action_type == 'redirect' for r in rules['198.51.100.1']) + assert "198.51.100.2" in rules + assert any(r.action_type == 'block' for r in rules['198.51.100.2']) + + +def test_replace_only_invoked(monkeypatch): + calls = [] + + def fake_run(cmd, capture_output=False, text=False, timeout=None, check=False): + calls.append(cmd) + # Simulate replace returning non-zero with "File exists" on some calls + if 'qdisc' in cmd and 'replace' in cmd: + return DummyCompleted(0, stdout="") + if 'class' in cmd and 'replace' in cmd: + return DummyCompleted(0, stdout="") + if 'filter' in cmd and 'replace' in cmd: + return DummyCompleted(0, stdout="") + return DummyCompleted(0, stdout="") + + monkeypatch.setattr(subprocess, 'run', fake_run) + engine = TrafficControlEngine(config_path='/nonexistent') + # apply a delay which would call tc class replace / qdisc replace / filter replace + ok = engine.apply_delay('198.51.100.99', 50) + assert ok is True + # ensure replace cmds were called (not add) + joined = [' '.join(c) for c in calls] + assert any('tc qdisc replace' in ' '.join(c) or 'tc class replace' in ' '.join(c) for c in calls) \ No newline at end of file diff --git a/tests/utils/fake_subprocess.py b/tests/utils/fake_subprocess.py new file mode 100644 index 0000000..b434533 --- /dev/null +++ b/tests/utils/fake_subprocess.py @@ -0,0 +1,76 @@ +"""Test helper: fake subprocess runner utilities. + +Provides: +- make_completed_process(cmd, returncode=0, stdout='', stderr='') -> CompletedProcess-like +- FakeSubprocess: callable object mapping command signatures to CompletedProcess-like results + +Usage example in tests: + + from tests.utils.fake_subprocess import make_completed_process, FakeSubprocess + + fake = FakeSubprocess() + fake.when("tc qdisc show").then_stdout("htb 1:") + engine.set_subprocess_runner(fake) + +The FakeSubprocess supports simple substring matching of the joined cmd list. +""" +from __future__ import annotations +from typing import List, Tuple, Callable +import subprocess + + +def make_completed_process(cmd, returncode: int = 0, stdout: str = "", stderr: str = ""): + try: + return subprocess.CompletedProcess(cmd, returncode, stdout=stdout, stderr=stderr) + except Exception: + class _Dummy: + def __init__(self, args, rc, out, err): + self.args = args + self.returncode = rc + self.stdout = out + self.stderr = err + + return _Dummy(cmd, returncode, stdout, stderr) + + +class FakeSubprocess: + """A small callable object to fake subprocess.run-like behavior. + + - Use when(...) to register expected command substrings and responses. + - When called, it finds the first registered rule where the substring is in the joined command. + - Returns a CompletedProcess-like object. + """ + + def __init__(self): + self._rules: List[Tuple[str, Callable[[], subprocess.CompletedProcess]]] = [] + + def when(self, cmd_substring: str): + class _Then: + def __init__(self, parent: FakeSubprocess, substr: str): + self.parent = parent + self.substr = substr + + def then_stdout(self, stdout: str, returncode: int = 0, stderr: str = ""): + def factory(): + return make_completed_process(self.substr, returncode=returncode, stdout=stdout, stderr=stderr) + + self.parent._rules.append((self.substr, factory)) + return self.parent + + def then_result(self, completed): + def factory(): + return completed + + self.parent._rules.append((self.substr, factory)) + return self.parent + + return _Then(self, cmd_substring) + + def __call__(self, cmd, **kwargs): + # normalize cmd to a string for matching + joined = " ".join(cmd) if isinstance(cmd, (list, tuple)) else str(cmd) + for substr, factory in self._rules: + if substr in joined: + return factory() + # default: return a successful empty result + return make_completed_process(cmd, 0, stdout="", stderr="") diff --git a/tests/utils/test_fake_subprocess_usage.py b/tests/utils/test_fake_subprocess_usage.py new file mode 100644 index 0000000..f7e38d3 --- /dev/null +++ b/tests/utils/test_fake_subprocess_usage.py @@ -0,0 +1,47 @@ +import pytest +from azazel_pi.core.enforcer.traffic_control import TrafficControlEngine +from tests.utils.fake_subprocess import FakeSubprocess + + +def test_fake_subprocess_injection_applies_dnat_and_records_rule(tmp_path, monkeypatch): + """Demonstrate injecting a FakeSubprocess runner and exercising apply_dnat_redirect. + + This test does not touch system /var paths because we monkeypatch the nft handles path + to a temporary location under tmp_path. + """ + fake = FakeSubprocess() + # Simulate tc/nft outputs used by TrafficControlEngine during setup and DNAT add + fake.when("tc qdisc show").then_stdout("") + fake.when("tc qdisc replace").then_stdout("") + fake.when("tc class replace").then_stdout("") + fake.when("tc filter show").then_stdout("") + fake.when("nft list table").then_stdout("") + # Simulate nft add with a handle in stdout + fake.when("nft -a add rule").then_stdout("added rule handle 123") + + # Keep persisted handles in a temp location so test doesn't need root + monkeypatch.setattr( + TrafficControlEngine, + "_nft_handles_path", + lambda self: tmp_path / "nft_handles.json", + ) + + # Inject fake runner at class level so __init__ uses it during setup + TrafficControlEngine._subprocess_runner = fake + + engine = None + try: + engine = TrafficControlEngine(config_path=str(tmp_path / "azazel.yaml")) + ok = engine.apply_dnat_redirect("10.0.5.5", dest_port=2222) + assert ok is True + + rules = engine.get_active_rules() + assert "10.0.5.5" in rules + # Ensure at least one redirect rule recorded + assert any(r.action_type == "redirect" for r in rules["10.0.5.5"]) is True + finally: + # Cleanup injected runner to avoid side effects on other tests + try: + delattr(TrafficControlEngine, "_subprocess_runner") + except Exception: + pass