diff --git a/web/lib/potato_mesh/application/federation.rb b/web/lib/potato_mesh/application/federation.rb index 496431d..e01d4e5 100644 --- a/web/lib/potato_mesh/application/federation.rb +++ b/web/lib/potato_mesh/application/federation.rb @@ -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 diff --git a/web/lib/potato_mesh/application/routes/ingest.rb b/web/lib/potato_mesh/application/routes/ingest.rb index 6e71f03..7f4f02f 100644 --- a/web/lib/potato_mesh/application/routes/ingest.rb +++ b/web/lib/potato_mesh/application/routes/ingest.rb @@ -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( diff --git a/web/spec/app_spec.rb b/web/spec/app_spec.rb index 778770b..7da6740 100644 --- a/web/spec/app_spec.rb +++ b/web/spec/app_spec.rb @@ -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) diff --git a/web/spec/federation_spec.rb b/web/spec/federation_spec.rb index 53ccc50..d50193d 100644 --- a/web/spec/federation_spec.rb +++ b/web/spec/federation_spec.rb @@ -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"}' }