mirror of
https://github.com/MobileGL-Dev/MobileGL
synced 2026-11-07 01:53:24 +09:00
[MG_Remote, MG_Test, scripts] (P7): authenticate TCP peers before fork with bounded pre-auth work and backoff
This commit is contained in:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user