mirror of
https://github.com/l5yth/potato-mesh.git
synced 2026-05-07 05:44:50 +02:00
Compare commits
16 Commits
v0.5.0
..
v0.5.2-rc0
| Author | SHA1 | Date | |
|---|---|---|---|
| 957e597004 | |||
| 68cfbf139f | |||
| b2f4fcaaa5 | |||
| dc2fa9d247 | |||
| a32125996c | |||
| 506a1ab5f6 | |||
| db7b67d859 | |||
| 49f08a7f75 | |||
| b2d35d3edf | |||
| a9d618cdbc | |||
| 6a65abd2e3 | |||
| a3aef8cadd | |||
| cff89a8c88 | |||
| 26c1366412 | |||
| 28f5b49f4d | |||
| a46da284e5 |
@@ -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
|
||||
|
||||
|
||||
+86
-3
@@ -1,12 +1,95 @@
|
||||
# CHANGELOG
|
||||
|
||||
## Unreleased
|
||||
## v0.5.1
|
||||
|
||||
* Preserve legacy configuration assets when migrating to XDG directories.
|
||||
* 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
|
||||
|
||||
* Add JavaScript configuration tests and coverage workflow
|
||||
* Ensure node overlays appear above fullscreen map by @l5yth in <https://github.com/l5yth/potato-mesh/pull/333>
|
||||
* Adjust node table columns responsively by @l5yth in <https://github.com/l5yth/potato-mesh/pull/332>
|
||||
* Add LoRa metadata fields to nodes and messages by @l5yth in <https://github.com/l5yth/potato-mesh/pull/331>
|
||||
* Add channel metadata capture for message tagging by @l5yth in <https://github.com/l5yth/potato-mesh/pull/329>
|
||||
* Capture radio metadata for ingestor payloads by @l5yth in <https://github.com/l5yth/potato-mesh/pull/327>
|
||||
* Fix FrozenError when filtering node query results by @l5yth in <https://github.com/l5yth/potato-mesh/pull/324>
|
||||
* Ensure frontend reports git-aware version strings by @l5yth in <https://github.com/l5yth/potato-mesh/pull/321>
|
||||
* Ensure web Docker image ships application sources by @l5yth in <https://github.com/l5yth/potato-mesh/pull/322>
|
||||
* Refine stacked short info overlays on the map by @l5yth in <https://github.com/l5yth/potato-mesh/pull/319>
|
||||
* Refine environment configuration defaults by @l5yth in <https://github.com/l5yth/potato-mesh/pull/318>
|
||||
* Fix legacy configuration migration to XDG directories by @l5yth in <https://github.com/l5yth/potato-mesh/pull/317>
|
||||
* Adopt XDG base directories for app data and config by @l5yth in <https://github.com/l5yth/potato-mesh/pull/316>
|
||||
* Refactor: streamline ingestor environment variables by @l5yth in <https://github.com/l5yth/potato-mesh/pull/314>
|
||||
* Adjust map auto-fit padding and default zoom by @l5yth in <https://github.com/l5yth/potato-mesh/pull/315>
|
||||
* Ensure APIs filter stale data and refresh node details from latest sources by @l5yth in <https://github.com/l5yth/potato-mesh/pull/312>
|
||||
* Improve offline tile fallback initialization by @l5yth in <https://github.com/l5yth/potato-mesh/pull/307>
|
||||
* Add fallback for offline tile rendering errors by @l5yth in <https://github.com/l5yth/potato-mesh/pull/306>
|
||||
* Fix map auto-fit handling and add controller by @l5yth in <https://github.com/l5yth/potato-mesh/pull/311>
|
||||
* Fix map initialization bounds and add coverage by @l5yth in <https://github.com/l5yth/potato-mesh/pull/305>
|
||||
* Increase coverage for configuration and sanitizer helpers by @l5yth in <https://github.com/l5yth/potato-mesh/pull/303>
|
||||
* Add comprehensive theme and background front-end tests by @l5yth in <https://github.com/l5yth/potato-mesh/pull/302>
|
||||
* Document sanitization and helper modules by @l5yth in <https://github.com/l5yth/potato-mesh/pull/301>
|
||||
* Add in-repo Meshtastic protobuf stubs for tests by @l5yth in <https://github.com/l5yth/potato-mesh/pull/300>
|
||||
* Handle CRL lookup failures during federation TLS by @l5yth in <https://github.com/l5yth/potato-mesh/pull/299>
|
||||
* Ensure JavaScript workflow runs frontend tests by @l5yth in <https://github.com/l5yth/potato-mesh/pull/298>
|
||||
* Unify structured logging across application and ingestor by @l5yth in <https://github.com/l5yth/potato-mesh/pull/296>
|
||||
* Add Apache license headers to missing sources by @l5yth in <https://github.com/l5yth/potato-mesh/pull/297>
|
||||
* Update workflows for ingestor, sinatra, and frontend by @l5yth in <https://github.com/l5yth/potato-mesh/pull/295>
|
||||
* Fix IPv6 instance domain canonicalization by @l5yth in <https://github.com/l5yth/potato-mesh/pull/294>
|
||||
* Handle federation HTTPS CRL verification failures by @l5yth in <https://github.com/l5yth/potato-mesh/pull/293>
|
||||
* Adjust federation announcement interval to eight hours by @l5yth in <https://github.com/l5yth/potato-mesh/pull/292>
|
||||
* Restore modular app functionality by @l5yth in <https://github.com/l5yth/potato-mesh/pull/291>
|
||||
* Refactor config and metadata helpers into PotatoMesh modules by @l5yth in <https://github.com/l5yth/potato-mesh/pull/290>
|
||||
* Update default site configuration defaults by @l5yth in <https://github.com/l5yth/potato-mesh/pull/288>
|
||||
* Add regression test for queue drain concurrency by @l5yth in <https://github.com/l5yth/potato-mesh/pull/287>
|
||||
* Ensure Docker config directories are created for non-root user by @l5yth in <https://github.com/l5yth/potato-mesh/pull/286>
|
||||
* Clarify numeric address requirement for network target parsing by @l5yth in <https://github.com/l5yth/potato-mesh/pull/285>
|
||||
* Ensure mesh ingestor queue resets active flag when idle by @l5yth in <https://github.com/l5yth/potato-mesh/pull/284>
|
||||
* Clarify BLE connection description in README by @l5yth in <https://github.com/l5yth/potato-mesh/pull/283>
|
||||
* Configure web container for production mode by @l5yth in <https://github.com/l5yth/potato-mesh/pull/282>
|
||||
* Normalize INSTANCE_DOMAIN configuration to require hostnames by @l5yth in <https://github.com/l5yth/potato-mesh/pull/280>
|
||||
* Avoid blocking startup on federation announcements by @l5yth in <https://github.com/l5yth/potato-mesh/pull/281>
|
||||
* Fix production Docker builds for web and ingestor images by @l5yth in <https://github.com/l5yth/potato-mesh/pull/279>
|
||||
* Improve instance domain detection logic by @l5yth in <https://github.com/l5yth/potato-mesh/pull/278>
|
||||
* Implement federation announcements and instances API by @l5yth in <https://github.com/l5yth/potato-mesh/pull/277>
|
||||
* Fix federation signature handling and IP guard by @l5yth in <https://github.com/l5yth/potato-mesh/pull/276>
|
||||
* Add persistent federation metadata endpoint by @l5yth in <https://github.com/l5yth/potato-mesh/pull/274>
|
||||
* Add configurable instance domain with reverse DNS fallback by @l5yth in <https://github.com/l5yth/potato-mesh/pull/272>
|
||||
* Document production deployment configuration by @l5yth in <https://github.com/l5yth/potato-mesh/pull/273>
|
||||
* Add targeted API endpoints and expose version metadata by @l5yth in <https://github.com/l5yth/potato-mesh/pull/271>
|
||||
* Prometheus metrics updates on startup and for position/telemetry by @nicjansma in <https://github.com/l5yth/potato-mesh/pull/270>
|
||||
* Add hourly reconnect handling for inactive mesh interface by @l5yth in <https://github.com/l5yth/potato-mesh/pull/267>
|
||||
* Dockerfile fixes by @nicjansma in <https://github.com/l5yth/potato-mesh/pull/268>
|
||||
* Added prometheus /metrics endpoint by @nicjansma in <https://github.com/l5yth/potato-mesh/pull/262>
|
||||
* Add fullscreen toggle to map view by @l5yth in <https://github.com/l5yth/potato-mesh/pull/263>
|
||||
* Relocate JS coverage export script into web directory by @l5yth in <https://github.com/l5yth/potato-mesh/pull/266>
|
||||
* V0.4.0 version string in web UI by @nicjansma in <https://github.com/l5yth/potato-mesh/pull/265>
|
||||
* Add energy saving cycle to ingestor daemon by @l5yth in <https://github.com/l5yth/potato-mesh/pull/256>
|
||||
* Chore: restore apache headers by @l5yth in <https://github.com/l5yth/potato-mesh/pull/260>
|
||||
* Docs: add matrix to readme by @l5yth in <https://github.com/l5yth/potato-mesh/pull/259>
|
||||
* Force dark theme default based on sanitized cookie by @l5yth in <https://github.com/l5yth/potato-mesh/pull/252>
|
||||
* Document mesh ingestor modules with PDoc-style docstrings by @l5yth in <https://github.com/l5yth/potato-mesh/pull/255>
|
||||
* Handle missing node IDs in Meshtastic nodeinfo packets by @l5yth in <https://github.com/l5yth/potato-mesh/pull/251>
|
||||
* Document Ruby helper methods with RDoc comments by @l5yth in <https://github.com/l5yth/potato-mesh/pull/254>
|
||||
* Add JSDoc documentation across client scripts by @l5yth in <https://github.com/l5yth/potato-mesh/pull/253>
|
||||
* Fix mesh ingestor telemetry and neighbor handling by @l5yth in <https://github.com/l5yth/potato-mesh/pull/249>
|
||||
* Refactor front-end assets into external modules by @l5yth in <https://github.com/l5yth/potato-mesh/pull/245>
|
||||
* Add tests for helper utilities and asset routes by @l5yth in <https://github.com/l5yth/potato-mesh/pull/243>
|
||||
* Docs: add ingestor inline docstrings by @l5yth in <https://github.com/l5yth/potato-mesh/pull/244>
|
||||
* Add comprehensive coverage tests for mesh ingestor by @l5yth in <https://github.com/l5yth/potato-mesh/pull/241>
|
||||
* Add inline documentation to config helpers and frontend scripts by @l5yth in <https://github.com/l5yth/potato-mesh/pull/240>
|
||||
* Update changelog by @l5yth in <https://github.com/l5yth/potato-mesh/pull/238>
|
||||
|
||||
## v0.4.0
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -50,9 +53,15 @@ Additional environment variables are optional:
|
||||
Use the `docker-compose.yml` file provided in the repository (or download the
|
||||
[raw file from GitHub](https://raw.githubusercontent.com/l5yth/potato-mesh/main/docker-compose.yml)).
|
||||
It already references the published GHCR images, defines persistent volumes for
|
||||
data and logs, and includes optional bridge-profile services for environments
|
||||
that require classic port mapping. Place this file in the same directory as
|
||||
your `.env` file so Compose can pick up both.
|
||||
data, configuration, and logs, and includes optional bridge-profile services for
|
||||
environments that require classic port mapping. Place this file in the same
|
||||
directory as your `.env` file so Compose can pick up both.
|
||||
|
||||
The dedicated configuration volume binds to `/app/.config/potato-mesh` inside
|
||||
the container. This path stores the instance private key and staged
|
||||
`/.well-known/potato-mesh` documents. Because the volume persists independently
|
||||
of container lifecycle events, generated credentials are not replaced on reboot
|
||||
or re-deploy.
|
||||
|
||||
## Start the stack
|
||||
|
||||
|
||||
@@ -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,20 +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`.
|
||||
|
||||
The web app can be configured with environment variables (defaults shown):
|
||||
|
||||
* `SITE_NAME` - title and header shown in the UI (default: "PotatoMesh Demo")
|
||||
@@ -90,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
|
||||
@@ -101,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:
|
||||
@@ -110,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`)
|
||||
@@ -162,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
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
+4
-4
@@ -26,7 +26,7 @@ RUN set -eux; \
|
||||
python -m pip install --no-cache-dir -r requirements.txt; \
|
||||
apk del .build-deps
|
||||
|
||||
COPY data/ .
|
||||
COPY data /app/data
|
||||
RUN addgroup -S potatomesh && \
|
||||
adduser -S potatomesh -G potatomesh && \
|
||||
adduser potatomesh dialout && \
|
||||
@@ -40,7 +40,7 @@ ENV CONNECTION=/dev/ttyACM0 \
|
||||
POTATOMESH_INSTANCE="" \
|
||||
API_TOKEN=""
|
||||
|
||||
CMD ["python", "mesh.py"]
|
||||
CMD ["python", "-m", "data.mesh"]
|
||||
|
||||
# Windows production image
|
||||
FROM python:${PYTHON_VERSION}-windowsservercore-ltsc2022 AS production-windows
|
||||
@@ -55,7 +55,7 @@ WORKDIR /app
|
||||
COPY data/requirements.txt ./
|
||||
RUN python -m pip install --no-cache-dir -r requirements.txt
|
||||
|
||||
COPY data/ .
|
||||
COPY data /app/data
|
||||
|
||||
USER ContainerUser
|
||||
|
||||
@@ -65,6 +65,6 @@ ENV CONNECTION=/dev/ttyACM0 \
|
||||
POTATOMESH_INSTANCE="" \
|
||||
API_TOKEN=""
|
||||
|
||||
CMD ["python", "mesh.py"]
|
||||
CMD ["python", "-m", "data.mesh"]
|
||||
|
||||
FROM production-${TARGETOS} AS production
|
||||
|
||||
@@ -6,6 +6,7 @@ services:
|
||||
volumes:
|
||||
- ./web:/app
|
||||
- ./data:/app/.local/share/potato-mesh
|
||||
- ./.config/potato-mesh:/app/.config/potato-mesh
|
||||
- /app/vendor/bundle
|
||||
|
||||
web-bridge:
|
||||
@@ -14,6 +15,7 @@ services:
|
||||
volumes:
|
||||
- ./web:/app
|
||||
- ./data:/app/.local/share/potato-mesh
|
||||
- ./.config/potato-mesh:/app/.config/potato-mesh
|
||||
- /app/vendor/bundle
|
||||
ports:
|
||||
- "41447:41447"
|
||||
@@ -25,7 +27,9 @@ services:
|
||||
volumes:
|
||||
- ./data:/app
|
||||
- ./data:/app/.local/share/potato-mesh
|
||||
- ./.config/potato-mesh:/app/.config/potato-mesh
|
||||
- /app/.local
|
||||
- /dev:/dev
|
||||
|
||||
ingestor-bridge:
|
||||
environment:
|
||||
@@ -33,4 +37,6 @@ services:
|
||||
volumes:
|
||||
- ./data:/app
|
||||
- ./data:/app/.local/share/potato-mesh
|
||||
- ./.config/potato-mesh:/app/.config/potato-mesh
|
||||
- /app/.local
|
||||
- /dev:/dev
|
||||
|
||||
+11
-2
@@ -10,10 +10,12 @@ 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:
|
||||
- potatomesh_data:/app/.local/share/potato-mesh
|
||||
- potatomesh_config:/app/.config/potato-mesh
|
||||
- potatomesh_logs:/app/logs
|
||||
restart: unless-stopped
|
||||
deploy:
|
||||
@@ -32,12 +34,17 @@ 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
|
||||
- potatomesh_config:/app/.config/potato-mesh
|
||||
- potatomesh_logs:/app/logs
|
||||
devices:
|
||||
- ${CONNECTION:-/dev/ttyACM0}:${CONNECTION:-/dev/ttyACM0}
|
||||
- /dev:/dev
|
||||
device_cgroup_rules:
|
||||
- 'c 166:* rwm' # ttyACM devices
|
||||
- 'c 188:* rwm' # ttyUSB devices
|
||||
- 'c 4:* rwm' # ttyS devices
|
||||
privileged: false
|
||||
restart: unless-stopped
|
||||
deploy:
|
||||
@@ -85,6 +92,8 @@ services:
|
||||
volumes:
|
||||
potatomesh_data:
|
||||
driver: local
|
||||
potatomesh_config:
|
||||
driver: local
|
||||
potatomesh_logs:
|
||||
driver: local
|
||||
|
||||
|
||||
@@ -48,6 +48,7 @@ require_relative "application/prometheus"
|
||||
require_relative "application/queries"
|
||||
require_relative "application/data_processing"
|
||||
require_relative "application/filesystem"
|
||||
require_relative "application/instances"
|
||||
require_relative "application/routes/api"
|
||||
require_relative "application/routes/ingest"
|
||||
require_relative "application/routes/root"
|
||||
@@ -59,6 +60,7 @@ module PotatoMesh
|
||||
extend App::Networking
|
||||
extend App::Identity
|
||||
extend App::Federation
|
||||
extend App::Instances
|
||||
extend App::Prometheus
|
||||
extend App::Queries
|
||||
extend App::DataProcessing
|
||||
@@ -69,6 +71,7 @@ module PotatoMesh
|
||||
include App::Networking
|
||||
include App::Identity
|
||||
include App::Federation
|
||||
include App::Instances
|
||||
include App::Prometheus
|
||||
include App::Queries
|
||||
include App::DataProcessing
|
||||
@@ -166,6 +169,7 @@ SELF_INSTANCE_ID = PotatoMesh::Application::SELF_INSTANCE_ID unless defined?(SEL
|
||||
PotatoMesh::App::Networking,
|
||||
PotatoMesh::App::Identity,
|
||||
PotatoMesh::App::Federation,
|
||||
PotatoMesh::App::Instances,
|
||||
PotatoMesh::App::Prometheus,
|
||||
PotatoMesh::App::Queries,
|
||||
PotatoMesh::App::DataProcessing,
|
||||
|
||||
@@ -22,6 +22,25 @@ module PotatoMesh
|
||||
raise "INSTANCE_DOMAIN could not be determined"
|
||||
end
|
||||
|
||||
# Determine whether the local instance should persist its own record.
|
||||
#
|
||||
# @param domain [String, nil] candidate domain for the running instance.
|
||||
# @return [Array(Boolean, String, nil)] tuple containing a decision flag and an optional reason.
|
||||
def self_instance_registration_decision(domain)
|
||||
source = app_constant(:INSTANCE_DOMAIN_SOURCE)
|
||||
return [false, "INSTANCE_DOMAIN source is #{source}"] unless source == :environment
|
||||
|
||||
sanitized = sanitize_instance_domain(domain)
|
||||
return [false, "INSTANCE_DOMAIN missing or invalid"] unless sanitized
|
||||
|
||||
ip = ip_from_domain(sanitized)
|
||||
if ip && restricted_ip_address?(ip)
|
||||
return [false, "INSTANCE_DOMAIN resolves to restricted IP"]
|
||||
end
|
||||
|
||||
[true, nil]
|
||||
end
|
||||
|
||||
def self_instance_attributes
|
||||
domain = self_instance_domain
|
||||
last_update = latest_node_update_timestamp || Time.now.to_i
|
||||
@@ -68,43 +87,68 @@ module PotatoMesh
|
||||
def ensure_self_instance_record!
|
||||
attributes = self_instance_attributes
|
||||
signature = sign_instance_attributes(attributes)
|
||||
db = open_database
|
||||
upsert_instance_record(db, attributes, signature)
|
||||
debug_log(
|
||||
"Registered self instance record",
|
||||
context: "federation.instances",
|
||||
domain: attributes[:domain],
|
||||
instance_id: attributes[:id],
|
||||
)
|
||||
db = nil
|
||||
allowed, reason = self_instance_registration_decision(attributes[:domain])
|
||||
if allowed
|
||||
db = open_database
|
||||
upsert_instance_record(db, attributes, signature)
|
||||
debug_log(
|
||||
"Registered self instance record",
|
||||
context: "federation.instances",
|
||||
domain: attributes[:domain],
|
||||
instance_id: attributes[:id],
|
||||
)
|
||||
else
|
||||
debug_log(
|
||||
"Skipped self instance registration",
|
||||
context: "federation.instances",
|
||||
domain: attributes[:domain],
|
||||
reason: reason,
|
||||
)
|
||||
end
|
||||
[attributes, signature]
|
||||
ensure
|
||||
db&.close
|
||||
end
|
||||
|
||||
def federation_target_domains(self_domain)
|
||||
domains = Set.new
|
||||
normalized_self = sanitize_instance_domain(self_domain)&.downcase
|
||||
ordered = []
|
||||
seen = Set.new
|
||||
|
||||
PotatoMesh::Config.federation_seed_domains.each do |seed|
|
||||
sanitized = sanitize_instance_domain(seed)
|
||||
domains << sanitized.downcase if sanitized
|
||||
sanitized = sanitize_instance_domain(seed)&.downcase
|
||||
next unless sanitized
|
||||
next if normalized_self && sanitized == normalized_self
|
||||
next if seen.include?(sanitized)
|
||||
|
||||
ordered << sanitized
|
||||
seen << sanitized
|
||||
end
|
||||
|
||||
db = open_database(readonly: true)
|
||||
db.results_as_hash = false
|
||||
rows = with_busy_retry { db.execute("SELECT domain FROM instances WHERE domain IS NOT NULL AND TRIM(domain) != ''") }
|
||||
rows = with_busy_retry {
|
||||
db.execute("SELECT domain FROM instances WHERE domain IS NOT NULL AND TRIM(domain) != ''")
|
||||
}
|
||||
rows.flatten.compact.each do |raw_domain|
|
||||
sanitized = sanitize_instance_domain(raw_domain)
|
||||
domains << sanitized.downcase if sanitized
|
||||
sanitized = sanitize_instance_domain(raw_domain)&.downcase
|
||||
next unless sanitized
|
||||
next if normalized_self && sanitized == normalized_self
|
||||
next if seen.include?(sanitized)
|
||||
|
||||
ordered << sanitized
|
||||
seen << sanitized
|
||||
end
|
||||
if self_domain
|
||||
domains.delete(self_domain.downcase)
|
||||
end
|
||||
domains.to_a
|
||||
ordered
|
||||
rescue SQLite3::Exception
|
||||
domains =
|
||||
PotatoMesh::Config.federation_seed_domains.map do |seed|
|
||||
sanitize_instance_domain(seed)&.downcase
|
||||
end.compact
|
||||
self_domain ? domains.reject { |domain| domain == self_domain.downcase } : domains
|
||||
fallback = PotatoMesh::Config.federation_seed_domains.filter_map do |seed|
|
||||
candidate = sanitize_instance_domain(seed)&.downcase
|
||||
next if normalized_self && candidate == normalized_self
|
||||
|
||||
candidate
|
||||
end
|
||||
fallback.uniq
|
||||
ensure
|
||||
db&.close
|
||||
end
|
||||
@@ -112,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)
|
||||
@@ -137,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
|
||||
|
||||
@@ -266,7 +347,30 @@ module PotatoMesh
|
||||
end
|
||||
end
|
||||
rescue StandardError => e
|
||||
raise InstanceFetchError, e.message
|
||||
raise_instance_fetch_error(e)
|
||||
end
|
||||
|
||||
# Build a human readable error message for a failed instance request.
|
||||
#
|
||||
# @param error [StandardError] failure raised while performing the request.
|
||||
# @return [String] description including the error class when necessary.
|
||||
def instance_fetch_error_message(error)
|
||||
message = error.message.to_s.strip
|
||||
class_name = error.class.name || error.class.to_s
|
||||
return class_name if message.empty?
|
||||
|
||||
message.include?(class_name) ? message : "#{class_name}: #{message}"
|
||||
end
|
||||
|
||||
# Raise an InstanceFetchError that preserves the original context.
|
||||
#
|
||||
# @param error [StandardError] failure raised while performing the request.
|
||||
# @return [void]
|
||||
def raise_instance_fetch_error(error)
|
||||
message = instance_fetch_error_message(error)
|
||||
wrapped = InstanceFetchError.new(message)
|
||||
wrapped.set_backtrace(error.backtrace)
|
||||
raise wrapped
|
||||
end
|
||||
|
||||
def fetch_instance_json(domain, path)
|
||||
@@ -284,6 +388,156 @@ module PotatoMesh
|
||||
[nil, errors]
|
||||
end
|
||||
|
||||
# Parse a remote federation instance payload into canonical attributes.
|
||||
#
|
||||
# @param payload [Hash] JSON object describing a remote instance.
|
||||
# @return [Array<(Hash, String), String>] tuple containing the attribute
|
||||
# hash and signature when valid or a failure reason when invalid.
|
||||
def remote_instance_attributes_from_payload(payload)
|
||||
unless payload.is_a?(Hash)
|
||||
return [nil, nil, "instance payload is not an object"]
|
||||
end
|
||||
|
||||
id = string_or_nil(payload["id"])
|
||||
return [nil, nil, "missing instance id"] unless id
|
||||
|
||||
domain = sanitize_instance_domain(payload["domain"])
|
||||
return [nil, nil, "missing instance domain"] unless domain
|
||||
|
||||
pubkey = sanitize_public_key_pem(payload["pubkey"])
|
||||
return [nil, nil, "missing instance public key"] unless pubkey
|
||||
|
||||
signature = string_or_nil(payload["signature"])
|
||||
return [nil, nil, "missing instance signature"] unless signature
|
||||
|
||||
private_value = if payload.key?("isPrivate")
|
||||
payload["isPrivate"]
|
||||
else
|
||||
payload["is_private"]
|
||||
end
|
||||
private_flag = coerce_boolean(private_value)
|
||||
if private_flag.nil?
|
||||
numeric_flag = coerce_integer(private_value)
|
||||
private_flag = !numeric_flag.to_i.zero? if numeric_flag
|
||||
end
|
||||
|
||||
attributes = {
|
||||
id: id,
|
||||
domain: domain,
|
||||
pubkey: pubkey,
|
||||
name: string_or_nil(payload["name"]),
|
||||
version: string_or_nil(payload["version"]),
|
||||
channel: string_or_nil(payload["channel"]),
|
||||
frequency: string_or_nil(payload["frequency"]),
|
||||
latitude: coerce_float(payload["latitude"]),
|
||||
longitude: coerce_float(payload["longitude"]),
|
||||
last_update_time: coerce_integer(payload["lastUpdateTime"]),
|
||||
is_private: private_flag,
|
||||
}
|
||||
|
||||
[attributes, signature, nil]
|
||||
rescue StandardError => e
|
||||
[nil, nil, e.message]
|
||||
end
|
||||
|
||||
# Recursively ingest federation records exposed by the supplied domain.
|
||||
#
|
||||
# @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.
|
||||
# @return [Set<String>] updated set of visited domains.
|
||||
def ingest_known_instances_from!(db, domain, visited: nil)
|
||||
sanitized = sanitize_instance_domain(domain)
|
||||
return visited || Set.new unless sanitized
|
||||
|
||||
visited ||= Set.new
|
||||
return visited if visited.include?(sanitized)
|
||||
|
||||
visited << sanitized
|
||||
|
||||
payload, metadata = fetch_instance_json(sanitized, "/api/instances")
|
||||
unless payload.is_a?(Array)
|
||||
warn_log(
|
||||
"Failed to load remote federation instances",
|
||||
context: "federation.instances",
|
||||
domain: sanitized,
|
||||
reason: Array(metadata).map(&:to_s).join("; "),
|
||||
)
|
||||
return visited
|
||||
end
|
||||
|
||||
payload.each do |entry|
|
||||
attributes, signature, reason = remote_instance_attributes_from_payload(entry)
|
||||
unless attributes && signature
|
||||
warn_log(
|
||||
"Discarded remote instance entry",
|
||||
context: "federation.instances",
|
||||
domain: sanitized,
|
||||
reason: reason || "invalid payload",
|
||||
)
|
||||
next
|
||||
end
|
||||
|
||||
if attributes[:is_private]
|
||||
debug_log(
|
||||
"Skipped private remote instance",
|
||||
context: "federation.instances",
|
||||
domain: attributes[:domain],
|
||||
)
|
||||
next
|
||||
end
|
||||
|
||||
unless verify_instance_signature(attributes, signature, attributes[:pubkey])
|
||||
warn_log(
|
||||
"Discarded remote instance entry",
|
||||
context: "federation.instances",
|
||||
domain: attributes[:domain],
|
||||
reason: "invalid signature",
|
||||
)
|
||||
next
|
||||
end
|
||||
|
||||
attributes[:is_private] = false if attributes[:is_private].nil?
|
||||
|
||||
remote_nodes, node_metadata = fetch_instance_json(attributes[:domain], "/api/nodes")
|
||||
unless remote_nodes
|
||||
warn_log(
|
||||
"Failed to load remote node data",
|
||||
context: "federation.instances",
|
||||
domain: attributes[:domain],
|
||||
reason: Array(node_metadata).map(&:to_s).join("; "),
|
||||
)
|
||||
next
|
||||
end
|
||||
|
||||
fresh, freshness_reason = validate_remote_nodes(remote_nodes)
|
||||
unless fresh
|
||||
warn_log(
|
||||
"Discarded remote instance entry",
|
||||
context: "federation.instances",
|
||||
domain: attributes[:domain],
|
||||
reason: freshness_reason || "stale node data",
|
||||
)
|
||||
next
|
||||
end
|
||||
|
||||
begin
|
||||
upsert_instance_record(db, attributes, signature)
|
||||
ingest_known_instances_from!(db, attributes[:domain], visited: visited)
|
||||
rescue ArgumentError => e
|
||||
warn_log(
|
||||
"Failed to persist remote instance",
|
||||
context: "federation.instances",
|
||||
domain: attributes[:domain],
|
||||
error_class: e.class.name,
|
||||
error_message: e.message,
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
visited
|
||||
end
|
||||
|
||||
# Build an HTTP client configured for communication with a remote instance.
|
||||
#
|
||||
# @param uri [URI::Generic] target URI describing the remote endpoint.
|
||||
@@ -291,7 +545,7 @@ module PotatoMesh
|
||||
def build_remote_http_client(uri)
|
||||
http = Net::HTTP.new(uri.host, uri.port)
|
||||
http.open_timeout = PotatoMesh::Config.remote_instance_http_timeout
|
||||
http.read_timeout = PotatoMesh::Config.remote_instance_http_timeout
|
||||
http.read_timeout = PotatoMesh::Config.remote_instance_read_timeout
|
||||
http.use_ssl = uri.scheme == "https"
|
||||
return http unless http.use_ssl?
|
||||
|
||||
@@ -435,14 +689,13 @@ module PotatoMesh
|
||||
latest = nodes.filter_map do |node|
|
||||
next unless node.is_a?(Hash)
|
||||
|
||||
timestamps = []
|
||||
timestamps << coerce_integer(node["last_heard"])
|
||||
timestamps << coerce_integer(node["position_time"])
|
||||
timestamps << coerce_integer(node["first_heard"])
|
||||
timestamps.compact.max
|
||||
last_heard_values = []
|
||||
last_heard_values << coerce_integer(node["last_heard"])
|
||||
last_heard_values << coerce_integer(node["lastHeard"])
|
||||
last_heard_values.compact.max
|
||||
end.compact.max
|
||||
|
||||
return [false, "missing recent node updates"] unless latest
|
||||
return [false, "missing last_heard data"] unless latest
|
||||
|
||||
cutoff = Time.now.to_i - PotatoMesh::Config.remote_instance_max_node_age
|
||||
return [false, "node data is stale"] if latest < cutoff
|
||||
@@ -451,6 +704,34 @@ module PotatoMesh
|
||||
end
|
||||
|
||||
def upsert_instance_record(db, attributes, signature)
|
||||
sanitized_domain = sanitize_instance_domain(attributes[:domain])
|
||||
raise ArgumentError, "invalid domain" unless sanitized_domain
|
||||
|
||||
ip = ip_from_domain(sanitized_domain)
|
||||
if ip && restricted_ip_address?(ip)
|
||||
raise ArgumentError, "restricted domain"
|
||||
end
|
||||
|
||||
normalized_domain = sanitized_domain
|
||||
existing_id = with_busy_retry do
|
||||
db.get_first_value(
|
||||
"SELECT id FROM instances WHERE domain = ?",
|
||||
normalized_domain,
|
||||
)
|
||||
end
|
||||
if existing_id && existing_id != attributes[:id]
|
||||
with_busy_retry do
|
||||
db.execute("DELETE FROM instances WHERE id = ?", existing_id)
|
||||
end
|
||||
debug_log(
|
||||
"Removed conflicting instance by domain",
|
||||
context: "federation.instances",
|
||||
domain: normalized_domain,
|
||||
replaced_id: existing_id,
|
||||
incoming_id: attributes[:id],
|
||||
)
|
||||
end
|
||||
|
||||
sql = <<~SQL
|
||||
INSERT INTO instances (
|
||||
id, domain, pubkey, name, version, channel, frequency,
|
||||
@@ -472,7 +753,7 @@ module PotatoMesh
|
||||
|
||||
params = [
|
||||
attributes[:id],
|
||||
attributes[:domain],
|
||||
normalized_domain,
|
||||
attributes[:pubkey],
|
||||
attributes[:name],
|
||||
attributes[:version],
|
||||
|
||||
@@ -53,9 +53,10 @@ module PotatoMesh
|
||||
# Proxy for {PotatoMesh::Sanitizer.sanitize_instance_domain}.
|
||||
#
|
||||
# @param value [Object] candidate domain string.
|
||||
# @param downcase [Boolean] whether to force lowercase normalisation.
|
||||
# @return [String, nil] canonical domain or nil.
|
||||
def sanitize_instance_domain(value)
|
||||
PotatoMesh::Sanitizer.sanitize_instance_domain(value)
|
||||
def sanitize_instance_domain(value, downcase: true)
|
||||
PotatoMesh::Sanitizer.sanitize_instance_domain(value, downcase: downcase)
|
||||
end
|
||||
|
||||
# Proxy for {PotatoMesh::Sanitizer.instance_domain_host}.
|
||||
|
||||
@@ -170,11 +170,13 @@ module PotatoMesh
|
||||
# @return [Array(String, String)] pair of JSON output and base64 signature.
|
||||
def build_well_known_document
|
||||
last_update = latest_node_update_timestamp
|
||||
domain_value = sanitize_instance_domain(app_constant(:INSTANCE_DOMAIN))
|
||||
|
||||
payload = {
|
||||
publicKey: app_constant(:INSTANCE_PUBLIC_KEY_PEM),
|
||||
name: sanitized_site_name,
|
||||
version: app_constant(:APP_VERSION),
|
||||
domain: app_constant(:INSTANCE_DOMAIN),
|
||||
domain: domain_value,
|
||||
lastUpdate: last_update,
|
||||
}
|
||||
|
||||
@@ -236,9 +238,7 @@ module PotatoMesh
|
||||
return nil unless File.exist?(PotatoMesh::Config.db_path)
|
||||
|
||||
db = open_database(readonly: true)
|
||||
value = db.get_first_value(
|
||||
"SELECT MAX(COALESCE(last_heard, first_heard, position_time)) FROM nodes",
|
||||
)
|
||||
value = db.get_first_value("SELECT MAX(last_heard) FROM nodes")
|
||||
value&.to_i
|
||||
rescue SQLite3::Exception
|
||||
nil
|
||||
|
||||
@@ -0,0 +1,199 @@
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
|
||||
# frozen_string_literal: true
|
||||
|
||||
module PotatoMesh
|
||||
module App
|
||||
# Helper methods for maintaining and presenting instance records.
|
||||
module Instances
|
||||
# Remove duplicate instance records grouped by their canonical domain name
|
||||
# while favouring the most recent entry.
|
||||
#
|
||||
# @return [void]
|
||||
def clean_duplicate_instances!
|
||||
db = open_database
|
||||
rows = with_busy_retry do
|
||||
db.execute(
|
||||
<<~SQL
|
||||
SELECT rowid, domain, last_update_time
|
||||
FROM instances
|
||||
WHERE domain IS NOT NULL AND TRIM(domain) != ''
|
||||
SQL
|
||||
)
|
||||
end
|
||||
|
||||
grouped = rows.group_by do |row|
|
||||
sanitize_instance_domain(row[1])&.downcase
|
||||
rescue StandardError
|
||||
nil
|
||||
end
|
||||
|
||||
deletions = []
|
||||
updates = {}
|
||||
|
||||
grouped.each do |canonical_domain, entries|
|
||||
next if canonical_domain.nil?
|
||||
next if entries.size <= 1
|
||||
|
||||
sorted_entries = entries.sort_by do |entry|
|
||||
timestamp = coerce_integer(entry[2]) || -1
|
||||
[timestamp, entry[0].to_i]
|
||||
end
|
||||
keeper = sorted_entries.last
|
||||
next unless keeper
|
||||
|
||||
deletions.concat(sorted_entries[0...-1].map { |entry| entry[0].to_i })
|
||||
|
||||
current_domain = entries.find { |entry| entry[0] == keeper[0] }&.[](1)
|
||||
if canonical_domain && current_domain != canonical_domain
|
||||
updates[keeper[0].to_i] = canonical_domain
|
||||
end
|
||||
|
||||
removed_count = sorted_entries.length - 1
|
||||
warn_log(
|
||||
"Removed duplicate instance records",
|
||||
context: "instances.cleanup",
|
||||
domain: canonical_domain,
|
||||
removed: removed_count,
|
||||
) if removed_count.positive?
|
||||
end
|
||||
|
||||
unless deletions.empty?
|
||||
placeholders = Array.new(deletions.size, "?").join(",")
|
||||
with_busy_retry do
|
||||
db.execute("DELETE FROM instances WHERE rowid IN (#{placeholders})", deletions)
|
||||
end
|
||||
end
|
||||
|
||||
updates.each do |rowid, canonical_domain|
|
||||
with_busy_retry do
|
||||
db.execute("UPDATE instances SET domain = ? WHERE rowid = ?", [canonical_domain, rowid])
|
||||
end
|
||||
end
|
||||
rescue SQLite3::Exception => e
|
||||
warn_log(
|
||||
"Failed to clean duplicate instances",
|
||||
context: "instances.cleanup",
|
||||
error_class: e.class.name,
|
||||
error_message: e.message,
|
||||
)
|
||||
ensure
|
||||
db&.close
|
||||
end
|
||||
|
||||
# Normalise and validate an instance database row for API presentation.
|
||||
#
|
||||
# @param row [Hash] raw database row with string keys.
|
||||
# @return [Hash, nil] cleaned hash or +nil+ when the row is discarded.
|
||||
def normalize_instance_row(row)
|
||||
unless row.is_a?(Hash)
|
||||
warn_log(
|
||||
"Discarded malformed instance row",
|
||||
context: "instances.normalize",
|
||||
reason: "row not hash",
|
||||
)
|
||||
return nil
|
||||
end
|
||||
|
||||
id = string_or_nil(row["id"])
|
||||
domain = sanitize_instance_domain(row["domain"])&.downcase
|
||||
pubkey = sanitize_public_key_pem(row["pubkey"])
|
||||
signature = string_or_nil(row["signature"])
|
||||
last_update_time = coerce_integer(row["last_update_time"])
|
||||
is_private_raw = row["is_private"]
|
||||
private_flag = coerce_boolean(is_private_raw)
|
||||
if private_flag.nil?
|
||||
numeric_private = coerce_integer(is_private_raw)
|
||||
private_flag = !numeric_private.to_i.zero? if numeric_private
|
||||
end
|
||||
private_flag = false if private_flag.nil?
|
||||
|
||||
if id.nil? || domain.nil? || pubkey.nil?
|
||||
warn_log(
|
||||
"Discarded malformed instance row",
|
||||
context: "instances.normalize",
|
||||
instance_id: row["id"],
|
||||
domain: row["domain"],
|
||||
reason: "missing required fields",
|
||||
)
|
||||
return nil
|
||||
end
|
||||
|
||||
payload = {
|
||||
"id" => id,
|
||||
"domain" => domain,
|
||||
"pubkey" => pubkey,
|
||||
"name" => string_or_nil(row["name"]),
|
||||
"version" => string_or_nil(row["version"]),
|
||||
"channel" => string_or_nil(row["channel"]),
|
||||
"frequency" => string_or_nil(row["frequency"]),
|
||||
"latitude" => coerce_float(row["latitude"]),
|
||||
"longitude" => coerce_float(row["longitude"]),
|
||||
"lastUpdateTime" => last_update_time,
|
||||
"isPrivate" => private_flag,
|
||||
"signature" => signature,
|
||||
}
|
||||
|
||||
payload.reject { |_, value| value.nil? }
|
||||
rescue StandardError => e
|
||||
warn_log(
|
||||
"Failed to normalise instance row",
|
||||
context: "instances.normalize",
|
||||
instance_id: row.respond_to?(:[]) ? row["id"] : nil,
|
||||
domain: row.respond_to?(:[]) ? row["domain"] : nil,
|
||||
error_class: e.class.name,
|
||||
error_message: e.message,
|
||||
)
|
||||
nil
|
||||
end
|
||||
|
||||
# Fetch all instance rows ready to be served by the API while handling
|
||||
# malformed rows gracefully.
|
||||
#
|
||||
# @return [Array<Hash>] list of cleaned instance payloads.
|
||||
def load_instances_for_api
|
||||
clean_duplicate_instances!
|
||||
|
||||
db = open_database(readonly: true)
|
||||
db.results_as_hash = true
|
||||
rows = with_busy_retry do
|
||||
db.execute(
|
||||
<<~SQL
|
||||
SELECT id, domain, pubkey, name, version, channel, frequency,
|
||||
latitude, longitude, last_update_time, is_private, signature
|
||||
FROM instances
|
||||
WHERE domain IS NOT NULL AND TRIM(domain) != ''
|
||||
AND pubkey IS NOT NULL AND TRIM(pubkey) != ''
|
||||
ORDER BY LOWER(domain)
|
||||
SQL
|
||||
)
|
||||
end
|
||||
|
||||
rows.each_with_object([]) do |row, memo|
|
||||
normalized = normalize_instance_row(row)
|
||||
memo << normalized if normalized
|
||||
end
|
||||
rescue SQLite3::Exception => e
|
||||
warn_log(
|
||||
"Failed to load instance records",
|
||||
context: "instances.load",
|
||||
error_class: e.class.name,
|
||||
error_message: e.message,
|
||||
)
|
||||
[]
|
||||
ensure
|
||||
db&.close
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -127,39 +127,8 @@ module PotatoMesh
|
||||
app.get "/api/instances" do
|
||||
content_type :json
|
||||
ensure_self_instance_record!
|
||||
db = open_database(readonly: true)
|
||||
db.results_as_hash = true
|
||||
rows = with_busy_retry do
|
||||
db.execute(
|
||||
<<~SQL,
|
||||
SELECT id, domain, pubkey, name, version, channel, frequency,
|
||||
latitude, longitude, last_update_time, is_private, signature
|
||||
FROM instances
|
||||
WHERE domain IS NOT NULL AND TRIM(domain) != ''
|
||||
AND pubkey IS NOT NULL AND TRIM(pubkey) != ''
|
||||
ORDER BY LOWER(domain)
|
||||
SQL
|
||||
)
|
||||
end
|
||||
payload = rows.map do |row|
|
||||
{
|
||||
"id" => row["id"],
|
||||
"domain" => row["domain"],
|
||||
"pubkey" => row["pubkey"],
|
||||
"name" => row["name"],
|
||||
"version" => row["version"],
|
||||
"channel" => row["channel"],
|
||||
"frequency" => row["frequency"],
|
||||
"latitude" => row["latitude"],
|
||||
"longitude" => row["longitude"],
|
||||
"lastUpdateTime" => row["last_update_time"]&.to_i,
|
||||
"isPrivate" => row["is_private"].to_i == 1,
|
||||
"signature" => row["signature"],
|
||||
}.reject { |_, value| value.nil? }
|
||||
end
|
||||
payload = load_instances_for_api
|
||||
JSON.generate(payload)
|
||||
ensure
|
||||
db&.close
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -84,7 +84,10 @@ module PotatoMesh
|
||||
end
|
||||
|
||||
id = string_or_nil(payload["id"]) || string_or_nil(payload["instanceId"])
|
||||
domain = sanitize_instance_domain(payload["domain"])
|
||||
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)
|
||||
pubkey = sanitize_public_key_pem(payload["pubkey"])
|
||||
name = string_or_nil(payload["name"])
|
||||
version = string_or_nil(payload["version"])
|
||||
@@ -99,7 +102,7 @@ module PotatoMesh
|
||||
|
||||
attributes = {
|
||||
id: id,
|
||||
domain: domain,
|
||||
domain: normalized_domain,
|
||||
pubkey: pubkey,
|
||||
name: name,
|
||||
version: version,
|
||||
@@ -120,11 +123,21 @@ module PotatoMesh
|
||||
halt 400, { error: "missing required fields" }.to_json
|
||||
end
|
||||
|
||||
unless verify_instance_signature(attributes, signature, attributes[:pubkey])
|
||||
signature_valid = verify_instance_signature(attributes, signature, attributes[:pubkey])
|
||||
# Some remote peers sign payloads using a canonicalised lowercase
|
||||
# domain while still sending a mixed-case domain. Retry signature
|
||||
# verification with the original casing when the first attempt
|
||||
# fails to maximise interoperability.
|
||||
if !signature_valid && raw_domain && normalized_domain && raw_domain.casecmp?(normalized_domain) && raw_domain != normalized_domain
|
||||
alternate_attributes = attributes.merge(domain: raw_domain)
|
||||
signature_valid = verify_instance_signature(alternate_attributes, signature, attributes[:pubkey])
|
||||
end
|
||||
|
||||
unless signature_valid
|
||||
warn_log(
|
||||
"Instance registration rejected",
|
||||
context: "ingest.register",
|
||||
domain: attributes[:domain],
|
||||
domain: raw_domain || attributes[:domain],
|
||||
reason: "invalid signature",
|
||||
)
|
||||
halt 400, { error: "invalid signature" }.to_json
|
||||
@@ -204,6 +217,7 @@ module PotatoMesh
|
||||
|
||||
db = open_database
|
||||
upsert_instance_record(db, attributes, signature)
|
||||
ingest_known_instances_from!(db, attributes[:domain])
|
||||
debug_log(
|
||||
"Registered remote instance",
|
||||
context: "ingest.register",
|
||||
|
||||
@@ -32,6 +32,8 @@ module PotatoMesh
|
||||
DEFAULT_FREQUENCY = "915MHz"
|
||||
DEFAULT_CONTACT_LINK = "#potatomesh:dod.ngo"
|
||||
DEFAULT_MAX_DISTANCE_KM = 42.0
|
||||
DEFAULT_REMOTE_INSTANCE_CONNECT_TIMEOUT = 5
|
||||
DEFAULT_REMOTE_INSTANCE_READ_TIMEOUT = 12
|
||||
|
||||
# Resolve the absolute path to the web application root directory.
|
||||
#
|
||||
@@ -129,7 +131,7 @@ module PotatoMesh
|
||||
#
|
||||
# @return [String] semantic version identifier.
|
||||
def version_fallback
|
||||
"v0.5.0"
|
||||
"v0.5.2"
|
||||
end
|
||||
|
||||
# Default refresh interval for frontend polling routines.
|
||||
@@ -269,11 +271,18 @@ module PotatoMesh
|
||||
"rsa-sha256"
|
||||
end
|
||||
|
||||
# Timeout used when querying remote instances during federation.
|
||||
# Connection timeout used when establishing federation HTTP sockets.
|
||||
#
|
||||
# @return [Integer] HTTP timeout in seconds.
|
||||
# @return [Integer] connect timeout in seconds.
|
||||
def remote_instance_http_timeout
|
||||
5
|
||||
DEFAULT_REMOTE_INSTANCE_CONNECT_TIMEOUT
|
||||
end
|
||||
|
||||
# Read timeout used when streaming federation HTTP responses.
|
||||
#
|
||||
# @return [Integer] read timeout in seconds.
|
||||
def remote_instance_read_timeout
|
||||
DEFAULT_REMOTE_INSTANCE_READ_TIMEOUT
|
||||
end
|
||||
|
||||
# Maximum acceptable age for remote node data.
|
||||
|
||||
@@ -41,8 +41,9 @@ module PotatoMesh
|
||||
# rules. This rejects whitespace, path separators, and trailing dots.
|
||||
#
|
||||
# @param value [String, Object, nil] candidate domain name.
|
||||
# @param downcase [Boolean] whether to force the result to lowercase.
|
||||
# @return [String, nil] canonical domain value or +nil+ when invalid.
|
||||
def sanitize_instance_domain(value)
|
||||
def sanitize_instance_domain(value, downcase: true)
|
||||
host = string_or_nil(value)
|
||||
return nil unless host
|
||||
|
||||
@@ -51,7 +52,7 @@ module PotatoMesh
|
||||
return nil if trimmed.empty?
|
||||
return nil if trimmed.match?(%r{[\s/\\@]})
|
||||
|
||||
trimmed
|
||||
downcase ? trimmed.downcase : trimmed
|
||||
end
|
||||
|
||||
# Extract the host component from a potentially bracketed domain literal.
|
||||
|
||||
@@ -0,0 +1,126 @@
|
||||
/*
|
||||
* 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 {
|
||||
extractChatMessageMetadata,
|
||||
formatChatMessagePrefix,
|
||||
formatChatChannelTag,
|
||||
formatNodeAnnouncementPrefix,
|
||||
__test__
|
||||
} from '../chat-format.js';
|
||||
|
||||
const {
|
||||
firstNonNull,
|
||||
normalizeString,
|
||||
normalizeFrequency,
|
||||
normalizeFrequencySlot,
|
||||
FREQUENCY_PLACEHOLDER
|
||||
} = __test__;
|
||||
|
||||
test('extractChatMessageMetadata prefers explicit region_frequency and channel_name', () => {
|
||||
const payload = {
|
||||
region_frequency: 868,
|
||||
channel_name: ' Test Channel ',
|
||||
lora_freq: 915,
|
||||
channelName: 'Ignored'
|
||||
};
|
||||
const result = extractChatMessageMetadata(payload);
|
||||
assert.deepEqual(result, { frequency: '868', channelName: 'Test Channel' });
|
||||
});
|
||||
|
||||
test('extractChatMessageMetadata falls back to LoRa metadata', () => {
|
||||
const payload = {
|
||||
lora_freq: 915,
|
||||
channelName: 'SpecChannel'
|
||||
};
|
||||
const result = extractChatMessageMetadata(payload);
|
||||
assert.deepEqual(result, { frequency: '915', channelName: 'SpecChannel' });
|
||||
});
|
||||
|
||||
test('extractChatMessageMetadata returns null metadata for invalid input', () => {
|
||||
assert.deepEqual(extractChatMessageMetadata(null), { frequency: null, channelName: null });
|
||||
assert.deepEqual(extractChatMessageMetadata(undefined), { frequency: null, channelName: null });
|
||||
});
|
||||
|
||||
test('firstNonNull returns the first non-null candidate', () => {
|
||||
assert.equal(firstNonNull(null, undefined, '', 'value'), '');
|
||||
assert.equal(firstNonNull(undefined, null), null);
|
||||
});
|
||||
|
||||
test('normalizeString trims strings and rejects empties', () => {
|
||||
assert.equal(normalizeString(' Spec '), 'Spec');
|
||||
assert.equal(normalizeString(' '), null);
|
||||
assert.equal(normalizeString(123), '123');
|
||||
assert.equal(normalizeString(Number.POSITIVE_INFINITY), null);
|
||||
});
|
||||
|
||||
test('normalizeFrequency handles numeric and string inputs', () => {
|
||||
assert.equal(normalizeFrequency(915), '915');
|
||||
assert.equal(normalizeFrequency(868.125), '868.125');
|
||||
assert.equal(normalizeFrequency(' 868MHz '), '868');
|
||||
assert.equal(normalizeFrequency('n/a'), 'n/a');
|
||||
assert.equal(normalizeFrequency(-5), null);
|
||||
assert.equal(normalizeFrequency(null), null);
|
||||
});
|
||||
|
||||
test('formatChatMessagePrefix preserves bracket placeholders', () => {
|
||||
assert.equal(
|
||||
formatChatMessagePrefix({ timestamp: '11:46:48', frequency: '868' }),
|
||||
'[11:46:48][868]'
|
||||
);
|
||||
assert.equal(
|
||||
formatChatMessagePrefix({ timestamp: '16:19:19', frequency: null }),
|
||||
`[16:19:19][${FREQUENCY_PLACEHOLDER}]`
|
||||
);
|
||||
assert.equal(
|
||||
formatChatMessagePrefix({ timestamp: '09:00:00', frequency: '' }),
|
||||
`[09:00:00][${FREQUENCY_PLACEHOLDER}]`
|
||||
);
|
||||
});
|
||||
|
||||
test('formatChatChannelTag wraps channel names after the short name slot', () => {
|
||||
assert.equal(
|
||||
formatChatChannelTag({ channelName: 'TEST' }),
|
||||
'[TEST]'
|
||||
);
|
||||
assert.equal(
|
||||
formatChatChannelTag({ channelName: '' }),
|
||||
'[]'
|
||||
);
|
||||
assert.equal(
|
||||
formatChatChannelTag({ channelName: null }),
|
||||
'[]'
|
||||
);
|
||||
});
|
||||
|
||||
test('formatNodeAnnouncementPrefix includes optional frequency bracket', () => {
|
||||
assert.equal(
|
||||
formatNodeAnnouncementPrefix({ timestamp: '12:34:56', frequency: '868' }),
|
||||
'[12:34:56][868]'
|
||||
);
|
||||
assert.equal(
|
||||
formatNodeAnnouncementPrefix({ timestamp: '01:02:03', frequency: null }),
|
||||
`[01:02:03][${FREQUENCY_PLACEHOLDER}]`
|
||||
);
|
||||
});
|
||||
|
||||
test('normalizeFrequencySlot returns placeholder when frequency is missing', () => {
|
||||
assert.equal(normalizeFrequencySlot(null), FREQUENCY_PLACEHOLDER);
|
||||
assert.equal(normalizeFrequencySlot(''), FREQUENCY_PLACEHOLDER);
|
||||
assert.equal(normalizeFrequencySlot(undefined), FREQUENCY_PLACEHOLDER);
|
||||
assert.equal(normalizeFrequencySlot('915'), '915');
|
||||
});
|
||||
@@ -26,6 +26,7 @@ const {
|
||||
extractNumber,
|
||||
assignString,
|
||||
assignNumber,
|
||||
mergeModemMetadata,
|
||||
mergeNodeFields,
|
||||
mergeTelemetry,
|
||||
mergePosition,
|
||||
@@ -49,6 +50,8 @@ test('refreshNodeInformation merges telemetry metrics when the base node lacks t
|
||||
short_name: 'TST',
|
||||
battery_level: null,
|
||||
last_heard: 1_000,
|
||||
modem_preset: 'MediumFast',
|
||||
lora_freq: '868.1',
|
||||
})],
|
||||
['/api/telemetry/!test?limit=1', createResponse(200, [{
|
||||
node_id: '!test',
|
||||
@@ -87,6 +90,8 @@ test('refreshNodeInformation merges telemetry metrics when the base node lacks t
|
||||
assert.equal(node.battery, 73.5);
|
||||
assert.equal(node.voltage, 4.1);
|
||||
assert.equal(node.role, 'CLIENT');
|
||||
assert.equal(node.modemPreset, 'MediumFast');
|
||||
assert.equal(node.loraFreq, 868.1);
|
||||
assert.equal(node.lastHeard, 1_200);
|
||||
assert.equal(node.telemetryTime, 1_180);
|
||||
assert.equal(node.latitude, 52.5);
|
||||
@@ -123,7 +128,7 @@ test('refreshNodeInformation preserves fallback metrics when telemetry is unavai
|
||||
return response ?? createResponse(404, { error: 'not found' });
|
||||
};
|
||||
|
||||
const fallback = { nodeNum: 42, battery: 12.5, role: 'CLIENT' };
|
||||
const fallback = { nodeNum: 42, battery: 12.5, role: 'CLIENT', modemPreset: 'FallbackPreset', loraFreq: 915 };
|
||||
const node = await refreshNodeInformation({ nodeNum: 42, fallback }, { fetchImpl });
|
||||
|
||||
assert.equal(node.nodeId, '!num');
|
||||
@@ -131,6 +136,8 @@ test('refreshNodeInformation preserves fallback metrics when telemetry is unavai
|
||||
assert.equal(node.shortName, 'NUM');
|
||||
assert.equal(node.battery, 12.5);
|
||||
assert.equal(node.role, 'CLIENT');
|
||||
assert.equal(node.modemPreset, 'FallbackPreset');
|
||||
assert.equal(node.loraFreq, 915);
|
||||
assert.equal(Array.isArray(node.neighbors) && node.neighbors.length, 0);
|
||||
});
|
||||
|
||||
@@ -196,6 +203,21 @@ test('refreshNodeInformation enforces a fetch implementation', async () => {
|
||||
}
|
||||
});
|
||||
|
||||
test('mergeModemMetadata respects preference flags', () => {
|
||||
const target = {};
|
||||
mergeModemMetadata(target, { modem_preset: 'Base', lora_freq: '915.5' });
|
||||
assert.equal(target.modemPreset, 'Base');
|
||||
assert.equal(target.loraFreq, 915.5);
|
||||
|
||||
mergeModemMetadata(target, { modem_preset: 'New', lora_freq: '433' }, { preferExisting: true });
|
||||
assert.equal(target.modemPreset, 'Base');
|
||||
assert.equal(target.loraFreq, 915.5);
|
||||
|
||||
mergeModemMetadata(target, { modem_preset: 'Updated', lora_freq: '433' }, { preferExisting: false });
|
||||
assert.equal(target.modemPreset, 'Updated');
|
||||
assert.equal(target.loraFreq, 433);
|
||||
});
|
||||
|
||||
test('helper utilities normalise primitive values', () => {
|
||||
assert.equal(toTrimmedString(' hello '), 'hello');
|
||||
assert.equal(toTrimmedString(''), null);
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
/*
|
||||
* 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 { describe, it } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { extractModemMetadata, formatLoraFrequencyMHz, formatModemDisplay, __testUtils } from '../node-modem-metadata.js';
|
||||
|
||||
describe('node-modem-metadata', () => {
|
||||
it('extracts modem preset and frequency from mixed payloads', () => {
|
||||
const payload = {
|
||||
modem_preset: ' MediumFast ',
|
||||
lora_freq: '915',
|
||||
};
|
||||
assert.deepEqual(extractModemMetadata(payload), { modemPreset: 'MediumFast', loraFreq: 915 });
|
||||
});
|
||||
|
||||
it('falls back across naming conventions when extracting metadata', () => {
|
||||
const payload = {
|
||||
modemPreset: 'LongSlow',
|
||||
frequency: 868,
|
||||
};
|
||||
assert.deepEqual(extractModemMetadata(payload), { modemPreset: 'LongSlow', loraFreq: 868 });
|
||||
});
|
||||
|
||||
it('ignores invalid modem metadata entries', () => {
|
||||
assert.deepEqual(extractModemMetadata({ modem_preset: ' ', lora_freq: 'NaN' }), {
|
||||
modemPreset: null,
|
||||
loraFreq: null,
|
||||
});
|
||||
});
|
||||
|
||||
it('formats positive frequencies with MHz suffix', () => {
|
||||
assert.equal(formatLoraFrequencyMHz(915), '915MHz');
|
||||
assert.equal(formatLoraFrequencyMHz(867.5), '867.5MHz');
|
||||
assert.equal(formatLoraFrequencyMHz('433.1234'), '433.123MHz');
|
||||
assert.equal(formatLoraFrequencyMHz(null), null);
|
||||
});
|
||||
|
||||
it('combines preset and frequency for overlay display', () => {
|
||||
assert.equal(formatModemDisplay('MediumFast', 868), 'MediumFast (868MHz)');
|
||||
assert.equal(formatModemDisplay('ShortSlow', null), 'ShortSlow');
|
||||
assert.equal(formatModemDisplay(null, 433), '433MHz');
|
||||
assert.equal(formatModemDisplay(undefined, undefined), null);
|
||||
});
|
||||
|
||||
it('exposes trimmed string helper for targeted assertions', () => {
|
||||
const { toTrimmedString } = __testUtils;
|
||||
assert.equal(toTrimmedString(' hello '), 'hello');
|
||||
assert.equal(toTrimmedString(''), null);
|
||||
assert.equal(toTrimmedString(null), null);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,194 @@
|
||||
/*
|
||||
* 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.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Extract channel metadata from a message payload for chat display.
|
||||
*
|
||||
* @param {Object} message Raw message payload from the API.
|
||||
* @returns {{ frequency: string|null, channelName: string|null }}
|
||||
* Normalized metadata values.
|
||||
*/
|
||||
export function extractChatMessageMetadata(message) {
|
||||
if (!message || typeof message !== 'object') {
|
||||
return { frequency: null, channelName: null };
|
||||
}
|
||||
|
||||
const frequency = normalizeFrequency(
|
||||
firstNonNull(
|
||||
message.region_frequency,
|
||||
message.regionFrequency,
|
||||
message.lora_freq,
|
||||
message.loraFreq,
|
||||
message.frequency
|
||||
)
|
||||
);
|
||||
|
||||
const channelName = normalizeString(
|
||||
firstNonNull(message.channel_name, message.channelName)
|
||||
);
|
||||
|
||||
return { frequency, channelName };
|
||||
}
|
||||
|
||||
/**
|
||||
* Produce the formatted prefix for a chat message entry.
|
||||
*
|
||||
* Timestamp and frequency will each be wrapped in square brackets. Missing
|
||||
* metadata values result in empty brackets (with the frequency replaced by the
|
||||
* configured placeholder) to preserve the positional layout expected by
|
||||
* operators.
|
||||
*
|
||||
* @param {{
|
||||
* timestamp: string,
|
||||
* frequency: string|null
|
||||
* }} params Normalised and escaped display strings.
|
||||
* @returns {string} Prefix string suitable for HTML insertion.
|
||||
*/
|
||||
export function formatChatMessagePrefix({ timestamp, frequency }) {
|
||||
const ts = typeof timestamp === 'string' ? timestamp : '';
|
||||
const freq = normalizeFrequencySlot(frequency);
|
||||
return `[${ts}][${freq}]`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Render the channel tag that follows the short name in a chat message entry.
|
||||
*
|
||||
* Empty channel names remain blank within the brackets, mirroring the original
|
||||
* UI behaviour that reserves the slot without introducing placeholder text.
|
||||
*
|
||||
* @param {{ channelName: string|null }} params Normalised and escaped display strings.
|
||||
* @returns {string} Channel tag suitable for HTML insertion.
|
||||
*/
|
||||
export function formatChatChannelTag({ channelName }) {
|
||||
const channel = typeof channelName === 'string' ? channelName : channelName == null ? '' : String(channelName);
|
||||
return `[${channel}]`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create the formatted prefix for node announcements in the chat log.
|
||||
*
|
||||
* Both the timestamp and the optional frequency will be wrapped in brackets,
|
||||
* mirroring the chat message display while omitting the channel indicator.
|
||||
*
|
||||
* @param {{ timestamp: string, frequency: string|null }} params Display strings.
|
||||
* @returns {string} Prefix string suitable for HTML insertion.
|
||||
*/
|
||||
export function formatNodeAnnouncementPrefix({ timestamp, frequency }) {
|
||||
const ts = typeof timestamp === 'string' ? timestamp : '';
|
||||
const freq = normalizeFrequencySlot(frequency);
|
||||
return `[${ts}][${freq}]`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Produce a consistently formatted frequency slot for chat prefixes.
|
||||
*
|
||||
* A missing or empty frequency is rendered as three HTML non-breaking spaces to
|
||||
* ensure the UI maintains its expected alignment while clearly indicating the
|
||||
* absence of data.
|
||||
*
|
||||
* @param {*} value Frequency value that has already been escaped for HTML.
|
||||
* @returns {string} Frequency slot suitable for prefix rendering.
|
||||
*/
|
||||
function normalizeFrequencySlot(value) {
|
||||
if (value == null) {
|
||||
return FREQUENCY_PLACEHOLDER;
|
||||
}
|
||||
if (typeof value === 'string') {
|
||||
return value.length > 0 ? value : FREQUENCY_PLACEHOLDER;
|
||||
}
|
||||
const strValue = String(value);
|
||||
return strValue.length > 0 ? strValue : FREQUENCY_PLACEHOLDER;
|
||||
}
|
||||
|
||||
/**
|
||||
* HTML entity sequence inserted when a frequency is unavailable.
|
||||
* @type {string}
|
||||
*/
|
||||
const FREQUENCY_PLACEHOLDER = ' ';
|
||||
|
||||
/**
|
||||
* Return the first value in ``candidates`` that is not ``null`` or ``undefined``.
|
||||
*
|
||||
* @param {...*} candidates Candidate values.
|
||||
* @returns {*} First present value or ``null`` when missing.
|
||||
*/
|
||||
function firstNonNull(...candidates) {
|
||||
for (const value of candidates) {
|
||||
if (value !== null && value !== undefined) {
|
||||
return value;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalise potential channel name values to trimmed strings.
|
||||
*
|
||||
* @param {*} value Raw value.
|
||||
* @returns {string|null} Sanitised channel name.
|
||||
*/
|
||||
function normalizeString(value) {
|
||||
if (value == null) return null;
|
||||
if (typeof value === 'string') {
|
||||
const trimmed = value.trim();
|
||||
return trimmed.length > 0 ? trimmed : null;
|
||||
}
|
||||
if (typeof value === 'number') {
|
||||
if (!Number.isFinite(value)) return null;
|
||||
return String(value);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert various frequency representations into clean strings.
|
||||
*
|
||||
* @param {*} value Raw frequency value.
|
||||
* @returns {string|null} Frequency in MHz as a string, when available.
|
||||
*/
|
||||
function normalizeFrequency(value) {
|
||||
if (value == null) return null;
|
||||
if (typeof value === 'number') {
|
||||
if (!Number.isFinite(value) || value <= 0) {
|
||||
return null;
|
||||
}
|
||||
return Number.isInteger(value) ? String(value) : String(Number(value.toFixed(3)));
|
||||
}
|
||||
if (typeof value === 'string') {
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed) return null;
|
||||
const numericMatch = trimmed.match(/\d+(?:\.\d+)?/);
|
||||
if (numericMatch) {
|
||||
const parsed = Number(numericMatch[0]);
|
||||
if (Number.isFinite(parsed) && parsed > 0) {
|
||||
return Number.isInteger(parsed) ? String(Math.trunc(parsed)) : String(parsed);
|
||||
}
|
||||
}
|
||||
return trimmed;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export const __test__ = {
|
||||
firstNonNull,
|
||||
normalizeString,
|
||||
normalizeFrequency,
|
||||
formatChatMessagePrefix,
|
||||
formatNodeAnnouncementPrefix,
|
||||
normalizeFrequencySlot,
|
||||
FREQUENCY_PLACEHOLDER,
|
||||
formatChatChannelTag
|
||||
};
|
||||
@@ -19,6 +19,13 @@ import { createMapAutoFitController } from './map-auto-fit-controller.js';
|
||||
import { attachNodeInfoRefreshToMarker, overlayToPopupNode } from './map-marker-node-info.js';
|
||||
import { createShortInfoOverlayStack } from './short-info-overlay-manager.js';
|
||||
import { refreshNodeInformation } from './node-details.js';
|
||||
import { extractModemMetadata, formatModemDisplay } from './node-modem-metadata.js';
|
||||
import {
|
||||
extractChatMessageMetadata,
|
||||
formatChatMessagePrefix,
|
||||
formatChatChannelTag,
|
||||
formatNodeAnnouncementPrefix
|
||||
} from './chat-format.js';
|
||||
|
||||
/**
|
||||
* Entry point for the interactive dashboard. Wires up event listeners,
|
||||
@@ -115,6 +122,29 @@ export function initializeApp(config) {
|
||||
/** @type {ReturnType<typeof setTimeout>|null} */
|
||||
let refreshTimer = null;
|
||||
|
||||
/**
|
||||
* Close any open short-info overlays that do not contain the provided anchor.
|
||||
*
|
||||
* The method preserves ancestor overlays that host nested short-name badges,
|
||||
* ensuring context overlays (for example, neighbor listings) remain visible
|
||||
* while unrelated overlays are dismissed.
|
||||
*
|
||||
* @param {?Element} anchorEl Short-name badge that triggered the interaction.
|
||||
* @returns {void}
|
||||
*/
|
||||
function closeUnrelatedShortOverlays(anchorEl) {
|
||||
if (!anchorEl) {
|
||||
return;
|
||||
}
|
||||
const openOverlays = overlayStack.getOpenOverlays();
|
||||
for (const entry of openOverlays) {
|
||||
if (!entry || !entry.element || entry.element.contains(anchorEl)) {
|
||||
continue;
|
||||
}
|
||||
overlayStack.close(entry.anchor);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine whether the provided value contains a non-empty string.
|
||||
*
|
||||
@@ -1512,6 +1542,7 @@ export function initializeApp(config) {
|
||||
const nodeNum = Number.isFinite(fallbackDetails.nodeNum) ? fallbackDetails.nodeNum : null;
|
||||
|
||||
if (!nodeId && !nodeNum) {
|
||||
closeUnrelatedShortOverlays(shortTarget);
|
||||
openShortInfoOverlay(shortTarget, fallbackDetails);
|
||||
return;
|
||||
}
|
||||
@@ -1526,6 +1557,7 @@ export function initializeApp(config) {
|
||||
if (!overlayDetails.shortName && shortTarget.textContent) {
|
||||
overlayDetails.shortName = shortTarget.textContent.replace(/\u00a0/g, ' ').trim();
|
||||
}
|
||||
closeUnrelatedShortOverlays(shortTarget);
|
||||
openShortInfoOverlay(shortTarget, overlayDetails);
|
||||
})
|
||||
.catch(err => {
|
||||
@@ -1535,6 +1567,7 @@ export function initializeApp(config) {
|
||||
if (!overlayDetails.shortName && shortTarget.textContent) {
|
||||
overlayDetails.shortName = shortTarget.textContent.replace(/\u00a0/g, ' ').trim();
|
||||
}
|
||||
closeUnrelatedShortOverlays(shortTarget);
|
||||
openShortInfoOverlay(shortTarget, overlayDetails);
|
||||
});
|
||||
return;
|
||||
@@ -1860,6 +1893,14 @@ export function initializeApp(config) {
|
||||
normalized.hwModel = source.hwModel ?? source.hw_model;
|
||||
}
|
||||
|
||||
const modemMetadata = extractModemMetadata(source);
|
||||
if (modemMetadata.modemPreset) {
|
||||
normalized.modemPreset = modemMetadata.modemPreset;
|
||||
}
|
||||
if (modemMetadata.loraFreq != null) {
|
||||
normalized.loraFreq = modemMetadata.loraFreq;
|
||||
}
|
||||
|
||||
const numericPairs = [
|
||||
['battery', source.battery ?? source.battery_level],
|
||||
['voltage', source.voltage],
|
||||
@@ -1969,6 +2010,10 @@ export function initializeApp(config) {
|
||||
if (shortParts.length) {
|
||||
lines.push(shortParts.join(' '));
|
||||
}
|
||||
const modemDisplay = formatModemDisplay(overlayInfo.modemPreset, overlayInfo.loraFreq);
|
||||
if (modemDisplay) {
|
||||
lines.push(escapeHtml(modemDisplay));
|
||||
}
|
||||
const roleValue = shortInfoValueOrDash(overlayInfo.role || 'CLIENT');
|
||||
if (roleValue !== '—') {
|
||||
lines.push(`Role: ${escapeHtml(roleValue)}`);
|
||||
@@ -2069,7 +2114,12 @@ export function initializeApp(config) {
|
||||
div.className = 'chat-entry-node';
|
||||
const short = renderShortHtml(n.short_name, n.role, n.long_name, n);
|
||||
const longName = escapeHtml(n.long_name || '');
|
||||
div.innerHTML = `[${ts}] ${short} <em>New node: ${longName}</em>`;
|
||||
const metadata = extractChatMessageMetadata(n);
|
||||
const prefix = formatNodeAnnouncementPrefix({
|
||||
timestamp: escapeHtml(ts),
|
||||
frequency: metadata.frequency ? escapeHtml(metadata.frequency) : ''
|
||||
});
|
||||
div.innerHTML = `${prefix} ${short} <em>New node: ${longName}</em>`;
|
||||
return div;
|
||||
}
|
||||
|
||||
@@ -2084,8 +2134,16 @@ export function initializeApp(config) {
|
||||
const ts = formatTime(new Date(m.rx_time * 1000));
|
||||
const short = renderShortHtml(m.node?.short_name, m.node?.role, m.node?.long_name, m.node);
|
||||
const text = escapeHtml(m.text || '');
|
||||
const metadata = extractChatMessageMetadata(m);
|
||||
const prefix = formatChatMessagePrefix({
|
||||
timestamp: escapeHtml(ts),
|
||||
frequency: metadata.frequency ? escapeHtml(metadata.frequency) : ''
|
||||
});
|
||||
const channelTag = formatChatChannelTag({
|
||||
channelName: metadata.channelName ? escapeHtml(metadata.channelName) : ''
|
||||
});
|
||||
div.className = 'chat-entry-msg';
|
||||
div.innerHTML = `[${ts}] ${short} ${text}`;
|
||||
div.innerHTML = `${prefix} ${short} ${channelTag} ${text}`;
|
||||
return div;
|
||||
}
|
||||
|
||||
@@ -2861,12 +2919,14 @@ export function initializeApp(config) {
|
||||
},
|
||||
showDetails: (anchor, info) => {
|
||||
if (anchor) {
|
||||
closeUnrelatedShortOverlays(anchor);
|
||||
openShortInfoOverlay(anchor, info);
|
||||
}
|
||||
},
|
||||
showError: (anchor, info, error) => {
|
||||
console.warn('Failed to refresh node information for map marker', error);
|
||||
if (anchor) {
|
||||
closeUnrelatedShortOverlays(anchor);
|
||||
openShortInfoOverlay(anchor, info);
|
||||
}
|
||||
},
|
||||
|
||||
@@ -14,6 +14,8 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { extractModemMetadata } from './node-modem-metadata.js';
|
||||
|
||||
const DEFAULT_FETCH_OPTIONS = Object.freeze({ cache: 'no-store' });
|
||||
const TELEMETRY_LIMIT = 1;
|
||||
const POSITION_LIMIT = 1;
|
||||
@@ -130,6 +132,30 @@ function assignNumber(target, key, value, { preferExisting = false } = {}) {
|
||||
target[key] = numericValue;
|
||||
}
|
||||
|
||||
/**
|
||||
* Merge modem preset and frequency metadata into the aggregate node object.
|
||||
*
|
||||
* @param {Object} target Mutable aggregate node reference.
|
||||
* @param {*} source Source record inspected for modem attributes.
|
||||
* @param {{ preferExisting?: boolean }} [options] Behaviour modifiers.
|
||||
* @returns {void}
|
||||
*/
|
||||
function mergeModemMetadata(target, source, { preferExisting = false } = {}) {
|
||||
if (!isObject(target)) return;
|
||||
if (!source || typeof source !== 'object') return;
|
||||
const metadata = extractModemMetadata(source);
|
||||
if (metadata.modemPreset) {
|
||||
if (!preferExisting || toTrimmedString(target.modemPreset) == null) {
|
||||
target.modemPreset = metadata.modemPreset;
|
||||
}
|
||||
}
|
||||
if (metadata.loraFreq != null) {
|
||||
if (!preferExisting || toFiniteNumber(target.loraFreq) == null) {
|
||||
target.loraFreq = metadata.loraFreq;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Merge base node fields from an arbitrary record into the aggregate node object.
|
||||
*
|
||||
@@ -145,6 +171,7 @@ function mergeNodeFields(target, record) {
|
||||
assignString(target, 'longName', extractString(record, ['longName', 'long_name']));
|
||||
assignString(target, 'role', extractString(record, ['role']));
|
||||
assignString(target, 'hwModel', extractString(record, ['hwModel', 'hw_model']));
|
||||
mergeModemMetadata(target, record);
|
||||
assignNumber(target, 'snr', extractNumber(record, ['snr']));
|
||||
assignNumber(target, 'battery', extractNumber(record, ['battery', 'battery_level', 'batteryLevel']));
|
||||
assignNumber(target, 'voltage', extractNumber(record, ['voltage']));
|
||||
@@ -176,6 +203,7 @@ function mergeTelemetry(target, telemetry) {
|
||||
target.telemetry = telemetry;
|
||||
assignString(target, 'nodeId', extractString(telemetry, ['node_id', 'nodeId']), { preferExisting: true });
|
||||
assignNumber(target, 'nodeNum', extractNumber(telemetry, ['node_num', 'nodeNum']), { preferExisting: true });
|
||||
mergeModemMetadata(target, telemetry, { preferExisting: true });
|
||||
assignNumber(target, 'battery', extractNumber(telemetry, ['battery_level', 'batteryLevel']), { preferExisting: true });
|
||||
assignNumber(target, 'voltage', extractNumber(telemetry, ['voltage']), { preferExisting: true });
|
||||
assignNumber(target, 'uptime', extractNumber(telemetry, ['uptime_seconds', 'uptimeSeconds']), { preferExisting: true });
|
||||
@@ -408,6 +436,7 @@ export const __testUtils = {
|
||||
extractNumber,
|
||||
assignString,
|
||||
assignNumber,
|
||||
mergeModemMetadata,
|
||||
mergeNodeFields,
|
||||
mergeTelemetry,
|
||||
mergePosition,
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
/*
|
||||
* 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.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Convert arbitrary input into a trimmed string representation.
|
||||
*
|
||||
* @param {*} value Candidate value.
|
||||
* @returns {string|null} Trimmed string or ``null`` when empty.
|
||||
*/
|
||||
function toTrimmedString(value) {
|
||||
if (value == null) return null;
|
||||
const stringValue = String(value).trim();
|
||||
return stringValue.length > 0 ? stringValue : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize modem-related metadata from a node-shaped record.
|
||||
*
|
||||
* @param {*} source Arbitrary payload that may contain modem attributes.
|
||||
* @returns {{ modemPreset: (string|null), loraFreq: (number|null) }} Normalized modem metadata.
|
||||
*/
|
||||
export function extractModemMetadata(source) {
|
||||
if (!source || typeof source !== 'object') {
|
||||
return { modemPreset: null, loraFreq: null };
|
||||
}
|
||||
|
||||
const presetCandidate =
|
||||
source.modemPreset ?? source.modem_preset ?? source.modempreset ?? source.ModemPreset ?? null;
|
||||
const modemPreset = toTrimmedString(presetCandidate);
|
||||
|
||||
const freqCandidate = source.loraFreq ?? source.lora_freq ?? source.frequency ?? null;
|
||||
const parsedFreq = Number(freqCandidate);
|
||||
const loraFreq = Number.isFinite(parsedFreq) && parsedFreq > 0 ? parsedFreq : null;
|
||||
|
||||
return { modemPreset, loraFreq };
|
||||
}
|
||||
|
||||
/**
|
||||
* Format a numeric LoRa frequency in MHz with up to three fractional digits.
|
||||
*
|
||||
* @param {*} value Numeric frequency in MHz.
|
||||
* @returns {string|null} Formatted frequency with units or ``null`` when invalid.
|
||||
*/
|
||||
export function formatLoraFrequencyMHz(value) {
|
||||
const numeric = typeof value === 'number' ? value : Number(value);
|
||||
if (!Number.isFinite(numeric) || numeric <= 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const formatter = new Intl.NumberFormat('en-US', {
|
||||
minimumFractionDigits: 0,
|
||||
maximumFractionDigits: 3,
|
||||
});
|
||||
|
||||
return `${formatter.format(numeric)}MHz`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Produce a combined modem preset and frequency description suitable for overlays.
|
||||
*
|
||||
* @param {*} preset Raw modem preset value.
|
||||
* @param {*} frequency Raw frequency value expressed in MHz.
|
||||
* @returns {string|null} Human-readable description or ``null`` when no data available.
|
||||
*/
|
||||
export function formatModemDisplay(preset, frequency) {
|
||||
const presetText = toTrimmedString(preset);
|
||||
const freqText = formatLoraFrequencyMHz(frequency);
|
||||
|
||||
if (!presetText && !freqText) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (presetText && freqText) {
|
||||
return `${presetText} (${freqText})`;
|
||||
}
|
||||
|
||||
return presetText ?? freqText;
|
||||
}
|
||||
|
||||
export const __testUtils = {
|
||||
toTrimmedString,
|
||||
};
|
||||
+763
-13
@@ -78,6 +78,15 @@ RSpec.describe "Potato Mesh Sinatra app" do
|
||||
ensure_self_instance_record!
|
||||
end
|
||||
|
||||
# Retrieve the number of rows stored in the instances table.
|
||||
#
|
||||
# @return [Integer] count of stored instance records.
|
||||
def instance_count
|
||||
with_db(readonly: true) do |db|
|
||||
db.get_first_value("SELECT COUNT(*) FROM instances").to_i
|
||||
end
|
||||
end
|
||||
|
||||
# Build a hash excluding entries whose values are nil.
|
||||
#
|
||||
# @param hash [Hash] collection filtered for nil values.
|
||||
@@ -739,6 +748,176 @@ RSpec.describe "Potato Mesh Sinatra app" do
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe ".self_instance_registration_decision" do
|
||||
let(:domain) { "spec.mesh.test" }
|
||||
|
||||
it "rejects registration when the domain source is not the environment" do
|
||||
stub_const("PotatoMesh::Application::INSTANCE_DOMAIN_SOURCE", :reverse_dns) do
|
||||
allowed, reason = application_class.self_instance_registration_decision(domain)
|
||||
|
||||
expect(allowed).to be(false)
|
||||
expect(reason).to eq("INSTANCE_DOMAIN source is reverse_dns")
|
||||
end
|
||||
end
|
||||
|
||||
it "rejects registration when the domain is invalid" do
|
||||
stub_const("PotatoMesh::Application::INSTANCE_DOMAIN_SOURCE", :environment) do
|
||||
allowed, reason = application_class.self_instance_registration_decision(nil)
|
||||
|
||||
expect(allowed).to be(false)
|
||||
expect(reason).to eq("INSTANCE_DOMAIN missing or invalid")
|
||||
end
|
||||
end
|
||||
|
||||
it "rejects registration when the domain resolves to a restricted IP" do
|
||||
stub_const("PotatoMesh::Application::INSTANCE_DOMAIN_SOURCE", :environment) do
|
||||
allowed, reason = application_class.self_instance_registration_decision("127.0.0.1")
|
||||
|
||||
expect(allowed).to be(false)
|
||||
expect(reason).to eq("INSTANCE_DOMAIN resolves to restricted IP")
|
||||
end
|
||||
end
|
||||
|
||||
it "accepts registration when configuration is valid" do
|
||||
stub_const("PotatoMesh::Application::INSTANCE_DOMAIN_SOURCE", :environment) do
|
||||
allowed, reason = application_class.self_instance_registration_decision(domain)
|
||||
|
||||
expect(allowed).to be(true)
|
||||
expect(reason).to be_nil
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe ".ensure_self_instance_record!" do
|
||||
it "persists the self instance when registration is allowed" do
|
||||
stub_const("PotatoMesh::Application::INSTANCE_DOMAIN_SOURCE", :environment) do
|
||||
stub_const("PotatoMesh::Application::INSTANCE_DOMAIN", "self.mesh") do
|
||||
with_db do |db|
|
||||
db.execute("DELETE FROM instances")
|
||||
end
|
||||
|
||||
application_class.ensure_self_instance_record!
|
||||
|
||||
expect(instance_count).to eq(1)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
it "skips persistence when registration is not allowed" do
|
||||
stub_const("PotatoMesh::Application::INSTANCE_DOMAIN_SOURCE", :reverse_dns) do
|
||||
with_db do |db|
|
||||
db.execute("DELETE FROM instances")
|
||||
end
|
||||
|
||||
application_class.ensure_self_instance_record!
|
||||
|
||||
expect(instance_count).to eq(0)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe ".federation_target_domains" do
|
||||
it "prioritises seed domains before database records" do
|
||||
with_db do |db|
|
||||
db.execute(
|
||||
"INSERT INTO instances (id, domain, pubkey, name, version, channel, frequency, latitude, longitude, last_update_time, is_private, signature) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
|
||||
[
|
||||
"remote-id",
|
||||
"Remote.Mesh",
|
||||
"pubkey",
|
||||
"Remote",
|
||||
"1.0.0",
|
||||
nil,
|
||||
nil,
|
||||
nil,
|
||||
nil,
|
||||
Time.now.to_i,
|
||||
0,
|
||||
"signature",
|
||||
],
|
||||
)
|
||||
end
|
||||
|
||||
targets = application_class.federation_target_domains("self.mesh")
|
||||
|
||||
expect(targets.first).to eq("potatomesh.net")
|
||||
expect(targets).to include("remote.mesh")
|
||||
expect(targets).not_to include("self.mesh")
|
||||
end
|
||||
|
||||
it "falls back to seeds when the database is unavailable" do
|
||||
allow(application_class).to receive(:open_database).and_raise(SQLite3::Exception.new("boom"))
|
||||
|
||||
targets = application_class.federation_target_domains("self.mesh")
|
||||
|
||||
expect(targets).to eq(["potatomesh.net"])
|
||||
end
|
||||
end
|
||||
|
||||
describe ".latest_node_update_timestamp" do
|
||||
it "returns the maximum last_heard value" do
|
||||
with_db do |db|
|
||||
db.execute("DELETE FROM nodes")
|
||||
db.execute("INSERT INTO nodes (node_id, last_heard) VALUES (?, ?)", ["node-a", 100])
|
||||
db.execute("INSERT INTO nodes (node_id, last_heard) VALUES (?, ?)", ["node-b", 200])
|
||||
end
|
||||
|
||||
expect(application_class.latest_node_update_timestamp).to eq(200)
|
||||
end
|
||||
|
||||
it "returns nil when no nodes contain last_heard values" do
|
||||
with_db do |db|
|
||||
db.execute("DELETE FROM nodes")
|
||||
end
|
||||
|
||||
expect(application_class.latest_node_update_timestamp).to be_nil
|
||||
end
|
||||
end
|
||||
|
||||
describe ".build_well_known_document" do
|
||||
it "signs the payload and normalises the domain" do
|
||||
with_db do |db|
|
||||
db.execute("DELETE FROM nodes")
|
||||
db.execute("INSERT INTO nodes (node_id, last_heard) VALUES (?, ?)", ["node-z", 321])
|
||||
end
|
||||
|
||||
stub_const("PotatoMesh::Application::INSTANCE_DOMAIN", "Example.NET") do
|
||||
json_output, signature = application_class.build_well_known_document
|
||||
document = JSON.parse(json_output)
|
||||
|
||||
expect(document["domain"]).to eq("example.net")
|
||||
expect(document["lastUpdate"]).to eq(321)
|
||||
expect(document["signatureAlgorithm"]).to eq("rsa-sha256")
|
||||
expect(signature).to be_a(String)
|
||||
expect(signature).not_to be_empty
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe ".upsert_instance_record" do
|
||||
it "rejects restricted domains" do
|
||||
attributes = {
|
||||
id: "restricted",
|
||||
domain: "127.0.0.1",
|
||||
pubkey: application_class::INSTANCE_PUBLIC_KEY_PEM,
|
||||
name: nil,
|
||||
version: nil,
|
||||
channel: nil,
|
||||
frequency: nil,
|
||||
latitude: nil,
|
||||
longitude: nil,
|
||||
last_update_time: Time.now.to_i,
|
||||
is_private: false,
|
||||
}
|
||||
|
||||
expect do
|
||||
with_db do |db|
|
||||
application_class.upsert_instance_record(db, attributes, "sig")
|
||||
end
|
||||
end.to raise_error(ArgumentError, "restricted domain")
|
||||
end
|
||||
end
|
||||
|
||||
describe "logging configuration" do
|
||||
@@ -1014,18 +1193,28 @@ RSpec.describe "Potato Mesh Sinatra app" do
|
||||
it "rejects registrations with invalid signatures" do
|
||||
invalid_payload = instance_payload.merge("signature" => Base64.strict_encode64("invalid"))
|
||||
|
||||
expect_any_instance_of(Sinatra::Application).to receive(:warn_log).with(
|
||||
"Instance registration rejected",
|
||||
context: "ingest.register",
|
||||
domain: domain,
|
||||
reason: "invalid signature",
|
||||
).at_least(:once)
|
||||
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 signature")
|
||||
|
||||
expect(warning_calls).to include(
|
||||
[
|
||||
["Instance registration rejected"],
|
||||
hash_including(
|
||||
context: "ingest.register",
|
||||
domain: domain,
|
||||
reason: "invalid signature",
|
||||
),
|
||||
],
|
||||
)
|
||||
|
||||
with_db(readonly: true) do |db|
|
||||
count = db.get_first_value("SELECT COUNT(*) FROM instances")
|
||||
expect(count).to eq(1)
|
||||
@@ -1044,25 +1233,375 @@ RSpec.describe "Potato Mesh Sinatra app" do
|
||||
"signature" => restricted_signature,
|
||||
)
|
||||
|
||||
expect_any_instance_of(Sinatra::Application).to receive(:warn_log).with(
|
||||
"Instance registration rejected",
|
||||
context: "ingest.register",
|
||||
domain: restricted_domain,
|
||||
reason: "restricted IP address",
|
||||
resolved_ip: an_instance_of(IPAddr),
|
||||
).at_least(:once)
|
||||
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", restricted_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: restricted_domain,
|
||||
reason: "restricted IP address",
|
||||
resolved_ip: an_instance_of(IPAddr),
|
||||
),
|
||||
],
|
||||
)
|
||||
|
||||
with_db(readonly: true) do |db|
|
||||
count = db.get_first_value("SELECT COUNT(*) FROM instances")
|
||||
expect(count).to eq(1)
|
||||
end
|
||||
end
|
||||
|
||||
it "ingests federation instances advertised by remote peers" do
|
||||
ally_key = OpenSSL::PKey::RSA.new(2048)
|
||||
ally_domain = "ally.mesh"
|
||||
ally_attributes = {
|
||||
id: "ally-instance-1",
|
||||
domain: ally_domain,
|
||||
pubkey: ally_key.public_key.export,
|
||||
name: "Ally Mesh",
|
||||
version: "2.0.0",
|
||||
channel: "#Allies",
|
||||
frequency: "433MHz",
|
||||
latitude: 40.1,
|
||||
longitude: -74.0,
|
||||
last_update_time: Time.now.to_i,
|
||||
is_private: false,
|
||||
}
|
||||
ally_signature_payload = canonical_instance_payload(ally_attributes)
|
||||
ally_signature = Base64.strict_encode64(
|
||||
ally_key.sign(OpenSSL::Digest::SHA256.new, ally_signature_payload),
|
||||
)
|
||||
ally_payload = {
|
||||
"id" => ally_attributes[:id],
|
||||
"domain" => ally_domain,
|
||||
"pubkey" => ally_attributes[:pubkey],
|
||||
"name" => ally_attributes[:name],
|
||||
"version" => ally_attributes[:version],
|
||||
"channel" => ally_attributes[:channel],
|
||||
"frequency" => ally_attributes[:frequency],
|
||||
"latitude" => ally_attributes[:latitude],
|
||||
"longitude" => ally_attributes[:longitude],
|
||||
"lastUpdateTime" => ally_attributes[:last_update_time],
|
||||
"isPrivate" => ally_attributes[:is_private],
|
||||
"signature" => ally_signature,
|
||||
}
|
||||
|
||||
ally_nodes = Array.new(PotatoMesh::Config.remote_instance_min_node_count) do |index|
|
||||
{ "node_id" => "ally-node-#{index}", "last_heard" => Time.now.to_i - index }
|
||||
end
|
||||
|
||||
allow_any_instance_of(Sinatra::Application).to receive(:fetch_instance_json) do |_instance, host, path|
|
||||
case [host, path]
|
||||
when [domain, "/.well-known/potato-mesh"]
|
||||
[well_known_document, URI("https://#{host}#{path}")]
|
||||
when [domain, "/api/nodes"]
|
||||
[remote_nodes, URI("https://#{host}#{path}")]
|
||||
when [domain, "/api/instances"]
|
||||
[[ally_payload], URI("https://#{host}#{path}")]
|
||||
when [ally_domain, "/api/nodes"]
|
||||
[ally_nodes, URI("https://#{host}#{path}")]
|
||||
when [ally_domain, "/api/instances"]
|
||||
[[instance_payload], URI("https://#{host}#{path}")]
|
||||
else
|
||||
[nil, []]
|
||||
end
|
||||
end
|
||||
|
||||
post "/api/instances", instance_payload.to_json, { "CONTENT_TYPE" => "application/json" }
|
||||
|
||||
expect(last_response.status).to eq(201)
|
||||
|
||||
with_db(readonly: true) do |db|
|
||||
db.results_as_hash = true
|
||||
ally_row = db.get_first_row(
|
||||
"SELECT domain, signature FROM instances WHERE domain = ?",
|
||||
[ally_domain],
|
||||
)
|
||||
remote_row = db.get_first_row(
|
||||
"SELECT domain, signature FROM instances WHERE domain = ?",
|
||||
[domain],
|
||||
)
|
||||
|
||||
expect(ally_row).not_to be_nil
|
||||
expect(ally_row["signature"]).to eq(ally_signature)
|
||||
expect(remote_row).not_to be_nil
|
||||
expect(remote_row["signature"]).to eq(instance_signature)
|
||||
end
|
||||
end
|
||||
|
||||
it "skips remote federation entries that fail validation" do
|
||||
stale_key = OpenSSL::PKey::RSA.new(2048)
|
||||
stale_domain = "stale.mesh"
|
||||
stale_attributes = {
|
||||
id: "stale-instance",
|
||||
domain: stale_domain,
|
||||
pubkey: stale_key.public_key.export,
|
||||
name: "Stale Mesh",
|
||||
version: "0.1.0",
|
||||
channel: "#Stale",
|
||||
frequency: "868MHz",
|
||||
latitude: 10.0,
|
||||
longitude: 20.0,
|
||||
last_update_time: Time.now.to_i,
|
||||
is_private: false,
|
||||
}
|
||||
stale_signature_payload = canonical_instance_payload(stale_attributes)
|
||||
stale_signature = Base64.strict_encode64(
|
||||
stale_key.sign(OpenSSL::Digest::SHA256.new, stale_signature_payload),
|
||||
)
|
||||
stale_payload = {
|
||||
"id" => stale_attributes[:id],
|
||||
"domain" => stale_domain,
|
||||
"pubkey" => stale_attributes[:pubkey],
|
||||
"name" => stale_attributes[:name],
|
||||
"version" => stale_attributes[:version],
|
||||
"channel" => stale_attributes[:channel],
|
||||
"frequency" => stale_attributes[:frequency],
|
||||
"latitude" => stale_attributes[:latitude],
|
||||
"longitude" => stale_attributes[:longitude],
|
||||
"lastUpdateTime" => stale_attributes[:last_update_time],
|
||||
"isPrivate" => false,
|
||||
"signature" => stale_signature,
|
||||
}
|
||||
|
||||
private_key = OpenSSL::PKey::RSA.new(2048)
|
||||
private_domain = "private.mesh"
|
||||
private_attributes = {
|
||||
id: "private-instance",
|
||||
domain: private_domain,
|
||||
pubkey: private_key.public_key.export,
|
||||
name: "Private Mesh",
|
||||
version: "3.0.0",
|
||||
channel: "#Private",
|
||||
frequency: "915MHz",
|
||||
latitude: 0.0,
|
||||
longitude: 0.0,
|
||||
last_update_time: Time.now.to_i,
|
||||
is_private: true,
|
||||
}
|
||||
private_signature_payload = canonical_instance_payload(private_attributes)
|
||||
private_signature = Base64.strict_encode64(
|
||||
private_key.sign(OpenSSL::Digest::SHA256.new, private_signature_payload),
|
||||
)
|
||||
private_payload = {
|
||||
"id" => private_attributes[:id],
|
||||
"domain" => private_domain,
|
||||
"pubkey" => private_attributes[:pubkey],
|
||||
"name" => private_attributes[:name],
|
||||
"version" => private_attributes[:version],
|
||||
"channel" => private_attributes[:channel],
|
||||
"frequency" => private_attributes[:frequency],
|
||||
"latitude" => private_attributes[:latitude],
|
||||
"longitude" => private_attributes[:longitude],
|
||||
"lastUpdateTime" => private_attributes[:last_update_time],
|
||||
"isPrivate" => true,
|
||||
"signature" => private_signature,
|
||||
}
|
||||
|
||||
invalid_key = OpenSSL::PKey::RSA.new(2048)
|
||||
invalid_payload = {
|
||||
"id" => "invalid-instance",
|
||||
"domain" => "invalid.mesh",
|
||||
"pubkey" => invalid_key.public_key.export,
|
||||
"name" => "Invalid Mesh",
|
||||
"version" => "1.0.0",
|
||||
"channel" => "#Invalid",
|
||||
"frequency" => "915MHz",
|
||||
"latitude" => 1.0,
|
||||
"longitude" => 2.0,
|
||||
"lastUpdateTime" => Time.now.to_i,
|
||||
"isPrivate" => false,
|
||||
"signature" => Base64.strict_encode64("bogus"),
|
||||
}
|
||||
|
||||
unreachable_key = OpenSSL::PKey::RSA.new(2048)
|
||||
unreachable_domain = "unreachable.mesh"
|
||||
unreachable_attributes = {
|
||||
id: "unreachable-instance",
|
||||
domain: unreachable_domain,
|
||||
pubkey: unreachable_key.public_key.export,
|
||||
name: "Unreachable Mesh",
|
||||
version: "6.0.0",
|
||||
channel: "#Offline",
|
||||
frequency: "915MHz",
|
||||
latitude: 12.0,
|
||||
longitude: 24.0,
|
||||
last_update_time: Time.now.to_i,
|
||||
is_private: false,
|
||||
}
|
||||
unreachable_signature_payload = canonical_instance_payload(unreachable_attributes)
|
||||
unreachable_signature = Base64.strict_encode64(
|
||||
unreachable_key.sign(OpenSSL::Digest::SHA256.new, unreachable_signature_payload),
|
||||
)
|
||||
unreachable_payload = {
|
||||
"id" => unreachable_attributes[:id],
|
||||
"domain" => unreachable_domain,
|
||||
"pubkey" => unreachable_attributes[:pubkey],
|
||||
"name" => unreachable_attributes[:name],
|
||||
"version" => unreachable_attributes[:version],
|
||||
"channel" => unreachable_attributes[:channel],
|
||||
"frequency" => unreachable_attributes[:frequency],
|
||||
"latitude" => unreachable_attributes[:latitude],
|
||||
"longitude" => unreachable_attributes[:longitude],
|
||||
"lastUpdateTime" => unreachable_attributes[:last_update_time],
|
||||
"isPrivate" => false,
|
||||
"signature" => unreachable_signature,
|
||||
}
|
||||
|
||||
offline_domain = "offline.mesh"
|
||||
offline_key = OpenSSL::PKey::RSA.new(2048)
|
||||
offline_attributes = {
|
||||
id: "offline-instance",
|
||||
domain: offline_domain,
|
||||
pubkey: offline_key.public_key.export,
|
||||
name: "Offline Mesh",
|
||||
version: "4.0.0",
|
||||
channel: "#Offline",
|
||||
frequency: "915MHz",
|
||||
latitude: 5.0,
|
||||
longitude: 6.0,
|
||||
last_update_time: Time.now.to_i,
|
||||
is_private: false,
|
||||
}
|
||||
offline_signature_payload = canonical_instance_payload(offline_attributes)
|
||||
offline_signature = Base64.strict_encode64(
|
||||
offline_key.sign(OpenSSL::Digest::SHA256.new, offline_signature_payload),
|
||||
)
|
||||
offline_payload = {
|
||||
"id" => offline_attributes[:id],
|
||||
"domain" => offline_domain,
|
||||
"pubkey" => offline_attributes[:pubkey],
|
||||
"name" => offline_attributes[:name],
|
||||
"version" => offline_attributes[:version],
|
||||
"channel" => offline_attributes[:channel],
|
||||
"frequency" => offline_attributes[:frequency],
|
||||
"latitude" => offline_attributes[:latitude],
|
||||
"longitude" => offline_attributes[:longitude],
|
||||
"lastUpdateTime" => offline_attributes[:last_update_time],
|
||||
"isPrivate" => false,
|
||||
"signature" => offline_signature,
|
||||
}
|
||||
|
||||
restricted_domain = "127.0.0.1"
|
||||
restricted_key = OpenSSL::PKey::RSA.new(2048)
|
||||
restricted_attributes = {
|
||||
id: "restricted-instance",
|
||||
domain: restricted_domain,
|
||||
pubkey: restricted_key.public_key.export,
|
||||
name: "Restricted Mesh",
|
||||
version: "5.0.0",
|
||||
channel: "#Restricted",
|
||||
frequency: "915MHz",
|
||||
latitude: 9.0,
|
||||
longitude: 9.0,
|
||||
last_update_time: Time.now.to_i,
|
||||
is_private: false,
|
||||
}
|
||||
restricted_signature_payload = canonical_instance_payload(restricted_attributes)
|
||||
restricted_signature = Base64.strict_encode64(
|
||||
restricted_key.sign(OpenSSL::Digest::SHA256.new, restricted_signature_payload),
|
||||
)
|
||||
restricted_payload = {
|
||||
"id" => restricted_attributes[:id],
|
||||
"domain" => restricted_domain,
|
||||
"pubkey" => restricted_attributes[:pubkey],
|
||||
"name" => restricted_attributes[:name],
|
||||
"version" => restricted_attributes[:version],
|
||||
"channel" => restricted_attributes[:channel],
|
||||
"frequency" => restricted_attributes[:frequency],
|
||||
"latitude" => restricted_attributes[:latitude],
|
||||
"longitude" => restricted_attributes[:longitude],
|
||||
"lastUpdateTime" => restricted_attributes[:last_update_time],
|
||||
"isPrivate" => false,
|
||||
"signature" => restricted_signature,
|
||||
}
|
||||
|
||||
stale_nodes = Array.new(PotatoMesh::Config.remote_instance_min_node_count) do |index|
|
||||
{ "node_id" => "stale-node-#{index}", "last_heard" => (Time.now.to_i - PotatoMesh::Config.remote_instance_max_node_age) - index - 1 }
|
||||
end
|
||||
|
||||
allow_any_instance_of(Sinatra::Application).to receive(:fetch_instance_json) do |_instance, host, path|
|
||||
case [host, path]
|
||||
when [domain, "/.well-known/potato-mesh"]
|
||||
[well_known_document, URI("https://#{host}#{path}")]
|
||||
when [domain, "/api/nodes"]
|
||||
[remote_nodes, URI("https://#{host}#{path}")]
|
||||
when [domain, "/api/instances"]
|
||||
[
|
||||
[
|
||||
"unexpected",
|
||||
private_payload,
|
||||
invalid_payload,
|
||||
offline_payload,
|
||||
stale_payload,
|
||||
restricted_payload,
|
||||
unreachable_payload,
|
||||
],
|
||||
URI("https://#{host}#{path}"),
|
||||
]
|
||||
when [offline_domain, "/api/nodes"]
|
||||
[nil, ["timeout"]]
|
||||
when [stale_domain, "/api/nodes"]
|
||||
[stale_nodes, URI("https://#{host}#{path}")]
|
||||
when [restricted_domain, "/api/nodes"]
|
||||
[remote_nodes, URI("https://#{host}#{path}")]
|
||||
when [unreachable_domain, "/api/nodes"]
|
||||
[remote_nodes, URI("https://#{host}#{path}")]
|
||||
when [unreachable_domain, "/api/instances"]
|
||||
[nil, ["connection refused"]]
|
||||
else
|
||||
[nil, []]
|
||||
end
|
||||
end
|
||||
|
||||
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", instance_payload.to_json, { "CONTENT_TYPE" => "application/json" }
|
||||
|
||||
expect(last_response.status).to eq(201)
|
||||
|
||||
with_db(readonly: true) do |db|
|
||||
domains = db.execute("SELECT domain FROM instances ORDER BY domain").flatten
|
||||
expect(domains).to include(domain, unreachable_domain)
|
||||
expect(domains).not_to include(stale_domain, private_domain, "invalid.mesh", offline_domain, restricted_domain)
|
||||
expect(domains.count { |value| value == domain }).to eq(1)
|
||||
end
|
||||
|
||||
expect(warning_calls).to include(
|
||||
[
|
||||
["Failed to load remote federation instances"],
|
||||
hash_including(context: "federation.instances", domain: unreachable_domain),
|
||||
],
|
||||
)
|
||||
expect(warning_calls).to include(
|
||||
[
|
||||
["Discarded remote instance entry"],
|
||||
hash_including(domain: stale_domain, reason: "node data is stale"),
|
||||
],
|
||||
)
|
||||
expect(warning_calls).to include(
|
||||
[
|
||||
["Failed to persist remote instance"],
|
||||
hash_including(domain: restricted_domain, error_class: "ArgumentError"),
|
||||
],
|
||||
)
|
||||
end
|
||||
|
||||
it "accepts signatures when the optional isPrivate field is omitted" do
|
||||
unsigned_attributes = instance_attributes.merge(is_private: nil)
|
||||
unsigned_payload_json = canonical_instance_payload(unsigned_attributes)
|
||||
@@ -1088,6 +1627,113 @@ RSpec.describe "Potato Mesh Sinatra app" do
|
||||
expect(row["is_private"]).to eq(0)
|
||||
end
|
||||
end
|
||||
|
||||
it "replaces an existing record when the domain is reused" do
|
||||
with_db do |db|
|
||||
db.execute(
|
||||
<<~SQL,
|
||||
INSERT INTO instances (
|
||||
id, domain, pubkey, name, version, channel, frequency,
|
||||
latitude, longitude, last_update_time, is_private, signature
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
SQL
|
||||
[
|
||||
"legacy-id",
|
||||
domain,
|
||||
"legacy-pubkey",
|
||||
"Legacy Instance",
|
||||
"0.9.0",
|
||||
nil,
|
||||
nil,
|
||||
nil,
|
||||
nil,
|
||||
last_update_time - 100,
|
||||
0,
|
||||
"legacy-signature",
|
||||
],
|
||||
)
|
||||
end
|
||||
|
||||
debug_calls = []
|
||||
allow_any_instance_of(Sinatra::Application).to receive(:debug_log).and_wrap_original do |method, *args, **kwargs|
|
||||
debug_calls << [args, kwargs]
|
||||
method.call(*args, **kwargs)
|
||||
end
|
||||
|
||||
post "/api/instances", instance_payload.to_json, { "CONTENT_TYPE" => "application/json" }
|
||||
|
||||
expect(last_response.status).to eq(201)
|
||||
|
||||
with_db(readonly: true) do |db|
|
||||
ids = db.execute("SELECT id FROM instances WHERE domain = ?", [domain]).flatten
|
||||
|
||||
expect(ids).to eq([instance_attributes[:id]])
|
||||
end
|
||||
|
||||
expect(debug_calls).to include(
|
||||
[
|
||||
["Removed conflicting instance by domain"],
|
||||
hash_including(
|
||||
context: "federation.instances",
|
||||
domain: domain,
|
||||
replaced_id: "legacy-id",
|
||||
incoming_id: instance_attributes[:id],
|
||||
),
|
||||
],
|
||||
)
|
||||
end
|
||||
|
||||
it "normalises stored domains to lowercase" do
|
||||
uppercase_payload = instance_payload.merge("domain" => "Mesh.Example")
|
||||
|
||||
post "/api/instances", uppercase_payload.to_json, { "CONTENT_TYPE" => "application/json" }
|
||||
|
||||
expect(last_response.status).to eq(201)
|
||||
|
||||
with_db(readonly: true) do |db|
|
||||
stored = db.get_first_value("SELECT domain FROM instances WHERE id = ?", [instance_attributes[:id]])
|
||||
expect(stored).to eq(domain)
|
||||
end
|
||||
end
|
||||
|
||||
it "rejects registrations missing last_heard data" do
|
||||
missing_nodes = Array.new(PotatoMesh::Config.remote_instance_min_node_count) do |index|
|
||||
{ "node_id" => "remote-#{index}", "first_heard" => Time.now.to_i - index }
|
||||
end
|
||||
|
||||
allow_any_instance_of(Sinatra::Application).to receive(:fetch_instance_json) do |_instance, host, path|
|
||||
case path
|
||||
when "/.well-known/potato-mesh"
|
||||
[well_known_document, URI("https://#{host}#{path}")]
|
||||
when "/api/nodes"
|
||||
[missing_nodes, URI("https://#{host}#{path}")]
|
||||
else
|
||||
[nil, []]
|
||||
end
|
||||
end
|
||||
|
||||
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", instance_payload.to_json, { "CONTENT_TYPE" => "application/json" }
|
||||
|
||||
expect(last_response.status).to eq(400)
|
||||
expect(JSON.parse(last_response.body)).to eq("error" => "missing last_heard data")
|
||||
|
||||
expect(warning_calls).to include(
|
||||
[
|
||||
["Instance registration rejected"],
|
||||
hash_including(
|
||||
context: "ingest.register",
|
||||
domain: domain,
|
||||
reason: "missing last_heard data",
|
||||
),
|
||||
],
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
describe "GET /api/instances" do
|
||||
@@ -1140,6 +1786,110 @@ RSpec.describe "Potato Mesh Sinatra app" do
|
||||
expect(remote_entry["isPrivate"]).to eq(false)
|
||||
expect(remote_entry["signature"]).to eq(remote_signature)
|
||||
end
|
||||
|
||||
it "skips malformed rows without failing" do
|
||||
with_db do |db|
|
||||
sql = <<~SQL
|
||||
INSERT INTO instances (
|
||||
id, domain, pubkey, name, version, channel, frequency,
|
||||
latitude, longitude, last_update_time, is_private, signature
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
SQL
|
||||
db.execute(
|
||||
sql,
|
||||
[
|
||||
"broken-instance",
|
||||
"invalid domain name",
|
||||
remote_key.public_key.export,
|
||||
"Broken",
|
||||
"0.0.0",
|
||||
nil,
|
||||
nil,
|
||||
"not-a-number",
|
||||
nil,
|
||||
"not-a-timestamp",
|
||||
"not-a-bool",
|
||||
nil,
|
||||
],
|
||||
)
|
||||
end
|
||||
|
||||
get "/api/instances"
|
||||
|
||||
expect(last_response).to be_ok
|
||||
payload = JSON.parse(last_response.body)
|
||||
broken_entry = payload.find { |entry| entry["id"] == "broken-instance" }
|
||||
|
||||
expect(broken_entry).to be_nil
|
||||
expect(payload).not_to be_empty
|
||||
end
|
||||
|
||||
it "deduplicates records by domain keeping the newest entry" do
|
||||
newer_time = Time.now.to_i
|
||||
older_time = newer_time - 60
|
||||
|
||||
with_db do |db|
|
||||
insert_sql = <<~SQL
|
||||
INSERT INTO instances (
|
||||
id, domain, pubkey, name, version, channel, frequency,
|
||||
latitude, longitude, last_update_time, is_private, signature
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
SQL
|
||||
db.execute(
|
||||
insert_sql,
|
||||
[
|
||||
"duplicate-old",
|
||||
"duplicate.example ",
|
||||
remote_key.public_key.export,
|
||||
"Duplicate Old",
|
||||
"1.0.0",
|
||||
nil,
|
||||
nil,
|
||||
nil,
|
||||
nil,
|
||||
older_time,
|
||||
0,
|
||||
"sig-old",
|
||||
],
|
||||
)
|
||||
|
||||
db.execute(
|
||||
insert_sql,
|
||||
[
|
||||
"duplicate-new",
|
||||
"Duplicate.Example",
|
||||
remote_key.public_key.export,
|
||||
"Duplicate New",
|
||||
"2.0.0",
|
||||
nil,
|
||||
nil,
|
||||
nil,
|
||||
nil,
|
||||
newer_time,
|
||||
0,
|
||||
"sig-new",
|
||||
],
|
||||
)
|
||||
end
|
||||
|
||||
get "/api/instances"
|
||||
|
||||
expect(last_response).to be_ok
|
||||
payload = JSON.parse(last_response.body)
|
||||
duplicate_entries = payload.select { |entry| entry["domain"] == "duplicate.example" }
|
||||
|
||||
expect(duplicate_entries.size).to eq(1)
|
||||
expect(duplicate_entries.first["id"]).to eq("duplicate-new")
|
||||
|
||||
with_db(readonly: true) do |db|
|
||||
domains = db.execute(
|
||||
"SELECT domain FROM instances WHERE domain LIKE ? ORDER BY domain",
|
||||
["duplicate.example%"],
|
||||
).flatten
|
||||
|
||||
expect(domains).to eq(["duplicate.example"])
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe "POST /api/nodes" do
|
||||
|
||||
@@ -137,6 +137,38 @@ RSpec.describe PotatoMesh::Config do
|
||||
end
|
||||
end
|
||||
|
||||
describe ".remote_instance_http_timeout" do
|
||||
it "returns the baked-in connect timeout" do
|
||||
expect(described_class.remote_instance_http_timeout).to eq(
|
||||
PotatoMesh::Config::DEFAULT_REMOTE_INSTANCE_CONNECT_TIMEOUT,
|
||||
)
|
||||
end
|
||||
|
||||
it "ignores environment overrides" do
|
||||
within_env("REMOTE_INSTANCE_CONNECT_TIMEOUT" => "27") do
|
||||
expect(described_class.remote_instance_http_timeout).to eq(
|
||||
PotatoMesh::Config::DEFAULT_REMOTE_INSTANCE_CONNECT_TIMEOUT,
|
||||
)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe ".remote_instance_read_timeout" do
|
||||
it "returns the baked-in read timeout" do
|
||||
expect(described_class.remote_instance_read_timeout).to eq(
|
||||
PotatoMesh::Config::DEFAULT_REMOTE_INSTANCE_READ_TIMEOUT,
|
||||
)
|
||||
end
|
||||
|
||||
it "ignores environment overrides" do
|
||||
within_env("REMOTE_INSTANCE_READ_TIMEOUT" => "20") do
|
||||
expect(described_class.remote_instance_read_timeout).to eq(
|
||||
PotatoMesh::Config::DEFAULT_REMOTE_INSTANCE_READ_TIMEOUT,
|
||||
)
|
||||
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)
|
||||
|
||||
+115
-6
@@ -34,6 +34,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 +54,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
|
||||
@@ -113,10 +126,12 @@ RSpec.describe PotatoMesh::App::Federation do
|
||||
end
|
||||
|
||||
describe ".build_remote_http_client" do
|
||||
let(:timeout) { 15 }
|
||||
let(:connect_timeout) { 5 }
|
||||
let(:read_timeout) { 12 }
|
||||
|
||||
before do
|
||||
allow(PotatoMesh::Config).to receive(:remote_instance_http_timeout).and_return(timeout)
|
||||
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)
|
||||
end
|
||||
|
||||
it "configures SSL settings for HTTPS endpoints" do
|
||||
@@ -129,8 +144,8 @@ RSpec.describe PotatoMesh::App::Federation do
|
||||
http = federation_helpers.build_remote_http_client(uri)
|
||||
|
||||
expect(http.use_ssl?).to be(true)
|
||||
expect(http.open_timeout).to eq(timeout)
|
||||
expect(http.read_timeout).to eq(timeout)
|
||||
expect(http.open_timeout).to eq(connect_timeout)
|
||||
expect(http.read_timeout).to eq(read_timeout)
|
||||
expect(http.cert_store).to eq(store)
|
||||
expect(http.verify_mode).to eq(OpenSSL::SSL::VERIFY_PEER)
|
||||
expect(http.verify_callback).to eq(callback)
|
||||
@@ -146,8 +161,8 @@ RSpec.describe PotatoMesh::App::Federation do
|
||||
|
||||
expect(http.use_ssl?).to be(false)
|
||||
expect(http.cert_store).to be_nil
|
||||
expect(http.open_timeout).to eq(timeout)
|
||||
expect(http.read_timeout).to eq(timeout)
|
||||
expect(http.open_timeout).to eq(connect_timeout)
|
||||
expect(http.read_timeout).to eq(read_timeout)
|
||||
end
|
||||
|
||||
it "leaves the certificate store unset when unavailable" do
|
||||
@@ -161,4 +176,98 @@ RSpec.describe PotatoMesh::App::Federation do
|
||||
expect(http.verify_callback).to be_nil
|
||||
end
|
||||
end
|
||||
|
||||
describe ".perform_instance_http_request" do
|
||||
let(:uri) { URI.parse("https://remote.example.com/api") }
|
||||
let(:http_client) { instance_double(Net::HTTP) }
|
||||
|
||||
before do
|
||||
allow(federation_helpers).to receive(:build_remote_http_client).with(uri).and_return(http_client)
|
||||
end
|
||||
|
||||
it "wraps errors that omit a message with the error class name" do
|
||||
stub_const(
|
||||
"RemoteTcpFailure",
|
||||
Class.new(StandardError) do
|
||||
def message
|
||||
""
|
||||
end
|
||||
end,
|
||||
)
|
||||
|
||||
allow(http_client).to receive(:start).and_raise(RemoteTcpFailure.new)
|
||||
|
||||
expect do
|
||||
federation_helpers.send(:perform_instance_http_request, uri)
|
||||
end.to raise_error(PotatoMesh::App::InstanceFetchError, "RemoteTcpFailure")
|
||||
end
|
||||
|
||||
it "includes the error class name when the message omits it" do
|
||||
allow(http_client).to receive(:start).and_raise(OpenSSL::SSL::SSLError.new("handshake failed"))
|
||||
|
||||
expect do
|
||||
federation_helpers.send(:perform_instance_http_request, uri)
|
||||
end.to raise_error(
|
||||
PotatoMesh::App::InstanceFetchError,
|
||||
"OpenSSL::SSL::SSLError: handshake failed",
|
||||
)
|
||||
end
|
||||
|
||||
it "preserves messages that already include the error class" do
|
||||
allow(http_client).to receive(:start).and_raise(Net::ReadTimeout.new)
|
||||
|
||||
expect do
|
||||
federation_helpers.send(:perform_instance_http_request, uri)
|
||||
end.to raise_error(PotatoMesh::App::InstanceFetchError, "Net::ReadTimeout")
|
||||
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
|
||||
|
||||
@@ -35,9 +35,13 @@ RSpec.describe PotatoMesh::Sanitizer do
|
||||
end
|
||||
|
||||
it "normalises valid domains" do
|
||||
expect(described_class.sanitize_instance_domain(" Example.Org. ")).to eq("Example.Org")
|
||||
expect(described_class.sanitize_instance_domain(" Example.Org. ")).to eq("example.org")
|
||||
expect(described_class.sanitize_instance_domain("[::1]")).to eq("[::1]")
|
||||
end
|
||||
|
||||
it "preserves case when requested" do
|
||||
expect(described_class.sanitize_instance_domain("Mesh.Example", downcase: false)).to eq("Mesh.Example")
|
||||
end
|
||||
end
|
||||
|
||||
describe ".instance_domain_host" do
|
||||
|
||||
@@ -34,6 +34,7 @@ require "tmpdir"
|
||||
require "fileutils"
|
||||
|
||||
ENV["RACK_ENV"] = "test"
|
||||
ENV["INSTANCE_DOMAIN"] ||= "spec.mesh.test"
|
||||
|
||||
SPEC_TMPDIR = Dir.mktmpdir("potato-mesh-spec-")
|
||||
ENV["XDG_DATA_HOME"] = File.join(SPEC_TMPDIR, "xdg-data")
|
||||
|
||||
Reference in New Issue
Block a user