From 0e569ae0cb29a06aab4c76fdefbd05ad21aeb096 Mon Sep 17 00:00:00 2001 From: Josiah Baldwin Date: Fri, 26 Sep 2025 14:11:39 -0700 Subject: [PATCH 1/3] Added support for run_console_commands --- src/meshctrl/session.py | 75 ++++++++++++++++++++++++++++++++++++++++- 1 file changed, 74 insertions(+), 1 deletion(-) diff --git a/src/meshctrl/session.py b/src/meshctrl/session.py index 5b7bdcb..cf1b737 100644 --- a/src/meshctrl/session.py +++ b/src/meshctrl/session.py @@ -1496,7 +1496,6 @@ class Session(object): result[node]["result"].append(event["result"]) if all(_["complete"] for key, _ in result.items()): return True - if start_data is not None: if _parse_event(start_data): return @@ -1540,6 +1539,80 @@ class Session(object): return {n: v | {"result": "".join(v["result"])} for n,v in result.items()} + async def run_console_command(self, nodeids, command, powershell=False, runasuser=False, runasuseronly=False, ignore_output=False, timeout=None): + ''' + Run a mesh console command on any number of nodes. WARNING: Non namespaced call. Calling this function again before it returns may cause unintended consequences. + + Args: + nodeids (str|list[str]): Unique ids of nodes on which to run the command + command (str): Command to run + ignore_output (bool): Don't bother trying to get the output. Every device will return an empty string for its result. + timeout (int): duration in seconds to wait for a response before throwing an error + + Returns: + dict[str, ~meshctrl.types.RunCommandResponse]: Dict containing mapped output of the commands by device + + Raises: + :py:class:`~meshctrl.exceptions.ServerError`: Error text from server if there is a failure + :py:class:`~meshctrl.exceptions.SocketError`: Info about socket closure + ValueError: `Invalid device id` if device is not found + asyncio.TimeoutError: Command timed out + ''' + if isinstance(nodeids, str): + nodeids = [nodeids] + + def match_nodeid(id, ids): + for nid in ids: + if (nid == id): + return nid + if (nid[6:] == id): + return nid + if (f"node//{nid}" == id): + return nid + + result = {n: {"complete": False, "result": [], "command": command} for n in nodeids} + async def _console(): + async for event in self.events({"action": "msg", "type": "console"}): + node = match_nodeid(event["nodeid"], nodeids) + if node: + result[node]["result"].append(event["value"]) + result.setdefault(node, {})["complete"] = True + if all(_["complete"] for key, _ in result.items()): + break + async def __(command, tg, tasks): + data = await self._send_command(command, "run_console_command", timeout=timeout) + + if data.get("type", None) != "runcommands" and data.get("result", "ok").lower() != "ok": + raise exceptions.ServerError(data["result"]) + elif data.get("type", None) != "runcommands" and data.get("result", "ok").lower() == "ok": + expect_response = False + console_task = tg.create_task(asyncio.wait_for(_console(), timeout=timeout)) + if not ignore_output: + userid = (await self.user_info())["_id"] + for n in nodeids: + device_info = await self.device_info(n, timeout=timeout) + try: + permissions = device_info.mesh.links.get(userid, {}).get("rights",constants.DeviceRights.norights)\ + # This should work for device rights, but it only seems to work for mesh rights. Not sure why, but I can't get the events to show up when the user only has individual device rights + # |device_info.get("links", {}).get(userid, {}).get("rights", constants.DeviceRights.norights) + # If we don't have agentconsole rights, we won't be able te read the output, so fill in blanks on this node + if not permissions&constants.DeviceRights.agentconsole: + result[n]["complete"] = True + else: + expect_response = True + except AttributeError: + result[n]["complete"] = True + if expect_response: + tasks.append(console_task) + else: + console_task.cancel() + + tasks = [] + async with asyncio.TaskGroup() as tg: + tasks.append(tg.create_task(__({ "action": 'runcommands', "nodeids": nodeids, "type": 4, "cmds": command}, tg, tasks))) + + return {n: v | {"result": "".join(v["result"])} for n,v in result.items()} + def shell(self, nodeid): ''' Get a terminal shell on the given device From 078e07cb4f9ed5a77a2a6fbf503f811bcf3693fb Mon Sep 17 00:00:00 2001 From: Josiah Baldwin Date: Fri, 26 Sep 2025 14:12:22 -0700 Subject: [PATCH 2/3] Added mesh agent hex ID to agent server return value --- tests/environment/__init__.py | 4 +++- tests/environment/scripts/client/agent_server.py | 2 +- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/tests/environment/__init__.py b/tests/environment/__init__.py index 7bc9cb3..3c62614 100644 --- a/tests/environment/__init__.py +++ b/tests/environment/__init__.py @@ -37,7 +37,9 @@ class Agent(object): self._clienturl = clienturl self._dockerurl = dockerurl r = requests.post(f"{self._clienturl}/add-agent", json={"url": f"{self._dockerurl}", "meshid": self.meshid}) - self.nodeid = r.json()["id"] + agent_json = r.json() + self.nodeid = agent_json["id"] + self.nodehex = agent_json["hex"] def __enter__(self): return self diff --git a/tests/environment/scripts/client/agent_server.py b/tests/environment/scripts/client/agent_server.py index 1ee8639..52e42e8 100644 --- a/tests/environment/scripts/client/agent_server.py +++ b/tests/environment/scripts/client/agent_server.py @@ -53,7 +53,7 @@ def add_agent(): time.sleep(.1) else: raise Exception(f"Failed to start agent: {text}") - return {"id": agent_id} + return {"id": agent_id, "hex": agent_hex} @api.route('/remove-agent/', methods=['POST']) def remove_agent(agentid): From 6daaa91758a16d725721d120afa29c40036323a8 Mon Sep 17 00:00:00 2001 From: Josiah Baldwin Date: Fri, 26 Sep 2025 14:12:56 -0700 Subject: [PATCH 3/3] Added test for run_console_command --- tests/test_session.py | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/tests/test_session.py b/tests/test_session.py index 79fa2fd..dd1c973 100644 --- a/tests/test_session.py +++ b/tests/test_session.py @@ -259,6 +259,19 @@ async def test_mesh_device(env): else: raise Exception("Run command on a device that doesn't exist did not raise an exception") + r = await admin_session.run_console_command([agent.nodeid, agent2.nodeid], "info", timeout=10) + print("\ninfo run_console_command: {}\n".format(r)) + assert agent.nodeid in r[agent.nodeid]["result"], "Run console command gave bad response" + assert agent2.nodeid in r[agent2.nodeid]["result"], "Run console command gave bad response" + + # Test run_commands missing device + try: + await admin_session.run_console_command([agent.nodeid, "notanid"], "info", timeout=10) + except* (meshctrl.exceptions.ServerError, ValueError): + pass + else: + raise Exception("Run console command on a device that doesn't exist did not raise an exception") + # Test run commands with individual device permissions try: await unprivileged_session.run_command(agent.nodeid, "ls", timeout=10)