[MG_Remote, MG_Test, scripts] (P7): authenticate TCP peers before fork with bounded pre-auth work and backoff

This commit is contained in:
2026-09-23 13:44:38 -04:00
parent 7aee3e2105
commit 7bb1da73d4
7 changed files with 1233 additions and 127 deletions
+203 -18
View File
@@ -13,6 +13,12 @@ unit test of `SocketTransport::Listen` can assert the return code but not that a
misconfigured a token gets a dead server with a named reason rather than a live one that silently
serves loopback only.
P7 F2, PH-7 (5): the supervisor now reads and authenticates every first frame before it forks, so
the controls here assert that an unauthenticated peer learns nothing of ours
(`unauthenticated_wrong_layout`), that a silent one costs no fork and is answered at the pre-auth
deadline (`silent_peer`), that Busy is the answer to an AUTHENTICATED second client (`busy`), the
pending cap and the backoff (`pending_cap`, `auth_backoff`).
It needs flatc, which is deliberately absent from the default build graph (gen_protocol.py's
header block says why). Resolution order is gen_protocol.py's, minus the build-it-for-you arm:
--flatc, then $MOBILEGL_FLATC_EXECUTABLE, then <repo>/../flatc-build/flatc. With none of them
@@ -222,13 +228,15 @@ def refused_listen(server, log, endpoint, token):
@contextmanager
def supervisor(server, log, same_build=False, serve=True):
def supervisor(server, log, same_build=False, serve=True, extra_env=None):
"""A supervisor on its own free loopback port. `serve=False` is the single-session shape
(no fork; the process IS the session and exits with it), which `single_session_listener`
below needs and nothing else does."""
below needs and nothing else does. `extra_env` sets supervisor knobs (PH-7 (5)'s pre-auth
deadline, pending cap and backoff) for the controls that need non-default ones."""
port = free_loopback_port()
base = log.with_suffix('.library.log')
env = supervisor_environment(base, kToken, same_build=same_build)
env.update(extra_env or {})
with log.open('wb') as output:
child = subprocess.Popen([server, f'tcp://127.0.0.1:{port}'] + (['--serve'] if serve else []),
env=env, stdout=output, stderr=output, start_new_session=True)
@@ -342,6 +350,100 @@ def exchange(port, schema, message):
raise RuntimeError('supervisor remained Busy after the previous peer closed')
# PH-7 (5): the refusal details MG_Remote/Server/PreAuthGate.h names. Mirrored, not parsed out of
# the header: a rename there must fail these controls rather than be followed by them silently.
kDetailNotAFrame = 'first frame is not a control frame'
kDetailTruncated = 'first frame ended before it was complete'
kDetailNoIdentifier = 'first frame carries no CtrlEnvelope identifier'
kDetailUnverifiable = 'first frame did not verify as a CtrlEnvelope'
kDetailNotAHello = 'first control frame is not a verifiable Hello'
kDetailDeadline = 'no authenticated first frame within the pre-auth deadline'
kDetailPendingFull = 'too many connections are awaiting authentication'
kDetailBackoff = 'too many failed authentications from this address; retry later'
kDetailHandoff = 'data connection could not be handed to the live session'
def child_pids(pid):
"""Every process whose parent is `pid` - the supervisor's session children, including one that
has exited and is not reaped yet. Read from /proc directly, because the property PH-7 (5)
asserts is that NO child exists for a peer that has not authenticated, and the log only speaks
of a child once it is reaped."""
children = []
for entry in Path('/proc').iterdir():
if not entry.name.isdigit():
continue
try:
stat = (entry / 'stat').read_text()
except OSError:
continue
fields = stat.rsplit(')', 1)[1].split()
if int(fields[1]) == pid:
children.append(int(entry.name))
return children
def wait_for_no_session(pid, seconds=10):
"""Until the supervisor has no session child (a previous control's session reaped), so the
next control starts from "no live session" by construction rather than by timing."""
deadline = time.monotonic() + seconds
while child_pids(pid):
if time.monotonic() >= deadline:
raise RuntimeError(f'supervisor pid={pid} still has session children {child_pids(pid)}')
time.sleep(.05)
def load_schema(flatc, directory):
"""The protocol's Python bindings, generated by the pinned flatc into `directory`."""
subprocess.run([flatc, '--python', '-o', directory,
str(ROOT / 'MobileGL/MG_Remote/Protocol/protocol.fbs')], check=True)
sys.path[:0] = [directory, str(ROOT / '3rdparty/flatbuffers/python')]
import flatbuffers
schema = {name: importlib.import_module('MobileGL.Wire.' + name)
for name in ('CtrlEnvelope', 'CtrlMsg', 'DataBind', 'Hello', 'LinkTerms', 'LogFlush', 'Refuse',
'Welcome')}
return flatbuffers, schema
def silent_peer_control(port, process, schema):
"""PH-7 (5) (ph-f.md §6.4 (a)): A SILENT PEER COSTS NO FORK.
The supervisor forked a session child the moment it accepted, and that child waited up to
10 s for a Hello before exiting 67 without a word. Now nothing is forked for a peer that has
not authenticated: while the silent connection is pending the supervisor has NO child (read
from /proc, not from the log, which only names a child once it is reaped), and at the pre-auth
deadline (default 2000 ms) the peer is answered by name. RED before the fix: a child exists
at the first check, and the peer's read ends with EOF after 10 s instead of a frame. A function
of its own so the red-once probe can run exactly this control against an older server."""
wait_for_no_session(process.pid)
silent = connect_control(port)
try:
time.sleep(.5)
forked = child_pids(process.pid)
silent.settimeout(15)
began = time.monotonic()
try:
answer = receive(silent, schema)
except (RuntimeError, OSError) as error:
answer = {'error': repr(error)}
waited = time.monotonic() - began
finally:
silent.close()
assert not forked, f'the supervisor forked {forked} for a peer that has not sent a byte'
assert answer.get('code') == 4 and answer.get('detail') == kDetailDeadline, answer
assert waited < 5, f'the silent peer was answered after {waited:.1f} s'
assert process.poll() is None, 'a silent peer killed the supervisor'
return {'refusal': answer, 'childrenWhilePending': forked, 'answeredAfterSeconds': round(waited + .5, 2)}
def learn_fingerprint(port, schema, flatbuffers):
"""The supervisor's wire fingerprint, from the one peer allowed to learn it: an AUTHENTICATED
Hello with fingerprint 0 is answered Refuse{WireFingerprint} carrying ours (PH-7 (5): an
unauthenticated one is not)."""
layout = exchange(port, schema, hello(schema, flatbuffers))
assert layout.get('code') == 2 and layout.get('expected', 0), layout
return layout
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument('--server', required=True)
@@ -359,12 +461,7 @@ def main():
print(f'tcp_supervisor_smoke: no server image at {args.server}; skipping', flush=True)
return kSkipExit
with tempfile.TemporaryDirectory(prefix='mgl-tcp-schema-') as directory:
subprocess.run([flatc, '--python', '-o', directory,
str(ROOT / 'MobileGL/MG_Remote/Protocol/protocol.fbs')], check=True)
sys.path[:0] = [directory, str(ROOT / '3rdparty/flatbuffers/python')]
import flatbuffers
schema = {name: importlib.import_module('MobileGL.Wire.' + name)
for name in ('CtrlEnvelope', 'CtrlMsg', 'DataBind', 'Hello', 'LinkTerms', 'Refuse', 'Welcome')}
flatbuffers, schema = load_schema(flatc, directory)
evidence = {}
# ---- PH-7 (2)(3), ID-P7-3: THE TWO LISTEN REFUSALS -------------------------------
@@ -393,18 +490,31 @@ def main():
evidence['short_token_listen'] = {'exit': code, 'line': next(
l.strip() for l in said.splitlines() if 'Refuse{Authentication}' in l)}
with supervisor(args.server, args.out / 'supervisor.log') as (port, process, serverLog):
with pair(port) as held:
with pair(port) as second:
busy = receive(second, schema)
assert busy.get('code') == 7, busy
evidence['busy'] = busy
layout = exchange(port, schema, hello(schema, flatbuffers))
assert layout.get('code') == 2 and layout.get('expected', 0), layout
layout = learn_fingerprint(port, schema, flatbuffers)
fingerprint = layout['expected']
evidence['wrong_layout'] = layout
token = exchange(port, schema, hello(schema, flatbuffers, fingerprint=fingerprint, token='wrong-token'))
assert token.get('code') == 4, token
evidence['wrong_token'] = token
# ---- PH-7 (5) (ph-f.md §6.4 (b)): AN UNAUTHENTICATED PEER LEARNS NOTHING ABOUT US ----
#
# The same wrong layout as `wrong_layout` above, WITHOUT the token. The session child
# used to check the wire before the token, so this peer was answered
# Refuse{WireFingerprint} with our fingerprint in `expected` (and with major 99,
# Refuse{ProtocolVersion} with our version). The token is asked first now, by the
# supervisor, and the answer is Refuse{Authentication} carrying nothing of ours.
# RED before the fix: code 2 with expected == our fingerprint.
unauthenticated = exchange(port, schema, hello(schema, flatbuffers, token='wrong-token'))
assert unauthenticated.get('code') == 4 and unauthenticated.get('expected') == 0, (
f'an unauthenticated Hello with a wrong layout was answered {unauthenticated}; '
f'our fingerprint is {fingerprint}')
unauthenticated_version = exchange(port, schema, hello(schema, flatbuffers, major=99, token='wrong-token'))
assert unauthenticated_version.get('code') == 4 and unauthenticated_version.get('expected') == 0, (
unauthenticated_version)
evidence['unauthenticated_wrong_layout'] = unauthenticated
evidence['unauthenticated_wire_major_99'] = unauthenticated_version
version = exchange(port, schema, hello(schema, flatbuffers, major=99))
assert version.get('code') == 1, version
evidence['wire_major_99'] = version
@@ -414,6 +524,24 @@ def main():
evidence['different_build_connect'] = accepted
assert process.poll() is None, 'a rejected session killed the supervisor'
# ---- Busy is the answer to an AUTHENTICATED second client (PH-7 (5)) ---------------
#
# This control used to hold a connection that never sent a Hello and read Busy on a
# second silent one: the supervisor forked for the first and refused the second after
# waiting 2 s for its frame. Neither is a client now - both are unauthenticated peers
# and are answered at the pre-auth deadline (`silent_peer` below). One session at a
# time is unchanged, so a WELCOMED session plus a second authenticated Hello is Busy.
with held_session(port, schema, flatbuffers,
hello(schema, flatbuffers, fingerprint=fingerprint)) as (control, welcome):
assert welcome.get('welcome', 0), welcome
with pair(port) as second:
second.sendall(hello(schema, flatbuffers, fingerprint=fingerprint))
busy = receive(second, schema)
assert busy.get('code') == 7, busy
evidence['busy'] = busy
evidence['silent_peer'] = silent_peer_control(port, process, schema)
# P7 wave 0: the steady-state control loop asks for the file identifier, which
# ServerSession::ParseEnvelope has asked since P5 and ServerMain's loop never did.
# flatbuffers verifies a table's offsets, not the four identifier bytes, so until now
@@ -449,9 +577,10 @@ def main():
with held_session(port, schema, flatbuffers, held) as (control, welcome):
faulted = welcome.get('welcome', 0)
assert faulted and faulted != os.getpid(), welcome
# The count is a supervisor LIFETIME figure and the controls above have already
# moved it (the Busy probe holds a pair that never sends a Hello, and that child
# exits 67), so what is asserted is the DELTA, not an absolute.
# The count is a supervisor LIFETIME figure, so what is asserted is the DELTA, not
# an absolute. (Until PH-7 (5) the controls above had already moved it: the old
# Busy probe held a pair that never sent a Hello, and the child forked for it exited
# 67. Nothing is forked for such a peer now.)
prior = last_faulted(serverLog)
os.kill(faulted, signal.SIGABRT)
reaped = wait_for_log(serverLog, f'pid={faulted} reaped signal={int(signal.SIGABRT)}')
@@ -483,6 +612,8 @@ def main():
# connection (a DataBind with a nonce no live session minted) is presented FIRST,
# the control connection second. It is refused by name, as a clean exit, and the
# control connection behind it is welcomed.
# "No live session" by construction: the previous control's child is reaped first.
wait_for_no_session(process.pid)
stray = open_data(port, schema, flatbuffers, os.urandom(16))
try:
refused = receive(stray, schema)
@@ -591,6 +722,7 @@ def main():
'fresh_control': still_busy}
assert process.poll() is None, 'the refused DataBinds killed the supervisor'
# ---- F fix round: A GARBAGE FIRST FRAME WHILE BUSY IS MalformedHello, NOT Busy ------
#
# While a session is live the supervisor reads a new connection's first frame to route
@@ -618,6 +750,59 @@ def main():
assert strict.get('code') == 3, strict
evidence['same_build_required'] = strict
# ---- PH-7 (5): THE PENDING CAP AND THE AUTHENTICATION BACKOFF ---------------------------
#
# A supervisor with small knobs, so both limits are reached in a few connections: at most
# two connections pending authentication, and an address backed off after two failures,
# for 1500 ms.
knobs = {'MOBILEGL_IPC_PREAUTH_MAX': '2', 'MOBILEGL_IPC_PREAUTH_MS': '3000',
'MOBILEGL_IPC_AUTH_BACKOFF_AFTER': '2', 'MOBILEGL_IPC_AUTH_BACKOFF_MS': '1500'}
with supervisor(args.server, args.out / 'preauth-knobs.log', extra_env=knobs) as (port, process, serverLog):
# (a) THE CAP. Two silent connections fill it; the third is refused Busy AT ACCEPT, at
# once, instead of joining them. The two are then closed before their deadline -
# a close before any byte is a probe, not an authentication failure, so (b) below
# starts from zero. RED with the cap disabled: the third is not answered until its
# own deadline, and then as Authentication.
first, second = connect_control(port), connect_control(port)
try:
time.sleep(.3) # both accepted and pending
third = connect_control(port)
try:
third.settimeout(10)
began = time.monotonic()
full = receive(third, schema)
waited = time.monotonic() - began
finally:
third.close()
finally:
first.close()
second.close()
assert full.get('code') == 7 and full.get('detail') == kDetailPendingFull, full
assert waited < 1, f'the connection over the pending cap waited {waited:.2f} s for its answer'
evidence['pending_cap'] = {'refusal': full, 'answeredAfterSeconds': round(waited, 3)}
time.sleep(.3)
# (b) THE BACKOFF. Two wrong tokens from this address, and the THIRD connection - with
# the right token - is refused at accept, before its Hello is read, with the backoff's
# own words; once the window has passed the right token is welcomed again (and that
# clears the address). RED with the backoff disabled: the third is welcomed.
fingerprint = learn_fingerprint(port, schema, flatbuffers)['expected']
for _ in range(2):
wrong = exchange(port, schema, hello(schema, flatbuffers, fingerprint=fingerprint, token='wrong-token'))
assert wrong.get('code') == 4 and wrong.get('detail') == 'token mismatch', wrong
backed = exchange(port, schema, hello(schema, flatbuffers, fingerprint=fingerprint))
assert backed.get('code') == 4 and backed.get('detail') == kDetailBackoff, (
f'two failed authentications from one address and the next Hello was answered {backed}')
named = wait_for_log(serverLog, 'refused at accept: authentication backoff')
assert named is not None, f'the backoff refusal was not named in {serverLog}'
time.sleep(1.6)
after = exchange(port, schema, hello(schema, flatbuffers, fingerprint=fingerprint))
assert after.get('welcome', 0), f'the address was still refused after its window: {after}'
evidence['auth_backoff'] = {'refusal': backed, 'line': next(
l.strip() for l in named.splitlines() if 'refused at accept: authentication backoff' in l),
'afterWindow': after}
assert process.poll() is None, 'the pre-auth limits killed the supervisor'
# ---- F fix round: THE SINGLE-SESSION SERVER CLOSES ITS LISTENER ONCE THE SESSION IS BOUND
#
# Without --serve the process is the session; it keeps the listener open only so the