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.
This commit is contained in:
2026-06-15 07:49:10 +03:00
parent 0375b20bb4
commit 9fc35e86fe
13 changed files with 773 additions and 4 deletions

View File

@@ -123,6 +123,13 @@ SKILLS_PATH=/app/skills
SKILLS_MAX_MATCHED=2
SKILLS_MAX_BODY_CHARS=8000
# Asterisk golden config templates (templates/asterisk/<profile>/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

View File

@@ -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

View File

@@ -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,
}

View File

@@ -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}

View File

@@ -80,6 +80,9 @@ class Settings(BaseSettings):
skills_max_matched: int = 2
skills_max_body_chars: int = 8000
# Asterisk golden config templates (templates/asterisk/<profile>/manifest.yaml)
asterisk_templates_path: str = "/app/templates/asterisk"
# Runtime paths (inside container)
runtime_dir: str = "/runtime"

View File

@@ -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("/")

View File

@@ -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):

View File

@@ -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

View File

@@ -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:

22
docs/README.md Normal file
View File

@@ -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`

View File

@@ -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

View File

@@ -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_<NAME> (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"

View File

@@ -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.