Files
l5y 2479a6554f fix: UDP-transport hardening, bridge tracker coverage, CI repairs; release 0.7.2 (#840)
* fix: UDP-transport hardening, bridge tracker coverage, CI repairs; release 0.7.2

* fix: UDP-transport hardening, bridge tracker coverage, CI repairs; release 0.7.2
2026-07-06 00:30:52 +02:00

986 lines
35 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
# Configuration wrapper responsible for exposing ENV backed settings used by
# the web and data ingestion services.
module Config
module_function
DEFAULT_DB_BUSY_TIMEOUT_MS = 5_000
DEFAULT_DB_BUSY_MAX_RETRIES = 5
DEFAULT_DB_BUSY_RETRY_DELAY = 0.05
DEFAULT_MAX_JSON_BODY_BYTES = 1_048_576
DEFAULT_REFRESH_INTERVAL_SECONDS = 60
# Slow safety-poll cadence used by the dashboard when live SSE updates are
# active (PS5): the only timer-driven fetch path while pushes flow, a
# fallback that converges the UI if events are missed.
DEFAULT_LIVE_SAFETY_POLL_SECONDS = 300
# Interval at which the SSE route emits a heartbeat comment so dead
# connections are detected and intermediaries do not buffer the stream.
DEFAULT_SSE_HEARTBEAT_SECONDS = 15
# Maximum lifetime of a single SSE connection before the server closes it,
# prompting the client to reconnect (and resync). Bounds per-connection
# thread occupancy and gives graceful shutdown a hard ceiling.
DEFAULT_SSE_MAX_LIFETIME_SECONDS = 600
# Default per-collection SSE publish cooldown (seconds). A burst of writes
# to one collection within this settle window coalesces into a single
# client event so N ingestors relaying one packet do not stampede
# subscribers (SPEC LV6).
DEFAULT_SSE_PUBLISH_COOLDOWN_SECONDS = 1.0
# Seconds Puma waits for in-flight requests (e.g. an open SSE stream)
# before force-terminating them during shutdown, so Ctrl+C reaps promptly
# instead of blocking on a long-lived /api/events connection. Backstop to
# the graceful subscriber-close performed by the shutdown signal handler.
DEFAULT_PUMA_FORCE_SHUTDOWN_SECONDS = 3
# Default Puma worker-thread pool bounds. The pool must stay larger than the
# SSE subscriber cap ({PotatoMesh::App::PubSub::MAX_SUBSCRIBERS}) by at least
# {DEFAULT_SSE_THREAD_RESERVE}, so a flood of long-lived +/api/events+
# streams (each pins one request thread for its lifetime) can never occupy
# every worker thread and starve API/ingest/federation traffic (SPEC
# PS8/PS9). Puma's own MRI default is only 5, which is why the floor is
# raised here in code rather than left to the deployment environment.
DEFAULT_PUMA_MIN_THREADS = 16
DEFAULT_PUMA_MAX_THREADS = 96
# Request threads kept in reserve for non-SSE traffic even when every SSE
# subscriber slot is occupied. With the defaults this reconciles the SSE cap
# back to its historical value (96 - 32 = 64).
DEFAULT_SSE_THREAD_RESERVE = 32
DEFAULT_MAP_CENTER_LAT = 38.761944
DEFAULT_MAP_CENTER_LON = -27.090833
DEFAULT_MAP_CENTER = "#{DEFAULT_MAP_CENTER_LAT},#{DEFAULT_MAP_CENTER_LON}"
DEFAULT_CHANNEL = "#LongFast"
DEFAULT_FREQUENCY = "915MHz"
DEFAULT_MESHTASTIC_PSK_B64 = "AQ=="
DEFAULT_CONTACT_LINK = "#potatomesh:dod.ngo"
DEFAULT_MAX_DISTANCE_KM = 42.0
DEFAULT_REMOTE_INSTANCE_CONNECT_TIMEOUT = 15
DEFAULT_REMOTE_INSTANCE_READ_TIMEOUT = 60
DEFAULT_REMOTE_INSTANCE_REQUEST_TIMEOUT = 30
DEFAULT_REMOTE_INSTANCE_MAX_RESPONSE_BYTES = 8 * 1_048_576
DEFAULT_FEDERATION_MAX_INSTANCES_PER_RESPONSE = 64
DEFAULT_FEDERATION_MAX_DOMAINS_PER_CRAWL = 256
DEFAULT_FEDERATION_WORKER_POOL_SIZE = 4
DEFAULT_FEDERATION_WORKER_QUEUE_CAPACITY = 128
DEFAULT_FEDERATION_TASK_TIMEOUT_SECONDS = 120
DEFAULT_FEDERATION_SHUTDOWN_TIMEOUT_SECONDS = 3
DEFAULT_FEDERATION_CRAWL_COOLDOWN_SECONDS = 300
DEFAULT_INITIAL_FEDERATION_DELAY_SECONDS = 2
DEFAULT_FEDERATION_SEED_DOMAINS = %w[potatomesh.net potatomesh.jmrp.io mesh.qrp.ro].freeze
DEFAULT_OG_IMAGE_TTL_SECONDS = 3_600
DEFAULT_OG_IMAGE_VIEWPORT_WIDTH = 1_200
DEFAULT_OG_IMAGE_VIEWPORT_HEIGHT = 630
DEFAULT_OG_IMAGE_NAVIGATION_TIMEOUT = 15
DEFAULT_OG_IMAGE_NETWORK_IDLE_DURATION = 1.5
DEFAULT_OG_IMAGE_NETWORK_IDLE_TIMEOUT = 8
# Retrieve the configured API token used for authenticated requests.
#
# @return [String, nil] API token when provided, otherwise nil.
def api_token
fetch_string("API_TOKEN", nil)
end
# Retrieve an explicit instance domain override when present.
#
# @return [String, nil] hostname or host:port pair supplied via ENV.
def instance_domain
fetch_string("INSTANCE_DOMAIN", nil)
end
# Determine whether private mode should be activated.
#
# @return [Boolean] true when PRIVATE=1 in the environment.
def private_mode_enabled?
value = ENV.fetch("PRIVATE", "0")
value.to_s.strip == "1"
end
# Determine whether federation features are permitted for the instance.
#
# Federation is disabled when ``PRIVATE=1`` regardless of the
# ``FEDERATION`` environment variable to ensure a private deployment does
# not announce itself or crawl peers.
#
# @return [Boolean] true when federation should remain active.
def federation_enabled?
return false if private_mode_enabled?
value = ENV.fetch("FEDERATION", "1")
value.to_s.strip != "0"
end
# Resolve the absolute path to the operator-managed static pages directory.
#
# The directory defaults to +pages/+ at the application root and can be
# overridden with the +PAGES_DIR+ environment variable.
#
# @return [String] absolute filesystem path to the pages directory.
def pages_directory
custom = fetch_string("PAGES_DIR", nil)
return File.expand_path(custom) if custom
File.join(web_root, "pages")
end
# Maximum file size in bytes accepted when reading a static page.
#
# @return [Integer] byte ceiling for markdown files.
def max_page_file_bytes
512 * 1024
end
# Resolve the absolute path to the web application root directory.
#
# @return [String] absolute filesystem path of the web folder.
def web_root
@web_root ||= File.expand_path("../..", __dir__)
end
# Resolve the repository root directory relative to the web folder.
#
# @return [String] path to the Git repository root.
def repo_root
@repo_root ||= File.expand_path("..", web_root)
end
# Resolve the current XDG data directory for PotatoMesh content.
#
# @return [String] absolute path to the PotatoMesh data directory.
def data_directory
File.join(resolve_xdg_home("XDG_DATA_HOME", %w[.local share]), "potato-mesh")
end
# Resolve the current XDG configuration directory for PotatoMesh files.
#
# @return [String] absolute path to the PotatoMesh configuration directory.
def config_directory
File.join(resolve_xdg_home("XDG_CONFIG_HOME", %w[.config]), "potato-mesh")
end
# Build the default SQLite database path inside the data directory.
#
# @return [String] absolute path to the managed +mesh.db+ file.
def default_db_path
File.join(data_directory, "mesh.db")
end
# Legacy database path bundled alongside the repository.
#
# @return [String] absolute path to the repository managed database file.
def legacy_db_path
File.expand_path("../data/mesh.db", web_root)
end
# Determine the configured database location, defaulting to the bundled
# SQLite file.
#
# @return [String] absolute path to the database file.
def db_path
default_db_path
end
# Retrieve the SQLite busy timeout duration in milliseconds.
#
# @return [Integer] timeout value in milliseconds.
def db_busy_timeout_ms
DEFAULT_DB_BUSY_TIMEOUT_MS
end
# Retrieve the maximum number of retries when encountering SQLITE_BUSY.
#
# @return [Integer] maximum retry attempts.
def db_busy_max_retries
DEFAULT_DB_BUSY_MAX_RETRIES
end
# Retrieve the backoff delay between busy retries in seconds.
#
# @return [Float] seconds to wait between retries.
def db_busy_retry_delay
DEFAULT_DB_BUSY_RETRY_DELAY
end
# Default rolling retention window in seconds.
#
# Used as the freshness floor for every "general" bulk read endpoint —
# nodes, messages, positions, telemetry, and the federation instance
# catalog — and as the freshness floor for federation/dashboard activity
# counters. Exceptions (sparse data) live on
# {#four_weeks_seconds}; per-id lookups also widen to the extended
# window so callers can backfill historical context for a single node.
#
# @return [Integer] seconds in seven days.
def week_seconds
7 * 24 * 60 * 60
end
# Extended rolling retention window in seconds.
#
# Used as the default freshness floor for endpoints whose data is more
# fragile (traces, neighbors, ingestors) and as the floor for every
# +/api/.../:id+ lookup so callers can backfill historical records that
# would otherwise fall outside the seven-day default applied to bulk
# endpoints. This is also the absolute API-level visibility cap: no
# request may return data older than this window regardless of the
# +since+ or +windowSeconds+ parameters submitted.
#
# @return [Integer] seconds in twenty-eight days.
def four_weeks_seconds
28 * 24 * 60 * 60
end
# Hard retention window applied by the periodic purge job. Rows whose
# most recent activity timestamp is older than this duration are deleted
# from the database. The 28-day API visibility floor is enforced
# separately via {#four_weeks_seconds}, so the gap between the two
# windows preserves recoverable history without ever exposing it to API
# consumers.
#
# @return [Integer] seconds in 365 days.
def year_seconds
365 * 24 * 60 * 60
end
# Interval between consecutive retention purge passes.
#
# The retention worker wakes once per day to delete rows older than
# {#year_seconds}. A daily cadence keeps the working set bounded
# without holding write locks for long stretches.
#
# @return [Integer] seconds between purge cycles.
def retention_purge_interval_seconds
24 * 60 * 60
end
# Grace period applied before the first retention purge runs after boot.
#
# Mirrors the federation-announcer pattern: the daemon yields the
# request thread for a short delay so initial schema migrations and
# federation handshakes complete before a potentially long-running
# +DELETE+ ties up the database.
#
# @return [Integer] seconds to wait before the first purge.
def initial_retention_delay_seconds
30
end
# Substring marker that signals a node has opted out of being displayed
# in any user-facing surface. Operators include the marker anywhere in
# the node's short or long name; the API filters out any node whose
# display name contains this exact character (a single grapheme). Data
# is still ingested so deduplication and routing continue to work.
#
# @return [String] frozen single-character opt-out marker.
NODE_OPT_OUT_MARKER = "\u{1F6D1}".freeze
# Expose {NODE_OPT_OUT_MARKER} via a method so callers that prefer the
# module-function style stay consistent with the rest of the module.
#
# @return [String] frozen opt-out marker character.
def node_opt_out_marker
NODE_OPT_OUT_MARKER
end
# Default upper bound for accepted JSON payload sizes.
#
# @return [Integer] byte ceiling for HTTP request bodies.
def default_max_json_body_bytes
DEFAULT_MAX_JSON_BODY_BYTES
end
# Determine the maximum allowed JSON body size with validation.
#
# @return [Integer] configured byte limit.
def max_json_body_bytes
default_max_json_body_bytes
end
# Provide the fallback version string when git metadata is unavailable.
#
# 0.7.0 introduced the breaking /api/stats response-shape change
# (SPEC S1); 0.7.2 is the current patch release, kept in lockstep with
# the other language manifests. The matching +git tag v0.7.2+ is the
# maintainer release step.
#
# @return [String] semantic version identifier.
def version_fallback
"0.7.2"
end
# Default refresh interval for frontend polling routines.
#
# @return [Integer] refresh period in seconds.
def default_refresh_interval_seconds
DEFAULT_REFRESH_INTERVAL_SECONDS
end
# Fetch the refresh interval, ensuring a positive integer value.
#
# @return [Integer] polling cadence in seconds.
def refresh_interval_seconds
default_refresh_interval_seconds
end
# Determine whether live SSE updates are offered to clients.
#
# Disabled by setting +EVENTS=0+; clients then fall back to the polling
# cadence ({#refresh_interval_seconds}) exactly as before (PS5/PS8).
#
# @return [Boolean] true when the +GET /api/events+ stream is served.
def live_updates_enabled?
ENV.fetch("EVENTS", "1").to_s.strip != "0"
end
# Slow safety-poll cadence (seconds) used while live updates are active.
#
# @return [Integer] positive interval, overridable via +LIVE_SAFETY_POLL_SECONDS+.
def live_safety_poll_seconds
fetch_positive_integer("LIVE_SAFETY_POLL_SECONDS", DEFAULT_LIVE_SAFETY_POLL_SECONDS)
end
# Heartbeat cadence (seconds) for the SSE stream.
#
# @return [Integer] positive interval, overridable via +SSE_HEARTBEAT_SECONDS+.
def sse_heartbeat_seconds
fetch_positive_integer("SSE_HEARTBEAT_SECONDS", DEFAULT_SSE_HEARTBEAT_SECONDS)
end
# Maximum lifetime (seconds) of a single SSE connection.
#
# @return [Integer] positive ceiling, overridable via +SSE_MAX_LIFETIME_SECONDS+.
def sse_max_lifetime_seconds
fetch_positive_integer("SSE_MAX_LIFETIME_SECONDS", DEFAULT_SSE_MAX_LIFETIME_SECONDS)
end
# Per-collection SSE publish cooldown (seconds). Within this settle window
# a burst of writes to one collection coalesces into a single emitted
# event (SPEC LV6). Zero disables the cooldown (emit as soon as a change
# lands).
#
# @return [Float] non-negative cooldown, overridable via +SSE_PUBLISH_COOLDOWN+.
def sse_publish_cooldown_seconds
fetch_nonnegative_float("SSE_PUBLISH_COOLDOWN", DEFAULT_SSE_PUBLISH_COOLDOWN_SECONDS)
end
# Seconds Puma waits before force-terminating in-flight requests on
# shutdown (e.g. an open SSE stream), so Ctrl+C is not blocked by it.
#
# @return [Integer] positive timeout, overridable via +PUMA_FORCE_SHUTDOWN+.
def puma_force_shutdown_seconds
fetch_positive_integer("PUMA_FORCE_SHUTDOWN", DEFAULT_PUMA_FORCE_SHUTDOWN_SECONDS)
end
# Minimum Puma worker threads kept warm.
#
# @return [Integer] positive minimum, overridable via +MIN_THREADS+.
def puma_min_threads
fetch_positive_integer("MIN_THREADS", DEFAULT_PUMA_MIN_THREADS)
end
# Maximum Puma worker threads. Sized above the SSE subscriber cap plus
# {#sse_thread_reserve} so long-lived +/api/events+ streams cannot occupy
# every request thread and starve other traffic (SPEC PS9).
#
# @return [Integer] positive maximum, overridable via +MAX_THREADS+.
def puma_max_threads
fetch_positive_integer("MAX_THREADS", DEFAULT_PUMA_MAX_THREADS)
end
# Request threads reserved for non-SSE traffic even when every SSE
# subscriber slot is taken, so live updates are never load-bearing
# (SPEC PS8/PS9).
#
# @return [Integer] positive reserve, overridable via +SSE_THREAD_RESERVE+.
def sse_thread_reserve
fetch_positive_integer("SSE_THREAD_RESERVE", DEFAULT_SSE_THREAD_RESERVE)
end
# Build Puma's +Threads+ "min:max" pool spec. The minimum is clamped to the
# maximum so an out-of-order override (+MIN_THREADS+ > +MAX_THREADS+) cannot
# break the boot.
#
# @return [String] the +"min:max"+ thread-pool spec passed to Puma.
def puma_threads_setting
max = puma_max_threads
min = [puma_min_threads, max].min
"#{min}:#{max}"
end
# Retrieve the raw comma separated Prometheus report identifiers.
#
# @return [String] comma separated list of report IDs.
def prom_report_ids
fetch_string("PROM_REPORT_IDS", "")
end
# Transform Prometheus report identifiers into a cleaned array.
#
# @return [Array<String>] list of unique report identifiers.
def prom_report_id_list
prom_report_ids.split(",").map(&:strip).reject(&:empty?)
end
# Path storing the instance private key used for signing.
#
# @return [String] absolute location of the PEM file.
def keyfile_path
File.join(config_directory, "keyfile")
end
# Sub-path used when exposing well known configuration files.
#
# @return [String] relative path within the public directory.
def well_known_relative_path
File.join(".well-known", "potato-mesh")
end
# Filesystem directory used to stage /.well-known artifacts.
#
# @return [String] absolute storage path.
def well_known_storage_root
File.join(config_directory, "well-known")
end
# Legacy configuration directory bundled with the repository.
#
# @return [String] absolute path to the repository managed configuration directory.
def legacy_config_directory
File.join(web_root, ".config")
end
# Legacy keyfile location used before introducing XDG directories.
#
# @return [String] absolute filesystem path to the legacy keyfile.
def legacy_keyfile_path
legacy_keyfile_candidates.find { |path| File.exist?(path) } || legacy_keyfile_candidates.first
end
# Enumerate known legacy keyfile locations for migration.
#
# @return [Array<String>] ordered list of absolute legacy keyfile paths.
def legacy_keyfile_candidates
[
File.join(web_root, ".config", "keyfile"),
File.join(web_root, ".config", "potato-mesh", "keyfile"),
File.join(web_root, "config", "keyfile"),
File.join(web_root, "config", "potato-mesh", "keyfile"),
].map { |path| File.expand_path(path) }.uniq
end
# Legacy location for well known assets within the public folder.
#
# @return [String] absolute path to the legacy output directory.
def legacy_public_well_known_path
File.join(web_root, "public", well_known_relative_path)
end
# Enumerate known legacy well-known document locations for migration.
#
# @return [Array<String>] ordered list of absolute legacy well-known document paths.
def legacy_well_known_candidates
filename = File.basename(well_known_relative_path)
[
File.join(web_root, ".config", "well-known", filename),
File.join(web_root, ".config", ".well-known", filename),
File.join(web_root, ".config", "potato-mesh", "well-known", filename),
File.join(web_root, ".config", "potato-mesh", ".well-known", filename),
File.join(web_root, "config", "well-known", filename),
File.join(web_root, "config", ".well-known", filename),
File.join(web_root, "config", "potato-mesh", "well-known", filename),
File.join(web_root, "config", "potato-mesh", ".well-known", filename),
].map { |path| File.expand_path(path) }.uniq
end
# Interval used to refresh well known documents from disk.
#
# @return [Integer] refresh duration in seconds.
def well_known_refresh_interval
24 * 60 * 60
end
# Cryptographic algorithm identifier for HTTP signatures.
#
# @return [String] RFC-compliant algorithm label.
def instance_signature_algorithm
"rsa-sha256"
end
# Current federation signature/canonical format version (SPEC FS3). Stamped
# inside every signed payload (instance announcement + well-known) so the
# format cannot be silently downgraded; verifiers accept this version and the
# legacy unmarked v1 form.
#
# @return [Integer] the current signature format version.
def federation_signature_version
2
end
# Connection timeout used when establishing federation HTTP sockets.
#
# The timeout can be customised with the REMOTE_INSTANCE_CONNECT_TIMEOUT
# environment variable to accommodate slower or distant federation peers.
#
# @return [Integer] connect timeout in seconds.
def remote_instance_http_timeout
fetch_positive_integer(
"REMOTE_INSTANCE_CONNECT_TIMEOUT",
DEFAULT_REMOTE_INSTANCE_CONNECT_TIMEOUT,
)
end
# Read timeout used when streaming federation HTTP responses.
#
# The timeout can be customised with the REMOTE_INSTANCE_READ_TIMEOUT
# environment variable to accommodate slower or distant federation peers.
#
# @return [Integer] read timeout in seconds.
def remote_instance_read_timeout
fetch_positive_integer(
"REMOTE_INSTANCE_READ_TIMEOUT",
DEFAULT_REMOTE_INSTANCE_READ_TIMEOUT,
)
end
# End-to-end timeout applied to each outbound federation HTTP request.
#
# @return [Integer] maximum request duration in seconds.
def remote_instance_request_timeout
fetch_positive_integer(
"REMOTE_INSTANCE_REQUEST_TIMEOUT",
DEFAULT_REMOTE_INSTANCE_REQUEST_TIMEOUT,
)
end
# Maximum accepted size of a single federation HTTP response body.
#
# Mirrors {#max_json_body_bytes}, which caps inbound ingest payloads, so
# a malicious or misbehaving federation peer cannot force this process to
# buffer an unbounded response body in memory. Customisable via the
# REMOTE_INSTANCE_MAX_RESPONSE_BYTES environment variable.
#
# @return [Integer] byte ceiling for federation HTTP response bodies.
def remote_instance_max_response_bytes
fetch_positive_integer(
"REMOTE_INSTANCE_MAX_RESPONSE_BYTES",
DEFAULT_REMOTE_INSTANCE_MAX_RESPONSE_BYTES,
)
end
# Limit the number of remote instances processed from a single response.
#
# @return [Integer] maximum entries processed per /api/instances payload.
def federation_max_instances_per_response
fetch_positive_integer(
"FEDERATION_MAX_INSTANCES_PER_RESPONSE",
DEFAULT_FEDERATION_MAX_INSTANCES_PER_RESPONSE,
)
end
# Limit the total number of distinct domains crawled during one ingestion.
#
# @return [Integer] maximum unique domains visited per crawl.
def federation_max_domains_per_crawl
fetch_positive_integer(
"FEDERATION_MAX_DOMAINS_PER_CRAWL",
DEFAULT_FEDERATION_MAX_DOMAINS_PER_CRAWL,
)
end
# Determine the worker pool size used for federation tasks.
#
# @return [Integer] number of worker threads dedicated to federation jobs.
def federation_worker_pool_size
fetch_positive_integer(
"FEDERATION_WORKERS",
DEFAULT_FEDERATION_WORKER_POOL_SIZE,
)
end
# Determine the queue capacity for pending federation jobs.
#
# @return [Integer] maximum number of queued tasks before rejecting work.
def federation_worker_queue_capacity
fetch_positive_integer(
"FEDERATION_WORK_QUEUE",
DEFAULT_FEDERATION_WORKER_QUEUE_CAPACITY,
)
end
# Determine the timeout applied when awaiting federation worker tasks.
#
# @return [Integer] seconds to wait for asynchronous jobs to complete.
def federation_task_timeout_seconds
fetch_positive_integer(
"FEDERATION_TASK_TIMEOUT",
DEFAULT_FEDERATION_TASK_TIMEOUT_SECONDS,
)
end
# Determine how long shutdown waits before forcing federation thread exit.
#
# @return [Integer] per-thread shutdown timeout in seconds.
def federation_shutdown_timeout_seconds
fetch_positive_integer(
"FEDERATION_SHUTDOWN_TIMEOUT",
DEFAULT_FEDERATION_SHUTDOWN_TIMEOUT_SECONDS,
)
end
# Define how long finished crawl domains remain on cooldown.
#
# @return [Integer] cooldown window in seconds.
def federation_crawl_cooldown_seconds
fetch_positive_integer(
"FEDERATION_CRAWL_COOLDOWN",
DEFAULT_FEDERATION_CRAWL_COOLDOWN_SECONDS,
)
end
# Maximum acceptable age for remote node data.
#
# @return [Integer] seconds before remote nodes are considered stale.
def remote_instance_max_node_age
86_400
end
# Minimum node count expected from a remote instance before storing.
#
# @return [Integer] node threshold for remote ingestion.
def remote_instance_min_node_count
10
end
# Domains used to seed the federation discovery process.
#
# @return [Array<String>] list of default seed domains.
def federation_seed_domains
DEFAULT_FEDERATION_SEED_DOMAINS
end
# Determine how often we broadcast federation announcements.
#
# @return [Integer] number of seconds between announcement cycles.
def federation_announcement_interval
8 * 60 * 60
end
# Determine the grace period before sending the initial federation announcement.
#
# @return [Integer] seconds to wait before the first broadcast cycle.
def initial_federation_delay_seconds
fetch_positive_integer(
"INITIAL_FEDERATION_DELAY_SECONDS",
DEFAULT_INITIAL_FEDERATION_DELAY_SECONDS,
)
end
# Retrieve the configured site name for presentation.
#
# @return [String] human friendly site label.
def site_name
fetch_string("SITE_NAME", "PotatoMesh Demo")
end
# Retrieve the configured announcement banner copy.
#
# @return [String, nil] announcement string when configured.
def announcement
fetch_string("ANNOUNCEMENT", nil)
end
# Retrieve the default radio channel label.
#
# @return [String] channel name from configuration.
def channel
fetch_string("CHANNEL", DEFAULT_CHANNEL)
end
# Retrieve the default radio frequency description.
#
# @return [String] frequency identifier.
def frequency
fetch_string("FREQUENCY", DEFAULT_FREQUENCY)
end
# Retrieve the Meshtastic PSK used for decrypting channel messages.
#
# @return [String] base64-encoded PSK or alias.
def meshtastic_psk_b64
fetch_string("MESHTASTIC_PSK_B64", DEFAULT_MESHTASTIC_PSK_B64)
end
# Parse the configured map centre coordinates.
#
# @return [Hash{Symbol=>Float}] latitude and longitude in decimal degrees.
def map_center
raw = fetch_string("MAP_CENTER", DEFAULT_MAP_CENTER)
lat_str, lon_str = raw.split(",", 2).map { |part| part&.strip }.compact
lat = Float(lat_str, exception: false)
lon = Float(lon_str, exception: false)
lat = DEFAULT_MAP_CENTER_LAT unless lat
lon = DEFAULT_MAP_CENTER_LON unless lon
{ lat: lat, lon: lon }
end
# Map display latitude centre for the frontend map widget.
#
# @return [Float] latitude in decimal degrees.
def map_center_lat
map_center[:lat]
end
# Map display longitude centre for the frontend map widget.
#
# @return [Float] longitude in decimal degrees.
def map_center_lon
map_center[:lon]
end
# Retrieve an explicit map zoom override when provided.
#
# @return [Float, nil] positive zoom value or +nil+ when unset.
def map_zoom
raw = fetch_string("MAP_ZOOM", nil)
return nil unless raw
zoom = Float(raw, exception: false)
return nil unless zoom
return nil unless zoom.positive?
zoom
end
# Maximum straight-line distance between nodes before relationships are
# hidden.
#
# @return [Float] distance in kilometres.
def max_distance_km
raw = fetch_string("MAX_DISTANCE", nil)
parsed = raw && Float(raw, exception: false)
return parsed if parsed && parsed.positive?
DEFAULT_MAX_DISTANCE_KM
end
# Contact link for community discussion.
#
# @return [String] contact URI or identifier.
def contact_link
fetch_string("CONTACT_LINK", DEFAULT_CONTACT_LINK)
end
# Retrieve the configured connection target for the ingestor service.
#
# @return [String] serial device, TCP endpoint, or Bluetooth target.
def connection_target
fetch_string("CONNECTION", "/dev/ttyACM0")
end
# Optional absolute URL to use for the social share preview image.
#
# When set, the layout uses this URL verbatim for +og:image+ and
# +twitter:image+ and the runtime capture pipeline is skipped. Operators
# who do not want to ship Chromium in their container, or who prefer to
# host their own preview image on a CDN, can point at any reachable
# +https://+ URL.
#
# @return [String, nil] override URL or +nil+ when unset.
def og_image_url
fetch_string("OG_IMAGE_URL", nil)
end
# Cache lifetime for runtime-generated +/og-image.png+ responses, in
# seconds. Successful captures are stored on disk and reused until the
# TTL elapses; the next request after expiry refreshes the cache
# synchronously while holding a process-wide mutex so concurrent
# requesters serialise rather than spawning multiple browsers.
#
# @return [Integer] positive cache duration in seconds.
def og_image_ttl_seconds
fetch_positive_integer("OG_IMAGE_TTL_SECONDS", DEFAULT_OG_IMAGE_TTL_SECONDS)
end
# Viewport width used for the headless browser preview capture.
#
# @return [Integer] viewport width in CSS pixels.
def og_image_viewport_width
DEFAULT_OG_IMAGE_VIEWPORT_WIDTH
end
# Viewport height used for the headless browser preview capture.
#
# @return [Integer] viewport height in CSS pixels.
def og_image_viewport_height
DEFAULT_OG_IMAGE_VIEWPORT_HEIGHT
end
# Maximum time the headless browser may spend navigating to the
# capture target before the request is abandoned.
#
# @return [Integer] navigation timeout in seconds.
def og_image_navigation_timeout
DEFAULT_OG_IMAGE_NAVIGATION_TIMEOUT
end
# Continuous duration of network silence required before the screenshot
# is taken. Acts as a heuristic for "page settled".
#
# @return [Float] idle window duration in seconds.
def og_image_network_idle_duration
DEFAULT_OG_IMAGE_NETWORK_IDLE_DURATION
end
# Maximum time spent waiting for {og_image_network_idle_duration} of
# silence before the capture proceeds anyway.
#
# @return [Integer] idle wait ceiling in seconds.
def og_image_network_idle_timeout
DEFAULT_OG_IMAGE_NETWORK_IDLE_TIMEOUT
end
# Filesystem path used to cache the most recent runtime-generated
# preview image. The directory is created lazily on first capture.
#
# @return [String] absolute cache file path.
def og_image_cache_path
File.join(data_directory, "og-image.png")
end
# Filesystem path of the bundled fallback preview image served when no
# cached capture is available and the runtime generator is unable to
# produce one (e.g. Chromium missing, transient navigation failure).
#
# @return [String] absolute path to the packaged default PNG.
def og_image_default_path
File.join(web_root, "public", "og-image-default.png")
end
# Determine the best URL to represent the configured contact link.
#
# @return [String, nil] absolute URL when derivable, otherwise nil.
def contact_link_url
link = contact_link.to_s.strip
return nil if link.empty?
if matrix_alias?(link)
"https://matrix.to/#/#{link}"
elsif link.match?(%r{\Ahttps?://}i)
link
else
nil
end
end
# Check whether a contact link is a Matrix room alias.
#
# @param link [String] candidate link string.
# @return [Boolean] true when the link resembles a Matrix alias.
def matrix_alias?(link)
link.match?(/\A[#!][^\s:]+:[^\s]+\z/)
end
# Check whether verbose debugging is enabled for the runtime.
#
# @return [Boolean] true when DEBUG=1.
def debug?
ENV["DEBUG"] == "1"
end
# Fetch and sanitise string based configuration values.
#
# @param key [String] environment variable to read.
# @param default [String] fallback value when unset or blank.
# @return [String] cleaned configuration string.
def fetch_string(key, default)
value = ENV[key]
return default if value.nil?
trimmed = value.strip
trimmed.empty? ? default : trimmed
end
# Fetch and validate integer based configuration flags.
#
# @param key [String] environment variable to read.
# @param default [Integer] fallback value when unset or invalid.
# @return [Integer] positive integer sourced from configuration.
def fetch_positive_integer(key, default)
value = ENV[key]
return default if value.nil?
trimmed = value.strip
return default if trimmed.empty?
begin
parsed = Integer(trimmed, 10)
rescue ArgumentError
return default
end
parsed.positive? ? parsed : default
end
# Fetch a non-negative float from the environment, falling back to
# +default+ when unset, blank, unparseable, or negative.
#
# @param key [String] environment variable name.
# @param default [Float] fallback value.
# @return [Float] the parsed non-negative float, or +default+.
def fetch_nonnegative_float(key, default)
value = ENV[key]
return default if value.nil?
trimmed = value.strip
return default if trimmed.empty?
parsed = Float(trimmed, exception: false)
return default if parsed.nil? || parsed.negative?
parsed
end
# Resolve the effective XDG directory honoring environment overrides.
#
# @param env_key [String] name of the environment variable to inspect.
# @param fallback_segments [Array<String>] path segments appended to the user home directory.
# @return [String] absolute base directory referenced by the XDG variable.
def resolve_xdg_home(env_key, fallback_segments)
raw = fetch_string(env_key, nil)
candidate = raw && !raw.empty? ? raw : nil
return File.expand_path(candidate) if candidate
base_home = safe_home_directory
File.expand_path(File.join(base_home, *fallback_segments))
end
# Retrieve the current user's home directory handling runtime failures.
#
# @return [String] absolute path to the user home or web root fallback.
def safe_home_directory
home = Dir.home
return web_root if home.nil? || home.empty?
home
rescue ArgumentError, RuntimeError
web_root
end
end
end