105 lines
4.4 KiB
Python
105 lines
4.4 KiB
Python
#!/usr/bin/env python3
|
|
"""Decode a hex-dump capture of LiteNetLib 1.x traffic.
|
|
|
|
Input format: alternating timestamp lines and lowercase hex payload lines
|
|
(one UDP payload per line), which is what the in-game capture produces.
|
|
|
|
./tools/decode_lnl.py traffic.txt
|
|
"""
|
|
import re, sys, struct, collections
|
|
|
|
PROP = {0:'Unreliable',1:'Channeled',2:'Ack',3:'Ping',4:'Pong',5:'ConnectRequest',
|
|
6:'ConnectAccept',7:'Disconnect',8:'UnconnectedMessage',9:'MtuCheck',10:'MtuOk',
|
|
11:'Broadcast',12:'Merged',13:'ShutdownOk',14:'PeerNotFound',15:'InvalidProtocol',
|
|
16:'NatMessage',17:'Empty'}
|
|
|
|
BATCH_MAGIC = 0x1160
|
|
|
|
|
|
def read_packed_uint(d, p):
|
|
n = d[p] & 0x07
|
|
if n == 5:
|
|
return struct.unpack_from('<I', d, p + 1)[0], p + 5
|
|
v = int.from_bytes(d[p:p + n], 'little')
|
|
return v >> 3, p + n
|
|
|
|
|
|
def decode_ngo(payload):
|
|
"""Decode an NGO batch riding inside a Channeled/Unreliable LiteNetLib payload."""
|
|
if len(payload) < 16:
|
|
return " (too short for a batch header)"
|
|
magic, count, size = struct.unpack_from('<HHi', payload, 0)
|
|
bhash = struct.unpack_from('<Q', payload, 8)[0]
|
|
if magic != BATCH_MAGIC:
|
|
return f" not an NGO batch (magic 0x{magic:04x})"
|
|
out = [f" NGO batch: count={count} size={size} len={len(payload)} hash=0x{bhash:016x}"]
|
|
p = 16
|
|
for i in range(count):
|
|
if p >= len(payload):
|
|
break
|
|
mtype, p = read_packed_uint(payload, p)
|
|
msize, p = read_packed_uint(payload, p)
|
|
out.append(f" [{mtype}] {msize} bytes: {payload[p:p+min(msize,32)].hex()}")
|
|
p += msize
|
|
return "\n".join(out)
|
|
|
|
|
|
def main(path):
|
|
lines = open(path).read().splitlines()
|
|
events, ts = [], None
|
|
for l in lines:
|
|
l = l.strip()
|
|
if re.match(r'^\d{4}-\d\d-\d\d', l):
|
|
ts = l
|
|
elif re.fullmatch(r'[0-9a-f]+', l) and len(l) >= 2:
|
|
events.append((ts, bytes.fromhex(l)))
|
|
|
|
hist = collections.Counter()
|
|
for ts, b in events:
|
|
prop = b[0] & 0x1f
|
|
name = PROP.get(prop, f'?{prop}')
|
|
hist[name] += 1
|
|
t = ts.split()[1][:12] if ts else ''
|
|
detail = ''
|
|
if name == 'ConnectRequest':
|
|
pid = struct.unpack_from('<i', b, 1)[0]
|
|
ct = struct.unpack_from('<q', b, 5)[0]
|
|
asz = b[17]
|
|
addr = b[18:18 + asz]
|
|
port = struct.unpack_from('>H', addr, 2)[0]
|
|
ip = '.'.join(str(x) for x in addr[4:8])
|
|
detail = (f"protocolId={pid} ct={ct} target={ip}:{port} "
|
|
f"connectData={b[18+asz:].hex()}")
|
|
elif name == 'ConnectAccept':
|
|
# [1..8] connectionTime, [9] connectionNumber, [10] isReused, [11..14] peerId
|
|
detail = (f"ct={struct.unpack_from('<q', b, 1)[0]} "
|
|
f"connNum={b[9]} reused={b[10]} "
|
|
f"peerId={struct.unpack_from('<i', b, 11)[0]}")
|
|
elif name in ('Ping', 'Pong'):
|
|
detail = f"seq={struct.unpack_from('<H', b, 1)[0]}"
|
|
elif name == 'MtuCheck':
|
|
detail = f"mtu={struct.unpack_from('<i', b, 1)[0]}"
|
|
print(f"{t:13} {name:16} {len(b):5} {detail}")
|
|
if name in ('Channeled', 'Unreliable'):
|
|
# Channeled carries a 4-byte LNL header, Unreliable a 1-byte one.
|
|
print(decode_ngo(b[4:] if name == 'Channeled' else b[1:]))
|
|
|
|
print("\nhistogram:")
|
|
for k, v in hist.most_common():
|
|
print(f" {k:16} {v}")
|
|
if not hist['Channeled'] and not hist['Unreliable']:
|
|
print("\n NOTE: no data packets at all - the NGO layer never got a chance to speak.")
|
|
if hist['Ping'] and not hist['Pong']:
|
|
print(" NOTE: pings with no pongs. These captures carry no direction or port info, so"
|
|
"\n note the pings are most likely the SERVER's: it accepted the connection"
|
|
"\n and started pinging, while the client is still in Outgoing state and so"
|
|
"\n never pongs. Expect a DisconnectTimeout (~5s) loop on the server.")
|
|
if hist['ConnectRequest'] > 3 and hist['ConnectAccept']:
|
|
print(" NOTE: repeated ConnectRequests answered by ConnectAccepts. If the connectionTime"
|
|
"\n is identical across them it is ONE attempt being resent - the client is"
|
|
"\n not acting on the accept. Capture with tcpdump to see if it arrives.")
|
|
|
|
|
|
if __name__ == '__main__':
|
|
main(sys.argv[1] if len(sys.argv) > 1 else 'traffic.txt')
|