From b454757533dc2831e155ed89cdf18c5c581e49fe Mon Sep 17 00:00:00 2001 From: Ivan Date: Wed, 29 Apr 2026 14:38:44 -0500 Subject: [PATCH] refactor(meshchat): streamline transport identity handling and add extra config validation --- meshchatx/meshchat.py | 82 ++++++++++++++++++++----- tests/backend/test_interface_options.py | 56 +++++++++++++++++ 2 files changed, 124 insertions(+), 14 deletions(-) diff --git a/meshchatx/meshchat.py b/meshchatx/meshchat.py index ab0b52e..3123494 100644 --- a/meshchatx/meshchat.py +++ b/meshchatx/meshchat.py @@ -4386,22 +4386,13 @@ class ReticulumMeshChat: }, status=422, ) - transport_identity = data.get("transport_identity") - if ( - transport_identity is None - or str(transport_identity).strip() == "" - ): - return web.json_response( - { - "message": "Transport identity is required", - }, - status=422, - ) interface_details["remote"] = str(remote).strip() interface_details["target_port"] = interface_target_port - interface_details["transport_identity"] = str( - transport_identity, - ).strip() + InterfaceEditor.update_value( + interface_details, + data, + "transport_identity", + ) # handle I2P interface if interface_type == "I2PInterface": @@ -4802,6 +4793,42 @@ class ReticulumMeshChat: interface_details["command"] = interface_command interface_details["respawn_delay"] = interface_respawn_delay + _builtin_interface_types = frozenset( + { + "AutoInterface", + "TCPClientInterface", + "BackboneInterface", + "I2PInterface", + "TCPServerInterface", + "UDPInterface", + "RNodeInterface", + "RNodeIPInterface", + "RNodeMultiInterface", + "SerialInterface", + "KISSInterface", + "AX25KISSInterface", + "PipeInterface", + }, + ) + if interface_type not in _builtin_interface_types: + extra = data.get("extra_config") + if extra is None: + extra = {} + if not isinstance(extra, dict): + return web.json_response( + { + "message": "extra_config must be a JSON object", + }, + status=422, + ) + for key, value in extra.items(): + if key in {"name", "type", "allow_overwriting_interface"}: + continue + if value is None or value == "": + interface_details.pop(key, None) + else: + interface_details[key] = value + # interface discovery options for discovery_key in ( "discoverable", @@ -13706,6 +13733,27 @@ class ReticulumMeshChat: ) return + if lu.startswith("meshchatx://docs") or lu.startswith("meshchat://docs"): + from urllib.parse import parse_qsl, urlparse + + parsed = urlparse(uri_raw) + q = dict(parse_qsl(parsed.query, keep_blank_values=True)) + rel = (q.get("reticulum") or q.get("path") or "").strip() + payload: dict = { + "type": "lxm.ingest_uri.result", + "status": "success", + "message": "Opening documentation.", + "ingest_type": "docs_view", + } + if rel: + payload["docs_query"] = {"reticulum": rel} + AsyncUtils.run_async( + client.send_str( + json.dumps(payload), + ), + ) + return + # Columba-style contact sharing URI: # lxma://: if uri.lower().startswith("lxma://"): @@ -16697,6 +16745,12 @@ def main(): if args.no_crash_recovery: recovery.disable() + planned_storage_dir = args.storage_dir or os.path.join("storage") + recovery.update_paths( + storage_dir=planned_storage_dir, + reticulum_config_dir=args.reticulum_config_dir, + ) + # check if we want to test exception messages if args.test_exception_message is not None: raise Exception(args.test_exception_message) diff --git a/tests/backend/test_interface_options.py b/tests/backend/test_interface_options.py index e149d49..a03a51f 100644 --- a/tests/backend/test_interface_options.py +++ b/tests/backend/test_interface_options.py @@ -308,6 +308,62 @@ async def test_backbone_listener_mode_persists_options(temp_dir): assert saved["prefer_ipv6"] is True +@pytest.mark.asyncio +async def test_backbone_connector_allows_empty_transport_identity(temp_dir): + config = ConfigDict({"reticulum": {}, "interfaces": {}}) + + free_port = _free_port("tcp") + async with make_app(temp_dir, config) as handler: + payload = { + "name": "BackboneOut", + "type": "BackboneInterface", + "target_host": "10.0.0.1", + "target_port": str(free_port), + } + response = await handler(make_request(payload)) + body = json.loads(response.body) + assert response.status == 200, body + saved = config["interfaces"]["BackboneOut"] + assert saved["remote"] == "10.0.0.1" + assert str(saved["target_port"]) == str(free_port) + assert "transport_identity" not in saved + + +@pytest.mark.asyncio +async def test_external_interface_merges_extra_config(temp_dir): + config = ConfigDict({"reticulum": {}, "interfaces": {}}) + + async with make_app(temp_dir, config) as handler: + payload = { + "name": "WeaveTest", + "type": "WeaveInterface", + "extra_config": {"listen_ip": "127.0.0.1", "listen_port": 4242}, + } + response = await handler(make_request(payload)) + body = json.loads(response.body) + assert response.status == 200, body + saved = config["interfaces"]["WeaveTest"] + assert saved["type"] == "WeaveInterface" + assert saved["listen_ip"] == "127.0.0.1" + assert saved["listen_port"] == 4242 + + +@pytest.mark.asyncio +async def test_external_interface_rejects_non_object_extra_config(temp_dir): + config = ConfigDict({"reticulum": {}, "interfaces": {}}) + + async with make_app(temp_dir, config) as handler: + payload = { + "name": "Bad", + "type": "FooInterface", + "extra_config": "not-an-object", + } + response = await handler(make_request(payload)) + body = json.loads(response.body) + assert response.status == 422 + assert "extra_config" in body["message"].lower() + + @pytest.mark.asyncio async def test_backbone_connector_mode_still_requires_remote(temp_dir): config = ConfigDict({"reticulum": {}, "interfaces": {}})