mirror of
https://github.com/l5yth/potato-mesh.git
synced 2026-03-28 17:42:48 +01:00
Compare commits
13 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cc8fec6d05 | ||
|
|
01665b6e3a | ||
|
|
1898a99789 | ||
|
|
3eefda9205 | ||
|
|
a6ba9a8227 | ||
|
|
7055444c4b | ||
|
|
4bfc0e25cb | ||
|
|
81335cbf7b | ||
|
|
76b57c08c6 | ||
|
|
926b5591b0 | ||
|
|
957e597004 | ||
|
|
68cfbf139f | ||
|
|
b2f4fcaaa5 |
@@ -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
|
||||
|
||||
|
||||
16
CHANGELOG.md
16
CHANGELOG.md
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
48
README.md
48
README.md
@@ -1,10 +1,11 @@
|
||||
# 🥔 PotatoMesh
|
||||
|
||||
[](https://github.com/l5yth/potato-mesh/actions)
|
||||
[](https://github.com/l5yth/potato-mesh/releases)
|
||||
[](https://github.com/l5yth/potato-mesh/releases)
|
||||
[](https://codecov.io/gh/l5yth/potato-mesh)
|
||||
[](LICENSE)
|
||||
[](https://github.com/l5yth/potato-mesh/issues)
|
||||
[](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
|
||||
|
||||
|
||||
14
configure.sh
14
configure.sh
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
]
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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}"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
123
web/public/assets/js/app/__tests__/message-node-hydrator.test.js
Normal file
123
web/public/assets/js/app/__tests__/message-node-hydrator.test.js
Normal 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);
|
||||
});
|
||||
@@ -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();
|
||||
|
||||
150
web/public/assets/js/app/message-node-hydrator.js
Normal file
150
web/public/assets/js/app/message-node-hydrator.js
Normal 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 };
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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>×</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" %>;
|
||||
|
||||
Reference in New Issue
Block a user