From 7bb1da73d4cef4a6ae9f1050c244d528d2bdc729 Mon Sep 17 00:00:00 2001 From: Swung0x48 Date: Wed, 23 Sep 2026 10:34:14 -0400 Subject: [PATCH] [MG_Remote, MG_Test, scripts] (P7): authenticate TCP peers before fork with bounded pre-auth work and backoff --- MobileGL/MG_Remote/Server/PreAuthGate.h | 305 +++++++++++ MobileGL/MG_Remote/Server/ServerMain.cpp | 484 ++++++++++++++---- MobileGL/MG_Remote/Server/ServerSession.cpp | 18 +- MobileGL/MG_Test/Wire/CMakeLists.txt | 4 + MobileGL/MG_Test/Wire/PreAuthGateTest.cpp | 278 ++++++++++ .../MG_Test/Wire/SessionHandshakeTest.cpp | 50 ++ scripts/ci/tcp_supervisor_smoke.py | 221 +++++++- 7 files changed, 1233 insertions(+), 127 deletions(-) create mode 100644 MobileGL/MG_Remote/Server/PreAuthGate.h create mode 100644 MobileGL/MG_Test/Wire/PreAuthGateTest.cpp diff --git a/MobileGL/MG_Remote/Server/PreAuthGate.h b/MobileGL/MG_Remote/Server/PreAuthGate.h new file mode 100644 index 00000000..d2ef9fdd --- /dev/null +++ b/MobileGL/MG_Remote/Server/PreAuthGate.h @@ -0,0 +1,305 @@ +// MobileGL - MobileGL/MG_Remote/Server/PreAuthGate.h +// Copyright (c) 2025-2026 MobileGL-Dev +// Licensed under the GNU Lesser General Public License v3.0: +// https://www.gnu.org/licenses/gpl-3.0.txt +// https://www.gnu.org/licenses/lgpl-3.0.txt +// SPDX-License-Identifier: LGPL-3.0-only +// End of Source File Header + +// PH-7 (5), CONTRACT-P7 §12, notes/p7/ph-f.md §6.4. THE TCP SUPERVISOR AUTHENTICATES BEFORE IT +// FORKS, AND AN UNAUTHENTICATED PEER'S WORK IS BOUNDED. +// +// Two exposures were on the tree before wave 2-F and were recorded by its review: +// (a) the supervisor forked a session child for every accepted TCP connection and let the child +// read the Hello - so any peer that could reach the port cost the server a fork, and a +// silent one held that child for up to 10 s; +// (b) the child ran ValidatePeerHandshake before AuthenticatePeerToken, so an unauthenticated +// peer that sent a Hello with a wrong fingerprint was answered with Refuse{WireFingerprint} +// carrying THIS build's wire fingerprint (and Refuse{BuildFingerprint} its build stamp). +// +// The supervisor now reads every new connection's FIRST FRAME itself, without blocking on any one +// peer, under a short deadline; a Hello is authenticated there, token first, and only an +// authenticated Hello is ever forked for. The pieces that decide are here, free of I/O policy and +// of the supervisor's loop, so they are unit-testable (PreAuthGateTest): +// +// PreAuthKnobs the four knobs, read once from the environment; +// ClassifyFirstFrame what a complete first frame IS - the fuzz arm 1 malformations each land +// on a named shape here, and the identifier is asked before the verifier; +// FirstFrameAssembler reads EXACTLY one frame off a non-blocking socket, a few bytes at a time, +// and never a byte past it: a DataBind is followed on its connection by +// StreamLink's own frames, which belong to the session child; +// AuthBackoff failures per peer address, and the exponentially growing window during +// which that address is refused at accept, before a byte of it is read. +// +// What a failure IS, for the backoff: a Hello whose token did not match, a first frame that is not +// a well-formed control frame, and a connection that let the deadline pass. What it is NOT: a +// connection that closed before sending a byte (a port probe costs the server an accept and +// nothing else), a DataBind that names no live session (a stale data connection of a session that +// just ended is a legitimate client's), and a refusal the backoff itself issued (counting those +// would let anybody behind a shared address keep that address locked out for ever). + +#pragma once + +#include "../Protocol/generated/protocol_generated.h" +#include "../Transport/AuthToken.h" +#include "../Transport/Framing.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#if !defined(_WIN32) +#include +#include +#endif + +namespace MobileGL::MG_Remote::Server { + + // A first frame is a Hello or a DataBind, both a few hundred bytes. The old per-child read + // allowed 1 MiB and so does this; the point of the bound is that it is checked against the + // HEADER, before anything is allocated for the payload. + inline constexpr std::uint64_t kPreAuthFirstFrameMaxBytes = 1024 * 1024; + // The backoff window never grows past this (unless the base itself is larger). + inline constexpr std::uint32_t kAuthBackoffCapMs = 60 * 1000; + // Addresses tracked at once. A peer rotating through addresses gets no more than the bound on + // pending connections gives everybody; the table only has to not grow without limit. + inline constexpr std::size_t kAuthBackoffTrackedPeers = 256; + // A failure this old no longer counts: the operator who mistyped a token yesterday starts + // from zero today. + inline constexpr std::uint32_t kAuthBackoffForgetMs = 10 * 60 * 1000; + + // The refusal details a peer (and the log) sees. Named once so the supervisor and the unit test + // cannot drift on the words; the TCP lane's scripts mirror them rather than parse them, so a + // rename here reds those controls instead of being followed silently. + inline constexpr const char* kFirstFrameNotAFrame = "first frame is not a control frame"; + inline constexpr const char* kFirstFrameTruncated = "first frame ended before it was complete"; + inline constexpr const char* kFirstFrameNoIdentifier = "first frame carries no CtrlEnvelope identifier"; + inline constexpr const char* kFirstFrameUnverifiable = "first frame did not verify as a CtrlEnvelope"; + inline constexpr const char* kFirstFrameNotAHello = "first control frame is not a verifiable Hello"; + inline constexpr const char* kFirstFrameBadDataBind = "first frame is a DataBind without a 16-byte nonce"; + inline constexpr const char* kPreAuthDeadline = "no authenticated first frame within the pre-auth deadline"; + inline constexpr const char* kPreAuthFull = "too many connections are awaiting authentication"; + inline constexpr const char* kAuthBackoffRefusal = "too many failed authentications from this address; retry later"; + + struct PreAuthKnobs { + // MOBILEGL_IPC_PREAUTH_MS: how long a new connection has to present a complete first + // frame. A real client writes its Hello (or DataBind) the moment it connects. + std::uint32_t deadlineMs = 2000; + // MOBILEGL_IPC_PREAUTH_MAX: connections the supervisor holds unauthenticated at once; one + // more is refused Busy at accept. + std::uint32_t maxPending = 8; + // MOBILEGL_IPC_AUTH_BACKOFF_AFTER: failures from one address before it is refused at + // accept. 0 turns the backoff off (the fuzz arm does, to send many malformed frames from + // one address and still be served afterwards). + std::uint32_t backoffAfter = 5; + // MOBILEGL_IPC_AUTH_BACKOFF_MS: the first window; each further failure doubles it, up to + // kAuthBackoffCapMs. + std::uint32_t backoffBaseMs = 1000; + + // An unset, empty, non-numeric or zero value keeps the default - except + // MOBILEGL_IPC_AUTH_BACKOFF_AFTER, where 0 is the documented "off". + static PreAuthKnobs FromEnvironment() { + PreAuthKnobs knobs; + knobs.deadlineMs = Read("MOBILEGL_IPC_PREAUTH_MS", knobs.deadlineMs, false); + knobs.maxPending = Read("MOBILEGL_IPC_PREAUTH_MAX", knobs.maxPending, false); + knobs.backoffAfter = Read("MOBILEGL_IPC_AUTH_BACKOFF_AFTER", knobs.backoffAfter, true); + knobs.backoffBaseMs = Read("MOBILEGL_IPC_AUTH_BACKOFF_MS", knobs.backoffBaseMs, false); + return knobs; + } + + private: + static std::uint32_t Read(const char* name, std::uint32_t fallback, bool zeroAllowed) { + const char* text = std::getenv(name); + if (text == nullptr || *text == '\0') return fallback; + char* end = nullptr; + const unsigned long long value = std::strtoull(text, &end, 10); + if (end == nullptr || *end != '\0' || value > 0xFFFFFFFFull) return fallback; + if (value == 0 && !zeroAllowed) return fallback; + return static_cast(value); + } + }; + + // What a complete first frame is. The identifier is asked FIRST (plan §1.1 fuzz arm 1 row): + // the generated VerifyCtrlEnvelopeBuffer checks it too, but only as part of a general "did not + // verify", and a peer speaking some other schema deserves the specific answer. msg_as_Hello() + // and msg_as_DataBind() are part of the guard, not consequences of it: flatbuffers verifies a + // NULL union member as valid, so a 24-byte envelope whose tag says Hello carries no Hello. + enum class FirstFrameShape { NoIdentifier, Unverifiable, Hello, DataBind, BadDataBind, NotAHello }; + + inline FirstFrameShape ClassifyFirstFrame(const std::uint8_t* payload, std::size_t size) { + if (payload == nullptr || size < 8 || !::MobileGL::Wire::CtrlEnvelopeBufferHasIdentifier(payload)) + return FirstFrameShape::NoIdentifier; + ::flatbuffers::Verifier verifier(payload, size); + if (!::MobileGL::Wire::VerifyCtrlEnvelopeBuffer(verifier)) return FirstFrameShape::Unverifiable; + const auto* envelope = ::MobileGL::Wire::GetCtrlEnvelope(payload); + if (envelope->msg_type() == ::MobileGL::Wire::CtrlMsg::DataBind) { + const auto* bind = envelope->msg_as_DataBind(); + return bind != nullptr && bind->nonce() != nullptr && + bind->nonce()->size() == Transport::kDataNonceBytes + ? FirstFrameShape::DataBind + : FirstFrameShape::BadDataBind; + } + if (envelope->msg_type() == ::MobileGL::Wire::CtrlMsg::Hello && envelope->msg_as_Hello() != nullptr) + return FirstFrameShape::Hello; + return FirstFrameShape::NotAHello; + } + + // The detail a malformed shape is refused with (Refuse{MalformedHello}); nullptr for the two + // shapes that are not malformed. + inline const char* FirstFrameRefusalDetail(FirstFrameShape shape) { + switch (shape) { + case FirstFrameShape::NoIdentifier: return kFirstFrameNoIdentifier; + case FirstFrameShape::Unverifiable: return kFirstFrameUnverifiable; + case FirstFrameShape::BadDataBind: return kFirstFrameBadDataBind; + case FirstFrameShape::NotAHello: return kFirstFrameNotAHello; + case FirstFrameShape::Hello: + case FirstFrameShape::DataBind: return nullptr; + } + return kFirstFrameNotAHello; + } + + // Failures per peer address and the window each address is refused for. + class AuthBackoff { + public: + using Clock = std::chrono::steady_clock; + + explicit AuthBackoff(const PreAuthKnobs& knobs) : m_after(knobs.backoffAfter), m_baseMs(knobs.backoffBaseMs) {} + + // 0 when `address` may try now; otherwise the milliseconds until it may. + std::uint32_t RetryAfterMs(const std::string& address, Clock::time_point now) const { + if (m_after == 0) return 0; + const auto found = m_peers.find(address); + if (found == m_peers.end() || now >= found->second.until) return 0; + const auto left = std::chrono::duration_cast(found->second.until - now).count(); + return static_cast(std::max(1, left)); + } + + // Counts one failure from `address` and returns the window now in force for it (0 while it + // is still under the threshold, and always 0 with the backoff off). + std::uint32_t NoteFailure(const std::string& address, Clock::time_point now) { + auto found = m_peers.find(address); + if (found == m_peers.end()) { + if (m_peers.size() >= kAuthBackoffTrackedPeers) EvictOne(now); + found = m_peers.emplace(address, Entry{}).first; + } + Entry& entry = found->second; + if (entry.failures != 0 && now - entry.last > std::chrono::milliseconds(kAuthBackoffForgetMs)) + entry.failures = 0; + if (entry.failures != 0xFFFFFFFFu) ++entry.failures; + entry.last = now; + if (m_after == 0 || entry.failures < m_after) return 0; + const std::uint32_t window = WindowFor(entry.failures); + entry.until = now + std::chrono::milliseconds(window); + return window; + } + + // An authenticated Hello from `address`: its count starts again from zero. + void NoteSuccess(const std::string& address) { m_peers.erase(address); } + + std::uint32_t Failures(const std::string& address) const { + const auto found = m_peers.find(address); + return found == m_peers.end() ? 0u : found->second.failures; + } + std::size_t Tracked() const { return m_peers.size(); } + + // base << (failures - after), capped. Public so the unit test states the arithmetic. + std::uint32_t WindowFor(std::uint32_t failures) const { + const std::uint32_t cap = std::max(kAuthBackoffCapMs, m_baseMs); + const std::uint32_t shift = failures <= m_after ? 0u : std::min(failures - m_after, 31u); + const std::uint64_t window = static_cast(m_baseMs) << shift; + return static_cast(std::min(window, cap)); + } + + private: + struct Entry { + std::uint32_t failures = 0; + Clock::time_point last{}; + Clock::time_point until{}; + }; + + // Drops the entry whose last failure is oldest; one whose window has run out goes first. + void EvictOne(Clock::time_point now) { + auto victim = m_peers.end(); + for (auto it = m_peers.begin(); it != m_peers.end(); ++it) { + const bool expired = it->second.until <= now; + if (victim == m_peers.end()) { victim = it; continue; } + const bool victimExpired = victim->second.until <= now; + if (expired != victimExpired) { + if (expired) victim = it; + continue; + } + if (it->second.last < victim->second.last) victim = it; + } + if (victim != m_peers.end()) m_peers.erase(victim); + } + + std::uint32_t m_after; + std::uint32_t m_baseMs; + std::unordered_map m_peers; + }; + +#if !defined(_WIN32) + // Reads exactly one [u32 'MGLF'][u32 length][payload] frame off a NON-BLOCKING read of `fd`, + // across as many calls as the peer takes, and never a byte beyond it. + class FirstFrameAssembler { + public: + enum class Step { + NeedMore, // nothing wrong; the frame is not complete yet + Complete, // Payload() is the whole first frame + NotAFrame, // wrong magic, or a length over kPreAuthFirstFrameMaxBytes + EndedEmpty, // the peer closed without sending a byte + EndedPartial, // the peer closed (or the read failed) inside the frame + }; + + Step Pump(int fd) { + for (;;) { + if (m_have < Transport::kFrameHeaderSize) { + const auto step = ReadInto(fd, m_header + m_have, Transport::kFrameHeaderSize - m_have); + if (step != Step::Complete) return step; + if (m_have < Transport::kFrameHeaderSize) continue; + std::uint32_t magic = 0, length = 0; + std::memcpy(&magic, m_header, sizeof(magic)); + std::memcpy(&length, m_header + 4, sizeof(length)); + if (magic != Transport::kFrameMagic || length > kPreAuthFirstFrameMaxBytes) return Step::NotAFrame; + m_payload.resize(length); + if (length == 0) return Step::Complete; + continue; + } + const std::size_t done = static_cast(m_have - Transport::kFrameHeaderSize); + if (done == m_payload.size()) return Step::Complete; + const auto step = ReadInto(fd, m_payload.data() + done, m_payload.size() - done); + if (step != Step::Complete) return step; + } + } + + const std::vector& Payload() const { return m_payload; } + std::uint64_t BytesSeen() const { return m_have; } + + private: + // Complete = some bytes were read (the caller loops to see whether that finished anything). + Step ReadInto(int fd, std::uint8_t* into, std::size_t size) { + for (;;) { + const ssize_t n = ::recv(fd, into, size, MSG_DONTWAIT); + if (n > 0) { + m_have += static_cast(n); + return Step::Complete; + } + if (n < 0 && errno == EINTR) continue; + if (n < 0 && (errno == EAGAIN || errno == EWOULDBLOCK)) return Step::NeedMore; + return m_have == 0 ? Step::EndedEmpty : Step::EndedPartial; + } + } + + std::uint8_t m_header[Transport::kFrameHeaderSize] = {}; + std::uint64_t m_have = 0; + std::vector m_payload; + }; +#endif + +} // namespace MobileGL::MG_Remote::Server diff --git a/MobileGL/MG_Remote/Server/ServerMain.cpp b/MobileGL/MG_Remote/Server/ServerMain.cpp index d1e5c281..960eaceb 100644 --- a/MobileGL/MG_Remote/Server/ServerMain.cpp +++ b/MobileGL/MG_Remote/Server/ServerMain.cpp @@ -7,6 +7,7 @@ #include "../Protocol/SurfaceOpCodec.h" #include "../Handshake.h" #include "../FatalFunnel.h" +#include "PreAuthGate.h" #include "ServerLoop.h" #include "ServerSession.h" #include "SurfaceControlFrame.h" @@ -25,6 +26,8 @@ #include #include #include +#include +#include #include #include #include @@ -64,31 +67,11 @@ void RefuseBusy(SocketTransport& transport, const char* detail) { Refuse(transport, Protocol::RefuseCode::Busy, detail); } -// PH-7 (4), ID-P7-3. THE DATA CONNECTION IS ROUTED BY WHAT IT SAYS, NOT BY WHEN IT CAME. -// -// On TCP the supervisor accepts ONE connection at a time. While no session is live, a -// connection is a session's control connection and goes to a fresh child. While one is, the -// connection's first frame decides: a DataBind is the live session's data connection and its -// descriptor goes to that child over the hand-off socketpair, with the nonce it presented as -// the sideband; anything else is a second client and is refused Busy exactly as before. The -// supervisor never judges the nonce - ServerSession::BindDataConnection compares it, once, in -// the child that minted it. This replaces AcceptPair's rule that the second connection to -// arrive inside the window IS the data plane: over a Windows `adb forward` the two arrived -// reordered, the data connection was read as control, and every child exited 67. -// -// THE HAND-OFF HAS ONE READER, AND IT STOPS READING (F fix round, review of slices 1-3). The -// child reads the hand-off only inside BindDataConnection; once its data connection is bound -// it never reads it again. The first shape of this function kept forwarding every later -// DataBind regardless: each descriptor sat in the child's receive queue with no reply, its TCP -// connection was held open until the session ended, and once the queue reached -// net.unix.max_dgram_qlen (10 on the phone's kernel, 512 on a desktop's) the supervisor BLOCKED -// in sendmsg for the rest of the session - no accepts, no Busy refusals, no reaping. Two halves -// close it: the child closes its end of the pair the moment Accept returns (RunSession), so the -// send fails with "peer gone" and the refusal below fires by name; and the send is MSG_DONTWAIT, -// so a queue that is full for any other reason refuses too rather than stalling the one process -// that answers the port. +// The single-session TCP shape's wait for its data connection's DataBind (ListenerSource). constexpr std::uint32_t kFirstFrameWaitMs = 2000; -constexpr std::uint64_t kFirstFrameMaxBytes = 1024 * 1024; +constexpr std::uint64_t kFirstFrameMaxBytes = Server::kPreAuthFirstFrameMaxBytes; +// The detail a data connection the live session's hand-off will not take is refused with. +constexpr const char* kHandoffRefused = "data connection could not be handed to the live session"; // Whatever the peer has already sent, read and dropped (non-blocking, at most 1 MiB), so a close // with bytes still unread does not become an RST that discards the refusal in flight - the @@ -100,35 +83,25 @@ void DrainUnread(int fd) { } } -void RouteWhileBusy(SocketTransport& connection, int handoff) { - std::vector frame; - const int fd = connection.StreamFd(); - const auto read = SocketTransport::ReceiveOneFrame(fd, kFirstFrameWaitMs, kFirstFrameMaxBytes, &frame); - if (read == MOBILEGL_ERR_PROTOCOL_MISMATCH) { - // (F fix round) A first frame with the wrong magic or a length over 1 MiB is not a second - // client waiting its turn; it is not a control frame at all, and Busy would name a - // condition this connection does not have. The word is the one ServerSession::Accept uses - // for a first frame that is not a verifiable Hello. - DrainUnread(fd); - Refuse(connection, Protocol::RefuseCode::MalformedHello, "first frame is not a control frame"); - return; - } - std::uint8_t nonce[kDataNonceBytes] = {}; - if (read == MOBILEGL_OK && DecodeDataBind(frame, nonce)) { - if (handoff >= 0 && - FdPassing::SendFd(handoff, fd, MobileGLByteSpan{nonce, sizeof(nonce)}, /*dontWait=*/true) == MOBILEGL_OK) { - // The child holds its own descriptor for this connection now; ours goes without a - // shutdown(2), which would disconnect the child's too. - connection.CloseLocalCopy(); - return; - } - Refuse(connection, Protocol::RefuseCode::Authentication, - "data connection could not be handed to the live session"); - return; - } - // The frame (if any) is consumed, so closing cannot RST the refusal away - the reason - // RefuseBusy reads the Hello first. - Refuse(connection, Protocol::RefuseCode::Busy, "one session is already active"); +// Refuses a connection the supervisor still holds as a bare descriptor, and closes it. +void RefuseAndClose(int fd, Protocol::RefuseCode code, const char* detail) { + SocketTransport connection(fd, -1, TransportRole::Server); + DrainUnread(fd); + Refuse(connection, code, detail); +} + +// The numeric address of a TCP peer, without its port: the backoff's key. A peer whose address +// cannot be read is keyed "?", so it is still counted, just not on its own. +std::string PeerAddress(int fd) { + sockaddr_storage address{}; + socklen_t size = sizeof(address); + if (::getpeername(fd, reinterpret_cast(&address), &size) != 0) return "?"; + char text[INET6_ADDRSTRLEN] = {}; + const void* raw = nullptr; + if (address.ss_family == AF_INET) raw = &reinterpret_cast(&address)->sin_addr; + else if (address.ss_family == AF_INET6) raw = &reinterpret_cast(&address)->sin6_addr; + if (raw == nullptr || ::inet_ntop(address.ss_family, raw, text, sizeof(text)) == nullptr) return "?"; + return text; } // True when the control peer has closed (EOF or error); false while it is open, readable or not. @@ -203,38 +176,64 @@ void SendLogAck(void* pointer) { (void)ack.transport->SendFrame({builder.GetBufferPointer(), builder.GetSize()}); } -// Called only after fork: the supervisor never creates a backend or EGL context. -// `dataSource` is set on TCP (PH-7 (4)) and empty on a unix endpoint, whose second connection -// is the SCM_RIGHTS socket AcceptPair already paired. `sourceFd` is the descriptor that source -// reads from - the child's end of the hand-off socketpair under --serve, the listener itself in -// the single-session shape - and -1 when there is none; it is closed the moment Accept returns. -[[noreturn]] void RunSession(std::unique_ptr control, - Server::ServerSession::DataConnectionSource dataSource, int sourceFd) { +// Called only after fork (or, in the single-session shape, instead of it): the supervisor never +// creates a backend or EGL context. `dataSource` is set on TCP (PH-7 (4)) and empty on a unix +// endpoint, whose second connection is the SCM_RIGHTS socket AcceptPair already paired. +// `sourceFd` is the descriptor that source reads from - the child's end of the hand-off +// socketpair under --serve, the listener itself in the single-session shape - and -1 when there +// is none; it is let go of the moment Accept returns. +// +// `helloFrame` is the first frame when somebody has already read and authenticated it - the TCP +// supervisor always has (PH-7 (5)) - and empty when this function reads it itself, within +// `helloWaitMs`. +[[noreturn]] void RunSession(std::unique_ptr control, std::vector helloFrame, + Server::ServerSession::DataConnectionSource dataSource, int sourceFd, + std::uint32_t helloWaitMs) { const int selfPid = static_cast(::getpid()); const bool tcp = control->IsTcp(); - std::vector helloFrame; - std::uint64_t bytes = 0; - auto received = control->ReceiveFrame({nullptr, 0}, &bytes, 10000); - if (received != MOBILEGL_ERR_BUFFER_TOO_SMALL || bytes > 1024 * 1024) ::_exit(67); - helloFrame.resize(static_cast(bytes)); - if (control->ReceiveFrame({helloFrame.data(), helloFrame.size()}, &bytes, 0) != MOBILEGL_OK) ::_exit(67); - flatbuffers::Verifier verifier(helloFrame.data(), helloFrame.size()); - if (!Protocol::VerifyCtrlEnvelopeBuffer(verifier)) ::_exit(67); + if (helloFrame.empty()) { + // Every way this read can go wrong is the PEER's (silence, a stream that is not ours, a + // close), so each is answered by name where the peer is still there to read it and is a + // clean exit rather than a counted fault (PH-7 (5)). + std::uint64_t bytes = 0; + const auto received = control->ReceiveFrame({nullptr, 0}, &bytes, helloWaitMs); + if (received == MOBILEGL_ERR_TIMEOUT) { + Refuse(*control, Protocol::RefuseCode::Authentication, Server::kPreAuthDeadline); + std::fflush(nullptr); + ::_exit(0); + } + if (received == MOBILEGL_ERR_PROTOCOL_MISMATCH || + (received == MOBILEGL_ERR_BUFFER_TOO_SMALL && bytes > Server::kPreAuthFirstFrameMaxBytes)) { + DrainUnread(control->StreamFd()); + Refuse(*control, Protocol::RefuseCode::MalformedHello, Server::kFirstFrameNotAFrame); + std::fflush(nullptr); + ::_exit(0); + } + if (received != MOBILEGL_ERR_BUFFER_TOO_SMALL) ::_exit(0); + helloFrame.resize(static_cast(bytes)); + if (control->ReceiveFrame({helloFrame.data(), helloFrame.size()}, &bytes, 0) != MOBILEGL_OK) ::_exit(0); + } + // THE FIRST FRAME, CLASSIFIED BY THE SAME FUNCTION THE TCP SUPERVISOR USES (plan §1.1, fuzz + // arm 1 row: "ServerMain.cpp:193 CtrlEnvelopeBufferHasIdentifier"). The identifier is asked + // first, and every malformed shape is refused BY NAME - this read used to `_exit(67)` on all + // of them, so a peer that sent garbage got a bare close and the supervisor counted a fault. + const auto shape = Server::ClassifyFirstFrame(helloFrame.data(), helloFrame.size()); // PH-7 (4). A DataBind with no live session to join: the supervisor routes a data connection // to a child only while one is live, so this is a stale connection (its session already // ended) or one that never had a session. Refused by name, and the child is a clean exit - - // it is a refusal, not a fault. - if (Protocol::GetCtrlEnvelope(helloFrame.data())->msg_type() == Protocol::CtrlMsg::DataBind) { + // it is a refusal, not a fault. (On TCP --serve the supervisor answers this itself now; this + // arm is the single-session shape's.) + if (shape == Server::FirstFrameShape::DataBind) { Refuse(*control, Protocol::RefuseCode::Authentication, "data connection names no live session"); std::fflush(nullptr); ::_exit(0); } + if (shape != Server::FirstFrameShape::Hello) { + Refuse(*control, Protocol::RefuseCode::MalformedHello, Server::FirstFrameRefusalDetail(shape)); + std::fflush(nullptr); + ::_exit(0); + } const auto* hello = Protocol::GetCtrlEnvelope(helloFrame.data())->msg_as_Hello(); - if (!hello) ::_exit(67); - // Reject incompatible wire before backend bring-up can obscure the cause. - if (ValidatePeerHandshake(*control, hello->abiMajor(), hello->abiMinor(), - hello->wireFingerprint(), hello->buildFingerprint() ? hello->buildFingerprint()->c_str() : nullptr, - hello->dialMode()) != MOBILEGL_OK) ::_exit(0); // PH-7 (1). The SAME policy ServerSession::Accept applies, from Handshake.h, so the // supervisor's pre-fork answer and the session's post-fork answer cannot drift. What went: // a `std::string` comparison (byte-at-a-time, early-returning) that ALSO refused a peer for @@ -242,7 +241,18 @@ void SendLogAck(void* pointer) { // unix control socket exempt by construction rather than by the policy's own reasoning. // RefuseHandshake logs `Refuse{Authentication}` and sends the peer the frame; the supervisor's // local Refuse() helper stays for the refusals that are its own (Busy, backend). + // + // PH-7 (5) (ph-f.md §6.4 (b)): the token is asked FIRST. ValidatePeerHandshake used to run + // before it, so an unauthenticated peer with a wrong fingerprint was answered + // Refuse{WireFingerprint} carrying this build's wire fingerprint - and Refuse{BuildFingerprint} + // this build's stamp. An unauthenticated peer now learns only Refuse{Authentication}. On TCP + // --serve the supervisor has already asked (the answer is the same; it is asked again so this + // function holds the rule on its own for the unix and single-session shapes). if (AuthenticatePeerToken(*control, hello->token()) != MOBILEGL_OK) ::_exit(0); + // Reject incompatible wire before backend bring-up can obscure the cause. + if (ValidatePeerHandshake(*control, hello->abiMajor(), hello->abiMinor(), + hello->wireFingerprint(), hello->buildFingerprint() ? hello->buildFingerprint()->c_str() : nullptr, + hello->dialMode()) != MOBILEGL_OK) ::_exit(0); MobileGL::MG_ConfigLoader::Init(); MobileGL::MG_Config::Transport = MobileGL::MG_Config::TransportMode::Spawn; MobileGL::MG_Pipe::MGPipeSetServerProcessRole(true); @@ -555,6 +565,280 @@ void LogSupervisorSummary(unsigned long sessionsFaulted) { WireLogError("MG_Remote server: supervisor pid=%d shutting down sessionsFaulted=%lu", static_cast(::getpid()), sessionsFaulted); } + +// PH-7 (5), ID-P7-3, ph-f.md §6.4, CONTRACT-P7 §12. THE TCP SUPERVISOR: AUTHENTICATE, THEN FORK. +// +// Until this package the --serve loop accepted a TCP connection and forked a session child for +// it before a byte had been read; the child read the Hello (10 s budget), checked the wire and +// only then the token. So anybody who could reach the port cost the server a fork per connection +// and a child held for up to 10 s, and an unauthenticated Hello with a wrong fingerprint was +// answered with this build's fingerprint. Now: +// +// - every new connection is PENDING until its first frame is complete. The supervisor reads it +// itself, a few bytes per wakeup, from all pending connections at once (one poll loop - no +// peer can make it wait for another), and never a byte past the frame; +// - a pending connection has MOBILEGL_IPC_PREAUTH_MS (default 2000) to complete it, and at most +// MOBILEGL_IPC_PREAUTH_MAX (default 8) are pending at once - one more is refused Busy at accept; +// - a Hello is AUTHENTICATED HERE, token first; an unauthenticated one is answered +// Refuse{Authentication} and nothing else. Only an authenticated Hello is forked for, and the +// child is handed the frame it would otherwise have read; +// - a failure (wrong token, malformed first frame, deadline) is counted against the peer's +// address; after MOBILEGL_IPC_AUTH_BACKOFF_AFTER (default 5) of them that address is refused +// at accept, before a byte is read, for MOBILEGL_IPC_AUTH_BACKOFF_MS doubling per further +// failure up to a minute. An authenticated Hello from the address clears it; +// - a DataBind is routed exactly as PH-7 (4) routed it: to the live session's child over the +// hand-off with the nonce as sideband (the child compares), or refused by name when there is +// no live session - here now, instead of in a child forked to say so. +// +// What is unchanged: one session at a time (an AUTHENTICATED second client is Busy), the child's +// own ValidatePeerHandshake / Accept / BindDataConnection, the hand-off and its refusals, the +// reap lines and sessionsFaulted. +class TcpSupervisor { +public: + using Clock = std::chrono::steady_clock; + + TcpSupervisor(int listener, const Server::PreAuthKnobs& knobs, unsigned long& sessionsFaulted) + : m_listener(listener), m_knobs(knobs), m_backoff(knobs), m_sessionsFaulted(sessionsFaulted) {} + + // Returns only on a failure of the listener itself (73, as the old loop did). + int Run() { + WireLogError("MG_Remote server: pre-auth deadline=%u ms, at most %u pending, backoff after %u failures " + "from %u ms (0 = off)", + m_knobs.deadlineMs, m_knobs.maxPending, m_knobs.backoffAfter, m_knobs.backoffBaseMs); + std::vector fds; + for (;;) { + Reap(); + fds.clear(); + fds.push_back({m_listener, POLLIN, 0}); + for (const auto& peer : m_pending) fds.push_back({peer.fd, POLLIN, 0}); + auto now = Clock::now(); + // 250 ms is the old accept timeout, i.e. how often a finished child is reaped while + // nothing arrives; a pending deadline that falls sooner shortens it. + std::int64_t timeout = 250; + for (const auto& peer : m_pending) + timeout = std::clamp( + std::chrono::duration_cast(peer.deadline - now).count(), 0, timeout); + const int polled = ::poll(fds.data(), static_cast(fds.size()), static_cast(timeout)); + if (polled < 0 && errno != EINTR) { + WireLogError("MG_Remote server: supervisor poll failed: %s", std::strerror(errno)); + return 73; + } + now = Clock::now(); + std::vector waiting; + for (std::size_t index = 0; index < m_pending.size(); ++index) { + PendingPeer& peer = m_pending[index]; + if (polled > 0 && fds[index + 1].revents != 0) { + switch (peer.frame.Pump(peer.fd)) { + case Step::NeedMore: + break; + case Step::Complete: + m_ready.push_back(std::move(peer)); + continue; + case Step::NotAFrame: + // Wrong magic, or a length over 1 MiB: refused on the HEADER, before a + // byte of payload is allocated for. + RefuseAndClose(peer.fd, Protocol::RefuseCode::MalformedHello, Server::kFirstFrameNotAFrame); + NoteFailure(peer.address, "not a control frame"); + continue; + case Step::EndedEmpty: + // A port probe: it cost an accept and nothing else. Not a failure. + ::close(peer.fd); + continue; + case Step::EndedPartial: + RefuseAndClose(peer.fd, Protocol::RefuseCode::MalformedHello, Server::kFirstFrameTruncated); + NoteFailure(peer.address, "truncated first frame"); + continue; + } + } + if (now >= peer.deadline) { + RefuseAndClose(peer.fd, Protocol::RefuseCode::Authentication, Server::kPreAuthDeadline); + NoteFailure(peer.address, "pre-auth deadline"); + continue; + } + waiting.push_back(std::move(peer)); + } + m_pending.swap(waiting); + for (auto& peer : m_ready) Judge(peer); + m_ready.clear(); + if (polled > 0 && (fds[0].revents & (POLLIN | POLLERR | POLLHUP)) != 0) { + const int failed = AcceptNew(now); + if (failed != 0) return failed; + } + } + } + +private: + using Step = Server::FirstFrameAssembler::Step; + + struct PendingPeer { + int fd = -1; + std::string address; + Clock::time_point deadline{}; + Server::FirstFrameAssembler frame; + }; + + bool Reap() { + const bool reaped = ReapActiveSession(m_active, m_sessionsFaulted); + if (m_active <= 0 && m_handoff >= 0) { ::close(m_handoff); m_handoff = -1; } + return reaped; + } + + // exit_group closes files just before waitpid can observe the exit. Allow that small + // scheduling window before deciding whether a session is live - the old loop gave it to every + // connection it accepted while a child existed; this gives it to the two decisions that + // depend on the answer (a DataBind's routing, an authenticated Hello's Busy). + void WaitOutAnExitingSession() { + const auto reapDeadline = Clock::now() + std::chrono::milliseconds(50); + while (m_active > 0 && Clock::now() < reapDeadline) { + if (Reap()) break; + ::usleep(1000); + } + } + + void NoteFailure(const std::string& address, const char* why) { + const std::uint32_t window = m_backoff.NoteFailure(address, Clock::now()); + if (window != 0) + WireLogError("MG_Remote server: %s failed pre-auth %u times (last: %s); refusing it at accept for %u ms", + address.c_str(), m_backoff.Failures(address), why, window); + } + + // Accepts everything the listener has, deciding at accept what can be decided without reading. + int AcceptNew(Clock::time_point now) { + for (int burst = 0; burst < 64; ++burst) { + int fd = -1; + const auto accepted = SocketTransport::AcceptOne(m_listener, 0, &fd); + if (accepted == MOBILEGL_ERR_TIMEOUT) return 0; + if (accepted != MOBILEGL_OK) return 73; + std::string address = PeerAddress(fd); + if (const std::uint32_t retry = m_backoff.RetryAfterMs(address, now); retry != 0) { + WireLogError("MG_Remote server: connection from %s refused at accept: authentication backoff, " + "%u ms left", address.c_str(), retry); + RefuseAndClose(fd, Protocol::RefuseCode::Authentication, Server::kAuthBackoffRefusal); + continue; + } + if (m_pending.size() >= m_knobs.maxPending) { + WireLogError("MG_Remote server: connection from %s refused at accept: %zu connections are " + "already awaiting authentication", address.c_str(), m_pending.size()); + RefuseAndClose(fd, Protocol::RefuseCode::Busy, Server::kPreAuthFull); + continue; + } + PendingPeer peer; + peer.fd = fd; + peer.address = std::move(address); + peer.deadline = now + std::chrono::milliseconds(m_knobs.deadlineMs); + m_pending.push_back(std::move(peer)); + } + return 0; + } + + // A complete first frame. Every refusal here is by name; only an authenticated Hello forks. + void Judge(PendingPeer& peer) { + auto connection = std::make_unique(peer.fd, -1, TransportRole::Server); + const int fd = peer.fd; + peer.fd = -1; // `connection` owns it now + const auto& payload = peer.frame.Payload(); + const auto shape = Server::ClassifyFirstFrame(payload.data(), payload.size()); + if (shape == Server::FirstFrameShape::DataBind) { + // PH-7 (4). While a session is live, its data connection goes to its child with the + // nonce it presented (the child compares, once); with none live there is nobody it + // could belong to. Neither is an authentication failure: a data connection left over + // from a session that just ended is a legitimate client's. + std::uint8_t nonce[kDataNonceBytes] = {}; + (void)DecodeDataBind(payload, nonce); + WaitOutAnExitingSession(); + if (m_active <= 0) { + // StreamLink's frames may already follow the DataBind; read them away so the close + // does not become an RST that discards the refusal. + DrainUnread(fd); + Refuse(*connection, Protocol::RefuseCode::Authentication, "data connection names no live session"); + return; + } + // The send is MSG_DONTWAIT and the child closes its end the moment Accept returns + // (RunSession), so this either queues the descriptor for the child or fails and is + // refused here. + if (m_handoff >= 0 && FdPassing::SendFd(m_handoff, fd, MobileGLByteSpan{nonce, sizeof(nonce)}, + /*dontWait=*/true) == MOBILEGL_OK) { + // The child holds its own descriptor now; ours goes without a shutdown(2), which + // would disconnect the child's too. + connection->CloseLocalCopy(); + return; + } + DrainUnread(fd); + Refuse(*connection, Protocol::RefuseCode::Authentication, kHandoffRefused); + return; + } + if (shape != Server::FirstFrameShape::Hello) { + DrainUnread(fd); + Refuse(*connection, Protocol::RefuseCode::MalformedHello, Server::FirstFrameRefusalDetail(shape)); + NoteFailure(peer.address, "malformed first frame"); + return; + } + const auto* hello = Protocol::GetCtrlEnvelope(payload.data())->msg_as_Hello(); + // The token FIRST, and the only answer an unauthenticated peer gets is this one + // (RefuseHandshake: Refuse{Authentication} "token mismatch", no fingerprint, no stamp). + if (AuthenticatePeerToken(*connection, hello->token()) != MOBILEGL_OK) { + // Anything the peer sent after its Hello is read away before the close, so the close + // cannot become an RST that discards the refusal already in flight. + DrainUnread(fd); + WireLogError("MG_Remote server: unauthenticated Hello from %s refused before any session work", + peer.address.c_str()); + NoteFailure(peer.address, "token mismatch"); + return; + } + m_backoff.NoteSuccess(peer.address); + WaitOutAnExitingSession(); + if (m_active > 0) { + // The frame is consumed, so closing cannot RST the refusal away. + Refuse(*connection, Protocol::RefuseCode::Busy, "one session is already active"); + return; + } + Fork(std::move(connection), payload); + } + + void Fork(std::unique_ptr connection, const std::vector& hello) { + int pair[2] = {-1, -1}; + if (FdPassing::CreateSocketPair(pair) != MOBILEGL_OK) { + Refuse(*connection, Protocol::RefuseCode::Busy, "could not create the data-connection hand-off"); + return; + } + std::fflush(nullptr); + const pid_t child = ::fork(); + if (child == 0) { + // The child owns this one connection and its end of the hand-off. Every OTHER pending + // peer's descriptor is closed here, or the supervisor's refusal of that peer would not + // end its connection while this session lives. + ::close(m_listener); + ::close(pair[0]); + for (const auto& other : m_pending) + if (other.fd >= 0) ::close(other.fd); + for (const auto& other : m_ready) + if (other.fd >= 0) ::close(other.fd); + const int controlFd = connection->StreamFd(); + RunSession(std::move(connection), hello, HandoffSource(pair[1], controlFd), pair[1], 0); + } + if (child < 0) { + ::close(pair[0]); + ::close(pair[1]); + Refuse(*connection, Protocol::RefuseCode::Busy, "could not create session child"); + return; + } + m_active = child; + ::close(pair[1]); + m_handoff = pair[0]; + connection->CloseLocalCopy(); + } + + int m_listener; + Server::PreAuthKnobs m_knobs; + Server::AuthBackoff m_backoff; + unsigned long& m_sessionsFaulted; + pid_t m_active = -1; + // The supervisor's end of the live child's data-connection hand-off (PH-7 (4)); -1 while no + // session is live. It goes with the child: a reaped session can be handed nothing. + int m_handoff = -1; + std::vector m_pending; + std::vector m_ready; +}; } extern "C" __attribute__((visibility("default"))) int mobilegl_server_main(int argc, char** argv) { @@ -598,24 +882,27 @@ extern "C" __attribute__((visibility("default"))) int mobilegl_server_main(int a int listener = -1; if (SocketTransport::Listen(endpoint, &listener) != MOBILEGL_OK) return 72; WireLogError("MG_Remote server: pid=%d listening on %s", static_cast(::getpid()), endpoint.c_str()); - pid_t active = -1; unsigned long sessionsFaulted = 0; const bool tcpEndpoint = endpoint.compare(0, 6, "tcp://") == 0; - // The supervisor's end of the live child's data-connection hand-off (PH-7 (4)); -1 while no - // session is live. It goes with the child: a reaped session can be handed nothing. - int handoff = -1; - const auto reap = [&]() { - const bool reaped = ReapActiveSession(active, sessionsFaulted); - if (active <= 0 && handoff >= 0) { ::close(handoff); handoff = -1; } - return reaped; - }; + const auto knobs = Server::PreAuthKnobs::FromEnvironment(); + if (tcpEndpoint && serve) { + // PH-7 (5): the TCP supervisor reads and authenticates every first frame before it forks. + TcpSupervisor supervisor(listener, knobs, sessionsFaulted); + const int failed = supervisor.Run(); + LogSupervisorSummary(sessionsFaulted); + ::close(listener); + return failed; + } + // From here: the unix endpoint (--serve or not), and the single-session TCP shape. + pid_t active = -1; + const auto reap = [&]() { return ReapActiveSession(active, sessionsFaulted); }; for (;;) { reap(); std::unique_ptr control; MobileGLResult accepted = MOBILEGL_ERR_UNSUPPORTED; if (tcpEndpoint) { int fd = -1; - accepted = SocketTransport::AcceptOne(listener, serve ? 250 : 30000, &fd); + accepted = SocketTransport::AcceptOne(listener, 30000, &fd); if (accepted == MOBILEGL_OK) control = std::make_unique(fd, -1, TransportRole::Server); } else { accepted = SocketTransport::AcceptPair(listener, serve ? 250 : 30000, control); @@ -625,13 +912,16 @@ extern "C" __attribute__((visibility("default"))) int mobilegl_server_main(int a if (!serve) { if (tcpEndpoint) { // The listener stays open until this session's data connection has arrived on - // it; RunSession closes it the moment Accept returns. + // it; RunSession closes it the moment Accept returns. The process IS the session, + // so there is no fork to spare; the Hello is still read within the pre-auth + // deadline and the token is still asked first. const int controlFd = control->StreamFd(); - RunSession(std::move(control), ListenerSource(listener, controlFd), listener); + RunSession(std::move(control), {}, ListenerSource(listener, controlFd), listener, + knobs.deadlineMs); } ::close(listener); if (endpoint[0] != '@') ::unlink(endpoint.c_str()); - RunSession(std::move(control), {}, -1); + RunSession(std::move(control), {}, {}, -1, 10000); } reap(); // exit_group closes files just before waitpid can observe the exit. @@ -642,34 +932,20 @@ extern "C" __attribute__((visibility("default"))) int mobilegl_server_main(int a ::usleep(1000); } if (active > 0) { - if (tcpEndpoint) RouteWhileBusy(*control, handoff); - else RefuseBusy(*control, "one session is already active"); - continue; - } - int pair[2] = {-1, -1}; - if (tcpEndpoint && FdPassing::CreateSocketPair(pair) != MOBILEGL_OK) { - RefuseBusy(*control, "could not create the data-connection hand-off"); + RefuseBusy(*control, "one session is already active"); continue; } std::fflush(nullptr); const pid_t child = ::fork(); if (child == 0) { ::close(listener); - if (pair[0] >= 0) ::close(pair[0]); - const int controlFd = control->StreamFd(); - RunSession(std::move(control), tcpEndpoint ? HandoffSource(pair[1], controlFd) - : Server::ServerSession::DataConnectionSource{}, - pair[1]); + RunSession(std::move(control), {}, {}, -1, 10000); } if (child < 0) { - if (pair[0] >= 0) ::close(pair[0]); - if (pair[1] >= 0) ::close(pair[1]); RefuseBusy(*control, "could not create session child"); continue; } active = child; - if (pair[1] >= 0) ::close(pair[1]); - handoff = pair[0]; control->CloseLocalCopy(); } LogSupervisorSummary(sessionsFaulted); diff --git a/MobileGL/MG_Remote/Server/ServerSession.cpp b/MobileGL/MG_Remote/Server/ServerSession.cpp index e1c03099..dd9778d9 100644 --- a/MobileGL/MG_Remote/Server/ServerSession.cpp +++ b/MobileGL/MG_Remote/Server/ServerSession.cpp @@ -635,6 +635,19 @@ namespace MobileGL::MG_Remote::Server { const char* theirStamp = hello->buildFingerprint() == nullptr ? nullptr : hello->buildFingerprint()->c_str(); + // PH-7 (1). One policy, one constant-time comparison, shared with ServerMain's supervisor + // and its session child (Handshake.h AuthenticatePeerToken). This site used std::strcmp, + // which returns at the first differing byte. + // + // PH-7 (5) (ph-f.md §6.4 (b)): and it is asked FIRST. It used to follow the dial-mode, + // version and fingerprint checks, so an unauthenticated peer was answered + // Refuse{WireFingerprint} with this build's wire fingerprint in `expected`, or + // Refuse{BuildFingerprint} - everything a peer needs to know which build it is talking to, + // given to one that has not shown it may talk at all. Nothing the checks below say is + // said to a peer that has not authenticated. + const MobileGLResult authenticated = AuthenticatePeerToken(transport, hello->token()); + if (authenticated != MOBILEGL_OK) return authenticated; + if (hello->dialMode() != ::MobileGL::Wire::DialMode::No && hello->dialMode() != ::MobileGL::Wire::DialMode::Fork && hello->dialMode() != ::MobileGL::Wire::DialMode::Connect) @@ -654,11 +667,6 @@ namespace MobileGL::MG_Remote::Server { if (stream && transport.Role() == Transport::TransportRole::InProcess) return RefuseHandshake(transport, ::MobileGL::Wire::RefuseCode::LinkTerms, "stream requires a data connection"); - // PH-7 (1). One policy, one constant-time comparison, shared with ServerMain's supervisor - // child (Handshake.h AuthenticatePeerToken). This site used std::strcmp, which returns at - // the first differing byte. - const MobileGLResult authenticated = AuthenticatePeerToken(transport, hello->token()); - if (authenticated != MOBILEGL_OK) return authenticated; if (hello->backendType() >= static_cast(BackendType::BackendTypeCount) || (m_backend != nullptr && hello->backendType() != static_cast(m_backend->GetBackendType()))) return RefuseHandshake(transport, ::MobileGL::Wire::RefuseCode::Backend, diff --git a/MobileGL/MG_Test/Wire/CMakeLists.txt b/MobileGL/MG_Test/Wire/CMakeLists.txt index 723708d7..be9bedf4 100644 --- a/MobileGL/MG_Test/Wire/CMakeLists.txt +++ b/MobileGL/MG_Test/Wire/CMakeLists.txt @@ -23,6 +23,10 @@ if (NOT WIN32) # socketpair with real threads, plus the three a stream has and a message queue does not. # POSIX for the same reason as FdPassingTest - and because CONTRACT-P6 §2.6 lands POSIX only. list(APPEND MOBILEGL_WIRE_TESTS SocketTransportTest StreamLinkTest StreamClientPublicationTest InitialCapsStartupTest) + # P7 F2, PH-7 (5) and fuzz arm 1: the TCP supervisor's pre-auth gate (first-frame shapes, the + # exact first-frame reader, the backoff's arithmetic, the knobs). Its reader is a socket read, + # so POSIX for the same reason. + list(APPEND MOBILEGL_WIRE_TESTS PreAuthGateTest) # P6 sm: two MobileGL processes. Needs the real server image beside it, so # the dependency is declared after the loop rather than assumed. list(APPEND MOBILEGL_WIRE_TESTS ServerSpawnTest) diff --git a/MobileGL/MG_Test/Wire/PreAuthGateTest.cpp b/MobileGL/MG_Test/Wire/PreAuthGateTest.cpp new file mode 100644 index 00000000..e44d592f --- /dev/null +++ b/MobileGL/MG_Test/Wire/PreAuthGateTest.cpp @@ -0,0 +1,278 @@ +// MobileGL - MobileGL/MG_Test/Wire/PreAuthGateTest.cpp +// Copyright (c) 2025-2026 MobileGL-Dev +// Licensed under the GNU Lesser General Public License v3.0: +// https://www.gnu.org/licenses/gpl-3.0.txt +// https://www.gnu.org/licenses/lgpl-3.0.txt +// SPDX-License-Identifier: LGPL-3.0-only +// End of Source File Header + +// PH-7 (5) and fuzz arm 1: the TCP supervisor's pre-auth gate, piece by piece. +// +// These are the cheap halves. The end-to-end halves - a live supervisor that must answer every +// malformed first frame by name, fork for nothing it has not authenticated, refuse a silent peer +// at its deadline, cap its pending connections and back an address off - are +// TcpLane.SupervisorProtocolControls (scripts/ci/tcp_supervisor_smoke.py). What is pinned here +// is what +// those scripts cannot see from outside: that each malformation lands on the shape it is named +// for (and not merely on SOME refusal), that the assembler never reads a byte past the first +// frame, and the backoff's arithmetic. + +#include +#include + +#include + +#include +#include +#include +#include +#include +#include + +#include +#include + +using namespace MobileGL::MG_Remote; +using Server::FirstFrameShape; +namespace Wire = ::MobileGL::Wire; + +namespace { + + std::vector Finish(::flatbuffers::FlatBufferBuilder& builder, ::flatbuffers::Offset root, + const char* identifier = Wire::CtrlEnvelopeIdentifier()) { + builder.Finish(root, identifier); + return std::vector(builder.GetBufferPointer(), builder.GetBufferPointer() + builder.GetSize()); + } + + std::vector HelloPayload(const char* identifier = Wire::CtrlEnvelopeIdentifier()) { + ::flatbuffers::FlatBufferBuilder builder(512); + auto terms = Wire::CreateLinkTerms(builder); + auto hello = Wire::CreateHelloDirect(builder, MOBILEGL_PROTOCOL_ABI_MAJOR, MOBILEGL_PROTOCOL_ABI_MINOR, + "build", 0, 1, nullptr, 0, 0, terms, "token", Wire::DialMode::Connect); + return Finish(builder, Wire::CreateCtrlEnvelope(builder, Wire::CtrlMsg::Hello, hello.Union()), identifier); + } + + std::vector DataBindPayload(std::size_t nonceBytes) { + ::flatbuffers::FlatBufferBuilder builder(64); + const std::vector nonce(nonceBytes, 0x5A); + auto bind = Wire::CreateDataBind(builder, builder.CreateVector(nonce)); + return Finish(builder, Wire::CreateCtrlEnvelope(builder, Wire::CtrlMsg::DataBind, bind.Union())); + } + + std::vector Framed(const std::vector& payload) { + std::vector out; + EXPECT_EQ(Transport::AppendFrame(out, payload.data(), payload.size()), MOBILEGL_OK); + return out; + } + + struct SocketPair { + int fds[2] = {-1, -1}; + SocketPair() { EXPECT_EQ(::socketpair(AF_UNIX, SOCK_STREAM, 0, fds), 0); } + ~SocketPair() { + for (int fd : fds) + if (fd >= 0) ::close(fd); + } + void Send(const void* bytes, std::size_t size) { + ASSERT_EQ(::send(fds[0], bytes, size, MSG_NOSIGNAL), static_cast(size)); + } + void CloseWriter() { + ::close(fds[0]); + fds[0] = -1; + } + }; + + struct ScopedEnvironment { + std::string name, previous; + bool had = false; + ScopedEnvironment(const char* key, const char* value) : name(key) { + if (const char* old = std::getenv(key)) { previous = old; had = true; } + if (value) ::setenv(key, value, 1); else ::unsetenv(key); + } + ~ScopedEnvironment() { + if (had) ::setenv(name.c_str(), previous.c_str(), 1); else ::unsetenv(name.c_str()); + } + }; + +} // namespace + +// Fuzz arm 1's five first-frame malformations, each on its OWN shape. Red once by deleting the +// CtrlEnvelopeBufferHasIdentifier line from ClassifyFirstFrame: the wrong-identifier Hello then +// classifies Unverifiable (VerifyCtrlEnvelopeBuffer asks the identifier too, but only as part of +// "did not verify"), and the first EXPECT_EQ names it. +TEST(PreAuthGateTest, EachMalformedFirstFrameLandsOnTheShapeItIsNamedFor) { + const auto wrongIdentifier = HelloPayload("XXXX"); + EXPECT_EQ(Server::ClassifyFirstFrame(wrongIdentifier.data(), wrongIdentifier.size()), FirstFrameShape::NoIdentifier); + // Shorter than a root offset plus an identifier: nothing to ask. + const std::uint8_t tiny[5] = {1, 2, 3, 4, 5}; + EXPECT_EQ(Server::ClassifyFirstFrame(tiny, sizeof(tiny)), FirstFrameShape::NoIdentifier); + EXPECT_EQ(Server::ClassifyFirstFrame(nullptr, 0), FirstFrameShape::NoIdentifier); + // Our identifier, and a root offset that points nowhere: identifier yes, verifier no. + std::vector garbage(64, 0xEE); + const std::uint32_t root = 0xFFFFFF00u; + std::memcpy(garbage.data(), &root, sizeof(root)); + std::memcpy(garbage.data() + 4, Wire::CtrlEnvelopeIdentifier(), 4); + EXPECT_EQ(Server::ClassifyFirstFrame(garbage.data(), garbage.size()), FirstFrameShape::Unverifiable); + // A valid envelope of the wrong union type, and the 24-byte envelope whose tag says Hello but + // whose table is NULL (flatbuffers verifies that as valid). + ::flatbuffers::FlatBufferBuilder builder(64); + auto flush = Wire::CreateLogFlush(builder, 1, false); + const auto logFlush = Finish(builder, Wire::CreateCtrlEnvelope(builder, Wire::CtrlMsg::LogFlush, flush.Union())); + EXPECT_EQ(Server::ClassifyFirstFrame(logFlush.data(), logFlush.size()), FirstFrameShape::NotAHello); + ::flatbuffers::FlatBufferBuilder empty(64); + const auto nullHello = Finish(empty, Wire::CreateCtrlEnvelope(empty, Wire::CtrlMsg::Hello, ::flatbuffers::Offset())); + EXPECT_EQ(Server::ClassifyFirstFrame(nullHello.data(), nullHello.size()), FirstFrameShape::NotAHello); + // A DataBind is only a DataBind with exactly kDataNonceBytes of nonce. + const auto shortBind = DataBindPayload(8); + EXPECT_EQ(Server::ClassifyFirstFrame(shortBind.data(), shortBind.size()), FirstFrameShape::BadDataBind); + // And the two well-formed shapes. + const auto hello = HelloPayload(); + EXPECT_EQ(Server::ClassifyFirstFrame(hello.data(), hello.size()), FirstFrameShape::Hello); + const auto bind = DataBindPayload(Transport::kDataNonceBytes); + EXPECT_EQ(Server::ClassifyFirstFrame(bind.data(), bind.size()), FirstFrameShape::DataBind); + // Every malformed shape has words; the two well-formed ones have none. + for (auto shape : {FirstFrameShape::NoIdentifier, FirstFrameShape::Unverifiable, FirstFrameShape::BadDataBind, + FirstFrameShape::NotAHello}) + EXPECT_NE(Server::FirstFrameRefusalDetail(shape), nullptr); + EXPECT_EQ(Server::FirstFrameRefusalDetail(FirstFrameShape::Hello), nullptr); + EXPECT_EQ(Server::FirstFrameRefusalDetail(FirstFrameShape::DataBind), nullptr); + EXPECT_STREQ(Server::FirstFrameRefusalDetail(FirstFrameShape::NoIdentifier), Server::kFirstFrameNoIdentifier); +} + +// The assembler reads EXACTLY the first frame, however the peer splits it, and leaves what follows +// on the socket - a DataBind's connection carries StreamLink's frames straight after it, and they +// belong to the session child, not to the supervisor. +TEST(PreAuthGateTest, TheAssemblerStopsAtTheFirstFrameWhateverTheFragmentation) { + SocketPair pair; + const auto frame = Framed(HelloPayload()); + Server::FirstFrameAssembler assembler; + EXPECT_EQ(assembler.Pump(pair.fds[1]), Server::FirstFrameAssembler::Step::NeedMore); + pair.Send(frame.data(), 5); // inside the header + EXPECT_EQ(assembler.Pump(pair.fds[1]), Server::FirstFrameAssembler::Step::NeedMore); + pair.Send(frame.data() + 5, 10); // header done, payload begun + EXPECT_EQ(assembler.Pump(pair.fds[1]), Server::FirstFrameAssembler::Step::NeedMore); + const char trailer[] = "stream-link-bytes"; + std::vector rest(frame.begin() + 15, frame.end()); + rest.insert(rest.end(), trailer, trailer + sizeof(trailer)); + pair.Send(rest.data(), rest.size()); + ASSERT_EQ(assembler.Pump(pair.fds[1]), Server::FirstFrameAssembler::Step::Complete); + EXPECT_EQ(assembler.Payload(), HelloPayload()); + EXPECT_EQ(assembler.BytesSeen(), frame.size()); + char left[sizeof(trailer)] = {}; + ASSERT_EQ(::recv(pair.fds[1], left, sizeof(left), MSG_DONTWAIT), static_cast(sizeof(trailer))); + EXPECT_STREQ(left, trailer); +} + +// A header that is not ours is refused on the header, before the payload is allocated for. +TEST(PreAuthGateTest, TheAssemblerRefusesAForeignMagicAndAnOversizeLengthOnTheHeader) { + { + SocketPair pair; + const std::uint8_t header[8] = {'X', 'X', 'X', 'X', 16, 0, 0, 0}; + pair.Send(header, sizeof(header)); + Server::FirstFrameAssembler assembler; + EXPECT_EQ(assembler.Pump(pair.fds[1]), Server::FirstFrameAssembler::Step::NotAFrame); + } + { + SocketPair pair; + std::uint8_t header[8] = {}; + std::memcpy(header, &Transport::kFrameMagic, 4); + const std::uint32_t length = static_cast(Server::kPreAuthFirstFrameMaxBytes + 1); + std::memcpy(header + 4, &length, 4); + pair.Send(header, sizeof(header)); + Server::FirstFrameAssembler assembler; + EXPECT_EQ(assembler.Pump(pair.fds[1]), Server::FirstFrameAssembler::Step::NotAFrame); + EXPECT_TRUE(assembler.Payload().empty()); + } +} + +// A close before any byte is a port probe; a close inside the frame is a truncated first frame. +TEST(PreAuthGateTest, TheAssemblerTellsAProbeFromATruncatedFrame) { + { + SocketPair pair; + pair.CloseWriter(); + Server::FirstFrameAssembler assembler; + EXPECT_EQ(assembler.Pump(pair.fds[1]), Server::FirstFrameAssembler::Step::EndedEmpty); + } + { + SocketPair pair; + const auto frame = Framed(HelloPayload()); + pair.Send(frame.data(), 5); + pair.CloseWriter(); + Server::FirstFrameAssembler assembler; + EXPECT_EQ(assembler.Pump(pair.fds[1]), Server::FirstFrameAssembler::Step::EndedPartial); + } +} + +// The backoff's arithmetic: nothing below the threshold, then base, doubling, capped; an +// authenticated Hello clears the address; old failures are forgotten; 0 turns it off; the table +// is bounded. Red once by making NoteFailure ignore m_after (window from the first failure): the +// first EXPECT_EQ(..., 0u) names it. +TEST(PreAuthGateTest, TheBackoffWindowStartsAtTheThresholdDoublesAndIsCapped) { + Server::PreAuthKnobs knobs; + knobs.backoffAfter = 3; + knobs.backoffBaseMs = 100; + Server::AuthBackoff backoff(knobs); + const auto t0 = Server::AuthBackoff::Clock::now(); + const std::string peer = "192.0.2.7"; + EXPECT_EQ(backoff.NoteFailure(peer, t0), 0u); + EXPECT_EQ(backoff.NoteFailure(peer, t0), 0u); + EXPECT_EQ(backoff.RetryAfterMs(peer, t0), 0u); + EXPECT_EQ(backoff.NoteFailure(peer, t0), 100u); + EXPECT_GT(backoff.RetryAfterMs(peer, t0), 0u); + EXPECT_LE(backoff.RetryAfterMs(peer, t0), 100u); + EXPECT_EQ(backoff.RetryAfterMs(peer, t0 + std::chrono::milliseconds(100)), 0u); + EXPECT_EQ(backoff.NoteFailure(peer, t0), 200u); + EXPECT_EQ(backoff.NoteFailure(peer, t0), 400u); + EXPECT_EQ(backoff.WindowFor(3 + 20), Server::kAuthBackoffCapMs); + EXPECT_EQ(backoff.WindowFor(0xFFFFFFFFu), Server::kAuthBackoffCapMs); + // Another address is not affected. + EXPECT_EQ(backoff.RetryAfterMs("192.0.2.8", t0), 0u); + // An authenticated Hello clears the address. + backoff.NoteSuccess(peer); + EXPECT_EQ(backoff.RetryAfterMs(peer, t0), 0u); + EXPECT_EQ(backoff.Failures(peer), 0u); + // Failures older than the forget interval count from zero again. + EXPECT_EQ(backoff.NoteFailure(peer, t0), 0u); + EXPECT_EQ(backoff.NoteFailure(peer, t0), 0u); + const auto later = t0 + std::chrono::milliseconds(Server::kAuthBackoffForgetMs + 1); + EXPECT_EQ(backoff.NoteFailure(peer, later), 0u); + EXPECT_EQ(backoff.Failures(peer), 1u); + // Off means off. + knobs.backoffAfter = 0; + Server::AuthBackoff off(knobs); + for (int i = 0; i < 50; ++i) EXPECT_EQ(off.NoteFailure(peer, t0), 0u); + EXPECT_EQ(off.RetryAfterMs(peer, t0), 0u); + // Bounded, however many addresses fail. + for (int i = 0; i < 1000; ++i) backoff.NoteFailure("198.51.100." + std::to_string(i), t0); + EXPECT_LE(backoff.Tracked(), Server::kAuthBackoffTrackedPeers); +} + +// The knobs: defaults, values, and the one knob where 0 means something. +TEST(PreAuthGateTest, TheKnobsReadTheirEnvironmentAndKeepTheirDefaultsOtherwise) { + { + ScopedEnvironment a("MOBILEGL_IPC_PREAUTH_MS", nullptr), b("MOBILEGL_IPC_PREAUTH_MAX", nullptr), + c("MOBILEGL_IPC_AUTH_BACKOFF_AFTER", nullptr), d("MOBILEGL_IPC_AUTH_BACKOFF_MS", nullptr); + const auto knobs = Server::PreAuthKnobs::FromEnvironment(); + EXPECT_EQ(knobs.deadlineMs, 2000u); + EXPECT_EQ(knobs.maxPending, 8u); + EXPECT_EQ(knobs.backoffAfter, 5u); + EXPECT_EQ(knobs.backoffBaseMs, 1000u); + } + { + ScopedEnvironment a("MOBILEGL_IPC_PREAUTH_MS", "500"), b("MOBILEGL_IPC_PREAUTH_MAX", "2"), + c("MOBILEGL_IPC_AUTH_BACKOFF_AFTER", "0"), d("MOBILEGL_IPC_AUTH_BACKOFF_MS", "1500"); + const auto knobs = Server::PreAuthKnobs::FromEnvironment(); + EXPECT_EQ(knobs.deadlineMs, 500u); + EXPECT_EQ(knobs.maxPending, 2u); + EXPECT_EQ(knobs.backoffAfter, 0u); + EXPECT_EQ(knobs.backoffBaseMs, 1500u); + } + { + ScopedEnvironment a("MOBILEGL_IPC_PREAUTH_MS", "0"), b("MOBILEGL_IPC_PREAUTH_MAX", "two"), + c("MOBILEGL_IPC_AUTH_BACKOFF_AFTER", "-1"), d("MOBILEGL_IPC_AUTH_BACKOFF_MS", "99999999999"); + const auto knobs = Server::PreAuthKnobs::FromEnvironment(); + EXPECT_EQ(knobs.deadlineMs, 2000u); + EXPECT_EQ(knobs.maxPending, 8u); + EXPECT_EQ(knobs.backoffAfter, 5u); + EXPECT_EQ(knobs.backoffBaseMs, 1000u); + } +} diff --git a/MobileGL/MG_Test/Wire/SessionHandshakeTest.cpp b/MobileGL/MG_Test/Wire/SessionHandshakeTest.cpp index d5c05f41..094d52c6 100644 --- a/MobileGL/MG_Test/Wire/SessionHandshakeTest.cpp +++ b/MobileGL/MG_Test/Wire/SessionHandshakeTest.cpp @@ -47,6 +47,9 @@ #include #include #include +#if !defined(_WIN32) +#include +#endif #if __has_include() #include @@ -337,6 +340,53 @@ TEST(SessionHandshakeTest, ConnectCanRequireTheSameBuild) { ::MobileGL::Wire::DialMode::Connect, ::MobileGL::Wire::RefuseCode::BuildFingerprint); } +#if !defined(_WIN32) +// PH-7 (5) (ph-f.md §6.4 (b)). AN UNAUTHENTICATED PEER LEARNS ONLY THAT IT IS UNAUTHENTICATED. +// +// ServerSession::Accept checked the dial mode, the version, the wire fingerprint and the build +// stamp BEFORE the token, so a peer without the token that sent a wrong fingerprint was answered +// Refuse{WireFingerprint} with this build's fingerprint in `expected` - and with a wrong major, +// Refuse{ProtocolVersion} with this build's version; with REQUIRE_SAME_BUILD, the build stamp. +// Here a Hello wrong in EVERY one of those, and in its token, reaches a real Accept over a real +// socket pair (the only transport role the token policy applies to - InProcess has no peer) with a +// token configured: the one answer is Refuse{Authentication} with expected = actual = 0 and no +// peer value. RED ONCE by moving AuthenticatePeerToken back below ValidatePeerHandshake in +// ServerSession::Accept: the code comes back ProtocolVersion with our version in `expected`. +TEST(SessionHandshakeTest, AnUnauthenticatedHelloLearnsNeitherTheFingerprintNorTheBuild) { + using namespace ::MobileGL::Wire; + ScopedHandshakeEnvironment token("MOBILEGL_IPC_TOKEN", "ph7-5-the-servers-own-token"); + ScopedHandshakeEnvironment required("MOBILEGL_IPC_REQUIRE_SAME_BUILD", "1"); + std::unique_ptr client, server; + ASSERT_EQ(Transport::SocketTransport::CreatePair(client, server), MOBILEGL_OK); + ::flatbuffers::FlatBufferBuilder builder(512); + auto terms = CreateLinkTerms(builder); + auto hello = CreateHelloDirect(builder, 99, MOBILEGL_PROTOCOL_ABI_MINOR, "some-other-build", 0, 1, nullptr, + WireFingerprint() ^ 1, WireFingerprint() ^ 1, terms, "not-the-servers-token", + DialMode::Fork); + auto root = CreateCtrlEnvelope(builder, CtrlMsg::Hello, hello.Union()); + FinishCtrlEnvelopeBuffer(builder, root); + std::vector first(builder.GetBufferPointer(), builder.GetBufferPointer() + builder.GetSize()); + Server::ServerSession session; + Transport::SessionSegmentSizes sizes; + sizes.CmdRingBytes = 4096; sizes.StageBytes = 4096; + sizes.ReplyBytes = 4096; sizes.EventRingBytes = 4096; + session.SetSegmentSizes(sizes); + EXPECT_EQ(session.Accept(*server, &first), MOBILEGL_ERR_PROTOCOL_MISMATCH); + EXPECT_FALSE(session.Accepted()); + const auto reply = ReadHandshakeFrame(*client); + ASSERT_FALSE(reply.empty()); + ::flatbuffers::Verifier verifier(reply.data(), reply.size()); + ASSERT_TRUE(VerifyCtrlEnvelopeBuffer(verifier)); + const auto* refusal = GetCtrlEnvelope(reply.data())->msg_as_Refuse(); + ASSERT_NE(refusal, nullptr); + EXPECT_EQ(refusal->code(), RefuseCode::Authentication) << EnumNameRefuseCode(refusal->code()); + EXPECT_EQ(refusal->expected(), 0u) << "an unauthenticated peer was told a value of ours"; + EXPECT_EQ(refusal->actual(), 0u); + EXPECT_TRUE(refusal->peerValue() == nullptr || refusal->peerValue()->size() == 0); + session.Close(); +} +#endif + // --------------------------------------------------------------------------- // The two null-union guards, driven THROUGH the handshakes (ID-46 finding 7) // --------------------------------------------------------------------------- diff --git a/scripts/ci/tcp_supervisor_smoke.py b/scripts/ci/tcp_supervisor_smoke.py index 8f993915..012f744d 100644 --- a/scripts/ci/tcp_supervisor_smoke.py +++ b/scripts/ci/tcp_supervisor_smoke.py @@ -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 /../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