web: a port-variant registration no longer blocks a peer's federation (#1063)

This commit is contained in:
Afri authored and GitHub committed 2026-10-18 11:53:49 +02:00
1 parent b17b364f20
commit bcfe48502c
14 files changed
+378 -143

No files matched your search

+1
View File
@@ -4,6 +4,7 @@
# CHANGELOG
### Fixes
* Web: an unauthenticated `POST /api/instances` for a peer on a non-default, closed port can no longer block that peer's federation; a port variant keeps its own fetch cooldown and backoff, separate from the host's default-port endpoint (SPEC PV1)
* Web/Data: the mesh activity card hides a protocol with no packets in the last 24 hours and shows the other rates rounded, `0` for an active protocol below 0.5 packets/h; `GET /api/stats` serves `packets.hour` with 3 decimals instead of a whole number, and the activity announcement still prints a whole number (SPEC IR1-IR3); update ingestors that announce (`TX_ANNOUNCE=1`) with the web app; older ones announce the rate with decimals
* Web: the footer and the phone menu label the chat link `Chat:` (SPEC CK1)
@@ -208,7 +208,7 @@ module PotatoMesh
domains.each_with_object(scheduled) do |domain, scheduled_tasks|
break if federation_shutdown_requested?
backoff = federation_peer_backoff.backoff_seconds(federation_peer_host(domain))
backoff = federation_peer_backoff.backoff_seconds(federation_backoff_key(domain))
if backoff.positive?
debug_log(
"Skipped federation announcement",
@@ -58,10 +58,10 @@ module PotatoMesh
end
# Feed one announcement's outcome to the peer backoff, as a fetch's
# outcome is (SPEC FB5, FL4): no answer on any candidate, or a +429+
# or a +503+, honouring its +Retry-After+, backs the host off; any
# other answer clears it. An announcement cut short by shutdown counts
# neither way.
# outcome is (SPEC FB5, FL4, PV1): no answer on any candidate, or a
# +429+ or a +503+, honouring its +Retry-After+, backs the target's
# endpoint off; any other answer clears it. An announcement cut short
# by shutdown counts neither way.
#
# @param domain [String] the target domain.
# @param answer [Net::HTTPResponse, nil] the last answer the target
@@ -70,14 +70,14 @@ module PotatoMesh
def record_announce_outcome(domain, answer)
return if federation_shutdown_requested?
host = federation_peer_host(domain)
peer_key = federation_backoff_key(domain)
backoff = federation_peer_backoff
if answer.nil?
backoff.record_failure(host)
backoff.record_failure(peer_key)
elsif [429, 503].include?(answer.code.to_i)
backoff.record_failure(host, retry_after: federation_retry_after_seconds(answer["Retry-After"]))
backoff.record_failure(peer_key, retry_after: federation_retry_after_seconds(answer["Retry-After"]))
else
backoff.record_success(host)
backoff.record_success(peer_key)
end
end
end
@@ -250,15 +250,15 @@ module PotatoMesh
#
# When a request would pass the crawl's request budget, its domain
# budget or its deadline, the crawl stops: this logs it and throws
# {FEDERATION_CRAWL_STOPPED}. The first request to a host claims the
# host's cooldown for this crawl, so its later requests in the crawl
# pass while every other crawl and registration waits.
# {FEDERATION_CRAWL_STOPPED}. The first request to an endpoint (SPEC
# PV1) claims its cooldown for this crawl, so the crawl's later
# requests to it pass while other crawls and registrations wait.
#
# @param crawl [CrawlState] the crawl.
# @param domain [String] sanitized domain the request goes to.
# @yieldreturn [Array(Object, Object)] the +fetch_instance_json+ result.
# @return [Array(Object, Object)] the block's result, or
# +[nil, [reason]]+ when the host's cooldown is held elsewhere.
# +[nil, [reason]]+ when the endpoint's cooldown is held elsewhere.
def federation_crawl_request(crawl, domain)
unless crawl.request_allowed?(domain)
debug_log(
@@ -272,21 +272,21 @@ module PotatoMesh
throw FEDERATION_CRAWL_STOPPED
end
host = federation_peer_host(domain)
unless crawl.claimed?(host)
wait = federation_peer_backoff.claim(host, cooldown: PotatoMesh::Config.federation_peer_fetch_cooldown_seconds)
peer_key = federation_backoff_key(domain)
unless crawl.claimed?(peer_key)
wait = federation_peer_backoff.claim(peer_key, cooldown: PotatoMesh::Config.federation_peer_fetch_cooldown_seconds)
return [nil, ["#{domain}: peer fetch cooldown, #{wait.ceil} s left"]] if wait.positive?
crawl.claim(host)
crawl.claim(peer_key)
end
crawl.spend_request(domain)
yield
end
# Whether a crawl leaves +host+ alone: another domain of the host was
# already visited in this crawl, or the host was fetched inside its
# cooldown, or backs off, outside this crawl (SPEC FL1, FL3). The
# stored record is kept.
# already visited in this crawl, or the domain's endpoint was fetched
# inside its cooldown, or backs off, outside this crawl (SPEC FL1, FL3,
# PV1). The stored record is kept.
#
# @param crawl [CrawlState] the crawl.
# @param host [String, nil] peer host.
@@ -304,23 +304,23 @@ module PotatoMesh
)
return true
end
return false if crawl.claimed?(host)
wait = federation_peer_backoff.wait_seconds(host, cooldown: PotatoMesh::Config.federation_peer_fetch_cooldown_seconds)
peer_key = federation_backoff_key(domain)
return false if crawl.claimed?(peer_key)
wait = federation_peer_backoff.wait_seconds(peer_key, cooldown: PotatoMesh::Config.federation_peer_fetch_cooldown_seconds)
return false unless wait.positive?
debug_log(
message,
context: "federation.instances",
domain: domain,
reason: federation_peer_backoff.backoff_seconds(host).positive? ? "peer backoff" : "peer fetch cooldown",
reason: federation_peer_backoff.backoff_seconds(peer_key).positive? ? "peer backoff" : "peer fetch cooldown",
retry_in: wait.ceil,
)
true
end
# Judge a peer on the result of its acceptance-list fetch (SPEC FL2),
# in the shape both paths keep for the host's cooldown (SPEC FL7).
# in the shape both paths keep for the endpoint's cooldown (SPEC FL7, PV1).
#
# @param nodes [Array, nil] the decoded node list, nil when the fetch
# failed.
@@ -344,7 +344,7 @@ module PotatoMesh
# Judge +domain+ on its one node list, once per host and crawl (SPEC
# FL1, FL2): later copies reuse the outcome, a failure included. The
# outcome is also kept for the host's cooldown (SPEC FL7).
# outcome is also kept for its endpoint's cooldown (SPEC FL7, PV1).
#
# @param crawl [CrawlState] the crawl.
# @param domain [String] sanitized domain.
@@ -358,7 +358,7 @@ module PotatoMesh
nodes, metadata = federation_crawl_request(crawl, domain) do
fetch_instance_json(domain, remote_instance_acceptance_path).tap do |result|
acceptance = remote_instance_acceptance(*result)
federation_peer_backoff.record_acceptance(host, federation_domain_key(domain), acceptance)
federation_peer_backoff.record_acceptance(federation_backoff_key(domain), federation_domain_key(domain), acceptance)
end
end
acceptance ||= remote_instance_acceptance(nodes, metadata)
@@ -454,8 +454,8 @@ module PotatoMesh
# count fallbacks and the walk of the entry's own list, happen once per
# host and crawl; the list is queued, behind the lists queued before
# it, when the instance's row is kept (SPEC FB6); the crawl never
# fetches this instance's own domain nor a host inside its peer fetch
# cooldown (SPEC FL1-FL3, FS8, FS9).
# fetches this instance's own domain nor an endpoint inside its peer
# fetch cooldown (SPEC FL1-FL3, FS8, FS9, PV1).
#
# @param db [SQLite3::Database] open database handle.
# @param crawl [CrawlState] the crawl.
@@ -577,9 +577,9 @@ module PotatoMesh
# One crawl state runs through the walk (SPEC FL1, FL6): each host is
# fetched at most once, the walk stops at the crawl's request budget,
# domain budget ({PotatoMesh::Config.federation_max_domains_per_crawl}
# domains fetched) or deadline, and a host fetched inside its
# domains fetched) or deadline, and an endpoint fetched inside its
# {PotatoMesh::Config.federation_peer_fetch_cooldown_seconds} outside
# this crawl is skipped. A peer is judged on one
# this crawl is skipped (SPEC PV1). A peer is judged on one
# +/api/nodes?limit=10+ list, its newest nodes inside the 7-day floor
# (ACCEPTANCE FS-A5); node counts come from the signed record, else
# from {#fill_missing_instance_counts!} (SPEC FL2, RL9). The domain is
@@ -680,7 +680,7 @@ module PotatoMesh
# Walk the list of one queued domain, unless it is this instance's own,
# federation refuses it (SPEC FB10), its host's list was walked in this
# crawl, or its host is skipped (SPEC FL1, FL3). A database error ends
# crawl, or the crawl skips it (SPEC FL1, FL3, PV1). A database error ends
# the walk of a list a peer named, logged as the entry that named it,
# and the crawl goes on; for a list the crawl started from it ends the
# crawl (SPEC FK2).
@@ -24,8 +24,8 @@ module PotatoMesh
#
# It holds the crawl's limits, a domain budget, a request budget, a
# deadline and a quota of new domains per relayer, and what the crawl
# learned on the way: the hosts it claimed in the process-wide peer
# fetch cooldown, the hosts whose instance list it walked, the outcome
# learned on the way: the peer endpoints it claimed in the fetch
# cooldown (SPEC PV1), the hosts whose instance list it walked, the outcome
# of each host's visit, the well-known document of each domain it
# fetched (failures included), the port variant of each host whose
# well-known it fetched, the new domains each relayer brought in the
@@ -204,18 +204,18 @@ module PotatoMesh
@fetched_domains << domain
end
# @param host [String] peer host.
# @return [Boolean] true when this crawl holds the host's cooldown.
def claimed?(host)
@claimed.include?(host)
# @param key [String] peer endpoint key (+federation_backoff_key+).
# @return [Boolean] true when this crawl holds the endpoint's cooldown.
def claimed?(key)
@claimed.include?(key)
end
# Record that this crawl holds +host+'s cooldown.
# Record that this crawl holds the cooldown of the endpoint +key+ names.
#
# @param host [String] peer host.
# @param key [String] peer endpoint key (+federation_backoff_key+).
# @return [void]
def claim(host)
@claimed << host
def claim(key)
@claimed << key
end
# @param host [String] peer host.
@@ -274,8 +274,8 @@ module PotatoMesh
end
end
# Clear the process-wide federation peer state: the fetch cooldown, the
# backoff and the revalidation store of every peer host, the
# Clear the process-wide federation peer state: the fetch cooldown and
# backoff of every peer endpoint, the revalidation store, the
# registration verifications counted in flight, and the announce
# targets kept for the next cycle (SPEC FB5).
#
@@ -28,9 +28,9 @@ module PotatoMesh
# relayed-record check share this one check. Within a crawl the
# document is fetched once per domain and every record is judged
# against it, so a forged record costs one fetch and cannot spoil the
# genuine record's check (SPEC FL1). A registration inside the host's
# genuine record's check (SPEC FL1). A registration inside its endpoint's
# fetch cooldown is judged against the document, or the fetch failure,
# that the fetch which started the cooldown kept (SPEC FL7).
# that the fetch which started the cooldown kept (SPEC FL7, PV1).
#
# @param domain [String] sanitized domain whose document is fetched.
# @param pubkey [String] canonical PEM public key the document must name.
@@ -61,10 +61,10 @@ module PotatoMesh
# Load a domain's well-known document with the block, once per crawl
# and through the crawl's limits, or directly outside a crawl. What a
# fetch learned, the document or the failure, is kept for the host's
# fetch cooldown (SPEC FL7). In one crawl the bare domain of a host and
# all its port variants together fetch once each; a further variant is
# refused unfetched and so stays unconfirmed (SPEC FL1).
# fetch learned, the document or the failure, is kept for its endpoint's
# fetch cooldown, a port variant's apart from its host's (SPEC FL7, PV1).
# In one crawl a host's bare domain and its port variants together fetch
# once each; a further variant stays unfetched and unconfirmed (SPEC FL1).
#
# @param domain [String] sanitized domain.
# @param crawl [CrawlState, nil] the crawl, nil outside one.
@@ -74,7 +74,7 @@ module PotatoMesh
host = federation_peer_host(domain)
key = federation_domain_key(domain)
learn = lambda do
fetch.call.tap { |result| federation_peer_backoff.record_well_known(host, key, *result) }
fetch.call.tap { |result| federation_peer_backoff.record_well_known(federation_backoff_key(domain), key, *result) }
end
return learn.call unless crawl
@@ -242,8 +242,8 @@ module PotatoMesh
# Fetch and JSON-decode a federation document from a peer.
#
# A host backing off sends nothing (SPEC FL4). The outcome feeds the
# backoff: a transport failure, a 429 or a 503 backs off the host,
# An endpoint backing off sends nothing (SPEC FL4, PV1). The outcome
# feeds its backoff: a transport failure, a 429 or a 503 backs it off,
# honouring a +Retry-After+; any other answer clears it. A request this
# instance's own deadline kept from being sent counts neither way; one
# the worker pool's task timeout cuts short counts as a failure before
@@ -258,8 +258,8 @@ module PotatoMesh
def fetch_instance_json(domain, path)
return [nil, ["federation shutdown requested"]] if federation_shutdown_requested?
host = federation_peer_host(domain)
backoff = federation_peer_backoff.backoff_seconds(host)
peer_key = federation_backoff_key(domain)
backoff = federation_peer_backoff.backoff_seconds(peer_key)
return [nil, ["#{domain}#{path}: backing off for #{backoff.ceil} s"]] if backoff.positive?
errors = []
@@ -272,14 +272,14 @@ module PotatoMesh
body = perform_instance_http_request(uri)
if body
payload = JSON.parse(body)
federation_peer_backoff.record_success(host)
federation_peer_backoff.record_success(peer_key)
return [payload, uri]
end
rescue PotatoMesh::App::WorkerPool::TaskTimeoutError
# The pool's task timeout cut the fetch short: it counts as a
# failed fetch, so the host backs off and a later crawl skips it,
# failed fetch, so the endpoint backs off and a later crawl skips it,
# and the task ends (SPEC FL4, FL6, FT4).
federation_peer_backoff.record_failure(host)
federation_peer_backoff.record_failure(peer_key)
raise
rescue JSON::ParserError => e
# The peer answered; plain http adds no chance (SPEC FT1).
@@ -292,7 +292,7 @@ module PotatoMesh
errors << "#{uri}: #{e.message}"
failed = e.peer_failure?
retry_after = e.retry_after
federation_peer_backoff.record_success(host) unless failed
federation_peer_backoff.record_success(peer_key) unless failed
break
rescue FederationDeadlineError => e
# Nothing was sent, and no later candidate could be: the time was
@@ -305,7 +305,7 @@ module PotatoMesh
break unless federation_http_fallback?(uri, e)
end
end
federation_peer_backoff.record_failure(host, retry_after: retry_after) if failed
federation_peer_backoff.record_failure(peer_key, retry_after: retry_after) if failed
[nil, errors]
end
end
@@ -22,23 +22,23 @@ module PotatoMesh
module App
module Federation
# Bounded, process-wide memory of the federation fetches this instance
# made, keyed by peer host (SPEC FL3, FL4).
# made, per +host+ key (SPEC FL3, FL4, PV1).
#
# Per host it keeps when a crawl or a registration last claimed a fetch
# of it (the peer fetch cooldown), until when requests to it back off
# after a 429 or 503 with +Retry-After+ or after consecutive failures,
# and, per URL, the +ETag+ and +Last-Modified+ of its last successful
# answer together with that body, so a repeat request can revalidate
# and a 304 reuses the body. It also keeps, until the cooldown ends,
# what the fetches under the current claim learned of each domain on
# the host, its well-known document (or the fetch failure) and its
# node-list acceptance, so a registration inside the cooldown is
# answered from them (SPEC FL7). Hosts beyond {MAX_HOSTS} are evicted
# least recently used first, a body over {MAX_BODY_BYTES} or a document
# over {MAX_DOCUMENT_BYTES} is not kept, at most
# {MAX_KNOWN_DOMAINS_PER_HOST} domains of one host are known at once,
# A key is a peer endpoint, +federation_backoff_key+: the bare host for
# its default port, one key for all its port variants. Per key it keeps
# when a crawl or a registration last claimed a fetch (the peer fetch
# cooldown), until when requests to it back off after a 429 or 503 with
# +Retry-After+ or after consecutive failures, and what the fetches under
# the current claim learned of each domain, its well-known document (or
# the fetch failure) and its node-list acceptance, until the cooldown
# ends, so a registration inside it is answered from them (SPEC FL7). The
# revalidation store keeps, per URL under the URL's host, the +ETag+ and
# +Last-Modified+ of its last successful answer with that body. Keys
# beyond {MAX_HOSTS} are evicted least recently used first, a body over
# {MAX_BODY_BYTES} or a document over {MAX_DOCUMENT_BYTES} is not kept, at
# most {MAX_KNOWN_DOMAINS_PER_HOST} domains of one key are known at once,
# and the kept bodies and documents never exceed {MAX_TOTAL_BODY_BYTES}
# together. All methods are thread-safe and ignore a nil host.
# together. All methods are thread-safe and ignore a nil key.
class PeerBackoff
# Seconds of the backoff after a first failure; each further
# consecutive failure doubles it.
@@ -47,7 +47,7 @@ module PotatoMesh
# Ceiling of every backoff, a peer's +Retry-After+ included.
BACKOFF_MAX_SECONDS = 86_400
# Hosts remembered at once.
# Keys remembered at once.
MAX_HOSTS = 1024
# Largest response body kept for revalidation.
@@ -60,13 +60,13 @@ module PotatoMesh
# long before.
MAX_BACKOFF_DOUBLINGS = 20
# Largest well-known document kept for a host's cooldown, as JSON.
# Largest well-known document kept for a key's cooldown, as JSON.
MAX_DOCUMENT_BYTES = 65_536
# Domains of one host whose verification results are kept at once.
# Domains of one key whose verification results are kept at once.
MAX_KNOWN_DOMAINS_PER_HOST = 4
# Start knowing no host: no claim, backoff, kept body or kept result.
# Start knowing no key: no claim, backoff, kept body or kept result.
#
# @return [void]
def initialize
@@ -76,13 +76,13 @@ module PotatoMesh
end
# Claim a fetch of +host+ unless it must wait: record the claim as the
# start of the host's cooldown, which starts knowing nothing.
# start of the key's cooldown, which starts knowing nothing.
#
# @param host [String, nil] peer host.
# @param cooldown [Numeric] seconds between two claims of one host.
# @param host [String, nil] peer key (+federation_backoff_key+).
# @param cooldown [Numeric] seconds between two claims of one key.
# @param now [Float] current time in seconds.
# @return [Float] 0.0 when the claim succeeded, otherwise the seconds
# left before the host may be fetched.
# left before the key may be fetched.
def claim(host, cooldown:, now: Time.now.to_f)
return 0.0 if host.nil?
@@ -100,10 +100,10 @@ module PotatoMesh
# Seconds before +host+ may be fetched again: the longer of what is
# left of its cooldown and of its backoff.
#
# @param host [String, nil] peer host.
# @param cooldown [Numeric] seconds between two claims of one host.
# @param host [String, nil] peer key (+federation_backoff_key+).
# @param cooldown [Numeric] seconds between two claims of one key.
# @param now [Float] current time in seconds.
# @return [Float] seconds, 0.0 when the host may be fetched now.
# @return [Float] seconds, 0.0 when the key may be fetched now.
def wait_seconds(host, cooldown:, now: Time.now.to_f)
return 0.0 if host.nil?
@@ -112,8 +112,8 @@ module PotatoMesh
# Seconds left of +host+'s fetch cooldown alone.
#
# @param host [String, nil] peer host.
# @param cooldown [Numeric] seconds between two claims of one host.
# @param host [String, nil] peer key (+federation_backoff_key+).
# @param cooldown [Numeric] seconds between two claims of one key.
# @param now [Float] current time in seconds.
# @return [Float] seconds, 0.0 when no cooldown runs.
def cooldown_seconds(host, cooldown:, now: Time.now.to_f)
@@ -124,9 +124,9 @@ module PotatoMesh
# Seconds left of +host+'s backoff.
#
# @param host [String, nil] peer host.
# @param host [String, nil] peer key (+federation_backoff_key+).
# @param now [Float] current time in seconds.
# @return [Float] seconds, 0.0 when the host is not backing off.
# @return [Float] seconds, 0.0 when the key is not backing off.
def backoff_seconds(host, now: Time.now.to_f)
return 0.0 if host.nil?
@@ -137,7 +137,7 @@ module PotatoMesh
# doubled per consecutive failure, at least the peer's +Retry-After+,
# at most {BACKOFF_MAX_SECONDS}.
#
# @param host [String, nil] peer host.
# @param host [String, nil] peer key (+federation_backoff_key+).
# @param retry_after [Numeric, nil] seconds the peer asked for.
# @param now [Float] current time in seconds.
# @return [void]
@@ -155,7 +155,7 @@ module PotatoMesh
# Record that +host+ answered: clear its failures and backoff.
#
# @param host [String, nil] peer host.
# @param host [String, nil] peer key (+federation_backoff_key+).
# @return [void]
def record_success(host)
return if host.nil?
@@ -206,9 +206,9 @@ module PotatoMesh
# Keep what a fetch under +host+'s current claim learned of +domain+'s
# well-known document: the document, or the failure of its fetch. A
# document over {MAX_DOCUMENT_BYTES} is not kept, nor anything for a
# host without a claim.
# key without a claim.
#
# @param host [String, nil] peer host.
# @param host [String, nil] peer key (+federation_backoff_key+).
# @param domain [String] sanitized domain whose document was fetched.
# @param document [Hash, nil] the document, nil when the fetch failed.
# @param metadata [Object] the fetch's URI or its errors.
@@ -233,7 +233,7 @@ module PotatoMesh
# Keep what a fetch under +host+'s current claim learned of +domain+'s
# node list: whether it accepted the peer, and why not.
#
# @param host [String, nil] peer host.
# @param host [String, nil] peer key (+federation_backoff_key+).
# @param domain [String] sanitized domain whose node list was fetched.
# @param acceptance [Hash] +:accepted+, plus +:error+, +:reason+ and
# +:details+ for a refusal.
@@ -250,9 +250,9 @@ module PotatoMesh
# What the fetches under +host+'s current claim learned of +domain+,
# while its cooldown runs.
#
# @param host [String, nil] peer host.
# @param host [String, nil] peer key (+federation_backoff_key+).
# @param domain [String] sanitized domain.
# @param cooldown [Numeric] seconds between two claims of one host.
# @param cooldown [Numeric] seconds between two claims of one key.
# @param now [Float] current time in seconds.
# @return [Hash, nil] +:well_known+ (document and metadata) and
# +:acceptance+, each when learned; nil once the cooldown ended or
@@ -268,7 +268,7 @@ module PotatoMesh
end
end
# Forget every host.
# Forget every key.
#
# @return [void]
def reset!
@@ -281,11 +281,11 @@ module PotatoMesh
private
# The entry for +host+, created when missing and moved to the most
# recently used end; the least recently used hosts beyond
# recently used end; the least recently used keys beyond
# {MAX_HOSTS} are evicted. Called with the mutex held.
#
# @param host [String] peer host.
# @return [Hash] the host's entry.
# @param host [String] peer key.
# @return [Hash] the key's entry.
def entry_locked(host)
entry = @hosts.delete(host) || { fetched_at: nil, backoff_until: nil, failures: 0, validators: {}, knowledge: {} }
@hosts[host] = entry
@@ -300,7 +300,7 @@ module PotatoMesh
# Forget the validators and body kept for +url+, releasing their
# bytes. Called with the mutex held.
#
# @param entry [Hash] the host's entry.
# @param entry [Hash] the key's entry.
# @param url [String] full request URL.
# @return [void]
def drop_validator_locked(entry, url)
@@ -309,12 +309,12 @@ module PotatoMesh
end
# The knowledge of +domain+ under +host+'s current claim, created when
# missing; the oldest domain of the host goes beyond
# missing; the oldest domain of the key goes beyond
# {MAX_KNOWN_DOMAINS_PER_HOST}. Called with the mutex held.
#
# @param host [String] peer host.
# @param domain [String] domain key on the host.
# @return [Hash, nil] the domain's knowledge, nil when the host holds
# @param host [String] peer key.
# @param domain [String] domain key under the peer key.
# @return [Hash, nil] the domain's knowledge, nil when the key holds
# no claim.
def knowledge_slot_locked(host, domain)
entry = @hosts[host]
@@ -331,17 +331,17 @@ module PotatoMesh
knowledge[domain]
end
# Forget everything the host's claim kept, releasing the bytes of its
# Forget everything the key's claim kept, releasing the bytes of its
# documents. Called with the mutex held.
#
# @param entry [Hash] the host's entry.
# @param entry [Hash] the key's entry.
# @return [void]
def drop_knowledge_locked(entry)
entry[:knowledge].each_value { |known| @body_bytes -= known[:bytes].to_i }
entry[:knowledge].clear
end
# Drop kept bodies and documents, least recently used host first,
# Drop kept bodies and documents, least recently used key first,
# until they fit {MAX_TOTAL_BODY_BYTES}. Called with the mutex held.
#
# @return [void]
@@ -357,19 +357,19 @@ module PotatoMesh
# Seconds before +host+ may be fetched again, the longer of what is
# left of its cooldown and of its backoff. Called with the mutex held.
#
# @param host [String] peer host.
# @param cooldown [Numeric] seconds between two claims of one host.
# @param host [String] peer key.
# @param cooldown [Numeric] seconds between two claims of one key.
# @param now [Float] current time in seconds.
# @return [Float] seconds, 0.0 when the host may be fetched now.
# @return [Float] seconds, 0.0 when the key may be fetched now.
def wait_locked(host, cooldown, now)
entry = @hosts[host]
[cooldown_left_locked(entry, cooldown, now), backoff_left_locked(entry, now)].max.to_f
end
# Seconds left of a host's cooldown. Called with the mutex held.
# Seconds left of a key's cooldown. Called with the mutex held.
#
# @param entry [Hash, nil] the host's entry, nil for an unknown host.
# @param cooldown [Numeric] seconds between two claims of one host.
# @param entry [Hash, nil] the key's entry, nil for an unknown key.
# @param cooldown [Numeric] seconds between two claims of one key.
# @param now [Float] current time in seconds.
# @return [Float] seconds, 0.0 when no cooldown runs.
def cooldown_left_locked(entry, cooldown, now)
@@ -378,11 +378,11 @@ module PotatoMesh
[entry[:fetched_at] + cooldown - now, 0.0].max
end
# Seconds left of a host's backoff. Called with the mutex held.
# Seconds left of a key's backoff. Called with the mutex held.
#
# @param entry [Hash, nil] the host's entry, nil for an unknown host.
# @param entry [Hash, nil] the key's entry, nil for an unknown key.
# @param now [Float] current time in seconds.
# @return [Float] seconds, 0.0 when the host is not backing off.
# @return [Float] seconds, 0.0 when the key is not backing off.
def backoff_left_locked(entry, now)
return 0.0 unless entry && entry[:backoff_until]
@@ -406,8 +406,9 @@ module PotatoMesh
PotatoMesh::App::Federation.peer_backoff
end
# The host part of a federation domain, the key of the peer fetch
# cooldown and backoff: ports of one host share them.
# The host part of a federation domain, without its port: the key of a
# crawl's per-host state (SPEC FL1). The peer store's cooldown, backoff
# and kept knowledge use {#federation_backoff_key} instead (SPEC PV1).
#
# @param domain [String, nil] sanitized domain, optionally with a port.
# @return [String, nil] lowercase host, or nil when the domain does not
@@ -443,6 +444,36 @@ module PotatoMesh
key != federation_peer_host(key)
end
# Suffix of the peer-store key of a host's non-default port variants
# (SPEC PV1). A +#+ begins a URI fragment and never survives into a
# host, so {#federation_peer_host} never returns one: the suffixed key
# cannot collide with any bare host.
PORT_VARIANT_BACKOFF_SUFFIX = "#port-variant"
# The {PeerBackoff} key of a federation domain: its fetch cooldown,
# backoff and kept-knowledge bucket (SPEC FL3, FL4, FL7, PV1).
#
# A default port reads as the bare host (SPEC FL1), so +host+,
# +host:443+ and +host:80+ share the host's bucket. Every non-default
# port variant of the host shares one *separate* bucket, keyed by the
# host with {PORT_VARIANT_BACKOFF_SUFFIX}. So a closed port any caller
# can name (PV1) writes no cooldown, backoff or fetch failure onto the
# host's default-port endpoint and never occupies its kept-knowledge
# slot, while all of the host's port variants together still cost at
# most one fetch per cooldown, preserving the amplification bound
# (SPEC FL1, FL6). The key is lower case whatever the domain's letter
# case. A nil host yields a nil key, which every store method ignores.
#
# @param domain [String, nil] sanitized domain, optionally with a port.
# @return [String, nil] the bucket key, or nil when the domain has no
# host.
def federation_backoff_key(domain)
host = federation_peer_host(domain)
return host if host.nil?
federation_port_variant?(domain.to_s.downcase) ? "#{host}#{PORT_VARIANT_BACKOFF_SUFFIX}" : host
end
# Parse an HTTP +Retry-After+ value: delay seconds or an HTTP date.
#
# @param value [String, nil] header value.
@@ -58,10 +58,10 @@ module PotatoMesh
slots[:mutex].synchronize { slots[:in_flight] = 0 }
end
# Answer a registration whose host is inside its fetch cooldown or
# backoff from what the fetch that started the cooldown learned of the
# domain, without a new fetch (SPEC FL7). Request context only; always
# halts.
# Answer a registration whose endpoint is inside its fetch cooldown or
# backoff (SPEC PV1) from what the fetch that started the cooldown
# learned of the domain, without a new fetch (SPEC FL7). Request context
# only; always halts.
#
# An announcement under a key other than the stored one is judged
# against the kept well-known document, or refused on the kept fetch
@@ -69,22 +69,22 @@ module PotatoMesh
# in the crawl (SPEC FS8). Either is then judged on the kept node-list
# result. Whatever cannot be checked is deferred with 503: no document
# kept for a new key, or no node list kept, which is also the case once
# the cooldown ended while the host still backs off. A copy that passes
# the cooldown ended while the endpoint still backs off. A copy that passes
# is stored, newest copy winning, and answered 201.
#
# @param db [SQLite3::Database] open database handle.
# @param attributes [Hash] the verified announcement's attributes.
# @param signature [String] its signature.
# @param same_key [Boolean] whether the stored row holds the same key.
# @param wait [Numeric] seconds left of the host's cooldown or backoff.
# @param wait [Numeric] seconds left of the endpoint's cooldown or backoff.
# @return [void]
def answer_instance_registration_from_cooldown!(db, attributes, signature, same_key:, wait:)
domain = attributes[:domain]
host = federation_peer_host(domain)
peer_key = federation_backoff_key(domain)
cooldown = PotatoMesh::Config.federation_peer_fetch_cooldown_seconds
known = federation_peer_backoff.knowledge(host, federation_domain_key(domain), cooldown: cooldown) || {}
# Once the cooldown ended only the backoff holds the host.
reason = federation_peer_backoff.cooldown_seconds(host, cooldown: cooldown).positive? ? "peer fetch cooldown" : "peer backoff"
known = federation_peer_backoff.knowledge(peer_key, federation_domain_key(domain), cooldown: cooldown) || {}
# Once the cooldown ended only the backoff holds the endpoint.
reason = federation_peer_backoff.cooldown_seconds(peer_key, cooldown: cooldown).positive? ? "peer fetch cooldown" : "peer backoff"
unless same_key
defer_instance_registration!(domain, wait, reason) unless known[:well_known]
check = verify_well_known_identity(domain, attributes[:pubkey], cached: known[:well_known])
@@ -133,7 +133,7 @@ module PotatoMesh
def judge_registering_instance(domain)
nodes, metadata = fetch_instance_json(domain, remote_instance_acceptance_path)
remote_instance_acceptance(nodes, metadata).tap do |acceptance|
federation_peer_backoff.record_acceptance(federation_peer_host(domain), federation_domain_key(domain), acceptance)
federation_peer_backoff.record_acceptance(federation_backoff_key(domain), federation_domain_key(domain), acceptance)
end
end
@@ -352,13 +352,13 @@ module PotatoMesh
halt 201, { status: "registered" }.to_json
end
# A peer host is fetched at most once per
# FEDERATION_PEER_FETCH_COOLDOWN, by a crawl or a registration;
# inside it a registration is answered from what that fetch
# learned (SPEC FL3, FL7).
peer_host = federation_peer_host(attributes[:domain])
# At most once per FEDERATION_PEER_FETCH_COOLDOWN a crawl or a
# registration fetches a peer endpoint: a host's default port, or
# all its port variants together. Inside it a registration is
# answered from what that fetch learned (SPEC FL3, FL7, PV1).
peer_key = federation_backoff_key(attributes[:domain])
cooldown = PotatoMesh::Config.federation_peer_fetch_cooldown_seconds
peer_wait = federation_peer_backoff.wait_seconds(peer_host, cooldown: cooldown)
peer_wait = federation_peer_backoff.wait_seconds(peer_key, cooldown: cooldown)
if peer_wait.positive?
answer_instance_registration_from_cooldown!(
db, attributes, signature, same_key: same_key, wait: peer_wait,
@@ -372,7 +372,7 @@ module PotatoMesh
defer_instance_registration!(attributes[:domain], verification_deadline, "too many registrations in flight")
end
begin
peer_wait = federation_peer_backoff.claim(peer_host, cooldown: cooldown)
peer_wait = federation_peer_backoff.claim(peer_key, cooldown: cooldown)
defer_instance_registration!(attributes[:domain], peer_wait, "peer fetch cooldown") if peer_wait.positive?
with_federation_deadline(verification_deadline) do
+18
View File
@@ -291,6 +291,24 @@ RSpec.describe "Federation peer state" do
end
end
describe ".federation_backoff_key" do
it "keys a default-port endpoint by the bare host and shares one bucket per host for the port variants" do
domains = ["peer.mesh", "peer.mesh:443", "peer.mesh:80", "peer.mesh:8443", "peer.mesh:9443", "[2001:db8::1]:8443", "Peer.Mesh", "Peer.Mesh:8443"]
expect(domains.map { |domain| application.federation_backoff_key(domain) }).to eq(
[
"peer.mesh", "peer.mesh", "peer.mesh", "peer.mesh#port-variant", "peer.mesh#port-variant",
"[2001:db8::1]#port-variant", "peer.mesh", "peer.mesh#port-variant",
],
)
end
it "returns nil for a domain that is no host, so the stores ignore it" do
expect(application.federation_backoff_key(nil)).to be_nil
expect(application.federation_backoff_key("bad host")).to be_nil
end
end
describe ".federation_retry_after_seconds" do
let(:now) { Time.utc(2026, 10, 7, 12, 0, 0) }
@@ -0,0 +1,178 @@
# Copyright © 2025-26 l5yth & contributors
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
# frozen_string_literal: true
require "spec_helper"
require_relative "support/federation_fake_peers"
# Regression guard for gap FL-G1 (SPEC PV1): an unauthenticated registration,
# or a relayed crawl record, for a peer's non-default, unreachable port must
# not lock the genuine peer, reached on its default port, out of federation.
#
# The peer fetch cooldown, backoff and kept knowledge are keyed per endpoint
# (+federation_backoff_key+): a host's default port is one key and all its
# port variants share another. A refused fetch of +victim:<closed port>+ backs
# off only the variants' key, so the genuine peer stays verifiable and
# reachable by registrations, crawls and the announcer. Before PV1 the key was
# the bare host, so that refusal claimed and backed off the genuine peer too.
# A crawl claims the key of every endpoint it fetches, so it keeps what it
# learned for a registration inside the cooldown, and it skips an endpoint
# whose cooldown another claim holds (SPEC FL3, FL7).
#
# Runs through the app's real request and crawl code against the in-process
# fake federation (no socket leaves the process, test domains use +.invalid+).
RSpec.describe "Federation port-variant registrations and a peer's backoff (FL-G1)" do
include_context "fake federation peers"
let(:peers) { FederationFakePeers }
let(:application) { FederationFakePeers::APP }
let(:victim) { "victim.fed.invalid" }
let(:closed_port) { 8443 }
let(:variant) { "#{victim}:#{closed_port}" }
let(:relayer) { "relayer.fed.invalid" }
let(:attacker_key) { peers::KEYS["attacker-key"] }
# The key stored for +domain+.
#
# @param domain [String] instance domain.
# @return [String, nil] PEM public key.
def stored_key(domain)
with_db { |db| db.get_first_value("SELECT pubkey FROM instances WHERE domain = ?", domain) }
end
# Requests the fake peers logged for +authority+ (Host header, so a
# non-default port is distinct from the bare host) of a given kind.
#
# @param authority [String] the request authority (host, or host:port).
# @param kind [Symbol] a {FederationFakePeers::KINDS} entry.
# @return [Integer] request count.
def requests_to(authority, kind)
fed.log.count { |entry| entry[:headers]["host"] == authority && entry[:kind] == kind }
end
# Skip reasons logged for +domain+ under +message+.
#
# @param message [String] the log message.
# @param domain [String] the domain the skip names.
# @return [Array<String>] the reasons.
def skip_reasons(message, domain)
log_lines(message).map { |line| line[2] }.select { |meta| meta[:domain] == domain }.map { |meta| meta[:reason] }
end
before do
# The victim's chosen port is closed: every connection to victim:8443 is
# refused, as a real peer that serves no such port would refuse it, while
# the bare victim on its default port answers normally.
closed = variant
fed.hooks << lambda do |entry|
raise Errno::ECONNREFUSED, "spec: #{closed} refuses" if entry[:headers]["host"] == closed
end
end
describe "an unauthenticated registration for a closed port variant" do
it "leaves the genuine bare-domain peer able to register" do
forged = announce(variant, attacker_key)
expect(forged.status).to eq(400)
genuine = announce(victim)
expect(genuine.status).to eq(201)
expect(stored_key(victim)).to eq(peers::KEYS[victim].public_key.to_pem)
end
it "lets a crawl still reach the bare-domain peer" do
announce(variant, attacker_key)
fed.lists[victim] = []
logs.clear
crawl(victim)
expect(requests_to(victim, :instances)).to be >= 1
expect(skip_reasons("Skipped remote instance crawl", victim)).not_to include("peer backoff", "peer fetch cooldown")
end
it "lets the announcer still reach the bare-domain peer" do
announce(variant, attacker_key)
allow(application).to receive(:federation_target_domains).and_return([victim])
logs.clear
application.announce_instance_to_all_domains
expect(requests_to(victim, :instances)).to be >= 1
expect(skip_reasons("Skipped federation announcement", victim)).to be_empty
end
end
describe "a forged closed-port variant relayed in a crawl" do
it "leaves the genuine bare-domain peer able to register" do
fed.lists[relayer] = [peers.record(variant, attacker_key)]
crawl(relayer)
genuine = announce(victim)
expect(genuine.status).to eq(201)
expect(stored_key(victim)).to eq(peers::KEYS[victim].public_key.to_pem)
end
it "lets a later crawl still reach the bare-domain peer" do
fed.lists[relayer] = [peers.record(variant, attacker_key)]
crawl(relayer)
fed.lists[victim] = []
logs.clear
crawl(victim)
expect(requests_to(victim, :instances)).to be >= 1
expect(skip_reasons("Skipped remote instance crawl", victim)).not_to include("peer backoff", "peer fetch cooldown")
end
it "lets the announcer still reach the bare-domain peer" do
fed.lists[relayer] = [peers.record(variant, attacker_key)]
crawl(relayer)
allow(application).to receive(:federation_target_domains).and_return([victim])
logs.clear
application.announce_instance_to_all_domains
expect(requests_to(victim, :instances)).to be >= 1
expect(skip_reasons("Skipped federation announcement", victim)).to be_empty
end
end
describe "a crawl that reaches both endpoints of a host" do
it "claims each endpoint's cooldown, so a registration inside it is answered from the crawl's fetches" do
fed.lists[relayer] = [peers.record(variant, attacker_key), peers.record(victim)]
crawl(relayer)
fetched = requests_to(victim, :well_known) + requests_to(victim, :nodes_acceptance)
genuine = announce(victim, name: "Newer", last_update_time: Time.now.to_i + 60)
expect(genuine.status).to eq(201)
expect(requests_to(victim, :well_known) + requests_to(victim, :nodes_acceptance)).to eq(fetched)
end
it "skips an endpoint whose cooldown a registration holds, after claiming the host's other endpoint" do
expect(announce(victim, attacker_key).status).to eq(400)
fetched = requests_to(victim, :well_known)
fed.lists[relayer] = [peers.record(variant, attacker_key), peers.record(victim)]
logs.clear
crawl(relayer)
expect(requests_to(victim, :well_known)).to eq(fetched)
expect(skip_reasons("Skipped remote instance entry", victim)).to eq(["peer fetch cooldown"])
end
end
end
@@ -17,7 +17,7 @@
require "spec_helper"
require_relative "support/federation_fake_peers"
# Registrations inside a peer host's fetch cooldown are judged against what
# Registrations inside a peer endpoint's fetch cooldown are judged against what
# the fetch that started the cooldown kept (SPEC FL7), an announcement never
# rolls the stored row back (SPEC FL1, FL7), and the first crawl runs with the
# first announcement after boot (SPEC FL3). Through the app's real request code
@@ -174,13 +174,16 @@ RSpec.describe "Federation registrations inside a peer's cooldown" do
expect(fed.total(x)).to eq(0)
end
it "answers 503 for another domain on the host, whose fetch kept nothing for it" do
it "does not defer the default-port domain for a port variant's own fetch (FL-G1/PV1)" do
# A port variant keys its own cooldown bucket, so it never claims the
# host's default-port cooldown: the bare domain is verified on its own
# fetch, not answered from the variant's (SPEC PV1).
expect(announce("#{a}:8443").status).to eq(400)
response = announce(a)
expect(response.status).to eq(503)
expect(fed.counts(a)).to eq(well_known: 1, nodes_acceptance: 1)
expect(response.status).to eq(201)
expect(fed.counts(a)).to eq(well_known: 2, nodes_acceptance: 2)
end
it "lets what a fetch kept expire with the cooldown" do
+5 -1
View File
@@ -373,7 +373,11 @@ RSpec.describe "Federation transport" do
expect(outcome).to be_a(PotatoMesh::App::WorkerPool::TaskTimeoutError)
expect(outcome.message).to eq("task exceeded timeout")
expect(served_over("http")).to eq([])
expect(application.federation_peer_backoff.backoff_seconds("peer.invalid")).to be_between(1, 60)
# A non-default port variant backs off its own bucket, not the host's
# default-port endpoint (SPEC PV1).
expect(application.federation_peer_backoff.backoff_seconds("peer.invalid")).to eq(0)
variant_key = application.federation_backoff_key("peer.invalid:8443")
expect(application.federation_peer_backoff.backoff_seconds(variant_key)).to be_between(1, 60)
end
context "when the task timeout fires before the request timeout" do