mirror of
https://github.com/l5yth/potato-mesh.git
synced 2026-10-30 19:41:58 +01:00
web: a port-variant registration no longer blocks a peer's federation (#1063)
This commit is contained in:
14 files changed
+378
-143
No files matched your search
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user