mirror of
https://github.com/l5yth/potato-mesh.git
synced 2026-08-11 11:23:58 +02:00
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:
@@ -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
@@ -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)
|
||||
|
||||
@@ -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"}' }
|
||||
|
||||
Reference in New Issue
Block a user