diff --git a/Provisioning.cpp b/Provisioning.cpp index 38797c60..230b5621 100644 --- a/Provisioning.cpp +++ b/Provisioning.cpp @@ -75,6 +75,8 @@ extern bool kiss_framed_logs; extern bool nomadnet_enabled; extern RNS::Destination nomadnet_destination; extern char nomadnet_name[64]; +extern char lora_ifac_netname[64]; +extern char lora_ifac_netkey[64]; extern float battery_voltage; extern float battery_percent; extern uint8_t battery_state; @@ -214,7 +216,27 @@ static void register_provisioning_namespaces() { /* getter */ []() { return static_cast(lora_interface.mode()); } - ); + ) + // Interface access code of the LoRa interface, as the ifac_netname / + // ifac_netkey (passphrase) of a Python RNS interface: both empty = + // off. Applied when the persisted values are loaded at boot. + .field_string("LoRa IFAC Network Name", PROV_GENERAL_LORA_IFAC_NAME, FF_REBOOT_REQUIRED, + lora_ifac_netname, sizeof(lora_ifac_netname)-1, + [](const Value& v) { + strncpy(lora_ifac_netname, v.as_string().c_str(), sizeof(lora_ifac_netname)-1); + lora_interface.setup_ifac(lora_ifac_netname, lora_ifac_netkey); + return true; + }, + []() { return lora_ifac_netname; }) + .field_string("LoRa IFAC Passphrase", PROV_GENERAL_LORA_IFAC_KEY, + FF_REBOOT_REQUIRED | FF_SECRET, + lora_ifac_netkey, sizeof(lora_ifac_netkey)-1, + [](const Value& v) { + strncpy(lora_ifac_netkey, v.as_string().c_str(), sizeof(lora_ifac_netkey)-1); + lora_interface.setup_ifac(lora_ifac_netname, lora_ifac_netkey); + return true; + }, + []() { return ""; }); } #endif diff --git a/Provisioning.h b/Provisioning.h index e771e440..65a1ac0b 100644 --- a/Provisioning.h +++ b/Provisioning.h @@ -57,6 +57,8 @@ #define PROV_GENERAL_NOMADNET_NAME 5 #define PROV_GENERAL_GPIO0 6 #define PROV_GENERAL_GPIO1 7 +#define PROV_GENERAL_LORA_IFAC_NAME 8 +#define PROV_GENERAL_LORA_IFAC_KEY 9 #define PROV_METRICS_TRANS_ID 1 #define PROV_METRICS_PROBE_DST 2 diff --git a/RNode_Firmware.ino b/RNode_Firmware.ino index eafd665a..63cc5017 100644 --- a/RNode_Firmware.ino +++ b/RNode_Firmware.ino @@ -95,6 +95,9 @@ bool kiss_framed_logs = true; bool nomadnet_enabled = true; RNS::Destination nomadnet_destination = {RNS::Type::NONE}; char nomadnet_name[64]; +// LoRa interface access code (IFAC), set through Provisioning; empty = off. +char lora_ifac_netname[64] = ""; +char lora_ifac_netkey[64] = ""; #if HAS_CONSOLE #include "Console.h" diff --git a/docs/Provisioning.md b/docs/Provisioning.md index 83996068..7c378487 100644 --- a/docs/Provisioning.md +++ b/docs/Provisioning.md @@ -21,6 +21,7 @@ This guide walks through using the **RNode Console** (the single-page web app sh - [Node Config](#node-config) - [Transport Config](#transport-config) - [Logs](#logs) +- [LoRa interface access code (IFAC)](#lora-interface-access-code-ifac) - [Caveats and pitfalls](#caveats-and-pitfalls) - [RNS over LoRa](#rns-over-lora) - [Security and exposure](#security-and-exposure) @@ -202,6 +203,32 @@ Visible only when the firmware advertises any Provisioning namespace with id < 1 Visible on Serial / BLE / WebSocket connections. Streams KISS `CMD_LOG` frames from the device. Use the toolbar to clear, pause / resume follow, and download. Hidden on RNS connections. +## LoRa interface access code (IFAC) + +An interface access code lets several networks share a LoRa channel: every packet carries a code derived from a network name and/or passphrase, and nodes drop packets whose code doesn't match. It is the same mechanism as `network_name` / `passphrase` on a Python RNS interface — all nodes on the channel must use the same values. + +The LoRa interface's IFAC is set with two fields of the **General** namespace (**Transport Config** tab): + +| Field | Id | Flags | Notes | +|---|---|---|---| +| `LoRa IFAC Network Name` | 8 | `REBOOT_REQUIRED` | Readable. | +| `LoRa IFAC Passphrase` | 9 | `REBOOT_REQUIRED`, `SECRET` | Persisted, never returned by `GET_STATE`. | + +Both empty (the default) = IFAC off. Commit, then reboot: the values are applied at boot. `ifac_size` is 8 bytes, the RNode/LoRa default of Python RNS. + +From a script, `rnode_ifac.py` (repository root) sets them over USB and reboots the node, reading the passphrase from a file rather than the command line: + +``` +$ cat ifac.json +{"network_name": "my-network", "passphrase": "my passphrase"} +$ ./rnode_ifac.py /dev/ttyACM0 set ifac.json +$ ./rnode_ifac.py /dev/ttyACM0 get # after the reboot +IFAC network name: 'my-network' +$ ./rnode_ifac.py /dev/ttyACM0 off +``` + +Changing the IFAC of a node you manage over LoRa cuts it off from nodes that don't share the new values: change the other nodes first, or do it over USB. + ## Caveats and pitfalls ### RNS over LoRa diff --git a/rnode_ifac.py b/rnode_ifac.py new file mode 100755 index 00000000..850ef828 --- /dev/null +++ b/rnode_ifac.py @@ -0,0 +1,142 @@ +#!/usr/bin/env python3 +"""rnode_ifac.py — set the interface access code (IFAC) of a node's LoRa interface over USB, +through the Provisioning subsystem. + + rnode_ifac.py get # current IFAC network name (the passphrase is never read back) + rnode_ifac.py set # {"network_name": "...", "passphrase": "..."} + rnode_ifac.py off # disable IFAC + +Equivalent to `network_name` / `passphrase` on a Python RNS interface: every node sharing the +LoRa channel must use the same values. The passphrase is read from a file, never from the command +line (shell history, process list). `set` and `off` commit the fields, then reboot the node: they +take effect at boot. + +Wire protocol: KISS frames CMD_PROVISION_REQ (0x86) / CMD_PROVISION_RSP (0x87) carrying MsgPack +[op, seq, payload] envelopes (see microReticulum docs/provisioning_client_guide.md). Fields: +namespace "General" (100), 8 = IFAC network name, 9 = IFAC passphrase (secret). +Requires pyserial and RNS (for its bundled umsgpack) or the msgpack package. +""" +import json +import sys +import time + +import serial + +try: + import RNS.vendor.umsgpack as msgpack +except ImportError: + import msgpack + +FEND, FESC, TFEND, TFESC = 0xC0, 0xDB, 0xDC, 0xDD +CMD_PROVISION_REQ, CMD_PROVISION_RSP = 0x86, 0x87 +OP_GET_STATE, OP_SET_STATE, OP_COMMIT, OP_REBOOT, OP_ERROR = 4, 5, 6, 9, 101 +NS_GENERAL, F_NETNAME, F_PASSPHRASE = 100, 8, 9 +MAX_LEN = 63 # 64-byte buffers on the firmware side + + +def escape(data): + out = bytearray() + for b in data: + if b == FEND: + out += bytes([FESC, TFEND]) + elif b == FESC: + out += bytes([FESC, TFESC]) + else: + out.append(b) + return bytes(out) + + +class Node: + def __init__(self, port): + self.serial = serial.Serial(port, 115200, timeout=0.2) + time.sleep(2) + self.serial.reset_input_buffer() + self.seq = 0 + self.buffer = bytearray() + + def frames(self): + # Log text and KISS frames share the port: keep complete frames only. + self.buffer += self.serial.read(4096) + while True: + start = self.buffer.find(bytes([FEND])) + if start < 0: + self.buffer.clear() + return + end = self.buffer.find(bytes([FEND]), start + 1) + if end < 0: + del self.buffer[:start] + return + raw, self.buffer = bytes(self.buffer[start + 1:end]), self.buffer[end:] + frame, esc = bytearray(), False + for b in raw: + if esc: + frame.append(FEND if b == TFEND else FESC if b == TFESC else b) + esc = False + elif b == FESC: + esc = True + else: + frame.append(b) + if frame: + yield bytes(frame) + + def request(self, op, payload=None, timeout=8.0, expect_reply=True): + self.seq += 1 + body = msgpack.packb([op, self.seq, payload]) + self.serial.write(bytes([FEND, CMD_PROVISION_REQ]) + escape(body) + bytes([FEND])) + if not expect_reply: + return None + deadline = time.time() + timeout + while time.time() < deadline: + for frame in self.frames(): + if frame[0] != CMD_PROVISION_RSP: + continue + reply = msgpack.unpackb(frame[1:]) + if reply[1] != self.seq: + continue + if reply[0] == OP_ERROR: + raise SystemExit(f"error from node: {reply[2] if len(reply) > 2 else reply}") + return reply[2] if len(reply) > 2 else None + raise SystemExit("error: no reply from the node (still booting? Provisioning starts once RNS is up)") + + +def read_secret_file(path): + with open(path) as f: + data = json.load(f) + netname, passphrase = data.get("network_name") or "", data.get("passphrase") or "" + if not netname and not passphrase: + raise SystemExit("error: the file has neither network_name nor passphrase") + if len(netname.encode()) > MAX_LEN or len(passphrase.encode()) > MAX_LEN: + raise SystemExit(f"error: network_name and passphrase are limited to {MAX_LEN} bytes") + return netname, passphrase + + +def apply(node, netname, passphrase): + reply = node.request(OP_SET_STATE, {3: {NS_GENERAL: {F_NETNAME: netname, F_PASSPHRASE: passphrase}}}) + if reply.get(3): + raise SystemExit(f"error: fields rejected {reply[3]} — firmware without IFAC fields?") + reply = node.request(OP_COMMIT, {1: [NS_GENERAL]}) + print(f"committed ({reply.get(1)} field(s)), rebooting the node...") + node.request(OP_REBOOT, None, expect_reply=False) + + +def main(argv): + if len(argv) < 2: + raise SystemExit(__doc__) + node = Node(argv[0]) + command = argv[1] + if command == "get": + values = node.request(OP_GET_STATE, {1: [NS_GENERAL]}).get(1, {}).get(NS_GENERAL, {}) + if F_NETNAME not in values: + raise SystemExit("error: no IFAC field — firmware without IFAC support?") + name = values[F_NETNAME] + print(f"IFAC network name: {name!r}" if name else "IFAC: no network name set") + elif command == "set" and len(argv) == 3: + apply(node, *read_secret_file(argv[2])) + elif command == "off": + apply(node, "", "") + else: + raise SystemExit(__doc__) + + +if __name__ == "__main__": + main(sys.argv[1:])