在自建RADIUS认证计费系统的场景里,Session-Timeout是Access-Accept报文中一个常见属性,它告诉NAS设备该用户会话最多允许维持多少秒。但现实往往比协议复杂:用户设备异常掉电、强制关机、网线被拔,或者NAS自身重启,任何一种情况都可能导致NAS不再发送Accounting-Stop报文,RADIUS服务端的在线用户表里就会残留大量“僵尸会话”。这些幽灵条目不仅占用内存,还会影响并发计算、限速策略以及后续认证。很多团队选择定期跑SQL清理,但这样无法做到实时,而且还会误踢正常用户。要根本解决问题,必须实现一套主动检测机制:当用户在线时长超过Session-Timeout给定的上限,服务端就主动发送Disconnect-Request(DM)报文,要求NAS强制断开该用户的PPP或无线连接。

本文基于Ruby语言实现这个功能。Ruby的Socket标准库足够完成UDP通信,配合EventMachine或自带线程池可以轻松并发处理大量定时任务。下文从RADIUS报文格式、超时检测模型、DM报文构造三个方面逐步展开,最后给出一个可直接运行的完整脚本。
RADIUS协议中的超时相关属性与报文类型
RADIUS协议(RFC 2865/2866)定义了几种与时间相关的属性。最常见的是Session-Timeout(属性编号27),它在Access-Accept或Access-Challenge中出现,单位是秒。当NAS收到该属性后,会启动本地计时器,超时后自动断开用户。但NAS的计时器并不总是可靠——有些老旧设备固件实现不完整,超时后只断开链路层,但RADIUS在线表还保留;有些设备因为网络抖动导致计时器重置。另一种属性是Acct-Session-Time(属性编号46),它出现在Accounting-Request报文中,记录本次会话已经持续的秒数。如果RADIUS服务端定期收到Accounting-Interim-Update报文,就可以用这个属性来估算用户真实在线时长。但一旦用户异常离线,Interim报文停止发送,服务端就无法再获取时长信息。
要弥补NAS的缺陷,RADIUS提供了Disconnect-Request和Disconnect-ACK/NACK报文(RFC 3576),通常称为DM(Disconnect Message)或CoA(Change of Authorization)机制。Disconnect-Request由RADIUS服务端主动发给NAS,请求其立即终止指定用户的会话。NAS收到后需要校验报文的合法性,然后执行踢人操作,并回复Disconnect-ACK。Ruby服务端正是利用这个机制,在本地自行维护一个“超时表”,当某个用户在线时长超过设定阈值,就构造一个DM报文发给对应的NAS,完成强制下线。
构造DM报文的关键在于计算RADIUS报文头部的Authenticator字段以及可选的Message-Authenticator属性。DM报文属于请求类型,头部Code字段值为40(Disconnect-Request),ID随机,Length为整个报文长度,Authenticator为16字节随机数。报文必须携带User-Name(属性编号1)和NAS-IP-Address(属性编号4)或NAS-Identifier(属性编号32)来指定目标用户和NAS。同时强烈建议携带Message-Authenticator(属性编号80),用HMAC-MD5保护报文完整性。NAS校验时,用与RADIUS服务端共享的密钥(Secret)重新计算HMAC,不一致则丢弃。
Ruby实现超时检测与强制下线的核心逻辑
实现一个高可用的超时检测器,首先需要明确数据存储方案。最简单的方式是用Ruby的Hash配合线程安全的Mutex,或者使用Redis作为外部存储。本文采用内存Hash加一个后台线程周期扫描的结构,适合单机部署且在线用户量在十万级以下的场景。每个在线用户记录包含:用户名、NAS地址、会话ID、共享密钥、起始时间、超时阈值。当用户认证成功(Access-Accept发送成功)后,将该记录写入Hash;当收到Accounting-Stop时,移除记录。后台线程每间隔5秒扫描Hash,判断当前时间减去起始时间是否超过阈值,若超时则触发下线流程。
下线流程的核心是构造并发送RADIUS Disconnect-Request报文。Ruby代码需要完成以下步骤:生成16字节随机Authenticator;按属性格式(1字节Type + 1字节Length + Value)编码User-Name、NAS-IP-Address、Acct-Session-Id等属性;计算Message-Authenticator(HMAC-MD5,密钥为共享Secret,输入为Code+ID+Length+Request-Authenticator+所有属性字节);将属性追加到报文尾部;更新Length字段;通过UDP Socket发送到NAS的3799端口(CoA/DM专用端口,老设备可能使用1812)。发送后等待ACK,若超时未收到可重试2次。
下面是一段精简的Ruby代码,展示了构造DM报文的完整过程。代码中使用了digest/md5和openssl标准库,HMAC-MD5通过OpenSSL::HMAC.digest实现。注意属性长度字段是包含Type和Length两个字节的总长度,值不够4字节需要填充零字节。实际生产代码还需要处理网络异常、超时重传、NAS返回NACK的日志记录等。
require 'socket'
require 'digest/md5'
require 'openssl'
def build_disconnect_request(user_name, nas_ip, secret, session_id)
# RADIUS报文头部
code = 40 # Disconnect-Request
id = rand(255)
request_auth = Random.bytes(16) # 请求认证器
# 属性编码函数
encode_attr = lambda do |type, value|
data = value.to_s.force_encoding('BINARY')
len = 2 + data.bytesize
[type, len].pack('CC') + data
end
attrs = ''
# User-Name (属性1)
attrs << encode_attr.call(1, user_name)
# NAS-IP-Address (属性4),必须是4字节IPv4
ip_bytes = nas_ip.split('.').map(&:to_i).pack('C4')
attrs << encode_attr.call(4, ip_bytes)
# Acct-Session-Id (属性44),可选,很多NAS必须要求
if session_id
attrs << encode_attr.call(44, session_id)
end
# Message-Authenticator (属性80),先占位16字节
msg_auth_placeholder = "\x00" * 16
attrs << encode_attr.call(80, msg_auth_placeholder)
# 计算Message-Authenticator
header_without_auth = [code, id, 0].pack('CCn') + request_auth
hmac_input = header_without_auth + attrs
hmac = OpenSSL::HMAC.digest('MD5', secret, hmac_input)
# 替换占位符
attrs = attrs.sub(msg_auth_placeholder, hmac)
# 构造完整报文
length = 20 + attrs.bytesize # 20字节头部
packet = [code, id, length].pack('CCn') + request_auth + attrs
packet
end
def send_dm(nas_ip, packet, port=3799)
socket = UDPSocket.new
socket.connect(nas_ip, port)
socket.send(packet, 0)
# 简单等待ACK,实际需设置超时并解析
begin
socket.recvfrom(4096)
puts "Disconnect-ACK received from #{nas_ip}"
rescue IO::WaitReadable => e
puts "Timeout waiting for ACK: #{e.message}"
ensure
socket.close
end
end
# 使用示例
secret = 'my_radius_secret'
nas_ip = '192.168.1.1'
user = 'testuser'
session = 'abc123'
packet = build_disconnect_request(user, nas_ip, secret, session)
send_dm(nas_ip, packet)
生产环境中的注意事项与优化建议
第一个需要注意的点是UDP通信的不可靠性。RADIUS DM报文默认使用UDP,丢包率在公网或高负载内网中不可忽视。生产代码应该实现指数退避的重发机制,比如第一次发送后等待1秒,未收到ACK再重发,最多重试3次。同时,NAS可能返回Disconnect-NAK,表示拒绝下线请求,常见原因是用户不存在、会话ID不匹配或共享密钥错误。服务端必须解析NAK报文并记录详细日志,便于排查现场问题。
第二个关键是共享密钥的管理。RADIUS协议的安全完全依赖共享密钥(Secret)的保密性。如果所有NAS使用同一个Secret,一旦泄露,攻击者可以伪造DM报文踢掉任意用户。生产环境建议为每个NAS配置独立的Secret,并存储在加密的配置文件中。Ruby代码可以通过环境变量或专用配置中心读取,避免硬编码在源码里。同时,Message-Authenticator的计算必须包含所有属性,任何顺序或填充错误都会导致NAS校验失败,建议单元测试覆盖多种属性组合。
第三个优化点是并发处理能力。如果在线用户数达到数十万,单线程周期扫描会消耗大量CPU时间。推荐使用EventMachine或Celluloid之类的异步框架,或者将超时检测放到Redis的Sorted Set中,用用户到期时间作为score,后台线程只取出score小于当前时间的条目。这样能把扫描复杂度从O(N)降到O(log N + M),其中M是实际超时用户数。强制下线的UDP发送可以放入队列,由多个工作线程并行处理,避免阻塞主循环。
最后,要充分测试NAS设备的兼容性。不同厂商对RFC 3576的支持程度参差不齐,有些老款设备根本不响应3799端口,有些设备要求DM报文必须携带Calling-Station-Id或Framed-IP-Address才能定位会话。因此,上线前需要准备多台不同品牌的NAS进行联调,记录每种设备的特殊要求,并在代码里做条件分支。超时阈值通常设置为Session-Timeout的值加上一个合理的缓冲时间(比如5秒),避免因网络延迟导致提前踢人。