From f5eebe638c7cc80fd0d826b046b30a4a602ef1aa Mon Sep 17 00:00:00 2001 From: Rogee Date: Fri, 9 Oct 2026 10:28:28 +0800 Subject: [PATCH] Harden sip-call ownership and capture failure handling --- AGENTS.md | 2 + cmd/sip-call/README.md | 16 ++- cmd/sip-call/main.go | 6 +- cmd/sip-call/main_test.go | 11 ++ .../sip-call-ownership-repair-20261009.md | 89 +++++++++++++ internal/sipcall/capture.go | 2 +- internal/sipcall/capture_test.go | 2 + internal/sipcall/configuration.go | 67 +++++++++- internal/sipcall/configuration_test.go | 123 ++++++++++++++++++ internal/sipcall/native_test.go | 1 - internal/sipcall/run.go | 3 + internal/sipcall/run_test.go | 25 ++++ 12 files changed, 337 insertions(+), 10 deletions(-) create mode 100644 docs/evidence/sip-call-ownership-repair-20261009.md diff --git a/AGENTS.md b/AGENTS.md index 6b16d47..6d155a5 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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 兜底或偷偷启用旧执行字段。已接纳任务持久绑定原快照。 diff --git a/cmd/sip-call/README.md b/cmd/sip-call/README.md index 6d294bf..f07fe47 100644 --- a/cmd/sip-call/README.md +++ b/cmd/sip-call/README.md @@ -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-` 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、拨号前绑定时间、接通时间、结束确认时间、最终状态及失败原因。 开启调试后另有: diff --git a/cmd/sip-call/main.go b/cmd/sip-call/main.go index 8f079b7..5a61c4b 100644 --- a/cmd/sip-call/main.go +++ b/cmd/sip-call/main.go @@ -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 diff --git a/cmd/sip-call/main_test.go b/cmd/sip-call/main_test.go index c1db7c2..9824f37 100644 --- a/cmd/sip-call/main_test.go +++ b/cmd/sip-call/main_test.go @@ -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{} diff --git a/docs/evidence/sip-call-ownership-repair-20261009.md b/docs/evidence/sip-call-ownership-repair-20261009.md new file mode 100644 index 0000000..08eec3b --- /dev/null +++ b/docs/evidence/sip-call-ownership-repair-20261009.md @@ -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 联合自检及真实线路接通保持未验证。 diff --git a/internal/sipcall/capture.go b/internal/sipcall/capture.go index 5c6b61f..3192842 100644 --- a/internal/sipcall/capture.go +++ b/internal/sipcall/capture.go @@ -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) diff --git a/internal/sipcall/capture_test.go b/internal/sipcall/capture_test.go index 20ce91f..5e0d9c5 100644 --- a/internal/sipcall/capture_test.go +++ b/internal/sipcall/capture_test.go @@ -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 { diff --git a/internal/sipcall/configuration.go b/internal/sipcall/configuration.go index 7adc638..959da08 100644 --- a/internal/sipcall/configuration.go +++ b/internal/sipcall/configuration.go @@ -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)) } diff --git a/internal/sipcall/configuration_test.go b/internal/sipcall/configuration_test.go index 0df1d85..af443e5 100644 --- a/internal/sipcall/configuration_test.go +++ b/internal/sipcall/configuration_test.go @@ -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") diff --git a/internal/sipcall/native_test.go b/internal/sipcall/native_test.go index 82aba0f..dbb98ea 100644 --- a/internal/sipcall/native_test.go +++ b/internal/sipcall/native_test.go @@ -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 { diff --git a/internal/sipcall/run.go b/internal/sipcall/run.go index 3c56950..85f0d64 100644 --- a/internal/sipcall/run.go +++ b/internal/sipcall/run.go @@ -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 diff --git a/internal/sipcall/run_test.go b/internal/sipcall/run_test.go index 01d472b..76eb333 100644 --- a/internal/sipcall/run_test.go +++ b/internal/sipcall/run_test.go @@ -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{}