H.323网守的核心职责可以拆成两条独立的通道:RAS通道跑在UDP 1719端口,用来完成发现、注册、准入和状态上报;呼叫信令通道跑在TCP 1720端口,用来承载H.225.0的Q.931消息。Ruby标准库中没有现成的H.323协议栈,但我们可以用UDPSocket和TCPServer手动搭出这两条通道,把精力集中在状态管理和消息路由上。对于完整PER编码,文章采用简化解析方案:只提取消息类型字节,其余字段保留原始编码或以最小结构处理,这样既能看清网守逻辑,又不必陷入ASN.1的细节。

RAS消息从哪来、到哪去
RAS是H.323体系中最早建立的信令通道之一。终端上线时先发GRQ询问网守地址,收到GCF后再发RRQ注册自己的别名和呼叫信令地址。进入呼叫前,终端会发ARQ申请带宽和准入;呼叫结束后通过DRQ释放。这些都依赖UDP,超时重传由终端侧处理。
用Ruby监听RAS端口很简单,UDPSocket可以绑定到0.0.0.0:1719。真正的难点在于PER解码。RAS消息体不是文本,也不是常见的TLV,而是采用ASN.1 PER编码,字段顺序、可选标记、扩展位都会影响字节布局。如果直接手写完整解析器,代码会迅速膨胀。因此在简易网守中,通常只解析首字节的CHOICE索引,再根据消息类型决定后续动作。首字节低5位对应RasMessage根选项,例如0是GRQ、3是RRQ、9是ARQ、15是DRQ。这个假设并不覆盖所有情况,但对学习和小范围实验足够。
require 'socket'
RAS_PORT = 1719
MSG_GRQ = 0
MSG_RRQ = 3
MSG_ARQ = 9
MSG_DRQ = 15
server = UDPSocket.new
server.bind('0.0.0.0', RAS_PORT)
puts "H.323 gatekeeper RAS listening on #{RAS_PORT}/udp"
loop do
data, addr = server.recvfrom(2048)
next if data.nil? || data.empty?
# 简化PER解析:首字节低5位作为CHOICE索引
msg_type = data[0].ord & 0x1f
peer = "#{addr[3]}:#{addr[1]}"
case msg_type
when MSG_GRQ
puts "[RAS] GRQ from #{peer}"
server.send([0x01].pack('C'), 0, addr[3], addr[1])
when MSG_RRQ
puts "[RAS] RRQ from #{peer}"
server.send([0x04].pack('C'), 0, addr[3], addr[1])
when MSG_ARQ
puts "[RAS] ARQ from #{peer}"
server.send([0x0a].pack('C'), 0, addr[3], addr[1])
when MSG_DRQ
puts "[RAS] DRQ from #{peer}"
server.send([0x10].pack('C'), 0, addr[3], addr[1])
else
puts "[RAS] unknown message type #{msg_type} from #{peer}"
end
end
上面这个例子只是回送一个字节表示确认,并不能作为真实RCF或ACF使用。真实网守需要返回完整的PER编码消息,里面包含终端标识、别名地址、支持的协议版本以及呼叫信令地址等字段。一个可变通的做法是预先生成几种响应模板,例如用Wireshark抓包获取标准RCF的字节内容,然后在Ruby里按终端信息替换其中的IP和端口字段。虽然不如动态编码灵活,但足以支撑测试。
如果项目需要长期维护,建议把PER编码部分独立成模块,或者使用Ruby的ffi调用C语言H.323库,避免每次修改字段都手动计算偏移。对本文来说,重点是消息类型分派和网守状态管理,所以只保留最小解析逻辑。
终端状态表与准入判断
网守之所以能管理呼叫,是因为它维护着一张终端状态表。注册成功后,终端别名、信令IP、RAS端口、在线状态等都需要记录。Ruby的Hash配合Mutex可以满足小规模并发。终端别名可能包含E.164号码或H.323 ID,在简化模型中可以直接用字符串作为键。
require 'thread'
class EndpointRegistry
def initialize
@mutex = Mutex.new
@endpoints = {}
end
def register(alias_name, ip, port)
@mutex.synchronize do
@endpoints[alias_name] = { ip: ip, port: port, status: 'online' }
end
end
def unregister(alias_name)
@mutex.synchronize do
@endpoints.delete(alias_name)
end
end
def find(alias_name)
@mutex.synchronize do
@endpoints[alias_name]
end
end
def online?
@mutex.synchronize do
@endpoints.values.any? { |e| e[:status] == 'online' }
end
end
end
状态表不只要记录地址,还要处理生命周期。终端可能异常离线而没有发DRQ,此时网守需要借助超时机制清理失效条目。RAS通道的IRR消息可以用于周期上报,如果长时间没有收到IRR,就应将会话标记为超时。简易实现可以用一个后台线程定期扫描所有记录,将更新时间超过阈值的终端注销,防止信令转发到不可达地址。
准入判断发生在ARQ阶段。网守收到ARQ后,需要确认主叫和被叫是否都注册、当前带宽是否充足,以及被叫方是否愿意接收呼叫。如果条件满足,就返回ACF并在其中填入呼叫信令地址;否则返回ARJ。ACF里最重要的字段之一是callSignalAddress,它告诉终端应该连接哪个TCP端口进行下一步的H.225.0呼叫信令。这个地址可以是网守自身,也可以直接指向被叫终端,取决于呼叫信令是否由网守路由。
在直接路由模式下,网守只负责准入和地址解析,终端之间点对点建立呼叫信令通道。在网守路由模式下,所有呼叫信令都会经过网守转发,便于做计费、监听和策略控制。后者的好处是终端无需知道彼此的真实地址,适合隔离网络。本文采用网守路由模式,这样TCP代理部分才有实际意义。
呼叫信令通道:解析Q.931与TCP转发
当终端收到ACF后,会向callSignalAddress发起TCP连接,默认端口是1720。这个连接传输的是H.225.0呼叫信令,也就是承载在TPKT之上的Q.931消息。终端先发Setup消息,里面包含被叫号码和媒体能力。网守需要从Setup中解析出被叫别名,然后查找状态表得到目标终端,再向目标终端发起另一个TCP连接,并把两端的字节流互相转发。
Ruby的TCPServer可以很轻松地监听1720端口。每接入一个连接,就启动一个线程处理。为了从流量中提取Q.931消息,需要先读取TPKT头。TPKT头固定4字节,前两个字节是版本和保留字段,后两个字节表示整条消息的长度。拿到长度后,继续读取剩余字节,就可以得到完整的Q.931消息。Setup消息的消息类型编码为0x05,被叫号码位于消息体的信息单元中,需要再往前解析若干偏移。简易实现可以先做盲转发,再逐步补充解析。
require 'socket'
CALL_SIGNAL_PORT = 1720
def read_tpkt(io)
header = io.read(4)
return nil if header.nil? || header.bytesize < 4
high = header.getbyte(2)
low = header.getbyte(3)
length = high * 256 + low
body = io.read(length - 4)
header + body
rescue IOError
nil
end
def forward_tcp(client, target)
loop do
ready = IO.select([client, target])
ready[0].each do |sock|
data = sock.recv(4096)
if data.empty?
client.close rescue nil
target.close rescue nil
return
end
if sock == client
target.write(data)
else
client.write(data)
end
end
end
end
server = TCPServer.new('0.0.0.0', CALL_SIGNAL_PORT)
puts "call signaling listening on #{CALL_SIGNAL_PORT}/tcp"
loop do
client = server.accept
Thread.new(client) do |c|
first_message = read_tpkt(c)
unless first_message
c.close rescue nil
next
end
# 简化策略:根据注册表选择一个目标地址
# 实际项目应从Setup消息中提取calledPartyNumber
target = TCPSocket.new('192.168.1.20', 1720)
target.write(first_message) if first_message
forward_tcp(c, target)
end
end
上面的代码中目标地址写死成了192.168.1.20,这只是为了演示转发链路。真正实现时,应该在第一个Q.931消息里解析被叫号码,再用EndpointRegistry查询对应的IP和端口。如果状态表中不存在目标终端,网守可以直接回送ReleaseComplete消息,释放本次呼叫。
转发线程中还有一个容易忽略的问题:如果一端先关闭连接,另一端可能还在发送数据。因此需要在关闭前把对端可写的数据尽量刷出去。简易实现里通过IO.select检测可读事件,当recv返回空时,就认为对端已关闭,立即关闭双边套接字。这种方式能覆盖大多数短呼叫场景,但不适合长时间媒体流传输。媒体流通常走RTP,并不经过呼叫信令通道,所以TCP代理不会成为音视频流量的瓶颈。
串联完整流程与验证
把RAS处理和呼叫信令转发放在同一个Ruby进程中,网守就具备了基本雏形。链路顺序如下:终端先通过GRQ发现网守,随后发RRQ完成注册;主叫发起呼叫前发ARQ请求准入,收到ACF后连接网守的1720端口;网守解析Setup消息中的被叫号码,查找状态表,再向被叫终端发起第二条呼叫信令连接;后续Q.931消息和H.245控制消息都由网守中转。被叫挂机时,双方终端会发DRQ请求释放,网守删除对应准入记录并更新状态。
如果只是在实验环境验证,可以用两台软电话或H.323终端模拟器指向这个网守。网守启动后观察到GRQ、RRQ、ARQ日志,说明RAS部分工作正常。再通过抓包确认TCP 1720上出现了Setup、Alerting、Connect等消息,并确认两端能进入通话。对于无法解析的PER字段,先用Wireshark对比标准消息,找出偏移后再修改模板。
这种简易网守的局限性很明显:没有处理H.245能力协商的细节,没有实现带宽控制,PER编码也不完整。但作为理解H.323信令层次的原型,它足够轻量,适合嵌入到测试工具或学习项目里。若后续要升级,可以逐步引入完整的ASN.1编解码库,把Ruby作为控制层,把协议栈放到专门的C扩展中处理。
Ruby H.323网守RAS消息处理H.225.0呼叫信令修改时间:2026-09-24 16:28:17