Files
l5y 027b6160df web: federation signature v2 migration (#805)
* web: federation signature v2 migration

* web: address review comments
2026-06-22 10:01:45 +02:00

206 lines
8.3 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
module PotatoMesh
module App
module Federation
# Process-wide memo for the most recently emitted self-registration
# decision. Sinatra spins up a fresh app instance per request so a
# plain instance variable would not survive across calls; storing the
# state on the module itself keeps the dedupe stable for the lifetime
# of the worker process.
@self_registration_log_state = { mutex: Mutex.new, last: nil }
# Accessor for the dedupe state used by {#ensure_self_instance_record!}.
#
# @return [Hash{Symbol => Object}] mutable state hash holding +:mutex+ and +:last+.
def self.self_registration_log_state
@self_registration_log_state
end
# Reset the dedupe memo. Intended for tests; production code never
# needs to clear the state because each process starts fresh.
#
# @return [void]
def self.reset_self_registration_log_state!
state = @self_registration_log_state
state[:mutex].synchronize { state[:last] = nil }
end
# Resolve the canonical domain for the running instance.
#
# @return [String, nil] sanitized instance domain or nil outside production.
# @raise [RuntimeError] when the domain cannot be determined in production.
def self_instance_domain
sanitized = sanitize_instance_domain(app_constant(:INSTANCE_DOMAIN))
return sanitized if sanitized
unless production_environment?
debug_log(
"INSTANCE_DOMAIN unavailable; skipping self instance domain",
context: "federation.instances",
app_env: string_or_nil(ENV["APP_ENV"]),
rack_env: string_or_nil(ENV["RACK_ENV"]),
source: app_constant(:INSTANCE_DOMAIN_SOURCE),
)
return nil
end
raise "INSTANCE_DOMAIN could not be determined"
end
# Determine whether the local instance should persist its own record.
#
# @param domain [String, nil] candidate domain for the running instance.
# @return [Array(Boolean, String, nil)] tuple containing a decision flag and an optional reason.
def self_instance_registration_decision(domain)
source = app_constant(:INSTANCE_DOMAIN_SOURCE)
return [false, "INSTANCE_DOMAIN source is #{source}"] unless source == :environment
sanitized = sanitize_instance_domain(domain)
return [false, "INSTANCE_DOMAIN missing or invalid"] unless sanitized
ip = ip_from_domain(sanitized)
if ip && restricted_ip_address?(ip)
return [false, "INSTANCE_DOMAIN resolves to restricted IP"]
end
[true, nil]
end
# Build the canonical attribute hash describing the local instance.
#
# @return [Hash] populated instance attribute hash.
def self_instance_attributes
domain = self_instance_domain
last_update = latest_node_update_timestamp || Time.now.to_i
cutoff = Time.now.to_i - PotatoMesh::Config.remote_instance_max_node_age
db = open_database(readonly: true)
nodes_count = active_node_count_since(cutoff, db: db)
mc_count = active_node_count_since_for_protocol(cutoff, "meshcore", db: db)
mt_count = active_node_count_since_for_protocol(cutoff, "meshtastic", db: db)
{
id: app_constant(:SELF_INSTANCE_ID),
domain: domain,
pubkey: app_constant(:INSTANCE_PUBLIC_KEY_PEM),
name: sanitized_site_name,
version: app_constant(:APP_VERSION),
channel: sanitized_channel,
frequency: sanitized_frequency,
latitude: PotatoMesh::Config.map_center_lat,
longitude: PotatoMesh::Config.map_center_lon,
last_update_time: last_update,
is_private: private_mode?,
contact_link: sanitized_contact_link,
nodes_count: nodes_count,
meshcore_nodes_count: mc_count,
meshtastic_nodes_count: mt_count,
# Forward-compat stub (SPEC FS2): always 0 until a Reticulum ingestor
# exists, but signed now so the shape need not change again.
reticulum_nodes_count: 0,
}
ensure
db&.close
end
# Sign a canonical instance attribute set with the local private key.
#
# @param attributes [Hash] canonical instance attributes.
# @return [String] base64-encoded RSA-SHA256 signature.
def sign_instance_attributes(attributes)
payload = canonical_instance_payload(attributes)
Base64.strict_encode64(
app_constant(:INSTANCE_PRIVATE_KEY).sign(OpenSSL::Digest::SHA256.new, payload),
)
end
# Compose the JSON-friendly announcement payload sent to peers.
#
# @param attributes [Hash] canonical instance attributes.
# @param signature [String] base64-encoded signature.
# @return [Hash] payload with nil entries removed.
def instance_announcement_payload(attributes, signature)
payload = {
"id" => attributes[:id],
"domain" => attributes[:domain],
"public_key" => attributes[:pubkey],
"name" => attributes[:name],
"version" => attributes[:version],
"channel" => attributes[:channel],
"frequency" => attributes[:frequency],
"latitude" => attributes[:latitude],
"longitude" => attributes[:longitude],
"last_update" => attributes[:last_update_time],
"is_private" => attributes[:is_private],
"contact_link" => attributes[:contact_link],
"nodes_count" => attributes[:nodes_count],
"meshcore_nodes_count" => attributes[:meshcore_nodes_count],
"meshtastic_nodes_count" => attributes[:meshtastic_nodes_count],
"reticulum_nodes_count" => attributes[:reticulum_nodes_count],
"signature" => signature,
"signature_version" => PotatoMesh::Config.federation_signature_version,
}
payload.reject { |_, value| value.nil? }
end
# Persist the local instance record when registration is allowed.
#
# @return [Array(Hash, String)] tuple of (attributes, signature) suitable
# for direct reuse by the announcer thread.
def ensure_self_instance_record!
attributes = self_instance_attributes
signature = sign_instance_attributes(attributes)
db = nil
allowed, reason = self_instance_registration_decision(attributes[:domain])
# Decisions are stable per process while INSTANCE_DOMAIN_SOURCE
# remains the same — without dedupe, the federation banner on every
# page navigation produced one log line apiece. Only emit when the
# tuple changes so operators still see the first decision (and any
# later flip) without the spam.
sentinel = [allowed, reason, attributes[:domain]]
state = PotatoMesh::App::Federation.self_registration_log_state
should_log = state[:mutex].synchronize do
changed = state[:last] != sentinel
state[:last] = sentinel if changed
changed
end
if allowed
db = open_database
upsert_instance_record(db, attributes, signature)
if should_log
debug_log(
"Registered self instance record",
context: "federation.instances",
domain: attributes[:domain],
instance_id: attributes[:id],
)
end
elsif should_log
debug_log(
"Skipped self instance registration",
context: "federation.instances",
domain: attributes[:domain],
reason: reason,
)
end
[attributes, signature]
ensure
db&.close
end
end
end
end