feat(agent-call): add repeatable Asterisk bootstrap
This commit is contained in:
@@ -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
|
||||
@@ -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端口。
|
||||
|
||||
| 流向 | 放通与限制 |
|
||||
|
||||
@@ -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
@@ -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,不清空既有防火墙。
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user