Harden sip-call ownership and capture failure handling

This commit is contained in:
2026-10-09 10:28:28 +08:00
parent 4b27ef31ca
commit f5eebe638c
12 changed files with 337 additions and 10 deletions
+2
View File
@@ -94,6 +94,8 @@
- 2026-10-08 使用者指定中鼎改为 `222.186.130.228:5060`、主叫保持 `mbkq`,ENV 与 SaaS 测试 SIP revision 15 已更新,其余两线未变;源码 `310a14f` 的 Agent/Dispatcher 与 sip-call 已部署。获批独立工具 DEBUG 只实际发出一通:1 个 INVITE transaction、6 包(5 次协议重传),0 SIP 回复、未接通;原 `unknown` 结果保留,后续精确原通道 404 的操作者证据先私密落盘后才恢复本通原始配置,未改写为正式终结结果。Agent active、Dispatcher 保持原来 inactive、Asterisk 未重启。新版拒绝旧 SQLite 6 表/version 2,使用者未批准替代运行目录,不能自动迁移、清理或以新空库绕过;恢复后的业务中鼎 AOR 仍为旧地址,不能把工具临时加载等同于持久业务配置生效。DEBUG 还确认 transport 未设外部信令/媒体地址、报文公布私网地址,但旧中鼎抓包在同样设置下仍返回 480,不能把此次无回复唯一归因于 NAT 或断言供应商故障;未修改静态 transport、重启 Asterisk或重复盲拨。现场与回归边界见 [`docs/evidence/zhongding-new-ip-sip-call-debug-20261008.md`](docs/evidence/zhongding-new-ip-sip-call-debug-20261008.md)。
- 2026-10-08 使用者批准独立测试工具 [`cmd/sip-call`](cmd/sip-call/README.md):单独编译,以 `sip-call call --sip <线路.env> <原始号码>` 自动加载固定 ENV、临时配置并核对原生 Asterisk,仅拨一通,不接 SaaS/MQ/AI/OSS,不重拨、不换线;业务 Agent/其他 ARI 应用须停止,活动通话或旧测试配置阻断执行。仅 `--debug/-D` 才使用 tcpdump/tshark 收集本通证据;不带参数只报告原生通道事实,不编造 SIP/媒体/抓包事实。`sip-go-agent agent` 同样增加 `--debug/-D`,默认不依赖外部 tcpdump/抓包凭证;调试复用现有抓证脚本并在拨号前严格核对活跃凭证,不能静默降级。HEP 真实 SIP 响应、业务授权、时段、额度及未知执行保留规则不变。开发核验已完成;2026-10-08 经使用者另行确认,独立工具及按当前表生成的三份 0600 ENV 已部署到登记测试机 `rogee` 用户的 `~/sip-call/`,未更新业务 Agent/Dispatcher、未加载或改动既有 Asterisk 线路、未拨号或调用 AI。默认运行不调用抓包工具;使用者进一步要求生产和测试安装环境均统一预装 tcpdump/tshark,不按环境跳过,但抓包仍须显式 `--debug/-D`,不足时不能静默降级。登记测试机现已安装并核对 tcpdump 4.99.5、TShark 4.4.19 的 SIP/RTP 解析能力;未开启抓包或更改抓包权限,不能把工具安装当作逐通抓证、线路接通或生产签收。部署来源、文件 hash 和只读主机事实见 [`docs/evidence/sip-call-test-deployment-20261008.md`](docs/evidence/sip-call-test-deployment-20261008.md)。独立工具不属于生产发布制品,也不授权真实试拨。
- 2026-10-09 使用者批准修复独立 `sip-call` 的 sudo 归属问题:先将测试机 `pjsip.conf` 恢复为 `rogee:rogee`、保留 `0600` 与原字节,再本地修复工具。主程序必须以配置所属用户运行,写入/抓包前拒绝 root 或归属不匹配;临时与恢复配置保留原用户/组及权限,恢复检查不得只看测试 endpoint 消失。另获明确批准,仅为测试机 `/usr/bin/tcpdump` 配置 `cap_net_raw,cap_net_admin=eip`,已用 `rogee` 验证启动与 SIGINT 停止,输出丢弃、零包、未生成流量;不对主程序提权,不用 sudo 整条命令。调试启动失败须区分“已请求但未启动”和默认“未启用”。归属修复阶段未部署新版工具、未重启服务、未拨号;随后使用者明确仅批准独立工具部署与版本/帮助检查,已将源码基线 `4b27ef3` 及本地 Go 差异固定的工具安装至 `/home/rogee/.local/bin/sip-call`,SHA-256 为 `c6999f84692e31704915450151f9c6c1a31b3e5920acc58a1d3164aafb0941ba`,原工具与来源清单私密保存在 `~/sip-call/tool-backups/20261009T021242Z-c6999f84692e/`;帮助命令通过,原配置和 ENV 未变。此次不启动抓包、不执行 tcpdump→TShark 联合自检、不重启服务、不拨号;此前普通用户中鼎等待接通超时的线路/网络/程序根因仍未证实。详见 [`docs/evidence/sip-call-ownership-repair-20261009.md`](docs/evidence/sip-call-ownership-repair-20261009.md)。
## SaaS、Dispatcher 与 Agent 的现行边界
- SaaS→Dispatcher 的**五类只读配置**为 `GET /internal/v1/dispatcher/sip`、`/task/:task_id`、`/tasks`、`/tenant/:tenant_id/quota`、`/ai-providers`;路径前缀固定,均须校验 Dispatcher UUID/资源归属、数字 `tenant_id`、完整快照、来源、有效授权和版本。配置读失败、过期、矛盾或不确定时关新准入;没有旧 MQ 配置回退、通用业务 HTTP、ETag 兜底或偷偷启用旧执行字段。已接纳任务持久绑定原快照。
+14 -2
View File
@@ -21,23 +21,35 @@ chmod 600 sip-xx.env
## 运行条件
- 在已有原生 Asterisk 的主机运行;已有 `asterisk.conf`、0600 的 `pjsip.conf`、原生 PJSIP/ARI 模块及私有 `ari-secret`。
- 以 Asterisk 配置的所属用户运行(登记测试机为 `rogee`),**不要 `sudo sip-call`**。root 或文件归属不匹配会在创建结果、抓包及改配置前明确拒绝;不自动改归属或放宽权限。
- 使用现有本地 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`。
## 调试抓包权限
主程序不提权。Linux 主机安装人员可仅为 tcpdump 设置抓包能力,生产与测试按同样方式准备:
```sh
sudo setcap cap_net_raw,cap_net_admin=eip /usr/bin/tcpdump
/usr/sbin/getcap /usr/bin/tcpdump
```
先确认该路径是主机实际安装的 tcpdump;不对 `sip-call`、tshark 或整个目录设置能力。普通用户仍使用前述不带 sudo 的 `-D` 命令。权限不足时不拨号、不回退到无抓包模式,输出“已请求,但未启动”;具体启动错误保留在本次私有 `tcpdump.log`。
## 自动配置与退出
取得该 Asterisk 配置目录的独占测试锁,确认空闲后保存原 `pjsip.conf` 到本次私有目录的 `pjsip.conf.before`。临时增加独立 include/endpoint/AOR,使用原生 `module reload res_pjsip.so` 并核对明确的成功响应,不依赖可选的 `pjsip reload` 别名,不把 CLI 退出码 0 当作加载成功;随后核对实际服务地址、主叫、codec、transport 和 context。通过才执行一次原生 ARI Dial。
收到真正的 `Up` 和 `StasisStart` 才记录接通;按 `HOLD_SECONDS` 保持后挂断。观察到通道结束或挂断后的 ARI 404 才记录结束确认。正常结束后恢复原 `pjsip.conf` 原字节、删除本次专用配置、reload 并确认 endpoint 已移除,不改 `go-sip-managed.conf` 或业务 SIP 加载代次。
收到真正的 `Up` 和 `StasisStart` 才记录接通;按 `HOLD_SECONDS` 保持后挂断。观察到通道结束或挂断后的 ARI 404 才记录结束确认。正常结束后恢复原 `pjsip.conf` 原字节及原所属用户/组、保持 `0600`,删除本次专用配置、reload 并确认 endpoint 已移除,不改 `go-sip-managed.conf` 或业务 SIP 加载代次。临时 include 同样使用原配置的所属用户/组;恢复前后及 reload 后均核对配置归属和权限,期间发生变化则明确报错并保留恢复资料,不覆盖变化后的文件。
旧 `sip-call-managed.conf`/include、并发配置修改或未知通道状态均报错。无法确认结束时保留测试配置和原配置副本供人工排查,**不要直接重跑或自行清理**。恢复、抓包停止或证据解析失败都保留错误并以非零退出,不报告“全部正常”。
## 输出与证据
每次创建独立私有目录,成功或失败均有 `result.json`:线路、服务地址、主叫、原始/线路被叫、SIP Call-ID、拨号前绑定时间、接通时间、结束确认时间、最终状态及失败原因。
归属与输入检查通过后创建独立私有目录,此后的成功或失败均有 `result.json`(检查未通过时不创建结果):线路、服务地址、主叫、原始/线路被叫、SIP Call-ID、拨号前绑定时间、接通时间、结束确认时间、最终状态及失败原因。
开启调试后另有:
+5 -1
View File
@@ -66,7 +66,11 @@ func printReport(out io.Writer, r *sipcall.Report) error {
_, 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 开启);没有抓包链路证据或媒体验证。")
message := "抓包:未启用(使用 --debug/-D 开启);没有抓包链路证据或媒体验证。"
if r.Debug {
message = "抓包:已请求,但未启动;没有抓包链路证据或媒体验证。"
}
_, e := fmt.Fprintln(out, message)
err = errors.Join(err, e)
}
return err
+11
View File
@@ -77,6 +77,17 @@ func TestCallCLIRejectsMissingOrMalformedInputsBeforeHostAccess(t *testing.T) {
}
}
func TestDebugStartupFailureDoesNotClaimCaptureDisabled(t *testing.T) {
out := &bytes.Buffer{}
err := printReport(out, &sipcall.Report{Status: "not_dialed", Directory: "/tmp/test", Debug: true, Failure: "tcpdump startup failed"})
if err != nil {
t.Fatal(err)
}
if strings.Contains(out.String(), "抓包:未启用") || !strings.Contains(out.String(), "抓包:已请求,但未启动") {
t.Fatalf("debug failure was reported as disabled: %s", out.String())
}
}
func TestCallCLIDoesNotHideRunFailures(t *testing.T) {
errExpected := errors.New("unconfirmed call")
out := &bytes.Buffer{}
@@ -0,0 +1,89 @@
# sip-call 配置归属恢复与抓包权限修复(2026-10-09)
## 授权与完成标准
使用者明确批准以下顺序:
1. 测试机只恢复 `pjsip.conf` 正确所属用户/组,不改变内容与 `0600`。
2. 本地修复独立 sip-call,并补回归测试;不部署新版、不重启服务、不拨号。
3. 追加批准:仅为 tcpdump 设置独立抓包权限,普通用户做不拨号的启动/停止检查。
完成标准:原配置归属和可读性恢复、内容未变;工具在写入前拒绝错误用户;临时/恢复配置保留归属和权限;调试启动失败提示准确;本地检查、构建及覆盖率通过;原通话证据不改写。
## 现场恢复
连接前核对已登记 SSH 主机指纹,使用 StrictHostKeyChecking。
对 `~/.config/go-sip-asterisk/pjsip.conf` 使用不跟随符号链接的文件描述符检查:普通文件、`0600`、内容与原备份一致;同时确认 Asterisk 以 `rogee` 运行。仅通过该描述符更改所属用户/组为 `rogee:rogee` 并复核路径仍指向同一文件。
| 项目 | 前后事实 |
| --- | --- |
| 所属用户/组 | `root:root` → `rogee:rogee` |
| 权限 | 始终 `0600` |
| 内容 SHA-256 | 始终 `c319476e7e4962008899a11ae0461db282eed3d63871b2d8adcd143812408cf8` |
| Asterisk 用户读取 | 从不可读恢复为可读 |
未 reload 或重启 Asterisk;此检查只证明原文件可读,不代签本次业务配置重新加载。未更改 `go-sip-managed.conf`、线路 ENV、静态 transport、数据库、原结果或抓包文件。
## tcpdump 独立权限
先核对 `/usr/bin/tcpdump` 是 root 所有的 `0755` 普通文件,原本没有文件能力,再设置 `cap_net_raw,cap_net_admin=eip`。
- tcpdump 4.99.5,二进制内容 SHA-256 始终为 `0d426e2571a22de0d30996fd01bb44c1f267f33065023abea092c88bad16d2d2`。
- 由普通用户 `rogee` 启动,监听 `any`,仅筛选 loopback UDP port 9,输出写入 `/dev/null`,不生成流量。
- 启动成功、进程保持运行;SIGINT 正常停止,退出码 0、捕获 0 包。
- 未对 sip-call 或 tshark 提权,未开放配置文件读取权限,未抓取实际呼叫。
## 本地工具修复
- `internal/sipcall/run.go`:在创建结果、抓包及访问 Asterisk 前检查配置类型、权限和所属用户;拒绝 root 或不匹配用户,明确提示不要 `sudo sip-call`。
- `internal/sipcall/configuration.go`:临时 include 和原子替换文件保留原配置的所属用户/组;拒绝不合要求的现有文件;恢复前后及 reload 后检查原配置归属和权限,不以测试 endpoint 消失替代这些检查。期间归属/权限变化则报错,保留恢复资料。
- `internal/sipcall/capture.go`:启动失败返回私有 `tcpdump.log` 的具体路径,并说明抓包权限应单独交给 tcpdump,不能 sudo 主程序。
- `cmd/sip-call/main.go`:已指定 `-D` 但抓包未启动时输出“已请求,但未启动”,不再误报“未启用”。
- 操作说明和 AGENTS.md 同步更新;未修改业务合同、内部 RPC、任务、配额或未知执行规则。
## 验证
新增测试先在旧实现上失败,再通过修复:恢复覆盖了已变更权限、原子替换接受非私有/符号链接/缺失文件、写入前未拦截无效配置、调试失败误报未启用。另覆盖 root/不同 UID 拒绝、UID/GID 比对、临时与恢复配置归属保持、抓包失败诊断。
| 检查 | 结果 |
| --- | --- |
| `go test ./internal/sipcall ./cmd/sip-call -count=1 -cover` | 通过;83.0% / 68.3% |
| `make check build-sip-call` | 通过;格式、Proto 来源/哈希、现行固定合同、`go vet ./...`、`go test -race ./...`、构建 |
| 隔离 RabbitMQ/HTTPS/mTLS 本地验收 | A01–A12 及 P08/current-mq 现有本地脚本通过;不代表真实外部签收 |
| `make coverage` | 通过;排除生成代码后的覆盖率 70.5%,达到 65% |
| `git diff --check` | 通过 |
| 本地 `dist/sip-call --help` | 通过,未调用 Asterisk 或发起呼叫 |
| 本地新工具 SHA-256 | `c6999f84692e31704915450151f9c6c1a31b3e5920acc58a1d3164aafb0941ba` |
## 边界与结束状态
归属修复结束时,测试机独立 sip-call 尚未更新,安装文件 SHA-256 仍为 `5a4da5af12a9e9f685ed767daa11cef90c1eaebb08cdd71014d1e61d695c922f`。结束时 Asterisk 与 Agent active,Dispatcher activating;原生 Asterisk 零活动通道、零活动通话。这不证明 Dispatcher ready,也不允许工具接管正在运行的 Agent。
此前普通用户中鼎调用的 20 秒等待接通超时仍缺少 SIP 抓包证据,线路/网络/程序原因未证实。本轮没有重新拨号、调用 AI/OSS、重拨、换线或改写原结果;本地修复与抓包权限验证不能代签中鼎接通或真实媒体。
## 后续工具部署(同日,独立批准)
使用者提出部署独立 sip-call,明确选择“仅部署并检查版本和帮助命令”,未批准启动抓包或 tcpdump→TShark 联合自检。
本地仓库 `git pull --ff-only` 已同步、无新远程提交。保留现存修改,以当前源码重新构建、检查 Go 元信息、再次通过独立工具单元测试。此版本来源为 Git 基线及本地已测试代码差异,不伪称所有修改已提交或推送。
| 项目 | 事实 |
| --- | --- |
| 源码基线 | `4b27ef31cab4d9fedb080e7a9eb2fed9cc7c1aee` |
| 两处 Go 目录差异 SHA-256 | `7647db0ee8c3f6871b3e8b1145beeaee8862217d9950dc86c0e8e6f122749dde`(`git diff --binary -- cmd/sip-call/*.go internal/sipcall/*.go`) |
| Go /主机 | Go 1.27.1;x86_64,测试机 glibc 2.41 |
| 部署文件 | `/home/rogee/.local/bin/sip-call` |
| 所属用户/组与权限 | `rogee:rogee`,`0755` |
| 新工具 SHA-256 | `c6999f84692e31704915450151f9c6c1a31b3e5920acc58a1d3164aafb0941ba` |
| 旧工具 SHA-256 | `5a4da5af12a9e9f685ed767daa11cef90c1eaebb08cdd71014d1e61d695c922f` |
| 私密备份目录 | `~/sip-call/tool-backups/20261009T021242Z-c6999f84692e/`,`0700` |
| 备份与来源清单 | `sip-call.before`、`manifest.json`(清单 `0600`),复核 hash 与部署状态 |
| 帮助检查 | 上传候选及安装文件的 `--help`、`call --help` 均退出 0;保留 `--sip`/`--debug` |
| 配置保护 | `pjsip.conf` 和三份线路 ENV 的内容/所属用户/组/权限前后相同 |
先确认没有 sip-call 进程,再在目标文件同一目录上传私有候选、校验内容和帮助命令;旧文件完成私密备份与清单记录后,原子替换目标文件并同步目录,复核安装后的文件与帮助。未执行任何 call、capture、reload、restart 或业务发布操作。
工具没有独立 `--version` 参数,本次通过 Go 构建元信息、源码基线/差异和安装文件 hash 固定版本。SSH 非登录环境 PATH 不含 `~/.local/bin`,全部检查使用明确的安装绝对路径;没有修改 shell 配置或其他二进制。
结束时 Asterisk/Agent 仍 active,Dispatcher 仍 activating;这不是 Dispatcher ready 或真实通话验证。Agent 正在运行仍会按工具原有规则阻断独立试拨,本次没有停止它。tcpdump→TShark 联合自检及真实线路接通保持未验证。
+1 -1
View File
@@ -72,7 +72,7 @@ func startCapture(ctx context.Context, c Config, dir string) (captureSession, er
for {
select {
case e := <-cap.done:
return nil, errors.Join(errors.New("tcpdump stopped before readiness; inspect private tcpdump.log"), e, log.Close())
return nil, errors.Join(fmt.Errorf("tcpdump stopped before readiness; inspect private %s; run sip-call as configuration owner and grant capture permission only to tcpdump (do not sudo sip-call)", filepath.Join(dir, "tcpdump.log")), e, log.Close())
case <-ctx.Done():
_, stopErr := cap.Stop(context.Background(), "")
return nil, errors.Join(ctx.Err(), stopErr)
+2
View File
@@ -100,6 +100,8 @@ func TestDebugCaptureFailureDoesNotSilentlyDisableEvidence(t *testing.T) {
c := fixtureConfig(t)
if _, err := startCapture(context.Background(), c, t.TempDir()); err == nil {
t.Fatal("broken tcpdump accepted")
} else if !strings.Contains(err.Error(), "tcpdump.log") || !strings.Contains(err.Error(), "do not sudo sip-call") {
t.Fatalf("capture failure lacks log path or permission guidance: %v", err)
}
t.Setenv("PATH", t.TempDir())
if _, err := startCapture(context.Background(), c, t.TempDir()); err == nil {
+62 -5
View File
@@ -9,6 +9,7 @@ import (
"os"
"path/filepath"
"strings"
"syscall"
"time"
)
@@ -21,13 +22,10 @@ const testInclude = "\n#tryinclude sip-call-managed.conf\n"
func applyConfiguration(ctx context.Context, c Config, cli cliFunc) (restore func() error, err error) {
basePath := filepath.Join(c.ConfigDir, "pjsip.conf")
managed := filepath.Join(c.ConfigDir, "sip-call-managed.conf")
info, err := os.Lstat(basePath)
info, err := privateConfigInfo(basePath, os.Geteuid())
if err != nil {
return nil, err
}
if !info.Mode().IsRegular() || info.Mode().Perm() != 0600 {
return nil, errors.New("pjsip.conf must be a private regular file (0600)")
}
base, err := os.ReadFile(basePath)
if err != nil {
return nil, err
@@ -54,6 +52,10 @@ func applyConfiguration(ctx context.Context, c Config, cli cliFunc) (restore fun
if err != nil {
return nil, fmt.Errorf("test configuration already exists or cannot be created; do not overwrite: %w", err)
}
owner := info.Sys().(*syscall.Stat_t)
if err := file.Chown(int(owner.Uid), int(owner.Gid)); err != nil {
return nil, errors.Join(err, file.Close(), os.Remove(managed))
}
_, writeErr := file.WriteString(text)
err = errors.Join(writeErr, file.Sync(), file.Close())
backup, backupErr := os.OpenFile(filepath.Join(c.resultDir, "pjsip.conf.before"), os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0600)
@@ -66,6 +68,9 @@ func applyConfiguration(ctx context.Context, c Config, cli cliFunc) (restore fun
restore = func() error {
cleanupCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
if e := checkConfigMetadata(basePath, info); e != nil {
return fmt.Errorf("refuse configuration restore: %w", e)
}
channels, e := cli(cleanupCtx, "core show channels count")
if e != nil {
return e
@@ -86,12 +91,18 @@ func applyConfiguration(ctx context.Context, c Config, cli cliFunc) (restore fun
return e
}
}
if e := checkConfigMetadata(basePath, info); e != nil {
return fmt.Errorf("restored configuration metadata: %w", e)
}
if e := os.Remove(managed); e != nil {
return e
}
if e := reloadPJSIP(cleanupCtx, cli); e != nil {
return e
}
if e := checkConfigMetadata(basePath, info); e != nil {
return fmt.Errorf("configuration metadata after reload: %w", e)
}
out, e := cli(cleanupCtx, "pjsip show endpoint "+c.Endpoint())
if e != nil {
return e
@@ -156,18 +167,64 @@ func reloadPJSIP(ctx context.Context, cli cliFunc) error {
return fmt.Errorf("native PJSIP reload not confirmed (output_sha256=%x)", sha256.Sum256(out))
}
// uid is explicit so mismatched ownership and sudo can be tested without root.
func privateConfigInfo(path string, uid int) (os.FileInfo, error) {
if uid == 0 {
return nil, errors.New("do not sudo sip-call: run as the Asterisk configuration owner; grant capture permission only to tcpdump")
}
info, err := os.Lstat(path)
if err != nil {
return nil, err
}
if !info.Mode().IsRegular() || info.Mode().Perm() != 0600 {
return nil, errors.New("pjsip.conf must be a private regular file (0600)")
}
owner, ok := info.Sys().(*syscall.Stat_t)
if !ok {
return nil, errors.New("cannot determine Asterisk configuration owner")
}
if int(owner.Uid) != uid {
return nil, fmt.Errorf("Asterisk configuration owner uid %d differs from sip-call uid %d; stop and restore the correct owner, do not sudo sip-call", owner.Uid, uid)
}
return info, nil
}
func checkConfigMetadata(path string, expected os.FileInfo) error {
info, err := privateConfigInfo(path, os.Geteuid())
if err != nil {
return err
}
currentOwner := info.Sys().(*syscall.Stat_t)
originalOwner := expected.Sys().(*syscall.Stat_t)
if currentOwner.Uid != originalOwner.Uid || currentOwner.Gid != originalOwner.Gid || info.Mode().Perm() != expected.Mode().Perm() {
return errors.New("Asterisk configuration metadata changed; original owner/group/permissions must be preserved")
}
return nil
}
func atomicConfig(path string, data []byte) error {
info, err := privateConfigInfo(path, os.Geteuid())
if err != nil {
return err
}
f, err := os.CreateTemp(filepath.Dir(path), ".sip-call-write-*")
if err != nil {
return err
}
name := f.Name()
defer os.Remove(name)
owner := info.Sys().(*syscall.Stat_t)
if err := f.Chown(int(owner.Uid), int(owner.Gid)); err != nil {
return errors.Join(err, f.Close())
}
_, writeErr := f.Write(data)
err = errors.Join(writeErr, f.Sync(), f.Close())
if err != nil {
return err
}
if err = checkConfigMetadata(path, info); err != nil {
return err
}
if err = os.Rename(name, path); err != nil {
return err
}
@@ -175,5 +232,5 @@ func atomicConfig(path string, data []byte) error {
if err != nil {
return err
}
return errors.Join(dir.Sync(), dir.Close())
return errors.Join(dir.Sync(), dir.Close(), checkConfigMetadata(path, info))
}
+123
View File
@@ -8,6 +8,7 @@ import (
"os"
"path/filepath"
"strings"
"syscall"
"testing"
)
@@ -50,11 +51,18 @@ func stageFixture(t *testing.T) (Config, string, []byte) {
func TestTemporaryConfigurationPreservesBusinessBytes(t *testing.T) {
c, path, base := stageFixture(t)
info, err := os.Stat(path)
if err != nil {
t.Fatal(err)
}
restore, err := applyConfiguration(context.Background(), c, configCLI(c, path))
if err != nil {
t.Fatal(err)
}
managed := filepath.Join(c.ConfigDir, "sip-call-managed.conf")
if err := checkConfigMetadata(managed, info); err != nil {
t.Fatalf("temporary include owner/group/mode: %v", err)
}
data, err := os.ReadFile(managed)
if err != nil || !bytes.Contains(data, []byte("from_user=BD1234")) {
t.Fatal("requested identity not configured")
@@ -62,6 +70,9 @@ func TestTemporaryConfigurationPreservesBusinessBytes(t *testing.T) {
if err = restore(); err != nil {
t.Fatal(err)
}
if err := checkConfigMetadata(path, info); err != nil {
t.Fatalf("restored owner/group/mode: %v", err)
}
data, err = os.ReadFile(path)
if err != nil || !bytes.Equal(data, base) {
t.Fatal("business config bytes changed")
@@ -71,6 +82,118 @@ func TestTemporaryConfigurationPreservesBusinessBytes(t *testing.T) {
}
}
func TestPrivateConfigInfoRejectsRootAndDifferentOwner(t *testing.T) {
_, path, base := stageFixture(t)
for _, uid := range []int{0, os.Geteuid() + 1} {
if _, err := privateConfigInfo(path, uid); err == nil || !strings.Contains(err.Error(), "sudo sip-call") {
t.Fatalf("uid %d was not rejected with actionable guidance: %v", uid, err)
}
}
if _, err := privateConfigInfo(path, os.Geteuid()); err != nil {
t.Fatal(err)
}
data, err := os.ReadFile(path)
if err != nil || !bytes.Equal(data, base) {
t.Fatal("owner preflight changed configuration")
}
}
type configOwnerInfo struct {
os.FileInfo
owner syscall.Stat_t
}
func (i configOwnerInfo) Sys() any { return &i.owner }
func TestConfigMetadataChecksBothOwnerAndGroup(t *testing.T) {
_, path, _ := stageFixture(t)
info, err := os.Stat(path)
if err != nil {
t.Fatal(err)
}
for _, field := range []string{"uid", "gid"} {
owner := *info.Sys().(*syscall.Stat_t)
if field == "uid" {
owner.Uid++
} else {
owner.Gid++
}
if err := checkConfigMetadata(path, configOwnerInfo{FileInfo: info, owner: owner}); err == nil {
t.Fatalf("%s mismatch was ignored", field)
}
}
}
func TestAtomicConfigPreservesMetadata(t *testing.T) {
_, path, _ := stageFixture(t)
info, err := os.Stat(path)
if err != nil {
t.Fatal(err)
}
data := []byte("replacement configuration\n")
if err := atomicConfig(path, data); err != nil {
t.Fatal(err)
}
if err := checkConfigMetadata(path, info); err != nil {
t.Fatal(err)
}
got, err := os.ReadFile(path)
if err != nil || !bytes.Equal(got, data) {
t.Fatal("configuration replacement content mismatch")
}
}
func TestRestoreRefusesChangedConfigMetadata(t *testing.T) {
c, path, base := stageFixture(t)
restore, err := applyConfiguration(context.Background(), c, configCLI(c, path))
if err != nil {
t.Fatal(err)
}
if err := os.Chmod(path, 0644); err != nil {
t.Fatal(err)
}
if err := restore(); err == nil {
t.Fatal("restoration silently replaced configuration with changed metadata")
}
info, err := os.Stat(path)
if err != nil || info.Mode().Perm() != 0644 {
t.Fatalf("changed configuration was overwritten: %v, %v", info, err)
}
if _, err := os.Stat(filepath.Join(c.ConfigDir, "sip-call-managed.conf")); err != nil {
t.Fatalf("recovery include was removed: %v", err)
}
current, err := os.ReadFile(path)
if err != nil || bytes.Equal(current, base) {
t.Fatal("restoration changed content after metadata mismatch")
}
}
func TestAtomicConfigRejectsInvalidExistingFile(t *testing.T) {
for _, mode := range []string{"public", "symlink", "missing"} {
t.Run(mode, func(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "pjsip.conf")
switch mode {
case "public":
if err := os.WriteFile(path, []byte("original"), 0644); err != nil {
t.Fatal(err)
}
case "symlink":
target := filepath.Join(dir, "target")
if err := os.WriteFile(target, []byte("original"), 0600); err != nil {
t.Fatal(err)
}
if err := os.Symlink(target, path); err != nil {
t.Fatal(err)
}
}
if err := atomicConfig(path, []byte("replacement")); err == nil {
t.Fatal("invalid configuration was replaced")
}
})
}
}
func TestConfigurationRefusesOldAndConcurrentState(t *testing.T) {
c, path, _ := stageFixture(t)
managed := filepath.Join(c.ConfigDir, "sip-call-managed.conf")
-1
View File
@@ -10,7 +10,6 @@ import (
func TestNativeRunnerRejectsMissingAsteriskWithoutDialing(t *testing.T) {
c := fixtureConfig(t)
c.ConfigDir = t.TempDir()
c.Binary = filepath.Join(t.TempDir(), "missing-asterisk")
r, err := Run(context.Background(), c, "18601010101", false)
if err == nil || r == nil || r.Status != "not_dialed" || r.CallID != "" || r.AnsweredAt != nil {
+3
View File
@@ -66,6 +66,9 @@ func run(ctx context.Context, c Config, number string, debug bool, h host, start
if err != nil {
return nil, err
}
if _, err := privateConfigInfo(filepath.Join(c.ConfigDir, "pjsip.conf"), os.Geteuid()); err != nil {
return nil, fmt.Errorf("configuration preflight: %w", err)
}
random := make([]byte, 12)
if _, err = rand.Read(random); err != nil {
return nil, err
+25
View File
@@ -64,6 +64,10 @@ func fixtureConfig(t *testing.T) Config {
t.Fatal(err)
}
c.OutputDir = t.TempDir()
c.ConfigDir = t.TempDir()
if err := os.WriteFile(filepath.Join(c.ConfigDir, "pjsip.conf"), []byte("private config"), 0600); err != nil {
t.Fatal(err)
}
if err := os.Chmod(c.OutputDir, 0700); err != nil {
t.Fatal(err)
}
@@ -72,6 +76,27 @@ func fixtureConfig(t *testing.T) Config {
return c
}
func TestRunRejectsInvalidConfigBeforeCaptureOrDial(t *testing.T) {
c := fixtureConfig(t)
c.ConfigDir = t.TempDir()
if err := os.WriteFile(filepath.Join(c.ConfigDir, "pjsip.conf"), []byte("private config"), 0644); err != nil {
t.Fatal(err)
}
h := &fakeHost{}
captureStarted := false
r, err := run(context.Background(), c, "18601010101", true, h, func(context.Context, Config, string) (captureSession, error) {
captureStarted = true
return &fakeCapture{}, nil
})
if err == nil || captureStarted || h.calls != 0 || r != nil {
t.Fatalf("unsafe configuration reached side effects: report=%+v, err=%v, capture=%v, calls=%d", r, err, captureStarted, h.calls)
}
entries, err := os.ReadDir(c.OutputDir)
if err != nil || len(entries) != 0 {
t.Fatalf("preflight created output: %v, %v", entries, err)
}
}
func TestRunDefaultNeedsNoCaptureToolsAndDialsOnce(t *testing.T) {
c := fixtureConfig(t)
h := &fakeHost{}