diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..97904b9 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,45 @@ +name: CI + +on: + push: + branches: [ main, feature/** ] + pull_request: + branches: [ main ] + +jobs: + test: + runs-on: ubuntu-latest + strategy: + matrix: + python-version: [3.11] + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v4 + with: + python-version: ${{ matrix.python-version }} + + - name: Create virtualenv (.venv) + run: | + python -m venv .venv + .venv/bin/python -m pip install --upgrade pip + + - name: Install project and test dependencies + run: | + # Install package in editable mode and test extras from pyproject + .venv/bin/pip install -e '.[test]' + # Pillow is optional in pyproject; ensure it is present for renderer tests + .venv/bin/pip install pillow + + - name: Run tests + run: .venv/bin/pytest -v + + - name: Upload pytest JUnit result (optional) + if: always() + uses: actions/upload-artifact@v4 + with: + name: pytest-log + path: .pytest_cache diff --git a/CHANGELOG.md b/CHANGELOG.md index 4c08e41..ba66589 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,27 @@ All notable changes to this project will be documented in this file. +## [3.0.0] - 2025-11-09 +### Added +- Dynamic WAN selection and runtime orchestration via `azctl wan-manager`: + - Evaluates candidate uplink interfaces and selects the healthiest WAN at boot and runtime. + - Writes health snapshots to `runtime/wan_state.json` (production path `/var/run/azazel/wan_state.json`); path can be overridden with `AZAZEL_WAN_STATE_PATH`. + - Candidate precedence: explicit CLI `--candidate` → `AZAZEL_WAN_CANDIDATES` env var (comma-separated) → `configs/network/azazel.yaml` (`interfaces.external`/`interfaces.wan`) → safe fallbacks. + - On WAN change, the manager reapplies traffic control (`bin/azazel-traffic-init.sh`), refreshes NAT, and restarts dependent services (Suricata, `azctl-unified`). + +- Universal runtime interface resolution for consumers: + - CLI/TUI, scripts, and services now prefer explicit CLI args → environment variables (`AZAZEL_WAN_IF` / `AZAZEL_LAN_IF`) → WAN manager state → configuration values → final fallback. + - Added `AZAZEL_WAN_CANDIDATES` and `AZAZEL_WAN_STATE_PATH` environment variables for operational control and testing. + +### Changed +- Scripts and documentation updated to use parameterized interface references (`${AZAZEL_WAN_IF:-}` and `${AZAZEL_LAN_IF:-}`) in help text and examples. Where safe, runtime resolution now uses the WAN manager helper instead of hard-coded interface names. + +### Notes +- Backwards-compatible: explicit CLI flags and environment variables still override runtime selection. Existing deployments should continue to work; review scripts that assume literal interface names before automating deployment. +- Tests and shell syntax checks were run after edits; no regressions detected in the unit test suite. +- QoS features are opt-in via systemd service enablement. +- All changes maintain backward compatibility with existing configurations. + ## [2.2.0] - 2025-11-07 ### Added - **Internal Network QoS Control**: Comprehensive privilege-based traffic shaping and security enforcement for LAN devices. @@ -20,6 +41,7 @@ All notable changes to this project will be documented in this file. ### Changed - QoS scripts support DRY_RUN mode (print commands without execution, no root required). - All QoS scripts are idempotent (safe to re-run). + - Dynamic WAN selection: `wan-manager` now determines the active WAN interface at runtime and writes runtime/wan_state.json. Consumers (CLI, TUI, scripts) will use that selection by default when `--wan-if` is omitted. Environment variables `AZAZEL_WAN_IF` and `AZAZEL_LAN_IF` may be used to override defaults where needed. ### Security - MAC address verification prevents ARP spoofing for privileged devices. @@ -34,8 +56,6 @@ All notable changes to this project will be documented in this file. ### Notes - Minor version bump (2.1.0 → 2.2.0) adds significant new QoS feature without breaking existing functionality. -- QoS features are opt-in via systemd service enablement. -- All changes maintain backward compatibility with existing configurations. ## [2.1.0] - 2025-11-07 ### Added @@ -84,6 +104,18 @@ Semantic versioning: MAJOR.MINOR.PATCH. Deprecations queued for removal after at ## [1.0.0] - 2025-10-05 ### Initial release - Initial public baseline of Azazel-Pi with core features: + +## [3.1.0] - 2025-11-09 +### Added +- Display: clear and force a full E-Paper refresh when the active WAN interface changes (e.g. eth0 -> wlan1) to avoid ghosting and show the updated interface/IP immediately. (commit 478b8ee) +- Status collection: prefer kernel default route when runtime WAN state is missing and provide a `wan_state_path` injection point for testing/overrides. +- Renderer: improve network line formatting by removing the redundant "WAN" prefix and suppressing non-actionable "[WAN] unknown" messages; reserve footer area to prevent text overlap. + +### Changed +- Backwards-compatible `StatusCollector` initialization handling in `epd_daemon` — older installs without the new `wan_state_path` parameter are tolerated. + +### Notes +- These are backward-compatible improvements (minor release). See commit 478b8ee for details and files changed: `azazel_pi/core/display/status_collector.py`, `epd_daemon.py`, `renderer.py`. - Suricata integration for network threat detection - AI-based threat evaluation pipeline and scoring - Basic TUI and CLI utilities for status and control diff --git a/README.md b/README.md index 4853999..7d4cf03 100644 --- a/README.md +++ b/README.md @@ -145,8 +145,18 @@ Lightweight configuration optimized for Raspberry Pi, enabling rapid deployment After cloning the repository or downloading a release, run the complete automated installer: ```bash -cd Azazel-Pi -# Complete installation with all dependencies and configurations +# Launch TUI menu. If you omit --wan-if the CLI will dynamically resolve the WAN +# interface using the WAN manager (recommended). You can also force an interface +# via the AZAZEL_WAN_IF / AZAZEL_LAN_IF environment variables. +# Example: prefer runtime selection — WAN will be resolved automatically when omitted. +# You can override the detected interfaces with environment variables: +# export AZAZEL_LAN_IF=${AZAZEL_LAN_IF:-wlan0} +# export AZAZEL_WAN_IF=${AZAZEL_WAN_IF:-wlan1} +# then run the CLI without the --wan-if flag if you want the runtime helper to pick the WAN. +python3 -m azctl.cli menu --lan-if ${AZAZEL_LAN_IF:-wlan0} +# or: omit --wan-if to let the system choose the active WAN interface +python3 -m azctl.cli menu --lan-if ${AZAZEL_LAN_IF:-wlan0} +``` sudo scripts/install_azazel_complete.sh --start # Or step-by-step installation: @@ -203,6 +213,35 @@ sudo systemctl enable --now azazel-epd.service See [`docs/en/EPD_SETUP.md`](docs/en/EPD_SETUP.md) for complete E-Paper configuration instructions. +## Running tests (developer) + +This project uses a local virtual environment at `.venv` for development tests. To run the unit tests that exercise E-Paper rendering in emulation mode, do the following: + +1. Activate or create the virtual environment (example): + +```bash +python3 -m venv .venv +source .venv/bin/activate +pip install -U pip +pip install -r requirements-dev.txt +``` + +2. Install optional dependencies used by E-Paper rendering (Pillow) if not included in `requirements-dev.txt`: + +```bash +pip install pillow +``` + +3. Run tests (example): + +```bash +.venv/bin/pytest tests/core/test_epd_daemon.py -q +``` + +Notes: +- The E-Paper renderer supports `--emulate` which avoids hardware access and writes a PNG file when run in `--mode test`. +- Use `--wan-state-path` to point the renderer/collector at a custom WAN state file for integration testing. + ### Optional: Front Mattermost with Nginx To serve Mattermost via Nginx reverse proxy (recommended), use the provided template and setup script: @@ -229,7 +268,7 @@ The interactive Terminal User Interface (TUI) menu provides comprehensive system python3 -m azctl.cli menu # With specific interface configuration -python3 -m azctl.cli menu --lan-if wlan0 --wan-if wlan1 +python3 -m azctl.cli menu --lan-if ${AZAZEL_LAN_IF:-wlan0} --wan-if ${AZAZEL_WAN_IF:-wlan1} ``` **Modular Architecture:** @@ -293,11 +332,16 @@ echo '{"mode": "lockdown"}' | azctl events --config - The modular TUI menu provides comprehensive system management: ```bash -# Launch modular TUI menu +# Launch modular TUI menu. 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 -# Specify custom interfaces -python3 -m azctl.cli menu --lan-if wlan0 --wan-if wlan1 +# Specify custom interfaces (explicit override) +python3 -m azctl.cli menu --lan-if ${AZAZEL_LAN_IF:-wlan0} --wan-if ${AZAZEL_WAN_IF:-wlan1} + +# Or let the system choose WAN automatically: +python3 -m azctl.cli menu --lan-if ${AZAZEL_LAN_IF:-wlan0} ``` **Menu Features:** @@ -386,7 +430,12 @@ python3 -m azctl.cli menu --lan-if wlan0 --wan-if wlan1 ### 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`). - - By default, `wlan0` is treated as the internal LAN (AP), and both `wlan1` and `eth0` are considered external (WAN/uplink) interfaces. See `interfaces.external: ["eth0", "wlan1"]` in `configs/network/azazel.yaml` and adjust as needed. + - Interface defaults: `${AZAZEL_LAN_IF:-wlan0}` is typically treated as the internal LAN (AP); `${AZAZEL_WAN_IF:-wlan1}` and `${AZAZEL_WAN_IF:-eth0}` are common external (WAN/uplink) candidates and are listed under `interfaces.external` in `configs/network/azazel.yaml`. + Note: Azazel now prefers a runtime WAN selection produced by the WAN manager when `--wan-if` is not provided. To explicitly override the chosen interfaces, set the environment variables `AZAZEL_WAN_IF` and/or `AZAZEL_LAN_IF` before running commands or scripts. + - Override options: + - CLI: pass `--lan-if` and/or `--wan-if` to `azctl` commands to explicitly set interfaces. + - Environment: set `AZAZEL_LAN_IF` or `AZAZEL_WAN_IF` to change defaults for scripts and services. + - Dynamic: if `--wan-if` is omitted, `azctl` will query the WAN manager (recommended) to pick the active WAN interface based on runtime health checks. 2. **Generate Suricata Rules**: Use `scripts/suricata_generate.py` to render environment-specific IDS configurations @@ -396,6 +445,32 @@ python3 -m azctl.cli menu --lan-if wlan0 --wan-if wlan1 5. **Monitor Operations**: Analyze scoring results in `decisions.log` and use `azctl` for manual mode switching during incidents +### Dynamic WAN Selection (NEW) + +- The `azctl wan-manager` service evaluates all candidate WAN interfaces (from `interfaces.external`) after boot and continuously during runtime. +- Health snapshots (link status, IP presence, estimated speed) are written to `runtime/wan_state.json` (or `/var/run/azazel/wan_state.json` on deployed systems) and surfaced on the E-Paper display. You can override the default path with the `AZAZEL_WAN_STATE_PATH` environment variable when testing or for non-standard deployments. +- The WAN manager reads candidate lists in order of precedence: explicit CLI `--candidate` arguments, the `AZAZEL_WAN_CANDIDATES` environment variable (comma-separated), values declared in `configs/network/azazel.yaml` (`interfaces.external` or `interfaces.wan`), then safe fallbacks. Use `AZAZEL_WAN_CANDIDATES` to force a specific candidate ordering without changing config files. +- When the active interface changes, the manager reapplies `bin/azazel-traffic-init.sh`, refreshes NAT (`iptables -t nat`), and restarts dependent services (Suricata and `azctl-unified`) so they immediately consume the new interface. +- Suricata now launches through `azazel_pi.core.network.suricata_wrapper`, which reads the same WAN state file, so restarting the service is sufficient to follow the latest selection. + +Developer note — non-root testing and fallback behavior + +- The WAN manager will attempt to write the runtime state file to a system runtime path (for example `/var/run/azazel/wan_state.json`) when running as a system service. On systems where the process does not have permission to create `/var/run/azazel`, the manager now falls back automatically to a repository-local path `runtime/wan_state.json` so developers can run and test `azctl wan-manager` without root. +- For explicit control in tests or non-standard deployments, set `AZAZEL_WAN_STATE_PATH` to a writable path before running the manager. Example (development): + +```bash +# write state into the repository runtime directory (no root required) +AZAZEL_WAN_STATE_PATH=runtime/wan_state.json python3 -m azctl.cli wan-manager --once +``` + +- For production systems, run the WAN manager via systemd (root) so that traffic-init, iptables/nft, and service restarts run with the required privileges. Example (recommended for deployed systems): + +```bash +sudo systemctl enable --now azazel-wan-manager.service +``` + +These options allow safe developer testing while preserving the intended privileged behavior in production. + ### Defensive Mode Operations - **Portal Mode**: Baseline monitoring with minimal network impact diff --git a/README_ja.md b/README_ja.md index 49da979..5032734 100644 --- a/README_ja.md +++ b/README_ja.md @@ -202,7 +202,11 @@ sudo systemctl enable --now azazel-epd.service python3 -m azctl.cli menu # 特定のインターフェース設定で起動 -python3 -m azctl.cli menu --lan-if wlan0 --wan-if wlan1 +# 例: 実行時のWAN選択を優先します。`--wan-if` を省略した場合、WANマネージャが既定値を選択します。 +# 必要に応じて環境変数で上書きできます: +# export AZAZEL_LAN_IF=${AZAZEL_LAN_IF:-wlan0} +# export AZAZEL_WAN_IF=${AZAZEL_WAN_IF:-wlan1} +python3 -m azctl.cli menu --lan-if ${AZAZEL_LAN_IF:-wlan0} ``` **モジュラーアーキテクチャ:** @@ -283,13 +287,14 @@ echo '{"mode": "lockdown"}' | azctl events --config - python3 -m azctl.cli menu # カスタムインターフェースを指定 -python3 -m azctl.cli menu --lan-if wlan0 --wan-if wlan1 +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"]` を定義済みです(必要に応じて変更可能)。 + - 既定では `wlan0` を内部LAN(AP)、`wlan1` と `eth0` を外部(WAN/アップリンク)として扱います。`configs/network/azazel.yaml` の `interfaces.external` に `["eth0", "wlan1"]` を定義済みです(必要に応じて変更可能)。 + 注: `--wan-if` を指定しない場合、WAN 管理コンポーネントがランタイムで最適な WAN インターフェイスを選択します。明示的に指定したい場合は `AZAZEL_WAN_IF` / `AZAZEL_LAN_IF` を環境変数で設定してください。 2. **Suricataルール生成**: `scripts/suricata_generate.py` を使用して環境固有のIDS設定をレンダリング @@ -412,6 +417,33 @@ azctl/menu/ - [`docs/ja/API_REFERENCE.md`](docs/ja/API_REFERENCE.md) — Pythonモジュールとスクリプトリファレンス - [`docs/ja/SURICATA_INSTALLER.md`](docs/ja/SURICATA_INSTALLER.md) — Suricataインストールと設定詳細 +#### 動的WAN切り替え(新機能) + +- `azctl wan-manager` サービスが `interfaces.external` に列挙されたインターフェースを順番にヘルスチェックし、起動直後と運用中の両方で最も安定した WAN を自動選択します。 +- 選定結果と各インターフェースの状態は `runtime/wan_state.json`(本番では `/var/run/azazel/wan_state.json`)に記録され、E-Paper 画面にも「再設定中」「WAN切替完了」といったメッセージで表示されます。テストやカスタム配置では `AZAZEL_WAN_STATE_PATH` 環境変数で状態ファイルの場所を上書きできます。 +- WAN マネージャは候補の読み取り順序(優先順位)を持ちます: 明示的な CLI の `--candidate` → `AZAZEL_WAN_CANDIDATES` 環境変数(カンマ区切り)→ `configs/network/azazel.yaml` の `interfaces.external` / `interfaces.wan` → フォールバック。設定ファイルを直接編集せずに候補順序を制御したい環境では `AZAZEL_WAN_CANDIDATES` を利用してください。 +- 切り替え時には `bin/azazel-traffic-init.sh`、NAT (`iptables -t nat`) を再適用し、Suricata と `azctl-unified` を順次再起動して即座に新しいインターフェースを利用させます。 +- Suricata は `azazel_pi.core.network.suricata_wrapper` を経由して起動するため、サービス再起動だけで常に最新の WAN 状態を参照できます。 + +開発者向けメモ — 非 root 環境でのテストとフォールバック動作 + +- WAN マネージャは通常システムのランタイムパス(例: `/var/run/azazel/wan_state.json`)へ状態を書き込みます。systemd 等で root 権限で実行される本番環境ではこれが期待どおりに動作します。 +- 一方で開発や CI 環境などでプロセスに `/var/run/azazel` を作成する権限が無い場合、現在の実装は自動的にリポジトリ内の `runtime/wan_state.json` にフォールバックするため、非 root ユーザーでも `azctl wan-manager` を実行して動作確認ができます。 +- 明示的に書き込み先を指定したい場合は `AZAZEL_WAN_STATE_PATH` 環境変数を設定してください(開発例): + +```bash +# リポジトリ内の runtime ディレクトリに状態を書き込む(root 不要) +AZAZEL_WAN_STATE_PATH=runtime/wan_state.json python3 -m azctl.cli wan-manager --once +``` + +- 本番運用では systemd(root)経由で WAN マネージャを動かすことを推奨します。これにより `tc`/`iptables`(または `nft`)やサービス再起動など、特権を要する処理が正しく行われます。例(推奨): + +```bash +sudo systemctl enable --now azazel-wan-manager.service +``` + +これにより、開発者は権限に縛られずにローカルで動作確認ができ、本番では特権を持った実行で期待どおりの自動適用が行われます。 + ## 開発の背景 現代のサイバー攻撃はますます高速化・自動化されており、従来のハニーポットでは不十分です。このシステムは **観察やブロックではなく戦術的遅延** を目的として設計されており、時間を防御資産として活用します。 diff --git a/azazel_pi/core/display/epd_daemon.py b/azazel_pi/core/display/epd_daemon.py index e4b7fa2..e57d0f8 100755 --- a/azazel_pi/core/display/epd_daemon.py +++ b/azazel_pi/core/display/epd_daemon.py @@ -25,33 +25,43 @@ class EPaperDaemon: def __init__( self, update_interval: int = 10, + *, state_machine_path: Path | None = None, events_log: Path | None = None, - gentle_updates: bool = True, - full_refresh_minutes: int = 30, + wan_state_path: Path | None = None, + gentle_updates: bool = True, + full_refresh_minutes: int = 30, debug: bool = False, emulate: bool = False, rotation: int = 0, power_save: bool = False, - ): + ) -> None: """Initialize the EPaperDaemon. - Args: - update_interval: Seconds between display updates - state_machine_path: Optional path to state machine config - events_log: Path to events.json log file - gentle_updates: Use partial updates to reduce flicker - debug: Enable debug logging - emulate: Emulation mode (no physical display required) - rotation: Display rotation in degrees (0/90/180/270) - power_save: If True, put the EPD to sleep after each update + Keyword args (aside from positional `update_interval`): + update_interval: Seconds between display updates (positional) + state_machine_path: Optional path to a state-machine config used + to construct an internal StateMachine instance (if present). + events_log: Path to events.json for alert counting (defaults to + /var/log/azazel/events.json when not provided to StatusCollector). + wan_state_path: Optional explicit path to the WAN state JSON file + (overrides $AZAZEL_WAN_STATE_PATH and other fallbacks). + gentle_updates: Use partial/fast updates to reduce flicker (default True). + full_refresh_minutes: Perform a non-partial full refresh every N minutes + to reduce E-Paper ghosting. Set 0 to disable. + debug: Enable debug-level logging and additional trace output. + emulate: Emulation mode (does not require physical E-Paper hardware). + rotation: Display rotation in degrees (0, 90, 180, 270). + power_save: If True, attempt to sleep the EPD after each update + (may increase chance of SPI/device races; default False). """ - self.update_interval = update_interval - self.gentle_updates = gentle_updates - self.debug = debug - self.emulate = emulate + # Core runtime configuration + self.update_interval = int(update_interval) + self.gentle_updates = bool(gentle_updates) + self.debug = bool(debug) + self.emulate = bool(emulate) self.running = False - self.power_save = power_save + self.power_save = bool(power_save) # Periodic full-refresh interval in minutes. If > 0, the daemon will # perform a full (non-gentle) refresh every `full_refresh_minutes` # minutes to reduce E-Paper ghosting from repeated partial updates. @@ -69,7 +79,7 @@ def __init__( ) self.logger = logging.getLogger(__name__) - # Initialize state machine (optional, for mode/score tracking) + # Initialize state machine (optional, for mode/score tracking). self.state_machine = None if state_machine_path and Path(state_machine_path).exists(): try: @@ -81,11 +91,23 @@ def __init__( except Exception as e: self.logger.warning(f"Could not load state machine: {e}") - # Initialize status collector - self.collector = StatusCollector( - state_machine=self.state_machine, - events_log=events_log, - ) + # Initialize status collector (allow explicit wan_state_path for testing). + # Some installed copies of StatusCollector may not accept the + # wan_state_path kwarg (older deployments). Attempt to pass the + # argument but fall back to calling without it for compatibility. + try: + self.collector = StatusCollector( + state_machine=self.state_machine, + events_log=events_log, + wan_state_path=wan_state_path, + ) + except TypeError: + # Backwards-compatible fallback for older StatusCollector API + self.logger.debug("StatusCollector.__init__ does not accept wan_state_path; using fallback call") + self.collector = StatusCollector( + state_machine=self.state_machine, + events_log=events_log, + ) # Initialize renderer (support rotation) try: @@ -93,6 +115,16 @@ def __init__( except Exception: self.rotation = 0 self.renderer = EPaperRenderer(debug=debug, emulate=emulate, rotation=self.rotation) + # Track last seen WAN state so we can detect interface-driven + # transitions (e.g. when WAN manager sets status to "reconfiguring"). + # On such transitions we clear the E-Paper to a clean white state + # before rendering the next update to avoid flicker/ghosting. + self._last_wan_state: str | None = None + # Track last seen active interface so we can detect when the + # active WAN interface switches (e.g. eth0 -> wlan1). When this + # happens perform a short clear + force a full refresh to avoid + # ghosting/artifacts from partial updates. + self._last_interface: str | None = None # Set up signal handlers signal.signal(signal.SIGINT, self._signal_handler) @@ -107,7 +139,12 @@ def _signal_handler(self, signum: int, frame) -> None: env_ps_bool = str(env_ps).lower() in ("1", "true", "yes") except Exception: env_ps_bool = False - self.power_save = power_save or env_ps_bool + # Ensure we reference the instance attribute; avoid NameError when + # a signal arrives. + try: + self.power_save = bool(self.power_save) or env_ps_bool + except Exception: + self.power_save = env_ps_bool self.logger.info(f"Received signal {signum}, shutting down...") self.running = False @@ -133,6 +170,45 @@ def run(self) -> int: status = self.collector.collect() update_count += 1 + # If WAN state transitioned to a reconfiguration event, + # clear the display briefly before rendering the updated + # status. This helps avoid artifacts when the active + # interface changes (WAN manager writes 'reconfiguring' + # into the runtime WAN state during switch). Force a + # full refresh for this update to ensure a clean output. + force_full_refresh = False + try: + current_wan_state = getattr(status.network, "wan_state", None) + current_interface = getattr(status.network, "interface", None) + if self._last_wan_state != current_wan_state and current_wan_state == "reconfiguring": + self.logger.info("WAN reconfiguration detected: clearing display before update") + try: + # Best-effort clear — ignore hardware errors here. + self.renderer.clear() + except Exception as e: + self.logger.debug(f"Display clear failed: {e}") + force_full_refresh = True + # If the active interface changed (e.g. eth0 -> wlan1), + # perform a clear + force full refresh so the new + # interface/IP is shown cleanly. Skip on initial run + # when we don't have a previous value. + if ( + current_interface is not None + and self._last_interface is not None + and current_interface != self._last_interface + ): + self.logger.info( + f"WAN interface changed: {self._last_interface} -> {current_interface}; clearing display" + ) + try: + self.renderer.clear() + except Exception as e: + self.logger.debug(f"Display clear on interface change failed: {e}") + force_full_refresh = True + except Exception: + # Conservative: if any error occurs, don't prevent normal update + pass + self.logger.debug( f"Update #{update_count}: mode={status.security.mode}, " f"score={status.security.score_average:.1f}, " @@ -164,6 +240,11 @@ def run(self) -> int: # epdconfig.module_exit() (closing the SPI device) which can # race with subsequent updates and produce "Bad file descriptor". # Keep the display initialized and only sleep on shutdown. + # If we detected a WAN reconfiguration transition, force a + # full (non-gentle) refresh for this specific update so the + # display shows a clean, consistent output. + if force_full_refresh: + gentle = False self.renderer.display(image, gentle=gentle) # Optionally put the module to sleep after each update when @@ -178,7 +259,15 @@ def run(self) -> int: import traceback traceback.print_exc() - + # Remember last seen WAN state for next iteration's transition detection + try: + self._last_wan_state = getattr(status.network, "wan_state", None) + try: + self._last_interface = getattr(status.network, "interface", None) + except Exception: + pass + except Exception: + pass # Wait for next update (with early exit on shutdown) for _ in range(self.update_interval): if not self.running: @@ -274,6 +363,17 @@ def main() -> int: default=int(os.getenv("EPD_FULL_REFRESH_MINUTES", "30")), help="Perform a full (non-partial) refresh every N minutes to reduce e-paper ghosting. Set 0 to disable (default: 30)", ) + parser.add_argument( + "--wan-state-path", + type=Path, + help="Optional path to WAN state JSON file (overrides AZAZEL_WAN_STATE_PATH)", + ) + parser.add_argument( + "--emulate-output", + type=Path, + default=Path("/tmp/azazel_epd_test.png"), + help="When in --mode test and --emulate is set, save the output image to this path", + ) args = parser.parse_args() @@ -289,7 +389,7 @@ def main() -> int: return 0 if args.mode == "test": - collector = StatusCollector(events_log=args.events_log) + collector = StatusCollector(events_log=args.events_log, wan_state_path=args.wan_state_path) renderer = EPaperRenderer(debug=args.debug, emulate=args.emulate, rotation=args.rotate) status = collector.collect() image = renderer.render_status(status) @@ -302,7 +402,7 @@ def main() -> int: # (apply the renderer rotation before saving so test output matches # what hardware would receive). if args.emulate: - output_path = "/tmp/azazel_epd_test.png" + output_path = str(args.emulate_output) try: rot = getattr(renderer, "rotation", 0) if rot: @@ -327,6 +427,7 @@ def main() -> int: power_save=args.power_save, state_machine_path=args.state_config, events_log=args.events_log, + wan_state_path=args.wan_state_path, gentle_updates=not args.no_gentle, debug=args.debug, emulate=args.emulate, diff --git a/azazel_pi/core/display/renderer.py b/azazel_pi/core/display/renderer.py index a27a9f4..ada1af1 100644 --- a/azazel_pi/core/display/renderer.py +++ b/azazel_pi/core/display/renderer.py @@ -186,10 +186,12 @@ def render_status(self, status: SystemStatus) -> Image.Image: draw = ImageDraw.Draw(img) # Fonts - # Use a slightly larger title font so the header is more readable. - title_font = self._pick_font(TITLE_FONT_CANDIDATES, 20) + # Use a slightly larger title font so the header is readable. Keep + # conservative sizing to avoid layout overflow on small displays. + title_font = self._pick_font(TITLE_FONT_CANDIDATES, 18) header_font = self._pick_font(MONO_FONT_CANDIDATES, 14) body_font = self._pick_font(MONO_FONT_CANDIDATES, 12) + small_font = self._pick_font(MONO_FONT_CANDIDATES, 10) y = 2 @@ -236,128 +238,32 @@ def render_status(self, status: SystemStatus) -> Image.Image: draw.line([(0, y), (self.width, y)], fill=0, width=1) y += 4 - # Network status: choose a single interface to show when multiple - # interfaces are active. Rules: - # 1) If primary interface (from StatusCollector) and wlan1 are in the - # same /24 network, prefer the primary interface (e.g., eth0). - # 2) If they are in different networks, prefer the interface with the - # higher reported link speed (attempt /sys, ethtool or iw), falling - # back to the primary interface on error. net_icon = "●" if status.network.is_up else "○" primary_iface = status.network.interface ip_text = status.network.ip_address or "No IP" - - def _get_iface_ip(iface: str) -> str | None: - try: - import subprocess - - res = subprocess.run( - ["ip", "-4", "addr", "show", iface], - capture_output=True, - text=True, - timeout=0.5, - check=False, - ) - for line in res.stdout.splitlines(): - if "inet " in line: - parts = line.strip().split() - if len(parts) >= 2: - return parts[1].split("/")[0] - except Exception: - pass - return None - - def _same_subnet_a_b(ip_a: str | None, ip_b: str | None) -> bool: - # Reasonable default: compare /24 prefixes for LANs. If either is - # missing, treat as different. - try: - if not ip_a or not ip_b: - return False - a_parts = ip_a.split(".") - b_parts = ip_b.split(".") - return a_parts[0:3] == b_parts[0:3] - except Exception: - return False - - def _get_iface_speed_mbps(iface: str) -> int | None: - # Try to read /sys/class/net//speed (works for many wired) - try: - p = Path(f"/sys/class/net/{iface}/speed") - if p.exists(): - val = p.read_text().strip() - if val and val != "unknown": - return int(val) - except Exception: - pass - - # Try ethtool (may require package installed). Parse 'Speed: 1000Mb/s' - try: - import subprocess, re - - res = subprocess.run(["ethtool", iface], capture_output=True, text=True, timeout=0.8, check=False) - for line in res.stdout.splitlines(): - if "Speed:" in line: - m = re.search(r"Speed:\s*([0-9]+)\s*Mb/s", line) - if m: - return int(m.group(1)) - except Exception: - pass - - # For Wi-Fi, try 'iw dev link' and parse 'tx bitrate:' - try: - import subprocess, re - - res = subprocess.run(["iw", "dev", iface, "link"], capture_output=True, text=True, timeout=0.8, check=False) - for line in res.stdout.splitlines(): - if "tx bitrate" in line.lower() or "tx bitrate:" in line: - # line like: 'tx bitrate: 270.0 MBit/s' - m = re.search(r"([0-9]+(?:\.[0-9]+)?)\s*MBit/s", line) - if m: - return int(float(m.group(1))) - except Exception: - pass - - return None - - # Query wlan1 IP (best-effort) - wlan_ip = _get_iface_ip("wlan1") - - # Decide which interface to show - chosen_iface = primary_iface - chosen_ip = ip_text - try: - if wlan_ip: - # If both in same /24, prefer primary iface (likely eth0) - if _same_subnet_a_b(ip_text, wlan_ip): - chosen_iface = primary_iface - chosen_ip = ip_text - else: - # Different networks: compare speeds - try: - sp0 = _get_iface_speed_mbps(primary_iface) or 0 - except Exception: - sp0 = 0 - try: - sp1 = _get_iface_speed_mbps("wlan1") or 0 - except Exception: - sp1 = 0 - # If wlan faster, prefer wlan1; else prefer primary - if sp1 > sp0: - chosen_iface = "wlan1" - chosen_ip = wlan_ip - else: - chosen_iface = primary_iface - chosen_ip = ip_text - except Exception: - # On any error, fall back to primary - chosen_iface = primary_iface - chosen_ip = ip_text - - net_line = f"{net_icon} {chosen_iface}: {chosen_ip}" + # Show only the active interface and IP to keep the display concise; + # remove the literal "WAN" prefix which the user found redundant. + net_line = f"{net_icon} {primary_iface}: {ip_text}" net_line = self._fit_text(draw, net_line, body_font, self.width - 8) draw.text((4, y), net_line, font=body_font, fill=0) y += 16 + # Only display WAN status messages for meaningful states. Suppress + # the common/harmless 'unknown' state to avoid clutter. + if ( + status.network.wan_state + and status.network.wan_state not in ("ready", "unknown") + ): + warn_text = status.network.wan_message or status.network.wan_state + warn_line = self._fit_text( + draw, + f"{warn_text}", + small_font, + self.width - 8, + ) + draw.text((4, y), warn_line, font=small_font, fill=0) + y += 14 + # Alert counters alert_line = f"Alerts: {status.security.recent_alerts}/{status.security.total_alerts} (5m/total)" alert_line = self._fit_text(draw, alert_line, body_font, self.width - 8) @@ -385,7 +291,21 @@ def _get_iface_speed_mbps(iface: str) -> int | None: time_str = status.timestamp.strftime("%H:%M:%S") footer = f"Up {uptime_hours}h{uptime_mins}m | {time_str}" footer = self._fit_text(draw, footer, body_font, self.width - 8) - draw.text((4, self.height - 14), footer, font=body_font, fill=0) + # Reserve footer area to avoid content overlap: compute footer bbox + # and ensure we don't draw other content into this region. + try: + fbbox = draw.textbbox((0, 0), footer, font=body_font) + footer_height = fbbox[3] - fbbox[1] + except Exception: + footer_height = 12 + footer_y = self.height - footer_height - 2 + # If current y has already reached footer area, shift it up slightly + if y >= footer_y: + y = max(2, footer_y - 16) + draw.text((4, footer_y), footer, font=body_font, fill=0) + + # Prevent any accidental drawing beyond footer by returning image + # (all content should be complete at this point). return img diff --git a/azazel_pi/core/display/status_collector.py b/azazel_pi/core/display/status_collector.py index 855996d..74bd76e 100644 --- a/azazel_pi/core/display/status_collector.py +++ b/azazel_pi/core/display/status_collector.py @@ -5,10 +5,17 @@ import subprocess from dataclasses import dataclass from datetime import datetime, timezone +import os from pathlib import Path -from typing import Any, Dict, Optional +from typing import Any, Dict, Optional, Iterable from ..state_machine import StateMachine +from ...utils.wan_state import ( + InterfaceSnapshot, + WANState, + get_active_wan_interface, + load_wan_state, +) @dataclass @@ -20,6 +27,8 @@ class NetworkStatus: is_up: bool = False tx_bytes: int = 0 rx_bytes: int = 0 + wan_state: Optional[str] = None + wan_message: Optional[str] = None @dataclass @@ -52,15 +61,18 @@ def __init__( self, state_machine: Optional[StateMachine] = None, events_log: Optional[Path] = None, + wan_state_path: Optional[Path] = None, # 修正: wan_state_path を追加 ): """Initialize the status collector. Args: state_machine: Optional state machine instance for mode/score info events_log: Path to events.json for alert counting + wan_state_path: Optional path to WAN state JSON file """ self.state_machine = state_machine self.events_log = events_log or Path("/var/log/azazel/events.json") + self.wan_state_path = wan_state_path # 修正: wan_state_path を保存 def collect(self) -> SystemStatus: """Collect current system status.""" @@ -86,14 +98,47 @@ def _get_hostname(self) -> str: except Exception: return "azazel-pi" - def _get_network_status(self, interface: str = "eth0") -> NetworkStatus: + def _get_network_status(self, interface: Optional[str] = None) -> NetworkStatus: """Get network interface status.""" - status = NetworkStatus(interface=interface) + # Load WAN state, allowing an explicit path to override env/defaults. + wan_state = load_wan_state(path=self.wan_state_path) + env_iface = os.environ.get("AZAZEL_WAN_IF") + active_iface = wan_state.active_interface or interface or env_iface + if not active_iface: + try: + active_iface = get_active_wan_interface(default=env_iface or "wlan1") + except Exception: + active_iface = env_iface or "wlan1" + + # If the WAN manager did not provide an explicit active_interface + # and the environment did not force one, prefer the kernel's actual + # default route decision — this catches cases where the system's + # default route is via eth0 even though no wan_state file exists. + if (not wan_state.active_interface) and (env_iface is None): + route_iface = self._get_default_route_interface() + if route_iface: + active_iface = route_iface + + if not active_iface: + active_iface = "wlan1" + + # When multiple WAN candidates are up simultaneously (e.g. wlan1 + # and eth0), prefer the interface reporting the highest measured + # speed so the display reflects the interface users actually use. + # This mirrors operator expectations where a newly plugged-in + # faster link should immediately become the "primary" on the EPD + # even before state files are refreshed. + active_iface = self._prefer_fastest_candidate(wan_state, active_iface) + status = NetworkStatus( + interface=active_iface, + wan_state=wan_state.status, + wan_message=wan_state.message, + ) # Check if interface is up try: result = subprocess.run( - ["ip", "link", "show", interface], + ["ip", "link", "show", active_iface], capture_output=True, text=True, timeout=1, @@ -106,7 +151,7 @@ def _get_network_status(self, interface: str = "eth0") -> NetworkStatus: # Get IP address try: result = subprocess.run( - ["ip", "-4", "addr", "show", interface], + ["ip", "-4", "addr", "show", active_iface], capture_output=True, text=True, timeout=1, @@ -123,7 +168,7 @@ def _get_network_status(self, interface: str = "eth0") -> NetworkStatus: # Get traffic stats try: - stats_path = Path(f"/sys/class/net/{interface}/statistics") + stats_path = Path(f"/sys/class/net/{active_iface}/statistics") if stats_path.exists(): tx_path = stats_path / "tx_bytes" rx_path = stats_path / "rx_bytes" @@ -136,6 +181,82 @@ def _get_network_status(self, interface: str = "eth0") -> NetworkStatus: return status + def _get_default_route_interface(self) -> Optional[str]: + """Return the interface used for the default route (best-effort).""" + try: + result = subprocess.run( + ["ip", "route", "get", "1.1.1.1"], + capture_output=True, + text=True, + timeout=1, + check=False, + ) + except Exception: + return None + + out = (result.stdout or "").strip() + if not out: + return None + parts = out.split() + if "dev" not in parts: + return None + idx = parts.index("dev") + if idx + 1 >= len(parts): + return None + return parts[idx + 1] or None + + def _prefer_fastest_candidate(self, wan_state: WANState, current_iface: str) -> str: + """Return the interface representing the fastest healthy candidate.""" + fastest = self._fastest_candidate(wan_state.candidates) + if not fastest: + return current_iface + + if fastest.name == current_iface: + return current_iface + + current_snapshot = self._find_snapshot(wan_state.candidates, current_iface) + fastest_speed = fastest.speed_mbps or 0 + current_speed = (current_snapshot.speed_mbps or 0) if current_snapshot else -1 + + # If we have no data for the current interface or it is down, switch immediately. + if (not current_snapshot) or (not current_snapshot.link_up) or ( + not current_snapshot.ip_address + ): + return fastest.name + + if fastest_speed > current_speed: + return fastest.name + return current_iface + + @staticmethod + def _fastest_candidate( + candidates: Iterable[InterfaceSnapshot], + ) -> Optional[InterfaceSnapshot]: + """Pick the fastest candidate that is link-up and has an IP.""" + fastest: Optional[InterfaceSnapshot] = None + for snap in candidates: + if not snap.link_up or not snap.ip_address: + continue + if fastest is None: + fastest = snap + continue + snap_speed = snap.speed_mbps or 0 + fastest_speed = fastest.speed_mbps or 0 + if snap_speed > fastest_speed: + fastest = snap + return fastest + + @staticmethod + def _find_snapshot( + candidates: Iterable[InterfaceSnapshot], iface: Optional[str] + ) -> Optional[InterfaceSnapshot]: + if iface is None: + return None + for snap in candidates: + if snap.name == iface: + return snap + return None + def _get_security_status(self) -> SecurityStatus: """Get security monitoring status.""" mode = "portal" diff --git a/azazel_pi/core/enforcer/traffic_control.py b/azazel_pi/core/enforcer/traffic_control.py index f1591bc..3e23011 100644 --- a/azazel_pi/core/enforcer/traffic_control.py +++ b/azazel_pi/core/enforcer/traffic_control.py @@ -18,6 +18,8 @@ load_opencanary_ip, ensure_nft_table_and_chain, list_active_diversions, OPENCANARY_IP ) +from ...utils.wan_state import get_active_wan_interface +import os # ログ設定 try: @@ -53,7 +55,8 @@ class TrafficControlEngine: def __init__(self, config_path: Optional[str] = None): self.config_path = config_path or "/home/azazel/Azazel-Pi/configs/network/azazel.yaml" - self.interface = "wlan1" + # Respect AZAZEL_WAN_IF environment override first, then WAN manager helper + self.interface = os.environ.get("AZAZEL_WAN_IF") or get_active_wan_interface() self.active_rules: Dict[str, List[TrafficControlRule]] = {} self._ensure_tc_setup() @@ -558,4 +561,4 @@ def get_traffic_control_engine() -> TrafficControlEngine: else: print("✗ Failed to remove rules") else: - print("✗ Failed to apply shield mode") \ No newline at end of file + print("✗ Failed to apply shield mode") diff --git a/azazel_pi/core/network/suricata_wrapper.py b/azazel_pi/core/network/suricata_wrapper.py new file mode 100644 index 0000000..efd6eb8 --- /dev/null +++ b/azazel_pi/core/network/suricata_wrapper.py @@ -0,0 +1,29 @@ +"""Helper wrapper to launch Suricata with the currently selected WAN interface.""" +from __future__ import annotations + +import os +import sys +from typing import List + +from azazel_pi.utils.wan_state import get_active_wan_interface +import os + + +def build_command(iface: str) -> List[str]: + suricata_bin = os.environ.get("SURICATA_BIN", "/usr/bin/suricata") + config_path = os.environ.get("SURICATA_CONFIG", "/etc/suricata/suricata.yaml") + return [suricata_bin, "-c", config_path, "-i", iface] + + +def main() -> int: + # Preference: SURICATA_IFACE env -> AZAZEL_WAN_IF env -> WANManager helper + iface = os.environ.get("SURICATA_IFACE") or os.environ.get("AZAZEL_WAN_IF") or get_active_wan_interface() + if not iface: + print("suricata-wrapper: active WAN interface unknown", file=sys.stderr) + return 1 + cmd = build_command(iface) + os.execvp(cmd[0], cmd) # Replace the process; no return + + +if __name__ == "__main__": # pragma: no cover + raise SystemExit(main()) diff --git a/azazel_pi/core/network/wan_manager.py b/azazel_pi/core/network/wan_manager.py new file mode 100644 index 0000000..cf2d0df --- /dev/null +++ b/azazel_pi/core/network/wan_manager.py @@ -0,0 +1,475 @@ +"""Dynamic WAN interface selection and orchestration logic.""" +from __future__ import annotations + +import logging +import os +import subprocess +import time +from dataclasses import dataclass +from pathlib import Path +from typing import Dict, Iterable, List, Optional, Sequence, Tuple + +import yaml + +from azazel_pi.utils.wan_state import ( + InterfaceSnapshot, + WANState, + load_wan_state, + save_wan_state, + update_wan_state, +) + +LOG = logging.getLogger("azazel.wan_manager") + + +def _repo_root() -> Path: + # Path(__file__) -> .../azazel_pi/core/network/wan_manager.py + # parents indices: 0=network,1=core,2=azazel_pi,3= + return Path(__file__).resolve().parents[3] + + +def _now_iso() -> str: + from datetime import datetime, timezone + + return datetime.now(timezone.utc).isoformat() + + +@dataclass +class ProbeResult: + """Container describing single interface health check.""" + + name: str + exists: bool + link_up: bool + ip_address: Optional[str] + speed_mbps: Optional[int] + score: float + reason: str + + def to_snapshot(self) -> InterfaceSnapshot: + return InterfaceSnapshot( + name=self.name, + link_up=self.link_up, + ip_address=self.ip_address, + speed_mbps=self.speed_mbps, + score=self.score, + reason=self.reason, + last_checked=_now_iso(), + ) + + +class WANManager: + """Monitor candidate interfaces, select the healthiest WAN and reconfigure services.""" + + def __init__( + self, + *, + config_path: Optional[Path] = None, + candidates: Optional[Sequence[str]] = None, + poll_interval: float = 20.0, + lan_cidr: str = "172.16.0.0/24", + state_path: Optional[Path] = None, + services_to_restart: Optional[Sequence[str]] = None, + traffic_init_script: Optional[Path] = None, + ) -> None: + self.repo_root = _repo_root() + self.config_path = ( + Path(config_path) + if config_path + else Path("/etc/azazel/azazel.yaml") + ) + # Priority for candidates: + # 1) explicit candidates argument + # 2) AZAZEL_WAN_CANDIDATES env var (comma-separated) + # 3) config file (interfaces.external or interfaces.wan) + # 4) fallback to canonical list + if candidates: + self.candidates = list(candidates) + else: + env_cands = os.environ.get("AZAZEL_WAN_CANDIDATES") + if env_cands: + # parse comma/space separated list + parsed = [c.strip() for c in env_cands.replace(',', ' ').split() if c.strip()] + self.candidates = parsed + else: + self.candidates = self._load_candidates() + + if not self.candidates: + # As an absolute fallback, consider the two canonical interfaces + self.candidates = ["wlan1", "eth0"] + self.poll_interval = poll_interval + self.lan_cidr = lan_cidr + self.state_path = state_path + self.traffic_init_script = ( + Path(traffic_init_script) + if traffic_init_script + else self.repo_root / "bin" / "azazel-traffic-init.sh" + ) + self.services_to_restart = list( + services_to_restart + if services_to_restart + else ["suricata.service", "azctl-unified.service"] + ) + self.current_interface: Optional[str] = load_wan_state( + self.state_path + ).active_interface + + # ------------------------------------------------------------------ + # Public API + # ------------------------------------------------------------------ + def run(self, once: bool = False) -> int: + """Start monitoring loop (or run a single evaluation if once=True).""" + logging.basicConfig( + level=logging.INFO, + format="%(asctime)s [%(levelname)s] %(message)s", + ) + LOG.info( + "Starting WAN manager (candidates=%s, interval=%ss)", + ", ".join(self.candidates), + self.poll_interval, + ) + self._evaluate_cycle(initial=True) + if once: + return 0 + try: + while True: + time.sleep(self.poll_interval) + self._evaluate_cycle(initial=False) + except KeyboardInterrupt: + LOG.info("WAN manager stopped via signal") + return 0 + + # ------------------------------------------------------------------ + # Internal helpers + # ------------------------------------------------------------------ + def _load_candidates(self) -> List[str]: + """Read external interface candidates from config file.""" + try: + with self.config_path.open("r", encoding="utf-8") as handle: + config = yaml.safe_load(handle) or {} + except FileNotFoundError: + return [] + except Exception as exc: # pragma: no cover - config errors reported but ignored + LOG.warning("Failed to load %s: %s", self.config_path, exc) + return [] + + interfaces = config.get("interfaces", {}) + arr = interfaces.get("external") or [] + if isinstance(arr, list) and arr: + return [str(entry) for entry in arr] + # Fallback to explicit WAN entry if defined + if interfaces.get("wan"): + return [str(interfaces["wan"])] + return [] + + def _evaluate_cycle(self, *, initial: bool) -> None: + snapshots: List[ProbeResult] = [] + for iface in self.candidates: + snapshots.append(self._probe_interface(iface)) + best = self._choose_best(snapshots) + candidate_snapshots = [snap.to_snapshot() for snap in snapshots] + + if not best: + msg = "No viable WAN interface detected" + update_wan_state( + status="degraded", + message=msg, + candidates=candidate_snapshots, + active_interface=None, + path=self.state_path, + ) + LOG.warning(msg) + self.current_interface = None + return + + if self.current_interface != best.name: + LOG.info( + "Switching WAN interface from %s to %s (%s)", + self.current_interface or "none", + best.name, + best.reason, + ) + update_wan_state( + active_interface=best.name, + status="reconfiguring", + message=f"Applying network policy for {best.name}", + candidates=candidate_snapshots, + path=self.state_path, + ) + self._apply_interface(best.name) + update_wan_state( + active_interface=best.name, + status="ready", + message=f"{best.name} active ({best.reason})", + candidates=candidate_snapshots, + path=self.state_path, + ) + self.current_interface = best.name + else: + # Refresh state to confirm readiness + update_wan_state( + active_interface=best.name, + status="ready", + message=f"{best.name} healthy ({best.reason})", + candidates=candidate_snapshots, + path=self.state_path, + ) + if not initial: + LOG.debug("WAN interface %s remains active", best.name) + + def _probe_interface(self, iface: str) -> ProbeResult: + """Gather health data for the interface.""" + exists = False + link_up = False + ip_addr: Optional[str] = None + + try: + res = subprocess.run( + ["ip", "link", "show", iface], + capture_output=True, + text=True, + timeout=2, + check=False, + ) + exists = res.returncode == 0 + if exists: + link_up = "state UP" in res.stdout or "state UNKNOWN" in res.stdout + except Exception as exc: + LOG.debug("ip link show %s failed: %s", iface, exc) + + if exists: + try: + res = subprocess.run( + ["ip", "-4", "addr", "show", iface], + capture_output=True, + text=True, + timeout=2, + check=False, + ) + for line in res.stdout.splitlines(): + if "inet " in line: + parts = line.strip().split() + if len(parts) >= 2: + ip_addr = parts[1].split("/")[0] + break + except Exception as exc: + LOG.debug("ip addr show %s failed: %s", iface, exc) + + speed = self._determine_speed(iface) + score, reason = self._score_interface( + iface, + exists=exists, + link_up=link_up, + has_ip=ip_addr is not None, + speed_mbps=speed, + ) + + return ProbeResult( + name=iface, + exists=exists, + link_up=link_up, + ip_address=ip_addr, + speed_mbps=speed, + score=score, + reason=reason, + ) + + def _determine_speed(self, iface: str) -> Optional[int]: + """Try multiple strategies to estimate link speed (best-effort).""" + sysfs_path = Path(f"/sys/class/net/{iface}/speed") + if sysfs_path.exists(): + try: + val = sysfs_path.read_text().strip() + if val and val.isdigit(): + return int(val) + except Exception: + pass + + # ethtool fallback (mostly for wired NICs) + try: + res = subprocess.run( + ["ethtool", iface], + capture_output=True, + text=True, + timeout=2, + check=False, + ) + for line in res.stdout.splitlines(): + if "Speed:" in line and "Mb/s" in line: + tokens = "".join(ch for ch in line if ch.isdigit()) + if tokens: + return int(tokens) + except FileNotFoundError: + pass + except Exception: + pass + + # Wi-Fi bitrate via `iw` + try: + res = subprocess.run( + ["iw", "dev", iface, "link"], + capture_output=True, + text=True, + timeout=2, + check=False, + ) + for line in res.stdout.splitlines(): + if "tx bitrate" in line.lower(): + parts = line.split() + for idx, token in enumerate(parts): + if token.lower().startswith("mbit/s"): + try: + return int(float(parts[idx - 1])) + except Exception: + continue + except FileNotFoundError: + pass + except Exception: + pass + return None + + def _score_interface( + self, + iface: str, + *, + exists: bool, + link_up: bool, + has_ip: bool, + speed_mbps: Optional[int], + ) -> Tuple[float, str]: + """Generate a simple availability score and explanation.""" + reason_bits: List[str] = [] + score = 0.0 + if not exists: + return 0.0, "interface missing" + score += 20 + reason_bits.append("detected") + + if link_up: + score += 40 + reason_bits.append("link up") + else: + reason_bits.append("link down") + + if has_ip: + score += 25 + reason_bits.append("IP assigned") + else: + reason_bits.append("no IP") + + if speed_mbps: + score += min(speed_mbps, 1000) / 10.0 + reason_bits.append(f"{speed_mbps}Mbps") + + return score, ", ".join(reason_bits) + + def _choose_best(self, probes: Iterable[ProbeResult]) -> Optional[ProbeResult]: + best: Optional[ProbeResult] = None + for probe in probes: + if not probe.exists: + continue + if best is None or probe.score > best.score: + best = probe + elif best and probe.score == best.score: + # Tie-breaker: prefer interface with IP, then higher speed + if probe.ip_address and not (best.ip_address): + best = probe + elif ( + probe.speed_mbps or 0 + ) > (best.speed_mbps or 0): + best = probe + return best + + def _apply_interface(self, iface: str) -> None: + """Reconfigure traffic control, NAT, and dependent services.""" + self._ensure_traffic_control(iface) + self._reapply_nat(iface) + self._restart_services() + + def _ensure_traffic_control(self, iface: str) -> None: + if not self.traffic_init_script.exists(): + LOG.warning("Traffic init script %s missing", self.traffic_init_script) + return + env = os.environ.copy() + env["WAN_IF_OVERRIDE"] = iface + try: + subprocess.run( + [str(self.traffic_init_script)], + cwd=str(self.repo_root), + env=env, + check=True, + text=True, + ) + LOG.info("Re-applied traffic control on %s", iface) + except subprocess.CalledProcessError as exc: + LOG.error("Traffic control initialization failed: %s", exc) + + def _reapply_nat(self, iface: str) -> None: + try: + subprocess.run(["iptables", "-t", "nat", "-F"], check=True) + subprocess.run( + [ + "iptables", + "-t", + "nat", + "-A", + "POSTROUTING", + "-s", + self.lan_cidr, + "-o", + iface, + "-j", + "MASQUERADE", + ], + check=True, + ) + LOG.info("NAT POSTROUTING updated for %s", iface) + except FileNotFoundError: + LOG.warning("iptables not available; skipping NAT reapply") + except subprocess.CalledProcessError as exc: + LOG.error("Failed to apply NAT rule: %s", exc) + + def _restart_services(self) -> None: + for svc in self.services_to_restart: + try: + subprocess.run( + ["systemctl", "try-restart", svc], + check=False, + timeout=30, + ) + LOG.info("Triggered restart for %s", svc) + except FileNotFoundError: + LOG.warning("systemctl not found while restarting %s", svc) + except Exception as exc: + LOG.error("Failed to restart %s: %s", svc, exc) + + +def main() -> int: + import argparse + + parser = argparse.ArgumentParser(description="Dynamic WAN manager") + parser.add_argument("--config", help="Path to azazel.yaml", default=None) + parser.add_argument( + "--candidate", + action="append", + dest="candidates", + help="Explicit WAN candidate (can be repeated)", + ) + parser.add_argument("--interval", type=float, default=20.0, help="Polling interval seconds") + parser.add_argument("--lan-cidr", default="172.16.0.0/24") + parser.add_argument("--state-file", help="Override WAN state file path") + parser.add_argument("--once", action="store_true", help="Run a single evaluation and exit") + args = parser.parse_args() + + manager = WANManager( + config_path=Path(args.config) if args.config else None, + candidates=args.candidates, + poll_interval=args.interval, + lan_cidr=args.lan_cidr, + state_path=Path(args.state_file) if args.state_file else None, + ) + return manager.run(once=args.once) + + +if __name__ == "__main__": # pragma: no cover + raise SystemExit(main()) diff --git a/azazel_pi/monitor/run_all.py b/azazel_pi/monitor/run_all.py index fe73b31..8f5165f 100644 --- a/azazel_pi/monitor/run_all.py +++ b/azazel_pi/monitor/run_all.py @@ -9,6 +9,8 @@ from ..core import notify_config as notice from ..core.enforcer.traffic_control import get_traffic_control_engine from ..utils.mattermost import send_alert_to_mattermost +import os +from ..utils.wan_state import get_active_wan_interface from . import main_suricata from . import main_opencanary @@ -42,6 +44,8 @@ def notify_attack_detected(): def reset_network_config(): logging.info("Flushing NAT rules and resetting network config via integrated system...") + # Prefer explicit environment override, then runtime WAN manager helper, then fallback + wan_iface = os.environ.get("AZAZEL_WAN_IF") or get_active_wan_interface() # ① 統合トラフィック制御システムで全制御ルールをクリア try: @@ -62,9 +66,9 @@ def reset_network_config(): except Exception as e: logging.error(f"Integrated system cleanup failed: {e}") # フォールバック: 従来のtc直接実行 - result = subprocess.run(["tc", "qdisc", "show", "dev", "wlan1"], capture_output=True, text=True) + result = subprocess.run(["tc", "qdisc", "show", "dev", wan_iface], capture_output=True, text=True) if "prio" in result.stdout or "netem" in result.stdout: - subprocess.run(["tc", "qdisc", "del", "dev", "wlan1", "root"], check=False) + subprocess.run(["tc", "qdisc", "del", "dev", wan_iface, "root"], check=False) logging.info("Fallback: tc qdisc deleted directly") # ② NATテーブルの全ルールを一旦削除 @@ -72,7 +76,7 @@ def reset_network_config(): # ③ 内部LAN(172.16.0.0/24)からWAN出口(wlan1)へのMASQUERADEを再設定 subprocess.run(["iptables", "-t", "nat", "-A", "POSTROUTING", - "-s", "172.16.0.0/24", "-o", "wlan1", "-j", "MASQUERADE"], check=True) + "-s", "172.16.0.0/24", "-o", wan_iface, "-j", "MASQUERADE"], check=True) logging.info("Internal LAN to WAN routing re-established.") logging.info("Network reset completed via integrated system.") diff --git a/azazel_pi/utils/wan_state.py b/azazel_pi/utils/wan_state.py new file mode 100644 index 0000000..c01b666 --- /dev/null +++ b/azazel_pi/utils/wan_state.py @@ -0,0 +1,186 @@ +"""Helper utilities for reading/writing active WAN interface state.""" +from __future__ import annotations + +import json +import os +from dataclasses import dataclass, field +from datetime import datetime, timezone +from pathlib import Path +from typing import Any, Dict, List, Optional + + +def _repo_root() -> Path: + """Best-effort detection of repository root for runtime fallback path.""" + return Path(__file__).resolve().parents[2] + + +def _candidate_state_paths() -> List[Path]: + """Return preferred search order for the WAN state file.""" + candidates: List[Path] = [] + env_path = os.environ.get("AZAZEL_WAN_STATE_PATH") + if env_path: + candidates.append(Path(env_path)) + + # Runtime paths used on deployed systems + candidates.append(Path("/var/run/azazel/wan_state.json")) + candidates.append(Path("/run/azazel/wan_state.json")) + + # Repository fallback for development environments + candidates.append(_repo_root() / "runtime" / "wan_state.json") + + # Deduplicate while preserving order + deduped: List[Path] = [] + for path in candidates: + if path not in deduped: + deduped.append(path) + return deduped + + +def resolve_state_path(create: bool = False) -> Path: + """Locate the WAN state file path, optionally ensuring its parent exists.""" + for path in _candidate_state_paths(): + if path.exists(): + return path + + # If nothing exists yet, return the first candidate and ensure parent dir if requested + target = _candidate_state_paths()[0] + if create: + try: + target.parent.mkdir(parents=True, exist_ok=True) + except PermissionError: + # If we cannot create the system path (e.g. /var/run) due to + # permission restrictions in development environments, fall + # back to a repository-local runtime path to allow non-root + # testing without failing. + repo_fallback = _repo_root() / "runtime" / "wan_state.json" + repo_fallback.parent.mkdir(parents=True, exist_ok=True) + return repo_fallback + return target + + +_UNSET = object() + + +@dataclass +class InterfaceSnapshot: + """Represents the most recent health snapshot for a WAN candidate.""" + + name: str + link_up: bool = False + ip_address: Optional[str] = None + speed_mbps: Optional[int] = None + score: float = 0.0 + reason: Optional[str] = None + last_checked: str = field( + default_factory=lambda: datetime.now(timezone.utc).isoformat() + ) + + @classmethod + def from_dict(cls, data: Dict[str, Any]) -> "InterfaceSnapshot": + return cls( + name=data.get("name", "unknown"), + link_up=bool(data.get("link_up", False)), + ip_address=data.get("ip_address"), + speed_mbps=data.get("speed_mbps"), + score=float(data.get("score", 0.0)), + reason=data.get("reason"), + last_checked=data.get("last_checked") + or datetime.now(timezone.utc).isoformat(), + ) + + def to_dict(self) -> Dict[str, Any]: + return { + "name": self.name, + "link_up": self.link_up, + "ip_address": self.ip_address, + "speed_mbps": self.speed_mbps, + "score": self.score, + "reason": self.reason, + "last_checked": self.last_checked, + } + + +@dataclass +class WANState: + """Structured representation of the WAN manager state file.""" + + active_interface: Optional[str] = None + status: str = "unknown" + message: Optional[str] = None + last_changed: Optional[str] = None + candidates: List[InterfaceSnapshot] = field(default_factory=list) + + @classmethod + def from_dict(cls, data: Dict[str, Any]) -> "WANState": + return cls( + active_interface=data.get("active_interface"), + status=data.get("status", "unknown"), + message=data.get("message"), + last_changed=data.get("last_changed"), + candidates=[ + InterfaceSnapshot.from_dict(item) + for item in data.get("candidates", []) + ], + ) + + def to_dict(self) -> Dict[str, Any]: + return { + "active_interface": self.active_interface, + "status": self.status, + "message": self.message, + "last_changed": self.last_changed, + "candidates": [snap.to_dict() for snap in self.candidates], + } + + +def load_wan_state(path: Optional[Path] = None) -> WANState: + """Load WAN state data from disk (or return defaults if missing).""" + path = path or resolve_state_path(create=False) + if not path.exists(): + return WANState() + try: + with path.open("r", encoding="utf-8") as handle: + data = json.load(handle) + return WANState.from_dict(data) + except Exception: + # If the file is unreadable (e.g., truncated), fall back to defaults + return WANState() + + +def save_wan_state(state: WANState, path: Optional[Path] = None) -> None: + """Persist WAN state to disk with an atomic write.""" + target = path or resolve_state_path(create=True) + target.parent.mkdir(parents=True, exist_ok=True) + tmp_path = target.with_suffix(".tmp") + with tmp_path.open("w", encoding="utf-8") as handle: + json.dump(state.to_dict(), handle, ensure_ascii=False, indent=2) + tmp_path.replace(target) + + +def get_active_wan_interface(default: str = "wlan1") -> str: + """Convenience accessor for the current active WAN interface.""" + state = load_wan_state() + return state.active_interface or default + + +def update_wan_state( + *, + active_interface=_UNSET, + status: Optional[str] = None, + message: Optional[str] = None, + candidates: Optional[List[InterfaceSnapshot]] = None, + path: Optional[Path] = None, +) -> WANState: + """Update the WAN state file, returning the resulting state.""" + state = load_wan_state(path) + if active_interface is not _UNSET: + state.active_interface = active_interface # type: ignore[assignment] + state.last_changed = datetime.now(timezone.utc).isoformat() + if status is not None: + state.status = status + if message is not None: + state.message = message + if candidates is not None: + state.candidates = candidates + save_wan_state(state, path=path) + return state diff --git a/azctl/cli.py b/azctl/cli.py index 5f8ae98..c0a12bd 100644 --- a/azctl/cli.py +++ b/azctl/cli.py @@ -31,6 +31,7 @@ from azazel_pi.core.ingest.canary_tail import CanaryTail from azazel_pi.core import notify_config as notice from azazel_pi.core.display.status_collector import StatusCollector +from azazel_pi.utils.wan_state import get_active_wan_interface # --------------------------------------------------------------------------- @@ -303,7 +304,7 @@ def _parse_hostapd_status(text: str) -> dict: # レガシー関数は network_utils.py に移行し、統合関数に完全移行しました -def cmd_status(decisions: Optional[str], output_json: bool, lan_if: str = "wlan0", wan_if: str = "wlan1") -> int: +def cmd_status(decisions: Optional[str], output_json: bool, lan_if: str, wan_if: str) -> int: # Likely locations to probe for decisions.log candidates = [ Path(decisions) if decisions else None, @@ -491,8 +492,8 @@ def main(argv: Iterable[str] | None = None) -> int: p_status = sub.add_parser("status", help="Show defensive mode and WLAN info") p_status.add_argument("--decisions-log", help="Path to decisions.log (optional)") p_status.add_argument("--json", action="store_true", help="Output JSON") - p_status.add_argument("--lan-if", default="wlan0", help="LAN/AP interface name (default: wlan0)") - p_status.add_argument("--wan-if", default="wlan1", help="WAN/client interface name (default: wlan1)") + p_status.add_argument("--lan-if", default=os.environ.get("AZAZEL_LAN_IF", "wlan0"), help="LAN/AP interface name (default: wlan0 or AZAZEL_LAN_IF)") + p_status.add_argument("--wan-if", default=None, help="WAN/client interface name (default: dynamically determined by WAN manager)") p_status.add_argument("--tui", action="store_true", help="Rich TUI like the E-Paper layout") p_status.add_argument("--watch", action="store_true", help="Continuously update status") p_status.add_argument("--interval", type=float, default=5.0, help="Refresh interval in seconds (default: 5.0)") @@ -500,16 +501,25 @@ def main(argv: Iterable[str] | None = None) -> int: # Menu: interactive TUI menu system p_menu = sub.add_parser("menu", help="Launch interactive TUI menu for Azazel control operations") p_menu.add_argument("--decisions-log", help="Path to decisions.log (optional)") - p_menu.add_argument("--lan-if", default="wlan0", help="LAN/AP interface name (default: wlan0)") - p_menu.add_argument("--wan-if", default="wlan1", help="WAN/client interface name (default: wlan1)") + p_menu.add_argument("--lan-if", default=os.environ.get("AZAZEL_LAN_IF", "wlan0"), help="LAN/AP interface name (default: wlan0 or AZAZEL_LAN_IF)") + p_menu.add_argument("--wan-if", default=None, help="WAN/client interface name (default: dynamically determined by WAN manager)") # Serve: long-running daemon that consumes ingest streams and updates mode p_serve = sub.add_parser("serve", help="Run long-running daemon to consume events and auto-update mode") p_serve.add_argument("--config", help="Path to Azazel configuration YAML for system initialization") p_serve.add_argument("--decisions-log", help="Path to decisions.log (optional)") p_serve.add_argument("--suricata-eve", help="Path to Suricata eve.json (defaults from configs)", default=notice.SURICATA_EVE_JSON_PATH) - p_serve.add_argument("--lan-if", default="wlan0", help="LAN/AP interface name (default: wlan0)") - p_serve.add_argument("--wan-if", default="wlan1", help="WAN/client interface name (default: wlan1)") + p_serve.add_argument("--lan-if", default=os.environ.get("AZAZEL_LAN_IF", "wlan0"), help="LAN/AP interface name (default: wlan0 or AZAZEL_LAN_IF)") + p_serve.add_argument("--wan-if", default=None, help="WAN/client interface name (default: dynamically determined by WAN manager)") + + # WAN Manager: dynamic interface orchestrator + p_wan = sub.add_parser("wan-manager", help="Monitor and select WAN interfaces automatically") + p_wan.add_argument("--config", help="Path to azazel.yaml (default: /etc/azazel/azazel.yaml)") + p_wan.add_argument("--candidate", action="append", dest="candidates", help="Override WAN candidates (repeatable)") + p_wan.add_argument("--interval", type=float, default=20.0, help="Polling interval in seconds (default: 20)") + p_wan.add_argument("--lan-cidr", default="172.16.0.0/24", help="Internal LAN CIDR for NAT (default: 172.16.0.0/24)") + p_wan.add_argument("--state-file", help="Override WAN state file path") + p_wan.add_argument("--once", action="store_true", help="Run a single evaluation then exit") # Back-compat: events processing (original behavior) p_events = sub.add_parser("events", help="Process events from a YAML config") @@ -520,13 +530,27 @@ def main(argv: Iterable[str] | None = None) -> int: args = parser.parse_args(list(argv) if argv is not None else None) + def safe_path(path_str: Optional[str], fallback: Optional[str] = None) -> Optional[Path]: + """Return a Path for path_str if provided, otherwise a Path for fallback or None. + + This avoids calling Path(None) which raises TypeError when argparse sets + the attribute to None even if the option wasn't provided. + """ + if path_str: + return Path(path_str) + if fallback: + return Path(fallback) + return None + if args.command == "status": + # Resolve WAN interface dynamically if not provided on CLI + wan_if = getattr(args, "wan_if", None) or get_active_wan_interface() if getattr(args, "tui", False) or getattr(args, "watch", False): # TUI path (one-shot with --tui or continuous with --watch) return cmd_status_tui( decisions=getattr(args, "decisions_log", None), lan_if=getattr(args, "lan_if", "wlan0"), - wan_if=getattr(args, "wan_if", "wlan1"), + wan_if=wan_if, interval=float(getattr(args, "interval", 5.0)), once=not bool(getattr(args, "watch", False)), ) @@ -534,22 +558,38 @@ def main(argv: Iterable[str] | None = None) -> int: decisions=getattr(args, "decisions_log", None), output_json=bool(getattr(args, "json", False)), lan_if=getattr(args, "lan_if", "wlan0"), - wan_if=getattr(args, "wan_if", "wlan1"), + wan_if=wan_if, ) if args.command == "menu": + wan_if = getattr(args, "wan_if", None) or get_active_wan_interface() return cmd_menu( decisions=getattr(args, "decisions_log", None), lan_if=getattr(args, "lan_if", "wlan0"), - wan_if=getattr(args, "wan_if", "wlan1"), + wan_if=wan_if, ) if args.command == "serve": + wan_if = getattr(args, "wan_if", None) or get_active_wan_interface() return cmd_serve( config=getattr(args, "config", None), decisions=getattr(args, "decisions_log", None), suricata_eve=getattr(args, "suricata_eve", notice.SURICATA_EVE_JSON_PATH), lan_if=getattr(args, "lan_if", "wlan0"), - wan_if=getattr(args, "wan_if", "wlan1"), + wan_if=wan_if, + ) + if args.command == "wan-manager": + from azazel_pi.core.network.wan_manager import WANManager + + cfg_path = safe_path(getattr(args, "config", None), "/etc/azazel/azazel.yaml") + state_path = safe_path(getattr(args, "state_file", None), None) + + manager = WANManager( + config_path=cfg_path, + candidates=getattr(args, "candidates", None), + poll_interval=float(getattr(args, "interval", 20.0)), + lan_cidr=getattr(args, "lan_cidr", "172.16.0.0/24"), + state_path=state_path, ) + return manager.run(once=bool(getattr(args, "once", False))) # Legacy or explicit events mode config_path = None diff --git a/azctl/menu/core.py b/azctl/menu/core.py index 2e219d6..e021df8 100644 --- a/azctl/menu/core.py +++ b/azctl/menu/core.py @@ -13,6 +13,7 @@ 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 @@ -39,11 +40,18 @@ class AzazelTUIMenu: """Main TUI menu system for Azazel-Pi control interface.""" - def __init__(self, decisions_log: Optional[str] = None, lan_if: str = "wlan0", wan_if: str = "wlan1"): + 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 - self.lan_if = lan_if - self.wan_if = wan_if + # 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 @@ -55,14 +63,14 @@ def __init__(self, decisions_log: Optional[str] = None, lan_if: str = "wlan0", w # Initialize all modules (import here to avoid circular imports) from azctl.menu.network import NetworkModule - from azctl.menu.defense import DefenseModule + 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.defense_module = DefenseModule(self.console) + + 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) diff --git a/azctl/menu/defense.py b/azctl/menu/defense.py index b0bf0bf..69fd65f 100644 --- a/azctl/menu/defense.py +++ b/azctl/menu/defense.py @@ -9,6 +9,7 @@ import subprocess from pathlib import Path from typing import Optional, Dict, Any +import os from rich.console import Console from rich.layout import Layout @@ -30,9 +31,22 @@ class DefenseModule: """Defense control and mode management functionality.""" - def __init__(self, console: Console, decisions_log: Optional[str] = None): + 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: @@ -116,8 +130,8 @@ def _view_status(self) -> None: 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("wlan0") - wlan1 = get_wlan_link_info("wlan1") + wlan0 = get_wlan_ap_status(self.lan_if) + wlan1 = get_wlan_link_info(self.wan_if) profile = get_active_profile() try: @@ -183,9 +197,9 @@ def _view_status(self) -> None: wan_status += f" ({wlan1['ssid']})" info_table.add_row( - "📡 AP (wlan0):", + f"📡 AP ({self.lan_if}):", ap_status, - "🌐 WAN (wlan1):", + f"🌐 WAN ({self.wan_if}):", wan_status ) diff --git a/azctl/menu/emergency.py b/azctl/menu/emergency.py index fc0dfd1..7dc0bb5 100644 --- a/azctl/menu/emergency.py +++ b/azctl/menu/emergency.py @@ -6,6 +6,7 @@ """ import subprocess +import os from datetime import datetime from pathlib import Path from typing import Optional @@ -16,15 +17,21 @@ 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: str = "wlan0", wan_if: str = "wlan1"): + def __init__(self, console: Console, lan_if: Optional[str] = None, wan_if: Optional[str] = None): self.console = console - self.lan_if = lan_if - self.wan_if = wan_if + # 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.""" diff --git a/azctl/menu/network.py b/azctl/menu/network.py index 0584b87..b931d2d 100644 --- a/azctl/menu/network.py +++ b/azctl/menu/network.py @@ -7,11 +7,13 @@ """ 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 @@ -26,11 +28,16 @@ class NetworkModule: """Network information and management functionality.""" - def __init__(self, console: Console, lan_if: str = "wlan0", wan_if: str = "wlan1", + def __init__(self, console: Console, lan_if: Optional[str] = None, wan_if: Optional[str] = None, status_collector: Optional[Any] = None): self.console = console - self.lan_if = lan_if - self.wan_if = wan_if + # 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 @@ -195,7 +202,8 @@ def _traffic_stats(self) -> None: # Add interface statistics for interface, stats in status.get('interfaces', {}).items(): - if interface in [self.lan_if, self.wan_if, 'eth0']: + # 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):,}" @@ -242,7 +250,7 @@ def _show_basic_traffic_stats(self) -> None: interface = fields[0].rstrip(':') # Only show relevant interfaces - if interface in [self.lan_if, self.wan_if, 'eth0', 'lo']: + 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]) diff --git a/azctl/menu/wifi.py b/azctl/menu/wifi.py index 45eea21..2e906ff 100644 --- a/azctl/menu/wifi.py +++ b/azctl/menu/wifi.py @@ -24,11 +24,23 @@ class WiFiManager: def __init__(self, console: Console): self.console = console - def manage_wifi(self, wan_if: str = "wlan1") -> None: - """Wi-Fi connection manager entry point.""" + 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 diff --git a/bin/azazel-qos-apply.sh b/bin/azazel-qos-apply.sh index df3e249..9baea15 100755 --- a/bin/azazel-qos-apply.sh +++ b/bin/azazel-qos-apply.sh @@ -26,14 +26,23 @@ if [[ "$DRY_RUN" == "1" ]]; then else echo "[DRY_RUN] yq not found, using fallback defaults" >&2 MARK_PREMIUM="0x10" - LAN_IF="wlan0" + # Allow environment override for LAN interface in DRY_RUN + # Use the AZAZEL_LAN_IF environment variable when present, otherwise + # fall back to the historical default (wlan0). This mirrors how the + # non-DRY_RUN path resolves the LAN interface. + LAN_IF="${AZAZEL_LAN_IF:-wlan0}" fi else for cmd in nft yq ip; do command -v "$cmd" >/dev/null 2>&1 || { echo "missing command: $cmd" >&2; exit 1; } done MARK_PREMIUM=$(yq -r '.mark_map.premium' "$CFG") - LAN_IF=$(yq -r '.lan_iface' "$CFG") + # Prefer environment override, then config + if [[ -n "${AZAZEL_LAN_IF:-}" ]]; then + LAN_IF="$AZAZEL_LAN_IF" + else + LAN_IF=$(yq -r '.lan_iface' "$CFG") + fi fi # Prepare sets diff --git a/bin/azazel-qos-menu.sh b/bin/azazel-qos-menu.sh index 666bca1..dcd2097 100755 --- a/bin/azazel-qos-menu.sh +++ b/bin/azazel-qos-menu.sh @@ -1,5 +1,10 @@ #!/usr/bin/env bash # bin/azazel-qos-menu.sh +# Interactive helper for managing privileged host CSV and invoking azazel-qos-apply.sh +# Notes: +# - This menu is intentionally minimal and defers interface selection to +# the underlying apply script. To override the LAN interface globally, +# set AZAZEL_LAN_IF (e.g. export AZAZEL_LAN_IF=${AZAZEL_LAN_IF:-wlan0}). set -euo pipefail CSV="configs/network/privileged.csv" diff --git a/bin/azazel-traffic-init.sh b/bin/azazel-traffic-init.sh index 7a2dc21..0256e98 100755 --- a/bin/azazel-traffic-init.sh +++ b/bin/azazel-traffic-init.sh @@ -23,12 +23,55 @@ if ! command -v yq >/dev/null 2>&1; then fi fi -if command -v yq >/dev/null 2>&1; then - WAN_IF=$(yq -r '.wan_iface' "$CFG") +# Resolve WAN interface in order of precedence: +# 1) Explicit runtime override via WAN_IF_OVERRIDE +# 2) Environment override AZAZEL_WAN_IF +# 3) Configuration value in YAML (requires yq) +# 4) Ask the Python WAN manager helper (get_active_wan_interface) +# 5) Final fallback: ${AZAZEL_WAN_IF:-eth0} + +# 1) runtime override +if [[ -n "${WAN_IF_OVERRIDE:-}" ]]; then + WAN_IF="$WAN_IF_OVERRIDE" else - WAN_IF="eth0" + # 2) environment override + if [[ -n "${AZAZEL_WAN_IF:-}" ]]; then + WAN_IF="$AZAZEL_WAN_IF" + else + # 3) config via yq + if command -v yq >/dev/null 2>&1; then + WAN_IF=$(yq -r '.wan_iface' "$CFG" 2>/dev/null || echo "") + else + WAN_IF="" + fi + + # 4) try Python helper if still empty + if [[ -z "${WAN_IF:-}" || "$WAN_IF" == "null" ]]; then + if command -v python3 >/dev/null 2>&1; then + # Use project-installed package if available; fall back silently on error + PY_IF=$(python3 - <<'PY' +import sys +try: + from azazel_pi.utils.wan_state import get_active_wan_interface + iface = get_active_wan_interface() + if iface: + sys.stdout.write(iface) +except Exception: + pass +PY + 2>/dev/null || true) + if [[ -n "${PY_IF:-}" ]]; then + WAN_IF="$PY_IF" + fi + fi + fi + fi fi -[[ -n "$WAN_IF" && "$WAN_IF" != "null" ]] || { echo "wan_iface missing in $CFG" >&2; exit 1; } + +# 5) final fallback — prefer AZAZEL_WAN_IF if set, otherwise fall back to ${AZAZEL_WAN_IF:-eth0} +: ${WAN_IF:=${AZAZEL_WAN_IF:-eth0}} + +[[ -n "$WAN_IF" && "$WAN_IF" != "null" ]] || { echo "wan_iface missing in $CFG and no fallback available" >&2; exit 1; } # HTB root qdisc (idempotent replace) run tc qdisc replace dev "$WAN_IF" root handle 1: htb default 30 diff --git a/configs/monitoring/notify.yaml b/configs/monitoring/notify.yaml index 893361d..c235fdb 100644 --- a/configs/monitoring/notify.yaml +++ b/configs/monitoring/notify.yaml @@ -44,6 +44,8 @@ opencanary: # ネットワーク制御設定 network: + # 遅延制御対象インタフェース(例: wlan1)。ランタイムでの検出を優先する場合は + # 環境変数 AZAZEL_WAN_IF を設定してください(例: export AZAZEL_WAN_IF=wlan1)。 interface: "wlan1" # 遅延制御対象インタフェース inactivity_minutes: 2 # 無活動判定時間 delay: diff --git a/configs/network/azazel.yaml b/configs/network/azazel.yaml index 7d566eb..d19f6d4 100644 --- a/configs/network/azazel.yaml +++ b/configs/network/azazel.yaml @@ -3,6 +3,9 @@ interfaces: lan: wlan0 wan: wlan1 # 外部(アップリンク)として扱うインターフェースの明示(複数可) + # 注意: これらは例です。ランタイムでの自動選択を優先する場合は + # 環境変数 `AZAZEL_WAN_IF` (単一) または `AZAZEL_WAN_CANDIDATES` (カンマ区切り) を使用して + # 実行時に優先するインターフェースを指定できます。 external: ["eth0", "wlan1"] profiles: active: lte @@ -39,6 +42,8 @@ privacy: { pii_minimize: true, hash_fields: ["src.ip", "dst.ip", "username"] } # QoS internal network control (premium/standard/best_effort/restricted) wan_iface: "eth0" # shaping target interface (can mirror 'interfaces.wan') +# Runtime note: to override the shaping target at runtime set AZAZEL_WAN_IF env var +# export AZAZEL_WAN_IF=wlan1 lan_iface: "br0" # LAN bridge for ARP pinning when MODE=lock mark_map: premium: 0x10 diff --git a/configs/profiles/fiber.yaml b/configs/profiles/fiber.yaml index b232d1d..8235c3a 100644 --- a/configs/profiles/fiber.yaml +++ b/configs/profiles/fiber.yaml @@ -1,4 +1,9 @@ node: azazel-fiber-hub +# Interface examples: these values are examples for this profile. +# You can override them at runtime using environment variables: +# export AZAZEL_LAN_IF=${AZAZEL_LAN_IF:-wlan0} +# export AZAZEL_WAN_IF=${AZAZEL_WAN_IF:-wlan1} +# Or set AZAZEL_WAN_CANDIDATES for a prioritized candidate list (comma-separated). interfaces: { lan: lan0, wan: wlan1 } profiles: active: fiber diff --git a/configs/profiles/lte.yaml b/configs/profiles/lte.yaml index 65897f1..4352659 100644 --- a/configs/profiles/lte.yaml +++ b/configs/profiles/lte.yaml @@ -1,4 +1,7 @@ node: azazel-lte-field +# Interface examples: override at runtime via AZAZEL_LAN_IF / AZAZEL_WAN_IF +# e.g. export AZAZEL_LAN_IF=${AZAZEL_LAN_IF:-wlan0} +# export AZAZEL_WAN_IF=${AZAZEL_WAN_IF:-wlan1} interfaces: { lan: lan0, wan: wlan1 } profiles: active: lte diff --git a/configs/profiles/sat.yaml b/configs/profiles/sat.yaml index e02e248..6829b03 100644 --- a/configs/profiles/sat.yaml +++ b/configs/profiles/sat.yaml @@ -1,4 +1,7 @@ node: azazel-sat-field +# Interface examples: these are sample values for this profile. +# Override at runtime with AZAZEL_LAN_IF / AZAZEL_WAN_IF or set +# AZAZEL_WAN_CANDIDATES for a prioritized list (comma-separated). interfaces: { lan: lan0, wan: wlan1 } profiles: active: sat diff --git a/deploy/suricata/suricata.yaml b/deploy/suricata/suricata.yaml index aa20623..9ff6b47 100644 --- a/deploy/suricata/suricata.yaml +++ b/deploy/suricata/suricata.yaml @@ -18,7 +18,11 @@ rule-files: - suricata.rules # suricata-updateで取得したルール+自作ルール(Emerging Threats推奨) af-packet: - # 外部ネットワーク接続インターフェース(WAN)として wlan1 と eth0 の両方を監視 + # 外部ネットワーク接続インターフェース(WAN)の例。実環境では + # AZAZEL_WAN_IF 環境変数で単一インターフェースを指定するか、 + # azazel のランタイム WAN マネージャ経由で選択することを推奨します。 + # 例: export AZAZEL_WAN_IF=wlan1 + # 下記は複数インターフェースを監視するサンプルです。 - interface: wlan1 cluster-id: 99 cluster-type: cluster_flow diff --git a/docs/en/NETWORK_SETUP.md b/docs/en/NETWORK_SETUP.md index 30f1944..9b98403 100644 --- a/docs/en/NETWORK_SETUP.md +++ b/docs/en/NETWORK_SETUP.md @@ -12,6 +12,12 @@ Azazel-Pi can operate in multiple network modes: The installer provides automatic configuration for most scenarios, with manual options available for specialized deployments. +Note on interface names and dynamic selection: + +- Many examples in this guide use common interface names such as `wlan0`, `wlan1`, or `eth0` for clarity. These are examples only. +- Azazel now supports dynamic WAN selection via the `wan-manager`. If you omit `--wan-if` in CLI commands, the system will prefer the runtime-selected WAN interface. To override defaults explicitly, set the environment variables `AZAZEL_WAN_IF` and/or `AZAZEL_LAN_IF`, or pass `--wan-if` / `--lan-if` on the CLI. + + ## Automatic Network Configuration ### During Installation @@ -24,8 +30,8 @@ Edit `/etc/azazel/azazel.yaml` to specify your network requirements: ```yaml network: - # Primary monitoring interface - interface: "eth0" + # Primary monitoring interface (use AZAZEL_WAN_IF to override at runtime) + interface: "${AZAZEL_WAN_IF:-eth0}" # Home network definition for IDS rules home_net: "192.168.1.0/24" @@ -33,8 +39,8 @@ network: # Gateway mode configuration (optional) gateway_mode: enabled: false - ap_interface: "wlan0" - client_interface: "wlan1" + ap_interface: "${AZAZEL_LAN_IF:-wlan0}" + client_interface: "${AZAZEL_WAN_IF:-wlan1}" internal_network: "172.16.0.0/24" ap_ssid: "Azazel-GW" ap_passphrase: "SecurePassphrase123" @@ -81,8 +87,8 @@ sudo systemctl status hostapd # Verify DHCP server sudo systemctl status dnsmasq -# Test internet connectivity -ping -I wlan1 8.8.8.8 +# Test internet connectivity (uses the runtime-selected WAN interface) +ping -I ${AZAZEL_WAN_IF:-wlan1} 8.8.8.8 # Check NAT rules sudo nft list ruleset | grep -A5 -B5 masquerade @@ -146,9 +152,9 @@ If automatic configuration doesn't meet your needs, you can configure components #### 1. hostapd Configuration ```bash -# Create hostapd configuration +# Create hostapd configuration (AP interface uses AZAZEL_LAN_IF) sudo tee /etc/hostapd/hostapd.conf < AZAZEL_WAN_IF env -> fallback ${AZAZEL_WAN_IF:-wlan1} +INTERFACE=${3:-${AZAZEL_WAN_IF:-wlan1}} # Logging function log() { diff --git a/scripts/install_azazel.sh b/scripts/install_azazel.sh index f39c478..47ae678 100755 --- a/scripts/install_azazel.sh +++ b/scripts/install_azazel.sh @@ -236,11 +236,11 @@ configure_nginx() { } configure_internal_network() { - log "Configuring internal network (wlan0 as AP with 172.16.0.254)" + log "Configuring internal network (AP interface: ${AZAZEL_LAN_IF:-wlan0} — WAN interface: ${AZAZEL_WAN_IF:-wlan1})" # Run the unified wireless setup script if [[ -x "$REPO_ROOT/scripts/setup_wireless.sh" ]]; then - log "Running setup_wireless.sh for AP configuration" + log "Running setup_wireless.sh for AP configuration (AP: ${AZAZEL_LAN_IF:-wlan0})" "$REPO_ROOT/scripts/setup_wireless.sh" --ap-only --skip-confirm || { log "ERROR: Wireless setup script failed; manual configuration required" return 1 @@ -497,4 +497,6 @@ log " * Configure Mattermost webhooks at http://172.16.0.254:8065 (internal net log " * Update webhook URLs in /etc/azazel/monitoring/notify.yaml to match your Mattermost setup" log " * Run 'systemctl restart azctl-unified.service' after making Azazel changes" log " * Use scripts/sanity_check.sh plus 'systemctl status mattermost nginx docker' to verify services" -log " * Internal network (172.16.0.0/24) is accessible via wlan0 AP, external via wlan1" +LAN_IF=${AZAZEL_LAN_IF:-wlan0} +WAN_IF=${AZAZEL_WAN_IF:-wlan1} +log " * Internal network (172.16.0.0/24) is accessible via ${LAN_IF} AP, external via ${WAN_IF}" diff --git a/scripts/install_azazel_complete.sh b/scripts/install_azazel_complete.sh index 52d7648..2b4f5fd 100755 --- a/scripts/install_azazel_complete.sh +++ b/scripts/install_azazel_complete.sh @@ -224,16 +224,19 @@ PY # if the installer is invoked with EPD_WIFI_SSID and EPD_WIFI_PSK environment variables. # Example: sudo EPD_WIFI_SSID="MySSID" EPD_WIFI_PSK="mypassword" ./install_azazel_complete.sh --enable-epd if [[ -n "${EPD_WIFI_SSID:-}" && -n "${EPD_WIFI_PSK:-}" ]]; then - log "Attempting to provision Wi-Fi for EPD (SSID=${EPD_WIFI_SSID})" + # Allow override of which interface to use for EPD Wi-Fi provisioning. + # Precedence: EPD_WIFI_IF -> AZAZEL_WAN_IF -> fallback ${AZAZEL_WAN_IF:-wlan1} + EPD_WIFI_IF=${EPD_WIFI_IF:-${AZAZEL_WAN_IF:-wlan1}} + log "Attempting to provision Wi-Fi for EPD (SSID=${EPD_WIFI_SSID}, if=${EPD_WIFI_IF})" # Try direct connect first (creates a connection profile on success) - if nmcli -t -f GENERAL.STATE device show wlan1 >/dev/null 2>&1; then - if nmcli device wifi connect "$EPD_WIFI_SSID" password "$EPD_WIFI_PSK" ifname wlan1; then - log "Wi-Fi connected (wlan1) to ${EPD_WIFI_SSID}" + if nmcli -t -f GENERAL.STATE device show "$EPD_WIFI_IF" >/dev/null 2>&1; then + if nmcli device wifi connect "$EPD_WIFI_SSID" password "$EPD_WIFI_PSK" ifname "$EPD_WIFI_IF"; then + log "Wi-Fi connected (${EPD_WIFI_IF}) to ${EPD_WIFI_SSID}" else warn "Direct nmcli connect failed; attempting to create persistent connection profile" # Create connection, then explicitly set security properties to avoid # "key-mgmt missing" errors across nmcli versions. - nmcli connection add type wifi con-name azazel-epd-wifi ifname wlan1 ssid "$EPD_WIFI_SSID" || true + nmcli connection add type wifi con-name azazel-epd-wifi ifname "$EPD_WIFI_IF" ssid "$EPD_WIFI_SSID" || true # Set PSK and key management using the 802-11-wireless-security keys nmcli connection modify azazel-epd-wifi 802-11-wireless-security.key-mgmt "wpa-psk" || true nmcli connection modify azazel-epd-wifi 802-11-wireless-security.psk "$EPD_WIFI_PSK" || true @@ -246,7 +249,7 @@ PY nmcli connection up azazel-epd-wifi || warn "Failed to activate azazel-epd-wifi" fi else - warn "wlan1 device not present or controllable by NetworkManager; skipping wifi provisioning" + warn "${EPD_WIFI_IF} device not present or controllable by NetworkManager; skipping wifi provisioning" fi fi else diff --git a/scripts/setup_wireless.sh b/scripts/setup_wireless.sh index 078eb7b..2e791f2 100755 --- a/scripts/setup_wireless.sh +++ b/scripts/setup_wireless.sh @@ -1,6 +1,7 @@ #!/usr/bin/env bash # Unified Wireless Network Setup for Azazel-Pi -# Configures wlan0 as AP (internal network) and wlan1 monitoring for Suricata +# Configures an internal AP (default ${AZAZEL_LAN_IF:-wlan0}) and an upstream/monitoring interface (default ${AZAZEL_WAN_IF:-wlan1}). +# Interfaces may be overridden by environment variables: AZAZEL_LAN_IF (AP) and AZAZEL_WAN_IF (upstream). # Run as root (sudo) set -euo pipefail @@ -8,8 +9,9 @@ set -euo pipefail # --- Configuration --- SSID="Azazel_Internal" PASSPHRASE="change-this-to-a-strong-pass" -WLAN_AP="wlan0" # Internal AP interface -WLAN_UP="wlan1" # Upstream/monitoring interface +# Allow environment overrides for interfaces. Precedence: AZAZEL_LAN_IF for AP, AZAZEL_WAN_IF for upstream +WLAN_AP="${AZAZEL_LAN_IF:-wlan0}" # Internal AP interface (default ${AZAZEL_LAN_IF:-wlan0}) +WLAN_UP="${AZAZEL_WAN_IF:-wlan1}" # Upstream/monitoring interface (default ${AZAZEL_WAN_IF:-wlan1}) INTERNAL_NET="172.16.0.0/24" AP_IP="172.16.0.254" DHCPS_START="172.16.0.10" @@ -48,12 +50,12 @@ usage() { Usage: $0 [OPTIONS] Configure wireless interfaces for Azazel-Pi: -- wlan0: Internal Access Point (172.16.0.0/24) -- wlan1: Upstream connection + Suricata monitoring +- ${AZAZEL_LAN_IF:-wlan0}: Internal Access Point (172.16.0.0/24) +- ${AZAZEL_WAN_IF:-wlan1}: Upstream connection + Suricata monitoring Options: - --ap-only Configure only AP (wlan0), skip Suricata setup - --suricata-only Configure only Suricata monitoring (wlan1), skip AP + --ap-only Configure only AP (default: ${AZAZEL_LAN_IF:-wlan0}), skip Suricata setup + --suricata-only Configure only Suricata monitoring (default: ${AZAZEL_WAN_IF:-wlan1}), skip AP --skip-confirm Skip interactive confirmations (for automation) --ssid NAME Set AP SSID (default: $SSID) --passphrase PASS Set AP passphrase (default: $PASSPHRASE) @@ -124,8 +126,8 @@ fi log "Unified Wireless Network Setup for Azazel-Pi" echo echo "Configuration Summary:" -echo " AP Interface (wlan0): $([ $SETUP_AP -eq 1 ] && echo "✓ Configure as $AP_IP" || echo "✗ Skip")" -echo " Monitor Interface (wlan1): $([ $SETUP_SURICATA -eq 1 ] && echo "✓ Configure for Suricata" || echo "✗ Skip")" + echo " AP Interface (${WLAN_AP}): $([ $SETUP_AP -eq 1 ] && echo "✓ Configure as $AP_IP" || echo "✗ Skip")" + echo " Monitor Interface (${WLAN_UP}): $([ $SETUP_SURICATA -eq 1 ] && echo "✓ Configure for Suricata" || echo "✗ Skip")" echo " Internal Network: $INTERNAL_NET" echo " AP SSID: $SSID" echo " DHCP Range: $DHCPS_START - $DHCPS_END" @@ -136,7 +138,7 @@ if [[ $SKIP_CONFIRM -eq 0 ]]; then confirm "Proceed with wireless configuration?" || { echo "Aborted."; exit 1; } fi -# Function: Setup Access Point (wlan0) +# Function: Setup Access Point (${AZAZEL_LAN_IF:-wlan0}) setup_access_point() { log "Setting up Access Point on $WLAN_AP" @@ -241,16 +243,17 @@ EOF # Apply nftables rules nft -f /etc/nftables.conf || warn "nftables rules may have issues" - # Exclude wlan0 from NetworkManager management + # Exclude ${AZAZEL_LAN_IF:-wlan0} from NetworkManager management log "Configuring NetworkManager to ignore $WLAN_AP..." mkdir -p /etc/NetworkManager/conf.d - cat > /etc/NetworkManager/conf.d/unmanaged-wlan0.conf < /etc/NetworkManager/conf.d/unmanaged-${WLAN_AP}.conf </dev/null || true - # Configure systemd-networkd for persistent IP on wlan0 + # Configure systemd-networkd for persistent IP on ${AZAZEL_LAN_IF:-wlan0} log "Configuring systemd-networkd for persistent IP..." mkdir -p /etc/systemd/network cat > /etc/systemd/network/10-${WLAN_AP}.network < AZAZEL_WAN_IF env -> fallback ${AZAZEL_WAN_IF:-wlan1} +IFACE=${1:-${AZAZEL_WAN_IF:-wlan1}} tc qdisc del dev "$IFACE" root 2>/dev/null || true tc qdisc add dev "$IFACE" root handle 1: htb default 30 diff --git a/systemd/azazel-wan-manager.service b/systemd/azazel-wan-manager.service new file mode 100644 index 0000000..576a8d7 --- /dev/null +++ b/systemd/azazel-wan-manager.service @@ -0,0 +1,15 @@ +[Unit] +Description=Azazel dynamic WAN manager +After=network-online.target +Wants=network-online.target + +[Service] +Type=simple +WorkingDirectory=/home/azazel/Azazel-Pi +Environment=PYTHONPATH=/home/azazel/Azazel-Pi +ExecStart=/usr/bin/env python3 -m azctl.cli wan-manager --config /etc/azazel/azazel.yaml +Restart=always +RestartSec=5 + +[Install] +WantedBy=multi-user.target diff --git a/systemd/azctl-unified.service b/systemd/azctl-unified.service index 3ca5025..eec88ab 100644 --- a/systemd/azctl-unified.service +++ b/systemd/azctl-unified.service @@ -1,8 +1,8 @@ [Unit] Description=Azazel Unified Control Daemon with AI Edge Computing Documentation=https://github.com/01rabbit/Azazel-Pi -After=network-online.target suricata.service opencanary.service azazel-ai-services.service -Wants=network-online.target azazel-ai-services.service +After=network-online.target suricata.service opencanary.service azazel-ai-services.service azazel-wan-manager.service +Wants=network-online.target azazel-ai-services.service azazel-wan-manager.service Requires=suricata.service [Service] @@ -30,4 +30,4 @@ TimeoutStopSec=30 [Install] WantedBy=multi-user.target -Also=suricata.service opencanary.service vector.service \ No newline at end of file +Also=suricata.service opencanary.service vector.service diff --git a/systemd/suricata.service b/systemd/suricata.service index 37c474a..caa254a 100644 --- a/systemd/suricata.service +++ b/systemd/suricata.service @@ -1,10 +1,13 @@ [Unit] Description=Suricata IDS -After=network-online.target +After=network-online.target azazel-wan-manager.service +Wants=azazel-wan-manager.service [Service] Type=simple -ExecStart=/usr/bin/suricata -c /etc/suricata/suricata.yaml -i wlan1 +WorkingDirectory=/home/azazel/Azazel-Pi +Environment=PYTHONPATH=/home/azazel/Azazel-Pi +ExecStart=/usr/bin/env python3 -m azazel_pi.core.network.suricata_wrapper Restart=on-failure [Install] diff --git a/tests/core/test_cli_wan_resolution.py b/tests/core/test_cli_wan_resolution.py new file mode 100644 index 0000000..4be848f --- /dev/null +++ b/tests/core/test_cli_wan_resolution.py @@ -0,0 +1,59 @@ +import json +from types import SimpleNamespace + +import azctl.cli as cli + + +def test_status_uses_helper_when_no_wan_if(monkeypatch, capsys): + # Arrange: patch helper to return a distinctive interface + # CLI module imports the helper at module import time; patch the symbol in the cli module + monkeypatch.setattr(cli, "get_active_wan_interface", lambda: "eth99") + + captured = {} + + def fake_cmd_status(decisions, output_json, lan_if, wan_if): + captured['wan_if'] = wan_if + return 0 + + monkeypatch.setattr(cli, "cmd_status", fake_cmd_status) + + # Act + rc = cli.main(["status", "--lan-if", "wlan0", "--json"]) + + # Assert + assert rc == 0 + assert captured.get('wan_if') == "eth99" + + +def test_menu_uses_helper_when_no_wan_if(monkeypatch): + monkeypatch.setattr(cli, "get_active_wan_interface", lambda: "ethX") + + captured = {} + + def fake_cmd_menu(decisions, lan_if, wan_if): + captured['wan_if'] = wan_if + return 0 + + monkeypatch.setattr(cli, "cmd_menu", fake_cmd_menu) + + rc = cli.main(["menu", "--lan-if", "wlan0"]) + + assert rc == 0 + assert captured.get('wan_if') == "ethX" + + +def test_serve_uses_helper_when_no_wan_if(monkeypatch): + monkeypatch.setattr(cli, "get_active_wan_interface", lambda: "ethSERVE") + + captured = {} + + def fake_cmd_serve(config, decisions, suricata_eve, lan_if, wan_if): + captured['wan_if'] = wan_if + return 0 + + monkeypatch.setattr(cli, "cmd_serve", fake_cmd_serve) + + rc = cli.main(["serve", "--lan-if", "wlan0"]) + + assert rc == 0 + assert captured.get('wan_if') == "ethSERVE" diff --git a/tests/core/test_epd_daemon.py b/tests/core/test_epd_daemon.py new file mode 100644 index 0000000..44756f1 --- /dev/null +++ b/tests/core/test_epd_daemon.py @@ -0,0 +1,84 @@ +import json +import os +from pathlib import Path +import tempfile +import subprocess + +import pytest + +from azazel_pi.core.display import epd_daemon + + +def test_status_collector_reads_wan_state(tmp_path): + # prepare a WAN state file + state = { + "active_interface": "test0", + "status": "degraded", + "message": "link flaps", + "candidates": [], + } + path = tmp_path / "wan_state.json" + path.write_text(json.dumps(state)) + + # create collector with explicit path + collector = epd_daemon.StatusCollector(events_log=None, wan_state_path=path) + net = collector._get_network_status() + + assert net.wan_state == "degraded" + assert net.wan_message == "link flaps" + assert net.interface == "test0" + + +def test_status_collector_prefers_fastest_candidate(tmp_path): + """Ensure a faster wired link overrides a slower wireless link on the display.""" + state = { + "active_interface": "wlan1", + "status": "ready", + "message": "wlan1 active", + "candidates": [ + { + "name": "wlan1", + "link_up": True, + "ip_address": "10.0.0.5", + "speed_mbps": 150, + "score": 120, + "reason": "wifi", + }, + { + "name": "eth0", + "link_up": True, + "ip_address": "192.168.1.20", + "speed_mbps": 1000, + "score": 180, + "reason": "wired", + }, + ], + } + path = tmp_path / "wan_state.json" + path.write_text(json.dumps(state)) + + collector = epd_daemon.StatusCollector(events_log=None, wan_state_path=path) + net = collector._get_network_status() + + assert net.interface == "eth0" + + +def test_epd_daemon_test_mode_saves_image(tmp_path, monkeypatch): + # prepare a fake status and renderer to avoid hardware access + out_path = tmp_path / "out.png" + # Run as a subprocess invoking the module to keep behavior realistic + cmd = [ + "python", + "-m", + "azazel_pi.core.display.epd_daemon", + "--mode", + "test", + "--emulate", + "--emulate-output", + str(out_path), + ] + + # Run the command; it should exit 0 and write the output file + res = subprocess.run(cmd, capture_output=True, text=True) + assert res.returncode == 0, f"Module failed: {res.stderr}" + assert out_path.exists(), "Emulate output image was not created" diff --git a/tests/helpers/run_wan_state_test.py b/tests/helpers/run_wan_state_test.py new file mode 100644 index 0000000..56039c9 --- /dev/null +++ b/tests/helpers/run_wan_state_test.py @@ -0,0 +1,56 @@ +#!/usr/bin/env python3 +"""Helper to run epd_daemon test-mode against a given WAN state file. + +Usage: + python tests/helpers/run_wan_state_test.py /path/to/wan_state.json /tmp/out.png + +This script writes a minimal WAN state file if it does not exist, then runs +`azazel_pi.core.display.epd_daemon` in test+emulate mode pointing at that file and +saving the output PNG to the given path. +""" +from __future__ import annotations + +import json +import subprocess +import sys +from pathlib import Path + + +def main(argv: list[str]) -> int: + if len(argv) < 3: + print("Usage: run_wan_state_test.py ") + return 2 + + wan_path = Path(argv[1]) + out_png = Path(argv[2]) + + if not wan_path.exists(): + wan = { + "active_interface": "eth0", + "status": "degraded", + "message": "manual-test", + "candidates": [], + } + wan_path.parent.mkdir(parents=True, exist_ok=True) + wan_path.write_text(json.dumps(wan)) + + cmd = [ + sys.executable, + "-m", + "azazel_pi.core.display.epd_daemon", + "--mode", + "test", + "--emulate", + "--wan-state-path", + str(wan_path), + "--emulate-output", + str(out_png), + ] + + print("Running:", " ".join(cmd)) + res = subprocess.run(cmd) + return res.returncode + + +if __name__ == "__main__": + raise SystemExit(main(sys.argv)) diff --git a/tests/utils/test_wan_state.py b/tests/utils/test_wan_state.py new file mode 100644 index 0000000..91e0245 --- /dev/null +++ b/tests/utils/test_wan_state.py @@ -0,0 +1,37 @@ +import os +from pathlib import Path + +from azazel_pi.utils import wan_state + + +def test_update_and_load_custom_path(tmp_path, monkeypatch): + """WAN state helpers should honor custom file paths.""" + state_path = tmp_path / "wan_state.json" + monkeypatch.setenv("AZAZEL_WAN_STATE_PATH", str(state_path)) + + # Initial load should be empty + state = wan_state.load_wan_state(state_path) + assert state.active_interface is None + + wan_state.update_wan_state( + active_interface="eth0", + status="ready", + message="eth0 selected", + path=state_path, + ) + state = wan_state.load_wan_state(state_path) + assert state.active_interface == "eth0" + assert state.status == "ready" + assert state.message == "eth0 selected" + + # Setting active_interface=None should clear it + wan_state.update_wan_state( + active_interface=None, + status="degraded", + message="no interface", + path=state_path, + ) + state = wan_state.load_wan_state(state_path) + assert state.active_interface is None + assert state.status == "degraded" + assert state.message == "no interface"