From fda8c58d4e0efa93e6527f8312263ef8b7aab5e0 Mon Sep 17 00:00:00 2001 From: Ivan Date: Thu, 30 Apr 2026 11:42:09 -0500 Subject: [PATCH] feat(network): implement API for listing host network interfaces and normalize TCP port handling --- meshchatx/meshchat.py | 68 +++++- meshchatx/src/backend/interface_editor.py | 40 +++ .../interfaces/AddInterfacePage.vue | 230 +++++++++++++++--- meshchatx/src/frontend/locales/de.json | 11 +- meshchatx/src/frontend/locales/en.json | 11 +- meshchatx/src/frontend/locales/es.json | 11 +- meshchatx/src/frontend/locales/fr.json | 11 +- meshchatx/src/frontend/locales/it.json | 11 +- meshchatx/src/frontend/locales/nl.json | 11 +- meshchatx/src/frontend/locales/ru.json | 11 +- meshchatx/src/frontend/locales/zh.json | 11 +- tests/backend/fixtures/http_api_routes.json | 4 + tests/backend/test_interface_editor.py | 30 +++ tests/backend/test_interface_options.py | 59 +++++ .../test_system_network_interfaces_api.py | 108 ++++++++ 15 files changed, 583 insertions(+), 44 deletions(-) create mode 100644 tests/backend/test_system_network_interfaces_api.py diff --git a/meshchatx/meshchat.py b/meshchatx/meshchat.py index 0dfd864..741cab8 100644 --- a/meshchatx/meshchat.py +++ b/meshchatx/meshchat.py @@ -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 == "": diff --git a/meshchatx/src/backend/interface_editor.py b/meshchatx/src/backend/interface_editor.py index f68e3f6..40278ac 100644 --- a/meshchatx/src/backend/interface_editor.py +++ b/meshchatx/src/backend/interface_editor.py @@ -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://``. + """ + 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): diff --git a/meshchatx/src/frontend/components/interfaces/AddInterfacePage.vue b/meshchatx/src/frontend/components/interfaces/AddInterfacePage.vue index 7f9ddf4..17df717 100644 --- a/meshchatx/src/frontend/components/interfaces/AddInterfacePage.vue +++ b/meshchatx/src/frontend/components/interfaces/AddInterfacePage.vue @@ -351,7 +351,9 @@
- Listen IP (Optional) + Listen IP (optional if Device is set)
+
+

+ {{ $t("interfaces.kernel_iface_picker_title") }} +

+
+ {{ $t("interfaces.kernel_iface_loading") }} +
+
+ +
+

+ {{ hostKernelInterfacesUnavailable }} +

+

+ {{ $t("interfaces.kernel_iface_picker_help") }} +

+
- Listen IP (Optional) + Listen IP
@@ -419,10 +470,53 @@
+
+

+ {{ $t("interfaces.kernel_iface_picker_title") }} +

+
+ {{ $t("interfaces.kernel_iface_loading") }} +
+
+ +
+

+ {{ hostKernelInterfacesUnavailable }} +

+

+ {{ $t("interfaces.kernel_iface_picker_help") }} +

+
- Host + {{ + $t("interfaces.rnode_ip_host_label") + }}
-
- Port - -
+

+ {{ $t("interfaces.rnode_tcp_port_fixed_hint") }} +