mirror of
https://github.com/l5yth/potato-mesh.git
synced 2026-07-06 09:52:02 +02:00
027b6160df
* web: federation signature v2 migration * web: address review comments
206 lines
8.3 KiB
Ruby
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
|