RADIUS协议在网络接入认证领域应用广泛,但很多刚接触这块的开发者只做到了认证通过,却忽略了会话生命周期管理。用户上线后如果没有任何约束,理论上可以永久占用连接资源,这对运营商网络、企业无线网络甚至小型ISP来说都是不可接受的。RADIUS协议本身提供了Session-Timeout属性来限制会话时长,但要在服务端真正实现超时强制下线,还需要理解属性的传递链路、NAS设备的配合机制,以及服务端如何主动发起断连请求。本文用Ruby来实现这套逻辑。

一、理解Session-Timeout属性的工作机制
RADIUS协议中与会话时长相关的属性主要有两个:Session-Timeout(属性编号27)和Acct-Session-Time(属性编号46)。两者的作用方向完全不同,混淆它们是实现超时下线时最常见的错误来源。
Session-Timeout是服务端在Access-Accept报文中下发给NAS(网络接入服务器)的,单位是秒,含义是「这个会话最多允许持续多久」。标准的NAS设备收到这个属性后,会在会话到达指定时长时主动断开用户连接,并向RADIUS服务器发送Accounting-Request Stop报文。也就是说,超时执行动作的主体是NAS,而不是RADIUS服务器本身。这是一个非常重要的认知:如果你只是简单地在Access-Accept里塞了Session-Timeout就以为万事大吉,那么当NAS不严格遵守该属性(某些廉价设备或配置错误的情况)时,用户就不会被踢下线。
Acct-Session-Time则是NAS在周期性发送的中间计费报文(Interim-Update)中上报给服务端的,表示会话已经持续了多少秒。服务端可以拿这个值与自己授权的时长做比对,一旦发现超时,就走主动下线流程。这两者结合起来,就形成了「NAS自律为主、服务端监督为辅」的双保险架构,下面给出的Ruby实现正是基于这个思路。
二、用Ruby搭建RADIUS服务端基础
Ruby生态里有ruby-radius、radius等库,但很多已经年久失修,实际项目中更常见的做法是基于UDP Socket自己解析RADIUS报文。RADIUS报文结构并不复杂:1字节Code、1字节Identifier、2字节Length、16字节Request Authenticator,后面跟一串Type-Length-Value格式的属性。自己写解析器的好处是可控性强,对Session-Timeout这类自定义处理逻辑的嵌入也更直接。
下面是一个简化版的报文打包函数,用于在认证成功后携带Session-Timeout属性返回:
require 'socket'
RADIUS_CODE_ACCESS_REQUEST = 1
RADIUS_CODE_ACCESS_ACCEPT = 2
ATTR_SESSION_TIMEOUT = 27
def build_access_accept(identifier, req_authenticator, secret, session_timeout)
attrs = [ATTR_SESSION_TIMEOUT].pack('C') +
[3 + 4].pack('C') +
[session_timeout].pack('N') # 属性值4字节,单位秒
code = RADIUS_CODE_ACCESS_ACCEPT.pack('C')
ident = identifier.pack('C')
length = (20 + attrs.bytesize)
header = code + ident + [length].pack('n') + req_authenticator
# Response Authenticator = MD5(header + attrs + secret + req_auth)
digest = Digest::MD5.digest(header + attrs + secret + req_authenticator)
packet = code + ident + [length].pack('n') + digest + attrs
packet
end
# 认证监听循环
socket = UDPSocket.new
socket.bind('0.0.0.0', 1812)
loop do
data, addr = socket.recvfrom(2048)
# 实际项目中此处应校验密码、解析User-Name等属性
# 假设认证通过,下发3600秒即1小时的会话超时
resp = build_access_accept(data[1].ord, data.bytes[4, 16], 'testing123', 3600)
socket.send(resp, 0, addr[3], addr[1])
end
这段代码中,session_timeout参数的值就是下发给NAS的超时秒数。实际业务里这个值通常不是硬编码的,而是根据用户套餐、账户余额动态计算的。比如按小时计费的用户可以给3600,包月用户则给到月底的剩余秒数。注意属性值的字节序是大端序,用pack('N')处理,写成小端会导致NAS解析出一个天文数字,会话要么永远不断,要么立刻被断,这是新手很容易踩的坑。
三、实现服务端主动强制下线
如前所述,Session-Timeout依赖NAS自觉。作为兜底手段,服务端需要一套监控加主动断连的机制。RFC 3576(后来被RFC 5176更新)定义了Change-of-Authorization和Disconnect消息,其中Disconnect-Request就是服务端发给NAS的「把这个用户踢下线」指令,Code值为40。
要发送Disconnect-Request,需要在报文中带上能够定位会话的属性,常用的是User-Name(编号1)、NAS-IP-Address(编号4)、Acct-Session-Id(编号44)。NAS收到后会校验并回复Disconnect-ACK(41)或Disconnect-NAK(42)。实现代码如下:
RADIUS_CODE_DISCONNECT_REQUEST = 40
def build_disconnect_request(identifier, secret, attrs_payload)
code = [RADIUS_CODE_DISCONNECT_REQUEST].pack('C')
ident = identifier.pack('C')
length = 20 + attrs_payload.bytesize
# Request Authenticator为16字节随机数
req_auth = SecureRandom.random_bytes(16)
header = code + ident + [length].pack('n') + req_auth
packet = header + attrs_payload
# Disconnect报文在末尾追加Message-Authenticator占位时需特殊处理
# 简化场景下直接发送,NAS端校验方式略有差异
packet
end
# 会话监控:发现超时会话后调用
def force_disconnect(user_name, session_id, nas_ip)
payload =
[1].pack('C') + [user_name.length + 2].pack('C') + user_name +
[44].pack('C') + [session_id.length + 2].pack('C') + session_id
req = build_disconnect_request(rand(256), 'testing123', payload)
sock = UDPSocket.new
sock.send(req, 0, nas_ip, 3799) # 3799是动态授权标准端口
reply = sock.recvfrom(2048)
reply[0][0].ord == 41 ? puts('下线成功') : puts('NAS拒绝了下线请求')
ensure
sock&.close
end
超时判定的调度可以用一个独立的监控线程配合会话表实现。服务端在收到Interim-Update计费报文时更新会话的last_seen和session_time,监控线程定期扫描,发现满足条件的会话就触发force_disconnect:
require 'time'
$active_sessions = {} # key为Acct-Session-Id,value为会话信息哈希
Thread.new do
loop do
now = Time.now.to_i
$active_sessions.each do |sid, sess|
if sess[:session_time] > sess[:authorized_timeout] ||
now - sess[:last_seen] > sess[:authorized_timeout] + 300
# 超时或失去响应超过5分钟宽限期,强制下线
force_disconnect(sess[:user_name], sid, sess[:nas_ip])
$active_sessions.delete(sid)
end
end
sleep 10
end
end
这里有两个细节值得展开。第一是判定条件不要只用Acct-Session-Time,还要加上「失去响应」的判断,因为NAS可能在中断计费上报后用户实际还在线,靠时间差推断更稳妥。第二是重试策略,UDP本身不可靠,Disconnect-Request发出后如果若干秒内没收到ACK,应重发两到三次,间隔采用指数退避,避免NAS短暂抖动导致误判失败。重发时Identifier必须递增,Request Authenticator要重新生成,否则部分NAS会当作重复报文丢弃。
四、部署中的常见问题与排查思路
实现逻辑写完只是第一步,实际部署时的坑往往更多。首先是NAS兼容性问题,并非所有设备都实现了RFC 5176的动态授权扩展,尤其是一些老款企业级AP和家用路由器固件。排查方法是先向3799端口发送Disconnect-Request观察是否有响应,完全没有回包基本可以判定不支持,此时只能退回依赖Session-Timeout的被动模式,并在运营层面接受超时不够精确的现实。
其次是共享密钥一致性。RADIUS的报文完整性依赖共享密钥做MD5运算,服务端与NAS两端任何一个字符不匹配,都会导致校验失败。表现出来的现象往往是认证能通(如果恰好密钥一致)但断连被NAK,或者干脆是NAK里带的Error-Cause属性值为401(不支持的扩展)。用tcpdump抓包结合报文里的Error-Cause值(501表示Session-Context not found等)可以快速定位是属性问题还是会话查找失败。
最后是并发与会话表的内存管理。Ruby的全局哈希在多线程下要注意加锁,或者直接用Mutex保护,也可以选用Sidekiq之类的后台任务框架把扫描逻辑从主线程剥离。会话表要设置兜底清理策略,比如超过24小时没有更新的僵尸记录直接移除,否则内存会随着时间缓慢泄漏。如果会话规模上了十万级,建议把会话状态落到Redis,监控线程只负责扫描和派发下线任务,认证与计费处理进程保持无状态,这样横向扩展也会轻松很多。
总结一下,完整的RADIUS会话超时强制下线方案是三层防线:Access-Accept下发Session-Timeout让NAS主动限时、服务端根据计费报文监控会话时长、通过Disconnect-Request做最终兜底。Ruby虽然不是网络编程的主流选择,但其清晰的语法配合原生Socket API,实现这套协议处理并不吃力,代码量可控且便于按业务需求定制。