Compare commits

..

13 Commits

Author SHA1 Message Date
l5y
cc8fec6d05 Align theme and info controls (#371)
* Align theme and info controls

* design tweaks
2025-10-17 19:27:14 +00:00
l5y
01665b6e3a Fixes POST request 403 errors on instances behind Cloudflare proxy (#368)
* Add full headers to ingestor POST requests to avoid CF bans

* run black

* Guard Authorization header when token absent

---------

Co-authored-by: varna9000 <milen@aeroisk.com>
2025-10-16 22:29:04 +02:00
l5y
1898a99789 Delay initial federation announcements (#366) 2025-10-16 21:50:43 +02:00
l5y
3eefda9205 Ensure well-known document stays in sync (#365) 2025-10-16 21:43:11 +02:00
l5y
a6ba9a8227 Guard federation DNS resolution against restricted networks (#362)
* Guard federation DNS resolution against restricted networks

* Pin federation HTTP clients to vetted IPs
2025-10-16 21:15:34 +02:00
l5y
7055444c4b Add federation ingestion limits and tests (#364) 2025-10-16 21:15:18 +02:00
l5y
4bfc0e25cb Prefer reported primary channel names (#363) 2025-10-16 20:35:24 +02:00
l5y
81335cbf7b Decouple messages API from node joins (#360) 2025-10-16 13:19:29 +02:00
l5y
76b57c08c6 Fix ingestor reconnection detection (#361) 2025-10-16 13:06:32 +02:00
l5y
926b5591b0 Harden instance domain validation (#359) 2025-10-16 10:51:34 +02:00
l5y
957e597004 Ensure INSTANCE_DOMAIN propagates to containers (#358) 2025-10-15 23:22:46 +02:00
l5y
68cfbf139f chore: bump version to 0.5.2 (#356)
Co-authored-by: l5yth <d220195275+l5yth@users.noreply.github.com>
2025-10-15 23:16:30 +02:00
l5y
b2f4fcaaa5 Gracefully retry federation announcements over HTTP (#355) 2025-10-15 23:11:59 +02:00
27 changed files with 1418 additions and 275 deletions

View File

@@ -57,6 +57,11 @@ CONTACT_LINK='#potatomesh:dod.ngo'
# Debug mode (0=off, 1=on)
DEBUG=0
# Public domain name for this PotatoMesh instance
# Provide a hostname (with optional port) that resolves to the web service.
# Example: mesh.example.org or mesh.example.org:41447
INSTANCE_DOMAIN=mesh.example.org
# Docker image architecture (linux-amd64, linux-arm64, linux-armv7)
POTATOMESH_IMAGE_ARCH=linux-amd64

View File

@@ -1,5 +1,21 @@
# CHANGELOG
## v0.5.1
* Recursively ingest federated instances by @l5yth in <https://github.com/l5yth/potato-mesh/pull/353>
* Remove federation timeout environment overrides by @l5yth in <https://github.com/l5yth/potato-mesh/pull/352>
* Close unrelated short info overlays when opening short info by @l5yth in <https://github.com/l5yth/potato-mesh/pull/351>
* Improve federation instance error diagnostics by @l5yth in <https://github.com/l5yth/potato-mesh/pull/350>
* Harden federation domain validation and tests by @l5yth in <https://github.com/l5yth/potato-mesh/pull/347>
* Handle malformed instance records gracefully by @l5yth in <https://github.com/l5yth/potato-mesh/pull/348>
* Fix ingestor device mounting for non-serial connections by @l5yth in <https://github.com/l5yth/potato-mesh/pull/346>
* Ensure Docker deployments persist keyfile and well-known assets by @l5yth in <https://github.com/l5yth/potato-mesh/pull/345>
* Add modem preset display to node overlay by @l5yth in <https://github.com/l5yth/potato-mesh/pull/340>
* Display message frequency and channel in chat log by @l5yth in <https://github.com/l5yth/potato-mesh/pull/339>
* Bump fallback version string to v0.5.1 by @l5yth in <https://github.com/l5yth/potato-mesh/pull/338>
* Docs: update changelog for 0.5.0 by @l5yth in <https://github.com/l5yth/potato-mesh/pull/337>
* Fix ingestor docker import path by @l5yth in <https://github.com/l5yth/potato-mesh/pull/336>
## v0.5.0
* Ensure node overlays appear above fullscreen map by @l5yth in <https://github.com/l5yth/potato-mesh/pull/333>

View File

@@ -31,6 +31,7 @@ against the web API.
API_TOKEN=replace-with-a-strong-token
SITE_NAME=PotatoMesh Demo
CONNECTION=/dev/ttyACM0
INSTANCE_DOMAIN=mesh.example.org
```
Additional environment variables are optional:
@@ -43,6 +44,8 @@ Additional environment variables are optional:
the ingestor.
- `CHANNEL_INDEX` selects the LoRa channel when using serial or Bluetooth
connections.
- `INSTANCE_DOMAIN` pins the public hostname advertised by the web UI and API
responses, bypassing reverse DNS detection when set.
- `DEBUG` enables verbose logging across the stack.
## Docker Compose file

View File

@@ -1,10 +1,11 @@
# 🥔 PotatoMesh
[![GitHub Workflow Status](https://img.shields.io/github/actions/workflow/status/l5yth/potato-mesh/ruby.yml?branch=main)](https://github.com/l5yth/potato-mesh/actions)
[![GitHub release (latest by date)](https://img.shields.io/github/v/release/l5yth/potato-mesh)](https://github.com/l5yth/potato-mesh/releases)
[![GitHub release](https://img.shields.io/github/v/release/l5yth/potato-mesh)](https://github.com/l5yth/potato-mesh/releases)
[![codecov](https://codecov.io/gh/l5yth/potato-mesh/branch/main/graph/badge.svg?token=FS7252JVZT)](https://codecov.io/gh/l5yth/potato-mesh)
[![Open-Source License](https://img.shields.io/github/license/l5yth/potato-mesh)](LICENSE)
[![Contributions Welcome](https://img.shields.io/badge/contributions-welcome-brightgreen.svg?style=flat)](https://github.com/l5yth/potato-mesh/issues)
[![Matrix Chat](https://img.shields.io/badge/matrix-%23potatomesh:dod.ngo-blue)](https://matrix.to/#/#potatomesh:dod.ngo)
A simple Meshtastic-powered node dashboard for your local community. _No MQTT clutter, just local LoRa aether._
@@ -24,7 +25,7 @@ Requires Ruby for the Sinatra web app and SQLite3 for the app's database.
```bash
pacman -S ruby sqlite3
gem install sinatra sqlite3 rackup puma rspec rack-test rufo
gem install sinatra sqlite3 rackup puma rspec rack-test rufo prometheus-client
cd ./web
bundle install
```
@@ -67,25 +68,6 @@ exec ruby app.rb -p 41447 -o 0.0.0.0
* Configure `INSTANCE_DOMAIN` with the public URL of your deployment so vanity
links and generated metadata resolve correctly.
### Configuration storage
PotatoMesh stores its runtime assets using the XDG base directory specification.
During startup the web application migrates existing configuration from
`web/.config` and `web/config` into the resolved `XDG_CONFIG_HOME` directory.
This preserves previously generated instance key material and
`/.well-known/potato-mesh` documents so upgrades do not create new credentials
unnecessarily. When XDG directories are not provided the application falls back
to the repository root.
The migrated key is written to `<XDG_CONFIG_HOME>/potato-mesh/keyfile` and the
well-known document is staged in
`<XDG_CONFIG_HOME>/potato-mesh/well-known/potato-mesh`.
When deploying with Docker Compose, the default `docker-compose.yml` mounts a
named volume at `/app/.config/potato-mesh` to persist these files. Avoid
removing this volume once a key has been generated so the instance identity and
well-known metadata remain stable across restarts.
The web app can be configured with environment variables (defaults shown):
* `SITE_NAME` - title and header shown in the UI (default: "PotatoMesh Demo")
@@ -95,6 +77,7 @@ The web app can be configured with environment variables (defaults shown):
* `MAX_DISTANCE` - hide nodes farther than this distance from the center (default: `42`)
* `CONTACT_LINK` - chat link or Matrix alias for footer and overlay (default: `#potatomesh:dod.ngo`)
* `PRIVATE` - set to `1` to hide the chat UI, disable message APIs, and exclude hidden clients (default: unset)
* `INSTANCE_DOMAIN` - public hostname (optionally with port) used for metadata, federation, and API links (default: auto-detected)
The application derives SEO-friendly document titles, descriptions, and social
preview tags from these existing configuration values and reuses the bundled
@@ -106,6 +89,18 @@ Example:
SITE_NAME="PotatoMesh Demo" MAP_CENTER=38.761944,-27.090833 MAX_DISTANCE=42 CONTACT_LINK="#potatomesh:dod.ngo" ./app.sh
```
### Configuration & Storage
PotatoMesh stores its runtime assets using the XDG base directory specification.
When XDG directories are not provided the application falls back
to the repository root.
The key is written to `$XDG_CONFIG_HOME/potato-mesh/keyfile` and the
well-known document is staged in
`$XDG_CONFIG_HOME/potato-mesh/well-known/potato-mesh`.
The database can be found in `$XDG_DATA_HOME/potato-mesh`.
### API
The web app contains an API:
@@ -115,7 +110,9 @@ The web app contains an API:
* GET `/api/messages?limit=100` - returns the latest 100 messages (disabled when `PRIVATE=1`)
* GET `/api/telemetry?limit=100` - returns the latest 100 telemetry data
* GET `/api/neighbors?limit=100` - returns the latest 100 neighbor tuples
* GET `/metrics`- prometheus endpoint
* GET `/api/instances` - returns known potato-mesh instances in other locations
* GET `/metrics`- metrics for the prometheus endpoint
* GET `/version`- information about the potato-mesh instance
* POST `/api/nodes` - upserts nodes provided as JSON object mapping node ids to node data (requires `Authorization: Bearer <API_TOKEN>`)
* POST `/api/positions` - appends positions provided as a JSON object or array (requires `Authorization: Bearer <API_TOKEN>`)
* POST `/api/messages` - appends messages provided as a JSON object or array (requires `Authorization: Bearer <API_TOKEN>`; disabled when `PRIVATE=1`)
@@ -167,10 +164,9 @@ interface. `CONNECTION` also accepts Bluetooth device addresses (e.g.,
## Demos
* <https://potatomesh.net/>
* <https://vrs.kdd2105.ru/>
* <https://potatomesh.stratospire.com/>
* <https://es1tem.uk/>
Post your nodes here:
* <https://github.com/l5yth/potato-mesh/discussions/258>
## Docker

View File

@@ -75,6 +75,7 @@ MAX_DISTANCE=$(grep "^MAX_DISTANCE=" .env 2>/dev/null | cut -d'=' -f2- | tr -d '
CONTACT_LINK=$(grep "^CONTACT_LINK=" .env 2>/dev/null | cut -d'=' -f2- | tr -d '"' || echo "#potatomesh:dod.ngo")
API_TOKEN=$(grep "^API_TOKEN=" .env 2>/dev/null | cut -d'=' -f2- | tr -d '"' || echo "")
POTATOMESH_IMAGE_ARCH=$(grep "^POTATOMESH_IMAGE_ARCH=" .env 2>/dev/null | cut -d'=' -f2- | tr -d '"' || echo "linux-amd64")
INSTANCE_DOMAIN=$(grep "^INSTANCE_DOMAIN=" .env 2>/dev/null | cut -d'=' -f2- | tr -d '"' || echo "")
echo "📍 Location Settings"
echo "-------------------"
@@ -99,6 +100,13 @@ echo "------------------"
echo "Specify the Docker image architecture for your host (linux-amd64, linux-arm64, linux-armv7)."
read_with_default "Docker image architecture" "$POTATOMESH_IMAGE_ARCH" POTATOMESH_IMAGE_ARCH
echo ""
echo "🌐 Domain Settings"
echo "------------------"
echo "Provide the public hostname that clients should use to reach this PotatoMesh instance."
echo "Leave blank to allow automatic detection via reverse DNS."
read_with_default "Instance domain (e.g. mesh.example.org)" "$INSTANCE_DOMAIN" INSTANCE_DOMAIN
echo ""
echo "🔐 Security Settings"
echo "-------------------"
@@ -142,6 +150,11 @@ update_env "MAX_DISTANCE" "$MAX_DISTANCE"
update_env "CONTACT_LINK" "\"$CONTACT_LINK\""
update_env "API_TOKEN" "$API_TOKEN"
update_env "POTATOMESH_IMAGE_ARCH" "$POTATOMESH_IMAGE_ARCH"
if [ -n "$INSTANCE_DOMAIN" ]; then
update_env "INSTANCE_DOMAIN" "$INSTANCE_DOMAIN"
else
sed -i.bak '/^INSTANCE_DOMAIN=.*/d' .env
fi
# Migrate legacy connection settings and ensure defaults exist
if grep -q "^MESH_SERIAL=" .env; then
@@ -176,6 +189,7 @@ echo " Frequency: $FREQUENCY"
echo " Chat: ${CONTACT_LINK:-'Not set'}"
echo " API Token: ${API_TOKEN:0:8}..."
echo " Docker Image Arch: $POTATOMESH_IMAGE_ARCH"
echo " Instance Domain: ${INSTANCE_DOMAIN:-'Auto-detected'}"
echo ""
echo "🚀 You can now start PotatoMesh with:"
echo " docker-compose up -d"

View File

@@ -78,17 +78,36 @@ def _iter_channel_objects(channels_obj: Any) -> Iterator[Any]:
def _primary_channel_name() -> str | None:
"""Return the name to use for the primary channel when available."""
"""Return the fallback name to use for the primary channel when needed."""
preset = getattr(config, "MODEM_PRESET", None)
if isinstance(preset, str) and preset.strip():
return preset
return preset.strip()
env_name = os.environ.get("CHANNEL", "").strip()
if env_name:
return env_name
return None
def _extract_channel_name(settings_obj: Any) -> str | None:
"""Normalise the configured channel name extracted from ``settings_obj``."""
if settings_obj is None:
return None
if isinstance(settings_obj, dict):
candidate = settings_obj.get("name")
else:
candidate = getattr(settings_obj, "name", None)
if isinstance(candidate, str):
candidate = candidate.strip()
if candidate:
return candidate
return None
def _normalize_role(role: Any) -> int | None:
"""Convert a channel role descriptor into an integer value."""
@@ -122,27 +141,23 @@ def _channel_tuple(channel_obj: Any) -> tuple[int, str] | None:
role_value = _normalize_role(getattr(channel_obj, "role", None))
if role_value == _ROLE_PRIMARY:
channel_index = 0
channel_name = _primary_channel_name()
channel_name = _extract_channel_name(getattr(channel_obj, "settings", None))
if channel_name is None:
channel_name = _primary_channel_name()
elif role_value == _ROLE_SECONDARY:
raw_index = getattr(channel_obj, "index", None)
try:
channel_index = int(raw_index)
except Exception:
channel_index = None
settings = getattr(channel_obj, "settings", None)
channel_name = getattr(settings, "name", None) if settings else None
channel_name = _extract_channel_name(getattr(channel_obj, "settings", None))
else:
return None
if not isinstance(channel_index, int):
return None
if isinstance(channel_name, str):
channel_name = channel_name.strip()
else:
channel_name = None
if not channel_name:
if not isinstance(channel_name, str) or not channel_name:
return None
return channel_index, channel_name

View File

@@ -167,6 +167,45 @@ def _is_ble_interface(iface_obj) -> bool:
return "ble_interface" in module_name
def _connected_state(candidate) -> bool | None:
"""Return the connection state advertised by ``candidate``.
Parameters:
candidate: Attribute returned from ``iface.isConnected`` on a
Meshtastic interface. The value may be a boolean, a callable that
yields a boolean, or a :class:`threading.Event` instance.
Returns:
``True`` when the interface is believed to be connected, ``False``
when it appears disconnected, and ``None`` when the state cannot be
determined from the provided attribute.
"""
if candidate is None:
return None
if isinstance(candidate, threading.Event):
return candidate.is_set()
is_set_method = getattr(candidate, "is_set", None)
if callable(is_set_method):
try:
return bool(is_set_method())
except Exception:
return None
if callable(candidate):
try:
return bool(candidate())
except Exception:
return None
try:
return bool(candidate)
except Exception: # pragma: no cover - defensive guard
return None
def main() -> None:
"""Run the mesh ingestion daemon until interrupted."""
@@ -411,13 +450,20 @@ def main() -> None:
connected_attr = getattr(iface, "isConnected", None)
believed_disconnected = False
if callable(connected_attr):
try:
believed_disconnected = not bool(connected_attr())
except Exception:
believed_disconnected = False
elif connected_attr is not None:
believed_disconnected = not bool(connected_attr)
connected_state = _connected_state(connected_attr)
if connected_state is None:
if callable(connected_attr):
try:
believed_disconnected = not bool(connected_attr())
except Exception:
believed_disconnected = False
elif connected_attr is not None:
try:
believed_disconnected = not bool(connected_attr)
except Exception: # pragma: no cover - defensive guard
believed_disconnected = False
else:
believed_disconnected = not connected_state
should_reconnect = believed_disconnected or (
inactivity_elapsed >= inactivity_reconnect_secs
@@ -468,5 +514,6 @@ __all__ = [
"_node_items_snapshot",
"_subscribe_receive_topics",
"_is_ble_interface",
"_connected_state",
"main",
]

View File

@@ -72,11 +72,25 @@ def _post_json(
return
url = f"{instance}{path}"
data = json.dumps(payload).encode("utf-8")
req = urllib.request.Request(
url, data=data, headers={"Content-Type": "application/json"}
)
# Add full headers to avoid Cloudflare blocks on instances behind cloudflare proxy
headers = {
"Content-Type": "application/json",
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36",
"Accept": "application/json",
"Accept-Language": "en-US,en;q=0.9",
"Origin": f"{instance}",
"Referer": f"{instance}",
}
if api_token:
req.add_header("Authorization", f"Bearer {api_token}")
headers["Authorization"] = f"Bearer {api_token}"
req = urllib.request.Request(
url,
data=data,
headers=headers,
)
try:
with urllib.request.urlopen(req, timeout=10) as resp:
resp.read()

View File

@@ -10,6 +10,7 @@ x-web-base: &web-base
MAX_DISTANCE: ${MAX_DISTANCE:-42}
CONTACT_LINK: ${CONTACT_LINK:-#potatomesh:dod.ngo}
API_TOKEN: ${API_TOKEN}
INSTANCE_DOMAIN: ${INSTANCE_DOMAIN}
DEBUG: ${DEBUG:-0}
command: ["ruby", "app.rb", "-p", "41447", "-o", "0.0.0.0"]
volumes:
@@ -33,6 +34,7 @@ x-ingestor-base: &ingestor-base
CHANNEL_INDEX: ${CHANNEL_INDEX:-0}
POTATOMESH_INSTANCE: ${POTATOMESH_INSTANCE:-http://web:41447}
API_TOKEN: ${API_TOKEN}
INSTANCE_DOMAIN: ${INSTANCE_DOMAIN}
DEBUG: ${DEBUG:-0}
volumes:
- potatomesh_data:/app/.local/share/potato-mesh

View File

@@ -407,11 +407,14 @@ def test_capture_channels_from_interface_records_metadata(mesh_module, capsys):
mesh = mesh_module
mesh.config.MODEM_PRESET = "MediumFast"
mesh.channels._reset_channel_cache()
class DummyInterface:
def __init__(self) -> None:
self.wait_calls = 0
primary = SimpleNamespace(role=1, settings=SimpleNamespace())
primary = SimpleNamespace(
role=1, settings=SimpleNamespace(name=" radioamator ")
)
secondary = SimpleNamespace(
role="SECONDARY",
index="7",
@@ -428,19 +431,20 @@ def test_capture_channels_from_interface_records_metadata(mesh_module, capsys):
log_output = capsys.readouterr().out
assert iface.wait_calls == 1
assert mesh.channels.channel_mappings() == ((0, "MediumFast"), (7, "TestChannel"))
assert mesh.channels.channel_mappings() == ((0, "radioamator"), (7, "TestChannel"))
assert mesh.channels.channel_name(7) == "TestChannel"
assert "Captured channel metadata" in log_output
assert "channels=((0, 'MediumFast'), (7, 'TestChannel'))" in log_output
assert "channels=((0, 'radioamator'), (7, 'TestChannel'))" in log_output
mesh.channels.capture_from_interface(SimpleNamespace(localNode=None))
assert mesh.channels.channel_mappings() == ((0, "MediumFast"), (7, "TestChannel"))
assert mesh.channels.channel_mappings() == ((0, "radioamator"), (7, "TestChannel"))
def test_capture_channels_primary_falls_back_to_env(mesh_module, monkeypatch, capsys):
mesh = mesh_module
mesh.config.MODEM_PRESET = None
mesh.channels._reset_channel_cache()
monkeypatch.setenv("CHANNEL", "FallbackName")
class DummyInterface:
@@ -461,6 +465,29 @@ def test_capture_channels_primary_falls_back_to_env(mesh_module, monkeypatch, ca
assert "FallbackName" in log_output
def test_capture_channels_primary_falls_back_to_preset(mesh_module, capsys):
mesh = mesh_module
mesh.config.MODEM_PRESET = " MediumFast "
mesh.channels._reset_channel_cache()
class DummyInterface:
def __init__(self) -> None:
self.localNode = SimpleNamespace(
channels=[SimpleNamespace(role="PRIMARY", settings=SimpleNamespace())]
)
def waitForConfig(self) -> None: # noqa: D401 - matches interface contract
return None
mesh.channels.capture_from_interface(DummyInterface())
log_output = capsys.readouterr().out
assert mesh.channels.channel_mappings() == ((0, "MediumFast"),)
assert mesh.channels.channel_name(0) == "MediumFast"
assert "MediumFast" in log_output
def test_create_default_interface_falls_back_to_tcp(mesh_module, monkeypatch):
mesh = mesh_module
attempts = []
@@ -1201,6 +1228,81 @@ def test_main_retries_interface_creation(mesh_module, monkeypatch):
assert iface.closed is True
def test_connected_state_handles_threading_event(mesh_module):
mesh = mesh_module
event = mesh.threading.Event()
assert mesh._connected_state(event) is False
event.set()
assert mesh._connected_state(event) is True
def test_main_reconnects_when_connection_event_clears(mesh_module, monkeypatch):
mesh = mesh_module
attempts = []
interfaces = []
current_iface = {"obj": None}
import threading as real_threading_module
real_event_cls = real_threading_module.Event
class DummyInterface:
def __init__(self):
self.nodes = {}
self.isConnected = real_event_cls()
self.isConnected.set()
self.close_calls = 0
def close(self):
self.close_calls += 1
def fake_create(port):
iface = DummyInterface()
attempts.append(port)
interfaces.append(iface)
current_iface["obj"] = iface
return iface, port
class DummyStopEvent:
def __init__(self):
self._flag = False
self.wait_calls = 0
def is_set(self):
return self._flag
def set(self):
self._flag = True
def wait(self, timeout):
self.wait_calls += 1
if self.wait_calls == 1:
iface = current_iface["obj"]
assert iface is not None, "interface should be available"
iface.isConnected.clear()
return self._flag
self._flag = True
return True
monkeypatch.setattr(mesh, "PORT", "/dev/ttyTEST")
monkeypatch.setattr(mesh, "_create_serial_interface", fake_create)
monkeypatch.setattr(mesh.threading, "Event", DummyStopEvent)
monkeypatch.setattr(mesh.signal, "signal", lambda *_, **__: None)
monkeypatch.setattr(mesh, "SNAPSHOT_SECS", 0)
monkeypatch.setattr(mesh, "_RECONNECT_INITIAL_DELAY_SECS", 0)
monkeypatch.setattr(mesh, "_RECONNECT_MAX_DELAY_SECS", 0)
monkeypatch.setattr(mesh, "_CLOSE_TIMEOUT_SECS", 0)
mesh.main()
assert len(attempts) == 2
assert len(interfaces) == 2
assert interfaces[0].close_calls >= 1
assert interfaces[1].close_calls >= 1
def test_main_recreates_interface_after_snapshot_error(mesh_module, monkeypatch):
mesh = mesh_module

View File

@@ -156,6 +156,8 @@ module PotatoMesh
def announce_instance_to_domain(domain, payload_json)
return false unless domain && !domain.empty?
https_failures = []
instance_uri_candidates(domain, "/api/instances").each do |uri|
begin
http = build_remote_http_client(uri)
@@ -181,16 +183,51 @@ module PotatoMesh
status: response.code,
)
rescue StandardError => e
warn_log(
"Federation announcement raised exception",
metadata = {
context: "federation.announce",
target: uri.to_s,
error_class: e.class.name,
error_message: e.message,
}
if uri.scheme == "https" && https_connection_refused?(e)
debug_log(
"HTTPS federation announcement failed, retrying with HTTP",
**metadata,
)
https_failures << metadata
next
end
warn_log(
"Federation announcement raised exception",
**metadata,
)
end
end
https_failures.each do |metadata|
warn_log(
"Federation announcement raised exception",
**metadata,
)
end
false
end
# Determine whether an HTTPS announcement failure should fall back to HTTP.
#
# @param error [StandardError] failure raised while attempting HTTPS.
# @return [Boolean] true when the error corresponds to a refused TCP connection.
def https_connection_refused?(error)
current = error
while current
return true if current.is_a?(Errno::ECONNREFUSED)
current = current.respond_to?(:cause) ? current.cause : nil
end
false
end
@@ -236,12 +273,17 @@ module PotatoMesh
thread
end
# Launch a background thread responsible for the first federation broadcast.
#
# @return [Thread, nil] the thread handling the initial announcement.
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
delay = PotatoMesh::Config.initial_federation_delay_seconds
Kernel.sleep(delay) if delay.positive?
announce_instance_to_all_domains
rescue StandardError => e
warn_log(
@@ -408,12 +450,34 @@ module PotatoMesh
# @param db [SQLite3::Database] open database connection used for writes.
# @param domain [String] remote domain to crawl for federation records.
# @param visited [Set<String>] domains processed during this crawl.
# @param per_response_limit [Integer, nil] maximum entries processed per response.
# @param overall_limit [Integer, nil] maximum unique domains visited.
# @return [Set<String>] updated set of visited domains.
def ingest_known_instances_from!(db, domain, visited: nil)
def ingest_known_instances_from!(
db,
domain,
visited: nil,
per_response_limit: nil,
overall_limit: nil
)
sanitized = sanitize_instance_domain(domain)
return visited || Set.new unless sanitized
visited ||= Set.new
overall_limit ||= PotatoMesh::Config.federation_max_domains_per_crawl
per_response_limit ||= PotatoMesh::Config.federation_max_instances_per_response
if overall_limit && overall_limit.positive? && visited.size >= overall_limit
debug_log(
"Skipped remote instance crawl due to crawl limit",
context: "federation.instances",
domain: sanitized,
limit: overall_limit,
)
return visited
end
return visited if visited.include?(sanitized)
visited << sanitized
@@ -429,7 +493,29 @@ module PotatoMesh
return visited
end
processed_entries = 0
payload.each do |entry|
if per_response_limit && per_response_limit.positive? && processed_entries >= per_response_limit
debug_log(
"Skipped remote instance entry due to response limit",
context: "federation.instances",
domain: sanitized,
limit: per_response_limit,
)
break
end
if overall_limit && overall_limit.positive? && visited.size >= overall_limit
debug_log(
"Skipped remote instance entry due to crawl limit",
context: "federation.instances",
domain: sanitized,
limit: overall_limit,
)
break
end
processed_entries += 1
attributes, signature, reason = remote_instance_attributes_from_payload(entry)
unless attributes && signature
warn_log(
@@ -486,7 +572,13 @@ module PotatoMesh
begin
upsert_instance_record(db, attributes, signature)
ingest_known_instances_from!(db, attributes[:domain], visited: visited)
ingest_known_instances_from!(
db,
attributes[:domain],
visited: visited,
per_response_limit: per_response_limit,
overall_limit: overall_limit,
)
rescue ArgumentError => e
warn_log(
"Failed to persist remote instance",
@@ -501,12 +593,52 @@ module PotatoMesh
visited
end
# Resolve the host component of a remote URI and ensure the destination is
# safe for federation HTTP requests.
#
# The method performs a DNS lookup using Addrinfo to capture every
# available address for the supplied URI host. The resulting addresses are
# converted to {IPAddr} objects for consistent inspection via
# {restricted_ip_address?}. When all resolved addresses fall within
# restricted ranges, the method raises an ArgumentError so callers can
# abort the federation request before contacting the remote endpoint.
#
# @param uri [URI::Generic] remote endpoint candidate.
# @return [Array<IPAddr>] list of resolved, unrestricted IP addresses.
# @raise [ArgumentError] when +uri.host+ is blank or resolves solely to
# restricted addresses.
def resolve_remote_ip_addresses(uri)
host = uri&.host
raise ArgumentError, "URI missing host" unless host
addrinfo_records = Addrinfo.getaddrinfo(host, nil, Socket::AF_UNSPEC, Socket::SOCK_STREAM)
addresses = addrinfo_records.filter_map do |addr|
begin
IPAddr.new(addr.ip_address)
rescue IPAddr::InvalidAddressError
nil
end
end
unique_addresses = addresses.uniq { |ip| [ip.family, ip.to_s] }
unrestricted_addresses = unique_addresses.reject { |ip| restricted_ip_address?(ip) }
if unique_addresses.any? && unrestricted_addresses.empty?
raise ArgumentError, "restricted domain"
end
unrestricted_addresses
end
# Build an HTTP client configured for communication with a remote instance.
#
# @param uri [URI::Generic] target URI describing the remote endpoint.
# @return [Net::HTTP] HTTP client ready to execute the request.
def build_remote_http_client(uri)
remote_addresses = resolve_remote_ip_addresses(uri)
http = Net::HTTP.new(uri.host, uri.port)
if http.respond_to?(:ipaddr=) && remote_addresses.any?
http.ipaddr = remote_addresses.first.to_s
end
http.open_timeout = PotatoMesh::Config.remote_instance_http_timeout
http.read_timeout = PotatoMesh::Config.remote_instance_read_timeout
http.use_ssl = uri.scheme == "https"

View File

@@ -195,24 +195,31 @@ module PotatoMesh
[json_output, signature]
end
# Regenerate the well-known document when the on-disk copy is stale.
# Regenerate the well-known document when it is stale or when the existing
# content no longer matches the current instance configuration.
#
# @return [void]
def refresh_well_known_document_if_stale
FileUtils.mkdir_p(well_known_directory)
path = well_known_file_path
now = Time.now
json_output, signature = build_well_known_document
expected_contents = json_output.end_with?("\n") ? json_output : "#{json_output}\n"
needs_update = true
if File.exist?(path)
current_contents = File.binread(path)
mtime = File.mtime(path)
if (now - mtime) < PotatoMesh::Config.well_known_refresh_interval
return
if current_contents == expected_contents &&
(now - mtime) < PotatoMesh::Config.well_known_refresh_interval
needs_update = false
end
end
json_output, signature = build_well_known_document
return unless needs_update
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")
file.write(expected_contents)
end
debug_log(

View File

@@ -62,6 +62,22 @@ module PotatoMesh
candidate = "#{candidate_host}:#{port}" if port_required?(uri, trimmed)
end
ipv6_with_port = candidate.match(/\A(?<address>.+):(?<port>\d+)\z/)
if ipv6_with_port
address = ipv6_with_port[:address]
port = ipv6_with_port[:port]
literal = ipv6_literal?(address)
if literal && PotatoMesh::Sanitizer.valid_port?(port)
candidate = "[#{literal}]:#{port}"
else
ipv6_literal = ipv6_literal?(candidate)
candidate = "[#{ipv6_literal}]" if ipv6_literal
end
else
ipv6_literal = ipv6_literal?(candidate)
candidate = "[#{ipv6_literal}]" if ipv6_literal
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}"

View File

@@ -205,34 +205,8 @@ module PotatoMesh
sql = <<~SQL
SELECT m.id, m.rx_time, m.rx_iso, m.from_id, m.to_id, m.channel,
m.portnum, m.text, m.encrypted, m.rssi, m.hop_limit,
m.lora_freq AS msg_lora_freq, m.modem_preset AS msg_modem_preset,
m.channel_name AS msg_channel_name, m.snr AS msg_snr,
n.node_id AS node_node_id, n.num AS node_num,
n.short_name AS node_short_name, n.long_name AS node_long_name,
n.macaddr AS node_macaddr, n.hw_model AS node_hw_model,
n.role AS node_role, n.public_key AS node_public_key,
n.is_unmessagable AS node_is_unmessagable,
n.is_favorite AS node_is_favorite,
n.hops_away AS node_hops_away, n.snr AS node_snr,
n.last_heard AS node_last_heard, n.first_heard AS node_first_heard,
n.battery_level AS node_battery_level, n.voltage AS node_voltage,
n.channel_utilization AS node_channel_utilization,
n.air_util_tx AS node_air_util_tx,
n.uptime_seconds AS node_uptime_seconds,
n.position_time AS node_position_time,
n.location_source AS node_location_source,
n.precision_bits AS node_precision_bits,
n.latitude AS node_latitude, n.longitude AS node_longitude,
n.altitude AS node_altitude,
n.lora_freq AS node_lora_freq, n.modem_preset AS node_modem_preset
m.lora_freq, m.modem_preset, m.channel_name, m.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
@@ -243,56 +217,19 @@ module PotatoMesh
rows = db.execute(sql, params)
rows.each do |r|
r.delete_if { |key, _| key.is_a?(Integer) }
r["lora_freq"] = r.delete("msg_lora_freq")
r["modem_preset"] = r.delete("msg_modem_preset")
r["channel_name"] = r.delete("msg_channel_name")
snr_value = r.delete("msg_snr")
if PotatoMesh::Config.debug? && (r["from_id"].nil? || r["from_id"].to_s.empty?)
if PotatoMesh::Config.debug? && (r["from_id"].nil? || r["from_id"].to_s.strip.empty?)
raw = db.execute("SELECT * FROM messages WHERE id = ?", [r["id"]]).first
debug_log(
"Message join produced empty sender",
"Message query produced empty sender",
context: "queries.messages",
stage: "before_join",
stage: "raw_row",
row: raw,
)
debug_log(
"Message join produced empty sender",
context: "queries.messages",
stage: "after_join",
row: r,
)
end
node = {}
r.keys.grep(/^node_/).each do |k|
attribute = k.delete_prefix("node_")
node[attribute] = r.delete(k)
end
r["snr"] = snr_value
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)
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"]))
canonical_from_id = string_or_nil(normalize_node_id(db, r["from_id"]))
node_id = canonical_from_id || string_or_nil(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/)
@@ -302,11 +239,13 @@ module PotatoMesh
end
end
if PotatoMesh::Config.debug? && (r["from_id"].nil? || r["from_id"].to_s.empty?)
r["node_id"] = node_id if node_id
if PotatoMesh::Config.debug? && (r["from_id"].nil? || r["from_id"].to_s.strip.empty?)
debug_log(
"Message row missing sender after processing",
"Message query produced empty sender",
context: "queries.messages",
stage: "after_processing",
stage: "after_normalization",
row: r,
)
end

View File

@@ -84,10 +84,18 @@ module PotatoMesh
end
id = string_or_nil(payload["id"]) || string_or_nil(payload["instanceId"])
raw_domain = sanitize_instance_domain(payload["domain"], downcase: false)
# Normalise the domain for persistence while retaining the caller's
# original casing for signature verification fallbacks.
normalized_domain = sanitize_instance_domain(raw_domain)
raw_domain_input = payload["domain"]
raw_domain = sanitize_instance_domain(raw_domain_input, downcase: false)
normalized_domain = raw_domain && sanitize_instance_domain(raw_domain)
unless raw_domain && normalized_domain
warn_log(
"Instance registration rejected",
context: "ingest.register",
domain: string_or_nil(raw_domain_input),
reason: "invalid domain",
)
halt 400, { error: "invalid domain" }.to_json
end
pubkey = sanitize_public_key_pem(payload["pubkey"])
name = string_or_nil(payload["name"])
version = string_or_nil(payload["version"])
@@ -165,6 +173,22 @@ module PotatoMesh
halt 400, { error: "restricted domain" }.to_json
end
begin
resolve_remote_ip_addresses(URI.parse("https://#{attributes[:domain]}"))
rescue ArgumentError => e
warn_log(
"Instance registration rejected",
context: "ingest.register",
domain: attributes[:domain],
reason: "restricted domain",
error_message: e.message,
)
halt 400, { error: "restricted domain" }.to_json
rescue SocketError
# DNS lookups that fail to resolve are handled later when the
# registration flow attempts to contact the remote instance.
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)
@@ -217,7 +241,12 @@ module PotatoMesh
db = open_database
upsert_instance_record(db, attributes, signature)
ingest_known_instances_from!(db, attributes[:domain])
ingest_known_instances_from!(
db,
attributes[:domain],
per_response_limit: PotatoMesh::Config.federation_max_instances_per_response,
overall_limit: PotatoMesh::Config.federation_max_domains_per_crawl,
)
debug_log(
"Registered remote instance",
context: "ingest.register",

View File

@@ -34,6 +34,9 @@ module PotatoMesh
DEFAULT_MAX_DISTANCE_KM = 42.0
DEFAULT_REMOTE_INSTANCE_CONNECT_TIMEOUT = 5
DEFAULT_REMOTE_INSTANCE_READ_TIMEOUT = 12
DEFAULT_FEDERATION_MAX_INSTANCES_PER_RESPONSE = 64
DEFAULT_FEDERATION_MAX_DOMAINS_PER_CRAWL = 256
DEFAULT_INITIAL_FEDERATION_DELAY_SECONDS = 2
# Resolve the absolute path to the web application root directory.
#
@@ -131,7 +134,7 @@ module PotatoMesh
#
# @return [String] semantic version identifier.
def version_fallback
"v0.5.1"
"v0.5.2"
end
# Default refresh interval for frontend polling routines.
@@ -285,6 +288,26 @@ module PotatoMesh
DEFAULT_REMOTE_INSTANCE_READ_TIMEOUT
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
# Maximum acceptable age for remote node data.
#
# @return [Integer] seconds before remote nodes are considered stale.
@@ -313,6 +336,16 @@ module PotatoMesh
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.
@@ -424,6 +457,27 @@ module PotatoMesh
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
# Resolve the effective XDG directory honoring environment overrides.
#
# @param env_key [String] name of the environment variable to inspect.

View File

@@ -38,7 +38,12 @@ module PotatoMesh
end
# Ensure a value is a valid instance domain according to RFC 1035/3986
# rules. This rejects whitespace, path separators, and trailing dots.
# rules. Hostnames must include at least one dot-separated label and a
# top-level domain containing an alphabetic character. Literal IP
# addresses must be provided in standard dotted decimal form or enclosed in
# brackets when IPv6 notation is used. Optional ports must fall within the
# valid TCP/UDP range. Any opaque identifiers, URIs, or malformed hosts are
# rejected.
#
# @param value [String, Object, nil] candidate domain name.
# @param downcase [Boolean] whether to force the result to lowercase.
@@ -52,7 +57,92 @@ module PotatoMesh
return nil if trimmed.empty?
return nil if trimmed.match?(%r{[\s/\\@]})
downcase ? trimmed.downcase : trimmed
if trimmed.start_with?("[")
match = trimmed.match(/\A\[(?<address>[^\]]+)\](?::(?<port>\d+))?\z/)
return nil unless match
address = match[:address]
port = match[:port]
return nil if port && !valid_port?(port)
begin
IPAddr.new(address)
rescue IPAddr::InvalidAddressError
return nil
end
sanitized_address = downcase ? address.downcase : address
return "[#{sanitized_address}]#{port ? ":#{port}" : ""}"
end
domain = trimmed
port = nil
if domain.include?(":")
host_part, port_part = domain.split(":", 2)
return nil if host_part.nil? || host_part.empty?
return nil unless port_part && port_part.match?(/\A\d+\z/)
return nil unless valid_port?(port_part)
return nil if port_part.include?(":")
domain = host_part
port = port_part
end
unless valid_hostname?(domain) || valid_ipv4_literal?(domain)
return nil
end
sanitized_domain = downcase ? domain.downcase : domain
port ? "#{sanitized_domain}:#{port}" : sanitized_domain
end
# Determine whether the supplied hostname conforms to RFC 1035 label
# requirements and includes a valid top-level domain.
#
# @param hostname [String] host component without any port information.
# @return [Boolean] true when the hostname is valid.
def valid_hostname?(hostname)
return false if hostname.length > 253
labels = hostname.split(".")
return false if labels.length < 2
return false unless labels.all? { |label| valid_hostname_label?(label) }
top_level = labels.last
top_level.match?(/[a-z]/i)
end
# Validate a single hostname label ensuring the first and last characters
# are alphanumeric and that no unsupported symbols are present.
#
# @param label [String] hostname component between dots.
# @return [Boolean] true when the label is valid.
def valid_hostname_label?(label)
return false if label.empty?
return false if label.length > 63
label.match?(/\A[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\z/i)
end
# Validate whether a candidate represents a dotted decimal IPv4 literal.
#
# @param address [String] IP address string without port information.
# @return [Boolean] true when the address is a valid IPv4 literal.
def valid_ipv4_literal?(address)
return false unless address.match?(/\A\d{1,3}(?:\.\d{1,3}){3}\z/)
address.split(".").all? { |octet| octet.to_i.between?(0, 255) }
end
# Determine whether a port string represents a valid TCP/UDP port.
#
# @param port [String] numeric port representation.
# @return [Boolean] true when the port falls within the acceptable range.
def valid_port?(port)
value = port.to_i
value.positive? && value <= 65_535
end
# Extract the host component from a potentially bracketed domain literal.

View File

@@ -0,0 +1,123 @@
/*
* Copyright (C) 2025 l5yth
*
* 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.
*/
import test from 'node:test';
import assert from 'node:assert/strict';
import { createMessageNodeHydrator } from '../message-node-hydrator.js';
/**
* Capture warning invocations produced during a test run.
*/
class LoggerStub {
constructor() {
this.messages = [];
}
/**
* Record a warning message for later inspection.
*
* @param {...*} args Warning arguments.
* @returns {void}
*/
warn(...args) {
this.messages.push(args);
}
}
test('hydrate attaches cached nodes without performing lookups', async () => {
const node = { node_id: '!abc', short_name: 'Node' };
const nodesById = new Map([[node.node_id, node]]);
const hydrator = createMessageNodeHydrator({
fetchNodeById: async () => {
throw new Error('fetch should not be called');
},
applyNodeFallback: () => {}
});
const messages = [{ node_id: '!abc', text: 'Hello' }];
const result = await hydrator.hydrate(messages, nodesById);
assert.equal(result.length, 1);
assert.strictEqual(result[0].node, node);
assert.equal(nodesById.size, 1);
});
test('hydrate fetches missing nodes once and caches the result', async () => {
let fetchCalls = 0;
const fetchedNode = { node_id: '!fetch', short_name: 'Fetched' };
const hydrator = createMessageNodeHydrator({
fetchNodeById: async id => {
fetchCalls += 1;
assert.equal(id, '!fetch');
return { ...fetchedNode };
},
applyNodeFallback: () => {}
});
const nodesById = new Map();
const messages = [{ from_id: '!fetch', text: 'one' }, { node_id: '!fetch', text: 'two' }];
const result = await hydrator.hydrate(messages, nodesById);
assert.equal(fetchCalls, 1);
assert.strictEqual(nodesById.get('!fetch').short_name, 'Fetched');
assert.strictEqual(result[0].node, nodesById.get('!fetch'));
assert.strictEqual(result[1].node, nodesById.get('!fetch'));
});
test('hydrate falls back to placeholders when lookups fail', async () => {
const logger = new LoggerStub();
let fallbackCalls = 0;
const hydrator = createMessageNodeHydrator({
fetchNodeById: async () => null,
applyNodeFallback: node => {
fallbackCalls += 1;
if (!node.short_name) {
node.short_name = 'Fallback';
}
},
logger
});
const nodesById = new Map();
const messages = [{ from_id: '!missing', text: 'hi' }];
const result = await hydrator.hydrate(messages, nodesById);
assert.equal(nodesById.has('!missing'), false);
assert.equal(fallbackCalls, 1);
assert.ok(result[0].node);
assert.equal(result[0].node.short_name, 'Fallback');
assert.equal(logger.messages.length, 0);
});
test('hydrate records warning when fetch rejects', async () => {
const logger = new LoggerStub();
const hydrator = createMessageNodeHydrator({
fetchNodeById: async () => {
throw new Error('network error');
},
applyNodeFallback: () => {},
logger
});
const nodesById = new Map();
const messages = [{ from_id: '!warn', text: 'warn' }];
const result = await hydrator.hydrate(messages, nodesById);
assert.equal(result[0].node.node_id, '!warn');
assert.ok(logger.messages.length >= 1);
assert.equal(nodesById.has('!warn'), false);
});

View File

@@ -20,6 +20,7 @@ import { attachNodeInfoRefreshToMarker, overlayToPopupNode } from './map-marker-
import { createShortInfoOverlayStack } from './short-info-overlay-manager.js';
import { refreshNodeInformation } from './node-details.js';
import { extractModemMetadata, formatModemDisplay } from './node-modem-metadata.js';
import { createMessageNodeHydrator } from './message-node-hydrator.js';
import {
extractChatMessageMetadata,
formatChatMessagePrefix,
@@ -110,6 +111,11 @@ export function initializeApp(config) {
let allNeighbors = [];
/** @type {Map<string, Object>} */
let nodesById = new Map();
const messageNodeHydrator = createMessageNodeHydrator({
fetchNodeById,
applyNodeFallback: applyNodeNameFallback,
logger: console,
});
/** @type {string|undefined} */
let lastChatDate;
const NODE_LIMIT = 1000;
@@ -2417,6 +2423,22 @@ export function initializeApp(config) {
return r.json();
}
/**
* Retrieve a single node record by identifier from the API.
*
* @param {string} nodeId Canonical node identifier.
* @returns {Promise<Object|null>} Parsed node payload or null when absent.
*/
async function fetchNodeById(nodeId) {
if (typeof nodeId !== 'string') return null;
const trimmed = nodeId.trim();
if (trimmed.length === 0) return null;
const r = await fetch(`/api/nodes/${encodeURIComponent(trimmed)}`, { cache: 'no-store' });
if (r.status === 404) return null;
if (!r.ok) throw new Error('HTTP ' + r.status);
return r.json();
}
/**
* Fetch recent messages from the JSON API.
*
@@ -3061,14 +3083,10 @@ export function initializeApp(config) {
mergePositionsIntoNodes(nodes, positions);
computeDistances(nodes);
mergeTelemetryIntoNodes(nodes, telemetryEntries);
if (Array.isArray(messages)) {
messages.forEach(message => {
if (message && message.node) applyNodeNameFallback(message.node);
});
}
renderChatLog(nodes, messages);
allNodes = nodes;
rebuildNodeIndex(allNodes);
const chatMessages = await messageNodeHydrator.hydrate(messages, nodesById);
renderChatLog(nodes, chatMessages);
allNeighbors = Array.isArray(neighborTuples) ? neighborTuples : [];
applyFilter();
statusEl.textContent = 'updated ' + new Date().toLocaleTimeString();

View File

@@ -0,0 +1,150 @@
/*
* Copyright (C) 2025 l5yth
*
* 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.
*/
/**
* Build a hydrator capable of attaching node metadata to chat messages.
*
* @param {{
* fetchNodeById: (nodeId: string) => Promise<object|null>,
* applyNodeFallback: (node: object) => void,
* logger?: { warn?: (message?: any, ...optionalParams: any[]) => void }
* }} options Factory configuration.
* @returns {{
* hydrate: (messages: Array<object>|null|undefined, nodesById: Map<string, object>) => Promise<Array<object>>
* }} Hydrator API.
*/
export function createMessageNodeHydrator({ fetchNodeById, applyNodeFallback, logger = console }) {
if (typeof fetchNodeById !== 'function') {
throw new TypeError('fetchNodeById must be a function');
}
if (typeof applyNodeFallback !== 'function') {
throw new TypeError('applyNodeFallback must be a function');
}
/** @type {Map<string, Promise<object|null>>} */
const inflightLookups = new Map();
/**
* Normalise potential node identifiers into canonical strings.
*
* @param {*} value Raw node identifier value.
* @returns {string} Trimmed identifier or empty string when invalid.
*/
function normalizeNodeId(value) {
if (value == null) return '';
const source = typeof value === 'string' ? value : String(value);
const trimmed = source.trim();
return trimmed.length > 0 ? trimmed : '';
}
/**
* Resolve the node metadata for the provided identifier.
*
* @param {string} nodeId Canonical node identifier.
* @param {Map<string, object>} nodesById Existing node cache.
* @returns {Promise<object|null>} Resolved node or null when unavailable.
*/
async function resolveNode(nodeId, nodesById) {
const id = normalizeNodeId(nodeId);
if (!id) return null;
if (nodesById instanceof Map && nodesById.has(id)) {
return nodesById.get(id);
}
if (inflightLookups.has(id)) {
return inflightLookups.get(id);
}
const promise = Promise.resolve()
.then(() => fetchNodeById(id))
.then(node => {
if (node && typeof node === 'object') {
applyNodeFallback(node);
if (nodesById instanceof Map) {
nodesById.set(id, node);
}
return node;
}
return null;
})
.catch(error => {
if (logger && typeof logger.warn === 'function') {
logger.warn('message node lookup failed', { nodeId: id, error });
}
return null;
})
.finally(() => {
inflightLookups.delete(id);
});
inflightLookups.set(id, promise);
return promise;
}
/**
* Attach node information to the provided message collection.
*
* @param {Array<object>|null|undefined} messages Message payloads from the API.
* @param {Map<string, object>} nodesById Lookup table of known nodes.
* @returns {Promise<Array<object>>} Hydrated message entries.
*/
async function hydrate(messages, nodesById) {
if (!Array.isArray(messages) || messages.length === 0) {
return Array.isArray(messages) ? messages : [];
}
const tasks = [];
for (const message of messages) {
if (!message || typeof message !== 'object') {
continue;
}
const explicitId = normalizeNodeId(message.node_id ?? message.nodeId ?? '');
const fallbackId = normalizeNodeId(message.from_id ?? message.fromId ?? '');
const targetId = explicitId || fallbackId;
if (!targetId) {
message.node = null;
continue;
}
message.node_id = targetId;
const existing = nodesById instanceof Map ? nodesById.get(targetId) : null;
if (existing) {
message.node = existing;
continue;
}
const task = resolveNode(targetId, nodesById).then(node => {
if (node) {
message.node = node;
} else {
const placeholder = { node_id: targetId };
applyNodeFallback(placeholder);
message.node = placeholder;
}
});
tasks.push(task);
}
if (tasks.length > 0) {
await Promise.all(tasks);
}
return messages;
}
return { hydrate };
}

View File

@@ -574,6 +574,16 @@ button {
color: var(--fg);
}
.icon-button {
width: 36px;
height: 36px;
padding: 0;
display: inline-flex;
align-items: center;
justify-content: center;
line-height: 1;
}
button:hover {
background: #f6f6f6;
}

View File

@@ -20,6 +20,7 @@ require "json"
require "time"
require "base64"
require "uri"
require "socket"
RSpec.describe "Potato Mesh Sinatra app" do
let(:app) { Sinatra::Application }
@@ -248,7 +249,10 @@ RSpec.describe "Potato Mesh Sinatra app" do
end
it "stores and clears the initial federation thread" do
allow(app).to receive(:announce_instance_to_all_domains)
delay = 3
allow(PotatoMesh::Config).to receive(:initial_federation_delay_seconds).and_return(delay)
expect(Kernel).to receive(:sleep).with(delay)
expect(app).to receive(:announce_instance_to_all_domains)
allow(Thread).to receive(:new) do |&block|
dummy_thread.block = block
dummy_thread
@@ -1190,6 +1194,40 @@ RSpec.describe "Potato Mesh Sinatra app" do
end
end
it "rejects registrations with invalid domains" do
invalid_payload = instance_payload.merge("domain" => "mesh-instance")
warning_calls = []
allow_any_instance_of(Sinatra::Application).to receive(:warn_log).and_wrap_original do |method, *args, **kwargs|
warning_calls << [args, kwargs]
method.call(*args, **kwargs)
end
post "/api/instances", invalid_payload.to_json, { "CONTENT_TYPE" => "application/json" }
expect(last_response.status).to eq(400)
expect(JSON.parse(last_response.body)).to eq("error" => "invalid domain")
expect(warning_calls).to include(
[
["Instance registration rejected"],
hash_including(
context: "ingest.register",
domain: "mesh-instance",
reason: "invalid domain",
),
],
)
with_db(readonly: true) do |db|
stored = db.get_first_value(
"SELECT COUNT(*) FROM instances WHERE id = ?",
[instance_attributes[:id]],
)
expect(stored).to eq(0)
end
end
it "rejects registrations with invalid signatures" do
invalid_payload = instance_payload.merge("signature" => Base64.strict_encode64("invalid"))
@@ -1221,6 +1259,101 @@ RSpec.describe "Potato Mesh Sinatra app" do
end
end
it "rejects registrations when DNS resolves to restricted addresses" do
restricted_addrinfo = Addrinfo.ip("127.0.0.1")
allow(Addrinfo).to receive(:getaddrinfo).and_return([restricted_addrinfo])
warning_calls = []
allow_any_instance_of(Sinatra::Application).to receive(:warn_log).and_wrap_original do |method, *args, **kwargs|
warning_calls << [args, kwargs]
method.call(*args, **kwargs)
end
allow_any_instance_of(Sinatra::Application).to receive(:fetch_instance_json) do
raise "fetch_instance_json should not be called for restricted domains"
end
post "/api/instances", instance_payload.to_json, { "CONTENT_TYPE" => "application/json" }
expect(last_response.status).to eq(400)
expect(JSON.parse(last_response.body)).to eq("error" => "restricted domain")
expect(warning_calls).to include(
[
["Instance registration rejected"],
hash_including(
context: "ingest.register",
domain: domain,
reason: "restricted domain",
),
],
)
with_db(readonly: true) do |db|
stored = db.get_first_value(
"SELECT COUNT(*) FROM instances WHERE id = ?",
[instance_attributes[:id]],
)
expect(stored).to eq(0)
end
end
it "accepts bracketed IPv6 domains" do
ipv6_domain = "[2001:db8::1]"
ipv6_attributes = instance_attributes.merge(domain: ipv6_domain)
ipv6_signature_payload = canonical_instance_payload(ipv6_attributes)
ipv6_signature = Base64.strict_encode64(
instance_key.sign(OpenSSL::Digest::SHA256.new, ipv6_signature_payload),
)
ipv6_payload = instance_payload.merge(
"domain" => ipv6_domain,
"signature" => ipv6_signature,
)
ipv6_remote_payload = JSON.generate(
{
"publicKey" => pubkey,
"name" => instance_attributes[:name],
"version" => instance_attributes[:version],
"domain" => ipv6_domain,
"lastUpdate" => last_update_time,
},
sort_keys: true,
)
ipv6_document = well_known_document.merge(
"domain" => ipv6_domain,
"signedPayload" => Base64.strict_encode64(ipv6_remote_payload),
"signature" => Base64.strict_encode64(
instance_key.sign(OpenSSL::Digest::SHA256.new, ipv6_remote_payload),
),
)
allow_any_instance_of(Sinatra::Application).to receive(:fetch_instance_json) do |_instance, host, path|
case path
when "/.well-known/potato-mesh"
[ipv6_document, URI("https://#{host}#{path}")]
when "/api/nodes"
[remote_nodes, URI("https://#{host}#{path}")]
else
[nil, []]
end
end
post "/api/instances", ipv6_payload.to_json, { "CONTENT_TYPE" => "application/json" }
expect(last_response.status).to eq(201)
expect(JSON.parse(last_response.body)).to eq("status" => "registered")
with_db(readonly: true) do |db|
stored_domain = db.get_first_value(
"SELECT domain FROM instances WHERE id = ?",
[ipv6_attributes[:id]],
)
expect(stored_domain).to eq(ipv6_domain.downcase)
end
end
it "rejects registrations targeting restricted literal IPs when a port is supplied" do
restricted_domain = "127.0.0.1:8080"
restricted_attributes = instance_attributes.merge(domain: restricted_domain)
@@ -3082,7 +3215,7 @@ RSpec.describe "Potato Mesh Sinatra app" do
end
describe "GET /api/messages" do
it "returns the stored messages along with joined node data" do
it "returns the stored messages with canonical node references" do
import_nodes_fixture
import_messages_fixture
@@ -3096,86 +3229,59 @@ RSpec.describe "Potato Mesh Sinatra app" do
acc[row["id"]] = row
end
nodes_by_id = {}
node_aliases = {}
nodes_fixture.each do |node|
node_id = node["node_id"]
expected_row = expected_node_row(node)
nodes_by_id[node_id] = expected_row
if (num = node["num"])
node_aliases[num.to_s] = node_id
node_aliases[num.to_s] = node["node_id"]
end
end
messages_fixture.each do |message|
node = message["node"]
next unless node.is_a?(Hash)
canonical = node["node_id"]
num = node["num"]
next unless canonical && num
node_aliases[num.to_s] ||= canonical
end
latest_rx_by_node = {}
messages_fixture.each do |message|
rx_time = message["rx_time"]
next unless rx_time
canonical = nil
from_id = message["from_id"]
if from_id.is_a?(String)
trimmed = from_id.strip
unless trimmed.empty?
if trimmed.match?(/\A[0-9]+\z/)
canonical = node_aliases[trimmed] || trimmed
else
canonical = trimmed
end
end
end
canonical ||= message.dig("node", "node_id")
if canonical.nil?
num = message.dig("node", "num")
canonical = node_aliases[num.to_s] if num
end
next unless canonical
existing = latest_rx_by_node[canonical]
latest_rx_by_node[canonical] = [existing, rx_time].compact.max
end
messages_fixture.each do |message|
expected = message.reject { |key, _| key == "node" }
actual_row = actual_by_id.fetch(message["id"])
expect(actual_row["rx_time"]).to eq(expected["rx_time"])
expect(actual_row["rx_iso"]).to eq(expected["rx_iso"])
expected_from_id = expected["from_id"]
if expected_from_id.is_a?(String) && expected_from_id.match?(/\A[0-9]+\z/)
expected_from_id = node_aliases[expected_from_id] || expected_from_id
if expected_from_id.is_a?(String)
trimmed = expected_from_id.strip
if trimmed.match?(/\A[0-9]+\z/)
expected_from_id = node_aliases[trimmed] || message.dig("node", "node_id") || trimmed
else
expected_from_id = trimmed
end
elsif expected_from_id.nil?
expected_from_id = message.dig("node", "node_id")
end
expect(actual_row["from_id"]).to eq(expected_from_id)
expect(actual_row).not_to have_key("from_node_id")
expect(actual_row).not_to have_key("from_node_num")
expected_node_id = if expected_from_id.is_a?(String)
expected_from_id
else
node_id = message.dig("node", "node_id")
if node_id.nil?
num = message.dig("node", "num")
node_id = node_aliases[num.to_s] if num
end
node_id
end
if expected_node_id
expect(actual_row["node_id"]).to eq(expected_node_id)
else
expect(actual_row).not_to have_key("node_id")
end
expected_to_id = expected["to_id"]
if expected_to_id.is_a?(String) && expected_to_id.match?(/\A[0-9]+\z/)
expected_to_id = node_aliases[expected_to_id] || expected_to_id
if expected_to_id.is_a?(String)
trimmed_to = expected_to_id.strip
if trimmed_to.match?(/\A[0-9]+\z/)
expected_to_id = node_aliases[trimmed_to] || trimmed_to
else
expected_to_id = trimmed_to
end
end
expect(actual_row["to_id"]).to eq(expected_to_id)
expect(actual_row).not_to have_key("to_node_id")
expect(actual_row).not_to have_key("to_node_num")
expect(actual_row["channel"]).to eq(expected["channel"])
expect(actual_row["portnum"]).to eq(expected["portnum"])
expect(actual_row["text"]).to eq(expected["text"])
@@ -3186,46 +3292,11 @@ RSpec.describe "Potato Mesh Sinatra app" do
expect(actual_row["lora_freq"]).to eq(expected["lora_freq"])
expect(actual_row["modem_preset"]).to eq(expected["modem_preset"])
expect(actual_row["channel_name"]).to eq(expected["channel_name"])
if expected["from_id"]
lookup_id = expected["from_id"]
node_expected = nodes_by_id[lookup_id]
unless node_expected
canonical_id = node_aliases[lookup_id.to_s]
expect(canonical_id).not_to be_nil,
"node fixture missing for from_id #{lookup_id.inspect}"
node_expected = nodes_by_id.fetch(canonical_id)
end
node_actual = actual_row.fetch("node")
expect(node_actual["node_id"]).to eq(node_expected["node_id"])
expect(node_actual["short_name"]).to eq(node_expected["short_name"])
expect(node_actual["long_name"]).to eq(node_expected["long_name"])
expect(node_actual["role"]).to eq(node_expected["role"])
expect_same_value(node_actual["snr"], node_expected["snr"])
expect(node_actual["lora_freq"]).to eq(node_expected["lora_freq"])
expect(node_actual["modem_preset"]).to eq(node_expected["modem_preset"])
expect_same_value(node_actual["battery_level"], node_expected["battery_level"])
expect_same_value(node_actual["voltage"], node_expected["voltage"])
expected_last_heard = node_expected["last_heard"]
latest_rx = latest_rx_by_node[node_expected["node_id"]]
if latest_rx
expected_last_heard = [expected_last_heard, latest_rx].compact.max
end
expect(node_actual["last_heard"]).to eq(expected_last_heard)
expect(node_actual["first_heard"]).to eq(node_expected["first_heard"])
expect_same_value(node_actual["latitude"], node_expected["latitude"])
expect_same_value(node_actual["longitude"], node_expected["longitude"])
expect_same_value(node_actual["altitude"], node_expected["altitude"])
else
expect(actual_row["node"]).to be_a(Hash)
expect(actual_row["node"]["node_id"]).to be_nil
end
expect(actual_row["rx_time"]).to eq(expected["rx_time"])
expect(actual_row["rx_iso"]).to eq(expected["rx_iso"])
expect(actual_row).not_to have_key("node")
end
end
context "when DEBUG logging is enabled" do
it "logs diagnostics for messages missing a sender" do
allow(PotatoMesh::Config).to receive(:debug?).and_return(true)
@@ -3248,28 +3319,19 @@ RSpec.describe "Potato Mesh Sinatra app" do
expect(PotatoMesh::Logging).to have_received(:log).with(
kind_of(Logger),
:debug,
"Message join produced empty sender",
"Message query produced empty sender",
context: "queries.messages",
stage: "before_join",
stage: "raw_row",
row: a_hash_including("id" => message_id),
)
expect(PotatoMesh::Logging).to have_received(:log).with(
kind_of(Logger),
:debug,
"Message join produced empty sender",
"Message query produced empty sender",
context: "queries.messages",
stage: "after_join",
stage: "after_normalization",
row: a_hash_including("id" => message_id),
)
expect(PotatoMesh::Logging).to have_received(:log).with(
kind_of(Logger),
:debug,
"Message row missing sender after processing",
context: "queries.messages",
stage: "after_processing",
row: a_hash_including("id" => message_id),
)
messages = JSON.parse(last_response.body)
expect(messages.size).to eq(1)
expect(messages.first["from_id"]).to be_nil

View File

@@ -169,6 +169,54 @@ RSpec.describe PotatoMesh::Config do
end
end
describe ".federation_max_instances_per_response" do
it "returns the baked-in response limit when unset" do
within_env("FEDERATION_MAX_INSTANCES_PER_RESPONSE" => nil) do
expect(described_class.federation_max_instances_per_response).to eq(
PotatoMesh::Config::DEFAULT_FEDERATION_MAX_INSTANCES_PER_RESPONSE,
)
end
end
it "accepts positive overrides" do
within_env("FEDERATION_MAX_INSTANCES_PER_RESPONSE" => "7") do
expect(described_class.federation_max_instances_per_response).to eq(7)
end
end
it "rejects non-positive overrides" do
within_env("FEDERATION_MAX_INSTANCES_PER_RESPONSE" => "0") do
expect(described_class.federation_max_instances_per_response).to eq(
PotatoMesh::Config::DEFAULT_FEDERATION_MAX_INSTANCES_PER_RESPONSE,
)
end
end
end
describe ".federation_max_domains_per_crawl" do
it "returns the baked-in crawl limit when unset" do
within_env("FEDERATION_MAX_DOMAINS_PER_CRAWL" => nil) do
expect(described_class.federation_max_domains_per_crawl).to eq(
PotatoMesh::Config::DEFAULT_FEDERATION_MAX_DOMAINS_PER_CRAWL,
)
end
end
it "accepts positive overrides" do
within_env("FEDERATION_MAX_DOMAINS_PER_CRAWL" => "11") do
expect(described_class.federation_max_domains_per_crawl).to eq(11)
end
end
it "rejects invalid overrides" do
within_env("FEDERATION_MAX_DOMAINS_PER_CRAWL" => "-5") do
expect(described_class.federation_max_domains_per_crawl).to eq(
PotatoMesh::Config::DEFAULT_FEDERATION_MAX_DOMAINS_PER_CRAWL,
)
end
end
end
describe ".db_path" do
it "returns the default path inside the data directory" do
expect(described_class.db_path).to eq(described_class.default_db_path)

View File

@@ -15,7 +15,9 @@
require "spec_helper"
require "net/http"
require "openssl"
require "set"
require "uri"
require "socket"
RSpec.describe PotatoMesh::App::Federation do
subject(:federation_helpers) do
@@ -34,6 +36,18 @@ RSpec.describe PotatoMesh::App::Federation do
def reset_debug_messages
@debug_messages = []
end
def warn_messages
@warn_messages ||= []
end
def warn_log(message, **_metadata)
warn_messages << message
end
def reset_warn_messages
@warn_messages = []
end
end
end
end
@@ -42,6 +56,7 @@ RSpec.describe PotatoMesh::App::Federation do
federation_helpers.instance_variable_set(:@remote_instance_cert_store, nil)
federation_helpers.instance_variable_set(:@remote_instance_verify_callback, nil)
federation_helpers.reset_debug_messages
federation_helpers.reset_warn_messages
end
describe ".remote_instance_cert_store" do
@@ -115,10 +130,12 @@ RSpec.describe PotatoMesh::App::Federation do
describe ".build_remote_http_client" do
let(:connect_timeout) { 5 }
let(:read_timeout) { 12 }
let(:public_addrinfo) { Addrinfo.ip("203.0.113.5") }
before do
allow(PotatoMesh::Config).to receive(:remote_instance_http_timeout).and_return(connect_timeout)
allow(PotatoMesh::Config).to receive(:remote_instance_read_timeout).and_return(read_timeout)
allow(Addrinfo).to receive(:getaddrinfo).and_return([public_addrinfo])
end
it "configures SSL settings for HTTPS endpoints" do
@@ -162,6 +179,123 @@ RSpec.describe PotatoMesh::App::Federation do
expect(http.cert_store).to be_nil
expect(http.verify_callback).to be_nil
end
it "rejects URIs that resolve exclusively to restricted addresses" do
uri = URI.parse("https://loopback.mesh/api")
allow(Addrinfo).to receive(:getaddrinfo).and_return([Addrinfo.ip("127.0.0.1")])
expect do
federation_helpers.build_remote_http_client(uri)
end.to raise_error(ArgumentError, "restricted domain")
end
it "binds the HTTP client to the first unrestricted address" do
uri = URI.parse("https://remote.example.com/api")
allow(Addrinfo).to receive(:getaddrinfo).and_return([
Addrinfo.ip("127.0.0.1"),
public_addrinfo,
Addrinfo.ip("10.0.0.3"),
])
http = federation_helpers.build_remote_http_client(uri)
if http.respond_to?(:ipaddr)
expect(http.ipaddr).to eq("203.0.113.5")
else
skip "Net::HTTP#ipaddr accessor unavailable"
end
end
end
describe ".ingest_known_instances_from!" do
let(:db) { double(:db) }
let(:seed_domain) { "seed.mesh" }
let(:payload_entries) do
Array.new(3) do |index|
{
"id" => "remote-#{index}",
"domain" => "ally-#{index}.mesh",
"pubkey" => "ignored-pubkey-#{index}",
"signature" => "ignored-signature-#{index}",
}
end
end
let(:attributes_list) do
payload_entries.map do |entry|
{
id: entry["id"],
domain: entry["domain"],
pubkey: entry["pubkey"],
name: nil,
version: nil,
channel: nil,
frequency: nil,
latitude: nil,
longitude: nil,
last_update_time: nil,
is_private: false,
}
end
end
let(:node_payload) do
Array.new(PotatoMesh::Config.remote_instance_min_node_count) do |index|
{ "node_id" => "node-#{index}", "last_heard" => Time.now.to_i - index }
end
end
let(:response_map) do
mapping = { [seed_domain, "/api/instances"] => [payload_entries, :instances] }
attributes_list.each do |attributes|
mapping[[attributes[:domain], "/api/nodes"]] = [node_payload, :nodes]
mapping[[attributes[:domain], "/api/instances"]] = [[], :instances]
end
mapping
end
before do
allow(federation_helpers).to receive(:fetch_instance_json) do |host, path|
response_map.fetch([host, path]) { [nil, []] }
end
allow(federation_helpers).to receive(:verify_instance_signature).and_return(true)
allow(federation_helpers).to receive(:validate_remote_nodes).and_return([true, nil])
payload_entries.each_with_index do |entry, index|
allow(federation_helpers).to receive(:remote_instance_attributes_from_payload).with(entry).and_return([attributes_list[index], "signature-#{index}", nil])
end
end
it "stops processing once the per-response limit is exceeded" do
processed_domains = []
allow(federation_helpers).to receive(:upsert_instance_record) do |_db, attrs, _signature|
processed_domains << attrs[:domain]
end
allow(PotatoMesh::Config).to receive(:federation_max_instances_per_response).and_return(2)
allow(PotatoMesh::Config).to receive(:federation_max_domains_per_crawl).and_return(10)
visited = federation_helpers.ingest_known_instances_from!(db, seed_domain)
expect(processed_domains).to eq([
attributes_list[0][:domain],
attributes_list[1][:domain],
])
expect(visited).to include(seed_domain, attributes_list[0][:domain], attributes_list[1][:domain])
expect(visited).not_to include(attributes_list[2][:domain])
expect(federation_helpers.debug_messages).to include(a_string_including("response limit"))
end
it "halts recursion once the crawl limit would be exceeded" do
processed_domains = []
allow(federation_helpers).to receive(:upsert_instance_record) do |_db, attrs, _signature|
processed_domains << attrs[:domain]
end
allow(PotatoMesh::Config).to receive(:federation_max_instances_per_response).and_return(5)
allow(PotatoMesh::Config).to receive(:federation_max_domains_per_crawl).and_return(2)
visited = federation_helpers.ingest_known_instances_from!(db, seed_domain)
expect(processed_domains).to eq([attributes_list.first[:domain]])
expect(visited).to include(seed_domain, attributes_list.first[:domain])
expect(visited).not_to include(attributes_list[1][:domain], attributes_list[2][:domain])
expect(federation_helpers.debug_messages).to include(a_string_including("crawl limit"))
end
end
describe ".perform_instance_http_request" do
@@ -207,5 +341,63 @@ RSpec.describe PotatoMesh::App::Federation do
federation_helpers.send(:perform_instance_http_request, uri)
end.to raise_error(PotatoMesh::App::InstanceFetchError, "Net::ReadTimeout")
end
it "wraps restricted address resolution failures" do
allow(federation_helpers).to receive(:build_remote_http_client).and_call_original
allow(Addrinfo).to receive(:getaddrinfo).and_return([Addrinfo.ip("127.0.0.1")])
expect do
federation_helpers.send(:perform_instance_http_request, uri)
end.to raise_error(PotatoMesh::App::InstanceFetchError, "ArgumentError: restricted domain")
end
end
describe ".announce_instance_to_domain" do
let(:payload) { "{}" }
let(:https_uri) { URI.parse("https://remote.mesh/api/instances") }
let(:http_uri) { URI.parse("http://remote.mesh/api/instances") }
let(:http_connection) { instance_double("Net::HTTPConnection") }
let(:success_response) { Net::HTTPOK.new("1.1", "200", "OK") }
before do
allow(success_response).to receive(:code).and_return("200")
end
it "retries over HTTP when HTTPS connections are refused" do
https_client = instance_double(Net::HTTP)
http_client = instance_double(Net::HTTP)
allow(federation_helpers).to receive(:build_remote_http_client).with(https_uri).and_return(https_client)
allow(federation_helpers).to receive(:build_remote_http_client).with(http_uri).and_return(http_client)
allow(https_client).to receive(:start).and_raise(Errno::ECONNREFUSED.new("refused"))
allow(http_connection).to receive(:request).and_return(success_response)
allow(http_client).to receive(:start).and_yield(http_connection).and_return(success_response)
result = federation_helpers.announce_instance_to_domain("remote.mesh", payload)
expect(result).to be(true)
expect(federation_helpers.debug_messages).to include("HTTPS federation announcement failed, retrying with HTTP")
expect(federation_helpers.warn_messages).to be_empty
end
it "logs a warning when HTTPS refusal persists after HTTP fallback" do
https_client = instance_double(Net::HTTP)
http_client = instance_double(Net::HTTP)
allow(federation_helpers).to receive(:build_remote_http_client).with(https_uri).and_return(https_client)
allow(federation_helpers).to receive(:build_remote_http_client).with(http_uri).and_return(http_client)
allow(https_client).to receive(:start).and_raise(Errno::ECONNREFUSED.new("refused"))
allow(http_client).to receive(:start).and_raise(SocketError.new("dns failure"))
result = federation_helpers.announce_instance_to_domain("remote.mesh", payload)
expect(result).to be(false)
expect(federation_helpers.debug_messages).to include("HTTPS federation announcement failed, retrying with HTTP")
expect(
federation_helpers.warn_messages.count { |message| message.include?("Federation announcement raised exception") },
).to eq(2)
end
end
end

View File

@@ -64,4 +64,59 @@ RSpec.describe PotatoMesh::App::Identity do
allow(PotatoMesh::Config).to receive(:legacy_keyfile_candidates).and_call_original
end
end
describe ".refresh_well_known_document_if_stale" do
let(:storage_dir) { Dir.mktmpdir }
let(:well_known_path) do
File.join(storage_dir, File.basename(PotatoMesh::Config.well_known_relative_path))
end
before do
allow(PotatoMesh::Config).to receive(:well_known_storage_root).and_return(storage_dir)
allow(PotatoMesh::Config).to receive(:well_known_relative_path).and_return(".well-known/potato-mesh")
allow(PotatoMesh::Config).to receive(:well_known_refresh_interval).and_return(86_400)
allow(PotatoMesh::Sanitizer).to receive(:sanitized_site_name).and_return("Test Instance")
allow(PotatoMesh::Sanitizer).to receive(:sanitize_instance_domain).and_return("example.com")
end
after do
FileUtils.remove_entry(storage_dir)
allow(PotatoMesh::Config).to receive(:well_known_storage_root).and_call_original
allow(PotatoMesh::Config).to receive(:well_known_relative_path).and_call_original
allow(PotatoMesh::Config).to receive(:well_known_refresh_interval).and_call_original
allow(PotatoMesh::Sanitizer).to receive(:sanitized_site_name).and_call_original
allow(PotatoMesh::Sanitizer).to receive(:sanitize_instance_domain).and_call_original
end
it "writes a well-known document when none exists" do
PotatoMesh::Application.refresh_well_known_document_if_stale
expect(File.exist?(well_known_path)).to be(true)
document = JSON.parse(File.read(well_known_path))
expect(document.fetch("version")).to eq(PotatoMesh::Application::APP_VERSION)
expect(document.fetch("domain")).to eq("example.com")
end
it "rewrites the document when configuration values change" do
PotatoMesh::Application.refresh_well_known_document_if_stale
original_contents = File.binread(well_known_path)
stub_const("PotatoMesh::Application::APP_VERSION", "9.9.9-test")
PotatoMesh::Application.refresh_well_known_document_if_stale
rewritten_contents = File.binread(well_known_path)
expect(rewritten_contents).not_to eq(original_contents)
document = JSON.parse(rewritten_contents)
expect(document.fetch("version")).to eq("9.9.9-test")
end
it "does not rewrite when content is current and within the refresh interval" do
PotatoMesh::Application.refresh_well_known_document_if_stale
original_contents = File.binread(well_known_path)
PotatoMesh::Application.refresh_well_known_document_if_stale
expect(File.binread(well_known_path)).to eq(original_contents)
end
end
end

View File

@@ -30,17 +30,24 @@ RSpec.describe PotatoMesh::Sanitizer do
it "rejects invalid domains" do
expect(described_class.sanitize_instance_domain(nil)).to be_nil
expect(described_class.sanitize_instance_domain(" ")).to be_nil
expect(described_class.sanitize_instance_domain("example")).to be_nil
expect(described_class.sanitize_instance_domain("example.org/")).to be_nil
expect(described_class.sanitize_instance_domain("example .org")).to be_nil
expect(described_class.sanitize_instance_domain("mesh_instance.example")).to be_nil
expect(described_class.sanitize_instance_domain("example.org:70000")).to be_nil
expect(described_class.sanitize_instance_domain("[::1")).to be_nil
end
it "normalises valid domains" do
expect(described_class.sanitize_instance_domain(" Example.Org. ")).to eq("example.org")
expect(described_class.sanitize_instance_domain("[::1]")).to eq("[::1]")
expect(described_class.sanitize_instance_domain("Example.Org:443")).to eq("example.org:443")
expect(described_class.sanitize_instance_domain("[2001:DB8::1]")).to eq("[2001:db8::1]")
expect(described_class.sanitize_instance_domain("127.0.0.1:8080")).to eq("127.0.0.1:8080")
end
it "preserves case when requested" do
expect(described_class.sanitize_instance_domain("Mesh.Example", downcase: false)).to eq("Mesh.Example")
expect(described_class.sanitize_instance_domain("[2001:DB8::1]", downcase: false)).to eq("[2001:DB8::1]")
end
end

View File

@@ -66,12 +66,12 @@
crossorigin=""
></script>
</head>
<% body_classes = [] %>
<% body_classes << "dark" if initial_theme == "dark" %>
@@ -97,8 +97,8 @@
<input type="text" id="filterInput" placeholder="Filter nodes" />
<button type="button" id="filterClear" class="filter-clear" aria-label="Clear filter" hidden>&times;</button>
</div>
<button id="themeToggle" type="button" aria-label="Toggle dark mode">🌙</button>
<button id="infoBtn" type="button" aria-haspopup="dialog" aria-controls="infoOverlay" aria-label="Show site information"> Info</button>
<button id="themeToggle" class="icon-button" type="button" aria-label="Toggle dark mode"><span aria-hidden="true">🌙</span></button>
<button id="infoBtn" class="icon-button" type="button" aria-haspopup="dialog" aria-controls="infoOverlay" aria-label="Show site information"><span aria-hidden="true"></span></button>
</div>
</div>
@@ -106,7 +106,6 @@
<div class="info-dialog" tabindex="-1">
<button type="button" class="info-close" id="infoClose" aria-label="Close site information">×</button>
<h2 id="infoTitle" class="info-title">About <%= site_name %></h2>
<p class="info-intro">Quick facts about this PotatoMesh instance.</p>
<dl class="info-details">
<dt>Channel</dt>
<dd><%= channel %></dd>
@@ -116,8 +115,6 @@
<dd><%= format("%.5f, %.5f", map_center_lat, map_center_lon) %></dd>
<dt>Visible range</dt>
<dd>Nodes within roughly <%= max_distance_km %> km of the center are shown.</dd>
<dt>Auto-refresh</dt>
<dd>Updates every <%= refresh_interval_seconds %> seconds.</dd>
<% if contact_link && !contact_link.empty? %>
<dt>Chat</dt>
<% if contact_link_url %>
@@ -221,7 +218,7 @@
</div>
</footer>
<script>
const CHAT_ENABLED = <%= private_mode ? "false" : "true" %>;