From bcfe48502c46dd01cd621cf573edbd156ed8593e Mon Sep 17 00:00:00 2001 From: Afri <220195275+l5yth@users.noreply.github.com> Date: Sun, 18 Oct 2026 11:53:49 +0200 Subject: [PATCH] web: a port-variant registration no longer blocks a peer's federation (#1063) --- CHANGELOG.md | 1 + .../application/federation/announce.rb | 2 +- .../application/federation/announce_state.rb | 16 +- .../application/federation/crawl.rb | 46 ++--- .../application/federation/crawl_state.rb | 24 +-- .../application/federation/identity_anchor.rb | 14 +- .../federation/instance_fetcher.rb | 18 +- .../application/federation/peer_backoff.rb | 151 +++++++++------ .../application/federation/registration.rb | 22 +-- .../potato_mesh/application/routes/ingest.rb | 14 +- web/spec/federation_peer_state_spec.rb | 18 ++ .../federation_port_variant_backoff_spec.rb | 178 ++++++++++++++++++ .../federation_registration_cooldown_spec.rb | 11 +- web/spec/federation_transport_spec.rb | 6 +- 14 files changed, 378 insertions(+), 143 deletions(-) create mode 100644 web/spec/federation_port_variant_backoff_spec.rb diff --git a/CHANGELOG.md b/CHANGELOG.md index 19ed47f1..ef05f472 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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) diff --git a/web/lib/potato_mesh/application/federation/announce.rb b/web/lib/potato_mesh/application/federation/announce.rb index fe2735f7..fb535e5e 100644 --- a/web/lib/potato_mesh/application/federation/announce.rb +++ b/web/lib/potato_mesh/application/federation/announce.rb @@ -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", diff --git a/web/lib/potato_mesh/application/federation/announce_state.rb b/web/lib/potato_mesh/application/federation/announce_state.rb index 75ebcf08..664d2d02 100644 --- a/web/lib/potato_mesh/application/federation/announce_state.rb +++ b/web/lib/potato_mesh/application/federation/announce_state.rb @@ -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 diff --git a/web/lib/potato_mesh/application/federation/crawl.rb b/web/lib/potato_mesh/application/federation/crawl.rb index 8f912c9a..1c0bfc2c 100644 --- a/web/lib/potato_mesh/application/federation/crawl.rb +++ b/web/lib/potato_mesh/application/federation/crawl.rb @@ -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). diff --git a/web/lib/potato_mesh/application/federation/crawl_state.rb b/web/lib/potato_mesh/application/federation/crawl_state.rb index 34964a2d..bebb9b50 100644 --- a/web/lib/potato_mesh/application/federation/crawl_state.rb +++ b/web/lib/potato_mesh/application/federation/crawl_state.rb @@ -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). # diff --git a/web/lib/potato_mesh/application/federation/identity_anchor.rb b/web/lib/potato_mesh/application/federation/identity_anchor.rb index b67ee199..fb3d7fda 100644 --- a/web/lib/potato_mesh/application/federation/identity_anchor.rb +++ b/web/lib/potato_mesh/application/federation/identity_anchor.rb @@ -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 diff --git a/web/lib/potato_mesh/application/federation/instance_fetcher.rb b/web/lib/potato_mesh/application/federation/instance_fetcher.rb index 47c17f44..fe574be4 100644 --- a/web/lib/potato_mesh/application/federation/instance_fetcher.rb +++ b/web/lib/potato_mesh/application/federation/instance_fetcher.rb @@ -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 diff --git a/web/lib/potato_mesh/application/federation/peer_backoff.rb b/web/lib/potato_mesh/application/federation/peer_backoff.rb index d424c248..f270fab7 100644 --- a/web/lib/potato_mesh/application/federation/peer_backoff.rb +++ b/web/lib/potato_mesh/application/federation/peer_backoff.rb @@ -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. diff --git a/web/lib/potato_mesh/application/federation/registration.rb b/web/lib/potato_mesh/application/federation/registration.rb index fe7bb791..509e0192 100644 --- a/web/lib/potato_mesh/application/federation/registration.rb +++ b/web/lib/potato_mesh/application/federation/registration.rb @@ -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 diff --git a/web/lib/potato_mesh/application/routes/ingest.rb b/web/lib/potato_mesh/application/routes/ingest.rb index b5c007e7..491cbac3 100644 --- a/web/lib/potato_mesh/application/routes/ingest.rb +++ b/web/lib/potato_mesh/application/routes/ingest.rb @@ -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 diff --git a/web/spec/federation_peer_state_spec.rb b/web/spec/federation_peer_state_spec.rb index 1ba83bd6..06f29db1 100644 --- a/web/spec/federation_peer_state_spec.rb +++ b/web/spec/federation_peer_state_spec.rb @@ -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) } diff --git a/web/spec/federation_port_variant_backoff_spec.rb b/web/spec/federation_port_variant_backoff_spec.rb new file mode 100644 index 00000000..41b444b5 --- /dev/null +++ b/web/spec/federation_port_variant_backoff_spec.rb @@ -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:+ 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] 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 diff --git a/web/spec/federation_registration_cooldown_spec.rb b/web/spec/federation_registration_cooldown_spec.rb index 339fd73f..5aa240d9 100644 --- a/web/spec/federation_registration_cooldown_spec.rb +++ b/web/spec/federation_registration_cooldown_spec.rb @@ -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 diff --git a/web/spec/federation_transport_spec.rb b/web/spec/federation_transport_spec.rb index 47a49d43..a5fea34d 100644 --- a/web/spec/federation_transport_spec.rb +++ b/web/spec/federation_transport_spec.rb @@ -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