feat(network): implement API for listing host network interfaces and normalize TCP port handling

This commit is contained in:
Ivan
2026-04-30 11:42:09 -05:00
parent d4ca96a67f
commit fda8c58d4e
15 changed files with 583 additions and 44 deletions
+66 -2
View File
@@ -237,6 +237,34 @@ def _python_jit_status_line() -> str:
return "Python JIT: enabled" if enabled else "Python JIT: disabled"
def list_host_network_interfaces():
"""Enumerate kernel network interfaces on the host running MeshChat.
Uses psutil (Linux, macOS, Windows). Fails soft on restricted environments
(e.g. some Android sandboxes) and returns ``([], error)``.
Reticulum's ``device`` field on server-style interfaces is a *single* interface
name, or omitted when binding only via ``listen_ip``.
"""
try:
raw = psutil.net_if_addrs()
except Exception as exc:
logging.debug("list_host_network_interfaces: net_if_addrs failed: %s", exc)
return [], str(exc)
out: list[dict[str, object]] = []
for name in sorted(raw.keys(), key=lambda n: str(n).lower()):
addrs: list[str] = []
for addr in raw[name]:
if addr.family == socket.AF_INET:
addrs.append(addr.address)
elif addr.family == socket.AF_INET6:
if addr.address.startswith("fe80:"):
continue
addrs.append(addr.address)
out.append({"name": name, "addresses": addrs})
return out, None
class ReticulumMeshChat:
def __init__(
self,
@@ -3835,6 +3863,15 @@ class ReticulumMeshChat:
},
)
@routes.get("/api/v1/system/network-interfaces")
async def system_network_interfaces(request):
interfaces, unavailable_reason = list_host_network_interfaces()
payload = {
"interfaces": interfaces,
"unavailable_reason": unavailable_reason,
}
return web.json_response(payload)
@routes.get("/api/v1/tools/rnode/latest_release")
async def tools_rnode_latest_release(request):
"""Proxy GitHub's latest-release JSON for RNode firmware (official repo).
@@ -4430,7 +4467,14 @@ class ReticulumMeshChat:
if interface_type == "TCPServerInterface":
# ensure listen ip provided
interface_listen_ip = data.get("listen_ip")
if interface_listen_ip is None or interface_listen_ip == "":
if (
interface_listen_ip is not None
and str(interface_listen_ip).strip() != ""
):
interface_listen_ip = str(interface_listen_ip).strip()
else:
interface_listen_ip = ""
if interface_listen_ip == "":
return web.json_response(
{
"message": "Listen IP is required",
@@ -4479,7 +4523,14 @@ class ReticulumMeshChat:
if interface_type == "UDPInterface":
# ensure listen ip provided
interface_listen_ip = data.get("listen_ip")
if interface_listen_ip is None or interface_listen_ip == "":
if (
interface_listen_ip is not None
and str(interface_listen_ip).strip() != ""
):
interface_listen_ip = str(interface_listen_ip).strip()
else:
interface_listen_ip = ""
if interface_listen_ip == "":
return web.json_response(
{
"message": "Listen IP is required",
@@ -4559,6 +4610,19 @@ class ReticulumMeshChat:
status=422,
)
if str(interface_port).strip().lower().startswith("tcp://"):
interface_port = InterfaceEditor.normalize_rnode_tcp_port(
str(interface_port),
)
host_part = str(interface_port)[len("tcp://") :].strip().strip(":")
if not host_part:
return web.json_response(
{
"message": "TCP host is required for RNode over IP",
},
status=422,
)
# ensure frequency provided
interface_frequency = data.get("frequency")
if interface_frequency is None or interface_frequency == "":
+40
View File
@@ -1,5 +1,44 @@
# SPDX-License-Identifier: 0BSD AND MIT
import re
_IPV4_HOST_PORT = re.compile(r"^(\d{1,3}(?:\.\d{1,3}){3}):(\d{1,5})$")
def normalize_rnode_tcp_port(port: str) -> str:
"""Normalize RNodeInterface ``port`` when using ``tcp://``.
Reticulum's ``TCPConnection`` (``RNS/Interfaces/RNodeInterface.py``) calls
``socket.getaddrinfo(target_host, 7633)``. The first argument must be a hostname or IP **only**; an embedded ``:port``
breaks resolution. Config may list legacy ``tcp://host:7633`` or ``tcp://host:``;
strip those so storage matches ``tcp://<host>``.
"""
raw = str(port).strip()
low = raw.lower()
scheme = "tcp://"
if not low.startswith(scheme):
return raw
rest = raw[len(scheme) :].strip()
while rest.endswith(":"):
rest = rest[:-1]
if not rest:
return scheme
if rest.startswith("["):
close = rest.find("]")
if close != -1 and len(rest) > close + 1 and rest[close + 1] == ":":
tail = rest[close + 2 :]
if tail.isdigit() and 1 <= int(tail) <= 65535:
rest = rest[: close + 1]
return scheme + rest
m = _IPV4_HOST_PORT.match(rest)
if m and int(m.group(2)) <= 65535:
rest = m.group(1)
elif rest.count(":") == 1:
head, tail = rest.split(":", 1)
if tail.isdigit() and 1 <= int(tail) <= 65535:
rest = head
return scheme + rest
def coerce_rnode_frequency_hz(value):
"""Return RNode carrier frequency as integer Hz for Reticulum config.
@@ -32,6 +71,7 @@ def coerce_rnode_frequency_hz(value):
class InterfaceEditor:
coerce_rnode_frequency_hz = staticmethod(coerce_rnode_frequency_hz)
normalize_rnode_tcp_port = staticmethod(normalize_rnode_tcp_port)
@staticmethod
def update_value(interface_details: dict, data: dict, key: str):
@@ -351,7 +351,9 @@
</div>
<div v-else class="space-y-4">
<div>
<FormLabel class="glass-label">Listen IP (Optional)</FormLabel>
<FormLabel class="glass-label"
>Listen IP (optional if Device is set)</FormLabel
>
<input
v-model="newInterfaceBackboneListenIp"
type="text"
@@ -373,10 +375,58 @@
<input
v-model="newInterfaceBackboneListenDevice"
type="text"
placeholder="e.g. eth0"
placeholder="Kernel interface e.g. eth0, wlan0"
class="input-field"
/>
</div>
<div
v-if="
hostKernelInterfacesLoading ||
hostKernelInterfaces.length ||
hostKernelInterfacesUnavailable
"
class="rounded-xl border border-gray-100 dark:border-zinc-800 bg-gray-50/50 dark:bg-zinc-900/30 p-3 space-y-2"
>
<p class="text-xs text-gray-600 dark:text-zinc-400">
{{ $t("interfaces.kernel_iface_picker_title") }}
</p>
<div
v-if="hostKernelInterfacesLoading"
class="text-xs text-gray-400"
>
{{ $t("interfaces.kernel_iface_loading") }}
</div>
<div v-else class="flex flex-wrap gap-1.5 max-h-36 overflow-y-auto">
<button
v-for="iface in hostKernelInterfaces"
:key="'bb-' + iface.name"
type="button"
class="px-2.5 py-1.5 text-left text-xs rounded-lg border border-gray-200 dark:border-zinc-700 bg-white/90 dark:bg-zinc-900/90 hover:border-blue-400 dark:hover:border-blue-500 transition-colors max-w-full"
@click="newInterfaceBackboneListenDevice = iface.name"
>
<span
class="font-mono font-medium text-gray-900 dark:text-zinc-100"
>{{ iface.name }}</span
>
<span
v-if="iface.addresses && iface.addresses.length"
class="block text-[10px] text-gray-500 dark:text-zinc-500 truncate max-w-[20rem]"
>{{
iface.addresses.slice(0, 3).join(" \u00b7 ")
}}</span
>
</button>
</div>
<p
v-if="hostKernelInterfacesUnavailable"
class="text-xs text-amber-600 dark:text-amber-500"
>
{{ hostKernelInterfacesUnavailable }}
</p>
<p class="text-xs text-gray-500 dark:text-zinc-500">
{{ $t("interfaces.kernel_iface_picker_help") }}
</p>
</div>
<div class="flex items-center gap-2">
<Toggle
id="backbone-listen-ipv6"
@@ -397,12 +447,13 @@
class="space-y-4"
>
<div>
<FormLabel class="glass-label">Listen IP (Optional)</FormLabel>
<FormLabel class="glass-label">Listen IP</FormLabel>
<input
v-model="newInterfaceListenIp"
type="text"
placeholder="0.0.0.0"
class="input-field"
required
/>
</div>
<div>
@@ -419,10 +470,53 @@
<input
v-model="newInterfaceNetworkDevice"
type="text"
placeholder="e.g. eth0 (overrides Listen IP)"
placeholder="e.g. eth0 (real OS interface; leave empty to use Listen IP only)"
class="input-field"
/>
</div>
<div
v-if="
hostKernelInterfacesLoading ||
hostKernelInterfaces.length ||
hostKernelInterfacesUnavailable
"
class="rounded-xl border border-gray-100 dark:border-zinc-800 bg-gray-50/50 dark:bg-zinc-900/30 p-3 space-y-2"
>
<p class="text-xs text-gray-600 dark:text-zinc-400">
{{ $t("interfaces.kernel_iface_picker_title") }}
</p>
<div v-if="hostKernelInterfacesLoading" class="text-xs text-gray-400">
{{ $t("interfaces.kernel_iface_loading") }}
</div>
<div v-else class="flex flex-wrap gap-1.5 max-h-36 overflow-y-auto">
<button
v-for="iface in hostKernelInterfaces"
:key="'srv-' + iface.name"
type="button"
class="px-2.5 py-1.5 text-left text-xs rounded-lg border border-gray-200 dark:border-zinc-700 bg-white/90 dark:bg-zinc-900/90 hover:border-blue-400 dark:hover:border-blue-500 transition-colors max-w-full"
@click="newInterfaceNetworkDevice = iface.name"
>
<span
class="font-mono font-medium text-gray-900 dark:text-zinc-100"
>{{ iface.name }}</span
>
<span
v-if="iface.addresses && iface.addresses.length"
class="block text-[10px] text-gray-500 dark:text-zinc-500 truncate max-w-[20rem]"
>{{ iface.addresses.slice(0, 3).join(" \u00b7 ") }}</span
>
</button>
</div>
<p
v-if="hostKernelInterfacesUnavailable"
class="text-xs text-amber-600 dark:text-amber-500"
>
{{ hostKernelInterfacesUnavailable }}
</p>
<p class="text-xs text-gray-500 dark:text-zinc-500">
{{ $t("interfaces.kernel_iface_picker_help") }}
</p>
</div>
<div
v-if="newInterfaceType === 'TCPServerInterface'"
class="flex flex-wrap items-center gap-4"
@@ -562,10 +656,12 @@
<div
v-if="newInterfaceRNodeUseIP || newInterfaceType === 'RNodeIPInterface'"
class="grid grid-cols-2 gap-4"
class="space-y-2"
>
<div>
<FormLabel class="glass-label">Host</FormLabel>
<FormLabel class="glass-label">{{
$t("interfaces.rnode_ip_host_label")
}}</FormLabel>
<input
v-model="newInterfaceRNodeIPHost"
type="text"
@@ -573,15 +669,9 @@
class="input-field"
/>
</div>
<div>
<FormLabel class="glass-label">Port</FormLabel>
<input
v-model="newInterfaceRNodeIPPort"
type="number"
placeholder="7633"
class="input-field"
/>
</div>
<p class="text-xs text-gray-500 dark:text-zinc-500 leading-relaxed">
{{ $t("interfaces.rnode_tcp_port_fixed_hint") }}
</p>
</div>
<div
v-else-if="
@@ -1697,6 +1787,10 @@ export default {
comports: [],
hostKernelInterfaces: [],
hostKernelInterfacesLoading: false,
hostKernelInterfacesUnavailable: null,
newInterfaceName: null,
newInterfaceType: null,
@@ -1783,8 +1877,7 @@ export default {
newInterfaceRNodeUseIP: false,
newInterfaceRNodeUseBle: false,
newInterfaceRNodeBlePeer: "",
newInterfaceRNodeIPHost: "localhost",
newInterfaceRNodeIPPort: "7633",
newInterfaceRNodeIPHost: "",
RNodeGHzValue: 0,
RNodeMHzValue: 0,
RNodekHzValue: 0,
@@ -1902,6 +1995,7 @@ export default {
this.getConfig();
this.loadReticulumDiscoveryConfig();
this.loadComports();
this.loadHostKernelInterfaces();
this.loadCommunityInterfaces();
// check if we are editing an interface
@@ -1986,6 +2080,59 @@ export default {
console.log(e);
}
},
buildRNodeTcpPort() {
let h = String(this.newInterfaceRNodeIPHost ?? "").trim();
while (h.endsWith(":")) {
h = h.slice(0, -1);
}
if (!h) {
return "";
}
return `tcp://${h}`;
},
parseRnodeTcpHostFromPort(portStr) {
const s = String(portStr || "");
if (!s.startsWith("tcp://")) {
return "localhost";
}
let rest = s.slice(6);
while (rest.endsWith(":")) {
rest = rest.slice(0, -1);
}
if (!rest) {
return "";
}
if (rest.startsWith("[")) {
const close = rest.indexOf("]");
if (close !== -1 && rest[close + 1] === ":") {
return rest.slice(0, close + 1);
}
return rest;
}
if (rest.includes(":") && rest.indexOf(":") === rest.lastIndexOf(":")) {
const idx = rest.indexOf(":");
const tail = rest.slice(idx + 1);
if (/^\d{1,5}$/.test(tail) && Number(tail) <= 65535) {
return rest.slice(0, idx);
}
}
return rest;
},
async loadHostKernelInterfaces() {
this.hostKernelInterfacesLoading = true;
this.hostKernelInterfacesUnavailable = null;
try {
const response = await window.api.get(`/api/v1/system/network-interfaces`);
this.hostKernelInterfaces = response.data.interfaces || [];
this.hostKernelInterfacesUnavailable = response.data.unavailable_reason || null;
} catch (e) {
this.hostKernelInterfaces = [];
this.hostKernelInterfacesUnavailable = e.response?.data?.message ?? e.message ?? "unavailable";
console.log(e);
} finally {
this.hostKernelInterfacesLoading = false;
}
},
effectiveRNodeBlePort() {
let p = (this.newInterfaceRNodeBlePeer || "").trim();
if (!p) {
@@ -2139,10 +2286,7 @@ export default {
this.newInterfaceRNodeUseBle = true;
this.newInterfaceRNodeBlePeer = String(iface.port);
} else if (iface.port && String(iface.port).startsWith("tcp://")) {
const addr = String(iface.port).replace("tcp://", "");
const parts = addr.split(":");
this.newInterfaceRNodeIPHost = parts[0] || "localhost";
this.newInterfaceRNodeIPPort = parts[1] || "7633";
this.newInterfaceRNodeIPHost = this.parseRnodeTcpHostFromPort(iface.port);
this.newInterfaceRNodeUseIP = true;
}
this.newInterfaceFrequency = iface.frequency;
@@ -2265,10 +2409,7 @@ export default {
this.newInterfaceRNodeUseBle = true;
this.newInterfaceRNodeBlePeer = config.port;
} else if (config.port.startsWith("tcp://")) {
const addr = config.port.replace("tcp://", "");
const [host, port] = addr.split(":");
this.newInterfaceRNodeIPHost = host;
this.newInterfaceRNodeIPPort = port || "7633";
this.newInterfaceRNodeIPHost = this.parseRnodeTcpHostFromPort(config.port);
this.newInterfaceRNodeUseIP = true;
}
}
@@ -2561,6 +2702,24 @@ export default {
}
}
if (
this.newInterfaceType === "RNodeIPInterface" ||
(this.newInterfaceType === "RNodeInterface" && this.newInterfaceRNodeUseIP)
) {
if (!this.buildRNodeTcpPort()) {
ToastUtils.error(this.$t("interfaces.rnode_tcp_host_required"));
return;
}
}
if (this.newInterfaceType === "TCPServerInterface" || this.newInterfaceType === "UDPInterface") {
const lip = String(this.newInterfaceListenIp ?? "").trim();
if (!lip) {
ToastUtils.error(this.$t("interfaces.listen_ip_required"));
return;
}
}
if (this.newInterfaceType === "__external__") {
const typeStr = (this.customExternalTypeName || "").trim();
if (!typeStr) {
@@ -2636,16 +2795,18 @@ export default {
transport_identity: isBackboneListener ? null : this.newInterfaceTransportIdentity,
peers: i2pPeers,
listen_ip: isBackboneListener
? this.newInterfaceBackboneListenIp || null
: this.newInterfaceListenIp,
? (this.newInterfaceBackboneListenIp || "").trim() || null
: this.newInterfaceType === "TCPServerInterface" || this.newInterfaceType === "UDPInterface"
? String(this.newInterfaceListenIp ?? "").trim()
: this.newInterfaceListenIp,
listen_port: isBackboneListener
? this.newInterfaceBackboneListenPort || null
: this.newInterfaceListenPort,
forward_ip: this.newInterfaceForwardIp,
forward_port: this.newInterfaceForwardPort,
device: isBackboneListener
? this.newInterfaceBackboneListenDevice || null
: this.newInterfaceNetworkDevice,
? (this.newInterfaceBackboneListenDevice || "").trim() || null
: (this.newInterfaceNetworkDevice || "").trim() || null,
prefer_ipv6: this.newInterfacePreferIPV6 === true,
kiss_framing: this.newInterfaceKISSFramingEnabled === true,
i2p_tunneled: this.newInterfaceI2PTunnelingEnabled === true,
@@ -2659,11 +2820,12 @@ export default {
: undefined,
connectable:
this.newInterfaceType === "I2PInterface" ? this.newInterfaceConnectable === true : null,
port: this.newInterfaceRNodeUseIP
? `tcp://${this.newInterfaceRNodeIPHost}:${this.newInterfaceRNodeIPPort}`
: this.newInterfaceRNodeUseBle
? this.effectiveRNodeBlePort()
: this.newInterfacePort,
port:
this.newInterfaceType === "RNodeIPInterface" || this.newInterfaceRNodeUseIP
? this.buildRNodeTcpPort()
: this.newInterfaceRNodeUseBle
? this.effectiveRNodeBlePort()
: this.newInterfacePort,
frequency: freqHz,
bandwidth: this.newInterfaceBandwidth,
txpower: this.newInterfaceTxpower,
+10 -1
View File
@@ -810,6 +810,8 @@
"contact_support_title": "Kontakt & Unterstuetzung",
"contact_lxmf_address": "LXMF-Adresse",
"contact_propagation_hint": "An den Verbreitungsknoten senden, wenn Sie mich nicht direkt erreichen können.",
"contact_open_messages": "Unterhaltung oeffnen",
"contact_copy_address": "Adresse kopieren",
"donate_label": "Spenden",
"donate_monero_label": "Monero (XMR)",
"donate_copy_monero": "Monero-Adresse kopieren",
@@ -951,7 +953,14 @@
"rnode_ble_hint": "Reticulum nutzt BLE über bleak auf dem Gerät, auf dem MeshChatX läuft. RNode zuerst im Betriebssystem koppeln oder verbinden. Reine Browser- oder Remote-UI kann kein BLE an Reticulum durchreichen; Electron, Linux, macOS, Windows oder die Android-App verwenden, wo eingebettetes Python bleak enthält. Unter Android gelten weiterhin normale Kopplung, Hardwareunterstützung und Berechtigungsabfragen.",
"rnode_ble_peer_label": "BLE-Name oder MAC",
"rnode_ble_peer_placeholder": "MyRNode oder aa:bb:cc:dd:ee:ff",
"rnode_ble_peer_required": "Anzeigenamen oder MAC-Adresse des RNode eingeben."
"rnode_ble_peer_required": "Anzeigenamen oder MAC-Adresse des RNode eingeben.",
"rnode_ip_host_label": "Hostname oder IP-Adresse",
"rnode_tcp_port_fixed_hint": "Reticulum nutzt TCP-Port 7633 auf dem RNode; nur Hostname oder IP eingeben (kein :Port).",
"rnode_tcp_host_required": "Hostname oder IP für RNode über TCP eingeben.",
"listen_ip_required": "Listen-IP ist erforderlich.",
"kernel_iface_picker_title": "Schnittstellen auf diesem Rechner",
"kernel_iface_loading": "Laden…",
"kernel_iface_picker_help": "Optional ein Kernel-Interface-Name, oder leer lassen und nur über Listen-IP binden. Klick füllt das Feld."
},
"map": {
"title": "Karte",
+10 -1
View File
@@ -740,6 +740,8 @@
"contact_support_title": "Contact & support",
"contact_lxmf_address": "LXMF Address",
"contact_propagation_hint": "Send to propagation node if you cant reach me!",
"contact_open_messages": "Open conversation",
"contact_copy_address": "Copy address",
"donate_label": "Donate",
"donate_monero_label": "Monero (XMR)",
"donate_copy_monero": "Copy Monero address",
@@ -899,7 +901,14 @@
"rnode_ble_hint": "Reticulum uses BLE via bleak on the device running MeshChatX. Pair or bond the RNode in the OS first. A browser-only or remote UI cannot attach BLE to Reticulum; use Electron, Linux, macOS, Windows, or the Android app, where the bundled Python includes bleak. On Android, Bluetooth still follows normal OS pairing, hardware support, and permission prompts.",
"rnode_ble_peer_label": "BLE name or MAC",
"rnode_ble_peer_placeholder": "MyRNode or aa:bb:cc:dd:ee:ff",
"rnode_ble_peer_required": "Enter a BLE advertised name or MAC address for the RNode."
"rnode_ble_peer_required": "Enter a BLE advertised name or MAC address for the RNode.",
"rnode_ip_host_label": "Host or IP address",
"rnode_tcp_port_fixed_hint": "Reticulum opens TCP port 7633 on the RNode itself; enter only the hostname or IP (do not add :port).",
"rnode_tcp_host_required": "Enter the hostname or IP for RNode over TCP.",
"listen_ip_required": "Listen IP is required.",
"kernel_iface_picker_title": "Interfaces on this host",
"kernel_iface_loading": "Loading…",
"kernel_iface_picker_help": "Device is optional: one kernel interface name, or leave empty to bind using Listen IP only. Click a row to fill the field."
},
"map": {
"title": "Map",
+10 -1
View File
@@ -758,6 +758,8 @@
"contact_support_title": "Contacto y apoyo",
"contact_lxmf_address": "Dirección LXMF",
"contact_propagation_hint": "Enviar al nodo de propagación si no puedes alcanzarme directamente.",
"contact_open_messages": "Abrir conversación",
"contact_copy_address": "Copiar dirección",
"donate_label": "Donar",
"donate_monero_label": "Monero (XMR)",
"donate_copy_monero": "Copiar dirección Monero",
@@ -899,7 +901,14 @@
"rnode_ble_hint": "Reticulum usa BLE mediante bleak en el dispositivo donde corre MeshChatX. Empareje o vincule el RNode en el sistema primero. Una interfaz solo web o remota no puede conectar BLE a Reticulum; use Electron, Linux, macOS, Windows o la app Android, donde Python integrado incluye bleak. En Android, el Bluetooth sigue las reglas habituales de emparejamiento, hardware y permisos del sistema.",
"rnode_ble_peer_label": "Nombre BLE o MAC",
"rnode_ble_peer_placeholder": "MyRNode o aa:bb:cc:dd:ee:ff",
"rnode_ble_peer_required": "Introduzca el nombre anunciado o la MAC del RNode."
"rnode_ble_peer_required": "Introduzca el nombre anunciado o la MAC del RNode.",
"rnode_ip_host_label": "Nombre de host o dirección IP",
"rnode_tcp_port_fixed_hint": "Reticulum usa el puerto TCP 7633 en el RNode; indique solo el host o la IP (sin :puerto).",
"rnode_tcp_host_required": "Indique el host o la IP para RNode por TCP.",
"listen_ip_required": "La IP de escucha es obligatoria.",
"kernel_iface_picker_title": "Interfaces en este equipo",
"kernel_iface_loading": "Cargando…",
"kernel_iface_picker_help": "“Dispositivo” es opcional: un nombre de interfaz del kernel, o vacío para enlazar solo con la IP de escucha. Pulse una fila para rellenar."
},
"map": {
"title": "Mapa",
+10 -1
View File
@@ -758,6 +758,8 @@
"contact_support_title": "Contact et soutien",
"contact_lxmf_address": "Adresse LXMF",
"contact_propagation_hint": "Envoyez au nœud de propagation si vous ne pouvez pas me joindre directement.",
"contact_open_messages": "Ouvrir la conversation",
"contact_copy_address": "Copier ladresse",
"donate_label": "Faire un don",
"donate_monero_label": "Monero (XMR)",
"donate_copy_monero": "Copier ladresse Monero",
@@ -899,7 +901,14 @@
"rnode_ble_hint": "Reticulum utilise le BLE via bleak sur l'appareil qui exécute MeshChatX. Associez ou appairez d'abord le RNode dans le système. Une interface web seule ou distante ne peut pas raccorder le BLE à Reticulum ; utilisez Electron, Linux, macOS, Windows ou l'application Android, où Python embarqué inclut bleak. Sous Android, le Bluetooth suit l'appairage, le matériel et les demandes de permission habituels du système.",
"rnode_ble_peer_label": "Nom BLE ou MAC",
"rnode_ble_peer_placeholder": "MyRNode ou aa:bb:cc:dd:ee:ff",
"rnode_ble_peer_required": "Entrez le nom annoncé ou l'adresse MAC du RNode."
"rnode_ble_peer_required": "Entrez le nom annoncé ou l'adresse MAC du RNode.",
"rnode_ip_host_label": "Nom d'hôte ou adresse IP",
"rnode_tcp_port_fixed_hint": "Reticulum utilise le port TCP 7633 sur le RNode ; saisissez uniquement le nom d'hôte ou l'IP (sans :port).",
"rnode_tcp_host_required": "Saisissez le nom d'hôte ou l'IP pour le RNode en TCP.",
"listen_ip_required": "L'adresse IP d'écoute est obligatoire.",
"kernel_iface_picker_title": "Interfaces sur cette machine",
"kernel_iface_loading": "Chargement…",
"kernel_iface_picker_help": "Le champ Périphérique est optionnel : un seul nom d'interface noyau, ou laisser vide pour lier via l'IP d'écoute uniquement. Cliquez sur une ligne pour remplir."
},
"map": {
"title": "Carte",
+10 -1
View File
@@ -810,6 +810,8 @@
"contact_support_title": "Contatto e supporto",
"contact_lxmf_address": "Indirizzo LXMF",
"contact_propagation_hint": "Invia al nodo di propagazione se non riesci a raggiungermi direttamente.",
"contact_open_messages": "Apri conversazione",
"contact_copy_address": "Copia indirizzo",
"donate_label": "Dona",
"donate_monero_label": "Monero (XMR)",
"donate_copy_monero": "Copia indirizzo Monero",
@@ -951,7 +953,14 @@
"rnode_ble_hint": "Reticulum usa il BLE tramite bleak sul dispositivo che esegue MeshChatX. Associare o accoppiare prima l'RNode nel sistema. Solo browser o interfaccia remota non possono collegare il BLE a Reticulum; usare Electron, Linux, macOS, Windows o l'app Android, dove Python integrato include bleak. Su Android valgono come sempre accoppiamento, hardware e richieste di permesso del sistema.",
"rnode_ble_peer_label": "Nome BLE o MAC",
"rnode_ble_peer_placeholder": "MyRNode o aa:bb:cc:dd:ee:ff",
"rnode_ble_peer_required": "Inserire il nome annunciato o il MAC dell'RNode."
"rnode_ble_peer_required": "Inserire il nome annunciato o il MAC dell'RNode.",
"rnode_ip_host_label": "Nome host o indirizzo IP",
"rnode_tcp_port_fixed_hint": "Reticulum usa la porta TCP 7633 sull'RNode; inserire solo host o IP (senza :porta).",
"rnode_tcp_host_required": "Inserire host o IP per l'RNode via TCP.",
"listen_ip_required": "L'IP di ascolto è obbligatoria.",
"kernel_iface_picker_title": "Interfacce su questo host",
"kernel_iface_loading": "Caricamento…",
"kernel_iface_picker_help": "Il dispositivo è facoltativo: un solo nome di interfaccia del kernel, o lascia vuoto e lega solo con l'IP di ascolto. Clic su una riga per compilare."
},
"map": {
"title": "Mappa",
+10 -1
View File
@@ -758,6 +758,8 @@
"contact_support_title": "Contact & ondersteuning",
"contact_lxmf_address": "LXMF-adres",
"contact_propagation_hint": "Stuur naar het propagatieknooppunt als je me niet direct kunt bereiken.",
"contact_open_messages": "Gesprek openen",
"contact_copy_address": "Adres kopiëren",
"donate_label": "Doneren",
"donate_monero_label": "Monero (XMR)",
"donate_copy_monero": "Monero-adres kopiëren",
@@ -899,7 +901,14 @@
"rnode_ble_hint": "Reticulum gebruikt BLE via bleak op het apparaat waar MeshChatX draait. Koppel of verbind de RNode eerst in het besturingssysteem. Met alleen een browser of een externe UI kunt u geen BLE aan Reticulum koppelen; gebruik Electron, Linux, macOS, Windows of de Android-app, waar ingebed Python bleak bevat. Op Android gelden de gebruikelijke koppeling, hardware en machtigingsvragen van het OS.",
"rnode_ble_peer_label": "BLE-naam of MAC",
"rnode_ble_peer_placeholder": "MyRNode of aa:bb:cc:dd:ee:ff",
"rnode_ble_peer_required": "Voer de aangekondigde naam of MAC van de RNode in."
"rnode_ble_peer_required": "Voer de aangekondigde naam of MAC van de RNode in.",
"rnode_ip_host_label": "Hostnaam of IP-adres",
"rnode_tcp_port_fixed_hint": "Reticulum gebruikt TCP-poort 7633 op de RNode; alleen hostnaam of IP (geen :poort).",
"rnode_tcp_host_required": "Voer hostnaam of IP in voor RNode via TCP.",
"listen_ip_required": "Listen-IP is verplicht.",
"kernel_iface_picker_title": "Interfaces op deze host",
"kernel_iface_loading": "Laden…",
"kernel_iface_picker_help": "Apparaat is optioneel: één kernelinterfacenaam, of leeg laten en alleen via listen-IP binden. Klik op een regel om in te vullen."
},
"map": {
"title": "Kaart",
+10 -1
View File
@@ -810,6 +810,8 @@
"contact_support_title": "Контакты и поддержка",
"contact_lxmf_address": "Адрес LXMF",
"contact_propagation_hint": "Отправьте на узел распространения, если не удаётся связаться со мной напрямую.",
"contact_open_messages": "Открыть переписку",
"contact_copy_address": "Копировать адрес",
"donate_label": "Пожертвование",
"donate_monero_label": "Monero (XMR)",
"donate_copy_monero": "Копировать адрес Monero",
@@ -951,7 +953,14 @@
"rnode_ble_hint": "Reticulum использует BLE через bleak на устройстве, где запущен MeshChatX. Сначала сопрягите или свяжите RNode в ОС. Только веб или удалённый интерфейс не подключит BLE к Reticulum; используйте Electron, Linux, macOS, Windows или приложение Android, где встроенный Python включает bleak. На Android действуют обычные сопряжение, поддержка оборудования и запросы разрешений ОС.",
"rnode_ble_peer_label": "Имя BLE или MAC",
"rnode_ble_peer_placeholder": "MyRNode или aa:bb:cc:dd:ee:ff",
"rnode_ble_peer_required": "Укажите объявленное имя или MAC-адрес RNode."
"rnode_ble_peer_required": "Укажите объявленное имя или MAC-адрес RNode.",
"rnode_ip_host_label": "Имя хоста или IP-адрес",
"rnode_tcp_port_fixed_hint": "Reticulum подключается к RNode по TCP 7633; укажите только имя хоста или IP (без :порт).",
"rnode_tcp_host_required": "Укажите имя хоста или IP для RNode по TCP.",
"listen_ip_required": "Укажите IP для прослушивания.",
"kernel_iface_picker_title": "Интерфейсы на этом узле",
"kernel_iface_loading": "Загрузка…",
"kernel_iface_picker_help": "Поле «устройство» необязательно: одно имя интерфейса ядра или пусто — привязка только по Listen IP. Нажмите строку, чтобы подставить имя."
},
"map": {
"title": "Карта",
+10 -1
View File
@@ -758,6 +758,8 @@
"contact_support_title": "联系与支持",
"contact_lxmf_address": "LXMF 地址",
"contact_propagation_hint": "若无法直接联系我,请发送至传播节点。",
"contact_open_messages": "打开对话",
"contact_copy_address": "复制地址",
"donate_label": "捐赠",
"donate_monero_label": "MoneroXMR",
"donate_copy_monero": "复制 Monero 地址",
@@ -899,7 +901,14 @@
"rnode_ble_hint": "Reticulum 通过 bleak 在运行 MeshChatX 的设备上使用 BLE。请先在系统中配对或绑定 RNode。纯网页或远程界面无法将 BLE 交给 Reticulum;请使用内置 Python 且包含 bleak 的 Electron、Linux、macOS、Windows 或 Android 应用。在 Android 上仍需遵循系统常规的配对、硬件支持与权限提示。",
"rnode_ble_peer_label": "BLE 名称或 MAC",
"rnode_ble_peer_placeholder": "MyRNode 或 aa:bb:cc:dd:ee:ff",
"rnode_ble_peer_required": "请输入 RNode 的广播名称或 MAC 地址。"
"rnode_ble_peer_required": "请输入 RNode 的广播名称或 MAC 地址。",
"rnode_ip_host_label": "主机名或 IP 地址",
"rnode_tcp_port_fixed_hint": "Reticulum 使用 TCP 7633 连接 RNode;只需填写主机名或 IP,不要加 :端口。",
"rnode_tcp_host_required": "请填写 RNode TCP 连接的主机名或 IP。",
"listen_ip_required": "Listen IP 为必填项。",
"kernel_iface_picker_title": "本机网络接口",
"kernel_iface_loading": "加载中…",
"kernel_iface_picker_help": "“设备”为可选,填写一个内核网卡名,或留空仅按 Listen IP 绑定。点击一行填入输入框。"
},
"map": {
"title": "地图",
@@ -108,6 +108,10 @@
"method": "GET",
"path": "/api/v1/comports"
},
{
"method": "GET",
"path": "/api/v1/system/network-interfaces"
},
{
"method": "GET",
"path": "/api/v1/config"
+30
View File
@@ -44,3 +44,33 @@ def test_coerce_rnode_frequency_hz_integer_mhz():
def test_coerce_rnode_frequency_hz_leaves_midrange_hz():
assert InterfaceEditor.coerce_rnode_frequency_hz(125000) == 125000
def test_normalize_rnode_tcp_port_host_only():
assert (
InterfaceEditor.normalize_rnode_tcp_port("tcp://10.0.0.5") == "tcp://10.0.0.5"
)
def test_normalize_rnode_tcp_port_strips_legacy_ipv4_port():
assert (
InterfaceEditor.normalize_rnode_tcp_port("tcp://10.0.0.5:7633")
== "tcp://10.0.0.5"
)
def test_normalize_rnode_tcp_port_strips_trailing_colons():
assert (
InterfaceEditor.normalize_rnode_tcp_port("tcp://10.0.0.5:") == "tcp://10.0.0.5"
)
def test_normalize_rnode_tcp_port_bracket_ipv6_with_port():
assert (
InterfaceEditor.normalize_rnode_tcp_port("tcp://[2001:db8::1]:7633")
== "tcp://[2001:db8::1]"
)
def test_normalize_rnode_tcp_port_non_tcp_unchanged():
assert InterfaceEditor.normalize_rnode_tcp_port("/dev/ttyUSB0") == "/dev/ttyUSB0"
+59
View File
@@ -235,6 +235,23 @@ async def test_tcp_server_persists_optional_options(temp_dir):
assert saved["i2p_tunneled"] is True
@pytest.mark.asyncio
async def test_tcp_server_rejects_whitespace_listen_ip(temp_dir):
config = ConfigDict({"reticulum": {}, "interfaces": {}})
async with make_app(temp_dir, config) as handler:
payload = {
"name": "TCPServer",
"type": "TCPServerInterface",
"listen_ip": " ",
"listen_port": _free_port("tcp"),
}
response = await handler(make_request(payload))
body = json.loads(response.body)
assert response.status == 422, body
assert "Listen IP" in body["message"]
@pytest.mark.asyncio
async def test_tcp_server_rejects_busy_listen_port(temp_dir):
config = ConfigDict({"reticulum": {}, "interfaces": {}})
@@ -433,6 +450,48 @@ async def test_rnode_ble_uart_port_persisted(temp_dir):
assert config["interfaces"]["RadioBLE"]["port"] == "ble://aa:bb:cc:dd:ee:ff"
@pytest.mark.asyncio
async def test_rnode_tcp_over_ip_normalizes_to_host_only(temp_dir):
config = ConfigDict({"reticulum": {}, "interfaces": {}})
async with make_app(temp_dir, config) as handler:
payload = {
"name": "RNodeWiFi",
"type": "RNodeIPInterface",
"port": "tcp://192.168.4.1:7633",
"frequency": 868000000,
"bandwidth": 125000,
"txpower": 7,
"spreadingfactor": 8,
"codingrate": 5,
}
response = await handler(make_request(payload))
body = json.loads(response.body)
assert response.status == 200, body
assert config["interfaces"]["RNodeWiFi"]["port"] == "tcp://192.168.4.1"
@pytest.mark.asyncio
async def test_rnode_tcp_over_ip_rejects_empty_host(temp_dir):
config = ConfigDict({"reticulum": {}, "interfaces": {}})
async with make_app(temp_dir, config) as handler:
payload = {
"name": "Bad",
"type": "RNodeIPInterface",
"port": "tcp://",
"frequency": 868000000,
"bandwidth": 125000,
"txpower": 7,
"spreadingfactor": 8,
"codingrate": 5,
}
response = await handler(make_request(payload))
body = json.loads(response.body)
assert response.status == 422, body
assert "TCP host" in body["message"]
@pytest.mark.asyncio
async def test_rnode_frequency_mhz_decimal_normalized_to_hz(temp_dir):
config = ConfigDict({"reticulum": {}, "interfaces": {}})
@@ -0,0 +1,108 @@
# SPDX-License-Identifier: 0BSD
import json
import shutil
import tempfile
from unittest.mock import MagicMock, patch
import pytest
import RNS
from meshchatx.meshchat import ReticulumMeshChat
@pytest.fixture
def temp_dir():
dir_path = tempfile.mkdtemp()
yield dir_path
shutil.rmtree(dir_path)
@pytest.fixture
def mock_rns_minimal():
with (
patch("RNS.Reticulum") as mock_rns,
patch("RNS.Transport"),
patch("LXMF.LXMRouter"),
patch("meshchatx.meshchat.get_file_path", return_value="/tmp/mock_path"),
):
mock_rns_instance = mock_rns.return_value
mock_rns_instance.configpath = "/tmp/mock_config"
mock_rns_instance.is_connected_to_shared_instance = False
mock_rns_instance.transport_enabled.return_value = True
mock_id = MagicMock(spec=RNS.Identity)
mock_id.hash = b"test_hash_32_bytes_long_01234567"
mock_id.hexhash = mock_id.hash.hex()
mock_id.get_private_key.return_value = b"test_private_key"
yield mock_id
@pytest.mark.asyncio
async def test_system_network_interfaces_returns_json(mock_rns_minimal, temp_dir):
fake_ifaces = (
[
{"name": "eth0", "addresses": []},
{"name": "lo", "addresses": []},
],
None,
)
with (
patch("meshchatx.meshchat.generate_ssl_certificate"),
patch(
"meshchatx.meshchat.list_host_network_interfaces", return_value=fake_ifaces
),
):
app_instance = ReticulumMeshChat(
identity=mock_rns_minimal,
storage_dir=temp_dir,
reticulum_config_dir=temp_dir,
)
handler = None
for route in app_instance.get_routes():
if (
route.path == "/api/v1/system/network-interfaces"
and route.method == "GET"
):
handler = route.handler
break
assert handler is not None
request = MagicMock()
response = await handler(request)
data = json.loads(response.body)
assert data["interfaces"] == [
{"name": "eth0", "addresses": []},
{"name": "lo", "addresses": []},
]
assert data["unavailable_reason"] is None
@pytest.mark.asyncio
async def test_system_network_interfaces_surfaces_psutil_error(
mock_rns_minimal, temp_dir
):
fake_ifaces = ([], "permission denied")
with (
patch("meshchatx.meshchat.generate_ssl_certificate"),
patch(
"meshchatx.meshchat.list_host_network_interfaces", return_value=fake_ifaces
),
):
app_instance = ReticulumMeshChat(
identity=mock_rns_minimal,
storage_dir=temp_dir,
reticulum_config_dir=temp_dir,
)
handler = None
for route in app_instance.get_routes():
if (
route.path == "/api/v1/system/network-interfaces"
and route.method == "GET"
):
handler = route.handler
break
assert handler is not None
response = await handler(MagicMock())
data = json.loads(response.body)
assert data["interfaces"] == []
assert data["unavailable_reason"] == "permission denied"