大概半年前,我们切一个新SIP trunk,一宿抓包看到一堆v:、f:、t:、i:、m:开头的行,团队里新同事第一反应是网关被入侵了。我盯着sngrep的原始报文看了十几秒,确认这些都是SIP里的compact headers——官方协议允许的缩写头字段,不是什么异常流量。如果你也在用Kamailio处理这类消息,或者正准备对接某个老运营商网关,这篇内容基本能把Kamailio和compact headers之间那点事讲透,包括协议原理、解析器行为、路由脚本处理姿势,以及几个我实际踩过的坑。
1. Compact Headers的来龙去脉:为什么SIP要大费周章省几个字节
1.1 单字母缩写背后的网络约束
SIP协议从设计之初就考虑到窄带和弱网环境。RFC 3261第7.3.3节明确规定了compact header form,允许把常见头字段名替换为单字母缩写。比如From可以写成f,Via可以写成v,Call-ID可以写成i。头字段的值部分完全不变,只有名字被缩短,所以解析方没有任何歧义。
这个机制看起来简单,但解决的是真实痛点:SIP消息走UDP时,一个INVITE请求通常几百字节,加上SDP body很容易超过以太网MTU 1500字节的安全阈值。一旦IP分片,丢包率就上去了,NAT会话也可能出问题。在3G/4G早期,无线承载的MTU更小,压缩几个头名字对信令面稳定性是有实际贡献的。
1.2 RFC 3261官方映射表
说到compact headers,绝大多数人会直接查RFC 3261。官方定义的映射关系不长,就是下面这11个:
| 完整头字段 | Compact形式 | 备注 |
|---|---|---|
| To | t | 请求/响应中的To |
| From | f | 请求/响应中的From |
| Call-ID | i | 全局唯一呼叫标识 |
| Contact | m | 联系地址 |
| Content-Length | l | Body长度 |
| Content-Type | c | Body类型 |
| Via | v | 传输路径 |
| Route | r | 路由头 |
| Record-Route | o | 记录路由头 |
| Subject | s | 会话主题 |
| Supported | k | 扩展能力声明 |
注意,Organization在RFC 2543里曾经使用o作为缩写,但RFC 3261把o分配给了Record-Route,Organization从此没有标准compact形式。这个历史遗留我在后面第五部分会专门讲,因为真有人被它坑过。
1.3 compact header是大小写敏感的
RFC 3261规定compact form必须是小写字母。v是Via,但V就是一个普通自定义头,跟Via没有任何关系。很多设备对大小写不敏感,解析时宽容处理,但Kamailio按标准实现,就不该指望它把大写V当成Via来识别。抓包时看到F:或M:这种,先不要急着当作From和Contact,很可能只是某个设备自己定义的非标头。
1.4 什么时候必须关注compact headers
不是所有项目都需要关心它。你如果只在纯内网环境用Kamailio做代理,所有终端都是自己掌控的软电话,那大概率几年都见不到几个compact header。但下面几类场景里,它一定会找上门:
- 对接运营商SIP trunk或IMS网络,运营商侧SBC为了压包长,经常在出方向启用compact form
- 用Kamailio做汇聚接入层,接大量老式IP话机,尤其是一些欧洲和日本品牌的设备,为了省带宽喜欢用缩写
- IP话机通过GPRS/4G/LTE模块注册,模组厂商会在协议栈里默认开compact
- 任何对UDP包长敏感的弱网环境,消息一大就出问题
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. Kamailio解析器对v/f/m等缩写的内建识别逻辑
2.1 解析层就把两种写法归成同一类型
Kamailio的SIP解析器在处理消息头时,不是简单做字符串比较。它对常见头做了类型映射,Via和v都会被归为HDR_VIA,Contact和m都会被归为HDR_CONTACT,Route和r都会被归为HDR_ROUTE。这意味着Kamailio核心流程在处理消息时,根本不关心你发过来的是Via:还是v:。
实际影响很大:loose_route()、has_totag()、has_contact()这类函数,底层都是基于解析器已经识别好的头字段类型干活,所以收到带r:或o:的请求时,路由逻辑照常工作。你在脚本里写的if (loose_route())不会因为对方用了compact form就失效,这一点Kamailio做得相当稳。
2.2 $hdr伪变量对两种写法都能取值
路由脚本里最常用的取值方式是通过$hdr(name)伪变量。从实际行为来看,Kamailio在实现$hdr时同时考虑了名字匹配和类型匹配。
在我的5.x版本测试环境里,这两种写法都能正确取到值:
kamailio复制xlog("L_INFO", "From完整名取值:$hdr(From)\n");
xlog("L_INFO", "From缩写取值:$hdr(f)\n");
所以当你抓包看到消息头是f: "Alice" <sip:100@example.com>,在Kamailio脚本里用$hdr(From)也能拿到同样的内容。不过要注意,伪变量匹配的具体行为在不同版本间有细微差异,我后面会讲为什么我在生产脚本里仍然坚持只用完整名称。
2.3 is_present_hf、remove_hf这类API的匹配边界
这是最容易踩坑的地方。is_present_hf()和remove_hf()在实现上更偏向名称匹配,它们不像loose_route()那样天然基于类型。有些版本对compact form做了兼容,有些则没有,完全看实现细节。
我遇到过的情况是:请求里实际头是m: <sip:100@x.x>,脚本里写if (is_present_hf("Contact")),在某个Kamailio 5.2版本上失效了,但同一个脚本放到5.5又正常。这种版本差异很难提前预判,唯一的稳妥做法是:
在上线前用sngrep灌一条compact header的SIP消息进测试环境,把
is_present_hf、remove_hf这些函数挨个验证一遍。不要假设它在你的版本上一定兼容。
如果你不想赌版本行为,就优先用那些天然基于类型的函数:判断From用has_from(),判断Contact用has_contact(),判断To Tag用has_totag(),路由判断用loose_route()。
3. 路由脚本里读写Compact Headers的几种可靠姿势
3.1 从compact头中提取关键信息
Kamailio解析消息后,很多信息根本不走$hdr,而是被解析器塞进了伪变量和AVP里。比如$fU是From头的URI用户部分,$tU是To头的URI用户部分,$ci是Call-ID,$du是目标URI。这些伪变量跟消息原始头是完整写法还是compact写法完全无关,因为解析器已经干完活了。
所以实际项目中,我读信息时尽量不碰头字段本身:
kamailio复制if (is_method("INVITE")) {
xlog("L_INFO", "caller=$fU callee=$tU callid=$ci\n");
# 如果确实需要原始头文本,再走$hdr
xlog("L_INFO", "raw contact=$hdr(Contact)\n");
}
只有需要完整头字段字符串时,比如做签名校验、头字段透传,才用$hdr(Contact)或$hdr(f)。这种写法对compact和完整形式都不敏感,最省心。
3.2 判断和删除特定头字段
判断头是否存在,我的推荐优先级是这样:能用专用函数就用专用函数,比如has_contact()、has_totag();没有专用函数的,用is_present_hf("Contact"),并在测试环境验证compact形式是否命中。
删除头字段时,remove_hf()同样存在版本兼容问题。我在实际项目中更倾向于用正则排除法,或者干脆不动它:
kamailio复制# 从Request中删除Record-Route并自己重设路由头
# 注意:如果消息里是o:开头,remove_hf("Record-Route")可能不生效
remove_hf("Record-Route");
route(REROUTE);
真要稳妥处理删除,就在抓包确认这条消息的compact行为之后再做。我个人的习惯是:删除操作前面加一条xlog,先看$hdr(Record-Route)能不能取到值,再决定要不要依赖remove_hf。
3.3 用textopsx模块主动添加compact头字段
Kamailio里真正提供compact头字段原生操作的是textopsx模块,核心函数是append_compact_hf和insert_compact_hf。它们的语义很简单:
append_compact_hf(hf, text):在消息末尾追加一个头字段,头名字是hf指定的单字符insert_compact_hf(hf, text):在消息头部插入一个头字段
比如你在响应里加一个自定义的X: 123头:
kamailio复制# 需要确保textopsx模块已加载
append_compact_hf("X", "123");
这会在消息末尾生成X: 123。虽然这跟SIP标准里的v/f/m不是同一个层次的概念——它更像是"用一个字符做头名字",但函数确实叫compact header,而且在处理消息瘦身时很好用。
一个容易误解的地方:append_compact_hf("f", "xxx")并不是把已有的From头转换成缩写,它就是简单地往消息里追加一个名为f的新头字段。如果消息里已经有From:,那消息里会同时存在From:和f:两个头,对端解析时就是双From,行为不可控。所以这个函数只建议用来加自定义头或确认不冲突的短头,不建议用它去复制已有标准头。
3.4 msg_apply_changes的延迟生效机制
Kamailio对消息的修改是延迟提交的。append_compact_hf、remove_hf这一类操作会先把变更挂在消息的修改链表上,真正写入消息要等msg_apply_changes()被调用。
这个坑特别隐蔽。你想验证插入是否成功,马上用xlog("$hdr(X)")去读,发现是空的,以为是函数没执行。实际原因是变更还没提交:
kamailio复制append_compact_hf("X", "123");
msg_apply_changes();
xlog("L_INFO", "now x=$hdr(X)\n");
在实际生产脚本里,我一般会在所有头字段修改做完之后调一次msg_apply_changes(),然后一次性查结果。这样既减少重复提交的开销,也避免在中间状态上做判断。
4. 抓包对比:Compact Headers到底能让SIP消息瘦多少
4.1 怎么抓到compact headers并看清它
抓包工具首推sngrep,它底层调libpcap,专门解析SIP和RTP,而且会还原消息结构。在sngrep界面里按i键能看原始报文,compact header是原样展示的。
如果需要后台抓包,可以这样:
bash复制ngrep -d any -T -W byline -qt -s 3000 -p SIP
或者用tcpdump抓后拖进Wireshark,过滤器直接写sip,在SIP协议树里能看到头字段的原始名字。
4.2 一个典型INVITE头的字节节省明细
我们拿一个典型的INVITE请求头字段估算一下,不含SDP body,只算头字段名部分:
| 头字段 | 完整名长度(含冒号) | Compact长度(含冒号) | 节省字节 |
|---|---|---|---|
| Via: | 5 | 2 | 3 |
| From: | 6 | 2 | 4 |
| To: | 4 | 2 | 2 |
| Call-ID: | 9 | 2 | 7 |
| Contact: | 9 | 2 | 7 |
| Record-Route: | 14 | 2 | 12 |
| Content-Type: | 14 | 2 | 12 |
| Content-Length: | 15 | 2 | 13 |
| Subject: | 9 | 2 | 7 |
这9个头加起来,光名字部分就能省约67字节。如果加上每个头后面的空格和CRLF,整体省下的字节数差不多在70到80字节。对一条800到1200字节的SIP消息来说,这个优化比例不算小,尤其是在MTU边界徘徊的时候,省这几十字节可能就是分片和不分片的分界线。
4.3 MTU安全线和IP分片的实际代价
以太网MTU通常1500字节,IPv4头20字节,UDP头8字节,所以UDP payload的安全上限是1472字节。如果走VLAN,还要再扣4字节,PPPoE拨号环境扣8字节,实际可用空间更小。
IP分片的代价很直接:任何一个分片丢了,整个数据报都废了,而且很多家用路由器、运营商ALG设备对分片包的处理非常粗暴。SIP信令是UDP实时交互,分片重传会导致注册延迟、呼叫建立变慢,严重时直接呼叫失败。
所以我的习惯是:在Kamailio前面加一个简单的包长统计,定期看SIP消息大小分布。如果发现消息经常在1400字节以上波动,就要考虑压缩手段了。Compact headers是其中一个手段,它能省一点,但如果消息Body本身是上千字节的SDP,省头字段名的意义就有限了,这时候更该考虑删减SDP行或者用TCP/TLS承载。
4.4 Kamailio转发时并不会自动输出compact形式
这一点需要先讲清楚:Kamailio本身在生成Via、Record-Route这些头时,默认全部输出完整名称。也就是说,你的消息从Kamailio出去时,头字段名还是Via:、Record-Route:这种完整写法,不会因为入向消息用了v:、o:就自动跟着变。
所以如果你真的需要出方向消息也走compact形式,唯一的办法是在路由脚本里手动处理:把已有的完整头删掉,再用append_compact_hf补上短头。但这种操作风险很高,尤其是Route和Record-Route这种参与路由决策的头,手动改错一个字符,呼叫就绕飞了。我一般不建议在生产环境做这种转换,除非有明确的运营商合同要求。
5. 生产环境中容易被Compact Headers坑到的三个场景
5.1 用正则匹配头名字导致路由失效
这是我们第一次栽跟头的地方。当时新接入一个运营商trunk,对方SBC在Record-Route和Route上全部用了compact形式,消息里是r: <sip:10.0.0.1;lr>,而我们脚本里有一段老代码:
kamailio复制if (search("^Route:(.*)myproxy.example.com")) {
route(TO_VENDOR);
}
结果路由一直匹配不上,请求被当成未知路由处理。排查链路是这样的:
- 先把消息从
sngrep里导出来,确认Route头实际是r:开头 - 用
xlog打印$hdr(Route),发现Kamailio能取到路由头的内容 - 测试
is_present_hf("Route"),在这个版本上失效 - 最终把判断逻辑改成
loose_route()加$hdr(Route)内容正则
修复后的代码风格:
kamailio复制if (loose_route()) {
if ($hdr(Route) =~ "myproxy.example.com") {
route(TO_VENDOR);
} else {
route(RELAY);
}
}
这个坑的核心教训是:不要用文本正则去匹配头字段名,而是用Kamailio提供的头字段API和伪变量。
5.2 append_compact_hf引发重复标准头
有一段时间我们需要向所有出方向INVITE强制插入一个Contact头,让对端媒体流走指定IP。当时图省事,直接在脚本里写:
kamailio复制append_compact_hf("m", "<sip:media@192.168.10.20>");
结果对端反馈Call-ID都没变但呼叫100%失败。抓包发现消息里同时存在Contact:和m:两个头,对端UA严格按照RFC 3261解析,只认第一个Contact,但第一个又不是我们想要的,于是媒体流就乱了。
这个案例的教训是:append_compact_hf不会去检查消息里是否已经有同名头,它只是无脑追加。如果你要修改已有的Contact,正确做法是先remove_hf("Contact")再插入,或者用contact_avp相关模块去覆盖,不要在完整头旁边再塞一个短头。
5.3 老设备的历史遗留坑和大小写问题
最后一个场景比较冷门。有次对接一个很老的SIP中继网关,它发出的消息里出现了o:头。我们第一反应这是Record-Route,但往下翻发现消息里根本没有Record-Route,只有这个o:后面跟的是一串Organization: xxx内容。翻文档才知道,RFC 2543时代o是Organization的缩写,RFC 3261改成了Record-Route,但这个老网关还在按旧规范发消息。
Kamailio遇到这种o:头时解析器会按Record-Route处理,而内容又是Organization的值,导致loose_route()把无意义的地址加到路由路径里,请求就被带偏了。处理办法是前置检测:在进入路由逻辑前,如果发现o:头的内容不像一个SIP URI,就先remove_hf("Record-Route")删掉它。
还有一个相关的小坑是大小写。某些设备发出的V:(大写)不是Via,如果你脚本里有if (has_via())之类的判断,大写V是能被正确解析为Via的?实际上Kamailio按标准实现,非法大小写可能不被识别为Via类型,所以遇到设备发大写V的情况,务必先归一化消息再进路由。
5.4 我现在的防空检查套路
踩过这几个坑之后,我给自己定了个规矩,每次新接入一个外部SIP对端,先在测试环境做三件事:
- 用
sngrep抓50条真实消息,统计里面出现的所有单字母头 - 在Kamailio路由脚本里加一段临时
xlog,打印$hdr(From)、$hdr(Contact)、$hdr(Route),确认伪变量取到的值跟预期一致 - 重点观察
o:和r:头的值是否真的是合法SIP URI,排除历史遗留坑
这套动作做完,才允许把对端切到生产流量。不能说百分之百杜绝问题,但确实能帮我在业务受损前发现大部分compact header相关的互通问题。
我个人在实际操作中的一个体会是:compact headers本身不是问题,问题出在"你以为你处理的是Route:,而实际消息里是r:"这种认知偏差上。Kamailio的解析器内核比大多数脚本作者想象得更包容,但在脚本层做文本级别判断时,这种包容性不一定能传到你的API调用上。所以最重要的不是背下所有缩写映射,而是养成"能用类型函数就不用文本匹配"的编码习惯,以及每次对接新对端时先抓包看看真实头名字。这比任何配置技巧都保值。
