前两天有个同事甩了个标题过来:Kamailio 使用 re.substr 碰到的一个问题。我第一眼看到 re.substr 就愣了一下,Kamailio 的脚本函数列表里,压根没有叫 substr 的正则函数。他搜了半天文档也没找到,后来才反应过来,他实际想用的是 re 模块家族的 re.sub / re.match,只是某篇笔记里把函数名写串了。函数名记错不算大事,真正让他头大的是 re.sub 在改 SDP 时怎么都替换不上,还偶尔把整个消息体改坏。这期就从这个小插曲出发,把 Kamailio 正则替换的转义机制、以及和 rtpengine / rtpproxy 配合时最容易踩的坑,一次说清楚。如果你正在用 Kamailio 做 SIP 网关、SBC,或者刚准备接入 rtpengine 做媒体代理,这篇文章应该能帮你省下至少一个通宵。
1. 项目背景与故障现场
1.1 这个坑是在什么场景下出现的
先说我的实际场景。公司语音外呼平台里,Kamailio 承担入口 SIP 网关的职责,前端接运营商中继,后端接内部的 FreeSWITCH 集群。用户侧不少终端躲在 NAT 后面,所以媒体肯定不能直连内网地址,必须引入 rtpengine 做媒体转发和 SDP 改写。大部分常规流程用 rtpengine_manage() 就能解决,但那次有个定制需求:呼叫保持或者 re-INVITE 的时候,要在 SDP 里新增一个自定义 attribute,同时需要把内部拓扑里某几段特殊 c= 行的地址改成公网出口地址。
当时我第一版方案很“直观”:在 request_route 里直接对消息体做文本替换。大概就是先取 $mb,然后用 re.sub 把目标 IP 换掉,再写回 $mb。结果一跑起来,SDP 纹丝不动。加 xlog 打了半天日志,发现 re.sub 返回的是替换次数 0,压根没匹配上。再后来把匹配模式放开一点,又出现更离谱的现象:替换后 SDP 变成畸形内容,c= 行后面一大截全被吞掉,终端直接回复 488 Not Acceptable Here。
这个故障组合拳打下来,基本可以确定不是简单的“正则写错了”,而是 Kamailio 对字符串转义的处理方式跟普通编程语言不太一样。后面排查时发现,问题分了三层:模式字符串的转义、替换串的转义、以及修改 $mb 之后是否需要显式让消息体生效。
1.2 网上资料为什么会让你越看越晕
搜 Kamailio re.sub 的时候,会发现官方文档不太友好,例子少,还经常混着 re.match、re.match_group、re.regname 一起讲。而且 Kamailio 5.x 的 re 模块底层基于 PCRE,但 cfg 解析器又会先对字符串常量做一层处理,于是网上同一段正则,有人写单引号、有人写双引号,反斜杠数量还不一样。复制过来基本不能用,因为换一个版本,解析行为可能就有细微差异。
还有一层干扰来自模块名。很多人搜“kamailio rtpproxy”“kamailio rtpengine 模块怎么启用”,其实他们要的是媒体代理功能,而不是正则替换本身。但一旦进入“手工改 SDP”这个动作,就必然要碰正则、碰消息体修改,最后又绕回 re.sub 这些函数上来。所以我这篇文章不打算再去介绍路由脚本基础语法,而是假设你已经能在 Kamailio 里跑起来一个基本呼叫流程,然后把正则替换这个环节彻底讲透。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 先把函数名和语法对齐:re.substr 到底是谁
2.1 Kamailio re 函数家族的真实名单
Kamailio 里带 re 前缀的常用函数,主要就这几个:
re.match("/pattern/", "input", "$m"):执行一次匹配,匹配成功后$m会变成一个数组,$m[0]是整体匹配,$m[1]、$m[2]是括号分组。它本身不替换。re.match_group("$m[1]"):从匹配数组里取某个分组,等价于部分版本的$(m[1])写法。re.sub("/pattern/replacement/flags", "input", "result"):替换所有匹配到的地方,结果放到第三个参数里,函数返回值是替换次数。- 比较老的版本还有
re.regname,用来给正则表达式临时命名,方便多次复用。
这里根本没 substr 什么事。如果你在笔记或旧博客里看到 re.substr,大概率是把 PHP 或 Perl 的函数名混进来了。真正确认函数名有没有记错,最快的方法是看一眼安装的模块版本,执行 kamcmd mod.stats re 或者直接看 Kamailio 启动日志里模块加载列表。我自己在 5.5 和 5.6 两个版本上都验证过,稳定的入口就是 re.match 配 re.sub,后面所有示例也统一用这两个。
2.2 反斜杠的“双层解析”才是万恶之源
为什么 re.sub 的正则这么容易写错?因为一个正则模式从 cfg 文件写到最终 PCRE 引擎手里,中间要过两层:
第一层是 Kamailio cfg 加载器解析字符串常量。Kamailio 的双引号字符串支持转义序列,比如 "\\" 在加载后会变成一个反斜杠;单引号字符串的行为在多数版本里更接近字面量,反斜杠不做额外转义。但为了保险,我后来的代码统一用双引号写正则,自己心里先记住一层转义。
第二层是 PCRE 正则引擎解析。PCRE 看到 \. 才知道这是匹配字面点号,看到 \d 才知道是数字。所以如果你想最终送给 PCRE 的字符串里包含 \d,cfg 源码里就要写成 "\\d"。经常有人写正则时直接照搬在线工具里的模式,比如 \d+\.\d+,放进双引号后不补反斜杠,最后 PCRE 收到的其实是 d+ 和 .,点号变成任意字符,整个 SDP 被吃掉也就不奇怪了。
这里有一个非常容易踩的误区:很多人以为单引号字符串里写 '\.' 就能少转义一层。实测在不同小版本下,单引号和双引号对反斜杠的处理并不完全统一,尤其在升级 Kamailio 之后行为可能会有差异。所以我的建议是别混用,全程双引号,严格按照“两层转义”来写:
- 想让 PCRE 收到
\d,源码里写"\\d" - 想让 PCRE 收到
\.,源码里写"\.",也就是代码里看到两个字符反斜杠和点号 - 想让 PCRE 收到
\(去匹配字面左括号,源码里写"\("
[!IMPORTANT]
不要只记“双引号里多写一个反斜杠”这种口决,而是理解:cfg 字符串这一层会把
\\还原成\,之后 PCRE 再解释一次。理解了这一层,不管 Kamailio 版本怎么变,你都能根据实际报错反推正确的转义数量。
2.3 re.sub 的返回值和第三个参数别搞混
官方文档里 re.sub 的常见调用方式是 re.sub("/pattern/replacement/flags", "input", "result"),第三个参数是输出变量。函数返回值表示替换次数,0 表示没有匹配,大于 0 表示替换成功。写成脚本大概是这样:
kamailio复制if(re.sub("/(c=IN IP4) \\d+\\.\\d+\\.\\d+\\.\\d+/\\1 203.0.113.10/g", "$mb", "$var(new_sdp)")) {
xlog("L_INFO", "re.sub ok, new_sdp=$var(new_sdp)\n");
$mb = $var(new_sdp);
msg_apply_changes();
}
注意这里如果只写了两个参数,$var(new_sdp) 永远拿不到内容。我早期踩过这个坑,一直以为 re.sub 会把结果直接放在输入变量里,结果日志打出来输入变量还是原样。正确思路是:输入变量是不动的,第三个参数才是真正的输出。判断是否替换成功,看第一个返回值即可,别拿输出变量是否为空来判断,因为有些场景下输出变量可能恰好等于原始输入。
3. 实战排查:从一个失败的替换到完整解决方案
3.1 故障现场和第一轮排查
故障最开始,我的改写规则写在 INVITE 处理分支里。想实现的效果很简单:把 SDP 里所有 c=IN IP4 10.10.10.10 替换成 c=IN IP4 203.0.113.10。第一版代码如下:
kamailio复制$mb = re.sub("/(c=IN IP4) 10.10.10.10/\\1 203.0.113.10/g", "$mb", "$var(new_sdp)");
跑完后抓包发现 INVITE 里 SDP 一点没变。于是我在代码前后加了 xlog,分别打印 $mb 和 $var(new_sdp),发现 $var(new_sdp) 和 $mb 完全一样,而且 re.sub 返回 0。这里暴露了第一个问题:内网 SDP 里的地址根本不是 10.10.10.10,实际是另一个私网段 172.16.0.10。我的规则写得太死,等于白写。
把规则改成通配 IP 之后,重新测试,$var(new_sdp) 倒是变了,但内容惨不忍睹:c=IN IP4 后面的 IP 和后面的几行属性全被替换成一个固定地址,SDP 格式直接烂掉。这里就是典型的点号没有正确转义,\\d+ 里的点号和分隔符点号写成了 .,被 PCRE 当成“任意字符”,于是贪婪匹配吞掉了后面一大段。
3.2 问题根源拆解:模式串、替换串、消息体生效缺一不可
把整个故障拆开看,其实是三个独立问题叠加:
第一个是模式串转义错误。正确写法必须确保源码里的点号是 "\.",数字简写是 "\\d"。IPv4 的匹配模式应该长这样:
kamailio复制"\\d+\\.\\d+\\.\\d+\\.\\d+"
第二个是替换串里的 \1 处理。很多人模式串会转义了,但替换串里想引用分组时又写错。替换串里的 \1 要表达的是“反斜杠字符 + 数字 1”,在双引号字符串里同样要把反斜杠转义,所以源码里写 "\\1"。如果你看到替换结果里出现了字面 \1,说明这里又少了一层转义。
第三个问题藏得最深:就算 $var(new_sdp) 已经拿到正确内容,把 $mb = $var(new_sdp) 赋值之后,Kamailio 发送消息时并不保证一定使用你修改后的消息体。很多场景下需要调用一次 msg_apply_changes(),把脚本里对消息体的修改正式提交到内部 SIP 消息缓冲区,后续转发或者回复才用得上。
最终能用的版本变成了下面这样:
kamailio复制route[SDP_REWRITE] {
if(!has_body("application/sdp")) {
return;
}
if(re.sub("/(c=IN IP4) \\d+\\.\\d+\\.\\d+\\.\\d+/\\1 203.0.113.10/g", "$mb", "$var(new_sdp)")) {
$mb = $var(new_sdp);
msg_apply_changes();
xlog("L_INFO", "SDP rewritten, new body=$mb\n");
} else {
xlog("L_WARN", "SDP rewrite failed, ret=$? mb=$mb\n");
}
}
这个版本我在 Kamailio 5.6 上实测通过。注意 rematch 模式里分了组 (c=IN IP4),替换串用 \\1 引用这个分组,所以替换后的内容会保留 c=IN IP4 这段前缀,只替换后面的 IP。很多教程里不用分组,直接整体替换,也能实现,但分组写法在需要“保留前缀只改后缀”的场景下更灵活,可读性也更高。
3.3 现场附带发现:$m 数组取值和变量嵌套的写法
排查过程中还顺带发现一个跟 re.match 有关的经典误用。很多人会用 re.match 去提取 SDP 里的某个值,比如取 c=IN IP4 后面的 IP:
kamailio复制if(re.match("/(c=IN IP4) (\\d+\\.\\d+\\.\\d+\\.\\d+)/", "$mb", "$m")) {
xlog("L_INFO", "match ip=$(m[2])\n");
}
这里有两个细节。第一,$m 数组的下标从 0 开始,$m[0] 是整个匹配,$m[1] 是第一个括号,$m[2] 是第二个括号。想要 IP,就得用 $(m[2]) 包起来,写 $m[2] 在部分上下文里会被 Kamailio 解析成变量 $m 后面接字符 2,结果不是你想要的分组值。第二,如果分组数量不固定,取值前最好先判断 re.match 的返回值,不然 $(m[2]) 可能为空,后面再做字符串拼接就会产生空结果。
我在实际项目里会把这种提取和替换结合使用,先取出原始值,做一次合法性判断,再决定是否改写。比如只有私网地址段才改写,公网地址不动,这样能大大降低误改风险。具体判断可以这样:
kamailio复制if(re.match("/(c=IN IP4) (\\d+\\.\\d+\\.\\d+\\.\\d+)/", "$mb", "$m")) {
if($(m[2]) =~ "^10\\." || $(m[2]) =~ "^192\\.168\\.") {
re.sub("/(c=IN IP4) \\d+\\.\\d+\\.\\d+\\.\\d+/\\1 203.0.113.10/g", "$mb", "$var(new_sdp)");
$mb = $var(new_sdp);
msg_apply_changes();
}
}
这段逻辑里我用 =~ 直接做了 PV 字符串匹配,正则写在双引号里同样遵循两层转义规则。实际跑下来,误伤范围小了很多,也不会把已经改好的公网地址再改一遍。
4. 和 rtpengine / rtpproxy 配合时,手改 SDP 要格外小心
4.1 人工改写和媒体代理模块为什么会互相打架
很多人在网上搜“kamailio 启用 rtpproxy、rtpengine 模块”,搜到的资料喜欢教人手动把 SDP 里的内网地址改公网地址,再调用 rtpproxy_manage() 或 rtpengine_manage()。这个顺序本身没问题,但要命的是两个模块内部都有自己的 SDP 处理逻辑,它们是基于原始 SDP 做解析的。如果你先手动把 c= 行改了,rptengine 再用它内部记录的结果去覆盖,很容易出现“你改完,它又改回去”或者“它改完,你又覆盖掉”的情况。
我后来在项目里总结出的原则是:能用 rtpengine 自身能力实现的功能,绝不手动改 SDP。rtpengine 支持很多定制属性,比如设置 TOS、ICE、SRTP 参数,文档里都有对应选项。手动 re.sub 只用于两个场景:一是增加 rtpengine 不认识的私有 SDP attribute,二是删除某些会引发终端兼容性问题的行。这两个场景才是正则替换的合理位置。
4.2 推荐流程:先预处理,再交给 rtpengine 重新生成 SDP
如果要加自定义 attribute,我的做法是在 INVITE 或 200 OK 进入业务分支后,先执行我们之前写的 SDP_REWRITE 路由,再调用 rtpengine_manage。顺序绝对不能反。因为 rtpengine_manage 会根据它看到的消息体生成新的 SDP,如果你先调了 rtpengine_manage,再手动改 SDP,那改完的内容和 rtpengine 内部的媒体端口、短期媒体会话状态就对不上了,媒体流基本起不来。
一个实际可用的流程大概是:
kamailio复制route[CALL_PREPROCESS] {
if(has_body("application/sdp")) {
route(SDP_REWRITE);
# 到这里 $mb 已经是经过正则处理后的 SDP
# 再交给 rtpengine 统一处理 NAT、媒体代理等动作
rtpengine_manage("direction=internal");
}
}
这样正则在前面做“外科手术”,rtpengine 在后面做“整体接管”。实际上 rtpengine 内部会重新解析整理 SDP,你新增的自定义 attribute 如果符合 SDP 语法,一般会被保留。但要注意别把 c= 行改成和 rtpengine 协商结果冲突的地址,否则 rtpengine 生成的 SDP 里还是会用媒体代理的公网地址,你手改的就白费了,甚至还可能造成协商不一致。
如果确实需要在 rtpengine_manage 之后再修改,也要重新调用一次 msg_apply_changes,并且在日志里对比 rtpengine 返回的 SDP。我的经验是这种“后改”做法极其容易翻车,两个字段冲突时,终端有时会接受,有时直接回 488,排查起来非常痛苦。能绕就绕。
4.3 调测时盯住这几个关键位置
跟 rtpengine 联调时,我最常做的三件事:
第一,抓包看实际发出的 INVITE 和 200 OK,直接看 SDP 的 c= 行、m= 行和 a=rtcp 属性。很多问题在日志里看不出来,但抓包一眼就能看到地址到底是内网还是公网。
第二,在 Kamailio 脚本里把每个关键节点的 $mb 打出来,特别是 rtpengine_manage 前后的对比。rtpengine 模块本身也能看日志,把日志级别调到 debug,可以看到它生成 SDP 时的完整过程。两边对照,基本能定位是手动改写的问题还是 rtpengine 配置的问题。
第三,用 rtpengine-ctl 查媒体流状态。如果呼叫建立后媒体流没有双向通过,优先看 rtpengine 的端口是否通、会话是否超时。很多时候问题根本不在 SDP 改写,而是防火墙没放行媒体端口。如果只盯 re.sub 就容易绕半天回不到正题。
4.4 rtpproxy 和 rtpengine 的选型建议
老项目还在用 rtpproxy 的话,思路是一样的,只是函数名换成 rtpproxy_manage()、rtpproxy_offer() 之类。rtpproxy 功能相对简单,SDP 处理能力没有 rtpengine 强,遇到 ICE、SRTP、TOS 这类需求时会非常吃力。新项目我建议直接上 rtpengine,别再花时间学 rtpproxy 的旧参数了。不管选哪个,手改 SDP 的时机原则都一样:在媒体代理接管之前做预处理,不要抢模块的活。
5. 正则函数族常见问题速查与避坑建议
5.1 高频故障排查速查表
下面这张表是我处理 Kamailio SDP 改写问题时最常翻的清单,基本覆盖了 re.sub / re.match 和 rtpengine 联调时的大部分报错。
| 症状 | 可能原因 | 解决思路 |
|---|---|---|
| re.sub 返回值 0,消息体不变 | 模式串和实际内容不匹配,或地址写死 | 先用 xlog 打印 $mb,确认目标行真实内容;用通配 IP 替代固定地址 |
| 替换后 SDP 被吞掉一大截 | 点号没有转义,PCRE 把 . 当任意字符 |
源码里写 "\.";IPv4 模式写成 \\d+\\.\\d+\\.\\d+\\.\\d+ |
替换结果里出现字面 \1 |
替换串中反斜杠转义不对 | 双引号中写 "\\1" 来引用分组 |
分组取值 $(m[2]) 为空 |
re.match 没匹配到,或数组下标用错 | 先判断 re.match 返回;确认分组括号数量;用 $(m[n]) 取值 |
手动改了 $mb 但发出的包没变 |
没有调用 msg_apply_changes | 修改 $mb 后立刻调用 msg_apply_changes() |
| 和 rtpengine 联调时媒体不通 | 手动改的 SDP 和 rtpengine 协商结果冲突 | 优先在 rtpengine_manage 前预处理;别覆盖 c= 和 m= 的端口地址 |
| 正则只匹配第一处,后续没改 | 没用全局替换标志 | re.sub 模式末尾加 g,例如 /pattern/repl/g |
这张表我也贴在团队内部文档里了,后来其他同事再遇到 SDP 改写问题,基本都是先查这张表再动手,效率高很多。
5.2 不是所有场景都适合用 re.sub 硬刚
如果你要做的操作很简单,比如删除 SDP 里所有 a=inactive 行,或者把 a=sendrecv 改成 a=sendonly,我会优先推荐用 sdpops 模块,而不是写一大段正则。sdpops 提供了 sdp_get_line_startswith、sdp_remove_line_by_prefix 这类专门处理 SDP 的函数,语义清晰,不容易误伤。正则最大的问题是可读性差,一旦模式写了七八行,三个月后你自己回来看可能都要想半天。
另外,textops 模块里的 replace_body_all 在简单字符串替换场景下也很好用,它不涉及正则转义,直接做字面替换,性能还更高。比如只想把 203.0.113.10 换成 198.51.100.20,用 replace_body_all("203.0.113.10", "198.51.100.20") 就够了,完全不需要引入 re 模块。
5.3 我自己的几个调试小习惯
每次写新的 re.sub 规则,我不会直接塞进生产路由,而是先在一个独立的小路由里用固定 $mb 测试。方法很简单:构造一个假的 SDP 消息体,赋给一个临时变量,然后跑 re.sub,再打印结果。这样能在不影响线上流程的情况下把正则调对。
还有一个小技巧,用 xlog 打印消息体时,如果 $mb 太长,日志会被刷屏。我习惯只打印 $mb 的前 200 个字符,或者用 $(mb{re.match(...)}) 只提取关键子串再打印。具体写法看版本支持,但思路就是减小日志量、提高排查效率。
再就是写正则之前,先在 PCRE 在线工具或者本地 pcregrep 命令里验证模式本身是否正确。验证通过后,再往模式里补一层 cfg 双引号转义。这样做可以把“正则语法错误”和“Kamailio 转义错误”分开排查,比直接在 Kamailio 里猜要快很多。
6. 最后再分享一点经验
这个问题折腾完,我最大的感受是:Kamailio 的坑很少在函数本身,多数在它那层隐形的字符串解析规则上。re.sub 不复杂,复杂的是你永远要记得,你写下的正则在到达 PCRE 引擎之前,已经被 cfg 解析器处理过一轮。只要把转义层次捋清楚,re 函数家族用起来就很顺手。
如果你也是从 re.substr 这个函数名搜过来的,现在可以放心了,正确的入口是 re.match 和 re.sub。真的再次遇到“SDP 替换不生效”,别再怀疑是不是名字写错了,先打印 $mb,再看返回值,再检查 msg_apply_changes,三步基本能解决九成问题。后面如果再碰到更复杂的 SDP 改写需求,我建议试试 sdpops 模块,它能帮你避开很多正则转义的烦恼。我这里的经验就这些,希望帮你少踩几个坑。
