feat(agent-call): add repeatable Asterisk bootstrap

This commit is contained in:
2026-09-13 14:30:00 +08:00
parent 5c9165cb4b
commit d30314c7d1
6 changed files with 363 additions and 1 deletions
+82
View File
@@ -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
+131
View File
@@ -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())
@@ -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端口。
| 流向 | 放通与限制 |
+132
View File
@@ -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 <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": "<approved-vswitch-id>",
"security_group_id": "<dedicated-security-group-id>",
"key_pair_name": "<approved-key-pair>",
"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 <instance-id> \
--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`;未确认线路规则前不要重复拨号。
+3 -1
View File
@@ -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,不清空既有防火墙。
+13
View File
@@ -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)