Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 23 additions & 1 deletion Provisioning.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -214,7 +216,27 @@ static void register_provisioning_namespaces() {
/* getter */ []() {
return static_cast<fint_t>(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

Expand Down
2 changes: 2 additions & 0 deletions Provisioning.h
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
3 changes: 3 additions & 0 deletions RNode_Firmware.ino
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
27 changes: 27 additions & 0 deletions docs/Provisioning.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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
Expand Down
142 changes: 142 additions & 0 deletions rnode_ifac.py
Original file line number Diff line number Diff line change
@@ -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 <port> get # current IFAC network name (the passphrase is never read back)
rnode_ifac.py <port> set <file.json> # {"network_name": "...", "passphrase": "..."}
rnode_ifac.py <port> 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:])