Add isolated SIP call tool, optional debug capture and split interface schemas

This commit is contained in:
2026-10-08 12:44:45 +08:00
parent 98b3fd469d
commit 385bda0822
49 changed files with 3688 additions and 28 deletions
+69
View File
@@ -0,0 +1,69 @@
# 单通外呼线路测试
独立程序 `sip-call`,只复用项目包,不进入 Agent/Dispatcher 业务流程。不接 SaaS、MQ、AI、OSS,不录音,不排队、不重拨、不换线。
## 使用
```sh
make build-sip-call
cp cmd/sip-call/sip.env.example sip-xx.env
chmod 600 sip-xx.env
# 填写服务商确认的线路参数及本机 Asterisk 路径后执行:
./dist/sip-call call --sip sip-xx.env 18601010101
# 需要本通抓包与 SIP/RTP 证据时:
./dist/sip-call call --sip sip-xx.env -D 18601010101
```
`--debug` 与 `-D` 等价。默认模式不调用或检查 tcpdump/tshark;调试模式要求两者可执行、指定接口可抓包且权限充分。调试抓包未就绪或在 Dial 前已退出就不拨号。参数后的号码是原始号码,仅添加该文件声明的前缀一次;不要自己先加线路前缀。
**命令会真实拨号。每次执行都须另获线路和号码的明确授权;本文示例不是授权。** 本次代码交付只做本地测试,未部署、未试拨。
## 运行条件
- 在已有原生 Asterisk 的主机运行;已有 `asterisk.conf`、0600 的 `pjsip.conf`、原生 PJSIP/ARI 模块及私有 `ari-secret`。
- 使用现有本地 ARI 用户 `go-sip-agent` 和已配置的 loopback HTTP 地址;密码只从 `ASTERISK_CONFIG_DIR/ari-secret` 在内存读取,不复制到 ENV 或结果。
- 已有静态 `go-sip-udp` UDP transport,绑定 `0.0.0.0:5060`;工具不更改静态 transport,不启动/重启 Asterisk。
- 不存在活动通话,且业务调用方、Agent/其他 ARI 应用已停止。工具拒绝接管业务应用;采用独立 `sip-call` ARI 应用及 `siptest-<SIP_ID>` endpoint。
- 只支持 IPv4、UDP、IP 鉴权、无需 REGISTER、PCMA(alaw)。其他方式明确拒绝,不猜测配置。
- ENV 为 0600 普通文件;结果目录为 0700。所有配置项见 [`sip.env.example`](sip.env.example);不读取 shell 环境覆盖线路,也不执行 `source`。
## 自动配置与退出
取得该 Asterisk 配置目录的独占测试锁,确认空闲后保存原 `pjsip.conf` 到本次私有目录的 `pjsip.conf.before`。临时增加独立 include/endpoint/AOR,reload 后核对实际服务地址、主叫、codec、transport 和 context。通过才执行一次原生 ARI Dial。
收到真正的 `Up` 和 `StasisStart` 才记录接通;按 `HOLD_SECONDS` 保持后挂断。观察到通道结束或挂断后的 ARI 404 才记录结束确认。正常结束后恢复原 `pjsip.conf` 原字节、删除本次专用配置、reload 并确认 endpoint 已移除,不改 `go-sip-managed.conf` 或业务 SIP 加载代次。
旧 `sip-call-managed.conf`/include、并发配置修改或未知通道状态均报错。无法确认结束时保留测试配置和原配置副本供人工排查,**不要直接重跑或自行清理**。恢复、抓包停止或证据解析失败都保留错误并以非零退出,不报告“全部正常”。
## 输出与证据
每次创建独立私有目录,成功或失败均有 `result.json`:线路、服务地址、主叫、原始/线路被叫、SIP Call-ID、拨号前绑定时间、接通时间、结束确认时间、最终状态及失败原因。
开启调试后另有:
- `capture.pcap` 与 SHA-256:只抓指定 SIP 服务端 IP 的 UDP,避免复制其他主机流量。
- `tcpdump.log`:本通抓包工具的启动/退出信息。
- `sip.tsv`:真实 tshark 按本通 Call-ID 解出的 SIP 时间线;最终响应进一步匹配原始 INVITE 的 CSeq 与 Via branch,不能把 BYE 的 200 当作接通。
- `rtp.tsv`:依据该通 SDP 音频端口识别的 RTP 包及字节;结果明确记录观察范围。
SIP 服务端与媒体服务器使用不同 IP 时,这个窄过滤器**不覆盖另一个媒体 IP**;未观察到 RTP 不能据此断言无媒体。裸线路测试不发送合成语音、不验证双向音质,RTP 包存在也不等于语音/AI 正常。
调试模式观察到原生接通但没有同一原始 INVITE 的真实 2xx 最终响应时,保留原生接通/结束事实,同时明确报告抓证失败,不给出完整成功结论。
未开调试时只报告原生通道事实,不编造 SIP 状态码、媒体事实或抓包证据。原始抓包可能含用户音频,配置副本也须私密保存;不要复制到聊天、普通日志或 Git。
## Agent 的调试参数
```sh
sip-go-agent agent --mode nonprod-real -D
```
普通 Agent 默认不依赖 tcpdump,不校验外部抓包凭证。`-D` 则复用 [`nonprod-call-evidence.sh`](../../deploys/test/nonprod-call-evidence.sh):脚本提前开启抓包与 PJSIP logger,Agent 核对 `AGENT_EVIDENCE_ROOT` 下绑定本通 event/线路/原始号码的活跃凭证。调试条件缺失拒绝拨号;脚本不会因为新增参数而自动启动。
此参数仅属于 Agent,Dispatcher 不接受。业务授权、任务/线路时段、额度和 HEP 原生 SIP 响应采集规则未取消;没有 tcpdump 不等于绕过业务门禁。
## 本地验证范围
单元测试覆盖参数、ENV、一次拨号、无 AI/ExternalMedia/bridge、失效抓包、异常恢复、未知结束和原始 INVITE 关联。额外使用网络禁用的临时 Debian 容器中真实 tcpdump/tshark **离线解析合成 pcap**,可用 `GO_SIP_TSHARK_TEST_IMAGE` 显式启用对应测试。
这些测试没有向真实线路发送 SIP,也不能证明已接通、媒体可用或生产可发布。
+73
View File
@@ -0,0 +1,73 @@
// sip-call is a standalone, operator-invoked Asterisk line test. It does not
// import the Agent/Dispatcher commands or require a business configuration.
package main
import (
"context"
"errors"
"fmt"
"git.ipao.vip/rogee/go-sip/internal/sipcall"
"github.com/spf13/cobra"
"io"
"os"
"os/signal"
"syscall"
)
type callRunner func(context.Context, sipcall.Config, string, bool) (*sipcall.Report, error)
func main() {
ctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer cancel()
if err := newRootCommand(sipcall.Run).ExecuteContext(ctx); err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
}
func newRootCommand(run callRunner) *cobra.Command {
var debug bool
var file string
root := &cobra.Command{Use: "sip-call", Short: "单通 Asterisk 外呼线路测试(不接 AI)", SilenceUsage: true, SilenceErrors: true, RunE: func(*cobra.Command, []string) error {
return errors.New("请使用 sip-call call --sip <线路.env> <原始号码>")
}}
root.PersistentFlags().BoolVarP(&debug, "debug", "D", false, "开启本通抓包和 SIP/RTP 证据提取;缺少工具或权限就拒绝拨号")
call := &cobra.Command{Use: "call --sip <线路.env> <原始号码>", Short: "自动临时配置线路,只拨一次,接通后短暂保持并挂断", Args: cobra.ExactArgs(1), RunE: func(cmd *cobra.Command, args []string) error {
c, err := sipcall.LoadConfig(file)
if err != nil {
return err
}
if _, err = c.Route(args[0]); err != nil {
return err
}
result, runErr := run(cmd.Context(), c, args[0], debug)
if result != nil {
runErr = errors.Join(runErr, printReport(cmd.OutOrStdout(), result))
}
return runErr
}}
call.Flags().StringVar(&file, "sip", "", "线路 ENV 文件(0600;不执行 shell)")
if err := call.MarkFlagRequired("sip"); err != nil {
panic(err)
}
root.AddCommand(call)
return root
}
func printReport(out io.Writer, r *sipcall.Report) error {
status := map[string]string{"connected": "已接通并确认结束", "not_connected": "未接通", "not_dialed": "未拨号", "unknown": "状态不明,禁止自动重拨", "failed": "失败(详情见结果文件)"}[r.Status]
if status == "" {
status = "状态不明"
}
_, err := fmt.Fprintf(out, "结论:%s\n线路:%s (%s)\n主叫:%s\n原始被叫:%s\n线路被叫:%s\n详情:%s\n", status, r.Line, r.Server, r.CallerID, r.Callee, r.DialedCallee, r.Directory+"/result.json")
if r.Evidence != nil {
code := "未观测到最终响应"
if r.Evidence.FinalINVITECode != 0 {
code = fmt.Sprint(r.Evidence.FinalINVITECode)
}
_, e := fmt.Fprintf(out, "抓包:%s\nSHA-256:%s\nSIP 时间线:%s\n原始 INVITE 最终响应:%s\n媒体观察:%s\n", r.Evidence.PCAP, r.Evidence.PCAPSHA256, r.Evidence.SIPTimeline, code, r.Evidence.RTPStatus)
err = errors.Join(err, e)
} else {
_, e := fmt.Fprintln(out, "抓包:未启用(使用 --debug/-D 开启);没有抓包链路证据或媒体验证。")
err = errors.Join(err, e)
}
return err
}
+87
View File
@@ -0,0 +1,87 @@
package main
import (
"bytes"
"context"
"errors"
"git.ipao.vip/rogee/go-sip/internal/sipcall"
"os"
"path/filepath"
"strings"
"testing"
)
func TestCallCLIHasNoBusinessOrAIDependencies(t *testing.T) {
for _, args := range [][]string{{"call", "--sip", "line.env", "18601010101"}, {"call", "--sip", "line.env", "-D", "18601010101"}, {"--debug", "call", "--sip", "line.env", "18601010101"}} {
dir := t.TempDir()
env := filepath.Join(dir, "line.env")
data := `SIP_ID=line
SIP_SERVER=192.0.2.1
SIP_PORT=5060
SIP_CALLER_ID=BD123
SIP_PREFIX=7089
SIP_TRANSPORT=udp
SIP_AUTH=ip
SIP_REGISTER=no
SIP_CODEC=alaw
ASTERISK_CONFIG_DIR=/tmp/ast
ASTERISK_BIN=/usr/local/sbin/asterisk
ASTERISK_LIBRARY_DIR=/usr/local/lib
OUTPUT_DIR=/tmp/results
RING_SECONDS=20
HOLD_SECONDS=3
DEBUG_INTERFACE=any
`
if err := os.WriteFile(env, []byte(data), 0600); err != nil {
t.Fatal(err)
}
for i := range args {
if args[i] == "line.env" {
args[i] = env
}
}
calls := 0
root := newRootCommand(func(ctx context.Context, c sipcall.Config, n string, debug bool) (*sipcall.Report, error) {
calls++
want := strings.Contains(strings.Join(args, " "), "-D") || args[0] == "--debug"
if debug != want || n != "18601010101" || c.ID != "line" {
t.Fatal("incorrect CLI inputs")
}
return &sipcall.Report{Status: "connected", Directory: "/tmp/result", Callee: n, DialedCallee: c.Prefix + n, Debug: debug}, nil
})
out := &bytes.Buffer{}
root.SetOut(out)
root.SetErr(out)
root.SetArgs(args)
if err := root.Execute(); err != nil || calls != 1 {
t.Fatalf("%v %v", args, err)
}
if !strings.Contains(out.String(), "result.json") {
t.Fatal("result path missing")
}
}
}
func TestCallCLIRejectsMissingOrMalformedInputsBeforeHostAccess(t *testing.T) {
for _, args := range [][]string{{}, {"call"}, {"call", "123"}, {"call", "--sip", "missing.env", "+123"}, {"call", "--sip", "missing.env", "123", "456"}, {"agent"}} {
root := newRootCommand(func(context.Context, sipcall.Config, string, bool) (*sipcall.Report, error) {
t.Fatal("invalid input must never access host")
return nil, nil
})
root.SetArgs(args)
root.SetOut(&bytes.Buffer{})
root.SetErr(&bytes.Buffer{})
if err := root.Execute(); err == nil {
t.Fatalf("invalid invocation accepted: %v", args)
}
}
}
func TestCallCLIDoesNotHideRunFailures(t *testing.T) {
errExpected := errors.New("unconfirmed call")
out := &bytes.Buffer{}
err := printReport(out, &sipcall.Report{Status: "unknown", Directory: "/tmp/test", Failure: errExpected.Error()})
if err != nil || !strings.Contains(out.String(), "状态不明") {
t.Fatal("unknown call must not look connected")
}
}
+25
View File
@@ -0,0 +1,25 @@
# Copy to a private file, chmod 600, then fill your provider-approved values.
# Documentation-only address: this template cannot test a real line as-is.
# Plain KEY=VALUE only: no quotes, shell commands, variables or duplicate keys.
SIP_ID=test-line
SIP_SERVER=192.0.2.10
SIP_PORT=5060
SIP_CALLER_ID=YOUR_CALLER_ID
# Empty is valid, but this field must be present. Raw CLI number is unchanged.
SIP_PREFIX=
# Only these supported values are accepted; no REGISTER or Digest credentials.
SIP_TRANSPORT=udp
SIP_AUTH=ip
SIP_REGISTER=no
SIP_CODEC=alaw
# Existing native Asterisk installation and the user's private ari-secret.
ASTERISK_CONFIG_DIR=/home/rogee/.config/go-sip-asterisk
ASTERISK_BIN=/usr/local/sbin/asterisk
ASTERISK_LIBRARY_DIR=/usr/local/lib
OUTPUT_DIR=/home/rogee/.local/state/sip-call
# Ring: 1-120 seconds. Connected hold: 1-30 seconds. No redial.
RING_SECONDS=20
HOLD_SECONDS=3
# Used only with --debug/-D; no tcpdump/tshark needed otherwise.
DEBUG_INTERFACE=any
+6
View File
@@ -23,6 +23,7 @@ import (
func newAgentCommand() *cobra.Command {
var mode string
var debug bool
command := &cobra.Command{
Use: "agent", Short: "Run the bound Agent", Args: cobra.NoArgs,
SilenceUsage: true,
@@ -31,6 +32,10 @@ func newAgentCommand() *cobra.Command {
if err != nil {
return err
}
settings.Debug = debug
if err := settings.ValidateDebug(); err != nil {
return err
}
ca, err := readAgentPEM("MTLS_CA_FILE", settings.CAFile)
if err != nil {
return err
@@ -146,6 +151,7 @@ func newAgentCommand() *cobra.Command {
return nil
},
}
command.Flags().BoolVarP(&debug, "debug", "D", false, "Require per-call evidence from the nonproduction capture script")
command.Flags().StringVar(&mode, "mode", "mock", "isolated Mock, SIP-only or explicit nonprod-real mode")
return command
}
+5 -2
View File
@@ -40,7 +40,10 @@ func newRealAgentServer(ctx context.Context, settings config.AgentEnvironment, d
if value.Kind() == reflect.Pointer && value.IsNil() {
return nil, errors.New("real Agent requires a live pinned Dispatcher transport")
}
for _, path := range []string{settings.AsteriskConfigDir, settings.AsteriskBin, settings.AsteriskLibraryDir, settings.EvidenceRoot, settings.RecoveryRoot} {
if err := settings.ValidateDebug(); err != nil {
return nil, err
}
for _, path := range []string{settings.AsteriskConfigDir, settings.AsteriskBin, settings.AsteriskLibraryDir, settings.RecoveryRoot} {
if !filepath.IsAbs(path) {
return nil, errors.New("real Agent paths must be absolute")
}
@@ -85,7 +88,7 @@ func newRealAgentServer(ctx context.Context, settings config.AgentEnvironment, d
Journal: &agent.ResultJournal{Root: settings.RecoveryRoot},
}
return (&rpc.ApprovedRecordedRealCall{
Loader: loader, MediaPayloadType: 118, EvidenceRoot: settings.EvidenceRoot,
Loader: loader, MediaPayloadType: 118, EvidenceRoot: settings.EvidenceRoot, Debug: settings.Debug,
MaxWAVBytes: 64 << 20, ReportTimeout: 15 * time.Minute, Delivery: delivery, Mirror: mirror,
}).Prepare(execution)
},
+24
View File
@@ -0,0 +1,24 @@
package main
import "testing"
func TestDebugFlagIsExplicitAndAgentOnly(t *testing.T) {
for _, flag := range []string{"--debug", "-D"} {
command := newAgentCommand()
if err := command.ParseFlags([]string{flag}); err != nil {
t.Fatal(err)
}
enabled, err := command.Flags().GetBool("debug")
if err != nil || !enabled {
t.Fatalf("%s: %v %v", flag, enabled, err)
}
if err := newDispatcherCommand().ParseFlags([]string{flag}); err == nil {
t.Fatal("Dispatcher must not silently accept an Agent capture flag")
}
}
command := newAgentCommand()
enabled, err := command.Flags().GetBool("debug")
if err != nil || enabled {
t.Fatal("capture must be disabled by default")
}
}