Compare commits

...
Author SHA1 Message Date
twislaandClaude Opus 5.5 305818466f LoRa Scanner: MeshCore by default, and its public channel read
CI / build (pull_request) Successful in 2m19s
Site / build (pull_request) Successful in 9s
The MeshCore preset is the default for a new device, in the Settings and
in the radio before the Scanner is opened. The setting now accepts it:
it only took the 7 Meshtastic presets, so MeshCore picked in the App was
not saved.

Messages on MeshCore's public channel are decrypted with the channel's
published key (AES-128, checked with 2 bytes of HMAC-SHA256): the row
shows who says they sent it and the start of the text, the details the
sender, the time and the whole text. Other channels and private messages
stay encrypted.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0162FokPdvY2KsS4NBfwyWPk
2026-10-09 22:19:14 +02:00
twislaandClaude Opus 5.5 3339d9ee59 LoRa Scanner: MeshCore searches for nodes and their answers, read
The first two MeshCore packets heard were these: a search for repeaters
and a repeater answering. Both are in clear; they are the test's bytes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0162FokPdvY2KsS4NBfwyWPk
2026-10-09 20:51:15 +02:00
twislaandClaude Opus 5.5 8ba41f72b6 LoRa Scanner: a MeshCore preset, and its packets read
- MeshCore joins the presets (EU/UK Narrow: 869.618 MHz, 62.5 kHz, SF8,
  CR 4/8, sync 0x12), after the Meshtastic ones so stored choices keep
  their number; `lora preset MeshCore` on the console
- lib/meshcore reads what MeshCore sends in clear: header, path, and a
  node's advert (name, kind, key, position)
- the list row, the details, a Capture's viewer and the console echo read
  a packet by the sync word it was received on
- the bandwidth is printed as 62.5, not 62

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0162FokPdvY2KsS4NBfwyWPk
2026-10-09 20:30:29 +02:00
twisla 861a60b73d Merge pull request 'uname, /uname, scp in the Shell, firmware version in CTCP VERSION' (#95) from uname-scp into main
Site / build (push) Successful in 17s
CI / build (push) Successful in 3m7s
2026-10-09 17:20:13 +00:00
twislaandClaude Sonnet 5.5 4c4d7fc467 Pin libsodium to 1.10021.11: 1.10021.12 no longer links with LibSSH
CI / build (pull_request) Successful in 1m40s
Site / build (pull_request) Successful in 10s
WireGuard brings libsodium in unpinned. 1.10021.12 defines
crypto_sign_ed25519_open, as LibSSH-ESP32 does: a clean build failed with
a multiple definition. A cached build kept linking, which hid it.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0162FokPdvY2KsS4NBfwyWPk
2026-10-09 19:17:37 +02:00
twislaandClaude Sonnet 5.5 18285c3212 Add uname, /uname and scp (key or masked password) in the Shell
CI / build (pull_request) Failing after 1m59s
Site / build (pull_request) Successful in 9s
- uname in the console and /uname in IRC: the firmware, its version and the chip
- scp: one file between the card and a trusted server, over SSH, with the
  device's key or a password asked (masked) in the Shell
- shared libssh helpers in src/platform/libssh_util
- README, user guide, command reference and a how-to updated

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0162FokPdvY2KsS4NBfwyWPk
2026-10-09 19:01:36 +02:00
twislaandClaude Sonnet 5.5 e707a5f2d4 IRC: add the firmware version to the CTCP VERSION reply
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0162FokPdvY2KsS4NBfwyWPk
2026-10-09 19:01:36 +02:00
twisla 5bec851a1a Remove unwanted file
CI / build (push) Successful in 1m1s
2026-10-08 11:26:17 +02:00
twisla 9dfe675db7 Merge pull request 'SSH client: a terminal on another machine (#2)' (#94) from ssh-client into main
Site / build (push) Successful in 13s
CI / build (push) Successful in 3m4s
2026-10-08 07:09:11 +00:00
55 changed files with 1781 additions and 106 deletions
+6 -4
View File
@@ -6,7 +6,7 @@ A multi-app firmware for the **M5Stack Cardputer ADV** with the **Cap LoRa-1262*
What it does today:
- **LoRa Scanner:** every packet it hears, with the Meshtastic header read; a spectrum Sweep; captures for Wireshark. It listens and never transmits: the mesh messenger is the next milestone.
- **LoRa Scanner:** every packet it hears, with the Meshtastic or MeshCore header read; a spectrum Sweep; captures for Wireshark. It listens and never transmits: the mesh messenger is the next milestone.
- **GNSS:** position, sky view, tracks as GPX.
- **Gemini:** a browser, with bookmarks and pages saved for offline.
- **IRC:** over TLS, with logs on the card.
@@ -138,7 +138,7 @@ On a page, `b` bookmarks it, `s` saves it to the SD card to read offline (a non-
## LoRa Scanner
The LoRa Scanner (docs/milestones/M3.md) listens with the Cap's radio and **never transmits**. The Sniffer lists what it hears, newest first: time, RSSI, SNR, and for Meshtastic packets the sender and receiver (their last 4 hex digits) and hops. Enter shows a packet's details: the Meshtastic header (which is never encrypted) and a hex dump. `p` picks one of the 7 Meshtastic presets allowed in EU868 (LongFast by default), `c` starts or stops a Capture: a pcap file with LoRaTap headers in `/captures/lora/`, for Wireshark. A Capture keeps recording with the App closed; otherwise the radio sleeps when the App isn't open. Tab switches to **Sweep**: the signal strength across 863–870 MHz in 100 kHz steps, as bars with peak hold and a waterfall, with the Sniffer's frequency marked; the Sniffer is paused meanwhile and picks up where it was. The GNSS receiver on the same Cap raises the radio's noise floor by 8 dB while it runs: Settings > "Pause GNSS for LoRa" (off by default) puts it in standby while the radio listens, except during a Track. The Status Bar shows `L` while the radio listens (bright for a moment on each packet), `SW` while sweeping, and `CAP` while capturing.
The LoRa Scanner (docs/milestones/M3.md) listens with the Cap's radio and **never transmits**. The Sniffer lists what it hears, newest first: time, RSSI, SNR, and for Meshtastic packets the sender and receiver (their last 4 hex digits) and hops; for MeshCore packets what they are (an advert with the node's name, a message with the first byte of each node's key, a message on the public channel read, with the name its sender gives; any other channel message with the channel's byte) and the hops in their path. Enter shows a packet's details: what is never encrypted (the Meshtastic header; MeshCore's header, path and, in an advert, the node's name, kind, key and position; and a public channel message's sender, time and text, decrypted with the channel's published key) and a hex dump. `p` picks **MeshCore** (the default; its EU/UK Narrow setting: 869.618 MHz, 62.5 kHz, SF8, CR 4/8) or one of the 7 Meshtastic presets allowed in EU868, `c` starts or stops a Capture: a pcap file with LoRaTap headers in `/captures/lora/`, for Wireshark. A Capture keeps recording with the App closed; otherwise the radio sleeps when the App isn't open. Tab switches to **Sweep**: the signal strength across 863–870 MHz in 100 kHz steps, as bars with peak hold and a waterfall, with the Sniffer's frequency marked; the Sniffer is paused meanwhile and picks up where it was. The GNSS receiver on the same Cap raises the radio's noise floor by 8 dB while it runs: Settings > "Pause GNSS for LoRa" (off by default) puts it in standby while the radio listens, except during a Track. The Status Bar shows `L` while the radio listens (bright for a moment on each packet), `SW` while sweeping, and `CAP` while capturing.
## Storage
@@ -161,7 +161,7 @@ Enter on a file opens it by type; Tab switches to the same file as a hex dump or
- **Text** (`.txt`, `.log`, `.gmi`, `.csv`, and anything that looks like text): only the screen's worth is read from the card, so a file of any size opens at once. Logs open at the end. Up and Down move a line, Left and Right a page, `t` and `b` go to the top and the end, `e` edits it, whatever its size (see Notes).
- **Pictures** (`.png`, `.jpg`, `.bmp`, `.gif`; issue #45): shrunk to fit the screen, or at their own size with Enter, the arrows then moving half a screen at a time; `i` gives the size in pixels. Dithered to the screen's 256 colours, decoded straight into the screen's buffer with no copy in memory, on the storage task so the keys keep working (12 megapixels of JPEG: 7 s). A GIF shows its first picture. A progressive JPEG or an interlaced PNG opens as hex, with the reason.
- **Captures** (`.pcap`): the packets as the LoRa Scanner lists them; Enter shows one with its Meshtastic header and bytes.
- **Captures** (`.pcap`): the packets as the LoRa Scanner lists them; Enter shows one with its Meshtastic or MeshCore header and bytes.
- **Tracks** (`.gpx`): the number of points, the start, the duration and the distance.
- **Update Files** (`.ota`): the version, and whether the file would install: it's checked as an install checks it (signature and contents) without writing anything. Enter then installs it.
- **Anything else:** a hex dump.
@@ -234,7 +234,7 @@ The Shell App (docs/milestones/S1.md) runs the commands below on the device's ow
| `lora probe` | Finds the radio: chip, oscillator, antenna switch, DIO1 interrupt, noise floor |
| `lora status` | Radio settings, who's listening, packet and error counters, noise floor, task stack |
| `lora rx on` / `lora rx off` | Listens and prints each packet on the console |
| `lora preset <name>` / `lora custom <MHz> <BW kHz> <SF> <CR> <sync hex> [preamble]` | Receive settings: a Meshtastic preset, or anything else (`lora custom 868.1 125 7 5 34 8` for LoRaWAN) |
| `lora preset <name>` / `lora custom <MHz> <BW kHz> <SF> <CR> <sync hex> [preamble]` | Receive settings: a Meshtastic preset or `MeshCore`, or anything else (`lora custom 868.1 125 7 5 34 8` for LoRaWAN) |
| `lora capture start` / `lora capture stop` | A LoRa Capture, as `c` in the App |
| `lora sweep on [from MHz] [to MHz] [step kHz]` / `lora sweep off` / `lora sweep dump` | Sweep a band (863 870 100 by default), with a summary every 2 s (floor, strongest, peaks), or print the latest pass |
| `gnss quiet on` / `gnss quiet off` | The "Pause GNSS for LoRa" setting |
@@ -252,6 +252,8 @@ The Shell App (docs/milestones/S1.md) runs the commands below on the device's ow
| `tls <host> [port]` / `ntp [server]` | A TLS handshake that checks nothing, then the certificate said in words: who it is for, who signed it, until when, its SHA-256, and whether this device's roots and the name asked for accept it (about 52 KB of heap while it runs; refused under 70 KB free). A time server's clock against the device's, with the round trip |
| `ifconfig` / `arp` / `netstat` | The interfaces (Wi-Fi and the VPN) with their addresses, MTU, which is the default route, and the DNS servers; the neighbours heard on the Wi-Fi; what listens and what is connected |
| `ssh user@host[:port]` / `ssh status` / `ssh stop` | Opens the SSH App and connects (the password is asked there, never on a console); the session's state and this device's public key; end the session |
| `scp [-f] [-P port] <file> user@host:path` / `scp [-f] [-P port] user@host:path <file>` | One file between the card and a server, with the device's key, or a password asked (masked) in the Shell, to a server the SSH App already trusts; `-f` replaces a file on the card; `cancel` stops it |
| `uname` | The firmware, its version and the chip it is built for (IRC has `/uname`) |
| `vpn status` / `vpn up [seconds]` / `vpn down` / `vpn import [path]` / `vpn forget` / `vpn auto on\|off` | The WireGuard tunnel: its state, on (for that many seconds, then off by itself: for trying a configuration from afar), off, read a `.conf` from the card (`/vpn/wg0.conf`), erase it, start with Wi-Fi. No key is ever printed |
| `debug status` / `debug off [seconds]` | The Debug Console: whether it's on, has a token and a client; switch it off. With a number of seconds, it comes back by itself after that long |
| `debug on` / `debug token <value>` / `debug token new` | USB serial only: switch it on (making a token if there's none), give it a token of 16 to 64 characters, or make a new one. The token is never printed |
+1 -1
View File
@@ -36,7 +36,7 @@
| Q92 | A **Radio Service** owns the SX1262: driver, bus lock, IRQ task. The LoRa Scanner uses it in M3; the Mesh Service sits on top of it in M4. |
| Q93 | Every radio transfer takes the shared bus lock (`SPI.beginTransaction`, as the card does). DIO1's interrupt only wakes the task; no SPI in the ISR. **Done when** a Gemini page streams to the card while the Sniffer receives, with no lost packets and no card errors (`lora status` counters). |
| Q94 | **Receive only:** the Radio Service has no transmit function in M3. It doesn't exist, rather than being unused. |
| Q95 | Sniffer defaults: **EU868 LongFast**, 869.525 MHz, BW 250 kHz, SF 11, CR 4/5, sync word 0x2B, preamble 16 (Q19). The other Meshtastic presets are offered, plus custom settings. |
| Q95 | Sniffer defaults: **EU868 LongFast**, 869.525 MHz, BW 250 kHz, SF 11, CR 4/5, sync word 0x2B, preamble 16 (Q19). The other Meshtastic presets are offered, plus custom settings. *Later, the default became is the MeshCore preset (869.618 MHz, BW 62.5 kHz, SF 8, CR 4/8, sync word 0x12): it is what is heard here.* |
| Q96 | The Sniffer lists packets (time, RSSI, SNR, frequency error, length; hex dump on Enter) **and decodes the Meshtastic header**: the first 16 bytes are never encrypted (destination, sender, packet ID, hop limit and hop start, channel hash, next hop, relay node). Host-tested. Payload decryption is M4. |
| Q97 | A Sniffer **Capture** is pcap with **LoRaTap** headers (link type 270), for Wireshark. Started by hand, Status Bar mark, its own Clean-up category, the 90% rule. |
| Q98 | **Sweep** steps across the Region's band (863–870 MHz) in 100 kHz steps by default, reading instant RSSI: bars with peak hold, and a waterfall, Wi-Fi Tools style. Optionally CAD on the preset's frequency to tell LoRa traffic from noise. |
+1 -1
View File
@@ -188,7 +188,7 @@ inline constexpr KeyHelp kGeminiAnswer[] = {
inline constexpr KeyHelp kLora[] = {
{"; .", "up, down"},
{"Enter", "the packet's details"},
{"p", "pick a Meshtastic preset"},
{"p", "pick a preset"},
{"c", "start a Capture, or stop it"},
{"Tab", "the Sweep"},
};
+5 -2
View File
@@ -4,6 +4,7 @@
#include <cctype>
#include "base64.h"
#include "version.h"
namespace roro {
@@ -284,7 +285,7 @@ void IrcSession::onPrivmsg(const IrcMessage& m, int64_t utc, bool notice) {
action = true;
text = rest;
} else {
if (verb == "VERSION" && !notice) send(std::string("NOTICE ") + from + " :" + kCtcp + "VERSION roro9stack" + kCtcp);
if (verb == "VERSION" && !notice) send(std::string("NOTICE ") + from + " :" + kCtcp + "VERSION " + kProductName + " " + versionString() + kCtcp);
return;
}
}
@@ -359,10 +360,12 @@ void IrcSession::command(int b, const std::string& text, int64_t utc) {
} else if (verb == "quit") {
quit_ = true;
send(IrcMessage::serialize("QUIT", {rest.empty() ? "roro9stack" : rest}));
} else if (verb == "uname") { // shown here, not said to anyone
info(b, unameString(), utc);
} else if (verb == "raw" || verb == "quote") {
if (!rest.empty()) send(rest);
} else {
info(b, "Unknown command /" + verb + " (try /join or /j, /part /msg /me /nick /topic /names /quit /raw)", utc);
info(b, "Unknown command /" + verb + " (try /join or /j, /part /msg /me /nick /topic /names /uname /quit /raw)", utc);
}
}
+131 -2
View File
@@ -2,7 +2,10 @@
#include <cmath>
#include <cstdio>
#include <ctime>
#include "meshcore_channel.h"
#include "meshcore_packet.h"
#include "meshtastic_header.h"
#include "meshtastic_presets.h"
@@ -10,13 +13,137 @@ namespace roro::lora {
using namespace meshtastic;
namespace {
std::string hex(const uint8_t* p, size_t n) {
std::string out;
char s[4];
for (size_t i = 0; i < n; ++i) {
std::snprintf(s, sizeof s, "%02x", p[i]);
out += s;
}
return out;
}
// Onto lines of `cols` characters at most, broken at spaces where there are some.
void wrapInto(std::vector<std::string>& lines, std::string text, size_t cols) {
while (text.size() > cols) {
size_t cut = text.rfind(' ', cols);
if (cut == std::string::npos || cut == 0) cut = cols;
lines.push_back(text.substr(0, cut));
text.erase(0, text[cut] == ' ' ? cut + 1 : cut);
}
if (!text.empty()) lines.push_back(text);
}
// `room` characters at most: a node's name or a message is cut to fit.
std::string meshcoreRow(const meshcore::Packet& m, size_t room) {
std::string out = meshcore::payloadShort(m.type);
std::string hops = m.hops ? " " + std::to_string(m.hops) + "h" : "";
meshcore::Advert a;
meshcore::Discover d;
meshcore::ChannelText t;
if (meshcore::parseDiscover(m, d)) {
out = d.response ? "here " + hex(d.id, 2) : "who's there?";
} else if (meshcore::readChannelText(m, t)) {
out = (t.sender.empty() ? "" : t.sender + ": ") + t.text;
out = out.substr(0, room > hops.size() ? room - hops.size() : 0);
while (!out.empty() && out.back() == ' ') out.pop_back();
return out + hops;
} else if (meshcore::parseAdvert(m, a)) {
size_t used = out.size() + 1 + hops.size();
out += " " + (a.name.empty() ? hex(a.key, 2) : a.name.substr(0, room > used ? room - used : 0));
} else if (m.addressed() && m.payloadLen >= 2)
out += " " + hex(m.payload + 1, 1) + ">" + hex(m.payload, 1);
else if (m.group() && m.payloadLen >= 1)
out += " #" + hex(m.payload, 1);
else if (m.type == meshcore::Payload::AnonRequest && m.payloadLen >= 1)
out += " >" + hex(m.payload, 1);
return out + hops;
}
std::vector<std::string> meshcoreLines(const uint8_t* data, size_t len) {
meshcore::Packet m;
if (!meshcore::parsePacket(data, len, m)) return {};
char s[64];
std::vector<std::string> lines;
lines.push_back("MeshCore " + std::string(meshcore::payloadName(m.type)) + ", " + meshcore::routeName(m.route));
if (m.hasTransport) {
std::snprintf(s, sizeof s, "Region code %04x", m.transport[0]);
lines.push_back(s);
}
// A flood gathers who repeated it; a direct packet carries who is still to repeat it.
if (m.hops) {
std::string path = std::string(m.flood() ? "Through" : "Route");
for (uint8_t i = 0; i < m.hops; ++i) {
std::string hop = " " + hex(m.path + i * m.hashSize, m.hashSize);
if (path.size() + hop.size() > 36) { // onto another line
lines.push_back(path);
path = " ";
}
path += hop;
}
lines.push_back(path);
} else {
lines.push_back(m.flood() ? "Heard first hand: no repeater yet" : "No route in it: to a neighbour");
}
meshcore::Advert a;
meshcore::Discover d;
if (meshcore::parseDiscover(m, d) && d.response) {
lines.push_back(std::string(meshcore::kindName(d.kind)) + " answering a search");
lines.push_back("Key " + hex(d.id, 8) + "...");
std::snprintf(s, sizeof s, "It heard the search at %.1f dB SNR", d.snr);
lines.push_back(s);
} else if (meshcore::parseDiscover(m, d)) {
std::string who;
for (int kind = 1; kind <= 4; ++kind)
if (d.kinds & (1 << kind)) who += std::string(who.empty() ? "" : ", ") + meshcore::kindName(static_cast<uint8_t>(kind));
lines.push_back("A search for nodes nearby");
lines.push_back("Wanted: " + (who.empty() ? std::string("any") : who));
} else if (meshcore::parseAdvert(m, a)) {
lines.push_back(std::string(meshcore::kindName(a.kind)) + (a.name.empty() ? "" : " " + a.name));
lines.push_back("Key " + hex(a.key, 8) + "...");
if (a.hasLocation) {
std::snprintf(s, sizeof s, "At %.5f, %.5f", a.latE6 / 1e6, a.lonE6 / 1e6);
lines.push_back(s);
}
} else if (m.addressed() && m.payloadLen >= 4) {
std::snprintf(s, sizeof s, "From ..%02x to ..%02x, %u B encrypted", m.payload[1], m.payload[0], static_cast<unsigned>(m.payloadLen - 4));
lines.push_back(s);
} else if (meshcore::ChannelText t; meshcore::readChannelText(m, t)) {
lines.push_back(std::string("On the ") + t.channel + " channel" + (t.sender.empty() ? "" : ", from " + t.sender));
std::time_t sent = t.timestamp;
std::tm tm{};
gmtime_r(&sent, &tm);
std::strftime(s, sizeof s, "Sent %Y-%m-%d %H:%M:%S UTC", &tm);
lines.push_back(s);
wrapInto(lines, t.text, 38);
} else if (m.group() && m.payloadLen >= 3) {
std::snprintf(s, sizeof s, "Channel 0x%02x%s, %u B encrypted", m.payload[0], m.payload[0] == meshcore::kPublicChannelHash ? " (public?)" : "",
static_cast<unsigned>(m.payloadLen - 3));
lines.push_back(s);
} else if (m.type == meshcore::Payload::AnonRequest && m.payloadLen >= 35) {
std::snprintf(s, sizeof s, "To ..%02x from key %s...", m.payload[0], hex(m.payload + 1, 4).c_str());
lines.push_back(s);
} else if (m.type == meshcore::Payload::Ack && m.payloadLen >= 4) {
lines.push_back("Acknowledges " + hex(m.payload, 4));
}
return lines;
}
} // namespace
Protocol protocolOf(uint8_t syncWord) {
return syncWord == meshtastic::kSyncWord ? Protocol::Meshtastic : syncWord == meshcore::kSyncWord ? Protocol::MeshCore : Protocol::None;
}
std::string row(const PacketSummary& p, const std::string& time) {
char s[64];
int n = std::snprintf(s, sizeof s, "%s %ld %.1f ", time.c_str(), std::lround(p.rssi), p.snr);
std::string out(s, n);
PacketHeader h;
if (!p.crcOk) return out + "bad CRC, " + std::to_string(p.len) + " B";
if (!p.meshtastic || !parseHeader(p.data, p.len, h)) return out + std::to_string(p.len) + " B";
meshcore::Packet m;
if (p.protocol == Protocol::MeshCore && meshcore::parsePacket(p.data, p.len, m)) return out + meshcoreRow(m, out.size() < 38 ? 38 - out.size() : 0);
if (p.protocol != Protocol::Meshtastic || !parseHeader(p.data, p.len, h)) return out + std::to_string(p.len) + " B";
auto shortId = [](uint32_t node) {
if (node == kBroadcast) return std::string("all");
char id[8];
@@ -48,7 +175,9 @@ std::vector<std::string> hexDump(const uint8_t* data, size_t len) {
return lines;
}
std::vector<std::string> headerLines(const uint8_t* data, size_t len) {
std::vector<std::string> headerLines(const uint8_t* data, size_t len, Protocol protocol) {
if (protocol == Protocol::MeshCore) return meshcoreLines(data, len);
if (protocol != Protocol::Meshtastic) return {};
PacketHeader h;
if (!parseHeader(data, len, h)) return {};
char s[64];
+12 -5
View File
@@ -7,23 +7,30 @@
namespace roro::lora {
// Whose packets the radio's settings were for, by their sync word: whose clear header to read.
enum class Protocol : uint8_t { None, Meshtastic, MeshCore };
Protocol protocolOf(uint8_t syncWord);
// What the LoRa Scanner shows of one packet (M3, Q96).
struct PacketSummary {
const uint8_t* data;
size_t len;
float rssi, snr;
bool crcOk;
bool meshtastic; // received on Meshtastic settings (sync word 0x2B): read its clear header
Protocol protocol; // what the settings it was received on were for
};
// One list row, at most about 36 characters: "21:45:07 -97 6.2 5678>all 1/3". Nodes by their default
// short name (the last 4 hex digits); headerLines() has the full numbers.
// One list row, at most about 36 characters: "21:45:07 -97 6.2 5678>all 1/3". Meshtastic nodes by their
// default short name (the last 4 hex digits); headerLines() has the full numbers. MeshCore: what
// it is, then who ("adv Brussels-R1", "txt a3>7f", "chan #11", "who's there?", "here 18a6") and the hops in its path ("2h");
// a message on the public channel is read: "Alice: hello fro 2h".
std::string row(const PacketSummary& p, const std::string& time);
// Eight bytes a line, with the printable ones: "0000 ff ff ff ff 78 56 34 12 ....xV4.".
std::vector<std::string> hexDump(const uint8_t* data, size_t len);
// The Meshtastic header, one field a line; empty when the packet is too short to have one.
std::vector<std::string> headerLines(const uint8_t* data, size_t len);
// What is in clear, one field a line: the Meshtastic header, or MeshCore's header, path and (for an
// advert) who the node says it is, and a message on its public channel. Empty when the packet can't be read as that.
std::vector<std::string> headerLines(const uint8_t* data, size_t len, Protocol protocol = Protocol::Meshtastic);
} // namespace roro::lora
+34
View File
@@ -0,0 +1,34 @@
#include "scan_presets.h"
#include <cctype>
#include "meshcore_packet.h"
#include "meshtastic_presets.h"
namespace roro::lora {
size_t scanPresetCount() { return meshtastic::kEu868PresetCount + 1; }
ScanPreset scanPreset(size_t i) {
if (i < meshtastic::kEu868PresetCount) {
const meshtastic::Preset& p = meshtastic::kEu868Presets[i];
return {p.name, meshtastic::eu868FrequencyHz(p), p.bwKHz, p.sf, p.cr, meshtastic::kSyncWord, meshtastic::kPreambleLength, Protocol::Meshtastic};
}
return {meshcore::kPresetName, meshcore::kEuNarrowHz, meshcore::kEuNarrowBwKHz, meshcore::kEuNarrowSf, meshcore::kEuNarrowCr,
meshcore::kSyncWord, meshcore::kPreambleLength, Protocol::MeshCore};
}
bool findScanPreset(const char* name, ScanPreset& out) {
for (size_t i = 0; i < scanPresetCount(); ++i) {
ScanPreset p = scanPreset(i);
const char *a = p.name, *b = name;
while (*a && *b && std::tolower(static_cast<unsigned char>(*a)) == std::tolower(static_cast<unsigned char>(*b))) ++a, ++b;
if (!*a && !*b) {
out = p;
return true;
}
}
return false;
}
} // namespace roro::lora
+28
View File
@@ -0,0 +1,28 @@
#pragma once
#include <cstddef>
#include <cstdint>
#include "packet_view.h"
namespace roro::lora {
// What the LoRa Scanner can be told to listen to by name: the Meshtastic presets allowed in
// EU_868, LongFast first, then MeshCore's EU/UK (Narrow), the default. The order is kept: the
// chosen one is stored by its number.
struct ScanPreset {
const char* name;
uint32_t frequencyHz;
float bwKHz;
uint8_t sf, cr, syncWord;
uint16_t preamble;
Protocol protocol;
};
constexpr size_t kDefaultScanPreset = 7; // MeshCore: the Settings' default says the same
size_t scanPresetCount();
ScanPreset scanPreset(size_t i); // i < scanPresetCount()
// By name, whatever its case. False when there is none.
bool findScanPreset(const char* name, ScanPreset& out);
} // namespace roro::lora
+87
View File
@@ -0,0 +1,87 @@
#include "aes128.h"
#include <cstring>
namespace roro::meshcore {
namespace {
// The S-box and its inverse, worked out once rather than typed in.
struct Tables {
uint8_t s[256], inv[256];
Tables() {
auto rotl = [](uint8_t x, int n) { return static_cast<uint8_t>(x << n | x >> (8 - n)); };
uint8_t p = 1, q = 1;
do {
p = static_cast<uint8_t>(p ^ (p << 1) ^ (p & 0x80 ? 0x1B : 0)); // p times 3
q ^= static_cast<uint8_t>(q << 1); // q divided by 3
q ^= static_cast<uint8_t>(q << 2);
q ^= static_cast<uint8_t>(q << 4);
if (q & 0x80) q ^= 0x09;
uint8_t x = static_cast<uint8_t>(q ^ rotl(q, 1) ^ rotl(q, 2) ^ rotl(q, 3) ^ rotl(q, 4) ^ 0x63);
s[p] = x;
inv[x] = p;
} while (p != 1);
s[0] = 0x63;
inv[0x63] = 0;
}
};
const Tables& tables() {
static const Tables t;
return t;
}
uint8_t mul(uint8_t a, uint8_t b) { // in GF(2^8)
uint8_t r = 0;
for (; b; b >>= 1) {
if (b & 1) r ^= a;
a = static_cast<uint8_t>(a << 1 ^ (a & 0x80 ? 0x1B : 0));
}
return r;
}
} // namespace
Aes128::Aes128(const uint8_t key[16]) {
const Tables& t = tables();
std::memcpy(roundKeys_, key, 16);
uint8_t rcon = 1;
for (int i = 16; i < 176; i += 4) {
uint8_t w[4];
std::memcpy(w, roundKeys_ + i - 4, 4);
if (i % 16 == 0) {
uint8_t first = w[0];
w[0] = static_cast<uint8_t>(t.s[w[1]] ^ rcon);
w[1] = t.s[w[2]];
w[2] = t.s[w[3]];
w[3] = t.s[first];
rcon = static_cast<uint8_t>(rcon << 1 ^ (rcon & 0x80 ? 0x1B : 0));
}
for (int j = 0; j < 4; ++j) roundKeys_[i + j] = roundKeys_[i - 16 + j] ^ w[j];
}
}
void Aes128::decryptBlock(const uint8_t in[16], uint8_t out[16]) const {
const Tables& t = tables();
uint8_t s[16];
for (int i = 0; i < 16; ++i) s[i] = in[i] ^ roundKeys_[160 + i];
for (int round = 9; round >= 0; --round) {
// Rows shifted back and bytes substituted back, in one go: column c, row r came from column c - r.
uint8_t u[16];
for (int c = 0; c < 4; ++c)
for (int r = 0; r < 4; ++r) u[4 * c + r] = t.inv[s[4 * ((c - r + 4) % 4) + r]];
for (int i = 0; i < 16; ++i) u[i] ^= roundKeys_[16 * round + i];
if (round == 0) {
std::memcpy(s, u, 16);
break;
}
for (int c = 0; c < 4; ++c) {
const uint8_t* a = u + 4 * c;
s[4 * c + 0] = mul(a[0], 14) ^ mul(a[1], 11) ^ mul(a[2], 13) ^ mul(a[3], 9);
s[4 * c + 1] = mul(a[0], 9) ^ mul(a[1], 14) ^ mul(a[2], 11) ^ mul(a[3], 13);
s[4 * c + 2] = mul(a[0], 13) ^ mul(a[1], 9) ^ mul(a[2], 14) ^ mul(a[3], 11);
s[4 * c + 3] = mul(a[0], 11) ^ mul(a[1], 13) ^ mul(a[2], 9) ^ mul(a[3], 14);
}
}
std::memcpy(out, s, 16);
}
} // namespace roro::meshcore
+18
View File
@@ -0,0 +1,18 @@
#pragma once
#include <cstdint>
namespace roro::meshcore {
// AES-128 (FIPS 197), one block at a time, decryption only: what reading a MeshCore message
// takes. Small and dependency-free, so the same code runs in the PC tests and on the device.
class Aes128 {
public:
explicit Aes128(const uint8_t key[16]);
void decryptBlock(const uint8_t in[16], uint8_t out[16]) const;
private:
uint8_t roundKeys_[176];
};
} // namespace roro::meshcore
+87
View File
@@ -0,0 +1,87 @@
#include "meshcore_channel.h"
#include <cstring>
#include "aes128.h"
#include "sha256.h"
namespace roro::meshcore {
namespace {
// izOH6cXN6mrJ5e26oRXNcg== in base64, as the apps show it.
const Channel kChannels[] = {
{"Public", {0x8b, 0x33, 0x87, 0xe9, 0xc5, 0xcd, 0xea, 0x6a, 0xc9, 0xe5, 0xed, 0xba, 0xa1, 0x15, 0xcd, 0x72}},
};
void hmacSha256(const uint8_t key[16], const uint8_t* data, size_t len, uint8_t out[32]) {
uint8_t pad[64], inner[32];
std::memset(pad, 0x36, sizeof pad);
for (int i = 0; i < 16; ++i) pad[i] ^= key[i];
Sha256 in;
in.update(pad, sizeof pad);
in.update(data, len);
in.finish(inner);
std::memset(pad, 0x5C, sizeof pad);
for (int i = 0; i < 16; ++i) pad[i] ^= key[i];
Sha256 o;
o.update(pad, sizeof pad);
o.update(inner, sizeof inner);
o.finish(out);
}
// Bytes that aren't printable ASCII become '?': one for a whole UTF-8 character.
std::string printable(const uint8_t* p, size_t n) {
std::string out;
for (size_t i = 0; i < n; ++i) {
if (p[i] >= 0x20 && p[i] < 0x7F) out += static_cast<char>(p[i]);
else if ((p[i] & 0xC0) != 0x80) out += '?';
}
return out;
}
} // namespace
size_t channelCount() { return sizeof kChannels / sizeof kChannels[0]; }
const Channel& channel(size_t i) { return kChannels[i]; }
uint8_t channelHash(const Channel& c) {
uint8_t h[32];
Sha256::hash(c.key, sizeof c.key, h);
return h[0];
}
bool readChannelText(const Packet& p, ChannelText& out) {
// The channel's hash, 2 bytes of check, then whole AES blocks.
if (p.type != Payload::GroupText || p.payloadLen < 3 + 16 || (p.payloadLen - 3) % 16) return false;
const uint8_t* cipher = p.payload + 3;
size_t len = p.payloadLen - 3;
for (size_t i = 0; i < channelCount(); ++i) {
const Channel& c = channel(i);
if (channelHash(c) != p.payload[0]) continue;
uint8_t mac[32];
hmacSha256(c.key, cipher, len, mac);
if (mac[0] != p.payload[1] || mac[1] != p.payload[2]) continue;
uint8_t plain[192];
Aes128 aes(c.key);
for (size_t at = 0; at < len; at += 16) aes.decryptBlock(cipher + at, plain + at);
// Timestamp, then a byte whose top 6 bits say what follows: 0 is plain text, "name: words".
if (plain[4] >> 2) return false;
ChannelText t;
t.channel = c.name;
t.timestamp = static_cast<uint32_t>(plain[0] | plain[1] << 8 | plain[2] << 16 | static_cast<uint32_t>(plain[3]) << 24);
size_t end = 5;
while (end < len && plain[end]) ++end; // padded with zeros
std::string all = printable(plain + 5, end - 5);
size_t colon = all.find(": ");
if (colon == std::string::npos) {
t.text = all;
} else {
t.sender = all.substr(0, colon);
t.text = all.substr(colon + 2);
}
out = t;
return true;
}
return false;
}
} // namespace roro::meshcore
+34
View File
@@ -0,0 +1,34 @@
#pragma once
#include <cstddef>
#include <cstdint>
#include <string>
#include "meshcore_packet.h"
// MeshCore's channel messages: encrypted with a key everyone on the channel has. The public
// channel's key is published, so what is said there can be read by anyone listening.
namespace roro::meshcore {
struct Channel {
const char* name;
uint8_t key[16];
};
// The channels whose key is known here: the public one, for now.
size_t channelCount();
const Channel& channel(size_t i);
// What a message on the channel starts with: the first byte of the SHA-256 of its key.
uint8_t channelHash(const Channel& c);
struct ChannelText {
const char* channel = "";
uint32_t timestamp = 0; // the sender's clock, seconds since 1970
std::string sender; // what the sender calls itself: not signed, anyone can claim a name
std::string text; // printable ASCII kept, the rest as '?'
};
// True when the packet is a text on a known channel and its check (2 bytes of HMAC-SHA256) holds.
bool readChannelText(const Packet& p, ChannelText& out);
} // namespace roro::meshcore
+149
View File
@@ -0,0 +1,149 @@
#include "meshcore_packet.h"
#include <cstring>
namespace roro::meshcore {
namespace {
int32_t le32(const uint8_t* p) { return static_cast<int32_t>(p[0] | p[1] << 8 | p[2] << 16 | static_cast<uint32_t>(p[3]) << 24); }
constexpr size_t kMaxPath = 64, kMaxPayload = 184;
} // namespace
bool parsePacket(const uint8_t* data, size_t len, Packet& out) {
if (!data || len < 2) return false;
uint8_t header = data[0];
if (header >> 6) return false; // versions 1 to 3 are reserved
uint8_t type = (header >> 2) & 0x0F;
if (type >= 0xC && type <= 0xE) return false; // reserved
Packet p;
p.route = static_cast<Route>(header & 0x03);
p.type = static_cast<Payload>(type);
size_t at = 1;
p.hasTransport = p.route == Route::TransportFlood || p.route == Route::TransportDirect;
if (p.hasTransport) {
if (len < at + 5) return false;
p.transport[0] = static_cast<uint16_t>(data[at] | data[at + 1] << 8);
p.transport[1] = static_cast<uint16_t>(data[at + 2] | data[at + 3] << 8);
at += 4;
}
uint8_t pathLen = data[at++];
p.hops = pathLen & 0x3F;
p.hashSize = static_cast<uint8_t>((pathLen >> 6) + 1);
size_t pathBytes = static_cast<size_t>(p.hops) * p.hashSize;
if (p.hashSize > 3 || pathBytes > kMaxPath || at + pathBytes > len) return false;
p.path = data + at;
at += pathBytes;
p.payload = data + at;
p.payloadLen = len - at;
if (p.payloadLen > kMaxPayload) return false;
out = p;
return true;
}
bool parseAdvert(const Packet& p, Advert& out) {
constexpr size_t kFixed = 32 + 4 + 64; // key, timestamp, signature
if (p.type != Payload::Advert || p.payloadLen < kFixed + 1) return false;
Advert a;
std::memcpy(a.key, p.payload, 32);
a.timestamp = static_cast<uint32_t>(le32(p.payload + 32));
const uint8_t* app = p.payload + kFixed;
size_t left = p.payloadLen - kFixed;
uint8_t flags = app[0];
size_t at = 1;
a.kind = flags & 0x0F;
if (flags & 0x10) {
if (left < at + 8) return false;
a.hasLocation = true;
a.latE6 = le32(app + at);
a.lonE6 = le32(app + at + 4);
at += 8;
}
if (flags & 0x20) at += 2; // two reserved fields
if (flags & 0x40) at += 2;
if (at > left) return false;
if (flags & 0x80)
for (; at < left && app[at]; ++at) a.name += app[at] >= 0x20 && app[at] < 0x7F ? static_cast<char>(app[at]) : '?';
out = a;
return true;
}
bool parseDiscover(const Packet& p, Discover& out) {
if (p.type != Payload::Control || p.payloadLen < 6) return false;
uint8_t sub = p.payload[0] >> 4;
Discover d;
d.tag = static_cast<uint32_t>(le32(p.payload + 2));
if (sub == 0x8) {
d.kinds = p.payload[1];
} else if (sub == 0x9) {
d.response = true;
d.kind = p.payload[0] & 0x0F;
d.snr = static_cast<int8_t>(p.payload[1]) / 4.0f;
d.id = p.payload + 6;
d.idLen = p.payloadLen - 6;
if (d.idLen != 8 && d.idLen != 32) return false;
} else {
return false;
}
out = d;
return true;
}
const char* routeName(Route r) {
switch (r) {
case Route::TransportFlood: return "flood in a region";
case Route::Flood: return "flood";
case Route::Direct: return "direct";
case Route::TransportDirect: return "direct in a region";
}
return "?";
}
const char* payloadName(Payload t) {
switch (t) {
case Payload::Request: return "request";
case Payload::Response: return "response";
case Payload::Text: return "text message";
case Payload::Ack: return "ack";
case Payload::Advert: return "advert";
case Payload::GroupText: return "channel message";
case Payload::GroupData: return "channel data";
case Payload::AnonRequest: return "anonymous request";
case Payload::Path: return "returned path";
case Payload::Trace: return "trace";
case Payload::Multipart: return "multipart";
case Payload::Control: return "control";
case Payload::RawCustom: return "custom";
}
return "?";
}
const char* payloadShort(Payload t) {
switch (t) {
case Payload::Request: return "req";
case Payload::Response: return "rsp";
case Payload::Text: return "txt";
case Payload::Ack: return "ack";
case Payload::Advert: return "adv";
case Payload::GroupText: return "chan";
case Payload::GroupData: return "cdat";
case Payload::AnonRequest: return "anon";
case Payload::Path: return "path";
case Payload::Trace: return "trace";
case Payload::Multipart: return "multi";
case Payload::Control: return "ctl";
case Payload::RawCustom: return "raw";
}
return "?";
}
const char* kindName(uint8_t kind) {
switch (kind) {
case 1: return "Chat node";
case 2: return "Repeater";
case 3: return "Room server";
case 4: return "Sensor";
}
return "Node";
}
} // namespace roro::meshcore
+79
View File
@@ -0,0 +1,79 @@
#pragma once
#include <cstddef>
#include <cstdint>
#include <string>
// MeshCore's packets as they are on air (its docs/packet_format.md and docs/payloads.md): what is
// sent in clear, which is the header, the path, and a node's advertisement. meshcore_channel.h
// reads the public channel's messages.
namespace roro::meshcore {
// Radio settings: RadioLib's "private" sync word, and the preset its EU/UK networks use.
constexpr uint8_t kSyncWord = 0x12;
constexpr uint16_t kPreambleLength = 16;
constexpr const char* kPresetName = "MeshCore"; // "EU/UK (Narrow)" in its apps
constexpr uint32_t kEuNarrowHz = 869618000;
constexpr float kEuNarrowBwKHz = 62.5f;
constexpr uint8_t kEuNarrowSf = 8, kEuNarrowCr = 8;
// The first byte of the SHA-256 of the public channel's key (izOH6cXN6mrJ5e26oRXNcg==): what a
// group message on the channel every node has starts with.
constexpr uint8_t kPublicChannelHash = 0x11;
enum class Route : uint8_t { TransportFlood = 0, Flood = 1, Direct = 2, TransportDirect = 3 };
enum class Payload : uint8_t {
Request = 0x0, Response = 0x1, Text = 0x2, Ack = 0x3, Advert = 0x4, GroupText = 0x5, GroupData = 0x6,
AnonRequest = 0x7, Path = 0x8, Trace = 0x9, Multipart = 0xA, Control = 0xB, RawCustom = 0xF,
};
struct Packet {
Route route = Route::Flood;
Payload type = Payload::Request;
bool hasTransport = false;
uint16_t transport[2] = {0, 0}; // the first is the region's; the second is reserved
uint8_t hops = 0; // entries in the path
uint8_t hashSize = 1; // bytes each
const uint8_t* path = nullptr; // hops * hashSize bytes
const uint8_t* payload = nullptr;
size_t payloadLen = 0;
bool flood() const { return route == Route::Flood || route == Route::TransportFlood; }
// Sent to one node from one node, each named by the first byte of its key.
bool addressed() const { return type == Payload::Request || type == Payload::Response || type == Payload::Text || type == Payload::Path; }
bool group() const { return type == Payload::GroupText || type == Payload::GroupData; }
};
// False when the bytes can't be a MeshCore packet: a version other than the first, a reserved
// type, or a path longer than what follows.
bool parsePacket(const uint8_t* data, size_t len, Packet& out);
// A node saying who it is, signed: never encrypted.
struct Advert {
uint8_t key[32];
uint32_t timestamp = 0; // the node's clock, seconds since 1970
uint8_t kind = 0; // 1 chat, 2 repeater, 3 room server, 4 sensor
bool hasLocation = false;
int32_t latE6 = 0, lonE6 = 0;
std::string name; // printable ASCII kept, the rest as '?'
};
bool parseAdvert(const Packet& p, Advert& out);
// Control packets that look for nodes nearby, and the answers: sent in clear, to neighbours only.
struct Discover {
bool response = false;
uint8_t kinds = 0; // a request: one bit per kind of node wanted (bit 2: repeaters)
uint8_t kind = 0; // a response: what answers, as in an advert
float snr = 0; // a response: how well it heard the request, dB
uint32_t tag = 0; // random in the request, repeated in its responses
const uint8_t* id = nullptr; // a response: the node's key, or its first 8 bytes
size_t idLen = 0;
};
bool parseDiscover(const Packet& p, Discover& out);
const char* routeName(Route r); // "flood", "direct", ...
const char* payloadName(Payload t); // "advert", "text", ...
const char* payloadShort(Payload t); // for a list row: "adv", "txt", ...
const char* kindName(uint8_t kind); // "Chat node", "Repeater", ...
} // namespace roro::meshcore
+1 -1
View File
@@ -35,7 +35,7 @@ const Definition kDefinitions[] = {
{"wifi_on", Kind::Bool, 1, nullptr, 0, 1},
{"gnss_on", Kind::Bool, 1, nullptr, 0, 1},
{"coord_dms", Kind::Bool, 0, nullptr, 0, 1},
{"lora_preset", Kind::Int, 0, nullptr, 0, 6}, // LongFast first
{"lora_preset", Kind::Int, 7, nullptr, 0, 7}, // MeshCore, after the 7 Meshtastic ones
{"dns1", Kind::String, 0, "9.9.9.9", 7, 15}, // Quad9
{"dns2", Kind::String, 0, "1.1.1.1", 0, 15}, // Cloudflare
{"dns_always", Kind::Bool, 0, nullptr, 0, 1},
+1 -1
View File
@@ -23,7 +23,7 @@ enum class Setting : uint8_t {
WifiEnabled, // bool: the Wi-Fi Service stays Connected when a Saved Network is in range
GnssEnabled, // bool: the GNSS Service reads the receiver (M2, Q58)
CoordinatesDms, // bool: show degrees, minutes and seconds instead of decimal degrees (Q64)
LoraPreset, // int: the LoRa Scanner's Meshtastic preset, an index into the EU868 list (M3, Q95)
LoraPreset, // int: the LoRa Scanner's preset, an index into lora::scanPreset (M3, Q95): MeshCore by default
Dns1, // string: the first DNS server, an IPv4 address (S1, Q108, Q109)
Dns2, // string: the second, or empty
DnsAlways, // bool: use them on Automatic (DHCP) networks too, instead of DHCP's
+81
View File
@@ -0,0 +1,81 @@
#include "scp_args.h"
#include <vector>
namespace roro::term {
namespace {
std::vector<std::string> words(const std::string& text) {
std::vector<std::string> out;
for (size_t at = 0; at < text.size();) {
size_t end = text.find(' ', at);
if (end == std::string::npos) end = text.size();
if (end > at) out.push_back(text.substr(at, end - at));
at = end + 1;
}
return out;
}
// user@host:path, as opposed to a path on the card.
bool isRemote(const std::string& w) {
if (w.empty() || w[0] == '/') return false;
size_t at = w.find('@'), colon = w.find(':');
return at != std::string::npos && colon != std::string::npos && at < colon;
}
std::string baseName(const std::string& path) {
size_t slash = path.rfind('/');
return slash == std::string::npos ? path : path.substr(slash + 1);
}
} // namespace
std::string parseScp(const std::string& args, ScpArgs& out) {
static const char* kUsage = "scp [-f] [-P port] <file on the card> user@host:path | scp [-f] [-P port] user@host:path <file on the card>";
std::vector<std::string> w = words(args);
long port = 0;
bool replace = false;
while (!w.empty() && w[0][0] == '-') {
if (w[0] == "-f") {
replace = true;
w.erase(w.begin());
} else if (w[0] == "-P" && w.size() >= 2) {
port = 0;
for (char c : w[1]) {
if (c < '0' || c > '9' || port > 65535) return "a port from 1 to 65535";
port = port * 10 + (c - '0');
}
if (port < 1 || port > 65535) return "a port from 1 to 65535";
w.erase(w.begin(), w.begin() + 2);
} else {
return kUsage;
}
}
if (w.size() != 2) return kUsage;
bool firstRemote = isRemote(w[0]), secondRemote = isRemote(w[1]);
if (firstRemote == secondRemote) return firstRemote ? "one side has to be on the card, not both on servers" : kUsage;
ScpArgs a;
a.upload = secondRemote;
a.replace = replace;
const std::string& remote = a.upload ? w[1] : w[0];
std::string local = a.upload ? w[0] : w[1];
size_t colon = remote.find(':');
std::string why = parseSshTarget(remote.substr(0, colon), a.target);
if (!why.empty()) return why;
if (port) a.target.port = static_cast<uint16_t>(port);
a.remote = remote.substr(colon + 1);
if (a.remote.empty()) return "say where on " + a.target.host + ": user@host:path";
if (local.empty() || local[0] != '/') return "a path on the card starts with a slash";
if (local.back() == '/') {
if (a.upload) return "that's a folder: scp copies one file";
std::string name = baseName(a.remote);
if (name.empty()) return "name the file to fetch";
local += name;
}
a.local = local;
out = a;
return "";
}
} // namespace roro::term
+23
View File
@@ -0,0 +1,23 @@
#pragma once
#include <string>
#include "ssh_hosts.h"
namespace roro::term {
// What `scp` was asked: one file to the card from a server, or from the card to a server.
// scp [-f] [-P port] /notes/a.txt user@host:path (upload)
// scp [-f] [-P port] user@host:path /notes/a.txt (download; a destination ending in / gets the remote file's name)
// The card's paths start with a slash; the server's are the server's own (relative ones start at the home folder).
struct ScpArgs {
bool upload = false;
bool replace = false; // -f: a download may replace a file on the card
SshTarget target;
std::string local, remote;
};
// "" or what is wrong with it.
std::string parseScp(const std::string& args, ScpArgs& out);
} // namespace roro::term
+18
View File
@@ -8,8 +8,26 @@
#define RORO_VERSION "unknown"
#endif
#if __has_include("sdkconfig.h")
#include "sdkconfig.h"
#endif
namespace roro {
const char* versionString() { return RORO_VERSION; }
const char* architectureString() {
#if defined(CONFIG_IDF_TARGET_ESP32S3)
return "xtensa-lx7 esp32s3";
#elif defined(CONFIG_IDF_TARGET_ESP32)
return "xtensa-lx6 esp32";
#elif defined(CONFIG_IDF_TARGET)
return CONFIG_IDF_TARGET;
#else
return "host";
#endif
}
std::string unameString() { return std::string(kProductName) + " " + versionString() + " " + architectureString(); }
} // namespace roro
+8
View File
@@ -1,5 +1,7 @@
#pragma once
#include <string>
namespace roro {
constexpr const char* kProductName = "roro9stack";
@@ -9,4 +11,10 @@ constexpr const char* kProductName = "roro9stack";
// that one file, and everything else comes from the build cache (issue #74).
const char* versionString();
// The chip this firmware is built for: "xtensa-lx7 esp32s3". "host" in the native tests.
const char* architectureString();
// What `uname` and IRC's /uname report: "roro9stack v0.22.0 xtensa-lx7 esp32s3".
std::string unameString();
} // namespace roro
+2
View File
@@ -26,6 +26,8 @@ lib_deps =
jgromes/RadioLib @ 7.8.1
esphome/wireguard @ 0.4.8
ewpa/LibSSH-ESP32 @ 5.10.0
; WireGuard brings libsodium in; 1.10021.12 defines crypto_sign_ed25519_open as LibSSH does, and the two do not link
esphome/libsodium @ 1.10021.11
test_ignore = *
; Smaller TLS buffers (M2): the framework is rebuilt with these settings (pioarduino "hybrid
; compile"). Receive stays 16 KB (servers send full TLS records); send drops to 4 KB (IRC lines are
-1
View File
@@ -1 +0,0 @@
claude --resume a3bf56ca-1259-41f1-99f6-9acea510e771
+7 -3
View File
@@ -21,7 +21,7 @@ boot other restart into the other app slot (manual Rollback)
log level <0-5> ESP-IDF log level (0 none ... 5 verbose)
ls [folder] | du <path> | mkdir <path> | rm [-r] [-f] <path> | cp [-f] <from> <to> | mv [-f] <from> <to> | cancel the SD card, with the Storage App's rules (rm -r for a folder; -f: the Shell doesn't ask; * and ? in a name: /notes/*.txt)
screenshot [seconds] the screen as a PNG in /screenshots on the card, now or after a pause
lora probe | lora status | lora rx on|off | lora preset <name> the LoRa radio, receive only
lora probe | lora status | lora rx on|off | lora preset <name> the LoRa radio, receive only; a Meshtastic preset (LongFast...) or MeshCore
lora capture start|stop a LoRa Capture to /captures/lora (pcap, LoRaTap)
lora sweep on [from MHz] [to MHz] [step kHz] | lora sweep off | lora sweep dump RSSI across a band (863 870 100)
lora custom <MHz> <BW kHz> <SF> <CR 5-8> <sync hex> [preamble] e.g. 868.1 125 7 5 34 8 (LoRaWAN)
@@ -42,6 +42,8 @@ Irc | Wifi | Gnss | Gemini | Lora | Storage | Notes | Shell | System | Settings
ping <host> [count] [size] | nslookup <name> [server] | port <host> <port> | traceroute <host> | cancel is it there, does its name resolve, is its port open, which way; one at a time
tls <host> [port] | ntp [server] a TLS handshake: who the certificate is for, by whom, until when, and whether this device trusts it; a time server's clock against this one
ifconfig | arp | netstat the interfaces (Wi-Fi and the VPN), their addresses, the default route and the DNS servers; the neighbours heard; what listens and what is connected
uname the firmware, its version and the chip it is built for
scp [-f] [-P port] <file on the card> user@host:path | scp [-f] [-P port] user@host:path <file on the card> one file over SSH, with this device's key or, in the Shell, a password, to a server the SSH App already trusts; -f replaces a file on the card; `cancel` stops it
ssh user@host[:port] | ssh status | ssh stop a terminal on another machine, in the SSH App; the password is asked there, never here
vpn status | vpn up [seconds] | vpn down | vpn import [path] | vpn forget | vpn auto on|off the WireGuard tunnel (Settings > VPN); import reads /vpn/wg0.conf; with seconds, it goes down by itself
debug status | debug off [seconds] the Debug Console over Wi-Fi (Settings > Debug Console); with seconds, it comes back
@@ -58,7 +60,7 @@ get <path> | put <path> <size> <sha256> | screenshot (Debug Console only) bina
quit close the Debug Console connection
```
In **Safe Mode** (see [Crashes and Safe Mode](/dev/debug/crashes/)) only a few run: `help`, `info`, `tasks`, `net`, `reboot`, `boot other`, `wifi status`, and anything starting with `log level`, `crash`, `coredump`, `wifi add`, `debug`. Anything else answers `not available in Safe Mode`.
In **Safe Mode** (see [Crashes and Safe Mode](/dev/debug/crashes/)) only a few run: `help`, `uname`, `info`, `tasks`, `net`, `reboot`, `boot other`, `wifi status`, and anything starting with `log level`, `crash`, `coredump`, `wifi add`, `debug`. Anything else answers `not available in Safe Mode`.
## What they do
@@ -100,7 +102,7 @@ In **Safe Mode** (see [Crashes and Safe Mode](/dev/debug/crashes/)) only a few r
| `lora probe` | Finds the radio: chip, oscillator, antenna switch, DIO1 interrupt, noise floor |
| `lora status` | Radio settings, who's listening, packet and error counters, noise floor, task stack |
| `lora rx on` / `lora rx off` | Listens and prints each packet on the console |
| `lora preset <name>` / `lora custom <MHz> <BW kHz> <SF> <CR> <sync hex> [preamble]` | Receive settings: a Meshtastic preset, or anything else (`lora custom 868.1 125 7 5 34 8` for LoRaWAN) |
| `lora preset <name>` / `lora custom <MHz> <BW kHz> <SF> <CR> <sync hex> [preamble]` | Receive settings: a Meshtastic preset or `MeshCore`, or anything else (`lora custom 868.1 125 7 5 34 8` for LoRaWAN) |
| `lora capture start` / `lora capture stop` | A LoRa Capture, as `c` in the App |
| `lora sweep on [from MHz] [to MHz] [step kHz]` / `lora sweep off` / `lora sweep dump` | Sweep a band (863 870 100 by default), with a summary every 2 s (floor, strongest, peaks), or print the latest pass |
| `gnss quiet on` / `gnss quiet off` | The "Pause GNSS for LoRa" setting |
@@ -118,6 +120,8 @@ In **Safe Mode** (see [Crashes and Safe Mode](/dev/debug/crashes/)) only a few r
| `tls <host> [port]` / `ntp [server]` | A TLS handshake that checks nothing, then the certificate said in words: who it is for, who signed it, until when, its SHA-256, and whether this device's roots and the name asked for accept it (about 52 KB of heap while it runs; refused under 70 KB free). A time server's clock against the device's, with the round trip |
| `ifconfig` / `arp` / `netstat` | The interfaces (Wi-Fi and the VPN) with their addresses, MTU, which is the default route, and the DNS servers; the neighbours heard on the Wi-Fi; what listens and what is connected |
| `ssh user@host[:port]` / `ssh status` / `ssh stop` | Opens the SSH App and connects (the password is asked there, never on a console); the session's state and this device's public key; end the session |
| `scp [-f] [-P port] <file> user@host:path` / `scp [-f] [-P port] user@host:path <file>` | One file between the card and a server, with the device's key, or a password asked (masked) in the Shell, to a server the SSH App already trusts; `-f` replaces a file on the card; `cancel` stops it |
| `uname` | The firmware, its version and the chip it is built for (IRC has `/uname`) |
| `vpn status` / `vpn up [seconds]` / `vpn down` / `vpn import [path]` / `vpn forget` / `vpn auto on\|off` | The WireGuard tunnel: its state, on (for that many seconds, then off by itself: for trying a configuration from afar), off, read a `.conf` from the card (`/vpn/wg0.conf`), erase it, start with Wi-Fi. No key is ever printed |
| `debug status` / `debug off [seconds]` | The Debug Console: whether it's on, has a token and a client; switch it off. With a number of seconds, it comes back by itself after that long |
| `debug on` / `debug token <value>` / `debug token new` | USB serial only: switch it on (making a token if there's none), give it a token of 16 to 64 characters, or make a new one. The token is never printed |
+1 -1
View File
@@ -44,7 +44,7 @@ tag = "M3"
| Q92 | A **Radio Service** owns the SX1262: driver, bus lock, IRQ task. The LoRa Scanner uses it in M3; the Mesh Service sits on top of it in M4. |
| Q93 | Every radio transfer takes the shared bus lock (`SPI.beginTransaction`, as the card does). DIO1's interrupt only wakes the task; no SPI in the ISR. **Done when** a Gemini page streams to the card while the Sniffer receives, with no lost packets and no card errors (`lora status` counters). |
| Q94 | **Receive only:** the Radio Service has no transmit function in M3. It doesn't exist, rather than being unused. |
| Q95 | Sniffer defaults: **EU868 LongFast**, 869.525 MHz, BW 250 kHz, SF 11, CR 4/5, sync word 0x2B, preamble 16 (Q19). The other Meshtastic presets are offered, plus custom settings. |
| Q95 | Sniffer defaults: **EU868 LongFast**, 869.525 MHz, BW 250 kHz, SF 11, CR 4/5, sync word 0x2B, preamble 16 (Q19). The other Meshtastic presets are offered, plus custom settings. *Later, the default became is the MeshCore preset (869.618 MHz, BW 62.5 kHz, SF 8, CR 4/8, sync word 0x12): it is what is heard here.* |
| Q96 | The Sniffer lists packets (time, RSSI, SNR, frequency error, length; hex dump on Enter) **and decodes the Meshtastic header**: the first 16 bytes are never encrypted (destination, sender, packet ID, hop limit and hop start, channel hash, next hop, relay node). Host-tested. Payload decryption is M4. |
| Q97 | A Sniffer **Capture** is pcap with **LoRaTap** headers (link type 270), for Wireshark. Started by hand, Status Bar mark, its own Clean-up category, the 90% rule. |
| Q98 | **Sweep** steps across the Region's band (863–870 MHz) in 100 kHz steps by default, reading instant RSSI: bars with peak hold, and a waterfall, Wi-Fi Tools style. Optionally CAD on the preset's frequency to tell LoRa traffic from noise. |
+2 -2
View File
@@ -12,7 +12,7 @@ Press **<kbd>Fn</kbd> + <kbd>h</kbd>**, on any screen: it lists the keys that wo
## Can I send messages over the mesh?
**Not yet.** The LoRa Scanner **listens** to Meshtastic traffic and shows what it hears, but nothing is transmitted. A mesh messenger is the goal and is planned in two milestones, one for receiving and one for transmitting; it waits for a second node to test against.
**Not yet.** The LoRa Scanner **listens** to Meshtastic or MeshCore traffic and shows what it hears, but nothing is transmitted. A mesh messenger is the goal and is planned in two milestones, one for receiving and one for transmitting; it waits for a second node to test against.
## Is this Meshtastic?
@@ -67,7 +67,7 @@ Yes, WireGuard: one tunnel to one server, set up by copying the client's `.conf`
## Can it log in to my server?
Yes, over SSH: the [SSH App](/guide/ssh/) is a terminal on another machine, with a password or with a key the device makes for itself. `vim`, `top` and `less` work. One session at a time, and not at the same time as IRC: there isn't the memory for both.
Yes, over SSH: the [SSH App](/guide/ssh/) is a terminal on another machine, with a password or with a key the device makes for itself. `vim`, `top` and `less` work. One session at a time, and not at the same time as IRC: there isn't the memory for both. `scp` in the [Shell](/guide/shell/) copies a file between the card and a server.
## How do I copy files to and from my phone?
+3
View File
@@ -31,10 +31,13 @@ You type at the bottom; <kbd>Enter</kbd> sends. A line starting with `/` is a co
| `/nick newnick` | Changes your nick |
| `/topic [text]` | Shows or sets the channel topic |
| `/names [#channel]` | Lists who is there |
| `/uname` | Shows the firmware, its version and the chip, in this buffer only: nothing is sent |
| `/quit [message]` | Disconnects and **stays disconnected** until you type something again |
| `/raw …` (or `/quote`) | Sends a line to the server as it is |
| `/settings` | The settings page |
Anyone who asks the device for its CTCP `VERSION` (`/ctcp roro VERSION` in most clients) is told `roro9stack` and the firmware version.
Each server, channel and private chat is a **buffer**. <kbd>Tab</kbd> moves to the next one, each shows how many messages are unread, and your own lines are in the accent colour. Scroll back with <kbd>Alt</kbd> + <kbd>;</kbd> (older) and <kbd>Alt</kbd> + <kbd>.</kbd> (newer). The up and down arrows (<kbd>Fn</kbd> + <kbd>;</kbd> and <kbd>.</kbd>) recall lines you sent.
## Mentions and the unread count
+34 -3
View File
@@ -13,14 +13,45 @@ The Scanner uses the **Cap LoRa-1262**. It **never transmits**: it listens and s
The first view lists what the radio hears, newest first: the time, the RSSI (signal strength, in dBm) and the SNR (signal over noise, in dB). For a **Meshtastic** packet it also shows the sender and the receiver (the last four hex digits of their numbers) and the number of hops.
For a **MeshCore** packet it shows what the packet is, who it concerns and how many repeaters are in its path:
| Row | Is |
|---|---|
| `adv Brussels-R1 2h` | A node saying who it is, by its name, heard through 2 repeaters |
| `txt a3>7f` | A private message from the node whose key starts with `a3` to the one starting with `7f` (`req`, `rsp` and `path` look the same) |
| `Alice: hello 1h` | A message on the **public channel**, read: who says they sent it, and the start of what it says |
| `chan #5c 1h` | A message on another channel, still encrypted; `#5c` is the channel's byte |
| `who's there?` | Someone nearby searching for nodes (repeaters, usually) |
| `here 18a6` | A node answering that search; `18a6` starts its key |
| `ack`, `anon`, `trace`, `ctl` | An acknowledgement, a login, a trace, other control packets |
| Key | Does |
|---|---|
| <kbd>Enter</kbd> | The details of a packet: the Meshtastic header, which is never encrypted, and a hex dump |
| <kbd>p</kbd> | Picks one of the 7 Meshtastic presets allowed in EU868 (LongFast by default) |
| <kbd>Enter</kbd> | The details of a packet: what is never encrypted (below), and a hex dump |
| <kbd>p</kbd> | Picks what to listen to: **MeshCore** (the default), or one of the 7 Meshtastic presets allowed in EU868 |
| <kbd>c</kbd> | Starts or stops a **capture** |
| <kbd>Tab</kbd> | Switches to the Sweep |
The Scanner shows the header and the bytes; it does not decrypt the message itself, which Meshtastic encrypts with the channel's key.
The Scanner shows the header and the bytes. Meshtastic and MeshCore both encrypt the message itself, and the Scanner reads only one kind: what is said on MeshCore's public channel, whose key is published.
**One network at a time.** The radio listens on one frequency with one setting, and the two networks share neither: on a Meshtastic preset no MeshCore packet is heard, and the other way round.
### MeshCore
The **MeshCore** preset is the setting its networks use in Europe, "EU/UK (Narrow)": 869.618 MHz, 62.5 kHz wide, SF8, coding rate 4/8. A network set up differently needs `lora custom` in the [Shell](/guide/shell/).
<kbd>Enter</kbd> on a MeshCore packet shows:
- what it is and how it travels: **flood** (every repeater passes it on, and adds itself to the path) or **direct** (it carries the route to follow);
- the path, one repeater a hop, each named by the first byte of its key;
- for an **advert**, which is signed and never encrypted: the node's name, its kind (chat node, repeater, room server, sensor), the start of its key, and its position if it gives one;
- for a **search** and its **answers**, also in clear: what kind of node is wanted; and in an answer the node's kind, the start of its key, and how well it heard the search;
- for a message on the **public channel**: the name the sender gives, when the sender's clock says it was sent (UTC), and the text;
- for any other message: the first byte of the sender's and the receiver's keys, or the channel's byte, and how many bytes are encrypted.
The public channel is the one every MeshCore node starts with, and its key is the same everywhere, so anyone listening can read it. Private messages and other channels stay encrypted. The sender's name is part of the message and is not signed: anyone can write any name. Letters outside plain ASCII (accents, emoji) show as `?`.
A byte is all a packet says about who it is from: two nodes can share it. The name comes only with a node's advert, which a node sends now and then, so a quiet network can take a while to show its names.
## Capture
+3 -1
View File
@@ -21,6 +21,7 @@ Type a command and press <kbd>Enter</kbd>. `help` lists them all; the [command r
| `wifi status` | The network, the address, and where the DNS and time servers came from |
| `ls /notes` | A folder of the SD card, with sizes and dates |
| `crash` | The last crash, if there was one |
| `uname` | The firmware, its version and the chip it is built for: `roro9stack v0.22.0 xtensa-lx7 esp32s3` |
| `update check` | Whether a newer release exists |
| `lora status` | What the radio is set to and what it has heard |
@@ -45,7 +46,7 @@ The capital is the difference: every command is in small letters, every App star
## The network
For the day the network doesn't do what it should. Each of the first six takes a moment and prints as it goes; one runs at a time, and `cancel` stops it.
For the day the network doesn't do what it should. Each of the first six takes a moment and prints as it goes, and so does `scp`; one runs at a time, and `cancel` stops it.
| Command | Tells you |
|---|---|
@@ -56,6 +57,7 @@ For the day the network doesn't do what it should. Each of the first six takes a
| `tls git.twis.la` | A secure connection's certificate: who it is for, who signed it, until when, and **whether this device trusts it**. A port may follow (443 if not) |
| `ntp` | A time server's clock against this device's, and how far the server is. Another server may follow |
| `ssh user@host` | Opens the [SSH App](/guide/ssh/) and connects; `ssh status` and `ssh stop` for the session |
| `scp /notes/a.txt user@host:docs/` | One file between the card and a server, over [SSH](/guide/ssh/#copying-a-file-scp): with the device's key, or a password asked in the Shell, masked. `cancel` stops it |
| `ifconfig` | The interfaces (Wi-Fi, and the [VPN](/guide/vpn/) when it is up), their addresses, **which one is the default route**, and the DNS servers |
| `arp` | The neighbours heard on the Wi-Fi: is the gateway there at all |
| `netstat` | What the device **listens** on (updates, the Debug Console, sharing) and what is connected to it now |
+22 -1
View File
@@ -9,7 +9,7 @@ screens = ["ssh.png", "ssh-trust.png", "ssh-terminal.png", "ssh-key.png", "ssh-c
The SSH App opens a **terminal on another machine**: a server, a Raspberry Pi, a router. What you type goes there, and what its programs print is drawn here: a shell, `less`, `top`, `nano`, `vim`.
It is a client and nothing more: one session at a time, to a shell. No file transfer, no port forwarding, no jump hosts.
It is a client and nothing more: one session at a time, to a shell. No port forwarding, no jump hosts. Files go one at a time with [`scp` from the Shell](#copying-a-file-scp).
## Connecting
@@ -89,6 +89,26 @@ A session takes about **50 KB** of the device's 100 KB while it is open, and giv
See [When a connection says "not enough memory"](/howto/not-enough-memory/).
## Copying a file: scp
From the [Shell](/guide/shell/), `scp` copies **one file** between the SD card and a server, over the same SSH:
```
scp /notes/a.txt user@host:docs/a.txt the card to the server
scp user@host:/var/log/syslog /logs/ the server to the card, keeping its name
scp -P 2222 /notes/a.txt user@host: another port (-P first); a path is needed after the colon
scp -f user@host:x.txt /notes/x.txt replace a file that is on the card already
```
Paths on the card start with a slash. A path on the server is the server's own, and starts in the user's home folder when it has no slash. Only a file: no folders, no `*`.
- **The server has to be one you have already trusted** in this App (the fingerprint question above). `scp` can't ask that question, and refuses a server it doesn't know, or one whose key changed.
- **It logs in with the device's key, and asks for the password** if the server doesn't accept the key: the Shell shows the question and what you type is masked (<kbd>Back</kbd> cancels). The password is asked only in the Shell, not from a PC on the USB port or the Debug Console.
- **A download arrives under a temporary name** and takes its real one when it is all there, so a cut connection never leaves half a file. A file already there is not replaced without `-f`.
- It prints how long it took. `cancel` stops it. It needs the same 75 KB of free memory as a session, and **not while a session is open**.
See [Copy a file to or from a server](/howto/scp/).
## From the Shell
```
@@ -96,6 +116,7 @@ ssh user@host connect, in the SSH App
ssh user@host:2222 on another port
ssh status the session, and this device's public key
ssh stop end the session
scp ... copy one file, see above
```
## Keys
+1 -1
View File
@@ -36,7 +36,7 @@ The App says why when it refuses.
- **Text** (`.txt`, `.log`, `.gmi`, `.csv`, and anything that looks like text): only a screenful is read from the card, so a file of any size opens at once, and logs open at the end. Up and down move a line, left and right a page, <kbd>t</kbd> and <kbd>b</kbd> go to the top and the end, and <kbd>e</kbd> edits it, whatever its size (as in [Notes](/guide/notes/)).
- **Pictures** (`.png`, `.jpg`, `.bmp`, `.gif`): see [Pictures](#pictures) below.
- **Captures** (`.pcap`): the packets as the [LoRa Scanner](/guide/lora-scanner/) lists them; <kbd>Enter</kbd> shows one with its Meshtastic header and bytes.
- **Captures** (`.pcap`): the packets as the [LoRa Scanner](/guide/lora-scanner/) lists them; <kbd>Enter</kbd> shows one with its Meshtastic or MeshCore header and bytes.
- **Tracks** (`.gpx`): the number of points, the start, the duration and the distance.
- **Update files** (`.ota`): the version, and whether the file would install: it is checked as an install checks it, signature and contents, without writing anything. <kbd>Enter</kbd> then installs it (see [Updates](/guide/updates/)).
- **Anything else:** a hex dump.
+1 -1
View File
@@ -1,6 +1,6 @@
+++
title = "How-tos"
description = "Short recipes for things you will want to do: move files with your phone, set up the VPN, log in to a server with the device's SSH key, take a screenshot, find out why the network doesn't work, record a track, capture radio packets, and what to try when something does not work."
description = "Short recipes for things you will want to do: move files with your phone, set up the VPN, log in to a server with the device's SSH key, copy a file to or from a server, take a screenshot, find out why the network doesn't work, record a track, capture radio packets, and what to try when something does not work."
template = "guide-index.html"
page_template = "guide-page.html"
sort_by = "weight"
+2 -2
View File
@@ -9,12 +9,12 @@ tag = "LoRa Scanner"
The radio only **listens**: nothing is transmitted. You need the **Cap LoRa-1262** and an **SD card**.
1. **Open the LoRa Scanner.** The Status Bar shows `L` while the radio listens.
2. **Pick a preset with <kbd>p</kbd>.** The Scanner offers the 7 Meshtastic presets allowed in EU868; LongFast is the default.
2. **Pick a preset with <kbd>p</kbd>.** The Scanner offers MeshCore (the default) and the 7 Meshtastic presets allowed in EU868.
3. **Wait for packets.** Each line is a packet: the time, RSSI and SNR, and for a Meshtastic packet the sender, the receiver and the hops. <kbd>Enter</kbd> shows a packet's header and its bytes.
4. **Press <kbd>c</kbd> to start a capture.** The Status Bar shows `CAP`. It keeps recording with the App closed.
5. **Press <kbd>c</kbd> again to stop.**
6. **Get the file.** It is in `/captures/lora/`, a `.pcap` with LoRaTap headers. In the [Storage App](/guide/storage/) you can already look at its packets; on a computer, open it in Wireshark.
**What you will and will not see.** Meshtastic's header is never encrypted, so who sent a packet and how far it hopped is visible. The message itself is encrypted with the channel's key, and the Scanner does not decrypt it.
**What you will and will not see.** Meshtastic's header is never encrypted, so who sent a packet and how far it hopped is visible. The message itself is encrypted with the channel's key, and the Scanner does not decrypt it. MeshCore's public channel is the exception: its key is published, and the Scanner [reads what is said there](/guide/lora-scanner/#meshcore).
**Hearing nothing is normal** if no node is in range, or if the preset does not match what the nodes nearby use. The [Sweep](/guide/lora-scanner/) (<kbd>Tab</kbd>) shows whether anything is on the air at all across 863 to 870 MHz.
+40
View File
@@ -0,0 +1,40 @@
+++
title = "Copy a file to or from a server"
description = "Send a note from the SD card to a server, or fetch a log from it, with scp in the Shell."
weight = 14
[extra]
tag = "SSH"
+++
One file at a time, between the SD card and a server you can log in to over SSH.
1. **Connect to the server once in the SSH App** (**SSH → New connection**, `user@host`) and trust its fingerprint. `scp` can't ask that question, so it only goes to servers it has met there.
2. For no password to type, put the device's key on the server: [Log in to a server without a password](/howto/ssh-key/). Without it, `scp` asks for the password.
3. Open the **Shell** and type, for a file going **to** the server:
```
scp /notes/a.txt user@host:docs/a.txt
```
or one coming **from** it, into a folder on the card (it keeps its name):
```
scp user@host:/var/log/syslog /logs/
```
You should see `scp: ... (1234 bytes) to ...`, then `scp: sent 1234 bytes in 310 ms` (or `received`).
4. If it says `password for host`, type it and press <kbd>Enter</kbd>: it shows as stars and is kept nowhere. <kbd>Back</kbd> cancels.
## If it doesn't work
| It says | Do |
|---|---|
| `is not a server this device trusts yet` | Step 1: connect once in the SSH App |
| `showed a different key than the one remembered` | The server was reinstalled, or something answers in its place. Check in the SSH App, which asks what to do |
| `exists already` | The file is on the card: add `-f` (`scp -f user@host:x /notes/x`) to replace it |
| `An SSH session is open` or `Not enough memory` | End the session (`ssh stop`), or close IRC or a Gemini page: [not enough memory](/howto/not-enough-memory/) |
| `not logged in`, `wrong password` | Three tries, then it stops. Check the user, or the key on the server |
| `a password is only asked in the Shell` | You typed it from a PC on the USB port or the Debug Console: use the Shell, or put the key on the server |
| `that isn't a plain file` | `scp` copies one file, not a folder |
Another port goes first: `scp -P 2222 /notes/a.txt user@host:a.txt`. `cancel` stops a copy that is running.
+1 -1
View File
@@ -17,7 +17,7 @@ Typing a password on a small keyboard, every time, gets old. With a key, the ser
chmod 600 ~/.ssh/authorized_keys
```
4. On the Cardputer, connect: **SSH → New connection**, `user@host`. It logs in without asking for anything.
4. On the Cardputer, connect: **SSH → New connection**, `user@host`. It logs in without asking for anything. The same key is what [`scp`](/howto/scp/) uses.
## If it still asks for a password
+1 -1
View File
@@ -5,7 +5,7 @@
num = "01"
tag = "LoRa Scanner"
title = "See what is on the air"
text = "Lists every packet it hears with time, RSSI, SNR, sender and hops, reading the Meshtastic header. A Sweep shows signal strength from 863 to 870 MHz. Capture to a pcap for Wireshark."
text = "Lists every packet it hears with time, RSSI, SNR, sender and hops, reading the Meshtastic or MeshCore header. A Sweep shows signal strength from 863 to 870 MHz. Capture to a pcap for Wireshark."
fact = "Only listens, never transmits"
icon = 1
+1 -1
View File
@@ -206,7 +206,7 @@ title = "LoRa Scanner, the packets"
rows = [
["; .", "up, down"],
["Enter", "the packet's details"],
["p", "pick a Meshtastic preset"],
["p", "pick a preset"],
["c", "start a Capture, or stop it"],
["Tab", "the Sweep"],
]
+3 -4
View File
@@ -264,10 +264,9 @@ void FileViewer::openPacket(int index) {
if (tap) {
std::snprintf(line, sizeof line, "%.0f dBm, SNR %.1f dB, noise %.0f", rx.rssi, rx.snr, rx.noiseFloor);
details_.push_back(line);
std::snprintf(line, sizeof line, "%.4f MHz, SF%u, %.0f kHz", rx.frequencyHz / 1e6, rx.spreadingFactor, rx.bandwidthKHz);
std::snprintf(line, sizeof line, "%.4f MHz, SF%u, %g kHz", rx.frequencyHz / 1e6, rx.spreadingFactor, rx.bandwidthKHz);
details_.push_back(line);
if (rx.syncWord == meshtastic::kSyncWord)
for (auto& l : lora::headerLines(body + skip, have - skip)) details_.push_back(l);
for (auto& l : lora::headerLines(body + skip, have - skip, lora::protocolOf(rx.syncWord))) details_.push_back(l);
}
for (auto& l : lora::hexDump(body + skip, have - skip)) details_.push_back(l);
if (have < r.length) details_.push_back("(the first bytes only)");
@@ -391,7 +390,7 @@ void FileViewer::refresh() {
size_t have = std::min<size_t>(r.length, n - files::kPcapRecordSize);
if (tap && files::parseLoraTap(body, have, rx)) {
shown_.push_back(lora::row({body + lora::kLoraTapSize, have - lora::kLoraTapSize, rx.rssi, rx.snr, true,
rx.syncWord == meshtastic::kSyncWord},
lora::protocolOf(rx.syncWord)},
clockTime(r.seconds)));
} else {
shown_.push_back(clockTime(r.seconds) + " " + std::to_string(r.length) + " bytes");
+17 -15
View File
@@ -7,7 +7,7 @@
#include <cstdio>
#include <ctime>
#include "meshtastic_presets.h"
#include "scan_presets.h"
#include "packet_view.h"
#include "sweep_view.h"
#include "ui/canvas.h"
@@ -21,8 +21,8 @@ constexpr uint32_t kMessageMs = 4000;
}
void LoraScannerApp::onEnter() {
int i = std::clamp<int>(settings_.getInt(Setting::LoraPreset), 0, meshtastic::kEu868PresetCount - 1);
const meshtastic::Preset& p = meshtastic::kEu868Presets[i];
int i = std::clamp<int>(settings_.getInt(Setting::LoraPreset), 0, lora::scanPresetCount() - 1);
lora::ScanPreset p = lora::scanPreset(i);
if (std::string(radio_.config().name) != p.name) radio_.setPreset(p);
radio_.listen(RadioService::App, true);
view_ = View::Packets;
@@ -48,7 +48,7 @@ void LoraScannerApp::showSweep(bool on) {
requestRedraw();
}
bool LoraScannerApp::meshtasticSettings() const { return radio_.config().syncWord == meshtastic::kSyncWord; }
lora::Protocol LoraScannerApp::protocol() const { return lora::protocolOf(radio_.config().syncWord); }
void LoraScannerApp::say(const std::string& text) {
message_ = text;
@@ -83,8 +83,8 @@ bool LoraScannerApp::onKey(const KeyEvent& e) {
else if (e.key == Key::Select) {
int i = presets_.selected();
settings_.setInt(Setting::LoraPreset, i);
radio_.setPreset(meshtastic::kEu868Presets[i]);
say(std::string("Listening on ") + meshtastic::kEu868Presets[i].name);
radio_.setPreset(lora::scanPreset(i));
say(std::string("Listening on ") + lora::scanPreset(i).name);
view_ = View::Packets;
} else if (e.key == Key::Back) view_ = View::Packets;
else return true;
@@ -104,7 +104,7 @@ bool LoraScannerApp::onKey(const KeyEvent& e) {
return true;
}
if (e.key == Key::Char && (e.ch == 'p' || e.ch == 'P')) {
presets_.setCount(meshtastic::kEu868PresetCount);
presets_.setCount(lora::scanPresetCount());
presets_.select(settings_.getInt(Setting::LoraPreset));
view_ = View::Presets;
requestRedraw();
@@ -133,8 +133,8 @@ void LoraScannerApp::openDetails() {
details_.push_back(line);
std::snprintf(line, sizeof line, "%.4f MHz, off by %.0f Hz", p.rx.frequencyHz / 1e6, p.frequencyError);
details_.push_back(line);
if (meshtasticSettings())
for (auto& l : lora::headerLines(p.data, p.len)) details_.push_back(l);
if (p.crcOk)
for (auto& l : lora::headerLines(p.data, p.len, lora::protocolOf(p.rx.syncWord))) details_.push_back(l);
for (auto& l : lora::hexDump(p.data, p.len)) details_.push_back(l);
detailsTop_ = 0;
view_ = View::Details;
@@ -238,10 +238,12 @@ void LoraScannerApp::drawPackets(Canvas& c) {
c.setFont(&fonts::body);
c.setTextColor(theme::kMuted);
c.drawString("No packets yet.", 4, list.y + 4);
c.drawString(meshtasticSettings() ? "Meshtastic nodes in range show here." : "Custom settings.", 4,
lora::Protocol heard = protocol();
c.drawString(heard == lora::Protocol::Meshtastic ? "Meshtastic nodes in range show here."
: heard == lora::Protocol::MeshCore ? "MeshCore nodes in range show here." : "Custom settings.", 4,
list.y + 4 + theme::kLineHeight);
} else {
bool mesh = meshtasticSettings();
lora::Protocol mesh = protocol();
widgets::list(c, packets_, list, [&](int i) {
RadioPacket p;
if (!radio_.packet(newest - i, p)) return std::string("(gone)");
@@ -265,14 +267,14 @@ void LoraScannerApp::drawPresets(Canvas& c) {
const auto& area = theme::kContent;
c.setFont(&fonts::small);
c.setTextColor(theme::kMuted);
c.drawString("Meshtastic presets allowed in EU868", 4, area.y + 2);
c.drawString("Meshtastic in EU868, and MeshCore", 4, area.y + 2);
widgets::list(
c, presets_, {area.x, area.y + 12, area.w, (kListRows + 1) * theme::kLineHeight},
[](int i) { return std::string(meshtastic::kEu868Presets[i].name); },
[](int i) { return std::string(lora::scanPreset(i).name); },
[](int i) {
char v[24];
const auto& p = meshtastic::kEu868Presets[i];
std::snprintf(v, sizeof v, "SF%u %.0fk %.3f", p.sf, p.bwKHz, meshtastic::eu868FrequencyHz(p) / 1e6);
lora::ScanPreset p = lora::scanPreset(i);
std::snprintf(v, sizeof v, "SF%u %gk %.3f", p.sf, p.bwKHz, p.frequencyHz / 1e6);
return std::string(v);
});
}
+2 -2
View File
@@ -14,7 +14,7 @@
namespace roro {
// The LoRa Scanner (docs/milestones/M3.md). Sniffer: the packets the radio hears, newest first,
// with the Meshtastic header read (Q96) and a hex dump on Enter; p picks the preset (Q95), c starts
// with the Meshtastic or MeshCore header read (Q96) and a hex dump on Enter; p picks the preset (Q95), c starts
// or stops a Capture (Q97). The radio listens while the App is open (Q100). Tab: Sweep, the band's
// RSSI as bars with peak hold and a waterfall (Q98); it pauses the Sniffer while shown (Q99).
class LoraScannerApp : public App {
@@ -40,7 +40,7 @@ class LoraScannerApp : public App {
void showSweep(bool on);
void openDetails();
std::string timeOf(const RadioPacket& p) const;
bool meshtasticSettings() const;
lora::Protocol protocol() const; // whose packets the radio is set for
void say(const std::string& text); // a line in the footer for a few seconds
RadioService& radio_;
+32 -1
View File
@@ -33,6 +33,9 @@ void ShellApp::onEnter() {
// Nothing is kept once it's left: the ring, the lines and the list of commands all go (Q207).
void ShellApp::onExit() {
if (password_ && abort) abort();
password_ = false;
input_.setText("");
console.closeShellRing();
console.shellShowsAll(false);
open_ = false;
@@ -49,6 +52,11 @@ void ShellApp::update(uint32_t) {
if (skipped) log_.add("[... " + std::to_string(skipped) + " bytes lost: more was printed than fits]");
log_.feed(reinterpret_cast<const char*>(buf), n);
}
if (!password_ && asking && asking()) { // after the question has been printed above
password_ = true;
input_.setText("");
requestRedraw();
}
if (log_.revision() != seenRevision_) {
seenRevision_ = log_.revision();
requestRedraw();
@@ -114,6 +122,23 @@ bool ShellApp::onKey(const KeyEvent& e) {
log_.add(console.shellShowsAll() ? "Showing everything the console prints." : "Showing only the replies to your commands.");
return true;
}
if (password_) { // masked, kept nowhere: not in the history, not in the log
switch (e.key) {
case Key::Char: input_.insert(e.ch); break;
case Key::Delete: input_.backspace(); break;
case Key::Select: {
std::string typed = input_.text();
input_.setText("");
password_ = false;
log_.add("> " + std::string(typed.size(), '*'));
if (answer) answer(typed);
typed.assign(typed.size(), '\0');
break;
}
default: return false; // Back leaves the Shell, which cancels the copy
}
return true;
}
std::string recalled;
switch (e.key) {
case Key::Char: input_.insert(e.ch); break;
@@ -197,7 +222,13 @@ void ShellApp::draw(Canvas& c) {
}
c.setTextDatum(top_left);
widgets::lineEditor(c, input_, {2, area.y + area.h - inputH, area.w - 4, 0});
if (password_) {
LineEditor stars(240);
stars.setText(std::string(input_.text().size(), '*'));
widgets::lineEditor(c, stars, {2, area.y + area.h - inputH, area.w - 4, 0});
} else {
widgets::lineEditor(c, input_, {2, area.y + area.h - inputH, area.w - 4, 0});
}
if (confirm_) widgets::dialog(c, "Delete?", question_, *confirm_);
}
+7
View File
@@ -36,6 +36,12 @@ class ShellApp : public App {
ShellApp(Run run, Probe probe, List list, Count count, const char* helpText, AppManager& apps)
: run_(std::move(run)), probe_(std::move(probe)), list_(std::move(list)), count_(std::move(count)), helpText_(helpText), apps_(apps) {}
// `scp` asking for a password: while asking() is true, the next line typed is masked and goes to
// answer() instead of the console; leaving the Shell calls abort().
std::function<bool()> asking;
std::function<void(const std::string&)> answer;
std::function<void()> abort;
void onEnter() override;
void onExit() override;
bool onKey(const KeyEvent& e) override;
@@ -63,6 +69,7 @@ class ShellApp : public App {
uint32_t ringPos_ = 0, seenRevision_ = 0;
int scroll_ = 0; // wrapped lines scrolled back from the bottom
bool open_ = false;
bool password_ = false; // the line being typed is a password
};
} // namespace roro
+31 -6
View File
@@ -14,6 +14,7 @@
#include "apps/shell_app.h"
#include "apps/ssh_app.h"
#include "services/net_tools.h"
#include "services/scp_service.h"
#include "services/ssh_service.h"
#include "services/vpn_service.h"
#include "services/web_share.h"
@@ -63,6 +64,7 @@
#include "storage_paths.h"
#include "ui/notifier.h"
#include "ui/screen.h"
#include "scp_args.h"
#include "version.h"
using namespace roro;
@@ -95,6 +97,8 @@ static VpnService* vpnService; // not in Safe Mode
static NetTools netTools; // ping, nslookup and the rest (issue #90)
static SshService* sshService; // not in Safe Mode (issue #2)
static SshApp* sshApp;
static ShellApp* shellApp;
static ScpService* scpService; // not in Safe Mode
static Notifier* notifier;
static LauncherApp launcher;
static AppManager* apps;
@@ -235,8 +239,13 @@ void setup() {
apps->registerApp({"notes", "Notes", false, new NotesApp(*fileOps, *storageService, *clockService, *power)});
sshService = new SshService(settings);
sshApp = new SshApp(*sshService, settings, *storageService);
scpService = new ScpService(settings, *storageService, *fileOps);
apps->registerApp({"ssh", "SSH", false, sshApp});
apps->registerApp({"shell", "Shell", false, new ShellApp(shellRun, shellProbe, shellList, shellCount, helpText(), *apps)});
shellApp = new ShellApp(shellRun, shellProbe, shellList, shellCount, helpText(), *apps);
apps->registerApp({"shell", "Shell", false, shellApp});
shellApp->asking = [] { return scpService->asking(); };
shellApp->answer = [](const std::string& password) { scpService->answer(password); };
shellApp->abort = [] { scpService->cancel(); };
// Leaving the foreground App makes it save: a note being typed, when the device is powered off.
power->beforePowerOff = []() { apps->home(); };
netTools.ntpServer = []() { return settings.getString(Setting::Ntp1); };
@@ -667,7 +676,7 @@ static const char* const kHelp =
"log level <0-5> ESP-IDF log level (0 none ... 5 verbose)\n"
"ls [folder] | du <path> | mkdir <path> | rm [-r] [-f] <path> | cp [-f] <from> <to> | mv [-f] <from> <to> | cancel the SD card, with the Storage App's rules (rm -r for a folder; -f: the Shell doesn't ask; * and ? in a name: /notes/*.txt)\n"
"screenshot [seconds] the screen as a PNG in /screenshots on the card, now or after a pause\n"
"lora probe | lora status | lora rx on|off | lora preset <name> the LoRa radio, receive only\n"
"lora probe | lora status | lora rx on|off | lora preset <name> the LoRa radio, receive only; a Meshtastic preset (LongFast...) or MeshCore\n"
"lora capture start|stop a LoRa Capture to /captures/lora (pcap, LoRaTap)\n"
"lora sweep on [from MHz] [to MHz] [step kHz] | lora sweep off | lora sweep dump RSSI across a band (863 870 100)\n"
"lora custom <MHz> <BW kHz> <SF> <CR 5-8> <sync hex> [preamble] e.g. 868.1 125 7 5 34 8 (LoRaWAN)\n"
@@ -688,6 +697,8 @@ static const char* const kHelp =
"ping <host> [count] [size] | nslookup <name> [server] | port <host> <port> | traceroute <host> | cancel is it there, does its name resolve, is its port open, which way; one at a time\n"
"tls <host> [port] | ntp [server] a TLS handshake: who the certificate is for, by whom, until when, and whether this device trusts it; a time server's clock against this one\n"
"ifconfig | arp | netstat the interfaces (Wi-Fi and the VPN), their addresses, the default route and the DNS servers; the neighbours heard; what listens and what is connected\n"
"uname the firmware, its version and the chip it is built for\n"
"scp [-f] [-P port] <file on the card> user@host:path | scp [-f] [-P port] user@host:path <file on the card> one file over SSH, with this device's key or, in the Shell, a password, to a server the SSH App already trusts; -f replaces a file on the card; `cancel` stops it\n"
"ssh user@host[:port] | ssh status | ssh stop a terminal on another machine, in the SSH App; the password is asked there, never here\n"
"vpn status | vpn up [seconds] | vpn down | vpn import [path] | vpn forget | vpn auto on|off the WireGuard tunnel (Settings > VPN); import reads /vpn/wg0.conf; with seconds, it goes down by itself\n"
"debug status | debug off [seconds] the Debug Console over Wi-Fi (Settings > Debug Console); with seconds, it comes back\n"
@@ -771,6 +782,16 @@ static void sshCommand(const String& arg) {
}
}
// `scp ...`: see ScpService. It needs the key and the trusted server the SSH App sets up.
static void scpCommand(const String& arg) {
if (!scpService) return (void)console.println("scp: not available in Safe Mode");
term::ScpArgs args;
std::string why = term::parseScp(arg.c_str(), args);
if (!why.empty()) return (void)console.printf("scp: %s\n", why.c_str());
why = scpService->start(args, sshService && sshService->state() != SshService::State::Idle && sshService->state() != SshService::State::Ended);
if (!why.empty()) console.printf("scp: error %s\n", why.c_str());
}
// `vpn ...` (issue #8). Nothing here prints a key.
static void vpnCommand(const String& arg) {
if (!vpnService) return (void)console.println("vpn: not available in Safe Mode");
@@ -840,7 +861,7 @@ static void screenshotStep() {
// Commands that only touch what Safe Mode starts.
static bool safeModeCommand(const String& line) {
return line == "help" || line == "info" || line == "tasks" || line == "net" || line == "reboot" || line == "boot other" ||
return line == "help" || line == "uname" || line == "info" || line == "tasks" || line == "net" || line == "reboot" || line == "boot other" ||
line.startsWith("log level ") || line.startsWith("crash") || line.startsWith("coredump") ||
line == "wifi status" || line.startsWith("wifi add ") || line.startsWith("debug ");
}
@@ -905,6 +926,9 @@ static void runCommand(String line, bool fromSerial = false) {
}
if (netTools.command(line.c_str())) return;
if (line == "cancel") netTools.cancel(); // and a copy or a delete, below
if (line == "uname" || line == "uname -a") return (void)console.println(unameString().c_str());
if (line.startsWith("scp ")) return scpCommand(line.substring(4));
if (line == "cancel" && scpService) scpService->cancel();
if (line == "ssh" || line.startsWith("ssh ")) return sshCommand(line.length() > 4 ? line.substring(4) : String("status"));
if (line == "vpn" || line.startsWith("vpn ")) return vpnCommand(line.length() > 4 ? line.substring(4) : String("status"));
if (line.startsWith("debug ")) return debugCommand(line.substring(6), fromSerial);
@@ -1109,9 +1133,10 @@ static void runCommand(String line, bool fromSerial = false) {
}
}
if (line.startsWith("lora preset ") && radioService) {
const meshtastic::Preset* p = meshtastic::findPreset(line.substring(12).c_str());
if (p) radioService->setPreset(*p);
console.printf("lora preset: %s\n", p ? p->name : "unknown (LongFast LongSlow MediumSlow MediumFast ShortSlow ShortFast LongMod)");
lora::ScanPreset p;
bool found = lora::findScanPreset(line.substring(12).c_str(), p);
if (found) radioService->setPreset(p);
console.printf("lora preset: %s\n", found ? p.name : "unknown (LongFast LongSlow MediumSlow MediumFast ShortSlow ShortFast LongMod MeshCore)");
}
if (line == "gnss quiet on" || line == "gnss quiet off") { // Settings > Pause GNSS for LoRa (issue #20)
settings.setBool(Setting::GnssQuietForLora, line.endsWith("on"));
+29
View File
@@ -0,0 +1,29 @@
#include "platform/libssh_util.h"
#include <libssh_esp32.h>
namespace roro::sshutil {
std::string fingerprintOf(ssh_session s) {
ssh_key key = nullptr;
unsigned char* hash = nullptr;
size_t len = 0;
std::string out;
if (ssh_get_server_publickey(s, &key) == SSH_OK && ssh_get_publickey_hash(key, SSH_PUBLICKEY_HASH_SHA256, &hash, &len) == SSH_OK) {
if (char* text = ssh_get_fingerprint_hash(SSH_PUBLICKEY_HASH_SHA256, hash, len)) {
out = text; // "SHA256:..."
ssh_string_free_char(text);
}
ssh_clean_pubkey_hash(&hash);
}
if (key) ssh_key_free(key);
return out;
}
void startLibrary() {
static bool started = false;
if (!started) libssh_begin();
started = true;
}
} // namespace roro::sshutil
+15
View File
@@ -0,0 +1,15 @@
#pragma once
#include <string>
#include <libssh/libssh.h>
// What the SSH Service and the Scp Service both do with libssh.
namespace roro::sshutil {
// libssh_begin(), once.
void startLibrary();
// "SHA256:..." of the key the server showed, or "".
std::string fingerprintOf(ssh_session s);
} // namespace roro::sshutil
+13 -8
View File
@@ -39,9 +39,8 @@ constexpr float kDeafBelow = -108.0f;
RadioService* RadioService::instance_ = nullptr;
RadioService::RadioService() : lock_(xSemaphoreCreateMutex()) {
const meshtastic::Preset& p = meshtastic::kEu868Presets[0]; // LongFast (Q95)
config_ = {meshtastic::eu868FrequencyHz(p), p.bwKHz, p.sf, p.cr, meshtastic::kSyncWord, meshtastic::kPreambleLength,
p.name};
lora::ScanPreset p = lora::scanPreset(lora::kDefaultScanPreset);
config_ = {p.frequencyHz, p.bwKHz, p.sf, p.cr, p.syncWord, p.preamble, p.name};
}
void RadioService::start() {
@@ -334,10 +333,9 @@ RadioService::Config RadioService::config() const {
return c;
}
void RadioService::setPreset(const meshtastic::Preset& p) {
void RadioService::setPreset(const lora::ScanPreset& p) {
xSemaphoreTake(lock_, portMAX_DELAY);
config_ = {meshtastic::eu868FrequencyHz(p), p.bwKHz, p.sf, p.cr, meshtastic::kSyncWord, meshtastic::kPreambleLength,
p.name};
config_ = {p.frequencyHz, p.bwKHz, p.sf, p.cr, p.syncWord, p.preamble, p.name};
xSemaphoreGive(lock_);
configChanged_ = true;
wake(kRequest);
@@ -374,7 +372,14 @@ void RadioService::tick(uint32_t nowMs) {
console.printf("lora: #%lu %u B %.1f dBm SNR %.1f dB%s", (unsigned long)p.seq, p.len, p.rx.rssi, p.rx.snr,
p.crcOk ? "" : " (bad CRC)");
meshtastic::PacketHeader h;
if (meshtastic::parseHeader(p.data, p.len, h))
lora::Protocol protocol = lora::protocolOf(p.rx.syncWord);
if (protocol == lora::Protocol::MeshCore && p.crcOk) { // what the Scanner's details show, on one line
const char* sep = " | ";
for (auto& l : lora::headerLines(p.data, p.len, protocol)) {
console.printf("%s%s", sep, l.c_str());
sep = "; ";
}
} else if (protocol == lora::Protocol::Meshtastic && meshtastic::parseHeader(p.data, p.len, h))
console.printf(" | %s > %s, ch 0x%02X, hops %d/%u%s", meshtastic::nodeId(h.from).c_str(),
meshtastic::nodeId(h.to).c_str(), h.channelHash, h.hopsAway(), h.hopStart(),
h.viaMqtt() ? ", via MQTT" : "");
@@ -400,7 +405,7 @@ void RadioService::printStatus(Print& out) const {
Counters n = counters();
out.printf("radio: %s, %s, antenna switch %s\n", present_ ? "SX1262" : "no radio",
tcxo_ > 0 ? "TCXO 1.8 V" : "crystal", expander_ ? "on (expander 0x43 P0)" : "expander missing");
out.printf("radio: %s, %s %.3f MHz, BW %.0f kHz, SF %u, CR 4/%u, sync 0x%02X, preamble %u\n",
out.printf("radio: %s, %s %.3f MHz, BW %g kHz, SF %u, CR 4/%u, sync 0x%02X, preamble %u\n",
sweeping_ ? "sweeping" : listening_ ? "listening" : "asleep", c.name, c.frequencyHz / 1e6, c.bandwidthKHz, c.spreadingFactor,
c.codingRate, c.syncWord, c.preamble);
out.printf("radio: clients%s%s%s%s\n", clients_ ? "" : " none", clients_ & App ? " App" : "",
+2 -2
View File
@@ -10,7 +10,7 @@
#include <memory>
#include "loratap.h"
#include "meshtastic_presets.h"
#include "scan_presets.h"
#include "service.h"
class SX1262;
@@ -74,7 +74,7 @@ class RadioService : public Service {
bool listening() const { return listening_; }
void listen(Client client, bool on);
Config config() const;
void setPreset(const meshtastic::Preset& preset);
void setPreset(const lora::ScanPreset& preset);
void setConfig(const Config& config); // custom settings (Q95), named "Custom"
uint32_t received() const { return seq_; } // seq of the newest packet
+269
View File
@@ -0,0 +1,269 @@
#include "services/scp_service.h"
#include <Arduino.h>
#include <SD.h>
#include <algorithm>
#include <libssh/libssh.h>
#include "platform/libssh_util.h"
namespace roro {
namespace {
constexpr uint32_t kStack = 20480; // as the SSH Service's
constexpr size_t kPiece = 4096;
std::string baseName(const std::string& path) {
size_t slash = path.rfind('/');
return slash == std::string::npos ? path : path.substr(slash + 1);
}
} // namespace
struct ScpService::Job {
ScpService* owner;
Console::Origin from;
term::ScpArgs args;
std::string privateKey, known;
bool stopped() const { return owner->stop_; }
bool onCard(const std::function<void()>& work) { return owner->storage_.runAndWait(work); }
void run();
bool login(ssh_session s);
void upload(ssh_session s);
void download(ssh_session s);
};
std::string ScpService::start(const term::ScpArgs& args, bool sshOpen) {
if (busy_) return "A copy is running: `cancel` stops it";
if (sshOpen) return "An SSH session is open: close it first, there isn't memory for both";
if (ESP.getFreeHeap() < kNeedFree) return "Not enough memory: close IRC or a Gemini page";
std::string key = settings_.getString(Setting::SshKey);
if (!args.upload) {
if (std::string why = files_.whyReadOnly(args.local, false); !why.empty()) return why;
bool exists = false;
if (!storage_.runAndWait([&]() { exists = SD.exists(args.local.c_str()); })) return "The card isn't answering";
if (exists && !args.replace) return args.local + " exists already: scp -f replaces it";
}
auto* job = new Job{this, console.origin(), args, key, term::SshKnownHosts(settings_.getString(Setting::SshKnown)).fingerprintOf(args.target.hostPort())};
stop_ = asking_ = answered_ = false;
busy_ = true;
if (xTaskCreate(task, "scp", kStack, job, 1, nullptr) != pdPASS) {
busy_ = false;
job->privateKey.assign(job->privateKey.size(), '\0');
delete job;
return "Not enough memory for it";
}
return "";
}
void ScpService::task(void* arg) {
Job* job = static_cast<Job*>(arg);
{
Console::As as(job->from);
job->run();
}
job->privateKey.assign(job->privateKey.size(), '\0');
ScpService* owner = job->owner;
delete job;
owner->busy_ = false;
vTaskDelete(nullptr);
}
void ScpService::answer(const std::string& password) {
if (!asking_) return;
password_ = password;
asking_ = false;
answered_ = true;
}
// This device's key first, then the password, three tries: asked in the Shell that typed the command.
bool ScpService::Job::login(ssh_session s) {
int rc = ssh_userauth_none(s, nullptr);
if (rc == SSH_AUTH_SUCCESS) return true;
int methods = ssh_userauth_list(s, nullptr);
if (!privateKey.empty() && (methods & SSH_AUTH_METHOD_PUBLICKEY)) {
ssh_key key = nullptr;
if (ssh_pki_import_privkey_base64(privateKey.c_str(), nullptr, nullptr, nullptr, &key) == SSH_OK) {
rc = ssh_userauth_publickey(s, nullptr, key);
ssh_key_free(key);
}
}
privateKey.assign(privateKey.size(), '\0');
if (rc == SSH_AUTH_SUCCESS) return true;
if (!(methods & (SSH_AUTH_METHOD_PASSWORD | SSH_AUTH_METHOD_INTERACTIVE))) {
console.println("scp: error the server takes neither this device's key nor a password");
return false;
}
if (from != Console::Origin::Shell) {
console.println("scp: error the server doesn't know this device's key, and a password is only asked in the Shell");
return false;
}
for (int tries = 0; tries < 3 && !stopped(); tries++) {
console.printf("scp: %s's password for %s (Back cancels):\n", args.target.user.c_str(), args.target.host.c_str());
owner->answered_ = false;
owner->asking_ = true;
while (!owner->answered_ && !stopped()) vTaskDelay(pdMS_TO_TICKS(50));
owner->asking_ = false;
if (stopped()) break;
std::string password;
password.swap(owner->password_);
if (methods & SSH_AUTH_METHOD_PASSWORD) rc = ssh_userauth_password(s, nullptr, password.c_str());
else { // asked as questions: every one gets the password
rc = ssh_userauth_kbdint(s, nullptr, nullptr);
for (int round = 0; rc == SSH_AUTH_INFO && round < 4; round++) {
int n = ssh_userauth_kbdint_getnprompts(s);
for (int i = 0; i < n; i++) ssh_userauth_kbdint_setanswer(s, static_cast<unsigned>(i), password.c_str());
rc = ssh_userauth_kbdint(s, nullptr, nullptr);
}
}
password.assign(password.size(), '\0');
if (rc == SSH_AUTH_SUCCESS) return true;
if (rc == SSH_AUTH_ERROR) break;
console.println("scp: wrong password");
}
console.println(stopped() ? "scp: stopped" : "scp: error not logged in");
return false;
}
void ScpService::Job::run() {
sshutil::startLibrary();
ssh_session s = ssh_new();
if (!s) return (void)console.println("scp: error not enough memory");
int verbosity = SSH_LOG_NOLOG, port = args.target.port;
long timeout = 10;
ssh_options_set(s, SSH_OPTIONS_HOST, args.target.host.c_str());
ssh_options_set(s, SSH_OPTIONS_PORT, &port);
ssh_options_set(s, SSH_OPTIONS_USER, args.target.user.c_str());
ssh_options_set(s, SSH_OPTIONS_TIMEOUT, &timeout);
ssh_options_set(s, SSH_OPTIONS_LOG_VERBOSITY, &verbosity);
if (ssh_connect(s) != SSH_OK) {
console.printf("scp: error no connection to %s: %s\n", args.target.hostPort().c_str(), ssh_get_error(s));
} else if (known.empty()) {
console.printf("scp: error %s is not a server this device trusts yet: connect with the SSH App once\n", args.target.hostPort().c_str());
} else if (sshutil::fingerprintOf(s) != known) {
console.printf("scp: error %s showed a different key than the one remembered: not connected\n", args.target.hostPort().c_str());
} else if (login(s)) {
if (args.upload) upload(s);
else download(s);
}
ssh_disconnect(s);
ssh_free(s);
}
void ScpService::Job::upload(ssh_session s) {
// The remote path is where the file goes; the name pushed is only used if that is a folder.
ssh_scp scp = ssh_scp_new(s, SSH_SCP_WRITE, args.remote.c_str());
if (!scp || ssh_scp_init(scp) != SSH_OK) {
console.printf("scp: error %s\n", ssh_get_error(s));
if (scp) ssh_scp_free(scp);
return;
}
File f;
size_t size = 0;
bool opened = onCard([&]() {
f = SD.open(args.local.c_str());
if (f && !f.isDirectory()) size = f.size();
else if (f) f.close();
});
if (!opened || !f) {
console.printf("scp: error cannot read %s\n", args.local.c_str());
ssh_scp_close(scp);
ssh_scp_free(scp);
return;
}
console.printf("scp: %s (%u bytes) to %s:%s\n", args.local.c_str(), (unsigned)size, args.target.text().c_str(), args.remote.c_str());
bool ok = ssh_scp_push_file(scp, baseName(args.local).c_str(), size, 0644) == SSH_OK;
uint8_t buf[kPiece];
uint32_t started = millis();
size_t sent = 0;
while (ok && sent < size && !stopped()) {
int n = 0;
onCard([&]() { n = f.read(buf, std::min(sizeof buf, size - sent)); });
if (n <= 0) {
console.printf("scp: error the card stopped reading at %u of %u\n", (unsigned)sent, (unsigned)size);
ok = false;
break;
}
if (ssh_scp_write(scp, buf, static_cast<size_t>(n)) != SSH_OK) {
console.printf("scp: error %s\n", ssh_get_error(s));
ok = false;
break;
}
sent += static_cast<size_t>(n);
}
onCard([&]() { f.close(); });
if (ok && stopped()) console.printf("scp: stopped at %u of %u bytes\n", (unsigned)sent, (unsigned)size);
else if (ok) console.printf("scp: sent %u bytes in %lu ms\n", (unsigned)sent, (unsigned long)(millis() - started));
else if (!ok && sent == 0) console.printf("scp: error the server refused it: %s\n", ssh_get_error(s));
ssh_scp_close(scp);
ssh_scp_free(scp);
}
void ScpService::Job::download(ssh_session s) {
ssh_scp scp = ssh_scp_new(s, SSH_SCP_READ, args.remote.c_str());
if (!scp || ssh_scp_init(scp) != SSH_OK) {
console.printf("scp: error %s\n", ssh_get_error(s));
if (scp) ssh_scp_free(scp);
return;
}
// Into a part file, renamed once it is all there: a failure never leaves half a file under the real name.
std::string part = args.local + ".part";
File f;
bool ok = false;
size_t got = 0, size = 0;
uint32_t started = millis();
if (ssh_scp_pull_request(scp) != SSH_SCP_REQUEST_NEWFILE) {
const char* why = ssh_get_error(s);
console.printf("scp: error %s\n", why && *why ? why : "that isn't a plain file: scp copies one file");
} else {
size = static_cast<size_t>(ssh_scp_request_get_size64(scp));
ssh_scp_accept_request(scp);
console.printf("scp: %s:%s (%u bytes) to %s\n", args.target.text().c_str(), args.remote.c_str(), (unsigned)size, args.local.c_str());
bool opened = false;
onCard([&]() {
size_t slash = args.local.rfind('/');
if (slash > 0 && !SD.exists(args.local.substr(0, slash).c_str())) SD.mkdir(args.local.substr(0, slash).c_str());
if (SD.exists(part.c_str())) SD.remove(part.c_str());
f = SD.open(part.c_str(), FILE_WRITE);
opened = static_cast<bool>(f);
});
if (!opened) console.printf("scp: error cannot write %s\n", args.local.c_str());
ok = opened;
uint8_t buf[kPiece];
while (ok && got < size && !stopped()) {
int n = ssh_scp_read(scp, buf, std::min(sizeof buf, size - got));
if (n <= 0) {
console.printf("scp: error the connection ended at %u of %u bytes\n", (unsigned)got, (unsigned)size);
ok = false;
break;
}
size_t written = 0;
onCard([&]() { written = f.write(buf, static_cast<size_t>(n)); });
if (written != static_cast<size_t>(n)) {
console.println("scp: error the card refused the write: is it full?");
ok = false;
break;
}
got += static_cast<size_t>(n);
}
if (ok && got < size) ok = false, console.printf("scp: stopped at %u of %u bytes\n", (unsigned)got, (unsigned)size);
}
bool renamed = false;
onCard([&]() {
if (f) f.close();
if (ok) {
if (SD.exists(args.local.c_str())) SD.remove(args.local.c_str());
renamed = SD.rename(part.c_str(), args.local.c_str());
} else if (SD.exists(part.c_str())) {
SD.remove(part.c_str());
}
});
if (ok && !renamed) console.println("scp: error the card refused the new name");
else if (ok) console.printf("scp: received %u bytes in %lu ms\n", (unsigned)got, (unsigned long)(millis() - started));
ssh_scp_close(scp);
ssh_scp_free(scp);
}
} // namespace roro
+50
View File
@@ -0,0 +1,50 @@
#pragma once
#include <atomic>
#include <memory>
#include <string>
#include "platform/console.h"
#include "scp_args.h"
#include "services/file_ops.h"
#include "services/storage_service.h"
#include "settings.h"
namespace roro {
// `scp`: one file between the card and a server, over SSH (libssh's SCP), on a task of its own that
// prints to the console that asked. It logs in with this device's key and, if the server doesn't
// know it, asks for the password: but only when the command was typed in the Shell, which draws
// the question masked (asking(), answer()). It goes only to servers the SSH App has already been
// told to trust. One at a time, and not while an SSH session is open (they don't fit in memory
// together).
class ScpService {
public:
static constexpr size_t kNeedFree = 75 * 1024;
ScpService(Settings& settings, StorageService& storage, FileOps& files) : settings_(settings), storage_(storage), files_(files) {}
// "" when it started, or why not.
std::string start(const term::ScpArgs& args, bool sshOpen);
void cancel() {
stop_ = true;
asking_ = false;
answered_ = true; // whatever it was waiting for
}
bool busy() const { return busy_; }
// The password is wanted (for user@host, in `who`); the Shell calls answer() with what was typed.
bool asking() const { return asking_; }
void answer(const std::string& password);
private:
struct Job;
static void task(void* arg);
Settings& settings_;
StorageService& storage_;
FileOps& files_;
std::atomic<bool> busy_{false}, stop_{false}, asking_{false}, answered_{false};
std::string password_; // written by answer() before answered_ is set, read by the task after
};
} // namespace roro
+5 -24
View File
@@ -5,6 +5,8 @@
#include <libssh_esp32.h>
#include <libssh/libssh.h>
#include "platform/libssh_util.h"
namespace roro {
namespace {
@@ -17,27 +19,6 @@ struct Locked {
SemaphoreHandle_t lock_;
};
std::string fingerprintOf(ssh_session s) {
ssh_key key = nullptr;
unsigned char* hash = nullptr;
size_t len = 0;
std::string out;
if (ssh_get_server_publickey(s, &key) == SSH_OK && ssh_get_publickey_hash(key, SSH_PUBLICKEY_HASH_SHA256, &hash, &len) == SSH_OK) {
if (char* text = ssh_get_fingerprint_hash(SSH_PUBLICKEY_HASH_SHA256, hash, len)) {
out = text; // "SHA256:..."
ssh_string_free_char(text);
}
ssh_clean_pubkey_hash(&hash);
}
if (key) ssh_key_free(key);
return out;
}
void startLibrary() {
static bool started = false;
if (!started) libssh_begin();
started = true;
}
} // namespace
struct SshService::Run {
@@ -170,7 +151,7 @@ void SshService::task(void* arg) {
}
void SshService::session(Run& run) {
startLibrary();
sshutil::startLibrary();
ssh_session s = ssh_new();
if (!s) return end("Not enough memory for the session");
int verbosity = SSH_LOG_NOLOG;
@@ -204,7 +185,7 @@ void SshService::session(Run& run) {
if (stop_) return fail("Stopped");
// Is it the server it was last time? The first time, and when it has changed, the user decides.
std::string seen = fingerprintOf(s);
std::string seen = sshutil::fingerprintOf(s);
if (seen.empty()) return fail("The server showed no key");
if (seen != run.known) {
{
@@ -333,7 +314,7 @@ std::string SshService::makeKey() {
auto work = [](void* p) {
std::shared_ptr<Made> m = *static_cast<std::shared_ptr<Made>*>(p);
delete static_cast<std::shared_ptr<Made>*>(p);
startLibrary();
sshutil::startLibrary();
ssh_key key = nullptr, pub = nullptr;
char *b64 = nullptr, *pub64 = nullptr;
if (ssh_pki_generate(SSH_KEYTYPE_ED25519, 0, &key) != SSH_OK) m->why = "The key couldn't be made";
+15 -1
View File
@@ -5,6 +5,7 @@
#include "../memory_store.h"
#include "irc_session.h"
#include "version.h"
using namespace roro;
@@ -326,7 +327,19 @@ void test_action_and_ctcp_version() {
f.take();
f.recv(":alice!u@h PRIVMSG roro :\x01VERSION\x01");
f.take();
TEST_ASSERT_TRUE(f.sent("NOTICE alice :\x01VERSION roro9stack\x01"));
TEST_ASSERT_TRUE(f.sent(std::string("NOTICE alice :\x01VERSION roro9stack ") + versionString() + "\x01"));
}
void test_uname_shows_the_firmware_here_and_sends_nothing() {
Fixture f;
f.registerNow();
f.recv(":roro!u@h JOIN #roro");
f.take();
f.session->input(f.buffer("#roro"), "/uname", 0);
const auto& line = f.session->buffer(f.buffer("#roro")).lines.back();
TEST_ASSERT_EQUAL(static_cast<int>(IrcLine::Kind::Info), static_cast<int>(line.kind));
TEST_ASSERT_EQUAL_STRING(unameString().c_str(), line.text.c_str());
TEST_ASSERT_EQUAL(0, f.take().send.size());
}
void test_topic_is_kept() {
@@ -491,6 +504,7 @@ int main() {
RUN_TEST(test_private_message_opens_a_query_buffer_and_notifies);
RUN_TEST(test_no_notification_while_viewing_that_buffer);
RUN_TEST(test_action_and_ctcp_version);
RUN_TEST(test_uname_shows_the_firmware_here_and_sends_nothing);
RUN_TEST(test_topic_is_kept);
RUN_TEST(test_input_message_and_commands);
RUN_TEST(test_j_is_short_for_join);
+286
View File
@@ -0,0 +1,286 @@
#include <unity.h>
#include <cstring>
#include <string>
#include <vector>
#include "aes128.h"
#include "meshcore_channel.h"
#include "meshcore_packet.h"
#include "packet_view.h"
#include "scan_presets.h"
using namespace roro;
using namespace roro::meshcore;
void setUp() {}
void tearDown() {}
// A flood advert relayed by two repeaters (1-byte hashes), from a repeater that says where it is.
static std::vector<uint8_t> advert(const char* name, bool location = true, uint8_t pathByte = 2) {
std::vector<uint8_t> v = {static_cast<uint8_t>(0x04 << 2 | 0x01), pathByte};
for (int i = 0; i < (pathByte & 0x3F) * ((pathByte >> 6) + 1); ++i) v.push_back(static_cast<uint8_t>(0xa3 + i));
for (int i = 0; i < 32; ++i) v.push_back(static_cast<uint8_t>(0xc0 + i)); // key
v.insert(v.end(), {0x00, 0x5e, 0xd0, 0x68}); // timestamp
v.insert(v.end(), 64, 0x55); // signature
v.push_back(static_cast<uint8_t>(0x80 | (location ? 0x10 : 0) | 0x02)); // named, repeater
if (location) {
int32_t lat = 50860320, lon = 4300480;
for (int i = 0; i < 4; ++i) v.push_back(static_cast<uint8_t>(lat >> (8 * i)));
for (int i = 0; i < 4; ++i) v.push_back(static_cast<uint8_t>(lon >> (8 * i)));
}
v.insert(v.end(), name, name + std::strlen(name));
return v;
}
void test_header_route_type_and_path() {
auto v = advert("Brussels-R1");
Packet p;
TEST_ASSERT_TRUE(parsePacket(v.data(), v.size(), p));
TEST_ASSERT_TRUE(p.route == Route::Flood);
TEST_ASSERT_TRUE(p.type == Payload::Advert);
TEST_ASSERT_FALSE(p.hasTransport);
TEST_ASSERT_EQUAL(2, p.hops);
TEST_ASSERT_EQUAL(1, p.hashSize);
TEST_ASSERT_EQUAL_HEX8(0xa3, p.path[0]);
TEST_ASSERT_EQUAL_HEX8(0xc0, p.payload[0]);
}
void test_transport_codes_and_wider_hashes() {
// Direct in a region, a text message, two hops of 2 bytes each.
const uint8_t v[] = {0x02 << 2 | 0x03, 0x2b, 0x1a, 0x00, 0x00, 0x40 | 2, 0x11, 0x22, 0x33, 0x44, 0x7f, 0xa3, 0xde, 0xad, 1, 2, 3};
Packet p;
TEST_ASSERT_TRUE(parsePacket(v, sizeof v, p));
TEST_ASSERT_TRUE(p.route == Route::TransportDirect);
TEST_ASSERT_TRUE(p.hasTransport);
TEST_ASSERT_EQUAL_HEX16(0x1a2b, p.transport[0]);
TEST_ASSERT_EQUAL(2, p.hops);
TEST_ASSERT_EQUAL(2, p.hashSize);
TEST_ASSERT_EQUAL_size_t(7, p.payloadLen);
TEST_ASSERT_EQUAL_HEX8(0x7f, p.payload[0]);
TEST_ASSERT_TRUE(p.addressed());
}
void test_what_is_not_a_meshcore_packet() {
Packet p;
const uint8_t version[] = {0x40 | 0x05, 0x00, 1, 2}; // a version that doesn't exist yet
const uint8_t reserved[] = {0x0c << 2 | 0x01, 0x00, 1, 2}; // a reserved type
const uint8_t longPath[] = {0x05, 0x09, 1, 2, 3}; // nine hops, three bytes left
const uint8_t badHash[] = {0x05, 0xc1, 1, 2, 3, 4}; // 4-byte hashes aren't defined
TEST_ASSERT_FALSE(parsePacket(version, sizeof version, p));
TEST_ASSERT_FALSE(parsePacket(reserved, sizeof reserved, p));
TEST_ASSERT_FALSE(parsePacket(longPath, sizeof longPath, p));
TEST_ASSERT_FALSE(parsePacket(badHash, sizeof badHash, p));
TEST_ASSERT_FALSE(parsePacket(version, 1, p));
}
void test_advert() {
auto v = advert("Brussels-R1");
Packet p;
Advert a;
TEST_ASSERT_TRUE(parsePacket(v.data(), v.size(), p));
TEST_ASSERT_TRUE(parseAdvert(p, a));
TEST_ASSERT_EQUAL_STRING("Brussels-R1", a.name.c_str());
TEST_ASSERT_EQUAL(2, a.kind);
TEST_ASSERT_TRUE(a.hasLocation);
TEST_ASSERT_EQUAL_INT32(50860320, a.latE6);
TEST_ASSERT_EQUAL_INT32(4300480, a.lonE6);
TEST_ASSERT_EQUAL_HEX32(0x68d05e00, a.timestamp);
TEST_ASSERT_EQUAL_HEX8(0xc0, a.key[0]);
auto plain = advert("n", false, 0);
TEST_ASSERT_TRUE(parsePacket(plain.data(), plain.size(), p));
TEST_ASSERT_TRUE(parseAdvert(p, a));
TEST_ASSERT_FALSE(a.hasLocation);
TEST_ASSERT_EQUAL_STRING("n", a.name.c_str());
v.resize(60); // cut short: still a packet, not an advert that can be read
TEST_ASSERT_TRUE(parsePacket(v.data(), v.size(), p));
TEST_ASSERT_FALSE(parseAdvert(p, a));
}
void test_rows() {
auto v = advert("Brussels-R1");
lora::PacketSummary s{v.data(), v.size(), -97.4f, 6.25f, true, lora::Protocol::MeshCore};
TEST_ASSERT_EQUAL_STRING("21:45:07 -97 6.2 adv Brussels-R1 2h", lora::row(s, "21:45:07").c_str());
auto longName = advert("A repeater with a very long name");
lora::PacketSummary l{longName.data(), longName.size(), -117.0f, -12.25f, true, lora::Protocol::MeshCore};
TEST_ASSERT_TRUE(lora::row(l, "21:45:07").size() <= 38);
const uint8_t text[] = {0x02 << 2 | 0x02, 0x00, 0x7f, 0xa3, 0xde, 0xad, 1, 2, 3};
lora::PacketSummary t{text, sizeof text, -80.0f, 9.0f, true, lora::Protocol::MeshCore};
TEST_ASSERT_EQUAL_STRING("21:45:07 -80 9.0 txt a3>7f", lora::row(t, "21:45:07").c_str());
const uint8_t group[] = {0x05 << 2 | 0x01, 0x01, 0x42, 0x11, 0xbe, 0xef, 1, 2, 3, 4};
lora::PacketSummary g{group, sizeof group, -80.0f, 9.0f, true, lora::Protocol::MeshCore};
TEST_ASSERT_EQUAL_STRING("21:45:07 -80 9.0 chan #11 1h", lora::row(g, "21:45:07").c_str());
// Not MeshCore after all (sync word 0x12 is anyone's): the bytes are counted, no more.
const uint8_t other[] = {0xff, 0x00, 1, 2};
lora::PacketSummary o{other, sizeof other, -80.0f, 9.0f, true, lora::Protocol::MeshCore};
TEST_ASSERT_EQUAL_STRING("21:45:07 -80 9.0 4 B", lora::row(o, "21:45:07").c_str());
}
void test_detail_lines() {
auto v = advert("Brussels-R1");
auto lines = lora::headerLines(v.data(), v.size(), lora::Protocol::MeshCore);
TEST_ASSERT_EQUAL_size_t(5, lines.size());
TEST_ASSERT_EQUAL_STRING("MeshCore advert, flood", lines[0].c_str());
TEST_ASSERT_EQUAL_STRING("Through a3 a4", lines[1].c_str());
TEST_ASSERT_EQUAL_STRING("Repeater Brussels-R1", lines[2].c_str());
TEST_ASSERT_EQUAL_STRING("Key c0c1c2c3c4c5c6c7...", lines[3].c_str());
TEST_ASSERT_EQUAL_STRING("At 50.86032, 4.30048", lines[4].c_str());
const uint8_t group[] = {0x05 << 2 | 0x01, 0x00, 0x11, 0xbe, 0xef, 1, 2, 3, 4};
lines = lora::headerLines(group, sizeof group, lora::Protocol::MeshCore);
TEST_ASSERT_EQUAL_size_t(3, lines.size());
TEST_ASSERT_EQUAL_STRING("MeshCore channel message, flood", lines[0].c_str());
TEST_ASSERT_EQUAL_STRING("Heard first hand: no repeater yet", lines[1].c_str());
TEST_ASSERT_EQUAL_STRING("Channel 0x11 (public?), 4 B encrypted", lines[2].c_str());
const uint8_t text[] = {0x02 << 2 | 0x02, 0x01, 0x42, 0x7f, 0xa3, 0xde, 0xad, 1, 2, 3};
lines = lora::headerLines(text, sizeof text, lora::Protocol::MeshCore);
TEST_ASSERT_EQUAL_STRING("MeshCore text message, direct", lines[0].c_str());
TEST_ASSERT_EQUAL_STRING("Route 42", lines[1].c_str());
TEST_ASSERT_EQUAL_STRING("From ..a3 to ..7f, 3 B encrypted", lines[2].c_str());
for (auto& l : lines) TEST_ASSERT_TRUE(l.size() <= 38);
TEST_ASSERT_EQUAL_size_t(0, lora::headerLines(text, sizeof text, lora::Protocol::None).size());
}
// As received near Brussels on 2026-10-09: a search for repeaters, and one answering.
void test_discover_as_heard() {
const uint8_t ask[] = {0x2e, 0x00, 0x80, 0x04, 0xbb, 0xdd, 0x5f, 0x5a, 0x00, 0x00, 0x00, 0x00};
const uint8_t answer[] = {0x2e, 0x00, 0x92, 0x2d, 0xbb, 0xdd, 0x5f, 0x5a, 0x18, 0xa6, 0x3f, 0xae, 0xf4, 0x01, 0x9b, 0xee,
0xd0, 0x4f, 0xfd, 0xd3, 0xd1, 0x72, 0x65, 0xba, 0x4b, 0x9e, 0xeb, 0xd0, 0xdf, 0xb0, 0xb2, 0xc4,
0x94, 0xec, 0x50, 0x5b, 0x03, 0x20, 0x59, 0x9d};
Packet p;
Discover d;
TEST_ASSERT_TRUE(parsePacket(ask, sizeof ask, p));
TEST_ASSERT_TRUE(p.route == Route::Direct);
TEST_ASSERT_TRUE(p.type == Payload::Control);
TEST_ASSERT_TRUE(parseDiscover(p, d));
TEST_ASSERT_FALSE(d.response);
TEST_ASSERT_EQUAL_HEX8(0x04, d.kinds);
TEST_ASSERT_EQUAL_HEX32(0x5a5fddbb, d.tag);
TEST_ASSERT_TRUE(parsePacket(answer, sizeof answer, p));
TEST_ASSERT_TRUE(parseDiscover(p, d));
TEST_ASSERT_TRUE(d.response);
TEST_ASSERT_EQUAL(2, d.kind);
TEST_ASSERT_EQUAL_FLOAT(11.25f, d.snr);
TEST_ASSERT_EQUAL_HEX32(0x5a5fddbb, d.tag);
TEST_ASSERT_EQUAL_size_t(32, d.idLen);
lora::PacketSummary a{ask, sizeof ask, -107.0f, -7.0f, true, lora::Protocol::MeshCore};
TEST_ASSERT_EQUAL_STRING("20:44:01 -107 -7.0 who's there?", lora::row(a, "20:44:01").c_str());
lora::PacketSummary r{answer, sizeof answer, -96.0f, 3.0f, true, lora::Protocol::MeshCore};
TEST_ASSERT_EQUAL_STRING("20:44:02 -96 3.0 here 18a6", lora::row(r, "20:44:02").c_str());
auto lines = lora::headerLines(ask, sizeof ask, lora::Protocol::MeshCore);
TEST_ASSERT_EQUAL_size_t(4, lines.size());
TEST_ASSERT_EQUAL_STRING("MeshCore control, direct", lines[0].c_str());
TEST_ASSERT_EQUAL_STRING("No route in it: to a neighbour", lines[1].c_str());
TEST_ASSERT_EQUAL_STRING("A search for nodes nearby", lines[2].c_str());
TEST_ASSERT_EQUAL_STRING("Wanted: Repeater", lines[3].c_str());
lines = lora::headerLines(answer, sizeof answer, lora::Protocol::MeshCore);
TEST_ASSERT_EQUAL_size_t(5, lines.size());
TEST_ASSERT_EQUAL_STRING("Repeater answering a search", lines[2].c_str());
TEST_ASSERT_EQUAL_STRING("Key 18a63faef4019bee...", lines[3].c_str());
TEST_ASSERT_EQUAL_STRING("It heard the search at 11.2 dB SNR", lines[4].c_str());
}
void test_aes128_decrypts_the_standard_block() { // FIPS 197, appendix C.1
uint8_t key[16], in[16] = {0x69, 0xc4, 0xe0, 0xd8, 0x6a, 0x7b, 0x04, 0x30, 0xd8, 0xcd, 0xb7, 0x80, 0x70, 0xb4, 0xc5, 0x5a}, out[16];
for (int i = 0; i < 16; ++i) key[i] = static_cast<uint8_t>(i);
Aes128(key).decryptBlock(in, out);
for (int i = 0; i < 16; ++i) TEST_ASSERT_EQUAL_HEX8(i * 0x11, out[i]);
}
// Messages on the public channel, relayed twice, encrypted and signed with openssl:
// "Alice: hello from Brussels" and "Zo\xc3\xa9: \xc3\xa7a va ?", both sent at 1791575000.
static const uint8_t kHello[] = {0x15, 0x02, 0xa3, 0x7f, 0x11, 0x20, 0x14, 0x90, 0xb7, 0xe1, 0x1d, 0x46, 0x5f,
0x3f, 0xac, 0x4f, 0x98, 0xa8, 0xc6, 0x07, 0x79, 0x4c, 0x96, 0x19, 0x5e, 0x54,
0x60, 0x67, 0x2b, 0x2e, 0xec, 0x0e, 0xe4, 0x6a, 0xbc, 0x8b, 0x51, 0x38, 0xfd};
static const uint8_t kAccents[] = {0x15, 0x02, 0xa3, 0x7f, 0x11, 0x19, 0xdd, 0x85, 0x58, 0xc5, 0x73, 0x2d, 0x55,
0x4d, 0xf4, 0xfd, 0x02, 0x47, 0xdc, 0xfa, 0xa2, 0xce, 0x3b, 0xcb, 0xb1, 0x8e,
0x8f, 0x66, 0xc7, 0xd6, 0x61, 0x30, 0x3c, 0xbe, 0x29, 0xd7, 0xb8, 0x08, 0x26};
void test_public_channel_message() {
TEST_ASSERT_EQUAL_HEX8(kPublicChannelHash, channelHash(channel(0)));
Packet p;
ChannelText t;
TEST_ASSERT_TRUE(parsePacket(kHello, sizeof kHello, p));
TEST_ASSERT_TRUE(readChannelText(p, t));
TEST_ASSERT_EQUAL_STRING("Public", t.channel);
TEST_ASSERT_EQUAL_UINT32(1791575000, t.timestamp);
TEST_ASSERT_EQUAL_STRING("Alice", t.sender.c_str());
TEST_ASSERT_EQUAL_STRING("hello from Brussels", t.text.c_str());
TEST_ASSERT_TRUE(parsePacket(kAccents, sizeof kAccents, p));
TEST_ASSERT_TRUE(readChannelText(p, t));
TEST_ASSERT_EQUAL_STRING("Zo?", t.sender.c_str()); // one '?' for a character outside ASCII
TEST_ASSERT_EQUAL_STRING("?a va ?", t.text.c_str());
// One bit wrong and the check fails: nothing is made up from it.
std::vector<uint8_t> bad(kHello, kHello + sizeof kHello);
bad[20] ^= 1;
TEST_ASSERT_TRUE(parsePacket(bad.data(), bad.size(), p));
TEST_ASSERT_FALSE(readChannelText(p, t));
// Another channel with the same first byte, or not whole blocks: left encrypted.
bad.assign(kHello, kHello + sizeof kHello - 1);
TEST_ASSERT_TRUE(parsePacket(bad.data(), bad.size(), p));
TEST_ASSERT_FALSE(readChannelText(p, t));
}
void test_public_channel_message_shown() {
lora::PacketSummary s{kHello, sizeof kHello, -97, 6.2f, true, lora::Protocol::MeshCore};
std::string row = lora::row(s, "21:45:07");
TEST_ASSERT_EQUAL_STRING("21:45:07 -97 6.2 Alice: hello from 2h", row.c_str());
TEST_ASSERT_LESS_OR_EQUAL(38, row.size());
auto lines = lora::headerLines(kHello, sizeof kHello, lora::Protocol::MeshCore);
TEST_ASSERT_EQUAL_size_t(5, lines.size());
TEST_ASSERT_EQUAL_STRING("MeshCore channel message, flood", lines[0].c_str());
TEST_ASSERT_EQUAL_STRING("Through a3 7f", lines[1].c_str());
TEST_ASSERT_EQUAL_STRING("On the Public channel, from Alice", lines[2].c_str());
TEST_ASSERT_EQUAL_STRING("Sent 2026-10-09 19:43:20 UTC", lines[3].c_str());
TEST_ASSERT_EQUAL_STRING("hello from Brussels", lines[4].c_str());
}
void test_scan_presets() {
TEST_ASSERT_EQUAL_size_t(8, lora::scanPresetCount());
TEST_ASSERT_EQUAL_STRING("MeshCore", lora::scanPreset(lora::kDefaultScanPreset).name);
lora::ScanPreset p = lora::scanPreset(0); // the order stays: a preset is stored by number
TEST_ASSERT_EQUAL_STRING("LongFast", p.name);
TEST_ASSERT_EQUAL_UINT32(869525000u, p.frequencyHz);
TEST_ASSERT_EQUAL_HEX8(0x2B, p.syncWord);
TEST_ASSERT_TRUE(lora::findScanPreset("meshcore", p));
TEST_ASSERT_EQUAL_STRING("MeshCore", p.name);
TEST_ASSERT_EQUAL_UINT32(869618000u, p.frequencyHz);
TEST_ASSERT_EQUAL_FLOAT(62.5f, p.bwKHz);
TEST_ASSERT_EQUAL(8, p.sf);
TEST_ASSERT_EQUAL(8, p.cr);
TEST_ASSERT_EQUAL_HEX8(0x12, p.syncWord);
TEST_ASSERT_EQUAL(16, p.preamble);
TEST_ASSERT_TRUE(p.protocol == lora::Protocol::MeshCore);
TEST_ASSERT_TRUE(lora::protocolOf(0x12) == lora::Protocol::MeshCore);
TEST_ASSERT_TRUE(lora::protocolOf(0x2B) == lora::Protocol::Meshtastic);
TEST_ASSERT_TRUE(lora::protocolOf(0x34) == lora::Protocol::None);
TEST_ASSERT_FALSE(lora::findScanPreset("ShortTurbo", p));
TEST_ASSERT_FALSE(lora::findScanPreset("MeshCor", p));
}
int main() {
UNITY_BEGIN();
RUN_TEST(test_header_route_type_and_path);
RUN_TEST(test_transport_codes_and_wider_hashes);
RUN_TEST(test_what_is_not_a_meshcore_packet);
RUN_TEST(test_advert);
RUN_TEST(test_rows);
RUN_TEST(test_detail_lines);
RUN_TEST(test_discover_as_heard);
RUN_TEST(test_aes128_decrypts_the_standard_block);
RUN_TEST(test_public_channel_message);
RUN_TEST(test_public_channel_message_shown);
RUN_TEST(test_scan_presets);
return UNITY_END();
}
+5 -5
View File
@@ -17,30 +17,30 @@ static const uint8_t kMeshtastic[] = {
};
void test_row_for_a_meshtastic_packet() {
PacketSummary p{kMeshtastic, sizeof kMeshtastic, -97.4f, 6.25f, true, true};
PacketSummary p{kMeshtastic, sizeof kMeshtastic, -97.4f, 6.25f, true, Protocol::Meshtastic};
// Time, RSSI, SNR, then who sent it to whom (by default short name: the last 4 hex digits,
// as Meshtastic names a Node) and how far it came. Fits 40 columns with two node numbers.
TEST_ASSERT_EQUAL_STRING("21:45:07 -97 6.2 5678>all 1/3", row(p, "21:45:07").c_str());
const uint8_t direct[] = {0x0a, 0x0b, 0x0c, 0x0d, 0xc4, 0xd3, 0xb2, 0xa1, 1, 2, 3, 4, 0x6a, 8, 0, 0xaa};
PacketSummary d{direct, sizeof direct, -117.0f, -9.25f, true, true};
PacketSummary d{direct, sizeof direct, -117.0f, -9.25f, true, Protocol::Meshtastic};
TEST_ASSERT_EQUAL_STRING("21:45:07 -117 -9.2 d3c4>0b0a 1/3", row(d, "21:45:07").c_str());
TEST_ASSERT_TRUE(row(d, "21:45:07").size() <= 38);
}
void test_row_for_something_else() {
const uint8_t data[] = {0x40, 0x01, 0x02};
PacketSummary p{data, sizeof data, -120.6f, -7.5f, true, false};
PacketSummary p{data, sizeof data, -120.6f, -7.5f, true, Protocol::None};
TEST_ASSERT_EQUAL_STRING("21:45:07 -121 -7.5 3 B", row(p, "21:45:07").c_str());
}
// Not on Meshtastic settings (LoRaWAN, say): the same bytes aren't read as a Meshtastic header.
void test_row_not_meshtastic() {
PacketSummary p{kMeshtastic, sizeof kMeshtastic, -97.0f, 6.0f, true, false};
PacketSummary p{kMeshtastic, sizeof kMeshtastic, -97.0f, 6.0f, true, Protocol::None};
TEST_ASSERT_EQUAL_STRING("21:45:07 -97 6.0 19 B", row(p, "21:45:07").c_str());
}
void test_row_with_a_bad_crc() {
PacketSummary p{kMeshtastic, sizeof kMeshtastic, -97.0f, 6.0f, false, true};
PacketSummary p{kMeshtastic, sizeof kMeshtastic, -97.0f, 6.0f, false, Protocol::Meshtastic};
TEST_ASSERT_EQUAL_STRING("21:45:07 -97 6.0 bad CRC, 19 B", row(p, "21:45:07").c_str());
}
+66
View File
@@ -0,0 +1,66 @@
#include <unity.h>
#include "scp_args.h"
using roro::term::ScpArgs;
using roro::term::parseScp;
void setUp() {}
void tearDown() {}
void test_upload() {
ScpArgs a;
TEST_ASSERT_EQUAL_STRING("", parseScp("/notes/a.txt bob@nas.lan:docs/a.txt", a).c_str());
TEST_ASSERT_TRUE(a.upload);
TEST_ASSERT_EQUAL_STRING("bob", a.target.user.c_str());
TEST_ASSERT_EQUAL_STRING("nas.lan", a.target.host.c_str());
TEST_ASSERT_EQUAL(22, a.target.port);
TEST_ASSERT_EQUAL_STRING("/notes/a.txt", a.local.c_str());
TEST_ASSERT_EQUAL_STRING("docs/a.txt", a.remote.c_str());
}
void test_download_and_port() {
ScpArgs a;
TEST_ASSERT_EQUAL_STRING("", parseScp("-P 2222 bob@10.9.0.1:/var/log/syslog /logs/syslog", a).c_str());
TEST_ASSERT_FALSE(a.upload);
TEST_ASSERT_EQUAL(2222, a.target.port);
TEST_ASSERT_EQUAL_STRING("/var/log/syslog", a.remote.c_str());
TEST_ASSERT_EQUAL_STRING("/logs/syslog", a.local.c_str());
}
void test_download_into_a_folder_takes_the_remote_name() {
ScpArgs a;
TEST_ASSERT_EQUAL_STRING("", parseScp("bob@h:/etc/motd /notes/", a).c_str());
TEST_ASSERT_EQUAL_STRING("/notes/motd", a.local.c_str());
}
void test_replace_flag() {
ScpArgs a;
TEST_ASSERT_EQUAL_STRING("", parseScp("-f -P 22 bob@h:x /a", a).c_str());
TEST_ASSERT_TRUE(a.replace);
TEST_ASSERT_EQUAL_STRING("", parseScp("bob@h:x /a", a).c_str());
TEST_ASSERT_FALSE(a.replace);
TEST_ASSERT_TRUE(parseScp("-r bob@h:x /a", a).size() > 0);
}
void test_refusals() {
ScpArgs a;
TEST_ASSERT_TRUE(parseScp("", a).size() > 0);
TEST_ASSERT_TRUE(parseScp("/a.txt /b.txt", a).size() > 0); // neither is a server
TEST_ASSERT_TRUE(parseScp("a@h:x b@h:y", a).size() > 0); // both are
TEST_ASSERT_TRUE(parseScp("a.txt bob@h:x", a).size() > 0); // not a card path
TEST_ASSERT_TRUE(parseScp("/a.txt bob@h:", a).size() > 0); // no remote path
TEST_ASSERT_TRUE(parseScp("/notes/ bob@h:x", a).size() > 0); // a folder
TEST_ASSERT_TRUE(parseScp("-P 0 /a bob@h:x", a).size() > 0);
TEST_ASSERT_TRUE(parseScp("/a bob@bad_host:x", a).size() > 0);
}
int main() {
UNITY_BEGIN();
RUN_TEST(test_upload);
RUN_TEST(test_download_and_port);
RUN_TEST(test_download_into_a_folder_takes_the_remote_name);
RUN_TEST(test_replace_flag);
RUN_TEST(test_refusals);
return UNITY_END();
}
+2 -2
View File
@@ -28,8 +28,8 @@ void test_defaults_when_store_is_empty() {
TEST_ASSERT_TRUE(s.getBool(Setting::WifiEnabled));
TEST_ASSERT_TRUE(s.getBool(Setting::GnssEnabled)); // Q58: on by default
TEST_ASSERT_FALSE(s.getBool(Setting::CoordinatesDms)); // Q64: decimal degrees
TEST_ASSERT_EQUAL_INT32(0, s.getInt(Setting::LoraPreset)); // Q95: LongFast
TEST_ASSERT_FALSE(s.setInt(Setting::LoraPreset, 7)); // seven EU868 presets
TEST_ASSERT_EQUAL_INT32(7, s.getInt(Setting::LoraPreset)); // MeshCore, after the seven EU868 presets
TEST_ASSERT_FALSE(s.setInt(Setting::LoraPreset, 8));
TEST_ASSERT_EQUAL_STRING("EU868", s.getString(Setting::Region).c_str());
TEST_ASSERT_EQUAL_STRING("CET-1CEST,M3.5.0,M10.5.0/3", s.getString(Setting::Timezone).c_str());
}
+7
View File
@@ -14,9 +14,16 @@ void test_product_name() {
TEST_ASSERT_EQUAL_STRING("roro9stack", roro::kProductName);
}
void test_uname_has_name_version_and_architecture() {
std::string expected = std::string("roro9stack ") + roro::versionString() + " " + roro::architectureString();
TEST_ASSERT_EQUAL_STRING(expected.c_str(), roro::unameString().c_str());
TEST_ASSERT_TRUE(std::strlen(roro::architectureString()) > 0);
}
int main() {
UNITY_BEGIN();
RUN_TEST(test_version_is_never_empty);
RUN_TEST(test_product_name);
RUN_TEST(test_uname_has_name_version_and_architecture);
return UNITY_END();
}