IS-IS作为经典的链路状态路由协议,在运营商网络中应用广泛。它的数据库同步机制依赖两类序列号PDU:CSNP(Complete Sequence Number PDU)用于通告整个链路状态数据库的摘要,PSNP(Partial Sequence Number PDU)则用于请求缺失的LSP或对收到的LSP做确认。很多学习IS-IS的人停留在概念层面,对报文的具体字节结构并不清楚。用Ruby写一个简单的实现,是理解这些细节最直接的方式。本文将带领大家完成一个可运行的最小化实现。

一、CSNP和PSNP的报文结构解析
IS-IS的所有PDU都遵循统一的头部格式:前8个字节是通用头,包括协议鉴别符(固定为0x83)、头长度(固定为0x1F)、版本(0x01)、系统ID长度(0x00表示6字节)、PDU类型、版本和保留字段、保留字段以及最大区域地址数。从第9个字节开始是各PDU的专有头部。
CSNP的专有头包含:PDU长度(2字节)、源LSP ID(系统ID6字节+伪节点ID1字节+LSP分片号1字节,共8字节)、起始LSP ID和结束LSP ID(各8字节,表示本CSNP覆盖的LSP ID范围)。头部之后跟随TLV,最关键的是类型10的完整序列号TLV,其中逐条列出每个LSP的摘要信息:剩余生存时间、LSP ID、序列号和校验和。一个CSNP可以携带多条这样的记录,直到达到MTU上限。
PSNP的专有头类似但更短:PDU长度、源LSP ID之后直接跟TLV,使用的是类型10的同一格式,但语义不同。CSNP中列出的摘要代表“我这里有这些LSP”,而PSNP中列出的摘要根据方向不同,既可以是“请把这些LSP发给我”,也可以是“我确认收到了这些LSP”。
二、用Ruby构建报文编码与解析
Ruby标准库中的Array#pack和String#unpack非常适合处理这类二进制协议。下面先实现通用头和CSNP的编码函数。注意所有多字节字段采用网络字节序(大端)。
module Isis
PDU_CSNP = 24 # PDU类型:Level 1 CSNP
PDU_PSNP = 26 # PDU类型:Level 1 PSNP
def self.common_header(pdu_type, sys_id_len = 6)
[0x83, 0x1f, 0x01, sys_id_len, pdu_type, 0x01, 0x00, 0x00].pack('C8')
end
def self.encode_lsp_entry(rem_lifetime, lsp_id, seq, checksum)
entry = [rem_lifetime].pack('n')
entry += [lsp_id].pack('H16') # lsp_id为16个十六进制字符
entry += [seq].pack('N')
entry += [checksum].pack('n')
entry
end
def self.encode_csnp(source_lsp_id, start_lsp_id, end_lsp_id, lsp_entries)
body = common_header(PDU_CSNP)
pdu_len_placeholder = 0
body += [pdu_len_placeholder].pack('n')
body += [source_lsp_id].pack('H16')
body += [start_lsp_id].pack('H16')
body += [end_lsp_id].pack('H16')
tlv = [10, lsp_entries.length, lsp_entries].pack('CCa*')
body += tlv
# 回填真实的PDU总长度
body[8, 2] = [body.length].pack('n')
body
end
end上面代码有几个细节值得注意。LSP ID在实际报文中是8字节二进制,这里为了简化使用16位十六进制字符串表示,例如"0000.0000.0001.00-00"会先被规整化处理后再编码。PDU长度字段在编码时先占位,整个报文拼装完成后再回填真实值,这是处理长度字段依赖自身长度的常见技巧。
解析方向则用unpack逆向操作。PSNP的编码函数与CSNP几乎一致,只是PDU类型不同且没有起始和结束LSP ID字段:
def self.encode_psnp(source_lsp_id, lsp_entries)
body = Isis.common_header(Isis::PDU_PSNP)
body += [0].pack('n') # PDU长度占位
body += [source_lsp_id].pack('H16')
tlv = [10, lsp_entries.length, lsp_entries].pack('CCa*')
body += tlv
body[8, 2] = [body.length].pack('n')
body
end
def self.parse_pdu(raw)
hdr = raw.unpack('C8')
pdu_type = hdr[4]
case pdu_type
when 24 then parse_csnp(raw)
when 26 then parse_psnp(raw)
else raise "未知PDU类型: #{pdu_type}"
end
end解析函数需要把TLV循环读出来,每条类型10的TLV内部再按固定12字节步进切出LSP条目(2字节生存时间+8字节LSP ID+4字节序列号+2字节校验和)。由于真实报文中TLV的长度字段以字节计,而每条LSP摘要恰好12字节,解析时要注意对非整条记录的尾部数据做容错处理。
三、模拟邻接同步:CSNP与PSNP的交互流程
光有编码解码还不够,协议的精髓在于交互。IS-IS的点对点链路上,两端建立邻接后会互相发送CSNP;接收方对比自己的数据库,发现缺失或过期的LSP时,发送PSNP去请求;对方收到PSNP后用完整LSP回应;最后请求方再发PSNP确认。我们可以用Ruby的UDP Socket在本地模拟这个流程。
require 'socket'
class IsisNode
def initialize(name, port, lsdb = {})
@name = name
@socket = UDPSocket.new
@socket.bind('127.0.0.1', port)
@lsdb = lsdb # key: lsp_id字符串, value: {seq:, checksum:}
end
def send_csnp(peer_port)
entries = @lsdb.map do |lsp_id, info|
Isis.encode_lsp_entry(1200, lsp_id, info[:seq], info[:checksum])
end.join
data = Isis.encode_csnp("#{@name}-00-00", "0"*15 + "0", "f"*16, entries)
@socket.send(data, 0, '127.0.0.1', peer_port)
end
def receive_loop
loop do
data, _addr = @socket.recvfrom(2048)
handle(data)
end
end
def handle(data)
type = data.unpack('C8')[4]
if type == Isis::PDU_CSNP
# 对比LSDB,找出自己缺失的LSP并发PSNP请求
remote = parse_csnp_entries(data)
missing = remote.keys - @lsdb.keys
unless missing.empty?
entries = missing.map { |id| Isis.encode_lsp_entry(0, id, 0, 0) }.join
# 实际实现中应带上远端通告的seq和checksum
puts "#{@name} 发送PSNP请求: #{missing}"
end
end
end
end运行时启动两个节点,节点A持有两条LSP,节点B为空。B收到A的CSNP后对比本地数据库,发现自己的LSDB缺少条目,于是构造PSNP发回给A。A收到PSNP后在真实协议里会回发完整的LSP报文,我们这个简化版中可以直接在控制台打印请求列表,验证交互逻辑正确性。
这个模拟实现刻意省略了校验和的真实计算、剩余生存时间的老化机制以及组播封装,目的是聚焦CSNP与PSNP的核心字段和状态判断。真实协议中,广播链路上CSNP只由指定路由器(DIS)周期性发送,而点对点链路上是双方各自发送一次。如果在这套代码基础上加入LSP报文的完整编码和定时器,就能扩展成一个功能更完整的IS-IS教学模拟器,对理解协议状态机非常有帮助。