web: fix federation node counts (#749)

* web: fix federation node counts

* web: fix federation node counts

* web: address review comments
This commit is contained in:
l5y
2026-04-15 15:43:34 +02:00
committed by GitHub
parent 13b2ce9067
commit 580c2fb6ea
4 changed files with 276 additions and 5 deletions
@@ -169,6 +169,9 @@ module PotatoMesh
"lastUpdateTime" => attributes[:last_update_time],
"isPrivate" => attributes[:is_private],
"contactLink" => attributes[:contact_link],
"nodesCount" => attributes[:nodes_count],
"meshcoreNodesCount" => attributes[:meshcore_nodes_count],
"meshtasticNodesCount" => attributes[:meshtastic_nodes_count],
"signature" => signature,
}
payload.reject { |_, value| value.nil? }
@@ -1611,9 +1614,9 @@ module PotatoMesh
longitude=excluded.longitude,
last_update_time=excluded.last_update_time,
is_private=excluded.is_private,
nodes_count=excluded.nodes_count,
meshcore_nodes_count=excluded.meshcore_nodes_count,
meshtastic_nodes_count=excluded.meshtastic_nodes_count,
nodes_count=COALESCE(excluded.nodes_count, instances.nodes_count),
meshcore_nodes_count=COALESCE(excluded.meshcore_nodes_count, instances.meshcore_nodes_count),
meshtastic_nodes_count=COALESCE(excluded.meshtastic_nodes_count, instances.meshtastic_nodes_count),
contact_link=excluded.contact_link,
signature=excluded.signature
SQL
@@ -141,6 +141,9 @@ module PotatoMesh
is_private = coerce_boolean(raw_private)
signature = string_or_nil(payload["signature"])
contact_link = string_or_nil(payload["contactLink"])
nodes_count = coerce_integer(payload["nodesCount"])
meshcore_nodes_count = coerce_integer(payload["meshcoreNodesCount"])
meshtastic_nodes_count = coerce_integer(payload["meshtasticNodesCount"])
attributes = {
id: id,
@@ -155,6 +158,9 @@ module PotatoMesh
last_update_time: last_update_time,
is_private: is_private,
contact_link: contact_link,
nodes_count: nodes_count,
meshcore_nodes_count: meshcore_nodes_count,
meshtastic_nodes_count: meshtastic_nodes_count,
}
if [attributes[:id], attributes[:domain], attributes[:pubkey], signature, attributes[:last_update_time]].any?(&:nil?)
@@ -282,6 +288,31 @@ module PotatoMesh
halt 400, { error: freshness_reason || "stale node data" }.to_json
end
# Recompute node counts from the fetched node list so that
# nodes_count, meshcore_nodes_count, and meshtastic_nodes_count
# stay internally consistent. The announcement payload may carry
# sender-asserted counts, but those are unsigned and could diverge
# from the actual node data — overwriting them here is intentional.
if remote_nodes.is_a?(Array)
cutoff = Time.now.to_i - PotatoMesh::Config.remote_instance_max_node_age
total = 0
meshcore = 0
meshtastic = 0
remote_nodes.each do |n|
next unless n.is_a?(Hash)
ts = coerce_integer(n["lastHeard"] || n["last_heard"])
next unless ts && ts >= cutoff
total += 1
case (n["protocol"] || n["mesh_protocol"]).to_s.downcase
when "meshcore" then meshcore += 1
when "meshtastic" then meshtastic += 1
end
end
attributes[:nodes_count] = total
attributes[:meshcore_nodes_count] = meshcore
attributes[:meshtastic_nodes_count] = meshtastic
end
db = open_database
upsert_instance_record(db, attributes, signature)
enqueued = enqueue_federation_crawl(
+151 -2
View File
@@ -1618,13 +1618,15 @@ RSpec.describe "Potato Mesh Sinatra app" do
end
end
before do
# Stub fetch_instance_json on both the instance and class to return the
# supplied nodes array for /api/nodes requests.
def stub_remote_nodes(nodes)
fetch_stub = lambda do |host, path|
case path
when "/.well-known/potato-mesh"
[well_known_document, URI("https://#{host}#{path}")]
when "/api/nodes"
[remote_nodes, URI("https://#{host}#{path}")]
[nodes, URI("https://#{host}#{path}")]
else
[nil, []]
end
@@ -1637,6 +1639,10 @@ RSpec.describe "Potato Mesh Sinatra app" do
allow(PotatoMesh::Application).to receive(:fetch_instance_json) do |host, path|
fetch_stub.call(host, path)
end
end
before do
stub_remote_nodes(remote_nodes)
allow_any_instance_of(Sinatra::Application).to receive(:enqueue_federation_crawl) do |instance, domain, per_response_limit:, overall_limit:|
db = instance.open_database
@@ -1675,6 +1681,149 @@ RSpec.describe "Potato Mesh Sinatra app" do
end
end
it "recomputes node counts from remote nodes including per-protocol breakdown" do
now = Time.now.to_i
nodes_with_protocols = [
{ "node_id" => "mc-1", "lastHeard" => now - 10, "protocol" => "meshcore" },
{ "node_id" => "mc-2", "lastHeard" => now - 20, "protocol" => "meshcore" },
{ "node_id" => "mt-1", "lastHeard" => now - 30, "protocol" => "meshtastic" },
{ "node_id" => "mt-2", "lastHeard" => now - 40, "protocol" => "meshtastic" },
{ "node_id" => "mt-3", "lastHeard" => now - 50, "protocol" => "meshtastic" },
] + Array.new([PotatoMesh::Config.remote_instance_min_node_count - 5, 0].max) { |i|
{ "node_id" => "pad-#{i}", "lastHeard" => now - (60 + i), "protocol" => "meshtastic" }
}
stub_remote_nodes(nodes_with_protocols)
post "/api/instances", instance_payload.to_json, { "CONTENT_TYPE" => "application/json" }
expect(last_response.status).to eq(201)
with_db(readonly: true) do |db|
row = db.get_first_row(
"SELECT nodes_count, meshcore_nodes_count, meshtastic_nodes_count FROM instances WHERE id = ?",
instance_attributes[:id],
)
expect(row[0]).to eq(nodes_with_protocols.length)
expect(row[1]).to eq(2)
expect(row[2]).to eq(nodes_with_protocols.length - 2)
end
end
it "excludes nodes with lastHeard older than remote_instance_max_node_age" do
now = Time.now.to_i
max_age = PotatoMesh::Config.remote_instance_max_node_age
mixed_nodes = [
{ "node_id" => "fresh-1", "lastHeard" => now - 10 },
{ "node_id" => "fresh-2", "lastHeard" => now - 100 },
{ "node_id" => "stale-1", "lastHeard" => now - max_age - 1 },
{ "node_id" => "stale-2", "lastHeard" => now - max_age - 3600 },
] + Array.new([PotatoMesh::Config.remote_instance_min_node_count - 4, 0].max) { |i|
{ "node_id" => "pad-#{i}", "lastHeard" => now - (200 + i) }
}
stub_remote_nodes(mixed_nodes)
post "/api/instances", instance_payload.to_json, { "CONTENT_TYPE" => "application/json" }
expect(last_response.status).to eq(201)
with_db(readonly: true) do |db|
stored = db.get_first_value(
"SELECT nodes_count FROM instances WHERE id = ?",
instance_attributes[:id],
)
fresh_count = mixed_nodes.count { |n| n["lastHeard"] >= now - max_age }
expect(stored).to eq(fresh_count)
end
end
it "excludes nodes without a lastHeard timestamp" do
now = Time.now.to_i
nodes_with_gaps = [
{ "node_id" => "has-ts", "lastHeard" => now - 10 },
{ "node_id" => "no-ts" },
{ "node_id" => "null-ts", "lastHeard" => nil },
] + Array.new([PotatoMesh::Config.remote_instance_min_node_count - 3, 0].max) { |i|
{ "node_id" => "pad-#{i}", "lastHeard" => now - (20 + i) }
}
stub_remote_nodes(nodes_with_gaps)
post "/api/instances", instance_payload.to_json, { "CONTENT_TYPE" => "application/json" }
expect(last_response.status).to eq(201)
with_db(readonly: true) do |db|
stored = db.get_first_value(
"SELECT nodes_count FROM instances WHERE id = ?",
instance_attributes[:id],
)
expected = nodes_with_gaps.count { |n|
ts = n["lastHeard"]
ts.is_a?(Integer) && ts >= Time.now.to_i - PotatoMesh::Config.remote_instance_max_node_age
}
expect(stored).to eq(expected)
end
end
it "honors the last_heard snake_case key fallback" do
now = Time.now.to_i
snake_case_nodes = Array.new(PotatoMesh::Config.remote_instance_min_node_count) do |i|
{ "node_id" => "sc-#{i}", "last_heard" => now - i }
end
stub_remote_nodes(snake_case_nodes)
post "/api/instances", instance_payload.to_json, { "CONTENT_TYPE" => "application/json" }
expect(last_response.status).to eq(201)
with_db(readonly: true) do |db|
stored = db.get_first_value(
"SELECT nodes_count FROM instances WHERE id = ?",
instance_attributes[:id],
)
expect(stored).to eq(snake_case_nodes.length)
end
end
it "skips non-Hash entries in the remote nodes array" do
now = Time.now.to_i
mixed_entries = [
{ "node_id" => "valid", "lastHeard" => now - 10 },
"not-a-hash",
42,
nil,
] + Array.new([PotatoMesh::Config.remote_instance_min_node_count - 4, 0].max) { |i|
{ "node_id" => "pad-#{i}", "lastHeard" => now - (20 + i) }
}
stub_remote_nodes(mixed_entries)
post "/api/instances", instance_payload.to_json, { "CONTENT_TYPE" => "application/json" }
expect(last_response.status).to eq(201)
with_db(readonly: true) do |db|
stored = db.get_first_value(
"SELECT nodes_count FROM instances WHERE id = ?",
instance_attributes[:id],
)
hash_count = mixed_entries.count { |n|
next false unless n.is_a?(Hash)
ts = n["lastHeard"]
ts.is_a?(Integer) && ts >= Time.now.to_i - PotatoMesh::Config.remote_instance_max_node_age
}
expect(stored).to eq(hash_count)
end
end
it "accepts registrations when contactLink is part of the signed payload" do
contact_link = "https://example.test/contact"
linked_attributes = instance_attributes.merge(contact_link: contact_link)
+88
View File
@@ -787,6 +787,47 @@ RSpec.describe PotatoMesh::App::Federation do
expect(row[1]).to eq(40)
end
end
it "preserves nodes_count when re-upserted with nil" do
with_db do |db|
federation_helpers.send(:upsert_instance_record, db, base_attributes.merge(nodes_count: 77), "sig-1")
federation_helpers.send(:upsert_instance_record, db, base_attributes, "sig-2")
stored = db.get_first_value("SELECT nodes_count FROM instances WHERE id = ?", base_attributes[:id])
expect(stored).to eq(77)
end
end
it "preserves per-protocol counts when re-upserted with nil" do
with_db do |db|
attrs = base_attributes.merge(
meshcore_nodes_count: 20,
meshtastic_nodes_count: 30,
)
federation_helpers.send(:upsert_instance_record, db, attrs, "sig-1")
federation_helpers.send(:upsert_instance_record, db, base_attributes, "sig-2")
row = db.get_first_row(
"SELECT meshcore_nodes_count, meshtastic_nodes_count FROM instances WHERE id = ?",
base_attributes[:id],
)
expect(row[0]).to eq(20)
expect(row[1]).to eq(30)
end
end
it "allows nodes_count to be updated to zero" do
with_db do |db|
federation_helpers.send(:upsert_instance_record, db, base_attributes.merge(nodes_count: 50), "sig-1")
federation_helpers.send(:upsert_instance_record, db, base_attributes.merge(nodes_count: 0), "sig-2")
stored = db.get_first_value("SELECT nodes_count FROM instances WHERE id = ?", base_attributes[:id])
expect(stored).to eq(0)
end
end
end
describe ".federation_user_agent_header" do
@@ -1065,6 +1106,53 @@ RSpec.describe PotatoMesh::App::Federation do
end
end
describe ".instance_announcement_payload" do
it "includes node count fields when present" do
attributes = {
id: "test-id",
domain: "test.mesh",
pubkey: "key",
name: "Test",
version: "1.0",
channel: "#ch",
frequency: "868",
latitude: 50.0,
longitude: 10.0,
last_update_time: Time.now.to_i,
is_private: false,
contact_link: nil,
nodes_count: 42,
meshcore_nodes_count: 30,
meshtastic_nodes_count: 12,
}
payload = federation_helpers.instance_announcement_payload(attributes, "sig")
expect(payload["nodesCount"]).to eq(42)
expect(payload["meshcoreNodesCount"]).to eq(30)
expect(payload["meshtasticNodesCount"]).to eq(12)
end
it "omits node count fields when nil" do
attributes = {
id: "test-id",
domain: "test.mesh",
pubkey: "key",
name: "Test",
version: "1.0",
channel: "#ch",
frequency: "868",
latitude: 50.0,
longitude: 10.0,
last_update_time: Time.now.to_i,
is_private: false,
contact_link: nil,
}
payload = federation_helpers.instance_announcement_payload(attributes, "sig")
expect(payload).not_to have_key("nodesCount")
expect(payload).not_to have_key("meshcoreNodesCount")
expect(payload).not_to have_key("meshtasticNodesCount")
end
end
describe ".perform_announce_request" do
let(:uri) { URI.parse("https://remote.mesh/api/instances") }
let(:payload) { '{"id":"test"}' }