Compare commits

...

9 Commits

Author SHA1 Message Date
l5y e74f985630 matrix: fix docker build (#564) 2025-12-16 18:52:07 +01:00
l5y e4facd7f26 web: fix federation signature validation and create fallback (#563)
* web: fix federation signature validation and create fallback

* web: cover missing unit test vectors
2025-12-16 10:52:59 +01:00
l5y f533362f8a chore: update readme (#561) 2025-12-16 08:54:31 +01:00
l5y 175a8f368f matrix: add docker file for bridge (#556)
* matrix: add docker file for bridge

* matrix: address review comments

* matrix: address review comments

* matrix: address review comments

* matrix: address review comments

* matrix: address review comments
2025-12-16 08:53:01 +01:00
l5y 872bcbd529 matrix: add health checks to startup (#555)
* matrix: add health checks to startup

* matrix: address review comments

* matrix: cover missing unit test vectors

* matrix: cover missing unit test vectors
2025-12-15 22:53:32 +01:00
l5y 8811f71e53 matrix: omit the api part in base url (#554)
* matrix: omit the api part in base url

* matrix: address review comments
2025-12-15 22:04:01 +01:00
l5y fec649a159 app: add utility coverage tests for main.dart (#552)
* Add utility coverage tests for main.dart

* Add channel names to message sorting tests

* Fix MeshMessage sort test construction

* chore: run dart formatter
2025-12-15 11:03:51 +01:00
l5y 9e3f481401 Add unit tests for daemon helpers (#553) 2025-12-15 08:43:13 +01:00
l5y 1a497864a7 chore: bump version to 0.5.8 (#551)
* chore: bump version to 0.5.8

* chore: add missing license headers
2025-12-15 08:29:27 +01:00
27 changed files with 3709 additions and 120 deletions
+27 -5
View File
@@ -43,7 +43,7 @@ jobs:
strategy:
matrix:
service: [web, ingestor]
service: [web, ingestor, matrix-bridge]
architecture:
- { name: linux-amd64, platform: linux/amd64, label: "Linux x86_64", os: linux, architecture: amd64 }
- { name: linux-arm64, platform: linux/arm64, label: "Linux ARM64", os: linux, architecture: arm64 }
@@ -109,8 +109,8 @@ jobs:
uses: docker/build-push-action@v5
with:
context: .
file: ./${{ matrix.service == 'web' && 'web/Dockerfile' || 'data/Dockerfile' }}
target: production
file: ${{ matrix.service == 'web' && './web/Dockerfile' || matrix.service == 'ingestor' && './data/Dockerfile' || './matrix/Dockerfile' }}
target: ${{ matrix.service == 'matrix-bridge' && 'runtime' || 'production' }}
platforms: ${{ matrix.architecture.platform }}
push: true
tags: |
@@ -119,12 +119,12 @@ jobs:
${{ steps.tagging.outputs.include_latest == 'true' && format('{0}/{1}-{2}-{3}:latest', env.REGISTRY, env.IMAGE_PREFIX, matrix.service, matrix.architecture.name) || '' }}
labels: |
org.opencontainers.image.source=https://github.com/${{ github.repository }}
org.opencontainers.image.description=PotatoMesh ${{ matrix.service == 'web' && 'Web Application' || 'Python Ingestor' }} for ${{ matrix.architecture.label }}
org.opencontainers.image.description=PotatoMesh ${{ matrix.service == 'web' && 'Web Application' || matrix.service == 'ingestor' && 'Python Ingestor' || 'Matrix Bridge' }} for ${{ matrix.architecture.label }}
org.opencontainers.image.licenses=Apache-2.0
org.opencontainers.image.version=${{ steps.version.outputs.version }}
org.opencontainers.image.created=${{ github.event.head_commit.timestamp }}
org.opencontainers.image.revision=${{ github.sha }}
org.opencontainers.image.title=PotatoMesh ${{ matrix.service == 'web' && 'Web' || 'Ingestor' }} (${{ matrix.architecture.label }})
org.opencontainers.image.title=PotatoMesh ${{ matrix.service == 'web' && 'Web' || matrix.service == 'ingestor' && 'Ingestor' || 'Matrix Bridge' }} (${{ matrix.architecture.label }})
org.opencontainers.image.vendor=PotatoMesh
org.opencontainers.image.architecture=${{ matrix.architecture.architecture }}
org.opencontainers.image.os=${{ matrix.architecture.os }}
@@ -208,6 +208,19 @@ jobs:
VERSION=${GITHUB_REF#refs/tags/v}
echo "version=$VERSION" >> $GITHUB_OUTPUT
- name: Determine tagging strategy
id: tagging
run: |
VERSION="${{ steps.version.outputs.version }}"
if echo "$VERSION" | grep -E -- '-(rc|beta|alpha|dev)'; then
INCLUDE_LATEST=false
else
INCLUDE_LATEST=true
fi
echo "include_latest=$INCLUDE_LATEST" >> $GITHUB_OUTPUT
- name: Publish release summary
run: |
echo "## 🚀 PotatoMesh Images Published to GHCR" >> $GITHUB_STEP_SUMMARY
@@ -234,4 +247,13 @@ jobs:
echo "- \`${{ env.REGISTRY }}/${{ env.IMAGE_PREFIX }}-ingestor-linux-armv7:latest\` - Linux ARMv7" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
fi
# Matrix bridge images
echo "### 🧩 Matrix Bridge" >> $GITHUB_STEP_SUMMARY
if [ "${{ steps.tagging.outputs.include_latest }}" = "true" ]; then
echo "- \`${{ env.REGISTRY }}/${{ env.IMAGE_PREFIX }}-matrix-bridge-linux-amd64:latest\` - Linux x86_64" >> $GITHUB_STEP_SUMMARY
echo "- \`${{ env.REGISTRY }}/${{ env.IMAGE_PREFIX }}-matrix-bridge-linux-arm64:latest\` - Linux ARM64" >> $GITHUB_STEP_SUMMARY
echo "- \`${{ env.REGISTRY }}/${{ env.IMAGE_PREFIX }}-matrix-bridge-linux-armv7:latest\` - Linux ARMv7" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
fi
+4
View File
@@ -17,11 +17,15 @@ The repository splits runtime and ingestion logic. `web/` holds the Sinatra dash
`data/` hosts the Python Meshtastic ingestor plus migrations and CLI scripts. API fixtures and end-to-end harnesses live in `tests/`. Dockerfiles and compose files support containerized workflows.
`matrix/` contains the Rust Matrix bridge; build with `cargo build --release` or `docker build -f matrix/Dockerfile .`, and keep bridge config under `matrix/Config.toml` when running locally.
## Build, Test, and Development Commands
Run dependency installs inside `web/`: `bundle install` for gems and `npm ci` for JavaScript tooling. Start the app with `cd web && API_TOKEN=dev ./app.sh` for local work or `bundle exec rackup -p 41447` when integrating elsewhere.
Prep ingestion with `python -m venv .venv && pip install -r data/requirements.txt`; `./data/mesh.sh` streams from live radios. `docker-compose -f docker-compose.dev.yml up` brings up the full stack.
Container images publish via `.github/workflows/docker.yml` as `potato-mesh-{service}-linux-$arch` (`web`, `ingestor`, `matrix-bridge`), using the Dockerfiles in `web/`, `data/`, and `matrix/`.
## Coding Style & Naming Conventions
Use two-space indentation for Ruby and keep `# frozen_string_literal: true` at the top of new files. Keep Ruby classes/modules in `CamelCase`, filenames in `snake_case.rb`, and feature specs in `*_spec.rb`.
+62
View File
@@ -1,5 +1,67 @@
# CHANGELOG
## v0.5.7
* Data: track ingestors heartbeat by @l5yth in <https://github.com/l5yth/potato-mesh/pull/549>
* Harden instance selector navigation URLs by @l5yth in <https://github.com/l5yth/potato-mesh/pull/550>
* Data: hide channels that have been flag for ignoring by @l5yth in <https://github.com/l5yth/potato-mesh/pull/548>
* Web: fix limit when counting remote nodes by @l5yth in <https://github.com/l5yth/potato-mesh/pull/547>
* Web: improve instances map and table view by @l5yth in <https://github.com/l5yth/potato-mesh/pull/546>
* Web: fix traces submission with optional fields on udp by @l5yth in <https://github.com/l5yth/potato-mesh/pull/545>
* Chore: bump version to 0.5.7 by @l5yth in <https://github.com/l5yth/potato-mesh/pull/542>
* Handle zero telemetry aggregates by @l5yth in <https://github.com/l5yth/potato-mesh/pull/538>
* Web: fix telemetry api to return current in amperes by @l5yth in <https://github.com/l5yth/potato-mesh/pull/541>
* Web: fix traces rendering by @l5yth in <https://github.com/l5yth/potato-mesh/pull/535>
* Normalize numeric node roles to canonical labels by @l5yth in <https://github.com/l5yth/potato-mesh/pull/539>
* Use INSTANCE_DOMAIN env for ingestor by @l5yth in <https://github.com/l5yth/potato-mesh/pull/536>
* Web: further refine the federation page by @l5yth in <https://github.com/l5yth/potato-mesh/pull/534>
* Add Federation Map by @apo-mak in <https://github.com/l5yth/potato-mesh/pull/532>
* Add contact link to the instance data by @apo-mak in <https://github.com/l5yth/potato-mesh/pull/533>
* Matrix: create potato-matrix-bridge by @l5yth in <https://github.com/l5yth/potato-mesh/pull/528>
## v0.5.6
* Web: display sats in view by @l5yth in <https://github.com/l5yth/potato-mesh/pull/523>
* Web: display air quality in separate chart by @l5yth in <https://github.com/l5yth/potato-mesh/pull/521>
* Ci: Add macOS and Ubuntu builds to Flutter workflow by @l5yth in <https://github.com/l5yth/potato-mesh/pull/519>
* Web: add current to charts by @l5yth in <https://github.com/l5yth/potato-mesh/pull/520>
* App: fix notification icon by @l5yth in <https://github.com/l5yth/potato-mesh/pull/518>
* Spec: update test fixtures by @l5yth in <https://github.com/l5yth/potato-mesh/pull/517>
* App: generate proper icons by @l5yth in <https://github.com/l5yth/potato-mesh/pull/516>
* Web: fix favicon by @l5yth in <https://github.com/l5yth/potato-mesh/pull/515>
* Web: add ?since= parameter to api/messages by @l5yth in <https://github.com/l5yth/potato-mesh/pull/512>
* App: implement notifications by @l5yth in <https://github.com/l5yth/potato-mesh/pull/511>
* App: add theme selector by @l5yth in <https://github.com/l5yth/potato-mesh/pull/507>
* App: further harden refresh logic and prefer local first by @l5yth in <https://github.com/l5yth/potato-mesh/pull/506>
* Ci: fix app artifacts for tags by @l5yth in <https://github.com/l5yth/potato-mesh/pull/504>
* Ci: build app artifacts for tags by @l5yth in <https://github.com/l5yth/potato-mesh/pull/503>
* App: add persistance by @l5yth in <https://github.com/l5yth/potato-mesh/pull/501>
* App: instance and chat mvp by @l5yth in <https://github.com/l5yth/potato-mesh/pull/498>
* App: add instance selector to settings by @l5yth in <https://github.com/l5yth/potato-mesh/pull/497>
* App: add scaffholding gitignore by @l5yth in <https://github.com/l5yth/potato-mesh/pull/496>
* Handle reaction app packets without reply id by @l5yth in <https://github.com/l5yth/potato-mesh/pull/495>
* Render reaction multiplier counts by @l5yth in <https://github.com/l5yth/potato-mesh/pull/494>
* Add comprehensive tests for Flutter reader by @l5yth in <https://github.com/l5yth/potato-mesh/pull/491>
* Map numeric role ids to canonical Meshtastic roles by @l5yth in <https://github.com/l5yth/potato-mesh/pull/489>
* Update node detail hydration for traces by @l5yth in <https://github.com/l5yth/potato-mesh/pull/490>
* Add mobile Flutter CI workflow by @l5yth in <https://github.com/l5yth/potato-mesh/pull/488>
* Align OCI labels in docker workflow by @l5yth in <https://github.com/l5yth/potato-mesh/pull/487>
* Add Meshtastic reader Flutter app by @l5yth in <https://github.com/l5yth/potato-mesh/pull/483>
* Handle pre-release Docker tagging by @l5yth in <https://github.com/l5yth/potato-mesh/pull/486>
* Web: remove range from charts labels by @l5yth in <https://github.com/l5yth/potato-mesh/pull/485>
* Floor override frequencies to MHz integers by @l5yth in <https://github.com/l5yth/potato-mesh/pull/476>
* Prevent message ids from being treated as node identifiers by @l5yth in <https://github.com/l5yth/potato-mesh/pull/475>
* Fix 1 after emojis in reply. by @Alexkurd in <https://github.com/l5yth/potato-mesh/pull/464>
* Add frequency and preset to node table by @l5yth in <https://github.com/l5yth/potato-mesh/pull/472>
* Subscribe to traceroute app pubsub topic by @l5yth in <https://github.com/l5yth/potato-mesh/pull/471>
* Aggregate telemetry over the last 7 days by @l5yth in <https://github.com/l5yth/potato-mesh/pull/470>
* Address missing id field ingestor bug by @l5yth in <https://github.com/l5yth/potato-mesh/pull/469>
* Merge secondary channels by name by @l5yth in <https://github.com/l5yth/potato-mesh/pull/468>
* Rate limit host device telemetry by @l5yth in <https://github.com/l5yth/potato-mesh/pull/467>
* Add traceroutes to frontend by @l5yth in <https://github.com/l5yth/potato-mesh/pull/466>
* Feat: implement traceroute app packet handling across the stack by @l5yth in <https://github.com/l5yth/potato-mesh/pull/463>
* Bump version and update changelog by @l5yth in <https://github.com/l5yth/potato-mesh/pull/462>
## v0.5.5
* Added comprehensive helper unit tests by @l5yth in <https://github.com/l5yth/potato-mesh/pull/457>
+24 -7
View File
@@ -7,13 +7,20 @@
[![Contributions Welcome](https://img.shields.io/badge/contributions-welcome-brightgreen.svg?style=flat)](https://github.com/l5yth/potato-mesh/issues)
[![Matrix Chat](https://img.shields.io/badge/matrix-%23potatomesh:dod.ngo-blue)](https://matrix.to/#/#potatomesh:dod.ngo)
A federated Meshtastic-powered node dashboard for your local community. _No MQTT clutter, just local LoRa aether._
A federated, Meshtastic-powered node dashboard for your local community.
_No MQTT clutter, just local LoRa aether._
* Web app with chat window and map view showing nodes, neighbors, telemetry, and messages.
* API to POST (authenticated) and to GET nodes and messages.
* Shows new node notifications (first seen) in chat.
* Web dashboard with chat window and map view showing nodes, positions, neighbors,
trace routes, telemetry, and messages.
* API to POST (authenticated) and to GET nodes, messages, and telemetry.
* Shows new node notifications (first seen) and telemetry logs in chat.
* Allows searching and filtering for nodes in map and table view.
* Federated: _automatically_ froms a federation with other communities running
Potato Mesh!
* Supplemental Python ingestor to feed the POST APIs of the Web app with data remotely.
* Supports multiple ingestors per instance.
* Matrix bridge that posts Meshtastic messages to a defined matrix channel (no
radio required).
* Mobile app to _read_ messages on your local aether (no radio required).
Live demo for Berlin #MediumFast: [potatomesh.net](https://potatomesh.net)
@@ -58,6 +65,7 @@ RACK_ENV="production" \
APP_ENV="production" \
API_TOKEN="SuperSecureTokenReally" \
INSTANCE_DOMAIN="https://potatomesh.net" \
MAP_CENTER="53.55,13.42" \
exec ruby app.rb -p 41447 -o 0.0.0.0
```
@@ -68,6 +76,7 @@ exec ruby app.rb -p 41447 -o 0.0.0.0
* Provide a strong `API_TOKEN` value to authorize POST requests against the API.
* Configure `INSTANCE_DOMAIN` with the public URL of your deployment so vanity
links and generated metadata resolve correctly.
* Don't forget to set a `MAP_CENTER` to point to your local region.
The web app can be configured with environment variables (defaults shown):
@@ -134,7 +143,9 @@ The web app contains an API:
* GET `/api/messages?limit=100&encrypted=false&since=0` - returns the latest 100 messages newer than the provided unix timestamp (defaults to `since=0` to return full history; 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 `/api/traces?limit=100` - returns the latest 100 trace-routes caught
* GET `/api/instances` - returns known potato-mesh instances in other locations
* GET `/api/ingestors` - returns active potato-mesh python ingestors that feed data
* 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>`)
@@ -142,6 +153,7 @@ The web app contains an API:
* POST `/api/messages` - appends messages provided as a JSON object or array (requires `Authorization: Bearer <API_TOKEN>`; disabled when `PRIVATE=1`)
* POST `/api/telemetry` - appends telemetry provided as a JSON object or array (requires `Authorization: Bearer <API_TOKEN>`)
* POST `/api/neighbors` - appends neighbor tuples provided as a JSON object or array (requires `Authorization: Bearer <API_TOKEN>`)
* POST `/api/traces` - appends caught traces routes provided as a JSON object or array (requires `Authorization: Bearer <API_TOKEN>`)
The `API_TOKEN` environment variable must be set to a non-empty value and match the token supplied in the `Authorization` header for `POST` requests.
@@ -190,9 +202,7 @@ node records and parsing new incoming messages. Enable debug output with `DEBUG=
specify the connection target with `CONNECTION` (default `/dev/ttyACM0`) or set it to
an IP address (for example `192.168.1.20:4403`) to use the Meshtastic TCP
interface. `CONNECTION` also accepts Bluetooth device addresses (e.g.,
`ED:4D:9E:95:CF:60`) and the script attempts a BLE connection if available. The
ingestor will still honor the legacy `POTATOMESH_INSTANCE` variable when
`INSTANCE_DOMAIN` is unset to ease upgrades from earlier deployments. To keep
`ED:4D:9E:95:CF:60`) and the script attempts a BLE connection if available. To keep
private channels out of the web UI, set `HIDDEN_CHANNELS` to a comma-separated
list of channel names (for example `HIDDEN_CHANNELS="Secret,Ops"`); packets on
those channels are discarded instead of being sent to `/api/messages`.
@@ -205,12 +215,19 @@ Docker images are published on Github for each release:
docker pull ghcr.io/l5yth/potato-mesh/web:latest # newest release
docker pull ghcr.io/l5yth/potato-mesh/web:v0.5.5 # pinned historical release
docker pull ghcr.io/l5yth/potato-mesh/ingestor:latest
docker pull ghcr.io/l5yth/potato-mesh/matrix-bridge:latest
```
Feel free to run the [configure.sh](./configure.sh) script to set up your
environment. See the [Docker guide](DOCKER.md) for more details and custom
deployment instructions.
## Matrix Bridge
A matrix bridge is currently being worked on. It requests messages from a configured
potato-mesh instance and forwards it to a specified matrix channel; see
[matrix/README.md](./matrix/README.md).
## Mobile App
A mobile _reader_ app is currently being worked on. Stay tuned for releases and updates.
+2 -2
View File
@@ -15,11 +15,11 @@
<key>CFBundlePackageType</key>
<string>FMWK</string>
<key>CFBundleShortVersionString</key>
<string>0.5.7</string>
<string>0.5.8</string>
<key>CFBundleSignature</key>
<string>????</string>
<key>CFBundleVersion</key>
<string>0.5.7</string>
<string>0.5.8</string>
<key>MinimumOSVersion</key>
<string>14.0</string>
</dict>
+1 -1
View File
@@ -1,7 +1,7 @@
name: potato_mesh_reader
description: Meshtastic Reader — read-only view for PotatoMesh messages.
publish_to: "none"
version: 0.5.7
version: 0.5.8
environment:
sdk: ">=3.4.0 <4.0.0"
+128
View File
@@ -0,0 +1,128 @@
// Copyright © 2025-26 l5yth & contributors
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:potato_mesh_reader/main.dart';
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
test('BootstrapProgress renders stage, counts, and detail', () {
const progress = BootstrapProgress(
stage: 'Downloading',
current: 2,
total: 5,
detail: 'instances',
);
expect(progress.label, 'Downloading 2/5 • instances');
const fallback = BootstrapProgress(stage: 'Starting');
expect(fallback.label, 'Starting');
});
test('InstanceVersion summary prefers populated fields', () {
const populated = InstanceVersion(
name: 'BerlinMesh',
channel: '#MediumFast',
frequency: '868MHz',
instanceDomain: 'potatomesh.net',
);
expect(populated.summary, 'BerlinMesh · #MediumFast · 868MHz');
const minimal = InstanceVersion(
name: '',
channel: null,
frequency: null,
instanceDomain: null,
);
expect(minimal.summary, 'Unknown');
});
test('sortMessagesByRxTime keeps unknown timestamps in place', () {
MeshMessage buildMessage({
required int id,
required String text,
required String rxIso,
DateTime? rxTime,
}) {
return MeshMessage(
id: id,
rxTime: rxTime,
rxIso: rxIso,
fromId: '!$id',
nodeId: '!$id',
toId: '^',
channelName: '#general',
channel: 1,
portnum: 'TEXT',
text: text,
rssi: -50,
snr: 1.0,
hopLimit: 1,
);
}
final withTime = buildMessage(
id: 2,
rxTime: DateTime.utc(2024, 1, 1, 12, 1),
rxIso: '2024-01-01T12:01:00Z',
text: 'timed',
);
final withoutTime = buildMessage(
id: 1,
rxTime: null,
rxIso: 'unknown',
text: 'unknown',
);
final laterTime = buildMessage(
id: 3,
rxTime: DateTime.utc(2024, 1, 1, 12, 5),
rxIso: '2024-01-01T12:05:00Z',
text: 'later',
);
final sorted = sortMessagesByRxTime([withoutTime, laterTime, withTime]);
expect(sorted.first.id, withoutTime.id,
reason: 'messages without rxTime should retain position');
expect(sorted[1].id, withTime.id,
reason: 'messages with timestamps should be ordered chronologically');
expect(sorted.last.id, laterTime.id);
});
testWidgets('LoadingScreen displays progress label and icon', (tester) async {
const screen = LoadingScreen(
progress: BootstrapProgress(stage: 'Fetching'),
);
await tester.pumpWidget(const MaterialApp(home: screen));
expect(find.byType(CircularProgressIndicator), findsOneWidget);
expect(find.text('Fetching'), findsOneWidget);
expect(find.bySemanticsLabel('PotatoMesh'), findsOneWidget);
});
testWidgets('LoadingScreen surfaces errors', (tester) async {
const screen = LoadingScreen(
progress: BootstrapProgress(stage: 'Loading'),
error: 'boom',
);
await tester.pumpWidget(const MaterialApp(home: screen));
expect(find.textContaining('Failed to load: boom'), findsOneWidget);
});
}
+1 -1
View File
@@ -18,7 +18,7 @@ The ``data.mesh`` module exposes helpers for reading Meshtastic node and
message information before forwarding it to the accompanying web application.
"""
VERSION = "0.5.7"
VERSION = "0.5.8"
"""Semantic version identifier shared with the dashboard and front-end."""
__version__ = VERSION
+35
View File
@@ -76,6 +76,21 @@ x-ingestor-base: &ingestor-base
memory: 128M
cpus: '0.1'
x-matrix-bridge-base: &matrix-bridge-base
image: ghcr.io/l5yth/potato-mesh-matrix-bridge-${POTATOMESH_IMAGE_ARCH:-linux-amd64}:${POTATOMESH_IMAGE_TAG:-latest}
volumes:
- potatomesh_matrix_bridge_state:/app
- ./matrix/Config.toml:/app/Config.toml:ro
restart: unless-stopped
deploy:
resources:
limits:
memory: 128M
cpus: '0.1'
reservations:
memory: 64M
cpus: '0.05'
services:
web:
<<: *web-base
@@ -109,6 +124,24 @@ services:
profiles:
- bridge
matrix-bridge:
<<: *matrix-bridge-base
network_mode: host
depends_on:
- web
extra_hosts:
- "web:127.0.0.1"
matrix-bridge-bridge:
<<: *matrix-bridge-base
container_name: potatomesh-matrix-bridge
networks:
- potatomesh-network
depends_on:
- web-bridge
profiles:
- bridge
volumes:
potatomesh_data:
driver: local
@@ -116,6 +149,8 @@ volumes:
driver: local
potatomesh_logs:
driver: local
potatomesh_matrix_bridge_state:
driver: local
networks:
potatomesh-network:
+1 -1
View File
@@ -1,3 +1,3 @@
target/
Cargo.lock
coverage.lcov
bridge_state.json
+2117
View File
File diff suppressed because it is too large Load Diff
+16 -2
View File
@@ -1,6 +1,20 @@
# Copyright © 2025-26 l5yth & contributors
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
[package]
name = "potatomesh-matrix-bridge"
version = "0.5.7"
version = "0.5.8"
edition = "2021"
[dependencies]
@@ -17,4 +31,4 @@ urlencoding = "2"
[dev-dependencies]
tempfile = "3"
mockito = "1"
serial_test = "3"
serial_test = "3"
+7 -6
View File
@@ -1,19 +1,20 @@
[potatomesh]
# Base URL without trailing slash
base_url = "https://potatomesh.net/api"
# Base domain (with or without trailing slash)
base_url = "https://potatomesh.net"
# Poll interval in seconds
poll_interval_secs = 60
[matrix]
# Homeserver base URL (client API) without trailing slash
homeserver = "https://matrix.example.org"
homeserver = "https://matrix.dod.ngo"
# Appservice access token (from your registration.yaml)
as_token = "YOUR_APPSERVICE_AS_TOKEN"
as_token = "INVALID_TOKEN_NOT_WORKING"
# Server name (domain) part of Matrix user IDs
server_name = "example.org"
server_name = "dod.ngo"
# Room ID to send into (must be joined by the appservice / puppets)
room_id = "!yourroomid:example.org"
room_id = "!sXabOBXbVObAlZQEUs:c-base.org" # "#potato-bridge:c-base.org"
[state]
# Where to persist last seen message id (optional but recommended)
state_file = "bridge_state.json"
+42
View File
@@ -0,0 +1,42 @@
# Copyright © 2025-26 l5yth & contributors
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
FROM rust:1.79-bookworm AS builder
WORKDIR /app
COPY matrix/Cargo.toml matrix/Cargo.lock ./
COPY matrix/src ./src
RUN --mount=type=cache,target=/usr/local/cargo/registry \
--mount=type=cache,target=/usr/local/cargo/git \
cargo build --release --locked
FROM debian:bookworm-slim AS runtime
RUN apt-get update \
&& apt-get install -y --no-install-recommends ca-certificates gosu \
&& rm -rf /var/lib/apt/lists/*
RUN useradd --create-home --uid 10001 --shell /usr/sbin/nologin potatomesh
WORKDIR /app
COPY --from=builder /app/target/release/potatomesh-matrix-bridge /usr/local/bin/potatomesh-matrix-bridge
COPY matrix/Config.toml /app/Config.example.toml
COPY matrix/docker-entrypoint.sh /usr/local/bin/docker-entrypoint.sh
RUN chmod +x /usr/local/bin/docker-entrypoint.sh
ENTRYPOINT ["/usr/local/bin/docker-entrypoint.sh"]
+46 -16
View File
@@ -4,7 +4,7 @@ A small Rust daemon that bridges **PotatoMesh** LoRa messages into a **Matrix**
For each PotatoMesh node, the bridge creates (or uses) a **Matrix puppet user**:
- Matrix localpart: the hex node id (without `!`), e.g. `!67fc83cb``@67fc83cb:example.org`
- Matrix localpart: `potato_` + the hex node id (without `!`), e.g. `!67fc83cb``@potato_67fc83cb:example.org`
- Matrix display name: the nodes `long_name` from the PotatoMesh API
Messages from PotatoMesh are periodically fetched and forwarded to a single Matrix room as those puppet users.
@@ -13,10 +13,10 @@ Messages from PotatoMesh are periodically fetched and forwarded to a single Matr
## Features
- Polls `https://potatomesh.net/api/messages` (or any configured base URL)
- Looks up node metadata via `GET /nodes/{hex}` and caches it
- Polls `https://potatomesh.net/api/messages` (deriving `/api` from the configured base domain)
- Looks up node metadata via `GET /api/nodes/{hex}` and caches it
- One Matrix user per node:
- username: hex node id
- username: `potato_{hex node id}`
- display name: `long_name`
- Forwards `TEXT_MESSAGE_APP` messages into a single Matrix room
- Persists last-seen message ID to avoid duplicates across restarts
@@ -26,12 +26,12 @@ Messages from PotatoMesh are periodically fetched and forwarded to a single Matr
## Architecture Overview
- **PotatoMesh side**
- `GET /messages` returns an array of messages
- `GET /nodes/{hex}` returns node metadata (including `long_name`)
- `GET /api/messages` returns an array of messages
- `GET /api/nodes/{hex}` returns node metadata (including `long_name`)
- **Matrix side**
- Uses the Matrix Client-Server API with an **appservice access token**
- Impersonates puppet users via `user_id=@{hex}:{server_name}&access_token={as_token}`
- Impersonates puppet users via `user_id=@potato_{hex}:{server_name}&access_token={as_token}`
- Sends `m.room.message` events into a configured room
This is **not** a full appservice framework; it just speaks the minimal HTTP needed.
@@ -43,11 +43,11 @@ This is **not** a full appservice framework; it just speaks the minimal HTTP nee
- Rust (stable) and `cargo`
- A Matrix homeserver you control (e.g. Synapse)
- An **application service registration** on your homeserver that:
- Whitelists the puppet user namespace (e.g. `@[0-9a-f]{8}:example.org`)
- Whitelists the puppet user namespace (e.g. `@potato_[0-9a-f]{8}:example.org`)
- Provides an `as_token` the bridge can use
- Network access from the bridge host to:
- `https://potatomesh.net/api` (or your configured PotatoMesh API)
- `https://potatomesh.net/` (bridge appends `/api`)
- Your Matrix homeserver (`https://matrix.example.org`)
---
@@ -60,8 +60,8 @@ Example:
```toml
[potatomesh]
# Base URL without trailing slash
base_url = "https://potatomesh.net/api"
# Base domain (bridge will call {base_url}/api)
base_url = "https://potatomesh.net/"
# Poll interval in seconds
poll_interval_secs = 10
@@ -84,7 +84,7 @@ state_file = "bridge_state.json"
The bridge assumes:
* Messages: `GET {base_url}/messages` JSON array, for example:
* Messages: `GET {base_url}/api/messages` JSON array, for example:
```json
[
@@ -108,7 +108,7 @@ The bridge assumes:
]
```
* Nodes: `GET {base_url}/nodes/{hex}` JSON, for example:
* Nodes: `GET {base_url}/api/nodes/{hex}` JSON, for example:
```json
{
@@ -122,7 +122,7 @@ The bridge assumes:
}
```
Node hex ID is derived from `node_id` by stripping the leading `!` and using the remainder as the Matrix localpart.
Node hex ID is derived from `node_id` by stripping the leading `!` and using the remainder inside the puppet localpart prefix (`potato_{hex}`).
---
@@ -142,7 +142,7 @@ rate_limited: false
namespaces:
users:
- exclusive: true
regex: "@[0-9a-f]{8}:example.org"
regex: "@potato_[0-9a-f]{8}:example.org"
```
For this bridge, only the `as_token` and `namespaces.users` actually matter. The bridge does not accept inbound events; it only uses the `as_token` to call the homeserver.
@@ -170,6 +170,36 @@ target/release/potatomesh-matrix-bridge
---
## Docker
Build the container from the repo root with the included `matrix/Dockerfile`:
```bash
docker build -f matrix/Dockerfile -t potatomesh-matrix-bridge .
```
Provide your config at `/app/Config.toml` and persist the bridge state file by mounting volumes. Minimal example:
```bash
docker run --rm \
-v bridge_state:/app \
-v "$(pwd)/matrix/Config.toml:/app/Config.toml:ro" \
potatomesh-matrix-bridge
```
If you prefer to isolate the state file from the config, mount it directly instead of the whole `/app` directory:
```bash
docker run --rm \
-v bridge_state:/app \
-v "$(pwd)/matrix/Config.toml:/app/Config.toml:ro" \
potatomesh-matrix-bridge
```
The image ships `Config.example.toml` for reference, but the bridge will exit if `/app/Config.toml` is not provided.
---
## Run
Ensure `Config.toml` is present and valid, then:
@@ -193,7 +223,7 @@ The bridge will:
3. For each new `TEXT_MESSAGE_APP`:
* Fetch node info.
* Ensure puppet is registered (`@{hex}:{server_name}`).
* Ensure puppet is registered (`@potato_{hex}:{server_name}`).
* Set puppet display name to `long_name`.
* Send a formatted text message into `room_id` as that puppet.
* Update and persist `bridge_state.json`.
+33
View File
@@ -0,0 +1,33 @@
#!/bin/sh
# Copyright © 2025-26 l5yth & contributors
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
set -e
# Default state file path from Config.toml unless overridden.
STATE_FILE="${STATE_FILE:-/app/bridge_state.json}"
STATE_DIR="$(dirname "$STATE_FILE")"
# Ensure state directory exists and is writable by the non-root user without
# touching the read-only config bind mount.
if [ ! -d "$STATE_DIR" ]; then
mkdir -p "$STATE_DIR"
fi
# Best-effort ownership fix; ignore if the underlying volume is read-only.
chown potatomesh:potatomesh "$STATE_DIR" 2>/dev/null || true
touch "$STATE_FILE" 2>/dev/null || true
chown potatomesh:potatomesh "$STATE_FILE" 2>/dev/null || true
exec gosu potatomesh potatomesh-matrix-bridge "$@"
+18 -4
View File
@@ -1,3 +1,17 @@
// Copyright © 2025-26 l5yth & contributors
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
use serde::Deserialize;
use std::{fs, path::Path};
@@ -53,7 +67,7 @@ mod tests {
fn parse_minimal_config_from_toml_str() {
let toml_str = r#"
[potatomesh]
base_url = "https://potatomesh.net/api"
base_url = "https://potatomesh.net/"
poll_interval_secs = 10
[matrix]
@@ -67,7 +81,7 @@ mod tests {
"#;
let cfg: Config = toml::from_str(toml_str).expect("toml should parse");
assert_eq!(cfg.potatomesh.base_url, "https://potatomesh.net/api");
assert_eq!(cfg.potatomesh.base_url, "https://potatomesh.net/");
assert_eq!(cfg.potatomesh.poll_interval_secs, 10);
assert_eq!(cfg.matrix.homeserver, "https://matrix.example.org");
@@ -88,7 +102,7 @@ mod tests {
fn load_from_file_valid_file() {
let toml_str = r#"
[potatomesh]
base_url = "https://potatomesh.net/api"
base_url = "https://potatomesh.net/"
poll_interval_secs = 10
[matrix]
@@ -120,7 +134,7 @@ mod tests {
fn from_default_path_found() {
let toml_str = r#"
[potatomesh]
base_url = "https://potatomesh.net/api"
base_url = "https://potatomesh.net/"
poll_interval_secs = 10
[matrix]
+246 -39
View File
@@ -1,3 +1,17 @@
// Copyright © 2025-26 l5yth & contributors
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
mod config;
mod matrix;
mod potatomesh;
@@ -10,11 +24,12 @@ use tracing::{error, info};
use crate::config::Config;
use crate::matrix::MatrixAppserviceClient;
use crate::potatomesh::{PotatoClient, PotatoMessage};
use crate::potatomesh::{FetchParams, PotatoClient, PotatoMessage};
#[derive(Debug, serde::Serialize, serde::Deserialize, Default)]
pub struct BridgeState {
last_message_id: Option<u64>,
last_checked_at: Option<u64>,
}
impl BridgeState {
@@ -48,6 +63,92 @@ impl BridgeState {
}
}
fn build_fetch_params(state: &BridgeState) -> FetchParams {
if state.last_message_id.is_none() {
FetchParams {
limit: None,
since: None,
}
} else if let Some(ts) = state.last_checked_at {
FetchParams {
limit: None,
since: Some(ts),
}
} else {
FetchParams {
limit: Some(10),
since: None,
}
}
}
fn update_checkpoint(state: &mut BridgeState, delivered_all: bool, now_secs: u64) -> bool {
if !delivered_all {
return false;
}
if state.last_message_id.is_some() {
state.last_checked_at = Some(now_secs);
true
} else {
false
}
}
async fn poll_once(
potato: &PotatoClient,
matrix: &MatrixAppserviceClient,
state: &mut BridgeState,
state_path: &str,
now_secs: u64,
) {
let params = build_fetch_params(state);
match potato.fetch_messages(params).await {
Ok(mut msgs) => {
// sort by id ascending so we process in order
msgs.sort_by_key(|m| m.id);
let mut delivered_all = true;
for msg in &msgs {
if !state.should_forward(msg) {
continue;
}
// Filter to the ports you care about
if let Some(port) = &msg.portnum {
if port != "TEXT_MESSAGE_APP" {
state.update_with(msg);
continue;
}
}
if let Err(e) = handle_message(potato, matrix, state, msg).await {
error!("Error handling message {}: {:?}", msg.id, e);
delivered_all = false;
continue;
}
// persist after each processed message
if let Err(e) = state.save(state_path) {
error!("Error saving state: {:?}", e);
}
}
// Only advance checkpoint after successful delivery and a known last_message_id.
if update_checkpoint(state, delivered_all, now_secs) {
if let Err(e) = state.save(state_path) {
error!("Error saving state: {:?}", e);
}
}
}
Err(e) => {
error!("Error fetching PotatoMesh messages: {:?}", e);
}
}
}
#[tokio::main]
async fn main() -> Result<()> {
// Logging: RUST_LOG=info,bridge=debug,reqwest=warn ...
@@ -64,7 +165,9 @@ async fn main() -> Result<()> {
let http = reqwest::Client::builder().build()?;
let potato = PotatoClient::new(http.clone(), cfg.potatomesh.clone());
potato.health_check().await?;
let matrix = MatrixAppserviceClient::new(http.clone(), cfg.matrix.clone());
matrix.health_check().await?;
let state_path = &cfg.state.state_file;
let mut state = BridgeState::load(state_path)?;
@@ -73,36 +176,12 @@ async fn main() -> Result<()> {
let poll_interval = Duration::from_secs(cfg.potatomesh.poll_interval_secs);
loop {
match potato.fetch_messages().await {
Ok(mut msgs) => {
// sort by id ascending so we process in order
msgs.sort_by_key(|m| m.id);
let now_secs = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_secs();
for msg in msgs {
if !state.should_forward(&msg) {
continue;
}
// Filter to the ports you care about
if msg.portnum != "TEXT_MESSAGE_APP" {
state.update_with(&msg);
continue;
}
if let Err(e) = handle_message(&potato, &matrix, &mut state, &msg).await {
error!("Error handling message {}: {:?}", msg.id, e);
}
// persist after each processed message
if let Err(e) = state.save(state_path) {
error!("Error saving state: {:?}", e);
}
}
}
Err(e) => {
error!("Error fetching PotatoMesh messages: {:?}", e);
}
}
poll_once(&potato, &matrix, &mut state, state_path, now_secs).await;
sleep(poll_interval).await;
}
@@ -129,13 +208,19 @@ async fn handle_message(
.unwrap_or_else(|| node.long_name.clone());
let body = format!(
"[{short}] {text}\n({from_id}{to_id}, RSSI {rssi} dB, SNR {snr} dB, {chan}/{preset})",
"[{short}] {text}\n({from_id}{to_id}, {rssi}, {snr}, {chan}/{preset})",
short = short,
text = msg.text,
from_id = msg.from_id,
to_id = msg.to_id,
rssi = msg.rssi,
snr = msg.snr,
rssi = msg
.rssi
.map(|v| format!("RSSI {v} dB"))
.unwrap_or_else(|| "RSSI n/a".to_string()),
snr = msg
.snr
.map(|v| format!("SNR {v} dB"))
.unwrap_or_else(|| "SNR n/a".to_string()),
chan = msg.channel_name,
preset = msg.modem_preset,
);
@@ -161,14 +246,14 @@ mod tests {
from_id: "!abcd1234".to_string(),
to_id: "^all".to_string(),
channel: 1,
portnum: "TEXT_MESSAGE_APP".to_string(),
portnum: Some("TEXT_MESSAGE_APP".to_string()),
text: "Ping".to_string(),
rssi: -100,
hop_limit: 1,
rssi: Some(-100),
hop_limit: Some(1),
lora_freq: 868,
modem_preset: "MediumFast".to_string(),
channel_name: "TEST".to_string(),
snr: 0.0,
snr: Some(0.0),
reply_id: None,
node_id: "!abcd1234".to_string(),
}
@@ -209,6 +294,7 @@ mod tests {
fn bridge_state_update_is_monotonic() {
let mut state = BridgeState {
last_message_id: Some(50),
last_checked_at: None,
};
let m = sample_msg(40);
@@ -225,11 +311,13 @@ mod tests {
let state = BridgeState {
last_message_id: Some(12345),
last_checked_at: Some(99),
};
state.save(path_str).unwrap();
let loaded_state = BridgeState::load(path_str).unwrap();
assert_eq!(loaded_state.last_message_id, Some(12345));
assert_eq!(loaded_state.last_checked_at, Some(99));
}
#[test]
@@ -240,6 +328,125 @@ mod tests {
let state = BridgeState::load(path_str).unwrap();
assert_eq!(state.last_message_id, None);
assert_eq!(state.last_checked_at, None);
}
#[test]
fn update_checkpoint_requires_last_message_id() {
let mut state = BridgeState {
last_message_id: None,
last_checked_at: Some(10),
};
let saved = update_checkpoint(&mut state, true, 123);
assert!(!saved);
assert_eq!(state.last_checked_at, Some(10));
}
#[test]
fn update_checkpoint_skips_when_not_delivered() {
let mut state = BridgeState {
last_message_id: Some(5),
last_checked_at: Some(10),
};
let saved = update_checkpoint(&mut state, false, 123);
assert!(!saved);
assert_eq!(state.last_checked_at, Some(10));
}
#[test]
fn update_checkpoint_sets_when_safe() {
let mut state = BridgeState {
last_message_id: Some(5),
last_checked_at: None,
};
let saved = update_checkpoint(&mut state, true, 123);
assert!(saved);
assert_eq!(state.last_checked_at, Some(123));
}
#[test]
fn fetch_params_respects_missing_last_message_id() {
let state = BridgeState {
last_message_id: None,
last_checked_at: Some(123),
};
let params = build_fetch_params(&state);
assert_eq!(params.limit, None);
assert_eq!(params.since, None);
}
#[test]
fn fetch_params_uses_since_when_safe() {
let state = BridgeState {
last_message_id: Some(1),
last_checked_at: Some(123),
};
let params = build_fetch_params(&state);
assert_eq!(params.limit, None);
assert_eq!(params.since, Some(123));
}
#[test]
fn fetch_params_defaults_to_small_window() {
let state = BridgeState {
last_message_id: Some(1),
last_checked_at: None,
};
let params = build_fetch_params(&state);
assert_eq!(params.limit, Some(10));
assert_eq!(params.since, None);
}
#[tokio::test]
async fn poll_once_persists_checkpoint_without_messages() {
let tmp_dir = tempfile::tempdir().unwrap();
let state_path = tmp_dir.path().join("state.json");
let state_str = state_path.to_str().unwrap();
let mut server = mockito::Server::new_async().await;
let mock_msgs = server
.mock("GET", "/api/messages")
.match_query(mockito::Matcher::Any)
.with_status(200)
.with_header("content-type", "application/json")
.with_body("[]")
.create();
let http_client = reqwest::Client::new();
let potatomesh_cfg = PotatomeshConfig {
base_url: server.url(),
poll_interval_secs: 1,
};
let matrix_cfg = MatrixConfig {
homeserver: server.url(),
as_token: "AS_TOKEN".to_string(),
server_name: "example.org".to_string(),
room_id: "!roomid:example.org".to_string(),
};
let potato = PotatoClient::new(http_client.clone(), potatomesh_cfg);
let matrix = MatrixAppserviceClient::new(http_client, matrix_cfg);
let mut state = BridgeState {
last_message_id: Some(1),
last_checked_at: None,
};
poll_once(&potato, &matrix, &mut state, state_str, 123).await;
mock_msgs.assert();
// Should have advanced checkpoint and saved it.
assert_eq!(state.last_checked_at, Some(123));
let loaded = BridgeState::load(state_str).unwrap();
assert_eq!(loaded.last_checked_at, Some(123));
assert_eq!(loaded.last_message_id, Some(1));
}
#[tokio::test]
@@ -258,11 +465,11 @@ mod tests {
};
let node_id = "abcd1234";
let user_id = format!("@{}:{}", node_id, matrix_cfg.server_name);
let user_id = format!("@potato_{}:{}", node_id, matrix_cfg.server_name);
let encoded_user = urlencoding::encode(&user_id);
let mock_get_node = server
.mock("GET", "/nodes/abcd1234")
.mock("GET", "/api/nodes/abcd1234")
.with_status(200)
.with_header("content-type", "application/json")
.with_body(r#"{"node_id": "!abcd1234", "long_name": "Test Node", "short_name": "TN"}"#)
+69 -6
View File
@@ -1,3 +1,17 @@
// Copyright © 2025-26 l5yth & contributors
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
use serde::Serialize;
use std::sync::{
atomic::{AtomicU64, Ordering},
@@ -27,9 +41,24 @@ impl MatrixAppserviceClient {
}
}
/// Convert a node_id like "!deadbeef" into Matrix localpart "deadbeef".
/// Basic liveness check against the homeserver.
pub async fn health_check(&self) -> anyhow::Result<()> {
let url = format!("{}/_matrix/client/versions", self.cfg.homeserver);
let resp = self.http.get(&url).send().await?;
if resp.status().is_success() {
tracing::info!("Matrix homeserver healthy at {}", self.cfg.homeserver);
Ok(())
} else {
Err(anyhow::anyhow!(
"Matrix homeserver versions check failed with status {}",
resp.status()
))
}
}
/// Convert a node_id like "!deadbeef" into Matrix localpart "potato_deadbeef".
pub fn localpart_from_node_id(node_id: &str) -> String {
node_id.trim_start_matches('!').to_string()
format!("potato_{}", node_id.trim_start_matches('!'))
}
/// Build a full Matrix user_id from localpart.
@@ -175,11 +204,11 @@ mod tests {
fn localpart_strips_bang_correctly() {
assert_eq!(
MatrixAppserviceClient::localpart_from_node_id("!deadbeef"),
"deadbeef"
"potato_deadbeef"
);
assert_eq!(
MatrixAppserviceClient::localpart_from_node_id("cafebabe"),
"cafebabe"
"potato_cafebabe"
);
}
@@ -188,8 +217,42 @@ mod tests {
let http = reqwest::Client::builder().build().unwrap();
let client = MatrixAppserviceClient::new(http, dummy_cfg());
let uid = client.user_id("deadbeef");
assert_eq!(uid, "@deadbeef:example.org");
let uid = client.user_id("potato_deadbeef");
assert_eq!(uid, "@potato_deadbeef:example.org");
}
#[tokio::test]
async fn health_check_success() {
let mut server = mockito::Server::new_async().await;
let mock = server
.mock("GET", "/_matrix/client/versions")
.with_status(200)
.create();
let mut cfg = dummy_cfg();
cfg.homeserver = server.url();
let client = MatrixAppserviceClient::new(reqwest::Client::new(), cfg);
let result = client.health_check().await;
mock.assert();
assert!(result.is_ok());
}
#[tokio::test]
async fn health_check_failure() {
let mut server = mockito::Server::new_async().await;
let mock = server
.mock("GET", "/_matrix/client/versions")
.with_status(500)
.create();
let mut cfg = dummy_cfg();
cfg.homeserver = server.url();
let client = MatrixAppserviceClient::new(reqwest::Client::new(), cfg);
let result = client.health_check().await;
mock.assert();
assert!(result.is_err());
}
#[test]
+222 -24
View File
@@ -1,3 +1,17 @@
// Copyright © 2025-26 l5yth & contributors
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
use serde::Deserialize;
use std::collections::HashMap;
use std::sync::Arc;
@@ -14,19 +28,29 @@ pub struct PotatoMessage {
pub from_id: String,
pub to_id: String,
pub channel: u8,
pub portnum: String,
#[serde(default)]
pub portnum: Option<String>,
pub text: String,
pub rssi: i16,
pub hop_limit: u8,
#[serde(default)]
pub rssi: Option<i16>,
#[serde(default)]
pub hop_limit: Option<u8>,
pub lora_freq: u32,
pub modem_preset: String,
pub channel_name: String,
pub snr: f32,
#[serde(default)]
pub snr: Option<f32>,
#[serde(default)]
pub reply_id: Option<u64>,
pub node_id: String,
}
#[derive(Debug, Default, Clone)]
pub struct FetchParams {
pub limit: Option<u32>,
pub since: Option<u64>,
}
#[allow(dead_code)]
#[derive(Debug, Deserialize, Clone)]
pub struct PotatoNode {
@@ -67,22 +91,55 @@ impl PotatoClient {
}
}
/// Build the API root; accept either a bare domain or one already ending in `/api`.
fn api_base(&self) -> String {
let trimmed = self.cfg.base_url.trim_end_matches('/');
if trimmed.ends_with("/api") {
trimmed.to_string()
} else {
format!("{}/api", trimmed)
}
}
fn messages_url(&self) -> String {
format!("{}/messages", self.cfg.base_url)
format!("{}/messages", self.api_base())
}
fn node_url(&self, hex_id: &str) -> String {
// e.g. https://potatomesh.net/api/nodes/67fc83cb
format!("{}/nodes/{}", self.cfg.base_url, hex_id)
format!("{}/nodes/{}", self.api_base(), hex_id)
}
pub async fn fetch_messages(&self) -> anyhow::Result<Vec<PotatoMessage>> {
let resp = self
.http
.get(self.messages_url())
.send()
.await?
.error_for_status()?;
/// Basic liveness check against the PotatoMesh API.
pub async fn health_check(&self) -> anyhow::Result<()> {
let base = self
.cfg
.base_url
.trim_end_matches('/')
.trim_end_matches("/api");
let url = format!("{}/version", base);
let resp = self.http.get(&url).send().await?;
if resp.status().is_success() {
tracing::info!("PotatoMesh API healthy at {}", self.cfg.base_url);
Ok(())
} else {
Err(anyhow::anyhow!(
"PotatoMesh health check failed with status {}",
resp.status()
))
}
}
pub async fn fetch_messages(&self, params: FetchParams) -> anyhow::Result<Vec<PotatoMessage>> {
let mut req = self.http.get(self.messages_url());
if let Some(limit) = params.limit {
req = req.query(&[("limit", limit)]);
}
if let Some(since) = params.since {
req = req.query(&[("since", since)]);
}
let resp = req.send().await?.error_for_status()?;
let msgs: Vec<PotatoMessage> = resp.json().await?;
Ok(msgs)
@@ -146,9 +203,38 @@ mod tests {
assert_eq!(m.id, 2947676906);
assert_eq!(m.from_id, "!da6556d4");
assert_eq!(m.node_id, "!06871773");
assert_eq!(m.portnum, "TEXT_MESSAGE_APP");
assert_eq!(m.portnum.as_deref(), Some("TEXT_MESSAGE_APP"));
assert_eq!(m.lora_freq, 868);
assert!((m.snr - (-9.0)).abs() < f32::EPSILON);
assert!((m.snr.unwrap() - (-9.0)).abs() < f32::EPSILON);
}
#[test]
fn deserialize_message_with_missing_optional_fields() {
let json = r#"
[
{
"id": 1,
"rx_time": 0,
"rx_iso": "2025-11-27T11:03:56Z",
"from_id": "!abcd1234",
"to_id": "^all",
"channel": 1,
"text": "Ping",
"lora_freq": 868,
"modem_preset": "MediumFast",
"channel_name": "TEST",
"node_id": "!abcd1234"
}
]
"#;
let msgs: Vec<PotatoMessage> = serde_json::from_str(json).expect("valid message json");
assert_eq!(msgs.len(), 1);
let m = &msgs[0];
assert!(m.portnum.is_none());
assert!(m.rssi.is_none());
assert!(m.hop_limit.is_none());
assert!(m.snr.is_none());
}
#[test]
@@ -206,7 +292,29 @@ mod tests {
poll_interval_secs: 60,
};
let client = PotatoClient::new(http_client, config);
assert_eq!(client.messages_url(), "http://localhost:8080/messages");
assert_eq!(client.messages_url(), "http://localhost:8080/api/messages");
}
#[test]
fn test_messages_url_with_trailing_slash() {
let http_client = reqwest::Client::new();
let config = PotatomeshConfig {
base_url: "http://localhost:8080/".to_string(),
poll_interval_secs: 60,
};
let client = PotatoClient::new(http_client, config);
assert_eq!(client.messages_url(), "http://localhost:8080/api/messages");
}
#[test]
fn test_messages_url_with_existing_api_suffix() {
let http_client = reqwest::Client::new();
let config = PotatomeshConfig {
base_url: "http://localhost:8080/api/".to_string(),
poll_interval_secs: 60,
};
let client = PotatoClient::new(http_client, config);
assert_eq!(client.messages_url(), "http://localhost:8080/api/messages");
}
#[test]
@@ -219,7 +327,7 @@ mod tests {
let client = PotatoClient::new(http_client, config);
assert_eq!(
client.node_url("!1234"),
"http://localhost:8080/nodes/!1234"
"http://localhost:8080/api/nodes/!1234"
);
}
@@ -227,7 +335,8 @@ mod tests {
async fn test_fetch_messages_success() {
let mut server = mockito::Server::new_async().await;
let mock = server
.mock("GET", "/messages")
.mock("GET", "/api/messages")
.match_query(mockito::Matcher::Any) // allow optional query params
.with_status(200)
.with_header("content-type", "application/json")
.with_body(
@@ -251,7 +360,7 @@ mod tests {
poll_interval_secs: 60,
};
let client = PotatoClient::new(http_client, config);
let result = client.fetch_messages().await;
let result = client.fetch_messages(FetchParams::default()).await;
mock.assert();
assert!(result.is_ok());
@@ -261,9 +370,9 @@ mod tests {
}
#[tokio::test]
async fn test_fetch_messages_error() {
async fn test_health_check_success() {
let mut server = mockito::Server::new_async().await;
let mock = server.mock("GET", "/messages").with_status(500).create();
let mock = server.mock("GET", "/version").with_status(200).create();
let http_client = reqwest::Client::new();
let config = PotatomeshConfig {
@@ -271,12 +380,97 @@ mod tests {
poll_interval_secs: 60,
};
let client = PotatoClient::new(http_client, config);
let result = client.fetch_messages().await;
let result = client.health_check().await;
mock.assert();
assert!(result.is_ok());
}
#[tokio::test]
async fn test_health_check_strips_api_suffix() {
let mut server = mockito::Server::new_async().await;
let mock = server.mock("GET", "/version").with_status(200).create();
let http_client = reqwest::Client::new();
let mut base = server.url();
base.push_str("/api");
let config = PotatomeshConfig {
base_url: base,
poll_interval_secs: 60,
};
let client = PotatoClient::new(http_client, config);
let result = client.health_check().await;
mock.assert();
assert!(result.is_ok());
}
#[tokio::test]
async fn test_health_check_failure() {
let mut server = mockito::Server::new_async().await;
let mock = server.mock("GET", "/version").with_status(500).create();
let http_client = reqwest::Client::new();
let config = PotatomeshConfig {
base_url: server.url(),
poll_interval_secs: 60,
};
let client = PotatoClient::new(http_client, config);
let result = client.health_check().await;
mock.assert();
assert!(result.is_err());
}
#[tokio::test]
async fn test_fetch_messages_error() {
let mut server = mockito::Server::new_async().await;
let mock = server
.mock("GET", "/api/messages")
.match_query(mockito::Matcher::Any)
.with_status(500)
.create();
let http_client = reqwest::Client::new();
let config = PotatomeshConfig {
base_url: server.url(),
poll_interval_secs: 60,
};
let client = PotatoClient::new(http_client, config);
let result = client.fetch_messages(FetchParams::default()).await;
mock.assert();
assert!(result.is_err());
}
#[tokio::test]
async fn test_fetch_messages_with_limit_and_since() {
let mut server = mockito::Server::new_async().await;
let mock = server
.mock("GET", "/api/messages")
.match_query("limit=10&since=123")
.with_status(200)
.with_header("content-type", "application/json")
.with_body("[]")
.create();
let http_client = reqwest::Client::new();
let config = PotatomeshConfig {
base_url: server.url(),
poll_interval_secs: 60,
};
let client = PotatoClient::new(http_client, config);
let params = FetchParams {
limit: Some(10),
since: Some(123),
};
let result = client.fetch_messages(params).await;
mock.assert();
assert!(result.is_ok());
assert!(result.unwrap().is_empty());
}
#[tokio::test]
async fn test_get_node_cache_hit() {
let http_client = reqwest::Client::new();
@@ -313,7 +507,8 @@ mod tests {
async fn test_get_node_cache_miss() {
let mut server = mockito::Server::new_async().await;
let mock = server
.mock("GET", "/nodes/1234")
.mock("GET", "/api/nodes/1234")
.match_query(mockito::Matcher::Any)
.with_status(200)
.with_header("content-type", "application/json")
.with_body(
@@ -348,7 +543,10 @@ mod tests {
#[tokio::test]
async fn test_get_node_error() {
let mut server = mockito::Server::new_async().await;
let mock = server.mock("GET", "/nodes/1234").with_status(500).create();
let mock = server
.mock("GET", "/api/nodes/1234")
.with_status(500)
.create();
let http_client = reqwest::Client::new();
let config = PotatomeshConfig {
+437
View File
@@ -0,0 +1,437 @@
# Copyright © 2025-26 l5yth & contributors
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
"""Unit tests for :mod:`data.mesh_ingestor.daemon`."""
from __future__ import annotations
import sys
import threading
import types
from pathlib import Path
from typing import Any
import pytest
REPO_ROOT = Path(__file__).resolve().parents[1]
if str(REPO_ROOT) not in sys.path:
sys.path.insert(0, str(REPO_ROOT))
from data.mesh_ingestor import daemon
class FakeEvent:
"""Test double for :class:`threading.Event` that can auto-set itself."""
instances: list["FakeEvent"] = []
def __init__(self, *, auto_set_on_wait: bool = False):
self._is_set = False
self._auto_set_on_wait = auto_set_on_wait
self.wait_calls: list[Any] = []
FakeEvent.instances.append(self)
def set(self) -> None:
"""Mark the event as set."""
self._is_set = True
def is_set(self) -> bool:
"""Return whether the event is currently set."""
return self._is_set
def wait(self, timeout: float | None = None) -> bool:
"""Record waits and optionally auto-set the flag."""
self.wait_calls.append(timeout)
if self._auto_set_on_wait:
self._is_set = True
return self._is_set
class AutoSetEvent(FakeEvent):
"""Event variant that automatically sets on each wait call."""
def __init__(self): # noqa: D401 - short initializer docstring handled by class
super().__init__(auto_set_on_wait=True)
@pytest.fixture(autouse=True)
def reset_fake_events():
"""Ensure :class:`FakeEvent` registry is cleared between tests."""
FakeEvent.instances.clear()
yield
FakeEvent.instances.clear()
def test_event_wait_default_detection(monkeypatch):
"""``_event_wait_allows_default_timeout`` matches defaulted signatures."""
assert daemon._event_wait_allows_default_timeout() is True
class _NoDefaultEvent:
def wait(self, timeout): # type: ignore[override]
return bool(timeout)
monkeypatch.setattr(
daemon, "threading", types.SimpleNamespace(Event=_NoDefaultEvent)
)
assert daemon._event_wait_allows_default_timeout() is False
def test_subscribe_receive_topics(monkeypatch):
"""Subscribing to receive topics returns the exact topic list."""
subscribed: list[str] = []
def _record_subscription(_handler, topic):
subscribed.append(topic)
monkeypatch.setattr(
daemon, "pub", types.SimpleNamespace(subscribe=_record_subscription)
)
assert daemon._subscribe_receive_topics() == list(daemon._RECEIVE_TOPICS)
assert subscribed == list(daemon._RECEIVE_TOPICS)
def test_node_items_snapshot_handles_mutation(monkeypatch):
"""Snapshots tolerate temporary runtime errors while iterating."""
class MutatingMapping(dict):
def __bool__(self):
return True
def items(self): # type: ignore[override]
raise RuntimeError("dictionary changed size during iteration")
monkeypatch.setattr(daemon.time, "sleep", lambda _: None)
assert daemon._node_items_snapshot({"a": 1}) == [("a", 1)]
assert daemon._node_items_snapshot(MutatingMapping(), retries=1) is None
class IteratingMapping:
def __init__(self):
self.calls = 0
self._data = {"x": 10, "y": 20}
def __iter__(self):
self.calls += 1
if self.calls == 1:
raise RuntimeError("dictionary changed size during iteration")
return iter(self._data)
def __getitem__(self, key):
return self._data[key]
mapping = IteratingMapping()
assert daemon._node_items_snapshot(mapping, retries=2) == [("x", 10), ("y", 20)]
def test_close_interface_respects_timeout(monkeypatch):
"""Long-running close calls emit a timeout debug log."""
log_calls = []
monkeypatch.setattr(daemon.config, "_CLOSE_TIMEOUT_SECS", 0.01)
monkeypatch.setattr(
daemon.config, "_debug_log", lambda *args, **kwargs: log_calls.append(kwargs)
)
blocker = threading.Event()
class SlowInterface:
def close(self):
blocker.wait(timeout=0.1)
daemon._close_interface(SlowInterface())
assert any("timeout_seconds" in entry for entry in log_calls)
def test_close_interface_immediate_path(monkeypatch):
"""A zero timeout calls ``close`` inline without threading."""
flags = {"called": False}
monkeypatch.setattr(daemon.config, "_CLOSE_TIMEOUT_SECS", 0)
class ImmediateInterface:
def close(self):
flags["called"] = True
daemon._close_interface(ImmediateInterface())
assert flags["called"] is True
def test_ble_interface_detection():
"""Detect BLE module names reliably."""
class BLE:
__module__ = "meshtastic.ble_interface"
class NonBLE:
__module__ = "meshtastic.serial"
assert daemon._is_ble_interface(BLE()) is True
assert daemon._is_ble_interface(NonBLE()) is False
assert daemon._is_ble_interface(None) is False
def test_process_ingestor_heartbeat_with_extracted_host(monkeypatch):
"""Host id extraction triggers heartbeat announcement flag updates."""
host_ids: list[str | None] = [None]
ingestor_ids: list[str | None] = []
queued: list[bool] = []
monkeypatch.setattr(daemon.handlers, "host_node_id", lambda: host_ids[0])
monkeypatch.setattr(
daemon.interfaces, "_extract_host_node_id", lambda iface: "!abcd"
)
monkeypatch.setattr(
daemon.handlers,
"register_host_node_id",
lambda node: host_ids.__setitem__(0, node),
)
monkeypatch.setattr(daemon.ingestors, "set_ingestor_node_id", ingestor_ids.append)
monkeypatch.setattr(
daemon.ingestors,
"queue_ingestor_heartbeat",
lambda force: queued.append(force) or True,
)
assert (
daemon._process_ingestor_heartbeat(object(), ingestor_announcement_sent=False)
is True
)
assert host_ids[0] == "!abcd"
assert ingestor_ids[-1] == "!abcd"
assert queued[-1] is True
monkeypatch.setattr(daemon.handlers, "host_node_id", lambda: "!abcd")
monkeypatch.setattr(
daemon.ingestors,
"queue_ingestor_heartbeat",
lambda force: queued.append(force) or False,
)
assert (
daemon._process_ingestor_heartbeat(object(), ingestor_announcement_sent=True)
is True
)
assert queued[-1] is False
def test_connected_state_branches(monkeypatch):
"""Connection state resolves across multiple attribute forms."""
event = threading.Event()
event.set()
assert daemon._connected_state(event) is True
class CallableCandidate:
def __call__(self):
return False
assert daemon._connected_state(CallableCandidate()) is False
class BooleanCandidate:
def __bool__(self):
raise RuntimeError("cannot bool")
assert daemon._connected_state(BooleanCandidate()) is None
class HasIsSet:
def is_set(self):
raise RuntimeError("broken")
assert daemon._connected_state(HasIsSet()) is None
def _configure_common_defaults(
monkeypatch, *, energy_saving: bool = False, inactivity: float = 0.0
):
"""Set fast configuration defaults shared by daemon integration tests."""
monkeypatch.setattr(daemon.config, "SNAPSHOT_SECS", 0)
monkeypatch.setattr(daemon.config, "_RECONNECT_INITIAL_DELAY_SECS", 0)
monkeypatch.setattr(daemon.config, "_RECONNECT_MAX_DELAY_SECS", 0)
monkeypatch.setattr(daemon.config, "_CLOSE_TIMEOUT_SECS", 0)
monkeypatch.setattr(daemon.config, "ENERGY_SAVING", energy_saving)
monkeypatch.setattr(
daemon.config, "_ENERGY_ONLINE_DURATION_SECS", 0 if energy_saving else 0.0
)
monkeypatch.setattr(daemon.config, "_ENERGY_SLEEP_SECS", 0.0)
monkeypatch.setattr(daemon.config, "_INGESTOR_HEARTBEAT_SECS", 0)
monkeypatch.setattr(daemon.config, "_INACTIVITY_RECONNECT_SECS", inactivity)
monkeypatch.setattr(daemon.config, "CONNECTION", "serial0")
class DummyInterface:
"""Lightweight mesh interface stand-in used for daemon integration tests."""
def __init__(self, *, nodes=None, is_connected=True, client_present=True):
self.nodes = nodes if nodes is not None else {"!node": {"id": 1}}
self.isConnected = is_connected
self.client = object() if client_present else None
def close(self):
return None
def test_main_happy_path(monkeypatch):
"""The main loop processes snapshots and heartbeats once before stopping."""
_configure_common_defaults(monkeypatch)
monkeypatch.setattr(
daemon,
"threading",
types.SimpleNamespace(
Event=AutoSetEvent,
current_thread=threading.current_thread,
main_thread=threading.main_thread,
),
)
monkeypatch.setattr(
daemon, "pub", types.SimpleNamespace(subscribe=lambda *_args, **_kwargs: None)
)
monkeypatch.setattr(
daemon.interfaces,
"_create_serial_interface",
lambda candidate: (DummyInterface(), candidate),
)
monkeypatch.setattr(daemon.interfaces, "_ensure_radio_metadata", lambda iface: None)
monkeypatch.setattr(
daemon.interfaces, "_ensure_channel_metadata", lambda iface: None
)
monkeypatch.setattr(
daemon.interfaces, "_extract_host_node_id", lambda iface: "!host"
)
host_id = {"value": None}
monkeypatch.setattr(
daemon.handlers,
"register_host_node_id",
lambda node: host_id.__setitem__("value", node),
)
monkeypatch.setattr(daemon.handlers, "host_node_id", lambda: host_id["value"])
monkeypatch.setattr(daemon.handlers, "upsert_node", lambda *_args, **_kwargs: None)
monkeypatch.setattr(daemon.handlers, "last_packet_monotonic", lambda: None)
heartbeats: list[bool] = []
monkeypatch.setattr(
daemon.ingestors, "set_ingestor_node_id", lambda *_args, **_kwargs: None
)
monkeypatch.setattr(
daemon.ingestors,
"queue_ingestor_heartbeat",
lambda force: heartbeats.append(force) or True,
)
daemon.main()
assert heartbeats
assert host_id["value"] == "!host"
assert FakeEvent.instances and FakeEvent.instances[0].is_set() is True
def test_main_energy_saving_disconnect(monkeypatch):
"""Energy saving mode disconnects and sleeps when deadlines expire."""
_configure_common_defaults(monkeypatch, energy_saving=True)
monkeypatch.setattr(
daemon,
"threading",
types.SimpleNamespace(
Event=AutoSetEvent,
current_thread=threading.current_thread,
main_thread=threading.main_thread,
),
)
monkeypatch.setattr(
daemon, "pub", types.SimpleNamespace(subscribe=lambda *_args, **_kwargs: None)
)
monkeypatch.setattr(
daemon.interfaces,
"_create_serial_interface",
lambda candidate: (DummyInterface(), candidate),
)
monkeypatch.setattr(daemon.interfaces, "_ensure_radio_metadata", lambda iface: None)
monkeypatch.setattr(
daemon.interfaces, "_ensure_channel_metadata", lambda iface: None
)
monkeypatch.setattr(
daemon.interfaces, "_extract_host_node_id", lambda iface: "!host"
)
monkeypatch.setattr(
daemon.handlers, "register_host_node_id", lambda *_args, **_kwargs: None
)
monkeypatch.setattr(daemon.handlers, "host_node_id", lambda: "!host")
monkeypatch.setattr(daemon.handlers, "upsert_node", lambda *_args, **_kwargs: None)
monkeypatch.setattr(daemon.handlers, "last_packet_monotonic", lambda: None)
monkeypatch.setattr(
daemon.ingestors, "set_ingestor_node_id", lambda *_args, **_kwargs: None
)
monkeypatch.setattr(
daemon.ingestors, "queue_ingestor_heartbeat", lambda *_args, **_kwargs: True
)
daemon.main()
assert FakeEvent.instances and FakeEvent.instances[0].is_set() is True
def test_main_inactivity_reconnect(monkeypatch):
"""Inactivity triggers reconnect attempts and respects stop events."""
_configure_common_defaults(monkeypatch, inactivity=0.5)
monkeypatch.setattr(
daemon,
"threading",
types.SimpleNamespace(
Event=AutoSetEvent,
current_thread=threading.current_thread,
main_thread=threading.main_thread,
),
)
monkeypatch.setattr(
daemon, "pub", types.SimpleNamespace(subscribe=lambda *_args, **_kwargs: None)
)
interface_cycle = iter(
[DummyInterface(is_connected=False), DummyInterface(is_connected=True)]
)
monkeypatch.setattr(
daemon.interfaces,
"_create_serial_interface",
lambda candidate: (next(interface_cycle), candidate),
)
monkeypatch.setattr(daemon.interfaces, "_ensure_radio_metadata", lambda iface: None)
monkeypatch.setattr(
daemon.interfaces, "_ensure_channel_metadata", lambda iface: None
)
monkeypatch.setattr(
daemon.interfaces, "_extract_host_node_id", lambda iface: "!host"
)
monkeypatch.setattr(
daemon.handlers, "register_host_node_id", lambda *_args, **_kwargs: None
)
monkeypatch.setattr(daemon.handlers, "host_node_id", lambda: "!host")
monkeypatch.setattr(daemon.handlers, "upsert_node", lambda *_args, **_kwargs: None)
monotonic_calls = iter([0.0, 1.0, 2.0, 3.0, 4.0])
monkeypatch.setattr(daemon.time, "monotonic", lambda: next(monotonic_calls))
monkeypatch.setattr(daemon.handlers, "last_packet_monotonic", lambda: 0.0)
monkeypatch.setattr(
daemon.ingestors, "set_ingestor_node_id", lambda *_args, **_kwargs: None
)
monkeypatch.setattr(
daemon.ingestors, "queue_ingestor_heartbeat", lambda *_args, **_kwargs: True
)
daemon.main()
assert any(event.is_set() for event in FakeEvent.instances)
@@ -132,6 +132,7 @@ module PotatoMesh
raw_private = payload.key?("isPrivate") ? payload["isPrivate"] : payload["is_private"]
is_private = coerce_boolean(raw_private)
signature = string_or_nil(payload["signature"])
contact_link = string_or_nil(payload["contactLink"])
attributes = {
id: id,
@@ -145,6 +146,7 @@ module PotatoMesh
longitude: longitude,
last_update_time: last_update_time,
is_private: is_private,
contact_link: contact_link,
}
if [attributes[:id], attributes[:domain], attributes[:pubkey], signature, attributes[:last_update_time]].any?(&:nil?)
@@ -157,6 +159,10 @@ module PotatoMesh
end
signature_valid = verify_instance_signature(attributes, signature, attributes[:pubkey])
if !signature_valid && contact_link
stripped_attributes = attributes.merge(contact_link: nil)
signature_valid = verify_instance_signature(stripped_attributes, signature, attributes[:pubkey])
end
# 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
@@ -164,6 +170,10 @@ module PotatoMesh
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])
if !signature_valid && contact_link
stripped_alternate = alternate_attributes.merge(contact_link: nil)
signature_valid = verify_instance_signature(stripped_alternate, signature, attributes[:pubkey])
end
end
unless signature_valid
+1 -1
View File
@@ -175,7 +175,7 @@ module PotatoMesh
#
# @return [String] semantic version identifier.
def version_fallback
"0.5.7"
"0.5.8"
end
# Default refresh interval for frontend polling routines.
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "potato-mesh",
"version": "0.5.7",
"version": "0.5.8",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "potato-mesh",
"version": "0.5.7",
"version": "0.5.8",
"devDependencies": {
"istanbul-lib-coverage": "^3.2.2",
"istanbul-lib-report": "^3.0.1",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "potato-mesh",
"version": "0.5.7",
"version": "0.5.8",
"type": "module",
"private": true,
"scripts": {
+155
View File
@@ -1607,6 +1607,161 @@ RSpec.describe "Potato Mesh Sinatra app" do
end
end
it "accepts registrations when contactLink is part of the signed payload" do
contact_link = "https://example.test/contact"
linked_attributes = instance_attributes.merge(contact_link: contact_link)
linked_signature_payload = canonical_instance_payload(linked_attributes)
linked_signature = Base64.strict_encode64(
instance_key.sign(OpenSSL::Digest::SHA256.new, linked_signature_payload),
)
linked_payload = instance_payload.merge(
"contactLink" => contact_link,
"signature" => linked_signature,
)
post "/api/instances", linked_payload.to_json, { "CONTENT_TYPE" => "application/json" }
expect(last_response.status).to eq(201)
expect(JSON.parse(last_response.body)).to eq("status" => "registered")
with_db(readonly: true) do |db|
db.results_as_hash = true
row = db.get_first_row(
"SELECT contact_link, signature FROM instances WHERE id = ?",
[instance_attributes[:id]],
)
expect(row).not_to be_nil
expect(row["contact_link"]).to eq(contact_link)
expect(row["signature"]).to eq(linked_signature)
end
end
it "accepts instance announcement payloads produced by the application including contactLink" do
contact_link = "https://example.test/contact"
announcement_attributes = instance_attributes.merge(contact_link: contact_link)
announcement_signature = Base64.strict_encode64(
instance_key.sign(
OpenSSL::Digest::SHA256.new,
canonical_instance_payload(announcement_attributes),
),
)
announcement_payload = application_class.instance_announcement_payload(
announcement_attributes,
announcement_signature,
)
post "/api/instances", announcement_payload.to_json, { "CONTENT_TYPE" => "application/json" }
expect(last_response.status).to eq(201)
expect(JSON.parse(last_response.body)).to eq("status" => "registered")
with_db(readonly: true) do |db|
db.results_as_hash = true
row = db.get_first_row(
"SELECT contact_link, signature FROM instances WHERE id = ?",
[instance_attributes[:id]],
)
expect(row).not_to be_nil
expect(row["contact_link"]).to eq(contact_link)
expect(row["signature"]).to eq(announcement_signature)
end
end
it "accepts signatures that omit contactLink for backwards compatibility" do
contact_link = "https://legacy.example/contact"
legacy_signature_payload = canonical_instance_payload(instance_attributes)
legacy_signature = Base64.strict_encode64(
instance_key.sign(OpenSSL::Digest::SHA256.new, legacy_signature_payload),
)
legacy_payload = instance_payload.merge(
"contactLink" => contact_link,
"signature" => legacy_signature,
)
post "/api/instances", legacy_payload.to_json, { "CONTENT_TYPE" => "application/json" }
expect(last_response.status).to eq(201)
expect(JSON.parse(last_response.body)).to eq("status" => "registered")
with_db(readonly: true) do |db|
db.results_as_hash = true
row = db.get_first_row(
"SELECT contact_link, signature FROM instances WHERE id = ?",
[instance_attributes[:id]],
)
expect(row).not_to be_nil
expect(row["contact_link"]).to eq(contact_link)
expect(row["signature"]).to eq(legacy_signature)
end
end
it "accepts mixed-case domains when the signature omits contactLink but the payload includes it" do
raw_domain = "Mesh.Example"
normalized_domain = raw_domain.downcase
contact_link = "https://mixed.example/contact"
mixed_attributes = instance_attributes.merge(domain: raw_domain)
mixed_signature_payload = canonical_instance_payload(mixed_attributes)
mixed_signature = Base64.strict_encode64(
instance_key.sign(OpenSSL::Digest::SHA256.new, mixed_signature_payload),
)
mixed_payload = instance_payload.merge(
"domain" => raw_domain,
"contactLink" => contact_link,
"signature" => mixed_signature,
)
mixed_remote_payload = JSON.generate(
{
"publicKey" => pubkey,
"name" => instance_attributes[:name],
"version" => instance_attributes[:version],
"domain" => normalized_domain,
"lastUpdate" => last_update_time,
},
sort_keys: true,
)
mixed_document = well_known_document.merge(
"domain" => normalized_domain,
"signedPayload" => Base64.strict_encode64(mixed_remote_payload),
"signature" => Base64.strict_encode64(
instance_key.sign(OpenSSL::Digest::SHA256.new, mixed_remote_payload),
),
)
allow_any_instance_of(Sinatra::Application).to receive(:fetch_instance_json) do |_instance, host, path|
case path
when "/.well-known/potato-mesh"
[mixed_document, URI("https://#{host}#{path}")]
when "/api/nodes"
[remote_nodes, URI("https://#{host}#{path}")]
else
[nil, []]
end
end
post "/api/instances", mixed_payload.to_json, { "CONTENT_TYPE" => "application/json" }
expect(last_response.status).to eq(201)
expect(JSON.parse(last_response.body)).to eq("status" => "registered")
with_db(readonly: true) do |db|
db.results_as_hash = true
row = db.get_first_row(
"SELECT domain, contact_link, signature FROM instances WHERE id = ?",
[mixed_attributes[:id]],
)
expect(row).not_to be_nil
expect(row["domain"]).to eq(normalized_domain)
expect(row["contact_link"]).to eq(contact_link)
expect(row["signature"]).to eq(mixed_signature)
end
end
it "rejects registrations with invalid domains" do
invalid_payload = instance_payload.merge("domain" => "mesh-instance")
+2 -2
View File
@@ -61,7 +61,7 @@ RSpec.describe "Ingestor endpoints" do
node_id: "!abc12345",
start_time: now - 120,
last_seen_time: now - 60,
version: "0.5.7",
version: "0.5.8",
lora_freq: 915,
modem_preset: "LongFast",
}.merge(overrides)
@@ -133,7 +133,7 @@ RSpec.describe "Ingestor endpoints" do
with_db do |db|
db.execute(
"INSERT INTO ingestors(node_id, start_time, last_seen_time, version) VALUES(?,?,?,?)",
["!fresh000", now - 100, now - 10, "0.5.7"],
["!fresh000", now - 100, now - 10, "0.5.8"],
)
db.execute(
"INSERT INTO ingestors(node_id, start_time, last_seen_time, version) VALUES(?,?,?,?)",