mirror of
https://github.com/l5yth/potato-mesh.git
synced 2026-10-30 19:41:58 +01:00
681 lines
27 KiB
Ruby
681 lines
27 KiB
Ruby
# 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"
|
|
|
|
# What federation sends to its peers, through the app's real request code
|
|
# against a fake federation (SPEC FL1-FL8): a crawl fetches each peer host
|
|
# once, judges it on one node list and keeps the newest signed copy; a host is
|
|
# fetched at most once per cooldown and the announcer crawls on its timer; the
|
|
# client revalidates, honours Retry-After and backs off; a crawl has a request
|
|
# budget, a domain budget and a deadline; a registration is verified within a
|
|
# deadline and few at once; and the operator docs say so.
|
|
RSpec.describe "Federation crawl limits" do
|
|
include_context "fake federation peers"
|
|
|
|
let(:peers) { FederationFakePeers }
|
|
let(:application) { FederationFakePeers::APP }
|
|
let(:root) { "root.fed.invalid" }
|
|
let(:a) { "a.fed.invalid" }
|
|
let(:b) { "b.fed.invalid" }
|
|
let(:x) { "x.fed.invalid" }
|
|
let(:victim) { "victim.fed.invalid" }
|
|
let(:hostile) { "hostile.fed.invalid" }
|
|
# A record's four node counts, all absent.
|
|
let(:no_counts) { { nodes_count: nil, meshcore_nodes_count: nil, meshtastic_nodes_count: nil, reticulum_nodes_count: nil } }
|
|
|
|
# The stored row for +domain+.
|
|
#
|
|
# @param domain [String] instance domain.
|
|
# @return [Array, nil] name, last update time and the four counts.
|
|
def stored_row(domain)
|
|
with_db do |db|
|
|
db.get_first_row(
|
|
"SELECT name, last_update_time, nodes_count, meshcore_nodes_count, meshtastic_nodes_count, " \
|
|
"reticulum_nodes_count FROM instances WHERE domain = ?",
|
|
domain,
|
|
)
|
|
end
|
|
end
|
|
|
|
describe "a crawl fetches each peer once (FL1)" do
|
|
it "fetches a peer listed in two walked lists once" do
|
|
fed.lists[root] = [peers.record(a), peers.record(b)]
|
|
fed.lists[a] = [peers.record(x)]
|
|
fed.lists[b] = [peers.record(x)]
|
|
|
|
crawl(root)
|
|
|
|
expect(fed.counts(x)).to eq(instances: 1, well_known: 1, nodes_acceptance: 1)
|
|
end
|
|
|
|
it "fetches a peer listed 64 times in one list once and stores it once" do
|
|
fed.lists[hostile] = Array.new(64) { peers.record(victim) }
|
|
|
|
crawl(hostile)
|
|
|
|
expect(fed.counts(victim)).to eq(instances: 1, well_known: 1, nodes_acceptance: 1)
|
|
expect(stored_rows.map(&:first)).to eq([victim])
|
|
end
|
|
|
|
it "never fetches its own domain" do
|
|
attributes, signature = application.ensure_self_instance_record!
|
|
own = JSON.parse(JSON.generate(application.instance_announcement_payload(attributes, signature)))
|
|
fed.lists[root] = [peers.record(root), own]
|
|
|
|
crawl(root)
|
|
|
|
expect(fed.total(attributes[:domain])).to eq(0)
|
|
expect(fed.counts(root)).to eq(instances: 1, well_known: 1, nodes_acceptance: 1)
|
|
end
|
|
|
|
it "keeps the newest signed copy of an instance and never rolls a stored row back" do
|
|
now = Time.now.to_i
|
|
with_db { |db| peers.store(db, x, name: "Stored", last_update_time: now - 60) }
|
|
fed.lists[root] = [
|
|
peers.record(x, name: "Older", last_update_time: now - (8 * 86_400)),
|
|
peers.record(x, name: "Newest", last_update_time: now),
|
|
peers.record(x, name: "Middle", last_update_time: now - 30),
|
|
]
|
|
|
|
crawl(root)
|
|
|
|
expect(stored_row(x).first(2)).to eq(["Newest", now])
|
|
end
|
|
|
|
it "keeps listing a stored instance when an older copy of its record is relayed" do
|
|
with_db { |db| peers.store(db, victim, name: "Current") }
|
|
fed.lists[hostile] = [peers.record(victim, name: "Old", last_update_time: Time.now.to_i - (8 * 86_400))]
|
|
|
|
crawl(hostile)
|
|
get "/api/instances"
|
|
|
|
expect(stored_row(victim).first).to eq("Current")
|
|
expect(JSON.parse(last_response.body).map { |row| row["domain"] }).to include(victim)
|
|
end
|
|
|
|
it "keeps a newer copy stored while the crawl fetched a record's counts" do
|
|
now = Time.now.to_i
|
|
fed.lists[root] = [peers.record(x, **no_counts, name: "Relayed", last_update_time: now - 30)]
|
|
fed.hooks << lambda do |entry|
|
|
next unless entry[:host] == x && entry[:kind] == :stats
|
|
|
|
with_db { |db| peers.store(db, x, name: "Stored meanwhile", last_update_time: now) }
|
|
end
|
|
|
|
crawl(root)
|
|
|
|
expect(stored_row(x).first(2)).to eq(["Stored meanwhile", now])
|
|
expect(fed.counts(x)).to include(stats: 1)
|
|
end
|
|
|
|
it "costs a host one well-known fetch for 64 port variants in one list" do
|
|
attacker_key = peers::KEYS["attacker-key"]
|
|
fed.lists[hostile] = (1..64).map { |port| peers.record("#{victim}:#{port}", attacker_key) }
|
|
|
|
crawl(hostile)
|
|
|
|
expect(fed.counts(victim)).to eq(well_known: 1)
|
|
reasons = log_lines("Discarded remote instance entry").map { |line| line[2][:reason] }
|
|
expect(reasons.count { |reason| reason.include?("fetched another port variant's well-known in this crawl") }).to eq(63)
|
|
end
|
|
|
|
it "lets the bare domain of a host fetch its own well-known beside the port variants" do
|
|
attacker_key = peers::KEYS["attacker-key"]
|
|
fed.lists[hostile] = (1..63).map { |port| peers.record("#{victim}:#{port}", attacker_key) } + [peers.record(victim)]
|
|
|
|
crawl(hostile)
|
|
|
|
expect(fed.counts(victim)).to eq(instances: 1, well_known: 2, nodes_acceptance: 1)
|
|
expect(stored_rows.map(&:first)).to eq([victim])
|
|
end
|
|
|
|
it "treats a default port as the bare domain" do
|
|
now = Time.now.to_i
|
|
fed.lists[root] = [
|
|
peers.record("#{x}:443", peers::KEYS[x], last_update_time: now),
|
|
peers.record(x, last_update_time: now - 10),
|
|
peers.record("spec.mesh.test:443"),
|
|
]
|
|
|
|
crawl(root)
|
|
|
|
expect(fed.counts(x)).to eq(instances: 1, well_known: 1, nodes_acceptance: 1)
|
|
expect(stored_rows.map(&:first)).to eq(["#{x}:443"])
|
|
expect(log_lines("Skipped remote instance entry").map { |line| line[2][:reason] }).to include("own instance")
|
|
end
|
|
|
|
it "counts fetched domains against FEDERATION_MAX_DOMAINS_PER_CRAWL" do
|
|
quiet = %w[q1 q2 q3 q4].map { |name| "#{name}.fed.invalid" }
|
|
fed.lists[root] = quiet.map { |domain| peers.record(domain) }
|
|
quiet.each { |domain| fed.nodes[domain] = peers.fresh_nodes(10, newest_age: 8 * 86_400) }
|
|
|
|
with_env("FEDERATION_MAX_DOMAINS_PER_CRAWL" => "3") { crawl(root) }
|
|
|
|
expect(fed.log.map { |entry| entry[:host] }.uniq).to eq([root, quiet[0], quiet[1]])
|
|
end
|
|
end
|
|
|
|
describe "one node list per peer (FL2)" do
|
|
it "judges a crawled peer on one /api/nodes?limit=10 request" do
|
|
fed.lists[root] = [peers.record(x)]
|
|
|
|
crawl(root)
|
|
|
|
expect(fed.paths(x, prefix: "/api/nodes")).to eq(["/api/nodes?limit=10"])
|
|
end
|
|
|
|
it "judges an announcing peer on one /api/nodes?limit=10 request" do
|
|
fed.lists[a] = [peers.record(a)]
|
|
|
|
expect(announce(a).status).to eq(201)
|
|
|
|
expect(fed.paths(a, prefix: "/api/nodes")).to eq(["/api/nodes?limit=10"])
|
|
end
|
|
|
|
it "stores the signed counts of a record that carries them without asking the peer" do
|
|
fed.lists[root] = [peers.record(x)]
|
|
fed.stats[x] = { "total" => { "nodes" => { "day" => 99 } }, "meshtastic" => { "nodes" => { "day" => 77 } } }
|
|
fed.nodes[x] = peers.fresh_nodes(50)
|
|
|
|
crawl(root)
|
|
|
|
expect(stored_row(x).last(4)).to eq([12, 4, 8, 0])
|
|
expect(fed.counts(x).keys).to contain_exactly(:instances, :well_known, :nodes_acceptance)
|
|
end
|
|
|
|
it "asks /api/stats, then the 24-hour list, only for a record without counts" do
|
|
fed.lists[root] = [peers.record(a, **no_counts), peers.record(b, **no_counts)]
|
|
fed.stats[a] = {
|
|
"total" => { "nodes" => { "hour" => 1, "day" => 7, "week" => 9 } },
|
|
"meshcore" => { "nodes" => { "day" => 3 } },
|
|
"meshtastic" => { "nodes" => { "day" => 4 } },
|
|
"reticulum" => { "nodes" => { "day" => 0 } },
|
|
}
|
|
fed.status[[b, :stats]] = 500
|
|
fed.nodes[b] = peers.fresh_nodes(6, protocol: "meshcore") +
|
|
peers.fresh_nodes(6, newest_age: 2 * 86_400, offset: 100)
|
|
|
|
crawl(root)
|
|
|
|
expect(stored_row(a).last(4)).to eq([7, 3, 4, 0])
|
|
expect(fed.counts(a)).to eq(instances: 1, well_known: 1, stats: 1, nodes_acceptance: 1)
|
|
expect(stored_row(b).last(4)).to eq([6, 6, 0, 0])
|
|
expect(fed.counts(b)).to eq(instances: 1, well_known: 1, stats: 1, nodes_acceptance: 1, nodes_24h: 1)
|
|
end
|
|
|
|
it "keeps a peer heard 2 days ago and refuses one heard 8 days ago on that one list" do
|
|
fed.lists[root] = [peers.record(a), peers.record(b)]
|
|
fed.nodes[a] = peers.fresh_nodes(10, newest_age: 2 * 86_400)
|
|
fed.nodes[b] = peers.fresh_nodes(10, newest_age: 8 * 86_400)
|
|
|
|
crawl(root)
|
|
|
|
expect(stored_rows.map(&:first)).to eq([a])
|
|
expect(log_lines("Discarded remote instance entry").map { |line| line[2] }).to include(
|
|
hash_including(domain: b, reason: "insufficient nodes"),
|
|
)
|
|
end
|
|
end
|
|
|
|
describe "one fetch per peer per cooldown, crawls on the timer (FL3)" do
|
|
it "verifies an announcing peer and schedules no crawl" do
|
|
fed.lists[a] = [peers.record(a), peers.record(x)]
|
|
|
|
expect(announce(a).status).to eq(201)
|
|
|
|
expect(pool.scheduled).to eq(0)
|
|
expect(fed.counts(a)).to eq(well_known: 1, nodes_acceptance: 1)
|
|
expect(fed.total(x)).to eq(0)
|
|
end
|
|
|
|
it "skips a crawl entry for a peer fetched inside the cooldown and keeps its stored row" do
|
|
now = Time.now.to_i
|
|
expect(announce(x, last_update_time: now - 60).status).to eq(201)
|
|
fed.log.clear
|
|
fed.lists[root] = [peers.record(x, name: "Relayed copy", last_update_time: now)]
|
|
|
|
crawl(root)
|
|
|
|
expect(fed.total(x)).to eq(0)
|
|
expect(stored_row(x).first(2)).to eq(["Mesh #{x}", now - 60])
|
|
end
|
|
|
|
it "fetches a peer once across two crawls inside the cooldown" do
|
|
fed.lists[root] = [peers.record(x)]
|
|
|
|
2.times { crawl(root) }
|
|
|
|
expect(fed.counts(x)).to eq(instances: 1, well_known: 1, nodes_acceptance: 1)
|
|
expect(fed.counts(root)).to eq(instances: 1)
|
|
end
|
|
|
|
it "answers a re-announcement under the stored key inside the cooldown from the stored row" do
|
|
payload = JSON.generate(peers.payload(a))
|
|
|
|
statuses = Array.new(3) { post("/api/instances", payload, json_headers).status }
|
|
|
|
expect(statuses).to eq([201, 201, 201])
|
|
expect(fed.counts(a)).to eq(well_known: 1, nodes_acceptance: 1)
|
|
end
|
|
|
|
it "stores a newer copy under the stored key inside the cooldown without a fetch and ignores an older one" do
|
|
now = Time.now.to_i
|
|
expect(announce(a, last_update_time: now - 60).status).to eq(201)
|
|
|
|
newer = announce(a, name: "Renamed", last_update_time: now)
|
|
after_newer = stored_row(a).first(2)
|
|
older = announce(a, name: "Old copy", last_update_time: now - 120)
|
|
|
|
expect([newer.status, older.status]).to eq([201, 201])
|
|
expect(after_newer).to eq(["Renamed", now])
|
|
expect(stored_row(a).first(2)).to eq(["Renamed", now])
|
|
expect(fed.counts(a)).to eq(well_known: 1, nodes_acceptance: 1)
|
|
end
|
|
|
|
it "answers 503 with Retry-After to a registration inside a cooldown that kept nothing to judge it by" do
|
|
fed.lists[root] = []
|
|
crawl(root)
|
|
|
|
response = announce(root)
|
|
|
|
expect(response.status).to eq(503)
|
|
expect(response.headers["Retry-After"]).to match(/\A\d+\z/)
|
|
expect(response.headers["Retry-After"].to_i).to be_between(1, 900)
|
|
expect(fed.counts(root)).to eq(instances: 1)
|
|
end
|
|
|
|
it "crawls from the announcer thread once per announcement interval" do
|
|
announcements = 0
|
|
roots = []
|
|
allow(PotatoMesh::Config).to receive(:federation_announcement_interval).and_return(0.05)
|
|
allow(application).to receive(:announce_instance_to_all_domains) { announcements += 1 }
|
|
allow(application).to receive(:ingest_known_instances_from!) { |_db, domain, **| roots << domain }
|
|
|
|
thread = application.start_federation_announcer!
|
|
deadline = monotonic + 3
|
|
sleep 0.05 until (announcements >= 3 && roots.size >= 3) || monotonic > deadline
|
|
application.shutdown_federation_background_work!(timeout: 1)
|
|
|
|
expect(thread.alive?).to be(false)
|
|
expect(announcements).to be >= 3
|
|
expect(roots).to include(*PotatoMesh::Config.federation_seed_domains)
|
|
end
|
|
end
|
|
|
|
describe "revalidation, Retry-After and backoff (FL4)" do
|
|
it "revalidates with If-None-Match and reuses the stored body on 304" do
|
|
fed.lists[x] = [peers.record(a)]
|
|
fed.headers[[x, :instances]] = { "ETag" => 'W/"abc123"' }
|
|
|
|
first, = application.fetch_instance_json(x, "/api/instances")
|
|
second, = application.fetch_instance_json(x, "/api/instances")
|
|
|
|
expect(fed.log.last[:headers]).to include("if-none-match" => 'W/"abc123"')
|
|
expect(fed.log.last[:status]).to eq(304)
|
|
expect(second).to eq(first)
|
|
end
|
|
|
|
it "revalidates the well-known with If-Modified-Since and never skips a fetch for max-age" do
|
|
modified = "Tue, 06 Oct 2026 10:00:00 GMT"
|
|
fed.headers[[x, :well_known]] = { "Last-Modified" => modified, "Cache-Control" => "public, max-age=86400" }
|
|
|
|
first, = application.fetch_instance_json(x, "/.well-known/potato-mesh")
|
|
second, = application.fetch_instance_json(x, "/.well-known/potato-mesh")
|
|
|
|
expect(fed.total(x)).to eq(2)
|
|
expect(fed.log.last[:headers]).to include("if-modified-since" => modified)
|
|
expect(second).to eq(first)
|
|
end
|
|
|
|
it "sends no request to a host inside the Retry-After of its 429" do
|
|
fed.status[[x, :stats]] = 429
|
|
fed.headers[[x, :stats]] = { "Retry-After" => "600" }
|
|
|
|
application.fetch_instance_json(x, "/api/stats")
|
|
payload, = application.fetch_instance_json(x, "/api/nodes?limit=10")
|
|
|
|
expect(fed.total(x)).to eq(1)
|
|
expect(payload).to be_nil
|
|
end
|
|
|
|
it "does not back a peer off for a request this instance's own deadline kept from being sent" do
|
|
payload, errors = application.with_federation_deadline(0) { application.fetch_instance_json(x, "/api/instances") }
|
|
|
|
expect(payload).to be_nil
|
|
expect(errors).to eq(["https://#{x}/api/instances: verification deadline passed"])
|
|
expect(fed.total(x)).to eq(0)
|
|
expect(PotatoMesh::App::Federation.peer_backoff.backoff_seconds(x)).to eq(0.0)
|
|
end
|
|
|
|
it "backs off a failing host and doubles the wait after the next failure" do
|
|
fed.refused << x
|
|
|
|
3.times { application.fetch_instance_json(x, "/api/instances") }
|
|
first_failure = fed.total(x)
|
|
travel(61) { application.fetch_instance_json(x, "/api/instances") }
|
|
second_failure = fed.total(x)
|
|
travel(61 + 119) { application.fetch_instance_json(x, "/api/instances") }
|
|
|
|
expect([first_failure, second_failure, fed.total(x)]).to eq([2, 4, 4])
|
|
end
|
|
end
|
|
|
|
describe "request budget, domain budget and deadline (FL6)" do
|
|
it "stops a crawl cleanly once FEDERATION_MAX_REQUESTS_PER_CRAWL requests are spent" do
|
|
domains = (1..10).map { |i| "p#{i}.fed.invalid" }
|
|
fed.lists[root] = domains.map { |domain| peers.record(domain) }
|
|
|
|
with_env("FEDERATION_MAX_REQUESTS_PER_CRAWL" => "7") { crawl(root) }
|
|
|
|
# Breadth first: the root's list, then three peers' well-known and
|
|
# node list; no peer's own list is reached (SPEC FB6).
|
|
expect(fed.total).to eq(7)
|
|
expect(stored_rows.map(&:first)).to eq(domains.first(3))
|
|
end
|
|
|
|
it "stops a crawl at its deadline, FEDERATION_TASK_TIMEOUT" do
|
|
domains = (1..6).map { |i| "slow#{i}.fed.invalid" }
|
|
fed.lists[root] = domains.map { |domain| peers.record(domain) }
|
|
fed.hooks << ->(entry) { sleep 0.25 if entry[:host].start_with?("slow") }
|
|
|
|
started = monotonic
|
|
with_env("FEDERATION_TASK_TIMEOUT" => "1") { crawl(root) }
|
|
elapsed = monotonic - started
|
|
|
|
expect(elapsed).to be < 1.6
|
|
expect(stored_rows.size).to be < domains.size
|
|
end
|
|
|
|
it "lets the worker pool's task timeout end a crawl" do
|
|
fed.lists[hostile] = %w[p1 p2 p3 p4].map { |name| peers.record("#{name}.fed.invalid") }
|
|
# The first request holds until the pool's task timeout interrupts it,
|
|
# however late that fires, so the crawl cannot go on by itself. After 5 s
|
|
# it ends anyway, so a pool and a wait that both ignore their timeouts
|
|
# fail, not hang.
|
|
gate = Queue.new
|
|
first = true
|
|
fed.hooks << lambda do |_entry|
|
|
if first
|
|
first = false
|
|
gate.pop(timeout: 5)
|
|
end
|
|
end
|
|
worker_pool = PotatoMesh::App::WorkerPool.new(size: 1, max_queue: 2, task_timeout: 0.3, name: "spec-fl6")
|
|
|
|
task = worker_pool.schedule { crawl(hostile) }
|
|
begin
|
|
# The pool's message: the wait's own deadline raises "task deadline
|
|
# exceeded", which is no task timeout ending the crawl (SPEC FL6).
|
|
expect { task.wait(timeout: 3) }.to raise_error(PotatoMesh::App::WorkerPool::TaskTimeoutError, "task exceeded timeout")
|
|
ensure
|
|
gate.close
|
|
worker_pool.shutdown(timeout: 1, force_kill_after: 1)
|
|
end
|
|
|
|
expect(fed.total).to eq(1)
|
|
end
|
|
|
|
it "fetches an unreachable host once per crawl, however often it is listed" do
|
|
with_db { |db| peers.store(db, x) }
|
|
fed.lists[root] = [peers.record(a), peers.record(x)]
|
|
fed.lists[a] = [peers.record(x)]
|
|
fed.refused << x
|
|
|
|
crawl(root)
|
|
|
|
expect(fed.total(x)).to eq(2)
|
|
end
|
|
|
|
it "judges a forged and a genuine copy of a new domain against one well-known fetch" do
|
|
genuine_key = peers::KEYS[victim]
|
|
fed.lists[hostile] = [peers.record(victim, peers::KEYS["attacker-key"], name: "Forged"), peers.record(victim)]
|
|
|
|
crawl(hostile)
|
|
|
|
expect(stored_rows.map { |row| row.first(2) }).to eq([[victim, genuine_key.public_key.to_pem]])
|
|
expect(fed.counts(victim)[:well_known]).to eq(1)
|
|
end
|
|
end
|
|
|
|
describe "bounded registration verification (FL7)" do
|
|
# Time the block.
|
|
#
|
|
# @return [Array(Object, Float)] the block's result and the seconds it took.
|
|
def timed
|
|
started = monotonic
|
|
result = yield
|
|
[result, monotonic - started]
|
|
end
|
|
|
|
it "ends a registration naming a peer that never answers within REMOTE_INSTANCE_REQUEST_TIMEOUT" do
|
|
fed.hooks << ->(entry) { sleep 5 if entry[:host] == a }
|
|
|
|
response, elapsed = with_env("REMOTE_INSTANCE_REQUEST_TIMEOUT" => "1") { timed { announce(a) } }
|
|
|
|
expect(response.status).to eq(400)
|
|
expect(JSON.parse(response.body)).to eq("error" => "failed to verify well-known document")
|
|
expect(elapsed).to be < 1.6
|
|
end
|
|
|
|
it "ends a registration whose node list stalls after its well-known answered within the same deadline" do
|
|
fed.hooks << ->(entry) { sleep(entry[:kind] == :well_known ? 0.4 : 5) if entry[:host] == a }
|
|
|
|
response, elapsed = with_env("REMOTE_INSTANCE_REQUEST_TIMEOUT" => "1") { timed { announce(a) } }
|
|
|
|
expect(response.status).to eq(400)
|
|
expect(JSON.parse(response.body)).to eq("error" => "failed to fetch nodes")
|
|
expect(elapsed).to be < 1.6
|
|
end
|
|
|
|
it "caps the verifications in flight and answers 503 with Retry-After when they are full" do
|
|
slow = (1..8).map { |i| "slow#{i}.fed.invalid" }
|
|
payloads = slow.to_h { |domain| [domain, JSON.generate(peers.payload(domain))] }
|
|
fed.hooks << ->(entry) { sleep 0.5 if entry[:host].start_with?("slow") }
|
|
|
|
responses = slow.map do |domain|
|
|
Thread.new { Rack::MockRequest.new(app).post("/api/instances", input: payloads[domain], "CONTENT_TYPE" => "application/json") }
|
|
end.map(&:value)
|
|
|
|
expect(fed.max_in_flight).to be <= 4
|
|
expect(responses.map(&:status).tally).to eq(201 => 4, 503 => 4)
|
|
expect(responses.select { |response| response.status == 503 }.map { |response| response.headers["Retry-After"] }).to all(match(/\A\d+\z/))
|
|
end
|
|
|
|
it "answers 201 only once the announcer is verified and stored" do
|
|
response = announce(a)
|
|
|
|
expect(response.status).to eq(201)
|
|
expect(JSON.parse(response.body)).to eq("status" => "registered")
|
|
expect(stored_rows.map(&:first)).to eq([a])
|
|
end
|
|
end
|
|
|
|
describe "crawl and registration edge cases" do
|
|
let(:store) { PotatoMesh::App::Federation.peer_backoff }
|
|
|
|
# Reasons of the captured "Skipped remote instance entry" lines.
|
|
#
|
|
# @return [Array<String>] skip reasons.
|
|
def skip_reasons
|
|
log_lines("Skipped remote instance entry").map { |line| line[2][:reason] }
|
|
end
|
|
|
|
it "visits one domain per host and skips another domain on it" do
|
|
fed.lists[root] = [peers.record(x), peers.record("#{x}:8443")]
|
|
|
|
crawl(root)
|
|
|
|
expect(skip_reasons).to include("host already fetched in this crawl for #{x}")
|
|
expect(fed.counts(x)).to eq(instances: 1, well_known: 1, nodes_acceptance: 1)
|
|
end
|
|
|
|
it "skips a peer that backs off and keeps its stored row" do
|
|
with_db { |db| peers.store(db, x, name: "Stored") }
|
|
store.record_failure(x)
|
|
fed.lists[root] = [peers.record(x, name: "Relayed")]
|
|
|
|
crawl(root)
|
|
|
|
expect(skip_reasons).to eq(["peer backoff"])
|
|
expect(fed.total(x)).to eq(0)
|
|
expect(stored_row(x).first).to eq("Stored")
|
|
end
|
|
|
|
it "discards a record whose host another crawl or a registration claimed meanwhile" do
|
|
allow(store).to receive(:claim).and_wrap_original do |original, host, **options|
|
|
host == x ? 30.0 : original.call(host, **options)
|
|
end
|
|
fed.lists[root] = [peers.record(x)]
|
|
|
|
crawl(root)
|
|
|
|
expect(fed.total(x)).to eq(0)
|
|
expect(log_lines("Discarded remote instance entry").map { |line| line[2][:reason] }).to eq(
|
|
["unconfirmed new domain: #{x}: peer fetch cooldown, 30 s left"],
|
|
)
|
|
end
|
|
|
|
it "fills only the counts a record lacks from the 24-hour list" do
|
|
fed.lists[root] = [peers.record(a, **no_counts, meshcore_nodes_count: 5)]
|
|
fed.status[[a, :stats]] = 500
|
|
fed.nodes[a] = peers.fresh_nodes(10, protocol: "meshtastic")
|
|
|
|
crawl(root)
|
|
|
|
expect(stored_row(a).last(4)).to eq([10, 5, 10, 0])
|
|
end
|
|
|
|
it "returns the crawl untouched for a domain that does not sanitize" do
|
|
result = with_db { |db| application.ingest_known_instances_from!(db, "bad domain") }
|
|
|
|
expect(result).to be_a(PotatoMesh::App::Federation::CrawlState)
|
|
expect(fed.total).to eq(0)
|
|
end
|
|
|
|
it "stops walking a list once shutdown is requested" do
|
|
fed.lists[root] = [peers.record(a), peers.record(b)]
|
|
fed.hooks << ->(entry) { application.request_federation_shutdown! if entry[:host] == root }
|
|
|
|
crawl(root)
|
|
|
|
expect(fed.total).to eq(1)
|
|
ensure
|
|
application.clear_federation_shutdown_request!
|
|
end
|
|
|
|
it "crawls every seed and known peer under one state, stopping at its budget" do
|
|
crawl = with_env("FEDERATION_MAX_REQUESTS_PER_CRAWL" => "1") { application.crawl_federation! }
|
|
|
|
expect(crawl.requests).to eq(1)
|
|
expect(log_lines("Federation crawl complete").map { |line| line[2] }).to eq(
|
|
[{
|
|
context: "federation.instances", root_count: PotatoMesh::Config.federation_seed_domains.size,
|
|
domain_count: 1, request_count: 1, stop_reason: "request budget spent",
|
|
}],
|
|
)
|
|
end
|
|
|
|
it "raises from a crawl whose database does not open" do
|
|
allow(application).to receive(:open_database).and_call_original
|
|
allow(application).to receive(:open_database).with(no_args).and_raise(SQLite3::CantOpenException, "spec: no db")
|
|
|
|
expect { application.crawl_federation! }.to raise_error(SQLite3::CantOpenException)
|
|
end
|
|
|
|
it "answers 503 when another registration claims the host between the check and the claim" do
|
|
allow(store).to receive(:claim).and_return(42.0)
|
|
|
|
response = announce(a)
|
|
|
|
expect(response.status).to eq(503)
|
|
expect(response.headers["Retry-After"]).to eq("42")
|
|
expect(fed.total(a)).to eq(0)
|
|
end
|
|
|
|
it "rejects a registration whose node list fetch returns nothing" do
|
|
allow_any_instance_of(application).to receive(:fetch_instance_json).and_wrap_original do |original, domain, path|
|
|
path.start_with?("/api/nodes") ? [nil, []] : original.call(domain, path)
|
|
end
|
|
|
|
response = announce(a)
|
|
|
|
expect(response.status).to eq(400)
|
|
expect(log_lines("Instance registration rejected").map { |line| line[2] }).to include(
|
|
hash_including(reason: "failed to fetch nodes", details: "no response"),
|
|
)
|
|
end
|
|
|
|
it "returns no payload and records no outcome when a request yields no body" do
|
|
allow(application).to receive(:perform_instance_http_request).and_return(nil)
|
|
|
|
expect(application.fetch_instance_json(x, "/api/instances")).to eq([nil, []])
|
|
expect(store.backoff_seconds(x)).to eq(0.0)
|
|
end
|
|
end
|
|
|
|
describe "operator docs (FL8)" do
|
|
let(:repository_root) { File.expand_path("../..", __dir__) }
|
|
let(:readme) { File.read(File.join(repository_root, "README.md")) }
|
|
let(:nginx) { File.read(File.join(repository_root, "deploy/nginx.example.conf")) }
|
|
|
|
# The README's Federation section.
|
|
#
|
|
# @return [String] the section body.
|
|
def federation_section
|
|
readme[/^### Federation\n(.*?)^### /m, 1].to_s
|
|
end
|
|
|
|
# The Advanced tuning table row of +name+.
|
|
#
|
|
# @param name [String] environment variable.
|
|
# @return [String, nil] the row, nil when the table has none.
|
|
def tuning_row(name)
|
|
readme.lines.find { |line| line.start_with?("| `#{name}` |") }
|
|
end
|
|
|
|
it "states the crawl after boot and every 8 hours" do
|
|
expect(federation_section).to include("crawl peers after boot and every 8 hours")
|
|
expect(PotatoMesh::Config.federation_announcement_interval).to eq(8 * 60 * 60)
|
|
end
|
|
|
|
it "names the User-Agent federation requests carry" do
|
|
expect(federation_section).to include("`PotatoMesh/<version> (+https://<domain>)`")
|
|
expect(application.federation_user_agent_header).to match(%r{\APotatoMesh/\S+ \(\+https://spec\.mesh\.test\)\z})
|
|
end
|
|
|
|
it "documents the fetch cooldown and the request budget with their defaults" do
|
|
expect(tuning_row("FEDERATION_PEER_FETCH_COOLDOWN")).to include("| `900` |")
|
|
expect(tuning_row("FEDERATION_MAX_REQUESTS_PER_CRAWL")).to include("| `1024` |")
|
|
end
|
|
|
|
it "says what the domain limit and the boot delay apply to" do
|
|
expect(tuning_row("FEDERATION_MAX_DOMAINS_PER_CRAWL")).to include("peer domains one crawl fetches")
|
|
expect(tuning_row("INITIAL_FEDERATION_DELAY_SECONDS")).to include("first announcement and crawl")
|
|
expect(tuning_row("FEDERATION_CRAWL_COOLDOWN")).to be_nil
|
|
end
|
|
|
|
it "carries an optional limit_req block for the federation User-Agent in the nginx example" do
|
|
expect(nginx).to match(/^# map \$http_user_agent \$potatomesh_peer \{$/)
|
|
expect(nginx).to match(%r{^#\s+~\^PotatoMesh/\s+\$binary_remote_addr;$})
|
|
expect(nginx).to match(/^# limit_req_zone \$potatomesh_peer zone=potatomesh_peers:/)
|
|
expect(nginx).to match(/^# limit_req_status 429;$/)
|
|
expect(nginx).to match(/^\s+# limit_req zone=potatomesh_peers /)
|
|
end
|
|
end
|
|
end
|