1
0
mirror of https://github.com/coturn/coturn.git synced 2026-10-24 00:08:57 +03:00
Files
coturn/examples/scripts/mobility_resume_flood.py
Pavel Punsky a97f1924bb Merge commit from fork
Under --mobility a resume does not hard-switch the allocation onto the
new client path; it opens a dual-5-tuple transition and links the
resuming session to the allocation via a single backlink
(mobile_pending_resume). An authenticated user could resume one
allocation repeatedly from fresh 5-tuples without completing any
handoff: each resume disarmed the new session's un-allocated watchdog
and overwrote that single backlink, orphaning the previous pending
session. Orphans were unreachable by the transition-deadline sweep and
owned no allocation timer, so they stayed allocated indefinitely,
letting one user grow server memory without bound. --user-quota did not
stop it, since pending sessions hold no quota unit.

Enforce one pending resume per allocation:

- mobile_begin_transition() fully tears down the existing pending
  session before opening a new one. It runs in REFRESH packet context,
  so the shutdown is synchronous and also clears the allocation's
  backlink via shutdown's transition-unlink path.
- mobile_abort_transition() re-arms the disarmed watchdog so an
  abandoned pending left after the transition deadline is reaped from
  timer context (safe during the session-map sweep, which only
  schedules the timer).
- mobile_complete_transition() promotes only when the session is still
  the allocation's current pending resume, so a superseded pending can
  neither clear live transition state nor steal a socket move.

Add examples/run_tests_mobility_resume_flood.sh (+ raw-STUN driver
scripts/mobility_resume_flood.py): it floods chained resumes and asserts
every superseded pending is torn down. It passes on the fixed server and
fails on the unpatched one.

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-07-26 22:39:45 -07:00

142 lines
4.3 KiB
Python
Executable File

#!/usr/bin/env python3
# Chained-mobility-resume load driver for the RFC 8016 handoff regression test
# (see examples/run_tests_mobility_resume_flood.sh).
#
# Allocates one mobility-enabled TURN allocation, then repeatedly resumes it
# from fresh UDP 5-tuples WITHOUT ever completing a handoff (it never sends a
# Send/ChannelData on the new path). Each accepted resume opens a new pending
# transition. A server that does not bound pending resumes per allocation
# accumulates one orphaned server-side session per resume; a fixed server tears
# down the superseded pending on every subsequent resume, so the live session
# count stays bounded.
#
# Raw STUN over the standard library only (no coturn client dependency), so the
# test can drive the exact abuse pattern turnutils_uclient does not model.
import hashlib
import hmac
import os
import socket
import struct
import sys
MAGIC = 0x2112A442
M_ALLOCATE = 0x0003
M_REFRESH = 0x0004
ALLOCATE_OK = 0x0103
REFRESH_OK = 0x0104
A_USERNAME = 0x0006
A_MI = 0x0008
A_ERROR = 0x0009
A_REALM = 0x0014
A_NONCE = 0x0015
A_REQ_TRANSPORT = 0x0019
A_MOBILITY = 0x8030
A_LIFETIME = 0x000D
def attr(t, v):
return struct.pack("!HH", t, len(v)) + v + b"\0" * ((-len(v)) & 3)
def parse(msg):
typ, ln, _magic, _tid = struct.unpack("!HHI12s", msg[:20])
out = []
p = 20
while p + 4 <= min(len(msg), 20 + ln):
t, n = struct.unpack("!HH", msg[p : p + 4])
out.append((t, msg[p + 4 : p + 4 + n]))
p += 4 + n + ((-n) & 3)
return typ, out
def get(attrs, t):
for k, v in attrs:
if k == t:
return v
return None
def make(typ, attrs, key=None):
tid = os.urandom(12)
body = b"".join(attr(t, v) for t, v in attrs)
if key is not None:
# RFC 5389: length in the header used for the HMAC includes the
# MESSAGE-INTEGRITY TLV (24 bytes) but the HMAC input stops before it.
hdr = struct.pack("!HHI12s", typ, len(body) + 24, MAGIC, tid)
mac = hmac.new(key, hdr + body, hashlib.sha1).digest()
body += attr(A_MI, mac)
return struct.pack("!HHI12s", typ, len(body), MAGIC, tid) + body
def xact(sock, msg, host, port):
sock.sendto(msg, (host, port))
return sock.recvfrom(4096)[0]
def errcode(attrs):
v = get(attrs, A_ERROR)
if not v or len(v) < 4:
return None
return (v[2] & 7) * 100 + v[3]
def main():
host = sys.argv[1] if len(sys.argv) > 1 else "127.0.0.1"
port = int(sys.argv[2]) if len(sys.argv) > 2 else 3478
user = (sys.argv[3] if len(sys.argv) > 3 else "user").encode()
password = (sys.argv[4] if len(sys.argv) > 4 else "pass").encode()
count = int(sys.argv[5] if len(sys.argv) > 5 else 100)
s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
s.settimeout(3)
# Unauthenticated Allocate to obtain the realm/nonce challenge.
typ, aa = parse(xact(s, make(M_ALLOCATE, [(A_REQ_TRANSPORT, b"\x11\0\0\0"), (A_MOBILITY, b"")]), host, port))
realm = get(aa, A_REALM)
nonce = get(aa, A_NONCE)
if not realm or not nonce:
print(f"FAIL: no challenge (type={typ:#x} err={errcode(aa)})")
return 2
key = hashlib.md5(user + b":" + realm + b":" + password).digest()
req = [
(A_USERNAME, user),
(A_REALM, realm),
(A_NONCE, nonce),
(A_REQ_TRANSPORT, b"\x11\0\0\0"),
(A_MOBILITY, b""),
]
typ, aa = parse(xact(s, make(M_ALLOCATE, req, key), host, port))
ticket = get(aa, A_MOBILITY)
if typ != ALLOCATE_OK or not ticket:
print(f"FAIL: allocate rejected (type={typ:#x} err={errcode(aa)})")
return 2
held = []
for i in range(count):
q = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
q.settimeout(3)
attrs = [
(A_USERNAME, user),
(A_REALM, realm),
(A_NONCE, nonce),
(A_LIFETIME, struct.pack("!I", 600)),
(A_MOBILITY, ticket),
]
typ, aa = parse(xact(q, make(M_REFRESH, attrs, key), host, port))
nt = get(aa, A_MOBILITY)
if typ != REFRESH_OK or not nt:
print(f"FAIL: resume {i} rejected (type={typ:#x} err={errcode(aa)})")
return 2
held.append(q) # keep the client socket open (never complete the handoff)
ticket = nt
print(f"RESULT resumes={len(held)}")
return 0
if __name__ == "__main__":
sys.exit(main())