A listener reachable by anyone must bound what one caller can spend before any authentication, decoding or history read happens. The p2p::admission module decides three things for one process: whether a connection may occupy a slot, whether a received message may be charged against the budget its traffic class was given, and whether a source address has misbehaved often enough to be refused for a bounded period. Every decision is a pure function of a caller-supplied millisecond tick, so a test replays an entire admission history deterministically.

This is local resource admission. It is not authentication, not authorization and not evidence. Transport authentication under the configured network CA and the registered signature, genesis and committee checks on every consensus message are unchanged and still mandatory. Nothing described here enters block validity, voting power, committee membership, finality or the content of any signed message.

Integer token buckets

A TokenBucket holds whole tokens, a sustained refill rate expressed per second, a burst capacity and a carried sub-token remainder. Refill is exact integer arithmetic over the supplied tick:

scaled    = elapsed_ms * per_second + remainder
tokens    = min(capacity, tokens + scaled / 1000)
remainder = scaled % 1000

Carrying remainder is what removes precision drift. Discarding it would lose up to one token per advance, so a caller polled every few milliseconds would be metered far more harshly than one polled once a second. With the carry, a sequence of short advances accrues exactly as many whole tokens as one advance of the same total length, and both equal elapsed_ms * per_second / 1000. The product is formed at double width, so no multiplication wraps, and every subtraction saturates at zero.

The module reads no clock of its own. The transport and the daemon each read one monotonic origin at startup and supply milliseconds elapsed since it. A tick at or below the highest tick already observed accrues nothing: a caller whose clock moves backwards receives no refund, no panic and no overflow, and refill later resumes from the highest observed tick rather than from the backwards one. There is no floating-point arithmetic anywhere in the module. A bucket is not a queue and not a delay line; a refused charge is refused, never deferred, and admission never reorders or paces work it did admit.

Traffic classes and budget tiers

A cheap message and an expensive one do not share a budget. Each MessageKind maps to one MessageClass: Hello to Handshake, Transactions to Transactions, CompactBlock to Blocks, and Proposal, Vote and Finality to Consensus. Each class carries its own message-count limit and its own byte-volume limit, so exhausting one class never starves another. The grouping is a local cost judgement; it is not part of the wire protocol, and no peer can observe or select its own class.

Each class budget exists at three tiers. The peer tier bounds one connection, keyed by its full socket address. The source tier bounds one remote host across every connection it holds concurrently and every connection it reopens, keyed by its source address; this is the tier a caller churning connections cannot reset. The process tier bounds this node's total exposure. A charge is all-or-nothing: charge_message checks the message-count and byte-volume buckets of the class at all three tiers before deducting anything, so a refusal at one tier leaves the other two untouched. A refused charge also records a RateLimitBreach offence, so a caller that keeps pushing past its budget eventually earns a ban rather than an unbounded stream of cheap refusals.

The tiers bound aggregate volume per address. They do not identify a sender, do not detect a coordinated flood spread across many addresses, and do not make any statement about fairness between peers beyond the per-connection ceiling.

Misbehaviour scoring and bounded bans

Offence classes are derived from failures this stack actually reports, not from invented categories. NetworkError::InvalidFrame becomes InvalidFrame, NetworkError::LimitExceeded becomes OversizedPayload, NetworkError::IncompatiblePeer becomes ProtocolViolation, an exhausted budget becomes RateLimitBreach, and a genesis, committee or signature rejection reported by the node layer becomes UnauthenticatedMessage. NetworkNodeError::offence performs that last mapping and returns nothing for a local durability failure, which is this node's problem and never a peer offence.

Each offence adds its configured weight to a capped per-source score. The score decays over the supplied tick with the same exact carried remainder as refill, so decay is drift-free too. Reaching the threshold applies a ban that expires at exactly tick + ban_duration_ms and clears the score, so a source that serves its ban starts again from zero. A ban is one node's local refusal to spend resources on one address for a bounded time. It is not a protocol penalty, it is never gossiped, it never survives a restart, and it is not evidence of anything. Ban duration does not escalate for a repeat offender.

The score and ban table is bounded at max_tracked_sources records. When it is full, records that owe nothing and remember nothing are dropped first. If every slot is still occupied, the least penalised record without a live connection is evicted, ordered by ban expiry, then score, then retained tokens, then address. A record holding a live connection is never evicted, and the total connection cap keeps that set far smaller than the table. The per-connection bucket table is bounded the same way: debt-free records first, then the record holding the most tokens in the class being charged. Evicting a per-connection record buys no extra allowance, because the source tier of that host keeps its own debt. A caller cannot grow either table, and neither holds payload bytes, keys or message contents.

Because the table is bounded, an attacker controlling many source addresses can evict earlier records, and the earliest-expiring ban is the first to go. That is stated rather than hidden: a bounded table cannot retain every offender, and no configuration of these parameters changes that.

Per-source connection caps

The historical transport ceiling was a single flat count, so one remote host could occupy every slot. Admission keeps that total and adds a per-source concurrent cap and a per-source connection-attempt bucket. The attempt bucket is charged whether or not a slot is then granted, so a caller that is refused cannot retry for free, and opening and closing connections repeatedly is bounded by the same machinery as message volume.

The source address is parsed out of the existing PeerId.addr string by source_address, which accepts address:port, a bare address, and the bracketed [::1] and [::1]:port IPv6 forms. An IPv4-mapped IPv6 address is reduced to its IPv4 form, so one host cannot claim two allowances by alternating representations. A scoped address such as fe80::1%eth0, a DNS name and the placeholder used when a socket reports no peer all fail to parse and are refused outright rather than admitted unmetered. No lookup of any kind is performed and nothing is contacted.

A source address is not an identity. Hosts behind one address share one allowance, a host with many addresses receives many, and nothing here reasons about network topology, address ownership, reflection or amplification.

Transport and daemon integration

PeerManager::connect and PeerManager::accept now consult admission before a connection exists. The earlier order established the connection first and only then compared the peer count against the limit, so a manager already at capacity still completed a handshake and immediately discarded it; that defect is fixed in both directions. A refused dial creates no socket, a refused inbound connection has its stream dropped where it was accepted, and PeerManager::disconnect returns the slot. PeerManager::receive refuses a banned peer before reading, records the offence a malformed or oversized frame names, and charges an accepted frame to its class at all three tiers. PeerManager::admission exposes the shared controller so a caller that observes an offence the transport cannot see records it against the same state, and PeerManager::tick_ms supplies the tick.

The certified daemon keeps its own listener. Its accept loop builds a PeerId from the accepted socket's peer address and calls refuse_inbound; a refused or banned source has its stream closed there, before a TLS handshake, a worker thread or the node mutex is spent on it, and the drop is counted as a session-limit drop. The admitted slot is held by the session's ConnectionSlot and returned when its worker finishes. Inside a session, every inbound request is charged to the Blocks class before any history is read, because a catch-up request obliges this node to read and encode finalized history. A refused charge ends that session. A malformed envelope or request body is scored as InvalidFrame, and a request carrying a foreign genesis namespace is scored as UnauthenticatedMessage, which honest current input cannot produce. On the outgoing side, a banned address is not dialled at all; the polling worker treats that like any other failed exchange, so the existing backoff bounds the retry rate, and a malformed response is scored against the responder.

Stale, duplicate and out-of-order gossip is deliberately not scored. Ordinary catch-up and propagation overlap produce those rejections routinely, and scoring them would ban correct peers. Responses to requests this node initiated are also not charged: their volume is bounded by the exchange codec's existing limits and by the poll spacing this node chooses, not by a peer. Admission therefore bounds what an inbound caller can spend; it does not bound what this node chooses to fetch.

Configuration and defaults

Every limit is an explicit named constant, and AdmissionConfig::default is exactly those constants with no value read from the environment, a file or a peer. PeerManager::with_limit keeps every default except the total cap, and the daemon replaces the total cap with its configured max_peers.

BoundConstantDefault
Total concurrent connectionsDEFAULT_MAX_TOTAL_CONNECTIONS128
Concurrent connections per sourceDEFAULT_MAX_CONNECTIONS_PER_SOURCE32
Connection attempts per sourceDEFAULT_CONNECTION_ATTEMPTS_PER_SECOND / DEFAULT_CONNECTION_ATTEMPT_BURST32 per second, burst 128
Handshake messagesDEFAULT_HANDSHAKE_MESSAGES_PER_SECOND / DEFAULT_HANDSHAKE_MESSAGE_BURST8 per second, burst 16
Handshake bytesDEFAULT_HANDSHAKE_BYTES_PER_SECOND / DEFAULT_HANDSHAKE_BYTE_BURST65,536 per second, burst 131,072
Transactions messagesDEFAULT_TRANSACTION_MESSAGES_PER_SECOND / DEFAULT_TRANSACTION_MESSAGE_BURST64 per second, burst 128
Transactions bytesDEFAULT_TRANSACTION_BYTES_PER_SECOND / DEFAULT_TRANSACTION_BYTE_BURST4,194,304 per second, burst 8,388,608
Blocks messagesDEFAULT_BLOCK_MESSAGES_PER_SECOND / DEFAULT_BLOCK_MESSAGE_BURST64 per second, burst 128
Blocks bytesDEFAULT_BLOCK_BYTES_PER_SECOND / DEFAULT_BLOCK_BYTE_BURST16,777,216 per second, burst 33,554,432
Consensus messagesDEFAULT_CONSENSUS_MESSAGES_PER_SECOND / DEFAULT_CONSENSUS_MESSAGE_BURST128 per second, burst 256
Consensus bytesDEFAULT_CONSENSUS_BYTES_PER_SECOND / DEFAULT_CONSENSUS_BYTE_BURST2,097,152 per second, burst 4,194,304
Source-tier factorDEFAULT_SOURCE_RATE_MULTIPLIER32
Process-tier factorDEFAULT_GLOBAL_RATE_MULTIPLIER128
InvalidFrame weightDEFAULT_WEIGHT_INVALID_FRAME32
OversizedPayload weightDEFAULT_WEIGHT_OVERSIZED_PAYLOAD48
UnauthenticatedMessage weightDEFAULT_WEIGHT_UNAUTHENTICATED_MESSAGE16
RateLimitBreach weightDEFAULT_WEIGHT_RATE_LIMIT_BREACH8
ProtocolViolation weightDEFAULT_WEIGHT_PROTOCOL_VIOLATION24
Ban thresholdDEFAULT_BAN_THRESHOLD128
Score ceilingDEFAULT_SCORE_CEILING1,024
Score decayDEFAULT_SCORE_DECAY_PER_SECOND4 per second
Ban durationDEFAULT_BAN_DURATION_MS600,000 milliseconds
Retained source recordsDEFAULT_MAX_TRACKED_SOURCES1,024
Retained connection recordsDEFAULT_MAX_TRACKED_PEERS1,024

The source-tier and process-tier budgets are the per-peer budgets multiplied by their factors, which equal the per-source and total connection caps. One host's concurrently admitted connections are therefore not penalised for sharing an address, while that host keeps one source budget across every connection it reopens. The per-source concurrent cap of 32 is one quarter of the total of 128 and is large enough for the 32 route workers a private-network profile may run behind one address, as described in the private-network operations guide.

These defaults are chosen so that the existing private-network and compact-transport profiles described in the execution pipeline specification operate unchanged. They are deliberately loose for a hostile public deployment and are a starting point for operator tuning, not a calibrated setting. No default here has been validated against a measured attack.

Verification

Tests supply every tick explicitly and use exhaustive loops rather than randomized property generation. Refill exactness is checked across ten rates and five step sizes by comparing 97 short advances against one advance of the same total length and against the closed-form integer accrual; score decay is checked the same way. Further tests cover burst clamping at every elapsed tick, backwards ticks against both the attempt budget and a message budget, a zero-rate bucket, the complete MessageKind to MessageClass mapping, class isolation under an exhausted budget, byte-before-message refusal, all-or-nothing deduction across tiers, source-tier debt surviving a reconnection from a new port, process-tier refusal, every offence weight and every NetworkError mapping, exact ban onset and expiry, decay below the threshold, score saturation, scored rate-limit breaches leading to a ban, refusal of a banned source on both inbound admission and outbound dialling, per-source and total connection caps, slot release and double release, attempt bounding under connect and disconnect churn, twelve accepted and twelve rejected endpoint forms including bracketed and IPv4-mapped IPv6, independent caps per IPv4 and IPv6 source, both bounded tables under floods of thousands of addresses, eviction ordering, pruning, and agreement between AdmissionConfig::default and every named constant.

Transport tests check that a refused dial creates no socket, that an unparsable endpoint is refused, that a connection past the per-source cap is not retained in the managed set, that disconnect returns the slot for reuse, and that a ban recorded through the shared controller refuses a real accepted stream. Daemon tests check the per-source cap and slot reuse through refuse_inbound, that repeated malformed requests over real authenticated sessions score their source until it is refused, that a served session charges every request and ends once its block budget is spent, and that a banned address receives no connection at all. The existing private-network, compact-transport, discovery, recovery and multi-process TLS suites remain active and unchanged.

These are deterministic single-process replay tests. They establish arithmetic exactness, bounded tables and bounded bans. They establish nothing about throughput, latency, behaviour under a real distributed flood, or any consensus outcome.

Limits

Admission is local and bounded, and that is the whole of its claim. It is not a defence against a distributed attack: a caller with many source addresses receives many allowances and can evict bounded records, and no parameter here changes that. It is not a congestion-control or fair-queueing algorithm, offers no anti-amplification or anti-reflection argument, and provides no shared reputation: scores and bans are process-local, are never gossiped, and do not survive a restart.

Remaining work is separate. Ban duration does not escalate for a repeat offender, and there is no operator allow-list, deny-list or manual ban command. Admission state is not exported to the metrics endpoint, so a refused connection is visible only as a session-limit drop and a refused charge only as the same counter; per-offence and per-refusal counters, and the fixed metric names they would need, belong to the telemetry surface rather than to this module. Prefix-level grouping, so that a /24 or a /64 shares an allowance rather than each address receiving its own, is not implemented. Per-source caps are not configurable from config::NetworkConfig; only the total cap is, through max_peers. Stale and out-of-order gossip is not scored, so a peer that sends only useless but well-formed current-namespace data is bounded by rate limits alone. Responses to requests this node initiated are not charged. Calibration of every default against a measured hostile load, and qualification on a public network, are external work that these tests do not perform.

Источник: docs/57-public-network-hardening.md
Отдельная MDX-копия · SHA-256 исходника: d43f234b0ff2aaf53ba7f455615dce6e589a66b21e835858db19eb30476ff317