mirror of
https://github.com/l5yth/potato-mesh.git
synced 2026-03-28 17:42:48 +01:00
Restore modular app functionality (#291)
* Restore modular app functionality * Fix federation thread settings and add coverage * Use Sinatra set for federation threads * Restore 41447 as default web port
This commit is contained in:
3518
web/app.rb
3518
web/app.rb
File diff suppressed because it is too large
Load Diff
147
web/lib/potato_mesh/application.rb
Normal file
147
web/lib/potato_mesh/application.rb
Normal file
@@ -0,0 +1,147 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
require "sinatra/base"
|
||||
require "json"
|
||||
require "sqlite3"
|
||||
require "fileutils"
|
||||
require "logger"
|
||||
require "rack/utils"
|
||||
require "open3"
|
||||
require "resolv"
|
||||
require "socket"
|
||||
require "time"
|
||||
require "openssl"
|
||||
require "base64"
|
||||
require "prometheus/client"
|
||||
require "prometheus/client/formats/text"
|
||||
require "prometheus/middleware/collector"
|
||||
require "prometheus/middleware/exporter"
|
||||
require "net/http"
|
||||
require "uri"
|
||||
require "ipaddr"
|
||||
require "set"
|
||||
require "digest"
|
||||
|
||||
require_relative "config"
|
||||
require_relative "sanitizer"
|
||||
require_relative "meta"
|
||||
require_relative "application/helpers"
|
||||
require_relative "application/errors"
|
||||
require_relative "application/database"
|
||||
require_relative "application/networking"
|
||||
require_relative "application/identity"
|
||||
require_relative "application/federation"
|
||||
require_relative "application/prometheus"
|
||||
require_relative "application/queries"
|
||||
require_relative "application/data_processing"
|
||||
require_relative "application/routes/api"
|
||||
require_relative "application/routes/ingest"
|
||||
require_relative "application/routes/root"
|
||||
|
||||
module PotatoMesh
|
||||
class Application < Sinatra::Base
|
||||
extend App::Helpers
|
||||
extend App::Database
|
||||
extend App::Networking
|
||||
extend App::Identity
|
||||
extend App::Federation
|
||||
extend App::Prometheus
|
||||
extend App::Queries
|
||||
extend App::DataProcessing
|
||||
|
||||
helpers App::Helpers
|
||||
include App::Database
|
||||
include App::Networking
|
||||
include App::Identity
|
||||
include App::Federation
|
||||
include App::Prometheus
|
||||
include App::Queries
|
||||
include App::DataProcessing
|
||||
|
||||
register App::Routes::Api
|
||||
register App::Routes::Ingest
|
||||
register App::Routes::Root
|
||||
|
||||
APP_VERSION = determine_app_version
|
||||
INSTANCE_PRIVATE_KEY, INSTANCE_KEY_GENERATED = load_or_generate_instance_private_key
|
||||
INSTANCE_PUBLIC_KEY_PEM = INSTANCE_PRIVATE_KEY.public_key.export
|
||||
SELF_INSTANCE_ID = Digest::SHA256.hexdigest(INSTANCE_PUBLIC_KEY_PEM)
|
||||
INSTANCE_DOMAIN, INSTANCE_DOMAIN_SOURCE = determine_instance_domain
|
||||
|
||||
def self.apply_logger_level!
|
||||
logger = settings.logger
|
||||
return unless logger
|
||||
|
||||
logger.level = PotatoMesh::Config.debug? ? Logger::DEBUG : Logger::WARN
|
||||
end
|
||||
|
||||
# Determine the port the application should listen on.
|
||||
#
|
||||
# @param default_port [Integer] fallback port when ENV['PORT'] is absent or invalid.
|
||||
# @return [Integer] port number for the HTTP server.
|
||||
def self.resolve_port(default_port: 41_447)
|
||||
raw = ENV["PORT"]
|
||||
return default_port if raw.nil?
|
||||
|
||||
Integer(raw, 10)
|
||||
rescue ArgumentError
|
||||
default_port
|
||||
end
|
||||
|
||||
configure do
|
||||
set :public_folder, File.expand_path("../../public", __dir__)
|
||||
set :views, File.expand_path("../../views", __dir__)
|
||||
set :federation_thread, nil
|
||||
set :port, resolve_port
|
||||
|
||||
app_logger = Logger.new($stdout)
|
||||
set :logger, app_logger
|
||||
use Rack::CommonLogger, app_logger
|
||||
use Rack::Deflater
|
||||
use ::Prometheus::Middleware::Collector
|
||||
use ::Prometheus::Middleware::Exporter
|
||||
|
||||
apply_logger_level!
|
||||
|
||||
cleanup_legacy_well_known_artifacts
|
||||
init_db unless db_schema_present?
|
||||
ensure_schema_upgrades
|
||||
|
||||
log_instance_domain_resolution
|
||||
log_instance_public_key
|
||||
refresh_well_known_document_if_stale
|
||||
ensure_self_instance_record!
|
||||
update_all_prometheus_metrics_from_nodes
|
||||
|
||||
if federation_announcements_active?
|
||||
start_initial_federation_announcement!
|
||||
start_federation_announcer!
|
||||
elsif federation_enabled?
|
||||
debug_log("Federation announcements disabled in test environment")
|
||||
else
|
||||
debug_log("Federation announcements disabled by configuration or private mode")
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
if defined?(Sinatra::Application) && Sinatra::Application != PotatoMesh::Application
|
||||
Sinatra.send(:remove_const, :Application)
|
||||
end
|
||||
Sinatra::Application = PotatoMesh::Application unless defined?(Sinatra::Application)
|
||||
|
||||
APP_VERSION = PotatoMesh::Application::APP_VERSION unless defined?(APP_VERSION)
|
||||
SELF_INSTANCE_ID = PotatoMesh::Application::SELF_INSTANCE_ID unless defined?(SELF_INSTANCE_ID)
|
||||
|
||||
[
|
||||
PotatoMesh::App::Helpers,
|
||||
PotatoMesh::App::Database,
|
||||
PotatoMesh::App::Networking,
|
||||
PotatoMesh::App::Identity,
|
||||
PotatoMesh::App::Federation,
|
||||
PotatoMesh::App::Prometheus,
|
||||
PotatoMesh::App::Queries,
|
||||
PotatoMesh::App::DataProcessing,
|
||||
].each do |mod|
|
||||
Object.include(mod) unless Object < mod
|
||||
end
|
||||
1016
web/lib/potato_mesh/application/data_processing.rb
Normal file
1016
web/lib/potato_mesh/application/data_processing.rb
Normal file
File diff suppressed because it is too large
Load Diff
75
web/lib/potato_mesh/application/database.rb
Normal file
75
web/lib/potato_mesh/application/database.rb
Normal file
@@ -0,0 +1,75 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
module PotatoMesh
|
||||
module App
|
||||
module Database
|
||||
def open_database(readonly: false)
|
||||
SQLite3::Database.new(PotatoMesh::Config.db_path, readonly: readonly).tap do |db|
|
||||
db.busy_timeout = PotatoMesh::Config.db_busy_timeout_ms
|
||||
db.execute("PRAGMA foreign_keys = ON")
|
||||
end
|
||||
end
|
||||
|
||||
def with_busy_retry(
|
||||
max_retries: PotatoMesh::Config.db_busy_max_retries,
|
||||
base_delay: PotatoMesh::Config.db_busy_retry_delay
|
||||
)
|
||||
attempts = 0
|
||||
begin
|
||||
yield
|
||||
rescue SQLite3::BusyException
|
||||
attempts += 1
|
||||
raise if attempts > max_retries
|
||||
|
||||
sleep(base_delay * attempts)
|
||||
retry
|
||||
end
|
||||
end
|
||||
|
||||
def db_schema_present?
|
||||
return false unless File.exist?(PotatoMesh::Config.db_path)
|
||||
|
||||
db = open_database(readonly: true)
|
||||
required = %w[nodes messages positions telemetry neighbors instances]
|
||||
tables =
|
||||
db.execute(
|
||||
"SELECT name FROM sqlite_master WHERE type='table' AND name IN ('nodes','messages','positions','telemetry','neighbors','instances')",
|
||||
).flatten
|
||||
(required - tables).empty?
|
||||
rescue SQLite3::Exception
|
||||
false
|
||||
ensure
|
||||
db&.close
|
||||
end
|
||||
|
||||
def init_db
|
||||
FileUtils.mkdir_p(File.dirname(PotatoMesh::Config.db_path))
|
||||
db = open_database
|
||||
%w[nodes messages positions telemetry neighbors instances].each do |schema|
|
||||
sql_file = File.expand_path("../../../../data/#{schema}.sql", __dir__)
|
||||
db.execute_batch(File.read(sql_file))
|
||||
end
|
||||
ensure
|
||||
db&.close
|
||||
end
|
||||
|
||||
def ensure_schema_upgrades
|
||||
db = open_database
|
||||
node_columns = db.execute("PRAGMA table_info(nodes)").map { |row| row[1] }
|
||||
unless node_columns.include?("precision_bits")
|
||||
db.execute("ALTER TABLE nodes ADD COLUMN precision_bits INTEGER")
|
||||
end
|
||||
|
||||
tables = db.execute("SELECT name FROM sqlite_master WHERE type='table' AND name='instances'").flatten
|
||||
if tables.empty?
|
||||
sql_file = File.expand_path("../../../../data/instances.sql", __dir__)
|
||||
db.execute_batch(File.read(sql_file))
|
||||
end
|
||||
rescue SQLite3::SQLException, Errno::ENOENT => e
|
||||
warn "[warn] failed to apply schema upgrade: #{e.message}"
|
||||
ensure
|
||||
db&.close
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
7
web/lib/potato_mesh/application/errors.rb
Normal file
7
web/lib/potato_mesh/application/errors.rb
Normal file
@@ -0,0 +1,7 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
module PotatoMesh
|
||||
module App
|
||||
class InstanceFetchError < StandardError; end
|
||||
end
|
||||
end
|
||||
365
web/lib/potato_mesh/application/federation.rb
Normal file
365
web/lib/potato_mesh/application/federation.rb
Normal file
@@ -0,0 +1,365 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
module PotatoMesh
|
||||
module App
|
||||
module Federation
|
||||
def self_instance_domain
|
||||
sanitized = sanitize_instance_domain(app_constant(:INSTANCE_DOMAIN))
|
||||
return sanitized if sanitized
|
||||
|
||||
raise "INSTANCE_DOMAIN could not be determined"
|
||||
end
|
||||
|
||||
def self_instance_attributes
|
||||
domain = self_instance_domain
|
||||
last_update = latest_node_update_timestamp || Time.now.to_i
|
||||
{
|
||||
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_default_channel,
|
||||
frequency: sanitized_default_frequency,
|
||||
latitude: PotatoMesh::Config.map_center_lat,
|
||||
longitude: PotatoMesh::Config.map_center_lon,
|
||||
last_update_time: last_update,
|
||||
is_private: private_mode?,
|
||||
}
|
||||
end
|
||||
|
||||
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
|
||||
|
||||
def instance_announcement_payload(attributes, signature)
|
||||
payload = {
|
||||
"id" => attributes[:id],
|
||||
"domain" => attributes[:domain],
|
||||
"pubkey" => attributes[:pubkey],
|
||||
"name" => attributes[:name],
|
||||
"version" => attributes[:version],
|
||||
"channel" => attributes[:channel],
|
||||
"frequency" => attributes[:frequency],
|
||||
"latitude" => attributes[:latitude],
|
||||
"longitude" => attributes[:longitude],
|
||||
"lastUpdateTime" => attributes[:last_update_time],
|
||||
"isPrivate" => attributes[:is_private],
|
||||
"signature" => signature,
|
||||
}
|
||||
payload.reject { |_, value| value.nil? }
|
||||
end
|
||||
|
||||
def ensure_self_instance_record!
|
||||
attributes = self_instance_attributes
|
||||
signature = sign_instance_attributes(attributes)
|
||||
db = open_database
|
||||
upsert_instance_record(db, attributes, signature)
|
||||
debug_log(
|
||||
"Registered self instance record #{attributes[:domain]} (id: #{attributes[:id]})",
|
||||
)
|
||||
[attributes, signature]
|
||||
ensure
|
||||
db&.close
|
||||
end
|
||||
|
||||
def federation_target_domains(self_domain)
|
||||
domains = Set.new
|
||||
PotatoMesh::Config.federation_seed_domains.each do |seed|
|
||||
sanitized = sanitize_instance_domain(seed)
|
||||
domains << sanitized.downcase if sanitized
|
||||
end
|
||||
|
||||
db = open_database(readonly: true)
|
||||
db.results_as_hash = false
|
||||
rows = with_busy_retry { db.execute("SELECT domain FROM instances WHERE domain IS NOT NULL AND TRIM(domain) != ''") }
|
||||
rows.flatten.compact.each do |raw_domain|
|
||||
sanitized = sanitize_instance_domain(raw_domain)
|
||||
domains << sanitized.downcase if sanitized
|
||||
end
|
||||
if self_domain
|
||||
domains.delete(self_domain.downcase)
|
||||
end
|
||||
domains.to_a
|
||||
rescue SQLite3::Exception
|
||||
domains =
|
||||
PotatoMesh::Config.federation_seed_domains.map do |seed|
|
||||
sanitize_instance_domain(seed)&.downcase
|
||||
end.compact
|
||||
self_domain ? domains.reject { |domain| domain == self_domain.downcase } : domains
|
||||
ensure
|
||||
db&.close
|
||||
end
|
||||
|
||||
def announce_instance_to_domain(domain, payload_json)
|
||||
return false unless domain && !domain.empty?
|
||||
|
||||
instance_uri_candidates(domain, "/api/instances").each do |uri|
|
||||
begin
|
||||
http = Net::HTTP.new(uri.host, uri.port)
|
||||
http.open_timeout = PotatoMesh::Config.remote_instance_http_timeout
|
||||
http.read_timeout = PotatoMesh::Config.remote_instance_http_timeout
|
||||
http.use_ssl = uri.scheme == "https"
|
||||
response = http.start do |connection|
|
||||
request = Net::HTTP::Post.new(uri)
|
||||
request["Content-Type"] = "application/json"
|
||||
request.body = payload_json
|
||||
connection.request(request)
|
||||
end
|
||||
if response.is_a?(Net::HTTPSuccess)
|
||||
debug_log("Announced instance to #{uri}")
|
||||
return true
|
||||
end
|
||||
debug_log(
|
||||
"Federation announcement to #{uri} failed with status #{response.code}",
|
||||
)
|
||||
rescue StandardError => e
|
||||
debug_log("Federation announcement to #{uri} failed: #{e.message}")
|
||||
end
|
||||
end
|
||||
|
||||
false
|
||||
end
|
||||
|
||||
def announce_instance_to_all_domains
|
||||
return unless federation_enabled?
|
||||
|
||||
attributes, signature = ensure_self_instance_record!
|
||||
payload_json = JSON.generate(instance_announcement_payload(attributes, signature))
|
||||
domains = federation_target_domains(attributes[:domain])
|
||||
domains.each do |domain|
|
||||
announce_instance_to_domain(domain, payload_json)
|
||||
end
|
||||
debug_log(
|
||||
"Federation announcement cycle complete (targets: #{domains.join(", ")})",
|
||||
) unless domains.empty?
|
||||
end
|
||||
|
||||
def start_federation_announcer!
|
||||
existing = settings.federation_thread
|
||||
return existing if existing&.alive?
|
||||
|
||||
thread = Thread.new do
|
||||
loop do
|
||||
sleep PotatoMesh::Config.federation_announcement_interval
|
||||
begin
|
||||
announce_instance_to_all_domains
|
||||
rescue StandardError => e
|
||||
debug_log("Federation announcement loop error: #{e.message}")
|
||||
end
|
||||
end
|
||||
end
|
||||
thread.name = "potato-mesh-federation" if thread.respond_to?(:name=)
|
||||
set(:federation_thread, thread)
|
||||
thread
|
||||
end
|
||||
|
||||
def start_initial_federation_announcement!
|
||||
existing = settings.respond_to?(:initial_federation_thread) ? settings.initial_federation_thread : nil
|
||||
return existing if existing&.alive?
|
||||
|
||||
thread = Thread.new do
|
||||
begin
|
||||
announce_instance_to_all_domains
|
||||
rescue StandardError => e
|
||||
debug_log("Initial federation announcement failed: #{e.message}")
|
||||
ensure
|
||||
set(:initial_federation_thread, nil)
|
||||
end
|
||||
end
|
||||
thread.name = "potato-mesh-federation-initial" if thread.respond_to?(:name=)
|
||||
thread.report_on_exception = false if thread.respond_to?(:report_on_exception=)
|
||||
set(:initial_federation_thread, thread)
|
||||
thread
|
||||
end
|
||||
|
||||
def canonical_instance_payload(attributes)
|
||||
data = {}
|
||||
data["id"] = attributes[:id] if attributes[:id]
|
||||
data["domain"] = attributes[:domain] if attributes[:domain]
|
||||
data["pubkey"] = attributes[:pubkey] if attributes[:pubkey]
|
||||
data["name"] = attributes[:name] if attributes[:name]
|
||||
data["version"] = attributes[:version] if attributes[:version]
|
||||
data["channel"] = attributes[:channel] if attributes[:channel]
|
||||
data["frequency"] = attributes[:frequency] if attributes[:frequency]
|
||||
data["latitude"] = attributes[:latitude] unless attributes[:latitude].nil?
|
||||
data["longitude"] = attributes[:longitude] unless attributes[:longitude].nil?
|
||||
data["lastUpdateTime"] = attributes[:last_update_time] unless attributes[:last_update_time].nil?
|
||||
data["isPrivate"] = attributes[:is_private] unless attributes[:is_private].nil?
|
||||
|
||||
JSON.generate(data, sort_keys: true)
|
||||
end
|
||||
|
||||
def verify_instance_signature(attributes, signature, public_key_pem)
|
||||
return false unless signature && public_key_pem
|
||||
|
||||
canonical = canonical_instance_payload(attributes)
|
||||
signature_bytes = Base64.strict_decode64(signature)
|
||||
key = OpenSSL::PKey::RSA.new(public_key_pem)
|
||||
key.verify(OpenSSL::Digest::SHA256.new, signature_bytes, canonical)
|
||||
rescue ArgumentError, OpenSSL::PKey::PKeyError
|
||||
false
|
||||
end
|
||||
|
||||
def instance_uri_candidates(domain, path)
|
||||
base = domain
|
||||
[
|
||||
URI.parse("https://#{base}#{path}"),
|
||||
URI.parse("http://#{base}#{path}"),
|
||||
]
|
||||
rescue URI::InvalidURIError
|
||||
[]
|
||||
end
|
||||
|
||||
def perform_instance_http_request(uri)
|
||||
http = Net::HTTP.new(uri.host, uri.port)
|
||||
http.open_timeout = PotatoMesh::Config.remote_instance_http_timeout
|
||||
http.read_timeout = PotatoMesh::Config.remote_instance_http_timeout
|
||||
http.use_ssl = uri.scheme == "https"
|
||||
http.start do |connection|
|
||||
response = connection.request(Net::HTTP::Get.new(uri))
|
||||
case response
|
||||
when Net::HTTPSuccess
|
||||
response.body
|
||||
else
|
||||
raise InstanceFetchError, "unexpected response #{response.code}"
|
||||
end
|
||||
end
|
||||
rescue StandardError => e
|
||||
raise InstanceFetchError, e.message
|
||||
end
|
||||
|
||||
def fetch_instance_json(domain, path)
|
||||
errors = []
|
||||
instance_uri_candidates(domain, path).each do |uri|
|
||||
begin
|
||||
body = perform_instance_http_request(uri)
|
||||
return [JSON.parse(body), uri] if body
|
||||
rescue JSON::ParserError => e
|
||||
errors << "#{uri}: invalid JSON (#{e.message})"
|
||||
rescue InstanceFetchError => e
|
||||
errors << "#{uri}: #{e.message}"
|
||||
end
|
||||
end
|
||||
[nil, errors]
|
||||
end
|
||||
|
||||
def validate_well_known_document(document, domain, pubkey)
|
||||
unless document.is_a?(Hash)
|
||||
return [false, "document is not an object"]
|
||||
end
|
||||
|
||||
remote_pubkey = sanitize_public_key_pem(document["publicKey"])
|
||||
return [false, "public key missing"] unless remote_pubkey
|
||||
return [false, "public key mismatch"] unless remote_pubkey == pubkey
|
||||
|
||||
remote_domain = string_or_nil(document["domain"])
|
||||
return [false, "domain missing"] unless remote_domain
|
||||
return [false, "domain mismatch"] unless remote_domain.casecmp?(domain)
|
||||
|
||||
algorithm = string_or_nil(document["signatureAlgorithm"])
|
||||
unless algorithm&.casecmp?(PotatoMesh::Config.instance_signature_algorithm)
|
||||
return [false, "unsupported signature algorithm"]
|
||||
end
|
||||
|
||||
signed_payload_b64 = string_or_nil(document["signedPayload"])
|
||||
signature_b64 = string_or_nil(document["signature"])
|
||||
return [false, "missing signed payload"] unless signed_payload_b64
|
||||
return [false, "missing signature"] unless signature_b64
|
||||
|
||||
signed_payload = Base64.strict_decode64(signed_payload_b64)
|
||||
signature = Base64.strict_decode64(signature_b64)
|
||||
key = OpenSSL::PKey::RSA.new(remote_pubkey)
|
||||
unless key.verify(OpenSSL::Digest::SHA256.new, signature, signed_payload)
|
||||
return [false, "invalid well-known signature"]
|
||||
end
|
||||
|
||||
payload = JSON.parse(signed_payload)
|
||||
unless payload.is_a?(Hash)
|
||||
return [false, "signed payload is not an object"]
|
||||
end
|
||||
|
||||
payload_domain = string_or_nil(payload["domain"])
|
||||
payload_pubkey = sanitize_public_key_pem(payload["publicKey"])
|
||||
return [false, "signed payload domain mismatch"] unless payload_domain&.casecmp?(domain)
|
||||
return [false, "signed payload public key mismatch"] unless payload_pubkey == pubkey
|
||||
|
||||
[true, nil]
|
||||
rescue ArgumentError, OpenSSL::PKey::PKeyError => e
|
||||
[false, e.message]
|
||||
rescue JSON::ParserError => e
|
||||
[false, "signed payload JSON error: #{e.message}"]
|
||||
end
|
||||
|
||||
def validate_remote_nodes(nodes)
|
||||
unless nodes.is_a?(Array)
|
||||
return [false, "node response is not an array"]
|
||||
end
|
||||
|
||||
if nodes.length < PotatoMesh::Config.remote_instance_min_node_count
|
||||
return [false, "insufficient nodes"]
|
||||
end
|
||||
|
||||
latest = nodes.filter_map do |node|
|
||||
next unless node.is_a?(Hash)
|
||||
|
||||
timestamps = []
|
||||
timestamps << coerce_integer(node["last_heard"])
|
||||
timestamps << coerce_integer(node["position_time"])
|
||||
timestamps << coerce_integer(node["first_heard"])
|
||||
timestamps.compact.max
|
||||
end.compact.max
|
||||
|
||||
return [false, "missing recent node updates"] unless latest
|
||||
|
||||
cutoff = Time.now.to_i - PotatoMesh::Config.remote_instance_max_node_age
|
||||
return [false, "node data is stale"] if latest < cutoff
|
||||
|
||||
[true, nil]
|
||||
end
|
||||
|
||||
def upsert_instance_record(db, attributes, signature)
|
||||
sql = <<~SQL
|
||||
INSERT INTO instances (
|
||||
id, domain, pubkey, name, version, channel, frequency,
|
||||
latitude, longitude, last_update_time, is_private, signature
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(id) DO UPDATE SET
|
||||
domain=excluded.domain,
|
||||
pubkey=excluded.pubkey,
|
||||
name=excluded.name,
|
||||
version=excluded.version,
|
||||
channel=excluded.channel,
|
||||
frequency=excluded.frequency,
|
||||
latitude=excluded.latitude,
|
||||
longitude=excluded.longitude,
|
||||
last_update_time=excluded.last_update_time,
|
||||
is_private=excluded.is_private,
|
||||
signature=excluded.signature
|
||||
SQL
|
||||
|
||||
params = [
|
||||
attributes[:id],
|
||||
attributes[:domain],
|
||||
attributes[:pubkey],
|
||||
attributes[:name],
|
||||
attributes[:version],
|
||||
attributes[:channel],
|
||||
attributes[:frequency],
|
||||
attributes[:latitude],
|
||||
attributes[:longitude],
|
||||
attributes[:last_update_time],
|
||||
attributes[:is_private] ? 1 : 0,
|
||||
signature,
|
||||
]
|
||||
|
||||
with_busy_retry do
|
||||
db.execute(sql, params)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
212
web/lib/potato_mesh/application/helpers.rb
Normal file
212
web/lib/potato_mesh/application/helpers.rb
Normal file
@@ -0,0 +1,212 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
module PotatoMesh
|
||||
module App
|
||||
module Helpers
|
||||
def app_constant(name)
|
||||
PotatoMesh::Application.const_get(name)
|
||||
end
|
||||
|
||||
def prom_report_ids
|
||||
PotatoMesh::Config.prom_report_id_list
|
||||
end
|
||||
|
||||
def fetch_config_string(key, default)
|
||||
PotatoMesh::Config.fetch_string(key, default)
|
||||
end
|
||||
|
||||
def string_or_nil(value)
|
||||
PotatoMesh::Sanitizer.string_or_nil(value)
|
||||
end
|
||||
|
||||
def sanitize_instance_domain(value)
|
||||
PotatoMesh::Sanitizer.sanitize_instance_domain(value)
|
||||
end
|
||||
|
||||
def instance_domain_host(domain)
|
||||
PotatoMesh::Sanitizer.instance_domain_host(domain)
|
||||
end
|
||||
|
||||
def ip_from_domain(domain)
|
||||
PotatoMesh::Sanitizer.ip_from_domain(domain)
|
||||
end
|
||||
|
||||
def sanitized_string(value)
|
||||
PotatoMesh::Sanitizer.sanitized_string(value)
|
||||
end
|
||||
|
||||
def sanitized_site_name
|
||||
PotatoMesh::Sanitizer.sanitized_site_name
|
||||
end
|
||||
|
||||
def sanitized_default_channel
|
||||
PotatoMesh::Sanitizer.sanitized_default_channel
|
||||
end
|
||||
|
||||
def sanitized_default_frequency
|
||||
PotatoMesh::Sanitizer.sanitized_default_frequency
|
||||
end
|
||||
|
||||
def frontend_app_config
|
||||
{
|
||||
refreshIntervalSeconds: PotatoMesh::Config.refresh_interval_seconds,
|
||||
refreshMs: PotatoMesh::Config.refresh_interval_seconds * 1000,
|
||||
chatEnabled: !private_mode?,
|
||||
defaultChannel: sanitized_default_channel,
|
||||
defaultFrequency: sanitized_default_frequency,
|
||||
mapCenter: {
|
||||
lat: PotatoMesh::Config.map_center_lat,
|
||||
lon: PotatoMesh::Config.map_center_lon,
|
||||
},
|
||||
maxNodeDistanceKm: PotatoMesh::Config.max_node_distance_km,
|
||||
tileFilters: PotatoMesh::Config.tile_filters,
|
||||
instanceDomain: app_constant(:INSTANCE_DOMAIN),
|
||||
}
|
||||
end
|
||||
|
||||
def sanitized_matrix_room
|
||||
PotatoMesh::Sanitizer.sanitized_matrix_room
|
||||
end
|
||||
|
||||
def sanitized_max_distance_km
|
||||
PotatoMesh::Sanitizer.sanitized_max_distance_km
|
||||
end
|
||||
|
||||
def formatted_distance_km(distance)
|
||||
PotatoMesh::Meta.formatted_distance_km(distance)
|
||||
end
|
||||
|
||||
def meta_description
|
||||
PotatoMesh::Meta.description(private_mode: private_mode?)
|
||||
end
|
||||
|
||||
def meta_configuration
|
||||
PotatoMesh::Meta.configuration(private_mode: private_mode?)
|
||||
end
|
||||
|
||||
def coerce_integer(value)
|
||||
case value
|
||||
when Integer
|
||||
value
|
||||
when Float
|
||||
value.finite? ? value.to_i : nil
|
||||
when Numeric
|
||||
value.to_i
|
||||
when String
|
||||
trimmed = value.strip
|
||||
return nil if trimmed.empty?
|
||||
return trimmed.to_i(16) if trimmed.match?(/\A0[xX][0-9A-Fa-f]+\z/)
|
||||
return trimmed.to_i(10) if trimmed.match?(/\A-?\d+\z/)
|
||||
begin
|
||||
float_val = Float(trimmed)
|
||||
float_val.finite? ? float_val.to_i : nil
|
||||
rescue ArgumentError
|
||||
nil
|
||||
end
|
||||
else
|
||||
nil
|
||||
end
|
||||
end
|
||||
|
||||
def coerce_float(value)
|
||||
case value
|
||||
when Float
|
||||
value.finite? ? value : nil
|
||||
when Integer
|
||||
value.to_f
|
||||
when Numeric
|
||||
value.to_f
|
||||
when String
|
||||
trimmed = value.strip
|
||||
return nil if trimmed.empty?
|
||||
begin
|
||||
float_val = Float(trimmed)
|
||||
float_val.finite? ? float_val : nil
|
||||
rescue ArgumentError
|
||||
nil
|
||||
end
|
||||
else
|
||||
nil
|
||||
end
|
||||
end
|
||||
|
||||
def coerce_boolean(value)
|
||||
case value
|
||||
when true, false
|
||||
value
|
||||
when String
|
||||
trimmed = value.strip.downcase
|
||||
return true if %w[true 1 yes y].include?(trimmed)
|
||||
return false if %w[false 0 no n].include?(trimmed)
|
||||
nil
|
||||
when Numeric
|
||||
!value.to_i.zero?
|
||||
else
|
||||
nil
|
||||
end
|
||||
end
|
||||
|
||||
def sanitize_public_key_pem(value)
|
||||
return nil if value.nil?
|
||||
|
||||
pem = value.is_a?(String) ? value : value.to_s
|
||||
pem = pem.gsub(/\r\n?/, "\n")
|
||||
return nil if pem.strip.empty?
|
||||
|
||||
pem
|
||||
end
|
||||
|
||||
def normalize_json_value(value)
|
||||
case value
|
||||
when Hash
|
||||
value.each_with_object({}) do |(key, val), memo|
|
||||
memo[key.to_s] = normalize_json_value(val)
|
||||
end
|
||||
when Array
|
||||
value.map { |element| normalize_json_value(element) }
|
||||
else
|
||||
value
|
||||
end
|
||||
end
|
||||
|
||||
def normalize_json_object(value)
|
||||
case value
|
||||
when Hash
|
||||
normalize_json_value(value)
|
||||
when String
|
||||
trimmed = value.strip
|
||||
return nil if trimmed.empty?
|
||||
begin
|
||||
parsed = JSON.parse(trimmed)
|
||||
rescue JSON::ParserError
|
||||
return nil
|
||||
end
|
||||
parsed.is_a?(Hash) ? normalize_json_value(parsed) : nil
|
||||
else
|
||||
nil
|
||||
end
|
||||
end
|
||||
|
||||
def debug_log(message)
|
||||
logger = settings.logger if respond_to?(:settings)
|
||||
logger&.debug(message)
|
||||
end
|
||||
|
||||
def private_mode?
|
||||
ENV["PRIVATE"] == "1"
|
||||
end
|
||||
|
||||
def test_environment?
|
||||
ENV["RACK_ENV"] == "test"
|
||||
end
|
||||
|
||||
def federation_enabled?
|
||||
ENV.fetch("FEDERATION", "1") != "0" && !private_mode?
|
||||
end
|
||||
|
||||
def federation_announcements_active?
|
||||
federation_enabled? && !test_environment?
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
164
web/lib/potato_mesh/application/identity.rb
Normal file
164
web/lib/potato_mesh/application/identity.rb
Normal file
@@ -0,0 +1,164 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
module PotatoMesh
|
||||
module App
|
||||
module Identity
|
||||
def determine_app_version
|
||||
repo_root = File.expand_path("../../..", __dir__)
|
||||
git_dir = File.join(repo_root, ".git")
|
||||
return PotatoMesh::Config.version_fallback unless File.directory?(git_dir)
|
||||
|
||||
stdout, status = Open3.capture2("git", "-C", repo_root, "describe", "--tags", "--long", "--abbrev=7")
|
||||
return PotatoMesh::Config.version_fallback unless status.success?
|
||||
|
||||
raw = stdout.strip
|
||||
return PotatoMesh::Config.version_fallback if raw.empty?
|
||||
|
||||
match = /\A(?<tag>.+)-(?<count>\d+)-g(?<hash>[0-9a-f]+)\z/.match(raw)
|
||||
return raw unless match
|
||||
|
||||
tag = match[:tag]
|
||||
count = match[:count].to_i
|
||||
hash = match[:hash]
|
||||
return tag if count.zero?
|
||||
|
||||
"#{tag}+#{count}-#{hash}"
|
||||
rescue StandardError
|
||||
PotatoMesh::Config.version_fallback
|
||||
end
|
||||
|
||||
def load_or_generate_instance_private_key
|
||||
keyfile_path = PotatoMesh::Config.keyfile_path
|
||||
FileUtils.mkdir_p(File.dirname(keyfile_path))
|
||||
if File.exist?(keyfile_path)
|
||||
contents = File.binread(keyfile_path)
|
||||
return [OpenSSL::PKey.read(contents), false]
|
||||
end
|
||||
|
||||
key = OpenSSL::PKey::RSA.new(2048)
|
||||
File.open(keyfile_path, File::WRONLY | File::CREAT | File::TRUNC, 0o600) do |file|
|
||||
file.write(key.export)
|
||||
end
|
||||
[key, true]
|
||||
rescue OpenSSL::PKey::PKeyError, ArgumentError => e
|
||||
warn "[warn] failed to load instance private key, generating a new key: #{e.message}"
|
||||
key = OpenSSL::PKey::RSA.new(2048)
|
||||
File.open(keyfile_path, File::WRONLY | File::CREAT | File::TRUNC, 0o600) do |file|
|
||||
file.write(key.export)
|
||||
end
|
||||
[key, true]
|
||||
end
|
||||
|
||||
def well_known_directory
|
||||
PotatoMesh::Config.well_known_storage_root
|
||||
end
|
||||
|
||||
def well_known_file_path
|
||||
File.join(
|
||||
well_known_directory,
|
||||
File.basename(PotatoMesh::Config.well_known_relative_path),
|
||||
)
|
||||
end
|
||||
|
||||
def cleanup_legacy_well_known_artifacts
|
||||
legacy_path = PotatoMesh::Config.legacy_public_well_known_path
|
||||
FileUtils.rm_f(legacy_path)
|
||||
legacy_dir = File.dirname(legacy_path)
|
||||
FileUtils.rmdir(legacy_dir) if Dir.exist?(legacy_dir) && Dir.empty?(legacy_dir)
|
||||
rescue SystemCallError
|
||||
# Ignore errors removing legacy static files; failure only means the directory
|
||||
# or file did not exist or is in use.
|
||||
end
|
||||
|
||||
def build_well_known_document
|
||||
last_update = latest_node_update_timestamp
|
||||
payload = {
|
||||
publicKey: app_constant(:INSTANCE_PUBLIC_KEY_PEM),
|
||||
name: sanitized_site_name,
|
||||
version: app_constant(:APP_VERSION),
|
||||
domain: app_constant(:INSTANCE_DOMAIN),
|
||||
lastUpdate: last_update,
|
||||
}
|
||||
|
||||
signed_payload = JSON.generate(payload, sort_keys: true)
|
||||
signature = Base64.strict_encode64(
|
||||
app_constant(:INSTANCE_PRIVATE_KEY).sign(OpenSSL::Digest::SHA256.new, signed_payload),
|
||||
)
|
||||
|
||||
document = payload.merge(
|
||||
signature: signature,
|
||||
signatureAlgorithm: PotatoMesh::Config.instance_signature_algorithm,
|
||||
signedPayload: Base64.strict_encode64(signed_payload),
|
||||
)
|
||||
|
||||
json_output = JSON.pretty_generate(document)
|
||||
[json_output, signature]
|
||||
end
|
||||
|
||||
def refresh_well_known_document_if_stale
|
||||
FileUtils.mkdir_p(well_known_directory)
|
||||
path = well_known_file_path
|
||||
now = Time.now
|
||||
if File.exist?(path)
|
||||
mtime = File.mtime(path)
|
||||
if (now - mtime) < PotatoMesh::Config.well_known_refresh_interval
|
||||
return
|
||||
end
|
||||
end
|
||||
|
||||
json_output, signature = build_well_known_document
|
||||
File.open(path, File::WRONLY | File::CREAT | File::TRUNC, 0o644) do |file|
|
||||
file.write(json_output)
|
||||
file.write("\n") unless json_output.end_with?("\n")
|
||||
end
|
||||
|
||||
debug_log("Updated #{PotatoMesh::Config.well_known_relative_path} content: #{json_output}")
|
||||
debug_log(
|
||||
"Updated #{PotatoMesh::Config.well_known_relative_path} signature (#{PotatoMesh::Config.instance_signature_algorithm}): #{signature}",
|
||||
)
|
||||
end
|
||||
|
||||
def latest_node_update_timestamp
|
||||
return nil unless File.exist?(PotatoMesh::Config.db_path)
|
||||
|
||||
db = open_database(readonly: true)
|
||||
value = db.get_first_value(
|
||||
"SELECT MAX(COALESCE(last_heard, first_heard, position_time)) FROM nodes",
|
||||
)
|
||||
value&.to_i
|
||||
rescue SQLite3::Exception
|
||||
nil
|
||||
ensure
|
||||
db&.close
|
||||
end
|
||||
|
||||
def log_instance_public_key
|
||||
debug_log("Instance public key (PEM):\n#{app_constant(:INSTANCE_PUBLIC_KEY_PEM)}")
|
||||
if app_constant(:INSTANCE_KEY_GENERATED)
|
||||
debug_log(
|
||||
"Generated new instance private key at #{PotatoMesh::Config.keyfile_path}",
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
def log_instance_domain_resolution
|
||||
message = case app_constant(:INSTANCE_DOMAIN_SOURCE)
|
||||
when :environment
|
||||
"Instance domain configured from INSTANCE_DOMAIN environment variable: #{app_constant(:INSTANCE_DOMAIN).inspect}"
|
||||
when :reverse_dns
|
||||
"Instance domain resolved via reverse DNS lookup: #{app_constant(:INSTANCE_DOMAIN).inspect}"
|
||||
when :public_ip
|
||||
"Instance domain resolved using public IP address: #{app_constant(:INSTANCE_DOMAIN).inspect}"
|
||||
when :protected_ip
|
||||
"Instance domain resolved using protected network IP address: #{app_constant(:INSTANCE_DOMAIN).inspect}"
|
||||
when :local_ip
|
||||
"Instance domain defaulted to local IP address: #{app_constant(:INSTANCE_DOMAIN).inspect}"
|
||||
else
|
||||
"Instance domain could not be determined from the environment or local network."
|
||||
end
|
||||
|
||||
debug_log(message)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
205
web/lib/potato_mesh/application/networking.rb
Normal file
205
web/lib/potato_mesh/application/networking.rb
Normal file
@@ -0,0 +1,205 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
module PotatoMesh
|
||||
module App
|
||||
module Networking
|
||||
def canonicalize_configured_instance_domain(raw)
|
||||
return nil if raw.nil?
|
||||
|
||||
trimmed = raw.to_s.strip
|
||||
return nil if trimmed.empty?
|
||||
|
||||
candidate = trimmed
|
||||
|
||||
if candidate.include?("://")
|
||||
begin
|
||||
uri = URI.parse(candidate)
|
||||
rescue URI::InvalidURIError => e
|
||||
raise "INSTANCE_DOMAIN must be a valid hostname or URL, but parsing #{candidate.inspect} failed: #{e.message}"
|
||||
end
|
||||
|
||||
unless uri.host
|
||||
raise "INSTANCE_DOMAIN URL must include a hostname: #{candidate.inspect}"
|
||||
end
|
||||
|
||||
if uri.userinfo
|
||||
raise "INSTANCE_DOMAIN URL must not include credentials: #{candidate.inspect}"
|
||||
end
|
||||
|
||||
if uri.path && !uri.path.empty? && uri.path != "/"
|
||||
raise "INSTANCE_DOMAIN URL must not include a path component: #{candidate.inspect}"
|
||||
end
|
||||
|
||||
if uri.query || uri.fragment
|
||||
raise "INSTANCE_DOMAIN URL must not include query or fragment data: #{candidate.inspect}"
|
||||
end
|
||||
|
||||
hostname = uri.hostname
|
||||
unless hostname
|
||||
raise "INSTANCE_DOMAIN URL must include a hostname: #{candidate.inspect}"
|
||||
end
|
||||
|
||||
candidate = hostname
|
||||
port = uri.port
|
||||
if port && (!uri.respond_to?(:default_port) || uri.default_port.nil? || port != uri.default_port)
|
||||
candidate = "#{candidate}:#{port}"
|
||||
elsif port && uri.to_s.match?(/:\d+/)
|
||||
candidate = "#{candidate}:#{port}"
|
||||
end
|
||||
end
|
||||
|
||||
sanitized = sanitize_instance_domain(candidate)
|
||||
unless sanitized
|
||||
raise "INSTANCE_DOMAIN must be a bare hostname (optionally with a port) without schemes or paths: #{raw.inspect}"
|
||||
end
|
||||
|
||||
sanitized.downcase
|
||||
end
|
||||
|
||||
def determine_instance_domain
|
||||
raw = ENV["INSTANCE_DOMAIN"]
|
||||
if raw
|
||||
canonical = canonicalize_configured_instance_domain(raw)
|
||||
return [canonical, :environment] if canonical
|
||||
end
|
||||
|
||||
reverse = sanitize_instance_domain(reverse_dns_domain)
|
||||
return [reverse, :reverse_dns] if reverse
|
||||
|
||||
public_ip = discover_public_ip_address
|
||||
return [public_ip, :public_ip] if public_ip
|
||||
|
||||
protected_ip = discover_protected_ip_address
|
||||
return [protected_ip, :protected_ip] if protected_ip
|
||||
|
||||
[discover_local_ip_address, :local_ip]
|
||||
end
|
||||
|
||||
def reverse_dns_domain
|
||||
Socket.ip_address_list.each do |address|
|
||||
next unless address.respond_to?(:ip?) && address.ip?
|
||||
|
||||
loopback =
|
||||
(address.respond_to?(:ipv4_loopback?) && address.ipv4_loopback?) ||
|
||||
(address.respond_to?(:ipv6_loopback?) && address.ipv6_loopback?)
|
||||
next if loopback
|
||||
|
||||
link_local =
|
||||
address.respond_to?(:ipv6_linklocal?) && address.ipv6_linklocal?
|
||||
next if link_local
|
||||
|
||||
ip = address.ip_address
|
||||
next if ip.nil? || ip.empty?
|
||||
|
||||
begin
|
||||
hostname = Resolv.getname(ip)
|
||||
trimmed = hostname&.strip
|
||||
return trimmed unless trimmed.nil? || trimmed.empty?
|
||||
rescue Resolv::ResolvError, Resolv::ResolvTimeout, SocketError
|
||||
next
|
||||
end
|
||||
end
|
||||
|
||||
nil
|
||||
end
|
||||
|
||||
def discover_public_ip_address
|
||||
address = ip_address_candidates.find { |candidate| public_ip_address?(candidate) }
|
||||
address&.ip_address
|
||||
end
|
||||
|
||||
def discover_protected_ip_address
|
||||
address = ip_address_candidates.find { |candidate| protected_ip_address?(candidate) }
|
||||
address&.ip_address
|
||||
end
|
||||
|
||||
def ip_address_candidates
|
||||
Socket.ip_address_list.select { |addr| addr.respond_to?(:ip?) && addr.ip? }
|
||||
end
|
||||
|
||||
def public_ip_address?(addr)
|
||||
ip = ipaddr_from(addr)
|
||||
return false unless ip
|
||||
return false if loopback_address?(addr, ip)
|
||||
return false if link_local_address?(addr, ip)
|
||||
return false if private_address?(addr, ip)
|
||||
return false if unspecified_address?(ip)
|
||||
|
||||
true
|
||||
end
|
||||
|
||||
def protected_ip_address?(addr)
|
||||
ip = ipaddr_from(addr)
|
||||
return false unless ip
|
||||
return false if loopback_address?(addr, ip)
|
||||
return false if link_local_address?(addr, ip)
|
||||
|
||||
private_address?(addr, ip)
|
||||
end
|
||||
|
||||
def ipaddr_from(addr)
|
||||
ip = addr.ip_address
|
||||
return nil if ip.nil? || ip.empty?
|
||||
|
||||
IPAddr.new(ip)
|
||||
rescue IPAddr::InvalidAddressError
|
||||
nil
|
||||
end
|
||||
|
||||
def loopback_address?(addr, ip)
|
||||
(addr.respond_to?(:ipv4_loopback?) && addr.ipv4_loopback?) ||
|
||||
(addr.respond_to?(:ipv6_loopback?) && addr.ipv6_loopback?) ||
|
||||
ip.loopback?
|
||||
end
|
||||
|
||||
def link_local_address?(addr, ip)
|
||||
(addr.respond_to?(:ipv6_linklocal?) && addr.ipv6_linklocal?) ||
|
||||
(ip.respond_to?(:link_local?) && ip.link_local?)
|
||||
end
|
||||
|
||||
def private_address?(addr, ip)
|
||||
if addr.respond_to?(:ipv4?) && addr.ipv4? && addr.respond_to?(:ipv4_private?)
|
||||
addr.ipv4_private?
|
||||
else
|
||||
ip.private?
|
||||
end
|
||||
end
|
||||
|
||||
def unspecified_address?(ip)
|
||||
(ip.ipv4? || ip.ipv6?) && ip.to_i.zero?
|
||||
end
|
||||
|
||||
def discover_local_ip_address
|
||||
candidates = ip_address_candidates
|
||||
|
||||
ipv4 = candidates.find do |addr|
|
||||
addr.respond_to?(:ipv4?) && addr.ipv4? && !(addr.respond_to?(:ipv4_loopback?) && addr.ipv4_loopback?)
|
||||
end
|
||||
return ipv4.ip_address if ipv4
|
||||
|
||||
non_loopback = candidates.find do |addr|
|
||||
!(addr.respond_to?(:ipv4_loopback?) && addr.ipv4_loopback?) &&
|
||||
!(addr.respond_to?(:ipv6_loopback?) && addr.ipv6_loopback?)
|
||||
end
|
||||
return non_loopback.ip_address if non_loopback
|
||||
|
||||
loopback = candidates.find do |addr|
|
||||
(addr.respond_to?(:ipv4_loopback?) && addr.ipv4_loopback?) ||
|
||||
(addr.respond_to?(:ipv6_loopback?) && addr.ipv6_loopback?)
|
||||
end
|
||||
return loopback.ip_address if loopback
|
||||
|
||||
"127.0.0.1"
|
||||
end
|
||||
|
||||
def restricted_ip_address?(ip)
|
||||
return true if ip.loopback?
|
||||
return true if ip.private?
|
||||
return true if ip.link_local?
|
||||
return true if ip.to_i.zero?
|
||||
|
||||
false
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
184
web/lib/potato_mesh/application/prometheus.rb
Normal file
184
web/lib/potato_mesh/application/prometheus.rb
Normal file
@@ -0,0 +1,184 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
module PotatoMesh
|
||||
module App
|
||||
module Prometheus
|
||||
MESSAGES_TOTAL = ::Prometheus::Client::Counter.new(
|
||||
:meshtastic_messages_total,
|
||||
docstring: "Total number of messages received",
|
||||
)
|
||||
|
||||
NODES_GAUGE = ::Prometheus::Client::Gauge.new(
|
||||
:meshtastic_nodes,
|
||||
docstring: "Number of nodes tracked",
|
||||
)
|
||||
|
||||
NODE_GAUGE = ::Prometheus::Client::Gauge.new(
|
||||
:meshtastic_node,
|
||||
docstring: "Presence of a Meshtastic node",
|
||||
labels: %i[node short_name long_name hw_model role],
|
||||
)
|
||||
|
||||
NODE_BATTERY_LEVEL = ::Prometheus::Client::Gauge.new(
|
||||
:meshtastic_node_battery_level,
|
||||
docstring: "Battery level of a Meshtastic node",
|
||||
labels: [:node],
|
||||
)
|
||||
|
||||
NODE_VOLTAGE = ::Prometheus::Client::Gauge.new(
|
||||
:meshtastic_node_voltage,
|
||||
docstring: "Battery voltage of a Meshtastic node",
|
||||
labels: [:node],
|
||||
)
|
||||
|
||||
NODE_UPTIME = ::Prometheus::Client::Gauge.new(
|
||||
:meshtastic_node_uptime_seconds,
|
||||
docstring: "Uptime reported by a Meshtastic node",
|
||||
labels: [:node],
|
||||
)
|
||||
|
||||
NODE_CHANNEL_UTIL = ::Prometheus::Client::Gauge.new(
|
||||
:meshtastic_node_channel_utilization,
|
||||
docstring: "Channel utilization reported by a Meshtastic node",
|
||||
labels: [:node],
|
||||
)
|
||||
|
||||
NODE_AIR_UTIL_TX = ::Prometheus::Client::Gauge.new(
|
||||
:meshtastic_node_transmit_air_utilization,
|
||||
docstring: "Transmit air utilization reported by a Meshtastic node",
|
||||
labels: [:node],
|
||||
)
|
||||
|
||||
NODE_LATITUDE = ::Prometheus::Client::Gauge.new(
|
||||
:meshtastic_node_latitude,
|
||||
docstring: "Latitude of a Meshtastic node",
|
||||
labels: [:node],
|
||||
)
|
||||
|
||||
NODE_LONGITUDE = ::Prometheus::Client::Gauge.new(
|
||||
:meshtastic_node_longitude,
|
||||
docstring: "Longitude of a Meshtastic node",
|
||||
labels: [:node],
|
||||
)
|
||||
|
||||
NODE_ALTITUDE = ::Prometheus::Client::Gauge.new(
|
||||
:meshtastic_node_altitude,
|
||||
docstring: "Altitude of a Meshtastic node",
|
||||
labels: [:node],
|
||||
)
|
||||
|
||||
METRICS = [
|
||||
MESSAGES_TOTAL,
|
||||
NODES_GAUGE,
|
||||
NODE_GAUGE,
|
||||
NODE_BATTERY_LEVEL,
|
||||
NODE_VOLTAGE,
|
||||
NODE_UPTIME,
|
||||
NODE_CHANNEL_UTIL,
|
||||
NODE_AIR_UTIL_TX,
|
||||
NODE_LATITUDE,
|
||||
NODE_LONGITUDE,
|
||||
NODE_ALTITUDE,
|
||||
].freeze
|
||||
|
||||
METRICS.each do |metric|
|
||||
::Prometheus::Client.registry.register(metric)
|
||||
rescue ::Prometheus::Client::Registry::AlreadyRegisteredError
|
||||
# Ignore duplicate registrations when the code is reloaded.
|
||||
end
|
||||
|
||||
def update_prometheus_metrics(node_id, user = nil, role = "", met = nil, pos = nil)
|
||||
ids = prom_report_ids
|
||||
return if ids.empty? || !node_id
|
||||
|
||||
return unless ids[0] == "*" || ids.include?(node_id)
|
||||
|
||||
if user && user.is_a?(Hash) && role && role != ""
|
||||
NODE_GAUGE.set(
|
||||
1,
|
||||
labels: {
|
||||
node: node_id,
|
||||
short_name: user["shortName"],
|
||||
long_name: user["longName"],
|
||||
hw_model: user["hwModel"],
|
||||
role: role,
|
||||
},
|
||||
)
|
||||
end
|
||||
|
||||
if met && met.is_a?(Hash)
|
||||
if met["batteryLevel"]
|
||||
NODE_BATTERY_LEVEL.set(met["batteryLevel"], labels: { node: node_id })
|
||||
end
|
||||
|
||||
if met["voltage"]
|
||||
NODE_VOLTAGE.set(met["voltage"], labels: { node: node_id })
|
||||
end
|
||||
|
||||
if met["uptimeSeconds"]
|
||||
NODE_UPTIME.set(met["uptimeSeconds"], labels: { node: node_id })
|
||||
end
|
||||
|
||||
if met["channelUtilization"]
|
||||
NODE_CHANNEL_UTIL.set(met["channelUtilization"], labels: { node: node_id })
|
||||
end
|
||||
|
||||
if met["airUtilTx"]
|
||||
NODE_AIR_UTIL_TX.set(met["airUtilTx"], labels: { node: node_id })
|
||||
end
|
||||
end
|
||||
|
||||
if pos && pos.is_a?(Hash)
|
||||
if pos["latitude"]
|
||||
NODE_LATITUDE.set(pos["latitude"], labels: { node: node_id })
|
||||
end
|
||||
|
||||
if pos["longitude"]
|
||||
NODE_LONGITUDE.set(pos["longitude"], labels: { node: node_id })
|
||||
end
|
||||
|
||||
if pos["altitude"]
|
||||
NODE_ALTITUDE.set(pos["altitude"], labels: { node: node_id })
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def update_all_prometheus_metrics_from_nodes
|
||||
nodes = query_nodes(1000)
|
||||
|
||||
NODES_GAUGE.set(nodes.size)
|
||||
|
||||
ids = prom_report_ids
|
||||
unless ids.empty?
|
||||
nodes.each do |n|
|
||||
node_id = n["node_id"]
|
||||
|
||||
next if ids[0] != "*" && !ids.include?(node_id)
|
||||
|
||||
update_prometheus_metrics(
|
||||
node_id,
|
||||
{
|
||||
"shortName" => n["short_name"] || "",
|
||||
"longName" => n["long_name"] || "",
|
||||
"hwModel" => n["hw_model"] || "",
|
||||
},
|
||||
n["role"] || "",
|
||||
{
|
||||
"batteryLevel" => n["battery_level"],
|
||||
"voltage" => n["voltage"],
|
||||
"uptimeSeconds" => n["uptime_seconds"],
|
||||
"channelUtilization" => n["channel_utilization"],
|
||||
"airUtilTx" => n["air_util_tx"],
|
||||
},
|
||||
{
|
||||
"latitude" => n["latitude"],
|
||||
"longitude" => n["longitude"],
|
||||
"altitude" => n["altitude"],
|
||||
},
|
||||
)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
372
web/lib/potato_mesh/application/queries.rb
Normal file
372
web/lib/potato_mesh/application/queries.rb
Normal file
@@ -0,0 +1,372 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
module PotatoMesh
|
||||
module App
|
||||
module Queries
|
||||
def node_reference_tokens(node_ref)
|
||||
parts = canonical_node_parts(node_ref)
|
||||
canonical_id, numeric_id = parts ? parts[0, 2] : [nil, nil]
|
||||
|
||||
string_values = []
|
||||
numeric_values = []
|
||||
|
||||
case node_ref
|
||||
when Integer
|
||||
numeric_values << node_ref
|
||||
string_values << node_ref.to_s
|
||||
when Numeric
|
||||
coerced = node_ref.to_i
|
||||
numeric_values << coerced
|
||||
string_values << coerced.to_s
|
||||
when String
|
||||
trimmed = node_ref.strip
|
||||
unless trimmed.empty?
|
||||
string_values << trimmed
|
||||
numeric_values << trimmed.to_i if trimmed.match?(/\A-?\d+\z/)
|
||||
end
|
||||
when nil
|
||||
# no-op
|
||||
else
|
||||
coerced = node_ref.to_s.strip
|
||||
string_values << coerced unless coerced.empty?
|
||||
end
|
||||
|
||||
if canonical_id
|
||||
string_values << canonical_id
|
||||
string_values << canonical_id.upcase
|
||||
end
|
||||
|
||||
if numeric_id
|
||||
numeric_values << numeric_id
|
||||
string_values << numeric_id.to_s
|
||||
end
|
||||
|
||||
cleaned_strings = string_values.compact.map(&:to_s).map(&:strip).reject(&:empty?).uniq
|
||||
cleaned_numbers = numeric_values.compact.map do |value|
|
||||
begin
|
||||
Integer(value, 10)
|
||||
rescue ArgumentError, TypeError
|
||||
nil
|
||||
end
|
||||
end.compact.uniq
|
||||
|
||||
{
|
||||
string_values: cleaned_strings,
|
||||
numeric_values: cleaned_numbers,
|
||||
}
|
||||
end
|
||||
|
||||
def node_lookup_clause(node_ref, string_columns:, numeric_columns: [])
|
||||
tokens = node_reference_tokens(node_ref)
|
||||
string_values = tokens[:string_values]
|
||||
numeric_values = tokens[:numeric_values]
|
||||
|
||||
clauses = []
|
||||
params = []
|
||||
|
||||
unless string_columns.empty? || string_values.empty?
|
||||
string_columns.each do |column|
|
||||
placeholders = Array.new(string_values.length, "?").join(", ")
|
||||
clauses << "#{column} IN (#{placeholders})"
|
||||
params.concat(string_values)
|
||||
end
|
||||
end
|
||||
|
||||
unless numeric_columns.empty? || numeric_values.empty?
|
||||
numeric_columns.each do |column|
|
||||
placeholders = Array.new(numeric_values.length, "?").join(", ")
|
||||
clauses << "#{column} IN (#{placeholders})"
|
||||
params.concat(numeric_values)
|
||||
end
|
||||
end
|
||||
|
||||
return nil if clauses.empty?
|
||||
|
||||
["(#{clauses.join(" OR ")})", params]
|
||||
end
|
||||
|
||||
def query_nodes(limit, node_ref: nil)
|
||||
db = open_database(readonly: true)
|
||||
db.results_as_hash = true
|
||||
now = Time.now.to_i
|
||||
min_last_heard = now - PotatoMesh::Config.week_seconds
|
||||
params = []
|
||||
where_clauses = []
|
||||
|
||||
if node_ref
|
||||
clause = node_lookup_clause(node_ref, string_columns: ["node_id"], numeric_columns: ["num"])
|
||||
return [] unless clause
|
||||
where_clauses << clause.first
|
||||
params.concat(clause.last)
|
||||
else
|
||||
where_clauses << "last_heard >= ?"
|
||||
params << min_last_heard
|
||||
end
|
||||
|
||||
if private_mode?
|
||||
where_clauses << "(role IS NULL OR role <> 'CLIENT_HIDDEN')"
|
||||
end
|
||||
|
||||
sql = <<~SQL
|
||||
SELECT node_id, short_name, long_name, hw_model, role, snr,
|
||||
battery_level, voltage, last_heard, first_heard,
|
||||
uptime_seconds, channel_utilization, air_util_tx,
|
||||
position_time, location_source, precision_bits,
|
||||
latitude, longitude, altitude
|
||||
FROM nodes
|
||||
SQL
|
||||
sql += " WHERE #{where_clauses.join(" AND ")}\n" if where_clauses.any?
|
||||
sql += <<~SQL
|
||||
ORDER BY last_heard DESC
|
||||
LIMIT ?
|
||||
SQL
|
||||
params << limit
|
||||
|
||||
rows = db.execute(sql, params)
|
||||
rows.each do |r|
|
||||
r["role"] ||= "CLIENT"
|
||||
lh = r["last_heard"]&.to_i
|
||||
pt = r["position_time"]&.to_i
|
||||
lh = now if lh && lh > now
|
||||
pt = nil if pt && pt > now
|
||||
r["last_heard"] = lh
|
||||
r["position_time"] = pt
|
||||
r["last_seen_iso"] = Time.at(lh).utc.iso8601 if lh
|
||||
r["pos_time_iso"] = Time.at(pt).utc.iso8601 if pt
|
||||
pb = r["precision_bits"]
|
||||
r["precision_bits"] = pb.to_i if pb
|
||||
end
|
||||
rows
|
||||
ensure
|
||||
db&.close
|
||||
end
|
||||
|
||||
def query_messages(limit, node_ref: nil)
|
||||
db = open_database(readonly: true)
|
||||
db.results_as_hash = true
|
||||
params = []
|
||||
where_clauses = ["COALESCE(TRIM(m.encrypted), '') = ''"]
|
||||
|
||||
if node_ref
|
||||
clause = node_lookup_clause(node_ref, string_columns: ["m.from_id", "m.to_id"])
|
||||
return [] unless clause
|
||||
where_clauses << clause.first
|
||||
params.concat(clause.last)
|
||||
end
|
||||
|
||||
sql = <<~SQL
|
||||
SELECT m.*, n.*, m.snr AS msg_snr
|
||||
FROM messages m
|
||||
LEFT JOIN nodes n ON (
|
||||
m.from_id IS NOT NULL AND TRIM(m.from_id) <> '' AND (
|
||||
m.from_id = n.node_id OR (
|
||||
m.from_id GLOB '[0-9]*' AND CAST(m.from_id AS INTEGER) = n.num
|
||||
)
|
||||
)
|
||||
)
|
||||
SQL
|
||||
sql += " WHERE #{where_clauses.join(" AND ")}\n"
|
||||
sql += <<~SQL
|
||||
ORDER BY m.rx_time DESC
|
||||
LIMIT ?
|
||||
SQL
|
||||
params << limit
|
||||
rows = db.execute(sql, params)
|
||||
msg_fields = %w[id rx_time rx_iso from_id to_id channel portnum text encrypted msg_snr rssi hop_limit]
|
||||
rows.each do |r|
|
||||
if PotatoMesh::Config.debug? && (r["from_id"].nil? || r["from_id"].to_s.empty?)
|
||||
raw = db.execute("SELECT * FROM messages WHERE id = ?", [r["id"]]).first
|
||||
Kernel.warn "[debug] messages row before join: #{raw.inspect}"
|
||||
Kernel.warn "[debug] row after join: #{r.inspect}"
|
||||
end
|
||||
node = {}
|
||||
r.keys.each do |k|
|
||||
next if msg_fields.include?(k)
|
||||
node[k] = r.delete(k)
|
||||
end
|
||||
r["snr"] = r.delete("msg_snr")
|
||||
references = [r["from_id"]].compact
|
||||
if references.any? && (node["node_id"].nil? || node["node_id"].to_s.empty?)
|
||||
lookup_keys = []
|
||||
canonical = normalize_node_id(db, r["from_id"])
|
||||
lookup_keys << canonical if canonical
|
||||
raw_ref = r["from_id"].to_s.strip
|
||||
lookup_keys << raw_ref unless raw_ref.empty?
|
||||
lookup_keys << raw_ref.to_i if raw_ref.match?(/\A[0-9]+\z/)
|
||||
fallback = nil
|
||||
lookup_keys.uniq.each do |ref|
|
||||
sql = ref.is_a?(Integer) ? "SELECT * FROM nodes WHERE num = ?" : "SELECT * FROM nodes WHERE node_id = ?"
|
||||
fallback = db.get_first_row(sql, [ref])
|
||||
break if fallback
|
||||
end
|
||||
if fallback
|
||||
fallback.each do |key, value|
|
||||
next unless key.is_a?(String)
|
||||
next if msg_fields.include?(key)
|
||||
node[key] = value if node[key].nil?
|
||||
end
|
||||
end
|
||||
end
|
||||
node["role"] = "CLIENT" if node.key?("role") && (node["role"].nil? || node["role"].to_s.empty?)
|
||||
r["node"] = node
|
||||
|
||||
canonical_from_id = string_or_nil(node["node_id"]) || string_or_nil(normalize_node_id(db, r["from_id"]))
|
||||
if canonical_from_id
|
||||
raw_from_id = string_or_nil(r["from_id"])
|
||||
if raw_from_id.nil? || raw_from_id.match?(/\A[0-9]+\z/)
|
||||
r["from_id"] = canonical_from_id
|
||||
elsif raw_from_id.start_with?("!") && raw_from_id.casecmp(canonical_from_id) != 0
|
||||
r["from_id"] = canonical_from_id
|
||||
end
|
||||
end
|
||||
|
||||
if PotatoMesh::Config.debug? && (r["from_id"].nil? || r["from_id"].to_s.empty?)
|
||||
Kernel.warn "[debug] row after processing: #{r.inspect}"
|
||||
end
|
||||
end
|
||||
rows
|
||||
ensure
|
||||
db&.close
|
||||
end
|
||||
|
||||
def query_positions(limit, node_ref: nil)
|
||||
db = open_database(readonly: true)
|
||||
db.results_as_hash = true
|
||||
params = []
|
||||
where_clauses = []
|
||||
|
||||
if node_ref
|
||||
clause = node_lookup_clause(node_ref, string_columns: ["node_id"], numeric_columns: ["node_num"])
|
||||
return [] unless clause
|
||||
where_clauses << clause.first
|
||||
params.concat(clause.last)
|
||||
end
|
||||
|
||||
sql = <<~SQL
|
||||
SELECT * FROM positions
|
||||
SQL
|
||||
sql += " WHERE #{where_clauses.join(" AND ")}\n" if where_clauses.any?
|
||||
sql += <<~SQL
|
||||
ORDER BY rx_time DESC
|
||||
LIMIT ?
|
||||
SQL
|
||||
params << limit
|
||||
rows = db.execute(sql, params)
|
||||
now = Time.now.to_i
|
||||
rows.each do |r|
|
||||
rx_time = coerce_integer(r["rx_time"])
|
||||
r["rx_time"] = rx_time if rx_time
|
||||
r["rx_iso"] = Time.at(rx_time).utc.iso8601 if rx_time && string_or_nil(r["rx_iso"]).nil?
|
||||
|
||||
node_num = coerce_integer(r["node_num"])
|
||||
r["node_num"] = node_num if node_num
|
||||
|
||||
position_time = coerce_integer(r["position_time"])
|
||||
position_time = nil if position_time && position_time > now
|
||||
r["position_time"] = position_time
|
||||
r["position_time_iso"] = Time.at(position_time).utc.iso8601 if position_time
|
||||
|
||||
r["precision_bits"] = coerce_integer(r["precision_bits"])
|
||||
r["sats_in_view"] = coerce_integer(r["sats_in_view"])
|
||||
r["pdop"] = coerce_float(r["pdop"])
|
||||
r["snr"] = coerce_float(r["snr"])
|
||||
end
|
||||
rows
|
||||
ensure
|
||||
db&.close
|
||||
end
|
||||
|
||||
def query_neighbors(limit, node_ref: nil)
|
||||
db = open_database(readonly: true)
|
||||
db.results_as_hash = true
|
||||
params = []
|
||||
where_clauses = []
|
||||
|
||||
if node_ref
|
||||
clause = node_lookup_clause(node_ref, string_columns: ["node_id", "neighbor_id"])
|
||||
return [] unless clause
|
||||
where_clauses << clause.first
|
||||
params.concat(clause.last)
|
||||
end
|
||||
|
||||
sql = <<~SQL
|
||||
SELECT * FROM neighbors
|
||||
SQL
|
||||
sql += " WHERE #{where_clauses.join(" AND ")}\n" if where_clauses.any?
|
||||
sql += <<~SQL
|
||||
ORDER BY rx_time DESC
|
||||
LIMIT ?
|
||||
SQL
|
||||
params << limit
|
||||
rows = db.execute(sql, params)
|
||||
now = Time.now.to_i
|
||||
rows.each do |r|
|
||||
rx_time = coerce_integer(r["rx_time"])
|
||||
rx_time = now if rx_time && rx_time > now
|
||||
r["rx_time"] = rx_time if rx_time
|
||||
r["rx_iso"] = Time.at(rx_time).utc.iso8601 if rx_time
|
||||
r["snr"] = coerce_float(r["snr"])
|
||||
end
|
||||
rows
|
||||
ensure
|
||||
db&.close
|
||||
end
|
||||
|
||||
def query_telemetry(limit, node_ref: nil)
|
||||
db = open_database(readonly: true)
|
||||
db.results_as_hash = true
|
||||
params = []
|
||||
where_clauses = []
|
||||
|
||||
if node_ref
|
||||
clause = node_lookup_clause(node_ref, string_columns: ["node_id"], numeric_columns: ["node_num"])
|
||||
return [] unless clause
|
||||
where_clauses << clause.first
|
||||
params.concat(clause.last)
|
||||
end
|
||||
|
||||
sql = <<~SQL
|
||||
SELECT * FROM telemetry
|
||||
SQL
|
||||
sql += " WHERE #{where_clauses.join(" AND ")}\n" if where_clauses.any?
|
||||
sql += <<~SQL
|
||||
ORDER BY rx_time DESC
|
||||
LIMIT ?
|
||||
SQL
|
||||
params << limit
|
||||
rows = db.execute(sql, params)
|
||||
now = Time.now.to_i
|
||||
rows.each do |r|
|
||||
rx_time = coerce_integer(r["rx_time"])
|
||||
r["rx_time"] = rx_time if rx_time
|
||||
r["rx_iso"] = Time.at(rx_time).utc.iso8601 if rx_time && string_or_nil(r["rx_iso"]).nil?
|
||||
|
||||
node_num = coerce_integer(r["node_num"])
|
||||
r["node_num"] = node_num if node_num
|
||||
|
||||
telemetry_time = coerce_integer(r["telemetry_time"])
|
||||
telemetry_time = nil if telemetry_time && telemetry_time > now
|
||||
r["telemetry_time"] = telemetry_time
|
||||
r["telemetry_time_iso"] = Time.at(telemetry_time).utc.iso8601 if telemetry_time
|
||||
|
||||
r["channel"] = coerce_integer(r["channel"])
|
||||
r["hop_limit"] = coerce_integer(r["hop_limit"])
|
||||
r["rssi"] = coerce_integer(r["rssi"])
|
||||
r["bitfield"] = coerce_integer(r["bitfield"])
|
||||
r["snr"] = coerce_float(r["snr"])
|
||||
r["battery_level"] = coerce_float(r["battery_level"])
|
||||
r["voltage"] = coerce_float(r["voltage"])
|
||||
r["channel_utilization"] = coerce_float(r["channel_utilization"])
|
||||
r["air_util_tx"] = coerce_float(r["air_util_tx"])
|
||||
r["uptime_seconds"] = coerce_integer(r["uptime_seconds"])
|
||||
r["temperature"] = coerce_float(r["temperature"])
|
||||
r["relative_humidity"] = coerce_float(r["relative_humidity"])
|
||||
r["barometric_pressure"] = coerce_float(r["barometric_pressure"])
|
||||
end
|
||||
rows
|
||||
ensure
|
||||
db&.close
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
155
web/lib/potato_mesh/application/routes/api.rb
Normal file
155
web/lib/potato_mesh/application/routes/api.rb
Normal file
@@ -0,0 +1,155 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
module PotatoMesh
|
||||
module App
|
||||
module Routes
|
||||
module Api
|
||||
def self.registered(app)
|
||||
app.get "/version" do
|
||||
content_type :json
|
||||
last_update = latest_node_update_timestamp
|
||||
payload = {
|
||||
name: sanitized_site_name,
|
||||
version: app_constant(:APP_VERSION),
|
||||
lastNodeUpdate: last_update,
|
||||
config: {
|
||||
siteName: sanitized_site_name,
|
||||
defaultChannel: sanitized_default_channel,
|
||||
defaultFrequency: sanitized_default_frequency,
|
||||
refreshIntervalSeconds: PotatoMesh::Config.refresh_interval_seconds,
|
||||
mapCenter: {
|
||||
lat: PotatoMesh::Config.map_center_lat,
|
||||
lon: PotatoMesh::Config.map_center_lon,
|
||||
},
|
||||
maxNodeDistanceKm: PotatoMesh::Config.max_node_distance_km,
|
||||
matrixRoom: sanitized_matrix_room,
|
||||
instanceDomain: app_constant(:INSTANCE_DOMAIN),
|
||||
privateMode: private_mode?,
|
||||
},
|
||||
}
|
||||
payload.to_json
|
||||
end
|
||||
|
||||
app.get "/.well-known/potato-mesh" do
|
||||
refresh_well_known_document_if_stale
|
||||
cache_control :public, max_age: PotatoMesh::Config.well_known_refresh_interval
|
||||
content_type :json
|
||||
send_file well_known_file_path
|
||||
end
|
||||
|
||||
app.get "/api/nodes" do
|
||||
content_type :json
|
||||
limit = [params["limit"]&.to_i || 200, 1000].min
|
||||
query_nodes(limit).to_json
|
||||
end
|
||||
|
||||
app.get "/api/nodes/:id" do
|
||||
content_type :json
|
||||
node_ref = string_or_nil(params["id"])
|
||||
halt 400, { error: "missing node id" }.to_json unless node_ref
|
||||
limit = [params["limit"]&.to_i || 200, 1000].min
|
||||
rows = query_nodes(limit, node_ref: node_ref)
|
||||
halt 404, { error: "not found" }.to_json if rows.empty?
|
||||
rows.first.to_json
|
||||
end
|
||||
|
||||
app.get "/api/messages" do
|
||||
halt 404 if private_mode?
|
||||
content_type :json
|
||||
limit = [params["limit"]&.to_i || 200, 1000].min
|
||||
query_messages(limit).to_json
|
||||
end
|
||||
|
||||
app.get "/api/messages/:id" do
|
||||
halt 404 if private_mode?
|
||||
content_type :json
|
||||
node_ref = string_or_nil(params["id"])
|
||||
halt 400, { error: "missing node id" }.to_json unless node_ref
|
||||
limit = [params["limit"]&.to_i || 200, 1000].min
|
||||
query_messages(limit, node_ref: node_ref).to_json
|
||||
end
|
||||
|
||||
app.get "/api/positions" do
|
||||
content_type :json
|
||||
limit = [params["limit"]&.to_i || 200, 1000].min
|
||||
query_positions(limit).to_json
|
||||
end
|
||||
|
||||
app.get "/api/positions/:id" do
|
||||
content_type :json
|
||||
node_ref = string_or_nil(params["id"])
|
||||
halt 400, { error: "missing node id" }.to_json unless node_ref
|
||||
limit = [params["limit"]&.to_i || 200, 1000].min
|
||||
query_positions(limit, node_ref: node_ref).to_json
|
||||
end
|
||||
|
||||
app.get "/api/neighbors" do
|
||||
content_type :json
|
||||
limit = [params["limit"]&.to_i || 200, 1000].min
|
||||
query_neighbors(limit).to_json
|
||||
end
|
||||
|
||||
app.get "/api/neighbors/:id" do
|
||||
content_type :json
|
||||
node_ref = string_or_nil(params["id"])
|
||||
halt 400, { error: "missing node id" }.to_json unless node_ref
|
||||
limit = [params["limit"]&.to_i || 200, 1000].min
|
||||
query_neighbors(limit, node_ref: node_ref).to_json
|
||||
end
|
||||
|
||||
app.get "/api/telemetry" do
|
||||
content_type :json
|
||||
limit = [params["limit"]&.to_i || 200, 1000].min
|
||||
query_telemetry(limit).to_json
|
||||
end
|
||||
|
||||
app.get "/api/telemetry/:id" do
|
||||
content_type :json
|
||||
node_ref = string_or_nil(params["id"])
|
||||
halt 400, { error: "missing node id" }.to_json unless node_ref
|
||||
limit = [params["limit"]&.to_i || 200, 1000].min
|
||||
query_telemetry(limit, node_ref: node_ref).to_json
|
||||
end
|
||||
|
||||
app.get "/api/instances" do
|
||||
content_type :json
|
||||
ensure_self_instance_record!
|
||||
db = open_database(readonly: true)
|
||||
db.results_as_hash = true
|
||||
rows = with_busy_retry do
|
||||
db.execute(
|
||||
<<~SQL,
|
||||
SELECT id, domain, pubkey, name, version, channel, frequency,
|
||||
latitude, longitude, last_update_time, is_private, signature
|
||||
FROM instances
|
||||
WHERE domain IS NOT NULL AND TRIM(domain) != ''
|
||||
AND pubkey IS NOT NULL AND TRIM(pubkey) != ''
|
||||
ORDER BY LOWER(domain)
|
||||
SQL
|
||||
)
|
||||
end
|
||||
payload = rows.map do |row|
|
||||
{
|
||||
"id" => row["id"],
|
||||
"domain" => row["domain"],
|
||||
"pubkey" => row["pubkey"],
|
||||
"name" => row["name"],
|
||||
"version" => row["version"],
|
||||
"channel" => row["channel"],
|
||||
"frequency" => row["frequency"],
|
||||
"latitude" => row["latitude"],
|
||||
"longitude" => row["longitude"],
|
||||
"lastUpdateTime" => row["last_update_time"]&.to_i,
|
||||
"isPrivate" => row["is_private"].to_i == 1,
|
||||
"signature" => row["signature"],
|
||||
}.reject { |_, value| value.nil? }
|
||||
end
|
||||
JSON.generate(payload)
|
||||
ensure
|
||||
db&.close
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
210
web/lib/potato_mesh/application/routes/ingest.rb
Normal file
210
web/lib/potato_mesh/application/routes/ingest.rb
Normal file
@@ -0,0 +1,210 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
module PotatoMesh
|
||||
module App
|
||||
module Routes
|
||||
module Ingest
|
||||
def self.registered(app)
|
||||
app.post "/api/nodes" do
|
||||
require_token!
|
||||
content_type :json
|
||||
begin
|
||||
data = JSON.parse(read_json_body)
|
||||
rescue JSON::ParserError
|
||||
halt 400, { error: "invalid JSON" }.to_json
|
||||
end
|
||||
unless data.is_a?(Hash)
|
||||
halt 400, { error: "invalid payload" }.to_json
|
||||
end
|
||||
halt 400, { error: "too many nodes" }.to_json if data.size > 1000
|
||||
db = open_database
|
||||
data.each do |node_id, node|
|
||||
upsert_node(db, node_id, node)
|
||||
end
|
||||
PotatoMesh::App::Prometheus::NODES_GAUGE.set(query_nodes(1000).length)
|
||||
{ status: "ok" }.to_json
|
||||
ensure
|
||||
db&.close
|
||||
end
|
||||
|
||||
app.post "/api/messages" do
|
||||
halt 404 if private_mode?
|
||||
require_token!
|
||||
content_type :json
|
||||
begin
|
||||
data = JSON.parse(read_json_body)
|
||||
rescue JSON::ParserError
|
||||
halt 400, { error: "invalid JSON" }.to_json
|
||||
end
|
||||
messages = data.is_a?(Array) ? data : [data]
|
||||
halt 400, { error: "too many messages" }.to_json if messages.size > 1000
|
||||
db = open_database
|
||||
messages.each do |msg|
|
||||
insert_message(db, msg)
|
||||
end
|
||||
{ status: "ok" }.to_json
|
||||
ensure
|
||||
db&.close
|
||||
end
|
||||
|
||||
app.post "/api/instances" do
|
||||
content_type :json
|
||||
begin
|
||||
payload = JSON.parse(read_json_body)
|
||||
rescue JSON::ParserError
|
||||
warn "[warn] instance registration rejected: invalid JSON"
|
||||
halt 400, { error: "invalid JSON" }.to_json
|
||||
end
|
||||
|
||||
unless payload.is_a?(Hash)
|
||||
warn "[warn] instance registration rejected: payload is not an object"
|
||||
halt 400, { error: "invalid payload" }.to_json
|
||||
end
|
||||
|
||||
id = string_or_nil(payload["id"]) || string_or_nil(payload["instanceId"])
|
||||
domain = sanitize_instance_domain(payload["domain"])
|
||||
pubkey = sanitize_public_key_pem(payload["pubkey"])
|
||||
name = string_or_nil(payload["name"])
|
||||
version = string_or_nil(payload["version"])
|
||||
channel = string_or_nil(payload["channel"])
|
||||
frequency = string_or_nil(payload["frequency"])
|
||||
latitude = coerce_float(payload["latitude"])
|
||||
longitude = coerce_float(payload["longitude"])
|
||||
last_update_time = coerce_integer(payload["last_update_time"] || payload["lastUpdateTime"])
|
||||
raw_private = payload.key?("isPrivate") ? payload["isPrivate"] : payload["is_private"]
|
||||
is_private = coerce_boolean(raw_private)
|
||||
signature = string_or_nil(payload["signature"])
|
||||
|
||||
attributes = {
|
||||
id: id,
|
||||
domain: domain,
|
||||
pubkey: pubkey,
|
||||
name: name,
|
||||
version: version,
|
||||
channel: channel,
|
||||
frequency: frequency,
|
||||
latitude: latitude,
|
||||
longitude: longitude,
|
||||
last_update_time: last_update_time,
|
||||
is_private: is_private,
|
||||
}
|
||||
|
||||
if [attributes[:id], attributes[:domain], attributes[:pubkey], signature, attributes[:last_update_time]].any?(&:nil?)
|
||||
warn "[warn] instance registration rejected: missing required fields"
|
||||
halt 400, { error: "missing required fields" }.to_json
|
||||
end
|
||||
|
||||
unless verify_instance_signature(attributes, signature, attributes[:pubkey])
|
||||
warn "[warn] instance registration rejected for #{attributes[:domain]}: invalid signature"
|
||||
halt 400, { error: "invalid signature" }.to_json
|
||||
end
|
||||
|
||||
if attributes[:is_private]
|
||||
warn "[warn] instance registration rejected for #{attributes[:domain]}: instance marked private"
|
||||
halt 403, { error: "instance marked private" }.to_json
|
||||
end
|
||||
|
||||
ip = ip_from_domain(attributes[:domain])
|
||||
if ip && restricted_ip_address?(ip)
|
||||
warn "[warn] instance registration rejected for #{attributes[:domain]}: restricted IP address"
|
||||
halt 400, { error: "restricted domain" }.to_json
|
||||
end
|
||||
|
||||
well_known, well_known_meta = fetch_instance_json(attributes[:domain], "/.well-known/potato-mesh")
|
||||
unless well_known
|
||||
details_list = Array(well_known_meta).map(&:to_s)
|
||||
details = details_list.empty? ? "no response" : details_list.join("; ")
|
||||
warn "[warn] instance registration rejected for #{attributes[:domain]}: failed to fetch well-known document (#{details})"
|
||||
halt 400, { error: "failed to verify well-known document" }.to_json
|
||||
end
|
||||
|
||||
valid, reason = validate_well_known_document(well_known, attributes[:domain], attributes[:pubkey])
|
||||
unless valid
|
||||
warn "[warn] instance registration rejected for #{attributes[:domain]}: #{reason}"
|
||||
halt 400, { error: reason || "invalid well-known document" }.to_json
|
||||
end
|
||||
|
||||
remote_nodes, node_source = fetch_instance_json(attributes[:domain], "/api/nodes")
|
||||
unless remote_nodes
|
||||
details_list = Array(node_source).map(&:to_s)
|
||||
details = details_list.empty? ? "no response" : details_list.join("; ")
|
||||
warn "[warn] instance registration rejected for #{attributes[:domain]}: failed to fetch nodes (#{details})"
|
||||
halt 400, { error: "failed to fetch nodes" }.to_json
|
||||
end
|
||||
|
||||
fresh, freshness_reason = validate_remote_nodes(remote_nodes)
|
||||
unless fresh
|
||||
warn "[warn] instance registration rejected for #{attributes[:domain]}: #{freshness_reason}"
|
||||
halt 400, { error: freshness_reason || "stale node data" }.to_json
|
||||
end
|
||||
|
||||
db = open_database
|
||||
upsert_instance_record(db, attributes, signature)
|
||||
debug_log("Registered instance #{attributes[:domain]} (id: #{attributes[:id]})")
|
||||
status 201
|
||||
{ status: "registered" }.to_json
|
||||
ensure
|
||||
db&.close
|
||||
end
|
||||
|
||||
app.post "/api/positions" do
|
||||
require_token!
|
||||
content_type :json
|
||||
begin
|
||||
data = JSON.parse(read_json_body)
|
||||
rescue JSON::ParserError
|
||||
halt 400, { error: "invalid JSON" }.to_json
|
||||
end
|
||||
positions = data.is_a?(Array) ? data : [data]
|
||||
halt 400, { error: "too many positions" }.to_json if positions.size > 1000
|
||||
db = open_database
|
||||
positions.each do |pos|
|
||||
insert_position(db, pos)
|
||||
end
|
||||
{ status: "ok" }.to_json
|
||||
ensure
|
||||
db&.close
|
||||
end
|
||||
|
||||
app.post "/api/neighbors" do
|
||||
require_token!
|
||||
content_type :json
|
||||
begin
|
||||
data = JSON.parse(read_json_body)
|
||||
rescue JSON::ParserError
|
||||
halt 400, { error: "invalid JSON" }.to_json
|
||||
end
|
||||
neighbor_payloads = data.is_a?(Array) ? data : [data]
|
||||
halt 400, { error: "too many neighbor packets" }.to_json if neighbor_payloads.size > 1000
|
||||
db = open_database
|
||||
neighbor_payloads.each do |packet|
|
||||
insert_neighbors(db, packet)
|
||||
end
|
||||
{ status: "ok" }.to_json
|
||||
ensure
|
||||
db&.close
|
||||
end
|
||||
|
||||
app.post "/api/telemetry" do
|
||||
require_token!
|
||||
content_type :json
|
||||
begin
|
||||
data = JSON.parse(read_json_body)
|
||||
rescue JSON::ParserError
|
||||
halt 400, { error: "invalid JSON" }.to_json
|
||||
end
|
||||
telemetry_packets = data.is_a?(Array) ? data : [data]
|
||||
halt 400, { error: "too many telemetry packets" }.to_json if telemetry_packets.size > 1000
|
||||
db = open_database
|
||||
telemetry_packets.each do |packet|
|
||||
insert_telemetry(db, packet)
|
||||
end
|
||||
{ status: "ok" }.to_json
|
||||
ensure
|
||||
db&.close
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
66
web/lib/potato_mesh/application/routes/root.rb
Normal file
66
web/lib/potato_mesh/application/routes/root.rb
Normal file
@@ -0,0 +1,66 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
module PotatoMesh
|
||||
module App
|
||||
module Routes
|
||||
module Root
|
||||
def self.registered(app)
|
||||
app.get "/favicon.ico" do
|
||||
cache_control :public, max_age: PotatoMesh::Config.week_seconds
|
||||
ico_path = File.join(settings.public_folder, "favicon.ico")
|
||||
if File.file?(ico_path)
|
||||
send_file ico_path, type: "image/x-icon"
|
||||
else
|
||||
send_file File.join(settings.public_folder, "potatomesh-logo.svg"), type: "image/svg+xml"
|
||||
end
|
||||
end
|
||||
|
||||
app.get "/potatomesh-logo.svg" do
|
||||
path = File.expand_path("potatomesh-logo.svg", settings.public_folder)
|
||||
settings.logger&.info("logo_path=#{path} exist=#{File.exist?(path)} file=#{File.file?(path)}")
|
||||
halt 404, "Not Found" unless File.exist?(path) && File.readable?(path)
|
||||
|
||||
content_type "image/svg+xml"
|
||||
last_modified File.mtime(path)
|
||||
cache_control :public, max_age: 3600
|
||||
send_file path
|
||||
end
|
||||
|
||||
app.get "/" do
|
||||
meta = meta_configuration
|
||||
config = frontend_app_config
|
||||
|
||||
raw_theme = request.cookies["theme"]
|
||||
theme = %w[dark light].include?(raw_theme) ? raw_theme : "dark"
|
||||
if raw_theme != theme
|
||||
response.set_cookie("theme", value: theme, path: "/", max_age: 60 * 60 * 24 * 7, same_site: :lax)
|
||||
end
|
||||
|
||||
erb :index, locals: {
|
||||
site_name: meta[:name],
|
||||
meta_title: meta[:title],
|
||||
meta_name: meta[:name],
|
||||
meta_description: meta[:description],
|
||||
default_channel: sanitized_default_channel,
|
||||
default_frequency: sanitized_default_frequency,
|
||||
map_center_lat: PotatoMesh::Config.map_center_lat,
|
||||
map_center_lon: PotatoMesh::Config.map_center_lon,
|
||||
max_node_distance_km: PotatoMesh::Config.max_node_distance_km,
|
||||
matrix_room: sanitized_matrix_room,
|
||||
version: app_constant(:APP_VERSION),
|
||||
private_mode: private_mode?,
|
||||
refresh_interval_seconds: PotatoMesh::Config.refresh_interval_seconds,
|
||||
app_config_json: JSON.generate(config),
|
||||
initial_theme: theme,
|
||||
}
|
||||
end
|
||||
|
||||
app.get "/metrics" do
|
||||
content_type ::Prometheus::Client::Formats::Text::CONTENT_TYPE
|
||||
::Prometheus::Client::Formats::Text.marshal(::Prometheus::Client.registry)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -23,6 +23,42 @@ require "uri"
|
||||
|
||||
RSpec.describe "Potato Mesh Sinatra app" do
|
||||
let(:app) { Sinatra::Application }
|
||||
let(:application_class) { PotatoMesh::Application }
|
||||
|
||||
describe "configuration" do
|
||||
it "sets the default HTTP port to 41_447" do
|
||||
expect(app.settings.port).to eq(41_447)
|
||||
end
|
||||
end
|
||||
|
||||
describe ".resolve_port" do
|
||||
around do |example|
|
||||
original_present = ENV.key?("PORT")
|
||||
original_value = ENV["PORT"] if original_present
|
||||
ENV.delete("PORT")
|
||||
example.run
|
||||
ensure
|
||||
if original_present
|
||||
ENV["PORT"] = original_value
|
||||
else
|
||||
ENV.delete("PORT")
|
||||
end
|
||||
end
|
||||
|
||||
it "returns the default port when the environment is unset" do
|
||||
expect(application_class.resolve_port).to eq(41_447)
|
||||
end
|
||||
|
||||
it "parses the environment override when provided" do
|
||||
ENV["PORT"] = "51515"
|
||||
expect(application_class.resolve_port).to eq(51_515)
|
||||
end
|
||||
|
||||
it "falls back to the default port when parsing fails" do
|
||||
ENV["PORT"] = "potato"
|
||||
expect(application_class.resolve_port).to eq(41_447)
|
||||
end
|
||||
end
|
||||
|
||||
# Return the absolute filesystem path to the requested fixture.
|
||||
#
|
||||
@@ -200,6 +236,53 @@ RSpec.describe "Potato Mesh Sinatra app" do
|
||||
Time.at((latest || Time.now.to_i) + 1000)
|
||||
end
|
||||
|
||||
describe "federation announcers" do
|
||||
class DummyThread
|
||||
attr_accessor :name, :report_on_exception, :block
|
||||
|
||||
def alive?
|
||||
false
|
||||
end
|
||||
end
|
||||
|
||||
let(:dummy_thread) { DummyThread.new }
|
||||
|
||||
before do
|
||||
app.set(:initial_federation_thread, nil)
|
||||
app.set(:federation_thread, nil)
|
||||
end
|
||||
|
||||
it "stores and clears the initial federation thread" do
|
||||
allow(app).to receive(:announce_instance_to_all_domains)
|
||||
allow(Thread).to receive(:new) do |&block|
|
||||
dummy_thread.block = block
|
||||
dummy_thread
|
||||
end
|
||||
|
||||
result = app.start_initial_federation_announcement!
|
||||
|
||||
expect(result).to be(dummy_thread)
|
||||
expect(app.settings.initial_federation_thread).to be(dummy_thread)
|
||||
expect(dummy_thread.block).not_to be_nil
|
||||
|
||||
expect { dummy_thread.block.call }.to change {
|
||||
app.settings.initial_federation_thread
|
||||
}.from(dummy_thread).to(nil)
|
||||
end
|
||||
|
||||
it "stores the recurring federation announcer thread" do
|
||||
allow(Thread).to receive(:new) do |&block|
|
||||
dummy_thread.block = block
|
||||
dummy_thread
|
||||
end
|
||||
|
||||
result = app.start_federation_announcer!
|
||||
|
||||
expect(result).to be(dummy_thread)
|
||||
expect(app.settings.federation_thread).to be(dummy_thread)
|
||||
end
|
||||
end
|
||||
|
||||
before do
|
||||
@original_token = ENV["API_TOKEN"]
|
||||
@original_private = ENV["PRIVATE"]
|
||||
|
||||
Reference in New Issue
Block a user