Files
agent-call/agent_call/cell_agent.py
T

668 lines
23 KiB
Python

from __future__ import annotations
import hashlib
import json
import os
import sqlite3
import ssl
import subprocess
import tempfile
import threading
import uuid
from collections.abc import Mapping
from contextlib import contextmanager, suppress
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from pathlib import Path
from typing import Any
from urllib.parse import unquote, urlsplit
from .sip_management import (
SipManagementError,
_id,
_json,
_validate_trunk,
asterisk_codec_allow,
)
CELL_BODY_BYTES = 256 * 1024
class CellAgentError(Exception):
def __init__(
self,
status: int,
code: str,
detail: str,
extra: Mapping[str, Any] | None = None,
) -> None:
super().__init__(detail)
self.status = status
self.code = code
self.detail = detail
self.extra = dict(extra or {})
def response(self) -> dict[str, Any]:
return {"error": {"code": self.code, "message": self.detail, **self.extra}}
class CellAgentStore:
def __init__(self, db_path: str | Path) -> None:
self.path = str(db_path)
if self.path != ":memory:":
Path(self.path).parent.mkdir(parents=True, exist_ok=True)
self._lock = threading.RLock()
self._db = sqlite3.connect(self.path, timeout=5, check_same_thread=False)
self._db.row_factory = sqlite3.Row
self._db.execute(
"PRAGMA journal_mode = WAL"
) if self.path != ":memory:" else None
self._db.execute("PRAGMA busy_timeout = 5000")
self._db.executescript(
"""
CREATE TABLE IF NOT EXISTS trunk_state (
trunk_id TEXT PRIMARY KEY,
desired_revision INTEGER NOT NULL,
desired_hash TEXT NOT NULL,
desired_config_json TEXT NOT NULL,
applied_revision INTEGER NOT NULL,
applied_hash TEXT,
applied_config_json TEXT,
status TEXT NOT NULL,
last_error TEXT,
updated_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS audit_log (
audit_id TEXT PRIMARY KEY,
trunk_id TEXT NOT NULL,
revision INTEGER NOT NULL,
action TEXT NOT NULL,
details_json TEXT NOT NULL,
created_at TEXT NOT NULL
);
"""
)
self._db.commit()
@contextmanager
def write(self):
with self._lock:
self._db.execute("BEGIN IMMEDIATE")
try:
yield self._db
except Exception:
self._db.rollback()
raise
else:
self._db.commit()
@contextmanager
def read(self):
with self._lock:
yield self._db
def close(self) -> None:
with self._lock:
self._db.close()
def _now() -> str:
from datetime import datetime, timezone
return (
datetime.now(timezone.utc)
.replace(microsecond=0)
.isoformat()
.replace("+00:00", "Z")
)
def _decode(value: str, detail: str) -> Any:
try:
return json.loads(value)
except json.JSONDecodeError as exc:
raise CellAgentError(500, "CORRUPT_STATE", detail) from exc
def _hash_config(config: Mapping[str, Any]) -> str:
return hashlib.sha256(_json(config).encode("utf-8")).hexdigest()
def _request_id(value: str | None) -> str:
if not value or len(value) > 128 or value != value.strip():
raise CellAgentError(400, "MISSING_REQUEST_ID", "X-Request-ID is required")
if any(ord(char) < 32 for char in value):
raise CellAgentError(400, "INVALID_REQUEST_ID", "X-Request-ID is invalid")
return value
def _parse_config(body: Any, trunk_id: str) -> tuple[dict[str, Any], str]:
if not isinstance(body, dict):
raise CellAgentError(
400, "INVALID_PUBLICATION", "publication must be an object"
)
required = {"mode", "cell_id", "trunk_id", "revision", "config", "config_sha256"}
if set(body) != required:
raise CellAgentError(
400, "INVALID_PUBLICATION", "publication fields are invalid"
)
if body["mode"] != "real":
raise CellAgentError(
400, "INVALID_MODE", "Cell Agent accepts real publications only"
)
if body["trunk_id"] != trunk_id:
raise CellAgentError(400, "TRUNK_MISMATCH", "path and body trunk_id differ")
if (
not isinstance(body["revision"], int)
or isinstance(body["revision"], bool)
or body["revision"] < 1
):
raise CellAgentError(
400, "INVALID_REVISION", "revision must be a positive integer"
)
digest = body["config_sha256"]
if (
not isinstance(digest, str)
or len(digest) != 64
or any(char not in "0123456789abcdef" for char in digest)
):
raise CellAgentError(
400, "INVALID_CONFIG_HASH", "config_sha256 must be lowercase SHA-256"
)
try:
config = _validate_trunk(trunk_id, body["config"])
except SipManagementError as exc:
raise CellAgentError(exc.status, exc.code, exc.detail, exc.extra) from exc
actual = _hash_config(config)
if actual != digest:
raise CellAgentError(
400, "CONFIG_HASH_MISMATCH", "config_sha256 does not match config"
)
return config, digest
def _asterisk_name(trunk_id: str) -> str:
return "agent_trunk_" + trunk_id.encode("utf-8").hex()
def render_pjsip_trunk(config: Mapping[str, Any], trunk_id: str | None = None) -> str:
if config["sip"]["auth_mode"] == "digest":
raise CellAgentError(
422,
"DIGEST_SECRET_RESOLUTION_REQUIRED",
"Cell Agent requires a local secret resolver for digest trunks",
)
name = _asterisk_name(
trunk_id or config.get("trunk_id") or config.get("display_name", "trunk")
)
sip = config["sip"]
host = sip["host"]
contact_host = f"[{host}]" if ":" in host and not host.startswith("[") else host
scheme = "sips" if sip["transport"] == "tls" else "sip"
allow = ",".join(asterisk_codec_allow(config["codec_profile"]))
caller = config["caller_ids"][0]
aor = f"{name}-aor"
return (
f"[{name}]\n"
"type=endpoint\n"
f"transport=transport-{sip['transport']}\n"
"context=deny-inbound\n"
"disallow=all\n"
f"allow={allow}\n"
"direct_media=no\n"
"rtp_symmetric=yes\n"
"force_rport=yes\n"
"rewrite_contact=yes\n"
f"from_user={caller}\n"
f"aors={aor}\n\n"
f"[{aor}]\n"
"type=aor\n"
f"contact={scheme}:{contact_host}:{sip['port']}\n"
"qualify_frequency=30\n"
)
class AsteriskConfigApplier:
def __init__(
self,
config_dir: str | Path,
*,
asterisk_cli: str = "/usr/sbin/asterisk",
reload_timeout: float = 30.0,
) -> None:
self.config_dir = Path(config_dir)
self.asterisk_cli = asterisk_cli
self.reload_timeout = reload_timeout
def _target(self, trunk_id: str) -> Path:
return self.config_dir / f"{_asterisk_name(trunk_id)}.conf"
def _reload(self) -> None:
try:
result = subprocess.run(
[self.asterisk_cli, "-rx", "pjsip reload"],
stdin=subprocess.DEVNULL,
capture_output=True,
check=False,
timeout=self.reload_timeout,
text=True,
)
except (OSError, subprocess.SubprocessError) as exc:
raise CellAgentError(
502, "ASTERISK_RELOAD_FAILED", "Asterisk reload command failed"
) from exc
if result.returncode != 0:
raise CellAgentError(
502, "ASTERISK_RELOAD_FAILED", "Asterisk rejected the PJSIP reload"
)
def apply(self, trunk_id: str, config: Mapping[str, Any]) -> None:
self.config_dir.mkdir(parents=True, exist_ok=True)
target = self._target(trunk_id)
previous = target.read_bytes() if target.exists() else None
try:
if config["enabled"]:
content = render_pjsip_trunk(config, trunk_id).encode("utf-8")
fd, temporary = tempfile.mkstemp(
prefix=target.name + ".", dir=self.config_dir
)
try:
with os.fdopen(fd, "wb") as stream:
stream.write(content)
stream.flush()
os.fsync(stream.fileno())
os.chmod(temporary, 0o640)
os.replace(temporary, target)
finally:
if os.path.exists(temporary):
os.unlink(temporary)
elif target.exists():
target.unlink()
self._reload()
except CellAgentError:
self._restore(target, previous)
raise
def _restore(self, target: Path, previous: bytes | None) -> None:
try:
if previous is None:
if target.exists():
target.unlink()
else:
fd, temporary = tempfile.mkstemp(
prefix=target.name + ".restore.", dir=self.config_dir
)
try:
with os.fdopen(fd, "wb") as stream:
stream.write(previous)
stream.flush()
os.fsync(stream.fileno())
os.chmod(temporary, 0o640)
os.replace(temporary, target)
finally:
if os.path.exists(temporary):
os.unlink(temporary)
with suppress(CellAgentError):
self._reload()
except OSError:
pass
class CellAgentService:
def __init__(
self,
db_path: str | Path,
cell_id: str,
applier: Any,
*,
mode: str = "real",
) -> None:
if mode != "real":
raise ValueError("Cell Agent only supports real mode")
self.cell_id = _id(cell_id, "cell_id")
self.mode = mode
self.applier = applier
self.store = CellAgentStore(db_path)
def close(self) -> None:
self.store.close()
def apply(self, body: Any, request_id: str) -> dict[str, Any]:
request_id = _request_id(request_id)
if not isinstance(body, dict) or body.get("cell_id") != self.cell_id:
raise CellAgentError(
403, "CELL_MISMATCH", "publication is for another Cell"
)
trunk_id = _id(body.get("trunk_id"), "trunk_id")
config, digest = _parse_config(body, trunk_id)
revision = body["revision"]
with self.store.write() as db:
row = db.execute(
"SELECT * FROM trunk_state WHERE trunk_id = ?", (trunk_id,)
).fetchone()
applied_revision = row["applied_revision"] if row else 0
applied_hash = row["applied_hash"] if row else None
if revision == applied_revision and digest == applied_hash:
return {
"mode": self.mode,
"cell_id": self.cell_id,
"trunk_id": trunk_id,
"revision": revision,
"config_sha256": digest,
"status": "applied",
"idempotent": True,
}
if revision <= applied_revision:
raise CellAgentError(
409,
"REVISION_CONFLICT",
"publication revision is older than the applied revision",
{"applied_revision": applied_revision},
)
if revision != applied_revision + 1:
raise CellAgentError(
409,
"REVISION_GAP",
"Cell requires the next revision in sequence",
{"applied_revision": applied_revision},
)
now = _now()
db.execute(
"INSERT INTO trunk_state(trunk_id,desired_revision,desired_hash,desired_config_json,applied_revision,applied_hash,applied_config_json,status,last_error,updated_at) VALUES(?,?,?,?,?,?,?,?,?,?) "
"ON CONFLICT(trunk_id) DO UPDATE SET desired_revision=excluded.desired_revision,desired_hash=excluded.desired_hash,desired_config_json=excluded.desired_config_json,status=excluded.status,last_error=NULL,updated_at=excluded.updated_at",
(
trunk_id,
revision,
digest,
_json(config),
applied_revision,
applied_hash,
row["applied_config_json"] if row else None,
"applying",
None,
now,
),
)
db.execute(
"INSERT INTO audit_log(audit_id,trunk_id,revision,action,details_json,created_at) VALUES(?,?,?,?,?,?)",
(
str(uuid.uuid4()),
trunk_id,
revision,
"apply_started",
_json({"request_id": request_id}),
now,
),
)
try:
self.applier.apply(trunk_id, config)
except CellAgentError as exc:
self._mark_failed(trunk_id, revision, exc.code)
raise
except Exception as exc:
self._mark_failed(trunk_id, revision, "APPLIER_ERROR")
raise CellAgentError(
502, "ASTERISK_APPLY_FAILED", "Cell configuration apply failed"
) from exc
with self.store.write() as db:
now = _now()
db.execute(
"UPDATE trunk_state SET applied_revision=?,applied_hash=?,applied_config_json=?,status='applied',last_error=NULL,updated_at=? WHERE trunk_id=?",
(revision, digest, _json(config), now, trunk_id),
)
db.execute(
"INSERT INTO audit_log(audit_id,trunk_id,revision,action,details_json,created_at) VALUES(?,?,?,?,?,?)",
(
str(uuid.uuid4()),
trunk_id,
revision,
"applied",
_json({"request_id": request_id}),
now,
),
)
return {
"mode": self.mode,
"cell_id": self.cell_id,
"trunk_id": trunk_id,
"revision": revision,
"config_sha256": digest,
"status": "applied",
"idempotent": False,
}
def _mark_failed(self, trunk_id: str, revision: int, error_code: str) -> None:
with self.store.write() as db:
db.execute(
"UPDATE trunk_state SET status='failed',last_error=?,updated_at=? WHERE trunk_id=? AND desired_revision=?",
(error_code, _now(), trunk_id, revision),
)
db.execute(
"INSERT INTO audit_log(audit_id,trunk_id,revision,action,details_json,created_at) VALUES(?,?,?,?,?,?)",
(
str(uuid.uuid4()),
trunk_id,
revision,
"apply_failed",
_json({"error_code": error_code}),
_now(),
),
)
def state(self, trunk_id: str) -> dict[str, Any]:
trunk_id = _id(trunk_id, "trunk_id")
with self.store.read() as db:
row = db.execute(
"SELECT * FROM trunk_state WHERE trunk_id=?", (trunk_id,)
).fetchone()
if not row:
raise CellAgentError(
404, "TRUNK_NOT_FOUND", "trunk has not been applied"
)
return {
"mode": self.mode,
"cell_id": self.cell_id,
"trunk_id": trunk_id,
"desired_revision": row["desired_revision"],
"applied_revision": row["applied_revision"],
"status": row["status"],
"last_error": row["last_error"],
"updated_at": row["updated_at"],
}
class CellAgentHandler(BaseHTTPRequestHandler):
server_version = "agent-call-cell-agent/1"
def __init__(
self,
request: Any,
client_address: Any,
server: Any,
*,
service: CellAgentService,
) -> None:
self.service = service
super().__init__(request, client_address, server)
def log_message(self, format: str, *args: Any) -> None:
return
def _send(self, status: int, payload: Mapping[str, Any]) -> None:
body = json.dumps(payload, ensure_ascii=False).encode("utf-8")
self.send_response(status)
self.send_header("Content-Type", "application/json; charset=utf-8")
self.send_header("Content-Length", str(len(body)))
self.end_headers()
self.wfile.write(body)
def _body(self) -> Any:
raw_length = self.headers.get("Content-Length")
try:
length = int(raw_length or "-1")
except ValueError as exc:
raise CellAgentError(
400, "INVALID_CONTENT_LENGTH", "Content-Length is invalid"
) from exc
if length < 0 or length > CELL_BODY_BYTES:
raise CellAgentError(
413, "PAYLOAD_TOO_LARGE", "publication body is too large"
)
try:
return json.loads(self.rfile.read(length).decode("utf-8"))
except (UnicodeDecodeError, json.JSONDecodeError) as exc:
raise CellAgentError(
400, "INVALID_JSON", "publication body must be JSON"
) from exc
def _path(self) -> list[str]:
parts = [unquote(part) for part in urlsplit(self.path).path.split("/") if part]
if any(part in {".", ".."} for part in parts):
raise CellAgentError(400, "INVALID_PATH", "path is invalid")
return parts
def do_GET(self) -> None:
try:
parts = self._path()
if parts == ["healthz", "live"]:
self._send(
200,
{
"status": "ok",
"mode": self.service.mode,
"cell_id": self.service.cell_id,
},
)
return
if (
len(parts) == 5
and parts[:3] == ["v1", "sip", "trunks"]
and parts[4] == "state"
):
self._send(200, self.service.state(parts[3]))
return
raise CellAgentError(404, "NOT_FOUND", "endpoint does not exist")
except CellAgentError as exc:
self._send(exc.status, exc.response())
def do_POST(self) -> None:
try:
parts = self._path()
if (
len(parts) != 5
or parts[:3] != ["v1", "sip", "trunks"]
or parts[4] != "apply"
):
raise CellAgentError(404, "NOT_FOUND", "endpoint does not exist")
body = self._body()
if not isinstance(body, dict) or body.get("trunk_id") != parts[3]:
raise CellAgentError(
400, "TRUNK_MISMATCH", "path and body trunk_id differ"
)
result = self.service.apply(
body, _request_id(self.headers.get("X-Request-ID"))
)
self._send(200, result)
except CellAgentError as exc:
self._send(exc.status, exc.response())
class CellAgentHTTPServer(ThreadingHTTPServer):
allow_reuse_address = True
daemon_threads = True
def __init__(
self,
address: tuple[str, int],
handler: Any,
*,
ssl_context: ssl.SSLContext | None = None,
) -> None:
self.ssl_context = ssl_context
super().__init__(address, handler)
def get_request(self):
connection, address = super().get_request()
if self.ssl_context is None:
return connection, address
try:
return self.ssl_context.wrap_socket(connection, server_side=True), address
except Exception:
connection.close()
raise
def make_server(
service: CellAgentService,
host: str = "127.0.0.1",
port: int = 9443,
*,
ssl_context: ssl.SSLContext | None = None,
) -> CellAgentHTTPServer:
return CellAgentHTTPServer(
(host, port),
lambda request, client_address, server: CellAgentHandler(
request, client_address, server, service=service
),
ssl_context=ssl_context,
)
def build_server_tls_context(
ca_file: str, cert_file: str, key_file: str
) -> ssl.SSLContext:
context = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER)
context.minimum_version = ssl.TLSVersion.TLSv1_2
context.verify_mode = ssl.CERT_REQUIRED
try:
context.load_cert_chain(certfile=cert_file, keyfile=key_file)
context.load_verify_locations(cafile=ca_file)
except (OSError, ssl.SSLError) as exc:
raise ValueError("Cell Agent TLS files could not be loaded") from exc
return context
def build_from_env() -> tuple[CellAgentService, CellAgentHTTPServer]:
cell_id = os.environ.get("CELL_ID")
if not cell_id:
raise ValueError("CELL_ID is required")
config_dir = os.environ.get("CELL_ASTERISK_CONFIG_DIR", "/etc/asterisk/pjsip.d")
db_path = os.environ.get("CELL_AGENT_DB", "/var/lib/agent-call/cell-agent.sqlite3")
applier = AsteriskConfigApplier(
config_dir,
asterisk_cli=os.environ.get("CELL_ASTERISK_CLI", "/usr/sbin/asterisk"),
)
service = CellAgentService(db_path, cell_id, applier)
ca_file = os.environ.get("CELL_TLS_CA_FILE")
cert_file = os.environ.get("CELL_TLS_CERT_FILE")
key_file = os.environ.get("CELL_TLS_KEY_FILE")
if not ca_file or not cert_file or not key_file:
service.close()
raise ValueError(
"CELL_TLS_CA_FILE, CELL_TLS_CERT_FILE and CELL_TLS_KEY_FILE are required"
)
try:
port = int(os.environ.get("CELL_AGENT_PORT", "9443"))
except ValueError as exc:
service.close()
raise ValueError("CELL_AGENT_PORT must be an integer") from exc
server = make_server(
service,
os.environ.get("CELL_AGENT_HOST", "127.0.0.1"),
port,
ssl_context=build_server_tls_context(ca_file, cert_file, key_file),
)
return service, server
__all__ = [
"AsteriskConfigApplier",
"CellAgentError",
"CellAgentHTTPServer",
"CellAgentService",
"CellAgentStore",
"build_from_env",
"build_server_tls_context",
"make_server",
"render_pjsip_trunk",
]