RADIUS(Remote Authentication Dial-In User Service)是网络接入领域广泛使用的认证、授权、计费协议。运营商、企业Wi-Fi以及VPN网关通常都依赖RADIUS服务器来验证用户身份。不过,默认情况下RADIUS只负责回答用户是否合法,并不会记录该用户当前已经建立了多少条会话。这就带来了一个管理难题:一个合法账号可以被多个人同时使用,共享同一个套餐或权限,造成带宽、IP地址等资源被过度占用。要解决这个问题,需要在RADIUS服务器内部增加一个并发会话计数模块。本文将以Ruby语言为例,实现一个能够限制同一账号最大并发会话数的RADIUS服务器,所有核心逻辑均从零编写,不依赖重量级框架。

RADIUS协议与会话并发控制的实现难点
RADIUS报文基于UDP协议,默认端口为1812(认证)和1813(计费)。一个典型的接入流程包含两个阶段:首先是Access-Request,客户端提交用户名、加密后的密码以及NAS(网络接入服务器)信息,服务器验证通过后返回Access-Accept,拒绝则返回Access-Reject。其次是计费阶段,NAS会发送Accounting-Request报文,其中包含Acct-Status-Type属性,取值Start表示会话开始,Stop表示会话结束,Interim-Update则是周期性更新,用于报告用户仍然在线。
要实现并发限制,最直观的思路就是在Access-Request阶段检查当前该用户名对应的活跃会话数量。如果数量已经达到或超过预设阈值,则直接返回Access-Reject。然而难点在于如何准确维护这个计数。因为计费报文可能丢失、重复或延迟到达,单纯依赖Start和Stop进行加减很容易造成计数偏差。例如,NAS发送了Accounting-Start但服务器没有收到,计数就不会增加;如果Stop丢失,计数就会一直居高不下,导致用户永远无法重新登录。因此,设计时必须加入会话超时机制和幂等处理。
另一个难点是原子性。RADIUS服务器往往需要同时处理大量请求,多个Access-Request可能同时针对同一个用户名。如果使用读-判断-写这样的非原子操作,在并发场景下可能让多个请求同时通过检查,结果都获得了授权。使用Redis这样的外部存储时,可以利用其单线程命令特性或者Lua脚本来保证检查与更新是一个不可分割的操作。本文后续会展示具体做法。
基于Ruby搭建RADIUS服务器并实现并发检查
首先需要准备Ruby环境和一个可用的Redis服务。Ruby本身没有内置RADIUS报文解析库,但手动解析并不复杂。RADIUS数据包结构很简单:一个Code字段(1字节)、Identifier(1字节)、Length(2字节)、Authenticator(16字节),后面跟着任意数量的Attribute。每个Attribute由Type(1字节)、Length(1字节,包含Type和Length自身)、Value组成。Access-Request的Code为1,Access-Accept为2,Access-Reject为3,Accounting-Request为4,Accounting-Response为5。用户密码通常使用MD5加密,加密时需要共享密钥和请求中的Authenticator。
下面这段Ruby代码创建了一个监听1812端口的UDP套接字,并解析Access-Request中的用户名和密码(假设密码以明文方式存储在配置中,实际生产环境应接入LDAP或数据库)。代码中使用bind和recvfrom处理请求,解析属性并调用check_concurrency方法。
require 'socket'
require 'redis'
require 'digest/md5'
SHARED_SECRET = 'my_radius_secret'
MAX_CONCURRENT = 2
REDIS = Redis.new(host: '127.0.0.1', port: 6379)
def parse_radius_packet(data)
code, id, length = data.unpack('CCn')
authenticator = data[4,16]
attrs = {}
offset = 20
while offset < length
type, attr_len = data[offset,2].unpack('CC')
value = data[offset+2, attr_len-2]
attrs[type] = value
offset += attr_len
end
[code, id, authenticator, attrs]
end
def decode_password(enc_pwd, authenticator, secret)
padded = enc_pwd.ljust(16, "\0")
(0...padded.length).step(16) do |i|
chunk = padded[i,16]
chunk = chunk.bytes.zip(secret.bytes.cycle).map { |a,b| a ^ b }.pack('C*')
if i == 0
chunk = chunk.bytes.zip(authenticator.bytes).map { |a,b| a ^ b }.pack('C*')
end
return chunk.delete("\0") if chunk == chunk.delete("\0")
end
''
end
def check_concurrency(username)
key = "radius:session:#{username}"
current = REDIS.scard(key)
if current >= MAX_CONCURRENT
false
else
true
end
end
server = UDPSocket.new
server.bind('0.0.0.0', 1812)
loop do
data, addr = server.recvfrom(4096)
code, id, authenticator, attrs = parse_radius_packet(data[0])
if code == 1
username = attrs[1].to_s
enc_pwd = attrs[2].to_s
password = decode_password(enc_pwd, authenticator, SHARED_SECRET)
# 这里假设用户名和密码相同则认证成功
if password == username
if check_concurrency(username)
# 构建Access-Accept
resp = [2, id, 20].pack('CCn') + authenticator
server.send(resp, 0, addr[3], addr[1])
else
# 构建Access-Reject,可以附加Reply-Message属性
reply_attr = [18, 0].pack('CC') + 'Concurrent session limit reached'
resp = [3, id, 20 + reply_attr.length].pack('CCn') + authenticator + reply_attr
server.send(resp, 0, addr[3], addr[1])
end
else
resp = [3, id, 20].pack('CCn') + authenticator
server.send(resp, 0, addr[3], addr[1])
end
end
end
上面的代码只是最小可用版本,它通过Redis的集合(Set)来记录活跃会话。当用户认证成功时,需要把该会话的唯一标识(例如NAS的IP加上端口,或者一个随机生成的Session-ID)加入集合中,这个操作应该由后续的Accounting-Start报文触发。在Access-Request阶段只做检查,不修改集合。这样的分离设计能避免在认证阶段就锁定会话,因为认证通过并不代表用户一定会上线,有些NAS会发送Access-Request但最终未建立连接。更好的做法是等待Accounting-Start,那里才代表真正的会话开始。
为了在Ruby中处理计费报文,需要扩展上面的UDP循环,同时监听1813端口,或者复用同一个套接字。完整的服务器通常会同时监听两个端口。计费报文中的Acct-Status-Type属性(类型40)取值不同:1表示Start,2表示Stop,3表示Interim-Update。在Start时,向Redis集合中添加一个唯一标识,例如使用Time.now.to_i.to_s + rand(1000).to_s,同时设置该成员的过期时间(如30分钟),这样即使Stop丢失,过期后也会自动移除,避免计数永久占用。在Stop时,从集合中移除对应的成员;对于Interim-Update,可以刷新过期时间。
下面给出处理计费报文的代码框架,与主循环合并后的效果。注意,RADIUS的计费响应相对简单,只需要回显Code=5即可。同时,为了防止伪造计费报文,需要验证报文中的Authenticator是否正确,这里暂时省略以保持示例简洁。
# 在之前的loop中增加对code==4的处理
if code == 4
acct_status = attrs[40].to_s.unpack('N')[0] # 4字节整数
username = attrs[1].to_s
session_id = attrs[44].to_s # Acct-Session-Id
key = "radius:session:#{username}"
case acct_status
when 1 # Start
member = session_id.empty? ? "sid_#{SecureRandom.hex(8)}" : session_id
REDIS.sadd(key, member)
REDIS.expire(key, 1800) # 30分钟过期,防止永久占用
when 2 # Stop
REDIS.srem(key, session_id)
when 3 # Interim-Update
REDIS.expire(key, 1800)
end
resp = [5, id, 20].pack('CCn') + authenticator
server.send(resp, 0, addr[3], addr[1])
end
注意,上述代码使用了SecureRandom,需要先require 'securerandom'。另外,Redis的expire作用于整个集合,如果多个成员都设置了过期时间但使用的是同一个key,那么每次expire会重置整个集合的过期时间,而不是针对单个成员。更好的做法是将每个会话存储为独立的键,例如radius:session:username:sid,并对其设置TTL,然后通过扫描前缀键来统计数量。不过为了脚本的简洁性,本例使用集合加整体过期,对于大多数场景已经足够,因为只要集合中还有活跃成员,就不应该过期;当最后一个成员被移除后,可以手动删除集合,或者让其自然过期。实际生产中建议使用有序集合或哈希配合单独的键TTL,获得更精确的控制。
并发计数的一致性与高可用优化
前文实现的check_concurrency方法存在一个明显的竞态条件:它先执行SCARD获取当前数量,然后判断是否小于上限,最后才允许用户登录。如果在高并发下两个请求同时到达,都读到当前数量为1(上限为2),那么两个请求都会通过,最终并发数可能达到3。要解决这个问题,必须把检查和后续的潜在增加操作放在一个原子上下文中。由于我们的设计是认证阶段只检查,实际增加发生在计费Start阶段,所以可以在认证阶段使用Redis的WATCH加事务,或者在计费Start阶段使用原子操作。但是更好的方法是使用Lua脚本,在Redis服务端一次执行完整个检查逻辑。
下面展示一个原子化的并发检查Lua脚本,它在认证阶段被调用,但不会修改任何数据,只是返回当前计数是否超限。如果超限,服务器直接拒绝。为了在Ruby中执行Lua脚本,可以使用Redis的eval命令。脚本中的KEYS[1]是会话集合的键,ARGV[1]是最大并发数。
local current = redis.call('SCARD', KEYS[1])
local limit = tonumber(ARGV[1])
if current < limit then
return 1
else
return 0
end
在Ruby中调用如下:
allowed = REDIS.eval(
"local current = redis.call('SCARD', KEYS[1]); local limit = tonumber(ARGV[1]); if current < limit then return 1 else return 0 end",
keys: [key],
argv: [MAX_CONCURRENT]
)
如果allowed等于1则放行,否则拒绝。这样做避免了非原子读造成的概率性超卖。但要注意,真正的计数仍然依赖于计费报文,如果恶意客户端只发送Access-Request而不发送Accounting-Start,那么计数不会增加,依旧无法完全防止共享。可行的方案是在Access-Accept中附带一个定时器,一段时间内若未收到Start则自动释放配额,但这会增加复杂度。对于多数内部接入场景,NAS设备会可靠地发送计费报文,因此上述方案足以满足需求。
性能方面,单线程的UDP循环在高并发下可能成为瓶颈。Ruby的UDPSocket默认是阻塞的,每次只能处理一个请求。可以使用IO.select或者引入EventMachine、Celluloid等异步框架。如果并发量不高(每秒几十个请求),简单的循环已经足够。另外,Redis的连接池也需要考虑,每个请求都创建新连接会浪费时间,应使用连接池复用。本文示例为了简洁直接使用全局Redis实例,实际部署时可改为ConnectionPool。
最后,需要对实现进行充分测试。可以使用radclient工具模拟RADIUS请求,或者编写Ruby测试脚本。重点验证:同一用户名在并发数未达上限时能正常认证,达到上限后被拒绝,计费Start和Stop能正确增减计数,Stop丢失后会话在一段时间后自动释放。只有经过这些场景的验证,才能确保并发限制功能在生产环境中稳定可靠。