☰
SIP注册故障排查指南:从401认证到NAT穿透一网打尽
2026/10/8 9:20:18 网站建设 项目流程

简介:面向SIP协议与VoIP开发者,围绕eXosip2-3.6.0库实现客户端注册到服务器的完整代码包,重点演示MD5摘要认证过程。包内共8个文件,以C++源程序为主,包含SipServer与SipClient两个模块,并配套Visual Studio工程文件(dsp/dsw)与7z依赖库(eXosip2、md5),免去二次收集依赖的繁琐;整体仅222KB,轻量易部署。已有1567人学习。通过示例代码可直观理解REGISTER请求构造、401质询响应、MD5凭据设置及注册有效期刷新机制,掌握eXosip初始化、发送请求、处理响应与重试判断的完整链路;可直接将工程框架复用到SIP客户端开发中,为后续实现可靠VoIP音视频通信奠定基础。

1. 注册到服务器:为什么看着简单却在认证阶段反复翻车

被凌晨两点的电话叫醒,十有八九是监控平台的SIP接入出问题了。设备侧SIP注册状态刚亮绿灯,平台侧列表里却看不到设备上线;或者上线几分钟又掉线,来回拉扯。所谓SIP注册流程,就是终端(话机、视频设备、软交换)通过REGISTER方法向SIP服务器声明自己的地址和URI,最终把自己注册到服务器上并维持在线状态。流程本身不长,但认证方式、过期策略、NAT穿透三件事搅在一起,让看似简单的注册成了SIP接入链路上翻车率最高的环节。这篇笔记按我自己的排错顺序展开:先拆注册信令,再调服务器参数,然后讲海康平台这类严格校验方的对接配置,最后落到抓包验证。适合正在做VoIP设备接入、监控平台联网对接的从业者,新手能照着走,熟手能核对参数边界。

2. 先拆注册流程:REGISTER信令的生命周期与认证头域

2.1 注册不是“发一个包”,而是一轮挑战-应答握手

很多刚上手的人以为SIP注册就是发一条UDP消息过去,服务器回200就完事。实际抓包会看到,几乎所有服务器都会先回401或407,迫使终端重新发第二封REGISTER。完整的注册序列长这样:

终端 -> 服务器: REGISTER sip:sip.example.com SIP/2.0 (不带任何认证信息, Expires: 3600, Contact: 终端实际地址) 服务器 -> 终端: 401 Unauthorized WWW-Authenticate: Digest realm="sip.example.com", nonce="Nc1aB2", algorithm=MD5, qop="auth" 终端 -> 服务器: REGISTER sip:sip.example.com SIP/2.0 Authorization: Digest username="1001", realm="sip.example.com", nonce="Nc1aB2", uri="sip:sip.example.com", response="abcdef1234567890", algorithm=MD5, qop=auth, nc=00000001, cnonce="xyz" CSeq: 2 REGISTER 服务器 -> 终端: 200 OK Expires: 3600

为什么第一封REGISTER不带认证也要发?因为Digest认证是“挑战-应答”机制,服务器需要先给一个nonce。nonce是服务器生成的随机串,带时效,防重放。如果服务器判断你提交的nonce、username、response都对,才会回200并写入位置信息。有人嫌这一来一回浪费一次UDP往返,想直接把认证信息塞进第一封REGISTER,绝大多数服务器不会接受,因为nonce还没生成。

另一个很多人分不清的点是401和407的区别。401表示对请求资源的用户身份认证失败,通常由注册服务器或业务服务器回;407常见于经过SIP中继时的认证要求,处理方式几乎一样,只是头域从WWW-Authenticate换成Proxy-Authenticate,Authorization换成Proxy-Authorization。调试时看到407别慌,先看响应头里带的是哪种Challenge。

还有一个细节我踩过坑:第二封REGISTER的CSeq必须加1(1 REGISTER 变 2 REGISTER),Call-ID保持和第一封一致,否则服务器会认为这是两个无关事务,照样不会给你200。很多自研终端在循环401的时候,抓包一看CSeq永远都是1,这就是典型的客户端自己把自己卡死的案例。

2.2 Digest认证的计算链与三个最容易错的头域

第二封REGISTER能不能通过校验,核心在Authorization头里的response。它的计算链如下:

HA1 = MD5(username:realm:password) HA2 = MD5(method:uri) response = MD5(HA1:nonce:HA2) // 无 qop response = MD5(HA1:nonce:nc:cnonce:qop:HA2) // qop=auth

这不是随便算的,任何一个输入和服务器端不一致,结果就完全不同。我排错时优先核对三个地方。

第一个是realm和nonce,必须原样取服务器401响应里的值,大小写都不能变。有些平台会在WWW-Authenticate里带realm="34020000002000000001",这个realm可能是平台ID而不是域名,你按自己配置的域名去算,response必错,然后就是无休止的401循环。第二个是uri,它必须等于REGISTER请求行里的Request-URI,不能填From头里的URI。很多代码库写死了uri=username@domain,一旦服务器域和From域不一致就会抓狂。第三个是method,必须是大写REGISTER,不能因为收到401就把method改成别的。

再就是qop。服务器返回的Challenge里如果带了qop="auth",你必须按带qop的公式算,并额外提供nc和cnonce两个参数,否则服务器报400。反过来,如果服务器没带qop而你非要加,很多老平台也认,但少数平台会直接拒。这里没有银弹,我的习惯是先查服务器返回的Challenge里有没有qop,有就按有算,没有就按没有算,而不是写死。海康这类监控平台对接时,多数用无qop的MD5,但个别版本会带qop=auth,所以最好做成可配置。

2.3 Expires、Contact与在线状态的维护

注册并不是一次性的动作,服务器记录的位置信息带有效期,过期后终端必须重新注册。有效期通过Expires头或Contact头里的expires参数控制,常见值是300、600、3600。服务器收到REGISTER后会检查这个值和自己的策略,如果小于min_expires会回423响应并告诉你Min-Expires,如果大于max_expires会被强制截断。

Contact头里填的是终端实际可被呼叫的地址,服务器后续的INVITE、OPTIONS都会发到这个地址。在纯内网环境里没问题,但设备一旦在NAT后面,Contact里往往是内网地址,服务器直接把呼叫请求发过去是到不了的。常见做法是服务器用Via头里的received参数改写Contact,记录请求实际来源。除此之外,设备还要定期发OPTIONS或轻量REGISTER做保活,维持NAT映射。判断设备是否在线,不能只看有没有注册成功,还要看有没有在周期内刷新。服务器一般把最近一次收到REGISTER或OPTIONS的时间作为在线判据,超过1.5到2个注册周期就算掉线。

3. 服务器侧注册配置:把认证参数、过期策略和心跳窗口一次调对

3.1 用Kamailio做注册服务器:最小可用的认证与保存配置

我平时搭建注册服务器,首选Kamailio。它是纯SIP路由引擎,把认证和位置保存解耦得很清楚,出问题也好定位。最小可用的注册配置,围绕auth、auth_db、registrar三个模块展开:

modparam("auth_db", "db_url", "mysql://sipuser:sippass@localhost/sip") modparam("auth_db", "password_column", "ha1") modparam("auth", "auth_checks_no_qop", 1) modparam("auth", "nonce_expire", 300) modparam("registrar", "default_expires", 3600) modparam("registrar", "min_expires", 60) modparam("registrar", "max_expires", 7200) modparam("registrar", "max_contacts", 5)

带认证的REGISTER路由逻辑一般长这样:

route[REGISTRAR] { if (!is_method("REGISTER")) return; if (!auth_check("$fd", "subscriber", "0")) { auth_challenge("$fd", "0"); exit; } if (!save("location")) return; }

auth_check里的第三个参数0表示普通文本数据库存储,如果你用HA1预计算列,改成1也行,但auth_db模块的password_column要对应。save("location")是真正把Contact写进位置表的一步,失败时多半是表结构不对或权限不足。

这里有个关键认知:nonce_expire=300表示nonce只活5分钟,设备如果注册周期是3600秒,刷新时nonce一定过期了,于是服务器再回401,终端重新完整认证一遍。这不是故障,是防重放机制,但从平台侧看会表现为每隔一小时闪断一次。如果不想让它太频繁,把nonce_expire调到略大于最大Expires,比如7500秒,代价是nonce被重放的风险窗口稍微变大。内网环境我没那么在意,公网环境建议维持较小的nonce_expire。

min_expires=60是另一个容易引发误会的参数。海康这类监控设备经常把注册周期写成30秒,你这里如果设了60秒,设备第一个REGISTER就会被423拒绝。正确做法是把min_expires调低到10秒左右,或者去平台侧把注册周期改大,别让服务器和平台在过期策略上打架。

3.2 location表:注册数据的落库与过期清理

Kamailio的registrar模块会把注册信息写进location表。表结构一般按官方模板建,核心字段就这些:

CREATE TABLE location ( username VARCHAR(64) NOT NULL, contact VARCHAR(255) NOT NULL, expires INT NOT NULL, q REAL DEFAULT 1.0, callid VARCHAR(255), user_agent VARCHAR(255), last_modified DATETIME, PRIMARY KEY (username, contact) );

注意主键是(username, contact)而不是username,因为一个用户可能有多条注册:分机、手机App、桌面话机各一个Contact。如果你把username设成主键,同一个用户第二次注册会把第一条覆盖掉,这在通话场景会出大问题。

password列最好存HA1而不是明文密码。auth_db的password_column=ha1表示认证时直接用数据库里的ha1参与哈希计算,即使数据库被拖走,攻击者拿到的是哈希而不是明文。用户名和明文密码的对应表一旦泄露,内网账号就全完了。

过期清理也不能省。Kamailio本身会在收到新REGISTER时处理过期Contact,但长期不活跃的用户记录会一直占着max_contacts。我一般用cron每5分钟跑一次清理SQL:

DELETE FROM location WHERE expires < UNIX_TIMESTAMP();

同时把appearance表的在线标志同步清掉。如果这块没做,你会看到用户明明已经断网一周,位置表里还留着记录,平台侧依然显示在线。

3.3 Asterisk和FreeSWITCH的注册参数对照

不是每个团队都用Kamailio,很多场景是把Asterisk或FreeSWITCH当作注册服务器兼媒体服务器。参数名不同,但功能一一对应:

功能Kamailio参数Asterisk pjsip参数建议值
认证来源auth_dbauth_type=userpass无
nonce有效期nonce_expireauth_nonce_lifetime3600或更高
最短注册周期registrar min_expires无直接参数10-60
最长注册周期registrar max_expiresmax_expires7200
心跳探测dispatcher/optionsqualify_frequency60

Asterisk的auth_nonce_lifetime默认300秒,如果设备注册周期是3600秒,同样会发生每小时闪断一次。我习惯把它调成比max_expires大一些。

另外,注册和业务分离是我一直推荐的做法。注册服务器只负责认证和位置表,媒体与呼叫交给FreeSWITCH或Asterisk。这样做的好处是,注册风暴或认证攻击不会直接压垮媒体服务器,两边可以独立扩容。小规模部署一台机器跑两个进程也没问题,但至少逻辑上要分开。

4. 对接海康这类三方平台:从平台的严格校验反推你的注册配置

4.1 平台侧SIP注册和通用话机注册的差异

海康平台SIP对接配置,本质是让设备通过REGISTER注册到平台这个SIP服务器上。和普通话机注册最大的差异是:平台不仅管注册,还管后续的INVITE、心跳、媒体协商;设备注册用的ID不是电话号码,而是平台分配的20位编码。这个ID会出现在From、To、Contact等多个头域里,任何一个写错都可能鉴权失败。

我抓过一套典型的平台注册报文,第一封REGISTER长这样:

REGISTER sip:34020000002000000001@192.168.1.100:5060 SIP/2.0 Via: SIP/2.0/UDP 192.168.1.64:5060;rport;branch=z9hG4bK4d3f2a From: <sip:34020000001320000001@34020000002000000001>;tag=0987 To: <sip:34020000001320000001@34020000002000000001> Call-ID: 7a1b2c3d4e5f@192.168.1.64 CSeq: 1 REGISTER Contact: <sip:34020000001320000001@192.168.1.64:5060> Expires: 3600 User-Agent: IP Camera Content-Length: 0

注意这里有个容易搞反的地方:REGISTER请求行里的域是平台的SIP服务器ID,From和To里@后面的也是平台域,不是设备自己的IP。Contact里才是设备自己的实际地址。有些自研设备把From里的域填成设备IP,平台直接不认。

第二封REGISTER带认证头,格式和通用SIP一样,Authorization里的username用平台分配的20位编码,realm取服务器返回的值。海康平台多数用MD5、无qop的摘要算法,个别版本带qop。按第2章的方法算response即可。

4.2 对接前先向平台方要齐的配置清单

不要等上线了才去翻平台手册,对接前把下面几项问清楚,能省掉一大半调试时间:

  • 平台SIP服务器地址和端口,对应REGISTER请求行的域。
  • 平台返回的realm取值,很多平台直接用平台ID,别猜。
  • 设备接入编码和认证密码,密码区分大小写,别在配置里加空格。
  • 注册周期建议值,平台一般接受300或3600,海康设备默认可能偏小。
  • 是否提供公网映射,如果设备在NAT后面,平台是否支持rport和received改写。
  • 摘要算法细节,是MD5还是MD5-sess,是否强制qop。

拿到这些信息后再写REGISTER,事半功倍。如果平台方自己也说不清,就抓包看401响应里的WWW-Authenticate头,上面写着realm、algorithm、qop,这就是最权威的答案。

4.3 平台侧常见校验:UA头、Header顺序、摘要算法

监控平台对接时,比通用SIP服务器更容易遇到平台侧的隐性校验。第一个是User-Agent,有些平台会按UA头判定设备型号,如果你用通用UA(比如PJSIP),平台可能回复403或直接丢弃请求。对接前先确认平台允许的UA列表,或者直接抓包看平台上已经注册成功的设备用的什么UA。第二个是Header顺序。协议上顺序无关,但有些平台实现得很粗糙,解析器按固定偏移截取头域。我遇到过把Via放在第二位就解析失败的情况,解决方式是先抓一个平台自带设备能成功的报文,完全照抄头域顺序。

第三个是摘要算法。个别平台在WWW-Authenticate里不声明algorithm,默认MD5;有的声明了但实际校验时又不用,这就是纯玄学了。这时候唯一的办法是:把两个REGISTER之间的所有头域逐行抄下来,在本地用Digest计算器重算response,比对不通过时,试着去掉qop重算,再试着改realm大小写,一个一个排除。至少我遇过的平台,最后都收敛到realm或qop这两个问题上。

4.4 平台注册成功后,还需要心跳维持在线状态

平台侧在收到REGISTER之后会周期性查看设备是否在线。很多监控平台规定,设备要每隔30到60秒发一次心跳(常见是国标联网协议里的MESSAGE消息,也可能就是OPTIONS),平台收到后更新在线状态。如果你只注册一次就等着,平台会在一个周期后把设备标记为离线。这个心跳和SIP注册的刷新不是一回事,心跳消息可能是平台私有格式,也可能就是OPTIONS。对接时把心跳间隔设为平台要求值的二分之一,留出重试余量。海康平台对心跳间隔的要求比较严格,超过时限就是在线刷新失败,日志里会直接告诉你超时秒数。

5. 注册到服务器的避坑实录:四个高频故障的排查路径

5.1 注册成功却反复重连:Expires与min_expires打架

现象:设备侧显示注册成功,但抓包看到REGISTER和200 OK的序列每隔几秒就重复一次,平台列表里设备状态一直在上下跳动。

原因:设备把注册周期设成了一个很小的值(有些设备默认10秒),而服务器设置了min_expires=60,设备收到423响应后又立刻重发;或者设备根本不看423里的Min-Expires,依然按自己的小周期刷。还有一种情况是,设备在自己的Expires到期前没收到200,就急着发下一个REGISTER,两个事务重叠,服务器只处理了其中一个。

解决:把服务器min_expires调低到10秒,同时在设备侧把注册周期改到300或600秒。如果是自研设备,把状态机改成收到200后再进入刷新等待,不要用绝对时间唤醒。

5.2 401循环:response算不对,先查realm、uri和qop

现象:第二封REGISTER已经带了Authorization头,服务器还是回401,反复多次。

原因:response计算里的输入和服务器对不上。最常见的是realm没取服务器返回的值,而是用自己配置文件里的域名;其次是uri填错;第三是qop算法没对齐。

解决:先在抓包里把401响应的WWW-Authenticate头完整抄出来,取realm、nonce、algorithm、qop。然后找一个独立的Digest计算工具,填入和抓包完全相同的值,算出response。如果算出来和终端发的不一致,问题在终端;如果算出来一样服务器还拒,再查服务器侧密码库里的密码和realm是否和终端一致。

5.3 公网服务器收不到REGISTER响应:NAT回程路由问题

现象:设备在内网,服务器在公网。REGISTER消息发出去了,抓包能看到出站请求,但设备永远收不到401或200。

原因:这是NAT和SIP的老问题。REGISTER从内网设备一个随机端口出去,NAT映射表建立后,服务器回包如果发到另一个端口,内网设备就收不到。更麻烦的是SIP的Contact头里写的是内网IP,服务器按Contact回包,直接把包发到内网地址去了。

解决:服务器开启rport和received处理。Kamailio里用nathelper模块,对REGISTER的Via头做处理,回包发到实际收到请求的源地址。同时让设备每隔30秒发一次OPTIONS或轻量REGISTER,维持NAT映射的老化时间。如果设备在运营商级NAT后面,还要考虑是否配置端口映射把5060映射到设备。

5.4 假在线:注册超时了服务器还留着位置记录

现象:设备被断电或断网,平台侧仍然显示在线,呼叫设备失败后才报离线。

原因:设备没有发BYE或REGISTER注销,服务器只能等Expires超时才能清位置记录。如果注册周期是3600秒,平台要等1小时才发现设备真死了。很多监控平台对在线的定义是注册成功那一刻,之后全靠自己的心跳探测来修正,探测间隔设得太长就出现假在线。

解决:服务器对每个注册用户做主动探测,周期设为注册Expires的五分之一。比如注册周期300秒,则每60秒发一次OPTIONS,连续3次没回就强制把这个用户的注册记录置为过期,并推送离线事件给平台。这样假在线的窗口最多只有180秒。我自己的服务器上还把主动探测的日志单开了一个文件,这样排查谁掉线了不用再翻全量抓包。

5.5 注册、掉线、再注册的快速排查清单

遇到注册类问题,我一般按这个顺序走:

  1. tcpdump抓包:tcpdump -i eth0 -s 0 -w reg.pcap -vvv port 5060,保留完整信令。
  2. 用sngrep或Wireshark看REGISTER、401、REGISTER、200四个关键包的时间戳和头域。
  3. 检查服务器auth模块日志,确认是密码错误还是nonce过期还是找不到用户。
  4. 对比终端和设备两边的时间,nonce有效期判断依赖时间一致。
  5. 用sipp模拟一个标准REGISTER发给服务器,确认服务器对标准终端的响应,排除终端协议栈问题。

6. 用抓包与脚本把注册链路做成可回归的验证套路

6.1 拨测脚本:验证REGISTER往返和鉴权算法

每次改服务器配置后,我都习惯用一个拨测脚本快速验证注册链路是否正常,而不是手动发消息。核心逻辑是:发第一封REGISTER,解析401里的nonce,计算response,再发第二封REGISTER,打印最终状态码。一个精简版长这样:

import socket, hashlib, re def digest(username, realm, password, nonce, uri, method="REGISTER"): ha1 = hashlib.md5(f"{username}:{realm}:{password}".encode()).hexdigest() ha2 = hashlib.md5(f"{method}:{uri}".encode()).hexdigest() return hashlib.md5(f"{ha1}:{nonce}:{ha2}".encode()).hexdigest() def build_register(uri, username, branch, cseq, auth_header=""): return (f"REGISTER {uri} SIP/2.0\r\n" f"Via: SIP/2.0/UDP 127.0.0.1:5060;branch={branch}\r\n" f"From: <sip:{username}@127.0.0.1>;tag=1\r\n" f"To: <sip:{username}@127.0.0.1>\r\n" f"Call-ID: test01\r\n" f"CSeq: {cseq} REGISTER\r\n" f"Contact: <sip:{username}@127.0.0.1:5060>\r\n" f"Expires: 300\r\n" f"{auth_header}" f"Content-Length: 0\r\n\r\n") def reg(ip, port, username, password, uri): sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) sock.settimeout(3) msg1 = build_register(uri, username, "z9hG4bK-1", 1) sock.sendto(msg1.encode(), (ip, port)) resp = sock.recv(4096).decode() nonce = re.search(r'nonce="([^"]+)"', resp) if not nonce: return resp.split("\r\n")[0] nonce = nonce.group(1) realm = re.search(r'realm="([^"]+)"', resp).group(1) response = digest(username, realm, password, nonce, uri) auth = (f"Authorization: Digest username=\"{username}\", realm=\"{realm}\", " f"nonce=\"{nonce}\", uri=\"{uri}\", response=\"{response}\"\r\n") msg2 = build_register(uri, username, "z9hG4bK-2", 2, auth) sock.sendto(msg2.encode(), (ip, port)) resp2 = sock.recv(4096).decode() return resp2.split("\r\n")[0] print(reg("192.168.1.100", 5060, "34020000001320000001", "secret", "sip:34020000002000000001@192.168.1.100"))

脚本里build_register封装了头域模板,每次拼接一个CSeq递增的新REGISTER;digest函数实现了无qop的MD5摘要计算,如果你的平台强制qop=auth,在函数里多加nc、cnonce两个参数即可。参数ip、port是服务器地址和端口,username用平台分配的用户编码,password是认证密码,uri填写REGISTER请求行里的域。这个脚本只依赖标准库,内网任何一台Linux机器都能跑。如果返回200 OK,说明认证算法、realm、密码都对;如果返回401,把脚本里的uri或realm打印出来,和抓包对比。

6.2 把抓包基线存成习惯

我现在的习惯是:每次改完服务器或平台对接参数,先跑一遍这个拨测脚本,同时tcpdump抓包留存,确认注册、刷新、注销三个动作都正常才收工。抓包文件命名带上日期和改动点,比如reg-20250110-nonce-lifetime.pcap,方便两周后出问题时有历史可查。SIP注册链路看起来简单,但认证算法、过期策略、NAT三块叠加时,没有基线就只能靠玄学。把每一次改动固化成一个可重复的验证动作,比记住一堆参数更有用。希望帮到你。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询