diff --git a/deploy/asterisk_bootstrap.sh b/deploy/asterisk_bootstrap.sh new file mode 100644 index 0000000..5876739 --- /dev/null +++ b/deploy/asterisk_bootstrap.sh @@ -0,0 +1,82 @@ +#!/usr/bin/env bash +# User-data template. build_asterisk_userdata.py prepends the image and config payloads. +set -euo pipefail + +: "${ASTERISK_IMAGE:?generated user-data must set an immutable Asterisk image digest}" +: "${HTTP_CONF_B64:?missing http.conf payload}" +: "${ARI_CONF_B64:?missing ari.conf payload}" +: "${PJSIP_CONF_B64:?missing pjsip.conf payload}" +: "${RTP_CONF_B64:?missing rtp.conf payload}" +: "${EXTENSIONS_CONF_B64:?missing extensions.conf payload}" + +ASTERISK_CONFIG_DIR="/opt/agent-call/asterisk/generated" +ASTERISK_CONFIG_GID="${ASTERISK_CONFIG_GID:-1000}" + +install -d -m 0750 "$ASTERISK_CONFIG_DIR" +write_config() { + local name="$1" payload="$2" path="$ASTERISK_CONFIG_DIR/$1" + printf '%s' "$payload" | base64 --decode >"$path" + chgrp "$ASTERISK_CONFIG_GID" "$path" + chmod 0640 "$path" +} +write_config http.conf "$HTTP_CONF_B64" +write_config ari.conf "$ARI_CONF_B64" +write_config pjsip.conf "$PJSIP_CONF_B64" +write_config rtp.conf "$RTP_CONF_B64" +write_config extensions.conf "$EXTENSIONS_CONF_B64" + +if ! command -v docker >/dev/null 2>&1; then + export DEBIAN_FRONTEND=noninteractive + while fuser /var/lib/dpkg/lock-frontend /var/lib/dpkg/lock >/dev/null 2>&1; do + sleep 5 + done + until apt-get update; do + sleep 10 + done + until apt-get install -y ca-certificates curl docker.io; do + sleep 10 + done +fi + +systemctl daemon-reload +systemctl enable --now docker +until docker info >/dev/null 2>&1; do + sleep 5 +done +until docker pull "$ASTERISK_IMAGE"; do + sleep 15 +done + +docker rm -f agent-call-asterisk >/dev/null 2>&1 || true +docker volume create agent-call-recordings >/dev/null +docker run -d \ + --name agent-call-asterisk \ + --network host \ + --restart unless-stopped \ + --stop-timeout 60 \ + --log-opt max-size=10m \ + --log-opt max-file=3 \ + -v "$ASTERISK_CONFIG_DIR/http.conf:/etc/asterisk/http.conf:ro" \ + -v "$ASTERISK_CONFIG_DIR/ari.conf:/etc/asterisk/ari.conf:ro" \ + -v "$ASTERISK_CONFIG_DIR/pjsip.conf:/etc/asterisk/pjsip.conf:ro" \ + -v "$ASTERISK_CONFIG_DIR/rtp.conf:/etc/asterisk/rtp.conf:ro" \ + -v "$ASTERISK_CONFIG_DIR/extensions.conf:/etc/asterisk/extensions.conf:ro" \ + -v agent-call-recordings:/var/spool/asterisk/recording \ + "$ASTERISK_IMAGE" + +ready=0 +for _ in $(seq 1 60); do + if docker exec agent-call-asterisk asterisk -rx 'core show version' >/dev/null 2>&1; then + ready=1 + break + fi + sleep 2 +done +if [[ "$ready" != 1 ]]; then + echo 'Asterisk did not become CLI-ready; inspect docker logs' >&2 + exit 1 +fi +docker exec agent-call-asterisk asterisk -rx 'pjsip show endpoint provider-primary' +install -d -m 0750 /var/lib/agent-call +date -u +%Y-%m-%dT%H:%M:%SZ >/var/lib/agent-call/bootstrap.done +chmod 0600 /var/lib/agent-call/bootstrap.done diff --git a/deploy/build_asterisk_userdata.py b/deploy/build_asterisk_userdata.py new file mode 100644 index 0000000..e470787 --- /dev/null +++ b/deploy/build_asterisk_userdata.py @@ -0,0 +1,131 @@ +#!/usr/bin/env python3 +"""Build a secret-bearing ECS user-data file without printing its contents.""" + +import argparse +import base64 +import json +import os +import re +import shlex +import sys +import tempfile +from pathlib import Path + +try: + from .render_asterisk import render +except ImportError: # Direct execution: python3 deploy/build_asterisk_userdata.py + from render_asterisk import render + +CONFIG_FILES = ("http.conf", "ari.conf", "pjsip.conf", "rtp.conf", "extensions.conf") +IMAGE_RE = re.compile(r"^[A-Za-z0-9._/-]+(?::[A-Za-z0-9_.-]+)?@sha256:[0-9a-f]{64}$") + + +def read_secret(path): + if path is None: + value = os.environ.get("ARI_PASSWORD", "") + else: + path = Path(path) + if path.is_symlink() or not path.is_file(): + raise ValueError("ARI password file must be an existing non-symlink file") + value = path.read_text().rstrip("\n") + if not value: + raise ValueError("set ARI_PASSWORD or --ari-password-file") + return value + + +def immutable_image(value): + if not isinstance(value, str) or not IMAGE_RE.fullmatch(value): + raise ValueError("--image must be a registry image pinned by @sha256:<64 hex>") + return value + + +def build(cfg, image, password, config_gid=1000, template=None, environment=None): + if ( + isinstance(config_gid, bool) + or not isinstance(config_gid, int) + or not 1 <= config_gid <= 65535 + ): + raise ValueError("config GID must be between 1 and 65535") + env = dict(os.environ if environment is None else environment) + env["ARI_PASSWORD"] = password + rendered = render(cfg, env) + payloads = { + name.upper().replace(".", "_") + "_B64": base64.b64encode( + rendered[name].encode() + ).decode() + for name in CONFIG_FILES + } + variables = [ + "#!/usr/bin/env bash", + "# Generated by deploy/build_asterisk_userdata.py; do not commit this file.", + f"ASTERISK_IMAGE={shlex.quote(image)}", + f"ASTERISK_CONFIG_GID={config_gid}", + ] + variables.extend( + f"{key}={shlex.quote(value)}" for key, value in payloads.items() + ) + variables.append("") + if template is None: + template = Path(__file__).with_name("asterisk_bootstrap.sh") + template_text = Path(template).read_text() + if template_text.startswith("#!"): + template_text = template_text.split("\n", 1)[1] + return "\n".join(variables) + template_text + + +def write_output(content, path): + path = Path(path) + if path.exists() or path.is_symlink(): + raise ValueError("output already exists; choose a new local secret-bearing path") + path.parent.mkdir(parents=True, exist_ok=True) + temporary_path = None + try: + with tempfile.NamedTemporaryFile( + mode="w", + encoding="utf-8", + dir=path.parent, + prefix=".userdata-", + delete=False, + ) as temporary: + temporary.write(content) + temporary.flush() + os.fsync(temporary.fileno()) + temporary_path = Path(temporary.name) + temporary_path.chmod(0o600) + temporary_path.replace(path) + path.chmod(0o600) + except Exception: + if temporary_path is not None: + temporary_path.unlink(missing_ok=True) + raise + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--config", required=True, help="render_asterisk JSON config") + parser.add_argument("--image", help="immutable registry image@sha256:digest") + parser.add_argument("--ari-password-file") + parser.add_argument("--output", required=True, help="local secret-bearing user-data path") + parser.add_argument("--config-gid", type=int, default=1000) + args = parser.parse_args() + try: + cfg = json.loads(Path(args.config).read_text()) + image = immutable_image( + args.image or cfg.get("asterisk_image") or os.environ.get("ASTERISK_IMAGE") + ) + content = build(cfg, image, read_secret(args.ari_password_file), args.config_gid) + if len(content.encode()) > 16 * 1024: + raise ValueError("generated user-data exceeds the ECS 16 KiB limit") + write_output(content, args.output) + print( + f"wrote {args.output} ({len(content.encode())} bytes); " + "secret payload not printed" + ) + except (OSError, ValueError, json.JSONDecodeError) as exc: + print(f"ERROR: {exc}", file=sys.stderr) + return 1 + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/docs/交付文档/一期中间调度件与Asterisk_v1.0/02_Asterisk部署与SIP对接步骤.md b/docs/交付文档/一期中间调度件与Asterisk_v1.0/02_Asterisk部署与SIP对接步骤.md index 10db98a..5b3077a 100644 --- a/docs/交付文档/一期中间调度件与Asterisk_v1.0/02_Asterisk部署与SIP对接步骤.md +++ b/docs/交付文档/一期中间调度件与Asterisk_v1.0/02_Asterisk部署与SIP对接步骤.md @@ -120,6 +120,8 @@ Mock多实例、固定端口和媒体关联方式存在简化,顺序成功不 推荐初版:A/B分别运行在独立Linux主机,Asterisk使用host network;中间件通过管理网访问ARI,媒体组件与节点双向可达。这样避免将仓库同一Docker私网中的音频可达性误认为真实外网可达性。 +单个真实 SIP 测试 Cell 的可重复 ECS UserData 安装流程已落地于`docs/真实SIP_Asterisk快速部署_v1.0.md`,脚本为`deploy/asterisk_bootstrap.sh`和`deploy/build_asterisk_userdata.py`;本文件保留手工部署与验收边界,不把示例参数当成已授权生产线路。 + 同机运行两个host-network节点会争用5060、8088和RTP端口,不允许直接照搬。若必须同机双节点,需另行设计完整端口/地址及NAT映射,不能只改变ARI端口。 | 流向 | 放通与限制 | diff --git a/docs/真实SIP_Asterisk快速部署_v1.0.md b/docs/真实SIP_Asterisk快速部署_v1.0.md new file mode 100644 index 0000000..71aecfc --- /dev/null +++ b/docs/真实SIP_Asterisk快速部署_v1.0.md @@ -0,0 +1,132 @@ +# 真实 SIP/Asterisk 快速部署(v1.0) + +本流程用于在北京 `cn-beijing` 使用固定 EIP `123.56.71.98` 部署单个真实 SIP 测试 Cell。默认只计划,不创建资源;只有明确执行 `--apply` 才会创建/绑定 ECS。 + +> 这是单路联调底座,不是生产 1000 路容量方案。竞价实例使用 `SpotAsPriceGo` 时可能被回收并中断通话。 + +## 1. 已落地脚本 + +| 文件 | 用途 | +| --- | --- | +| `deploy/render_asterisk.py` | 校验并生成 `http.conf`、`ari.conf`、`pjsip.conf`、`rtp.conf`、`extensions.conf` | +| `deploy/asterisk_bootstrap.sh` | ECS UserData:安装 Docker、拉取 digest 镜像、安装配置、启动并检查 Asterisk | +| `deploy/build_asterisk_userdata.py` | 将渲染配置安全嵌入 UserData;输出包含 ARI 密钥,必须放在 600 权限的本地目录 | +| `deploy/aliyun_host.py` | 北京 ECS/EIP 只读计划与显式 `--apply` 创建/绑定,支持 `SpotAsPriceGo` | +| `deploy/asterisk.sh` | 本地 Compose 配置检查;只有 `up` 才启动服务 | + +## 2. 准备本地受限目录 + +不要把 ARI 密钥、SIP 密码、SSH 私钥写入仓库、命令日志或聊天。以下目录已被 `.gitignore` 忽略: + +```bash +set -euo pipefail +umask 077 +mkdir -p .local/agent-call +openssl rand -hex 32 > .local/agent-call/ari-password +chmod 600 .local/agent-call/ari-password +``` + +准备 `.local/agent-call/asterisk.json`。下面只放线路参数,不放密码;`from_user`/`caller_id` 必须按供应商规则确认: + +```json +{ + "public_ip": "123.56.71.98", + "transport": "udp", + "local_net": "172.16.0.0/12", + "ari_bind": "127.0.0.1", + "primary": { + "host": "61.132.228.221", + "port": 5060, + "auth_mode": "ip", + "register": false, + "from_user": "BD93205882", + "caller_id": "BD93205882 " + }, + "backup": null +} +``` + +生成一次性 UserData。镜像必须是已批准的 immutable digest: + +```bash +python3 deploy/build_asterisk_userdata.py \ + --config .local/agent-call/asterisk.json \ + --image 'git.ipao.vip/rogee/asterisk-real@sha256:0f5e3cd0e9a86bc9dc4750929abee9ba1faae11ed39739f3e75343d39d837e41' \ + --ari-password-file .local/agent-call/ari-password \ + --output .local/agent-call/asterisk-user-data.sh +bash -n .local/agent-call/asterisk-user-data.sh +``` + +## 3. ECS 配置与部署 + +先查询固定 EIP、全部现有实例和项目标签;不要复用无项目归属的实例。使用专用 VSwitch、安全组、SSH KeyPair,安全组只允许供应商 SIP/RTP 来源: + +```bash +python3 deploy/aliyun_host.py \ + --config .local/agent-call/aliyun.json \ + --state .local/agent-call/host-state.json +``` + +创建配置 `.local/agent-call/aliyun.json` 时至少填写: + +```json +{ + "region": "cn-beijing", + "public_ip": "123.56.71.98", + "project_tag": "agent-call", + "image_id": "ubuntu_24_04_x64_20G_alibase_20260828.vhd", + "instance_type": "ecs.e-c1m1.large", + "vswitch_id": "", + "security_group_id": "", + "key_pair_name": "", + "spot_strategy": "SpotAsPriceGo", + "system_disk_category": "cloud_essd", + "system_disk_gib": 40, + "system_disk_performance_level": "PL1", + "user_data_file": ".local/agent-call/asterisk-user-data.sh" +} +``` + +确认计划无误后才执行一次: + +```bash +python3 deploy/aliyun_host.py \ + --config .local/agent-call/aliyun.json \ + --state .local/agent-call/host-state.json \ + --apply +``` + +`SpotAsPriceGo` 不填写 `spot_price_limit`;创建后脚本会等待 ECS Running,再绑定既有 EIP。创建/绑定失败时保留 state 中的实例 ID,禁止自动再创建第二台。 + +## 4. 验证与单次外呼 + +UserData 完成后通过 Cloud Assistant 执行只读检查,确认容器健康、Asterisk 版本、PJSIP contact 和出站 dialplan: + +```bash +# RunCommand 的实例数组使用 --InstanceId.1,不要传 JSON 数组给 --InstanceId +aliyun ecs RunCommand --RegionId cn-beijing \ + --InstanceId.1 \ + --Type RunShellScript \ + --Name agent-call-readiness \ + --CommandContent 'docker ps --format "{{.Names}} {{.Status}}"; docker exec agent-call-asterisk asterisk -rx "core show version"; docker exec agent-call-asterisk asterisk -rx "pjsip show endpoint provider-primary"; docker exec agent-call-asterisk asterisk -rx "dialplan show outbound"' \ + --ContentEncoding PlainText \ + --Timeout 60 \ + --RepeatMode Once \ + --KeepCommand false +``` + +确认供应商已经冻结目标号码、前缀、From 域、PAI 和编解码后,一次只测试一个号码;不自动重试: + +```bash +docker exec agent-call-asterisk asterisk -rx \ + 'channel originate Local/<7089+原始被叫>@outbound application Wait 30' +``` + +记录完整 SIP 状态、`Reason`、RTP/录音结果和 `call_id`。`Avail`/OPTIONS 只证明探活,不证明外呼接通。 + +## 5. 更新与回收 + +- 配置变更:生成新的 UserData/配置目录,核对后再重启容器;不要覆盖正在使用的配置目录。 +- 竞价回收:活动通话可能中断;EIP、实例状态、未上传录音和 outbox 必须单独对账。 +- 测试环境若需回收,只删除明确标记为 `project=agent-call` 的本次实例;不要释放或解绑固定 EIP,不能删除无关实例。 +- 当前供应商返回过 `488 / Q.850 cause=88 INCOMPATIBLE_DESTINATION`;未确认线路规则前不要重复拨号。 diff --git a/docs/部署接入_运行说明.md b/docs/部署接入_运行说明.md index 6e57f38..d7985b8 100644 --- a/docs/部署接入_运行说明.md +++ b/docs/部署接入_运行说明.md @@ -33,6 +33,8 @@ services/asr-web/ ASR-only Go服务及Web页面 deploy/aliyun_host.py 阿里云CLI只读计划/显式apply deploy/aliyun.example.json 实例/预算/网络参数占位 deploy/render_asterisk.py 受控配置生成 + deploy/asterisk_bootstrap.sh ECS UserData安装Docker并启动Asterisk + deploy/build_asterisk_userdata.py 将配置安全嵌入UserData deploy/asterisk.example.json SIP接入参数占位 deploy/asterisk.sh 默认检查;显式up才启动 tests/ 离线部署逻辑及PCM测试 @@ -167,7 +169,7 @@ python3 deploy/render_asterisk.py --config .local/asterisk.json ### 5.2 镜像与安全组 -`.env`中的ASTERISK_IMAGE必须为批准镜像的`@sha256:`引用。本轮R1使用内部Registry中的固定digest镜像并实测Asterisk 22.10.1;该镜像验证仅覆盖单路底座,不等于生产镜像批准或容量验收。配置读取权限按镜像实际UID/GID修正为必要的组读权限,未使用chmod 777。 +`.env`中的ASTERISK_IMAGE必须为批准镜像的`@sha256:`引用。本轮R1使用内部Registry中的固定digest镜像并实测Asterisk 22.10.1;该镜像验证仅覆盖单路底座,不等于生产镜像批准或容量验收。配置读取权限按镜像实际UID/GID修正为必要的组读权限,未使用chmod 777。ECS UserData快速部署步骤见`docs/真实SIP_Asterisk快速部署_v1.0.md`,生成文件包含ARI密钥,只能放在`.local/`并保持600权限。 确认:SIP服务端IP/协议/端口、RTP回程、EIP/NAT、实际VPC网段、管理来源、录音卷目录和权限。安全组只按来源和用途开放;不公开裸ARI,不清空既有防火墙。 diff --git a/tests/test_deployment.py b/tests/test_deployment.py index c97a3ba..c1c5325 100644 --- a/tests/test_deployment.py +++ b/tests/test_deployment.py @@ -6,6 +6,7 @@ import unittest from pathlib import Path from deploy import aliyun_host as cloud +from deploy import build_asterisk_userdata as user_data from deploy import render_asterisk as ast @@ -249,6 +250,18 @@ class AsteriskTests(unittest.TestCase): "backup": {"host": "sip-b.test", "auth_mode": "ip"}, } + def test_build_user_data_pins_image_and_does_not_print_secret(self): + content = user_data.build( + self.cfg(), + "registry.example/asterisk@sha256:" + "a" * 64, + "x" * 32, + ) + self.assertIn("ASTERISK_IMAGE=registry.example/asterisk@sha256:", content) + self.assertIn("HTTP_CONF_B64=", content) + self.assertNotIn("password=" + "x" * 32, content) + with self.assertRaises(ValueError): + user_data.immutable_image("registry.example/asterisk:latest") + def test_private_ari_fixed_nat_and_recording_config(self): files = ast.render(self.cfg(), {"ARI_PASSWORD": "x" * 32}) self.assertEqual(len(files), 5)