From bee7910cd8722fc75af16a33575a6ce93a18b3f9 Mon Sep 17 00:00:00 2001 From: "Mr.Rabbit" Date: Mon, 23 Feb 2026 00:26:17 +0900 Subject: [PATCH 1/3] port webui from azazel-zero to azazel-pi --- README.md | 20 + README_ja.md | 20 + azazel_web/app.py | 1482 ++++++++++++++++++++++++++++ azazel_web/static/app.js | 1024 +++++++++++++++++++ azazel_web/static/style.css | 630 ++++++++++++ azazel_web/templates/index.html | 299 ++++++ pyproject.toml | 3 +- scripts/install_azazel.sh | 6 +- scripts/install_azazel_complete.sh | 4 +- systemd/azazel-web.service | 23 + 10 files changed, 3508 insertions(+), 3 deletions(-) create mode 100644 azazel_web/app.py create mode 100644 azazel_web/static/app.js create mode 100644 azazel_web/static/style.css create mode 100644 azazel_web/templates/index.html create mode 100644 systemd/azazel-web.service diff --git a/README.md b/README.md index 439acbf..351b759 100644 --- a/README.md +++ b/README.md @@ -260,6 +260,26 @@ This will: Afterwards, Mattermost should be reachable at `http:///` (port 80) and proxied to `127.0.0.1:8065`. For HTTPS, add your TLS server block or use Certbot. +### Optional: Flask Web UI Dashboard + +Azazel-Pi now includes a Flask-based Web UI backend and dashboard assets in `azazel_web/`. + +```bash +# local run (dev) +python3 azazel_web/app.py + +# systemd run (installed host) +sudo systemctl enable --now azazel-web.service +``` + +Default bind is `127.0.0.1:8084`. Change with: + +```bash +export AZAZEL_WEB_HOST=0.0.0.0 +export AZAZEL_WEB_PORT=8084 +python3 azazel_web/app.py +``` + ### Modular TUI Menu System The interactive Terminal User Interface (TUI) menu provides comprehensive system management through a modular architecture designed for maintainability and extensibility: diff --git a/README_ja.md b/README_ja.md index 4988d32..d31f2f7 100644 --- a/README_ja.md +++ b/README_ja.md @@ -249,6 +249,26 @@ sudo scripts/setup_nginx_mattermost.sh 適用後は `http://<デバイスIP>/`(80番)で Mattermost に到達できます(裏側は `127.0.0.1:8065` へプロキシ)。 HTTPS を利用する場合は、TLS 用の server ブロックを追加するか Certbot を利用してください。 +### オプション: Flask WebUI ダッシュボード + +`azazel_web/` に Flask ベースの WebUI バックエンドとダッシュボード資産が含まれます。 + +```bash +# ローカル起動(開発用) +python3 azazel_web/app.py + +# systemd 起動(インストール環境) +sudo systemctl enable --now azazel-web.service +``` + +デフォルトのバインドは `127.0.0.1:8084` です。変更する場合: + +```bash +export AZAZEL_WEB_HOST=0.0.0.0 +export AZAZEL_WEB_PORT=8084 +python3 azazel_web/app.py +``` + ## 使用方法 ### コマンドラインインターフェース diff --git a/azazel_web/app.py b/azazel_web/app.py new file mode 100644 index 0000000..a1f9243 --- /dev/null +++ b/azazel_web/app.py @@ -0,0 +1,1482 @@ +#!/usr/bin/env python3 +""" +Azazel-Gadget Web UI - Flask Application + +Provides HTTP API for remote monitoring and control via USB gadget network. +- Reads: control-plane snapshot (with file fallback) +- Executes: Actions via Unix socket to control daemon +- Serves: HTML dashboard + JSON API endpoints +""" + +from flask import ( + Flask, + jsonify, + request, + render_template, + send_from_directory, + send_file, + Response, + stream_with_context, + has_request_context, +) +import json +import os +import socket +import sys +import time +import subprocess +import hashlib +import queue +import threading +import tempfile +from pathlib import Path +from datetime import datetime +from typing import Dict, Any, Optional, Iterator, Tuple, List +from urllib.request import Request, urlopen +from urllib.parse import urlparse + +app = Flask(__name__) + +PROJECT_ROOT = Path(__file__).resolve().parents[1] +PY_ROOT = PROJECT_ROOT / "py" +if str(PY_ROOT) not in sys.path: + sys.path.insert(0, str(PY_ROOT)) + +try: + from azazel_gadget.control_plane import ( + read_snapshot_payload as cp_read_snapshot_payload, + watch_snapshots as cp_watch_snapshots, + ) + from azazel_gadget.path_schema import ( + config_dir_candidates, + first_minute_config_candidates, + portal_env_candidates, + snapshot_path_candidates, + web_token_candidates, + warn_if_legacy_path, + ) +except Exception: + cp_read_snapshot_payload = None + cp_watch_snapshots = None + config_dir_candidates = lambda: [Path("/etc/azazel-pi"), Path("/etc/azazel-gadget"), Path("/etc/azazel-zero")] # type: ignore + first_minute_config_candidates = lambda: [Path("/etc/azazel-pi/first_minute.yaml"), Path("/etc/azazel-gadget/first_minute.yaml"), Path("/etc/azazel-zero/first_minute.yaml")] # type: ignore + portal_env_candidates = lambda: [Path("/etc/azazel-pi/portal-viewer.env"), Path("/etc/azazel-gadget/portal-viewer.env"), Path("/etc/azazel-zero/portal-viewer.env")] # type: ignore + snapshot_path_candidates = lambda: [Path("/run/azazel/ui_snapshot.json"), Path("/var/run/azazel/ui_snapshot.json"), Path("/run/azazel-gadget/ui_snapshot.json"), Path("/run/azazel-zero/ui_snapshot.json"), Path("runtime/ui_snapshot.json"), Path(".azazel-gadget/run/ui_snapshot.json"), Path(".azazel-zero/run/ui_snapshot.json")] # type: ignore + web_token_candidates = lambda: [Path.home() / ".azazel-pi" / "web_token.txt", Path.home() / ".azazel-gadget" / "web_token.txt", Path.home() / ".azazel-zero" / "web_token.txt"] # type: ignore + warn_if_legacy_path = lambda *args, **kwargs: None # type: ignore + +# Configuration +_STATE_PATHS = snapshot_path_candidates() +STATE_PATH = _STATE_PATHS[0] # Share TUI snapshot +FALLBACK_STATE_PATH = _STATE_PATHS[-1] # Fallback for testing +CONTROL_SOCKET = Path("/run/azazel/control.sock") +TOKEN_FILE = web_token_candidates()[0] +BIND_HOST = os.environ.get("AZAZEL_WEB_HOST", "0.0.0.0") +BIND_PORT = int(os.environ.get("AZAZEL_WEB_PORT", "8084")) +STATUS_API_HOSTS = ["10.55.0.10", "127.0.0.1"] +PORTAL_VIEWER_ENV_PATH = portal_env_candidates()[0] +NTFY_CONFIG_PATHS = [ + Path(os.environ.get("AZAZEL_CONFIG_PATH", str(first_minute_config_candidates()[0]))), + Path("configs/first_minute.yaml"), +] +NTFY_SSE_KEEPALIVE_SEC = int(os.environ.get("AZAZEL_SSE_KEEPALIVE_SEC", "20")) +NTFY_SSE_READ_TIMEOUT_SEC = int(os.environ.get("AZAZEL_NTFY_READ_TIMEOUT_SEC", "35")) +NTFY_SSE_MAX_BACKOFF_SEC = int(os.environ.get("AZAZEL_NTFY_MAX_BACKOFF_SEC", "30")) +_CA_CERT_FILENAME = "azazel-webui-local-ca.crt" +_WEBUI_CA_CERT_CANDIDATES: List[Path] = [] +_env_ca_cert = str(os.environ.get("AZAZEL_WEBUI_CA_PATH", "")).strip() +if _env_ca_cert: + _WEBUI_CA_CERT_CANDIDATES.append(Path(_env_ca_cert)) +for cfg_dir in config_dir_candidates(): + _WEBUI_CA_CERT_CANDIDATES.append(cfg_dir / "certs" / _CA_CERT_FILENAME) +if not _WEBUI_CA_CERT_CANDIDATES: + _WEBUI_CA_CERT_CANDIDATES = [ + Path("/etc/azazel-gadget/certs/azazel-webui-local-ca.crt"), + Path("/etc/azazel-zero/certs/azazel-webui-local-ca.crt"), + ] +_seen_ca_paths: set[str] = set() +WEBUI_CA_CERT_PATHS: List[Path] = [] +for _candidate in _WEBUI_CA_CERT_CANDIDATES: + key = str(_candidate) + if key in _seen_ca_paths: + continue + _seen_ca_paths.add(key) + WEBUI_CA_CERT_PATHS.append(_candidate) + +# Allowed actions +ALLOWED_ACTIONS = { + "refresh", "reprobe", "contain", "release", "details", "stage_open", "disconnect", + "wifi_scan", "wifi_connect", "portal_viewer_open", "shutdown", "reboot" # Wi-Fi + portal viewer actions +} + + +def _load_first_minute_config() -> Dict[str, Any]: + """Load first_minute.yaml if available, return empty dict on failure.""" + for cfg_path in NTFY_CONFIG_PATHS: + try: + if not cfg_path.exists(): + continue + try: + import yaml # type: ignore + except Exception: + app.logger.warning("PyYAML not installed; using default ntfy bridge config") + return {} + data = yaml.safe_load(cfg_path.read_text(encoding="utf-8")) or {} + if isinstance(data, dict): + return data + except Exception as e: + app.logger.warning(f"Failed to load config {cfg_path}: {e}") + return {} + + +def _load_ntfy_bridge_settings() -> Dict[str, Any]: + """Resolve ntfy settings from config + env with sane defaults.""" + mgmt_ip = os.environ.get("MGMT_IP", "10.55.0.10") + ntfy_port = os.environ.get("NTFY_PORT", "8081") + default_base_url = f"http://{mgmt_ip}:{ntfy_port}" + default_topic_alert = "azg-alert-critical" + default_topic_info = "azg-info-status" + default_token_file = "/etc/azazel/ntfy.token" + + cfg = _load_first_minute_config() + notify_cfg = cfg.get("notify", {}) if isinstance(cfg, dict) else {} + ntfy_cfg = notify_cfg.get("ntfy", {}) if isinstance(notify_cfg, dict) else {} + + base_url = ( + os.environ.get("NTFY_BASE_URL") + or ntfy_cfg.get("base_url") + or default_base_url + ).rstrip("/") + topic_alert = os.environ.get("NTFY_TOPIC_ALERT") or ntfy_cfg.get("topic_alert") or default_topic_alert + topic_info = os.environ.get("NTFY_TOPIC_INFO") or ntfy_cfg.get("topic_info") or default_topic_info + token_file = Path( + os.environ.get("NTFY_TOKEN_FILE") + or ntfy_cfg.get("token_file") + or default_token_file + ) + + token = "" + try: + if token_file.exists() and os.access(token_file, os.R_OK): + token = token_file.read_text(encoding="utf-8").strip() + elif token_file.exists(): + # Subscription can work without token when topics are read-allowed. + # Avoid noisy warnings for expected permission boundaries. + app.logger.debug(f"ntfy token file exists but is not readable by webui user: {token_file}") + except PermissionError: + app.logger.debug(f"ntfy token file is not readable by webui user: {token_file}") + except Exception as e: + app.logger.warning(f"Failed to read ntfy token file {token_file}: {e}") + + topics = [str(topic_alert).strip(), str(topic_info).strip()] + topics = [t for t in topics if t] + dedup_topics: List[str] = [] + for topic in topics: + if topic not in dedup_topics: + dedup_topics.append(topic) + + return { + "base_url": base_url, + "topics": dedup_topics or [default_topic_alert], + "token": token, + } + + +def _build_ntfy_sse_url(base_url: str, topics: List[str]) -> str: + topic_path = ",".join(topics) + return f"{base_url}/{topic_path}/sse" + + +def _to_iso_timestamp(raw_ts: Any) -> str: + """Convert ntfy timestamp to ISO-8601 if possible.""" + try: + if isinstance(raw_ts, (int, float)): + return datetime.fromtimestamp(float(raw_ts)).isoformat() + if isinstance(raw_ts, str): + return datetime.fromtimestamp(float(raw_ts)).isoformat() + except Exception: + pass + return datetime.now().isoformat() + + +def _normalize_ntfy_event(data: Dict[str, Any]) -> Optional[Dict[str, Any]]: + """Normalize ntfy payload for WebUI event consumers.""" + payload_event = str(data.get("event") or "").lower() + if payload_event in {"open", "keepalive", "poll_request"}: + return None + + topic = str(data.get("topic") or "unknown") + title = str(data.get("title") or "Azazel Notification") + message = str(data.get("message") or data.get("body") or "") + try: + priority = int(data.get("priority") or 2) + except Exception: + priority = 2 + tags = data.get("tags") if isinstance(data.get("tags"), list) else [] + event_id = str(data.get("id") or "") + dedup_key = f"ntfy:{event_id}" if event_id else f"ntfy:{topic}:{title}:{message}" + + severity = "info" + if priority >= 5: + severity = "error" + elif priority >= 4: + severity = "warning" + + return { + "source": "ntfy", + "id": event_id, + "topic": topic, + "title": title, + "message": message, + "priority": priority, + "tags": tags, + "timestamp": _to_iso_timestamp(data.get("time")), + "dedup_key": dedup_key, + "severity": severity, + "event": payload_event or "message", + } + + +def _iter_ntfy_sse_events( + ntfy_url: str, + token: str, + stop_event: threading.Event, +) -> Iterator[Tuple[str, str]]: + """Yield ntfy SSE event/data pairs.""" + headers = {"Accept": "text/event-stream"} + if token: + headers["Authorization"] = f"Bearer {token}" + + req = Request(ntfy_url, headers=headers, method="GET") + with urlopen(req, timeout=NTFY_SSE_READ_TIMEOUT_SEC) as resp: + yield "__bridge_open__", "" + current_event = "message" + data_lines: List[str] = [] + + while not stop_event.is_set(): + raw_line = resp.readline() + if not raw_line: + raise ConnectionError("ntfy SSE stream closed") + + line = raw_line.decode("utf-8", errors="replace").rstrip("\r\n") + if line == "": + if data_lines: + yield current_event, "\n".join(data_lines) + current_event = "message" + data_lines = [] + continue + + if line.startswith(":"): + continue + if line.startswith("event:"): + current_event = line.split(":", 1)[1].strip() or "message" + continue + if line.startswith("data:"): + data_lines.append(line.split(":", 1)[1].strip()) + + +def _sse_message(event_name: str, payload: Dict[str, Any]) -> str: + data = json.dumps(payload, ensure_ascii=False) + return f"event: {event_name}\ndata: {data}\n\n" + + +def _sha256_file(path: Path) -> str: + """Return SHA-256 hex digest for a file.""" + hasher = hashlib.sha256() + with path.open("rb") as f: + for chunk in iter(lambda: f.read(1024 * 1024), b""): + hasher.update(chunk) + return hasher.hexdigest() + + +def _resolve_webui_ca_cert_path() -> Tuple[Path, List[Path]]: + """Return first existing CA cert path plus all checked candidates.""" + for candidate in WEBUI_CA_CERT_PATHS: + if candidate.exists(): + warn_if_legacy_path(candidate, app.logger) + return candidate, WEBUI_CA_CERT_PATHS + return WEBUI_CA_CERT_PATHS[0], WEBUI_CA_CERT_PATHS + + +def _queue_put_drop_oldest(out_q: queue.Queue, item: Dict[str, Any]) -> None: + """Try queue put; if full, drop oldest one and retry once.""" + try: + out_q.put(item, timeout=0.2) + return + except queue.Full: + pass + try: + out_q.get_nowait() + except queue.Empty: + return + try: + out_q.put_nowait(item) + except queue.Full: + pass + + +def _stream_ntfy_to_queue(out_q: queue.Queue, stop_event: threading.Event) -> None: + """Bridge ntfy SSE events into queue with reconnect/backoff.""" + settings = _load_ntfy_bridge_settings() + ntfy_url = _build_ntfy_sse_url(settings["base_url"], settings["topics"]) + token = settings["token"] + backoff = 1.0 + + while not stop_event.is_set(): + try: + _queue_put_drop_oldest(out_q, { + "kind": "bridge_status", + "status": "UPSTREAM_CONNECTING", + "timestamp": datetime.now().isoformat(), + "source": "bridge", + "dedup_key": "bridge:upstream_connecting", + "severity": "info", + }) + for event_name, raw_data in _iter_ntfy_sse_events(ntfy_url, token, stop_event): + if stop_event.is_set(): + break + if event_name == "__bridge_open__": + _queue_put_drop_oldest(out_q, { + "kind": "bridge_status", + "status": "UPSTREAM_CONNECTED", + "timestamp": datetime.now().isoformat(), + "source": "bridge", + "dedup_key": "bridge:upstream_connected", + "severity": "info", + }) + backoff = 1.0 + continue + try: + parsed = json.loads(raw_data) + except json.JSONDecodeError: + continue + if not isinstance(parsed, dict): + continue + normalized = _normalize_ntfy_event(parsed) + if normalized is None: + continue + _queue_put_drop_oldest(out_q, normalized) + except Exception as e: + if stop_event.is_set(): + break + app.logger.warning(f"ntfy bridge disconnected, retrying in {backoff:.1f}s: {e}") + try: + _queue_put_drop_oldest(out_q, { + "kind": "bridge_status", + "status": "UPSTREAM_RECONNECTING", + "message": str(e), + "retry_sec": round(backoff, 1), + "timestamp": datetime.now().isoformat(), + "source": "bridge", + "dedup_key": f"bridge:{type(e).__name__}", + "severity": "warning", + }) + except Exception: + pass + if stop_event.wait(backoff): + break + backoff = min(backoff * 2.0, float(NTFY_SSE_MAX_BACKOFF_SEC)) + +def load_token() -> Optional[str]: + """Web UI 認証トークンをロード""" + if TOKEN_FILE.exists(): + return TOKEN_FILE.read_text().strip() + return None + +def verify_token() -> bool: + """リクエストのトークン検証(ヘッダーまたはクエリパラメータ)""" + token = load_token() + if not token: + return True # トークン未設定の場合はスルー + + req_token = ( + request.headers.get('X-AZAZEL-TOKEN') + or request.headers.get('X-Auth-Token') + or request.args.get('token') + ) + return req_token == token + + +def _pid_running(pid_path: Path) -> bool: + """Check whether the pid in pid_path is running.""" + try: + pid_text = pid_path.read_text().strip() + if not pid_text: + return False + pid = int(pid_text) + except Exception: + return False + try: + os.kill(pid, 0) + except ProcessLookupError: + return False + except PermissionError: + return True + return True + + +def _service_active(service: str) -> bool: + """Check systemd service status without requiring root.""" + try: + result = subprocess.run( + ["/bin/systemctl", "is-active", service], + capture_output=True, + text=True, + timeout=2, + ) + return result.returncode == 0 and result.stdout.strip() == "active" + except Exception: + return False + + +def _portal_viewer_config() -> Dict[str, Any]: + """Resolve portal viewer bind/port from env file.""" + default_bind = os.environ.get("PORTAL_NOVNC_BIND", os.environ.get("MGMT_IP", "10.55.0.10")) + default_port = int(os.environ.get("PORTAL_NOVNC_PORT", "6080")) + config = { + "bind": default_bind, + "port": default_port, + } + try: + if not PORTAL_VIEWER_ENV_PATH.exists(): + return config + for raw in PORTAL_VIEWER_ENV_PATH.read_text(encoding="utf-8").splitlines(): + line = raw.strip() + if not line or line.startswith("#") or "=" not in line: + continue + if line.startswith("export "): + line = line[7:].strip() + key, value = line.split("=", 1) + key = key.strip() + value = value.strip().strip('"').strip("'") + if key == "PORTAL_NOVNC_PORT": + config["port"] = int(value) + elif key == "PORTAL_NOVNC_BIND": + config["bind"] = value + except Exception: + return config + return config + + +def _probe_hosts_for_bind(bind_host: str) -> list: + """Build probe host candidates from bind address.""" + host = str(bind_host or "").strip() + if host.startswith("[") and host.endswith("]"): + host = host[1:-1] + if host in {"", "0.0.0.0", "::", "*"}: + return ["127.0.0.1", "::1"] + if host in {"localhost", "127.0.0.1", "::1"}: + return [host] + return [host, "127.0.0.1"] + + +def _tcp_open(port: int, hosts: list, timeout_sec: float = 0.2) -> bool: + """Check TCP availability on any candidate host.""" + seen = set() + for host in hosts: + if host in seen: + continue + seen.add(host) + try: + with socket.create_connection((host, int(port)), timeout=timeout_sec): + return True + except Exception: + continue + return False + + +def _url_host(host: str) -> str: + """Format host part for URL (wrap IPv6 if needed).""" + formatted = str(host or "").strip() + if ":" in formatted and not (formatted.startswith("[") and formatted.endswith("]")): + return f"[{formatted}]" + return formatted + + +def _is_wildcard_bind(host: str) -> bool: + host = str(host or "").strip() + return host in {"", "0.0.0.0", "::", "*"} + + +def _request_host_or_default() -> str: + if has_request_context(): + return request.host.split(":")[0] if request.host else "10.55.0.10" + return "10.55.0.10" + + +def _request_scheme_or_default() -> str: + if has_request_context(): + return request.scheme + return "http" + + +def _normalize_http_url(candidate: Any) -> str: + """Return normalized http(s) URL or empty string.""" + text = str(candidate or "").strip() + if not text or any(ch in text for ch in ("\r", "\n")): + return "" + parsed = urlparse(text) + if parsed.scheme not in ("http", "https") or not parsed.netloc: + return "" + return text + + +def _portal_start_url_from_state(state: Dict[str, Any]) -> str: + """Infer the best portal start URL from latest captive probe state.""" + if not isinstance(state, dict): + return "" + conn = state.get("connection") + if not isinstance(conn, dict): + return "" + + status = str(conn.get("captive_portal", "") or "").upper() + detail = conn.get("captive_portal_detail") if isinstance(conn.get("captive_portal_detail"), dict) else {} + candidates = [ + detail.get("portal_url"), + conn.get("captive_portal_url"), + detail.get("location"), + detail.get("effective_url"), + conn.get("captive_location"), + conn.get("captive_effective_url"), + detail.get("probe_url"), + conn.get("captive_probe_url"), + ] + for candidate in candidates: + normalized = _normalize_http_url(candidate) + if normalized: + return normalized + + if status in {"YES", "SUSPECTED"}: + return "http://connectivitycheck.gstatic.com/generate_204" + return "" + + +def _portal_viewer_url_host(config_bind: str) -> str: + """Choose host advertised to clients for noVNC URL.""" + override_host = os.environ.get("PORTAL_VIEWER_HOST", "").strip() + if override_host: + return override_host + if not _is_wildcard_bind(config_bind): + return config_bind + return _request_host_or_default() + + +def _portal_viewer_state_from_config(config: Dict[str, Any]) -> Dict[str, Any]: + """Build portal viewer state from resolved config + runtime checks.""" + bind_host = str(config.get("bind", "")).strip() or os.environ.get("MGMT_IP", "10.55.0.10") + port = int(config.get("port", 6080)) + probe_hosts = _probe_hosts_for_bind(bind_host) + active = _service_active("azazel-portal-viewer.service") + ready = active and _tcp_open(port, probe_hosts) + scheme = _request_scheme_or_default() + host = _url_host(_portal_viewer_url_host(bind_host)) + url = f"{scheme}://{host}:{port}/vnc.html?autoconnect=true&resize=scale" + return { + "active": active, + "ready": ready, + "bind": bind_host, + "probe_hosts": probe_hosts, + "port": port, + "url": url, + } + + +def get_portal_viewer_state() -> Dict[str, Any]: + """Return current noVNC portal viewer availability.""" + return _portal_viewer_state_from_config(_portal_viewer_config()) + + +def _ntfy_health_ok() -> bool: + """Check ntfy HTTP health endpoint.""" + mgmt_ip = os.environ.get("MGMT_IP", "10.55.0.10") + ntfy_port = os.environ.get("NTFY_PORT", "8081") + url = f"http://{mgmt_ip}:{ntfy_port}/v1/health" + try: + with urlopen(url, timeout=2) as resp: + if resp.status != 200: + return False + body = resp.read(256).decode("utf-8", errors="ignore") + return '"healthy":true' in body + except Exception: + return False + + +def get_monitoring_state() -> Dict[str, str]: + """Return ON/OFF status for local monitoring daemons.""" + # Prefer systemd state to avoid pidfile permission issues + opencanary_ok = _service_active("opencanary.service") + suricata_ok = _service_active("suricata.service") + ntfy_ok = _service_active("ntfy.service") and _ntfy_health_ok() + opencanary_pid = Path("/home/azazel/canary-venv/bin/opencanaryd.pid") + suricata_pid = Path("/run/suricata.pid") + return { + "opencanary": "ON" if (opencanary_ok or _pid_running(opencanary_pid)) else "OFF", + "suricata": "ON" if (suricata_ok or _pid_running(suricata_pid)) else "OFF", + "ntfy": "ON" if ntfy_ok else "OFF", + } + + +def _mode_to_webui_state(mode: str) -> Tuple[str, int]: + """Map Azazel-Pi mode name to Web UI state/suspicion.""" + normalized = str(mode or "").strip().lower() + if normalized in {"lockdown", "user_lockdown"}: + return ("CONTAIN", 85) + if normalized in {"shield", "user_shield"}: + return ("DEGRADED", 55) + if normalized in {"portal", "user_portal"}: + return ("NORMAL", 25) + return ("NORMAL", 0) + + +def _read_azctl_status_snapshot() -> Optional[Dict[str, Any]]: + """Build a WebUI-compatible snapshot from `azctl.cli status --json`.""" + try: + result = subprocess.run( + ["python3", "-m", "azctl.cli", "status", "--json"], + cwd=str(PROJECT_ROOT), + capture_output=True, + text=True, + timeout=6, + ) + if result.returncode != 0: + return None + payload = json.loads(result.stdout) + if not isinstance(payload, dict): + return None + + mode = str(payload.get("defensive_mode") or "portal") + state_name, suspicion = _mode_to_webui_state(mode) + wan = payload.get("wlan1") if isinstance(payload.get("wlan1"), dict) else {} + connected = bool(wan.get("connected")) + recommendation = { + "CONTAIN": "Contain traffic and investigate immediately", + "DEGRADED": "Enhanced monitoring and partial throttling", + "NORMAL": "Nominal operation", + }.get(state_name, "Nominal operation") + + return { + "ok": True, + "source": "AZCTL_STATUS", + "ts": datetime.now().isoformat(), + "now_time": datetime.now().strftime("%H:%M:%S"), + "internal": { + "state_name": state_name, + "suspicion": suspicion, + }, + "recommendation": recommendation, + "reasons": [f"defensive_mode={mode}"], + "ssid": wan.get("ssid") or "-", + "bssid": wan.get("bssid") or "-", + "gateway_ip": wan.get("gateway") or "-", + "signal_dbm": wan.get("signal_dbm"), + "connection": { + "wifi_state": "CONNECTED" if connected else "DISCONNECTED", + "internet_check": "OK" if connected else "UNKNOWN", + "captive_portal": "NO", + "usb_nat": "UNKNOWN", + }, + "degrade": { + "on": state_name in {"DEGRADED", "CONTAIN"}, + "rate_mbps": 0, + }, + "quic": "ALLOWED", + "doh": "BLOCKED", + "probe": {}, + "suricata_critical": 0, + "suricata_warning": 0, + } + except Exception: + return None + + +def _apply_pi_mode(mode: str) -> Dict[str, Any]: + """Apply Azazel-Pi mode by invoking `azctl.cli events` with a temp config.""" + event_name = str(mode or "").strip().lower() + if event_name not in {"portal", "shield", "lockdown"}: + return {"ok": False, "action": mode, "error": f"Unsupported mode: {mode}"} + + temp_path: Optional[Path] = None + try: + with tempfile.NamedTemporaryFile( + mode="w", + suffix=".yaml", + prefix="azazel-webui-", + delete=False, + encoding="utf-8", + ) as tf: + tf.write("events:\n") + tf.write(f" - name: {event_name}\n") + tf.write(" severity: 0\n") + temp_path = Path(tf.name) + + result = subprocess.run( + ["python3", "-m", "azctl.cli", "events", "--config", str(temp_path)], + cwd=str(PROJECT_ROOT), + capture_output=True, + text=True, + timeout=15, + ) + if result.returncode != 0: + stderr = (result.stderr or "").strip() + return { + "ok": False, + "action": event_name, + "error": stderr or "azctl.cli events failed", + } + return { + "ok": True, + "action": event_name, + "message": f"Mode switched to {event_name}", + } + except Exception as e: + return {"ok": False, "action": event_name, "error": str(e)} + finally: + if temp_path is not None: + try: + temp_path.unlink(missing_ok=True) + except Exception: + pass + + +def read_state() -> Dict[str, Any]: + """Read snapshot from control-plane first, then filesystem fallback.""" + try: + if cp_read_snapshot_payload is not None: + data, source = cp_read_snapshot_payload(prefer_control_plane=True, logger=app.logger) + if isinstance(data, dict): + data["ok"] = True + data["source"] = source + return data + + # Try primary path (TUI snapshot) + path = STATE_PATH + if not path.exists(): + # Try fallback path (for dev/testing) + path = FALLBACK_STATE_PATH + + if not path.exists(): + pi_snapshot = _read_azctl_status_snapshot() + if pi_snapshot is not None: + return pi_snapshot + return { + "ok": False, + "error": "ui_snapshot.json not found", + "source": "NONE", + "ts": time.strftime("%Y-%m-%dT%H:%M:%S") + } + + warn_if_legacy_path(path, logger=app.logger) + data = json.loads(path.read_text(encoding="utf-8")) + data["ok"] = True + data.setdefault("source", f"FILE:{path}") + return data + except Exception as e: + return { + "ok": False, + "error": f"Failed to read state: {str(e)}", + "ts": time.strftime("%Y-%m-%dT%H:%M:%S") + } + + +def _normalize_status_payload(payload: Dict[str, Any], action: str = "") -> Dict[str, Any]: + """Normalize Status API payload shape.""" + if payload.get("status") == "ok" and "ok" not in payload: + payload["ok"] = True + if action and "action" not in payload: + payload["action"] = action + return payload + + +def _status_api_json( + host: str, + path: str, + method: str = "GET", + timeout_sec: float = 2.0, + action: str = "", + empty_ok: bool = False, + empty_message: str = "", +) -> Optional[Dict[str, Any]]: + """Call first-minute Status API and return normalized JSON payload if available.""" + try: + cmd: List[str] = ["curl", "-s"] + if method.upper() == "POST": + cmd.extend(["-X", "POST"]) + cmd.append(f"http://{host}:8082{path}") + + result = subprocess.run(cmd, capture_output=True, timeout=timeout_sec) + if result.returncode != 0: + return None + + response_text = result.stdout.decode("utf-8").strip() + if not response_text: + if not empty_ok: + return None + payload: Dict[str, Any] = {"ok": True} + if action: + payload["action"] = action + if empty_message: + payload["message"] = empty_message + return payload + + payload = json.loads(response_text) + if not isinstance(payload, dict): + return None + return _normalize_status_payload(payload, action=action) + except Exception: + return None + + +def _first_status_api_response( + path: str, + method: str = "GET", + action: str = "", + empty_ok: bool = False, + empty_message: str = "", +) -> Optional[Dict[str, Any]]: + """Try Status API hosts in order and return first successful JSON payload.""" + for host in STATUS_API_HOSTS: + payload = _status_api_json( + host=host, + path=path, + method=method, + action=action, + empty_ok=empty_ok, + empty_message=empty_message, + ) + if payload is not None: + return payload + return None + + +def execute_contain_action() -> Dict[str, Any]: + """Execute contain action: activate CONTAIN stage via Status API""" + try: + payload = _first_status_api_response( + path="/action/contain", + method="POST", + action="contain", + empty_ok=True, + empty_message="Containment activated", + ) + if payload is not None: + return payload + return _apply_pi_mode("lockdown") + except Exception as e: + return {"ok": False, "action": "contain", "error": str(e)} + +def execute_disconnect_action() -> Dict[str, Any]: + """Execute disconnect action: disconnect downstream USB clients""" + try: + # Try to send to Status API first + payload = _first_status_api_response( + path="/action/disconnect", + method="POST", + action="disconnect", + empty_ok=False, + ) + if payload is not None: + return payload + + # Fallback: attempt to bring down upstream Wi-Fi interface + iface = os.environ.get("AZAZEL_UP_IF", "wlan0") + down_result = subprocess.run( + ["ip", "link", "set", iface, "down"], + capture_output=True, + timeout=5 + ) + if down_result.returncode != 0: + stderr = down_result.stderr.decode("utf-8").strip() if down_result.stderr else "unknown error" + return { + "ok": False, + "action": "disconnect", + "error": f"Fallback disconnect failed: {iface} down failed: {stderr}" + } + + return {"ok": True, "action": "disconnect", "message": f"Wi-Fi disconnected ({iface} down)"} + except Exception as e: + return {"ok": False, "action": "disconnect", "error": str(e)} + +def _post_status_action(host: str, action: str) -> Optional[Dict[str, Any]]: + """POST action to first-minute Status API and normalize response.""" + return _status_api_json( + host=host, + path=f"/action/{action}", + method="POST", + action=action, + empty_ok=True, + ) + +def _read_status_state(host: str) -> Optional[Dict[str, Any]]: + """GET current state from first-minute Status API.""" + return _status_api_json(host=host, path="/", method="GET", empty_ok=False) + +def execute_release_action() -> Dict[str, Any]: + """Execute release action and verify that stage actually leaves CONTAIN.""" + try: + for host in STATUS_API_HOSTS: + first = _post_status_action(host, "release") + if not first: + continue + if not first.get("ok", False): + return { + "ok": False, + "action": "release", + "error": first.get("error", "Failed to send release command"), + } + + second_sent = False + last_reason = "" + deadline = time.time() + 12.0 + + while time.time() < deadline: + state_payload = _read_status_state(host) + if not state_payload: + time.sleep(0.25) + continue + + state_name = str(state_payload.get("stage") or state_payload.get("state") or "").upper() + reason = str(state_payload.get("reason") or "").strip() + if reason: + last_reason = reason + + if state_name and state_name != "CONTAIN": + return { + "ok": True, + "action": "release", + "message": "Containment released", + "state": state_name, + "reason": reason, + } + + reason_lower = reason.lower() + if "minimum duration not reached" in reason_lower: + return {"ok": False, "action": "release", "error": reason} + + if ("confirmation required" in reason_lower) and not second_sent: + second = _post_status_action(host, "release") + if not second or not second.get("ok", False): + return { + "ok": False, + "action": "release", + "error": (second or {}).get("error", "Failed to send release confirmation"), + } + second_sent = True + + time.sleep(0.25) + + if last_reason: + return {"ok": False, "action": "release", "error": f"Release timeout: {last_reason}"} + return {"ok": False, "action": "release", "error": "Release timeout: stage stayed CONTAIN"} + + return _apply_pi_mode("portal") + except Exception as e: + return {"ok": False, "action": "release", "error": str(e)} + +def execute_details_action() -> Dict[str, Any]: + """Execute details action: get detailed threat analysis from Status API""" + try: + payload = _first_status_api_response( + path="/details", + method="GET", + action="details", + empty_ok=True, + empty_message="No details available", + ) + if payload is not None: + return payload + snapshot = _read_azctl_status_snapshot() + if snapshot is not None: + return {"ok": True, "action": "details", "details": snapshot} + return {"ok": False, "action": "details", "error": "Failed to reach Status API on any host"} + except Exception as e: + return {"ok": False, "action": "details", "error": str(e)} + +def execute_stage_open_action() -> Dict[str, Any]: + """Execute stage_open action: return to NORMAL stage via Status API""" + try: + payload = _first_status_api_response( + path="/action/stage_open", + method="POST", + action="stage_open", + empty_ok=True, + empty_message="Stage opened", + ) + if payload is not None: + return payload + return _apply_pi_mode("portal") + except Exception as e: + return {"ok": False, "action": "stage_open", "error": str(e)} + + +def _send_control_command_socket( + action: str, + params: Optional[Dict[str, Any]] = None, + timeout_sec: float = 5.0, +) -> Dict[str, Any]: + """Send command to Control Daemon via Unix socket.""" + if not CONTROL_SOCKET.exists(): + return { + "ok": False, + "action": action, + "error": "Control daemon not running", + "ts": time.strftime("%Y-%m-%dT%H:%M:%S") + } + + try: + sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) + sock.settimeout(timeout_sec) + sock.connect(str(CONTROL_SOCKET)) + + command: Dict[str, Any] = {"action": action, "ts": time.time()} + if params is not None: + command["params"] = params + + sock.sendall(json.dumps(command).encode("utf-8") + b"\n") + + response = b"" + while True: + chunk = sock.recv(4096) + if not chunk: + break + response += chunk + if b"\n" in chunk: + break + sock.close() + + if response: + return json.loads(response.decode("utf-8")) + return { + "ok": False, + "action": action, + "error": "Empty response from daemon", + "ts": time.strftime("%Y-%m-%dT%H:%M:%S") + } + except socket.timeout: + return { + "ok": False, + "action": action, + "error": "Daemon timeout", + "ts": time.strftime("%Y-%m-%dT%H:%M:%S") + } + except Exception as e: + return { + "ok": False, + "action": action, + "error": str(e), + "ts": time.strftime("%Y-%m-%dT%H:%M:%S") + } + +def send_control_command(action: str) -> Dict[str, Any]: + """Send command to Control Daemon via Unix socket""" + if action not in ALLOWED_ACTIONS: + return { + "ok": False, + "action": action, + "error": "Unknown action", + "ts": time.strftime("%Y-%m-%dT%H:%M:%S") + } + + direct_handlers = { + "contain": execute_contain_action, + "release": execute_release_action, + "disconnect": execute_disconnect_action, + "details": execute_details_action, + "stage_open": execute_stage_open_action, + } + handler = direct_handlers.get(action) + if handler is not None: + return handler() + if action == "portal_viewer_open": + return send_control_command_with_params("portal_viewer_open", {"timeout_sec": 15}) + return _send_control_command_socket(action=action, params=None, timeout_sec=5.0) + + +def send_control_command_with_params(action: str, params: Dict[str, Any]) -> Dict[str, Any]: + """Send command with parameters to Control Daemon via Unix socket""" + if action not in ALLOWED_ACTIONS: + return { + "ok": False, + "action": action, + "error": "Unknown action", + "ts": time.strftime("%Y-%m-%dT%H:%M:%S") + } + return _send_control_command_socket(action=action, params=params, timeout_sec=30.0) + + +# Web UI Routes + +@app.route("/") +def index(): + """Main dashboard page""" + return render_template("index.html") + + +@app.route("/api/state") +def api_state(): + """GET /api/state - Return current state.json""" + if not verify_token(): + return jsonify({"error": "Unauthorized"}), 403 + + state = read_state() + # Add local monitoring status + state["monitoring"] = get_monitoring_state() + state["portal_viewer"] = get_portal_viewer_state() + return jsonify(state) + + +@app.route("/api/state/stream") +def api_state_stream(): + """GET /api/state/stream - SSE stream for control-plane snapshot updates.""" + if not verify_token(): + return jsonify({"error": "Unauthorized"}), 403 + + headers = { + "Cache-Control": "no-cache", + "Connection": "keep-alive", + "X-Accel-Buffering": "no", + } + + def generate() -> Iterator[str]: + yield "event: ready\ndata: " + json.dumps({"ok": True, "source": "state_stream"}) + "\n\n" + if cp_watch_snapshots is not None: + for snap in cp_watch_snapshots(interval_sec=1.0): + payload = dict(snap) + payload["ok"] = True + payload["monitoring"] = get_monitoring_state() + payload["portal_viewer"] = get_portal_viewer_state() + yield "event: state\ndata: " + json.dumps(payload, ensure_ascii=False) + "\n\n" + return + while True: + payload = read_state() + payload["monitoring"] = get_monitoring_state() + payload["portal_viewer"] = get_portal_viewer_state() + yield "event: state\ndata: " + json.dumps(payload, ensure_ascii=False) + "\n\n" + time.sleep(1.0) + + return Response(stream_with_context(generate()), headers=headers, mimetype="text/event-stream") + + +@app.route("/api/portal-viewer") +def api_portal_viewer(): + """GET /api/portal-viewer - Return portal viewer status and URL.""" + if not verify_token(): + return jsonify({"error": "Unauthorized"}), 403 + return jsonify(get_portal_viewer_state()) + + +@app.route("/api/portal-viewer/open", methods=["POST"]) +def api_portal_viewer_open(): + """POST /api/portal-viewer/open - Ensure noVNC is up then return URL.""" + if not verify_token(): + return jsonify({"ok": False, "error": "Unauthorized"}), 403 + + request_body = request.get_json(silent=True) or {} + timeout_sec = request_body.get("timeout_sec", 15) + start_url = _normalize_http_url(request_body.get("start_url", "")) + if not start_url: + state = read_state() + if state.get("ok"): + start_url = _portal_start_url_from_state(state) + + params = {"timeout_sec": timeout_sec} + if start_url: + params["start_url"] = start_url + daemon_result = send_control_command_with_params( + "portal_viewer_open", + params, + ) + + if not daemon_result.get("ok"): + return jsonify({ + "ok": False, + "error": daemon_result.get("error", "Failed to start portal viewer"), + "portal_viewer": get_portal_viewer_state(), + "daemon": daemon_result, + }), 500 + + portal_state = get_portal_viewer_state() + if not portal_state.get("ready"): + return jsonify({ + "ok": False, + "error": ( + "Portal viewer service started, but noVNC is not reachable " + f"(bind={portal_state.get('bind')}, probe={portal_state.get('probe_hosts')})" + ), + "portal_viewer": portal_state, + "daemon": daemon_result, + }), 500 + + resolved_start_url = start_url or str(daemon_result.get("start_url", "") or "") + return jsonify({ + "ok": True, + "url": portal_state.get("url"), + "start_url": resolved_start_url, + "portal_viewer": portal_state, + "daemon": daemon_result, + }), 200 + + +@app.route("/api/certs/azazel-webui-local-ca/meta") +def api_webui_ca_meta(): + """GET certificate metadata for client-side trust onboarding.""" + cert_path, checked_paths = _resolve_webui_ca_cert_path() + if not cert_path.exists(): + return jsonify({ + "ok": False, + "error": "CA certificate not found", + "path": str(cert_path), + "checked_paths": [str(p) for p in checked_paths], + }), 404 + + try: + stat = cert_path.stat() + return jsonify({ + "ok": True, + "path": str(cert_path), + "filename": cert_path.name, + "sha256": _sha256_file(cert_path), + "size_bytes": stat.st_size, + "updated_at": datetime.fromtimestamp(stat.st_mtime).isoformat(), + "download_url": "/api/certs/azazel-webui-local-ca.crt", + }) + except Exception as e: + return jsonify({ + "ok": False, + "error": f"Failed to inspect CA certificate: {e}", + }), 500 + + +@app.route("/api/certs/azazel-webui-local-ca.crt") +def api_webui_ca_download(): + """Download local CA certificate used by Caddy internal TLS.""" + cert_path, checked_paths = _resolve_webui_ca_cert_path() + if not cert_path.exists(): + return jsonify({ + "ok": False, + "error": "CA certificate not found", + "path": str(cert_path), + "checked_paths": [str(p) for p in checked_paths], + }), 404 + + return send_file( + cert_path, + mimetype="application/x-x509-ca-cert", + as_attachment=True, + download_name="azazel-webui-local-ca.crt", + conditional=True, + ) + + +@app.route("/api/events/stream") +def api_events_stream(): + """GET /api/events/stream - SSE bridge for ntfy topic events.""" + if not verify_token(): + return jsonify({"error": "Unauthorized"}), 403 + + def generate() -> Iterator[str]: + out_q: "queue.Queue[Dict[str, Any]]" = queue.Queue(maxsize=256) + stop_event = threading.Event() + worker = threading.Thread( + target=_stream_ntfy_to_queue, + args=(out_q, stop_event), + daemon=True, + ) + worker.start() + last_keepalive = time.monotonic() + + # Initial stream event for UI diagnostics + yield _sse_message("azazel", { + "kind": "bridge_status", + "status": "STREAM_CONNECTED", + "timestamp": datetime.now().isoformat(), + "source": "bridge", + "dedup_key": "bridge:stream_connected", + "severity": "info", + }) + + try: + while not stop_event.is_set(): + try: + item = out_q.get(timeout=1.0) + yield _sse_message("azazel", item) + except queue.Empty: + pass + + now = time.monotonic() + if now - last_keepalive >= NTFY_SSE_KEEPALIVE_SEC: + # Safari対策: 定期keepaliveを送る + yield ": keepalive\n\n" + last_keepalive = now + except GeneratorExit: + pass + finally: + stop_event.set() + worker.join(timeout=0.2) + + headers = { + "Cache-Control": "no-cache", + "Connection": "keep-alive", + "X-Accel-Buffering": "no", + } + return Response(stream_with_context(generate()), headers=headers, mimetype="text/event-stream") + + +@app.route("/api/action", methods=["POST"]) +def api_action_new(): + """POST /api/action - Execute control action (AI Coding Spec v1 format)""" + if not verify_token(): + return jsonify({"error": "Unauthorized"}), 403 + + data = request.json + if not data or 'action' not in data: + return jsonify({ + "status": "error", + "message": "Missing action" + }), 400 + + action = data['action'] + if action not in ALLOWED_ACTIONS: + return jsonify({ + "status": "error", + "message": f"Forbidden action: {action}" + }), 403 + + result = send_control_command(action) + + # Convert to AI Coding Spec format + if result.get("ok"): + return jsonify({"status": "ok", "message": result.get("message", "Action executed")}), 200 + else: + return jsonify({"status": "error", "message": result.get("error", "Unknown error")}), 500 + + +@app.route("/api/action/", methods=["POST"]) +def api_action(action: str): + """POST /api/action/ - Execute control action (legacy format)""" + if not verify_token(): + return jsonify({"ok": False, "error": "Unauthorized"}), 403 + + # Validate action + if action not in ALLOWED_ACTIONS: + return jsonify({ + "ok": False, + "error": f"Unknown action: {action}" + }), 404 + + # Forward to Control Daemon + result = send_control_command(action) + + if result.get("ok"): + return jsonify(result), 200 + else: + return jsonify(result), 500 + + +@app.route("/api/wifi/scan", methods=["GET"]) +def api_wifi_scan(): + """GET /api/wifi/scan - Scan for Wi-Fi access points""" + # No token required (read-only operation) + + result = send_control_command_with_params("wifi_scan", {}) + + if result.get("ok"): + return jsonify(result), 200 + else: + return jsonify(result), 500 + + +@app.route("/api/wifi/connect", methods=["POST"]) +def api_wifi_connect(): + """POST /api/wifi/connect - Connect to Wi-Fi AP""" + if not verify_token(): + return jsonify({"ok": False, "error": "Unauthorized"}), 401 + + data = request.json + if not data: + return jsonify({"ok": False, "error": "Missing request body"}), 400 + + # Extract parameters + ssid = data.get("ssid") + security = data.get("security", "UNKNOWN") + passphrase = data.get("passphrase") + saved = bool(data.get("saved", False)) + persist = bool(data.get("persist", False)) + + # Validation + if not ssid: + return jsonify({"ok": False, "error": "Missing SSID"}), 400 + + # For OPEN networks, discard passphrase if present + if security == "OPEN": + passphrase = None + # OPEN AP profiles are always ephemeral to avoid stale remembered entries. + persist = False + elif not passphrase and not saved: + # Non-OPEN network requires passphrase unless already saved + return jsonify({"ok": False, "error": "Passphrase required for protected network"}), 400 + + # NEVER log request body for this endpoint + app.logger.info(f"Wi-Fi connect request: SSID={ssid}, Security={security} (passphrase sanitized)") + + # Forward to Control Daemon + params = { + "ssid": ssid, + "security": security, + "passphrase": passphrase, + "persist": persist, + "saved": saved + } + + result = send_control_command_with_params("wifi_connect", params) + + if result.get("ok"): + return jsonify(result), 200 + else: + return jsonify(result), 500 + + +@app.route("/static/") +def static_files(filename): + """Serve static files""" + return send_from_directory("static", filename) + + +@app.route("/health") +def health(): + """ヘルスチェック(認証不要)""" + return jsonify({ + "status": "ok", + "service": "azazel-web", + "timestamp": datetime.now().isoformat() + }) + + +# Error handlers + +@app.errorhandler(404) +def not_found(e): + return jsonify({"ok": False, "error": "Not found"}), 404 + + +@app.errorhandler(500) +def server_error(e): + return jsonify({"ok": False, "error": "Server error"}), 500 + + +if __name__ == "__main__": + print(f"🛡️ Azazel-Gadget Web UI starting...") + print(f" Bind: {BIND_HOST}:{BIND_PORT}") + print(f" State: {STATE_PATH}") + print(f" Control: {CONTROL_SOCKET}") + + if load_token(): + print(f" 🔒 Token authentication enabled") + else: + print(f" ⚠️ WARNING: No token configured (open access)") + + app.run( + host=BIND_HOST, + port=BIND_PORT, + debug=False, + threaded=True + ) diff --git a/azazel_web/static/app.js b/azazel_web/static/app.js new file mode 100644 index 0000000..790d2fa --- /dev/null +++ b/azazel_web/static/app.js @@ -0,0 +1,1024 @@ +// Azazel-Gadget Web UI Frontend +// Polls /api/state every 2 seconds + +const AUTH_TOKEN = localStorage.getItem('azazel_token') || 'azazel-default-token-change-me'; +let updateInterval; +let portalViewerOpening = false; +let portalReprobeRunning = false; +let eventSource = null; +let unreadEventCount = 0; +let lastEventSourceErrorToastAt = 0; +const eventDedupMap = new Map(); +const EVENT_DEDUP_WINDOW_MS = 12000; +const EVENT_LOG_MAX_ITEMS = 50; +let caCertificateDownloadUrl = '/api/certs/azazel-webui-local-ca.crt'; + +// Initialize +document.addEventListener('DOMContentLoaded', () => { + fetchState(); + updateInterval = setInterval(fetchState, 2000); // Poll every 2 seconds + initLiveNotifications(); + startEventStream(); +}); + +window.addEventListener('beforeunload', () => { + if (eventSource) { + eventSource.close(); + eventSource = null; + } +}); + +// Fetch state from API +async function fetchState() { + try { + const res = await fetch('/api/state', { + headers: { + 'X-Auth-Token': AUTH_TOKEN + } + }); + const data = await res.json(); + + if (!data.ok) { + showToast(`Error: ${data.error}`, 'error'); + displayErrorState(); + return; + } + + updateUI(data); + } catch (e) { + console.error('Failed to fetch state:', e); + showToast('Connection error', 'error'); + } +} + +// Update UI with state data +function updateUI(state) { + // Map ui_snapshot.json fields to UI elements + + // Header + updateElement('headerClock', state.now_time || '--:--:--'); + + // Risk Assessment (based on internal state) + const internal = state.internal || {}; + const suspicion = internal.suspicion || 0; + const stateVal = (internal.state_name || 'NORMAL').toUpperCase(); + + // Update score circle + const scoreCircle = document.getElementById('scoreCircle'); + scoreCircle.textContent = suspicion; + + const statusEl = document.getElementById('riskStatus'); + const cardEl = document.getElementById('cardRisk'); + const statusClass = getStatusClass(stateVal); + + scoreCircle.className = `score-circle ${statusClass}`; + statusEl.className = `risk-status ${statusClass}`; + statusEl.textContent = mapState(stateVal); + cardEl.className = `card card-risk ${statusClass}`; + + // Toggle Contain/Release buttons based on state + const containBtn = document.getElementById('containBtn'); + const releaseBtn = document.getElementById('releaseBtn'); + if (containBtn && releaseBtn) { + if (stateVal === 'CONTAIN') { + containBtn.style.display = 'none'; + releaseBtn.style.display = 'inline-flex'; + } else { + containBtn.style.display = 'inline-flex'; + releaseBtn.style.display = 'none'; + } + } + + // Threat level based on suspicion + let threatLevel = 'LOW'; + if (suspicion >= 50) threatLevel = 'CRITICAL'; + else if (suspicion >= 30) threatLevel = 'HIGH'; + else if (suspicion >= 15) threatLevel = 'MEDIUM'; + updateElement('riskThreatLevel', threatLevel); + + updateElement('riskRecommendation', state.recommendation || '-'); + updateElement('riskReason', (state.reasons || [])[0] || '-'); + + // Monitoring status + const monitoring = state.monitoring || {}; + updateBadge('riskSuricata', monitoring.suricata || 'UNKNOWN'); + updateBadge('riskOpenCanary', monitoring.opencanary || 'UNKNOWN'); + updateBadge('riskNtfy', monitoring.ntfy || 'UNKNOWN'); + + // Connection Info + updateElement('connSSID', state.ssid || '-'); + updateElement('connBSSID', state.bssid || '-'); + updateElement('connGateway', state.gateway_ip || '-'); + updateElement('connSignal', `${state.signal_dbm || '-'} dBm`); + + // Wi-Fi Connection State + const connection = state.connection || {}; + updateBadge('wifiState', connection.wifi_state || 'DISCONNECTED'); + updateBadge('usbNat', connection.usb_nat || 'OFF'); + updateBadge('internetCheck', connection.internet_check || 'UNKNOWN'); + + // Captive Portal Warning + const captivePortal = connection.captive_portal || 'NO'; + const captiveWarning = document.getElementById('captivePortalWarning'); + if (captivePortal === 'SUSPECTED' || captivePortal === 'YES') { + captiveWarning.style.display = 'block'; + } else { + captiveWarning.style.display = 'none'; + } + + const portalViewer = state.portal_viewer || {}; + const portalViewerRow = document.getElementById('portalViewerRow'); + const portalViewerBtn = document.getElementById('portalViewerBtn'); + const portalReprobeRow = document.getElementById('portalReprobeRow'); + const portalReprobeBtn = document.getElementById('portalReprobeBtn'); + const shouldShowPortalButton = ( + (captivePortal === 'SUSPECTED' || captivePortal === 'YES') && + portalViewer.url + ); + if (portalViewerRow && portalViewerBtn) { + if (shouldShowPortalButton) { + portalViewerRow.style.display = 'flex'; + portalViewerBtn.dataset.url = portalViewer.url; + if (!portalViewerOpening) { + portalViewerBtn.disabled = false; + if (portalViewer.ready) { + portalViewerBtn.textContent = '🧭 Open Portal'; + portalViewerBtn.title = ''; + } else if (portalViewer.active) { + portalViewerBtn.textContent = '⏳ Preparing Portal'; + portalViewerBtn.title = 'Portal viewer is starting'; + } else { + portalViewerBtn.textContent = '▶ Start & Open Portal'; + portalViewerBtn.title = 'Start azazel-portal-viewer.service and open noVNC'; + } + } + } else { + portalViewerRow.style.display = 'none'; + delete portalViewerBtn.dataset.url; + portalViewerOpening = false; + portalViewerBtn.disabled = false; + portalViewerBtn.textContent = '🧭 Open Portal'; + portalViewerBtn.title = ''; + } + } + if (portalReprobeRow && portalReprobeBtn) { + if (captivePortal === 'SUSPECTED' || captivePortal === 'YES') { + portalReprobeRow.style.display = 'flex'; + if (!portalReprobeRunning) { + portalReprobeBtn.disabled = false; + portalReprobeBtn.textContent = '✅ Auth Done & Re-Probe'; + portalReprobeBtn.title = 'Run Re-Probe after portal login'; + } + } else { + portalReprobeRow.style.display = 'none'; + portalReprobeRunning = false; + portalReprobeBtn.disabled = false; + portalReprobeBtn.textContent = '✅ Auth Done & Re-Probe'; + portalReprobeBtn.title = ''; + } + } + + // Control & Safety + const degrade = state.degrade || {}; + updateBadge('ctrlDegrade', degrade.on ? 'ON' : 'OFF'); + updateBadge('ctrlQUIC', state.quic || 'ALLOWED'); + updateBadge('ctrlDoH', state.doh || 'BLOCKED'); + const downMbps = degrade.rate_mbps || 0; + const upMbps = degrade.rate_mbps || 0; + updateElement('ctrlSpeed', `${downMbps} / ${upMbps}`); + + // Security - Probe results + const probe = state.probe || {}; + const probeStatus = probe.tls_total > 0 + ? `${probe.tls_ok}/${probe.tls_total} ✓` + (probe.blocked > 0 ? ` (${probe.blocked} blocked)` : '') + : '-'; + updateElement('ctrlProbe', probeStatus); + + // Security - IDS (Suricata alerts) + const suricataCritical = state.suricata_critical || 0; + const suricataWarning = state.suricata_warning || 0; + let idsStatus = '-'; + if (suricataCritical > 0 || suricataWarning > 0) { + const parts = []; + if (suricataCritical > 0) parts.push(`${suricataCritical} critical`); + if (suricataWarning > 0) parts.push(`${suricataWarning} warning`); + idsStatus = parts.join(', '); + } + updateElement('ctrlIDS', idsStatus); + + // Evidence + updateBadge('evidState', mapState(stateVal)); + updateElement('evidSuspicion', suspicion); + + // Scan Results - Channel congestion and AP count + const channelCongestion = state.channel_congestion || 'unknown'; + const apCount = state.channel_ap_count || 0; + const scanStatus = apCount > 0 + ? `${apCount} APs (${channelCongestion})` + : '-'; + updateElement('evidScan', scanStatus); + + // Decision - State + Suspicion + const decisionText = `State: ${mapState(stateVal)}, Suspicion: ${suspicion}`; + updateElement('evidDecision', decisionText); + + // System Health Card + updateElement('sysCPUTemp', `${state.temp_c || '--'}°C`); + updateElement('sysCPUUsage', `${state.cpu_percent || '--'}%`); + updateElement('sysMemUsage', `${state.mem_percent || '--'}%`); +} + +// Map state names between different systems +function mapState(state) { + const map = { + 'NORMAL': 'SAFE', + 'PROBE': 'CHECKING', + 'DEGRADED': 'LIMITED', + 'CONTAIN': 'CONTAINED', + 'DECEPTION': 'DECEPTION', + 'INIT': 'CHECKING' + }; + return map[state] || state; +} + +// Get CSS class for status +function getStatusClass(status) { + const lower = (status || '').toLowerCase(); + if (lower === 'normal') return 'normal'; + if (lower === 'probe') return 'degraded'; + if (lower === 'degraded') return 'degraded'; + if (lower === 'contain') return 'contained'; + if (lower === 'deception') return 'lockdown'; + return 'normal'; +} + +// Helper: Update element text +function updateElement(id, text) { + const el = document.getElementById(id); + if (el) { + el.textContent = text; + } +} + +// Helper: Update badge with color +function updateBadge(id, value) { + const el = document.getElementById(id); + if (!el) return; + + el.textContent = value; + + // Remove all possible classes + el.classList.remove('allowed', 'blocked', 'on', 'off', 'normal', 'degraded', 'contained', 'lockdown'); + + // Add appropriate class + const valueLower = value.toLowerCase(); + if (valueLower === 'allowed') { + el.classList.add('allowed'); + } else if (valueLower === 'blocked') { + el.classList.add('blocked'); + } else if (valueLower === 'on') { + el.classList.add('on'); + } else if (valueLower === 'off') { + el.classList.add('off'); + } else if (valueLower === 'normal') { + el.classList.add('normal'); + } else if (valueLower === 'degraded') { + el.classList.add('degraded'); + } else if (valueLower === 'contained') { + el.classList.add('contained'); + } else if (valueLower === 'lockdown') { + el.classList.add('lockdown'); + } +} + +async function openPortalViewer() { + const btn = document.getElementById('portalViewerBtn'); + if (!btn || !btn.dataset.url) { + showToast('Portal viewer is not ready', 'error'); + return; + } + + if (portalViewerOpening) { + return; + } + + portalViewerOpening = true; + btn.disabled = true; + btn.textContent = '⏳ Starting Portal...'; + + // Keep user gesture context to reduce popup blocking. + const popup = window.open('', '_blank'); + + try { + const res = await fetch('/api/portal-viewer/open', { + method: 'POST', + headers: { + 'X-Auth-Token': AUTH_TOKEN, + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ timeout_sec: 18 }) + }); + const data = await res.json(); + if (!res.ok || !data.ok || !data.url) { + if (popup && !popup.closed) popup.close(); + showToast(`Portal open failed: ${data.error || 'unknown error'}`, 'error'); + return; + } + + if (popup && !popup.closed) { + popup.opener = null; + popup.location.href = data.url; + } else { + window.open(data.url, '_blank', 'noopener,noreferrer'); + } + showToast('Portal viewer ready', 'success'); + } catch (e) { + if (popup && !popup.closed) popup.close(); + showToast(`Portal open failed: ${e.message}`, 'error'); + } finally { + portalViewerOpening = false; + btn.disabled = false; + setTimeout(fetchState, 400); + } +} + +async function completePortalAuthReprobe() { + const btn = document.getElementById('portalReprobeBtn'); + if (!btn || portalReprobeRunning) { + return; + } + + portalReprobeRunning = true; + btn.disabled = true; + btn.textContent = '⏳ Re-Probing...'; + + try { + const data = await postAction('reprobe'); + if (data.ok) { + showToast('🔍 Re-Probe started', 'success'); + } else { + showToast(`❌ Re-Probe failed: ${data.error || 'unknown error'}`, 'error'); + } + } catch (e) { + showToast(`❌ Re-Probe failed: ${e.message}`, 'error'); + } finally { + portalReprobeRunning = false; + btn.disabled = false; + btn.textContent = '✅ Auth Done & Re-Probe'; + setTimeout(fetchState, 600); + } +} + +// Display error state when API unavailable +function displayErrorState() { + const scoreCircle = document.getElementById('scoreCircle'); + if (scoreCircle) scoreCircle.textContent = '?'; + + const statusEl = document.getElementById('riskStatus'); + if (statusEl) statusEl.textContent = 'ERROR'; + + updateElement('headerClock', '--:--:--'); +} + +// Execute action via API +async function executeAction(action) { + try { + if (action === 'release') { + showToast('⏳ Releasing...', 'info'); + } + + const data = await postAction(action); + + if (data.ok) { + // Special handling for details action + if (action === 'details') { + showDetailsModal(data); + return; + } + if (action === 'shutdown') { + showToast('🛑 Shutdown requested. Device will power off in a few seconds.', 'success'); + return; + } + if (action === 'reboot') { + showToast('♻️ Reboot requested. Device will restart in a few seconds.', 'success'); + return; + } + showToast(`✅ ${action} executed successfully`, 'success'); + // Immediately refresh state + setTimeout(fetchState, 500); + } else { + showToast(`❌ ${action} failed: ${data.error}`, 'error'); + } + } catch (e) { + console.error(`Action ${action} failed:`, e); + showToast(`❌ ${action} failed: ${e.message}`, 'error'); + } +} + +async function executeShutdown() { + const firstConfirm = window.confirm('⚠️ This will shut down Azazel-Gadget now. Continue?'); + if (!firstConfirm) return; + + const typed = window.prompt('Type SHUTDOWN to confirm power off'); + if (typed !== 'SHUTDOWN') { + showToast('ℹ️ Shutdown canceled', 'info'); + return; + } + + await executeAction('shutdown'); +} + +async function executeReboot() { + const firstConfirm = window.confirm('⚠️ This will reboot Azazel-Gadget now. Continue?'); + if (!firstConfirm) return; + + const typed = window.prompt('Type REBOOT to confirm restart'); + if (typed !== 'REBOOT') { + showToast('ℹ️ Reboot canceled', 'info'); + return; + } + + await executeAction('reboot'); +} + +// POST /api/action/ +async function postAction(action) { + const res = await fetch(`/api/action/${action}`, { + method: 'POST', + headers: { + 'X-Auth-Token': AUTH_TOKEN, + 'Content-Type': 'application/json' + } + }); + + return res.json(); +} + +// Show toast notification +function showToast(message, type = 'info') { + const toast = document.getElementById('toast'); + toast.textContent = message; + toast.className = `toast show ${type}`; + + setTimeout(() => { + toast.className = 'toast'; + }, 3000); +} + +function initLiveNotifications() { + unreadEventCount = 0; + updateUnreadBadge(); + updateBrowserNotificationStatus(); + setLiveBadge('ntfyStreamStatus', 'CONNECTING', 'degraded'); + setLiveBadge('caCertStatus', 'CHECKING', 'degraded'); + loadCACertificateMeta(); + + const logEl = document.getElementById('ntfyEventLog'); + if (logEl) { + logEl.addEventListener('click', () => { + unreadEventCount = 0; + updateUnreadBadge(); + }); + } +} + +async function loadCACertificateMeta() { + try { + const res = await fetch('/api/certs/azazel-webui-local-ca/meta'); + const data = await res.json(); + if (!res.ok || !data.ok) { + setLiveBadge('caCertStatus', 'MISSING', 'blocked'); + setCACertFingerprint('SHA256: not available'); + toggleCACertificateButton(false); + return; + } + + caCertificateDownloadUrl = data.download_url || '/api/certs/azazel-webui-local-ca.crt'; + setLiveBadge('caCertStatus', 'AVAILABLE', 'on'); + setCACertFingerprint(`SHA256: ${data.sha256 || '-'}`); + toggleCACertificateButton(true); + } catch (e) { + console.warn('Failed to load CA certificate metadata:', e); + setLiveBadge('caCertStatus', 'ERROR', 'blocked'); + setCACertFingerprint('SHA256: lookup failed'); + toggleCACertificateButton(false); + } +} + +function toggleCACertificateButton(enabled) { + const btn = document.getElementById('downloadCaBtn'); + if (!btn) return; + btn.disabled = !enabled; +} + +function setCACertFingerprint(text) { + const el = document.getElementById('caCertFingerprint'); + if (!el) return; + el.textContent = text; +} + +function downloadCACertificate() { + window.location.href = caCertificateDownloadUrl; + showToast('📥 Downloading CA certificate...', 'info'); +} + +function startEventStream() { + if (eventSource) { + eventSource.close(); + } + + const streamUrl = `/api/events/stream?token=${encodeURIComponent(AUTH_TOKEN)}`; + eventSource = new EventSource(streamUrl); + setLiveBadge('ntfyStreamStatus', 'CONNECTING', 'degraded'); + + eventSource.addEventListener('open', () => { + setLiveBadge('ntfyStreamStatus', 'CONNECTED', 'on'); + }); + + eventSource.addEventListener('azazel', (event) => { + try { + const payload = JSON.parse(event.data); + handleLiveEvent(payload); + } catch (e) { + console.warn('Failed to parse SSE event payload:', e); + } + }); + + eventSource.addEventListener('error', () => { + setLiveBadge('ntfyStreamStatus', 'RECONNECTING', 'degraded'); + const now = Date.now(); + if (now - lastEventSourceErrorToastAt > 15000) { + showToast('⚠️ Event stream reconnecting...', 'info'); + lastEventSourceErrorToastAt = now; + } + }); +} + +function handleLiveEvent(payload) { + if (!payload || typeof payload !== 'object') return; + + if (payload.kind === 'bridge_status') { + handleBridgeStatus(payload); + return; + } + + const dedupKey = payload.dedup_key + || `ntfy:${payload.id || ''}:${payload.topic || ''}:${payload.title || ''}:${payload.message || ''}`; + if (isDuplicateLiveEvent(dedupKey)) { + return; + } + + unreadEventCount += 1; + updateUnreadBadge(); + appendLiveEventLog(payload); + + const title = payload.title || 'Azazel Notification'; + const message = payload.message || ''; + const toastType = payload.severity === 'error' ? 'error' : 'info'; + const toastMessage = message ? `🔔 ${title}: ${message}` : `🔔 ${title}`; + showToast(toastMessage, toastType); + + const shown = showBrowserNotification(payload); + if (!shown) { + // UI toast/log are already shown; this keeps behavior explicit on fallback path. + return; + } +} + +function handleBridgeStatus(payload) { + const status = (payload.status || '').toUpperCase(); + if (status.includes('CONNECTED')) { + setLiveBadge('ntfyStreamStatus', 'CONNECTED', 'on'); + return; + } + if (status.includes('RECONNECT')) { + setLiveBadge('ntfyStreamStatus', 'RECONNECTING', 'degraded'); + return; + } + if (status.includes('CONNECTING')) { + setLiveBadge('ntfyStreamStatus', 'CONNECTING', 'degraded'); + } +} + +function isDuplicateLiveEvent(dedupKey) { + const now = Date.now(); + for (const [key, ts] of eventDedupMap.entries()) { + if (now - ts > EVENT_DEDUP_WINDOW_MS) { + eventDedupMap.delete(key); + } + } + + const lastSeen = eventDedupMap.get(dedupKey); + if (lastSeen && (now - lastSeen) < EVENT_DEDUP_WINDOW_MS) { + return true; + } + eventDedupMap.set(dedupKey, now); + return false; +} + +function appendLiveEventLog(payload) { + const logEl = document.getElementById('ntfyEventLog'); + if (!logEl) return; + + const li = document.createElement('li'); + li.className = 'event-log-item'; + + const ts = payload.timestamp ? String(payload.timestamp).replace('T', ' ').slice(0, 19) : '--:--:--'; + const topic = payload.topic || 'unknown'; + const title = payload.title || 'Azazel Notification'; + const message = payload.message || ''; + li.textContent = `[${ts}] [${topic}] ${title}${message ? ` - ${message}` : ''}`; + + if (payload.severity === 'error') { + li.classList.add('error'); + } else if (payload.severity === 'warning') { + li.classList.add('warning'); + } + + logEl.prepend(li); + while (logEl.children.length > EVENT_LOG_MAX_ITEMS) { + logEl.removeChild(logEl.lastChild); + } +} + +function updateUnreadBadge() { + const countLabel = unreadEventCount > 99 ? '99+' : String(unreadEventCount); + const style = unreadEventCount > 0 ? 'contained' : 'off'; + setLiveBadge('ntfyUnreadBadge', countLabel, style); +} + +function setLiveBadge(id, label, styleClass) { + const el = document.getElementById(id); + if (!el) return; + + el.textContent = label; + el.classList.remove('allowed', 'blocked', 'on', 'off', 'normal', 'degraded', 'contained', 'lockdown'); + if (styleClass) { + el.classList.add(styleClass); + } +} + +function isBrowserNotificationSupported() { + return typeof window !== 'undefined' && 'Notification' in window; +} + +function isBrowserNotificationContextAllowed() { + // Notification API generally requires secure context (HTTPS or localhost) + return window.isSecureContext === true; +} + +function updateBrowserNotificationStatus() { + const btn = document.getElementById('enableNotificationsBtn'); + if (!btn) return; + + if (!isBrowserNotificationSupported()) { + setLiveBadge('browserNotifyStatus', 'UNSUPPORTED', 'off'); + btn.disabled = true; + btn.textContent = '🔕 未対応ブラウザ'; + return; + } + + if (!isBrowserNotificationContextAllowed()) { + setLiveBadge('browserNotifyStatus', 'HTTP_ONLY', 'degraded'); + btn.disabled = false; + btn.textContent = '🔔 通知を有効化'; + return; + } + + const permission = Notification.permission; + if (permission === 'granted') { + setLiveBadge('browserNotifyStatus', 'GRANTED', 'on'); + btn.disabled = true; + btn.textContent = '✅ 通知は有効です'; + } else if (permission === 'denied') { + setLiveBadge('browserNotifyStatus', 'DENIED', 'blocked'); + btn.disabled = false; + btn.textContent = '🔔 通知を有効化'; + } else { + setLiveBadge('browserNotifyStatus', 'DEFAULT', 'degraded'); + btn.disabled = false; + btn.textContent = '🔔 通知を有効化'; + } +} + +async function enableBrowserNotifications() { + if (!isBrowserNotificationSupported()) { + showToast('ℹ️ Browser notifications are not supported on this browser', 'info'); + updateBrowserNotificationStatus(); + return; + } + + if (!isBrowserNotificationContextAllowed()) { + showToast('ℹ️ OS通知は HTTPS/localhost でのみ利用できます。画面内通知で継続します。', 'info'); + updateBrowserNotificationStatus(); + return; + } + + try { + const permission = await Notification.requestPermission(); + updateBrowserNotificationStatus(); + if (permission === 'granted') { + showToast('✅ Browser notifications enabled', 'success'); + } else { + showToast('ℹ️ Browser notifications not granted. Using in-app notifications only.', 'info'); + } + } catch (e) { + console.warn('Notification permission request failed:', e); + showToast('ℹ️ Browser notifications unavailable. Using in-app notifications only.', 'info'); + updateBrowserNotificationStatus(); + } +} + +function showBrowserNotification(payload) { + if (!isBrowserNotificationSupported()) return false; + if (!isBrowserNotificationContextAllowed()) return false; + if (Notification.permission !== 'granted') return false; + + try { + const title = payload.title || 'Azazel Notification'; + const message = payload.message || ''; + const topic = payload.topic ? `[${payload.topic}] ` : ''; + const notification = new Notification(title, { + body: `${topic}${message}`.trim(), + tag: payload.dedup_key || payload.id || undefined, + renotify: false, + }); + notification.onclick = () => { + window.focus(); + notification.close(); + }; + return true; + } catch (e) { + console.warn('Failed to show browser notification:', e); + return false; + } +} + +// Show more menu (mobile) +function showMoreMenu() { + const menu = document.getElementById('moreMenu'); + menu.style.display = 'flex'; +} + +// Hide more menu +function hideMoreMenu() { + const menu = document.getElementById('moreMenu'); + menu.style.display = 'none'; +} + +// Close more menu when clicking outside +document.addEventListener('click', (e) => { + const menu = document.getElementById('moreMenu'); + const moreBtn = document.querySelector('.mobile-more'); + + if (menu && moreBtn && + !menu.contains(e.target) && + !moreBtn.contains(e.target)) { + hideMoreMenu(); + } +}); + +// Show Details Modal +function showDetailsModal(data) { + const modal = document.getElementById('detailsModal'); + const body = document.getElementById('detailsBody'); + + let html = '
'; + + // Current State + html += '

Current State

'; + html += `

Stage: ${data.state || 'UNKNOWN'}

`; + html += `

Suspicion Score: ${data.suspicion || 0}

`; + html += `

Reason: ${data.reason || '-'}

`; + + // Probe Details + if (data.details) { + html += '

Probe Results

'; + + // TLS checks + if (data.details.tls && Array.isArray(data.details.tls)) { + html += '

TLS Verification:

    '; + data.details.tls.forEach(item => { + const status = item.ok ? '✅' : '❌'; + html += `
  • ${status} ${item.site || 'Unknown'}
  • `; + }); + html += '
'; + } + + // DNS checks + if (data.details.dns !== undefined) { + const dnsStatus = data.details.dns ? '❌ Mismatch detected' : '✅ OK'; + html += `

DNS: ${dnsStatus}

`; + } + + // Captive Portal + if (data.details.captive_portal !== undefined) { + const cpStatus = data.details.captive_portal ? '⚠️ Detected' : '✅ None'; + html += `

Captive Portal: ${cpStatus}

`; + } + + // Route Anomaly + if (data.details.route_anomaly !== undefined) { + const routeStatus = data.details.route_anomaly ? '⚠️ Anomaly detected' : '✅ OK'; + html += `

Route: ${routeStatus}

`; + } + } else { + html += '

No probe details available

'; + } + + html += '
'; + + body.innerHTML = html; + modal.style.display = 'flex'; +} + +// Close Details Modal +function closeDetailsModal() { + const modal = document.getElementById('detailsModal'); + modal.style.display = 'none'; +} + +// ========== Wi-Fi Control Functions ========== + +let selectedSSID = ''; +let selectedSecurity = 'UNKNOWN'; +let selectedSaved = false; + +// Scan Wi-Fi networks +async function scanWiFi() { + try { + showToast('🔍 Scanning Wi-Fi networks...', 'info'); + + const res = await fetch('/api/wifi/scan', { + method: 'GET' + }); + + const data = await res.json(); + + if (data.ok && data.aps) { + displayWiFiResults(data.aps); + showToast(`✅ Found ${data.aps.length} networks`, 'success'); + } else { + showToast(`❌ Scan failed: ${data.error || 'Unknown error'}`, 'error'); + } + } catch (e) { + console.error('Wi-Fi scan failed:', e); + showToast(`❌ Scan failed: ${e.message}`, 'error'); + } +} + +// Display Wi-Fi scan results +function displayWiFiResults(aps) { + const resultsDiv = document.getElementById('wifiScanResults'); + const apList = document.getElementById('wifiAPList'); + + // Clear existing results + apList.innerHTML = ''; + + // Populate AP list + aps.forEach(ap => { + const row = document.createElement('tr'); + row.style.borderBottom = '1px solid #333'; + row.style.cursor = 'pointer'; + + const ssidCell = document.createElement('td'); + ssidCell.textContent = ap.ssid; + if (ap.saved) { + ssidCell.textContent += ' ★'; + ssidCell.style.color = '#4CAF50'; + } + + const signalCell = document.createElement('td'); + signalCell.textContent = `${ap.signal_dbm} dBm`; + signalCell.style.textAlign = 'center'; + + // Color code signal strength + if (ap.signal_dbm >= -50) { + signalCell.style.color = '#4CAF50'; + } else if (ap.signal_dbm >= -70) { + signalCell.style.color = '#FFC107'; + } else { + signalCell.style.color = '#F44336'; + } + + const securityCell = document.createElement('td'); + securityCell.textContent = ap.security; + securityCell.style.textAlign = 'center'; + + if (ap.security === 'OPEN') { + securityCell.style.color = '#ff6b35'; + } + + const actionCell = document.createElement('td'); + actionCell.style.textAlign = 'center'; + + const selectBtn = document.createElement('button'); + selectBtn.textContent = 'Select'; + selectBtn.className = 'btn-small'; + selectBtn.onclick = () => selectAP(ap.ssid, ap.security, ap.saved); + + actionCell.appendChild(selectBtn); + + row.appendChild(ssidCell); + row.appendChild(signalCell); + row.appendChild(securityCell); + row.appendChild(actionCell); + + apList.appendChild(row); + }); + + // Show results section + resultsDiv.style.display = 'block'; +} + +// Select AP from list +function selectAP(ssid, security, saved) { + selectedSSID = ssid; + selectedSecurity = security; + selectedSaved = !!saved; + + // Populate manual SSID field + document.getElementById('manualSSID').value = ssid; + + // Show/hide passphrase section based on security + const passphraseSection = document.getElementById('passphraseSection'); + if (security === 'OPEN' || selectedSaved) { + passphraseSection.style.display = 'none'; + document.getElementById('wifiPassphrase').value = ''; + } else { + passphraseSection.style.display = 'block'; + } + + const savedLabel = selectedSaved ? ' (saved)' : ''; + showToast(`✅ Selected: ${ssid} (${security})${savedLabel}`, 'info'); +} + +// Connect to Wi-Fi +async function connectWiFi() { + const manualSSID = document.getElementById('manualSSID').value.trim(); + const passphrase = document.getElementById('wifiPassphrase').value; + + // Use manual SSID if provided, else selected SSID + const ssid = manualSSID || selectedSSID; + + if (!ssid) { + showToast('❌ Please select or enter an SSID', 'error'); + return; + } + + // Determine security if manually entered + let security = selectedSecurity; + if (manualSSID && manualSSID !== selectedSSID) { + security = passphrase ? 'WPA2' : 'OPEN'; + } + + const isSavedSelection = !!(selectedSaved && ssid === selectedSSID); + + // Validate passphrase for protected networks + if (security !== 'OPEN' && !passphrase && !isSavedSelection) { + showToast('❌ Passphrase required for protected network', 'error'); + return; + } + + try { + showToast(`🔗 Connecting to ${ssid}...`, 'info'); + + const body = { + ssid: ssid, + security: security, + persist: security !== 'OPEN', + saved: isSavedSelection + }; + + // Add passphrase only for protected networks + if (security !== 'OPEN' && passphrase) { + body.passphrase = passphrase; + } + + const res = await fetch('/api/wifi/connect', { + method: 'POST', + headers: { + 'X-Auth-Token': AUTH_TOKEN, + 'Content-Type': 'application/json' + }, + body: JSON.stringify(body) + }); + + const data = await res.json(); + + if (data.ok) { + showToast(`✅ Connected to ${ssid}!`, 'success'); + + // Clear passphrase field + document.getElementById('wifiPassphrase').value = ''; + + // Refresh state immediately + setTimeout(fetchState, 1000); + } else { + showToast(`❌ Connection failed: ${data.error || 'Unknown error'}`, 'error'); + } + } catch (e) { + console.error('Wi-Fi connect failed:', e); + showToast(`❌ Connection failed: ${e.message}`, 'error'); + } +} diff --git a/azazel_web/static/style.css b/azazel_web/static/style.css new file mode 100644 index 0000000..c60aafb --- /dev/null +++ b/azazel_web/static/style.css @@ -0,0 +1,630 @@ +/* Azazel-Gadget Web UI - Redesigned */ + +* { + margin: 0; + padding: 0; + box-sizing: border-box; +} + +:root { + --color-safe: #2ecc71; + --color-caution: #f39c12; + --color-danger: #e74c3c; + --bg-dark: #1a1a2e; + --bg-card: rgba(255, 255, 255, 0.05); + --text-primary: #eee; + --text-secondary: #aaa; + --accent: #00d4ff; +} + +body { + font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif; + background: linear-gradient(135deg, var(--bg-dark) 0%, #16213e 100%); + color: var(--text-primary); + min-height: 100vh; + padding-bottom: 80px; +} + +/* Header */ +.header { + background: rgba(0, 0, 0, 0.3); + padding: 12px 20px; + border-bottom: 2px solid var(--accent); + display: flex; + justify-content: space-between; + align-items: center; + flex-wrap: wrap; +} + +.header h1 { + color: var(--accent); + font-size: 1.7em; + margin: 0; +} + +.header-ssid { + display: none; +} + +#headerSSID, +#headerTemp, +#headerCPU { + display: none !important; +} + +.header-right { + display: flex; + gap: 12px; + font-family: monospace; + font-size: 1.2em; +} + +.header-clock { + color: var(--accent); + font-size: 1.35em; +} + +/* Container */ +.container { + max-width: 1400px; + margin: 12px auto; + padding: 0 12px; +} + +/* Grid - Always 3 columns on desktop */ +.grid { + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 12px; + margin-bottom: 12px; +} + +/* Cards - Compact Design */ +.card { + background: var(--bg-card); + padding: 12px; + border-radius: 8px; + border: 1px solid rgba(255, 255, 255, 0.1); + backdrop-filter: blur(10px); + overflow: hidden; +} + +.card h2 { + color: var(--accent); + font-size: 1.3em; + margin-bottom: 10px; + padding-bottom: 6px; + border-bottom: 1px solid rgba(0, 212, 255, 0.3); +} + +.card h3 { + color: var(--text-secondary); + font-size: 1.05em; + margin: 10px 0 6px 0; + font-weight: 600; +} + +/* Risk Card - Compact */ +.card-risk { + border-left: 3px solid var(--color-safe); +} + +.card-risk.caution { + border-left-color: var(--color-caution); +} + +.card-risk.danger { + border-left-color: var(--color-danger); +} + +.risk-compact { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 10px; +} + +.score-circle { + width: 70px; + height: 70px; + border-radius: 50%; + background: rgba(46, 204, 113, 0.2); + border: 3px solid var(--color-safe); + display: flex; + align-items: center; + justify-content: center; + font-size: 2em; + font-weight: bold; + color: var(--color-safe); + flex-shrink: 0; +} + +.score-circle.caution { + background: rgba(243, 156, 18, 0.2); + border-color: var(--color-caution); + color: var(--color-caution); +} + +.score-circle.danger { + background: rgba(231, 76, 60, 0.2); + border-color: var(--color-danger); + color: var(--color-danger); +} + +.risk-info { + flex: 1; + margin-left: 12px; +} + +.risk-status { + font-size: 1.5em; + font-weight: bold; + color: var(--color-safe); + margin-bottom: 4px; +} + +.risk-status.caution { + color: var(--color-caution); +} + +.risk-status.danger { + color: var(--color-danger); +} + +.risk-level { + font-size: 1.05em; + color: var(--text-secondary); +} + +/* Metrics - Compact */ +.metric { + display: flex; + justify-content: space-between; + padding: 8px 0; + border-bottom: 1px solid rgba(255, 255, 255, 0.05); + font-size: 1.05em; +} + +.metric:last-child { + border-bottom: none; +} + +.metric-label { + color: var(--text-secondary); + font-size: 1em; +} + +.metric-value { + color: var(--text-primary); + font-weight: 600; + text-align: right; + word-break: break-word; +} + +.metric-value.mono { + font-family: 'Courier New', monospace; + font-size: 0.95em; +} + +.metric-value.text-wrap { + max-width: 65%; +} + +/* Badges - Compact */ +.badge { + display: inline-block; + padding: 4px 12px; + border-radius: 10px; + font-size: 0.95em; + font-weight: bold; +} + +.badge.allowed { + background: var(--color-safe); + color: #000; +} + +.badge.blocked { + background: var(--color-danger); + color: #fff; +} + +.badge.on { + background: var(--color-caution); + color: #000; +} + +.badge.off { + background: #666; + color: #fff; +} + +.badge.normal { + background: var(--color-safe); + color: #000; +} + +.badge.degraded { + background: var(--color-caution); + color: #000; +} + +.badge.contained { + background: #e67e22; + color: #fff; +} + +.badge.lockdown { + background: var(--color-danger); + color: #fff; +} + +/* Evidence Card - Full Width */ +.card-evidence { + grid-column: 1 / -1; +} + +.card-notifications { + grid-column: 1 / -1; +} + +.event-log-wrap { + margin-top: 10px; + border: 1px solid rgba(255, 255, 255, 0.15); + border-radius: 6px; + background: rgba(0, 0, 0, 0.25); + max-height: 180px; + overflow-y: auto; +} + +.event-log-list { + list-style: none; + margin: 0; + padding: 0; +} + +.event-log-item { + font-family: "Courier New", monospace; + font-size: 0.82em; + color: var(--text-primary); + padding: 6px 8px; + border-bottom: 1px solid rgba(255, 255, 255, 0.07); + word-break: break-word; +} + +.event-log-item:last-child { + border-bottom: none; +} + +.event-log-item.warning { + color: #ffd166; +} + +.event-log-item.error { + color: #ff8a8a; +} + +.cert-fingerprint { + margin-top: 8px; + font-family: "Courier New", monospace; + font-size: 0.72em; + color: var(--text-secondary); + word-break: break-all; +} + +.evidence-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); + gap: 10px; + margin-bottom: 10px; +} + +/* Wi-Fi Control Section - Collapsible */ +.wifi-control-section { + margin-top: 10px; +} + +.wifi-control-section h3 { + cursor: pointer; + user-select: none; +} + +.wifi-control-section h3:hover { + color: var(--accent); +} + +/* Action Bar */ +.action-bar { + position: fixed; + bottom: 0; + left: 0; + right: 0; + background: rgba(0, 0, 0, 0.9); + backdrop-filter: blur(10px); + padding: 12px; + display: flex; + justify-content: center; + gap: 8px; + flex-wrap: wrap; + border-top: 2px solid var(--accent); + z-index: 1000; +} + +.btn { + padding: 11px 22px; + border: none; + border-radius: 6px; + font-size: 1.05em; + font-weight: bold; + cursor: pointer; + transition: all 0.3s; + display: inline-flex; + align-items: center; + gap: 5px; +} + +.btn-inline { + padding: 6px 10px; + font-size: 0.8em; +} + +.btn:hover { + transform: translateY(-2px); + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3); +} + +.btn:disabled { + opacity: 0.55; + cursor: not-allowed; + transform: none; + box-shadow: none; +} + +.btn-primary { + background: #3498db; + color: #fff; +} + +.btn-secondary { + background: #95a5a6; + color: #fff; +} + +.btn-success { + background: #2ecc71; + color: #fff; +} + +.btn-warning { + background: #f39c12; + color: #fff; +} + +.btn-danger { + background: #e74c3c; + color: #fff; +} + +.btn-info { + background: #1abc9c; + color: #fff; +} + +.mobile-more { + display: inline-flex; +} + +/* More Menu */ +.more-menu { + position: fixed; + bottom: 80px; + right: 20px; + background: rgba(0, 0, 0, 0.95); + border: 2px solid var(--accent); + border-radius: 10px; + padding: 10px; + display: flex; + flex-direction: column; + gap: 8px; + z-index: 999; +} + +/* Toast */ +.toast { + position: fixed; + top: 80px; + right: 20px; + background: rgba(0, 0, 0, 0.9); + color: #fff; + padding: 12px 20px; + border-radius: 8px; + border-left: 4px solid var(--accent); + display: none; + z-index: 1001; +} + +.toast.show { + display: block; + animation: slideIn 0.3s ease-out; +} + +.toast.success { + border-left-color: var(--color-safe); +} + +.toast.error { + border-left-color: var(--color-danger); +} + +@keyframes slideIn { + from { + transform: translateX(400px); + opacity: 0; + } + to { + transform: translateX(0); + opacity: 1; + } +} + +/* Responsive - Tablet */ +@media (max-width: 1100px) { + .grid { + grid-template-columns: repeat(2, 1fr); + } + + .card:nth-child(3) { + grid-column: 1 / -1; + } +} + +/* Responsive - Mobile */ +@media (max-width: 768px) { + body { + padding-bottom: 120px; + } + + .grid { + grid-template-columns: 1fr; + } + + .card:nth-child(3) { + grid-column: auto; + } + + .header h1 { + font-size: 1.3em; + } + + .header-right { + font-size: 0.8em; + } + + .action-bar { + padding: 8px; + gap: 6px; + max-height: 100px; + overflow-y: auto; + } + + .btn { + padding: 8px 14px; + font-size: 0.85em; + white-space: nowrap; + } + + .mobile-more { + display: inline-flex; + } + + .action-bar .btn:nth-child(n+6):not(.mobile-more) { + display: none; + } +} + +/* Modal */ +.modal { + display: none; + position: fixed; + z-index: 1000; + left: 0; + top: 0; + width: 100%; + height: 100%; + background-color: rgba(0, 0, 0, 0.8); + justify-content: center; + align-items: center; +} + +.modal-content { + background-color: #1a1a1a; + border: 1px solid #333; + border-radius: 8px; + max-width: 600px; + width: 90%; + max-height: 80vh; + overflow-y: auto; + box-shadow: 0 4px 20px rgba(0, 0, 0, 0.5); +} + +.modal-header { + display: flex; + justify-content: space-between; + align-items: center; + padding: 16px 20px; + border-bottom: 1px solid #333; +} + +.modal-header h3 { + margin: 0; + font-size: 1.2em; + color: #fff; +} + +.modal-close { + background: none; + border: none; + color: #999; + font-size: 1.5em; + cursor: pointer; + padding: 0; + width: 30px; + height: 30px; + display: flex; + align-items: center; + justify-content: center; + border-radius: 4px; + transition: all 0.2s; +} + +.modal-close:hover { + background: #333; + color: #fff; +} + +.modal-body { + padding: 20px; + color: #e0e0e0; +} + +.details-section h4 { + color: #4CAF50; + margin-top: 16px; + margin-bottom: 8px; + font-size: 1.1em; +} + +.details-section p { + margin: 8px 0; + line-height: 1.6; +} + +.details-section ul { + margin: 8px 0; + padding-left: 20px; +} + +.details-section li { + margin: 4px 0; +} + +/* Responsive - Small Mobile */ +@media (max-width: 480px) { + .header { + padding: 10px 12px; + } + + .container { + padding: 0 8px; + } + + .card { + padding: 10px; + } + + .score-circle { + width: 50px; + height: 50px; + font-size: 1.5em; + } +} diff --git a/azazel_web/templates/index.html b/azazel_web/templates/index.html new file mode 100644 index 0000000..2da4de8 --- /dev/null +++ b/azazel_web/templates/index.html @@ -0,0 +1,299 @@ + + + + + + Azazel-Gadget Dashboard + + + + +
+
+

🛡️ Azazel-Gadget

+
+
+ --:--:-- +
+
+ + +
+ +
+ +
+

🎯 Risk Assessment

+
+
0
+
+
SAFE
+
LOW
+
+
+
+ Recommendation + - +
+
+ Reason + - +
+
+ Suricata + UNKNOWN +
+
+ OpenCanary + UNKNOWN +
+
+ ntfy + UNKNOWN +
+
+ + +
+

🚦 Control & Safety

+ +

Traffic Filtering

+
+ QUIC + ALLOWED +
+
+ DoH + BLOCKED +
+ +

Traffic Shaping

+
+ Degrade + OFF +
+
+ Down/Up + - / - Mbps +
+ +

Security

+
+ Probe + - +
+
+ IDS + - +
+
+ + +
+

📡 Connection

+
+ SSID + - +
+
+ BSSID + - +
+
+ Signal + - dBm +
+
+ Gateway + - +
+
+ State + DISCONNECTED +
+
+ Internet + UNKNOWN +
+ + + + + + + + + + +
+ + +
+

🔔 Live Notifications

+
+ Event Stream + CONNECTING +
+
+ Browser Notification + DEFAULT +
+
+ Unread + 0 +
+
+ CA Cert + CHECKING +
+ + +
SHA256: -
+
+
    +
    +
    +
    + + +
    +

    📜 Evidence & State

    +
    +
    + State + NORMAL +
    +
    + Suspicion + 0 +
    +
    + CPU Temp + --°C +
    +
    + CPU Usage + --% +
    +
    + Memory Usage + --% +
    +
    +
    + Scan Results + - +
    +
    + Decision + - +
    +
    +
    + + +
    + + + + + + +
    + + + + + + + + +
    + + + + diff --git a/pyproject.toml b/pyproject.toml index ca62e7e..84805fc 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -13,6 +13,7 @@ authors = [ { name = "01rabbit", email = "appleseedj073@gmail.com" } ] dependencies = [ + "flask>=3.1.0", "pyyaml>=6.0", "requests>=2.28.0", "rich>=13.0.0", @@ -30,4 +31,4 @@ python_files = ["test_*.py"] addopts = "-v --cov=azazel_pi" [tool.hatch.build.targets.wheel] -packages = ["azazel_pi"] \ No newline at end of file +packages = ["azazel_pi"] diff --git a/scripts/install_azazel.sh b/scripts/install_azazel.sh index c354c2c..38f6578 100755 --- a/scripts/install_azazel.sh +++ b/scripts/install_azazel.sh @@ -266,6 +266,7 @@ APT_PACKAGES=( netfilter-persistent nginx python3 + python3-flask python3-pip python3-toml python3-venv @@ -386,7 +387,7 @@ fi log "Staging Azazel runtime under $TARGET_ROOT" mkdir -p "$TARGET_ROOT" "$CONFIG_ROOT" # Copy current package layout (azazel_pi) and azctl CLI into target runtime -rsync -a --delete "$REPO_ROOT/azazel_pi" "$REPO_ROOT/azctl" "$TARGET_ROOT/" +rsync -a --delete "$REPO_ROOT/azazel_pi" "$REPO_ROOT/azctl" "$REPO_ROOT/azazel_web" "$TARGET_ROOT/" rsync -a "$REPO_ROOT/configs/" "$CONFIG_ROOT/" rsync -a "$REPO_ROOT/systemd/" /etc/systemd/system/ @@ -410,6 +411,9 @@ systemctl daemon-reload if systemctl list-unit-files | grep -q '^azctl-unified.service'; then systemctl enable --now azctl-unified.service || log "Failed to enable/start azctl-unified.service; continue" fi +if systemctl list-unit-files | grep -q '^azazel-web.service'; then + systemctl enable --now azazel-web.service || log "Failed to enable/start azazel-web.service; continue" +fi configure_internal_network install_mattermost # Note: azctl.target is no longer used - azctl-unified.service handles all control diff --git a/scripts/install_azazel_complete.sh b/scripts/install_azazel_complete.sh index 9a06d3d..2a978fb 100755 --- a/scripts/install_azazel_complete.sh +++ b/scripts/install_azazel_complete.sh @@ -555,6 +555,7 @@ log "Step 5b/9: Configuring systemd services" # Enable core services systemctl enable azctl-unified.service || warn "Failed to enable azctl-unified.service" +systemctl enable azazel-web.service || warn "Failed to enable azazel-web.service" systemctl enable suricata.service || warn "Failed to enable suricata.service" systemctl enable vector.service || warn "Failed to enable vector.service" systemctl enable mattermost.service || warn "Failed to enable mattermost.service" @@ -654,6 +655,7 @@ if [[ $START_SERVICES -eq 1 ]]; then # Start services in order systemctl start vector.service || warn "Vector service may have issues" systemctl start azctl-unified.service || warn "Azctl-unified service may have issues" + systemctl start azazel-web.service || warn "Azazel-web service may have issues" systemctl start nginx.service || warn "Nginx service may have issues" # Wait a moment for services to stabilize @@ -664,7 +666,7 @@ if [[ $START_SERVICES -eq 1 ]]; then systemctl start azazel-suricata-update.timer || warn "Failed to start Suricata auto-update timer" log "Service status check:" - services=("azctl-unified" "suricata" "vector" "nginx" "docker") + services=("azctl-unified" "azazel-web" "suricata" "vector" "nginx" "docker") for service in "${services[@]}"; do if systemctl is-active --quiet "$service.service"; then success "✓ $service: running" diff --git a/systemd/azazel-web.service b/systemd/azazel-web.service new file mode 100644 index 0000000..cf1d399 --- /dev/null +++ b/systemd/azazel-web.service @@ -0,0 +1,23 @@ +[Unit] +Description=Azazel-Pi Web UI Backend (Flask) +After=network-online.target azctl-unified.service +Wants=network-online.target azctl-unified.service + +[Service] +Type=simple +WorkingDirectory=/opt/azazel +Environment="AZAZEL_WEB_HOST=127.0.0.1" +Environment="AZAZEL_WEB_PORT=8084" +EnvironmentFile=-/etc/default/azazel-pi +ExecStart=/usr/bin/env python3 /opt/azazel/azazel_web/app.py +Restart=always +RestartSec=5 +StandardOutput=journal +StandardError=journal +SyslogIdentifier=azazel-web + +# Security hardening +NoNewPrivileges=true + +[Install] +WantedBy=multi-user.target From 6f3f865610a24d631c8da6264251e68a9c8ff726 Mon Sep 17 00:00:00 2001 From: "Mr.Rabbit" Date: Mon, 23 Feb 2026 07:23:08 +0900 Subject: [PATCH 2/3] replace legacy modular tui with azazel-zero textual tui --- README.md | 129 +------- azctl/cli.py | 16 +- azctl/menu/__init__.py | 29 -- azctl/menu/core.py | 508 ----------------------------- azctl/menu/defense.py | 570 -------------------------------- azctl/menu/emergency.py | 425 ------------------------ azctl/menu/monitoring.py | 162 ---------- azctl/menu/network.py | 298 ----------------- azctl/menu/services.py | 530 ------------------------------ azctl/menu/system.py | 328 ------------------- azctl/menu/types.py | 28 -- azctl/menu/wifi.py | 660 -------------------------------------- azctl/tui_zero.py | 333 +++++++++++++++++++ azctl/tui_zero_textual.py | 382 ++++++++++++++++++++++ pyproject.toml | 1 + scripts/install_azazel.sh | 6 + 16 files changed, 743 insertions(+), 3662 deletions(-) delete mode 100644 azctl/menu/__init__.py delete mode 100644 azctl/menu/core.py delete mode 100644 azctl/menu/defense.py delete mode 100644 azctl/menu/emergency.py delete mode 100644 azctl/menu/monitoring.py delete mode 100644 azctl/menu/network.py delete mode 100644 azctl/menu/services.py delete mode 100644 azctl/menu/system.py delete mode 100644 azctl/menu/types.py delete mode 100644 azctl/menu/wifi.py create mode 100644 azctl/tui_zero.py create mode 100644 azctl/tui_zero_textual.py diff --git a/README.md b/README.md index 351b759..fc41f5b 100644 --- a/README.md +++ b/README.md @@ -280,42 +280,28 @@ export AZAZEL_WEB_PORT=8084 python3 azazel_web/app.py ``` -### Modular TUI Menu System +### Unified Textual TUI (Azazel-Zero Port) -The interactive Terminal User Interface (TUI) menu provides comprehensive system management through a modular architecture designed for maintainability and extensibility: +The old `azctl/menu` modular TUI has been removed and replaced with the Azazel-Zero style unified Textual TUI. ```bash -# Launch the TUI menu +# Launch unified TUI menu python3 -m azctl.cli menu -# With specific interface configuration +# With specific interface configuration (optional) python3 -m azctl.cli menu --lan-if ${AZAZEL_LAN_IF:-wlan0} --wan-if ${AZAZEL_WAN_IF:-wlan1} ``` -**Modular Architecture:** - -Azazel-Pi's menu system employs a modular design with functional separation for improved maintainability: - -``` text -azctl/menu/ -├── core.py # Main framework -├── types.py # Data type definitions -├── defense.py # Defense control module -├── services.py # Service management module -├── network.py # Network information module -├── wifi.py # WiFi management module -├── monitoring.py # Log monitoring module -├── system.py # System information module -└── emergency.py # Emergency operations module -``` - -**Key Features:** - -- **Modular Design**: Function-specific modules for enhanced maintainability -- **Rich UI**: Color-coded panels, tables, and progress bars -- **Safety-First**: Multi-stage confirmation for dangerous operations -- **Extensible**: Easy addition of new functionality through module system -- **Real-time Monitoring**: Live status displays with automatic updates +- Runtime implementation: `azctl/tui_zero.py` + `azctl/tui_zero_textual.py` +- Dependency: `textual` +- Key controls: + - `u`: Refresh + - `a`: Stage-Open (`portal`) + - `r`: Re-Probe (`shield`) + - `c`: Contain (`lockdown`) + - `m`: Toggle menu + - `l`: Toggle details + - `q`: Quit ## Usage @@ -350,10 +336,8 @@ echo '{"mode": "lockdown"}' | azctl events --config - #### Interactive TUI Menu -The modular TUI menu provides comprehensive system management: - ```bash -# Launch modular TUI menu. If --wan-if is omitted, azctl will consult the +# Launch unified Textual TUI. If --wan-if is omitted, azctl will consult the # WAN manager to select the active WAN interface. To override selection use # the CLI flags or environment variables described below. python3 -m azctl.cli menu @@ -365,89 +349,6 @@ python3 -m azctl.cli menu --lan-if ${AZAZEL_LAN_IF:-wlan0} --wan-if ${AZAZEL_WAN python3 -m azctl.cli menu --lan-if ${AZAZEL_LAN_IF:-wlan0} ``` -**Menu Features:** - -1. **Defense Control** (`defense.py`) - - Current defense mode display (Portal/Shield/Lockdown) - - Manual mode switching (emergency overrides) - - Decision history and score trends - - Real-time threat score monitoring - -2. **Service Management** (`services.py`) - - Azazel core service control (azctl-unified, suricata, opencanary, vector) - - Service status overview - - Real-time log file viewing - - Service restart and health checks - -3. **Network Information** (`network.py`) - - WiFi management integration - - Interface status and IP configuration - - Active profiles and QoS settings - - Network traffic statistics - -4. **WiFi Management** (`wifi.py`) - - Nearby WiFi network scanning - - WPA/WPA2 network connection - - Saved network management - - Connection status and signal strength - -5. **Log Monitoring** (`monitoring.py`) - - Suricata alert real-time monitoring - - OpenCanary honeypot events - - System logs and daemon logs - - Security event summaries - -6. **System Information** (`system.py`) - - CPU, memory, disk usage - - Network interface statistics - - System temperature monitoring - - Process list and resource usage - -7. **Emergency Operations** (`emergency.py`) - - Emergency lockdown (immediate network isolation) - - Complete network configuration reset - - System status report generation - - Factory reset (requires confirmation) - -**Technical Features:** - -- **Modular Design**: Each function implemented as independent module -- **Rich UI**: Color-coded panels, tables, progress bars -- **Error Handling**: Robust error processing and recovery -- **Security-Focused**: Multi-stage confirmation for dangerous operations -- **Extensible**: Easy addition of new functionality - -**Safety Features:** - -- Confirmation dialogs for dangerous operations -- Automatic root permission verification -- Automatic operation logging -- Error handling and automatic recovery procedures -- Emergency operations require multiple confirmations - -**Keyboard Navigation:** - -- `Number keys`: Select menu items -- `r`: Refresh screen -- `b`: Return to previous menu -- `q`: Exit -- `Ctrl+C`: Safe interruption anytime - -**Safety Features:** - -- Confirmation dialogs for dangerous operations -- Root permission validation for privileged actions -- Automatic operation logging -- Error handling and recovery procedures - -**Navigation:** - -- `Number keys`: Select menu items -- `r`: Refresh screen -- `b`: Back to previous menu -- `q`: Quit -- `Ctrl+C`: Interrupt at any time - ### Configuration Workflow 1. **Edit Core Configuration**: Modify `/etc/azazel/azazel.yaml` to adjust delay values, bandwidth controls, and lockdown allowlists (template at `configs/network/azazel.yaml`). diff --git a/azctl/cli.py b/azctl/cli.py index 09d8c15..a03f286 100644 --- a/azctl/cli.py +++ b/azctl/cli.py @@ -706,19 +706,15 @@ def safe_path(path_str: Optional[str], fallback: Optional[str] = None) -> Option def cmd_menu(decisions: Optional[str], lan_if: str, wan_if: str) -> int: - """Launch the interactive TUI menu system.""" + """Launch Azazel-Zero style unified Textual TUI menu.""" try: - from azctl.menu import AzazelTUIMenu - menu = AzazelTUIMenu( - decisions_log=decisions, - lan_if=lan_if, - wan_if=wan_if - ) - menu.run() + from azctl.tui_zero import run_menu + _ = decisions # legacy arg retained for CLI compatibility + run_menu(lan_if=lan_if, wan_if=wan_if, start_menu=True) return 0 except ImportError as e: - print(f"Error: TUI menu requires additional dependencies: {e}") - print("Install with: pip install rich") + print(f"Error: unified Textual TUI requires additional dependencies: {e}") + print("Install with: pip install textual") return 1 except Exception as e: print(f"Error launching menu: {e}") diff --git a/azctl/menu/__init__.py b/azctl/menu/__init__.py deleted file mode 100644 index a9e1981..0000000 --- a/azctl/menu/__init__.py +++ /dev/null @@ -1,29 +0,0 @@ -#!/usr/bin/env python3 -""" -Modular TUI Menu System for Azazel-Pi - -This module provides a modular terminal user interface for managing the Azazel-Pi system. -""" - -from azctl.menu.core import AzazelTUIMenu -from azctl.menu.types import MenuAction, MenuCategory -from azctl.menu.wifi import WiFiManager -from azctl.menu.network import NetworkModule -from azctl.menu.defense import DefenseModule -from azctl.menu.services import ServicesModule -from azctl.menu.monitoring import MonitoringModule -from azctl.menu.system import SystemModule -from azctl.menu.emergency import EmergencyModule - -__all__ = [ - 'AzazelTUIMenu', - 'MenuAction', - 'MenuCategory', - 'WiFiManager', - 'NetworkModule', - 'DefenseModule', - 'ServicesModule', - 'MonitoringModule', - 'SystemModule', - 'EmergencyModule' -] \ No newline at end of file diff --git a/azctl/menu/core.py b/azctl/menu/core.py deleted file mode 100644 index 70b116b..0000000 --- a/azctl/menu/core.py +++ /dev/null @@ -1,508 +0,0 @@ -#!/usr/bin/env python3 -""" -Core Menu Framework - -Provides the base menu system structure and data classes -for the Azazel TUI menu system. -""" - -import subprocess -from azazel_pi.utils.cmd_runner import run as run_cmd -import sys -import time -from dataclasses import dataclass -from datetime import datetime -from pathlib import Path -from typing import List, Dict, Optional, Tuple, Any, Callable -import os - -from rich.console import Console -from rich.text import Text -from rich.align import Align -from rich.panel import Panel -from rich.prompt import Prompt - -# Import CLI functions -from azctl.cli import ( - _read_last_decision, - _mode_style, -) - -# Import types -from azctl.menu.types import MenuAction, MenuCategory - -# Import status collector -try: - from azazel_pi.core.ingest.status_collector import NetworkStatusCollector -except ImportError: - NetworkStatusCollector = None - - -class AzazelTUIMenu: - """Main TUI menu system for Azazel-Pi control interface.""" - - def __init__(self, decisions_log: Optional[str] = None, lan_if: Optional[str] = None, wan_if: Optional[str] = None): - self.console = Console() - self.decisions_log = decisions_log - # LAN precedence: explicit arg -> AZAZEL_LAN_IF env -> default wlan0 - self.lan_if = lan_if or os.environ.get("AZAZEL_LAN_IF") or "wlan0" - # Resolve WAN interface default from explicit arg -> env -> helper -> fallback - try: - from azazel_pi.utils.wan_state import get_active_wan_interface - self.wan_if = wan_if or os.environ.get("AZAZEL_WAN_IF") or get_active_wan_interface() - except Exception: - # Fallback to previous hardcoded default if resolution fails - self.wan_if = wan_if or os.environ.get("AZAZEL_WAN_IF") or "wlan1" - - # Initialize status collector if available - self.status_collector = None - if NetworkStatusCollector: - try: - self.status_collector = NetworkStatusCollector() - except Exception: - pass - - # Initialize all modules (import here to avoid circular imports) - from azctl.menu.network import NetworkModule - from azctl.menu.defense import DefenseModule - from azctl.menu.services import ServicesModule - from azctl.menu.monitoring import MonitoringModule - from azctl.menu.system import SystemModule - from azctl.menu.emergency import EmergencyModule - - self.network_module = NetworkModule(self.console, self.lan_if, self.wan_if, self.status_collector) - self.defense_module = DefenseModule(self.console, decisions_log=self.decisions_log, lan_if=self.lan_if, wan_if=self.wan_if) - self.services_module = ServicesModule(self.console) - self.monitoring_module = MonitoringModule(self.console) - self.system_module = SystemModule(self.console, self.status_collector) - self.emergency_module = EmergencyModule(self.console, self.lan_if, self.wan_if) - - # Setup menu categories - self._setup_menu_categories() - - def _setup_menu_categories(self) -> None: - """Setup menu categories and actions using pre-initialized modules.""" - self.categories = [ - self.defense_module.get_category(), - self.services_module.get_category(), - self.network_module.get_category(), - self.monitoring_module.get_category(), - self.system_module.get_category(), - self.emergency_module.get_category(), - ] - - def run(self) -> None: - """Main menu loop.""" - try: - while True: - choice = self._display_main_menu() - if choice == 'q': - self.console.print("\n[yellow]Exiting Azazel TUI Menu...[/yellow]") - break - elif choice == 'r': - continue # _display_main_menu will handle screen clearing - - try: - category_idx = int(choice) - 1 - if 0 <= category_idx < len(self.categories): - self._handle_category(self.categories[category_idx]) - else: - self.console.print(f"[red]Invalid choice: {choice}[/red]") - self._pause() - except ValueError: - self.console.print(f"[red]Invalid input: {choice}[/red]") - self._pause() - - except KeyboardInterrupt: - self.console.print("\n[yellow]Interrupted by user. Exiting...[/yellow]") - except Exception as e: - self.console.print(f"[red]Unexpected error: {e}[/red]") - - def _show_banner(self) -> None: - """Display the application banner.""" - # バージョン取得 - version = None - pyproject_path = Path(__file__).parent.parent.parent / "pyproject.toml" - if pyproject_path.exists(): - try: - # まずtomlで試みる - try: - import toml - pyproject = toml.load(pyproject_path) - # poetry用とPEP 621用両方対応 - version = pyproject.get("tool", {}).get("poetry", {}).get("version") - if not version: - version = pyproject.get("project", {}).get("version") - except ImportError: - # tomlがなければ正規表現で取得 - import re - with open(pyproject_path, "r") as f: - content = f.read() - m = re.search(r"^version\s*=\s*['\"]([^'\"]+)['\"]", content, re.MULTILINE) - if m: - version = m.group(1) - except Exception: - pass - version_str = f"v{version}" if version else "" - - # バナーを複数行で構築 - from rich.table import Table - banner_table = Table.grid(padding=0) - banner_table.add_column(justify="center", width=55) - - # 1行目: タイトル(センター) - banner_table.add_row(Text("🛡️ AZ-01X Azazel-Pi CONTROL INTERFACE", style="bold white")) - - # 2行目: バージョン(右揃え) - if version_str: - banner_table.add_row(Align.right(Text(version_str, style="dim white"))) - - # 3行目: 空行 - banner_table.add_row("") - - # 4行目: サブタイトル(センター) - banner_table.add_row(Text("The Cyber Scapegoat Gateway", style="dim white")) - - banner_panel = Panel( - banner_table, - border_style="cyan", - padding=(1, 2), - width=59 - ) - - self.console.print(Align.center(banner_panel)) - self.console.print() - - # Show current status summary - try: - status = self._get_enhanced_status() - # Use custom mode_display if available, otherwise use _mode_style - if status.get("mode_display"): - mode_label = status["mode_display"] - # Determine color based on mode type - User Override uses base mode colors - if "USER_PORTAL" in mode_label: - color = "green" # グリーン - elif "USER_SHIELD" in mode_label: - color = "yellow" # イエロー - elif "USER_LOCKDOWN" in mode_label: - color = "red" # レッド - elif "PORTAL" in mode_label: - color = "green" # グリーン - elif "SHIELD" in mode_label: - color = "yellow" # イエロー - elif "LOCKDOWN" in mode_label: - color = "red" # レッド - else: - color = "white" - else: - mode_label, color = _mode_style(status.get("mode")) - - # Create multi-line status display - status_lines = [ - f"Mode: [{color}]{mode_label}[/{color}] | Profile: [cyan]{status.get('profile', 'N/A')}[/cyan] | Services: {status.get('services_active', 0)}/{status.get('services_total', 0)} active" - ] - - # Add network information if available - if status.get('wlan0_info'): - wlan0 = status['wlan0_info'] - if wlan0.get('is_ap') and wlan0.get('stations') is not None: - status_lines.append(f"AP ({self.lan_if}): [green]Active[/green] | Clients: {wlan0['stations']}") - else: - status_lines.append(f"AP ({self.lan_if}): [red]Inactive[/red]") - - if status.get('wlan1_info'): - wlan1 = status['wlan1_info'] - if wlan1.get('connected') and wlan1.get('ssid'): - signal_info = f"{wlan1.get('signal_dbm')} dBm" if wlan1.get('signal_dbm') else 'N/A' - ip_info = wlan1.get('ip4', 'No IP') - status_lines.append(f"WAN ({self.wan_if}): [green]{wlan1['ssid']}[/green] | IP: [cyan]{ip_info}[/cyan] | Signal: {signal_info}") - else: - status_lines.append(f"WAN ({self.wan_if}): [red]Disconnected[/red]") - - status_lines.append(f"Updated: {datetime.now().strftime('%H:%M:%S')}") - - status_panel = Panel( - "\n".join(status_lines), - title="System Status", - border_style=color, - padding=(0, 1) - ) - self.console.print(Align.center(status_panel)) - self.console.print() - except Exception: - # If status fails, continue without it - pass - - def _display_main_menu(self) -> str: - """Display the main menu and get user choice.""" - # Clear screen and show banner/status before menu - self.console.clear() - self._show_banner() - - title = Text("Main Menu", style="bold blue") - self.console.print(title) - self.console.print(Text("─" * len("Main Menu"), style="blue")) - - for i, category in enumerate(self.categories, 1): - self.console.print(f"[cyan]{i}.[/cyan] {category.title}") - self.console.print(f" [dim]{category.description}[/dim]") - - self.console.print() - self.console.print("[cyan]r.[/cyan] Refresh screen") - self.console.print("[cyan]q.[/cyan] Quit") - self.console.print() - - return Prompt.ask("Select option", default="1") - - def _handle_category(self, category: MenuCategory) -> None: - """Handle selection of a menu category.""" - while True: - self.console.clear() - self._show_category_header(category) - - choice = self._display_category_menu(category) - if choice == 'b': - break - elif choice == 'r': - continue - - try: - action_idx = int(choice) - 1 - if 0 <= action_idx < len(category.actions): - action = category.actions[action_idx] - self._execute_action(action) - else: - self.console.print(f"[red]Invalid choice: {choice}[/red]") - self._pause() - except ValueError: - self.console.print(f"[red]Invalid input: {choice}[/red]") - self._pause() - - def _show_category_header(self, category: MenuCategory) -> None: - """Show header for a category.""" - header = Panel.fit( - f"[bold]{category.title}[/bold]\n{category.description}", - title="Category", - border_style="blue" - ) - self.console.print(header) - self.console.print() - - def _display_category_menu(self, category: MenuCategory) -> str: - """Display actions in a category and get user choice.""" - for i, action in enumerate(category.actions, 1): - # Add indicators for special actions - indicators = [] - if action.requires_root: - indicators.append("[red]🔒[/red]") - if action.dangerous: - indicators.append("[red]⚠️[/red]") - - indicator_str = " ".join(indicators) - if indicator_str: - indicator_str = " " + indicator_str - - self.console.print(f"[cyan]{i}.[/cyan] {action.title}{indicator_str}") - self.console.print(f" [dim]{action.description}[/dim]") - - self.console.print() - self.console.print("[cyan]r.[/cyan] Refresh") - self.console.print("[cyan]b.[/cyan] Back to main menu") - self.console.print() - - return Prompt.ask("Select option", default="b") - - def _execute_action(self, action: MenuAction) -> None: - """Execute a menu action with safety checks.""" - # Root permission check - if action.requires_root and not self._check_root(): - self.console.print("[red]This action requires root privileges. Please run with sudo.[/red]") - self._pause() - return - - # Dangerous action confirmation - if action.dangerous: - from rich.prompt import Confirm - if not Confirm.ask(f"[red]Warning: {action.title} is a potentially dangerous operation. Continue?[/red]"): - return - - try: - self.console.print(f"[blue]Executing: {action.title}[/blue]") - action.action() - except Exception as e: - self.console.print(f"[red]Error executing {action.title}: {e}[/red]") - self._pause() - - def _check_root(self) -> bool: - """Check if running with root privileges.""" - import os - return os.geteuid() == 0 - - def _pause(self) -> None: - """Pause for user input.""" - Prompt.ask("\n[dim]Press Enter to continue[/dim]", default="", show_default=False) - - def _print_section_header(self, title: str, style: str = "bold") -> None: - """Print a consistent section header with underline.""" - title_text = Text(title, style=style) - self.console.print(title_text) - self.console.print(Text("─" * len(title), style="dim")) - - def _get_current_status(self) -> Dict[str, Any]: - """Get current system status summary.""" - # Try to get real-time status from running daemon or state files - mode = None - mode_display = None - - # Check for state files or daemon status - state_files = [ - "/tmp/azazel_state.json", - "/var/run/azazel_state.json", - "/tmp/azazel_user_command.yaml" - ] - - for state_file in state_files: - try: - if Path(state_file).exists(): - import json - import yaml - - if state_file.endswith('.json'): - with open(state_file, 'r') as f: - state_data = json.load(f) - else: - with open(state_file, 'r') as f: - state_data = yaml.safe_load(f) - - if state_data and isinstance(state_data, dict): - if 'command' in state_data and state_data['command'] == 'user_override': - # This is a user override command file - override_mode = state_data.get('mode', 'unknown') - duration = state_data.get('duration_minutes', 3) - timestamp = state_data.get('timestamp', 0) - - import time - elapsed = time.time() - timestamp - remaining = max(0, (duration * 60) - elapsed) - - if remaining > 0: - mode = f"user_{override_mode}" - mode_display = f"USER_{override_mode.upper()} ({remaining:.0f}s)" - break - elif 'state' in state_data and state_data.get('user_mode'): - # This is a state file with user mode info - mode = state_data['state'] - base_mode = state_data.get('base_mode', 'unknown') - timeout_timestamp = state_data.get('timeout_timestamp', 0) - - import time - remaining = max(0, timeout_timestamp - time.time()) - - if remaining > 0: - mode_display = f"USER_{base_mode.upper()} ({remaining:.0f}s)" - else: - # Timeout expired, clean up - try: - import os - os.unlink(state_file) - except: - pass - mode = base_mode - mode_display = base_mode.upper() - break - elif 'state' in state_data: - mode = state_data['state'] - mode_display = mode.upper() - break - except Exception: - continue - - # If no state file found, try to get from a new state machine instance - if not mode: - try: - from azctl.cli import build_machine - machine = build_machine() - summary = machine.summary() - current_mode = summary.get("state", "unknown") - is_user_mode = summary.get("user_mode") == "true" - - if is_user_mode: - timeout_remaining = float(summary.get("user_timeout_remaining", "0")) - base_mode = machine.get_base_mode() - mode = f"user_{base_mode}" - mode_display = f"USER_{base_mode.upper()} ({timeout_remaining:.0f}s)" - else: - mode = current_mode - mode_display = current_mode.upper() - - except Exception: - # Final fallback to decision log status - decision_paths = [ - Path(self.decisions_log) if self.decisions_log else None, - Path("/var/log/azazel/decisions.log"), - Path("decisions.log"), - ] - decision_paths = [p for p in decision_paths if p is not None] - - last_decision = _read_last_decision(decision_paths) - mode = last_decision.get("mode") if last_decision else None - mode_display = mode.upper() if mode else "UNKNOWN" - - # Count active services (simplified) - systemd_services = ["suricata", "vector", "azctl"] - services_active = 0 - for service in systemd_services: - try: - result = run_cmd( - ["systemctl", "is-active", service], - capture_output=True, text=True, timeout=5 - ) - if result.returncode == 0 and result.stdout.strip() == "active": - services_active += 1 - except Exception: - pass - - services_total = len(systemd_services) + 1 # include OpenCanary container - if self._is_container_running("azazel_opencanary"): - services_active += 1 - - return { - "mode": mode, - "mode_display": mode_display if 'mode_display' in locals() else (mode.upper() if mode else "UNKNOWN"), - "services_active": services_active, - "services_total": services_total, - } - - def _get_enhanced_status(self) -> Dict[str, Any]: - """Get enhanced system status with network information.""" - # Get basic status - basic_status = self._get_current_status() - - # Get network profile - from azazel_pi.utils.network_utils import get_active_profile, get_wlan_ap_status, get_wlan_link_info - profile = get_active_profile() - - # Get WLAN interface information - wlan0_info = get_wlan_ap_status(self.lan_if) - wlan1_info = get_wlan_link_info(self.wan_if) - - return { - **basic_status, - "profile": profile, - "wlan0_info": wlan0_info, - "wlan1_info": wlan1_info, - } - - def _is_container_running(self, container_name: str) -> bool: - """Check whether a Docker container is running.""" - try: - result = run_cmd( - ["docker", "inspect", "-f", "{{.State.Running}}", container_name], - capture_output=True, - text=True, - timeout=5, - check=False, - ) - return result.returncode == 0 and (result.stdout or "").strip().lower() == "true" - except Exception: - return False diff --git a/azctl/menu/defense.py b/azctl/menu/defense.py deleted file mode 100644 index 9163b83..0000000 --- a/azctl/menu/defense.py +++ /dev/null @@ -1,570 +0,0 @@ -#!/usr/bin/env python3 -""" -Defense Control Module - -Provides defensive mode management and threat response functionality -for the Azazel TUI menu system. -""" - -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 - -from rich.console import Console -from rich.layout import Layout -from rich.table import Table -from rich.panel import Panel -from rich.prompt import Prompt, Confirm -from rich.text import Text - -from azctl.menu.types import MenuCategory, MenuAction -from azctl.cli import _read_last_decision, _mode_style -from azazel_pi.utils.network_utils import get_wlan_ap_status, get_wlan_link_info, get_active_profile - -try: - from azazel_pi.core.ingest.status_collector import NetworkStatusCollector -except ImportError: - NetworkStatusCollector = None - - -class DefenseModule: - """Defense control and mode management functionality.""" - - def __init__(self, console: Console, decisions_log: Optional[str] = None, lan_if: Optional[str] = None, wan_if: Optional[str] = None): - self.console = console - self.decisions_log = decisions_log - # LAN precedence: explicit arg -> AZAZEL_LAN_IF env -> default wlan0 - self.lan_if = lan_if or os.environ.get("AZAZEL_LAN_IF") or "wlan0" - # Resolve WAN interface default from explicit arg -> env -> WANManager helper -> fallback - try: - from azazel_pi.utils.wan_state import get_active_wan_interface - # Resolve WAN precedence: explicit arg -> AZAZEL_WAN_IF -> WAN manager -> fallback - self.wan_if = ( - wan_if - or os.environ.get("AZAZEL_WAN_IF") - or get_active_wan_interface(default=os.environ.get("AZAZEL_WAN_IF", "wlan1")) - ) - except Exception: - self.wan_if = wan_if or os.environ.get("AZAZEL_WAN_IF") or "wlan1" - - # Initialize status collector if available - try: - self.status_collector = NetworkStatusCollector() - except Exception: - self.status_collector = None - - def get_category(self) -> MenuCategory: - """Get the defense control menu category.""" - return MenuCategory( - title="Defense Control", - description="Manage defensive modes and threat response", - actions=[ - MenuAction("View Current Status", "Display current defensive mode and system status", self._view_status), - MenuAction("User Override: Portal Mode ⏱️", "Override to Portal mode for 3 minutes", self._user_override_portal, requires_root=True), - MenuAction("User Override: Shield Mode ⏱️", "Override to Shield mode for 3 minutes", self._user_override_shield, requires_root=True), - MenuAction("User Override: Lockdown Mode ⚠️⏱️", "Override to Lockdown mode for 3 minutes", self._user_override_lockdown, dangerous=True, requires_root=True), - MenuAction("Return to Auto Mode", "Cancel user override and return to automatic mode", self._return_to_auto, requires_root=True), - MenuAction("View Decision History", "Show recent mode change decisions", self._view_decisions), - ] - ) - - def _view_status(self) -> None: - """Display comprehensive system status.""" - # Clear screen for better visibility - self.console.clear() - - title = Text("Defense Status & System Overview", style="bold blue") - self.console.print(title) - self.console.print(Text("─" * 40, style="dim")) - - # Get status data - decision_paths = [ - Path(self.decisions_log) if self.decisions_log else None, - Path("/var/log/azazel/decisions.log"), - Path("decisions.log"), - ] - decision_paths = [p for p in decision_paths if p is not None] - - # Get current machine state - try: - from azctl.cli import build_machine - machine = build_machine() - summary = machine.summary() - current_mode = summary.get("state", "unknown") - is_user_mode = summary.get("user_mode") == "true" - timeout_remaining = float(summary.get("user_timeout_remaining", "0")) - - if is_user_mode: - base_mode = machine.get_base_mode() - mode_label = f"USER_{base_mode.upper()} ({timeout_remaining:.0f}s remaining)" - # Use base mode color for user override - if base_mode == "portal": - color = "green" - elif base_mode == "shield": - color = "yellow" - elif base_mode == "lockdown": - color = "red" - else: - color = "green" - mode_emoji = "👤" - else: - from azctl.cli import _mode_style - result = _mode_style(current_mode) - if result and len(result) == 2: - mode_label, color = result - else: - mode_label = current_mode.upper() - color = "green" if current_mode == "portal" else "yellow" if current_mode == "shield" else "red" if current_mode == "lockdown" else "blue" - mode_emoji = {"portal": "🟢", "shield": "🟡", "lockdown": "🔴"}.get(current_mode, "⚪") - except Exception: - # Fallback to decision log - last_decision = _read_last_decision(decision_paths) - mode = last_decision.get("mode") if last_decision else "portal" - from azctl.cli import _mode_style - result = _mode_style(mode) - if result and len(result) == 2: - mode_label, color = result - else: - mode_label = mode.upper() - color = "green" if mode == "portal" else "yellow" if mode == "shield" else "red" if mode == "lockdown" else "blue" - mode_emoji = {"portal": "🟢", "shield": "🟡", "lockdown": "🔴"}.get(mode, "⚪") - - wlan0 = get_wlan_ap_status(self.lan_if) - wlan1 = get_wlan_link_info(self.wan_if) - profile = get_active_profile() - - try: - status = self.status_collector.collect() - uptime = status.get('uptime', 'Unknown') - # Parse CPU and memory if available - fallback to our custom functions - try: - cpu_usage = status.get('cpu_percent', None) - if cpu_usage is None or cpu_usage == 'N/A': - cpu_usage = self._get_cpu_usage() - else: - cpu_usage = f"{cpu_usage}%" - - memory_usage = status.get('memory_percent', None) - if memory_usage is None or memory_usage == 'N/A': - memory_usage = self._get_memory_usage() - else: - memory_usage = f"{memory_usage}%" - except: - cpu_usage = self._get_cpu_usage() - memory_usage = self._get_memory_usage() - except Exception: - status = None - uptime = 'Unknown' - cpu_usage = self._get_cpu_usage() - memory_usage = self._get_memory_usage() - - # Create compact info table - info_table = Table(show_header=False, box=None, pad_edge=False) - info_table.add_column("Label", style="bold", width=16) - info_table.add_column("Value", style="white", width=28) - info_table.add_column("Label2", style="bold", width=14) - info_table.add_column("Value2", style="white") - - # Row 1: Defense Mode & Profile - info_table.add_row( - f"{mode_emoji} Defense Mode:", - f"[{color}]{mode_label}[/{color}]", - "📊 Profile:", - profile or "Unknown" - ) - - # Row 2: Threat Scores (if available) - if status and 'threat_score' in status: - score_info = f"{status['threat_score']:.1f} (avg: {status.get('avg_score', 0):.1f})" - else: - score_info = "No data" - - info_table.add_row( - "⚠️ Threat Score:", - score_info, - "⏱️ Uptime:", - str(uptime) - ) - - # Row 3: Network Interfaces - ap_status = "Active" if wlan0.get('is_ap') else "Inactive" - if wlan0.get('is_ap') and wlan0.get('ssid'): - ap_status += f" ({wlan0['ssid']})" - - wan_status = "Connected" if wlan1.get('connected') else "Disconnected" - if wlan1.get('connected') and wlan1.get('ssid'): - wan_status += f" ({wlan1['ssid']})" - - info_table.add_row( - f"📡 AP ({self.lan_if}):", - ap_status, - f"🌐 WAN ({self.wan_if}):", - wan_status - ) - - # Row 4: System Resources - info_table.add_row( - "💾 CPU Usage:", - cpu_usage, - "🧠 Memory:", - memory_usage - ) - - # Add services status if available - try: - suricata_status = run_cmd(['systemctl', 'is-active', 'suricata'], capture_output=True, text=True).stdout.strip() - canary_running = self._is_container_running("azazel_opencanary") - - services_info = f"Suricata: {'✅' if suricata_status == 'active' else '❌'} | Canary: {'✅' if canary_running else '❌'}" - except Exception: - services_info = "Status unknown" - - info_table.add_row( - "🛡️ Services:", - services_info, - "", - "" - ) - - # Display in a compact panel - self.console.print(Panel( - info_table, - title="[bold]System Status[/bold]", - border_style=color, - padding=(1, 2) - )) - - # Add pause to allow user to read the information - self.console.print("\n[dim]Press Enter to continue...[/dim]") - input() - - def _user_override_portal(self) -> None: - """User override to Portal mode.""" - self._user_override_mode("portal", "Portal mode provides minimal restrictions and monitoring.") - - def _user_override_shield(self) -> None: - """User override to Shield mode.""" - self._user_override_mode("shield", "Shield mode provides enhanced monitoring and moderate restrictions.") - - def _user_override_lockdown(self) -> None: - """User override to Lockdown mode.""" - if not Confirm.ask("[red]Warning: Lockdown mode will block all traffic except essential services. Continue?[/red]"): - return - self._user_override_mode("lockdown", "Lockdown mode provides maximum security with strict traffic filtering.") - - def _return_to_auto(self) -> None: - """Return to automatic mode.""" - try: - from azctl.daemon import AzazelDaemon - from azazel_pi.core.state_machine import Event - from azazel_pi.core.config import AzazelConfig - from azazel_pi.core.scorer import ScoreEvaluator - from azctl.cli import build_machine - - # Load the config - config_path = "/home/azazel/Azazel-Pi/configs/network/azazel.yaml" - config = AzazelConfig.from_file(config_path) - - # Create state machine and check current state - machine = build_machine() - - # Load existing state if possible (simplified approach) - if machine.is_user_mode(): - base_mode = machine.get_base_mode() - timeout_event = f"timeout_{base_mode}" - machine.dispatch(Event(name=timeout_event, severity=0)) - self.console.print(f"[green]✓ Returned to automatic {base_mode.upper()} mode[/green]") - else: - self.console.print("[yellow]Already in automatic mode[/yellow]") - - except Exception as e: - self.console.print(f"[red]✗ Failed to return to auto mode: {e}[/red]") - - def _user_override_mode(self, mode: str, description: str) -> None: - """User override mode switching function with 3-minute timer.""" - # Clear screen for better visibility - self.console.clear() - - self.console.print(f"[bold blue]🔄 User Override: {mode.upper()} Mode[/bold blue]") - self.console.print(Text("─" * 40, style="dim")) - self.console.print(f"[blue]Starting user override: {mode.upper()} mode (3 minutes)...[/blue]") - self.console.print(f"[dim]{description}[/dim]") - self.console.print("[yellow]⏱️ User override will expire in 3 minutes and return to automatic mode[/yellow]") - - try: - # Create a command file for the daemon to process - import tempfile - import yaml - import json - import os - import time - - # Create command file with user override instruction - command_data = { - "command": "user_override", - "mode": mode, - "duration_minutes": 3.0, - "timestamp": time.time() - } - - command_file = "/tmp/azazel_user_command.yaml" - with open(command_file, 'w') as f: - yaml.dump(command_data, f) - - # Also create a direct state file for immediate menu feedback - state_data = { - "state": f"user_{mode}", - "user_mode": True, - "base_mode": mode, - "timeout_timestamp": time.time() + (3.0 * 60), - "updated": time.time() - } - - state_file = "/tmp/azazel_state.json" - with open(state_file, 'w') as f: - json.dump(state_data, f) - - # Signal the daemon by creating events with the new user mode - config_path = "/home/azazel/Azazel-Pi/configs/network/azazel.yaml" - - # Create temporary config with user override event - with open(config_path, 'r') as f: - config_data = yaml.safe_load(f) - - # Add user override event - config_data['events'] = [ - { - "name": f"user_{mode}", - "severity": 0, - "user_override": True - } - ] - - # Write temporary config - temp_config = f"/tmp/azazel_override_{mode}.yaml" - with open(temp_config, 'w') as f: - yaml.dump(config_data, f) - - try: - # Process events with azctl - result = run_cmd( - ["python3", "-m", "azctl", "events", "--config", temp_config], - capture_output=True, - text=True, - timeout=30 - ) - - if result.returncode == 0: - self.console.print(f"[green]✓ Successfully started user override: {mode.upper()} mode[/green]") - self.console.print("[dim]Mode will automatically return to system control in 3 minutes[/dim]") - - # Show updated status - self.console.print("\n[bold]Current Status:[/bold]") - try: - from azctl.cli import build_machine - machine = build_machine() - machine.start_user_mode(mode, 3.0) # Simulate the state locally for display - summary = machine.summary() - - if summary.get("user_mode") == "true": - timeout_remaining = float(summary.get("user_timeout_remaining", "0")) - self.console.print(f"[yellow]👤 Mode: USER_{mode.upper()} ({timeout_remaining:.0f}s remaining)[/yellow]") - else: - self.console.print(f"[green]🟢 Mode: {mode.upper()}[/green]") - except Exception: - self.console.print(f"[green]🟢 Mode: {mode.upper()} (User Override Active)[/green]") - else: - self.console.print(f"[red]✗ Failed to start user override: {result.stderr.strip()}[/red]") - - finally: - # Clean up temp files - for temp_file in [command_file, temp_config]: - if os.path.exists(temp_file): - os.unlink(temp_file) - - except Exception as e: - self.console.print(f"[red]✗ Failed to start user override: {e}[/red]") - - # Add pause to allow user to read the result - self.console.print("\n[dim]Press Enter to continue...[/dim]") - input() - - def _switch_mode(self, mode: str, description: str) -> None: - """Generic mode switching function.""" - self.console.print(f"[blue]Switching to {mode.upper()} mode...[/blue]") - self.console.print(f"[dim]{description}[/dim]") - - try: - # Simple approach: create a temporary config with the event and process it - import tempfile - import yaml - import os - - # Load base config - config_path = "/home/azazel/Azazel-Pi/configs/network/azazel.yaml" - with open(config_path, 'r') as f: - config_data = yaml.safe_load(f) - - # Add the mode switch event - config_data['events'] = [ - { - "name": mode, - "severity": 0 - } - ] - - # Write temporary config file - with tempfile.NamedTemporaryFile(mode='w', suffix='.yaml', delete=False) as temp_file: - yaml.dump(config_data, temp_file, default_flow_style=False) - temp_config_path = temp_file.name - - try: - # Process events with azctl - result = run_cmd( - ["python3", "-m", "azctl", "events", "--config", temp_config_path], - capture_output=True, - text=True, - timeout=30 - ) - - if result.returncode == 0: - self.console.print(f"[green]✓ Successfully switched to {mode.upper()} mode[/green]") - else: - # Try to give more specific error info - error_msg = result.stderr.strip() or result.stdout.strip() - self.console.print(f"[red]✗ Failed to switch mode: {error_msg}[/red]") - - finally: - # Clean up temp file - if os.path.exists(temp_config_path): - os.unlink(temp_config_path) - - except Exception as e: - self.console.print(f"[red]✗ Failed to change mode: {e}[/red]") - - self._pause() - - def _view_decisions(self) -> None: - """Display recent decision history.""" - title = Text("Recent Decision History", style="bold") - self.console.print(title) - self.console.print(Text("─" * len("Recent Decision History"), style="dim")) - - decision_file = Path("/var/log/azazel/decisions.log") - if not decision_file.exists(): - self.console.print("[yellow]No decision log found.[/yellow]") - self._pause() - return - - try: - # Read last 10 decisions - with open(decision_file, 'r') as f: - lines = f.readlines() - - recent_lines = lines[-10:] if len(lines) >= 10 else lines - - table = Table() - table.add_column("Timestamp", style="cyan", width=20) - table.add_column("Mode", style="bold", width=10) - table.add_column("Reason", style="white") - table.add_column("Score", justify="right", width=8) - - for line in recent_lines: - try: - import json - decision = json.loads(line.strip()) - - timestamp = decision.get('timestamp', 'Unknown') - mode = decision.get('mode', 'Unknown') - reason = decision.get('reason', 'No reason provided') - score = decision.get('score', 'N/A') - - # Format timestamp - if timestamp and timestamp != 'Unknown': - from datetime import datetime - try: - dt = datetime.fromisoformat(timestamp.replace('Z', '+00:00')) - timestamp = dt.strftime('%Y-%m-%d %H:%M:%S') - except Exception: - pass - - # Color code mode - mode_label, color = _mode_style(mode) - mode_colored = f"[{color}]{mode_label}[/{color}]" - - table.add_row( - timestamp, - mode_colored, - reason[:50] + "..." if len(reason) > 50 else reason, - str(score) if score != 'N/A' else score - ) - - except Exception: - continue - - if table.row_count == 0: - self.console.print("[yellow]No valid decisions found in log.[/yellow]") - else: - self.console.print(table) - - except Exception as e: - self.console.print(f"[red]Error reading decision log: {e}[/red]") - - self._pause() - - def _get_cpu_usage(self) -> str: - """Get current CPU usage percentage.""" - try: - # Read load average - with open('/proc/loadavg', 'r') as f: - load_avg = float(f.read().strip().split()[0]) - - # Get CPU count - with open('/proc/cpuinfo', 'r') as f: - cpu_count = len([line for line in f if line.startswith('processor')]) - - # Calculate rough CPU usage from load average - cpu_usage = min(100.0, (load_avg / cpu_count) * 100) - return f"{cpu_usage:.1f}%" - - except Exception: - return "N/A" - - def _get_memory_usage(self) -> str: - """Get current memory usage percentage.""" - try: - with open('/proc/meminfo', 'r') as f: - meminfo = {} - for line in f: - key, value = line.split(':', 1) - meminfo[key.strip()] = int(value.strip().split()[0]) * 1024 # Convert to bytes - - total_mem = meminfo.get('MemTotal', 0) - free_mem = meminfo.get('MemFree', 0) + meminfo.get('Buffers', 0) + meminfo.get('Cached', 0) - used_mem = total_mem - free_mem - mem_usage = (used_mem / total_mem * 100) if total_mem > 0 else 0 - - return f"{mem_usage:.1f}%" - - except Exception: - return "N/A" - - def _is_container_running(self, container_name: str) -> bool: - """Check whether a Docker container is running.""" - try: - result = run_cmd( - ["docker", "inspect", "-f", "{{.State.Running}}", container_name], - capture_output=True, - text=True, - timeout=5, - check=False, - ) - return result.returncode == 0 and (result.stdout or "").strip().lower() == "true" - except Exception: - return False - - def _pause(self) -> None: - """Pause for user input.""" - Prompt.ask("\n[dim]Press Enter to continue[/dim]", default="", show_default=False) diff --git a/azctl/menu/emergency.py b/azctl/menu/emergency.py deleted file mode 100644 index a7ea492..0000000 --- a/azctl/menu/emergency.py +++ /dev/null @@ -1,425 +0,0 @@ -#!/usr/bin/env python3 -""" -Emergency Operations Module - -Provides emergency response and recovery operations for the Azazel TUI menu system. -""" - -import subprocess -from azazel_pi.utils.cmd_runner import run as run_cmd -import os -from datetime import datetime -from pathlib import Path -from typing import Optional - -from rich.console import Console -from rich.text import Text -from rich.prompt import Prompt, Confirm - -from azctl.menu.types import MenuCategory, MenuAction -from azazel_pi.utils.network_utils import get_wlan_ap_status, get_wlan_link_info -from azazel_pi.utils.wan_state import get_active_wan_interface - - -class EmergencyModule: - """Emergency operations and recovery functionality.""" - - def __init__(self, console: Console, lan_if: Optional[str] = None, wan_if: Optional[str] = None): - self.console = console - # LAN precedence: explicit arg -> AZAZEL_LAN_IF env -> default wlan0 - self.lan_if = lan_if or os.environ.get("AZAZEL_LAN_IF") or "wlan0" - # WAN precedence: explicit arg -> AZAZEL_WAN_IF env -> helper -> fallback wlan1 - try: - self.wan_if = wan_if or os.environ.get("AZAZEL_WAN_IF") or get_active_wan_interface() - except Exception: - self.wan_if = wan_if or os.environ.get("AZAZEL_WAN_IF") or "wlan1" - - def get_category(self) -> MenuCategory: - """Get the emergency operations menu category.""" - return MenuCategory( - title="Emergency Operations", - description="Emergency response and recovery operations", - actions=[ - MenuAction("Emergency Lockdown 🔒 ⚠️", "Immediately lock down all network access", self._emergency_lockdown, requires_root=True, dangerous=True), - MenuAction("Reset Network Configuration 🔒 ⚠️", "Reset all network settings to defaults", self._reset_network, requires_root=True, dangerous=True), - MenuAction("Generate System Report", "Create comprehensive system status report", self._system_report), - MenuAction("Factory Reset 🔒 ⚠️", "Reset system to factory defaults", self._factory_reset, requires_root=True, dangerous=True), - ] - ) - - def _emergency_lockdown(self) -> None: - """Immediate emergency lockdown of all network access.""" - self.console.print("[bold red]EMERGENCY LOCKDOWN PROCEDURE[/bold red]") - title = Text("Emergency Network Lockdown", style="bold red") - self.console.print(title) - self.console.print(Text("─" * len("Emergency Network Lockdown"), style="dim")) - - self.console.print("[yellow]This will immediately:") - self.console.print("• Block all incoming and outgoing traffic") - self.console.print("• Disconnect all wireless connections") - self.console.print("• Stop all network services") - self.console.print("• Switch to maximum security mode[/yellow]") - self.console.print() - - if not Confirm.ask("[red]Proceed with emergency lockdown?[/red]", default=False): - self.console.print("[yellow]Emergency lockdown cancelled.[/yellow]") - self._pause() - return - - self.console.print("[red]Initiating emergency lockdown...[/red]") - - try: - # Step 1: Switch to lockdown mode - self.console.print("[blue]1. Switching to lockdown mode...[/blue]") - result = run_cmd( - ["python3", "-m", "azctl", "events", "--mode", "lockdown"], - capture_output=True, text=True, timeout=30 - ) - if result.returncode == 0: - self.console.print("[green]✓ Lockdown mode activated[/green]") - else: - self.console.print(f"[red]✗ Mode switch failed: {result.stderr.strip()}[/red]") - - # Step 2: Apply emergency firewall rules - self.console.print("[blue]2. Applying emergency firewall rules...[/blue]") - try: - run_cmd(["sudo", "nft", "flush", "ruleset"], timeout=10) - run_cmd([ - "sudo", "nft", "add", "table", "inet", "emergency" - ], timeout=5) - run_cmd([ - "sudo", "nft", "add", "chain", "inet", "emergency", "input", - "{", "type", "filter", "hook", "input", "priority", "0", ";", "policy", "drop", ";", "}" - ], timeout=5) - run_cmd([ - "sudo", "nft", "add", "chain", "inet", "emergency", "forward", - "{", "type", "filter", "hook", "forward", "priority", "0", ";", "policy", "drop", ";", "}" - ], timeout=5) - run_cmd([ - "sudo", "nft", "add", "chain", "inet", "emergency", "output", - "{", "type", "filter", "hook", "output", "priority", "0", ";", "policy", "drop", ";", "}" - ], timeout=5) - # Allow loopback - run_cmd([ - "sudo", "nft", "add", "rule", "inet", "emergency", "input", "iif", "lo", "accept" - ], timeout=5) - run_cmd([ - "sudo", "nft", "add", "rule", "inet", "emergency", "output", "oif", "lo", "accept" - ], timeout=5) - - self.console.print("[green]✓ Emergency firewall rules applied[/green]") - except Exception as e: - self.console.print(f"[red]✗ Firewall rules failed: {e}[/red]") - - # Step 3: Disconnect wireless - self.console.print("[blue]3. Disconnecting wireless connections...[/blue]") - try: - 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]") - - # Step 4: Stop services - self.console.print("[blue]4. Stopping non-essential services...[/blue]") - services_to_stop = ["vector"] - for service in services_to_stop: - try: - 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]") - try: - run_cmd(["sudo", "docker", "stop", "azazel_opencanary"], timeout=30) - self.console.print("[green]✓ azazel_opencanary stopped[/green]") - except Exception: - self.console.print("[yellow]! azazel_opencanary stop failed[/yellow]") - - self.console.print("\n[bold red]EMERGENCY LOCKDOWN COMPLETED[/bold red]") - self.console.print("[yellow]System is now in maximum security lockdown mode.[/yellow]") - self.console.print("[yellow]Manual intervention required to restore normal operations.[/yellow]") - - except Exception as e: - self.console.print(f"[red]Emergency lockdown failed: {e}[/red]") - - self._pause() - - def _reset_network(self) -> None: - """Reset network configuration to defaults.""" - title = Text("Reset Network Configuration", style="bold red") - self.console.print(title) - self.console.print(Text("─" * len("Reset Network Configuration"), style="dim")) - - self.console.print("[yellow]This will:") - self.console.print("• Remove all saved Wi-Fi networks") - self.console.print("• Reset network interfaces") - self.console.print("• Restore default network configuration") - self.console.print("• Restart network services[/yellow]") - self.console.print() - - if not Confirm.ask("[red]Proceed with network reset?[/red]", default=False): - return - - self.console.print("[blue]Resetting network configuration...[/blue]") - - try: - # Reset wpa_supplicant configuration - self.console.print("[blue]1. Resetting Wi-Fi configuration...[/blue]") - try: - run_cmd(["sudo", "systemctl", "stop", "wpa_supplicant"], timeout=10) - - # Backup and reset wpa_supplicant.conf - 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 -update_config=1 -country=US -""" - with open("/tmp/wpa_supplicant_reset.conf", "w") as f: - f.write(minimal_config) - - run_cmd([ - "sudo", "cp", "/tmp/wpa_supplicant_reset.conf", - "/etc/wpa_supplicant/wpa_supplicant.conf" - ], timeout=5) - - run_cmd(["sudo", "systemctl", "start", "wpa_supplicant"], timeout=10) - self.console.print("[green]✓ Wi-Fi configuration reset[/green]") - - except Exception as e: - self.console.print(f"[red]✗ Wi-Fi reset failed: {e}[/red]") - - # Reset network interfaces - self.console.print("[blue]2. Resetting network interfaces...[/blue]") - try: - 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]") - - # Restart network services - self.console.print("[blue]3. Restarting network services...[/blue]") - services = ["dhcpcd", "hostapd"] - 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]") - - except Exception as e: - self.console.print(f"[red]Network reset failed: {e}[/red]") - - self._pause() - - def _system_report(self) -> None: - """Generate comprehensive system status report.""" - title = Text("System Status Report Generator", style="bold") - self.console.print(title) - self.console.print(Text("─" * len("System Status Report Generator"), style="dim")) - - timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") - report_file = f"/tmp/azazel_system_report_{timestamp}.txt" - - self.console.print(f"[blue]Generating system report: {report_file}[/blue]") - - try: - with open(report_file, 'w') as report: - report.write(f"AZAZEL-PI SYSTEM REPORT\n") - report.write(f"Generated: {datetime.now().isoformat()}\n") - report.write("=" * 50 + "\n\n") - - # System information - report.write("SYSTEM INFORMATION\n") - report.write("-" * 20 + "\n") - try: - 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 = 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") - - report.write("\n") - - # Current mode - report.write("AZAZEL STATUS\n") - report.write("-" * 15 + "\n") - try: - from azctl.cli import _read_last_decision - decision_paths = [Path("/var/log/azazel/decisions.log")] - current = _read_last_decision(decision_paths) - if current: - report.write(f"Current Mode: {current.get('mode', 'unknown').upper()}\n") - report.write(f"Last Decision: {current.get('timestamp', 'unknown')}\n") - report.write(f"Reason: {current.get('reason', 'No reason provided')}\n") - else: - report.write("Current Mode: Unable to determine\n") - except Exception: - report.write("Current Mode: Error reading decision log\n") - - report.write("\n") - - # Network status - report.write("NETWORK STATUS\n") - report.write("-" * 15 + "\n") - try: - wlan0 = get_wlan_ap_status(self.lan_if) - wlan1 = get_wlan_link_info(self.wan_if) - - report.write(f"LAN Interface ({self.lan_if}):\n") - report.write(f" AP Mode: {'Yes' if wlan0.get('is_ap') else 'No'}\n") - report.write(f" SSID: {wlan0.get('ssid', 'N/A')}\n") - report.write(f" Clients: {wlan0.get('stations', 'N/A')}\n") - - report.write(f"WAN Interface ({self.wan_if}):\n") - report.write(f" Connected: {'Yes' if wlan1.get('connected') else 'No'}\n") - report.write(f" SSID: {wlan1.get('ssid', 'N/A')}\n") - report.write(f" IP: {wlan1.get('ip4', 'N/A')}\n") - - except Exception as e: - report.write(f"Network Status: Error - {e}\n") - - report.write("\n") - - # Service status - report.write("SERVICE STATUS\n") - report.write("-" * 15 + "\n") - services = ["azctl", "azctl-serve", "suricata", "vector"] - for service in services: - try: - result = run_cmd( - ["systemctl", "is-active", f"{service}.service"], - capture_output=True, text=True, timeout=5 - ) - status = "ACTIVE" if result.returncode == 0 else "INACTIVE" - report.write(f"{service}: {status}\n") - except Exception: - report.write(f"{service}: UNKNOWN\n") - report.write(f"azazel_opencanary (Docker): {'ACTIVE' if self._is_container_running('azazel_opencanary') else 'INACTIVE'}\n") - - report.write("\n") - - # System resources - report.write("SYSTEM RESOURCES\n") - report.write("-" * 17 + "\n") - try: - 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 = run_cmd(["df", "-h", "/"], capture_output=True, text=True, timeout=5) - report.write("\nDisk Usage:\n") - report.write(result.stdout) - except Exception: - report.write("Disk Usage: Unable to determine\n") - - report.write("\n") - - # Recent logs - report.write("RECENT SYSTEM LOGS\n") - report.write("-" * 19 + "\n") - try: - result = run_cmd( - ["journalctl", "-n", "20", "--no-pager"], - capture_output=True, text=True, timeout=10 - ) - report.write(result.stdout) - except Exception: - report.write("Recent logs: Unable to retrieve\n") - - self.console.print(f"[green]✓ System report generated: {report_file}[/green]") - self.console.print(f"[blue]Report can be viewed with: cat {report_file}[/blue]") - - except Exception as e: - self.console.print(f"[red]Failed to generate report: {e}[/red]") - - self._pause() - - def _factory_reset(self) -> None: - """Reset system to factory defaults.""" - title = Text("Factory Reset", style="bold red") - self.console.print(title) - self.console.print(Text("─" * len("Factory Reset"), style="dim")) - - self.console.print("[red]WARNING: This will reset ALL system settings to factory defaults![/red]") - self.console.print("[yellow]This includes:") - self.console.print("• All Wi-Fi networks and passwords") - self.console.print("• Custom configurations") - self.console.print("• Log files and history") - self.console.print("• User settings and profiles") - self.console.print("• Decision history[/yellow]") - self.console.print() - - # Multiple confirmations for factory reset - if not Confirm.ask("[red]Are you sure you want to perform a factory reset?[/red]", default=False): - return - - if not Confirm.ask("[red]This cannot be undone. Really proceed with factory reset?[/red]", default=False): - return - - # Require typing "FACTORY RESET" to confirm - confirmation = Prompt.ask("[red]Type 'FACTORY RESET' to confirm") - if confirmation != "FACTORY RESET": - self.console.print("[yellow]Factory reset cancelled.[/yellow]") - self._pause() - return - - self.console.print("[red]Performing factory reset...[/red]") - - try: - # This would implement actual factory reset logic - # For safety, we'll just simulate the process - self.console.print("[blue]1. Stopping all services...[/blue]") - self.console.print("[blue]2. Clearing configurations...[/blue]") - self.console.print("[blue]3. Resetting to defaults...[/blue]") - self.console.print("[blue]4. Restarting services...[/blue]") - - # In a real implementation, you would: - # - Stop all Azazel services - # - Remove configuration files - # - Restore default configurations - # - Clear logs and decision history - # - Reset network settings - # - Restart all services - - self.console.print("[yellow]Factory reset simulation completed.[/yellow]") - self.console.print("[red]In production, this would reset all system settings.[/red]") - - except Exception as e: - self.console.print(f"[red]Factory reset failed: {e}[/red]") - - self._pause() - - def _pause(self) -> None: - """Pause for user input.""" - Prompt.ask("\n[dim]Press Enter to continue[/dim]", default="", show_default=False) - - def _is_container_running(self, container_name: str) -> bool: - """Check whether a Docker container is running.""" - try: - result = run_cmd( - ["docker", "inspect", "-f", "{{.State.Running}}", container_name], - capture_output=True, - text=True, - timeout=5, - check=False, - ) - return result.returncode == 0 and (result.stdout or "").strip().lower() == "true" - except Exception: - return False diff --git a/azctl/menu/monitoring.py b/azctl/menu/monitoring.py deleted file mode 100644 index a3912a5..0000000 --- a/azctl/menu/monitoring.py +++ /dev/null @@ -1,162 +0,0 @@ -#!/usr/bin/env python3 -""" -Log Monitoring Module - -Provides log viewing and monitoring functionality for the Azazel TUI menu system. -""" - -import subprocess -from azazel_pi.utils.cmd_runner import run as run_cmd -from typing import Optional - -from rich.console import Console -from rich.text import Text -from rich.prompt import Prompt - -from azctl.menu.types import MenuCategory, MenuAction - - -class MonitoringModule: - """Log monitoring and viewing functionality.""" - - def __init__(self, console: Console): - self.console = console - - def get_category(self) -> MenuCategory: - """Get the log monitoring menu category.""" - return MenuCategory( - title="Log Monitoring", - description="View system and security logs", - actions=[ - MenuAction("View Live Decision Log", "Monitor defensive decision changes in real-time", self._live_decision_log), - MenuAction("View Suricata Alerts", "Display recent Suricata IDS alerts", self._suricata_alerts), - MenuAction("View System Logs", "Display recent system messages", self._system_logs), - MenuAction("View Security Logs", "Display security-related log entries", self._security_logs), - ] - ) - - def _live_decision_log(self) -> None: - """Display live decision log updates.""" - title = Text("Live Decision Log Monitor", style="bold") - self.console.print(title) - self.console.print(Text("─" * len("Live Decision Log Monitor"), style="dim")) - - self.console.print("[blue]Press Ctrl+C to stop monitoring[/blue]") - self.console.print() - - try: - result = run_cmd( - ["tail", "-f", "/var/log/azazel/decisions.log"], - timeout=30 - ) - except subprocess.TimeoutExpired: - self.console.print("[yellow]Monitoring stopped.[/yellow]") - except KeyboardInterrupt: - self.console.print("\n[yellow]Monitoring interrupted by user.[/yellow]") - except Exception as e: - self.console.print(f"[red]Error monitoring logs: {e}[/red]") - - self._pause() - - def _suricata_alerts(self) -> None: - """Display recent Suricata alerts.""" - title = Text("Recent Suricata Alerts", style="bold") - self.console.print(title) - self.console.print(Text("─" * len("Recent Suricata Alerts"), style="dim")) - - try: - result = run_cmd( - ["journalctl", "-u", "suricata", "-n", "50", "--no-pager"], - capture_output=True, text=True, timeout=10 - ) - - if result.returncode == 0: - logs = result.stdout.strip() - if logs: - for line in logs.split('\n')[-20:]: - if "ALERT" in line or "alert" in line: - self.console.print(f"[red]{line}[/red]") - elif "DROP" in line or "drop" in line: - self.console.print(f"[yellow]{line}[/yellow]") - else: - self.console.print(f"[dim]{line}[/dim]") - else: - self.console.print("[yellow]No recent alerts found.[/yellow]") - else: - self.console.print(f"[red]Failed to retrieve alerts: {result.stderr.strip()}[/red]") - - except Exception as e: - self.console.print(f"[red]Error retrieving alerts: {e}[/red]") - - self._pause() - - def _system_logs(self) -> None: - """Display recent system logs.""" - title = Text("Recent System Messages", style="bold") - self.console.print(title) - self.console.print(Text("─" * len("Recent System Messages"), style="dim")) - - try: - result = run_cmd( - ["journalctl", "-n", "30", "--no-pager"], - capture_output=True, text=True, timeout=10 - ) - - if result.returncode == 0: - logs = result.stdout.strip() - if logs: - for line in logs.split('\n')[-20:]: - if "error" in line.lower() or "failed" in line.lower(): - self.console.print(f"[red]{line}[/red]") - elif "warning" in line.lower() or "warn" in line.lower(): - self.console.print(f"[yellow]{line}[/yellow]") - else: - self.console.print(f"[dim]{line}[/dim]") - else: - self.console.print("[yellow]No recent logs found.[/yellow]") - else: - self.console.print(f"[red]Failed to retrieve logs: {result.stderr.strip()}[/red]") - - except Exception as e: - self.console.print(f"[red]Error retrieving logs: {e}[/red]") - - self._pause() - - def _security_logs(self) -> None: - """Display security-related logs.""" - title = Text("Security Log Entries", style="bold") - self.console.print(title) - self.console.print(Text("─" * len("Security Log Entries"), style="dim")) - - try: - # Check multiple security-related logs - commands = [ - (["journalctl", "_COMM=sshd", "-n", "20", "--no-pager"], "SSH"), - (["journalctl", "_COMM=sudo", "-n", "10", "--no-pager"], "Sudo"), - (["tail", "-20", "/var/log/auth.log"], "Auth"), - ] - - for cmd, label in commands: - self.console.print(f"\n[bold cyan]{label} Events:[/bold cyan]") - try: - 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(): - if "failed" in line.lower() or "invalid" in line.lower(): - self.console.print(f"[red]{line}[/red]") - else: - self.console.print(f"[dim]{line}[/dim]") - else: - self.console.print("[dim]No recent events[/dim]") - except Exception: - self.console.print("[dim]Unable to retrieve logs[/dim]") - - except Exception as e: - self.console.print(f"[red]Error retrieving security logs: {e}[/red]") - - self._pause() - - def _pause(self) -> None: - """Pause for user input.""" - Prompt.ask("\n[dim]Press Enter to continue[/dim]", default="", show_default=False) \ No newline at end of file diff --git a/azctl/menu/network.py b/azctl/menu/network.py deleted file mode 100644 index b931d2d..0000000 --- a/azctl/menu/network.py +++ /dev/null @@ -1,298 +0,0 @@ -#!/usr/bin/env python3 -""" -Network Management Module - -Provides network interface status, Wi-Fi management, and traffic monitoring -functionality for the Azazel TUI menu system. -""" - -from typing import Optional, Any -import os - -from rich.console import Console - -from azctl.menu.types import MenuCategory, MenuAction -from azctl.menu.wifi import WiFiManager -from azazel_pi.utils.wan_state import get_active_wan_interface -from azazel_pi.utils.network_utils import ( - get_wlan_ap_status, get_wlan_link_info, get_active_profile, - get_network_interfaces_stats, format_bytes -) - -try: - from azazel_pi.core.ingest.status_collector import NetworkStatusCollector -except ImportError: - NetworkStatusCollector = None - - -class NetworkModule: - """Network information and management functionality.""" - - def __init__(self, console: Console, lan_if: Optional[str] = None, wan_if: Optional[str] = None, - status_collector: Optional[Any] = None): - self.console = console - # LAN precedence: explicit arg -> AZAZEL_LAN_IF env -> default wlan0 - self.lan_if = lan_if or os.environ.get("AZAZEL_LAN_IF") or "wlan0" - # WAN precedence: explicit arg -> AZAZEL_WAN_IF env -> helper -> fallback wlan1 - try: - self.wan_if = wan_if or os.environ.get("AZAZEL_WAN_IF") or get_active_wan_interface() - except Exception: - self.wan_if = wan_if or os.environ.get("AZAZEL_WAN_IF") or "wlan1" - self.status_collector = status_collector - - # Initialize Wi-Fi manager - self.wifi_manager = WiFiManager(console) - - def get_category(self) -> MenuCategory: - """Get the network management menu category.""" - return MenuCategory( - title="Network Information", - description="View and manage network configuration", - actions=[ - MenuAction("Network Interface Status", "Display WLAN interface information", self._show_interface_status), - MenuAction("Wi-Fi Connection Manager 🔒", "Scan and connect to available Wi-Fi networks", self._manage_wifi, requires_root=True), - MenuAction("Active Profile", "Show current network profile configuration", self._show_active_profile), - MenuAction("Traffic Statistics", "Display network traffic information", self._traffic_stats), - ] - ) - - def _show_interface_status(self) -> None: - """Display detailed network interface status.""" - self._print_section_header("Network Interface Status") - - wlan0 = get_wlan_ap_status(self.lan_if) - wlan1 = get_wlan_link_info(self.wan_if) - - # Create layout for both interfaces - from rich.layout import Layout - from rich.table import Table - - layout = Layout() - layout.split_row( - Layout(name="lan"), - Layout(name="wan") - ) - - # LAN interface (AP) table - lan_table = Table(show_header=False, box=None) - lan_table.add_column("Property", style="cyan", min_width=15) - lan_table.add_column("Value", style="white") - - lan_table.add_row("Mode", "Access Point" if wlan0.get('is_ap') else "Station") - lan_table.add_row("Status", "[green]Active[/green]" if wlan0.get('is_ap') else "[red]Inactive[/red]") - - if wlan0.get('ssid'): - lan_table.add_row("SSID", wlan0['ssid']) - if wlan0.get('channel'): - lan_table.add_row("Channel", str(wlan0['channel'])) - if wlan0.get('stations') is not None: - lan_table.add_row("Connected Clients", str(wlan0['stations'])) - - from rich.panel import Panel - layout["lan"].update(Panel(lan_table, title=f"{self.lan_if} (LAN)", border_style="cyan")) - - # WAN interface table - wan_table = Table(show_header=False, box=None) - wan_table.add_column("Property", style="cyan", min_width=15) - wan_table.add_column("Value", style="white") - - if wlan1.get('connected'): - wan_table.add_row("Status", "[green]Connected[/green]") - wan_table.add_row("SSID", wlan1.get('ssid', 'N/A')) - wan_table.add_row("IP Address", wlan1.get('ip4', 'N/A')) - if wlan1.get('signal_dbm'): - signal_color = "green" if wlan1['signal_dbm'] > -50 else "yellow" if wlan1['signal_dbm'] > -70 else "red" - wan_table.add_row("Signal", f"[{signal_color}]{wlan1['signal_dbm']} dBm[/{signal_color}]") - if wlan1.get('tx_bitrate'): - wan_table.add_row("TX Bitrate", wlan1['tx_bitrate']) - if wlan1.get('rx_bitrate'): - wan_table.add_row("RX Bitrate", wlan1['rx_bitrate']) - else: - wan_table.add_row("Status", "[red]Disconnected[/red]") - wan_table.add_row("SSID", "N/A") - wan_table.add_row("IP Address", "N/A") - - layout["wan"].update(Panel(wan_table, title=f"{self.wan_if} (WAN)", border_style="yellow")) - - self.console.print(layout) - self._pause() - - def _manage_wifi(self) -> None: - """Launch Wi-Fi connection manager.""" - self.wifi_manager.manage_wifi(self.wan_if) - - def _show_active_profile(self) -> None: - """Display active network profile configuration.""" - self._print_section_header("Active Network Profile Configuration") - - profile_name = get_active_profile() - - if not profile_name: - self.console.print("[yellow]No active profile detected.[/yellow]") - self._pause() - return - - self.console.print(f"[green]Active Profile: {profile_name}[/green]") - - # Try to read profile configuration - from pathlib import Path - import yaml - - profile_path = Path(f"/etc/azazel/profiles/{profile_name}.yaml") - if not profile_path.exists(): - profile_path = Path(f"configs/profiles/{profile_name}.yaml") - - if profile_path.exists(): - try: - with open(profile_path, 'r') as f: - config = yaml.safe_load(f) - - from rich.table import Table - - for section_name, section_data in config.items(): - if isinstance(section_data, dict): - table = Table(show_header=False, title=section_name.replace('_', ' ').title()) - table.add_column("Setting", style="cyan", min_width=20) - table.add_column("Value", style="white") - - for key, value in section_data.items(): - if isinstance(value, (list, dict)): - value = str(value) - table.add_row(key.replace('_', ' ').title(), str(value)) - - from rich.panel import Panel - panel = Panel(table, title=section_name, border_style="blue") - self.console.print(panel) - self.console.print() - - except Exception as e: - self.console.print(f"[red]Error reading profile: {e}[/red]") - else: - self.console.print(f"[yellow]Profile configuration file not found: {profile_path}[/yellow]") - - self._pause() - - def _traffic_stats(self) -> None: - """Display network traffic statistics.""" - self._print_section_header("Network Traffic Statistics") - - if self.status_collector is None: - self.console.print("[yellow]Status collector not available. Attempting to collect basic statistics...[/yellow]") - self._show_basic_traffic_stats() - return - - try: - status = self.status_collector.collect() - except Exception as e: - self.console.print(f"[red]Error collecting statistics: {e}[/red]") - self.console.print("[yellow]Falling back to basic statistics...[/yellow]") - self._show_basic_traffic_stats() - return - - # Create main statistics table - from rich.table import Table - - stats_table = Table() - stats_table.add_column("Interface", style="cyan") - stats_table.add_column("RX Bytes", justify="right", style="green") - stats_table.add_column("TX Bytes", justify="right", style="blue") - stats_table.add_column("RX Packets", justify="right", style="green") - stats_table.add_column("TX Packets", justify="right", style="blue") - stats_table.add_column("Errors", justify="right", style="red") - - # Add interface statistics - for interface, stats in status.get('interfaces', {}).items(): - # Show statistics for LAN and WAN interfaces; prefer resolved interfaces - if interface in [self.lan_if, self.wan_if]: - rx_bytes = self._format_bytes(stats.get('rx_bytes', 0)) - tx_bytes = self._format_bytes(stats.get('tx_bytes', 0)) - rx_packets = f"{stats.get('rx_packets', 0):,}" - tx_packets = f"{stats.get('tx_packets', 0):,}" - errors = stats.get('rx_errors', 0) + stats.get('tx_errors', 0) - - stats_table.add_row( - interface, - rx_bytes, - tx_bytes, - rx_packets, - tx_packets, - str(errors) if errors > 0 else "0" - ) - - from rich.panel import Panel - panel = Panel(stats_table, title="Current Statistics", border_style="green") - self.console.print(panel) - - self._pause() - - def _show_basic_traffic_stats(self) -> None: - """Display basic network traffic statistics using /proc/net/dev.""" - try: - import subprocess - from rich.table import Table - from rich.panel import Panel - - # Read network statistics from /proc/net/dev - with open('/proc/net/dev', 'r') as f: - lines = f.readlines() - - stats_table = Table() - stats_table.add_column("Interface", style="cyan") - stats_table.add_column("RX Bytes", justify="right", style="green") - stats_table.add_column("TX Bytes", justify="right", style="blue") - stats_table.add_column("RX Packets", justify="right", style="green") - stats_table.add_column("TX Packets", justify="right", style="blue") - stats_table.add_column("Errors", justify="right", style="red") - - for line in lines[2:]: # Skip header lines - fields = line.split() - if len(fields) >= 17: - interface = fields[0].rstrip(':') - - # Only show relevant interfaces - if interface in [self.lan_if, self.wan_if, 'lo']: - rx_bytes = int(fields[1]) - rx_packets = int(fields[2]) - rx_errors = int(fields[3]) - tx_bytes = int(fields[9]) - tx_packets = int(fields[10]) - tx_errors = int(fields[11]) - - total_errors = rx_errors + tx_errors - - stats_table.add_row( - interface, - self._format_bytes(rx_bytes), - self._format_bytes(tx_bytes), - f"{rx_packets:,}", - f"{tx_packets:,}", - str(total_errors) if total_errors > 0 else "0" - ) - - panel = Panel(stats_table, title="Network Interface Statistics", border_style="green") - self.console.print(panel) - - except Exception as e: - self.console.print(f"[red]Error reading basic statistics: {e}[/red]") - - self._pause() - - def _format_bytes(self, bytes_value: int) -> str: - """Format bytes value with appropriate units.""" - for unit in ['B', 'KB', 'MB', 'GB', 'TB']: - if bytes_value < 1024.0: - return f"{bytes_value:.1f} {unit}" - bytes_value /= 1024.0 - return f"{bytes_value:.1f} PB" - - def _print_section_header(self, title: str, style: str = "bold") -> None: - """Print a consistent section header with underline.""" - from rich.text import Text - title_text = Text(title, style=style) - self.console.print(title_text) - self.console.print(Text("─" * len(title), style="dim")) - - def _pause(self) -> None: - """Pause for user input.""" - from rich.prompt import Prompt - Prompt.ask("\n[dim]Press Enter to continue[/dim]", default="", show_default=False) \ No newline at end of file diff --git a/azctl/menu/services.py b/azctl/menu/services.py deleted file mode 100644 index 024ef01..0000000 --- a/azctl/menu/services.py +++ /dev/null @@ -1,530 +0,0 @@ -#!/usr/bin/env python3 -""" -Services Management Module - -Provides system service control and monitoring functionality -for the Azazel TUI menu system. -""" - -from azazel_pi.utils.cmd_runner import run as run_cmd -from typing import Tuple - -from rich.console import Console -from rich.table import Table -from rich.text import Text -from rich.prompt import Prompt, Confirm - -from azctl.menu.types import MenuCategory, MenuAction - - -class ServicesModule: - """System services management functionality.""" - - def __init__(self, console: Console): - self.console = console - - def get_category(self) -> MenuCategory: - """Get the services management menu category.""" - return MenuCategory( - title="Service Management", - description="Control Azazel system services", - actions=[ - MenuAction("Service Status Overview", "View all Azazel services status", self._service_status), - MenuAction("Start/Stop Suricata 🔒", "Control Suricata IDS service", lambda: self._manage_service("suricata.service", "Suricata IDS"), requires_root=True), - MenuAction("Start/Stop OpenCanary 🔒", "Control OpenCanary honeypot container", self._manage_opencanary_container, requires_root=True), - MenuAction("Start/Stop Vector 🔒", "Control Vector log processing service", lambda: self._manage_service("vector.service", "Vector Log Processor"), requires_root=True), - MenuAction("Restart All Services 🔒 ⚠️", "Restart all Azazel services", self._restart_all_services, requires_root=True, dangerous=True), - ] - ) - - def _service_status(self) -> None: - """Display comprehensive service status overview.""" - title = Text("Azazel Service Status Overview", style="bold") - self.console.print(title) - self.console.print(Text("─" * len("Azazel Service Status Overview"), style="dim")) - - # Define Azazel services to monitor - azazel_services = [ - ("azctl-unified.service", "Azazel Unified Control Daemon", "systemd"), - ("suricata.service", "Suricata IDS/IPS", "systemd"), - ("azazel_opencanary", "OpenCanary Honeypot (Docker)", "container"), - ("vector.service", "Vector Log Processor", "systemd"), - ("azazel-epd.service", "E-Paper Display", "systemd"), - ] - - # Create services table - table = Table() - table.add_column("Service", style="white", min_width=20) - table.add_column("Description", style="dim", min_width=25) - table.add_column("Status", justify="center", width=12) - table.add_column("Active Since", style="cyan", width=15) - table.add_column("Actions", style="yellow", width=15) - - for service_name, description, svc_type in azazel_services: - if svc_type == "container": - status, since, actions = self._get_container_info(service_name) - else: - status, since, actions = self._get_service_info(service_name) - table.add_row(service_name, description, status, since, actions) - - self.console.print(table) - self.console.print() - - self.console.print("[bold]Available actions:[/bold]") - self.console.print("• Individual service management: Select from Service Management menu") - self.console.print("• Quick restart all: Use 'Restart All Services' action") - - self._pause() - - def _get_service_info(self, service_name: str) -> Tuple[str, str, str]: - """Get service status information.""" - try: - # Get service status - status_result = run_cmd( - ["systemctl", "is-active", service_name], - capture_output=True, text=True, timeout=5 - ) - - if status_result.returncode == 0 and status_result.stdout.strip() == "active": - status = "🟢 ACTIVE" - - # Get when service started - since_result = run_cmd( - ["systemctl", "show", service_name, "--property=ActiveEnterTimestamp", "--value"], - capture_output=True, text=True, timeout=5 - ) - - if since_result.returncode == 0 and since_result.stdout.strip(): - try: - from datetime import datetime - timestamp_str = since_result.stdout.strip() - # Parse systemd timestamp format - dt = datetime.strptime(timestamp_str.split()[0:3], '%a %Y-%m-%d %H:%M:%S') - since = dt.strftime('%a') # Short day name - except Exception: - since = "Unknown" - else: - since = "Unknown" - - actions = "stop | restart" - - else: - status = "🔴 STOPPED" - since = "─" - actions = "start" - - except Exception: - status = "❓ UNKNOWN" - since = "─" - actions = "check" - - return status, since, actions - - def _get_container_info(self, container_name: str) -> Tuple[str, str, str]: - """Get Docker container status information.""" - try: - result = run_cmd( - [ - "docker", - "ps", - "-a", - "--filter", - f"name=^{container_name}$", - "--format", - "{{.Status}}|{{.RunningFor}}", - ], - capture_output=True, - text=True, - timeout=5, - check=False, - ) - - data = (result.stdout or "").strip() - if not data: - return "🔴 STOPPED", "─", "start" - - parts = data.split("|", 1) - status_str = parts[0] - running_for = parts[1] if len(parts) > 1 else "" - - if status_str.startswith("Up"): - status = "🟢 ACTIVE" - actions = "stop | restart" - since = running_for or "active" - else: - status = "🔴 STOPPED" - actions = "start" - since = "─" - - return status, since, actions - - except Exception: - return "❓ UNKNOWN", "─", "check" - - def _manage_service(self, service_name: str, display_name: str) -> None: - """Generic service management interface.""" - self.console.clear() - title = Text(f"{display_name} Management", style="bold") - self.console.print(title) - self.console.print(Text("─" * len(f"{display_name} Management"), style="dim")) - - # Get current status - try: - status_result = run_cmd( - ["systemctl", "is-active", service_name], - capture_output=True, text=True, timeout=5 - ) - - is_active = status_result.returncode == 0 and status_result.stdout.strip() == "active" - - if is_active: - self.console.print(f"[green]✓ {display_name} is currently ACTIVE[/green]") - self.console.print() - - self.console.print("[cyan]1.[/cyan] Stop Service") - self.console.print("[cyan]2.[/cyan] Restart Service") - self.console.print("[cyan]3.[/cyan] View Recent Logs") - self.console.print("[cyan]4.[/cyan] View Service Status Details") - - else: - self.console.print(f"[red]✗ {display_name} is currently STOPPED[/red]") - self.console.print() - - self.console.print("[cyan]1.[/cyan] Start Service") - self.console.print("[cyan]2.[/cyan] View Recent Logs") - self.console.print("[cyan]3.[/cyan] View Service Status Details") - - self.console.print() - self.console.print("[cyan]b.[/cyan] Back to Service Management") - self.console.print() - - choice = Prompt.ask("Select action", default="b") - - if choice == 'b': - return - elif choice == '1': - if is_active: - self._control_service(service_name, "stop", display_name) - else: - self._control_service(service_name, "start", display_name) - elif choice == '2': - if is_active: - self._control_service(service_name, "restart", display_name) - else: - self._show_service_logs(service_name, display_name) - elif choice == '3': - if is_active: - self._show_service_logs(service_name, display_name) - else: - self._show_service_details(service_name, display_name) - elif choice == '4' and is_active: - self._show_service_details(service_name, display_name) - - except Exception as e: - self.console.print(f"[red]Error checking service status: {e}[/red]") - self._pause() - - def _manage_opencanary_container(self) -> None: - """Management interface for the OpenCanary Docker container.""" - container_name = "azazel_opencanary" - display_name = "OpenCanary Honeypot (Docker)" - - self.console.clear() - title = Text(f"{display_name} Management", style="bold") - self.console.print(title) - self.console.print(Text("─" * len(f"{display_name} Management"), style="dim")) - - try: - is_running = self._is_container_running(container_name) - - if is_running: - self.console.print(f"[green]✓ {display_name} is currently ACTIVE[/green]") - self.console.print() - self.console.print("[cyan]1.[/cyan] Stop Container") - self.console.print("[cyan]2.[/cyan] Restart Container") - self.console.print("[cyan]3.[/cyan] View Recent Logs") - self.console.print("[cyan]4.[/cyan] View Container Details") - else: - self.console.print(f"[red]✗ {display_name} is currently STOPPED[/red]") - self.console.print() - self.console.print("[cyan]1.[/cyan] Start Container") - self.console.print("[cyan]2.[/cyan] View Recent Logs") - self.console.print("[cyan]3.[/cyan] View Container Details") - - self.console.print() - self.console.print("[cyan]b.[/cyan] Back to Service Management") - self.console.print() - - choice = Prompt.ask("Select action", default="b") - - if choice == "b": - return - elif choice == "1": - if is_running: - self._control_container(container_name, "stop", display_name) - else: - self._control_container(container_name, "start", display_name) - elif choice == "2": - if is_running: - self._control_container(container_name, "restart", display_name) - else: - self._show_container_logs(container_name, display_name) - elif choice == "3": - if is_running: - self._show_container_logs(container_name, display_name) - else: - self._show_container_details(container_name, display_name) - elif choice == "4" and is_running: - self._show_container_details(container_name, display_name) - - except Exception as e: - self.console.print(f"[red]Error managing container: {e}[/red]") - self._pause() - - def _control_service(self, service_name: str, action: str, display_name: str) -> None: - """Control service (start/stop/restart).""" - if action in ["stop", "restart"] and not Confirm.ask(f"{action.title()} {display_name}?", default=False): - return - - self.console.print(f"[blue]{action.title()}ing {display_name}...[/blue]") - - try: - result = run_cmd( - ["sudo", "systemctl", action, service_name], - capture_output=True, text=True, timeout=30 - ) - - if result.returncode == 0: - self.console.print(f"[green]✓ {display_name} {action}ed successfully[/green]") - else: - self.console.print(f"[red]✗ Failed to {action} {display_name}: {result.stderr.strip()}[/red]") - - except Exception as e: - self.console.print(f"[red]Error {action}ing service: {e}[/red]") - - self._pause() - - def _show_service_logs(self, service_name: str, display_name: str) -> None: - """Show recent service logs.""" - title = Text(f"{display_name} Recent Logs", style="bold") - self.console.print(title) - self.console.print(Text("─" * len(f"{display_name} Recent Logs"), style="dim")) - - try: - result = run_cmd( - ["journalctl", "-u", service_name, "-n", "50", "--no-pager"], - capture_output=True, text=True, timeout=15 - ) - - if result.returncode == 0: - logs = result.stdout.strip() - if logs: - # Display logs with syntax highlighting for common patterns - for line in logs.split('\n')[-20:]: # Show last 20 lines - if "ERROR" in line or "error" in line: - self.console.print(f"[red]{line}[/red]") - elif "WARN" in line or "warning" in line: - self.console.print(f"[yellow]{line}[/yellow]") - elif "INFO" in line or "info" in line: - self.console.print(f"[cyan]{line}[/cyan]") - else: - self.console.print(f"[dim]{line}[/dim]") - else: - self.console.print("[yellow]No recent logs found.[/yellow]") - else: - self.console.print(f"[red]Failed to retrieve logs: {result.stderr.strip()}[/red]") - - except Exception as e: - self.console.print(f"[red]Error retrieving logs: {e}[/red]") - - self._pause() - - def _show_service_details(self, service_name: str, display_name: str) -> None: - """Show detailed service status.""" - title = Text(f"{display_name} Service Details", style="bold") - self.console.print(title) - self.console.print(Text("─" * len(f"{display_name} Service Details"), style="dim")) - - try: - result = run_cmd( - ["systemctl", "status", service_name, "--no-pager", "-l"], - capture_output=True, text=True, timeout=10 - ) - - if result.returncode == 0 or result.stdout.strip(): - # Parse and display key information - lines = result.stdout.split('\n') - for line in lines[:15]: # Show first 15 lines - if "Active:" in line: - if "active (running)" in line: - self.console.print(f"[green]{line}[/green]") - elif "inactive" in line or "failed" in line: - self.console.print(f"[red]{line}[/red]") - else: - self.console.print(f"[yellow]{line}[/yellow]") - elif "Loaded:" in line: - self.console.print(f"[cyan]{line}[/cyan]") - else: - self.console.print(f"[dim]{line}[/dim]") - else: - self.console.print(f"[red]Failed to get service details: {result.stderr.strip()}[/red]") - - except Exception as e: - self.console.print(f"[red]Error getting service details: {e}[/red]") - - self._pause() - - def _control_container(self, container_name: str, action: str, display_name: str) -> None: - """Control a Docker container.""" - if action in ["stop", "restart"] and not Confirm.ask(f"{action.title()} {display_name}?", default=False): - return - - self.console.print(f"[blue]{action.title()}ing {display_name}...[/blue]") - - try: - result = run_cmd( - ["sudo", "docker", action, container_name], - capture_output=True, - text=True, - timeout=30, - ) - - if result.returncode == 0: - self.console.print(f"[green]✓ {display_name} {action}ed successfully[/green]") - else: - err = result.stderr.strip() or "Unknown error" - self.console.print(f"[red]✗ Failed to {action} {display_name}: {err}[/red]") - - except Exception as e: - self.console.print(f"[red]Error {action}ing container: {e}[/red]") - - self._pause() - - def _show_container_logs(self, container_name: str, display_name: str) -> None: - """Show recent Docker container logs.""" - title = Text(f"{display_name} Recent Logs", style="bold") - self.console.print(title) - self.console.print(Text("─" * len(f"{display_name} Recent Logs"), style="dim")) - - try: - result = run_cmd( - ["docker", "logs", "--tail", "50", container_name], - capture_output=True, - text=True, - timeout=15, - check=False, - ) - - if result.returncode == 0: - logs = result.stdout.strip() or "(no logs)" - self.console.print(logs) - else: - self.console.print(f"[red]Failed to retrieve logs: {result.stderr.strip()}[/red]") - - except Exception as e: - self.console.print(f"[red]Error retrieving logs: {e}[/red]") - - self._pause() - - def _show_container_details(self, container_name: str, display_name: str) -> None: - """Show Docker container status details.""" - title = Text(f"{display_name} Details", style="bold") - self.console.print(title) - self.console.print(Text("─" * len(f"{display_name} Details"), style="dim")) - - try: - result = run_cmd( - [ - "docker", - "ps", - "-a", - "--filter", - f"name=^{container_name}$", - "--format", - "table {{.Names}}\t{{.Status}}\t{{.RunningFor}}\t{{.Image}}", - ], - capture_output=True, - text=True, - timeout=10, - check=False, - ) - - output = result.stdout.strip() - if output: - self.console.print(output) - else: - self.console.print("[yellow]Container not found[/yellow]") - - except Exception as e: - self.console.print(f"[red]Error retrieving container details: {e}[/red]") - - self._pause() - - def _is_container_running(self, container_name: str) -> bool: - """Check whether a Docker container is running.""" - try: - result = run_cmd( - ["docker", "inspect", "-f", "{{.State.Running}}", container_name], - capture_output=True, - text=True, - timeout=5, - check=False, - ) - return result.returncode == 0 and (result.stdout or "").strip().lower() == "true" - except Exception: - return False - - def _restart_all_services(self) -> None: - """Restart all Azazel services.""" - title = Text("Restarting All Azazel Services", style="bold red") - self.console.print(title) - self.console.print(Text("─" * len("Restarting All Azazel Services"), style="dim")) - - services = [ - "azctl-unified.service", - "suricata.service", - "vector.service", - ] - - if not Confirm.ask("This will restart all Azazel services. Continue?", default=False): - return - - for service in services: - self.console.print(f"[blue]Restarting {service}...[/blue]") - try: - result = run_cmd( - ["sudo", "systemctl", "restart", service], - capture_output=True, text=True, timeout=30 - ) - - if result.returncode == 0: - self.console.print(f"[green]✓ {service} restarted successfully[/green]") - else: - self.console.print(f"[red]✗ Failed to restart {service}: {result.stderr.strip()}[/red]") - - except Exception as e: - self.console.print(f"[red]✗ Error restarting {service}: {e}[/red]") - - self.console.print(f"[blue]Restarting azazel_opencanary container...[/blue]") - try: - result = run_cmd( - ["sudo", "docker", "restart", "azazel_opencanary"], - capture_output=True, - text=True, - timeout=30, - check=False, - ) - if result.returncode == 0: - self.console.print("[green]✓ azazel_opencanary restarted[/green]") - else: - err = result.stderr.strip() or "Unknown error" - self.console.print(f"[red]✗ Failed to restart azazel_opencanary: {err}[/red]") - except Exception as e: - self.console.print(f"[red]✗ Error restarting azazel_opencanary: {e}[/red]") - - self.console.print("\n[bold]All services restart attempts completed.[/bold]") - self._pause() - - def _pause(self) -> None: - """Pause for user input.""" - Prompt.ask("\n[dim]Press Enter to continue[/dim]", default="", show_default=False) diff --git a/azctl/menu/system.py b/azctl/menu/system.py deleted file mode 100644 index 9c8cc89..0000000 --- a/azctl/menu/system.py +++ /dev/null @@ -1,328 +0,0 @@ -#!/usr/bin/env python3 -""" -System Information Module - -Provides system monitoring and resource information for the Azazel TUI menu system. -""" - -import subprocess -from azazel_pi.utils.cmd_runner import run as run_cmd -from typing import Optional, Any - -from rich.console import Console -from rich.table import Table -from rich.text import Text -from rich.prompt import Prompt - -from azctl.menu.types import MenuCategory, MenuAction - -try: - from azazel_pi.core.ingest.status_collector import NetworkStatusCollector -except ImportError: - NetworkStatusCollector = None - - -class SystemModule: - """System information and monitoring functionality.""" - - def __init__(self, console: Console, status_collector: Optional[Any] = None): - self.console = console - self.status_collector = status_collector - - def get_category(self) -> MenuCategory: - """Get the system information menu category.""" - return MenuCategory( - title="System Information", - description="View system status and resources", - actions=[ - MenuAction("System Resource Monitor", "Display CPU, memory, and disk usage", self._system_resources), - MenuAction("Network Statistics", "Show detailed network interface statistics", self._network_stats), - MenuAction("Temperature Monitor", "Display system temperature readings", self._temperature_monitor), - MenuAction("Process List", "Show running processes", self._process_list), - ] - ) - - def _system_resources(self) -> None: - """Display system resource information.""" - title = Text("System Resource Monitor", style="bold") - self.console.print(title) - self.console.print(Text("─" * len("System Resource Monitor"), style="dim")) - - try: - if self.status_collector: - status = self.status_collector.collect() - else: - status = {} - - # CPU Information - self.console.print("[bold cyan]CPU Information:[/bold cyan]") - cpu_table = Table(show_header=False, box=None) - cpu_table.add_column("Metric", style="cyan", min_width=15) - cpu_table.add_column("Value", style="white") - - # Get CPU info - try: - with open('/proc/cpuinfo', 'r') as f: - cpuinfo = f.read() - - model_name = "Unknown" - cpu_cores = 0 - for line in cpuinfo.split('\n'): - if line.startswith('model name'): - model_name = line.split(':', 1)[1].strip() - elif line.startswith('processor'): - cpu_cores += 1 - - cpu_table.add_row("Model", model_name) - cpu_table.add_row("Cores", str(cpu_cores)) - - except Exception: - cpu_table.add_row("CPU Info", "Unable to retrieve") - - # Load average - try: - with open('/proc/loadavg', 'r') as f: - load = f.read().strip().split()[:3] - cpu_table.add_row("Load Average", f"{load[0]} {load[1]} {load[2]}") - except Exception: - cpu_table.add_row("Load Average", "Unknown") - - self.console.print(cpu_table) - self.console.print() - - # Memory Information - self.console.print("[bold cyan]Memory Information:[/bold cyan]") - mem_table = Table(show_header=False, box=None) - mem_table.add_column("Type", style="cyan", min_width=15) - mem_table.add_column("Total", justify="right", style="white") - mem_table.add_column("Used", justify="right", style="yellow") - mem_table.add_column("Free", justify="right", style="green") - mem_table.add_column("Usage", justify="right", style="white") - - try: - with open('/proc/meminfo', 'r') as f: - meminfo = {} - for line in f: - key, value = line.split(':', 1) - meminfo[key.strip()] = int(value.strip().split()[0]) * 1024 # Convert to bytes - - total_mem = meminfo.get('MemTotal', 0) - free_mem = meminfo.get('MemFree', 0) + meminfo.get('Buffers', 0) + meminfo.get('Cached', 0) - used_mem = total_mem - free_mem - mem_usage = (used_mem / total_mem * 100) if total_mem > 0 else 0 - - mem_table.add_row( - "RAM", - self._format_bytes(total_mem), - self._format_bytes(used_mem), - self._format_bytes(free_mem), - f"{mem_usage:.1f}%" - ) - - except Exception: - mem_table.add_row("RAM", "Unknown", "Unknown", "Unknown", "Unknown") - - self.console.print(mem_table) - self.console.print() - - # Disk Information - self.console.print("[bold cyan]Disk Usage:[/bold cyan]") - try: - 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: - disk_info = lines[1].split() - if len(disk_info) >= 5: - disk_table = Table(show_header=False, box=None) - disk_table.add_column("Filesystem", style="cyan", min_width=15) - disk_table.add_column("Size", justify="right", style="white") - disk_table.add_column("Used", justify="right", style="yellow") - disk_table.add_column("Available", justify="right", style="green") - disk_table.add_column("Usage", justify="right", style="white") - - disk_table.add_row( - "Root (/)", - disk_info[1], - disk_info[2], - disk_info[3], - disk_info[4] - ) - - self.console.print(disk_table) - else: - self.console.print("[yellow]Unable to parse disk usage[/yellow]") - else: - self.console.print("[yellow]No disk usage data available[/yellow]") - else: - self.console.print("[red]Failed to get disk usage[/red]") - except Exception as e: - self.console.print(f"[red]Error getting disk usage: {e}[/red]") - - except Exception as e: - self.console.print(f"[red]Error collecting system resources: {e}[/red]") - - self._pause() - - def _network_stats(self) -> None: - """Show detailed network statistics.""" - title = Text("Network Interface Statistics", style="bold") - self.console.print(title) - self.console.print(Text("─" * len("Network Interface Statistics"), style="dim")) - - try: - if self.status_collector: - status = self.status_collector.collect() - interfaces = status.get('interfaces', {}) - - if interfaces: - table = Table() - table.add_column("Interface", style="cyan") - table.add_column("RX Bytes", justify="right", style="green") - table.add_column("TX Bytes", justify="right", style="blue") - table.add_column("RX Packets", justify="right", style="green") - table.add_column("TX Packets", justify="right", style="blue") - table.add_column("Errors", justify="right", style="red") - table.add_column("Drops", justify="right", style="yellow") - - for iface, stats in interfaces.items(): - table.add_row( - iface, - self._format_bytes(stats.get('rx_bytes', 0)), - self._format_bytes(stats.get('tx_bytes', 0)), - f"{stats.get('rx_packets', 0):,}", - f"{stats.get('tx_packets', 0):,}", - str(stats.get('rx_errors', 0) + stats.get('tx_errors', 0)), - str(stats.get('rx_dropped', 0) + stats.get('tx_dropped', 0)) - ) - - self.console.print(table) - else: - self.console.print("[yellow]No network interface data available[/yellow]") - else: - self.console.print("[yellow]Network status collector not available[/yellow]") - - except Exception as e: - self.console.print(f"[red]Error collecting network statistics: {e}[/red]") - - self._pause() - - def _temperature_monitor(self) -> None: - """Display system temperature readings.""" - title = Text("System Temperature Monitor", style="bold") - self.console.print(title) - self.console.print(Text("─" * len("System Temperature Monitor"), style="dim")) - - try: - # Try to read Raspberry Pi temperature - temp_paths = [ - '/sys/class/thermal/thermal_zone0/temp', - '/sys/devices/virtual/thermal/thermal_zone0/temp' - ] - - temp_found = False - for temp_path in temp_paths: - try: - with open(temp_path, 'r') as f: - temp_raw = int(f.read().strip()) - temp_c = temp_raw / 1000.0 - temp_f = (temp_c * 9/5) + 32 - - # Color code temperature - if temp_c < 50: - temp_color = "green" - elif temp_c < 70: - temp_color = "yellow" - else: - temp_color = "red" - - temp_table = Table(show_header=False, box=None) - temp_table.add_column("Sensor", style="cyan", min_width=15) - temp_table.add_column("Temperature", style="white") - temp_table.add_column("Status", style="white") - - status = "Normal" if temp_c < 70 else "High" if temp_c < 80 else "Critical" - status_color = "green" if temp_c < 70 else "yellow" if temp_c < 80 else "red" - - temp_table.add_row( - "CPU", - f"[{temp_color}]{temp_c:.1f}°C ({temp_f:.1f}°F)[/{temp_color}]", - f"[{status_color}]{status}[/{status_color}]" - ) - - self.console.print(temp_table) - temp_found = True - break - - except Exception: - continue - - if not temp_found: - self.console.print("[yellow]Temperature sensors not available[/yellow]") - - except Exception as e: - self.console.print(f"[red]Error reading temperature: {e}[/red]") - - self._pause() - - def _process_list(self) -> None: - """Show running processes.""" - title = Text("Running Processes", style="bold") - self.console.print(title) - self.console.print(Text("─" * len("Running Processes"), style="dim")) - - try: - result = run_cmd( - ['ps', 'aux', '--sort=-pcpu'], - capture_output=True, text=True, timeout=10 - ) - - if result.returncode == 0: - lines = result.stdout.strip().split('\n') - - if len(lines) > 1: - # Parse header - header = lines[0].split() - - # Create table - table = Table() - table.add_column("PID", style="cyan", width=8) - table.add_column("User", style="white", width=10) - table.add_column("CPU%", justify="right", style="yellow", width=6) - table.add_column("MEM%", justify="right", style="green", width=6) - table.add_column("Command", style="white") - - # Show top 15 processes - for line in lines[1:16]: - parts = line.split(None, 10) - if len(parts) >= 11: - table.add_row( - parts[1], # PID - parts[0][:10], # User (truncated) - parts[2], # CPU% - parts[3], # MEM% - parts[10][:50] + "..." if len(parts[10]) > 50 else parts[10] # Command (truncated) - ) - - self.console.print(table) - else: - self.console.print("[yellow]No process information available[/yellow]") - else: - self.console.print(f"[red]Failed to get process list: {result.stderr.strip()}[/red]") - - except Exception as e: - self.console.print(f"[red]Error getting process list: {e}[/red]") - - self._pause() - - def _format_bytes(self, bytes_value: int) -> str: - """Format bytes value with appropriate units.""" - for unit in ['B', 'KB', 'MB', 'GB', 'TB']: - if bytes_value < 1024.0: - return f"{bytes_value:.1f} {unit}" - bytes_value /= 1024.0 - return f"{bytes_value:.1f} PB" - - def _pause(self) -> None: - """Pause for user input.""" - Prompt.ask("\n[dim]Press Enter to continue[/dim]", default="", show_default=False) \ No newline at end of file diff --git a/azctl/menu/types.py b/azctl/menu/types.py deleted file mode 100644 index 92a88ef..0000000 --- a/azctl/menu/types.py +++ /dev/null @@ -1,28 +0,0 @@ -#!/usr/bin/env python3 -""" -Menu Types and Data Classes - -Defines the core data structures used by the Azazel TUI menu system. -""" - -from dataclasses import dataclass -from typing import Callable, Optional - - -@dataclass -class MenuAction: - """Represents a menu action item.""" - title: str - description: str - action: Callable - requires_root: bool = False - danger_level: int = 0 # 0=safe, 1=caution, 2=dangerous - dangerous: bool = False # For backward compatibility - - -@dataclass -class MenuCategory: - """Represents a menu category containing multiple actions.""" - title: str - description: str - actions: list[MenuAction] \ No newline at end of file diff --git a/azctl/menu/wifi.py b/azctl/menu/wifi.py deleted file mode 100644 index f5a4217..0000000 --- a/azctl/menu/wifi.py +++ /dev/null @@ -1,660 +0,0 @@ -#!/usr/bin/env python3 -""" -Wi-Fi Management Module - -Provides Wi-Fi network scanning, connection, and management functionality -for the Azazel TUI menu system. -""" - -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 - -from rich.console import Console -from rich.progress import Progress -from rich.prompt import Prompt, Confirm -from rich.table import Table -from rich.text import Text - - -class WiFiManager: - """Wi-Fi connection and network management.""" - - def __init__(self, console: Console): - self.console = console - - def manage_wifi(self, wan_if: Optional[str] = None) -> None: - """Wi-Fi connection manager entry point. - - If wan_if is not provided, resolve it using the WANManager active - interface accessor so callers get the WANManager-driven default. - """ - self.console.clear() - self._print_section_header("Wi-Fi Connection Manager") - - # Resolve default WAN interface from WANManager if not provided - if wan_if is None: - try: - from azazel_pi.utils.wan_state import get_active_wan_interface - wan_if = get_active_wan_interface() - except Exception: - wan_if = "wlan1" - - # Check if required tools are available - if not self._check_wifi_tools(): - return - - # Get available interfaces - interfaces = self._get_wifi_interfaces() - if not interfaces: - self.console.print("[red]No Wi-Fi interfaces found.[/red]") - return - - # Select interface if multiple available - if len(interfaces) > 1: - interface = self._select_wifi_interface(interfaces) - if not interface: - return - else: - interface = interfaces[0] - - self.console.print(f"[green]Using interface: {interface}[/green]") - self.console.print() - - while True: - choice = self._wifi_main_menu(interface) - if choice == 'b': - break - elif choice == '1': - self._wifi_scan_and_connect(interface) - elif choice == '2': - self._wifi_show_current_connection(interface) - elif choice == '3': - self._wifi_disconnect(interface) - elif choice == '4': - self._wifi_saved_networks(interface) - - def _print_section_header(self, title: str, style: str = "bold") -> None: - """Print a consistent section header with underline.""" - title_text = Text(title, style=style) - self.console.print(title_text) - self.console.print(Text("─" * len(title), style="dim")) - - def _pause(self) -> None: - """Pause for user input.""" - Prompt.ask("\n[dim]Press Enter to continue[/dim]", default="", show_default=False) - - def _check_wifi_tools(self) -> bool: - """Check if required Wi-Fi tools are available.""" - required_tools = ['iw', 'wpa_cli', 'dhcpcd'] - missing_tools = [] - - for tool in required_tools: - result = run_cmd(['which', tool], capture_output=True, text=True) - if result.returncode != 0: - missing_tools.append(tool) - - if missing_tools: - self.console.print(f"[red]Missing required tools: {', '.join(missing_tools)}[/red]") - self.console.print("[yellow]Please install missing tools and try again.[/yellow]") - self._pause() - return False - return True - - def _get_wifi_interfaces(self) -> List[str]: - """Get list of available Wi-Fi interfaces.""" - try: - result = run_cmd(['iw', 'dev'], capture_output=True, text=True, timeout=5) - if result.returncode != 0: - return [] - - interfaces = [] - for line in result.stdout.splitlines(): - line = line.strip() - if line.startswith('Interface '): - interface = line.split()[1] - interfaces.append(interface) - - return interfaces - except Exception: - return [] - - def _select_wifi_interface(self, interfaces: List[str]) -> Optional[str]: - """Let user select Wi-Fi interface.""" - self.console.print("[bold]Available Wi-Fi Interfaces:[/bold]") - for i, iface in enumerate(interfaces, 1): - self.console.print(f"[cyan]{i}.[/cyan] {iface}") - - choice = Prompt.ask("Select interface", choices=[str(i) for i in range(1, len(interfaces) + 1)], default="1") - return interfaces[int(choice) - 1] - - def _wifi_main_menu(self, interface: str) -> str: - """Display Wi-Fi management main menu.""" - title_text = f"Wi-Fi Management - {interface}" - title = Text(title_text, style="bold") - self.console.print(title) - self.console.print(Text("─" * len(title_text), style="dim")) - - self.console.print("[cyan]1.[/cyan] Scan and Connect to Network") - self.console.print("[cyan]2.[/cyan] Show Current Connection") - self.console.print("[cyan]3.[/cyan] Disconnect") - self.console.print("[cyan]4.[/cyan] Manage Saved Networks") - self.console.print() - self.console.print("[cyan]b.[/cyan] Back to Network Information menu") - self.console.print() - - return Prompt.ask("Select option", default="b") - - def _wifi_scan_and_connect(self, interface: str) -> None: - """Scan for networks and connect to selected one.""" - self.console.print(f"[blue]Scanning for Wi-Fi networks on {interface}...[/blue]") - - try: - with Progress() as progress: - scan_task = progress.add_task("Scanning networks...", total=100) - - # Perform scan - result = run_cmd( - ['sudo', 'iw', 'dev', interface, 'scan'], - capture_output=True, text=True, timeout=15 - ) - progress.update(scan_task, completed=100) - - if result.returncode != 0: - self.console.print(f"[red]Scan failed: {result.stderr.strip()}[/red]") - self._pause() - return - - # Parse scan results - networks = self._parse_wifi_scan(result.stdout) - if not networks: - self.console.print("[yellow]No networks found.[/yellow]") - self._pause() - return - - # Display networks and let user select - selected_network = self._select_wifi_network(networks) - if not selected_network: - return - - # Connect to selected network - self._connect_to_wifi_network(interface, selected_network) - - except subprocess.TimeoutExpired: - self.console.print("[red]Scan timeout. Try again.[/red]") - self._pause() - except Exception as e: - self.console.print(f"[red]Scan error: {e}[/red]") - self._pause() - - def _parse_wifi_scan(self, scan_output: str) -> List[Dict[str, Any]]: - """Parse iw scan output into network list.""" - networks = [] - current_network = None - rsn_block = False - wpa_block = False - - for line in scan_output.splitlines(): - line = line.rstrip() - - # New BSS entry - bss_match = re.match(r"^BSS\s+([0-9a-f:]{17})", line) - if bss_match: - if current_network: - networks.append(current_network) - current_network = { - "bssid": bss_match.group(1), - "ssid": "", - "freq": None, - "channel": None, - "signal": None, - "security": "OPEN", - "rsn": False, - "wpa": False, - "wpa3": False - } - rsn_block = False - wpa_block = False - continue - - if not current_network: - continue - - # Parse network properties - if line.strip().startswith("SSID:"): - current_network["ssid"] = line.split("SSID:", 1)[1].strip() - elif line.strip().startswith("freq:"): - try: - freq = int(line.split("freq:", 1)[1].strip()) - current_network["freq"] = freq - # Calculate channel from frequency - if 2412 <= freq <= 2472: - current_network["channel"] = (freq - 2407) // 5 - elif freq == 2484: - current_network["channel"] = 14 - elif 5000 <= freq <= 5900: - current_network["channel"] = (freq - 5000) // 5 - except Exception: - pass - elif line.strip().startswith("signal:"): - try: - signal_str = line.split("signal:", 1)[1].split("dBm")[0].strip() - current_network["signal"] = float(signal_str) - except Exception: - pass - elif line.strip().startswith("RSN:"): - current_network["rsn"] = True - current_network["security"] = "WPA2/WPA3" - rsn_block = True - wpa_block = False - elif line.strip().startswith("WPA:"): - current_network["wpa"] = True - if current_network["security"] == "OPEN": - current_network["security"] = "WPA/WPA2" - wpa_block = True - rsn_block = False - elif (rsn_block or wpa_block) and "SAE" in line: - current_network["wpa3"] = True - current_network["security"] = "WPA3/WPA2" - - # Add the last network - if current_network: - networks.append(current_network) - - # Remove duplicates and sort by signal strength - unique_networks = {} - for network in networks: - ssid = network["ssid"] or f"" - if ssid not in unique_networks or ( - network["signal"] is not None and - (unique_networks[ssid]["signal"] is None or - network["signal"] > unique_networks[ssid]["signal"]) - ): - unique_networks[ssid] = network - - result = list(unique_networks.values()) - result.sort(key=lambda x: x["signal"] if x["signal"] is not None else -999, reverse=True) - - return result - - def _select_wifi_network(self, networks: List[Dict[str, Any]]) -> Optional[Dict[str, Any]]: - """Display networks and let user select one.""" - self.console.clear() - self._print_section_header("Available Wi-Fi Networks") - - # Create table - table = Table() - table.add_column("#", style="cyan", width=3) - table.add_column("SSID", style="white", min_width=20) - table.add_column("Security", style="yellow", width=15) - table.add_column("Signal", justify="right", width=8) - table.add_column("Channel", justify="center", width=7) - table.add_column("BSSID", style="dim", width=17) - - for i, network in enumerate(networks[:20], 1): # Limit to 20 networks - ssid = network["ssid"] or f"" - signal = f"{int(network['signal'])} dBm" if network["signal"] is not None else "N/A" - channel = str(network["channel"]) if network["channel"] else "N/A" - - # Color code signal strength - if network["signal"] is not None: - if network["signal"] > -50: - signal_style = "green" - elif network["signal"] > -70: - signal_style = "yellow" - else: - signal_style = "red" - else: - signal_style = "dim" - - table.add_row( - str(i), - ssid[:32], - network["security"], - Text(signal, style=signal_style), - channel, - network["bssid"] - ) - - self.console.print(table) - self.console.print() - - if len(networks) > 20: - self.console.print(f"[dim]Showing top 20 of {len(networks)} networks[/dim]") - - self.console.print("[cyan]r.[/cyan] Refresh scan") - self.console.print("[cyan]b.[/cyan] Back") - self.console.print() - - choice = Prompt.ask("Select network (number) or action", default="b") - - if choice == 'r': - return self._wifi_scan_and_connect(networks[0]["bssid"].split(':')[0]) # Trigger rescan - elif choice == 'b': - return None - - try: - network_idx = int(choice) - 1 - if 0 <= network_idx < min(len(networks), 20): - return networks[network_idx] - except ValueError: - pass - - self.console.print("[red]Invalid selection.[/red]") - self._pause() - return None - - def _connect_to_wifi_network(self, interface: str, network: Dict[str, Any]) -> None: - """Connect to selected Wi-Fi network.""" - ssid = network["ssid"] or f"" - is_open = network["security"] == "OPEN" - - self.console.print(f"[blue]Connecting to: {ssid}[/blue]") - self.console.print(f"Security: {network['security']}") - - if not is_open: - # Need passphrase - passphrase = Prompt.ask(f"Enter passphrase for '{ssid}'", password=True) - if not passphrase: - self.console.print("[yellow]Connection cancelled.[/yellow]") - self._pause() - return - - try: - # Get current connection for rollback - current_id, current_ssid, _ = self._get_current_wifi_connection(interface) - - # Check if network already exists - network_id = self._find_wifi_network_id(ssid, interface) - created_new = False - - if network_id is None: - # Create new network - result = run_cmd( - ['sudo', 'wpa_cli', '-i', interface, 'add_network'], - capture_output=True, text=True, timeout=10 - ) - if result.returncode != 0 or not result.stdout.strip().isdigit(): - self.console.print("[red]Failed to add network configuration.[/red]") - self._pause() - return - - network_id = result.stdout.strip() - created_new = True - - # Configure network - run_cmd(['sudo', 'wpa_cli', '-i', interface, 'set_network', network_id, 'ssid', f'"{ssid}"'], - capture_output=True, timeout=10) - - if is_open: - run_cmd(['sudo', 'wpa_cli', '-i', interface, 'set_network', network_id, 'key_mgmt', 'NONE'], - capture_output=True, timeout=10) - else: - 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) - - run_cmd(['sudo', 'wpa_cli', '-i', interface, 'enable_network', network_id], - capture_output=True, timeout=10) - run_cmd(['sudo', 'wpa_cli', '-i', interface, 'select_network', network_id], - capture_output=True, timeout=10) - - # Wait for connection with timeout - connected = False - for i in range(20): # 10 seconds - progress.update(connect_task, completed=(i + 1) * 5) - - result = run_cmd(['wpa_cli', '-i', interface, 'status'], - capture_output=True, text=True, timeout=5) - - if result.returncode == 0: - status = result.stdout - if "wpa_state=COMPLETED" in status and f"ssid={ssid}" in status: - connected = True - break - - time.sleep(0.5) - - progress.update(connect_task, completed=100) - - if connected: - # Get IP address - run_cmd(['sudo', 'dhcpcd', '-n', interface], capture_output=True, timeout=10) - # Save configuration - run_cmd(['sudo', 'wpa_cli', '-i', interface, 'save_config'], capture_output=True, timeout=10) - - self.console.print(f"[green]✓ Successfully connected to {ssid}[/green]") - - # Show connection info - self._wifi_show_current_connection(interface) - - else: - self.console.print("[red]✗ Connection failed[/red]") - - # Rollback - if created_new and network_id: - run_cmd(['sudo', 'wpa_cli', '-i', interface, 'remove_network', network_id], capture_output=True, timeout=10) - - if current_id: - 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]") - - self._pause() - - def _find_wifi_network_id(self, ssid: str, interface: str) -> Optional[str]: - """Find network ID for given SSID.""" - try: - result = run_cmd(['wpa_cli', '-i', interface, 'list_networks'], - capture_output=True, text=True, timeout=5) - if result.returncode != 0: - return None - - for line in result.stdout.splitlines()[1:]: # Skip header - parts = line.split('\t') - if len(parts) >= 2 and parts[1] == ssid: - return parts[0] - - return None - except Exception: - return None - - def _get_current_wifi_connection(self, interface: str) -> Tuple[Optional[str], Optional[str], Optional[str]]: - """Get current Wi-Fi connection info.""" - try: - result = run_cmd(['wpa_cli', '-i', interface, 'status'], - capture_output=True, text=True, timeout=5) - if result.returncode != 0: - return None, None, None - - current_id = None - current_ssid = None - current_bssid = None - - for line in result.stdout.splitlines(): - if line.startswith("id="): - current_id = line.split("=", 1)[1] - elif line.startswith("ssid="): - current_ssid = line.split("=", 1)[1] - elif line.startswith("bssid="): - current_bssid = line.split("=", 1)[1] - - return current_id, current_ssid, current_bssid - except Exception: - return None, None, None - - def _wifi_show_current_connection(self, interface: str) -> None: - """Show current Wi-Fi connection details.""" - self._print_section_header(f"Current Wi-Fi Connection - {interface}") - - try: - # Get wpa_cli status - result = run_cmd(['wpa_cli', '-i', interface, 'status'], - capture_output=True, text=True, timeout=5) - - if result.returncode != 0: - self.console.print("[red]Failed to get connection status.[/red]") - self._pause() - return - - # Parse status - status_info = {} - for line in result.stdout.splitlines(): - if '=' in line: - key, value = line.split('=', 1) - status_info[key] = value - - # Get IP information - ip_result = run_cmd(['ip', '-4', 'addr', 'show', interface], - capture_output=True, text=True, timeout=5) - - ip_addr = "Not assigned" - if ip_result.returncode == 0: - for line in ip_result.stdout.splitlines(): - if 'inet ' in line and 'scope global' in line: - ip_addr = line.strip().split()[1] - break - - # Display connection info - from rich.panel import Panel - table = Table.grid(padding=(0, 2)) - table.add_column("Property", style="cyan", min_width=15) - table.add_column("Value", style="white") - - wpa_state = status_info.get('wpa_state', 'UNKNOWN') - if wpa_state == 'COMPLETED': - state_display = Text("Connected", style="green") - elif wpa_state in ['ASSOCIATING', 'ASSOCIATED', 'AUTHENTICATING']: - state_display = Text("Connecting", style="yellow") - else: - state_display = Text("Disconnected", style="red") - - table.add_row("Status", state_display) - table.add_row("SSID", status_info.get('ssid', 'N/A')) - table.add_row("BSSID", status_info.get('bssid', 'N/A')) - table.add_row("IP Address", ip_addr) - table.add_row("Frequency", f"{status_info.get('freq', 'N/A')} MHz") - - if 'key_mgmt' in status_info: - table.add_row("Security", status_info['key_mgmt']) - - self.console.print(Panel(table, title="Connection Details", border_style="green")) - - except Exception as e: - self.console.print(f"[red]Error getting connection info: {e}[/red]") - - self._pause() - - def _wifi_disconnect(self, interface: str) -> None: - """Disconnect from current Wi-Fi network.""" - if not Confirm.ask(f"Disconnect from current network on {interface}?", default=False): - return - - try: - result = run_cmd(['sudo', 'wpa_cli', '-i', interface, 'disconnect'], - capture_output=True, text=True, timeout=10) - - if result.returncode == 0: - self.console.print("[green]✓ Disconnected successfully[/green]") - else: - self.console.print(f"[red]✗ Disconnect failed: {result.stderr.strip()}[/red]") - - except Exception as e: - self.console.print(f"[red]Disconnect error: {e}[/red]") - - self._pause() - - def _wifi_saved_networks(self, interface: str) -> None: - """Show and manage saved Wi-Fi networks.""" - self._print_section_header(f"Saved Wi-Fi Networks - {interface}") - - try: - result = run_cmd(['wpa_cli', '-i', interface, 'list_networks'], - capture_output=True, text=True, timeout=5) - - if result.returncode != 0: - self.console.print("[red]Failed to get saved networks.[/red]") - self._pause() - return - - lines = result.stdout.splitlines() - if len(lines) <= 1: - self.console.print("[yellow]No saved networks found.[/yellow]") - self._pause() - return - - # Parse and display networks - table = Table() - table.add_column("ID", style="cyan", width=5) - table.add_column("SSID", style="white", min_width=20) - table.add_column("BSSID", style="dim", width=17) - table.add_column("Flags", style="yellow", width=15) - - networks = [] - for line in lines[1:]: # Skip header - parts = line.split('\t') - if len(parts) >= 2: - network_id = parts[0] - ssid = parts[1] - bssid = parts[2] if len(parts) > 2 else "any" - flags = parts[3] if len(parts) > 3 else "" - - networks.append({ - 'id': network_id, - 'ssid': ssid, - 'bssid': bssid, - 'flags': flags - }) - - # Color code flags - flag_display = flags - if 'CURRENT' in flags: - flag_display = Text(flags, style="green") - elif 'DISABLED' in flags: - flag_display = Text(flags, style="red") - else: - flag_display = Text(flags, style="yellow") - - table.add_row(network_id, ssid, bssid, flag_display) - - self.console.print(table) - - if networks: - self.console.print("\n[cyan]Actions:[/cyan]") - self.console.print("Enter network ID to enable/disable") - self.console.print("'d' + ID to delete (e.g., 'd2')") - - choice = Prompt.ask("Action or 'b' to go back", default="b") - - if choice != 'b': - if choice.startswith('d') and len(choice) > 1: - # Delete network - net_id = choice[1:] - if Confirm.ask(f"Delete network ID {net_id}?", default=False): - result = run_cmd(['sudo', 'wpa_cli', '-i', interface, 'remove_network', net_id], capture_output=True, text=True, timeout=10) - if result.returncode == 0: - 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 = 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]") - else: - self.console.print(f"[red]✗ Failed to enable network {net_id}[/red]") - - except Exception as e: - self.console.print(f"[red]Error managing saved networks: {e}[/red]") - - self._pause() \ No newline at end of file diff --git a/azctl/tui_zero.py b/azctl/tui_zero.py new file mode 100644 index 0000000..8cef13b --- /dev/null +++ b/azctl/tui_zero.py @@ -0,0 +1,333 @@ +"""Azazel-Zero style unified Textual TUI for Azazel-Pi. + +Ported from Azazel-Zero unified TUI and adapted to Azazel-Pi control paths. +""" +from __future__ import annotations + +import json +import os +import subprocess +import tempfile +import time +from dataclasses import dataclass +from pathlib import Path +from typing import Any, Dict, List, Optional, Tuple + + +@dataclass +class Snapshot: + now_time: str + ssid: str + bssid: str + channel: str + signal_dbm: str + gateway_ip: str + up_if: str + up_ip: str + user_state: str + recommendation: str + reasons: List[str] + next_action_hint: str + quic: str + doh: str + dns_mode: str + degrade: Dict[str, object] + probe: Dict[str, object] + evidence: List[str] + internal: Dict[str, object] + connection: Dict[str, object] + monitoring: Dict[str, str] + source: str = "SNAPSHOT" + snapshot_epoch: float = 0.0 + threat_level: int = 0 + risk_score: int = 0 + cpu_percent: float = 0.0 + mem_percent: int = 0 + mem_used_mb: int = 0 + mem_total_mb: int = 0 + temp_c: float = 0.0 + download_mbps: float = 0.0 + upload_mbps: float = 0.0 + channel_congestion: str = "unknown" + channel_ap_count: int = 0 + state_timeline: str = "-" + dns_stats: Dict[str, int] = None + top_blocked: List[Tuple[str, int]] = None + + def __post_init__(self) -> None: + if self.dns_stats is None: + self.dns_stats = {"ok": 0, "anomaly": 0, "blocked": 0} + if self.top_blocked is None: + self.top_blocked = [] + + +def _safe_int(value: Any, default: int = 0) -> int: + try: + return int(float(value)) + except Exception: + return default + + +def _parse_signal_dbm(raw: Any) -> str: + try: + if raw is None: + return "-" + text = str(raw).strip().lower().replace("dbm", "").strip() + if not text or text == "-": + return "-" + return str(int(float(text))) + except Exception: + return "-" + + +def _mode_to_state(mode: str) -> Tuple[str, int]: + m = str(mode or "").strip().lower() + if m in {"lockdown", "user_lockdown"}: + return ("CONTAINED", 5) + if m in {"shield", "user_shield"}: + return ("LIMITED", 3) + if m in {"portal", "user_portal"}: + return ("SAFE", 1) + return ("CHECKING", 0) + + +def _service_active(name: str) -> bool: + try: + res = subprocess.run(["systemctl", "is-active", name], capture_output=True, text=True, timeout=1.5) + return res.returncode == 0 and res.stdout.strip() == "active" + except Exception: + return False + + +def _collect_monitoring_state() -> Dict[str, str]: + return { + "suricata": "ON" if _service_active("suricata.service") else "OFF", + "opencanary": "ON" if _service_active("opencanary.service") else "OFF", + "ntfy": "ON" if _service_active("ntfy.service") else "OFF", + } + + +def _run_status_json(lan_if: str, wan_if: str) -> Optional[Dict[str, Any]]: + try: + cmd = [ + "python3", + "-m", + "azctl.cli", + "status", + "--json", + "--lan-if", + lan_if, + ] + if wan_if: + cmd.extend(["--wan-if", wan_if]) + env = dict(os.environ) + env["AZCTL_TUI_STATUS_CALL"] = "1" + result = subprocess.run(cmd, capture_output=True, text=True, timeout=8, env=env) + if result.returncode != 0: + return None + payload = json.loads(result.stdout) + return payload if isinstance(payload, dict) else None + except Exception: + return None + + +def _snapshot_from_status(lan_if: str, wan_if: str) -> Optional[Snapshot]: + payload = _run_status_json(lan_if, wan_if) + if payload is None: + return None + + wlan = payload.get("wlan1") if isinstance(payload.get("wlan1"), dict) else {} + mode = str(payload.get("defensive_mode") or "portal") + state, level = _mode_to_state(mode) + connected = bool(wlan.get("connected")) + + recommendation = { + "CONTAINED": "Containment active. Isolate and investigate.", + "LIMITED": "Threat elevated. Keep monitoring and verify upstream.", + "SAFE": "Nominal operation.", + "CHECKING": "Collecting status.", + }.get(state, "Collecting status.") + + reasons = [f"defensive_mode={mode}"] + evidence = [ + f"mode={mode}", + f"wan_connected={connected}", + f"wan_ssid={wlan.get('ssid') or '-'}", + ] + + return Snapshot( + now_time=time.strftime("%H:%M:%S"), + ssid=str(wlan.get("ssid") or "-"), + bssid=str(wlan.get("bssid") or "-"), + channel="-", + signal_dbm=_parse_signal_dbm(wlan.get("signal_dbm")), + gateway_ip=str(wlan.get("gateway") or "-"), + up_if=str(wan_if or "-"), + up_ip=str(wlan.get("ip4") or "-"), + user_state=state, + recommendation=recommendation, + reasons=reasons, + next_action_hint="Use menu actions to switch mode", + quic="unknown", + doh="unknown", + dns_mode="azazel", + degrade={"on": state in {"LIMITED", "CONTAINED"}, "rtt_ms": 0, "rate_mbps": 0}, + probe={"tls_ok": 0, "tls_total": 0, "blocked": 0}, + evidence=evidence, + internal={"state_name": state, "suspicion": level * 20, "decay": "-"}, + connection={ + "wifi_state": "CONNECTED" if connected else "DISCONNECTED", + "usb_nat": "UNKNOWN", + "internet_check": "OK" if connected else "UNKNOWN", + "captive_portal": "NO", + "captive_portal_reason": "-", + }, + monitoring=_collect_monitoring_state(), + source="AZCTL_STATUS", + snapshot_epoch=time.time(), + threat_level=level, + risk_score=min(100, level * 20), + ) + + +def load_snapshot(lan_if: str, wan_if: str) -> Snapshot: + runtime_path = Path("runtime/ui_snapshot.json") + if runtime_path.exists(): + try: + data = json.loads(runtime_path.read_text(encoding="utf-8")) + if isinstance(data, dict): + internal = data.get("internal") if isinstance(data.get("internal"), dict) else {} + state_name = str(internal.get("state_name") or "NORMAL").upper() + state_map = { + "NORMAL": "SAFE", + "PROBE": "CHECKING", + "DEGRADED": "LIMITED", + "CONTAIN": "CONTAINED", + "DECEPTION": "DECEPTION", + } + state = state_map.get(state_name, "CHECKING") + connection = data.get("connection") if isinstance(data.get("connection"), dict) else {} + monitoring = data.get("monitoring") if isinstance(data.get("monitoring"), dict) else _collect_monitoring_state() + return Snapshot( + now_time=str(data.get("now_time") or time.strftime("%H:%M:%S")), + ssid=str(data.get("ssid") or "-"), + bssid=str(data.get("bssid") or "-"), + channel=str(data.get("channel") or "-"), + signal_dbm=_parse_signal_dbm(data.get("signal_dbm")), + gateway_ip=str(data.get("gateway_ip") or "-"), + up_if=str(data.get("up_if") or wan_if or "-"), + up_ip=str(data.get("up_ip") or "-"), + user_state=state, + recommendation=str(data.get("recommendation") or "Checking"), + reasons=list(data.get("reasons") or ["-"])[:3], + next_action_hint=str(data.get("next_action_hint") or "-"), + quic=str(data.get("quic") or "unknown"), + doh=str(data.get("doh") or "unknown"), + dns_mode=str(data.get("dns_mode") or "unknown"), + degrade=data.get("degrade") if isinstance(data.get("degrade"), dict) else {"on": False, "rtt_ms": 0, "rate_mbps": 0}, + probe=data.get("probe") if isinstance(data.get("probe"), dict) else {"tls_ok": 0, "tls_total": 0, "blocked": 0}, + evidence=list(data.get("evidence") or [])[-20:], + internal=internal, + connection=connection, + monitoring=monitoring, + source="SNAPSHOT", + snapshot_epoch=float(data.get("snapshot_epoch") or time.time()), + threat_level=min(5, max(0, _safe_int(internal.get("suspicion"), 0) // 20)), + risk_score=min(100, max(0, _safe_int(internal.get("suspicion"), 0))), + cpu_percent=float(data.get("cpu_percent") or 0.0), + mem_percent=_safe_int(data.get("mem_percent"), 0), + mem_used_mb=_safe_int(data.get("mem_used_mb"), 0), + mem_total_mb=_safe_int(data.get("mem_total_mb"), 0), + temp_c=float(data.get("temp_c") or 0.0), + download_mbps=float(data.get("download_mbps") or 0.0), + upload_mbps=float(data.get("upload_mbps") or 0.0), + channel_congestion=str(data.get("channel_congestion") or "unknown"), + channel_ap_count=_safe_int(data.get("channel_ap_count"), 0), + state_timeline=str(data.get("state_timeline") or "-"), + dns_stats=data.get("dns_stats") if isinstance(data.get("dns_stats"), dict) else {"ok": 0, "anomaly": 0, "blocked": 0}, + top_blocked=data.get("top_blocked") if isinstance(data.get("top_blocked"), list) else [], + ) + except Exception: + pass + + snap = _snapshot_from_status(lan_if, wan_if) + if snap is not None: + return snap + + return Snapshot( + now_time=time.strftime("%H:%M:%S"), + ssid="-", + bssid="-", + channel="-", + signal_dbm="-", + gateway_ip="-", + up_if=wan_if or "-", + up_ip="-", + user_state="CHECKING", + recommendation="Status unavailable", + reasons=["status fetch failed"], + next_action_hint="verify azctl service", + quic="unknown", + doh="unknown", + dns_mode="unknown", + degrade={"on": False, "rtt_ms": 0, "rate_mbps": 0}, + probe={"tls_ok": 0, "tls_total": 0, "blocked": 0}, + evidence=["could not read runtime/ui_snapshot.json", "could not query azctl status"], + internal={"state_name": "UNKNOWN", "suspicion": 0, "decay": "-"}, + connection={"wifi_state": "UNKNOWN", "usb_nat": "UNKNOWN", "internet_check": "UNKNOWN", "captive_portal": "UNKNOWN", "captive_portal_reason": "-"}, + monitoring=_collect_monitoring_state(), + source="FALLBACK", + snapshot_epoch=time.time(), + ) + + +def send_command(action: str) -> None: + mapping = { + "stage_open": "portal", + "contain": "lockdown", + "reprobe": "shield", + } + mode = mapping.get(action) + if mode is None: + return + + temp_path: Optional[Path] = None + try: + with tempfile.NamedTemporaryFile(mode="w", suffix=".yaml", prefix="azctl-tui-", delete=False, encoding="utf-8") as tf: + tf.write("events:\n") + tf.write(f" - name: {mode}\n") + tf.write(" severity: 0\n") + temp_path = Path(tf.name) + + result = subprocess.run( + ["python3", "-m", "azctl.cli", "events", "--config", str(temp_path)], + capture_output=True, + text=True, + timeout=12, + env={**os.environ, "AZCTL_TUI_STATUS_CALL": "1"}, + ) + if result.returncode != 0: + raise RuntimeError(result.stderr.strip() or "action failed") + finally: + if temp_path is not None: + temp_path.unlink(missing_ok=True) + + +def run_menu(lan_if: str, wan_if: str, start_menu: bool = True) -> int: + try: + from .tui_zero_textual import run_textual + except Exception: + try: + from tui_zero_textual import run_textual # type: ignore + except Exception as e: + print(f"Textual UI is unavailable: {e}") + print("Install dependency: pip install textual") + return 1 + + run_textual( + load_snapshot_fn=lambda: load_snapshot(lan_if, wan_if), + send_command_fn=send_command, + unicode_mode=True, + start_menu=start_menu, + ) + return 0 diff --git a/azctl/tui_zero_textual.py b/azctl/tui_zero_textual.py new file mode 100644 index 0000000..15356d5 --- /dev/null +++ b/azctl/tui_zero_textual.py @@ -0,0 +1,382 @@ +"""Textual UI ported from Azazel-Zero unified monitor.""" +from __future__ import annotations + +import asyncio +import time +from typing import Any, Callable + +from rich.text import Text +from textual.app import App, ComposeResult +from textual.binding import Binding +from textual.containers import Horizontal +from textual.widgets import Footer, Header, Static + +SnapshotLoader = Callable[[], Any] +ActionSender = Callable[[str], None] + + +class AzazelTextualApp(App): + TITLE = "Azazel Unified TUI" + SUB_TITLE = "Azazel-Zero port" + + CSS = """ + Screen { layout: vertical; background: #080a0f; color: #eeeeee; } + Header { background: #0f131a; color: #00d4ff; text-style: bold; } + Footer { background: #0f131a; color: #aaaaaa; height: 1; } + + #status-line { height: 1; color: #05080e; background: #00d4ff; text-style: bold; content-align: left middle; padding: 0 1; } + #status-line.state-safe { background: #2ecc71; color: #05080e; } + #status-line.state-limited { background: #f39c12; color: #05080e; } + #status-line.state-contained { background: #e74c3c; color: #ffffff; } + #status-line.state-deception { background: #e74c3c; color: #ffffff; } + + #summary { height: 8; border: round #00d4ff; background: #0f131a; padding: 0 1; } + #summary.state-safe { border: round #2ecc71; } + #summary.state-limited { border: round #f39c12; } + #summary.state-contained { border: round #e74c3c; } + #summary.state-deception { border: round #e74c3c; } + + #middle { height: 12; } + #connection { width: 1fr; border: round #00d4ff; background: #0f131a; padding: 0 1; } + #control { width: 1fr; border: round #00d4ff; background: #0f131a; padding: 0 1; } + #evidence { height: 1fr; border: round #f39c12; background: #0f131a; padding: 0 1; } + #flow { height: 1; background: #0f131a; color: #aaaaaa; content-align: left middle; padding: 0 1; } + #details { height: 8; border: round #00d4ff; background: #0f131a; padding: 0 1; display: none; } + #menu { height: 1fr; border: round #00d4ff; background: #0f131a; padding: 0 1; display: none; } + """ + + _STATE_CLASSES = ("state-safe", "state-limited", "state-contained", "state-deception") + + BINDINGS = [ + Binding("u", "refresh", "Refresh"), + Binding("a", "stage_open", "Stage-Open"), + Binding("r", "reprobe", "Re-Probe"), + Binding("c", "contain", "Contain"), + Binding("l", "details", "Details"), + Binding("m", "toggle_menu", "Menu"), + Binding("up", "menu_up", "Menu Up", show=False), + Binding("down", "menu_down", "Menu Down", show=False), + Binding("j", "menu_down", "Menu Down", show=False), + Binding("k", "menu_up", "Menu Up", show=False), + Binding("enter", "menu_select", "Select", show=False), + Binding("q", "quit", "Quit"), + ] + + def __init__( + self, + load_snapshot_fn: SnapshotLoader, + send_command_fn: ActionSender, + unicode_mode: bool, + start_menu: bool = False, + ) -> None: + super().__init__() + self._load_snapshot_fn = load_snapshot_fn + self._send_command_fn = send_command_fn + self._unicode_mode = unicode_mode + + self._snapshot: Any = None + self._is_loading = False + self._details_open = False + self._menu_open = start_menu + self._menu_idx = 0 + self._menu_items = self._build_menu_items() + self._status_message = "Ready" + + def compose(self) -> ComposeResult: + yield Header(show_clock=True) + yield Static("Status: booting...", id="status-line", markup=False) + yield Static("Loading snapshot...", id="summary", markup=False) + with Horizontal(id="middle"): + yield Static("Loading connection...", id="connection", markup=False) + yield Static("Loading control...", id="control", markup=False) + yield Static("Loading evidence...", id="evidence", markup=False) + yield Static("Flow: PROBE -> DEGRADED -> NORMAL -> SAFE", id="flow", markup=False) + yield Static("Control menu loading...", id="menu", markup=False) + yield Static("Details hidden. Press [L] to toggle.", id="details", markup=False) + yield Footer() + + async def on_mount(self) -> None: + self.set_interval(1.0, self._tick_age_only) + self._apply_menu_visibility() + await self._refresh_snapshot(initial=True) + + def _build_menu_items(self) -> list[dict[str, object]]: + return [ + {"label": "Refresh Snapshot", "kind": "refresh"}, + {"label": "Stage-Open (portal)", "kind": "send_action", "action": "stage_open"}, + {"label": "Re-Probe (shield)", "kind": "send_action", "action": "reprobe"}, + {"label": "Contain (lockdown)", "kind": "send_action", "action": "contain"}, + {"label": "Toggle Details", "kind": "toggle_details"}, + {"label": "Close Menu", "kind": "close_menu"}, + ] + + def _menu_text(self) -> str: + lines = ["Control Menu [Enter=Run, M=Close]", ""] + for i, item in enumerate(self._menu_items): + marker = ">" if i == self._menu_idx else " " + lines.append(f"{marker} {item['label']}") + return "\n".join(lines) + + def _apply_menu_visibility(self) -> None: + summary = self.query_one("#summary", Static) + middle = self.query_one("#middle", Horizontal) + flow = self.query_one("#flow", Static) + menu = self.query_one("#menu", Static) + evidence = self.query_one("#evidence", Static) + if self._menu_open: + summary.styles.height = 6 + middle.styles.height = 8 + flow.styles.display = "none" + evidence.styles.display = "none" + menu.styles.display = "block" + else: + summary.styles.height = 8 + middle.styles.height = 12 + flow.styles.display = "block" + evidence.styles.display = "block" + menu.styles.display = "none" + if self._menu_open: + menu.update(Text(self._menu_text())) + + def _tick_age_only(self) -> None: + if self._snapshot is not None: + self._render_status_line() + + def _safe_get(self, obj: Any, name: str, default: Any) -> Any: + try: + return getattr(obj, name, default) + except Exception: + return default + + def _live_age(self) -> str: + ts = self._safe_get(self._snapshot, "snapshot_epoch", 0.0) or 0.0 + if not ts: + return "00:00:00" + delta = max(0, int(time.time() - float(ts))) + return time.strftime("%H:%M:%S", time.gmtime(delta)) + + def _state_css_class(self, state: str) -> str: + name = str(state).upper() + if name == "SAFE": + return "state-safe" + if name == "LIMITED": + return "state-limited" + if name in ("CONTAINED", "LOCKDOWN"): + return "state-contained" + if name == "DECEPTION": + return "state-deception" + return "" + + def _apply_state_class(self, widget: Static, state: str) -> None: + for css_class in self._STATE_CLASSES: + widget.remove_class(css_class) + new_cls = self._state_css_class(state) + if new_cls: + widget.add_class(new_cls) + + def _state_icon(self, state: str) -> str: + if not self._unicode_mode: + return {"SAFE": "OK", "LIMITED": "!", "CONTAINED": "X", "DECEPTION": "D"}.get(str(state).upper(), "~") + return { + "SAFE": "✅", + "LIMITED": "⚠️", + "CONTAINED": "⛔", + "DECEPTION": "👁", + }.get(str(state).upper(), "⟳") + + def _threat_bar(self, level: int) -> str: + level = max(0, min(int(level), 5)) + if self._unicode_mode: + return "".join("🔴" if i < level else "⚪" for i in range(5)) + return "".join("X" if i < level else "." for i in range(5)) + + def _render_status_line(self) -> None: + if self._snapshot is None: + self.query_one("#status-line", Static).update(Text(f"Status: {self._status_message}")) + return + status_widget = self.query_one("#status-line", Static) + state = self._safe_get(self._snapshot, "user_state", "CHECKING") + self._apply_state_class(status_widget, state) + line = ( + f"State={state} SSID={self._safe_get(self._snapshot, 'ssid', '-')} " + f"Risk={self._safe_get(self._snapshot, 'risk_score', 0)}/100 Age={self._live_age()} " + f"View={self._safe_get(self._snapshot, 'source', 'SNAPSHOT')} Status={self._status_message}" + ) + status_widget.update(Text(line)) + + def _render_panels(self) -> None: + if self._snapshot is None: + return + + snap = self._snapshot + connection = self._safe_get(snap, "connection", {}) or {} + monitoring = self._safe_get(snap, "monitoring", {}) or {} + degrade = self._safe_get(snap, "degrade", {}) or {} + probe = self._safe_get(snap, "probe", {}) or {} + dns_stats = self._safe_get(snap, "dns_stats", {}) or {} + top_blocked = self._safe_get(snap, "top_blocked", []) or [] + evidence = self._safe_get(snap, "evidence", []) or [] + + state = self._safe_get(snap, "user_state", "CHECKING") + self._apply_state_class(self.query_one("#summary", Static), state) + summary = ( + f"{self._state_icon(state)} {state} Recommendation: {self._safe_get(snap, 'recommendation', '-') }\n" + f"Reason: {' / '.join(self._safe_get(snap, 'reasons', []) or ['-'])}\n" + f"Threat: [{self._threat_bar(self._safe_get(snap, 'threat_level', 0))}] " + f"level={self._safe_get(snap, 'threat_level', 0)} Risk Score: {self._safe_get(snap, 'risk_score', 0)}/100\n" + f"Next: {self._safe_get(snap, 'next_action_hint', '-') }\n" + f"CPU: {self._safe_get(snap, 'cpu_percent', 0.0)}% " + f"Mem: {self._safe_get(snap, 'mem_used_mb', 0)}/{self._safe_get(snap, 'mem_total_mb', 0)}MB " + f"({self._safe_get(snap, 'mem_percent', 0)}%) Temp: {self._safe_get(snap, 'temp_c', 0.0)}C\n" + f"Monitoring: Suricata={monitoring.get('suricata', 'UNKNOWN')} " + f"OpenCanary={monitoring.get('opencanary', 'UNKNOWN')} ntfy={monitoring.get('ntfy', 'UNKNOWN')}" + ) + self.query_one("#summary", Static).update(Text(summary)) + + connection_text = ( + "Connection\n" + f"SSID: {self._safe_get(snap, 'ssid', '-')}\n" + f"BSSID: {self._safe_get(snap, 'bssid', '-')}\n" + f"Signal: {self._safe_get(snap, 'signal_dbm', '-')} dBm\n" + f"Channel: {self._safe_get(snap, 'channel', '-')} " + f"(congestion={self._safe_get(snap, 'channel_congestion', 'unknown')}, " + f"APs={self._safe_get(snap, 'channel_ap_count', 0)})\n" + f"Gateway: {self._safe_get(snap, 'gateway_ip', '-')}\n" + f"Up IF: {self._safe_get(snap, 'up_if', '-')} IP: {self._safe_get(snap, 'up_ip', '-')}\n" + f"WiFi: {connection.get('wifi_state', 'UNKNOWN')} NAT: {connection.get('usb_nat', 'UNKNOWN')} " + f"Internet: {connection.get('internet_check', 'UNKNOWN')}" + ) + self.query_one("#connection", Static).update(Text(connection_text)) + + control_text = ( + "Control / Safety\n" + f"QUIC: {self._safe_get(snap, 'quic', 'unknown')} " + f"DoH: {self._safe_get(snap, 'doh', 'unknown')} DNS mode: {self._safe_get(snap, 'dns_mode', 'unknown')}\n" + f"Degrade: on={degrade.get('on', False)} rtt={degrade.get('rtt_ms', 0)}ms rate={degrade.get('rate_mbps', 0)}Mbps\n" + f"Probe: ok={probe.get('tls_ok', 0)}/{probe.get('tls_total', 0)} blocked={probe.get('blocked', 0)}\n" + f"DNS stats: ok={dns_stats.get('ok', 0)} warn={dns_stats.get('anomaly', 0)} blocked={dns_stats.get('blocked', 0)}\n" + f"Traffic: down={self._safe_get(snap, 'download_mbps', 0.0):.1f} up={self._safe_get(snap, 'upload_mbps', 0.0):.1f} Mbps" + ) + self.query_one("#control", Static).update(Text(control_text)) + + ev = evidence[-12:] if len(evidence) > 12 else evidence + evidence_text = "Evidence (last entries)\n" + "\n".join(f"• {line}" for line in ev) + if not ev: + evidence_text += "\n- (no evidence)" + self.query_one("#evidence", Static).update(Text(evidence_text)) + + self.query_one("#flow", Static).update(Text(f"Flow: PROBE -> DEGRADED -> NORMAL -> SAFE | state_timeline: {self._safe_get(snap, 'state_timeline', '-') }")) + + if self._details_open: + blocked_text = ", ".join(f"{d}({c})" for d, c in top_blocked[:5]) if top_blocked else "-" + details_text = ( + "Details / Internal\n" + f"state_name={self._safe_get(snap, 'internal', {}).get('state_name', '-')}\n" + f"suspicion={self._safe_get(snap, 'internal', {}).get('suspicion', '-')}\n" + f"decay={self._safe_get(snap, 'internal', {}).get('decay', '-')}\n" + f"top_blocked={blocked_text}" + ) + self.query_one("#details", Static).update(Text(details_text)) + + self._apply_menu_visibility() + self._render_status_line() + + async def _refresh_snapshot(self, initial: bool = False) -> None: + if self._is_loading: + return + self._is_loading = True + self._status_message = "Refreshing..." + self._render_status_line() + try: + snap = await asyncio.to_thread(self._load_snapshot_fn) + self._snapshot = snap + self._status_message = "Refresh complete" + except Exception as exc: + self._status_message = f"Refresh failed: {exc}" + finally: + self._is_loading = False + self._render_panels() + + async def _send_action(self, action: str) -> None: + try: + await asyncio.to_thread(self._send_command_fn, action) + self._status_message = f"Action sent: {action}" + self._render_panels() + await self._refresh_snapshot() + except Exception as exc: + self._status_message = f"Action failed: {exc}" + self._render_status_line() + + async def action_refresh(self) -> None: + await self._refresh_snapshot() + + async def action_stage_open(self) -> None: + await self._send_action("stage_open") + + async def action_reprobe(self) -> None: + await self._send_action("reprobe") + + async def action_contain(self) -> None: + await self._send_action("contain") + + def action_details(self) -> None: + self._details_open = not self._details_open + details = self.query_one("#details", Static) + details.styles.display = "block" if self._details_open else "none" + self._status_message = "Details shown" if self._details_open else "Details hidden" + self._render_panels() + + def action_toggle_menu(self) -> None: + self._menu_open = not self._menu_open + self._status_message = "Menu opened" if self._menu_open else "Menu closed" + self._apply_menu_visibility() + self._render_status_line() + + def action_menu_up(self) -> None: + if not self._menu_open: + return + self._menu_idx = (self._menu_idx - 1) % len(self._menu_items) + self._apply_menu_visibility() + + def action_menu_down(self) -> None: + if not self._menu_open: + return + self._menu_idx = (self._menu_idx + 1) % len(self._menu_items) + self._apply_menu_visibility() + + async def action_menu_select(self) -> None: + if not self._menu_open: + return + item = self._menu_items[self._menu_idx] + kind = item.get("kind") + if kind == "close_menu": + self._menu_open = False + self._status_message = "Menu closed" + self._apply_menu_visibility() + self._render_status_line() + return + if kind == "toggle_details": + self.action_details() + return + if kind == "refresh": + await self._refresh_snapshot() + return + if kind == "send_action": + action = str(item.get("action", "")) + if action: + await self._send_action(action) + + +def run_textual( + load_snapshot_fn: SnapshotLoader, + send_command_fn: ActionSender, + unicode_mode: bool, + start_menu: bool = False, +) -> None: + app = AzazelTextualApp( + load_snapshot_fn=load_snapshot_fn, + send_command_fn=send_command_fn, + unicode_mode=unicode_mode, + start_menu=start_menu, + ) + app.run() diff --git a/pyproject.toml b/pyproject.toml index 84805fc..64ed86a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -17,6 +17,7 @@ dependencies = [ "pyyaml>=6.0", "requests>=2.28.0", "rich>=13.0.0", + "textual>=0.58.0", ] [project.optional-dependencies] diff --git a/scripts/install_azazel.sh b/scripts/install_azazel.sh index 38f6578..98b2ad9 100755 --- a/scripts/install_azazel.sh +++ b/scripts/install_azazel.sh @@ -298,6 +298,12 @@ apt-get update -qq log "Installing base packages: ${APT_PACKAGES[*]}" apt-get install -yqq "${APT_PACKAGES[@]}" +# Textual TUI dependency (Azazel-Zero port). Prefer pip for broader distro compatibility. +if ! python3 -c "import textual" >/dev/null 2>&1; then + log "Installing Python dependency: textual" + pip3 install -q 'textual>=0.58.0' || log "Failed to install textual; menu TUI may be unavailable" +fi + if ! command -v vector >/dev/null 2>&1; then log "Vector not found. Installing via official repo (signed-by), with tarball fallback." From 6383fa807f03c6cf7ce5210cfc1ab71e9be7fc88 Mon Sep 17 00:00:00 2001 From: "Mr.Rabbit" Date: Mon, 23 Feb 2026 07:31:42 +0900 Subject: [PATCH 3/3] finalize azazel-pi tui/webui integration docs and api behavior --- README_ja.md | 139 ++++--------------- azazel_web/app.py | 22 ++- docs/en/API_REFERENCE.md | 268 +++---------------------------------- docs/en/ARCHITECTURE.md | 142 ++------------------ docs/en/TROUBLESHOOTING.md | 23 ++-- docs/ja/API_REFERENCE.md | 268 +++---------------------------------- docs/ja/ARCHITECTURE.md | 142 ++------------------ docs/ja/TROUBLESHOOTING.md | 77 +++-------- 8 files changed, 135 insertions(+), 946 deletions(-) diff --git a/README_ja.md b/README_ja.md index d31f2f7..d1acfed 100644 --- a/README_ja.md +++ b/README_ja.md @@ -193,9 +193,9 @@ sudo systemctl enable --now azazel-epd.service 完全なE-Paper設定手順については、[`docs/ja/EPD_SETUP.md`](docs/ja/EPD_SETUP.md) を参照してください。 -### モジュラーTUIメニューシステム +### Unified Textual TUI(Azazel-Zero移植) -インタラクティブなターミナルユーザーインターフェース(TUI)メニューは、保守性と拡張性を考慮したモジュラーアーキテクチャを通じて包括的なシステム管理を提供します: +旧 `azctl/menu` ベースのモジュラーTUIは廃止し、Azazel-Zero由来の unified Textual TUI へ統一しました。 ```bash # TUIメニューを起動 @@ -209,29 +209,21 @@ python3 -m azctl.cli menu python3 -m azctl.cli menu --lan-if ${AZAZEL_LAN_IF:-wlan0} ``` -**モジュラーアーキテクチャ:** +実装ファイル: -Azazel-Piのメニューシステムは、保守性向上のために機能分離を採用したモジュラー設計を採用しています: - -``` -azctl/menu/ -├── core.py # メインフレームワーク -├── types.py # データ型定義 -├── defense.py # 防御制御モジュール -├── services.py # サービス管理モジュール -├── network.py # ネットワーク情報モジュール -├── wifi.py # WiFi管理モジュール -├── monitoring.py # ログ監視モジュール -├── system.py # システム情報モジュール -└── emergency.py # 緊急操作モジュール +```text +azctl/tui_zero.py +azctl/tui_zero_textual.py ``` -**主要機能:** -- **モジュラー設計**: 保守性向上のための機能別モジュール -- **Rich UI**: 色分けされたパネル、表、プログレスバー -- **安全性優先**: 危険な操作には多段階確認 -- **拡張可能**: モジュールシステムを通じた新機能の簡単な追加 -- **リアルタイム監視**: 自動更新付きライブステータス表示 +主要キー: +- `u`: Refresh +- `a`: Stage-Open(portal) +- `r`: Re-Probe(shield) +- `c`: Contain(lockdown) +- `m`: メニュー開閉 +- `l`: 詳細表示 +- `q`: 終了 ### オプション: Nginx を介して Mattermost を公開 @@ -300,18 +292,14 @@ echo '{"mode": "lockdown"}' | azctl events --config - #### インタラクティブTUIメニュー -モジュラーTUIメニューは包括的なシステム管理を提供します: - ```bash -# モジュラーTUIメニューを起動 +# Unified Textual TUIを起動 python3 -m azctl.cli menu # カスタムインターフェースを指定 python3 -m azctl.cli menu --lan-if ${AZAZEL_LAN_IF:-wlan0} --wan-if ${AZAZEL_WAN_IF:-wlan1} ``` -**メニュー機能:** - 1. **コア設定の編集**: `/etc/azazel/azazel.yaml` を修正して遅延値、帯域制御、ロックダウン許可リストを調整(テンプレートは `configs/network/azazel.yaml`)。 - 既定では `wlan0` を内部LAN(AP)、`wlan1` と `eth0` を外部(WAN/アップリンク)として扱います。`configs/network/azazel.yaml` の `interfaces.external` に `["eth0", "wlan1"]` を定義済みです(必要に応じて変更可能)。 注: `--wan-if` を指定しない場合、WAN 管理コンポーネントがランタイムで最適な WAN インターフェイスを選択します。明示的に指定したい場合は `AZAZEL_WAN_IF` / `AZAZEL_LAN_IF` を環境変数で設定してください。 @@ -332,97 +320,18 @@ python3 -m azctl.cli menu --lan-if ${AZAZEL_LAN_IF:-wlan0} --wan-if ${AZAZEL_WAN モード遷移は、タイムスタンプ、スコア、トリガーイベントとともに `/var/log/azazel/decisions.log` に記録されます。 -### インタラクティブTUIメニュー - -Azazel-Piは完全にモジュール化された包括的なターミナルベースのメニューインターフェースを提供します: - -```bash -# TUIメニューを起動 -python3 -m azctl.cli menu - -# または特定のインターフェースを指定 -python3 -m azctl.cli menu --lan-if wlan0 --wan-if wlan1 -``` - -**モジュラーメニューシステム:** - -Azazel-Piのメニューシステムは、保守性と拡張性を向上させるために機能別に分離されたモジュラーアーキテクチャを採用しています: - -``` -azctl/menu/ -├── core.py # メインフレームワーク -├── types.py # データ型定義 -├── defense.py # 防御制御モジュール -├── services.py # サービス管理モジュール -├── network.py # ネットワーク情報モジュール -├── wifi.py # WiFi管理モジュール -├── monitoring.py # ログ監視モジュール -├── system.py # システム情報モジュール -└── emergency.py # 緊急操作モジュール -``` +### インタラクティブTUIメニュー(統合版) -**メニュー機能:** - -1. **防御制御** (`defense.py`) - - 現在の防御モード表示(Portal/Shield/Lockdown) - - 手動モード切り替え(緊急時オーバーライド) - - 決定履歴とスコア変動の確認 - - リアルタイム脅威スコア監視 - -2. **サービス管理** (`services.py`) - - Azazelコアサービスの制御(azctl, suricata, opencanary, vector) - - サービス状態の一覧表示 - - ログファイルのリアルタイム表示 - - サービス再起動とヘルスチェック - -3. **ネットワーク情報** (`network.py`) - - WiFi管理機能の統合 - - インターフェース状態とIP設定 - - アクティブプロファイルとQoS設定 - - ネットワークトラフィック統計 - -4. **WiFi管理** (`wifi.py`) - - 近隣WiFiネットワークのスキャン - - WPA/WPA2ネットワークへの接続 - - 保存済みネットワークの管理 - - 接続状態とシグナル強度表示 - -5. **ログ監視** (`monitoring.py`) - - Suricataアラートのリアルタイム監視 - - OpenCanaryハニーポットイベント - - システムログとデーモンログ - - セキュリティイベントの要約 - -6. **システム情報** (`system.py`) - - CPU、メモリ、ディスク使用率 - - ネットワークインターフェース統計 - - システム温度監視 - - プロセス一覧とリソース使用状況 - -7. **緊急操作** (`emergency.py`) - - 緊急ロックダウン(即座にネットワーク封鎖) - - ネットワーク設定の完全リセット - - システム状態レポート生成 - - ファクトリーリセット(要確認) - -**技術的特徴:** -- **モジュラー設計**: 各機能が独立したモジュールで実装 -- **Rich UI**: 色分けされたパネル、表、プログレスバー -- **エラーハンドリング**: 堅牢なエラー処理と回復機能 -- **セキュリティ重視**: 危険な操作には多段階確認 -- **拡張可能**: 新機能を簡単に追加可能な構造 - -**安全機能:** -- 危険な操作には確認ダイアログを表示 -- root権限が必要な操作の自動検証 -- 操作ログの自動記録 -- エラーハンドリングと自動回復手順 -- 緊急操作には複数回確認が必要 +`python3 -m azctl.cli menu` は、`azctl/tui_zero.py` と `azctl/tui_zero_textual.py` を用いた統合TUIを起動します。 +旧 `azctl/menu` 実装は削除済みです。 **キーボードナビゲーション:** -- `数字キー`: メニュー項目を選択 -- `r`: 画面を更新 -- `b`: 前のメニューに戻る +- `u`: 画面を更新 +- `a`: Stage-Open(portal) +- `r`: Re-Probe(shield) +- `c`: Contain(lockdown) +- `m`: メニュー開閉 +- `l`: 詳細表示 - `q`: 終了 - `Ctrl+C`: いつでも安全に中断可能 diff --git a/azazel_web/app.py b/azazel_web/app.py index a1f9243..09a74de 100644 --- a/azazel_web/app.py +++ b/azazel_web/app.py @@ -108,6 +108,7 @@ "refresh", "reprobe", "contain", "release", "details", "stage_open", "disconnect", "wifi_scan", "wifi_connect", "portal_viewer_open", "shutdown", "reboot" # Wi-Fi + portal viewer actions } +PI_NOT_IMPLEMENTED_ACTIONS = {"wifi_scan", "wifi_connect", "portal_viewer_open"} def _load_first_minute_config() -> Dict[str, Any]: @@ -1066,6 +1067,16 @@ def _send_control_command_socket( "ts": time.strftime("%Y-%m-%dT%H:%M:%S") } + +def _pi_not_implemented_result(action: str) -> Dict[str, Any]: + """Return a stable error payload for features not wired on Azazel-Pi.""" + return { + "ok": False, + "action": action, + "error": f"{action} is not implemented in Azazel-Pi unified port", + "ts": time.strftime("%Y-%m-%dT%H:%M:%S"), + } + def send_control_command(action: str) -> Dict[str, Any]: """Send command to Control Daemon via Unix socket""" if action not in ALLOWED_ACTIONS: @@ -1100,6 +1111,8 @@ def send_control_command_with_params(action: str, params: Dict[str, Any]) -> Dic "error": "Unknown action", "ts": time.strftime("%Y-%m-%dT%H:%M:%S") } + if action in PI_NOT_IMPLEMENTED_ACTIONS and not CONTROL_SOCKET.exists(): + return _pi_not_implemented_result(action) return _send_control_command_socket(action=action, params=params, timeout_sec=30.0) @@ -1187,12 +1200,13 @@ def api_portal_viewer_open(): ) if not daemon_result.get("ok"): + status_code = 501 if "not implemented in Azazel-Pi unified port" in str(daemon_result.get("error", "")) else 500 return jsonify({ "ok": False, "error": daemon_result.get("error", "Failed to start portal viewer"), "portal_viewer": get_portal_viewer_state(), "daemon": daemon_result, - }), 500 + }), status_code portal_state = get_portal_viewer_state() if not portal_state.get("ready"): @@ -1382,7 +1396,8 @@ def api_wifi_scan(): if result.get("ok"): return jsonify(result), 200 else: - return jsonify(result), 500 + status_code = 501 if "not implemented in Azazel-Pi unified port" in str(result.get("error", "")) else 500 + return jsonify(result), status_code @app.route("/api/wifi/connect", methods=["POST"]) @@ -1432,7 +1447,8 @@ def api_wifi_connect(): if result.get("ok"): return jsonify(result), 200 else: - return jsonify(result), 500 + status_code = 501 if "not implemented in Azazel-Pi unified port" in str(result.get("error", "")) else 500 + return jsonify(result), status_code @app.route("/static/") diff --git a/docs/en/API_REFERENCE.md b/docs/en/API_REFERENCE.md index ff55343..0c9bc64 100644 --- a/docs/en/API_REFERENCE.md +++ b/docs/en/API_REFERENCE.md @@ -4,266 +4,36 @@ This reference documents the Python modules that make up the Azazel control plane. The intent is to provide enough context for operators to extend or mock the behaviour during testing. -## `azctl.menu` - Modular TUI Menu System +## `azctl.tui_zero` - Unified Textual TUI -Azazel-Pi provides a modular Terminal User Interface designed for maintainability and extensibility. +Azazel-Pi now uses the Azazel-Zero style unified Textual TUI. +The legacy `azctl/menu` modular implementation was removed. -### Architecture +### Runtime modules ``` -azctl/menu/ -├── __init__.py # Module entry point -├── types.py # Common data class definitions -├── core.py # Main framework -├── defense.py # Defense control module -├── services.py # Service management module -├── network.py # Network information module -├── wifi.py # WiFi management module -├── monitoring.py # Log monitoring module -├── system.py # System information module -└── emergency.py # Emergency operations module +azctl/tui_zero.py +azctl/tui_zero_textual.py ``` -### Basic Data Types (`types.py`) +### Entry point -#### `MenuAction` -Data class representing a menu action item. +- CLI: `python3 -m azctl.cli menu` +- Internally, `azctl.cli.cmd_menu()` calls `azctl.tui_zero.run_menu()` -**Properties:** -- `title: str` - Display title -- `description: str` - Detailed description -- `action: Callable` - Function to execute -- `requires_root: bool` - Whether root privileges required (default: False) -- `dangerous: bool` - Whether operation is dangerous (default: False) +### Primary behaviors -**Usage Example:** -```python -from azctl.menu.types import MenuAction +- Loads state from `runtime/ui_snapshot.json` when available. +- Falls back to `python3 -m azctl.cli status --json`. +- Menu actions map to mode events: + - `stage_open` -> `portal` + - `reprobe` -> `shield` + - `contain` -> `lockdown` -action = MenuAction( - title="Mode Switch", - description="Manually change defense mode", - action=lambda: switch_mode("shield"), - requires_root=True, - dangerous=True -) -``` - -#### `MenuCategory` -Data class representing a menu category containing multiple actions. - -**Properties:** -- `title: str` - Category title -- `description: str` - Category description -- `actions: list[MenuAction]` - List of contained actions - -**Usage Example:** -```python -from azctl.menu.types import MenuCategory, MenuAction - -category = MenuCategory( - title="Defense Control", - description="Defense system monitoring and control", - actions=[ - MenuAction("Show Status", "Display system status", show_status), - MenuAction("Switch Mode", "Change defense mode", switch_mode) - ] -) -``` - -### Core Framework (`core.py`) - -#### `AzazelTUIMenu` -Main TUI menu system class. - -**Initialization:** -```python -AzazelTUIMenu( - decisions_log: Optional[str] = None, - lan_if: str = "wlan0", - wan_if: str = "wlan1" -) -``` - -**Parameters:** -- `decisions_log` - Path to decision log file -- `lan_if` - LAN interface name -- `wan_if` - WAN interface name - -**Methods:** - -##### `run()` -Start the main TUI loop. - -**Usage Example:** -```python -from azctl.menu import AzazelTUIMenu - -menu = AzazelTUIMenu(lan_if="wlan0", wan_if="wlan1") -menu.run() -``` - -### Functional Modules - -#### Defense Control Module (`defense.py`) - -##### `DefenseModule` -Provides defense system monitoring and control. - -**Features:** -- Current defense mode display -- Manual mode switching -- Decision history display -- Real-time threat score monitoring - -**Usage Example:** -```python -from azctl.menu.defense import DefenseModule -from rich.console import Console - -module = DefenseModule(Console()) -category = module.get_category() -``` - -#### Service Management Module (`services.py`) - -##### `ServicesModule` -Manages Azazel system services. - -**Managed Services:** -- `azctl-unified.service` - Unified control daemon -- `azctl-unified.service` - HTTP server -- `suricata.service` - IDS/IPS -- `azazel_opencanary (Docker)` - Honeypot -- `vector.service` - Log collection -- `azazel-epd.service` - E-Paper display - -**Features:** -- Service status listing -- Service start/stop/restart -- Real-time log viewing -- System-wide health checks - -#### Network Information Module (`network.py`) +### Dependencies -##### `NetworkModule` -Provides network status and WiFi management integration. - -**Features:** -- Interface status display -- Active profile confirmation -- WiFi management integration -- Network traffic statistics - -#### WiFi Management Module (`wifi.py`) - -##### `WiFiManager` -Comprehensive WiFi network management. - -**Features:** -- Nearby WiFi network scanning -- WPA/WPA2 network connection -- Saved network management -- Connection status and signal strength display -- Interactive SSID selection and password input - -**Technical Specifications:** -- Uses `iw scan` for network discovery -- Uses `wpa_cli` for connection management -- Rich UI with tabular display -- Automatic security type detection - -#### Log Monitoring Module (`monitoring.py`) - -##### `MonitoringModule` -Security and system log monitoring. - -**Monitored Sources:** -- Suricata alert logs (`/var/log/suricata/eve.json`) -- OpenCanary event logs -- Azazel decision logs (`/var/log/azazel/decisions.log`) -- System journal - -**Features:** -- Real-time log monitoring -- Alert summary and counting -- Log file history display -- Security event analysis - -#### System Information Module (`system.py`) - -##### `SystemModule` -System resource and hardware status monitoring. - -**Monitored Items:** -- CPU usage and processor information -- Memory usage (physical/swap) -- Disk usage -- Network interface statistics -- System temperature (Raspberry Pi) -- Running process list - -#### Emergency Operations Module (`emergency.py`) - -##### `EmergencyModule` -Emergency response operations. - -**Features:** -- **Emergency Lockdown**: Immediately block network access -- **Network Configuration Reset**: Reset WiFi settings to defaults -- **System Report Generation**: Create comprehensive status report -- **Factory Reset**: Reset entire system to initial state - -**Safety Features:** -- Multi-stage confirmation dialogs -- Danger level-based warnings -- Automatic operation logging -- Interruptible operation flows - -### Custom Module Creation - -Example of adding a new functional module: - -```python -# azctl/menu/custom.py -from rich.console import Console -from .types import MenuCategory, MenuAction - -class CustomModule: - def __init__(self, console: Console): - self.console = console - - def get_category(self) -> MenuCategory: - return MenuCategory( - title="Custom Features", - description="Custom feature management", - actions=[ - MenuAction( - title="Custom Operation", - description="Execute custom operation", - action=self._custom_action - ) - ] - ) - - def _custom_action(self): - self.console.print("[green]Executing custom operation...[/green]") -``` - -### Integration and Testing - -```python -# Integrate new module into core system -# Add to azctl/menu/core.py _setup_menu_categories() - -from .custom import CustomModule - -# In __init__ method -self.custom_module = CustomModule(self.console) - -# In _setup_menu_categories method -self.categories.append(self.custom_module.get_category()) -``` +- `textual` (required for menu TUI) +- `rich` (status/TUI rendering helpers used elsewhere) ## `azazel_core.state_machine` diff --git a/docs/en/ARCHITECTURE.md b/docs/en/ARCHITECTURE.md index 3b18144..f462dc4 100644 --- a/docs/en/ARCHITECTURE.md +++ b/docs/en/ARCHITECTURE.md @@ -13,7 +13,7 @@ become operational without ad-hoc configuration. | `azazel_core/ingest/` | Parses Suricata EVE logs and OpenCanary events. | | `azazel_core/qos/` | Maps profiles to QoS enforcement classes. | | `azctl/` | Thin CLI/daemon interface used by systemd. | -| `azctl/menu/` | Modular TUI menu system for comprehensive system management. | +| `azctl/tui_zero.py` + `azctl/tui_zero_textual.py` | Unified Textual TUI (Azazel-Zero port). | | `configs/` | Declarative configuration set including schema validation. | | `scripts/install_azazel.sh` | Provisioning script that stages the runtime and dependencies. | | `systemd/` | Units and targets that compose the Azazel service stack. | @@ -38,142 +38,22 @@ published in `configs/azazel.schema.json` and enforced in CI. Vendor applications—Suricata, Vector, OpenCanary, nftables and tc—are provided with opinionated defaults that can be adapted per deployment. -## TUI Menu System Architecture +## TUI Architecture -### Modular Design +Azazel-Pi uses a unified Textual UI ported from Azazel-Zero. -Azazel-Pi employs a modular TUI menu system designed for maintainability and extensibility: - -``` -azctl/menu/ -├── __init__.py # Entry point -├── types.py # Common data types (MenuAction, MenuCategory) -├── core.py # Main framework (AzazelTUIMenu) -├── defense.py # Defense control module -├── services.py # Service management module -├── network.py # Network information integration module -├── wifi.py # WiFi management specialized module -├── monitoring.py # Log monitoring module -├── system.py # System information module -└── emergency.py # Emergency operations module ``` - -### Design Principles - -#### 1. Separation of Concerns -Each module has a clearly defined single responsibility: - -```python -# Example: WiFi management module -class WiFiManager: - """Specialized for WiFi network management""" - def scan_networks(self) -> List[Network]: pass - def connect_to_network(self, ssid: str, password: str): pass - def get_saved_networks(self) -> List[Network]: pass -``` - -#### 2. Type Safety -Common data types are defined in `types.py` to avoid circular imports: - -```python -@dataclass -class MenuAction: - title: str - description: str - action: Callable - requires_root: bool = False - dangerous: bool = False - -@dataclass -class MenuCategory: - title: str - description: str - actions: list[MenuAction] -``` - -#### 3. Dependency Injection -Each module receives Console objects as injection, making them independently testable: - -```python -class DefenseModule: - def __init__(self, console: Console): - self.console = console - - def get_category(self) -> MenuCategory: - return MenuCategory( - title="Defense Control", - description="Defense system monitoring and control", - actions=self._build_actions() - ) +azctl/cli.py # `menu` subcommand entry +azctl/tui_zero.py # Azazel-Pi adapters (snapshot/action mapping) +azctl/tui_zero_textual.py # Textual application layout + key bindings ``` -### TUI Menu Execution Flow +Execution flow: -``` -User Launch → Main Menu → Category Selection → Action Execution → Result Display - ↓ ↓ ↓ ↓ ↓ -┌─────────────┐ ┌──────────┐ ┌──────────┐ ┌─────────┐ ┌──────────┐ -│azctl.cli │ │AzazelTUI │ │Module │ │Action │ │Rich │ -│menu command │ │Menu.run()│ │Category │ │Function │ │Console │ -└─────────────┘ └──────────┘ └──────────┘ └─────────┘ └──────────┘ -``` - -### Menu Rendering Architecture - -Consistent UI display using Rich library: - -```python -# Unified section header display -def _print_section_header(self, title: str, subtitle: str = ""): - panel = Panel( - Align.center(f"[bold]{title}[/bold]\n{subtitle}"), - border_style="blue", - padding=(1, 2) - ) - self.console.print(panel) -``` - -### Extension System - -Adding new menu modules is straightforward: - -```python -# azctl/menu/custom.py -from rich.console import Console -from .types import MenuCategory, MenuAction - -class CustomModule: - def __init__(self, console: Console): - self.console = console - - def get_category(self) -> MenuCategory: - return MenuCategory( - title="Custom Features", - description="Custom feature management", - actions=[ - MenuAction( - title="Custom Operation", - description="Execute custom operation", - action=self._custom_action - ) - ] - ) - - def _custom_action(self): - self.console.print("[green]Executing custom operation...[/green]") - -# Integration in core system -# Add to azctl/menu/core.py: -from .custom import CustomModule - -class AzazelTUIMenu: - def __init__(self, ...): - # ... - self.custom_module = CustomModule(self.console) - - def _setup_menu_categories(self): - self.categories.append(self.custom_module.get_category()) -``` +1. Operator runs `python3 -m azctl.cli menu`. +2. `cmd_menu()` delegates to `azctl.tui_zero.run_menu()`. +3. Snapshot loader prefers `runtime/ui_snapshot.json`; fallback is `azctl status --json`. +4. Textual actions (`stage_open`, `reprobe`, `contain`) are translated to mode events (`portal`, `shield`, `lockdown`). ## Packaging goal diff --git a/docs/en/TROUBLESHOOTING.md b/docs/en/TROUBLESHOOTING.md index f2011c5..bce54a0 100644 --- a/docs/en/TROUBLESHOOTING.md +++ b/docs/en/TROUBLESHOOTING.md @@ -11,14 +11,14 @@ This comprehensive troubleshooting guide covers common issues encountered during **Symptoms:** ```bash $ python3 -m azctl.cli menu -ModuleNotFoundError: No module named 'azctl.menu' +ModuleNotFoundError: No module named 'textual' ``` **Solutions:** ```bash -# Check module existence -ls -la azctl/menu/ +# Install textual dependency +pip3 install textual # Verify Python path python3 -c "import sys; print('\n'.join(sys.path))" @@ -28,21 +28,21 @@ cd /opt/azazel python3 -m azctl.cli menu ``` -#### Problem: Circular import errors +#### Problem: Unified TUI module import errors **Symptoms:** ``` -ImportError: cannot import name 'MenuCategory' from partially initialized module +ImportError: cannot import name 'run_menu' from azctl.tui_zero ``` **Solutions:** ```bash -# Verify correct module structure -find azctl/menu -name "*.py" -exec grep -l "from.*core import" {} \; +# Verify new TUI files exist +ls -la azctl/tui_zero.py azctl/tui_zero_textual.py -# Ensure imports are from types.py -grep -r "from.*types import" azctl/menu/ +# Validate syntax +python3 -m py_compile azctl/tui_zero.py azctl/tui_zero_textual.py azctl/cli.py ``` ### Menu Display Issues @@ -240,9 +240,8 @@ python3 -m azctl.cli menu python3 -c " import logging logging.basicConfig(level=logging.DEBUG) -from azctl.menu import AzazelTUIMenu -menu = AzazelTUIMenu() -menu.run() +from azctl.tui_zero import run_menu +run_menu(lan_if='wlan0', wan_if='wlan1', start_menu=True) " ``` diff --git a/docs/ja/API_REFERENCE.md b/docs/ja/API_REFERENCE.md index 14899bb..5039e21 100644 --- a/docs/ja/API_REFERENCE.md +++ b/docs/ja/API_REFERENCE.md @@ -370,266 +370,36 @@ server.start() systemdサービスを支援し、イベントを`AzazelDaemon`に送信してスコアベースの決定を適用し、選択されたモードとアクションプリセットを含む`decisions.log`エントリを書き込みます。 -## `azctl.menu` - モジュラーTUIメニューシステム +## `azctl.tui_zero` - Unified Textual TUI -Azazel-Piは、保守性と拡張性を重視したモジュラーアーキテクチャによるターミナルユーザーインターフェースを提供します。 +Azazel-Pi のメニューTUIは、Azazel-Zero由来の unified Textual UI に統一されました。 +旧 `azctl/menu` モジュラー実装は削除済みです。 -### アーキテクチャ +### 実装ファイル ``` -azctl/menu/ -├── __init__.py # モジュールエントリーポイント -├── types.py # 共通データクラス定義 -├── core.py # メインフレームワーク -├── defense.py # 防御制御モジュール -├── services.py # サービス管理モジュール -├── network.py # ネットワーク情報モジュール -├── wifi.py # WiFi管理モジュール -├── monitoring.py # ログ監視モジュール -├── system.py # システム情報モジュール -└── emergency.py # 緊急操作モジュール +azctl/tui_zero.py +azctl/tui_zero_textual.py ``` -### 基本データ型 (`types.py`) +### 起動経路 -#### `MenuAction` -メニューアクション項目を表現するデータクラスです。 +- CLI: `python3 -m azctl.cli menu` +- 内部実装: `azctl.cli.cmd_menu()` -> `azctl.tui_zero.run_menu()` -**プロパティ:** -- `title: str` - 表示タイトル -- `description: str` - 詳細説明 -- `action: Callable` - 実行される関数 -- `requires_root: bool` - root権限の要否(デフォルト: False) -- `dangerous: bool` - 危険な操作かどうか(デフォルト: False) - -**使用例:** -```python -from azctl.menu.types import MenuAction - -action = MenuAction( - title="モード切り替え", - description="防御モードを手動で変更します", - action=lambda: switch_mode("shield"), - requires_root=True, - dangerous=True -) -``` - -#### `MenuCategory` -メニューカテゴリ(複数のアクションを含む)を表現するデータクラスです。 +### 主要挙動 -**プロパティ:** -- `title: str` - カテゴリタイトル -- `description: str` - カテゴリ説明 -- `actions: list[MenuAction]` - 含まれるアクション一覧 - -**使用例:** -```python -from azctl.menu.types import MenuCategory, MenuAction - -category = MenuCategory( - title="防御制御", - description="防御システムの監視と制御", - actions=[ - MenuAction("現在の状態表示", "システム状態を確認", show_status), - MenuAction("モード切り替え", "防御モードを変更", switch_mode) - ] -) -``` - -### コアフレームワーク (`core.py`) - -#### `AzazelTUIMenu` -メインのTUIメニューシステムクラスです。 - -**初期化:** -```python -AzazelTUIMenu( - decisions_log: Optional[str] = None, - lan_if: str = "wlan0", - wan_if: str = "wlan1" -) -``` - -**パラメータ:** -- `decisions_log` - 決定ログファイルのパス -- `lan_if` - LANインターフェース名 -- `wan_if` - WANインターフェース名 - -**メソッド:** - -##### `run()` -メインのTUIループを開始します。 - -**使用例:** -```python -from azctl.menu import AzazelTUIMenu - -menu = AzazelTUIMenu(lan_if="wlan0", wan_if="wlan1") -menu.run() -``` - -### 機能モジュール - -#### 防御制御モジュール (`defense.py`) - -##### `DefenseModule` -防御システムの監視と制御を行います。 - -**提供機能:** -- 現在の防御モード表示 -- 手動モード切り替え -- 決定履歴の表示 -- リアルタイム脅威スコア監視 - -**使用例:** -```python -from azctl.menu.defense import DefenseModule -from rich.console import Console - -module = DefenseModule(Console()) -category = module.get_category() -``` +- 状態取得は `runtime/ui_snapshot.json` を優先 +- フォールバックとして `python3 -m azctl.cli status --json` を使用 +- メニューアクションは以下へ変換: + - `stage_open` -> `portal` + - `reprobe` -> `shield` + - `contain` -> `lockdown` -#### サービス管理モジュール (`services.py`) +### 依存関係 -##### `ServicesModule` -Azazelシステムサービスの管理を行います。 - -**管理対象サービス:** -- `azctl-unified.service` - 統合制御デーモン -- `azctl-unified.service` - HTTPサーバー -- `suricata.service` - IDS/IPS -- `azazel_opencanary (Docker)` - ハニーポット -- `vector.service` - ログ収集 -- `azazel-epd.service` - E-Paperディスプレイ - -**提供機能:** -- サービス状態の一覧表示 -- サービスの開始/停止/再起動 -- ログファイルのリアルタイム表示 -- システム全体のヘルスチェック - -#### ネットワーク情報モジュール (`network.py`) - -##### `NetworkModule` -ネットワーク状態とWiFi管理の統合表示を行います。 - -**提供機能:** -- インターフェース状態表示 -- アクティブプロファイル確認 -- WiFi管理機能の統合 -- ネットワークトラフィック統計 - -#### WiFi管理モジュール (`wifi.py`) - -##### `WiFiManager` -WiFiネットワークの包括的な管理を行います。 - -**提供機能:** -- 近隣WiFiネットワークのスキャン -- WPA/WPA2ネットワークへの接続 -- 保存済みネットワークの管理 -- 接続状態とシグナル強度の表示 -- SSID選択とパスワード入力のインタラクティブUI - -**技術仕様:** -- `iw scan` による周辺ネットワーク検索 -- `wpa_cli` による接続管理 -- Rich UIによる見やすい表形式表示 -- セキュリティ種別の自動判別 - -#### ログ監視モジュール (`monitoring.py`) - -##### `MonitoringModule` -セキュリティログとシステムログの監視を行います。 - -**監視対象:** -- Suricataアラートログ (`/var/log/suricata/eve.json`) -- OpenCanaryイベントログ -- Azazel決定ログ (`/var/log/azazel/decisions.log`) -- システムジャーナル - -**提供機能:** -- リアルタイムログ監視 -- アラート要約とカウント -- ログファイルの履歴表示 -- セキュリティイベントの分析 - -#### システム情報モジュール (`system.py`) - -##### `SystemModule` -システムリソースとハードウェア状態の監視を行います。 - -**監視項目:** -- CPU使用率とプロセッサ情報 -- メモリ使用量(物理/スワップ) -- ディスク使用量 -- ネットワークインターフェース統計 -- システム温度(Raspberry Pi) -- 実行中プロセス一覧 - -#### 緊急操作モジュール (`emergency.py`) - -##### `EmergencyModule` -緊急時の対応操作を提供します。 - -**提供機能:** -- **緊急ロックダウン**: 即座にネットワークアクセスを遮断 -- **ネットワーク設定リセット**: WiFi設定を初期状態に戻す -- **システムレポート生成**: 包括的な状態レポートを作成 -- **ファクトリーリセット**: システム全体を初期状態に戻す - -**安全機能:** -- 複数段階の確認ダイアログ -- 危険度に応じた警告表示 -- 操作ログの自動記録 -- 中断可能な操作フロー - -### カスタムモジュールの作成 - -新しい機能モジュールを追加する場合の例: - -```python -# azctl/menu/custom.py -from rich.console import Console -from .types import MenuCategory, MenuAction - -class CustomModule: - def __init__(self, console: Console): - self.console = console - - def get_category(self) -> MenuCategory: - return MenuCategory( - title="カスタム機能", - description="カスタム機能の説明", - actions=[ - MenuAction( - title="カスタム操作", - description="カスタム操作の説明", - action=self._custom_action - ) - ] - ) - - def _custom_action(self): - self.console.print("カスタム操作を実行中...") -``` - -### 統合とテスト - -```python -# 新しいモジュールをコアシステムに統合 -# azctl/menu/core.py の _setup_menu_categories() に追加 - -from .custom import CustomModule - -# __init__ メソッド内 -self.custom_module = CustomModule(self.console) - -# _setup_menu_categories メソッド内 -self.categories.append(self.custom_module.get_category()) -``` +- `textual`(メニューTUI必須) +- `rich`(他CLI/TUIの描画で利用) ### 主要関数 diff --git a/docs/ja/ARCHITECTURE.md b/docs/ja/ARCHITECTURE.md index 7eafdd5..9bf60ab 100644 --- a/docs/ja/ARCHITECTURE.md +++ b/docs/ja/ARCHITECTURE.md @@ -41,7 +41,7 @@ Azazel-Piは、ネットワークセキュリティ監視および自動応答 | `azazel_pi/core/display/` | E-Paperステータス表示とレンダリング | 物理インターフェース | | `azazel_pi/core/qos/` | プロファイルをQoS実行クラスにマッピング | 帯域制御 | | `azctl/` | systemdで使用される軽量CLI/デーモンインターフェース | システム統合 | -| `azctl/menu/` | モジュラーTUIメニューシステム | ユーザーインターフェース | +| `azctl/tui_zero.py` + `azctl/tui_zero_textual.py` | unified Textual TUI(Azazel-Zero移植) | ユーザーインターフェース | | `configs/` | スキーマ検証を含む宣言的設定セット | 設定管理 | | `scripts/install_azazel.sh` | ランタイムと依存関係をステージングするプロビジョニングスクリプト | 導入支援 | | `systemd/` | Azazelサービススタックを構成するユニットとターゲット | サービス管理 | @@ -437,100 +437,22 @@ class MetricsCollector: - 証明書検証 - セキュアな認証機構 -## TUIメニューシステムアーキテクチャ +## TUIメニューアーキテクチャ -### モジュラー設計 +Azazel-PiのメニューTUIは、Azazel-Zero由来の unified Textual UI に統一されました。 -Azazel-Piは保守性と拡張性を重視したモジュラーTUIメニューシステムを採用しています: - -``` -azctl/menu/ -├── __init__.py # エントリーポイント -├── types.py # 共通データ型(MenuAction, MenuCategory) -├── core.py # メインフレームワーク(AzazelTUIMenu) -├── defense.py # 防御制御モジュール -├── services.py # サービス管理モジュール -├── network.py # ネットワーク情報統合モジュール -├── wifi.py # WiFi管理専用モジュール -├── monitoring.py # ログ監視モジュール -├── system.py # システム情報モジュール -└── emergency.py # 緊急操作モジュール -``` - -### 設計原則 - -#### 1. 責任分離 -各モジュールは明確に定義された単一の責任を持ちます: - -```python -# 例: WiFi管理モジュール -class WiFiManager: - """WiFiネットワーク管理に特化""" - def scan_networks(self) -> List[Network]: pass - def connect_to_network(self, ssid: str, password: str): pass - def get_saved_networks(self) -> List[Network]: pass -``` - -#### 2. 型安全性 -共通のデータ型を`types.py`で定義し、循環インポートを回避: - -```python -@dataclass -class MenuAction: - title: str - description: str - action: Callable - requires_root: bool = False - dangerous: bool = False - -@dataclass -class MenuCategory: - title: str - description: str - actions: list[MenuAction] -``` - -#### 3. 依存関係注入 -各モジュールはConsoleオブジェクトを注入され、独立してテスト可能: - -```python -class DefenseModule: - def __init__(self, console: Console): - self.console = console - - def get_category(self) -> MenuCategory: - return MenuCategory( - title="防御制御", - description="防御システムの監視と制御", - actions=self._build_actions() - ) ``` - -### TUIメニューの実行フロー - -``` -ユーザー起動 → メインメニュー → カテゴリ選択 → アクション実行 → 結果表示 - ↓ ↓ ↓ ↓ ↓ -┌─────────────┐ ┌──────────┐ ┌──────────┐ ┌─────────┐ ┌──────────┐ -│azctl.cli │ │AzazelTUI │ │Module │ │Action │ │Rich │ -│menu command │ │Menu.run()│ │Category │ │Function │ │Console │ -└─────────────┘ └──────────┘ └──────────┘ └─────────┘ └──────────┘ +azctl/cli.py # `menu` サブコマンド入口 +azctl/tui_zero.py # Azazel-Pi向けアダプタ(状態/アクション変換) +azctl/tui_zero_textual.py # Textualレイアウトとキー操作 ``` -### メニューレンダリングアーキテクチャ +実行フロー: -Rich ライブラリを使用した一貫したUI表示: - -```python -# 統一されたセクションヘッダー表示 -def _print_section_header(self, title: str, subtitle: str = ""): - panel = Panel( - Align.center(f"[bold]{title}[/bold]\n{subtitle}"), - border_style="blue", - padding=(1, 2) - ) - self.console.print(panel) -``` +1. `python3 -m azctl.cli menu` を起動。 +2. `cmd_menu()` が `azctl.tui_zero.run_menu()` を呼び出し。 +3. 状態は `runtime/ui_snapshot.json` を優先し、なければ `azctl status --json` を利用。 +4. TUI操作 (`stage_open` / `reprobe` / `contain`) を `portal` / `shield` / `lockdown` イベントへ変換して適用。 ## 拡張性と保守性 @@ -552,47 +474,9 @@ class CustomAction(Action): pass ``` -### TUIメニューモジュールの追加 +### TUI拡張 -新しいメニューモジュールの追加は簡単です: - -```python -# azctl/menu/custom.py -from rich.console import Console -from .types import MenuCategory, MenuAction - -class CustomModule: - def __init__(self, console: Console): - self.console = console - - def get_category(self) -> MenuCategory: - return MenuCategory( - title="カスタム機能", - description="カスタム機能の管理", - actions=[ - MenuAction( - title="カスタム操作", - description="カスタム操作を実行", - action=self._custom_action - ) - ] - ) - - def _custom_action(self): - self.console.print("[green]カスタム操作を実行中...[/green]") - -# コアシステムに統合 -# azctl/menu/core.py に追加: -from .custom import CustomModule - -class AzazelTUIMenu: - def __init__(self, ...): - # ... - self.custom_module = CustomModule(self.console) - - def _setup_menu_categories(self): - self.categories.append(self.custom_module.get_category()) -``` +TUI拡張時は `azctl/tui_zero.py` の状態/アクション変換と、`azctl/tui_zero_textual.py` の画面・キー定義をセットで更新してください。 ### テスト戦略 diff --git a/docs/ja/TROUBLESHOOTING.md b/docs/ja/TROUBLESHOOTING.md index 1bd04b9..6c94cd6 100644 --- a/docs/ja/TROUBLESHOOTING.md +++ b/docs/ja/TROUBLESHOOTING.md @@ -837,6 +837,8 @@ sudo nano /etc/logrotate.d/azazel ## TUIメニューシステム問題 +Azazel-PiのメニューTUIは `azctl/menu` ではなく、`azctl/tui_zero.py` / `azctl/tui_zero_textual.py` を使用します。 + ### メニュー起動失敗 #### 問題: TUIメニューが起動しない @@ -844,82 +846,42 @@ sudo nano /etc/logrotate.d/azazel **症状:** ```bash $ python3 -m azctl.cli menu -ModuleNotFoundError: No module named 'azctl.menu' +ModuleNotFoundError: No module named 'textual' ``` **解決策:** ```bash -# モジュールの存在確認 -ls -la azctl/menu/ +# Textual依存を導入 +pip3 install textual -# Pythonパスの確認 -python3 -c "import sys; print('\n'.join(sys.path))" +# 新TUIモジュールの存在確認 +ls -la azctl/tui_zero.py azctl/tui_zero_textual.py -# 現在のディレクトリから実行 -cd /opt/azazel -python3 -m azctl.cli menu +# 構文確認 +python3 -m py_compile azctl/tui_zero.py azctl/tui_zero_textual.py azctl/cli.py ``` -#### 問題: 循環インポートエラー +### 表示・入力問題 **症状:** -``` -ImportError: cannot import name 'MenuCategory' from partially initialized module -``` +- キー入力が反応しない +- 画面が乱れる **解決策:** ```bash -# 正しいモジュール構造を確認 -find azctl/menu -name "*.py" -exec grep -l "from.*core import" {} \; - -# types.pyからのインポートに修正されているか確認 -grep -r "from.*types import" azctl/menu/ -``` - -### メニュー表示問題 - -#### 問題: Rich UIが正しく表示されない - -**症状:** -- 色が表示されない -- 表が崩れる -- 文字化けが発生 - -**解決策:** - -```bash -# ターミナル環境変数を確認 +# ターミナル環境を確認 echo $TERM -echo $COLORTERM - -# Richコンソール機能をテスト -python3 -c "from rich.console import Console; c = Console(); c.print('[red]Test[/red]')" - -# 適切なターミナルを使用 -export TERM=xterm-256color -python3 -m azctl.cli menu -``` - -#### 問題: キーボード入力が正しく処理されない - -**症状:** -- 数字キーでメニューが選択されない -- Ctrl+Cで終了できない -- 画面更新されない - -**解決策:** - -```bash -# ターミナル入力モードを確認 stty -a - -# 標準入力の確認 python3 -c "import sys; print(sys.stdin.isatty())" # SSH経由の場合 ssh -t user@host python3 -m azctl.cli menu + +# 256色端末を明示 +export TERM=xterm-256color +python3 -m azctl.cli menu ``` ### WiFi管理機能問題 @@ -1070,9 +1032,8 @@ python3 -m azctl.cli menu python3 -c " import logging logging.basicConfig(level=logging.DEBUG) -from azctl.menu import AzazelTUIMenu -menu = AzazelTUIMenu() -menu.run() +from azctl.tui_zero import run_menu +run_menu(lan_if='wlan0', wan_if='wlan1', start_menu=True) " ```