From 9fc35e86fec9d5c0f1676c23f0863e8881cdd894 Mon Sep 17 00:00:00 2001 From: nearxos Date: Mon, 15 Jun 2026 07:49:10 +0300 Subject: [PATCH] Add Asterisk configuration and diagnostics support - Updated .env.example to include Asterisk templates path. - Modified docker-compose.yml to mount the templates directory. - Enhanced backend Dockerfile to copy templates into the container. - Introduced Asterisk diagnostics functionality in asterisk_profiles.py, allowing for baseline checks and diagnostics reporting. - Integrated Asterisk diagnostics into the device diagnostics workflow in graph.py. - Added formatting for Asterisk baseline drift reports in diagnostic_format.py. - Updated SKILL.md to document new config baseline drift feature for Asterisk. This commit enhances the system's capabilities for managing Asterisk configurations and diagnostics, improving overall troubleshooting processes. --- .env.example | 7 + backend/Dockerfile | 1 + backend/app/agent/asterisk_profiles.py | 197 +++++++++++- backend/app/agent/graph.py | 23 +- backend/app/config.py | 3 + backend/app/services/asterisk_baseline.py | 293 ++++++++++++++++++ backend/app/services/diagnostic_format.py | 35 ++- backend/tests/test_asterisk_baseline.py | 138 +++++++++ docker-compose.yml | 2 + docs/README.md | 22 ++ skills/vessel-voip/SKILL.md | 1 + templates/asterisk/geneseasx/manifest.yaml | 42 +++ .../asterisk/geneseasx/pjsip.conf.snippet | 13 + 13 files changed, 773 insertions(+), 4 deletions(-) create mode 100644 backend/app/services/asterisk_baseline.py create mode 100644 backend/tests/test_asterisk_baseline.py create mode 100644 docs/README.md create mode 100644 templates/asterisk/geneseasx/manifest.yaml create mode 100644 templates/asterisk/geneseasx/pjsip.conf.snippet diff --git a/.env.example b/.env.example index e50eedb..d0bceab 100644 --- a/.env.example +++ b/.env.example @@ -123,6 +123,13 @@ SKILLS_PATH=/app/skills SKILLS_MAX_MATCHED=2 SKILLS_MAX_BODY_CHARS=8000 +# Asterisk golden config templates (templates/asterisk//manifest.yaml) +ASTERISK_TEMPLATES_PATH=/app/templates/asterisk +# Per-vessel overrides when not using inventory metadata: +# ASTERISK_BASELINE_ASTERISK_IP=10.20.30.222 +# ASTERISK_BASELINE_PHONE_SUBNET=192.168.0.0/24 +# ASTERISK_BASELINE_VOIP_SUBNET=10.20.30.0/24 + # ---- Frontend --------------------------------------------------------- # Public URL the browser uses to reach the backend API NEXT_PUBLIC_API_BASE_URL=http://localhost:8000 diff --git a/backend/Dockerfile b/backend/Dockerfile index 5c5da61..8673c50 100644 --- a/backend/Dockerfile +++ b/backend/Dockerfile @@ -18,6 +18,7 @@ RUN pip install -r requirements.txt COPY backend/ . COPY rules/ /app/rules/ COPY skills/ /app/skills/ +COPY templates/ /app/templates/ EXPOSE 8000 diff --git a/backend/app/agent/asterisk_profiles.py b/backend/app/agent/asterisk_profiles.py index 03118a0..3572482 100644 --- a/backend/app/agent/asterisk_profiles.py +++ b/backend/app/agent/asterisk_profiles.py @@ -1,10 +1,19 @@ -"""Asterisk deployment profiles and connect playbooks.""" +"""Asterisk deployment profiles, connect playbooks, and diagnostics.""" from __future__ import annotations +import json import os import shlex +from typing import Awaitable, Callable from app.agent.device_connect import ssh_connect_args +from app.agent.tool_runner import invoke_connect, invoke_tool +from app.services.asterisk_baseline import ( + compare_against_baseline, + config_paths_for_profile, + config_root_for_profile, + profile_for_catalog, +) # Only one Asterisk slot allowed per vessel. ASTERISK_CATALOG_KEYS = frozenset({"asterisk", "asterisk_geneseasx", "asterisk_satbox"}) @@ -12,6 +21,8 @@ ASTERISK_CATALOG_KEYS = frozenset({"asterisk", "asterisk_geneseasx", "asterisk_s GENESEASX_KEYS = frozenset({"asterisk", "asterisk_geneseasx"}) SATBOX_KEYS = frozenset({"asterisk_satbox"}) +EmitFn = Callable[[int, str, str, dict | None], Awaitable[None]] + def asterisk_container_name(catalog_key: str | None) -> str: """Docker container name for GeneseasX Asterisk (default: voip).""" @@ -40,10 +51,32 @@ def _docker_asterisk(command: str): return builder +def _docker_cat_config(relative_path: str, *, config_root: str = "/etc/asterisk"): + def builder(c: dict) -> dict: + container = c.get("asterisk_container") or asterisk_container_name(c.get("catalog_key")) or "voip" + full = f"{config_root.rstrip('/')}/{relative_path.lstrip('/')}" + return { + "command": ( + f"docker exec {shlex.quote(container)} cat {shlex.quote(full)} 2>/dev/null " + f"|| echo '__MISSING__'" + ) + } + + return builder + + def _asterisk_cli(command: str): return lambda c: {"command": f'asterisk -rx "{command}"'} +def _baseline_device_ctx(device: dict, vessel_context: dict | None) -> dict: + ctx = dict(vessel_context or {}) + meta = device.get("metadata") or device.get("baseline_vars") or {} + if isinstance(meta, dict): + ctx.update({k: v for k, v in meta.items() if v is not None}) + return ctx + + # Native Debian host diagnostics (TMGeneseas / satbox) via SSH. SATBOX_ASTERISK_DIAGNOSTICS = [ ("ssh_run", _asterisk_cli("pjsip show registrations")), @@ -70,7 +103,6 @@ ASTERISK_PLAYBOOKS: dict[str, dict] = { "connect": ("ssh_connect", _ssh_connect), "diagnostics": GENESEASX_ASTERISK_DIAGNOSTICS, }, - # Legacy catalog key — same as GeneseasX "asterisk": { "mcp_server": "ssh-generic-mcp", "connect": ("ssh_connect", _ssh_connect), @@ -82,3 +114,164 @@ ASTERISK_PLAYBOOKS: dict[str, dict] = { "diagnostics": SATBOX_ASTERISK_DIAGNOSTICS, }, } + + +async def run_asterisk_diagnostics( + task_id: int, + device: dict, + manager, + emit: EmitFn, + *, + title: str = "", + issue: str = "", + vessel_context: dict | None = None, + max_diagnostics: int = 8, +) -> list[dict]: + """Connect to Asterisk (GeneseasX or satbox) and run CLI + config baseline checks.""" + catalog_key = device.get("catalog_key") or "" + pb = ASTERISK_PLAYBOOKS.get(catalog_key) + if not pb: + pb = ASTERISK_PLAYBOOKS.get("asterisk_geneseasx") + if not pb: + return [] + + results: list[dict] = [] + label = catalog_key or device.get("name") or "asterisk" + server = pb["mcp_server"] + + server_state = manager.servers.get(server or "") + if not server or not server_state or server_state.status != "loaded": + await emit( + task_id, + "diagnose", + f"MCP '{server}' not available for {label}.", + {"device": label, "server": server}, + ) + return results + + profile = profile_for_catalog(catalog_key) + intent = "voip + config baseline" if profile else "voip health" + + await emit( + task_id, + "connect", + f"Connecting to {label} via {server} ({intent})...", + {"device": label, "intent": intent, "baseline_profile": profile}, + ) + + connect = pb.get("connect") + if connect: + tool, builder = connect + ok = await invoke_connect(task_id, label, server, tool, builder(device), manager, emit) + if not ok: + return results + + for tool, builder in pb["diagnostics"][:max_diagnostics]: + args = builder(device) + res, tool_ok = await invoke_tool( + task_id, + label, + server, + tool, + args, + manager, + emit, + task_title=title, + task_issue=issue, + ) + results.append( + { + "device": label, + "tool": tool, + "ok": tool_ok, + "output": res.get("text", ""), + "intent": intent, + } + ) + + if profile: + baseline_result = await _run_config_baseline( + task_id, + device, + manager, + emit, + profile=profile, + label=label, + server=server, + title=title, + issue=issue, + vessel_context=vessel_context, + ) + if baseline_result: + results.append(baseline_result) + + return results + + +async def _run_config_baseline( + task_id: int, + device: dict, + manager, + emit: EmitFn, + *, + profile: str, + label: str, + server: str, + title: str, + issue: str, + vessel_context: dict | None, +) -> dict | None: + paths = config_paths_for_profile(profile) + if not paths: + return None + + config_root = config_root_for_profile(profile) + live_files: dict[str, str] = {} + + await emit( + task_id, + "diagnose", + f"Fetching Asterisk config files for baseline ({profile})…", + {"device": label, "profile": profile, "files": paths}, + ) + + for path in paths: + args = _docker_cat_config(path, config_root=config_root)(device) + res, ok = await invoke_tool( + task_id, + label, + server, + "ssh_run", + args, + manager, + emit, + task_title=title, + task_issue=issue, + ) + live_files[path] = res.get("text", "") if ok else "__MISSING__" + + report = compare_against_baseline( + live_files, + profile=profile, + device_ctx=_baseline_device_ctx(device, vessel_context), + ) + if not report: + return None + + payload = report.as_dict() + summary = report.summary + await emit( + task_id, + "diagnose", + summary, + {"device": label, "baseline": payload}, + ) + + return { + "device": label, + "tool": "asterisk_config_baseline", + "ok": report.ok, + "output": json.dumps(payload, indent=2), + "intent": "config baseline", + "baseline": payload, + } diff --git a/backend/app/agent/graph.py b/backend/app/agent/graph.py index 4a7a1ee..e4aee8b 100644 --- a/backend/app/agent/graph.py +++ b/backend/app/agent/graph.py @@ -17,7 +17,11 @@ from app.agent.json_utils import parse_json as _parse_json from app.agent.playbooks import playbook_for from app.agent.pfsense_profiles import run_pfsense_diagnostics from app.agent.proxmox_profiles import enrich_proxmox_device, run_proxmox_diagnostics -from app.agent.asterisk_profiles import asterisk_container_name +from app.agent.asterisk_profiles import ( + ASTERISK_CATALOG_KEYS, + asterisk_container_name, + run_asterisk_diagnostics, +) from app.agent.tool_runner import invoke_connect, invoke_tool from app.core.crypto import decrypt_secret from app.config import settings @@ -249,6 +253,7 @@ async def _run_device_diagnostics( *, title: str = "", issue: str = "", + vessel_context: dict | None = None, ) -> list[dict]: """Connect to one onboard device and run read-only playbook diagnostics.""" from app.models.enums import DeviceType as DT @@ -279,6 +284,18 @@ async def _run_device_diagnostics( max_diagnostics=8, ) + if device.get("catalog_key") in ASTERISK_CATALOG_KEYS or dtype == DT.asterisk: + return await run_asterisk_diagnostics( + task_id, + device, + manager, + _emit, + title=title, + issue=issue, + vessel_context=vessel_context, + max_diagnostics=MAX_DIAGNOSTICS_PER_DEVICE, + ) + pb = playbook_for(dtype, device.get("mcp_server"), device.get("catalog_key")) if not pb: @@ -395,6 +412,10 @@ async def diagnose_node(state: AgentState) -> AgentState: manager, title=state.get("title") or "", issue=state.get("issue") or "", + vessel_context={ + "vessel_name": state.get("vessel_name"), + "vessel_public_ip": state.get("vessel_public_ip"), + }, ) ) return {"diagnostics": diagnostics} diff --git a/backend/app/config.py b/backend/app/config.py index f8f0d21..0f931e6 100644 --- a/backend/app/config.py +++ b/backend/app/config.py @@ -80,6 +80,9 @@ class Settings(BaseSettings): skills_max_matched: int = 2 skills_max_body_chars: int = 8000 + # Asterisk golden config templates (templates/asterisk//manifest.yaml) + asterisk_templates_path: str = "/app/templates/asterisk" + # Runtime paths (inside container) runtime_dir: str = "/runtime" diff --git a/backend/app/services/asterisk_baseline.py b/backend/app/services/asterisk_baseline.py new file mode 100644 index 0000000..df91afd --- /dev/null +++ b/backend/app/services/asterisk_baseline.py @@ -0,0 +1,293 @@ +"""Compare live Asterisk config against golden templates on disk.""" +from __future__ import annotations + +import logging +import os +import re +from dataclasses import dataclass, field +from typing import Any + +import yaml + +from app.config import settings + +logger = logging.getLogger(__name__) + +_manifest_cache: dict[str, dict] = {} +_manifest_mtime: float = -1.0 + +_PLACEHOLDER_RE = re.compile(r"\{\{([A-Z0-9_]+)\}\}") + + +@dataclass +class BaselineCheck: + kind: str # required_line | required_substring + expected: str + found: bool + ok: bool + + def as_dict(self) -> dict: + return { + "kind": self.kind, + "expected": self.expected, + "found": self.found, + "ok": self.ok, + } + + +@dataclass +class BaselineFileResult: + path: str + description: str + ok: bool + missing: bool = False + checks: list[BaselineCheck] = field(default_factory=list) + + def as_dict(self) -> dict: + return { + "path": self.path, + "description": self.description, + "ok": self.ok, + "missing": self.missing, + "checks": [c.as_dict() for c in self.checks], + } + + +@dataclass +class BaselineReport: + profile: str + ok: bool + variables: dict[str, str] + files: list[BaselineFileResult] + summary: str + + def as_dict(self) -> dict: + return { + "profile": self.profile, + "ok": self.ok, + "variables": self.variables, + "files": [f.as_dict() for f in self.files], + "summary": self.summary, + } + + +def templates_root() -> str: + return os.environ.get("ASTERISK_TEMPLATES_PATH", settings.asterisk_templates_path) + + +def manifest_path(profile: str = "geneseasx") -> str: + return os.path.join(templates_root(), profile, "manifest.yaml") + + +def profile_for_catalog(catalog_key: str | None) -> str | None: + root = templates_root() + if not os.path.isdir(root): + return None + key = (catalog_key or "").lower() + for name in sorted(os.listdir(root)): + path = os.path.join(root, name, "manifest.yaml") + if not os.path.isfile(path): + continue + try: + with open(path, encoding="utf-8") as fh: + data = yaml.safe_load(fh) or {} + except (OSError, yaml.YAMLError): + continue + keys = [str(k).lower() for k in (data.get("catalog_keys") or [])] + if key in keys: + return name + return None + + +def load_manifest(profile: str, *, force: bool = False) -> dict | None: + global _manifest_cache, _manifest_mtime + path = manifest_path(profile) + try: + mtime = os.path.getmtime(path) + except OSError: + return None + + if not force and _manifest_cache.get(profile) and _manifest_mtime == mtime: + return _manifest_cache[profile] + + try: + with open(path, encoding="utf-8") as fh: + data = yaml.safe_load(fh) or {} + except (OSError, yaml.YAMLError) as exc: + logger.warning("invalid asterisk manifest %s: %s", path, exc) + return None + + _manifest_cache[profile] = data + _manifest_mtime = mtime + return data + + +def invalidate_manifest_cache() -> None: + global _manifest_cache, _manifest_mtime + _manifest_cache = {} + _manifest_mtime = -1.0 + + +def substitute(text: str, variables: dict[str, str]) -> str: + out = text + for name, value in variables.items(): + out = out.replace(f"{{{{{name}}}}}", value) + out = out.replace(f"__{name}__", value) + return out + + +def resolve_variables(manifest: dict, device_ctx: dict | None = None) -> dict[str, str]: + """Merge manifest defaults, env overrides, and optional device context.""" + device_ctx = device_ctx or {} + variables: dict[str, str] = { + str(k): str(v) for k, v in (manifest.get("variables") or {}).items() + } + + for name in list(variables.keys()): + env_key = f"ASTERISK_BASELINE_{name}" + env_val = os.environ.get(env_key, "").strip() + if env_val: + variables[name] = env_val + + # Optional per-device overrides (inventory metadata or env-derived) + mapping = { + "ASTERISK_IP": device_ctx.get("asterisk_ip"), + "PHONE_SUBNET": device_ctx.get("phone_subnet"), + "VOIP_SUBNET": device_ctx.get("voip_subnet"), + "RTP_START": device_ctx.get("rtp_start"), + "RTP_END": device_ctx.get("rtp_end"), + } + for name, val in mapping.items(): + if val: + variables[name] = str(val) + + return variables + + +def _normalize_ini_line(line: str) -> str: + line = line.strip() + if not line or line.startswith(";") or line.startswith("#"): + return "" + line = line.split(";", 1)[0].strip() + line = re.sub(r"\s+", "", line) + return line.lower() + + +def _content_normalized_lines(content: str) -> list[str]: + return [n for n in (_normalize_ini_line(ln) for ln in content.splitlines()) if n] + + +def _line_present(content: str, required: str) -> bool: + req = _normalize_ini_line(required) + if not req: + return True + norm_lines = _content_normalized_lines(content) + if req in norm_lines: + return True + # Allow key=value match when live file has extra whitespace/casing + if "=" in req: + key, _, val = req.partition("=") + for line in norm_lines: + if line.startswith(key + "=") and val in line: + return True + return False + + +def _substring_present(content: str, needle: str) -> bool: + return needle.strip().lower() in content.lower() + + +def compare_file( + live_content: str | None, + file_spec: dict, + variables: dict[str, str], +) -> BaselineFileResult: + path = str(file_spec.get("path") or "") + description = str(file_spec.get("description") or "") + checks: list[BaselineCheck] = [] + + if live_content is None or live_content.strip() == "__MISSING__": + return BaselineFileResult( + path=path, + description=description, + ok=False, + missing=True, + checks=checks, + ) + + for raw in file_spec.get("required_lines") or []: + expected = substitute(str(raw), variables) + found = _line_present(live_content, expected) + checks.append( + BaselineCheck( + kind="required_line", + expected=expected, + found=found, + ok=found, + ) + ) + + for raw in file_spec.get("required_substrings") or []: + expected = substitute(str(raw), variables) + found = _substring_present(live_content, expected) + checks.append( + BaselineCheck( + kind="required_substring", + expected=expected, + found=found, + ok=found, + ) + ) + + ok = all(c.ok for c in checks) if checks else True + return BaselineFileResult(path=path, description=description, ok=ok, checks=checks) + + +def compare_against_baseline( + live_files: dict[str, str], + *, + profile: str, + device_ctx: dict | None = None, +) -> BaselineReport | None: + manifest = load_manifest(profile) + if not manifest: + return None + + variables = resolve_variables(manifest, device_ctx) + file_results: list[BaselineFileResult] = [] + + for file_spec in manifest.get("files") or []: + path = str(file_spec.get("path") or "") + live = live_files.get(path) + file_results.append(compare_file(live, file_spec, variables)) + + total_checks = sum(len(f.checks) for f in file_results) + passed = sum(1 for f in file_results for c in f.checks if c.ok) + files_ok = all(f.ok for f in file_results) if file_results else True + + if any(f.missing for f in file_results): + summary = f"Config baseline: {passed}/{total_checks} checks passed; some files missing" + elif total_checks == 0: + summary = "Config baseline: no checks defined" + else: + summary = f"Config baseline: {passed}/{total_checks} checks passed" + + return BaselineReport( + profile=profile, + ok=files_ok and passed == total_checks, + variables=variables, + files=file_results, + summary=summary, + ) + + +def config_paths_for_profile(profile: str) -> list[str]: + manifest = load_manifest(profile) + if not manifest: + return [] + return [str(f.get("path")) for f in (manifest.get("files") or []) if f.get("path")] + + +def config_root_for_profile(profile: str) -> str: + manifest = load_manifest(profile) or {} + return str(manifest.get("config_root") or "/etc/asterisk").rstrip("/") diff --git a/backend/app/services/diagnostic_format.py b/backend/app/services/diagnostic_format.py index 1f54384..e7a9724 100644 --- a/backend/app/services/diagnostic_format.py +++ b/backend/app/services/diagnostic_format.py @@ -273,8 +273,41 @@ def _qm_config_text_block(text: str) -> str: return "\n".join(lines) if len(lines) > 2 else f"```\n{_clip(text, 8000)}\n```" +def _baseline_drift_table(payload: dict) -> str: + lines = [ + f"**Profile:** `{payload.get('profile', '?')}` — {payload.get('summary', '')}", + "", + "| File | Check | Expected | Status |", + "|---|---|---|---|", + ] + for file_entry in payload.get("files") or []: + path = file_entry.get("path") or "?" + if file_entry.get("missing"): + lines.append(f"| `{path}` | file | present | **MISSING** |") + continue + checks = file_entry.get("checks") or [] + if not checks: + lines.append(f"| `{path}` | — | — | ok |") + continue + for i, check in enumerate(checks): + path_col = f"`{path}`" if i == 0 else "" + kind = check.get("kind") or "check" + expected = _cell(check.get("expected") or "") + status = "ok" if check.get("ok") else "**DRIFT**" + lines.append(f"| {path_col} | {kind} | {expected} | {status} |") + vars_used = payload.get("variables") or {} + if vars_used: + lines.append("") + lines.append("Variables: " + ", ".join(f"`{k}={v}`" for k, v in vars_used.items())) + return "\n".join(lines) + + def format_tool_output_markdown(tool: str, output: str) -> str: """Turn raw MCP JSON/text into readable markdown when possible.""" + if tool == "asterisk_config_baseline": + parsed = _parse_json_payload(output) + if isinstance(parsed, dict): + return _baseline_drift_table(parsed) parsed = _parse_json_payload(output) if tool == "proxmox_list_qemu": if isinstance(parsed, (dict, list)): @@ -325,7 +358,7 @@ def prepare_report_diagnostics(diagnostics: list[dict]) -> list[dict]: formatted = "" tool = d.get("tool") if text: - if tool in _TABLE_TOOLS: + if tool in _TABLE_TOOLS or tool == "asterisk_config_baseline": formatted = format_tool_output_markdown(tool, text) elif tool == "ssh_run": if re.search(r"^\s*\d+\s+\S+\s+(running|stopped)", text, re.MULTILINE): diff --git a/backend/tests/test_asterisk_baseline.py b/backend/tests/test_asterisk_baseline.py new file mode 100644 index 0000000..040aaed --- /dev/null +++ b/backend/tests/test_asterisk_baseline.py @@ -0,0 +1,138 @@ +"""Tests for Asterisk golden config baseline.""" +from __future__ import annotations + +import json +import os +from pathlib import Path + +os.environ.setdefault("SECRET_KEY", "test-secret-key-for-unit-tests-only") +_test_dir = Path(__file__).resolve().parent +_repo_root = _test_dir.parent.parent +_templates_root = _repo_root / "templates" / "asterisk" +if not _templates_root.is_dir(): + _templates_root = _test_dir.parent / "templates" / "asterisk" +os.environ["ASTERISK_TEMPLATES_PATH"] = str(_templates_root) + +from app.services.asterisk_baseline import ( # noqa: E402 + compare_against_baseline, + compare_file, + invalidate_manifest_cache, + profile_for_catalog, + resolve_variables, + substitute, +) +from app.services.diagnostic_format import format_tool_output_markdown # noqa: E402 + + +def test_profile_for_geneseasx_catalog(): + invalidate_manifest_cache() + assert profile_for_catalog("asterisk_geneseasx") == "geneseasx" + assert profile_for_catalog("asterisk") == "geneseasx" + assert profile_for_catalog("asterisk_satbox") is None + + +def test_substitute_variables(): + assert substitute("local_net={{PHONE_SUBNET}}", {"PHONE_SUBNET": "192.168.0.0/24"}) == ( + "local_net=192.168.0.0/24" + ) + + +def test_compare_file_detects_missing_line(): + live = """ +[transport-udp] +type=transport +local_net=10.20.30.0/24 +external_media_address=10.20.30.222 +""" + spec = { + "path": "pjsip.conf", + "description": "test", + "required_lines": [ + "local_net={{PHONE_SUBNET}}", + "local_net={{VOIP_SUBNET}}", + ], + } + variables = {"PHONE_SUBNET": "192.168.0.0/24", "VOIP_SUBNET": "10.20.30.0/24"} + result = compare_file(live, spec, variables) + assert result.ok is False + assert any(not c.ok for c in result.checks) + + +def test_compare_file_passes_golden_pjsip(): + live = """ +[transport-udp] +type=transport +protocol=udp +local_net=192.168.0.0/24 +local_net=10.20.30.0/24 +external_media_address=10.20.30.222 +external_signaling_address=10.20.30.222 +""" + spec = { + "path": "pjsip.conf", + "required_lines": [ + "local_net={{PHONE_SUBNET}}", + "local_net={{VOIP_SUBNET}}", + "external_media_address={{ASTERISK_IP}}", + "external_signaling_address={{ASTERISK_IP}}", + ], + } + variables = { + "PHONE_SUBNET": "192.168.0.0/24", + "VOIP_SUBNET": "10.20.30.0/24", + "ASTERISK_IP": "10.20.30.222", + } + result = compare_file(live, spec, variables) + assert result.ok is True + + +def test_compare_against_baseline_full_report(): + invalidate_manifest_cache() + live_files = { + "pjsip.conf": """ +local_net=192.168.0.0/24 +local_net=10.20.30.0/24 +external_media_address=10.20.30.222 +external_signaling_address=10.20.30.222 +""", + "rtp.conf": "rtpstart=10000\nrtpend=10029\n", + "extensions.conf": "[public]\nexten => _7XXXX,1,NoOp(trunk)\n", + } + report = compare_against_baseline(live_files, profile="geneseasx") + assert report is not None + assert report.ok is True + assert "6/6" in report.summary or "checks passed" in report.summary + + +def test_env_override_variables(monkeypatch): + invalidate_manifest_cache() + from app.services.asterisk_baseline import load_manifest + + manifest = load_manifest("geneseasx", force=True) + monkeypatch.setenv("ASTERISK_BASELINE_ASTERISK_IP", "10.99.99.99") + vars = resolve_variables(manifest or {}, {}) + assert vars["ASTERISK_IP"] == "10.99.99.99" + + +def test_baseline_markdown_formatting(): + payload = { + "profile": "geneseasx", + "summary": "Config baseline: 5/6 checks passed", + "variables": {"ASTERISK_IP": "10.20.30.222"}, + "files": [ + { + "path": "pjsip.conf", + "missing": False, + "checks": [ + { + "kind": "required_line", + "expected": "local_net=192.168.0.0/24", + "ok": False, + } + ], + } + ], + } + md = format_tool_output_markdown("asterisk_config_baseline", json.dumps(payload)) + assert "DRIFT" in md + assert "pjsip.conf" in md diff --git a/docker-compose.yml b/docker-compose.yml index 4b8b59c..09e2a69 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -72,6 +72,7 @@ services: - ./mcp-servers:/app/mcp-servers:ro - ./rules:/app/rules - ./skills:/app/skills + - ./templates:/app/templates ports: - "8000:8000" command: ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"] @@ -94,6 +95,7 @@ services: - ./mcp-servers:/app/mcp-servers:ro - ./rules:/app/rules - ./skills:/app/skills + - ./templates:/app/templates command: ["python", "-m", "app.worker"] frontend: diff --git a/docs/README.md b/docs/README.md new file mode 100644 index 0000000..a5db649 --- /dev/null +++ b/docs/README.md @@ -0,0 +1,22 @@ +# Agentic OS Documentation + +Agentic OS is a self-hosted troubleshooting platform for vessel and homelab infrastructure. It combines an operator UI, a FastAPI backend, a LangGraph diagnostic agent, MCP tool integrations, local/cloud LLM routing, and durable reporting to project memory and Obsidian. + +## Documentation Map + +- [Project Overview](./project-overview.md) — purpose, core capabilities, users, and supported infrastructure. +- [Architecture](./architecture.md) — services, data flow, task lifecycle, persistence, and integration boundaries. +- [Operator Guide](./operator-guide.md) — how to use the dashboard, inventory, tasks, approvals, reports, and artifacts. +- [Developer and Operations Guide](./developer-operations.md) — local stack, configuration, tests, deployment notes, and extension points. + +## Quick Links + +- Frontend: `frontend/app/` +- Backend API: `backend/app/api/` +- Agent workflow: `backend/app/agent/graph.py` +- MCP manager: `backend/app/mcp_manager/` +- Rules: `rules/troubleshooting.yaml` +- Skills: `skills/*/SKILL.md` +- Templates: `templates/` +- Runtime stack: `docker-compose.yml` + diff --git a/skills/vessel-voip/SKILL.md b/skills/vessel-voip/SKILL.md index 076755c..26a6685 100644 --- a/skills/vessel-voip/SKILL.md +++ b/skills/vessel-voip/SKILL.md @@ -88,6 +88,7 @@ Interface map (verify on device): `vtnet0`=WAN1, `vtnet1`=WAN2, `vtnet2`=CREW, ` - pfSense rule tracker IDs, filter.log lines for blocked RTP/SIP - Asterisk CLI: endpoint config, channel stats, `core show channels` +- **Config baseline drift** — task report includes `asterisk_config_baseline` when GeneseasX Asterisk is diagnosed; fix any **DRIFT** on `local_net`, RTP range, dialplan before chasing firewall - Do not propose firewall writes without backup — see pfsense-change-safe skill ## During investigation diff --git a/templates/asterisk/geneseasx/manifest.yaml b/templates/asterisk/geneseasx/manifest.yaml new file mode 100644 index 0000000..9a9f3f0 --- /dev/null +++ b/templates/asterisk/geneseasx/manifest.yaml @@ -0,0 +1,42 @@ +# GeneseasX Asterisk golden config baseline +# Used by asterisk_baseline.py to compare live container config on task runs. +# +# Variables use {{NAME}} placeholders — resolved from: +# 1. This file (defaults) +# 2. Env ASTERISK_BASELINE_ (e.g. ASTERISK_BASELINE_ASTERISK_IP) +# 3. Per-vessel device metadata (future UI fields) + +profile: geneseasx +catalog_keys: + - asterisk_geneseasx + - asterisk + +variables: + ASTERISK_IP: "10.20.30.222" + PHONE_SUBNET: "192.168.0.0/24" + VOIP_SUBNET: "10.20.30.0/24" + RTP_START: "10000" + RTP_END: "10029" + +config_root: /etc/asterisk + +files: + - path: pjsip.conf + description: PJSIP NAT / SDP — critical for registration and one-way audio + required_lines: + - "local_net={{PHONE_SUBNET}}" + - "local_net={{VOIP_SUBNET}}" + - "external_media_address={{ASTERISK_IP}}" + - "external_signaling_address={{ASTERISK_IP}}" + + - path: rtp.conf + description: RTP port range — must align with pfSense pass rules (UDP 10000-10029) + required_lines: + - "rtpstart={{RTP_START}}" + - "rtpend={{RTP_END}}" + + - path: extensions.conf + description: Dialplan — trunk patterns and internal routing + required_substrings: + - "[public]" + - "exten => _7" diff --git a/templates/asterisk/geneseasx/pjsip.conf.snippet b/templates/asterisk/geneseasx/pjsip.conf.snippet new file mode 100644 index 0000000..c07c9d0 --- /dev/null +++ b/templates/asterisk/geneseasx/pjsip.conf.snippet @@ -0,0 +1,13 @@ +; Golden reference snippet — GeneseasX Asterisk PJSIP (not executed; for human review). +; Live checks use manifest.yaml required_lines against /etc/asterisk/pjsip.conf in container. + +[transport-udp] +type=transport +protocol=udp +bind=0.0.0.0:5060 +local_net={{PHONE_SUBNET}} +local_net={{VOIP_SUBNET}} +external_media_address={{ASTERISK_IP}} +external_signaling_address={{ASTERISK_IP}} + +; Endpoint includes vary by vessel — verify local_net on transport and per-endpoint if used.