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:
@@ -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 <algorithm>
|
||||
#include <chrono>
|
||||
#include <cstddef>
|
||||
#include <cstdint>
|
||||
#include <cstdlib>
|
||||
#include <cstring>
|
||||
#include <string>
|
||||
#include <unordered_map>
|
||||
#include <vector>
|
||||
|
||||
#if !defined(_WIN32)
|
||||
#include <cerrno>
|
||||
#include <sys/socket.h>
|
||||
#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<std::uint32_t>(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<std::chrono::milliseconds>(found->second.until - now).count();
|
||||
return static_cast<std::uint32_t>(std::max<std::int64_t>(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<std::uint32_t>(failures - m_after, 31u);
|
||||
const std::uint64_t window = static_cast<std::uint64_t>(m_baseMs) << shift;
|
||||
return static_cast<std::uint32_t>(std::min<std::uint64_t>(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<std::string, Entry> 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<std::size_t>(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<std::uint8_t>& 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<std::uint64_t>(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<std::uint8_t> m_payload;
|
||||
};
|
||||
#endif
|
||||
|
||||
} // namespace MobileGL::MG_Remote::Server
|
||||
@@ -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 <vector>
|
||||
#include <chrono>
|
||||
#include <cerrno>
|
||||
#include <arpa/inet.h>
|
||||
#include <netinet/in.h>
|
||||
#include <poll.h>
|
||||
#include <sys/socket.h>
|
||||
#include <sys/wait.h>
|
||||
@@ -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<std::uint8_t> 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<sockaddr*>(&address), &size) != 0) return "?";
|
||||
char text[INET6_ADDRSTRLEN] = {};
|
||||
const void* raw = nullptr;
|
||||
if (address.ss_family == AF_INET) raw = &reinterpret_cast<const sockaddr_in*>(&address)->sin_addr;
|
||||
else if (address.ss_family == AF_INET6) raw = &reinterpret_cast<const sockaddr_in6*>(&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<SocketTransport> 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<SocketTransport> control, std::vector<std::uint8_t> helloFrame,
|
||||
Server::ServerSession::DataConnectionSource dataSource, int sourceFd,
|
||||
std::uint32_t helloWaitMs) {
|
||||
const int selfPid = static_cast<int>(::getpid());
|
||||
const bool tcp = control->IsTcp();
|
||||
std::vector<std::uint8_t> 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<std::size_t>(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<std::size_t>(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<int>(::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<pollfd> 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::int64_t>(
|
||||
std::chrono::duration_cast<std::chrono::milliseconds>(peer.deadline - now).count(), 0, timeout);
|
||||
const int polled = ::poll(fds.data(), static_cast<nfds_t>(fds.size()), static_cast<int>(timeout));
|
||||
if (polled < 0 && errno != EINTR) {
|
||||
WireLogError("MG_Remote server: supervisor poll failed: %s", std::strerror(errno));
|
||||
return 73;
|
||||
}
|
||||
now = Clock::now();
|
||||
std::vector<PendingPeer> 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<SocketTransport>(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<SocketTransport> connection, const std::vector<std::uint8_t>& 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<PendingPeer> m_pending;
|
||||
std::vector<PendingPeer> 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<int>(::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<SocketTransport> 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<SocketTransport>(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);
|
||||
|
||||
@@ -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<Uint32>(BackendType::BackendTypeCount) ||
|
||||
(m_backend != nullptr && hello->backendType() != static_cast<Uint32>(m_backend->GetBackendType())))
|
||||
return RefuseHandshake(transport, ::MobileGL::Wire::RefuseCode::Backend,
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 <MG_Remote/Handshake.h>
|
||||
#include <MG_Remote/Server/PreAuthGate.h>
|
||||
|
||||
#include <gtest/gtest.h>
|
||||
|
||||
#include <chrono>
|
||||
#include <cstdint>
|
||||
#include <cstdlib>
|
||||
#include <cstring>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
#include <sys/socket.h>
|
||||
#include <unistd.h>
|
||||
|
||||
using namespace MobileGL::MG_Remote;
|
||||
using Server::FirstFrameShape;
|
||||
namespace Wire = ::MobileGL::Wire;
|
||||
|
||||
namespace {
|
||||
|
||||
std::vector<std::uint8_t> Finish(::flatbuffers::FlatBufferBuilder& builder, ::flatbuffers::Offset<Wire::CtrlEnvelope> root,
|
||||
const char* identifier = Wire::CtrlEnvelopeIdentifier()) {
|
||||
builder.Finish(root, identifier);
|
||||
return std::vector<std::uint8_t>(builder.GetBufferPointer(), builder.GetBufferPointer() + builder.GetSize());
|
||||
}
|
||||
|
||||
std::vector<std::uint8_t> 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<std::uint8_t> DataBindPayload(std::size_t nonceBytes) {
|
||||
::flatbuffers::FlatBufferBuilder builder(64);
|
||||
const std::vector<std::uint8_t> nonce(nonceBytes, 0x5A);
|
||||
auto bind = Wire::CreateDataBind(builder, builder.CreateVector(nonce));
|
||||
return Finish(builder, Wire::CreateCtrlEnvelope(builder, Wire::CtrlMsg::DataBind, bind.Union()));
|
||||
}
|
||||
|
||||
std::vector<std::uint8_t> Framed(const std::vector<std::uint8_t>& payload) {
|
||||
std::vector<std::uint8_t> 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<ssize_t>(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<std::uint8_t> 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<void>()));
|
||||
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<std::uint8_t> 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<ssize_t>(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<std::uint32_t>(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);
|
||||
}
|
||||
}
|
||||
@@ -47,6 +47,9 @@
|
||||
#include <MG_Remote/Server/ServerSession.h>
|
||||
#include <MG_Remote/Transport/InProcessTransport.h>
|
||||
#include <MG_Remote/Transport/SessionRings.h>
|
||||
#if !defined(_WIN32)
|
||||
#include <MG_Remote/Transport/SocketTransport.h>
|
||||
#endif
|
||||
|
||||
#if __has_include(<MGGitHash.h>)
|
||||
#include <MGGitHash.h>
|
||||
@@ -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<Transport::SocketTransport> 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<Uint8> 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)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -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